diff --git a/CLAUDE.md b/CLAUDE.md index 078043f9..452f3001 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,9 +71,9 @@ ox # Start an interactive session ├── slash/ │ ├── clear.rs # /clear (new, reset) — forwards UserAction::Clear, resets ChatView, drops the AI title │ ├── config.rs # /config — read-only resolved config + layered file paths -│ ├── context.rs # SlashContext (borrowed ChatView + SessionInfo + modal slot) handed to each command's execute +│ ├── context.rs # SlashContext (borrowed ChatView + LiveSessionInfo + modal slot) handed to each command's execute │ ├── diff.rs # /diff — `git diff HEAD` + untracked, 64 KB cap on UTF-8 boundary -│ ├── effort.rs # /effort — bare opens picker focused on effort; `/effort ` swaps directly +│ ├── effort.rs # /effort — direct effort swap; bare form errors with usage hint pointing at /model │ ├── format.rs # Shared kv-section / kv-table renderer │ ├── help.rs # /help — registry-driven command listing │ ├── init.rs # /init — synthesize an AGENTS.md / CLAUDE.md author-or-update prompt @@ -96,11 +96,6 @@ ox # Start an interactive session ├── tui/ │ ├── app.rs # Root App struct, tokio::select! event loop, render dispatch │ ├── components.rs # Components module root -│ ├── theme.rs # Theme palette (Slot{fg,bg,modifiers} per role) + style helpers + LazyLock-cached Mocha default -│ ├── theme/ -│ │ ├── builtin.rs # Built-in TOML catalogue (Mocha / Macchiato / Frappe / Latte / Material via include_str!) + lookup -│ │ ├── color.rs # Color string parsing (hex, ANSI named, indexed, reset) -│ │ └── loader.rs # Theme TOML deserialization + base+overrides resolution (resolve_theme + SlotPatch) │ ├── components/ │ │ ├── chat.rs # ChatView: scrollable chat with block stacking and history load │ │ ├── chat/ @@ -138,6 +133,11 @@ ox # Start an interactive session │ │ └── list_picker.rs # Generic ListPicker — cursor + render primitive used by concrete pickers │ ├── pending_calls.rs # Tool-call correlation state for streaming and transcript resume │ ├── terminal.rs # Terminal init / restore, synchronized output, panic hook +│ ├── theme.rs # Theme palette (Slot{fg,bg,modifiers} per role) + style helpers + LazyLock-cached Mocha default +│ ├── theme/ +│ │ ├── builtin.rs # Built-in TOML catalogue (Mocha / Macchiato / Frappe / Latte / Material via include_str!) + lookup +│ │ ├── color.rs # Color string parsing (hex, ANSI named, indexed, reset) +│ │ └── loader.rs # Theme TOML deserialization + base+overrides resolution (resolve_theme + SlotPatch) │ └── wrap.rs # Word-wrap with continuation indent for styled lines ├── util.rs # Shared utilities module root └── util/ diff --git a/crates/oxide-code/src/agent.rs b/crates/oxide-code/src/agent.rs index 29fe8c74..262ceda0 100644 --- a/crates/oxide-code/src/agent.rs +++ b/crates/oxide-code/src/agent.rs @@ -1,9 +1,5 @@ -//! Agent turn loop. -//! -//! Drives one user → assistant round: streams the model response, -//! dispatches any tool calls it emits, records each turn to the -//! session, and stops when the model returns text only or the safety -//! cap [`MAX_TOOL_ROUNDS`] trips. +//! Agent turn loop. Streams the model response, dispatches tool calls, records to the session, +//! and stops on text-only response or [`MAX_TOOL_ROUNDS`]. pub(crate) mod event; @@ -26,7 +22,11 @@ const MAX_TOOL_ROUNDS: usize = 25; // ── Turn Abort ── -/// Reasons a turn ends before normal completion. +/// Why a turn ended without a clean assistant reply. +/// +/// `Cancelled` and `Quit` are user-driven and intentionally distinct so the outer driver can +/// keep running on cancel but tear down on quit. `Failed` wraps any other error (stream / tool +/// dispatch / session write) so the caller can render it once via `{e:#}`. #[derive(Debug, thiserror::Error)] pub(crate) enum TurnAbort { #[error("turn cancelled")] @@ -66,8 +66,19 @@ impl AgentClient for Client { // ── Agent Turn ── -/// Drives one user→assistant turn until text-only response or safety cap. -/// Long-running awaits race `user_rx` for cancel / quit / mid-turn submit. +/// Drives one user prompt to a final assistant text reply. +/// +/// Each round streams a model response, dispatches any tool calls, and appends both the +/// assistant message and the synthesized tool-result message to `messages`. The loop returns +/// as soon as a round produces no tool calls; mid-turn `SubmitPrompt` actions queue and +/// splice in as user messages at round boundaries. Long-running awaits race `user_rx` so +/// `Cancel` / `Quit` abort promptly without leaving partial round writes. +/// +/// Errors: +/// +/// - [`TurnAbort::Cancelled`] / [`TurnAbort::Quit`] on the matching [`UserAction`]. +/// - [`TurnAbort::Failed`] for stream errors, tool-dispatch failures, or hitting +/// [`MAX_TOOL_ROUNDS`] without a final response. pub(crate) async fn agent_turn( client: &dyn AgentClient, tools: &ToolRegistry, @@ -143,7 +154,6 @@ fn collect_tool_uses(blocks: &[ContentBlock]) -> Vec<(String, String, serde_json .collect() } -/// Dispatches tool calls, racing each against `user_rx` for abort signals. async fn run_tool_round( tools: &ToolRegistry, tool_uses: Vec<(String, String, serde_json::Value)>, @@ -182,7 +192,6 @@ async fn run_tool_round( Ok((results, sidecars)) } -/// Persists the round's messages + metadata in one coalesced flush. async fn commit_round_writes( session: &SessionHandle, sink: &dyn AgentSink, @@ -200,7 +209,6 @@ async fn commit_round_writes( sink.session_write_error(metadata_outcome.failure.as_deref()); } -/// Dispatches one tool call; short-circuits to error result on malformed input. async fn dispatch_tool_call( tools: &ToolRegistry, name: &str, @@ -219,7 +227,6 @@ async fn dispatch_tool_call( await_unless_aborted(tools.run(name, input), user_rx, pending).await } -/// Splices queued prompts into the conversation and emits `PromptDrained` per item, in order. async fn record_drained_prompts( texts: impl IntoIterator, messages: &mut Vec, @@ -265,8 +272,6 @@ where } } -/// Surfaces the first I/O failure on `sink`; drops the AI-title seed -/// (only the fresh-start trigger in `main` consumes it). async fn record_message(session: &SessionHandle, msg: Message, sink: &dyn AgentSink) { let outcome: RecordOutcome = session.record_message(msg).await; sink.session_write_error(outcome.failure.as_deref()); @@ -294,13 +299,12 @@ enum BlockAccumulator { RedactedThinking { data: String, }, - /// Placeholder for unrecognized content block types. Absorbs deltas silently - /// and produces no [`ContentBlock`] at the end. + /// Absorbs deltas; produces no [`ContentBlock`]. Skipped, } impl BlockAccumulator { - /// Converts to a [`ContentBlock`], surfacing any tool-input parse error. + /// Surfaces any tool-input parse error alongside the produced block. fn into_content_block(self) -> (Option, Option<(String, String)>) { match self { Self::Text(text) => (Some(ContentBlock::Text { text }), None), @@ -335,7 +339,7 @@ impl BlockAccumulator { } } -/// Parses tool JSON; returns empty object + error string on failure. +/// Returns empty object + error string on failure. fn parse_tool_json(json_buf: &str) -> (serde_json::Value, Option) { match serde_json::from_str(json_buf) { Ok(value) => (value, None), @@ -506,10 +510,8 @@ mod tests { #[test] fn turn_abort_display_alternate_propagates_anyhow_cause_chain() { - // {abort:#} must delegate to the inner anyhow Error's alternate - // form so the full cause chain reaches the user (e.g., when a - // bare-REPL or headless run prints "Error: {e:#}"); plain {abort} - // surfaces only the outermost context (anyhow's default Display). + // {abort:#} must delegate to the inner anyhow Error's alternate form so the full cause + // chain reaches the user. Plain {abort} surfaces only the outermost context. let inner = anyhow!("HTTP 503 from upstream"); let chained = inner.context("stream error"); let abort = TurnAbort::Failed(chained); @@ -532,8 +534,7 @@ mod tests { // ── agent_turn ── - /// In-process fake that hands the agent loop a scripted sequence of - /// [`StreamEvent`]s per turn. + /// In-process fake that scripts a [`StreamEvent`] sequence per turn. struct FakeClient { turns: StdMutex>>, } @@ -631,8 +632,7 @@ mod tests { ] } - /// Tool that echoes its input. Exercises the agent's tool-dispatch - /// and result-plumbing path without any subprocess machinery. + /// Echoes its input; exercises the tool-dispatch path without subprocess machinery. struct EchoTool; impl Tool for EchoTool { @@ -665,10 +665,8 @@ mod tests { } } - /// Tool that signals when invoked then blocks forever. Lets cancel - /// tests reliably wait until the agent is parked inside the tool - /// future before sending the interrupt — without it `tokio::join!` - /// races the cancel against the prior stream phase. + /// Signals on entry then blocks forever, so cancel tests can synchronize on the agent being + /// parked inside the tool future before sending the interrupt. struct GateTool { started: Arc, } @@ -698,11 +696,8 @@ mod tests { } } - /// Inert `UserAction` receiver for `agent_turn` tests that don't - /// drive cancel / quit / submit signals. The sender is leaked so - /// `recv()` stays pending for the test's lifetime; a tracked-leak - /// alternative (returning the pair) costs every call site a `let` - /// binding for no test-correctness benefit. + /// Inert receiver whose `recv()` stays pending — sender is leaked deliberately so call sites + /// don't need to bind it for the test's lifetime. fn inert_user_rx() -> mpsc::Receiver { let (tx, rx) = crate::agent::event::inert_user_action_channel(); std::mem::forget(tx); @@ -721,16 +716,14 @@ mod tests { handle::start(&store, "claude-sonnet-4-6") } - /// Handle whose actor channel is already closed; every write - /// returns the actor-gone failure. + /// Handle whose actor channel is closed; every write returns actor-gone. fn dead_test_session() -> SessionHandle { crate::session::handle::testing::dead("dead-test-session") } #[tokio::test] async fn agent_turn_dead_session_surfaces_write_failure_on_first_call() { - // Write errors must not abort the turn — agent_turn returns Ok - // and emits exactly one Error event for the user. + // Write errors must not abort the turn; one Error event surfaces and the turn returns Ok. let session = dead_test_session(); let client = FakeClient::new(vec![text_turn("Hello!")]); let tools = ToolRegistry::new(vec![]); @@ -763,12 +756,8 @@ mod tests { #[tokio::test] async fn agent_turn_metadata_batch_failure_after_healthy_messages_surfaces_error() { - // The assistant + tool-result messages succeed; the sidecar - // batch is the first failing cmd, so the batch's failure - // handler (not the message handler) is what fires the Error - // event. Programmable handle: ack the first 2 cmds healthily - // then drop every cmd without acking — the 3rd cmd - // (ToolMetadata) hits dispatch_outcome's rx-await fallback. + // First 2 cmds (assistant + tool_result messages) ack healthily; the 3rd (metadata batch) + // is dropped without ack so the batch's failure handler fires the Error event. let session = crate::session::handle::testing::acks_then_drops("metadata-batch-fails", 2); let client = FakeClient::new(vec![ tool_use_turn("tool_1", "echo", r#"{"v":1}"#), @@ -936,13 +925,8 @@ mod tests { #[tokio::test] async fn agent_turn_drains_mid_round_submit_into_messages_at_round_boundary() { - // Round 1 emits a tool_use; we pre-load the user channel with a - // SubmitPrompt so `await_unless_aborted` consumes it during the - // round (either while the SSE stream produces frames or while - // the tool runs). At the round boundary the agent must splice - // the queued text into `messages` as a trailing user message - // and emit a `PromptDrained` event, then proceed to round 2 - // which is text-only. + // Pre-loaded SubmitPrompt is consumed during the round; at the boundary the agent splices + // the queued text as a trailing user message and emits PromptDrained. let dir = tempfile::tempdir().unwrap(); let session = test_session(dir.path()); let client = FakeClient::new(vec![ @@ -953,8 +937,7 @@ mod tests { let sink = CapturingSink::new(); let mut messages = vec![Message::user("kick off")]; let (tx, mut rx) = mpsc::channel::(4); - // Hold the sender until the test ends so `recv()` after the - // queued submit stays pending instead of resolving to `None`. + // Hold the sender so `recv()` stays pending after the queued submit. tx.send(UserAction::SubmitPrompt("follow up".into())) .await .unwrap(); @@ -1001,9 +984,8 @@ mod tests { #[tokio::test] async fn agent_turn_drains_multiple_mid_round_submits_in_order() { - // Two SubmitPrompts arrive during the same tool's await; both must - // land as separate trailing User messages in dispatch order, and - // the agent must emit one PromptDrained event per item. + // Two SubmitPrompts during one await must land as separate User messages in dispatch order + // with one PromptDrained event each. let dir = tempfile::tempdir().unwrap(); let session = test_session(dir.path()); let client = FakeClient::new(vec![ @@ -1059,10 +1041,8 @@ mod tests { #[tokio::test] async fn agent_turn_cancel_during_stream_is_cancelled_abort() { - // Biased select picks the queued Cancel before the synchronous - // stream future, so the turn never produces an assistant - // message. The session must stay at its pre-turn tail and the - // abort must be Cancelled (not a `Failed` error). + // Biased select picks the queued Cancel before the stream future, so the session stays at + // its pre-turn tail and the abort is Cancelled. let dir = tempfile::tempdir().unwrap(); let session = test_session(dir.path()); let client = FakeClient::new(vec![text_turn("never reached")]); @@ -1091,9 +1071,7 @@ mod tests { #[tokio::test] async fn agent_turn_quit_during_stream_is_quit_abort() { - // `Quit` is the explicit teardown signal; the agent loop relies - // on it to break out of its outer driver. Pre-queueing it must - // surface as Err(Quit) so callers don't conflate it with cancel. + // Quit is the teardown signal; it must stay distinct from Cancel for the outer driver. let dir = tempfile::tempdir().unwrap(); let session = test_session(dir.path()); let client = FakeClient::new(vec![text_turn("never reached")]); @@ -1121,9 +1099,7 @@ mod tests { #[tokio::test] async fn agent_turn_sender_drop_during_turn_collapses_to_quit_abort() { - // When every `UserAction` sender drops, `recv()` resolves to - // `None`. The agent treats it as Quit so the outer loop can - // exit cleanly instead of looping on a dead channel. + // Closed channel resolves `recv()` to None; treated as Quit so the outer loop exits. let dir = tempfile::tempdir().unwrap(); let session = test_session(dir.path()); let client = FakeClient::new(vec![text_turn("never reached")]); @@ -1150,10 +1126,8 @@ mod tests { #[tokio::test] async fn agent_turn_absorbs_stragglers_without_killing_turn() { - // Every variant in the catch-all arm of `await_unless_aborted` - // must let an in-flight turn complete. A future change that - // returns `TurnAbort::Cancelled` for any of these (because they - // "feel" mutating) would kill the turn from a buffered no-op. + // Every catch-all variant in `await_unless_aborted` must let the turn complete; returning + // Cancelled for any of these would kill the turn from a buffered no-op. for action in [ UserAction::ConfirmExit, UserAction::Clear, @@ -1198,11 +1172,8 @@ mod tests { #[tokio::test] async fn agent_turn_cancel_during_tool_round_is_cancelled_outcome() { - // Drives the tool-round path of `await_unless_aborted`: the - // stream completes synchronously, then `dispatch_tool_call` - // parks on `GateTool`'s pending future. Sending Cancel after - // the gate fires the started signal guarantees the cancel - // races the tool future, not the prior stream phase. + // Synchronizes on `GateTool`'s started signal so Cancel races the tool future, not the + // prior stream phase. let dir = tempfile::tempdir().unwrap(); let session = test_session(dir.path()); let client = FakeClient::new(vec![tool_use_turn("tool_1", "gate", r"{}")]); @@ -1233,15 +1204,11 @@ mod tests { let abort = turn_result.expect_err("cancel must surface as Err(Cancelled)"); assert!(matches!(abort, TurnAbort::Cancelled), "got {abort:?}"); - // Tool-round cancel happens before the assistant tool_use and - // tool_result messages are appended, so the conversation must - // stay at the pre-turn tail. Iteration-atomic: the next turn - // sees the same shape it would after a clean abort. + // Cancel here happens before the round's messages are appended; iteration-atomic so the + // next turn sees the pre-turn tail. assert_eq!(messages.len(), 1, "{messages:#?}"); - // The agent did emit ToolCallStart for the gated call (the - // start event fires before `dispatch_tool_call` parks). The - // matching End event must NOT fire because the tool's future - // was dropped — assert on that to pin the cancel boundary. + // ToolCallStart fires before `dispatch_tool_call` parks; the matching End must not fire + // because the tool future was dropped. let events = sink.events(); assert!( events @@ -1297,10 +1264,8 @@ mod tests { #[tokio::test] async fn agent_turn_malformed_tool_input_short_circuits_to_parse_error_result() { - // The model sometimes emits truncated / invalid JSON in - // `input_json_delta`. The tool MUST NOT run with empty input — - // instead the agent synthesizes an `is_error: true` tool result - // that names the parse failure so the model can self-correct. + // Truncated `input_json_delta` must not run the tool with empty input; the agent + // synthesizes an `is_error: true` result naming the parse failure for self-correction. let dir = tempfile::tempdir().unwrap(); let session = test_session(dir.path()); let client = FakeClient::new(vec![ @@ -1331,8 +1296,7 @@ mod tests { if id == "tool_1" && *input == json!({}), )); - // Tool was NOT dispatched: result content is the synthetic - // parse-error message, not EchoTool's `{}` echo. + // Tool not dispatched: result is the synthetic parse-error, not EchoTool's `{}` echo. assert!(matches!( &messages[2].content[0], ContentBlock::ToolResult { @@ -1344,8 +1308,7 @@ mod tests { && content.contains("retry with a valid object"), )); - // Sink saw a ToolCallEnd with is_error so the UI also reflects - // the failure (instead of rendering an unparsed input label). + // Sink also reflects the failure via ToolCallEnd { is_error: true }. let events = sink.events(); assert!(events.iter().any(|e| matches!( e, @@ -1420,9 +1383,7 @@ mod tests { #[tokio::test] async fn agent_turn_strips_trailing_thinking_before_next_round() { - // A trailing thinking block is legal on the first round but - // rejected by the API on the second — agent_turn must strip it - // before the follow-up turn. + // The API rejects a trailing thinking block on follow-up rounds. let dir = tempfile::tempdir().unwrap(); let session = test_session(dir.path()); let client = FakeClient::new(vec![text_turn("done")]); @@ -1461,8 +1422,7 @@ mod tests { assert!(matches!(&stripped.content[0], ContentBlock::Text { .. })); } - /// Covers `::stream_message` on the real - /// production path; the `FakeClient` tests above stub the trait. + /// Covers the real `` path; `FakeClient` tests above stub the trait. #[tokio::test] async fn agent_turn_drives_real_client_over_wiremock() { let server = MockServer::start().await; diff --git a/crates/oxide-code/src/agent/event.rs b/crates/oxide-code/src/agent/event.rs index 22a4284f..b320d547 100644 --- a/crates/oxide-code/src/agent/event.rs +++ b/crates/oxide-code/src/agent/event.rs @@ -11,58 +11,68 @@ use crate::tool::ToolRegistry; // ── Visible Markers ── +/// Inline glyph appended to the chat (and stderr in `--no-tui` mode) when a turn is cancelled +/// mid-stream. Matched by the chat block layer to render as a dim italic separator. pub(crate) const INTERRUPTED_MARKER: &str = "(interrupted)"; // ── Agent Events ── -/// Events emitted by the agent loop for display. +/// One-way notifications from the agent loop to whichever UI is rendering it +/// (TUI [`ChannelSink`](crate::tui::event::ChannelSink) or stdio [`StdioSink`]). #[derive(Debug, Clone)] 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 + /// has thinking display enabled. ThinkingToken(String), + /// The model invoked a tool — 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`]. 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 + /// 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 + /// inline [`INTERRUPTED_MARKER`] is rendered. Cancelled, - SessionTitleUpdated { - session_id: String, - title: String, - }, - SessionRolled { - id: String, - }, - /// Live config after a [`UserAction::SwapConfig`] applied. `effort` - /// is the resolved value (post-clamp); `requested_effort` is the - /// user's pick if they explicitly chose one — used to surface - /// `(clamped from X)` in the confirmation message. + /// Background title generator finished; UI updates the chrome label. + SessionTitleUpdated { session_id: String, title: String }, + /// `/clear` rolled the session — a new session UUID is now active. + SessionRolled { id: String }, + /// Live config after a [`UserAction::SwapConfig`]. `effort` is the resolved value (post-clamp); + /// `requested_effort` is the user's explicit pick, used to surface `(clamped from X)`. ConfigChanged { model_id: String, effort: Option, requested_effort: Option, }, + /// User-visible error from the agent loop, session writer, or tool dispatch. Renders as a + /// dedicated chat block. Error(String), } // ── 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!`. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum UserAction { SubmitPrompt(String), Clear, - /// Symmetric model + effort swap. At least one field must be `Some`; - /// `None` means "leave that axis as-is". Modal pickers and the - /// typed-arg `/model ` / `/effort ` paths both flow - /// through here. + /// 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, @@ -73,7 +83,7 @@ pub(crate) enum UserAction { Quit, } -/// Channel pair whose `recv()` stays pending forever (no in-process sender). +/// Channel pair whose `recv()` stays pending forever. pub(crate) fn inert_user_action_channel() -> (mpsc::Sender, mpsc::Receiver) { mpsc::channel(1) @@ -81,12 +91,18 @@ pub(crate) fn inert_user_action_channel() -> (mpsc::Sender, mpsc::Re // ── Agent Sink ── +/// Sized to absorb a full streaming-token burst without blocking the agent loop; the TUI drains +/// per frame, so dropping events here would visibly stutter the rendered response. pub(crate) const AGENT_EVENT_CHANNEL_CAP: usize = 4096; -/// Abstraction over where agent events are sent (TUI channel / stdio). +/// Transport from the agent loop to a UI. Implementations must be cheap to clone-by-`&` (the +/// loop calls `send` on a hot path) and tolerate dropped events without panicking — `_ = +/// sink.send(...)` is the standard call pattern at the call sites. pub(crate) trait AgentSink: Send + Sync { fn send(&self, event: AgentEvent) -> Result<()>; + /// Convenience wrapper: surfaces a session-writer failure (sticky once-flag upstream) as a + /// single user-visible [`AgentEvent::Error`]. No-op when `failure` is `None`. fn session_write_error(&self, failure: Option<&str>) { if let Some(msg) = failure { _ = self.send(AgentEvent::Error(format!("Session write failed: {msg}"))); @@ -96,6 +112,9 @@ pub(crate) trait AgentSink: Send + Sync { // ── Stdio Sink (bare REPL / headless) ── +/// [`AgentSink`] for the `--no-tui` REPL and `--prompt` headless modes: assistant tokens stream +/// to stdout, everything else (tool calls, errors, the interrupted marker) to stderr so piped +/// output stays clean. pub(crate) struct StdioSink { show_thinking: bool, tools: Arc, diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index 92e5857c..d0730f35 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -1,16 +1,9 @@ -//! Anthropic Messages API streaming client. +//! Anthropic Messages API client. [`Client::stream_message`] drives the agent loop; +//! [`Client::complete`] handles one-shots (title generation, structured-output probes). //! -//! [`Client::stream_message`] drives the main agent loop: assembles -//! the request (identity prefix, billing attestation for OAuth, -//! static / dynamic system-block split for cache reuse), POSTs -//! `/v1/messages` with SSE streaming, and forwards parsed -//! [`StreamEvent`]s on an mpsc channel. [`Client::complete`] (in -//! [`completion`]) covers non-streaming one-shots. -//! -//! Per-request `anthropic-beta` headers are computed from the model's -//! [`crate::model::Capabilities`] via [`betas::compute_betas`], so -//! gateways that reject unsupported betas don't 400 on spurious -//! feature flags. +//! The wire shape mirrors the official `claude-code` CLI: pinned User-Agent and Stainless SDK +//! header set, billing attestation in `system[0]`, and per-request beta headers gated on model / +//! auth-mode capabilities. Deviating from any of these trips 3P gateway fingerprinting. mod betas; mod billing; @@ -46,30 +39,27 @@ use wire::{ const API_VERSION: &str = "2023-06-01"; -/// Pinned to the latest packaged claude-code release; gateways -/// reject pre-allowlist versions. +/// Pinned to the latest claude-code release; gateways reject pre-allowlist versions. const CLAUDE_CLI_VERSION: &str = "2.1.121"; -/// `@anthropic-ai/sdk` version shipped by the pinned claude-code release. const STAINLESS_PACKAGE_VERSION: &str = "0.81.0"; -/// Node.js version the Stainless SDK reports as `process.version`. const STAINLESS_RUNTIME_VERSION: &str = "v24.3.0"; -/// Stainless per-request timeout matching the SDK default. const STAINLESS_TIMEOUT_SECS: &str = "600"; -/// OAuth-required identity prefix. The Anthropic API returns 429 for non-Haiku -/// models with OAuth tokens unless the system prompt starts with this exact -/// string in its own text block. +/// Required as its own text block; 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. #[derive(Clone)] pub(crate) struct Client { http: reqwest::Client, config: Config, session_id: String, device_id: String, - /// Cached 1P check — gates `scope: "global"` in `cache_control`. + /// Gates `scope: "global"` in `cache_control` (1P-only — 3P gateways reject it). is_first_party: bool, } @@ -131,9 +121,8 @@ impl Client { ); headers.insert("x-stainless-retry-count", HeaderValue::from_static("0")); - // No whole-request timeout — assistant responses can legitimately - // run for minutes. The 60 s read timeout catches slowloris dribble; - // Anthropic sends keepalives every ~15 s on healthy streams. + // No whole-request timeout — responses can run for minutes. The 60 s read timeout + // catches slowloris dribble; Anthropic sends keepalives every ~15 s on healthy streams. let http = reqwest::Client::builder() .default_headers(headers) .connect_timeout(Duration::from_secs(15)) @@ -150,27 +139,20 @@ impl Client { }) } - /// Returns the model name for use in the system prompt. pub(crate) fn model(&self) -> &str { &self.config.model } - /// Returns the effort tier the next request will be issued at. pub(crate) fn effort(&self) -> Option { self.config.effort } - /// Client-side session id carried in `x-claude-code-session-id` and - /// billing metadata. Caller-supplied or auto-generated UUID v4. #[cfg(test)] pub(crate) fn session_id(&self) -> &str { &self.session_id } - /// Replaces the id used for `x-claude-code-session-id` and - /// `metadata.user_id`. Debug-asserts header-value validity; - /// callers feed UUID v4s from `SessionHandle`, so failure is a - /// programmer error. + /// Replaces the id used for `x-claude-code-session-id` and `metadata.user_id`. pub(crate) fn set_session_id(&mut self, id: String) { debug_assert!( HeaderValue::from_str(&id).is_ok(), @@ -179,8 +161,7 @@ impl Client { self.session_id = id; } - /// Swaps the active model and re-clamps `config.effort` against - /// the new caps. Returns the effective effort for display. + /// Swaps the active model and re-clamps `config.effort` against the new caps. pub(crate) fn set_model(&mut self, model: String) -> Option { let caps = crate::model::capabilities_for(&model); let effort = caps.resolve_effort(self.config.effort); @@ -189,8 +170,7 @@ impl Client { effort } - /// Swaps the active effort, clamped against the current model's - /// caps. Returns the resolved effort. + /// Swaps the active effort, clamped against the current model's caps. pub(crate) fn set_effort(&mut self, pick: Effort) -> Option { let caps = crate::model::capabilities_for(&self.config.model); let effort = caps.clamp_effort(pick); @@ -198,18 +178,18 @@ impl Client { effort } - /// Stream a message response from the Anthropic API. - /// - /// `system_sections` ship as individual `system` text blocks so - /// `cache_control` can apply to the static prefix only. `user_context` - /// is prepended as a synthetic user message (keeping dynamic content - /// like CLAUDE.md out of the cacheable `system` parameter). + /// Streams an agentic turn. The returned receiver delivers parsed [`StreamEvent`]s; the + /// background task ends when the stream completes, the receiver is dropped, or an unrecoverable + /// transport error is forwarded as a final `Err`. /// - /// System-block ordering is delegated to [`build_system_blocks`]. - /// The static section is the only block carrying `cache_control` — - /// `scope=global` on 1P, default org-scoped on 3P. + /// Wire shape: /// - /// Returns an mpsc receiver of [`StreamEvent`]s. + /// - `system_sections` ship as individual `system` text blocks so `cache_control` covers the + /// static prefix only — anything after [`SYSTEM_PROMPT_DYNAMIC_BOUNDARY`] is left uncached. + /// - `user_context` rides as a synthetic user message so dynamic content (CLAUDE.md and + /// friends) does not invalidate the cached `system` parameter on every turn. + /// - The billing attestation block is injected unconditionally; 3P gateways reject API-key + /// traffic without it. pub(crate) fn stream_message( &self, messages: &[Message], @@ -224,8 +204,7 @@ impl Client { None => messages.to_vec(), }; - // 3P gateways reject API-key traffic without the cch - // attestation, so the billing block ships under both auth modes. + // 3P gateways reject API-key traffic without the cch attestation. let billing_header = { let fingerprint = billing::compute_fingerprint( first_user_text(&effective_messages), @@ -252,7 +231,6 @@ impl Client { let url = format!("{}/v1/messages?beta=true", self.config.base_url); let mut body = serde_json::to_string(&CreateMessageRequest { - // `[1m]` is a client-side tag; strip before the wire. model: betas::api_model_id(&self.config.model), max_tokens: self.config.max_tokens, stream: true, @@ -261,8 +239,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 and header stay in sync — claude-code 2.1.119 ships - // both on every 4.6+ agentic request. + // 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), @@ -292,7 +269,7 @@ impl Client { // ── Request Building ── -/// Stringified `metadata.user_id`. Field order is load-bearing (gateway check). +/// Field order is load-bearing — 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> { @@ -310,7 +287,7 @@ fn build_metadata(device_id: &str, session_id: &str) -> RequestMetadata { RequestMetadata { user_id } } -/// System blocks in wire order. Order is load-bearing for billing injection. +/// Wire order is load-bearing for billing injection. fn build_system_blocks<'a, const N: usize>( billing_header: Option<&'a str>, extras: [(&'a str, Option); N], @@ -369,11 +346,7 @@ fn normalize_arch(arch: &str) -> &'static str { // ── System Prompt Helpers ── -/// Splits system sections at the boundary marker into static and dynamic parts. -/// -/// Returns `(static_sections, dynamic_sections)`. The boundary marker itself -/// is excluded from both. Sections before the boundary are static (globally -/// cacheable); sections after are dynamic (per-session). +/// Splits at the boundary marker; marker itself is dropped. fn split_at_boundary<'a>(sections: &[&'a str]) -> (Vec<&'a str>, Vec<&'a str>) { let boundary_pos = sections .iter() @@ -392,13 +365,11 @@ fn split_at_boundary<'a>(sections: &[&'a str]) -> (Vec<&'a str>, Vec<&'a str>) { .collect(); (static_part, dynamic_part) } else { - // No boundary — treat everything as static. let all = sections.iter().filter(|s| !s.is_empty()).copied().collect(); (all, Vec::new()) } } -/// Extracts the text of the first user message for fingerprint computation. fn first_user_text(messages: &[Message]) -> &str { messages .iter() @@ -428,10 +399,7 @@ 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. Each - /// frame is emitted as `event: \ndata: \n\n`, encoding - /// the frame-separator invariant in one place so call sites don't - /// hand-roll it (and can't silently omit the `\n\n`). + /// 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(); @@ -532,14 +500,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 the error instead of panicking. + // `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 derive, so .unwrap_err() doesn't - // compile — use .err().unwrap() on the Option instead. + // `Client` has no Debug, so .unwrap_err() doesn't compile — use .err().unwrap(). let err = Client::new( test_config(OFFLINE_URL, auth, TEST_MODEL), Some("sid".to_owned()), @@ -557,9 +523,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`. + // 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`. let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/messages")) @@ -613,10 +578,9 @@ mod tests { #[test] fn set_model_resolves_effort_and_persists_full_state() { - // Each row pins one resolution arm: clamp-down, pass-through, - // no-tier clear, model-default fallback, and unknown-id (all - // caps false). Asserting both returned + stored effort catches - // the "returned but not persisted" mutation. + // Rows pin each resolution arm: clamp-down, pass-through, no-tier clear, model-default + // fallback, unknown-id. Asserting both returned + stored effort catches the + // "returned but not persisted" mutation. for (from_model, from_effort, swap_to, expect) in [ ( "claude-opus-4-7", @@ -662,9 +626,8 @@ 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 per-request `compute_betas` keeps sending the - // 1M context beta. Regressing this drops 1M context silently. + // `[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()); assert_eq!(client.model(), "claude-opus-4-7[1m]"); @@ -674,9 +637,7 @@ mod tests { #[test] fn set_effort_resolves_pick_against_active_model_caps() { - // Each row is one resolution arm: pass-through, clamp-down, - // and explicit-on-no-tier collapses to None. Tested through - // `&mut self` to cover both returned and stored effort. + // Rows: pass-through, clamp-down, explicit-on-no-tier → None. for (model, initial, pick, expect) in [ ( "claude-opus-4-7", @@ -750,8 +711,8 @@ mod tests { #[tokio::test] async fn stream_message_preserves_multibyte_codepoints_in_deltas() { - // Pins the byte-level SSE buffer: a chunk decoded as lossy UTF-8 - // would mangle a 4-byte emoji split across TCP chunk boundaries. + // Pins the byte-level SSE buffer: lossy UTF-8 would mangle a 4-byte emoji split across + // TCP chunk boundaries. let server = MockServer::start().await; let body = sse_body(&[ ( @@ -798,8 +759,7 @@ mod tests { #[tokio::test] async fn stream_message_malformed_frame_is_skipped_without_poisoning_stream() { - // The valid delta after a malformed frame must still deliver — - // one bad frame cannot poison the whole turn. + // One bad frame must not poison the rest of the turn. let server = MockServer::start().await; let body = sse_body(&[ ( @@ -847,8 +807,7 @@ mod tests { #[tokio::test] async fn stream_message_mid_stream_error_event_is_delivered_with_api_payload() { - // `StreamEvent::Error` flows as `Ok(Error { .. })` on the channel; - // the caller (`agent.rs`) converts it to a bail!. + // `StreamEvent::Error` flows as `Ok(Error { .. })`; `agent.rs` converts to bail!. let server = MockServer::start().await; let body = sse_body(&[( "error", @@ -920,9 +879,8 @@ mod tests { #[tokio::test] async fn stream_message_429_threads_retry_after_header_into_error() { - // The streaming path's 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. + // 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")) @@ -973,8 +931,7 @@ mod tests { .unwrap(); _ = rx.recv().await; drop(rx); - // Lets the background task observe the closed channel and exit; - // any panic would surface in test output. + // Lets the background task observe the closed channel; any panic surfaces in test output. tokio::time::sleep(Duration::from_millis(80)).await; } @@ -1047,9 +1004,8 @@ mod tests { #[tokio::test] async fn stream_message_billing_block_ships_under_both_auth_modes_with_cch_populated() { - // 3P gateways reject API-key requests without the cch - // attestation, so the billing block must ship under both - // auth modes with the placeholder replaced by xxHash64. + // 3P gateways reject API-key requests without the cch attestation, so the billing block + // must ship under both auth modes with the placeholder replaced by xxHash64. for auth in [api_key(), oauth()] { let server = MockServer::start().await; let body_sink: Captured = captured(); @@ -1138,9 +1094,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 the scope downstream of tools). + // 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). let server = MockServer::start().await; let sink: Captured<(String, String)> = captured(); let sink_clone = std::sync::Arc::clone(&sink); @@ -1181,7 +1136,7 @@ mod tests { ); let v: serde_json::Value = serde_json::from_str(&body).unwrap(); let system = v["system"].as_array().expect("system array"); - // [0] billing header, [1] identity prefix, [2] static joined. + // [0] billing, [1] identity prefix, [2] static joined. assert!( system[0]["text"] .as_str() @@ -1203,9 +1158,6 @@ mod tests { // ── Client::stream_message / agentic body fields ── /// Captures the serialized body of a single streaming request. - /// Most agentic-body tests only care about what oxide-code sends, - /// not the response — this collapses the ceremony to two lines - /// per test. async fn capture_stream_body(config: Config) -> serde_json::Value { let server = MockServer::start().await; let sink: Captured = captured(); @@ -1245,8 +1197,7 @@ mod tests { #[tokio::test] async fn stream_message_omits_output_config_when_effort_is_none() { - // Non-effort-capable model → `Config.effort == None` → the - // whole `output_config` block is absent (not `{}`). + // Non-effort-capable model → `Config.effort == None` → `output_config` absent (not `{}`). let cfg = test_config( "https://placeholder.invalid", api_key(), @@ -1262,9 +1213,8 @@ mod tests { #[tokio::test] async fn stream_message_context_management_body_present_on_4_6_plus() { - // Every model whose `context_management` capability flag is - // set must also ship the body directive alongside the beta - // header. + // Every model with the `context_management` capability must ship the body directive + // alongside the beta header. for model in [ "claude-opus-4-7", "claude-opus-4-6", @@ -1284,9 +1234,8 @@ mod tests { #[tokio::test] async fn stream_message_context_management_absent_on_unknown_model() { - // Unknown model ids (no `MODELS` row matches) fall back to - // the all-false `Capabilities::default()` — no beta, no body - // directive. Keeps "beta sent ⇒ body populated" an invariant. + // Unknown ids fall back to all-false `Capabilities::default()` — no beta, no body. Keeps + // "beta sent ⇒ body populated" an invariant. let cfg = test_config("https://placeholder.invalid", api_key(), "claude-opus-5-0"); let body = capture_stream_body(cfg).await; assert!( @@ -1308,8 +1257,7 @@ mod tests { #[tokio::test] async fn stream_message_show_thinking_false_omits_display_field() { - // `Adaptive { display: None }` must serialize without a - // `display` key — `skip_serializing_if` on the wire. + // `Adaptive { display: None }` must serialize without a `display` key. let mut cfg = test_config("https://placeholder.invalid", api_key(), "claude-opus-4-7"); cfg.thinking = Some(ThinkingConfig::Adaptive { display: None }); let body = capture_stream_body(cfg).await; @@ -1324,8 +1272,8 @@ mod tests { #[test] fn build_metadata_wraps_ids_in_stringified_json_with_canonical_field_order() { - // Field order must be `device_id, account_uuid, session_id` — - // `serde_json::json!` would alphabetize and trip 3P validation. + // 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!( meta.user_id, diff --git a/crates/oxide-code/src/client/anthropic/betas.rs b/crates/oxide-code/src/client/anthropic/betas.rs index 3f91a30f..419c20b6 100644 --- a/crates/oxide-code/src/client/anthropic/betas.rs +++ b/crates/oxide-code/src/client/anthropic/betas.rs @@ -13,6 +13,11 @@ pub(super) const OAUTH_BETA_HEADER: &str = "oauth-2025-04-20"; const PROMPT_CACHING_SCOPE_BETA_HEADER: &str = "prompt-caching-scope-2026-01-05"; pub(super) const STRUCTURED_OUTPUTS_BETA_HEADER: &str = "structured-outputs-2025-12-15"; +/// Builds the comma-joined `anthropic-beta` header value. +/// +/// Order is load-bearing: 3P proxies fingerprint the exact sequence and reject mismatches. The +/// `is_agentic` gate distinguishes streaming turns (full agentic stack) from one-shots (only the +/// claude-code tag plus auth / structured-output extensions). pub(super) fn compute_betas( model: &str, auth: &Auth, @@ -61,6 +66,8 @@ pub(super) fn supports_structured_outputs(model: &str) -> bool { crate::model::capabilities_for(model).structured_outputs } +/// Whether `base_url` points at Anthropic's production or staging host. Gates body-side +/// `cache_control.scope = "global"`, which 3P gateways reject downstream of tools. pub(super) fn is_first_party_base_url(base_url: &str) -> bool { reqwest::Url::parse(base_url) .ok() @@ -73,6 +80,8 @@ pub(super) fn is_first_party_base_url(base_url: &str) -> bool { }) } +/// `cache_control` for the static system prefix. `scope: "global"` shares the cache across +/// sessions and is 1P-only — see [`is_first_party_base_url`]. pub(super) fn static_prefix_cache_control( is_first_party: bool, ttl: PromptCacheTtl, diff --git a/crates/oxide-code/src/client/anthropic/billing.rs b/crates/oxide-code/src/client/anthropic/billing.rs index 5472eaae..ba34902c 100644 --- a/crates/oxide-code/src/client/anthropic/billing.rs +++ b/crates/oxide-code/src/client/anthropic/billing.rs @@ -4,25 +4,16 @@ use anyhow::{Result, bail}; use sha2::{Digest, Sha256}; use xxhash_rust::xxh64; -/// Salt for the version fingerprint (hardcoded in Claude Code JS source). +/// Salt hardcoded in the Claude Code JS source. const FINGERPRINT_SALT: &str = "59cf53e54c78"; - -/// Character indices extracted from the first user message for fingerprinting. const FINGERPRINT_INDICES: [usize; 3] = [4, 7, 20]; - /// xxHash64 seed for the cch body hash (extracted from the Bun binary). const CCH_SEED: u64 = 0x6E52_736A_C806_831E; - -/// Placeholder written into the billing header before the real hash is computed. const CCH_PLACEHOLDER: &str = "cch=00000"; // ── Public API ── -/// Computes the 3-character hex fingerprint suffix for `cc_version`. -/// -/// `SHA-256(salt + chars_at_indices + version)`, take first 3 hex chars. -/// Character extraction uses Unicode scalar indexing, which matches -/// JavaScript's UTF-16 code-unit indexing for all BMP characters. +/// 3-char hex suffix for `cc_version`: first 3 hex of `SHA-256(salt + chars_at_indices + version)`. pub(super) fn compute_fingerprint(first_user_message: &str, version: &str) -> String { let chars: String = FINGERPRINT_INDICES .iter() @@ -34,9 +25,7 @@ pub(super) fn compute_fingerprint(first_user_message: &str, version: &str) -> St format!("{:02x}{:02x}", hash[0], hash[1])[..3].to_string() } -/// Builds the billing attribution header with a `cch=00000` placeholder. -/// -/// The placeholder is later replaced by [`inject_cch`] with the computed hash. +/// Builds the billing attribution header; [`inject_cch`] replaces the `cch=00000` placeholder. pub(super) fn build_billing_header(version: &str, fingerprint: &str) -> String { format!( "x-anthropic-billing-header: \ @@ -46,18 +35,7 @@ pub(super) fn build_billing_header(version: &str, fingerprint: &str) -> String { ) } -/// Computes xxHash64 of the request body and replaces the `cch` placeholder. -/// -/// The hash is computed over the body *with* the placeholder in place, -/// then the placeholder is replaced with the 5-char hex result. Only the -/// first occurrence is replaced — field ordering in -/// [`super::wire::CreateMessageRequest`] ensures `system` is serialized -/// before `messages`, so the billing header's placeholder comes first. -/// -/// Errors out if the placeholder is missing — a release build that -/// silently shipped `cch=00000` would still pass the gateway's signature -/// shape check and only fail later at billing reconciliation, far from -/// the buggy call site. +/// Replaces the first `cch=00000` with `cch={5-hex of xxHash64(body)}`. Errors if absent. pub(super) fn inject_cch(body: &str) -> Result { if !body.contains(CCH_PLACEHOLDER) { bail!("billing header placeholder `{CCH_PLACEHOLDER}` missing from request body"); @@ -74,8 +52,8 @@ mod tests { // ── compute_fingerprint ── - /// Verified via `echo -n "59cf53e54c78'li2.1.37" | shasum -a 256` → "9e71...". - /// Chars at indices [4, 7, 20] of "Say 'hello'..." are `'`, `l`, `i`. + // Verified via `echo -n "59cf53e54c78'li2.1.37" | shasum -a 256` → "9e71...". + // Chars at indices [4, 7, 20] of "Say 'hello'..." are `'`, `l`, `i`. #[test] fn compute_fingerprint_known_vector() { let fp = compute_fingerprint("Say 'hello' and nothing else.", "2.1.37"); @@ -94,8 +72,7 @@ mod tests { #[test] fn compute_fingerprint_short_message_pads_with_zero() { - // "Hi" (len 2): all fingerprint indices (4, 7, 20) are out of bounds, - // so chars default to '0'. Same result as an empty message. + // "Hi" (len 2): all fingerprint indices are out of bounds; chars default to '0'. let short = compute_fingerprint("Hi", "2.1.87"); let empty = compute_fingerprint("", "2.1.87"); assert_eq!(short, empty, "both should pad all positions with '0'"); @@ -103,7 +80,7 @@ mod tests { #[test] fn compute_fingerprint_partial_bounds() { - // "Hello" (len 5): index 4 = 'o', indices 7 and 20 out of bounds. + // "Hello" (len 5): index 4 = 'o'; 7 and 20 are out of bounds. let fp = compute_fingerprint("Hello", "2.1.87"); let fp_all_zero = compute_fingerprint("", "2.1.87"); assert_eq!(fp.len(), 3); @@ -166,7 +143,7 @@ mod tests { #[test] fn inject_cch_replaces_only_first_occurrence() { - // system (with placeholder) is before messages — matches our struct field order. + // `system` precedes `messages` — matches our struct field order. let body = r#"{"system":[{"type":"text","text":"cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"cch=00000"}]}]}"#; let result = inject_cch(body).unwrap(); diff --git a/crates/oxide-code/src/client/anthropic/completion.rs b/crates/oxide-code/src/client/anthropic/completion.rs index 2899d903..61ed313f 100644 --- a/crates/oxide-code/src/client/anthropic/completion.rs +++ b/crates/oxide-code/src/client/anthropic/completion.rs @@ -1,8 +1,4 @@ -//! Non-streaming `/v1/messages` one-shot path. -//! -//! Mirrors the streaming request shape (identity prefix, optional -//! billing attestation, optional schema-constrained output) but -//! returns flattened assistant text instead of an event stream. +//! Non-streaming `/v1/messages` one-shot path. Returns flattened assistant text. use anyhow::{Context, Result, bail}; use serde::Deserialize; @@ -18,15 +14,13 @@ use crate::message::{ContentBlock, Message}; // ── Client::complete ── impl Client { - /// Non-streaming completion, used for one-shot utility calls (AI - /// title generation, future classifiers). Returns the concatenated - /// text of the assistant's reply; non-text blocks are filtered out. + /// Non-streaming completion. Returns the concatenated assistant text; tool-use and thinking + /// blocks are dropped. `output_format` is silently ignored on models without the + /// structured-outputs capability so callers can pass it unconditionally. /// - /// `output_format` constrains the reply to a JSON schema via the - /// `structured-outputs-2025-12-15` beta. On models whose - /// [`Capabilities::structured_outputs`][crate::model::Capabilities::structured_outputs] - /// is `false`, both the body field and the beta are silently - /// dropped — the caller must tolerate free-form text in that case. + /// Distinct from [`Client::stream_message`]: no agentic beta stack, no `context_management` + /// directive, and the billing block ships **only** on OAuth — API-key one-shots omit it + /// because 3P gateways do not gate non-streaming traffic on the attestation. pub(crate) async fn complete( &self, model: &str, @@ -84,16 +78,7 @@ impl Client { // ── Body Builder ── -/// Serializes the JSON request body for [`Client::complete`]. -/// -/// System block order matches [`Client::stream_message`]: -/// -/// 1. Billing header (OAuth only; injected with `cch`). -/// 2. Identity prefix (required for non-Haiku OAuth). -/// 3. Caller-supplied system prompt (omitted when empty). -/// -/// The caller is expected to have pre-gated `output_format` against -/// [`supports_structured_outputs`]. +/// System block order: billing (OAuth only) → identity prefix → caller system. #[expect( clippy::too_many_arguments, reason = "8 distinct wire fields; a wrapper struct would just rename them" @@ -118,7 +103,6 @@ fn build_completion_body( let system_blocks = build_system_blocks(billing_header.as_deref(), [(system, None)]); let mut body = serde_json::to_string(&CreateMessageRequest { - // `[1m]` is a client-side tag; strip before the wire. model: api_model_id(model), max_tokens, stream: false, @@ -127,8 +111,6 @@ fn build_completion_body( tools: None, thinking: None, output_config: OutputConfig::new(output_format, None), - // One-shot completions never opt into context management — - // matches claude-code's one-shot path. context_management: None, messages: &messages, }) @@ -142,8 +124,7 @@ fn build_completion_body( // ── Response Handling ── -/// Flattens a `messages.create` response's content array into the -/// assistant's user-visible text (drops tool-use / thinking blocks). +/// Concatenates text blocks; drops tool-use / thinking. fn join_text_blocks(content: Vec) -> String { content .into_iter() @@ -154,9 +135,6 @@ fn join_text_blocks(content: Vec) -> String { .collect() } -/// Shape we accept back from the non-streaming `/v1/messages` endpoint. -/// The API sends many more fields (`id`, `role`, `model`, `stop_reason`, -/// `usage`); we only care about the content blocks — serde ignores the rest. #[derive(Deserialize)] struct CompletionResponse { content: Vec, @@ -183,8 +161,7 @@ mod tests { #[tokio::test] async fn complete_sends_x_claude_code_session_id_header() { - // Pins per-request injection on the non-streaming path so - // `/clear` can roll the id without rebuilding the client. + // `/clear` rolls the id without rebuilding the client; the non-streaming path must follow. let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/messages")) @@ -255,9 +232,6 @@ mod tests { #[tokio::test] async fn complete_429_surfaces_retry_after_header_in_error() { - // The `Retry-After` header from a 429 must thread into the - // error message so callers (and humans reading logs) know how - // long to back off — `format_api_error` interpolates it inline. let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/messages")) @@ -287,9 +261,7 @@ mod tests { #[tokio::test] async fn complete_malformed_response_body_errors_with_parse_context() { - // A 200 with a non-JSON body must surface the parse-failure - // context, not panic — the agent loop relies on a clean Err - // for fallback to a default title. + // A 200 with non-JSON must surface a parse error so the title path falls back cleanly. let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/messages")) @@ -314,9 +286,7 @@ mod tests { #[tokio::test] async fn complete_structured_output_gated_by_model_capability() { - // Supported model → body carries output_config, header carries - // the beta tag. Unsupported model → both are silently dropped - // (mirrors the `[1m]` × `context_1m` cross-check). + // Supported → body + beta tag ship; unsupported → both silently dropped. let fmt = OutputFormat::json_schema(serde_json::json!({ "type": "object", "properties": {"title": {"type": "string"}}, @@ -369,9 +339,7 @@ mod tests { #[tokio::test] async fn complete_oauth_haiku_carries_billing_block_but_not_gateway_tag() { - // Non-agentic Haiku drops the `claude-code-20250219` gateway tag - // (1P / 3P both tolerate its absence for Haiku one-shots) while - // still carrying the OAuth billing attestation. + // Non-agentic Haiku: drops the gateway tag, keeps the OAuth billing attestation. let server = MockServer::start().await; let sink: Captured<(String, String)> = captured(); let sink_clone = std::sync::Arc::clone(&sink); @@ -419,9 +387,8 @@ mod tests { #[tokio::test] async fn complete_does_not_emit_context_management_edits() { - // `context_management.edits` is an agentic-path directive; it - // must stay off the one-shot `complete` path even on models - // that carry the capability flag (Haiku 4.5 here). + // `context_management.edits` is agentic-only; must stay off `complete` even on + // capability-bearing models (Haiku 4.5 here). let server = MockServer::start().await; let sink: Captured = captured(); let sink_clone = std::sync::Arc::clone(&sink); @@ -483,9 +450,7 @@ mod tests { #[test] fn build_completion_body_system_blocks_match_auth_mode() { - // API key: identity prefix + caller's system = 2 blocks, no - // billing attestation. OAuth: billing + identity + system = 3 - // blocks, `cch=00000` placeholder replaced. + // API key → 2 blocks (identity + system); OAuth → 3 blocks (billing + identity + system). let api_body = build_completion_body( "claude-haiku-4-5", "sys-prompt", @@ -528,8 +493,7 @@ mod tests { #[test] fn build_completion_body_empty_system_keeps_identity_prefix_alone() { - // Identity prefix must survive even without a caller-supplied - // system prompt — non-Haiku OAuth requires it in block 0. + // Non-Haiku OAuth requires the identity prefix in block 0 even with no caller system. let body = build_completion_body( "claude-haiku-4-5", "", @@ -599,8 +563,7 @@ mod tests { #[test] fn join_text_blocks_concatenates_text_and_drops_non_text_blocks() { - // Round-trips through the real `CompletionResponse` deserializer - // to pin the live-like wire shape, not just `ContentBlock` shapes. + // Round-trips through `CompletionResponse` to pin the live wire shape. let body = r#"{ "id": "msg_1", "type": "message", @@ -621,8 +584,7 @@ mod tests { #[test] fn join_text_blocks_is_empty_for_tool_only_response() { - // Defensive: a tool_use-only reply must not surface as a title — - // the caller treats empty as "parse failure, keep fallback". + // Empty signals "parse failure, keep fallback" to the title caller. let blocks = vec![ContentBlock::ToolUse { id: "t1".to_owned(), name: "noop".to_owned(), diff --git a/crates/oxide-code/src/client/anthropic/identity.rs b/crates/oxide-code/src/client/anthropic/identity.rs index 7049fe82..3c23a76a 100644 --- a/crates/oxide-code/src/client/anthropic/identity.rs +++ b/crates/oxide-code/src/client/anthropic/identity.rs @@ -1,8 +1,5 @@ -//! Per-machine `device_id` sent in `metadata.user_id.device_id`. -//! -//! 64 lowercase hex chars persisted at `$XDG_DATA_HOME/ox/user-id`, -//! lazily minted on first use. Filesystem failure degrades to an -//! in-memory id rather than blocking client construction. +//! 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. use std::fmt::Write as _; use std::fs; @@ -20,8 +17,7 @@ const DATA_DIR: &str = "ox"; const FILE_NAME: &str = "user-id"; const ID_LEN: usize = 64; -/// Loads the persisted device id, minting and writing one if absent. -/// Filesystem failures fall back to an ephemeral id with a `warn!`. +/// Loads the persisted id, minting one if absent; falls back to ephemeral on filesystem failure. pub(super) fn load_or_create_device_id() -> String { fallback_to_ephemeral(try_load_or_create()) } @@ -61,9 +57,6 @@ fn device_id_path() -> Option { ) } -/// `Some(id)` only when `path` exists and parses as 64-char lowercase -/// hex; missing, malformed, or non-UTF-8 content returns `None` so -/// the caller mints fresh. fn read_existing(path: &Path) -> Result> { let bytes = match fs::read(path) { Ok(b) => b, @@ -105,8 +98,7 @@ mod tests { #[test] fn load_or_create_device_id_produces_valid_id_under_normal_env() { - // Doesn't override XDG_DATA_HOME because parallel `Client::new()` - // calls in other tests would race on the same tempdir. + // Don't override XDG_DATA_HOME — parallel Client::new() calls would race the tempdir. let id = load_or_create_device_id(); assert!(is_valid_id(&id), "{id}"); } @@ -160,7 +152,7 @@ mod tests { #[test] fn try_load_or_create_at_propagates_unwritable_parent_as_error() { - // `/dev/null/user-id` — parent is a regular file, can't `mkdir`. + // Parent is a regular file (`/dev/null`), so `mkdir` fails. let path = Path::new("/dev/null/ox/user-id"); let err = try_load_or_create_at(path).unwrap_err(); let chain = format!("{err:#}"); @@ -172,8 +164,7 @@ mod tests { #[test] fn try_load_or_create_at_errors_on_path_without_parent() { - // Empty path: read_existing returns None (NotFound), then - // `.parent()` is None, exercising the "no parent" branch. + // Empty path: NotFound from read_existing, then `.parent()` is None. let err = try_load_or_create_at(Path::new("")).unwrap_err(); let chain = format!("{err:#}"); assert!( @@ -194,7 +185,7 @@ mod tests { #[test] fn read_existing_propagates_io_error_other_than_not_found() { - // Reading a directory as a file errors with IsADirectory (not NotFound). + // Reading a directory as a file errors with IsADirectory. let dir = tempdir().unwrap(); let err = read_existing(dir.path()).unwrap_err(); let chain = format!("{err:#}"); diff --git a/crates/oxide-code/src/client/anthropic/sse.rs b/crates/oxide-code/src/client/anthropic/sse.rs index 97f8a808..b17519cd 100644 --- a/crates/oxide-code/src/client/anthropic/sse.rs +++ b/crates/oxide-code/src/client/anthropic/sse.rs @@ -7,15 +7,18 @@ use tracing::debug; use super::wire::StreamEvent; -/// Hard cap on the unterminated SSE frame buffer. A misbehaving upstream that -/// never emits `\n\n` would otherwise let `buf` grow without bound until OOM. +/// Hard cap — a misbehaving upstream that never emits `\n\n` would otherwise grow until OOM. #[cfg(not(test))] const MAX_SSE_FRAME_BYTES: usize = 8 * 1024 * 1024; -/// Tests use a smaller cap so the overflow path is exercised in -/// milliseconds without allocating 8 MiB of mock body per run. +/// Tests use a smaller cap so the overflow path exercises without allocating 8 MiB per run. #[cfg(test)] const MAX_SSE_FRAME_BYTES: usize = 4 * 1024; +/// Drives the streaming `/v1/messages` request and forwards parsed [`StreamEvent`]s on `tx`. +/// +/// Returns `Ok(())` on graceful end-of-stream **or** receiver drop (consumer cancellation is not +/// an error). Returns `Err` only for transport / HTTP failures and the SSE-buffer overflow guard. +/// Single malformed frames are logged and skipped — they must not poison the rest of the turn. pub(super) async fn stream_sse( http: &reqwest::Client, url: &str, @@ -47,8 +50,7 @@ pub(super) async fn stream_sse( } let mut stream = response.bytes_stream(); - // Byte buffer: reassembles UTF-8 sequences split across chunk boundaries - // intact (`from_utf8_lossy` would inject U+FFFD at the boundary). + // Byte buffer reassembles UTF-8 split across chunks; lossy would inject U+FFFD. let mut buf: Vec = Vec::new(); while let Some(chunk) = stream.next().await { @@ -91,9 +93,9 @@ pub(super) async fn stream_sse( Ok(()) } -/// Builds an actionable error message for a non-2xx Anthropic API -/// response. The raw body is always appended as `details: {body}` so -/// debug context is preserved on every branch. +/// Renders a user-facing diagnostic for a non-2xx Anthropic response. Per-status branches surface +/// actionable hints (auth recovery, retry-after, transient overload); the body is appended for +/// debuggability. pub(super) fn format_api_error( status: reqwest::StatusCode, retry_after: Option<&str>, @@ -114,12 +116,10 @@ pub(super) fn format_api_error( format!("{prefix} details: {body}") } -/// Parses a single SSE frame into a [`StreamEvent`]. -/// -/// Per the SSE spec, multiple `data:` lines concatenate with `\n`. -/// Anthropic currently emits single-line data, but we follow the spec -/// so a future multi-line payload doesn't silently lose everything -/// but the last line. +/// Parses one SSE frame (lines up to a blank-line separator). Returns `Ok(None)` for frames with +/// no `data:` line (comments, heartbeats). Per the SSE spec, multiple `data:` lines concatenate +/// with `\n`; both `data: payload` and `data:payload` (no leading space) are accepted because some +/// gateways drop the space. pub(super) fn parse_sse_frame(frame: &str) -> Result> { let mut data_lines: Vec<&str> = Vec::new(); @@ -155,9 +155,7 @@ mod tests { #[tokio::test] async fn stream_sse_buffer_overflow_bails_when_frame_lacks_terminator() { - // An upstream that emits a long byte string with no `\n\n` - // separator would let the buffer grow unbounded; the cap turns - // it into an actionable error instead of an OOM. + // No `\n\n` separator would grow the buffer unbounded; the cap turns this into an error. use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -196,9 +194,7 @@ mod tests { #[tokio::test] async fn stream_sse_skips_invalid_utf8_frame_then_keeps_streaming() { - // A chunk with non-UTF-8 bytes between SSE separators must be - // skipped (not surfaced) so a single corrupted frame can't poison - // the stream — every other frame after it must still deliver. + // A non-UTF-8 frame must be skipped silently so it doesn't poison the rest of the stream. use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -249,9 +245,7 @@ mod tests { #[tokio::test] async fn stream_sse_handles_data_less_frames_without_emitting_event() { - // A frame with only an `event:` (or comment) line and no `data:` - // line parses to `Ok(None)`; the loop must consume it silently - // and continue — Anthropic occasionally emits comment heartbeats. + // Frames with no `data:` (e.g., comment heartbeats) parse to Ok(None) and are skipped. use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -298,9 +292,7 @@ mod tests { #[tokio::test] async fn stream_sse_succeeds_when_receiver_drops_before_send() { - // Consumer cancellation closes the channel; the next tx.send - // call surfaces an Err which stream_sse must treat as graceful - // shutdown (Ok(())), not propagate as a stream failure. + // Consumer cancellation must surface as graceful Ok(()), not an Err on the stream. use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -502,8 +494,7 @@ mod tests { #[test] fn parse_sse_frame_concatenates_multiple_data_lines_with_newline() { - // Per the SSE spec, multiple data: lines join with \n. JSON - // treats \n as token whitespace, so this round-trips cleanly. + // Multiple `data:` lines join with `\n`; JSON treats it as whitespace, so this round-trips. let frame = indoc! {r#" event: ping data: {"type": diff --git a/crates/oxide-code/src/client/anthropic/testing.rs b/crates/oxide-code/src/client/anthropic/testing.rs index 2d8dc21c..3f8ed692 100644 --- a/crates/oxide-code/src/client/anthropic/testing.rs +++ b/crates/oxide-code/src/client/anthropic/testing.rs @@ -1,5 +1,4 @@ -//! Shared test fixtures for the Anthropic client and its consumers -//! (`agent::tests`, `session::title_generator::tests`). +//! Shared test fixtures for the Anthropic client and its consumers. use std::sync::{Arc, Mutex}; @@ -7,9 +6,7 @@ use super::Client; use crate::config::{Auth, Config, PromptCacheTtl}; use crate::tui::theme::Theme; -/// Minimal [`Config`] suitable for unit and wiremock tests. Defaults -/// match every existing call site: `max_tokens = 128`, `thinking = None`, -/// `show_thinking = false`. +/// Minimal [`Config`] for unit / wiremock tests. pub(crate) fn test_config(base_url: impl Into, auth: Auth, model: &str) -> Config { Config { auth, @@ -24,15 +21,12 @@ pub(crate) fn test_config(base_url: impl Into, auth: Auth, model: &str) } } -/// [`Client`] on top of [`test_config`], with a fixed session id so the -/// wire headers carry a deterministic `x-claude-code-session-id`. +/// Fixed session id so wire headers carry a deterministic `x-claude-code-session-id`. pub(crate) fn test_client(base_url: impl Into, auth: Auth, model: &str) -> Client { Client::new(test_config(base_url, auth, model), Some("sid".to_owned())).unwrap() } -/// Non-streaming Messages-API response body with the given text content. -/// Model is hardcoded; assertions in tests inspect request-side model -/// selection, never response-side. +/// Non-streaming response body with the given text. Model is hardcoded; tests assert request side. pub(crate) fn completion_body(text: &str) -> String { serde_json::json!({ "id": "msg_1", @@ -54,10 +48,8 @@ pub(crate) fn oauth() -> Auth { Auth::OAuth("t".to_owned()) } -/// Slot for data captured by a wiremock responder closure (request body, -/// header pair, etc.). The `Arc>>` triple is needed -/// because wiremock responders are `Fn`, capture is mutable, and tests -/// need to inspect the value across the await boundary. +/// Slot for data captured by a wiremock responder. `Fn` capture + cross-await inspection require +/// the full `Arc>>` triple. pub(crate) type Captured = Arc>>; pub(crate) fn captured() -> Captured { diff --git a/crates/oxide-code/src/client/anthropic/wire.rs b/crates/oxide-code/src/client/anthropic/wire.rs index 0ba80385..9c79df6c 100644 --- a/crates/oxide-code/src/client/anthropic/wire.rs +++ b/crates/oxide-code/src/client/anthropic/wire.rs @@ -1,5 +1,4 @@ -//! Anthropic Messages API wire types — pure data; helpers that build -//! or interpret these types live in sibling modules. +//! Anthropic Messages API wire types. Pure data; builders / interpreters live in sibling modules. use serde::{Deserialize, Serialize}; @@ -9,42 +8,33 @@ 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 +/// `messages` content so [`super::billing::inject_cch`]'s single-occurrence replacement targets +/// the system block, not a user echo. #[derive(Serialize)] pub(super) struct CreateMessageRequest<'a> { pub(super) model: &'a str, pub(super) max_tokens: u32, pub(super) stream: bool, pub(super) metadata: RequestMetadata, - /// Serialized before `messages` so the billing header's `cch=00000` - /// placeholder appears first in the JSON, making - /// [`super::billing::inject_cch`]'s single-occurrence replacement - /// safe even when tool results contain the literal placeholder - /// string. + /// 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")] pub(super) tools: Option<&'a [ToolDefinition]>, #[serde(skip_serializing_if = "Option::is_none")] pub(super) thinking: Option<&'a ThinkingConfig>, - /// Carries both the `format` (JSON-schema-constrained output for - /// one-shot calls) and `effort` (agentic-path intelligence tier) - /// knobs. Wrapped in `Option` so an empty `OutputConfig` never - /// ships — callers build one via [`OutputConfig::new`] and pass - /// `None` when neither sub-field is set. + /// `format` (JSON-schema for one-shots) + `effort` (agentic tier). Optional so an empty + /// `OutputConfig` never ships. #[serde(skip_serializing_if = "Option::is_none")] pub(super) output_config: Option>, - /// `context_management.edits` — the client-side context-editing - /// directive that partners the `context-management-2025-06-27` - /// beta header. Populated on the streaming path for any model - /// with [`Capabilities::context_management`][crate::model::Capabilities::context_management] - /// set. + /// Partners the `context-management-2025-06-27` beta on streaming requests for capable models. #[serde(skip_serializing_if = "Option::is_none")] pub(super) context_management: Option, pub(super) messages: &'a [Message], } -/// Shared wrapper for the `output_config` body field. Either field -/// may be absent; when both are, [`Self::new`] returns `None` so the -/// builder never ships an empty object. #[derive(Serialize)] pub(super) struct OutputConfig<'a> { #[serde(skip_serializing_if = "Option::is_none")] @@ -54,26 +44,19 @@ pub(super) struct OutputConfig<'a> { } impl<'a> OutputConfig<'a> { - /// Returns `None` when every field is empty so callers can avoid - /// shipping a bare `{}`. `Some(_)` otherwise. + /// Returns `None` when both fields are empty; `Some(_)` otherwise. pub(super) fn new(format: Option<&'a OutputFormat>, effort: Option) -> Option { (format.is_some() || effort.is_some()).then_some(Self { format, effort }) } } -/// `context_management.edits` body field. oxide-code mirrors -/// claude-code 2.1.119's observed wire shape — a single -/// `clear_thinking_20251015` edit with `keep = "all"` on every -/// agentic request that also ships the matching beta header. +/// Mirrors claude-code 2.1.119: a single `clear_thinking_20251015` edit with `keep = "all"`. #[derive(Serialize)] pub(super) struct ContextManagement { edits: [ContextEdit; 1], } impl ContextManagement { - /// Wire shape claude-code 2.1.119 sends on every 4.6+ request. - /// Single place to edit when Anthropic ships newer edit types or - /// we need to diverge from the default. pub(super) fn clear_thinking_keep_all() -> Self { Self { edits: [ContextEdit { @@ -90,10 +73,7 @@ struct ContextEdit { keep: &'static str, } -/// JSON-schema-constrained completion format. Constructed via -/// [`OutputFormat::json_schema`]; callers typically build one per -/// request shape (e.g., `{"title": string}`) and pass it by reference -/// to [`super::Client::complete`]. +/// JSON-schema-constrained completion format. #[derive(Debug, Serialize)] pub(crate) struct OutputFormat { r#type: &'static str, @@ -101,10 +81,6 @@ pub(crate) struct OutputFormat { } impl OutputFormat { - /// Builds a `json_schema` output format from a precomputed schema - /// value. The schema must already match Anthropic's expectations - /// (`type: "object"`, `additionalProperties: false`, explicit - /// `required` array) — we don't validate here. pub(crate) fn json_schema(schema: serde_json::Value) -> Self { Self { r#type: "json_schema", @@ -113,21 +89,14 @@ impl OutputFormat { } } -/// Top-level `metadata` object on every outbound request. -/// -/// `user_id` is a stringified JSON object with the canonical claude-code -/// shape `{device_id, account_uuid, session_id}`; field order is part of -/// the wire fingerprint. The API receives it as a flat string, not a -/// nested object. +/// `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 { pub(super) user_id: String, } -/// A text block in the system prompt array. The Anthropic API accepts `system` -/// as either a string or an array of these blocks. Using the array form lets -/// the identity prefix occupy its own block, which is required for OAuth -/// validation on non-Haiku models. +/// Array form is required so the identity prefix occupies its own block (OAuth non-Haiku gate). #[derive(Serialize)] pub(super) struct SystemBlock<'a> { pub(super) r#type: &'static str, @@ -136,17 +105,8 @@ pub(super) struct SystemBlock<'a> { pub(super) cache_control: Option, } -/// Prompt caching control. The `scope` field determines the cache sharing -/// level: `"global"` for static content identical across sessions (1P only), -/// `None` for the default org-scoped ephemeral cache (universally accepted). -/// The `ttl` field overrides the server default (5 m as of 2026-03) — -/// oxide-code defaults to `"1h"`, opt-out via `prompt_cache_ttl = "5m"`. -/// -/// `scope: "global"` must be a true prefix of all preceding request content -/// — the server rejects a global-scoped block preceded by a non-global -/// block (including tool definitions, which render before `system`). See -/// [`super::betas::is_first_party_base_url`] for where the gating decision -/// is made. +/// `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 { pub(super) r#type: &'static str, @@ -165,6 +125,8 @@ pub(super) struct CacheControl { reason = "fields are populated by serde and used in downstream matching" ) )] +/// One frame in the streaming response. `Unknown` swallows future event types so an upstream +/// addition does not break parsing — agent dispatch ignores `Unknown` rather than erroring. #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub(crate) enum StreamEvent { @@ -191,8 +153,6 @@ pub(crate) enum StreamEvent { Error { error: ApiError, }, - /// Catch-all for unrecognized event types. - /// Silently skipped during stream processing. #[serde(other)] Unknown, } @@ -232,8 +192,6 @@ pub(crate) enum ContentBlockInfo { RedactedThinking { data: String, }, - /// Catch-all for unrecognized block types. - /// Silently skipped during stream processing. #[serde(other)] Unknown, } @@ -254,12 +212,9 @@ pub(crate) enum Delta { ThinkingDelta { thinking: String, }, - /// Full signature value (overwrites, not appended). SignatureDelta { signature: String, }, - /// Catch-all for unrecognized delta types. - /// Silently skipped during stream processing. #[serde(other)] Unknown, } diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index 294e7f58..21a18c28 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -1,9 +1,7 @@ //! Configuration loading. //! -//! Layered precedence (highest wins): env vars > project `ox.toml` > -//! user `~/.config/ox/config.toml` > built-in defaults. Auth follows -//! the same precedence but terminates at the first source that -//! resolves (API key env > API key in file > OAuth credentials). +//! Precedence (highest wins): env > project `ox.toml` > user `~/.config/ox/config.toml` > defaults. +//! Auth stops at the first source that resolves (API key env > API key in file > OAuth). pub(crate) mod file; mod oauth; @@ -22,18 +20,15 @@ const DEFAULT_BASE_URL: &str = "https://api.anthropic.com"; // ── Auth ── +/// Resolved credential. First source wins, in order: `ANTHROPIC_API_KEY` env, `client.api_key` +/// in the config file, then OAuth (macOS Keychain → `~/.claude/.credentials.json`). #[derive(Debug, Clone)] pub(crate) enum Auth { - /// Explicit API key (`x-api-key` header). ApiKey(String), - /// OAuth access token from Claude Code (`Authorization: Bearer` header). OAuth(String), } impl Auth { - /// Short label naming the credential type (`"API key"` / - /// `"OAuth"`). Surfaced by `/status` and `/config` so the user - /// knows which auth source is live without dumping the secret. pub(crate) const fn label(&self) -> &'static str { match self { Self::ApiKey(_) => "API key", @@ -44,11 +39,7 @@ impl Auth { // ── ConfigSnapshot ── -/// Resolved-config view — every field [`Config`] holds except the -/// secret. Built at startup from [`Config::snapshot`] and handed into -/// the slash dispatcher; survives the move when [`Config`] itself is -/// consumed by the API client. `model_id` and `effort` are rebound by -/// runtime slash-command swaps so the snapshot reflects live values. +/// Resolved-config view minus the secret. Survives [`Config`] being consumed by the client. #[derive(Debug, Clone)] pub(crate) struct ConfigSnapshot { pub(crate) model_id: String, @@ -65,19 +56,14 @@ pub(crate) struct ConfigSnapshot { #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub(crate) enum ThinkingConfig { - /// Model decides the thinking budget (Claude 4.6+). `display` - /// controls what the API streams back: `Omitted` (4.7 default, - /// empty `thinking` field) or `Summarized` (the 4.6 default, and - /// what oxide-code enables whenever `show_thinking=true`). + /// Model decides the thinking budget (4.6+). Adaptive { #[serde(skip_serializing_if = "Option::is_none")] display: Option, }, } -/// `thinking.display` values accepted by the API on 4.7+. Only -/// `Summarized` is ever emitted — omitting the field entirely (via -/// `display: None`) already yields the `omitted` default on 4.7. +/// `thinking.display` values accepted on 4.7+. #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum ThinkingDisplay { @@ -86,9 +72,7 @@ pub(crate) enum ThinkingDisplay { // ── Effort ── -/// Intelligence-vs-latency tier sent as `output_config.effort` on -/// effort-capable models. The per-model ceiling lives in -/// [`crate::model::Capabilities`]. +/// Intelligence-vs-latency tier sent as `output_config.effort`. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub(crate) enum Effort { @@ -140,9 +124,7 @@ impl FromStr for Effort { // ── PromptCacheTtl ── -/// Prompt-cache TTL sent as `cache_control.ttl`. Anthropic silently -/// dropped the default from 1h to 5m on 2026-03-06, so `OneHour` is -/// explicit opt-in. oxide-code defaults to `OneHour`. +/// Sent as `cache_control.ttl`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub(crate) enum PromptCacheTtl { #[serde(rename = "5m")] @@ -152,8 +134,7 @@ pub(crate) enum PromptCacheTtl { } impl PromptCacheTtl { - /// Wire value for `cache_control.ttl`. `None` when the TTL is - /// the server default (5 m) so the JSON omits the field entirely. + /// `None` for the server default (5 m). pub(crate) const fn wire(self) -> Option<&'static str> { match self { Self::FiveMin => None, @@ -193,35 +174,22 @@ impl FromStr for PromptCacheTtl { #[derive(Debug, Clone)] pub(crate) struct Config { pub(crate) model: String, - /// `output_config.effort` for the streaming path. `None` means - /// the model doesn't accept the parameter and the field is - /// omitted. Resolved once at [`Config::load`]; mutated mid-session - /// by `/effort` and re-clamped on `/model` swaps. + /// `None` when the model doesn't accept it. pub(crate) effort: Option, pub(crate) auth: Auth, pub(crate) base_url: String, pub(crate) max_tokens: u32, - /// `cache_control.ttl` for every cacheable block. Default is - /// [`PromptCacheTtl::OneHour`] since Anthropic's 2026-03 TTL - /// drop made the server default (5 m) a silent cost regression - /// on long sessions. pub(crate) prompt_cache_ttl: PromptCacheTtl, pub(crate) thinking: Option, pub(crate) show_thinking: bool, - /// Resolved TUI theme — base + per-slot overrides applied at - /// load time. Theme-selection errors hard-fail; per-slot value - /// errors warn and fall back to the base value. pub(crate) theme: Theme, } impl Config { - /// Loads configuration from files and environment variables. - /// - /// Precedence (highest wins): env vars > project `ox.toml` > user - /// `~/.config/ox/config.toml` > built-in defaults. - /// - /// Auth priority: `ANTHROPIC_API_KEY` env var > `api_key` in config - /// file > Claude Code OAuth credentials. + /// Resolves config from layered sources. Per-field precedence is env > project `ox.toml` > + /// user `~/.config/ox/config.toml` > built-in default; see the module docs for the auth + /// chain. Parse errors (TOML, env, theme) propagate so a typo doesn't degrade silently into + /// "no credentials". pub(crate) async fn load() -> Result { let fc = file::load()?; let client = fc.client.unwrap_or_default(); @@ -264,10 +232,8 @@ impl Config { .or(tui.show_thinking) .unwrap_or(false); - // Adaptive thinking is always enabled — the model decides the - // budget. `display` opts 4.7 into streaming summarized thinking - // text (its default changed to `omitted` silently); 4.6 and - // older ignore the field. + // 4.7 silently defaulted to `omitted`; `display` opts back into summarized. 4.6 and older + // ignore the field. let thinking = Some(ThinkingConfig::Adaptive { display: show_thinking.then_some(ThinkingDisplay::Summarized), }); @@ -297,9 +263,7 @@ impl Config { }) } - /// Captures the resolved descriptors `/config` (and friends) - /// print, minus the auth secret. Called before `self` is moved - /// into the API client so the snapshot survives the move. + /// Descriptors for `/config` and `/status`, minus the auth secret. pub(crate) fn snapshot(&self) -> ConfigSnapshot { ConfigSnapshot { model_id: self.model.clone(), @@ -319,8 +283,6 @@ pub(crate) fn display_effort(effort: Option) -> String { effort.map_or_else(|| "(no effort tier)".to_owned(), |e| e.to_string()) } -/// Per-effort `max_tokens` default; overridden by -/// `ANTHROPIC_MAX_TOKENS` / `[client].max_tokens`. fn default_max_tokens(effort: Option) -> u32 { match effort { Some(Effort::Xhigh | Effort::Max) => 64_000, @@ -342,9 +304,7 @@ mod tests { #[test] fn label_distinguishes_api_key_from_oauth() { - // Both branches reach `/status` and `/config` rows. Pin the - // exact strings — a regression that swapped them would mislabel - // every user's auth source without otherwise tripping a test. + // Swap would mislabel every user's auth source. assert_eq!(Auth::ApiKey("secret".to_owned()).label(), "API key"); assert_eq!(Auth::OAuth("token".to_owned()).label(), "OAuth"); } @@ -353,8 +313,6 @@ mod tests { #[test] fn thinking_config_adaptive_without_display_serializes_bare() { - // Older models ignore `display`; absence keeps the wire as - // pre-4.7 clients expect. let json = serde_json::to_value(&ThinkingConfig::Adaptive { display: None }).unwrap(); assert_eq!(json["type"], "adaptive"); assert!(json.get("display").is_none(), "display omitted: {json}"); @@ -408,7 +366,6 @@ mod tests { #[test] fn prompt_cache_ttl_wire_shape() { - // 5m is the server default → field omitted. 1h opts in → "1h". assert_eq!(PromptCacheTtl::FiveMin.wire(), None); assert_eq!(PromptCacheTtl::OneHour.wire(), Some("1h")); } @@ -436,10 +393,8 @@ mod tests { // ── Config::load ── - /// Env keys `Config::load` reads. Baseline for [`env_vars`] so - /// nothing bleeds in from the caller's environment; `ANTHROPIC_API_KEY` - /// ships with a non-empty default so tests land on the `ApiKey` arm - /// and never consult the real OAuth credential sources. + /// Env keys `Config::load` reads. `ANTHROPIC_API_KEY` defaults non-empty in [`env_vars`] so + /// tests land on `ApiKey` without consulting OAuth. const ENV_KEYS: &[&str] = &[ "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL", @@ -457,11 +412,7 @@ mod tests { std::fs::write(config_dir.join("config.toml"), body).unwrap(); } - /// Baseline env list (every [`ENV_KEYS`] entry unset, `ANTHROPIC_API_KEY` - /// set to `"sk-default"`) with `overrides` applied on top. Panics if - /// an override key is not in [`ENV_KEYS`] so a misspelling surfaces - /// immediately. Returns a `Vec` because `temp_env::async_with_vars` - /// takes `AsRef<[(K, Option)]>`. + /// Baseline env (all unset, `ANTHROPIC_API_KEY` = `"sk-default"`) plus overrides. fn env_vars( overrides: impl IntoIterator)>, ) -> Vec<(&'static str, Option)> { @@ -498,8 +449,7 @@ mod tests { #[tokio::test] async fn load_defaults_apply_when_no_config_and_no_env() { - // Opus 4.7 supports `xhigh`, so both `effort` and `max_tokens` - // derive from that ceiling. Prompt cache defaults to 1h. + // Opus 4.7 supports `xhigh`; `effort` / `max_tokens` derive from that ceiling. let dir = tempfile::tempdir().unwrap(); let config = temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load()) .await @@ -650,11 +600,8 @@ mod tests { ); } - /// Regression: a misplaced field used to drop the entire config - /// silently (parse error logged at `warn`, invisible without - /// `RUST_LOG`), which then surfaced as a confusing - /// "no credentials" error when the dropped config also held the - /// API key. The parse error must propagate instead. + /// Regression: misplaced fields used to drop the whole config silently and surface as + /// "no credentials". Parse errors must propagate. #[tokio::test] async fn load_propagates_invalid_config_file() { let dir = tempfile::tempdir().unwrap(); @@ -675,10 +622,6 @@ mod tests { assert!(msg.contains("unknown field `show_thinking`"), "{msg}"); } - /// Theme-resolution failures from `[tui.theme]` must propagate - /// out of `Config::load` instead of getting swallowed — the user - /// needs to see *which* theme name is broken, not a downstream - /// "no credentials"-style misdirection. #[tokio::test] async fn load_propagates_theme_resolution_error() { let dir = tempfile::tempdir().unwrap(); @@ -736,8 +679,7 @@ mod tests { #[tokio::test] async fn load_effort_clamps_xhigh_down_to_high_on_sonnet_4_6() { - // Sonnet 4.6 supports `effort` but not `xhigh` / `max` — the - // user's pick must clamp rather than 400 the gateway. + // Sonnet 4.6 has effort but not `xhigh` / `max`; must clamp, not 400 the gateway. let dir = tempfile::tempdir().unwrap(); let vars = env_vars(vec![ xdg(&dir), @@ -811,56 +753,6 @@ mod tests { assert!(msg.contains("insane"), "{msg}"); } - // ── display_effort ── - - #[test] - fn display_effort_names_effective_tier_or_no_tier() { - assert_eq!(display_effort(Some(Effort::High)), "high"); - assert_eq!(display_effort(None), "(no effort tier)"); - } - - // ── Config::snapshot ── - - #[test] - fn snapshot_copies_every_user_facing_field_and_drops_secret() { - // The snapshot is what `/config` prints; pin every field so a - // regression that forgot to copy one (or that swapped two - // names) shows up here, not silently in the rendered table. - // The auth secret never reaches the snapshot — only the - // `label()` projection does. - let cfg = Config { - auth: Auth::OAuth("token-must-not-leak".to_owned()), - base_url: "https://api.example.test".to_owned(), - model: "claude-test-1-0".to_owned(), - effort: Some(Effort::Xhigh), - max_tokens: 64_000, - prompt_cache_ttl: PromptCacheTtl::FiveMin, - thinking: None, - show_thinking: true, - theme: Theme::default(), - }; - let snap = cfg.snapshot(); - assert_eq!(snap.auth_label, "OAuth"); - assert_eq!(snap.base_url, "https://api.example.test"); - assert_eq!(snap.model_id, "claude-test-1-0"); - assert_eq!(snap.effort, Some(Effort::Xhigh)); - assert_eq!(snap.max_tokens, 64_000); - assert_eq!(snap.prompt_cache_ttl, PromptCacheTtl::FiveMin); - assert!(snap.show_thinking); - } - - // ── default_max_tokens ── - - #[test] - fn default_max_tokens_scales_with_effort() { - assert_eq!(default_max_tokens(Some(Effort::Max)), 64_000); - assert_eq!(default_max_tokens(Some(Effort::Xhigh)), 64_000); - assert_eq!(default_max_tokens(Some(Effort::High)), 32_000); - assert_eq!(default_max_tokens(Some(Effort::Medium)), 16_000); - assert_eq!(default_max_tokens(Some(Effort::Low)), 16_000); - assert_eq!(default_max_tokens(None), 16_000); - } - // ── Config::load / prompt_cache_ttl ── #[tokio::test] @@ -917,4 +809,50 @@ mod tests { assert!(msg.contains("OX_PROMPT_CACHE_TTL"), "{msg}"); assert!(msg.contains("forever"), "{msg}"); } + + // ── Config::snapshot ── + + #[test] + fn snapshot_copies_every_user_facing_field_and_drops_secret() { + // `/config` prints from the snapshot; secret must reduce to `label()`. + let cfg = Config { + auth: Auth::OAuth("token-must-not-leak".to_owned()), + base_url: "https://api.example.test".to_owned(), + model: "claude-test-1-0".to_owned(), + effort: Some(Effort::Xhigh), + max_tokens: 64_000, + prompt_cache_ttl: PromptCacheTtl::FiveMin, + thinking: None, + show_thinking: true, + theme: Theme::default(), + }; + let snap = cfg.snapshot(); + assert_eq!(snap.auth_label, "OAuth"); + assert_eq!(snap.base_url, "https://api.example.test"); + assert_eq!(snap.model_id, "claude-test-1-0"); + assert_eq!(snap.effort, Some(Effort::Xhigh)); + assert_eq!(snap.max_tokens, 64_000); + assert_eq!(snap.prompt_cache_ttl, PromptCacheTtl::FiveMin); + assert!(snap.show_thinking); + } + + // ── display_effort ── + + #[test] + fn display_effort_names_effective_tier_or_no_tier() { + assert_eq!(display_effort(Some(Effort::High)), "high"); + assert_eq!(display_effort(None), "(no effort tier)"); + } + + // ── default_max_tokens ── + + #[test] + fn default_max_tokens_scales_with_effort() { + assert_eq!(default_max_tokens(Some(Effort::Max)), 64_000); + assert_eq!(default_max_tokens(Some(Effort::Xhigh)), 64_000); + assert_eq!(default_max_tokens(Some(Effort::High)), 32_000); + assert_eq!(default_max_tokens(Some(Effort::Medium)), 16_000); + assert_eq!(default_max_tokens(Some(Effort::Low)), 16_000); + assert_eq!(default_max_tokens(None), 16_000); + } } diff --git a/crates/oxide-code/src/config/file.rs b/crates/oxide-code/src/config/file.rs index 567a2eea..333767c4 100644 --- a/crates/oxide-code/src/config/file.rs +++ b/crates/oxide-code/src/config/file.rs @@ -16,25 +16,7 @@ const PROJECT_CONFIG_FILENAME: &str = "ox.toml"; // ── Config Structs ── -/// Top-level configuration loaded from a TOML file. -/// -/// All sections and fields are optional — each source contributes only the -/// values it sets. Higher-priority sources override lower-priority ones -/// field by field via [`FileConfig::merge`]. -/// -/// ```toml -/// [client] -/// model = "claude-sonnet-4-6" -/// -/// [tui] -/// show_thinking = true -/// -/// [tui.theme] -/// base = "latte" -/// -/// [tui.theme.overrides] -/// error = "#ff0000" -/// ``` +/// All fields optional; merge via [`FileConfig::merge`]. #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct FileConfig { @@ -42,11 +24,6 @@ pub(super) struct FileConfig { pub(super) tui: Option, } -/// API client settings (`[client]` section). -/// -/// Fields are grouped by concern so adjacent lines stay related: -/// connection (`api_key`, `base_url`), model selection (`model`, -/// `effort`), then request tuning (`max_tokens`, `prompt_cache_ttl`). #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct ClientConfig { @@ -58,7 +35,6 @@ pub(super) struct ClientConfig { pub(super) prompt_cache_ttl: Option, } -/// Terminal UI settings (`[tui]` section). #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct TuiConfig { @@ -66,12 +42,6 @@ pub(super) struct TuiConfig { pub(super) theme: Option, } -/// Theme settings (`[tui.theme]` section). -/// -/// `base` selects a built-in name (`mocha`, `macchiato`, `frappe`, -/// `latte`) or a filesystem path (`~/.config/ox/themes/dark.toml`) -/// to a TOML body. The `[tui.theme.overrides]` table patches -/// individual slots on top of the resolved base. #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct ThemeFileConfig { @@ -82,7 +52,7 @@ pub(super) struct ThemeFileConfig { // ── Merge ── impl FileConfig { - /// Merges two configs. Fields in `other` take precedence over `self`. + /// Fields in `other` take precedence over `self`. fn merge(self, other: Self) -> Self { Self { client: merge_section(self.client, other.client, ClientConfig::merge), @@ -114,11 +84,7 @@ impl TuiConfig { } impl ThemeFileConfig { - /// Merge two theme configs. `base` follows the standard "other - /// wins" rule. `overrides` are merged key-by-key — each side - /// contributes its slot patches; on key collision, `other` wins - /// so a project-level patch overrides a user-level patch for - /// the same slot. + /// `base`: other wins. `overrides`: merged key-by-key, `other` wins collisions. fn merge(self, other: Self) -> Self { let overrides = match (self.overrides, other.overrides) { (Some(mut s), Some(o)) => { @@ -134,8 +100,6 @@ impl ThemeFileConfig { } } -/// Merges two optional config sections. When both are present, merges their -/// fields. When only one is present, use it as-is. fn merge_section(base: Option, other: Option, merge: fn(T, T) -> T) -> Option { match (base, other) { (Some(b), Some(o)) => Some(merge(b, o)), @@ -145,16 +109,8 @@ fn merge_section(base: Option, other: Option, merge: fn(T, T) -> T) -> // ── Loading ── -/// Loads and merges configuration from user and project TOML files. -/// -/// Precedence (highest wins): project config > user config. -/// Environment variable overrides are applied later in [`super::Config::load`]. -/// -/// Returns an error if any discovered file is unreadable or malformed — -/// silent fallthrough would otherwise hide typos (e.g. a misplaced -/// `show_thinking` under `[client]`) and surface as a confusing -/// downstream "no credentials" error after the dropped config takes -/// the API key with it. +/// Loads + merges user and project TOML; project wins. Errors propagate so typos don't surface as +/// downstream "no credentials" errors. pub(super) fn load() -> Result { let user = user_config_path() .map(|p| load_file(&p)) @@ -172,9 +128,7 @@ pub(super) fn load() -> Result { }) } -/// Reads a single config file. `Ok(None)` when the file does not -/// exist; `Err` when it exists but cannot be read or parsed (so the -/// caller can surface the path and underlying TOML diagnostic). +/// `Ok(None)` when missing; `Err` when present but unreadable or malformed. fn load_file(path: &Path) -> Result> { let content = match std::fs::read_to_string(path) { Ok(c) => c, @@ -191,8 +145,6 @@ fn load_file(path: &Path) -> Result> { // ── Path Discovery ── -/// User config: `$XDG_CONFIG_HOME/ox/config.toml`, falling back to -/// `~/.config/ox/config.toml`. pub(crate) fn user_config_path() -> Option { xdg_dir( std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), @@ -207,10 +159,6 @@ pub(crate) fn find_project_config() -> Option { find_project_config_from(std::env::current_dir().ok()?) } -/// Walks from `start` upward to find the nearest `ox.toml`. -/// -/// Separated from [`find_project_config`] for testability (avoids changing -/// the process CWD). fn find_project_config_from(mut dir: PathBuf) -> Option { loop { let candidate = dir.join(PROJECT_CONFIG_FILENAME); @@ -370,8 +318,6 @@ mod tests { #[test] fn theme_merge_overrides_extend_when_both_set() { - // Project (other) extends user (self) — disjoint slots - // contribute from both layers. let base = theme_with(None, &[("error", "#aaaaaa")]); let other = theme_with(None, &[("accent", "#bbbbbb")]); let merged = base.merge(other); @@ -383,8 +329,6 @@ mod tests { #[test] fn theme_merge_other_override_wins_on_slot_collision() { - // Same slot patched in both layers — `other` (higher priority, - // typically the project file) wins for that slot. let base = theme_with(None, &[("error", "#aaaaaa")]); let other = theme_with(None, &[("error", "#bbbbbb")]); let merged = base.merge(other); @@ -500,11 +444,7 @@ mod tests { assert!(result.is_none()); } - /// Read errors other than `NotFound` (here: pointing at a - /// directory raises `IsADirectory`) must surface with the - /// offending path so the user can act on it. Splitting this from - /// the missing-file branch is the whole point of distinguishing - /// `NotFound` from other IO errors in `load_file`. + /// Non-`NotFound` IO errors (e.g. directory → `IsADirectory`) must surface with the path. #[test] fn load_file_unreadable_path_propagates_io_error() { let dir = tempfile::tempdir().unwrap(); @@ -530,10 +470,7 @@ mod tests { assert!(msg.contains("invalid config at"), "{msg}"); } - /// Surfaced as a hard error so the user sees the offending path - /// and the TOML diagnostic instead of a silent fallthrough. - /// Covers `FileConfig`'s `deny_unknown_fields` (top-level keys); - /// the section-level analog is [`load_file_rejects_misplaced_field`]. + /// Covers top-level `deny_unknown_fields`. Section-level: `load_file_rejects_misplaced_field`. #[test] fn load_file_rejects_unknown_top_level_key() { let dir = tempfile::tempdir().unwrap(); @@ -546,11 +483,8 @@ mod tests { assert!(msg.contains("unknown field `model`"), "{msg}"); } - /// Catches the original bug report shape: `show_thinking` belongs - /// in `[tui]`, not `[client]`. Without `deny_unknown_fields` + - /// hard-fail, the whole file used to be dropped silently and the - /// user got an unrelated "no credentials" error instead. Also - /// covers a generic typo within a section (same code path). + /// Regression: `show_thinking` in `[client]` used to drop the whole file silently and surface + /// as "no credentials". Also covers section-level typos (same code path). #[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 c90feb5e..6f26b494 100644 --- a/crates/oxide-code/src/config/oauth.rs +++ b/crates/oxide-code/src/config/oauth.rs @@ -54,6 +54,8 @@ impl OAuthCredential { // ── Token Loading ── +/// Loads (and refreshes if near expiry) the Claude Code OAuth access token. On macOS the Keychain +/// is consulted first; non-macOS and Keychain-miss fall back to `~/.claude/.credentials.json`. pub(super) async fn load_token() -> Result { let file_path = credentials_path().context("could not determine home directory")?; let lock_path = lock_path().context("could not determine home directory")?; @@ -83,6 +85,7 @@ async fn load_token_from( let _lock = acquire_lock(lock_path).await?; + // Double-checked: a sibling process may have refreshed while we were waiting on the lock. let oauth = loader(file_path)?.claude_ai_oauth; let expires_at_ms = oauth.expires_at_ms(); if !is_near_expiry(expires_at_ms) { @@ -304,6 +307,9 @@ impl Drop for LockGuard { } } +/// Directory-based advisory lock around credential refresh. `mkdir` is the cross-platform atomic +/// primitive; the guard `rmdir`s on drop. A lock older than [`LOCK_STALE_THRESHOLD`] is treated +/// as abandoned by a crashed sibling and cleared on the next retry. async fn acquire_lock(path: &Path) -> Result { lock::retry_acquire( || match std::fs::create_dir(path) { diff --git a/crates/oxide-code/src/file_tracker.rs b/crates/oxide-code/src/file_tracker.rs index 822c050d..71bdd1dd 100644 --- a/crates/oxide-code/src/file_tracker.rs +++ b/crates/oxide-code/src/file_tracker.rs @@ -1,4 +1,4 @@ -//! Per-session file-change tracker: Read-before-Edit gate with mtime + xxh64 staleness detection. +//! Per-session Read-before-Edit gate with mtime + xxh64 staleness detection. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -13,6 +13,20 @@ const HASH_SEED: u64 = 0; // ── FileTracker ── +/// Per-session record of files the agent has read, gating subsequent Edit / Write tool calls. +/// +/// The gate enforces three invariants before mutation: +/// +/// - **Read-before-Edit**: every Edit / Write must follow a `Read` of the same path within the +/// session. +/// - **Full read required**: a partial (offset / limit) Read does not satisfy the gate; the agent +/// must Read the file in full before mutating it. +/// - **Freshness**: if (mtime, size) drifted since the recorded Read, the caller must rehash the +/// current bytes via [`Self::verify_drift_bytes`]; only matching xxh64 content qualifies as an +/// unchanged file. +/// +/// State is persisted into the session JSONL as [`FileSnapshot`]s and re-verified against disk on +/// resume — see [`Self::restore_verified`]. #[derive(Default)] pub(crate) struct FileTracker { by_path: Mutex>, @@ -27,6 +41,8 @@ struct FileState { recorded_at: OffsetDateTime, } +/// Extent of the most recent Read. Only `Full` satisfies the Edit / Write gate; `Partial` requires +/// the agent to re-Read the file in full before mutating. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub(crate) enum LastView { @@ -34,6 +50,9 @@ pub(crate) enum LastView { Partial { offset: usize, limit: usize }, } +/// Which mutating tool is asking for the gate. Carried into [`GateError`] purely so the rendered +/// error message reads `"... before editing it"` vs `"... before writing to it"` — `verb()` is the +/// only consumer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum GatePurpose { Edit, @@ -132,6 +151,8 @@ impl FileTracker { } } + /// Records bytes the agent just wrote. Always lands as [`LastView::Full`] so a subsequent Edit + /// passes the gate without requiring a fresh Read. pub(crate) fn record_modify(&self, path: &Path, bytes: &[u8], mtime: SystemTime, size: u64) { let content_hash = xxh64(bytes, HASH_SEED); let now = OffsetDateTime::now_utc(); @@ -174,7 +195,9 @@ impl FileTracker { self.lock().clear(); } - /// Rehydrates from session JSONL; drops entries whose stat no longer matches. + /// Rehydrates from session JSONL on resume. Each snapshot must still match disk on (mtime, + /// size) — drifted or missing files are dropped, forcing a fresh Read before any mutation. On + /// duplicate paths the entry with the newer `recorded_at` wins. pub(crate) fn restore_verified(&self, snapshots: Vec) { let mut by_path = self.lock(); for snap in snapshots { @@ -219,6 +242,9 @@ impl FileTracker { // ── FileSnapshot ── +/// JSONL-persisted record of one tracked file. Written into the session log on `finish` and read +/// back on resume by [`FileTracker::restore_verified`], which re-stats each path before +/// re-admitting the entry to the live tracker. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct FileSnapshot { pub(crate) path: PathBuf, @@ -233,18 +259,31 @@ pub(crate) struct FileSnapshot { // ── Outcomes ── +/// Outcome of [`FileTracker::record_read`]. +/// +/// `CacheHit` means the path was already in the tracker with identical content (full-read + +/// matching xxh64); the caller should return [`CACHE_HIT_STUB`] instead of the real bytes to +/// save tokens. `Inserted` is the regular path — fresh read, snapshot stored. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RecordRead { Inserted, CacheHit, } +/// Outcome of the cheap stat-only freshness check. +/// +/// `Pass` — `(mtime, size)` matches the recorded snapshot; the gate is satisfied without +/// re-reading bytes. `NeedsBytes { stored_hash }` — stat drifted; the caller must rehash the +/// current file contents and call [`FileTracker::verify_drift_bytes`] to confirm whether the +/// content actually changed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum StatCheck { Pass, NeedsBytes { stored_hash: u64 }, } +/// Reasons the Read-before-Edit / Write gate refuses a tool call. Each variant's `#[error]` +/// message tells the model exactly how to recover (Read first / Read in full / re-Read). #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub(crate) enum GateError { #[error("File {} has not been read in this session. Use the Read tool first before {} it.", .path.display(), .purpose.verb())] @@ -255,16 +294,13 @@ pub(crate) enum GateError { ContentDrifted { path: PathBuf, purpose: GatePurpose }, } -/// Stub returned in place of file bytes on a full re-Read of an -/// unchanged file. Signals that the prior Read is still authoritative. +/// Returned in place of file bytes when the agent re-Reads a full, unchanged file. Saves tokens +/// without giving up the Read-before-Edit invariant — the gate already saw the original content. pub(crate) const CACHE_HIT_STUB: &str = "File hasn't been modified since the last read. Returning already-read file."; // ── Testing ── -/// Shared test fixtures. Centralized so the five callers don't each -/// grow near-duplicates that subtly disagree on what a "seeded -/// tracker" means. #[cfg(test)] pub(crate) mod testing { use std::path::Path; @@ -273,15 +309,10 @@ pub(crate) mod testing { use super::{FileTracker, LastView}; - /// Fresh `Arc` for callers that need ownership - /// (`ReadTool::new`, `EditTool::new`, `WriteTool::new`); plain - /// callers use `FileTracker::default()`. pub(crate) fn tracker() -> Arc { Arc::new(FileTracker::default()) } - /// Seeds `tracker` with a full Read of `path` from disk, mirroring - /// a real Read turn. pub(crate) fn seed_full_read(tracker: &FileTracker, path: &Path) { let bytes = std::fs::read(path).unwrap(); let meta = std::fs::metadata(path).unwrap(); @@ -294,17 +325,12 @@ pub(crate) mod testing { ); } - /// Fresh tracker pre-seeded with a full Read of `path` — the - /// shape most edit-tool tests want. pub(crate) fn tracker_seeded(path: &Path) -> FileTracker { let tracker = FileTracker::default(); seed_full_read(&tracker, path); tracker } - /// Writes `bytes` to `path`, records the resulting state as a - /// successful Edit / Write, and returns the captured - /// `(mtime, size)` for assertions. pub(crate) fn record_tracked_file( tracker: &FileTracker, path: &Path, diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index 09b8df86..874cb2a4 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -33,14 +33,14 @@ use session::handle::{ResumedSession, SessionHandle, roll as roll_session}; use session::list_view::render_list; use session::resolver::resolve_session; use session::store::SessionStore; -use slash::SessionInfo; +use slash::LiveSessionInfo; use tool::{ ToolRegistry, bash::BashTool, edit::EditTool, glob::GlobTool, grep::GrepTool, read::ReadTool, write::WriteTool, }; use util::path::tildify; -/// Computed before the tokio runtime starts (unsound under multi-threaded). +// Captured before the tokio runtime starts — `now_local()` is unsound under multi-threaded. static LOCAL_OFFSET: std::sync::OnceLock = std::sync::OnceLock::new(); #[derive(Parser)] @@ -59,8 +59,7 @@ struct Cli { #[arg(short, long, value_name = "PROMPT")] prompt: Option, - /// Resume a session. Without a value, resumes the most recent session. - /// With a session ID prefix, resumes that specific session. + /// Resume a session: bare flag picks the most recent, an ID prefix picks a specific one. #[expect( clippy::option_option, reason = "encodes three CLI states: absent (None), flag only (Some(None)), flag with value (Some(Some))" @@ -77,10 +76,7 @@ struct Cli { #[arg(short, long, conflicts_with_all = ["prompt", "continue"])] list: bool, - /// Operate across every project. Widens the scope of `--list` / - /// `--continue` from the current working directory to every - /// project. Must be combined with `--list` or `--continue`; on its - /// own (or with `--prompt`) it would have no effect. + /// Widen `--list` / `--continue` from the current cwd to every project. #[arg(short, long, requires = "scope")] all: bool, } @@ -195,7 +191,7 @@ fn create_tool_registry(tracker: &Arc) -> ToolRegistry { ]) } -/// Waits for SIGINT, SIGTERM, or SIGHUP (Unix). Returns on first arrival. +/// Resolves on the first SIGINT / SIGTERM / SIGHUP. async fn shutdown_signal() { #[cfg(unix)] { @@ -255,7 +251,7 @@ async fn run_tui( .map(tildify) .unwrap_or_default(); - let session_info = SessionInfo { + let session_info = LiveSessionInfo { cwd, version: env!("CARGO_PKG_VERSION"), session_id: session.session_id().to_owned(), @@ -319,8 +315,7 @@ async fn run_tui( result } -/// Each `TurnAbort` arm emits exactly one terminal event (`Error` and -/// `TurnComplete` are mutually exclusive). +/// Each `TurnAbort` arm emits exactly one terminal event (`Error` xor `TurnComplete`). #[expect( clippy::too_many_arguments, reason = "session lifecycle (store, handle, file tracker) lives here for /clear; bundling into a struct would just rename the dependencies" @@ -399,11 +394,7 @@ async fn agent_loop_task( Ok(()) } -/// Apply a [`UserAction::SwapConfig`] to the live `Client` and emit -/// [`AgentEvent::ConfigChanged`]. Order is load-bearing: the model -/// swap re-clamps existing effort first, then the explicit effort -/// pick (if any) clamps against the new model's caps. `requested_effort` -/// echoes the user's pick so the UI can surface clamping. +/// Order matters: model swap re-clamps effort before the explicit pick is applied. fn apply_swap_config( client: &mut Client, sink: &dyn AgentSink, @@ -501,7 +492,7 @@ async fn bare_repl( let failure = session.finalize(file_tracker.snapshot_all()).await; sink.session_write_error(failure.as_deref()); - // tokio::io::stdin's blocking thread hangs runtime Drop on signal exit. + // tokio::io::stdin's blocking thread hangs runtime Drop on signal; force-exit instead. if shutdown_fired { std::process::exit(0); } diff --git a/crates/oxide-code/src/message.rs b/crates/oxide-code/src/message.rs index cf2ad0eb..54e1b4c1 100644 --- a/crates/oxide-code/src/message.rs +++ b/crates/oxide-code/src/message.rs @@ -1,15 +1,8 @@ -//! Conversation message types. -//! -//! [`Message`] plus the [`ContentBlock`] variants that make up its -//! body. Shared between the Anthropic API's wire-level message array, -//! the JSONL session transcript, and the TUI renderer — a single -//! `Message` representation the whole agent pipeline agrees on. +//! Conversation message types shared by the API client, session transcript, and TUI renderer. use serde::{Deserialize, Serialize}; -fn is_default(v: &T) -> bool { - *v == T::default() -} +// ── Role ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -18,11 +11,11 @@ pub(crate) enum Role { Assistant, } -/// A content block within a message. -/// -/// User messages contain `Text` or `ToolResult` blocks. Assistant messages -/// contain `Text`, `ToolUse`, `ServerToolUse`, `Thinking`, and / or -/// `RedactedThinking` blocks. +// ── ContentBlock ── + +/// One entry in a [`Message::content`] vector. Variants mirror the Anthropic Messages API +/// content-block taxonomy verbatim so the same struct serves the wire format, the JSONL +/// session transcript, and the TUI renderer without lossy conversions. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub(crate) enum ContentBlock { @@ -49,13 +42,18 @@ pub(crate) enum ContentBlock { thinking: String, signature: String, }, - /// Opaque safety-redacted thinking block. Must be preserved verbatim for - /// round-tripping — the API validates its contents. + /// Opaque safety-redacted block; must round-trip verbatim or the API rejects it. RedactedThinking { data: String, }, } +fn is_default(v: &T) -> bool { + *v == T::default() +} + +// ── Message ── + #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct Message { pub(crate) role: Role, @@ -80,14 +78,8 @@ impl Message { // ── Message normalization ── -/// Strips trailing thinking / `redacted_thinking` blocks from the last assistant -/// message. The API rejects assistant messages that end with thinking blocks. -/// -/// If stripping removes all content (thinking-only response), a placeholder text -/// block is inserted to preserve user / assistant alternation. -/// -/// Only the most recent assistant message can have un-stripped trailing thinking -/// — earlier ones were already processed in prior iterations. +/// Drops trailing thinking blocks from the last assistant message (the API rejects them); +/// inserts a placeholder if stripping empties the content. pub(crate) fn strip_trailing_thinking(messages: &mut [Message]) { let Some(msg) = messages.iter_mut().rfind(|m| m.role == Role::Assistant) else { return; @@ -358,7 +350,6 @@ mod tests { }, ]; strip_trailing_thinking(&mut messages); - // Only the last assistant message is stripped. assert_eq!(messages[0].content.len(), 2); assert!(matches!( &messages[0].content[1], diff --git a/crates/oxide-code/src/model.rs b/crates/oxide-code/src/model.rs index ec9e3dca..ad712397 100644 --- a/crates/oxide-code/src/model.rs +++ b/crates/oxide-code/src/model.rs @@ -1,8 +1,4 @@ -//! Ground-truth table of known Claude models. -//! -//! Each row carries marketing name, cutoff, and capability flags gating -//! beta headers and body fields. Matching is substring-based; more-specific -//! entries come first. +//! Ground-truth table of known Claude models. Substring-matched, most-specific entry first. use std::borrow::Cow; @@ -10,31 +6,22 @@ use crate::config::Effort; // ── ModelInfo ── -/// Metadata and capability flags for a single Claude model. +/// One row in the [`MODELS`] catalogue. Pure data — no methods. Looked up by substring against a +/// caller-supplied model id (alias-resolved + `[1m]`-stripped); see [`lookup`]. pub(crate) struct ModelInfo { - /// Substring that identifies this model. The first substring match in - /// [`MODELS`] wins, so entries are ordered most-specific first. + /// First substring match in [`MODELS`] wins; ordering matters. pub(crate) id_substr: &'static str, - /// User-visible product name for the TUI / prompt / session list. pub(crate) marketing: &'static str, - /// Knowledge cutoff date for the `` block. `None` when not known. pub(crate) cutoff: Option<&'static str>, pub(crate) capabilities: Capabilities, } // ── Capabilities ── -/// Per-model feature flags consulted by the API client to gate beta -/// headers and body fields. `interleaved_thinking`, `context_management`, -/// `effort`, `context_1m`, and `structured_outputs` mirror upstream -/// `modelSupports*` predicates; `effort_max` and `effort_xhigh` are -/// client-side allowlists derived from the migration guide and live -/// packet captures. -/// -/// `context_1m` does not currently drive beta sending — that signal is -/// the user-opt-in `[1m]` tag on the model string. The flag is kept for -/// UI paths (a future `/model` picker) that want to hide the 1M variant -/// on models that can't honor it. +/// Per-model gate set consumed by the wire-builder (header + body fields), the slash commands +/// (`/effort` rejects unsupported tiers, `/model` rejects `[1m]` on non-1M models), and the +/// effort picker (renders only the supported ladder). Every field is a Boolean because each one +/// maps 1:1 to a distinct upstream switch — combining them would obscure the source of truth. #[expect( clippy::struct_excessive_bools, reason = "seven independent capability flags — each maps 1:1 to a separate upstream `modelSupports*` predicate or a per-version allowlist; a bitflag or state-machine refactor would add indirection without any expressiveness gain" @@ -43,35 +30,23 @@ pub(crate) struct ModelInfo { pub(crate) struct Capabilities { pub(crate) interleaved_thinking: bool, pub(crate) context_management: bool, - /// Whether the model accepts the `context-1m-2025-08-07` beta. - /// `compute_betas` gates the beta on `has_1m_tag(model) AND - /// context_1m` so a user who tags `claude-haiku-4[1m]` doesn't - /// silently send an unsupported beta and 400. + /// `context-1m-2025-08-07` beta. pub(crate) context_1m: bool, - /// Gates `output_config.effort` at `low` / `medium` / `high`. - /// Upper bound: see [`Self::effort_max`] / [`Self::effort_xhigh`]. + /// `output_config.effort` at low / medium / high. pub(crate) effort: bool, - /// Whether `effort = "max"` is accepted. Opus-only. + /// `effort = "max"`. Opus-only. pub(crate) effort_max: bool, - /// Whether `effort = "xhigh"` is accepted. Opus 4.7 only. + /// `effort = "xhigh"`. Opus 4.7 only. pub(crate) effort_xhigh: bool, - /// Whether the model accepts the `structured-outputs-2025-12-15` - /// beta (JSON-schema-constrained text output). The upstream - /// allowlist is Opus 4.1/4.5/4.6, Sonnet 4.5/4.6, Haiku 4.5; - /// everything else silently falls back to free-form text, which - /// [`Client::complete`][crate::client::anthropic::Client::complete] - /// mirrors by dropping the `output_config` body field together with - /// the beta header rather than 400ing on the gateway. + /// `structured-outputs-2025-12-15` beta. pub(crate) structured_outputs: bool, } // ── MODELS ── -/// Ordered table: most-specific substring first. No inheritance between rows. +/// Most-specific substring first. No inheritance between rows. pub(crate) const MODELS: &[ModelInfo] = &[ - // Upstream predates 4.7; substring-derived flags inherit 4.6 as a - // monotonic projection, and `effort_xhigh` is the one 4.7-only - // addition (rejected as 400 by every other model). + // Inherits 4.6 as a monotonic projection; `effort_xhigh` is the 4.7-only addition. ModelInfo { id_substr: "claude-opus-4-7", marketing: "Claude Opus 4.7", @@ -109,8 +84,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ context_management: true, context_1m: true, effort: true, - // `max` is Opus-only per the migration guide; Sonnet 4.6 - // 400s on it. + // `max` is Opus-only; Sonnet 4.6 400s on it. effort_max: false, effort_xhigh: false, structured_outputs: true, @@ -149,10 +123,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ marketing: "Claude Haiku 4.5", cutoff: Some("February 2025"), capabilities: Capabilities { - // Haiku 4.5 doesn't match the `opus-4 || sonnet-4` - // substring rule that gates `interleaved-thinking`, and 3P - // gateways 400 on it. First-party would accept, but we - // target 3P throughout. + // 3P gateways 400 on `interleaved-thinking` for Haiku 4.5. interleaved_thinking: false, context_management: true, context_1m: false, @@ -176,10 +147,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ structured_outputs: true, }, }, - // Unqualified base (`claude-opus-4`, `claude-opus-4-0`, - // `claude-opus-4-20250514`). Structured outputs arrived with 4.1 - // per upstream's explicit allowlist, so the base row must not - // claim them. + // Unqualified base (`claude-opus-4`, `-4-0`, `-4-20250514`); structured outputs arrived in 4.1. ModelInfo { id_substr: "claude-opus-4", marketing: "Claude Opus 4", @@ -194,9 +162,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ structured_outputs: false, }, }, - // Sonnet 4 base: all Sonnet 4.x carry 1M per upstream's - // `sonnet-4` substring rule, so `context_1m` stays on here even - // though structured outputs don't. + // Sonnet 4 base: all Sonnet 4.x carry 1M per upstream's `sonnet-4` substring rule. ModelInfo { id_substr: "claude-sonnet-4", marketing: "Claude Sonnet 4", @@ -228,10 +194,9 @@ pub(crate) const MODELS: &[ModelInfo] = &[ ]; impl Capabilities { - /// Whether the model accepts `output_config.effort = `. - /// Centralises the `low`/`medium`/`high` → [`Self::effort`], - /// `xhigh` → [`Self::effort_xhigh`], `max` → [`Self::effort_max`] - /// mapping so callers don't re-derive it. + /// Whether the model accepts a given `output_config.effort` value. Splits on tier because the + /// Anthropic API rejects unsupported levels with a 400, not a silent clamp — `xhigh` is 4.7 + /// only, `max` is Opus-only. pub(crate) fn accepts_effort(self, level: Effort) -> bool { match level { Effort::Low | Effort::Medium | Effort::High => self.effort, @@ -240,11 +205,7 @@ impl Capabilities { } } - /// Highest level this model accepts ≤ `pick`. `None` when the - /// model doesn't accept `output_config.effort` at all. Used by - /// [`crate::config::Config::load`] to clamp an out-of-range user - /// pick down to the nearest supported level rather than 400ing - /// the gateway. + /// Highest accepted level ≤ `pick`. `None` when the model doesn't support effort. pub(crate) fn clamp_effort(self, pick: Effort) -> Option { if !self.effort { return None; @@ -260,9 +221,8 @@ impl Capabilities { .find(|&level| level <= pick && self.accepts_effort(level)) } - /// Per-model default when the user hasn't specified one: `Xhigh` - /// on 4.7 (matches claude-code 2.1.119), `High` on other - /// effort-capable models, `None` otherwise. + /// Highest tier the model accepts when the user hasn't picked one — `xhigh` for 4.7, `high` + /// for other effort-capable models, `None` otherwise. pub(crate) fn default_effort(self) -> Option { if self.effort_xhigh { Some(Effort::Xhigh) @@ -273,10 +233,7 @@ impl Capabilities { } } - /// One-shot "user pick or model default" — clamps `pick` if - /// present, falls back to [`Self::default_effort`] otherwise. - /// Single seam for `Config::load` and runtime model swaps so the - /// two paths can't drift. + /// Clamps `pick` when present, otherwise falls back to [`Self::default_effort`]. pub(crate) fn resolve_effort(self, pick: Option) -> Option { match pick { Some(p) => self.clamp_effort(p), @@ -287,16 +244,11 @@ impl Capabilities { // ── ResolvedModelId ── -/// A model id that has passed through the `/model` resolver. The private -/// inner field ensures arbitrary strings cannot flow into -/// [`UserAction::SwapConfig`](crate::agent::event::UserAction::SwapConfig) -/// without validation. +/// A model id that has passed through the `/model` resolver. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ResolvedModelId(String); impl ResolvedModelId { - /// Wraps a resolver-validated id. Call sites outside the resolver - /// (tests, deserialization) should audit that the value is valid. pub(crate) fn new(id: String) -> Self { Self(id) } @@ -313,32 +265,23 @@ impl ResolvedModelId { // ── Lookup ── -/// First-match substring lookup against [`MODELS`]. Returns `None` for -/// model strings that don't contain any known family stem (e.g. a future -/// `claude-opus-5` before the table is bumped); callers decide whether -/// to fall back to empty capabilities or reject the request. +/// First-match substring lookup against [`MODELS`]. pub(crate) fn lookup(model: &str) -> Option<&'static ModelInfo> { MODELS.iter().find(|info| model.contains(info.id_substr)) } -/// Capabilities for `model`, falling back to the all-false -/// [`Capabilities::default`] when the id doesn't match any known row. -/// Single entry point for the "unknown model → conservative defaults" -/// invariant so every call site decays the same way. +/// Capabilities for `model`, falling back to [`Capabilities::default`] for unknown ids. pub(crate) fn capabilities_for(model: &str) -> Capabilities { lookup(model) .map(|info| info.capabilities) .unwrap_or_default() } -/// Marketing display name when `model` matches a known row. pub(crate) fn marketing_name(model: &str) -> Option<&'static str> { lookup(model).map(|info| info.marketing) } -/// Marketing name when known, raw id otherwise. Single seam for the -/// "unknown id → fall back to literal" rule so `Config::load`, -/// `/status`, `/config`, and the `/model` swap all decay the same way. +/// Marketing name when known, raw id otherwise. pub(crate) fn marketing_or_id(model: &str) -> Cow<'_, str> { marketing_name(model).map_or_else(|| Cow::Borrowed(model), Cow::Borrowed) } @@ -351,25 +294,16 @@ mod tests { #[test] fn capability_flags_match_upstream_substring_predicates() { - // Lock every row's substring-derived capability flags to the - // `modelSupports*`-style rules the third-party gateway expects. - // A mis-bump or typo that lets the predicates below drift from - // the `MODELS` table will fail here instead of silently - // 400-ing one model family on a release day. - // - // Allowlist-shaped flags (`effort_max`, `effort_xhigh`, - // `structured_outputs`) don't reduce to a substring rule, so - // they're covered by per-flag enumeration tests below. - // - // Opus 4.7 postdates the predicate set we mirror, so we skip - // it here — there is no substring rule to check against. + // Locks substring-derived flags to upstream's `modelSupports*` predicates. Allowlist + // flags have their own tests. Opus 4.7 postdates the predicate set and is skipped. for info in MODELS { if info.id_substr == "claude-opus-4-7" { continue; } let m = info.id_substr; let is_opus_or_sonnet_4 = m.contains("opus-4") || m.contains("sonnet-4"); - let expect_interleaved_thinking = is_opus_or_sonnet_4; // haiku-4 is not in modelSupportsISP + // haiku-4 is not in modelSupportsISP + let expect_interleaved_thinking = is_opus_or_sonnet_4; let expect_context_management = is_opus_or_sonnet_4 || m.contains("haiku-4"); let expect_context_1m = m.contains("claude-sonnet-4") || m.contains("opus-4-6"); let expect_effort = m.contains("opus-4-6") || m.contains("sonnet-4-6"); @@ -395,12 +329,7 @@ mod tests { #[test] fn opus_4_7_uniquely_supports_xhigh() { - // Upstream predates 4.7 so its predicates wouldn't claim - // `effort` or `1M` on this id_substr. We override to the - // monotonic-bump projection. Pin it so a well-meaning future - // edit that "aligns 4.7 with the predicates" doesn't - // accidentally strip the caps we rely on. `effort_xhigh` is - // the one 4.7-only addition — every other row must reject it. + // Upstream predates 4.7; pin so a future "alignment" edit doesn't strip our caps. let caps = lookup("claude-opus-4-7").unwrap().capabilities; assert!(caps.interleaved_thinking); assert!(caps.context_management); @@ -427,9 +356,7 @@ mod tests { #[test] fn effort_max_is_opus_only() { - // `max` effort is Opus-only per the migration guide. Sonnet - // 4.6 supports base `effort` but 400s on `max`; Haiku doesn't - // support `effort` at all. + // `max` is Opus-only; Sonnet 4.6 supports base `effort` but 400s on `max`. for supported in ["claude-opus-4-7", "claude-opus-4-6"] { assert!( lookup(supported).unwrap().capabilities.effort_max, @@ -453,6 +380,61 @@ mod tests { } } + #[test] + fn structured_outputs_flag_tracks_upstream_allowlist() { + // Per-version allowlist: Opus 4.1/4.5/4.6 (+ our 4.7 bump), Sonnet 4.5/4.6, Haiku 4.5. + for supported in [ + "claude-opus-4-7", + "claude-opus-4-6", + "claude-opus-4-5", + "claude-opus-4-1", + "claude-sonnet-4-6", + "claude-sonnet-4-5", + "claude-haiku-4-5", + ] { + assert!( + lookup(supported).unwrap().capabilities.structured_outputs, + "{supported} should claim structured outputs per upstream", + ); + } + for unsupported in ["claude-opus-4", "claude-sonnet-4", "claude-haiku-4"] { + assert!( + !lookup(unsupported).unwrap().capabilities.structured_outputs, + "{unsupported} fallback row must not claim structured outputs", + ); + } + } + + // ── Capabilities::accepts_effort ── + + #[test] + fn accepts_effort_matches_per_tier_capability_flag() { + let opus_4_7 = lookup("claude-opus-4-7").unwrap().capabilities; + let opus_4_6 = lookup("claude-opus-4-6").unwrap().capabilities; + let sonnet_4_6 = lookup("claude-sonnet-4-6").unwrap().capabilities; + let sonnet_4_5 = lookup("claude-sonnet-4-5").unwrap().capabilities; + + // Opus 4.7 — full ladder. + assert!(opus_4_7.accepts_effort(Effort::Low)); + assert!(opus_4_7.accepts_effort(Effort::High)); + assert!(opus_4_7.accepts_effort(Effort::Xhigh)); + assert!(opus_4_7.accepts_effort(Effort::Max)); + + // Opus 4.6 — base + max but no xhigh. + assert!(opus_4_6.accepts_effort(Effort::High)); + assert!(opus_4_6.accepts_effort(Effort::Max)); + assert!(!opus_4_6.accepts_effort(Effort::Xhigh)); + + // Sonnet 4.6 — base only, no max / xhigh. + assert!(sonnet_4_6.accepts_effort(Effort::High)); + assert!(!sonnet_4_6.accepts_effort(Effort::Max)); + assert!(!sonnet_4_6.accepts_effort(Effort::Xhigh)); + + // Sonnet 4.5 — no effort at all. + assert!(!sonnet_4_5.accepts_effort(Effort::Low)); + assert!(!sonnet_4_5.accepts_effort(Effort::Max)); + } + // ── Capabilities::clamp_effort ── #[test] @@ -462,14 +444,13 @@ mod tests { assert_eq!(opus_4_7.clamp_effort(Effort::Xhigh), Some(Effort::Xhigh)); assert_eq!(opus_4_7.clamp_effort(Effort::Low), Some(Effort::Low)); - // Opus 4.6: Max ✓, Xhigh ✗. `xhigh` clamps down to `high` - // (never sideways-up to `max`). + // Opus 4.6: Max ✓, Xhigh ✗ — `xhigh` clamps down to `high`, never up to `max`. let opus_4_6 = lookup("claude-opus-4-6").unwrap().capabilities; assert_eq!(opus_4_6.clamp_effort(Effort::Max), Some(Effort::Max)); assert_eq!(opus_4_6.clamp_effort(Effort::Xhigh), Some(Effort::High)); assert_eq!(opus_4_6.clamp_effort(Effort::High), Some(Effort::High)); - // Sonnet 4.6: Max ✗, Xhigh ✗. Both clamp to `high`. + // Sonnet 4.6: Max ✗, Xhigh ✗ — both clamp to `high`. let sonnet_4_6 = lookup("claude-sonnet-4-6").unwrap().capabilities; assert_eq!(sonnet_4_6.clamp_effort(Effort::Max), Some(Effort::High)); assert_eq!(sonnet_4_6.clamp_effort(Effort::Xhigh), Some(Effort::High)); @@ -484,31 +465,23 @@ mod tests { assert_eq!(haiku_4_5.clamp_effort(Effort::Low), None); } + // ── Capabilities::default_effort ── + #[test] - fn structured_outputs_flag_tracks_upstream_allowlist() { - // Upstream `modelSupportsStructuredOutputs` is a per-version - // allowlist: Opus 4.1/4.5/4.6 (+ our 4.7 monotonic bump), - // Sonnet 4.5/4.6, Haiku 4.5. Everything else is out. - for supported in [ - "claude-opus-4-7", - "claude-opus-4-6", - "claude-opus-4-5", - "claude-opus-4-1", - "claude-sonnet-4-6", - "claude-sonnet-4-5", - "claude-haiku-4-5", - ] { - assert!( - lookup(supported).unwrap().capabilities.structured_outputs, - "{supported} should claim structured outputs per upstream", - ); - } - for unsupported in ["claude-opus-4", "claude-sonnet-4", "claude-haiku-4"] { - assert!( - !lookup(unsupported).unwrap().capabilities.structured_outputs, - "{unsupported} fallback row must not claim structured outputs", - ); + fn default_effort_picks_highest_supported_tier_when_user_has_no_pick() { + // Opus 4.7: full ladder → xhigh. + let opus_4_7 = lookup("claude-opus-4-7").unwrap().capabilities; + assert_eq!(opus_4_7.default_effort(), Some(Effort::Xhigh)); + + // Opus 4.6 / Sonnet 4.6: effort but no xhigh → high. + for id in ["claude-opus-4-6", "claude-sonnet-4-6"] { + let caps = lookup(id).unwrap().capabilities; + assert_eq!(caps.default_effort(), Some(Effort::High), "{id}"); } + + // No effort tier at all → None. + let haiku_4_5 = lookup("claude-haiku-4-5").unwrap().capabilities; + assert_eq!(haiku_4_5.default_effort(), None); } // ── Capabilities::resolve_effort ── @@ -524,7 +497,7 @@ mod tests { #[test] fn resolve_effort_clamps_pick_against_model_ceiling() { - // Sonnet 4.6 caps at `high`; an `xhigh` pick must clamp down. + // Sonnet 4.6 caps at `high`. let sonnet_4_6 = lookup("claude-sonnet-4-6").unwrap().capabilities; assert_eq!( sonnet_4_6.resolve_effort(Some(Effort::Xhigh)), @@ -534,7 +507,6 @@ mod tests { #[test] fn resolve_effort_falls_back_to_model_default_when_pick_is_none() { - // None ≡ "use the model default" — Opus 4.7 → xhigh, others → high. let opus_4_7 = lookup("claude-opus-4-7").unwrap().capabilities; assert_eq!(opus_4_7.resolve_effort(None), Some(Effort::Xhigh)); let sonnet_4_6 = lookup("claude-sonnet-4-6").unwrap().capabilities; @@ -543,9 +515,7 @@ mod tests { #[test] fn resolve_effort_is_none_on_no_tier_model() { - // Haiku 4.5 doesn't accept the effort field — both pick=None - // and pick=Some collapse to None so the request omits the - // field rather than 400-ing the gateway. + // Haiku 4.5 rejects the effort field; both pick=None and pick=Some collapse to None. let haiku_4_5 = lookup("claude-haiku-4-5").unwrap().capabilities; assert_eq!(haiku_4_5.resolve_effort(None), None); assert_eq!(haiku_4_5.resolve_effort(Some(Effort::High)), None); @@ -563,29 +533,24 @@ mod tests { #[test] fn lookup_matches_most_specific_row_before_family_base() { - // `claude-opus-4-6` must hit the 4.6 row, not fall through to - // the `claude-opus-4` base. let info = lookup("claude-opus-4-6").unwrap(); assert_eq!(info.marketing, "Claude Opus 4.6"); assert!(info.capabilities.effort); } - #[test] - fn lookup_unknown_model_family_is_absent() { - // A hypothetical future family with no entry should miss entirely - // so callers can opt into conservative defaults. - assert!(lookup("claude-opus-5-0").is_none()); - assert!(lookup("gpt-4").is_none()); - } - #[test] fn lookup_ignores_1m_suffix_tag_for_matching() { - // `[1m]` is a client-side opt-in marker; the substring match - // still finds the base model row. + // `[1m]` is a client-side opt-in marker; substring match still finds the base row. let info = lookup("claude-opus-4-6[1m]").unwrap(); assert_eq!(info.marketing, "Claude Opus 4.6"); } + #[test] + fn lookup_unknown_model_family_is_absent() { + assert!(lookup("claude-opus-5-0").is_none()); + assert!(lookup("gpt-4").is_none()); + } + // ── marketing_name ── #[test] @@ -608,12 +573,6 @@ mod tests { assert_eq!(marketing_name("claude-haiku-4"), Some("Claude Haiku 4")); } - #[test] - fn marketing_name_unknown_model_is_absent() { - assert_eq!(marketing_name("gpt-4o"), None); - assert_eq!(marketing_name("custom-model"), None); - } - #[test] fn marketing_name_with_dated_suffix_falls_through_to_family_row() { assert_eq!( @@ -622,6 +581,12 @@ mod tests { ); } + #[test] + fn marketing_name_unknown_model_is_absent() { + assert_eq!(marketing_name("gpt-4o"), None); + assert_eq!(marketing_name("custom-model"), None); + } + // ── marketing_or_id ── #[test] @@ -631,10 +596,7 @@ mod tests { #[test] fn marketing_or_id_falls_back_to_raw_id_for_unknown() { - // Single seam for the unknown-id fallback — every UI surface - // (status bar, /status, /config, swap confirmation) goes - // through this. A regression that returned a placeholder or - // panicked here would show up everywhere at once. + // Single seam for unknown-id fallback — every UI surface goes through this. assert_eq!(marketing_or_id("gpt-4"), "gpt-4"); } } diff --git a/crates/oxide-code/src/prompt.rs b/crates/oxide-code/src/prompt.rs index f89bd5af..ef908ab8 100644 --- a/crates/oxide-code/src/prompt.rs +++ b/crates/oxide-code/src/prompt.rs @@ -1,10 +1,4 @@ -//! System prompt assembly. -//! -//! Builds [`PromptParts`]: static sections (identity, guidance, tool -//! use) that live in the API `system` parameter and cache globally, -//! plus a ``-wrapped user context (CLAUDE.md, date) -//! prepended to the messages array so per-session content doesn't -//! invalidate the static cache. +//! System prompt assembly: cacheable static sections plus a per-session user-context reminder. pub(crate) mod environment; mod instructions; @@ -20,36 +14,27 @@ use sections::{ CAUTION, INTRO, OUTPUT_EFFICIENCY, STYLE, SYSTEM_SECTION, TASK_GUIDANCE, TOOL_GUIDANCE, }; -/// Marker between static (globally cacheable) and dynamic (per-session) -/// system prompt sections. Used by the API client to apply `cache_control` -/// scopes to the static portion. +/// Marker between cacheable static and per-session dynamic sections. pub(crate) const SYSTEM_PROMPT_DYNAMIC_BOUNDARY: &str = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"; -/// Assembled prompt split into two API surfaces. +/// Output of [`build_prompt`]. /// -/// `system_sections` contains the static system prompt sections — one -/// per API text block, so `cache_control` can apply to the static -/// portion without re-caching on every turn. `user_context` contains -/// dynamic content (CLAUDE.md, date) that is prepended to the -/// `messages` array as a ``-wrapped user message, so -/// per-session content doesn't invalidate the static cache. +/// `system_sections` is ordered: cacheable static sections, then [`SYSTEM_PROMPT_DYNAMIC_BOUNDARY`] +/// as its own element, then per-session dynamic sections. The client splits on the marker to apply +/// `cache_control` to the static prefix only. `user_context` is the per-session CLAUDE.md / +/// AGENTS.md reminder, attached as the first user message rather than to the system prompt. pub(crate) struct PromptParts { pub(crate) system_sections: Vec, pub(crate) user_context: Option, } impl PromptParts { - /// Joins all system sections into a single string for testing / display. #[cfg(test)] fn system_joined(&self) -> String { self.system_sections.join("\n\n") } } -/// Builds the prompt parts for the agent. -/// -/// Resolves the working directory and git root automatically, then delegates -/// to [`assemble`]. pub(crate) async fn build_prompt(model: &str) -> PromptParts { let cwd = std::env::current_dir().ok(); let git_root = match &cwd { @@ -60,19 +45,13 @@ pub(crate) async fn build_prompt(model: &str) -> PromptParts { assemble(model, cwd.as_deref(), git_root.as_deref()).await } -/// Assembles the prompt from explicit path parameters. -/// -/// The identity prefix required for OAuth is handled by the API client as a -/// separate system block. This function builds the remaining prompt content: -/// identity body and static guidance sections go into `system`; CLAUDE.md -/// and date go into `user_context` as a `` block. async fn assemble(model: &str, cwd: Option<&Path>, git_root: Option<&Path>) -> PromptParts { let env = Environment::detect(model, cwd, git_root); let claude_md = instructions::load(cwd, git_root).await; let env_section = env.render(); let system_sections: Vec = [ - // Static content (globally cacheable) + // Cacheable static above the boundary; per-session dynamic below. INTRO, SYSTEM_SECTION, TASK_GUIDANCE, @@ -80,9 +59,7 @@ async fn assemble(model: &str, cwd: Option<&Path>, git_root: Option<&Path>) -> P TOOL_GUIDANCE, STYLE, OUTPUT_EFFICIENCY, - // Cache boundary SYSTEM_PROMPT_DYNAMIC_BOUNDARY, - // Dynamic content (per-session) &env_section, ] .into_iter() @@ -97,11 +74,6 @@ async fn assemble(model: &str, cwd: Option<&Path>, git_root: Option<&Path>) -> P } } -/// Builds the `` user message content from dynamic context. -/// -/// CLAUDE.md and date ride in a synthetic user message rather than the -/// `system` parameter so per-session content doesn't invalidate the -/// static-section prompt cache. fn build_user_context(claude_md: &str, date: &str) -> Option { if claude_md.is_empty() { return None; @@ -125,10 +97,6 @@ fn build_user_context(claude_md: &str, date: &str) -> Option { }) } -/// Finds the git repository root from a working directory. -/// -/// Returns `None` when not inside a git repository or when `git` is not -/// available. async fn find_git_root(cwd: &Path) -> Option { let output = Command::new("git") .args(["rev-parse", "--show-toplevel"]) @@ -186,10 +154,9 @@ mod tests { assert!(parts.system_joined().contains("test-model")); } - /// This test runs inside the oxide-code repo which has CLAUDE.md, so the - /// `user_context` branch should be exercised. #[tokio::test] async fn build_prompt_includes_user_context_with_claude_md() { + // Runs inside the oxide-code repo so CLAUDE.md exists and `user_context` is populated. let parts = build_prompt("test-model").await; let ctx = parts .user_context @@ -213,8 +180,6 @@ mod tests { #[tokio::test] async fn build_prompt_sections_joined_with_double_newline() { let parts = build_prompt("test-model").await; - // Each section boundary is a double newline. Verify the intro - // section is separated from the next by exactly "\n\n". let joined = parts.system_joined(); let system_start = joined.find("# System").expect("system section missing"); let before = &joined[..system_start]; @@ -264,8 +229,7 @@ mod tests { assert!(joined.contains("Is a git repository: true")); assert!(joined.contains("test-model")); - // Boundary marker must be a distinct element in system_sections - // so the API client can split static from dynamic content. + // Boundary marker must be a distinct element so the API client can split it out. assert!( parts .system_sections @@ -274,7 +238,6 @@ mod tests { "system_sections should contain the boundary marker" ); - // CLAUDE.md goes into user_context as let ctx = parts .user_context .as_deref() @@ -283,9 +246,6 @@ mod tests { assert!(ctx.contains("")); } - /// Without project-level CLAUDE.md, `user_context` depends only on the - /// global `~/.claude/CLAUDE.md`. The system prompt must never contain - /// user instructions regardless. #[tokio::test] async fn assemble_without_project_claude_md_keeps_system_clean() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); @@ -366,10 +326,9 @@ mod tests { // ── constant content ── - /// Verify the prompt constants use `indoc!` correctly — no leading - /// whitespace from source indentation should appear in the output. #[test] fn prompt_constants_have_no_leading_whitespace() { + // Catch `indoc!` regressions that would leak source indentation. for (name, content) in [ ("INTRO", INTRO), ("SYSTEM_SECTION", SYSTEM_SECTION), diff --git a/crates/oxide-code/src/prompt/environment.rs b/crates/oxide-code/src/prompt/environment.rs index deb7dfc6..4bf21a6d 100644 --- a/crates/oxide-code/src/prompt/environment.rs +++ b/crates/oxide-code/src/prompt/environment.rs @@ -8,13 +8,8 @@ use platform_info::{PlatformInfo, PlatformInfoAPI, UNameAPI}; use crate::model::marketing_name; use crate::util::env; -/// Fallback used in the `# Environment` prompt section when a value -/// (cwd, shell, OS version) couldn't be detected. const UNKNOWN_MARKER: &str = "(unknown)"; -/// Detected runtime environment for the system prompt. -/// -/// Each field maps to one or more bullets in the `# Environment` section. pub(super) struct Environment { cwd: String, is_git: bool, @@ -26,10 +21,7 @@ pub(super) struct Environment { } impl Environment { - /// Detects the current runtime environment. - /// - /// All detection is best-effort: failures produce fallback values rather - /// than errors, so the system prompt is always constructible. + /// Best-effort detection; failures produce fallback values. pub(super) fn detect(model: &str, cwd: Option<&Path>, git_root: Option<&Path>) -> Self { let cwd_str = cwd.map_or_else( || UNKNOWN_MARKER.to_owned(), @@ -52,16 +44,10 @@ impl Environment { } } - /// The formatted date string (e.g., `"Today's date is 2026-04-12."`). pub(super) fn date(&self) -> String { format!("Today's date is {}.", self.date) } - /// Render the environment section for the system prompt. - /// - /// Standard markdown formatting: - /// - Top-level items: `- item` - /// - Sub-items: ` - item` (2-space indent) pub(super) fn render(&self) -> String { let model_bullet = match marketing_name(&self.model) { Some(name) => format!( @@ -98,18 +84,13 @@ impl Environment { // ── Model Metadata ── -/// Maps a model ID to its knowledge cutoff date. fn knowledge_cutoff(model: &str) -> Option<&'static str> { crate::model::lookup(model).and_then(|info| info.cutoff) } // ── Platform Detection ── -/// Maps Rust's OS name to Node's `process.platform` values. -/// -/// Rust's `std::env::consts::OS` returns `"macos"` on macOS, but Claude -/// Code (via Node) uses `"darwin"`. This mapping ensures the environment -/// section matches the expected format. +/// Rust's `std::env::consts::OS` → Node's `process.platform` values. fn normalize_node_platform(os: &str) -> &'static str { match os { "macos" => "darwin", @@ -121,7 +102,6 @@ fn normalize_node_platform(os: &str) -> &'static str { // ── Shell Detection ── -/// Extracts the shell basename from `$SHELL` (e.g. `"zsh"`, `"bash"`). fn detect_shell() -> String { let shell = env::string("SHELL").unwrap_or_else(|| "unknown".to_owned()); std::path::Path::new(&shell) @@ -133,10 +113,6 @@ fn detect_shell() -> String { // ── OS Version Detection ── -/// Detects the OS version via `platform_info::PlatformInfo`. -/// -/// Returns a string like `"Darwin 25.3.0"` on macOS or `"Linux 6.1.0"` on -/// Linux. Falls back to the OS name when detection fails. fn detect_os_version() -> String { let Ok(info) = PlatformInfo::new() else { return std::env::consts::OS.to_owned(); @@ -153,8 +129,7 @@ fn detect_os_version() -> String { // ── Date Detection ── fn current_date() -> String { - // now_local() fails on multi-threaded Linux (time crate safety constraint), - // so this effectively falls back to UTC there. + // now_local() fails on multi-threaded Linux (time crate safety), falling back to UTC there. let date = time::OffsetDateTime::now_local() .unwrap_or_else(|_| time::OffsetDateTime::now_utc()) .date(); diff --git a/crates/oxide-code/src/prompt/instructions.rs b/crates/oxide-code/src/prompt/instructions.rs index 7c383590..a40cd9c8 100644 --- a/crates/oxide-code/src/prompt/instructions.rs +++ b/crates/oxide-code/src/prompt/instructions.rs @@ -5,51 +5,25 @@ use std::path::{Path, PathBuf}; use indoc::indoc; use tokio::fs; -/// Instruction filenames to check at each project location, in priority order. -/// At each location, the first file found is used. +/// First file found per location wins. const INSTRUCTION_FILENAMES: &[&str] = &["CLAUDE.md", "AGENTS.md"]; -/// Tool-agnostic instruction subdirectories. Each slot pair walks both the -/// directory itself (e.g. `/CLAUDE.md`) and one-level-down under each -/// of these names (e.g. `/.claude/CLAUDE.md`). Listed here once so a -/// future "configurable instruction dirs" feature has a single knob to turn. +/// Tool-agnostic subdirectories walked alongside each location (e.g. `/.claude/CLAUDE.md`). const INSTRUCTION_DIRS: &[&str] = &[".claude"]; -/// A group of candidate paths to try at a single discovery location. -/// -/// Candidates are tried in order; the first file found wins for this slot. +/// Candidates tried in order at a single location; the first hit wins. struct Slot { candidates: Vec, label: &'static str, } -/// A discovered instruction file with its content and a human-readable label. struct MemoryFile { path: PathBuf, content: String, label: &'static str, } -/// Discovers and loads instruction files, returning the formatted section for the -/// system prompt. -/// -/// At each directory level, filenames are checked in -/// [`INSTRUCTION_FILENAMES`] order — the first file found wins. Discovery -/// walks from the project root down to the working directory so that -/// subdirectory-specific instructions appear later (higher priority). -/// -/// Discovery locations: -/// -/// 1. User global: `~/.claude/CLAUDE.md` or `~/.claude/AGENTS.md`. -/// 2. Each directory from project root to CWD (inclusive): -/// - `/CLAUDE.md` or `/AGENTS.md` -/// - `/.claude/CLAUDE.md` or `/.claude/AGENTS.md` -/// -/// The project root is the git repository root when available, otherwise the -/// current working directory. The global file is always checked regardless of -/// whether a project root exists. -/// -/// Returns an empty string when no files are found. +/// Walks `~/.claude/`, then root → cwd. Subdirectory files appear later (higher priority). pub(super) async fn load(cwd: Option<&Path>, git_root: Option<&Path>) -> String { let project_root = git_root.or(cwd); let slots = candidate_slots(cwd, project_root); @@ -62,12 +36,7 @@ pub(super) async fn load(cwd: Option<&Path>, git_root: Option<&Path>) -> String render(&files) } -/// Builds candidate slots — groups of paths to try at each location. -/// -/// Each slot lists [`INSTRUCTION_FILENAMES`] in priority order. The global -/// slot is always included when a home directory exists. Project slots walk -/// from the root to the working directory, generating two slots per directory -/// level (root-level and `.claude/`). +/// Emits a global slot (when home exists) plus one root-level + one `.claude/` slot per directory. fn candidate_slots(cwd: Option<&Path>, project_root: Option<&Path>) -> Vec { let mut slots = Vec::new(); @@ -104,10 +73,7 @@ fn candidate_slots(cwd: Option<&Path>, project_root: Option<&Path>) -> Vec slots } -/// Returns every directory from `root` down to `cwd` (inclusive). -/// -/// If `cwd` is not a subdirectory of `root`, or `cwd` is `None`, returns -/// just `[root]`. +/// Every directory from `root` down to `cwd` inclusive; `[root]` when `cwd` is outside or `None`. fn walk_root_to_cwd(root: &Path, cwd: Option<&Path>) -> Vec { let Some(cwd) = cwd else { return vec![root.to_path_buf()]; @@ -117,9 +83,8 @@ fn walk_root_to_cwd(root: &Path, cwd: Option<&Path>) -> Vec { return vec![root.to_path_buf()]; }; - // When cwd == root, strip_prefix returns an empty path whose - // components() iterator yields nothing, so the loop is skipped and - // we correctly return just [root]. + // cwd == root yields an empty relative path, so the loop below is skipped and only [root] is + // returned. let mut dirs = vec![root.to_path_buf()]; let mut current = root.to_path_buf(); for component in relative.components() { @@ -130,7 +95,6 @@ fn walk_root_to_cwd(root: &Path, cwd: Option<&Path>) -> Vec { dirs } -/// Try each slot's candidates in order, loading the first file found per slot. async fn load_files(slots: Vec) -> Vec { let mut files = Vec::new(); @@ -153,7 +117,6 @@ async fn load_files(slots: Vec) -> Vec { files } -/// Render memory files into a system prompt section. fn render(files: &[MemoryFile]) -> String { use std::fmt::Write; diff --git a/crates/oxide-code/src/session/actor.rs b/crates/oxide-code/src/session/actor.rs index b2b4e7e7..5a52f3bf 100644 --- a/crates/oxide-code/src/session/actor.rs +++ b/crates/oxide-code/src/session/actor.rs @@ -1,14 +1,6 @@ -//! Session actor — owns [`SessionState`] + the writer, drains -//! [`SessionCmd`]s, and writes one batch per `recv()` wakeup. -//! -//! Receive-and-drain (`recv().await` then `try_recv()` until empty) -//! coalesces whatever cmds land in the channel before the actor -//! processes the first one. `agent_turn` deliberately queues a tool -//! round's three cmds through one `tokio::join!` so they pile up -//! before this drain runs; isolated writes (a text-only turn, the -//! AI title append, the final summary) flush immediately because -//! the drain returns `Empty` after the first cmd. No interval timer -//! — see `docs/design/session/persistence.md`. +//! Session actor — owns [`SessionState`] + writer, drains [`SessionCmd`]s, batches one flush +//! per `recv()` wakeup. Receive-and-drain coalesces a turn's queued cmds before the first flush; +//! isolated writes flush immediately. No interval timer — see `docs/design/session/persistence.md`. use std::sync::Arc; @@ -23,19 +15,14 @@ use crate::file_tracker::FileSnapshot; use crate::message::Message; use crate::tool::ToolMetadata; -/// Cross-task protocol for [`super::handle::SessionHandle`]. Each ack -/// fires after the batch flush, so a returned ack implies the entry is -/// in at least the OS write cache. +/// Cross-task protocol for [`super::handle::SessionHandle`]. Acks fire after the batch flush. pub(super) enum SessionCmd { Record { msg: Message, ack: oneshot::Sender, }, - /// One or more tool-result sidecars from a single agent turn. The - /// batch shape lets the agent loop emit all sidecars for a tool - /// round in one cmd → one ack → one flush, instead of N awaits in - /// a row. Items whose `metadata == ToolMetadata::default()` add no - /// display fields and are skipped at absorb time. + /// One or more tool-result sidecars from a single agent turn. Default-metadata items are + /// skipped at absorb time. ToolMetadata { items: Vec<(String, ToolMetadata)>, ack: oneshot::Sender, @@ -45,17 +32,12 @@ pub(super) enum SessionCmd { ack: oneshot::Sender, }, Finish { - /// Snapshots drained from the shared file tracker just before - /// the cmd was sent; written as one `Entry::FileSnapshot` per - /// snapshot followed by the `Entry::Summary` marker. + /// Drained tracker snapshots; written as one `FileSnapshot` entry each plus a `Summary`. snapshots: Vec, ack: oneshot::Sender, }, - /// Drains pending writes, acks, then exits the actor loop. Lets - /// [`super::handle::SessionHandle::shutdown`] return even when - /// orphaned clones (e.g., the detached title-generator task) still - /// hold a sender — a bare clone-drop wait would block on whichever - /// HTTP timeout the orphan is racing. + /// Drains pending writes, acks, then exits the actor loop so shutdown returns without + /// waiting for orphaned clones to drop. Shutdown { ack: oneshot::Sender<()> }, /// Test-only: panics inside the actor task so callers can exercise /// the `JoinError::is_panic()` path in `shutdown`. @@ -63,9 +45,7 @@ pub(super) enum SessionCmd { Panic, } -/// One absorbed cmd whose ack fires once the batch flush returns. Held -/// per-batch so the same flush result reaches every caller in the -/// batch. +/// Pending ack for one absorbed cmd; fires once the batch flush returns. enum PendingAck { Record { ack: oneshot::Sender, @@ -75,6 +55,9 @@ enum PendingAck { Shutdown(oneshot::Sender<()>), } +/// Actor task body. Owns [`SessionState`] (which owns the writer); absorbs each `recv`-and-drain +/// batch into one buffered flush, then fires acks. Exits when the channel closes or a +/// [`SessionCmd::Shutdown`] is absorbed. pub(super) async fn run( mut state: SessionState, mut rx: mpsc::Receiver, @@ -85,8 +68,8 @@ pub(super) async fn run( let mut acks: Vec = Vec::new(); let mut should_exit = false; absorb(first, &mut entries, &mut acks, &mut state, &mut should_exit); - // Drain whatever is already queued — cmds sent after this point - // wait for the next outer `recv().await`. + // try_recv only drains what is already queued; cmds arriving after this loop wait for + // the next `recv().await` (and form a new batch). Coalescing without an interval timer. while let Ok(next) = rx.try_recv() { absorb(next, &mut entries, &mut acks, &mut state, &mut should_exit); } @@ -121,8 +104,7 @@ fn absorb( acks.push(PendingAck::Record { ack, ai_title_seed }); } SessionCmd::ToolMetadata { items, ack } => { - // Default metadata adds no display fields; emitting it would - // bloat the transcript with empty sidecar lines. + // Default metadata adds no display fields; skip to avoid bloating the transcript. let mut wrote_any = false; for (tool_use_id, metadata) in items { if metadata == ToolMetadata::default() { @@ -138,8 +120,7 @@ fn absorb( if wrote_any { acks.push(PendingAck::Outcome(ack)); } else { - // Empty / all-default batch — nothing to flush, no - // batch result to await. + // Empty / all-default batch — nothing to flush. _ = ack.send(Outcome { failure: None }); } } @@ -186,10 +167,7 @@ fn deliver_acks(acks: Vec, failure: Option<&str>, shared: &SharedSta } } -/// At-most-once flush-failure surfacing: the first failure carries -/// through to the caller, subsequent ones go to the warn-log only so a -/// disk-full mid-conversation doesn't drown the user. Independent of -/// the actor-gone surface flag — see [`SharedState`]. +/// At-most-once: the first failure carries through; subsequent ones stay in the warn-log. fn surface_failure(failure: Option<&str>, shared: &SharedState) -> Option { let msg = failure?; if shared.surface_first_flush_failure() { @@ -212,8 +190,7 @@ mod tests { use crate::session::state::SessionState; /// Run the actor against an in-memory state until the receiver - /// closes (caller drops `tx`), then return the final state for - /// assertions on file contents. + /// closes (caller drops `tx`), then return the final state for assertions on file contents. async fn drive(state: SessionState, cmds: Vec) -> Arc { let shared = Arc::new(SharedState::default()); let (tx, rx) = mpsc::channel(cmds.len().max(1)); @@ -310,8 +287,7 @@ mod tests { #[tokio::test] async fn run_tool_metadata_with_default_short_circuits_without_writing() { - // Default metadata has no display fields, so emitting an entry - // would be noise. + // Default metadata has no display fields, so emitting an entry would be noise. let dir = tempdir().unwrap(); let store = test_store(dir.path()); let state = SessionState::fresh(store.clone(), "m"); @@ -367,8 +343,7 @@ mod tests { async fn run_shutdown_exits_loop_even_with_live_sender_clones() { // The point of `SessionCmd::Shutdown` is to break out without // waiting for every clone to drop — otherwise an orphaned - // title-generator's mid-HTTP clone keeps the actor alive - // through the whole HTTP timeout. + // title-generator's mid-HTTP clone keeps the actor alive through the whole HTTP timeout. let dir = tempdir().unwrap(); let store = test_store(dir.path()); let state = SessionState::fresh(store, "m"); diff --git a/crates/oxide-code/src/session/chain.rs b/crates/oxide-code/src/session/chain.rs index afcb5c06..e485bd01 100644 --- a/crates/oxide-code/src/session/chain.rs +++ b/crates/oxide-code/src/session/chain.rs @@ -1,9 +1,5 @@ -//! UUID-DAG message-chain reconstruction. -//! -//! Concurrent resumes append in parallel and form forks in the on-disk -//! DAG. [`ChainBuilder`] reconstructs the newest non-sidechain linear -//! chain: feed it every `Entry::Message` tuple as the file is read, -//! then call [`ChainBuilder::resolve`] for the chain plus its tip. +//! UUID-DAG message chain reconstruction. Forks form when concurrent resumes append in +//! parallel; [`ChainBuilder::resolve`] picks the newest non-sidechain linear chain. use std::collections::{HashMap, HashSet}; @@ -13,10 +9,15 @@ use uuid::Uuid; use crate::message::Message; +/// Accumulator for messages read off disk. Loader calls [`Self::insert`] per `Entry::Message`, +/// then [`Self::resolve`] once to flatten the recorded UUID DAG into a linear chain. +/// +/// The DAG can fork when concurrent resumes (e.g. two `ox -c` processes pointed at the same +/// session) each append their own follow-ups onto the same parent. `resolve` picks one tip +/// deterministically rather than returning every branch. pub(super) struct ChainBuilder { nodes: HashMap, - /// UUIDs claimed as some other message's `parent_uuid`. The DAG - /// leaves are `nodes.keys() - referenced`. + /// UUIDs claimed as some other message's `parent_uuid`. Leaves = keys − referenced. referenced: HashSet, } @@ -34,8 +35,7 @@ impl ChainBuilder { } } - /// Last-append-wins on duplicate UUIDs so a retry / partial-write - /// replay collapses to the most recent representation. + /// Last-append-wins on duplicate UUIDs (retry / partial-write replay). pub(super) fn insert( &mut self, uuid: Uuid, @@ -56,10 +56,13 @@ impl ChainBuilder { ); } - /// Picks the newest leaf and walks back via `parent_uuid`. Cycles - /// and orphan parents act as chain terminators (preserve the - /// prefix collected so far) so corrupted on-disk state cannot hang - /// the walker. + /// Returns `(messages root → tip, tip uuid)`. + /// + /// Tip selection is fork-aware: the leaf (UUID never referenced as a parent) with the + /// newest timestamp wins, with UUID order breaking ties so two callers loading the same + /// file always pick the same branch. Walks back via `parent_uuid`; cycles trip the seen-set + /// and orphan parents terminate the walk, so corrupted on-disk state cannot hang the loader. + /// Returns `(vec![], None)` when no leaf exists (every node referenced — pure cycle). pub(super) fn resolve(mut self) -> (Vec, Option) { let tip = self .nodes diff --git a/crates/oxide-code/src/session/entry.rs b/crates/oxide-code/src/session/entry.rs index a808af2f..a73d0084 100644 --- a/crates/oxide-code/src/session/entry.rs +++ b/crates/oxide-code/src/session/entry.rs @@ -8,8 +8,12 @@ use crate::file_tracker::FileSnapshot; use crate::message::Message; use crate::tool::ToolMetadata; +/// On-disk schema version stamped into every `Header`. Loader rejects files with a higher +/// version so a downgraded binary fails fast instead of silently dropping unknown fields. pub(crate) const CURRENT_VERSION: u32 = 1; +/// One JSONL line — externally tagged on `type` so `serde(other)` lets older readers skip +/// future variants without aborting the load. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub(crate) enum Entry { diff --git a/crates/oxide-code/src/session/handle.rs b/crates/oxide-code/src/session/handle.rs index 52f14a87..4839f79b 100644 --- a/crates/oxide-code/src/session/handle.rs +++ b/crates/oxide-code/src/session/handle.rs @@ -1,9 +1,5 @@ -//! Public-facing session API. -//! -//! [`SessionHandle`] is held by the agent loop, the title generator, -//! and the TUI shutdown path. Every write method sends a -//! [`super::actor::SessionCmd`] and awaits the actor's oneshot ack; -//! callers never hold a lock across `await`. +//! Public session API. Every write method sends a [`super::actor::SessionCmd`] and awaits the +//! actor's ack; callers never hold a lock across `await`. use std::collections::HashMap; use std::path::Path; @@ -47,14 +43,9 @@ pub(crate) struct SessionHandle { /// Sticky-once failure flags shared between actor and handle clones. #[derive(Default)] pub(super) struct SharedState { - /// First batch-flush failure has surfaced through a caller's ack. flush_failure_surfaced: AtomicBool, - /// First actor-gone surface has fired; subsequent send/recv errors - /// stay silent. actor_gone_surfaced: AtomicBool, - /// Most recent flush error message. `actor_gone_failure` reads this - /// so the user sees the underlying I/O cause when the actor died on - /// a real disk error, not just the generic "session actor is gone". + /// Most recent flush error — threaded into actor-gone messages so the user sees the I/O cause. last_flush_failure: std::sync::Mutex>, } @@ -69,10 +60,7 @@ impl SharedState { } } - /// `true` on the first call after [`Self::record_flush_failure`]; - /// sticky `false` afterwards. Surfaces a flush error to the user - /// once and stays silent on every subsequent failure so a - /// disk-full mid-conversation doesn't drown the UI. + /// `true` on the first call after [`Self::record_flush_failure`]; sticky `false` afterwards. pub(super) fn surface_first_flush_failure(&self) -> bool { !self.flush_failure_surfaced.swap(true, Ordering::AcqRel) } @@ -94,9 +82,7 @@ impl SharedState { } } -/// Combines the AI-title seed and the first-failure surface into one -/// ack so callers don't have to make a second channel round-trip after -/// `record_message`. +/// Combined ack from `record_message`: AI-title seed + first-failure surface in one round-trip. pub(crate) struct RecordOutcome { /// `Some` only on the first user-text message of a fresh session. pub(crate) ai_title_seed: Option, @@ -105,6 +91,7 @@ pub(crate) struct RecordOutcome { pub(crate) failure: Option, } +/// Plain ack from non-record cmds; carries the same first-failure surface as [`RecordOutcome`]. pub(crate) struct Outcome { pub(crate) failure: Option, } @@ -114,8 +101,7 @@ impl SessionHandle { &self.session_id } - /// Record a conversation message. Returns after the batch flush - /// containing this cmd completes. + /// Record a conversation message. Returns after the batch flush completes. pub(crate) async fn record_message(&self, msg: Message) -> RecordOutcome { let (ack, rx) = oneshot::channel(); if self @@ -135,11 +121,7 @@ impl SessionHandle { }) } - /// Record sidecar metadata for every tool result from one agent - /// turn in one shot — one cmd, one ack, one flush — realising the - /// plan's "a turn's worth of cmds collapse into one flush" intent - /// for the hot path. Items whose `metadata == ToolMetadata::default()` - /// add no display fields and are skipped at absorb. + /// Record sidecar metadata for a tool round in one cmd. Default-metadata items are skipped. pub(crate) async fn record_tool_metadata_batch( &self, items: Vec<(String, ToolMetadata)>, @@ -149,48 +131,31 @@ impl SessionHandle { .await } - /// Append an AI-generated session title. Tail-scan picks the - /// latest title (max `updated_at`), so this supersedes the - /// first-prompt title on listings and resumes. + /// Append an AI-generated session title (supersedes the first-prompt title). pub(crate) async fn append_ai_title(&self, title: String) -> Outcome { let (ack, rx) = oneshot::channel(); self.dispatch_outcome(SessionCmd::AppendAiTitle { title, ack }, rx) .await } - /// Write the session summary and finalize. Idempotent; no-op on - /// fresh sessions that never recorded anything. - /// - /// `snapshots` is drained from the shared - /// [`FileTracker`][crate::file_tracker::FileTracker] just before - /// the call — one `Entry::FileSnapshot` lands per snapshot, ahead - /// of the `Entry::Summary`. An empty `Vec` finalizes with no - /// tracker entries. + /// 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(); self.dispatch_outcome(SessionCmd::Finish { snapshots, ack }, rx) .await } - /// Finalize and tear down: writes the summary, surfaces a flush - /// error (if any), and shuts the actor down. Returns the failure - /// message so callers can route it as fits their context (warn-log - /// after the TUI has torn down, [`AgentSink::session_write_error`] - /// while the sink is still live). + /// Finalize and tear down: writes the summary, surfaces a flush error (if any), and shuts + /// the actor down. Returns the failure message for the caller to route. pub(crate) async fn finalize(self, snapshots: Vec) -> Option { let outcome = self.finish(snapshots).await; self.shutdown().await; outcome.failure } - /// Sends [`SessionCmd::Shutdown`] so the actor breaks its loop - /// after the current batch flushes, then awaits the join handle. - /// Cmd-driven exit (rather than waiting for every clone's - /// [`mpsc::Sender`] to drop) keeps process-exit fast even when an - /// orphaned clone — most importantly the detached title-generator - /// task — is mid-HTTP and far from dropping its handle. - /// Subsequent calls (on other clones) drain the join slot and - /// return immediately. + /// Sends [`SessionCmd::Shutdown`] so the actor breaks after the current batch, then awaits + /// the join handle. Cmd-driven exit keeps process-exit fast when orphaned clones are mid-HTTP. pub(crate) async fn shutdown(self) { let Self { cmd_tx, actor_join, .. @@ -254,44 +219,32 @@ impl SessionHandle { // ── ResumedSession ── -/// Live handle plus display-only extras the TUI uses to reconstruct -/// what the user saw live. +/// Live handle plus display-only extras the TUI uses to reconstruct what the user saw live. pub(crate) struct ResumedSession { pub(crate) handle: SessionHandle, pub(crate) messages: Vec, pub(crate) title: Option, pub(crate) tool_result_metadata: HashMap, - /// Persisted tracker snapshots. The caller passes these to - /// [`FileTracker::restore_verified`][crate::file_tracker::FileTracker::restore_verified] - /// before the agent loop runs so the gate clears for previously- - /// observed files that still match disk. + /// 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, } // ── Constructors ── -/// Start a fresh session and spawn its actor. The file materializes -/// lazily on the first record cmd. +/// Start a fresh session and spawn its actor. The file materializes lazily on the first record cmd. pub(crate) fn start(store: &SessionStore, model: &str) -> SessionHandle { spawn_actor(SessionState::fresh(store.clone(), model)) } -/// Outcome of [`roll`]: the new session id plus any flush failure -/// from finalizing the old session. Caller decides where to surface -/// the failure (sink for live UI, warn-log post-teardown). +/// New id plus any flush failure from finalizing the old session. pub(crate) struct RollOutcome { pub(crate) new_id: String, pub(crate) finalize_failure: Option, } -/// Rolls `session` to a fresh handle and finalizes the old one. Two -/// orderings matter: snapshot **before** clear (so the snapshots -/// survive into the old JSONL via `finalize`), and replace **before** -/// finalize (so the new session is in place before the old handle is -/// consumed). -/// -/// Caller must update any state derived from the session id (HTTP -/// client header, in-memory message log) — this helper does not. +/// Rolls `session` to a fresh handle and finalizes the old one. Snapshot before clear (so they +/// survive into the old JSONL), replace before finalize (so the new session is already in place). pub(crate) async fn roll( session: &mut SessionHandle, store: &SessionStore, @@ -310,8 +263,7 @@ pub(crate) async fn roll( } } -/// Resume by session ID — loads, sanitizes, opens for append, spawns -/// the actor. +/// Resume by session ID — loads, sanitizes, opens for append, spawns the actor. pub(crate) fn resume(store: &SessionStore, session_id: &str) -> Result { let data = store.load_session_data(session_id)?; let writer = store.open_append(session_id)?; @@ -334,8 +286,7 @@ fn from_resumed_data( writer: super::store::SessionWriter, ) -> Result { sanitize_resumed_messages(&mut data.messages); - // Bail rather than chain the next record onto a `last_message_uuid` - // that sanitize just dropped. + // Bail rather than chain the next record onto a `last_message_uuid` that sanitize just dropped. if data.messages.is_empty() { bail!("session {session_id} has no messages to resume"); } @@ -384,6 +335,37 @@ mod tests { use crate::file_tracker::testing::record_tracked_file; use crate::message::{ContentBlock, Role}; + // ── SharedState ── + + #[test] + fn shared_state_record_flush_failure_recovers_from_poisoned_mutex() { + let shared = Arc::new(SharedState::default()); + let s = Arc::clone(&shared); + _ = std::thread::spawn(move || { + let _guard = s.last_flush_failure.lock().unwrap(); + panic!("deliberate poison"); + }) + .join(); + // Mutex is now poisoned. record_flush_failure must recover. + shared.record_flush_failure("disk full"); + assert_eq!(shared.last_flush_failure(), Some("disk full".to_owned())); + } + + #[test] + fn shared_state_last_flush_failure_recovers_from_poisoned_mutex() { + let shared = Arc::new(SharedState::default()); + // Poison by writing then panicking. + let s = Arc::clone(&shared); + _ = std::thread::spawn(move || { + let mut guard = s.last_flush_failure.lock().unwrap(); + *guard = Some("pre-poison".to_owned()); + panic!("deliberate poison"); + }) + .join(); + // Recovery path must still return the value written pre-panic. + assert_eq!(shared.last_flush_failure(), Some("pre-poison".to_owned()),); + } + // ── record_message ── #[tokio::test] @@ -485,8 +467,7 @@ mod tests { #[tokio::test] async fn record_tool_metadata_batch_writes_all_non_default_in_one_cmd() { - // Default-metadata items are skipped at absorb; non-defaults - // each produce one line. + // Default-metadata items are skipped at absorb; non-defaults each produce one line. let dir = tempfile::tempdir().unwrap(); let store = test_store(dir.path()); let handle = start(&store, "m"); @@ -728,12 +709,52 @@ mod tests { assert!(second.failure.is_none(), "subsequent calls must be silent"); } + #[tokio::test] + async fn finish_persists_one_file_snapshot_per_tracked_file() { + // Three tracked files in → three FileSnapshot lines on disk + // with the right paths. Counting strings would pass even if + // every snapshot pointed at the same file; parse and assert the path set instead. + let dir = tempfile::tempdir().unwrap(); + let files_dir = tempfile::tempdir().unwrap(); + let store = test_store(dir.path()); + let tracker = FileTracker::default(); + let handle = start(&store, "m"); + let sid = handle.session_id().to_owned(); + + handle.record_message(Message::user("trigger")).await; + let expected_paths: std::collections::HashSet = + ["a.rs", "b.rs", "c.rs"] + .into_iter() + .map(|name| { + let path = files_dir.path().join(name); + record_tracked_file(&tracker, &path, name.as_bytes()); + path + }) + .collect(); + + handle.finish(tracker.snapshot_all()).await; + drop(handle); + + let content = std::fs::read_to_string(test_session_file(dir.path(), &sid)).unwrap(); + let snapshot_paths: std::collections::HashSet = content + .lines() + .filter(|l| l.contains(r#""type":"file_snapshot""#)) + .map(|l| { + let v: serde_json::Value = serde_json::from_str(l).unwrap(); + std::path::PathBuf::from(v["path"].as_str().unwrap()) + }) + .collect(); + assert_eq!( + snapshot_paths, expected_paths, + "every tracked file lands as its own FileSnapshot", + ); + } + // ── finalize ── #[tokio::test] async fn finalize_writes_summary_then_succeeds() { - // Pins the success contract: summary lands on disk and the - // returned failure is `None`. + // Pins the success contract: summary lands on disk and the returned failure is `None`. let dir = tempfile::tempdir().unwrap(); let store = test_store(dir.path()); let handle = start(&store, "m"); @@ -763,8 +784,7 @@ mod tests { #[tokio::test] async fn shutdown_after_record_completes_after_actor_exits() { - // shutdown's contract: the recorded message must be on disk by - // the time the await returns. + // shutdown's contract: the recorded message must be on disk by the time the await returns. let dir = tempfile::tempdir().unwrap(); let store = test_store(dir.path()); let handle = start(&store, "m"); @@ -831,6 +851,22 @@ mod tests { handle.shutdown().await; } + #[tokio::test] + async fn shutdown_recovers_from_poisoned_actor_join_mutex() { + let dir = tempfile::tempdir().unwrap(); + let store = test_store(dir.path()); + let handle = start(&store, "m"); + // Poison the actor_join mutex. + let join_ref = Arc::clone(&handle.actor_join); + _ = std::thread::spawn(move || { + let _guard = join_ref.lock().unwrap(); + panic!("deliberate poison"); + }) + .join(); + // shutdown must recover (take the JoinHandle) and not panic. + handle.shutdown().await; + } + // ── start ── #[tokio::test] @@ -920,153 +956,6 @@ mod tests { ); } - // ── file-snapshot persistence ── - - #[tokio::test] - async fn finish_persists_one_file_snapshot_per_tracked_file() { - // Three tracked files in → three FileSnapshot lines on disk - // with the right paths. Counting strings would pass even if - // every snapshot pointed at the same file; parse and assert - // the path set instead. - let dir = tempfile::tempdir().unwrap(); - let files_dir = tempfile::tempdir().unwrap(); - let store = test_store(dir.path()); - let tracker = FileTracker::default(); - let handle = start(&store, "m"); - let sid = handle.session_id().to_owned(); - - handle.record_message(Message::user("trigger")).await; - let expected_paths: std::collections::HashSet = - ["a.rs", "b.rs", "c.rs"] - .into_iter() - .map(|name| { - let path = files_dir.path().join(name); - record_tracked_file(&tracker, &path, name.as_bytes()); - path - }) - .collect(); - - handle.finish(tracker.snapshot_all()).await; - drop(handle); - - let content = std::fs::read_to_string(test_session_file(dir.path(), &sid)).unwrap(); - let snapshot_paths: std::collections::HashSet = content - .lines() - .filter(|l| l.contains(r#""type":"file_snapshot""#)) - .map(|l| { - let v: serde_json::Value = serde_json::from_str(l).unwrap(); - std::path::PathBuf::from(v["path"].as_str().unwrap()) - }) - .collect(); - assert_eq!( - snapshot_paths, expected_paths, - "every tracked file lands as its own FileSnapshot", - ); - } - - #[tokio::test] - async fn resume_restores_unchanged_snapshots() { - let dir = tempfile::tempdir().unwrap(); - let files_dir = tempfile::tempdir().unwrap(); - let store = test_store(dir.path()); - let tracker = FileTracker::default(); - let handle = start(&store, "m"); - let session_id = handle.session_id().to_owned(); - handle.record_message(Message::user("hi")).await; - let path_a = files_dir.path().join("a.rs"); - record_tracked_file(&tracker, &path_a, b"alpha"); - handle.finish(tracker.snapshot_all()).await; - drop(handle); - - let resumed_tracker = FileTracker::default(); - let resumed = resume(&store, &session_id).unwrap(); - resumed_tracker.restore_verified(resumed.file_snapshots); - - let meta = std::fs::metadata(&path_a).unwrap(); - let check = resumed_tracker.check_stat( - &path_a, - meta.modified().unwrap(), - meta.len(), - crate::file_tracker::GatePurpose::Edit, - ); - assert_eq!(check, Ok(crate::file_tracker::StatCheck::Pass)); - } - - #[tokio::test] - async fn resume_drops_drifted_snapshots() { - let dir = tempfile::tempdir().unwrap(); - let files_dir = tempfile::tempdir().unwrap(); - let store = test_store(dir.path()); - let tracker = FileTracker::default(); - let handle = start(&store, "m"); - let session_id = handle.session_id().to_owned(); - handle.record_message(Message::user("hi")).await; - let path_a = files_dir.path().join("a.rs"); - record_tracked_file(&tracker, &path_a, b"alpha"); - handle.finish(tracker.snapshot_all()).await; - drop(handle); - - // Bump mtime / size before resume so the snapshot stat - // mismatches and the entry drops. - std::fs::write(&path_a, b"externally edited bytes").unwrap(); - - let resumed_tracker = FileTracker::default(); - let resumed = resume(&store, &session_id).unwrap(); - resumed_tracker.restore_verified(resumed.file_snapshots); - - let meta = std::fs::metadata(&path_a).unwrap(); - let check = resumed_tracker.check_stat( - &path_a, - meta.modified().unwrap(), - meta.len(), - crate::file_tracker::GatePurpose::Edit, - ); - assert_eq!( - check, - Err(crate::file_tracker::GateError::NeverRead { - path: path_a.clone(), - purpose: crate::file_tracker::GatePurpose::Edit, - }), - "drifted file must hit the must-read-first gate", - ); - } - - #[tokio::test] - async fn resume_drops_snapshots_for_missing_files() { - let dir = tempfile::tempdir().unwrap(); - let files_dir = tempfile::tempdir().unwrap(); - let store = test_store(dir.path()); - let tracker = FileTracker::default(); - let handle = start(&store, "m"); - let session_id = handle.session_id().to_owned(); - handle.record_message(Message::user("hi")).await; - let path_a = files_dir.path().join("a.rs"); - record_tracked_file(&tracker, &path_a, b"alpha"); - handle.finish(tracker.snapshot_all()).await; - drop(handle); - - std::fs::remove_file(&path_a).unwrap(); - - let resumed_tracker = FileTracker::default(); - let resumed = resume(&store, &session_id).unwrap(); - // restore_verified must drop silently; the tracker should - // end up empty for this path rather than panic. - resumed_tracker.restore_verified(resumed.file_snapshots); - let check = resumed_tracker.check_stat( - &path_a, - std::time::UNIX_EPOCH, - 0, - crate::file_tracker::GatePurpose::Edit, - ); - assert_eq!( - check, - Err(crate::file_tracker::GateError::NeverRead { - path: path_a.clone(), - purpose: crate::file_tracker::GatePurpose::Edit, - }), - ); - } - // ── resume ── #[tokio::test] @@ -1117,8 +1006,7 @@ mod tests { #[tokio::test] async fn resume_all_messages_sanitized_errors() { - // Sanitize drops the lone unresolved tool_use, leaving an - // empty chain — resume must bail. + // Sanitize drops the lone unresolved tool_use, leaving an empty chain — resume must bail. let dir = tempfile::tempdir().unwrap(); let store = test_store(dir.path()); let original = start(&store, "m"); @@ -1214,50 +1102,105 @@ mod tests { ); } - // ── SharedState poison recovery ── + #[tokio::test] + async fn resume_restores_unchanged_snapshots() { + let dir = tempfile::tempdir().unwrap(); + let files_dir = tempfile::tempdir().unwrap(); + let store = test_store(dir.path()); + let tracker = FileTracker::default(); + let handle = start(&store, "m"); + let session_id = handle.session_id().to_owned(); + handle.record_message(Message::user("hi")).await; + let path_a = files_dir.path().join("a.rs"); + record_tracked_file(&tracker, &path_a, b"alpha"); + handle.finish(tracker.snapshot_all()).await; + drop(handle); - #[test] - fn shared_state_record_flush_failure_recovers_from_poisoned_mutex() { - let shared = Arc::new(SharedState::default()); - let s = Arc::clone(&shared); - _ = std::thread::spawn(move || { - let _guard = s.last_flush_failure.lock().unwrap(); - panic!("deliberate poison"); - }) - .join(); - // Mutex is now poisoned. record_flush_failure must recover. - shared.record_flush_failure("disk full"); - assert_eq!(shared.last_flush_failure(), Some("disk full".to_owned())); + let resumed_tracker = FileTracker::default(); + let resumed = resume(&store, &session_id).unwrap(); + resumed_tracker.restore_verified(resumed.file_snapshots); + + let meta = std::fs::metadata(&path_a).unwrap(); + let check = resumed_tracker.check_stat( + &path_a, + meta.modified().unwrap(), + meta.len(), + crate::file_tracker::GatePurpose::Edit, + ); + assert_eq!(check, Ok(crate::file_tracker::StatCheck::Pass)); } - #[test] - fn shared_state_last_flush_failure_recovers_from_poisoned_mutex() { - let shared = Arc::new(SharedState::default()); - // Poison by writing then panicking. - let s = Arc::clone(&shared); - _ = std::thread::spawn(move || { - let mut guard = s.last_flush_failure.lock().unwrap(); - *guard = Some("pre-poison".to_owned()); - panic!("deliberate poison"); - }) - .join(); - // Recovery path must still return the value written pre-panic. - assert_eq!(shared.last_flush_failure(), Some("pre-poison".to_owned()),); + #[tokio::test] + async fn resume_drops_drifted_snapshots() { + let dir = tempfile::tempdir().unwrap(); + let files_dir = tempfile::tempdir().unwrap(); + let store = test_store(dir.path()); + let tracker = FileTracker::default(); + let handle = start(&store, "m"); + let session_id = handle.session_id().to_owned(); + handle.record_message(Message::user("hi")).await; + let path_a = files_dir.path().join("a.rs"); + record_tracked_file(&tracker, &path_a, b"alpha"); + handle.finish(tracker.snapshot_all()).await; + drop(handle); + + // Bump mtime / size before resume so the snapshot stat mismatches and the entry drops. + std::fs::write(&path_a, b"externally edited bytes").unwrap(); + + let resumed_tracker = FileTracker::default(); + let resumed = resume(&store, &session_id).unwrap(); + resumed_tracker.restore_verified(resumed.file_snapshots); + + let meta = std::fs::metadata(&path_a).unwrap(); + let check = resumed_tracker.check_stat( + &path_a, + meta.modified().unwrap(), + meta.len(), + crate::file_tracker::GatePurpose::Edit, + ); + assert_eq!( + check, + Err(crate::file_tracker::GateError::NeverRead { + path: path_a.clone(), + purpose: crate::file_tracker::GatePurpose::Edit, + }), + "drifted file must hit the must-read-first gate", + ); } #[tokio::test] - async fn shutdown_recovers_from_poisoned_actor_join_mutex() { + async fn resume_drops_snapshots_for_missing_files() { let dir = tempfile::tempdir().unwrap(); + let files_dir = tempfile::tempdir().unwrap(); let store = test_store(dir.path()); + let tracker = FileTracker::default(); let handle = start(&store, "m"); - // Poison the actor_join mutex. - let join_ref = Arc::clone(&handle.actor_join); - _ = std::thread::spawn(move || { - let _guard = join_ref.lock().unwrap(); - panic!("deliberate poison"); - }) - .join(); - // shutdown must recover (take the JoinHandle) and not panic. - handle.shutdown().await; + let session_id = handle.session_id().to_owned(); + handle.record_message(Message::user("hi")).await; + let path_a = files_dir.path().join("a.rs"); + record_tracked_file(&tracker, &path_a, b"alpha"); + handle.finish(tracker.snapshot_all()).await; + drop(handle); + + std::fs::remove_file(&path_a).unwrap(); + + let resumed_tracker = FileTracker::default(); + let resumed = resume(&store, &session_id).unwrap(); + // restore_verified must drop silently; the tracker should + // end up empty for this path rather than panic. + resumed_tracker.restore_verified(resumed.file_snapshots); + let check = resumed_tracker.check_stat( + &path_a, + std::time::UNIX_EPOCH, + 0, + crate::file_tracker::GatePurpose::Edit, + ); + assert_eq!( + check, + Err(crate::file_tracker::GateError::NeverRead { + path: path_a.clone(), + purpose: crate::file_tracker::GatePurpose::Edit, + }), + ); } } diff --git a/crates/oxide-code/src/session/handle/testing.rs b/crates/oxide-code/src/session/handle/testing.rs index 5d1f262a..f454f187 100644 --- a/crates/oxide-code/src/session/handle/testing.rs +++ b/crates/oxide-code/src/session/handle/testing.rs @@ -1,9 +1,5 @@ -//! Test-only [`SessionHandle`] constructors for sibling modules -//! (`agent`, `session::title_generator`) that need a stand-in handle -//! without poking at private fields. -//! -//! Lives as a child of `handle` so it can read those private fields; -//! every item is `#[cfg(test)] pub(crate)`. +//! Test-only [`SessionHandle`] constructors. Child of `handle` so it can build the private +//! fields directly; every item is `#[cfg(test)] pub(crate)`. use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -13,10 +9,8 @@ use tokio::sync::mpsc; use super::super::actor::SessionCmd; use super::{Outcome, RecordOutcome, SessionHandle, SharedState}; -/// Returns a handle whose actor channel is already closed, so every -/// write call immediately surfaces the actor-gone failure on the -/// first attempt. Useful for asserting the once-per-session -/// failure-surfacing behaviour without spinning up a real actor. +/// Handle whose actor channel is already closed — every write surfaces actor-gone immediately, +/// so failure surfacing can be asserted without a real actor. pub(crate) fn dead(session_id: &str) -> SessionHandle { let (cmd_tx, _) = mpsc::channel(1); SessionHandle { @@ -27,14 +21,9 @@ pub(crate) fn dead(session_id: &str) -> SessionHandle { } } -/// Returns a handle whose stand-in actor acks the first `succeed` -/// non-Shutdown cmds with a healthy outcome, then drops every -/// subsequent cmd without acking — the receiver's rx-await fallback -/// fires. Exercises the cross-task path where the actor task panics -/// or stalls between receiving a cmd and sending its ack. -/// -/// Shutdown is honoured unconditionally so `handle.shutdown().await` -/// returns on the stand-in. +/// Stand-in actor that acks the first `succeed` non-Shutdown cmds, then drops cmds without +/// acking — exercises the rx-await fallback when the actor stalls between receive and ack. +/// Shutdown is always honoured so `handle.shutdown()` still returns. pub(crate) fn acks_then_drops(session_id: &str, succeed: usize) -> SessionHandle { let (cmd_tx, mut cmd_rx) = mpsc::channel::(8); let count = Arc::new(AtomicUsize::new(0)); diff --git a/crates/oxide-code/src/session/history.rs b/crates/oxide-code/src/session/history.rs index f24c0e7a..1338d6ae 100644 --- a/crates/oxide-code/src/session/history.rs +++ b/crates/oxide-code/src/session/history.rs @@ -1,37 +1,12 @@ -//! Display-oriented reshaping of a loaded session transcript. -//! -//! The JSONL layer stores assistant turns as `Message { content: [ToolUse, -//! ToolUse, ...] }` followed by a single user turn with the matching -//! `ToolResult` blocks batched together. Live rendering, by contrast, emits -//! each `ToolCall` immediately followed by its `ToolResult`. Without some -//! rearrangement, a resumed session would scroll through all the calls and -//! then all the results — visually different from how the same conversation -//! rendered while streaming. -//! -//! [`walk_transcript`] produces an [`Interaction`] sequence that matches the -//! live layout: calls paired with their results inline, orphan results -//! preserved at their original position with an explicit -//! [`Interaction::OrphanToolResult`] marker, and adjacent text blocks within -//! the same message merged into a single entry. Any content that is not -//! surfaced (whitespace-only text, `RedactedThinking`) is dropped here so -//! renderers do not need to repeat the filtering. -//! -//! The transform is pure and owns no dependencies beyond [`Message`]. Tool -//! label resolution is left to the caller, which usually needs a -//! [`ToolRegistry`](crate::tool::ToolRegistry) that the session layer should -//! not depend on. +//! 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. +//! [`walk_transcript`] reorders into the live shape and merges adjacent text blocks. use std::collections::HashMap; use crate::message::{ContentBlock, Message, Role}; -/// A logical interaction derived from a session transcript. -/// -/// Emitted in display order: text and thinking appear in the order they -/// occurred inside a message; tool calls are followed immediately by their -/// paired [`ToolResult`][Self::ToolResult] (consumed from wherever it appears -/// in the transcript); unpaired results surface as -/// [`OrphanToolResult`][Self::OrphanToolResult] at their original position. +/// A logical interaction derived from a transcript, in display order. #[derive(Debug)] pub(crate) enum Interaction<'a> { UserText(String), @@ -47,21 +22,16 @@ pub(crate) enum Interaction<'a> { content: &'a str, is_error: bool, }, - /// A tool result whose `tool_use_id` has no matching call in the same - /// transcript — typically a leftover after crash-recovery sanitization - /// trimmed the call. Callers render with a fallback label. + /// Tool result whose call is missing from the transcript — typically a sanitization + /// leftover. Renders with a fallback label. OrphanToolResult { content: &'a str, is_error: bool, }, } -/// Walks a resumed transcript and emits interactions in display order. -/// -/// Text blocks inside a single message are merged into one entry; whitespace -/// only entries are dropped. Tool calls are paired inline with their matching -/// results via the `tool_use_id` index, so the output mirrors the -/// live-streaming layout regardless of how the JSONL batched them. +/// Walks a resumed transcript and emits interactions in display order. Tool calls are paired +/// inline with their `tool_use_id` results; whitespace-only text is dropped. pub(crate) fn walk_transcript(messages: &[Message]) -> Vec> { let mut pairs: HashMap<&str, (&str, bool)> = messages .iter() @@ -99,8 +69,7 @@ pub(crate) fn walk_transcript(messages: &[Message]) -> Vec> { }); } } - // Paired results were already emitted inline; only orphans - // reach this arm (the pending-map guard ensures that). + // Paired results were already emitted inline; only orphans reach here. ContentBlock::ToolResult { tool_use_id, content, @@ -124,8 +93,7 @@ pub(crate) fn walk_transcript(messages: &[Message]) -> Vec> { out } -/// Emits any accumulated `text_buf` as a role-tagged text interaction and -/// reset the buffer. No-op when the buffer is empty. +/// Flushes `text_buf` as a role-tagged text interaction. No-op on empty. fn flush_text(out: &mut Vec>, text_buf: &mut String, role: Role) { if text_buf.is_empty() { return; diff --git a/crates/oxide-code/src/session/list_view.rs b/crates/oxide-code/src/session/list_view.rs index fbc4ade1..7f0090f3 100644 --- a/crates/oxide-code/src/session/list_view.rs +++ b/crates/oxide-code/src/session/list_view.rs @@ -1,9 +1,4 @@ -//! Text rendering for `ox --list` output. -//! -//! Sits between [`SessionStore::list`][super::store::SessionStore::list] / -//! [`list_all`][super::store::SessionStore::list_all] and the terminal so -//! the rendering can be unit-tested — `main.rs` is excluded from coverage, -//! so prior inline rendering had no automated coverage. +//! Text rendering for `ox --list`. Split out of `main.rs` so the table layout is unit-testable. use std::io::Write; use std::path::Path; @@ -17,38 +12,24 @@ use super::store::SessionStore; use crate::util::path::tildify; use crate::util::text::truncate_to_width; -/// `ID(10) + ' ' + LastActive(19) + ' ' + Msgs(6) + ' '` — the fixed -/// prefix every row shares before `--all` inserts its optional Project -/// column and the row finally reaches `Title`. +/// `ID(10) + ' ' + LastActive(19) + ' ' + Msgs(6) + ' '` — fixed prefix before any optional +/// `Project` column and the final `Title`. const FIXED_PREFIX_WIDTH: usize = 10 + 1 + 19 + 1 + 6 + 1; -/// Smallest title-column width we will truncate to. Below this, the -/// output is so narrow that truncation destroys almost all signal, so -/// we skip it and let the terminal wrap instead. +/// Skip title truncation under this width — wrap rather than chop everything to `F...`. const MIN_TITLE_BUDGET: usize = 12; -/// Minimum width for the `Project` column — at least wide enough to -/// fit the header label ("Project" = 7 chars) without truncation-by-padding. +/// Header "Project" is 7 chars; pad to 8 so the label always fits. const PROJECT_COL_MIN: usize = 8; -/// Upper cap on the `Project` column width. A session started from a -/// pathologically deep path should not squeeze the `Title` column into -/// oblivion; the value overflows its padding when a row exceeds the -/// cap (one-off alignment hiccup rather than hiding the title column -/// for the entire listing). +/// Project column cap. Pathologically deep paths overflow the column rather than starve the +/// title column for every row in the listing. const PROJECT_COL_MAX: usize = 40; -/// Title-column fallback when a session has no recorded title yet. const UNTITLED_MARKER: &str = "(untitled)"; -/// Render `--list` output to `out`. -/// -/// `all` selects the store scope: `false` lists only the current -/// project; `true` spans every project the store can see. -/// `local_offset` is applied to the displayed `Last Active` timestamp. -/// `term_width` is the terminal width used to truncate the `Title` -/// column; pass `None` when the output is piped or width is unknown -/// to skip truncation. +/// Render `--list` output to `out`. `all=true` spans every project; `term_width=None` skips +/// title truncation (use when output is piped or width is unknown). pub(crate) fn render_list( out: &mut dyn Write, store: &SessionStore, @@ -64,14 +45,8 @@ pub(crate) fn render_list( render_sessions(out, &sessions, all, local_offset, term_width) } -/// Pure formatter: take an already-loaded `sessions` slice and write a -/// table to `out`. Split from [`render_list`] so tests can exercise the -/// formatting without constructing a real [`SessionStore`]. -/// -/// When `all` is `true`, a `Project` column is inserted so cross-project -/// rows can be disambiguated. In single-project mode the cwd is -/// redundant (it's always `$PWD`), so the column is omitted to keep -/// the output narrow. +/// Pure formatter — split from [`render_list`] so tests can skip building a real store. `all` +/// inserts a `Project` column to disambiguate cross-project rows. fn render_sessions( out: &mut dyn Write, sessions: &[SessionInfo], @@ -96,9 +71,7 @@ fn render_sessions( 0 }; - // FIXED_PREFIX_WIDTH + (Project + 1) when --all. Title starts after - // this many visual columns; anything beyond that must be truncated - // to keep rows single-line. + // Title starts after this many cols; anything beyond truncates to keep rows single-line. let prefix_width = FIXED_PREFIX_WIDTH + if all { project_col_width + 1 } else { 0 }; let title_budget = term_width.and_then(|w| { let budget = w.checked_sub(prefix_width)?; @@ -192,8 +165,7 @@ mod tests { #[test] fn render_list_empty_store_shows_no_sessions_notice() { - // Covers the `render_list → render_sessions` glue; empty store - // keeps the test fixture-free. + // Covers the `render_list → render_sessions` glue. let dir = tempfile::tempdir().unwrap(); let store = super::super::store::test_store(dir.path()); let mut buf = Vec::new(); @@ -266,9 +238,7 @@ mod tests { rows[1], ); - // All rows share the padded Project width, so the Title slot - // should start at the same column across rows and line up - // with the header. + // Title slot must start at the same column across rows, aligned with the header. let header_title_col = header.find("Title").expect("header must contain Title"); let row_title_cols: Vec = rows .iter() @@ -298,11 +268,8 @@ mod tests { let title_pos = row .rfind("(untitled)") .expect("row must render the default title"); - // 10 (ID) + 1 + 19 (Last Active) + 1 + 6 (Msgs) + 1 + 40 (cap) + cwd overflow + 1 - // The cwd length exceeds the cap, so the untruncated cwd plus - // one separator space should land the title past the header - // cap position — the key point is the cwd is rendered in full - // (no data loss) and columns don't collapse. + // cwd exceeds the 40-col cap; row should still render the full cwd (no data loss) + // even though the title slips past the header column. assert!(row.contains(&long_cwd), "cwd missing from row: {row:?}"); assert!(title_pos > 0); } @@ -314,8 +281,7 @@ mod tests { title: "A very long session title that will not fit".to_owned(), updated_at: datetime!(2026-04-18 09:05:00 UTC), }); - // Prefix = 38, term_width = 60 → title budget = 22 (fits ~19 - // chars + `...`). + // Prefix = 38, term_width = 60 → title budget = 22 (fits ~19 chars + `...`). let out = render_with_width(&[s], false, Some(60)); let row = out.lines().nth(1).unwrap(); let title = row @@ -353,9 +319,7 @@ mod tests { title: full_title.to_owned(), updated_at: datetime!(2026-04-18 09:05:00 UTC), }); - // Prefix 38 + MIN_TITLE_BUDGET(12) = 50. term_width = 45 leaves - // a budget below minimum → no truncation (let the terminal - // wrap rather than chopping everything to `F...`). + // term_width 45 < prefix 38 + MIN_TITLE_BUDGET 12 → no truncation; terminal wraps. let out = render_with_width(&[s], false, Some(45)); assert!( out.contains(full_title), diff --git a/crates/oxide-code/src/session/path.rs b/crates/oxide-code/src/session/path.rs index f3c3c3e4..c555af95 100644 --- a/crates/oxide-code/src/session/path.rs +++ b/crates/oxide-code/src/session/path.rs @@ -1,39 +1,23 @@ -//! Filesystem-safe project directory derivation from an absolute path. -//! -//! Sessions live under `$XDG_DATA_HOME/ox/sessions/{sanitized-cwd}/` so -//! listings stay scoped to the project the user is working in. The -//! sanitization here turns an arbitrary path into a single directory -//! name: path separators and other reserved characters become `-`, and -//! very long paths fall back to a truncation + hash fingerprint so -//! distinct paths cannot collide after truncation. +//! Filesystem-safe project subdir derivation. Sessions live under +//! `$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; use xxhash_rust::xxh64::xxh64; -/// Maximum character length of a project subdirectory name before we -/// truncate and append a hash. 80 keeps names readable while staying -/// well below filesystem `NAME_MAX` limits (255 on ext4 / APFS). +/// Char-length cap before truncate + hash. 80 stays well under filesystem `NAME_MAX` (255). const MAX_PROJECT_DIR_LEN: usize = 80; -/// Width of the hash suffix (in hex chars) appended to truncated names. +/// Hex chars in the truncation hash suffix. const HASH_SUFFIX_HEX_LEN: usize = 16; -/// Fallback subdirectory when the current working directory cannot be -/// resolved. Rare in practice — the process would be running from a -/// deleted directory. +/// Fallback when the cwd cannot be resolved (e.g. running from a deleted directory). pub(crate) const UNKNOWN_PROJECT_DIR: &str = "_unknown_"; -/// Derive a filesystem-safe subdirectory name from a working-directory -/// path. Reserved characters (`/`, `\`, `:`, and anything not -/// `[A-Za-z0-9._-]`) become `-`. Leading and trailing `-` characters -/// are trimmed. Names longer than [`MAX_PROJECT_DIR_LEN`] are -/// truncated and suffixed with a 16-char xxh64 hash of the original -/// path bytes so distinct long paths never collide after truncation. -/// -/// Hashing uses `OsStr::as_encoded_bytes` (stable since Rust 1.74) -/// rather than the UTF-8-lossy string representation, so two non-UTF8 -/// paths whose lossy form coincides still hash apart. +/// Derives a filesystem-safe subdir name. Non-`[A-Za-z0-9._-]` chars become `-`; long names get +/// a 16-char xxh64 suffix over the raw path bytes (so non-UTF8 paths whose lossy forms collide +/// still hash apart). pub(crate) fn sanitize_cwd(path: &Path) -> String { let raw = path.to_string_lossy(); let sanitized: String = raw @@ -115,8 +99,7 @@ mod tests { #[test] fn sanitize_cwd_truncation_distinguishes_similar_long_paths() { - // Two paths that share the same prefix beyond the truncation - // point must still yield different subdir names. + // Paths sharing a prefix beyond the truncation point must still hash apart. let base = "/".to_string() + &"a".repeat(200); let a = sanitize_cwd(Path::new(&(base.clone() + "/alpha"))); let b = sanitize_cwd(Path::new(&(base + "/beta"))); @@ -132,10 +115,7 @@ mod tests { #[cfg(unix)] #[test] fn sanitize_cwd_distinguishes_non_utf8_paths_with_same_lossy_form() { - // Two paths that differ only in their invalid-UTF8 bytes - // render the same as String (both produce U+FFFD REPLACEMENT - // CHARACTER for the bad byte). The hash suffix must still - // separate them so sessions do not collide. + // Different invalid bytes both lossy-render as U+FFFD; raw-byte hash must disambiguate. use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; use std::path::PathBuf; diff --git a/crates/oxide-code/src/session/resolver.rs b/crates/oxide-code/src/session/resolver.rs index 9ddca8e5..84bb62ba 100644 --- a/crates/oxide-code/src/session/resolver.rs +++ b/crates/oxide-code/src/session/resolver.rs @@ -1,10 +1,5 @@ -//! CLI `--continue` argument resolution. -//! -//! Translates the `clap`-parsed `Option>` into either a -//! freshly-started [`SessionHandle`] or a resumed one with its loaded -//! messages, via [`resolve_session`]. Split out of `main.rs` so the -//! resolution logic (parsing, prefix matching, ambiguity reporting) -//! can be exercised by unit tests. +//! CLI `--continue` argument resolution. Translates the `clap`-parsed `Option>` +//! into a fresh or resumed [`SessionHandle`] via [`resolve_session`]. use std::path::Path; @@ -15,35 +10,20 @@ use super::handle::{self, ResumedSession}; use super::store::SessionStore; use crate::util::text::ELLIPSIS; -/// Normalized form of the CLI `--continue` argument. Built by -/// [`normalize_resume_arg`] and consumed by [`resolve_session`]. +/// Normalized form of the CLI `--continue` argument. pub(crate) enum ResumeMode<'a> { - /// No `--continue` was passed — start a brand new session. + /// No `--continue` flag — start a new session. Fresh, /// Bare `--continue` — resume the most recent session in scope. Latest, - /// `--continue ` — resume the single session whose ID - /// starts with the (trimmed, non-empty) prefix. + /// `--continue ` — resume the unique session whose ID starts with `prefix`. Prefix(&'a str), - /// `--continue ` — resume a session file by explicit - /// filesystem path, bypassing the XDG project subdirectory lookup. - /// Selected when the argument contains a path separator or ends with - /// `.jsonl`; any UUID-shaped token is still classified as - /// [`Prefix`][Self::Prefix]. + /// `--continue ` — resume by filesystem path, bypassing the XDG lookup. Path(&'a Path), } -/// Creates or resumes a session based on CLI flags. -/// -/// `resume`: -/// - `None`: no `--continue` flag → new session. -/// - `Some(None)`: `--continue` without value → resume latest. -/// - `Some(Some(id))`: `--continue ` → resume specific session. -/// -/// `all` widens the search scope for `--continue` from the current -/// project to every project. A specific session ID is always resolved -/// across projects once matched, so `--all` mainly changes which -/// sessions are eligible in the prefix / latest lookup. +/// Creates or resumes a session per CLI flags. `all` widens prefix / latest lookup from the +/// current project to every project subdir. pub(crate) async fn resolve_session( store: &SessionStore, model: &str, @@ -52,8 +32,7 @@ pub(crate) async fn resolve_session( ) -> Result { let mode = normalize_resume_arg(resume)?; - // Path resumes bypass the store's project-subdir lookup entirely and - // can be resolved without listing anything. + // Path resumes bypass project-subdir listing entirely. if let ResumeMode::Path(path) = mode { let resumed = handle::resume_from_path(store, path)?; debug!("resuming session from {}", path.display()); @@ -116,15 +95,9 @@ pub(crate) async fn resolve_session( Ok(resumed) } -/// Trims and classifies a `--continue` argument into a [`ResumeMode`]. -/// Empty / whitespace-only prefixes are rejected explicitly so they -/// cannot silently collapse into "resume latest" — the bare -/// `--continue` flag already expresses that intent. -/// -/// An argument that looks like a path (contains a path separator or ends -/// with `.jsonl`) is classified as [`ResumeMode::Path`]; otherwise it is -/// a UUID-shaped prefix. UUIDs contain only hex + `-`, neither of which -/// trigger the path heuristic, so prefix resume stays unchanged. +/// Trims and classifies a `--continue` argument into a [`ResumeMode`]. Empty / whitespace +/// prefixes are rejected explicitly to avoid silently collapsing into "resume latest" — bare +/// `--continue` already expresses that. pub(crate) fn normalize_resume_arg(resume: Option<&Option>) -> Result> { match resume { None => Ok(ResumeMode::Fresh), @@ -143,11 +116,8 @@ pub(crate) fn normalize_resume_arg(resume: Option<&Option>) -> Result bool { arg.contains('/') || arg.contains('\\') @@ -156,10 +126,7 @@ fn looks_like_path(arg: &str) -> bool { .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl")) } -/// Joins the first `MATCH_PREVIEW_LIMIT` session IDs (truncated to -/// 8 chars each) as `aaaaaaaa, bbbbbbbb`, appending `, ...` when more -/// matches were provided. Drives the ambiguous-prefix error in -/// [`resolve_session`]. +/// Renders up to `MATCH_PREVIEW_LIMIT` IDs (truncated to 8 chars), with `, ...` for overflow. fn format_session_id_preview(ids: impl IntoIterator) -> String { const MATCH_PREVIEW_LIMIT: usize = 5; let mut iter = ids.into_iter(); @@ -203,9 +170,8 @@ mod tests { #[tokio::test] async fn resolve_session_resumes_from_external_path() { - // A session file living somewhere the store wouldn't search. - // The path-based resume must still pick up the title, messages, - // and session_id recorded in the header. + // Path-based resume must pick up title, messages, and session_id from the header + // even when the file lives outside any store-searched directory. let dir = tempfile::tempdir().unwrap(); let store = test_store(dir.path()); let original = handle::start(&store, "m"); @@ -217,8 +183,7 @@ mod tests { let path = test_session_file(dir.path(), &full_id); drop(original); - // Copy the file outside the project directory to simulate the - // "imported from another machine" scenario. + // Imported-from-another-machine scenario. let external_dir = tempfile::tempdir().unwrap(); let external_path = external_dir.path().join("copied.jsonl"); std::fs::copy(&path, &external_path).unwrap(); @@ -255,11 +220,7 @@ mod tests { #[tokio::test] async fn resolve_session_all_widens_scope_to_list_all() { - // `--all` flips the listing from `store.list()` (current project only) - // to `store.list_all()` (every project subdir). The prefix match - // then resolves across projects and the error hint drops the - // "in this project" qualifier. Exercising the `all = true` branch - // here also covers the empty `scope_hint`. + // `--all` flips listing to `list_all`; the error hint also drops "in this project". let dir = tempfile::tempdir().unwrap(); let store = test_store(dir.path()); let original = handle::start(&store, "m"); @@ -305,10 +266,8 @@ mod tests { async fn resolve_session_prefix_errors_on_no_match() { let dir = tempfile::tempdir().unwrap(); let store = test_store(dir.path()); - // Materialize a session file so the project listing is - // non-empty; without `record_message` the lazy creation - // never touches disk and the prefix lookup would short-circuit - // on "no sessions" instead of testing the no-match path. + // Need a real file on disk so the listing is non-empty; otherwise the prefix lookup + // short-circuits on "no sessions" before exercising the no-match path. let s = handle::start(&store, "m"); s.record_message(Message::user("noop")).await; s.finish(Vec::new()).await; @@ -325,11 +284,7 @@ mod tests { #[tokio::test] async fn resolve_session_prefix_reports_ambiguous_matches() { - // Spin up 20 sessions and record a message in each so any - // of them is resumable. Then pick the hex char that the - // most session IDs start with — with 20 v4 UUIDs across 16 - // characters, this is guaranteed ≥ 2 by pigeonhole, so the - // prefix lookup must bail with "ambiguous". + // 20 v4 UUIDs across 16 hex chars guarantees ≥ 2 collisions by pigeonhole. let dir = tempfile::tempdir().unwrap(); let store = test_store(dir.path()); @@ -418,8 +373,7 @@ mod tests { #[test] fn normalize_resume_arg_keeps_uuid_shaped_prefix_as_prefix() { - // A v4 UUID prefix uses only hex + `-`; neither triggers the path - // heuristic, so bare prefixes still resume through the store. + // UUID prefix has no `/` and no `.jsonl`; must not classify as path. let arg = Some("a1b2c3d4-e5f6-7890".to_owned()); let mode = normalize_resume_arg(Some(&arg)).unwrap(); assert!(matches!(mode, ResumeMode::Prefix("a1b2c3d4-e5f6-7890"))); diff --git a/crates/oxide-code/src/session/sanitize.rs b/crates/oxide-code/src/session/sanitize.rs index db781c85..224a5f4e 100644 --- a/crates/oxide-code/src/session/sanitize.rs +++ b/crates/oxide-code/src/session/sanitize.rs @@ -1,14 +1,5 @@ -//! Resume-time transcript repair. -//! -//! Turns a crash- / partial-write-damaged loaded conversation into a -//! shape the Anthropic API will accept as the prefix of a new turn: -//! drop unresolved `tool_use` and orphan `tool_result` blocks, collapse -//! same-role neighbors emptied by the filters, and patch leading-assistant -//! / trailing-tool-result-only edges with synthetic sentinels. -//! -//! [`sanitize_resumed_messages`] sequences four named passes; the -//! `strip_trailing_thinking` brackets cover both the loaded tail and a -//! sentinel we may have just appended. +//! Resume-time transcript repair. Drops unresolved `tool_use` / orphan `tool_result` blocks, +//! collapses same-role neighbors, and patches head / tail edges the API would reject. use std::collections::HashSet; @@ -16,17 +7,24 @@ use crate::message::{ContentBlock, Message, Role, strip_trailing_thinking}; // ── Constants ── -/// Stub appended after a trailing tool-result-only user turn so the -/// next API call has a valid assistant follow-up. +/// Stub appended after a trailing tool-result-only user turn so the next API call has a +/// valid assistant follow-up. pub(super) const RESUME_CONTINUATION_SENTINEL: &str = "[Previous turn was interrupted; continuing.]"; -/// Stub prepended when sanitization leaves an assistant as the first -/// message. The API rejects transcripts that start with assistant. +/// Stub prepended when sanitization leaves an assistant as the first message — the API rejects +/// transcripts that start with assistant. pub(super) const RESUME_HEAD_SENTINEL: &str = "[Previous session prefix lost in recovery.]"; // ── Entry Point ── +/// Repairs a transcript loaded from JSONL so the next API call won't 400. Pass order matters: +/// drop unresolved `tool_use` first so any `tool_result` paired only with a dropped call is +/// orphaned in the next pass; collapse roles after both filter passes so emptied messages +/// can't desync the alternation; sentinels run last because head / tail shape only stabilizes +/// post-collapse. The trailing thinking strip runs twice — once before, once after — so a +/// thinking block at the end of a now-removed assistant turn doesn't survive into the next API +/// call. pub(super) fn sanitize_resumed_messages(messages: &mut Vec) { strip_trailing_thinking(messages); drop_unresolved_tool_uses(messages); @@ -38,9 +36,8 @@ pub(super) fn sanitize_resumed_messages(messages: &mut Vec) { // ── Passes ── -/// Drops assistant `tool_use` blocks whose id has no matching -/// `tool_result` anywhere in the transcript. Cloned ids: a borrowed -/// `HashSet<&str>` would conflict with the mutable iteration below. +/// Drops assistant `tool_use` blocks whose id has no matching `tool_result`. Ids are owned +/// because the `HashSet` is built from a shared borrow before mutating below. fn drop_unresolved_tool_uses(messages: &mut [Message]) { let resolved: HashSet = messages .iter() @@ -64,9 +61,8 @@ fn drop_unresolved_tool_uses(messages: &mut [Message]) { } } -/// Drops user `tool_result` blocks whose `tool_use_id` does not match -/// any surviving assistant `tool_use`, then strips messages that the -/// preceding two filters emptied so role-collapse sees no stubs. +/// Drops user `tool_result` blocks with no surviving assistant `tool_use`, then strips +/// messages that prior passes emptied so role-collapse sees no stubs. fn drop_orphan_tool_results(messages: &mut Vec) { let surviving: HashSet = messages .iter() @@ -92,8 +88,7 @@ fn drop_orphan_tool_results(messages: &mut Vec) { messages.retain(|m| !m.content.is_empty()); } -/// Merges adjacent same-role runs into one message so role alternation -/// holds after the filter passes drop messages whole. +/// Merges adjacent same-role runs so role alternation holds after the filter passes. fn collapse_consecutive_roles(messages: &mut Vec) { messages.dedup_by(|next, prev| { if prev.role == next.role { @@ -105,9 +100,8 @@ fn collapse_consecutive_roles(messages: &mut Vec) { }); } -/// Patches head / tail edges that the API rejects: prepends a user -/// stub when the chain starts with assistant, appends an assistant -/// stub when the last user turn is tool-results only. +/// Patches head / tail edges the API rejects: prepends a user stub on leading-assistant, +/// appends an assistant stub on trailing tool-results-only user. fn insert_resume_sentinels(messages: &mut Vec) { if messages .first() @@ -358,8 +352,7 @@ mod tests { #[test] fn drop_unresolved_tool_uses_treats_server_tool_use_like_tool_use() { - // ServerToolUse shares the resolved-id contract with ToolUse — - // both arms of the OR pattern in the filter must match here. + // Both `ToolUse` and `ServerToolUse` arms must match — covers the OR in the filter. let mut messages = vec![ Message { role: Role::Assistant, @@ -420,8 +413,7 @@ mod tests { #[test] fn drop_orphan_tool_results_keeps_results_paired_to_server_tool_use() { - // The surviving-id set must include ServerToolUse, otherwise - // its paired tool_result would be dropped as orphan. + // Surviving-id set must include `ServerToolUse` ids, else its result would be orphaned. let mut messages = vec![ Message { role: Role::Assistant, @@ -518,8 +510,7 @@ mod tests { #[test] fn insert_resume_sentinels_skips_append_when_trailing_user_has_text() { - // Mixed text + tool_result is a normal follow-up, not the - // post-crash shape — no sentinel. + // Mixed text + tool_result is a normal follow-up, not the post-crash shape — no sentinel. let mut messages = vec![ Message::user("do X"), Message { diff --git a/crates/oxide-code/src/session/state.rs b/crates/oxide-code/src/session/state.rs index a9f91596..4a7bf842 100644 --- a/crates/oxide-code/src/session/state.rs +++ b/crates/oxide-code/src/session/state.rs @@ -1,8 +1,5 @@ -//! In-memory session state owned by the [`super::actor`] task. -//! -//! Pure data: no tokio, file I/O hidden behind [`WriterStatus`]. Split -//! out so lifecycle transitions (first-prompt detection, uuid chain, -//! finish idempotency) test without a runtime. +//! In-memory session state owned by the actor. Pure data — no tokio; I/O hides behind +//! [`WriterStatus`] so lifecycle transitions test without a runtime. use std::sync::Arc; @@ -17,35 +14,36 @@ use crate::file_tracker::FileSnapshot; use crate::message::{ContentBlock, Message, Role}; use crate::util::text::{ELLIPSIS, ELLIPSIS_WIDTH}; -/// Maximum title length (in characters) derived from the first user prompt. -/// -/// Sized for wide terminals: the `--list` row is `ID(10) Last Active(19) -/// Msgs(6) Title`, so ~80 chars of title space on a 120-col terminal -/// and still truncates cleanly (`...`) on narrower ones. +/// Char cap for first-prompt-derived titles. Sized so a 120-col `--list` row still has room +/// after the fixed `ID / Last Active / Msgs` prefix. const MAX_TITLE_LEN: usize = 80; // ── SessionState ── +/// Pure-data lifecycle owned by [`super::actor::run`]. All I/O happens through +/// [`SessionWriter`] held inside [`WriterStatus`]; the rest is bookkeeping the actor mutates +/// between batches. Never shared across tasks — the actor is the sole owner. pub(super) struct SessionState { - /// Cloned out by `SessionHandle::session_id` so `&str` deref stays - /// off the cmd channel. pub(super) session_id: Arc, - /// Cloned at start so the first append can drive `Pending → Active`. store: SessionStore, writer_status: WriterStatus, - /// `parent_uuid` for the next recorded message. last_message_uuid: Option, - /// Loaded message count for resumed sessions; `0` for fresh. - /// `finish_entries` skips the summary when nothing was added. + /// Loaded count on resume (`0` for fresh). `finish_entries` skips the summary when unchanged. initial_message_count: u32, message_count: u32, - /// Latched the first time a user-text message lands so we don't - /// re-promote a later user message to a duplicate title. + /// Latched on first user-text so we don't emit a duplicate title entry. first_user_prompt_seen: bool, finished: bool, } -/// Writer lifecycle: lazy-create, healthy, or poisoned after a partial write. +/// Writer lifecycle. +/// +/// `Pending` defers `create + header write` until the first record cmd, so a fresh session +/// that exits without recording leaves nothing on disk. `Broken` marks the writer's +/// `BufWriter` as undefined after a partial write — its buffer state is unspecified by the +/// stdlib, so we drop it and `open_append` afresh on the next batch instead of risking a +/// torn line. Recovery from `Broken` is automatic: [`SessionState::take_or_open_writer`] +/// reopens against the existing file and the next flush appends cleanly. enum WriterStatus { Pending { header: Entry }, Active(SessionWriter), @@ -67,8 +65,7 @@ impl SessionState { } } - /// Resumed sessions land directly in `Active` because the loader - /// already had to read the file. + /// Resumed sessions land directly in `Active` because the loader already read the file. pub(super) fn resumed( store: SessionStore, session_id: String, @@ -101,9 +98,7 @@ impl SessionState { if !self.first_user_prompt_seen && let Some(text) = extract_user_text(message) { - // Latch the flag before the title push — if the later flush - // fails we still won't promote the next user message to a - // duplicate title. + // Latch before the title push so a later flush failure won't produce a duplicate. self.first_user_prompt_seen = true; ai_title_seed = Some(text.to_owned()); entries.push(Entry::Title { @@ -136,8 +131,7 @@ impl SessionState { return Vec::new(); } self.finished = true; - // message_count rather than writer status — writer may still be Pending in a - // batched Finish. + // Writer may still be Pending in a batched Finish; key off message_count instead. if self.message_count == 0 { return Vec::new(); } @@ -155,7 +149,7 @@ impl SessionState { entries } - /// Writes entries in one flush; transitions writer on failure for next-batch retry. + /// Writes entries in one flush; transitions to Broken on failure so next batch reopens. pub(super) fn flush_entries(&mut self, entries: &[Entry]) -> Result<()> { if entries.is_empty() { return Ok(()); @@ -167,8 +161,8 @@ impl SessionState { } writer.flush() })(); - // `BufWriter`'s buffer is undefined after a partial write — flag - // Broken so the next batch reopens instead of poisoning the flush. + // BufWriter's buffer is undefined after a partial write — flag Broken so next batch + // reopens. self.writer_status = match result { Ok(()) => WriterStatus::Active(writer), Err(_) => WriterStatus::Broken, @@ -176,9 +170,10 @@ impl SessionState { result } - /// Returns a writer, transitioning from Pending or Broken as needed. fn take_or_open_writer(&mut self) -> Result { - // Temporarily swap with Broken to take ownership. + // Swap-with-Broken takes ownership without an extra clone. On `Pending` failure we + // restore the header so the next batch retries `create` instead of `open_append`-ing + // a file that was never created. match std::mem::replace(&mut self.writer_status, WriterStatus::Broken) { WriterStatus::Active(w) => Ok(w), WriterStatus::Pending { header } => match self.store.create(&header) { @@ -232,13 +227,7 @@ pub(super) fn extract_user_text(message: &Message) -> Option<&str> { }) } -/// Truncates a title to `max_len` characters, appending [`ELLIPSIS`] -/// when truncated. -/// -/// `max_len` must be at least `ELLIPSIS_WIDTH + 1` (room for the marker -/// plus at least one character of the title). Only internal callers -/// drive this with [`MAX_TITLE_LEN`] = 80, so the precondition is a -/// sanity check, not user input handling. +/// Truncates a title to `max_len` characters, appending [`ELLIPSIS`] when truncated. fn truncate_title(s: &str, max_len: usize) -> String { debug_assert!( max_len > ELLIPSIS_WIDTH, @@ -515,8 +504,7 @@ mod tests { #[test] fn flush_entries_pending_create_failure_keeps_pending_for_retry() { - // The next batch must retry create rather than open_append a - // file that was never created. + // The next batch must retry create rather than open_append a file that was never created. let dir = tempfile::tempdir().unwrap(); let store = test_store(dir.path()); let mut state = SessionState::fresh(store, "m"); diff --git a/crates/oxide-code/src/session/store.rs b/crates/oxide-code/src/session/store.rs index 66fb2030..9e268ec9 100644 --- a/crates/oxide-code/src/session/store.rs +++ b/crates/oxide-code/src/session/store.rs @@ -186,8 +186,7 @@ pub(crate) fn load_session_data_from_path(path: &Path) -> Result { let mut buf = Vec::new(); let mut line_no: u32 = 0; - // Manual byte-by-line read so truncated UTF-8 at EOF is warn-skipped - // instead of propagating `InvalidData` from `BufReader::lines()`. + // Byte-by-line so truncated UTF-8 at EOF is warn-skipped (not `InvalidData` from `lines()`). loop { buf.clear(); let read = reader diff --git a/crates/oxide-code/src/session/title_generator.rs b/crates/oxide-code/src/session/title_generator.rs index dc77dde0..25f01e0e 100644 --- a/crates/oxide-code/src/session/title_generator.rs +++ b/crates/oxide-code/src/session/title_generator.rs @@ -1,17 +1,6 @@ -//! Background AI session title generator. -//! -//! Once a fresh session has recorded its first user prompt, spawn a -//! detached task that asks Haiku for a concise 3-7 word sentence-case -//! title, append it to the session file as a new -//! [`Entry::Title`][crate::session::entry::Entry::Title] with source -//! [`AiGenerated`][crate::session::entry::TitleSource::AiGenerated], and -//! push an [`AgentEvent::SessionTitleUpdated`] so the TUI status bar -//! updates live. -//! -//! Failure modes (Haiku timeout / malformed response / write error) all -//! warn-log only — the first-prompt title stays on disk and in the UI. -//! Callers wire this on fresh sessions exactly once; resumed sessions skip -//! regeneration (the original title, if any, is already on disk). +//! Background AI title generator. After the first user prompt, asks Haiku for a 3-7 word title, +//! persists it as an `AiGenerated` `Entry::Title`, and emits `AgentEvent::SessionTitleUpdated`. +//! Failures warn-log only — the first-prompt title stays on disk. Fresh sessions only. use anyhow::{Context, Result, bail}; use indoc::indoc; @@ -25,25 +14,17 @@ use crate::session::handle::SessionHandle; // ── Constants ── -/// Haiku model used for title generation. Small and fast, OAuth-compatible, -/// and cheap enough to fire on every fresh session without thought. +/// Haiku model used for title generation. Cheap enough to fire on every fresh session. const HAIKU_MODEL: &str = "claude-haiku-4-5"; -/// Output budget for the title response. 40 tokens comfortably fits the -/// 3-7 word JSON envelope the prompt demands; anything longer is a -/// Haiku misstep we'd rather cut off than bill. +/// Output budget — 40 tokens comfortably fits the 3-7 word JSON envelope. const MAX_TOKENS: u32 = 40; -/// Clamp on the prompt we feed Haiku. Long first messages occasionally -/// contain pasted code or logs; truncating to 1 000 chars keeps the title -/// request small, predictable, and cheap regardless of input size. +/// Clamp on the prompt — long first messages with pasted code stay cheap to title. const MAX_PROMPT_CHARS: usize = 1_000; -/// Title prompt. Instructs the model to return JSON with a single -/// `title` field; the paired JSON-schema output format (see -/// [`title_output_format`]) enforces that shape regardless of whether -/// the model would otherwise try to answer the user's prompt -/// conversationally. +/// Title prompt. The paired JSON-schema output format ([`title_output_format`]) enforces +/// the `{"title": ...}` shape regardless of how the model would otherwise respond. const SYSTEM_PROMPT: &str = indoc! {r#" Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of this coding session. The title should be clear enough that the user recognizes the session in a list. Use sentence case: capitalize only the first word and proper nouns. @@ -62,21 +43,18 @@ const SYSTEM_PROMPT: &str = indoc! {r#" // ── Public API ── -/// Spawns a detached task that asks Haiku for a title, records it on -/// `session`, and notifies `sink`. -/// -/// `first_prompt` should be the user's first message text — truncated here -/// to [`MAX_PROMPT_CHARS`] to keep the Haiku request small. +/// Fire-and-forget: spawns a detached tokio task and returns immediately. The task asks Haiku +/// for a title, appends it to `session`, and emits `SessionTitleUpdated` through `sink`. All +/// failures (HTTP, parse, write) warn-log and drop — the first-prompt title persists, so the +/// session is never left without a title. `first_prompt` is tail-truncated to +/// [`MAX_PROMPT_CHARS`] to keep cost bounded on long pasted code. pub(crate) fn spawn(client: Client, session: SessionHandle, sink: S, first_prompt: String) where S: AgentSink + Clone + Send + 'static, { tokio::spawn(async move { if let Err(e) = generate_and_record(&client, &session, &sink, &first_prompt).await { - // Expected failure: network hiccup, rate-limit, or non-JSON - // Haiku reply. The first-prompt title stays in the file and - // status bar; the warning routes per - // `crate::util::log::init_tracing`. + // Expected: network hiccup, rate-limit, or non-JSON reply. First-prompt title stays. warn!("AI title generation failed: {e}"); } }); @@ -115,16 +93,8 @@ async fn generate_and_record( Ok(()) } -/// `{"title": string}` schema for [`Client::complete`]'s structured -/// outputs. Built once per call — the schema JSON itself is small and -/// constructing a `serde_json::Value` is cheap compared to the HTTP -/// round-trip, so a `LazyLock` optimization would be theatre. -/// -/// Without this, a first prompt phrased as a direct request (e.g. -/// `"see what's next to do in this repo"`) would frequently drive Haiku -/// to answer the task instead of titling it, and [`parse_title`] would -/// then bail on the conversational reply. The schema forces Haiku onto -/// the envelope shape regardless of how the prompt scans. +/// `{"title": string}` schema for structured outputs. Forces the envelope shape so Haiku +/// doesn't answer the prompt conversationally. fn title_output_format() -> OutputFormat { OutputFormat::json_schema(serde_json::json!({ "type": "object", @@ -138,17 +108,8 @@ fn title_output_format() -> OutputFormat { // ── Parsing ── -/// Parses Haiku's response as the `{"title": "..."}` JSON envelope, or -/// bail with enough context for the caller's warn-log. -/// -/// The envelope is mandatory. A bare plain-text response is almost -/// always Haiku's conversational refusal to the title task ("I'd be -/// happy to help! However, I need more details..." for short prompts -/// like `hi`), and using that prose as the title is worse than keeping -/// the first-prompt title we already wrote to disk. -/// -/// Triple-backtick code fences (`` ```json ... ``` ``) are stripped -/// first — Haiku wraps the envelope that way on some gateways. +/// Parses the `{"title": "..."}` JSON envelope from Haiku's response. Plain-text responses +/// (Haiku refusals) are rejected so the first-prompt title stays. Code fences are stripped first. fn parse_title(response: &str) -> Result { let trimmed = response.trim(); if trimmed.is_empty() { @@ -169,8 +130,7 @@ fn parse_title(response: &str) -> Result { Ok(cleaned.to_owned()) } -/// Cap a string for inclusion in a log / error message. Haiku refusals -/// run long; truncate so the warn-log stays readable. +/// Cap a string for log / error messages so long Haiku refusals stay readable. fn truncate_for_log(s: &str) -> String { const LOG_CAP: usize = 120; if s.chars().count() <= LOG_CAP { @@ -180,10 +140,8 @@ fn truncate_for_log(s: &str) -> String { format!("{head}...") } -/// Strips a surrounding triple-backtick markdown code fence (with an -/// optional `json` / `text` / ... language tag) from `s`, returning the -/// inner body trimmed of whitespace. Leaves any input that isn't wrapped -/// in a fence untouched. +/// Strips a surrounding triple-backtick code fence (with optional language tag). Leaves +/// non-fenced input untouched. fn strip_code_fence(s: &str) -> &str { let Some(rest) = s.strip_prefix("```") else { return s; @@ -193,10 +151,7 @@ fn strip_code_fence(s: &str) -> &str { body.trim_end().strip_suffix("```").unwrap_or(body).trim() } -/// Truncates `text` to at most `max_chars` characters, preferring the tail -/// when the input is long. The tail of a long first message is usually the -/// actual request (setup, pasted logs, or context appear earlier), so the -/// title signal lives there. +/// Truncates `text` to `max_chars`, keeping the tail (where the actual request usually lives). fn truncate_prompt(text: &str, max_chars: usize) -> String { if text.chars().count() <= max_chars { return text.to_owned(); diff --git a/crates/oxide-code/src/slash.rs b/crates/oxide-code/src/slash.rs index bde38a4c..66e91ad3 100644 --- a/crates/oxide-code/src/slash.rs +++ b/crates/oxide-code/src/slash.rs @@ -1,18 +1,11 @@ //! Slash-command surface. //! -//! [`parse_slash`] decides whether a submitted prompt is a slash -//! command; [`dispatch`] resolves it against the registry and runs it. -//! Each command is a [`registry::SlashCommand`] impl in its own -//! submodule — adding one is one file plus one entry in -//! [`registry::BUILT_INS`]. +//! [`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`]. //! -//! Output: every command pushes a `SystemMessageBlock` (info / results) -//! or an `ErrorBlock` (unknown command, bad args) to the chat. No modal -//! overlays. -//! -//! Persistence: commands never write user config files. Mutations are -//! session-local; restart returns to the user-declared config (see -//! `docs/design/slash/commands.md` § Design Decisions 6). +//! 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; mod config; @@ -30,21 +23,17 @@ mod registry; mod status; mod status_modal; -pub(crate) use context::{SessionInfo, SlashContext}; +pub(crate) use context::{LiveSessionInfo, SlashContext}; pub(crate) use matcher::MatchedCommand; pub(crate) use parser::{Parsed, parse_slash, popup_query}; pub(crate) use registry::SlashKind; -/// Filter the built-in registry against a popup query (the buffer -/// with the leading `/` stripped). Convenience wrapper around -/// [`matcher::filter_and_rank`] so the popup component never touches -/// `BUILT_INS` directly. +/// Filter the built-in registry against a popup query (leading `/` stripped). pub(crate) fn filter_built_ins(query: &str) -> Vec { matcher::filter_and_rank(query, registry::BUILT_INS) } -/// Resolves and runs a parsed slash command against the built-in -/// registry. See [`dispatch_with`]. +/// Resolves and runs `parsed` against the built-in registry. pub(crate) fn dispatch( parsed: &Parsed, ctx: &mut SlashContext<'_>, @@ -52,12 +41,8 @@ pub(crate) fn dispatch( dispatch_with(registry::BUILT_INS, parsed, ctx) } -/// Resolves `parsed` against `commands` and runs the matching impl. -/// Returns `Some(action)` for state-mutating commands so the caller -/// can forward it to the agent loop; `None` for local / unknown / -/// errored paths (which already pushed the appropriate chat block). -/// -/// Extracted so tests can drive the dispatcher with a synthetic registry. +/// `Some(action)` for state-mutating commands; `None` when the command handled its own output. +/// Extracted so tests can drive a synthetic registry. fn dispatch_with( commands: &[&dyn registry::SlashCommand], parsed: &Parsed, @@ -82,7 +67,6 @@ fn dispatch_with( } } -/// Comma-separated `/name` list for the unknown-command hint. fn format_available(commands: &[&dyn registry::SlashCommand]) -> String { use std::fmt::Write as _; let mut out = String::new(); @@ -95,7 +79,8 @@ fn format_available(commands: &[&dyn registry::SlashCommand]) -> String { out } -/// Routes a parsed command for the busy-turn dispatch path. +/// Whether `parsed` is safe to dispatch mid-turn. Only `ReadOnly` may run while the agent is +/// streaming; the caller defers `Mutating` and rejects `Unknown`. pub(crate) fn classify(parsed: &Parsed) -> SlashKind { classify_in(registry::BUILT_INS, parsed) } @@ -107,17 +92,13 @@ fn classify_in(commands: &[&dyn registry::SlashCommand], parsed: &Parsed) -> Sla } } -/// Shared test fixture — a fully-populated `SessionInfo` for the -/// per-command test modules. +/// Fully-populated `LiveSessionInfo` for per-command tests. #[cfg(test)] -pub(crate) fn test_session_info() -> SessionInfo { +pub(crate) fn test_session_info() -> LiveSessionInfo { use crate::config::{ConfigSnapshot, Effort, PromptCacheTtl}; - // model_id resolves to a real `MODELS` row so the - // `info.marketing_name()` accessor produces a known marketing - // name in tests; switch to a non-known id only when the test - // explicitly exercises the unknown-id fallback. - SessionInfo { + // Real MODELS row so `marketing_name()` produces a known name. + LiveSessionInfo { cwd: "~/test".to_owned(), version: "0.0.0-test", session_id: "test-session".to_owned(), @@ -158,16 +139,11 @@ mod tests { assert!(outcome.is_none(), "/help is Done, not Forward"); assert!(!chat.last_is_error()); assert_eq!(chat.entry_count(), 1); - // Pin: SystemMessageBlock inherits `error_text` default `None` - // — flipping that default would let non-error blocks claim - // error wording. assert_eq!(chat.last_error_text(), None); } #[test] fn dispatch_prompt_submit_command_produces_synthesized_body() { - // Pin: public `dispatch` (not just `dispatch_with`) surfaces - // the synthesized body so the App-side caller can forward it. let mut chat = fresh_chat(); let info = test_session_info(); let parsed = Parsed { @@ -204,8 +180,6 @@ mod tests { #[test] fn dispatch_unknown_command_message_lists_available_and_escape_hint() { - // The error must surface alternatives (commands) and the - // `//foo` escape — those are the user's two recovery paths. let mut chat = fresh_chat(); let info = test_session_info(); let parsed = Parsed { @@ -227,9 +201,7 @@ mod tests { // ── dispatch_with ── - /// Synthetic command with two aliases that always errors — drives - /// the dispatcher's alias-resolution and error-wrapping branches - /// against a test-controlled registry. + /// Always-erroring; exercises error-wrapping and alias resolution. struct Failing; impl registry::SlashCommand for Failing { fn name(&self) -> &'static str { @@ -248,8 +220,6 @@ mod tests { #[test] fn failing_fixture_metadata_matches_what_dispatcher_tests_assume() { - // Pin so a fixture drift (one alias missing, no Err) fails - // here rather than silently misleading the dispatcher tests. assert_eq!(Failing.name(), "failing"); assert_eq!(Failing.aliases(), &["bust", "boom"]); assert_eq!(Failing.description(), "test"); @@ -257,8 +227,6 @@ mod tests { #[test] fn dispatch_with_command_failure_renders_error_block_prefixed_with_name() { - // `Err` from `execute` must land as `/name: msg`. Driven through - // the real `dispatch_with`, not a reimplementation of its tail. let mut chat = fresh_chat(); let info = test_session_info(); let parsed = Parsed { @@ -272,8 +240,7 @@ mod tests { #[test] fn dispatch_with_alias_routes_to_canonical_impl() { - // Alias must run the canonical impl — and the error wrapping - // must echo the typed name back (the alias), not the canonical. + // Error must echo the typed alias, not the canonical name. let mut chat = fresh_chat(); let info = test_session_info(); let parsed = Parsed { @@ -298,8 +265,6 @@ mod tests { #[test] fn classify_built_in_state_mutating_command_is_mutating() { - // `/clear` (rolls state) and `/init` (starts turn) both - // override `is_read_only`. Aliases route to the same impl. for name in ["clear", "new", "reset", "init"] { let parsed = Parsed { name: name.to_owned(), diff --git a/crates/oxide-code/src/slash/clear.rs b/crates/oxide-code/src/slash/clear.rs index b5a80462..63a134b2 100644 --- a/crates/oxide-code/src/slash/clear.rs +++ b/crates/oxide-code/src/slash/clear.rs @@ -1,5 +1,4 @@ -//! `/clear` — returns [`UserAction::Clear`] so the dispatcher forwards -//! it to the agent loop, which rolls the session. +//! `/clear` — forwards [`UserAction::Clear`]; the agent loop rolls the session. use super::context::SlashContext; use super::registry::{SlashCommand, SlashKind, SlashOutcome}; @@ -47,8 +46,6 @@ mod tests { #[test] fn classify_is_mutating() { - // Refuses mid-turn rather than racing the live `messages` / - // session writer. assert_eq!(ClearCmd.classify(""), SlashKind::Mutating); } diff --git a/crates/oxide-code/src/slash/config.rs b/crates/oxide-code/src/slash/config.rs index 69984c6f..90ba6e51 100644 --- a/crates/oxide-code/src/slash/config.rs +++ b/crates/oxide-code/src/slash/config.rs @@ -1,15 +1,9 @@ -//! `/config` — read-only view of the resolved effective config plus -//! the layered TOML paths it was assembled from -//! (`$XDG_CONFIG_HOME/ox/config.toml`, `./ox.toml`). -//! -//! Path discovery is per-invocation so the user sees fresh state if -//! they edited a file mid-session. `(not found)` flags a path that -//! resolved but isn't on disk; `(not configured)` flags an unresolved -//! path. The command never writes. +//! `/config` — read-only view of resolved config plus its layered TOML source paths. Path +//! discovery is per-invocation so mid-session file edits surface immediately. use std::path::Path; -use super::context::{SessionInfo, SlashContext}; +use super::context::{LiveSessionInfo, SlashContext}; use super::format::write_kv_section; use super::registry::{SlashCommand, SlashOutcome}; use crate::config::{display_effort, file}; @@ -35,10 +29,8 @@ impl SlashCommand for ConfigCmd { } } -/// Two key-value tables: resolved values (overlapping with `/status`) -/// and the on-disk paths they came from. fn render_config( - info: &SessionInfo, + info: &LiveSessionInfo, user_path: Option<&Path>, project_path: Option<&Path>, ) -> String { @@ -68,10 +60,7 @@ fn render_config( out } -/// `(not configured)` when the path didn't resolve, otherwise the -/// tildified path with `(not found)` appended if it doesn't exist on -/// disk. The path itself is always shown so the user sees *where* we -/// looked. +/// `(not configured)` when unresolved; `~/...` plus ` (not found)` when missing. fn display_path(path: Option<&Path>) -> String { let Some(path) = path else { return "(not configured)".to_owned(); @@ -104,8 +93,6 @@ mod tests { #[test] fn config_execute_pushes_a_non_error_block() { - // Trait-method end-to-end. Path discovery may or may not - // resolve in the test env; either way `execute` is `Ok`. use crate::tui::components::chat::ChatView; use crate::tui::theme::Theme; @@ -129,9 +116,6 @@ mod tests { #[test] fn render_config_includes_every_resolved_field_value() { - // Pin every `ConfigSnapshot` field reaches the user; this also - // covers the happy-path `Some(effort)` + `show_thinking=false` - // combination the dedicated branch tests below skip. let info = test_session_info(); let cfg = &info.config; let model = info.marketing_name(); @@ -170,7 +154,6 @@ mod tests { #[test] fn render_config_thinking_renders_yes_or_no_per_flag() { - // Pin `yes`/`no` instead of `true`/`false`, both branches. let mut info = test_session_info(); info.config.show_thinking = false; let body = render_config(&info, None, None); @@ -186,8 +169,6 @@ mod tests { let info = test_session_info(); let path = PathBuf::from("/nonexistent/dir/config.toml"); let body = render_config(&info, Some(&path), None); - // Missing files render the path with `(not found)` so the - // user sees *where* we looked. assert!(body.contains("(not found)"), "{body}"); assert!( body.contains("/nonexistent/dir/config.toml"), @@ -197,7 +178,6 @@ mod tests { #[test] fn render_config_paths_none_marks_each_section_explicitly() { - // Both rows render `(not configured)`, never blank. let info = test_session_info(); let body = render_config(&info, None, None); assert_eq!(body.matches("(not configured)").count(), 2, "{body}"); @@ -220,7 +200,6 @@ mod tests { #[test] fn display_path_existing_file_is_tildified_value_only() { - // Workspace `Cargo.toml` is guaranteed to exist at test time. let here = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); assert!(here.exists(), "test fixture missing"); let got = display_path(Some(&here)); diff --git a/crates/oxide-code/src/slash/context.rs b/crates/oxide-code/src/slash/context.rs index f42a823a..a43b3d5f 100644 --- a/crates/oxide-code/src/slash/context.rs +++ b/crates/oxide-code/src/slash/context.rs @@ -1,7 +1,5 @@ -//! Inputs handed to [`SlashCommand::execute`]. [`SlashContext`] holds -//! borrowed handles to App-owned mutable state; [`SessionInfo`] is the -//! session-level descriptor snapshot. Splitting the two keeps the -//! borrow story clean. +//! Inputs to [`SlashCommand::execute`]: [`SlashContext`] borrows App-owned mutable state; +//! [`LiveSessionInfo`] is the session-level snapshot. use std::borrow::Cow; @@ -10,50 +8,33 @@ use crate::model::marketing_or_id; use crate::tui::components::chat::ChatView; use crate::tui::modal::Modal; -/// Session-level descriptors surfaced by read-only slash commands. -/// Built at TUI startup, then rebound mid-session by -/// [`AgentEvent::SessionRolled`](crate::agent::event::AgentEvent::SessionRolled) -/// (`/clear`) and -/// [`AgentEvent::ConfigChanged`](crate::agent::event::AgentEvent::ConfigChanged) -/// (`/model`, `/effort`). Embeds [`ConfigSnapshot`] so `/config` reads -/// from a single source. -pub(crate) struct SessionInfo { - /// Tildified working directory (`$HOME` rewritten as `~`). +/// Live snapshot of the running session handed to every slash command. Built at TUI startup and +/// rebound by `SessionRolled` / `ConfigChanged` so commands always see the current model, effort, +/// session id, and theme. Distinct from [`crate::session::entry::SessionInfo`], which is the +/// persisted JSONL record consumed by `--list`. +pub(crate) struct LiveSessionInfo { pub(crate) cwd: String, - /// Crate version (`env!("CARGO_PKG_VERSION")`). pub(crate) version: &'static str, - /// Active session UUID — useful for `--continue` lookups. pub(crate) session_id: String, - /// Resolved-config view (auth method, model id, effort, ...). pub(crate) config: ConfigSnapshot, } -impl SessionInfo { - /// Marketing display name derived from the live `config.model_id` - /// (e.g. `"Claude Sonnet 4.6"`). Single seam — caching the name - /// alongside `model_id` would just be a stale-state risk. +impl LiveSessionInfo { pub(crate) fn marketing_name(&self) -> Cow<'_, str> { marketing_or_id(&self.config.model_id) } } -/// Borrowed view of App-owned state for one -/// [`super::registry::SlashCommand::execute`] call. Never stored. -/// State-mutating commands return [`super::registry::SlashOutcome::Forward`]; -/// the dispatcher owns forwarding to the agent loop. Commands open -/// modals via [`SlashContext::open_modal`] — the dispatcher harvests -/// the slot after `execute` returns and pushes onto the App's modal -/// stack. +/// Borrowed App-owned state for one [`super::registry::SlashCommand::execute`] call. Open modals +/// via [`SlashContext::open_modal`]; the dispatcher harvests the slot after `execute`. pub(crate) struct SlashContext<'a> { pub(crate) chat: &'a mut ChatView, - pub(crate) info: &'a SessionInfo, - /// Out-parameter for modals opened by `execute`. `None` until set. - /// One slot per dispatch — only one modal can open per command. + pub(crate) info: &'a LiveSessionInfo, modal: Option>, } impl<'a> SlashContext<'a> { - pub(crate) fn new(chat: &'a mut ChatView, info: &'a SessionInfo) -> Self { + pub(crate) fn new(chat: &'a mut ChatView, info: &'a LiveSessionInfo) -> Self { Self { chat, info, @@ -61,16 +42,12 @@ impl<'a> SlashContext<'a> { } } - /// Open `modal` after this command finishes. Overwriting an - /// existing slot is a programmer error — only one modal per - /// dispatch is meaningful. + /// Open `modal` after this command finishes. One modal per dispatch. pub(crate) fn open_modal(&mut self, modal: Box) { debug_assert!(self.modal.is_none(), "modal slot set twice in one dispatch"); self.modal = Some(modal); } - /// Take the modal slot, if any. The dispatcher calls this once - /// after `execute` returns. pub(crate) fn take_modal(&mut self) -> Option> { self.modal.take() } diff --git a/crates/oxide-code/src/slash/diff.rs b/crates/oxide-code/src/slash/diff.rs index b61d6363..6da92173 100644 --- a/crates/oxide-code/src/slash/diff.rs +++ b/crates/oxide-code/src/slash/diff.rs @@ -1,9 +1,6 @@ -//! `/diff` — show uncommitted git changes inline. -//! -//! Runs `git diff HEAD` (or `git diff --cached` in a fresh repo with -//! no commits yet) and appends the names of untracked files that -//! aren't gitignored. Output is capped so a runaway diff can't lock -//! the render loop. +//! `/diff` — show uncommitted git changes inline. Runs `git diff HEAD` (falls back to +//! `git diff --cached` pre-first-commit) and appends untracked file names. Capped at +//! [`MAX_BYTES`] so a runaway diff can't stall rendering. use std::fmt::Write as _; use std::path::Path; @@ -16,8 +13,6 @@ use super::registry::{SlashCommand, SlashOutcome}; // ── DiffCmd ── -/// Cap so a runaway binary diff can't freeze rendering. 64 KB sits -/// comfortably above a typical PR-sized review. const MAX_BYTES: usize = 64 * 1024; pub(super) struct DiffCmd; @@ -41,8 +36,7 @@ impl SlashCommand for DiffCmd { // ── Execution ── -/// Body of [`DiffCmd::execute`] with cwd injected as data so tests can -/// drive it against a tempdir without touching process state. +/// Testable core; avoids mutating process cwd. fn execute_in(cwd: &Path, ctx: &mut SlashContext<'_>) -> Result { let text = collect_diff_in(cwd).map_err(|e| format!("{e:#}"))?; if text.trim().is_empty() { @@ -53,11 +47,7 @@ fn execute_in(cwd: &Path, ctx: &mut SlashContext<'_>) -> Result Result { if !inside_git_repo(cwd)? { bail!("not inside a git repository"); @@ -77,8 +67,7 @@ fn collect_diff_in(cwd: &Path) -> Result { fn format_diff(tracked: &str, untracked: &str) -> String { let mut out = String::new(); - // Strip only git's trailing newline — keep any in-line trailing - // whitespace on real context/change lines. + // Strip only git's trailing newline; keep trailing whitespace on real diff lines. let tracked = tracked.trim_end_matches('\n'); if !tracked.trim().is_empty() { out.push_str(tracked); @@ -98,9 +87,7 @@ fn format_diff(tracked: &str, untracked: &str) -> String { // ── Git Helpers ── -/// Distinguish "git binary missing" (Err) from "git ran but we're -/// outside a work tree" (Ok(false)) so the user sees the actionable -/// spawn error instead of the misleading "not inside a git repository". +/// `Ok(false)` outside a worktree; `Err` when git can't spawn. fn inside_git_repo(cwd: &Path) -> Result { let out = Command::new("git") .args(["rev-parse", "--is-inside-work-tree"]) @@ -128,8 +115,7 @@ fn run_git_in(cwd: &Path, args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } -/// Trimmed stderr if non-empty, else `git failed` so a -/// blank-stderr exit doesn't surface as the empty string. +/// Trimmed stderr, or `git failed` when stderr is blank. fn git_failure_message(args: &[&str], stderr: &[u8]) -> String { let s = String::from_utf8_lossy(stderr); let msg = s.trim(); @@ -142,8 +128,7 @@ fn git_failure_message(args: &[&str], stderr: &[u8]) -> String { // ── Truncation ── -/// Cuts on a UTF-8 boundary so the prefix is always ≤ [`MAX_BYTES`], -/// then appends a one-line footer naming the dropped size. +/// Cut on a UTF-8 boundary (≤ [`MAX_BYTES`]); footer names dropped size. fn truncate(s: String) -> String { if s.len() <= MAX_BYTES { return s; @@ -163,9 +148,7 @@ fn truncate(s: String) -> String { t } -/// Render a byte count as `"N B"`, `"N.N KB"`, or `"N.N MB"`. The -/// fractional digit is integer-truncated (not rounded) for -/// deterministic output across platforms. +/// `"N B"`, `"N.N KB"`, or `"N.N MB"` (integer-truncated decimal). fn format_size(bytes: usize) -> String { const KB: usize = 1024; const MB: usize = KB * 1024; @@ -191,10 +174,6 @@ mod tests { use super::*; - // The git-IO tests below shell out to a real `git` binary against - // a tempdir to avoid racing other parallel tests on the process cwd. - - /// Spawn `git args...` against `cwd`, panicking on failure. fn git_setup(cwd: &Path, args: &[&str]) { let out = Command::new("git") .args(args) @@ -205,8 +184,6 @@ mod tests { assert!(out.status.success(), "git {args:?} failed: {stderr}"); } - /// Tempdir initialized as a git repo with `user.email` and - /// `user.name` set (else `git commit` fails on hermetic CI runners). fn fresh_repo() -> (TempDir, PathBuf) { let dir = tempfile::tempdir().unwrap(); let path = dir.path().to_owned(); @@ -229,8 +206,6 @@ mod tests { #[test] fn execute_forwards_process_cwd_through_execute_in() { - // `cargo test` runs from the workspace root (a git repo) so the - // wrapper round-trips through `current_dir` without error. use crate::slash::test_session_info; use crate::tui::components::chat::ChatView; use crate::tui::theme::Theme; @@ -247,7 +222,6 @@ mod tests { #[test] fn execute_in_clean_repo_pushes_no_changes_marker() { - // Empty diff → friendly marker, not a blank message. use crate::slash::test_session_info; use crate::tui::components::chat::ChatView; use crate::tui::theme::Theme; @@ -263,8 +237,6 @@ mod tests { #[test] fn execute_in_dirty_repo_pushes_diff_text() { - // Pin exactly one block lands — no double-push between the - // empty-trim guard and the body branch. use crate::slash::test_session_info; use crate::tui::components::chat::ChatView; use crate::tui::theme::Theme; @@ -281,8 +253,6 @@ mod tests { #[test] fn execute_in_outside_a_repo_errors() { - // Pin the actionable wording survives the `anyhow → String` - // boundary on the trait return type. use crate::slash::test_session_info; use crate::tui::components::chat::ChatView; use crate::tui::theme::Theme; @@ -299,9 +269,6 @@ mod tests { #[test] fn collect_diff_in_fresh_repo_is_empty_when_nothing_staged() { - // Pre-first-commit path: `has_head` is false, so we fall back to - // `git diff --cached` (empty). Empty result drives the execute - // path's "Working tree clean." marker. let (_dir, repo) = fresh_repo(); assert_eq!(collect_diff_in(&repo).unwrap(), ""); } @@ -321,7 +288,6 @@ mod tests { std::fs::write(repo.join("a.txt"), "first\n").unwrap(); git_setup(&repo, &["add", "a.txt"]); git_setup(&repo, &["commit", "--quiet", "-m", "init"]); - // Modify after commit so `git diff HEAD` has output. std::fs::write(repo.join("a.txt"), "first\nsecond\n").unwrap(); let body = collect_diff_in(&repo).unwrap(); assert!(body.contains("a.txt"), "diff body missing path: {body}"); @@ -330,8 +296,6 @@ mod tests { #[test] fn collect_diff_in_separates_tracked_changes_from_untracked_list() { - // Both arms populated, exercising real git output (the - // synthetic separator case is pinned in `format_diff_*`). let (_dir, repo) = fresh_repo(); std::fs::write(repo.join("a.txt"), "first\n").unwrap(); git_setup(&repo, &["add", "a.txt"]); @@ -364,8 +328,6 @@ mod tests { #[test] fn format_diff_keeps_trailing_whitespace_on_diff_lines() { - // Real diffs can include trailing-whitespace edits; only git's - // trailing newline gets stripped. let body = format_diff(" context \n+added \n", ""); assert_eq!(body, " context \n+added "); } @@ -400,8 +362,6 @@ mod tests { #[test] fn format_diff_both_empty_yields_empty_string() { - // Pin so a trim-semantics change can't emit "Untracked files:" - // alone with no body. assert_eq!(format_diff("", ""), ""); assert_eq!(format_diff(" \n", " \n"), ""); } @@ -416,7 +376,6 @@ mod tests { #[test] fn inside_git_repo_is_false_outside_a_repo() { - // A bare tempdir with no `.git` is not a repo. let dir = tempfile::tempdir().unwrap(); assert!(!inside_git_repo(dir.path()).unwrap()); } @@ -442,13 +401,9 @@ mod tests { #[test] fn run_git_in_propagates_stderr_on_failure() { - // `cat-file` of a missing SHA gives stable, version-independent - // wording — flag-parser errors drift across git versions. let (_dir, repo) = fresh_repo(); let err = run_git_in(&repo, &["cat-file", "-t", "deadbeef"]).unwrap_err(); let msg = format!("{err:#}"); - // Both arms eager — `||` short-circuit would otherwise leave - // the right arm unexecuted on git versions that echo the SHA. let has_sha = msg.contains("deadbeef"); let has_not_valid = msg.to_ascii_lowercase().contains("not a valid"); assert!( @@ -469,8 +424,6 @@ mod tests { #[test] fn git_failure_message_falls_back_to_synthetic_when_stderr_blank() { - // Without the fallback an empty / whitespace-only stderr would - // surface as the empty string. assert_eq!(git_failure_message(&["status"], b""), "git status failed",); assert_eq!( git_failure_message(&["diff", "HEAD"], b" \n\t\n "), @@ -488,8 +441,6 @@ mod tests { #[test] fn truncate_at_exact_cap_preserves_input() { - // Boundary: gate is `<=`, so MAX_BYTES is in-bounds. Flipping - // to `<` would footer every full-cap diff. let s = "a".repeat(MAX_BYTES); assert_eq!(truncate(s.clone()), s); } @@ -498,8 +449,6 @@ mod tests { fn truncate_oversized_input_appends_footer_with_human_size() { let s = "a".repeat(MAX_BYTES + 100); let got = truncate(s); - // Pin kept prefix and footer separately — an off-by-one in - // either direction or a dropped recovery hint must fail here. let footer = "\n\n(truncated: 100 B more — run `git diff HEAD` for the full output)"; assert_eq!(got.len(), MAX_BYTES + footer.len()); assert_eq!(&got[..MAX_BYTES], &"a".repeat(MAX_BYTES)); @@ -509,8 +458,7 @@ mod tests { #[test] fn truncate_cuts_on_utf8_boundary_and_never_exceeds_max_bytes() { - // Final char straddles the cap; cut must back up to the - // preceding boundary so the prefix never exceeds MAX_BYTES. + // Final char straddles the cap; cut backs up to preceding boundary. let prefix = "a".repeat(MAX_BYTES - 1); let s = format!("{prefix}€trailing"); // '€' is 3 bytes let got = truncate(s); @@ -523,8 +471,6 @@ mod tests { #[test] fn format_size_truncates_to_one_decimal_for_kb() { - // Fractional digit is integer truncation; pin both unit - // boundaries so an off-by-one in `<` would fail visibly. assert_eq!(format_size(0), "0 B"); assert_eq!(format_size(512), "512 B"); assert_eq!(format_size(1023), "1023 B"); diff --git a/crates/oxide-code/src/slash/effort.rs b/crates/oxide-code/src/slash/effort.rs index c60c2205..d97df731 100644 --- a/crates/oxide-code/src/slash/effort.rs +++ b/crates/oxide-code/src/slash/effort.rs @@ -37,9 +37,7 @@ impl SlashCommand for EffortCmd { )); } let pick = parse_effort_arg(arg)?; - // Preflight: setting an explicit level on a no-effort model is - // a clear user mistake — surface upfront instead of letting it - // silently resolve to None. + // Reject upfront — setting a level on a no-effort model would silently resolve to None. let caps = capabilities_for(&ctx.info.config.model_id); if !caps.effort { return Err(format!( diff --git a/crates/oxide-code/src/slash/format.rs b/crates/oxide-code/src/slash/format.rs index 1f504af1..975de140 100644 --- a/crates/oxide-code/src/slash/format.rs +++ b/crates/oxide-code/src/slash/format.rs @@ -1,18 +1,11 @@ -//! Shared heading + key-value table renderer for `/help`, `/status`, -//! and `/config`. Keys are byte-aligned because every key in the slash -//! module is ASCII. +//! Shared heading + key-value table renderer for `/help` / `/status` / `/config`. use std::fmt::Write as _; -/// Row prefix; leaves room for the `▎` left-bar `SystemMessageBlock` -/// prepends. const ROW_PREFIX: &str = " "; - -/// Gap between key and value columns. const COL_GAP: &str = " "; -/// Append `Heading` + blank line + key-value table. Successive sections -/// are separated by a single blank line (the `/config` two-section shape). +/// Heading + blank line + table. Successive sections are separated by a blank line. pub(super) fn write_kv_section<'a>( out: &mut String, heading: &str, @@ -26,8 +19,7 @@ pub(super) fn write_kv_section<'a>( write_kv_table(out, rows); } -/// Append a `key value` table aligned to the longest key. Empty -/// input is a no-op so callers can branch on whether to emit a heading. +/// `key value` rows aligned to the longest key. Empty input is a no-op. pub(super) fn write_kv_table<'a>( out: &mut String, rows: impl IntoIterator + Clone, @@ -63,8 +55,6 @@ mod tests { #[test] fn write_kv_section_second_section_inserts_blank_separator() { - // The `/config` two-section shape: exactly one blank line - // separates successive headings. let mut out = String::new(); write_kv_section(&mut out, "First", [("a", "1")]); write_kv_section(&mut out, "Second", [("b", "2")]); @@ -81,7 +71,6 @@ mod tests { fn write_kv_table_aligns_value_column_to_longest_key() { let mut out = String::new(); write_kv_table(&mut out, [("a", "1"), ("longer", "2"), ("mid", "3")]); - // Pin the absolute byte offset, not just "all rows agree". let value_cols: Vec = out .lines() .map(|l| l.find(|c: char| c.is_ascii_digit()).expect("value present")) @@ -90,14 +79,11 @@ mod tests { value_cols.windows(2).all(|w| w[0] == w[1]), "value columns not aligned: {value_cols:?}", ); - // The shared offset is `prefix(2) + longest_key(6) + gap(2) = 10`. assert_eq!(value_cols[0], 10); } #[test] fn write_kv_table_empty_input_writes_nothing() { - // Pin so callers branching on `out.is_empty()` can't be - // regressed by an "always emit a header" tweak. let mut out = String::from("preexisting"); write_kv_table(&mut out, std::iter::empty::<(&str, &str)>()); assert_eq!(out, "preexisting"); @@ -105,7 +91,6 @@ mod tests { #[test] fn write_kv_table_single_row_uses_zero_padding() { - // `gutter == key.len()` ⇒ no padding. let mut out = String::new(); write_kv_table(&mut out, [("only", "value")]); assert_eq!(out, " only value\n"); @@ -113,8 +98,6 @@ mod tests { #[test] fn write_kv_table_each_row_ends_in_newline() { - // Pin trailing `\n` so a switch from `writeln!` to `write!` - // would fail here. let mut out = String::new(); write_kv_table(&mut out, [("a", "1"), ("b", "2")]); assert_eq!(out.matches('\n').count(), 2); diff --git a/crates/oxide-code/src/slash/help.rs b/crates/oxide-code/src/slash/help.rs index cdd0dfe0..f6ebf2ca 100644 --- a/crates/oxide-code/src/slash/help.rs +++ b/crates/oxide-code/src/slash/help.rs @@ -1,8 +1,4 @@ -//! `/help` — list every registered command. -//! -//! Renders one row per command (not one row per name): aliases live -//! parenthesized after the canonical name, matching the popup's -//! display rule. +//! `/help` — list every registered command. Aliases appear parenthesized after the canonical name. use std::fmt::Write as _; @@ -27,8 +23,6 @@ impl SlashCommand for HelpCmd { } } -/// Heading + key-value table + escape-hint footer. The footer is the -/// only discoverable path to `//foo` short of typing an unknown command. fn render_help() -> String { let labels: Vec = BUILT_INS.iter().map(|c| display_label(*c)).collect(); let rows = labels @@ -43,13 +37,7 @@ fn render_help() -> String { out } -/// Display label combining canonical name, optional alias list, and -/// optional usage hint into one cell: -/// -/// - `/help` — no aliases, no args. -/// - `/clear (new, reset)` — aliases only. -/// - `/model ` — usage only. -/// - `/clear (new, reset) ` — both. +/// `/name (aliases) ` — alias list and usage are optional. fn display_label(cmd: &dyn SlashCommand) -> String { let mut out = format!("/{}", cmd.name()); if !cmd.aliases().is_empty() { @@ -83,8 +71,6 @@ mod tests { let mut lines = body.lines(); assert_eq!(lines.next(), Some("Available Commands")); assert_eq!(lines.next(), Some(""), "heading separated by blank line"); - // Alias presentation is `display_label`'s test concern; here - // we just confirm every canonical name appears. for cmd in BUILT_INS { let needle = format!("/{}", cmd.name()); assert!( @@ -96,8 +82,6 @@ mod tests { #[test] fn render_help_includes_escape_tip_footer() { - // `//foo` is otherwise only discoverable through the unknown- - // command error; the footer is the documented path. let body = render_help(); assert!(body.contains("`//`"), "footer missing tip body: {body}"); assert!( @@ -108,8 +92,6 @@ mod tests { #[test] fn render_help_aligns_descriptions_to_a_shared_gutter() { - // Pin the absolute column, not just "all rows match" — a - // uniformly broken renderer would pass the latter. let body = render_help(); let longest = BUILT_INS .iter() @@ -137,15 +119,11 @@ mod tests { #[test] fn display_label_with_aliases_lists_them_in_parens() { - // Synthetic fixture so the test pins the format rule, not a - // future built-in's alias list. assert_eq!(display_label(&Fake::CLEAR), "/clear (new, reset)"); } #[test] fn fake_fixture_stub_methods_satisfy_trait_contract() { - // `display_label` reads only name/aliases/usage; exercise the - // unused trait slots here so they aren't silently uncovered. use crate::tui::components::chat::ChatView; use crate::tui::theme::Theme; @@ -158,9 +136,6 @@ mod tests { #[test] fn display_label_with_usage_appends_hint_after_name() { - // No live built-in carries a usage hint, so the `usage()` - // branch is dead in the registry — drive synthetic fixtures - // to pin both usage-only and aliases+usage shapes. assert_eq!(display_label(&Fake::MODEL), "/model "); assert_eq!( display_label(&Fake::CLEAR_WITH_USAGE), @@ -168,8 +143,7 @@ mod tests { ); } - /// Synthetic command flipped per `Fake::*` constructor — covers the - /// no-alias / alias-only / usage-only / both-present matrix. + /// Covers the no-alias / alias-only / usage-only / both-present matrix. struct Fake { name: &'static str, aliases: &'static [&'static str], diff --git a/crates/oxide-code/src/slash/init.rs b/crates/oxide-code/src/slash/init.rs index f8fc6c5e..8eb36ddf 100644 --- a/crates/oxide-code/src/slash/init.rs +++ b/crates/oxide-code/src/slash/init.rs @@ -1,6 +1,5 @@ -//! `/init` — synthesizes a prompt asking the model to author or update -//! the project's `AGENTS.md` / `CLAUDE.md`. See -//! `docs/design/slash/commands.md` § /init. +//! `/init` — synthesizes a prompt asking the model to author or update the project's +//! `AGENTS.md` / `CLAUDE.md`. use indoc::indoc; @@ -30,9 +29,9 @@ impl SlashCommand for InitCmd { } } -/// Body forwarded to the agent loop on `/init`. Single-shot prompt; -/// the multi-phase interactive flow needs `AgentEvent::PromptRequest` -/// plumbing not yet implemented (see `init.md` § Deferred). +/// System prompt template for `/init` — instructs the model to author a fresh `AGENTS.md` (or +/// propose a diff against an existing one) grounded in files it actually reads, with explicit +/// exclusion rules to avoid generic / boilerplate output. const PROMPT: &str = indoc! {r" Please analyze this codebase and create an `AGENTS.md` file at the project root that future AI coding assistants will read when working on it. @@ -105,7 +104,6 @@ mod tests { #[test] fn metadata_matches_built_ins_contract() { - // Description non-emptiness covered by registry's built-ins test. assert_eq!(InitCmd.name(), "init"); assert!(InitCmd.aliases().is_empty()); assert!(InitCmd.usage().is_none()); @@ -113,8 +111,6 @@ mod tests { #[test] fn classify_is_mutating() { - // Override is load-bearing: a parallel turn would race the - // in-flight one over `messages` / the session writer. assert_eq!(InitCmd.classify(""), SlashKind::Mutating); } @@ -122,15 +118,12 @@ mod tests { #[test] fn execute_does_not_push_chat_blocks() { - // The agent loop's response stream is the only block source — - // an extra push here would land before the typed `/init` row. let (chat, _outcome) = run_execute(); assert_eq!(chat.entry_count(), 0); } #[test] fn execute_prompt_targets_agents_md_and_claude_md() { - // Subsumes non-emptiness. let (_chat, outcome) = run_execute(); assert!( matches!( @@ -144,8 +137,6 @@ mod tests { #[test] fn execute_prompt_says_do_not_overwrite_existing_file() { - // Conjunction pins the actual rule — `contains("overwrite")` - // alone would pass `"please overwrite all existing files"`. let (_chat, outcome) = run_execute(); assert!( matches!( diff --git a/crates/oxide-code/src/slash/matcher.rs b/crates/oxide-code/src/slash/matcher.rs index c31b50a6..b0db85f2 100644 --- a/crates/oxide-code/src/slash/matcher.rs +++ b/crates/oxide-code/src/slash/matcher.rs @@ -1,34 +1,19 @@ -//! Slash-popup match-and-rank. +//! Slash-popup match-and-rank. Filters a registry slice against a typed query (leading `/` +//! stripped) and returns ranked rows for the popup renderer. //! -//! Filters a registry slice against a typed query (the buffer minus -//! the leading `/`) and returns ranked rows ready for the popup -//! renderer. Pure data — no ratatui types — so the popup component -//! can hand-roll fixtures in tests without dragging in `BUILT_INS`. -//! -//! Ranking ladder: name-prefix → alias-prefix → name-substring → -//! alias-substring, alphabetical (canonical name) within each tier. -//! Empty query bypasses ranking and returns the registry in -//! presentation order (`BUILT_INS`'s declared order). -//! -//! Aliases display conditionally: a match against the canonical name -//! leaves [`MatchedCommand::matched_alias`] as `None`; a match against -//! an alias surfaces that one alias only — the popup never paints a -//! full alias list. +//! Ranking: name-prefix → alias-prefix → name-substring → alias-substring, alphabetical within +//! each tier. Empty query returns the registry in declared order. use super::registry::SlashCommand; -/// One row of popup output. Renderer-agnostic so the popup can be -/// tested without depending on `BUILT_INS`. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct MatchedCommand { pub(crate) name: &'static str, pub(crate) description: &'static str, - /// The alias the user's query matched, if any. `None` when the - /// match landed on the canonical name or when the query is empty. + /// `None` when the match landed on the canonical name or query is empty. pub(crate) matched_alias: Option<&'static str>, } -/// Rank tier — lower is shown first. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum Tier { NamePrefix, @@ -37,11 +22,8 @@ enum Tier { AliasSubstring, } -/// Filter and rank `commands` against `query` (the buffer with the -/// leading `/` stripped). Empty `query` returns every command in the -/// slice's declared order; non-empty queries return only commands -/// that match by prefix or substring on the name or any alias, -/// sorted by tier then alphabetically on canonical name. +/// Empty `query` returns the slice in declared order; non-empty matches by prefix or substring, +/// sorted by tier. pub(crate) fn filter_and_rank(query: &str, commands: &[&dyn SlashCommand]) -> Vec { if query.is_empty() { return commands @@ -62,10 +44,7 @@ pub(crate) fn filter_and_rank(query: &str, commands: &[&dyn SlashCommand]) -> Ve hits.into_iter().map(|(_, m)| m).collect() } -/// Score one command against a lowercase query. Returns the best -/// tier the command qualifies for, with the matching alias surfaced -/// when the match landed on an alias rather than the canonical name. -/// `None` ⇒ neither name nor any alias matched. +/// `None` when neither name nor alias match. fn best_match(query: &str, cmd: &dyn SlashCommand) -> Option<(Tier, MatchedCommand)> { let name = cmd.name(); let name_lower = name.to_ascii_lowercase(); @@ -84,7 +63,6 @@ fn best_match(query: &str, cmd: &dyn SlashCommand) -> Option<(Tier, MatchedComma None } -/// First alias whose lowercased form satisfies `pred`. fn matching_alias(cmd: &dyn SlashCommand, pred: impl Fn(&str) -> bool) -> Option<&'static str> { cmd.aliases() .iter() @@ -114,8 +92,6 @@ mod tests { use super::super::registry::SlashOutcome; use super::*; - /// Synthetic registry fixture so tests pin the matcher's - /// behaviour independent of which built-ins ship today. struct Fake { name: &'static str, aliases: &'static [&'static str], @@ -170,7 +146,6 @@ mod tests { #[test] fn filter_and_rank_empty_query_yields_full_registry_in_declared_order() { - // The "just typed `/`" state — order tracks `BUILT_INS`. let rows = filter_and_rank("", ®istry()); assert_eq!(names(&rows), vec!["help", "clear", "status", "compact"]); assert!(rows.iter().all(|m| m.matched_alias.is_none())); @@ -178,8 +153,6 @@ mod tests { #[test] fn filter_and_rank_name_prefix_beats_substring_within_other_command() { - // "co" name-prefixes `compact` and name-substrings `forconfig`. - // Pin that the prefix-match wins and is reported first. const FORCONFIG: Fake = Fake { name: "forconfig", aliases: &[], @@ -192,8 +165,6 @@ mod tests { #[test] fn filter_and_rank_alias_prefix_beats_name_substring() { - // `new` alias-prefixes `clear` and name-substrings `renew`. - // Alias-prefix outranks name-substring, so `/clear` leads. const RENEW: Fake = Fake { name: "renew", aliases: &[], @@ -208,9 +179,6 @@ mod tests { #[test] fn filter_and_rank_within_tier_sorts_alphabetically_on_canonical_name() { - // Two commands both match by name prefix (`s` ⇒ `status` and - // a synthetic `select`). Tier is equal; secondary sort is - // alphabetical on canonical name. const SELECT: Fake = Fake { name: "select", aliases: &[], @@ -223,8 +191,6 @@ mod tests { #[test] fn filter_and_rank_alias_match_surfaces_only_typed_alias() { - // Two aliases on /clear; query matches one. `matched_alias` - // must surface the typed alias, not the full list. let rows = filter_and_rank("res", ®istry()); assert_eq!(names(&rows), vec!["clear"]); assert_eq!(rows[0].matched_alias, Some("reset")); @@ -232,9 +198,6 @@ mod tests { #[test] fn filter_and_rank_alias_substring_lands_below_other_tiers() { - // Pin the alias-substring branch (the lowest tier) — the - // query "se" appears mid-string in /clear's "reset" alias - // but doesn't match name or alias prefix on a sibling. let rows = filter_and_rank("se", ®istry()); assert_eq!(names(&rows), vec!["clear"]); assert_eq!(rows[0].matched_alias, Some("reset")); @@ -242,9 +205,6 @@ mod tests { #[test] fn filter_and_rank_query_is_case_insensitive() { - // Uppercase query must match lowercase canonical names. Pin - // both prefix and substring cases since they take separate - // code paths through `to_ascii_lowercase`. let prefix = filter_and_rank("HE", ®istry()); assert_eq!(names(&prefix), vec!["help"]); @@ -262,10 +222,6 @@ mod tests { #[test] fn best_match_prefers_name_prefix_over_any_alias_branch() { - // A query that prefixes both the canonical name and an alias - // (synthetic `clearview` aliased to `cl`) must report - // `Tier::NamePrefix` so the popup labels it on the canonical - // name rather than the alias. const ALSO: Fake = Fake { name: "clearview", aliases: &["cl"], @@ -285,8 +241,7 @@ mod tests { #[test] fn fake_execute_is_a_no_op_ok() { - // Pin the trait stub so registries that include Fake never - // trip a panic during dispatch. + // Stub so registries containing Fake don't panic during dispatch. let mut chat = crate::tui::components::chat::ChatView::new( &crate::tui::theme::Theme::default(), false, diff --git a/crates/oxide-code/src/slash/model.rs b/crates/oxide-code/src/slash/model.rs index a4443fa2..723cd2b2 100644 --- a/crates/oxide-code/src/slash/model.rs +++ b/crates/oxide-code/src/slash/model.rs @@ -1,9 +1,5 @@ -//! `/model` — open the picker, or swap directly with `/model `. -//! -//! Resolution tiers for the typed-arg form: alias → exact / dated-id → -//! unique suffix → unique substring. `[1m]` is a first-class variant; -//! rejected on models without `context_1m`. The bare form opens -//! [`super::picker::ModelEffortPicker`]; the curated roster lives there. +//! `/model` — open the picker, or swap with `/model `. Resolution: alias → exact / dated-id → +//! unique suffix → unique substring. `[1m]` rejected on models lacking `context_1m`. use super::context::SlashContext; use super::registry::{SlashCommand, SlashKind, SlashOutcome}; @@ -12,11 +8,8 @@ use crate::model::{MODELS, ResolvedModelId, lookup}; // ── Constants ── -/// `[1m]` opt-in tag — appended to a canonical id to request the 1M -/// context window on models whose capability row has `context_1m`. const TAG_1M: &str = "[1m]"; -/// Short aliases resolved before suffix / substring matching. const ALIASES: &[(&str, &str)] = &[ ("opus", "claude-opus-4-7"), ("sonnet", "claude-sonnet-4-6"), @@ -37,8 +30,6 @@ impl SlashCommand for ModelCmd { } fn classify(&self, args: &str) -> SlashKind { - // Bare opens the picker (UI-local; safe mid-turn). The - // swap form races the in-flight `Client` and must wait. if args.trim().is_empty() { SlashKind::ReadOnly } else { @@ -66,7 +57,6 @@ impl SlashCommand for ModelCmd { // ── Resolver ── -/// Strips `[1m]`, resolves base, re-attaches if supported. Case-insensitive. fn resolve_model_arg(arg: &str) -> Result { let arg = arg.to_ascii_lowercase(); let (base_arg, want_1m) = match arg.strip_suffix(TAG_1M) { @@ -92,8 +82,9 @@ fn resolve_model_arg(arg: &str) -> Result { Ok(ResolvedModelId::new(format!("{base_id}{TAG_1M}"))) } -/// Four-tier resolution against [`MODELS`]: alias → exact / dated-id -/// pass-through → unique suffix → unique substring. +/// Resolution tiers (first hit wins): short alias → known canonical id → dated id (`-YYYYMMDD`) +/// → unique suffix → unique substring. Multi-match at the substring tier is an error so the user +/// disambiguates rather than silently landing on whichever model sorts first. fn resolve_base(arg: &str) -> Result { if let Some(&(_, target)) = ALIASES.iter().find(|(name, _)| *name == arg) { return Ok(target.to_owned()); @@ -175,7 +166,6 @@ mod tests { #[test] fn classify_splits_on_args() { - // Whitespace-only args route the same as bare. assert_eq!(ModelCmd.classify(""), SlashKind::ReadOnly); assert_eq!(ModelCmd.classify(" "), SlashKind::ReadOnly); assert_eq!(ModelCmd.classify("opus"), SlashKind::Mutating); @@ -193,9 +183,6 @@ mod tests { #[test] fn execute_no_args_opens_picker_via_ctx_and_pushes_no_chat_block() { - // Bare `/model` opens the combined picker. Nothing should land - // in the chat — the modal is the UI. The picker's own tests in - // `slash::picker` cover its initial state and key handling. let mut chat = ChatView::new(&Theme::default(), false); let info = test_session_info(); let mut ctx = SlashContext::new(&mut chat, &info); @@ -228,9 +215,6 @@ mod tests { #[test] fn execute_1m_on_incompatible_model_is_rejected_with_marketing_name() { - // Haiku 4.5 has `context_1m: false`; silent acceptance would - // degrade to 200K. `haiku[1m]` (alias) and the spelled-out - // `claude-haiku-4-5[1m]` both route through the same check. for arg in ["haiku[1m]", "claude-haiku-4-5[1m]"] { let (_, outcome) = run_execute(arg); let msg = outcome.expect_err("must error"); @@ -243,8 +227,6 @@ mod tests { #[test] fn execute_canonical_id_round_trips_for_bare_and_1m_variants() { - // Pass-through tier returns exact table rows unchanged, including - // non-LISTED_MODELS older rows and 1M variants. for id in [ "claude-opus-4-7", "claude-opus-4-7[1m]", @@ -264,9 +246,6 @@ mod tests { #[test] fn execute_short_id_resolves_via_suffix_tier() { - // Suffix tier turns short forms into canonical ids without an - // explicit alias. The `[1m]` cases pin the strip-resolve- - // reattach pipeline; bare forms must NOT acquire `[1m]`. for (arg, expected) in [ ("haiku-4-5", "claude-haiku-4-5"), ("opus-4-1", "claude-opus-4-1"), @@ -301,18 +280,12 @@ mod tests { #[test] fn execute_unique_suffix_resolves_above_substring_ambiguity() { - // `opus-4` is a substring of 5 ids but a suffix of only one — - // the suffix tier must short-circuit before the substring - // ambiguity check runs. let (_, outcome) = run_execute("opus-4"); assert_eq!(outcome, Ok(swap_model("claude-opus-4"))); } #[test] fn execute_ambiguous_substring_lists_count_and_each_candidate() { - // `4-6` is neither a unique suffix nor a unique substring — - // both `claude-opus-4-6` and `claude-sonnet-4-6` end with it. - // The error must list both candidates and the alias hint. let (_, outcome) = run_execute("4-6"); let msg = outcome.expect_err("ambiguous arg must error"); assert!( @@ -333,7 +306,6 @@ mod tests { #[test] fn execute_trims_whitespace_around_arg() { - // Padded input resolves the same as bare input. let (_, outcome) = run_execute(" haiku-4-5 "); assert_eq!(outcome, Ok(swap_model("claude-haiku-4-5"))); } @@ -342,9 +314,6 @@ mod tests { #[test] fn resolve_model_arg_alias_substitution_runs_before_substring_match() { - // `opus` matches every Opus row as a substring (would be - // ambiguous), but the alias map intercepts and routes it to - // the canonical opus-4-7 row. Pin the precedence directly. assert_eq!( resolve_model_arg("opus") .as_ref() @@ -355,8 +324,6 @@ mod tests { #[test] fn resolve_model_arg_round_trips_every_models_row() { - // Drift in MODELS would silently shrink the manual-entry - // surface — every row must be exactly typeable. for info in MODELS { assert_eq!( resolve_model_arg(info.id_substr) @@ -371,9 +338,6 @@ mod tests { #[test] fn resolve_model_arg_passes_through_dated_id_via_lookup() { - // Anthropic's fully-qualified dated ids round-trip unchanged — - // `lookup` finds the family row for capability detection but - // the user's exact string is sent on the wire. for dated in [ "claude-opus-4-7-20260101", "claude-opus-4-6-20250805", @@ -407,8 +371,6 @@ mod tests { #[test] fn resolve_model_arg_lowercases_arg_before_matching() { - // Mirrors `/effort`'s case-insensitivity so `/model OPUS` - // doesn't silently fail with "Unknown model". assert_eq!( resolve_model_arg("OPUS") .as_ref() @@ -431,9 +393,6 @@ mod tests { #[test] fn resolve_model_arg_bare_1m_tag_errors_without_listing_models() { - // `/model [1m]` strips to an empty base — a substring filter - // would match every row. Reject up front so the user gets a - // clear "tag, not a model" message instead of a 10-row dump. let msg = resolve_model_arg("[1m]").expect_err("must error"); assert!(msg.contains("tag, not a model"), "{msg}"); assert!(!msg.contains("matches"), "must not list candidates: {msg}"); diff --git a/crates/oxide-code/src/slash/parser.rs b/crates/oxide-code/src/slash/parser.rs index c44b61f0..621c793c 100644 --- a/crates/oxide-code/src/slash/parser.rs +++ b/crates/oxide-code/src/slash/parser.rs @@ -1,22 +1,14 @@ -//! Slash-command parser. -//! -//! Detects whether a submitted prompt is a local slash command and, if -//! so, splits it into a name + args pair. Names accept ASCII letters, -//! digits, `_`, `-`, `:`, and `.` so a future plugin-namespace layer -//! (e.g. `/plugin:cmd`) doesn't need a parser rewrite. - -/// A parsed `/cmd args...` invocation. `name` is the command name with -/// the leading `/` stripped; `args` is the remainder, trimmed. +//! Slash-command parser. Splits `/cmd args...` into name + args. Names accept ASCII alphanumerics, +//! `_`, `-`, `:`, `.` for plugin-namespace forward-compat. + +/// `name` has the leading `/` stripped; `args` is trimmed. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct Parsed { pub(crate) name: String, pub(crate) args: String, } -/// Parses `input` as a slash command, returning `None` for plain -/// prompts. Leading whitespace is tolerated; `//` (escape sequence), -/// bare `/`, and names containing characters outside the allowed set -/// all return `None`. +/// `None` for plain prompts, `//` escape, bare `/`, or names with disallowed chars. pub(crate) fn parse_slash(input: &str) -> Option { let rest = input.trim_start().strip_prefix('/')?; if rest.is_empty() || rest.starts_with('/') { @@ -36,12 +28,8 @@ fn is_name_char(c: char) -> bool { c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ':' | '.') } -/// Popup-trigger predicate. Returns the in-progress query (the -/// buffer's leading `/` stripped) when `buffer` reads as a slash -/// command being typed — `/`, `/c`, `/clear` — and `None` otherwise: -/// plain prompts, the `//foo` escape, or anything once whitespace -/// appears (user has committed to typing args). Leading whitespace -/// is tolerated to match [`parse_slash`]. +/// In-progress query (leading `/` stripped) when `buffer` is a slash command being typed. +/// `None` for plain prompts, `//` escape, or once whitespace appears (args started). pub(crate) fn popup_query(buffer: &str) -> Option<&str> { let rest = buffer.trim_start().strip_prefix('/')?; if rest.starts_with('/') || !rest.chars().all(is_name_char) { @@ -72,9 +60,6 @@ mod tests { #[test] fn parse_slash_collapses_inner_whitespace_into_args() { - // The split happens at the first whitespace run; arg-side - // internal whitespace is preserved (e.g. `/init please write - // CLAUDE.md`). let parsed = parse_slash("/init please write CLAUDE.md").unwrap(); assert_eq!(parsed.name, "init"); assert_eq!(parsed.args, "please write CLAUDE.md"); @@ -100,24 +85,17 @@ mod tests { #[test] fn parse_slash_bare_slash_is_not_a_command() { - // Just `/` typed alone (e.g. while opening the popup) is not a - // command — the popup handles the in-flight buffer separately. assert!(parse_slash("/").is_none()); assert!(parse_slash(" / ").is_none()); } #[test] fn parse_slash_double_slash_is_not_a_command() { - // `//foo` is the escape sequence: the user wants to send `/foo` - // as a literal prompt to the model. assert!(parse_slash("//help").is_none()); } #[test] fn parse_slash_accepts_plugin_namespace_in_name() { - // Forward-compat for plugin-namespace commands. v1 returns - // "unknown command" at lookup time; the parser must not reject - // the syntax so a future plugin layer can ride on top. let parsed = parse_slash("/context7-plugin:docs").unwrap(); assert_eq!(parsed.name, "context7-plugin:docs"); assert_eq!(parsed.args, ""); @@ -125,9 +103,6 @@ mod tests { #[test] fn parse_slash_rejects_non_ascii_or_special_chars_in_name() { - // Names with chars outside `[A-Za-z0-9_:.\-]` fall through as - // "not a command" so the user's prompt isn't hijacked by - // stray glyphs. assert!(parse_slash("/foo🦀").is_none()); assert!(parse_slash("/foo!").is_none()); assert!(parse_slash("/foo,bar").is_none()); @@ -137,7 +112,6 @@ mod tests { #[test] fn popup_query_bare_slash_is_empty_query() { - // Just `/` is the popup's "show full registry" state. assert_eq!(popup_query("/"), Some("")); } @@ -149,15 +123,11 @@ mod tests { #[test] fn popup_query_tolerates_leading_whitespace() { - // Match parse_slash's leading-whitespace handling so the - // popup follows the same syntax rules as dispatch. assert_eq!(popup_query(" /he"), Some("he")); } #[test] fn popup_query_hides_once_whitespace_appears() { - // Trailing space, mid-word space, leading-then-trailing — any - // internal whitespace means the user is typing args. assert!(popup_query("/clear ").is_none()); assert!(popup_query("/clear arg").is_none()); assert!(popup_query("/cl ear").is_none()); @@ -165,8 +135,6 @@ mod tests { #[test] fn popup_query_hides_for_double_slash_escape() { - // `//foo` routes to the model literally, so the popup must - // not surface command suggestions. assert!(popup_query("//etc/hosts").is_none()); assert!(popup_query("//").is_none()); } @@ -180,8 +148,6 @@ mod tests { #[test] fn popup_query_hides_when_name_chars_violated() { - // Mirror parse_slash's name-char rule: if it can't parse, - // the popup shouldn't pretend it might. assert!(popup_query("/foo🦀").is_none()); assert!(popup_query("/foo!").is_none()); } diff --git a/crates/oxide-code/src/slash/picker.rs b/crates/oxide-code/src/slash/picker.rs index c63594ca..95b87e28 100644 --- a/crates/oxide-code/src/slash/picker.rs +++ b/crates/oxide-code/src/slash/picker.rs @@ -1,12 +1,5 @@ -//! Combined `/model + /effort` picker modal. -//! -//! Bare `/model` and bare `/effort` both open this surface — the only -//! difference is initial focus (model axis vs effort axis). The two -//! axes commit through a single [`UserAction::SwapConfig`] event, so -//! the agent loop sees one atomic config swap. -//! -//! Companion design: `docs/design/slash/modals.md` (added with the -//! design-notes commit at the end of this PR). +//! Combined model + effort picker, opened by bare `/model`. Both axes commit through one +//! [`UserAction::SwapConfig`]. use crossterm::event::{KeyCode, KeyEvent}; use ratatui::Frame; @@ -15,7 +8,7 @@ use ratatui::style::Modifier; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; -use super::context::SessionInfo; +use super::context::LiveSessionInfo; use crate::agent::event::UserAction; use crate::config::Effort; use crate::model::{ResolvedModelId, capabilities_for, marketing_or_id}; @@ -25,8 +18,7 @@ use crate::tui::theme::Theme; // ── Constants ── -/// Curated roster shown in the picker. The typed-arg `/model ` resolves against the full -/// `MODELS` table; this slice governs what the modal lists. +/// Curated roster shown in the picker; `/model ` resolves against the full `MODELS` table. const LISTED_MODELS: &[&str] = &[ "claude-opus-4-7", "claude-opus-4-7[1m]", @@ -37,8 +29,6 @@ const LISTED_MODELS: &[&str] = &[ // ── PickerItem ── -/// One model row in the picker. Holds the canonical id plus the active -/// flag; `key_hint` derives from the row's position in the list. struct ModelRow { id: &'static str, is_active: bool, @@ -71,8 +61,7 @@ fn describe(id: &str) -> String { } } -/// `'1'`–`'9'` for the first nine rows; `None` after that. Numeric -/// shortcuts are muscle-memory aids, not addressability for every row. +/// `'1'`–`'9'` for the first nine rows; `None` after that. fn numeric_hint(idx: usize) -> Option { let digit = u32::try_from(idx).ok()?.checked_add(1)?; if (1..=9).contains(&digit) { @@ -101,23 +90,22 @@ impl PickerItem for ModelRow { pub(super) struct ModelEffortPicker { list: ListPicker, - /// Active model captured at open — used to detect whether the user changed the model axis - /// on submit. + /// Active model at open — compared on submit to detect a model-axis change. active_model: String, - /// Active effort captured at open. Used for rendering the "(default)" suffix in the effort row. + /// Active effort at open — drives the "(default)" suffix when the user hasn't picked. active_effort: Option, - /// Resolved initial effort. Compared on submit to detect a real effort change — prevents - /// spurious `SwapConfig` when `active_effort` is `None` but the model resolves a default. + /// Resolved initial effort. Guards against spurious `SwapConfig` when `active_effort` is + /// `None` but the model's default resolves to a concrete tier. initial_effort: Option, - /// Current effort pick. Tracks Left / Right navigation. `None` when the highlighted model - /// has no effort tier. + /// Current pick; `None` when the highlighted model lacks an effort tier. effort: Option, - /// Whether the user touched the effort axis. False on open; only Left / Right cycling sets it. + /// Set by Left / Right navigation; lets submit distinguish a real pick from the resolved + /// default. effort_dirty: bool, } impl ModelEffortPicker { - pub(super) fn new(info: &SessionInfo) -> Self { + pub(super) fn new(info: &LiveSessionInfo) -> Self { let active_model = info.config.model_id.clone(); let active_effort = info.config.effort; let rows = ModelRow::build(&active_model); @@ -139,9 +127,8 @@ impl ModelEffortPicker { } } - /// Re-resolve the effort axis after the cursor moves. The axis - /// reflects the highlighted model's caps — `None` when that model - /// has no effort tier. + /// Re-resolve the effort axis after the cursor moves. Reflects the highlighted model's caps + /// — `None` when that model has no effort tier. fn refresh_effort_for_cursor(&mut self) { self.effort = effort_for_highlighted(&self.list, self.effort_or_active()); } @@ -184,6 +171,9 @@ impl ModelEffortPicker { self.effort_dirty = true; } + /// Commit both axes through one atomic `SwapConfig`. Each axis is `Some` only when actually + /// moved — a no-touch Enter (or a touch that returns to the initial pick) cancels rather than + /// firing a spurious swap that would re-resolve config defaults. fn submit(&self) -> ModalKey { let model = self .list diff --git a/crates/oxide-code/src/slash/registry.rs b/crates/oxide-code/src/slash/registry.rs index 11a173e1..3532ffa3 100644 --- a/crates/oxide-code/src/slash/registry.rs +++ b/crates/oxide-code/src/slash/registry.rs @@ -1,9 +1,4 @@ -//! Slash-command trait and built-in registry. -//! -//! Each built-in command lives in its own module under `slash/`, -//! implements [`SlashCommand`], and lands in [`BUILT_INS`]. Adding a -//! new command is one file plus one slice entry — no central match -//! arm, no enum variant. +//! Slash-command trait and built-in registry. Adding a command: one file + one [`BUILT_INS`] entry. use super::clear::ClearCmd; use super::config::ConfigCmd; @@ -16,85 +11,56 @@ use super::model::ModelCmd; use super::status::StatusCmd; use crate::agent::event::UserAction; -/// What [`SlashCommand::execute`] returns. `Done` for client-side -/// work that finishes via `ctx`; `Forward` for state-mutating -/// commands that hand a [`UserAction`] back for the dispatcher to -/// forward to the agent loop. The trait stays the only seam — slash -/// impls never reach into `user_tx` themselves. +/// `Done` for client-side work via `ctx`; `Forward` for state-mutating commands. #[derive(Debug, PartialEq, Eq)] pub(crate) enum SlashOutcome { Done, Forward(UserAction), } -/// Whether a slash command can run while the agent is busy. Returned -/// by [`SlashCommand::classify`]; the free [`super::classify`] wraps -/// it with `Unknown` when lookup fails. +/// Drives busy-gating: only `ReadOnly` may dispatch mid-turn; the App defers `Mutating` and +/// rejects `Unknown` while a stream is in flight. #[derive(Debug, PartialEq, Eq)] pub(crate) enum SlashKind { - /// Safe mid-turn — dispatch immediately. + /// Pure local read (chat output / modal open). Safe to run during streaming. ReadOnly, - /// State-mutating — refuse mid-turn, let the user retry when idle. + /// Touches session state or forwards a `UserAction` that races the in-flight client. + /// Deferred until the agent is idle. Mutating, - /// Not in the registry — dispatch anyway so the user sees the - /// canonical "unknown command" error block with recovery hints. - /// Trait implementations never return this; only the free - /// dispatcher does. + /// Returned only by the free dispatcher when the name doesn't resolve. Trait impls never + /// produce this variant. Unknown, } -/// A locally-dispatched command typed as `/name args`. Each command -/// owns its display metadata so help and popup rows render from the -/// trait alone — no parallel switch. +/// Locally-dispatched `/name args` command. Each impl owns its display metadata. pub(crate) trait SlashCommand: Sync { - /// Canonical name shown first in help and popup rows. No leading - /// `/`. ASCII letters / digits plus `_`, `-`, `:`, `.` are allowed. fn name(&self) -> &'static str; - /// Alternate names that route to the same impl. Display is - /// consolidated as `/name (alias1, alias2)` — alias rows do not - /// appear separately. Default is empty. fn aliases(&self) -> &'static [&'static str] { &[] } - /// One-line description for help and the popup gutter. fn description(&self) -> &'static str; - /// Whether this invocation is safe to run mid-turn. `args` enables - /// per-form classification — `/model` lists when bare and mutates - /// when given an id. Trait implementations return `ReadOnly` or - /// `Mutating`; never `Unknown` (that's the dispatcher's lookup - /// signal). + /// Per-form: `ReadOnly` or `Mutating`, never `Unknown`. fn classify(&self, _args: &str) -> SlashKind { SlashKind::ReadOnly } - /// Optional usage hint used by the error message when the command - /// is invoked with malformed arguments. `None` means no args are - /// expected. fn usage(&self) -> Option<&'static str> { None } - /// Runs the command. Mutations land through `ctx`. `Err(msg)` is - /// rendered by the dispatcher as a single `ErrorBlock` — commands - /// must not push errors themselves. `Ok(Done)` commands push - /// their own informational block; `Ok(Forward(_))` commands hand - /// a `UserAction` back for the dispatcher to forward. + /// `Err(msg)` renders as an `ErrorBlock`. fn execute(&self, args: &str, ctx: &mut SlashContext<'_>) -> Result; } -/// Every built-in command. Alphabetical for stable presentation in -/// `/help` and the empty-query popup; the matcher already sorts -/// alphabetically within each tier when filtering, so this keeps -/// every popup state consistent. +/// Alphabetical for stable `/help` and empty-query popup ordering. pub(super) const BUILT_INS: &[&dyn SlashCommand] = &[ &ClearCmd, &ConfigCmd, &DiffCmd, &EffortCmd, &HelpCmd, &InitCmd, &ModelCmd, &StatusCmd, ]; -/// Resolves `name` by canonical name first, then aliases. Generic -/// over the slice so tests can drive it against a synthetic registry. +/// Resolves `name` against canonical names first, then aliases. pub(super) fn lookup_in<'a>( commands: &'a [&'a dyn SlashCommand], name: &str, @@ -114,8 +80,6 @@ mod tests { use crate::tui::components::chat::ChatView; use crate::tui::theme::Theme; - /// Runs `cmd.execute` against a fresh in-memory chat. Lets synthetic - /// fixtures pin their trait stubs without per-test boilerplate. fn run_execute(cmd: &dyn SlashCommand, args: &str) -> Result { let mut chat = ChatView::new(&Theme::default(), false); let info = crate::slash::test_session_info(); @@ -127,7 +91,6 @@ mod tests { #[test] fn built_ins_have_unique_canonical_names() { - // Duplicate names would silently shadow — first wins. let names: HashSet<_> = BUILT_INS.iter().map(|c| c.name()).collect(); assert_eq!( names.len(), @@ -136,8 +99,6 @@ mod tests { ); } - /// Every `(canonical, alias)` pair where the alias overlaps a - /// canonical name in `commands`. Empty ⇒ namespace is disjoint. fn alias_collisions<'a>(commands: &[&'a dyn SlashCommand]) -> Vec<(&'a str, &'a str)> { let names: HashSet<_> = commands.iter().map(|c| c.name()).collect(); commands @@ -151,8 +112,6 @@ mod tests { .collect() } - /// Canonical names of commands missing a non-empty name or - /// description. Empty ⇒ every command satisfies the metadata contract. fn empty_metadata_offenders<'a>(commands: &[&'a dyn SlashCommand]) -> Vec<&'a str> { commands .iter() @@ -163,17 +122,12 @@ mod tests { #[test] fn built_ins_aliases_do_not_collide_with_any_canonical_name() { - // Alias / name namespace is shared on lookup; an overlap routes - // a typed name to the wrong impl. let collisions = alias_collisions(BUILT_INS); assert!(collisions.is_empty(), "alias collisions: {collisions:?}"); } #[test] fn alias_collisions_finds_a_synthetic_overlap() { - // BUILT_INS has no aliases today, so the collision branch needs - // a synthetic registry to execute. `ColliderCmd`'s alias `help` - // overlaps `HelpCmd`'s canonical name. struct ColliderCmd; impl SlashCommand for ColliderCmd { fn name(&self) -> &'static str { @@ -192,7 +146,6 @@ mod tests { let registry: &[&dyn SlashCommand] = &[&HelpCmd, &ColliderCmd]; assert_eq!(alias_collisions(registry), vec![("collider", "help")]); - // Exercise the trait stubs the helper doesn't reach. assert_eq!(ColliderCmd.description(), "collide"); assert_eq!(run_execute(&ColliderCmd, ""), Ok(SlashOutcome::Done)); } @@ -208,8 +161,6 @@ mod tests { #[test] fn empty_metadata_offenders_flags_a_synthetic_violator() { - // Drive the offender-collection branch — BUILT_INS satisfies - // the contract, so a positive case is needed for coverage. struct EmptyDescCmd; impl SlashCommand for EmptyDescCmd { fn name(&self) -> &'static str { @@ -224,14 +175,11 @@ mod tests { } assert_eq!(empty_metadata_offenders(&[&EmptyDescCmd]), vec!["no-desc"]); - // Exercise the execute stub the offender helper doesn't reach. assert_eq!(run_execute(&EmptyDescCmd, ""), Ok(SlashOutcome::Done)); } // ── lookup_in ── - /// Synthetic alias-bearing command for `lookup_in` tests; lets - /// them pin the alias branch independent of the live registry. struct AliasedCmd; impl SlashCommand for AliasedCmd { fn name(&self) -> &'static str { @@ -250,7 +198,6 @@ mod tests { #[test] fn aliased_cmd_fixture_satisfies_trait_contract() { - // The `classify` calls exercise the trait default body. assert_eq!(AliasedCmd.name(), "primary"); assert_eq!(AliasedCmd.aliases(), &["alt", "shortcut"]); assert_eq!(AliasedCmd.description(), "fake"); @@ -267,8 +214,6 @@ mod tests { #[test] fn lookup_in_resolves_each_alias_to_canonical_impl() { - // Alias branch is dead in the live registry — drive a synthetic - // one so a mutation flipping `||` to `&&` fails here. let registry: &[&dyn SlashCommand] = &[&AliasedCmd]; for alias in ["alt", "shortcut"] { let cmd = lookup_in(registry, alias).expect("alias must resolve"); diff --git a/crates/oxide-code/src/slash/status.rs b/crates/oxide-code/src/slash/status.rs index 4205a351..f8e57882 100644 --- a/crates/oxide-code/src/slash/status.rs +++ b/crates/oxide-code/src/slash/status.rs @@ -1,7 +1,5 @@ -//! `/status` — open the read-only [`StatusModal`](super::status_modal::StatusModal) -//! overview of the live session. -//! -//! No args, no chat output — the modal is the surface. Esc closes. +//! `/status` — open the read-only [`StatusModal`](super::status_modal::StatusModal). No args, +//! no chat output — the modal is the surface. use super::context::SlashContext; use super::registry::{SlashCommand, SlashOutcome}; @@ -43,9 +41,6 @@ mod tests { #[test] fn execute_opens_the_status_modal_via_ctx_and_pushes_no_chat_block() { - // The modal is the UI; chat must stay clean. Behavioural tests - // for the modal's content + key handling live in - // `slash::status_modal`. let mut chat = ChatView::new(&Theme::default(), false); let info = test_session_info(); let mut ctx = SlashContext::new(&mut chat, &info); diff --git a/crates/oxide-code/src/slash/status_modal.rs b/crates/oxide-code/src/slash/status_modal.rs index b66665a0..9b83e5ae 100644 --- a/crates/oxide-code/src/slash/status_modal.rs +++ b/crates/oxide-code/src/slash/status_modal.rs @@ -7,7 +7,7 @@ use ratatui::style::Modifier; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; -use super::context::SessionInfo; +use super::context::LiveSessionInfo; use crate::config::display_effort; use crate::tui::modal::{Modal, ModalKey}; use crate::tui::theme::Theme; @@ -25,7 +25,7 @@ pub(super) struct StatusModal { } impl StatusModal { - pub(super) fn new(info: &SessionInfo) -> Self { + pub(super) fn new(info: &LiveSessionInfo) -> Self { let model = format!("{} ({})", info.marketing_name(), info.config.model_id); let show_thinking = if info.config.show_thinking { "on".to_owned() @@ -107,16 +107,12 @@ mod tests { #[test] fn new_produces_one_row_per_session_descriptor() { - // Pin row count so a dropped descriptor (e.g. forgotten Auth) - // surfaces here. Independent of label / value contents. let m = modal(); assert_eq!(m.rows.len(), 8); } #[test] fn new_collects_every_session_field_value() { - // Pin the actual data flow — every value the user expects to - // see in the modal must come straight from the snapshot. let info = test_session_info(); let m = StatusModal::new(&info); let body: String = m @@ -145,14 +141,11 @@ mod tests { .iter() .find(|(k, _)| *k == "Show Thinking") .expect("show-thinking row"); - // Default fixture has `show_thinking: false`. assert_eq!(thinking_row.1, "off"); } #[test] fn new_renders_thinking_on_when_snapshot_says_true() { - // Pin the on-branch so a regression that always renders "off" - // fails here. The off-branch test alone can't catch that. let mut info = test_session_info(); info.config.show_thinking = true; let m = StatusModal::new(&info); @@ -168,7 +161,6 @@ mod tests { #[test] fn esc_closes_modal_silently() { - // Status is read-only; closing it must NOT dispatch an action. let mut m = modal(); let outcome = m.handle_key(&key(KeyCode::Esc)); assert!(matches!(outcome, ModalKey::Cancelled)); @@ -176,8 +168,6 @@ mod tests { #[test] fn enter_also_closes_modal_silently() { - // Enter on a no-op view feels natural; pin both code paths so - // a regression that special-cases Esc only fails here. let mut m = modal(); let outcome = m.handle_key(&key(KeyCode::Enter)); assert!(matches!(outcome, ModalKey::Cancelled)); @@ -185,8 +175,6 @@ mod tests { #[test] fn other_keys_are_consumed_and_modal_stays_open() { - // Arrow / printable / Tab — all must stay locally consumed - // so the input area never sees them while status is on screen. let mut m = modal(); for code in [KeyCode::Up, KeyCode::Down, KeyCode::Char('x'), KeyCode::Tab] { let outcome = m.handle_key(&key(code)); diff --git a/crates/oxide-code/src/tool.rs b/crates/oxide-code/src/tool.rs index 4493ed58..8d1674d3 100644 --- a/crates/oxide-code/src/tool.rs +++ b/crates/oxide-code/src/tool.rs @@ -38,7 +38,7 @@ pub(crate) struct ToolOutput { pub(crate) metadata: ToolMetadata, } -/// Structured data for UI display and logging, not sent to the model. +/// Structured data for UI display and logging; not sent to the model. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct ToolMetadata { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -56,7 +56,6 @@ pub(crate) struct ToolMetadata { } impl ToolOutput { - /// Converts `Ok` / `Err` into a `ToolOutput` with default metadata. pub(crate) fn from_result(result: Result) -> Self { match result { Ok(content) => Self { @@ -72,19 +71,16 @@ impl ToolOutput { } } - /// Attaches a display title. pub(crate) fn with_title(mut self, title: impl Into) -> Self { self.metadata.title = Some(title.into()); self } - /// Records a replacement count (edit tool). pub(crate) fn with_replacements(mut self, count: usize) -> Self { self.metadata.replacements = Some(count); self } - /// Attaches per-match diff hunks (edit tool). pub(crate) fn with_diff_chunks(mut self, chunks: Vec) -> Self { self.metadata.diff_chunks = Some(chunks); self @@ -165,6 +161,9 @@ pub(crate) struct GrepMatchLine { // ── Tool Trait ── /// A tool that the agent can invoke. +/// +/// Per-instance metadata (name, icon, schema, input summary) lives on the trait so that adding a +/// new tool requires editing only its module — the registry never matches on `name`. pub(crate) trait Tool: Send + Sync { fn name(&self) -> &'static str; fn description(&self) -> &'static str; @@ -174,6 +173,8 @@ pub(crate) trait Tool: Send + Sync { "⟡" } + /// Pulls the tool's primary argument out of `input` for terse `Name(arg)` labels in the UI. + /// Returning `None` falls back to the bare name. fn summarize_input<'a>(&self, input: &'a serde_json::Value) -> Option<&'a str> { _ = input; None @@ -187,7 +188,8 @@ pub(crate) trait Tool: Send + Sync { } } - /// Returns `None` to fall back to [`ToolResultView::Text`]. + /// Returns the structured shape of a successful tool result for richer UI rendering, or `None` + /// to fall back to [`ToolResultView::Text`]. Errors short-circuit to text in the registry. fn result_view( &self, _input: &serde_json::Value, @@ -211,15 +213,12 @@ pub(crate) trait Tool: Send + Sync { } } -/// Fallback icon for unknown tool names. pub(crate) const DEFAULT_TOOL_ICON: &str = "⟡"; -/// Extracts a string field from a tool input object. pub(crate) fn extract_input_field<'a>(input: &'a serde_json::Value, key: &str) -> Option<&'a str> { input.get(key).and_then(serde_json::Value::as_str) } -/// Capitalizes the first character of a tool name for display. pub(crate) fn title_case(s: &str) -> String { let mut chars = s.chars(); match chars.next() { @@ -230,6 +229,10 @@ pub(crate) fn title_case(s: &str) -> String { // ── Tool Registry ── +/// Owns the set of [`Tool`]s available to the agent and dispatches calls by name. +/// +/// The registry is built once at session start with the tools the model is allowed to invoke. +/// Lookups are linear over a small `Vec` (registry size is fixed at < 10), so no map is needed. pub(crate) struct ToolRegistry { tools: Vec>, } @@ -259,6 +262,10 @@ impl ToolRegistry { .map_or_else(|| name.to_owned(), |t| t.summarize_call(input)) } + /// Dispatches to the named tool and enforces the `MAX_OUTPUT_BYTES` byte safety net on the + /// returned content, recording the pre-cap length on `metadata.truncated_bytes` if trimming + /// fired. Per-tool row caps run inside the tool itself; this is the last-line defense against + /// pathological output sizes. pub(crate) async fn run(&self, name: &str, input: serde_json::Value) -> ToolOutput { let Some(tool) = self.get(name) else { return ToolOutput { @@ -276,6 +283,9 @@ impl ToolRegistry { output } + /// Resolves the structured result view for a tool call. Errors short-circuit to + /// [`ToolResultView::Text`] regardless of the tool's preference, so failure messages render + /// consistently across the chat. Unknown tool names also fall back to text. pub(crate) fn result_view( &self, name: &str, @@ -303,7 +313,14 @@ pub(crate) const MAX_OUTPUT_BYTES: usize = 128 * 1024; const TRUNCATION_OVERHEAD: usize = 80; -/// Caps `content` at [`MAX_OUTPUT_BYTES`], keeping head and tail halves. +/// Trims `content` to fit under [`MAX_OUTPUT_BYTES`], keeping the head and tail and replacing the +/// middle with a `[N bytes truncated]` marker. +/// +/// Returns `(content, original_len)` — `original_len` is `Some` only when truncation actually +/// fired, so the caller can record the pre-cap size. Skips truncation when the omitted slice is +/// smaller than the marker itself (truncating would grow the output rather than shrink it). +/// Char-boundary floors keep the returned `String` valid UTF-8 even when the cap lands inside a +/// multibyte sequence. fn cap_output(content: String) -> (String, Option) { if content.len() <= MAX_OUTPUT_BYTES { return (content, None); @@ -332,7 +349,11 @@ fn cap_output(content: String) -> (String, Option) { // ── Input Parsing ── -/// Deserializes raw JSON into a tool's input struct. +/// Deserializes a tool's typed input from the raw JSON the agent loop hands us. +/// +/// On failure returns a fully-formed [`ToolOutput`] with `is_error: true` (not a normal +/// `serde_json::Error`) so the caller can short-circuit straight back to the model with a +/// user-visible "Invalid input: ..." message — no extra mapping needed at every call site. #[expect( clippy::result_large_err, reason = "ToolOutput carries the full tool result; the Err here is the cold input-validation path constructed at most once per tool call" @@ -347,7 +368,6 @@ pub(crate) fn parse_input(raw: serde_json::Value) -> Result // ── Path Utilities ── -/// Resolves `search_path` or falls back to the current working directory. pub(crate) fn resolve_base_dir(search_path: Option<&str>) -> Result { let cwd = std::env::current_dir().map_err(|e| format!("Failed to get working directory: {e}"))?; @@ -366,7 +386,11 @@ fn display_cwd_path_from(path: &str, cwd: Option<&Path>) -> String { } } -/// Returns a tool-call label with a cwd-relative path argument. +/// Renders a `Name(path)` summary for a tool call whose primary argument is a filesystem path. +/// +/// `path_key` names the JSON field to extract from `input` (e.g. `"file_path"`, `"path"`). +/// Falls back to the bare title-cased tool name when the field is absent or non-string. Path +/// is rendered through [`display_cwd_path`] so it appears relative to cwd. pub(crate) fn summarize_path_call( tool_name: &str, input: &serde_json::Value, @@ -379,7 +403,11 @@ pub(crate) fn summarize_path_call( } } -/// Returns a cwd-relative path, or absolute if outside `base`. +/// Renders `path` for chat display. +/// +/// Strips `base` if it's a prefix; when `path == base` (relative path is empty) returns the file +/// name so the chat row says `foo.rs` rather than `.`. Paths outside `base` render as their full +/// string. Lossy UTF-8 conversion is acceptable here because the result is for display only. pub(crate) fn display_path(path: &Path, base: &Path) -> String { if let Ok(rel) = path.strip_prefix(base) { if rel.as_os_str().is_empty() { @@ -393,7 +421,9 @@ pub(crate) fn display_path(path: &Path, base: &Path) -> String { path.to_string_lossy().into_owned() } -/// Extracts the filename component, falling back to the full path. +/// Returns the trailing filename component, falling back to the input string when no component +/// can be extracted (root path, non-UTF-8 component). The fallback is what makes this safe to +/// call on user-supplied path strings. pub(crate) fn file_name(path: &str) -> &str { Path::new(path) .file_name() @@ -405,7 +435,8 @@ pub(crate) fn file_name(path: &str) -> &str { const BINARY_CHECK_SIZE: usize = 8192; -/// Detects binary files by scanning for null bytes in the first 8 KB. +/// Heuristic: a NUL byte in the first 8 KiB marks the file as binary. Same rule git uses; cheap +/// enough to run before every text-mode read. pub(crate) fn is_binary(bytes: &[u8]) -> bool { bytes.iter().take(BINARY_CHECK_SIZE).any(|&b| b == 0) } @@ -414,7 +445,10 @@ pub(crate) fn is_binary(bytes: &[u8]) -> bool { const MAX_WALK_DEPTH: usize = 64; -/// Returns a gitignore-aware iterator over regular files under `base`. +/// Recursively walks `base`, yielding regular files only. +/// +/// Honors `.gitignore`, global ignore files, and hidden-entry rules via the `ignore` crate's +/// defaults, and pins the walk to the starting filesystem so symlinked mounts don't escape. pub(crate) fn walk_files(base: &Path) -> impl Iterator { ignore::WalkBuilder::new(base) .same_file_system(true) @@ -424,7 +458,11 @@ pub(crate) fn walk_files(base: &Path) -> impl Iterator .filter(|e| e.file_type().is_some_and(|ft| ft.is_file())) } -/// Extracts the modification time, falling back to `UNIX_EPOCH`. +/// Reads the entry's modification time for sorting / display. +/// +/// Falls back to [`SystemTime::UNIX_EPOCH`] when stat fails (entry vanished, permission denied, +/// platform without mtime). Callers use the result for ordering only — a synthetic epoch +/// timestamp sorts oldest, which is a sane default for unreadable entries. pub(crate) fn entry_mtime(entry: &ignore::DirEntry) -> SystemTime { entry .metadata() @@ -435,7 +473,6 @@ pub(crate) fn entry_mtime(entry: &ignore::DirEntry) -> SystemTime { // ── Formatting ── -/// Converts a byte count to megabytes for display. #[expect( clippy::cast_precision_loss, reason = "MB display tolerates minor precision loss at > 2^53 bytes; file size caps are nowhere near that" @@ -446,7 +483,12 @@ pub(crate) fn bytes_to_mb(bytes: u64) -> f64 { pub(crate) const MAX_LINE_LENGTH: usize = 500; -/// Truncates a line beyond [`MAX_LINE_LENGTH`] characters. +/// Caps a line at [`MAX_LINE_LENGTH`] characters and appends `... [N chars]` when truncation +/// fires. +/// +/// Returns `Cow::Borrowed` (no allocation) when the line already fits — both the byte fast-path +/// and the char-count fallback short-circuit. The `[N chars]` footer surfaces the original length +/// so the model can decide whether to read more. pub(crate) fn truncate_line(line: &str) -> Cow<'_, str> { if line.len() <= MAX_LINE_LENGTH { return Cow::Borrowed(line); @@ -646,14 +688,6 @@ mod tests { } } - #[test] - fn display_cwd_path_from_without_cwd_falls_back_to_original_path() { - assert_eq!( - display_cwd_path_from("/tmp/example.rs", None), - "/tmp/example.rs" - ); - } - #[test] fn tool_summarize_call_shortens_file_paths_inside_cwd() { let cwd = std::env::current_dir().unwrap(); @@ -1059,6 +1093,16 @@ mod tests { assert_eq!(result, std::env::current_dir().unwrap()); } + // ── display_cwd_path_from ── + + #[test] + fn display_cwd_path_from_without_cwd_falls_back_to_original_path() { + assert_eq!( + display_cwd_path_from("/tmp/example.rs", None), + "/tmp/example.rs" + ); + } + // ── display_path ── #[test] diff --git a/crates/oxide-code/src/tool/bash.rs b/crates/oxide-code/src/tool/bash.rs index 5409f3ae..e1444847 100644 --- a/crates/oxide-code/src/tool/bash.rs +++ b/crates/oxide-code/src/tool/bash.rs @@ -10,12 +10,8 @@ use tokio::process::Command; use super::{Tool, ToolMetadata, ToolOutput, extract_input_field, title_case}; -/// Default per-command timeout — two minutes covers typical -/// compile / test cycles without letting a runaway command hold -/// the agent loop indefinitely. const DEFAULT_TIMEOUT: Duration = Duration::from_mins(2); -/// Stand-in content when a command produced no stdout / stderr. const NO_OUTPUT_MARKER: &str = "(no output)"; pub(crate) struct BashTool; @@ -58,12 +54,6 @@ impl Tool for BashTool { extract_input_field(input, "command") } - /// Bash uses `$ ` as its visual identity — the dollar - /// icon already reads as a shell prompt, so wrapping the command - /// in `Bash(...)` would be redundant. When the `command` field is - /// absent (malformed input — schema validation should catch this - /// upstream) fall back to the default shape (`Bash`) so the UI - /// still prints a readable label rather than a bare `$ `. fn summarize_call(&self, input: &serde_json::Value) -> String { extract_input_field(input, "command").map_or_else(|| title_case(self.name()), str::to_owned) } @@ -115,8 +105,8 @@ async fn execute(command: &str, timeout: Duration) -> ToolOutput { .stderr(std::process::Stdio::piped()) .kill_on_drop(true); - // Own process group so timeout can kill the whole tree, not just bash — - // otherwise `(sleep 3600; ...) &` outlives the direct child. + // Put the child in its own process group so a timeout can `killpg` the whole tree, including + // any `cmd &`-style detached descendants the shell launched. #[cfg(unix)] cmd.process_group(0); @@ -144,8 +134,7 @@ async fn execute(command: &str, timeout: Duration) -> ToolOutput { }; } Err(_) => { - // kill_on_drop handles bash; killpg catches any backgrounded - // grandchildren still in the same process group. + // kill_on_drop handles bash; killpg catches detached grandchildren in the same group. #[cfg(unix)] kill_process_group(pgid); return ToolOutput { @@ -182,10 +171,10 @@ async fn execute(command: &str, timeout: Duration) -> ToolOutput { content.push_str(NO_OUTPUT_MARKER); } - // Only flag execution failures (timeout, spawn error) as is_error. - // Nonzero exit codes are informational — many commands use them normally - // (grep returns 1 for no matches, diff returns 1 for differences, etc.). - // The model can determine severity from the output content itself. + // Nonzero exit is reported in the body, not as `is_error`: tools the model invokes (`grep` + // returning 1 on no match, `test`-style probes) are expected to use the exit code as a signal. + // Reserving `is_error` for spawn / timeout failures keeps the model from treating those probes + // as hard failures. ToolOutput { content, is_error: false, @@ -195,9 +184,6 @@ async fn execute(command: &str, timeout: Duration) -> ToolOutput { // ── Process Group Cleanup ── -/// Best-effort SIGKILL of an entire process group on Unix via the safe -/// `nix` wrapper around `killpg(2)`. Errors are ignored (`ESRCH` just -/// means the group already exited). #[cfg(unix)] fn kill_process_group(pgid: Option) { use nix::sys::signal::{Signal, killpg}; @@ -292,10 +278,8 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn execute_timeout_kills_backgrounded_children() { - // A real shell command spawns a long-lived descendant and detaches. - // Before the process-group fix, the descendant would outlive the - // timeout and leak as an orphan. The test writes to a marker file - // after 1 second; if the group is killed first, the marker is absent. + // Without the process-group kill, a detached descendant outlives the timeout and touches + // the marker file. The marker's absence proves the whole group was killed. let dir = tempfile::tempdir().unwrap(); let marker = dir.path().join("leaked"); let marker_str = marker.to_str().unwrap(); diff --git a/crates/oxide-code/src/tool/edit.rs b/crates/oxide-code/src/tool/edit.rs index 99148327..58a71535 100644 --- a/crates/oxide-code/src/tool/edit.rs +++ b/crates/oxide-code/src/tool/edit.rs @@ -150,6 +150,19 @@ async fn run(raw: serde_json::Value, tracker: Arc) -> ToolOutput { } } +/// Performs the in-place exact-string replacement. +/// +/// Contract: +/// +/// - `old_string` must occur verbatim in the file (whitespace and indentation included). Without +/// `replace_all`, ambiguous matches are rejected so the model cannot silently edit the wrong +/// site. +/// - The Read-before-Edit gate refuses files that haven't been read in this session and files +/// whose stat / hash drifted since the last Read. +/// - CRLF is preserved: the file is normalized to LF for matching, the dominant EOL is restored +/// on write so we don't flip line endings just because the caller used `\n` in `old_string`. +/// - Returns `(message, match_count, diff_chunks)` on success; `chunks` carry real post-edit line +/// numbers shifted by cumulative delta from earlier replacements. async fn edit_file( path: &str, old_string: &str, @@ -250,7 +263,6 @@ async fn edit_file( // ── Diff Production ── -/// Builds per-match diff chunks with real file line numbers. fn build_diff_chunks( original: &str, old_string: &str, @@ -372,7 +384,6 @@ fn parse_replacement_count(content: &str) -> Option { .ok() } -/// Builds a fallback chunk from raw input strings (line 1). pub(crate) fn synthesize_chunk(old: &str, new: &str) -> DiffChunk { let mut chunk = DiffChunk { old: split_into_diff_lines(old, 1), diff --git a/crates/oxide-code/src/tool/glob.rs b/crates/oxide-code/src/tool/glob.rs index a84e722a..d2292cc0 100644 --- a/crates/oxide-code/src/tool/glob.rs +++ b/crates/oxide-code/src/tool/glob.rs @@ -117,6 +117,13 @@ struct GlobOutput { truncated_total: Option, } +/// Walks `search_path` (defaulting to the current working directory), filters entries against +/// `pattern`, and returns up to `MAX_RESULTS` matches sorted newest-first by mtime. +/// +/// `walk_files` already enforces the ignore-list precedence (`.gitignore` and global ignore +/// files first, then hidden-entry rules), so the glob only ever sees tracked files. Truncation +/// past `MAX_RESULTS` is announced inline and surfaced separately on `truncated_total` for the +/// structured view. fn glob_files(pattern: &str, search_path: Option<&str>) -> Result { let base = super::resolve_base_dir(search_path)?; if !base.is_dir() { diff --git a/crates/oxide-code/src/tool/grep.rs b/crates/oxide-code/src/tool/grep.rs index 29c5624d..9e215fbd 100644 --- a/crates/oxide-code/src/tool/grep.rs +++ b/crates/oxide-code/src/tool/grep.rs @@ -13,9 +13,6 @@ use super::{ }; const DEFAULT_HEAD_LIMIT: usize = 250; -/// Per-file size cap for `grep` (1 MB). Tighter than other file tools -/// because regex over very large files is the wrong tool — point users -/// at a dedicated streaming search instead. const MAX_GREP_FILE_SIZE: u64 = 1024 * 1024; pub(crate) struct GrepTool; @@ -81,8 +78,8 @@ impl Tool for GrepTool { content: &str, _metadata: &ToolMetadata, ) -> Option { - // Only content mode produces line-numbered rows; other modes - // fall through so their summary header stays visible. + // Only content mode produces line-numbered rows; other modes fall through so their + // summary header stays visible. let mode = input .get("output_mode") .and_then(serde_json::Value::as_str) @@ -180,8 +177,7 @@ fn grep_title(output: Option<&str>) -> String { if let Some(line) = text.lines().find(|l| l.starts_with("Found ")) { return line.trim_end_matches('.').to_owned(); } - // Content mode: count matches only — context lines have the - // same `path:NUM` prefix but `is_match == false`. + // Content mode: count matches only — context lines share the `path:NUM` prefix. let match_count = text .lines() .filter_map(parse_match_line) @@ -206,9 +202,9 @@ struct GrepParams<'a> { } fn grep_files(params: &GrepParams<'_>) -> Result { - // Bound regex compilation. The default size limit is 10 MB; a pattern - // like `a{100000}{100000}` would allocate a massive DFA per tool call. - // 1 MB is plenty for any real-world search expression. + // Patterns are line-oriented: the regex matches against each line individually, so `^` / `$` + // anchor to line boundaries without needing the multi-line flag. Compiled-program and DFA + // size caps keep adversarial patterns (e.g., catastrophic alternations) from OOMing. let re = regex::RegexBuilder::new(params.pattern) .case_insensitive(params.case_insensitive) .size_limit(1 << 20) @@ -515,8 +511,6 @@ fn format_count(files: &[PathBuf], base: &Path, re: ®ex::Regex, head_limit: u let truncated = total_files > head_limit; counts.truncate(head_limit); - // Summary first — the renderer's title-strip pass consumes it, - // leaving a clean `paths` body. Mirrors `format_files_with_matches`. let mut output = format!( "Found {total_matches} total {} across {total_files} {}", if total_matches == 1 { @@ -538,10 +532,7 @@ fn format_count(files: &[PathBuf], base: &Path, re: ®ex::Regex, head_limit: u // ── Result View ── -/// Parses content-mode grep output into per-file groups. Returns `None` -/// for any unrecognised line — skipped-file warnings, malformed rows — -/// so the block falls through to the text body and the reader sees raw -/// output instead of a silently truncated render. +/// Returns `None` for any unrecognised line so the block falls through to text. fn parse_content_view(content: &str) -> Option { if content.contains("\n\nSkipped (exceeds ") { return None; @@ -579,10 +570,11 @@ fn parse_content_view(content: &str) -> Option { Some(ToolResultView::GrepMatches { groups, truncated }) } -/// Parses one row of content-mode grep output. `path:NUM:text` is a -/// match; `path:NUM-text` is a context line. Scans colons left-to-right -/// to skip path-internal `:` (e.g., Windows `C:foo`) without digits -/// after. +/// Parses one row: `path:NUM:text` (match) or `path:NUM-text` (context). +/// +/// Iterates through colons left-to-right because paths can themselves contain `:` (Windows drive +/// letters, URL-shaped names) — the row only commits on the first colon followed by digits and a +/// `:` / `-` separator, which uniquely identifies the line-number column. fn parse_match_line(line: &str) -> Option<(&str, GrepMatchLine)> { let mut search_start = 0; while let Some(off) = line[search_start..].find(':') { @@ -781,9 +773,7 @@ mod tests { p.search_path = Some(dir.path().to_str().unwrap()); p.context = 1; let result = grep_files(&p).unwrap(); - // With context=1, MATCH1 (line 3) shows lines 2-4 and MATCH2 (line 6) shows - // lines 5-7. Lines 4 and 5 bridge the gap, so the ranges merge into one block - // with no "--" separator. + // MATCH1 ctx → 2-4, MATCH2 ctx → 5-7; lines 4-5 bridge the gap so ranges merge. assert!(!result.contains("--")); assert!(result.contains("test.txt:2-b")); assert!(result.contains("test.txt:3:MATCH1")); @@ -815,14 +805,12 @@ mod tests { p.search_path = Some(dir.path().to_str().unwrap()); p.context = 1; let result = grep_files(&p).unwrap(); - // MATCH1 (line 1) context=1 → lines 1-2; MATCH2 (line 8) → lines 7-8. - // Gap between ranges, so a "--" separator should appear. + // MATCH1 ctx → 1-2, MATCH2 ctx → 7-8; the gap forces a "--" separator. assert!(result.contains("--")); assert!(result.contains("test.txt:1:MATCH1")); assert!(result.contains("test.txt:2-a")); assert!(result.contains("test.txt:7-f")); assert!(result.contains("test.txt:8:MATCH2")); - // Middle lines should not appear. assert!(!result.contains("test.txt:3")); assert!(!result.contains("test.txt:4")); assert!(!result.contains("test.txt:5")); @@ -1022,7 +1010,7 @@ mod tests { #[test] fn grep_title_with_context_counts_matches_only() { - // Context lines share the `path:NUM` prefix but `is_match == false`. + // Context lines share the `path:NUM` prefix but must not be counted. let dir = tempfile::tempdir().unwrap(); std::fs::write( dir.path().join("test.txt"), @@ -1152,7 +1140,7 @@ mod tests { #[test] fn grep_files_count_mode_summary_first() { - // Summary heads the body so the renderer's title-strip removes it. + // Pin summary as the first line so the renderer's title-strip removes it. let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("a.txt"), "match\n").unwrap(); std::fs::write(dir.path().join("b.txt"), "match\n").unwrap(); @@ -1247,10 +1235,8 @@ mod tests { #[test] fn result_view_non_content_mode_falls_back_to_text() { - // files_with_matches and count have a summary header line that - // the structured GrepMatches shape doesn't model — rendering - // them through the default text body keeps the "Found N files" - // / "Found N total occurrences" line visible to the reader. + // files_with_matches / count have a summary header that GrepMatches doesn't model; + // the text fallback keeps the "Found N ..." line visible. for mode in ["files_with_matches", "count"] { let input = serde_json::json!({"pattern": "fn", "output_mode": mode}); let view = GrepTool.result_view(&input, "irrelevant", &ToolMetadata::default()); @@ -1273,8 +1259,7 @@ mod tests { #[test] fn parse_content_view_groups_consecutive_lines_with_context_and_separator() { - // Groups by path, distinguishes match (`:`) from context (`-`), - // accepts `--` separators, chains a second file into its own group. + // Groups by path, splits match (`:`) from context (`-`), accepts `--`, opens a second file. let content = indoc! {r#" src/main.rs:10:fn main() { src/main.rs:11- println!("hi"); @@ -1345,8 +1330,7 @@ mod tests { #[test] fn parse_content_view_falls_back_when_skipped_warnings_present() { - // Skipped-warning text isn't modelled by GrepMatches; fall back - // so the warning stays visible in the rendered text body. + // Skipped-warning text isn't in GrepMatches; fall back so it stays visible. let content = indoc! {" src/main.rs:1:hit @@ -1358,8 +1342,7 @@ mod tests { #[test] fn parse_content_view_falls_back_on_invalid_line() { - // Any unrecognised row triggers full fallback rather than a - // partial render that silently drops information. + // Any unrecognised row triggers full fallback rather than silently dropping it. assert!(parse_content_view("src/main.rs:1:hit\nunexpected line").is_none()); } @@ -1382,8 +1365,7 @@ mod tests { #[test] fn parse_match_line_skips_path_internal_colons_without_digit_separator() { - // Path-internal `:` without digits after (e.g., Windows `C:foo`) - // is skipped; the scanner accepts the next valid `:NUM` boundary. + // Path-internal `:` without digits (e.g., Windows `C:foo`) skips to the next `:NUM`. let (path, m) = parse_match_line("C:foo:42:body").unwrap(); assert_eq!(path, "C:foo"); assert_eq!(m.number, 42); @@ -1393,11 +1375,8 @@ mod tests { #[test] fn parse_match_line_is_none_when_shape_does_not_match() { - // No `:NUM:` / `:NUM-` pattern anywhere → fall through. assert!(parse_match_line("not a match line").is_none()); - // `path:` with non-digit content after → fall through. assert!(parse_match_line("path:non_digits:text").is_none()); - // `path:NUM` with no separator at all → fall through. assert!(parse_match_line("path:42").is_none()); } } diff --git a/crates/oxide-code/src/tool/read.rs b/crates/oxide-code/src/tool/read.rs index 0c11fae3..46b30edf 100644 --- a/crates/oxide-code/src/tool/read.rs +++ b/crates/oxide-code/src/tool/read.rs @@ -14,16 +14,10 @@ use super::{ }; use crate::file_tracker::{CACHE_HIT_STUB, FileTracker, LastView, RecordRead}; -/// Default line cap when the caller doesn't pass `limit`. const DEFAULT_LINE_LIMIT: usize = 2000; -/// Per-file size cap (10 MB). Fits typical large config / log -/// files; rejects accidental binary dumps. const MAX_READ_FILE_SIZE: u64 = 10 * 1024 * 1024; -/// Stand-in content for a zero-byte file. The producer writes it; the -/// `read_excerpt_view` consumer compares against it to render an empty -/// excerpt — the two paths must agree on the exact string. const EMPTY_FILE_MARKER: &str = "(empty file)"; pub(crate) struct ReadTool { @@ -121,6 +115,15 @@ async fn run(raw: serde_json::Value, tracker: Arc) -> ToolOutput { .with_title(format!("Read {name}")) } +/// Reads `path` as text and returns a tab-separated `LINENO\tTEXT` excerpt. +/// +/// Refuses directories, non-regular files (FIFO / socket / device — these can report `len() == 0` +/// and would otherwise bypass the size gate), files larger than `MAX_READ_FILE_SIZE`, and files +/// with a NUL byte in the first 8 KiB. A leading UTF-8 BOM is stripped before line splitting. +/// +/// Records the read with the [`FileTracker`] so a follow-up Edit / Write can clear the +/// Read-before-Edit gate. A no-arg read records [`LastView::Full`]; any `offset` or `limit` +/// records [`LastView::Partial`] so partial views never satisfy the full-read precondition. async fn read_file( path: &str, offset: Option, @@ -131,9 +134,7 @@ async fn read_file( .await .map_err(|e| format!("Error reading {path}: {e}"))?; - // Reject non-regular files: pseudo-files like /dev/urandom report - // len() == 0, bypassing the size gate below, and would stream without - // bound through tokio::fs::read. + // Pseudo-files report len() == 0, bypassing the size gate. let file_type = metadata.file_type(); if file_type.is_dir() { return Err(format!( @@ -163,8 +164,6 @@ async fn read_file( return Err("File appears to be binary. Use the bash tool to inspect binary files.".into()); } - // Capture mtime / size before formatting so the tracker entry - // matches the bytes the model is about to see. let mtime = metadata .modified() .map_err(|e| format!("Error reading {path}: {e}"))?; @@ -191,8 +190,6 @@ async fn read_file( )); } - // Per-line cap (truncate_line) and the row cap (limit) keep this - // bounded; the byte safety net lives in ToolRegistry::run. let mut output = String::new(); let mut num_shown: usize = 0; for (i, line) in lines[start_idx..].iter().enumerate().take(limit_n) { @@ -216,8 +213,6 @@ async fn read_file( Ok(stub_or(outcome, &output).into_owned()) } -/// Substitutes [`CACHE_HIT_STUB`] for the rendered excerpt when the -/// Read was a redundant full re-read, otherwise returns the excerpt. fn stub_or(outcome: RecordRead, body: &str) -> std::borrow::Cow<'_, str> { match outcome { RecordRead::CacheHit => std::borrow::Cow::Borrowed(CACHE_HIT_STUB), @@ -225,10 +220,6 @@ fn stub_or(outcome: RecordRead, body: &str) -> std::borrow::Cow<'_, str> { } } -/// Maps Read inputs to the recorded view. Default `(None, None)` is -/// `Full` so no-args re-Reads cache-hit; any explicit slice is -/// `Partial` even if it covers the whole file (the model asked for -/// a range, so a different range is a different question). fn view_for(offset: Option, limit: Option) -> LastView { match (offset, limit) { (None, None) => LastView::Full, @@ -266,13 +257,6 @@ fn read_excerpt_view(path: String, content: &str) -> Option { }) } -/// Splits the read tool's output on its `(Showing lines N–M of TOTAL total)` -/// view-shape footer. The footer is parsed here rather than carried in -/// metadata because the totals are a read-specific signal (line counts, -/// not byte counts); the byte safety net in [`crate::tool::ToolRegistry::run`] -/// uses a different metadata field. When the byte cap fires, the footer -/// is replaced by the truncation separator and this function returns -/// `None` for the footer — the caller falls through to the raw text view. fn split_read_footer(content: &str) -> (&str, Option<&str>) { match content.split_once("\n\n") { Some((body, footer)) if footer.starts_with("(Showing lines ") => (body, Some(footer)), @@ -324,8 +308,7 @@ mod tests { #[tokio::test] async fn run_records_full_read_in_tracker() { - // A no-args read populates the tracker as Full so a follow-up - // Edit clears the gate. + // A no-args read populates the tracker as Full so a follow-up Edit clears the gate. let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.txt"); std::fs::write(&path, "hello\n").unwrap(); @@ -373,8 +356,7 @@ mod tests { #[tokio::test] async fn run_partial_reread_does_not_cache_hit() { - // Partial reads always return the line-numbered slice so the - // model can re-request a different range. + // Partial reads always return the slice so the model can re-request a different range. let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.txt"); std::fs::write( @@ -535,8 +517,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn read_file_rejects_non_regular_file() { - // A unix-domain socket is a non-regular file with `metadata.len() == 0`, - // which would bypass the size gate if `file_type().is_file()` were skipped. + // A unix-domain socket has `len() == 0` and would bypass the size gate without is_file(). let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("sock"); let _listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); @@ -608,7 +589,7 @@ mod tests { ); } - // ── result_view ── + // ── read_excerpt_view ── #[test] fn result_view_builds_read_excerpt() { diff --git a/crates/oxide-code/src/tool/write.rs b/crates/oxide-code/src/tool/write.rs index faa825d8..55d3181d 100644 --- a/crates/oxide-code/src/tool/write.rs +++ b/crates/oxide-code/src/tool/write.rs @@ -106,8 +106,6 @@ async fn write_file( }; let is_new = pre_meta.is_none(); - // Existing files run the strict gate; new files bypass — nothing - // to clobber. if let Some(meta) = &pre_meta && let Err(msg) = check_gate(file_path, meta, path, tracker).await { @@ -136,10 +134,6 @@ async fn write_file( (Ok(msg), is_new) } -/// Existing-file gate. Stat-match short-circuits; on drift the file -/// is read once to confirm a content-preserving touch before letting -/// the write proceed. Structural rejects (never-read, partial-view) -/// surface as a model-facing `GateError` via `Display`. async fn check_gate( file_path: &Path, meta: &std::fs::Metadata, @@ -198,8 +192,7 @@ mod tests { #[tokio::test] async fn run_overwrites_existing_file_uses_updated_verb() { - // Pins the `Updated` arm of the verb branch; the `Created` - // arm is covered by `run_creates_file`. + // Pins the `Updated` arm; `Created` is covered by `run_creates_file`. let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("existing.txt"); std::fs::write(&path, "old").unwrap(); @@ -226,8 +219,7 @@ mod tests { #[tokio::test] async fn write_tool_run_dispatches_through_trait_to_inner_run() { - // Mirror of `edit_tool_run_dispatches_through_trait_to_inner_run` - // — pin the trait shim's `Arc::clone` of the tracker. + // Pin the trait shim's `Arc::clone` of the tracker. let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("new.txt"); let tracker_arc = Arc::new(FileTracker::default()); @@ -296,8 +288,7 @@ mod tests { #[tokio::test] async fn write_file_after_external_modification_is_rejected() { - // Read, then overwrite to simulate an external editor; the - // drift rehash surfaces "modified externally". + // Read, then overwrite to simulate an external editor; drift rehash surfaces the error. let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("existing.txt"); std::fs::write(&path, "old content").unwrap(); @@ -317,8 +308,7 @@ mod tests { #[tokio::test] async fn write_file_phantom_drift_passes_via_hash_match() { - // Cloud-sync shape on Write: stat moved but bytes match — - // the rehash fallback must accept. + // Cloud-sync shape: stat moved but bytes match — the rehash fallback must accept. let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("existing.txt"); std::fs::write(&path, "stable content").unwrap(); @@ -357,9 +347,7 @@ mod tests { #[tokio::test] async fn write_file_fails_when_parent_is_a_file() { - // Parent is a regular file → `metadata()` returns ENOTDIR; - // surfaced via the same `Error reading {path}: {e}` shape - // as edit.rs. + // Regular-file parent → ENOTDIR, surfaced via the same shape as edit.rs. let dir = tempfile::tempdir().unwrap(); let blocker = dir.path().join("blocker"); std::fs::write(&blocker, "I am a file").unwrap(); @@ -380,8 +368,7 @@ mod tests { #[tokio::test] async fn write_file_unread_directory_hits_strict_gate() { - // No Read entry → the gate fires before the OS would - // reject the write on the directory. + // No Read entry → the gate fires before the OS would reject the write. let dir = tempfile::tempdir().unwrap(); let (result, _) = write_file( dir.path().to_str().unwrap(), @@ -414,17 +401,14 @@ mod tests { err.contains("Failed to write file"), "expected write-failure error, got: {err}", ); - // Permission denial did not corrupt the original bytes. assert_eq!(std::fs::read_to_string(&path).unwrap(), "original"); } #[cfg(unix)] #[tokio::test] async fn write_file_fails_when_parent_creation_is_denied() { - // Missing parent under a read-only ancestor: pre-stat hits - // ENOENT (walks into the missing component) so control - // reaches `create_dir_all`, which then denies. Ancestor - // stays empty so tempdir can rmdir it during cleanup. + // Missing parent under a read-only ancestor: pre-stat hits ENOENT so control reaches + // `create_dir_all`, which then denies. Ancestor stays empty for tempdir cleanup. use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); diff --git a/crates/oxide-code/src/tui.rs b/crates/oxide-code/src/tui.rs index 9b21c010..01d8ee3e 100644 --- a/crates/oxide-code/src/tui.rs +++ b/crates/oxide-code/src/tui.rs @@ -1,9 +1,8 @@ //! 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; [`theme`] centralizes -//! all styling. +//! 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; pub(crate) mod components; diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index 6b85eac3..ef98513c 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -1,11 +1,8 @@ //! Root TUI application. //! -//! [`App`] owns every component (chat, input, status), holds the -//! cross-task channels, and runs the `tokio::select!` loop that -//! multiplexes crossterm events, agent events, user actions, and a -//! 60 FPS render tick. Render coalescing (dirty flag + timer) keeps -//! redraw work proportional to state change rather than event -//! throughput. +//! [`App`] owns every component, holds the cross-task channels, and runs the `tokio::select!` +//! loop multiplexing crossterm events, agent events, user actions, and a 60 FPS render tick. +//! A dirty flag coalesces redraws so render work tracks state change, not event throughput. use std::collections::{HashMap, VecDeque}; use std::sync::Arc; @@ -28,30 +25,25 @@ use super::terminal::{Tui, draw_sync}; use super::theme::Theme; use crate::agent::event::{AgentEvent, UserAction}; use crate::message::Message; -use crate::slash::{self, SessionInfo, SlashContext, SlashKind}; +use crate::slash::{self, LiveSessionInfo, SlashContext, SlashKind}; use crate::tool::{ToolMetadata, ToolRegistry, ToolResultView}; use crate::util::text::truncate_to_width; /// Tick interval for animation frames and render coalescing (~60 FPS). const TICK_INTERVAL: Duration = Duration::from_millis(16); -/// Window in which a second Ctrl+C confirms exit. 1 s — comfortable for -/// a deliberate double-tap, short enough that the hint doesn't linger. +/// Window in which a second Ctrl+C confirms exit. const EXIT_WINDOW: Duration = Duration::from_secs(1); -/// Maximum queued prompts shown in the preview before the row collapses -/// into a `+N more` overflow tag. The full FIFO drains regardless. +/// Maximum queued prompts shown in the preview before collapsing into `+N more`. const PREVIEW_VISIBLE: usize = 3; -/// Root application state. Owns all components and drives the render loop. pub(crate) struct App { theme: Theme, status_bar: StatusBar, chat: ChatView, input: InputArea, - /// Frozen snapshot of session-level descriptors that `/status`, - /// `/config`, and other read-only slash commands surface. - session_info: SessionInfo, + session_info: LiveSessionInfo, agent_rx: mpsc::Receiver, user_tx: mpsc::Sender, tools: Arc, @@ -59,10 +51,8 @@ pub(crate) struct App { pending_calls: PendingCalls, /// FIFO of prompts submitted mid-turn; drained at turn boundaries. pending_prompts: VecDeque, - /// Active modal overlay(s). Empty when no modal is on screen. modals: ModalStack, should_quit: bool, - /// Whether state has changed since the last render. dirty: bool, } @@ -73,7 +63,7 @@ impl App { )] pub(crate) fn new( theme: &Theme, - session_info: SessionInfo, + session_info: LiveSessionInfo, show_thinking: bool, title: Option, agent_rx: mpsc::Receiver, @@ -107,7 +97,7 @@ impl App { } } - /// Main event loop. Runs until the user quits or the agent channel closes. + /// Runs until the user quits or the agent channel closes. pub(crate) async fn run(&mut self, terminal: &mut Tui) -> Result<()> { self.run_with_events(terminal, EventStream::new()).await } @@ -127,6 +117,11 @@ impl App { self.render(terminal)?; loop { + // Three concurrent sources race to wake the loop. `select!` picks pseudo-randomly + // among ready arms, which is the property we want — neither input nor agent output + // can starve the other under sustained load. The tick arm is the only one that + // renders; per-event handlers just flip `dirty`, so render work is paced by the + // 60 FPS clock instead of event volume. tokio::select! { event = crossterm_events.next() => { if let Some(Ok(event)) = event { @@ -136,6 +131,7 @@ impl App { event = self.agent_rx.recv() => { match event { Some(event) => self.handle_agent_event(event), + // Channel closed = agent task gone; quit instead of spinning. None => self.should_quit = true, } } @@ -164,8 +160,10 @@ impl App { // ── Event Handling ── fn handle_crossterm_event(&mut self, event: &Event) { - // First-priority: an active modal owns keyboard focus end-to-end. - // Other components don't see the key until the modal closes. + // Modal gate: while any modal is on screen, all key events route to it exclusively — + // never the input area or chat. Mouse / resize still fall through so the user can scroll + // chat under an overlay. The early return keeps the modal's key from also being seen by + // the input on the same frame. if let Event::Key(key) = event && self.modals.is_active() { @@ -202,7 +200,6 @@ impl App { self.dirty = true; } - /// Dispatcher for actions emitted by a closed modal. fn apply_modal_action(&mut self, action: ModalAction) { match action { ModalAction::None => {} @@ -216,7 +213,7 @@ impl App { self.dirty = true; } - /// Routes Esc: cancel if busy, pop queue if idle+empty, else no-op. + /// Cancel if busy; pop queue if idle and buffer empty; else no-op. fn handle_esc(&mut self) { if !self.input.is_enabled() { self.dispatch_user_action(UserAction::Cancel); @@ -229,6 +226,10 @@ impl App { } /// Applies UI side-effects then forwards to the agent channel. + /// + /// `apply_action_locally` returns `false` when the action was fully handled in the UI (slash + /// command synthesized its own forward, prompt was queued mid-turn, exit-arm was set, etc.), + /// gating the forward so the agent loop never sees actions that were never meant for it. fn dispatch_user_action(&mut self, action: UserAction) { if !self.apply_action_locally(&action) { return; @@ -236,9 +237,7 @@ impl App { self.forward_to_agent(action); } - /// Send `action` to the agent loop; channel errors land as a chat - /// error block. Reused by the slash `Action(_)` branch — both - /// `Action(SubmitPrompt(_))` and `Action(Clear)` flow through here. + /// Sends `action` to the agent loop; channel errors surface as a chat error block. fn forward_to_agent(&mut self, action: UserAction) { if let Err(e) = self.user_tx.try_send(action) { match e { @@ -310,6 +309,9 @@ impl App { } self.pending_prompts.push_back(text.clone()); self.sync_input_queue_hint(); + // Forward the queued prompt to the agent so it can drain at turn end — + // unless we're already cancelling, in which case the agent will reset and + // the queue drains locally on the next idle transition. !matches!(self.status_bar.status(), Status::Cancelling) } } @@ -442,7 +444,6 @@ impl App { self.drain_pending_prompt(); } - /// Pops the front of the queue and dispatches as a fresh submit. fn drain_pending_prompt(&mut self) { if let Some(prompt) = self.pending_prompts.pop_front() { self.dispatch_user_action(UserAction::SubmitPrompt(prompt)); @@ -464,8 +465,7 @@ impl App { } } - /// Returns `true` when an [`Status::ExitArmed`] window has elapsed - /// and the bar was reset to idle. + /// Returns `true` when an [`Status::ExitArmed`] window has elapsed and the bar was reset. fn expire_armed_exit(&mut self) -> bool { if let Status::ExitArmed { until } = self.status_bar.status() && Instant::now() >= *until @@ -494,9 +494,7 @@ impl App { Ok(()) } - /// Returns the chat area so the caller can refresh scroll-cache - /// bookkeeping. Backend-agnostic (takes `&mut Frame`) so `TestBackend` - /// tests share the live-crossterm layout path. + /// Returns the chat area so the scroll cache can refresh its layout. fn draw_frame(&mut self, frame: &mut ratatui::Frame<'_>) -> ratatui::layout::Rect { let input_height = self.input.height(); let preview_height = self.preview_height(); @@ -560,10 +558,7 @@ impl App { } } -/// Renders a single queued prompt as a dim user-message ghost, capped -/// at `body_width` columns (excluding the chevron gutter). CJK / emoji -/// are billed at their display width via `unicode-width` so the -/// truncation budget matches what the user actually sees. +/// Renders a queued prompt as a dim ghost, capped at `body_width` columns. fn preview_line(prompt: &str, theme: &Theme, body_width: usize) -> Line<'static> { use ratatui::style::Modifier; @@ -576,10 +571,7 @@ fn preview_line(prompt: &str, theme: &Theme, body_width: usize) -> Line<'static> ]) } -/// `AgentEvent::ConfigChanged` confirmation. Surfaces silent effort -/// shifts — model-driven (cleared / clamped / model-default) and -/// user-driven (clamped pick / lost on no-tier model) — so the user -/// sees the resulting state, not just a generic "OK". +/// `AgentEvent::ConfigChanged` confirmation, surfacing silent effort shifts. fn format_config_change( marketing: &str, model_id: &str, @@ -595,8 +587,7 @@ fn format_config_change( (Some(req), None) => { format!("Effort unchanged — model has no effort tier (asked for {req}).") } - // No-op SwapConfig — slash dispatch keeps this unreachable - // in practice, but a clear fallback beats a panic. + // Slash dispatch keeps this unreachable, but a clear fallback beats a panic. (None, _) => "Config unchanged.".to_owned(), }; } @@ -632,18 +623,15 @@ mod tests { use super::*; use crate::tool::ToolRegistry; - /// Fresh idle `App` plus the `user_tx` consumer (for forwarded-action - /// assertions) and the `agent_tx` producer (kept alive so the - /// `agent_rx` side doesn't close on construction). + /// Idle `App` plus the `user_tx` consumer and an `agent_tx` kept alive so `agent_rx` stays + /// open. fn test_app( title: Option<&str>, ) -> (App, mpsc::Receiver, mpsc::Sender) { test_app_with_registry(title, Arc::new(ToolRegistry::new(Vec::new()))) } - /// Variant that plumbs the real tool catalog into the `App` so - /// `ToolCallStart` label lookups match what production would render. - /// Used by tool-event tests that exercise the Start → End flow. + /// Variant with the real tool catalog so `ToolCallStart` labels match production. fn test_app_with_tools() -> (App, mpsc::Receiver, mpsc::Sender) { let tracker = crate::file_tracker::testing::tracker(); let tools = ToolRegistry::new(vec![ @@ -677,13 +665,12 @@ mod tests { (app, user_rx, agent_tx) } - fn test_session_info() -> SessionInfo { - // `model_id = "test-model"` is intentionally unknown so - // `marketing_or_id` falls back to the literal id, keeping - // every TUI insta snapshot stable as `test-model`. + fn test_session_info() -> LiveSessionInfo { + // `test-model` is intentionally unknown so `marketing_or_id` falls back to the + // literal id, keeping insta snapshots stable. use crate::config::{ConfigSnapshot, Effort, PromptCacheTtl}; - SessionInfo { + LiveSessionInfo { cwd: "~/test".to_owned(), version: "0.0.0-test", session_id: "test-session".to_owned(), @@ -749,8 +736,7 @@ mod tests { #[test] fn new_whitespace_title_is_filtered_by_status_bar() { - // Status bar filters whitespace-only titles, so plumbing such a - // value from `SessionData` won't leave a blank slot in the bar. + // Status bar filters whitespace-only titles so a resumed blank title doesn't leave a slot. let (app, _rx, _agent_tx) = test_app(Some(" \n ")); assert!(app.status_bar.title().is_none()); } @@ -786,8 +772,7 @@ mod tests { #[tokio::test] async fn run_with_events_marks_dirty_when_spinner_frame_advances() { - // Streaming spinner flips frames after 5 * 16ms = 80ms, so - // sleep past that to drive `status_bar.tick()` truthy. + // Streaming spinner flips after 5 * 16ms = 80ms; sleep past that to drive `tick()` truthy. let (mut app, _rx, agent_tx) = test_app(None); app.status_bar.set_status(Status::Streaming); app.dirty = false; @@ -811,9 +796,8 @@ mod tests { #[tokio::test] async fn run_with_events_pumps_crossterm_and_agent_events_through_select() { - // Pin the crossterm and agent arms of the select! loop: - // a key reaches the input, a stream token disables it, and - // closing the agent channel ends the loop. + // Pins both arms of `select!`: a key reaches input, a stream token disables it, + // and closing the agent channel ends the loop. let (mut app, _rx, agent_tx) = test_app(None); let (mut terminal, _buf) = crossterm_test_terminal(60, 8); @@ -853,9 +837,8 @@ mod tests { #[tokio::test] async fn handle_crossterm_key_submit_forwards_through_input_to_dispatch() { let (mut app, mut rx, _agent_tx) = test_app(None); - // Simulate typing "hi" then Enter — the input area composes the - // prompt and returns `SubmitPrompt`, which `handle_crossterm_event` - // must pipe into `dispatch_user_action`. + // Type "hi" then Enter; input returns `SubmitPrompt` which must reach + // `dispatch_user_action`. app.handle_crossterm_event(&key_event(KeyCode::Char('h'), KeyModifiers::NONE)); app.handle_crossterm_event(&key_event(KeyCode::Char('i'), KeyModifiers::NONE)); app.handle_crossterm_event(&key_event(KeyCode::Enter, KeyModifiers::NONE)); @@ -881,10 +864,7 @@ mod tests { #[tokio::test] async fn handle_crossterm_key_ctrl_c_busy_forwards_cancel_without_quitting() { - // Mid-turn Ctrl+C must reach the agent loop as `Cancel` so it - // can drop the future, not flip `should_quit` and tear down - // the TUI. Drive the app into the streaming state first to - // mirror production: input disabled => cancel branch fires. + // Mid-turn Ctrl+C must reach the agent loop as `Cancel`, not flip `should_quit`. let (mut app, mut rx, _agent_tx) = test_app(None); app.handle_agent_event(AgentEvent::StreamToken("partial".into())); assert!(!app.input.is_enabled()); @@ -908,8 +888,7 @@ mod tests { #[test] fn handle_crossterm_mouse_is_forwarded_to_chat() { - // Mouse events reach `ChatView::handle_event` which consumes them - // for scroll. Assert the dirty flag flips so the next tick renders. + // Mouse events reach `ChatView::handle_event` for scroll; the dirty flag must flip. let (mut app, _rx, _agent_tx) = test_app(None); app.handle_crossterm_event(&Event::Mouse(MouseEvent { kind: MouseEventKind::ScrollDown, @@ -922,9 +901,7 @@ mod tests { #[test] fn handle_crossterm_resize_schedules_dirty_for_relayout() { - // Resize matches the arm that does no per-component work but still - // falls through to `self.dirty = true`, so the next tick re-runs - // the layout split with the new `frame.area()`. + // Resize falls through to `dirty = true` so the next tick re-splits with the new area. let (mut app, _rx, _agent_tx) = test_app(None); app.dirty = false; app.handle_crossterm_event(&Event::Resize(80, 24)); @@ -933,10 +910,7 @@ mod tests { #[test] fn handle_crossterm_unknown_event_is_a_noop() { - // The `_ => return` arm covers FocusGained/FocusLost/Paste — the - // early return here is significant: every other arm falls through - // to `self.dirty = true`, so without this branch a stream of - // unhandled terminal events would cause continuous re-renders. + // The `_ => return` arm prevents FocusGained / FocusLost / Paste from forcing re-renders. let (mut app, _rx, _agent_tx) = test_app(None); app.dirty = false; app.handle_crossterm_event(&Event::FocusGained); @@ -945,8 +919,7 @@ mod tests { #[test] fn handle_crossterm_scroll_key_routes_to_chat_while_input_disabled() { - // When the input is disabled (mid-stream), arrow / page keys - // must still reach chat so the user can scroll through output. + // While input is disabled, arrow / page keys must still reach chat for scrolling. let (mut app, _rx, _agent_tx) = test_app(None); app.input.set_enabled(false); app.handle_crossterm_event(&key_event(KeyCode::PageUp, KeyModifiers::NONE)); @@ -955,10 +928,8 @@ mod tests { #[tokio::test] async fn handle_crossterm_popup_enter_dispatches_canonical_command() { - // Typing `/h` filters the popup to /help; Enter on the - // highlighted row submits it through the existing dispatch - // path. /help is read-only, so the chat lands a system - // message instead of forwarding to the agent. + // `/h` filters to /help; Enter dispatches it. /help is read-only so chat + // lands a system message instead of forwarding to the agent. let (mut app, mut rx, _agent_tx) = test_app(None); app.handle_crossterm_event(&key_event(KeyCode::Char('/'), KeyModifiers::NONE)); app.handle_crossterm_event(&key_event(KeyCode::Char('h'), KeyModifiers::NONE)); @@ -979,10 +950,8 @@ mod tests { #[test] fn handle_crossterm_popup_tab_completes_canonical_name_into_buffer() { - // Tab on a popup row inserts `/{name} ` and hides the popup — - // the user is now in args-typing mode. Filter to /help first - // so the test pins the completion shape independent of the - // BUILT_INS-ordered first row. + // Tab inserts `/{name} ` and hides the popup. Filter to /help so the test pins + // the completion shape independent of BUILT_INS ordering. let (mut app, _rx, _agent_tx) = test_app(None); app.handle_crossterm_event(&key_event(KeyCode::Char('/'), KeyModifiers::NONE)); app.handle_crossterm_event(&key_event(KeyCode::Char('h'), KeyModifiers::NONE)); @@ -1001,10 +970,7 @@ mod tests { #[tokio::test] async fn modal_gate_intercepts_keys_before_input_sees_them() { - // While a modal is on screen, any key event lands on the modal - // first — the input area must NOT receive them. Pin so a - // regression that fans keys to both surfaces (double-handling - // a single keystroke) fails here. + // While a modal is on screen, keys land on the modal only — never the input. use crate::tui::modal::ModalAction; use crate::tui::modal::testing::ScriptedModal; @@ -1013,7 +979,6 @@ mod tests { UserAction::Cancel, )))); - // Type a printable that the input area would otherwise capture. app.handle_crossterm_event(&key_event(KeyCode::Char('x'), KeyModifiers::NONE)); assert!( app.input.lines().iter().all(String::is_empty), @@ -1024,8 +989,7 @@ mod tests { "no UserAction must reach user_tx for a key the modal consumed", ); - // Submit the modal — its action flows through the normal - // dispatch path, just like a keyboard-typed UserAction. + // Modal submit flows through the normal dispatch path. app.handle_crossterm_event(&key_event(KeyCode::Char('s'), KeyModifiers::NONE)); let forwarded = rx.recv().await.expect("modal-submitted action forwarded"); assert!(matches!(forwarded, UserAction::Cancel)); @@ -1033,8 +997,7 @@ mod tests { #[test] fn modal_gate_cancel_closes_modal_without_dispatching() { - // `ModalKey::Cancelled` pops the modal but does not dispatch a - // UserAction. The next key must reach the input area. + // `ModalKey::Cancelled` pops the modal without dispatching; next key reaches input. use crate::tui::modal::ModalAction; use crate::tui::modal::testing::ScriptedModal; @@ -1055,8 +1018,7 @@ mod tests { #[tokio::test] async fn handle_esc_busy_dispatches_cancel() { - // Esc is routed by `App` because its meaning depends on queue - // state and run-state; the input component can't see those. + // Esc is routed by `App` because its meaning depends on queue state and run-state. let (mut app, mut rx, _agent_tx) = test_app(None); app.handle_agent_event(AgentEvent::StreamToken("partial".into())); app.handle_crossterm_event(&key_event(KeyCode::Esc, KeyModifiers::NONE)); @@ -1075,8 +1037,7 @@ mod tests { #[test] fn handle_esc_idle_with_queue_pops_most_recent_into_input() { - // The most-recent (back of the FIFO) returns to the input for - // editing; the rest stay queued so the user can keep peeling. + // The most-recent (back of FIFO) returns to input; the rest stay queued. let (mut app, _rx, _agent_tx) = test_app(None); app.pending_prompts.push_back("first".into()); app.pending_prompts.push_back("second".into()); @@ -1092,8 +1053,7 @@ mod tests { #[test] fn handle_esc_idle_with_buffer_content_refuses_pop() { - // Esc must not clobber an in-progress draft. The user has to - // clear the buffer (or submit) before peeling a queued prompt. + // Esc must not clobber an in-progress draft. let (mut app, _rx, _agent_tx) = test_app(None); app.pending_prompts.push_back("queued".into()); app.input.set_text("draft"); @@ -1109,10 +1069,7 @@ mod tests { #[test] fn handle_esc_with_popup_visible_dismisses_popup_and_leaves_queue_intact() { - // The popup gate sits in front of App's Esc routing so a - // visible popup can swallow Esc before queue / cancel logic - // fires. Open the popup by typing `/`, queue a prompt, then - // press Esc — popup hides, queue stays put. + // The popup gate sits in front of App's Esc routing — popup hides, queue stays put. let (mut app, _rx, _agent_tx) = test_app(None); app.pending_prompts.push_back("queued".into()); app.handle_crossterm_event(&key_event(KeyCode::Char('/'), KeyModifiers::NONE)); @@ -1145,8 +1102,7 @@ mod tests { #[tokio::test] async fn dispatch_slash_command_renders_locally_without_forwarding() { - // Slash commands must stay client-side: user message + command - // output land in chat, agent loop never sees the prompt. + // Slash commands stay client-side; the agent loop never sees the prompt. let (mut app, mut rx, _agent_tx) = test_app(None); app.dispatch_user_action(UserAction::SubmitPrompt("/help".to_owned())); @@ -1186,8 +1142,7 @@ mod tests { #[tokio::test] async fn dispatch_double_slash_escapes_command_and_forwards_literal() { - // `//foo` parses as "not a command", so the agent receives the - // bytes verbatim — pin so a future prefix check can't swallow it. + // `//foo` parses as "not a command", so the agent receives the bytes verbatim. let (mut app, mut rx, _agent_tx) = test_app(None); app.dispatch_user_action(UserAction::SubmitPrompt("//etc/hosts".to_owned())); @@ -1204,12 +1159,9 @@ mod tests { #[tokio::test] async fn dispatch_cancel_flips_status_to_cancelling_and_forwards() { - // Cancel acknowledges the user request immediately by flipping - // the status; the matching `AgentEvent::Cancelled` returns to - // idle once the agent loop has actually dropped the future. + // Cancel flips status immediately; `AgentEvent::Cancelled` later returns to idle. let (mut app, mut rx, _agent_tx) = test_app(None); app.dispatch_user_action(UserAction::SubmitPrompt("hi".to_owned())); - // Drain the SubmitPrompt to isolate the Cancel payload. rx.recv().await.expect("submit forwarded"); let entries_before = app.chat.entry_count(); @@ -1229,16 +1181,13 @@ mod tests { assert!(app.should_quit); assert_eq!(app.chat.entry_count(), 0); - // Status bar stays idle — Quit flows past the streaming setup so - // the tear-down path doesn't have to un-spinner it. + // Quit flows past streaming setup so tear-down doesn't have to un-spinner. assert_eq!(app.status_bar.status(), &Status::Idle); } #[test] fn dispatch_closed_channel_surfaces_error_and_quits() { - // Dropping `user_rx` simulates the agent task exiting — try_send - // returns `Closed`. The UI must announce the failure and tear - // itself down so the user isn't left staring at a stuck spinner. + // Dropping `user_rx` simulates the agent task exiting; the UI must announce and tear down. let (mut app, rx, _agent_tx) = test_app(None); drop(rx); @@ -1249,7 +1198,7 @@ mod tests { !app.input.is_enabled(), "input stays disabled during teardown" ); - // User message pushed before try_send, error block after — two entries. + // User message pushed before try_send, error block after. assert_eq!(app.chat.entry_count(), 2); assert!( app.chat.last_is_error(), @@ -1259,10 +1208,8 @@ mod tests { #[test] fn dispatch_full_channel_surfaces_error_but_keeps_app_alive() { - // Fill the 8-slot channel without draining, then overflow. - // `Cancel` still goes through `try_send` (the queue only routes - // submits) so it's the natural way to overflow now that submits - // during a busy turn buffer locally instead of forwarding. + // `Cancel` always goes through `try_send`, so it's the natural way to overflow + // (busy-turn submits buffer locally instead). let (mut app, _rx, _agent_tx) = test_app(None); app.dispatch_user_action(UserAction::SubmitPrompt("active".to_owned())); for _ in 0..7 { @@ -1289,10 +1236,8 @@ mod tests { app.dispatch_user_action(UserAction::SubmitPrompt("queued".to_owned())); - // Buffered for the preview pane AND forwarded so `agent_turn` - // can splice it into the same multi-step turn at the next - // round boundary. The chat history stays untouched until the - // matching `PromptDrained` event lands. + // Buffered for preview AND forwarded so `agent_turn` can splice it at the next + // round boundary. Chat stays untouched until `PromptDrained` lands. assert_eq!( app.pending_prompts.iter().cloned().collect::>(), vec!["queued".to_owned()], @@ -1333,14 +1278,8 @@ mod tests { #[tokio::test] async fn dispatch_submit_during_cancelling_holds_locally_without_forwarding() { - // Cancel-window FIFO authority: between the user pressing Esc - // and the matching `AgentEvent::Cancelled` arriving, the agent's - // outer `recv` is racing for the next signal. Forwarding a fresh - // submit here lets it slip ahead of `pending_prompts`'s existing - // head — the agent picks it up and starts a new turn while older - // queued items fall behind. Hold mid-turn submits locally; - // `finalize_idle`'s drain re-fires them in order after `Cancelled` - // lands. + // Cancel-window FIFO authority: forwarding a submit during cancel could let it + // slip ahead of `pending_prompts`. Hold locally until `Cancelled`, then drain. let (mut app, mut rx, _agent_tx) = test_app(None); app.dispatch_user_action(UserAction::SubmitPrompt("active".to_owned())); rx.recv().await.expect("active submit forwarded"); @@ -1363,9 +1302,8 @@ mod tests { #[tokio::test] async fn dispatch_read_only_slash_during_busy_runs_client_side_without_queueing() { - // Read-only slash commands typed during a busy turn must run - // immediately. Otherwise the queue-drain path persists them as - // user prompts and the LLM ends up answering `/help` literally. + // Read-only slash commands during busy must run immediately, not queue — + // otherwise the LLM ends up answering `/help` literally on drain. let (mut app, mut rx, _agent_tx) = test_app(None); app.dispatch_user_action(UserAction::SubmitPrompt("active".to_owned())); rx.recv().await.expect("active submit forwarded"); @@ -1381,16 +1319,14 @@ mod tests { matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty)), "slash command must not forward to user_tx", ); - // User-message + dispatched help block, on top of the active prompt. assert_eq!(app.chat.entry_count(), 3); assert!(!app.chat.last_is_error()); } #[tokio::test] async fn dispatch_clear_during_busy_refuses_with_system_message_no_dispatch() { - // State-mutating commands must refuse mid-turn — rolling the - // session while `messages` is still draining would race the - // in-flight turn into the wrong JSONL. + // State-mutating commands refuse mid-turn — rolling while `messages` is still draining + // would race the in-flight turn into the wrong JSONL. let (mut app, mut rx, _agent_tx) = test_app(None); app.dispatch_user_action(UserAction::SubmitPrompt("active".to_owned())); rx.recv().await.expect("active submit forwarded"); @@ -1411,7 +1347,7 @@ mod tests { #[tokio::test] async fn dispatch_init_forwards_synthesized_prompt_and_flips_to_streaming() { - // The chat shows only `/init`; the agent must receive the body. + // Chat shows only `/init`; the agent must receive the synthesized body. let (mut app, mut rx, _agent_tx) = test_app(None); app.dispatch_user_action(UserAction::SubmitPrompt("/init".to_owned())); @@ -1430,8 +1366,7 @@ mod tests { #[tokio::test] async fn dispatch_init_during_busy_refuses_with_system_message_no_forward() { - // Mutating ⇒ refuse. The typed `/init` row still lands; only - // the synthesized body is suppressed. + // Mutating: refuse. Typed `/init` row still lands; only the synthesized body suppresses. let (mut app, mut rx, _agent_tx) = test_app(None); app.dispatch_user_action(UserAction::SubmitPrompt("active".to_owned())); rx.recv().await.expect("active submit forwarded"); @@ -1457,8 +1392,7 @@ mod tests { #[tokio::test] async fn dispatch_arg_bearing_slash_during_busy_refuses_with_system_message_no_forward() { - // `/model ` and `/effort ` both mutate the live - // Client and must wait for idle. Pinning both so a regression + // `/model ` and `/effort ` both mutate Client; pin both so a regression // that special-cases only one leaks the other. for (cmd, gate_phrase) in [ ("/model opus", "/model runs only when idle"), @@ -1481,9 +1415,8 @@ mod tests { #[tokio::test] async fn dispatch_bare_slash_during_busy_opens_modal_picker() { - // Bare `/model` classifies as ReadOnly so it dispatches mid-turn and opens the picker - // modal instead of printing a list. (`/effort` is Mutating regardless of args after the - // typed-arg-only refactor — its bare-form busy path is covered by + // Bare `/model` is ReadOnly so it dispatches mid-turn into the picker modal. (`/effort` is + // always Mutating after the typed-arg-only refactor; its bare-form busy path is covered by // `dispatch_arg_bearing_slash_during_busy_refuses_with_system_message_no_forward`.) let (mut app, _rx, _agent_tx) = test_app(None); app.dispatch_user_action(UserAction::SubmitPrompt("active".to_owned())); @@ -1500,8 +1433,7 @@ mod tests { async fn dispatch_unknown_slash_during_busy_renders_error_no_queue() { // Unknown commands route through `dispatch` so the user sees // the canonical "unknown command" error with recovery hints - // (alternatives + `//` escape) instead of the prompt being - // silently sent to the LLM. + // (alternatives + `//` escape) instead of the prompt being silently sent to the LLM. let (mut app, mut rx, _agent_tx) = test_app(None); app.dispatch_user_action(UserAction::SubmitPrompt("active".to_owned())); rx.recv().await.expect("active submit forwarded"); @@ -1532,8 +1464,7 @@ mod tests { #[test] fn handle_session_title_updated_drops_event_for_stale_session_id() { // Title task spawned before `/clear` finishes after the roll; - // its event must not paint the old session's title onto the - // current one. + // its event must not paint the old session's title onto the current one. let (mut app, _rx, _agent_tx) = test_app(Some("First prompt")); app.handle_agent_event(AgentEvent::SessionTitleUpdated { session_id: "different-session".to_owned(), @@ -1550,8 +1481,7 @@ mod tests { fn handle_config_changed_with_model_swap_refreshes_status_bar_session_info_and_chat() { // Three surfaces refresh in one shot: status-bar label, // `session_info` (backs `/status` / `/config`), and a chat - // confirmation block. Marketing name is derived locally from - // `model_id`. + // confirmation block. Marketing name is derived locally from `model_id`. let (mut app, _rx, _agent_tx) = test_app(None); app.handle_agent_event(AgentEvent::ConfigChanged { model_id: "claude-sonnet-4-6".to_owned(), @@ -1686,8 +1616,7 @@ mod tests { #[test] fn format_config_change_swap_with_explicit_effort_clamped_against_new_caps() { // Combined picker case: user asks for xhigh on Sonnet (caps at - // high). Surface that the *requested* tier was clamped — not - // the previous-effort delta. + // high). Surface that the *requested* tier was clamped — not the previous-effort delta. let s = format_config_change( "Claude Sonnet 4.6", "claude-sonnet-4-6", @@ -1789,8 +1718,7 @@ mod tests { fn handle_thinking_token_routes_to_chat_and_marks_streaming() { // Thinking tokens land in the chat view as a separate block // (not interleaved with assistant text) and must flip the bar - // to Streaming so the user sees the agent is working even - // before any visible text arrives. + // to Streaming so the user sees the agent is working even before any visible text arrives. let (mut app, _rx, _agent_tx) = test_app(None); app.handle_agent_event(AgentEvent::ThinkingToken("planning...".to_owned())); assert_eq!(app.status_bar.status(), &Status::Streaming); @@ -1851,8 +1779,7 @@ mod tests { // have queued StreamToken / ToolCallStart events the agent emitted // before its select arm dropped the turn future. Those buffered // events must not flip the bar back to Streaming / ToolRunning — - // otherwise the cancel acknowledgement flickers off until - // `Cancelled` finally arrives. + // otherwise the cancel acknowledgement flickers off until `Cancelled` finally arrives. let (mut app, _rx, _agent_tx) = test_app(None); app.dispatch_user_action(UserAction::SubmitPrompt("hi".to_owned())); app.dispatch_user_action(UserAction::Cancel); @@ -1896,8 +1823,7 @@ mod tests { // aborted before `.with_title(...)`. The result must still be // pushed (silent swallow would hide the error body) and the // header must render the tool-call label stashed at - // `ToolCallStart`, not a blank string or the generic - // `(result)` fallback. + // `ToolCallStart`, not a blank string or the generic `(result)` fallback. let (mut app, _rx, _agent_tx) = test_app_with_tools(); app.handle_agent_event(AgentEvent::ToolCallStart { id: "t1".to_owned(), @@ -1919,8 +1845,7 @@ mod tests { // The result header must be the stashed call label (the bash // command). It appears twice in the rendered view — once for // the tool call line, once for the result header — which is - // what we want to confirm: both the call row and the result - // row carry the same label. + // what we want to confirm: both the call row and the result row carry the same label. let text = rendered_text(&mut app, 60, 8); let occurrences = text.matches("distinctive_label_xyz").count(); assert_eq!( @@ -2052,8 +1977,7 @@ mod tests { #[tokio::test] async fn cancelled_drains_queue_head_to_match_completed_path() { // Cancellation does not auto-clear the queue — a user who - // interrupts a stuck turn typically still wants their planned - // follow-up to fire. + // interrupts a stuck turn typically still wants their planned follow-up to fire. let (mut app, mut rx, _agent_tx) = test_app(None); app.dispatch_user_action(UserAction::SubmitPrompt("active".to_owned())); rx.recv().await.expect("active submit forwarded"); diff --git a/crates/oxide-code/src/tui/components/chat.rs b/crates/oxide-code/src/tui/components/chat.rs index 684095c3..c7f39e49 100644 --- a/crates/oxide-code/src/tui/components/chat.rs +++ b/crates/oxide-code/src/tui/components/chat.rs @@ -1,10 +1,5 @@ -//! Chat view — the scrollable message list. -//! -//! Each visible unit in the transcript (user messages, assistant -//! replies, tool calls and results, errors) is a [`blocks::ChatBlock`] -//! implementation. [`ChatView`] is the thin container: it appends -//! blocks, owns the streaming buffer, handles scroll state, and stacks -//! `render` outputs with appropriate blank-line separators. +//! Scrollable chat view. Each visible transcript unit is a [`blocks::ChatBlock`] impl; +//! [`ChatView`] owns the streaming buffer and stacks block renders with separator lines. mod blocks; @@ -30,23 +25,15 @@ use crate::tui::theme::Theme; /// Scrollable chat message list with auto-scroll. pub(crate) struct ChatView { - // Config theme: Theme, show_thinking: bool, - // Committed blocks blocks: Vec>, - // Transient state (cleared per turn) - /// In-flight assistant tokens with a rendered-prefix cache. streaming: Option, - /// Live thinking tokens for the current block; flushed into a - /// committed [`AssistantThinking`] block on stream start or turn end. thinking_buffer: String, - // View state scroll_offset: u16, - /// Updated during render to avoid a redundant `build_text` call. content_height: Cell, viewport_height: u16, viewport_width: u16, @@ -141,7 +128,9 @@ impl ChatView { } /// Appends a streamed token to the current assistant response. - /// Any pending thinking is committed first. + /// + /// Pending thinking is committed first so a `Thinking → Text` boundary always lands at a + /// block split. Auto-scroll only follows the tail when the user hasn't manually scrolled up. pub(crate) fn append_stream_token(&mut self, token: &str) { self.commit_thinking_buffer(); self.streaming @@ -161,8 +150,10 @@ impl ChatView { } } - /// Finalize the streaming buffer into a committed block. Flushes - /// pending thinking first so thinking-only turns still leave a block. + /// Finalizes the streaming buffer into a committed block. + /// + /// Pending thinking is flushed first so a turn that produced only thinking still leaves an + /// `AssistantThinking` block in the transcript. pub(crate) fn commit_streaming(&mut self) { self.commit_thinking_buffer(); if let Some(mut s) = self.streaming.take() { @@ -222,8 +213,7 @@ impl ChatView { self.blocks.push(Box::new(GitDiffBlock::new(text))); } - /// Appends an interrupted marker. Flushes any in-flight streaming - /// buffer first. + /// Appends an interrupted marker. Flushes any in-flight streaming buffer first. pub(crate) fn push_interrupted_marker(&mut self) { self.commit_streaming(); self.blocks.push(Box::new(InterruptedMarker)); @@ -263,9 +253,7 @@ impl ChatView { self.blocks.last().and_then(|b| b.system_text()) } - /// Updates cached viewport height and syncs scroll position. - /// Returns `true` when auto-scroll moved `scroll_offset`, so the - /// caller knows to repaint with the new offset. + /// Updates cached viewport size and syncs scroll. `true` if auto-scroll moved the offset. #[must_use] pub(crate) fn update_layout(&mut self, area: Rect) -> bool { self.viewport_height = area.height; @@ -392,6 +380,8 @@ impl ChatView { continue; } let kind = block.block_kind(); + // Standalone blocks always take a separator; tool `Call` blocks hug their preceding + // `Result` (`Result → Call` opens a new tool group, but `Call → Result` stays flush). let needs_blank_before = !lines.is_empty() && last_has_width(&lines) && (block.standalone() || prev_kind == Some(BlockKind::Result)); diff --git a/crates/oxide-code/src/tui/components/chat/blocks.rs b/crates/oxide-code/src/tui/components/chat/blocks.rs index db15c564..59a4095e 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks.rs @@ -27,12 +27,20 @@ use crate::tui::wrap::wrap_line; // ── Trait ── +/// Per-render snapshot threaded through every [`ChatBlock::render`] call. +/// +/// `width` is the inner content width (already stripped of any chrome). `show_thinking` lets +/// thinking blocks elide their content without removing themselves from the block list. pub(super) struct RenderCtx<'a> { pub(super) width: u16, pub(super) theme: &'a Theme, pub(super) show_thinking: bool, } +/// Categorizes a block for inter-block spacing decisions. +/// +/// Adjacent `Call` + `Result` pairs render flush against each other so a tool call and its +/// result read as a single unit; `Other` blocks always get a blank-line separator. #[derive(Clone, Copy, PartialEq, Eq)] pub(super) enum BlockKind { Call, @@ -40,10 +48,14 @@ pub(super) enum BlockKind { Other, } +/// Render contract for one chat block (assistant prose, tool call, error marker, etc.). +/// +/// Implementations are pure functions of their state plus the [`RenderCtx`]; the chat view caches +/// nothing and may re-render any block on every frame. pub(super) trait ChatBlock { fn render(&self, ctx: &RenderCtx<'_>) -> Vec>; - /// Standalone blocks get blank-line separators; tool blocks hug. + /// Standalone blocks get blank-line separators; tool call/result pairs hug. fn standalone(&self) -> bool { true } @@ -56,7 +68,8 @@ pub(super) trait ChatBlock { true } - /// Whether this block is committed assistant prose (for streaming continuation). + /// True when this block is committed assistant prose, so a fresh streaming buffer can + /// continue its turn without inserting a separator. fn continues_assistant_turn(&self) -> bool { false } diff --git a/crates/oxide-code/src/tui/components/chat/blocks/assistant.rs b/crates/oxide-code/src/tui/components/chat/blocks/assistant.rs index d7115297..5d69bf3e 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks/assistant.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks/assistant.rs @@ -14,8 +14,7 @@ const THINKING_LABEL: &str = "Thinking..."; // ── AssistantText ── -/// A committed assistant text response, rendered through the markdown -/// pipeline. +/// A committed assistant text response, rendered through the markdown pipeline. pub(crate) struct AssistantText { text: String, } @@ -36,16 +35,7 @@ impl ChatBlock for AssistantText { } } -/// Render assistant prose as markdown with a first-line icon and a -/// matching-width space indent on every subsequent line. -/// -/// `starts_new_turn = true` emits [`ASSISTANT_PREFIX`] on the first line -/// (a fresh turn). `false` uses [`ASSISTANT_CONT`] on every line so the -/// block flows into an existing assistant turn (used by the streaming -/// cache after its first line has already been emitted). -/// -/// The markdown renderer wraps to `width - 2` so the 2-column lead-in -/// never pushes content past the terminal edge. +/// Renders assistant prose as markdown with a first-line icon prefix. pub(super) fn render_assistant_markdown( text: &str, ctx: &RenderCtx<'_>, @@ -71,8 +61,7 @@ pub(super) fn render_assistant_markdown( // ── AssistantThinking ── -/// Extended-thinking block — bar-prefixed quote with a `Thinking...` -/// header and markdown-rendered body. Hidden when `show_thinking` is off. +/// Extended-thinking block. Hidden when `show_thinking` is off. pub(crate) struct AssistantThinking { text: String, } @@ -102,8 +91,6 @@ impl ChatBlock for AssistantThinking { out.extend(wrap_line(header, width, bar_width, Some(&bar_spans))); if !self.text.trim().is_empty() { - // Blank gutter line between header and body — keeps the - // bar continuous while giving the prose breathing room. out.push(Line::from(Span::styled(THINKING_PREFIX, style))); let rendered = render_markdown(&self.text, theme, md_width); for line in rendered.lines { @@ -120,8 +107,7 @@ impl ChatBlock for AssistantThinking { } } -/// Dims plain spans; leaves explicitly-colored spans (inline code, -/// links, highlighted fences) at full color. +/// Dims plain spans; leaves explicitly-colored spans at full color. fn apply_thinking_style(mut line: Line<'static>, theme: &Theme) -> Line<'static> { if line.style.fg.is_some() { return line; @@ -154,9 +140,8 @@ mod tests { #[test] fn assistant_text_fenced_code_preserves_highlight_style() { - // Regression: `prepend_markdown_prefix` used to drop `line.style` - // on its output, so an unknown-lang fenced block inside an - // assistant reply silently lost its whole-line fg. + // Regression: `prepend_markdown_prefix` used to drop `line.style`, silently losing the + // whole-line fg of unknown-lang fence blocks inside assistant replies. let theme = Theme::default(); let block = AssistantText::new(indoc! {" ``` @@ -188,8 +173,7 @@ mod tests { #[test] fn render_inserts_gutter_line_between_header_and_body() { - // Visual breathing room: a bar-only line separates the header - // from the prose so the bar stays continuous without crowding. + // Bar-only gutter row separates header from prose without crowding. let theme = Theme::default(); let block = AssistantThinking::new("hello"); let lines = block.render(&ctx_at(60, &theme)); diff --git a/crates/oxide-code/src/tui/components/chat/blocks/git_diff.rs b/crates/oxide-code/src/tui/components/chat/blocks/git_diff.rs index 42fe6c82..c514b94a 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks/git_diff.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks/git_diff.rs @@ -1,7 +1,5 @@ -//! `GitDiffBlock` — `git diff` rendered with the Edit-tool diff -//! aesthetic (red `-` rows, green `+` rows, dim hunk headers, line- -//! number gutter). Reuses `numbered_row` / `bordered_row` from the tool -//! tree. The block owns raw text and parses it lazily on `render`. +//! `git diff` block — Edit-tool diff aesthetic with line-number gutter. Reuses `numbered_row` +//! / `bordered_row` from the tool tree; raw text parses lazily on `render`. use ratatui::style::Modifier; use ratatui::text::Line; @@ -66,17 +64,12 @@ impl ChatBlock for GitDiffBlock { || line.starts_with("new file ") || line.starts_with("deleted file ") { - // Metadata between "diff --git" and the first hunk; - // the path header already names the file. } else if let Some((o, n)) = parse_hunk_starts(line) { old_ln = o; new_ln = n; in_hunk = true; bordered_row::render(&mut out, ctx, border, line.to_owned(), ctx.theme.dim()); } else if line.is_empty() { - // Zero-byte line ends the hunk: unified-diff context - // is always prefixed with " ", so an empty line marks - // a section break (untracked heading, truncation footer). in_hunk = false; bordered_row::render(&mut out, ctx, border, String::new(), ctx.theme.dim()); } else if !in_hunk { @@ -98,8 +91,6 @@ impl ChatBlock for GitDiffBlock { old_ln += 1; new_ln += 1; } else { - // Corrupt hunk body — render as plain row instead of - // silently misaligning the line-number gutter. bordered_row::render(&mut out, ctx, border, line.to_owned(), ctx.theme.dim()); } } @@ -113,8 +104,6 @@ impl ChatBlock for GitDiffBlock { // ── Diff Parsing ── -/// "a" path of a `diff --git a/X b/Y` line, or `None` when not a -/// file header. fn parse_diff_git_path(line: &str) -> Option<&str> { let rest = line.strip_prefix("diff --git ")?; let after_a = rest.strip_prefix("a/")?; @@ -122,8 +111,6 @@ fn parse_diff_git_path(line: &str) -> Option<&str> { Some(&after_a[..space]) } -/// Starting line numbers from `@@ -A,B +C,D @@` (or `@@ -A +C @@` when -/// count is 1). `None` when not a hunk header. fn parse_hunk_starts(line: &str) -> Option<(usize, usize)> { let after_minus = line.strip_prefix("@@ -")?; let old_start = parse_first_number(after_minus)?; @@ -133,13 +120,11 @@ fn parse_hunk_starts(line: &str) -> Option<(usize, usize)> { Some((old_start, new_start)) } -/// Leading integer up to the next `,` or ` `. fn parse_first_number(s: &str) -> Option { let stop = s.find([',', ' ']).unwrap_or(s.len()); s[..stop].parse().ok() } -/// Body of a `+`/`-` line, but not the `+++ b/X` / `--- a/X` metadata. fn strip_marker(line: &str, marker: char) -> Option<&str> { line.strip_prefix(marker) .filter(|rest| !rest.starts_with(marker)) @@ -147,8 +132,6 @@ fn strip_marker(line: &str, marker: char) -> Option<&str> { // ── Gutter Sizing ── -/// Decimal-digit width of the highest line number across all hunks. -/// Computed globally so the gutter stays one width through the diff. fn max_line_number_width(text: &str) -> usize { let mut max_ln: usize = 0; for line in text.lines() { @@ -159,7 +142,6 @@ fn max_line_number_width(text: &str) -> usize { max_ln.to_string().len().max(1) } -/// Highest line number a hunk header refers to on either side. fn parse_hunk_extents(line: &str) -> Option { let after_minus = line.strip_prefix("@@ -")?; let old_extent = parse_range_extent(after_minus)?; @@ -169,7 +151,6 @@ fn parse_hunk_extents(line: &str) -> Option { Some(old_extent.max(new_extent)) } -/// `start[,count]` → `start + count - 1`. Bare `start` ⇒ one line. fn parse_range_extent(s: &str) -> Option { let start = parse_first_number(s)?; let Some(comma) = s.find(',') else { @@ -322,8 +303,7 @@ mod tests { #[test] fn render_skips_index_and_marker_lines() { - // `index`, `--- a/X`, `+++ b/X` are all skipped — the path - // header already names the file. + // `index`, `--- a/X`, `+++ b/X` are all skipped — the path header already names the file. let theme = Theme::default(); let block = GitDiffBlock::new(indoc! {" diff --git a/foo.rs b/foo.rs @@ -337,8 +317,7 @@ mod tests { #[test] fn render_add_row_carries_diff_add_row_bg() { - // Pin: dropping or swapping `diff_add_row` on the renderer - // construction would trip here. + // Pin: dropping or swapping `diff_add_row` on the renderer construction would trip here. let theme = Theme::default(); let block = GitDiffBlock::new(indoc! {" diff --git a/x b/x @@ -489,8 +468,7 @@ mod tests { ); } - // Surrounding `+` rows keep 1 and 2 — corrupt line consumed - // no number slot on either side. + // Surrounding `+` rows keep 1 and 2 — corrupt line consumed no number slot on either side. let plus_numbers: Vec = [&body[0], &body[2]] .iter() .map(|line| line.spans[1].content.trim().to_owned()) diff --git a/crates/oxide-code/src/tui/components/chat/blocks/interrupted.rs b/crates/oxide-code/src/tui/components/chat/blocks/interrupted.rs index b81c085c..78096a06 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks/interrupted.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks/interrupted.rs @@ -7,9 +7,8 @@ use ratatui::text::{Line, Span}; use super::{ChatBlock, RenderCtx}; use crate::agent::event::INTERRUPTED_MARKER; -/// Trailing marker placed after the partial assistant block when a -/// turn is cancelled mid-flight, so the rendered transcript shows -/// where the cancel landed without a separate error treatment. +/// Trails a cancelled turn's partial assistant block so the transcript shows where the cancel +/// landed. pub(crate) struct InterruptedMarker; impl ChatBlock for InterruptedMarker { diff --git a/crates/oxide-code/src/tui/components/chat/blocks/streaming.rs b/crates/oxide-code/src/tui/components/chat/blocks/streaming.rs index 5733b44b..e6184f2e 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks/streaming.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks/streaming.rs @@ -1,31 +1,22 @@ -//! Streaming assistant block. -//! -//! Holds the in-flight assistant response as tokens arrive, with a -//! per-line rendered-prefix cache so committed text is not re-parsed on -//! every frame. Promoted to a committed [`super::AssistantText`] block -//! on `commit_streaming`. +//! Streaming assistant block. Buffers tokens with a rendered-prefix cache so committed text is not +//! re-parsed each frame; promoted to [`super::AssistantText`] on `commit_streaming`. use ratatui::text::Line; use super::RenderCtx; use super::assistant::render_assistant_markdown; -/// Mutable streaming state for the current assistant response. Not a -/// [`ChatBlock`](super::ChatBlock) — its render path needs context from -/// the surrounding chat (whether it continues the prior turn) that the -/// trait doesn't carry. +/// In-flight assistant turn buffered between stream tokens. +/// +/// Tokens append to `buffer`; everything up to the most recent paragraph break (`\n\n`) is +/// pre-rendered into `rendered` so each frame only re-parses the unstable tail. The cache is +/// keyed by viewport width and invalidated on resize. pub(crate) struct StreamingAssistant { - /// Full buffered text as it arrives. buffer: String, - /// Rendered lines for the stable prefix of the buffer. Avoids - /// re-parsing committed text on every frame. + /// Rendered lines for the stable prefix up to `rendered_boundary`. rendered: Vec>, - /// Byte offset in `buffer` up to which `rendered` is current. Text - /// before this offset is cached; text past it needs parsing. rendered_boundary: usize, - /// Viewport width at which `rendered` was produced. When the - /// viewport resizes mid-stream, the cache must be cleared so lines - /// re-wrap to the new width. + /// Width at which `rendered` was produced; cache invalidates on resize. cached_width: u16, } @@ -43,9 +34,6 @@ impl StreamingAssistant { self.buffer.push_str(token); } - /// Take ownership of the accumulated buffer, leaving the streaming - /// state empty. The caller promotes the buffer into an - /// [`AssistantText`](super::AssistantText) block. pub(crate) fn take_buffer(&mut self) -> String { self.rendered.clear(); self.rendered_boundary = 0; @@ -53,7 +41,6 @@ impl StreamingAssistant { std::mem::take(&mut self.buffer) } - /// Drop the cache when the viewport changes width so lines re-wrap. pub(crate) fn invalidate_cache_for_width(&mut self, width: u16) { if self.cached_width != 0 && self.cached_width != width { self.rendered.clear(); @@ -63,8 +50,6 @@ impl StreamingAssistant { } /// Caches rendered lines up to the last `\n\n` boundary. - /// Splitting at `\n\n` (not `\n`) preserves inter-paragraph spacing - /// that pulldown-cmark only emits in a single-pass parse. pub(crate) fn advance_cache(&mut self, ctx: &RenderCtx<'_>, continues_turn: bool) { if ctx.width == 0 { return; @@ -82,8 +67,7 @@ impl StreamingAssistant { let rendered = render_assistant_markdown(new_committed, ctx, !continues_turn && cache_empty); if !self.rendered.is_empty() { - // Paragraph break between successive commits — the - // single-pass renderer would emit this blank line. + // Paragraph break that the single-pass renderer would emit between commits. self.rendered.push(Line::raw("")); } self.rendered.extend(rendered); @@ -100,8 +84,6 @@ impl StreamingAssistant { ctx: &RenderCtx<'_>, continues_turn: bool, ) { - // Leading gap when starting a fresh turn and the transcript - // already has content. if !continues_turn && super::last_has_width(out) { out.push(Line::raw("")); } @@ -113,26 +95,16 @@ impl StreamingAssistant { return; } - // Cache ends on a paragraph break; the tail is a new paragraph - // (possibly still being typed). Insert the separator that a - // single-pass renderer would produce between them. let cache_empty = self.rendered.is_empty(); if !cache_empty { out.push(Line::raw("")); } - // Full-tail parse: pulldown's block separators (blank-line - // before list / heading / fence) only fire on a single-pass - // parse. Splitting the tail off to render raw would strip - // them — a bounded one-frame flash on unclosed inline markers - // isn't worth that persistent bug. + // Full-tail parse: splitting would lose pulldown's block separators. let starts_new_turn = !continues_turn && cache_empty; out.extend(render_assistant_markdown(tail, ctx, starts_new_turn)); } - // Test-only accessors for sibling-module tests that assert on - // cache bookkeeping. Gated to keep the API surface minimal in - // production builds. #[cfg(test)] pub(crate) fn rendered_boundary(&self) -> usize { self.rendered_boundary diff --git a/crates/oxide-code/src/tui/components/chat/blocks/system.rs b/crates/oxide-code/src/tui/components/chat/blocks/system.rs index cc4391fb..32a73e38 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks/system.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks/system.rs @@ -1,6 +1,5 @@ -//! Slash-command output block: a `▎` left-bar in `accent` plus body -//! in `text`. Errors keep their own `ErrorBlock` styling so a -//! transcript scan distinguishes informational from error output. +//! Slash-command output block — `▎` left-bar in `accent` + body in `text`. Errors use +//! [`super::ErrorBlock`] so info output is visually distinct from failures. use ratatui::style::Style; use ratatui::text::{Line, Span}; @@ -42,8 +41,7 @@ impl ChatBlock for SystemMessageBlock { out.extend(wrap_line(line, width, indent, Some(&cont_prefix))); } if out.is_empty() { - // Empty content → single bar so the block is visible - // rather than silently dropped. + // Empty content → bare bar so the block stays visible rather than silently dropped. out.push(Line::from(Span::styled( TOOL_BORDER_PREFIX.to_owned(), bar_style, @@ -53,8 +51,6 @@ impl ChatBlock for SystemMessageBlock { } } -/// Wrap-continuation prefix that keeps `▎` aligned under the first -/// line's bar and carries `bar_style` for theme overrides. fn bar_continuation_prefix(bar_style: Style) -> Vec> { let bar_pos = TOOL_BORDER_PREFIX .find(BAR) @@ -101,8 +97,7 @@ mod tests { #[test] fn render_empty_text_still_emits_a_bar_line() { - // Empty payload still produces a visible block — silently - // swallowing it would hide a bug at the call site. + // Silently dropping empty payloads would mask call-site bugs. let theme = Theme::default(); let block = SystemMessageBlock::new(""); let lines = block.render(&ctx_at(60, &theme)); @@ -113,8 +108,7 @@ mod tests { #[test] fn render_trailing_newline_does_not_emit_extra_blank_row() { - // Pin `str::lines()` semantics — `split('\n')` would emit an - // extra empty trailing row. + // Pin `str::lines()` semantics — `split('\n')` would emit an extra empty trailing row. let theme = Theme::default(); let block = SystemMessageBlock::new(indoc! {" alpha @@ -127,15 +121,14 @@ mod tests { #[test] fn render_wraps_long_body_under_bar_at_viewport_width() { - // Wrapped continuation lines must re-emit the bar so the - // block reads as one visual unit. + // Wrapped continuation lines must re-emit the bar so the block reads as one visual unit. let theme = Theme::default(); let block = SystemMessageBlock::new("alpha beta gamma delta epsilon zeta"); let lines = block.render(&ctx_at(16, &theme)); assert!(lines.len() >= 2, "expected wrap, got {lines:#?}"); for (i, line) in lines.iter().enumerate() { - // First row: bar+space as one span. Continuation: split - // into [bar, space] so the bar carries `accent`. + // First row: bar+space as one span. Continuation splits to [bar, space] so the bar + // keeps the `accent` style. let head = &line.spans[0]; let content = head.content.as_ref(); assert!( @@ -148,8 +141,7 @@ mod tests { #[test] fn render_per_logical_line_wraps_independently() { - // Two long source lines wrap separately; a body that joined - // them would render as one paragraph. + // Joining input lines would render two source lines as one paragraph. let theme = Theme::default(); let block = SystemMessageBlock::new( "first really long line of text\nsecond really long line of text", diff --git a/crates/oxide-code/src/tui/components/chat/blocks/tool.rs b/crates/oxide-code/src/tui/components/chat/blocks/tool.rs index 5ece9a06..6e2964e8 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks/tool.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks/tool.rs @@ -1,19 +1,9 @@ -//! Tool call and tool result blocks. +//! Tool call and result blocks. The left-edge bar visually couples a call to its output and +//! color-codes success / error; this is the only chat block that keeps it, so the bar / border +//! helpers live here rather than in the trait module. //! -//! The tool group is the only chat block that keeps a left-edge bar — -//! it visually couples a call to its output and color-codes success / -//! error at the same time. Every other block (user, assistant, error) -//! uses the bar-less icon-prefix helpers in [`super`] and flushes to -//! col 0. The bar / border machinery therefore lives here, not in the -//! trait module, so it scopes to exactly the blocks that use it. -//! -//! Result rendering is per-variant via [`ToolResultView`]: the default -//! is a truncated text body; tools with structured output (Edit diffs, -//! Read excerpts, Grep matches today; Glob later) produce richer -//! variants via [`Tool::result_view`](crate::tool::Tool::result_view). -//! The variant-specific bodies live in sibling modules under [`tool`]; -//! this file owns block types, central dispatch, and the shared border -//! helpers child renderers reuse. +//! Result rendering is per-variant via [`ToolResultView`] — default is truncated text; structured +//! tools (Edit, Read, Grep, Glob) get richer bodies in sibling modules under [`tool`]. pub(super) mod bordered_row; mod diff; @@ -33,22 +23,13 @@ use crate::tui::glyphs::{BAR, TOOL_BORDER_CONT, TOOL_BORDER_PREFIX, TOOL_ERROR, use crate::tui::theme::Theme; use crate::tui::wrap::wrap_line; -/// Maximum lines of tool output shown inline before truncation. Shared -/// between the default text body and the read excerpt; both surface -/// the same hidden-line footer when the body overflows. const MAX_TOOL_OUTPUT_LINES: usize = 5; -/// Maximum bytes per tool output line before horizontal truncation. -/// Measured in bytes (matched against `str::len`) rather than Unicode -/// characters — display width is already gated by the terminal width -/// budget; this cap exists to avoid pathological multi-kilobyte lines -/// pasted into tool output. const MAX_TOOL_OUTPUT_LINE_BYTES: usize = 512; // ── Tool Call ── -/// A running or completed tool invocation — one bordered line with the -/// tool icon and input summary. +/// One bordered line with the tool icon and input summary. pub(crate) struct ToolCallBlock { icon: &'static str, label: String, @@ -92,9 +73,7 @@ impl ChatBlock for ToolCallBlock { // ── Tool Result ── -/// The outcome of a tool call — indicator (✓ / ✗), label, and a -/// per-view body (truncated text by default; richer shapes for tools -/// with structured inputs). +/// Tool-call outcome — indicator (✓ / ✗), label, and per-view body. pub(crate) struct ToolResultBlock { label: String, view: ToolResultView, @@ -115,6 +94,8 @@ impl ChatBlock for ToolResultBlock { fn render(&self, ctx: &RenderCtx<'_>) -> Vec> { let mut out = Vec::new(); render_status_line(&mut out, ctx, &self.label, self.is_error); + // Per-variant body dispatch — each tool's structured renderer owns its own gutter sizing, + // truncation footer, and overflow rules. The `Text` arm is the catch-all fallback. match &self.view { ToolResultView::Text { content } => { text::render(&mut out, ctx, content, &self.label, self.is_error); @@ -165,8 +146,6 @@ impl ChatBlock for ToolResultBlock { // ── Shared Helpers ── -/// Renders the tool-result header line — success / error indicator, -/// styled label, and wrapped continuation under the bar. fn render_status_line( out: &mut Vec>, ctx: &RenderCtx<'_>, @@ -194,13 +173,7 @@ fn render_status_line( )); } -/// Builds a continuation prefix that keeps the `▎` bar aligned under -/// the original prefix. For a prefix like `"▎ "` (4 cols), produces -/// `["", "▎", " "]` where the bar span is styled. -/// -/// Precondition: `prefix` must contain [`BAR`] — every tool-rendering -/// call site passes either [`TOOL_BORDER_PREFIX`], [`TOOL_BORDER_CONT`], or -/// the diff-side continuation, all of which satisfy it. +/// Continuation prefix that keeps `▎` aligned under the original prefix. fn border_continuation_prefix(prefix: &str, bar_style: Style) -> Vec> { let bar_pos = prefix.find(BAR).expect("prefix must contain ▎ bar"); let left = &prefix[..bar_pos]; @@ -220,9 +193,6 @@ fn border_style_for(theme: &Theme, is_error: bool) -> Style { } } -/// Truncates a string to `max_bytes` bytes, appending `...` if cut. -/// Falls back to the nearest char boundary at or before `max_bytes` to -/// avoid splitting multi-byte UTF-8 sequences. fn truncate_to_bytes(s: &str, max_bytes: usize) -> String { if s.len() <= max_bytes { return s.to_owned(); @@ -263,10 +233,8 @@ mod tests { #[test] fn truncate_to_bytes_respects_char_boundary() { - // Each `中` is 3 bytes in UTF-8. If floor_char_boundary wasn't used, - // cutting at byte 5 would split the second `中` mid-codepoint and - // produce invalid UTF-8 (panic on `&s[..5]`). Boundary fallback - // rounds down to byte 3, yielding one clean `中` + `...`. + // Each `中` is 3 bytes; cutting at byte 5 without `floor_char_boundary` would split a + // codepoint and panic on `&s[..5]`. Rounding down to byte 3 yields one `中` + `...`. let input = "中中中中"; let result = truncate_to_bytes(input, 5); assert_eq!(result, "中..."); @@ -275,7 +243,6 @@ mod tests { #[test] fn truncate_to_bytes_exact_boundary_no_split() { - // 6 bytes = exactly two `中`s; result stays untouched. assert_eq!(truncate_to_bytes("中中", 6), "中中"); } } diff --git a/crates/oxide-code/src/tui/components/chat/blocks/tool/bordered_row.rs b/crates/oxide-code/src/tui/components/chat/blocks/tool/bordered_row.rs index 4d31870e..5e0f9a9b 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks/tool/bordered_row.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks/tool/bordered_row.rs @@ -1,9 +1,6 @@ -//! Shared `[bar] [text]` row primitive for unnumbered chat-block body -//! rows. Numbered rows use the sibling [`super::numbered_row`]. -//! Visibility is widened to `pub(in super::super)` so non-tool block -//! modules (`blocks::git_diff` for file headers / hunk headers / -//! truncation footers) reuse it without having to physically move -//! the file. +//! Shared `[bar] [text]` row primitive for unnumbered chat-block body rows. Numbered rows use the +//! sibling [`super::numbered_row`]. Visibility is widened to `pub(in super::super)` so +//! `blocks::git_diff` can reuse it for headers / hunk separators / truncation footers. use ratatui::style::Style; use ratatui::text::{Line, Span}; @@ -64,8 +61,7 @@ mod tests { #[test] fn render_wraps_long_text_under_bar() { - // Continuation lines must keep the bar aligned via - // `border_continuation_prefix`. + // Continuation lines must keep the bar aligned via `border_continuation_prefix`. let theme = Theme::default(); let ctx = RenderCtx { width: 12, @@ -91,8 +87,7 @@ mod tests { #[test] fn render_carries_text_style_through_wrap() { - // Text style must apply across all wrapped fragments, not - // just the first. + // Text style must apply across all wrapped fragments, not just the first. let theme = Theme::default(); let ctx = RenderCtx { width: 12, diff --git a/crates/oxide-code/src/tui/components/chat/blocks/tool/glob.rs b/crates/oxide-code/src/tui/components/chat/blocks/tool/glob.rs index b887a18f..e1de1acf 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks/tool/glob.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks/tool/glob.rs @@ -47,9 +47,8 @@ pub(super) fn render( } } -/// Footer combining TUI-side hiding (`hidden`) with the tool's -/// `MAX_RESULTS` cap. Header carries the actual counts; footer just -/// flags the cap with grep's `(limit reached)` token. +/// Footer combining TUI-side row hiding with the tool's `MAX_RESULTS` cap. Counts live in the +/// header; the footer flags the cap with grep's `(limit reached)` token. fn footer_text(hidden: usize, truncated_by_tool: bool) -> Option { let noun = |n: usize| if n == 1 { "file" } else { "files" }; match (hidden, truncated_by_tool) { @@ -213,8 +212,7 @@ mod tests { #[test] fn footer_text_tool_truncated_with_no_tui_hidden_names_limit() { - // Defensive arm: in practice MAX_RESULTS (100) is well above - // MAX_TOOL_OUTPUT_LINES (5), so this combination is unreachable. + // Defensive arm: MAX_RESULTS (100) far exceeds MAX_TOOL_OUTPUT_LINES (5) in practice. assert_eq!(footer_text(0, true), Some("... limit reached".to_owned())); } diff --git a/crates/oxide-code/src/tui/components/chat/blocks/tool/grep.rs b/crates/oxide-code/src/tui/components/chat/blocks/tool/grep.rs index a703e543..618f7eda 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks/tool/grep.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks/tool/grep.rs @@ -1,6 +1,5 @@ -//! `grep` tool body (content mode) — per-file groups of line-numbered -//! match rows. Context lines (`-` separator in grep output) render dim -//! to keep matches visually distinct. +//! `grep` tool body (content mode) — per-file groups of line-numbered match rows; non-match +//! context lines render dim. use ratatui::text::Line; use unicode_width::UnicodeWidthStr; @@ -69,8 +68,7 @@ pub(super) fn render( } } -/// Footer combining TUI-side row hiding (`hidden`) and grep's -/// server-side `head_limit` (`truncated`). `None` when neither applies. +/// Footer combining TUI-side row hiding and grep's `head_limit` cap; `None` when neither applies. fn footer_text(hidden: usize, truncated: bool) -> Option { match (hidden, truncated) { (0, false) => None, @@ -110,17 +108,15 @@ mod tests { show_thinking: true, }; let mut out = Vec::new(); - // truncated=true is intentional: empty groups short-circuit - // before the footer, so even a `truncated` flag emits no body. + // truncated=true is intentional: empty groups short-circuit before the footer. render(&mut out, &ctx, &[], true, false); assert!(out.is_empty()); } #[test] fn render_stops_when_budget_fills_at_path_boundary() { - // Six 0-line groups overflow the 5-row budget on path headers - // alone, exercising the outer-loop budget guard that the inner - // `break 'outer` doesn't reach when groups have no match rows. + // Six 0-line groups overflow the 5-row budget on path headers alone, hitting the outer + // guard that `break 'outer` does not reach when groups have no match rows. let theme = Theme::default(); let ctx = RenderCtx { width: 80, diff --git a/crates/oxide-code/src/tui/components/chat/blocks/tool/numbered_row.rs b/crates/oxide-code/src/tui/components/chat/blocks/tool/numbered_row.rs index 0a0067e5..bd645227 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks/tool/numbered_row.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks/tool/numbered_row.rs @@ -1,16 +1,7 @@ -//! Shared row primitive for chat blocks that render a -//! `[bar] [number] [separator] [text]` row shape. Read / grep use the -//! default `" │ "` pipe separator with no row background; Edit-tool -//! diff sides and the slash `/diff` `GitDiffBlock` pass the `- ` / `+ ` -//! sign as separator and a Catppuccin red / green row bg via -//! [`Renderer::with_style`]. Visibility is widened to `pub(in -//! super::super)` so non-tool block modules (`blocks::git_diff`) reuse -//! the renderer without having to physically move it. -//! -//! The renderer captures per-call state — border style, separator, -//! optional row bg, number column width, continuation prefix — once at -//! construction so each row only carries its own number, text, and text -//! style. +//! Shared `[bar] [number] [separator] [text]` row renderer. Read / grep use the default pipe +//! separator; Edit-tool diff sides and `/diff` pass `- ` / `+ ` plus a row bg via +//! [`Renderer::with_style`]. Visibility widened to `pub(in super::super)` so `blocks::git_diff` +//! can reuse it without relocating. use ratatui::style::Style; use ratatui::text::{Line, Span}; @@ -21,9 +12,6 @@ use super::{MAX_TOOL_OUTPUT_LINE_BYTES, TOOL_BORDER_CONT, truncate_to_bytes}; use crate::tui::glyphs::BAR; use crate::tui::wrap::{expand_tabs, wrap_line}; -/// Default separator between the line-number column and the text. Read -/// / grep render this as a dim pipe; diff sides override via -/// [`Renderer::with_style`]. const DEFAULT_SEPARATOR: &str = " │ "; /// Renders numbered rows under a shared bordered tool-result body. @@ -39,8 +27,7 @@ pub(in super::super) struct Renderer<'a> { } impl<'a> Renderer<'a> { - /// Default constructor for plain numbered rows — pipe separator, - /// no row bg. Used by read / grep. + /// Plain numbered rows — pipe separator, no row bg. Used by read / grep. pub(in super::super) fn new( ctx: &'a RenderCtx<'a>, border_style: Style, @@ -56,12 +43,8 @@ impl<'a> Renderer<'a> { ) } - /// Customized constructor — diff sides pass their `- ` / `+ ` sign - /// as separator and a [`Theme::diff_add_row`] / [`Theme::diff_del_row`] - /// bg style so the row tint extends across the full terminal width. - /// - /// [`Theme::diff_add_row`]: crate::tui::theme::Theme::diff_add_row - /// [`Theme::diff_del_row`]: crate::tui::theme::Theme::diff_del_row + /// Diff-side variant — caller passes the `- ` / `+ ` sign as separator and a row bg so the + /// tint extends across the full terminal width. pub(in super::super) fn with_style( ctx: &'a RenderCtx<'a>, border_style: Style, @@ -117,11 +100,7 @@ impl<'a> Renderer<'a> { } } -/// Builds the wrapped-line continuation prefix. The bar prefix area -/// (`▎` + `TOOL_BORDER_CONT` padding) stays transparent so it doesn't -/// inherit the row tint; the text-column padding (number + separator -/// width) carries `row_bg` so the tint visually starts under the -/// number column on every wrapped row. +/// Builds the wrapped-line continuation prefix (bar area stays transparent, text area gets bg). fn make_cont_spans( border_style: Style, number_width: usize, @@ -138,10 +117,6 @@ fn make_cont_spans( ] } -/// Extends `line` to `target_width` cols with a trailing styled -/// space-fill so the `row_bg` tint reads as a contiguous block instead -/// of a ragged content-width band. No-op when the line already fills -/// or exceeds the target. fn pad_to_width(line: &mut Line<'static>, target_width: usize, bg: Style) { let current: usize = line.spans.iter().map(|s| s.content.width()).sum(); if current >= target_width { @@ -213,14 +188,33 @@ mod tests { assert!(text_span.content.len() <= MAX_TOOL_OUTPUT_LINE_BYTES + 3); } + #[test] + fn render_wraps_with_aligned_continuation_prefix() { + // Continuation lines carry the bar plus padding aligned under the text column. + let theme = Theme::default(); + let ctx = RenderCtx { + width: 14, + theme: &theme, + show_thinking: true, + }; + let renderer = Renderer::new(&ctx, theme.tool_border(), 2); + let mut out = Vec::new(); + renderer.render(&mut out, 1, "alpha beta gamma", theme.text()); + + assert!(out.len() >= 2, "expected wrapped output, got {out:#?}"); + let cont_text: String = out[1].spans.iter().map(|s| s.content.as_ref()).collect(); + assert!( + cont_text.starts_with(TOOL_BORDER_CONT), + "continuation must keep tool border continuation prefix: {cont_text:?}", + ); + } + // ── Renderer::with_style ── #[test] fn with_style_render_uses_custom_separator_in_place_of_pipe() { - // Diff-side path: the separator slot carries the `- ` / `+ ` - // sign instead of the dim pipe. The text is still emitted as a - // separate styled span so the sign can take its own (red / - // green) color independent of the row text. + // Diff path: separator carries `- ` / `+ ` instead of the pipe; text stays a separate + // span so the sign can take its own color. let theme = Theme::default(); let ctx = RenderCtx { width: 80, @@ -248,11 +242,8 @@ mod tests { #[test] fn with_style_patches_row_bg_onto_content_spans() { - // The bar prefix span must stay transparent (bg=None) while the - // number / separator / text spans inherit the row bg via patch. - // A regression here would either tint the bar (visual noise - // bleeding into the chrome column) or leave the number column - // bare (breaking the contiguous block effect). + // Bar prefix stays transparent; number / separator / text inherit the row bg. Regression + // would either tint the chrome column or leave a hole in the band. let theme = Theme::default(); let ctx = RenderCtx { width: 80, @@ -279,9 +270,7 @@ mod tests { #[test] fn with_style_pads_to_full_width_with_row_bg() { - // Trailing pad span fills the remaining columns so the bg tint - // reaches ctx.width — without it, the row would be a ragged - // band ending at the text width. + // Trailing pad fills remaining columns so the bg tint reaches `ctx.width`. let theme = Theme::default(); let ctx = RenderCtx { width: 40, @@ -314,9 +303,8 @@ mod tests { #[test] fn with_style_no_bg_skips_padding() { - // When row_bg is None, no trailing pad is emitted — the row - // ends at its natural content width. Read / grep rely on this: - // a transparent terminal must not paint a phantom band. + // No row bg → no trailing pad. Read / grep rely on this so a transparent terminal + // does not paint a phantom band. let theme = Theme::default(); let ctx = RenderCtx { width: 80, @@ -337,10 +325,8 @@ mod tests { #[test] fn with_style_wrapped_continuation_keeps_bg_under_text_column() { - // After wrap, every continuation line must also pad to ctx.width - // so the bg block stays contiguous across wraps. The bar prefix - // area on continuations stays transparent (mirrors the header - // line). + // Every wrapped line pads to `ctx.width` so the bg stays contiguous; the bar prefix + // on continuations stays transparent like the header line. let theme = Theme::default(); let ctx = RenderCtx { width: 20, @@ -374,30 +360,4 @@ mod tests { "continuation bar prefix must stay clear" ); } - - // ── Renderer::render ── - - #[test] - fn render_wraps_with_aligned_continuation_prefix() { - // Width forces the row to wrap. Continuation lines should - // carry the `▎` bar plus padding aligned under the text column - // (4 cols of bar prefix + 2 cols of number column + 3 cols of - // ` │ ` = 9 cols). - let theme = Theme::default(); - let ctx = RenderCtx { - width: 14, - theme: &theme, - show_thinking: true, - }; - let renderer = Renderer::new(&ctx, theme.tool_border(), 2); - let mut out = Vec::new(); - renderer.render(&mut out, 1, "alpha beta gamma", theme.text()); - - assert!(out.len() >= 2, "expected wrapped output, got {out:#?}"); - let cont_text: String = out[1].spans.iter().map(|s| s.content.as_ref()).collect(); - assert!( - cont_text.starts_with(TOOL_BORDER_CONT), - "continuation must keep tool border continuation prefix: {cont_text:?}", - ); - } } diff --git a/crates/oxide-code/src/tui/components/chat/blocks/tool/read_excerpt.rs b/crates/oxide-code/src/tui/components/chat/blocks/tool/read_excerpt.rs index 868a3411..4b84f1a9 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks/tool/read_excerpt.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks/tool/read_excerpt.rs @@ -1,5 +1,4 @@ -//! `read` tool body — line-numbered excerpt with a path / range header -//! that summarizes which slice of the file the model just looked at. +//! `read` tool body — line-numbered excerpt with a path / range header. use ratatui::text::{Line, Span}; use unicode_width::UnicodeWidthStr; diff --git a/crates/oxide-code/src/tui/components/chat/blocks/tool/text.rs b/crates/oxide-code/src/tui/components/chat/blocks/tool/text.rs index 1702dddf..b3721261 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks/tool/text.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks/tool/text.rs @@ -24,11 +24,7 @@ pub(super) fn render( let border_style = border_style_for(ctx.theme, is_error); let text_style = ctx.theme.dim(); - // Preserve per-line leading whitespace — some tools (e.g., `git - // diff --stat`) indent every line with a meaningful space that - // `content.trim()` would strip off the first line only, producing - // a misaligned first row. Drop whole blank surrounding lines - // instead, one unit of work at a time. + // Strip surrounding blank lines but preserve per-line leading whitespace. let mut output_lines: Vec<&str> = content.lines().collect(); while output_lines.first().is_some_and(|l| l.trim().is_empty()) { output_lines.remove(0); @@ -37,12 +33,7 @@ pub(super) fn render( output_lines.pop(); } - // Tools (grep, glob) commonly use their own summary line as both - // the `title` metadata (shown in the status line) and the first - // line of `content` (shown in the body) — the model needs the - // summary to parse counts, but rendering both duplicates it on - // screen. Skip the first body line when it matches the label - // verbatim. + // Deduplicate when the first body line matches the status-line label. if output_lines .first() .is_some_and(|l| l.trim() == label.trim()) diff --git a/crates/oxide-code/src/tui/components/input.rs b/crates/oxide-code/src/tui/components/input.rs index 1576409c..b8cf4fe2 100644 --- a/crates/oxide-code/src/tui/components/input.rs +++ b/crates/oxide-code/src/tui/components/input.rs @@ -24,17 +24,12 @@ use crate::tui::theme::Theme; /// Maximum number of visible content lines before the input stops growing. const MAX_VISIBLE_LINES: u16 = 6; -/// Outcome of routing a keystroke through the slash popup. The three -/// variants encode "consumed with action", "consumed silently", and -/// "popup let it pass through to the textarea path". enum PopupKey { Action(UserAction), Consumed, Pass, } -/// Placeholder copy keyed off `(enabled, has_queued)`. Visible only -/// while the buffer is empty. const PLACEHOLDER_IDLE: &str = "Ask anything..."; const PLACEHOLDER_BUSY: &str = "Type to queue a follow-up..."; const PLACEHOLDER_IDLE_QUEUED: &str = "Esc edits last queued · Enter adds another"; @@ -45,23 +40,11 @@ const PLACEHOLDER_IDLE_QUEUED: &str = "Esc edits last queued · Enter adds anoth pub(crate) struct InputArea { theme: Theme, textarea: TextArea<'static>, - /// Slash-command autocomplete overlay. Visible iff the buffer - /// reads as a slash query (see [`popup_query`]); App reads - /// height through [`Self::popup_height`]. popup: SlashPopup, enabled: bool, - /// Whether the surrounding [`App`](super::super::app::App) has any - /// queued prompts pending dispatch — drives the idle placeholder - /// copy. Set explicitly because the input has no view of - /// app-level queue state. has_queued: bool, - /// Last render width for visual line count estimation. Updated each - /// frame by `render()`, used by `height()` on the *next* frame. /// `Cell` because `render(&self)` is immutable. last_width: Cell, - /// Tracked viewport scroll offset (screen line index of the topmost - /// visible row). Mirrors ratatui-textarea's internal `viewport` which - /// is not publicly accessible. scroll_top: Cell, } @@ -89,8 +72,6 @@ impl InputArea { input } - /// Mirrors the parent's queue non-emptiness onto the placeholder - /// so an empty buffer shows the queue hint instead of the default. pub(crate) fn set_has_queued(&mut self, has_queued: bool) { if self.has_queued == has_queued { return; @@ -104,10 +85,6 @@ impl InputArea { return; } self.enabled = enabled; - // Visual styling stays put — the user can keep composing while - // a turn streams (typed prompts queue), so the input never - // looks "switched off". Only the placeholder copy reflects the - // run-state. Mid-turn cues live on the status bar. self.refresh_placeholder(); } @@ -115,17 +92,12 @@ impl InputArea { self.enabled } - /// Current buffer as logical lines. Exposed for cross-module tests - /// (`tui::app`) so they can pin queue pop-back / `set_text` behavior - /// without reaching through the private textarea. #[cfg(test)] pub(crate) fn lines(&self) -> Vec { self.textarea.lines().to_vec() } - /// Replaces the current buffer with `text` and parks the cursor at - /// its end. Used by the queue pop-back path to surface a queued - /// prompt for editing. + /// Replaces the buffer with `text` and parks the cursor at its end. pub(crate) fn set_text(&mut self, text: &str) { self.textarea.select_all(); self.textarea.cut(); @@ -133,27 +105,19 @@ impl InputArea { self.scroll_top.set(0); } - /// Returns the height this component needs (content lines + top + - /// bottom borders). pub(crate) fn height(&self) -> u16 { let content_lines = self.visual_line_count(); content_lines.min(MAX_VISIBLE_LINES) + 2 } - /// Whether the popup has rows to draw. App reads this to gate - /// Esc routing so popup-dismiss wins over queue / cancel. pub(crate) fn popup_visible(&self) -> bool { self.popup.is_visible() } - /// Rows the popup needs in the surrounding layout. Zero when - /// hidden so the input keeps its natural height. pub(crate) fn popup_height(&self) -> u16 { self.popup.height() } - /// Render the popup band into `area`. Caller is responsible for - /// reserving `popup_height()` rows above the input. pub(crate) fn render_popup(&self, frame: &mut Frame, area: Rect) { self.popup.render(frame, area); } @@ -163,7 +127,6 @@ impl InputArea { impl InputArea { pub(crate) fn handle_event(&mut self, event: &Event) -> Option { - // Ctrl+D: quit only when idle + empty (POSIX EOF idiom). if let Event::Key(KeyEvent { code: KeyCode::Char('d'), modifiers: KeyModifiers::CONTROL, @@ -177,9 +140,6 @@ impl InputArea { }; } - // Ctrl+C: cancel mid-turn; arm exit when idle. The arm-vs-exit - // decision lives in `App::dispatch_user_action` since it owns - // the [`Status::ExitArmed`] window. if let Event::Key(KeyEvent { code: KeyCode::Char('c'), modifiers: KeyModifiers::CONTROL, @@ -193,9 +153,6 @@ impl InputArea { }); } - // Popup-routed nav keys take priority over plain typing while - // the popup is visible. App-level Esc is gated by - // `popup_visible` so it routes here when the popup owns the key. if self.popup.is_visible() { match self.handle_popup_key(event) { PopupKey::Action(action) => return Some(action), @@ -221,25 +178,17 @@ impl InputArea { return self.submit(); } - // Scroll keys while busy belong to the chat view; the textarea - // would otherwise swallow them for cursor movement and the - // user would lose the ability to scroll history mid-turn. + // Scroll keys while busy belong to the chat view. if !self.enabled && Self::is_scroll_key(event) { return None; } - // Typing flows through in both states so the user can compose - // a follow-up that the queue will fire after the current turn. self.textarea.input(event.clone()); self.refresh_popup(); None } pub(crate) fn render(&self, frame: &mut Frame, area: Rect) { - // Border, marker, and textarea styling don't react to the - // run-state — users compose mid-turn for the queue, so the - // input always reads as live. The status bar carries the - // streaming / running-tool cue. let block = Block::default() .borders(Borders::TOP | Borders::BOTTOM) .border_style(self.theme.border_focused()) @@ -247,10 +196,6 @@ impl InputArea { let inner = block.inner(area); frame.render_widget(block, area); - // Reserve the leftmost columns for the prompt marker so the - // textarea content aligns with chat-history user blocks. The - // marker only paints the first visible row; subsequent wrapped - // rows leave the prompt gutter blank (hanging indent). let [prompt_area, textarea_area] = Layout::horizontal([ Constraint::Length(USER_PROMPT_PREFIX_WIDTH), Constraint::Min(0), @@ -267,15 +212,8 @@ impl InputArea { frame.render_widget(&self.textarea, textarea_area); - // Store width for visual line count estimation on the next frame. self.last_width.set(textarea_area.width); - // screen_cursor().row is an absolute screen-line index across - // all wrapped lines, not viewport-relative. Replicate the - // scroll logic from ratatui-textarea's `next_scroll_top` to - // convert to a position within the rendered area. Cursor - // tracking runs in both run-states so typing into the queue - // mid-turn updates the visible caret. let sc = self.textarea.screen_cursor(); let cursor_row = to_u16(sc.row); let height = textarea_area.height; @@ -303,8 +241,6 @@ impl InputArea { impl InputArea { // ── Render Helpers ── - /// Picks the placeholder copy for the current `(enabled, has_queued)` - /// combo. Visible only while the buffer is empty. fn refresh_placeholder(&mut self) { let text = if !self.enabled { PLACEHOLDER_BUSY @@ -318,9 +254,6 @@ impl InputArea { // ── Popup & State ── - /// Route a key through the slash popup when it owns focus. Tab - /// and Enter only route here under no modifiers so Shift+Enter - /// keeps inserting newlines. fn handle_popup_key(&mut self, event: &Event) -> PopupKey { let Event::Key(KeyEvent { code, modifiers, .. @@ -353,8 +286,6 @@ impl InputArea { } } - /// Tab handler: replace the buffer with `/{canonical} ` so the - /// user can type args next, and hide the popup (now in args mode). fn popup_complete_to_buffer(&mut self) { let Some(name) = self.popup.selected().map(|m| m.name) else { return; @@ -363,9 +294,6 @@ impl InputArea { self.popup.set_query(None); } - /// Enter handler: submit `/{canonical}` and clear the buffer. - /// Routes through `UserAction::SubmitPrompt` so the existing - /// dispatch path runs the slash command. fn popup_submit_selected(&mut self) -> Option { let name = self.popup.selected()?.name; self.textarea.select_all(); @@ -375,10 +303,10 @@ impl InputArea { Some(UserAction::SubmitPrompt(format!("/{name}"))) } - /// Recompute the popup query from the current buffer. Called after - /// any keystroke that mutates the textarea. Multi-line content - /// hides the popup — slash commands are single-line. fn refresh_popup(&mut self) { + // Popup wiring: only the single-line case can be a slash command, and `popup_query` + // returns `None` for non-`/`-prefixed text — so a multi-line buffer or a typed-over slash + // both close the popup. let query = match self.textarea.lines() { [single] => popup_query(single), _ => None, @@ -386,8 +314,6 @@ impl InputArea { self.popup.set_query(query); } - /// Whether `event` is one of the chat-scroll keys reserved for the - /// surrounding `ChatView` while the input is busy. fn is_scroll_key(event: &Event) -> bool { matches!( event, @@ -403,10 +329,6 @@ impl InputArea { ) } - /// Estimate the number of visual (screen) lines after word-wrap. - /// - /// Uses `last_width` from the previous render frame. Falls back to - /// logical line count when no width is known yet (first frame). #[expect( clippy::cast_possible_truncation, reason = "line count fits in u16 for any practical input" @@ -431,9 +353,6 @@ impl InputArea { .max(1) } - /// Whether the buffer contains only whitespace. Drives the - /// POSIX-style Ctrl+D EOF gate, short-circuits empty submits, - /// and gates the Esc-pop refusal in `App::handle_esc`. pub(crate) fn is_empty(&self) -> bool { self.textarea .lines() @@ -447,7 +366,6 @@ impl InputArea { } let trimmed = self.textarea.lines().join("\n").trim().to_owned(); - // Clear the textarea and reset scroll state. self.textarea.select_all(); self.textarea.cut(); self.scroll_top.set(0); @@ -458,10 +376,7 @@ impl InputArea { // ── Free Functions ── -/// Lossy `usize → u16` cast scoped to cursor / column positions, where -/// the source value is bounded by terminal dimensions. Centralises the -/// `cast_possible_truncation` lint suppression so the call sites stay -/// readable. +/// Lossy `usize → u16` for cursor / column positions, bounded by terminal dimensions. #[expect( clippy::cast_possible_truncation, reason = "cursor / column positions fit in u16 for terminal widths" @@ -581,8 +496,7 @@ mod tests { #[test] fn handle_event_disabled_empty_enter_is_silent() { // Submit's empty-buffer guard short-circuits, so a stray Enter - // mid-turn produces no action even though the textarea now - // accepts typing during busy. + // mid-turn produces no action even though the textarea now accepts typing during busy. let mut input = test_input(); input.set_enabled(false); let action = input.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)); @@ -591,8 +505,7 @@ mod tests { #[test] fn handle_event_disabled_typing_lands_in_textarea() { - // Enables the queue UX: the user composes a follow-up while - // the spinner is still spinning. + // Enables the queue UX: the user composes a follow-up while the spinner is still spinning. let mut input = test_input(); input.set_enabled(false); input.handle_event(&key(KeyCode::Char('h'), KeyModifiers::NONE)); @@ -733,8 +646,7 @@ mod tests { #[test] fn render_prompt_marker_always_uses_user_color() { // The chevron stays the user accent across run-states because - // composing mid-turn is allowed — same intent as the typed - // text styling above. + // composing mid-turn is allowed — same intent as the typed text styling above. let theme = Theme::default(); let mut input = InputArea::new(&theme); diff --git a/crates/oxide-code/src/tui/components/input/popup.rs b/crates/oxide-code/src/tui/components/input/popup.rs index 5e116b31..175d8d09 100644 --- a/crates/oxide-code/src/tui/components/input/popup.rs +++ b/crates/oxide-code/src/tui/components/input/popup.rs @@ -1,17 +1,6 @@ -//! Slash-command autocomplete popup. -//! -//! Rendered as a band of rows above the [`InputArea`](super::InputArea) -//! whenever the textarea buffer reads as a slash query (see -//! [`SlashPopup::set_query`]). Pure render + selection state — the -//! filtering happens in [`crate::slash::filter_built_ins`] and the -//! component owns no registry handle. -//! -//! Style follows Claude Code's popup: the selected row paints in the -//! normal `text` palette while the rest of the rows render dim, so -//! the active suggestion stands out by contrast rather than by a -//! prefix glyph or background fill. Aliases parenthesize only the -//! alias the user typed (`/clear (new)`); the popup never paints a -//! full alias list. +//! Slash-command autocomplete popup. Selected row paints in `text`, others dim — contrast +//! stands in for a prefix glyph or fill. Aliases parenthesize only the typed alias +//! (`/clear (new)`); never the full list. use ratatui::Frame; use ratatui::layout::Rect; @@ -24,19 +13,11 @@ use crate::slash::{MatchedCommand, filter_built_ins}; use crate::tui::theme::Theme; use crate::util::text::truncate_to_width; -/// Maximum visible rows. Beyond this the popup truncates with a -/// `... (N more)` footer so the input layout doesn't lose half the -/// terminal to the overlay. const MAX_VISIBLE_ROWS: usize = 8; -/// Padding columns between the name column and the description. const COLUMN_GAP: usize = 2; -/// Slash-command autocomplete overlay. -/// -/// `matches.is_empty()` is the visibility predicate — callers set the -/// query via [`Self::set_query`], and any state where the query is -/// `None` or yields no matches collapses the popup to zero rows. +/// Slash-command autocomplete overlay. Empty `matches` means hidden. pub(crate) struct SlashPopup { theme: Theme, matches: Vec, @@ -52,9 +33,6 @@ impl SlashPopup { } } - /// Update the popup's match list from a typed query. `query` is - /// the buffer with the leading `/` stripped; `None` hides the - /// popup outright (e.g., the buffer no longer starts with `/`). pub(crate) fn set_query(&mut self, query: Option<&str>) { let Some(q) = query else { self.matches.clear(); @@ -65,17 +43,14 @@ impl SlashPopup { self.selected = self.selected.min(self.matches.len().saturating_sub(1)); } - /// Whether the popup currently has any matches to draw. pub(crate) fn is_visible(&self) -> bool { !self.matches.is_empty() } - /// Currently-selected match. `None` when the popup is hidden. pub(crate) fn selected(&self) -> Option<&MatchedCommand> { self.matches.get(self.selected) } - /// Advance the selection one row, wrapping past the last. pub(crate) fn select_next(&mut self) { if self.matches.is_empty() { return; @@ -83,7 +58,6 @@ impl SlashPopup { self.selected = (self.selected + 1) % self.matches.len(); } - /// Move the selection back one row, wrapping past the first. pub(crate) fn select_prev(&mut self) { if self.matches.is_empty() { return; @@ -152,9 +126,7 @@ impl SlashPopup { Line::from(spans) } - /// `... (N more)` footer when matches exceed `MAX_VISIBLE_ROWS`. - /// Returned as an iterator so the caller can chain it onto the - /// row list without an `if`-shaped branch. + /// `... (N more)` overflow footer; iterator form keeps the caller branch-free. fn render_footer(&self, width: usize) -> impl Iterator> { let hidden = self.matches.len().saturating_sub(MAX_VISIBLE_ROWS); let style = self.theme.dim(); @@ -168,9 +140,8 @@ impl SlashPopup { } } -/// Display label for a matched row: `/name` plus the typed alias when -/// the match landed on an alias (`/clear (new)`). Used for both -/// rendering and column-width computation, so it must match exactly. +/// `/name` plus the typed alias when matched via alias (`/clear (new)`). Used for both +/// rendering and column-width computation, so the two must agree exactly. fn label(m: &MatchedCommand) -> String { match m.matched_alias { Some(alias) => format!("/{} ({alias})", m.name), @@ -178,9 +149,7 @@ fn label(m: &MatchedCommand) -> String { } } -/// Row palette: selected rows paint in the normal `text` slot with a -/// `BOLD` modifier so they stand out against the surrounding `dim` -/// non-selected rows. Mirrors Claude Code's selected-row contrast. +/// Selected → `text` + BOLD; non-selected → dim. Contrast mirrors Claude Code's popup. fn row_style(theme: &Theme, selected: bool) -> Style { if selected { theme.text().add_modifier(Modifier::BOLD) @@ -376,8 +345,7 @@ mod tests { #[test] fn render_alias_match_parenthesizes_only_typed_alias() { // No live registry command has aliases yet, but the renderer - // is the place where the alias-display rule lives. Drive it - // via a hand-rolled match list. + // is the place where the alias-display rule lives. Drive it via a hand-rolled match list. let mut popup = SlashPopup::new(&theme()); popup.matches = vec![MatchedCommand { name: "clear", @@ -406,8 +374,7 @@ mod tests { #[test] fn render_overflow_emits_n_more_footer_when_matches_exceed_cap() { // Cap is 8; a hand-rolled list of 10 commands triggers the - // "... (N more)" footer. Live registry is too small to drive - // this branch. + // "... (N more)" footer. Live registry is too small to drive this branch. let mut popup = SlashPopup::new(&theme()); popup.matches = (0..10) .map(|i| MatchedCommand { diff --git a/crates/oxide-code/src/tui/event.rs b/crates/oxide-code/src/tui/event.rs index 8c6a400b..e61b1b51 100644 --- a/crates/oxide-code/src/tui/event.rs +++ b/crates/oxide-code/src/tui/event.rs @@ -8,8 +8,9 @@ use crate::agent::event::{AGENT_EVENT_CHANNEL_CAP, AgentEvent, AgentSink}; /// Sends agent events through an `mpsc` channel for TUI consumption. /// -/// Cloneable so background helpers (e.g. the AI title generator) can hold -/// their own handle and emit events alongside the main agent loop. +/// Non-blocking — uses `try_send` so the agent task never stalls when the TUI is slow to drain. +/// A full channel surfaces as an error to the agent loop rather than backpressuring the model +/// stream. #[derive(Clone)] pub(crate) struct ChannelSink { tx: mpsc::Sender, @@ -37,8 +38,7 @@ impl AgentSink for ChannelSink { } } -/// Creates a linked channel pair: the `ChannelSink` for the agent loop, and -/// the bounded `Receiver` for the TUI. +/// Creates a linked `ChannelSink` + bounded `Receiver` pair. pub(crate) fn channel() -> (ChannelSink, mpsc::Receiver) { let (tx, rx) = mpsc::channel(AGENT_EVENT_CHANNEL_CAP); (ChannelSink::new(tx), rx) diff --git a/crates/oxide-code/src/tui/glyphs.rs b/crates/oxide-code/src/tui/glyphs.rs index 73661e7a..0a69abb5 100644 --- a/crates/oxide-code/src/tui/glyphs.rs +++ b/crates/oxide-code/src/tui/glyphs.rs @@ -1,22 +1,14 @@ -//! Visual glyphs used across the TUI — single source of truth for the -//! UI's iconography so the visual identity is easy to scan and easy -//! to keep consistent. +//! Visual glyphs used across the TUI — single source of truth for iconography. //! //! Conventions: //! -//! - First-line prefix glyphs end with a trailing space so callers -//! can concatenate without padding (`"❯ "`, `"◉ "`, `"✗ "`). -//! - Continuation indents (`*_CONT`) match the prefix's display width -//! so wrapped lines align under the text, not under the icon. -//! - Status indicators that participate in structured layouts (the -//! tool `✓` / `✗`) carry no trailing space; the renderer handles -//! the column gap explicitly. +//! - First-line prefix glyphs end with a trailing space (`"❯ "`, `"◉ "`, `"✗ "`). +//! - Continuation indents (`*_CONT`) match the prefix's display width. +//! - Tool status indicators (`✓` / `✗`) carry no trailing space; renderer handles the gap. // ── User prompt ── -/// First-line prefix shown for any user-typed prompt — past messages -/// in the chat history, queued prompts in the preview panel, and the -/// active input area. +/// First-line prefix for user-typed prompts. pub(crate) const USER_PROMPT_PREFIX: &str = "❯ "; /// Display width of [`USER_PROMPT_PREFIX`] in terminal columns. @@ -27,7 +19,7 @@ pub(crate) const USER_PROMPT_CONT: &str = " "; // ── Assistant ── -/// First-line prefix for assistant text — diamond + space. +/// First-line prefix for assistant text. pub(crate) const ASSISTANT_PREFIX: &str = "◉ "; /// Continuation indent for multi-line assistant content. @@ -35,49 +27,37 @@ pub(crate) const ASSISTANT_CONT: &str = " "; // ── Tool / Thinking ── -/// Left-bar glyph for tool blocks and the thinking block. Single -/// column wide; usually paired with a trailing space when used as a -/// line prefix (see [`THINKING_PREFIX`]). +/// Left-bar glyph for tool and thinking blocks. pub(crate) const BAR: &str = "▎"; -/// Per-line prefix for thinking blocks — [`BAR`] + space, so thinking -/// rows align with adjacent tool-block bars. +/// Per-line prefix for thinking blocks. pub(crate) const THINKING_PREFIX: &str = "▎ "; -/// First-line prefix for tool-call and tool-result status lines — [`BAR`] -/// + space. Content sits at col 2. +/// First-line prefix for tool-call and tool-result status lines. pub(crate) const TOOL_BORDER_PREFIX: &str = "▎ "; -/// Continuation prefix for lines subordinate to a tool-block status -/// header — wrapped tool name / result label and tool output body lines. -/// Aligns content at col 4, past the [`TOOL_SUCCESS`] / [`TOOL_ERROR`] -/// indicator, so the body reads as a child of the status header rather -/// than a peer. +/// Continuation prefix for tool-block body lines (content at col 4). pub(crate) const TOOL_BORDER_CONT: &str = "▎ "; -/// Tool-result success indicator. No trailing space — tool blocks -/// handle their own column gap via [`BAR`] and the status-line layout. +/// Tool-result success indicator. pub(crate) const TOOL_SUCCESS: &str = "✓"; -/// Tool-result failure indicator. Same glyph as [`ERROR_PREFIX`] -/// without the trailing space. +/// Tool-result failure indicator. pub(crate) const TOOL_ERROR: &str = "✗"; // ── Error ── -/// First-line prefix for error blocks — cross mark + space. +/// First-line prefix for error blocks. pub(crate) const ERROR_PREFIX: &str = "✗ "; // ── Inline markers ── -/// Inline newline replacement for collapsing multi-line previews to -/// a single row without losing the "this is more than it looks" hint. +/// Inline newline replacement for collapsed multi-line previews. pub(crate) const NEWLINE_GLYPH: &str = " ⏎ "; // ── Status spinner ── -/// 8-dot Braille spinner (`cli-spinners` `dots2`), reversed for -/// counterclockwise motion. Cycle by modulo index. +/// 8-dot Braille spinner (counterclockwise); cycle by modulo index. pub(crate) const SPINNER_FRAMES: &[char] = &['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', '⣾']; #[cfg(test)] diff --git a/crates/oxide-code/src/tui/markdown.rs b/crates/oxide-code/src/tui/markdown.rs index 9bc1ebcc..092d4442 100644 --- a/crates/oxide-code/src/tui/markdown.rs +++ b/crates/oxide-code/src/tui/markdown.rs @@ -9,11 +9,13 @@ use ratatui::text::Text; use super::theme::Theme; use render::MarkdownRenderer; -/// Converts a markdown string to styled ratatui [`Text`]. +/// Renders markdown into styled ratatui [`Text`]. /// -/// When `width` is non-zero, long lines are word-wrapped to fit within -/// the given column budget. Continuation lines preserve the current -/// indent level (list markers, blockquote prefixes, etc.). +/// `width == 0` disables wrapping entirely (useful for tests and width-agnostic callers); any +/// non-zero value wraps each block to that column budget, preserving the enclosing block's +/// continuation indent (blockquote `>`, list-item gutter, etc.) on wrapped sub-lines. +/// +/// Tables and `GFM` strikethrough are enabled; other extensions stay off. pub(crate) fn render_markdown(input: &str, theme: &Theme, width: usize) -> Text<'static> { let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES; let parser = Parser::new_ext(input, options); diff --git a/crates/oxide-code/src/tui/markdown/highlight.rs b/crates/oxide-code/src/tui/markdown/highlight.rs index d6a9fbd8..783c31be 100644 --- a/crates/oxide-code/src/tui/markdown/highlight.rs +++ b/crates/oxide-code/src/tui/markdown/highlight.rs @@ -8,14 +8,18 @@ use syntect::easy::HighlightLines; use syntect::highlighting::ThemeSet; use syntect::parsing::SyntaxSet; +// Loaded once on first highlight (a few hundred ms of grammar / theme parsing) and reused for +// the rest of the process. Keeping them lazy avoids paying that cost when no fenced block is +// rendered. static SYNTAX_SET: LazyLock = LazyLock::new(SyntaxSet::load_defaults_newlines); static THEME_SET: LazyLock = LazyLock::new(ThemeSet::load_defaults); -/// Highlight `code` using syntect for the given language. +/// Highlights `code` using syntect, returning one [`Line`] per source line. /// -/// The language token is extracted from the first word of `lang` (so info -/// strings like `rust,no_run` still work). Falls back to `fallback_style` -/// when the language is unrecognized. +/// `lang` is parsed as a fenced-block info string: only the first whitespace-separated token is +/// used (so `rust,no_run` and `rust ignore` both resolve to `rust`). Unknown or empty languages +/// fall back to `fallback_style` with no per-token coloring so plain fenced blocks render +/// uniformly without ragged background fills. pub(super) fn highlight_code(lang: &str, code: &str, fallback_style: Style) -> Vec> { let syntax = lang .split_ascii_whitespace() diff --git a/crates/oxide-code/src/tui/markdown/render.rs b/crates/oxide-code/src/tui/markdown/render.rs index 73695dec..6f3907d7 100644 --- a/crates/oxide-code/src/tui/markdown/render.rs +++ b/crates/oxide-code/src/tui/markdown/render.rs @@ -1,11 +1,6 @@ -//! Markdown → [`ratatui::text::Text`] renderer. -//! -//! Walks the pulldown-cmark event stream and produces styled lines -//! sized to a fixed terminal width. Supports inline formatting, code -//! blocks (syntect-highlighted via [`super::highlight`]), lists, -//! blockquotes, tables (box-drawing borders with column alignment), -//! and horizontal rules. Wrapping is block-aware so word breaks -//! respect the enclosing block's continuation indent. +//! Markdown → [`ratatui::text::Text`] renderer. Walks the pulldown-cmark stream into styled +//! lines wrapped to terminal width; wrapping is block-aware so it respects the enclosing +//! block's continuation indent. use pulldown_cmark::{Alignment, CodeBlockKind, CowStr, Event, HeadingLevel, Tag, TagEnd}; use ratatui::style::{Modifier, Style}; @@ -19,38 +14,42 @@ use crate::tui::wrap::wrap_line; // ── Renderer ── +/// Stateful walker over a `pulldown-cmark` event stream that materializes each block as one or +/// more styled [`Line`]s. +/// +/// The renderer is single-pass: every `Event::Start` pushes onto the relevant stack +/// (`indent_stack` for blockquote / list nesting, `inline_styles` for emphasis / strong / heading +/// modifiers), every `Event::End` pops, and inline events append spans to `lines.last_mut()`. +/// Block boundaries set `needs_newline = true` so the next block opener emits a separator +/// without producing trailing blanks. pub(super) struct MarkdownRenderer { // Core iter: I, theme: Theme, - /// Max line width for word-wrapping (0 = no wrapping). width: usize, pub(super) lines: Vec>, - // Block-level spacing - /// Whether to insert a blank line before the next block element. + /// Set after a block ends; the next block opener emits a blank separator before its content. needs_newline: bool, - // Block nesting (lists + blockquotes) - /// Indent prefix per nesting level (list continuation or blockquote) + /// Block-prefix stack — each entry is the spans prepended to every line inside that block + /// (blockquote `> `, list-item continuation indent, etc.). Wrapping uses the concatenation + /// as the continuation prefix so deep nesting stays visually aligned. indent_stack: Vec>>, - /// Ordered (`Some(index)`) or unordered (`None`) per nesting level + /// One entry per nested list; `Some(n)` for ordered (next marker = `n. `), `None` for + /// unordered (`- `). list_stack: Vec>, - /// Deferred list marker spans, emitted on the next `push_line` + /// List-item marker queued by `start_item`. The next `push_line` consumes it for the deepest + /// indent slot so the marker prints in place of that level's continuation indent. pending_marker: Option>>, - // Inline state - /// Nested inline style stack (bold, italic, strikethrough) inline_styles: Vec