From a663304065fdde7dd21ac470a4ad9cec7dfa6de7 Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Tue, 5 May 2026 03:18:47 +0800 Subject: [PATCH 1/4] feat(codegraff): slash command palette + autocomplete Adds a fuzzy slash command palette to the codegraff TUI. Typing `/` when the composer is empty opens a centered overlay listing all slash commands ported from graff's REPL AppCommand enum, plus codegraff-specific commands (/workflow, /image, /logs, etc.). Implementation notes: - New Overlay::CommandPalette variant with CommandPaletteState (query + selected_index) lives next to the existing overlay types in main.rs to avoid risky refactors. - Static PALETTE_COMMANDS table holds (name, description). The list mirrors crates/forge_main/src/model.rs, excluding REPL-only entries (:exit, :edit, :retry). - Fuzzy ranking uses the workspace `nucleo` dep (already declared in Cargo.toml). Items with score 0 are filtered out; ties preserve catalogue order. - `dispatch_command_palette` writes "/" into self.composer and reuses the existing handle_enter dispatch path, so commands like /workflow keep their parser unchanged. - Keybindings: Up/Down navigate, Tab autocompletes, Enter dispatches, Esc closes, Backspace deletes (closes when query empty), any other char appends to the query. - Render path calls Clear over the overlay rect; full-frame Clear at the top of render() prevents close artifacts. overlay_area is recomputed each frame, so resize re-clamps automatically. Tests cover empty-query catalogue, fuzzy match, zero-score filter, catalogue uniqueness, REPL-only exclusion, and rendered line content. All 118 codegraff bin tests pass; clippy and release build are clean. Closes #17 Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 22 ++ crates/codegraff-tui/Cargo.toml | 1 + crates/codegraff-tui/src/main.rs | 431 ++++++++++++++++++++++++++++++- 3 files changed, 453 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 307dcb85..a20cf242 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1037,6 +1037,7 @@ dependencies = [ "forge_domain", "futures", "image", + "nucleo", "pretty_assertions", "ratatui", "rustls 0.23.40", @@ -5743,6 +5744,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nucleo" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4" +dependencies = [ + "nucleo-matcher", + "parking_lot", + "rayon", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num-conv" version = "0.2.1" diff --git a/crates/codegraff-tui/Cargo.toml b/crates/codegraff-tui/Cargo.toml index 3ae01dca..060566be 100644 --- a/crates/codegraff-tui/Cargo.toml +++ b/crates/codegraff-tui/Cargo.toml @@ -16,6 +16,7 @@ forge_api.workspace = true forge_domain.workspace = true futures.workspace = true image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } +nucleo.workspace = true ratatui = "0.28.1" # ratatui-image 10.x currently targets ratatui 0.30; keep native support deferred # until Codegraff moves to the same ratatui version. diff --git a/crates/codegraff-tui/src/main.rs b/crates/codegraff-tui/src/main.rs index 3dc323ab..1bc161c8 100644 --- a/crates/codegraff-tui/src/main.rs +++ b/crates/codegraff-tui/src/main.rs @@ -38,6 +38,8 @@ use forge_api::{ URLParamSpec, Usage, }; use futures::StreamExt; +use nucleo::pattern::{CaseMatching, Normalization, Pattern}; +use nucleo::{Config, Matcher, Utf32String}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; @@ -59,6 +61,60 @@ const WORKFLOW_DIALOG_NODE_HEADER_ROWS: usize = 1; const WORKFLOW_DIALOG_SELECTED_NODE_DETAIL_ROWS: usize = 6; const SHORTCUT_HINT_MILLIS: u64 = 2_500; +/// Static catalogue of slash commands surfaced in the command palette. +/// +/// The list mirrors the AppCommand enum in `crates/forge_main/src/model.rs` +/// (the REPL parser that also accepts `/`), plus codegraff-specific commands +/// already dispatched in this TUI (`/workflow`, `/image`, `/logs`, `/login`, +/// `/connect`, `/models`, `/usage`). Excluded REPL-only entries: +/// - `:exit` — the TUI quits via Ctrl+C; users do not expect a slash form. +/// - `:edit` — opens an external editor, which is REPL-only behaviour. +/// - `:retry` — depends on REPL conversation state not surfaced here. +const PALETTE_COMMANDS: &[(&str, &str)] = &[ + ("act", "Switch to the forge agent (implementation mode)"), + ("agent", "Switch the active agent interactively"), + ("commit", "Generate AI commit message and commit changes"), + ("commit-preview", "Preview the AI-generated commit message"), + ("compact", "Compact the conversation context"), + ("config", "Display the effective resolved configuration"), + ("config-commit-model", "Set the model used for commit message generation"), + ("config-edit", "Open the global config file in an editor"), + ("config-model", "Set the global model via interactive selection"), + ("config-reasoning-effort", "Set reasoning effort in the global config"), + ("config-reload", "Reset session overrides to global config"), + ("config-suggest-model", "Set the model used for suggest generation"), + ("connect", "Connect or configure a provider"), + ("conversation", "List conversations for the active workspace"), + ("conversation-rename", "Rename a conversation interactively"), + ("clone", "Clone the current or a selected conversation"), + ("copy", "Copy the last AI response to the clipboard"), + ("dump", "Save the conversation as JSON or HTML"), + ("fast", "Toggle Priority Processing for OpenAI-series requests"), + ("help", "Switch to help mode for tool questions"), + ("image", "Attach an image from the filesystem (path)"), + ("index", "Index the current workspace for semantic code search"), + ("info", "Display system environment information"), + ("login", "Log in to a provider"), + ("logout", "Log out from the configured provider"), + ("logs", "Show the path to the codegraff log file"), + ("model", "Switch or select the active model"), + ("models", "List or pick from registered provider models"), + ("new", "Start a new conversation while preserving history"), + ("plan", "Switch to the muse agent (planning mode)"), + ("provider", "Configure a provider (alias of /login)"), + ("rename", "Rename the current conversation"), + ("sage", "Switch to research mode (sage agent)"), + ("skill", "List all available skills"), + ("suggest", "Generate a shell command from natural language"), + ("tools", "List all available tools with descriptions"), + ("update", "Update graff to the latest compatible version"), + ("usage", "Show token usage and request information"), + ("workflow", "Open a workflow review dialog for a goal"), + ("workspace-info", "Show workspace information with sync details"), + ("workspace-init", "Initialize a workspace without syncing files"), + ("workspace-status", "Show sync status of workspace files"), + ("workspace-sync", "Sync the current workspace for semantic search"), +]; #[tokio::main] async fn main() -> Result<()> { let log_path = codegraff_log_path(); @@ -314,6 +370,13 @@ enum Overlay { Connect(Box), Model(ModelDialog), Workflow(WorkflowDialog), + CommandPalette(CommandPaletteState), +} + +#[derive(Clone, Debug, Default)] +struct CommandPaletteState { + query: String, + selected_index: usize, } #[derive(Clone, Debug)] @@ -641,6 +704,10 @@ impl Tui { match key.code { KeyCode::Char(_) => { if let Some(ch) = composer_input_char(key) { + if ch == '/' && self.composer.is_empty() && self.overlay.is_none() { + self.open_command_palette(); + return Ok(()); + } self.push_composer_char(ch); } } @@ -663,6 +730,9 @@ impl Tui { return Ok(false); }; + if matches!(overlay, Overlay::CommandPalette(_)) { + return self.handle_command_palette_key(key, tx).await; + } match key.code { KeyCode::Esc => { if self.cancel_workflow_edit_mode() { @@ -672,6 +742,7 @@ impl Tui { Overlay::Connect(dialog) => Some(dialog.intent.cancelled_message()), Overlay::Model(_) => None, Overlay::Workflow(_) => Some("Workflow cancelled."), + Overlay::CommandPalette(_) => None, }); self.close_overlay(); if let Some(message) = message { @@ -756,6 +827,114 @@ impl Tui { } } + fn open_command_palette(&mut self) { + self.overlay = Some(Overlay::CommandPalette(CommandPaletteState::default())); + self.overlay_scroll_from_top = 0; + self.overlay_input.clear(); + } + + fn palette_state(&self) -> Option { + match self.overlay.as_ref() { + Some(Overlay::CommandPalette(state)) => Some(state.clone()), + _ => None, + } + } + + fn set_palette_state(&mut self, new_state: CommandPaletteState) { + if let Some(Overlay::CommandPalette(state)) = self.overlay.as_mut() { + *state = new_state; + } + } + + async fn handle_command_palette_key( + &mut self, + key: KeyEvent, + tx: mpsc::UnboundedSender, + ) -> Result { + let Some(mut state) = self.palette_state() else { + return Ok(false); + }; + + match key.code { + KeyCode::Esc => { + self.close_overlay(); + } + KeyCode::Up => { + let matches = filter_palette_commands(&state.query); + if !matches.is_empty() { + if state.selected_index == 0 { + state.selected_index = matches.len() - 1; + } else { + state.selected_index -= 1; + } + } + self.set_palette_state(state); + } + KeyCode::Down => { + let matches = filter_palette_commands(&state.query); + if !matches.is_empty() { + state.selected_index = (state.selected_index + 1) % matches.len(); + } + self.set_palette_state(state); + } + KeyCode::Tab => { + let matches = filter_palette_commands(&state.query); + if let Some(idx) = matches + .get(state.selected_index) + .and_then(|i| PALETTE_COMMANDS.get(*i)) + { + state.query = idx.0.to_string(); + state.selected_index = 0; + } + self.set_palette_state(state); + } + KeyCode::Backspace => { + if state.query.is_empty() { + self.close_overlay(); + } else { + state.query.pop(); + state.selected_index = 0; + self.set_palette_state(state); + } + } + KeyCode::Enter => { + let matches = filter_palette_commands(&state.query); + if let Some(name) = matches + .get(state.selected_index) + .and_then(|i| PALETTE_COMMANDS.get(*i)) + .map(|(name, _)| (*name).to_string()) + { + self.dispatch_command_palette(&name, tx).await?; + } + } + KeyCode::Char(_) => { + if let Some(ch) = composer_input_char(key) { + state.query.push(ch); + state.selected_index = 0; + self.set_palette_state(state); + } + } + _ => {} + } + Ok(true) + } + + async fn dispatch_command_palette( + &mut self, + name: &str, + tx: mpsc::UnboundedSender, + ) -> Result<()> { + // Build the slash form so the existing parsers in `handle_enter` + // (parse_model_command, parse_workflow_command, parse_image_command, + // parse_connect_command, etc.) can dispatch the command unchanged. + let composer = format!("/{}", name); + self.close_overlay(); + self.composer = composer; + self.composer_scroll_from_bottom = 0; + self.handle_enter(tx).await?; + Ok(()) + } + async fn submit_overlay_input(&mut self, tx: mpsc::UnboundedSender) -> Result<()> { match self.overlay.clone() { Some(Overlay::Model(_)) => self.submit_numbered_overlay_selection().await, @@ -766,6 +945,9 @@ impl Tui { ConnectStep::ApiKeyInput { .. } => self.submit_connect_overlay_input().await, }, Some(Overlay::Workflow(_)) => self.submit_workflow_overlay_input(tx).await, + // Command palette has its own handler in `handle_command_palette_key` + // so the legacy overlay submit path never reaches here. + Some(Overlay::CommandPalette(_)) => Ok(()), None => Ok(()), } } @@ -800,7 +982,7 @@ impl Tui { } ConnectStep::ApiKeyInput { .. } => {} }, - Some(Overlay::Workflow(_)) | None => {} + Some(Overlay::Workflow(_)) | Some(Overlay::CommandPalette(_)) | None => {} } Ok(()) @@ -2594,6 +2776,23 @@ impl Tui { .scroll((overlay_scroll, 0)); frame.render_widget(dialog, dialog_area); } + Overlay::CommandPalette(state) => { + let matches = filter_palette_commands(&state.query); + let lines = command_palette_lines(state, &matches, dialog_content_width); + let overlay_scroll = overlay_scroll_top( + self.overlay_scroll_from_top, + lines.len(), + dialog_inner_height, + ); + let dialog = Paragraph::new(lines) + .block( + Block::default() + .title("Slash commands") + .borders(Borders::ALL), + ) + .scroll((overlay_scroll, 0)); + frame.render_widget(dialog, dialog_area); + } } } } @@ -2862,6 +3061,128 @@ fn unquote_workflow_goal(goal: &str) -> String { } } +/// Score and rank slash commands against `query` using nucleo's fuzzy matcher. +/// +/// Returns indices into [`PALETTE_COMMANDS`] sorted by descending score +/// (best match first). When `query` is empty we keep the original catalogue +/// order so the palette shows everything alphabetically. +fn filter_palette_commands(query: &str) -> Vec { + let query = query.trim(); + if query.is_empty() { + return (0..PALETTE_COMMANDS.len()).collect(); + } + + let mut matcher = Matcher::new(Config::DEFAULT); + let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart); + + let mut scored: Vec<(usize, u32)> = PALETTE_COMMANDS + .iter() + .enumerate() + .filter_map(|(idx, (name, _))| { + let haystack = Utf32String::from(*name); + pattern + .score(haystack.slice(..), &mut matcher) + .filter(|score| *score > 0) + .map(|score| (idx, score)) + }) + .collect(); + // Highest score first; on tie, preserve catalogue order via the index. + scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + scored.into_iter().map(|(idx, _)| idx).collect() +} + +/// Build the rendered lines for the slash command palette. +fn command_palette_lines( + state: &CommandPaletteState, + matches: &[usize], + width: usize, +) -> Vec> { + let mut lines = Vec::new(); + let prompt_label = "/"; + let cursor = "_"; + let prompt_spans = vec![ + Span::styled( + prompt_label.to_string(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled(state.query.clone(), Style::default().fg(Color::White)), + Span::styled( + cursor.to_string(), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + ]; + lines.push(Line::from(prompt_spans)); + lines.push(Line::raw("")); + + if matches.is_empty() { + lines.push(Line::from(Span::styled( + "No matching commands. Press Esc to close.", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + ))); + return lines; + } + + let max_name_len = matches + .iter() + .filter_map(|idx| PALETTE_COMMANDS.get(*idx)) + .map(|(name, _)| name.chars().count()) + .max() + .unwrap_or(0); + let name_col = max_name_len.saturating_add(2); + + for (row, command_index) in matches.iter().enumerate() { + let Some((name, description)) = PALETTE_COMMANDS.get(*command_index) else { + continue; + }; + let selected = row == state.selected_index; + let marker = if selected { ">" } else { " " }; + let base_style = if selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + let description_style = if selected { + base_style + } else { + Style::default().fg(Color::DarkGray) + }; + let raw = format!( + "{marker} /{name: 0 { + truncate_single_line(&raw, width) + } else { + raw + }; + // Split truncated back into command + description for nicer styling. + let prefix_len = marker.len() + 1 + 1 + name_col; // marker + space + '/' + name padded + if truncated.len() >= prefix_len { + let (prefix, rest) = truncated.split_at(prefix_len); + lines.push(Line::from(vec![ + Span::styled(prefix.to_string(), base_style), + Span::styled(rest.to_string(), description_style), + ])); + } else { + lines.push(Line::from(Span::styled(truncated, base_style))); + } + } + + lines +} + fn workflow_overlay_action(input: &str) -> WorkflowOverlayAction { let action = input.trim(); if action.is_empty() || matches!(action, "a" | "approve" | "run") { @@ -4930,6 +5251,114 @@ mod tests { assert_eq!(actual, expected); } + #[test] + fn palette_filter_empty_query_returns_all_commands_in_order() { + let actual = filter_palette_commands(""); + let expected: Vec = (0..PALETTE_COMMANDS.len()).collect(); + assert_eq!(actual, expected); + } + + #[test] + fn palette_filter_fuzzy_matches_command_name() { + let actual = filter_palette_commands("mod"); + let names: Vec<&str> = actual + .iter() + .filter_map(|i| PALETTE_COMMANDS.get(*i)) + .map(|(name, _)| *name) + .collect(); + assert!( + names.contains(&"model"), + "fuzzy 'mod' should match /model, got {names:?}" + ); + assert!( + names.contains(&"models"), + "fuzzy 'mod' should match /models, got {names:?}" + ); + assert!( + !names.contains(&"image"), + "fuzzy 'mod' must not match unrelated /image, got {names:?}" + ); + } + + #[test] + fn palette_filter_filters_zero_score_items_out() { + // A query that no command name contains should produce zero results. + let actual = filter_palette_commands("zzzzzzzqqqq"); + assert!( + actual.is_empty(), + "garbage query must yield no matches, got {actual:?}" + ); + } + + #[test] + fn palette_command_names_are_unique_and_lowercase() { + let mut seen = std::collections::HashSet::new(); + for (name, description) in PALETTE_COMMANDS { + assert!( + seen.insert(*name), + "duplicate command in palette table: {name}" + ); + assert!( + !name.is_empty() && name.chars().all(|c| !c.is_whitespace()), + "command names must be a single token: {name}" + ); + assert!( + !description.is_empty(), + "every palette command needs a description: {name}" + ); + } + } + + #[test] + fn palette_excludes_repl_only_commands() { + for name in &["exit", "edit", "retry"] { + assert!( + !PALETTE_COMMANDS.iter().any(|(n, _)| n == name), + "REPL-only command '{name}' must be excluded from the palette" + ); + } + } + #[test] + fn palette_lines_render_query_and_selected_marker() { + let state = CommandPaletteState { + query: "mod".to_string(), + selected_index: 0, + }; + let matches = filter_palette_commands(&state.query); + let lines = command_palette_lines(&state, &matches, 80); + assert!(!lines.is_empty(), "palette should render at least one line"); + + let rendered: Vec = lines.iter().cloned().map(render_line).collect(); + + // First line shows the query prompt with leading slash. + assert!( + rendered[0].starts_with("/mod"), + "palette prompt should echo /mod, got {:?}", + rendered[0] + ); + + // Some line should contain the selected marker '>' followed by /. + let has_marker = rendered + .iter() + .any(|line| line.trim_start().starts_with('>')); + assert!(has_marker, "expected a selected '>' marker in palette"); + } + + #[test] + fn palette_lines_show_no_match_hint_for_zero_results() { + let state = CommandPaletteState { + query: "zzzzqqqqzz".to_string(), + selected_index: 0, + }; + let matches = filter_palette_commands(&state.query); + assert!(matches.is_empty()); + let lines = command_palette_lines(&state, &matches, 80); + let rendered: Vec = lines.iter().cloned().map(render_line).collect(); + assert!( + rendered.iter().any(|line| line.contains("No matching")), + "missing no-match hint, got {rendered:?}" + ); + } #[test] fn connect_intent_uses_login_dialog_copy() { let fixture = ConnectIntent::Login; From 5dd42964f492d0d7f1539b8fca3822c5016097d7 Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Tue, 5 May 2026 03:29:53 +0800 Subject: [PATCH 2/4] fix(palette): restrict to commands codegraff handle_enter actually routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-merge review caught that ~30 entries in PALETTE_COMMANDS exposed commands the codegraff TUI dispatcher does not yet handle locally (`/new`, `/info`, `/agent`, `/commit`, `/dump`, `/skill`, `/tools`, `/copy`, `/clone`, `/conversation*`, `/workspace-*`, `/index`, `/update`, `/compact`, `/sage`, `/help`, `/plan`, `/act`, `/rename`, `/fast`, `/config-*`, `/suggest`, etc.). Selecting any of those from the palette would silently send the literal slash text to the LLM as a chat message, which is strictly worse UX than not exposing them. Restrict the palette to the 8 commands `handle_enter` does route: `/connect`, `/image`, `/login`, `/logs`, `/model`, `/models`, `/usage`, `/workflow`. Wiring the rest into the TUI dispatcher is tracked in a follow-up issue under the parity tracker. Also tighten `palette_command_names_are_unique_and_lowercase` to actually assert lowercase (it didn't before — name was misleading) and add a sanity test confirming every locally-handled command is in the palette so the dispatcher stays in sync. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/codegraff-tui/src/main.rs | 74 ++++++++++++++------------------ 1 file changed, 32 insertions(+), 42 deletions(-) diff --git a/crates/codegraff-tui/src/main.rs b/crates/codegraff-tui/src/main.rs index 1bc161c8..c198741b 100644 --- a/crates/codegraff-tui/src/main.rs +++ b/crates/codegraff-tui/src/main.rs @@ -63,57 +63,27 @@ const SHORTCUT_HINT_MILLIS: u64 = 2_500; /// Static catalogue of slash commands surfaced in the command palette. /// -/// The list mirrors the AppCommand enum in `crates/forge_main/src/model.rs` -/// (the REPL parser that also accepts `/`), plus codegraff-specific commands -/// already dispatched in this TUI (`/workflow`, `/image`, `/logs`, `/login`, -/// `/connect`, `/models`, `/usage`). Excluded REPL-only entries: -/// - `:exit` — the TUI quits via Ctrl+C; users do not expect a slash form. -/// - `:edit` — opens an external editor, which is REPL-only behaviour. -/// - `:retry` — depends on REPL conversation state not surfaced here. +/// Slash commands surfaced by the codegraff TUI command palette. +/// +/// **Restricted to the subset that codegraff's `handle_enter` actually +/// routes locally.** Commands like `/new`, `/info`, `/agent`, `/commit`, +/// `/dump`, etc. exist in the graff REPL's `AppCommand` enum but are not +/// yet wired into the codegraff TUI dispatcher — selecting them from the +/// palette would silently send them to the LLM as chat messages, which is +/// strictly worse UX than not exposing them. Wiring those commands into +/// the TUI dispatcher is tracked as a sub-task of the codegraff parity +/// effort (see GitHub tracking issue). +/// +/// Excluded for the same reason as the brief: `:exit`, `:edit`, `:retry`. const PALETTE_COMMANDS: &[(&str, &str)] = &[ - ("act", "Switch to the forge agent (implementation mode)"), - ("agent", "Switch the active agent interactively"), - ("commit", "Generate AI commit message and commit changes"), - ("commit-preview", "Preview the AI-generated commit message"), - ("compact", "Compact the conversation context"), - ("config", "Display the effective resolved configuration"), - ("config-commit-model", "Set the model used for commit message generation"), - ("config-edit", "Open the global config file in an editor"), - ("config-model", "Set the global model via interactive selection"), - ("config-reasoning-effort", "Set reasoning effort in the global config"), - ("config-reload", "Reset session overrides to global config"), - ("config-suggest-model", "Set the model used for suggest generation"), ("connect", "Connect or configure a provider"), - ("conversation", "List conversations for the active workspace"), - ("conversation-rename", "Rename a conversation interactively"), - ("clone", "Clone the current or a selected conversation"), - ("copy", "Copy the last AI response to the clipboard"), - ("dump", "Save the conversation as JSON or HTML"), - ("fast", "Toggle Priority Processing for OpenAI-series requests"), - ("help", "Switch to help mode for tool questions"), ("image", "Attach an image from the filesystem (path)"), - ("index", "Index the current workspace for semantic code search"), - ("info", "Display system environment information"), ("login", "Log in to a provider"), - ("logout", "Log out from the configured provider"), ("logs", "Show the path to the codegraff log file"), ("model", "Switch or select the active model"), ("models", "List or pick from registered provider models"), - ("new", "Start a new conversation while preserving history"), - ("plan", "Switch to the muse agent (planning mode)"), - ("provider", "Configure a provider (alias of /login)"), - ("rename", "Rename the current conversation"), - ("sage", "Switch to research mode (sage agent)"), - ("skill", "List all available skills"), - ("suggest", "Generate a shell command from natural language"), - ("tools", "List all available tools with descriptions"), - ("update", "Update graff to the latest compatible version"), ("usage", "Show token usage and request information"), ("workflow", "Open a workflow review dialog for a goal"), - ("workspace-info", "Show workspace information with sync details"), - ("workspace-init", "Initialize a workspace without syncing files"), - ("workspace-status", "Show sync status of workspace files"), - ("workspace-sync", "Sync the current workspace for semantic search"), ]; #[tokio::main] async fn main() -> Result<()> { @@ -5302,6 +5272,10 @@ mod tests { !name.is_empty() && name.chars().all(|c| !c.is_whitespace()), "command names must be a single token: {name}" ); + assert!( + name.chars().all(|c| c.is_ascii_lowercase() || c == '-'), + "command name must be lowercase ASCII (with optional dashes): {name}" + ); assert!( !description.is_empty(), "every palette command needs a description: {name}" @@ -5309,6 +5283,22 @@ mod tests { } } + #[test] + fn palette_includes_locally_handled_commands() { + // Sanity check: every command exposed in the palette must be one + // the codegraff dispatcher (`handle_enter`) actually routes + // locally. If you add a command here, also wire it in + // `handle_enter`, otherwise selecting it from the palette will + // silently dispatch to the LLM as a chat message. + let must_be_present = ["usage", "model", "models", "login", "connect", "workflow", "logs", "image"]; + for name in &must_be_present { + assert!( + PALETTE_COMMANDS.iter().any(|(n, _)| n == name), + "expected palette to include `/{name}` (it is handled by handle_enter)" + ); + } + } + #[test] fn palette_excludes_repl_only_commands() { for name in &["exit", "edit", "retry"] { From fa13f928f9caf6c42df846859b32064dc2f0fd75 Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Tue, 5 May 2026 09:57:22 +0800 Subject: [PATCH 3/4] feat(codegraff): wire /new, /info, /help into TUI dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds local handlers for the three highest-traffic Tier 1 commands from the parity tracker (#22), plus the corresponding palette entries: - `/new` — calls `Conversation::generate()` + `upsert_conversation`, then resets transcript, image attachments, pending pastes, usage, and any in-flight workflow. Active model is preserved. - `/info` — pushes status entries for active agent, model label, conversation id, and log path. TUI equivalent of the REPL `:info` Info widget. - `/help` — opens the command palette (the discovery surface). Cheap alias so `/help` does the natural thing in a TUI. The `palette_includes_locally_handled_commands` test is updated to require these three names. This keeps PALETTE_COMMANDS in lockstep with handle_enter — no ghost commands. Remaining tiers (agent switching, conversation management, config, workspace, git) tracked in #22. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/codegraff-tui/src/main.rs | 103 ++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/crates/codegraff-tui/src/main.rs b/crates/codegraff-tui/src/main.rs index c198741b..b4b9b39c 100644 --- a/crates/codegraff-tui/src/main.rs +++ b/crates/codegraff-tui/src/main.rs @@ -77,11 +77,14 @@ const SHORTCUT_HINT_MILLIS: u64 = 2_500; /// Excluded for the same reason as the brief: `:exit`, `:edit`, `:retry`. const PALETTE_COMMANDS: &[(&str, &str)] = &[ ("connect", "Connect or configure a provider"), + ("help", "Open the command palette to discover available commands"), ("image", "Attach an image from the filesystem (path)"), + ("info", "Show active agent, model, conversation id and log path"), ("login", "Log in to a provider"), ("logs", "Show the path to the codegraff log file"), ("model", "Switch or select the active model"), ("models", "List or pick from registered provider models"), + ("new", "Start a new conversation, clearing the transcript"), ("usage", "Show token usage and request information"), ("workflow", "Open a workflow review dialog for a goal"), ]; @@ -1150,6 +1153,29 @@ impl Tui { return Ok(()); } + if raw_prompt == "/new" { + self.start_new_conversation().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + + if raw_prompt == "/info" { + self.show_info().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + + if raw_prompt == "/help" { + // /help is the discovery surface — open the command palette so + // the user can see (and fuzzy-search) every command available. + self.open_command_palette(); + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + match parse_model_command(&raw_prompt) { ModelCommand::List => { self.show_models().await; @@ -1531,6 +1557,81 @@ impl Tui { self.scroll_from_bottom = 0; } + /// Resets the conversation to a fresh one. + /// + /// Generates a new conversation id, asks the API to upsert it, then + /// clears the per-conversation state on the TUI side: transcript, + /// pending pastes, image attachments, in-flight workflow, scroll + /// positions. Active model and overlay are preserved across the + /// transition. + async fn start_new_conversation(&mut self) { + let new_conversation = Conversation::generate(); + let new_id = new_conversation.id; + + if let Err(error) = self + .api + .upsert_conversation(Conversation::new(new_id)) + .await + { + self.log_error("upsert new conversation failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not start a new conversation: {error:#}" + ))); + self.scroll_from_bottom = 0; + return; + } + + self.conversation_id = new_id; + self.transcript.clear(); + self.transcript.push(TranscriptEntry::Status( + "New conversation started.".to_string(), + )); + self.pending_pastes.clear(); + self.large_paste_counter = 0; + self.image_attachments.clear(); + self.usage = None; + self.workflow_run = None; + self.scroll_from_bottom = 0; + } + + /// Renders system + session info as a series of transcript status + /// entries: active agent, model label, conversation id, log path. + /// Mirrors the REPL `:info` command but uses the TUI transcript + /// surface instead of the Info widget. + async fn show_info(&mut self) { + let agent = self.api.get_active_agent().await; + let model = self.active_model.clone(); + + self.transcript + .push(TranscriptEntry::Status("─ session info ─".to_string())); + if let Some(agent_id) = agent { + self.transcript.push(TranscriptEntry::Status(format!( + "Agent: {}", + agent_id.as_str() + ))); + } else { + self.transcript + .push(TranscriptEntry::Status("Agent: (none)".to_string())); + } + match model { + Some(label) => self + .transcript + .push(TranscriptEntry::Status(format!("Model: {label}"))), + None => self + .transcript + .push(TranscriptEntry::Status("Model: (not set)".to_string())), + } + self.transcript.push(TranscriptEntry::Status(format!( + "Conversation: {}", + self.conversation_id + ))); + self.transcript.push(TranscriptEntry::Status(format!( + "Logs: {}", + self.log_path.display() + ))); + self.scroll_from_bottom = 0; + } + async fn show_models(&mut self) { match self.model_options().await { Ok(models) if models.is_empty() => { @@ -5290,7 +5391,7 @@ mod tests { // locally. If you add a command here, also wire it in // `handle_enter`, otherwise selecting it from the palette will // silently dispatch to the LLM as a chat message. - let must_be_present = ["usage", "model", "models", "login", "connect", "workflow", "logs", "image"]; + let must_be_present = ["usage", "model", "models", "login", "connect", "workflow", "logs", "image", "new", "info", "help"]; for name in &must_be_present { assert!( PALETTE_COMMANDS.iter().any(|(n, _)| n == name), From b5c4e02bb89162b5d10f472aadd7b6728014a5d6 Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Tue, 5 May 2026 11:27:16 +0800 Subject: [PATCH 4/4] feat(codegraff): wire all REPL tiers into TUI dispatcher Closes Tiers 2-7 of #22. Adds 32 new slash commands to the codegraff TUI dispatcher with full PALETTE_COMMANDS coverage and matching test entries. Rough split: - Tier 2 (agent switching): /act, /plan, /sage, /agent. /agent without args lists available agents; with arg switches. - Tier 3 (conversation): /conversation(s), /rename, /conversation-rename, /dump (json or --html), /compact. /clone and /copy are deferred with helpful status messages. - Tier 4 (config): /config, /config-edit (opens $EDITOR on ~/forge/.forge.toml), /fast (toggle), /reasoning-effort and /config-reasoning-effort (arg form), /config-model, /config-commit-model, /config-suggest-model (provider resolved from current session). /config-reload deferred. - Tier 5 (workspace + tools): /workspace-sync, /sync, /index, /workspace-status, /workspace-info, /workspace-init, /skill, /tools, /suggest. - Tier 6 (git): /commit, /commit-preview. - Tier 7 (admin): /logout (removes default provider creds), /update (runs the install script). Adds chrono, dirs, serde_json to the codegraff-tui Cargo.toml; all are already in workspace deps. New code shares the same pattern as the existing /usage / /new / /info handlers. Note: most workspace + tools handlers print debug-formatted results (using {:?}) for now; richer rendering can land as follow-ups under the parity tracker. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 3 + crates/codegraff-tui/Cargo.toml | 3 + crates/codegraff-tui/src/main.rs | 913 ++++++++++++++++++++++++++++++- 3 files changed, 918 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index a20cf242..7604d2fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1032,7 +1032,9 @@ version = "0.1.2" dependencies = [ "anyhow", "arboard", + "chrono", "crossterm 0.29.0", + "dirs", "forge_api", "forge_domain", "futures", @@ -1041,6 +1043,7 @@ dependencies = [ "pretty_assertions", "ratatui", "rustls 0.23.40", + "serde_json", "tokio", "unicode-width 0.2.2", ] diff --git a/crates/codegraff-tui/Cargo.toml b/crates/codegraff-tui/Cargo.toml index 060566be..49b671ea 100644 --- a/crates/codegraff-tui/Cargo.toml +++ b/crates/codegraff-tui/Cargo.toml @@ -11,7 +11,9 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true +chrono.workspace = true crossterm = { version = "0.29.0", features = ["event-stream"] } +dirs.workspace = true forge_api.workspace = true forge_domain.workspace = true futures.workspace = true @@ -21,6 +23,7 @@ ratatui = "0.28.1" # ratatui-image 10.x currently targets ratatui 0.30; keep native support deferred # until Codegraff moves to the same ratatui version. rustls.workspace = true +serde_json.workspace = true tokio.workspace = true unicode-width.workspace = true diff --git a/crates/codegraff-tui/src/main.rs b/crates/codegraff-tui/src/main.rs index b4b9b39c..d4474470 100644 --- a/crates/codegraff-tui/src/main.rs +++ b/crates/codegraff-tui/src/main.rs @@ -37,6 +37,8 @@ use forge_api::{ Event, ForgeAPI, ForgeConfig, InputModality, Model, ModelConfig, ProviderId, TokenCount, URLParamSpec, Usage, }; +use forge_domain::{AgentId, Effort}; +use std::str::FromStr; use futures::StreamExt; use nucleo::pattern::{CaseMatching, Normalization, Pattern}; use nucleo::{Config, Matcher, Utf32String}; @@ -76,17 +78,49 @@ const SHORTCUT_HINT_MILLIS: u64 = 2_500; /// /// Excluded for the same reason as the brief: `:exit`, `:edit`, `:retry`. const PALETTE_COMMANDS: &[(&str, &str)] = &[ + ("act", "Switch to the forge agent (implementation mode)"), + ("agent", "List or switch agents (`/agent ` to switch)"), + ("clone", "Clone the current conversation (deferred — see #22)"), + ("commit", "Generate AI commit message and commit changes"), + ("commit-preview", "Preview the AI-generated commit message without committing"), + ("compact", "Compact the conversation context"), + ("config", "Show effective session configuration"), + ("config-commit-model", "Set the commit-message model (``)"), + ("config-edit", "Open the global config file in $EDITOR"), + ("config-model", "Set the session model (``, provider from current session)"), + ("config-reasoning-effort", "Set reasoning effort (``)"), + ("config-reload", "Reload session overrides (deferred — see #22)"), + ("config-suggest-model", "Set the suggest model (``)"), ("connect", "Connect or configure a provider"), + ("conversation", "List conversations for the active workspace"), + ("conversation-rename", "Rename the active conversation (``)"), + ("copy", "Copy the last AI response to clipboard (deferred — see #22)"), + ("dump", "Save conversation as JSON (`--html` for HTML wrapper)"), + ("fast", "Toggle Priority Processing for OpenAI-series requests"), ("help", "Open the command palette to discover available commands"), ("image", "Attach an image from the filesystem (path)"), + ("index", "Index the current workspace for semantic search"), ("info", "Show active agent, model, conversation id and log path"), ("login", "Log in to a provider"), + ("logout", "Log out from the active provider"), ("logs", "Show the path to the codegraff log file"), ("model", "Switch or select the active model"), ("models", "List or pick from registered provider models"), ("new", "Start a new conversation, clearing the transcript"), + ("plan", "Switch to the muse agent (planning mode)"), + ("reasoning-effort", "Set reasoning effort (``)"), + ("rename", "Rename the active conversation (``)"), + ("sage", "Switch to the sage agent (research mode)"), + ("skill", "List all available skills"), + ("suggest", "Generate a shell command from natural-language description"), + ("tools", "List available tools"), + ("update", "Run install script to update graff/codegraff binaries"), ("usage", "Show token usage and request information"), ("workflow", "Open a workflow review dialog for a goal"), + ("workspace-info", "Show workspace info for the current directory"), + ("workspace-init", "Initialize a workspace for the current directory"), + ("workspace-status", "Show sync status of files in the workspace"), + ("workspace-sync", "Sync the current workspace for semantic search"), ]; #[tokio::main] async fn main() -> Result<()> { @@ -1176,6 +1210,242 @@ impl Tui { return Ok(()); } + + // ───── Tier 2: agent switching ───── + if raw_prompt == "/act" { + self.switch_agent(AgentId::FORGE).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/plan" { + self.switch_agent(AgentId::MUSE).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/sage" { + self.switch_agent(AgentId::SAGE).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/agent" || raw_prompt.starts_with("/agent ") { + let arg = raw_prompt.strip_prefix("/agent").unwrap_or("").trim(); + self.handle_agent_command(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + + // ───── Tier 3: conversation management ───── + if raw_prompt == "/conversation" || raw_prompt == "/conversations" { + self.list_conversations().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/rename" || raw_prompt.starts_with("/rename ") { + let arg = raw_prompt.strip_prefix("/rename").unwrap_or("").trim(); + self.rename_active_conversation(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/conversation-rename" || raw_prompt.starts_with("/conversation-rename ") { + // Convenience alias of /rename for symmetry with the REPL. + let arg = raw_prompt + .strip_prefix("/conversation-rename") + .unwrap_or("") + .trim(); + self.rename_active_conversation(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/copy" { + self.copy_last_response().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/dump" || raw_prompt.starts_with("/dump ") { + let arg = raw_prompt.strip_prefix("/dump").unwrap_or("").trim(); + self.dump_conversation(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/compact" { + self.compact_active_conversation().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/clone" { + self.transcript.push(TranscriptEntry::Status( + "/clone is not available in the TUI yet — use graff REPL `:clone`. Tracked in #22.".to_string(), + )); + self.scroll_from_bottom = 0; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + + // ───── Tier 4: config ───── + if raw_prompt == "/config" { + self.show_config().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/config-edit" { + self.open_config_in_editor().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/fast" { + self.toggle_fast_mode().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt.starts_with("/reasoning-effort") { + let arg = raw_prompt + .strip_prefix("/reasoning-effort") + .unwrap_or("") + .trim(); + self.set_reasoning_effort(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt.starts_with("/config-reasoning-effort") { + let arg = raw_prompt + .strip_prefix("/config-reasoning-effort") + .unwrap_or("") + .trim(); + self.set_reasoning_effort(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt.starts_with("/config-model") { + let arg = raw_prompt.strip_prefix("/config-model").unwrap_or("").trim(); + self.set_session_model(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt.starts_with("/config-commit-model") { + let arg = raw_prompt + .strip_prefix("/config-commit-model") + .unwrap_or("") + .trim(); + self.set_commit_model(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt.starts_with("/config-suggest-model") { + let arg = raw_prompt + .strip_prefix("/config-suggest-model") + .unwrap_or("") + .trim(); + self.set_suggest_model(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/config-reload" { + self.transcript.push(TranscriptEntry::Status( + "/config-reload not yet wired into the TUI — restart codegraff to reload session overrides. Tracked in #22.".to_string(), + )); + self.scroll_from_bottom = 0; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + + // ───── Tier 5: workspace + tools ───── + if raw_prompt == "/workspace-sync" || raw_prompt == "/sync" { + self.run_workspace_sync().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/index" { + self.run_workspace_sync().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/workspace-status" || raw_prompt == "/sync-status" { + self.show_workspace_status().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/workspace-info" || raw_prompt == "/sync-info" { + self.show_workspace_info().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/workspace-init" || raw_prompt == "/sync-init" { + self.run_workspace_init().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/skill" || raw_prompt == "/skills" { + self.list_skills().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/tools" { + self.list_tools().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt.starts_with("/suggest") { + let arg = raw_prompt.strip_prefix("/suggest").unwrap_or("").trim(); + self.suggest_command(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + + // ───── Tier 6: git ───── + if raw_prompt == "/commit-preview" { + self.run_commit(true).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/commit" { + self.run_commit(false).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + + // ───── Tier 7: admin ───── + if raw_prompt == "/logout" { + self.logout_active_provider().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/update" { + self.run_self_update().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } match parse_model_command(&raw_prompt) { ModelCommand::List => { self.show_models().await; @@ -1632,6 +1902,639 @@ impl Tui { self.scroll_from_bottom = 0; } + + // ───────────── Tier 2: agent switching ───────────── + + /// Switches the active agent and pushes a status entry. Used by the + /// fixed `/act`, `/plan`, `/sage` shortcuts. + async fn switch_agent(&mut self, agent_id: AgentId) { + let label = agent_id.as_str().to_string(); + if let Err(error) = self.api.set_active_agent(agent_id).await { + self.log_error("set_active_agent failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not switch agent: {error:#}" + ))); + } else { + self.transcript.push(TranscriptEntry::Status(format!( + "Switched to agent: {label}" + ))); + self.refresh_active_model().await; + } + self.scroll_from_bottom = 0; + } + + /// Handles `/agent` (no arg → list available) and `/agent ` → + /// switch by id. Avoids overlay scope by being arg-driven for v1. + async fn handle_agent_command(&mut self, arg: &str) { + if arg.is_empty() { + match self.api.get_agent_infos().await { + Ok(agents) if agents.is_empty() => { + self.transcript.push(TranscriptEntry::Status( + "No agents available.".to_string(), + )); + } + Ok(agents) => { + self.transcript.push(TranscriptEntry::Status( + "Available agents — type `/agent ` to switch:".to_string(), + )); + for info in agents { + let desc = info.description.clone().unwrap_or_default(); + self.transcript.push(TranscriptEntry::Status(format!( + " · {} — {desc}", + info.id.as_str() + ))); + } + } + Err(error) => { + self.log_error("get_agent_infos failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not list agents: {error:#}" + ))); + } + } + } else { + self.switch_agent(AgentId::new(arg)).await; + return; + } + self.scroll_from_bottom = 0; + } + + // ───────────── Tier 3: conversation management ───────────── + + /// Lists conversations for the active workspace as transcript entries. + async fn list_conversations(&mut self) { + match self.api.get_conversations(Some(20)).await { + Ok(conversations) if conversations.is_empty() => { + self.transcript.push(TranscriptEntry::Status( + "No conversations in this workspace yet.".to_string(), + )); + } + Ok(conversations) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Conversations ({}):", + conversations.len() + ))); + for c in conversations { + let title = c.title.clone().unwrap_or_else(|| "(untitled)".to_string()); + let active = if c.id == self.conversation_id { " · active" } else { "" }; + self.transcript.push(TranscriptEntry::Status(format!( + " · {} — {title}{active}", + c.id + ))); + } + } + Err(error) => { + self.log_error("get_conversations failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not list conversations: {error:#}" + ))); + } + } + self.scroll_from_bottom = 0; + } + + /// Renames the active conversation. Empty arg prints a usage hint. + async fn rename_active_conversation(&mut self, arg: &str) { + if arg.is_empty() { + self.transcript.push(TranscriptEntry::Status( + "Usage: /rename ".to_string(), + )); + } else if let Err(error) = self + .api + .rename_conversation(&self.conversation_id, arg.to_string()) + .await + { + self.log_error("rename_conversation failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not rename conversation: {error:#}" + ))); + } else { + self.transcript.push(TranscriptEntry::Status(format!( + "Conversation renamed to: {arg}" + ))); + } + self.scroll_from_bottom = 0; + } + + /// Compacts the active conversation, replacing its context with a + /// summary. Prints original/compacted token counts when available. + async fn compact_active_conversation(&mut self) { + self.transcript.push(TranscriptEntry::Status( + "Compacting conversation context…".to_string(), + )); + self.scroll_from_bottom = 0; + match self.api.compact_conversation(&self.conversation_id).await { + Ok(result) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Compacted: {result:?}" + ))); + } + Err(error) => { + self.log_error("compact_conversation failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not compact conversation: {error:#}" + ))); + } + } + self.scroll_from_bottom = 0; + } + + /// Dumps the active conversation as JSON (default) or HTML when arg is + /// `--html`. Writes under `~/forge/dumps/-.`. + async fn dump_conversation(&mut self, arg: &str) { + let want_html = arg == "--html"; + let conversation = match self.api.conversation(&self.conversation_id).await { + Ok(Some(c)) => c, + Ok(None) => { + self.transcript.push(TranscriptEntry::Error( + "No active conversation to dump.".to_string(), + )); + self.scroll_from_bottom = 0; + return; + } + Err(error) => { + self.log_error("conversation fetch failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not fetch conversation: {error:#}" + ))); + self.scroll_from_bottom = 0; + return; + } + }; + let dir: std::path::PathBuf = dirs::home_dir() + .map(|h| h.join("forge").join("dumps")) + .unwrap_or_else(std::env::temp_dir); + if let Err(error) = std::fs::create_dir_all(&dir) { + let msg = error.to_string(); + self.log_error("create dumps dir failed", &anyhow::anyhow!("{msg}")); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not create dump directory {}: {msg}", + dir.display() + ))); + self.scroll_from_bottom = 0; + return; + } + let ext = if want_html { "html" } else { "json" }; + let ts = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let path = dir.join(format!("{}-{ts}.{ext}", self.conversation_id)); + let content = if want_html { + // No HTML serializer wired yet — emit JSON inside a
 block
+            // so users get something open-able in a browser.
+            let body = serde_json::to_string_pretty(&conversation)
+                .unwrap_or_else(|_| "(serialize failed)".to_string());
+            format!("
{body}
") + } else { + serde_json::to_string_pretty(&conversation) + .unwrap_or_else(|_| "(serialize failed)".to_string()) + }; + if let Err(error) = std::fs::write(&path, content) { + let msg = error.to_string(); + self.log_error("write dump failed", &anyhow::anyhow!("{msg}")); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not write dump: {msg}" + ))); + } else { + self.transcript.push(TranscriptEntry::Status(format!( + "Dumped conversation to {}", + path.display() + ))); + } + self.scroll_from_bottom = 0; + } + + /// Copies the last assistant text response to the system clipboard via + /// `arboard`. Walks the conversation context to find the most recent + /// assistant message. + async fn copy_last_response(&mut self) { + // Without a stable Context message-walker available here, defer with a + // helpful status. Tracked as part of #22. + self.transcript.push(TranscriptEntry::Status( + "/copy is not yet wired into the TUI — use the terminal's selection or `:copy` in graff REPL. Tracked in #22.".to_string(), + )); + self.scroll_from_bottom = 0; + } + + // ───────────── Tier 4: config ───────────── + + /// Prints the effective session config as a series of status entries. + async fn show_config(&mut self) { + self.transcript + .push(TranscriptEntry::Status("─ session config ─".to_string())); + match self.api.get_session_config().await { + Some(cfg) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Provider: {}", cfg.provider + ))); + self.transcript.push(TranscriptEntry::Status(format!( + "Model: {}", cfg.model + ))); + } + None => { + self.transcript + .push(TranscriptEntry::Status("No session config set yet — use /login then /models.".to_string())); + } + } + if let Ok(Some(effort)) = self.api.get_reasoning_effort().await { + self.transcript.push(TranscriptEntry::Status(format!( + "Reasoning effort: {effort:?}" + ))); + } + if let Ok(Some(fast)) = self.api.get_fast_mode().await { + self.transcript.push(TranscriptEntry::Status(format!( + "Fast mode: {}", + if fast { "ON" } else { "OFF" } + ))); + } + self.scroll_from_bottom = 0; + } + + /// Opens `~/forge/.forge.toml` in `$EDITOR` (or `$FORGE_EDITOR`). + async fn open_config_in_editor(&mut self) { + let path: std::path::PathBuf = match dirs::home_dir() { + Some(h) => h.join("forge").join(".forge.toml"), + None => { + self.transcript.push(TranscriptEntry::Error( + "Could not resolve home directory.".to_string(), + )); + self.scroll_from_bottom = 0; + return; + } + }; + let editor = std::env::var("FORGE_EDITOR") + .or_else(|_| std::env::var("EDITOR")) + .unwrap_or_else(|_| "vi".to_string()); + let cmd = format!("{editor} {}", path.display()); + if let Err(error) = self.api.execute_shell_command_raw(&cmd).await { + self.log_error("open editor failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not open editor: {error:#}" + ))); + } else { + self.transcript.push(TranscriptEntry::Status(format!( + "Edited {} (run /config-reload to pick up session changes — currently restart required).", + path.display() + ))); + } + self.scroll_from_bottom = 0; + } + + /// Toggles fast mode (Priority Processing) and prints the new state. + async fn toggle_fast_mode(&mut self) { + let current = self.api.get_fast_mode().await.ok().flatten().unwrap_or(false); + let next = !current; + if let Err(error) = self + .api + .update_config(vec![ConfigOperation::SetFastMode(next)]) + .await + { + self.log_error("toggle fast mode failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not toggle fast mode: {error:#}" + ))); + } else { + self.transcript.push(TranscriptEntry::Status(format!( + "Fast mode: {}", + if next { "ON" } else { "OFF" } + ))); + } + self.scroll_from_bottom = 0; + } + + /// Sets reasoning effort. Empty arg prints a usage hint. + async fn set_reasoning_effort(&mut self, arg: &str) { + if arg.is_empty() { + self.transcript.push(TranscriptEntry::Status( + "Usage: /reasoning-effort " + .to_string(), + )); + self.scroll_from_bottom = 0; + return; + } + let effort = match Effort::from_str(arg) { + Ok(e) => e, + Err(_) => { + self.transcript.push(TranscriptEntry::Error(format!( + "Invalid effort '{arg}' — try one of none|minimal|low|medium|high|xhigh|max." + ))); + self.scroll_from_bottom = 0; + return; + } + }; + if let Err(error) = self + .api + .update_config(vec![ConfigOperation::SetReasoningEffort(effort.clone())]) + .await + { + self.log_error("set reasoning effort failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not set reasoning effort: {error:#}" + ))); + } else { + self.transcript.push(TranscriptEntry::Status(format!( + "Reasoning effort: {effort:?}" + ))); + } + self.scroll_from_bottom = 0; + } + + /// Sets the session model by name. Resolves provider from current + /// session config — if no session is set, prompts user to /login first. + async fn set_session_model(&mut self, arg: &str) { + self.set_model_for_target(arg, "session").await; + } + async fn set_commit_model(&mut self, arg: &str) { + self.set_model_for_target(arg, "commit").await; + } + async fn set_suggest_model(&mut self, arg: &str) { + self.set_model_for_target(arg, "suggest").await; + } + + /// Shared implementation of model-setter commands. `target` is one of + /// "session" | "commit" | "suggest". + async fn set_model_for_target(&mut self, arg: &str, target: &str) { + if arg.is_empty() { + self.transcript.push(TranscriptEntry::Status(format!( + "Usage: /config-{}-model ", + if target == "session" { "" } else { target } + ))); + self.scroll_from_bottom = 0; + return; + } + let provider_id = match self.api.get_session_config().await { + Some(cfg) => cfg.provider, + None => { + self.transcript.push(TranscriptEntry::Error( + "No session provider set — run /login first.".to_string(), + )); + self.scroll_from_bottom = 0; + return; + } + }; + let model_id = forge_api::ModelId::new(arg); + let new_cfg = ModelConfig::new(provider_id.clone(), model_id); + let op = match target { + "session" => ConfigOperation::SetSessionConfig(new_cfg), + "commit" => ConfigOperation::SetCommitConfig(Some(new_cfg)), + "suggest" => ConfigOperation::SetSuggestConfig(new_cfg), + _ => unreachable!(), + }; + if let Err(error) = self.api.update_config(vec![op]).await { + self.log_error("update_config model setter failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not set {target} model: {error:#}" + ))); + } else { + self.transcript.push(TranscriptEntry::Status(format!( + "{target} model set to {arg} (provider {provider_id})." + ))); + if target == "session" { + self.refresh_active_model().await; + } + } + self.scroll_from_bottom = 0; + } + + // ───────────── Tier 5: workspace + tools ───────────── + + /// Runs a workspace sync against the current working directory. Reads + /// the resulting progress stream and prints a brief completion status. + async fn run_workspace_sync(&mut self) { + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + self.transcript.push(TranscriptEntry::Status(format!( + "Syncing workspace at {}…", + cwd.display() + ))); + self.scroll_from_bottom = 0; + let mut stream = match self.api.sync_workspace(cwd.clone()).await { + Ok(s) => s, + Err(error) => { + self.log_error("sync_workspace failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not start sync: {error:#}" + ))); + self.scroll_from_bottom = 0; + return; + } + }; + let mut count = 0usize; + while let Some(progress) = stream.next().await { + match progress { + Ok(_) => count += 1, + Err(error) => { + self.log_error("sync_workspace progress error", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Sync error: {error:#}" + ))); + self.scroll_from_bottom = 0; + return; + } + } + } + self.transcript.push(TranscriptEntry::Status(format!( + "Workspace sync complete ({count} progress events)." + ))); + self.scroll_from_bottom = 0; + } + + async fn show_workspace_status(&mut self) { + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + match self.api.get_workspace_status(cwd).await { + Ok(files) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Workspace status: {} files", + files.len() + ))); + } + Err(error) => { + self.log_error("get_workspace_status failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not get workspace status: {error:#}" + ))); + } + } + self.scroll_from_bottom = 0; + } + + async fn show_workspace_info(&mut self) { + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + match self.api.get_workspace_info(cwd).await { + Ok(Some(info)) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Workspace: {info:?}" + ))); + } + Ok(None) => { + self.transcript.push(TranscriptEntry::Status( + "No workspace registered for this directory — run /workspace-init.".to_string(), + )); + } + Err(error) => { + self.log_error("get_workspace_info failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not get workspace info: {error:#}" + ))); + } + } + self.scroll_from_bottom = 0; + } + + async fn run_workspace_init(&mut self) { + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + match self.api.init_workspace(cwd.clone()).await { + Ok(id) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Initialized workspace {id:?} at {}", + cwd.display() + ))); + } + Err(error) => { + self.log_error("init_workspace failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not initialize workspace: {error:#}" + ))); + } + } + self.scroll_from_bottom = 0; + } + + async fn list_skills(&mut self) { + match self.api.get_skills().await { + Ok(skills) if skills.is_empty() => { + self.transcript.push(TranscriptEntry::Status( + "No skills registered.".to_string(), + )); + } + Ok(skills) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Skills ({}):", + skills.len() + ))); + for skill in skills { + self.transcript.push(TranscriptEntry::Status(format!( + " · {skill:?}" + ))); + } + } + Err(error) => { + self.log_error("get_skills failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not list skills: {error:#}" + ))); + } + } + self.scroll_from_bottom = 0; + } + + async fn list_tools(&mut self) { + match self.api.get_tools().await { + Ok(tools) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Tools: {tools:?}" + ))); + } + Err(error) => { + self.log_error("get_tools failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not list tools: {error:#}" + ))); + } + } + self.scroll_from_bottom = 0; + } + + async fn suggest_command(&mut self, arg: &str) { + if arg.is_empty() { + self.transcript.push(TranscriptEntry::Status( + "Usage: /suggest ".to_string(), + )); + self.scroll_from_bottom = 0; + return; + } + match self.api.generate_command(forge_api::UserPrompt::from(arg.to_string())).await { + Ok(cmd) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Suggested: {cmd}" + ))); + } + Err(error) => { + self.log_error("generate_command failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not generate suggestion: {error:#}" + ))); + } + } + self.scroll_from_bottom = 0; + } + + // ───────────── Tier 6: git ───────────── + + async fn run_commit(&mut self, preview: bool) { + let label = if preview { "Generating preview…" } else { "Generating commit…" }; + self.transcript.push(TranscriptEntry::Status(label.to_string())); + self.scroll_from_bottom = 0; + match self.api.commit(preview, None, None, None).await { + Ok(result) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Commit result: {result:?}" + ))); + } + Err(error) => { + self.log_error("commit failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not commit: {error:#}" + ))); + } + } + self.scroll_from_bottom = 0; + } + + // ───────────── Tier 7: admin ───────────── + + async fn logout_active_provider(&mut self) { + let provider = match self.api.get_default_provider().await { + Ok(p) => p, + Err(error) => { + self.log_error("get_default_provider failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "No active provider to logout from: {error:#}" + ))); + self.scroll_from_bottom = 0; + return; + } + }; + if let Err(error) = self.api.remove_provider(&provider.id).await { + self.log_error("remove_provider failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not logout from {}: {error:#}", + provider.id + ))); + } else { + self.transcript.push(TranscriptEntry::Status(format!( + "Logged out from provider: {}", + provider.id + ))); + } + self.scroll_from_bottom = 0; + } + + async fn run_self_update(&mut self) { + self.transcript.push(TranscriptEntry::Status( + "Running install script — codegraff/graff binaries will be replaced with the latest GitHub release.".to_string(), + )); + self.scroll_from_bottom = 0; + let cmd = "curl -fsSL https://github.com/justrach/codegraff/releases/latest/download/install.sh | sh"; + if let Err(error) = self.api.execute_shell_command_raw(cmd).await { + self.log_error("self update failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Update failed: {error:#}" + ))); + } else { + self.transcript.push(TranscriptEntry::Status( + "Update complete — restart codegraff to use the new binary.".to_string(), + )); + } + self.scroll_from_bottom = 0; + } async fn show_models(&mut self) { match self.model_options().await { Ok(models) if models.is_empty() => { @@ -5391,7 +6294,15 @@ mod tests { // locally. If you add a command here, also wire it in // `handle_enter`, otherwise selecting it from the palette will // silently dispatch to the LLM as a chat message. - let must_be_present = ["usage", "model", "models", "login", "connect", "workflow", "logs", "image", "new", "info", "help"]; + let must_be_present = [ + "act", "agent", "clone", "commit", "commit-preview", "compact", "config", + "config-commit-model", "config-edit", "config-model", "config-reasoning-effort", + "config-reload", "config-suggest-model", "connect", "conversation", + "conversation-rename", "copy", "dump", "fast", "help", "image", "index", "info", + "login", "logout", "logs", "model", "models", "new", "plan", "reasoning-effort", + "rename", "sage", "skill", "suggest", "tools", "update", "usage", "workflow", + "workspace-info", "workspace-init", "workspace-status", "workspace-sync", + ]; for name in &must_be_present { assert!( PALETTE_COMMANDS.iter().any(|(n, _)| n == name),