diff --git a/rust/crates/rusty-claude-cli/src/chat_mode.rs b/rust/crates/rusty-claude-cli/src/chat_mode.rs new file mode 100644 index 00000000..45022781 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/chat_mode.rs @@ -0,0 +1,83 @@ +//! Chat mode — controls AI behavior (code/ask/architect). + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatMode { + /// Default — full access, edits files and runs commands. + Code, + /// Discussion only — no file changes. + Ask, + /// Plan first, then implement after approval. + Architect, +} + +impl ChatMode { + pub fn label(&self) -> &'static str { + match self { + ChatMode::Code => "code", + ChatMode::Ask => "ask", + ChatMode::Architect => "arch", + } + } + + pub fn description(&self) -> &'static str { + match self { + ChatMode::Code => "Full access — edits and runs code", + ChatMode::Ask => "Discussion only — no changes made", + ChatMode::Architect => "Plan first, then implement", + } + } + + /// Appended to system prompt to instruct the model about mode constraints. + pub fn system_prompt_suffix(&self) -> &'static str { + match self { + ChatMode::Code => "", + ChatMode::Ask => "\n\nIMPORTANT: Do NOT modify any files. Only discuss and explain.", + ChatMode::Architect => { + "\n\nIMPORTANT: First create a plan. After the user approves, implement it." + } + } + } + + /// Cycle to the next mode. + pub fn next(self) -> Self { + match self { + ChatMode::Code => ChatMode::Ask, + ChatMode::Ask => ChatMode::Architect, + ChatMode::Architect => ChatMode::Code, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cycle() { + assert_eq!(ChatMode::Code.next(), ChatMode::Ask); + assert_eq!(ChatMode::Ask.next(), ChatMode::Architect); + assert_eq!(ChatMode::Architect.next(), ChatMode::Code); + } + + #[test] + fn test_labels() { + assert_eq!(ChatMode::Code.label(), "code"); + assert_eq!(ChatMode::Ask.label(), "ask"); + assert_eq!(ChatMode::Architect.label(), "arch"); + } + + #[test] + fn test_code_mode_has_no_suffix() { + assert_eq!(ChatMode::Code.system_prompt_suffix(), ""); + } + + #[test] + fn test_ask_mode_restrains() { + assert!(ChatMode::Ask.system_prompt_suffix().contains("Do NOT modify")); + } + + #[test] + fn test_architect_mode_plans_first() { + assert!(ChatMode::Architect.system_prompt_suffix().contains("plan")); + } +} diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 1636d8b1..ab42a55c 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -18,6 +18,7 @@ mod init; mod input; mod render; mod setup_wizard; +mod chat_mode; mod command_palette; mod keybindings; mod markdown; @@ -7181,6 +7182,112 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box> { break; } + // Slash commands handled locally in TUI (not sent to model) + if trimmed.starts_with("/theme") { + let args = trimmed.strip_prefix("/theme").unwrap_or("").trim(); + if args.is_empty() { + let names = crate::theme::TuiTheme::all_builtin_names(); + app.push_system_message(&format!( + "Available themes: {}\nUsage: /theme ", + names.join(", ") + )); + } else if let Some(theme) = crate::theme::TuiTheme::builtin(args) { + app.set_theme(theme); + app.push_system_message(&format!("Theme: {args}")); + } else { + app.push_system_message(&format!("Unknown theme: {args}")); + } + continue; + } + if trimmed.starts_with("/keys") { + let args = trimmed.strip_prefix("/keys").unwrap_or("").trim(); + match args { + "emacs" => { app.keymap.set_preset(crate::keybindings::KeyPreset::Emacs); app.push_system_message("Keys: Emacs"); } + "vim" => { app.keymap.set_preset(crate::keybindings::KeyPreset::Vim); app.push_system_message("Keys: Vim — i for insert, Esc for normal"); } + "windows" => { app.keymap.set_preset(crate::keybindings::KeyPreset::Windows); app.push_system_message("Keys: Windows"); } + "" => { + app.push_system_message(&format!("Current: {:?}\nAvailable: emacs, vim, windows\nUsage: /keys ", app.keymap.preset())); + } + _ => { app.push_system_message(&format!("Unknown: {args}. Available: emacs, vim, windows")); } + } + continue; + } + if matches!(trimmed.as_str(), "/code" | "/ask" | "/architect" | "/arch") { + let mode = match trimmed.as_str() { + "/code" => crate::chat_mode::ChatMode::Code, + "/ask" => crate::chat_mode::ChatMode::Ask, + _ => crate::chat_mode::ChatMode::Architect, + }; + app.chat_mode = mode; + app.push_system_message(&format!("Mode: {} — {}", mode.label(), mode.description())); + continue; + } + if trimmed == "/diff" { + let output = std::process::Command::new("git") + .args(["diff"]) + .current_dir(std::env::current_dir().unwrap_or_default()) + .output(); + match output { + Ok(out) if out.status.success() => { + let diff = String::from_utf8_lossy(&out.stdout); + if diff.is_empty() { + app.push_system_message("No uncommitted changes."); + } else { + app.push_diff(&diff); + } + } + Ok(out) => { + let err = String::from_utf8_lossy(&out.stderr); + app.push_system_message(&format!("git diff failed: {err}")); + } + Err(e) => { app.push_system_message(&format!("Failed to run git: {e}")); } + } + continue; + } + if trimmed.starts_with("/undo") { + let args = trimmed.strip_prefix("/undo").unwrap_or("").trim(); + if args == "--confirm" || args == "-y" { + let output = std::process::Command::new("git") + .args(["checkout", "--", "."]) + .current_dir(std::env::current_dir().unwrap_or_default()) + .output(); + match output { + Ok(out) if out.status.success() => app.push_system_message("✓ Reverted all uncommitted changes."), + Ok(out) => { let err = String::from_utf8_lossy(&out.stderr); app.push_system_message(&format!("Undo failed: {err}")); } + Err(e) => { app.push_system_message(&format!("Failed to run git: {e}")); } + } + } else { + let output = std::process::Command::new("git") + .args(["diff", "--stat"]) + .current_dir(std::env::current_dir().unwrap_or_default()) + .output(); + let stat = output.ok().filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .unwrap_or_default(); + if stat.is_empty() { + app.push_system_message("Nothing to undo — no uncommitted changes."); + } else { + app.push_system_message(&format!("This will revert:\n{stat}\nType /undo --confirm to proceed.")); + } + } + continue; + } + if trimmed.starts_with("/ls") { + let path = trimmed.strip_prefix("/ls").unwrap_or("").trim(); + if path.is_empty() { + app.push_system_message("Usage: /ls "); + } else { + match std::fs::read_to_string(path) { + Ok(content) => { + app.push_system_message(&format!("── {path} ──")); + app.push_output(&content, false); + } + Err(e) => app.push_system_message(&format!("Cannot read {path}: {e}")), + } + } + continue; + } + app.push_user_input(&input); cli.record_prompt_history(&trimmed); update_dashboard(&dashboard_state, &cli); diff --git a/rust/crates/rusty-claude-cli/src/tui.rs b/rust/crates/rusty-claude-cli/src/tui.rs index bdfe6ae7..37734bb4 100644 --- a/rust/crates/rusty-claude-cli/src/tui.rs +++ b/rust/crates/rusty-claude-cli/src/tui.rs @@ -182,6 +182,7 @@ pub struct TuiApp { pub theme: crate::theme::TuiTheme, pub keymap: crate::keybindings::KeyMap, pub command_palette: crate::command_palette::CommandPalette, + pub chat_mode: crate::chat_mode::ChatMode, } const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -238,6 +239,7 @@ impl TuiApp { theme: crate::theme::TuiTheme::builtin("default").unwrap(), keymap: crate::keybindings::KeyMap::new(crate::keybindings::KeyPreset::Emacs), command_palette: crate::command_palette::CommandPalette::new(), + chat_mode: crate::chat_mode::ChatMode::Code, needs_redraw: true, }; me.draw_screen()?;