Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 22 additions & 13 deletions crates/goat-tui/src/app/keys.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
Expand All @@ -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)
}

Expand Down Expand Up @@ -96,6 +100,7 @@ impl App {
}
}

#[allow(clippy::too_many_lines)]
pub(crate) fn on_normal_key(&mut self, key: KeyEvent) -> Vec<Op> {
match key.code {
KeyCode::PageUp => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -471,19 +488,11 @@ impl App {

pub(crate) fn on_ctrl_c(&mut self) -> Vec<Op> {
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()
Expand Down
43 changes: 41 additions & 2 deletions crates/goat-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -78,6 +79,7 @@ pub struct App {
pub(crate) next_task: u64,
pub(crate) spinner: usize,
pub(crate) quit_arm: Option<u16>,
pub(crate) clear_arm: Option<u16>,
pub(crate) should_quit: bool,
pub(crate) dirty: bool,
pub(crate) scroll: u16,
Expand Down Expand Up @@ -115,6 +117,7 @@ impl App {
next_task: 1,
spinner: 0,
quit_arm: None,
clear_arm: None,
should_quit: false,
dirty: true,
scroll: 0,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
Expand All @@ -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());
Expand Down
3 changes: 2 additions & 1 deletion crates/goat-tui/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
1 change: 1 addition & 0 deletions crates/goat-tui/src/symbols.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "⇥";
Expand Down
65 changes: 59 additions & 6 deletions crates/goat-tui/src/transcript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};

use crate::{highlight::Highlighter, markdown, symbols, theme::Theme};

pub(crate) struct Working {
pub elapsed: Option<u64>,
pub label: Option<String>,
}

type LineCache = RefCell<Option<(u16, u64, usize, Vec<Line<'static>>)>>;

#[derive(Debug)]
Expand Down Expand Up @@ -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()));
}
}

Expand Down Expand Up @@ -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));
Expand All @@ -199,7 +206,7 @@ impl Transcript {
.sum::<u16>()
};
self.cached_height.set(Some((width, h)));
h
h.saturating_add(u16::from(busy))
}

pub fn render(
Expand All @@ -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 })
Expand All @@ -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,
Expand Down Expand Up @@ -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]
Expand All @@ -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"
);
}
}
Loading
Loading