diff --git a/rust/TUI-ENHANCEMENT-PLAN.md b/rust/TUI-ENHANCEMENT-PLAN.md index d2a0657..a9de6c3 100644 --- a/rust/TUI-ENHANCEMENT-PLAN.md +++ b/rust/TUI-ENHANCEMENT-PLAN.md @@ -20,12 +20,14 @@ This plan covers a comprehensive analysis of the current terminal user interface ### Current TUI Components +> Note: The legacy prototype files `app.rs` and `args.rs` were removed on 2026-04-05. +> References below describe future extraction targets, not current tracked source files. + | Component | File | What It Does Today | Quality | |---|---|---|---| | **Input** | `input.rs` (269 lines) | `rustyline`-based line editor with slash-command tab completion, Shift+Enter newline, history | ✅ Solid | | **Rendering** | `render.rs` (641 lines) | Markdown→terminal rendering (headings, lists, tables, code blocks with syntect highlighting, blockquotes), spinner widget | ✅ Good | | **App/REPL loop** | `main.rs` (3,159 lines) | The monolithic `LiveCli` struct: REPL loop, all slash command handlers, streaming output, tool call display, permission prompting, session management | ⚠️ Monolithic | -| **Alt App** | `app.rs` (398 lines) | An earlier `CliApp` prototype with `ConversationClient`, stream event handling, `TerminalRenderer`, output format support | ⚠️ Appears unused/legacy | ### Key Dependencies @@ -56,7 +58,7 @@ This plan covers a comprehensive analysis of the current terminal user interface 8. **Streaming is char-by-char with artificial delay** — `stream_markdown` sleeps 8ms per whitespace-delimited chunk 9. **No color theme customization** — hardcoded `ColorTheme::default()` 10. **No resize handling** — no terminal size awareness for wrapping, truncation, or layout -11. **Dual app structs** — `app.rs` has a separate `CliApp` that duplicates `LiveCli` from `main.rs` +11. **Historical dual app split** — the repo previously carried a separate `CliApp` prototype alongside `LiveCli`; the prototype is gone, but the monolithic `main.rs` still needs extraction 12. **No pager for long outputs** — `/status`, `/config`, `/memory` can overflow the viewport 13. **Tool results not collapsible** — large bash outputs flood the screen 14. **No thinking/reasoning indicator** — when the model is in "thinking" mode, no visual distinction @@ -73,8 +75,8 @@ This plan covers a comprehensive analysis of the current terminal user interface | Task | Description | Effort | |---|---|---| | 0.1 | **Extract `LiveCli` into `app.rs`** — Move the entire `LiveCli` struct, its impl, and helpers (`format_*`, `render_*`, session management) out of `main.rs` into focused modules: `app.rs` (core), `format.rs` (report formatting), `session_manager.rs` (session CRUD) | M | -| 0.2 | **Remove or merge the legacy `CliApp`** — The existing `app.rs` has an unused `CliApp` with its own `ConversationClient`-based rendering. Either delete it or merge its unique features (stream event handler pattern) into the active `LiveCli` | S | -| 0.3 | **Extract `main.rs` arg parsing** — The current `parse_args()` is a hand-rolled parser that duplicates the clap-based `args.rs`. Consolidate on the hand-rolled parser (it's more feature-complete) and move it to `args.rs`, or adopt clap fully | S | +| 0.2 | **Keep the legacy `CliApp` removed** — The old `CliApp` prototype has already been deleted; if any unique ideas remain valuable (for example stream event handler patterns), reintroduce them intentionally inside the active `LiveCli` extraction rather than restoring the old file wholesale | S | +| 0.3 | **Extract `main.rs` arg parsing** — The current `parse_args()` is still a hand-rolled parser in `main.rs`. If parsing is extracted later, do it into a newly-introduced module intentionally rather than reviving the removed prototype `args.rs` by accident | S | | 0.4 | **Create a `tui/` module** — Introduce `crates/rusty-claude-cli/src/tui/mod.rs` as the namespace for all new TUI components: `status_bar.rs`, `layout.rs`, `tool_panel.rs`, etc. | S | ### Phase 1: Status Bar & Live HUD @@ -214,7 +216,7 @@ crates/rusty-claude-cli/src/ | Terminal compatibility issues (tmux, SSH, Windows) | Rely on crossterm's abstraction; test in degraded environments | | Performance regression with rich rendering | Profile before/after; keep the fast path (raw streaming) always available | | Scope creep into Phase 6 | Ship Phases 0–3 as a coherent release before starting Phase 6 | -| `app.rs` vs `main.rs` confusion | Phase 0.2 explicitly resolves this by removing the legacy `CliApp` | +| Historical `app.rs` vs `main.rs` confusion | Keep the legacy prototype removed and avoid reintroducing a second app surface accidentally during extraction | --- diff --git a/rust/crates/rusty-claude-cli/src/app.rs b/rust/crates/rusty-claude-cli/src/app.rs deleted file mode 100644 index 8a97c05..0000000 --- a/rust/crates/rusty-claude-cli/src/app.rs +++ /dev/null @@ -1,567 +0,0 @@ -use std::io::{self, Write}; -use std::path::PathBuf; - -use crate::args::{OutputFormat, PermissionMode}; -use crate::input::{LineEditor, ReadOutcome}; -use crate::render::{Spinner, TerminalRenderer}; -use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SessionConfig { - pub model: String, - pub permission_mode: PermissionMode, - pub config: Option, - pub output_format: OutputFormat, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SessionState { - pub turns: usize, - pub compacted_messages: usize, - pub last_model: String, - pub last_usage: UsageSummary, -} - -impl SessionState { - #[must_use] - pub fn new(model: impl Into) -> Self { - Self { - turns: 0, - compacted_messages: 0, - last_model: model.into(), - last_usage: UsageSummary::default(), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CommandResult { - Continue, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SlashCommand { - Help, - Status, - Compact, - Model { model: Option }, - Permissions { mode: Option }, - Config { section: Option }, - Memory, - Clear { confirm: bool }, - Unknown(String), -} - -impl SlashCommand { - #[must_use] - pub fn parse(input: &str) -> Option { - let trimmed = input.trim(); - if !trimmed.starts_with('/') { - return None; - } - - let mut parts = trimmed.trim_start_matches('/').split_whitespace(); - let command = parts.next().unwrap_or_default(); - Some(match command { - "help" => Self::Help, - "status" => Self::Status, - "compact" => Self::Compact, - "model" => Self::Model { - model: parts.next().map(ToOwned::to_owned), - }, - "permissions" => Self::Permissions { - mode: parts.next().map(ToOwned::to_owned), - }, - "config" => Self::Config { - section: parts.next().map(ToOwned::to_owned), - }, - "memory" => Self::Memory, - "clear" => Self::Clear { - confirm: parts.next() == Some("--confirm"), - }, - other => Self::Unknown(other.to_string()), - }) - } -} - -struct SlashCommandHandler { - command: SlashCommand, - summary: &'static str, -} - -const SLASH_COMMAND_HANDLERS: &[SlashCommandHandler] = &[ - SlashCommandHandler { - command: SlashCommand::Help, - summary: "Show command help", - }, - SlashCommandHandler { - command: SlashCommand::Status, - summary: "Show current session status", - }, - SlashCommandHandler { - command: SlashCommand::Compact, - summary: "Compact local session history", - }, - SlashCommandHandler { - command: SlashCommand::Model { model: None }, - summary: "Show or switch the active model", - }, - SlashCommandHandler { - command: SlashCommand::Permissions { mode: None }, - summary: "Show or switch the active permission mode", - }, - SlashCommandHandler { - command: SlashCommand::Config { section: None }, - summary: "Inspect current config path or section", - }, - SlashCommandHandler { - command: SlashCommand::Memory, - summary: "Inspect loaded memory/instruction files", - }, - SlashCommandHandler { - command: SlashCommand::Clear { confirm: false }, - summary: "Start a fresh local session", - }, -]; - -pub struct CliApp { - config: SessionConfig, - renderer: TerminalRenderer, - state: SessionState, - conversation_client: ConversationClient, - conversation_history: Vec, -} - -impl CliApp { - pub fn new(config: SessionConfig) -> Result { - let state = SessionState::new(config.model.clone()); - let conversation_client = ConversationClient::from_env(config.model.clone())?; - Ok(Self { - config, - renderer: TerminalRenderer::new(), - state, - conversation_client, - conversation_history: Vec::new(), - }) - } - - pub fn run_repl(&mut self) -> io::Result<()> { - let mut editor = LineEditor::new("› ", Vec::new()); - println!("Rusty Claude CLI interactive mode"); - println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline."); - - loop { - match editor.read_line()? { - ReadOutcome::Submit(input) => { - if input.trim().is_empty() { - continue; - } - self.handle_submission(&input, &mut io::stdout())?; - } - ReadOutcome::Cancel => continue, - ReadOutcome::Exit => break, - } - } - - Ok(()) - } - - pub fn run_prompt(&mut self, prompt: &str, out: &mut impl Write) -> io::Result<()> { - self.render_response(prompt, out) - } - - pub fn handle_submission( - &mut self, - input: &str, - out: &mut impl Write, - ) -> io::Result { - if let Some(command) = SlashCommand::parse(input) { - return self.dispatch_slash_command(command, out); - } - - self.state.turns += 1; - self.render_response(input, out)?; - Ok(CommandResult::Continue) - } - - fn dispatch_slash_command( - &mut self, - command: SlashCommand, - out: &mut impl Write, - ) -> io::Result { - match command { - SlashCommand::Help => Self::handle_help(out), - SlashCommand::Status => self.handle_status(out), - SlashCommand::Compact => self.handle_compact(out), - SlashCommand::Model { model } => self.handle_model(model.as_deref(), out), - SlashCommand::Permissions { mode } => self.handle_permissions(mode.as_deref(), out), - SlashCommand::Config { section } => self.handle_config(section.as_deref(), out), - SlashCommand::Memory => self.handle_memory(out), - SlashCommand::Clear { confirm } => self.handle_clear(confirm, out), - SlashCommand::Unknown(name) => { - writeln!(out, "Unknown slash command: /{name}")?; - Ok(CommandResult::Continue) - } - } - } - - fn handle_help(out: &mut impl Write) -> io::Result { - writeln!(out, "Available commands:")?; - for handler in SLASH_COMMAND_HANDLERS { - let name = match handler.command { - SlashCommand::Help => "/help", - SlashCommand::Status => "/status", - SlashCommand::Compact => "/compact", - SlashCommand::Model { .. } => "/model [model]", - SlashCommand::Permissions { .. } => "/permissions [mode]", - SlashCommand::Config { .. } => "/config [section]", - SlashCommand::Memory => "/memory", - SlashCommand::Clear { .. } => "/clear [--confirm]", - SlashCommand::Unknown(_) => continue, - }; - writeln!(out, " {name:<9} {}", handler.summary)?; - } - Ok(CommandResult::Continue) - } - - fn handle_status(&mut self, out: &mut impl Write) -> io::Result { - writeln!( - out, - "status: turns={} model={} permission-mode={:?} output-format={:?} last-usage={} in/{} out config={}", - self.state.turns, - self.state.last_model, - self.config.permission_mode, - self.config.output_format, - self.state.last_usage.input_tokens, - self.state.last_usage.output_tokens, - self.config - .config - .as_ref() - .map_or_else(|| String::from(""), |path| path.display().to_string()) - )?; - Ok(CommandResult::Continue) - } - - fn handle_compact(&mut self, out: &mut impl Write) -> io::Result { - self.state.compacted_messages += self.state.turns; - self.state.turns = 0; - self.conversation_history.clear(); - writeln!( - out, - "Compacted session history into a local summary ({} messages total compacted).", - self.state.compacted_messages - )?; - Ok(CommandResult::Continue) - } - - fn handle_model( - &mut self, - model: Option<&str>, - out: &mut impl Write, - ) -> io::Result { - match model { - Some(model) => { - self.config.model = model.to_string(); - self.state.last_model = model.to_string(); - writeln!(out, "Active model set to {model}")?; - } - None => { - writeln!(out, "Active model: {}", self.config.model)?; - } - } - Ok(CommandResult::Continue) - } - - fn handle_permissions( - &mut self, - mode: Option<&str>, - out: &mut impl Write, - ) -> io::Result { - match mode { - None => writeln!(out, "Permission mode: {:?}", self.config.permission_mode)?, - Some("read-only") => { - self.config.permission_mode = PermissionMode::ReadOnly; - writeln!(out, "Permission mode set to read-only")?; - } - Some("workspace-write") => { - self.config.permission_mode = PermissionMode::WorkspaceWrite; - writeln!(out, "Permission mode set to workspace-write")?; - } - Some("danger-full-access") => { - self.config.permission_mode = PermissionMode::DangerFullAccess; - writeln!(out, "Permission mode set to danger-full-access")?; - } - Some(other) => { - writeln!(out, "Unknown permission mode: {other}")?; - } - } - Ok(CommandResult::Continue) - } - - fn handle_config( - &mut self, - section: Option<&str>, - out: &mut impl Write, - ) -> io::Result { - match section { - None => writeln!( - out, - "Config path: {}", - self.config - .config - .as_ref() - .map_or_else(|| String::from(""), |path| path.display().to_string()) - )?, - Some(section) => writeln!( - out, - "Config section `{section}` is not fully implemented yet; current config path is {}", - self.config - .config - .as_ref() - .map_or_else(|| String::from(""), |path| path.display().to_string()) - )?, - } - Ok(CommandResult::Continue) - } - - fn handle_memory(&mut self, out: &mut impl Write) -> io::Result { - writeln!( - out, - "Loaded memory/config file: {}", - self.config - .config - .as_ref() - .map_or_else(|| String::from(""), |path| path.display().to_string()) - )?; - Ok(CommandResult::Continue) - } - - fn handle_clear(&mut self, confirm: bool, out: &mut impl Write) -> io::Result { - if !confirm { - writeln!(out, "Refusing to clear without confirmation. Re-run as /clear --confirm")?; - return Ok(CommandResult::Continue); - } - - self.state.turns = 0; - self.state.compacted_messages = 0; - self.state.last_usage = UsageSummary::default(); - self.conversation_history.clear(); - writeln!(out, "Started a fresh local session.")?; - Ok(CommandResult::Continue) - } - - fn handle_stream_event( - renderer: &TerminalRenderer, - event: StreamEvent, - stream_spinner: &mut Spinner, - tool_spinner: &mut Spinner, - saw_text: &mut bool, - turn_usage: &mut UsageSummary, - out: &mut impl Write, - ) { - match event { - StreamEvent::TextDelta(delta) => { - if !*saw_text { - let _ = - stream_spinner.finish("Streaming response", renderer.color_theme(), out); - *saw_text = true; - } - let _ = write!(out, "{delta}"); - let _ = out.flush(); - } - StreamEvent::ToolCallStart { name, input } => { - if *saw_text { - let _ = writeln!(out); - } - let _ = tool_spinner.tick( - &format!("Running tool `{name}` with {input}"), - renderer.color_theme(), - out, - ); - } - StreamEvent::ToolCallResult { - name, - output, - is_error, - } => { - let label = if is_error { - format!("Tool `{name}` failed") - } else { - format!("Tool `{name}` completed") - }; - let _ = tool_spinner.finish(&label, renderer.color_theme(), out); - let rendered_output = format!("### Tool `{name}`\n\n```text\n{output}\n```\n"); - let _ = renderer.stream_markdown(&rendered_output, out); - } - StreamEvent::Usage(usage) => { - *turn_usage = usage; - } - } - } - - fn write_turn_output( - &self, - summary: &runtime::TurnSummary, - out: &mut impl Write, - ) -> io::Result<()> { - match self.config.output_format { - OutputFormat::Text => { - writeln!( - out, - "\nToken usage: {} input / {} output", - self.state.last_usage.input_tokens, self.state.last_usage.output_tokens - )?; - } - OutputFormat::Json => { - writeln!( - out, - "{}", - serde_json::json!({ - "message": summary.assistant_text, - "usage": { - "input_tokens": self.state.last_usage.input_tokens, - "output_tokens": self.state.last_usage.output_tokens, - } - }) - )?; - } - OutputFormat::Ndjson => { - writeln!( - out, - "{}", - serde_json::json!({ - "type": "message", - "text": summary.assistant_text, - "usage": { - "input_tokens": self.state.last_usage.input_tokens, - "output_tokens": self.state.last_usage.output_tokens, - } - }) - )?; - } - } - Ok(()) - } - - fn render_response(&mut self, input: &str, out: &mut impl Write) -> io::Result<()> { - let mut stream_spinner = Spinner::new(); - stream_spinner.tick( - "Opening conversation stream", - self.renderer.color_theme(), - out, - )?; - - let mut turn_usage = UsageSummary::default(); - let mut tool_spinner = Spinner::new(); - let mut saw_text = false; - let renderer = &self.renderer; - - let result = - self.conversation_client - .run_turn(&mut self.conversation_history, input, |event| { - Self::handle_stream_event( - renderer, - event, - &mut stream_spinner, - &mut tool_spinner, - &mut saw_text, - &mut turn_usage, - out, - ); - }); - - let summary = match result { - Ok(summary) => summary, - Err(error) => { - stream_spinner.fail( - "Streaming response failed", - self.renderer.color_theme(), - out, - )?; - return Err(io::Error::other(error)); - } - }; - self.state.last_usage = summary.usage.clone(); - if saw_text { - writeln!(out)?; - } else { - stream_spinner.finish("Streaming response", self.renderer.color_theme(), out)?; - } - - self.write_turn_output(&summary, out)?; - let _ = turn_usage; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use crate::args::{OutputFormat, PermissionMode}; - - use super::{CommandResult, SessionConfig, SlashCommand}; - - #[test] - fn parses_required_slash_commands() { - assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help)); - assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status)); - assert_eq!( - SlashCommand::parse("/compact now"), - Some(SlashCommand::Compact) - ); - assert_eq!( - SlashCommand::parse("/model claude-sonnet"), - Some(SlashCommand::Model { - model: Some("claude-sonnet".into()), - }) - ); - assert_eq!( - SlashCommand::parse("/permissions workspace-write"), - Some(SlashCommand::Permissions { - mode: Some("workspace-write".into()), - }) - ); - assert_eq!( - SlashCommand::parse("/config hooks"), - Some(SlashCommand::Config { - section: Some("hooks".into()), - }) - ); - assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); - assert_eq!( - SlashCommand::parse("/clear --confirm"), - Some(SlashCommand::Clear { confirm: true }) - ); - } - - #[test] - fn help_output_lists_commands() { - let mut out = Vec::new(); - let result = super::CliApp::handle_help(&mut out).expect("help succeeds"); - assert_eq!(result, CommandResult::Continue); - let output = String::from_utf8_lossy(&out); - assert!(output.contains("/help")); - assert!(output.contains("/status")); - assert!(output.contains("/compact")); - assert!(output.contains("/model [model]")); - assert!(output.contains("/permissions [mode]")); - assert!(output.contains("/config [section]")); - assert!(output.contains("/memory")); - assert!(output.contains("/clear [--confirm]")); - } - - #[test] - fn session_state_tracks_config_values() { - let config = SessionConfig { - model: "claude".into(), - permission_mode: PermissionMode::DangerFullAccess, - config: Some(PathBuf::from("settings.toml")), - output_format: OutputFormat::Text, - }; - - assert_eq!(config.model, "claude"); - assert_eq!(config.permission_mode, PermissionMode::DangerFullAccess); - assert_eq!(config.config, Some(PathBuf::from("settings.toml"))); - } -} diff --git a/rust/crates/rusty-claude-cli/src/args.rs b/rust/crates/rusty-claude-cli/src/args.rs deleted file mode 100644 index e36934a..0000000 --- a/rust/crates/rusty-claude-cli/src/args.rs +++ /dev/null @@ -1,108 +0,0 @@ -use std::path::PathBuf; - -use clap::{Parser, Subcommand, ValueEnum}; - -#[derive(Debug, Clone, Parser, PartialEq, Eq)] -#[command( - name = "rusty-claude-cli", - version, - about = "Rust Claude CLI prototype" -)] -pub struct Cli { - #[arg(long, default_value = "claude-opus-4-6")] - pub model: String, - - #[arg(long, value_enum, default_value_t = PermissionMode::DangerFullAccess)] - pub permission_mode: PermissionMode, - - #[arg(long)] - pub config: Option, - - #[arg(long, value_enum, default_value_t = OutputFormat::Text)] - pub output_format: OutputFormat, - - #[command(subcommand)] - pub command: Option, -} - -#[derive(Debug, Clone, Subcommand, PartialEq, Eq)] -pub enum Command { - /// Read upstream TS sources and print extracted counts - DumpManifests, - /// Print the current bootstrap phase skeleton - BootstrapPlan, - /// Start the OAuth login flow - Login, - /// Clear saved OAuth credentials - Logout, - /// Run a non-interactive prompt and exit - Prompt { prompt: Vec }, -} - -#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] -pub enum PermissionMode { - ReadOnly, - WorkspaceWrite, - DangerFullAccess, -} - -#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] -pub enum OutputFormat { - Text, - Json, - Ndjson, -} - -#[cfg(test)] -mod tests { - use clap::Parser; - - use super::{Cli, Command, OutputFormat, PermissionMode}; - - #[test] - fn parses_requested_flags() { - let cli = Cli::parse_from([ - "rusty-claude-cli", - "--model", - "claude-3-5-haiku", - "--permission-mode", - "read-only", - "--config", - "/tmp/config.toml", - "--output-format", - "ndjson", - "prompt", - "hello", - "world", - ]); - - assert_eq!(cli.model, "claude-3-5-haiku"); - assert_eq!(cli.permission_mode, PermissionMode::ReadOnly); - assert_eq!( - cli.config.as_deref(), - Some(std::path::Path::new("/tmp/config.toml")) - ); - assert_eq!(cli.output_format, OutputFormat::Ndjson); - assert_eq!( - cli.command, - Some(Command::Prompt { - prompt: vec!["hello".into(), "world".into()] - }) - ); - } - - #[test] - fn parses_login_and_logout_commands() { - let login = Cli::parse_from(["rusty-claude-cli", "login"]); - assert_eq!(login.command, Some(Command::Login)); - - let logout = Cli::parse_from(["rusty-claude-cli", "logout"]); - assert_eq!(logout.command, Some(Command::Logout)); - } - - #[test] - fn defaults_to_danger_full_access_permission_mode() { - let cli = Cli::parse_from(["rusty-claude-cli"]); - assert_eq!(cli.permission_mode, PermissionMode::DangerFullAccess); - } -}