Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -84,7 +84,8 @@ ox # Start an interactive session
│ ├── 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 — opens StatusModal
│ └── status_modal.rs # StatusModal — read-only kv-overview of the live session (Esc / Enter close)
│ ├── status_modal.rs # StatusModal — read-only kv-overview of the live session (Esc / Enter close)
│ └── theme.rs # /theme — bare opens the picker (live preview); `/theme <name>` validates against the curated roster and swaps
├── tool.rs # Tool trait, registry, definitions
├── tool/
│ ├── bash.rs # Shell command execution with timeout
Expand Down
4 changes: 3 additions & 1 deletion crates/oxide-code/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,9 @@ where
Some(
action @ (UserAction::ConfirmExit
| UserAction::Clear
| UserAction::SwapConfig { .. }),
| UserAction::SwapConfig { .. }
| UserAction::PreviewTheme { .. }
| UserAction::SwapTheme { .. }),
) => warn!("dropped mid-turn action: {action:?}"),
},
output = &mut fut => return Ok(output),
Expand Down
9 changes: 9 additions & 0 deletions crates/oxide-code/src/agent/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ pub(crate) enum UserAction {
model: Option<ResolvedModelId>,
effort: Option<Effort>,
},
/// TUI-only: live-preview the picker's highlighted theme without committing it. Cursor moves
/// in the `/theme` modal emit this; cancelling the modal restores the snapshot taken on open.
PreviewTheme {
name: String,
},
/// TUI-only: commit a theme swap for the rest of the session. Restart returns to user config.
SwapTheme {
name: String,
},
Cancel,
/// TUI-only; agent loop ignores this.
ConfirmExit,
Expand Down
1 change: 1 addition & 0 deletions crates/oxide-code/src/client/anthropic/testing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub(crate) fn test_config(base_url: impl Into<String>, auth: Auth, model: &str)
thinking: None,
show_thinking: false,
theme: Theme::default(),
theme_name: "mocha".to_owned(),
}
}

Expand Down
35 changes: 34 additions & 1 deletion crates/oxide-code/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ use crate::util::env;

const DEFAULT_MODEL: &str = "claude-opus-4-7[1m]";
const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
/// Mirrors the fallback `loader::resolve_theme` applies when no `[tui.theme] base` is set.
pub(crate) const DEFAULT_THEME: &str = "mocha";

// ── Auth ──

Expand Down Expand Up @@ -49,6 +51,9 @@ pub(crate) struct ConfigSnapshot {
pub(crate) max_tokens: u32,
pub(crate) prompt_cache_ttl: PromptCacheTtl,
pub(crate) show_thinking: bool,
/// Resolved theme base name — built-in catalogue key or filesystem path. `/theme` reads this
/// to mark the active row in the picker.
pub(crate) theme_name: String,
}

// ── ThinkingConfig ──
Expand Down Expand Up @@ -183,6 +188,9 @@ pub(crate) struct Config {
pub(crate) thinking: Option<ThinkingConfig>,
pub(crate) show_thinking: bool,
pub(crate) theme: Theme,
/// Built-in catalogue key (e.g. `"mocha"`) or filesystem path; mirrors `[tui.theme] base`,
/// falling back to [`DEFAULT_THEME`] when unset.
pub(crate) theme_name: String,
}

impl Config {
Expand Down Expand Up @@ -245,6 +253,10 @@ impl Config {
None => client.prompt_cache_ttl.unwrap_or(PromptCacheTtl::OneHour),
};

let theme_name = theme_config
.base
.clone()
.unwrap_or_else(|| DEFAULT_THEME.to_owned());
let theme = theme::resolve_theme(
theme_config.base.as_deref(),
&theme_config.overrides.unwrap_or_default(),
Expand All @@ -260,6 +272,7 @@ impl Config {
thinking,
show_thinking,
theme,
theme_name,
})
}

Expand All @@ -273,6 +286,7 @@ impl Config {
max_tokens: self.max_tokens,
prompt_cache_ttl: self.prompt_cache_ttl,
show_thinking: self.show_thinking,
theme_name: self.theme_name.clone(),
}
}
}
Expand Down Expand Up @@ -461,6 +475,23 @@ mod tests {
assert_eq!(config.prompt_cache_ttl, PromptCacheTtl::OneHour);
assert!(!config.show_thinking);
assert!(matches!(config.auth, Auth::ApiKey(k) if k == "sk-default"));
assert_eq!(config.theme_name, DEFAULT_THEME);
}

#[tokio::test]
async fn load_theme_name_reflects_user_picked_base() {
let dir = tempfile::tempdir().unwrap();
write_user_config(
dir.path(),
indoc::indoc! {r#"
[tui.theme]
base = "latte"
"#},
);
let config = temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load())
.await
.unwrap();
assert_eq!(config.theme_name, "latte");
}

#[tokio::test]
Expand Down Expand Up @@ -679,7 +710,7 @@ mod tests {

#[tokio::test]
async fn load_effort_clamps_xhigh_down_to_high_on_sonnet_4_6() {
// Sonnet 4.6 has effort but not `xhigh` / `max`; must clamp, not 400 the gateway.
// Sonnet 4.6 has effort but caps below `xhigh`; clamping keeps the request from 400ing.
let dir = tempfile::tempdir().unwrap();
let vars = env_vars(vec![
xdg(&dir),
Expand Down Expand Up @@ -825,6 +856,7 @@ mod tests {
thinking: None,
show_thinking: true,
theme: Theme::default(),
theme_name: "macchiato".to_owned(),
};
let snap = cfg.snapshot();
assert_eq!(snap.auth_label, "OAuth");
Expand All @@ -834,6 +866,7 @@ mod tests {
assert_eq!(snap.max_tokens, 64_000);
assert_eq!(snap.prompt_cache_ttl, PromptCacheTtl::FiveMin);
assert!(snap.show_thinking);
assert_eq!(snap.theme_name, "macchiato");
}

// ── display_effort ──
Expand Down
7 changes: 6 additions & 1 deletion crates/oxide-code/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,12 @@ async fn agent_loop_task(
}
}
}
UserAction::Cancel | UserAction::ConfirmExit => {}
// Cancel / ConfirmExit are no-ops here; PreviewTheme / SwapTheme are TUI-only and
// applied client-side in `App::apply_action_locally`.
UserAction::Cancel
| UserAction::ConfirmExit
| UserAction::PreviewTheme { .. }
| UserAction::SwapTheme { .. } => {}
UserAction::Clear => {
let outcome =
roll_session(&mut session, &store, &file_tracker, client.model()).await;
Expand Down
2 changes: 2 additions & 0 deletions crates/oxide-code/src/slash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ mod picker;
mod registry;
mod status;
mod status_modal;
mod theme;

pub(crate) use context::{LiveSessionInfo, SlashContext};
pub(crate) use matcher::MatchedCommand;
Expand Down Expand Up @@ -111,6 +112,7 @@ pub(crate) fn test_session_info() -> LiveSessionInfo {
max_tokens: 32_000,
prompt_cache_ttl: PromptCacheTtl::OneHour,
show_thinking: false,
theme_name: "mocha".to_owned(),
},
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/oxide-code/src/slash/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use super::help::HelpCmd;
use super::init::InitCmd;
use super::model::ModelCmd;
use super::status::StatusCmd;
use super::theme::ThemeCmd;
use crate::agent::event::UserAction;

/// `Done` for client-side work via `ctx`; `Forward` for state-mutating commands.
Expand Down Expand Up @@ -58,6 +59,7 @@ 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, &StatusCmd,
&ThemeCmd,
];

/// Resolves `name` against canonical names first, then aliases.
Expand Down
Loading