diff --git a/Cargo.lock b/Cargo.lock index b54afc6..e10dd6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3046,6 +3046,15 @@ dependencies = [ "libc", ] +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "unicode-id", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -3950,6 +3959,7 @@ dependencies = [ "dirs 5.0.1", "flume", "gpui", + "markdown", "notify-rust", "serde", "serde_json", @@ -5725,6 +5735,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-id" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 63b3f57..c64566d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,3 +13,4 @@ notify-rust = "4" serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "5" +markdown = "1.0.0-alpha.21" diff --git a/src/app/app_view.rs b/src/app/app_view.rs index e6305df..2c058e3 100644 --- a/src/app/app_view.rs +++ b/src/app/app_view.rs @@ -1,9 +1,25 @@ -use std::{borrow::Cow, collections::HashSet, fs, path::PathBuf}; +use std::{ + borrow::Cow, + collections::HashSet, + fs, + path::{Path, PathBuf}, + process::Command as ProcessCommand, + time::{SystemTime, UNIX_EPOCH}, +}; use gpui::prelude::FluentBuilder; use gpui::*; +use serde_json::Value; use crate::app::{ + codex::{ + apply_notification, derive_title_from_user_message, notification_thread_id, + parse_rate_limit_payload, save_codex_threads, ApprovalDecision, CodexAccountState, + CodexApproval, CodexCommand, CodexEvent, CodexRuntimeHandle, CodexRuntimeStatus, + CodexThread, CodexThreadLocalId, CodexThreadSummary, LocalThreadStatus, LoginStartResponse, + LoginType, SandboxMode, DEFAULT_THREAD_TITLE, + }, + codex_view::build_codex_pane, config::{ ProjectId, SessionId, APP_NAME, FALLBACK_CELL_W_PX, FONT_FAMILY, PADDING_PX, SIDEBAR_W_PX, TOP_BAR_H_PX, @@ -13,14 +29,15 @@ use crate::app::{ project::Project, session::{ clear_selection, drag_scroll, finish_selection, point_for_view_cell, resize_session, - selected_text, send_to_session, spawn_pty_session, start_selection, update_metrics, - update_selection, TerminalMetrics, TerminalSession, + selected_text, send_to_session, spawn_pty_session_with_command, start_selection, + update_metrics, update_selection, TerminalMetrics, TerminalSession, }, settings::{save_terminal_settings, TerminalSettings, UiSettings}, settings_window::SettingsView, sidebar::build_sidebar, theme::{Theme, ThemeRegistry}, top_bar::build_top_bar, + workspace_state::save_workspaces, }; pub(crate) struct AppView { @@ -44,6 +61,899 @@ pub(crate) struct AppView { pub(crate) sidebar_visible: bool, pub(crate) collapsed_projects: HashSet, pub(crate) terminal_context_menu: Option, + /// Popup shown when the user clicks the "+" button next to a workspace + /// in the sidebar. Lets them pick between starting a new terminal or a + /// new Codex thread. + pub(crate) new_item_menu: Option, + /// Lazy-started when the user first opens a Codex pane. The handle is + /// wired in `app::run` but the worker only spawns its child on the first + /// `EnsureStarted` command, preserving cold-start. + pub(crate) codex_runtime: Option, + pub(crate) codex_status: CodexRuntimeStatus, + pub(crate) codex_account: CodexAccountState, + pub(crate) codex_threads: Vec, + pub(crate) active_codex_thread: Option, + pub(crate) next_codex_thread_id: CodexThreadLocalId, + pub(crate) codex_composer: String, + pub(crate) codex_composer_cursor: usize, + pub(crate) codex_composer_focused: bool, + pub(crate) codex_image_attachments: Vec, + pub(crate) content_mode: ContentMode, + /// Phase 4: in-flight login state surfaced to the sign-in card. `None` + /// when no login is pending. + pub(crate) codex_login: Option, + /// Phase 4: result of the most recent `thread/list` query. The history + /// drawer renders from this; `None` means "not requested yet". + pub(crate) codex_history: Option, + /// Phase 5: `serverInfo.version` from the most recent `initialize` + /// response. Surfaced in the sidebar status row for protocol-debugging. + pub(crate) codex_server_version: Option, + /// Tool rows (commandExecution / fileChange) are collapsed by default; + /// the user clicks to expand. This set holds the `server_item_id`s of + /// items currently in the expanded state. + pub(crate) codex_expanded_items: HashSet, + /// Scroll handle for the Codex transcript. We flip its `scroll_to_bottom` + /// flag whenever new transcript content arrives so the next paint lands + /// the viewport at the latest message — matches chat-app expectations. + pub(crate) codex_transcript_scroll: ScrollHandle, +} + +/// Snapshot of an in-flight Codex login surfaced to the sign-in card. +#[derive(Debug, Clone)] +pub(crate) struct CodexLoginState { + pub(crate) flow: LoginType, + pub(crate) response: LoginStartResponse, + /// True when the runtime tried `xdg-open` on the browser URL and it + /// failed — the UI offers the device-code fallback automatically. + pub(crate) browser_open_failed: bool, +} + +/// Snapshot of the history drawer's state. +#[derive(Debug, Clone)] +pub(crate) struct CodexHistoryState { + pub(crate) project_id: ProjectId, + pub(crate) loading: bool, + pub(crate) error: Option, + pub(crate) entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CodexImageAttachment { + pub(crate) path: PathBuf, + pub(crate) label: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ContentMode { + Terminal, + Codex, +} + +impl AppView { + /// Lazy-start the Codex runtime on first use. Idempotent. + pub(crate) fn ensure_codex_started(&mut self) { + let handle = self + .codex_runtime + .as_ref() + .expect("codex_runtime should be wired in app::run before AppView is constructed"); + handle.send(CodexCommand::EnsureStarted); + } + + /// Switch the content area to the Codex pane and kick off the runtime if + /// it isn't already running. If the user has no Codex thread yet for the + /// active project, create one. + pub(crate) fn open_codex_pane(&mut self, cx: &mut Context) { + self.ensure_codex_started(); + self.content_mode = ContentMode::Codex; + self.codex_composer_focused = true; + if self.active_codex_thread.is_none() { + self.new_codex_thread(cx); + } + cx.notify(); + } + + pub(crate) fn close_codex_pane(&mut self, cx: &mut Context) { + self.content_mode = ContentMode::Terminal; + self.codex_composer_focused = false; + cx.notify(); + } + + /// Creates a new local Draft thread for the active project and issues + /// `thread/start`. The server thread id is filled in when the runtime + /// echoes back `ThreadStarted`. + pub(crate) fn new_codex_thread(&mut self, cx: &mut Context) { + let Some(project_id) = self.active_project else { + return; + }; + let cwd = self + .projects + .iter() + .find(|p| p.id == project_id) + .map(|p| p.root_path.clone()) + .unwrap_or_else(|| PathBuf::from(".")); + let local_id = self.next_codex_thread_id; + self.next_codex_thread_id += 1; + let thread = CodexThread::draft(local_id, project_id, cwd.clone()); + self.codex_threads.push(thread); + self.active_codex_thread = Some(local_id); + if let Some(handle) = self.codex_runtime.as_ref() { + handle.send(CodexCommand::StartThread { + local_id, + cwd, + sandbox: SandboxMode::WorkspaceWrite, + }); + } + self.persist_codex_threads(); + cx.notify(); + } + + pub(crate) fn switch_to_codex_thread( + &mut self, + local_id: CodexThreadLocalId, + cx: &mut Context, + ) { + if !self.codex_threads.iter().any(|t| t.local_id == local_id) { + return; + } + self.active_codex_thread = Some(local_id); + if let Some(t) = self + .codex_threads + .iter_mut() + .find(|t| t.local_id == local_id) + { + t.unread = false; + } + self.content_mode = ContentMode::Codex; + self.codex_transcript_scroll.scroll_to_bottom(); + cx.notify(); + } + + /// Sends the contents of the composer as a `turn/start`. No-op if the + /// active thread hasn't been linked to a server id yet (still `Draft`). + pub(crate) fn submit_codex_prompt(&mut self, cx: &mut Context) { + let text = self.codex_composer.trim().to_string(); + if text.is_empty() && self.codex_image_attachments.is_empty() { + return; + } + let Some(local_id) = self.active_codex_thread else { + return; + }; + let Some(thread) = self + .codex_threads + .iter_mut() + .find(|t| t.local_id == local_id) + else { + return; + }; + let Some(server_thread_id) = thread.server_thread_id.clone() else { + return; + }; + if thread.title == DEFAULT_THREAD_TITLE { + if let Some(title) = derive_title_from_user_message(&text) { + thread.title = title; + } + } + if let Some(handle) = self.codex_runtime.as_ref() { + handle.send(CodexCommand::SubmitPrompt { + server_thread_id, + text, + images: self + .codex_image_attachments + .iter() + .map(|attachment| attachment.path.clone()) + .collect(), + }); + } + self.codex_composer.clear(); + self.codex_composer_cursor = 0; + self.codex_image_attachments.clear(); + self.codex_transcript_scroll.scroll_to_bottom(); + self.persist_codex_threads(); + cx.notify(); + } + + #[allow(dead_code)] + pub(crate) fn interrupt_active_codex_turn(&mut self, cx: &mut Context) { + let Some(local_id) = self.active_codex_thread else { + return; + }; + let Some(thread) = self.codex_threads.iter().find(|t| t.local_id == local_id) else { + return; + }; + let Some(server_thread_id) = thread.server_thread_id.clone() else { + return; + }; + let Some(turn) = thread.turns.last() else { + return; + }; + let Some(server_turn_id) = turn.server_turn_id.clone() else { + return; + }; + if let Some(handle) = self.codex_runtime.as_ref() { + handle.send(CodexCommand::InterruptTurn { + server_thread_id, + server_turn_id, + }); + } + cx.notify(); + } + + pub(crate) fn active_codex_thread_ref(&self) -> Option<&CodexThread> { + let id = self.active_codex_thread?; + self.codex_threads.iter().find(|t| t.local_id == id) + } + + /// Appends a character to the Codex composer buffer. + pub(crate) fn codex_composer_insert(&mut self, ch: char, cx: &mut Context) { + self.codex_composer.insert(self.codex_composer_cursor, ch); + self.codex_composer_cursor += ch.len_utf8(); + cx.notify(); + } + + pub(crate) fn codex_composer_insert_str(&mut self, text: &str, cx: &mut Context) { + if text.is_empty() { + return; + } + self.codex_composer + .insert_str(self.codex_composer_cursor, text); + self.codex_composer_cursor += text.len(); + cx.notify(); + } + + /// Deletes the last character of the composer buffer. + pub(crate) fn codex_composer_backspace(&mut self, cx: &mut Context) { + let Some(prev) = self.previous_codex_cursor_boundary() else { + return; + }; + self.codex_composer.drain(prev..self.codex_composer_cursor); + self.codex_composer_cursor = prev; + cx.notify(); + } + + pub(crate) fn codex_composer_delete(&mut self, cx: &mut Context) { + let Some(next) = self.next_codex_cursor_boundary() else { + return; + }; + self.codex_composer.drain(self.codex_composer_cursor..next); + cx.notify(); + } + + pub(crate) fn codex_composer_move_left(&mut self, cx: &mut Context) { + if let Some(prev) = self.previous_codex_cursor_boundary() { + self.codex_composer_cursor = prev; + cx.notify(); + } + } + + pub(crate) fn codex_composer_move_right(&mut self, cx: &mut Context) { + if let Some(next) = self.next_codex_cursor_boundary() { + self.codex_composer_cursor = next; + cx.notify(); + } + } + + pub(crate) fn codex_composer_move_home(&mut self, cx: &mut Context) { + if self.codex_composer_cursor != 0 { + self.codex_composer_cursor = 0; + cx.notify(); + } + } + + pub(crate) fn codex_composer_move_end(&mut self, cx: &mut Context) { + if self.codex_composer_cursor != self.codex_composer.len() { + self.codex_composer_cursor = self.codex_composer.len(); + cx.notify(); + } + } + + pub(crate) fn focus_codex_composer(&mut self, cx: &mut Context) { + self.codex_composer_focused = true; + self.codex_composer_cursor = self.codex_composer.len(); + cx.notify(); + } + + pub(crate) fn attach_codex_images(&mut self, cx: &mut Context) { + let receiver = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: true, + prompt: Some("Attach Image".into()), + }); + cx.spawn(async move |this, cx| { + let Ok(Ok(Some(paths))) = receiver.await else { + return; + }; + let _ = this.update(cx, |this, cx| { + for path in paths + .into_iter() + .filter(|path| is_supported_image_path(path)) + { + this.add_codex_image_attachment(path); + } + cx.notify(); + }); + }) + .detach(); + } + + pub(crate) fn remove_codex_image_attachment(&mut self, index: usize, cx: &mut Context) { + if index < self.codex_image_attachments.len() { + self.codex_image_attachments.remove(index); + cx.notify(); + } + } + + pub(crate) fn paste_into_codex_composer(&mut self, cx: &mut Context) { + let Some(clip) = cx.read_from_clipboard() else { + return; + }; + let mut attached_image = false; + for entry in clip.entries() { + if let ClipboardEntry::Image(image) = entry { + if let Some(path) = write_clipboard_image_for_codex(image) { + self.add_codex_image_attachment(path); + attached_image = true; + } + } + } + if !attached_image { + if let Some(text) = clip.text() { + self.codex_composer_insert_str(&text, cx); + return; + } + } + cx.notify(); + } + + pub(crate) fn open_codex_link(&self, target: String) { + let target = strip_inline_code_marker(target.trim()); + if target.is_empty() { + return; + } + if is_url(target) { + let _ = ProcessCommand::new("xdg-open").arg(target).spawn(); + return; + } + let resolved = self + .resolve_codex_path(target) + .unwrap_or_else(|| PathBuf::from(target)); + let _ = ProcessCommand::new("zed").arg(resolved).spawn(); + } + + fn previous_codex_cursor_boundary(&self) -> Option { + self.codex_composer[..self.codex_composer_cursor] + .char_indices() + .last() + .map(|(idx, _)| idx) + } + + fn next_codex_cursor_boundary(&self) -> Option { + self.codex_composer[self.codex_composer_cursor..] + .chars() + .next() + .map(|ch| self.codex_composer_cursor + ch.len_utf8()) + } + + fn add_codex_image_attachment(&mut self, path: PathBuf) { + let label = path + .file_name() + .and_then(|name| name.to_str()) + .map(String::from) + .unwrap_or_else(|| path.display().to_string()); + self.codex_image_attachments + .push(CodexImageAttachment { path, label }); + } + + fn resolve_codex_path(&self, target: &str) -> Option { + let target = strip_line_suffix(target); + let path = PathBuf::from(target); + if path.is_absolute() { + return Some(path); + } + self.active_codex_thread_ref() + .map(|thread| thread.cwd.join(path)) + .or_else(|| { + self.active_project.and_then(|project_id| { + self.projects + .iter() + .find(|project| project.id == project_id) + .map(|project| project.root_path.join(target)) + }) + }) + } + + /// Applies a single [`CodexEvent`] to the view's Codex state. + pub(crate) fn handle_codex_event(&mut self, event: CodexEvent, cx: &mut Context) { + if self.handle_codex_event_inner(event, cx) { + self.persist_codex_threads(); + } + } + + fn handle_codex_event_inner(&mut self, event: CodexEvent, cx: &mut Context) -> bool { + match event { + CodexEvent::StatusChanged(status) => { + self.codex_status = status; + cx.notify(); + false + } + CodexEvent::AccountChanged(account) => { + self.codex_account = account; + cx.notify(); + false + } + CodexEvent::ThreadStarted { + local_id, + server_thread_id, + server_session_id, + } => { + let mut persist = false; + if let Some(t) = self + .codex_threads + .iter_mut() + .find(|t| t.local_id == local_id) + { + t.server_thread_id = Some(server_thread_id); + if server_session_id.is_some() { + t.server_session_id = server_session_id; + } + if matches!(t.status, LocalThreadStatus::Draft) { + t.status = LocalThreadStatus::Loaded; + } + cx.notify(); + persist = true; + } + persist + } + CodexEvent::ThreadStartFailed { local_id, message } => { + let mut persist = false; + if let Some(t) = self + .codex_threads + .iter_mut() + .find(|t| t.local_id == local_id) + { + t.status = LocalThreadStatus::Failed; + t.title = format!("Failed: {message}"); + cx.notify(); + persist = true; + } + persist + } + CodexEvent::ServerNotification { method, params } => { + self.dispatch_codex_notification(&method, ¶ms, cx) + } + CodexEvent::ServerRequest { + request_id, + method, + params, + } => { + self.dispatch_codex_server_request(request_id, &method, params, cx); + false + } + CodexEvent::LoginStarted { + response, + flow, + browser_open_failed, + } => { + self.codex_account = CodexAccountState::LoginPending { + login_id: response.login_id.clone(), + }; + self.codex_login = Some(CodexLoginState { + flow, + response, + browser_open_failed, + }); + cx.notify(); + false + } + CodexEvent::LoginFailed { message } => { + self.codex_login = None; + self.codex_account = CodexAccountState::SignedOutRequiresAuth; + eprintln!("codex login/start failed: {message}"); + cx.notify(); + false + } + CodexEvent::LoginCompleted { .. } => { + self.codex_login = None; + if let Some(handle) = self.codex_runtime.as_ref() { + handle.send(CodexCommand::ReadAccount); + } + cx.notify(); + false + } + CodexEvent::RateLimitChanged(info) => { + let next = self.codex_account.clone().with_rate_limit(info); + self.codex_account = next; + cx.notify(); + false + } + CodexEvent::ThreadList(entries) => { + if let Some(state) = self.codex_history.as_mut() { + state.loading = false; + state.error = None; + state.entries = entries; + cx.notify(); + } + false + } + CodexEvent::ThreadListFailed(message) => { + if let Some(state) = self.codex_history.as_mut() { + state.loading = false; + state.error = Some(message); + cx.notify(); + } + false + } + CodexEvent::ServerInfo { version } => { + self.codex_server_version = version; + cx.notify(); + false + } + CodexEvent::Failed(_) => { + // Already surfaced through CodexRuntimeStatus::Failed. + false + } + } + } + + fn dispatch_codex_notification( + &mut self, + method: &str, + params: &Value, + cx: &mut Context, + ) -> bool { + // Account / login / rate-limit notifications are global — they don't + // target a specific thread. + if method == "account/updated" { + let new_state = crate::app::codex::parse_account_update(params); + // Preserve any in-flight rate-limit overlay across the update. + let next = match self.codex_account.clone() { + CodexAccountState::RateLimited { + resets_at_secs, + primary_used_percent, + .. + } => CodexAccountState::RateLimited { + underlying: Box::new(new_state), + resets_at_secs, + primary_used_percent, + }, + _ => new_state, + }; + self.codex_account = next; + self.codex_login = None; + cx.notify(); + return false; + } + if method == "account/login/completed" { + let login_id = params + .get("loginId") + .and_then(|v| v.as_str()) + .map(String::from); + self.handle_codex_event(CodexEvent::LoginCompleted { login_id }, cx); + return false; + } + if method == "account/rateLimits/updated" { + let info = parse_rate_limit_payload(params); + self.handle_codex_event(CodexEvent::RateLimitChanged(info), cx); + return false; + } + // `serverRequest/resolved` clears pending approvals across threads. + if method == "serverRequest/resolved" { + if let Some(req_id) = params.get("requestId").and_then(|v| v.as_u64()) { + let mut changed = false; + for t in &mut self.codex_threads { + if let Some(idx) = t + .pending_approvals + .iter() + .position(|a| a.request_id == req_id) + { + t.pending_approvals.remove(idx); + changed = true; + } + } + if changed { + cx.notify(); + } + } + return false; + } + let Some(thread_id) = notification_thread_id(params) else { + return false; + }; + let Some(thread) = self + .codex_threads + .iter_mut() + .find(|t| t.server_thread_id.as_deref() == Some(thread_id)) + else { + return false; + }; + let active = self.active_codex_thread == Some(thread.local_id) + && self.content_mode == ContentMode::Codex; + let changed = apply_notification(thread, method, params); + if changed { + // Background activity marks the thread unread (badge in sidebar) + // and fires a desktop notification on turn completion so the user + // notices long-running tasks they backgrounded. + if !active { + thread.unread = true; + if method == "turn/completed" { + notify_codex_event(&format!("Codex: {}", thread.title), "Turn completed"); + } + } else if matches!( + method, + "turn/started" | "turn/completed" | "item/started" | "item/completed" + ) { + // Only follow the tail on item / turn boundaries — not on every + // streaming delta. Firing scroll_to_bottom per token thrashes + // GPUI's scroll handle and tears the layout mid-render. + self.codex_transcript_scroll.scroll_to_bottom(); + } + cx.notify(); + } + // Persist only at durable checkpoints — never on per-token delta + // notifications, which would thrash disk I/O. item/completed is the + // spec's authoritative replace point, so the disk image is correct + // even if intermediate deltas aren't flushed. + changed + && matches!( + method, + "thread/started" + | "thread/name/updated" + | "thread/archived" + | "thread/unarchived" + | "thread/closed" + | "turn/started" + | "turn/completed" + | "item/started" + | "item/completed" + ) + } + + fn dispatch_codex_server_request( + &mut self, + request_id: u64, + method: &str, + params: Value, + cx: &mut Context, + ) { + let is_approval = matches!( + method, + "item/commandExecution/requestApproval" + | "item/fileChange/requestApproval" + | "item/permissions/requestApproval" + ); + if !is_approval { + return; + } + let thread_id = match notification_thread_id(¶ms) { + Some(id) => id.to_string(), + None => return, + }; + let Some(thread) = self + .codex_threads + .iter_mut() + .find(|t| t.server_thread_id.as_deref() == Some(thread_id.as_str())) + else { + return; + }; + thread + .pending_approvals + .push(CodexApproval::new(request_id, method.into(), params)); + thread.recompute_status(crate::app::codex::ServerThreadStatus::Active); + let in_background = self.active_codex_thread != Some(thread.local_id) + || self.content_mode != ContentMode::Codex; + let title = thread.title.clone(); + if in_background { + thread.unread = true; + notify_codex_event(&format!("Codex: {title}"), "Approval requested"); + } + cx.notify(); + } + + /// Phase 4: kick off `account/login/start`. UI buttons in the sign-in + /// card call this with either `ChatGpt` (browser) or `ChatGptDeviceCode`. + pub(crate) fn start_codex_login(&mut self, flow: LoginType, cx: &mut Context) { + self.ensure_codex_started(); + if let Some(handle) = self.codex_runtime.as_ref() { + handle.send(CodexCommand::StartLogin(flow)); + } + cx.notify(); + } + + /// Phase 4: cancel an in-flight login the user no longer wants. + pub(crate) fn cancel_codex_login(&mut self, cx: &mut Context) { + let Some(login) = self.codex_login.take() else { + return; + }; + if let Some(handle) = self.codex_runtime.as_ref() { + handle.send(CodexCommand::CancelLogin { + login_id: login.response.login_id, + }); + } + self.codex_account = CodexAccountState::SignedOutRequiresAuth; + cx.notify(); + } + + /// Phase 4: open the history drawer for the active project and issue + /// `thread/list`. Closes the drawer if already open for the same project. + pub(crate) fn toggle_codex_history(&mut self, cx: &mut Context) { + let Some(project_id) = self.active_project else { + return; + }; + if matches!(&self.codex_history, Some(s) if s.project_id == project_id) { + self.codex_history = None; + cx.notify(); + return; + } + let cwd = self + .projects + .iter() + .find(|p| p.id == project_id) + .map(|p| p.root_path.clone()); + self.codex_history = Some(CodexHistoryState { + project_id, + loading: true, + error: None, + entries: Vec::new(), + }); + if let Some(handle) = self.codex_runtime.as_ref() { + handle.send(CodexCommand::ListThreads { cwd }); + } + cx.notify(); + } + + /// Phase 4: resume a server-known thread into a fresh local record. + /// Reuses the same draft-thread reconciliation path as `new_codex_thread`. + pub(crate) fn resume_codex_thread( + &mut self, + summary: CodexThreadSummary, + cx: &mut Context, + ) { + let Some(project_id) = self.active_project else { + return; + }; + let cwd = summary + .cwd + .as_ref() + .map(PathBuf::from) + .or_else(|| { + self.projects + .iter() + .find(|p| p.id == project_id) + .map(|p| p.root_path.clone()) + }) + .unwrap_or_else(|| PathBuf::from(".")); + // If we already have a local record for this server id, just switch. + if let Some(existing) = self + .codex_threads + .iter() + .find(|t| t.server_thread_id.as_deref() == Some(summary.thread_id.as_str())) + .map(|t| t.local_id) + { + self.switch_to_codex_thread(existing, cx); + return; + } + let local_id = self.next_codex_thread_id; + self.next_codex_thread_id += 1; + let mut thread = CodexThread::draft(local_id, project_id, cwd.clone()); + thread.title = summary.title.clone(); + self.codex_threads.push(thread); + self.active_codex_thread = Some(local_id); + self.content_mode = ContentMode::Codex; + if let Some(handle) = self.codex_runtime.as_ref() { + handle.send(CodexCommand::ResumeThread { + local_id, + server_thread_id: summary.thread_id, + }); + } + self.persist_codex_threads(); + cx.notify(); + } + + /// Phase 5: archive (or unarchive) the active Codex thread. The reducer + /// reacts to the resulting `thread/archived` notification — we don't + /// have to mutate `archived` locally. + pub(crate) fn archive_active_codex_thread(&mut self, archive: bool, cx: &mut Context) { + let Some(local_id) = self.active_codex_thread else { + return; + }; + let Some(thread) = self.codex_threads.iter().find(|t| t.local_id == local_id) else { + return; + }; + let Some(server_thread_id) = thread.server_thread_id.clone() else { + return; + }; + if let Some(handle) = self.codex_runtime.as_ref() { + handle.send(CodexCommand::ArchiveThread { + server_thread_id, + archive, + }); + } + if archive { + // Return to terminal — the thread will disappear from the + // sidebar (Archived is filtered out there). + self.content_mode = ContentMode::Terminal; + self.active_codex_thread = None; + } + self.persist_codex_threads(); + cx.notify(); + } + + /// Phase 4: fork the active thread at its tail turn (or unconditionally + /// at the tail when no turn id is known yet). + pub(crate) fn fork_active_codex_thread(&mut self, cx: &mut Context) { + let Some(local_id) = self.active_codex_thread else { + return; + }; + let Some(thread) = self.codex_threads.iter().find(|t| t.local_id == local_id) else { + return; + }; + let Some(server_thread_id) = thread.server_thread_id.clone() else { + return; + }; + let turn_id = thread.turns.last().and_then(|t| t.server_turn_id.clone()); + let project_id = thread.project_id; + let cwd = thread.cwd.clone(); + let title = format!("{} (fork)", thread.title); + let new_local_id = self.next_codex_thread_id; + self.next_codex_thread_id += 1; + let mut new_thread = CodexThread::draft(new_local_id, project_id, cwd); + new_thread.title = title; + self.codex_threads.push(new_thread); + self.active_codex_thread = Some(new_local_id); + if let Some(handle) = self.codex_runtime.as_ref() { + handle.send(CodexCommand::ForkThread { + local_id: new_local_id, + server_thread_id, + turn_id, + }); + } + self.persist_codex_threads(); + cx.notify(); + } + + /// Toggle the expanded state of a tool-row item by its + /// `server_item_id`. Used by `commandExecution` / `fileChange` rows in + /// the transcript. + pub(crate) fn toggle_codex_item_expanded(&mut self, item_id: String, cx: &mut Context) { + if !self.codex_expanded_items.remove(&item_id) { + self.codex_expanded_items.insert(item_id); + } + cx.notify(); + } + + /// Resolves a pending approval by sending the user's decision back to + /// the server. Removes the approval card optimistically; `serverRequest/ + /// resolved` is authoritative and arrives shortly after. + pub(crate) fn resolve_codex_approval( + &mut self, + thread_local_id: CodexThreadLocalId, + request_id: u64, + decision: ApprovalDecision, + cx: &mut Context, + ) { + let Some(thread) = self + .codex_threads + .iter_mut() + .find(|t| t.local_id == thread_local_id) + else { + return; + }; + let Some(idx) = thread + .pending_approvals + .iter() + .position(|a| a.request_id == request_id) + else { + return; + }; + let approval = thread.pending_approvals.remove(idx); + if let Some(handle) = self.codex_runtime.as_ref() { + handle.send(CodexCommand::ResolveServerRequest { + request_id, + result: crate::app::codex::decision_payload(&approval, decision), + }); + } + cx.notify(); + } } #[derive(Clone, Copy)] @@ -58,6 +968,81 @@ enum TerminalContextAction { Paste, } +#[derive(Clone, Copy)] +pub(crate) struct NewItemMenu { + pub(crate) project_id: ProjectId, + pub(crate) position: Point, +} + +/// Fire a transient desktop notification for background Codex activity. Best +/// effort — failures (no notify daemon, sandbox) are silently ignored, the +/// sidebar unread/approval badges remain the source of truth. +fn notify_codex_event(summary: &str, body: &str) { + let _ = notify_rust::Notification::new() + .summary(summary) + .body(body) + .icon("dialog-information") + .timeout(notify_rust::Timeout::Milliseconds(4000)) + .appname(APP_NAME) + .show(); +} + +pub(crate) fn is_url(target: &str) -> bool { + target.starts_with("https://") || target.starts_with("http://") +} + +fn is_supported_image_path(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| { + matches!( + ext.to_ascii_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tif" | "tiff" + ) + }) + .unwrap_or(false) +} + +fn write_clipboard_image_for_codex(image: &Image) -> Option { + if image.bytes.is_empty() { + return None; + } + let ext = match image.format { + ImageFormat::Png => "png", + ImageFormat::Jpeg => "jpg", + ImageFormat::Webp => "webp", + ImageFormat::Gif => "gif", + ImageFormat::Svg => "svg", + ImageFormat::Bmp => "bmp", + ImageFormat::Tiff => "tiff", + }; + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok()? + .as_millis(); + let path = std::env::temp_dir().join(format!("quackcode-codex-image-{stamp}.{ext}")); + fs::write(&path, &image.bytes).ok()?; + Some(path) +} + +fn strip_line_suffix(target: &str) -> &str { + let Some((path, suffix)) = target.rsplit_once(':') else { + return target; + }; + if !path.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) { + path + } else { + target + } +} + +fn strip_inline_code_marker(target: &str) -> &str { + target + .strip_prefix('`') + .and_then(|inner| inner.strip_suffix('`')) + .unwrap_or(target) +} + pub(crate) fn measure_cell_w(cx: &mut App, font_size: f32) -> f32 { let font_obj = font(FONT_FAMILY); let font_id = cx.text_system().resolve_font(&font_obj); @@ -122,10 +1107,30 @@ impl AppView { root_path: path, }); self.active_project = Some(id); + self.persist_workspaces(); self.new_session_in_active_project(cx); cx.notify(); } + fn persist_workspaces(&self) { + let paths: Vec = self.projects.iter().map(|p| p.root_path.clone()).collect(); + save_workspaces(&paths); + } + + pub(crate) fn persist_codex_threads(&self) { + let projects: Vec<(ProjectId, PathBuf)> = self + .projects + .iter() + .map(|p| (p.id, p.root_path.clone())) + .collect(); + save_codex_threads(&self.codex_threads, move |pid| { + projects + .iter() + .find(|(id, _)| *id == pid) + .map(|(_, path)| path.clone()) + }); + } + pub(crate) fn new_session_in_project(&mut self, pid: ProjectId, cx: &mut Context) { if !self.projects.iter().any(|p| p.id == pid) { return; @@ -134,7 +1139,30 @@ impl AppView { self.new_session_in_active_project(cx); } + pub(crate) fn new_claude_session_in_project(&mut self, pid: ProjectId, cx: &mut Context) { + if !self.projects.iter().any(|p| p.id == pid) { + return; + } + self.active_project = Some(pid); + self.new_claude_session_in_active_project(cx); + } + pub(crate) fn new_session_in_active_project(&mut self, cx: &mut Context) { + self.spawn_session_in_active_project(None, None, cx); + } + + /// Opens a regular PTY in the active project that auto-runs the `claude` + /// CLI as the interactive process. The PTY exits when `claude` exits. + pub(crate) fn new_claude_session_in_active_project(&mut self, cx: &mut Context) { + self.spawn_session_in_active_project(Some("claude".into()), Some("Claude".into()), cx); + } + + fn spawn_session_in_active_project( + &mut self, + initial_command: Option, + title_override: Option, + cx: &mut Context, + ) { let Some(project_id) = self.active_project else { return; }; @@ -150,7 +1178,7 @@ impl AppView { cell_w: self.cell_w, cell_h: self.terminal_settings.cell_height_px(), }; - if let Some(session) = spawn_pty_session( + if let Some(mut session) = spawn_pty_session_with_command( id, project_id, cwd, @@ -158,9 +1186,15 @@ impl AppView { self.bell_tx.clone(), self.exited_tx.clone(), metrics, + initial_command, ) { + if let Some(title) = title_override { + session.title = title; + } self.sessions.push(session); self.active_session = Some(id); + self.content_mode = ContentMode::Terminal; + self.codex_composer_focused = false; cx.notify(); } } @@ -216,6 +1250,8 @@ impl AppView { s.unread = false; self.active_session = Some(id); self.active_project = Some(s.project_id); + self.content_mode = ContentMode::Terminal; + self.codex_composer_focused = false; cx.notify(); } } @@ -397,6 +1433,40 @@ impl AppView { } } + pub(crate) fn open_new_item_menu( + &mut self, + project_id: ProjectId, + position: Point, + cx: &mut Context, + ) { + self.new_item_menu = Some(NewItemMenu { + project_id, + position, + }); + cx.notify(); + } + + pub(crate) fn close_new_item_menu(&mut self, cx: &mut Context) { + if self.new_item_menu.take().is_some() { + cx.notify(); + } + } + + pub(crate) fn new_codex_thread_in_project( + &mut self, + project_id: ProjectId, + cx: &mut Context, + ) { + if !self.projects.iter().any(|p| p.id == project_id) { + return; + } + self.active_project = Some(project_id); + self.ensure_codex_started(); + self.new_codex_thread(cx); + self.content_mode = ContentMode::Codex; + cx.notify(); + } + pub(crate) fn clear_all_selections(&mut self) { for session in &mut self.sessions { clear_selection(session); @@ -617,6 +1687,49 @@ impl Render for AppView { this.handle_terminal_mouse_up(ev, window, cx); }); + let content = match self.content_mode { + ContentMode::Terminal => div() + .relative() + .flex_1() + .h_full() + .p(px(PADDING_PX)) + .bg(theme.background) + .on_mouse_down(MouseButton::Left, mouse_down_handler) + .on_mouse_down(MouseButton::Right, context_menu_handler) + .on_mouse_move(mouse_move_handler) + .on_mouse_up(MouseButton::Left, mouse_up_handler) + .child(painter) + .when_some(self.terminal_context_menu, |el, menu| { + el.child( + deferred(terminal_context_menu(menu, &theme, self.ui_settings, cx)) + .with_priority(1), + ) + }) + .when(!has_active_session, |el| { + el.child(status_overlay( + &theme, + self.ui_settings, + "Terminal exited", + "Ctrl+Shift+T starts a new terminal", + )) + }) + .when(show_starting, |el| { + el.child(status_overlay( + &theme, + self.ui_settings, + "Starting terminal", + "Waiting for the shell prompt", + )) + }) + .into_any_element(), + ContentMode::Codex => div() + .relative() + .flex_1() + .h_full() + .child(build_codex_pane(self, cx)) + .into_any_element(), + }; + let workspace_row = div() .flex() .flex_row() @@ -624,41 +1737,14 @@ impl Render for AppView { .min_h_0() .w_full() .when_some(sidebar, |el, sidebar| el.child(sidebar)) - .child( - div() - .relative() - .flex_1() - .h_full() - .p(px(PADDING_PX)) - .bg(theme.background) - .on_mouse_down(MouseButton::Left, mouse_down_handler) - .on_mouse_down(MouseButton::Right, context_menu_handler) - .on_mouse_move(mouse_move_handler) - .on_mouse_up(MouseButton::Left, mouse_up_handler) - .child(painter) - .when_some(self.terminal_context_menu, |el, menu| { - el.child( - deferred(terminal_context_menu(menu, &theme, self.ui_settings, cx)) - .with_priority(1), - ) - }) - .when(!has_active_session, |el| { - el.child(status_overlay( - &theme, - self.ui_settings, - "Terminal exited", - "Ctrl+Shift+T starts a new terminal", - )) - }) - .when(show_starting, |el| { - el.child(status_overlay( - &theme, - self.ui_settings, - "Starting terminal", - "Waiting for the shell prompt", - )) - }), - ); + .child(content); + + let new_item_menu_overlay = self.new_item_menu.map(|menu| { + deferred(new_item_menu_view(menu, &theme, self.ui_settings, cx)).with_priority(2) + }); + let new_item_menu_close = cx.listener(|this, _ev: &MouseDownEvent, _w, cx| { + this.close_new_item_menu(cx); + }); div() .id("app") @@ -669,8 +1755,10 @@ impl Render for AppView { .bg(theme.background) .track_focus(&self.focus_handle) .on_key_down(key_handler) + .on_mouse_down(MouseButton::Left, new_item_menu_close) .child(top_bar) .child(workspace_row) + .when_some(new_item_menu_overlay, |el, overlay| el.child(overlay)) } } @@ -741,6 +1829,106 @@ fn context_menu_item( ) } +fn new_item_menu_view( + menu: NewItemMenu, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> impl IntoElement { + let pid = menu.project_id; + div() + .absolute() + .left(menu.position.x) + .top(menu.position.y) + .w(px(180.0)) + .flex() + .flex_col() + .overflow_hidden() + .rounded_md() + .border_1() + .border_color(theme.border) + .bg(theme.surface_background) + .shadow_lg() + .on_mouse_down( + MouseButton::Left, + cx.listener(|_this, _ev: &MouseDownEvent, _w, cx| { + cx.stop_propagation(); + }), + ) + .child(new_item_menu_row( + "new-item-terminal", + "New Terminal", + crate::app::assets::ICON_TERMINAL, + theme, + ui, + cx.listener(move |this, _ev: &MouseDownEvent, _w, cx| { + cx.stop_propagation(); + this.close_new_item_menu(cx); + this.new_session_in_project(pid, cx); + }), + )) + .child(new_item_menu_row( + "new-item-codex", + "New Codex Thread", + crate::app::assets::ICON_PALETTE, + theme, + ui, + cx.listener(move |this, _ev: &MouseDownEvent, _w, cx| { + cx.stop_propagation(); + this.close_new_item_menu(cx); + this.new_codex_thread_in_project(pid, cx); + }), + )) + .child(new_item_menu_row( + "new-item-claude", + "New Claude Code CLI", + crate::app::assets::ICON_TERMINAL, + theme, + ui, + cx.listener(move |this, _ev: &MouseDownEvent, _w, cx| { + cx.stop_propagation(); + this.close_new_item_menu(cx); + this.new_claude_session_in_project(pid, cx); + }), + )) +} + +fn new_item_menu_row( + id: &'static str, + label: &'static str, + icon_path: &'static str, + theme: &Theme, + ui: UiSettings, + listener: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static, +) -> impl IntoElement { + div() + .id(id) + .h(px(30.0)) + .flex() + .flex_row() + .items_center() + .gap_2() + .px(px(10.0)) + .bg(theme.surface_background) + .text_size(px(ui.font_size_px())) + .line_height(px(ui.line_height_px())) + .text_color(theme.foreground) + .cursor_pointer() + .hover(|s| s.bg(theme.element_hover)) + .child(menu_icon(icon_path, theme.icon_muted, 12.0)) + .child(label) + .on_mouse_down(MouseButton::Left, listener) +} + +fn menu_icon(path: &'static str, color: Hsla, size: f32) -> impl IntoElement { + svg() + .path(path) + .w(px(size)) + .h(px(size)) + .flex_shrink_0() + .text_color(color) +} + fn status_overlay( theme: &Theme, ui: UiSettings, diff --git a/src/app/codex/approval.rs b/src/app/codex/approval.rs new file mode 100644 index 0000000..a227759 --- /dev/null +++ b/src/app/codex/approval.rs @@ -0,0 +1,144 @@ +//! Pending-approval state for a Codex thread. + +use serde_json::{json, Value}; + +use crate::app::codex::rpc::RequestId; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct CodexApproval { + /// JSON-RPC request id sent by the app-server. Responding to this id is + /// how the user's decision reaches Codex. + pub(crate) request_id: RequestId, + /// `item/commandExecution/requestApproval` etc. + pub(crate) method: String, + /// Raw params from the server. Approval card UI formats a summary from + /// these. + pub(crate) params: Value, + /// The permissions subset the user is being asked to grant, captured at + /// construction time from `params.permissions`. `Accept` responds with + /// exactly this value rather than re-reading `params`, so any fields the + /// approval UI doesn't render can't sneak through on the response. + /// `None` for non-permissions approvals (command / file-change). + pub(crate) granted_permissions: Option, +} + +impl CodexApproval { + pub(crate) fn new(request_id: RequestId, method: String, params: Value) -> Self { + let granted_permissions = if method == "item/permissions/requestApproval" { + Some( + params + .get("permissions") + .cloned() + .unwrap_or_else(|| json!({})), + ) + } else { + None + }; + Self { + request_id, + method, + params, + granted_permissions, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ApprovalDecision { + Accept, + Decline, + Cancel, +} + +/// Builds the JSON-RPC `result` for a user's decision on a pending approval. +/// +/// Command and file-change approvals carry `{ "decision": "accept" | +/// "decline" | "cancel" }`. Permissions approvals carry `{ "permissions": … +/// }` — v1 grants the subset captured in `granted_permissions` at approval +/// construction time, and nothing on Decline/Cancel (see spec §Approvals). +pub(crate) fn decision_payload(approval: &CodexApproval, decision: ApprovalDecision) -> Value { + let decision_str = match decision { + ApprovalDecision::Accept => "accept", + ApprovalDecision::Decline => "decline", + ApprovalDecision::Cancel => "cancel", + }; + if approval.method == "item/permissions/requestApproval" { + let permissions = match decision { + ApprovalDecision::Accept => approval + .granted_permissions + .clone() + .unwrap_or_else(|| json!({})), + ApprovalDecision::Decline | ApprovalDecision::Cancel => json!({}), + }; + json!({ "permissions": permissions }) + } else { + json!({ "decision": decision_str }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn approval(method: &str, params: Value) -> CodexApproval { + CodexApproval::new(42, method.into(), params) + } + + #[test] + fn command_accept_payload_uses_decision_field() { + let a = approval("item/commandExecution/requestApproval", json!({})); + let v = decision_payload(&a, ApprovalDecision::Accept); + assert_eq!(v["decision"], "accept"); + } + + #[test] + fn file_change_decline_uses_decision_field() { + let a = approval("item/fileChange/requestApproval", json!({})); + let v = decision_payload(&a, ApprovalDecision::Decline); + assert_eq!(v["decision"], "decline"); + } + + #[test] + fn command_cancel_uses_decision_field() { + let a = approval("item/commandExecution/requestApproval", json!({})); + let v = decision_payload(&a, ApprovalDecision::Cancel); + assert_eq!(v["decision"], "cancel"); + } + + #[test] + fn permissions_accept_echoes_requested_subset() { + let a = approval( + "item/permissions/requestApproval", + json!({ "permissions": { "read": true } }), + ); + let v = decision_payload(&a, ApprovalDecision::Accept); + assert_eq!(v["permissions"]["read"], true); + assert!(v.get("decision").is_none()); + } + + #[test] + fn permissions_accept_uses_stored_grant_not_raw_params() { + // Construct from the wire, then mutate params to simulate a server + // payload that contains fields the UI didn't render. The Accept + // response must reflect what was captured at construction, never + // whatever is in `params` at decision time. + let mut a = approval( + "item/permissions/requestApproval", + json!({ "permissions": { "read": true } }), + ); + a.params = json!({ "permissions": { "read": true, "shell_exec": true } }); + let v = decision_payload(&a, ApprovalDecision::Accept); + assert_eq!(v["permissions"]["read"], true); + assert!(v["permissions"].get("shell_exec").is_none()); + } + + #[test] + fn permissions_decline_grants_nothing() { + let a = approval( + "item/permissions/requestApproval", + json!({ "permissions": { "read": true } }), + ); + let v = decision_payload(&a, ApprovalDecision::Decline); + assert_eq!(v["permissions"], json!({})); + } +} diff --git a/src/app/codex/auth.rs b/src/app/codex/auth.rs new file mode 100644 index 0000000..b9494f4 --- /dev/null +++ b/src/app/codex/auth.rs @@ -0,0 +1,341 @@ +//! Codex account / auth state on the app side. +//! +//! Phase 1 modeled the state itself; Phase 4 adds the login-flow types and +//! pure helpers that build / parse `account/login/start`, the rate-limit +//! payload, and the browser-opener result. + +use serde_json::{json, Value}; + +/// Which auth flow the user chose. Maps onto the `type` field of +/// `account/login/start`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LoginType { + /// Default: opens a local-callback URL in the user's browser. + ChatGpt, + /// Fallback used when the browser open fails or the user is on a + /// different device. The server returns a `verificationUrl` + `userCode`. + ChatGptDeviceCode, +} + +impl LoginType { + fn wire_value(self) -> &'static str { + match self { + LoginType::ChatGpt => "chatgpt", + LoginType::ChatGptDeviceCode => "chatgptDeviceCode", + } + } +} + +/// Parsed response from `account/login/start`. The browser and device-code +/// flows fill in different subsets — `auth_url` for browser, `verification_url` +/// + `user_code` for device code. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LoginStartResponse { + pub(crate) login_id: String, + pub(crate) auth_url: Option, + pub(crate) verification_url: Option, + pub(crate) user_code: Option, +} + +/// Rate-limit overlay information parsed from `account/rateLimits/updated`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct RateLimitInfo { + pub(crate) reached_type: Option, + pub(crate) resets_at_secs: Option, + pub(crate) primary_used_percent: Option, +} + +/// Builds the params for `account/login/start`. +pub(crate) fn build_login_start_params(login_type: LoginType) -> Value { + json!({ "type": login_type.wire_value() }) +} + +/// Builds the params for `account/login/cancel`. +pub(crate) fn build_login_cancel_params(login_id: &str) -> Value { + json!({ "loginId": login_id }) +} + +/// Parses an `account/login/start` response into our subset. +pub(crate) fn parse_login_start_response(v: &Value) -> Option { + let login_id = v.get("loginId").and_then(|s| s.as_str())?.to_string(); + let auth_url = v.get("authUrl").and_then(|s| s.as_str()).map(String::from); + let verification_url = v + .get("verificationUrl") + .and_then(|s| s.as_str()) + .map(String::from); + let user_code = v.get("userCode").and_then(|s| s.as_str()).map(String::from); + Some(LoginStartResponse { + login_id, + auth_url, + verification_url, + user_code, + }) +} + +/// Parses an `account/rateLimits/updated` payload. The payload reports a top +/// rate-limit object; we keep just the fields the sidebar overlay uses. +pub(crate) fn parse_rate_limit_payload(v: &Value) -> RateLimitInfo { + RateLimitInfo { + reached_type: v + .get("rateLimitReachedType") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from), + resets_at_secs: v + .get("resetsAtSecs") + .and_then(|s| s.as_i64()) + .or_else(|| v.get("resetsAt").and_then(|s| s.as_i64())), + primary_used_percent: v + .get("primaryUsedPercent") + .and_then(|s| s.as_u64()) + .map(|n| n.min(100) as u32), + } +} + +impl CodexAccountState { + /// Overlay a rate-limit reading on top of the current state. If the + /// payload's `rateLimitReachedType` is `None`, the overlay is cleared. + pub(crate) fn with_rate_limit(self, info: RateLimitInfo) -> Self { + // Unwrap any existing rate-limit envelope so we don't nest. + let underlying = match self { + CodexAccountState::RateLimited { underlying, .. } => *underlying, + other => other, + }; + if info.reached_type.is_some() { + CodexAccountState::RateLimited { + underlying: Box::new(underlying), + resets_at_secs: info.resets_at_secs, + primary_used_percent: info.primary_used_percent, + } + } else { + underlying + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) enum CodexAccountState { + /// `account/read` hasn't returned yet. + #[default] + Unknown, + /// `requiresOpenaiAuth: false` — the install is configured not to require auth. + NoAuthRequired, + /// `account: null && requiresOpenaiAuth: true` — user must sign in. + SignedOutRequiresAuth, + /// `account.type == "apiKey"`. Read-only in v1 (no UI to manage). + ApiKey, + /// `account.type == "chatgpt"`. + ChatGpt { + email: String, + plan_type: Option, + }, + /// A login flow is in progress. + LoginPending { login_id: String }, + /// Server reported a rate limit. Overlays on top of the underlying account + /// state — keep both around so the UI can show "ChatGPT, rate limited + /// until X" rather than dropping the email. + RateLimited { + underlying: Box, + resets_at_secs: Option, + primary_used_percent: Option, + }, +} + +impl CodexAccountState { + /// Returns true if the user is in a state where Codex can be used. + pub(crate) fn is_usable(&self) -> bool { + match self { + CodexAccountState::ChatGpt { .. } + | CodexAccountState::ApiKey + | CodexAccountState::NoAuthRequired => true, + CodexAccountState::RateLimited { underlying, .. } => underlying.is_usable(), + CodexAccountState::Unknown + | CodexAccountState::SignedOutRequiresAuth + | CodexAccountState::LoginPending { .. } => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signed_out_is_not_usable() { + assert!(!CodexAccountState::SignedOutRequiresAuth.is_usable()); + } + + #[test] + fn chatgpt_is_usable() { + let s = CodexAccountState::ChatGpt { + email: "u@example.com".into(), + plan_type: Some("plus".into()), + }; + assert!(s.is_usable()); + } + + #[test] + fn rate_limited_chatgpt_stays_usable() { + let s = CodexAccountState::RateLimited { + underlying: Box::new(CodexAccountState::ChatGpt { + email: "u@example.com".into(), + plan_type: None, + }), + resets_at_secs: Some(1_700_000_000), + primary_used_percent: Some(95), + }; + assert!(s.is_usable()); + } + + #[test] + fn rate_limited_signed_out_is_not_usable() { + let s = CodexAccountState::RateLimited { + underlying: Box::new(CodexAccountState::SignedOutRequiresAuth), + resets_at_secs: None, + primary_used_percent: None, + }; + assert!(!s.is_usable()); + } + + #[test] + fn build_login_start_uses_chatgpt_wire_value() { + let p = build_login_start_params(LoginType::ChatGpt); + assert_eq!(p["type"], "chatgpt"); + } + + #[test] + fn build_login_start_uses_device_code_wire_value() { + let p = build_login_start_params(LoginType::ChatGptDeviceCode); + assert_eq!(p["type"], "chatgptDeviceCode"); + } + + #[test] + fn build_login_cancel_carries_login_id() { + let p = build_login_cancel_params("login_abc"); + assert_eq!(p["loginId"], "login_abc"); + } + + #[test] + fn parse_login_start_browser_shape() { + let v = serde_json::json!({ + "loginId": "login_1", + "authUrl": "https://example.com/auth/callback" + }); + let parsed = parse_login_start_response(&v).unwrap(); + assert_eq!(parsed.login_id, "login_1"); + assert_eq!( + parsed.auth_url.as_deref(), + Some("https://example.com/auth/callback") + ); + assert_eq!(parsed.verification_url, None); + assert_eq!(parsed.user_code, None); + } + + #[test] + fn parse_login_start_device_code_shape() { + let v = serde_json::json!({ + "loginId": "login_2", + "verificationUrl": "https://chatgpt.com/device", + "userCode": "AB-12-CD" + }); + let parsed = parse_login_start_response(&v).unwrap(); + assert_eq!(parsed.login_id, "login_2"); + assert_eq!(parsed.auth_url, None); + assert_eq!( + parsed.verification_url.as_deref(), + Some("https://chatgpt.com/device") + ); + assert_eq!(parsed.user_code.as_deref(), Some("AB-12-CD")); + } + + #[test] + fn parse_login_start_without_login_id_returns_none() { + assert!(parse_login_start_response(&serde_json::json!({})).is_none()); + } + + #[test] + fn parse_rate_limit_extracts_known_fields() { + let v = serde_json::json!({ + "rateLimitReachedType": "primary", + "resetsAtSecs": 1_750_000_000_i64, + "primaryUsedPercent": 97 + }); + let info = parse_rate_limit_payload(&v); + assert_eq!(info.reached_type.as_deref(), Some("primary")); + assert_eq!(info.resets_at_secs, Some(1_750_000_000)); + assert_eq!(info.primary_used_percent, Some(97)); + } + + #[test] + fn parse_rate_limit_treats_empty_string_as_unset() { + let v = serde_json::json!({ "rateLimitReachedType": "" }); + assert!(parse_rate_limit_payload(&v).reached_type.is_none()); + } + + #[test] + fn with_rate_limit_overlays_chatgpt_underlying() { + let base = CodexAccountState::ChatGpt { + email: "u@example.com".into(), + plan_type: Some("plus".into()), + }; + let overlaid = base.with_rate_limit(RateLimitInfo { + reached_type: Some("primary".into()), + resets_at_secs: Some(123), + primary_used_percent: Some(99), + }); + match overlaid { + CodexAccountState::RateLimited { + underlying, + resets_at_secs, + primary_used_percent, + } => { + assert!(matches!(*underlying, CodexAccountState::ChatGpt { .. })); + assert_eq!(resets_at_secs, Some(123)); + assert_eq!(primary_used_percent, Some(99)); + } + other => panic!("expected RateLimited overlay, got {other:?}"), + } + } + + #[test] + fn with_rate_limit_cleared_unwraps_overlay() { + let base = CodexAccountState::RateLimited { + underlying: Box::new(CodexAccountState::ChatGpt { + email: "u@example.com".into(), + plan_type: None, + }), + resets_at_secs: Some(1), + primary_used_percent: Some(50), + }; + let cleared = base.with_rate_limit(RateLimitInfo::default()); + assert!(matches!(cleared, CodexAccountState::ChatGpt { .. })); + } + + #[test] + fn with_rate_limit_replaces_existing_overlay_without_nesting() { + let base = CodexAccountState::RateLimited { + underlying: Box::new(CodexAccountState::ChatGpt { + email: "u@example.com".into(), + plan_type: None, + }), + resets_at_secs: Some(1), + primary_used_percent: Some(50), + }; + let replaced = base.with_rate_limit(RateLimitInfo { + reached_type: Some("primary".into()), + resets_at_secs: Some(999), + primary_used_percent: Some(80), + }); + match replaced { + CodexAccountState::RateLimited { + underlying, + resets_at_secs, + .. + } => { + assert!(matches!(*underlying, CodexAccountState::ChatGpt { .. })); + assert_eq!(resets_at_secs, Some(999)); + } + _ => panic!("expected single-level overlay"), + } + } +} diff --git a/src/app/codex/mod.rs b/src/app/codex/mod.rs new file mode 100644 index 0000000..a21edc3 --- /dev/null +++ b/src/app/codex/mod.rs @@ -0,0 +1,37 @@ +//! Native Codex App Server integration. +//! +//! This module owns one app-wide child `codex app-server` process and exposes +//! it to the GPUI app through a [`CodexRuntimeHandle`] that sends commands and +//! receives events on `flume` channels. See `docs/codex-native-support-spec.md` +//! for the full design. + +#![allow(dead_code, unused_imports)] + +pub(crate) mod approval; +pub(crate) mod auth; +pub(crate) mod persistence; +pub(crate) mod process; +pub(crate) mod rpc; +pub(crate) mod runtime; +pub(crate) mod schema; +pub(crate) mod thread; + +pub(crate) use approval::{decision_payload, ApprovalDecision, CodexApproval}; +pub(crate) use auth::{ + parse_rate_limit_payload, CodexAccountState, LoginStartResponse, LoginType, RateLimitInfo, +}; +pub(crate) use persistence::{load_codex_threads, save_codex_threads}; +pub(crate) use runtime::{ + discover_binary, new_event_channel, notification_thread_id, parse_account_state, CodexCommand, + CodexEvent, CodexRuntimeHandle, CodexRuntimeStatus, DEFAULT_CODEX_BINARY, +}; + +/// Re-export the account-state parser under a more notification-ish name so +/// the AppView's dispatch path reads sensibly when it's reacting to +/// `account/updated` rather than the original `account/read` response. +pub(crate) use runtime::parse_account_state as parse_account_update; +pub(crate) use schema::{LocalThreadStatus, SandboxMode, ServerThreadStatus}; +pub(crate) use thread::{ + apply_notification, derive_title_from_user_message, CodexItem, CodexItemKind, CodexThread, + CodexThreadLocalId, CodexThreadSummary, CodexTurn, TurnStatus, DEFAULT_THREAD_TITLE, +}; diff --git a/src/app/codex/persistence.rs b/src/app/codex/persistence.rs new file mode 100644 index 0000000..2a33561 --- /dev/null +++ b/src/app/codex/persistence.rs @@ -0,0 +1,420 @@ +//! Persistence of the user's Codex threads. Saved to +//! `~/.config/quackcode/codex_threads.json` so threads reappear in the +//! sidebar (with their transcript history) after a restart. +//! +//! The on-disk schema is intentionally decoupled from in-memory types via +//! `Saved*` structs so we can evolve them independently. Local ids are not +//! persisted — they're reassigned on load. + +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::app::codex::schema::LocalThreadStatus; +use crate::app::codex::thread::{ + CodexItem, CodexItemKind, CodexThread, CodexThreadLocalId, CodexTurn, CodexTurnLocalId, + TurnStatus, +}; +use crate::app::config::{ProjectId, APP_NAME, CONFIG_DIR_NAME}; + +const THREADS_FILE: &str = "codex_threads.json"; + +#[derive(Debug, Default, Serialize, Deserialize)] +struct SavedThreads { + #[serde(default)] + threads: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SavedThread { + #[serde(default)] + server_thread_id: Option, + #[serde(default)] + server_session_id: Option, + project_path: PathBuf, + title: String, + cwd: PathBuf, + #[serde(default)] + archived: bool, + #[serde(default)] + updated_at_ms: Option, + #[serde(default)] + turns: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SavedTurn { + #[serde(default)] + server_turn_id: Option, + #[serde(default)] + items: Vec, + status: SavedTurnStatus, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum SavedTurnStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SavedItem { + server_item_id: String, + kind: String, + text: String, + #[serde(default)] + completed: bool, +} + +/// Persists the supplied threads. `project_path_for` resolves each thread's +/// `project_id` back to a filesystem path so the load side can re-link on +/// next launch even if project ids have shifted. +pub(crate) fn save_codex_threads( + threads: &[CodexThread], + project_path_for: impl Fn(ProjectId) -> Option, +) { + let Some(path) = threads_path() else { + return; + }; + save_codex_threads_to(&path, threads, project_path_for); +} + +/// Loads persisted threads and re-links each one to a current `project_id` +/// using `project_id_for_path`. Threads whose project is no longer open are +/// skipped. Returns `(threads, next_local_id)`. +pub(crate) fn load_codex_threads( + project_id_for_path: impl Fn(&Path) -> Option, +) -> (Vec, CodexThreadLocalId) { + let Some(path) = threads_path() else { + return (Vec::new(), 1); + }; + load_codex_threads_from(&path, project_id_for_path) +} + +fn threads_path() -> Option { + dirs::config_dir().map(|d| d.join(CONFIG_DIR_NAME).join(THREADS_FILE)) +} + +fn save_codex_threads_to( + path: &Path, + threads: &[CodexThread], + project_path_for: impl Fn(ProjectId) -> Option, +) { + if let Some(parent) = path.parent() { + if let Err(e) = fs::create_dir_all(parent) { + eprintln!( + "{APP_NAME}: failed to create config dir {}: {e}", + parent.display() + ); + return; + } + } + let saved = SavedThreads { + threads: threads + .iter() + .filter_map(|t| { + let project_path = project_path_for(t.project_id)?; + Some(SavedThread { + server_thread_id: t.server_thread_id.clone(), + server_session_id: t.server_session_id.clone(), + project_path, + title: t.title.clone(), + cwd: t.cwd.clone(), + archived: t.archived, + updated_at_ms: t.updated_at_ms, + turns: t.turns.iter().map(SavedTurn::from).collect(), + }) + }) + .collect(), + }; + match serde_json::to_vec_pretty(&saved) { + Ok(bytes) => { + if let Err(e) = write_atomic(path, &bytes) { + eprintln!("{APP_NAME}: failed to write {}: {e}", path.display()); + } + } + Err(e) => eprintln!("{APP_NAME}: failed to serialize codex threads: {e}"), + } +} + +/// Writes `bytes` to `path` via a sibling temp file + rename. If the process +/// is interrupted mid-write, `path` either keeps its previous contents or +/// already has the new contents — never a partially-written file that would +/// trip the parse fallback in `load_codex_threads_from` and silently drop +/// every persisted thread. +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + + let parent = path.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent") + })?; + let file_name = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("codex_threads"); + let tmp_path = parent.join(format!(".{file_name}.tmp.{}", std::process::id())); + + let result = (|| -> std::io::Result<()> { + let mut tmp = fs::File::create(&tmp_path)?; + tmp.write_all(bytes)?; + tmp.sync_all()?; + drop(tmp); + fs::rename(&tmp_path, path) + })(); + + if result.is_err() { + let _ = fs::remove_file(&tmp_path); + } + result +} + +fn load_codex_threads_from( + path: &Path, + project_id_for_path: impl Fn(&Path) -> Option, +) -> (Vec, CodexThreadLocalId) { + let Ok(bytes) = fs::read(path) else { + return (Vec::new(), 1); + }; + let parsed: SavedThreads = match serde_json::from_slice(&bytes) { + Ok(v) => v, + Err(_) => return (Vec::new(), 1), + }; + let mut out: Vec = Vec::new(); + let mut next_id: CodexThreadLocalId = 1; + for s in parsed.threads { + let Some(project_id) = project_id_for_path(&s.project_path) else { + continue; + }; + let local_id = next_id; + next_id += 1; + let status = if s.archived { + LocalThreadStatus::Archived + } else if s.server_thread_id.is_some() { + LocalThreadStatus::Loaded + } else { + LocalThreadStatus::Disconnected + }; + out.push(CodexThread { + local_id, + server_thread_id: s.server_thread_id, + server_session_id: s.server_session_id, + project_id, + title: s.title, + cwd: s.cwd, + status, + turns: s.turns.into_iter().enumerate().map(turn_from).collect(), + pending_approvals: Vec::new(), + unread: false, + updated_at_ms: s.updated_at_ms, + archived: s.archived, + }); + } + (out, next_id) +} + +fn turn_from((idx, t): (usize, SavedTurn)) -> CodexTurn { + CodexTurn { + local_id: (idx as CodexTurnLocalId) + 1, + server_turn_id: t.server_turn_id, + items: t.items.into_iter().map(CodexItem::from).collect(), + status: t.status.into(), + } +} + +impl From<&CodexTurn> for SavedTurn { + fn from(t: &CodexTurn) -> Self { + SavedTurn { + server_turn_id: t.server_turn_id.clone(), + items: t.items.iter().map(SavedItem::from).collect(), + status: t.status.into(), + } + } +} + +impl From<&CodexItem> for SavedItem { + fn from(i: &CodexItem) -> Self { + SavedItem { + server_item_id: i.server_item_id.clone(), + kind: kind_to_wire(&i.kind), + text: i.text.clone(), + completed: i.completed, + } + } +} + +impl From for CodexItem { + fn from(s: SavedItem) -> Self { + CodexItem { + server_item_id: s.server_item_id, + kind: CodexItemKind::from_wire(&s.kind), + text: s.text, + completed: s.completed, + } + } +} + +impl From for SavedTurnStatus { + fn from(t: TurnStatus) -> Self { + match t { + TurnStatus::InProgress => SavedTurnStatus::InProgress, + TurnStatus::Completed => SavedTurnStatus::Completed, + TurnStatus::Failed => SavedTurnStatus::Failed, + } + } +} + +impl From for TurnStatus { + fn from(s: SavedTurnStatus) -> Self { + match s { + SavedTurnStatus::InProgress => TurnStatus::InProgress, + SavedTurnStatus::Completed => TurnStatus::Completed, + SavedTurnStatus::Failed => TurnStatus::Failed, + } + } +} + +fn kind_to_wire(kind: &CodexItemKind) -> String { + match kind { + CodexItemKind::UserMessage => "userMessage".into(), + CodexItemKind::AgentMessage => "agentMessage".into(), + CodexItemKind::Reasoning => "reasoning".into(), + CodexItemKind::CommandExecution => "commandExecution".into(), + CodexItemKind::FileChange => "fileChange".into(), + CodexItemKind::Other(s) => s.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + fn temp_path(name: &str) -> PathBuf { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + env::temp_dir().join(format!("quackcode-test-{name}-{stamp}.json")) + } + + fn sample_thread(project_id: ProjectId, title: &str) -> CodexThread { + CodexThread { + local_id: 42, + server_thread_id: Some("thr_abc".into()), + server_session_id: Some("ses_xyz".into()), + project_id, + title: title.into(), + cwd: PathBuf::from("/tmp/repo"), + status: LocalThreadStatus::Loaded, + turns: vec![CodexTurn { + local_id: 1, + server_turn_id: Some("turn_1".into()), + items: vec![ + CodexItem { + server_item_id: "u1".into(), + kind: CodexItemKind::UserMessage, + text: "Hello there".into(), + completed: true, + }, + CodexItem { + server_item_id: "a1".into(), + kind: CodexItemKind::AgentMessage, + text: "Hi!".into(), + completed: true, + }, + ], + status: TurnStatus::Completed, + }], + pending_approvals: Vec::new(), + unread: true, + updated_at_ms: Some(1_700_000_000_000), + archived: false, + } + } + + #[test] + fn save_then_load_roundtrips_threads() { + let p = temp_path("codex-roundtrip"); + let project_path = PathBuf::from("/tmp/repo"); + let threads = vec![sample_thread(7, "Fix the bug")]; + save_codex_threads_to(&p, &threads, |_| Some(project_path.clone())); + + let (loaded, next_id) = + load_codex_threads_from(&p, |path| (path == project_path).then_some(7)); + assert_eq!(next_id, 2); + assert_eq!(loaded.len(), 1); + let t = &loaded[0]; + assert_eq!(t.local_id, 1, "local id should be reassigned"); + assert_eq!(t.project_id, 7); + assert_eq!(t.title, "Fix the bug"); + assert_eq!(t.server_thread_id.as_deref(), Some("thr_abc")); + assert_eq!(t.status, LocalThreadStatus::Loaded); + assert_eq!(t.turns.len(), 1); + assert_eq!(t.turns[0].items.len(), 2); + assert_eq!(t.turns[0].items[0].kind, CodexItemKind::UserMessage); + assert_eq!(t.turns[0].items[1].text, "Hi!"); + let _ = fs::remove_file(&p); + } + + #[test] + fn threads_for_closed_projects_are_skipped_on_load() { + let p = temp_path("codex-skip"); + let threads = vec![sample_thread(7, "Stale")]; + save_codex_threads_to(&p, &threads, |_| Some(PathBuf::from("/tmp/repo"))); + let (loaded, _) = load_codex_threads_from(&p, |_| None); + assert!(loaded.is_empty()); + let _ = fs::remove_file(&p); + } + + #[test] + fn archived_threads_round_trip_as_archived() { + let p = temp_path("codex-archived"); + let mut t = sample_thread(1, "Old work"); + t.archived = true; + t.status = LocalThreadStatus::Archived; + save_codex_threads_to(&p, &[t], |_| Some(PathBuf::from("/tmp/repo"))); + let (loaded, _) = + load_codex_threads_from(&p, |path| (path == Path::new("/tmp/repo")).then_some(1)); + assert_eq!(loaded.len(), 1); + assert!(loaded[0].archived); + assert_eq!(loaded[0].status, LocalThreadStatus::Archived); + let _ = fs::remove_file(&p); + } + + #[test] + fn missing_file_yields_empty_and_starts_local_ids_at_one() { + let p = temp_path("codex-missing"); + let (loaded, next_id) = load_codex_threads_from(&p, |_| Some(1)); + assert!(loaded.is_empty()); + assert_eq!(next_id, 1); + } + + #[test] + fn malformed_file_yields_empty() { + let p = temp_path("codex-malformed"); + fs::write(&p, b"definitely not json").unwrap(); + let (loaded, _) = load_codex_threads_from(&p, |_| Some(1)); + assert!(loaded.is_empty()); + let _ = fs::remove_file(&p); + } + + #[test] + fn other_kind_preserves_raw_wire_name() { + let p = temp_path("codex-other-kind"); + let mut t = sample_thread(1, "Other"); + t.turns[0].items[0].kind = CodexItemKind::Other("customThing".into()); + save_codex_threads_to(&p, &[t], |_| Some(PathBuf::from("/tmp/repo"))); + let (loaded, _) = + load_codex_threads_from(&p, |path| (path == Path::new("/tmp/repo")).then_some(1)); + assert_eq!( + loaded[0].turns[0].items[0].kind, + CodexItemKind::Other("customThing".into()) + ); + let _ = fs::remove_file(&p); + } +} diff --git a/src/app/codex/process.rs b/src/app/codex/process.rs new file mode 100644 index 0000000..fecd99d --- /dev/null +++ b/src/app/codex/process.rs @@ -0,0 +1,301 @@ +//! Owns the `codex app-server --listen stdio://` child process. +//! +//! Lifted and tightened from `src/codex_spike/process.rs` after Phase 0 +//! proved the protocol. Phase 1 still uses synchronous IO on dedicated +//! threads — the GPUI side talks to the runtime via `flume` channels, not +//! directly. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use flume::{Receiver, Sender}; +use serde_json::Value; + +use super::rpc::{decode, encode, ErrorObject, IncomingFrame, OutgoingFrame, PendingTable}; + +const SHUTDOWN_GRACE: Duration = Duration::from_millis(500); + +#[derive(Debug)] +pub(crate) enum ClientError { + Io(std::io::Error), + BinaryNotFound { + binary: String, + source: std::io::Error, + }, + ResponseTimeout { + method: String, + }, + Rpc { + method: String, + // Boxed so ClientError stays small — ErrorObject contains a + // `serde_json::Value` payload that bloats every Result<_, ClientError> + // return slot. + error: Box, + }, + ConnectionLost, +} + +impl std::fmt::Display for ClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClientError::Io(e) => write!(f, "io error: {e}"), + ClientError::BinaryNotFound { binary, source } => { + write!(f, "codex binary `{binary}` not found: {source}") + } + ClientError::ResponseTimeout { method } => { + write!(f, "timed out waiting for response to `{method}`") + } + ClientError::Rpc { method, error } => write!( + f, + "rpc error from `{method}`: code={} message={}", + error.code, error.message + ), + ClientError::ConnectionLost => write!(f, "app-server connection lost"), + } + } +} + +impl std::error::Error for ClientError {} + +impl From for ClientError { + fn from(value: std::io::Error) -> Self { + ClientError::Io(value) + } +} + +#[derive(Debug, Clone)] +pub(crate) enum ServerEvent { + Notification { + method: String, + params: Value, + }, + Request { + id: u64, + method: String, + params: Value, + }, +} + +pub(crate) struct AppServerClient { + child: Option, + stdin: Option>>, + pending: Arc, + events: Receiver, + reader: Option>, + stderr_drainer: Option>, + stderr_log: Arc>>, +} + +impl AppServerClient { + pub(crate) fn spawn(binary: &str) -> Result { + let mut child = Command::new(binary) + .args(["app-server", "--listen", "stdio://"]) + .env("LOG_FORMAT", "json") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|source| ClientError::BinaryNotFound { + binary: binary.into(), + source, + })?; + + let stdin = child.stdin.take().expect("requested piped stdin"); + let stdout = child.stdout.take().expect("requested piped stdout"); + let stderr = child.stderr.take().expect("requested piped stderr"); + + let pending: Arc = Arc::new(PendingTable::new()); + let (event_tx, event_rx) = flume::unbounded::(); + let pending_for_reader = pending.clone(); + let reader = thread::Builder::new() + .name("codex-reader".into()) + .spawn(move || reader_loop(stdout, pending_for_reader, event_tx)) + .expect("spawn codex reader thread"); + + let stderr_log = Arc::new(Mutex::new(Vec::::new())); + let stderr_log_for_drainer = stderr_log.clone(); + let stderr_drainer = thread::Builder::new() + .name("codex-stderr".into()) + .spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines().map_while(Result::ok) { + if let Ok(mut log) = stderr_log_for_drainer.lock() { + log.push(line); + } + } + }) + .expect("spawn codex stderr drainer"); + + Ok(Self { + child: Some(child), + stdin: Some(Arc::new(Mutex::new(stdin))), + pending, + events: event_rx, + reader: Some(reader), + stderr_drainer: Some(stderr_drainer), + stderr_log, + }) + } + + pub(crate) fn events(&self) -> Receiver { + self.events.clone() + } + + pub(crate) fn notify(&self, method: &str, params: Value) -> Result<(), ClientError> { + let frame = OutgoingFrame::Notification { + method: method.into(), + params, + }; + self.write_frame(&frame) + } + + pub(crate) fn respond(&self, id: u64, result: Value) -> Result<(), ClientError> { + let frame = OutgoingFrame::Response { id, result }; + self.write_frame(&frame) + } + + pub(crate) fn request( + &self, + method: &str, + params: Value, + timeout: Duration, + ) -> Result { + let (id, rx) = self.pending.register(); + let frame = OutgoingFrame::Request { + id, + method: method.into(), + params, + }; + if let Err(e) = self.write_frame(&frame) { + // Otherwise the waiter sits in the table until shutdown's + // cancel_all and accumulates on repeated write failures. + self.pending.forget(id); + return Err(e); + } + match rx.recv_timeout(timeout) { + Ok(Ok(value)) => Ok(value), + Ok(Err(error)) => Err(ClientError::Rpc { + method: method.into(), + error: Box::new(error), + }), + Err(flume::RecvTimeoutError::Timeout) => Err(ClientError::ResponseTimeout { + method: method.into(), + }), + Err(flume::RecvTimeoutError::Disconnected) => Err(ClientError::ConnectionLost), + } + } + + fn write_frame(&self, frame: &OutgoingFrame) -> Result<(), ClientError> { + let mut line = encode(frame); + line.push('\n'); + let stdin = self.stdin.as_ref().ok_or(ClientError::ConnectionLost)?; + let mut stdin = stdin.lock().expect("stdin lock"); + stdin.write_all(line.as_bytes())?; + stdin.flush()?; + Ok(()) + } + + pub(crate) fn stderr_log(&self) -> Vec { + self.stderr_log + .lock() + .map(|g| g.clone()) + .unwrap_or_default() + } + + pub(crate) fn shutdown(mut self) -> Result { + self.do_shutdown() + } + + fn do_shutdown(&mut self) -> Result { + // Drop stdin so the child sees EOF and exits naturally. Flushing the + // BufWriter alone is not enough — we need the fd to actually close. + self.stdin = None; + + let deadline = Instant::now() + SHUTDOWN_GRACE; + let mut child = self.child.take().ok_or(ClientError::ConnectionLost)?; + loop { + if let Some(status) = child.try_wait()? { + self.join_threads(); + return Ok(status); + } + if Instant::now() >= deadline { + break; + } + thread::sleep(Duration::from_millis(25)); + } + // EOF wasn't enough. If `codex` is a node shim, this kills only the + // shim — the bounded join below detaches the reader so we don't deadlock. + let _ = child.kill(); + let status = child.wait()?; + self.join_threads(); + Ok(status) + } + + fn join_threads(&mut self) { + let deadline = Instant::now() + Duration::from_millis(200); + if let Some(handle) = self.reader.take() { + try_join_within(handle, deadline); + } + if let Some(handle) = self.stderr_drainer.take() { + try_join_within(handle, deadline); + } + self.pending.cancel_all(ErrorObject { + code: -1, + message: "app-server connection closed".into(), + data: None, + }); + } +} + +impl Drop for AppServerClient { + fn drop(&mut self) { + if self.child.is_some() { + let _ = self.do_shutdown(); + } + } +} + +fn try_join_within(handle: JoinHandle<()>, deadline: Instant) { + loop { + if handle.is_finished() { + let _ = handle.join(); + return; + } + if Instant::now() >= deadline { + return; + } + thread::sleep(Duration::from_millis(10)); + } +} + +fn reader_loop(stdout: ChildStdout, pending: Arc, events: Sender) { + let reader = BufReader::new(stdout); + for line in reader.lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + match decode(&line) { + Ok(IncomingFrame::Response { id, result }) => pending.complete(id, Ok(result)), + Ok(IncomingFrame::Error { id, error }) => pending.complete(id, Err(error)), + Ok(IncomingFrame::Notification { method, params }) => { + let _ = events.send(ServerEvent::Notification { method, params }); + } + Ok(IncomingFrame::Request { id, method, params }) => { + let _ = events.send(ServerEvent::Request { id, method, params }); + } + Err(_) => { + // Phase 1 log-and-ignore. A future protocol-error event could + // be plumbed through the runtime's CodexEvent::ProtocolError. + } + } + } + pending.cancel_all(ErrorObject { + code: -1, + message: "app-server stdout closed".into(), + data: None, + }); +} diff --git a/src/app/codex/rpc.rs b/src/app/codex/rpc.rs new file mode 100644 index 0000000..1327512 --- /dev/null +++ b/src/app/codex/rpc.rs @@ -0,0 +1,317 @@ +//! JSON-RPC 2.0 framing and pending-response table for the Codex app-server. +//! +//! Frames omit the `"jsonrpc":"2.0"` field on the wire (matches the +//! `codex-rs/app-server` convention). Incoming frames are accepted with or +//! without it. + +use std::collections::HashMap; +use std::sync::Mutex; + +use flume::{Receiver, Sender}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub(crate) type RequestId = u64; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum OutgoingFrame { + Request { + id: RequestId, + method: String, + params: Value, + }, + Notification { + method: String, + params: Value, + }, + Response { + id: RequestId, + result: Value, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum IncomingFrame { + Response { + id: RequestId, + result: Value, + }, + Error { + id: RequestId, + error: ErrorObject, + }, + Notification { + method: String, + params: Value, + }, + Request { + id: RequestId, + method: String, + params: Value, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct ErrorObject { + pub(crate) code: i64, + pub(crate) message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) data: Option, +} + +#[derive(Debug)] +pub(crate) enum FrameError { + Json(serde_json::Error), + MissingIdAndMethod, +} + +impl std::fmt::Display for FrameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FrameError::Json(e) => write!(f, "invalid json: {e}"), + FrameError::MissingIdAndMethod => write!(f, "frame missing both id and method"), + } + } +} + +impl std::error::Error for FrameError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + FrameError::Json(e) => Some(e), + FrameError::MissingIdAndMethod => None, + } + } +} + +impl From for FrameError { + fn from(value: serde_json::Error) -> Self { + FrameError::Json(value) + } +} + +pub(crate) fn encode(frame: &OutgoingFrame) -> String { + let value = match frame { + OutgoingFrame::Request { id, method, params } => serde_json::json!({ + "id": id, + "method": method, + "params": params, + }), + OutgoingFrame::Notification { method, params } => serde_json::json!({ + "method": method, + "params": params, + }), + OutgoingFrame::Response { id, result } => serde_json::json!({ + "id": id, + "result": result, + }), + }; + serde_json::to_string(&value).expect("frame is always serializable") +} + +pub(crate) fn decode(line: &str) -> Result { + let value: Value = serde_json::from_str(line)?; + let obj = match value.as_object() { + Some(o) => o, + None => return Err(FrameError::MissingIdAndMethod), + }; + + let id = obj.get("id").and_then(|v| v.as_u64()); + let method = obj.get("method").and_then(|v| v.as_str()).map(String::from); + + match (id, method) { + (Some(id), Some(method)) => { + let params = obj.get("params").cloned().unwrap_or(Value::Null); + Ok(IncomingFrame::Request { id, method, params }) + } + (Some(id), None) => { + if let Some(err) = obj.get("error") { + let error: ErrorObject = serde_json::from_value(err.clone())?; + Ok(IncomingFrame::Error { id, error }) + } else { + let result = obj.get("result").cloned().unwrap_or(Value::Null); + Ok(IncomingFrame::Response { id, result }) + } + } + (None, Some(method)) => { + let params = obj.get("params").cloned().unwrap_or(Value::Null); + Ok(IncomingFrame::Notification { method, params }) + } + (None, None) => Err(FrameError::MissingIdAndMethod), + } +} + +pub(crate) type ResponseResult = Result; + +#[derive(Default)] +pub(crate) struct PendingTable { + inner: Mutex, +} + +#[derive(Default)] +struct Inner { + next_id: RequestId, + waiters: HashMap>, +} + +impl PendingTable { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn register(&self) -> (RequestId, Receiver) { + let (tx, rx) = flume::bounded::(1); + let mut inner = self.inner.lock().expect("pending table poisoned"); + inner.next_id = inner.next_id.checked_add(1).expect("request id overflow"); + let id = inner.next_id; + inner.waiters.insert(id, tx); + (id, rx) + } + + pub(crate) fn complete(&self, id: RequestId, result: ResponseResult) { + let waiter = { + let mut inner = self.inner.lock().expect("pending table poisoned"); + inner.waiters.remove(&id) + }; + if let Some(tx) = waiter { + let _ = tx.send(result); + } + } + + /// Drops a waiter without delivering anything (the caller is about to + /// surface a different error). Used to clean up after a request that + /// never made it onto the wire, so the entry doesn't leak until + /// `cancel_all`. + pub(crate) fn forget(&self, id: RequestId) { + let mut inner = self.inner.lock().expect("pending table poisoned"); + inner.waiters.remove(&id); + } + + pub(crate) fn cancel_all(&self, reason: ErrorObject) { + let waiters: Vec<_> = { + let mut inner = self.inner.lock().expect("pending table poisoned"); + inner.waiters.drain().collect() + }; + for (_id, tx) in waiters { + let _ = tx.send(Err(reason.clone())); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn request_serializes_without_jsonrpc_header() { + let req = OutgoingFrame::Request { + id: 1, + method: "initialize".into(), + params: json!({ "clientInfo": { "name": "quackcode" } }), + }; + let line = encode(&req); + let v: Value = serde_json::from_str(&line).unwrap(); + let obj = v.as_object().unwrap(); + assert!( + !obj.contains_key("jsonrpc"), + "wire frame must omit jsonrpc: {line}" + ); + assert_eq!(v["id"], 1); + assert_eq!(v["method"], "initialize"); + } + + #[test] + fn parses_response_without_jsonrpc_header() { + let line = r#"{"id":1,"result":{"ok":true}}"#; + match decode(line).unwrap() { + IncomingFrame::Response { id, result } => { + assert_eq!(id, 1); + assert_eq!(result["ok"], true); + } + other => panic!("expected response, got {other:?}"), + } + } + + #[test] + fn parses_response_with_jsonrpc_header_for_compatibility() { + let line = r#"{"jsonrpc":"2.0","id":1,"result":{}}"#; + assert!(matches!( + decode(line).unwrap(), + IncomingFrame::Response { id: 1, .. } + )); + } + + #[test] + fn parses_notification() { + let line = r#"{"method":"turn/started","params":{"threadId":"thr_1"}}"#; + match decode(line).unwrap() { + IncomingFrame::Notification { method, params } => { + assert_eq!(method, "turn/started"); + assert_eq!(params["threadId"], "thr_1"); + } + other => panic!("expected notification, got {other:?}"), + } + } + + #[test] + fn parses_server_initiated_request() { + let line = r#"{"id":42,"method":"item/commandExecution/requestApproval","params":{"command":"ls"}}"#; + match decode(line).unwrap() { + IncomingFrame::Request { id, method, .. } => { + assert_eq!(id, 42); + assert_eq!(method, "item/commandExecution/requestApproval"); + } + other => panic!("expected request, got {other:?}"), + } + } + + #[test] + fn parses_error_response() { + let line = r#"{"id":1,"error":{"code":-32601,"message":"method not found"}}"#; + match decode(line).unwrap() { + IncomingFrame::Error { id, error } => { + assert_eq!(id, 1); + assert_eq!(error.code, -32601); + assert_eq!(error.message, "method not found"); + } + other => panic!("expected error, got {other:?}"), + } + } + + #[test] + fn empty_object_fails() { + assert!(matches!( + decode("{}").unwrap_err(), + FrameError::MissingIdAndMethod + )); + } + + #[test] + fn pending_register_returns_increasing_ids() { + let table = PendingTable::new(); + let (id1, _r1) = table.register(); + let (id2, _r2) = table.register(); + assert!(id2 > id1); + } + + #[test] + fn pending_complete_resolves_matching_waiter() { + let table = PendingTable::new(); + let (id, rx) = table.register(); + table.complete(id, Ok(json!({ "ok": true }))); + assert_eq!(rx.recv().unwrap().unwrap()["ok"], true); + } + + #[test] + fn pending_cancel_all_drains_waiters() { + let table = PendingTable::new(); + let (_id, rx) = table.register(); + table.cancel_all(ErrorObject { + code: -1, + message: "stop".into(), + data: None, + }); + let err = rx.recv().unwrap().unwrap_err(); + assert_eq!(err.message, "stop"); + } +} diff --git a/src/app/codex/runtime.rs b/src/app/codex/runtime.rs new file mode 100644 index 0000000..9822bcc --- /dev/null +++ b/src/app/codex/runtime.rs @@ -0,0 +1,1088 @@ +//! `CodexRuntimeHandle` — the app-facing handle that owns the Codex worker +//! thread. The handle holds an unbounded outbound `CodexCommand` channel and +//! a bounded inbound `CodexEvent` channel; an event consumer in +//! `src/app/mod.rs` pumps events into `AppView::handle_codex_event`. + +use std::collections::HashMap; +use std::env; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use flume::{Receiver, Sender}; +use serde_json::{json, Value}; + +use super::auth::{ + build_login_cancel_params, build_login_start_params, parse_login_start_response, + CodexAccountState, LoginStartResponse, LoginType, RateLimitInfo, +}; +use super::process::{AppServerClient, ClientError, ServerEvent}; +use super::schema::SandboxMode; +use super::thread::{ + build_thread_fork_params, build_thread_list_params, build_thread_resume_params, + parse_thread_list_response, CodexThreadLocalId, CodexThreadSummary, +}; + +pub(crate) const DEFAULT_CODEX_BINARY: &str = "codex"; + +/// Capacity of the inbound `CodexEvent` channel. See spec § Protocol Client: +/// delta-heavy notifications get coalesced before they reach the AppView, so +/// this stays modest. +const INBOUND_CHANNEL_CAPACITY: usize = 1024; + +/// How long an `EnsureStarted` waits for `initialize` and `account/read`. +const STARTUP_RPC_TIMEOUT: Duration = Duration::from_secs(15); + +/// Top-level runtime status reflected in the sidebar status row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CodexRuntimeStatus { + /// No worker has been started yet. + Disconnected, + /// `EnsureStarted` was received; spawn / initialize / account-read are in flight. + Starting, + /// `initialize` succeeded; account state may be signed in or not. + Ready, + /// App-server initialization failed; the user-facing error is surfaced. + Failed(String), + /// Spec § Lifecycle: when shutdown begins, new actions are rejected by the UI. + ShuttingDown, +} + +impl CodexRuntimeStatus { + pub(crate) fn label(&self) -> String { + match self { + CodexRuntimeStatus::Disconnected => "Codex: disconnected".into(), + CodexRuntimeStatus::Starting => "Codex: starting".into(), + CodexRuntimeStatus::Ready => "Codex: ready".into(), + CodexRuntimeStatus::Failed(msg) => format!("Codex: failed — {msg}"), + CodexRuntimeStatus::ShuttingDown => "Codex: shutting down".into(), + } + } +} + +/// Commands sent from the GPUI side into the runtime worker. +#[derive(Debug)] +pub(crate) enum CodexCommand { + /// Spawn the app-server (if not running) and perform `initialize`, + /// `initialized`, and `account/read`. Idempotent. + EnsureStarted, + /// Refresh account state by calling `account/read` again. + ReadAccount, + /// Issue a `thread/start` for the given local thread. The runtime echoes + /// back a [`CodexEvent::ThreadStarted`] (or `ThreadStartFailed`) carrying + /// `local_id` so the AppView can reconcile. + StartThread { + local_id: CodexThreadLocalId, + cwd: PathBuf, + sandbox: SandboxMode, + }, + /// Issue a `turn/start` with a single text input item. + SubmitPrompt { + server_thread_id: String, + text: String, + images: Vec, + }, + /// Issue a `turn/interrupt` for the active turn on a thread. + InterruptTurn { + server_thread_id: String, + server_turn_id: String, + }, + /// Respond to a server-initiated JSON-RPC request (Phase 3 approvals). + /// The runtime writes the response with the given JSON `result`. + ResolveServerRequest { request_id: u64, result: Value }, + /// Phase 4: kick off `account/login/start` for either the browser flow + /// (`chatgpt`) or the device-code fallback (`chatgptDeviceCode`). The + /// runtime emits [`CodexEvent::LoginStarted`] / `LoginFailed` and, on + /// browser flows, attempts to open the returned `authUrl` automatically. + StartLogin(LoginType), + /// Phase 4: cancel an in-flight login. + CancelLogin { login_id: String }, + /// Phase 4: `thread/list` filtered by the project cwd. The runtime + /// publishes the parsed result as [`CodexEvent::ThreadList`]. + ListThreads { cwd: Option }, + /// Phase 4: `thread/resume`. The local thread record (Draft) is supplied + /// so the runtime can echo a `ThreadStarted` event for reconciliation. + ResumeThread { + local_id: CodexThreadLocalId, + server_thread_id: String, + }, + /// Phase 4: `thread/fork` of a known server thread. `turn_id` is + /// optional — when present, the fork branches at that turn. + ForkThread { + local_id: CodexThreadLocalId, + server_thread_id: String, + turn_id: Option, + }, + /// Phase 5: `thread/archive` or `thread/unarchive`. The reducer already + /// reacts to the resulting `thread/archived` / `unarchived` notification. + ArchiveThread { + server_thread_id: String, + archive: bool, + }, + /// Drop the worker and stop processing further commands. + Shutdown, +} + +/// Events sent from the runtime worker to the GPUI side. +#[derive(Debug, Clone)] +pub(crate) enum CodexEvent { + StatusChanged(CodexRuntimeStatus), + AccountChanged(CodexAccountState), + /// Successful `thread/start` response — the AppView links the local thread + /// to the server id and transitions out of `Draft`. + ThreadStarted { + local_id: CodexThreadLocalId, + server_thread_id: String, + server_session_id: Option, + }, + /// `thread/start` failed; the AppView marks the local thread `Failed`. + ThreadStartFailed { + local_id: CodexThreadLocalId, + message: String, + }, + ServerNotification { + method: String, + params: Value, + }, + /// A server-initiated JSON-RPC request the GPUI side must answer. + /// Phase 3 (approvals) consumes these. + ServerRequest { + request_id: u64, + method: String, + params: Value, + }, + /// Phase 4: `account/login/start` returned. Carries the URL/code the UI + /// must surface and a `browser_open_failed` flag — true when the runtime + /// attempted to `xdg-open` and failed, signaling the UI to offer the + /// device-code fallback. + LoginStarted { + response: LoginStartResponse, + flow: LoginType, + browser_open_failed: bool, + }, + /// Phase 4: `account/login/start` itself failed (network/RPC). + LoginFailed { + message: String, + }, + /// Phase 4: `account/login/completed` notification arrived; the UI clears + /// the LoginPending overlay and the runtime kicks off `account/read`. + LoginCompleted { + login_id: Option, + }, + /// Phase 4: rate-limit update parsed off the wire. + RateLimitChanged(RateLimitInfo), + /// Phase 4: parsed `thread/list` response. + ThreadList(Vec), + /// Phase 4: `thread/list` failed. + ThreadListFailed(String), + /// Phase 5: app-server `initialize` returned. Carries the parsed + /// `serverInfo.version` so the sidebar can surface the pinned protocol + /// build. + ServerInfo { + version: Option, + }, + /// Surfaced when the worker fails to start or the connection drops. + Failed(String), +} + +/// App-facing handle. Cheap to clone (it's just two flume channels and an Arc). +#[derive(Clone)] +pub(crate) struct CodexRuntimeHandle { + commands: Sender, + binary: Arc, +} + +impl CodexRuntimeHandle { + /// Spawn the worker thread and return a handle. The worker is idle until + /// `EnsureStarted` arrives. + pub(crate) fn spawn(binary: PathBuf, events: Sender) -> Self { + let (command_tx, command_rx) = flume::unbounded::(); + let binary = Arc::new(binary); + let binary_for_worker = binary.clone(); + thread::Builder::new() + .name("codex-runtime".into()) + .spawn(move || worker_loop(binary_for_worker, command_rx, events)) + .expect("spawn codex runtime worker"); + Self { + commands: command_tx, + binary, + } + } + + pub(crate) fn binary(&self) -> &PathBuf { + &self.binary + } + + pub(crate) fn send(&self, command: CodexCommand) { + // unbounded send only fails if the receiver is dropped — that's the + // runtime thread, which would mean the worker has exited. + let _ = self.commands.send(command); + } +} + +/// Builds the inbound event channel pair with the spec-specified capacity. +pub(crate) fn new_event_channel() -> (Sender, Receiver) { + flume::bounded::(INBOUND_CHANNEL_CAPACITY) +} + +/// Discovers the codex binary path. Priority: explicit override > env var > PATH. +pub(crate) fn discover_binary(override_path: Option) -> PathBuf { + if let Some(p) = override_path { + return p; + } + if let Ok(p) = env::var("CODEX_BINARY") { + if !p.is_empty() { + return PathBuf::from(p); + } + } + PathBuf::from(DEFAULT_CODEX_BINARY) +} + +fn worker_loop(binary: Arc, commands: Receiver, events: Sender) { + let mut state = WorkerState::default(); + while let Ok(cmd) = commands.recv() { + match cmd { + CodexCommand::EnsureStarted => { + if state.client.is_some() { + continue; + } + publish( + &events, + CodexEvent::StatusChanged(CodexRuntimeStatus::Starting), + ); + match start_client(&binary, &events) { + Ok(client) => { + state.client = Some(client); + publish( + &events, + CodexEvent::StatusChanged(CodexRuntimeStatus::Ready), + ); + } + Err(err) => { + let msg = err.to_string(); + publish(&events, CodexEvent::Failed(msg.clone())); + publish( + &events, + CodexEvent::StatusChanged(CodexRuntimeStatus::Failed(msg)), + ); + } + } + } + CodexCommand::ReadAccount => { + if let Some(client) = state.client.as_ref() { + match client.request( + "account/read", + json!({ "refreshToken": false }), + STARTUP_RPC_TIMEOUT, + ) { + Ok(v) => { + publish(&events, CodexEvent::AccountChanged(parse_account_state(&v))) + } + Err(e) => publish(&events, CodexEvent::Failed(e.to_string())), + } + } + } + CodexCommand::StartThread { + local_id, + cwd, + sandbox, + } => { + if let Some(client) = state.client.as_ref() { + let params = build_thread_start_params(&cwd, sandbox); + match client.request("thread/start", params, STARTUP_RPC_TIMEOUT) { + Ok(v) => match parse_thread_start_response(&v) { + Some((server_thread_id, server_session_id)) => publish( + &events, + CodexEvent::ThreadStarted { + local_id, + server_thread_id, + server_session_id, + }, + ), + None => publish( + &events, + CodexEvent::ThreadStartFailed { + local_id, + message: "thread/start response missing thread id".into(), + }, + ), + }, + Err(e) => publish( + &events, + CodexEvent::ThreadStartFailed { + local_id, + message: e.to_string(), + }, + ), + } + } else { + publish( + &events, + CodexEvent::ThreadStartFailed { + local_id, + message: "codex runtime not started".into(), + }, + ); + } + } + CodexCommand::SubmitPrompt { + server_thread_id, + text, + images, + } => { + if let Some(client) = state.client.as_ref() { + let params = build_turn_start_params(&server_thread_id, &text, &images); + if let Err(e) = client.request("turn/start", params, STARTUP_RPC_TIMEOUT) { + publish(&events, CodexEvent::Failed(format!("turn/start: {e}"))); + } + } + } + CodexCommand::InterruptTurn { + server_thread_id, + server_turn_id, + } => { + if let Some(client) = state.client.as_ref() { + let params = build_turn_interrupt_params(&server_thread_id, &server_turn_id); + if let Err(e) = client.request("turn/interrupt", params, STARTUP_RPC_TIMEOUT) { + publish(&events, CodexEvent::Failed(format!("turn/interrupt: {e}"))); + } + } + } + CodexCommand::ResolveServerRequest { request_id, result } => { + if let Some(client) = state.client.as_ref() { + if let Err(e) = client.respond(request_id, result) { + publish( + &events, + CodexEvent::Failed(format!("resolve server request: {e}")), + ); + } + } + } + CodexCommand::StartLogin(flow) => { + if let Some(client) = state.client.as_ref() { + let params = build_login_start_params(flow); + match client.request("account/login/start", params, STARTUP_RPC_TIMEOUT) { + Ok(v) => match parse_login_start_response(&v) { + Some(response) => { + let browser_open_failed = matches!(flow, LoginType::ChatGpt) + && response + .auth_url + .as_deref() + .map(|url| open_url(url).is_err()) + .unwrap_or(true); + publish( + &events, + CodexEvent::LoginStarted { + response, + flow, + browser_open_failed, + }, + ); + } + None => publish( + &events, + CodexEvent::LoginFailed { + message: "login/start missing loginId".into(), + }, + ), + }, + Err(e) => publish( + &events, + CodexEvent::LoginFailed { + message: e.to_string(), + }, + ), + } + } + } + CodexCommand::CancelLogin { login_id } => { + if let Some(client) = state.client.as_ref() { + let _ = client.request( + "account/login/cancel", + build_login_cancel_params(&login_id), + STARTUP_RPC_TIMEOUT, + ); + } + } + CodexCommand::ListThreads { cwd } => { + if let Some(client) = state.client.as_ref() { + let params = build_thread_list_params(cwd.as_deref()); + match client.request("thread/list", params, STARTUP_RPC_TIMEOUT) { + Ok(v) => publish( + &events, + CodexEvent::ThreadList(parse_thread_list_response(&v)), + ), + Err(e) => publish(&events, CodexEvent::ThreadListFailed(e.to_string())), + } + } + } + CodexCommand::ResumeThread { + local_id, + server_thread_id, + } => { + if let Some(client) = state.client.as_ref() { + let params = build_thread_resume_params(&server_thread_id); + match client.request("thread/resume", params, STARTUP_RPC_TIMEOUT) { + Ok(v) => { + let (tid, sid) = parse_thread_start_response(&v) + .unwrap_or((server_thread_id.clone(), None)); + publish( + &events, + CodexEvent::ThreadStarted { + local_id, + server_thread_id: tid, + server_session_id: sid, + }, + ); + } + Err(e) => publish( + &events, + CodexEvent::ThreadStartFailed { + local_id, + message: e.to_string(), + }, + ), + } + } else { + publish( + &events, + CodexEvent::ThreadStartFailed { + local_id, + message: "codex runtime not started".into(), + }, + ); + } + } + CodexCommand::ForkThread { + local_id, + server_thread_id, + turn_id, + } => { + if let Some(client) = state.client.as_ref() { + let params = build_thread_fork_params(&server_thread_id, turn_id.as_deref()); + match client.request("thread/fork", params, STARTUP_RPC_TIMEOUT) { + Ok(v) => match parse_thread_start_response(&v) { + Some((tid, sid)) => publish( + &events, + CodexEvent::ThreadStarted { + local_id, + server_thread_id: tid, + server_session_id: sid, + }, + ), + None => publish( + &events, + CodexEvent::ThreadStartFailed { + local_id, + message: "thread/fork response missing thread id".into(), + }, + ), + }, + Err(e) => publish( + &events, + CodexEvent::ThreadStartFailed { + local_id, + message: e.to_string(), + }, + ), + } + } + } + CodexCommand::ArchiveThread { + server_thread_id, + archive, + } => { + if let Some(client) = state.client.as_ref() { + let method = if archive { + "thread/archive" + } else { + "thread/unarchive" + }; + if let Err(e) = client.request( + method, + build_thread_archive_params(&server_thread_id), + STARTUP_RPC_TIMEOUT, + ) { + publish(&events, CodexEvent::Failed(format!("{method}: {e}"))); + } + } + } + CodexCommand::Shutdown => { + publish( + &events, + CodexEvent::StatusChanged(CodexRuntimeStatus::ShuttingDown), + ); + if let Some(client) = state.client.take() { + let _ = client.shutdown(); + } + publish( + &events, + CodexEvent::StatusChanged(CodexRuntimeStatus::Disconnected), + ); + break; + } + } + } +} + +#[derive(Default)] +struct WorkerState { + client: Option, +} + +fn start_client( + binary: &Path, + events: &Sender, +) -> Result { + let client = AppServerClient::spawn(binary.to_string_lossy().as_ref())?; + + // Pump server notifications/requests onto the event channel in their own + // thread so the worker stays responsive to commands. + let events_for_relay = events.clone(); + let server_events = client.events(); + thread::Builder::new() + .name("codex-event-relay".into()) + .spawn(move || relay_server_events(server_events, events_for_relay)) + .expect("spawn codex event relay"); + + let init_response = client.request( + "initialize", + json!({ + "clientInfo": { + "name": "quackcode", + "title": "QuackCode", + "version": env!("CARGO_PKG_VERSION"), + } + }), + STARTUP_RPC_TIMEOUT, + )?; + publish( + events, + CodexEvent::ServerInfo { + version: parse_server_version(&init_response), + }, + ); + client.notify("initialized", json!({}))?; + + match client.request( + "account/read", + json!({ "refreshToken": false }), + STARTUP_RPC_TIMEOUT, + ) { + Ok(v) => { + publish(events, CodexEvent::AccountChanged(parse_account_state(&v))); + } + Err(e) => { + publish(events, CodexEvent::Failed(format!("account/read: {e}"))); + } + } + + Ok(client) +} + +fn relay_server_events(server: Receiver, app: Sender) { + // Drain whatever has already arrived alongside each event so consecutive + // item/agentMessage/delta notifications for the same itemId get merged + // before they reach the bounded app channel. Without this, a delta burst + // pushes one bounded-channel send per token. + const MAX_BATCH: usize = 256; + while let Ok(first) = server.recv() { + let mut batch: Vec = Vec::with_capacity(8); + batch.push(translate_server_event(first)); + while batch.len() < MAX_BATCH { + match server.try_recv() { + Ok(next) => batch.push(translate_server_event(next)), + Err(_) => break, + } + } + for ev in coalesce_agent_message_deltas(batch) { + if app.send(ev).is_err() { + return; + } + } + } +} + +fn translate_server_event(event: ServerEvent) -> CodexEvent { + match event { + ServerEvent::Notification { method, params } => { + CodexEvent::ServerNotification { method, params } + } + ServerEvent::Request { id, method, params } => CodexEvent::ServerRequest { + request_id: id, + method, + params, + }, + } +} + +/// Standalone helper for batch coalescing — used by the unit tests below to +/// verify the merge semantics that a buffered coalescer will eventually own. +pub(crate) fn coalesce_agent_message_deltas(events: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(events.len()); + // For each itemId, the index in `out` of the merged delta (if any). + let mut merge_index: HashMap = HashMap::new(); + for ev in events { + match &ev { + CodexEvent::ServerNotification { method, params } + if method == "item/agentMessage/delta" => + { + let item_id = params + .get("itemId") + .and_then(|v| v.as_str()) + .map(String::from); + let delta = params.get("delta").and_then(|v| v.as_str()).unwrap_or(""); + match item_id + .as_deref() + .and_then(|id| merge_index.get(id).copied()) + { + Some(existing_index) => { + if let CodexEvent::ServerNotification { params, .. } = + &mut out[existing_index] + { + if let Some(prev) = params + .get("delta") + .and_then(|v| v.as_str()) + .map(String::from) + { + params["delta"] = Value::String(format!("{prev}{delta}")); + } + } + } + None => { + if let Some(id) = item_id { + merge_index.insert(id, out.len()); + } + out.push(ev); + } + } + } + _ => out.push(ev), + } + } + out +} + +/// Builds the params object for `thread/start`. Kept pure so it can be +/// unit-tested without spawning a child process. +pub(crate) fn build_thread_start_params(cwd: &Path, sandbox: SandboxMode) -> Value { + json!({ + "cwd": cwd.to_string_lossy(), + "sandbox": sandbox, + "serviceName": "quackcode", + }) +} + +/// Builds the params for `turn/start`. The wire field is `input` (spec writes +/// `items` but the live app-server rejects it — see implementation notes). +pub(crate) fn build_turn_start_params( + server_thread_id: &str, + text: &str, + images: &[PathBuf], +) -> Value { + let mut input = Vec::new(); + if !text.trim().is_empty() { + input.push(json!({"type": "text", "text": text})); + } + for path in images { + input.push(json!({"type": "localImage", "path": path.to_string_lossy()})); + } + json!({ + "threadId": server_thread_id, + "input": input, + }) +} + +pub(crate) fn build_turn_interrupt_params(server_thread_id: &str, server_turn_id: &str) -> Value { + json!({ + "threadId": server_thread_id, + "turnId": server_turn_id, + }) +} + +/// Extracts `(server_thread_id, server_session_id)` from a `thread/start` +/// response. Tolerates both the nested `{ thread: { id, sessionId } }` and the +/// flat `{ threadId, sessionId }` shapes — the README isn't explicit and +/// future versions may shift between them. +pub(crate) fn parse_thread_start_response(v: &Value) -> Option<(String, Option)> { + if let Some(thread) = v.get("thread") { + let id = thread.get("id").and_then(|v| v.as_str())?.to_string(); + let session = thread + .get("sessionId") + .and_then(|v| v.as_str()) + .map(String::from); + return Some((id, session)); + } + let id = v.get("threadId").and_then(|v| v.as_str())?.to_string(); + let session = v + .get("sessionId") + .and_then(|v| v.as_str()) + .map(String::from); + Some((id, session)) +} + +/// Builds the params for `thread/archive` / `thread/unarchive`. Server emits +/// `thread/archived` / `thread/unarchived` notifications which the reducer +/// already handles. +pub(crate) fn build_thread_archive_params(server_thread_id: &str) -> Value { + json!({ "threadId": server_thread_id }) +} + +/// Extracts the `serverInfo.version` string from an `initialize` response. +/// JSON-RPC's `serverInfo` is conventional; codex emits `{ name, version }`. +pub(crate) fn parse_server_version(initialize_response: &Value) -> Option { + initialize_response + .get("serverInfo") + .and_then(|v| v.get("version")) + .and_then(|v| v.as_str()) + .map(String::from) +} + +/// Returns the `threadId` carried by a notification's `params`, looking in +/// both the flat `threadId` field and the nested `thread.id` field. +pub(crate) fn notification_thread_id(params: &Value) -> Option<&str> { + if let Some(s) = params.get("threadId").and_then(|v| v.as_str()) { + return Some(s); + } + params + .get("thread") + .and_then(|t| t.get("id")) + .and_then(|v| v.as_str()) +} + +fn publish(events: &Sender, event: CodexEvent) { + let _ = events.send(event); +} + +/// Best-effort platform browser opener. On Linux we use `xdg-open`; the spec +/// (§ Auth) notes the device-code fallback exists precisely for the case +/// where this fails (no display, sandboxed env, missing binary). +fn open_url(url: &str) -> Result<(), std::io::Error> { + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open") + .arg(url) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map(|_| ()) + } + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg(url) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map(|_| ()) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = url; + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "unsupported platform browser opener", + )) + } +} + +pub(crate) fn parse_account_state(payload: &Value) -> CodexAccountState { + let requires_auth = payload + .get("requiresOpenaiAuth") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let account = payload.get("account"); + match account { + Some(a) if !a.is_null() => match a.get("type").and_then(|v| v.as_str()) { + Some("chatgpt") => CodexAccountState::ChatGpt { + email: a.get("email").and_then(|v| v.as_str()).unwrap_or("").into(), + plan_type: a.get("planType").and_then(|v| v.as_str()).map(String::from), + }, + Some("apiKey") => CodexAccountState::ApiKey, + _ => CodexAccountState::Unknown, + }, + _ => { + if requires_auth { + CodexAccountState::SignedOutRequiresAuth + } else { + CodexAccountState::NoAuthRequired + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn discover_binary_prefers_override() { + let p = discover_binary(Some(PathBuf::from("/custom/codex"))); + assert_eq!(p, PathBuf::from("/custom/codex")); + } + + #[test] + fn discover_binary_falls_back_to_default() { + // Avoid touching the user's actual env: just confirm the default + // path appears when no override is given. We don't unset CODEX_BINARY + // here because env mutation in tests is racy; the override-wins case + // is covered above and is the primary contract. + let p = discover_binary(None); + assert!(p == Path::new(DEFAULT_CODEX_BINARY) || std::env::var("CODEX_BINARY").is_ok()); + } + + #[test] + fn parse_account_signed_out_when_no_account_and_auth_required() { + let payload = json!({ "account": null, "requiresOpenaiAuth": true }); + assert_eq!( + parse_account_state(&payload), + CodexAccountState::SignedOutRequiresAuth + ); + } + + #[test] + fn parse_account_no_auth_required() { + let payload = json!({ "account": null, "requiresOpenaiAuth": false }); + assert_eq!( + parse_account_state(&payload), + CodexAccountState::NoAuthRequired + ); + } + + #[test] + fn parse_account_chatgpt_extracts_email_and_plan() { + let payload = json!({ + "account": { "type": "chatgpt", "email": "u@example.com", "planType": "plus" }, + "requiresOpenaiAuth": true + }); + assert_eq!( + parse_account_state(&payload), + CodexAccountState::ChatGpt { + email: "u@example.com".into(), + plan_type: Some("plus".into()), + } + ); + } + + #[test] + fn parse_account_api_key() { + let payload = json!({ "account": { "type": "apiKey" }, "requiresOpenaiAuth": false }); + assert_eq!(parse_account_state(&payload), CodexAccountState::ApiKey); + } + + #[test] + fn coalesce_merges_consecutive_deltas_for_same_item() { + let events = vec![ + CodexEvent::ServerNotification { + method: "item/agentMessage/delta".into(), + params: json!({ "itemId": "msg_1", "delta": "Hel" }), + }, + CodexEvent::ServerNotification { + method: "item/agentMessage/delta".into(), + params: json!({ "itemId": "msg_1", "delta": "lo" }), + }, + ]; + let coalesced = coalesce_agent_message_deltas(events); + assert_eq!(coalesced.len(), 1); + match &coalesced[0] { + CodexEvent::ServerNotification { params, .. } => { + assert_eq!(params["delta"], "Hello"); + } + other => panic!("expected merged delta, got {other:?}"), + } + } + + #[test] + fn coalesce_keeps_distinct_item_ids_separate() { + let events = vec![ + CodexEvent::ServerNotification { + method: "item/agentMessage/delta".into(), + params: json!({ "itemId": "a", "delta": "A1" }), + }, + CodexEvent::ServerNotification { + method: "item/agentMessage/delta".into(), + params: json!({ "itemId": "b", "delta": "B1" }), + }, + CodexEvent::ServerNotification { + method: "item/agentMessage/delta".into(), + params: json!({ "itemId": "a", "delta": "A2" }), + }, + ]; + let coalesced = coalesce_agent_message_deltas(events); + assert_eq!(coalesced.len(), 2); + // First entry is the merged "a"; second is the standalone "b". + match &coalesced[0] { + CodexEvent::ServerNotification { params, .. } => assert_eq!(params["delta"], "A1A2"), + _ => panic!(), + } + match &coalesced[1] { + CodexEvent::ServerNotification { params, .. } => assert_eq!(params["delta"], "B1"), + _ => panic!(), + } + } + + #[test] + fn coalesce_passes_through_non_delta_events() { + let events = vec![ + CodexEvent::ServerNotification { + method: "thread/started".into(), + params: json!({}), + }, + CodexEvent::StatusChanged(CodexRuntimeStatus::Ready), + ]; + let coalesced = coalesce_agent_message_deltas(events); + assert_eq!(coalesced.len(), 2); + } + + #[test] + fn build_thread_start_params_uses_kebab_sandbox() { + let p = build_thread_start_params(&PathBuf::from("/tmp/repo"), SandboxMode::WorkspaceWrite); + assert_eq!(p["cwd"], "/tmp/repo"); + assert_eq!(p["sandbox"], "workspace-write"); + assert_eq!(p["serviceName"], "quackcode"); + } + + #[test] + fn build_turn_start_params_wraps_text_input() { + // Spec writes `items`, but the live wire wants `input`. Lock that in. + let p = build_turn_start_params("thr_X", "hello", &[]); + assert_eq!(p["threadId"], "thr_X"); + let input = p["input"].as_array().expect("input array"); + assert_eq!(input.len(), 1); + assert_eq!(input[0]["type"], "text"); + assert_eq!(input[0]["text"], "hello"); + assert!( + p.get("items").is_none(), + "must not send the spec-form `items`" + ); + } + + #[test] + fn build_turn_start_params_includes_local_images() { + let p = + build_turn_start_params("thr_X", "see this", &[PathBuf::from("/tmp/screenshot.png")]); + let input = p["input"].as_array().expect("input array"); + assert_eq!(input.len(), 2); + assert_eq!(input[1]["type"], "localImage"); + assert_eq!(input[1]["path"], "/tmp/screenshot.png"); + } + + #[test] + fn build_turn_interrupt_params_includes_thread_and_turn() { + let p = build_turn_interrupt_params("thr_X", "turn_Y"); + assert_eq!(p["threadId"], "thr_X"); + assert_eq!(p["turnId"], "turn_Y"); + } + + #[test] + fn parse_thread_start_response_nested_shape() { + let v = json!({ + "thread": { "id": "thr_1", "sessionId": "ses_1" } + }); + let (tid, sid) = parse_thread_start_response(&v).unwrap(); + assert_eq!(tid, "thr_1"); + assert_eq!(sid.as_deref(), Some("ses_1")); + } + + #[test] + fn parse_thread_start_response_flat_shape() { + let v = json!({ "threadId": "thr_2", "sessionId": "ses_2" }); + let (tid, sid) = parse_thread_start_response(&v).unwrap(); + assert_eq!(tid, "thr_2"); + assert_eq!(sid.as_deref(), Some("ses_2")); + } + + #[test] + fn parse_thread_start_response_missing_id_returns_none() { + assert!(parse_thread_start_response(&json!({})).is_none()); + } + + #[test] + fn notification_thread_id_finds_flat_or_nested() { + assert_eq!( + notification_thread_id(&json!({ "threadId": "a" })), + Some("a"), + ); + assert_eq!( + notification_thread_id(&json!({ "thread": { "id": "b" } })), + Some("b"), + ); + assert!(notification_thread_id(&json!({})).is_none()); + } + + #[test] + fn build_thread_archive_params_round_trip() { + assert_eq!(build_thread_archive_params("thr_X")["threadId"], "thr_X"); + } + + #[test] + fn parse_server_version_picks_up_nested_version() { + let r = json!({ + "serverInfo": { "name": "codex-app-server", "version": "0.133.0" } + }); + assert_eq!(parse_server_version(&r).as_deref(), Some("0.133.0")); + } + + #[test] + fn parse_server_version_missing_returns_none() { + assert!(parse_server_version(&json!({})).is_none()); + assert!(parse_server_version(&json!({ "serverInfo": {} })).is_none()); + } + + #[test] + fn status_label_renders_failure_message() { + let s = CodexRuntimeStatus::Failed("boom".into()); + assert_eq!(s.label(), "Codex: failed — boom"); + } + + /// End-to-end integration test for Phase 1's runtime contract: spawn the + /// handle, send `EnsureStarted`, observe Status / Account events, then + /// shut down cleanly. + #[test] + #[ignore = "requires `codex` on PATH and an authenticated session"] + fn runtime_ensure_started_round_trip() { + let (event_tx, event_rx) = new_event_channel(); + let handle = CodexRuntimeHandle::spawn(PathBuf::from(DEFAULT_CODEX_BINARY), event_tx); + handle.send(CodexCommand::EnsureStarted); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + let mut saw_starting = false; + let mut saw_ready = false; + let mut account_observed = None; + while std::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + match event_rx.recv_timeout(remaining) { + Ok(CodexEvent::StatusChanged(CodexRuntimeStatus::Starting)) => saw_starting = true, + Ok(CodexEvent::StatusChanged(CodexRuntimeStatus::Ready)) => { + saw_ready = true; + if account_observed.is_some() { + break; + } + } + Ok(CodexEvent::AccountChanged(state)) => { + account_observed = Some(state); + if saw_ready { + break; + } + } + Ok(CodexEvent::Failed(msg)) => panic!("runtime failed: {msg}"), + Ok(_) => {} + Err(_) => break, + } + } + handle.send(CodexCommand::Shutdown); + // Drain the remaining shutdown events so the worker can exit cleanly. + while event_rx + .recv_timeout(std::time::Duration::from_millis(500)) + .is_ok() + {} + + assert!(saw_starting, "expected Status::Starting event"); + assert!(saw_ready, "expected Status::Ready event"); + assert!( + account_observed.is_some(), + "expected AccountChanged event during EnsureStarted" + ); + } +} diff --git a/src/app/codex/schema.rs b/src/app/codex/schema.rs new file mode 100644 index 0000000..9777705 --- /dev/null +++ b/src/app/codex/schema.rs @@ -0,0 +1,181 @@ +//! Hand-written serde structs / enums for the stable Codex app-server subset +//! that QuackCode actually consumes. We deliberately do not consume the full +//! generated schema; everything here is the narrow surface the runtime, +//! thread reducer, and UI need. +//! +//! The wire enum values are kebab-case (sandbox: `workspace-write`) or +//! camelCase (status types: `notLoaded`, `systemError`) depending on field — +//! both forms appear in the live app-server (codex-cli 0.133.0). + +use serde::{Deserialize, Serialize}; + +/// Server-side thread status enum. Matches the wire `thread.status.type`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum ServerThreadStatus { + NotLoaded, + Idle, + Active, + SystemError, +} + +/// Local UI status for a [`crate::app::codex::CodexThread`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LocalThreadStatus { + /// Local record exists before `thread/start` has returned. + Draft, + /// Server thread exists and is not running a turn. + Loaded, + /// Active turn in progress. + Running, + /// Server is blocked on a user decision (overlay on top of Loaded/Running). + WaitingForApproval, + /// Last turn failed (server reported `systemError`). + Failed, + /// App-server died, connection lost, or server unloaded an idle thread. + Disconnected, + /// Hidden from normal sidebar view. + Archived, +} + +/// Map a server status onto the local UI status. Per spec § Data Model, +/// `WaitingForApproval` is derived from pending-approval state rather than a +/// dedicated server value — the server stays `active` while waiting. +pub(crate) fn map_status( + server: ServerThreadStatus, + has_pending_approvals: bool, + archived: bool, +) -> LocalThreadStatus { + if archived { + return LocalThreadStatus::Archived; + } + match server { + ServerThreadStatus::NotLoaded => LocalThreadStatus::Disconnected, + ServerThreadStatus::SystemError => LocalThreadStatus::Failed, + ServerThreadStatus::Idle => { + if has_pending_approvals { + LocalThreadStatus::WaitingForApproval + } else { + LocalThreadStatus::Loaded + } + } + ServerThreadStatus::Active => { + if has_pending_approvals { + LocalThreadStatus::WaitingForApproval + } else { + LocalThreadStatus::Running + } + } + } +} + +/// Sandbox mode names as they appear on the wire. Note the spec writes +/// `workspaceWrite` (camelCase) but the actual app-server enum is kebab-case. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum SandboxMode { + ReadOnly, + #[default] + WorkspaceWrite, + DangerFullAccess, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn server_status_deserializes_from_camel_case() { + assert_eq!( + serde_json::from_str::("\"notLoaded\"").unwrap(), + ServerThreadStatus::NotLoaded + ); + assert_eq!( + serde_json::from_str::("\"systemError\"").unwrap(), + ServerThreadStatus::SystemError + ); + assert_eq!( + serde_json::from_str::("\"idle\"").unwrap(), + ServerThreadStatus::Idle + ); + assert_eq!( + serde_json::from_str::("\"active\"").unwrap(), + ServerThreadStatus::Active + ); + } + + #[test] + fn sandbox_mode_serializes_kebab_case() { + // Matches the live app-server's accepted values. + assert_eq!( + serde_json::to_string(&SandboxMode::WorkspaceWrite).unwrap(), + "\"workspace-write\"" + ); + assert_eq!( + serde_json::to_string(&SandboxMode::ReadOnly).unwrap(), + "\"read-only\"" + ); + } + + #[test] + fn map_status_idle_no_approvals_is_loaded() { + assert_eq!( + map_status(ServerThreadStatus::Idle, false, false), + LocalThreadStatus::Loaded + ); + } + + #[test] + fn map_status_active_no_approvals_is_running() { + assert_eq!( + map_status(ServerThreadStatus::Active, false, false), + LocalThreadStatus::Running + ); + } + + #[test] + fn map_status_idle_with_pending_approval_overlay() { + assert_eq!( + map_status(ServerThreadStatus::Idle, true, false), + LocalThreadStatus::WaitingForApproval + ); + } + + #[test] + fn map_status_active_with_pending_approval_overlay() { + // Per spec: server stays `active` while waiting on a decision; pending + // approvals lift it into WaitingForApproval. + assert_eq!( + map_status(ServerThreadStatus::Active, true, false), + LocalThreadStatus::WaitingForApproval + ); + } + + #[test] + fn map_status_not_loaded_is_disconnected() { + assert_eq!( + map_status(ServerThreadStatus::NotLoaded, false, false), + LocalThreadStatus::Disconnected + ); + } + + #[test] + fn map_status_system_error_is_failed() { + assert_eq!( + map_status(ServerThreadStatus::SystemError, false, false), + LocalThreadStatus::Failed + ); + } + + #[test] + fn map_status_archived_overrides_everything() { + assert_eq!( + map_status(ServerThreadStatus::Active, false, true), + LocalThreadStatus::Archived + ); + assert_eq!( + map_status(ServerThreadStatus::Idle, true, true), + LocalThreadStatus::Archived + ); + } +} diff --git a/src/app/codex/thread.rs b/src/app/codex/thread.rs new file mode 100644 index 0000000..f89d708 --- /dev/null +++ b/src/app/codex/thread.rs @@ -0,0 +1,1038 @@ +//! Codex thread and turn state on the app side, plus the reducer that applies +//! incoming notifications. Kept GPUI-free so the reducer is unit-testable. + +use std::path::PathBuf; + +use serde_json::Value; + +use crate::app::codex::approval::CodexApproval; +use crate::app::codex::schema::{map_status, LocalThreadStatus, ServerThreadStatus}; +use crate::app::config::ProjectId; + +pub(crate) type CodexThreadLocalId = u64; +pub(crate) type CodexTurnLocalId = u64; + +/// Title shown for a brand-new thread before the user (or the server) has +/// given it a real name. The auto-naming logic checks against this value to +/// decide whether to overwrite the title from the first user message. +pub(crate) const DEFAULT_THREAD_TITLE: &str = "New thread"; +const MAX_AUTO_TITLE_CHARS: usize = 48; + +/// Derives a short single-line title from a user message. Returns `None` if +/// the message has no usable text. Used to auto-name threads whose title is +/// still the default `New thread` placeholder. +pub(crate) fn derive_title_from_user_message(text: &str) -> Option { + let cleaned: String = text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" "); + let cleaned = cleaned.trim(); + if cleaned.is_empty() { + return None; + } + let mut out: String = cleaned.chars().take(MAX_AUTO_TITLE_CHARS).collect(); + if cleaned.chars().count() > MAX_AUTO_TITLE_CHARS { + // Try to break at the last word boundary to avoid mid-word truncation. + if let Some(idx) = out.rfind(char::is_whitespace) { + if idx > MAX_AUTO_TITLE_CHARS / 2 { + out.truncate(idx); + } + } + out.push('…'); + } + Some(out) +} + +/// One row from `thread/list`. Trimmed to the subset the resume UI uses. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CodexThreadSummary { + pub(crate) thread_id: String, + pub(crate) session_id: Option, + pub(crate) title: String, + pub(crate) cwd: Option, + pub(crate) updated_at_ms: Option, +} + +/// Builds the params for `thread/list`. If `cwd` is given the server filters +/// to threads rooted at that directory (matches spec § Resume A Thread). +pub(crate) fn build_thread_list_params(cwd: Option<&std::path::Path>) -> Value { + match cwd { + Some(c) => serde_json::json!({ "cwd": c.to_string_lossy() }), + None => serde_json::json!({}), + } +} + +/// Parses a `thread/list` response. The wire shape is `{ threads: [...] }`; +/// each entry can be either `{ id, sessionId, title, cwd, updatedAtMs }` or +/// the nested `{ thread: { id, ... } }` form — we tolerate both since the +/// spec's example isn't authoritative for codex 0.133.0. +pub(crate) fn parse_thread_list_response(v: &Value) -> Vec { + let arr = match v.get("threads").and_then(|t| t.as_array()) { + Some(a) => a, + None => return Vec::new(), + }; + let mut out = Vec::with_capacity(arr.len()); + for entry in arr { + let view = entry.get("thread").unwrap_or(entry); + let Some(thread_id) = view.get("id").and_then(|s| s.as_str()) else { + continue; + }; + out.push(CodexThreadSummary { + thread_id: thread_id.to_string(), + session_id: view + .get("sessionId") + .and_then(|s| s.as_str()) + .map(String::from), + title: view + .get("title") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from) + .unwrap_or_else(|| thread_id.to_string()), + cwd: view.get("cwd").and_then(|s| s.as_str()).map(String::from), + updated_at_ms: view + .get("updatedAtMs") + .and_then(|s| s.as_i64()) + .or_else(|| view.get("updatedAt").and_then(|s| s.as_i64())), + }); + } + out +} + +/// Builds the params for `thread/resume`. Server returns the canonical +/// thread plus (optionally) prior turns. +pub(crate) fn build_thread_resume_params(thread_id: &str) -> Value { + serde_json::json!({ "threadId": thread_id }) +} + +/// Builds the params for `thread/fork`. `turn_id` is optional — when present, +/// the fork branches at that turn rather than the tail. +pub(crate) fn build_thread_fork_params(thread_id: &str, turn_id: Option<&str>) -> Value { + let mut v = serde_json::json!({ "threadId": thread_id }); + if let Some(t) = turn_id { + v["turnId"] = Value::String(t.to_string()); + } + v +} + +#[derive(Debug, Clone)] +pub(crate) struct CodexThread { + pub(crate) local_id: CodexThreadLocalId, + pub(crate) server_thread_id: Option, + pub(crate) server_session_id: Option, + pub(crate) project_id: ProjectId, + pub(crate) title: String, + pub(crate) cwd: PathBuf, + pub(crate) status: LocalThreadStatus, + pub(crate) turns: Vec, + pub(crate) pending_approvals: Vec, + pub(crate) unread: bool, + pub(crate) updated_at_ms: Option, + /// Tracks whether the thread is currently archived. The server-status + /// mapping uses this as an override. + pub(crate) archived: bool, +} + +impl CodexThread { + pub(crate) fn draft(local_id: CodexThreadLocalId, project_id: ProjectId, cwd: PathBuf) -> Self { + Self { + local_id, + server_thread_id: None, + server_session_id: None, + project_id, + title: String::from(DEFAULT_THREAD_TITLE), + cwd, + status: LocalThreadStatus::Draft, + turns: Vec::new(), + pending_approvals: Vec::new(), + unread: false, + updated_at_ms: None, + archived: false, + } + } + + pub(crate) fn recompute_status(&mut self, server: ServerThreadStatus) { + self.status = map_status(server, !self.pending_approvals.is_empty(), self.archived); + } +} + +#[derive(Debug, Clone)] +pub(crate) struct CodexTurn { + pub(crate) local_id: CodexTurnLocalId, + pub(crate) server_turn_id: Option, + pub(crate) items: Vec, + pub(crate) status: TurnStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TurnStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Debug, Clone)] +pub(crate) struct CodexItem { + pub(crate) server_item_id: String, + pub(crate) kind: CodexItemKind, + pub(crate) text: String, + pub(crate) completed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CodexItemKind { + UserMessage, + AgentMessage, + Reasoning, + CommandExecution, + FileChange, + /// Anything the v1 reducer doesn't have a dedicated variant for. + Other(String), +} + +impl CodexItemKind { + pub(crate) fn from_wire(s: &str) -> Self { + match s { + "userMessage" => CodexItemKind::UserMessage, + "agentMessage" => CodexItemKind::AgentMessage, + "reasoning" => CodexItemKind::Reasoning, + "commandExecution" => CodexItemKind::CommandExecution, + "fileChange" => CodexItemKind::FileChange, + other => CodexItemKind::Other(other.into()), + } + } +} + +/// Applies a single app-server notification to a thread. Returns true if the +/// thread state changed in a way the UI should re-render. +pub(crate) fn apply_notification(thread: &mut CodexThread, method: &str, params: &Value) -> bool { + match method { + "thread/started" => { + // Confirm the server thread id if we didn't have it yet. + if let Some(id) = params + .get("thread") + .and_then(|t| t.get("id")) + .and_then(|v| v.as_str()) + { + thread.server_thread_id = Some(id.into()); + } + if let Some(id) = params + .get("thread") + .and_then(|t| t.get("sessionId")) + .and_then(|v| v.as_str()) + { + thread.server_session_id = Some(id.into()); + } + if let Some(status) = params + .get("thread") + .and_then(|t| t.get("status")) + .and_then(|s| s.get("type")) + .and_then(|v| v.as_str()) + .and_then(parse_server_status) + { + thread.recompute_status(status); + } + true + } + "thread/status/changed" => { + if let Some(status) = params + .get("status") + .and_then(|s| s.get("type")) + .and_then(|v| v.as_str()) + .and_then(parse_server_status) + { + thread.recompute_status(status); + return true; + } + false + } + "thread/name/updated" => { + if let Some(name) = params.get("name").and_then(|v| v.as_str()) { + thread.title = name.into(); + return true; + } + false + } + "thread/archived" => { + thread.archived = true; + thread.status = LocalThreadStatus::Archived; + true + } + "thread/unarchived" => { + thread.archived = false; + // Caller usually follows up with thread/status/changed; default + // to Loaded so the thread re-appears even without one. + thread.recompute_status(ServerThreadStatus::Idle); + true + } + "thread/closed" => { + thread.recompute_status(ServerThreadStatus::NotLoaded); + true + } + "turn/started" => { + let server_turn_id = params + .get("turn") + .and_then(|t| t.get("id")) + .and_then(|v| v.as_str()) + .map(String::from); + thread.turns.push(CodexTurn { + local_id: (thread.turns.len() as u64) + 1, + server_turn_id, + items: Vec::new(), + status: TurnStatus::InProgress, + }); + true + } + "turn/completed" => { + if let Some(turn) = thread.turns.last_mut() { + turn.status = TurnStatus::Completed; + return true; + } + false + } + "item/started" | "item/completed" => apply_item_event(thread, method, params), + "item/agentMessage/delta" => apply_agent_message_delta(thread, params), + "item/commandExecution/outputDelta" => apply_command_execution_output_delta(thread, params), + "item/reasoning/summaryTextDelta" => apply_reasoning_delta(thread, params, ""), + "item/reasoning/summaryPartAdded" => apply_reasoning_delta(thread, params, "\n\n"), + // Unknown / unmodeled notification — caller logs. + _ => false, + } +} + +fn apply_item_event(thread: &mut CodexThread, method: &str, params: &Value) -> bool { + let item_obj = match params.get("item") { + Some(v) => v, + None => return false, + }; + let server_item_id = match item_obj.get("id").and_then(|v| v.as_str()) { + Some(id) => id.to_string(), + None => return false, + }; + let kind = item_obj + .get("type") + .and_then(|v| v.as_str()) + .map(CodexItemKind::from_wire) + .unwrap_or_else(|| CodexItemKind::Other(String::new())); + let text = extract_item_text(item_obj, &kind); + let completed = method == "item/completed"; + + let auto_title = + if matches!(kind, CodexItemKind::UserMessage) && thread.title == DEFAULT_THREAD_TITLE { + derive_title_from_user_message(&text) + } else { + None + }; + + let turn = match thread.turns.last_mut() { + Some(t) => t, + None => return false, + }; + + // Reasoning summaries are a transient "thinking…" indicator — when the + // server marks the item complete, drop it from the transcript so the + // user only sees the agent's actual answer afterward. + if completed && matches!(kind, CodexItemKind::Reasoning) { + let before = turn.items.len(); + turn.items.retain(|i| i.server_item_id != server_item_id); + return turn.items.len() != before; + } + + if let Some(item) = turn + .items + .iter_mut() + .find(|i| i.server_item_id == server_item_id) + { + // Authoritative replace on completion (spec rule). + if completed { + item.text = text; + item.kind = kind; + item.completed = true; + } else { + item.kind = kind; + // Keep delta-accumulated text if non-empty; else use server's. + if item.text.is_empty() { + item.text = text; + } + } + } else { + turn.items.push(CodexItem { + server_item_id, + kind, + text, + completed, + }); + } + if let Some(title) = auto_title { + thread.title = title; + } + true +} + +fn apply_reasoning_delta(thread: &mut CodexThread, params: &Value, prefix: &str) -> bool { + let item_id = match params.get("itemId").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return false, + }; + let delta = params.get("delta").and_then(|v| v.as_str()).unwrap_or(""); + let turn = match thread.turns.last_mut() { + Some(t) => t, + None => return false, + }; + if let Some(item) = turn.items.iter_mut().find(|i| i.server_item_id == item_id) { + if !prefix.is_empty() && !item.text.is_empty() { + item.text.push_str(prefix); + } + item.text.push_str(delta); + } else { + turn.items.push(CodexItem { + server_item_id: item_id, + kind: CodexItemKind::Reasoning, + text: delta.into(), + completed: false, + }); + } + true +} + +fn apply_agent_message_delta(thread: &mut CodexThread, params: &Value) -> bool { + let item_id = match params.get("itemId").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return false, + }; + let delta = params.get("delta").and_then(|v| v.as_str()).unwrap_or(""); + let turn = match thread.turns.last_mut() { + Some(t) => t, + None => return false, + }; + if let Some(item) = turn.items.iter_mut().find(|i| i.server_item_id == item_id) { + item.text.push_str(delta); + } else { + turn.items.push(CodexItem { + server_item_id: item_id, + kind: CodexItemKind::AgentMessage, + text: delta.into(), + completed: false, + }); + } + true +} + +fn apply_command_execution_output_delta(thread: &mut CodexThread, params: &Value) -> bool { + let item_id = match params.get("itemId").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return false, + }; + let delta = params + .get("delta") + .or_else(|| params.get("output")) + .or_else(|| params.get("stdout")) + .or_else(|| params.get("stderr")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let turn = match thread.turns.last_mut() { + Some(t) => t, + None => return false, + }; + if let Some(item) = turn.items.iter_mut().find(|i| i.server_item_id == item_id) { + if item.text.is_empty() { + item.text = extract_command_execution_text(params); + } + item.text.push_str(delta); + } else { + let mut text = extract_command_execution_text(params); + if text.is_empty() { + text = delta.into(); + } else if !delta.is_empty() && !text.ends_with(delta) { + if !text.ends_with('\n') { + text.push('\n'); + } + text.push_str(delta); + } + turn.items.push(CodexItem { + server_item_id: item_id, + kind: CodexItemKind::CommandExecution, + text, + completed: false, + }); + } + true +} + +fn extract_item_text(item: &Value, kind: &CodexItemKind) -> String { + match kind { + CodexItemKind::CommandExecution => extract_command_execution_text(item), + CodexItemKind::FileChange => extract_file_change_text(item), + CodexItemKind::Reasoning => extract_reasoning_text(item), + _ => extract_generic_text(item), + } +} + +fn extract_generic_text(item: &Value) -> String { + // Agent message style: { text: "..." } + if let Some(s) = item.get("text").and_then(|v| v.as_str()) { + return s.into(); + } + // User message style: { content: [{ type: "text", text: "..." }, ...] } + if let Some(arr) = item.get("content").and_then(|v| v.as_array()) { + let mut acc = String::new(); + for entry in arr { + if let Some(t) = entry.get("text").and_then(|v| v.as_str()) { + acc.push_str(t); + } + } + return acc; + } + String::new() +} + +/// Builds a multi-line summary for a `commandExecution` item. Wire fields +/// vary across codex versions, so we probe defensively: command can be a +/// string or an argv array; exit code lives at `exitCode` or +/// `exitStatus.code`; output lives at `stdout` / `stderr` / `output`. +fn extract_command_execution_text(item: &Value) -> String { + let mut lines: Vec = Vec::new(); + if let Some(cmd) = command_string(item) { + lines.push(format!("$ {cmd}")); + } + if let Some(cwd) = item.get("cwd").and_then(|v| v.as_str()) { + lines.push(format!("cwd: {cwd}")); + } + if let Some(code) = item.get("exitCode").and_then(|v| v.as_i64()).or_else(|| { + item.get("exitStatus") + .and_then(|s| s.get("code")) + .and_then(|v| v.as_i64()) + }) { + lines.push(format!("exit: {code}")); + } + for field in ["stdout", "output", "stderr"] { + if let Some(s) = item.get(field).and_then(|v| v.as_str()) { + if !s.is_empty() { + lines.push(s.into()); + } + } + } + lines.join("\n") +} + +fn command_string(item: &Value) -> Option { + if let Some(s) = item.get("command").and_then(|v| v.as_str()) { + return Some(s.into()); + } + if let Some(arr) = item.get("command").and_then(|v| v.as_array()) { + let parts: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect(); + if !parts.is_empty() { + return Some(parts.join(" ")); + } + } + None +} + +/// Builds a summary for a `fileChange` item: the affected paths followed by +/// the unified diff if the server provided one. +fn extract_file_change_text(item: &Value) -> String { + let mut lines: Vec = Vec::new(); + if let Some(arr) = item.get("paths").and_then(|v| v.as_array()) { + for p in arr.iter().filter_map(|v| v.as_str()) { + lines.push(p.into()); + } + } else if let Some(s) = item.get("path").and_then(|v| v.as_str()) { + lines.push(s.into()); + } + if let Some(diff) = item.get("unifiedDiff").and_then(|v| v.as_str()) { + if !diff.is_empty() { + if !lines.is_empty() { + lines.push(String::new()); + } + lines.push(diff.into()); + } + } else if let Some(diff) = item.get("diff").and_then(|v| v.as_str()) { + if !diff.is_empty() { + if !lines.is_empty() { + lines.push(String::new()); + } + lines.push(diff.into()); + } + } + lines.join("\n") +} + +/// Reasoning items carry their summary either inline (`summary` string) or +/// as a parts array (`parts: [{ text: "..." }]`). Empty when neither is set +/// — deltas will populate it later. +fn extract_reasoning_text(item: &Value) -> String { + if let Some(s) = item.get("summary").and_then(|v| v.as_str()) { + return s.into(); + } + if let Some(arr) = item.get("parts").and_then(|v| v.as_array()) { + let mut acc = String::new(); + for (i, entry) in arr.iter().enumerate() { + let part = entry + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + if i > 0 && !part.is_empty() { + acc.push_str("\n\n"); + } + acc.push_str(part); + } + return acc; + } + String::new() +} + +fn parse_server_status(s: &str) -> Option { + match s { + "notLoaded" => Some(ServerThreadStatus::NotLoaded), + "idle" => Some(ServerThreadStatus::Idle), + "active" => Some(ServerThreadStatus::Active), + "systemError" => Some(ServerThreadStatus::SystemError), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn fresh_thread() -> CodexThread { + CodexThread::draft(1, 7, PathBuf::from("/tmp/repo")) + } + + #[test] + fn thread_started_records_server_ids_and_status() { + let mut t = fresh_thread(); + let p = json!({ + "thread": { + "id": "thr_1", + "sessionId": "ses_1", + "status": { "type": "idle" } + } + }); + assert!(apply_notification(&mut t, "thread/started", &p)); + assert_eq!(t.server_thread_id.as_deref(), Some("thr_1")); + assert_eq!(t.server_session_id.as_deref(), Some("ses_1")); + assert_eq!(t.status, LocalThreadStatus::Loaded); + } + + #[test] + fn thread_status_changed_drives_local_status() { + let mut t = fresh_thread(); + let p = json!({ "status": { "type": "active" } }); + assert!(apply_notification(&mut t, "thread/status/changed", &p)); + assert_eq!(t.status, LocalThreadStatus::Running); + + let p = json!({ "status": { "type": "idle" } }); + apply_notification(&mut t, "thread/status/changed", &p); + assert_eq!(t.status, LocalThreadStatus::Loaded); + } + + #[test] + fn turn_started_appends_in_progress_turn() { + let mut t = fresh_thread(); + let p = json!({ "turn": { "id": "turn_1" } }); + apply_notification(&mut t, "turn/started", &p); + assert_eq!(t.turns.len(), 1); + assert_eq!(t.turns[0].server_turn_id.as_deref(), Some("turn_1")); + assert_eq!(t.turns[0].status, TurnStatus::InProgress); + } + + #[test] + fn turn_completed_marks_last_turn_completed() { + let mut t = fresh_thread(); + apply_notification( + &mut t, + "turn/started", + &json!({ "turn": { "id": "turn_1" } }), + ); + apply_notification(&mut t, "turn/completed", &json!({})); + assert_eq!(t.turns[0].status, TurnStatus::Completed); + } + + #[test] + fn agent_message_deltas_accumulate_into_item() { + let mut t = fresh_thread(); + apply_notification(&mut t, "turn/started", &json!({ "turn": { "id": "tn" } })); + let item_id = "msg_1"; + apply_notification( + &mut t, + "item/agentMessage/delta", + &json!({ "itemId": item_id, "delta": "Hel" }), + ); + apply_notification( + &mut t, + "item/agentMessage/delta", + &json!({ "itemId": item_id, "delta": "lo" }), + ); + let items = &t.turns[0].items; + assert_eq!(items.len(), 1); + assert_eq!(items[0].text, "Hello"); + assert_eq!(items[0].kind, CodexItemKind::AgentMessage); + assert!(!items[0].completed); + } + + #[test] + fn command_execution_output_delta_creates_tool_item() { + let mut t = fresh_thread(); + apply_notification(&mut t, "turn/started", &json!({ "turn": { "id": "tn" } })); + apply_notification( + &mut t, + "item/commandExecution/outputDelta", + &json!({ + "itemId": "cmd_1", + "command": "git status --short", + "delta": " M src/app/app_view.rs\n" + }), + ); + let item = &t.turns[0].items[0]; + assert_eq!(item.kind, CodexItemKind::CommandExecution); + assert!(item.text.contains("$ git status --short")); + assert!(item.text.contains("M src/app/app_view.rs")); + } + + #[test] + fn item_completed_is_authoritative_over_deltas() { + let mut t = fresh_thread(); + apply_notification(&mut t, "turn/started", &json!({ "turn": { "id": "tn" } })); + apply_notification( + &mut t, + "item/agentMessage/delta", + &json!({ "itemId": "msg_1", "delta": "Partial" }), + ); + apply_notification( + &mut t, + "item/completed", + &json!({ + "item": { + "id": "msg_1", + "type": "agentMessage", + "text": "Final answer." + } + }), + ); + let item = &t.turns[0].items[0]; + assert_eq!(item.text, "Final answer."); + assert!(item.completed); + } + + #[test] + fn item_started_with_user_message_extracts_content_text() { + let mut t = fresh_thread(); + apply_notification(&mut t, "turn/started", &json!({ "turn": { "id": "tn" } })); + apply_notification( + &mut t, + "item/started", + &json!({ + "item": { + "id": "u1", + "type": "userMessage", + "content": [{ "type": "text", "text": "Hello there" }] + } + }), + ); + let item = &t.turns[0].items[0]; + assert_eq!(item.text, "Hello there"); + assert_eq!(item.kind, CodexItemKind::UserMessage); + } + + #[test] + fn archive_overlays_status() { + let mut t = fresh_thread(); + apply_notification(&mut t, "thread/archived", &json!({})); + assert!(t.archived); + assert_eq!(t.status, LocalThreadStatus::Archived); + } + + #[test] + fn unknown_notification_returns_false() { + let mut t = fresh_thread(); + assert!(!apply_notification( + &mut t, + "thread/tokenUsage/updated", + &json!({}) + )); + } + + #[test] + fn build_thread_list_with_cwd_filters_by_path() { + let p = build_thread_list_params(Some(std::path::Path::new("/tmp/repo"))); + assert_eq!(p["cwd"], "/tmp/repo"); + } + + #[test] + fn build_thread_list_without_cwd_is_empty_object() { + let p = build_thread_list_params(None); + assert!(p.get("cwd").is_none()); + } + + #[test] + fn parse_thread_list_flat_shape() { + let v = json!({ + "threads": [ + { "id": "thr_a", "title": "Plan it", "cwd": "/tmp/repo", "updatedAtMs": 1_700_000_000_000_i64 }, + { "id": "thr_b" } + ] + }); + let parsed = parse_thread_list_response(&v); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].thread_id, "thr_a"); + assert_eq!(parsed[0].title, "Plan it"); + assert_eq!(parsed[0].cwd.as_deref(), Some("/tmp/repo")); + assert_eq!(parsed[0].updated_at_ms, Some(1_700_000_000_000)); + // Missing-title case falls back to the thread id. + assert_eq!(parsed[1].title, "thr_b"); + } + + #[test] + fn parse_thread_list_nested_shape() { + let v = json!({ + "threads": [ + { "thread": { "id": "thr_c", "sessionId": "ses_c" } } + ] + }); + let parsed = parse_thread_list_response(&v); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].thread_id, "thr_c"); + assert_eq!(parsed[0].session_id.as_deref(), Some("ses_c")); + } + + #[test] + fn parse_thread_list_drops_entries_missing_id() { + let v = json!({ "threads": [{ "title": "no id" }] }); + assert!(parse_thread_list_response(&v).is_empty()); + } + + #[test] + fn build_thread_resume_carries_thread_id() { + assert_eq!(build_thread_resume_params("thr_X")["threadId"], "thr_X"); + } + + #[test] + fn build_thread_fork_with_turn_id() { + let v = build_thread_fork_params("thr_X", Some("turn_Y")); + assert_eq!(v["threadId"], "thr_X"); + assert_eq!(v["turnId"], "turn_Y"); + } + + #[test] + fn build_thread_fork_without_turn_id_omits_field() { + let v = build_thread_fork_params("thr_X", None); + assert_eq!(v["threadId"], "thr_X"); + assert!(v.get("turnId").is_none()); + } + + #[test] + fn command_execution_string_command_renders_summary() { + let item = json!({ + "id": "cmd_1", + "type": "commandExecution", + "command": "ls -la", + "cwd": "/tmp/repo", + "exitCode": 0, + "stdout": "file1\nfile2" + }); + let t = extract_item_text(&item, &CodexItemKind::CommandExecution); + assert!(t.contains("$ ls -la"), "missing command line: {t:?}"); + assert!(t.contains("cwd: /tmp/repo")); + assert!(t.contains("exit: 0")); + assert!(t.contains("file1\nfile2")); + } + + #[test] + fn command_execution_array_command_joins_argv() { + let item = json!({ + "id": "cmd_2", + "type": "commandExecution", + "command": ["bash", "-lc", "echo hi"] + }); + assert!(extract_item_text(&item, &CodexItemKind::CommandExecution) + .starts_with("$ bash -lc echo hi")); + } + + #[test] + fn command_execution_alt_exit_path_works() { + let item = json!({ + "id": "cmd_3", + "type": "commandExecution", + "command": "false", + "exitStatus": { "code": 1 } + }); + assert!(extract_item_text(&item, &CodexItemKind::CommandExecution).contains("exit: 1")); + } + + #[test] + fn file_change_paths_plus_diff() { + let item = json!({ + "id": "fc_1", + "type": "fileChange", + "paths": ["src/a.rs", "src/b.rs"], + "unifiedDiff": "@@ -1 +1 @@\n-old\n+new" + }); + let t = extract_item_text(&item, &CodexItemKind::FileChange); + assert!(t.starts_with("src/a.rs\nsrc/b.rs")); + assert!(t.contains("@@ -1 +1 @@")); + } + + #[test] + fn file_change_single_path_fallback() { + let item = json!({ + "id": "fc_2", + "type": "fileChange", + "path": "README.md" + }); + assert_eq!( + extract_item_text(&item, &CodexItemKind::FileChange), + "README.md" + ); + } + + #[test] + fn reasoning_summary_parts_joined_with_blank_line() { + let item = json!({ + "id": "r_1", + "type": "reasoning", + "parts": [{ "text": "first" }, { "text": "second" }] + }); + assert_eq!( + extract_item_text(&item, &CodexItemKind::Reasoning), + "first\n\nsecond" + ); + } + + #[test] + fn reasoning_delta_accumulates_into_item() { + let mut t = fresh_thread(); + apply_notification(&mut t, "turn/started", &json!({ "turn": { "id": "tn" } })); + apply_notification( + &mut t, + "item/reasoning/summaryTextDelta", + &json!({ "itemId": "r1", "delta": "Thinking " }), + ); + apply_notification( + &mut t, + "item/reasoning/summaryTextDelta", + &json!({ "itemId": "r1", "delta": "hard..." }), + ); + let items = &t.turns[0].items; + assert_eq!(items.len(), 1); + assert_eq!(items[0].kind, CodexItemKind::Reasoning); + assert_eq!(items[0].text, "Thinking hard..."); + } + + #[test] + fn reasoning_part_added_separates_with_blank_line() { + let mut t = fresh_thread(); + apply_notification(&mut t, "turn/started", &json!({ "turn": { "id": "tn" } })); + apply_notification( + &mut t, + "item/reasoning/summaryTextDelta", + &json!({ "itemId": "r1", "delta": "First step." }), + ); + apply_notification( + &mut t, + "item/reasoning/summaryPartAdded", + &json!({ "itemId": "r1", "delta": "" }), + ); + apply_notification( + &mut t, + "item/reasoning/summaryTextDelta", + &json!({ "itemId": "r1", "delta": "Second step." }), + ); + assert_eq!(t.turns[0].items[0].text, "First step.\n\nSecond step."); + } + + #[test] + fn reasoning_item_completed_removes_from_turn() { + let mut t = fresh_thread(); + apply_notification(&mut t, "turn/started", &json!({ "turn": { "id": "tn" } })); + apply_notification( + &mut t, + "item/reasoning/summaryTextDelta", + &json!({ "itemId": "r1", "delta": "thinking" }), + ); + assert_eq!(t.turns[0].items.len(), 1); + apply_notification( + &mut t, + "item/completed", + &json!({ "item": { "id": "r1", "type": "reasoning" } }), + ); + assert!( + t.turns[0].items.is_empty(), + "reasoning item should be removed on complete" + ); + } + + #[test] + fn user_message_auto_titles_default_thread() { + let mut t = fresh_thread(); + assert_eq!(t.title, DEFAULT_THREAD_TITLE); + apply_notification(&mut t, "turn/started", &json!({ "turn": { "id": "tn" } })); + apply_notification( + &mut t, + "item/started", + &json!({ + "item": { + "id": "u1", + "type": "userMessage", + "content": [{ "type": "text", "text": "Fix the broken docker build" }] + } + }), + ); + assert_eq!(t.title, "Fix the broken docker build"); + } + + #[test] + fn user_message_does_not_overwrite_custom_title() { + let mut t = fresh_thread(); + t.title = "Renamed by user".to_string(); + apply_notification(&mut t, "turn/started", &json!({ "turn": { "id": "tn" } })); + apply_notification( + &mut t, + "item/started", + &json!({ + "item": { + "id": "u1", + "type": "userMessage", + "content": [{ "type": "text", "text": "Anything" }] + } + }), + ); + assert_eq!(t.title, "Renamed by user"); + } + + #[test] + fn derive_title_collapses_whitespace_and_truncates() { + let long = derive_title_from_user_message( + " please refactor the giant module that handles every single edge case for me thanks ", + ) + .expect("non-empty"); + assert!(long.ends_with('…')); + assert!(long.chars().count() <= MAX_AUTO_TITLE_CHARS + 1); + // Multiline newlines collapse to a single line. + let multi = derive_title_from_user_message("first line\n\nsecond line").expect("non-empty"); + assert_eq!(multi, "first line second line"); + // Whitespace-only message gets no title. + assert!(derive_title_from_user_message(" \n ").is_none()); + } + + #[test] + fn agent_message_item_completed_stays_in_turn() { + // Regression for the reasoning auto-remove: only Reasoning items + // disappear on completion; everything else stays. + let mut t = fresh_thread(); + apply_notification(&mut t, "turn/started", &json!({ "turn": { "id": "tn" } })); + apply_notification( + &mut t, + "item/completed", + &json!({ + "item": { "id": "m1", "type": "agentMessage", "text": "Done." } + }), + ); + assert_eq!(t.turns[0].items.len(), 1); + assert_eq!(t.turns[0].items[0].text, "Done."); + assert!(t.turns[0].items[0].completed); + } +} diff --git a/src/app/codex_markdown.rs b/src/app/codex_markdown.rs new file mode 100644 index 0000000..dbaeb66 --- /dev/null +++ b/src/app/codex_markdown.rs @@ -0,0 +1,495 @@ +//! Markdown rendering for Codex `agentMessage` items. +//! +//! Parses the agent's text with the [`markdown`] crate (markdown-rs) and +//! walks the resulting mdast to produce GPUI block elements. The renderer +//! is intentionally minimal: paragraphs / headings / lists / fenced code +//! get distinct treatment; inline emphasis/strong/code/links become styled +//! text runs so word-wrap continues to behave inside a single paragraph div. +//! Anything the walker doesn't understand falls back to the raw markdown +//! source so the model's response always shows up. + +use gpui::*; +use markdown::mdast::Node; +use std::ops::Range; + +use crate::app::{config::FONT_FAMILY, settings::UiSettings, theme::Theme}; + +/// Render markdown text as a column of GPUI block elements. +pub(crate) fn render_markdown(text: &str, theme: &Theme, ui: UiSettings) -> AnyElement { + let ast = match markdown::to_mdast(text, &markdown::ParseOptions::gfm()) { + Ok(node) => node, + Err(_) => return raw_paragraph(text, theme, ui), + }; + + let mut blocks: Vec = Vec::new(); + collect_blocks(&ast, &mut blocks, theme, ui, 0); + + if blocks.is_empty() { + return raw_paragraph(text, theme, ui); + } + + let mut col = div().flex().flex_col().gap_2().w_full().min_w_0(); + for block in blocks { + col = col.child(block); + } + col.into_any_element() +} + +fn raw_paragraph(text: &str, theme: &Theme, ui: UiSettings) -> AnyElement { + div() + .w_full() + .min_w_0() + .text_color(theme.foreground) + .text_size(px(ui.font_size_px())) + .line_height(px(ui.line_height_px())) + .child(text.to_string()) + .into_any_element() +} + +/// Walks a `Node` and pushes one element per block-level child into `out`. +/// `list_depth` controls indentation for nested lists. +fn collect_blocks( + node: &Node, + out: &mut Vec, + theme: &Theme, + ui: UiSettings, + list_depth: usize, +) { + match node { + Node::Root(root) => { + for child in &root.children { + collect_blocks(child, out, theme, ui, list_depth); + } + } + Node::Paragraph(para) => { + let inline = collect_inline(¶.children); + if !inline.text.is_empty() { + out.push(paragraph_block(inline, theme, ui)); + } + } + Node::Heading(h) => { + let inline = collect_inline(&h.children); + if !inline.text.is_empty() { + out.push(heading_block(inline, h.depth, theme, ui)); + } + } + Node::Code(code) => { + out.push(code_block(&code.value, theme, ui)); + } + Node::List(list) => { + let ordered = list.ordered; + let start = list.start.unwrap_or(1); + for (i, child) in list.children.iter().enumerate() { + if let Node::ListItem(item) = child { + let marker = if ordered { + format!("{}.", start + i as u32) + } else { + "•".to_string() + }; + out.push(list_item_block(item, &marker, theme, ui, list_depth)); + } + } + } + Node::Blockquote(bq) => { + let mut inner: Vec = Vec::new(); + for child in &bq.children { + collect_blocks(child, &mut inner, theme, ui, list_depth); + } + out.push(block_quote(inner, theme)); + } + Node::ThematicBreak(_) => { + out.push( + div() + .w_full() + .h(px(1.0)) + .bg(theme.border) + .my_1() + .into_any_element(), + ); + } + Node::Html(html) => { + // GFM allows raw HTML; we render the source as code. + out.push(code_block(&html.value, theme, ui)); + } + // Everything else (table, image, definition, etc.) gets best-effort + // inline flattening so we don't lose the content. + other => { + if let Some(children) = node_children(other) { + let inline = collect_inline(children); + if !inline.text.is_empty() { + out.push(paragraph_block(inline, theme, ui)); + } + } + } + } +} + +fn paragraph_block(inline: InlineText, theme: &Theme, ui: UiSettings) -> AnyElement { + div() + .w_full() + .min_w_0() + .whitespace_normal() + .text_color(theme.foreground) + .text_size(px(ui.font_size_px())) + .line_height(px(ui.line_height_px())) + .child(styled_inline(inline, theme)) + .into_any_element() +} + +fn heading_block(inline: InlineText, depth: u8, theme: &Theme, ui: UiSettings) -> AnyElement { + // Larger size for shallower depth; cap at +4 / -1 offsets. + let offset = match depth { + 1 => 4.0, + 2 => 3.0, + 3 => 2.0, + 4 => 1.0, + _ => 0.0, + }; + div() + .w_full() + .min_w_0() + .text_color(theme.foreground) + .text_size(px(ui.font_size_with_offset(offset))) + .line_height(px(ui.line_height_with_offset(offset))) + .font_weight(FontWeight::BOLD) + .child(styled_inline(inline, theme)) + .into_any_element() +} + +fn code_block(source: &str, theme: &Theme, ui: UiSettings) -> AnyElement { + div() + .id("md-code-block") + .w_full() + .min_w_0() + .overflow_x_scroll() + .px(px(8.0)) + .py(px(6.0)) + .rounded_md() + .bg(theme.surface_background) + .text_color(theme.foreground) + .text_size(px(ui.font_size_with_offset(-1.0))) + .line_height(px(ui.line_height_with_offset(-1.0))) + .font_family(FONT_FAMILY) + .child(source.trim_end_matches('\n').to_string()) + .into_any_element() +} + +fn list_item_block( + item: &markdown::mdast::ListItem, + marker: &str, + theme: &Theme, + ui: UiSettings, + depth: usize, +) -> AnyElement { + // Split the item's children into the first paragraph (rendered inline + // next to the marker) and any subsequent blocks (rendered below). + let mut first_text: Option = None; + let mut tail_blocks: Vec = Vec::new(); + for (i, child) in item.children.iter().enumerate() { + if i == 0 { + if let Node::Paragraph(p) = child { + first_text = Some(collect_inline(&p.children)); + continue; + } + } + collect_blocks(child, &mut tail_blocks, theme, ui, depth + 1); + } + + let indent = px(14.0 * depth as f32); + let mut row = div() + .flex() + .flex_row() + .gap_2() + .w_full() + .min_w_0() + .pl(indent) + .child( + div() + .flex_shrink_0() + .text_color(theme.text_muted) + .text_size(px(ui.font_size_px())) + .line_height(px(ui.line_height_px())) + .child(marker.to_string()), + ); + let body_text = first_text.unwrap_or_default(); + row = row.child( + div() + .flex_1() + .min_w_0() + .text_color(theme.foreground) + .text_size(px(ui.font_size_px())) + .line_height(px(ui.line_height_px())) + .child(styled_inline(body_text, theme)), + ); + + if tail_blocks.is_empty() { + return row.into_any_element(); + } + + let mut col = div() + .flex() + .flex_col() + .gap_1() + .w_full() + .min_w_0() + .child(row); + for block in tail_blocks { + col = col.child(block); + } + col.into_any_element() +} + +fn block_quote(inner: Vec, theme: &Theme) -> AnyElement { + let mut col = div() + .flex() + .flex_col() + .gap_1() + .w_full() + .min_w_0() + .pl(px(10.0)) + .border_l_2() + .border_color(theme.border); + for el in inner { + col = col.child(el); + } + col.into_any_element() +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct InlineText { + text: String, + spans: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct InlineSpan { + range: Range, + style: InlineStyle, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct InlineStyle { + strong: bool, + emphasis: bool, + code: bool, + link: bool, + delete: bool, +} + +fn collect_inline(nodes: &[Node]) -> InlineText { + let mut out = InlineText::default(); + for node in nodes { + collect_inline_node(node, InlineStyle::default(), &mut out); + } + + trim_inline(out) +} + +fn collect_inline_node(node: &Node, style: InlineStyle, out: &mut InlineText) { + match node { + Node::Text(t) => push_inline_text(out, &t.value, style), + Node::InlineCode(c) => { + let mut style = style; + style.code = true; + push_inline_text(out, &c.value, style); + } + Node::Strong(s) => { + let mut style = style; + style.strong = true; + for child in &s.children { + collect_inline_node(child, style, out); + } + } + Node::Emphasis(e) => { + let mut style = style; + style.emphasis = true; + for child in &e.children { + collect_inline_node(child, style, out); + } + } + Node::Link(l) => { + let mut style = style; + style.link = true; + for child in &l.children { + collect_inline_node(child, style, out); + } + } + Node::Break(_) => push_inline_text(out, "\n", style), + Node::Delete(d) => { + let mut style = style; + style.delete = true; + for child in &d.children { + collect_inline_node(child, style, out); + } + } + Node::Image(img) => { + if !img.alt.is_empty() { + push_inline_text(out, &img.alt, style); + } else { + push_inline_text(out, "image", style); + } + } + other => { + if let Some(children) = node_children(other) { + for child in children { + collect_inline_node(child, style, out); + } + } + } + } +} + +fn push_inline_text(out: &mut InlineText, text: &str, style: InlineStyle) { + if text.is_empty() { + return; + } + + let start = out.text.len(); + out.text.push_str(text); + let end = out.text.len(); + if style != InlineStyle::default() { + out.spans.push(InlineSpan { + range: start..end, + style, + }); + } +} + +fn trim_inline(mut inline: InlineText) -> InlineText { + let trimmed_start = inline.text.len() - inline.text.trim_start().len(); + let trimmed_end = inline.text.trim_end().len(); + + if trimmed_start == 0 && trimmed_end == inline.text.len() { + return inline; + } + + inline.text = inline.text[trimmed_start..trimmed_end].to_string(); + inline.spans = inline + .spans + .into_iter() + .filter_map(|span| { + let start = span.range.start.max(trimmed_start); + let end = span.range.end.min(trimmed_end); + (start < end).then(|| InlineSpan { + range: (start - trimmed_start)..(end - trimmed_start), + style: span.style, + }) + }) + .collect(); + inline +} + +fn styled_inline(inline: InlineText, theme: &Theme) -> StyledText { + let highlights = inline.spans.into_iter().map(|span| { + ( + span.range, + HighlightStyle { + color: span.style.link.then_some(theme.accent), + font_weight: span.style.strong.then_some(FontWeight::BOLD), + font_style: span.style.emphasis.then_some(FontStyle::Italic), + background_color: span.style.code.then_some(theme.surface_background), + underline: span.style.link.then_some(UnderlineStyle { + color: Some(theme.accent), + thickness: px(1.0), + ..Default::default() + }), + strikethrough: span.style.delete.then_some(StrikethroughStyle { + thickness: px(1.0), + ..Default::default() + }), + ..Default::default() + }, + ) + }); + + StyledText::new(inline.text).with_highlights(highlights) +} + +fn node_children(node: &Node) -> Option<&Vec> { + match node { + Node::Root(n) => Some(&n.children), + Node::Paragraph(n) => Some(&n.children), + Node::Heading(n) => Some(&n.children), + Node::Blockquote(n) => Some(&n.children), + Node::List(n) => Some(&n.children), + Node::ListItem(n) => Some(&n.children), + Node::Strong(n) => Some(&n.children), + Node::Emphasis(n) => Some(&n.children), + Node::Link(n) => Some(&n.children), + Node::Delete(n) => Some(&n.children), + Node::Table(n) => Some(&n.children), + Node::TableRow(n) => Some(&n.children), + Node::TableCell(n) => Some(&n.children), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use markdown::ParseOptions; + + /// The walker shouldn't panic on any of the markdown constructs we + /// expect from model responses. Pixel-perfect output is verified by + /// hand; these tests just guard against regression panics. + #[test] + fn ast_parses_common_constructs() { + let cases = [ + "plain paragraph", + "# Heading\n\nbody", + "- one\n- two\n- three", + "1. first\n2. second", + "inline `code` here", + "```rust\nfn main() {}\n```", + "> a quote\n> with two lines", + "---", + "**bold** and *italic*", + "a [link](https://example.com)", + "| a | b |\n| - | - |\n| 1 | 2 |", + "", + ]; + for src in cases { + let parsed = markdown::to_mdast(src, &ParseOptions::gfm()); + assert!(parsed.is_ok(), "parse failed for {src:?}"); + } + } + + #[test] + fn collect_inline_strips_markdown_markers_and_records_styles() { + let ast = markdown::to_mdast( + "use `cargo run` to **start** the [app](https://example.com)", + &ParseOptions::gfm(), + ) + .expect("parse"); + if let markdown::mdast::Node::Root(root) = ast { + if let markdown::mdast::Node::Paragraph(p) = &root.children[0] { + let inline = super::collect_inline(&p.children); + assert_eq!(inline.text, "use cargo run to start the app"); + assert!( + inline + .spans + .iter() + .any(|span| &inline.text[span.range.clone()] == "cargo run" + && span.style.code), + "missing inline code span: {inline:?}" + ); + assert!( + inline.spans.iter().any( + |span| &inline.text[span.range.clone()] == "start" && span.style.strong + ), + "missing strong span: {inline:?}" + ); + assert!( + inline + .spans + .iter() + .any(|span| &inline.text[span.range.clone()] == "app" && span.style.link), + "missing link span: {inline:?}" + ); + } else { + panic!("expected paragraph as first child"); + } + } else { + panic!("expected Root node"); + } + } +} diff --git a/src/app/codex_view.rs b/src/app/codex_view.rs new file mode 100644 index 0000000..4a64c0f --- /dev/null +++ b/src/app/codex_view.rs @@ -0,0 +1,1336 @@ +//! GPUI rendering for the Codex pane that replaces the terminal canvas when +//! `AppView::content_mode == ContentMode::Codex`. Kept separate from +//! `codex/` because it imports `AppView` (GPUI-side state). + +use gpui::prelude::FluentBuilder; +use gpui::*; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use crate::app::{ + app_view::{is_url, AppView, CodexHistoryState, CodexLoginState}, + codex::{ + ApprovalDecision, CodexAccountState, CodexApproval, CodexItem, CodexItemKind, CodexThread, + CodexThreadLocalId, CodexThreadSummary, LocalThreadStatus, LoginType, TurnStatus, + }, + config::PADDING_PX, + settings::UiSettings, + theme::Theme, +}; + +pub(crate) fn build_codex_pane(app: &AppView, cx: &mut Context) -> impl IntoElement { + let theme = app.theme.clone(); + let ui = app.ui_settings; + let thread = app.active_codex_thread_ref(); + let needs_signin = matches!(app.codex_account, CodexAccountState::SignedOutRequiresAuth) + || app.codex_login.is_some(); + + let header = thread_header(thread, app.codex_history.is_some(), &theme, ui, cx); + let rate_limit_banner = rate_limit_banner(&app.codex_account, &theme, ui); + let body = if needs_signin { + signin_view(app.codex_login.as_ref(), &theme, ui, cx).into_any_element() + } else if let Some(history) = app + .codex_history + .as_ref() + .filter(|h| Some(h.project_id) == app.active_project) + { + history_view(history, &theme, ui, cx).into_any_element() + } else { + transcript_view( + thread, + &app.codex_expanded_items, + &app.codex_transcript_scroll, + &theme, + ui, + cx, + ) + .into_any_element() + }; + let composer = composer_view(app, &theme, ui, cx); + + let mut root = div() + .flex() + .flex_col() + .size_full() + .bg(theme.background) + .child(header); + if let Some(banner) = rate_limit_banner { + root = root.child(banner); + } + root.child(body).child(composer) +} + +fn thread_header( + thread: Option<&CodexThread>, + history_open: bool, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> impl IntoElement { + let (title, status_label) = match thread { + Some(t) => (t.title.clone(), status_label(t.status)), + None => ("No Codex thread".into(), "—".into()), + }; + let fork_disabled = thread.map(|t| t.server_thread_id.is_none()).unwrap_or(true); + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .px(px(PADDING_PX * 2.0)) + .py(px(6.0)) + .border_b_1() + .border_color(theme.border) + .bg(theme.sidebar_background) + .text_size(px(ui.font_size_px())) + .line_height(px(ui.line_height_px())) + .child( + div() + .flex_1() + .truncate() + .text_color(theme.foreground) + .child(title), + ) + .child( + div() + .text_color(theme.text_muted) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child(status_label), + ) + .child(header_button( + "codex-history-toggle", + if history_open { + "Close history" + } else { + "History" + }, + false, + theme, + ui, + cx.listener(|this, _ev: &MouseDownEvent, _w, cx| { + this.toggle_codex_history(cx); + }), + )) + .child(header_button( + "codex-fork-thread", + "Fork", + fork_disabled, + theme, + ui, + cx.listener(|this, _ev: &MouseDownEvent, _w, cx| { + this.fork_active_codex_thread(cx); + }), + )) + .child(header_button( + "codex-archive-thread", + "Archive", + fork_disabled, + theme, + ui, + cx.listener(|this, _ev: &MouseDownEvent, _w, cx| { + this.archive_active_codex_thread(true, cx); + }), + )) +} + +fn header_button( + id: &'static str, + label: &'static str, + disabled: bool, + theme: &Theme, + ui: UiSettings, + listener: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static, +) -> impl IntoElement { + div() + .id(id) + .px(px(8.0)) + .py(px(2.0)) + .rounded_md() + .border_1() + .border_color(theme.border) + .text_size(px(ui.font_size_with_offset(-1.0))) + .line_height(px(ui.line_height_with_offset(-1.0))) + .text_color(theme.foreground) + .bg(theme.surface_background) + .when(disabled, |el| el.opacity(0.4)) + .when(!disabled, |el| { + el.cursor_pointer() + .hover(|s| s.opacity(0.8)) + .on_mouse_down(MouseButton::Left, listener) + }) + .child(label) +} + +fn status_label(status: LocalThreadStatus) -> String { + match status { + LocalThreadStatus::Draft => "starting…", + LocalThreadStatus::Loaded => "idle", + LocalThreadStatus::Running => "running", + LocalThreadStatus::WaitingForApproval => "waiting for approval", + LocalThreadStatus::Failed => "failed", + LocalThreadStatus::Disconnected => "disconnected", + LocalThreadStatus::Archived => "archived", + } + .into() +} + +fn transcript_view( + thread: Option<&CodexThread>, + expanded_items: &std::collections::HashSet, + scroll_handle: &ScrollHandle, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> impl IntoElement { + let mut scroll = div() + .id("codex-transcript") + .track_scroll(scroll_handle) + .flex() + .flex_col() + .gap_2() + .flex_1() + .w_full() + .min_w_0() + .min_h_0() + .overflow_x_hidden() + .overflow_y_scroll() + .px(px(PADDING_PX * 2.0)) + .py(px(PADDING_PX * 2.0)); + + let Some(thread) = thread else { + return scroll.child(empty_state(theme, ui)); + }; + + if thread.turns.is_empty() && thread.pending_approvals.is_empty() { + scroll = scroll.child(empty_state(theme, ui)); + } + + for turn in &thread.turns { + let mut index = 0; + while index < turn.items.len() { + let item = &turn.items[index]; + if matches!(item.kind, CodexItemKind::Reasoning) && item.text.trim() == "..." { + index += 1; + continue; + } + if is_tool_item(item) { + let start = index; + index += 1; + while index < turn.items.len() && is_tool_item(&turn.items[index]) { + index += 1; + } + scroll = scroll.child(tool_group_row( + &turn.items[start..index], + expanded_items, + theme, + ui, + cx, + )); + continue; + } + scroll = scroll.child(item_row(item, expanded_items, theme, ui, cx)); + index += 1; + } + if turn.status == TurnStatus::Failed { + scroll = scroll.child( + div() + .text_color(theme.warning) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child("turn failed"), + ); + } + } + + for approval in &thread.pending_approvals { + scroll = scroll.child(approval_card(thread.local_id, approval, theme, ui, cx)); + } + scroll +} + +fn empty_state(theme: &Theme, ui: UiSettings) -> impl IntoElement { + div() + .text_color(theme.text_muted) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child("No turns yet — type a prompt and press Ctrl+Shift+Enter.") +} + +fn item_row( + item: &CodexItem, + expanded_items: &std::collections::HashSet, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> AnyElement { + match &item.kind { + CodexItemKind::CommandExecution | CodexItemKind::FileChange => { + tool_row(item, expanded_items, theme, ui, cx).into_any_element() + } + CodexItemKind::Reasoning => reasoning_row(item, theme, ui).into_any_element(), + CodexItemKind::AgentMessage => agent_message_row(item, theme, ui).into_any_element(), + CodexItemKind::UserMessage | CodexItemKind::Other(_) => { + plain_row(item, theme, ui, cx).into_any_element() + } + } +} + +fn is_tool_item(item: &CodexItem) -> bool { + matches!( + item.kind, + CodexItemKind::CommandExecution | CodexItemKind::FileChange + ) +} + +fn plain_row( + item: &CodexItem, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> impl IntoElement { + let (label, label_color) = match &item.kind { + CodexItemKind::UserMessage => ("you", theme.accent), + _ => ("event", theme.text_muted), + }; + let bubble_bg = match &item.kind { + CodexItemKind::UserMessage => theme.accent.opacity(0.14), + _ => theme.surface_background, + }; + div() + .flex() + .flex_col() + .gap_1() + .w_full() + .min_w_0() + .p(px(8.0)) + .rounded_md() + .bg(bubble_bg) + .child( + div() + .text_color(label_color) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child(label), + ) + .child( + div() + .w_full() + .min_w_0() + .text_color(theme.foreground) + .text_size(px(ui.font_size_px())) + .line_height(px(ui.line_height_px())) + .child(clickable_text( + &item.text, + &item.server_item_id, + theme, + ui, + cx, + )), + ) +} + +fn reasoning_row(item: &CodexItem, theme: &Theme, ui: UiSettings) -> impl IntoElement { + div() + .flex() + .flex_col() + .gap_1() + .w_full() + .min_w_0() + .child( + div() + .text_color(theme.text_muted) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child("thinking…"), + ) + .child( + div() + .w_full() + .min_w_0() + .whitespace_normal() + .text_color(theme.text_muted) + .text_size(px(ui.font_size_with_offset(-1.0))) + .line_height(px(ui.line_height_with_offset(-1.0))) + .child(item.text.clone()), + ) +} + +fn agent_message_row(item: &CodexItem, theme: &Theme, ui: UiSettings) -> impl IntoElement { + div() + .w_full() + .min_w_0() + .child(crate::app::codex_markdown::render_markdown( + &item.text, theme, ui, + )) +} + +fn tool_group_row( + items: &[CodexItem], + expanded_items: &std::collections::HashSet, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> impl IntoElement { + let group_id = tool_group_id(items); + let is_expanded = expanded_items.contains(&group_id); + let group_id_for_click = group_id.clone(); + + let count = items.len(); + let count_label = if count == 1 { + "1 call".to_string() + } else { + format!("{count} calls") + }; + + let header = div() + .flex() + .flex_row() + .items_center() + .justify_between() + .gap_2() + .w_full() + .min_w_0() + .px(px(8.0)) + .py(px(4.0)) + .child( + div() + .whitespace_nowrap() + .text_color(theme.foreground) + .text_size(px(ui.font_size_with_offset(-1.0))) + .line_height(px(ui.line_height_with_offset(-1.0))) + .child("Tool calls"), + ) + .child( + div() + .whitespace_nowrap() + .text_color(theme.text_muted) + .text_size(px(ui.font_size_with_offset(-1.0))) + .line_height(px(ui.line_height_with_offset(-1.0))) + .child(count_label), + ); + + let mut card = div() + .id(ElementId::Name(format!("tool-row-{}", group_id).into())) + .flex() + .flex_col() + .w_full() + .min_w_0() + .border_1() + .border_color(theme.border) + .rounded_md() + .bg(theme.surface_background) + .cursor_pointer() + .child(header); + + if is_expanded { + let mut details = div() + .flex() + .flex_col() + .w_full() + .min_w_0() + .px(px(8.0)) + .py(px(4.0)) + .border_t_1() + .border_color(theme.border); + for item in items { + let (tool_name, args) = tool_summary_label(&item.kind, &item.text); + details = details.child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .w_full() + .min_w_0() + .py(px(2.0)) + .child( + div() + .whitespace_nowrap() + .text_color(theme.text_muted) + .text_size(px(ui.font_size_with_offset(-1.0))) + .line_height(px(ui.line_height_with_offset(-1.0))) + .child(tool_name), + ) + .child( + div() + .flex_1() + .min_w_0() + .whitespace_nowrap() + .overflow_hidden() + .text_color(theme.foreground) + .text_size(px(ui.font_size_with_offset(-1.0))) + .line_height(px(ui.line_height_with_offset(-1.0))) + .child(args), + ), + ); + } + card = card.child(details); + } + + let row = card; + + row.on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _ev: &MouseDownEvent, _w, cx| { + this.toggle_codex_item_expanded(group_id_for_click.clone(), cx); + }), + ) +} + +/// Collapsible row for `commandExecution` / `fileChange` items. Renders as a +/// single-line `Tool Calls (n)` summary; click toggles the full body below. +fn tool_row( + item: &CodexItem, + expanded_items: &std::collections::HashSet, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> impl IntoElement { + tool_group_row(std::slice::from_ref(item), expanded_items, theme, ui, cx) +} + +fn tool_group_id(items: &[CodexItem]) -> String { + let mut id = String::from("tool-group"); + for item in items { + id.push(':'); + if item.server_item_id.is_empty() { + id.push_str(&stable_text_id(&item.text).to_string()); + } else { + id.push_str(&item.server_item_id); + } + } + id +} + +/// Returns `(tool_name, first_arg_line)` for a tool item's summary. The +/// first line of the extracted text is used as the args summary (with the +/// leading `$ ` shell prompt stripped for command items so the summary +/// reads `Tool - Bash ls` rather than `Tool - Bash $ ls`). +fn tool_summary_label(kind: &CodexItemKind, text: &str) -> (&'static str, String) { + let name = match kind { + CodexItemKind::CommandExecution => "Bash", + CodexItemKind::FileChange => "Edit", + _ => "Tool", + }; + let first_line = text + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or("") + .trim_start_matches("$ ") + .to_string(); + (name, first_line) +} + +fn approval_card( + thread_local_id: CodexThreadLocalId, + approval: &CodexApproval, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> impl IntoElement { + let request_id = approval.request_id; + let summary = approval_summary(approval); + let kind_label = match approval.method.as_str() { + "item/commandExecution/requestApproval" => "command approval", + "item/fileChange/requestApproval" => "file change approval", + "item/permissions/requestApproval" => "permissions approval", + _ => "approval", + }; + + div() + .flex() + .flex_col() + .gap_1() + .w_full() + .min_w_0() + .p(px(8.0)) + .border_1() + .border_color(theme.warning) + .rounded_md() + .bg(theme.surface_background) + .child( + div() + .text_color(theme.warning) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child(kind_label), + ) + .child( + div() + .w_full() + .min_w_0() + .text_color(theme.foreground) + .text_size(px(ui.font_size_px())) + .line_height(px(ui.line_height_px())) + .child(summary), + ) + .child( + div() + .flex() + .flex_row() + .gap_2() + .pt(px(4.0)) + .child(approval_button( + "Accept", + thread_local_id, + request_id, + ApprovalDecision::Accept, + theme, + ui, + cx, + )) + .child(approval_button( + "Decline", + thread_local_id, + request_id, + ApprovalDecision::Decline, + theme, + ui, + cx, + )) + .child(approval_button( + "Cancel", + thread_local_id, + request_id, + ApprovalDecision::Cancel, + theme, + ui, + cx, + )), + ) +} + +fn approval_summary(approval: &CodexApproval) -> String { + let p = &approval.params; + match approval.method.as_str() { + "item/commandExecution/requestApproval" => p + .get("command") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| { + p.get("command").and_then(|v| v.as_array()).map(|arr| { + arr.iter() + .filter_map(|s| s.as_str()) + .collect::>() + .join(" ") + }) + }) + .unwrap_or_else(|| "(command)".into()), + "item/fileChange/requestApproval" => p + .get("paths") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|s| s.as_str()) + .collect::>() + .join(", ") + }) + .or_else(|| p.get("path").and_then(|v| v.as_str()).map(String::from)) + .unwrap_or_else(|| "(file change)".into()), + "item/permissions/requestApproval" => p + .get("reason") + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| "(permissions)".into()), + _ => "(approval)".into(), + } +} + +fn approval_button( + label: &'static str, + thread_local_id: CodexThreadLocalId, + request_id: u64, + decision: ApprovalDecision, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> impl IntoElement { + let (bg, fg) = match decision { + ApprovalDecision::Accept => (theme.accent, theme.background), + _ => (theme.surface_background, theme.foreground), + }; + div() + .id(ElementId::Name( + format!("approval-{}-{}-{}", thread_local_id, request_id, label).into(), + )) + .px(px(10.0)) + .py(px(4.0)) + .rounded_md() + .border_1() + .border_color(theme.border) + .bg(bg) + .text_color(fg) + .text_size(px(ui.font_size_with_offset(-1.0))) + .line_height(px(ui.line_height_with_offset(-1.0))) + .cursor_pointer() + .hover(|s| s.opacity(0.8)) + .child(label) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _ev: &MouseDownEvent, _w, cx| { + this.resolve_codex_approval(thread_local_id, request_id, decision, cx); + }), + ) +} + +fn rate_limit_banner(account: &CodexAccountState, theme: &Theme, ui: UiSettings) -> Option
{ + let CodexAccountState::RateLimited { + primary_used_percent, + resets_at_secs, + .. + } = account + else { + return None; + }; + let used = primary_used_percent + .map(|p| format!("{p}% used")) + .unwrap_or_else(|| "rate limit reached".into()); + let resets = resets_at_secs + .map(|s| format!(" — resets at {s}")) + .unwrap_or_default(); + Some( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .px(px(PADDING_PX * 2.0)) + .py(px(4.0)) + .border_b_1() + .border_color(theme.border) + .bg(theme.warning) + .text_color(theme.background) + .text_size(px(ui.font_size_with_offset(-1.0))) + .line_height(px(ui.line_height_with_offset(-1.0))) + .child(format!("Codex rate limit: {used}{resets}")), + ) +} + +fn signin_view( + login: Option<&CodexLoginState>, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> Stateful
{ + let mut root = div() + .id("codex-signin") + .flex() + .flex_col() + .items_center() + .justify_center() + .flex_1() + .min_h_0() + .gap_3() + .px(px(PADDING_PX * 4.0)) + .py(px(PADDING_PX * 4.0)) + .bg(theme.background); + + root = root.child( + div() + .text_color(theme.foreground) + .text_size(px(ui.font_size_with_offset(2.0))) + .child("Sign in to Codex"), + ); + + match login { + None => { + root = root.child( + div() + .text_color(theme.text_muted) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child("ChatGPT-managed auth. Browser preferred; device code if it fails."), + ); + root = root.child( + div() + .flex() + .flex_row() + .gap_2() + .child(signin_button( + "signin-browser", + "Sign in with browser", + true, + theme, + ui, + cx.listener(|this, _ev: &MouseDownEvent, _w, cx| { + this.start_codex_login(LoginType::ChatGpt, cx); + }), + )) + .child(signin_button( + "signin-device", + "Use a different device", + false, + theme, + ui, + cx.listener(|this, _ev: &MouseDownEvent, _w, cx| { + this.start_codex_login(LoginType::ChatGptDeviceCode, cx); + }), + )), + ); + } + Some(state) => { + let r = &state.response; + match state.flow { + LoginType::ChatGpt => { + if state.browser_open_failed { + root = root.child( + div() + .text_color(theme.warning) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child("Couldn't open your browser automatically. Open this URL:"), + ); + } else { + root = root.child( + div() + .text_color(theme.text_muted) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child("Waiting for the browser callback…"), + ); + } + if let Some(url) = r.auth_url.as_ref() { + root = root.child( + div() + .text_color(theme.foreground) + .text_size(px(ui.font_size_px())) + .child(url.clone()), + ); + } + } + LoginType::ChatGptDeviceCode => { + root = root.child( + div() + .text_color(theme.text_muted) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child("Open this URL on another device and enter the code:"), + ); + if let Some(url) = r.verification_url.as_ref() { + root = root.child( + div() + .text_color(theme.foreground) + .text_size(px(ui.font_size_px())) + .child(url.clone()), + ); + } + if let Some(code) = r.user_code.as_ref() { + root = root.child( + div() + .px(px(10.0)) + .py(px(6.0)) + .rounded_md() + .border_1() + .border_color(theme.border) + .bg(theme.surface_background) + .text_color(theme.accent) + .text_size(px(ui.font_size_with_offset(4.0))) + .child(code.clone()), + ); + } + } + } + root = root.child(signin_button( + "signin-cancel", + "Cancel", + false, + theme, + ui, + cx.listener(|this, _ev: &MouseDownEvent, _w, cx| { + this.cancel_codex_login(cx); + }), + )); + } + } + + root +} + +fn signin_button( + id: &'static str, + label: &'static str, + primary: bool, + theme: &Theme, + ui: UiSettings, + listener: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static, +) -> impl IntoElement { + let (bg, fg) = if primary { + (theme.accent, theme.background) + } else { + (theme.surface_background, theme.foreground) + }; + div() + .id(id) + .px(px(14.0)) + .py(px(6.0)) + .rounded_md() + .border_1() + .border_color(theme.border) + .bg(bg) + .text_color(fg) + .text_size(px(ui.font_size_px())) + .line_height(px(ui.line_height_px())) + .cursor_pointer() + .hover(|s| s.opacity(0.85)) + .child(label) + .on_mouse_down(MouseButton::Left, listener) +} + +fn history_view( + history: &CodexHistoryState, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> Stateful
{ + let mut list = div() + .id("codex-history") + .flex() + .flex_col() + .gap_1() + .flex_1() + .min_h_0() + .overflow_y_scroll() + .px(px(PADDING_PX * 2.0)) + .py(px(PADDING_PX * 2.0)); + + if history.loading { + list = list.child( + div() + .text_color(theme.text_muted) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child("Loading thread history…"), + ); + return list; + } + if let Some(err) = history.error.as_ref() { + list = list.child( + div() + .text_color(theme.warning) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child(format!("thread/list failed: {err}")), + ); + return list; + } + if history.entries.is_empty() { + list = list.child( + div() + .text_color(theme.text_muted) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child("No prior Codex threads in this workspace."), + ); + return list; + } + for entry in &history.entries { + list = list.child(history_entry_row(entry, theme, ui, cx)); + } + list +} + +fn history_entry_row( + summary: &CodexThreadSummary, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> impl IntoElement { + let title = summary.title.clone(); + let subtitle = summary + .cwd + .clone() + .unwrap_or_else(|| summary.thread_id.clone()); + let summary_for_click = summary.clone(); + div() + .id(ElementId::Name( + format!("history-entry-{}", summary.thread_id).into(), + )) + .flex() + .flex_col() + .gap_1() + .px(px(8.0)) + .py(px(6.0)) + .rounded_md() + .border_1() + .border_color(theme.border) + .bg(theme.surface_background) + .cursor_pointer() + .hover(|s| s.bg(theme.element_hover)) + .child( + div() + .text_color(theme.foreground) + .text_size(px(ui.font_size_px())) + .child(title), + ) + .child( + div() + .text_color(theme.text_muted) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child(subtitle), + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _ev: &MouseDownEvent, _w, cx| { + this.resume_codex_thread(summary_for_click.clone(), cx); + this.codex_history = None; + cx.notify(); + }), + ) +} + +fn composer_view( + app: &AppView, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> impl IntoElement { + let placeholder = "Type a prompt — Ctrl+Shift+Enter to send, Esc to return"; + let is_placeholder = app.codex_composer.is_empty(); + let send_disabled = app + .active_codex_thread_ref() + .map(|t| t.server_thread_id.is_none()) + .unwrap_or(true) + || (app.codex_composer.trim().is_empty() && app.codex_image_attachments.is_empty()); + let border_color = if app.codex_composer_focused { + theme.accent + } else { + theme.border + }; + + div() + .flex() + .flex_col() + .items_start() + .gap_2() + .w_full() + .min_w_0() + .px(px(PADDING_PX * 2.0)) + .py(px(PADDING_PX * 2.0)) + .border_t_1() + .border_color(theme.border) + .bg(theme.sidebar_background) + .child( + div() + .id("codex-composer-input") + .flex_1() + .min_w_0() + .w_full() + .overflow_hidden() + .min_h(px(60.0)) + .px(px(10.0)) + .py(px(8.0)) + .rounded_md() + .border_1() + .border_color(border_color) + .bg(theme.surface_background) + .text_size(px(ui.font_size_px())) + .line_height(px(ui.line_height_px())) + .cursor(CursorStyle::IBeam) + .text_color(if is_placeholder { + theme.text_muted + } else { + theme.foreground + }) + .child(composer_text(app, placeholder, ui)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _ev: &MouseDownEvent, _window, cx| { + this.focus_codex_composer(cx); + }), + ), + ) + .when(!app.codex_image_attachments.is_empty(), |el| { + el.child(attachment_row(app, theme, ui, cx)) + }) + .child( + div() + .flex() + .flex_row() + .items_center() + .justify_between() + .w_full() + .child( + div() + .text_color(theme.text_muted) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child("Paste images or attach local screenshots."), + ) + .child( + div() + .flex() + .flex_row() + .gap_2() + .child(composer_button( + "codex-attach-button", + "Attach image", + false, + theme, + ui, + cx.listener(|this, _ev: &MouseDownEvent, _w, cx| { + this.attach_codex_images(cx); + }), + )) + .child(composer_button( + "codex-send-button", + "Send", + send_disabled, + theme, + ui, + cx.listener(|this, _ev: &MouseDownEvent, _w, cx| { + this.submit_codex_prompt(cx); + }), + )), + ), + ) +} + +fn composer_text(app: &AppView, placeholder: &'static str, ui: UiSettings) -> impl IntoElement { + if app.codex_composer.is_empty() { + return div().child(placeholder).into_any_element(); + } + let cursor = app.codex_composer_cursor.min(app.codex_composer.len()); + let mut text = String::with_capacity(app.codex_composer.len() + 1); + text.push_str(&app.codex_composer[..cursor]); + if app.codex_composer_focused { + text.push('|'); + } + text.push_str(&app.codex_composer[cursor..]); + div() + .block() + .w_full() + .min_w_0() + .whitespace_normal() + .text_size(px(ui.font_size_px())) + .line_height(px(ui.line_height_px())) + .child(text) + .into_any_element() +} + +fn attachment_row( + app: &AppView, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> impl IntoElement { + let mut row = div().flex().flex_row().flex_wrap().gap_1().w_full(); + for (index, attachment) in app.codex_image_attachments.iter().enumerate() { + let label = attachment.label.clone(); + row = row.child( + div() + .flex() + .flex_row() + .items_center() + .gap_1() + .px(px(8.0)) + .py(px(3.0)) + .rounded_md() + .border_1() + .border_color(theme.border) + .bg(theme.surface_background) + .text_color(theme.foreground) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child(format!("image: {label}")) + .child( + div() + .text_color(theme.text_muted) + .cursor_pointer() + .child("x") + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _ev: &MouseDownEvent, _w, cx| { + this.remove_codex_image_attachment(index, cx); + }), + ), + ), + ); + } + row +} + +fn composer_button( + id: &'static str, + label: &'static str, + disabled: bool, + theme: &Theme, + ui: UiSettings, + listener: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static, +) -> impl IntoElement { + div() + .id(id) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .px(px(14.0)) + .py(px(5.0)) + .rounded_md() + .border_1() + .border_color(theme.border) + .text_size(px(ui.font_size_with_offset(-1.0))) + .line_height(px(ui.line_height_with_offset(-1.0))) + .when(disabled, |el| el.opacity(0.4)) + .when(!disabled, |el| { + el.cursor_pointer() + .hover(|s| s.opacity(0.8)) + .on_mouse_down(MouseButton::Left, listener) + }) + .bg(if label == "Send" { + theme.accent + } else { + theme.surface_background + }) + .text_color(if label == "Send" { + theme.background + } else { + theme.foreground + }) + .child(label) +} + +fn clickable_text( + text: &str, + stable_key: &str, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> AnyElement { + let (text, ranges, targets) = linkable_text(text); + let content = if ranges.is_empty() { + StyledText::new(text).into_any_element() + } else { + let link_style = HighlightStyle { + color: Some(theme.accent), + underline: Some(UnderlineStyle { + thickness: px(1.0), + color: Some(theme.accent), + wavy: false, + }), + ..Default::default() + }; + let app_view = cx.entity(); + let click_ranges = ranges.clone(); + InteractiveText::new( + ElementId::Name( + format!("codex-link-text-{}-{}", stable_key, stable_text_id(&text)).into(), + ), + StyledText::new(text) + .with_highlights(ranges.iter().cloned().map(|range| (range, link_style))), + ) + .on_click(click_ranges, move |index, _window, cx| { + if let Some(target) = targets.get(index) { + app_view.read(cx).open_codex_link(target.clone()); + } + }) + .into_any_element() + }; + + div() + .block() + .w_full() + .min_w_0() + .whitespace_normal() + .text_color(theme.foreground) + .text_size(px(ui.font_size_px())) + .line_height(px(ui.line_height_px())) + .child(content) + .into_any_element() +} + +fn linkable_text(text: &str) -> (String, Vec>, Vec) { + let mut display_text = String::new(); + let mut ranges = Vec::new(); + let mut targets = Vec::new(); + for segment in link_segments(text) { + match segment { + TextSegment::Plain(value) => { + display_text.push_str(&value); + } + TextSegment::Link { label, target } => { + let start = display_text.len(); + display_text.push_str(&label); + let end = display_text.len(); + if start < end { + ranges.push(start..end); + targets.push(target); + } + } + } + } + (display_text, ranges, targets) +} + +enum TextSegment { + Plain(String), + Link { label: String, target: String }, +} + +fn link_segments(text: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = text; + while let Some(open) = rest.find('[') { + let before = &rest[..open]; + push_plain_link_segments(before, &mut out); + let candidate = &rest[open + 1..]; + let Some(mid) = candidate.find("](") else { + push_plain_link_segments(&rest[open..], &mut out); + return out; + }; + let after_mid = &candidate[mid + 2..]; + let Some(close) = after_mid.find(')') else { + push_plain_link_segments(&rest[open..], &mut out); + return out; + }; + let label = strip_inline_code_marker(&candidate[..mid]); + let target = strip_inline_code_marker(&after_mid[..close]); + if is_url(target) || looks_like_file_link(target) { + out.push(TextSegment::Link { + label: label.to_string(), + target: target.to_string(), + }); + } else { + push_plain_link_segments(&rest[open..open + mid + close + 4], &mut out); + } + rest = &after_mid[close + 1..]; + } + push_plain_link_segments(rest, &mut out); + out +} + +fn push_plain_link_segments(text: &str, out: &mut Vec) { + let mut current = String::new(); + for token in text.split_inclusive(char::is_whitespace) { + let trimmed = token.trim_end_matches(char::is_whitespace); + let whitespace = &token[trimmed.len()..]; + let raw_clean = trimmed.trim_end_matches([',', '.', ')', ']', ';']); + let clean = strip_inline_code_marker(raw_clean); + let trailing = &trimmed[raw_clean.len()..]; + if is_url(clean) || looks_like_file_link(clean) { + if !current.is_empty() { + out.push(TextSegment::Plain(std::mem::take(&mut current))); + } + out.push(TextSegment::Link { + label: clean.to_string(), + target: clean.to_string(), + }); + current.push_str(trailing); + current.push_str(whitespace); + } else { + current.push_str(token); + } + } + if !current.is_empty() { + out.push(TextSegment::Plain(current)); + } +} + +fn looks_like_file_link(token: &str) -> bool { + if token.is_empty() || token.contains(' ') || token.starts_with('-') { + return false; + } + let path_part = token.split_once(':').map(|(path, _)| path).unwrap_or(token); + path_part.starts_with('/') + || path_part.starts_with("./") + || path_part.starts_with("../") + || (path_part.contains('/') && path_part.contains('.')) +} + +fn strip_inline_code_marker(token: &str) -> &str { + token + .strip_prefix('`') + .and_then(|inner| inner.strip_suffix('`')) + .unwrap_or(token) +} + +fn stable_text_id(text: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() +} diff --git a/src/app/input.rs b/src/app/input.rs index c8589e1..b7b47bf 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -3,7 +3,10 @@ use std::borrow::Cow; use alacritty_terminal::term::TermMode; use gpui::{ClipboardEntry, ClipboardItem, Context, KeyDownEvent, Keystroke, Modifiers}; -use crate::app::{app_view::AppView, session::selected_text}; +use crate::app::{ + app_view::{AppView, ContentMode}, + session::selected_text, +}; pub(crate) fn handle_key(this: &mut AppView, ev: &KeyDownEvent, cx: &mut Context) { let ks = &ev.keystroke; @@ -27,6 +30,10 @@ pub(crate) fn handle_key(this: &mut AppView, ev: &KeyDownEvent, cx: &mut Context this.open_settings_window(cx); return; } + if ks.key.eq_ignore_ascii_case("v") && this.content_mode == ContentMode::Codex { + this.paste_into_codex_composer(cx); + return; + } } if mods.control && !mods.alt && !mods.platform && !mods.function && ks.key == "tab" { @@ -66,13 +73,33 @@ pub(crate) fn handle_key(this: &mut AppView, ev: &KeyDownEvent, cx: &mut Context return; } "v" => { + if this.content_mode == ContentMode::Codex { + this.paste_into_codex_composer(cx); + return; + } paste_from_clipboard(this, cx); return; } + "x" => { + this.open_codex_pane(cx); + return; + } + "enter" if this.content_mode == ContentMode::Codex => { + this.submit_codex_prompt(cx); + return; + } _ => {} } } + if this.content_mode == ContentMode::Codex && !this.sidebar_focused { + // Codex owns the composer focus — unhandled keys (e.g. `up`, `tab`, + // `ctrl-c`, `ctrl-d`) must NOT fall through to the hidden PTY, where + // they'd silently mutate or interrupt the terminal. + let _ = handle_codex_composer_key(this, ks, mods, cx); + return; + } + if this.sidebar_focused { if !mods.control && !mods.alt && !mods.platform && !mods.function { match ks.key.as_str() { @@ -81,6 +108,9 @@ pub(crate) fn handle_key(this: &mut AppView, ev: &KeyDownEvent, cx: &mut Context return; } "escape" => { + if this.content_mode == ContentMode::Codex { + this.close_codex_pane(cx); + } this.focus_terminal(cx); return; } @@ -270,6 +300,67 @@ pub(crate) fn paste_from_clipboard(this: &mut AppView, cx: &mut Context } } +/// Routes a keystroke into the Codex composer. Returns true if the keystroke +/// was consumed. +fn handle_codex_composer_key( + this: &mut AppView, + ks: &Keystroke, + mods: Modifiers, + cx: &mut Context, +) -> bool { + // Plain `Esc` returns the user to the terminal. + if !mods.control && !mods.alt && !mods.platform && ks.key == "escape" { + this.close_codex_pane(cx); + return true; + } + // Ctrl modifiers and other top-level shortcuts are intentionally not + // consumed here — the earlier branches already handled them. + if mods.control || mods.platform || mods.function { + return false; + } + match ks.key.as_str() { + "backspace" => { + this.codex_composer_backspace(cx); + true + } + "delete" => { + this.codex_composer_delete(cx); + true + } + "left" => { + this.codex_composer_move_left(cx); + true + } + "right" => { + this.codex_composer_move_right(cx); + true + } + "home" => { + this.codex_composer_move_home(cx); + true + } + "end" => { + this.codex_composer_move_end(cx); + true + } + "enter" => { + // Plain Enter inserts a newline; Ctrl+Shift+Enter is the submit + // shortcut (handled above). + this.codex_composer_insert('\n', cx); + true + } + _ => { + if let Some(ch) = ks.key_char.as_ref().and_then(|s| s.chars().next()) { + if !ch.is_control() { + this.codex_composer_insert(ch, cx); + return true; + } + } + false + } + } +} + pub(crate) fn copy_selection_to_clipboard(this: &mut AppView, cx: &mut Context) { let Some(text) = this.active_session_ref().and_then(selected_text) else { return; diff --git a/src/app/mod.rs b/src/app/mod.rs index 0849859..dac4a44 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4,6 +4,10 @@ mod app_view; mod assets; +mod codex; +#[allow(dead_code)] +mod codex_markdown; +mod codex_view; mod config; mod input; mod paint; @@ -14,6 +18,7 @@ mod settings_window; mod sidebar; mod theme; mod top_bar; +mod workspace_state; use std::{ collections::HashSet, @@ -26,35 +31,64 @@ use std::{ }, }; -use self::app_view::{terminal_metrics, AppView}; +use self::app_view::{terminal_metrics, AppView, ContentMode}; use self::assets::AppAssets; +use self::codex::{ + discover_binary as discover_codex_binary, load_codex_threads, + new_event_channel as new_codex_event_channel, CodexAccountState, CodexRuntimeHandle, + CodexRuntimeStatus, +}; use self::config::{ProjectId, SessionId, APP_NAME, BELL_SOUND}; use self::project::Project; use self::session::spawn_pty_session; use self::settings::load_settings; use self::theme::ThemeRegistry; +use self::workspace_state::{load_workspaces, save_workspaces}; use gpui::*; pub(crate) fn run() -> Result<(), Box> { let (dirty_tx, dirty_rx) = flume::unbounded::<()>(); let (bell_tx, bell_rx) = flume::unbounded::(); let (exited_tx, exited_rx) = flume::unbounded::(); + let (codex_event_tx, codex_event_rx) = new_codex_event_channel(); + let codex_handle = CodexRuntimeHandle::spawn(discover_codex_binary(None), codex_event_tx); + // The runtime stays idle until AppView::ensure_codex_started() is called + // from the first Codex action (open_codex_pane / new_codex_thread / login + // flow). Avoiding an unconditional EnsureStarted here keeps the binary + // spawn off the critical path for users who never open Codex. let main_focused = Arc::new(AtomicBool::new(true)); Application::new() .with_assets(AppAssets) .run(move |cx: &mut App| { let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); - let project_id: ProjectId = 1; - let project_name = cwd - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|| cwd.display().to_string()); - let initial_project = Project { - id: project_id, - name: project_name, - root_path: cwd.clone(), + let saved_workspaces: Vec = load_workspaces() + .into_iter() + .filter(|p| p.is_dir()) + .collect(); + let initial_paths: Vec = if saved_workspaces.is_empty() { + vec![cwd.clone()] + } else { + saved_workspaces }; + let initial_projects: Vec = initial_paths + .iter() + .enumerate() + .map(|(idx, path)| { + let name = path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + Project { + id: (idx as ProjectId) + 1, + name, + root_path: path.clone(), + } + }) + .collect(); + let initial_project_id = initial_projects[0].id; + let initial_project_path = initial_projects[0].root_path.clone(); + let next_project_id = initial_projects.last().unwrap().id + 1; let theme_registry = ThemeRegistry::load(); let app_settings = load_settings(); @@ -63,10 +97,14 @@ pub(crate) fn run() -> Result<(), Box> { let initial_terminal_settings = app_settings.terminal; let initial_metrics = terminal_metrics(cx, initial_terminal_settings); + // Persist the resolved set so a fresh install seeds the file on + // first run; restore is otherwise an exact no-op write. + save_workspaces(&initial_paths); + let initial_session = spawn_pty_session( 1, - project_id, - Some(cwd), + initial_project_id, + Some(initial_project_path), dirty_tx.clone(), bell_tx.clone(), exited_tx.clone(), @@ -74,9 +112,18 @@ pub(crate) fn run() -> Result<(), Box> { ) .expect("failed to spawn initial terminal"); + let projects_for_lookup = initial_projects.clone(); + let (loaded_codex_threads, next_codex_thread_id) = load_codex_threads(move |path| { + projects_for_lookup + .iter() + .find(|p| p.root_path == path) + .map(|p| p.id) + }); + let dirty_tx_for_view = dirty_tx.clone(); let bell_tx_for_view = bell_tx.clone(); let exited_tx_for_view = exited_tx.clone(); + let codex_handle_for_view = codex_handle.clone(); let main_focused_for_obs = main_focused.clone(); let main_window = cx @@ -94,12 +141,12 @@ pub(crate) fn run() -> Result<(), Box> { }) .detach(); AppView { - projects: vec![initial_project], + projects: initial_projects, sessions: vec![initial_session], active_session: Some(1), - active_project: Some(project_id), + active_project: Some(initial_project_id), next_session_id: 2, - next_project_id: 2, + next_project_id, focus_handle: focus_handle_for_init, dirty_tx: dirty_tx_for_view, bell_tx: bell_tx_for_view, @@ -114,6 +161,23 @@ pub(crate) fn run() -> Result<(), Box> { sidebar_visible: true, collapsed_projects: HashSet::new(), terminal_context_menu: None, + new_item_menu: None, + codex_runtime: Some(codex_handle_for_view), + codex_status: CodexRuntimeStatus::Disconnected, + codex_account: CodexAccountState::Unknown, + codex_threads: loaded_codex_threads, + active_codex_thread: None, + next_codex_thread_id, + codex_composer: String::new(), + codex_composer_cursor: 0, + codex_composer_focused: false, + codex_image_attachments: Vec::new(), + content_mode: ContentMode::Terminal, + codex_login: None, + codex_history: None, + codex_server_version: None, + codex_expanded_items: HashSet::new(), + codex_transcript_scroll: ScrollHandle::new(), } }); window.focus(&focus_handle); @@ -200,6 +264,25 @@ pub(crate) fn run() -> Result<(), Box> { } }) .detach(); + + // Codex runtime events → AppView::handle_codex_event. Bounded + // inbound channel, mirrors the dirty/exited/bell consumer style. + let view_for_codex = view.clone(); + cx.spawn(async move |cx| { + while let Ok(event) = codex_event_rx.recv_async().await { + if cx + .update(|app| { + view_for_codex.update(app, |this, cx| { + this.handle_codex_event(event, cx); + }); + }) + .is_err() + { + break; + } + } + }) + .detach(); }); Ok(()) diff --git a/src/app/project.rs b/src/app/project.rs index f397dc2..dad6c6c 100644 --- a/src/app/project.rs +++ b/src/app/project.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use crate::app::config::ProjectId; +#[derive(Clone)] pub(crate) struct Project { pub(crate) id: ProjectId, pub(crate) name: String, diff --git a/src/app/session.rs b/src/app/session.rs index 4cd9b1e..96fa439 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -97,6 +97,26 @@ pub(crate) fn spawn_pty_session( bell_tx: flume::Sender, exited_tx: flume::Sender, metrics: TerminalMetrics, +) -> Option { + spawn_pty_session_with_command( + id, project_id, cwd, dirty_tx, bell_tx, exited_tx, metrics, None, + ) +} + +/// Like [`spawn_pty_session`] but runs the user's login shell with +/// `-lc ` when `initial_command` is `Some`. The interactive +/// program (e.g. `claude`) inherits the shell's environment — `nvm`, `pyenv`, +/// `direnv`, etc. — and the PTY exits when the program exits. +#[allow(clippy::too_many_arguments)] +pub(crate) fn spawn_pty_session_with_command( + id: SessionId, + project_id: ProjectId, + cwd: Option, + dirty_tx: flume::Sender<()>, + bell_tx: flume::Sender, + exited_tx: flume::Sender, + metrics: TerminalMetrics, + initial_command: Option, ) -> Option { let window_size = WindowSize { num_cols: INITIAL_COLS as u16, @@ -104,10 +124,11 @@ pub(crate) fn spawn_pty_session( cell_width: metrics.cell_w.round().max(1.0) as u16, cell_height: metrics.cell_h.round().max(1.0) as u16, }; - let shell = Shell::new( - env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()), - vec![], - ); + let shell_program = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); + let shell = match initial_command { + Some(cmd) => Shell::new(shell_program, vec!["-lc".into(), cmd]), + None => Shell::new(shell_program, vec![]), + }; let tty_options = TtyOptions { shell: Some(shell), working_directory: cwd, diff --git a/src/app/sidebar.rs b/src/app/sidebar.rs index 64c413f..f02054e 100644 --- a/src/app/sidebar.rs +++ b/src/app/sidebar.rs @@ -1,11 +1,13 @@ +use gpui::prelude::FluentBuilder; use gpui::*; use crate::app::{ app_view::AppView, assets::{ - ICON_CHEVRON_DOWN, ICON_CHEVRON_RIGHT, ICON_FOLDER, ICON_FOLDER_PLUS, ICON_PLUS, - ICON_TERMINAL, + ICON_CHEVRON_DOWN, ICON_CHEVRON_RIGHT, ICON_FOLDER, ICON_FOLDER_PLUS, ICON_PALETTE, + ICON_PLUS, ICON_TERMINAL, }, + codex::{CodexThreadLocalId, LocalThreadStatus}, config::{ProjectId, SessionId, SIDEBAR_W_PX}, settings::UiSettings, theme::Theme, @@ -43,6 +45,19 @@ pub(crate) fn build_sidebar(app: &AppView, cx: &mut Context) -> impl In .iter() .map(|s| (s.id, s.project_id, s.title.clone(), s.unread)) .collect(); + let codex_thread_snapshots: Vec = app + .codex_threads + .iter() + .map(|t| CodexThreadSnapshot { + local_id: t.local_id, + project_id: t.project_id, + title: t.title.clone(), + status: t.status, + unread: t.unread, + pending_approvals: t.pending_approvals.len(), + }) + .collect(); + let active_codex_thread = app.active_codex_thread; for (pid, pname) in project_snapshots { let is_active_project = active_project_id == Some(pid); @@ -75,7 +90,7 @@ pub(crate) fn build_sidebar(app: &AppView, cx: &mut Context) -> impl In .child(icon(chevron, theme.icon_muted, 12.0)) .child(icon(ICON_FOLDER, header_color, 12.0)) .child(div().flex_1().truncate().child(pname)) - .child(new_terminal_button(pid, theme, cx)) + .child(new_item_button(pid, theme, cx)) .on_mouse_down( MouseButton::Left, cx.listener(move |this, _ev: &MouseDownEvent, _w, cx| { @@ -107,20 +122,171 @@ pub(crate) fn build_sidebar(app: &AppView, cx: &mut Context) -> impl In }), )); } + + for snap in codex_thread_snapshots + .iter() + .filter(|t| t.project_id == pid && t.status != LocalThreadStatus::Archived) + { + let active = active_codex_thread == Some(snap.local_id); + sidebar = sidebar.child(codex_thread_entry(snap, active, theme, ui, cx)); + } } sidebar + .child(codex_status_row(app, theme, ui)) .child(div().flex_1()) .child(new_project_row(theme, ui, cx)) } -fn new_terminal_button( - pid: ProjectId, +struct CodexThreadSnapshot { + local_id: CodexThreadLocalId, + project_id: ProjectId, + title: String, + status: LocalThreadStatus, + unread: bool, + pending_approvals: usize, +} + +fn codex_thread_entry( + snap: &CodexThreadSnapshot, + active: bool, theme: &Theme, + ui: UiSettings, cx: &mut Context, ) -> impl IntoElement { + let bg = if active { + theme.element_selected + } else { + theme.sidebar_background + }; + let fg = if active { + theme.foreground + } else { + theme.text_muted + }; + let hover = if active { + theme.element_selected_hover + } else { + theme.element_hover + }; + let dot = match snap.status { + LocalThreadStatus::Running => Some(theme.accent), + LocalThreadStatus::Failed | LocalThreadStatus::Disconnected => Some(theme.warning), + LocalThreadStatus::WaitingForApproval => Some(theme.warning), + _ => None, + }; + let icon_color = if active { + theme.accent + } else { + theme.icon_muted + }; + let target_id = snap.local_id; + let approvals = snap.pending_approvals; + let unread = snap.unread; + div() - .id(ElementId::Name(format!("new-terminal-{}", pid).into())) + .id(ElementId::Name( + format!("codex-thread-{}", snap.local_id).into(), + )) + .flex() + .flex_row() + .items_center() + .gap_1() + .pl(px(22.0)) + .pr(px(8.0)) + .py(px(3.0)) + .bg(bg) + .text_color(fg) + .text_size(px(ui.font_size_px())) + .line_height(px(ui.line_height_px())) + .cursor_pointer() + .hover(move |s| s.bg(hover)) + .child(icon(ICON_PALETTE, icon_color, 12.0)) + .child(div().flex_1().truncate().child(snap.title.clone())) + .when_some(dot, |el, color| { + el.child(div().w(px(6.0)).h(px(6.0)).rounded_full().bg(color)) + }) + .when(approvals > 0, |el| { + el.child( + div() + .px(px(4.0)) + .rounded_sm() + .bg(theme.warning) + .text_color(theme.background) + .text_size(px(ui.font_size_with_offset(-2.0))) + .child(format!("{}", approvals)), + ) + }) + .when(unread && !active, |el| { + el.child(div().w(px(6.0)).h(px(6.0)).rounded_full().bg(theme.warning)) + }) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _ev: &MouseDownEvent, _w, cx| { + this.switch_to_codex_thread(target_id, cx); + }), + ) +} + +fn codex_status_row(app: &AppView, theme: &Theme, ui: UiSettings) -> impl IntoElement { + use crate::app::codex::{CodexAccountState, CodexRuntimeStatus}; + + let (label, color) = match (&app.codex_status, &app.codex_account) { + (CodexRuntimeStatus::Failed(msg), _) => (format!("Codex: failed — {msg}"), theme.warning), + (CodexRuntimeStatus::Disconnected, _) => { + ("Codex: disconnected".to_string(), theme.text_muted) + } + (CodexRuntimeStatus::Starting, _) => ("Codex: starting…".to_string(), theme.text_muted), + (CodexRuntimeStatus::ShuttingDown, _) => { + ("Codex: shutting down".to_string(), theme.text_muted) + } + (CodexRuntimeStatus::Ready, CodexAccountState::SignedOutRequiresAuth) => { + ("Codex: signed out".to_string(), theme.warning) + } + (CodexRuntimeStatus::Ready, CodexAccountState::ChatGpt { email, .. }) => { + (format!("Codex: {email}"), theme.text_muted) + } + (CodexRuntimeStatus::Ready, CodexAccountState::ApiKey) => { + ("Codex: API key".to_string(), theme.text_muted) + } + (CodexRuntimeStatus::Ready, CodexAccountState::RateLimited { .. }) => { + ("Codex: rate limited".to_string(), theme.warning) + } + (CodexRuntimeStatus::Ready, _) => ("Codex: ready".to_string(), theme.text_muted), + }; + + let version_suffix = app + .codex_server_version + .as_ref() + .map(|v| format!("v{v}")) + .unwrap_or_default(); + + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .px(px(10.0)) + .py(px(4.0)) + .border_t_1() + .border_color(theme.border) + .text_color(color) + .text_size(px(ui.font_size_px() - 1.0)) + .line_height(px(ui.line_height_px())) + .child(div().flex_1().truncate().child(label)) + .when(!version_suffix.is_empty(), |el| { + el.child( + div() + .text_color(theme.text_muted) + .text_size(px(ui.font_size_with_offset(-2.0))) + .child(version_suffix), + ) + }) +} + +fn new_item_button(pid: ProjectId, theme: &Theme, cx: &mut Context) -> impl IntoElement { + div() + .id(ElementId::Name(format!("new-item-{}", pid).into())) .flex() .items_center() .justify_center() @@ -133,9 +299,10 @@ fn new_terminal_button( .child(icon(ICON_PLUS, theme.icon_muted, 12.0)) .on_mouse_down( MouseButton::Left, - cx.listener(move |this, _ev: &MouseDownEvent, _w, cx| { + cx.listener(move |this, ev: &MouseDownEvent, _w, cx| { cx.stop_propagation(); - this.new_session_in_project(pid, cx); + this.active_project = Some(pid); + this.open_new_item_menu(pid, ev.position, cx); }), ) } diff --git a/src/app/workspace_state.rs b/src/app/workspace_state.rs new file mode 100644 index 0000000..88b2c66 --- /dev/null +++ b/src/app/workspace_state.rs @@ -0,0 +1,133 @@ +//! Persistence of the user's open workspaces. Saved to +//! `~/.config/quackcode/workspaces.json` so the project list reopens on the +//! next launch matching what was open at shutdown. + +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::app::config::{APP_NAME, CONFIG_DIR_NAME}; + +const WORKSPACES_FILE: &str = "workspaces.json"; + +#[derive(Debug, Default, Serialize, Deserialize)] +struct SavedWorkspaces { + #[serde(default)] + workspaces: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SavedWorkspace { + path: PathBuf, +} + +/// Loads the persisted workspace list. Missing or unreadable files yield an +/// empty list — the caller falls back to the launch directory. +pub(crate) fn load_workspaces() -> Vec { + let Some(path) = workspaces_path() else { + return Vec::new(); + }; + load_workspaces_from(&path) +} + +pub(crate) fn save_workspaces(paths: &[PathBuf]) { + let Some(path) = workspaces_path() else { + return; + }; + save_workspaces_to(&path, paths); +} + +fn workspaces_path() -> Option { + dirs::config_dir().map(|d| d.join(CONFIG_DIR_NAME).join(WORKSPACES_FILE)) +} + +fn load_workspaces_from(path: &Path) -> Vec { + let Ok(bytes) = fs::read(path) else { + return Vec::new(); + }; + let parsed: SavedWorkspaces = match serde_json::from_slice(&bytes) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + parsed.workspaces.into_iter().map(|w| w.path).collect() +} + +fn save_workspaces_to(path: &Path, paths: &[PathBuf]) { + if let Some(parent) = path.parent() { + if let Err(e) = fs::create_dir_all(parent) { + eprintln!( + "{APP_NAME}: failed to create config dir {}: {e}", + parent.display() + ); + return; + } + } + let saved = SavedWorkspaces { + workspaces: paths + .iter() + .map(|p| SavedWorkspace { path: p.clone() }) + .collect(), + }; + match serde_json::to_vec_pretty(&saved) { + Ok(bytes) => { + if let Err(e) = fs::write(path, bytes) { + eprintln!("{APP_NAME}: failed to write {}: {e}", path.display()); + } + } + Err(e) => eprintln!("{APP_NAME}: failed to serialize workspaces: {e}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + fn temp_path(name: &str) -> PathBuf { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + env::temp_dir().join(format!("quackcode-test-{name}-{stamp}.json")) + } + + #[test] + fn roundtrip_preserves_path_order() { + let p = temp_path("roundtrip"); + let inputs = vec![ + PathBuf::from("/a"), + PathBuf::from("/b"), + PathBuf::from("/c"), + ]; + save_workspaces_to(&p, &inputs); + let read = load_workspaces_from(&p); + assert_eq!(read, inputs); + let _ = fs::remove_file(&p); + } + + #[test] + fn missing_file_yields_empty_list() { + let p = temp_path("missing"); + assert!(load_workspaces_from(&p).is_empty()); + } + + #[test] + fn malformed_file_yields_empty_list() { + let p = temp_path("malformed"); + fs::write(&p, b"not json at all").unwrap(); + assert!(load_workspaces_from(&p).is_empty()); + let _ = fs::remove_file(&p); + } + + #[test] + fn overwrite_replaces_previous_state() { + let p = temp_path("overwrite"); + save_workspaces_to(&p, &[PathBuf::from("/old")]); + save_workspaces_to(&p, &[PathBuf::from("/new")]); + assert_eq!(load_workspaces_from(&p), vec![PathBuf::from("/new")]); + let _ = fs::remove_file(&p); + } +}