From 2655b98775b2011fe0972c41ac47287cfafc8c72 Mon Sep 17 00:00:00 2001 From: jbj338033 Date: Tue, 9 Jun 2026 22:57:54 +0900 Subject: [PATCH] feat: esc interrupt, ctrl-c clear/quit, working status inline in transcript --- crates/goat-tui/src/app/keys.rs | 35 ++++++++++------- crates/goat-tui/src/app/mod.rs | 43 +++++++++++++++++++- crates/goat-tui/src/command.rs | 3 +- crates/goat-tui/src/symbols.rs | 1 + crates/goat-tui/src/transcript.rs | 65 ++++++++++++++++++++++++++++--- crates/goat-tui/src/view.rs | 49 ++++++++++++----------- 6 files changed, 152 insertions(+), 44 deletions(-) diff --git a/crates/goat-tui/src/app/keys.rs b/crates/goat-tui/src/app/keys.rs index d4f2d02..7501bad 100644 --- a/crates/goat-tui/src/app/keys.rs +++ b/crates/goat-tui/src/app/keys.rs @@ -1,7 +1,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use goat_protocol::Op; -use super::{App, Overlay, QUIT_ARM_TICKS}; +use super::{App, CLEAR_ARM_TICKS, Overlay, QUIT_ARM_TICKS}; use crate::{ config::ConfigOutcome, keymap, @@ -31,6 +31,7 @@ impl App { return self.on_ctrl_c(); } self.quit_arm = None; + self.clear_arm = None; match ch { 'a' => { self.dirty |= self.composer.move_home(); @@ -48,6 +49,9 @@ impl App { return Vec::new(); } self.quit_arm = None; + if !matches!(key.code, KeyCode::Esc) { + self.clear_arm = None; + } self.on_normal_key(key) } @@ -96,6 +100,7 @@ impl App { } } + #[allow(clippy::too_many_lines)] pub(crate) fn on_normal_key(&mut self, key: KeyEvent) -> Vec { match key.code { KeyCode::PageUp => { @@ -181,9 +186,21 @@ impl App { Vec::new() } KeyCode::Esc => { - self.overlay = Overlay::None; - self.composer.clear(); self.dirty = true; + if let Some(id) = self.active { + self.clear_arm = None; + return vec![Op::Interrupt { id }]; + } + self.overlay = Overlay::None; + if self.composer.is_empty() { + self.clear_arm = None; + return Vec::new(); + } + if self.clear_arm.take().is_some() { + self.composer.clear(); + } else { + self.clear_arm = Some(CLEAR_ARM_TICKS); + } Vec::new() } KeyCode::Char(c) => { @@ -471,19 +488,11 @@ impl App { pub(crate) fn on_ctrl_c(&mut self) -> Vec { self.dirty = true; - if matches!(self.overlay, Overlay::Ask(..)) { - self.overlay = Overlay::None; - if let Some(id) = self.active { - return vec![Op::Interrupt { id }]; - } - return Vec::new(); - } - if let Some(id) = self.active { - return vec![Op::Interrupt { id }]; - } + self.clear_arm = None; if self.quit_arm.is_some() { self.should_quit = true; } else { + self.composer.clear(); self.quit_arm = Some(QUIT_ARM_TICKS); } Vec::new() diff --git a/crates/goat-tui/src/app/mod.rs b/crates/goat-tui/src/app/mod.rs index a1b2961..aaaa8e0 100644 --- a/crates/goat-tui/src/app/mod.rs +++ b/crates/goat-tui/src/app/mod.rs @@ -59,6 +59,7 @@ pub(crate) enum Overlay { const TICK: Duration = Duration::from_millis(120); const QUIT_ARM_TICKS: u16 = 25; +const CLEAR_ARM_TICKS: u16 = 25; pub(crate) enum AppEvent { Input(CtEvent), @@ -78,6 +79,7 @@ pub struct App { pub(crate) next_task: u64, pub(crate) spinner: usize, pub(crate) quit_arm: Option, + pub(crate) clear_arm: Option, pub(crate) should_quit: bool, pub(crate) dirty: bool, pub(crate) scroll: u16, @@ -115,6 +117,7 @@ impl App { next_task: 1, spinner: 0, quit_arm: None, + clear_arm: None, should_quit: false, dirty: true, scroll: 0, @@ -151,6 +154,13 @@ impl App { self.dirty = true; } } + if let Some(ticks) = &mut self.clear_arm { + *ticks = ticks.saturating_sub(1); + if *ticks == 0 { + self.clear_arm = None; + self.dirty = true; + } + } if crate::toast::tick(&mut self.toasts) { self.dirty = true; } @@ -485,12 +495,16 @@ impl App { pub(crate) fn quit_armed(&self) -> bool { self.quit_arm.is_some() } + pub(crate) fn clear_armed(&self) -> bool { + self.clear_arm.is_some() + } pub(crate) fn spinner_frame(&self) -> &'static str { symbols::SPINNER[self.spinner % symbols::SPINNER.len()] } pub(crate) fn content_height(&self, width: u16) -> u16 { - self.active_transcript().content_height(width, self.theme) + self.active_transcript() + .content_height(width, self.theme, self.is_busy()) } pub(crate) fn scroll(&self) -> u16 { self.scroll @@ -706,10 +720,23 @@ mod tests { app.composer.insert_str("hi"); let started = app.submit(); assert!(matches!(started.as_slice(), [Op::SubmitMessage { .. }])); - let ops = app.on_ctrl_c(); + let ops = app.on_key(press(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(ops.as_slice(), [Op::Interrupt { .. }])); } + #[test] + fn ctrl_c_while_active_arms_quit_not_interrupt() { + let mut app = App::new(Theme::dark()); + app.composer.insert_str("hi"); + app.submit(); + let ops = app.on_ctrl_c(); + assert!( + ops.is_empty(), + "Ctrl+C during active task must not interrupt" + ); + assert!(app.quit_armed()); + } + #[test] fn ctrl_c_when_idle_arms_then_quits() { let mut app = App::new(Theme::dark()); @@ -721,6 +748,18 @@ mod tests { assert!(app.should_quit); } + #[test] + fn esc_idle_arms_then_clears() { + let mut app = App::new(Theme::dark()); + app.composer.insert_str("hello"); + app.on_key(press(KeyCode::Esc, KeyModifiers::NONE)); + assert!(app.clear_armed(), "first Esc must arm clear"); + assert!(!app.composer.is_empty(), "composer must not be cleared yet"); + app.on_key(press(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!app.clear_armed(), "second Esc must disarm"); + assert!(app.composer.is_empty(), "second Esc must clear composer"); + } + #[test] fn ctrl_c_dubeolsik_arms_then_quits() { let mut app = App::new(Theme::dark()); diff --git a/crates/goat-tui/src/command.rs b/crates/goat-tui/src/command.rs index 38bdc44..e60a8ae 100644 --- a/crates/goat-tui/src/command.rs +++ b/crates/goat-tui/src/command.rs @@ -173,7 +173,8 @@ pub fn help_text(registry: &CommandRegistry) -> String { out.push_str("\nKeybindings:\n"); out.push_str(" Enter send message\n"); out.push_str(" Shift/Alt+Enter newline\n"); - out.push_str(" Ctrl-C interrupt / quit\n"); + out.push_str(" Esc interrupt / clear input (x2)\n"); + out.push_str(" Ctrl-C clear input / quit (x2)\n"); out.push_str(" Ctrl-A/E line start/end\n"); out.push_str(" Ctrl-W delete word before\n"); let _ = writeln!( diff --git a/crates/goat-tui/src/symbols.rs b/crates/goat-tui/src/symbols.rs index 9344ad9..171bd42 100644 --- a/crates/goat-tui/src/symbols.rs +++ b/crates/goat-tui/src/symbols.rs @@ -29,6 +29,7 @@ pub mod ui { pub mod key { pub const CTRL: &str = "⌃"; + pub const ESC: &str = "⎋"; pub const SHIFT: &str = "⇧"; pub const ENTER: &str = "↵"; pub const TAB: &str = "⇥"; diff --git a/crates/goat-tui/src/transcript.rs b/crates/goat-tui/src/transcript.rs index ea9b2e1..566434d 100644 --- a/crates/goat-tui/src/transcript.rs +++ b/crates/goat-tui/src/transcript.rs @@ -12,6 +12,11 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::{highlight::Highlighter, markdown, symbols, theme::Theme}; +pub(crate) struct Working { + pub elapsed: Option, + pub label: Option, +} + type LineCache = RefCell>)>>; #[derive(Debug)] @@ -132,6 +137,8 @@ impl Transcript { }; self.items .push(Item::Agent(markdown::render(&text, theme, hl))); + } else if interrupted { + self.items.push(Item::Notice("interrupted".into())); } } @@ -173,11 +180,11 @@ impl Transcript { } } - pub fn content_height(&self, width: u16, theme: Theme) -> u16 { + pub fn content_height(&self, width: u16, theme: Theme, busy: bool) -> u16 { if let Some((w, h)) = self.cached_height.get() && w == width { - return h; + return h.saturating_add(u16::from(busy)); } let mut lines = self.get_or_build_static_lines(theme, width, symbols::SPINNER[0]); lines.extend(self.streaming_lines(theme)); @@ -199,7 +206,7 @@ impl Transcript { .sum::() }; self.cached_height.set(Some((width, h))); - h + h.saturating_add(u16::from(busy)) } pub fn render( @@ -209,9 +216,13 @@ impl Transcript { theme: Theme, scroll: u16, spinner: &'static str, + working: Option<&Working>, ) { let mut lines = self.get_or_build_static_lines(theme, area.width, spinner); lines.extend(self.streaming_lines(theme)); + if let Some(w) = working { + lines.push(working_line(theme, spinner, w)); + } frame.render_widget( Paragraph::new(lines) .wrap(Wrap { trim: false }) @@ -221,6 +232,22 @@ impl Transcript { } } +fn working_line(theme: Theme, spinner: &'static str, w: &Working) -> Line<'static> { + let mut spans = vec![Span::styled(spinner, theme.accent()), Span::raw(" ")]; + if let Some(label) = &w.label { + spans.push(Span::styled(label.clone(), theme.muted())); + } else { + spans.push(Span::styled( + format!("Working{}", symbols::ui::ELLIPSIS), + theme.muted(), + )); + if let Some(secs) = w.elapsed { + spans.push(Span::styled(format!(" {secs}s"), theme.muted())); + } + } + Line::from(spans) +} + fn build_static_lines( items: &[Item], theme: Theme, @@ -464,12 +491,16 @@ mod tests { if let Some(Item::Tool { status: ToolStatus::Done(o), .. - }) = t.items.last() + }) = t.items.first() { assert!(!o.ok); } else { panic!("expected failed tool"); } + assert!( + matches!(t.items.last(), Some(Item::Notice(_))), + "interrupt with no stream must append Notice" + ); } #[test] @@ -487,12 +518,34 @@ mod tests { fn content_height_counts_streaming() { let mut t = Transcript::default(); commit(&mut t, "hello world"); - let h1 = t.content_height(80, Theme::dark()); + let h1 = t.content_height(80, Theme::dark(), false); t.push_delta("line one\nline two\nline three\nline four"); - let h2 = t.content_height(80, Theme::dark()); + let h2 = t.content_height(80, Theme::dark(), false); assert!( h2 > h1, "content_height must grow while streaming is active" ); } + + #[test] + fn content_height_includes_working_line() { + let mut t = Transcript::default(); + commit(&mut t, "hello world"); + let h_idle = t.content_height(80, Theme::dark(), false); + let h_busy = t.content_height(80, Theme::dark(), true); + assert!( + h_busy > h_idle, + "content_height must be larger when busy (working line)" + ); + } + + #[test] + fn interrupted_without_stream_pushes_notice() { + let mut t = Transcript::default(); + t.complete(true, &PlainHighlighter, Theme::dark()); + assert!( + matches!(t.items.last(), Some(Item::Notice(_))), + "interrupting with no stream must push a Notice item" + ); + } } diff --git a/crates/goat-tui/src/view.rs b/crates/goat-tui/src/view.rs index 65588ee..bc50b1e 100644 --- a/crates/goat-tui/src/view.rs +++ b/crates/goat-tui/src/view.rs @@ -9,6 +9,7 @@ use crate::{ app::{App, Overlay}, overlay, symbols, theme::Theme, + transcript::Working, }; #[allow(clippy::too_many_lines)] @@ -178,8 +179,18 @@ fn render_transcript(frame: &mut Frame, area: Rect, app: &mut App, theme: Theme) height: area.height, }; app.clamp_scroll(content.height, content.width); - app.transcript() - .render(frame, content, theme, app.scroll(), app.spinner_frame()); + let working = app.is_busy().then(|| Working { + elapsed: app.elapsed_secs(), + label: app.agent_status(), + }); + app.transcript().render( + frame, + content, + theme, + app.scroll(), + app.spinner_frame(), + working.as_ref(), + ); if app.follow() { return; } @@ -276,33 +287,27 @@ fn render_header(frame: &mut Frame, area: Rect, app: &App, theme: Theme) { fn render_footer(frame: &mut Frame, area: Rect, app: &App, theme: Theme) { let sep = symbols::ui::SEPARATOR; let line = if app.is_busy() { - let mut spans = vec![Span::styled( - format!(" {} ", app.spinner_frame()), - theme.accent(), - )]; - if let Some(status) = app.agent_status() { - spans.push(Span::styled(status, theme.muted())); + if app.quit_armed() { + Line::from(vec![ + Span::styled(format!(" {}c", symbols::key::CTRL), theme.key()), + Span::styled(" again to quit", theme.muted()), + ]) } else { - spans.push(Span::styled( - format!("Working{}", symbols::ui::ELLIPSIS), - theme.muted(), - )); - if let Some(secs) = app.elapsed_secs() { - spans.push(Span::styled(format!(" {secs}s"), theme.muted())); - } + Line::from(vec![ + Span::styled(format!(" {}", symbols::key::ESC), theme.key()), + Span::styled(" interrupt", theme.muted()), + ]) } - spans.push(Span::styled(sep, theme.muted())); - spans.push(Span::styled( - format!("{}c", symbols::key::CTRL), - theme.key(), - )); - spans.push(Span::styled(" interrupt", theme.muted())); - Line::from(spans) } else if app.quit_armed() { Line::from(vec![ Span::styled(format!(" {}c", symbols::key::CTRL), theme.key()), Span::styled(" again to quit", theme.muted()), ]) + } else if app.clear_armed() { + Line::from(vec![ + Span::styled(format!(" {}", symbols::key::ESC), theme.key()), + Span::styled(" again to clear", theme.muted()), + ]) } else { let mut spans = vec![ Span::styled(format!(" {}", symbols::key::SHIFT_ENTER), theme.key()),