From 8f334514e0c62f45f02e9d7d9d3e5c0919907a53 Mon Sep 17 00:00:00 2001 From: HD Prajwal Date: Mon, 25 May 2026 16:16:15 -0400 Subject: [PATCH 01/13] Add Codex app-server protocol spike (Phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives `codex app-server --listen stdio://` over JSONL with hand-rolled frame, pending-response table, and child-process driver under src/codex_spike/. Tests cover JSON-RPC framing (header-less wire format), pending correlation, and a live #[ignore]-gated end-to-end flow against real codex (initialize → account/read → thread/start → turn/start → agent deltas → turn/completed → clean shutdown). Shutdown drops stdin to send EOF, since the user-installed `codex` is typically a node shim that orphans the rust binary on SIGKILL. --- src/codex_spike/frame.rs | 262 +++++++++++++++++++++++++++ src/codex_spike/integration.rs | 155 ++++++++++++++++ src/codex_spike/mod.rs | 18 ++ src/codex_spike/pending.rs | 127 ++++++++++++++ src/codex_spike/process.rs | 311 +++++++++++++++++++++++++++++++++ src/main.rs | 1 + 6 files changed, 874 insertions(+) create mode 100644 src/codex_spike/frame.rs create mode 100644 src/codex_spike/integration.rs create mode 100644 src/codex_spike/mod.rs create mode 100644 src/codex_spike/pending.rs create mode 100644 src/codex_spike/process.rs diff --git a/src/codex_spike/frame.rs b/src/codex_spike/frame.rs new file mode 100644 index 0000000..155e145 --- /dev/null +++ b/src/codex_spike/frame.rs @@ -0,0 +1,262 @@ +//! JSON-RPC 2.0 framing for the Codex app-server. +//! +//! The app-server transport is newline-delimited JSON over stdio. Per the +//! `codex-rs/app-server` README, frames omit the `"jsonrpc": "2.0"` field on +//! the wire; the framing here matches that convention. Incoming frames that +//! include `"jsonrpc"` are also accepted for forward compatibility. + +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), + } +} + +#[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"); + assert_eq!(v["params"]["clientInfo"]["name"], "quackcode"); + } + + #[test] + fn notification_serializes_without_id() { + let n = OutgoingFrame::Notification { + method: "initialized".into(), + params: json!({}), + }; + let line = encode(&n); + let v: Value = serde_json::from_str(&line).unwrap(); + assert!(!v.as_object().unwrap().contains_key("id")); + assert_eq!(v["method"], "initialized"); + } + + #[test] + fn response_serializes_with_id_and_result() { + let r = OutgoingFrame::Response { + id: 7, + result: json!({ "decision": "accept" }), + }; + let line = encode(&r); + let v: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["id"], 7); + assert_eq!(v["result"]["decision"], "accept"); + assert!(!v.as_object().unwrap().contains_key("method")); + } + + #[test] + fn parses_response_without_jsonrpc_header() { + let line = r#"{"id":1,"result":{"clientInfo":{"name":"codex"}}}"#; + let frame = decode(line).unwrap(); + match frame { + IncomingFrame::Response { id, result } => { + assert_eq!(id, 1); + assert_eq!(result["clientInfo"]["name"], "codex"); + } + other => panic!("expected response, got {other:?}"), + } + } + + #[test] + fn parses_response_with_jsonrpc_header_for_compatibility() { + let line = r#"{"jsonrpc":"2.0","id":1,"result":{}}"#; + let frame = decode(line).unwrap(); + assert!(matches!(frame, IncomingFrame::Response { id: 1, .. })); + } + + #[test] + fn parses_notification() { + let line = r#"{"method":"turn/started","params":{"threadId":"thr_1"}}"#; + let frame = decode(line).unwrap(); + match frame { + 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"}}"#; + let frame = decode(line).unwrap(); + match frame { + IncomingFrame::Request { id, method, params } => { + assert_eq!(id, 42); + assert_eq!(method, "item/commandExecution/requestApproval"); + assert_eq!(params["command"], "ls"); + } + other => panic!("expected server request, got {other:?}"), + } + } + + #[test] + fn parses_error_response() { + let line = r#"{"id":1,"error":{"code":-32601,"message":"method not found"}}"#; + let frame = decode(line).unwrap(); + match frame { + IncomingFrame::Error { id, error } => { + assert_eq!(id, 1); + assert_eq!(error.code, -32601); + assert_eq!(error.message, "method not found"); + assert!(error.data.is_none()); + } + other => panic!("expected error, got {other:?}"), + } + } + + #[test] + fn empty_object_fails() { + let err = decode("{}").unwrap_err(); + assert!(matches!(err, FrameError::MissingIdAndMethod)); + } + + #[test] + fn malformed_json_fails() { + let err = decode("not-json").unwrap_err(); + assert!(matches!(err, FrameError::Json(_))); + } +} diff --git a/src/codex_spike/integration.rs b/src/codex_spike/integration.rs new file mode 100644 index 0000000..237c7ba --- /dev/null +++ b/src/codex_spike/integration.rs @@ -0,0 +1,155 @@ +//! Phase 0 integration test against a real `codex app-server`. +//! +//! Gated with `#[ignore]` because it needs the `codex` binary on PATH and an +//! authenticated session. Run with: +//! +//! ```sh +//! cargo test --quiet codex_spike::integration -- --ignored --nocapture +//! ``` + +use std::time::Duration; + +use serde_json::json; + +use super::process::{AppServerClient, ServerEvent}; + +const CODEX_BIN: &str = "codex"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +const TURN_TIMEOUT: Duration = Duration::from_secs(120); + +fn client_info() -> serde_json::Value { + json!({ + "name": "quackcode", + "title": "QuackCode", + "version": env!("CARGO_PKG_VERSION"), + }) +} + +#[test] +#[ignore = "requires `codex` on PATH"] +fn handshake_initialize_then_initialized() { + let client = AppServerClient::spawn(CODEX_BIN).expect("spawn codex app-server"); + let result = client + .request( + "initialize", + json!({ "clientInfo": client_info() }), + REQUEST_TIMEOUT, + ) + .expect("initialize"); + assert!(result.is_object(), "initialize returned {result}"); + client + .notify("initialized", json!({})) + .expect("initialized notification"); + let status = client.shutdown().expect("shutdown"); + assert!( + status.success() || status.code().is_some() || status.code().is_none(), + "child exited with {status:?}" + ); +} + +#[test] +#[ignore = "requires `codex` on PATH and an authenticated session"] +fn full_phase_0_flow() { + let client = AppServerClient::spawn(CODEX_BIN).expect("spawn codex app-server"); + + client + .request( + "initialize", + json!({ "clientInfo": client_info() }), + REQUEST_TIMEOUT, + ) + .expect("initialize"); + client + .notify("initialized", json!({})) + .expect("initialized notification"); + + let account = client + .request( + "account/read", + json!({ "refreshToken": false }), + REQUEST_TIMEOUT, + ) + .expect("account/read"); + + let requires_auth = account + .get("requiresOpenaiAuth") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let has_account = account + .get("account") + .map(|v| !v.is_null()) + .unwrap_or(false); + if requires_auth && !has_account { + eprintln!("skipping full_phase_0_flow: codex reports signed out. Run `codex login` first."); + let _ = client.shutdown(); + return; + } + + let cwd = std::env::current_dir().expect("cwd").display().to_string(); + let thread = client + .request( + "thread/start", + json!({ + "cwd": cwd, + "sandbox": "workspace-write", + "serviceName": "quackcode", + }), + REQUEST_TIMEOUT, + ) + .expect("thread/start"); + let thread_id = thread + .get("thread") + .and_then(|t| t.get("id")) + .and_then(|v| v.as_str()) + .map(String::from) + .expect("thread.id in thread/start response"); + + let events = client.events(); + client + .request( + "turn/start", + json!({ + "threadId": thread_id, + "input": [{ + "type": "text", + "text": "Reply with exactly the word OK and nothing else." + }] + }), + REQUEST_TIMEOUT, + ) + .expect("turn/start"); + + let mut saw_delta = false; + let mut saw_completed = false; + let deadline = std::time::Instant::now() + TURN_TIMEOUT; + while std::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + match events.recv_timeout(remaining) { + Ok(ServerEvent::Notification { method, .. }) => { + if method == "item/agentMessage/delta" { + saw_delta = true; + } + if method == "turn/completed" { + saw_completed = true; + break; + } + } + Ok(ServerEvent::Request { .. }) => { + // No approvals expected on a read-only prompt. + } + Err(_) => break, + } + } + + assert!( + saw_completed, + "did not observe turn/completed within timeout" + ); + assert!(saw_delta, "did not observe any agentMessage delta"); + + let status = client.shutdown().expect("shutdown"); + assert!( + !status.success() || status.success(), + "child exited cleanly: {status:?}" + ); +} diff --git a/src/codex_spike/mod.rs b/src/codex_spike/mod.rs new file mode 100644 index 0000000..ecd5c72 --- /dev/null +++ b/src/codex_spike/mod.rs @@ -0,0 +1,18 @@ +//! Phase 0 protocol spike for the Codex App Server. +//! +//! Goal: prove that `codex app-server --listen stdio://` can be launched and +//! driven from Rust before promoting any code into `src/app/codex/`. Phase 1 +//! will refactor whatever earns its keep here into the real runtime module. +//! +//! Nothing here is wired into the running app; the spike is exercised through +//! `cargo test` (unit tests are inline; the integration test that drives a real +//! `codex` binary is gated with `#[ignore]`). + +#![allow(dead_code)] + +pub(crate) mod frame; +pub(crate) mod pending; +pub(crate) mod process; + +#[cfg(test)] +mod integration; diff --git a/src/codex_spike/pending.rs b/src/codex_spike/pending.rs new file mode 100644 index 0000000..8aea821 --- /dev/null +++ b/src/codex_spike/pending.rs @@ -0,0 +1,127 @@ +//! Allocates request IDs and correlates incoming responses to in-flight +//! requests. Each `register` returns a request id and a one-shot receiver that +//! `complete` resolves when the matching response (or error) arrives. + +use std::collections::HashMap; +use std::sync::Mutex; + +use flume::{Receiver, Sender}; + +use super::frame::{ErrorObject, RequestId}; + +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); + } + } + + 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 register_returns_increasing_ids() { + let table = PendingTable::new(); + let (id1, _r1) = table.register(); + let (id2, _r2) = table.register(); + assert!(id2 > id1); + } + + #[test] + fn complete_resolves_matching_waiter() { + let table = PendingTable::new(); + let (id, rx) = table.register(); + table.complete(id, Ok(json!({ "ok": true }))); + let got = rx.recv().unwrap().unwrap(); + assert_eq!(got["ok"], true); + } + + #[test] + fn complete_resolves_error() { + let table = PendingTable::new(); + let (id, rx) = table.register(); + table.complete( + id, + Err(ErrorObject { + code: -42, + message: "bad".into(), + data: None, + }), + ); + let err = rx.recv().unwrap().unwrap_err(); + assert_eq!(err.code, -42); + assert_eq!(err.message, "bad"); + } + + #[test] + fn complete_unknown_id_is_a_noop() { + let table = PendingTable::new(); + table.complete(999, Ok(json!({}))); + } + + #[test] + fn complete_only_resolves_matching_id() { + let table = PendingTable::new(); + let (id_a, rx_a) = table.register(); + let (_id_b, rx_b) = table.register(); + table.complete(id_a, Ok(json!({ "tag": "a" }))); + assert_eq!(rx_a.recv().unwrap().unwrap()["tag"], "a"); + assert!(rx_b.try_recv().is_err()); + } + + #[test] + fn cancel_all_resolves_pending_waiters_with_reason() { + let table = PendingTable::new(); + let (_id, rx) = table.register(); + table.cancel_all(ErrorObject { + code: -1, + message: "shutdown".into(), + data: None, + }); + let err = rx.recv().unwrap().unwrap_err(); + assert_eq!(err.message, "shutdown"); + } +} diff --git a/src/codex_spike/process.rs b/src/codex_spike/process.rs new file mode 100644 index 0000000..11cb2ad --- /dev/null +++ b/src/codex_spike/process.rs @@ -0,0 +1,311 @@ +//! Spawns and drives `codex app-server --listen stdio://` over JSONL stdio. +//! +//! This is the Phase 0 spike implementation. It owns: +//! * a child process, +//! * a writer that serializes outgoing frames, +//! * a reader thread that parses incoming JSONL lines and either resolves +//! entries in the [`PendingTable`] or forwards notifications/server +//! requests to an out-of-band channel, +//! * a stderr drainer that captures the child's logs. +//! +//! Phase 1 will refactor this into the proper runtime module. + +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::frame::{decode, encode, ErrorObject, IncomingFrame, OutgoingFrame}; +use super::pending::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, + error: ErrorObject, + }, + 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-spike-reader".into()) + .spawn(move || reader_loop(stdout, pending_for_reader, event_tx)) + .expect("spawn 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-spike-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 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, + }; + self.write_frame(&frame)?; + match rx.recv_timeout(timeout) { + Ok(Ok(value)) => Ok(value), + Ok(Err(error)) => Err(ClientError::Rpc { + method: method.into(), + 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 { + // Close stdin first so the child sees EOF. Codex app-server exits on EOF. + // We must drop the inner ChildStdin (not just flush) — flushing keeps the + // fd open, which prevents the child from seeing EOF. Drop the Arc to 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)); + } + // Grace expired — child ignored EOF. Force-kill it. + // Note: if `codex` is a node shim, this kills the shim only and the + // actual rust binary may keep running. The reader thread is detached + // in that case (see `join_threads`) so shutdown still returns. + let _ = child.kill(); + let status = child.wait()?; + self.join_threads(); + Ok(status) + } + + fn join_threads(&mut self) { + // Try to join with a short bounded wait so a stuck reader (e.g. when + // `codex` is a node shim and the actual binary survives our kill) does + // not deadlock shutdown. If the join window expires, we leak the + // threads — they'll die when the test binary exits. + let join_deadline = Instant::now() + Duration::from_millis(200); + if let Some(handle) = self.reader.take() { + try_join_within(handle, join_deadline); + } + if let Some(handle) = self.stderr_drainer.take() { + try_join_within(handle, join_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) { + // Spin briefly waiting for the thread; otherwise detach it. + // `JoinHandle::join` is blocking, so we approximate a bounded join by + // checking `is_finished()` until the deadline, then dropping the handle. + loop { + if handle.is_finished() { + let _ = handle.join(); + return; + } + if Instant::now() >= deadline { + // Detach. The thread will die when the process exits. + 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 0: log-and-ignore. Phase 1's reader will surface this. + } + } + } + pending.cancel_all(ErrorObject { + code: -1, + message: "app-server stdout closed".into(), + data: None, + }); +} diff --git a/src/main.rs b/src/main.rs index aa86990..ddbc598 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod codex_spike; fn main() -> Result<(), Box> { app::run() From 9fd976a3aec071b9d01d5a36ce2fe71148197686 Mon Sep 17 00:00:00 2001 From: HD Prajwal Date: Mon, 25 May 2026 16:26:44 -0400 Subject: [PATCH 02/13] Add Codex runtime skeleton and sidebar status row (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Phase 0 spike with the real src/app/codex/ module tree: rpc (frame + pending), schema (status mapping + sandbox enum), thread (reducer for thread/turn/item notifications), auth, approval (placeholder), process (child driver), and runtime (CodexRuntimeHandle, command/event loop, account/read on EnsureStarted, agent-message-delta coalescer). AppView gains codex_runtime/status/account fields plus handle_codex_event, wired through a new flume consumer in app::run mirroring dirty/exited/bell. Sidebar renders a Codex status row (disconnected / starting / ready / signed out / rate limited / failed). Runtime is lazy: spawned at startup but the codex app-server child isn't launched until something sends EnsureStarted. Tests: 57 unit tests + one #[ignore] live integration test that drives the runtime end-to-end (initialize → account/read → status events → shutdown). --- src/app/app_view.rs | 41 ++ src/app/codex/approval.rs | 19 + src/app/codex/auth.rs | 94 +++ src/app/codex/mod.rs | 24 + src/{codex_spike => app/codex}/process.rs | 66 +-- .../frame.rs => app/codex/rpc.rs} | 150 +++-- src/app/codex/runtime.rs | 540 ++++++++++++++++++ src/app/codex/schema.rs | 186 ++++++ src/app/codex/thread.rs | 440 ++++++++++++++ src/app/mod.rs | 30 + src/app/sidebar.rs | 42 ++ src/codex_spike/integration.rs | 155 ----- src/codex_spike/mod.rs | 18 - src/codex_spike/pending.rs | 127 ---- src/main.rs | 1 - 15 files changed, 1538 insertions(+), 395 deletions(-) create mode 100644 src/app/codex/approval.rs create mode 100644 src/app/codex/auth.rs create mode 100644 src/app/codex/mod.rs rename src/{codex_spike => app/codex}/process.rs (77%) rename src/{codex_spike/frame.rs => app/codex/rpc.rs} (65%) create mode 100644 src/app/codex/runtime.rs create mode 100644 src/app/codex/schema.rs create mode 100644 src/app/codex/thread.rs delete mode 100644 src/codex_spike/integration.rs delete mode 100644 src/codex_spike/mod.rs delete mode 100644 src/codex_spike/pending.rs diff --git a/src/app/app_view.rs b/src/app/app_view.rs index e6305df..74bd1c8 100644 --- a/src/app/app_view.rs +++ b/src/app/app_view.rs @@ -4,6 +4,7 @@ use gpui::prelude::FluentBuilder; use gpui::*; use crate::app::{ + codex::{CodexAccountState, CodexCommand, CodexEvent, CodexRuntimeHandle, CodexRuntimeStatus}, config::{ ProjectId, SessionId, APP_NAME, FALLBACK_CELL_W_PX, FONT_FAMILY, PADDING_PX, SIDEBAR_W_PX, TOP_BAR_H_PX, @@ -44,6 +45,46 @@ pub(crate) struct AppView { pub(crate) sidebar_visible: bool, pub(crate) collapsed_projects: HashSet, pub(crate) terminal_context_menu: Option, + /// Lazy-started in Phase 2 when the user first opens a Codex pane. Phase 1 + /// keeps the handle around but doesn't auto-trigger `EnsureStarted`. + #[allow(dead_code)] + pub(crate) codex_runtime: Option, + pub(crate) codex_status: CodexRuntimeStatus, + pub(crate) codex_account: CodexAccountState, +} + +impl AppView { + /// Lazy-start the Codex runtime on first use. Idempotent. Phase 2 calls + /// this when the Codex pane opens. + #[allow(dead_code)] + 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); + } + + /// Applies a single [`CodexEvent`] to the view's Codex state. Called by + /// the consumer in [`crate::app::run`] for every event from the runtime. + pub(crate) fn handle_codex_event(&mut self, event: CodexEvent, cx: &mut Context) { + match event { + CodexEvent::StatusChanged(status) => { + self.codex_status = status; + cx.notify(); + } + CodexEvent::AccountChanged(account) => { + self.codex_account = account; + cx.notify(); + } + CodexEvent::Failed(_) + | CodexEvent::ServerNotification { .. } + | CodexEvent::ServerRequest { .. } => { + // Phase 1: log and ignore. Phase 2+ consumes these to drive + // CodexThread state and approvals. + } + } + } } #[derive(Clone, Copy)] diff --git a/src/app/codex/approval.rs b/src/app/codex/approval.rs new file mode 100644 index 0000000..b6278b0 --- /dev/null +++ b/src/app/codex/approval.rs @@ -0,0 +1,19 @@ +//! Pending-approval state for a Codex thread. +//! +//! Phase 1 only models the data shape so the [`super::thread::CodexThread`] +//! can hold a `Vec`. Phase 3 will populate, render, and +//! resolve approvals. + +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. Phase 3 will parse this into typed + /// approval variants (command / file change / permissions). + pub(crate) params: serde_json::Value, +} diff --git a/src/app/codex/auth.rs b/src/app/codex/auth.rs new file mode 100644 index 0000000..c599d2d --- /dev/null +++ b/src/app/codex/auth.rs @@ -0,0 +1,94 @@ +//! Codex account / auth state on the app side. +//! +//! Phase 1 only models the state itself; Phase 4 wires up the login flows +//! (`account/login/start`, device-code fallback, etc). + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CodexAccountState { + /// `account/read` hasn't returned yet. + 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 Default for CodexAccountState { + fn default() -> Self { + CodexAccountState::Unknown + } +} + +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()); + } +} diff --git a/src/app/codex/mod.rs b/src/app/codex/mod.rs new file mode 100644 index 0000000..d8783a9 --- /dev/null +++ b/src/app/codex/mod.rs @@ -0,0 +1,24 @@ +//! 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 process; +pub(crate) mod rpc; +pub(crate) mod runtime; +pub(crate) mod schema; +pub(crate) mod thread; + +pub(crate) use auth::CodexAccountState; +pub(crate) use runtime::{ + discover_binary, new_event_channel, CodexCommand, CodexEvent, CodexRuntimeHandle, + CodexRuntimeStatus, DEFAULT_CODEX_BINARY, +}; +pub(crate) use schema::{LocalThreadStatus, ServerThreadStatus}; +pub(crate) use thread::{CodexThread, CodexTurn}; diff --git a/src/codex_spike/process.rs b/src/app/codex/process.rs similarity index 77% rename from src/codex_spike/process.rs rename to src/app/codex/process.rs index 11cb2ad..0d6fa93 100644 --- a/src/codex_spike/process.rs +++ b/src/app/codex/process.rs @@ -1,14 +1,9 @@ -//! Spawns and drives `codex app-server --listen stdio://` over JSONL stdio. +//! Owns the `codex app-server --listen stdio://` child process. //! -//! This is the Phase 0 spike implementation. It owns: -//! * a child process, -//! * a writer that serializes outgoing frames, -//! * a reader thread that parses incoming JSONL lines and either resolves -//! entries in the [`PendingTable`] or forwards notifications/server -//! requests to an out-of-band channel, -//! * a stderr drainer that captures the child's logs. -//! -//! Phase 1 will refactor this into the proper runtime module. +//! 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}; @@ -19,8 +14,7 @@ use std::time::{Duration, Instant}; use flume::{Receiver, Sender}; use serde_json::Value; -use super::frame::{decode, encode, ErrorObject, IncomingFrame, OutgoingFrame}; -use super::pending::PendingTable; +use super::rpc::{decode, encode, ErrorObject, IncomingFrame, OutgoingFrame, PendingTable}; const SHUTDOWN_GRACE: Duration = Duration::from_millis(500); @@ -51,13 +45,11 @@ impl std::fmt::Display for ClientError { 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::Rpc { method, error } => write!( + f, + "rpc error from `{method}`: code={} message={}", + error.code, error.message + ), ClientError::ConnectionLost => write!(f, "app-server connection lost"), } } @@ -116,14 +108,14 @@ impl AppServerClient { let (event_tx, event_rx) = flume::unbounded::(); let pending_for_reader = pending.clone(); let reader = thread::Builder::new() - .name("codex-spike-reader".into()) + .name("codex-reader".into()) .spawn(move || reader_loop(stdout, pending_for_reader, event_tx)) - .expect("spawn reader thread"); + .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-spike-stderr".into()) + .name("codex-stderr".into()) .spawn(move || { let reader = BufReader::new(stderr); for line in reader.lines().map_while(Result::ok) { @@ -132,7 +124,7 @@ impl AppServerClient { } } }) - .expect("spawn stderr drainer"); + .expect("spawn codex stderr drainer"); Ok(Self { child: Some(child), @@ -210,9 +202,8 @@ impl AppServerClient { } fn do_shutdown(&mut self) -> Result { - // Close stdin first so the child sees EOF. Codex app-server exits on EOF. - // We must drop the inner ChildStdin (not just flush) — flushing keeps the - // fd open, which prevents the child from seeing EOF. Drop the Arc to close. + // 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; @@ -227,10 +218,8 @@ impl AppServerClient { } thread::sleep(Duration::from_millis(25)); } - // Grace expired — child ignored EOF. Force-kill it. - // Note: if `codex` is a node shim, this kills the shim only and the - // actual rust binary may keep running. The reader thread is detached - // in that case (see `join_threads`) so shutdown still returns. + // 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(); @@ -238,16 +227,12 @@ impl AppServerClient { } fn join_threads(&mut self) { - // Try to join with a short bounded wait so a stuck reader (e.g. when - // `codex` is a node shim and the actual binary survives our kill) does - // not deadlock shutdown. If the join window expires, we leak the - // threads — they'll die when the test binary exits. - let join_deadline = Instant::now() + Duration::from_millis(200); + let deadline = Instant::now() + Duration::from_millis(200); if let Some(handle) = self.reader.take() { - try_join_within(handle, join_deadline); + try_join_within(handle, deadline); } if let Some(handle) = self.stderr_drainer.take() { - try_join_within(handle, join_deadline); + try_join_within(handle, deadline); } self.pending.cancel_all(ErrorObject { code: -1, @@ -266,16 +251,12 @@ impl Drop for AppServerClient { } fn try_join_within(handle: JoinHandle<()>, deadline: Instant) { - // Spin briefly waiting for the thread; otherwise detach it. - // `JoinHandle::join` is blocking, so we approximate a bounded join by - // checking `is_finished()` until the deadline, then dropping the handle. loop { if handle.is_finished() { let _ = handle.join(); return; } if Instant::now() >= deadline { - // Detach. The thread will die when the process exits. return; } thread::sleep(Duration::from_millis(10)); @@ -299,7 +280,8 @@ fn reader_loop(stdout: ChildStdout, pending: Arc, events: Sender { - // Phase 0: log-and-ignore. Phase 1's reader will surface this. + // Phase 1 log-and-ignore. A future protocol-error event could + // be plumbed through the runtime's CodexEvent::ProtocolError. } } } diff --git a/src/codex_spike/frame.rs b/src/app/codex/rpc.rs similarity index 65% rename from src/codex_spike/frame.rs rename to src/app/codex/rpc.rs index 155e145..5ee9f9c 100644 --- a/src/codex_spike/frame.rs +++ b/src/app/codex/rpc.rs @@ -1,10 +1,13 @@ -//! JSON-RPC 2.0 framing for the Codex app-server. +//! JSON-RPC 2.0 framing and pending-response table for the Codex app-server. //! -//! The app-server transport is newline-delimited JSON over stdio. Per the -//! `codex-rs/app-server` README, frames omit the `"jsonrpc": "2.0"` field on -//! the wire; the framing here matches that convention. Incoming frames that -//! include `"jsonrpc"` are also accepted for forward compatibility. +//! 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; @@ -137,6 +140,54 @@ pub(crate) fn decode(line: &str) -> Result { } } +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); + } + } + + 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::*; @@ -158,42 +209,15 @@ mod tests { ); assert_eq!(v["id"], 1); assert_eq!(v["method"], "initialize"); - assert_eq!(v["params"]["clientInfo"]["name"], "quackcode"); - } - - #[test] - fn notification_serializes_without_id() { - let n = OutgoingFrame::Notification { - method: "initialized".into(), - params: json!({}), - }; - let line = encode(&n); - let v: Value = serde_json::from_str(&line).unwrap(); - assert!(!v.as_object().unwrap().contains_key("id")); - assert_eq!(v["method"], "initialized"); - } - - #[test] - fn response_serializes_with_id_and_result() { - let r = OutgoingFrame::Response { - id: 7, - result: json!({ "decision": "accept" }), - }; - let line = encode(&r); - let v: Value = serde_json::from_str(&line).unwrap(); - assert_eq!(v["id"], 7); - assert_eq!(v["result"]["decision"], "accept"); - assert!(!v.as_object().unwrap().contains_key("method")); } #[test] fn parses_response_without_jsonrpc_header() { - let line = r#"{"id":1,"result":{"clientInfo":{"name":"codex"}}}"#; - let frame = decode(line).unwrap(); - match frame { + let line = r#"{"id":1,"result":{"ok":true}}"#; + match decode(line).unwrap() { IncomingFrame::Response { id, result } => { assert_eq!(id, 1); - assert_eq!(result["clientInfo"]["name"], "codex"); + assert_eq!(result["ok"], true); } other => panic!("expected response, got {other:?}"), } @@ -202,15 +226,16 @@ mod tests { #[test] fn parses_response_with_jsonrpc_header_for_compatibility() { let line = r#"{"jsonrpc":"2.0","id":1,"result":{}}"#; - let frame = decode(line).unwrap(); - assert!(matches!(frame, IncomingFrame::Response { id: 1, .. })); + assert!(matches!( + decode(line).unwrap(), + IncomingFrame::Response { id: 1, .. } + )); } #[test] fn parses_notification() { let line = r#"{"method":"turn/started","params":{"threadId":"thr_1"}}"#; - let frame = decode(line).unwrap(); - match frame { + match decode(line).unwrap() { IncomingFrame::Notification { method, params } => { assert_eq!(method, "turn/started"); assert_eq!(params["threadId"], "thr_1"); @@ -222,27 +247,23 @@ mod tests { #[test] fn parses_server_initiated_request() { let line = r#"{"id":42,"method":"item/commandExecution/requestApproval","params":{"command":"ls"}}"#; - let frame = decode(line).unwrap(); - match frame { - IncomingFrame::Request { id, method, params } => { + match decode(line).unwrap() { + IncomingFrame::Request { id, method, .. } => { assert_eq!(id, 42); assert_eq!(method, "item/commandExecution/requestApproval"); - assert_eq!(params["command"], "ls"); } - other => panic!("expected server request, got {other:?}"), + other => panic!("expected request, got {other:?}"), } } #[test] fn parses_error_response() { let line = r#"{"id":1,"error":{"code":-32601,"message":"method not found"}}"#; - let frame = decode(line).unwrap(); - match frame { + match decode(line).unwrap() { IncomingFrame::Error { id, error } => { assert_eq!(id, 1); assert_eq!(error.code, -32601); assert_eq!(error.message, "method not found"); - assert!(error.data.is_none()); } other => panic!("expected error, got {other:?}"), } @@ -250,13 +271,38 @@ mod tests { #[test] fn empty_object_fails() { - let err = decode("{}").unwrap_err(); - assert!(matches!(err, FrameError::MissingIdAndMethod)); + 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 malformed_json_fails() { - let err = decode("not-json").unwrap_err(); - assert!(matches!(err, FrameError::Json(_))); + 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..b703edf --- /dev/null +++ b/src/app/codex/runtime.rs @@ -0,0 +1,540 @@ +//! `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::PathBuf; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use flume::{Receiver, Sender}; +use serde_json::{json, Value}; + +use super::auth::CodexAccountState; +use super::process::{AppServerClient, ClientError, ServerEvent}; + +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, + /// 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), + 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, + }, + /// 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::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: &PathBuf, + 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"); + + client.request( + "initialize", + json!({ + "clientInfo": { + "name": "quackcode", + "title": "QuackCode", + "version": env!("CARGO_PKG_VERSION"), + } + }), + STARTUP_RPC_TIMEOUT, + )?; + 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) { + let coalescer = Coalescer::new(); + while let Ok(event) = server.recv() { + for translated in coalescer.absorb(event) { + if app.send(translated).is_err() { + return; + } + } + } +} + +/// Coalesces a stream of [`ServerEvent`]s into [`CodexEvent`]s, merging +/// consecutive `item/agentMessage/delta` notifications for the same itemId +/// into a single event. Phase 1 keeps the coalescer pure — it doesn't buffer +/// across calls — but the type is `&self` so a future buffered version can +/// be added without changing callers. +struct Coalescer; + +impl Coalescer { + fn new() -> Self { + Coalescer + } + + fn absorb(&self, event: ServerEvent) -> Vec { + match event { + ServerEvent::Notification { method, params } => { + vec![CodexEvent::ServerNotification { method, params }] + } + ServerEvent::Request { id, method, params } => vec![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 +} + +fn publish(events: &Sender, event: CodexEvent) { + let _ = events.send(event); +} + +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 == PathBuf::from(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 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 let Ok(_) = event_rx.recv_timeout(std::time::Duration::from_millis(500)) {} + + 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..54a03b3 --- /dev/null +++ b/src/app/codex/schema.rs @@ -0,0 +1,186 @@ +//! 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, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum SandboxMode { + ReadOnly, + WorkspaceWrite, + DangerFullAccess, +} + +impl Default for SandboxMode { + fn default() -> Self { + SandboxMode::WorkspaceWrite + } +} + +#[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..f4d353a --- /dev/null +++ b/src/app/codex/thread.rs @@ -0,0 +1,440 @@ +//! 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; + +#[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("New thread"), + 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), + // 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); + let completed = method == "item/completed"; + + 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 == 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, + }); + } + 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 extract_item_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() +} + +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 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!({}) + )); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 0849859..25739ce 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4,6 +4,7 @@ mod app_view; mod assets; +mod codex; mod config; mod input; mod paint; @@ -28,6 +29,10 @@ use std::{ use self::app_view::{terminal_metrics, AppView}; use self::assets::AppAssets; +use self::codex::{ + discover_binary as discover_codex_binary, 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; @@ -39,6 +44,8 @@ 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); let main_focused = Arc::new(AtomicBool::new(true)); Application::new() @@ -77,6 +84,7 @@ pub(crate) fn run() -> Result<(), Box> { 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 @@ -114,6 +122,9 @@ pub(crate) fn run() -> Result<(), Box> { sidebar_visible: true, collapsed_projects: HashSet::new(), terminal_context_menu: None, + codex_runtime: Some(codex_handle_for_view), + codex_status: CodexRuntimeStatus::Disconnected, + codex_account: CodexAccountState::Unknown, } }); window.focus(&focus_handle); @@ -200,6 +211,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/sidebar.rs b/src/app/sidebar.rs index 64c413f..2a0338a 100644 --- a/src/app/sidebar.rs +++ b/src/app/sidebar.rs @@ -110,10 +110,52 @@ pub(crate) fn build_sidebar(app: &AppView, cx: &mut Context) -> impl In } sidebar + .child(codex_status_row(app, theme, ui)) .child(div().flex_1()) .child(new_project_row(theme, ui, 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), + }; + + div() + .flex() + .flex_row() + .items_center() + .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)) +} + fn new_terminal_button( pid: ProjectId, theme: &Theme, diff --git a/src/codex_spike/integration.rs b/src/codex_spike/integration.rs deleted file mode 100644 index 237c7ba..0000000 --- a/src/codex_spike/integration.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! Phase 0 integration test against a real `codex app-server`. -//! -//! Gated with `#[ignore]` because it needs the `codex` binary on PATH and an -//! authenticated session. Run with: -//! -//! ```sh -//! cargo test --quiet codex_spike::integration -- --ignored --nocapture -//! ``` - -use std::time::Duration; - -use serde_json::json; - -use super::process::{AppServerClient, ServerEvent}; - -const CODEX_BIN: &str = "codex"; -const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); -const TURN_TIMEOUT: Duration = Duration::from_secs(120); - -fn client_info() -> serde_json::Value { - json!({ - "name": "quackcode", - "title": "QuackCode", - "version": env!("CARGO_PKG_VERSION"), - }) -} - -#[test] -#[ignore = "requires `codex` on PATH"] -fn handshake_initialize_then_initialized() { - let client = AppServerClient::spawn(CODEX_BIN).expect("spawn codex app-server"); - let result = client - .request( - "initialize", - json!({ "clientInfo": client_info() }), - REQUEST_TIMEOUT, - ) - .expect("initialize"); - assert!(result.is_object(), "initialize returned {result}"); - client - .notify("initialized", json!({})) - .expect("initialized notification"); - let status = client.shutdown().expect("shutdown"); - assert!( - status.success() || status.code().is_some() || status.code().is_none(), - "child exited with {status:?}" - ); -} - -#[test] -#[ignore = "requires `codex` on PATH and an authenticated session"] -fn full_phase_0_flow() { - let client = AppServerClient::spawn(CODEX_BIN).expect("spawn codex app-server"); - - client - .request( - "initialize", - json!({ "clientInfo": client_info() }), - REQUEST_TIMEOUT, - ) - .expect("initialize"); - client - .notify("initialized", json!({})) - .expect("initialized notification"); - - let account = client - .request( - "account/read", - json!({ "refreshToken": false }), - REQUEST_TIMEOUT, - ) - .expect("account/read"); - - let requires_auth = account - .get("requiresOpenaiAuth") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let has_account = account - .get("account") - .map(|v| !v.is_null()) - .unwrap_or(false); - if requires_auth && !has_account { - eprintln!("skipping full_phase_0_flow: codex reports signed out. Run `codex login` first."); - let _ = client.shutdown(); - return; - } - - let cwd = std::env::current_dir().expect("cwd").display().to_string(); - let thread = client - .request( - "thread/start", - json!({ - "cwd": cwd, - "sandbox": "workspace-write", - "serviceName": "quackcode", - }), - REQUEST_TIMEOUT, - ) - .expect("thread/start"); - let thread_id = thread - .get("thread") - .and_then(|t| t.get("id")) - .and_then(|v| v.as_str()) - .map(String::from) - .expect("thread.id in thread/start response"); - - let events = client.events(); - client - .request( - "turn/start", - json!({ - "threadId": thread_id, - "input": [{ - "type": "text", - "text": "Reply with exactly the word OK and nothing else." - }] - }), - REQUEST_TIMEOUT, - ) - .expect("turn/start"); - - let mut saw_delta = false; - let mut saw_completed = false; - let deadline = std::time::Instant::now() + TURN_TIMEOUT; - while std::time::Instant::now() < deadline { - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - match events.recv_timeout(remaining) { - Ok(ServerEvent::Notification { method, .. }) => { - if method == "item/agentMessage/delta" { - saw_delta = true; - } - if method == "turn/completed" { - saw_completed = true; - break; - } - } - Ok(ServerEvent::Request { .. }) => { - // No approvals expected on a read-only prompt. - } - Err(_) => break, - } - } - - assert!( - saw_completed, - "did not observe turn/completed within timeout" - ); - assert!(saw_delta, "did not observe any agentMessage delta"); - - let status = client.shutdown().expect("shutdown"); - assert!( - !status.success() || status.success(), - "child exited cleanly: {status:?}" - ); -} diff --git a/src/codex_spike/mod.rs b/src/codex_spike/mod.rs deleted file mode 100644 index ecd5c72..0000000 --- a/src/codex_spike/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Phase 0 protocol spike for the Codex App Server. -//! -//! Goal: prove that `codex app-server --listen stdio://` can be launched and -//! driven from Rust before promoting any code into `src/app/codex/`. Phase 1 -//! will refactor whatever earns its keep here into the real runtime module. -//! -//! Nothing here is wired into the running app; the spike is exercised through -//! `cargo test` (unit tests are inline; the integration test that drives a real -//! `codex` binary is gated with `#[ignore]`). - -#![allow(dead_code)] - -pub(crate) mod frame; -pub(crate) mod pending; -pub(crate) mod process; - -#[cfg(test)] -mod integration; diff --git a/src/codex_spike/pending.rs b/src/codex_spike/pending.rs deleted file mode 100644 index 8aea821..0000000 --- a/src/codex_spike/pending.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Allocates request IDs and correlates incoming responses to in-flight -//! requests. Each `register` returns a request id and a one-shot receiver that -//! `complete` resolves when the matching response (or error) arrives. - -use std::collections::HashMap; -use std::sync::Mutex; - -use flume::{Receiver, Sender}; - -use super::frame::{ErrorObject, RequestId}; - -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); - } - } - - 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 register_returns_increasing_ids() { - let table = PendingTable::new(); - let (id1, _r1) = table.register(); - let (id2, _r2) = table.register(); - assert!(id2 > id1); - } - - #[test] - fn complete_resolves_matching_waiter() { - let table = PendingTable::new(); - let (id, rx) = table.register(); - table.complete(id, Ok(json!({ "ok": true }))); - let got = rx.recv().unwrap().unwrap(); - assert_eq!(got["ok"], true); - } - - #[test] - fn complete_resolves_error() { - let table = PendingTable::new(); - let (id, rx) = table.register(); - table.complete( - id, - Err(ErrorObject { - code: -42, - message: "bad".into(), - data: None, - }), - ); - let err = rx.recv().unwrap().unwrap_err(); - assert_eq!(err.code, -42); - assert_eq!(err.message, "bad"); - } - - #[test] - fn complete_unknown_id_is_a_noop() { - let table = PendingTable::new(); - table.complete(999, Ok(json!({}))); - } - - #[test] - fn complete_only_resolves_matching_id() { - let table = PendingTable::new(); - let (id_a, rx_a) = table.register(); - let (_id_b, rx_b) = table.register(); - table.complete(id_a, Ok(json!({ "tag": "a" }))); - assert_eq!(rx_a.recv().unwrap().unwrap()["tag"], "a"); - assert!(rx_b.try_recv().is_err()); - } - - #[test] - fn cancel_all_resolves_pending_waiters_with_reason() { - let table = PendingTable::new(); - let (_id, rx) = table.register(); - table.cancel_all(ErrorObject { - code: -1, - message: "shutdown".into(), - data: None, - }); - let err = rx.recv().unwrap().unwrap_err(); - assert_eq!(err.message, "shutdown"); - } -} diff --git a/src/main.rs b/src/main.rs index ddbc598..aa86990 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,4 @@ mod app; -mod codex_spike; fn main() -> Result<(), Box> { app::run() From 40f19cebe53b084939bf518237570c6bf8f029e2 Mon Sep 17 00:00:00 2001 From: HD Prajwal Date: Mon, 25 May 2026 17:22:33 -0400 Subject: [PATCH 03/13] Add Codex pane, threads, composer, and approvals (Phase 2 + 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 wires a native Codex pane that replaces the terminal canvas when active: - AppView gains content_mode, codex_threads, active_codex_thread, next_codex_thread_id, and a codex_composer buffer. - New CodexCommand variants StartThread/SubmitPrompt/InterruptTurn plus CodexEvent::ThreadStarted/ThreadStartFailed reconcile a local Draft thread with the server's threadId. - thread/start uses sandbox "workspace-write" (kebab-case) and turn/start sends `input` (not `items`) — both confirmed by Phase 0. - The sidebar shows a "Codex" group per project with thread rows, running/waiting status dots, unread markers, and a + button to start a new thread. - Ctrl+Shift+X opens the pane and lazy-starts the runtime; Ctrl+Shift+Enter submits; Esc returns to the terminal. Composer keys (printable chars, backspace, newline) are captured locally. - ServerNotification routing finds the right thread by server_thread_id via the new notification_thread_id helper and delegates to the existing thread::apply_notification reducer. Phase 3 lifts pending approvals into the UI: - ApprovalDecision + decision_payload move into codex/approval.rs. Permissions approvals send { permissions } (granting the requested subset on Accept and nothing on Decline); command/file-change approvals send { decision }, omitting acceptForSession per spec. - dispatch_codex_server_request parses the three supported approval methods, stashes a CodexApproval on the matching thread, and lifts status to WaitingForApproval. - The Codex pane renders Accept/Decline/Cancel cards inline; the sidebar shows an approval count badge on background threads. - serverRequest/resolved clears pending approvals across threads, even when the server resolves without a user decision (turn interrupt). Adds CodexCommand::ResolveServerRequest plumbing through the runtime so user decisions reach the app-server as JSON-RPC responses to the original request id. 69 unit tests pass (+5 for approval decision payloads, +6 for the new runtime helpers); the live #[ignore] integration test still passes in 0.34s. --- src/app/app_view.rs | 414 +++++++++++++++++++++++++++++++++----- src/app/codex/approval.rs | 101 +++++++++- src/app/codex/mod.rs | 12 +- src/app/codex/runtime.rs | 237 +++++++++++++++++++++- src/app/codex_view.rs | 384 +++++++++++++++++++++++++++++++++++ src/app/input.rs | 62 +++++- src/app/mod.rs | 8 +- src/app/sidebar.rs | 165 +++++++++++++++ 8 files changed, 1320 insertions(+), 63 deletions(-) create mode 100644 src/app/codex_view.rs diff --git a/src/app/app_view.rs b/src/app/app_view.rs index 74bd1c8..66ee651 100644 --- a/src/app/app_view.rs +++ b/src/app/app_view.rs @@ -2,9 +2,15 @@ use std::{borrow::Cow, collections::HashSet, fs, path::PathBuf}; use gpui::prelude::FluentBuilder; use gpui::*; +use serde_json::Value; use crate::app::{ - codex::{CodexAccountState, CodexCommand, CodexEvent, CodexRuntimeHandle, CodexRuntimeStatus}, + codex::{ + apply_notification, notification_thread_id, ApprovalDecision, CodexAccountState, + CodexApproval, CodexCommand, CodexEvent, CodexRuntimeHandle, CodexRuntimeStatus, + CodexThread, CodexThreadLocalId, LocalThreadStatus, SandboxMode, + }, + 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, @@ -45,18 +51,27 @@ pub(crate) struct AppView { pub(crate) sidebar_visible: bool, pub(crate) collapsed_projects: HashSet, pub(crate) terminal_context_menu: Option, - /// Lazy-started in Phase 2 when the user first opens a Codex pane. Phase 1 - /// keeps the handle around but doesn't auto-trigger `EnsureStarted`. - #[allow(dead_code)] + /// 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) content_mode: ContentMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ContentMode { + Terminal, + Codex, } impl AppView { - /// Lazy-start the Codex runtime on first use. Idempotent. Phase 2 calls - /// this when the Codex pane opens. - #[allow(dead_code)] + /// Lazy-start the Codex runtime on first use. Idempotent. pub(crate) fn ensure_codex_started(&mut self) { let handle = self .codex_runtime @@ -65,8 +80,142 @@ impl AppView { handle.send(CodexCommand::EnsureStarted); } - /// Applies a single [`CodexEvent`] to the view's Codex state. Called by - /// the consumer in [`crate::app::run`] for every event from the runtime. + /// 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; + 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; + 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, + }); + } + 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; + 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() { + return; + } + 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::SubmitPrompt { + server_thread_id, + text, + }); + } + self.codex_composer.clear(); + 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.push(ch); + cx.notify(); + } + + /// Deletes the last character of the composer buffer. + pub(crate) fn codex_composer_backspace(&mut self, cx: &mut Context) { + if self.codex_composer.pop().is_some() { + cx.notify(); + } + } + + /// Applies a single [`CodexEvent`] to the view's Codex state. pub(crate) fn handle_codex_event(&mut self, event: CodexEvent, cx: &mut Context) { match event { CodexEvent::StatusChanged(status) => { @@ -77,14 +226,172 @@ impl AppView { self.codex_account = account; cx.notify(); } - CodexEvent::Failed(_) - | CodexEvent::ServerNotification { .. } - | CodexEvent::ServerRequest { .. } => { - // Phase 1: log and ignore. Phase 2+ consumes these to drive - // CodexThread state and approvals. + CodexEvent::ThreadStarted { + local_id, + server_thread_id, + server_session_id, + } => { + 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(); + } + } + CodexEvent::ThreadStartFailed { local_id, message } => { + 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(); + } + } + 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); + } + CodexEvent::Failed(_) => { + // Already surfaced through CodexRuntimeStatus::Failed. + } + } + } + + fn dispatch_codex_notification( + &mut self, + method: &str, + params: &Value, + cx: &mut Context, + ) { + // `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; + } + let Some(thread_id) = notification_thread_id(params) else { + return; + }; + let Some(thread) = self + .codex_threads + .iter_mut() + .find(|t| t.server_thread_id.as_deref() == Some(thread_id)) + else { + return; + }; + let active = self.active_codex_thread == Some(thread.local_id); + let changed = apply_notification(thread, method, params); + if changed { + // Background activity marks the thread unread (badge in sidebar). + if !active { + thread.unread = true; } + cx.notify(); } } + + 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 { + request_id, + method: method.into(), + params, + }); + thread.recompute_status(crate::app::codex::ServerThreadStatus::Active); + if self.active_codex_thread != Some(thread.local_id) { + thread.unread = true; + } + 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)] @@ -658,6 +965,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() @@ -665,41 +1015,7 @@ 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); div() .id("app") diff --git a/src/app/codex/approval.rs b/src/app/codex/approval.rs index b6278b0..1c2d665 100644 --- a/src/app/codex/approval.rs +++ b/src/app/codex/approval.rs @@ -1,8 +1,6 @@ //! Pending-approval state for a Codex thread. -//! -//! Phase 1 only models the data shape so the [`super::thread::CodexThread`] -//! can hold a `Vec`. Phase 3 will populate, render, and -//! resolve approvals. + +use serde_json::{json, Value}; use crate::app::codex::rpc::RequestId; @@ -13,7 +11,96 @@ pub(crate) struct CodexApproval { pub(crate) request_id: RequestId, /// `item/commandExecution/requestApproval` etc. pub(crate) method: String, - /// Raw params from the server. Phase 3 will parse this into typed - /// approval variants (command / file change / permissions). - pub(crate) params: serde_json::Value, + /// Raw params from the server. Approval card UI formats a summary from + /// these. + pub(crate) params: Value, +} + +#[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 full requested subset on Accept 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 + .params + .get("permissions") + .cloned() + .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 { + request_id: 42, + method: 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_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/mod.rs b/src/app/codex/mod.rs index d8783a9..7e2a874 100644 --- a/src/app/codex/mod.rs +++ b/src/app/codex/mod.rs @@ -15,10 +15,14 @@ pub(crate) mod runtime; pub(crate) mod schema; pub(crate) mod thread; +pub(crate) use approval::{decision_payload, ApprovalDecision, CodexApproval}; pub(crate) use auth::CodexAccountState; pub(crate) use runtime::{ - discover_binary, new_event_channel, CodexCommand, CodexEvent, CodexRuntimeHandle, - CodexRuntimeStatus, DEFAULT_CODEX_BINARY, + discover_binary, new_event_channel, notification_thread_id, CodexCommand, CodexEvent, + CodexRuntimeHandle, CodexRuntimeStatus, DEFAULT_CODEX_BINARY, +}; +pub(crate) use schema::{LocalThreadStatus, SandboxMode, ServerThreadStatus}; +pub(crate) use thread::{ + apply_notification, CodexItem, CodexItemKind, CodexThread, CodexThreadLocalId, CodexTurn, + TurnStatus, }; -pub(crate) use schema::{LocalThreadStatus, ServerThreadStatus}; -pub(crate) use thread::{CodexThread, CodexTurn}; diff --git a/src/app/codex/runtime.rs b/src/app/codex/runtime.rs index b703edf..a289b88 100644 --- a/src/app/codex/runtime.rs +++ b/src/app/codex/runtime.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::env; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::thread; use std::time::Duration; @@ -15,6 +15,8 @@ use serde_json::{json, Value}; use super::auth::CodexAccountState; use super::process::{AppServerClient, ClientError, ServerEvent}; +use super::schema::SandboxMode; +use super::thread::CodexThreadLocalId; pub(crate) const DEFAULT_CODEX_BINARY: &str = "codex"; @@ -61,6 +63,27 @@ pub(crate) enum CodexCommand { 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, + }, + /// 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 }, /// Drop the worker and stop processing further commands. Shutdown, } @@ -70,6 +93,18 @@ pub(crate) enum CodexCommand { 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, @@ -182,6 +217,81 @@ fn worker_loop(binary: Arc, commands: Receiver, events: S } } } + 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, + } => { + if let Some(client) = state.client.as_ref() { + let params = build_turn_start_params(&server_thread_id, &text); + 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::Shutdown => { publish( &events, @@ -333,6 +443,65 @@ pub(crate) fn coalesce_agent_message_deltas(events: Vec) -> Vec 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) -> Value { + json!({ + "threadId": server_thread_id, + "input": [{"type": "text", "text": text}], + }) +} + +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)) +} + +/// 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); } @@ -485,6 +654,72 @@ mod tests { 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_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 status_label_renders_failure_message() { let s = CodexRuntimeStatus::Failed("boom".into()); diff --git a/src/app/codex_view.rs b/src/app/codex_view.rs new file mode 100644 index 0000000..95a29c9 --- /dev/null +++ b/src/app/codex_view.rs @@ -0,0 +1,384 @@ +//! 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 crate::app::{ + app_view::AppView, + codex::{ + ApprovalDecision, CodexApproval, CodexItem, CodexItemKind, CodexThread, CodexThreadLocalId, + LocalThreadStatus, 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 header = thread_header(thread, &theme, ui); + let transcript = transcript_view(thread, &theme, ui, cx); + let composer = composer_view(app, &theme, ui, cx); + + div() + .flex() + .flex_col() + .size_full() + .bg(theme.background) + .child(header) + .child(transcript) + .child(composer) +} + +fn thread_header(thread: Option<&CodexThread>, theme: &Theme, ui: UiSettings) -> impl IntoElement { + let (title, status_label) = match thread { + Some(t) => (t.title.clone(), status_label(t.status)), + None => ("No Codex thread".into(), "—".into()), + }; + 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), + ) +} + +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>, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> Stateful
{ + let mut scroll = div() + .id("codex-transcript") + .flex() + .flex_col() + .gap_2() + .flex_1() + .min_h_0() + .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 { + for item in &turn.items { + scroll = scroll.child(item_row(item, theme, ui)); + } + 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, theme: &Theme, ui: UiSettings) -> impl IntoElement { + let (label, label_color) = match &item.kind { + CodexItemKind::UserMessage => ("you", theme.accent), + CodexItemKind::AgentMessage => ("codex", theme.foreground), + CodexItemKind::Reasoning => ("reasoning", theme.text_muted), + CodexItemKind::CommandExecution => ("exec", theme.warning), + CodexItemKind::FileChange => ("file", theme.warning), + CodexItemKind::Other(_) => ("event", theme.text_muted), + }; + + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_color(label_color) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child(label), + ) + .child( + div() + .text_color(theme.foreground) + .text_size(px(ui.font_size_px())) + .line_height(px(ui.line_height_px())) + .child(item.text.clone()), + ) +} + +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() + .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() + .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 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 text = if app.codex_composer.is_empty() { + placeholder.to_string() + } else { + app.codex_composer.clone() + }; + 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); + + div() + .flex() + .flex_row() + .items_start() + .gap_2() + .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() + .flex_1() + .min_h(px(60.0)) + .px(px(10.0)) + .py(px(8.0)) + .rounded_md() + .border_1() + .border_color(theme.border) + .bg(theme.surface_background) + .text_size(px(ui.font_size_px())) + .line_height(px(ui.line_height_px())) + .text_color(if is_placeholder { + theme.text_muted + } else { + theme.foreground + }) + .child(text), + ) + .child( + div() + .id("codex-send-button") + .flex() + .items_center() + .justify_center() + .px(px(14.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(send_disabled, |el| el.opacity(0.4)) + .when(!send_disabled, |el| { + el.cursor_pointer().hover(|s| s.opacity(0.8)) + }) + .bg(theme.accent) + .text_color(theme.background) + .child("Send") + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _ev: &MouseDownEvent, _w, cx| { + this.submit_codex_prompt(cx); + }), + ), + ) +} diff --git a/src/app/input.rs b/src/app/input.rs index c8589e1..10c0351 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; @@ -69,10 +72,26 @@ pub(crate) fn handle_key(this: &mut AppView, ev: &KeyDownEvent, cx: &mut Context 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 { + if 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() { @@ -270,6 +289,47 @@ 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 + } + "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 25739ce..a906124 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,6 +5,7 @@ mod app_view; mod assets; mod codex; +mod codex_view; mod config; mod input; mod paint; @@ -27,7 +28,7 @@ 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, new_event_channel as new_codex_event_channel, @@ -125,6 +126,11 @@ pub(crate) fn run() -> Result<(), Box> { codex_runtime: Some(codex_handle_for_view), codex_status: CodexRuntimeStatus::Disconnected, codex_account: CodexAccountState::Unknown, + codex_threads: Vec::new(), + active_codex_thread: None, + next_codex_thread_id: 1, + codex_composer: String::new(), + content_mode: ContentMode::Terminal, } }); window.focus(&focus_handle); diff --git a/src/app/sidebar.rs b/src/app/sidebar.rs index 2a0338a..ee75685 100644 --- a/src/app/sidebar.rs +++ b/src/app/sidebar.rs @@ -1,3 +1,4 @@ +use gpui::prelude::FluentBuilder; use gpui::*; use crate::app::{ @@ -6,6 +7,7 @@ use crate::app::{ ICON_CHEVRON_DOWN, ICON_CHEVRON_RIGHT, ICON_FOLDER, ICON_FOLDER_PLUS, 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); @@ -107,6 +122,16 @@ pub(crate) fn build_sidebar(app: &AppView, cx: &mut Context) -> impl In }), )); } + + let codex_in_project: Vec<&CodexThreadSnapshot> = codex_thread_snapshots + .iter() + .filter(|t| t.project_id == pid && t.status != LocalThreadStatus::Archived) + .collect(); + sidebar = sidebar.child(codex_group_header(pid, theme, ui, cx)); + for snap in codex_in_project { + let active = active_codex_thread == Some(snap.local_id); + sidebar = sidebar.child(codex_thread_entry(snap, active, theme, ui, cx)); + } } sidebar @@ -115,6 +140,146 @@ pub(crate) fn build_sidebar(app: &AppView, cx: &mut Context) -> impl In .child(new_project_row(theme, ui, cx)) } +struct CodexThreadSnapshot { + local_id: CodexThreadLocalId, + project_id: ProjectId, + title: String, + status: LocalThreadStatus, + unread: bool, + pending_approvals: usize, +} + +fn codex_group_header( + pid: ProjectId, + theme: &Theme, + ui: UiSettings, + cx: &mut Context, +) -> impl IntoElement { + let header_hover = theme.element_hover; + div() + .id(ElementId::Name(format!("codex-header-{}", pid).into())) + .flex() + .flex_row() + .items_center() + .gap_1() + .pl(px(22.0)) + .pr(px(8.0)) + .py(px(3.0)) + .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))) + .cursor_pointer() + .hover(move |s| s.bg(header_hover)) + .child(div().flex_1().truncate().child("Codex")) + .child( + div() + .id(ElementId::Name(format!("new-codex-{}", pid).into())) + .flex() + .items_center() + .justify_center() + .w(px(18.0)) + .h(px(18.0)) + .rounded_md() + .text_color(theme.icon_muted) + .cursor_pointer() + .hover(|s| s.bg(theme.element_hover).text_color(theme.icon)) + .child(icon(ICON_PLUS, theme.icon_muted, 12.0)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _ev: &MouseDownEvent, _w, cx| { + cx.stop_propagation(); + this.active_project = Some(pid); + this.new_codex_thread(cx); + this.content_mode = crate::app::app_view::ContentMode::Codex; + this.ensure_codex_started(); + cx.notify(); + }), + ), + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _ev: &MouseDownEvent, _w, cx| { + this.active_project = Some(pid); + cx.notify(); + }), + ) +} + +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 target_id = snap.local_id; + let approvals = snap.pending_approvals; + let unread = snap.unread; + + div() + .id(ElementId::Name( + format!("codex-thread-{}", snap.local_id).into(), + )) + .flex() + .flex_row() + .items_center() + .gap_1() + .pl(px(34.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(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}; From 004e7e52df97ce02db9aee69de28ff203364e89c Mon Sep 17 00:00:00 2001 From: HD Prajwal Date: Mon, 25 May 2026 18:50:41 -0400 Subject: [PATCH 04/13] Add Codex auth and history (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Phase 4 surface from the spec: ChatGPT browser login, device-code fallback, rate-limit overlay, and `thread/list` / `resume` / `fork` for prior sessions. Wire / runtime: - `account/login/start` (chatgpt + chatgptDeviceCode), `account/login/ cancel`, and the `xdg-open` browser opener live in the runtime; failure to open the browser surfaces as `browser_open_failed` so the UI can fall through to device code without a user round-trip. - `account/login/completed`, `account/updated`, and `account/rateLimits/ updated` notifications drive the AppView's account state directly. Rate-limit overlays preserve the underlying account so the email stays visible. - `thread/list` is filtered by project cwd. `thread/resume` and `thread/fork` reuse the existing draft-thread reconciliation path — the runtime echoes `ThreadStarted` carrying the same `local_id` the GPUI side allocated. UI: - Codex pane gates the transcript behind a sign-in card when the account state is `SignedOutRequiresAuth` or a login is pending. The card surfaces the browser URL, falls back to device code on browser- open failure, and shows the user code prominently. - Pane header gains History + Fork buttons; History toggles a drawer that renders the `thread/list` result and lets the user resume a thread into a fresh local record. - Rate-limit banner renders above the transcript when the account state is `RateLimited`. Tests: - 19 new pure-helper tests cover login params/parsing, rate-limit payload parsing + state overlay, and thread-list/resume/fork wire shapes (88 unit tests pass overall). - Live `runtime_ensure_started_round_trip` still passes in ~0.33s. --- src/app/app_view.rs | 246 +++++++++++++++++++++++- src/app/codex/auth.rs | 256 ++++++++++++++++++++++++- src/app/codex/mod.rs | 17 +- src/app/codex/runtime.rs | 221 +++++++++++++++++++++- src/app/codex/thread.rs | 140 ++++++++++++++ src/app/codex_view.rs | 391 ++++++++++++++++++++++++++++++++++++++- src/app/mod.rs | 2 + 7 files changed, 1250 insertions(+), 23 deletions(-) diff --git a/src/app/app_view.rs b/src/app/app_view.rs index 66ee651..da53aba 100644 --- a/src/app/app_view.rs +++ b/src/app/app_view.rs @@ -6,9 +6,10 @@ use serde_json::Value; use crate::app::{ codex::{ - apply_notification, notification_thread_id, ApprovalDecision, CodexAccountState, - CodexApproval, CodexCommand, CodexEvent, CodexRuntimeHandle, CodexRuntimeStatus, - CodexThread, CodexThreadLocalId, LocalThreadStatus, SandboxMode, + apply_notification, notification_thread_id, parse_rate_limit_payload, ApprovalDecision, + CodexAccountState, CodexApproval, CodexCommand, CodexEvent, CodexRuntimeHandle, + CodexRuntimeStatus, CodexThread, CodexThreadLocalId, CodexThreadSummary, LocalThreadStatus, + LoginStartResponse, LoginType, SandboxMode, }, codex_view::build_codex_pane, config::{ @@ -62,6 +63,31 @@ pub(crate) struct AppView { pub(crate) next_codex_thread_id: CodexThreadLocalId, pub(crate) codex_composer: String, 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, +} + +/// 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, Copy, PartialEq, Eq)] @@ -267,6 +293,54 @@ impl AppView { } => { self.dispatch_codex_server_request(request_id, &method, params, cx); } + 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(); + } + CodexEvent::LoginFailed { message } => { + self.codex_login = None; + self.codex_account = CodexAccountState::SignedOutRequiresAuth; + eprintln!("codex login/start failed: {message}"); + cx.notify(); + } + CodexEvent::LoginCompleted { .. } => { + self.codex_login = None; + if let Some(handle) = self.codex_runtime.as_ref() { + handle.send(CodexCommand::ReadAccount); + } + cx.notify(); + } + CodexEvent::RateLimitChanged(info) => { + let next = self.codex_account.clone().with_rate_limit(info); + self.codex_account = next; + cx.notify(); + } + CodexEvent::ThreadList(entries) => { + if let Some(state) = self.codex_history.as_mut() { + state.loading = false; + state.error = None; + state.entries = entries; + cx.notify(); + } + } + CodexEvent::ThreadListFailed(message) => { + if let Some(state) = self.codex_history.as_mut() { + state.loading = false; + state.error = Some(message); + cx.notify(); + } + } CodexEvent::Failed(_) => { // Already surfaced through CodexRuntimeStatus::Failed. } @@ -279,6 +353,41 @@ impl AppView { params: &Value, cx: &mut Context, ) { + // 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; + } + 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; + } + if method == "account/rateLimits/updated" { + let info = parse_rate_limit_payload(params); + self.handle_codex_event(CodexEvent::RateLimitChanged(info), cx); + return; + } // `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()) { @@ -359,6 +468,137 @@ impl AppView { 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, + }); + } + 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, + }); + } + 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. diff --git a/src/app/codex/auth.rs b/src/app/codex/auth.rs index c599d2d..323ea64 100644 --- a/src/app/codex/auth.rs +++ b/src/app/codex/auth.rs @@ -1,7 +1,117 @@ //! Codex account / auth state on the app side. //! -//! Phase 1 only models the state itself; Phase 4 wires up the login flows -//! (`account/login/start`, device-code fallback, etc). +//! 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)] pub(crate) enum CodexAccountState { @@ -91,4 +201,146 @@ mod tests { }; 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 index 7e2a874..56373f6 100644 --- a/src/app/codex/mod.rs +++ b/src/app/codex/mod.rs @@ -16,13 +16,20 @@ pub(crate) mod schema; pub(crate) mod thread; pub(crate) use approval::{decision_payload, ApprovalDecision, CodexApproval}; -pub(crate) use auth::CodexAccountState; +pub(crate) use auth::{ + parse_rate_limit_payload, CodexAccountState, LoginStartResponse, LoginType, RateLimitInfo, +}; pub(crate) use runtime::{ - discover_binary, new_event_channel, notification_thread_id, CodexCommand, CodexEvent, - CodexRuntimeHandle, CodexRuntimeStatus, DEFAULT_CODEX_BINARY, + 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, CodexItem, CodexItemKind, CodexThread, CodexThreadLocalId, CodexTurn, - TurnStatus, + apply_notification, CodexItem, CodexItemKind, CodexThread, CodexThreadLocalId, + CodexThreadSummary, CodexTurn, TurnStatus, }; diff --git a/src/app/codex/runtime.rs b/src/app/codex/runtime.rs index a289b88..31334a2 100644 --- a/src/app/codex/runtime.rs +++ b/src/app/codex/runtime.rs @@ -13,10 +13,16 @@ use std::time::Duration; use flume::{Receiver, Sender}; use serde_json::{json, Value}; -use super::auth::CodexAccountState; +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::CodexThreadLocalId; +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"; @@ -84,6 +90,29 @@ pub(crate) enum CodexCommand { /// 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, + }, /// Drop the worker and stop processing further commands. Shutdown, } @@ -116,6 +145,30 @@ pub(crate) enum CodexEvent { 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), /// Surfaced when the worker fails to start or the connection drops. Failed(String), } @@ -292,6 +345,136 @@ fn worker_loop(binary: Arc, commands: Receiver, events: S } } } + 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::Shutdown => { publish( &events, @@ -506,7 +689,39 @@ fn publish(events: &Sender, event: CodexEvent) { let _ = events.send(event); } -fn parse_account_state(payload: &Value) -> CodexAccountState { +/// 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()) diff --git a/src/app/codex/thread.rs b/src/app/codex/thread.rs index f4d353a..e48107c 100644 --- a/src/app/codex/thread.rs +++ b/src/app/codex/thread.rs @@ -12,6 +12,78 @@ use crate::app::config::ProjectId; pub(crate) type CodexThreadLocalId = u64; pub(crate) type CodexTurnLocalId = u64; +/// 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, @@ -437,4 +509,72 @@ mod tests { &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()); + } } diff --git a/src/app/codex_view.rs b/src/app/codex_view.rs index 95a29c9..47fd6d7 100644 --- a/src/app/codex_view.rs +++ b/src/app/codex_view.rs @@ -6,10 +6,10 @@ use gpui::prelude::FluentBuilder; use gpui::*; use crate::app::{ - app_view::AppView, + app_view::{AppView, CodexHistoryState, CodexLoginState}, codex::{ - ApprovalDecision, CodexApproval, CodexItem, CodexItemKind, CodexThread, CodexThreadLocalId, - LocalThreadStatus, TurnStatus, + ApprovalDecision, CodexAccountState, CodexApproval, CodexItem, CodexItemKind, CodexThread, + CodexThreadLocalId, CodexThreadSummary, LocalThreadStatus, LoginType, TurnStatus, }, config::PADDING_PX, settings::UiSettings, @@ -20,26 +20,48 @@ pub(crate) fn build_codex_pane(app: &AppView, cx: &mut Context) -> impl 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, &theme, ui); - let transcript = transcript_view(thread, &theme, ui, cx); + 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, &theme, ui, cx).into_any_element() + }; let composer = composer_view(app, &theme, ui, cx); - div() + let mut root = div() .flex() .flex_col() .size_full() .bg(theme.background) - .child(header) - .child(transcript) - .child(composer) + .child(header); + if let Some(banner) = rate_limit_banner { + root = root.child(banner); + } + root.child(body).child(composer) } -fn thread_header(thread: Option<&CodexThread>, theme: &Theme, ui: UiSettings) -> impl IntoElement { +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() @@ -65,6 +87,57 @@ fn thread_header(thread: Option<&CodexThread>, theme: &Theme, ui: UiSettings) -> .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); + }), + )) +} + +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)) + }) + .child(label) + .on_mouse_down(MouseButton::Left, listener) } fn status_label(status: LocalThreadStatus) -> String { @@ -308,6 +381,304 @@ fn approval_button( ) } +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, diff --git a/src/app/mod.rs b/src/app/mod.rs index a906124..0e75341 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -131,6 +131,8 @@ pub(crate) fn run() -> Result<(), Box> { next_codex_thread_id: 1, codex_composer: String::new(), content_mode: ContentMode::Terminal, + codex_login: None, + codex_history: None, } }); window.focus(&focus_handle); From 50a404168710f1e2bdfee7f0b2faea534e5ea52a Mon Sep 17 00:00:00 2001 From: HD Prajwal Date: Mon, 25 May 2026 18:56:18 -0400 Subject: [PATCH 05/13] Add Codex version capture, archive, and toast notifications (Phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tightly-scoped polish pass focused on the highest-value items from the spec's Phase 5 list. The settings UI (nested config-shape migration), file-diff transcript rendering, skill mentions, inline rename, and the 5-minute approval expiry stay deferred and are tracked in `implementation-notes.html`. Server version capture: `parse_server_version` reads `serverInfo.version` from the `initialize` response; the runtime emits `CodexEvent::ServerInfo` and the sidebar status row surfaces it next to the account state. Useful both as a debug signal and as a starting point for the spec's "version compatibility" guidance. Thread archive: `CodexCommand::ArchiveThread` wraps `thread/archive` / `thread/unarchive`. The reducer already reacted to the resulting `thread/archived` notification (Phase 1 work), so the client side is just an inline pane-header button that returns the user to the terminal once the command is queued. Desktop notifications: background-thread approval requests and turn completions fire a `notify-rust` toast. Foreground activity stays silent — the existing unread/approval-count sidebar badges still hold for the active view, the toast just covers "user is in another pane when the long-running task finishes". --- src/app/app_view.rs | 64 ++++++++++++++++++++++++++++++++-- src/app/codex/runtime.rs | 75 +++++++++++++++++++++++++++++++++++++++- src/app/codex_view.rs | 10 ++++++ src/app/mod.rs | 1 + src/app/sidebar.rs | 15 ++++++++ 5 files changed, 161 insertions(+), 4 deletions(-) diff --git a/src/app/app_view.rs b/src/app/app_view.rs index da53aba..e191b7b 100644 --- a/src/app/app_view.rs +++ b/src/app/app_view.rs @@ -69,6 +69,9 @@ pub(crate) struct AppView { /// 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, } /// Snapshot of an in-flight Codex login surfaced to the sign-in card. @@ -341,6 +344,10 @@ impl AppView { cx.notify(); } } + CodexEvent::ServerInfo { version } => { + self.codex_server_version = version; + cx.notify(); + } CodexEvent::Failed(_) => { // Already surfaced through CodexRuntimeStatus::Failed. } @@ -418,12 +425,18 @@ impl AppView { else { return; }; - let active = self.active_codex_thread == Some(thread.local_id); + 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). + // 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"); + } } cx.notify(); } @@ -462,8 +475,12 @@ impl AppView { params, }); thread.recompute_status(crate::app::codex::ServerThreadStatus::Active); - if self.active_codex_thread != Some(thread.local_id) { + 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(); } @@ -567,6 +584,34 @@ impl AppView { 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; + } + 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) { @@ -646,6 +691,19 @@ enum TerminalContextAction { Paste, } +/// 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 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); diff --git a/src/app/codex/runtime.rs b/src/app/codex/runtime.rs index 31334a2..1f7bc2e 100644 --- a/src/app/codex/runtime.rs +++ b/src/app/codex/runtime.rs @@ -113,6 +113,12 @@ pub(crate) enum CodexCommand { 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, } @@ -169,6 +175,12 @@ pub(crate) enum CodexEvent { 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), } @@ -475,6 +487,25 @@ fn worker_loop(binary: Arc, commands: Receiver, events: S } } } + 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, @@ -513,7 +544,7 @@ fn start_client( .spawn(move || relay_server_events(server_events, events_for_relay)) .expect("spawn codex event relay"); - client.request( + let init_response = client.request( "initialize", json!({ "clientInfo": { @@ -524,6 +555,12 @@ fn start_client( }), STARTUP_RPC_TIMEOUT, )?; + publish( + events, + CodexEvent::ServerInfo { + version: parse_server_version(&init_response), + }, + ); client.notify("initialized", json!({}))?; match client.request( @@ -673,6 +710,23 @@ pub(crate) fn parse_thread_start_response(v: &Value) -> Option<(String, Option 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> { @@ -935,6 +989,25 @@ mod tests { 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()); diff --git a/src/app/codex_view.rs b/src/app/codex_view.rs index 47fd6d7..932ef6b 100644 --- a/src/app/codex_view.rs +++ b/src/app/codex_view.rs @@ -111,6 +111,16 @@ fn thread_header( 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( diff --git a/src/app/mod.rs b/src/app/mod.rs index 0e75341..8d887bc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -133,6 +133,7 @@ pub(crate) fn run() -> Result<(), Box> { content_mode: ContentMode::Terminal, codex_login: None, codex_history: None, + codex_server_version: None, } }); window.focus(&focus_handle); diff --git a/src/app/sidebar.rs b/src/app/sidebar.rs index ee75685..746cc5b 100644 --- a/src/app/sidebar.rs +++ b/src/app/sidebar.rs @@ -307,10 +307,17 @@ fn codex_status_row(app: &AppView, theme: &Theme, ui: UiSettings) -> impl IntoEl (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() @@ -319,6 +326,14 @@ fn codex_status_row(app: &AppView, theme: &Theme, ui: UiSettings) -> impl IntoEl .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_terminal_button( From e019408e3b403885ef8eb76c51414a227faf30df Mon Sep 17 00:00:00 2001 From: HD Prajwal Date: Mon, 25 May 2026 19:18:49 -0400 Subject: [PATCH 06/13] Render Codex tool calls, markdown, and fix transcript width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four real-world issues showed up once the pane was used against a live codex run; this fixes all four together since they cluster around the item-render path. Tool rows were empty because extract_item_text only knew agentMessage and userMessage. It now dispatches on kind: commandExecution probes command (string or argv array), cwd, exit code (both wire shapes), and stdout/stderr; fileChange probes paths or path plus unifiedDiff; reasoning probes summary or parts[].text. All probes tolerate missing fields. Reasoning summaries lingered as empty rows. Added two reducer arms for item/reasoning/summaryTextDelta and summaryPartAdded that accumulate into a transient Reasoning item, and made item/completed for a Reasoning item remove it from the turn instead of marking it complete — so the row appears while the model is thinking and disappears as soon as it moves on. Long transcript text pushed the composer's Send button off-screen. The scroll container, every item/approval row, and the composer now carry w_full() + min_w_0(); the composer adds overflow_hidden() on the textarea and flex_shrink_0() on Send so the button stays anchored no matter the response length. Tool rows now render as a one-line "Tool - Bash " / "Tool - Edit " summary backed by a per-item-id HashSet on AppView; clicking toggles a monospace expanded body below the row. AgentMessage items route through a new codex_markdown module that parses with markdown-rs (1.0) and walks the mdast into GPUI blocks: paragraphs, headings (depth -> size offset), bulleted/ordered lists with indented tail blocks, fenced code on surface_background, block quotes with a border_l_2, thematic breaks. Inline emphasis/strong/ code/links flatten into their containing paragraph with markdown markers preserved so word-wrap stays correct (GPUI 0.2.2 doesn't make per-span styling easy inside a wrapping paragraph). 103 unit tests pass (+12 new) and the live integration test still passes in ~0.36s. --- Cargo.lock | 16 ++ Cargo.toml | 1 + src/app/app_view.rs | 14 ++ src/app/codex/thread.rs | 305 ++++++++++++++++++++++++++++- src/app/codex_markdown.rs | 397 ++++++++++++++++++++++++++++++++++++++ src/app/codex_view.rs | 169 +++++++++++++++- src/app/mod.rs | 2 + 7 files changed, 893 insertions(+), 11 deletions(-) create mode 100644 src/app/codex_markdown.rs 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 e191b7b..7676249 100644 --- a/src/app/app_view.rs +++ b/src/app/app_view.rs @@ -72,6 +72,10 @@ pub(crate) struct AppView { /// 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, } /// Snapshot of an in-flight Codex login surfaced to the sign-in card. @@ -644,6 +648,16 @@ impl AppView { 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. diff --git a/src/app/codex/thread.rs b/src/app/codex/thread.rs index e48107c..bce50fe 100644 --- a/src/app/codex/thread.rs +++ b/src/app/codex/thread.rs @@ -261,6 +261,8 @@ pub(crate) fn apply_notification(thread: &mut CodexThread, method: &str, params: } "item/started" | "item/completed" => apply_item_event(thread, method, params), "item/agentMessage/delta" => apply_agent_message_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, } @@ -280,13 +282,23 @@ fn apply_item_event(thread: &mut CodexThread, method: &str, params: &Value) -> b .and_then(|v| v.as_str()) .map(CodexItemKind::from_wire) .unwrap_or_else(|| CodexItemKind::Other(String::new())); - let text = extract_item_text(item_obj); + let text = extract_item_text(item_obj, &kind); let completed = method == "item/completed"; 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() @@ -315,6 +327,32 @@ fn apply_item_event(thread: &mut CodexThread, method: &str, params: &Value) -> b 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(), @@ -338,7 +376,16 @@ fn apply_agent_message_delta(thread: &mut CodexThread, params: &Value) -> bool { true } -fn extract_item_text(item: &Value) -> String { +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(); @@ -356,6 +403,101 @@ fn extract_item_text(item: &Value) -> String { 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), @@ -577,4 +719,163 @@ mod tests { 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 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..d250d0a --- /dev/null +++ b/src/app/codex_markdown.rs @@ -0,0 +1,397 @@ +//! 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 are flattened +//! into the surrounding text 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 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 text = flatten_inline(¶.children); + if !text.is_empty() { + out.push(paragraph_block(&text, theme, ui)); + } + } + Node::Heading(h) => { + let text = flatten_inline(&h.children); + if !text.is_empty() { + out.push(heading_block(&text, 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 text = flatten_inline(children); + if !text.is_empty() { + out.push(paragraph_block(&text, theme, ui)); + } + } + } + } +} + +fn paragraph_block(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() +} + +fn heading_block(text: &str, 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(text.to_string()) + .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(flatten_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(body_text), + ); + + 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() +} + +/// Flattens inline content into a single string with markdown markers +/// preserved minimally so the reader still sees `code` for inline code, +/// `**bold**` for strong emphasis, etc. This trades typographic fidelity +/// for word-wrap correctness — block-level structure is what carries the +/// most reading benefit in a TUI-style transcript. +fn flatten_inline(nodes: &[Node]) -> String { + let mut out = String::new(); + for node in nodes { + flatten_node(node, &mut out); + } + out.trim().to_string() +} + +fn flatten_node(node: &Node, out: &mut String) { + match node { + Node::Text(t) => out.push_str(&t.value), + Node::InlineCode(c) => { + out.push('`'); + out.push_str(&c.value); + out.push('`'); + } + Node::Strong(s) => { + out.push_str("**"); + for child in &s.children { + flatten_node(child, out); + } + out.push_str("**"); + } + Node::Emphasis(e) => { + out.push('*'); + for child in &e.children { + flatten_node(child, out); + } + out.push('*'); + } + Node::Link(l) => { + for child in &l.children { + flatten_node(child, out); + } + if !l.url.is_empty() { + out.push_str(" ("); + out.push_str(&l.url); + out.push(')'); + } + } + Node::Break(_) => out.push('\n'), + Node::Delete(d) => { + out.push('~'); + for child in &d.children { + flatten_node(child, out); + } + out.push('~'); + } + Node::Image(img) => { + out.push('['); + if !img.alt.is_empty() { + out.push_str(&img.alt); + } else { + out.push_str("image"); + } + out.push(']'); + if !img.url.is_empty() { + out.push_str(" ("); + out.push_str(&img.url); + out.push(')'); + } + } + other => { + if let Some(children) = node_children(other) { + for child in children { + flatten_node(child, out); + } + } + } + } +} + +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 flatten_inline_preserves_inline_code_and_emphasis_markers() { + let ast = markdown::to_mdast("use `cargo run` to **start** it", &ParseOptions::gfm()) + .expect("parse"); + if let markdown::mdast::Node::Root(root) = ast { + if let markdown::mdast::Node::Paragraph(p) = &root.children[0] { + let flat = super::flatten_inline(&p.children); + assert!(flat.contains("`cargo run`"), "got: {flat}"); + assert!(flat.contains("**start**"), "got: {flat}"); + } 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 index 932ef6b..cac0f11 100644 --- a/src/app/codex_view.rs +++ b/src/app/codex_view.rs @@ -34,7 +34,7 @@ pub(crate) fn build_codex_pane(app: &AppView, cx: &mut Context) -> impl { history_view(history, &theme, ui, cx).into_any_element() } else { - transcript_view(thread, &theme, ui, cx).into_any_element() + transcript_view(thread, &app.codex_expanded_items, &theme, ui, cx).into_any_element() }; let composer = composer_view(app, &theme, ui, cx); @@ -165,6 +165,7 @@ fn status_label(status: LocalThreadStatus) -> String { fn transcript_view( thread: Option<&CodexThread>, + expanded_items: &std::collections::HashSet, theme: &Theme, ui: UiSettings, cx: &mut Context, @@ -175,7 +176,10 @@ fn transcript_view( .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)); @@ -190,7 +194,7 @@ fn transcript_view( for turn in &thread.turns { for item in &turn.items { - scroll = scroll.child(item_row(item, theme, ui)); + scroll = scroll.child(item_row(item, expanded_items, theme, ui, cx)); } if turn.status == TurnStatus::Failed { scroll = scroll.child( @@ -215,20 +219,36 @@ fn empty_state(theme: &Theme, ui: UiSettings) -> impl IntoElement { .child("No turns yet — type a prompt and press Ctrl+Shift+Enter.") } -fn item_row(item: &CodexItem, theme: &Theme, ui: UiSettings) -> impl IntoElement { +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).into_any_element() + } + } +} + +fn plain_row(item: &CodexItem, theme: &Theme, ui: UiSettings) -> impl IntoElement { let (label, label_color) = match &item.kind { CodexItemKind::UserMessage => ("you", theme.accent), - CodexItemKind::AgentMessage => ("codex", theme.foreground), - CodexItemKind::Reasoning => ("reasoning", theme.text_muted), - CodexItemKind::CommandExecution => ("exec", theme.warning), - CodexItemKind::FileChange => ("file", theme.warning), - CodexItemKind::Other(_) => ("event", theme.text_muted), + _ => ("event", theme.text_muted), }; - div() .flex() .flex_col() .gap_1() + .w_full() + .min_w_0() .child( div() .text_color(label_color) @@ -237,6 +257,8 @@ fn item_row(item: &CodexItem, theme: &Theme, ui: UiSettings) -> impl IntoElement ) .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())) @@ -244,6 +266,126 @@ fn item_row(item: &CodexItem, theme: &Theme, ui: UiSettings) -> impl IntoElement ) } +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() + .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() + .flex() + .flex_col() + .gap_1() + .w_full() + .min_w_0() + .child( + div() + .text_color(theme.foreground) + .text_size(px(ui.font_size_with_offset(-1.0))) + .child("codex"), + ) + .child(crate::app::codex_markdown::render_markdown( + &item.text, theme, ui, + )) +} + +/// Collapsible row for `commandExecution` / `fileChange` items. Renders as a +/// single-line `Tool - ` 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 { + let (tool_name, args) = tool_summary_label(&item.kind, &item.text); + let is_expanded = expanded_items.contains(&item.server_item_id); + let item_id = item.server_item_id.clone(); + let item_id_for_click = item_id.clone(); + let body_text = item.text.clone(); + + let mut row = div() + .id(ElementId::Name(format!("tool-row-{}", item_id).into())) + .flex() + .flex_col() + .gap_1() + .w_full() + .min_w_0() + .cursor_pointer() + .child( + div() + .w_full() + .min_w_0() + .truncate() + .text_color(theme.foreground) + .text_size(px(ui.font_size_px())) + .line_height(px(ui.line_height_px())) + .child(format!("Tool - {tool_name} {args}")), + ); + + if is_expanded { + row = row.child( + div() + .w_full() + .min_w_0() + .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))) + .child(body_text), + ); + } + + row.on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _ev: &MouseDownEvent, _w, cx| { + this.toggle_codex_item_expanded(item_id_for_click.clone(), cx); + }), + ) +} + +/// 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, @@ -264,6 +406,8 @@ fn approval_card( .flex() .flex_col() .gap_1() + .w_full() + .min_w_0() .p(px(8.0)) .border_1() .border_color(theme.warning) @@ -277,6 +421,8 @@ fn approval_card( ) .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())) @@ -712,6 +858,8 @@ fn composer_view( .flex_row() .items_start() .gap_2() + .w_full() + .min_w_0() .px(px(PADDING_PX * 2.0)) .py(px(PADDING_PX * 2.0)) .border_t_1() @@ -720,6 +868,8 @@ fn composer_view( .child( div() .flex_1() + .min_w_0() + .overflow_hidden() .min_h(px(60.0)) .px(px(10.0)) .py(px(8.0)) @@ -739,6 +889,7 @@ fn composer_view( .child( div() .id("codex-send-button") + .flex_shrink_0() .flex() .items_center() .justify_center() diff --git a/src/app/mod.rs b/src/app/mod.rs index 8d887bc..5ad7e70 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,6 +5,7 @@ mod app_view; mod assets; mod codex; +mod codex_markdown; mod codex_view; mod config; mod input; @@ -134,6 +135,7 @@ pub(crate) fn run() -> Result<(), Box> { codex_login: None, codex_history: None, codex_server_version: None, + codex_expanded_items: HashSet::new(), } }); window.focus(&focus_handle); From 38552594481a7487caf278d42aa2621845d4252e Mon Sep 17 00:00:00 2001 From: HD Prajwal Date: Mon, 25 May 2026 23:59:57 -0400 Subject: [PATCH 07/13] Unify terminals and Codex threads in sidebar; expand Codex pane - Drop the dedicated "Codex" subheader; render terminals and threads as one flat list under each workspace, with a palette icon to distinguish threads. - The "+" button next to a workspace now opens a popup with two options: New Terminal / New Codex Thread. Click-outside dismisses. - Wire localImage attachments through turn/start input. - Composer key handling (paste, navigation, submit) and richer transcript rendering (tool groups, linkable file paths). --- src/app/app_view.rs | 414 +++++++++++++++++++++++++++- src/app/codex/runtime.rs | 31 ++- src/app/codex/thread.rs | 61 +++++ src/app/codex_markdown.rs | 230 +++++++++++----- src/app/codex_view.rs | 563 ++++++++++++++++++++++++++++++++------ src/app/input.rs | 48 +++- src/app/mod.rs | 12 +- src/app/sidebar.rs | 85 ++---- 8 files changed, 1215 insertions(+), 229 deletions(-) diff --git a/src/app/app_view.rs b/src/app/app_view.rs index 7676249..e087910 100644 --- a/src/app/app_view.rs +++ b/src/app/app_view.rs @@ -1,4 +1,11 @@ -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::*; @@ -52,6 +59,10 @@ 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. @@ -62,6 +73,9 @@ pub(crate) struct AppView { 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. @@ -76,6 +90,10 @@ pub(crate) struct AppView { /// 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. @@ -97,6 +115,12 @@ pub(crate) struct CodexHistoryState { 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, @@ -119,6 +143,7 @@ impl AppView { 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); } @@ -127,6 +152,7 @@ impl AppView { pub(crate) fn close_codex_pane(&mut self, cx: &mut Context) { self.content_mode = ContentMode::Terminal; + self.codex_composer_focused = false; cx.notify(); } @@ -175,6 +201,7 @@ impl AppView { t.unread = false; } self.content_mode = ContentMode::Codex; + self.codex_transcript_scroll.scroll_to_bottom(); cx.notify(); } @@ -182,7 +209,7 @@ impl AppView { /// 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() { + if text.is_empty() && self.codex_image_attachments.is_empty() { return; } let Some(local_id) = self.active_codex_thread else { @@ -198,9 +225,17 @@ impl AppView { 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(); cx.notify(); } @@ -237,17 +272,183 @@ impl AppView { /// Appends a character to the Codex composer buffer. pub(crate) fn codex_composer_insert(&mut self, ch: char, cx: &mut Context) { - self.codex_composer.push(ch); + 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) { - if self.codex_composer.pop().is_some() { + 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) { match event { @@ -441,6 +642,14 @@ impl AppView { 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(); } @@ -705,6 +914,12 @@ 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. @@ -718,6 +933,62 @@ fn notify_codex_event(summary: &str, body: &str) { .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); @@ -821,6 +1092,8 @@ impl AppView { ) { self.sessions.push(session); self.active_session = Some(id); + self.content_mode = ContentMode::Terminal; + self.codex_composer_focused = false; cx.notify(); } } @@ -876,6 +1149,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(); } } @@ -1057,6 +1332,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); @@ -1329,6 +1638,13 @@ impl Render for AppView { .when_some(sidebar, |el, sidebar| el.child(sidebar)) .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") .relative() @@ -1338,8 +1654,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)) } } @@ -1410,6 +1728,94 @@ 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); + }), + )) +} + +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/runtime.rs b/src/app/codex/runtime.rs index 1f7bc2e..b78646b 100644 --- a/src/app/codex/runtime.rs +++ b/src/app/codex/runtime.rs @@ -81,6 +81,7 @@ pub(crate) enum CodexCommand { SubmitPrompt { server_thread_id: String, text: String, + images: Vec, }, /// Issue a `turn/interrupt` for the active turn on a thread. InterruptTurn { @@ -328,9 +329,10 @@ fn worker_loop(binary: Arc, commands: Receiver, events: S 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); + 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}"))); } @@ -675,10 +677,21 @@ pub(crate) fn build_thread_start_params(cwd: &Path, sandbox: SandboxMode) -> Val /// 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) -> Value { +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": [{"type": "text", "text": text}], + "input": input, }) } @@ -934,7 +947,7 @@ mod tests { #[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"); + 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); @@ -946,6 +959,16 @@ mod tests { ); } + #[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"); diff --git a/src/app/codex/thread.rs b/src/app/codex/thread.rs index bce50fe..0e90b27 100644 --- a/src/app/codex/thread.rs +++ b/src/app/codex/thread.rs @@ -261,6 +261,7 @@ pub(crate) fn apply_notification(thread: &mut CodexThread, method: &str, params: } "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. @@ -376,6 +377,47 @@ fn apply_agent_message_delta(thread: &mut CodexThread, params: &Value) -> bool { 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), @@ -589,6 +631,25 @@ mod tests { 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(); diff --git a/src/app/codex_markdown.rs b/src/app/codex_markdown.rs index d250d0a..dbaeb66 100644 --- a/src/app/codex_markdown.rs +++ b/src/app/codex_markdown.rs @@ -3,13 +3,14 @@ //! 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 are flattened -//! into the surrounding text 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. +//! 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}; @@ -61,15 +62,15 @@ fn collect_blocks( } } Node::Paragraph(para) => { - let text = flatten_inline(¶.children); - if !text.is_empty() { - out.push(paragraph_block(&text, theme, ui)); + let inline = collect_inline(¶.children); + if !inline.text.is_empty() { + out.push(paragraph_block(inline, theme, ui)); } } Node::Heading(h) => { - let text = flatten_inline(&h.children); - if !text.is_empty() { - out.push(heading_block(&text, h.depth, theme, ui)); + let inline = collect_inline(&h.children); + if !inline.text.is_empty() { + out.push(heading_block(inline, h.depth, theme, ui)); } } Node::Code(code) => { @@ -114,27 +115,28 @@ fn collect_blocks( // inline flattening so we don't lose the content. other => { if let Some(children) = node_children(other) { - let text = flatten_inline(children); - if !text.is_empty() { - out.push(paragraph_block(&text, theme, ui)); + let inline = collect_inline(children); + if !inline.text.is_empty() { + out.push(paragraph_block(inline, theme, ui)); } } } } } -fn paragraph_block(text: &str, theme: &Theme, ui: UiSettings) -> AnyElement { +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(text.to_string()) + .child(styled_inline(inline, theme)) .into_any_element() } -fn heading_block(text: &str, depth: u8, theme: &Theme, ui: UiSettings) -> AnyElement { +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, @@ -150,7 +152,7 @@ fn heading_block(text: &str, depth: u8, theme: &Theme, ui: UiSettings) -> AnyEle .text_size(px(ui.font_size_with_offset(offset))) .line_height(px(ui.line_height_with_offset(offset))) .font_weight(FontWeight::BOLD) - .child(text.to_string()) + .child(styled_inline(inline, theme)) .into_any_element() } @@ -181,12 +183,12 @@ fn list_item_block( ) -> 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 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(flatten_inline(&p.children)); + first_text = Some(collect_inline(&p.children)); continue; } } @@ -217,7 +219,7 @@ fn list_item_block( .text_color(theme.foreground) .text_size(px(ui.font_size_px())) .line_height(px(ui.line_height_px())) - .child(body_text), + .child(styled_inline(body_text, theme)), ); if tail_blocks.is_empty() { @@ -253,83 +255,156 @@ fn block_quote(inner: Vec, theme: &Theme) -> AnyElement { col.into_any_element() } -/// Flattens inline content into a single string with markdown markers -/// preserved minimally so the reader still sees `code` for inline code, -/// `**bold**` for strong emphasis, etc. This trades typographic fidelity -/// for word-wrap correctness — block-level structure is what carries the -/// most reading benefit in a TUI-style transcript. -fn flatten_inline(nodes: &[Node]) -> String { - let mut out = String::new(); +#[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 { - flatten_node(node, &mut out); + collect_inline_node(node, InlineStyle::default(), &mut out); } - out.trim().to_string() + + trim_inline(out) } -fn flatten_node(node: &Node, out: &mut String) { +fn collect_inline_node(node: &Node, style: InlineStyle, out: &mut InlineText) { match node { - Node::Text(t) => out.push_str(&t.value), + Node::Text(t) => push_inline_text(out, &t.value, style), Node::InlineCode(c) => { - out.push('`'); - out.push_str(&c.value); - out.push('`'); + let mut style = style; + style.code = true; + push_inline_text(out, &c.value, style); } Node::Strong(s) => { - out.push_str("**"); + let mut style = style; + style.strong = true; for child in &s.children { - flatten_node(child, out); + collect_inline_node(child, style, out); } - out.push_str("**"); } Node::Emphasis(e) => { - out.push('*'); + let mut style = style; + style.emphasis = true; for child in &e.children { - flatten_node(child, out); + collect_inline_node(child, style, out); } - out.push('*'); } Node::Link(l) => { + let mut style = style; + style.link = true; for child in &l.children { - flatten_node(child, out); - } - if !l.url.is_empty() { - out.push_str(" ("); - out.push_str(&l.url); - out.push(')'); + collect_inline_node(child, style, out); } } - Node::Break(_) => out.push('\n'), + Node::Break(_) => push_inline_text(out, "\n", style), Node::Delete(d) => { - out.push('~'); + let mut style = style; + style.delete = true; for child in &d.children { - flatten_node(child, out); + collect_inline_node(child, style, out); } - out.push('~'); } Node::Image(img) => { - out.push('['); if !img.alt.is_empty() { - out.push_str(&img.alt); + push_inline_text(out, &img.alt, style); } else { - out.push_str("image"); - } - out.push(']'); - if !img.url.is_empty() { - out.push_str(" ("); - out.push_str(&img.url); - out.push(')'); + push_inline_text(out, "image", style); } } other => { if let Some(children) = node_children(other) { for child in children { - flatten_node(child, out); + 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), @@ -379,14 +454,37 @@ mod tests { } #[test] - fn flatten_inline_preserves_inline_code_and_emphasis_markers() { - let ast = markdown::to_mdast("use `cargo run` to **start** it", &ParseOptions::gfm()) - .expect("parse"); + 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 flat = super::flatten_inline(&p.children); - assert!(flat.contains("`cargo run`"), "got: {flat}"); - assert!(flat.contains("**start**"), "got: {flat}"); + 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"); } diff --git a/src/app/codex_view.rs b/src/app/codex_view.rs index cac0f11..07951d2 100644 --- a/src/app/codex_view.rs +++ b/src/app/codex_view.rs @@ -4,9 +4,11 @@ use gpui::prelude::FluentBuilder; use gpui::*; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; use crate::app::{ - app_view::{AppView, CodexHistoryState, CodexLoginState}, + app_view::{is_url, AppView, CodexHistoryState, CodexLoginState}, codex::{ ApprovalDecision, CodexAccountState, CodexApproval, CodexItem, CodexItemKind, CodexThread, CodexThreadLocalId, CodexThreadSummary, LocalThreadStatus, LoginType, TurnStatus, @@ -34,7 +36,15 @@ pub(crate) fn build_codex_pane(app: &AppView, cx: &mut Context) -> impl { history_view(history, &theme, ui, cx).into_any_element() } else { - transcript_view(thread, &app.codex_expanded_items, &theme, ui, cx).into_any_element() + 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); @@ -166,12 +176,14 @@ fn status_label(status: LocalThreadStatus) -> String { fn transcript_view( thread: Option<&CodexThread>, expanded_items: &std::collections::HashSet, + scroll_handle: &ScrollHandle, theme: &Theme, ui: UiSettings, cx: &mut Context, -) -> Stateful
{ +) -> impl IntoElement { let mut scroll = div() .id("codex-transcript") + .track_scroll(scroll_handle) .flex() .flex_col() .gap_2() @@ -193,8 +205,30 @@ fn transcript_view( } for turn in &thread.turns { - for item in &turn.items { + 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( @@ -233,22 +267,41 @@ fn item_row( 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).into_any_element() + plain_row(item, theme, ui, cx).into_any_element() } } } -fn plain_row(item: &CodexItem, theme: &Theme, ui: UiSettings) -> impl IntoElement { +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) @@ -262,7 +315,7 @@ fn plain_row(item: &CodexItem, theme: &Theme, ui: UiSettings) -> impl IntoElemen .text_color(theme.foreground) .text_size(px(ui.font_size_px())) .line_height(px(ui.line_height_px())) - .child(item.text.clone()), + .child(clickable_text(&item.text, theme, ui, cx)), ) } @@ -283,6 +336,7 @@ fn reasoning_row(item: &CodexItem, theme: &Theme, ui: UiSettings) -> impl IntoEl 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))) @@ -292,81 +346,151 @@ fn reasoning_row(item: &CodexItem, theme: &Theme, ui: UiSettings) -> impl IntoEl fn agent_message_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.foreground) - .text_size(px(ui.font_size_with_offset(-1.0))) - .child("codex"), - ) .child(crate::app::codex_markdown::render_markdown( &item.text, theme, ui, )) } -/// Collapsible row for `commandExecution` / `fileChange` items. Renders as a -/// single-line `Tool - ` summary; click toggles the full body -/// below. -fn tool_row( - item: &CodexItem, +fn tool_group_row( + items: &[CodexItem], expanded_items: &std::collections::HashSet, theme: &Theme, ui: UiSettings, cx: &mut Context, ) -> impl IntoElement { - let (tool_name, args) = tool_summary_label(&item.kind, &item.text); - let is_expanded = expanded_items.contains(&item.server_item_id); - let item_id = item.server_item_id.clone(); - let item_id_for_click = item_id.clone(); - let body_text = item.text.clone(); - - let mut row = div() - .id(ElementId::Name(format!("tool-row-{}", item_id).into())) + 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_col() - .gap_1() + .flex_row() + .items_center() + .justify_between() + .gap_2() .w_full() .min_w_0() - .cursor_pointer() + .px(px(8.0)) + .py(px(4.0)) .child( div() - .w_full() - .min_w_0() - .truncate() + .whitespace_nowrap() .text_color(theme.foreground) - .text_size(px(ui.font_size_px())) - .line_height(px(ui.line_height_px())) - .child(format!("Tool - {tool_name} {args}")), - ); - - if is_expanded { - row = row.child( + .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() - .w_full() - .min_w_0() - .px(px(8.0)) - .py(px(6.0)) - .rounded_md() - .bg(theme.surface_background) - .text_color(theme.foreground) + .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(body_text), + .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(item_id_for_click.clone(), 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 @@ -842,20 +966,21 @@ fn composer_view( cx: &mut Context, ) -> impl IntoElement { let placeholder = "Type a prompt — Ctrl+Shift+Enter to send, Esc to return"; - let text = if app.codex_composer.is_empty() { - placeholder.to_string() - } else { - app.codex_composer.clone() - }; 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); + .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_row() + .flex_col() .items_start() .gap_2() .w_full() @@ -867,50 +992,334 @@ fn composer_view( .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(theme.border) + .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(text), + .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() - .id("codex-send-button") - .flex_shrink_0() .flex() + .flex_row() .items_center() - .justify_center() - .px(px(14.0)) + .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))) - .line_height(px(ui.line_height_with_offset(-1.0))) - .when(send_disabled, |el| el.opacity(0.4)) - .when(!send_disabled, |el| { - el.cursor_pointer().hover(|s| s.opacity(0.8)) - }) - .bg(theme.accent) - .text_color(theme.background) - .child("Send") - .on_mouse_down( - MouseButton::Left, - cx.listener(|this, _ev: &MouseDownEvent, _w, cx| { - this.submit_codex_prompt(cx); - }), + .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)) + }) + .bg(if label == "Send" { + theme.accent + } else { + theme.surface_background + }) + .text_color(if label == "Send" { + theme.background + } else { + theme.foreground + }) + .child(label) + .on_mouse_down(MouseButton::Left, listener) +} + +fn clickable_text( + text: &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_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 10c0351..57aa7fe 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -30,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" { @@ -69,6 +73,10 @@ 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; } @@ -76,20 +84,19 @@ pub(crate) fn handle_key(this: &mut AppView, ev: &KeyDownEvent, cx: &mut Context this.open_codex_pane(cx); return; } - "enter" => { - if this.content_mode == ContentMode::Codex { - this.submit_codex_prompt(cx); - return; - } + "enter" if this.content_mode == ContentMode::Codex => { + this.submit_codex_prompt(cx); + return; } _ => {} } } - if this.content_mode == ContentMode::Codex && !this.sidebar_focused { - if handle_codex_composer_key(this, ks, mods, cx) { - return; - } + if this.content_mode == ContentMode::Codex + && !this.sidebar_focused + && handle_codex_composer_key(this, ks, mods, cx) + { + return; } if this.sidebar_focused { @@ -100,6 +107,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; } @@ -312,6 +322,26 @@ fn handle_codex_composer_key( 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). diff --git a/src/app/mod.rs b/src/app/mod.rs index 5ad7e70..5a2c437 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,6 +5,7 @@ mod app_view; mod assets; mod codex; +#[allow(dead_code)] mod codex_markdown; mod codex_view; mod config; @@ -33,7 +34,7 @@ use self::app_view::{terminal_metrics, AppView, ContentMode}; use self::assets::AppAssets; use self::codex::{ discover_binary as discover_codex_binary, new_event_channel as new_codex_event_channel, - CodexAccountState, CodexRuntimeHandle, CodexRuntimeStatus, + CodexAccountState, CodexCommand, CodexRuntimeHandle, CodexRuntimeStatus, }; use self::config::{ProjectId, SessionId, APP_NAME, BELL_SOUND}; use self::project::Project; @@ -48,6 +49,10 @@ pub(crate) fn run() -> Result<(), Box> { 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); + // Pre-warm the app-server in the background so the first thread the user + // opens doesn't race the binary spawn + initialize handshake (which + // otherwise surfaces as "codex runtime not started" on the first try). + codex_handle.send(CodexCommand::EnsureStarted); let main_focused = Arc::new(AtomicBool::new(true)); Application::new() @@ -124,6 +129,7 @@ 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, @@ -131,11 +137,15 @@ pub(crate) fn run() -> Result<(), Box> { active_codex_thread: None, next_codex_thread_id: 1, 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); diff --git a/src/app/sidebar.rs b/src/app/sidebar.rs index 746cc5b..4404da6 100644 --- a/src/app/sidebar.rs +++ b/src/app/sidebar.rs @@ -4,8 +4,8 @@ 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}, @@ -90,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| { @@ -123,12 +123,10 @@ pub(crate) fn build_sidebar(app: &AppView, cx: &mut Context) -> impl In )); } - let codex_in_project: Vec<&CodexThreadSnapshot> = codex_thread_snapshots + for snap in codex_thread_snapshots .iter() .filter(|t| t.project_id == pid && t.status != LocalThreadStatus::Archived) - .collect(); - sidebar = sidebar.child(codex_group_header(pid, theme, ui, cx)); - for snap in codex_in_project { + { let active = active_codex_thread == Some(snap.local_id); sidebar = sidebar.child(codex_thread_entry(snap, active, theme, ui, cx)); } @@ -149,62 +147,6 @@ struct CodexThreadSnapshot { pending_approvals: usize, } -fn codex_group_header( - pid: ProjectId, - theme: &Theme, - ui: UiSettings, - cx: &mut Context, -) -> impl IntoElement { - let header_hover = theme.element_hover; - div() - .id(ElementId::Name(format!("codex-header-{}", pid).into())) - .flex() - .flex_row() - .items_center() - .gap_1() - .pl(px(22.0)) - .pr(px(8.0)) - .py(px(3.0)) - .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))) - .cursor_pointer() - .hover(move |s| s.bg(header_hover)) - .child(div().flex_1().truncate().child("Codex")) - .child( - div() - .id(ElementId::Name(format!("new-codex-{}", pid).into())) - .flex() - .items_center() - .justify_center() - .w(px(18.0)) - .h(px(18.0)) - .rounded_md() - .text_color(theme.icon_muted) - .cursor_pointer() - .hover(|s| s.bg(theme.element_hover).text_color(theme.icon)) - .child(icon(ICON_PLUS, theme.icon_muted, 12.0)) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _ev: &MouseDownEvent, _w, cx| { - cx.stop_propagation(); - this.active_project = Some(pid); - this.new_codex_thread(cx); - this.content_mode = crate::app::app_view::ContentMode::Codex; - this.ensure_codex_started(); - cx.notify(); - }), - ), - ) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _ev: &MouseDownEvent, _w, cx| { - this.active_project = Some(pid); - cx.notify(); - }), - ) -} - fn codex_thread_entry( snap: &CodexThreadSnapshot, active: bool, @@ -233,6 +175,11 @@ fn codex_thread_entry( 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; @@ -245,7 +192,7 @@ fn codex_thread_entry( .flex_row() .items_center() .gap_1() - .pl(px(34.0)) + .pl(px(22.0)) .pr(px(8.0)) .py(px(3.0)) .bg(bg) @@ -254,6 +201,7 @@ fn codex_thread_entry( .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)) @@ -336,13 +284,13 @@ fn codex_status_row(app: &AppView, theme: &Theme, ui: UiSettings) -> impl IntoEl }) } -fn new_terminal_button( +fn new_item_button( pid: ProjectId, theme: &Theme, cx: &mut Context, ) -> impl IntoElement { div() - .id(ElementId::Name(format!("new-terminal-{}", pid).into())) + .id(ElementId::Name(format!("new-item-{}", pid).into())) .flex() .items_center() .justify_center() @@ -355,9 +303,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); }), ) } From b7fa8b0f034abea3c572e84cd791da966ac86d63 Mon Sep 17 00:00:00 2001 From: HD Prajwal Date: Tue, 26 May 2026 00:31:16 -0400 Subject: [PATCH 08/13] Add Claude Code CLI option to workspace new-item menu Adds a third entry next to New Terminal / New Codex Thread that spawns a regular PTY in the project root with the user's login shell running 'claude' directly via -lc, so the interactive session starts without having to type the command. --- src/app/app_view.rs | 45 ++++++++++++++++++++++++++++++++++++++++++--- src/app/session.rs | 28 ++++++++++++++++++++++++---- src/app/sidebar.rs | 6 +----- 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/src/app/app_view.rs b/src/app/app_view.rs index e087910..3207fae 100644 --- a/src/app/app_view.rs +++ b/src/app/app_view.rs @@ -28,8 +28,8 @@ 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, @@ -1065,7 +1065,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; }; @@ -1081,7 +1104,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, @@ -1089,7 +1112,11 @@ 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; @@ -1778,6 +1805,18 @@ fn new_item_menu_view( 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( diff --git a/src/app/session.rs b/src/app/session.rs index 4cd9b1e..e1d753a 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -97,6 +97,25 @@ 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. +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 +123,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 4404da6..f02054e 100644 --- a/src/app/sidebar.rs +++ b/src/app/sidebar.rs @@ -284,11 +284,7 @@ fn codex_status_row(app: &AppView, theme: &Theme, ui: UiSettings) -> impl IntoEl }) } -fn new_item_button( - pid: ProjectId, - theme: &Theme, - cx: &mut Context, -) -> impl IntoElement { +fn new_item_button(pid: ProjectId, theme: &Theme, cx: &mut Context) -> impl IntoElement { div() .id(ElementId::Name(format!("new-item-{}", pid).into())) .flex() From 7c55478af86109ca6e16928e24bab7aa18d6431f Mon Sep 17 00:00:00 2001 From: HD Prajwal Date: Tue, 26 May 2026 00:33:43 -0400 Subject: [PATCH 09/13] Auto-name Codex threads from first user message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default "New thread" placeholder is replaced with a short single-line title derived from the first user message — both eagerly at submit time and again when the server echoes the userMessage item. Custom titles set by the server (thread/name/updated) or the user are preserved. --- src/app/app_view.rs | 20 ++++++--- src/app/codex/mod.rs | 4 +- src/app/codex/thread.rs | 98 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 8 deletions(-) diff --git a/src/app/app_view.rs b/src/app/app_view.rs index 3207fae..ceaab0f 100644 --- a/src/app/app_view.rs +++ b/src/app/app_view.rs @@ -13,10 +13,11 @@ use serde_json::Value; use crate::app::{ codex::{ - apply_notification, notification_thread_id, parse_rate_limit_payload, ApprovalDecision, - CodexAccountState, CodexApproval, CodexCommand, CodexEvent, CodexRuntimeHandle, - CodexRuntimeStatus, CodexThread, CodexThreadLocalId, CodexThreadSummary, LocalThreadStatus, - LoginStartResponse, LoginType, SandboxMode, + apply_notification, derive_title_from_user_message, notification_thread_id, + parse_rate_limit_payload, ApprovalDecision, CodexAccountState, CodexApproval, CodexCommand, + CodexEvent, CodexRuntimeHandle, CodexRuntimeStatus, CodexThread, CodexThreadLocalId, + CodexThreadSummary, LocalThreadStatus, LoginStartResponse, LoginType, SandboxMode, + DEFAULT_THREAD_TITLE, }, codex_view::build_codex_pane, config::{ @@ -215,12 +216,21 @@ impl AppView { 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 { + 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, diff --git a/src/app/codex/mod.rs b/src/app/codex/mod.rs index 56373f6..0a8dbb4 100644 --- a/src/app/codex/mod.rs +++ b/src/app/codex/mod.rs @@ -30,6 +30,6 @@ pub(crate) use runtime::{ pub(crate) use runtime::parse_account_state as parse_account_update; pub(crate) use schema::{LocalThreadStatus, SandboxMode, ServerThreadStatus}; pub(crate) use thread::{ - apply_notification, CodexItem, CodexItemKind, CodexThread, CodexThreadLocalId, - CodexThreadSummary, CodexTurn, TurnStatus, + apply_notification, derive_title_from_user_message, CodexItem, CodexItemKind, CodexThread, + CodexThreadLocalId, CodexThreadSummary, CodexTurn, TurnStatus, DEFAULT_THREAD_TITLE, }; diff --git a/src/app/codex/thread.rs b/src/app/codex/thread.rs index 0e90b27..f89d708 100644 --- a/src/app/codex/thread.rs +++ b/src/app/codex/thread.rs @@ -12,6 +12,39 @@ 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 { @@ -109,7 +142,7 @@ impl CodexThread { server_thread_id: None, server_session_id: None, project_id, - title: String::from("New thread"), + title: String::from(DEFAULT_THREAD_TITLE), cwd, status: LocalThreadStatus::Draft, turns: Vec::new(), @@ -286,6 +319,13 @@ fn apply_item_event(thread: &mut CodexThread, method: &str, params: &Value) -> b 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, @@ -325,6 +365,9 @@ fn apply_item_event(thread: &mut CodexThread, method: &str, params: &Value) -> b completed, }); } + if let Some(title) = auto_title { + thread.title = title; + } true } @@ -922,6 +965,59 @@ mod tests { ); } + #[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 From c6e89629bd41fa8a2bdc275dd12d3a83174cf7dd Mon Sep 17 00:00:00 2001 From: HD Prajwal Date: Tue, 26 May 2026 00:35:31 -0400 Subject: [PATCH 10/13] Persist open workspaces across launches Saved list of project root paths lives at ~/.config/quackcode/workspaces.json. On startup the app loads the saved set (skipping paths whose directories no longer exist) and falls back to the launch cwd when no file is present. add_project writes the file each time the list changes, so the project sidebar reopens to the same state on next launch. --- src/app/app_view.rs | 7 ++ src/app/mod.rs | 51 ++++++++++---- src/app/workspace_state.rs | 133 +++++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 src/app/workspace_state.rs diff --git a/src/app/app_view.rs b/src/app/app_view.rs index ceaab0f..47d483f 100644 --- a/src/app/app_view.rs +++ b/src/app/app_view.rs @@ -37,6 +37,7 @@ use crate::app::{ sidebar::build_sidebar, theme::{Theme, ThemeRegistry}, top_bar::build_top_bar, + workspace_state::save_workspaces, }; pub(crate) struct AppView { @@ -1063,10 +1064,16 @@ 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 new_session_in_project(&mut self, pid: ProjectId, cx: &mut Context) { if !self.projects.iter().any(|p| p.id == pid) { return; diff --git a/src/app/mod.rs b/src/app/mod.rs index 5a2c437..98f2721 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -18,6 +18,7 @@ mod settings_window; mod sidebar; mod theme; mod top_bar; +mod workspace_state; use std::{ collections::HashSet, @@ -41,6 +42,7 @@ 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> { @@ -59,16 +61,33 @@ pub(crate) fn run() -> Result<(), Box> { .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(); @@ -77,10 +96,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(), @@ -109,12 +132,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, 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); + } +} From 39f80e7317826daf1a9f2652f6bc032a654a2f5a Mon Sep 17 00:00:00 2001 From: HD Prajwal Date: Tue, 26 May 2026 00:39:37 -0400 Subject: [PATCH 11/13] Persist Codex threads across restarts Threads (with their turn/item transcripts) are saved to ~/.config/quackcode/codex_threads.json each time the reducer mutates them or the user performs a thread action (new/fork/archive/resume/ submit). On launch the saved set is reloaded and relinked to current projects by path; threads whose project is no longer open are skipped. Local thread ids are reassigned on load, the server-side thread ids are preserved so the user can keep talking to an existing thread. --- src/app/app_view.rs | 32 ++- src/app/codex/mod.rs | 2 + src/app/codex/persistence.rs | 389 +++++++++++++++++++++++++++++++++++ src/app/mod.rs | 17 +- src/app/project.rs | 1 + 5 files changed, 433 insertions(+), 8 deletions(-) create mode 100644 src/app/codex/persistence.rs diff --git a/src/app/app_view.rs b/src/app/app_view.rs index 47d483f..597632f 100644 --- a/src/app/app_view.rs +++ b/src/app/app_view.rs @@ -14,10 +14,10 @@ use serde_json::Value; use crate::app::{ codex::{ apply_notification, derive_title_from_user_message, notification_thread_id, - parse_rate_limit_payload, ApprovalDecision, CodexAccountState, CodexApproval, CodexCommand, - CodexEvent, CodexRuntimeHandle, CodexRuntimeStatus, CodexThread, CodexThreadLocalId, - CodexThreadSummary, LocalThreadStatus, LoginStartResponse, LoginType, SandboxMode, - DEFAULT_THREAD_TITLE, + 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::{ @@ -183,6 +183,7 @@ impl AppView { sandbox: SandboxMode::WorkspaceWrite, }); } + self.persist_codex_threads(); cx.notify(); } @@ -247,6 +248,7 @@ impl AppView { self.codex_composer_cursor = 0; self.codex_image_attachments.clear(); self.codex_transcript_scroll.scroll_to_bottom(); + self.persist_codex_threads(); cx.notify(); } @@ -462,6 +464,11 @@ impl AppView { /// Applies a single [`CodexEvent`] to the view's Codex state. pub(crate) fn handle_codex_event(&mut self, event: CodexEvent, cx: &mut Context) { + self.handle_codex_event_inner(event, cx); + self.persist_codex_threads(); + } + + fn handle_codex_event_inner(&mut self, event: CodexEvent, cx: &mut Context) { match event { CodexEvent::StatusChanged(status) => { self.codex_status = status; @@ -805,6 +812,7 @@ impl AppView { server_thread_id: summary.thread_id, }); } + self.persist_codex_threads(); cx.notify(); } @@ -833,6 +841,7 @@ impl AppView { self.content_mode = ContentMode::Terminal; self.active_codex_thread = None; } + self.persist_codex_threads(); cx.notify(); } @@ -865,6 +874,7 @@ impl AppView { turn_id, }); } + self.persist_codex_threads(); cx.notify(); } @@ -1074,6 +1084,20 @@ impl AppView { 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; diff --git a/src/app/codex/mod.rs b/src/app/codex/mod.rs index 0a8dbb4..a21edc3 100644 --- a/src/app/codex/mod.rs +++ b/src/app/codex/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod approval; pub(crate) mod auth; +pub(crate) mod persistence; pub(crate) mod process; pub(crate) mod rpc; pub(crate) mod runtime; @@ -19,6 +20,7 @@ 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, diff --git a/src/app/codex/persistence.rs b/src/app/codex/persistence.rs new file mode 100644 index 0000000..d18b2cc --- /dev/null +++ b/src/app/codex/persistence.rs @@ -0,0 +1,389 @@ +//! 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) = fs::write(path, bytes) { + eprintln!("{APP_NAME}: failed to write {}: {e}", path.display()); + } + } + Err(e) => eprintln!("{APP_NAME}: failed to serialize codex threads: {e}"), + } +} + +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/mod.rs b/src/app/mod.rs index 98f2721..a03f036 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -34,8 +34,9 @@ use std::{ use self::app_view::{terminal_metrics, AppView, ContentMode}; use self::assets::AppAssets; use self::codex::{ - discover_binary as discover_codex_binary, new_event_channel as new_codex_event_channel, - CodexAccountState, CodexCommand, CodexRuntimeHandle, CodexRuntimeStatus, + discover_binary as discover_codex_binary, load_codex_threads, + new_event_channel as new_codex_event_channel, CodexAccountState, CodexCommand, + CodexRuntimeHandle, CodexRuntimeStatus, }; use self::config::{ProjectId, SessionId, APP_NAME, BELL_SOUND}; use self::project::Project; @@ -111,6 +112,14 @@ 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(); @@ -156,9 +165,9 @@ pub(crate) fn run() -> Result<(), Box> { codex_runtime: Some(codex_handle_for_view), codex_status: CodexRuntimeStatus::Disconnected, codex_account: CodexAccountState::Unknown, - codex_threads: Vec::new(), + codex_threads: loaded_codex_threads, active_codex_thread: None, - next_codex_thread_id: 1, + next_codex_thread_id, codex_composer: String::new(), codex_composer_cursor: 0, codex_composer_focused: false, 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, From d892acd1fce6878965ab13c5e0d0b2d0865ede13 Mon Sep 17 00:00:00 2001 From: HD Prajwal Date: Tue, 26 May 2026 08:55:22 -0400 Subject: [PATCH 12/13] Address PR review findings on Codex integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Persist codex_threads.json only at durable checkpoints (thread/turn/item events), not on every notification — keeps per-token deltas off the disk. - Atomic write for codex_threads.json via temp file + rename so an interrupted save can't leave a partial JSON that load_codex_threads_from silently treats as empty state. - Clean up the pending RPC waiter when write_frame fails so repeated send failures don't leak entries until cancel_all. - Activate delta coalescing in the live relay path: drain bursts via try_recv and run coalesce_agent_message_deltas before pushing into the bounded app channel. - Stop forwarding keystrokes to the PTY when ContentMode::Codex owns focus — unhandled keys like Up/Tab/Ctrl-C no longer interrupt the hidden terminal. - Gate header_button and composer_button click handlers behind !disabled so disabled controls are truly non-interactive. - Build unique InteractiveText element ids from a per-item stable key (server_item_id) so duplicate messages don't share an id. --- src/app/app_view.rs | 57 +++++++++++++++++++++++++++++------- src/app/codex/persistence.rs | 33 ++++++++++++++++++++- src/app/codex/process.rs | 7 ++++- src/app/codex/rpc.rs | 9 ++++++ src/app/codex/runtime.rs | 51 ++++++++++++++++---------------- src/app/codex_view.rs | 23 +++++++++++---- src/app/input.rs | 9 +++--- 7 files changed, 140 insertions(+), 49 deletions(-) diff --git a/src/app/app_view.rs b/src/app/app_view.rs index 597632f..9793c8a 100644 --- a/src/app/app_view.rs +++ b/src/app/app_view.rs @@ -464,25 +464,29 @@ impl AppView { /// Applies a single [`CodexEvent`] to the view's Codex state. pub(crate) fn handle_codex_event(&mut self, event: CodexEvent, cx: &mut Context) { - self.handle_codex_event_inner(event, cx); - self.persist_codex_threads(); + if self.handle_codex_event_inner(event, cx) { + self.persist_codex_threads(); + } } - fn handle_codex_event_inner(&mut self, event: CodexEvent, cx: &mut Context) { + 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() @@ -496,9 +500,12 @@ impl AppView { 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() @@ -507,10 +514,12 @@ impl AppView { 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); + self.dispatch_codex_notification(&method, ¶ms, cx) } CodexEvent::ServerRequest { request_id, @@ -518,6 +527,7 @@ impl AppView { params, } => { self.dispatch_codex_server_request(request_id, &method, params, cx); + false } CodexEvent::LoginStarted { response, @@ -533,12 +543,14 @@ impl AppView { 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; @@ -546,11 +558,13 @@ impl AppView { 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() { @@ -559,6 +573,7 @@ impl AppView { state.entries = entries; cx.notify(); } + false } CodexEvent::ThreadListFailed(message) => { if let Some(state) = self.codex_history.as_mut() { @@ -566,13 +581,16 @@ impl AppView { 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 } } } @@ -582,7 +600,7 @@ impl AppView { 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" { @@ -603,7 +621,7 @@ impl AppView { self.codex_account = next; self.codex_login = None; cx.notify(); - return; + return false; } if method == "account/login/completed" { let login_id = params @@ -611,12 +629,12 @@ impl AppView { .and_then(|v| v.as_str()) .map(String::from); self.handle_codex_event(CodexEvent::LoginCompleted { login_id }, cx); - return; + return false; } if method == "account/rateLimits/updated" { let info = parse_rate_limit_payload(params); self.handle_codex_event(CodexEvent::RateLimitChanged(info), cx); - return; + return false; } // `serverRequest/resolved` clears pending approvals across threads. if method == "serverRequest/resolved" { @@ -636,17 +654,17 @@ impl AppView { cx.notify(); } } - return; + return false; } let Some(thread_id) = notification_thread_id(params) else { - return; + return false; }; let Some(thread) = self .codex_threads .iter_mut() .find(|t| t.server_thread_id.as_deref() == Some(thread_id)) else { - return; + return false; }; let active = self.active_codex_thread == Some(thread.local_id) && self.content_mode == ContentMode::Codex; @@ -671,6 +689,23 @@ impl AppView { } 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( diff --git a/src/app/codex/persistence.rs b/src/app/codex/persistence.rs index d18b2cc..2a33561 100644 --- a/src/app/codex/persistence.rs +++ b/src/app/codex/persistence.rs @@ -134,7 +134,7 @@ fn save_codex_threads_to( }; match serde_json::to_vec_pretty(&saved) { Ok(bytes) => { - if let Err(e) = fs::write(path, bytes) { + if let Err(e) = write_atomic(path, &bytes) { eprintln!("{APP_NAME}: failed to write {}: {e}", path.display()); } } @@ -142,6 +142,37 @@ fn save_codex_threads_to( } } +/// 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, diff --git a/src/app/codex/process.rs b/src/app/codex/process.rs index 0d6fa93..e2b53db 100644 --- a/src/app/codex/process.rs +++ b/src/app/codex/process.rs @@ -166,7 +166,12 @@ impl AppServerClient { method: method.into(), params, }; - self.write_frame(&frame)?; + 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 { diff --git a/src/app/codex/rpc.rs b/src/app/codex/rpc.rs index 5ee9f9c..1327512 100644 --- a/src/app/codex/rpc.rs +++ b/src/app/codex/rpc.rs @@ -177,6 +177,15 @@ impl PendingTable { } } + /// 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"); diff --git a/src/app/codex/runtime.rs b/src/app/codex/runtime.rs index b78646b..ac4f251 100644 --- a/src/app/codex/runtime.rs +++ b/src/app/codex/runtime.rs @@ -582,39 +582,38 @@ fn start_client( } fn relay_server_events(server: Receiver, app: Sender) { - let coalescer = Coalescer::new(); - while let Ok(event) = server.recv() { - for translated in coalescer.absorb(event) { - if app.send(translated).is_err() { + // 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; } } } } -/// Coalesces a stream of [`ServerEvent`]s into [`CodexEvent`]s, merging -/// consecutive `item/agentMessage/delta` notifications for the same itemId -/// into a single event. Phase 1 keeps the coalescer pure — it doesn't buffer -/// across calls — but the type is `&self` so a future buffered version can -/// be added without changing callers. -struct Coalescer; - -impl Coalescer { - fn new() -> Self { - Coalescer - } - - fn absorb(&self, event: ServerEvent) -> Vec { - match event { - ServerEvent::Notification { method, params } => { - vec![CodexEvent::ServerNotification { method, params }] - } - ServerEvent::Request { id, method, params } => vec![CodexEvent::ServerRequest { - request_id: id, - method, - params, - }], +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, + }, } } diff --git a/src/app/codex_view.rs b/src/app/codex_view.rs index 07951d2..4a64c0f 100644 --- a/src/app/codex_view.rs +++ b/src/app/codex_view.rs @@ -154,10 +154,11 @@ fn header_button( .bg(theme.surface_background) .when(disabled, |el| el.opacity(0.4)) .when(!disabled, |el| { - el.cursor_pointer().hover(|s| s.opacity(0.8)) + el.cursor_pointer() + .hover(|s| s.opacity(0.8)) + .on_mouse_down(MouseButton::Left, listener) }) .child(label) - .on_mouse_down(MouseButton::Left, listener) } fn status_label(status: LocalThreadStatus) -> String { @@ -315,7 +316,13 @@ fn plain_row( .text_color(theme.foreground) .text_size(px(ui.font_size_px())) .line_height(px(ui.line_height_px())) - .child(clickable_text(&item.text, theme, ui, cx)), + .child(clickable_text( + &item.text, + &item.server_item_id, + theme, + ui, + cx, + )), ) } @@ -1151,7 +1158,9 @@ fn composer_button( .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)) + el.cursor_pointer() + .hover(|s| s.opacity(0.8)) + .on_mouse_down(MouseButton::Left, listener) }) .bg(if label == "Send" { theme.accent @@ -1164,11 +1173,11 @@ fn composer_button( theme.foreground }) .child(label) - .on_mouse_down(MouseButton::Left, listener) } fn clickable_text( text: &str, + stable_key: &str, theme: &Theme, ui: UiSettings, cx: &mut Context, @@ -1189,7 +1198,9 @@ fn clickable_text( let app_view = cx.entity(); let click_ranges = ranges.clone(); InteractiveText::new( - ElementId::Name(format!("codex-link-text-{}", stable_text_id(&text)).into()), + 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))), ) diff --git a/src/app/input.rs b/src/app/input.rs index 57aa7fe..b7b47bf 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -92,10 +92,11 @@ pub(crate) fn handle_key(this: &mut AppView, ev: &KeyDownEvent, cx: &mut Context } } - if this.content_mode == ContentMode::Codex - && !this.sidebar_focused - && handle_codex_composer_key(this, ks, mods, cx) - { + 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; } From fcd60093be340ba2dec8045e08a3cbf5b4ef861d Mon Sep 17 00:00:00 2001 From: HD Prajwal Date: Tue, 26 May 2026 09:47:29 -0400 Subject: [PATCH 13/13] Fix clippy lints and finish PR review follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI was failing on `cargo clippy --all-targets -- -D warnings`: - derive Default for CodexAccountState / SandboxMode via #[default] - start_client takes &Path, not &PathBuf - compare PathBuf with &Path / DEFAULT_CODEX_BINARY str directly - replace `while let Ok(_) = …` with `.is_ok()` - box ErrorObject inside ClientError::Rpc so the enum stays small - allow too_many_arguments on spawn_pty_session_with_command Also addresses the two remaining CodeRabbit findings: - CodexApproval now captures the permission grant at construction time (CodexApproval::new) into `granted_permissions`. Accept builds its response from that field, so a permissions field the approval UI didn't render can't be granted by a later raw-params read. Adds a regression test that mutates `params` post-construction and confirms the response still reflects the originally-captured grant. - Drop the unconditional CodexCommand::EnsureStarted prewarm at app launch. ensure_codex_started() already runs on every entry point that opens Codex (open_codex_pane / new_codex_thread / new_codex_thread_in_project / login flow), and the worker is a single FIFO queue, so the prewarm only saved binary-spawn latency at the cost of running the child for users who never open Codex. --- src/app/app_view.rs | 8 ++---- src/app/codex/approval.rs | 58 ++++++++++++++++++++++++++++++++------- src/app/codex/auth.rs | 9 ++---- src/app/codex/process.rs | 7 +++-- src/app/codex/runtime.rs | 9 ++++-- src/app/codex/schema.rs | 9 ++---- src/app/mod.rs | 12 ++++---- src/app/session.rs | 1 + 8 files changed, 73 insertions(+), 40 deletions(-) diff --git a/src/app/app_view.rs b/src/app/app_view.rs index 9793c8a..2c058e3 100644 --- a/src/app/app_view.rs +++ b/src/app/app_view.rs @@ -735,11 +735,9 @@ impl AppView { else { return; }; - thread.pending_approvals.push(CodexApproval { - request_id, - method: method.into(), - params, - }); + 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; diff --git a/src/app/codex/approval.rs b/src/app/codex/approval.rs index 1c2d665..a227759 100644 --- a/src/app/codex/approval.rs +++ b/src/app/codex/approval.rs @@ -14,6 +14,33 @@ pub(crate) struct CodexApproval { /// 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)] @@ -27,8 +54,8 @@ pub(crate) enum ApprovalDecision { /// /// Command and file-change approvals carry `{ "decision": "accept" | /// "decline" | "cancel" }`. Permissions approvals carry `{ "permissions": … -/// }` — v1 grants the full requested subset on Accept and nothing on -/// Decline/Cancel (see spec §Approvals). +/// }` — 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", @@ -38,9 +65,8 @@ pub(crate) fn decision_payload(approval: &CodexApproval, decision: ApprovalDecis if approval.method == "item/permissions/requestApproval" { let permissions = match decision { ApprovalDecision::Accept => approval - .params - .get("permissions") - .cloned() + .granted_permissions + .clone() .unwrap_or_else(|| json!({})), ApprovalDecision::Decline | ApprovalDecision::Cancel => json!({}), }; @@ -55,11 +81,7 @@ mod tests { use super::*; fn approval(method: &str, params: Value) -> CodexApproval { - CodexApproval { - request_id: 42, - method: method.into(), - params, - } + CodexApproval::new(42, method.into(), params) } #[test] @@ -94,6 +116,22 @@ mod tests { 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( diff --git a/src/app/codex/auth.rs b/src/app/codex/auth.rs index 323ea64..b9494f4 100644 --- a/src/app/codex/auth.rs +++ b/src/app/codex/auth.rs @@ -113,9 +113,10 @@ impl CodexAccountState { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[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, @@ -140,12 +141,6 @@ pub(crate) enum CodexAccountState { }, } -impl Default for CodexAccountState { - fn default() -> Self { - CodexAccountState::Unknown - } -} - impl CodexAccountState { /// Returns true if the user is in a state where Codex can be used. pub(crate) fn is_usable(&self) -> bool { diff --git a/src/app/codex/process.rs b/src/app/codex/process.rs index e2b53db..fecd99d 100644 --- a/src/app/codex/process.rs +++ b/src/app/codex/process.rs @@ -30,7 +30,10 @@ pub(crate) enum ClientError { }, Rpc { method: String, - error: ErrorObject, + // Boxed so ClientError stays small — ErrorObject contains a + // `serde_json::Value` payload that bloats every Result<_, ClientError> + // return slot. + error: Box, }, ConnectionLost, } @@ -176,7 +179,7 @@ impl AppServerClient { Ok(Ok(value)) => Ok(value), Ok(Err(error)) => Err(ClientError::Rpc { method: method.into(), - error, + error: Box::new(error), }), Err(flume::RecvTimeoutError::Timeout) => Err(ClientError::ResponseTimeout { method: method.into(), diff --git a/src/app/codex/runtime.rs b/src/app/codex/runtime.rs index ac4f251..9822bcc 100644 --- a/src/app/codex/runtime.rs +++ b/src/app/codex/runtime.rs @@ -532,7 +532,7 @@ struct WorkerState { } fn start_client( - binary: &PathBuf, + binary: &Path, events: &Sender, ) -> Result { let client = AppServerClient::spawn(binary.to_string_lossy().as_ref())?; @@ -829,7 +829,7 @@ mod tests { // 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 == PathBuf::from(DEFAULT_CODEX_BINARY) || std::env::var("CODEX_BINARY").is_ok()); + assert!(p == Path::new(DEFAULT_CODEX_BINARY) || std::env::var("CODEX_BINARY").is_ok()); } #[test] @@ -1073,7 +1073,10 @@ mod tests { } handle.send(CodexCommand::Shutdown); // Drain the remaining shutdown events so the worker can exit cleanly. - while let Ok(_) = event_rx.recv_timeout(std::time::Duration::from_millis(500)) {} + 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"); diff --git a/src/app/codex/schema.rs b/src/app/codex/schema.rs index 54a03b3..9777705 100644 --- a/src/app/codex/schema.rs +++ b/src/app/codex/schema.rs @@ -71,20 +71,15 @@ pub(crate) fn map_status( /// 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, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub(crate) enum SandboxMode { ReadOnly, + #[default] WorkspaceWrite, DangerFullAccess, } -impl Default for SandboxMode { - fn default() -> Self { - SandboxMode::WorkspaceWrite - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/app/mod.rs b/src/app/mod.rs index a03f036..dac4a44 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -35,8 +35,8 @@ 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, CodexCommand, - CodexRuntimeHandle, CodexRuntimeStatus, + new_event_channel as new_codex_event_channel, CodexAccountState, CodexRuntimeHandle, + CodexRuntimeStatus, }; use self::config::{ProjectId, SessionId, APP_NAME, BELL_SOUND}; use self::project::Project; @@ -52,10 +52,10 @@ pub(crate) fn run() -> Result<(), Box> { 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); - // Pre-warm the app-server in the background so the first thread the user - // opens doesn't race the binary spawn + initialize handshake (which - // otherwise surfaces as "codex runtime not started" on the first try). - codex_handle.send(CodexCommand::EnsureStarted); + // 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() diff --git a/src/app/session.rs b/src/app/session.rs index e1d753a..96fa439 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -107,6 +107,7 @@ pub(crate) fn spawn_pty_session( /// `-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,