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 ` jumps directly
│ ├── status.rs # /status — opens a KvOverview modal of session descriptors
│ └── theme.rs # /theme — bare opens the picker (live preview); `/theme ` 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 ` / `/effort ` 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,
},
+ /// User-supplied title from `/rename`. Latches `manual_title_set` to suppress AI titles.
+ SetManualTitle {
+ title: String,
+ ack: oneshot::Sender,
+ },
Finish {
/// Drained tracker snapshots; written as one `FileSnapshot` entry each plus a `Summary`.
snapshots: Vec,
@@ -67,11 +72,25 @@ pub(super) async fn run(
let mut entries: Vec = Vec::new();
let mut acks: Vec = 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,
acks: &mut Vec,
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