diff --git a/CLAUDE.md b/CLAUDE.md index 830ecd97..64e24c46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,7 @@ ox # Start an interactive session │ ├── parser.rs # parse_slash + popup_query — detect `/cmd args`; allows `:` and `.` │ ├── picker.rs # ModelEffortPicker — combined model + effort modal; emits a single SwapConfig │ ├── registry.rs # SlashCommand trait + SlashOutcome + echoes_input + BUILT_INS slice + alias-aware lookup +│ ├── rename.rs # /rename — bare opens a single-line title editor; `/rename ` applies directly; locks out AI title generation │ ├── resume.rs # /resume (alias /continue) — bare opens a searchable session picker (Tab toggles current-project ↔ all-projects); `/resume <id-prefix>` jumps directly │ ├── status.rs # /status — opens a KvOverview modal of session descriptors │ └── theme.rs # /theme — bare opens the picker (live preview); `/theme <name>` validates against the curated roster and swaps @@ -118,7 +119,7 @@ ox # Start an interactive session │ │ │ │ ├── read_excerpt.rs # Read-tool line-numbered excerpt body + path / range header │ │ │ │ └── text.rs # Default truncated-text body (fallback for tools without a richer view) │ │ │ └── user.rs # UserMessage -│ │ ├── input.rs # Multi-line input area (ratatui-textarea) + slash-popup wiring +│ │ ├── input.rs # Multi-line input area (ratatui-textarea) + slash-popup wiring + arg-mode `[id]` ghost text │ │ ├── input/ │ │ │ ├── popup.rs # Slash-command autocomplete overlay — dim non-selected, bold selected, alias parens │ │ │ └── snapshots/ # `cargo insta` baselines for popup render tests diff --git a/README.md b/README.md index 1254c218..452fd5d7 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Early development. What works today: - Agent loop with extended thinking and tool-use round-trip - File and search tools: `read`, `write`, `edit`, `glob`, `grep`, `bash` - Turn interruption (Esc / Ctrl+C) plus mid-turn queued follow-up prompts that splice into the same turn between tool calls, with double-press Ctrl+C exit confirmation -- Slash commands with `/`-triggered autocomplete (name and curated-arg modes): `/clear`, `/config`, `/diff`, `/effort`, `/help`, `/init`, `/model`, `/resume` (alias `/continue`), `/status`, `/theme` +- Slash commands with `/`-triggered autocomplete (name and curated-arg modes): `/clear`, `/config`, `/diff`, `/effort`, `/help`, `/init`, `/model`, `/rename`, `/resume`, `/status`, `/theme` - `CLAUDE.md` / `AGENTS.md` instruction-file discovery - Session persistence with JSONL conversation logs, listing, and resume - Per-session file-change tracking with a Read-before-Edit gate and on-disk drift detection diff --git a/crates/oxide-code/src/agent.rs b/crates/oxide-code/src/agent.rs index f6d8270a..98896644 100644 --- a/crates/oxide-code/src/agent.rs +++ b/crates/oxide-code/src/agent.rs @@ -265,6 +265,7 @@ where action @ (UserAction::ConfirmExit | UserAction::Clear | UserAction::Resume { .. } + | UserAction::Rename { .. } | UserAction::SwapConfig { .. } | UserAction::PreviewTheme { .. } | UserAction::SwapTheme { .. }), diff --git a/crates/oxide-code/src/agent/event.rs b/crates/oxide-code/src/agent/event.rs index dd34e331..da7edc53 100644 --- a/crates/oxide-code/src/agent/event.rs +++ b/crates/oxide-code/src/agent/event.rs @@ -83,6 +83,10 @@ pub(crate) enum UserAction { Resume { session_id: String, }, + /// Manual title from `/rename` or its modal. Suppresses AI title generation for this session. + Rename { + title: String, + }, /// Symmetric model + effort swap. At least one field must be `Some`; `None` leaves that axis /// as-is. Modal pickers and typed-arg `/model <id>` / `/effort <tier>` both flow through here. SwapConfig { diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index d0730f35..43566bd5 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -1003,7 +1003,7 @@ mod tests { } #[tokio::test] - async fn stream_message_billing_block_ships_under_both_auth_modes_with_cch_populated() { + async fn stream_message_billing_block_ships_under_both_auth_modes() { // 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()] { diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index b21a4100..e4c01e73 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -410,6 +410,9 @@ async fn agent_loop_task( ) .await; } + UserAction::Rename { title } => { + apply_rename(&session, &sink, title).await; + } UserAction::SwapConfig { model, effort } => { apply_swap_config(&mut client, &sink, model, effort); } @@ -487,6 +490,20 @@ fn format_drift_warning(drifted: &[std::path::PathBuf]) -> String { ) } +async fn apply_rename(session: &SessionHandle, sink: &dyn AgentSink, title: String) { + let outcome = session.set_manual_title(title.clone()).await; + sink.session_write_error(outcome.failure.as_deref()); + if outcome.failure.is_some() { + return; + } + if let Err(e) = sink.send(AgentEvent::SessionTitleUpdated { + session_id: session.session_id().to_owned(), + title, + }) { + tracing::error!("session-title-updated event dropped: {e}"); + } +} + /// Order matters: model swap re-clamps effort before the explicit pick is applied. fn apply_swap_config( client: &mut Client, diff --git a/crates/oxide-code/src/session/actor.rs b/crates/oxide-code/src/session/actor.rs index 6b1a2f77..2a24be34 100644 --- a/crates/oxide-code/src/session/actor.rs +++ b/crates/oxide-code/src/session/actor.rs @@ -31,6 +31,11 @@ pub(super) enum SessionCmd { title: String, ack: oneshot::Sender<Outcome>, }, + /// User-supplied title from `/rename`. Latches `manual_title_set` to suppress AI titles. + SetManualTitle { + title: String, + ack: oneshot::Sender<Outcome>, + }, Finish { /// Drained tracker snapshots; written as one `FileSnapshot` entry each plus a `Summary`. snapshots: Vec<FileSnapshot>, @@ -67,11 +72,25 @@ pub(super) async fn run( let mut entries: Vec<Entry> = Vec::new(); let mut acks: Vec<PendingAck> = Vec::new(); let mut should_exit = false; - absorb(first, &mut entries, &mut acks, &mut state, &mut should_exit); + absorb( + first, + &mut entries, + &mut acks, + &mut state, + &shared, + &mut should_exit, + ); // 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); + absorb( + next, + &mut entries, + &mut acks, + &mut state, + &shared, + &mut should_exit, + ); } let failure = match state.flush_entries(&entries) { Err(e) => { @@ -94,12 +113,14 @@ fn absorb( entries: &mut Vec<Entry>, acks: &mut Vec<PendingAck>, state: &mut SessionState, + shared: &SharedState, should_exit: &mut bool, ) { let now = OffsetDateTime::now_utc(); match cmd { SessionCmd::Record { msg, ack } => { - let (msg_entries, ai_title_seed) = state.queue_message_entries(&msg, now); + let (msg_entries, ai_title_seed) = + state.queue_message_entries(&msg, now, shared.manual_title_set()); entries.extend(msg_entries); acks.push(PendingAck::Record { ack, ai_title_seed }); } @@ -125,6 +146,11 @@ fn absorb( } } SessionCmd::AppendAiTitle { title, ack } => { + // Re-check: a `/rename` can flip the latch after the generator already queued this. + if shared.manual_title_set() { + _ = ack.send(Outcome { failure: None }); + return; + } entries.push(Entry::Title { title, source: super::entry::TitleSource::AiGenerated, @@ -132,6 +158,20 @@ fn absorb( }); acks.push(PendingAck::Outcome(ack)); } + SessionCmd::SetManualTitle { title, ack } => { + shared.mark_manual_title_set(); + match state.try_defer_title(title) { + None => _ = ack.send(Outcome { failure: None }), + Some(title) => { + entries.push(Entry::Title { + title, + source: super::entry::TitleSource::UserProvided, + updated_at: now, + }); + acks.push(PendingAck::Outcome(ack)); + } + } + } SessionCmd::Finish { snapshots, ack } => { entries.extend(state.finish_entries(snapshots, now)); acks.push(PendingAck::Outcome(ack)); @@ -337,6 +377,173 @@ mod tests { assert_eq!(title.title, "Fix the auth flow"); } + #[tokio::test] + async fn run_set_manual_title_alone_leaves_no_file_on_disk() { + // `/rename`-then-quit must leave no JSONL artifact. The deferred entry rides on + // `WriterStatus::Pending` and dies with the actor when no record ever arrives. + let dir = tempdir().unwrap(); + let store = test_store(dir.path()); + let state = SessionState::fresh(store.clone(), "m"); + let (manual_ack, manual_rx) = oneshot::channel(); + let manual_cmd = SessionCmd::SetManualTitle { + title: "Doomed rename".to_owned(), + ack: manual_ack, + }; + let (fin, _fin_rx) = finish_cmd(); + + drive(state, vec![manual_cmd, fin]).await; + + let outcome = manual_rx.await.expect("manual ack must arrive"); + assert!( + outcome.failure.is_none(), + "deferred-entry path acks healthily" + ); + + assert!( + store.list().unwrap().is_empty(), + "no message ever sent → no on-disk session", + ); + } + + #[tokio::test] + async fn run_set_manual_title_then_record_replaces_first_prompt_title() { + // `/rename` then send produces ONE title (UserProvided), not two: the deferred title + // flushes ahead of the message, and `manual_title_set` suppresses the FirstPrompt push. + let dir = tempdir().unwrap(); + let store = test_store(dir.path()); + let state = SessionState::fresh(store.clone(), "m"); + let session_id = state.session_id.to_string(); + let (manual_ack, _manual_rx) = oneshot::channel(); + let manual_cmd = SessionCmd::SetManualTitle { + title: "User-named".to_owned(), + ack: manual_ack, + }; + let (rec, _rec_rx) = record_cmd("hello"); + let (fin, _fin_rx) = finish_cmd(); + + drive(state, vec![manual_cmd, rec, fin]).await; + + let path = super::super::store::test_session_file(dir.path(), &session_id); + let content = std::fs::read_to_string(path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + let header_idx = lines + .iter() + .position(|l| l.contains(r#""type":"header""#)) + .expect("header line"); + let title_idx = lines + .iter() + .position(|l| l.contains(r#""type":"title""#)) + .expect("title line"); + let message_idx = lines + .iter() + .position(|l| l.contains(r#""type":"message""#)) + .expect("message line"); + assert!( + header_idx < title_idx && title_idx < message_idx, + "deferred title must flush AFTER header and BEFORE message: {content}", + ); + let title_count = lines + .iter() + .filter(|l| l.contains(r#""type":"title""#)) + .count(); + assert_eq!( + title_count, 1, + "deferred title replaces FirstPrompt: {content}" + ); + assert!( + lines[title_idx].contains("user_provided") && lines[title_idx].contains("User-named"), + "the lone title is the user-provided one: {content}", + ); + } + + #[tokio::test] + async fn run_two_set_manual_titles_keep_only_the_last() { + // Multiple `/rename` calls before any record must collapse to last-wins via the + // deferred slot's overwrite semantics. + let dir = tempdir().unwrap(); + let store = test_store(dir.path()); + let state = SessionState::fresh(store.clone(), "m"); + let session_id = state.session_id.to_string(); + let (a_ack, _a_rx) = oneshot::channel(); + let (b_ack, _b_rx) = oneshot::channel(); + let a = SessionCmd::SetManualTitle { + title: "First name".to_owned(), + ack: a_ack, + }; + let b = SessionCmd::SetManualTitle { + title: "Final name".to_owned(), + ack: b_ack, + }; + let (rec, _rec_rx) = record_cmd("trigger"); + let (fin, _fin_rx) = finish_cmd(); + + drive(state, vec![a, b, rec, fin]).await; + + let path = super::super::store::test_session_file(dir.path(), &session_id); + let content = std::fs::read_to_string(path).unwrap(); + let title_lines: Vec<&str> = content + .lines() + .filter(|l| l.contains(r#""type":"title""#)) + .collect(); + assert_eq!( + title_lines.len(), + 1, + "second rename overwrites the first: {content}" + ); + assert!( + title_lines[0].contains("Final name"), + "last-wins semantic: {content}", + ); + assert!( + !content.contains("First name"), + "earlier rename must not reach disk: {content}", + ); + } + + #[tokio::test] + async fn run_set_manual_title_after_record_blocks_ai_title() { + let dir = tempdir().unwrap(); + let store = test_store(dir.path()); + let state = SessionState::fresh(store.clone(), "m"); + let session_id = state.session_id.to_string(); + let (rec, _rec_rx) = record_cmd("Fix login"); + let (manual_ack, _manual_rx) = oneshot::channel(); + let manual_cmd = SessionCmd::SetManualTitle { + title: "User-picked title".to_owned(), + ack: manual_ack, + }; + let (ai_ack, _ai_rx) = oneshot::channel(); + let ai_cmd = SessionCmd::AppendAiTitle { + title: "AI title that should lose".to_owned(), + ack: ai_ack, + }; + let (fin, _fin_rx) = finish_cmd(); + + drive(state, vec![rec, manual_cmd, ai_cmd, fin]).await; + + let path = super::super::store::test_session_file(dir.path(), &session_id); + let content = std::fs::read_to_string(path).unwrap(); + let title_lines: Vec<&str> = content + .lines() + .filter(|l| l.contains(r#""type":"title""#)) + .collect(); + assert_eq!( + title_lines.len(), + 2, + "FirstPrompt + UserProvided; AI must not append a third: {content}", + ); + assert!( + title_lines + .iter() + .any(|l| l.contains("user_provided") && l.contains("User-picked title")), + "manual title must persist with `user_provided` source: {content}", + ); + assert!( + !content.contains("AI title that should lose"), + "AI title must not appear on disk after manual override: {content}", + ); + } + #[tokio::test] async fn run_shutdown_exits_loop_even_with_live_sender_clones() { // Otherwise an orphaned title-generator's mid-HTTP clone would pin the actor through diff --git a/crates/oxide-code/src/session/handle.rs b/crates/oxide-code/src/session/handle.rs index 751c232a..df569e12 100644 --- a/crates/oxide-code/src/session/handle.rs +++ b/crates/oxide-code/src/session/handle.rs @@ -45,6 +45,10 @@ pub(crate) struct SessionHandle { pub(super) struct SharedState { flush_failure_surfaced: AtomicBool, actor_gone_surfaced: AtomicBool, + /// Set when the user has run `/rename` — suppresses AI titles and the next `FirstPrompt`. + /// Latched by [`SessionHandle::set_manual_title`] before dispatch so the title generator's + /// pre-check observes it without an actor round-trip. + manual_title_set: AtomicBool, /// Most recent flush error — threaded into actor-gone messages so the user sees the I/O cause. last_flush_failure: std::sync::Mutex<Option<String>>, } @@ -70,6 +74,14 @@ impl SharedState { !self.actor_gone_surfaced.swap(true, Ordering::AcqRel) } + pub(super) fn mark_manual_title_set(&self) { + self.manual_title_set.store(true, Ordering::Release); + } + + pub(super) fn manual_title_set(&self) -> bool { + self.manual_title_set.load(Ordering::Acquire) + } + /// Most recent flush error, threaded into actor-gone messages. pub(super) fn last_flush_failure(&self) -> Option<String> { match self.last_flush_failure.lock() { @@ -145,6 +157,19 @@ impl SessionHandle { .await } + /// Persist a user-supplied title. The flag flips in `SharedState` *before* dispatch so the + /// title generator's pre-check sees it even if the actor hasn't drained its channel yet. + pub(crate) async fn set_manual_title(&self, title: String) -> Outcome { + self.shared.mark_manual_title_set(); + let (ack, rx) = oneshot::channel(); + self.dispatch_outcome(SessionCmd::SetManualTitle { title, ack }, rx) + .await + } + + pub(crate) fn manual_title_set(&self) -> bool { + self.shared.manual_title_set() + } + /// 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<FileSnapshot>) -> Outcome { @@ -345,13 +370,16 @@ fn from_resumed_data( mut data: SessionData, writer: super::store::SessionWriter, ) -> Result<ResumedSession> { + let pre_sanitize_len = data.messages.len(); sanitize_resumed_messages(&mut data.messages); - // 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"); + // Bail only on sanitize wiping a non-empty list — chaining onto a dropped `last_message_uuid` + // would corrupt the chain. A title-only file is fine; next message starts a fresh chain. + if pre_sanitize_len > 0 && data.messages.is_empty() { + bail!("session {session_id}: sanitize dropped all messages"); } - let first_user_prompt_seen = data.messages.iter().any(|m| extract_user_text(m).is_some()); + let first_user_prompt_seen = + data.messages.iter().any(|m| extract_user_text(m).is_some()) || data.title.is_some(); let message_count = u32::try_from(data.messages.len()).unwrap_or(u32::MAX); let title = data.title.map(|t| t.title); @@ -622,11 +650,12 @@ mod tests { async fn record_tool_metadata_batch_actor_drops_ack_surfaces_actor_gone_failure() { // Distinguishes the rx-await fallback in dispatch_outcome from the cmd_tx.send-failed // early return: send succeeds, actor receives the cmd, but drops the ack (simulates a - // panic between recv and ack). `succeed=3` exercises each ack-bearing variant of the - // helper's or-pattern healthy first, then the drop arm on the 4th cmd. - let handle = testing::acks_then_drops("acks-then-drops", 3); + // panic between recv and ack). `succeed=4` exercises each ack-bearing variant of the + // helper's or-pattern healthy first, then the drop arm on the 5th cmd. + let handle = testing::acks_then_drops("acks-then-drops", 4); let title = handle.append_ai_title("ok".to_owned()).await; + let manual = handle.set_manual_title("manual".to_owned()).await; let finish = handle.finish(Vec::new()).await; let batch_ok = handle .record_tool_metadata_batch(vec![( @@ -640,6 +669,10 @@ mod tests { let batch_dropped = handle.record_tool_metadata_batch(vec![]).await; assert!(title.failure.is_none(), "AppendAiTitle is acked healthily"); + assert!( + manual.failure.is_none(), + "SetManualTitle is acked healthily" + ); assert!(finish.failure.is_none(), "Finish is acked healthily"); assert!( batch_ok.failure.is_none(), @@ -690,6 +723,52 @@ mod tests { assert!(second.failure.is_none(), "subsequent calls must be silent"); } + // ── set_manual_title ── + + #[tokio::test] + async fn set_manual_title_writes_user_provided_title_and_latches_shared_flag() { + let dir = tempfile::tempdir().unwrap(); + let store = test_store(dir.path()); + let handle = start(&store, "m"); + let sid = handle.session_id().to_owned(); + + assert!( + !handle.manual_title_set(), + "fresh handle must start unlatched" + ); + + handle.record_message(Message::user("seed")).await; + let outcome = handle.set_manual_title("User wins".to_owned()).await; + + assert!(outcome.failure.is_none(), "healthy actor acks cleanly"); + assert!( + handle.manual_title_set(), + "the shared flag must latch immediately on dispatch", + ); + + handle.finish(Vec::new()).await; + drop(handle); + + let sessions = store.list().unwrap(); + let session = sessions.iter().find(|s| s.session_id == sid).unwrap(); + assert_eq!(session.title.as_ref().unwrap().title, "User wins"); + } + + #[tokio::test] + async fn set_manual_title_actor_gone_surfaces_failure_but_still_latches_flag() { + // The flag flips in SharedState *before* dispatch — even if the actor channel is dead, + // the title generator's pre-check must see the latch and skip its append. + let handle = testing::dead("dead"); + + let outcome = handle.set_manual_title("User wins".to_owned()).await; + + assert!(outcome.failure.is_some(), "actor-gone must surface failure"); + assert!( + handle.manual_title_set(), + "flag latches regardless of dispatch" + ); + } + // ── finish ── #[tokio::test] @@ -1195,6 +1274,52 @@ mod tests { assert_eq!(messages.len(), 1); } + #[tokio::test] + async fn resume_title_only_session_keeps_title_on_next_message() { + // Title-only resume must not push a duplicate `FirstPrompt` on the next message. + let dir = tempfile::tempdir().unwrap(); + let store = test_store(dir.path()); + let session_id = "title-only-fixture"; + super::super::store::seed_test_session( + &store, + session_id, + Some("User-named"), + None, + time::macros::datetime!(2026-01-01 00:00:00 UTC), + ); + + let resumed = resume(&store, session_id).expect("title-only session must resume"); + assert!(resumed.messages.is_empty(), "no messages on disk yet"); + assert_eq!(resumed.title.as_deref(), Some("User-named")); + + resumed.handle.record_message(Message::user("hi")).await; + resumed.handle.finish(Vec::new()).await; + resumed.handle.shutdown().await; + + let content = std::fs::read_to_string(test_session_file(dir.path(), session_id)).unwrap(); + let title_lines: Vec<&str> = content + .lines() + .filter(|l| l.contains(r#""type":"title""#)) + .collect(); + assert_eq!( + title_lines.len(), + 1, + "next message must NOT add a FirstPrompt title alongside the existing one: {content}", + ); + assert!( + title_lines[0].contains("user_provided") && title_lines[0].contains("User-named"), + "the lone title is the original UserProvided one: {content}", + ); + let message_count = content + .lines() + .filter(|l| l.contains(r#""type":"message""#)) + .count(); + assert_eq!( + message_count, 1, + "the resumed message must reach disk: {content}", + ); + } + #[tokio::test] async fn resume_empty_session_errors() { let dir = tempfile::tempdir().unwrap(); @@ -1230,7 +1355,7 @@ mod tests { .err() .expect("all messages sanitized must be an error"); assert!( - format!("{err:#}").contains("no messages to resume"), + format!("{err:#}").contains("sanitize dropped all messages"), "error explains why: {err:#}", ); } diff --git a/crates/oxide-code/src/session/handle/testing.rs b/crates/oxide-code/src/session/handle/testing.rs index 9cb254bc..183227b7 100644 --- a/crates/oxide-code/src/session/handle/testing.rs +++ b/crates/oxide-code/src/session/handle/testing.rs @@ -73,6 +73,7 @@ pub(crate) fn acks_then_drops(session_id: &str, succeed: usize) -> SessionHandle } SessionCmd::ToolMetadata { ack, .. } | SessionCmd::AppendAiTitle { ack, .. } + | SessionCmd::SetManualTitle { ack, .. } | SessionCmd::Finish { ack, .. } => { _ = ack.send(Outcome { failure: None }); } diff --git a/crates/oxide-code/src/session/state.rs b/crates/oxide-code/src/session/state.rs index dba9ef9a..937192e9 100644 --- a/crates/oxide-code/src/session/state.rs +++ b/crates/oxide-code/src/session/state.rs @@ -36,16 +36,15 @@ pub(super) struct SessionState { finished: bool, } -/// 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. +/// Writer lifecycle. `Pending` defers file creation until the first non-empty flush — a +/// `/rename`-then-quit leaves nothing on disk. `deferred_title` holds the latest rename +/// (last-wins) and flushes with the header on first promotion. `Broken` reopens via +/// `open_append` on the next batch. enum WriterStatus { - Pending { header: Entry }, + Pending { + header: Entry, + deferred_title: Option<String>, + }, Active(SessionWriter), Broken, } @@ -56,7 +55,10 @@ impl SessionState { Self { session_id: Arc::from(session_id), store, - writer_status: WriterStatus::Pending { header }, + writer_status: WriterStatus::Pending { + header, + deferred_title: None, + }, last_message_uuid: None, initial_message_count: 0, message_count: 0, @@ -65,7 +67,9 @@ impl SessionState { } } - /// Resumed sessions land directly in `Active` because the loader already read the file. + /// Resumed sessions land directly in `Active`. Pass `first_user_prompt_seen = true` whenever + /// the disk already holds a title or any user message — otherwise the next recorded message + /// will push a duplicate `FirstPrompt` entry. pub(super) fn resumed( store: SessionStore, session_id: String, @@ -86,11 +90,24 @@ impl SessionState { } } + /// Parks `title` for the first `Pending` → `Active` flush as `UserProvided`. Returns + /// `Some(title)` when already `Active` / `Broken` so the caller queues it normally. + /// Last-wins on repeat. + pub(super) fn try_defer_title(&mut self, title: String) -> Option<String> { + let WriterStatus::Pending { deferred_title, .. } = &mut self.writer_status else { + return Some(title); + }; + *deferred_title = Some(title); + None + } + /// Builds entries for one message; returns AI-title seed on first user-text. + /// `manual_title_set` skips both the `FirstPrompt` push and the AI-title seed. pub(super) fn queue_message_entries( &mut self, message: &Message, now: OffsetDateTime, + manual_title_set: bool, ) -> (Vec<Entry>, Option<String>) { let mut entries: Vec<Entry> = Vec::with_capacity(2); let mut ai_title_seed: Option<String> = None; @@ -98,14 +115,17 @@ impl SessionState { if !self.first_user_prompt_seen && let Some(text) = extract_user_text(message) { - // Latch before the title push so a later flush failure won't produce a duplicate. + // Latch before queueing anything — a flush failure must not replay this branch. self.first_user_prompt_seen = true; - ai_title_seed = Some(text.to_owned()); - entries.push(Entry::Title { - title: truncate_title(text, MAX_TITLE_LEN), - source: TitleSource::FirstPrompt, - updated_at: now, - }); + // `/rename` already queued the UserProvided title; skip the FirstPrompt + AI seed. + if !manual_title_set { + ai_title_seed = Some(text.to_owned()); + entries.push(Entry::Title { + title: truncate_title(text, MAX_TITLE_LEN), + source: TitleSource::FirstPrompt, + updated_at: now, + }); + } } let uuid = Uuid::new_v4(); @@ -150,19 +170,26 @@ impl SessionState { } /// Writes entries in one flush; transitions to Broken on failure so next batch reopens. + /// On first `Pending` → `Active` promotion, any deferred title flushes ahead of `entries` + /// as a `UserProvided` entry stamped at flush time. pub(super) fn flush_entries(&mut self, entries: &[Entry]) -> Result<()> { if entries.is_empty() { return Ok(()); } - let mut writer = self.take_or_open_writer()?; + let (mut writer, deferred_title) = self.take_or_open_writer()?; let result = (|| -> Result<()> { + if let Some(title) = &deferred_title { + writer.append_no_flush(&Entry::Title { + title: title.clone(), + source: TitleSource::UserProvided, + updated_at: OffsetDateTime::now_utc(), + })?; + } for entry in entries { writer.append_no_flush(entry)?; } writer.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, @@ -170,20 +197,25 @@ impl SessionState { result } - fn take_or_open_writer(&mut self) -> Result<SessionWriter> { - // 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. + /// Returns the writer plus any title deferred while `Pending`. On `Pending` failure the + /// header AND deferred title are restored so the next batch retries `create`. + fn take_or_open_writer(&mut self) -> Result<(SessionWriter, Option<String>)> { match std::mem::replace(&mut self.writer_status, WriterStatus::Broken) { - WriterStatus::Active(w) => Ok(w), - WriterStatus::Pending { header } => match self.store.create(&header) { - Ok(w) => Ok(w), + WriterStatus::Active(w) => Ok((w, None)), + WriterStatus::Pending { + header, + deferred_title, + } => match self.store.create(&header) { + Ok(w) => Ok((w, deferred_title)), Err(e) => { - self.writer_status = WriterStatus::Pending { header }; + self.writer_status = WriterStatus::Pending { + header, + deferred_title, + }; Err(e) } }, - WriterStatus::Broken => self.store.open_append(&self.session_id), + WriterStatus::Broken => Ok((self.store.open_append(&self.session_id)?, None)), } } } @@ -287,16 +319,63 @@ mod tests { use super::*; use crate::file_tracker::FileTracker; + // ── try_defer_title ── + + #[test] + fn try_defer_title_pending_accepts_and_overwrites_previous_value() { + let dir = tempfile::tempdir().unwrap(); + let store = test_store(dir.path()); + let mut state = SessionState::fresh(store, "m"); + + assert!(state.try_defer_title("first".to_owned()).is_none()); + assert!(state.try_defer_title("second".to_owned()).is_none()); + + let WriterStatus::Pending { + deferred_title: Some(slot), + .. + } = &state.writer_status + else { + panic!("expected Pending with last-wins deferred title"); + }; + assert_eq!(slot, "second", "later defer overwrites the earlier one"); + } + + #[test] + fn try_defer_title_active_rejects_with_title() { + let dir = tempfile::tempdir().unwrap(); + let store = test_store(dir.path()); + let mut state = SessionState::fresh(store, "m"); + let now = OffsetDateTime::now_utc(); + let (entries, _) = state.queue_message_entries(&Message::user("first"), now, false); + state.flush_entries(&entries).unwrap(); + assert!(matches!(state.writer_status, WriterStatus::Active(_))); + + let rejected = state.try_defer_title("Manual title".to_owned()); + assert_eq!(rejected.as_deref(), Some("Manual title")); + } + + #[test] + fn try_defer_title_broken_rejects_with_title() { + let dir = tempfile::tempdir().unwrap(); + let store = test_store(dir.path()); + let mut state = SessionState::fresh(store, "m"); + state.writer_status = WriterStatus::Broken; + + let rejected = state.try_defer_title("Manual title".to_owned()); + assert_eq!(rejected.as_deref(), Some("Manual title")); + } + // ── queue_message_entries ── #[test] - fn queue_message_entries_first_user_text_emits_title_then_message_and_seeds_ai_title() { + fn queue_message_entries_seeds_ai_title_on_first_user_text() { let dir = tempfile::tempdir().unwrap(); let store = test_store(dir.path()); let mut state = SessionState::fresh(store, "m"); let now = OffsetDateTime::now_utc(); - let (entries, seed) = state.queue_message_entries(&Message::user("Fix the auth bug"), now); + let (entries, seed) = + state.queue_message_entries(&Message::user("Fix the auth bug"), now, false); assert_eq!(entries.len(), 2, "title + message"); assert!(matches!( @@ -316,10 +395,10 @@ mod tests { let store = test_store(dir.path()); let mut state = SessionState::fresh(store, "m"); let now = OffsetDateTime::now_utc(); - _ = state.queue_message_entries(&Message::user("first"), now); + _ = state.queue_message_entries(&Message::user("first"), now, false); let first_tip = state.last_message_uuid.unwrap(); - let (entries, seed) = state.queue_message_entries(&Message::user("second"), now); + let (entries, seed) = state.queue_message_entries(&Message::user("second"), now, false); assert_eq!(entries.len(), 1, "no second title"); let Entry::Message { @@ -349,7 +428,7 @@ mod tests { }], }; - let (entries, seed) = state.queue_message_entries(&msg, now); + let (entries, seed) = state.queue_message_entries(&msg, now, false); assert_eq!(entries.len(), 1, "message only"); assert!(matches!(&entries[0], Entry::Message { .. })); @@ -367,7 +446,7 @@ mod tests { let mut state = SessionState::fresh(store, "m"); let now = OffsetDateTime::now_utc(); - let (entries, seed) = state.queue_message_entries(&Message::assistant("hi"), now); + let (entries, seed) = state.queue_message_entries(&Message::assistant("hi"), now, false); assert_eq!(entries.len(), 1); assert!(seed.is_none()); @@ -382,7 +461,7 @@ mod tests { let store = test_store(dir.path()); let mut state = SessionState::fresh(store, "m"); let now = OffsetDateTime::now_utc(); - let (entries, _) = state.queue_message_entries(&Message::user("hello"), now); + let (entries, _) = state.queue_message_entries(&Message::user("hello"), now, false); state.flush_entries(&entries).unwrap(); let entries = state.finish_entries(Vec::new(), now); @@ -413,7 +492,7 @@ mod tests { let tracker = FileTracker::default(); let mut state = SessionState::fresh(store, "m"); let now = OffsetDateTime::now_utc(); - let (msgs, _) = state.queue_message_entries(&Message::user("hi"), now); + let (msgs, _) = state.queue_message_entries(&Message::user("hi"), now, false); state.flush_entries(&msgs).unwrap(); for name in ["/tmp/a", "/tmp/b", "/tmp/c"] { @@ -443,7 +522,7 @@ mod tests { let store = test_store(dir.path()); let mut state = SessionState::fresh(store, "m"); let now = OffsetDateTime::now_utc(); - let (entries, _) = state.queue_message_entries(&Message::user("hi"), now); + let (entries, _) = state.queue_message_entries(&Message::user("hi"), now, false); state.flush_entries(&entries).unwrap(); let _first = state.finish_entries(Vec::new(), now); @@ -460,7 +539,7 @@ mod tests { let store = test_store(dir.path()); let mut original = SessionState::fresh(store.clone(), "m"); let now = OffsetDateTime::now_utc(); - let (entries, _) = original.queue_message_entries(&Message::user("hi"), now); + let (entries, _) = original.queue_message_entries(&Message::user("hi"), now, false); original.flush_entries(&entries).unwrap(); let parent = original.last_message_uuid; let session_id = original.session_id.to_string(); @@ -489,7 +568,7 @@ mod tests { .expect("/dev/full must be openable on Linux"); state.writer_status = WriterStatus::Active(writer); - let (entries, _) = state.queue_message_entries(&Message::user("hi"), now); + let (entries, _) = state.queue_message_entries(&Message::user("hi"), now, false); let result = state.flush_entries(&entries); assert!(result.is_err(), "flush to /dev/full must surface ENOSPC"); @@ -508,12 +587,12 @@ mod tests { let now = OffsetDateTime::now_utc(); let session_id = state.session_id.to_string(); - let (entries, _) = state.queue_message_entries(&Message::user("first"), now); + let (entries, _) = state.queue_message_entries(&Message::user("first"), now, false); state.flush_entries(&entries).unwrap(); assert!(matches!(state.writer_status, WriterStatus::Active(_))); state.writer_status = WriterStatus::Broken; - let (entries, _) = state.queue_message_entries(&Message::user("second"), now); + let (entries, _) = state.queue_message_entries(&Message::user("second"), now, false); state.flush_entries(&entries).unwrap(); assert!( @@ -534,22 +613,48 @@ 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. + fn flush_entries_pending_create_failure_preserves_deferred_title() { + // The next batch must retry create rather than open_append a file that was never + // created — and the deferred title must survive into the retry. A regression that + // restored `Pending { deferred_title: None }` on rollback would silently drop the + // user's `/rename`. let dir = tempfile::tempdir().unwrap(); let store = test_store(dir.path()); let mut state = SessionState::fresh(store, "m"); let now = OffsetDateTime::now_utc(); + let session_id = state.session_id.to_string(); + assert!( + state + .try_defer_title("Survives rollback".to_owned()) + .is_none(), + "fresh state must accept defer", + ); + let project_dir = super::super::store::test_project_dir(dir.path()); std::fs::remove_dir_all(&project_dir).unwrap(); - - let (entries, _) = state.queue_message_entries(&Message::user("first"), now); + let (entries, _) = state.queue_message_entries(&Message::user("first"), now, false); let result = state.flush_entries(&entries); assert!(result.is_err(), "create must fail with project dir gone"); + let WriterStatus::Pending { + deferred_title: Some(restored), + .. + } = &state.writer_status + else { + panic!("create failure must leave Pending with deferred title intact"); + }; + assert_eq!( + restored, "Survives rollback", + "deferred title must survive rollback verbatim", + ); + + std::fs::create_dir_all(&project_dir).unwrap(); + state.flush_entries(&entries).expect("retry succeeds"); + let path = super::super::store::test_session_file(dir.path(), &session_id); + let content = std::fs::read_to_string(path).unwrap(); assert!( - matches!(state.writer_status, WriterStatus::Pending { .. }), - "create failure must leave Pending intact for next-batch retry", + content.contains("Survives rollback"), + "deferred title reaches disk on retry: {content}", ); } diff --git a/crates/oxide-code/src/session/title_generator.rs b/crates/oxide-code/src/session/title_generator.rs index 90635afe..74444435 100644 --- a/crates/oxide-code/src/session/title_generator.rs +++ b/crates/oxide-code/src/session/title_generator.rs @@ -83,6 +83,11 @@ async fn generate_and_record( .context("Haiku completion failed")?; let title = parse_title(&raw).context("Haiku returned a malformed title")?; + // Skip if the user ran `/rename` while Haiku was thinking; otherwise the status bar would + // briefly flash the AI title before the actor drops it. + if session.manual_title_set() { + return Ok(()); + } let outcome = session.append_ai_title(title.clone()).await; if let Some(failure) = outcome.failure.as_deref() { if !session.is_actor_alive() { @@ -304,6 +309,38 @@ mod tests { ); } + #[tokio::test] + async fn generate_and_record_skips_emit_when_manual_title_was_set_during_haiku_call() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(wm_path("/v1/messages")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(completion_body(r#"{"title":"AI loses race"}"#)), + ) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let session = prepared_session(dir.path()).await; + session.set_manual_title("User wins".to_owned()).await; + + let client = title_client(server.uri()); + let sink = CapturingSink::new(); + + generate_and_record(&client, &session, &sink, "first prompt") + .await + .unwrap(); + + let events = sink.events(); + assert!( + !events + .iter() + .any(|e| matches!(e, AgentEvent::SessionTitleUpdated { .. })), + "AI title must not emit when manual title is already set: {events:?}", + ); + } + #[tokio::test] async fn generate_and_record_unwraps_code_fenced_json_envelope() { let server = MockServer::start().await; diff --git a/crates/oxide-code/src/slash.rs b/crates/oxide-code/src/slash.rs index 561f925b..72f199d4 100644 --- a/crates/oxide-code/src/slash.rs +++ b/crates/oxide-code/src/slash.rs @@ -20,6 +20,7 @@ mod model; mod parser; mod picker; mod registry; +mod rename; mod resume; mod status; mod theme; diff --git a/crates/oxide-code/src/slash/config.rs b/crates/oxide-code/src/slash/config.rs index 6074da23..0f6aa3d7 100644 --- a/crates/oxide-code/src/slash/config.rs +++ b/crates/oxide-code/src/slash/config.rs @@ -18,7 +18,7 @@ impl SlashCommand for ConfigCmd { } fn description(&self) -> &'static str { - "Show the resolved configuration and the layered files (~/.config/ox/config.toml, ./ox.toml) it was assembled from" + "Show resolved config" } fn echoes_input(&self, _args: &str) -> bool { diff --git a/crates/oxide-code/src/slash/context.rs b/crates/oxide-code/src/slash/context.rs index f0b7b74c..0753582c 100644 --- a/crates/oxide-code/src/slash/context.rs +++ b/crates/oxide-code/src/slash/context.rs @@ -30,14 +30,25 @@ impl LiveSessionInfo { pub(crate) struct SlashContext<'a> { pub(crate) chat: &'a mut ChatView, pub(crate) info: &'a LiveSessionInfo, + /// Live session title — `/rename` pre-fills its modal from here. `None` until seeded. + pub(crate) title: Option<&'a str>, modal: Option<Box<dyn Modal>>, } impl<'a> SlashContext<'a> { pub(crate) fn new(chat: &'a mut ChatView, info: &'a LiveSessionInfo) -> Self { + Self::with_title(chat, info, None) + } + + pub(crate) fn with_title( + chat: &'a mut ChatView, + info: &'a LiveSessionInfo, + title: Option<&'a str>, + ) -> Self { Self { chat, info, + title, modal: None, } } diff --git a/crates/oxide-code/src/slash/diff.rs b/crates/oxide-code/src/slash/diff.rs index 8461944d..f4704a71 100644 --- a/crates/oxide-code/src/slash/diff.rs +++ b/crates/oxide-code/src/slash/diff.rs @@ -23,7 +23,7 @@ impl SlashCommand for DiffCmd { } fn description(&self) -> &'static str { - "Show uncommitted working-tree changes (`git diff HEAD`) and the names of any untracked files" + "Show uncommitted working-tree changes" } fn execute(&self, _args: &str, ctx: &mut SlashContext<'_>) -> Result<SlashOutcome, String> { diff --git a/crates/oxide-code/src/slash/effort.rs b/crates/oxide-code/src/slash/effort.rs index 07c884c1..57213ab0 100644 --- a/crates/oxide-code/src/slash/effort.rs +++ b/crates/oxide-code/src/slash/effort.rs @@ -19,7 +19,7 @@ impl SlashCommand for EffortCmd { } fn description(&self) -> &'static str { - "Open the effort slider or switch directly with `/effort <level>`" + "Adjust reasoning effort" } fn classify(&self, args: &str) -> SlashKind { diff --git a/crates/oxide-code/src/slash/help.rs b/crates/oxide-code/src/slash/help.rs index 1071d1ae..facc7512 100644 --- a/crates/oxide-code/src/slash/help.rs +++ b/crates/oxide-code/src/slash/help.rs @@ -15,7 +15,7 @@ impl SlashCommand for HelpCmd { } fn description(&self) -> &'static str { - "List the available slash commands and their usage hints" + "List slash commands" } fn echoes_input(&self, _args: &str) -> bool { diff --git a/crates/oxide-code/src/slash/init.rs b/crates/oxide-code/src/slash/init.rs index 9d4b910d..ddf6be81 100644 --- a/crates/oxide-code/src/slash/init.rs +++ b/crates/oxide-code/src/slash/init.rs @@ -15,7 +15,7 @@ impl SlashCommand for InitCmd { } fn description(&self) -> &'static str { - "Generate or update the project's `AGENTS.md` / `CLAUDE.md`" + "Set up `AGENTS.md` / `CLAUDE.md`" } fn classify(&self, _args: &str) -> SlashKind { diff --git a/crates/oxide-code/src/slash/matcher.rs b/crates/oxide-code/src/slash/matcher.rs index d2f1b212..c2861865 100644 --- a/crates/oxide-code/src/slash/matcher.rs +++ b/crates/oxide-code/src/slash/matcher.rs @@ -281,7 +281,7 @@ mod tests { } #[test] - fn rank_by_prefix_promotes_prefix_matches_above_substring_within_declared_order() { + fn rank_by_prefix_promotes_prefix_above_substring() { // `h` prefixes `high`; substring-matches `xhigh`. Prefix wins; declared order otherwise. assert_eq!(ranked("h"), vec!["high", "xhigh"]); } diff --git a/crates/oxide-code/src/slash/model.rs b/crates/oxide-code/src/slash/model.rs index 6003ba1e..04b83263 100644 --- a/crates/oxide-code/src/slash/model.rs +++ b/crates/oxide-code/src/slash/model.rs @@ -30,7 +30,7 @@ impl SlashCommand for ModelCmd { } fn description(&self) -> &'static str { - "Open the model picker or switch directly with `/model <id>`" + "Switch the model" } fn classify(&self, args: &str) -> SlashKind { diff --git a/crates/oxide-code/src/slash/registry.rs b/crates/oxide-code/src/slash/registry.rs index 3425770c..8b6eb228 100644 --- a/crates/oxide-code/src/slash/registry.rs +++ b/crates/oxide-code/src/slash/registry.rs @@ -10,6 +10,7 @@ use super::effort::EffortCmd; use super::help::HelpCmd; use super::init::InitCmd; use super::model::ModelCmd; +use super::rename::RenameCmd; use super::resume::ResumeCmd; use super::status::StatusCmd; use super::theme::ThemeCmd; @@ -79,8 +80,8 @@ pub(crate) trait SlashCommand: Sync { /// Alphabetical for stable `/help` and empty-query popup ordering. pub(super) const BUILT_INS: &[&dyn SlashCommand] = &[ - &ClearCmd, &ConfigCmd, &DiffCmd, &EffortCmd, &HelpCmd, &InitCmd, &ModelCmd, &ResumeCmd, - &StatusCmd, &ThemeCmd, + &ClearCmd, &ConfigCmd, &DiffCmd, &EffortCmd, &HelpCmd, &InitCmd, &ModelCmd, &RenameCmd, + &ResumeCmd, &StatusCmd, &ThemeCmd, ]; /// Resolves `name` against canonical names first, then aliases. diff --git a/crates/oxide-code/src/slash/rename.rs b/crates/oxide-code/src/slash/rename.rs new file mode 100644 index 00000000..1fc6fa6e --- /dev/null +++ b/crates/oxide-code/src/slash/rename.rs @@ -0,0 +1,447 @@ +//! `/rename` — set the session title manually. Bare opens a modal pre-filled with the current +//! title; `/rename <title>` applies immediately. Suppresses AI title generation for the rest of +//! the session so a slow Haiku response can't overwrite the user's pick. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::Modifier; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use unicode_width::UnicodeWidthStr; + +use super::context::SlashContext; +use super::registry::{SlashCommand, SlashKind, SlashOutcome}; +use crate::agent::event::UserAction; +use crate::tui::modal::{Modal, ModalAction, ModalKey}; +use crate::tui::theme::Theme; +use crate::util::text::truncate_to_width; + +const MODAL_TITLE: &str = "Rename session"; +const PROMPT: &str = "/ "; +const PROMPT_WIDTH: u16 = 2; +const FOOTER_HINT: &str = "Enter to save · Esc to cancel"; +const TITLE_ROW_HEIGHT: u16 = 1; +const SECTION_GAP: u16 = 1; +const INPUT_ROW_HEIGHT: u16 = 1; +const FOOTER_ROW_HEIGHT: u16 = 1; +/// Mirrors the actor's first-prompt title cap so manual titles fit one `--list` row. +const MAX_TITLE_CHARS: usize = 80; + +// ── RenameCmd ── + +pub(super) struct RenameCmd; + +impl SlashCommand for RenameCmd { + fn name(&self) -> &'static str { + "rename" + } + + fn description(&self) -> &'static str { + "Rename the current session" + } + + fn classify(&self, _args: &str) -> SlashKind { + SlashKind::Mutating + } + + fn usage(&self) -> Option<&'static str> { + Some("[<title>]") + } + + fn echoes_input(&self, args: &str) -> bool { + !args.trim().is_empty() + } + + fn execute(&self, args: &str, ctx: &mut SlashContext<'_>) -> Result<SlashOutcome, String> { + let trimmed = args.trim(); + if trimmed.is_empty() { + ctx.open_modal(Box::new(RenameModal::new(ctx.title))); + return Ok(SlashOutcome::Done); + } + Ok(SlashOutcome::Forward(UserAction::Rename { + title: trimmed.to_owned(), + })) + } +} + +// ── RenameModal ── + +pub(super) struct RenameModal { + buffer: String, +} + +impl RenameModal { + fn new(initial: Option<&str>) -> Self { + Self { + buffer: initial + .map(|t| t.chars().take(MAX_TITLE_CHARS).collect()) + .unwrap_or_default(), + } + } + + fn submit(&self) -> ModalKey { + let trimmed = self.buffer.trim(); + if trimmed.is_empty() { + return ModalKey::Consumed; + } + ModalKey::Submitted(ModalAction::User(UserAction::Rename { + title: trimmed.to_owned(), + })) + } +} + +impl Modal for RenameModal { + fn height(&self, _width: u16) -> u16 { + TITLE_ROW_HEIGHT + SECTION_GAP + INPUT_ROW_HEIGHT + SECTION_GAP + FOOTER_ROW_HEIGHT + } + + fn render(&self, frame: &mut Frame<'_>, area: Rect, theme: &Theme) { + let lines: Vec<Line<'static>> = vec![ + Line::from(Span::styled( + MODAL_TITLE.to_owned(), + theme.accent().add_modifier(Modifier::BOLD), + )), + Line::default(), + self.input_row(area.width, theme), + Line::default(), + Line::from(Span::styled(FOOTER_HINT.to_owned(), theme.dim())), + ]; + frame.render_widget(Paragraph::new(lines).style(theme.surface()), area); + self.place_cursor(frame, area); + } + + fn handle_key(&mut self, event: &KeyEvent) -> ModalKey { + match event.code { + KeyCode::Enter => self.submit(), + KeyCode::Backspace => { + self.buffer.pop(); + ModalKey::Consumed + } + KeyCode::Char(c) => { + if self.buffer.chars().count() < MAX_TITLE_CHARS { + self.buffer.push(c); + } + ModalKey::Consumed + } + _ => ModalKey::Consumed, + } + } +} + +impl RenameModal { + fn input_row(&self, area_width: u16, theme: &Theme) -> Line<'static> { + let prompt = Span::styled(PROMPT.to_owned(), theme.accent()); + let budget = usize::from(area_width.saturating_sub(PROMPT_WIDTH + 1)); + let shown = truncate_to_width(&self.buffer, budget); + let body = Span::styled(shown, theme.text()); + Line::from(vec![prompt, body]) + } + + fn place_cursor(&self, frame: &mut Frame<'_>, area: Rect) { + let input_y_offset = TITLE_ROW_HEIGHT + SECTION_GAP; + if input_y_offset >= area.height { + return; + } + let cursor_y = area.y.saturating_add(input_y_offset); + let visible_width = + u16::try_from(UnicodeWidthStr::width(self.buffer.as_str())).unwrap_or(u16::MAX); + let raw_x = area + .x + .saturating_add(PROMPT_WIDTH) + .saturating_add(visible_width); + crate::tui::cursor::place_clamped(frame, raw_x, cursor_y, area); + } +} + +#[cfg(test)] +mod tests { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + use super::*; + use crate::slash::context::{LiveSessionInfo, SlashContext}; + use crate::tui::components::chat::ChatView; + + fn ctx_with_title<'a>( + chat: &'a mut ChatView, + info: &'a LiveSessionInfo, + title: Option<&'a str>, + ) -> SlashContext<'a> { + SlashContext::with_title(chat, info, title) + } + + fn fresh_chat() -> ChatView { + ChatView::new(&Theme::default(), false) + } + + // ── RenameCmd metadata ── + + #[test] + fn metadata_matches_built_ins_contract() { + assert_eq!(RenameCmd.name(), "rename"); + assert!(RenameCmd.aliases().is_empty()); + assert!(!RenameCmd.description().is_empty()); + assert_eq!(RenameCmd.usage(), Some("[<title>]")); + } + + #[test] + fn classify_is_always_mutating() { + assert_eq!(RenameCmd.classify(""), SlashKind::Mutating); + assert_eq!(RenameCmd.classify("New Title"), SlashKind::Mutating); + } + + #[test] + fn echoes_input_only_for_typed_arg_form() { + assert!(!RenameCmd.echoes_input("")); + assert!(!RenameCmd.echoes_input(" ")); + assert!(RenameCmd.echoes_input("New Title")); + } + + // ── RenameCmd::execute ── + + #[test] + fn execute_typed_arg_forwards_rename_action_with_trimmed_title() { + let mut chat = fresh_chat(); + let info = crate::slash::test_session_info(); + let mut ctx = ctx_with_title(&mut chat, &info, None); + + let outcome = RenameCmd.execute(" Fix auth bug ", &mut ctx).unwrap(); + + assert_eq!( + outcome, + SlashOutcome::Forward(UserAction::Rename { + title: "Fix auth bug".to_owned(), + }), + ); + assert!( + ctx.take_modal().is_none(), + "typed-arg form does not open a modal" + ); + } + + #[test] + fn execute_bare_form_opens_modal_pre_filled_with_current_title() { + let mut chat = fresh_chat(); + let info = crate::slash::test_session_info(); + let mut ctx = ctx_with_title(&mut chat, &info, Some("Existing title")); + + let outcome = RenameCmd.execute("", &mut ctx).unwrap(); + + assert_eq!(outcome, SlashOutcome::Done); + let mut modal = ctx.take_modal().expect("bare form opens a modal"); + let result = modal.handle_key(&KeyEvent::from(KeyCode::Enter)); + let ModalKey::Submitted(ModalAction::User(UserAction::Rename { title })) = result else { + panic!("Enter on pre-filled buffer must submit; got {result:?}"); + }; + assert_eq!(title, "Existing title"); + } + + #[test] + fn execute_bare_form_with_no_current_title_opens_empty_modal() { + let mut chat = fresh_chat(); + let info = crate::slash::test_session_info(); + let mut ctx = ctx_with_title(&mut chat, &info, None); + + let _ = RenameCmd.execute("", &mut ctx).unwrap(); + let mut modal = ctx.take_modal().expect("modal opens"); + let result = modal.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert!( + matches!(result, ModalKey::Consumed), + "empty Enter must not submit; got {result:?}", + ); + } + + // ── RenameModal::handle_key ── + + #[test] + fn handle_key_typing_appends_to_buffer_and_enter_submits_trimmed_title() { + let mut modal = RenameModal::new(None); + for ch in " Fix auth ".chars() { + assert!(matches!( + modal.handle_key(&KeyEvent::from(KeyCode::Char(ch))), + ModalKey::Consumed, + )); + } + let outcome = modal.handle_key(&KeyEvent::from(KeyCode::Enter)); + let ModalKey::Submitted(ModalAction::User(UserAction::Rename { title })) = outcome else { + panic!("trim happens at submit time; got {outcome:?}"); + }; + assert_eq!(title, "Fix auth"); + } + + #[test] + fn handle_key_backspace_removes_last_char_when_buffer_non_empty() { + let mut modal = RenameModal::new(Some("ab")); + assert!(matches!( + modal.handle_key(&KeyEvent::from(KeyCode::Backspace)), + ModalKey::Consumed, + )); + let outcome = modal.handle_key(&KeyEvent::from(KeyCode::Enter)); + let ModalKey::Submitted(ModalAction::User(UserAction::Rename { title })) = outcome else { + panic!("expected Submitted, got {outcome:?}"); + }; + assert_eq!(title, "a"); + } + + #[test] + fn handle_key_backspace_on_empty_buffer_is_a_silent_noop() { + let mut modal = RenameModal::new(None); + assert!(matches!( + modal.handle_key(&KeyEvent::from(KeyCode::Backspace)), + ModalKey::Consumed, + )); + } + + #[test] + fn handle_key_char_at_max_length_drops_extra_input() { + // Cap mirrors `session::state::MAX_TITLE_LEN` — over-long titles overflow `--list` rows. + let prefilled: String = "a".repeat(MAX_TITLE_CHARS); + let mut modal = RenameModal::new(Some(&prefilled)); + modal.handle_key(&KeyEvent::from(KeyCode::Char('z'))); + let outcome = modal.handle_key(&KeyEvent::from(KeyCode::Enter)); + let ModalKey::Submitted(ModalAction::User(UserAction::Rename { title })) = outcome else { + panic!("expected Submitted, got {outcome:?}"); + }; + assert_eq!(title.chars().count(), MAX_TITLE_CHARS, "extra char dropped"); + assert!(!title.contains('z'), "the dropped char must not appear"); + } + + #[test] + fn handle_key_unhandled_codes_are_silently_consumed_without_buffer_change() { + let mut modal = RenameModal::new(Some("ab")); + let outcome = modal.handle_key(&KeyEvent::from(KeyCode::Tab)); + + assert!( + matches!(outcome, ModalKey::Consumed), + "non-text keys must consume without action; got {outcome:?}", + ); + let submit = modal.handle_key(&KeyEvent::from(KeyCode::Enter)); + let ModalKey::Submitted(ModalAction::User(UserAction::Rename { title })) = submit else { + panic!("buffer should still submit cleanly; got {submit:?}"); + }; + assert_eq!(title, "ab", "Tab must not have mutated the buffer"); + } + + #[test] + fn handle_key_blank_only_buffer_does_not_submit() { + let mut modal = RenameModal::new(None); + for _ in 0..3 { + modal.handle_key(&KeyEvent::from(KeyCode::Char(' '))); + } + let outcome = modal.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert!( + matches!(outcome, ModalKey::Consumed), + "all-whitespace Enter must not submit; got {outcome:?}", + ); + } + + // ── Modal::render ── + + fn rendered_buffer(modal: &RenameModal, width: u16) -> ratatui::buffer::Buffer { + let theme = Theme::default(); + let h = modal.height(width); + let mut terminal = Terminal::new(TestBackend::new(width, h)).unwrap(); + terminal + .draw(|frame| modal.render(frame, Rect::new(0, 0, width, h), &theme)) + .expect("render must not panic"); + terminal.backend().buffer().clone() + } + + fn row_text(buf: &ratatui::buffer::Buffer, y: u16, width: u16) -> String { + (0..width).map(|x| buf[(x, y)].symbol()).collect() + } + + #[test] + fn render_lays_out_title_prompt_and_footer_in_order() { + let modal = RenameModal::new(Some("Fix auth")); + let width: u16 = 40; + let buf = rendered_buffer(&modal, width); + + assert!( + row_text(&buf, 0, width).contains(MODAL_TITLE), + "title row missing: {:?}", + row_text(&buf, 0, width), + ); + let input = row_text(&buf, 2, width); + assert!( + input.starts_with("/ Fix auth"), + "input row should show prompt + buffer; got {input:?}", + ); + assert!( + row_text(&buf, 4, width).contains(FOOTER_HINT), + "footer hint missing: {:?}", + row_text(&buf, 4, width), + ); + } + + #[test] + fn render_truncates_buffer_wider_than_input_row_budget() { + let long: String = "x".repeat(100); + let modal = RenameModal::new(Some(&long)); + let width: u16 = 20; + let buf = rendered_buffer(&modal, width); + + let input = row_text(&buf, 2, width); + assert!( + input.contains(crate::util::text::ELLIPSIS), + "narrow width must produce an ellipsis; got {input:?}", + ); + } + + #[test] + fn render_places_cursor_after_visible_buffer_text() { + let theme = Theme::default(); + let modal = RenameModal::new(Some("ab")); + let width: u16 = 40; + let h = modal.height(width); + let mut terminal = Terminal::new(TestBackend::new(width, h)).unwrap(); + terminal + .draw(|frame| modal.render(frame, Rect::new(0, 0, width, h), &theme)) + .unwrap(); + let pos = terminal.get_cursor_position().unwrap(); + + // Input row sits at y = title (1) + gap (1) = 2; cursor lands after "/ ab" → x = 4. + assert_eq!( + (pos.x, pos.y), + (4, 2), + "cursor must land after the rendered buffer", + ); + } + + #[test] + fn render_clamps_cursor_when_buffer_overflows_visible_row() { + let theme = Theme::default(); + let long: String = "x".repeat(100); + let modal = RenameModal::new(Some(&long)); + let width: u16 = 20; + let h = modal.height(width); + let mut terminal = Terminal::new(TestBackend::new(width, h)).unwrap(); + terminal + .draw(|frame| modal.render(frame, Rect::new(0, 0, width, h), &theme)) + .unwrap(); + let pos = terminal.get_cursor_position().unwrap(); + + assert_eq!(pos.x, width - 1, "cursor must clamp to the right edge"); + } + + #[test] + fn render_skips_cursor_when_area_too_short_for_input_row() { + let theme = Theme::default(); + let modal = RenameModal::new(Some("ab")); + let width: u16 = 40; + let mut terminal = Terminal::new(TestBackend::new(width, 5)).unwrap(); + // Render the modal into an oversized terminal but pass a 1-row Rect so the input row is + // entirely off-screen. `place_cursor` should bail without panicking. + terminal + .draw(|frame| modal.render(frame, Rect::new(0, 0, width, 1), &theme)) + .expect("short-area render must not panic"); + } + + #[test] + fn height_sums_to_five_rows() { + // Title (1) + gap (1) + input (1) + gap (1) + footer (1) = 5; pinning lets render tests + // assume the input row sits at y=2. + assert_eq!(RenameModal::new(None).height(40), 5); + } +} diff --git a/crates/oxide-code/src/slash/resume.rs b/crates/oxide-code/src/slash/resume.rs index 21a4a0da..5fda2324 100644 --- a/crates/oxide-code/src/slash/resume.rs +++ b/crates/oxide-code/src/slash/resume.rs @@ -30,8 +30,8 @@ const PICKER_DESCRIPTION: &str = "Pick a session to resume in place."; const VIEWPORT_HEIGHT: u16 = 6; const UNTITLED_MARKER: &str = "(untitled)"; const ID_WIDTH: usize = 8; -/// Each row paints a title line + a metadata line (Claude Code-style two-line layout). -const ROW_HEIGHT: u16 = 2; +/// Each row paints title + metadata + a trailing blank for breathing room between sessions. +const ROW_HEIGHT: u16 = 3; /// Floor on the title column so narrow terminals still show a truncated label. const TITLE_FLOOR: usize = 8; /// Visual separator between metadata segments. @@ -93,18 +93,14 @@ impl SearchableItem for SessionRow { } fn render(&self, width: u16, is_cursor: bool, theme: &Theme) -> Vec<Line<'static>> { - // Three-tier hierarchy via the theme's `text` / `muted` / `dim` slots: cursor row paints - // `text + bold` (primary), non-cursor titles take `muted` (secondary, distinct fg from - // metadata), metadata stays on `dim` (tertiary). Dimming `text` with `Modifier::DIM` - // would keep titles in the same color family as metadata and read flat at a glance. + let budget = usize::from(width).max(TITLE_FLOOR); let title_style = if is_cursor { - theme.text().add_modifier(ratatui::style::Modifier::BOLD) + theme.accent().add_modifier(ratatui::style::Modifier::BOLD) } else { - theme.muted() + theme.text() }; - let title_budget = usize::from(width).max(TITLE_FLOOR); let title_line = Line::from(Span::styled( - truncate_to_width(&self.title, title_budget), + truncate_to_width(&self.title, budget), title_style, )); @@ -128,12 +124,9 @@ impl SearchableItem for SessionRow { meta.push_str(META_SEPARATOR); meta.push_str(project); } - let meta_line = Line::from(Span::styled( - truncate_to_width(&meta, usize::from(width).max(TITLE_FLOOR)), - theme.dim(), - )); + let meta_line = Line::from(Span::styled(truncate_to_width(&meta, budget), theme.dim())); - vec![title_line, meta_line] + vec![title_line, meta_line, Line::default()] } fn row_height() -> u16 { @@ -354,7 +347,7 @@ impl SlashCommand for ResumeCmd { } fn description(&self) -> &'static str { - "Resume a previous session — `/resume` for the picker, `/resume <id-prefix>` to jump" + "Resume a previous session" } fn classify(&self, _args: &str) -> SlashKind { @@ -582,7 +575,11 @@ mod tests { let row = SessionRow::from_info(info, UtcOffset::UTC, true); let theme = Theme::default(); let lines = row.render(60, false, &theme); - assert_eq!(lines.len(), 2, "row must paint two terminal rows"); + assert_eq!( + lines.len(), + 3, + "row must paint title + meta + trailing blank" + ); let title_text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); let meta_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect(); assert!(title_text.contains("Fix auth"), "title row: {title_text}"); @@ -591,13 +588,14 @@ mod tests { meta_text.contains(" ago") || meta_text.contains('-'), "meta row must carry a relative time or ISO date: {meta_text}", ); + assert!( + lines[2].spans.iter().all(|s| s.content.is_empty()), + "trailing row must be blank for between-row breathing room", + ); } #[test] - fn render_uses_three_distinct_foregrounds_for_cursor_unselected_and_metadata() { - // Selected title → `text` + bold; unselected → `muted`; metadata → `dim`. Three distinct - // foregrounds keep the rows scannable; a regression that flattens any pair (e.g. dimming - // `text` with `Modifier::DIM`) would make titles and metadata read alike. + fn render_uses_two_distinct_foregrounds_with_bold_marking_the_cursor_row() { let info = raw_session_info( stamped_id(0xab), "/work/oxide", @@ -608,31 +606,41 @@ mod tests { let theme = Theme::default(); let cursor = row.render(60, true, &theme); - let cursor_title = cursor[0].spans[0].style; - assert_eq!(cursor_title.fg, theme.text.fg, "selected uses text fg"); + assert_eq!( + cursor[0].spans[0].style.fg, theme.accent.fg, + "cursor title uses accent fg", + ); assert!( - cursor_title + cursor[0].spans[0] + .style .add_modifier .contains(ratatui::style::Modifier::BOLD), - "selected is bold", + "cursor title is bold", ); let unselected = row.render(60, false, &theme); assert_eq!( - unselected[0].spans[0].style.fg, theme.muted.fg, - "unselected title uses muted fg", + unselected[0].spans[0].style.fg, theme.text.fg, + "non-cursor title uses text fg", + ); + assert!( + !unselected[0].spans[0] + .style + .add_modifier + .contains(ratatui::style::Modifier::BOLD), + "non-cursor title is not bold", + ); + assert_ne!( + theme.accent.fg, theme.text.fg, + "cursor vs non-cursor must use distinct fgs", ); assert_eq!( unselected[1].spans[0].style.fg, theme.dim.fg, "metadata uses dim fg", ); assert_ne!( - theme.text.fg, theme.muted.fg, - "tier 1 vs 2 must be distinct fgs", - ); - assert_ne!( - theme.muted.fg, theme.dim.fg, - "tier 2 vs 3 must be distinct fgs", + theme.text.fg, theme.dim.fg, + "title vs metadata must be distinct fgs", ); } diff --git a/crates/oxide-code/src/slash/status.rs b/crates/oxide-code/src/slash/status.rs index 2321ebd1..67d67ced 100644 --- a/crates/oxide-code/src/slash/status.rs +++ b/crates/oxide-code/src/slash/status.rs @@ -14,7 +14,7 @@ impl SlashCommand for StatusCmd { } fn description(&self) -> &'static str { - "Show session info: model, effort, version, working directory, auth source, and session ID" + "Show current session info" } fn echoes_input(&self, _args: &str) -> bool { diff --git a/crates/oxide-code/src/slash/theme.rs b/crates/oxide-code/src/slash/theme.rs index 46a6f13a..bf10fd19 100644 --- a/crates/oxide-code/src/slash/theme.rs +++ b/crates/oxide-code/src/slash/theme.rs @@ -179,7 +179,7 @@ impl SlashCommand for ThemeCmd { } fn description(&self) -> &'static str { - "Open the theme picker or switch directly with `/theme <name>`" + "Change the theme" } fn classify(&self, args: &str) -> SlashKind { diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index 948bf5ed..ad92bacd 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -240,7 +240,6 @@ impl App { && let Some(prompt) = self.pending_prompts.pop_back() { self.input.set_text(&prompt); - self.sync_input_queue_hint(); } } @@ -297,7 +296,7 @@ impl App { self.should_quit = true; true } - UserAction::Clear | UserAction::SwapConfig { .. } => true, + UserAction::Clear | UserAction::Rename { .. } | UserAction::SwapConfig { .. } => true, UserAction::Resume { .. } => { // Disable input until the SessionResumed event fires — otherwise a typed prompt // in the gap between forward and event would push into chat, then get wiped by @@ -341,7 +340,11 @@ impl App { self.chat.push_user_message(text.to_owned()); } let (synthesized, modal) = { - let mut ctx = SlashContext::new(&mut self.chat, &self.session_info); + let mut ctx = SlashContext::with_title( + &mut self.chat, + &self.session_info, + self.status_bar.title(), + ); let action = slash::dispatch(&parsed, &mut ctx); (action, ctx.take_modal()) }; @@ -391,7 +394,6 @@ impl App { return false; } self.pending_prompts.push_back(text.to_owned()); - self.sync_input_queue_hint(); // Skip forwarding while cancelling — the agent resets and we drain locally on idle. !matches!(self.status_bar.status(), Status::Cancelling) } @@ -437,7 +439,6 @@ impl App { AgentEvent::PromptDrained(text) => { self.pending_prompts.pop_front(); self.chat.push_user_message(text); - self.sync_input_queue_hint(); } AgentEvent::TurnComplete => { self.finish_turn(); @@ -526,7 +527,6 @@ impl App { // Belt-and-suspenders: the picker auto-pops on Submit, but a future nested overlay // would otherwise carry across the swap. self.modals.clear(); - self.sync_input_queue_hint(); self.finalize_idle(); } @@ -542,11 +542,6 @@ impl App { if let Some(prompt) = self.pending_prompts.pop_front() { self.dispatch_user_action(UserAction::SubmitPrompt(prompt)); } - self.sync_input_queue_hint(); - } - - fn sync_input_queue_hint(&mut self) { - self.input.set_has_queued(!self.pending_prompts.is_empty()); } /// Sets busy status unless a user-acknowledgement status is showing. @@ -1557,7 +1552,7 @@ mod tests { } #[tokio::test] - async fn dispatch_arg_bearing_slash_during_busy_refuses_with_system_message_no_forward() { + async fn dispatch_arg_bearing_slash_during_busy_refuses_locally() { // `/model <id>` and `/effort <level>` both mutate Client; pin both so a regression // that special-cases only one leaks the other. for (cmd, gate_phrase) in [ @@ -1583,7 +1578,7 @@ mod tests { async fn dispatch_bare_slash_during_busy_opens_modal_picker() { // 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`.) + // `dispatch_arg_bearing_slash_during_busy_refuses_locally`.) let (mut app, _rx, _agent_tx) = test_app(None); app.dispatch_user_action(UserAction::SubmitPrompt("active".to_owned())); @@ -1818,7 +1813,7 @@ mod tests { } #[test] - fn handle_config_changed_with_model_swap_refreshes_status_bar_session_info_and_chat() { + fn handle_config_changed_model_swap_refreshes_status_and_chat() { // Three surfaces refresh in one shot: status-bar label, // `session_info` (backs `/status` / `/config`), and a chat // confirmation block. Display name is derived locally from `model_id`. @@ -2027,7 +2022,7 @@ mod tests { } #[test] - fn handle_session_resumed_swaps_id_replays_transcript_and_clears_pending_state() { + fn handle_session_resumed_replays_transcript_and_clears_pending() { let (mut app, _rx, _agent_tx) = test_app(Some("Old")); app.chat.push_user_message("live prompt".to_owned()); app.pending_prompts.push_back("queued".to_owned()); @@ -2659,13 +2654,13 @@ mod tests { #[test] fn draw_frame_hides_input_and_popup_while_modal_active() { - // Modal owns focus, so the layout must collapse the input + popup bands. The idle - // placeholder is the cheapest substring proof that the input got rendered. + // Modal owns focus, so the layout must collapse the input + popup bands. The user-prompt + // marker (`❯`) is the cheapest substring proof that the input got rendered. let (mut app, _rx, _agent_tx) = test_app(None); let baseline = rendered_text(&mut app, 60, 14); assert!( - baseline.contains("Ask anything..."), - "input placeholder must paint without a modal: {baseline}", + baseline.contains(USER_PROMPT_PREFIX), + "input prompt marker must paint without a modal: {baseline}", ); app.modals @@ -2676,7 +2671,7 @@ mod tests { "modal body must render: {with_modal}", ); assert!( - !with_modal.contains("Ask anything..."), + !with_modal.contains(USER_PROMPT_PREFIX), "input must collapse while modal is active: {with_modal}", ); diff --git a/crates/oxide-code/src/tui/components/input.rs b/crates/oxide-code/src/tui/components/input.rs index c2512104..56ac5526 100644 --- a/crates/oxide-code/src/tui/components/input.rs +++ b/crates/oxide-code/src/tui/components/input.rs @@ -30,10 +30,6 @@ enum PopupKey { Pass, } -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"; - // ── InputArea ── /// Multi-line input area with dynamic height and slash-command popup. @@ -42,7 +38,6 @@ pub(crate) struct InputArea { textarea: TextArea<'static>, popup: SlashPopup, enabled: bool, - has_queued: bool, /// `Cell` because `render(&self)` is immutable. last_width: Cell<u16>, scroll_top: Cell<u16>, @@ -55,46 +50,29 @@ impl InputArea { let mut textarea = TextArea::default(); textarea.set_cursor_line_style(Style::default()); textarea.set_style(theme.text()); - textarea.set_placeholder_style(theme.dim()); textarea.set_wrap_mode(WrapMode::Word); textarea.set_block(Block::default()); - let mut input = Self { + Self { theme: theme.clone(), textarea, enabled: true, - has_queued: false, popup: SlashPopup::new(theme), last_width: Cell::new(0), scroll_top: Cell::new(0), - }; - input.refresh_placeholder(); - input + } } /// Re-skin subsequent renders. The textarea keeps a cached `Style`, so the previous theme's - /// text / placeholder colors would persist if not reapplied here. + /// text color would persist if not reapplied here. pub(crate) fn set_theme(&mut self, theme: &Theme) { self.theme = theme.clone(); self.textarea.set_style(theme.text()); - self.textarea.set_placeholder_style(theme.dim()); self.popup.set_theme(theme); } - pub(crate) fn set_has_queued(&mut self, has_queued: bool) { - if self.has_queued == has_queued { - return; - } - self.has_queued = has_queued; - self.refresh_placeholder(); - } - pub(crate) fn set_enabled(&mut self, enabled: bool) { - if self.enabled == enabled { - return; - } self.enabled = enabled; - self.refresh_placeholder(); } pub(crate) fn is_enabled(&self) -> bool { @@ -240,11 +218,12 @@ impl InputArea { let cursor_y = textarea_area.y + cursor_row - top; if let Some(token) = self.ghost_text() { - // Paint past the cursor, clipped to the textarea's right edge so wrapping mid-token - // doesn't bleed into the next row. - let width = textarea_area.right().saturating_sub(raw_cursor_x); + // Mirror `ratatui_textarea`'s built-in placeholder layout: the cursor sits at the + // insertion column, the dim hint starts one column right. + let ghost_x = raw_cursor_x.saturating_add(1); + let width = textarea_area.right().saturating_sub(ghost_x); if width > 0 { - let area = Rect::new(raw_cursor_x, cursor_y, width, 1); + let area = Rect::new(ghost_x, cursor_y, width, 1); frame.render_widget( Paragraph::new(Line::from(Span::styled(token, self.theme.dim()))), area, @@ -259,19 +238,6 @@ impl InputArea { // ── Private Helpers ── impl InputArea { - // ── Render Helpers ── - - fn refresh_placeholder(&mut self) { - let text = if !self.enabled { - PLACEHOLDER_BUSY - } else if self.has_queued { - PLACEHOLDER_IDLE_QUEUED - } else { - PLACEHOLDER_IDLE - }; - self.textarea.set_placeholder_text(text); - } - // ── Popup & State ── fn handle_popup_key(&mut self, event: &Event) -> PopupKey { @@ -676,7 +642,7 @@ mod tests { } #[test] - fn render_empty_shows_placeholder() { + fn render_empty_buffer_shows_only_prompt_marker_and_borders() { let input = test_input(); insta::assert_snapshot!(render_to_backend(&input, 60, 3)); } @@ -760,15 +726,28 @@ mod tests { } #[test] - fn render_paints_ghost_text_at_cursor_in_arg_mode_with_empty_prefix() { + fn render_paints_ghost_text_after_cursor_in_arg_mode() { + // Mirrors the textarea's built-in placeholder layout: cursor at the insertion point, + // dim hint starts the column right of it. let mut input = test_input(); type_text(&mut input, "/model "); input.refresh_popup(); - let backend = render_to_backend(&input, 60, 3); - let rendered = format!("{backend}"); + let mut terminal = Terminal::new(TestBackend::new(60, 3)).unwrap(); + terminal.draw(|f| input.render(f, f.area())).unwrap(); + + let buf = terminal.backend().buffer(); + let row: String = (0..60).map(|x| buf[(x, 1)].symbol()).collect(); assert!( - rendered.contains("[id]"), - "ghost-text `[id]` should paint at the cursor: {rendered}", + row.contains("/model [id]"), + "ghost-text must sit one column past the cursor: {row:?}", + ); + + let pos = terminal.get_cursor_position().unwrap(); + let cursor_col = USER_PROMPT_PREFIX_WIDTH + u16::try_from("/model ".len()).unwrap(); + assert_eq!( + (pos.x, pos.y), + (cursor_col, 1), + "OS cursor must land at the typed insertion point, not on `[`", ); } @@ -831,43 +810,6 @@ mod tests { ); } - // ── refresh_placeholder ── - - fn placeholder_text(input: &InputArea) -> String { - input.textarea.placeholder_text().to_owned() - } - - #[test] - fn refresh_placeholder_idle_empty_queue_shows_default() { - let input = test_input(); - assert_eq!(placeholder_text(&input), PLACEHOLDER_IDLE); - } - - #[test] - fn refresh_placeholder_busy_shows_queue_follow_up_copy() { - let mut input = test_input(); - input.set_enabled(false); - assert_eq!(placeholder_text(&input), PLACEHOLDER_BUSY); - } - - #[test] - fn refresh_placeholder_idle_with_queue_shows_edit_hint() { - let mut input = test_input(); - input.set_has_queued(true); - assert_eq!(placeholder_text(&input), PLACEHOLDER_IDLE_QUEUED); - } - - #[test] - fn refresh_placeholder_busy_takes_precedence_over_queue() { - // Busy + queue (the user typed a follow-up while a turn is - // running): the placeholder still encourages queueing rather - // than the idle-queue copy that suggests editing. - let mut input = test_input(); - input.set_has_queued(true); - input.set_enabled(false); - assert_eq!(placeholder_text(&input), PLACEHOLDER_BUSY); - } - // ── visual_line_count ── #[test] diff --git a/crates/oxide-code/src/tui/components/input/snapshots/ox__tui__components__input__popup__tests__render_empty_name_query_shows_each_command_once.snap b/crates/oxide-code/src/tui/components/input/snapshots/ox__tui__components__input__popup__tests__render_empty_name_query_shows_each_command_once.snap index bdd5a14f..128c5bda 100644 --- a/crates/oxide-code/src/tui/components/input/snapshots/ox__tui__components__input__popup__tests__render_empty_name_query_shows_each_command_once.snap +++ b/crates/oxide-code/src/tui/components/input/snapshots/ox__tui__components__input__popup__tests__render_empty_name_query_shows_each_command_once.snap @@ -5,10 +5,10 @@ expression: "render_to_backend(&popup, 60)" --- "────────────────────────────────────────────────────────────" "/clear Reset the conversation context " -"/config Show the resolved configuration and the layered ..." -"/diff Show uncommitted working-tree changes (`git diff..." -"/effort Open the effort slider or switch directly with `..." -"/help List the available slash commands and their usag..." -"/init Generate or update the project's `AGENTS.md` / `..." -"/model Open the model picker or switch directly with `/..." -"/resume Resume a previous session — `/resume` for the pi..." +"/config Show resolved config " +"/diff Show uncommitted working-tree changes " +"/effort Adjust reasoning effort " +"/help List slash commands " +"/init Set up `AGENTS.md` / `CLAUDE.md` " +"/model Switch the model " +"/rename Rename the current session " diff --git a/crates/oxide-code/src/tui/components/input/snapshots/ox__tui__components__input__popup__tests__render_filtered_name_query_shows_only_matching_rows.snap b/crates/oxide-code/src/tui/components/input/snapshots/ox__tui__components__input__popup__tests__render_filtered_name_query_shows_only_matching_rows.snap index e0b93631..65a55fa7 100644 --- a/crates/oxide-code/src/tui/components/input/snapshots/ox__tui__components__input__popup__tests__render_filtered_name_query_shows_only_matching_rows.snap +++ b/crates/oxide-code/src/tui/components/input/snapshots/ox__tui__components__input__popup__tests__render_filtered_name_query_shows_only_matching_rows.snap @@ -1,6 +1,7 @@ --- source: crates/oxide-code/src/tui/components/input/popup.rs +assertion_line: 474 expression: "render_to_backend(&popup, 60)" --- "────────────────────────────────────────────────────────────" -"/help List the available slash commands and their usage ..." +"/help List slash commands " diff --git a/crates/oxide-code/src/tui/components/input/snapshots/ox__tui__components__input__popup__tests__render_narrow_terminal_truncates_description.snap b/crates/oxide-code/src/tui/components/input/snapshots/ox__tui__components__input__popup__tests__render_narrow_terminal_truncates_description.snap index 40973d45..fe7662b3 100644 --- a/crates/oxide-code/src/tui/components/input/snapshots/ox__tui__components__input__popup__tests__render_narrow_terminal_truncates_description.snap +++ b/crates/oxide-code/src/tui/components/input/snapshots/ox__tui__components__input__popup__tests__render_narrow_terminal_truncates_description.snap @@ -5,10 +5,10 @@ expression: "render_to_backend(&popup, 30)" --- "──────────────────────────────" "/clear Reset the conversa..." -"/config Show the resolved ..." +"/config Show resolved config " "/diff Show uncommitted w..." -"/effort Open the effort sl..." -"/help List the available..." -"/init Generate or update..." -"/model Open the model pic..." -"/resume Resume a previous ..." +"/effort Adjust reasoning e..." +"/help List slash commands " +"/init Set up `AGENTS.md`..." +"/model Switch the model " +"/rename Rename the current..." diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__input__tests__render_empty_shows_placeholder.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__input__tests__render_empty_buffer_shows_only_prompt_marker_and_borders.snap similarity index 85% rename from crates/oxide-code/src/tui/components/snapshots/ox__tui__components__input__tests__render_empty_shows_placeholder.snap rename to crates/oxide-code/src/tui/components/snapshots/ox__tui__components__input__tests__render_empty_buffer_shows_only_prompt_marker_and_borders.snap index 9acb883b..34b652ca 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__input__tests__render_empty_shows_placeholder.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__input__tests__render_empty_buffer_shows_only_prompt_marker_and_borders.snap @@ -1,7 +1,8 @@ --- source: crates/oxide-code/src/tui/components/input.rs +assertion_line: 647 expression: "render_to_backend(&input, 60, 3)" --- "────────────────────────────────────────────────────────────" -"❯ Ask anything... " +"❯ " "────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index eef795a2..962dbb63 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -77,7 +77,6 @@ impl StatusBar { &self.status } - #[cfg(test)] pub(crate) fn title(&self) -> Option<&str> { self.title.as_deref() } diff --git a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_lays_out_status_chat_and_input_in_order.snap b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_lays_out_status_chat_and_input_in_order.snap index 287af961..7f67e90a 100644 --- a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_lays_out_status_chat_and_input_in_order.snap +++ b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_lays_out_status_chat_and_input_in_order.snap @@ -1,6 +1,6 @@ --- source: crates/oxide-code/src/tui/app.rs -assertion_line: 2589 +assertion_line: 2597 expression: "render_app(&mut app, 80, 10)" --- " test-model │ Session title │ Ready ~/test " @@ -11,5 +11,5 @@ expression: "render_app(&mut app, 80, 10)" " test-model · high effort · API key " " ~/test " "────────────────────────────────────────────────────────────────────────────────" -"❯ Ask anything... " +"❯ " "────────────────────────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_narrow_width_still_renders_all_three_panels.snap b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_narrow_width_still_renders_all_three_panels.snap index 9ccff1f8..806f1446 100644 --- a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_narrow_width_still_renders_all_three_panels.snap +++ b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_narrow_width_still_renders_all_three_panels.snap @@ -1,5 +1,6 @@ --- source: crates/oxide-code/src/tui/app.rs +assertion_line: 2627 expression: "render_app(&mut app, 40, 8)" --- " test-model │ narrow │ Ready ~/test " @@ -8,5 +9,5 @@ expression: "render_app(&mut app, 40, 8)" " " " " "────────────────────────────────────────" -"❯ Ask anything... " +"❯ " "────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_preview_panel_renders_queued_prompts_and_overflow_tag.snap b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_preview_panel_renders_queued_prompts_and_overflow_tag.snap index 8637e374..934f7289 100644 --- a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_preview_panel_renders_queued_prompts_and_overflow_tag.snap +++ b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_preview_panel_renders_queued_prompts_and_overflow_tag.snap @@ -1,5 +1,6 @@ --- source: crates/oxide-code/src/tui/app.rs +assertion_line: 2652 expression: "render_app(&mut app, 60, 14)" --- " test-model │ ⣷ Streaming · Esc to interrupt ~/test " @@ -14,5 +15,5 @@ expression: "render_app(&mut app, 60, 14)" "❯ queued 2 " " +2 more " "────────────────────────────────────────────────────────────" -"❯ Type to queue a follow-up... " +"❯ " "────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_streaming_shows_spinner_in_status_bar.snap b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_streaming_shows_spinner_in_status_bar.snap index 31969057..38e18053 100644 --- a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_streaming_shows_spinner_in_status_bar.snap +++ b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_streaming_shows_spinner_in_status_bar.snap @@ -1,5 +1,6 @@ --- source: crates/oxide-code/src/tui/app.rs +assertion_line: 2620 expression: "render_app(&mut app, 60, 8)" --- " test-model │ ⣷ Streaming · Esc to interrupt ~/test " @@ -8,5 +9,5 @@ expression: "render_app(&mut app, 60, 8)" " " "◉ part " "────────────────────────────────────────────────────────────" -"❯ Type to queue a follow-up... " +"❯ " "────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_with_conversation_and_tool_call.snap b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_with_conversation_and_tool_call.snap index 7adb7b80..90859244 100644 --- a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_with_conversation_and_tool_call.snap +++ b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_with_conversation_and_tool_call.snap @@ -1,5 +1,6 @@ --- source: crates/oxide-code/src/tui/app.rs +assertion_line: 2609 expression: "render_app(&mut app, 60, 12)" --- " test-model │ Ready ~/test " @@ -12,5 +13,5 @@ expression: "render_app(&mut app, 60, 12)" "◉ Two files. " " " "────────────────────────────────────────────────────────────" -"❯ Ask anything... " +"❯ " "────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/theme.rs b/crates/oxide-code/src/tui/theme.rs index ea719308..a38a06e9 100644 --- a/crates/oxide-code/src/tui/theme.rs +++ b/crates/oxide-code/src/tui/theme.rs @@ -51,7 +51,7 @@ macro_rules! for_each_slot { $callback! { // Text hierarchy (text, "Primary text"), - (muted, "Secondary text, labels, borders"), + (muted, "Secondary text and labels — between text and dim"), (dim, "Dimmed metadata, timestamps"), // Surfaces @@ -374,7 +374,7 @@ mod tests { #[test] fn composite_helpers_route_to_expected_slot_foreground() { let t = Theme::default(); - assert_eq!(t.tool_border().fg, t.muted.fg); + assert_eq!(t.tool_border().fg, t.tool_border.fg); assert_eq!(t.tool_icon().fg, t.accent.fg); assert_eq!(t.border_focused().fg, t.accent.fg); assert_eq!(t.border_unfocused().fg, t.dim.fg); diff --git a/crates/oxide-code/src/tui/theme/loader.rs b/crates/oxide-code/src/tui/theme/loader.rs index c8612488..38c66f89 100644 --- a/crates/oxide-code/src/tui/theme/loader.rs +++ b/crates/oxide-code/src/tui/theme/loader.rs @@ -734,7 +734,7 @@ mod tests { fn parse_theme_mocha_matches_known_palette() { let t = parse_theme(builtin::MOCHA).unwrap(); assert_eq!(t.text.fg, Some(Color::Rgb(0xcd, 0xd6, 0xf4)), "Text"); - assert_eq!(t.muted.fg, Some(Color::Rgb(0x6c, 0x70, 0x86)), "Overlay0"); + assert_eq!(t.muted.fg, Some(Color::Rgb(0xa6, 0xad, 0xc8)), "Subtext0"); assert_eq!(t.dim.fg, Some(Color::Rgb(0x58, 0x5b, 0x70)), "Surface2"); assert_eq!(t.accent.fg, Some(Color::Rgb(0x89, 0xb4, 0xfa)), "Blue"); assert_eq!(t.user.fg, Some(Color::Rgb(0xfa, 0xb3, 0x87)), "Peach"); diff --git a/crates/oxide-code/themes/frappe.toml b/crates/oxide-code/themes/frappe.toml index e4dd3577..78fba13a 100644 --- a/crates/oxide-code/themes/frappe.toml +++ b/crates/oxide-code/themes/frappe.toml @@ -3,7 +3,7 @@ # Text hierarchy text = "#c6d0f5" -muted = "#737994" +muted = "#a5adce" dim = "#626880" # Surfaces diff --git a/crates/oxide-code/themes/latte.toml b/crates/oxide-code/themes/latte.toml index f67ce7ce..89a96e62 100644 --- a/crates/oxide-code/themes/latte.toml +++ b/crates/oxide-code/themes/latte.toml @@ -3,7 +3,7 @@ # Text hierarchy text = "#4c4f69" -muted = "#9ca0b0" +muted = "#6c6f85" dim = "#acb0be" # Surfaces diff --git a/crates/oxide-code/themes/macchiato.toml b/crates/oxide-code/themes/macchiato.toml index 82b7f5df..69a45975 100644 --- a/crates/oxide-code/themes/macchiato.toml +++ b/crates/oxide-code/themes/macchiato.toml @@ -3,7 +3,7 @@ # Text hierarchy text = "#cad3f5" -muted = "#6e738d" +muted = "#a5adcb" dim = "#5b6078" # Surfaces diff --git a/crates/oxide-code/themes/material.toml b/crates/oxide-code/themes/material.toml index f45741cc..f616cb56 100644 --- a/crates/oxide-code/themes/material.toml +++ b/crates/oxide-code/themes/material.toml @@ -7,7 +7,7 @@ # Text hierarchy (M2 dark emphasis: 87% / 60% / 38% white) text = "#dedede" -muted = "#999999" +muted = "#c5c5c5" dim = "#616161" # Surfaces diff --git a/crates/oxide-code/themes/mocha.toml b/crates/oxide-code/themes/mocha.toml index 46b34853..7b6f9775 100644 --- a/crates/oxide-code/themes/mocha.toml +++ b/crates/oxide-code/themes/mocha.toml @@ -3,7 +3,7 @@ # Text hierarchy text = "#cdd6f4" -muted = "#6c7086" +muted = "#a6adc8" dim = "#585b70" # Surfaces diff --git a/docs/design/tui/cancellation.md b/docs/design/tui/cancellation.md index 466cc820..eb458991 100644 --- a/docs/design/tui/cancellation.md +++ b/docs/design/tui/cancellation.md @@ -28,5 +28,5 @@ The picks lean toward the Codex precedent on most surfaces — it is the one Rus - `crates/oxide-code/src/tool/bash.rs` — `kill_on_drop(true)`. - `crates/oxide-code/src/tui/app.rs` — `App::pending_prompts`, `dispatch_user_action` / `apply_action_locally`, `finalize_idle` / `drain_pending_prompt`, `set_active_status`, `expire_armed_exit`. - `crates/oxide-code/src/tui/components/chat/blocks/interrupted.rs` — `InterruptedMarker` block. -- `crates/oxide-code/src/tui/components/input.rs` — placeholder copy, Ctrl+D POSIX gate, Esc-pop, `set_text`. +- `crates/oxide-code/src/tui/components/input.rs` — Ctrl+D POSIX gate, Esc-pop, `set_text`. - `crates/oxide-code/src/tui/components/status.rs` — `Status` variants, busy hints, spinner. diff --git a/docs/guide/slash-commands.md b/docs/guide/slash-commands.md index c32743e3..e38692b0 100644 --- a/docs/guide/slash-commands.md +++ b/docs/guide/slash-commands.md @@ -1,6 +1,8 @@ # Slash Commands -Slash commands are built-in shortcuts that run client-side, without involving the model. Type `/` to open the autocomplete popup, browse with Up / Down, and complete with Tab. +Type `/` to open the autocomplete popup. **Up / Down** browse, **Tab** completes, **Enter** submits, **Esc** dismisses. Aliases (`/clear (new, reset)`) all route to the same command. + +For commands with curated arguments (`/model`, `/effort`, `/theme`), the popup switches to **arg mode** after `/<name>` + space, listing valid values. ## Built-in Commands @@ -13,51 +15,37 @@ Slash commands are built-in shortcuts that run client-side, without involving th | `/help` | Open a read-only modal listing available commands. | | `/init` | Generate or update the project's `AGENTS.md` / `CLAUDE.md`. | | `/model [<id>]` | Open the model + effort picker, or swap directly (alias / substring / exact id). | +| `/rename [<title>]` | Open a single-line modal pre-filled with the current title, or set it directly. | | `/resume [<id-prefix>]` (alias `/continue`) | Open the session picker (search, project / all toggle), or jump by id prefix. | | `/status` | Open a read-only modal of model, effort, cwd, version, auth, and session id. | | `/theme [<name>]` | Open the theme picker (live preview), or swap directly to a built-in theme. | -## Autocomplete Popup - -When you type `/`, a two-column popup appears above the input: - -- **Up / Down** navigate the rows. -- **Tab** completes the selected row. In name mode it inserts `/<name>` plus a trailing space; in arg mode it replaces the typed prefix with the picked value. -- **Enter** submits the current line. -- **Esc** dismisses the popup. - -Matches are ranked by tier: name-prefix > alias-prefix > name-substring > alias-substring. Aliases display inline in the canonical row (`/clear (new, reset)`); typing any alias routes to the same impl. - -After `/<name>` plus a trailing space, the popup switches to **arg mode** for commands with a curated argument roster (`/model`, `/effort`, `/theme`): rows show valid values with descriptions, prefix-filtered as you type. - ## Sending a Literal `/foo` -To send a message that _starts_ with a slash without invoking a command, double the leading slash. Typing `//etc` sends the literal `/etc`. +Double the leading slash. Typing `//etc` sends the literal `/etc`. ## Mid-Turn Behavior -Read-only commands (`/config`, `/diff`, `/help`, `/status`, and bare `/effort` / `/model` / `/theme` which open modals) are safe to run while the agent is streaming. State-mutating commands (`/clear`, `/effort <level>`, `/init`, `/model <id>`, `/resume` — both bare and `<id-prefix>` — and `/theme <name>`) refuse mid-turn — wait for the current response to complete, then retry. +State-mutating commands (`/clear`, `/init`, and the typed-arg forms of `/effort`, `/model`, `/rename`, `/resume`, `/theme`) wait for the current turn to finish. Read-only commands and the bare modal-opening forms run anytime. ## Model and Effort -Bare `/model` opens the combined model + effort picker; bare `/effort` opens a Speed ↔ Intelligence slider. Both apply on Enter and cancel on Esc. - -`/model <id>` resolves in four tiers: alias (`opus`, `sonnet`, `haiku`, with optional `[1m]` for the 1M-context variants) → exact / dated id → unique suffix → unique substring. Swapping clamps the current effort to the new model's caps; `/effort` on a model without effort (Haiku 4.5) errors with a recovery hint. See [Configuration](configuration.md) for the full tier reference and per-model defaults. +Bare `/model` and `/effort` open pickers; both apply on Enter, cancel on Esc. -## Resuming a Session +`/model <id>` accepts aliases (`opus`, `sonnet`, `haiku` — append `[1m]` for the 1M-context variants), full ids, or any unique suffix or substring. Haiku has no effort tier, so `/effort` on Haiku errors with a recovery hint. See [Configuration](configuration.md) for tier defaults. -Bare `/resume` (alias `/continue`) opens an in-place session picker. Type to filter by id, title, or project; Up / Down or PageUp / PageDown navigate; Tab toggles between the current project and all projects; Enter resumes the highlighted session; Esc cancels. Switching session preserves the running TUI — chat repopulates from the resumed transcript and the next prompt continues that thread. +## Sessions -`/resume <id-prefix>` resolves the prefix against the current project first and widens to all projects if there's no in-project match. Ambiguous prefixes list the candidates with their 8-character ids. +`/rename` opens a modal pre-filled with the current title; `/rename <title>` sets it directly. The chosen title sticks and replaces the auto-generated AI title for the rest of the session. -`/resume` mid-session is the in-app equivalent of `ox -c <id-prefix>` at launch — both call the same load + sanitize path. The CLI launcher is unchanged. +`/resume` opens a searchable session picker — type to filter by id, title, or project, Tab toggles current-project ↔ all projects, Enter resumes. `/resume <id-prefix>` jumps directly; ambiguous prefixes list candidates. Switching sessions preserves the running TUI — chat repopulates and the next prompt continues that thread. -## Switching the Theme +## Theme -`/theme` (no argument) opens a list picker for the built-in themes — Up / Down repaints the full TUI in the candidate theme so you can compare without committing, number keys (`1`–`9`) jump to a row, Enter applies for the rest of the session, Esc snaps back to the original. Restart returns to the theme set in your `ox.toml`. +`/theme` opens the picker — **Up / Down** previews each theme live, number keys jump to a row, **Enter** applies for the rest of the session, **Esc** reverts. -`/theme <name>` swaps directly to a built-in (`mocha`, `macchiato`, `frappe`, `latte`, `material`). Custom file-path themes aren't accepted via the slash form — edit `~/.config/ox/config.toml` to point `[tui.theme] base` at a custom TOML. +`/theme <name>` swaps directly to a built-in (`mocha`, `macchiato`, `frappe`, `latte`, `material`). Custom themes go in `~/.config/ox/config.toml` under `[tui.theme] base`. -## Stance: No Silent Config Writes +## Persistence -Slash commands never write user config files. Runtime mutations (`/model`, `/effort`, `/theme`) stay session-local; restart returns to the user-declared configuration. Persisting a slash-command choice across restarts will require an explicit subcommand writing to an explicit user-opted-in path — never a silent merge. +Slash-command choices stay session-local. Restart returns to your declared configuration in `ox.toml` — no slash command writes user config files. diff --git a/docs/roadmap.md b/docs/roadmap.md index 3531fba3..ad8ed806 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -13,7 +13,7 @@ The direction is simple: ### Terminal UI - Streaming output with markdown rendering and syntax-highlighted code blocks. -- Multi-line input with a prompt marker, dynamic placeholder hints, and a status bar showing model, working directory, and run state. +- Multi-line input with a prompt marker and a status bar showing model, working directory, and run state. - Welcome surface on empty chat with identity, environment, and a few starter commands. - Rich per-tool views: edit diffs with line gutters, line-numbered read excerpts, grouped grep matches, structured glob lists, and bash output. - Themable via runtime-loaded TOML — 5 built-in palettes (Catppuccin Mocha, Macchiato, Frappe, Latte, Material) with per-slot overrides. @@ -63,10 +63,10 @@ The direction is simple: ### Slash Commands -- Built-in: `/clear` (aliases `/new`, `/reset`), `/config`, `/diff`, `/effort`, `/help`, `/init`, `/model`, `/resume` (alias `/continue`), `/status`, `/theme`. See the [user guide](guide/slash-commands.md). +- Built-in: `/clear` (aliases `/new`, `/reset`), `/config`, `/diff`, `/effort`, `/help`, `/init`, `/model`, `/rename`, `/resume` (alias `/continue`), `/status`, `/theme`. See the [user guide](guide/slash-commands.md). - Autocomplete popup on typing `/`, with ranked filter, Tab completion, and arg-mode completion for commands with curated rosters (`/model`, `/effort`, `/theme`). -- Mid-session swap: `/model`, `/effort`, `/resume`, and `/theme`. Session-only — no slash command writes user config files. -- Modal UI primitive (focus-grabbing overlays above the input). Bare `/model` opens a combined model + effort picker; `/effort` opens a Speed ↔ Intelligence slider; `/resume` opens a searchable session picker; `/theme` opens a list picker with live preview (Up / Down repaints the TUI in the candidate theme; Esc snaps back, Enter commits); `/status`, `/config`, and `/help` open read-only kv overviews. Esc / Ctrl+C cancel any modal universally. +- Mid-session swaps (`/model`, `/effort`, `/rename`, `/resume`, `/theme`) are session-only — no slash command writes user config files. +- Modal UI primitive: focus-grabbing overlays above the input for picker, slider, editor, and read-only kv-overview forms. Esc / Ctrl+C cancels any modal. ### Authentication & Configuration @@ -90,7 +90,7 @@ The direction is simple: Remaining surface beyond Working Today: -- Deferred: `/compact`, `/cost`, `/login` / `/logout`, `/rename` (manual session-title override), custom user commands, `/init` multi-phase flow. +- Deferred: `/compact`, `/cost`, `/login` / `/logout`, custom user commands, `/init` multi-phase flow. Persistence stance: `/model`, `/effort`, and `/theme` mutate session state only; restart returns to user-declared config. Cross-session persistence will land as an **explicit subcommand** writing to an **explicit user-opted-in path** — never a silent merge. (Rejects Claude Code's `~/.claude.json` mega-file pattern.)