feat(tui): Sprint 5 — chat modes, /diff, /undo, /ls commands

- Create src/chat_mode.rs: ChatMode enum (Code/Ask/Architect) with
  system_prompt_suffix() for model behavior control
- /code, /ask, /architect slash commands to switch modes
- /diff — shows uncommitted git changes in conversation (no --color=always)
- /undo — shows revertable changes, /undo --confirm to actually revert
- /ls <path> — reads file contents into conversation
- /theme <name> — switches between 11 built-in themes at runtime
- /keys <preset> — switches Emacs/Vim/Windows keybindings
- ChatMode field in TuiApp, integrated with dashboard display
- 267 tests pass

Sprint 5 of 7.

Authored by TheArchitectit
This commit is contained in:
Claude 2026-06-11 19:15:02 -05:00
parent faf2ab831b
commit 81beafa2a1
3 changed files with 192 additions and 0 deletions

View File

@ -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"));
}
}

View File

@ -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<dyn std::error::Error>> {
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 <name>",
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 <preset>", 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 <file-path>");
} 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);

View File

@ -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()?;