diff --git a/Cargo.lock b/Cargo.lock index 0b3d0d5..d64763d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "TinyHarness" -version = "0.1.2" +version = "0.2.0" dependencies = [ "chrono", "clap", @@ -1837,6 +1837,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2005,7 +2015,7 @@ dependencies = [ [[package]] name = "tinyharness-lib" -version = "0.1.2" +version = "0.2.0" dependencies = [ "glob", "ollama-rs", @@ -2023,11 +2033,13 @@ dependencies = [ [[package]] name = "tinyharness-ui" -version = "0.1.2" +version = "0.2.0" dependencies = [ + "libc", "regex", "rustyline", "serde_json", + "signal-hook", "tinyharness-lib", ] diff --git a/Cargo.toml b/Cargo.toml index 58babd7..de44c69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [".", "tinyharness-lib", "tinyharness-ui"] [package] name = "TinyHarness" -version = "0.1.2" +version = "0.2.0" license = "MIT" description = "tinyharness - ai coding harness" edition = "2024" @@ -13,8 +13,8 @@ name = "tinyharness" path = "src/main.rs" [dependencies] -tinyharness-lib = { version = "0.1.2", path = "tinyharness-lib" } -tinyharness-ui = { version = "0.1.2", path = "tinyharness-ui" } +tinyharness-lib = { version = "0.2.0", path = "tinyharness-lib" } +tinyharness-ui = { version = "0.2.0", path = "tinyharness-ui" } clap = { version = "4.6.1", features = ["derive"] } tokio = { version = "1.52.1", features = ["full"] } serde = { version = "1.0.228", features = ["derive"] } diff --git a/README.md b/README.md index 2af4c94..3513ab2 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Lightweight AI assistant framework in Rust with pluggable LLM providers (Ollama, - **Context Management**: Token estimation with per-model context window sizes (8K–256K), load warnings at 70%/90% thresholds, and cascading conversation compaction via `/compact`. - **Session Persistence**: JSONL-based sessions with UUIDs, saved in `~/.local/share/tinyharness/sessions/`. Supports session listing, switching by prefix, renaming, deletion, and auto-save every 5 messages. - **Async Streaming**: Built on `tokio` for efficient streaming with all providers. Ctrl+C interrupts generation gracefully. +- **Experimental TUI**: Split-pane terminal UI with conversation view, sidebar, input bar, and tool output panel. Built from scratch with no external TUI framework. Activate with `--tui`. ⚠️ Experimental — may have rendering issues or incomplete features. - **Interactive CLI**: Color-coded terminal interface with 22+ slash commands for session management, configuration, file pinning, image attachment, audit logging, and tool control. - **Customizable Prompts**: System prompts are seeded from hardcoded defaults on first launch to `~/.config/tinyharness/prompts/` and can be freely edited. - **Command Safety**: Smart auto-accept for safe shell commands with prefix matching, deny lists, redirection stripping, and audit logging. @@ -100,6 +101,12 @@ tinyharness --prompt "Explain the architecture" ``` Sends an initial prompt and then drops into the interactive loop for follow-up turns. +**Terminal UI (experimental)**: +```bash +tinyharness --tui +``` +Launches a split-pane TUI with conversation view, sidebar, input bar, and tool output panel. Built from scratch using raw ANSI escape sequences — no external TUI framework. This is experimental and may have rendering issues or incomplete features. + **Interactive setup**: ```bash tinyharness --config @@ -117,6 +124,7 @@ Runs a guided setup: pick a provider, enter a URL, save to settings. Exits when | `-c`, `--continue` | Continue the most recent session in the current directory | | `--config` | Run interactive provider setup, then exit | | `-p`, `--prompt ` | Start with this message, then drop into interactive mode | +| `--tui` | Launch the experimental terminal UI (split-pane TUI) | ## Agent Modes @@ -327,19 +335,36 @@ tinyharness-lib/src/ ### UI library (`tinyharness-ui/`) -Terminal UI abstractions — reusable output formatting, diff display, confirmation prompts. +Terminal UI abstractions — reusable output formatting, diff display, confirmation prompts, and the experimental TUI. ``` tinyharness-ui/src/ ├── lib.rs Module declarations ├── output.rs Structured output writer (stdout/stderr abstraction) ├── style.rs ANSI color constants (BOLD, CYAN, RED, BG_TOOL, SPINNER_FRAMES, etc.) -└── ui/ - ├── mod.rs Module declarations - ├── confirm.rs Tool call confirmation prompts (Yes/No/Auto-accept) - ├── diff.rs Unified diff display - ├── input.rs CommandHelper for rustyline tab-completion - └── wrap.rs Word-wrapped output with ANSI-aware line filling +├── ui/ +│ ├── mod.rs Module declarations +│ ├── confirm.rs Tool call confirmation prompts (Yes/No/Auto-accept) +│ ├── diff.rs Unified diff display +│ ├── input.rs CommandHelper for rustyline tab-completion +│ └── wrap.rs Word-wrapped output with ANSI-aware line filling +└── tui/ ⚠️ Experimental TUI subsystem + ├── mod.rs TUI module declarations + agent integration types (TuiAgentEvent, TuiUserAction) + ├── app.rs Main TUI application loop, widget layout, event dispatch + ├── backend.rs Backend trait (StdioBackend + TestBackend for testing) + ├── cell.rs Color/style representation for the screen buffer (raw ANSI, no framework) + ├── event.rs Event system (keyboard, mouse, paste) + ├── layout.rs Rect/constraint-based layout (inspired by ratatui, from scratch) + ├── screen.rs Screen buffer with differential rendering (only changed cells written) + ├── terminal.rs Raw terminal control, alternate screen, signal handling + ├── widget.rs Widget trait, Action enum, shared style helpers + └── widgets/ + ├── conversation.rs Conversation pane (streaming text, thinking, tool calls) + ├── input_bar.rs Multi-line input with history, word-wrap, paste + ├── sidebar.rs Context panel (files, tools, mode, model info) + ├── spinner.rs Streaming indicator + ├── status_bar.rs Top bar (mode, model, token count) + └── tool_output.rs Tool result viewer ``` ### Binary crate (`src/`) @@ -348,9 +373,10 @@ CLI application — argument parsing, agent loop, slash commands, tool dispatch, ``` src/ -├── main.rs Entry point, CLI parsing (clap), provider creation, session init +├── main.rs Entry point, CLI parsing (clap), provider creation, session init, TUI launch ├── agent/ │ ├── mod.rs Main interaction loop, streaming response display, spinner, thinking chain +│ ├── tui_loop.rs Background agent loop for TUI mode (communicates via channels) │ ├── tools.rs Tool call dispatch, confirmation, generic execution, signal handlers │ ├── safety.rs Shell command safety checker (prefix + deny list + redirection stripping) │ ├── setup.rs Interactive provider setup (--config), URL prompting diff --git a/TINYHARNESS.md b/TINYHARNESS.md index 88ff827..9accf47 100644 --- a/TINYHARNESS.md +++ b/TINYHARNESS.md @@ -1,6 +1,6 @@ # TinyHarness -Lightweight AI assistant framework in Rust with pluggable LLM providers (Ollama, llama.cpp, vLLM) and built-in tool calling. +Lightweight AI assistant framework in Rust with pluggable LLM providers (Ollama, llama.cpp, vLLM), built-in tool calling, and an experimental terminal UI (TUI). ## Commands @@ -11,13 +11,14 @@ Lightweight AI assistant framework in Rust with pluggable LLM providers (Ollama, - Formatting: `cargo fmt --all` - Install: `make install` (builds release + copies to `~/.local/bin`) - Run: `cargo run` (Ollama default) or `cargo run -- --llama-cpp` / `--vllm` +- TUI (experimental): `cargo run -- --tui` ## Workspace Structure Three crates in a Cargo workspace: - **`tinyharness-lib`** — Core library: providers, tools, sessions, context, skills, tokens. No terminal I/O. -- **`tinyharness-ui`** — UI library: ANSI output, confirmation prompts, diff display, command input. +- **`tinyharness-ui`** — UI library: ANSI output, confirmation prompts, diff display, command input, experimental TUI subsystem. - **`TinyHarness`** — Binary CLI: agent loop, slash commands, tool dispatch, setup. ### Key `tinyharness-lib` modules @@ -33,6 +34,7 @@ Three crates in a Cargo workspace: ### Binary crate structure - `src/agent/` — Agent loop, tool execution, safety checks, display, multi-line input, provider setup +- `src/agent/tui_loop.rs` — Background agent loop for TUI mode (communicates with TUI via mpsc channels) - `src/commands/` — 22+ slash commands (mode, model, sessions, compact, init, context, files, image, skill, settings, help, etc.), `CommandRegistry` and `async_command!` macro ## Code Conventions diff --git a/docs/configuration.md b/docs/configuration.md index a1ce564..20439fe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -251,6 +251,7 @@ All CLI flags override settings: | `-c`, `--continue` | Loads most recent session (doesn't modify settings) | | `--config` | Runs interactive setup, saves, exits | | `-p`, `--prompt ` | Sends initial prompt then enters interactive mode | +| `--tui` | Launch the experimental terminal UI (split-pane TUI) | --- diff --git a/docs/contributing.md b/docs/contributing.md index c7c997b..f71d98a 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -50,12 +50,23 @@ tinyharness-lib/ Core library — no terminal I/O, no ANSI, no rust │ ├── mode.rs AgentMode enum, prompt assembly │ └── prompts/ Hardcoded default system prompts (.md files) │ -tinyharness-ui/ UI library — terminal output abstractions +tinyharness-ui/ UI library — terminal output abstractions + experimental TUI ├── src/ │ ├── lib.rs Module declarations │ ├── output.rs Structured output writer │ ├── style.rs ANSI color constants, spinner frames -│ └── ui/ confirm.rs, diff.rs, input.rs, wrap.rs +│ ├── ui/ confirm.rs, diff.rs, input.rs, wrap.rs +│ └── tui/ ⚠️ Experimental TUI subsystem +│ ├── mod.rs Agent integration types (TuiAgentEvent, TuiUserAction) +│ ├── app.rs Main TUI application loop +│ ├── backend.rs Backend trait (StdioBackend + TestBackend) +│ ├── cell.rs Color/style for screen buffer (raw ANSI, no framework) +│ ├── event.rs Keyboard/mouse/paste events +│ ├── layout.rs Constraint-based layout +│ ├── screen.rs Differential rendering screen buffer +│ ├── terminal.rs Raw terminal control, alternate screen +│ ├── widget.rs Widget trait, Action enum +│ └── widgets/ conversation, input_bar, sidebar, spinner, status_bar, tool_output │ docs/ User-facing documentation └── todo/ Enhancement tracking (local only, not committed) @@ -64,7 +75,7 @@ docs/ User-facing documentation ### Crate Rules - **`tinyharness-lib`**: Must not use terminal I/O, ANSI escape codes, or `rustyline`. Uses `tracing` for logging. -- **`tinyharness-ui`**: Terminal UI abstractions — ANSI colors, confirmation prompts, diff display, word wrapping. +- **`tinyharness-ui`**: Terminal UI abstractions — ANSI colors, confirmation prompts, diff display, word wrapping. Includes an experimental TUI subsystem (`tui/` module) built from scratch with raw ANSI escape sequences (no ratatui/crossterm). The TUI is feature-gated behind the `tui` Cargo feature. - **`src/` (binary)**: Wires everything together. Handles I/O, user interaction, and the agent loop. --- diff --git a/src/agent/command_result.rs b/src/agent/command_result.rs new file mode 100644 index 0000000..6b211dc --- /dev/null +++ b/src/agent/command_result.rs @@ -0,0 +1,207 @@ +// ── Shared Command Result Handling ───────────────────────────────────────── +// +// When slash commands return `CommandResult` variants, both CLI and TUI loops +// need to apply the same state mutations (session switches, skill activation, +// etc.). This module extracts that shared logic. +// +// Each handler returns a `CommandResultInfo` describing what happened, so +// callers can render appropriate output for their UI. + +use tinyharness_lib::{ + context::WorkspaceContext, + provider::{Message, Role}, + session::Session, +}; + +use crate::commands::CommandContext; + +/// Information about what happened when a command result was applied. +/// +/// Callers use this to render appropriate output (CLI: ANSI text, TUI: events). +#[derive(Debug)] +pub struct CommandResultInfo { + /// A human-readable summary of what happened. + pub description: String, + /// Whether this was an error. + pub is_error: bool, +} + +/// Apply a `SwitchSession` command result. +/// +/// Performs the session switch (flush, load, update context), returns +/// a description of what happened. +pub fn apply_switch_session( + id_prefix: &str, + ctx: &mut CommandContext, + messages: &mut Vec, + session: &mut Session, +) -> CommandResultInfo { + let store = tinyharness_lib::session::SessionStore::default_path(); + match store.find_by_prefix(id_prefix) { + Ok(full_id) => { + session.flush(); + match store.load(&full_id) { + Ok((new_session, loaded_msgs)) => { + let id_short = full_id[..12].to_string(); + let name = new_session + .meta() + .name + .clone() + .unwrap_or_else(|| "unnamed".to_string()); + let msg_count = new_session.meta().message_count; + let mode = new_session.meta().mode; + + *session = new_session; + *messages = loaded_msgs; + ctx.current_mode = session.meta().mode; + ctx.session_id = Some(session.id().to_string()); + ctx.refresh_system_prompt(messages); + + CommandResultInfo { + description: format!( + "Switched to session {} — {} ({} messages, {})", + id_short, name, msg_count, mode + ), + is_error: false, + } + } + Err(e) => CommandResultInfo { + description: e.to_string(), + is_error: true, + }, + } + } + Err(e) => CommandResultInfo { + description: e.to_string(), + is_error: true, + }, + } +} + +/// Apply a `RenameSession` command result. +pub fn apply_rename_session(new_name: &str, session: &mut Session) -> CommandResultInfo { + session.set_name(new_name.to_string()); + CommandResultInfo { + description: format!("Session renamed to {}", new_name), + is_error: false, + } +} + +/// Apply an `Init` command result. +pub fn apply_init( + result: &crate::commands::init::InitResult, + ctx: &mut CommandContext, + messages: &mut [Message], +) -> CommandResultInfo { + ctx.workspace_ctx = WorkspaceContext::collect(); + ctx.refresh_system_prompt(messages); + match result { + crate::commands::init::InitResult::Created { path } => CommandResultInfo { + description: format!("Created {} — workspace context refreshed.", path.display()), + is_error: false, + }, + crate::commands::init::InitResult::Updated { path } => CommandResultInfo { + description: format!("Updated {} — workspace context refreshed.", path.display()), + is_error: false, + }, + } +} + +/// Apply a `SkillUse` command result. +pub fn apply_skill_use( + skill_name: &str, + ctx: &mut CommandContext, + messages: &mut Vec, + session: &mut Session, +) -> CommandResultInfo { + // Prevent duplicate activation + if ctx + .active_skills + .iter() + .any(|s| s.eq_ignore_ascii_case(skill_name)) + { + return CommandResultInfo { + description: format!( + "Skill '{}' is already active. Use /unload {} to deactivate it.", + skill_name, skill_name + ), + is_error: false, // not an error per se, but a warning + }; + } + + match ctx.skill_registry.get(skill_name) { + Some(skill) => { + ctx.active_skills.push(skill.name.clone()); + messages.push(Message { + role: Role::User, + content: format!("/use {}", skill_name), + tool_calls: vec![], + images: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); + ctx.refresh_system_prompt(messages); + + CommandResultInfo { + description: format!("Skill activated: {} — {}", skill_name, skill.description), + is_error: false, + } + } + None => CommandResultInfo { + description: format!( + "Skill '{}' not found — it may have been removed.", + skill_name + ), + is_error: true, + }, + } +} + +/// Apply a `SkillUnload` command result. +pub fn apply_skill_unload( + skill_name: &str, + ctx: &mut CommandContext, + messages: &mut Vec, + session: &mut Session, +) -> CommandResultInfo { + let pos = ctx + .active_skills + .iter() + .position(|s| s.eq_ignore_ascii_case(skill_name)); + + match pos { + Some(idx) => { + let removed = ctx.active_skills.remove(idx); + messages.push(Message { + role: Role::User, + content: format!("/unload {}", skill_name), + tool_calls: vec![], + images: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); + ctx.refresh_system_prompt(messages); + + CommandResultInfo { + description: format!("Skill deactivated: {}", removed), + is_error: false, + } + } + None => CommandResultInfo { + description: format!("Skill '{}' is not active.", skill_name), + is_error: false, + }, + } +} + +/// Apply a generic `CommandResult::Ok` — update token usage if needed. +/// +/// Returns the updated token usage, if any. +pub fn apply_ok( + ctx: &mut CommandContext, + session: &mut Session, +) -> Option { + let usage = ctx.compaction_token_usage.take(); + if let Some(ref u) = usage { + session.set_token_usage(u.clone()); + } + usage +} diff --git a/src/agent/confirm.rs b/src/agent/confirm.rs new file mode 100644 index 0000000..09d8309 --- /dev/null +++ b/src/agent/confirm.rs @@ -0,0 +1,94 @@ +// ── Shared Tool Confirmation Logic ───────────────────────────────────────── +// +// The decision tree for whether a tool call is approved is identical in both +// CLI and TUI loops. The only difference is what happens at the "ask user" +// step — CLI uses an interactive terminal prompt, TUI sends a channel event. +// +// This module extracts the pure decision logic so both loops share the same +// branching, and each only needs to implement the I/O part. + +use tinyharness_lib::provider::ToolCall; + +use super::safety::is_safe_command; + +/// Decision about whether a tool call is allowed to proceed. +#[derive(Debug, Clone, PartialEq)] +pub enum ConfirmationDecision { + /// The tool call is automatically approved (no user interaction needed). + /// `auto_accepted` indicates whether this came from auto-accept mode + /// (affects display: "Executing..." vs "(auto-accepted)"). + AutoApproved { auto_accepted: bool }, + + /// The tool call needs explicit user confirmation. + NeedsConfirmation, + + /// The tool call was denied (e.g., by a previous user response). + /// This variant is not produced by `decide_tool_confirmation` directly, + /// but is useful for callers that receive a "no" from the user. + Denied, +} + +/// Determine whether a tool call should be approved, needs user confirmation, +/// or should be denied. +/// +/// This is pure logic — no I/O. The caller is responsible for implementing +/// the user interaction when `NeedsConfirmation` is returned. +/// +/// The logic follows these rules (matching both CLI and TUI implementations): +/// 1. Read-only tools (no confirmation needed) → `AutoApproved { auto_accepted: false }` +/// 2. Auto-accept mode → `AutoApproved { auto_accepted: true }` for most tools, +/// but `run` commands that aren't safe still need confirmation +/// 3. Auto-accept safe commands setting → `AutoApproved { auto_accepted: true }` +/// for safe `run` commands +/// 4. Everything else → `NeedsConfirmation` +pub fn decide_tool_confirmation( + call: &ToolCall, + auto_accept: bool, + auto_accept_safe_commands: bool, + safe_commands: &[String], + denied_commands: &[String], + needs_confirmation: bool, +) -> ConfirmationDecision { + // Read-only tools: always approved, never "auto-accepted" + if !needs_confirmation { + return ConfirmationDecision::AutoApproved { + auto_accepted: false, + }; + } + + // Auto-accept mode: approve most tools, but run commands still need checks + if auto_accept { + if call.function.name == "run" { + if let Some(cmd_value) = call.function.arguments.get("command") + && let Some(cmd_str) = cmd_value.as_str() + && is_safe_command(cmd_str, safe_commands, denied_commands) + { + return ConfirmationDecision::AutoApproved { + auto_accepted: true, + }; + } + // Unsafe run command — still needs confirmation even in auto-accept mode + return ConfirmationDecision::NeedsConfirmation; + } else { + // Other destructive tools can be auto-accepted + return ConfirmationDecision::AutoApproved { + auto_accepted: true, + }; + } + } + + // Auto-accept safe commands setting (for run tool only) + if auto_accept_safe_commands + && call.function.name == "run" + && let Some(cmd_value) = call.function.arguments.get("command") + && let Some(cmd_str) = cmd_value.as_str() + && is_safe_command(cmd_str, safe_commands, denied_commands) + { + return ConfirmationDecision::AutoApproved { + auto_accepted: true, + }; + } + + // Everything else needs explicit user confirmation + ConfirmationDecision::NeedsConfirmation +} diff --git a/src/agent/display.rs b/src/agent/display.rs index a2d1f1a..56b60e5 100644 --- a/src/agent/display.rs +++ b/src/agent/display.rs @@ -305,6 +305,88 @@ pub fn summarize_listing_result(result: &str, tool_name: &str) -> String { /// Format tool call arguments as a compact single-line summary. pub fn format_args_summary(arguments: &serde_json::Value) -> String { + format_args_summary_impl(arguments, true) +} + +/// Format tool call arguments for TUI display — shows full commands for `run` +/// and full paths for `edit`/`write`, without truncating important args. +pub fn format_args_summary_tui(tool_name: &str, arguments: &serde_json::Value) -> String { + // For run/edit/write, show the key argument in full and summarize the rest + match tool_name { + "run" => { + if let Some(cmd) = arguments.get("command").and_then(|v| v.as_str()) { + let cwd = arguments + .get("cwd") + .and_then(|v| v.as_str()) + .map(|c| format!(", cwd={}", c)) + .unwrap_or_default(); + let timeout = arguments + .get("timeout") + .and_then(|v| v.as_str()) + .map(|t| format!(", timeout={}", t)) + .unwrap_or_default(); + format!("command=\"{}\"{}{}", cmd, cwd, timeout) + } else { + format_args_summary_impl(arguments, false) + } + } + "edit" => { + if let Some(path) = arguments.get("path").and_then(|v| v.as_str()) { + format!("path=\"{}\"", path) + } else { + format_args_summary_impl(arguments, false) + } + } + "write" => { + if let Some(path) = arguments.get("path").and_then(|v| v.as_str()) { + let content_len = arguments + .get("content") + .and_then(|v| v.as_str()) + .map(|c| c.len()) + .unwrap_or(0); + format!("path=\"{}\", content=({} bytes)", path, content_len) + } else { + format_args_summary_impl(arguments, false) + } + } + "read" => { + if let Some(path) = arguments.get("path").and_then(|v| v.as_str()) { + let from_to = arguments + .get("from") + .and_then(|v| v.as_str()) + .map(|f| { + let to = arguments + .get("to") + .and_then(|v| v.as_str()) + .map(|t| format!("-{}", t)) + .unwrap_or_default(); + format!(":{}{}", f, to) + }) + .unwrap_or_default(); + format!("path=\"{}{}\"", path, from_to) + } else { + format_args_summary_impl(arguments, false) + } + } + "grep" | "glob" => { + // Show pattern in full, truncate if very long + if let Some(pattern) = arguments.get("pattern").and_then(|v| v.as_str()) { + let path = arguments + .get("path") + .and_then(|v| v.as_str()) + .map(|p| format!(", path={}", p)) + .unwrap_or_default(); + format!("pattern=\"{}\"{}", pattern, path) + } else { + format_args_summary_impl(arguments, false) + } + } + _ => format_args_summary_impl(arguments, false), + } +} + +/// Internal implementation of argument formatting with optional truncation. +fn format_args_summary_impl(arguments: &serde_json::Value, truncate: bool) -> String { match arguments { serde_json::Value::Object(map) => { let parts: Vec = map @@ -312,7 +394,7 @@ pub fn format_args_summary(arguments: &serde_json::Value) -> String { .map(|(key, val)| { let val_str = match val { serde_json::Value::String(s) => { - if s.len() > 60 { + if truncate && s.len() > 60 { let truncate_at = s.floor_char_boundary(57); format!("\"{}...\"", &s[..truncate_at]) } else { diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 860db81..945f885 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,8 +1,13 @@ +pub mod command_result; +pub mod confirm; pub mod display; pub mod input; pub mod safety; pub mod setup; +pub mod signal; +pub mod tool_result; pub mod tools; +pub mod tui_loop; use std::{ error::Error, @@ -18,16 +23,15 @@ use tokio::sync::Mutex; use tinyharness_lib::{ config::load_settings, - context::WorkspaceContext, mode::AgentMode, provider::{Message, Provider, Role}, - session::{Session, SessionStore}, + session::Session, token::ContextWindowSize, tools::ToolManager, }; use tinyharness_ui::output::Output; -use crate::commands::{CommandContext, CommandResult, build_registry, init}; +use crate::commands::{CommandContext, CommandResult, build_registry}; use tinyharness_ui::style::*; use tinyharness_ui::ui::input::CommandHelper; @@ -95,7 +99,14 @@ pub async fn run_agent_loop( &mut stdout, )?; - let helper = CommandHelper::new(); + let helper = CommandHelper::with_commands( + registry + .command_names() + .into_iter() + .map(|s| s.to_string()) + .collect(), + registry.subcommands(), + ); let history_dir = std::env::var("HOME") .map(|h| std::path::PathBuf::from(h).join(".local/share/tinyharness")) .unwrap_or_else(|_| std::path::PathBuf::from(".tinyharness_history")); @@ -224,166 +235,52 @@ pub async fn run_agent_loop( match registry.dispatch(&user_input, ctx, messages).await { Ok(CommandResult::Ok) => { // Update token usage from compaction side-channel. - if let Some(usage) = ctx.compaction_token_usage.take() { - last_known_token_usage = Some(usage.clone()); - session.set_token_usage(usage); + if let Some(usage) = command_result::apply_ok(ctx, session) { + last_known_token_usage = Some(usage); } } Ok(CommandResult::SwitchSession(id_prefix)) => { - let store = SessionStore::default_path(); - match store.find_by_prefix(&id_prefix) { - Ok(full_id) => { - // Flush current session before switching - session.flush(); - match store.load(&full_id) { - Ok((new_session, loaded_msgs)) => { - let meta = new_session.meta(); - let name = meta.name.as_deref().unwrap_or("unnamed"); - let mut err_out = Output::stderr(); - let _ = writeln!( - err_out, - "{BOLD}Switched to session {BLUE}{}{RESET} — {BOLD}{name}{RESET} ({} messages, {})", - &meta.id[..12], - meta.message_count, - meta.mode, - ); - *session = new_session; - *messages = loaded_msgs; - // Update context mode, session ID, and token usage - // from the loaded session - ctx.current_mode = session.meta().mode; - ctx.session_id = Some(session.id().to_string()); - last_known_token_usage = session.meta().token_usage.clone(); - // Ensure system prompt reflects current context - ctx.refresh_system_prompt(messages); - // Print loaded conversation history - print_conversation_history(messages, &mut stdout)?; - // Warn if the loaded session is near or over the context window limit, - // using the stored provider token count from the session's metadata. - print_context_load_warning( - messages, - session.meta().token_usage.as_ref().map(|u| u.total_tokens), - &mut stdout, - )?; - } - Err(e) => { - let mut err_out = Output::stderr(); - let _ = writeln!(err_out, "{RED}{e}{RESET}"); - } - } - } - Err(e) => { - let mut err_out = Output::stderr(); - let _ = writeln!(err_out, "{RED}{e}{RESET}"); - } + let info = + command_result::apply_switch_session(&id_prefix, ctx, messages, session); + if info.is_error { + let mut err_out = Output::stderr(); + let _ = writeln!(err_out, "{RED}{}{RESET}", info.description); + } else { + let mut err_out = Output::stderr(); + let _ = writeln!(err_out, "{BOLD}{}{RESET}", info.description); + last_known_token_usage = session.meta().token_usage.clone(); + print_conversation_history(messages, &mut stdout)?; + print_context_load_warning( + messages, + session.meta().token_usage.as_ref().map(|u| u.total_tokens), + &mut stdout, + )?; } } Ok(CommandResult::RenameSession(new_name)) => { - session.set_name(new_name.clone()); + let info = command_result::apply_rename_session(&new_name, session); let mut err_out = Output::stderr(); - let _ = writeln!(err_out, "{BOLD}Session renamed to {BLUE}{new_name}{RESET}",); + let _ = writeln!(err_out, "{BOLD}{}{RESET}", info.description); } Ok(CommandResult::Init(result)) => { - // Refresh workspace context since the project instruction file may have changed - ctx.workspace_ctx = WorkspaceContext::collect(); - ctx.refresh_system_prompt(messages); - + let info = command_result::apply_init(&result, ctx, messages); let mut err_out = Output::stderr(); - match &result { - init::InitResult::Created { path } => { - let _ = writeln!( - err_out, - "{GREEN} Created {BLUE}{}{GREEN} — workspace context refreshed.{RESET}", - path.display(), - ); - } - init::InitResult::Updated { path } => { - let _ = writeln!( - err_out, - "{GREEN} Updated {BLUE}{}{GREEN} — workspace context refreshed.{RESET}", - path.display(), - ); - } - } + let _ = writeln!(err_out, "{GREEN}{}{RESET}", info.description); } Ok(CommandResult::SkillUse(skill_name)) => { - // Prevent duplicate activation - if ctx - .active_skills - .iter() - .any(|s| s.eq_ignore_ascii_case(&skill_name)) - { - let mut err_out = Output::stderr(); - let _ = writeln!( - err_out, - "{ORANGE}⚠ Skill '{skill_name}' is already active.{RESET} Use {BOLD}/unload {skill_name}{RESET} to deactivate it.", - ); - continue; - } - match ctx.skill_registry.get(&skill_name) { - Some(skill) => { - let mut err_out = Output::stderr(); - let _ = writeln!( - err_out, - "{BOLD}⚡ Skill activated: {CYAN}{skill_name}{RESET} — {}{RESET}", - skill.description, - ); - // Track the active skill - ctx.active_skills.push(skill.name.clone()); - // Inject a user message indicating skill activation - messages.push(Message { - role: Role::User, - content: format!("/use {}", skill_name), - tool_calls: vec![], - images: vec![], - }); - session.append_message(messages.last().expect("just pushed a message")); - // Refresh system prompt to include the active skill - ctx.refresh_system_prompt(messages); - } - None => { - let mut err_out = Output::stderr(); - let _ = writeln!( - err_out, - "{RED}⚠ Skill '{skill_name}' not found — it may have been removed.{RESET}", - ); - } + let info = command_result::apply_skill_use(&skill_name, ctx, messages, session); + let mut err_out = Output::stderr(); + if info.is_error { + let _ = writeln!(err_out, "{RED}⚠ {}{RESET}", info.description); + } else { + let _ = writeln!(err_out, "{BOLD}⚡ {}{RESET}", info.description); } } Ok(CommandResult::SkillUnload(skill_name)) => { - // Find and remove the skill from active list - let pos = ctx - .active_skills - .iter() - .position(|s| s.eq_ignore_ascii_case(&skill_name)); - match pos { - Some(idx) => { - let removed = ctx.active_skills.remove(idx); - let mut err_out = Output::stderr(); - let _ = writeln!( - err_out, - "{BOLD}Skill deactivated: {CYAN}{removed}{RESET}", - ); - // Inject a user message indicating skill deactivation - messages.push(Message { - role: Role::User, - content: format!("/unload {}", skill_name), - tool_calls: vec![], - images: vec![], - }); - session.append_message(messages.last().expect("just pushed a message")); - // Refresh system prompt to remove the skill - ctx.refresh_system_prompt(messages); - } - None => { - // Should not happen since dispatch validates this - let mut err_out = Output::stderr(); - let _ = writeln!( - err_out, - "{ORANGE}⚠ Skill '{skill_name}' is not active.{RESET}", - ); - } - } + let info = + command_result::apply_skill_unload(&skill_name, ctx, messages, session); + let mut err_out = Output::stderr(); + let _ = writeln!(err_out, "{BOLD}{}{RESET}", info.description); } Err(e) => { let mut err_out = Output::stderr(); @@ -491,7 +388,7 @@ pub async fn run_agent_loop( // Show [thinking] header once, before the first delta if !thinking_header_shown { - write!(stdout, "{DIM}{THINK_COLOR}[thinking] ")?; + write!(stdout, "\n{DIM}{THINK_COLOR}[thinking] ")?; thinking_header_shown = true; } diff --git a/src/agent/signal.rs b/src/agent/signal.rs new file mode 100644 index 0000000..9ea4b0e --- /dev/null +++ b/src/agent/signal.rs @@ -0,0 +1,334 @@ +// ── Shared Signal Handling ────────────────────────────────────────────────── +// +// Signal tools (switch_mode, question, auto_compact, invoke_skill) produce +// structured side-effects on the conversation state. Both the CLI and TUI loops +// handle the same state mutations — the only difference is how they report +// results to the user. +// +// This module extracts the business logic into pure functions that return +// `SignalResult`, so both loops can share the mutation code and only differ +// in rendering. + +use tinyharness_lib::{ + mode::AgentMode, + provider::{Message, Role}, + session::Session, + tools::SignalEvent, +}; +use tokio::sync::Mutex; + +use crate::commands::CommandContext; +use crate::commands::compact::execute_compact; + +// ── SignalResult ───────────────────────────────────────────────────────────── + +/// Structured result from handling a signal tool event. +/// +/// Contains the state mutations that have already been applied (messages pushed, +/// session updated, mode changed) along with information the caller needs for +/// rendering to the user. +#[derive(Debug)] +pub enum SignalResult { + SwitchMode { + old_mode: AgentMode, + new_mode: AgentMode, + already_in: bool, + }, + Question { + /// The answer selected or entered by the user. + /// `None` means the question was skipped (no answer provided). + answer: Option, + /// Whether the user selected one of the provided options (vs free-form). + selected_provided: bool, + }, + AutoCompact { + focus: String, + success: bool, + error: Option, + }, + InvokeSkill { + name: String, + description: String, + already_active: bool, + found: bool, + }, + /// The signal event arguments could not be parsed. + ParseError { tool_name: String }, +} + +// ── Handle signal event ───────────────────────────────────────────────────── + +/// Handle a signal tool event, mutating conversation state and returning a +/// structured result for the caller to render. +/// +/// This function: +/// - Pushes appropriate `Message`s into the conversation +/// - Appends them to the session +/// - Updates `CommandContext` state (mode, skills, compaction token usage) +/// - Refreshes the system prompt when needed +/// +/// The caller is responsible for rendering the result to the user (CLI: ANSI +/// output; TUI: channel events). +#[allow(clippy::too_many_arguments)] +pub async fn handle_signal_event( + event: &SignalEvent, + messages: &mut Vec, + session: &mut Session, + ctx: &mut CommandContext, + provider: &std::sync::Arc>, +) -> SignalResult { + match event { + SignalEvent::SwitchMode { mode } => { + let old_mode = ctx.current_mode; + match ctx.switch_mode(*mode, messages) { + Ok(()) => { + session.set_mode(*mode); + messages.push(Message { + role: Role::Tool, + content: format!( + "SUCCESS: Mode switched from '{}' to '{}'. The assistant is now in {} mode and will use the appropriate toolset and behavior.", + old_mode, mode, mode + ), + tool_calls: vec![], + images: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); + SignalResult::SwitchMode { + old_mode, + new_mode: *mode, + already_in: false, + } + } + Err(_msg) => { + messages.push(Message { + role: Role::Tool, + content: format!("Already in '{}' mode. No change was made.", mode), + tool_calls: vec![], + images: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); + SignalResult::SwitchMode { + old_mode, + new_mode: *mode, + already_in: true, + } + } + } + } + + SignalEvent::Question { question, answers } => { + // Question signals require user interaction (prompting the user for + // input) which can't be done here — the caller must handle the I/O + // directly. Validate the question and return an error if invalid, + // otherwise return a marker result indicating the caller should + // handle the question. + if let Some(error) = validate_question(question, answers) { + apply_question_error(error, messages, session); + } + // The caller should handle the question before calling this function. + // This branch should never be reached in practice. + SignalResult::Question { + answer: None, + selected_provided: false, + } + } + + SignalEvent::AutoCompact { focus } => { + let mut provider_guard = provider.lock().await; + match execute_compact(&mut ctx.output, &mut *provider_guard, messages, focus).await { + Ok(token_usage) => { + if let Some(usage) = token_usage.clone() { + ctx.compaction_token_usage = Some(usage.clone()); + session.set_token_usage(usage); + } + messages.push(Message { + role: Role::Tool, + content: format!( + "Conversation compacted successfully. Focus: '{}'.", + if focus.is_empty() { + "general summary" + } else { + focus + } + ), + tool_calls: vec![], + images: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); + SignalResult::AutoCompact { + focus: focus.clone(), + success: true, + error: None, + } + } + Err(e) => { + messages.push(Message { + role: Role::Tool, + content: format!( + "Auto-compact failed: {}. The conversation was not modified.", + e + ), + tool_calls: vec![], + images: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); + SignalResult::AutoCompact { + focus: focus.clone(), + success: false, + error: Some(e.to_string()), + } + } + } + } + + SignalEvent::InvokeSkill { skill_name } => { + let skill_result = { + let registry = &ctx.skill_registry; + registry + .get(skill_name) + .map(|s| (s.name.clone(), s.description.clone())) + }; + match skill_result { + Some((name, description)) => { + if ctx + .active_skills + .iter() + .any(|s| s.eq_ignore_ascii_case(&name)) + { + messages.push(Message { + role: Role::Tool, + content: format!("Skill '{}' is already active. Its instructions are already in effect.", name), + tool_calls: vec![], + images: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); + SignalResult::InvokeSkill { + name, + description, + already_active: true, + found: true, + } + } else { + ctx.active_skills.push(name.clone()); + messages.push(Message { + role: Role::User, + content: format!("/use {}", skill_name), + tool_calls: vec![], + images: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); + ctx.refresh_system_prompt(messages); + SignalResult::InvokeSkill { + name, + description, + already_active: false, + found: true, + } + } + } + None => { + let available = ctx + .skill_registry + .skills + .iter() + .map(|s| s.name.clone()) + .collect::>() + .join(", "); + messages.push(Message { + role: Role::Tool, + content: format!( + "Error: Skill '{}' not found. Available skills: {}. Use /skills to list them.", + skill_name, available + ), + tool_calls: vec![], + images: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); + SignalResult::InvokeSkill { + name: skill_name.clone(), + description: String::new(), + already_active: false, + found: false, + } + } + } + } + } +} + +/// Apply the user's answer to a question signal event. +/// +/// This is called after the caller has obtained the user's answer through +/// its own I/O mechanism (CLI prompt or TUI channel). +pub fn apply_question_answer( + question: &str, + answer: &str, + is_skip: bool, + messages: &mut Vec, + session: &mut Session, +) { + let result_content = if is_skip { + format!( + "User skipped the provided options for the question '{}' and entered a custom answer: '{}'.\n\nUse this answer to continue helping the user.", + question, answer + ) + } else { + format!( + "User answered the question '{}' with: '{}'.\n\nUse this answer to continue helping the user.", + question, answer + ) + }; + messages.push(Message { + role: Role::Tool, + content: result_content, + tool_calls: vec![], + images: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); +} + +/// Handle the question signal event for validation. +/// +/// Returns `Some(error_message)` if validation fails (empty question or no answers), +/// `None` if the question is valid and the caller should proceed with user interaction. +pub fn validate_question(question: &str, answers: &[String]) -> Option { + if question.is_empty() { + Some("Error: 'question' argument is required for the question tool.".to_string()) + } else if answers.is_empty() { + Some( + "Error: 'answers' argument must contain at least one option for the question tool." + .to_string(), + ) + } else { + None + } +} + +/// Apply validation error for question signal as a message. +pub fn apply_question_error(error: String, messages: &mut Vec, session: &mut Session) { + messages.push(Message { + role: Role::Tool, + content: error, + tool_calls: vec![], + images: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); +} + +/// Apply a parse error for an unparseable signal tool. +pub fn apply_signal_parse_error( + tool_name: &str, + messages: &mut Vec, + session: &mut Session, +) { + messages.push(Message { + role: Role::Tool, + content: format!( + "Error: Could not parse arguments for signal tool '{}'.", + tool_name + ), + tool_calls: vec![], + images: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); +} diff --git a/src/agent/tool_result.rs b/src/agent/tool_result.rs new file mode 100644 index 0000000..1b02d7d --- /dev/null +++ b/src/agent/tool_result.rs @@ -0,0 +1,176 @@ +// ── Shared Tool Result Types ─────────────────────────────────────────────── +// +// Generic tool result struct and batching logic shared between CLI and TUI loops. + +use tinyharness_lib::{image::ImageAttachment, provider::Message}; + +/// Result from executing a generic (non-signal) tool call. +/// +/// Used by both CLI and TUI agent loops to track tool execution results +/// before batching them into a single `Role::Tool` message. +pub struct GenericToolResult { + /// Formatted content for batching into the conversation message. + pub content: String, + /// If this was an auditable tool (run/write/edit), the tool name. + pub audit_tool_name: Option, + /// For auditable tools: the primary argument (command for "run", path for "write"/"edit"). + pub audit_detail: Option, + /// Duration of the tool execution in milliseconds. + pub duration_ms: u64, + /// Whether the tool returned an error. + pub is_error: bool, + /// Images loaded by the tool (e.g. when reading an image file). + pub images: Vec, +} + +/// Batch multiple generic tool results into a single `Role::Tool` message. +/// +/// For a single result, the content is used directly. For multiple results, +/// they're joined with separators and a count header. +/// +/// Returns `None` if the results list is empty. +pub fn batch_tool_results(results: Vec) -> Option { + if results.is_empty() { + return None; + } + + let batched_content = if results.len() == 1 { + results[0].content.clone() + } else { + format!( + "Multiple tool results ({} total):\n\n{}", + results.len(), + results + .iter() + .map(|r| r.content.as_str()) + .collect::>() + .join("\n\n---\n\n") + ) + }; + + // Collect images from all tool results + let all_images: Vec = results.into_iter().flat_map(|r| r.images).collect(); + + Some(Message { + role: tinyharness_lib::provider::Role::Tool, + content: format!( + "Tool results:\n{}\n\nUse these results to continue helping the user.", + batched_content + ), + tool_calls: vec![], + images: all_images, + }) +} + +/// Build audit detail for a tool call. +/// +/// Returns `(tool_name, detail)` for auditable tools, or `(None, None)` otherwise. +pub fn audit_info_for_tool( + call: &tinyharness_lib::provider::ToolCall, +) -> (Option, Option) { + match call.function.name.as_str() { + "run" => ( + Some("run".to_string()), + call.function + .arguments + .get("command") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + ), + "write" => ( + Some("write".to_string()), + call.function + .arguments + .get("path") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + ), + "edit" => ( + Some("edit".to_string()), + call.function + .arguments + .get("path") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + ), + _ => (None, None), + } +} + +/// Log a tool call to the audit log. +pub fn log_tool_audit( + session_id: &str, + call: &tinyharness_lib::provider::ToolCall, + auto_accepted: bool, + duration_ms: u64, + is_error: bool, +) { + let (audit_tool_name, audit_detail) = audit_info_for_tool(call); + if let Some(tool_name) = audit_tool_name { + let exit_code = if is_error { -1 } else { 0 }; + crate::commands::audit::log_command( + session_id, + &tool_name, + audit_detail.as_deref().unwrap_or(""), + exit_code, + auto_accepted, + duration_ms, + ); + } +} + +/// Compute a plain-text diff for a tool call (edit or write). +/// +/// Returns `None` for non-edit/write tools, or if the diff is empty. +/// Returns `Some(diff_string)` with the diff content otherwise. +/// +/// This is used for both: +/// - Confirmation previews (before the tool executes) +/// - Display content (after the tool executes, to show what changed) +pub fn compute_tool_diff(tool_name: &str, arguments: &serde_json::Value) -> Option { + match tool_name { + "edit" => { + let path = arguments.get("path").and_then(|v| v.as_str()).unwrap_or(""); + let old_str = arguments + .get("old_str") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let new_str = arguments + .get("new_str") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let diff = + tinyharness_ui::ui::diff::compute_edit_diff_from_path(path, old_str, new_str); + if diff.is_empty() { None } else { Some(diff) } + } + "write" => { + let path = arguments.get("path").and_then(|v| v.as_str()).unwrap_or(""); + let content = arguments + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let diff = tinyharness_ui::ui::diff::compute_write_diff_plain(path, content); + if diff.is_empty() { None } else { Some(diff) } + } + _ => None, + } +} + +/// Build the display content for a tool result, appending a diff for edit/write tools. +/// +/// If the tool is edit or write and the diff is non-empty, the diff is prepended +/// to the result. Otherwise, the result is returned as-is. +pub fn tool_display_content( + tool_name: &str, + arguments: &serde_json::Value, + result: &str, + is_error: bool, +) -> String { + if is_error { + return result.to_string(); + } + match compute_tool_diff(tool_name, arguments) { + Some(diff) if !diff.is_empty() => format!("{}\n{}", diff.trim_end(), result), + _ => result.to_string(), + } +} diff --git a/src/agent/tools.rs b/src/agent/tools.rs index 630d14f..6a736e6 100644 --- a/src/agent/tools.rs +++ b/src/agent/tools.rs @@ -5,7 +5,6 @@ use tokio::sync::Mutex; use tinyharness_lib::{ config::load_settings, image::ImageAttachment, - mode::AgentMode, provider::{Message, Role, ToolCall}, session::Session, tools::SignalEvent, @@ -13,27 +12,14 @@ use tinyharness_lib::{ }; use crate::commands::CommandContext; -use crate::commands::compact::execute_compact; use tinyharness_ui::style::*; use tinyharness_ui::ui::confirm::Confirmation; -use super::safety::is_safe_command; - -/// Result from executing a generic tool call. -struct GenericToolResult { - /// Formatted content for batching into the conversation message. - content: String, - /// If this was an auditable tool (run/write/edit), the tool name. - audit_tool_name: Option, - /// For auditable tools: the primary argument (command for "run", path for "write"/"edit"). - audit_detail: Option, - /// Duration of the tool execution in milliseconds. - duration_ms: u64, - /// Whether the tool returned an error. - is_error: bool, - /// Images loaded by the tool (e.g. when reading an image file). - images: Vec, -} +use super::confirm::ConfirmationDecision; +use super::signal::{self, SignalResult}; +use super::tool_result::{ + GenericToolResult, audit_info_for_tool, batch_tool_results, log_tool_audit, +}; /// Handle tool calls from the assistant response. /// @@ -94,53 +80,21 @@ pub async fn handle_tool_calls( if let Some(event) = tool_manager.parse_signal_event(&call.function.name, &call.function.arguments) { - match event { - SignalEvent::SwitchMode { mode } => { - handle_switch_mode(mode, ctx, messages, session, stdout)?; - } + // Question signal requires user interaction — handle it directly + // since the shared signal module can't do CLI I/O. + match &event { SignalEvent::Question { question, answers } => { - handle_question(&question, &answers, messages, session, stdout)?; + handle_question_cli(question, answers, messages, session, stdout)?; } - SignalEvent::AutoCompact { focus } => { - handle_auto_compact( - &focus, - messages, - session, - ctx, - stdout, - std::sync::Arc::clone(&provider), - ) - .await?; - } - SignalEvent::InvokeSkill { skill_name } => { - // Clone skill info to avoid borrowing ctx while calling it mutably - let skill_result = { - let registry = &ctx.skill_registry; - registry - .get(&skill_name) - .map(|s| (s.name.clone(), s.description.clone())) - }; - handle_invoke_skill( - &skill_name, - &skill_result, - ctx, - messages, - session, - stdout, - )?; + _ => { + let result = + signal::handle_signal_event(&event, messages, session, ctx, &provider) + .await; + render_signal_result_cli(&result, stdout)?; } } } else { - messages.push(Message { - role: Role::Tool, - content: format!( - "Error: Could not parse arguments for signal tool '{}'.", - call.function.name - ), - tool_calls: vec![], - images: vec![], - }); - session.append_message(messages.last().expect("just pushed a message")); + signal::apply_signal_parse_error(&call.function.name, messages, session); } continue; } @@ -153,16 +107,41 @@ pub async fn handle_tool_calls( let safe_commands = settings.get_safe_commands(); let denied_commands = settings.get_denied_commands(); - // Confirmation step - let (approved, auto_accepted) = confirm_tool_call( + // Confirmation step using shared decision logic + let decision = super::confirm::decide_tool_confirmation( call, - needs_confirmation, - auto_accept, - stdout, + *auto_accept, auto_accept_safe_commands, &safe_commands, &denied_commands, - )?; + needs_confirmation, + ); + + let (approved, auto_accepted) = match decision { + ConfirmationDecision::AutoApproved { auto_accepted: aa } => (true, aa), + ConfirmationDecision::NeedsConfirmation => { + match tinyharness_ui::ui::confirm::prompt_tool_confirmation(stdout, call)? { + Confirmation::No => { + stdout.write_all( + format!(" {}Skipped{}{}\n", ORANGE, RESET, BOLD).as_bytes(), + )?; + stdout.flush()?; + (false, false) + } + Confirmation::AutoAccept => { + *auto_accept = true; + writeln!( + stdout, + " {}Auto-accept enabled for the rest of this turn{}", + GREEN, RESET + )?; + (true, true) + } + Confirmation::Yes => (true, false), + } + } + ConfirmationDecision::Denied => (false, false), + }; if !approved { let args_summary = super::display::format_args_summary(&call.function.arguments); @@ -182,123 +161,200 @@ pub async fn handle_tool_calls( let result = execute_generic_tool(call, tool_manager, stdout, auto_accepted).await; // Log to audit if this was an auditable tool (run/write/edit) - if let Some(ref tool_name) = result.audit_tool_name { - let exit_code = if result.is_error { -1 } else { 0 }; - let session_id = session.id().to_string(); - crate::commands::audit::log_command( - &session_id, - tool_name, - result.audit_detail.as_deref().unwrap_or(""), - exit_code, - auto_accepted, - result.duration_ms, - ); - } + log_tool_audit( + session.id(), + call, + auto_accepted, + result.duration_ms, + result.is_error, + ); generic_tool_results.push(result); } // Batch all generic tool results into a single message - if !generic_tool_results.is_empty() { - let batched_content = if generic_tool_results.len() == 1 { - generic_tool_results[0].content.clone() - } else { - format!( - "Multiple tool results ({} total):\n\n{}", - generic_tool_results.len(), - generic_tool_results - .iter() - .map(|r| r.content.as_str()) - .collect::>() - .join("\n\n---\n\n") - ) - }; - - // Collect images from all tool results (e.g. read tool on image files) - let all_images: Vec = generic_tool_results - .into_iter() - .flat_map(|r| r.images) - .collect(); - - messages.push(Message { - role: Role::Tool, - content: format!( - "Tool results:\n{}\n\nUse these results to continue helping the user.", - batched_content - ), - tool_calls: vec![], - images: all_images, - }); + if let Some(msg) = batch_tool_results(generic_tool_results) { + messages.push(msg); session.append_message(messages.last().expect("just pushed a message")); } Ok(true) } -/// Determine whether a tool call is allowed to proceed. -/// Returns `(approved, auto_accepted)` where: -/// - `approved` is true if the call should proceed -/// - `auto_accepted` is true if it was auto-accepted (no "Executing" header needed) -fn confirm_tool_call( - call: &ToolCall, - needs_confirmation: bool, - auto_accept: &mut bool, +/// Handle the question signal in CLI mode: display options and prompt user. +fn handle_question_cli( + question: &str, + answers: &[String], + messages: &mut Vec, + session: &mut Session, stdout: &mut W, - auto_accept_safe_commands: bool, - safe_commands: &[String], - denied_commands: &[String], -) -> Result<(bool, bool), Box> { - if !needs_confirmation { - return Ok((true, false)); +) -> Result<(), Box> { + // Validate + if let Some(error) = signal::validate_question(question, answers) { + signal::apply_question_error(error, messages, session); + return Ok(()); } - // Check for auto-accept mode (but still validate run commands) - if *auto_accept { - if call.function.name == "run" { - if let Some(cmd_value) = call.function.arguments.get("command") - && let Some(cmd_str) = cmd_value.as_str() - && is_safe_command(cmd_str, safe_commands, denied_commands) - { - return Ok((true, true)); - } - // Unsafe run command - still require confirmation even in auto-accept mode + // Display the question and options + writeln!( + stdout, + "\n{} ┌─── {}❓ Question {}─────{}", + BOLD, CYAN, BOLD, RESET + )?; + writeln!(stdout, " │ {}{}{}", BOLD, question, RESET)?; + writeln!(stdout, " │")?; + for (i, answer) in answers.iter().enumerate() { + writeln!( + stdout, + " │ {}{}.{}) {} {}{}", + GREEN, + i + 1, + RESET, + BOLD, + answer, + RESET + )?; + } + writeln!(stdout, " │")?; + writeln!( + stdout, + " │ {}Enter anything else to skip with a custom answer{}", + DIM, RESET + )?; + writeln!(stdout, " └{}──────────────────────────────{}", BOLD, RESET)?; + + let answer_count = answers.len(); + write!( + stdout, + " {}Your choice (1-{} or type to skip): {}", + BOLD, answer_count, RESET + )?; + stdout.flush()?; + + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .expect("Failed to read line"); + let input = input.trim(); + + // Determine if the user selected an option or skipped with custom input + let (selected_answer, is_skip) = if input.is_empty() { + ("Skipped (no answer provided)".to_string(), true) + } else if let Ok(num) = input.parse::() { + if num >= 1 && num <= answer_count { + (answers[num - 1].clone(), false) } else { - // Other tools can be auto-accepted - return Ok((true, true)); + (format!("Skipped (user entered: {})", input), true) } - } + } else { + let input_lower = input.to_lowercase(); + match answers.iter().find(|a| a.to_lowercase() == input_lower) { + Some(a) => (a.clone(), false), + None => (input.to_string(), true), + } + }; - // Check for auto-accept of safe commands (configurable via settings) - if auto_accept_safe_commands - && call.function.name == "run" - && let Some(cmd_value) = call.function.arguments.get("command") - && let Some(cmd_str) = cmd_value.as_str() - && is_safe_command(cmd_str, safe_commands, denied_commands) - { - return Ok((true, true)); + if is_skip { + writeln!( + stdout, + " {}⊘{} Skipped with: {}{}{}", + ORANGE, RESET, BOLD, selected_answer, RESET + )?; + } else { + writeln!( + stdout, + " {}✓{} Selected: {}{}{}", + GREEN, RESET, BOLD, selected_answer, RESET + )?; } + stdout.flush()?; - match tinyharness_ui::ui::confirm::prompt_tool_confirmation(stdout, call)? { - Confirmation::No => { - stdout.write_all(format!(" {}Skipped{}{}\n", ORANGE, RESET, BOLD).as_bytes())?; + signal::apply_question_answer(question, &selected_answer, is_skip, messages, session); + Ok(()) +} + +/// Render a signal result to CLI stdout with ANSI styling. +fn render_signal_result_cli( + result: &SignalResult, + stdout: &mut W, +) -> Result<(), Box> { + match result { + SignalResult::SwitchMode { + old_mode, + new_mode, + already_in, + } => { + if *already_in { + writeln!( + stdout, + " {ORANGE}Already in '{new_mode}' mode. No change was made.{RESET}", + )?; + } else { + writeln!( + stdout, + "\n{}{}🔄 Mode switched: {} → {}{}", + BOLD, BLUE, old_mode, new_mode, RESET + )?; + } + stdout.flush()?; + } + SignalResult::AutoCompact { + focus: _, + success, + error, + } => { + if *success { + writeln!( + stdout, + "\n{} {}▶ auto_compact{} Compacting conversation history...", + DIM, CYAN, RESET + )?; + } else if let Some(e) = error { + writeln!(stdout, "\n{}⚠ Auto-compact failed: {}{}", RED, e, RESET)?; + } + stdout.flush()?; + } + SignalResult::InvokeSkill { + name, + description, + already_active, + found, + } => { + if *already_active { + writeln!( + stdout, + "\n{}⚠ Skill '{}' is already active.{}", + ORANGE, name, RESET + )?; + } else if *found { + writeln!( + stdout, + "\n{}{}⚡ Skill activated: {}{}{} — {}{}", + BOLD, CYAN, BOLD, name, RESET, description, RESET + )?; + } else { + writeln!( + stdout, + "\n{}⚠ Skill '{}' not found — it may have been removed.{}", + RED, name, RESET + )?; + } stdout.flush()?; - Ok((false, false)) } - Confirmation::AutoAccept => { - *auto_accept = true; + SignalResult::Question { .. } => { + // Question is handled separately via handle_question_cli + } + SignalResult::ParseError { tool_name } => { writeln!( stdout, - " {}Auto-accept enabled for the rest of this turn{}", - GREEN, RESET + "\n{}⚠ Could not parse arguments for signal tool '{}'.{}", + RED, tool_name, RESET )?; - Ok((true, true)) } - Confirmation::Yes => Ok((true, false)), } + Ok(()) } -/// Execute a generic tool call, display the result summary, and record the -/// tool result as a message in the conversation. /// Format a duration in milliseconds as a human-readable string. /// Under 1 second: "42ms", 1-59 seconds: "1.2s", 60+ seconds: "1m 23s" fn format_duration(ms: u64) -> String { @@ -494,33 +550,7 @@ async fn execute_generic_tool( stdout.flush().unwrap(); // Capture audit-relevant info before returning - let (audit_tool_name, audit_detail) = match call.function.name.as_str() { - "run" => ( - Some("run".to_string()), - call.function - .arguments - .get("command") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - ), - "write" => ( - Some("write".to_string()), - call.function - .arguments - .get("path") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - ), - "edit" => ( - Some("edit".to_string()), - call.function - .arguments - .get("path") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - ), - _ => (None, None), - }; + let (audit_tool_name, audit_detail) = audit_info_for_tool(call); let is_error = result.starts_with("Error:"); // For read tool on image files, load the image data for the model to view. @@ -560,334 +590,3 @@ async fn execute_generic_tool( images, } } - -/// Handle the switch_mode signal: update context and system prompt. -fn handle_switch_mode( - new_mode: AgentMode, - ctx: &mut CommandContext, - messages: &mut Vec, - session: &mut Session, - stdout: &mut W, -) -> Result<(), Box> { - let old_mode = ctx.current_mode; - match ctx.switch_mode(new_mode, messages) { - Ok(()) => { - session.set_mode(new_mode); - - writeln!( - stdout, - "\n{}{}🔄 Mode switched: {} → {}{}", - BOLD, BLUE, old_mode, new_mode, RESET - )?; - stdout.flush()?; - - messages.push(Message { - role: Role::Tool, - content: format!( - "SUCCESS: Mode switched from '{}' to '{}'. The assistant is now in {} mode and will use the appropriate toolset and behavior.", - old_mode, new_mode, new_mode - ), - tool_calls: vec![], images: vec![], - }); - session.append_message(messages.last().expect("just pushed a message")); - } - Err(msg) => { - writeln!(stdout, " {}{}{}", ORANGE, msg, RESET)?; - messages.push(Message { - role: Role::Tool, - content: format!("Already in '{}' mode. No change was made.", new_mode), - tool_calls: vec![], - images: vec![], - }); - session.append_message(messages.last().expect("just pushed a message")); - } - } - Ok(()) -} - -/// Handle the question signal: display options and prompt user for a choice. -fn handle_question( - question_text: &str, - answers: &[String], - messages: &mut Vec, - session: &mut Session, - stdout: &mut W, -) -> Result<(), Box> { - if question_text.is_empty() { - messages.push(Message { - role: Role::Tool, - content: "Error: 'question' argument is required for the question tool.".to_string(), - tool_calls: vec![], - images: vec![], - }); - session.append_message(messages.last().expect("just pushed a message")); - return Ok(()); - } - - if answers.is_empty() { - messages.push(Message { - role: Role::Tool, - content: - "Error: 'answers' argument must contain at least one option for the question tool." - .to_string(), - tool_calls: vec![], - images: vec![], - }); - session.append_message(messages.last().expect("just pushed a message")); - return Ok(()); - } - - // Display the question and options - writeln!( - stdout, - "\n{} ┌─── {}❓ Question {}─────{}", - BOLD, CYAN, BOLD, RESET - )?; - writeln!(stdout, " │ {}{}{}", BOLD, question_text, RESET)?; - writeln!(stdout, " │")?; - for (i, answer) in answers.iter().enumerate() { - writeln!( - stdout, - " │ {}{}.{}) {} {}{}", - GREEN, - i + 1, - RESET, - BOLD, - answer, - RESET - )?; - } - writeln!(stdout, " │")?; - writeln!( - stdout, - " │ {}Enter anything else to skip with a custom answer{}", - DIM, RESET - )?; - writeln!(stdout, " └{}──────────────────────────────{}", BOLD, RESET)?; - - let answer_count = answers.len(); - write!( - stdout, - " {}Your choice (1-{} or type to skip): {}", - BOLD, answer_count, RESET - )?; - stdout.flush()?; - - let mut input = String::new(); - std::io::stdin() - .read_line(&mut input) - .expect("Failed to read line"); - let input = input.trim(); - - // Determine if the user selected an option or skipped with custom input - let (selected_answer, is_skip) = if input.is_empty() { - ("Skipped (no answer provided)".to_string(), true) - } else if let Ok(num) = input.parse::() { - if num >= 1 && num <= answer_count { - (answers[num - 1].clone(), false) - } else { - // Out-of-range number: treat as a skip with free-form input - (format!("Skipped (user entered: {})", input), true) - } - } else { - let input_lower = input.to_lowercase(); - match answers.iter().find(|a| a.to_lowercase() == input_lower) { - Some(a) => (a.clone(), false), - None => (input.to_string(), true), // Free-form answer (skip) - } - }; - - if is_skip { - writeln!( - stdout, - " {}⊘{} Skipped with: {}{}{}", - ORANGE, RESET, BOLD, selected_answer, RESET - )?; - } else { - writeln!( - stdout, - " {}✓{} Selected: {}{}{}", - GREEN, RESET, BOLD, selected_answer, RESET - )?; - } - stdout.flush()?; - - let result_content = if is_skip { - format!( - "User skipped the provided options for the question '{}' and entered a custom answer: '{}'.\n\nUse this answer to continue helping the user.", - question_text, selected_answer - ) - } else { - format!( - "User answered the question '{}' with: '{}'.\n\nUse this answer to continue helping the user.", - question_text, selected_answer - ) - }; - - messages.push(Message { - role: Role::Tool, - content: result_content, - tool_calls: vec![], - images: vec![], - }); - session.append_message(messages.last().expect("just pushed a message")); - Ok(()) -} - -/// Handle the auto_compact signal: trigger conversation compaction. -async fn handle_auto_compact( - focus: &str, - messages: &mut Vec, - session: &mut Session, - ctx: &mut CommandContext, - stdout: &mut W, - provider: std::sync::Arc>, -) -> Result<(), Box> { - writeln!( - stdout, - "\n{} {}▶ auto_compact{} Compacting conversation history...", - DIM, CYAN, RESET - )?; - stdout.flush()?; - - let mut provider_guard = provider.lock().await; - - match execute_compact(&mut ctx.output, &mut *provider_guard, messages, focus).await { - Ok(token_usage) => { - // Propagate token usage the same way /compact does: - // 1) store in CommandContext so the agent loop updates the display - // 2) persist in session metadata so it survives restarts - if let Some(usage) = token_usage.clone() { - ctx.compaction_token_usage = Some(usage.clone()); - session.set_token_usage(usage); - } - messages.push(Message { - role: Role::Tool, - content: format!( - "Conversation compacted successfully. Focus: '{}'.", - if focus.is_empty() { - "general summary" - } else { - focus - } - ), - tool_calls: vec![], - images: vec![], - }); - session.append_message(messages.last().expect("just pushed a message")); - } - Err(e) => { - messages.push(Message { - role: Role::Tool, - content: format!( - "Auto-compact failed: {}. The conversation was not modified.", - e - ), - tool_calls: vec![], - images: vec![], - }); - session.append_message(messages.last().expect("just pushed a message")); - } - } - - Ok(()) -} - -/// Handle the invoke_skill signal: activate a skill by name. -/// -/// When the model invokes a skill, we look it up in the skill registry, -/// display a confirmation message, track it as active, and refresh the -/// system prompt to include the skill's instructions. -/// -/// `skill_result` is `Some((name, description))` if the skill was found, -/// or `None` if not found. This avoids borrowing the context while also -/// calling it mutably. -fn handle_invoke_skill( - skill_name: &str, - skill_result: &Option<(String, String)>, - ctx: &mut CommandContext, - messages: &mut Vec, - session: &mut Session, - stdout: &mut W, -) -> Result<(), Box> { - match skill_result { - Some((name, description)) => { - // Prevent duplicate activation - if ctx - .active_skills - .iter() - .any(|s| s.eq_ignore_ascii_case(name)) - { - writeln!( - stdout, - "\n{}⚠ Skill '{}' is already active.{}", - ORANGE, name, RESET - )?; - stdout.flush()?; - - messages.push(Message { - role: Role::Tool, - content: format!( - "Skill '{}' is already active. Its instructions are already in effect.", - name - ), - tool_calls: vec![], - images: vec![], - }); - session.append_message(messages.last().expect("just pushed a message")); - return Ok(()); - } - - writeln!( - stdout, - "\n{}{}⚡ Skill activated: {}{}{} — {}{}", - BOLD, CYAN, BOLD, name, RESET, description, RESET - )?; - stdout.flush()?; - - // Track the active skill - ctx.active_skills.push(name.clone()); - - messages.push(Message { - role: Role::Tool, - content: format!( - "SUCCESS: Skill '{}' activated. The skill's instructions are now in effect.", - name - ), - tool_calls: vec![], - images: vec![], - }); - session.append_message(messages.last().expect("just pushed a message")); - - // Refresh system prompt to include the active skill - ctx.refresh_system_prompt(messages); - } - None => { - let available = ctx - .skill_registry - .skills - .iter() - .map(|s| s.name.clone()) - .collect::>() - .join(", "); - writeln!( - stdout, - "\n{}⚠ Skill '{}' not found.{} Use {}/skills{} to list available skills.", - ORANGE, skill_name, RESET, BOLD, RESET - )?; - stdout.flush()?; - - messages.push(Message { - role: Role::Tool, - content: format!( - "Error: Skill '{}' not found. Available skills: {}. Use /skills to list them.", - skill_name, available - ), - tool_calls: vec![], - images: vec![], - }); - session.append_message(messages.last().expect("just pushed a message")); - } - } - Ok(()) -} diff --git a/src/agent/tui_loop.rs b/src/agent/tui_loop.rs new file mode 100644 index 0000000..d36b791 --- /dev/null +++ b/src/agent/tui_loop.rs @@ -0,0 +1,856 @@ +// ── TUI Agent Loop ───────────────────────────────────────────────────────────── +// +// Background agent loop for TUI mode. Communicates with the TUI via channels: +// - Receives `TuiUserAction` (user messages, confirmations) from the TUI +// - Sends `TuiAgentEvent` (streaming text, tool calls, status) to the TUI +// +// This is a simplified version of the CLI agent loop that: +// - Reads user input from a channel instead of rustyline +// - Sends UI updates to a channel instead of writing to stdout +// - Auto-approves read-only tool calls (no interactive confirmation) +// - Handles slash commands via the command registry + +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, +}; + +use tokio::sync::Mutex; + +use tinyharness_lib::{ + config::load_settings, + provider::{Message, Provider, Role}, + session::Session, + token::{ContextWindowSize, check_context_warning}, + tools::{SignalEvent, ToolManager}, +}; +use tinyharness_ui::output::Output; +use tinyharness_ui::tui::{TuiAgentEvent, TuiUserAction}; + +use crate::commands::{CommandContext, CommandResult, build_registry}; + +use super::command_result; +use super::confirm::ConfirmationDecision; +use super::display::format_args_summary_tui; +use super::signal::{self, SignalResult}; +use super::tool_result::{ + GenericToolResult, audit_info_for_tool, batch_tool_results, compute_tool_diff, log_tool_audit, + tool_display_content, +}; + +/// Strip common ANSI SGR escape sequences from a string. +/// +/// In TUI mode, command output contains ANSI color/style codes meant for a +/// terminal. Since the TUI renders its own styling, we strip these codes before +/// sending the output as a system message. +fn strip_ansi_sgr(s: &str) -> String { + // Strip CSI sequences: ESC [ ... m (SGR) and ESC [ ... letter (other CSI) + let re = regex::Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").unwrap(); + re.replace_all(s, "").to_string() +} + +/// Check context window usage and send a warning event if thresholds are exceeded. +fn send_context_warning_if_needed( + token_count: u32, + context_size: ContextWindowSize, + agent_event_tx: &mpsc::Sender, +) { + if let Some(warning) = check_context_warning(token_count, context_size) { + let _ = agent_event_tx.send(TuiAgentEvent::ContextWarning { + percentage: warning.percentage(), + critical: warning.is_critical(), + }); + } +} + +/// A writer that captures output into a shared buffer, allowing the captured +/// text to be retrieved later. Used in TUI mode to intercept command output +/// that would otherwise go to stdout (which is invisible in alternate-screen mode). +#[derive(Clone)] +struct CaptureWriter { + buf: Arc>>, +} + +impl CaptureWriter { + fn new() -> Self { + Self { + buf: Arc::new(std::sync::Mutex::new(Vec::new())), + } + } + + fn take_output(&self) -> Vec { + let mut buf = self.buf.lock().unwrap(); + std::mem::take(&mut *buf) + } +} + +impl std::io::Write for CaptureWriter { + fn write(&mut self, data: &[u8]) -> std::io::Result { + let mut buf = self.buf.lock().unwrap(); + buf.extend_from_slice(data); + Ok(data.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +// Safety: CaptureWriter uses Arc>> which is safe to send across threads. +// The Mutex> is always locked for short durations (single write/flush calls). +unsafe impl Send for CaptureWriter {} + +/// Execute a slash command with output captured and redirected to the TUI. +/// +/// This replaces `ctx.output` with a buffer-backed writer, dispatches the +/// command, then sends whatever the command wrote (with ANSI codes stripped) +/// as `TuiAgentEvent::SystemMessage` events. This ensures slash command output +/// is visible in the TUI conversation pane instead of being written to the +/// raw terminal (which would be invisible or garbled in alternate-screen mode). +#[allow(clippy::too_many_arguments)] +async fn dispatch_command_to_tui( + input: &str, + ctx: &mut CommandContext, + messages: &mut Vec, + registry: &crate::commands::CommandRegistry, + agent_event_tx: &mpsc::Sender, +) -> Result { + // Swap ctx.output with a buffer-backed writer to capture command output + let capture = CaptureWriter::new(); + let captured_output = Output::new(Box::new(capture.clone())); + let original_output = std::mem::replace(&mut ctx.output, captured_output); + + let result = registry.dispatch(input, ctx, messages).await; + + // Replace ctx.output back and extract the captured bytes + let _restored = std::mem::replace(&mut ctx.output, original_output); + + let output_bytes = capture.take_output(); + let output_text = String::from_utf8_lossy(&output_bytes); + let stripped = strip_ansi_sgr(&output_text); + + if !stripped.is_empty() { + let _ = agent_event_tx.send(TuiAgentEvent::SystemMessage(stripped.to_string())); + } + + result +} + +/// Render a signal result to the TUI via channel events. +fn render_signal_result_tui(result: &SignalResult, agent_event_tx: &mpsc::Sender) { + match result { + SignalResult::SwitchMode { + old_mode: _, + new_mode, + already_in, + } => { + if *already_in { + let _ = agent_event_tx.send(TuiAgentEvent::SystemMessage(format!( + "Already in '{}' mode. No change was made.", + new_mode + ))); + } else { + let _ = agent_event_tx.send(TuiAgentEvent::ModeChanged(new_mode.to_string())); + } + } + SignalResult::AutoCompact { + focus: _, + success, + error, + } => { + if *success { + let _ = agent_event_tx.send(TuiAgentEvent::SystemMessage( + "Conversation compacted successfully.".to_string(), + )); + } else if let Some(e) = error { + let _ = agent_event_tx + .send(TuiAgentEvent::Error(format!("Auto-compact failed: {}", e))); + } + } + SignalResult::InvokeSkill { + name, + description, + already_active, + found, + } => { + if *already_active { + let _ = agent_event_tx.send(TuiAgentEvent::SystemMessage(format!( + "Skill '{}' is already active", + name + ))); + } else if *found { + let _ = agent_event_tx.send(TuiAgentEvent::SystemMessage(format!( + "Skill activated: {} — {}", + name, description + ))); + } else { + let _ = agent_event_tx + .send(TuiAgentEvent::Error(format!("Skill '{}' not found", name))); + } + } + SignalResult::Question { .. } => { + // Question is handled separately in the TUI loop + } + SignalResult::ParseError { tool_name } => { + let _ = agent_event_tx.send(TuiAgentEvent::Error(format!( + "Could not parse arguments for signal tool '{}'", + tool_name + ))); + } + } +} + +/// Background agent loop for TUI mode. +/// +/// This function runs in a background tokio task. It: +/// 1. Waits for user messages from the TUI input bar +/// 2. Processes slash commands locally +/// 3. Sends messages to the LLM provider +/// 4. Streams responses back to the TUI +/// 5. Handles tool calls (auto-approving read-only ones) +/// +/// It communicates with the TUI via `mpsc` channels. +#[allow(clippy::too_many_arguments)] +pub async fn run_tui_agent_loop( + provider: Arc>, + tool_manager: ToolManager, + mut messages: Vec, + mut ctx: CommandContext, + mut session: Session, + interrupted: Arc, + initial_prompt: Option, + mut user_action_rx: mpsc::Receiver, + agent_event_tx: mpsc::Sender, +) -> Result<(), String> { + let registry = build_registry(); + + let settings = load_settings(); + let context_size = settings + .context_limit + .map(ContextWindowSize::Custom) + .unwrap_or_else(ContextWindowSize::default_size); + + let mut last_known_token_usage: Option = + session.meta().token_usage.clone(); + + // Send initial state to the TUI + let model_name = { + let p = provider.lock().await; + p.current_model().unwrap_or_else(|| "unknown".to_string()) + }; + let _ = agent_event_tx.send(TuiAgentEvent::ModelChanged(model_name)); + let _ = agent_event_tx.send(TuiAgentEvent::ModeChanged(ctx.current_mode.to_string())); + + if let Some(ref usage) = last_known_token_usage { + let _ = agent_event_tx.send(TuiAgentEvent::TokenUpdate { + count: usage.total_tokens as u64, + limit: Some(context_size.tokens() as u64), + }); + send_context_warning_if_needed(usage.total_tokens, context_size, &agent_event_tx); + } + + // Handle initial prompt (from --prompt flag) + if let Some(prompt) = initial_prompt + && !prompt.trim().is_empty() + { + // Process the initial prompt as if the user sent it + process_user_message( + &prompt, + &mut messages, + &mut ctx, + &mut session, + &provider, + &tool_manager, + ®istry, + &interrupted, + &agent_event_tx, + &mut user_action_rx, + &mut last_known_token_usage, + context_size, + ) + .await; + } + + // Main loop: wait for user actions from the TUI + loop { + let action = match user_action_rx.recv() { + Ok(action) => action, + Err(_) => { + // Channel closed — TUI exited + let _ = agent_event_tx.send(TuiAgentEvent::Done); + break; + } + }; + + match action { + TuiUserAction::Quit => { + let _ = agent_event_tx.send(TuiAgentEvent::Done); + break; + } + TuiUserAction::SendMessage(text) => { + if text.trim().is_empty() { + continue; + } + + // Check if it's a slash command + if text.starts_with('/') { + process_slash_command( + &text, + &mut messages, + &mut ctx, + &mut session, + &provider, + ®istry, + &interrupted, + &agent_event_tx, + &mut last_known_token_usage, + context_size, + ) + .await; + + if ctx.exit_requested { + let _ = agent_event_tx.send(TuiAgentEvent::Done); + break; + } + continue; + } + + process_user_message( + &text, + &mut messages, + &mut ctx, + &mut session, + &provider, + &tool_manager, + ®istry, + &interrupted, + &agent_event_tx, + &mut user_action_rx, + &mut last_known_token_usage, + context_size, + ) + .await; + } + TuiUserAction::ConfirmResponse { .. } => { + // Confirmation responses are handled inline during tool execution + // This branch shouldn't normally be reached in the main loop + // since confirmations are handled synchronously in tool processing + } + TuiUserAction::QuestionAnswer(_) => { + // Same as above — handled inline during question signal processing + } + TuiUserAction::Interrupt => { + interrupted.store(true, Ordering::SeqCst); + } + } + } + + // Flush session on exit + session.flush(); + + Ok(()) +} + +/// Process a slash command in TUI mode. +#[allow(clippy::too_many_arguments)] +async fn process_slash_command( + input: &str, + messages: &mut Vec, + ctx: &mut CommandContext, + session: &mut Session, + _provider: &Arc>, + registry: &crate::commands::CommandRegistry, + _interrupted: &Arc, + agent_event_tx: &mpsc::Sender, + last_known_token_usage: &mut Option, + _context_size: ContextWindowSize, +) { + match dispatch_command_to_tui(input, ctx, messages, registry, agent_event_tx).await { + Ok(CommandResult::Ok) => { + // Update token usage from compaction side-channel + if let Some(usage) = command_result::apply_ok(ctx, session) { + *last_known_token_usage = Some(usage); + } + // Send mode update if it changed + let _ = agent_event_tx.send(TuiAgentEvent::ModeChanged(ctx.current_mode.to_string())); + } + Ok(CommandResult::SwitchSession(id_prefix)) => { + let info = command_result::apply_switch_session(&id_prefix, ctx, messages, session); + if info.is_error { + let _ = agent_event_tx.send(TuiAgentEvent::Error(info.description)); + } else { + let _ = agent_event_tx.send(TuiAgentEvent::SystemMessage(info.description)); + *last_known_token_usage = session.meta().token_usage.clone(); + let _ = + agent_event_tx.send(TuiAgentEvent::ModeChanged(ctx.current_mode.to_string())); + } + } + Ok(CommandResult::RenameSession(new_name)) => { + let info = command_result::apply_rename_session(&new_name, session); + let _ = agent_event_tx.send(TuiAgentEvent::SystemMessage(info.description)); + } + Ok(CommandResult::Init(result)) => { + let info = command_result::apply_init(&result, ctx, messages); + let _ = agent_event_tx.send(TuiAgentEvent::SystemMessage(info.description)); + } + Ok(CommandResult::SkillUse(skill_name)) => { + let info = command_result::apply_skill_use(&skill_name, ctx, messages, session); + if info.is_error { + let _ = agent_event_tx.send(TuiAgentEvent::Error(info.description)); + } else { + let _ = agent_event_tx.send(TuiAgentEvent::SystemMessage(info.description)); + } + } + Ok(CommandResult::SkillUnload(skill_name)) => { + let info = command_result::apply_skill_unload(&skill_name, ctx, messages, session); + let _ = agent_event_tx.send(TuiAgentEvent::SystemMessage(info.description)); + } + Err(e) => { + let _ = agent_event_tx.send(TuiAgentEvent::Error(e)); + } + } +} + +/// Process a user message in TUI mode: send to LLM, stream response, handle tools. +#[allow(clippy::too_many_arguments)] +async fn process_user_message( + text: &str, + messages: &mut Vec, + ctx: &mut CommandContext, + session: &mut Session, + provider: &Arc>, + tool_manager: &ToolManager, + _registry: &crate::commands::CommandRegistry, + interrupted: &Arc, + agent_event_tx: &mpsc::Sender, + user_action_rx: &mut mpsc::Receiver, + last_known_token_usage: &mut Option, + context_size: ContextWindowSize, +) { + let pending_images = std::mem::take(&mut ctx.pending_images); + messages.push(Message { + role: Role::User, + content: text.to_string(), + tool_calls: vec![], + images: pending_images, + }); + + // Auto-save: user message + session.append_message(messages.last().expect("just pushed a message")); + + let mut auto_accept = false; + + loop { + // Clear interrupt flag for this turn + interrupted.store(false, Ordering::SeqCst); + + // Filter tools based on current mode + let tools = tool_manager.tools_for_mode(ctx.current_mode); + + // Call the provider + let mut recv = { + let mut p = provider.lock().await; + match p.chat(messages.clone(), tools).await { + Ok(recv) => recv, + Err(e) => { + let _ = agent_event_tx.send(TuiAgentEvent::Error(format!( + "Failed to start request: {}", + e + ))); + // Remove the user message we just added + messages.pop(); + return; + } + } + }; + + let mut response_content = String::new(); + let mut tool_calls: Vec = Vec::new(); + let mut received_done = false; + let mut is_error = false; + + // Notify TUI that streaming has started + let _ = agent_event_tx.send(TuiAgentEvent::StreamingStarted); + + loop { + tokio::select! { + msg = recv.recv() => { + match msg { + Some(msg) => { + if !msg.message.tool_calls.is_empty() { + tool_calls = msg.message.tool_calls.clone(); + } + + if msg.done { + received_done = true; + if let Some(ref usage) = msg.usage { + *last_known_token_usage = Some(usage.clone()); + session.set_token_usage(usage.clone()); + let _ = agent_event_tx.send(TuiAgentEvent::TokenUpdate { + count: usage.total_tokens as u64, + limit: Some(context_size.tokens() as u64), + }); + send_context_warning_if_needed( + usage.total_tokens, + context_size, + agent_event_tx, + ); + } + } + + if msg.is_error { + is_error = true; + } + + // Send thinking content if present + if let Some(ref thinking) = msg.message.thinking + && !thinking.is_empty() + && ctx.show_thinking + { + let _ = agent_event_tx.send(TuiAgentEvent::StreamingThinking( + thinking.clone(), + )); + } + + // Send content chunks + if !msg.message.content.is_empty() { + response_content.push_str(&msg.message.content); + let _ = agent_event_tx.send(TuiAgentEvent::StreamingText( + msg.message.content.clone(), + )); + } + + if received_done { + break; + } + } + None => { + // Channel closed + break; + } + } + } + _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => { + // Check for interrupt + if interrupted.load(Ordering::SeqCst) { + interrupted.store(false, Ordering::SeqCst); + let _ = agent_event_tx.send(TuiAgentEvent::SystemMessage( + "Generation interrupted by user.".to_string(), + )); + + // Save partial response + if !response_content.is_empty() { + messages.push(Message { + role: Role::Assistant, + content: response_content, + tool_calls: vec![], + images: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); + } else { + messages.pop(); + } + + let _ = agent_event_tx.send(TuiAgentEvent::StreamingDone); + return; + } + } + } + } + + // Finish streaming + let _ = agent_event_tx.send(TuiAgentEvent::StreamingDone); + + if !received_done || is_error { + let error_detail = if is_error { + response_content.clone() + } else { + "Provider request was interrupted.".to_string() + }; + let _ = agent_event_tx.send(TuiAgentEvent::Error(error_detail)); + messages.pop(); + return; + } + + // Handle tool calls + if !tool_calls.is_empty() { + // Push the assistant message with tool calls + messages.push(Message { + role: Role::Assistant, + content: response_content.clone(), + tool_calls: tool_calls.clone(), + images: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); + + // Process tool calls + let has_more_tools = handle_tui_tool_calls( + &tool_calls, + messages, + tool_manager, + ctx, + session, + provider, + interrupted, + agent_event_tx, + user_action_rx, + &mut auto_accept, + ) + .await; + + if has_more_tools { + continue; // Loop back to call provider again with tool results + } + + // No more tool calls — we're done + return; + } + + // No tool calls — push the final assistant message + messages.push(Message { + role: Role::Assistant, + content: response_content, + tool_calls: vec![], + images: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); + return; + } +} + +/// Handle tool calls in TUI mode. +/// +/// Returns `true` if tool results were added (the caller should loop back +/// to call the provider again), or `false` if there were no tool calls. +#[allow(clippy::too_many_arguments)] +async fn handle_tui_tool_calls( + tool_calls: &[tinyharness_lib::provider::ToolCall], + messages: &mut Vec, + tool_manager: &ToolManager, + ctx: &mut CommandContext, + session: &mut Session, + provider: &Arc>, + interrupted: &Arc, + agent_event_tx: &mpsc::Sender, + user_action_rx: &mut mpsc::Receiver, + auto_accept: &mut bool, +) -> bool { + if tool_calls.is_empty() { + return false; + } + + let tool_count = tool_calls.len(); + let _ = agent_event_tx.send(TuiAgentEvent::SystemMessage(format!( + "{} tool call(s)", + tool_count + ))); + + let settings = load_settings(); + let auto_accept_safe_commands = settings.auto_accept_safe_commands; + let safe_commands = settings.get_safe_commands(); + let denied_commands = settings.get_denied_commands(); + + // Collect generic tool results + let mut generic_tool_results: Vec = Vec::new(); + + for call in tool_calls { + // Check for interrupt + if interrupted.load(Ordering::SeqCst) { + interrupted.store(false, Ordering::SeqCst); + let _ = agent_event_tx.send(TuiAgentEvent::SystemMessage( + "Tool execution interrupted by user.".to_string(), + )); + return true; + } + + // Signal tools handled specially + if tool_manager.is_signal_tool(&call.function.name) { + if let Some(event) = + tool_manager.parse_signal_event(&call.function.name, &call.function.arguments) + { + // Question signal requires user interaction — handle via TUI channel + match &event { + SignalEvent::Question { question, answers } => { + // Validate question + if let Some(error) = signal::validate_question(question, answers) { + signal::apply_question_error(error, messages, session); + continue; + } + + // Send the question to the TUI for user interaction + let _ = agent_event_tx.send(TuiAgentEvent::Question { + question: question.clone(), + answers: answers.clone(), + }); + + // Wait for the user's answer + let answer = loop { + match user_action_rx.recv() { + Ok(TuiUserAction::QuestionAnswer(ans)) => { + break ans; + } + Ok(TuiUserAction::Interrupt) => { + interrupted.store(true, Ordering::SeqCst); + break "Skipped (interrupted)".to_string(); + } + Ok(TuiUserAction::Quit) => { + let _ = agent_event_tx.send(TuiAgentEvent::Done); + return false; + } + Ok(_) => { + // Ignore other actions while waiting for answer + continue; + } + Err(_) => { + // Channel closed + break "Skipped (channel closed)".to_string(); + } + } + }; + + let is_skip = answer.starts_with("Skipped"); + signal::apply_question_answer( + question, &answer, is_skip, messages, session, + ); + } + _ => { + let result = + signal::handle_signal_event(&event, messages, session, ctx, provider) + .await; + render_signal_result_tui(&result, agent_event_tx); + } + } + } else { + signal::apply_signal_parse_error(&call.function.name, messages, session); + } + continue; + } + + let needs_confirmation = tool_manager.needs_approval(&call.function.name); + + // Use shared decision logic for confirmation + let decision = super::confirm::decide_tool_confirmation( + call, + *auto_accept, + auto_accept_safe_commands, + &safe_commands, + &denied_commands, + needs_confirmation, + ); + + let (approved, auto_accepted) = match decision { + ConfirmationDecision::AutoApproved { auto_accepted: aa } => (true, aa), + ConfirmationDecision::NeedsConfirmation => { + // Ask the user via the TUI confirmation flow + let args_summary = + format_args_summary_tui(&call.function.name, &call.function.arguments); + let diff_preview = compute_tool_diff(&call.function.name, &call.function.arguments); + let _ = agent_event_tx.send(TuiAgentEvent::ConfirmTool { + name: call.function.name.clone(), + args_summary: args_summary.clone(), + needs_approval: true, + diff_preview, + }); + // Wait for user response + loop { + match user_action_rx.recv() { + Ok(TuiUserAction::ConfirmResponse { + approved, + auto_accept: resp_auto_accept, + }) => { + if resp_auto_accept { + *auto_accept = true; + } + break (approved, resp_auto_accept); + } + Ok(TuiUserAction::Interrupt) => { + interrupted.store(true, Ordering::SeqCst); + break (false, false); + } + Ok(TuiUserAction::Quit) => { + let _ = agent_event_tx.send(TuiAgentEvent::Done); + return false; + } + Ok(_) => { + // Ignore other actions while waiting for confirmation + continue; + } + Err(_) => { + // Channel closed + return false; + } + } + } + } + ConfirmationDecision::Denied => (false, false), + }; + + if !approved { + let args_summary = + format_args_summary_tui(&call.function.name, &call.function.arguments); + messages.push(Message { + role: Role::System, + content: format!( + "The user denied the '{}' tool call with arguments: {}", + call.function.name, args_summary + ), + tool_calls: vec![], + images: vec![], + }); + session.append_message(messages.last().expect("just pushed a message")); + continue; + } + + // Notify TUI about tool call + let args_summary = format_args_summary_tui(&call.function.name, &call.function.arguments); + let _ = agent_event_tx.send(TuiAgentEvent::ToolCall { + name: call.function.name.clone(), + args_summary: args_summary.clone(), + }); + + // Execute the tool + let start_time = std::time::Instant::now(); + let result = tool_manager + .execute_tool_call(&call.function.name, &call.function.arguments) + .await; + let duration_ms = start_time.elapsed().as_millis() as u64; + + let is_error = result.starts_with("Error:"); + + let display_content = tool_display_content( + &call.function.name, + &call.function.arguments, + &result, + is_error, + ); + + let _ = agent_event_tx.send(TuiAgentEvent::ToolResult { + name: call.function.name.clone(), + content: display_content, + is_error, + }); + + // Log to audit if this was an auditable tool + log_tool_audit(session.id(), call, auto_accepted, duration_ms, is_error); + + // Collect result for batching + let (audit_tool_name, audit_detail) = audit_info_for_tool(call); + generic_tool_results.push(GenericToolResult { + content: format!("### {} Tool Result\n\n{}", call.function.name, result), + audit_tool_name, + audit_detail, + duration_ms, + is_error, + images: vec![], + }); + } + + // Batch all generic tool results into a single message + if let Some(msg) = batch_tool_results(generic_tool_results) { + messages.push(msg); + session.append_message(messages.last().expect("just pushed a message")); + } + + true +} diff --git a/src/commands/compact.rs b/src/commands/compact.rs index e520356..fc11ff1 100644 --- a/src/commands/compact.rs +++ b/src/commands/compact.rs @@ -18,9 +18,8 @@ async_command!( let focus = raw_arg.unwrap_or("").to_string(); let provider = ctx.provider.clone(); async move { - let mut out = Output::stdout(); let mut p = provider.lock().await; - match execute_compact(&mut out, &mut *p, messages, &focus).await { + match execute_compact(&mut ctx.output, &mut *p, messages, &focus).await { Ok(tokens) => { ctx.compaction_token_usage = tokens; Ok(CommandResult::Ok) diff --git a/src/commands/config_settings.rs b/src/commands/config_settings.rs index 5200c91..d9a1a2b 100644 --- a/src/commands/config_settings.rs +++ b/src/commands/config_settings.rs @@ -18,11 +18,10 @@ async_command!( let arg = raw_arg.unwrap_or("").to_string(); let provider = ctx.provider.clone(); async move { - let mut out = Output::stdout(); if arg.is_empty() { let settings = load_settings(); let _ = writeln!( - out, + ctx.output, "{BOLD}Current timeout: {BLUE}{}s{RESET}", settings.ollama_timeout_secs, ); @@ -36,7 +35,7 @@ async_command!( save_settings(&settings); let mut p = provider.lock().await; p.set_timeout(secs); - let _ = writeln!(out, "{BOLD}Timeout set to {BLUE}{secs}s.{RESET}",); + let _ = writeln!(ctx.output, "{BOLD}Timeout set to {BLUE}{secs}s.{RESET}",); Ok(CommandResult::Ok) } Ok(_) => Err("Timeout must be a positive number of seconds.".to_string()), @@ -60,11 +59,10 @@ async_command!( let arg = raw_arg.unwrap_or("").to_string(); let provider = ctx.provider.clone(); async move { - let mut out = Output::stdout(); if arg.is_empty() { let settings = load_settings(); let _ = writeln!( - out, + ctx.output, "{BOLD}Current max retries: {BLUE}{}{RESET}", settings.ollama_max_retries, ); @@ -78,7 +76,7 @@ async_command!( save_settings(&settings); let mut p = provider.lock().await; p.set_retries(count); - let _ = writeln!(out, "{BOLD}Max retries set to {BLUE}{count}.{RESET}",); + let _ = writeln!(ctx.output, "{BOLD}Max retries set to {BLUE}{count}.{RESET}",); Ok(CommandResult::Ok) } Err(_) => Err(format!( @@ -200,11 +198,10 @@ async_command!( let arg = raw_arg.unwrap_or("").to_string(); let provider = ctx.provider.clone(); async move { - let mut out = Output::stdout(); if arg.is_empty() { let settings = load_settings(); let _ = writeln!( - out, + ctx.output, "{BOLD}Current think level: {BLUE}{}{RESET}", settings.ollama_think_type, ); @@ -223,7 +220,10 @@ async_command!( let mut p = provider.lock().await; p.set_think_type(think_type); - let _ = writeln!(out, "{BOLD}Think level set to {BLUE}{think_type}.{RESET}",); + let _ = writeln!( + ctx.output, + "{BOLD}Think level set to {BLUE}{think_type}.{RESET}", + ); Ok(CommandResult::Ok) } diff --git a/src/commands/init.rs b/src/commands/init.rs index b355a9d..7c026cb 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -27,9 +27,8 @@ async_command!( let provider = ctx.provider.clone(); let workspace_ctx = ctx.workspace_ctx.clone(); async move { - let mut out = Output::stdout(); let mut p = provider.lock().await; - let result = execute_init(&mut out, &mut *p, &workspace_ctx, messages).await?; + let result = execute_init(&mut ctx.output, &mut *p, &workspace_ctx, messages).await?; Ok(CommandResult::Init(result)) } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index eed06ef..7875cab 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -405,6 +405,29 @@ pub fn build_registry() -> CommandRegistry { // Exit alias: /quit → /exit reg.register_alias("/quit", "/exit", None, "Exit the application"); + // ── Subcommand completions for tab-completion ───────────────────────── + + reg.register_subcommands( + "/command", + vec![ + "add", + "deny", + "help", + "list", + "rm", + "reset", + "resetdeny", + "undeny", + ], + ); + reg.register_subcommands("/session", vec!["delete"]); + reg.register_subcommands("/mode", vec!["agent", "casual", "planning", "research"]); + reg.register_subcommands("/settings", vec!["all"]); + reg.register_subcommands("/autoaccept", vec!["off", "on"]); + reg.register_subcommands("/apikey", vec!["clear"]); + reg.register_subcommands("/showthink", vec!["off", "on"]); + reg.register_subcommands("/think", vec!["high", "low", "medium", "off"]); + // ── Help (registered last, after descriptions are frozen) ───────────── reg.freeze_descriptions(); diff --git a/src/commands/models.rs b/src/commands/models.rs index d04a27e..0fcedf1 100644 --- a/src/commands/models.rs +++ b/src/commands/models.rs @@ -17,21 +17,20 @@ async_command!( let name = raw_arg.unwrap_or("").to_string(); let provider = ctx.provider.clone(); async move { - let mut out = Output::stdout(); if name.is_empty() { let p = provider.lock().await; - execute_list(&mut out, &*p).await?; + execute_list(&mut ctx.output, &*p).await?; if let Some(model) = p.current_model() { - let _ = writeln!(out, "{BOLD}Current model: {GREEN}{model}{RESET}",); + let _ = writeln!(ctx.output, "{BOLD}Current model: {GREEN}{model}{RESET}",); } else { - let _ = writeln!(out, "{ORANGE}No model currently selected.{RESET}"); + let _ = writeln!(ctx.output, "{ORANGE}No model currently selected.{RESET}"); } return Ok(CommandResult::Ok); } let mut p = provider.lock().await; - execute_select(&mut out, &mut *p, &name).await?; + execute_select(&mut ctx.output, &mut *p, &name).await?; let mut settings = load_settings(); settings.last_model = p.current_model(); diff --git a/src/commands/registry.rs b/src/commands/registry.rs index 2abfbd6..0f47163 100644 --- a/src/commands/registry.rs +++ b/src/commands/registry.rs @@ -312,6 +312,8 @@ pub struct CommandRegistry { /// Pre-computed (usage, description) pairs for /help, including aliases. /// Populated by [`freeze_descriptions`] after all registrations are done. descriptions: Vec<(&'static str, &'static str)>, + /// Subcommand completions for tab-completion (command name → argument completions). + subcommands: HashMap>, } impl Default for CommandRegistry { @@ -328,6 +330,7 @@ impl CommandRegistry { alias_fixed_args: HashMap::new(), alias_descriptions: HashMap::new(), descriptions: Vec::new(), + subcommands: HashMap::new(), } } @@ -494,4 +497,18 @@ impl CommandRegistry { pub fn contains(&self, name: &str) -> bool { self.commands.contains_key(name) || self.aliases.contains_key(name) } + + /// Get the subcommand completions map for tab-completion. + /// Returns a mapping from command name to its known argument completions. + pub fn subcommands(&self) -> HashMap> { + self.subcommands.clone() + } + + /// Register subcommand completions for a command. + pub fn register_subcommands(&mut self, cmd: &'static str, subs: Vec<&'static str>) { + self.subcommands.insert( + cmd.to_string(), + subs.iter().map(|s| s.to_string()).collect(), + ); + } } diff --git a/src/main.rs b/src/main.rs index db7bf0b..cadcb67 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,13 +21,19 @@ use tinyharness_lib::{ }; use crate::agent::setup as agent_setup; -use crate::{agent::run_agent_loop, commands::CommandContext}; +use crate::agent::tui_loop::run_tui_agent_loop; +use crate::{ + agent::run_agent_loop, + commands::{CommandContext, build_registry}, +}; use clap::Parser; use tinyharness_ui::output::Output; use tinyharness_ui::style::*; +use tinyharness_ui::tui::{StdioBackend, TuiApp, TuiGuard, spawn_stdin_reader}; use tokio::sync::Mutex; #[derive(clap::Parser, Debug)] +#[command(version, about = "tinyharness - ai coding harness")] struct Args { #[arg(short, long)] ollama: bool, @@ -49,6 +55,9 @@ struct Args { /// interactive loop for follow-up turns. #[arg(short = 'p', long = "prompt")] prompt: Option, + /// Launch the terminal UI (TUI) mode with split panes. + #[arg(long)] + tui: bool, } /// Determine the provider kind from CLI flags or saved settings. @@ -171,6 +180,142 @@ fn create_initial_session( (sess, msgs) } +/// Launch the TUI (terminal UI) mode. +/// +/// This creates a split-pane TUI with: +/// - Status bar at the top (mode, model, tokens, session) +/// - Conversation pane (scrollable, 70%) +/// - Sidebar (project context, 30%) +/// - Input bar at the bottom +/// +/// The agent loop runs in a background tokio task, sending conversation +/// updates to the TUI through a channel. The TUI reads events from stdin +/// and agent events from the channel, rendering diff-based updates. +#[allow(clippy::too_many_arguments)] +async fn run_tui_mode( + provider: Arc>, + tool_manager: ToolManager, + messages: Vec, + ctx: CommandContext, + session: Session, + interrupted: Arc, + initial_prompt: Option, + command_names: Vec, + subcommands: std::collections::HashMap>, +) -> Result<(), Box> { + use tinyharness_ui::tui::{TuiAgentEvent, TuiUserAction}; + + let model_name = { + let p = provider.lock().await; + p.current_model().unwrap_or_else(|| "unknown".to_string()) + }; + let mode_str = ctx.current_mode.to_string(); + + // Create channels for TUI ↔ agent communication + let (user_action_tx, user_action_rx) = std::sync::mpsc::channel::(); + let (agent_event_tx, agent_event_rx) = std::sync::mpsc::channel::(); + + // Create the terminal backend and TUI app + let backend = StdioBackend::new()?; + let terminal = tinyharness_ui::tui::Terminal::new(backend)?; + let guard = TuiGuard::new(terminal); + let terminal = guard.take(); + + let mut app = TuiApp::new(terminal, user_action_tx, agent_event_rx)?; + app.set_command_completions(command_names, subcommands); + + // Initialize TUI state from the session context + { + let state = app.state_mut(); + state.mode = mode_str; + state.model_name = model_name; + state.session_name = session + .meta() + .name + .as_deref() + .unwrap_or("unnamed") + .to_string(); + state.message_count = messages.len().saturating_sub(1); // exclude system message + state.sidebar_visible = true; + } + app.sync_from_state(); + + // Populate sidebar with project context + { + let sidebar = app.sidebar_mut(); + sidebar.project_name = ctx.workspace_ctx.project_name.clone(); + sidebar.project_type = ctx.workspace_ctx.project_type.clone(); + sidebar.git_branch = if ctx.workspace_ctx.is_git_repo { + std::process::Command::new("git") + .args(["branch", "--show-current"]) + .output() + .ok() + .and_then(|o| { + let s = String::from_utf8_lossy(&o.stdout).trim().to_string(); + if s.is_empty() { None } else { Some(s) } + }) + } else { + None + }; + sidebar.build_command = ctx.workspace_ctx.build_command.clone(); + sidebar.test_command = ctx.workspace_ctx.test_command.clone(); + sidebar.structure = ctx.workspace_ctx.structure.clone(); + } + + // If resuming a session, populate the conversation with existing messages + for msg in messages.iter() { + match msg.role { + Role::System => { + // Skip system messages in TUI display + } + Role::User => { + app.push_user_message(&msg.content); + } + Role::Assistant => { + app.push_assistant_message(&msg.content); + } + Role::Tool => { + // Tool results shown as tool result for now + app.push_tool_result("tool", &msg.content, false); + } + } + } + + // Spawn the stdin reader thread + let (_tx, rx) = spawn_stdin_reader(); + + // Clear the interrupt flag + interrupted.store(false, Ordering::SeqCst); + + // Spawn the background agent task — ownership is transferred + let agent_provider = Arc::clone(&provider); + + let agent_handle = tokio::spawn(async move { + run_tui_agent_loop( + agent_provider, + tool_manager, + messages, + ctx, + session, + interrupted, + initial_prompt, + user_action_rx, + agent_event_tx, + ) + .await + }); + + // Run the TUI event loop (blocks until quit) + app.run(rx)?; + + // The user_action_rx was moved into the agent task, so dropping it is + // handled automatically when the task finishes or the sender is dropped. + // Just wait for the agent task to finish. + let _ = agent_handle.await; + + Ok(()) +} + #[tokio::main] async fn main() -> Result<(), Box> { // Initialize tracing: library code uses tracing::warn!/error! instead of @@ -353,6 +498,33 @@ async fn main() -> Result<(), Box> { ctx.current_mode = initial_mode; ctx.session_id = Some(session.id().to_string()); + // Build the command registry to extract command names and subcommand + // completions for tab-completion (used by both TUI and CLI modes). + let reg = build_registry(); + let command_names = reg + .command_names() + .into_iter() + .map(|s| s.to_string()) + .collect(); + let subcommands = reg.subcommands(); + + // ── TUI mode ────────────────────────────────────────────────────────── + if args.tui { + return run_tui_mode( + provider, + tool_manager, + messages, + ctx, + session, + interrupted, + args.prompt, + command_names, + subcommands, + ) + .await; + } + + // ── CLI mode (default) ──────────────────────────────────────────────── run_agent_loop( provider, tool_manager, diff --git a/tinyharness-lib/Cargo.toml b/tinyharness-lib/Cargo.toml index 336c5df..93264f8 100644 --- a/tinyharness-lib/Cargo.toml +++ b/tinyharness-lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tinyharness-lib" -version = "0.1.2" +version = "0.2.0" license = "MIT" description = "core liblary for tinyharness" edition = "2024" diff --git a/tinyharness-ui/Cargo.toml b/tinyharness-ui/Cargo.toml index 3fec297..3e616e3 100644 --- a/tinyharness-ui/Cargo.toml +++ b/tinyharness-ui/Cargo.toml @@ -1,12 +1,18 @@ [package] name = "tinyharness-ui" -version = "0.1.2" +version = "0.2.0" license = "MIT" -description = "ui liblary for tinyharness" +description = "ui library for tinyharness" edition = "2024" [dependencies] -tinyharness-lib = { version = "0.1.2", path = "../tinyharness-lib" } +tinyharness-lib = { version = "0.2.0", path = "../tinyharness-lib" } rustyline = { version = "18.0.0", features = ["derive"] } serde_json = "1.0.149" regex = "1.11.1" +libc = "0.2" +signal-hook = "0.3" + +[features] +default = [] +tui = [] diff --git a/tinyharness-ui/src/lib.rs b/tinyharness-ui/src/lib.rs index af2c6c1..ab084e4 100644 --- a/tinyharness-ui/src/lib.rs +++ b/tinyharness-ui/src/lib.rs @@ -1,3 +1,13 @@ +#![allow( + clippy::too_many_arguments, + clippy::new_without_default, + clippy::cast_lossless, + clippy::collapsible_if, + clippy::unnecessary_cast, + clippy::explicit_counter_loop +)] + pub mod output; pub mod style; +pub mod tui; pub mod ui; diff --git a/tinyharness-ui/src/tui/app.rs b/tinyharness-ui/src/tui/app.rs new file mode 100644 index 0000000..cf5700a --- /dev/null +++ b/tinyharness-ui/src/tui/app.rs @@ -0,0 +1,2317 @@ +// ── TUI Application Loop ────────────────────────────────────────────────────── +// +// The main TUI application that owns all widgets, handles the event loop, +// renders frames, and diff-updates the terminal. + +use std::collections::HashMap; +use std::io::{self, Read, Write}; +use std::sync::mpsc; +use std::time::Duration; + +use super::TuiAgentEvent; +use super::backend::Backend; +use super::cell::Style; +use super::event::{Event, EventParser, Key, KeyEvent, MouseButton, MouseEvent}; +use super::layout::{Constraint, Direction, Layout, Rect}; +use super::screen::Screen; +use super::terminal::{Size, Terminal}; +use super::widget::{Action, Widget}; +use super::widgets::conversation::{ContextWarningLevel, ConversationLine, ConversationWidget}; +use super::widgets::input_bar::InputBarWidget; +use super::widgets::sidebar::SidebarWidget; +use super::widgets::spinner::SpinnerWidget; +use super::widgets::status_bar::StatusBarWidget; +use super::widgets::tool_output::{ToolOutputWidget, ToolResult, ToolStatus}; + +// ── Focus management ──────────────────────────────────────────────────────── + +/// Which widget currently has keyboard focus. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Focus { + #[default] + InputBar, + Conversation, + ToolOutput, + Sidebar, + /// Interactive file browser in the sidebar structure section. + Structure, +} + +// ── Application state ──────────────────────────────────────────────────────── + +/// State exposed to the outside world (agent loop integration). +#[derive(Clone, Debug)] +pub struct TuiState { + /// Current agent mode label. + pub mode: String, + /// Current model name. + pub model_name: String, + /// Whether the sidebar is visible. + pub sidebar_visible: bool, + /// Whether we're currently streaming a response. + pub streaming: bool, + /// Token usage info. + pub token_count: Option, + /// Token limit. + pub token_limit: Option, + /// Session name. + pub session_name: String, + /// Message count. + pub message_count: usize, +} + +impl Default for TuiState { + fn default() -> Self { + Self { + mode: "agent".to_string(), + model_name: String::new(), + sidebar_visible: true, + streaming: false, + token_count: None, + token_limit: None, + session_name: "unnamed".to_string(), + message_count: 0, + } + } +} + +// ── TUI Application ────────────────────────────────────────────────────────── + +/// The main TUI application. +/// +/// Owns all widgets and manages the event/render loop. The application +/// reads events from a channel (fed by a stdin reader thread), routes +/// them to the appropriate widget, and renders the screen using diff-based +/// updates for flicker-free rendering. +/// +/// It also receives `TuiAgentEvent` messages from a background agent task +/// (streaming text, tool calls, status updates) and sends `TuiUserAction` +/// messages back to the agent task (user input, confirmations). +pub struct TuiApp { + terminal: Terminal, + screen: Screen, + prev_screen: Screen, + + // Widgets + status_bar: StatusBarWidget, + conversation: ConversationWidget, + sidebar: SidebarWidget, + input_bar: InputBarWidget, + tool_output: ToolOutputWidget, + spinner: SpinnerWidget, + + // State + focus: Focus, + state: TuiState, + running: bool, + + // Agent integration channels + /// Channel to send user actions to the background agent task. + user_action_tx: mpsc::Sender, + /// Channel to receive agent events from the background agent task. + agent_event_rx: mpsc::Receiver, + + // Streaming state + /// Text accumulated during the current streaming response. + streaming_text: String, + /// Whether we're currently in streaming mode (receiving chunks from the agent). + is_streaming: bool, + /// Whether we're currently in a thinking phase (receiving thinking chunks). + is_thinking: bool, + /// Accumulated thinking text for the current thinking phase. + thinking_text: String, + /// Whether we're waiting for the user to confirm a tool call. + confirming: bool, + /// Stored answers for the current question (to resolve number selections). + pending_question_answers: Vec, + /// Whether the help overlay is currently visible. + help_visible: bool, + /// Scroll offset for the help overlay (in lines). + help_scroll: usize, + /// Number of lines in the help content (cached to avoid recomputing). + help_line_count: usize, + /// Whether the tool output panel is visible (toggled with Ctrl+T). + tool_output_visible: bool, +} + +impl TuiApp { + /// Create a new TUI application. + /// + /// Takes channels for communicating with the background agent task. + /// The `user_action_tx` channel is used to send user actions (messages, + /// confirmations) to the agent. The `agent_event_rx` channel receives + /// agent events (streaming text, tool calls) for display. + pub fn new( + terminal: Terminal, + user_action_tx: mpsc::Sender, + agent_event_rx: mpsc::Receiver, + ) -> io::Result { + let size = terminal.size(); + let width = size.cols; + let height = size.rows; + + Ok(TuiApp { + terminal, + screen: Screen::new(width, height), + prev_screen: Screen::new(width, height), + + status_bar: StatusBarWidget::new("agent", "unknown"), + conversation: ConversationWidget::new(), + sidebar: SidebarWidget::new(), + input_bar: InputBarWidget::new("agent", "unknown"), + tool_output: ToolOutputWidget::new(), + spinner: SpinnerWidget::new("thinking"), + + focus: Focus::InputBar, + state: TuiState::default(), + running: true, + + user_action_tx, + agent_event_rx, + + streaming_text: String::new(), + is_streaming: false, + is_thinking: false, + thinking_text: String::new(), + confirming: false, + pending_question_answers: Vec::new(), + help_visible: false, + help_scroll: 0, + help_line_count: 0, + tool_output_visible: false, + }) + } + + /// Get a reference to the application state. + pub fn state(&self) -> &TuiState { + &self.state + } + + /// Get a mutable reference to the application state. + pub fn state_mut(&mut self) -> &mut TuiState { + &mut self.state + } + + // ── Widget accessors ───────────────────────────────────────────────── + + /// Get a reference to the conversation widget. + pub fn conversation(&self) -> &ConversationWidget { + &self.conversation + } + + /// Get a mutable reference to the conversation widget. + pub fn conversation_mut(&mut self) -> ConversationMut<'_> { + ConversationMut(&mut self.conversation) + } + + /// Get a mutable reference to the sidebar widget. + pub fn sidebar_mut(&mut self) -> &mut SidebarWidget { + &mut self.sidebar + } + + /// Get a mutable reference to the tool output widget. + pub fn tool_output_mut(&mut self) -> &mut ToolOutputWidget { + &mut self.tool_output + } + + /// Get a mutable reference to the status bar widget. + pub fn status_bar_mut(&mut self) -> &mut StatusBarWidget { + &mut self.status_bar + } + + /// Set command names and subcommand completions for the input bar's + /// tab-completion feature. Should be called once after construction, + /// before the event loop starts. + pub fn set_command_completions( + &mut self, + command_names: Vec, + subcommands: HashMap>, + ) { + self.input_bar + .set_command_completions(command_names, subcommands); + } + + // ── Update helpers ─────────────────────────────────────────────────── + + /// Update all widgets from the current state. + pub fn sync_from_state(&mut self) { + self.status_bar + .update_labels(&self.state.mode, &self.state.model_name); + self.status_bar.set_session_name(&self.state.session_name); + self.status_bar.set_message_count(self.state.message_count); + if let Some(count) = self.state.token_count { + self.status_bar + .set_token_count(count, self.state.token_limit); + } + self.status_bar.set_streaming(self.state.streaming); + + self.input_bar + .update_labels(&self.state.mode, &self.state.model_name); + self.sidebar.visible = self.state.sidebar_visible; + } + + /// Add a user message to the conversation. + pub fn push_user_message(&mut self, text: &str) { + self.conversation.push(ConversationLine::User { + text: text.to_string(), + }); + self.state.message_count += 1; + } + + /// Add an assistant message to the conversation. + pub fn push_assistant_message(&mut self, text: &str) { + self.conversation.push(ConversationLine::Assistant { + text: text.to_string(), + }); + } + + /// Add a tool call to the conversation. + pub fn push_tool_call(&mut self, name: &str, args_summary: &str) { + self.conversation.push(ConversationLine::ToolCall { + name: name.to_string(), + args_summary: args_summary.to_string(), + }); + } + + /// Add a tool result to both the conversation and the tool output widget. + pub fn push_tool_result(&mut self, name: &str, content: &str, is_error: bool) { + self.conversation.push(ConversationLine::ToolResult { + name: name.to_string(), + content: content.to_string(), + is_error, + }); + self.tool_output.push(ToolResult { + name: name.to_string(), + args_summary: String::new(), + content: content.to_string(), + is_error, + collapsed: true, + status: if is_error { + ToolStatus::Error { + message: content.to_string(), + } + } else { + ToolStatus::Success { duration_ms: 0 } + }, + }); + } + + /// Add a system message to the conversation. + pub fn push_system_message(&mut self, text: &str) { + self.conversation.push(ConversationLine::System { + text: text.to_string(), + }); + } + + /// Add a thinking chain to the conversation. + pub fn push_thinking(&mut self, text: &str) { + self.conversation.push(ConversationLine::Thinking { + text: text.to_string(), + }); + } + + /// Add a separator line to the conversation. + pub fn push_separator(&mut self) { + self.conversation.push(ConversationLine::Separator); + } + + /// Add a confirmation prompt to the conversation. + pub fn push_confirm_prompt(&mut self, name: &str, args_summary: &str) { + self.conversation.push(ConversationLine::ConfirmPrompt { + name: name.to_string(), + args_summary: args_summary.to_string(), + diff_preview: None, + }); + } + + /// Set the streaming state (shows/hides spinner). + pub fn set_streaming(&mut self, streaming: bool) { + self.state.streaming = streaming; + self.status_bar.set_streaming(streaming); + } + + // ── Layout ─────────────────────────────────────────────────────────── + + /// Compute the layout for the current terminal size. + /// + /// Returns: (status_area, conv_area, sidebar_area, input_area, main_area, tool_output_area) + fn compute_layout(&self) -> (Rect, Rect, Rect, Rect, Rect, Rect) { + let size = self.terminal.size(); + let total = Rect::new(0, 0, size.cols, size.rows); + + // Vertical split: status bar | main area | input bar + let vertical = Layout::new(Direction::Vertical).constraints(vec![ + Constraint::Length(1), // status bar + Constraint::Percentage(100), // main area (takes remaining) + Constraint::Length(3), // input bar + ]); + let vertical_areas = vertical.split(total); + let status_area = vertical_areas[0]; + let main_area = vertical_areas[1]; + let input_area = vertical_areas[2]; + + // If tool output panel is visible, split main area vertically: + // conversation (top 60%) | tool output (bottom 40%) + let (conv_area, tool_output_area) = if self.tool_output_visible { + let tool_split = Layout::new(Direction::Vertical).constraints(vec![ + Constraint::Percentage(60), // conversation + Constraint::Percentage(40), // tool output + ]); + let split_areas = tool_split.split(main_area); + (split_areas[0], split_areas[1]) + } else { + (main_area, Rect::new(0, 0, 0, 0)) + }; + + if self.state.sidebar_visible { + // Horizontal split of main area: conversation | sidebar + // The sidebar shares the full main area height (including tool output) + let horizontal = Layout::new(Direction::Horizontal).constraints(vec![ + Constraint::Percentage(100), // conversation + Constraint::Length(25), // sidebar + ]); + let horizontal_areas = horizontal.split(conv_area); + let inner_conv = horizontal_areas[0]; + let sidebar_area = horizontal_areas[1]; + + ( + status_area, + inner_conv, + sidebar_area, + input_area, + main_area, + tool_output_area, + ) + } else { + // No sidebar — conversation takes the full main area + ( + status_area, + conv_area, + Rect::new(0, 0, 0, 0), + input_area, + main_area, + tool_output_area, + ) + } + } + + // ── Event handling ──────────────────────────────────────────────────── + + /// Handle a single event and return any action. + fn handle_event(&mut self, event: &Event) -> Action { + // If help overlay is visible, handle scrolling and dismiss keys + if self.help_visible { + if let Event::Key(key) = event { + match key { + // Ctrl+H / F1 close the overlay + KeyEvent { + key: Key::Char('h'), + modifiers, + } if modifiers.ctrl => { + self.help_visible = false; + self.help_scroll = 0; + return Action::None; + } + KeyEvent { + key: Key::F(1), + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.help_visible = false; + self.help_scroll = 0; + return Action::None; + } + // Escape closes the overlay (explicit, not via catch-all) + KeyEvent { + key: Key::Escape, .. + } => { + self.help_visible = false; + self.help_scroll = 0; + return Action::None; + } + // Ctrl+C passes through as quit/interrupt even when help is open + KeyEvent { + key: Key::Char('c'), + modifiers, + } if modifiers.ctrl => { + self.help_visible = false; + self.help_scroll = 0; + // Fall through to global handler below + } + // Ctrl+D passes through as quit even when help is open + KeyEvent { + key: Key::Char('d'), + modifiers, + } if modifiers.ctrl => { + self.help_visible = false; + self.help_scroll = 0; + // Fall through to global handler below + } + // Scroll up + KeyEvent { + key: Key::Up, + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.help_scroll = self.help_scroll.saturating_sub(1); + return Action::None; + } + KeyEvent { + key: Key::Char('k'), + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.help_scroll = self.help_scroll.saturating_sub(1); + return Action::None; + } + // Scroll down + KeyEvent { + key: Key::Down, + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.help_scroll = self.help_scroll.saturating_add(1); + return Action::None; + } + KeyEvent { + key: Key::Char('j'), + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.help_scroll = self.help_scroll.saturating_add(1); + return Action::None; + } + // Page up + KeyEvent { + key: Key::PageUp, .. + } => { + self.help_scroll = self.help_scroll.saturating_sub(10); + return Action::None; + } + // Page down + KeyEvent { + key: Key::PageDown, .. + } => { + self.help_scroll = self.help_scroll.saturating_add(10); + return Action::None; + } + // Home — scroll to top + KeyEvent { key: Key::Home, .. } => { + self.help_scroll = 0; + return Action::None; + } + // End — scroll to bottom + KeyEvent { key: Key::End, .. } => { + let content_height = self.help_content_height(); + let max_scroll = self.help_line_count.saturating_sub(content_height); + self.help_scroll = max_scroll; + return Action::None; + } + // Any other key dismisses the overlay + _ => { + self.help_visible = false; + self.help_scroll = 0; + return Action::None; + } + } + } + // Allow mouse scroll events to pass through to the help overlay handler below + } + + // Global keybindings (always active regardless of focus) + if let Event::Key(key) = event { + match key { + // Ctrl+C: quit (or interrupt streaming) + KeyEvent { + key: Key::Char('c'), + modifiers, + } if modifiers.ctrl => { + if self.state.streaming { + // Interrupt streaming — notify the agent loop + self.set_streaming(false); + return Action::Interrupt; + } + return Action::Quit; + } + // Ctrl+D: quit + KeyEvent { + key: Key::Char('d'), + modifiers, + } if modifiers.ctrl => { + return Action::Quit; + } + // Ctrl+S: toggle sidebar + KeyEvent { + key: Key::Char('s'), + modifiers, + } if modifiers.ctrl => { + self.state.sidebar_visible = !self.state.sidebar_visible; + return Action::ToggleSidebar; + } + // Ctrl+P: focus structure (interactive file browser) + KeyEvent { + key: Key::Char('p'), + modifiers, + } if modifiers.ctrl => { + if self.state.sidebar_visible { + self.set_focus(Focus::Structure); + } + return Action::None; + } + // Ctrl+F: toggle search in conversation + KeyEvent { + key: Key::Char('f'), + modifiers, + } if modifiers.ctrl => { + self.conversation.toggle_search(); + if self.conversation.is_search_active() { + // Enter conversation focus for search input + self.set_focus(Focus::Conversation); + } else { + // Search closed — return to input bar + self.set_focus(Focus::InputBar); + } + return Action::None; + } + // F1 or Ctrl+H: toggle help overlay + KeyEvent { + key: Key::F(1), + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.help_visible = !self.help_visible; + self.help_scroll = 0; + return Action::None; + } + KeyEvent { + key: Key::Char('h'), + modifiers, + } if modifiers.ctrl => { + self.help_visible = !self.help_visible; + self.help_scroll = 0; + return Action::None; + } + // Ctrl+T: toggle tool output panel + KeyEvent { + key: Key::Char('t'), + modifiers, + } if modifiers.ctrl => { + self.tool_output_visible = !self.tool_output_visible; + if self.tool_output_visible { + self.tool_output.un_collapse_all(); + } + return Action::None; + } + _ => {} + } + } + + // Resize events — update terminal size and screen buffers + if let Event::Resize { cols, rows } = event { + self.screen.resize(*cols, *rows); + self.prev_screen.resize(*cols, *rows); + self.terminal.update_size(); + // Clamp help scroll to valid range after resize + if self.help_visible { + let content_height = self.help_content_height(); + let max_scroll = self.help_line_count.saturating_sub(content_height); + if self.help_scroll > max_scroll { + self.help_scroll = max_scroll; + } + } + return Action::None; + } + + // Mouse scroll events go to the widget under the mouse cursor, + // not just the focused widget. This makes scroll feel natural. + // If help overlay is visible, scroll that instead. + if let Event::Mouse(MouseEvent::ScrollUp { row, col }) = event { + if self.help_visible { + self.help_scroll = self.help_scroll.saturating_sub(3); + return Action::None; + } + self.handle_mouse_scroll(*row, *col, 3); + return Action::None; + } + if let Event::Mouse(MouseEvent::ScrollDown { row, col }) = event { + if self.help_visible { + self.help_scroll = self.help_scroll.saturating_add(3); + return Action::None; + } + self.handle_mouse_scroll(*row, *col, -3); + return Action::None; + } + + // Mouse click events: switch focus to the clicked widget + if let Event::Mouse(MouseEvent::Press { row, col, button }) = event { + return self.handle_mouse_click(*row, *col, *button); + } + + // Scroll-related key events go to the focused scrollable widget + if let Event::Key(key) = event { + match key { + KeyEvent { + key: Key::PageUp, .. + } => { + match self.focus { + Focus::Sidebar | Focus::Structure => self.sidebar.scroll_up(10), + _ => self.conversation.scroll_up(20), + } + return Action::None; + } + KeyEvent { + key: Key::PageDown, .. + } => { + match self.focus { + Focus::Sidebar | Focus::Structure => self.sidebar.scroll_down(10), + _ => self.conversation.scroll_down(20), + } + return Action::None; + } + KeyEvent { key: Key::Home, .. } => { + match self.focus { + Focus::Sidebar | Focus::Structure => self.sidebar.scroll_home(), + _ => self.conversation.scroll_home(), + } + return Action::None; + } + KeyEvent { key: Key::End, .. } => { + match self.focus { + Focus::Sidebar => { /* sidebar has no scroll-to-bottom */ } + _ => self.conversation.scroll_to_bottom(), + } + return Action::None; + } + KeyEvent { + key: Key::Up, + modifiers, + } if modifiers.alt => { + self.conversation.scroll_up(3); + return Action::None; + } + KeyEvent { + key: Key::Down, + modifiers, + } if modifiers.alt => { + self.conversation.scroll_down(3); + return Action::None; + } + _ => {} + } + } + + // Route other events to focused widget + match self.focus { + Focus::InputBar => { + let action = self.input_bar.handle_event(event); + if matches!(action, Action::SendMessage(_)) { + self.conversation.scroll_to_bottom(); + } + action + } + Focus::Conversation => { + // Conversation focus is only used for search mode. + // When not searching, fall back to input bar behavior. + if self.conversation.is_search_active() { + self.conversation.handle_event(event) + } else { + // Search was closed — switch back to input bar + self.set_focus(Focus::InputBar); + self.input_bar.handle_event(event) + } + } + Focus::ToolOutput => self.tool_output.handle_event(event), + Focus::Sidebar => self.sidebar.handle_event(event), + Focus::Structure => { + // Tab/BackTab in structure mode exits it and cycles focus + if let Event::Key(KeyEvent { key: Key::Tab, .. }) = event { + self.sidebar.exit_structure_mode(); + self.cycle_focus(true); + return Action::None; + } + if let Event::Key(KeyEvent { + key: Key::BackTab, .. + }) = event + { + self.sidebar.exit_structure_mode(); + self.cycle_focus(false); + return Action::None; + } + self.sidebar.handle_event(event) + } + } + } + + /// Cycle focus between widgets. + /// + /// The conversation and input bar are treated as a unified unit — + /// typing happens in the input bar and scroll keys always affect the + /// conversation. The focus cycle is: + /// InputBar → ToolOutput (if visible) → Structure + /// + /// Conversation focus is only entered for search mode (Ctrl+F) and + /// is automatically exited when search is closed. + fn cycle_focus(&mut self, forward: bool) { + let order: Vec = if self.tool_output_visible { + vec![Focus::InputBar, Focus::ToolOutput, Focus::Structure] + } else { + vec![Focus::InputBar, Focus::Structure] + }; + let current = order.iter().position(|&f| f == self.focus).unwrap_or(0); + let next = if forward { + (current + 1) % order.len() + } else { + (current + order.len() - 1) % order.len() + }; + self.set_focus(order[next]); + } + + /// Set focus to a specific widget. + /// + /// When focusing InputBar, the conversation is still scrollable via + /// scroll keys — they're treated as a unified unit. Conversation focus + /// is only used for search mode. + fn set_focus(&mut self, focus: Focus) { + self.focus = focus; + // Input bar is focused whenever we're in InputBar focus (unified with conversation) + self.input_bar + .set_focus(matches!(focus, Focus::InputBar | Focus::Conversation)); + // Update the status bar focus indicator + let label = match focus { + Focus::InputBar | Focus::Conversation => "input", + Focus::ToolOutput => "tools", + Focus::Sidebar | Focus::Structure => "files", + }; + self.status_bar.set_focus_label(label); + // When focusing the sidebar, automatically enter structure (file browser) mode + if focus == Focus::Sidebar || focus == Focus::Structure { + self.sidebar.visible = true; + self.state.sidebar_visible = true; + self.sidebar.enter_structure_mode(); + } else { + self.sidebar.exit_structure_mode(); + } + } + + // ── Mouse handling ────────────────────────────────────────────────── + + /// Handle a mouse click: switch focus to the clicked widget and + /// perform widget-specific click actions (cursor positioning, etc.). + fn handle_mouse_click(&mut self, row: u16, col: u16, button: MouseButton) -> Action { + let (status_area, conv_area, sidebar_area, input_area, _main_area, _tool_area) = + self.compute_layout(); + + // Determine which widget area was clicked + if Self::rect_contains(status_area, row, col) { + // Click on status bar — no action, but don't unfocus + return Action::None; + } + + if Self::rect_contains(input_area, row, col) { + // Click on input bar — focus it and position cursor + if self.focus != Focus::InputBar { + self.set_focus(Focus::InputBar); + } + if button == MouseButton::Left { + self.input_bar.click_to_cursor(row, col, input_area); + } + return Action::None; + } + + if self.state.sidebar_visible + && !sidebar_area.is_empty() + && Self::rect_contains(sidebar_area, row, col) + { + // Click on sidebar — always focus structure (file browser) mode + if self.focus != Focus::Structure { + self.set_focus(Focus::Structure); + } + // Handle the click to select/navigate entries + if button == MouseButton::Left { + self.sidebar.click_structure_entry(row, col, sidebar_area); + } + return Action::None; + } + + if Self::rect_contains(conv_area, row, col) { + // Click on conversation area — don't change focus. + // The input bar and conversation are a unified unit; typing always + // goes to the input bar while scroll keys always affect the conversation. + // Only switch to conversation focus if search is active (for cursor + // positioning in the search bar). + if self.conversation.is_search_active() { + if self.focus != Focus::Conversation { + self.set_focus(Focus::Conversation); + } + } + return Action::None; + } + + Action::None + } + + /// Handle a mouse scroll event: scroll the widget under the mouse cursor. + fn handle_mouse_scroll(&mut self, row: u16, _col: u16, delta: i32) { + let (status_area, conv_area, sidebar_area, input_area, _main_area, _tool_area) = + self.compute_layout(); + + let n = delta.unsigned_abs() as usize; + + if Self::rect_contains(sidebar_area, row, 0) && self.state.sidebar_visible { + // Scroll the sidebar + if delta > 0 { + self.sidebar.scroll_up(n); + } else { + self.sidebar.scroll_down(n); + } + } else if Self::rect_contains(conv_area, row, 0) { + // Scroll the conversation + if delta > 0 { + self.conversation.scroll_up(n); + } else { + self.conversation.scroll_down(n); + } + } + // Don't scroll status bar or input bar + let _ = (status_area, input_area); + } + + /// Check if a screen position (row, col) falls within a Rect. + fn rect_contains(rect: Rect, row: u16, col: u16) -> bool { + row >= rect.y && row < rect.y + rect.height && col >= rect.x && col < rect.x + rect.width + } + + // ── Rendering ──────────────────────────────────────────────────────── + + /// Render all widgets to the screen buffer. + fn render_frame(&mut self) { + let (status_area, conv_area, sidebar_area, input_area, _main_area, _tool_area) = + self.compute_layout(); + + // Clear the screen + self.screen.clear(); + + // Render widgets + self.status_bar.render(status_area, &mut self.screen); + self.conversation.render(conv_area, &mut self.screen); + if self.state.sidebar_visible && !sidebar_area.is_empty() { + self.sidebar.render(sidebar_area, &mut self.screen); + } + if self.tool_output_visible && !_tool_area.is_empty() { + self.tool_output.render(_tool_area, &mut self.screen); + } + self.input_bar.render(input_area, &mut self.screen); + + // Render spinner if streaming + if self.state.streaming { + // Put spinner in the bottom-right of the conversation area. + // Clip to conv_area bounds so it doesn't overflow into the sidebar. + let spinner_width = 12u16; // frame(1) + space(1) + label up to ~10 chars + let spinner_x = conv_area.x + conv_area.width.saturating_sub(spinner_width); + let spinner_y = conv_area.y + conv_area.height.saturating_sub(1); + // Ensure we don't extend past the conversation area + let actual_width = spinner_width.min(conv_area.right().saturating_sub(spinner_x)); + let spinner_area = Rect::new(spinner_x, spinner_y, actual_width, 1); + self.spinner.render(spinner_area, &mut self.screen); + } + + // Render help overlay if visible + if self.help_visible { + self.render_help_overlay(conv_area); + } + } + + /// Returns the static help content lines as a slice. + /// + /// Extracted from `render_help_overlay` to avoid recreating the array + /// every frame and to allow reuse for scroll calculations. + fn help_content() -> &'static [&'static str] { + &[ + "", + " Keyboard Shortcuts", + " ─────────────────────────────────────────────────", + "", + " Global:", + " Ctrl+C Quit (or interrupt streaming)", + " Ctrl+D Quit", + " Ctrl+S Toggle sidebar", + " Ctrl+T Toggle tool output panel", + " Ctrl+P Focus file browser", + " Ctrl+F Search in conversation", + " Ctrl+H / F1 Toggle this help", + " Tab Cycle focus forward", + " Shift+Tab Cycle focus backward", + "", + " Input Bar:", + " Enter Send message", + " Shift+Enter Insert newline", + " Escape Clear input / Quit if empty", + " Up/Down History navigation", + " Ctrl+A Move to start of line", + " Ctrl+E Move to end of line", + " Ctrl+U Clear line before cursor", + " Ctrl+K Clear line after cursor", + " Ctrl+W Delete word backward", + " Ctrl+Y Yank (paste) from kill ring", + " Ctrl+Left/Right Move by word", + " Alt+B / Alt+F Move back/forward by word", + " Alt+Backspace Delete word backward", + " Tab Complete / command or cycle focus", + "", + " Navigation:", + " PageUp/Down Scroll conversation by page", + " Alt+Up/Down Scroll conversation by 3 lines", + " Home/End Scroll to top/bottom of conversation", + " Ctrl+F Search in conversation", + " Escape Close search", + "", + " File Browser (sidebar):", + " Up/Down Move selection", + " Enter Enter directory", + " Escape Go back / exit", + " PageUp/Down Scroll by page", + " Home/End First/last entry", + " / Filter files by name", + "", + " Confirm Prompt:", + " y Approve tool call", + " n Deny tool call", + " a Approve all future calls", + " Escape Deny", + "", + ] + } + + /// Calculate the available content height for the help overlay based on + /// the current terminal size. Returns 0 if there's not enough space. + fn help_content_height(&self) -> usize { + let size = self.terminal.size(); + // Available area: total height minus status bar (1) and input bar (3) + let area_height = size.rows.saturating_sub(4); // 1 status + 3 input + // Reserve 2 lines for top/bottom borders, 1 for the hint row + let content_height = area_height.saturating_sub(3); + content_height as usize + } + + /// Render the help overlay on top of the conversation area. + /// + /// Shows a centered box with keyboard shortcuts, drawn over whatever + /// is currently rendered. Supports scrolling when the terminal is too + /// small to show all content. Up/Down/PgUp/PgDn/Home/End scroll, + /// Escape or Ctrl+H/F1 close, any other key dismisses. + fn render_help_overlay(&mut self, area: Rect) { + use super::cell::Color; + + let lines = Self::help_content(); + // Cache the line count for scroll clamping + self.help_line_count = lines.len(); + + let box_width = 52u16; + // Don't render if there's no usable space + if area.height < 4 { + return; + } + + // Reserve: 2 lines for top/bottom borders, 1 for hint row at bottom + let max_content = area.height.saturating_sub(3) as usize; // 2 border + 1 hint + let content_height = max_content.min(lines.len()); + if content_height == 0 { + return; + } + + // Reserve one extra row at the bottom for the dismiss/scroll hint + let visible_content_lines = content_height.saturating_sub(1); + if visible_content_lines == 0 { + return; + } + + let box_height = content_height as u16 + 2; // +2 for top/bottom border + let box_x = area.x + (area.width.saturating_sub(box_width)) / 2; + let box_y = area.y + (area.height.saturating_sub(box_height)) / 2; + + // Clamp scroll so we don't scroll past the end + let max_scroll = lines.len().saturating_sub(visible_content_lines); + if self.help_scroll > max_scroll { + self.help_scroll = max_scroll; + } + + let overlay_bg = Color::Ansi(235); // dark gray + let border_fg = Color::Ansi(244); + let title_fg = Color::YELLOW; + let section_fg = Color::CYAN; + let key_fg = Color::WHITE; + let desc_fg = Color::Ansi(252); + let dim_fg = Color::Ansi(244); + + // Draw background fill + for row in box_y..box_y + box_height { + for col in box_x..box_x + box_width { + if let Some(cell) = self.screen.get_mut(row, col) { + cell.char = ' '; + cell.fg = desc_fg; + cell.bg = overlay_bg; + cell.style = Style::default(); + } + } + } + + // Draw border + // Top + if let Some(cell) = self.screen.get_mut(box_y, box_x) { + cell.char = '┌'; + cell.fg = border_fg; + cell.bg = overlay_bg; + } + for col in box_x + 1..box_x + box_width - 1 { + if let Some(cell) = self.screen.get_mut(box_y, col) { + cell.char = '─'; + cell.fg = border_fg; + cell.bg = overlay_bg; + } + } + if let Some(cell) = self.screen.get_mut(box_y, box_x + box_width - 1) { + cell.char = '┐'; + cell.fg = border_fg; + cell.bg = overlay_bg; + } + // Bottom + if let Some(cell) = self.screen.get_mut(box_y + box_height - 1, box_x) { + cell.char = '└'; + cell.fg = border_fg; + cell.bg = overlay_bg; + } + for col in box_x + 1..box_x + box_width - 1 { + if let Some(cell) = self.screen.get_mut(box_y + box_height - 1, col) { + cell.char = '─'; + cell.fg = border_fg; + cell.bg = overlay_bg; + } + } + if let Some(cell) = self + .screen + .get_mut(box_y + box_height - 1, box_x + box_width - 1) + { + cell.char = '┘'; + cell.fg = border_fg; + cell.bg = overlay_bg; + } + // Sides + for row in box_y + 1..box_y + box_height - 1 { + if let Some(cell) = self.screen.get_mut(row, box_x) { + cell.char = '│'; + cell.fg = border_fg; + cell.bg = overlay_bg; + } + if let Some(cell) = self.screen.get_mut(row, box_x + box_width - 1) { + cell.char = '│'; + cell.fg = border_fg; + cell.bg = overlay_bg; + } + } + + // Draw text lines (scrolled) + let content_x = box_x + 1; + let content_width = (box_width as usize).saturating_sub(2); + + for (i, line) in lines + .iter() + .skip(self.help_scroll) + .take(visible_content_lines) + .enumerate() + { + let row = box_y + 1 + i as u16; + if row >= box_y + box_height - 2 { + // Leave room for the hint row + break; + } + + let (fg, style) = if line.starts_with(" Keyboard Shortcuts") { + (title_fg, Style::bold()) + } else if line.starts_with(" ────") { + (border_fg, Style::default()) + } else if line.starts_with(" Global:") + || line.starts_with(" Input Bar:") + || line.starts_with(" Conversation:") + || line.starts_with(" File Browser") + || line.starts_with(" Confirm Prompt:") + { + (section_fg, Style::bold()) + } else if line.contains("Ctrl+") + || line.contains("Shift+") + || line.contains("Alt+") + || line.contains("Tab") + || line.contains("Enter") + || line.contains("Escape") + || line.contains("PageUp") + || line.contains("Home") + || line.contains("End") + || line.contains("Up/Down") + || line.contains("/") + { + (key_fg, Style::default()) + } else { + (desc_fg, Style::default()) + }; + + let display = if line.len() > content_width { + use crate::tui::widget::truncate_str; + format!("{}…", truncate_str(line, content_width.saturating_sub(1))) + } else { + line.to_string() + }; + + self.screen + .write_str(row, content_x, &display, fg, overlay_bg, style); + } + + // Draw dismiss/scroll hint at the bottom of the box (inside the border) + let hint_row = box_y + box_height - 2; + let can_scroll_up = self.help_scroll > 0; + let can_scroll_down = self.help_scroll < max_scroll; + let hint = if can_scroll_up || can_scroll_down { + " ↑↓ scroll · Esc to close" + } else { + " Press Esc to close" + }; + self.screen + .write_str(hint_row, content_x, hint, dim_fg, overlay_bg, Style::dim()); + + // Draw scroll position indicator inside the right border area + // (placed between the border and content, not overlapping the border) + if can_scroll_up || can_scroll_down { + let scroll_char = if can_scroll_up && can_scroll_down { + '↕' + } else if can_scroll_up { + '↑' + } else { + '↓' + }; + let indicator_y = box_y + box_height / 2; + // Draw inside the right border: column box_x + box_width - 2 + // (one column inside from the right border │) + if let Some(cell) = self.screen.get_mut(indicator_y, box_x + box_width - 2) { + cell.char = scroll_char; + cell.fg = border_fg; + cell.bg = overlay_bg; + cell.style = Style::default(); + } + } + } + + /// Diff the current screen against the previous frame and write changes. + fn flush_diff(&mut self) -> io::Result<()> { + let width = self.screen.width(); + let height = self.screen.height(); + + let mut last_pos: Option<(u16, u16)> = None; + + for row in 0..height { + for col in 0..width { + let curr = self.screen.get(row, col); + let prev = self.prev_screen.get(row, col); + + if curr != prev { + // Move cursor if not adjacent + if last_pos != Some((row, col.saturating_sub(1))) { + write!(self.terminal, "\x1b[{};{}H", row + 1, col + 1)?; + } + + // Write the cell + let cell = curr.unwrap(); + // Apply foreground color + write!(self.terminal, "{}", cell.fg.fg_escape())?; + // Apply background color + write!(self.terminal, "{}", cell.bg.bg_escape())?; + // Apply style + if cell.style.bold { + write!(self.terminal, "\x1b[1m")?; + } + if cell.style.dim { + write!(self.terminal, "\x1b[2m")?; + } + if cell.style.italic { + write!(self.terminal, "\x1b[3m")?; + } + if cell.style.underline { + write!(self.terminal, "\x1b[4m")?; + } + if cell.style.blink { + write!(self.terminal, "\x1b[5m")?; + } + // Write character + if cell.char != '\0' { + write!(self.terminal, "{}", cell.char)?; + } else { + write!(self.terminal, " ")?; + } + // Reset style + write!(self.terminal, "\x1b[0m")?; + + last_pos = Some((row, col)); + } + } + } + + // Swap buffers + std::mem::swap(&mut self.screen, &mut self.prev_screen); + + self.terminal.flush()?; + Ok(()) + } + + // ── Main loop ──────────────────────────────────────────────────────── + + /// Run the TUI event loop. + /// + /// This method takes ownership and blocks until the user quits. + /// It reads events from the provided receiver (which should be fed + /// by a stdin reader thread), and also processes events from the + /// background agent task (streaming text, tool calls, etc.). + pub fn run(&mut self, event_rx: mpsc::Receiver) -> io::Result<()> { + // Enter raw mode and alternate screen + self.terminal.enter_raw_mode()?; + self.terminal.enter_alternate_screen()?; + self.terminal.hide_cursor()?; + self.terminal.enable_mouse()?; + self.terminal.enable_bracketed_paste()?; + + // Initial render + self.render_frame(); + self.flush_diff()?; + + while self.running { + // Poll for UI events with a short timeout for smooth animation + let event = event_rx.recv_timeout(Duration::from_millis(50)); + + match event { + Ok(ev) => { + let action = self.handle_event(&ev); + self.handle_action(action); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + // No UI event + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + // Stdin event source closed — exit + break; + } + } + + // Process all pending agent events (non-blocking) + while let Ok(agent_event) = self.agent_event_rx.try_recv() { + self.handle_agent_event(agent_event); + } + + // Update spinner animation + if self.state.streaming { + self.spinner.tick(); + } + + // Render and flush + self.render_frame(); + self.flush_diff()?; + } + + // Cleanup + self.terminal.disable_bracketed_paste()?; + self.terminal.disable_mouse()?; + self.terminal.show_cursor()?; + self.terminal.leave_alternate_screen()?; + self.terminal.leave_raw_mode()?; + + Ok(()) + } + + /// Handle an action returned by a widget. + fn handle_action(&mut self, action: Action) { + match action { + Action::Quit => { + self.running = false; + let _ = self.user_action_tx.send(super::TuiUserAction::Quit); + } + Action::SendMessage(text) => { + self.push_user_message(&text); + // Send the message to the background agent task + let _ = self + .user_action_tx + .send(super::TuiUserAction::SendMessage(text)); + } + Action::ToggleSidebar => { + self.state.sidebar_visible = !self.state.sidebar_visible; + } + Action::SwitchMode(mode) => { + self.state.mode = mode; + self.sync_from_state(); + } + Action::CycleFocusForward => { + self.cycle_focus(true); + } + Action::CycleFocusBackward => { + self.cycle_focus(false); + } + Action::ScrollUp => { + self.conversation.scroll_up(3); + } + Action::ScrollDown => { + self.conversation.scroll_down(3); + } + Action::PageUp => { + self.conversation.scroll_up(20); + } + Action::PageDown => { + self.conversation.scroll_down(20); + } + Action::ConfirmYes => { + self.confirming = false; + self.input_bar.set_confirming(false); + let _ = self + .user_action_tx + .send(super::TuiUserAction::ConfirmResponse { + approved: true, + auto_accept: false, + }); + } + Action::ConfirmNo => { + self.confirming = false; + self.input_bar.set_confirming(false); + let _ = self + .user_action_tx + .send(super::TuiUserAction::ConfirmResponse { + approved: false, + auto_accept: false, + }); + } + Action::ConfirmAll => { + self.confirming = false; + self.input_bar.set_confirming(false); + let _ = self + .user_action_tx + .send(super::TuiUserAction::ConfirmResponse { + approved: true, + auto_accept: true, + }); + } + Action::AnswerQuestion(input) => { + self.input_bar.set_questioning(false, 0); + // Resolve the answer: if it's a number, map to the predefined answers + let answer = if let Ok(num) = input.parse::() { + if num >= 1 && num <= self.pending_question_answers.len() { + self.pending_question_answers[num - 1].clone() + } else { + input + } + } else { + input + }; + self.pending_question_answers.clear(); + let _ = self + .user_action_tx + .send(super::TuiUserAction::QuestionAnswer(answer)); + } + Action::Interrupt => { + let _ = self.user_action_tx.send(super::TuiUserAction::Interrupt); + } + Action::ExitStructureMode => { + // Exit structure mode — return focus to input bar + self.set_focus(Focus::InputBar); + } + Action::None => {} + } + } + + /// Process an agent event received from the background task. + fn handle_agent_event(&mut self, event: TuiAgentEvent) { + match event { + TuiAgentEvent::StreamingStarted => { + self.is_streaming = true; + self.streaming_text.clear(); + self.is_thinking = false; + self.thinking_text.clear(); + self.spinner.set_label("Thinking"); + self.spinner.start(); + self.set_streaming(true); + // Don't push a Thinking placeholder here — push it lazily + // when the first StreamingThinking event arrives. This avoids + // showing an empty thinking indicator when the model produces + // no thinking content (e.g., non-Ollama models or thinking disabled). + } + TuiAgentEvent::StreamingText(text) => { + // If we were thinking, finalize the thinking block first + if self.is_thinking { + self.is_thinking = false; + // Update the thinking line with accumulated text + if let Some(ConversationLine::Thinking { text: t }) = + self.conversation.last_mut() + { + *t = self.thinking_text.clone(); + } + self.thinking_text.clear(); + self.spinner.set_label("Responding"); + } + self.streaming_text.push_str(&text); + // Update the last assistant message or add a new one + self.update_streaming_assistant_message(); + // Ensure we auto-scroll to follow the new content + self.conversation.scroll_to_bottom(); + } + TuiAgentEvent::StreamingThinking(text) => { + // If this is the first thinking chunk, push a blank line for + // visual separation from the previous message, then push a + // Thinking line lazily. This mirrors the CLI behavior of writing + // a newline before the [thinking] header. + if !self.is_thinking { + self.is_thinking = true; + self.conversation.push(ConversationLine::Separator); + self.conversation.push(ConversationLine::Thinking { + text: String::new(), + }); + } + self.thinking_text.push_str(&text); + // Update the thinking indicator in the conversation + if let Some(ConversationLine::Thinking { text: t }) = self.conversation.last_mut() { + // Show a brief preview of thinking content + let preview = if self.thinking_text.len() > 80 { + use crate::tui::widget::truncate_str; + format!("{}…", truncate_str(&self.thinking_text, 78)) + } else { + self.thinking_text.clone() + }; + *t = preview; + } + // Ensure we auto-scroll to follow thinking content + self.conversation.scroll_to_bottom(); + } + TuiAgentEvent::StreamingDone => { + // Finalize thinking if still active + if self.is_thinking { + self.is_thinking = false; + if let Some(ConversationLine::Thinking { text: t }) = + self.conversation.last_mut() + { + *t = self.thinking_text.clone(); + } + self.thinking_text.clear(); + } + // Finalize the streaming text as a complete assistant message + if !self.streaming_text.is_empty() { + // The streaming text was already being displayed incrementally + // via update_streaming_assistant_message, so just finalize + self.streaming_text.clear(); + } + self.is_streaming = false; + self.spinner.stop(); + self.set_streaming(false); + } + TuiAgentEvent::Error(msg) => { + // Finalize thinking if still active + if self.is_thinking { + self.is_thinking = false; + if let Some(ConversationLine::Thinking { text: t }) = + self.conversation.last_mut() + { + *t = self.thinking_text.clone(); + } + self.thinking_text.clear(); + } + self.push_system_message(&format!("⚠ Error: {}", msg)); + self.is_streaming = false; + self.spinner.stop(); + self.set_streaming(false); + } + TuiAgentEvent::ToolCall { name, args_summary } => { + self.push_tool_call(&name, &args_summary); + } + TuiAgentEvent::ToolResult { + name, + content, + is_error, + } => { + self.push_tool_result(&name, &content, is_error); + } + TuiAgentEvent::ModeChanged(mode) => { + self.state.mode = mode; + self.sync_from_state(); + } + TuiAgentEvent::ModelChanged(model) => { + self.state.model_name = model; + self.sync_from_state(); + } + TuiAgentEvent::TokenUpdate { count, limit } => { + self.state.token_count = Some(count); + self.state.token_limit = limit; + self.status_bar.set_token_count(count, limit); + } + TuiAgentEvent::ContextWarning { + percentage, + critical, + } => { + let level = if critical { + ContextWarningLevel::Critical(percentage) + } else { + ContextWarningLevel::Warning(percentage) + }; + self.conversation.set_context_warning(level); + } + TuiAgentEvent::SystemMessage(msg) => { + self.push_system_message(&msg); + } + TuiAgentEvent::ConfirmTool { + name, + args_summary, + needs_approval: _, + diff_preview, + } => { + // Show a confirmation prompt in the conversation and switch + // the input bar to confirmation mode. The agent loop will + // block until we send a ConfirmResponse back. + self.conversation.push(ConversationLine::ConfirmPrompt { + name: name.clone(), + args_summary: args_summary.clone(), + diff_preview: diff_preview.clone(), + }); + self.confirming = true; + self.input_bar.set_confirming(true); + self.set_focus(Focus::InputBar); + } + TuiAgentEvent::Question { question, answers } => { + // Show the question in the conversation with styled answers. + // The user can type a number or custom text, then press Enter. + self.conversation.push(ConversationLine::Question { + question: question.clone(), + answers: answers.clone(), + }); + // Enter question mode in the input bar + self.input_bar.set_questioning(true, answers.len()); + self.set_focus(Focus::InputBar); + // Store the answers so we can resolve number selections + self.pending_question_answers = answers; + } + TuiAgentEvent::Done => { + // Finalize thinking if still active + if self.is_thinking { + self.is_thinking = false; + if let Some(ConversationLine::Thinking { text: t }) = + self.conversation.last_mut() + { + *t = self.thinking_text.clone(); + } + self.thinking_text.clear(); + } + self.is_streaming = false; + self.spinner.stop(); + self.set_streaming(false); + } + } + } + + /// Update the streaming assistant message in the conversation widget. + /// + /// If the last conversation line is an assistant message being streamed, + /// update its text. Otherwise, add a new assistant message. + fn update_streaming_assistant_message(&mut self) { + // Check if the last line is an assistant message we can update + if self.conversation.last_is_assistant() && self.is_streaming { + // Update the last assistant message with accumulated streaming text + if let Some(ConversationLine::Assistant { text }) = self.conversation.last_mut() { + *text = self.streaming_text.clone(); + } + } else { + // Add a new assistant message + self.conversation.push(ConversationLine::Assistant { + text: self.streaming_text.clone(), + }); + } + } +} + +// ── RAII guard for terminal state ──────────────────────────────────────────── + +/// RAII guard that ensures the terminal is restored when dropped, +/// even if the TUI crashes or panics. +pub struct TuiGuard { + terminal: Option>, +} + +impl TuiGuard { + /// Create a guard from a terminal. The terminal will be restored + /// when the guard is dropped. + pub fn new(terminal: Terminal) -> Self { + Self { + terminal: Some(terminal), + } + } + + /// Take the terminal out of the guard, releasing the restore obligation. + pub fn take(mut self) -> Terminal { + self.terminal.take().unwrap() + } +} + +impl Drop for TuiGuard { + fn drop(&mut self) { + if let Some(ref mut terminal) = self.terminal { + let _ = terminal.disable_bracketed_paste(); + let _ = terminal.disable_mouse(); + let _ = terminal.show_cursor(); + let _ = terminal.leave_alternate_screen(); + let _ = terminal.leave_raw_mode(); + } + } +} + +// ── Conversation mut helper (newtype to avoid lifetime issues) ─────────────── + +/// A helper that provides mutable access to the conversation widget. +pub struct ConversationMut<'a>(pub &'a mut ConversationWidget); + +// ── Stdin event reader ─────────────────────────────────────────────────────── + +/// Spawns a background thread that reads raw bytes from stdin and parses +/// them into events, sending them through a channel. +/// +/// Also spawns a SIGWINCH signal handler thread that detects terminal resizes +/// and injects `Event::Resize` events into the same channel. +pub fn spawn_stdin_reader() -> (mpsc::Sender, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(); + + // Stdin reader thread + let stdin_tx = tx.clone(); + std::thread::spawn(move || { + let mut parser = EventParser::new(); + let mut buf = [0u8; 64]; + + loop { + match std::io::stdin().read(&mut buf) { + Ok(0) => break, // EOF + Ok(n) => { + parser.feed(&buf[..n]); + while let Some(event) = parser.parse() { + if stdin_tx.send(event).is_err() { + return; // Receiver dropped + } + } + } + Err(_) => break, + } + } + }); + + // SIGWINCH handler thread — detects terminal resize and sends Resize events + let resize_tx = tx.clone(); + std::thread::spawn(move || { + #[cfg(unix)] + { + use std::sync::atomic::{AtomicBool, Ordering as SignalOrdering}; + + let resize_flag = std::sync::Arc::new(AtomicBool::new(false)); + // Register SIGWINCH handler — clones the Arc so we can still read the flag + if signal_hook::flag::register(signal_hook::consts::SIGWINCH, resize_flag.clone()) + .is_err() + { + // Could not register signal handler — resize detection disabled + return; + } + + loop { + std::thread::sleep(Duration::from_millis(100)); + if resize_flag.swap(false, SignalOrdering::SeqCst) { + if let Ok(size) = Size::from_terminal() { + let _ = resize_tx.send(Event::Resize { + cols: size.cols, + rows: size.rows, + }); + } + } + } + } + + #[cfg(not(unix))] + { + let _ = resize_tx; + loop { + std::thread::sleep(Duration::from_secs(1)); + } + } + }); + + (tx, rx) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::backend::TestBackend; + use crate::tui::event::Modifiers; + use crate::tui::terminal::Size; + + fn make_app() -> TuiApp { + let backend = TestBackend::new(Size::new(80, 24)); + let terminal = Terminal::new(backend).unwrap(); + let (user_action_tx, _user_action_rx) = mpsc::channel(); + let (_, agent_event_rx) = mpsc::channel(); + TuiApp::new(terminal, user_action_tx, agent_event_rx).unwrap() + } + + #[test] + fn test_app_new() { + let app = make_app(); + assert!(app.running); + assert_eq!(app.focus, Focus::InputBar); + } + + #[test] + fn test_app_default_state() { + let app = make_app(); + assert_eq!(app.state.mode, "agent"); + assert!(app.state.sidebar_visible); + assert!(!app.state.streaming); + } + + #[test] + fn test_app_push_user_message() { + let mut app = make_app(); + app.push_user_message("Hello"); + assert_eq!(app.state.message_count, 1); + } + + #[test] + fn test_app_push_assistant_message() { + let mut app = make_app(); + app.push_assistant_message("Hi there!"); + } + + #[test] + fn test_app_push_tool_call() { + let mut app = make_app(); + app.push_tool_call("read", "src/main.rs"); + } + + #[test] + fn test_app_push_tool_result() { + let mut app = make_app(); + app.push_tool_result("read", "fn main() {}", false); + } + + #[test] + fn test_app_push_system_message() { + let mut app = make_app(); + app.push_system_message("Session started"); + } + + #[test] + fn test_app_push_thinking() { + let mut app = make_app(); + app.push_thinking("Let me think..."); + } + + #[test] + fn test_app_set_streaming() { + let mut app = make_app(); + app.set_streaming(true); + assert!(app.state.streaming); + app.set_streaming(false); + assert!(!app.state.streaming); + } + + #[test] + fn test_app_sync_from_state() { + let mut app = make_app(); + app.state.mode = "planning".to_string(); + app.state.model_name = "gpt-4".to_string(); + app.state.session_name = "test-session".to_string(); + app.state.message_count = 5; + app.sync_from_state(); + } + + #[test] + fn test_app_cycle_focus() { + let mut app = make_app(); + // Cycle: InputBar → Structure → InputBar (without tool output) + assert_eq!(app.focus, Focus::InputBar); + app.cycle_focus(true); + assert_eq!(app.focus, Focus::Structure); + app.cycle_focus(true); + assert_eq!(app.focus, Focus::InputBar); + app.cycle_focus(false); + assert_eq!(app.focus, Focus::Structure); + } + + #[test] + fn test_app_set_focus() { + let mut app = make_app(); + // InputBar focus keeps the input bar focused + app.set_focus(Focus::InputBar); + assert!(app.input_bar.focused()); + // Conversation focus also keeps input bar focused (unified) + app.set_focus(Focus::Conversation); + assert!(app.input_bar.focused()); + // Structure focus removes input bar focus + app.set_focus(Focus::Structure); + assert!(!app.input_bar.focused()); + } + + #[test] + fn test_app_compute_layout() { + let app = make_app(); + let (status, conv, sidebar, input, _main, _tool) = app.compute_layout(); + assert_eq!(status.height, 1); + assert_eq!(input.height, 3); + assert!(conv.height > 0); + assert!(sidebar.width > 0); + } + + #[test] + fn test_app_compute_layout_no_sidebar() { + let mut app = make_app(); + app.state.sidebar_visible = false; + let (_status, conv, sidebar, _input, _main, _tool) = app.compute_layout(); + assert!(conv.width > 0); + assert_eq!(sidebar.width, 0); + } + + #[test] + fn test_app_handle_quit() { + let mut app = make_app(); + let event = Event::Key(KeyEvent { + key: Key::Char('c'), + modifiers: Modifiers::ctrl(), + }); + let action = app.handle_event(&event); + app.handle_action(action); + assert!(!app.running); + } + + #[test] + fn test_app_handle_ctrl_d() { + let mut app = make_app(); + let event = Event::Key(KeyEvent { + key: Key::Char('d'), + modifiers: Modifiers::ctrl(), + }); + let action = app.handle_event(&event); + app.handle_action(action); + assert!(!app.running); + } + + #[test] + fn test_app_handle_toggle_sidebar() { + let mut app = make_app(); + let event = Event::Key(KeyEvent { + key: Key::Char('s'), + modifiers: Modifiers::ctrl(), + }); + app.handle_event(&event); + assert!(!app.state.sidebar_visible); + app.handle_event(&event); + assert!(app.state.sidebar_visible); + } + + #[test] + fn test_app_handle_tab_focus() { + let mut app = make_app(); + // Tab on empty input returns CycleFocusForward from input bar + let event = Event::Key(KeyEvent { + key: Key::Tab, + modifiers: Modifiers::new(), + }); + let action = app.handle_event(&event); + // The action should cycle focus forward (InputBar → Structure) + app.handle_action(action); + assert_eq!(app.focus, Focus::Structure); + } + + #[test] + fn test_app_render_frame() { + let mut app = make_app(); + app.push_user_message("Hello"); + app.push_assistant_message("Hi!"); + app.render_frame(); + // Should not panic + } + + #[test] + fn test_tui_state_default() { + let state = TuiState::default(); + assert_eq!(state.mode, "agent"); + assert!(state.sidebar_visible); + assert!(!state.streaming); + } + + #[test] + fn test_focus_default() { + assert_eq!(Focus::default(), Focus::InputBar); + } + + #[test] + fn test_spawn_stdin_reader() { + // Just verify the channels are created + let (_tx, rx) = spawn_stdin_reader(); + // Drop the sender to close the thread + drop(_tx); + // Receiver should eventually get a Disconnected error + assert!(rx.recv_timeout(Duration::from_millis(100)).is_err()); + } + + // ── Mouse tests ──────────────────────────────────────────────────── + + #[test] + fn test_app_rect_contains() { + let rect = Rect::new(5, 3, 20, 10); + assert!(TuiApp::::rect_contains(rect, 3, 5)); // top-left + assert!(TuiApp::::rect_contains(rect, 12, 24)); // bottom-right + assert!(!TuiApp::::rect_contains(rect, 2, 5)); // above + assert!(!TuiApp::::rect_contains(rect, 13, 5)); // below + assert!(!TuiApp::::rect_contains(rect, 3, 4)); // left + assert!(!TuiApp::::rect_contains(rect, 3, 25)); // right + } + + #[test] + fn test_app_mouse_click_conversation() { + let mut app = make_app(); + assert_eq!(app.focus, Focus::InputBar); + // Click in the conversation area — should NOT change focus + // (input bar and conversation are a unified unit) + let (_, conv_area, _, _, _, _) = app.compute_layout(); + let event = Event::Mouse(MouseEvent::Press { + row: conv_area.y, + col: conv_area.x, + button: MouseButton::Left, + }); + app.handle_event(&event); + assert_eq!(app.focus, Focus::InputBar); + } + + #[test] + fn test_app_mouse_click_input_bar() { + let mut app = make_app(); + // Click in the input bar area (near bottom) — should focus input bar + let (_, _, _, input_area, _, _) = app.compute_layout(); + let input_row = input_area.y + 1; // middle of input bar + let event = Event::Mouse(MouseEvent::Press { + row: input_row, + col: input_area.x, + button: MouseButton::Left, + }); + app.handle_event(&event); + assert_eq!(app.focus, Focus::InputBar); + } + + #[test] + fn test_app_mouse_click_sidebar() { + let mut app = make_app(); + assert!(app.state.sidebar_visible); + // Click in the sidebar area (rightmost columns) — should focus Structure + let (_, _, sidebar_area, _, _, _) = app.compute_layout(); + if !sidebar_area.is_empty() { + let sidebar_row = sidebar_area.y + 2; + let sidebar_col = sidebar_area.x + 2; + let event = Event::Mouse(MouseEvent::Press { + row: sidebar_row, + col: sidebar_col, + button: MouseButton::Left, + }); + app.handle_event(&event); + assert_eq!(app.focus, Focus::Structure); + assert!(app.sidebar.is_structure_mode()); + } + } + + #[test] + fn test_app_mouse_scroll_conversation() { + let mut app = make_app(); + for i in 0..30 { + app.push_user_message(&format!("Message {}", i)); + } + // Scroll up in the conversation area + let (_, conv_area, _, _, _, _) = app.compute_layout(); + let conv_row = conv_area.y + 5; + let event = Event::Mouse(MouseEvent::ScrollUp { + row: conv_row, + col: conv_area.x, + }); + app.handle_event(&event); + // Verify the event was handled (no panic) and scroll was applied + // by scrolling down and checking it doesn't crash + let event2 = Event::Mouse(MouseEvent::ScrollDown { + row: conv_row, + col: conv_area.x, + }); + app.handle_event(&event2); + } + + #[test] + fn test_app_mouse_scroll_sidebar() { + let mut app = make_app(); + app.sidebar.structure = (0..30).map(|i| format!("file_{}.rs", i)).collect(); + // Scroll up in the sidebar area + let (_, _, sidebar_area, _, _, _) = app.compute_layout(); + if !sidebar_area.is_empty() { + let sidebar_row = sidebar_area.y + 3; + let event = Event::Mouse(MouseEvent::ScrollUp { + row: sidebar_row, + col: sidebar_area.x + 2, + }); + app.handle_event(&event); + } + } + + // ── Feature 3: Ctrl+C sends Interrupt ────────────────────────────── + + #[test] + fn test_ctrl_c_sends_interrupt_while_streaming() { + let mut app = make_app(); + app.state.streaming = true; + let event = Event::Key(KeyEvent { + key: Key::Char('c'), + modifiers: Modifiers::ctrl(), + }); + let action = app.handle_event(&event); + assert!(matches!(action, Action::Interrupt)); + assert!(!app.state.streaming); + } + + #[test] + fn test_ctrl_c_quits_when_not_streaming() { + let mut app = make_app(); + assert!(!app.state.streaming); + let event = Event::Key(KeyEvent { + key: Key::Char('c'), + modifiers: Modifiers::ctrl(), + }); + let action = app.handle_event(&event); + assert!(matches!(action, Action::Quit)); + } + + #[test] + fn test_interrupt_action_sends_user_action() { + let mut app = make_app(); + app.handle_action(Action::Interrupt); + // Should not panic + } + + // ── Feature 2: Help overlay ──────────────────────────────────────── + + #[test] + fn test_help_overlay_toggle_ctrl_h() { + let mut app = make_app(); + assert!(!app.help_visible); + let event = Event::Key(KeyEvent { + key: Key::Char('h'), + modifiers: Modifiers::ctrl(), + }); + app.handle_event(&event); + assert!(app.help_visible); + app.handle_event(&event); + assert!(!app.help_visible); + } + + #[test] + fn test_help_overlay_toggle_f1() { + let mut app = make_app(); + let event = Event::Key(KeyEvent { + key: Key::F(1), + modifiers: Modifiers::new(), + }); + app.handle_event(&event); + assert!(app.help_visible); + // Any key dismisses + let dismiss = Event::Key(KeyEvent { + key: Key::Char('a'), + modifiers: Modifiers::new(), + }); + app.handle_event(&dismiss); + assert!(!app.help_visible); + } + + #[test] + fn test_help_overlay_dismisses_on_any_key() { + let mut app = make_app(); + app.help_visible = true; + let event = Event::Key(KeyEvent { + key: Key::Enter, + modifiers: Modifiers::new(), + }); + app.handle_event(&event); + assert!(!app.help_visible); + } + + #[test] + fn test_help_overlay_renders() { + let mut app = make_app(); + app.help_visible = true; + app.render_frame(); + // Should not panic + } + + #[test] + fn test_help_overlay_scroll_up_down() { + let mut app = make_app(); + app.help_visible = true; + assert_eq!(app.help_scroll, 0); + // Scroll down + let event = Event::Key(KeyEvent { + key: Key::Down, + modifiers: Modifiers::new(), + }); + app.handle_event(&event); + assert_eq!(app.help_scroll, 1); + app.handle_event(&event); + assert_eq!(app.help_scroll, 2); + // Scroll up + let event = Event::Key(KeyEvent { + key: Key::Up, + modifiers: Modifiers::new(), + }); + app.handle_event(&event); + assert_eq!(app.help_scroll, 1); + // Can't scroll past top + app.help_scroll = 0; + app.handle_event(&event); + assert_eq!(app.help_scroll, 0); + } + + #[test] + fn test_help_overlay_scroll_page() { + let mut app = make_app(); + app.help_visible = true; + assert_eq!(app.help_scroll, 0); + // Page down + let event = Event::Key(KeyEvent { + key: Key::PageDown, + modifiers: Modifiers::new(), + }); + app.handle_event(&event); + assert_eq!(app.help_scroll, 10); + // Page up + let event = Event::Key(KeyEvent { + key: Key::PageUp, + modifiers: Modifiers::new(), + }); + app.handle_event(&event); + assert_eq!(app.help_scroll, 0); + } + + #[test] + fn test_help_overlay_home_end() { + let mut app = make_app(); + app.help_visible = true; + assert_eq!(app.help_scroll, 0); + // Scroll down first + app.help_scroll = 5; + // Home + let event = Event::Key(KeyEvent { + key: Key::Home, + modifiers: Modifiers::new(), + }); + app.handle_event(&event); + assert_eq!(app.help_scroll, 0); + // End — scroll to bottom + // First trigger a render to populate help_line_count + app.render_frame(); + let event = Event::Key(KeyEvent { + key: Key::End, + modifiers: Modifiers::new(), + }); + app.handle_event(&event); + // help_scroll should be set to max_scroll (clamped to content) + // In a 24-row terminal, with status(1)+input(3)=4, area=20, + // content_height ≈ 17, help_content has 52 lines, so max_scroll ≈ 35 + // Just verify scroll moved forward from 0 + assert!(app.help_scroll > 0); + } + + #[test] + fn test_help_overlay_escape_dismisses() { + let mut app = make_app(); + app.help_visible = true; + app.help_scroll = 5; + let event = Event::Key(KeyEvent { + key: Key::Escape, + modifiers: Modifiers::new(), + }); + app.handle_event(&event); + assert!(!app.help_visible); + assert_eq!(app.help_scroll, 0); + } + + #[test] + fn test_help_overlay_mouse_scroll() { + let mut app = make_app(); + app.help_visible = true; + assert_eq!(app.help_scroll, 0); + // Scroll down with mouse + let event = Event::Mouse(MouseEvent::ScrollDown { row: 5, col: 5 }); + app.handle_event(&event); + assert_eq!(app.help_scroll, 3); + // Scroll up with mouse + let event = Event::Mouse(MouseEvent::ScrollUp { row: 5, col: 5 }); + app.handle_event(&event); + assert_eq!(app.help_scroll, 0); + } + + #[test] + fn test_help_content_static() { + let lines = TuiApp::::help_content(); + assert!(!lines.is_empty()); + assert!(lines.len() > 30); // Should have all shortcut sections + } + + // ── Feature 4: Tool output panel ─────────────────────────────────── + + #[test] + fn test_tool_output_toggle_ctrl_t() { + let mut app = make_app(); + assert!(!app.tool_output_visible); + let event = Event::Key(KeyEvent { + key: Key::Char('t'), + modifiers: Modifiers::ctrl(), + }); + app.handle_event(&event); + assert!(app.tool_output_visible); + app.handle_event(&event); + assert!(!app.tool_output_visible); + } + + #[test] + fn test_tool_output_layout_with_panel_visible() { + let mut app = make_app(); + app.tool_output_visible = true; + let (.., tool_area) = app.compute_layout(); + assert!(!tool_area.is_empty()); + assert!(tool_area.height > 0); + } + + #[test] + fn test_tool_output_layout_without_panel() { + let mut app = make_app(); + assert!(!app.tool_output_visible); + let (.., tool_area) = app.compute_layout(); + assert!(tool_area.is_empty()); + } + + #[test] + fn test_tool_output_cycle_focus_includes_tool_output() { + let mut app = make_app(); + app.tool_output_visible = true; + // Cycle: InputBar → ToolOutput → Structure → InputBar + app.cycle_focus(true); + assert_eq!(app.focus, Focus::ToolOutput); + app.cycle_focus(true); + assert_eq!(app.focus, Focus::Structure); + app.cycle_focus(true); + assert_eq!(app.focus, Focus::InputBar); + } + + #[test] + fn test_tool_output_renders_when_visible() { + let mut app = make_app(); + app.tool_output_visible = true; + app.tool_output.push(ToolResult { + name: "read".to_string(), + args_summary: "src/main.rs".to_string(), + content: "fn main() {}".to_string(), + is_error: false, + collapsed: true, + status: ToolStatus::Success { duration_ms: 42 }, + }); + app.render_frame(); + // Should not panic + } + + // ── Input bar editing shortcuts tests are in input_bar.rs ─────────── + // (they access private fields directly) +} diff --git a/tinyharness-ui/src/tui/backend.rs b/tinyharness-ui/src/tui/backend.rs new file mode 100644 index 0000000..06a41e6 --- /dev/null +++ b/tinyharness-ui/src/tui/backend.rs @@ -0,0 +1,169 @@ +// ── Terminal backend abstraction ────────────────────────────────────────────── +// +// All terminal writes go through a `Backend` trait so we can swap real +// terminal I/O for a test double. This makes the entire TUI testable +// without needing a real terminal. + +use std::io::{self, Write}; + +use super::terminal::Size; + +// ── Backend trait ──────────────────────────────────────────────────────────── + +/// Abstraction over terminal I/O. +/// +/// The TUI writes all output through a `Backend` implementation. In +/// production, `StdioBackend` writes to stdout. In tests, `TestBackend` +/// captures output in memory. +pub trait Backend: Write { + /// Get the current terminal size (columns, rows). + fn size(&self) -> Size; + + /// Flush any buffered output. + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +// ── Stdio backend ──────────────────────────────────────────────────────────── + +/// Production backend that writes to stdout. +pub struct StdioBackend { + stdout: io::Stdout, +} + +impl StdioBackend { + /// Create a new stdio backend. + pub fn new() -> io::Result { + Ok(Self { + stdout: io::stdout(), + }) + } +} + +impl Default for StdioBackend { + fn default() -> Self { + Self::new().expect("failed to create StdioBackend") + } +} + +impl Write for StdioBackend { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.stdout.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.stdout.flush() + } +} + +impl Backend for StdioBackend { + fn size(&self) -> Size { + Size::from_terminal() + .unwrap_or_else(|_| Size::from_env().unwrap_or_else(Size::default_size)) + } +} + +// ── Test backend ───────────────────────────────────────────────────────────── + +/// In-memory backend for testing. Captures all output in a buffer. +pub struct TestBackend { + buffer: Vec, + size: Size, +} + +impl TestBackend { + /// Create a new test backend with the given terminal size. + pub fn new(size: Size) -> Self { + Self { + buffer: Vec::new(), + size, + } + } + + /// Get a reference to the captured output buffer. + pub fn buffer(&self) -> &[u8] { + &self.buffer + } + + /// Take ownership of the captured output buffer, clearing it. + pub fn take_buffer(&mut self) -> Vec { + std::mem::take(&mut self.buffer) + } + + /// Check if the captured output contains a specific byte sequence. + pub fn contains(&self, needle: &[u8]) -> bool { + self.buffer.windows(needle.len()).any(|w| w == needle) + } +} + +impl Write for TestBackend { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.buffer.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl Backend for TestBackend { + fn size(&self) -> Size { + self.size + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_test_backend_write() { + let mut backend = TestBackend::new(Size::new(80, 24)); + backend.write_all(b"hello").unwrap(); + assert!(backend.contains(b"hello")); + } + + #[test] + fn test_test_backend_take_buffer() { + let mut backend = TestBackend::new(Size::new(80, 24)); + backend.write_all(b"hello").unwrap(); + let buf = backend.take_buffer(); + assert_eq!(&buf, b"hello"); + assert!(backend.buffer().is_empty()); + } + + #[test] + fn test_test_backend_size() { + let backend = TestBackend::new(Size::new(120, 40)); + let size = backend.size(); + assert_eq!(size.cols, 120); + assert_eq!(size.rows, 40); + } + + #[test] + fn test_test_backend_contains() { + let mut backend = TestBackend::new(Size::new(80, 24)); + backend.write_all(b"\x1b[?1049h\x1b[2J").unwrap(); + assert!(backend.contains(b"\x1b[?1049h")); + assert!(backend.contains(b"\x1b[2J")); + assert!(!backend.contains(b"\x1b[?25l")); + } + + #[test] + fn test_stdio_backend_new() { + // Just verify it can be created + let backend = StdioBackend::new(); + assert!(backend.is_ok()); + } + + #[test] + fn test_stdio_backend_size() { + let backend = StdioBackend::new().unwrap(); + let size = backend.size(); + // Should return something reasonable (at least 1x1) + assert!(size.cols > 0); + assert!(size.rows > 0); + } +} diff --git a/tinyharness-ui/src/tui/cell.rs b/tinyharness-ui/src/tui/cell.rs new file mode 100644 index 0000000..917acba --- /dev/null +++ b/tinyharness-ui/src/tui/cell.rs @@ -0,0 +1,315 @@ +// ── Color representation for the TUI screen buffer ────────────────────────── +// +// Supports 4-bit (16 colors), 8-bit (256 colors), and 24-bit (true color) +// using raw ANSI escape sequences — no external TUI framework needed. + +/// Terminal color representation. +/// +/// Supports the full range of terminal colors: +/// - `Default`: use the terminal's default foreground/background +/// - `Ansi(n)`: 4-bit (0–15) or 8-bit (16–255) indexed color +/// - `Rgb(r, g, b)`: 24-bit true color +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Color { + /// Terminal default color (inherits from terminal theme). + Default, + /// 4-bit (0–15) or 8-bit (16–255) indexed color. + Ansi(u8), + /// 24-bit true color. + Rgb(u8, u8, u8), +} + +// ── Named color constants (matching style.rs ANSI codes) ─────────────────── + +impl Color { + // Standard foreground colors (4-bit) + pub const BLACK: Color = Color::Ansi(0); + pub const RED: Color = Color::Ansi(1); + pub const GREEN: Color = Color::Ansi(2); + pub const YELLOW: Color = Color::Ansi(3); + pub const BLUE: Color = Color::Ansi(4); + pub const MAGENTA: Color = Color::Ansi(5); + pub const CYAN: Color = Color::Ansi(6); + pub const WHITE: Color = Color::Ansi(7); + + // Bright / extended colors (matching style.rs) + pub const GRAY: Color = Color::Ansi(8); // bright black + pub const BRIGHT_RED: Color = Color::Ansi(9); + pub const BRIGHT_GREEN: Color = Color::Ansi(10); + pub const BRIGHT_YELLOW: Color = Color::Ansi(11); + pub const BRIGHT_BLUE: Color = Color::Ansi(12); + pub const BRIGHT_MAGENTA: Color = Color::Ansi(13); + pub const BRIGHT_CYAN: Color = Color::Ansi(14); + pub const BRIGHT_WHITE: Color = Color::Ansi(15); + pub const ORANGE: Color = Color::Ansi(208); + + // Background colors (matching style.rs BG_* constants) + pub const BG_DIM: Color = Color::Ansi(236); + pub const BG_TOOL: Color = Color::Ansi(237); + pub const BG_WARN: Color = Color::Ansi(17); +} + +impl Color { + /// Generate the ANSI escape sequence to set this color as the foreground. + pub fn fg_escape(&self) -> String { + match self { + Color::Default => "\x1b[39m".to_string(), + Color::Ansi(n) => { + if *n < 8 { + // Standard colors: ESC[30–37m + format!("\x1b[{}m", 30 + *n as u16) + } else if *n < 16 { + // Bright colors: ESC[90–97m + format!("\x1b[{}m", 90 + (*n - 8) as u16) + } else { + // 8-bit colors: ESC[38;5;nm + format!("\x1b[38;5;{}m", n) + } + } + Color::Rgb(r, g, b) => format!("\x1b[38;2;{r};{g};{b}m"), + } + } + + /// Generate the ANSI escape sequence to set this color as the background. + pub fn bg_escape(&self) -> String { + match self { + Color::Default => "\x1b[49m".to_string(), + Color::Ansi(n) => { + if *n < 8 { + // Standard background: ESC[40–47m + format!("\x1b[{}m", 40 + *n as u16) + } else if *n < 16 { + // Bright background: ESC[100–107m + format!("\x1b[{}m", 100 + (*n - 8) as u16) + } else { + // 8-bit background: ESC[48;5;nm + format!("\x1b[48;5;{}m", n) + } + } + Color::Rgb(r, g, b) => format!("\x1b[48;2;{r};{g};{b}m"), + } + } +} + +// ── Cell style flags ──────────────────────────────────────────────────────── + +/// Text style attributes that can be combined. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Style { + pub bold: bool, + pub dim: bool, + pub italic: bool, + pub underline: bool, + pub blink: bool, +} + +impl Style { + pub fn new() -> Self { + Self::default() + } + + pub fn bold() -> Self { + Self { + bold: true, + ..Self::default() + } + } + + pub fn dim() -> Self { + Self { + dim: true, + ..Self::default() + } + } + + pub fn bold_dim() -> Self { + Self { + bold: true, + dim: true, + ..Self::default() + } + } + + pub fn blink() -> Self { + Self { + blink: true, + ..Self::default() + } + } + + /// Generate the ANSI escape sequences for this style. + pub fn escape(&self) -> String { + let mut parts = Vec::new(); + if self.bold { + parts.push("\x1b[1m"); + } + if self.dim { + parts.push("\x1b[2m"); + } + if self.italic { + parts.push("\x1b[3m"); + } + if self.underline { + parts.push("\x1b[4m"); + } + if self.blink { + parts.push("\x1b[5m"); + } + parts.join("") + } + + /// Generate the ANSI reset sequence to clear all styles. + pub fn reset() -> &'static str { + "\x1b[0m" + } +} + +// ── Screen cell ───────────────────────────────────────────────────────────── + +/// A single cell in the screen buffer. +/// +/// Each cell stores a character, foreground color, background color, +/// and style flags. When the screen is rendered, only cells that changed +/// from the previous frame are written to the terminal. +#[derive(Clone, Debug, PartialEq)] +pub struct Cell { + pub char: char, + pub fg: Color, + pub bg: Color, + pub style: Style, +} + +impl Default for Cell { + fn default() -> Self { + Cell { + char: ' ', + fg: Color::Default, + bg: Color::Default, + style: Style::default(), + } + } +} + +impl Cell { + /// Create a cell with a character and default styling. + pub fn char(ch: char) -> Self { + Cell { + char: ch, + ..Self::default() + } + } + + /// Create a styled cell. + pub fn styled(ch: char, fg: Color, bg: Color, style: Style) -> Self { + Cell { + char: ch, + fg, + bg, + style, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_color_fg_escape_default() { + assert_eq!(Color::Default.fg_escape(), "\x1b[39m"); + } + + #[test] + fn test_color_fg_escape_standard() { + // Standard colors 0-7 use ESC[30–37m + assert_eq!(Color::Ansi(0).fg_escape(), "\x1b[30m"); // black + assert_eq!(Color::Ansi(1).fg_escape(), "\x1b[31m"); // red + assert_eq!(Color::Ansi(7).fg_escape(), "\x1b[37m"); // white + } + + #[test] + fn test_color_fg_escape_bright() { + // Bright colors 8-15 use ESC[90–97m + assert_eq!(Color::Ansi(8).fg_escape(), "\x1b[90m"); // bright black + assert_eq!(Color::Ansi(14).fg_escape(), "\x1b[96m"); // bright cyan + } + + #[test] + fn test_color_fg_escape_256() { + // 8-bit colors use ESC[38;5;Nm + assert_eq!(Color::Ansi(208).fg_escape(), "\x1b[38;5;208m"); // orange + assert_eq!(Color::Ansi(236).fg_escape(), "\x1b[38;5;236m"); // bg_dim + } + + #[test] + fn test_color_fg_escape_rgb() { + assert_eq!(Color::Rgb(255, 0, 128).fg_escape(), "\x1b[38;2;255;0;128m"); + } + + #[test] + fn test_color_bg_escape_default() { + assert_eq!(Color::Default.bg_escape(), "\x1b[49m"); + } + + #[test] + fn test_color_bg_escape_standard() { + assert_eq!(Color::Ansi(1).bg_escape(), "\x1b[41m"); // red bg + } + + #[test] + fn test_color_bg_escape_bright() { + assert_eq!(Color::Ansi(8).bg_escape(), "\x1b[100m"); // bright black bg + } + + #[test] + fn test_color_bg_escape_256() { + assert_eq!(Color::Ansi(237).bg_escape(), "\x1b[48;5;237m"); + } + + #[test] + fn test_color_bg_escape_rgb() { + assert_eq!(Color::Rgb(10, 20, 30).bg_escape(), "\x1b[48;2;10;20;30m"); + } + + #[test] + fn test_style_escape() { + let s = Style { + bold: true, + dim: false, + italic: true, + underline: false, + blink: false, + }; + assert_eq!(s.escape(), "\x1b[1m\x1b[3m"); + } + + #[test] + fn test_style_default_is_empty() { + let s = Style::default(); + assert_eq!(s.escape(), ""); + } + + #[test] + fn test_cell_default() { + let c = Cell::default(); + assert_eq!(c.char, ' '); + assert_eq!(c.fg, Color::Default); + assert_eq!(c.bg, Color::Default); + } + + #[test] + fn test_cell_char() { + let c = Cell::char('X'); + assert_eq!(c.char, 'X'); + assert_eq!(c.fg, Color::Default); + } + + #[test] + fn test_cell_styled() { + let c = Cell::styled('A', Color::RED, Color::BLUE, Style::bold()); + assert_eq!(c.char, 'A'); + assert_eq!(c.fg, Color::Ansi(1)); + assert_eq!(c.bg, Color::Ansi(4)); + assert!(c.style.bold); + } +} diff --git a/tinyharness-ui/src/tui/event.rs b/tinyharness-ui/src/tui/event.rs new file mode 100644 index 0000000..cb2b1e6 --- /dev/null +++ b/tinyharness-ui/src/tui/event.rs @@ -0,0 +1,855 @@ +// ── Event system for the TUI ────────────────────────────────────────────────── +// +// Defines keyboard, mouse, and resize events, plus a cross-platform +// event reader that parses raw terminal input into structured events. + +use std::io; +use std::time::Duration; + +// ── Key and modifier types ───────────────────────────────────────────────── + +/// A keyboard key press event. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct KeyEvent { + pub key: Key, + pub modifiers: Modifiers, +} + +/// A keyboard key. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Key { + /// A printable character. + Char(char), + /// Enter/Return key. + Enter, + /// Escape key. + Escape, + /// Backspace key. + Backspace, + /// Delete key. + Delete, + /// Tab key. + Tab, + /// Backtab (Shift+Tab). + BackTab, + /// Arrow keys. + Up, + Down, + Left, + Right, + /// Home key. + Home, + /// End key. + End, + /// Page Up key. + PageUp, + /// Page Down key. + PageDown, + /// Insert key. + Insert, + /// Function keys F1–F12. + F(u8), +} + +/// Keyboard modifier flags. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Modifiers { + pub ctrl: bool, + pub alt: bool, + pub shift: bool, +} + +impl Modifiers { + pub fn new() -> Self { + Self::default() + } + + pub fn ctrl() -> Self { + Self { + ctrl: true, + ..Self::default() + } + } + + pub fn alt() -> Self { + Self { + alt: true, + ..Self::default() + } + } + + pub fn shift() -> Self { + Self { + shift: true, + ..Self::default() + } + } +} + +// ── Mouse events ───────────────────────────────────────────────────────────── + +/// A mouse event. +#[derive(Clone, Debug, PartialEq)] +pub enum MouseEvent { + /// Mouse button pressed. + Press { + row: u16, + col: u16, + button: MouseButton, + }, + /// Mouse button released. + Release { row: u16, col: u16 }, + /// Mouse wheel scrolled up. + ScrollUp { row: u16, col: u16 }, + /// Mouse wheel scrolled down. + ScrollDown { row: u16, col: u16 }, + /// Mouse moved (hover/drag). + Move { row: u16, col: u16 }, +} + +/// Mouse button. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MouseButton { + Left, + Right, + Middle, +} + +// ── Top-level event enum ──────────────────────────────────────────────────── + +/// An event from the terminal. +#[derive(Clone, Debug, PartialEq)] +pub enum Event { + /// A key was pressed. + Key(KeyEvent), + /// A mouse event occurred. + Mouse(MouseEvent), + /// The terminal was resized. + Resize { cols: u16, rows: u16 }, + /// A paste event (bracketed paste mode). + Paste(String), +} + +// ── Event parser ───────────────────────────────────────────────────────────── + +/// Parses raw bytes from stdin into events. +/// +/// Terminal input arrives as a stream of bytes. Escape sequences can be +/// multi-byte (e.g., `\x1b[A` for Up arrow, `\x1b[1;5C` for Ctrl+Right). +/// The parser accumulates bytes until a complete sequence is recognized. +#[derive(Default)] +pub struct EventParser { + buf: Vec, +} + +impl EventParser { + pub fn new() -> Self { + Self { buf: Vec::new() } + } + + /// Feed bytes into the parser buffer. + pub fn feed(&mut self, bytes: &[u8]) { + self.buf.extend_from_slice(bytes); + } + + /// Try to parse a complete event from the buffer. + /// + /// Returns `Some(event)` if a complete event was parsed, `None` if + /// more bytes are needed. Incomplete sequences remain in the buffer. + pub fn parse(&mut self) -> Option { + if self.buf.is_empty() { + return None; + } + + // Check for escape sequences + if self.buf[0] == 0x1b && self.buf.len() > 1 { + return self.parse_escape_sequence(); + } + + // Single byte: control character or regular key + let byte = self.buf[0]; + self.buf.drain(..1); // Consume only the first byte + + match byte { + // Control characters + 0x0D | 0x0A => Some(Event::Key(KeyEvent { + key: Key::Enter, + modifiers: Modifiers::new(), + })), + 0x09 => Some(Event::Key(KeyEvent { + key: Key::Tab, + modifiers: Modifiers::new(), + })), + 0x7F => Some(Event::Key(KeyEvent { + key: Key::Backspace, + modifiers: Modifiers::new(), + })), + 0x1B => Some(Event::Key(KeyEvent { + key: Key::Escape, + modifiers: Modifiers::new(), + })), + // Ctrl+A through Ctrl+Z (0x01 through 0x1A) + 0x01..=0x1A => Some(Event::Key(KeyEvent { + key: Key::Char((b'a' + (byte - 0x01)) as char), + modifiers: Modifiers::ctrl(), + })), + // Regular printable character + byte if byte >= 0x20 => Some(Event::Key(KeyEvent { + key: Key::Char(byte as char), + modifiers: Modifiers::new(), + })), + // Ignore other control characters + _ => None, + } + } + + /// Parse an escape sequence starting with \x1b. + fn parse_escape_sequence(&mut self) -> Option { + let buf = &self.buf; + + // Bracketed paste: \x1b[200~ ... \x1b[201~ + if buf.len() >= 6 && &buf[0..6] == b"\x1b[200~" { + // Find the end marker + if let Some(end) = self.find_bracketed_paste_end() { + let paste_content = String::from_utf8_lossy(&buf[6..end]).to_string(); + self.buf.drain(..end + 6); + return Some(Event::Paste(paste_content)); + } + // Need more data + return None; + } + + // CSI sequences: \x1b[ ... + if buf.len() >= 2 && buf[1] == b'[' { + return self.parse_csi_sequence(); + } + + // Alt + key: \x1b + if buf.len() >= 2 { + let alt_key = buf[1]; + self.buf.drain(..2); + match alt_key { + 0x0D | 0x0A => Some(Event::Key(KeyEvent { + key: Key::Enter, + modifiers: Modifiers::alt(), + })), + 0x7F => Some(Event::Key(KeyEvent { + key: Key::Backspace, + modifiers: Modifiers::alt(), + })), + byte if byte >= 0x20 => Some(Event::Key(KeyEvent { + key: Key::Char(byte as char), + modifiers: Modifiers::alt(), + })), + _ => None, + } + } else { + // Just escape — might be a standalone Escape key + // Wait a bit for more bytes; if none come, it's Escape + None + } + } + + /// Parse a CSI (Control Sequence Introducer) sequence. + fn parse_csi_sequence(&mut self) -> Option { + // Find the terminating byte (0x40–0x7E for standard CSI) + let end_pos = self.buf[2..] + .iter() + .position(|&b| (0x40..=0x7E).contains(&b)) + .map(|p| p + 2); + + let end = match end_pos { + Some(p) => p, + None => { + // Need more bytes + // But if the buffer is getting long and still no terminator, + // it might be a malformed sequence. Give up after 20 bytes. + if self.buf.len() > 20 { + self.buf.drain(..1); // Remove the ESC + return None; + } + return None; + } + }; + + // Extract the parameter bytes and final byte before draining + let params = self.buf[2..end].to_vec(); + let final_byte = self.buf[end]; + + // Consume the entire sequence + self.buf.drain(..=end); + + // Parse the sequence + self.dispatch_csi(¶ms, final_byte) + } + + /// Dispatch a parsed CSI sequence to the appropriate event. + fn dispatch_csi(&self, params: &[u8], final_byte: u8) -> Option { + // Parse parameter string (semicolon-separated numbers) + // For SGR mouse events, the format is `\x1b[ = clean_str + .split(';') + .filter_map(|s| s.parse().ok()) + .collect(); + + let mods = Self::parse_modifiers(nums.get(1)); + + match final_byte { + // Arrow keys and special keys (VT100) + b'A' => Some(Event::Key(KeyEvent { + key: Key::Up, + modifiers: mods, + })), + b'B' => Some(Event::Key(KeyEvent { + key: Key::Down, + modifiers: mods, + })), + b'C' => Some(Event::Key(KeyEvent { + key: Key::Right, + modifiers: mods, + })), + b'D' => Some(Event::Key(KeyEvent { + key: Key::Left, + modifiers: mods, + })), + b'H' => Some(Event::Key(KeyEvent { + key: Key::Home, + modifiers: mods, + })), + b'F' => Some(Event::Key(KeyEvent { + key: Key::End, + modifiers: mods, + })), + + // Function keys + b'P' => Some(Event::Key(KeyEvent { + key: Key::F(1), + modifiers: mods, + })), + b'Q' => Some(Event::Key(KeyEvent { + key: Key::F(2), + modifiers: mods, + })), + b'R' => Some(Event::Key(KeyEvent { + key: Key::F(3), + modifiers: mods, + })), + b'S' => Some(Event::Key(KeyEvent { + key: Key::F(4), + modifiers: mods, + })), + + // Extended keys (with parameters) + b'~' => match nums.first().copied().unwrap_or(0) { + 1 => Some(Event::Key(KeyEvent { + key: Key::Home, + modifiers: mods, + })), + 2 => Some(Event::Key(KeyEvent { + key: Key::Insert, + modifiers: mods, + })), + 3 => Some(Event::Key(KeyEvent { + key: Key::Delete, + modifiers: mods, + })), + 4 => Some(Event::Key(KeyEvent { + key: Key::End, + modifiers: mods, + })), + 5 => Some(Event::Key(KeyEvent { + key: Key::PageUp, + modifiers: mods, + })), + 6 => Some(Event::Key(KeyEvent { + key: Key::PageDown, + modifiers: mods, + })), + 11..=15 => Some(Event::Key(KeyEvent { + key: Key::F((nums[0] - 10) as u8), + modifiers: mods, + })), + 17..=21 => Some(Event::Key(KeyEvent { + key: Key::F((nums[0] - 11) as u8), + modifiers: mods, + })), + 23 => Some(Event::Key(KeyEvent { + key: Key::F(12), + modifiers: mods, + })), + _ => None, + }, + + // Mouse events (SGR mode: \x1b[ self.parse_xterm_mouse(&nums, false), + b'm' => self.parse_xterm_mouse(&nums, true), + + _ => None, + } + } + + /// Parse modifier flags from CSI parameter. + /// + /// CSI modifier encoding: + /// - No modifier field → no modifiers + /// - 2 = Shift + /// - 3 = Alt (Meta) + /// - 5 = Ctrl + /// - 6 = Ctrl+Shift + fn parse_modifiers(mod_code: Option<&u16>) -> Modifiers { + let code = mod_code.copied().unwrap_or(0); + Modifiers { + shift: code == 2 || code == 4 || code == 6 || code == 8, + alt: code == 3 || code == 7, + ctrl: code == 5 || code == 6 || code == 7 || code == 8, + } + } + + /// Parse an xterm SGR mouse event. + /// + /// SGR mouse format: `\x1b[ Option { + if nums.len() < 3 { + return None; + } + let button_code = nums[0]; + let col = nums[1].saturating_sub(1); + let row = nums[2].saturating_sub(1); + + if release { + Some(Event::Mouse(MouseEvent::Release { row, col })) + } else { + match button_code { + 0 => Some(Event::Mouse(MouseEvent::Press { + row, + col, + button: MouseButton::Left, + })), + 1 => Some(Event::Mouse(MouseEvent::Press { + row, + col, + button: MouseButton::Middle, + })), + 2 => Some(Event::Mouse(MouseEvent::Press { + row, + col, + button: MouseButton::Right, + })), + 64 => Some(Event::Mouse(MouseEvent::ScrollUp { row, col })), + 65 => Some(Event::Mouse(MouseEvent::ScrollDown { row, col })), + _ => None, + } + } + } + + /// Find the end of a bracketed paste sequence. + fn find_bracketed_paste_end(&self) -> Option { + let end_marker = b"\x1b[201~"; + self.buf + .windows(end_marker.len()) + .position(|w| w == end_marker) + } +} + +// ── Async event reader ─────────────────────────────────────────────────────── + +/// Reads events from stdin asynchronously. +/// +/// Uses a background thread to read raw bytes from stdin and parse them +/// into events. Events are sent through a channel. +pub struct EventReader { + #[allow(dead_code)] + tx: std::sync::mpsc::Sender, + rx: std::sync::mpsc::Receiver, +} + +impl EventReader { + /// Create a new event reader. + /// + /// Spawns a background thread that reads from stdin in raw mode + /// and parses events. + pub fn new() -> io::Result { + let (tx, rx) = std::sync::mpsc::channel(); + Ok(Self { tx, rx }) + } + + /// Start reading events from stdin. + /// + /// This spawns a background thread that reads raw bytes from stdin + /// and sends parsed events through the channel. + pub fn start(&self) { + // Event reading from stdin will be integrated with the + // tokio event loop in the app. For now, the parser can be + // used directly for testing. + } + + /// Try to receive an event with a timeout. + pub fn recv_timeout(&self, timeout: Duration) -> Option { + self.rx.recv_timeout(timeout).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_regular_char() { + let mut parser = EventParser::new(); + parser.feed(b"a"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::Char('a'), + modifiers: Modifiers::new(), + }) + ); + } + + #[test] + fn test_parse_enter() { + let mut parser = EventParser::new(); + parser.feed(b"\r"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::Enter, + modifiers: Modifiers::new(), + }) + ); + } + + #[test] + fn test_parse_tab() { + let mut parser = EventParser::new(); + parser.feed(b"\t"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::Tab, + modifiers: Modifiers::new(), + }) + ); + } + + #[test] + fn test_parse_backspace() { + let mut parser = EventParser::new(); + parser.feed(b"\x7f"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::Backspace, + modifiers: Modifiers::new(), + }) + ); + } + + #[test] + fn test_parse_ctrl_a() { + let mut parser = EventParser::new(); + parser.feed(b"\x01"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::Char('a'), + modifiers: Modifiers::ctrl(), + }) + ); + } + + #[test] + fn test_parse_ctrl_c() { + let mut parser = EventParser::new(); + parser.feed(b"\x03"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::Char('c'), + modifiers: Modifiers::ctrl(), + }) + ); + } + + #[test] + fn test_parse_arrow_up() { + let mut parser = EventParser::new(); + parser.feed(b"\x1b[A"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::Up, + modifiers: Modifiers::new(), + }) + ); + } + + #[test] + fn test_parse_arrow_down() { + let mut parser = EventParser::new(); + parser.feed(b"\x1b[B"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::Down, + modifiers: Modifiers::new(), + }) + ); + } + + #[test] + fn test_parse_arrow_right() { + let mut parser = EventParser::new(); + parser.feed(b"\x1b[C"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::Right, + modifiers: Modifiers::new(), + }) + ); + } + + #[test] + fn test_parse_arrow_left() { + let mut parser = EventParser::new(); + parser.feed(b"\x1b[D"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::Left, + modifiers: Modifiers::new(), + }) + ); + } + + #[test] + fn test_parse_ctrl_arrow_up() { + let mut parser = EventParser::new(); + // \x1b[1;5A = Ctrl+Up + parser.feed(b"\x1b[1;5A"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::Up, + modifiers: Modifiers { + ctrl: true, + alt: false, + shift: false + }, + }) + ); + } + + #[test] + fn test_parse_shift_arrow_up() { + let mut parser = EventParser::new(); + // \x1b[1;2A = Shift+Up + parser.feed(b"\x1b[1;2A"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::Up, + modifiers: Modifiers { + ctrl: false, + alt: false, + shift: true + }, + }) + ); + } + + #[test] + fn test_parse_alt_key() { + let mut parser = EventParser::new(); + parser.feed(b"\x1ba"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::Char('a'), + modifiers: Modifiers::alt(), + }) + ); + } + + #[test] + fn test_parse_home() { + let mut parser = EventParser::new(); + parser.feed(b"\x1b[H"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::Home, + modifiers: Modifiers::new(), + }) + ); + } + + #[test] + fn test_parse_end() { + let mut parser = EventParser::new(); + parser.feed(b"\x1b[F"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::End, + modifiers: Modifiers::new(), + }) + ); + } + + #[test] + fn test_parse_page_up() { + let mut parser = EventParser::new(); + parser.feed(b"\x1b[5~"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::PageUp, + modifiers: Modifiers::new(), + }) + ); + } + + #[test] + fn test_parse_page_down() { + let mut parser = EventParser::new(); + parser.feed(b"\x1b[6~"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::PageDown, + modifiers: Modifiers::new(), + }) + ); + } + + #[test] + fn test_parse_delete() { + let mut parser = EventParser::new(); + parser.feed(b"\x1b[3~"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Key(KeyEvent { + key: Key::Delete, + modifiers: Modifiers::new(), + }) + ); + } + + #[test] + fn test_parse_multiple_events() { + let mut parser = EventParser::new(); + parser.feed(b"abc"); + let a = parser.parse().unwrap(); + let b = parser.parse().unwrap(); + let c = parser.parse().unwrap(); + assert_eq!( + a, + Event::Key(KeyEvent { + key: Key::Char('a'), + modifiers: Modifiers::new() + }) + ); + assert_eq!( + b, + Event::Key(KeyEvent { + key: Key::Char('b'), + modifiers: Modifiers::new() + }) + ); + assert_eq!( + c, + Event::Key(KeyEvent { + key: Key::Char('c'), + modifiers: Modifiers::new() + }) + ); + } + + #[test] + fn test_modifiers_ctrl() { + let mods = Modifiers::ctrl(); + assert!(mods.ctrl); + assert!(!mods.alt); + assert!(!mods.shift); + } + + #[test] + fn test_modifiers_alt() { + let mods = Modifiers::alt(); + assert!(!mods.ctrl); + assert!(mods.alt); + assert!(!mods.shift); + } + + #[test] + fn test_parse_sgr_mouse_scroll_up() { + // SGR mouse format: \x1b[<64;col;rowM + // Button 64 = scroll up + let mut parser = EventParser::new(); + parser.feed(b"\x1b[<64;10;5M"); + let event = parser.parse().unwrap(); + assert_eq!(event, Event::Mouse(MouseEvent::ScrollUp { row: 4, col: 9 })); + } + + #[test] + fn test_parse_sgr_mouse_scroll_down() { + // SGR mouse format: \x1b[<65;col;rowM + // Button 65 = scroll down + let mut parser = EventParser::new(); + parser.feed(b"\x1b[<65;10;5M"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Mouse(MouseEvent::ScrollDown { row: 4, col: 9 }) + ); + } + + #[test] + fn test_parse_sgr_mouse_left_click() { + // SGR mouse format: \x1b[<0;col;rowM + let mut parser = EventParser::new(); + parser.feed(b"\x1b[<0;1;1M"); + let event = parser.parse().unwrap(); + assert_eq!( + event, + Event::Mouse(MouseEvent::Press { + row: 0, + col: 0, + button: MouseButton::Left, + }) + ); + } +} diff --git a/tinyharness-ui/src/tui/layout.rs b/tinyharness-ui/src/tui/layout.rs new file mode 100644 index 0000000..989899a --- /dev/null +++ b/tinyharness-ui/src/tui/layout.rs @@ -0,0 +1,432 @@ +// ── Constraint-based layout engine ────────────────────────────────────────── +// +// Splits rectangular areas into sub-areas based on constraints. +// Inspired by ratatui's layout system but implemented from scratch. + +/// A rectangular area on the screen. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Rect { + pub x: u16, + pub y: u16, + pub width: u16, + pub height: u16, +} + +impl Rect { + pub fn new(x: u16, y: u16, width: u16, height: u16) -> Self { + Self { + x, + y, + width, + height, + } + } + + /// The area (width * height) of this rectangle. + pub fn area(&self) -> u32 { + (self.width as u32) * (self.height as u32) + } + + /// Returns true if this rectangle has zero area. + pub fn is_empty(&self) -> bool { + self.width == 0 || self.height == 0 + } + + /// Check if a point is inside this rectangle. + pub fn contains(&self, x: u16, y: u16) -> bool { + x >= self.x && x < self.x + self.width && y >= self.y && y < self.y + self.height + } + + /// Clamp this rectangle to fit within another rectangle. + pub fn clamp_to(&self, other: Rect) -> Rect { + let x = self.x.max(other.x); + let y = self.y.max(other.y); + let max_x = (self.x + self.width).min(other.x + other.width); + let max_y = (self.y + self.height).min(other.y + other.height); + Rect { + x, + y, + width: max_x.saturating_sub(x), + height: max_y.saturating_sub(y), + } + } + + /// The right edge (x + width). + pub fn right(&self) -> u16 { + self.x + self.width + } + + /// The bottom edge (y + height). + pub fn bottom(&self) -> u16 { + self.y + self.height + } + + /// Shrink the rectangle by the given amount on all sides. + pub fn shrink(&self, amount: u16) -> Rect { + Rect { + x: self.x + amount, + y: self.y + amount, + width: self.width.saturating_sub(amount * 2), + height: self.height.saturating_sub(amount * 2), + } + } + + /// The inner area, shrunk by 1 cell on each side (for borders). + pub fn inner(&self) -> Rect { + self.shrink(1) + } + + /// Split the rectangle horizontally (top/bottom) at the given row offset. + pub fn split_horizontally(&self, at: u16) -> (Rect, Rect) { + let top_height = at.min(self.height); + let bottom_height = self.height.saturating_sub(top_height); + ( + Rect::new(self.x, self.y, self.width, top_height), + Rect::new(self.x, self.y + top_height, self.width, bottom_height), + ) + } + + /// Split the rectangle vertically (left/right) at the given column offset. + pub fn split_vertically(&self, at: u16) -> (Rect, Rect) { + let left_width = at.min(self.width); + let right_width = self.width.saturating_sub(left_width); + ( + Rect::new(self.x, self.y, left_width, self.height), + Rect::new(self.x + left_width, self.y, right_width, self.height), + ) + } +} + +/// Layout direction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Direction { + /// Stack areas horizontally (left to right). + Horizontal, + /// Stack areas vertically (top to bottom). + Vertical, +} + +/// A constraint for laying out areas. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Constraint { + /// Fixed size in cells. + Length(u16), + /// Percentage of the available space (0–100). + Percentage(u16), + /// At least this many cells. + Min(u16), + /// At most this many cells. + Max(u16), +} + +/// A layout specification. +/// +/// Splits a rectangle into sub-rectangles based on constraints and direction. +pub struct Layout { + pub direction: Direction, + pub constraints: Vec, + /// Gap between areas in cells (0 = no gap). + pub gap: u16, +} + +impl Layout { + pub fn new(direction: Direction) -> Self { + Self { + direction, + constraints: Vec::new(), + gap: 0, + } + } + + pub fn constraints(mut self, constraints: Vec) -> Self { + self.constraints = constraints; + self + } + + pub fn gap(mut self, gap: u16) -> Self { + self.gap = gap; + self + } + + /// Split the given area into sub-rectangles based on the constraints. + /// + /// The algorithm: + /// 1. Calculate the total available space (minus gaps). + /// 2. Resolve each constraint into a desired size. + /// 3. Distribute remaining space to `Min`/`Percentage` constraints. + /// 4. Return the list of rectangles. + pub fn split(self, area: Rect) -> Vec { + if self.constraints.is_empty() || area.is_empty() { + return vec![]; + } + + let n = self.constraints.len(); + let total_gap = self.gap as u32 * n.saturating_sub(1) as u32; + + match self.direction { + Direction::Vertical => self.split_vertical(area, total_gap), + Direction::Horizontal => self.split_horizontal(area, total_gap), + } + } + + fn split_vertical(&self, area: Rect, total_gap: u32) -> Vec { + let available = area.height as u32; + let available_after_gap = available.saturating_sub(total_gap); + let sizes = self.resolve_sizes(available_after_gap, area.height); + + let mut rects = Vec::with_capacity(sizes.len()); + let mut y = area.y; + for (i, &size) in sizes.iter().enumerate() { + let height = size.min(area.y + area.height - y); + if height == 0 { + break; + } + rects.push(Rect::new(area.x, y, area.width, height)); + y += height; + if i + 1 < sizes.len() { + y += self.gap; + } + } + rects + } + + fn split_horizontal(&self, area: Rect, total_gap: u32) -> Vec { + let available = area.width as u32; + let available_after_gap = available.saturating_sub(total_gap); + let sizes = self.resolve_sizes(available_after_gap, area.width); + + let mut rects = Vec::with_capacity(sizes.len()); + let mut x = area.x; + for (i, &size) in sizes.iter().enumerate() { + let width = size.min(area.x + area.width - x); + if width == 0 { + break; + } + rects.push(Rect::new(x, area.y, width, area.height)); + x += width; + if i + 1 < sizes.len() { + x += self.gap; + } + } + rects + } + + /// Resolve constraints into actual sizes given available space. + fn resolve_sizes(&self, available: u32, _total: u16) -> Vec { + let n = self.constraints.len(); + let mut sizes = vec![0u16; n]; + let mut remaining = available; + + // First pass: resolve Length and Max constraints + for (i, constraint) in self.constraints.iter().enumerate() { + match constraint { + Constraint::Length(len) => { + let resolved = (*len as u32).min(remaining); + sizes[i] = resolved as u16; + remaining = remaining.saturating_sub(resolved); + } + Constraint::Max(max) => { + let resolved = (*max as u32).min(remaining); + sizes[i] = resolved as u16; + remaining = remaining.saturating_sub(resolved); + } + _ => {} + } + } + + // Second pass: resolve Percentage constraints based on the total + // available space (before Length/Max), not the remaining space. + // This matches how TUI frameworks like ratatui handle percentages: + // percentages are of the total, not of what's left after fixed items. + let percentage_base = available; + for (i, constraint) in self.constraints.iter().enumerate() { + if let Constraint::Percentage(pct) = constraint { + let resolved = (percentage_base * *pct as u32 / 100).min(remaining); + sizes[i] = resolved as u16; + remaining = remaining.saturating_sub(resolved); + } + } + + // Third pass: resolve Min constraints from remaining space + for (i, constraint) in self.constraints.iter().enumerate() { + if let Constraint::Min(min) = constraint { + let resolved = (*min as u32).min(remaining); + sizes[i] = sizes[i].max(resolved as u16); + remaining = remaining.saturating_sub(resolved); + } + } + + // Distribute any remaining space to zero-sized areas + if remaining > 0 { + let zero_count = sizes.iter().filter(|&&s| s == 0).count() as u32; + if zero_count > 0 { + let per_area = remaining / zero_count.max(1); + for size in sizes.iter_mut() { + if *size == 0 { + *size = per_area as u16; + remaining = remaining.saturating_sub(per_area); + } + } + } + } + + sizes + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rect_new() { + let r = Rect::new(1, 2, 30, 10); + assert_eq!(r.x, 1); + assert_eq!(r.y, 2); + assert_eq!(r.width, 30); + assert_eq!(r.height, 10); + } + + #[test] + fn test_rect_area() { + let r = Rect::new(0, 0, 30, 10); + assert_eq!(r.area(), 300); + } + + #[test] + fn test_rect_is_empty() { + assert!(Rect::new(0, 0, 0, 10).is_empty()); + assert!(Rect::new(0, 0, 10, 0).is_empty()); + assert!(!Rect::new(0, 0, 10, 10).is_empty()); + } + + #[test] + fn test_rect_contains() { + let r = Rect::new(5, 5, 10, 10); + assert!(r.contains(5, 5)); + assert!(r.contains(14, 14)); + assert!(!r.contains(4, 5)); + assert!(!r.contains(15, 5)); + assert!(!r.contains(5, 15)); + } + + #[test] + fn test_rect_shrink() { + let r = Rect::new(0, 0, 10, 10).shrink(1); + assert_eq!(r.x, 1); + assert_eq!(r.y, 1); + assert_eq!(r.width, 8); + assert_eq!(r.height, 8); + } + + #[test] + fn test_rect_inner() { + let r = Rect::new(0, 0, 10, 10).inner(); + assert_eq!(r.x, 1); + assert_eq!(r.y, 1); + assert_eq!(r.width, 8); + assert_eq!(r.height, 8); + } + + #[test] + fn test_rect_split_horizontally() { + let r = Rect::new(0, 0, 80, 24); + let (top, bottom) = r.split_horizontally(6); + assert_eq!(top, Rect::new(0, 0, 80, 6)); + assert_eq!(bottom, Rect::new(0, 6, 80, 18)); + } + + #[test] + fn test_rect_split_vertically() { + let r = Rect::new(0, 0, 80, 24); + let (left, right) = r.split_vertically(60); + assert_eq!(left, Rect::new(0, 0, 60, 24)); + assert_eq!(right, Rect::new(60, 0, 20, 24)); + } + + #[test] + fn test_layout_vertical_fixed() { + let layout = Layout::new(Direction::Vertical).constraints(vec![ + Constraint::Length(1), + Constraint::Length(20), + Constraint::Length(3), + ]); + let area = Rect::new(0, 0, 80, 24); + let rects = layout.split(area); + + assert_eq!(rects.len(), 3); + assert_eq!(rects[0], Rect::new(0, 0, 80, 1)); // status bar + assert_eq!(rects[1], Rect::new(0, 1, 80, 20)); // main area + assert_eq!(rects[2], Rect::new(0, 21, 80, 3)); // input bar + } + + #[test] + fn test_layout_horizontal_percentage() { + let layout = Layout::new(Direction::Horizontal) + .constraints(vec![Constraint::Percentage(70), Constraint::Percentage(30)]); + let area = Rect::new(0, 0, 100, 24); + let rects = layout.split(area); + + assert_eq!(rects.len(), 2); + assert_eq!(rects[0].width, 70); + assert_eq!(rects[1].width, 30); + } + + #[test] + fn test_layout_with_gap() { + let layout = Layout::new(Direction::Horizontal) + .constraints(vec![Constraint::Length(60), Constraint::Length(30)]) + .gap(1); + let area = Rect::new(0, 0, 91, 24); + let rects = layout.split(area); + + assert_eq!(rects.len(), 2); + assert_eq!(rects[0], Rect::new(0, 0, 60, 24)); + assert_eq!(rects[1], Rect::new(61, 0, 30, 24)); + } + + #[test] + fn test_layout_nested() { + // Simulate the TinyHarness TUI layout: + // Top: status bar (1 row) + // Middle: conversation (70%) + sidebar (30%) + // Bottom: input bar (3 rows) + + let full = Rect::new(0, 0, 80, 24); + let main_layout = Layout::new(Direction::Vertical).constraints(vec![ + Constraint::Length(1), // status bar + Constraint::Min(0), // main area (takes remaining) + Constraint::Length(3), // input bar + ]); + let rects = main_layout.split(full); + + assert_eq!(rects[0].height, 1); // status bar + assert_eq!(rects[2].height, 3); // input bar + assert_eq!(rects[1].height, 20); // main area + + // Split main area horizontally (80 columns wide) + // 70% of 80 = 56, 30% of 80 = 24 + let side_layout = Layout::new(Direction::Horizontal) + .constraints(vec![Constraint::Percentage(70), Constraint::Percentage(30)]); + let side_rects = side_layout.split(rects[1]); + + assert_eq!(side_rects[0].width, 56); // 70% of 80 + assert_eq!(side_rects[1].width, 24); // 30% of 80 + } + + #[test] + fn test_layout_empty_constraints() { + let layout = Layout::new(Direction::Vertical).constraints(vec![]); + let area = Rect::new(0, 0, 80, 24); + let rects = layout.split(area); + assert!(rects.is_empty()); + } + + #[test] + fn test_layout_empty_area() { + let layout = Layout::new(Direction::Vertical).constraints(vec![Constraint::Length(10)]); + let area = Rect::new(0, 0, 0, 0); + let rects = layout.split(area); + assert!(rects.is_empty()); + } +} diff --git a/tinyharness-ui/src/tui/mod.rs b/tinyharness-ui/src/tui/mod.rs new file mode 100644 index 0000000..8f856d0 --- /dev/null +++ b/tinyharness-ui/src/tui/mod.rs @@ -0,0 +1,131 @@ +#[allow( + clippy::too_many_arguments, + clippy::collapsible_if, + clippy::cast_lossless, + clippy::new_without_default +)] +pub mod app; +#[allow( + clippy::too_many_arguments, + clippy::collapsible_if, + clippy::cast_lossless, + clippy::new_without_default +)] +pub mod backend; +#[allow( + clippy::too_many_arguments, + clippy::collapsible_if, + clippy::cast_lossless, + clippy::new_without_default +)] +pub mod cell; +#[allow(clippy::too_many_arguments)] +pub mod event; +pub mod layout; +#[allow( + clippy::too_many_arguments, + clippy::collapsible_if, + clippy::cast_lossless +)] +pub mod screen; +pub mod terminal; +pub mod widget; +pub mod widgets { + pub mod conversation; + pub mod input_bar; + pub mod sidebar; + pub mod spinner; + pub mod status_bar; + pub mod tool_output; +} + +// ── TUI Agent Integration Types ────────────────────────────────────────────── +// +// These types define the communication protocol between the TUI event loop +// (main thread) and the background agent task (tokio runtime). + +/// Events sent FROM the background agent task TO the TUI. +/// +/// These drive UI updates: streaming text, tool call notifications, +/// status changes, etc. +#[derive(Clone, Debug)] +pub enum TuiAgentEvent { + /// The agent started streaming a response. + StreamingStarted, + /// A chunk of assistant text arrived during streaming. + StreamingText(String), + /// A chunk of thinking/reasoning text arrived during streaming. + StreamingThinking(String), + /// The agent finished streaming a response. + StreamingDone, + /// The agent encountered an error. + Error(String), + /// A tool call was made by the assistant. + ToolCall { name: String, args_summary: String }, + /// A tool produced a result. + ToolResult { + name: String, + content: String, + is_error: bool, + }, + /// The agent mode changed. + ModeChanged(String), + /// The model name changed. + ModelChanged(String), + /// Token usage was updated. + TokenUpdate { count: u64, limit: Option }, + /// Context window warning triggered (70%+ or 90%+). + ContextWarning { percentage: f64, critical: bool }, + /// A system/info message to display. + SystemMessage(String), + /// The agent is requesting user confirmation for a tool call. + /// The TUI should show a confirmation prompt. + /// `diff_preview` contains a plain-text unified diff to display (for edit/write). + ConfirmTool { + name: String, + args_summary: String, + needs_approval: bool, + diff_preview: Option, + }, + /// The agent is asking a question (from the question signal tool). + Question { + question: String, + answers: Vec, + }, + /// The agent loop has exited (clean shutdown). + Done, +} + +/// Actions sent FROM the TUI TO the background agent task. +/// +/// These represent user interactions that the agent needs to know about. +#[derive(Clone, Debug)] +pub enum TuiUserAction { + /// The user submitted a message (text entered in the input bar). + SendMessage(String), + /// The user responded to a tool confirmation prompt. + ConfirmResponse { approved: bool, auto_accept: bool }, + /// The user answered a question from the question signal tool. + QuestionAnswer(String), + /// The user requested to quit. + Quit, + /// The user interrupted the current generation (Ctrl+C). + Interrupt, +} + +// Re-export key types at the module root for convenience +pub use app::{Focus, TuiApp, TuiGuard, TuiState, spawn_stdin_reader}; +pub use backend::{Backend, StdioBackend, TestBackend}; +pub use cell::{Cell, Color, Style}; +pub use event::{Event, EventParser, Key, KeyEvent, Modifiers, MouseButton, MouseEvent}; +pub use layout::{Constraint, Direction, Layout, Rect}; +pub use screen::Screen; +pub use terminal::{Size, Terminal}; +pub use widget::{Action, Widget}; + +pub use widgets::conversation::{ContextWarningLevel, ConversationLine, ConversationWidget}; +pub use widgets::input_bar::InputBarWidget; +pub use widgets::sidebar::SidebarWidget; +pub use widgets::spinner::SpinnerWidget; +pub use widgets::status_bar::StatusBarWidget; +pub use widgets::tool_output::{ToolOutputWidget, ToolResult, ToolStatus}; diff --git a/tinyharness-ui/src/tui/screen.rs b/tinyharness-ui/src/tui/screen.rs new file mode 100644 index 0000000..118b044 --- /dev/null +++ b/tinyharness-ui/src/tui/screen.rs @@ -0,0 +1,810 @@ +// ── Double-buffered screen ─────────────────────────────────────────────────── +// +// The screen is a 2D grid of cells. Each frame, we compute a new grid and +// diff it against the previous frame. Only changed cells are written to +// the terminal, achieving flicker-free rendering. + +use std::fmt; + +use super::cell::{Cell, Color, Style}; +use super::layout::Rect; + +// ── Screen ────────────────────────────────────────────────────────────────── + +/// A double-buffered screen of cells. +/// +/// The screen tracks the current state of every cell. When rendering, +/// the diff from the previous frame determines which cells need updating. +/// This avoids redrawing the entire screen on every frame. +pub struct Screen { + width: u16, + height: u16, + cells: Vec, +} + +impl Screen { + /// Create a new screen with the given dimensions, filled with default cells. + pub fn new(width: u16, height: u16) -> Self { + let cells = vec![Cell::default(); (width as usize) * (height as usize)]; + Screen { + width, + height, + cells, + } + } + + /// Resize the screen, clearing all content. + pub fn resize(&mut self, width: u16, height: u16) { + self.width = width; + self.height = height; + self.cells = vec![Cell::default(); (width as usize) * (height as usize)]; + } + + /// Clear the entire screen to default cells. + pub fn clear(&mut self) { + self.cells.fill(Cell::default()); + } + + /// Get the screen width in columns. + pub fn width(&self) -> u16 { + self.width + } + + /// Get the screen height in rows. + pub fn height(&self) -> u16 { + self.height + } + + /// Get a cell at the given position. Returns `None` if out of bounds. + pub fn get(&self, row: u16, col: u16) -> Option<&Cell> { + if row >= self.height || col >= self.width { + return None; + } + self.cells + .get((row as usize) * (self.width as usize) + (col as usize)) + } + + /// Get a mutable cell at the given position. Returns `None` if out of bounds. + pub fn get_mut(&mut self, row: u16, col: u16) -> Option<&mut Cell> { + if row >= self.height || col >= self.width { + return None; + } + let idx = (row as usize) * (self.width as usize) + (col as usize); + self.cells.get_mut(idx) + } + + /// Set a cell at the given position. Does nothing if out of bounds. + pub fn set_cell(&mut self, row: u16, col: u16, cell: Cell) { + if let Some(c) = self.get_mut(row, col) { + *c = cell; + } + } + + /// Write a string starting at the given position, with the given style. + /// + /// Characters that exceed the screen width are truncated. + pub fn write_str( + &mut self, + row: u16, + col: u16, + text: &str, + fg: Color, + bg: Color, + style: Style, + ) { + let mut c = col; + for ch in text.chars() { + if c >= self.width { + break; + } + self.set_cell( + row, + c, + Cell { + char: ch, + fg, + bg, + style, + }, + ); + c += 1; + } + } + + /// Write a string starting at the given position, truncating or wrapping. + /// + /// If `wrap` is true, text wraps to the next line. If false, text is + /// truncated at the right edge. + pub fn write_str_wrapped( + &mut self, + start_row: u16, + start_col: u16, + text: &str, + fg: Color, + bg: Color, + style: Style, + wrap: bool, + ) -> u16 { + let mut row = start_row; + let mut col = start_col; + + for ch in text.chars() { + if col >= self.width { + if wrap && row + 1 < self.height { + row += 1; + col = 0; + } else { + break; + } + } + if ch == '\n' { + row += 1; + col = 0; + continue; + } + self.set_cell( + row, + col, + Cell { + char: ch, + fg, + bg, + style, + }, + ); + col += 1; + } + + row + } + + /// Write a string with wrapping, but clip rendering at the given maximum row + /// and wrap at the given column. + /// + /// `wrap_col` is the maximum column number; text wraps when `col >= wrap_col`. + /// `max_row` is the maximum row; text stops when `row > max_row`. + /// `left_margin` is the column where wrapped lines start. + pub fn write_str_wrapped_clipped( + &mut self, + start_row: u16, + start_col: u16, + text: &str, + fg: Color, + bg: Color, + style: Style, + left_margin: u16, + max_row: u16, + wrap_col: u16, + ) -> u16 { + let mut row = start_row; + let mut col = start_col; + + for ch in text.chars() { + if col >= wrap_col { + // Wrap to next line + row += 1; + col = left_margin; + } + // Stop if we've exceeded the max row + if row > max_row { + break; + } + if ch == '\n' { + row += 1; + col = left_margin; + if row > max_row { + break; + } + continue; + } + self.set_cell( + row, + col, + Cell { + char: ch, + fg, + bg, + style, + }, + ); + col += 1; + } + + row + } + + /// Write a string with wrapping, skip the first `skip_rows` visual rows, + /// and clip rendering at the given maximum row and wrap column. + /// + /// `wrap_col` is the maximum column number; text wraps when `col >= wrap_col`. + /// `skip_rows` is the number of visual rows to skip before rendering. + /// `max_row` is the maximum row; text stops when `row > max_row`. + /// `left_margin` is the column where wrapped lines start. + pub fn write_str_wrapped_skip_clipped( + &mut self, + start_row: u16, + start_col: u16, + text: &str, + fg: Color, + bg: Color, + style: Style, + left_margin: u16, + max_row: u16, + wrap_col: u16, + skip_rows: usize, + ) { + let mut visual_row: usize = 0; + let mut col = start_col; + let mut screen_row = start_row; + + for ch in text.chars() { + // Check if we need to wrap before placing this character + if ch != '\n' && col >= wrap_col { + // Wrap to next visual line + visual_row += 1; + col = left_margin; + // Only advance screen_row if we're past the renderable zone + if visual_row > skip_rows { + screen_row += 1; + } + if screen_row > max_row { + break; + } + } + + if ch == '\n' { + // Newline — advance to next visual row + visual_row += 1; + col = left_margin; + if visual_row > skip_rows { + screen_row += 1; + } + if screen_row > max_row { + break; + } + continue; + } + + // Only write the cell if we're past the skip zone + if visual_row >= skip_rows && screen_row <= max_row { + self.set_cell( + screen_row, + col, + Cell { + char: ch, + fg, + bg, + style, + }, + ); + } + + col += 1; + } + } + + /// Fill a rectangular area with the given cell. + pub fn fill_rect(&mut self, rect: Rect, cell: Cell) { + for row in rect.y..rect.y + rect.height { + for col in rect.x..rect.x + rect.width { + if row < self.height && col < self.width { + self.set_cell(row, col, cell.clone()); + } + } + } + } + + /// Draw a horizontal line using the given character. + pub fn hline( + &mut self, + row: u16, + col_start: u16, + col_end: u16, + ch: char, + fg: Color, + bg: Color, + ) { + for col in col_start..=col_end.min(self.width.saturating_sub(1)) { + self.set_cell( + row, + col, + Cell { + char: ch, + fg, + bg, + style: Style::default(), + }, + ); + } + } + + /// Draw a vertical line using the given character. + pub fn vline( + &mut self, + col: u16, + row_start: u16, + row_end: u16, + ch: char, + fg: Color, + bg: Color, + ) { + for row in row_start..=row_end.min(self.height.saturating_sub(1)) { + self.set_cell( + row, + col, + Cell { + char: ch, + fg, + bg, + style: Style::default(), + }, + ); + } + } + + /// Draw a box (border) around a rectangular area. + pub fn draw_box(&mut self, rect: Rect, fg: Color, bg: Color, style: Style) { + let x = rect.x; + let y = rect.y; + let w = rect.width; + let h = rect.height; + + if w < 2 || h < 2 { + return; + } + + // Corners + self.set_cell( + y, + x, + Cell { + char: '┌', + fg, + bg, + style, + }, + ); + self.set_cell( + y, + x + w - 1, + Cell { + char: '┐', + fg, + bg, + style, + }, + ); + self.set_cell( + y + h - 1, + x, + Cell { + char: '└', + fg, + bg, + style, + }, + ); + self.set_cell( + y + h - 1, + x + w - 1, + Cell { + char: '┘', + fg, + bg, + style, + }, + ); + + // Top and bottom borders + for col in (x + 1)..(x + w - 1) { + self.set_cell( + y, + col, + Cell { + char: '─', + fg, + bg, + style, + }, + ); + self.set_cell( + y + h - 1, + col, + Cell { + char: '─', + fg, + bg, + style, + }, + ); + } + + // Left and right borders + for row in (y + 1)..(y + h - 1) { + self.set_cell( + row, + x, + Cell { + char: '│', + fg, + bg, + style, + }, + ); + self.set_cell( + row, + x + w - 1, + Cell { + char: '│', + fg, + bg, + style, + }, + ); + } + } + + // ── Diff-based rendering ──────────────────────────────────────────── + + /// Compute the diff between this screen and a previous frame. + /// + /// Returns a list of `DiffOp` entries that, when applied in order, + /// will bring the terminal from the previous state to the current state. + pub fn diff_from(&self, previous: &Screen) -> Vec { + let mut ops = Vec::new(); + let max_row = self.height.min(previous.height); + let max_col = self.width.min(previous.width); + + for row in 0..max_row { + for col in 0..max_col { + let prev_cell = previous.get(row, col); + let curr_cell = self.get(row, col); + + match (prev_cell, curr_cell) { + (Some(prev), Some(curr)) if prev != curr => { + ops.push(DiffOp::SetCell { + row, + col, + cell: curr.clone(), + }); + } + (None, Some(curr)) => { + ops.push(DiffOp::SetCell { + row, + col, + cell: curr.clone(), + }); + } + _ => {} + } + } + } + + // Handle rows that exist in the new screen but not the old one + for row in previous.height..self.height { + for col in 0..self.width { + if let Some(cell) = self.get(row, col) { + ops.push(DiffOp::SetCell { + row, + col, + cell: cell.clone(), + }); + } + } + } + + ops + } + + /// Render a list of diff operations to an ANSI escape sequence string. + /// + /// This is the core of the efficient rendering: we only write cells + /// that actually changed, and we batch cursor movements. + pub fn render_diff(ops: &[DiffOp], width: u16) -> String { + if ops.is_empty() { + return String::new(); + } + + let mut output = String::with_capacity(ops.len() * 20); + let mut last_row: Option = None; + let mut last_col: Option = None; + + for op in ops { + match op { + DiffOp::SetCell { row, col, cell } => { + // Move cursor if needed + let need_move = last_row != Some(*row) || last_col.unwrap_or(0) + 1 != *col; + + if need_move { + output.push_str(&format!("\x1b[{};{}H", row + 1, col + 1)); + } + + // Apply style + output.push_str(&cell.style.escape()); + // Apply foreground color + output.push_str(&cell.fg.fg_escape()); + // Apply background color + output.push_str(&cell.bg.bg_escape()); + // Write character + output.push(cell.char); + + last_row = Some(*row); + last_col = Some(*col + 1); + + // If we're at the right edge, the cursor won't advance + // further, so we need to move it explicitly next time + if *col + 1 >= width { + last_col = None; + } + } + } + } + + // Reset all styles at the end + output.push_str(Style::reset()); + + output + } +} + +impl Clone for Screen { + fn clone(&self) -> Self { + Screen { + width: self.width, + height: self.height, + cells: self.cells.clone(), + } + } +} + +impl fmt::Debug for Screen { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Screen") + .field("width", &self.width) + .field("height", &self.height) + .finish() + } +} + +// ── Diff operation ─────────────────────────────────────────────────────────── + +/// A single rendering operation produced by diffing two screens. +#[derive(Clone, Debug, PartialEq)] +pub enum DiffOp { + /// Set the cell at (row, col) to the given value. + SetCell { row: u16, col: u16, cell: Cell }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_screen_new() { + let s = Screen::new(10, 5); + assert_eq!(s.width(), 10); + assert_eq!(s.height(), 5); + // All cells should be default (space) + assert_eq!(s.get(0, 0).unwrap().char, ' '); + } + + #[test] + fn test_screen_set_get_cell() { + let mut s = Screen::new(10, 5); + let cell = Cell::styled('X', Color::RED, Color::Default, Style::bold()); + s.set_cell(2, 3, cell.clone()); + assert_eq!(s.get(2, 3).unwrap(), &cell); + } + + #[test] + fn test_screen_out_of_bounds() { + let s = Screen::new(10, 5); + assert!(s.get(5, 0).is_none()); + assert!(s.get(0, 10).is_none()); + } + + #[test] + fn test_screen_write_str() { + let mut s = Screen::new(20, 5); + s.write_str(1, 2, "Hello", Color::GREEN, Color::Default, Style::new()); + assert_eq!(s.get(1, 2).unwrap().char, 'H'); + assert_eq!(s.get(1, 3).unwrap().char, 'e'); + assert_eq!(s.get(1, 6).unwrap().char, 'o'); + assert_eq!(s.get(1, 7).unwrap().char, ' '); // default + } + + #[test] + fn test_screen_write_str_truncates() { + let mut s = Screen::new(5, 1); + s.write_str( + 0, + 0, + "Hello World", + Color::Default, + Color::Default, + Style::new(), + ); + assert_eq!(s.get(0, 4).unwrap().char, 'o'); // 5th char (index 4) + // "World" should be truncated + } + + #[test] + fn test_screen_clear() { + let mut s = Screen::new(10, 5); + s.set_cell(0, 0, Cell::char('X')); + s.clear(); + assert_eq!(s.get(0, 0).unwrap().char, ' '); + } + + #[test] + fn test_screen_resize() { + let mut s = Screen::new(10, 5); + s.set_cell(0, 0, Cell::char('X')); + s.resize(20, 10); + assert_eq!(s.width(), 20); + assert_eq!(s.height(), 10); + // Old content should be gone + assert_eq!(s.get(0, 0).unwrap().char, ' '); + } + + #[test] + fn test_screen_draw_box() { + let mut s = Screen::new(10, 5); + s.draw_box( + Rect { + x: 0, + y: 0, + width: 10, + height: 5, + }, + Color::BLUE, + Color::Default, + Style::default(), + ); + assert_eq!(s.get(0, 0).unwrap().char, '┌'); + assert_eq!(s.get(0, 9).unwrap().char, '┐'); + assert_eq!(s.get(4, 0).unwrap().char, '└'); + assert_eq!(s.get(4, 9).unwrap().char, '┘'); + assert_eq!(s.get(0, 5).unwrap().char, '─'); + assert_eq!(s.get(2, 0).unwrap().char, '│'); + } + + #[test] + fn test_screen_diff_no_changes() { + let s1 = Screen::new(10, 5); + let s2 = Screen::new(10, 5); + let diff = s2.diff_from(&s1); + assert!(diff.is_empty()); + } + + #[test] + fn test_screen_diff_with_changes() { + let s1 = Screen::new(10, 5); + let mut s2 = Screen::new(10, 5); + s2.set_cell(1, 2, Cell::char('X')); + s2.set_cell(3, 4, Cell::char('Y')); + + let diff = s2.diff_from(&s1); + assert_eq!(diff.len(), 2); + } + + #[test] + fn test_screen_render_diff() { + let s1 = Screen::new(10, 5); + let mut s2 = Screen::new(10, 5); + s2.set_cell( + 0, + 0, + Cell::styled('A', Color::RED, Color::Default, Style::bold()), + ); + + let diff = s2.diff_from(&s1); + let rendered = Screen::render_diff(&diff, 10); + + // Should contain cursor movement and the character + assert!(rendered.contains("\x1b[1;1H")); // move to (1,1) + assert!(rendered.contains('A')); + assert!(rendered.contains("\x1b[0m")); // reset at end + } + + #[test] + fn test_screen_hline() { + let mut s = Screen::new(10, 5); + s.hline(2, 1, 8, '─', Color::Default, Color::Default); + assert_eq!(s.get(2, 1).unwrap().char, '─'); + assert_eq!(s.get(2, 8).unwrap().char, '─'); + assert_eq!(s.get(2, 0).unwrap().char, ' '); // before line + } + + #[test] + fn test_screen_vline() { + let mut s = Screen::new(10, 5); + s.vline(5, 1, 3, '│', Color::Default, Color::Default); + assert_eq!(s.get(1, 5).unwrap().char, '│'); + assert_eq!(s.get(2, 5).unwrap().char, '│'); + assert_eq!(s.get(3, 5).unwrap().char, '│'); + assert_eq!(s.get(0, 5).unwrap().char, ' '); // before line + } + + #[test] + fn test_screen_fill_rect() { + let mut s = Screen::new(10, 5); + let rect = Rect { + x: 2, + y: 1, + width: 3, + height: 2, + }; + s.fill_rect(rect, Cell::char('█')); + + assert_eq!(s.get(1, 2).unwrap().char, '█'); + assert_eq!(s.get(1, 4).unwrap().char, '█'); + assert_eq!(s.get(2, 2).unwrap().char, '█'); + assert_eq!(s.get(0, 2).unwrap().char, ' '); // outside rect + } + + #[test] + fn test_screen_write_str_wrapped() { + let mut s = Screen::new(5, 5); + let end_row = s.write_str_wrapped( + 0, + 0, + "ABCDEFGH", + Color::Default, + Color::Default, + Style::new(), + true, + ); + // "ABCDE" on row 0, "FGH" on row 1 + assert_eq!(s.get(0, 0).unwrap().char, 'A'); + assert_eq!(s.get(0, 4).unwrap().char, 'E'); + assert_eq!(s.get(1, 0).unwrap().char, 'F'); + assert_eq!(s.get(1, 2).unwrap().char, 'H'); + assert_eq!(end_row, 1); + } + + #[test] + fn test_screen_write_str_wrapped_skip_clipped() { + let mut s = Screen::new(5, 5); + // "ABCDE" on row 0, "FGH" on row 1 + // Skip the first row, render only "FGH" starting at screen row 0 + s.write_str_wrapped_skip_clipped( + 0, + 0, + "ABCDEFGH", + Color::Default, + Color::Default, + Style::new(), + 0, + 4, + 5, // wrap_col + 1, // skip 1 row + ); + // Row 0 should have "FGH" (the 2nd visual row of the text) + assert_eq!(s.get(0, 0).unwrap().char, 'F'); + assert_eq!(s.get(0, 2).unwrap().char, 'H'); + // Row 1 should be empty (default) + assert_eq!(s.get(1, 0).unwrap().char, ' '); + } + + #[test] + fn test_screen_write_str_wrapped_skip_clipped_newlines() { + let mut s = Screen::new(5, 5); + // "AB\nCD" → row 0: "AB", row 1: "CD" + // Skip 1 row, render "CD" starting at screen row 0 + s.write_str_wrapped_skip_clipped( + 0, + 0, + "AB\nCD", + Color::Default, + Color::Default, + Style::new(), + 0, + 4, + 5, + 1, + ); + assert_eq!(s.get(0, 0).unwrap().char, 'C'); + assert_eq!(s.get(0, 1).unwrap().char, 'D'); + } +} diff --git a/tinyharness-ui/src/tui/terminal.rs b/tinyharness-ui/src/tui/terminal.rs new file mode 100644 index 0000000..f2690f7 --- /dev/null +++ b/tinyharness-ui/src/tui/terminal.rs @@ -0,0 +1,524 @@ +// ── Terminal control primitives ───────────────────────────────────────────── +// +// Raw mode, alternate screen buffer, cursor control, and terminal size +// detection — all using raw ANSI sequences and POSIX termios. +// No external TUI framework dependency. + +use std::io::{self, Write}; + +// ── Terminal size ──────────────────────────────────────────────────────────── + +/// Terminal size in columns and rows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Size { + pub cols: u16, + pub rows: u16, +} + +impl Size { + pub fn new(cols: u16, rows: u16) -> Self { + Self { cols, rows } + } + + /// Get the terminal size from the TIOCGWINSZ ioctl (Unix). + #[cfg(unix)] + pub fn from_terminal() -> io::Result { + // Safety: TIOCGWINSZ is a read-only ioctl that doesn't modify memory. + let mut winsize: libc::winsize = libc::winsize { + ws_row: 0, + ws_col: 0, + ws_xpixel: 0, + ws_ypixel: 0, + }; + let result = unsafe { + libc::ioctl( + libc::STDOUT_FILENO, + libc::TIOCGWINSZ, + &mut winsize as *mut _, + ) + }; + if result == -1 { + return Err(io::Error::last_os_error()); + } + Ok(Size { + rows: winsize.ws_row, + cols: winsize.ws_col, + }) + } + + /// Fallback: use environment variables COLUMNS and LINES. + pub fn from_env() -> Option { + let cols = std::env::var("COLUMNS") + .ok() + .and_then(|v| v.parse::().ok())?; + let rows = std::env::var("LINES") + .ok() + .and_then(|v| v.parse::().ok())?; + Some(Size { cols, rows }) + } + + /// Default size used when detection fails. + pub fn default_size() -> Self { + Size { cols: 80, rows: 24 } + } +} + +// ── Raw mode control ──────────────────────────────────────────────────────── + +/// Saved terminal state for restoration on exit. +#[cfg(unix)] +struct SavedTermios { + original: libc::termios, +} + +/// Manages raw terminal mode and alternate screen buffer. +/// +/// On construction, the current terminal settings are saved. +/// `enter_raw_mode()` switches to raw mode (no echo, no line buffering, +/// no signal processing). `leave_raw_mode()` restores the original settings. +/// +/// The alternate screen buffer is managed separately: +/// `enter_alternate_screen()` switches to a private buffer so the original +/// terminal content is preserved on exit. +pub struct Terminal { + writer: W, + size: Size, + #[cfg(unix)] + saved_termios: Option, + in_raw_mode: bool, + in_alternate_screen: bool, + cursor_hidden: bool, + mouse_enabled: bool, + bracketed_paste_enabled: bool, +} + +impl Terminal { + pub fn new(writer: W) -> io::Result { + let size = Size::from_terminal() + .unwrap_or_else(|_| Size::from_env().unwrap_or_else(Size::default_size)); + + Ok(Terminal { + writer, + size, + #[cfg(unix)] + saved_termios: None, + in_raw_mode: false, + in_alternate_screen: false, + cursor_hidden: false, + mouse_enabled: false, + bracketed_paste_enabled: false, + }) + } + + /// Update the cached terminal size. + pub fn update_size(&mut self) { + self.size = Size::from_terminal() + .unwrap_or_else(|_| Size::from_env().unwrap_or_else(Size::default_size)); + } + + /// Get the current terminal size. + pub fn size(&self) -> Size { + self.size + } + + // ── Raw mode ──────────────────────────────────────────────────────── + + /// Switch the terminal to raw mode (no echo, no line buffering, + /// no signal processing, character-at-a-time input). + #[cfg(unix)] + pub fn enter_raw_mode(&mut self) -> io::Result<()> { + if self.in_raw_mode { + return Ok(()); + } + + // Get current terminal attributes + let mut termios: libc::termios = unsafe { std::mem::zeroed() }; + let result = unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut termios) }; + if result == -1 { + return Err(io::Error::last_os_error()); + } + + // Save original settings + self.saved_termios = Some(SavedTermios { original: termios }); + + // Modify for raw mode + // Turn off: ECHO (echo), ICANON (line buffering), ISIG (signals), + // IEXTEN (extended processing), OPOST (output processing) + let new_termios = { + let mut t = termios; + t.c_iflag &= !(libc::BRKINT | libc::ICRNL | libc::INPCK | libc::ISTRIP | libc::IXON); + t.c_oflag &= !libc::OPOST; + t.c_cflag |= libc::CS8; + t.c_lflag &= !(libc::ECHO | libc::ICANON | libc::ISIG | libc::IEXTEN); + // Minimum bytes for read: 1 (character-at-a-time) + t.c_cc[libc::VMIN] = 1; + // Timeout: 0 (blocking read, no timeout) + t.c_cc[libc::VTIME] = 0; + t + }; + + // Apply new settings (drain any pending output first) + let result = unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSAFLUSH, &new_termios) }; + if result == -1 { + return Err(io::Error::last_os_error()); + } + + self.in_raw_mode = true; + Ok(()) + } + + /// Restore the terminal to its original settings. + #[cfg(unix)] + pub fn leave_raw_mode(&mut self) -> io::Result<()> { + if !self.in_raw_mode { + return Ok(()); + } + + if let Some(saved) = &self.saved_termios { + let result = + unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSAFLUSH, &saved.original) }; + if result == -1 { + return Err(io::Error::last_os_error()); + } + } + + self.in_raw_mode = false; + Ok(()) + } + + // ── Alternate screen buffer ───────────────────────────────────────── + + /// Switch to the alternate screen buffer. + /// + /// This preserves the original terminal content. Call + /// `leave_alternate_screen()` to restore it. + pub fn enter_alternate_screen(&mut self) -> io::Result<()> { + if self.in_alternate_screen { + return Ok(()); + } + self.writer.write_all(b"\x1b[?1049h")?; + self.writer.flush()?; + self.in_alternate_screen = true; + Ok(()) + } + + /// Switch back to the main screen buffer. + pub fn leave_alternate_screen(&mut self) -> io::Result<()> { + if !self.in_alternate_screen { + return Ok(()); + } + self.writer.write_all(b"\x1b[?1049l")?; + self.writer.flush()?; + self.in_alternate_screen = false; + Ok(()) + } + + // ── Cursor control ───────────────────────────────────────────────── + + /// Move the cursor to the specified row and column (1-based). + pub fn set_cursor_pos(&mut self, row: u16, col: u16) -> io::Result<()> { + write!(self.writer, "\x1b[{};{}H", row, col)?; + Ok(()) + } + + /// Hide the cursor. + pub fn hide_cursor(&mut self) -> io::Result<()> { + if self.cursor_hidden { + return Ok(()); + } + self.writer.write_all(b"\x1b[?25l")?; + self.writer.flush()?; + self.cursor_hidden = true; + Ok(()) + } + + /// Show the cursor. + pub fn show_cursor(&mut self) -> io::Result<()> { + if !self.cursor_hidden { + return Ok(()); + } + self.writer.write_all(b"\x1b[?25h")?; + self.writer.flush()?; + self.cursor_hidden = false; + Ok(()) + } + + // ── Mouse tracking ────────────────────────────────────────────────── + + /// Enable mouse tracking (button presses, releases, and scrolling). + pub fn enable_mouse(&mut self) -> io::Result<()> { + if self.mouse_enabled { + return Ok(()); + } + // Enable basic mouse tracking (press/release) + self.writer.write_all(b"\x1b[?1000h")?; + // Enable button-motion tracking (drag) + self.writer.write_all(b"\x1b[?1002h")?; + // Enable SGR mouse mode (better coordinate reporting) + self.writer.write_all(b"\x1b[?1006h")?; + self.writer.flush()?; + self.mouse_enabled = true; + Ok(()) + } + + /// Disable mouse tracking. + pub fn disable_mouse(&mut self) -> io::Result<()> { + if !self.mouse_enabled { + return Ok(()); + } + // Disable in reverse order + self.writer.write_all(b"\x1b[?1006l")?; + self.writer.write_all(b"\x1b[?1002l")?; + self.writer.write_all(b"\x1b[?1000l")?; + self.writer.flush()?; + self.mouse_enabled = false; + Ok(()) + } + + // ── Bracketed paste ───────────────────────────────────────────────── + + /// Enable bracketed paste mode. + /// + /// When enabled, pasted text is surrounded by escape sequences: + /// `\x1b[200~` (paste start) and `\x1b[201~` (paste end). + /// This allows the TUI to treat pasted text as a single input event + /// rather than individual key presses. + pub fn enable_bracketed_paste(&mut self) -> io::Result<()> { + if self.bracketed_paste_enabled { + return Ok(()); + } + self.writer.write_all(b"\x1b[?2004h")?; + self.writer.flush()?; + self.bracketed_paste_enabled = true; + Ok(()) + } + + /// Disable bracketed paste mode. + pub fn disable_bracketed_paste(&mut self) -> io::Result<()> { + if !self.bracketed_paste_enabled { + return Ok(()); + } + self.writer.write_all(b"\x1b[?2004l")?; + self.writer.flush()?; + self.bracketed_paste_enabled = false; + Ok(()) + } + + // ── Screen control ───────────────────────────────────────────────── + + /// Clear the entire screen and move cursor to (1, 1). + pub fn clear_screen(&mut self) -> io::Result<()> { + self.writer.write_all(b"\x1b[2J\x1b[H")?; + self.writer.flush() + } + + /// Clear from cursor to end of line. + pub fn clear_to_eol(&mut self) -> io::Result<()> { + self.writer.write_all(b"\x1b[K")?; + Ok(()) + } + + /// Write raw bytes to the terminal. + pub fn write_raw(&mut self, data: &[u8]) -> io::Result<()> { + self.writer.write_all(data) + } + + /// Flush buffered output. + pub fn flush(&mut self) -> io::Result<()> { + self.writer.flush() + } +} + +impl Write for Terminal { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.writer.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.writer.flush() + } +} + +impl Drop for Terminal { + fn drop(&mut self) { + // Restore terminal state on drop + let _ = self.disable_mouse(); + let _ = self.disable_bracketed_paste(); + let _ = self.show_cursor(); + let _ = self.leave_alternate_screen(); + #[cfg(unix)] + { + let _ = self.leave_raw_mode(); + } + } +} + +// ── Test backend (in-memory terminal) ──────────────────────────────────────── + +#[cfg(test)] +pub struct TestBackend { + pub buffer: Vec, + pub size: Size, +} + +#[cfg(test)] +impl TestBackend { + pub fn new(size: Size) -> Self { + Self { + buffer: Vec::new(), + size, + } + } +} + +#[cfg(test)] +impl std::io::Write for TestBackend { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.buffer.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_size_default() { + let s = Size::default_size(); + assert_eq!(s.cols, 80); + assert_eq!(s.rows, 24); + } + + #[test] + fn test_size_new() { + let s = Size::new(120, 40); + assert_eq!(s.cols, 120); + assert_eq!(s.rows, 40); + } + + use std::io::Write; + + /// A writer that captures output into a `Vec` for testing. + /// Unlike `Vec`, this doesn't have Drop-related borrow issues. + struct TestWriter { + buf: Vec, + } + + impl TestWriter { + fn new() -> Self { + Self { buf: Vec::new() } + } + + fn into_contents(self) -> Vec { + self.buf + } + } + + impl Write for TestWriter { + fn write(&mut self, data: &[u8]) -> std::io::Result { + self.buf.extend_from_slice(data); + Ok(data.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + /// Helper: write some terminal commands, drop, and return the captured bytes. + /// The Drop impl writes cleanup sequences, so we check that our sequences + /// appear *anywhere* in the output, not just at the end. + fn with_terminal)>(f: F) -> Vec { + let mut writer = TestWriter::new(); + { + let mut term = Terminal::new(&mut writer).unwrap(); + f(&mut term); + // Drop restores terminal state, writing cleanup sequences + } + writer.buf + } + + /// Check if a byte slice contains a specific subsequence. + fn contains_seq(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) + } + + #[test] + fn test_terminal_enter_leave_alternate_screen() { + let buf = with_terminal(|term| { + term.enter_alternate_screen().unwrap(); + }); + assert!(contains_seq(&buf, b"\x1b[?1049h")); + } + + #[test] + fn test_terminal_hide_show_cursor() { + let buf = with_terminal(|term| { + term.hide_cursor().unwrap(); + }); + assert!(contains_seq(&buf, b"\x1b[?25l")); + + let buf2 = with_terminal(|term| { + term.hide_cursor().unwrap(); + term.show_cursor().unwrap(); + }); + assert!(contains_seq(&buf2, b"\x1b[?25h")); + } + + #[test] + fn test_terminal_enable_disable_mouse() { + let buf = with_terminal(|term| { + term.enable_mouse().unwrap(); + }); + assert!(contains_seq(&buf, b"\x1b[?1006h")); + + let buf2 = with_terminal(|term| { + term.enable_mouse().unwrap(); + term.disable_mouse().unwrap(); + }); + assert!(contains_seq(&buf2, b"\x1b[?1000l")); + } + + #[test] + fn test_terminal_clear_screen() { + let buf = with_terminal(|term| { + term.clear_screen().unwrap(); + }); + assert!(contains_seq(&buf, b"\x1b[2J\x1b[H")); + } + + #[test] + fn test_terminal_set_cursor_pos() { + let buf = with_terminal(|term| { + term.set_cursor_pos(5, 10).unwrap(); + }); + assert!(contains_seq(&buf, b"\x1b[5;10H")); + } + + #[test] + fn test_terminal_bracketed_paste() { + let buf = with_terminal(|term| { + term.enable_bracketed_paste().unwrap(); + }); + assert!(contains_seq(&buf, b"\x1b[?2004h")); + + let buf2 = with_terminal(|term| { + term.enable_bracketed_paste().unwrap(); + term.disable_bracketed_paste().unwrap(); + }); + assert!(contains_seq(&buf2, b"\x1b[?2004l")); + } + + #[test] + fn test_terminal_write_raw() { + let buf = with_terminal(|term| { + term.write_raw(b"hello").unwrap(); + }); + assert!(contains_seq(&buf, b"hello")); + } +} diff --git a/tinyharness-ui/src/tui/widget.rs b/tinyharness-ui/src/tui/widget.rs new file mode 100644 index 0000000..ce4909e --- /dev/null +++ b/tinyharness-ui/src/tui/widget.rs @@ -0,0 +1,183 @@ +// ── Widget trait and action types ───────────────────────────────────────────── +// +// All TUI widgets implement the `Widget` trait, which provides a `render` +// method (draw to a screen buffer) and an `handle_event` method (process +// input events and optionally return an action). + +use super::cell::Color; +use super::event::Event; +use super::layout::Rect; +use super::screen::Screen; + +// ── Action type ───────────────────────────────────────────────────────────── + +/// Actions that a widget can request from the application. +/// +/// When a widget handles an event, it can optionally return an action +/// that the application should perform (e.g., sending a message, +/// switching modes, or quitting). +#[derive(Clone, Debug)] +pub enum Action { + /// Send a message to the AI (user pressed Enter in the input bar). + SendMessage(String), + /// Switch to a different agent mode. + SwitchMode(String), + /// Scroll the conversation up. + ScrollUp, + /// Scroll the conversation down. + ScrollDown, + /// Scroll the conversation up by a page. + PageUp, + /// Scroll the conversation down by a page. + PageDown, + /// Toggle sidebar visibility. + ToggleSidebar, + /// Cycle focus forward (Tab without command input). + CycleFocusForward, + /// Cycle focus backward (Shift+Tab). + CycleFocusBackward, + /// Quit the application. + Quit, + /// User approved a tool confirmation (pressed 'y'). + ConfirmYes, + /// User denied a tool confirmation (pressed 'n'). + ConfirmNo, + /// User approved all future tool confirmations (pressed 'a'). + ConfirmAll, + /// Exit structure/file browser mode (Escape at root directory). + ExitStructureMode, + /// User answered a question with their input text. + AnswerQuestion(String), + /// User requested to interrupt the current generation (Ctrl+C while streaming). + Interrupt, + /// No action — the event was handled internally. + None, +} + +// ── Widget trait ───────────────────────────────────────────────────────────── + +/// A UI widget that can render itself to a screen buffer and handle events. +/// +/// Widgets are the building blocks of the TUI. Each widget owns its own +/// state and can render itself into a rectangular area of the screen. +pub trait Widget { + /// Render the widget into the given area of the screen buffer. + /// + /// This method should not write to the terminal directly. Instead, it + /// writes cells to the screen buffer, which is then diff-rendered. + fn render(&mut self, area: Rect, screen: &mut Screen); + + /// Handle an event and optionally return an action. + /// + /// Only the focused widget receives events. Other widgets should + /// return `Action::None`. + fn handle_event(&mut self, event: &Event) -> Action { + let _ = event; + Action::None + } + + /// Whether this widget currently has keyboard focus. + fn focused(&self) -> bool { + false + } + + /// Set whether this widget has keyboard focus. + fn set_focus(&mut self, _focused: bool) {} +} + +// ── Helper functions for widgets ───────────────────────────────────────────── + +/// Style presets commonly used across widgets. +pub mod styles { + use super::Color; + + /// Status bar background and text colors. + pub const STATUS_BAR_FG: Color = Color::WHITE; + pub const STATUS_BAR_BG: Color = Color::Ansi(236); + + /// Input bar background and text colors. + pub const INPUT_BAR_FG: Color = Color::WHITE; + pub const INPUT_BAR_BG: Color = Color::Ansi(235); + + /// Sidebar background and text colors. + pub const SIDEBAR_FG: Color = Color::Ansi(252); // light gray + pub const SIDEBAR_BG: Color = Color::Ansi(234); // dark gray + pub const SIDEBAR_BORDER: Color = Color::Ansi(240); // medium gray + + /// Conversation text colors. + pub const USER_MSG_FG: Color = Color::GREEN; + pub const ASSISTANT_MSG_FG: Color = Color::WHITE; + pub const TOOL_MSG_FG: Color = Color::Ansi(14); // bright cyan + pub const THINKING_FG: Color = Color::Ansi(97); // dimmer magenta + + /// Scrollbar colors. + pub const SCROLLBAR_FG: Color = Color::Ansi(244); // gray + pub const SCROLLBAR_BG: Color = Color::Default; + + /// Mode label colors (matching existing CLI). + pub const MODE_CASUAL_FG: Color = Color::GREEN; + pub const MODE_PLANNING_FG: Color = Color::YELLOW; + pub const MODE_AGENT_FG: Color = Color::CYAN; + pub const MODE_RESEARCH_FG: Color = Color::ORANGE; + + /// Box drawing characters. + pub const BOX_HORIZONTAL: char = '─'; + pub const BOX_VERTICAL: char = '│'; + pub const BOX_TOP_LEFT: char = '┌'; + pub const BOX_TOP_RIGHT: char = '┐'; + pub const BOX_BOTTOM_LEFT: char = '└'; + pub const BOX_BOTTOM_RIGHT: char = '┘'; + pub const BOX_LEFT_TEE: char = '├'; + pub const BOX_RIGHT_TEE: char = '┤'; + pub const BOX_TOP_TEE: char = '┬'; + pub const BOX_BOTTOM_TEE: char = '┴'; + pub const BOX_CROSS: char = '┼'; +} + +/// Safely truncate a string to at most `max_len` bytes, respecting UTF-8 +/// char boundaries. Returns a string slice that fits within `max_len` bytes. +/// +/// Use this instead of `&s[..n]` which can panic if `n` lands inside a +/// multi-byte UTF-8 character (common with emoji, CJK, accented letters). +pub fn truncate_str(s: &str, max_len: usize) -> &str { + if s.len() <= max_len { + return s; + } + // Find the largest char boundary <= max_len + let mut boundary = max_len; + while boundary > 0 && !s.is_char_boundary(boundary) { + boundary -= 1; + } + &s[..boundary] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_truncate_str_ascii() { + assert_eq!(truncate_str("hello", 3), "hel"); + assert_eq!(truncate_str("hello", 10), "hello"); + assert_eq!(truncate_str("hello", 0), ""); + } + + #[test] + fn test_truncate_str_multibyte() { + // 🦀 is 4 bytes + assert_eq!(truncate_str("🦀hello", 5), "🦀h"); // 🦀=4 bytes, +h=5 + assert_eq!(truncate_str("🦀hello", 3), ""); // 🦀=4 bytes, boundary=0 + // ⚙ is 3 bytes — truncating at byte 4 should include it + let s = "⚙hello"; + assert_eq!(truncate_str(s, 4), "⚙h"); // ⚙=3 bytes + h=1 = 4 + } + + #[test] + fn test_truncate_str_no_panic() { + // Should never panic even with tricky byte boundaries + let s = "naïve café 🦀"; + for i in 0..=s.len() + 5 { + let _ = truncate_str(s, i); + } + } +} diff --git a/tinyharness-ui/src/tui/widgets/conversation.rs b/tinyharness-ui/src/tui/widgets/conversation.rs new file mode 100644 index 0000000..96dd6ef --- /dev/null +++ b/tinyharness-ui/src/tui/widgets/conversation.rs @@ -0,0 +1,1893 @@ +// ── Conversation widget ───────────────────────────────────────────────────── +// +// Displays the conversation history in a scrollable pane with +// color-coded messages, tool call blocks, and thinking chains. + +use crate::tui::cell::{Cell, Color, Style}; +use crate::tui::event::{Event, Key, KeyEvent}; +use crate::tui::layout::Rect; +use crate::tui::screen::Screen; +use crate::tui::widget::{Action, Widget, styles, truncate_str}; + +/// A single line in the conversation display. +#[derive(Clone, Debug)] +pub enum ConversationLine { + /// A user message. + User { text: String }, + /// An assistant message. + Assistant { text: String }, + /// A tool call header (e.g., "── Tool: read ──"). + ToolCall { name: String, args_summary: String }, + /// A tool result. + ToolResult { + name: String, + content: String, + is_error: bool, + }, + /// A system message. + System { text: String }, + /// Thinking/reasoning chain content. + Thinking { text: String }, + /// A horizontal separator line. + Separator, + /// A confirmation prompt for a tool call, awaiting user y/n/a response. + /// `diff_preview` contains a plain-text unified diff to show (for edit/write). + ConfirmPrompt { + name: String, + args_summary: String, + diff_preview: Option, + }, + /// A question prompt from the question tool with numbered answers. + Question { + question: String, + answers: Vec, + }, +} + +/// Context warning level for the banner at the top of the conversation. +#[derive(Clone, Debug, Default, PartialEq)] +pub enum ContextWarningLevel { + #[default] + None, + /// 70%+ of context window used. + Warning(f64), + /// 90%+ of context window used. + Critical(f64), +} + +/// Search state for the conversation widget. +#[derive(Clone, Debug, Default)] +struct SearchState { + /// Whether search mode is active (Ctrl+F toggled). + active: bool, + /// The current search query. + query: String, + /// Cursor position within the search query (byte offset). + cursor: usize, + /// Visual row offsets where matches start. Computed on each render. + match_rows: Vec, + /// Index of the currently highlighted match (for navigation). + current_match: usize, +} + +/// Scrollable conversation pane. +pub struct ConversationWidget { + lines: Vec, + /// Scroll offset in **visual row units** (not conversation line units). + scroll_offset: usize, + auto_scroll: bool, + /// Context warning level (shown as a banner at the top). + context_warning: ContextWarningLevel, + /// Search state (Ctrl+F to toggle). + search: SearchState, +} + +impl ConversationWidget { + pub fn new() -> Self { + Self { + lines: Vec::new(), + scroll_offset: 0, + auto_scroll: true, + context_warning: ContextWarningLevel::None, + search: SearchState::default(), + } + } + + /// Add a line to the conversation. + pub fn push(&mut self, line: ConversationLine) { + self.lines.push(line); + if self.auto_scroll { + self.scroll_to_bottom(); + } + } + + /// Add multiple lines at once. + pub fn extend(&mut self, lines: Vec) { + self.lines.extend(lines); + if self.auto_scroll { + self.scroll_to_bottom(); + } + } + + /// Clear all conversation lines. + pub fn clear(&mut self) { + self.lines.clear(); + self.scroll_offset = 0; + } + + /// Get a mutable reference to the last line, if any. + pub fn last_mut(&mut self) -> Option<&mut ConversationLine> { + self.lines.last_mut() + } + + /// Check if the last line is an assistant message. + pub fn last_is_assistant(&self) -> bool { + matches!(self.lines.last(), Some(ConversationLine::Assistant { .. })) + } + + /// Set the context warning level. + pub fn set_context_warning(&mut self, level: ContextWarningLevel) { + self.context_warning = level; + } + + /// Get the current context warning level. + pub fn context_warning(&self) -> &ContextWarningLevel { + &self.context_warning + } + + /// Toggle search mode on/off. + pub fn toggle_search(&mut self) { + if self.search.active { + self.close_search(); + } else { + self.search.active = true; + self.search.query.clear(); + self.search.cursor = 0; + self.search.match_rows.clear(); + self.search.current_match = 0; + } + } + + /// Close search mode and clear search state. + pub fn close_search(&mut self) { + self.search.active = false; + self.search.query.clear(); + self.search.cursor = 0; + self.search.match_rows.clear(); + self.search.current_match = 0; + } + + /// Check if search mode is active. + pub fn is_search_active(&self) -> bool { + self.search.active + } + + /// Type a character into the search query. + fn search_type_char(&mut self, ch: char) { + self.search.query.insert(self.search.cursor, ch); + self.search.cursor += ch.len_utf8(); + self.recompute_search_matches(); + self.search.current_match = 0; + } + + /// Delete the character before the cursor in the search query. + fn search_backspace(&mut self) { + if self.search.cursor > 0 { + // Find the previous char boundary + let prev = self.search.query[..self.search.cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + self.search.query.drain(prev..self.search.cursor); + self.search.cursor = prev; + self.recompute_search_matches(); + self.search.current_match = 0; + } + } + + /// Move cursor left in the search query. + fn search_cursor_left(&mut self) { + if self.search.cursor > 0 { + let prev = self.search.query[..self.search.cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + self.search.cursor = prev; + } + } + + /// Move cursor right in the search query. + fn search_cursor_right(&mut self) { + if self.search.cursor < self.search.query.len() { + let next = self.search.query[self.search.cursor..] + .char_indices() + .nth(1) + .map(|(i, _)| self.search.cursor + i) + .unwrap_or(self.search.query.len()); + self.search.cursor = next; + } + } + + /// Navigate to the next search match. + fn search_next(&mut self) { + if !self.search.match_rows.is_empty() { + self.search.current_match = + (self.search.current_match + 1) % self.search.match_rows.len(); + let target_row = self.search.match_rows[self.search.current_match]; + self.scroll_offset = target_row; + self.auto_scroll = false; + } + } + + /// Navigate to the previous search match. + fn search_prev(&mut self) { + if !self.search.match_rows.is_empty() { + if self.search.current_match == 0 { + self.search.current_match = self.search.match_rows.len() - 1; + } else { + self.search.current_match -= 1; + } + let target_row = self.search.match_rows[self.search.current_match]; + self.scroll_offset = target_row; + self.auto_scroll = false; + } + } + + /// Recompute the visual row offsets where search matches occur. + fn recompute_search_matches(&mut self) { + self.search.match_rows.clear(); + if self.search.query.is_empty() { + return; + } + let query_lower = self.search.query.to_lowercase(); + let mut visual_row = 0usize; + for line in &self.lines { + let text = match line { + ConversationLine::User { text } => text.clone(), + ConversationLine::Assistant { text } => text.clone(), + ConversationLine::System { text } => text.clone(), + ConversationLine::Thinking { text } => text.clone(), + ConversationLine::ToolResult { content, .. } => content.clone(), + ConversationLine::ToolCall { name, args_summary } => { + format!("{} {}", name, args_summary) + } + ConversationLine::ConfirmPrompt { + name, args_summary, .. + } => { + format!("{} {}", name, args_summary) + } + ConversationLine::Question { question, answers } => { + format!("{} {}", question, answers.join(" ")) + } + ConversationLine::Separator => String::new(), + }; + if text.to_lowercase().contains(&query_lower) { + self.search.match_rows.push(visual_row); + } + // Advance visual_row — we just need a rough estimate for scrolling + // Use a simple heuristic: each line is at least 1 visual row + visual_row += 1; + } + } + + /// Calculate how many visual rows a conversation line occupies given the + /// area width. Uses character-level wrapping to match actual rendering. + fn line_height(&self, line: &ConversationLine, area_width: u16) -> usize { + let (text, prefix_len) = match line { + ConversationLine::User { text } => (text, 7), + ConversationLine::Assistant { text } => (text, 7), + ConversationLine::System { text } => (text, 2), + ConversationLine::Thinking { text } => (text, 13), + ConversationLine::ToolResult { content, .. } => { + // For tool results, trim trailing blank lines and limit to + // a reasonable number of visible lines + let trimmed = content.trim_end_matches('\n'); + let lines: Vec<&str> = trimmed.lines().collect(); + // Show at most 20 lines for a tool result + let visible_lines = lines.iter().copied().take(20).collect::>(); + let joined = visible_lines.join("\n"); + return self.line_height_for_text(&joined, 4, area_width).max(1); // At least 1 row even for empty content + } + ConversationLine::ToolCall { name, args_summary } => { + let header = format!(" ── {}", name); + let header_len = header.len(); + if args_summary.is_empty() { + return 1; + } + // Calculate wrapping for args_summary starting after the header + let total_text = if args_summary.is_empty() { + String::new() + } else { + args_summary.clone() + }; + return self.line_height_for_text_with_offset(&total_text, header_len, area_width); + } + ConversationLine::Question { question, answers } => { + // " ❓ " (4 chars) for question line, plus one line per answer + let question_rows = self.line_height_for_text(question, 4, area_width); + let answer_rows: usize = answers + .iter() + .map(|a| { + // " N. " prefix = 6 chars for single-digit, 7 for double + let prefix_len = if answers.len() >= 10 { 7 } else { 6 }; + self.line_height_for_text(a, prefix_len, area_width) + }) + .sum(); + return question_rows + answer_rows; + } + ConversationLine::Separator => return 1, + ConversationLine::ConfirmPrompt { diff_preview, .. } => { + // Base prompt line = 1 row + let mut rows = 1usize; + // Diff preview lines add extra rows + if let Some(diff) = diff_preview + && !diff.is_empty() + { + let _max_content_width = (area_width as usize).saturating_sub(4); + for line in diff.lines() { + rows += self.line_height_for_text(line, 4, area_width); + } + } + return rows; + } + }; + + self.line_height_for_text(text, prefix_len, area_width) + } + + /// Helper: calculate visual row count for text with a given prefix and area width. + fn line_height_for_text(&self, text: &str, prefix_len: usize, area_width: u16) -> usize { + if area_width == 0 || text.is_empty() { + return 1; + } + + let wrap_col = area_width as usize; + let mut rows = 1usize; + let mut col = prefix_len; + + for ch in text.chars() { + if ch == '\n' { + rows += 1; + col = prefix_len; + } else if col >= wrap_col { + rows += 1; + col = prefix_len + 1; + } else { + col += 1; + } + } + rows + } + + /// Helper: calculate visual row count for text where the first line starts + /// at `first_line_offset` (header length) and wrapped lines indent to the + /// same offset. This is used for tool call args that wrap. + fn line_height_for_text_with_offset( + &self, + text: &str, + first_line_offset: usize, + area_width: u16, + ) -> usize { + if area_width == 0 || text.is_empty() { + return 1; + } + + let wrap_col = area_width as usize; + let mut rows = 1usize; + let mut col = first_line_offset; + + for ch in text.chars() { + if ch == '\n' { + rows += 1; + col = first_line_offset; + } else if col >= wrap_col { + rows += 1; + col = first_line_offset + 1; + } else { + col += 1; + } + } + rows + } + + /// Calculate the total visual height of all conversation lines. + fn total_visual_height(&self, width: u16) -> usize { + self.lines.iter().map(|l| self.line_height(l, width)).sum() + } + + /// Scroll to the bottom of the conversation. + pub fn scroll_to_bottom(&mut self) { + // Use a sentinel large value that will be clamped during render. + // We track whether auto_scroll is active separately. + self.auto_scroll = true; + } + + /// Scroll up by `n` visual rows. + pub fn scroll_up(&mut self, n: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(n); + self.auto_scroll = false; + } + + /// Scroll down by `n` visual rows. + pub fn scroll_down(&mut self, n: usize) { + self.scroll_offset = self.scroll_offset.saturating_add(n); + } + + /// Scroll to the very top. + pub fn scroll_home(&mut self) { + self.scroll_offset = 0; + self.auto_scroll = false; + } + + /// Render a single conversation line, with clipping support for scroll. + fn render_line_clipped( + &self, + line: &ConversationLine, + start_row: u16, + screen: &mut Screen, + _width: u16, + area: Rect, + skip_top: usize, + rows_available: usize, + ) { + let max_row = area.bottom().saturating_sub(1); + let effective_max_row = (start_row + rows_available as u16) + .saturating_sub(1) + .min(max_row); + // Reserve 1 column for the scrollbar on the right edge + let wrap_col = area.x + area.width.saturating_sub(1); + + match line { + ConversationLine::User { text } => { + let prefix = " You: "; + if skip_top == 0 && start_row <= max_row { + screen.write_str( + start_row, + area.x, + prefix, + Color::GREEN, + Color::Default, + Style::bold(), + ); + screen.write_str_wrapped_clipped( + start_row, + area.x + prefix.len() as u16, + text, + Color::GREEN, + Color::Default, + Style::default(), + area.x, + effective_max_row, + wrap_col, + ); + } else if skip_top > 0 { + screen.write_str_wrapped_skip_clipped( + start_row, + area.x + prefix.len() as u16, + text, + Color::GREEN, + Color::Default, + Style::default(), + area.x, + effective_max_row, + wrap_col, + skip_top, + ); + } + } + ConversationLine::Assistant { text } => { + let prefix = " AI: "; + if skip_top == 0 && start_row <= max_row { + screen.write_str( + start_row, + area.x, + prefix, + Color::CYAN, + Color::Default, + Style::bold(), + ); + screen.write_str_wrapped_clipped( + start_row, + area.x + prefix.len() as u16, + text, + Color::WHITE, + Color::Default, + Style::default(), + area.x, + effective_max_row, + wrap_col, + ); + } else if skip_top > 0 { + screen.write_str_wrapped_skip_clipped( + start_row, + area.x + prefix.len() as u16, + text, + Color::WHITE, + Color::Default, + Style::default(), + area.x, + effective_max_row, + wrap_col, + skip_top, + ); + } + } + ConversationLine::ToolCall { name, args_summary } => { + let header = format!(" ── {}", name); + if skip_top == 0 && start_row <= max_row { + screen.write_str( + start_row, + area.x, + &header, + styles::TOOL_MSG_FG, + Color::Default, + Style::default(), + ); + if !args_summary.is_empty() { + let args_indent = area.x + header.len() as u16; + screen.write_str_wrapped_clipped( + start_row, + args_indent, + args_summary, + Color::Ansi(96), + Color::Default, + Style::dim(), + args_indent, + effective_max_row, + wrap_col, + ); + } + } else if skip_top > 0 && !args_summary.is_empty() { + let header = format!(" ── {}", name); + let args_indent = area.x + header.len() as u16; + screen.write_str_wrapped_skip_clipped( + start_row, + args_indent, + args_summary, + Color::Ansi(96), + Color::Default, + Style::dim(), + args_indent, + effective_max_row, + wrap_col, + skip_top, + ); + } + } + ConversationLine::ToolResult { + name: _, + content, + is_error, + } => { + let default_color = if *is_error { + Color::RED + } else { + Color::Ansi(252) + }; + let bg = if *is_error { + Color::Ansi(52) + } else { + Color::Default + }; + // Trim trailing blank lines and limit to 20 visible lines + let trimmed = content.trim_end_matches('\n'); + let content_lines: Vec<&str> = trimmed.lines().take(20).collect(); + let mut current_row = start_row; + let max_content_width = (area.width as usize).saturating_sub(5); + + for (i, content_line) in content_lines.iter().enumerate() { + if i < skip_top { + current_row += 1; + continue; + } + if current_row > max_row { + break; + } + + // Detect diff lines and apply appropriate colors with backgrounds + let (line_color, line_bg, line_prefix) = if !is_error { + if content_line.starts_with("@@") { + // Hunk header: @@ -1,3 +1,3 @@ + (Color::CYAN, Color::Default, " │ ") + } else if content_line.starts_with("---") || content_line.starts_with("+++") + { + // File header: --- a/file.rs or +++ b/file.rs + (Color::WHITE, Color::Default, " │ ") + } else if content_line.starts_with("-") { + // Removed line — red text with dark red background + (Color::RED, Color::Ansi(52), " │ ") + } else if content_line.starts_with("+") { + // Added line — green text with dark green background + (Color::GREEN, Color::Ansi(22), " │ ") + } else { + (default_color, bg, " │ ") + } + } else { + (default_color, bg, " │ ") + }; + + let display = if content_line.is_empty() { + line_prefix.to_string() + } else if content_line.len() > max_content_width { + format!( + "{}{}…", + line_prefix, + truncate_str(content_line, max_content_width.saturating_sub(1)) + ) + } else { + format!("{}{}", line_prefix, content_line) + }; + screen.write_str( + current_row, + area.x, + &display, + line_color, + line_bg, + Style::default(), + ); + // Fill background for diff lines and error tool results + if line_bg != Color::Default || *is_error { + let fill_bg = if line_bg != Color::Default { + line_bg + } else { + bg + }; + let fill_end = area.x + area.width.saturating_sub(1); + let end_col = area.x + display.len().min(area.width as usize - 1) as u16; + if end_col < fill_end { + for c in end_col..fill_end { + if let Some(cell) = screen.get_mut(current_row, c) { + cell.bg = fill_bg; + } + } + } + } + current_row += 1; + } + // If content was truncated, show a truncation indicator + let total_lines = trimmed.lines().count(); + if total_lines > 20 && skip_top == 0 && current_row <= max_row { + let truncation = " │ …"; + let trunc_bg = if *is_error { bg } else { Color::Default }; + screen.write_str( + current_row, + area.x, + truncation, + Color::Ansi(244), + trunc_bg, + Style::dim(), + ); + if *is_error { + let fill_end = area.x + area.width.saturating_sub(1); + for c in (area.x + truncation.len() as u16)..fill_end { + if let Some(cell) = screen.get_mut(current_row, c) { + cell.bg = bg; + } + } + } + } + } + ConversationLine::System { text } => { + if skip_top == 0 && start_row <= max_row { + screen.write_str( + start_row, + area.x, + " ", + Color::Default, + Color::Default, + Style::default(), + ); + screen.write_str_wrapped_clipped( + start_row, + area.x + 2, + text, + Color::YELLOW, + Color::Default, + Style::default(), + area.x, + effective_max_row, + wrap_col, + ); + } else if skip_top > 0 { + screen.write_str_wrapped_skip_clipped( + start_row, + area.x + 2, + text, + Color::YELLOW, + Color::Default, + Style::default(), + area.x, + effective_max_row, + wrap_col, + skip_top, + ); + } + } + ConversationLine::Thinking { text } => { + let prefix = " [thinking] "; + let content_indent = area.x + prefix.len() as u16; + if skip_top == 0 && start_row <= max_row { + screen.write_str( + start_row, + area.x, + prefix, + styles::THINKING_FG, + Color::Default, + Style::dim(), + ); + let display_text = if text.is_empty() { + "⋯".to_string() + } else { + text.clone() + }; + screen.write_str_wrapped_clipped( + start_row, + content_indent, + &display_text, + styles::THINKING_FG, + Color::Default, + Style::dim(), + content_indent, + effective_max_row, + wrap_col, + ); + } else if skip_top > 0 { + let display_text = if text.is_empty() { + "⋯".to_string() + } else { + text.clone() + }; + screen.write_str_wrapped_skip_clipped( + start_row, + content_indent, + &display_text, + styles::THINKING_FG, + Color::Default, + Style::dim(), + content_indent, + effective_max_row, + wrap_col, + skip_top, + ); + } + } + ConversationLine::Separator => { + if skip_top == 0 && start_row <= max_row { + screen.hline( + start_row, + area.x + 2, + area.x + area.width.saturating_sub(4), + '─', + Color::Ansi(240), + Color::Default, + ); + } + } + ConversationLine::ConfirmPrompt { + name, + args_summary, + diff_preview, + } => { + let mut current_row = start_row; + let mut lines_remaining = rows_available; + + // Render the prompt line + if skip_top == 0 && current_row <= max_row && lines_remaining > 0 { + let prompt = format!(" ⚠ Confirm {} {}", name, args_summary); + let suffix = " [y/n/a]?"; + screen.write_str( + current_row, + area.x, + &prompt, + Color::YELLOW, + Color::Default, + Style::bold(), + ); + let prompt_end = + area.x + prompt.len().min(area.width as usize - suffix.len()) as u16; + screen.write_str( + current_row, + prompt_end, + suffix, + Color::YELLOW, + Color::Default, + Style::default(), + ); + current_row += 1; + lines_remaining = lines_remaining.saturating_sub(1); + } + + // Render diff preview lines + if let Some(diff) = diff_preview + && !diff.is_empty() + { + let max_content_width = (area.width as usize).saturating_sub(5); + for line in diff.lines() { + if lines_remaining == 0 || current_row > max_row { + break; + } + + // Determine color based on diff prefix (enhanced) + let (line_color, line_bg, prefix) = if line.starts_with("@@") { + // Hunk header + (Color::CYAN, Color::Default, " │ ") + } else if line.starts_with("---") || line.starts_with("+++") { + // File header + (Color::WHITE, Color::Default, " │ ") + } else if line.starts_with('-') { + // Removed line — red text with dark red background + (Color::RED, Color::Ansi(52), " │ ") + } else if line.starts_with('+') { + // Added line — green text with dark green background + (Color::GREEN, Color::Ansi(22), " │ ") + } else { + (Color::Ansi(252), Color::Default, " │ ") + }; + + let display = if line.is_empty() { + prefix.to_string() + } else if line.len() > max_content_width { + format!( + "{}{}…", + prefix, + truncate_str(line, max_content_width.saturating_sub(1)) + ) + } else { + format!("{}{}", prefix, line) + }; + + screen.write_str( + current_row, + area.x, + &display, + line_color, + line_bg, + Style::default(), + ); + // Fill background for diff lines with colored backgrounds + if line_bg != Color::Default { + let fill_end = area.x + area.width.saturating_sub(1); + let end_col = + area.x + display.len().min(area.width as usize - 1) as u16; + if end_col < fill_end { + for c in end_col..fill_end { + if let Some(cell) = screen.get_mut(current_row, c) { + cell.bg = line_bg; + } + } + } + } + + current_row += 1; + lines_remaining = lines_remaining.saturating_sub(1); + } + } + } + ConversationLine::Question { question, answers } => { + // Question header: " ❓ " + let question_prefix = " ❓ "; + let answer_prefix_len = if answers.len() >= 10 { 7 } else { 6 }; + let mut current_row = start_row; + let mut lines_remaining = rows_available; + + // Render question line + if skip_top == 0 && current_row <= max_row && lines_remaining > 0 { + screen.write_str( + current_row, + area.x, + question_prefix, + Color::YELLOW, + Color::Default, + Style::bold(), + ); + screen.write_str_wrapped_clipped( + current_row, + area.x + question_prefix.len() as u16, + question, + Color::YELLOW, + Color::Default, + Style::default(), + area.x + question_prefix.len() as u16, + effective_max_row, + wrap_col, + ); + current_row += 1; + lines_remaining -= 1; + } else if skip_top > 0 { + // Skip question lines + let q_height = + self.line_height_for_text(question, question_prefix.len(), area.width); + if skip_top >= q_height { + // Question entirely skipped; adjust for remaining skip + } + } + + // Render answer lines: " N. " + for (i, answer) in answers.iter().enumerate() { + let answer_label = format!(" {}. ", i + 1); + let a_height = self.line_height_for_text(answer, answer_prefix_len, area.width); + + if skip_top > 0 { + // Still skipping + if a_height <= skip_top { + // This answer is entirely skipped + } else { + // Partially visible — render with skip + if current_row <= max_row && lines_remaining > 0 { + screen.write_str( + current_row, + area.x, + &answer_label, + Color::CYAN, + Color::Default, + Style::bold(), + ); + screen.write_str_wrapped_skip_clipped( + current_row, + area.x + answer_label.len() as u16, + answer, + Color::WHITE, + Color::Default, + Style::default(), + area.x + answer_label.len() as u16, + effective_max_row, + wrap_col, + skip_top, + ); + } + } + } else if current_row <= max_row && lines_remaining > 0 { + // Normal rendering (no skipping) + screen.write_str( + current_row, + area.x, + &answer_label, + Color::CYAN, + Color::Default, + Style::bold(), + ); + screen.write_str_wrapped_clipped( + current_row, + area.x + answer_label.len() as u16, + answer, + Color::WHITE, + Color::Default, + Style::default(), + area.x + answer_label.len() as u16, + effective_max_row, + wrap_col, + ); + current_row += 1; + lines_remaining = lines_remaining.saturating_sub(1); + } + } + } + } + } + + /// Render the context warning banner at the top of the conversation area. + fn render_context_warning(&self, area: Rect, screen: &mut Screen) { + let row = area.y; + let (fg, bg, msg) = match &self.context_warning { + ContextWarningLevel::None => return, + ContextWarningLevel::Warning(pct) => ( + Color::YELLOW, + Color::Ansi(17), // dark blue bg + format!( + " ⚠ Context {:.0}% full — consider /compact to free space ", + pct + ), + ), + ContextWarningLevel::Critical(pct) => ( + Color::WHITE, + Color::Ansi(52), // dark red bg + format!(" ⚠ Context {:.0}% — exceeded! Use /compact now ", pct), + ), + }; + + // Fill the banner row with background color + for col in area.x..area.x + area.width { + if let Some(cell) = screen.get_mut(row, col) { + cell.bg = bg; + cell.char = ' '; + } + } + + // Write the warning text (truncate if wider than area) + let display = if msg.len() > area.width as usize { + truncate_str(&msg, area.width as usize).to_string() + } else { + msg + }; + screen.write_str(row, area.x, &display, fg, bg, Style::bold()); + } + + /// Render the search bar at the bottom of the conversation area. + fn render_search_bar(&self, row: u16, area: Rect, screen: &mut Screen) { + // Fill the search bar background + for col in area.x..area.x + area.width { + if let Some(cell) = screen.get_mut(row, col) { + cell.bg = styles::STATUS_BAR_BG; + cell.char = ' '; + } + } + + // "Search: " label + let label = " Search: "; + screen.write_str( + row, + area.x, + label, + Color::YELLOW, + styles::STATUS_BAR_BG, + Style::bold(), + ); + + let query_col = area.x + label.len() as u16; + + // Search query text + let query_text = &self.search.query; + let max_query_width = area.width.saturating_sub(label.len() as u16 + 20); // reserve for match count + let display_query = if query_text.len() > max_query_width as usize { + truncate_str(query_text, max_query_width as usize).to_string() + } else { + query_text.clone() + }; + screen.write_str( + row, + query_col, + &display_query, + Color::WHITE, + styles::STATUS_BAR_BG, + Style::default(), + ); + + // Cursor indicator (underline the character at cursor position or show block if at end) + let cursor_col_in_display = if self.search.cursor > query_text.len() { + display_query.len() + } else { + // Find the display position corresponding to the cursor byte offset + let before_cursor = &query_text[..self.search.cursor.min(query_text.len())]; + let display_offset = before_cursor.chars().count(); + if display_offset > max_query_width as usize { + max_query_width as usize + } else { + display_offset + } + }; + let cursor_screen_col = area.x + label.len() as u16 + cursor_col_in_display as u16; + if cursor_screen_col < area.x + area.width { + if let Some(cell) = screen.get_mut(row, cursor_screen_col) { + cell.style.underline = true; + if cell.char == ' ' { + cell.fg = Color::WHITE; + } + } + } + + // Match count on the right side + let match_count = if !self.search.match_rows.is_empty() { + format!( + " {}/{} ", + self.search.current_match + 1, + self.search.match_rows.len() + ) + } else if !self.search.query.is_empty() { + " No matches ".to_string() + } else { + String::new() + }; + + if !match_count.is_empty() { + let match_start = area + .x + .saturating_add(area.width) + .saturating_sub(match_count.len() as u16); + screen.write_str( + row, + match_start, + &match_count, + Color::Ansi(244), + styles::STATUS_BAR_BG, + Style::dim(), + ); + } + } + + /// Highlight search matches in the visible area of the conversation. + fn highlight_search_matches(&self, area: Rect, screen: &mut Screen, content_width: u16) { + if self.search.query.is_empty() { + return; + } + + let query_lower = self.search.query.to_lowercase(); + let visible_start = self.scroll_offset; + let visible_end = visible_start + area.height as usize; + + // Walk through conversation lines, computing visual row offsets, + // and highlight matches in visible lines. + let mut visual_row = 0usize; + let mut screen_row = area.y; + let mut match_idx = 0usize; + + for line in &self.lines { + let height = self.line_height(line, content_width); + + // Skip lines entirely above the visible area + if visual_row + height <= visible_start { + visual_row += height; + continue; + } + + // Stop if we're past the visible area + if visual_row >= visible_end { + break; + } + + let skip_top = visible_start.saturating_sub(visual_row); + let rows_available = area.bottom().saturating_sub(screen_row) as usize; + if rows_available == 0 { + break; + } + + // Get the searchable text for this line + let text = match line { + ConversationLine::User { text } => text.clone(), + ConversationLine::Assistant { text } => text.clone(), + ConversationLine::System { text } => text.clone(), + ConversationLine::Thinking { text } => text.clone(), + ConversationLine::ToolResult { content, .. } => content.clone(), + ConversationLine::ToolCall { name, args_summary } => { + format!("{} {}", name, args_summary) + } + ConversationLine::ConfirmPrompt { + name, args_summary, .. + } => { + format!("{} {}", name, args_summary) + } + ConversationLine::Question { question, answers } => { + format!("{} {}", question, answers.join(" ")) + } + ConversationLine::Separator => String::new(), + }; + + if !text.is_empty() { + let text_lower = text.to_lowercase(); + let mut search_start = 0usize; + while let Some(pos) = text_lower[search_start..].find(&query_lower) { + let abs_pos = search_start + pos; + search_start = abs_pos + query_lower.len(); + + // Compute the screen column for this match + // The match may span multiple visual rows due to wrapping + let prefix = &text[..abs_pos]; + let prefix_chars: Vec = prefix.chars().collect(); + let query_chars: Vec = self.search.query.chars().collect(); + let query_len = query_chars.len(); + + // Determine which visual row within this line the match starts on + // Use the same wrapping logic as render (simplified) + let line_prefix_len = match line { + ConversationLine::User { .. } => 7, + ConversationLine::Assistant { .. } => 7, + ConversationLine::System { .. } => 2, + ConversationLine::Thinking { .. } => 13, + _ => 4, // tool result, etc. + }; + let wrap_at = content_width as usize; + + // Calculate the visual row offset for the start of the match + let mut row_offset = 0usize; + let mut col = line_prefix_len; + for _ in prefix_chars.iter() { + col += 1; + if col >= wrap_at { + row_offset += 1; + col = line_prefix_len; + } + } + + // Calculate the starting column for the match + let start_col = col; + + // Check if this match is visible (considering skip_top) + let actual_visual_row = visual_row + row_offset; + if actual_visual_row >= visible_end { + break; + } + if actual_visual_row < visible_start { + // Match is above visible area but might span into it + // For simplicity, skip matches that start above the visible area + continue; + } + + let match_screen_row = screen_row + row_offset as u16; + if skip_top > 0 && row_offset < skip_top { + continue; + } + + // Determine if this is the current match + let is_current = match_idx < self.search.match_rows.len() + && self.search.match_rows[match_idx] == visual_row; + + // Highlight the match characters on screen + let highlight_fg = if is_current { + Color::BLACK + } else { + Color::WHITE + }; + let highlight_bg = if is_current { + Color::ORANGE + } else { + Color::Ansi(58) // dark yellow/olive + }; + + let mut highlight_col = area.x + start_col as u16; + for _ in 0..query_len { + if highlight_col >= area.x + area.width.saturating_sub(1) { + break; // Don't overwrite scrollbar + } + if let Some(cell) = screen.get_mut(match_screen_row, highlight_col) { + cell.fg = highlight_fg; + cell.bg = highlight_bg; + } + highlight_col += 1; + } + + match_idx += 1; + } + } + + screen_row += height.saturating_sub(skip_top) as u16; + screen_row = screen_row.min(area.bottom()); + visual_row += height; + } + } + + /// Render a scrollbar on the right edge of the area. + fn render_scrollbar(&self, area: Rect, screen: &mut Screen, total_lines: usize) { + if total_lines <= area.height as usize { + return; + } + + let scrollbar_height = area.height; + let thumb_size = + ((scrollbar_height as usize * scrollbar_height as usize) / total_lines).max(1) as u16; + let thumb_position = if total_lines > area.height as usize { + (self.scroll_offset as u16 * (scrollbar_height - thumb_size)) + / (total_lines as u16 - area.height) + } else { + 0 + }; + + let x = area.x + area.width - 1; + for row in area.y..area.y + area.height { + if let Some(cell) = screen.get_mut(row, x) { + cell.char = '│'; + cell.fg = styles::SCROLLBAR_FG; + cell.bg = styles::SCROLLBAR_BG; + } + } + + for i in 0..thumb_size { + let row = area.y + thumb_position + i; + if row < area.y + area.height { + if let Some(cell) = screen.get_mut(row, x) { + cell.char = '█'; + cell.fg = styles::SCROLLBAR_FG; + } + } + } + } +} + +impl Widget for ConversationWidget { + fn render(&mut self, area: Rect, screen: &mut Screen) { + if area.is_empty() { + return; + } + + screen.fill_rect(area, Cell::default()); + + // Reserve space for search bar if active (1 row at bottom of conversation) + let search_active = self.search.active; + let search_bar_rows: u16 = if search_active { 1 } else { 0 }; + + // Reserve space for context warning banner (1 row at top) + let warning_banner_rows: u16 = if matches!(self.context_warning, ContextWarningLevel::None) + { + 0 + } else { + 1 + }; + + let content_area = Rect::new( + area.x, + area.y + warning_banner_rows, + area.width, + area.height + .saturating_sub(warning_banner_rows + search_bar_rows), + ); + + // Render context warning banner + if warning_banner_rows > 0 { + self.render_context_warning(area, screen); + } + + // Render search bar at the bottom of the conversation area + if search_active { + let search_row = area.y + area.height.saturating_sub(1); + self.render_search_bar(search_row, area, screen); + } + + let visible_rows = content_area.height as usize; + // Reserve 1 column for the scrollbar + let content_width = content_area.width.saturating_sub(1); + let total_height = self.total_visual_height(content_width); + + let max_scroll = total_height.saturating_sub(visible_rows); + if self.auto_scroll { + self.scroll_offset = max_scroll; + } + if self.scroll_offset > max_scroll { + self.scroll_offset = max_scroll; + } + + let mut visual_row = 0usize; + let mut screen_row = content_area.y; + let skip_rows = self.scroll_offset; + + for line in &self.lines { + let height = self.line_height(line, content_width); + + if visual_row + height <= skip_rows { + visual_row += height; + continue; + } + + let skip_top = skip_rows.saturating_sub(visual_row); + let rows_available = content_area.bottom().saturating_sub(screen_row) as usize; + + if rows_available == 0 { + break; + } + + self.render_line_clipped( + line, + screen_row, + screen, + content_width, + content_area, + skip_top, + rows_available, + ); + + screen_row += height.saturating_sub(skip_top) as u16; + screen_row = screen_row.min(content_area.bottom()); + visual_row += height; + + if screen_row >= content_area.bottom() { + break; + } + } + + self.render_scrollbar(content_area, screen, total_height); + + // Highlight search matches if active + if self.search.active && !self.search.query.is_empty() { + self.highlight_search_matches(content_area, screen, content_width); + } + } + + fn handle_event(&mut self, event: &Event) -> Action { + // If search mode is active, intercept all key events + if self.search.active { + if let Event::Key(key) = event { + match key { + KeyEvent { + key: Key::Escape, + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.close_search(); + return Action::None; + } + KeyEvent { + key: Key::Enter, + modifiers, + } if modifiers.shift => { + // Shift+Enter: previous match + self.search_prev(); + return Action::None; + } + KeyEvent { + key: Key::Enter, + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + // Enter: next match + self.search_next(); + return Action::None; + } + KeyEvent { + key: Key::Char(ch), + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.search_type_char(*ch); + return Action::None; + } + KeyEvent { + key: Key::Backspace, + .. + } => { + self.search_backspace(); + return Action::None; + } + KeyEvent { + key: Key::Left, + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.search_cursor_left(); + return Action::None; + } + KeyEvent { + key: Key::Right, + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.search_cursor_right(); + return Action::None; + } + _ => { + // Let other keys pass through (e.g., Ctrl+C for quit) + let _ = key; + } + } + } + // Non-key events pass through + return Action::None; + } + + if let Event::Key(key) = event { + match key { + KeyEvent { + key: Key::Char('f'), + modifiers, + } if modifiers.ctrl => { + self.toggle_search(); + return Action::None; + } + KeyEvent { + key: Key::Tab, + modifiers, + } if !modifiers.shift && !modifiers.alt && !modifiers.ctrl => { + return Action::CycleFocusForward; + } + KeyEvent { + key: Key::BackTab, .. + } => { + return Action::CycleFocusBackward; + } + _ => {} + } + } + let _ = event; + Action::None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_conversation_new() { + let conv = ConversationWidget::new(); + assert!(conv.lines.is_empty()); + assert!(conv.auto_scroll); + } + + #[test] + fn test_conversation_push() { + let mut conv = ConversationWidget::new(); + conv.push(ConversationLine::User { + text: "Hello".to_string(), + }); + conv.push(ConversationLine::Assistant { + text: "Hi there!".to_string(), + }); + assert_eq!(conv.lines.len(), 2); + } + + #[test] + fn test_conversation_scroll() { + let mut conv = ConversationWidget::new(); + for i in 0..50 { + conv.push(ConversationLine::User { + text: format!("Message {}", i), + }); + } + assert!(conv.auto_scroll); + + conv.scroll_up(10); + assert!(!conv.auto_scroll); + + conv.scroll_down(10); + } + + #[test] + fn test_conversation_clear() { + let mut conv = ConversationWidget::new(); + conv.push(ConversationLine::User { + text: "Hello".to_string(), + }); + conv.clear(); + assert!(conv.lines.is_empty()); + } + + #[test] + fn test_conversation_render() { + let mut screen = Screen::new(80, 24); + let mut conv = ConversationWidget::new(); + conv.push(ConversationLine::User { + text: "Hello".to_string(), + }); + conv.push(ConversationLine::Separator); + conv.push(ConversationLine::Assistant { + text: "Hi there!".to_string(), + }); + + let area = Rect::new(0, 0, 80, 20); + conv.render(area, &mut screen); + + assert!(screen.get(0, 0).unwrap().char != '\0'); + } + + #[test] + fn test_tool_result_line_height() { + let conv = ConversationWidget::new(); + let line = ConversationLine::ToolResult { + name: "read".to_string(), + content: "line1\nline2\nline3".to_string(), + is_error: false, + }; + assert_eq!(conv.line_height(&line, 80), 3); + } + + #[test] + fn test_tool_result_trailing_newlines() { + let conv = ConversationWidget::new(); + // Trailing newlines should be trimmed + let line = ConversationLine::ToolResult { + name: "read".to_string(), + content: "line1\nline2\n\n\n".to_string(), + is_error: false, + }; + // Should count 2 visible lines, not 4 + assert_eq!(conv.line_height(&line, 80), 2); + } + + #[test] + fn test_tool_result_truncated() { + let conv = ConversationWidget::new(); + // Content with more than 20 lines should be capped + let long_content: String = (0..30) + .map(|i| format!("line {}", i)) + .collect::>() + .join("\n"); + let line = ConversationLine::ToolResult { + name: "run".to_string(), + content: long_content, + is_error: false, + }; + // Should be capped at 20 lines + truncation indicator + let height = conv.line_height(&line, 80); + assert!( + height <= 21, + "Tool result should be capped at ~20 lines, got {}", + height + ); + } + + #[test] + fn test_tool_result_single_line() { + let conv = ConversationWidget::new(); + let line = ConversationLine::ToolResult { + name: "read".to_string(), + content: "single line".to_string(), + is_error: false, + }; + assert_eq!(conv.line_height(&line, 80), 1); + } + + #[test] + fn test_assistant_wrapping_line_height() { + let conv = ConversationWidget::new(); + // With width=20, prefix " AI: " (7 chars), text "12345678901234567890" (20 chars) + // First row: prefix + 13 chars, second row: 7 more chars + let line = ConversationLine::Assistant { + text: "12345678901234567890".to_string(), + }; + let height = conv.line_height(&line, 20); + assert!( + height >= 2, + "Long text should wrap to multiple rows, got {}", + height + ); + } + + #[test] + fn test_confirm_prompt_line_height() { + let conv = ConversationWidget::new(); + let line = ConversationLine::ConfirmPrompt { + name: "run".to_string(), + args_summary: "cargo test".to_string(), + diff_preview: None, + }; + // ConfirmPrompt with no diff takes 1 row + assert_eq!(conv.line_height(&line, 80), 1); + } + + #[test] + fn test_confirm_prompt_with_diff_line_height() { + let conv = ConversationWidget::new(); + let diff = "- old line\n+ new line\n context line".to_string(); + let line = ConversationLine::ConfirmPrompt { + name: "edit".to_string(), + args_summary: "src/main.rs".to_string(), + diff_preview: Some(diff), + }; + // 1 row for prompt + 3 rows for diff lines + assert_eq!(conv.line_height(&line, 80), 4); + } + + #[test] + fn test_confirm_prompt_push() { + let mut conv = ConversationWidget::new(); + conv.push(ConversationLine::ConfirmPrompt { + name: "run".to_string(), + args_summary: "cargo build".to_string(), + diff_preview: None, + }); + assert_eq!(conv.lines.len(), 1); + } + + // ── Search tests ──────────────────────────────────────────────────── + + #[test] + fn test_search_toggle() { + let mut conv = ConversationWidget::new(); + assert!(!conv.is_search_active()); + conv.toggle_search(); + assert!(conv.is_search_active()); + conv.toggle_search(); + assert!(!conv.is_search_active()); + } + + #[test] + fn test_search_close() { + let mut conv = ConversationWidget::new(); + conv.toggle_search(); + assert!(conv.is_search_active()); + conv.close_search(); + assert!(!conv.is_search_active()); + } + + #[test] + fn test_search_type_char_and_backspace() { + let mut conv = ConversationWidget::new(); + conv.toggle_search(); + conv.search_type_char('h'); + conv.search_type_char('i'); + assert_eq!(conv.search.query, "hi"); + assert_eq!(conv.search.cursor, 2); + conv.search_backspace(); + assert_eq!(conv.search.query, "h"); + assert_eq!(conv.search.cursor, 1); + conv.search_backspace(); + assert_eq!(conv.search.query, ""); + assert_eq!(conv.search.cursor, 0); + // Backspace on empty query is a no-op + conv.search_backspace(); + assert_eq!(conv.search.query, ""); + } + + #[test] + fn test_search_cursor_movement() { + let mut conv = ConversationWidget::new(); + conv.toggle_search(); + conv.search_type_char('a'); + conv.search_type_char('b'); + conv.search_type_char('c'); + assert_eq!(conv.search.cursor, 3); + conv.search_cursor_left(); + assert_eq!(conv.search.cursor, 2); + conv.search_cursor_left(); + assert_eq!(conv.search.cursor, 1); + conv.search_cursor_right(); + assert_eq!(conv.search.cursor, 2); + // Can't go past the end + conv.search_cursor_right(); + conv.search_cursor_right(); + assert_eq!(conv.search.cursor, 3); + } + + #[test] + fn test_search_matches() { + let mut conv = ConversationWidget::new(); + conv.push(ConversationLine::User { + text: "Hello world".to_string(), + }); + conv.push(ConversationLine::Assistant { + text: "Hi there!".to_string(), + }); + conv.push(ConversationLine::User { + text: "Hello again".to_string(), + }); + conv.toggle_search(); + conv.search_type_char('h'); + conv.search_type_char('e'); + conv.search_type_char('l'); + // "hel" matches "Hello world" and "Hello again" (case-insensitive) + assert_eq!(conv.search.match_rows.len(), 2); + } + + #[test] + fn test_search_no_matches() { + let mut conv = ConversationWidget::new(); + conv.push(ConversationLine::User { + text: "Hello world".to_string(), + }); + conv.toggle_search(); + conv.search_type_char('z'); + conv.search_type_char('z'); + conv.search_type_char('z'); + assert!(conv.search.match_rows.is_empty()); + } + + #[test] + fn test_search_navigation() { + let mut conv = ConversationWidget::new(); + conv.push(ConversationLine::User { + text: "First match".to_string(), + }); + conv.push(ConversationLine::Assistant { + text: "No hit here".to_string(), + }); + conv.push(ConversationLine::User { + text: "Second match".to_string(), + }); + conv.toggle_search(); + conv.search_type_char('m'); + conv.search_type_char('a'); + conv.search_type_char('t'); + conv.search_type_char('c'); + conv.search_type_char('h'); + assert_eq!(conv.search.match_rows.len(), 2); + assert_eq!(conv.search.current_match, 0); + conv.search_next(); + assert_eq!(conv.search.current_match, 1); + conv.search_next(); + assert_eq!(conv.search.current_match, 0); // wraps around + conv.search_prev(); + assert_eq!(conv.search.current_match, 1); // wraps around + } + + #[test] + fn test_search_clears_on_close() { + let mut conv = ConversationWidget::new(); + conv.toggle_search(); + conv.search_type_char('x'); + conv.close_search(); + assert!(!conv.is_search_active()); + assert!(conv.search.query.is_empty()); + assert!(conv.search.match_rows.is_empty()); + } + + // ── Context warning tests ────────────────────────────────────────── + + #[test] + fn test_context_warning_default() { + let conv = ConversationWidget::new(); + assert!(matches!(conv.context_warning(), ContextWarningLevel::None)); + } + + #[test] + fn test_context_warning_set_warning() { + let mut conv = ConversationWidget::new(); + conv.set_context_warning(ContextWarningLevel::Warning(75.0)); + assert!(matches!( + conv.context_warning(), + ContextWarningLevel::Warning(75.0) + )); + } + + #[test] + fn test_context_warning_set_critical() { + let mut conv = ConversationWidget::new(); + conv.set_context_warning(ContextWarningLevel::Critical(92.0)); + assert!(matches!( + conv.context_warning(), + ContextWarningLevel::Critical(92.0) + )); + } + + #[test] + fn test_context_warning_render() { + let mut screen = Screen::new(80, 24); + let mut conv = ConversationWidget::new(); + conv.push(ConversationLine::User { + text: "Hello".to_string(), + }); + conv.set_context_warning(ContextWarningLevel::Warning(75.0)); + let area = Rect::new(0, 0, 80, 20); + conv.render(area, &mut screen); + // First row should have the warning banner (non-default background) + let first_cell = screen.get(0, 0).unwrap(); + assert_ne!(first_cell.bg, Color::Default); + assert_eq!(first_cell.char, ' '); + } + + #[test] + fn test_context_warning_critical_render() { + let mut screen = Screen::new(80, 24); + let mut conv = ConversationWidget::new(); + conv.push(ConversationLine::User { + text: "Hello".to_string(), + }); + conv.set_context_warning(ContextWarningLevel::Critical(95.0)); + let area = Rect::new(0, 0, 80, 20); + conv.render(area, &mut screen); + let first_cell = screen.get(0, 0).unwrap(); + assert_eq!(first_cell.bg, Color::Ansi(52)); // dark red bg for critical + } + + // ── Diff coloring tests ───────────────────────────────────────────── + + #[test] + fn test_diff_coloring_in_tool_result() { + let mut screen = Screen::new(80, 24); + let mut conv = ConversationWidget::new(); + conv.push(ConversationLine::ToolResult { + name: "edit".to_string(), + content: + "--- a/file.rs\n+++ b/file.rs\n@@ -1,3 +1,3 @@\n- old line\n+ new line\n context" + .to_string(), + is_error: false, + }); + let area = Rect::new(0, 0, 80, 20); + conv.render(area, &mut screen); + // Just verify it doesn't panic and renders something + assert!(screen.get(0, 0).unwrap().char != '\0'); + } + + #[test] + fn test_diff_coloring_in_confirm_prompt() { + let mut screen = Screen::new(80, 24); + let mut conv = ConversationWidget::new(); + conv.push(ConversationLine::ConfirmPrompt { + name: "edit".to_string(), + args_summary: "src/main.rs".to_string(), + diff_preview: Some("@@ -1,3 +1,3 @@\n- old line\n+ new line".to_string()), + }); + let area = Rect::new(0, 0, 80, 20); + conv.render(area, &mut screen); + assert!(screen.get(0, 0).unwrap().char != '\0'); + } + + #[test] + fn test_search_bar_render() { + let mut screen = Screen::new(80, 24); + let mut conv = ConversationWidget::new(); + conv.push(ConversationLine::User { + text: "Hello".to_string(), + }); + conv.toggle_search(); + conv.search_type_char('H'); + let area = Rect::new(0, 0, 80, 20); + conv.render(area, &mut screen); + // Search bar should be at the bottom row (row 19) + let search_bar_row = 19u16; + let cell = screen.get(search_bar_row, 1).unwrap(); + assert_eq!(cell.char, 'S'); // "Search: " starts with S + } +} diff --git a/tinyharness-ui/src/tui/widgets/input_bar.rs b/tinyharness-ui/src/tui/widgets/input_bar.rs new file mode 100644 index 0000000..0518f96 --- /dev/null +++ b/tinyharness-ui/src/tui/widgets/input_bar.rs @@ -0,0 +1,1681 @@ +// ── Input bar widget ────────────────────────────────────────────────────────── +// +// Multi-line input with history, cursor tracking, mode/model label, +// and tab completion for slash commands. + +use crate::tui::cell::{Color, Style}; +use crate::tui::event::{Event, Key, KeyEvent}; +use crate::tui::layout::Rect; +use crate::tui::screen::Screen; +use crate::tui::widget::{Action, Widget, styles}; +use std::collections::HashMap; + +/// The input bar at the bottom of the screen. +/// +/// Displays a prompt with mode and model labels, and accepts +/// multi-line text input. Enter submits, Shift+Enter inserts a newline. +/// Tab completes slash commands when the input starts with `/`. +/// +/// In confirmation mode, the input bar shows a `[y/n/a]?` prompt +/// and only accepts y (approve), n (deny), or a (approve all) keys. +pub struct InputBarWidget { + /// Current input text. + content: String, + /// Cursor position (byte offset in content). + cursor: usize, + /// Scroll offset for the input area (for multi-line input). + scroll_offset: usize, + /// Input history (previous messages). + history: Vec, + /// Current position in history navigation (None = not navigating). + history_index: Option, + /// The mode label to display (e.g., "agent"). + mode_label: String, + /// The mode color. + mode_color: Color, + /// The model name to display. + model_name: String, + /// Whether the input bar is focused. + focused: bool, + /// Current tab-completion state: index into the list of matching completions. + /// `None` means we're not in tab-completion cycling mode. + tab_cycle_index: Option, + /// The prefix that was being completed when Tab cycling started. + tab_cycle_prefix: String, + /// Whether the last completion was a subcommand completion. + tab_cycle_subcommand: bool, + /// Whether the input bar is in confirmation mode (y/n/a). + confirming: bool, + /// Whether the input bar is in question mode (user must answer a question). + questioning: bool, + /// The number of predefined answers for the current question. + question_answer_count: usize, + /// Kill ring for Ctrl+K/U/W/Y emacs-style editing. + kill_ring: String, + /// All known command names (primary + aliases), for tab completion. + command_names: Vec, + /// Subcommand completions for commands that take arguments. + subcommands: HashMap>, +} + +impl InputBarWidget { + pub fn new(mode_label: &str, model_name: &str) -> Self { + Self::with_commands(mode_label, model_name, Vec::new(), HashMap::new()) + } + + /// Create an `InputBarWidget` with command names and subcommand completions + /// for tab completion, typically sourced from the binary's `CommandRegistry`. + pub fn with_commands( + mode_label: &str, + model_name: &str, + command_names: Vec, + subcommands: HashMap>, + ) -> Self { + let mode_color = match mode_label { + "casual" => styles::MODE_CASUAL_FG, + "planning" => styles::MODE_PLANNING_FG, + "agent" => styles::MODE_AGENT_FG, + "research" => styles::MODE_RESEARCH_FG, + _ => Color::WHITE, + }; + + Self { + content: String::new(), + cursor: 0, + scroll_offset: 0, + history: Vec::new(), + history_index: None, + mode_label: mode_label.to_string(), + mode_color, + model_name: model_name.to_string(), + focused: true, + tab_cycle_index: None, + tab_cycle_prefix: String::new(), + tab_cycle_subcommand: false, + confirming: false, + questioning: false, + question_answer_count: 0, + kill_ring: String::new(), + command_names, + subcommands, + } + } + + /// Get the current input text and clear the buffer. + pub fn take_input(&mut self) -> String { + let text = self.content.clone(); + if !text.is_empty() { + self.history.push(text.clone()); + } + self.content.clear(); + self.cursor = 0; + self.scroll_offset = 0; + self.history_index = None; + text + } + + /// Set the input text (e.g., from --prompt flag). + pub fn set_input(&mut self, text: &str) { + self.content = text.to_string(); + self.cursor = self.content.len(); + } + + /// Update the mode label and model name. + pub fn update_labels(&mut self, mode_label: &str, model_name: &str) { + self.mode_label = mode_label.to_string(); + self.mode_color = match mode_label { + "casual" => styles::MODE_CASUAL_FG, + "planning" => styles::MODE_PLANNING_FG, + "agent" => styles::MODE_AGENT_FG, + "research" => styles::MODE_RESEARCH_FG, + _ => Color::WHITE, + }; + self.model_name = model_name.to_string(); + } + + /// Set command names and subcommand completions for tab completion. + pub fn set_command_completions( + &mut self, + command_names: Vec, + subcommands: HashMap>, + ) { + self.command_names = command_names; + self.subcommands = subcommands; + } + + /// Calculate which line and column the cursor is on. + #[allow(dead_code)] + fn cursor_line_col(&self) -> (usize, usize) { + let text_before_cursor = &self.content[..self.cursor]; + let line = text_before_cursor.lines().count().saturating_sub(1); + let col = text_before_cursor + .lines() + .next_back() + .map(|l| l.len()) + .unwrap_or(0); + (line, col) + } + + /// Count the number of lines in the input. + #[allow(dead_code)] + fn line_count(&self) -> usize { + self.content.lines().count().max(1) + } + + /// Check if the current input starts with a slash (for command detection). + pub fn is_command_input(&self) -> bool { + self.content.starts_with('/') + } + + /// Enter or exit confirmation mode. + /// + /// In confirmation mode, the input bar shows a `[y/n/a]?` prompt + /// and only accepts y (approve), n (deny), or a (approve all) keys. + pub fn set_confirming(&mut self, confirming: bool) { + self.confirming = confirming; + if confirming { + self.content.clear(); + self.cursor = 0; + } + } + + /// Check if the input bar is in confirmation mode. + pub fn is_confirming(&self) -> bool { + self.confirming + } + + /// Enter or exit question mode. + /// + /// In question mode, the input bar shows a prompt for the user to + /// type a number (1-N) or custom text, then press Enter. + pub fn set_questioning(&mut self, questioning: bool, answer_count: usize) { + self.questioning = questioning; + self.question_answer_count = answer_count; + if questioning { + self.content.clear(); + self.cursor = 0; + } + } + + /// Check if the input bar is in question mode. + pub fn is_questioning(&self) -> bool { + self.questioning + } + + /// Handle a mouse click on the input bar to position the cursor. + /// + /// Computes where the user clicked relative to the prompt and text + /// content, then moves the cursor to that position. + pub fn click_to_cursor(&mut self, click_row: u16, click_col: u16, area: Rect) { + if self.confirming || self.questioning { + // No cursor positioning in confirmation/question mode + return; + } + + // The prompt is "[mode] " which takes some columns on the first input line + let prompt = format!("[{}] ", self.mode_label); + let prompt_len = prompt.len() as u16; + + // First content line starts at area.y + 1 (below the top border) + let first_input_row = area.y + 1; + + // Determine which line of content was clicked (relative to first input row) + let line_offset = click_row.saturating_sub(first_input_row) as usize; + + // Calculate the cursor position from the click + if line_offset == 0 { + // Clicked on the first line — account for the prompt prefix + let col_offset = click_col.saturating_sub(area.x + prompt_len) as usize; + // Move cursor to that character position within the first line + let first_line_len = self.content.lines().next().map(|l| l.len()).unwrap_or(0); + let new_pos = col_offset.min(first_line_len); + // The cursor position in the full string is at the start + new_pos + let line_start = 0; + self.cursor = line_start + new_pos; + if self.cursor > self.content.len() { + self.cursor = self.content.len(); + } + } else { + // Clicked on a subsequent line — calculate byte offset for that line + let mut byte_offset = 0usize; + for (i, line) in self.content.lines().enumerate() { + if i == line_offset { + // Found the target line + let col_offset = click_col.saturating_sub(area.x) as usize; + let new_pos = col_offset.min(line.len()); + self.cursor = byte_offset + new_pos; + if self.cursor > self.content.len() { + self.cursor = self.content.len(); + } + return; + } + // +1 for the '\n' character + byte_offset += line.len() + 1; + } + // Click was past the last line — position cursor at end + self.cursor = self.content.len(); + } + } + + /// Attempt tab completion for slash commands. + /// + /// If the input starts with `/`, cycle through matching command names + /// (or subcommand arguments). Returns `true` if a completion was applied, + /// `false` if no completions matched. + /// + /// Tab cycling works by remembering the original prefix the user typed + /// before the first Tab. Subsequent Tabs cycle through all commands + /// that start with that prefix. + fn tab_complete(&mut self) -> bool { + if !self.content.starts_with('/') { + return false; + } + + // Determine if we're completing a subcommand or a top-level command + if let Some(space_pos) = self.content.find(' ') { + // Subcommand completion: "/command sub" + let cmd = &self.content[..space_pos].to_lowercase(); + let current_arg = self.content[space_pos + 1..].trim_start().to_lowercase(); + + let subs = self + .subcommands + .get(cmd) + .map(|s| s.as_slice()) + .unwrap_or(&[]); + if subs.is_empty() { + return false; + } + + // On first Tab (or if the prefix changed), start a new cycle + if self.tab_cycle_index.is_none() + || self.tab_cycle_prefix != current_arg + || !self.tab_cycle_subcommand + { + self.tab_cycle_prefix = current_arg.clone(); + self.tab_cycle_index = Some(0); + self.tab_cycle_subcommand = true; + } + + let matches: Vec<&String> = subs + .iter() + .filter(|s| s.starts_with(&self.tab_cycle_prefix)) + .collect(); + + if matches.is_empty() { + self.tab_cycle_index = None; + return false; + } + + let idx = self.tab_cycle_index.unwrap() % matches.len(); + let completion = matches[idx]; + + // Replace the subcommand argument + self.content = format!("{} {}", cmd, completion); + self.cursor = self.content.len(); + self.tab_cycle_index = Some(idx + 1); + true + } else { + // Top-level command completion: "/mod" + let current_input = self.content.to_lowercase(); + + // On first Tab (or if cycling context was for subcommands), start fresh + if self.tab_cycle_index.is_none() || self.tab_cycle_subcommand { + self.tab_cycle_prefix = current_input.clone(); + self.tab_cycle_index = Some(0); + self.tab_cycle_subcommand = false; + } else { + // Continuing a cycle: the current content was set by the previous Tab, + // so the prefix we're matching against is still tab_cycle_prefix. + // The current content is a completed command name — don't update prefix. + } + + let matches: Vec<&String> = self + .command_names + .iter() + .filter(|name| name.starts_with(&self.tab_cycle_prefix)) + .collect(); + + if matches.is_empty() { + self.tab_cycle_index = None; + return false; + } + + let idx = self.tab_cycle_index.unwrap() % matches.len(); + let completion = matches[idx]; + + self.content = completion.to_string(); + self.cursor = self.content.len(); + self.tab_cycle_index = Some(idx + 1); + true + } + } + + /// Reset tab-completion cycling state (e.g., when a non-Tab key is pressed). + fn reset_tab_cycle(&mut self) { + self.tab_cycle_index = None; + self.tab_cycle_prefix.clear(); + self.tab_cycle_subcommand = false; + } +} + +impl Widget for InputBarWidget { + fn render(&mut self, area: Rect, screen: &mut Screen) { + if area.is_empty() || area.height < 2 { + return; + } + + let row = area.y; + let _width = area.width as usize; + + // Draw top border + screen.hline( + row, + area.x, + area.x + area.width - 1, + '─', + Color::Ansi(240), + Color::Default, + ); + + // Draw prompt and input on the next rows + let input_row = row + 1; + + if self.confirming { + // In confirmation mode, show a yellow prompt asking for y/n/a + let confirm_prompt = "[y/n/a]? "; + let mut col = area.x; + screen.write_str( + input_row, + col, + confirm_prompt, + Color::YELLOW, + styles::INPUT_BAR_BG, + Style::bold(), + ); + col += confirm_prompt.len() as u16; + + // Draw blinking cursor indicator + if self.focused && col < area.x + area.width { + if let Some(cell) = screen.get_mut(input_row, col) { + cell.char = '█'; + cell.fg = Color::YELLOW; + cell.style = Style::blink(); + } + } + + // Fill the rest with background + for c in col + 1..area.x + area.width { + if let Some(cell) = screen.get_mut(input_row, c) { + cell.bg = styles::INPUT_BAR_BG; + } + } + } else if self.questioning { + // In question mode, show a cyan prompt asking for answer + let question_prompt = format!("[1-{} or type]: ", self.question_answer_count); + let mut col = area.x; + screen.write_str( + input_row, + col, + &question_prompt, + Color::CYAN, + styles::INPUT_BAR_BG, + Style::bold(), + ); + col += question_prompt.len() as u16; + + // Draw input content + let available_width = area.width.saturating_sub(col - area.x); + let display_text = if self.content.len() > available_width as usize { + let start = self.content.len().saturating_sub(available_width as usize); + &self.content[start..] + } else { + &self.content + }; + + screen.write_str( + input_row, + col, + display_text, + Color::WHITE, + styles::INPUT_BAR_BG, + Style::default(), + ); + + // Draw cursor + if self.focused { + let cursor_col = col + self.cursor.min(display_text.len()) as u16; + if cursor_col < area.x + area.width { + if let Some(cell) = screen.get_mut(input_row, cursor_col) { + cell.style.underline = true; + } + } + } + } else { + let prompt = format!("[{}] ", self.mode_label); + let _model_suffix = format!(" {}{}", self.model_name, Color::Default.fg_escape()); + + // Draw mode label + let mut col = area.x; + screen.write_str( + input_row, + col, + &prompt, + self.mode_color, + styles::INPUT_BAR_BG, + Style::bold(), + ); + col += prompt.len() as u16; + + // Draw input content (with wrapping if needed) + let available_width = area.width.saturating_sub(col - area.x); + let display_text = if self.content.len() > available_width as usize { + // Show the end of the text that fits, scrolled to cursor + let start = self.content.len().saturating_sub(available_width as usize); + &self.content[start..] + } else { + &self.content + }; + + screen.write_str( + input_row, + col, + display_text, + Color::WHITE, + styles::INPUT_BAR_BG, + Style::default(), + ); + + // Draw cursor (blinking is handled by terminal, we just position it) + if self.focused { + let cursor_col = col + self.cursor.min(display_text.len()) as u16; + if cursor_col < area.x + area.width { + // Underline the character under the cursor + if let Some(cell) = screen.get_mut(input_row, cursor_col) { + cell.style.underline = true; + } + } + } + + // Fill the rest of the input line with background + let text_end = col + display_text.len() as u16; + if text_end < area.x + area.width { + // Background is already filled by write_str + } + + // For multi-line input, render additional lines + let lines: Vec<&str> = self.content.lines().collect(); + for (i, line) in lines.iter().enumerate() { + if i == 0 { + continue; // Already rendered the first line + } + let line_row = input_row + i as u16; + if line_row >= area.y + area.height { + break; + } + screen.write_str( + line_row, + area.x, + line, + Color::WHITE, + styles::INPUT_BAR_BG, + Style::default(), + ); + } + } + } + + fn handle_event(&mut self, event: &Event) -> Action { + // Handle paste events (bracketed paste mode) + if let Event::Paste(text) = event { + if !self.confirming && !self.questioning { + self.content.insert_str(self.cursor, text); + self.cursor += text.len(); + self.reset_tab_cycle(); + } + return Action::None; + } + + let Event::Key(key) = event else { + return Action::None; + }; + + // In confirmation mode, only accept y/n/a responses + if self.confirming { + match key { + KeyEvent { + key: Key::Char('y'), + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.confirming = false; + Action::ConfirmYes + } + KeyEvent { + key: Key::Char('n'), + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.confirming = false; + Action::ConfirmNo + } + KeyEvent { + key: Key::Char('a'), + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.confirming = false; + Action::ConfirmAll + } + KeyEvent { + key: Key::Escape, .. + } => { + self.confirming = false; + Action::ConfirmNo + } + _ => Action::None, + } + } else if self.questioning { + // In question mode, accept typing and Enter to submit + match key { + KeyEvent { + key: Key::Enter, + modifiers, + } if !modifiers.shift => { + let text = self.take_input(); + self.questioning = false; + if text.trim().is_empty() { + // Empty input — skip the question + Action::AnswerQuestion("Skipped (no answer provided)".to_string()) + } else { + // Check if the user typed a number matching an option + let trimmed = text.trim(); + if let Ok(num) = trimmed.parse::() { + if num >= 1 && num <= self.question_answer_count { + // Number input — will be resolved by the app + Action::AnswerQuestion(trimmed.to_string()) + } else { + // Out of range number — treat as free-form input + Action::AnswerQuestion(trimmed.to_string()) + } + } else { + // Free-form text answer + Action::AnswerQuestion(trimmed.to_string()) + } + } + } + KeyEvent { + key: Key::Escape, .. + } => { + self.questioning = false; + Action::AnswerQuestion("Skipped (no answer provided)".to_string()) + } + KeyEvent { + key: Key::Char(c), + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.content.insert(self.cursor, *c); + self.cursor += c.len_utf8(); + self.reset_tab_cycle(); + Action::None + } + KeyEvent { + key: Key::Backspace, + .. + } => { + if self.cursor > 0 { + let prev_char = self.content[..self.cursor].chars().next_back(); + if let Some(ch) = prev_char { + self.cursor -= ch.len_utf8(); + self.content.remove(self.cursor); + } + } + self.reset_tab_cycle(); + Action::None + } + _ => Action::None, + } + } else { + self.handle_normal_key(key) + } + } + + fn focused(&self) -> bool { + self.focused + } + + fn set_focus(&mut self, focused: bool) { + self.focused = focused; + } +} + +impl InputBarWidget { + /// Handle a key event in normal (non-confirmation) mode. + fn handle_normal_key(&mut self, key: &KeyEvent) -> Action { + match key { + KeyEvent { + key: Key::Enter, + modifiers, + } => { + if modifiers.shift { + // Shift+Enter: insert newline + self.content.insert(self.cursor, '\n'); + self.cursor += 1; + self.reset_tab_cycle(); + Action::None + } else { + // Enter: submit the message + let text = self.take_input(); + self.reset_tab_cycle(); + if text.trim().is_empty() { + Action::None + } else { + Action::SendMessage(text) + } + } + } + KeyEvent { + key: Key::Backspace, + .. + } => { + if self.cursor > 0 { + // Handle deleting across newlines + let prev_char = self.content[..self.cursor].chars().next_back(); + if let Some(ch) = prev_char { + self.cursor -= ch.len_utf8(); + self.content.remove(self.cursor); + } + } + self.reset_tab_cycle(); + Action::None + } + KeyEvent { + key: Key::Delete, .. + } => { + if self.cursor < self.content.len() { + // Find the next character boundary + let next_char = self.content[self.cursor..].chars().next(); + if let Some(ch) = next_char { + let end = self.cursor + ch.len_utf8(); + self.content.replace_range(self.cursor..end, ""); + } + } + self.reset_tab_cycle(); + Action::None + } + KeyEvent { + key: Key::Left, + modifiers, + } => { + if modifiers.ctrl { + // Ctrl+Left: move back one word + if self.cursor > 0 { + let text_before = &self.content[..self.cursor]; + let trimmed = + text_before.trim_end_matches(|c: char| c.is_whitespace() && c != '\n'); + let word_start = if trimmed.len() < text_before.len() { + trimmed + .rfind(|c: char| c.is_whitespace() || c == '\n') + .map(|p| p + 1) + .unwrap_or(0) + } else { + text_before + .rfind(|c: char| c.is_whitespace() || c == '\n') + .map(|p| p + 1) + .unwrap_or(0) + }; + self.cursor = word_start; + } + } else if self.cursor > 0 { + if let Some(ch) = self.content[..self.cursor].chars().next_back() { + self.cursor -= ch.len_utf8(); + } + } + self.reset_tab_cycle(); + Action::None + } + KeyEvent { + key: Key::Right, + modifiers, + } => { + if modifiers.ctrl { + // Ctrl+Right: move forward one word + if self.cursor < self.content.len() { + let text_after = &self.content[self.cursor..]; + // Skip the current word, then skip trailing whitespace + let word_end = text_after + .find(|c: char| c.is_whitespace() || c == '\n') + .unwrap_or(text_after.len()); + let after_word = &text_after[word_end..]; + let whitespace_skipped = after_word + .chars() + .take_while(|c| c.is_whitespace() && *c != '\n') + .count(); + self.cursor += word_end + whitespace_skipped; + } + } else if self.cursor < self.content.len() { + if let Some(ch) = self.content[self.cursor..].chars().next() { + self.cursor += ch.len_utf8(); + } + } + self.reset_tab_cycle(); + Action::None + } + KeyEvent { key: Key::Home, .. } => { + // Move to start of current line + let line_start = self.content[..self.cursor] + .rfind('\n') + .map(|p| p + 1) + .unwrap_or(0); + self.cursor = line_start; + self.reset_tab_cycle(); + Action::None + } + KeyEvent { key: Key::End, .. } => { + // Move to end of current line + let line_end = self.content[self.cursor..] + .find('\n') + .map(|p| self.cursor + p) + .unwrap_or(self.content.len()); + self.cursor = line_end; + self.reset_tab_cycle(); + Action::None + } + KeyEvent { + key: Key::Up, + modifiers, + } if !modifiers.alt && !modifiers.ctrl => { + // History navigation + if !self.history.is_empty() { + let idx = self.history_index.unwrap_or(self.history.len()); + if idx > 0 { + let new_idx = idx - 1; + self.history_index = Some(new_idx); + self.content = self.history[new_idx].clone(); + self.cursor = self.content.len(); + } + } + self.reset_tab_cycle(); + Action::None + } + KeyEvent { + key: Key::Down, + modifiers, + } if !modifiers.alt && !modifiers.ctrl => { + // History navigation + if let Some(idx) = self.history_index { + if idx + 1 < self.history.len() { + self.history_index = Some(idx + 1); + self.content = self.history[idx + 1].clone(); + } else { + self.history_index = None; + self.content.clear(); + } + self.cursor = self.content.len(); + } + self.reset_tab_cycle(); + Action::None + } + KeyEvent { + key: Key::Tab, + modifiers, + } if !modifiers.shift => { + // Tab: command completion if input starts with '/', otherwise cycle focus + if self.is_command_input() { + self.tab_complete(); + Action::None + } else { + // Not a command — let the app cycle focus + Action::CycleFocusForward + } + } + KeyEvent { + key: Key::BackTab, .. + } => { + // Shift+Tab: always cycle focus backward + Action::CycleFocusBackward + } + KeyEvent { + key: Key::Char(c), + modifiers, + } => { + if modifiers.ctrl { + // Handle Ctrl+key shortcuts + match c { + 'c' => Action::Quit, + 'd' => Action::Quit, + 'a' => { + // Ctrl+A: move cursor to start of line + let line_start = self.content[..self.cursor] + .rfind('\n') + .map(|p| p + 1) + .unwrap_or(0); + self.cursor = line_start; + self.reset_tab_cycle(); + Action::None + } + 'e' => { + // Ctrl+E: move cursor to end of line + let line_end = self.content[self.cursor..] + .find('\n') + .map(|p| self.cursor + p) + .unwrap_or(self.content.len()); + self.cursor = line_end; + self.reset_tab_cycle(); + Action::None + } + 'u' => { + // Ctrl+U: clear from cursor to beginning of line + let line_start = self.content[..self.cursor] + .rfind('\n') + .map(|p| p + 1) + .unwrap_or(0); + let killed = self.content[line_start..self.cursor].to_string(); + if !killed.is_empty() { + self.kill_ring = killed; + } + self.content.replace_range(line_start..self.cursor, ""); + self.cursor = line_start; + self.reset_tab_cycle(); + Action::None + } + 'k' => { + // Ctrl+K: clear from cursor to end of line + let line_end = self.content[self.cursor..] + .find('\n') + .map(|p| self.cursor + p) + .unwrap_or(self.content.len()); + let killed = self.content[self.cursor..line_end].to_string(); + if !killed.is_empty() { + self.kill_ring = killed; + } + self.content.replace_range(self.cursor..line_end, ""); + self.reset_tab_cycle(); + Action::None + } + 'w' => { + // Ctrl+W: delete word backward + if self.cursor > 0 { + // Find the start of the previous word + let text_before = &self.content[..self.cursor]; + let trimmed = text_before + .trim_end_matches(|c: char| c.is_whitespace() && c != '\n'); + let word_start = if trimmed.len() < text_before.len() { + // There was trailing whitespace — skip it then find the word + trimmed + .rfind(|c: char| c.is_whitespace() || c == '\n') + .map(|p| p + 1) + .unwrap_or(0) + } else { + // No trailing whitespace — find the word boundary + text_before + .rfind(|c: char| c.is_whitespace() || c == '\n') + .map(|p| p + 1) + .unwrap_or(0) + }; + let killed = self.content[word_start..self.cursor].to_string(); + if !killed.is_empty() { + self.kill_ring = killed; + } + self.content.replace_range(word_start..self.cursor, ""); + self.cursor = word_start; + } + self.reset_tab_cycle(); + Action::None + } + 'y' => { + // Ctrl+Y: yank (paste) from kill ring + if !self.kill_ring.is_empty() { + self.content.insert_str(self.cursor, &self.kill_ring); + self.cursor += self.kill_ring.len(); + } + self.reset_tab_cycle(); + Action::None + } + _ => Action::None, + } + } else if modifiers.alt { + // Handle Alt+key shortcuts + match c { + 'b' => { + // Alt+B: move cursor back one word + if self.cursor > 0 { + let text_before = &self.content[..self.cursor]; + let trimmed = text_before + .trim_end_matches(|c: char| c.is_whitespace() && c != '\n'); + let word_start = if trimmed.len() < text_before.len() { + trimmed + .rfind(|c: char| c.is_whitespace() || c == '\n') + .map(|p| p + 1) + .unwrap_or(0) + } else { + text_before + .rfind(|c: char| c.is_whitespace() || c == '\n') + .map(|p| p + 1) + .unwrap_or(0) + }; + self.cursor = word_start; + } + self.reset_tab_cycle(); + Action::None + } + 'f' => { + // Alt+F: move cursor forward one word + if self.cursor < self.content.len() { + let text_after = &self.content[self.cursor..]; + // Skip the current word, then skip trailing whitespace + let word_end = text_after + .find(|c: char| c.is_whitespace() || c == '\n') + .unwrap_or(text_after.len()); + let after_word = &text_after[word_end..]; + let whitespace_skipped = after_word + .chars() + .take_while(|c| c.is_whitespace() && *c != '\n') + .count(); + self.cursor += word_end + whitespace_skipped; + } + self.reset_tab_cycle(); + Action::None + } + '\x08' | '\x7f' => { + // Alt+Backspace: delete word backward (same as Ctrl+W) + if self.cursor > 0 { + let text_before = &self.content[..self.cursor]; + let trimmed = text_before + .trim_end_matches(|c: char| c.is_whitespace() && c != '\n'); + let word_start = if trimmed.len() < text_before.len() { + trimmed + .rfind(|c: char| c.is_whitespace() || c == '\n') + .map(|p| p + 1) + .unwrap_or(0) + } else { + text_before + .rfind(|c: char| c.is_whitespace() || c == '\n') + .map(|p| p + 1) + .unwrap_or(0) + }; + let killed = self.content[word_start..self.cursor].to_string(); + if !killed.is_empty() { + self.kill_ring = killed; + } + self.content.replace_range(word_start..self.cursor, ""); + self.cursor = word_start; + } + self.reset_tab_cycle(); + Action::None + } + _ => Action::None, + } + } else { + self.content.insert(self.cursor, *c); + self.cursor += c.len_utf8(); + self.reset_tab_cycle(); + Action::None + } + } + KeyEvent { + key: Key::Escape, .. + } => { + if self.content.is_empty() { + Action::Quit + } else { + // Clear input on Escape + self.content.clear(); + self.cursor = 0; + self.reset_tab_cycle(); + Action::None + } + } + _ => Action::None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::event::Modifiers; + + /// Standard command names used in tests. + fn test_command_names() -> Vec { + vec![ + "/add", + "/agent", + "/apikey", + "/audit", + "/autoaccept", + "/casual", + "/clear", + "/command", + "/compact", + "/context", + "/contextlimit", + "/drop", + "/dropall", + "/exit", + "/files", + "/help", + "/init", + "/mode", + "/model", + "/plan", + "/project-settings", + "/quit", + "/refresh", + "/rename", + "/retries", + "/research", + "/session", + "/sessions", + "/settings", + "/showthink", + "/skill", + "/skills", + "/think", + "/timeout", + "/unload", + "/use", + ] + .into_iter() + .map(|s| s.to_string()) + .collect() + } + + /// Standard subcommand completions used in tests. + fn test_subcommands() -> HashMap> { + let mut subs = HashMap::new(); + subs.insert( + "/command".to_string(), + vec![ + "add".into(), + "deny".into(), + "help".into(), + "list".into(), + "rm".into(), + "reset".into(), + "resetdeny".into(), + "undeny".into(), + ], + ); + subs.insert("/session".to_string(), vec!["delete".into()]); + subs.insert( + "/mode".to_string(), + vec![ + "agent".into(), + "casual".into(), + "planning".into(), + "research".into(), + ], + ); + subs.insert("/settings".to_string(), vec!["all".into()]); + subs.insert("/autoaccept".to_string(), vec!["off".into(), "on".into()]); + subs.insert("/apikey".to_string(), vec!["clear".into()]); + subs.insert("/showthink".to_string(), vec!["off".into(), "on".into()]); + subs.insert( + "/think".to_string(), + vec!["high".into(), "low".into(), "medium".into(), "off".into()], + ); + subs + } + + /// Create an InputBarWidget with test command data for tab-completion tests. + fn bar_with_commands(mode_label: &str, model_name: &str) -> InputBarWidget { + InputBarWidget::with_commands( + mode_label, + model_name, + test_command_names(), + test_subcommands(), + ) + } + + #[test] + fn test_input_bar_new() { + let bar = InputBarWidget::new("agent", "llama3.1:8b"); + assert!(bar.content.is_empty()); + assert_eq!(bar.cursor, 0); + assert!(bar.focused); + } + + #[test] + fn test_input_bar_take_input() { + let mut bar = InputBarWidget::new("agent", "llama3.1:8b"); + bar.content = "hello".to_string(); + bar.cursor = 5; + let text = bar.take_input(); + assert_eq!(text, "hello"); + assert!(bar.content.is_empty()); + assert_eq!(bar.cursor, 0); + assert_eq!(bar.history.len(), 1); + } + + #[test] + fn test_input_bar_type_chars() { + let mut bar = InputBarWidget::new("agent", "llama3.1:8b"); + let event = Event::Key(KeyEvent { + key: Key::Char('h'), + modifiers: Modifiers::new(), + }); + bar.handle_event(&event); + assert_eq!(bar.content, "h"); + assert_eq!(bar.cursor, 1); + + let event = Event::Key(KeyEvent { + key: Key::Char('i'), + modifiers: Modifiers::new(), + }); + bar.handle_event(&event); + assert_eq!(bar.content, "hi"); + assert_eq!(bar.cursor, 2); + } + + #[test] + fn test_input_bar_backspace() { + let mut bar = InputBarWidget::new("agent", "llama3.1:8b"); + bar.content = "hello".to_string(); + bar.cursor = 5; + + let event = Event::Key(KeyEvent { + key: Key::Backspace, + modifiers: Modifiers::new(), + }); + bar.handle_event(&event); + assert_eq!(bar.content, "hell"); + assert_eq!(bar.cursor, 4); + } + + #[test] + fn test_input_bar_enter_submits() { + let mut bar = InputBarWidget::new("agent", "llama3.1:8b"); + bar.content = "hello".to_string(); + bar.cursor = 5; + + let event = Event::Key(KeyEvent { + key: Key::Enter, + modifiers: Modifiers::new(), + }); + let action = bar.handle_event(&event); + assert!(matches!(action, Action::SendMessage(ref s) if s == "hello")); + assert!(bar.content.is_empty()); + } + + #[test] + fn test_input_bar_shift_enter_newline() { + let mut bar = InputBarWidget::new("agent", "llama3.1:8b"); + bar.content = "hello".to_string(); + bar.cursor = 5; + + let event = Event::Key(KeyEvent { + key: Key::Enter, + modifiers: Modifiers::shift(), + }); + let action = bar.handle_event(&event); + assert!(matches!(action, Action::None)); + assert_eq!(bar.content, "hello\n"); + } + + #[test] + fn test_input_bar_escape_clears() { + let mut bar = InputBarWidget::new("agent", "llama3.1:8b"); + bar.content = "hello".to_string(); + bar.cursor = 5; + + let event = Event::Key(KeyEvent { + key: Key::Escape, + modifiers: Modifiers::new(), + }); + let action = bar.handle_event(&event); + assert!(matches!(action, Action::None)); + assert!(bar.content.is_empty()); + } + + #[test] + fn test_tab_complete_command() { + let mut bar = bar_with_commands("agent", "llama3.1:8b"); + bar.content = "/mod".to_string(); + bar.cursor = 4; + + let event = Event::Key(KeyEvent { + key: Key::Tab, + modifiers: Modifiers::new(), + }); + bar.handle_event(&event); + assert_eq!(bar.content, "/mode"); + assert_eq!(bar.cursor, 5); + } + + #[test] + fn test_tab_complete_cycle() { + let mut bar = bar_with_commands("agent", "llama3.1:8b"); + bar.content = "/co".to_string(); + bar.cursor = 3; + + let event = Event::Key(KeyEvent { + key: Key::Tab, + modifiers: Modifiers::new(), + }); + + // First Tab — completes to first match + bar.handle_event(&event); + let first = bar.content.clone(); + assert!(first.starts_with("/co")); + + // Second Tab — cycles to next match + bar.handle_event(&event); + let second = bar.content.clone(); + assert!(second.starts_with("/co")); + assert_ne!(first, second); + + // Third Tab — cycles to next match + bar.handle_event(&event); + let third = bar.content.clone(); + assert!(third.starts_with("/co")); + // Should cycle through /command, /compact, /context + assert_ne!(second, third); + } + + #[test] + fn test_tab_complete_resets_on_typing() { + let mut bar = bar_with_commands("agent", "llama3.1:8b"); + bar.content = "/mod".to_string(); + bar.cursor = 4; + + let tab = Event::Key(KeyEvent { + key: Key::Tab, + modifiers: Modifiers::new(), + }); + bar.handle_event(&tab); + assert_eq!(bar.content, "/mode"); + + // Type a character — should reset tab cycle state + let char_event = Event::Key(KeyEvent { + key: Key::Char(' '), + modifiers: Modifiers::new(), + }); + bar.handle_event(&char_event); + assert_eq!(bar.content, "/mode "); + + // Tab again — should start a new completion cycle for subcommands + bar.handle_event(&tab); + // "/mode " with subcommand completion for /mode + assert!(bar.content.starts_with("/mode ")); + } + + #[test] + fn test_tab_complete_subcommand() { + let mut bar = bar_with_commands("agent", "llama3.1:8b"); + bar.content = "/command a".to_string(); + bar.cursor = 10; + + let event = Event::Key(KeyEvent { + key: Key::Tab, + modifiers: Modifiers::new(), + }); + bar.handle_event(&event); + assert_eq!(bar.content, "/command add"); + } + + #[test] + fn test_tab_non_command_cycles_focus() { + let mut bar = InputBarWidget::new("agent", "llama3.1:8b"); + bar.content = "hello".to_string(); + bar.cursor = 5; + + let event = Event::Key(KeyEvent { + key: Key::Tab, + modifiers: Modifiers::new(), + }); + let action = bar.handle_event(&event); + assert!(matches!(action, Action::CycleFocusForward)); + // Content should be unchanged + assert_eq!(bar.content, "hello"); + } + + #[test] + fn test_shift_tab_cycles_focus_backward() { + let mut bar = InputBarWidget::new("agent", "llama3.1:8b"); + bar.content = "hello".to_string(); + + let event = Event::Key(KeyEvent { + key: Key::BackTab, + modifiers: Modifiers::new(), + }); + let action = bar.handle_event(&event); + assert!(matches!(action, Action::CycleFocusBackward)); + } + + #[test] + fn test_tab_complete_empty_prefix() { + let mut bar = bar_with_commands("agent", "llama3.1:8b"); + bar.content = "/".to_string(); + bar.cursor = 1; + + let event = Event::Key(KeyEvent { + key: Key::Tab, + modifiers: Modifiers::new(), + }); + bar.handle_event(&event); + // Should complete to the first command alphabetically + assert!(bar.content.starts_with('/')); + assert!(bar.content.len() > 1); + } + + #[test] + fn test_is_command_input() { + let mut bar = InputBarWidget::new("agent", "llama3.1:8b"); + assert!(!bar.is_command_input()); + bar.content = "/help".to_string(); + assert!(bar.is_command_input()); + bar.content = "hello".to_string(); + assert!(!bar.is_command_input()); + } + + #[test] + fn test_confirmation_mode_set() { + let mut bar = InputBarWidget::new("agent", "llama3.1:8b"); + assert!(!bar.is_confirming()); + bar.set_confirming(true); + assert!(bar.is_confirming()); + assert!(bar.content.is_empty()); + bar.set_confirming(false); + assert!(!bar.is_confirming()); + } + + #[test] + fn test_confirmation_y_approves() { + let mut bar = InputBarWidget::new("agent", "llama3.1:8b"); + bar.set_confirming(true); + + let event = Event::Key(KeyEvent { + key: Key::Char('y'), + modifiers: Modifiers::new(), + }); + let action = bar.handle_event(&event); + assert!(matches!(action, Action::ConfirmYes)); + assert!(!bar.is_confirming()); + } + + #[test] + fn test_confirmation_n_denies() { + let mut bar = InputBarWidget::new("agent", "llama3.1:8b"); + bar.set_confirming(true); + + let event = Event::Key(KeyEvent { + key: Key::Char('n'), + modifiers: Modifiers::new(), + }); + let action = bar.handle_event(&event); + assert!(matches!(action, Action::ConfirmNo)); + assert!(!bar.is_confirming()); + } + + #[test] + fn test_confirmation_a_approves_all() { + let mut bar = InputBarWidget::new("agent", "llama3.1:8b"); + bar.set_confirming(true); + + let event = Event::Key(KeyEvent { + key: Key::Char('a'), + modifiers: Modifiers::new(), + }); + let action = bar.handle_event(&event); + assert!(matches!(action, Action::ConfirmAll)); + assert!(!bar.is_confirming()); + } + + #[test] + fn test_confirmation_escape_denies() { + let mut bar = InputBarWidget::new("agent", "llama3.1:8b"); + bar.set_confirming(true); + + let event = Event::Key(KeyEvent { + key: Key::Escape, + modifiers: Modifiers::new(), + }); + let action = bar.handle_event(&event); + assert!(matches!(action, Action::ConfirmNo)); + assert!(!bar.is_confirming()); + } + + #[test] + fn test_confirmation_ignores_other_keys() { + let mut bar = InputBarWidget::new("agent", "llama3.1:8b"); + bar.set_confirming(true); + + let event = Event::Key(KeyEvent { + key: Key::Char('x'), + modifiers: Modifiers::new(), + }); + let action = bar.handle_event(&event); + assert!(matches!(action, Action::None)); + assert!(bar.is_confirming()); // Still in confirmation mode + } + + #[test] + fn test_confirmation_ctrl_y_ignored() { + let mut bar = InputBarWidget::new("agent", "llama3.1:8b"); + bar.set_confirming(true); + + let event = Event::Key(KeyEvent { + key: Key::Char('y'), + modifiers: Modifiers::ctrl(), + }); + let action = bar.handle_event(&event); + assert!(matches!(action, Action::None)); + assert!(bar.is_confirming()); // Ctrl+y should not confirm + } + + // ── Emacs-style editing shortcut tests ───────────────────────────── + + #[test] + fn test_ctrl_u_clears_before_cursor() { + let mut bar = InputBarWidget::new("agent", "test"); + bar.content = "hello world".to_string(); + bar.cursor = 5; + let event = Event::Key(KeyEvent { + key: Key::Char('u'), + modifiers: Modifiers::ctrl(), + }); + bar.handle_event(&event); + assert_eq!(bar.content, " world"); + assert_eq!(bar.cursor, 0); + } + + #[test] + fn test_ctrl_k_clears_after_cursor() { + let mut bar = InputBarWidget::new("agent", "test"); + bar.content = "hello world".to_string(); + bar.cursor = 5; + let event = Event::Key(KeyEvent { + key: Key::Char('k'), + modifiers: Modifiers::ctrl(), + }); + bar.handle_event(&event); + assert_eq!(bar.content, "hello"); + assert_eq!(bar.cursor, 5); + } + + #[test] + fn test_ctrl_w_deletes_word_backward() { + let mut bar = InputBarWidget::new("agent", "test"); + bar.content = "hello world".to_string(); + bar.cursor = 11; + let event = Event::Key(KeyEvent { + key: Key::Char('w'), + modifiers: Modifiers::ctrl(), + }); + bar.handle_event(&event); + assert_eq!(bar.content, "hello "); + } + + #[test] + fn test_ctrl_a_move_to_line_start() { + let mut bar = InputBarWidget::new("agent", "test"); + bar.content = "hello world".to_string(); + bar.cursor = 5; + let event = Event::Key(KeyEvent { + key: Key::Char('a'), + modifiers: Modifiers::ctrl(), + }); + bar.handle_event(&event); + assert_eq!(bar.cursor, 0); + } + + #[test] + fn test_ctrl_e_move_to_line_end() { + let mut bar = InputBarWidget::new("agent", "test"); + bar.content = "hello world".to_string(); + bar.cursor = 0; + let event = Event::Key(KeyEvent { + key: Key::Char('e'), + modifiers: Modifiers::ctrl(), + }); + bar.handle_event(&event); + assert_eq!(bar.cursor, 11); + } + + #[test] + fn test_ctrl_y_yanks_kill_ring() { + let mut bar = InputBarWidget::new("agent", "test"); + bar.content = "hello world".to_string(); + bar.cursor = 11; + // Kill "world" with Ctrl+W + let kill_event = Event::Key(KeyEvent { + key: Key::Char('w'), + modifiers: Modifiers::ctrl(), + }); + bar.handle_event(&kill_event); + assert_eq!(bar.content, "hello "); + assert_eq!(bar.kill_ring, "world"); + // Yank it back + let yank_event = Event::Key(KeyEvent { + key: Key::Char('y'), + modifiers: Modifiers::ctrl(), + }); + bar.handle_event(&yank_event); + assert_eq!(bar.content, "hello world"); + } + + #[test] + fn test_ctrl_k_yank_roundtrip() { + let mut bar = InputBarWidget::new("agent", "test"); + bar.content = "hello world".to_string(); + bar.cursor = 5; + // Ctrl+K kills " world" + let event = Event::Key(KeyEvent { + key: Key::Char('k'), + modifiers: Modifiers::ctrl(), + }); + bar.handle_event(&event); + assert_eq!(bar.content, "hello"); + assert_eq!(bar.kill_ring, " world"); + // Ctrl+Y yanks it back + let yank_event = Event::Key(KeyEvent { + key: Key::Char('y'), + modifiers: Modifiers::ctrl(), + }); + bar.handle_event(&yank_event); + assert_eq!(bar.content, "hello world"); + } + + #[test] + fn test_ctrl_u_yank_roundtrip() { + let mut bar = InputBarWidget::new("agent", "test"); + bar.content = "hello world".to_string(); + bar.cursor = 5; + // Ctrl+U kills "hello" + let event = Event::Key(KeyEvent { + key: Key::Char('u'), + modifiers: Modifiers::ctrl(), + }); + bar.handle_event(&event); + assert_eq!(bar.content, " world"); + assert_eq!(bar.kill_ring, "hello"); + // Ctrl+Y yanks it back + let yank_event = Event::Key(KeyEvent { + key: Key::Char('y'), + modifiers: Modifiers::ctrl(), + }); + bar.handle_event(&yank_event); + assert_eq!(bar.content, "hello world"); + } + + #[test] + fn test_ctrl_left_right_word_movement() { + let mut bar = InputBarWidget::new("agent", "test"); + bar.content = "hello world test".to_string(); + bar.cursor = 0; + // Ctrl+Right: jump forward by word (word + trailing whitespace) + let right_event = Event::Key(KeyEvent { + key: Key::Right, + modifiers: Modifiers::ctrl(), + }); + bar.handle_event(&right_event); + assert_eq!(bar.cursor, 6); // after "hello " (0 + 5 + 1 = 6) + bar.handle_event(&right_event); + assert_eq!(bar.cursor, 12); // after "world " (6 + 5 + 1 = 12) + + // Ctrl+Left: jump back by word + let left_event = Event::Key(KeyEvent { + key: Key::Left, + modifiers: Modifiers::ctrl(), + }); + bar.handle_event(&left_event); + assert_eq!(bar.cursor, 6); // before "world " + bar.handle_event(&left_event); + assert_eq!(bar.cursor, 0); // before "hello " + } + + #[test] + fn test_alt_b_f_word_movement() { + let mut bar = InputBarWidget::new("agent", "test"); + bar.content = "hello world test".to_string(); + bar.cursor = 16; // end + + // Alt+B: move back by word + let alt_b = Event::Key(KeyEvent { + key: Key::Char('b'), + modifiers: Modifiers::alt(), + }); + bar.handle_event(&alt_b); + assert_eq!(bar.cursor, 12); // before "test" + bar.handle_event(&alt_b); + assert_eq!(bar.cursor, 6); // before "world" + + // Alt+F: move forward by word (skips word + trailing whitespace) + let alt_f = Event::Key(KeyEvent { + key: Key::Char('f'), + modifiers: Modifiers::alt(), + }); + bar.handle_event(&alt_f); + assert_eq!(bar.cursor, 12); // after "world " (6 + 5 + 1 = 12) + bar.handle_event(&alt_f); + assert_eq!(bar.cursor, 16); // after "test" (12 + 4 = 16) + } + + #[test] + fn test_bracketed_paste() { + let mut bar = InputBarWidget::new("agent", "test"); + bar.content = "hello".to_string(); + bar.cursor = 5; + let event = Event::Paste(" world from paste".to_string()); + bar.handle_event(&event); + assert_eq!(bar.content, "hello world from paste"); + assert_eq!(bar.cursor, 22); + } + + #[test] + fn test_bracketed_paste_ignored_in_confirmation_mode() { + let mut bar = InputBarWidget::new("agent", "test"); + bar.set_confirming(true); + let event = Event::Paste("pasted text".to_string()); + bar.handle_event(&event); + // In confirmation mode, paste should be ignored + assert!(bar.content.is_empty()); + } + + #[test] + fn test_bracketed_paste_ignored_in_question_mode() { + let mut bar = InputBarWidget::new("agent", "test"); + bar.set_questioning(true, 3); + // Actually, question mode does allow typing. Let's just test it doesn't crash. + let event = Event::Paste("test".to_string()); + bar.handle_event(&event); + // Paste is ignored in question mode per our implementation + } +} diff --git a/tinyharness-ui/src/tui/widgets/sidebar.rs b/tinyharness-ui/src/tui/widgets/sidebar.rs new file mode 100644 index 0000000..20df1af --- /dev/null +++ b/tinyharness-ui/src/tui/widgets/sidebar.rs @@ -0,0 +1,1526 @@ +// ── Sidebar widget ────────────────────────────────────────────────────────── +// +// Displays project context, directory structure, and active skills in a +// right-side panel. The structure section is scrollable when it overflows. +// When the sidebar is in "structure mode" (Ctrl+P), the structure section +// becomes an interactive file browser where you can navigate directories. + +use std::fs; +use std::path::PathBuf; + +use crate::tui::cell::{Cell, Color, Style}; +use crate::tui::event::{Event, Key, KeyEvent}; +use crate::tui::layout::Rect; +use crate::tui::screen::Screen; +use crate::tui::widget::{Action, Widget, styles, truncate_str}; + +// ── Directory entry for the file browser ────────────────────────────────────── + +/// A single entry in the file browser listing. +#[derive(Clone, Debug)] +struct DirEntry { + /// Display name (just the filename, not the full path). + name: String, + /// Whether this entry is a directory. + is_dir: bool, +} + +impl DirEntry { + /// Return a narrow (single-cell) icon for this entry. + /// + /// All icons are exactly 1 column wide so `write_str` cell positioning + /// stays aligned. Emojis are avoided because they are double-width + /// and cause column misalignment in the cell-based screen buffer. + fn icon(&self) -> &'static str { + if self.is_dir { + "▸" + } else { + match self.name.rsplit('.').next() { + Some("rs") => "Σ", + Some("toml") => "⚙", + Some("md") => "¶", + Some("json") => "{ }", + Some("yaml" | "yml") => "≡", + Some("py") => "λ", + Some("js" | "ts") => "ƒ", + Some("txt") => "─", + Some("lock") => "■", + Some("cfg" | "ini" | "conf") => "※", + Some("sh" | "bash") => "$", + Some("png" | "jpg" | "jpeg" | "gif" | "svg" | "webp") => "◈", + Some("gitignore" | "env") => "○", + _ => "·", + } + } + } +} + +/// Read directory entries from a path, sorted (directories first, then files). +/// If `show_hidden` is true, include files/dirs starting with `.`. +fn read_dir_sorted(path: &PathBuf, show_hidden: bool) -> Vec { + let mut entries: Vec = Vec::new(); + if let Ok(read_dir) = fs::read_dir(path) { + for entry in read_dir.flatten() { + let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); + let name = entry.file_name().to_string_lossy().to_string(); + // Skip hidden files/dirs unless show_hidden is true + if !show_hidden && name.starts_with('.') { + continue; + } + entries.push(DirEntry { name, is_dir }); + } + } + // Sort: directories first, then files; each group sorted alphabetically + entries.sort_by(|a, b| match (a.is_dir, b.is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + }); + entries +} + +/// Return a narrow (single-cell) icon for a file/dir entry. +/// +/// Mirrors `DirEntry::icon()` but works with just name + is_dir. +/// All icons are exactly 1 column wide (2 for `{ }` which is 3 chars +/// but still narrow) to keep cell positioning aligned. +fn icon_for_entry(name: &str, is_dir: bool) -> &'static str { + if is_dir { + "▸" + } else { + match name.rsplit('.').next() { + Some("rs") => "Σ", + Some("toml") => "⚙", + Some("md") => "¶", + Some("json") => "{ }", + Some("yaml" | "yml") => "≡", + Some("py") => "λ", + Some("js" | "ts") => "ƒ", + Some("txt") => "─", + Some("lock") => "■", + Some("cfg" | "ini" | "conf") => "※", + Some("sh" | "bash") => "$", + Some("png" | "jpg" | "jpeg" | "gif" | "svg" | "webp") => "◈", + Some("gitignore" | "env") => "○", + _ => "·", + } + } +} + +// ── Sidebar widget ─────────────────────────────────────────────────────────── + +/// The sidebar widget showing project context. +pub struct SidebarWidget { + pub project_name: String, + pub project_type: String, + pub git_branch: Option, + pub build_command: String, + pub test_command: String, + /// Project directory structure (top-level listing with contents). + pub structure: Vec, + pub active_skills: Vec<(String, String)>, // (name, description) + pub visible: bool, + /// Vertical scroll offset in rows (0 = top). + scroll_offset: usize, + + // ── Interactive file browser state ───────────────────────────────────── + /// Whether the sidebar is in interactive structure mode. + structure_mode: bool, + /// Current directory being browsed. + structure_cwd: PathBuf, + /// Navigation stack for going back (push on enter, pop on escape). + structure_nav_stack: Vec<(PathBuf, usize)>, + /// Directory entries in the current listing. + structure_entries: Vec, + /// Currently selected entry index (index into filtered_entries when filter active). + structure_selected: usize, + /// Scroll offset for the structure entries (in entry rows). + structure_scroll: usize, + /// The workspace root path (used to initialize the browser). + workspace_root: PathBuf, + /// Whether to show hidden files (dotfiles). Toggle with `.` key. + show_hidden: bool, + /// Whether file filter mode is active (started by pressing `/`). + structure_filter_active: bool, + /// The current filter query string. + structure_filter: String, +} + +impl SidebarWidget { + pub fn new() -> Self { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + Self { + project_name: String::new(), + project_type: String::new(), + git_branch: None, + build_command: String::new(), + test_command: String::new(), + structure: Vec::new(), + active_skills: Vec::new(), + visible: true, + scroll_offset: 0, + structure_mode: false, + structure_cwd: cwd.clone(), + structure_nav_stack: Vec::new(), + structure_entries: Vec::new(), + structure_selected: 0, + structure_scroll: 0, + workspace_root: cwd, + show_hidden: false, + structure_filter_active: false, + structure_filter: String::new(), + } + } + + /// Enter interactive structure mode (called when Focus::Structure is set). + pub fn enter_structure_mode(&mut self) { + if !self.structure_mode { + self.structure_mode = true; + self.structure_cwd = self.workspace_root.clone(); + self.structure_nav_stack.clear(); + self.structure_selected = 0; + self.structure_scroll = 0; + self.structure_filter.clear(); + self.structure_filter_active = false; + self.refresh_structure_listing(); + } + } + + /// Exit interactive structure mode (called when focus leaves Structure). + pub fn exit_structure_mode(&mut self) { + self.structure_mode = false; + } + + /// Whether the sidebar is currently in interactive structure mode. + pub fn is_structure_mode(&self) -> bool { + self.structure_mode + } + + /// Handle a mouse click on the sidebar in structure mode. + /// + /// Determines which entry was clicked and selects it. + /// If the same entry was already selected and the click is on a directory, + /// navigate into it (simulating a double-click/Enter). + pub fn click_structure_entry(&mut self, click_row: u16, _click_col: u16, area: Rect) { + if !self.structure_mode || self.structure_entries.is_empty() { + return; + } + + let filtered = self.get_filtered_entries(); + if filtered.is_empty() { + return; + } + + // Count how many rows are rendered before the structure entries + // This must match the rendering logic in render() + let items_before = self.count_items_before_structure(); + let first_entry_row = area.y + items_before as u16; + + // Check if the click is within the structure entries area + if click_row < first_entry_row { + return; + } + + // Calculate which entry was clicked + let entry_offset = (click_row - first_entry_row) as usize; + // Account for scroll: entries are rendered starting at structure_scroll + let entry_index = self.structure_scroll + entry_offset; + + if entry_index < filtered.len() { + let was_selected = self.structure_selected; + self.structure_selected = entry_index; + self.ensure_selected_visible(); + + // If clicking on the already-selected directory, enter it + // (this acts like a double-click / "open" action) + if was_selected == entry_index { + if let Some(entry) = filtered.get(entry_index) { + if entry.is_dir { + let new_path = self.structure_cwd.join(&entry.name); + self.structure_nav_stack + .push((self.structure_cwd.clone(), self.structure_selected)); + self.structure_cwd = new_path; + self.structure_selected = 0; + self.structure_scroll = 0; + self.structure_filter.clear(); + self.structure_filter_active = false; + self.refresh_structure_listing(); + } + } + } + } + } + + /// Set the workspace root path for the file browser. + pub fn set_workspace_root(&mut self, path: PathBuf) { + self.workspace_root = path; + } + + /// Refresh the directory listing from `structure_cwd`. + fn refresh_structure_listing(&mut self) { + self.structure_entries = read_dir_sorted(&self.structure_cwd, self.show_hidden); + // Clamp selection + if !self.structure_entries.is_empty() { + self.structure_selected = self + .structure_selected + .min(self.structure_entries.len() - 1); + } else { + self.structure_selected = 0; + } + self.clamp_structure_scroll(); + } + + /// Clamp structure_scroll so the selected item is visible. + fn clamp_structure_scroll(&mut self) { + // We'll adjust this during render based on visible rows. + // For now, just ensure scroll <= selected. + if self.structure_scroll > self.structure_selected { + self.structure_scroll = self.structure_selected; + } + } + + /// Scroll up by `n` rows. + pub fn scroll_up(&mut self, n: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(n); + } + + /// Scroll down by `n` rows. + pub fn scroll_down(&mut self, n: usize) { + self.scroll_offset = self.scroll_offset.saturating_add(n); + } + + /// Scroll to the top. + pub fn scroll_home(&mut self) { + self.scroll_offset = 0; + } + + /// Calculate how many visual rows the sidebar content needs + /// (excluding the top/bottom padding rows). + /// + /// This must match exactly the number of `SidebarItem`s pushed in `render()`. + fn content_height(&self) -> usize { + let mut rows = 0; + + // Project section header + rows += 1; // header + if !self.project_name.is_empty() { + rows += 1; + } + if !self.project_type.is_empty() { + rows += 1; + } + if self.git_branch.is_some() { + rows += 1; + } + if !self.build_command.is_empty() { + rows += 1; + } + if !self.test_command.is_empty() { + rows += 1; + } + + rows += 1; // spacer before structure + + // Structure section + if self.structure_mode { + rows += 1; // header (includes breadcrumb or filter) + let filtered_count = self.get_filtered_entries().len(); + rows += filtered_count.max(1); // entries (at least 1 for "empty"/"no matches" msg) + rows += 1; // spacer after entries + } else if !self.structure.is_empty() { + rows += 1; // header + rows += self.structure.len(); + rows += 1; // spacer after entries + } + + // Skills section + if !self.active_skills.is_empty() { + rows += 1; // header + rows += self.active_skills.len(); + } + + rows + } +} + +impl Widget for SidebarWidget { + fn render(&mut self, area: Rect, screen: &mut Screen) { + if !self.visible || area.is_empty() { + return; + } + + // Fill background + screen.fill_rect( + area, + Cell { + char: ' ', + fg: styles::SIDEBAR_FG, + bg: styles::SIDEBAR_BG, + style: Style::default(), + }, + ); + + // Draw left border + screen.vline( + area.x, + area.y, + area.y + area.height - 1, + '│', + styles::SIDEBAR_BORDER, + styles::SIDEBAR_BG, + ); + + let max_width = (area.width as usize).saturating_sub(4); // account for border + padding + let visible_rows = area.height as usize; + let total_content = self.content_height(); + + // Clamp scroll offset + let max_scroll = total_content.saturating_sub(visible_rows); + if self.scroll_offset > max_scroll { + self.scroll_offset = max_scroll; + } + + let mut screen_row = area.y + 1; // skip top border area + let skip_rows = self.scroll_offset; + + // Build the list of drawable items + let mut items: Vec = Vec::new(); + + // Project header + items.push(SidebarItem::Header("Project".to_string())); + if !self.project_name.is_empty() { + items.push(SidebarItem::LabeledValue { + label: "Name:".to_string(), + value: self.project_name.clone(), + color: Color::WHITE, + }); + } + if !self.project_type.is_empty() { + items.push(SidebarItem::LabeledValue { + label: "Type:".to_string(), + value: self.project_type.clone(), + color: Color::Ansi(14), + }); + } + if let Some(ref branch) = self.git_branch { + items.push(SidebarItem::LabeledValue { + label: "Git:".to_string(), + value: branch.clone(), + color: Color::GREEN, + }); + } + if !self.build_command.is_empty() { + items.push(SidebarItem::LabeledValue { + label: "Build:".to_string(), + value: self.build_command.clone(), + color: Color::Ansi(252), + }); + } + if !self.test_command.is_empty() { + items.push(SidebarItem::LabeledValue { + label: "Test:".to_string(), + value: self.test_command.clone(), + color: Color::Ansi(252), + }); + } + items.push(SidebarItem::Spacer); + + // Structure section + if self.structure_mode { + // Merge breadcrumb into header so entries start right after it, + // matching the non-interactive layout (no 2-row shift). + let hidden_indicator = if self.show_hidden { " ·" } else { "" }; + let path_display = self.format_breadcrumb(max_width.saturating_sub(14)); + let header = if self.structure_filter_active || !self.structure_filter.is_empty() { + let filter_display = if self.structure_filter.len() > max_width.saturating_sub(6) { + format!( + "…{}", + &self.structure_filter[self + .structure_filter + .len() + .saturating_sub(max_width.saturating_sub(8))..] + ) + } else { + self.structure_filter.clone() + }; + format!( + "Filter: {}{}", + filter_display, + if self.structure_filter_active { + "▏" + } else { + "" + } + ) + } else { + format!("Structure{} ─ {}", hidden_indicator, path_display) + }; + items.push(SidebarItem::Header(header)); + + let filtered = self.get_filtered_entries(); + + if filtered.is_empty() { + items.push(SidebarItem::StructureEntry { + icon: " ".to_string(), + name: "(no matches)".to_string(), + is_dir: false, + selected: false, + }); + } else { + for (i, entry) in filtered.iter().enumerate() { + items.push(SidebarItem::StructureEntry { + icon: entry.icon().to_string(), + name: entry.name.clone(), + is_dir: entry.is_dir, + selected: i == self.structure_selected, + }); + } + } + items.push(SidebarItem::Spacer); + } else if !self.structure.is_empty() { + items.push(SidebarItem::Header("Structure".to_string())); + // Parse and sort structure entries: dirs first, then files (matching interactive mode) + let mut parsed: Vec<(String, String, bool)> = self + .structure + .iter() + .map(|entry| { + let is_dir = entry.ends_with('/') || entry.contains("/ ("); + let name = if is_dir { + // "dirname/ (children)" → "dirname" + entry.split('/').next().unwrap_or(entry).trim().to_string() + } else { + entry.clone() + }; + let icon = icon_for_entry(&name, is_dir).to_string(); + (icon, name, is_dir) + }) + .collect(); + parsed.sort_by(|a, b| match (a.2, b.2) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.1.to_lowercase().cmp(&b.1.to_lowercase()), + }); + for (icon, name, is_dir) in parsed { + items.push(SidebarItem::Entry { icon, name, is_dir }); + } + items.push(SidebarItem::Spacer); + } + + // Skills section + if !self.active_skills.is_empty() { + items.push(SidebarItem::Header("Skills".to_string())); + for (name, _desc) in &self.active_skills { + items.push(SidebarItem::Skill(name.clone())); + } + } + + // Render items with scroll offset + let max_item = skip_rows; + let mut item_idx = 0usize; + let mut drawn_rows = 0usize; + let available_rows = visible_rows.saturating_sub(2); // top/bottom margin + + // Track which screen rows correspond to structure entries for scroll clamping + let mut structure_entry_screen_rows: Vec = Vec::new(); + + for item in &items { + if item_idx < max_item { + item_idx += 1; + continue; + } + if drawn_rows >= available_rows { + break; + } + if screen_row >= area.y + area.height - 1 { + break; + } + + match item { + SidebarItem::Header(title) => { + self.draw_section_header(screen, screen_row, area.x + 2, max_width, title); + } + SidebarItem::LabeledValue { + label, + value, + color, + } => { + self.draw_labeled_value( + screen, + screen_row, + area.x + 2, + max_width, + label, + value, + *color, + ); + } + SidebarItem::Entry { icon, name, is_dir } => { + let suffix = if *is_dir { "/" } else { "" }; + let display = format!("{} {}{}", icon, name, suffix); + let truncated = if display.chars().count() > max_width { + format!("{}…", truncate_str(&display, max_width.saturating_sub(1))) + } else { + display + }; + let fg = if *is_dir { + Color::BRIGHT_CYAN + } else { + Color::Ansi(252) + }; + let text = format!(" {}", truncated); + screen.write_str( + screen_row, + area.x + 2, + &text, + fg, + styles::SIDEBAR_BG, + Style::default(), + ); + // Clear remaining cells on this row to prevent stale characters + let end_col = area.x + 2 + text.chars().count() as u16; + let right_bound = area.x + area.width.saturating_sub(1); + for col in end_col..right_bound { + if let Some(cell) = screen.get_mut(screen_row, col) { + cell.char = ' '; + cell.fg = styles::SIDEBAR_FG; + cell.bg = styles::SIDEBAR_BG; + cell.style = Style::default(); + } + } + } + SidebarItem::StructureEntry { + icon, + name, + is_dir, + selected, + } => { + structure_entry_screen_rows.push(screen_row); + let suffix = if *is_dir { "/" } else { "" }; + let display = format!("{} {}{}", icon, name, suffix); + let truncated = if display.chars().count() > max_width { + format!("{}…", truncate_str(&display, max_width.saturating_sub(1))) + } else { + display + }; + + if *selected { + // Highlighted row: inverted or accent background + let sel_bg = Color::Ansi(240); // slightly lighter than sidebar bg + let sel_fg = Color::WHITE; + // Fill the entire row with selection background first + for col in 0..area.width.saturating_sub(2) { + if let Some(cell) = screen.get_mut(screen_row, area.x + 1 + col) { + cell.char = ' '; + cell.fg = sel_fg; + cell.bg = sel_bg; + cell.style = Style::default(); + } + } + let text = format!("▶ {}", truncated); + screen.write_str( + screen_row, + area.x + 2, + &text, + sel_fg, + sel_bg, + Style::bold(), + ); + } else { + let fg = if *is_dir { + Color::BRIGHT_CYAN + } else { + Color::Ansi(252) + }; + let text = format!(" {}", truncated); + screen.write_str( + screen_row, + area.x + 2, + &text, + fg, + styles::SIDEBAR_BG, + Style::default(), + ); + // Clear remaining cells on this row to prevent stale characters + let end_col = area.x + 2 + text.chars().count() as u16; + let right_bound = area.x + area.width.saturating_sub(1); + for col in end_col..right_bound { + if let Some(cell) = screen.get_mut(screen_row, col) { + cell.char = ' '; + cell.fg = styles::SIDEBAR_FG; + cell.bg = styles::SIDEBAR_BG; + cell.style = Style::default(); + } + } + } + } + SidebarItem::Skill(name) => { + let display = format!("✦ {}", name); + let end_col = area.x + 2 + display.chars().count() as u16; + screen.write_str( + screen_row, + area.x + 2, + &display, + Color::CYAN, + styles::SIDEBAR_BG, + Style::bold(), + ); + // Clear remaining cells on this row + let right_bound = area.x + area.width.saturating_sub(1); + for col in end_col..right_bound { + if let Some(cell) = screen.get_mut(screen_row, col) { + cell.char = ' '; + cell.fg = styles::SIDEBAR_FG; + cell.bg = styles::SIDEBAR_BG; + cell.style = Style::default(); + } + } + } + SidebarItem::Spacer => { + // Just a blank row — background already filled + } + } + + screen_row += 1; + drawn_rows += 1; + item_idx += 1; + } + + // Ensure selected structure entry is visible + if self.structure_mode && !structure_entry_screen_rows.is_empty() { + let sel_offset_in_entries = self.structure_selected; + if sel_offset_in_entries < structure_entry_screen_rows.len() { + let sel_screen_row = structure_entry_screen_rows[sel_offset_in_entries]; + let top = area.y + 1; + let bottom = area.y + area.height - 2; + if sel_screen_row < top || sel_screen_row > bottom { + // Selected item not visible — adjust scroll offset + let _entries_before_header = items + .iter() + .position(|it| matches!(it, SidebarItem::StructureEntry { .. })) + .unwrap_or(0); + let target_scroll = if sel_screen_row < top { + self.scroll_offset + .saturating_sub((top - sel_screen_row) as usize) + } else { + self.scroll_offset + (sel_screen_row - bottom) as usize + }; + self.scroll_offset = target_scroll.min(max_scroll); + } + } + } + + // Draw scrollbar if content overflows + if total_content > available_rows { + let scrollbar_height = available_rows; + let thumb_size = ((scrollbar_height * scrollbar_height) / total_content).max(1) as u16; + let thumb_position = if total_content > available_rows { + (self.scroll_offset as u16 * (scrollbar_height as u16 - thumb_size)) + / (total_content as u16 - available_rows as u16) + } else { + 0 + }; + let sb_x = area.x + area.width - 1; + let sb_top = area.y + 1; + let sb_bottom = area.y + area.height - 1; + + // Draw scrollbar track + for row in sb_top..sb_bottom { + if let Some(cell) = screen.get_mut(row, sb_x) { + cell.char = '│'; + cell.fg = styles::SCROLLBAR_FG; + cell.bg = styles::SIDEBAR_BG; + } + } + + // Draw thumb + for i in 0..thumb_size { + let row = sb_top + thumb_position + i; + if row < sb_bottom { + if let Some(cell) = screen.get_mut(row, sb_x) { + cell.char = '█'; + cell.fg = styles::SCROLLBAR_FG; + } + } + } + } + } + + fn handle_event(&mut self, event: &Event) -> Action { + if self.structure_mode { + return self.handle_structure_event(event); + } + + if let Event::Key(key) = event { + match key { + KeyEvent { + key: Key::Tab, + modifiers, + } if !modifiers.shift && !modifiers.alt && !modifiers.ctrl => { + Action::CycleFocusForward + } + KeyEvent { + key: Key::BackTab, .. + } => Action::CycleFocusBackward, + KeyEvent { + key: Key::Up, + modifiers, + } if !modifiers.alt && !modifiers.ctrl => { + self.scroll_up(1); + Action::None + } + KeyEvent { + key: Key::Down, + modifiers, + } if !modifiers.alt && !modifiers.ctrl => { + self.scroll_down(1); + Action::None + } + KeyEvent { + key: Key::PageUp, .. + } => { + self.scroll_up(10); + Action::None + } + KeyEvent { + key: Key::PageDown, .. + } => { + self.scroll_down(10); + Action::None + } + KeyEvent { key: Key::Home, .. } => { + self.scroll_home(); + Action::None + } + _ => Action::None, + } + } else { + Action::None + } + } +} + +impl SidebarWidget { + /// Handle keyboard events in interactive structure mode. + fn handle_structure_event(&mut self, event: &Event) -> Action { + // If filter mode is active, intercept all key events for the filter + if self.structure_filter_active { + if let Event::Key(key) = event { + match key { + KeyEvent { + key: Key::Escape, + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + // Escape: close filter mode (keep the filter applied) + self.structure_filter_active = false; + return Action::None; + } + KeyEvent { + key: Key::Enter, + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + // Enter: close filter and enter directory if selected + self.structure_filter_active = false; + if let Some(entry) = + self.get_filtered_entries().get(self.structure_selected) + { + if entry.is_dir { + let new_path = self.structure_cwd.join(&entry.name); + self.structure_nav_stack + .push((self.structure_cwd.clone(), self.structure_selected)); + self.structure_cwd = new_path; + self.structure_selected = 0; + self.structure_scroll = 0; + self.structure_filter.clear(); + self.refresh_structure_listing(); + } + } + return Action::None; + } + KeyEvent { + key: Key::Backspace, + .. + } => { + if self.structure_filter.pop().is_none() + && !self.structure_filter.is_empty() + { + // nothing + } + self.structure_selected = 0; + self.structure_scroll = 0; + return Action::None; + } + KeyEvent { + key: Key::Char(ch), + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.structure_filter.push(*ch); + self.structure_selected = 0; + self.structure_scroll = 0; + return Action::None; + } + // Let Up/Down through for navigating filtered results + KeyEvent { + key: Key::Up, + modifiers, + } if !modifiers.alt && !modifiers.ctrl => { + if self.structure_selected > 0 { + self.structure_selected -= 1; + } + self.ensure_selected_visible(); + return Action::None; + } + KeyEvent { + key: Key::Down, + modifiers, + } if !modifiers.alt && !modifiers.ctrl => { + let filtered = self.get_filtered_entries(); + if !filtered.is_empty() && self.structure_selected < filtered.len() - 1 { + self.structure_selected += 1; + } + self.ensure_selected_visible(); + return Action::None; + } + _ => { + // Other keys are ignored during filter mode + return Action::None; + } + } + } + return Action::None; + } + + if let Event::Key(key) = event { + match key { + // Up arrow: move selection up + KeyEvent { + key: Key::Up, + modifiers, + } if !modifiers.alt && !modifiers.ctrl => { + if self.structure_selected > 0 { + self.structure_selected -= 1; + } + self.ensure_selected_visible(); + Action::None + } + // Down arrow: move selection down + KeyEvent { + key: Key::Down, + modifiers, + } if !modifiers.alt && !modifiers.ctrl => { + let filtered = self.get_filtered_entries(); + if !filtered.is_empty() && self.structure_selected < filtered.len() - 1 { + self.structure_selected += 1; + } + self.ensure_selected_visible(); + Action::None + } + // Enter: enter directory (or do nothing for files) + KeyEvent { + key: Key::Enter, + modifiers, + } if !modifiers.alt && !modifiers.ctrl => { + let filtered = self.get_filtered_entries(); + if let Some(entry) = filtered.get(self.structure_selected) { + if entry.is_dir { + let new_path = self.structure_cwd.join(&entry.name); + self.structure_nav_stack + .push((self.structure_cwd.clone(), self.structure_selected)); + self.structure_cwd = new_path; + self.structure_selected = 0; + self.structure_scroll = 0; + self.structure_filter.clear(); + self.refresh_structure_listing(); + } + } + Action::None + } + // Escape: go back to parent directory, or exit structure mode at root + KeyEvent { + key: Key::Escape, + modifiers, + } if !modifiers.alt && !modifiers.ctrl => { + if let Some((prev_cwd, prev_selected)) = self.structure_nav_stack.pop() { + self.structure_cwd = prev_cwd; + self.structure_selected = prev_selected; + self.structure_scroll = 0; + self.structure_filter.clear(); + self.refresh_structure_listing(); + Action::None + } else { + // At root — exit structure mode + self.structure_mode = false; + Action::ExitStructureMode + } + } + // `/`: enter filter mode + KeyEvent { + key: Key::Char('/'), + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.structure_filter_active = true; + self.structure_filter.clear(); + self.structure_selected = 0; + self.structure_scroll = 0; + Action::None + } + // `.`: toggle hidden files + KeyEvent { + key: Key::Char('.'), + modifiers, + } if !modifiers.ctrl && !modifiers.alt => { + self.show_hidden = !self.show_hidden; + self.structure_selected = 0; + self.structure_scroll = 0; + self.refresh_structure_listing(); + Action::None + } + // PageUp: scroll up in entries + KeyEvent { + key: Key::PageUp, .. + } => { + let step = 10.min(self.structure_selected); + self.structure_selected -= step; + self.ensure_selected_visible(); + Action::None + } + // PageDown: scroll down in entries + KeyEvent { + key: Key::PageDown, .. + } => { + let filtered = self.get_filtered_entries(); + if !filtered.is_empty() { + let max_sel = filtered.len() - 1; + self.structure_selected = (self.structure_selected + 10).min(max_sel); + self.ensure_selected_visible(); + } + Action::None + } + // Home: jump to first entry + KeyEvent { key: Key::Home, .. } => { + self.structure_selected = 0; + self.ensure_selected_visible(); + Action::None + } + // End: jump to last entry + KeyEvent { key: Key::End, .. } => { + let filtered = self.get_filtered_entries(); + if !filtered.is_empty() { + self.structure_selected = filtered.len() - 1; + self.ensure_selected_visible(); + } + Action::None + } + _ => Action::None, + } + } else { + Action::None + } + } + + /// Get the filtered list of entries based on the current filter query. + fn get_filtered_entries(&self) -> Vec { + if self.structure_filter.is_empty() { + return self.structure_entries.clone(); + } + let filter_lower = self.structure_filter.to_lowercase(); + self.structure_entries + .iter() + .filter(|e| e.name.to_lowercase().contains(&filter_lower)) + .cloned() + .collect() + } + + /// Adjust scroll offset so the selected entry is visible. + fn ensure_selected_visible(&mut self) { + let visible_entry_rows = 12; // conservative estimate + if self.structure_selected < self.structure_scroll { + self.structure_scroll = self.structure_selected; + } else if self.structure_selected >= self.structure_scroll + visible_entry_rows { + self.structure_scroll = self.structure_selected - visible_entry_rows + 1; + } + + // Clamp selected to filtered entries length + let filtered = self.get_filtered_entries(); + if !filtered.is_empty() && self.structure_selected >= filtered.len() { + self.structure_selected = filtered.len() - 1; + } + + // Convert structure_scroll to global scroll_offset. + let items_before_structure = self.count_items_before_structure(); + self.scroll_offset = items_before_structure + self.structure_scroll; + // Clamp — render will also clamp, but keeping it sane here prevents issues + let total = self.content_height(); + let max_scroll = total.saturating_sub(1); // at least 1 item visible + if self.scroll_offset > max_scroll { + self.scroll_offset = max_scroll; + } + } + + /// Count sidebar items before the first StructureEntry. + /// + /// This must match exactly the items pushed in `render()` before the + /// `StructureEntry` items appear. + fn count_items_before_structure(&self) -> usize { + // Header("Project") + let mut count = 1; + if !self.project_name.is_empty() { + count += 1; + } + if !self.project_type.is_empty() { + count += 1; + } + if self.git_branch.is_some() { + count += 1; + } + if !self.build_command.is_empty() { + count += 1; + } + if !self.test_command.is_empty() { + count += 1; + } + count += 1; // spacer before structure + if self.structure_mode { + count += 1; // Header("Structure ─ ..." or "Filter: ...") + } + count + } + + /// Format the current directory as a breadcrumb for display. + fn format_breadcrumb(&self, max_width: usize) -> String { + // Show the path relative to workspace root, or just the last 2 components + let path = &self.structure_cwd; + let rel = path.strip_prefix(&self.workspace_root).unwrap_or(path); + let display = rel.to_string_lossy().to_string(); + if display.is_empty() { + return ".".to_string(); + } + // Truncate if too long + if display.len() > max_width { + let prefix = "…"; + let avail = max_width.saturating_sub(prefix.len()); + if avail > 0 { + let start = display.len().saturating_sub(avail); + // Find char boundary + let mut start = start; + while start < display.len() && !display.is_char_boundary(start) { + start += 1; + } + format!("{}{}", prefix, &display[start..]) + } else { + prefix.to_string() + } + } else { + display + } + } + + fn draw_section_header( + &self, + screen: &mut Screen, + row: u16, + col: u16, + max_width: usize, + title: &str, + ) -> u16 { + let header = format!("┌─ {} ", title); + let header_width = header.chars().count(); + screen.write_str( + row, + col, + &header, + styles::SIDEBAR_BORDER, + styles::SIDEBAR_BG, + Style::bold(), + ); + // Fill remaining space with ─ + let remaining = max_width.saturating_sub(header_width); + if remaining > 0 { + screen.write_str( + row, + col + header_width as u16, + &"─".repeat(remaining), + styles::SIDEBAR_BORDER, + styles::SIDEBAR_BG, + Style::default(), + ); + } + row + 1 + } + + fn draw_labeled_value( + &self, + screen: &mut Screen, + row: u16, + col: u16, + max_width: usize, + label: &str, + value: &str, + value_color: Color, + ) -> u16 { + let label_width = label.chars().count(); + screen.write_str( + row, + col, + label, + Color::Ansi(244), + styles::SIDEBAR_BG, + Style::dim(), + ); + let value_col = col + label_width as u16 + 1; + let available = max_width.saturating_sub(label_width + 1); + let display = if value.chars().count() > available { + format!("{}…", truncate_str(value, available.saturating_sub(1))) + } else { + value.to_string() + }; + screen.write_str( + row, + value_col, + &display, + value_color, + styles::SIDEBAR_BG, + Style::default(), + ); + // Clear remaining cells on this row to prevent stale characters + let end_col = value_col + display.chars().count() as u16; + let right_bound = col + max_width as u16; + for c in end_col..right_bound { + if let Some(cell) = screen.get_mut(row, c) { + cell.char = ' '; + cell.fg = styles::SIDEBAR_FG; + cell.bg = styles::SIDEBAR_BG; + cell.style = Style::default(); + } + } + row + 1 + } +} + +/// Items that make up the sidebar content, used for scroll-aware rendering. +enum SidebarItem { + Header(String), + LabeledValue { + label: String, + value: String, + color: Color, + }, + Entry { + icon: String, + name: String, + is_dir: bool, + }, + StructureEntry { + icon: String, + name: String, + is_dir: bool, + selected: bool, + }, + Skill(String), + Spacer, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sidebar_new() { + let sidebar = SidebarWidget::new(); + assert!(sidebar.visible); + assert!(sidebar.project_name.is_empty()); + assert_eq!(sidebar.scroll_offset, 0); + assert!(!sidebar.structure_mode); + } + + #[test] + fn test_sidebar_scroll() { + let mut sidebar = SidebarWidget::new(); + sidebar.scroll_down(5); + assert_eq!(sidebar.scroll_offset, 5); + sidebar.scroll_up(3); + assert_eq!(sidebar.scroll_offset, 2); + sidebar.scroll_home(); + assert_eq!(sidebar.scroll_offset, 0); + } + + #[test] + fn test_sidebar_render() { + let mut screen = Screen::new(80, 24); + let mut sidebar = SidebarWidget::new(); + sidebar.project_name = "TinyHarness".to_string(); + sidebar.project_type = "Rust".to_string(); + sidebar.build_command = "cargo build".to_string(); + sidebar.structure = vec!["src/ (main.rs)".to_string(), "Cargo.toml".to_string()]; + + let area = Rect::new(60, 1, 20, 22); + sidebar.render(area, &mut screen); + + // Should have rendered content in the sidebar area + assert!(screen.get(1, 60).unwrap().char == '│'); + } + + #[test] + fn test_sidebar_hidden() { + let mut screen = Screen::new(80, 24); + let mut sidebar = SidebarWidget::new(); + sidebar.visible = false; + + let area = Rect::new(60, 1, 20, 22); + sidebar.render(area, &mut screen); + + // Should not have rendered anything + assert_eq!(screen.get(1, 60).unwrap().char, ' '); // default + } + + #[test] + fn test_sidebar_content_height() { + let mut sidebar = SidebarWidget::new(); + sidebar.project_name = "Test".to_string(); + sidebar.project_type = "Rust".to_string(); + sidebar.structure = vec!["a".to_string(), "b".to_string()]; + let height = sidebar.content_height(); + assert!(height > 0); + // header(1) + name(1) + type(1) + spacer(1) + header(1) + 2 entries + spacer(1) = 8 + assert_eq!(height, 8); + } + + #[test] + fn test_sidebar_scroll_render() { + let mut screen = Screen::new(80, 24); + let mut sidebar = SidebarWidget::new(); + sidebar.project_name = "Test".to_string(); + sidebar.project_type = "Rust".to_string(); + sidebar.structure = (0..50).map(|i| format!("file_{}.rs", i)).collect(); + + let area = Rect::new(60, 1, 20, 22); + sidebar.render(area, &mut screen); + // Should render without panic even with many items + + // Scroll down and re-render + sidebar.scroll_down(5); + sidebar.render(area, &mut screen); + assert_eq!(sidebar.scroll_offset, 5); + } + + #[test] + fn test_sidebar_structure_mode() { + let mut sidebar = SidebarWidget::new(); + assert!(!sidebar.is_structure_mode()); + sidebar.enter_structure_mode(); + assert!(sidebar.is_structure_mode()); + sidebar.exit_structure_mode(); + assert!(!sidebar.is_structure_mode()); + } + + #[test] + fn test_sidebar_structure_navigation() { + let mut sidebar = SidebarWidget::new(); + sidebar.enter_structure_mode(); + // Verify entries were loaded + // (depends on the actual filesystem at test time, so just check no panic) + let entries = sidebar.structure_entries.len(); + // Navigate within entries + if entries > 1 { + sidebar.structure_selected = 0; + // Simulate down arrow + let event = Event::Key(KeyEvent { + key: Key::Down, + modifiers: crate::tui::event::Modifiers::new(), + }); + sidebar.handle_event(&event); + assert_eq!(sidebar.structure_selected, 1); + // Simulate up arrow + let event = Event::Key(KeyEvent { + key: Key::Up, + modifiers: crate::tui::event::Modifiers::new(), + }); + sidebar.handle_event(&event); + assert_eq!(sidebar.structure_selected, 0); + } + } + + #[test] + fn test_read_dir_sorted() { + // Test that read_dir_sorted doesn't panic on current directory + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let entries = read_dir_sorted(&cwd, false); + // Just verify it returns something (or empty if no access) + // Entries should be sorted: dirs first, then files + let mut last_was_dir = true; + for entry in &entries { + if !last_was_dir && entry.is_dir { + // This shouldn't happen — dirs should come first + panic!("Directories should come before files in sorted listing"); + } + last_was_dir = entry.is_dir; + } + } + + #[test] + fn test_format_breadcrumb() { + let sidebar = SidebarWidget::new(); + // Test with workspace root == cwd (should show ".") + let breadcrumb = sidebar.format_breadcrumb(30); + // At least it shouldn't panic + assert!(!breadcrumb.is_empty() || breadcrumb == "."); + } + + // ── File filter tests ────────────────────────────────────────────── + + #[test] + fn test_file_filter_basic() { + let mut sidebar = SidebarWidget::new(); + sidebar.enter_structure_mode(); + // Add some entries manually + sidebar.structure_entries = vec![ + DirEntry { + name: "Cargo.toml".to_string(), + is_dir: false, + }, + DirEntry { + name: "src".to_string(), + is_dir: true, + }, + DirEntry { + name: "README.md".to_string(), + is_dir: false, + }, + DirEntry { + name: "tests".to_string(), + is_dir: true, + }, + ]; + // Filter by "cargo" + sidebar.structure_filter = "cargo".to_string(); + let filtered = sidebar.get_filtered_entries(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].name, "Cargo.toml"); + } + + #[test] + fn test_file_filter_case_insensitive() { + let mut sidebar = SidebarWidget::new(); + sidebar.structure_entries = vec![ + DirEntry { + name: "Cargo.toml".to_string(), + is_dir: false, + }, + DirEntry { + name: "README.md".to_string(), + is_dir: false, + }, + ]; + sidebar.structure_filter = "CARGO".to_string(); + let filtered = sidebar.get_filtered_entries(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].name, "Cargo.toml"); + } + + #[test] + fn test_file_filter_empty_shows_all() { + let mut sidebar = SidebarWidget::new(); + sidebar.structure_entries = vec![ + DirEntry { + name: "a.rs".to_string(), + is_dir: false, + }, + DirEntry { + name: "b.rs".to_string(), + is_dir: false, + }, + ]; + sidebar.structure_filter = String::new(); + let filtered = sidebar.get_filtered_entries(); + assert_eq!(filtered.len(), 2); + } + + #[test] + fn test_file_filter_no_matches() { + let mut sidebar = SidebarWidget::new(); + sidebar.structure_entries = vec![DirEntry { + name: "a.rs".to_string(), + is_dir: false, + }]; + sidebar.structure_filter = "zzz".to_string(); + let filtered = sidebar.get_filtered_entries(); + assert!(filtered.is_empty()); + } + + #[test] + fn test_file_filter_slash_enters_filter_mode() { + let mut sidebar = SidebarWidget::new(); + sidebar.enter_structure_mode(); + assert!(!sidebar.structure_filter_active); + let event = Event::Key(KeyEvent { + key: Key::Char('/'), + modifiers: crate::tui::event::Modifiers::new(), + }); + sidebar.handle_event(&event); + assert!(sidebar.structure_filter_active); + assert!(sidebar.structure_filter.is_empty()); + } + + #[test] + fn test_file_filter_escape_closes_filter() { + let mut sidebar = SidebarWidget::new(); + sidebar.enter_structure_mode(); + sidebar.structure_filter_active = true; + sidebar.structure_filter = "test".to_string(); + let event = Event::Key(KeyEvent { + key: Key::Escape, + modifiers: crate::tui::event::Modifiers::new(), + }); + sidebar.handle_event(&event); + assert!(!sidebar.structure_filter_active); + // Filter text is kept (so results remain visible) + assert_eq!(sidebar.structure_filter, "test"); + } + + #[test] + fn test_toggle_hidden_files() { + let mut sidebar = SidebarWidget::new(); + assert!(!sidebar.show_hidden); + sidebar.enter_structure_mode(); + let event = Event::Key(KeyEvent { + key: Key::Char('.'), + modifiers: crate::tui::event::Modifiers::new(), + }); + sidebar.handle_event(&event); + assert!(sidebar.show_hidden); + sidebar.handle_event(&event); + assert!(!sidebar.show_hidden); + } + + #[test] + fn test_filter_type_chars() { + let mut sidebar = SidebarWidget::new(); + sidebar.enter_structure_mode(); + sidebar.structure_filter_active = true; + let event = Event::Key(KeyEvent { + key: Key::Char('r'), + modifiers: crate::tui::event::Modifiers::new(), + }); + sidebar.handle_event(&event); + assert_eq!(sidebar.structure_filter, "r"); + let event2 = Event::Key(KeyEvent { + key: Key::Char('s'), + modifiers: crate::tui::event::Modifiers::new(), + }); + sidebar.handle_event(&event2); + assert_eq!(sidebar.structure_filter, "rs"); + } + + #[test] + fn test_filter_backspace() { + let mut sidebar = SidebarWidget::new(); + sidebar.enter_structure_mode(); + sidebar.structure_filter_active = true; + sidebar.structure_filter = "abc".to_string(); + let event = Event::Key(KeyEvent { + key: Key::Backspace, + modifiers: crate::tui::event::Modifiers::new(), + }); + sidebar.handle_event(&event); + assert_eq!(sidebar.structure_filter, "ab"); + } +} diff --git a/tinyharness-ui/src/tui/widgets/spinner.rs b/tinyharness-ui/src/tui/widgets/spinner.rs new file mode 100644 index 0000000..225765d --- /dev/null +++ b/tinyharness-ui/src/tui/widgets/spinner.rs @@ -0,0 +1,178 @@ +// ── Spinner widget ────────────────────────────────────────────────────────── +// +// Animated spinner for streaming responses. + +use crate::tui::cell::{Color, Style}; +use crate::tui::layout::Rect; +use crate::tui::screen::Screen; +use crate::tui::widget::Widget; + +use crate::style::SPINNER_FRAMES; + +/// A simple animated spinner shown during streaming responses. +pub struct SpinnerWidget { + /// Current frame index. + frame: usize, + /// Label text (e.g., "Thinking", "Processing"). + label: String, + /// Whether the spinner is active (visible). + active: bool, +} + +impl SpinnerWidget { + pub fn new(label: &str) -> Self { + Self { + frame: 0, + label: label.to_string(), + active: false, + } + } + + /// Advance the spinner to the next frame. + pub fn tick(&mut self) { + if self.active { + self.frame = (self.frame + 1) % SPINNER_FRAMES.len(); + } + } + + /// Start the spinner. + pub fn start(&mut self) { + self.active = true; + self.frame = 0; + } + + /// Stop the spinner. + pub fn stop(&mut self) { + self.active = false; + } + + /// Update the label text. + pub fn set_label(&mut self, label: &str) { + self.label = label.to_string(); + } +} + +impl Widget for SpinnerWidget { + fn render(&mut self, area: Rect, screen: &mut Screen) { + if !self.active || area.is_empty() { + return; + } + + let row = area.y; + let col = area.x; + let max_col = area.x + area.width; // exclusive bound — clip here + + if let Some(frame) = SPINNER_FRAMES.get(self.frame) { + // Draw spinner character + if col < max_col { + screen.write_str( + row, + col, + frame, + Color::ORANGE, + Color::Default, + Style::default(), + ); + } + + // Draw label (clipped to area width) + let label_col = col + 2; + if label_col < max_col { + let available = (max_col - label_col) as usize; + let label = format!("{}…", self.label); + let clipped = if label.len() > available { + // Truncate to fit within the area, preserving char boundaries + let mut end = available; + while end > 0 && !label.is_char_boundary(end) { + end -= 1; + } + &label[..end] + } else { + label.as_str() + }; + screen.write_str( + row, + label_col, + clipped, + Color::Ansi(8), + Color::Default, + Style::dim(), + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_spinner_new() { + let spinner = SpinnerWidget::new("Thinking"); + assert!(!spinner.active); + assert_eq!(spinner.label, "Thinking"); + } + + #[test] + fn test_spinner_start_stop() { + let mut spinner = SpinnerWidget::new("Thinking"); + assert!(!spinner.active); + + spinner.start(); + assert!(spinner.active); + + spinner.stop(); + assert!(!spinner.active); + } + + #[test] + fn test_spinner_tick() { + let mut spinner = SpinnerWidget::new("Thinking"); + spinner.start(); + assert_eq!(spinner.frame, 0); + + spinner.tick(); + assert_eq!(spinner.frame, 1); + + // Wrap around + for _ in 0..9 { + spinner.tick(); + } + assert_eq!(spinner.frame, 0); // Wrapped to start + } + + #[test] + fn test_spinner_tick_not_active() { + let mut spinner = SpinnerWidget::new("Thinking"); + // Not active — tick should not advance + spinner.tick(); + assert_eq!(spinner.frame, 0); + } + + #[test] + fn test_spinner_render() { + let mut screen = Screen::new(80, 24); + let mut spinner = SpinnerWidget::new("Thinking"); + spinner.start(); + + let area = Rect::new(0, 0, 20, 1); + spinner.render(area, &mut screen); + + // Should have rendered the spinner character + assert_ne!(screen.get(0, 0).unwrap().char, ' '); + } + + #[test] + fn test_spinner_render_not_active() { + let mut screen = Screen::new(80, 24); + let mut spinner = SpinnerWidget::new("Thinking"); + // Not active — should render nothing + + let area = Rect::new(0, 0, 20, 1); + spinner.render(area, &mut screen); + + // Should not have rendered anything + assert_eq!(screen.get(0, 0).unwrap().char, ' '); + } +} diff --git a/tinyharness-ui/src/tui/widgets/status_bar.rs b/tinyharness-ui/src/tui/widgets/status_bar.rs new file mode 100644 index 0000000..dd21b18 --- /dev/null +++ b/tinyharness-ui/src/tui/widgets/status_bar.rs @@ -0,0 +1,340 @@ +// ── Status bar widget ─────────────────────────────────────────────────────── +// +// Displays mode, model name, token count, session name, and message count +// at the top of the TUI screen. + +use crate::tui::cell::{Cell, Color, Style}; +use crate::tui::layout::Rect; +use crate::tui::screen::Screen; +use crate::tui::widget::{Widget, styles}; + +/// The status bar at the top of the screen. +/// +/// Shows: mode label | model name | token count | message count | session name +/// +/// Example (not valid Rust — for illustration only): +/// +/// ```text +/// [agent] TinyHarness | llama3.1:8b | 12.4k/128k (9.6%) | 42 msgs | 3 files | session-name +/// ``` +pub struct StatusBarWidget { + pub mode_label: String, + pub mode_color: Color, + pub model_name: String, + pub token_count: Option<(u32, u32)>, // (used, total) + pub message_count: usize, + pub pinned_file_count: usize, + pub session_name: String, + pub is_streaming: bool, + /// Label for the currently focused widget (e.g., "input", "chat", "sidebar", "files"). + pub focus_label: String, +} + +impl StatusBarWidget { + pub fn new(mode_label: &str, model_name: &str) -> Self { + let mode_color = match mode_label { + "casual" => styles::MODE_CASUAL_FG, + "planning" => styles::MODE_PLANNING_FG, + "agent" => styles::MODE_AGENT_FG, + "research" => styles::MODE_RESEARCH_FG, + _ => Color::WHITE, + }; + + Self { + mode_label: mode_label.to_string(), + mode_color, + model_name: model_name.to_string(), + token_count: None, + message_count: 0, + pinned_file_count: 0, + session_name: String::from("unnamed"), + is_streaming: false, + focus_label: String::from("input"), + } + } + + /// Update the mode label and model name. + pub fn update_labels(&mut self, mode_label: &str, model_name: &str) { + self.mode_label = mode_label.to_string(); + self.mode_color = match mode_label { + "casual" => styles::MODE_CASUAL_FG, + "planning" => styles::MODE_PLANNING_FG, + "agent" => styles::MODE_AGENT_FG, + "research" => styles::MODE_RESEARCH_FG, + _ => Color::WHITE, + }; + self.model_name = model_name.to_string(); + } + + /// Set the session name. + pub fn set_session_name(&mut self, name: &str) { + self.session_name = name.to_string(); + } + + /// Set the message count. + pub fn set_message_count(&mut self, count: usize) { + self.message_count = count; + } + + /// Set the token count (used, total). + pub fn set_token_count(&mut self, used: u64, total: Option) { + self.token_count = total.map(|t| (used as u32, t as u32)); + } + + /// Set whether the assistant is currently streaming. + pub fn set_streaming(&mut self, streaming: bool) { + self.is_streaming = streaming; + } + + /// Set the focus indicator label (e.g., "input", "chat", "files"). + pub fn set_focus_label(&mut self, label: &str) { + self.focus_label = label.to_string(); + } + + /// Format token count for display. + fn format_tokens(&self) -> String { + match self.token_count { + Some((used, total)) => { + let used_str = if used >= 1000 { + format!("{:.1}k", used as f64 / 1000.0) + } else { + used.to_string() + }; + let total_str = if total >= 1000 { + format!("{:.0}k", total as f64 / 1000.0) + } else { + total.to_string() + }; + let pct = if total > 0 { + format!("{:.0}%", (used as f64 / total as f64) * 100.0) + } else { + "?%".to_string() + }; + format!("{used_str}/{total_str} ({pct})") + } + None => "? tokens".to_string(), + } + } +} + +impl Widget for StatusBarWidget { + fn render(&mut self, area: Rect, screen: &mut Screen) { + if area.is_empty() || area.height < 1 { + return; + } + + // Fill the status bar background + screen.fill_rect( + area, + Cell { + char: ' ', + fg: styles::STATUS_BAR_FG, + bg: styles::STATUS_BAR_BG, + style: Style::default(), + }, + ); + + // Build the status line content + let row = area.y; + + // Mode label with color + let mode_text = format!(" {} ", self.mode_label); + screen.write_str( + row, + area.x, + &mode_text, + self.mode_color, + styles::STATUS_BAR_BG, + Style::bold(), + ); + + // Separator + let mut col = area.x + mode_text.len() as u16; + screen.write_str( + row, + col, + " │ ", + Color::Ansi(240), + styles::STATUS_BAR_BG, + Style::default(), + ); + col += 3; + + // Model name + let model_text = &self.model_name; + screen.write_str( + row, + col, + model_text, + Color::WHITE, + styles::STATUS_BAR_BG, + Style::default(), + ); + col += model_text.len() as u16; + + // Separator + screen.write_str( + row, + col, + " │ ", + Color::Ansi(240), + styles::STATUS_BAR_BG, + Style::default(), + ); + col += 3; + + // Token count + let token_text = self.format_tokens(); + let token_color = match self.token_count { + Some((used, total)) if total > 0 => { + let pct = used as f64 / total as f64; + if pct > 0.9 { + Color::RED + } else if pct > 0.7 { + Color::YELLOW + } else { + Color::GREEN + } + } + _ => Color::GRAY, + }; + screen.write_str( + row, + col, + &token_text, + token_color, + styles::STATUS_BAR_BG, + Style::default(), + ); + col += token_text.len() as u16; + + // Separator + screen.write_str( + row, + col, + " │ ", + Color::Ansi(240), + styles::STATUS_BAR_BG, + Style::default(), + ); + col += 3; + + // Message count + let msg_text = format!("{} msgs", self.message_count); + screen.write_str( + row, + col, + &msg_text, + Color::WHITE, + styles::STATUS_BAR_BG, + Style::dim(), + ); + col += msg_text.len() as u16; + + // Pinned files + if self.pinned_file_count > 0 { + screen.write_str( + row, + col, + " │ ", + Color::Ansi(240), + styles::STATUS_BAR_BG, + Style::default(), + ); + col += 3; + let file_text = format!("{} files", self.pinned_file_count); + screen.write_str( + row, + col, + &file_text, + Color::WHITE, + styles::STATUS_BAR_BG, + Style::dim(), + ); + col += file_text.len() as u16; + } + + // Focus indicator (right of left-section, before session name) + let focus_text = format!(" ▸ {} ", self.focus_label); + let focus_color = Color::Ansi(178); // warm amber to stand out + // Place it just before the session name if there's room + let session_text = format!(" {} ", self.session_name); + let session_start = area.x + area.width.saturating_sub(session_text.len() as u16); + let focus_start = session_start.saturating_sub(focus_text.len() as u16); + if focus_start > col { + screen.write_str( + row, + focus_start, + &focus_text, + focus_color, + styles::STATUS_BAR_BG, + Style::bold(), + ); + } + + // Session name (right-aligned) + if session_start > col { + screen.write_str( + row, + session_start, + &session_text, + Color::Ansi(240), + styles::STATUS_BAR_BG, + Style::default(), + ); + } + + // Streaming indicator + if self.is_streaming && col + 4 < area.x + area.width { + screen.write_str( + row, + col, + " ⋯", + Color::ORANGE, + styles::STATUS_BAR_BG, + Style::default(), + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_status_bar_render() { + let mut screen = Screen::new(80, 24); + let mut widget = StatusBarWidget::new("agent", "llama3.1:8b"); + let area = Rect::new(0, 0, 80, 1); + widget.render(area, &mut screen); + + // Should have content in the first row + assert_ne!(screen.get(0, 0).unwrap().char, '\0'); + } + + #[test] + fn test_format_tokens() { + let widget = StatusBarWidget::new("agent", "llama3.1:8b"); + assert_eq!(widget.format_tokens(), "? tokens"); + + let mut widget = StatusBarWidget::new("agent", "llama3.1:8b"); + widget.token_count = Some((12000, 128000)); + let tokens = widget.format_tokens(); + // 12000/128000 = 9.375%, which rounds to 9% + assert!(tokens.contains("%") && tokens.contains("12.0k") && tokens.contains("128k")); + } + + #[test] + fn test_status_bar_with_tokens() { + let mut screen = Screen::new(80, 24); + let mut widget = StatusBarWidget::new("agent", "llama3.1:8b"); + widget.token_count = Some((5000, 128000)); + widget.message_count = 42; + let area = Rect::new(0, 0, 80, 1); + widget.render(area, &mut screen); + + // Should have rendered content + assert_ne!(screen.get(0, 0).unwrap().char, '\0'); + } +} diff --git a/tinyharness-ui/src/tui/widgets/tool_output.rs b/tinyharness-ui/src/tui/widgets/tool_output.rs new file mode 100644 index 0000000..417babe --- /dev/null +++ b/tinyharness-ui/src/tui/widgets/tool_output.rs @@ -0,0 +1,583 @@ +// ── Tool output widget ──────────────────────────────────────────────────────── +// +// Displays tool call results in a collapsible pane. Tool results start +// collapsed (just header line). Click or Enter to expand. + +use crate::tui::cell::{Cell, Color, Style}; +use crate::tui::event::{Event, Key, KeyEvent, Modifiers, MouseEvent}; +use crate::tui::layout::Rect; +use crate::tui::screen::Screen; +use crate::tui::widget::{Action, Widget, truncate_str}; + +/// Status of a tool call. +#[derive(Clone, Debug, PartialEq)] +pub enum ToolStatus { + /// The tool is currently running. + Running, + /// The tool completed successfully (with duration in ms). + Success { duration_ms: u64 }, + /// The tool failed with an error message. + Error { message: String }, +} + +/// A single tool result entry. +#[derive(Clone, Debug)] +pub struct ToolResult { + /// Tool name (e.g., "read", "run", "write"). + pub name: String, + /// Brief summary of arguments (e.g., "src/main.rs:42-58"). + pub args_summary: String, + /// The output content. + pub content: String, + /// Whether the result is an error. + pub is_error: bool, + /// Whether the result is collapsed. + pub collapsed: bool, + /// Tool execution status. + pub status: ToolStatus, +} + +/// Collapsible tool result display. +/// +/// Shows a list of tool results, each of which can be expanded +/// or collapsed. This is used inside the conversation widget or +/// as a standalone pane. +pub struct ToolOutputWidget { + /// Tool results to display. + results: Vec, + /// Currently selected/expanded result index. + selected: Option, + /// Scroll offset for the content area. + scroll_offset: usize, +} + +impl ToolOutputWidget { + pub fn new() -> Self { + Self { + results: Vec::new(), + selected: None, + scroll_offset: 0, + } + } + + /// Add a tool result. + pub fn push(&mut self, result: ToolResult) { + self.results.push(result); + } + + /// Clear all results. + pub fn clear(&mut self) { + self.results.clear(); + self.selected = None; + self.scroll_offset = 0; + } + + /// Get the number of results. + pub fn len(&self) -> usize { + self.results.len() + } + + /// Check if there are no results. + pub fn is_empty(&self) -> bool { + self.results.is_empty() + } + + /// Toggle collapse/expand for the given result index. + pub fn toggle(&mut self, index: usize) { + if index < self.results.len() { + self.results[index].collapsed = !self.results[index].collapsed; + if !self.results[index].collapsed { + self.selected = Some(index); + self.scroll_offset = 0; + } + } + } + + /// Uncollapse all results (expand everything for viewing). + pub fn un_collapse_all(&mut self) { + for result in &mut self.results { + result.collapsed = false; + } + } + + /// Render a collapsed result (single line header). + fn render_collapsed(&self, result: &ToolResult, row: u16, screen: &mut Screen, width: u16) { + let status_icon = match &result.status { + ToolStatus::Running => "⟳", + ToolStatus::Success { .. } => "✓", + ToolStatus::Error { .. } => "✗", + }; + + let status_color = match &result.status { + ToolStatus::Running => Color::YELLOW, + ToolStatus::Success { .. } => Color::GREEN, + ToolStatus::Error { .. } => Color::RED, + }; + + let duration_str = match &result.status { + ToolStatus::Success { duration_ms } => format!(" {}ms", duration_ms), + _ => String::new(), + }; + + // Format: " ✓ Tool: read src/main.rs:42-58 120ms" + let header = if result.args_summary.is_empty() { + format!(" {} Tool: {}", status_icon, result.name) + } else { + format!( + " {} Tool: {} {}", + status_icon, result.name, result.args_summary + ) + }; + + let header_with_dur = format!("{}{}", header, duration_str); + + screen.write_str( + row, + 0, + &header_with_dur, + status_color, + Color::Default, + Style::bold(), + ); + + // Show content preview (first line, truncated) + if !result.content.is_empty() { + let preview_col = (header_with_dur.len() as u16 + 2).min(width.saturating_sub(20)); + let available = (width as usize).saturating_sub(preview_col as usize); + if available > 10 { + let first_line = result.content.lines().next().unwrap_or(""); + let preview = if first_line.len() > available.saturating_sub(3) { + format!("{}…", truncate_str(first_line, available.saturating_sub(3))) + } else { + first_line.to_string() + }; + screen.write_str( + row, + preview_col, + &preview, + Color::Ansi(244), + Color::Default, + Style::dim(), + ); + } + } + + // Show expand hint at the far right + let hint_col = width.saturating_sub(3); + screen.write_str( + row, + hint_col, + " ▶", + Color::Ansi(240), + Color::Default, + Style::dim(), + ); + } + + /// Render an expanded result (header + content). + fn render_expanded( + &self, + result: &ToolResult, + row: u16, + screen: &mut Screen, + width: u16, + max_rows: u16, + ) -> u16 { + let status_icon = match &result.status { + ToolStatus::Running => "⟳", + ToolStatus::Success { .. } => "✓", + ToolStatus::Error { .. } => "✗", + }; + + let status_color = match &result.status { + ToolStatus::Running => Color::YELLOW, + ToolStatus::Success { .. } => Color::GREEN, + ToolStatus::Error { .. } => Color::RED, + }; + + // Header line + let header = if result.args_summary.is_empty() { + format!(" {} Tool: {}", status_icon, result.name) + } else { + format!( + " {} Tool: {} {}", + status_icon, result.name, result.args_summary + ) + }; + screen.write_str(row, 0, &header, status_color, Color::Default, Style::bold()); + + // Collapse hint at far right + let hint_col = width.saturating_sub(3); + if row < row + max_rows { + screen.write_str( + row, + hint_col, + " ▼", + Color::Ansi(240), + Color::Default, + Style::dim(), + ); + } + + // Content lines + let content_color = if result.is_error { + Color::RED + } else { + Color::Ansi(252) + }; + let content_bg = if result.is_error { + Color::Ansi(52) // dark red bg + } else { + Color::Default + }; + + let lines: Vec<&str> = result.content.lines().collect(); + let mut current_row = row + 1; + + for (i, line) in lines.iter().enumerate() { + if i < self.scroll_offset { + continue; + } + if current_row >= row + max_rows { + break; + } + + // Draw content with background and left border + screen.write_str( + current_row, + 0, + " │", + Color::Ansi(240), + content_bg, + Style::default(), + ); + + let available = (width as usize).saturating_sub(4); + let display = if line.len() > available { + format!("{}…", truncate_str(line, available.saturating_sub(1))) + } else { + line.to_string() + }; + screen.write_str( + current_row, + 3, + &display, + content_color, + content_bg, + Style::default(), + ); + + // Fill the rest of the line with background (only for errors) + if result.is_error { + let end_col = 3 + display.len() as u16; + if end_col < width { + for c in end_col..width { + if let Some(cell) = screen.get_mut(current_row, c) { + cell.bg = content_bg; + } + } + } + } + + current_row += 1; + } + + // Bottom border of expanded section + if current_row < row + max_rows { + screen.write_str( + current_row, + 0, + " └", + Color::Ansi(240), + Color::Default, + Style::default(), + ); + screen.hline( + current_row, + 3, + width.saturating_sub(4), + '─', + Color::Ansi(240), + Color::Default, + ); + current_row += 1; + } + + // Lines used + current_row - row + } +} + +impl Widget for ToolOutputWidget { + fn render(&mut self, area: Rect, screen: &mut Screen) { + if area.is_empty() || self.results.is_empty() { + return; + } + + // Clear the area + screen.fill_rect(area, Cell::default()); + + let mut row = area.y; + let width = area.width; + + for (i, result) in self.results.iter().enumerate() { + if row >= area.y + area.height { + break; + } + + if result.collapsed { + self.render_collapsed(result, row, screen, width); + row += 1; + } else { + let remaining = area.y + area.height - row; + let lines_used = self.render_expanded(result, row, screen, width, remaining); + row += lines_used; + } + + // Add a small gap between results (if space allows) + if i < self.results.len() - 1 && row < area.y + area.height { + // Just leave a blank line + row += 1; + } + } + } + + fn handle_event(&mut self, event: &Event) -> Action { + match event { + Event::Key(KeyEvent { + key: Key::Enter, + modifiers: + Modifiers { + ctrl: false, + alt: false, + shift: false, + }, + }) => { + // Toggle the selected result + if let Some(idx) = self.selected { + self.toggle(idx); + } else if !self.results.is_empty() { + self.toggle(0); + } + Action::None + } + Event::Key(KeyEvent { + key: Key::Up, + modifiers: Modifiers { alt: true, .. }, + }) => { + // Navigate up in the result list + if let Some(idx) = self.selected { + if idx > 0 { + self.selected = Some(idx - 1); + } + } else if !self.results.is_empty() { + self.selected = Some(self.results.len() - 1); + } + Action::None + } + Event::Key(KeyEvent { + key: Key::Down, + modifiers: Modifiers { alt: true, .. }, + }) => { + // Navigate down in the result list + if let Some(idx) = self.selected { + if idx + 1 < self.results.len() { + self.selected = Some(idx + 1); + } + } else if !self.results.is_empty() { + self.selected = Some(0); + } + Action::None + } + Event::Mouse(MouseEvent::Press { row, .. }) => { + // Click to toggle the result at this row + // Simple heuristic: each collapsed result takes 1 row, + // expanded results take variable rows + let mut current_row = 0u16; + for (i, result) in self.results.iter().enumerate() { + if *row == current_row { + self.toggle(i); + self.selected = Some(i); + break; + } + if result.collapsed { + current_row += 1; + } else { + // Approximate: count content lines + header + border + let line_count = result.content.lines().count() as u16; + current_row += line_count + 2; // header + bottom border + } + current_row += 1; // gap + } + Action::None + } + _ => Action::None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tool_output_new() { + let widget = ToolOutputWidget::new(); + assert!(widget.is_empty()); + assert_eq!(widget.len(), 0); + } + + #[test] + fn test_tool_output_push() { + let mut widget = ToolOutputWidget::new(); + widget.push(ToolResult { + name: "read".to_string(), + args_summary: "src/main.rs".to_string(), + content: "fn main() {}".to_string(), + is_error: false, + collapsed: true, + status: ToolStatus::Success { duration_ms: 42 }, + }); + assert_eq!(widget.len(), 1); + } + + #[test] + fn test_tool_output_toggle() { + let mut widget = ToolOutputWidget::new(); + widget.push(ToolResult { + name: "read".to_string(), + args_summary: "src/main.rs".to_string(), + content: "fn main() {}".to_string(), + is_error: false, + collapsed: true, + status: ToolStatus::Success { duration_ms: 42 }, + }); + assert!(widget.results[0].collapsed); + + widget.toggle(0); + assert!(!widget.results[0].collapsed); + + widget.toggle(0); + assert!(widget.results[0].collapsed); + } + + #[test] + fn test_tool_output_clear() { + let mut widget = ToolOutputWidget::new(); + widget.push(ToolResult { + name: "read".to_string(), + args_summary: String::new(), + content: String::new(), + is_error: false, + collapsed: true, + status: ToolStatus::Success { duration_ms: 0 }, + }); + widget.clear(); + assert!(widget.is_empty()); + } + + #[test] + fn test_tool_output_render_collapsed() { + let mut screen = Screen::new(80, 24); + let widget = ToolOutputWidget::new(); + let mut widget = widget; + widget.push(ToolResult { + name: "read".to_string(), + args_summary: "src/main.rs".to_string(), + content: "fn main() {}".to_string(), + is_error: false, + collapsed: true, + status: ToolStatus::Success { duration_ms: 42 }, + }); + + let area = Rect::new(0, 0, 80, 24); + widget.render(area, &mut screen); + + // First cell should have content (the status icon) + assert!(screen.get(0, 0).unwrap().char != '\0'); + } + + #[test] + fn test_tool_output_render_expanded() { + let mut screen = Screen::new(80, 24); + let mut widget = ToolOutputWidget::new(); + widget.push(ToolResult { + name: "read".to_string(), + args_summary: "src/main.rs".to_string(), + content: "fn main() {}\nfn other() {}".to_string(), + is_error: false, + collapsed: false, + status: ToolStatus::Success { duration_ms: 42 }, + }); + + let area = Rect::new(0, 0, 80, 24); + widget.render(area, &mut screen); + + // Should have rendered header and content lines + assert!(screen.get(0, 0).unwrap().char != '\0'); + } + + #[test] + fn test_tool_output_render_error() { + let mut screen = Screen::new(80, 24); + let mut widget = ToolOutputWidget::new(); + widget.push(ToolResult { + name: "run".to_string(), + args_summary: "cargo test".to_string(), + content: "error: test failed".to_string(), + is_error: true, + collapsed: true, + status: ToolStatus::Error { + message: "test failed".to_string(), + }, + }); + + let area = Rect::new(0, 0, 80, 24); + widget.render(area, &mut screen); + + // Should render with error styling + assert!(screen.get(0, 0).unwrap().char != '\0'); + } + + #[test] + fn test_tool_output_keyboard_toggle() { + let mut widget = ToolOutputWidget::new(); + widget.push(ToolResult { + name: "read".to_string(), + args_summary: String::new(), + content: "hello".to_string(), + is_error: false, + collapsed: true, + status: ToolStatus::Success { duration_ms: 0 }, + }); + + // Press Enter to expand + let event = Event::Key(KeyEvent { + key: Key::Enter, + modifiers: Modifiers::new(), + }); + widget.handle_event(&event); + assert!(!widget.results[0].collapsed); + + // Press Enter again to collapse + widget.handle_event(&event); + assert!(widget.results[0].collapsed); + } + + #[test] + fn test_tool_status_equality() { + let s1 = ToolStatus::Success { duration_ms: 100 }; + let s2 = ToolStatus::Success { duration_ms: 100 }; + assert_eq!(s1, s2); + + let e1 = ToolStatus::Error { + message: "fail".to_string(), + }; + let e2 = ToolStatus::Error { + message: "fail".to_string(), + }; + assert_eq!(e1, e2); + + assert_ne!(s1, ToolStatus::Running); + } +} diff --git a/tinyharness-ui/src/ui/diff.rs b/tinyharness-ui/src/ui/diff.rs index 9b85f26..4fc13ea 100644 --- a/tinyharness-ui/src/ui/diff.rs +++ b/tinyharness-ui/src/ui/diff.rs @@ -464,6 +464,250 @@ pub fn show_edit_diff( Ok(()) } +/// Render a [`DiffLine`] sequence into a plain-text string (no ANSI codes), +/// suitable for display in the TUI cell-based renderer. +/// +/// Returns a string with lines prefixed by ` ` (keep), `- ` (remove), or `+ ` (add), +/// and line numbers if requested. +pub fn render_diff_plain( + old_lines: &[&str], + new_lines: &[&str], + diff: &[DiffLine], + show_line_numbers: bool, +) -> String { + if diff.is_empty() { + return String::new(); + } + + let max_line = old_lines.len().max(new_lines.len()).max(1); + let num_width = if show_line_numbers { + max_line.to_string().len().max(2) + } else { + 0 + }; + + let mut result = String::new(); + let mut old_num: usize = 0; + let mut new_num: usize = 0; + + // Find change positions to determine hunks with context + let change_indices: Vec = diff + .iter() + .enumerate() + .filter_map(|(i, l)| matches!(l, DiffLine::Remove(_) | DiffLine::Add(_)).then_some(i)) + .collect(); + + if change_indices.is_empty() { + return result; + } + + // Merge overlapping/adjacent hunks + let mut hunk_ranges: Vec<(usize, usize)> = Vec::new(); + let mut hunk_start = change_indices[0].saturating_sub(DIFF_CONTEXT_LINES); + let mut hunk_end = (change_indices[0] + DIFF_CONTEXT_LINES + 1).min(diff.len()); + + for &idx in &change_indices[1..] { + let ns = idx.saturating_sub(DIFF_CONTEXT_LINES); + let ne = (idx + DIFF_CONTEXT_LINES + 1).min(diff.len()); + if ns <= hunk_end { + hunk_end = hunk_end.max(ne); + } else { + hunk_ranges.push((hunk_start, hunk_end)); + hunk_start = ns; + hunk_end = ne; + } + } + hunk_ranges.push((hunk_start, hunk_end)); + + for (hunk_idx, &(start, end)) in hunk_ranges.iter().enumerate() { + // Separator between hunks + if hunk_idx > 0 { + result.push_str(" ┈\n"); + } + + // Count line numbers up to the start of this hunk + for item in diff.iter().take(start) { + match item { + DiffLine::Keep(_) => { + old_num += 1; + new_num += 1; + } + DiffLine::Remove(_) => { + old_num += 1; + } + DiffLine::Add(_) => { + new_num += 1; + } + } + } + + for i in start..end { + if i >= diff.len() { + break; + } + match &diff[i] { + DiffLine::Keep(line) => { + old_num += 1; + new_num += 1; + if show_line_numbers { + result.push_str(&format!( + " {:>width$} │ {}\n", + old_num, + line, + width = num_width + )); + } else { + result.push_str(&format!(" {}\n", line)); + } + } + DiffLine::Remove(line) => { + old_num += 1; + if show_line_numbers { + result.push_str(&format!( + "- {:>width$} │ {}\n", + old_num, + line, + width = num_width + )); + } else { + result.push_str(&format!("- {}\n", line)); + } + } + DiffLine::Add(line) => { + new_num += 1; + if show_line_numbers { + result.push_str(&format!( + "+ {:>width$} │ {}\n", + new_num, + line, + width = num_width + )); + } else { + result.push_str(&format!("+ {}\n", line)); + } + } + } + } + } + + result +} + +/// Compute a unified diff between old and new content and return it as +/// a plain-text string (no ANSI codes). Returns an empty string if the +/// contents are identical. +pub fn compute_edit_diff_plain(old_content: &str, new_content: &str) -> String { + let old_lines: Vec<&str> = old_content.lines().collect(); + let new_lines: Vec<&str> = new_content.lines().collect(); + let diff = compute_diff(&old_lines, &new_lines); + + if diff.iter().all(|l| matches!(l, DiffLine::Keep(_))) { + return String::new(); + } + + render_diff_plain(&old_lines, &new_lines, &diff, true) +} + +/// Compute a diff for a write operation (file creation or modification) and +/// return it as a plain-text string (no ANSI codes). +pub fn compute_write_diff_plain(path: &str, new_content: &str) -> String { + let existing = std::fs::read_to_string(path); + + match existing { + Ok(old_content) => { + let old_lines: Vec<&str> = old_content.lines().collect(); + let new_lines: Vec<&str> = new_content.lines().collect(); + let diff = compute_diff(&old_lines, &new_lines); + + if diff.iter().all(|l| matches!(l, DiffLine::Keep(_))) { + return String::new(); + } + + render_diff_plain(&old_lines, &new_lines, &diff, true) + } + Err(_) => { + // New file — show all lines as additions + let new_lines: Vec<&str> = new_content.lines().collect(); + let num_width = new_lines.len().to_string().len().max(2); + let mut result = String::new(); + for (i, line) in new_lines.iter().enumerate() { + result.push_str(&format!( + "+ {:>width$} │ {}\n", + i + 1, + line, + width = num_width + )); + } + result + } + } +} + +/// Compute a diff for an edit operation and return it as a plain-text string +/// (no ANSI codes). Reads the current file content to locate the edit. +pub fn compute_edit_diff_from_path(path: &str, old_str: &str, new_str: &str) -> String { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return String::new(), + }; + + let lines: Vec<&str> = content.lines().collect(); + + // Find the line number of old_str + let offset = match content.find(old_str) { + Some(o) => o, + None => return String::new(), + }; + let line_number = content[..offset].matches('\n').count(); + + let old_lines: Vec<&str> = old_str.lines().collect(); + let new_lines: Vec<&str> = new_str.lines().collect(); + + // Show context (2 lines before and after) + let before_ctx = 2usize; + let after_ctx = 2usize; + let start_line = line_number.saturating_sub(before_ctx); + let end_line = (line_number + old_lines.len() + after_ctx).min(lines.len()); + let num_width = end_line.to_string().len().max(2); + + let mut result = String::new(); + + // Context lines before + for (i, line) in lines.iter().enumerate().take(line_number).skip(start_line) { + result.push_str(&format!( + " {:>width$} │ {}\n", + i + 1, + line, + width = num_width + )); + } + + // Removed lines (old) + for line in old_lines.iter() { + result.push_str(&format!("- │ {}\n", line)); + } + + // Added lines (new) + for line in new_lines.iter() { + result.push_str(&format!("+ │ {}\n", line)); + } + + // Context lines after + let after_start = line_number + old_lines.len(); + for i in after_start..end_line { + if i < lines.len() { + result.push_str(&format!( + " {:>width$} │ {}\n", + i + 1, + lines[i], + width = num_width + )); + } + } + + result +} + #[cfg(test)] mod tests { use super::*; diff --git a/tinyharness-ui/src/ui/input.rs b/tinyharness-ui/src/ui/input.rs index 1276080..fc9ea60 100644 --- a/tinyharness-ui/src/ui/input.rs +++ b/tinyharness-ui/src/ui/input.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use rustyline::{ Completer, Helper, Highlighter, Hinter, completion::Completer, @@ -8,70 +10,6 @@ use rustyline::{ use crate::style::*; -/// All known command names (primary + aliases), used for completion and hints. -/// This must be kept in sync with the command registry in `commands/mod.rs`. -const COMMAND_NAMES: &[&str] = &[ - "/add", - "/agent", - "/apikey", - "/audit", - "/autoaccept", - "/casual", - "/clear", - "/command", - "/compact", - "/context", - "/contextlimit", - "/drop", - "/dropall", - "/exit", - "/files", - "/help", - "/init", - "/mode", - "/model", - "/plan", - "/quit", - "/refresh", - "/rename", - "/retries", - "/research", - "/session", - "/sessions", - "/settings", - "/showthink", - "/skill", - "/skills", - "/think", - "/timeout", - "/unload", - "/use", -]; - -/// Subcommand completions for commands that take arguments. -fn subcommand_completions(cmd: &str) -> Vec<&'static str> { - match cmd { - "/command" => vec![ - "add", - "deny", - "help", - "list", - "rm", - "reset", - "resetdeny", - "undeny", - ], - "/session" => vec!["delete"], - "/mode" => vec!["agent", "casual", "planning", "research"], - "/settings" => vec!["all"], - "/autoaccept" => vec!["off", "on"], - "/apikey" => vec!["clear"], - "/showthink" => vec!["off", "on"], - "/think" => vec!["high", "low", "medium", "off"], - _ => vec![], - } -} - #[derive(Completer, Helper, Highlighter, Hinter)] pub struct CommandHelper { #[rustyline(Completer)] @@ -112,23 +50,50 @@ impl Validator for CommandHelper { } } -impl Default for CommandHelper { - fn default() -> Self { +impl CommandHelper { + /// Create a `CommandHelper` with no command data (no completions or hints). + pub fn new() -> Self { Self { - completer: CommandCompleter, - hinter: CommandHinter, + completer: CommandCompleter { + command_names: Vec::new(), + subcommands: HashMap::new(), + }, + hinter: CommandHinter { + command_names: Vec::new(), + subcommands: HashMap::new(), + }, highlighter: CommandHighlighter, } } -} -impl CommandHelper { - pub fn new() -> Self { - Self::default() + /// Create a `CommandHelper` populated with command names and subcommand + /// completions, typically sourced from the binary's `CommandRegistry`. + /// + /// - `command_names`: all slash-command names (primary + aliases), e.g. `"/help"`, `"/quit"`. + /// - `subcommands`: mapping from command name to its argument completions, + /// e.g. `"/mode" → ["agent", "casual", "planning", "research"]`. + pub fn with_commands( + command_names: Vec, + subcommands: HashMap>, + ) -> Self { + Self { + completer: CommandCompleter { + command_names: command_names.clone(), + subcommands: subcommands.clone(), + }, + hinter: CommandHinter { + command_names, + subcommands, + }, + highlighter: CommandHighlighter, + } } } -pub struct CommandCompleter; +pub struct CommandCompleter { + command_names: Vec, + subcommands: HashMap>, +} impl Completer for CommandCompleter { type Candidate = String; @@ -149,7 +114,11 @@ impl Completer for CommandCompleter { if let Some(space_pos) = prefix.find(' ') { let cmd = &prefix[..space_pos].to_lowercase(); let sub_prefix = prefix[space_pos + 1..].trim_start().to_lowercase(); - let subs = subcommand_completions(cmd); + let subs = self + .subcommands + .get(cmd) + .map(|s| s.as_slice()) + .unwrap_or(&[]); if !subs.is_empty() { let matches: Vec = subs @@ -170,11 +139,12 @@ impl Completer for CommandCompleter { // Top-level command completion let cmd_prefix = prefix.to_lowercase(); - let matches: Vec = COMMAND_NAMES + let matches: Vec = self + .command_names .iter() - .filter(|name| name.starts_with(&cmd_prefix)) + .filter(|name| name.to_lowercase().starts_with(&cmd_prefix)) .take(3) - .map(|s| s.to_string()) + .cloned() .collect(); if matches.is_empty() { @@ -185,7 +155,10 @@ impl Completer for CommandCompleter { } } -pub struct CommandHinter; +pub struct CommandHinter { + command_names: Vec, + subcommands: HashMap>, +} impl Hinter for CommandHinter { type Hint = String; @@ -199,10 +172,14 @@ impl Hinter for CommandHinter { if let Some(space_pos) = line.find(' ') { let cmd = &line[..space_pos].to_lowercase(); let sub_prefix = line[space_pos + 1..].trim_start().to_lowercase(); - let subs = subcommand_completions(cmd); + let subs = self + .subcommands + .get(cmd) + .map(|s| s.as_slice()) + .unwrap_or(&[]); if !subs.is_empty() { - let matches: Vec<&&str> = subs + let matches: Vec<&String> = subs .iter() .filter(|s| s.starts_with(&sub_prefix)) .take(5) @@ -223,18 +200,19 @@ impl Hinter for CommandHinter { } // Multiple matches or partial match — show options - let suggestions: Vec<&str> = matches.iter().map(|s| **s).collect(); + let suggestions: Vec<&str> = matches.iter().map(|s| s.as_str()).collect(); return Some(format!(" ({})", suggestions.join(" | "))); } } // Top-level command hinting let prefix = line.to_lowercase(); - let matches: Vec<&str> = COMMAND_NAMES + let matches: Vec<&str> = self + .command_names .iter() - .filter(|name| name.starts_with(&prefix)) + .filter(|name| name.to_lowercase().starts_with(&prefix)) .take(3) - .copied() + .map(|s| s.as_str()) .collect(); if matches.is_empty() {