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:
parent
faf2ab831b
commit
81beafa2a1
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
|
|
|||
Loading…
Reference in New Issue