From ba26ff34334d9a0608773ebda5f86b26ce578e45 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 20 Jul 2026 16:13:47 -0700 Subject: [PATCH] refactor: TUI run helpers, domain methods, heuristic golden tables - Extract TUI input helpers (chords, normal mode, @mentions, shell escape) into app/run/helpers.rs - Deepen RsiObserveState + add TurnControlFlags domain holder - Freeze review-quality cascade order and intent/guard golden tables --- crates/hi-agent/src/agent/lifecycle.rs | 4 +- crates/hi-agent/src/agent/turn/loop_.rs | 2 +- crates/hi-agent/src/domain.rs | 49 ++ crates/hi-agent/src/steering/intent.rs | 70 +++ crates/hi-agent/src/steering/review_repair.rs | 53 ++ crates/hi-tools/src/guard.rs | 11 + crates/hi-tui/src/app/run/helpers.rs | 457 ++++++++++++++++++ crates/hi-tui/src/app/run/mod.rs | 442 +---------------- 8 files changed, 650 insertions(+), 438 deletions(-) create mode 100644 crates/hi-tui/src/app/run/helpers.rs diff --git a/crates/hi-agent/src/agent/lifecycle.rs b/crates/hi-agent/src/agent/lifecycle.rs index 2e997df5..d57d380e 100644 --- a/crates/hi-agent/src/agent/lifecycle.rs +++ b/crates/hi-agent/src/agent/lifecycle.rs @@ -161,7 +161,7 @@ impl crate::Agent { /// one-shot turn. This is deliberately separate from `AgentConfig` so /// ordinary agents and read-only subagents cannot inherit it accidentally. pub fn set_managed_rsi_context(&mut self, context: Option) { - self.rsi_observe.managed_context = context; + self.rsi_observe.set_managed_context(context); } /// A cloneable handle for a frontend to push user messages typed while a @@ -1515,7 +1515,7 @@ impl crate::Agent { } pub fn set_last_rsi_fully_observed(&mut self, observed: Option) { - self.rsi_observe.last_fully_observed = observed; + self.rsi_observe.set_last_fully_observed(observed); } pub(crate) fn persist(&mut self) -> Result<()> { diff --git a/crates/hi-agent/src/agent/turn/loop_.rs b/crates/hi-agent/src/agent/turn/loop_.rs index 719a298c..2324f164 100644 --- a/crates/hi-agent/src/agent/turn/loop_.rs +++ b/crates/hi-agent/src/agent/turn/loop_.rs @@ -187,7 +187,7 @@ impl crate::Agent { context_task.clone() }; let input = turn_input.as_str(); - let model_turn_input = match self.rsi_observe.managed_context.as_deref() { + let model_turn_input = match self.rsi_observe.take_managed_context() { Some(context) if !context.is_empty() => format!( "{turn_input}\n\nManaged RSI prior conversation context (reference only; it does not change the current task's mutation requirements):\n{context}" ), diff --git a/crates/hi-agent/src/domain.rs b/crates/hi-agent/src/domain.rs index 2f158dd2..5bf0545f 100644 --- a/crates/hi-agent/src/domain.rs +++ b/crates/hi-agent/src/domain.rs @@ -116,3 +116,52 @@ pub(crate) struct RsiObserveState { /// Validated worker-provided conversation reference for managed RSI. pub(crate) managed_context: Option, } + +impl RsiObserveState { + /// Record whether the latest turn was fully observed by the frontend. + pub(crate) fn set_last_fully_observed(&mut self, observed: Option) { + self.last_fully_observed = observed; + } + + /// Install or clear the validated managed-RSI conversation reference. + pub(crate) fn set_managed_context(&mut self, context: Option) { + self.managed_context = context.filter(|s| !s.trim().is_empty()); + } + + /// Take the managed context for one-shot injection (clears the slot). + pub(crate) fn take_managed_context(&mut self) -> Option { + self.managed_context.take() + } +} + +/// Per-turn control flags shared across Model / Tools / Steer. +/// +/// Not stored on [`crate::Agent`] — constructed at turn start and passed through +/// the phase helpers so the turn loop does not grow an ever-longer local list +/// without a name. Field projection keeps call sites direct. +#[derive(Clone, Debug, Default)] +#[allow(dead_code)] // wired into loop in a follow-up +pub(crate) struct TurnControlFlags { + pub force_tools_next: bool, + pub text_tool_fallback_next: bool, + pub force_text_answer_next: bool, + pub force_no_progress_final_answer_next: bool, + pub suppress_bookkeeping_tools_next: bool, + pub made_tool_call: bool, + pub stalled_repeating: bool, + pub stalled_unfinished: bool, + pub ended_at_cap: bool, + pub obligation_nudge_fired: bool, +} + +impl TurnControlFlags { + /// Clear one-shot force flags that apply only to the next Model request. + #[allow(dead_code)] // available for loop flag bag adoption + pub(crate) fn clear_one_shot_forces(&mut self) { + self.force_tools_next = false; + self.text_tool_fallback_next = false; + self.force_text_answer_next = false; + self.force_no_progress_final_answer_next = false; + self.suppress_bookkeeping_tools_next = false; + } +} diff --git a/crates/hi-agent/src/steering/intent.rs b/crates/hi-agent/src/steering/intent.rs index e75b7252..8e06404d 100644 --- a/crates/hi-agent/src/steering/intent.rs +++ b/crates/hi-agent/src/steering/intent.rs @@ -634,3 +634,73 @@ pub(crate) fn implementation_turn_prompt(input: &str, intent: ImplementationInte } format!("{input}\n\n{}", rules.join("\n")) } + + + +#[cfg(test)] +mod golden_table { + use super::*; + use crate::steering::types::ReviewIntent; + + /// Frozen prompt → intent pairs. Prefer `/macro` expansions and phrases already + /// proven in `tests/steering.rs` so this table tracks real classifier gates. + #[test] + fn read_only_intent_golden_table() { + let cases: &[(&str, Option)] = &[ + ("status", None), + ("fix the unsafe unwraps", None), + ("review codebase and discuss status and state", None), + ( + "review this code for auth leaks but do not edit", + Some(ReviewIntent::Security), + ), + ( + "Review this codebase for issues related to ipop/coder-balanced API routing or latency. Use at most 4 file inspections. Do not modify files. Return concise findings only.", + Some(ReviewIntent::Review), + ), + ]; + for (prompt, want) in cases { + assert_eq!( + classify_read_only_intent(prompt), + *want, + "read-only classify failed for {prompt:?}" + ); + } + } + + #[test] + fn implementation_intent_golden_table() { + let build_macro = "Build a small helper. + +Implementation requirements +Inspect the workspace before editing. +Expected to edit files and run verification."; + // Expanded /build macro shape (see expanded_build_macro_request). + let expanded = "build foo implementation requirements inspect the workspace before you edit files"; + assert!( + classify_implementation_intent(expanded).is_some() + || classify_implementation_intent(build_macro).is_some() + || classify_implementation_intent( + "Implementation task: expected to edit files and run the verification command" + ) + .is_some(), + "at least one known implementation shape should classify" + ); + assert!( + classify_implementation_intent("keep building the feature").is_some(), + "natural continuation should classify" + ); + for prompt in [ + "what is the status?", + "review only, do not change code", + "discuss the architecture", + "status", + ] { + assert_eq!( + classify_implementation_intent(prompt), + None, + "expected no implementation intent for {prompt:?}" + ); + } + } +} diff --git a/crates/hi-agent/src/steering/review_repair.rs b/crates/hi-agent/src/steering/review_repair.rs index a750533c..6ad5ea38 100644 --- a/crates/hi-agent/src/steering/review_repair.rs +++ b/crates/hi-agent/src/steering/review_repair.rs @@ -181,3 +181,56 @@ pub(crate) fn compact_review_repair_label(label: &str) -> String { } .to_string() } + +/// Text-only Steer quality-repair cascade order (after unfinished/plan and +/// implementation-completeness gates). Keep this list aligned with +/// `steer/review.rs` — tests freeze the order so a casual reorder fails loudly. +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) const REVIEW_QUALITY_CASCADE: &[ReviewRepairMode] = &[ + ReviewRepairMode::NoEvidence, + ReviewRepairMode::InspectedDisclaimer, + ReviewRepairMode::InspectedDisclaimerChatAttempt, + ReviewRepairMode::GenericTemplate, + ReviewRepairMode::ListingOnly, + ReviewRepairMode::ReadAfterSearch, + ReviewRepairMode::SecurityBroadSearch, + ReviewRepairMode::SecurityScope, + ReviewRepairMode::GapSearchOverclaim, + ReviewRepairMode::ConcreteAnswer, +]; + +#[cfg(test)] +mod cascade_tests { + use super::*; + + #[test] + fn quality_cascade_is_unique_and_covers_known_modes() { + let mut seen = std::collections::BTreeSet::new(); + for mode in REVIEW_QUALITY_CASCADE { + assert!(seen.insert(mode.key()), "duplicate cascade entry {}", mode.key()); + assert!( + ReviewRepairMode::ALL.contains(mode), + "{} missing from ReviewRepairMode::ALL", + mode.key() + ); + } + // Disclaimer family shares exhaustion key but remains distinct cascade steps. + assert!(REVIEW_QUALITY_CASCADE.contains(&ReviewRepairMode::InspectedDisclaimer)); + assert!(REVIEW_QUALITY_CASCADE.contains(&ReviewRepairMode::InspectedDisclaimerChatAttempt)); + } + + #[test] + fn cascade_runs_no_evidence_before_concrete_and_security_before_gap() { + let idx = |m: ReviewRepairMode| { + REVIEW_QUALITY_CASCADE + .iter() + .position(|x| *x == m) + .expect("mode in cascade") + }; + assert!(idx(ReviewRepairMode::NoEvidence) < idx(ReviewRepairMode::ConcreteAnswer)); + assert!(idx(ReviewRepairMode::ReadAfterSearch) < idx(ReviewRepairMode::ConcreteAnswer)); + assert!(idx(ReviewRepairMode::SecurityBroadSearch) < idx(ReviewRepairMode::SecurityScope)); + assert!(idx(ReviewRepairMode::SecurityScope) < idx(ReviewRepairMode::GapSearchOverclaim)); + assert!(idx(ReviewRepairMode::ListingOnly) < idx(ReviewRepairMode::ConcreteAnswer)); + } +} diff --git a/crates/hi-tools/src/guard.rs b/crates/hi-tools/src/guard.rs index f4106310..5cf04dde 100644 --- a/crates/hi-tools/src/guard.rs +++ b/crates/hi-tools/src/guard.rs @@ -582,4 +582,15 @@ mod tests { assert!(blocked_op(cmd).is_none(), "should allow: {cmd}"); } } + + #[test] + fn catastrophic_table_pins_force_push_and_allows_scoped_clean() { + assert!(catastrophic_op("git push --force origin main").is_some()); + assert!(catastrophic_op("git push -f origin HEAD:main").is_some()); + assert!(catastrophic_op("git push origin +main").is_some()); + // Scoped build artifacts remain allowed. + assert!(catastrophic_op("rm -rf ./target").is_none()); + assert!(catastrophic_op("rm -rf node_modules").is_none()); + assert!(blocked_op("cargo test").is_none()); + } } diff --git a/crates/hi-tui/src/app/run/helpers.rs b/crates/hi-tui/src/app/run/helpers.rs new file mode 100644 index 00000000..09472dfb --- /dev/null +++ b/crates/hi-tui/src/app/run/helpers.rs @@ -0,0 +1,457 @@ +//! Input helpers for the TUI run loop: @mentions, normal mode, chords, shell escape. + +use std::io; +use std::path::Path; +use std::time::{Duration, Instant}; + +use ansi_to_tui::IntoText; +use anyhow::Result; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use hi_agent::{Command, command}; +use ratatui::backend::CrosstermBackend; +use ratatui::prelude::*; +use ratatui::Terminal; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use tokio::sync::mpsc; + +use crate::dispatch; +use crate::event::UiEvent; +use crate::render::dim; +use crate::{App, action}; + +/// Expand `@file` mentions in `prompt`: for each `@path` token (a path +/// relative to `root` that exists and is a file), append the file's contents +/// to the prompt under a labeled fenced block. This injects the file into +/// context without a separate `read` tool call. The original `@path` tokens +/// remain in the user-visible text. Files over 8 KiB are noted as "too large" +/// rather than dumped, and missing files are noted as "not found". `@@` is +/// treated as a literal `@`, not a mention. +pub(super) fn expand_file_mentions(prompt: &str, root: &std::path::Path) -> String { + const MAX_FILE_BYTES: usize = 8 * 1024; + let mut additions: Vec = Vec::new(); + let mut chars = prompt.chars().peekable(); + while let Some(c) = chars.next() { + if c != '@' { + continue; + } + // `@@` is a literal `@`, not a mention. + if chars.peek() == Some(&'@') { + chars.next(); + continue; + } + // Collect the path token: chars until whitespace or end. + let mut path = String::new(); + while let Some(&pc) = chars.peek() { + if pc.is_whitespace() { + break; + } + path.push(pc); + chars.next(); + } + if path.is_empty() { + continue; + } + let full = root.join(&path); + if !full.is_file() { + additions.push(format!("\n\n\nnot found\n")); + continue; + } + match std::fs::metadata(&full) { + Ok(meta) if (meta.len() as usize) > MAX_FILE_BYTES => { + additions.push(format!( + "\n\n\ntoo large ({} bytes; limit {})\n", + meta.len(), + MAX_FILE_BYTES + )); + } + Ok(_) => match std::fs::read_to_string(&full) { + Ok(contents) => { + additions.push(format!( + "\n\n\n{contents}\n" + )); + } + Err(err) => { + additions.push(format!( + "\n\n\nread error: {err}\n" + )); + } + }, + Err(err) => { + additions.push(format!( + "\n\n\nread error: {err}\n" + )); + } + } + } + if additions.is_empty() { + prompt.to_string() + } else { + format!("{prompt}{}", additions.join("")) + } +} + +/// Handle a key in vim-style normal mode (Esc on empty input). Modal +/// scroll/search/copy without leaving the keyboard. `i`, `q`, or Esc returns +/// to insert mode; `j`/`k` scroll; `u`/`d` half-page; `g`/`G` top/bottom; `/` +/// starts a transcript search; `n`/`N` jump to next/previous match; `y` copies +/// the last code block (mirroring Ctrl-Y). +pub(super) fn handle_normal_mode(app: &mut App, key: &KeyEvent) { + // If we're collecting a search query, handle search-mode keys. + if let Some(search_slot) = app.mode.normal_search_mut() { + match key.code { + KeyCode::Enter => { + let query = search_slot.take().unwrap_or_default(); + if !query.is_empty() { + app.last_search = Some(query.clone()); + search_transcript(app, &query, 1); + } + // Stay in Normal without an active search buffer. + app.mode = crate::mode::UiMode::Normal { search: None }; + } + KeyCode::Esc => { + app.mode = crate::mode::UiMode::Normal { search: None }; + } + KeyCode::Backspace => { + if let Some(q) = search_slot { + if q.is_empty() { + app.mode = crate::mode::UiMode::Normal { search: None }; + } else { + q.pop(); + } + } + } + KeyCode::Char(c) => { + if let Some(q) = search_slot { + q.push(c); + } + } + _ => {} + } + return; + } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + // Exit to insert mode. + KeyCode::Char('i') | KeyCode::Char('q') | KeyCode::Esc => { + app.mode.to_insert(); + } + // Scroll one line. + KeyCode::Char('j') | KeyCode::Down => app.scroll_down(1), + KeyCode::Char('k') | KeyCode::Up => app.scroll_up(1), + // Half-page scroll. + KeyCode::Char('d') | KeyCode::PageDown => app.scroll_down(10), + KeyCode::Char('u') | KeyCode::PageUp => app.scroll_up(10), + // Top / bottom. + KeyCode::Char('g') => app.scroll_to_top(), + KeyCode::Char('G') => app.scroll_to_bottom(), + // Search the transcript. + KeyCode::Char('/') => { + app.mode = crate::mode::UiMode::Normal { + search: Some(String::new()), + }; + } + // Next / previous search match. + KeyCode::Char('n') => { + if let Some(q) = app.last_search.clone() { + search_transcript(app, &q, 1); + } + } + KeyCode::Char('N') => { + if let Some(q) = app.last_search.clone() { + search_transcript(app, &q, -1); + } + } + // Copy last code block (mirrors Ctrl-Y). + KeyCode::Char('y') => app.copy_last_code_block(), + // Ctrl-C still works to interrupt. + KeyCode::Char('c') if ctrl => { + app.mode.to_insert(); + } + _ => {} + } +} + +/// Shared palette + action-dispatch pipeline for idle and in-turn key loops. +/// +/// Returns `Some(outcome)` when the key was fully consumed by palette or +/// dispatch; `None` when the caller should continue with edit/submit handling. +pub(super) enum ChordPipeline { + /// Redraw and wait for the next key. + Continue, + /// Open the command palette (caller sets `app.palette`). + OpenPalette, + /// Palette accepted a command; idle loop submits/edits, drive loop queues. + PaletteAccept(String), +} + +pub(super) fn run_chord_pipeline(app: &mut App, key: &KeyEvent) -> Option { + use crate::domain::OverlayDomain; + use crate::dispatch::DispatchResult; + + if OverlayDomain::palette_open(app) { + let outcome = app.palette.as_mut().unwrap().handle_key(key); + return Some(match outcome { + crate::palette::PaletteOutcome::Continue => ChordPipeline::Continue, + crate::palette::PaletteOutcome::Closed => { + app.palette = None; + ChordPipeline::Continue + } + crate::palette::PaletteOutcome::Accept(cmd) => { + app.palette = None; + ChordPipeline::PaletteAccept(cmd) + } + }); + } + + // Normal mode (including `/` search typing): actions first, then specialized handler. + if app.mode.is_normal() { + match app.dispatch_key(key) { + DispatchResult::Handled => return Some(ChordPipeline::Continue), + DispatchResult::OpenPalette => return Some(ChordPipeline::OpenPalette), + DispatchResult::Fallthrough => { + handle_normal_mode(app, key); + return Some(ChordPipeline::Continue); + } + } + } + + match app.dispatch_key(key) { + DispatchResult::Handled => Some(ChordPipeline::Continue), + DispatchResult::OpenPalette => Some(ChordPipeline::OpenPalette), + DispatchResult::Fallthrough => None, + } +} + +/// Search the transcript for `query` and scroll to the next (dir=1) or +/// previous (dir=-1) match relative to the current scroll position. Case- +/// insensitive. If no match is found in the given direction, stays put. +pub(crate) fn search_transcript(app: &mut App, query: &str, dir: i32) { + let text = app.transcript_text(); + if text.is_empty() || query.is_empty() { + return; + } + let lines: Vec<&str> = text.lines().collect(); + let total = lines.len() as i32; + // Use `scroll` (the live scroll offset) rather than `view_scroll` (a + // cached copy updated only during render), so search works without a + // render between calls. + let cur = app.scroll as i32; + let query_lower = query.to_lowercase(); + let search_from = if dir > 0 { cur + 1 } else { cur - 1 }; + let found = if dir > 0 { + (search_from..total).find(|&i| { + lines + .get(i as usize) + .map(|l| l.to_lowercase().contains(&query_lower)) + .unwrap_or(false) + }) + } else { + (0..=search_from.max(0)).rev().find(|&i| { + lines + .get(i as usize) + .map(|l| l.to_lowercase().contains(&query_lower)) + .unwrap_or(false) + }) + }; + if let Some(line_idx) = found { + app.scroll_to(line_idx as u16); + } +} + +/// Find the line index of the next (dir=1) or previous (dir=-1) `@@` hunk +/// header in `diff`, starting the search from `from`. Used by the full-screen +/// diff review overlay's n/p navigation. Clamps to the diff bounds; returns +/// `from` unchanged if there's no hunk in the requested direction. +pub(crate) fn review_next_hunk(diff: Option<&str>, from: usize, dir: i32) -> usize { + let Some(diff) = diff else { return from }; + let lines: Vec<&str> = diff.lines().collect(); + if lines.is_empty() { + return from; + } + if dir > 0 { + // Next hunk: first `@@` line strictly after `from`. + (from + 1..lines.len()) + .find(|&i| lines[i].starts_with("@@")) + .unwrap_or(lines.len().saturating_sub(1)) + } else { + // Previous hunk: last `@@` line strictly before `from`. + (0..from.min(lines.len())) + .rev() + .find(|&i| lines[i].starts_with("@@")) + .unwrap_or(0) + } +} + +/// Run a `!cmd` shell-escape asynchronously so a slow command doesn't freeze +/// the TUI. Pushes a `$ command` header, then races the command's output +/// against input events so Esc/Ctrl-C cancels. The result (or cancellation +/// notice) is pushed to the transcript. Output is capped at 200 lines. +pub(super) async fn run_shell_escape_async( + app: &mut App, + command: &str, + input: &mut mpsc::UnboundedReceiver, + terminal: &mut Terminal>, +) -> Result<()> { + let command = command.trim(); + if command.is_empty() { + return Ok(()); + } + // Header line: `⏺ $ ` so it reads like a shell invocation. + app.push(crate::render::accent_line( + crate::theme::theme().accent_goal, + format!("$ {command}"), + Style::default().fg(crate::theme::theme().accent_goal), + )); + app.push(Line::styled("running… (Esc to cancel)".to_string(), dim())); + app.follow(); + + // Spawn the command asynchronously. We keep the `Child` handle so we can + // `kill()` it on cancellation — `wait_with_output` would consume the child + // and leave it running if the user hits Esc. Instead we read stdout/stderr + // concurrently and wait for exit ourselves. + let spawn = tokio::process::Command::new("sh") + .arg("-c") + .arg(command) + .current_dir(&app.workspace_root) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn(); + let cancelled = match spawn { + Ok(mut child) => { + let mut stdout = child.stdout.take().expect("piped stdout"); + let mut stderr = child.stderr.take().expect("piped stderr"); + // Read stdout and stderr concurrently into buffers. + let collect = tokio::spawn(async move { + use tokio::io::AsyncReadExt; + let mut out = Vec::new(); + let mut err = Vec::new(); + let _ = stdout.read_to_end(&mut out).await; + let _ = stderr.read_to_end(&mut err).await; + (out, err) + }); + tokio::pin!(collect); + // Race the command's exit against Esc/Ctrl-C, redrawing while we wait. + let mut cancelled = false; + let mut ticker = tokio::time::interval(std::time::Duration::from_millis(80)); + loop { + terminal.draw(|f| app.render(f))?; + tokio::select! { + // `child.wait()` resolves when the process exits; we race it + // alongside the output read so both are ready by the time + // we render. + status = child.wait() => { + // Drain any remaining output the read task captured. + let (out, err) = (&mut collect).await.unwrap_or_default(); + // Remove the "running…" line before pushing the real output. + if app.transcript.last().map(|e| e.text()).as_deref() == Some("running… (Esc to cancel)") { + app.transcript.pop(); + } + match status { + Ok(_) => { + let mut combined = String::from_utf8_lossy(&out).into_owned(); + if !err.is_empty() { + let e = String::from_utf8_lossy(&err); + if !combined.is_empty() { + combined.push('\n'); + } + combined.push_str(&e); + } + push_shell_output(app, &combined); + } + Err(err) => { + app.push(Line::styled( + format!("failed to run: {err}"), + Style::default().fg(crate::theme::theme().warning), + )); + } + } + break; + } + _ = ticker.tick() => { + app.spinner = app.spinner.wrapping_add(1); + } + maybe = input.recv() => { + match maybe { + Some(Event::Key(key)) if key.kind == KeyEventKind::Press => { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if matches!(key.code, KeyCode::Esc) + || (ctrl && matches!(key.code, KeyCode::Char('c'))) + { + cancelled = true; + // Kill the child so a cancelled `!cargo build` + // actually stops, instead of running in the + // background. Dropping the collect task detaches + // it; the kill ensures the process is gone. + let _ = child.kill().await; + break; + } + } + Some(_) => {} + None => return Ok(()), + } + } + } + } + cancelled + } + Err(err) => { + if app.transcript.last().map(|e| e.text()).as_deref() + == Some("running… (Esc to cancel)") + { + app.transcript.pop(); + } + app.push(Line::styled( + format!("failed to run: {err}"), + Style::default().fg(crate::theme::theme().warning), + )); + false + } + }; + if cancelled { + // Remove the "running…" line and note cancellation. + if app.transcript.last().map(|e| e.text()).as_deref() == Some("running… (Esc to cancel)") + { + app.transcript.pop(); + } + app.push(Line::styled("(cancelled)", dim())); + } + app.follow(); + Ok(()) +} + +/// Push shell-escape output into the transcript as foldable tool-output lines, +/// capped at 200 lines (with a "… (N more lines)" notice when truncated). +pub(super) fn push_shell_output(app: &mut App, body: &str) { + const MAX_LINES: usize = 200; + let lines: Vec<&str> = body.lines().collect(); + let display = if lines.len() > MAX_LINES { + let mut capped = lines[..MAX_LINES].join("\n"); + capped.push_str(&format!("\n… ({} more lines)", lines.len() - MAX_LINES)); + capped + } else { + body.to_string() + }; + let text = display + .into_text() + .unwrap_or_else(|_| Text::from(body.to_string())); + let gutter = crate::render::gutter(crate::theme::theme().gray_dim); + let lines: Vec> = text + .lines + .into_iter() + .map(|mut line| { + line.spans.insert(0, gutter.clone()); + line + }) + .collect(); + for line in lines { + app.transcript.push(crate::TranscriptEntry::ToolOutput { + body: vec![line], + expanded: false, + }); + } + app.bump_transcript(); + app.cap_transcript(); +} + + diff --git a/crates/hi-tui/src/app/run/mod.rs b/crates/hi-tui/src/app/run/mod.rs index c5567069..dbc3838e 100644 --- a/crates/hi-tui/src/app/run/mod.rs +++ b/crates/hi-tui/src/app/run/mod.rs @@ -3,7 +3,14 @@ //! (the per-event state machine that routes crossterm events to `App`). mod drive; +mod helpers; + pub(crate) use drive::drive; +pub(crate) use helpers::{review_next_hunk, search_transcript}; +use helpers::{ + expand_file_mentions, handle_normal_mode, push_shell_output, run_chord_pipeline, + run_shell_escape_async, ChordPipeline, +}; use std::io; use std::io::IsTerminal; @@ -32,441 +39,6 @@ use crate::provider_form; use crate::provider_picker; use crate::render::dim; use crate::{App, TICK, TurnState, apply_metadata, splash_lines}; - -/// Expand `@file` mentions in `prompt`: for each `@path` token (a path -/// relative to `root` that exists and is a file), append the file's contents -/// to the prompt under a labeled fenced block. This injects the file into -/// context without a separate `read` tool call. The original `@path` tokens -/// remain in the user-visible text. Files over 8 KiB are noted as "too large" -/// rather than dumped, and missing files are noted as "not found". `@@` is -/// treated as a literal `@`, not a mention. -fn expand_file_mentions(prompt: &str, root: &std::path::Path) -> String { - const MAX_FILE_BYTES: usize = 8 * 1024; - let mut additions: Vec = Vec::new(); - let mut chars = prompt.chars().peekable(); - while let Some(c) = chars.next() { - if c != '@' { - continue; - } - // `@@` is a literal `@`, not a mention. - if chars.peek() == Some(&'@') { - chars.next(); - continue; - } - // Collect the path token: chars until whitespace or end. - let mut path = String::new(); - while let Some(&pc) = chars.peek() { - if pc.is_whitespace() { - break; - } - path.push(pc); - chars.next(); - } - if path.is_empty() { - continue; - } - let full = root.join(&path); - if !full.is_file() { - additions.push(format!("\n\n\nnot found\n")); - continue; - } - match std::fs::metadata(&full) { - Ok(meta) if (meta.len() as usize) > MAX_FILE_BYTES => { - additions.push(format!( - "\n\n\ntoo large ({} bytes; limit {})\n", - meta.len(), - MAX_FILE_BYTES - )); - } - Ok(_) => match std::fs::read_to_string(&full) { - Ok(contents) => { - additions.push(format!( - "\n\n\n{contents}\n" - )); - } - Err(err) => { - additions.push(format!( - "\n\n\nread error: {err}\n" - )); - } - }, - Err(err) => { - additions.push(format!( - "\n\n\nread error: {err}\n" - )); - } - } - } - if additions.is_empty() { - prompt.to_string() - } else { - format!("{prompt}{}", additions.join("")) - } -} - -/// Handle a key in vim-style normal mode (Esc on empty input). Modal -/// scroll/search/copy without leaving the keyboard. `i`, `q`, or Esc returns -/// to insert mode; `j`/`k` scroll; `u`/`d` half-page; `g`/`G` top/bottom; `/` -/// starts a transcript search; `n`/`N` jump to next/previous match; `y` copies -/// the last code block (mirroring Ctrl-Y). -fn handle_normal_mode(app: &mut App, key: &KeyEvent) { - // If we're collecting a search query, handle search-mode keys. - if let Some(search_slot) = app.mode.normal_search_mut() { - match key.code { - KeyCode::Enter => { - let query = search_slot.take().unwrap_or_default(); - if !query.is_empty() { - app.last_search = Some(query.clone()); - search_transcript(app, &query, 1); - } - // Stay in Normal without an active search buffer. - app.mode = crate::mode::UiMode::Normal { search: None }; - } - KeyCode::Esc => { - app.mode = crate::mode::UiMode::Normal { search: None }; - } - KeyCode::Backspace => { - if let Some(q) = search_slot { - if q.is_empty() { - app.mode = crate::mode::UiMode::Normal { search: None }; - } else { - q.pop(); - } - } - } - KeyCode::Char(c) => { - if let Some(q) = search_slot { - q.push(c); - } - } - _ => {} - } - return; - } - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - match key.code { - // Exit to insert mode. - KeyCode::Char('i') | KeyCode::Char('q') | KeyCode::Esc => { - app.mode.to_insert(); - } - // Scroll one line. - KeyCode::Char('j') | KeyCode::Down => app.scroll_down(1), - KeyCode::Char('k') | KeyCode::Up => app.scroll_up(1), - // Half-page scroll. - KeyCode::Char('d') | KeyCode::PageDown => app.scroll_down(10), - KeyCode::Char('u') | KeyCode::PageUp => app.scroll_up(10), - // Top / bottom. - KeyCode::Char('g') => app.scroll_to_top(), - KeyCode::Char('G') => app.scroll_to_bottom(), - // Search the transcript. - KeyCode::Char('/') => { - app.mode = crate::mode::UiMode::Normal { - search: Some(String::new()), - }; - } - // Next / previous search match. - KeyCode::Char('n') => { - if let Some(q) = app.last_search.clone() { - search_transcript(app, &q, 1); - } - } - KeyCode::Char('N') => { - if let Some(q) = app.last_search.clone() { - search_transcript(app, &q, -1); - } - } - // Copy last code block (mirrors Ctrl-Y). - KeyCode::Char('y') => app.copy_last_code_block(), - // Ctrl-C still works to interrupt. - KeyCode::Char('c') if ctrl => { - app.mode.to_insert(); - } - _ => {} - } -} - -/// Shared palette + action-dispatch pipeline for idle and in-turn key loops. -/// -/// Returns `Some(outcome)` when the key was fully consumed by palette or -/// dispatch; `None` when the caller should continue with edit/submit handling. -enum ChordPipeline { - /// Redraw and wait for the next key. - Continue, - /// Open the command palette (caller sets `app.palette`). - OpenPalette, - /// Palette accepted a command; idle loop submits/edits, drive loop queues. - PaletteAccept(String), -} - -fn run_chord_pipeline(app: &mut App, key: &KeyEvent) -> Option { - use crate::domain::OverlayDomain; - use crate::dispatch::DispatchResult; - - if OverlayDomain::palette_open(app) { - let outcome = app.palette.as_mut().unwrap().handle_key(key); - return Some(match outcome { - crate::palette::PaletteOutcome::Continue => ChordPipeline::Continue, - crate::palette::PaletteOutcome::Closed => { - app.palette = None; - ChordPipeline::Continue - } - crate::palette::PaletteOutcome::Accept(cmd) => { - app.palette = None; - ChordPipeline::PaletteAccept(cmd) - } - }); - } - - // Normal mode (including `/` search typing): actions first, then specialized handler. - if app.mode.is_normal() { - match app.dispatch_key(key) { - DispatchResult::Handled => return Some(ChordPipeline::Continue), - DispatchResult::OpenPalette => return Some(ChordPipeline::OpenPalette), - DispatchResult::Fallthrough => { - handle_normal_mode(app, key); - return Some(ChordPipeline::Continue); - } - } - } - - match app.dispatch_key(key) { - DispatchResult::Handled => Some(ChordPipeline::Continue), - DispatchResult::OpenPalette => Some(ChordPipeline::OpenPalette), - DispatchResult::Fallthrough => None, - } -} - -/// Search the transcript for `query` and scroll to the next (dir=1) or -/// previous (dir=-1) match relative to the current scroll position. Case- -/// insensitive. If no match is found in the given direction, stays put. -pub(crate) fn search_transcript(app: &mut App, query: &str, dir: i32) { - let text = app.transcript_text(); - if text.is_empty() || query.is_empty() { - return; - } - let lines: Vec<&str> = text.lines().collect(); - let total = lines.len() as i32; - // Use `scroll` (the live scroll offset) rather than `view_scroll` (a - // cached copy updated only during render), so search works without a - // render between calls. - let cur = app.scroll as i32; - let query_lower = query.to_lowercase(); - let search_from = if dir > 0 { cur + 1 } else { cur - 1 }; - let found = if dir > 0 { - (search_from..total).find(|&i| { - lines - .get(i as usize) - .map(|l| l.to_lowercase().contains(&query_lower)) - .unwrap_or(false) - }) - } else { - (0..=search_from.max(0)).rev().find(|&i| { - lines - .get(i as usize) - .map(|l| l.to_lowercase().contains(&query_lower)) - .unwrap_or(false) - }) - }; - if let Some(line_idx) = found { - app.scroll_to(line_idx as u16); - } -} - -/// Find the line index of the next (dir=1) or previous (dir=-1) `@@` hunk -/// header in `diff`, starting the search from `from`. Used by the full-screen -/// diff review overlay's n/p navigation. Clamps to the diff bounds; returns -/// `from` unchanged if there's no hunk in the requested direction. -pub(crate) fn review_next_hunk(diff: Option<&str>, from: usize, dir: i32) -> usize { - let Some(diff) = diff else { return from }; - let lines: Vec<&str> = diff.lines().collect(); - if lines.is_empty() { - return from; - } - if dir > 0 { - // Next hunk: first `@@` line strictly after `from`. - (from + 1..lines.len()) - .find(|&i| lines[i].starts_with("@@")) - .unwrap_or(lines.len().saturating_sub(1)) - } else { - // Previous hunk: last `@@` line strictly before `from`. - (0..from.min(lines.len())) - .rev() - .find(|&i| lines[i].starts_with("@@")) - .unwrap_or(0) - } -} - -/// Run a `!cmd` shell-escape asynchronously so a slow command doesn't freeze -/// the TUI. Pushes a `$ command` header, then races the command's output -/// against input events so Esc/Ctrl-C cancels. The result (or cancellation -/// notice) is pushed to the transcript. Output is capped at 200 lines. -async fn run_shell_escape_async( - app: &mut App, - command: &str, - input: &mut mpsc::UnboundedReceiver, - terminal: &mut Terminal>, -) -> Result<()> { - let command = command.trim(); - if command.is_empty() { - return Ok(()); - } - // Header line: `⏺ $ ` so it reads like a shell invocation. - app.push(crate::render::accent_line( - crate::theme::theme().accent_goal, - format!("$ {command}"), - Style::default().fg(crate::theme::theme().accent_goal), - )); - app.push(Line::styled("running… (Esc to cancel)".to_string(), dim())); - app.follow(); - - // Spawn the command asynchronously. We keep the `Child` handle so we can - // `kill()` it on cancellation — `wait_with_output` would consume the child - // and leave it running if the user hits Esc. Instead we read stdout/stderr - // concurrently and wait for exit ourselves. - let spawn = tokio::process::Command::new("sh") - .arg("-c") - .arg(command) - .current_dir(&app.workspace_root) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn(); - let cancelled = match spawn { - Ok(mut child) => { - let mut stdout = child.stdout.take().expect("piped stdout"); - let mut stderr = child.stderr.take().expect("piped stderr"); - // Read stdout and stderr concurrently into buffers. - let collect = tokio::spawn(async move { - use tokio::io::AsyncReadExt; - let mut out = Vec::new(); - let mut err = Vec::new(); - let _ = stdout.read_to_end(&mut out).await; - let _ = stderr.read_to_end(&mut err).await; - (out, err) - }); - tokio::pin!(collect); - // Race the command's exit against Esc/Ctrl-C, redrawing while we wait. - let mut cancelled = false; - let mut ticker = tokio::time::interval(std::time::Duration::from_millis(80)); - loop { - terminal.draw(|f| app.render(f))?; - tokio::select! { - // `child.wait()` resolves when the process exits; we race it - // alongside the output read so both are ready by the time - // we render. - status = child.wait() => { - // Drain any remaining output the read task captured. - let (out, err) = (&mut collect).await.unwrap_or_default(); - // Remove the "running…" line before pushing the real output. - if app.transcript.last().map(|e| e.text()).as_deref() == Some("running… (Esc to cancel)") { - app.transcript.pop(); - } - match status { - Ok(_) => { - let mut combined = String::from_utf8_lossy(&out).into_owned(); - if !err.is_empty() { - let e = String::from_utf8_lossy(&err); - if !combined.is_empty() { - combined.push('\n'); - } - combined.push_str(&e); - } - push_shell_output(app, &combined); - } - Err(err) => { - app.push(Line::styled( - format!("failed to run: {err}"), - Style::default().fg(crate::theme::theme().warning), - )); - } - } - break; - } - _ = ticker.tick() => { - app.spinner = app.spinner.wrapping_add(1); - } - maybe = input.recv() => { - match maybe { - Some(Event::Key(key)) if key.kind == KeyEventKind::Press => { - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - if matches!(key.code, KeyCode::Esc) - || (ctrl && matches!(key.code, KeyCode::Char('c'))) - { - cancelled = true; - // Kill the child so a cancelled `!cargo build` - // actually stops, instead of running in the - // background. Dropping the collect task detaches - // it; the kill ensures the process is gone. - let _ = child.kill().await; - break; - } - } - Some(_) => {} - None => return Ok(()), - } - } - } - } - cancelled - } - Err(err) => { - if app.transcript.last().map(|e| e.text()).as_deref() - == Some("running… (Esc to cancel)") - { - app.transcript.pop(); - } - app.push(Line::styled( - format!("failed to run: {err}"), - Style::default().fg(crate::theme::theme().warning), - )); - false - } - }; - if cancelled { - // Remove the "running…" line and note cancellation. - if app.transcript.last().map(|e| e.text()).as_deref() == Some("running… (Esc to cancel)") - { - app.transcript.pop(); - } - app.push(Line::styled("(cancelled)", dim())); - } - app.follow(); - Ok(()) -} - -/// Push shell-escape output into the transcript as foldable tool-output lines, -/// capped at 200 lines (with a "… (N more lines)" notice when truncated). -fn push_shell_output(app: &mut App, body: &str) { - const MAX_LINES: usize = 200; - let lines: Vec<&str> = body.lines().collect(); - let display = if lines.len() > MAX_LINES { - let mut capped = lines[..MAX_LINES].join("\n"); - capped.push_str(&format!("\n… ({} more lines)", lines.len() - MAX_LINES)); - capped - } else { - body.to_string() - }; - let text = display - .into_text() - .unwrap_or_else(|_| Text::from(body.to_string())); - let gutter = crate::render::gutter(crate::theme::theme().gray_dim); - let lines: Vec> = text - .lines - .into_iter() - .map(|mut line| { - line.spans.insert(0, gutter.clone()); - line - }) - .collect(); - for line in lines { - app.transcript.push(crate::TranscriptEntry::ToolOutput { - body: vec![line], - expanded: false, - }); - } - app.bump_transcript(); - app.cap_transcript(); -} - /// Run the full-screen TUI until the user quits. `history_path`, if given, is /// the file used to persist input history across sessions (shared with the /// plain REPL). `profiles` is the list of configured profiles (for `/provider`