Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8e46a77
docs: reorganize research/ and design/ into topic subdirectories
hakula139 May 4, 2026
271ca50
docs: expand CLAUDE.md docs index to include guide/ and roadmap.md
hakula139 May 4, 2026
f5ef509
refactor(slash): rename SlashOutcome variants and replace is_read_onl…
hakula139 May 4, 2026
659d535
refactor(agent): collapse SwitchModel + SwitchEffort into SwapConfig
hakula139 May 4, 2026
3587360
feat(tui): introduce Modal trait, ModalStack, and key routing
hakula139 May 4, 2026
cd75bd6
feat(tui): generic ListPicker primitive
hakula139 May 4, 2026
5bfb107
feat(slash): combined /model + /effort picker modal
hakula139 May 4, 2026
0a4929c
feat(slash): /status overview modal
hakula139 May 4, 2026
f79df8c
docs(design): modal-ui design notes and crate-tree update
hakula139 May 4, 2026
88f0ca7
test(slash): cover left-arrow effort cycle, no-tier render, thinking-…
hakula139 May 5, 2026
8c70d97
test(slash): tighten picker submit assertions and add tier-restore sc…
hakula139 May 5, 2026
ade6a93
fix(tui): correct ListPicker header_height off-by-one when descriptio…
hakula139 May 5, 2026
e0ea986
fix(slash): prevent spurious SwapConfig on bare /effort with implicit…
hakula139 May 5, 2026
eab69f1
style(slash): fix stale doc comment, import grouping, and test sectio…
hakula139 May 5, 2026
b5a3332
fix(tui): forward modal-emitted SwapConfig and Clear through user_tx
hakula139 May 5, 2026
a31e54b
feat(tui): paint top border above modal overlay
hakula139 May 5, 2026
264993b
feat(config): default to claude-opus-4-7[1m] for 1M context
hakula139 May 5, 2026
3b96b5f
test(tui): regroup modal tests by function and add list_picker defaul…
hakula139 May 5, 2026
d841ff1
refactor(slash): drop bare /effort picker form, keep typed-arg shortcut
hakula139 May 5, 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 .cspell/words.txt
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,14 +22,14 @@ deserialize
desync
disambiguable
dtolnay
frappe
EACCES
EISDIR
ENOENT
ENOSPC
ENOTDIR
ESRCH
feff
frappe
getpwuid
gitui
hakula
Expand All@@ -38,6 +38,7 @@ indoc
insta
isatty
killpg
Kobalte
latte
macchiato
misparse
Expand Down
22 changes: 17 additions & 5 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,17 +71,19 @@ ox # Start an interactive session
├── slash/
│ ├── clear.rs # /clear (new, reset) — forwards UserAction::Clear, resets ChatView, drops the AI title
│ ├── config.rs # /config — read-only resolved config + layered file paths
│ ├── context.rs # SlashContext (borrowed ChatView + SessionInfo) handed to each command's execute
│ ├── context.rs # SlashContext (borrowed ChatView + SessionInfo + modal slot) handed to each command's execute
│ ├── diff.rs # /diff — `git diff HEAD` + untracked, 64 KB cap on UTF-8 boundary
│ ├── effort.rs # /effort — list / swap explicit effort tier
│ ├── effort.rs # /effort — bare opens picker focused on effort; `/effort <level>` swaps directly
│ ├── format.rs # Shared kv-section / kv-table renderer
│ ├── help.rs # /help — registry-driven command listing
│ ├── init.rs # /init — synthesize an AGENTS.md / CLAUDE.md author-or-update prompt
│ ├── matcher.rs # filter_and_rank: tier-ranked popup matches
│ ├── model.rs # /model — list / swap; resolver alias → lookup → unique suffix → unique substring; `[1m]` first-class
│ ├── model.rs # /model — bare opens picker; `/model <id>` resolves alias → lookup → unique suffix → unique substring; `[1m]` first-class
│ ├── 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 + BUILT_INS slice + alias-aware lookup
│ └── status.rs # /status — model, effort, cwd, version, auth, session id
│ ├── status.rs # /status — opens StatusModal
│ └── status_modal.rs # StatusModal — read-only kv-overview of the live session (Esc / Enter close)
├── tool.rs # Tool trait, registry, definitions
├── tool/
│ ├── bash.rs # Shell command execution with timeout
Expand DownExpand Up@@ -131,6 +133,9 @@ ox # Start an interactive session
│ ├── markdown/
│ │ ├── highlight.rs # Syntax highlighting (syntect lazy-loaded SyntaxSet / ThemeSet)
│ │ └── render.rs # pulldown-cmark event walker, inline / block / list / table rendering
│ ├── modal.rs # Modal trait, ModalKey, ModalAction, ModalStack — focus-grabbing UI overlays
│ ├── modal/
│ │ └── list_picker.rs # Generic ListPicker<T: PickerItem> — cursor + render primitive used by concrete pickers
│ ├── pending_calls.rs # Tool-call correlation state for streaming and transcript resume
│ ├── terminal.rs # Terminal init / restore, synchronized output, panic hook
│ └── wrap.rs # Word-wrap with continuation indent for styled lines
Expand All@@ -144,6 +149,13 @@ ox # Start an interactive session
└── text.rs # Display-width-aware text helpers (`truncate_to_width`, `ELLIPSIS`)
```

## Documentation

- [`docs/README.md`](docs/README.md) — top-level index of design specs, research notes, user guides, and the roadmap.
- [`docs/guide/`](docs/guide/) — user-facing docs (quickstart, configuration, slash commands, instructions, sessions, theming).
- [`docs/design/`](docs/design/) and [`docs/research/`](docs/research/) — internal architecture decisions and external research, both organized by topic (api, session, slash, tools, tui). Each subdirectory has its own README with per-doc summaries.
- [`docs/roadmap.md`](docs/roadmap.md) — working features, current focus, and explicit non-goals.

## Coding Conventions

### Trait Design
Expand DownExpand Up@@ -241,7 +253,7 @@ Follows global CLAUDE.md commit / branch / PR conventions, plus:
- Keep `README.md` user-facing. It should describe value, supported features, and usage, not internal progress tracking.
- Keep `docs/roadmap.md` as the canonical in-repo roadmap / status summary. Update it when shipped capability areas or planned priorities change.
- Crate structure diagrams must match the actual filesystem. When adding, removing, or renaming modules, update the tree in this file. Entries are sorted alphabetically; directories sort alongside their parent `.rs` file.
- After substantive changes, sweep docs for stale claims: `README.md` status bullets, `docs/roadmap.md` Working Today / Current Focus sections, this file's crate tree and conventions, `docs/guide/*` user instructions, and `docs/research/*` deferred / follow-up notes that the change now resolves.
- After substantive changes, sweep docs for stale claims: `README.md` status bullets, `docs/roadmap.md` Working Today / Current Focus sections, this file's crate tree and conventions, `docs/guide/*` user instructions, and `docs/research/**/*` deferred / follow-up notes that the change now resolves.

## Verification

Expand Down
13 changes: 9 additions & 4 deletions crates/oxide-code/src/agent.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -257,8 +257,7 @@ where
Some(
action @ (UserAction::ConfirmExit
| UserAction::Clear
| UserAction::SwitchModel(_)
| UserAction::SwitchEffort(_)),
| UserAction::SwapConfig { .. }),
) => warn!("dropped mid-turn action: {action:?}"),
},
output = &mut fut => return Ok(output),
Expand DownExpand Up@@ -1158,8 +1157,14 @@ mod tests {
for action in [
UserAction::ConfirmExit,
UserAction::Clear,
UserAction::SwitchModel(ResolvedModelId::new("claude-opus-4-7".to_owned())),
UserAction::SwitchEffort(Effort::High),
UserAction::SwapConfig {
model: Some(ResolvedModelId::new("claude-opus-4-7".to_owned())),
effort: None,
},
UserAction::SwapConfig {
model: None,
effort: Some(Effort::High),
},
] {
let dir = tempfile::tempdir().unwrap();
let session = test_session(dir.path());
Expand Down
31 changes: 17 additions & 14 deletions crates/oxide-code/src/agent/event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,13 +41,14 @@ pub(crate) enum AgentEvent {
SessionRolled {
id: String,
},
ModelSwitched {
/// Live config after a [`UserAction::SwapConfig`] applied. `effort`
/// is the resolved value (post-clamp); `requested_effort` is the
/// user's pick if they explicitly chose one — used to surface
/// `(clamped from X)` in the confirmation message.
ConfigChanged {
model_id: String,
effort: Option<Effort>,
},
EffortSwitched {
pick: Effort,
effort: Option<Effort>,
requested_effort: Option<Effort>,
},
Error(String),
}
Expand All@@ -58,8 +59,14 @@ pub(crate) enum AgentEvent {
pub(crate) enum UserAction {
SubmitPrompt(String),
Clear,
SwitchModel(ResolvedModelId),
SwitchEffort(Effort),
/// Symmetric model + effort swap. At least one field must be `Some`;
/// `None` means "leave that axis as-is". Modal pickers and the
/// typed-arg `/model <id>` / `/effort <tier>` paths both flow
/// through here.
SwapConfig {
model: Option<ResolvedModelId>,
effort: Option<Effort>,
},
Cancel,
/// TUI-only; agent loop ignores this.
ConfirmExit,
Expand DownExpand Up@@ -142,8 +149,7 @@ impl StdioSink {
AgentEvent::PromptDrained(_)
| AgentEvent::SessionTitleUpdated { .. }
| AgentEvent::SessionRolled { .. }
| AgentEvent::ModelSwitched { .. }
| AgentEvent::EffortSwitched { .. } => {}
| AgentEvent::ConfigChanged { .. } => {}
AgentEvent::TurnComplete => {
writeln!(stdout)?;
}
Expand DownExpand Up@@ -304,13 +310,10 @@ mod tests {
AgentEvent::SessionRolled {
id: "rolled".to_owned(),
},
AgentEvent::ModelSwitched {
AgentEvent::ConfigChanged {
model_id: "claude-opus-4-7".to_owned(),
effort: Some(Effort::Xhigh),
},
AgentEvent::EffortSwitched {
pick: Effort::High,
effort: Some(Effort::High),
requested_effort: Some(Effort::Xhigh),
},
] {
let (stdout, stderr) = render_one(&test_sink(false), event);
Expand Down
5 changes: 5 additions & 0 deletions crates/oxide-code/src/client/anthropic.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,6 +155,11 @@ impl Client {
&self.config.model
}

/// Returns the effort tier the next request will be issued at.
pub(crate) fn effort(&self) -> Option<Effort> {
self.config.effort
}

/// Client-side session id carried in `x-claude-code-session-id` and
/// billing metadata. Caller-supplied or auto-generated UUID v4.
#[cfg(test)]
Expand Down
2 changes: 1 addition & 1 deletion crates/oxide-code/src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize};
use crate::tui::theme::{self, Theme};
use crate::util::env;

const DEFAULT_MODEL: &str = "claude-opus-4-7";
const DEFAULT_MODEL: &str = "claude-opus-4-7[1m]";
const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";

// ── Auth ──
Expand Down
46 changes: 31 additions & 15 deletions crates/oxide-code/src/main.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,9 +25,10 @@ use tracing::{debug, warn};
use agent::event::{AgentEvent, AgentSink, StdioSink, UserAction, inert_user_action_channel};
use agent::{TurnAbort, agent_turn};
use client::anthropic::Client;
use config::Config;
use config::{Config, Effort};
use file_tracker::FileTracker;
use message::Message;
use model::ResolvedModelId;
use session::handle::{ResumedSession, SessionHandle, roll as roll_session};
use session::list_view::render_list;
use session::resolver::resolve_session;
Expand DownExpand Up@@ -388,20 +389,8 @@ async fn agent_loop_task(
warn!("session-rolled event dropped: {e}");
}
}
UserAction::SwitchModel(id) => {
let effort = client.set_model(id.as_str().to_owned());
if let Err(e) = sink.send(AgentEvent::ModelSwitched {
model_id: id.into_inner(),
effort,
}) {
warn!("model-switched event dropped: {e}");
}
}
UserAction::SwitchEffort(pick) => {
let effort = client.set_effort(pick);
if let Err(e) = sink.send(AgentEvent::EffortSwitched { pick, effort }) {
warn!("effort-switched event dropped: {e}");
}
UserAction::SwapConfig { model, effort } => {
apply_swap_config(&mut client, &sink, model, effort);
}
UserAction::Quit => break,
}
Expand All@@ -410,6 +399,33 @@ async fn agent_loop_task(
Ok(())
}

/// Apply a [`UserAction::SwapConfig`] to the live `Client` and emit
/// [`AgentEvent::ConfigChanged`]. Order is load-bearing: the model
/// swap re-clamps existing effort first, then the explicit effort
/// pick (if any) clamps against the new model's caps. `requested_effort`
/// echoes the user's pick so the UI can surface clamping.
fn apply_swap_config(
client: &mut Client,
sink: &dyn AgentSink,
model: Option<ResolvedModelId>,
effort: Option<Effort>,
) {
if let Some(id) = model {
client.set_model(id.into_inner());
}
let resolved = match effort {
Some(pick) => client.set_effort(pick),
None => client.effort(),
};
if let Err(e) = sink.send(AgentEvent::ConfigChanged {
model_id: client.model().to_owned(),
effort: resolved,
requested_effort: effort,
}) {
warn!("config-changed event dropped: {e}");
}
}

// ── Bare REPL Mode ──

async fn bare_repl(
Expand Down
12 changes: 7 additions & 5 deletions crates/oxide-code/src/model.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -289,7 +289,8 @@ impl Capabilities {

/// A model id that has passed through the `/model` resolver. The private
/// inner field ensures arbitrary strings cannot flow into
/// [`UserAction::SwitchModel`] without validation.
/// [`UserAction::SwapConfig`](crate::agent::event::UserAction::SwapConfig)
/// without validation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedModelId(String);

Expand All@@ -300,13 +301,14 @@ impl ResolvedModelId {
Self(id)
}

pub(crate) fn as_str(&self) -> &str {
&self.0
}

pub(crate) fn into_inner(self) -> String {
self.0
}

#[cfg(test)]
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}

// ── Lookup ──
Expand Down
2 changes: 1 addition & 1 deletion crates/oxide-code/src/session/actor.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@
//! before this drain runs; isolated writes (a text-only turn, the
//! AI title append, the final summary) flush immediately because
//! the drain returns `Empty` after the first cmd. No interval timer
//! — see `docs/research/design/session-persistence.md`.
//! — see `docs/design/session/persistence.md`.

use std::sync::Arc;

Expand Down
26 changes: 8 additions & 18 deletions crates/oxide-code/src/slash.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,7 @@
//!
//! Persistence: commands never write user config files. Mutations are
//! session-local; restart returns to the user-declared config (see
//! `docs/research/design/slash-commands.md` § Design Decisions 6).
//! `docs/design/slash/commands.md` § Design Decisions 6).

mod clear;
mod config;
Expand All@@ -25,12 +25,15 @@ mod init;
mod matcher;
mod model;
mod parser;
mod picker;
mod registry;
mod status;
mod status_modal;

pub(crate) use context::{SessionInfo, SlashContext};
pub(crate) use matcher::MatchedCommand;
pub(crate) use parser::{Parsed, parse_slash, popup_query};
pub(crate) use registry::SlashKind;

/// Filter the built-in registry against a popup query (the buffer
/// with the leading `/` stripped). Convenience wrapper around
Expand DownExpand Up@@ -70,8 +73,8 @@ fn dispatch_with(
return None;
};
match cmd.execute(&parsed.args, ctx) {
Ok(registry::SlashOutcome::Local) => None,
Ok(registry::SlashOutcome::Action(action)) => Some(action),
Ok(registry::SlashOutcome::Done) => None,
Ok(registry::SlashOutcome::Forward(action)) => Some(action),
Err(msg) => {
ctx.chat.push_error(&format!("/{}: {msg}", parsed.name));
None
Expand DownExpand Up@@ -100,23 +103,10 @@ pub(crate) fn classify(parsed: &Parsed) -> SlashKind {
fn classify_in(commands: &[&dyn registry::SlashCommand], parsed: &Parsed) -> SlashKind {
match registry::lookup_in(commands, &parsed.name) {
None => SlashKind::Unknown,
Some(cmd) if cmd.is_read_only(&parsed.args) => SlashKind::ReadOnly,
Some(_) => SlashKind::Mutating,
Some(cmd) => cmd.classify(&parsed.args),
}
}

/// Whether a slash command can run while the agent is busy.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum SlashKind {
/// Safe mid-turn — dispatch immediately.
ReadOnly,
/// State-mutating — refuse mid-turn, let the user retry when idle.
Mutating,
/// Not in the registry — dispatch anyway so the user sees the
/// canonical "unknown command" error block with recovery hints.
Unknown,
}

/// Shared test fixture — a fully-populated `SessionInfo` for the
/// per-command test modules.
#[cfg(test)]
Expand DownExpand Up@@ -165,7 +155,7 @@ mod tests {
args: String::new(),
};
let outcome = dispatch(&parsed, &mut SlashContext::new(&mut chat, &info));
assert!(outcome.is_none(), "/help is Local, not PromptSubmit");
assert!(outcome.is_none(), "/help is Done, not Forward");
assert!(!chat.last_is_error());
assert_eq!(chat.entry_count(), 1);
// Pin: SystemMessageBlock inherits `error_text` default `None`
Expand Down
Loading