diff --git a/CLAUDE.md b/CLAUDE.md index 9a7fa3b5..a9a4e765 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,7 +193,7 @@ ox # Start an interactive session - Use `#[expect(lint)]` instead of `#[allow(lint)]`. `#[expect]` warns when the suppressed lint is no longer triggered, preventing stale suppressions from accumulating. - `#[expect]` reason strings must describe the current state rather than future plans. -- For complexity / size lints (`clippy::too_many_lines`, `clippy::cognitive_complexity`, etc.), the default response is to **extract a helper**. Reach for `#[expect]` only when the function is irreducibly cohesive: and say so in the reason string. +- For complexity / size lints (`clippy::too_many_lines`, `clippy::cognitive_complexity`, etc.), the default response is to **extract a helper**. Reach for `#[expect]` only when the function is irreducibly cohesive, and explain that in the reason string. ### Section Dividers diff --git a/crates/oxide-code/src/agent/event.rs b/crates/oxide-code/src/agent/event.rs index d2e63e2b..03d6d098 100644 --- a/crates/oxide-code/src/agent/event.rs +++ b/crates/oxide-code/src/agent/event.rs @@ -23,38 +23,38 @@ pub(crate) const INTERRUPTED_MARKER: &str = "(interrupted)"; pub(crate) enum AgentEvent { /// Assistant text chunk to append to the in-flight reply. StreamToken(String), - /// Extended-thinking chunk; rendered separately from `StreamToken` and only when the user + /// Extended-thinking chunk rendered separately from `StreamToken` and only when the user /// has thinking display enabled. ThinkingToken(String), - /// The model invoked a tool — render the call row and prepare the result slot. + /// Tool call emitted by the model, used to render the call row and prepare the result slot. ToolCallStart { id: String, name: String, input: serde_json::Value, }, - /// Tool finished — fill the slot opened by the matching [`Self::ToolCallStart`]. + /// Tool result for the slot opened by the matching [`Self::ToolCallStart`]. ToolCallEnd { id: String, content: String, is_error: bool, metadata: crate::tool::ToolMetadata, }, - /// A queued mid-turn submit was just spliced into the live transcript; the UI clears its + /// A queued mid-turn submit was just spliced into the live transcript so the UI clears its /// queued-prompt indicator. PromptDrained(String), /// Turn ended cleanly with a final assistant reply (no further tool rounds). TurnComplete, - /// User cancelled mid-turn ([`UserAction::Cancel`]); the in-flight reply is truncated and the + /// User cancelled mid-turn ([`UserAction::Cancel`]). The in-flight reply is truncated and the /// inline [`INTERRUPTED_MARKER`] is rendered. Cancelled, /// Automatic compaction started before the submitted prompt runs. TUI switches to compacting /// status while the summarizer request streams. AutoCompactionStarted, - /// Background title generator finished; UI updates the chrome label. + /// Background title generator finished, so the UI updates the chrome label. SessionTitleUpdated { session_id: String, title: String }, - /// `/clear` rolled the session — a new session UUID is now active. + /// `/clear` rolled the session, making a new session UUID active. SessionRolled { id: String }, - /// `/resume` swapped to an existing session in place — payload carries the target's + /// `/resume` swapped to an existing session in place. Payload carries the target's /// transcript so the UI can rebuild chat without restarting the process. SessionResumed { id: String, @@ -89,8 +89,8 @@ pub(crate) enum AgentEvent { // ── User Actions ── -/// UI → agent-loop commands. Multiplexed onto a single `mpsc` so the loop can race them -/// against in-flight stream / tool futures with one biased `select!`. +/// Commands from the UI to the agent loop. Multiplexed onto a single `mpsc` so the loop can race +/// them against in-flight stream / tool futures with one biased `select!`. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum UserAction { SubmitPrompt(String), @@ -108,14 +108,14 @@ pub(crate) enum UserAction { Rename { title: String, }, - /// Symmetric model + effort swap. At least one field must be `Some`; `None` leaves that axis + /// Symmetric model + effort swap. At least one field must be `Some`. `None` leaves that axis /// as-is. Modal pickers and typed-arg `/model ` / `/effort ` both flow through here. SwapConfig { model: Option, effort: Option, }, /// TUI-only: live-preview the picker's highlighted theme without committing it. Cursor moves - /// in the `/theme` modal emit this; cancelling the modal restores the snapshot taken on open. + /// in the `/theme` modal emit this, and cancelling the modal restores the open-time snapshot. PreviewTheme { name: String, }, @@ -124,7 +124,7 @@ pub(crate) enum UserAction { name: String, }, Cancel, - /// TUI-only; agent loop ignores this. + /// TUI-only. Agent loop ignores this. ConfirmExit, Quit, } diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index 3602e865..8189e82a 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -1,4 +1,4 @@ -//! Anthropic Messages API client. [`Client::stream_message`] drives the agent loop; +//! Anthropic Messages API client. [`Client::stream_message`] drives the agent loop. //! [`Client::complete`] handles one-shots (title generation, structured-output probes). //! //! The wire shape mirrors the official `claude-code` CLI: pinned User-Agent and Stainless SDK @@ -40,27 +40,27 @@ use wire::{ const API_VERSION: &str = "2023-06-01"; -/// Pinned to the latest claude-code release; gateways reject pre-allowlist versions. +/// Pinned to the claude-code version accepted by gateways because pre-allowlist versions fail. const CLAUDE_CLI_VERSION: &str = "2.1.121"; const STAINLESS_PACKAGE_VERSION: &str = "0.81.0"; const STAINLESS_RUNTIME_VERSION: &str = "v24.3.0"; const STAINLESS_TIMEOUT_SECS: &str = "600"; -/// Required as its own text block; non-Haiku OAuth requests 429 without it. +/// Required as its own text block because non-Haiku OAuth requests 429 without it. const SYSTEM_PROMPT_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude."; // ── Client ── -/// Shareable HTTP client for the Anthropic Messages API. `Clone` is cheap — `reqwest::Client` -/// shares its connection pool across clones, so callers may stash one per session and roll -/// `session_id` / `model` / `effort` in place via the `set_*` methods. +/// Shareable HTTP client for the Anthropic Messages API. `Clone` is cheap because +/// `reqwest::Client` shares its connection pool. Callers can keep one per session and update +/// `session_id` / `model` / `effort` via the `set_*` methods. #[derive(Clone)] pub(crate) struct Client { http: reqwest::Client, config: Config, session_id: String, device_id: String, - /// Gates `scope: "global"` in `cache_control` (1P-only — 3P gateways reject it). + /// Gates `scope: "global"` in `cache_control`, which 3P gateways reject. is_first_party: bool, } @@ -122,7 +122,7 @@ impl Client { ); headers.insert("x-stainless-retry-count", HeaderValue::from_static("0")); - // No whole-request timeout — responses can run for minutes. The 60 s read timeout + // No whole-request timeout because responses can run for minutes. The 60 s read timeout // catches slowloris dribble. Anthropic sends keepalives every ~15 s on healthy streams. let builder = reqwest::Client::builder() .default_headers(headers) @@ -251,7 +251,7 @@ impl Client { tools: (!tools.is_empty()).then_some(tools), thinking: self.config.thinking.as_ref(), output_config: OutputConfig::new(None, self.config.effort), - // Body must accompany the beta header; claude-code 2.1.119 ships both on every 4.6+. + // Body must accompany the beta header. claude-code 2.1.119 ships both on every 4.6+. context_management: caps .context_management .then(ContextManagement::clear_thinking_keep_all), @@ -281,7 +281,7 @@ impl Client { // ── Request Building ── -/// Field order is load-bearing — gateways validate the JSON shape of `metadata.user_id`. +/// Field order is load-bearing because gateways validate the JSON shape of `metadata.user_id`. fn build_metadata(device_id: &str, session_id: &str) -> RequestMetadata { #[derive(serde::Serialize)] struct UserId<'a> { @@ -358,7 +358,7 @@ fn normalize_arch(arch: &str) -> &'static str { // ── System Prompt Helpers ── -/// Splits at the boundary marker; marker itself is dropped. +/// Splits at the boundary marker, dropping the marker itself. fn split_at_boundary<'a>(sections: &[&'a str]) -> (Vec<&'a str>, Vec<&'a str>) { let boundary_pos = sections .iter() @@ -413,7 +413,6 @@ mod tests { const OFFLINE_URL: &str = "https://example.invalid"; const TEST_MODEL: &str = "claude-sonnet-4-6"; - /// Builds an SSE response body from `(event, data)` pairs. fn sse_body(frames: &[(&str, &str)]) -> String { use std::fmt::Write; let mut body = String::new(); @@ -525,11 +524,9 @@ mod tests { } #[test] - fn new_with_extra_ca_certs_trusts_them() { - // The assertion only confirms that `Client::new` returns Ok. A client that forgot to - // reassign the builder back after `add_root_certificate` would still pass. Keeping the - // loose check here since a stronger test would need a TLS-terminating mock server - // signed by `TEST_CA_PEM`, which is heavy machinery for one line of wiring. + fn new_with_extra_ca_certs_accepts_valid_bundle() { + // This pins Client wiring only. End-to-end trust would need a TLS mock server signed by + // `TEST_CA_PEM`. PEM parsing and builder application are covered in `util::tls`. let pem = tempfile::NamedTempFile::new().unwrap(); std::io::Write::write_all(&mut pem.as_file(), crate::util::tls::TEST_CA_PEM.as_bytes()) .unwrap(); @@ -540,12 +537,12 @@ mod tests { #[test] fn new_rejects_auth_values_containing_invalid_header_bytes() { - // `HeaderValue::from_str` rejects control chars (\n, \r); both auth arms must propagate. + // `HeaderValue::from_str` rejects control chars (\n, \r). Both auth arms must propagate. for auth in [ Auth::ApiKey("bad\nkey".to_owned()), Auth::OAuth("bad\rtoken".to_owned()), ] { - // `Client` has no Debug, so .unwrap_err() doesn't compile — use .err().unwrap(). + // `Client` has no Debug, so `.unwrap_err()` does not compile. let err = Client::new( test_config(OFFLINE_URL, auth, TEST_MODEL), Some("sid".to_owned()), @@ -562,7 +559,7 @@ mod tests { #[test] fn new_surfaces_extra_ca_certs_error_with_path() { // An unreadable trust anchor must fail the client build and cite the configured path so - // the user can debug without having to spelunk into TLS internals. + // the user can debug it without inspecting TLS internals. let mut cfg = test_config(OFFLINE_URL, api_key(), TEST_MODEL); cfg.extra_ca_certs = Some(std::path::PathBuf::from("/no/such/bundle.pem")); let err = Client::new(cfg, None).err().expect("missing CA must error"); @@ -575,8 +572,8 @@ mod tests { #[tokio::test] async fn set_session_id_propagates_to_header_and_metadata_user_id() { - // Pins both wire surfaces: the mock matches the rolled id in the header (wrong value 404s) - // and the assertion below pins the embedded JSON in `metadata.user_id`. + // Pin both wire surfaces: the mock matches the rolled id in the header, and the assertion + // below pins the embedded JSON in `metadata.user_id`. let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/messages")) @@ -630,8 +627,8 @@ mod tests { #[test] fn set_model_resolves_effort_and_persists_full_state() { - // Rows pin each resolution arm: clamp-down, pass-through, no-tier clear, model-default - // fallback, unknown-id. Asserting both returned + stored effort catches the + // Rows cover each resolution arm: clamp-down, pass-through, no-tier clear, model-default + // fallback, and unknown-id. Checking both returned and stored effort catches the // "returned but not persisted" mutation. for (from_model, from_effort, swap_to, expect) in [ ( @@ -678,7 +675,7 @@ mod tests { #[test] fn set_model_preserves_1m_tag_round_trip() { - // `[1m]` is a client-side opt-in; the swap must store it verbatim so `compute_betas` keeps + // `[1m]` is a client-side opt-in. The swap must store it verbatim so `compute_betas` keeps // sending the 1M context beta. Regressing this drops 1M context silently. let mut client = client_with("claude-opus-4-6", Some(Effort::Max)); client.set_model("claude-opus-4-7[1m]".to_owned()).unwrap(); @@ -724,7 +721,7 @@ mod tests { #[test] fn set_effort_resolves_pick_against_active_model_caps() { - // Rows: pass-through, clamp-down, explicit-on-no-tier → None. + // Rows cover pass-through, clamp-down, and explicit effort on a no-tier model. for (model, initial, pick, expect) in [ ( "claude-opus-4-7", @@ -844,184 +841,6 @@ mod tests { assert_eq!(got, Some("🦀rust")); } - #[tokio::test] - async fn stream_message_malformed_frame_is_skipped_without_poisoning_stream() { - // One bad frame must not poison the rest of the turn. - let server = MockServer::start().await; - let body = sse_body(&[ - ( - "content_block_start", - r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, - ), - ("content_block_delta", "{not valid json"), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}"#, - ), - ("message_stop", r#"{"type":"message_stop"}"#), - ]); - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let client = Client::new( - test_config(server.uri(), api_key(), "claude-sonnet-4-6"), - Some("sid".to_owned()), - ) - .unwrap(); - let events = collect_events( - client - .stream_message(&[Message::user("hi")], &[], None, &[]) - .unwrap(), - ) - .await - .unwrap(); - let delta = events.iter().find_map(|e| match e { - StreamEvent::ContentBlockDelta { - delta: Delta::TextDelta { text }, - .. - } => Some(text.as_str()), - _ => None, - }); - assert_eq!(delta, Some("Hi")); - } - - #[tokio::test] - async fn stream_message_mid_stream_error_event_is_delivered_with_api_payload() { - // `StreamEvent::Error` flows as `Ok(Error { .. })`; `agent.rs` converts to bail!. - let server = MockServer::start().await; - let body = sse_body(&[( - "error", - r#"{"type":"error","error":{"type":"overloaded_error","message":"Servers overloaded"}}"#, - )]); - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let client = Client::new( - test_config(server.uri(), api_key(), "claude-sonnet-4-6"), - Some("sid".to_owned()), - ) - .unwrap(); - let events = collect_events( - client - .stream_message(&[Message::user("hi")], &[], None, &[]) - .unwrap(), - ) - .await - .unwrap(); - let err = events - .iter() - .find_map(|e| match e { - StreamEvent::Error { error } => Some(error), - _ => None, - }) - .expect("error event must be delivered"); - assert_eq!(err.error_type, "overloaded_error"); - assert_eq!(err.message, "Servers overloaded"); - } - - #[tokio::test] - async fn stream_message_http_error_propagates_status_and_body() { - for (status, body) in [ - (429_u16, r#"{"error":{"type":"rate_limit_error"}}"#), - (529, "overloaded"), - ] { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with(ResponseTemplate::new(status).set_body_string(body)) - .mount(&server) - .await; - - let client = Client::new( - test_config(server.uri(), api_key(), "claude-sonnet-4-6"), - Some("sid".to_owned()), - ) - .unwrap(); - let rx = client - .stream_message(&[Message::user("hi")], &[], None, &[]) - .unwrap(); - let err = collect_events(rx).await.expect_err("expected HTTP error"); - let msg = format!("{err:#}"); - assert!( - msg.contains(&status.to_string()), - "status {status} in error: {msg}", - ); - assert!(msg.contains(body), "body surfaced in error: {msg}"); - } - } - - #[tokio::test] - async fn stream_message_429_threads_retry_after_header_into_error() { - // Retry-after extraction lives in stream_sse (not format_api_error); pin that the - // header is read off the response *before* the body is consumed. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with( - ResponseTemplate::new(429) - .insert_header("retry-after", "60") - .set_body_string(r#"{"error":{"type":"rate_limit_error"}}"#), - ) - .mount(&server) - .await; - - let client = Client::new( - test_config(server.uri(), api_key(), "claude-sonnet-4-6"), - Some("sid".to_owned()), - ) - .unwrap(); - let rx = client - .stream_message(&[Message::user("hi")], &[], None, &[]) - .unwrap(); - let err = collect_events(rx).await.expect_err("expected 429"); - assert!( - format!("{err:#}").contains("retry after 60"), - "retry-after threaded through stream_sse: {err:#}", - ); - } - - #[tokio::test] - async fn stream_message_receiver_dropped_mid_stream_does_not_deadlock() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(text_stream_body()) - .set_delay(Duration::from_millis(50)), - ) - .mount(&server) - .await; - - let client = Client::new( - test_config(server.uri(), api_key(), "claude-sonnet-4-6"), - Some("sid".to_owned()), - ) - .unwrap(); - let mut rx = client - .stream_message(&[Message::user("hi")], &[], None, &[]) - .unwrap(); - _ = rx.recv().await; - drop(rx); - // Lets the background task observe the closed channel; any panic surfaces in test output. - tokio::time::sleep(Duration::from_millis(80)).await; - } - #[tokio::test] async fn stream_message_api_key_sends_x_api_key_and_session_id() { let server = MockServer::start().await; @@ -1132,7 +951,7 @@ mod tests { } #[tokio::test] - async fn stream_message_prepends_user_context_as_synthetic_user_message() { + async fn stream_message_with_user_context_emits_leading_synthetic_user_message() { let server = MockServer::start().await; let body_sink: Captured = captured(); let sink_clone = std::sync::Arc::clone(&body_sink); @@ -1181,8 +1000,8 @@ mod tests { #[tokio::test] async fn stream_message_third_party_base_url_drops_global_scope_keeps_its_beta() { - // On 3P, the `prompt-caching-scope` beta still ships (gateway fingerprints absence) but - // the body-side `scope: "global"` is dropped (gateway rejects it downstream of tools). + // On 3P, the beta still ships for gateway fingerprint parity, but the body-side + // `scope: "global"` is dropped because gateways reject it downstream of tools. let server = MockServer::start().await; let sink: Captured<(String, String)> = captured(); let sink_clone = std::sync::Arc::clone(&sink); @@ -1238,13 +1057,190 @@ mod tests { cc.get("scope").is_none(), "scope field omitted entirely on 3P (not null): {body}", ); - // TTL rides through on 3P — only `scope` is gated on 1P. + // TTL rides through on 3P. Only `scope` is gated on 1P. assert_eq!(cc["ttl"], "1h", "default 1h ttl survives on 3P: {body}"); } + #[tokio::test] + async fn stream_message_malformed_frame_is_skipped_without_poisoning_stream() { + let server = MockServer::start().await; + let body = sse_body(&[ + ( + "content_block_start", + r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, + ), + ("content_block_delta", "{not valid json"), + ( + "content_block_delta", + r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}"#, + ), + ("message_stop", r#"{"type":"message_stop"}"#), + ]); + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(body), + ) + .mount(&server) + .await; + + let client = Client::new( + test_config(server.uri(), api_key(), "claude-sonnet-4-6"), + Some("sid".to_owned()), + ) + .unwrap(); + let events = collect_events( + client + .stream_message(&[Message::user("hi")], &[], None, &[]) + .unwrap(), + ) + .await + .unwrap(); + let delta = events.iter().find_map(|e| match e { + StreamEvent::ContentBlockDelta { + delta: Delta::TextDelta { text }, + .. + } => Some(text.as_str()), + _ => None, + }); + assert_eq!(delta, Some("Hi")); + } + + #[tokio::test] + async fn stream_message_mid_stream_error_event_is_delivered_with_api_payload() { + // `StreamEvent::Error` flows as `Ok(Error { .. })`. `agent.rs` still owns conversion to + // `bail!`. + let server = MockServer::start().await; + let body = sse_body(&[( + "error", + r#"{"type":"error","error":{"type":"overloaded_error","message":"Servers overloaded"}}"#, + )]); + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(body), + ) + .mount(&server) + .await; + + let client = Client::new( + test_config(server.uri(), api_key(), "claude-sonnet-4-6"), + Some("sid".to_owned()), + ) + .unwrap(); + let events = collect_events( + client + .stream_message(&[Message::user("hi")], &[], None, &[]) + .unwrap(), + ) + .await + .unwrap(); + let err = events + .iter() + .find_map(|e| match e { + StreamEvent::Error { error } => Some(error), + _ => None, + }) + .expect("error event must be delivered"); + assert_eq!(err.error_type, "overloaded_error"); + assert_eq!(err.message, "Servers overloaded"); + } + + #[tokio::test] + async fn stream_message_http_error_propagates_status_and_body() { + for (status, body) in [ + (429_u16, r#"{"error":{"type":"rate_limit_error"}}"#), + (529, "overloaded"), + ] { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with(ResponseTemplate::new(status).set_body_string(body)) + .mount(&server) + .await; + + let client = Client::new( + test_config(server.uri(), api_key(), "claude-sonnet-4-6"), + Some("sid".to_owned()), + ) + .unwrap(); + let rx = client + .stream_message(&[Message::user("hi")], &[], None, &[]) + .unwrap(); + let err = collect_events(rx).await.expect_err("expected HTTP error"); + let msg = format!("{err:#}"); + assert!( + msg.contains(&status.to_string()), + "status {status} in error: {msg}", + ); + assert!(msg.contains(body), "body surfaced in error: {msg}"); + } + } + + #[tokio::test] + async fn stream_message_429_threads_retry_after_header_into_error() { + // Retry-after extraction lives in stream_sse rather than format_api_error. This pins that + // the header is read before the body is consumed. + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with( + ResponseTemplate::new(429) + .insert_header("retry-after", "60") + .set_body_string(r#"{"error":{"type":"rate_limit_error"}}"#), + ) + .mount(&server) + .await; + + let client = Client::new( + test_config(server.uri(), api_key(), "claude-sonnet-4-6"), + Some("sid".to_owned()), + ) + .unwrap(); + let rx = client + .stream_message(&[Message::user("hi")], &[], None, &[]) + .unwrap(); + let err = collect_events(rx).await.expect_err("expected 429"); + assert!( + format!("{err:#}").contains("retry after 60"), + "retry-after threaded through stream_sse: {err:#}", + ); + } + + #[tokio::test] + async fn stream_message_receiver_dropped_mid_stream_does_not_deadlock() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(text_stream_body()) + .set_delay(Duration::from_millis(50)), + ) + .mount(&server) + .await; + + let client = Client::new( + test_config(server.uri(), api_key(), "claude-sonnet-4-6"), + Some("sid".to_owned()), + ) + .unwrap(); + let mut rx = client + .stream_message(&[Message::user("hi")], &[], None, &[]) + .unwrap(); + _ = rx.recv().await; + drop(rx); + // Give the background task time to observe the closed channel so any panic surfaces here. + tokio::time::sleep(Duration::from_millis(80)).await; + } + // ── Client::stream_message / agentic body fields ── - /// Captures the serialized body of a single streaming request. async fn capture_stream_body(config: Config) -> serde_json::Value { let server = MockServer::start().await; let sink: Captured = captured(); @@ -1284,7 +1280,8 @@ mod tests { #[tokio::test] async fn stream_message_omits_output_config_when_effort_is_none() { - // Non-effort-capable model → `Config.effort == None` → `output_config` absent (not `{}`). + // Non-effort-capable models resolve to `Config.effort == None`, so `output_config` is + // absent rather than `{}`. let cfg = test_config( "https://placeholder.invalid", api_key(), @@ -1321,8 +1318,8 @@ mod tests { #[tokio::test] async fn stream_message_context_management_absent_on_unknown_model() { - // Unknown ids fall back to all-false `Capabilities::default()` — no beta, no body. Keeps - // "beta sent ⇒ body populated" an invariant. + // Unknown ids fall back to all-false `Capabilities::default()`, preserving the + // "beta sent ⇒ body populated" invariant. let cfg = test_config("https://placeholder.invalid", api_key(), "claude-opus-5-0"); let body = capture_stream_body(cfg).await; assert!( @@ -1359,7 +1356,7 @@ mod tests { #[test] fn build_metadata_wraps_ids_in_stringified_json_with_canonical_field_order() { - // Field order is `device_id, account_uuid, session_id` — `serde_json::json!` would + // Field order is `device_id, account_uuid, session_id`. `serde_json::json!` would // alphabetize and trip 3P validation. let meta = build_metadata("dev-1", "abc-123"); assert_eq!( diff --git a/crates/oxide-code/src/client/anthropic/identity.rs b/crates/oxide-code/src/client/anthropic/identity.rs index 3c23a76a..d548a01c 100644 --- a/crates/oxide-code/src/client/anthropic/identity.rs +++ b/crates/oxide-code/src/client/anthropic/identity.rs @@ -1,5 +1,6 @@ -//! Per-machine `device_id` (64 lowercase hex chars) at `$XDG_DATA_HOME/ox/user-id`. Lazily minted; -//! filesystem failure falls back to an ephemeral id rather than blocking client construction. +//! Per-machine `device_id` (64 lowercase hex chars) stored at `$XDG_DATA_HOME/ox/user-id`. +//! The id is minted lazily. Filesystem failures fall back to an ephemeral id and do not block +//! client construction. use std::fmt::Write as _; use std::fs; @@ -17,7 +18,7 @@ const DATA_DIR: &str = "ox"; const FILE_NAME: &str = "user-id"; const ID_LEN: usize = 64; -/// Loads the persisted id, minting one if absent; falls back to ephemeral on filesystem failure. +/// Loads the persisted id, mints one if absent, and uses an ephemeral id on filesystem failure. pub(super) fn load_or_create_device_id() -> String { fallback_to_ephemeral(try_load_or_create()) } diff --git a/crates/oxide-code/src/client/anthropic/wire.rs b/crates/oxide-code/src/client/anthropic/wire.rs index 0a5d3e99..66b1c97f 100644 --- a/crates/oxide-code/src/client/anthropic/wire.rs +++ b/crates/oxide-code/src/client/anthropic/wire.rs @@ -1,4 +1,5 @@ -//! Anthropic Messages API wire types. Pure data; builders / interpreters live in sibling modules. +//! Pure data wire types for the Anthropic Messages API. Builders / interpreters live in sibling +//! modules. use serde::{Deserialize, Serialize}; @@ -9,7 +10,7 @@ use crate::tool::ToolDefinition; // ── Request types ── /// Body for `POST /v1/messages`. Field declaration order is the wire JSON order and is -/// load-bearing — the billing `cch=00000` placeholder must appear before user-controlled +/// load-bearing: the billing `cch=00000` placeholder must appear before user-controlled /// `messages` content so [`super::billing::inject_cch`]'s single-occurrence replacement targets /// the system block, not a user echo. #[derive(Serialize)] @@ -18,7 +19,7 @@ pub(super) struct CreateMessageRequest<'a> { pub(super) max_tokens: u32, pub(super) stream: bool, pub(super) metadata: RequestMetadata, - /// Before `messages` so the billing `cch=00000` placeholder appears first; required by + /// Before `messages` so the billing `cch=00000` placeholder appears first. Required by /// [`super::billing::inject_cch`]'s single-occurrence replacement. pub(super) system: Vec>, #[serde(skip_serializing_if = "Option::is_none")] @@ -44,7 +45,7 @@ pub(super) struct OutputConfig<'a> { } impl<'a> OutputConfig<'a> { - /// Returns `None` when both fields are empty; `Some(_)` otherwise. + /// Returns `None` when both fields are empty and `Some(_)` otherwise. pub(super) fn new(format: Option<&'a OutputFormat>, effort: Option) -> Option { (format.is_some() || effort.is_some()).then_some(Self { format, effort }) } @@ -89,7 +90,7 @@ impl OutputFormat { } } -/// `user_id` is a stringified JSON object `{device_id, account_uuid, session_id}`; field order is +/// `user_id` is a stringified JSON object `{device_id, account_uuid, session_id}`. Field order is /// part of the wire fingerprint. #[derive(Serialize)] pub(super) struct RequestMetadata { @@ -105,7 +106,7 @@ pub(super) struct SystemBlock<'a> { pub(super) cache_control: Option, } -/// `scope: "global"` shares cache across sessions (1P only). Default ttl is `"1h"`; opt out via +/// `scope: "global"` shares cache across sessions (1P only). Default ttl is `"1h"`. Opt out via /// `prompt_cache_ttl = "5m"` to use the server-side 5-minute default. #[derive(Serialize)] pub(super) struct CacheControl { diff --git a/crates/oxide-code/src/config/file.rs b/crates/oxide-code/src/config/file.rs index 78cd73e3..f54cf2d5 100644 --- a/crates/oxide-code/src/config/file.rs +++ b/crates/oxide-code/src/config/file.rs @@ -684,7 +684,8 @@ mod tests { assert!(msg.contains("unknown field `model`"), "{msg}"); } - /// Covers section-level `deny_unknown_fields` (top-level: `load_file_rejects_unknown_top_level_key`). + /// Covers section-level `deny_unknown_fields`. + /// Top-level coverage lives in `load_file_rejects_unknown_top_level_key`. #[test] fn load_file_rejects_misplaced_field() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/oxide-code/src/config/oauth.rs b/crates/oxide-code/src/config/oauth.rs index 30821ee2..28ea47db 100644 --- a/crates/oxide-code/src/config/oauth.rs +++ b/crates/oxide-code/src/config/oauth.rs @@ -453,39 +453,7 @@ mod tests { } #[tokio::test] - async fn load_token_from_without_refresh_token_keeps_nonexpired_as_is() { - let dir = tempfile::tempdir().unwrap(); - let creds = dir.path().join("creds.json"); - let lock = dir.path().join("lock"); - write_creds(&creds, "tok", None, now_millis().unwrap() + 60_000); - - let token = load_token_from( - &creds, - &lock, - "http://should-not-be-called", - None, - read_credentials, - ) - .await - .unwrap(); - assert_eq!(token, "tok"); - } - - #[tokio::test] - async fn load_token_from_without_refresh_token_bails_when_expired() { - let dir = tempfile::tempdir().unwrap(); - let creds = dir.path().join("creds.json"); - let lock = dir.path().join("lock"); - write_creds(&creds, "tok", None, 0); - - let err = load_token_from(&creds, &lock, "http://unused", None, read_credentials) - .await - .expect_err("expired without refresh must bail"); - assert!(format!("{err:#}").contains("expired")); - } - - #[tokio::test] - async fn load_token_from_refreshes_near_expiry_and_writes_back() { + async fn load_token_from_near_expiry_refreshes_and_persists_new_credentials() { let server = MockServer::start().await; Mock::given(method("POST")) .and(wm_path("/")) @@ -524,6 +492,25 @@ mod tests { ); } + #[tokio::test] + async fn load_token_from_without_refresh_token_keeps_nonexpired_as_is() { + let dir = tempfile::tempdir().unwrap(); + let creds = dir.path().join("creds.json"); + let lock = dir.path().join("lock"); + write_creds(&creds, "tok", None, now_millis().unwrap() + 60_000); + + let token = load_token_from( + &creds, + &lock, + "http://should-not-be-called", + None, + read_credentials, + ) + .await + .unwrap(); + assert_eq!(token, "tok"); + } + #[tokio::test] async fn load_token_from_refresh_endpoint_down_keeps_existing_token_if_unexpired() { let server = MockServer::start().await; @@ -549,6 +536,19 @@ mod tests { assert_eq!(token, "stale"); } + #[tokio::test] + async fn load_token_from_without_refresh_token_bails_when_expired() { + let dir = tempfile::tempdir().unwrap(); + let creds = dir.path().join("creds.json"); + let lock = dir.path().join("lock"); + write_creds(&creds, "tok", None, 0); + + let err = load_token_from(&creds, &lock, "http://unused", None, read_credentials) + .await + .expect_err("expired without refresh must bail"); + assert!(format!("{err:#}").contains("expired")); + } + #[tokio::test] async fn load_token_from_refresh_endpoint_down_bails_if_expired() { let server = MockServer::start().await; diff --git a/crates/oxide-code/src/session/chain.rs b/crates/oxide-code/src/session/chain.rs index e485bd01..6f4b1c89 100644 --- a/crates/oxide-code/src/session/chain.rs +++ b/crates/oxide-code/src/session/chain.rs @@ -1,5 +1,5 @@ //! UUID-DAG message chain reconstruction. Forks form when concurrent resumes append in -//! parallel; [`ChainBuilder::resolve`] picks the newest non-sidechain linear chain. +//! parallel. [`ChainBuilder::resolve`] picks the newest non-sidechain linear chain. use std::collections::{HashMap, HashSet}; diff --git a/crates/oxide-code/src/session/handle.rs b/crates/oxide-code/src/session/handle.rs index 42fcbcd8..fe893371 100644 --- a/crates/oxide-code/src/session/handle.rs +++ b/crates/oxide-code/src/session/handle.rs @@ -1,5 +1,5 @@ //! Public session API. Every write method sends a [`super::actor::SessionCmd`] and awaits the -//! actor's ack; callers never hold a lock across `await`. +//! actor's ack without holding a lock across `await`. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -30,14 +30,14 @@ const CHANNEL_CAPACITY: usize = 1024; // ── SessionHandle ── -/// Cheap to clone — clones share one actor task. All methods are async +/// Cheap to clone because clones share one actor task. All methods are async /// and return after the batch flush this cmd lands in. #[derive(Clone)] pub(crate) struct SessionHandle { cmd_tx: mpsc::Sender, session_id: Arc, shared: Arc, - /// First `shutdown` takes the join; subsequent calls no-op. + /// First `shutdown` takes the join. Subsequent calls no-op. actor_join: Arc>>>, } @@ -46,11 +46,11 @@ pub(crate) struct SessionHandle { pub(super) struct SharedState { flush_failure_surfaced: AtomicBool, actor_gone_surfaced: AtomicBool, - /// Set when the user has run `/rename` — suppresses AI titles and the next `FirstPrompt`. + /// Set when the user has run `/rename`, suppressing AI titles and the next `FirstPrompt`. /// Latched by [`SessionHandle::set_manual_title`] before dispatch so the title generator's /// pre-check observes it without an actor round-trip. manual_title_set: AtomicBool, - /// Most recent flush error — threaded into actor-gone messages so the user sees the I/O cause. + /// Most recent flush error, threaded into actor-gone messages so the user sees the I/O cause. last_flush_failure: std::sync::Mutex>, } @@ -65,7 +65,7 @@ impl SharedState { } } - /// `true` on the first call after [`Self::record_flush_failure`]; sticky `false` afterwards. + /// `true` on the first call after [`Self::record_flush_failure`], then sticky `false`. pub(super) fn surface_first_flush_failure(&self) -> bool { !self.flush_failure_surfaced.swap(true, Ordering::AcqRel) } @@ -99,18 +99,18 @@ impl SharedState { pub(crate) struct RecordOutcome { /// `Some` only on the first user-text message of a fresh session. pub(crate) ai_title_seed: Option, - /// First I/O failure once per session; subsequent failures stay + /// First I/O failure once per session. Subsequent failures stay /// silent (warn-logged instead). `None` on healthy writes. pub(crate) failure: Option, } -/// Plain ack from non-record cmds; carries the same first-failure surface as [`RecordOutcome`]. +/// Plain ack from non-record cmds, carrying the same first-failure surface as [`RecordOutcome`]. pub(crate) struct Outcome { pub(crate) failure: Option, } /// Ack from `SessionHandle::compact`. `pre_count` is the message count at the moment the -/// compact request landed in the actor — the post-compact UI line uses it for the +/// compact request landed in the actor. The post-compact UI line uses it for the /// "compacted N messages → 1 summary" header. pub(crate) struct CompactOutcome { pub(crate) pre_count: u32, @@ -210,7 +210,7 @@ impl SessionHandle { }) } - /// Write the session summary and finalize. Idempotent; no-op on fresh sessions that never + /// Write the session summary and finalize. Idempotent no-op on fresh sessions that never /// recorded anything. `snapshots` are written as `Entry::FileSnapshot` ahead of the summary. pub(crate) async fn finish(&self, snapshots: Vec) -> Outcome { let (ack, rx) = oneshot::channel(); @@ -257,7 +257,7 @@ impl SessionHandle { } } - /// Sends a cmd and awaits the `Outcome` ack; falls back to actor-gone on failure. + /// Sends a cmd and awaits the `Outcome` ack, falling back to actor-gone on failure. async fn dispatch_outcome(&self, cmd: SessionCmd, rx: oneshot::Receiver) -> Outcome { if self.cmd_tx.send(cmd).await.is_err() { return Outcome { @@ -298,7 +298,7 @@ pub(crate) struct ResumedSession { pub(crate) compact: Option, pub(crate) title: Option, pub(crate) tool_result_metadata: HashMap, - /// Persisted tracker snapshots; passed to `FileTracker::restore_verified` so the + /// Persisted tracker snapshots passed to `FileTracker::restore_verified` so the /// Read-before-Edit gate clears for files that still match disk. pub(crate) file_snapshots: Vec, } @@ -338,7 +338,7 @@ pub(crate) async fn roll( } /// Resumed transcript + finalize failure from the old session. The handle swap happens via -/// `&mut SessionHandle`; this struct only carries the UI-rebuild payload. +/// `&mut SessionHandle`. This struct only carries the UI-rebuild payload. #[derive(Debug)] pub(crate) struct RollIntoOutcome { pub(crate) messages: Vec, diff --git a/crates/oxide-code/src/session/history.rs b/crates/oxide-code/src/session/history.rs index 1338d6ae..e1d652fb 100644 --- a/crates/oxide-code/src/session/history.rs +++ b/crates/oxide-code/src/session/history.rs @@ -1,5 +1,5 @@ //! Display-oriented reshaping of a loaded transcript. JSONL batches all `ToolUse` blocks then -//! all `ToolResult` blocks per turn; live rendering pairs each call with its result inline. +//! all `ToolResult` blocks per turn. Live rendering pairs each call with its result inline. //! [`walk_transcript`] reorders into the live shape and merges adjacent text blocks. use std::collections::HashMap; diff --git a/crates/oxide-code/src/session/path.rs b/crates/oxide-code/src/session/path.rs index c555af95..fdef23b3 100644 --- a/crates/oxide-code/src/session/path.rs +++ b/crates/oxide-code/src/session/path.rs @@ -1,5 +1,5 @@ //! Filesystem-safe project subdir derivation. Sessions live under -//! `$XDG_DATA_HOME/ox/sessions/{sanitized-cwd}/`; reserved chars become `-` and long names get +//! `$XDG_DATA_HOME/ox/sessions/{sanitized-cwd}/`. Reserved chars become `-` and long names get //! a hash suffix to prevent post-truncation collisions. use std::path::Path; diff --git a/crates/oxide-code/src/session/resolver.rs b/crates/oxide-code/src/session/resolver.rs index 79b1dde8..ba442fc4 100644 --- a/crates/oxide-code/src/session/resolver.rs +++ b/crates/oxide-code/src/session/resolver.rs @@ -461,7 +461,7 @@ mod tests { } #[test] - fn resolve_prefix_to_info_no_match_returns_ok_none() { + fn resolve_prefix_to_info_no_match_is_ok_absent() { let dir = tempfile::tempdir().unwrap(); let store = test_store(dir.path()); assert!( diff --git a/crates/oxide-code/src/session/state.rs b/crates/oxide-code/src/session/state.rs index ca084d7d..8788c642 100644 --- a/crates/oxide-code/src/session/state.rs +++ b/crates/oxide-code/src/session/state.rs @@ -817,7 +817,7 @@ mod tests { // ── current_git_branch ── #[test] - fn current_git_branch_in_a_real_repo_returns_the_branch_name() { + fn current_git_branch_in_a_real_repo_yields_the_branch_name() { // Skipped silently if `git` isn't on PATH so CI without git doesn't fail. let dir = tempfile::tempdir().unwrap(); let cwd = dir.path().to_str().unwrap(); @@ -851,7 +851,7 @@ mod tests { } #[test] - fn current_git_branch_outside_a_repo_returns_none() { + fn current_git_branch_outside_a_repo_is_absent() { let dir = tempfile::tempdir().unwrap(); assert_eq!(current_git_branch(dir.path().to_str().unwrap()), None); } diff --git a/crates/oxide-code/src/slash.rs b/crates/oxide-code/src/slash.rs index 899f85f1..8daa7c16 100644 --- a/crates/oxide-code/src/slash.rs +++ b/crates/oxide-code/src/slash.rs @@ -1,10 +1,10 @@ //! Slash-command surface. //! -//! [`parse_slash`] detects commands; [`dispatch`] resolves them via the registry. Each command is -//! a [`registry::SlashCommand`] impl in its own submodule — adding one is one file plus an entry +//! [`parse_slash`] detects commands. [`dispatch`] resolves them via the registry. Each command is +//! a [`registry::SlashCommand`] impl in its own submodule. Adding one is one file plus an entry //! in [`registry::BUILT_INS`]. //! -//! Persistence: commands never write config. Mutations are session-local; restart returns to the +//! Persistence: commands never write config. Mutations are session-local. Restart returns to the //! user-declared config (see `docs/design/slash/commands.md` § Design Decisions 6). mod clear; diff --git a/crates/oxide-code/src/slash/clear.rs b/crates/oxide-code/src/slash/clear.rs index 63a134b2..ab3bf083 100644 --- a/crates/oxide-code/src/slash/clear.rs +++ b/crates/oxide-code/src/slash/clear.rs @@ -1,4 +1,4 @@ -//! `/clear` — forwards [`UserAction::Clear`]; the agent loop rolls the session. +//! `/clear` forwards [`UserAction::Clear`]. The agent loop rolls the session. use super::context::SlashContext; use super::registry::{SlashCommand, SlashKind, SlashOutcome}; diff --git a/crates/oxide-code/src/slash/compact.rs b/crates/oxide-code/src/slash/compact.rs index bafbf18e..2617b220 100644 --- a/crates/oxide-code/src/slash/compact.rs +++ b/crates/oxide-code/src/slash/compact.rs @@ -1,6 +1,6 @@ -//! `/compact [instructions]` — compress the conversation into a summary. Bare runs the default -//! rubric; trailing text becomes user-supplied focus instructions appended to the rubric. -//! Always [`SlashKind::Mutating`] — refused mid-turn so the in-flight reply finishes first. +//! `/compact [instructions]` compresses the conversation into a summary. Bare runs the default +//! rubric. Trailing text becomes user-supplied focus instructions appended to the rubric. +//! Always [`SlashKind::Mutating`], so it is refused mid-turn while the in-flight reply finishes. use super::context::SlashContext; use super::registry::{SlashCommand, SlashKind, SlashOutcome}; diff --git a/crates/oxide-code/src/slash/delete.rs b/crates/oxide-code/src/slash/delete.rs index 42108edc..f633e7c9 100644 --- a/crates/oxide-code/src/slash/delete.rs +++ b/crates/oxide-code/src/slash/delete.rs @@ -1,6 +1,6 @@ -//! `/delete ` — direct typed-arg form for session deletion. Bare `/delete` is rejected -//! because "what gets deleted?" is ambiguous; the picker form (`/resume` then Ctrl+D on a row) is -//! the discoverable path. Both forms share [`super::confirm::ConfirmDeleteSessionModal`]. +//! `/delete ` is the direct typed-arg form for session deletion. Bare `/delete` is +//! rejected because "what gets deleted?" is ambiguous. The picker form (`/resume` then Ctrl+D on +//! a row) is the discoverable path. Both forms share [`super::confirm::ConfirmDeleteSessionModal`]. use std::path::Path; @@ -67,9 +67,8 @@ impl SlashCommand for DeleteCmd { // ── Resolution ── -/// Wrap the shared resolver with `/delete`'s live-id surface: matches against the live id surface -/// a dedicated error so the user sees the real reason rather than the generic "no session -/// matching" they'd get for a typo. +/// Wraps the shared resolver with `/delete`'s live-id behavior: prefixes that match only the +/// live id return a dedicated error so typos keep the generic "no session matching" message. fn resolve_for_delete( store: &SessionStore, prefix: &str, diff --git a/crates/oxide-code/src/slash/effort.rs b/crates/oxide-code/src/slash/effort.rs index f9de89a8..551fa158 100644 --- a/crates/oxide-code/src/slash/effort.rs +++ b/crates/oxide-code/src/slash/effort.rs @@ -1,5 +1,5 @@ -//! `/effort` — open the slider, or swap with `/effort `. Bare form opens the -//! Speed ↔ Intelligence slider (see [`super::effort_slider`]); typed arg shortcuts the picker. +//! `/effort` opens the slider, or swaps with `/effort `. Bare form opens the +//! Speed ↔ Intelligence slider (see [`super::effort_slider`]). Typed arg shortcuts the picker. use std::borrow::Cow; diff --git a/crates/oxide-code/src/slash/model.rs b/crates/oxide-code/src/slash/model.rs index 77e43bbf..7665a888 100644 --- a/crates/oxide-code/src/slash/model.rs +++ b/crates/oxide-code/src/slash/model.rs @@ -1,4 +1,4 @@ -//! `/model` — open the picker, or swap with `/model `. Resolution: alias → exact / dated-id → +//! `/model` opens the picker, or swaps with `/model `. Resolution: alias → exact / dated-id → //! unique suffix → unique substring. `[1m]` rejected on models lacking `context_1m`. use std::borrow::Cow; @@ -427,10 +427,9 @@ mod tests { #[test] fn execute_ambiguous_listing_falls_back_to_full_curated_set_when_filter_empty() { - // `claude-opus` matches the listed Opus 4.7 entries; older non-listed rows must not surface. let (_, outcome) = run_execute("claude-opus"); let msg = outcome.expect_err("ambiguous arg must error"); - // `claude-opus` matches the listed Opus 4.7 entries; the listing surfaces those. + // `claude-opus` matches the listed Opus 4.7 entries, so the listing surfaces those. for id in ["claude-opus-4-7", "claude-opus-4-7[1m]"] { assert!(msg.contains(id), "{id} should be listed: {msg}"); } diff --git a/crates/oxide-code/src/slash/rename.rs b/crates/oxide-code/src/slash/rename.rs index 98ec56c9..1469d721 100644 --- a/crates/oxide-code/src/slash/rename.rs +++ b/crates/oxide-code/src/slash/rename.rs @@ -1,5 +1,5 @@ -//! `/rename` — set the session title manually. Bare opens a modal pre-filled with the current -//! title; `/rename ` applies immediately. Suppresses AI title generation for the rest of +//! `/rename` sets the session title manually. Bare opens a modal pre-filled with the current +//! title. `/rename <title>` applies immediately. Suppresses AI title generation for the rest of //! the session so a slow Haiku response can't overwrite the user's pick. use crossterm::event::{KeyCode, KeyEvent}; diff --git a/crates/oxide-code/src/slash/theme.rs b/crates/oxide-code/src/slash/theme.rs index bf10fd19..2eac84db 100644 --- a/crates/oxide-code/src/slash/theme.rs +++ b/crates/oxide-code/src/slash/theme.rs @@ -1,4 +1,4 @@ -//! `/theme` — open the picker, or swap directly with `/theme <name>`. Picker live-previews on +//! `/theme` opens the picker, or swaps directly with `/theme <name>`. Picker live-previews on //! cursor moves and reverts on cancel. use std::borrow::Cow; diff --git a/crates/oxide-code/src/tui.rs b/crates/oxide-code/src/tui.rs index 82aee969..d8389272 100644 --- a/crates/oxide-code/src/tui.rs +++ b/crates/oxide-code/src/tui.rs @@ -1,7 +1,7 @@ //! Terminal UI. //! -//! ratatui + crossterm with a `tokio::select!` event loop. [`app::App`] is the root state; -//! [`components`] owns the chat / input / status regions; [`markdown`] renders assistant text; +//! ratatui + crossterm with a `tokio::select!` event loop. [`app::App`] is the root state. +//! [`components`] owns the chat / input / status regions. [`markdown`] renders assistant text. //! [`theme`] centralizes all styling. pub(crate) mod app; diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index 81082d08..0fb1f1cc 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -1627,7 +1627,7 @@ mod tests { async fn dispatch_resume_forwards_to_agent_and_disables_input_until_event() { // Pin: between forwarding `Resume` and the SessionResumed event landing, input must be // gated so a typed prompt doesn't push into chat just before `apply_session_resumed`'s - // `clear_history` wipes it. Re-enable comes from `finalize_idle` inside the resumed handler. + // `clear_history` wipes it. `finalize_idle` inside the resumed handler re-enables input. let (mut app, mut rx, _agent_tx) = test_app(None); let action = UserAction::Resume { session_id: "resume-target".to_owned(), diff --git a/crates/oxide-code/src/tui/components/chat.rs b/crates/oxide-code/src/tui/components/chat.rs index 52ddd1b3..bf2bd1db 100644 --- a/crates/oxide-code/src/tui/components/chat.rs +++ b/crates/oxide-code/src/tui/components/chat.rs @@ -2136,7 +2136,7 @@ mod tests { // ── update_layout ── #[test] - fn update_layout_sets_viewport_height() { + fn update_layout_adopts_area_height_as_viewport() { let mut chat = test_chat(); _ = chat.update_layout(Rect::new(0, 0, 80, 30)); assert_eq!(chat.viewport_height, 30); @@ -2175,6 +2175,22 @@ mod tests { assert_eq!(chat.viewport_height, 20); } + #[test] + fn update_layout_invalidates_streaming_cache_on_width_change() { + let mut chat = test_chat(); + _ = chat.update_layout(Rect::new(0, 0, 80, 24)); + chat.append_stream_token("a complete paragraph\n\n"); + let s = chat.streaming.as_ref().unwrap(); + assert_ne!(s.rendered_len(), 0); + assert_eq!(s.cached_width(), 80); + + _ = chat.update_layout(Rect::new(0, 0, 40, 24)); + let s = chat.streaming.as_ref().unwrap(); + assert_eq!(s.rendered_len(), 0); + assert_eq!(s.rendered_boundary(), 0); + assert_eq!(s.cached_width(), 0); + } + // ── bump_paused_counter ── #[test] @@ -2213,22 +2229,6 @@ mod tests { assert_eq!(chat.new_content_since_pause(), u32::MAX); } - #[test] - fn update_layout_invalidates_streaming_cache_on_width_change() { - let mut chat = test_chat(); - _ = chat.update_layout(Rect::new(0, 0, 80, 24)); - chat.append_stream_token("a complete paragraph\n\n"); - let s = chat.streaming.as_ref().unwrap(); - assert_ne!(s.rendered_len(), 0); - assert_eq!(s.cached_width(), 80); - - _ = chat.update_layout(Rect::new(0, 0, 40, 24)); - let s = chat.streaming.as_ref().unwrap(); - assert_eq!(s.rendered_len(), 0); - assert_eq!(s.rendered_boundary(), 0); - assert_eq!(s.cached_width(), 0); - } - // ── handle_event ── #[test] @@ -2342,7 +2342,7 @@ mod tests { // ── render ── #[test] - fn render_updates_content_height() { + fn render_measures_content_height_from_layout() { let mut chat = test_chat(); chat.push_user_message("hi".to_owned()); render_chat(&mut chat, 80, 24); @@ -2520,7 +2520,7 @@ mod tests { // ── scroll_to_bottom / scroll_up / scroll_down ── #[test] - fn scroll_to_bottom_sets_offset_correctly() { + fn scroll_to_bottom_aligns_last_row_with_viewport_bottom() { let mut chat = test_chat(); chat.content_height.set(100); chat.viewport_height = 20; @@ -2591,7 +2591,7 @@ mod tests { // ── build_text ── #[test] - fn build_text_empty_returns_no_lines() { + fn build_text_empty_chat_has_no_lines() { let chat = test_chat(); assert!(chat.build_text(60).lines.is_empty()); } diff --git a/crates/oxide-code/src/tui/components/input.rs b/crates/oxide-code/src/tui/components/input.rs index 8fc84c98..86366ea9 100644 --- a/crates/oxide-code/src/tui/components/input.rs +++ b/crates/oxide-code/src/tui/components/input.rs @@ -457,6 +457,19 @@ mod tests { assert_eq!(input.height(), 5); // 3 content + 2 borders } + #[test] + fn height_accounts_for_visual_wrapping() { + let mut input = test_input(); + input.last_width.set(10); + for ch in "abcdefghijklmnopqrstuvwxy".chars() { + input.textarea.input(Event::Key(KeyEvent::new( + KeyCode::Char(ch), + KeyModifiers::NONE, + ))); + } + assert_eq!(input.height(), 5); + } + #[test] fn height_capped_at_max() { let mut input = test_input(); @@ -466,6 +479,27 @@ mod tests { assert_eq!(input.height(), MAX_VISIBLE_LINES + 2); } + // ── render_popup ── + + #[test] + fn render_popup_paints_when_visible() { + let mut input = test_input(); + input.handle_event(&key(KeyCode::Char('/'), KeyModifiers::NONE)); + assert!(input.popup_visible(), "typing `/` opens the popup"); + + let backend = TestBackend::new(40, 10); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + let area = Rect::new(0, 0, 40, input.popup_height()); + input.render_popup(frame, area); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + let first = buffer.cell(Position::new(0, 0)).unwrap().symbol(); + assert!(!first.is_empty(), "popup row paints something at (0,0)"); + } + // ── handle_event ── #[test] @@ -847,21 +881,6 @@ mod tests { assert_eq!(input.visual_line_count(), 3); } - #[test] - fn height_accounts_for_visual_wrapping() { - let mut input = test_input(); - input.last_width.set(10); - // Single logical line, 25 chars -> 3 visual lines. - for ch in "abcdefghijklmnopqrstuvwxy".chars() { - input.textarea.input(Event::Key(KeyEvent::new( - KeyCode::Char(ch), - KeyModifiers::NONE, - ))); - } - // 3 content + 2 borders - assert_eq!(input.height(), 5); - } - // ── submit ── #[test] @@ -1086,7 +1105,7 @@ mod tests { } #[test] - fn ghost_text_from_state_non_empty_prefix_returns_none() { + fn ghost_text_from_state_non_empty_prefix_is_absent() { let state = PopupState::Arg { name: "model", prefix: "claude-", @@ -1095,7 +1114,7 @@ mod tests { } #[test] - fn ghost_text_from_state_arg_without_usage_returns_none() { + fn ghost_text_from_state_arg_without_usage_is_absent() { let state = PopupState::Arg { name: "init", prefix: "", @@ -1104,7 +1123,7 @@ mod tests { } #[test] - fn ghost_text_from_state_name_mode_returns_none_even_with_usage() { + fn ghost_text_from_state_name_mode_is_absent_even_with_usage() { let state = PopupState::Name("mo"); assert_eq!(ghost_text_from_state(&state, Some("[<id>]")), None); } @@ -1127,22 +1146,4 @@ mod tests { fn normalize_placeholder_trims_whitespace_inside_brackets() { assert_eq!(normalize_placeholder("[ <id> ]"), "[id]"); } - - // ── render_popup ── - - #[test] - fn render_popup_paints_when_visible() { - let input = input_with_popup(); - let backend = TestBackend::new(40, 10); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| { - let area = Rect::new(0, 0, 40, input.popup_height()); - input.render_popup(frame, area); - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - let first = buffer.cell(Position::new(0, 0)).unwrap().symbol(); - assert!(!first.is_empty(), "popup row paints something at (0,0)"); - } } diff --git a/crates/oxide-code/src/tui/components/input/popup.rs b/crates/oxide-code/src/tui/components/input/popup.rs index 4497ee2a..3e495325 100644 --- a/crates/oxide-code/src/tui/components/input/popup.rs +++ b/crates/oxide-code/src/tui/components/input/popup.rs @@ -1,11 +1,11 @@ //! Slash-command autocomplete popup. Two modes: //! -//! - **Name** — typing `/cmd`. Rows are matched commands; aliases parenthesize only the typed +//! - **Name**: typing `/cmd`. Rows are matched commands. Aliases parenthesize only the typed //! alias (`/clear (new)`). Tab inserts `/{name} ` into the buffer. -//! - **Arg** — typing `/cmd <prefix>`. Rows are arg completions from the command's curated +//! - **Arg**: typing `/cmd <prefix>`. Rows are arg completions from the command's curated //! roster (`/model`, `/effort`, `/theme`). Tab replaces the prefix with `/{cmd} {value} `. //! -//! Selected row paints in `text` + BOLD; others dim — contrast stands in for a prefix glyph +//! Selected row paints in `text` + BOLD. Other rows dim so contrast stands in for a prefix glyph //! or fill. Lists past [`MAX_VISIBLE_ROWS`] scroll with a centered cursor (Claude Code //! typeahead style). @@ -26,7 +26,7 @@ const MAX_VISIBLE_ROWS: usize = 8; const COLUMN_GAP: usize = 2; -/// Rows of chrome above the row list — one dim horizontal rule that frames the popup as a +/// Rows of chrome above the row list. One dim horizontal rule frames the popup as a /// separate overlay so it doesn't bleed into whatever sits above (welcome, chat, preview). const CHROME_ROWS: u16 = 1; @@ -40,7 +40,7 @@ pub(super) enum PopupMode { }, } -/// One popup row. `value` is the bare token (command name or arg value); the renderer adds +/// One popup row. `value` is the bare token (command name or arg value). The renderer adds /// `/` and any matched-alias suffix in name mode. #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct PopupRow { @@ -408,7 +408,7 @@ mod tests { } #[test] - fn select_prev_decrements_when_not_at_top() { + fn select_prev_when_not_at_top_moves_up_one_row() { // Pin the non-wrap branch — the decrement path is otherwise dead because select_prev() // from row 0 always wraps. let mut popup = name_popup(""); @@ -604,7 +604,7 @@ mod tests { } #[test] - fn scroll_offset_at_exactly_cap_returns_zero_for_last_row() { + fn scroll_offset_at_exactly_cap_does_not_scroll_last_row() { // Boundary: total == MAX_VISIBLE_ROWS hits the `<=` early-return. Tightening the boundary // pins the invariant for the only case where the edge matters. let mut p = long_popup(MAX_VISIBLE_ROWS); diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 9279a0ac..7c02526a 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -330,7 +330,7 @@ mod tests { } #[test] - fn tick_streaming_increments_counter_before_threshold() { + fn tick_streaming_before_threshold_does_not_advance_spinner_frame() { let mut bar = test_bar(); bar.set_status(Status::Streaming); diff --git a/crates/oxide-code/src/tui/modal/kv_overview.rs b/crates/oxide-code/src/tui/modal/kv_overview.rs index 3c2d7267..7573ea48 100644 --- a/crates/oxide-code/src/tui/modal/kv_overview.rs +++ b/crates/oxide-code/src/tui/modal/kv_overview.rs @@ -1,5 +1,5 @@ -//! Read-only key-value overview modal — title, multi-section body, fixed footer. -//! Used by `/status`, `/config`, `/help`. Compose `KvSection` fixtures; the modal owns layout. +//! Read-only key-value overview modal with a title, multi-section body, and fixed footer. +//! Used by `/status`, `/config`, and `/help`. The modal owns layout for `KvSection` fixtures. use crossterm::event::KeyEvent; use ratatui::Frame; @@ -153,25 +153,6 @@ mod tests { ) } - // ── KvOverview::height ── - - #[test] - fn height_for_single_section_without_heading_counts_title_blank_rows_blank_footer() { - // 2 rows + (title + blank) + (blank + footer) = 6. - assert_eq!(flat_overview().height(80), 6); - } - - #[test] - fn height_for_two_headed_sections_adds_heading_blanks_and_inter_section_blank() { - // title + blank - // + heading + blank + 1 row - // + blank - // + heading + blank + 1 row - // + blank + footer - // = 2 + 3 + 1 + 3 + 2 = 11. - assert_eq!(sectioned_overview().height(80), 11); - } - // ── KvOverview::label_width ── #[test] @@ -187,11 +168,23 @@ mod tests { } #[test] - fn label_width_empty_sections_returns_zero() { + fn label_width_empty_sections_is_zero() { let m = KvOverview::new("T", vec![]); assert_eq!(m.label_width(), 0); } + // ── KvOverview::height ── + + #[test] + fn height_for_single_section_without_heading_counts_title_blank_rows_blank_footer() { + assert_eq!(flat_overview().height(80), 6); + } + + #[test] + fn height_for_two_headed_sections_adds_heading_blanks_and_inter_section_blank() { + assert_eq!(sectioned_overview().height(80), 11); + } + // ── KvOverview::render ── #[test] diff --git a/crates/oxide-code/src/tui/theme/color.rs b/crates/oxide-code/src/tui/theme/color.rs index 5d615d19..6b55715c 100644 --- a/crates/oxide-code/src/tui/theme/color.rs +++ b/crates/oxide-code/src/tui/theme/color.rs @@ -1,10 +1,10 @@ //! Color string parsing for theme TOML values. Case-insensitive. //! -//! - **6-digit hex**: `"#rrggbb"` — true color (24-bit RGB). +//! - **6-digit hex**: `"#rrggbb"` for true color (24-bit RGB). //! - **ANSI 16 named**: `"red"`, `"bright_blue"`, `"dark_gray"`, ... (`light_X` aliases -//! `bright_X`; `grey` aliases `gray`). +//! `bright_X`, `grey` aliases `gray`). //! - **Indexed 256-color**: `"ansi:N"` where `N` is 0–255. -//! - **Terminal default**: `"reset"` — follows the user's terminal palette. +//! - **Terminal default**: `"reset"` follows the user's terminal palette. //! //! Three-digit hex shorthand (`#fff`) is rejected to keep the format unambiguous. diff --git a/crates/oxide-code/src/tui/theme/loader.rs b/crates/oxide-code/src/tui/theme/loader.rs index fb5e2f40..061477a0 100644 --- a/crates/oxide-code/src/tui/theme/loader.rs +++ b/crates/oxide-code/src/tui/theme/loader.rs @@ -2,15 +2,15 @@ //! //! A theme document is a flat TOML body, one entry per slot. Each value is either: //! -//! - a **bare color string** (`text = "#cdd6f4"`) — sets `fg` only; or -//! - an **inline table** (`accent = { fg = "#89b4fa", bold = true }`) — explicit `fg` / `bg` plus +//! - a **bare color string** (`text = "#cdd6f4"`): sets `fg` only. +//! - an **inline table** (`accent = { fg = "#89b4fa", bold = true }`): explicit `fg` / `bg` plus //! modifiers (`bold`, `italic`, `underlined`, `dim`, `reversed`). //! -//! Every slot must be present; `deny_unknown_fields` catches typos. Per-slot parse errors are +//! Every slot must be present. `deny_unknown_fields` catches typos. Per-slot parse errors are //! wrapped with the slot name. //! //! [`resolve_theme`] applies a base + per-slot overrides from `[tui.theme]` config. Selection -//! errors (unknown name, missing file) hard-fail; per-slot value errors warn and fall back to base +//! errors (unknown name, missing file) hard-fail. Per-slot value errors warn and fall back to base //! so the TUI still launches. use std::collections::HashMap; @@ -24,8 +24,8 @@ use super::{Slot, Theme, builtin, color::parse_color}; // ── Resolution ── -/// Resolves a theme from an optional base + per-slot overrides. Unknown / unreadable base errors -/// hard-fail; per-slot override errors warn via `tracing` and keep the base value. +/// Resolves a theme from an optional base + per-slot overrides. Unknown / unreadable bases +/// hard-fail. Per-slot override errors warn via `tracing` and keep the base value. pub(crate) fn resolve_theme( base: Option<&str>, overrides: &HashMap<String, SlotPatch>, @@ -47,7 +47,7 @@ pub(crate) fn resolve_theme( Ok(theme) } -/// Resolves a `base` to a TOML body — built-in catalogue first, then a filesystem path with +/// Resolves a `base` to a TOML body. Built-in catalogue first, then a filesystem path with /// `~/` expanded to `$HOME`. fn load_base_body(name: &str) -> Result<String> { if let Some(body) = builtin::lookup(name) { @@ -62,7 +62,7 @@ fn load_base_body(name: &str) -> Result<String> { }) } -/// Applies one override patch; errors on unknown slot name or bad color value. +/// Applies one override patch. Errors on unknown slot name or bad color value. fn patch_slot(theme: &mut Theme, slot_name: &str, patch: &SlotPatch) -> Result<()> { let slot = slot_for_name(theme, slot_name) .ok_or_else(|| anyhow::anyhow!("unknown slot {slot_name:?}"))?; @@ -70,7 +70,7 @@ fn patch_slot(theme: &mut Theme, slot_name: &str, patch: &SlotPatch) -> Result<( Ok(()) } -/// Generated mutable slot lookup; the mapping can't drift from [`Theme`]'s fields. +/// Generated mutable slot lookup. The mapping can't drift from [`Theme`]'s fields. macro_rules! define_slot_for_name { ( $( ($name:ident, $doc:literal), )* ) => { fn slot_for_name<'a>(theme: &'a mut Theme, name: &str) -> Option<&'a mut Slot> { @@ -96,9 +96,9 @@ pub(crate) enum SlotPatch { Inline(InlinePatch), } -/// Inline TOML patch — every field optional. `Option<bool>` distinguishes "no change" / "set" / +/// Inline TOML patch with every field optional. `Option<bool>` distinguishes "no change" / "set" / /// "clear". Empty patches (`error = {}`) are rejected by [`apply`](Self::apply) so the slot warns -/// and falls back rather than silently re-writing the base with itself; serde's untagged enum +/// and falls back rather than silently re-writing the base with itself. Serde's untagged enum /// dispatcher would swallow a `Deserialize`-time message, so the warn path is clearer. #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] @@ -552,7 +552,7 @@ mod tests { /// pass a happy-path override test, but this assertion fails /// because patching `tool_icon` would visibly alter `tool_border`. #[test] - fn slot_for_name_routes_each_name_to_a_unique_slot() { + fn slot_for_name_each_name_resolves_to_a_unique_slot() { let sentinel = Slot { fg: Some(Color::Rgb(0xde, 0xad, 0xbe)), bg: None, diff --git a/docs/design/slash/compact.md b/docs/design/slash/compact.md index 64ff2515..2afe21a1 100644 --- a/docs/design/slash/compact.md +++ b/docs/design/slash/compact.md @@ -34,7 +34,7 @@ The TUI's `App::apply_session_compacted` clears the chat, pushes a single `Compa 8. **`Entry::Compact` JSONL boundary.** Carries `summary`, `pre_message_count`, optional `instructions`, and `timestamp`. Position: written immediately before the synthetic post-compact `Entry::Message`. Loader treats the compact entry as a chain reset, keeps `CompactInfo` for resume display, and only accepts messages that belong to the new tail. -9. **Same session id, do not roll.** All three reference CLIs converged on this. `/clear` rolls (intent reset), while `/compact` preserves (intent retained, context compressed). The JSONL file, session id, project, and title all carry through unchanged. The chain reset is purely an in-memory or replay concern. +9. **Preserve session identity.** All three reference CLIs converged on this. `/clear` rolls because intent resets, while `/compact` preserves because intent is retained and context is compressed. The JSONL file, session id, project, and title all carry through unchanged. The chain reset is purely an in-memory or replay concern. 10. **Reset the file tracker.** `/compact` discards the read history because Edit and Write contracts depend on a Read having happened _in the visible transcript_. Since the visible transcript is now the summary, the previous `Read`s are no longer "in scope" from a user-visible standpoint. The reset forces a fresh `Read` before any `Edit`, matching the post-`/clear` behavior. The trade-off (extra Reads after compact) is the right side of the safety / convenience line. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index dee6487d..c4f0f8df 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -32,9 +32,9 @@ show_thinking = true | Key | Type | Default | Description | | ------------------ | ------- | --------------------------- | ---------------------------------------------------------- | -| `api_key` | string | - | Anthropic API key; user config only | -| `base_url` | string | `https://api.anthropic.com` | API base URL; user config only | -| `extra_ca_certs` | string | - | PEM bundle appended to the trust store; user config only | +| `api_key` | string | - | Anthropic API key (user config only) | +| `base_url` | string | `https://api.anthropic.com` | API base URL (user config only) | +| `extra_ca_certs` | string | - | PEM bundle appended to the trust store (user config only) | | `model` | string | `claude-opus-4-7[1m]` | Model to use | | `effort` | string | per-model (see below) | Intelligence-vs-latency tier | | `max_tokens` | integer | effort-derived (see below) | Max tokens per response | @@ -76,7 +76,7 @@ Use `base_url` only in `~/.config/ox/config.toml` or `ANTHROPIC_BASE_URL`. Proje #### `extra_ca_certs`: corporate trust anchors -oxide-code uses `rustls` with the built-in Mozilla CA bundle, so self-signed or private-CA endpoints like a corporate gateway fail with `invalid peer certificate: UnknownIssuer`. Point `extra_ca_certs` at a PEM bundle (one or more `-----BEGIN CERTIFICATE-----` blocks in one file) to append those roots to the trust store: +For corporate gateways or other private-CA endpoints, point `extra_ca_certs` at a PEM bundle to append those roots to oxide-code's trust store: ```toml [client] @@ -84,7 +84,7 @@ base_url = "https://gw.llm.corp.example/anthropic" extra_ca_certs = "~/.config/ox/corp-cachain.pem" ``` -The path accepts `~/` / `~` for `$HOME`. Use an absolute or `~`-rooted path: a relative value resolves against the process working directory at read time, so it will break whenever `ox` is launched from a different folder. The field is user-config only (and rejected in project `ox.toml`) because a checked-in trust-anchor path could widen TLS trust for the process. Equivalent env var: `OX_EXTRA_CA_CERTS`. +Use an absolute or `~`-rooted path. Relative paths resolve from the launch cwd. The field is user-config only because a checked-in trust-anchor path could widen TLS trust for the process. Equivalent env var: `OX_EXTRA_CA_CERTS`. #### `prompt_cache_ttl`: cache duration @@ -156,7 +156,7 @@ oxide-code checks three credential sources in order: Expired tokens are refreshed automatically. No configuration needed. -Prefer the environment variable (or OAuth) over `api_key` in a config file. `ox.toml` resolves by walking up from the current directory, so oxide-code rejects project-level `api_key` and `base_url`; user-level `~/.config/ox/config.toml` is safer but still plaintext on disk. +Prefer the environment variable (or OAuth) over `api_key` in a config file. `ox.toml` resolves by walking up from the current directory, so oxide-code rejects project-level `api_key`, `base_url`, and `extra_ca_certs`. User-level `~/.config/ox/config.toml` is safer, but still plaintext on disk. ## Environment variables diff --git a/docs/guide/slash-commands.md b/docs/guide/slash-commands.md index bfb500da..a10912bb 100644 --- a/docs/guide/slash-commands.md +++ b/docs/guide/slash-commands.md @@ -38,11 +38,11 @@ Bare `/model` and `/effort` open pickers. Both apply on Enter and cancel on Esc. ## Compaction -`/compact` streams a one-shot summarization request through the live model, then replaces the in-memory transcript with a single boundary block: a header (`Compacted N messages → 1 summary`) plus the rendered summary. The next prompt continues from the summary rather than the full prior chat. +`/compact` summarizes the visible conversation into a single `Compacted N messages → 1 summary` block. The next prompt continues from that summary rather than the full prior chat. `/compact <instructions>` appends free-text focus instructions to the rubric (e.g., `/compact focus on the build error and how we fixed it`). Useful when only a slice of the work matters going forward. -The summary lands in the JSONL as a `compact` boundary entry plus a synthetic continuation message. Resuming via `ox -c` shows only the post-compact tail. The pre-compact transcript stays on disk for archival but is not replayed in chat. The file-change tracker resets on compact, so any `Edit` after `/compact` requires a fresh `Read`. Queued prompts survive the compaction. +The pre-compact transcript stays on disk, but resuming via `ox -c` starts from the compacted summary and post-compact tail. The file-change tracker resets on compact, so any `Edit` after `/compact` requires a fresh `Read`. Queued prompts survive the compaction. `/compact` refuses on sessions with fewer than 4 messages, when the model returns an empty summary, or while a turn is in flight.