Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
289a1ff
fix(tui): land input cursor on the placeholder's first character
hakula139 May 8, 2026
ed34b14
test(tui): catch up app-frame snapshots to the cursor placement shift
hakula139 May 8, 2026
49428a8
style(theme): rebalance the muted tier so secondary text reads distin…
hakula139 May 8, 2026
75f8aaf
feat(slash): /rename to set the session title manually
hakula139 May 8, 2026
4b4a321
style(rename): trim verbose comments to one-liners
hakula139 May 8, 2026
62e29bd
docs: surface /rename across guide, roadmap, README, and CLAUDE.md
hakula139 May 9, 2026
112eff5
docs(guide): tighten slash-commands guide from first principles
hakula139 May 9, 2026
5ca16a8
test(slash): cover RenameModal render path and SetManualTitle dispatch
hakula139 May 9, 2026
b598f1b
refactor(tui): drop input placeholder, slot ghost text after cursor
hakula139 May 9, 2026
a589329
fix(session): keep /rename-only sessions off disk and resumable
hakula139 May 9, 2026
e0bc9f2
refactor(session): tighten deferred-write API and last-wins rename
hakula139 May 9, 2026
0c56bca
refactor(session): single source of truth for manual_title_set
hakula139 May 9, 2026
dafb840
docs(session): drop incorrect String::pop claim, fix MAX_TITLE ref
hakula139 May 9, 2026
023d5b3
docs(slash): converge dual-mode descriptions on terse verb-first phrases
hakula139 May 9, 2026
7a9901a
refactor(session): try_defer_title returns Option, not Result
hakula139 May 9, 2026
d4b243f
docs(session): tighten resume bail and comment polish
hakula139 May 9, 2026
78915b1
style(resume): brighten title and add row gap in the picker
hakula139 May 9, 2026
eb0071d
style(resume): drop comments the code already says
hakula139 May 9, 2026
2aa9c07
refactor(resume): collapse duplicated truncation budget
hakula139 May 9, 2026
a628177
docs(session): trim verbose enum and field docs to one thought
hakula139 May 9, 2026
4721d72
style(resume): paint the cursor row title in accent
hakula139 May 9, 2026
e96bc22
docs(slash): balance command descriptions to a uniform short length
hakula139 May 9, 2026
19edf7e
test(session): reorder resume tests and tighten try_defer_title names
hakula139 May 9, 2026
8f5821d
test(session): trim verbose test names in session/
hakula139 May 9, 2026
627d124
test: trim verbose test names in app, input, matcher, anthropic
hakula139 May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <title>` 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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/oxide-code/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ where
action @ (UserAction::ConfirmExit
| UserAction::Clear
| UserAction::Resume { .. }
| UserAction::Rename { .. }
| UserAction::SwapConfig { .. }
| UserAction::PreviewTheme { .. }
| UserAction::SwapTheme { .. }),
Expand Down
4 changes: 4 additions & 0 deletions crates/oxide-code/src/agent/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion crates/oxide-code/src/client/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()] {
Expand Down
17 changes: 17 additions & 0 deletions crates/oxide-code/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down
213 changes: 210 additions & 3 deletions crates/oxide-code/src/session/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand Down Expand Up @@ -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) => {
Expand All @@ -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 });
}
Expand All @@ -125,13 +146,32 @@ 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,
updated_at: now,
});
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));
Expand Down Expand Up @@ -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
Expand Down
Loading