From 1ab4cb39ad16786864835f8a619f2beb34c8703b Mon Sep 17 00:00:00 2001 From: jbj338033 Date: Sun, 5 Jul 2026 22:02:17 +0900 Subject: [PATCH] feat: transcript diffs, thinking blocks, error hints, mouse selection --- crates/goat-agent/src/delegate.rs | 2 +- crates/goat-agent/src/lib.rs | 11 +- crates/goat-agent/src/persist.rs | 3 +- crates/goat-agent/src/retry.rs | 36 +++- crates/goat-agent/src/rounds.rs | 14 +- crates/goat-agent/src/threads.rs | 9 +- crates/goat-agent/src/turn.rs | 31 +++- crates/goat-client/src/lib.rs | 6 +- crates/goat-code/src/headless.rs | 1 + crates/goat-protocol/src/event.rs | 2 + crates/goat-protocol/src/types.rs | 3 + crates/goat-tui/src/app/engine.rs | 15 +- crates/goat-tui/src/app/keys.rs | 21 ++- crates/goat-tui/src/app/mod.rs | 183 +++++++++++++++++-- crates/goat-tui/src/help.rs | 3 +- crates/goat-tui/src/lib.rs | 1 + crates/goat-tui/src/overlay.rs | 2 +- crates/goat-tui/src/screenshot.rs | 23 +++ crates/goat-tui/src/select.rs | 192 ++++++++++++++++++++ crates/goat-tui/src/symbols.rs | 2 + crates/goat-tui/src/theme/palette.rs | 55 +++--- crates/goat-tui/src/transcript/item.rs | 9 +- crates/goat-tui/src/transcript/mod.rs | 151 +++++++++++++++- crates/goat-tui/src/transcript/render.rs | 218 ++++++++++++++++++++++- crates/goat-tui/src/usage.rs | 2 +- crates/goat-tui/src/view.rs | 93 +++++++++- 26 files changed, 1004 insertions(+), 84 deletions(-) create mode 100644 crates/goat-tui/src/select.rs diff --git a/crates/goat-agent/src/delegate.rs b/crates/goat-agent/src/delegate.rs index 3b3b590..ccea2c4 100644 --- a/crates/goat-agent/src/delegate.rs +++ b/crates/goat-agent/src/delegate.rs @@ -167,7 +167,7 @@ pub(crate) async fn run_delegation( let result = match outcome { LoopOutcome::Completed => Ok(final_text(conversation.messages())), LoopOutcome::Cancelled => Ok("(agent interrupted)".to_owned()), - LoopOutcome::Failed(message) => Err(message), + LoopOutcome::Failed(message, _) => Err(message), }; let _ = ctx .events diff --git a/crates/goat-agent/src/lib.rs b/crates/goat-agent/src/lib.rs index b2951a5..5ea8b49 100644 --- a/crates/goat-agent/src/lib.rs +++ b/crates/goat-agent/src/lib.rs @@ -1172,10 +1172,14 @@ mod tests { let mut saw_retry = false; let mut error_message = String::new(); + let mut error_hint = String::new(); while let Some(event) = events.recv().await { match event { Event::Retrying { .. } => saw_retry = true, - Event::Error { message, .. } => error_message = message, + Event::Error { message, hint, .. } => { + error_message = message; + error_hint = hint.unwrap_or_default(); + } Event::TaskDone { interrupted, .. } => { assert!(interrupted); break; @@ -1185,10 +1189,11 @@ mod tests { } assert!(!saw_retry, "auth failures must not retry"); assert!( - error_message.contains("/config to re-login"), + error_message.contains("authentication failed"), "{error_message}" ); - assert!(error_message.contains("progress saved"), "{error_message}"); + assert!(error_hint.contains("/config to re-login"), "{error_hint}"); + assert!(error_hint.contains("progress saved"), "{error_hint}"); assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); } diff --git a/crates/goat-agent/src/persist.rs b/crates/goat-agent/src/persist.rs index 1a65092..0070fbe 100644 --- a/crates/goat-agent/src/persist.rs +++ b/crates/goat-agent/src/persist.rs @@ -271,12 +271,13 @@ pub(crate) async fn finalize_turn(ctx: &Ctx<'_>, id: TaskId, outcome: &TurnEnd, }) .await; } - TurnEnd::Failed(message) => { + TurnEnd::Failed(message, hint) => { let _ = ctx .events .send(Event::Error { id: Some(id), message: message.clone(), + hint: hint.clone(), }) .await; if let Some(turn) = ids.turn_db_id diff --git a/crates/goat-agent/src/retry.rs b/crates/goat-agent/src/retry.rs index fcf11c7..f6ba278 100644 --- a/crates/goat-agent/src/retry.rs +++ b/crates/goat-agent/src/retry.rs @@ -40,13 +40,29 @@ pub(crate) fn reason_label(error: &StreamError) -> &'static str { pub(crate) fn failure_message(error: &StreamError, target: &goat_protocol::ModelTarget) -> String { match error { StreamError::Auth { message } => format!( - "authentication failed ({}/{}): {message} · /config to re-login · progress saved — send a message to continue", + "authentication failed ({}/{}): {message}", target.provider, target.account, ), other => other.to_string(), } } +pub(crate) fn error_hint(error: &StreamError) -> Option { + match error { + StreamError::Auth { .. } => { + Some("/config to re-login — progress saved, send a message to continue".to_owned()) + } + StreamError::ContextOverflow { .. } => { + Some("/compact to free context, then resend".to_owned()) + } + StreamError::RateLimited { .. } | StreamError::Overloaded { .. } => { + Some("wait a moment and resend, or /model to switch".to_owned()) + } + StreamError::Transport { .. } => Some("check your connection and resend".to_owned()), + StreamError::InvalidRequest { .. } | StreamError::Other { .. } => None, + } +} + fn format_elapsed(elapsed: Duration) -> String { let secs = elapsed.as_secs(); if secs < 60 { @@ -189,7 +205,21 @@ mod tests { }; let message = super::failure_message(&StreamError::auth("expired"), &target); assert!(message.contains("anthropic/work")); - assert!(message.contains("/config to re-login")); - assert!(message.contains("progress saved")); + let hint = super::error_hint(&StreamError::auth("expired")).unwrap(); + assert!(hint.contains("/config to re-login")); + assert!(hint.contains("progress saved")); + } + + #[test] + fn error_hint_covers_retryable_and_overflow() { + assert!(super::error_hint(&StreamError::rate_limited("x", None)).is_some()); + assert!(super::error_hint(&StreamError::transport("x")).is_some()); + assert!( + super::error_hint(&StreamError::ContextOverflow { + message: "x".into() + }) + .is_some() + ); + assert!(super::error_hint(&StreamError::other("x")).is_none()); } } diff --git a/crates/goat-agent/src/rounds.rs b/crates/goat-agent/src/rounds.rs index d2f5a07..9c7f0d2 100644 --- a/crates/goat-agent/src/rounds.rs +++ b/crates/goat-agent/src/rounds.rs @@ -56,7 +56,7 @@ pub(crate) enum RoundOutcome { pub(crate) enum LoopOutcome { Completed, Cancelled, - Failed(String), + Failed(String, Option), } async fn drain_steering(ctx: &Ctx<'_>, run: &Run<'_>, conversation: &mut Conversation) { @@ -403,7 +403,7 @@ pub(crate) async fn core_loop( ) -> LoopOutcome { let mut tool_ctx = match ToolContext::new(env.cwd) { Ok(tool_ctx) => tool_ctx, - Err(err) => return LoopOutcome::Failed(err.to_string()), + Err(err) => return LoopOutcome::Failed(err.to_string(), None), }; tool_ctx.exec_policy = env.exec_policy.clone(); let mut rounds = 0usize; @@ -444,12 +444,18 @@ pub(crate) async fn core_loop( return LoopOutcome::Cancelled; } Err(crate::compaction::CompactionError::Failed(message)) => { - return LoopOutcome::Failed(message); + return LoopOutcome::Failed( + message, + Some("/clear to reset the conversation".to_owned()), + ); } } } RoundEnd::Failed(error) => { - return LoopOutcome::Failed(crate::retry::failure_message(error, env.target)); + return LoopOutcome::Failed( + crate::retry::failure_message(error, env.target), + crate::retry::error_hint(error), + ); } RoundEnd::Completed => { compacted_for_overflow = false; diff --git a/crates/goat-agent/src/threads.rs b/crates/goat-agent/src/threads.rs index 896d1bf..f751bd4 100644 --- a/crates/goat-agent/src/threads.rs +++ b/crates/goat-agent/src/threads.rs @@ -265,9 +265,12 @@ pub(crate) async fn handle_resume( }); } } - ContentBlock::Image { .. } - | ContentBlock::Thinking { .. } - | ContentBlock::RedactedThinking { .. } => {} + ContentBlock::Thinking { text, .. } => { + if matches!(role, MessageRole::Assistant) { + entries.push(TranscriptEntry::Thinking { text: text.clone() }); + } + } + ContentBlock::Image { .. } | ContentBlock::RedactedThinking { .. } => {} } } parsed.push((stored.id, role, content)); diff --git a/crates/goat-agent/src/turn.rs b/crates/goat-agent/src/turn.rs index 37c30f7..967749e 100644 --- a/crates/goat-agent/src/turn.rs +++ b/crates/goat-agent/src/turn.rs @@ -77,16 +77,22 @@ async fn run_shell_command(tools: &ToolRegistry, command: &str, cwd: &std::path: pub(crate) enum TurnEnd { Done, Interrupted, - Failed(String), + Failed(String, Option), Shutdown, } -pub(crate) async fn emit_task_error(ctx: &Ctx<'_>, id: TaskId, message: String) { +pub(crate) async fn emit_task_error( + ctx: &Ctx<'_>, + id: TaskId, + message: String, + hint: Option, +) { let _ = ctx .events .send(Event::Error { id: Some(id), message, + hint, }) .await; let _ = ctx @@ -489,7 +495,13 @@ pub(crate) async fn handle_compact( .await; } Err(crate::compaction::CompactionError::Failed(message)) => { - emit_task_error(ctx, id, format!("compaction failed: {message}")).await; + emit_task_error( + ctx, + id, + format!("compaction failed: {message}"), + Some("/clear to reset the conversation".to_owned()), + ) + .await; } } if shutdown { @@ -525,7 +537,8 @@ async fn run_one_turn( emit_task_error( ctx, id, - "no model selected · /config to connect a provider".to_owned(), + "no model selected".to_owned(), + Some("/config to connect a provider".to_owned()), ) .await; return (TurnFlow::Idle, Vec::new()); @@ -536,7 +549,13 @@ async fn run_one_turn( &goat_provider::ProviderId::from(resolved.provider.as_str()), ); let Some(provider) = resolved_provider else { - emit_task_error(ctx, id, format!("unknown provider: {}", resolved.provider)).await; + emit_task_error( + ctx, + id, + format!("unknown provider: {}", resolved.provider), + Some("/config to select a provider".to_owned()), + ) + .await; return (TurnFlow::Idle, Vec::new()); }; @@ -660,7 +679,7 @@ async fn run_one_turn( let turn_end = match outcome { LoopOutcome::Completed => TurnEnd::Done, - LoopOutcome::Failed(message) => TurnEnd::Failed(message), + LoopOutcome::Failed(message, hint) => TurnEnd::Failed(message, hint), LoopOutcome::Cancelled => { if shutdown { TurnEnd::Shutdown diff --git a/crates/goat-client/src/lib.rs b/crates/goat-client/src/lib.rs index c497b18..944c950 100644 --- a/crates/goat-client/src/lib.rs +++ b/crates/goat-client/src/lib.rs @@ -382,7 +382,11 @@ fn frame_to_event(frame: ServerFrame) -> Option { }) .collect(), }), - ServerFrame::Error { message } => Some(Event::Error { id: None, message }), + ServerFrame::Error { message } => Some(Event::Error { + id: None, + message, + hint: None, + }), _ => None, } } diff --git a/crates/goat-code/src/headless.rs b/crates/goat-code/src/headless.rs index f92852e..70fd10c 100644 --- a/crates/goat-code/src/headless.rs +++ b/crates/goat-code/src/headless.rs @@ -282,6 +282,7 @@ fn emit_decode_error(message: &str) { let event = Event::Error { id: None, message: format!("headless decode error: {message}"), + hint: None, }; if let Ok(line) = serde_json::to_string(&event) { emit_line(&line); diff --git a/crates/goat-protocol/src/event.rs b/crates/goat-protocol/src/event.rs index f21efe2..56abc23 100644 --- a/crates/goat-protocol/src/event.rs +++ b/crates/goat-protocol/src/event.rs @@ -91,6 +91,8 @@ pub enum Event { Error { id: Option, message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + hint: Option, }, Notify { kind: NotifyKind, diff --git a/crates/goat-protocol/src/types.rs b/crates/goat-protocol/src/types.rs index 02f69fb..9266309 100644 --- a/crates/goat-protocol/src/types.rs +++ b/crates/goat-protocol/src/types.rs @@ -268,6 +268,9 @@ pub enum TranscriptEntry { Assistant { text: String, }, + Thinking { + text: String, + }, Tool { call: ToolCall, outcome: ToolOutcome, diff --git a/crates/goat-tui/src/app/engine.rs b/crates/goat-tui/src/app/engine.rs index 0780ec0..798d8ad 100644 --- a/crates/goat-tui/src/app/engine.rs +++ b/crates/goat-tui/src/app/engine.rs @@ -58,6 +58,9 @@ impl App { TranscriptEntry::Assistant { text } => { self.transcript.commit_text(&text); } + TranscriptEntry::Thinking { text } => { + self.transcript.push_thinking(text); + } TranscriptEntry::Tool { call, outcome } => { let id = call.id; self.transcript.push_tool(call); @@ -91,8 +94,13 @@ impl App { } self.model = Some(target); } - EngineEvent::ThinkingDelta { .. } => { + EngineEvent::ThinkingDelta { id, chunk } => { self.turn.thinking = true; + if let Some(i) = self.agent_index(id) { + self.agent_runs[i].transcript.push_thinking_delta(&chunk); + } else { + self.transcript.push_thinking_delta(&chunk); + } } EngineEvent::LoginProviders { .. } | EngineEvent::ThreadBound { .. } => {} EngineEvent::CompactionStarted { id } => { @@ -288,14 +296,15 @@ impl App { if !self.focused { self.queue_notification(crate::notification::Notification::Completion); } + self.transcript.flush_thinking(); self.transcript.complete(interrupted); self.reset_active_state(); if interrupted { self.restore_queued_to_composer(); } } - EngineEvent::Error { message, .. } => { - self.transcript.push_error(message); + EngineEvent::Error { message, hint, .. } => { + self.transcript.push_error(message, hint); self.reset_active_state(); self.restore_queued_to_composer(); } diff --git a/crates/goat-tui/src/app/keys.rs b/crates/goat-tui/src/app/keys.rs index 06c7aff..df62242 100644 --- a/crates/goat-tui/src/app/keys.rs +++ b/crates/goat-tui/src/app/keys.rs @@ -12,6 +12,14 @@ use crate::{ impl App { pub(crate) fn on_key(&mut self, key: KeyEvent) -> Vec { tracing::trace!(code = ?key.code, modifiers = ?key.modifiers, "key"); + if key + .modifiers + .intersects(KeyModifiers::SUPER | KeyModifiers::META) + && matches!(key.code, KeyCode::Char('c' | 'C')) + { + self.copy_selection(); + return Vec::new(); + } match &self.overlay { Overlay::Model(_) => return self.on_picker_key(key), Overlay::Effort(_) => return self.on_effort_picker_key(key), @@ -30,6 +38,11 @@ impl App { } } Overlay::Usage | Overlay::Help => return self.on_usage_key(key), + Overlay::ImageZoom(_) => { + self.overlay = Overlay::None; + self.dirty = true; + return Vec::new(); + } Overlay::None => {} } if let Some(ch) = keymap::ctrl_key(&key) { @@ -50,6 +63,9 @@ impl App { self.update_command_menu(); self.dirty = true; } + 't' => { + self.dirty |= self.transcript.toggle_thinking(); + } _ => {} } return Vec::new(); @@ -251,6 +267,9 @@ impl App { } KeyCode::Esc => { self.dirty = true; + if self.selection.take().is_some() { + return Vec::new(); + } if let Some(id) = self.turn.active { self.clear_arm = None; return vec![Op::Interrupt { id }]; @@ -262,7 +281,7 @@ impl App { return Vec::new(); } if self.clear_arm.take().is_some() { - self.composer.clear(); + self.composer.discard(); } else { self.clear_arm = Some(CLEAR_ARM_TICKS); } diff --git a/crates/goat-tui/src/app/mod.rs b/crates/goat-tui/src/app/mod.rs index 4b9b607..e6bd253 100644 --- a/crates/goat-tui/src/app/mod.rs +++ b/crates/goat-tui/src/app/mod.rs @@ -61,6 +61,7 @@ pub(crate) enum Overlay { Ask(AskPicker, ToolCallId), Usage, Help, + ImageZoom(Box), } const TICK: Duration = Duration::from_millis(120); @@ -107,6 +108,11 @@ pub struct App { pub(crate) scroll: usize, pub(crate) follow: bool, pub(crate) viewport_rows: u16, + pub(crate) selection: Option, + pub(crate) selection_version: u64, + pub(crate) transcript_area: ratatui::layout::Rect, + pub(crate) pending_copy: Option, + pub(crate) last_click: Option<(std::time::Instant, usize, u16)>, pub(crate) models: Vec, pub(crate) model: Option, pub(crate) overlay: Overlay, @@ -189,6 +195,11 @@ impl App { scroll: 0, follow: true, viewport_rows: 0, + selection: None, + selection_version: 0, + transcript_area: ratatui::layout::Rect::default(), + pending_copy: None, + last_click: None, models: Vec::new(), model: None, overlay: Overlay::None, @@ -284,20 +295,7 @@ impl App { Vec::new() } AppEvent::Input(CtEvent::Mouse(mouse)) => { - if self.wheel_scroll_allowed() { - match mouse.kind { - MouseEventKind::ScrollUp => { - self.scroll = self.scroll.saturating_sub(3); - self.follow = false; - self.dirty = true; - } - MouseEventKind::ScrollDown => { - self.scroll = self.scroll.saturating_add(3); - self.dirty = true; - } - _ => {} - } - } + self.on_mouse(mouse); Vec::new() } AppEvent::Input(CtEvent::FocusGained) => { @@ -790,6 +788,152 @@ impl App { ) } + pub(crate) fn selection_allowed(&self) -> bool { + matches!(self.overlay, Overlay::None | Overlay::Agents(_)) + } + + fn screen_to_cache(&self, col: u16, row: u16, clamp: bool) -> Option<(usize, u16)> { + let area = self.transcript_area; + let static_len = self.active_transcript().static_len(); + if area.height == 0 || static_len == 0 { + return None; + } + let bottom = (self.scroll + usize::from(area.height)) + .min(static_len) + .saturating_sub(1); + let line = if row < area.y { + if !clamp { + return None; + } + self.scroll + } else { + let candidate = self.scroll + usize::from(row - area.y); + if candidate > bottom { + if !clamp { + return None; + } + bottom + } else { + candidate + } + }; + let left = area.x.saturating_add(crate::layout::PAD_X); + let content_col = if col < left { + if !clamp && col < area.x { + return None; + } + 0 + } else { + col - left + }; + Some((line, content_col)) + } + + fn valid_selection(&self) -> Option { + self.selection + .filter(|_| self.active_transcript().version() == self.selection_version) + } + + fn copy_selection(&mut self) { + let Some(sel) = self.valid_selection() else { + return; + }; + let text = self + .active_transcript() + .selected_text(sel.anchor, sel.focus); + if text.is_empty() { + return; + } + self.pending_copy = Some(text); + self.toasts.push(crate::toast::Toast::new( + goat_protocol::NotifyKind::Info, + "copied".to_owned(), + )); + self.dirty = true; + } + + pub(crate) fn take_pending_copy(&mut self) -> Option { + self.pending_copy.take() + } + + fn on_left_down(&mut self, col: u16, row: u16) { + let Some(pos) = self.screen_to_cache(col, row, false) else { + self.selection = None; + self.last_click = None; + self.dirty = true; + return; + }; + self.selection_version = self.active_transcript().version(); + let now = std::time::Instant::now(); + let double = self.last_click.is_some_and(|(t, l, c)| { + l == pos.0 + && c.abs_diff(pos.1) <= 1 + && now.duration_since(t) < std::time::Duration::from_millis(400) + }); + if double && let Some((lo, hi)) = self.active_transcript().word_bounds_at(pos.0, pos.1) { + self.selection = Some(crate::select::Selection { + anchor: (pos.0, lo), + focus: (pos.0, hi), + dragging: false, + }); + self.last_click = None; + self.dirty = true; + return; + } + self.selection = Some(crate::select::Selection::new(pos)); + self.last_click = Some((now, pos.0, pos.1)); + self.dirty = true; + } + + fn on_mouse(&mut self, mouse: crossterm::event::MouseEvent) { + use crossterm::event::MouseButton; + match mouse.kind { + MouseEventKind::ScrollUp if self.wheel_scroll_allowed() => { + self.scroll = self.scroll.saturating_sub(3); + self.follow = false; + self.dirty = true; + } + MouseEventKind::ScrollDown if self.wheel_scroll_allowed() => { + self.scroll = self.scroll.saturating_add(3); + self.dirty = true; + } + MouseEventKind::Down(MouseButton::Left) + if matches!(self.overlay, Overlay::ImageZoom(_)) => + { + self.overlay = Overlay::None; + self.dirty = true; + } + MouseEventKind::Down(MouseButton::Left) if self.selection_allowed() => { + self.on_left_down(mouse.column, mouse.row); + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(pos) = self.screen_to_cache(mouse.column, mouse.row, true) + && let Some(sel) = self.selection.as_mut() + && sel.dragging + { + sel.focus = pos; + self.dirty = true; + } + } + MouseEventKind::Up(MouseButton::Left) => { + if let Some(sel) = self.selection { + if sel.is_empty() { + self.selection = None; + if self.selection_allowed() + && let Some(img) = self.active_transcript().image_at(sel.anchor.0) + { + self.overlay = Overlay::ImageZoom(Box::new(img)); + } + } else if let Some(active) = self.selection.as_mut() { + active.dragging = false; + } + self.dirty = true; + } + } + _ => {} + } + } + pub(crate) fn take_dirty(&mut self) -> bool { std::mem::take(&mut self.dirty) } @@ -1188,6 +1332,16 @@ async fn event_loop( if let Some(notification) = app.take_notification() { crate::notification::spawn(notification); } + if let Some(text) = app.take_pending_copy() { + tokio::spawn(async move { + let _ = tokio::task::spawn_blocking(move || { + if let Ok(mut clipboard) = arboard::Clipboard::new() { + let _ = clipboard.set_text(text); + } + }) + .await; + }); + } if app.take_dirty() { terminal.draw(|frame| view::render(frame, &mut app))?; } @@ -2168,6 +2322,7 @@ mod tests { app.on_engine(EngineEvent::Error { id: Some(TaskId(1)), message: "boom".to_owned(), + hint: None, }); assert!(app.compacting_status().is_none()); assert!(!app.is_busy()); diff --git a/crates/goat-tui/src/help.rs b/crates/goat-tui/src/help.rs index c502640..b0bea71 100644 --- a/crates/goat-tui/src/help.rs +++ b/crates/goat-tui/src/help.rs @@ -12,13 +12,14 @@ use crate::{ theme::Theme, }; -const BINDINGS: [(&str, &str); 12] = [ +const BINDINGS: [(&str, &str); 13] = [ ("⇧↵ ⌥↵", "newline"), ("↑↓", "history · move cursor"), ("pgup pgdn", "scroll transcript by page"), ("home end", "transcript top · bottom"), ("⌃a ⌃e", "line start · end"), ("⌃w", "delete word"), + ("⌃t", "expand · collapse thinking"), ("⌥← ⌥→", "word left · right"), ("⇥", "complete command"), ("esc", "interrupt · clear input ×2"), diff --git a/crates/goat-tui/src/lib.rs b/crates/goat-tui/src/lib.rs index d00c149..f5e622c 100644 --- a/crates/goat-tui/src/lib.rs +++ b/crates/goat-tui/src/lib.rs @@ -14,6 +14,7 @@ mod notification; mod overlay; mod picker; mod screenshot; +mod select; pub mod symbols; mod theme; mod toast; diff --git a/crates/goat-tui/src/overlay.rs b/crates/goat-tui/src/overlay.rs index 3a11de6..7eefb5f 100644 --- a/crates/goat-tui/src/overlay.rs +++ b/crates/goat-tui/src/overlay.rs @@ -70,7 +70,7 @@ pub fn hint_line<'a>(pairs: &[(&'a str, &'a str)], theme: Theme) -> Line<'a> { let mut spans: Vec> = vec![Span::raw(" ")]; for (i, (glyph, label)) in pairs.iter().enumerate() { if i > 0 { - spans.push(Span::styled(symbols::ui::SEPARATOR, theme.muted())); + spans.push(Span::raw(" ")); } spans.push(Span::styled(*glyph, theme.hint_key())); spans.push(Span::styled(format!(" {label}"), theme.muted())); diff --git a/crates/goat-tui/src/screenshot.rs b/crates/goat-tui/src/screenshot.rs index 056a7be..3399ff7 100644 --- a/crates/goat-tui/src/screenshot.rs +++ b/crates/goat-tui/src/screenshot.rs @@ -37,6 +37,10 @@ impl TranscriptImage { self.cells.map_or(0, |(_, h)| h) } + pub(crate) fn source(&self) -> ToolImageData { + self.source.clone() + } + #[cfg(test)] pub(crate) fn fixed(rows: u16) -> Self { Self { @@ -69,6 +73,25 @@ impl TranscriptImage { } } +pub(crate) fn render_zoom( + frame: &mut ratatui::Frame, + area: Rect, + picker: &Picker, + source: &ToolImageData, +) { + if area.width == 0 || area.height == 0 { + return; + } + let Some(img) = decode(source) else { + return; + }; + let size = Size::new(area.width, area.height); + let Ok(protocol) = picker.new_protocol(img, size, Resize::Fit(None)) else { + return; + }; + frame.render_widget(Image::new(&protocol).allow_clipping(true), area); +} + fn decode(source: &ToolImageData) -> Option { let bytes = STANDARD.decode(source.data.as_bytes()).ok()?; image::load_from_memory(&bytes).ok() diff --git a/crates/goat-tui/src/select.rs b/crates/goat-tui/src/select.rs new file mode 100644 index 0000000..a7cc662 --- /dev/null +++ b/crates/goat-tui/src/select.rs @@ -0,0 +1,192 @@ +use ratatui::text::Line; +use unicode_width::UnicodeWidthChar; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Selection { + pub anchor: (usize, u16), + pub focus: (usize, u16), + pub dragging: bool, +} + +impl Selection { + pub(crate) fn new(pos: (usize, u16)) -> Self { + Self { + anchor: pos, + focus: pos, + dragging: true, + } + } + + pub(crate) fn is_empty(&self) -> bool { + self.anchor == self.focus + } + + pub(crate) fn bounds(&self) -> ((usize, u16), (usize, u16)) { + if self.anchor <= self.focus { + (self.anchor, self.focus) + } else { + (self.focus, self.anchor) + } + } +} + +fn gutter_width(line: &Line<'_>) -> u16 { + line.spans.first().map_or(0, |span| { + let cols: usize = span + .content + .chars() + .filter_map(UnicodeWidthChar::width) + .sum(); + u16::try_from(cols).unwrap_or(u16::MAX) + }) +} + +fn line_slice(line: &Line<'_>, col_start: u16, col_end: u16) -> String { + let skip = gutter_width(line); + let start = col_start.max(skip); + let mut col: u16 = 0; + let mut out = String::new(); + for (i, span) in line.spans.iter().enumerate() { + for ch in span.content.chars() { + let w = u16::try_from(ch.width().unwrap_or(0)).unwrap_or(0); + if i > 0 && col >= start && col < col_end { + out.push(ch); + } + col = col.saturating_add(w); + } + } + out.trim_end().to_string() +} + +fn is_word(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +pub(crate) fn word_bounds(line: &Line<'_>, col: u16) -> Option<(u16, u16)> { + let skip = gutter_width(line); + let mut cells: Vec<(u16, u16, char)> = Vec::new(); + let mut c: u16 = 0; + for (i, span) in line.spans.iter().enumerate() { + for ch in span.content.chars() { + let w = u16::try_from(ch.width().unwrap_or(0)).unwrap_or(0); + if i > 0 { + cells.push((c, w, ch)); + } + c = c.saturating_add(w); + } + } + let target = col.max(skip); + let pos = cells + .iter() + .position(|(sc, w, _)| *sc <= target && target < sc.saturating_add((*w).max(1)))?; + if !is_word(cells[pos].2) { + return None; + } + let mut lo = pos; + while lo > 0 && is_word(cells[lo - 1].2) { + lo -= 1; + } + let mut hi = pos; + while hi + 1 < cells.len() && is_word(cells[hi + 1].2) { + hi += 1; + } + let start = cells[lo].0; + let end = cells[hi].0.saturating_add(cells[hi].1); + Some((start, end)) +} + +pub(crate) fn extract(lines: &[Line<'_>], anchor: (usize, u16), focus: (usize, u16)) -> String { + let (start, end) = if anchor <= focus { + (anchor, focus) + } else { + (focus, anchor) + }; + let mut rows: Vec = Vec::new(); + for idx in start.0..=end.0 { + let Some(line) = lines.get(idx) else { + continue; + }; + let col_start = if idx == start.0 { start.1 } else { 0 }; + let col_end = if idx == end.0 { end.1 } else { u16::MAX }; + rows.push(line_slice(line, col_start, col_end)); + } + rows.join("\n") +} + +#[cfg(test)] +mod tests { + use super::{Selection, extract}; + use ratatui::text::{Line, Span}; + + fn line(text: &str) -> Line<'static> { + Line::from(vec![Span::raw("● "), Span::raw(text.to_owned())]) + } + + #[test] + fn extract_strips_gutter_single_line() { + let lines = vec![line("hello world")]; + let got = extract(&lines, (0, 0), (0, u16::MAX)); + assert_eq!(got, "hello world"); + } + + #[test] + fn extract_partial_columns_within_line() { + let lines = vec![line("hello world")]; + let got = extract(&lines, (0, 2), (0, 7)); + assert_eq!(got, "hello"); + } + + #[test] + fn extract_multi_line_joins_with_newline() { + let lines = vec![line("first"), line("second")]; + let got = extract(&lines, (0, 0), (1, u16::MAX)); + assert_eq!(got, "first\nsecond"); + } + + #[test] + fn extract_partial_first_line_to_partial_last() { + let lines = vec![line("hello"), line("world")]; + let got = extract(&lines, (0, 4), (1, 5)); + assert_eq!(got, "llo\nwor"); + } + + #[test] + fn extract_reversed_bounds_normalized() { + let lines = vec![line("abc")]; + let got = extract(&lines, (0, u16::MAX), (0, 0)); + assert_eq!(got, "abc"); + } + + #[test] + fn extract_handles_blank_line_without_panic() { + let lines = vec![line("a"), Line::default(), line("b")]; + let got = extract(&lines, (0, 0), (2, u16::MAX)); + assert_eq!(got, "a\n\nb"); + } + + #[test] + fn extract_maps_wide_chars_by_display_column() { + let lines = vec![Line::from(vec![Span::raw("● "), Span::raw("한글x")])]; + let got = extract(&lines, (0, 2), (0, 4)); + assert_eq!(got, "한"); + } + + #[test] + fn word_bounds_selects_word_at_column() { + use super::word_bounds; + let l = line("foo bar_baz qux"); + assert_eq!(word_bounds(&l, 3), Some((2, 5))); + assert_eq!(word_bounds(&l, 8), Some((6, 13))); + assert_eq!(word_bounds(&l, 5), None); + } + + #[test] + fn selection_bounds_normalizes_order() { + let sel = Selection { + anchor: (2, 3), + focus: (0, 1), + dragging: false, + }; + assert_eq!(sel.bounds(), ((0, 1), (2, 3))); + } +} diff --git a/crates/goat-tui/src/symbols.rs b/crates/goat-tui/src/symbols.rs index 73d428d..4dc470b 100644 --- a/crates/goat-tui/src/symbols.rs +++ b/crates/goat-tui/src/symbols.rs @@ -19,6 +19,8 @@ pub mod ui { pub const SEPARATOR: &str = " · "; pub const ELLIPSIS: &str = "…"; pub const QUOTE_GUTTER: &str = "▎ "; + pub const CHEVRON_RIGHT: &str = "▸"; + pub const CHEVRON_DOWN: &str = "▾"; pub const RULE: &str = "──────────"; pub const HRULE: &str = "\u{1}hrule\u{1}"; pub const MORE_ABOVE: &str = "↑"; diff --git a/crates/goat-tui/src/theme/palette.rs b/crates/goat-tui/src/theme/palette.rs index 2951d0a..6b286de 100644 --- a/crates/goat-tui/src/theme/palette.rs +++ b/crates/goat-tui/src/theme/palette.rs @@ -36,6 +36,7 @@ pub struct Palette { pub panel: Color, pub shell: Color, pub shell_dim: Color, + pub selection: Color, pub code: CodePalette, } @@ -59,6 +60,7 @@ impl Palette { panel: Color::Rgb(0x1b, 0x1b, 0x1e), shell: Color::Rgb(0xdb, 0x4b, 0x4b), shell_dim: Color::Rgb(0x54, 0x29, 0x2e), + selection: Color::Rgb(0x2d, 0x3c, 0x52), code: CodePalette { bg: Color::Reset, keyword: Color::Rgb(0x56, 0x9c, 0xd6), @@ -80,32 +82,33 @@ impl Palette { id: 2, bg: Color::Rgb(0xfa, 0xfa, 0xfa), dark: false, - fg: Color::Rgb(0x1c, 0x1e, 0x22), - user: Color::Rgb(0x2e, 0x5c, 0xc9), - user_panel: Color::Rgb(0xf1, 0xf1, 0xf1), - agent: Color::Rgb(0x2f, 0x7d, 0x32), - tool: Color::Rgb(0xb5, 0x6a, 0x00), - error: Color::Rgb(0xc6, 0x28, 0x28), - muted: Color::Rgb(0x8a, 0x8f, 0x98), - accent: Color::Rgb(0x6a, 0x3d, 0xc9), - success: Color::Rgb(0x2f, 0x7d, 0x32), - border: Color::Rgb(0xd9, 0xdc, 0xe1), - border_dim: Color::Rgb(0xe6, 0xe8, 0xec), - panel: Color::Rgb(0xee, 0xee, 0xf0), - shell: Color::Rgb(0xb0, 0x35, 0x54), - shell_dim: Color::Rgb(0xe6, 0xc2, 0xcb), + fg: Color::Rgb(0x12, 0x14, 0x18), + user: Color::Rgb(0x1a, 0x56, 0xd6), + user_panel: Color::Rgb(0xec, 0xed, 0xf0), + agent: Color::Rgb(0x1b, 0x6e, 0x3c), + tool: Color::Rgb(0x9a, 0x5a, 0x00), + error: Color::Rgb(0xb4, 0x1c, 0x1c), + muted: Color::Rgb(0x5c, 0x63, 0x6e), + accent: Color::Rgb(0x5b, 0x21, 0xb6), + success: Color::Rgb(0x1b, 0x6e, 0x3c), + border: Color::Rgb(0xc8, 0xcc, 0xd4), + border_dim: Color::Rgb(0xdd, 0xe0, 0xe6), + panel: Color::Rgb(0xf0, 0xf1, 0xf4), + shell: Color::Rgb(0xa3, 0x15, 0x45), + shell_dim: Color::Rgb(0xf0, 0xd4, 0xdc), + selection: Color::Rgb(0xc7, 0xdd, 0xf5), code: CodePalette { bg: Color::Reset, - keyword: Color::Rgb(0x00, 0x00, 0xff), - string: Color::Rgb(0xa3, 0x15, 0x15), - comment: Color::Rgb(0x00, 0x80, 0x00), - number: Color::Rgb(0x09, 0x88, 0x58), - type_: Color::Rgb(0x26, 0x7f, 0x99), - function: Color::Rgb(0x79, 0x5e, 0x26), - variable: Color::Rgb(0x00, 0x16, 0x80), - operator: Color::Rgb(0x3b, 0x3b, 0x3b), - macro_: Color::Rgb(0xaf, 0x00, 0xdb), - property: Color::Rgb(0x00, 0x16, 0x80), + keyword: Color::Rgb(0x00, 0x3d, 0xb8), + string: Color::Rgb(0x8b, 0x12, 0x12), + comment: Color::Rgb(0x0d, 0x6b, 0x0d), + number: Color::Rgb(0x0a, 0x6b, 0x47), + type_: Color::Rgb(0x1a, 0x6b, 0x85), + function: Color::Rgb(0x5c, 0x4a, 0x1a), + variable: Color::Rgb(0x00, 0x1a, 0x72), + operator: Color::Rgb(0x2a, 0x2a, 0x2a), + macro_: Color::Rgb(0x8b, 0x00, 0x9e), + property: Color::Rgb(0x00, 0x1a, 0x72), }, } } @@ -158,6 +161,10 @@ impl Theme { ratatui::style::Style::new().fg(self.palette.accent) } + pub fn selection(self) -> ratatui::style::Style { + ratatui::style::Style::new().bg(self.palette.selection) + } + pub fn border(self) -> ratatui::style::Style { ratatui::style::Style::new().fg(self.palette.border) } diff --git a/crates/goat-tui/src/transcript/item.rs b/crates/goat-tui/src/transcript/item.rs index a285984..c0c7adb 100644 --- a/crates/goat-tui/src/transcript/item.rs +++ b/crates/goat-tui/src/transcript/item.rs @@ -29,6 +29,10 @@ pub(crate) enum ShellStatus { pub(crate) enum Item { User(UserMessage), Agent(String), + Thinking { + text: String, + collapsed: bool, + }, Tool { id: ToolCallId, name: String, @@ -41,7 +45,10 @@ pub(crate) enum Item { command: String, status: ShellStatus, }, - Error(String), + Error { + message: String, + hint: Option, + }, Interrupted, Compaction { tokens_before: u32, diff --git a/crates/goat-tui/src/transcript/mod.rs b/crates/goat-tui/src/transcript/mod.rs index e765884..7e8c5a3 100644 --- a/crates/goat-tui/src/transcript/mod.rs +++ b/crates/goat-tui/src/transcript/mod.rs @@ -57,6 +57,7 @@ struct StreamCache { pub struct Transcript { pub(crate) items: Vec, streaming: Option, + thinking_buffer: Option, version: u64, cache: RefCell>, stream_cache: RefCell>, @@ -127,19 +128,113 @@ impl Transcript { })); } + pub fn push_thinking_delta(&mut self, chunk: &str) { + self.thinking_buffer + .get_or_insert_with(String::new) + .push_str(chunk); + } + + pub fn flush_thinking(&mut self) { + let Some(buffer) = self.thinking_buffer.take() else { + return; + }; + if buffer.trim().is_empty() { + return; + } + self.bump_version(); + self.items.push(Item::Thinking { + text: buffer, + collapsed: true, + }); + } + + pub fn push_thinking(&mut self, text: String) { + if text.trim().is_empty() { + return; + } + self.bump_version(); + self.items.push(Item::Thinking { + text, + collapsed: true, + }); + } + + pub fn version(&self) -> u64 { + self.version + } + + pub fn static_len(&self) -> usize { + self.cache.borrow().as_ref().map_or(0, |c| c.lines.len()) + } + + pub fn selected_text(&self, anchor: (usize, u16), focus: (usize, u16)) -> String { + let guard = self.cache.borrow(); + guard.as_ref().map_or(String::new(), |cache| { + crate::select::extract(&cache.lines, anchor, focus) + }) + } + + pub fn word_bounds_at(&self, line: usize, col: u16) -> Option<(u16, u16)> { + let guard = self.cache.borrow(); + let cache = guard.as_ref()?; + crate::select::word_bounds(cache.lines.get(line)?, col) + } + + pub fn image_at(&self, line: usize) -> Option { + let guard = self.cache.borrow(); + let cache = guard.as_ref()?; + let placement = cache + .images + .iter() + .find(|p| line >= p.start && line < p.start + usize::from(p.rows))?; + match self.items.get(placement.item)? { + Item::Tool { + image: Some(img), .. + } => Some(img.source()), + _ => None, + } + } + + pub fn toggle_thinking(&mut self) -> bool { + let mut any = false; + let mut expand = false; + for item in &self.items { + if let Item::Thinking { collapsed, .. } = item { + any = true; + if *collapsed { + expand = true; + break; + } + } + } + if !any { + return false; + } + for item in &mut self.items { + if let Item::Thinking { collapsed, .. } = item { + *collapsed = !expand; + } + } + self.bump_version(); + true + } + pub fn push_delta(&mut self, chunk: &str) { + self.flush_thinking(); self.streaming .get_or_insert_with(String::new) .push_str(chunk); } pub fn commit_text(&mut self, text: &str) { + self.flush_thinking(); self.bump_version(); self.streaming = None; self.items.push(Item::Agent(text.to_owned())); } pub fn push_tool(&mut self, call: ToolCall) { + self.flush_thinking(); self.bump_version(); self.items.push(Item::Tool { id: call.id, @@ -197,13 +292,16 @@ impl Transcript { } } - pub fn push_error(&mut self, text: impl Into) { + pub fn push_error(&mut self, text: impl Into, hint: Option) { self.bump_version(); if let Some(buffer) = self.streaming.take() { let text = format!("{buffer} {} stopped", symbols::ui::ELLIPSIS); self.items.push(Item::Agent(text)); } - self.items.push(Item::Error(text.into())); + self.items.push(Item::Error { + message: text.into(), + hint, + }); } pub fn discard_stream(&mut self) { @@ -246,7 +344,7 @@ impl Transcript { buffer }; self.items.push(Item::Agent(text)); - } else if interrupted && !matches!(self.items.last(), Some(Item::Error(_))) { + } else if interrupted && !matches!(self.items.last(), Some(Item::Error { .. })) { self.items.push(Item::Interrupted); } } @@ -702,13 +800,56 @@ mod tests { ); } + #[test] + fn thinking_buffer_flushes_before_text_and_toggles() { + let mut t = Transcript::default(); + t.push_thinking_delta("weighing options"); + assert!(t.items.is_empty(), "thinking stays buffered until flushed"); + t.push_delta("answer"); + assert!( + matches!( + t.items.first(), + Some(Item::Thinking { + collapsed: true, + .. + }) + ), + "first content delta flushes thinking as a collapsed item" + ); + assert!(t.toggle_thinking(), "toggle reports thinking present"); + assert!(matches!( + t.items.first(), + Some(Item::Thinking { + collapsed: false, + .. + }) + )); + assert!(t.toggle_thinking()); + assert!(matches!( + t.items.first(), + Some(Item::Thinking { + collapsed: true, + .. + }) + )); + } + + #[test] + fn blank_thinking_is_dropped() { + let mut t = Transcript::default(); + t.push_thinking_delta(" "); + t.flush_thinking(); + assert!(t.items.is_empty()); + assert!(!t.toggle_thinking(), "no thinking means toggle is a no-op"); + } + #[test] fn error_commits_partial_stream_before_error_row() { let mut t = Transcript::default(); t.push_delta("partial answer"); - t.push_error("boom"); + t.push_error("boom", None); assert!(matches!(&t.items[0], Item::Agent(_))); - assert!(matches!(&t.items[1], Item::Error(_))); + assert!(matches!(&t.items[1], Item::Error { .. })); t.complete(true); assert!( !matches!(t.items.last(), Some(Item::Interrupted)), diff --git a/crates/goat-tui/src/transcript/render.rs b/crates/goat-tui/src/transcript/render.rs index 8c5506d..1bb0b55 100644 --- a/crates/goat-tui/src/transcript/render.rs +++ b/crates/goat-tui/src/transcript/render.rs @@ -265,6 +265,11 @@ pub(super) fn item_signature(item: &Item) -> u64 { 1u8.hash(&mut hasher); text.hash(&mut hasher); } + Item::Thinking { text, collapsed } => { + 8u8.hash(&mut hasher); + text.hash(&mut hasher); + collapsed.hash(&mut hasher); + } Item::Shell { command, status, .. } => { @@ -278,9 +283,10 @@ pub(super) fn item_signature(item: &Item) -> u64 { } } } - Item::Error(text) => { + Item::Error { message, hint } => { 3u8.hash(&mut hasher); - text.hash(&mut hasher); + message.hash(&mut hasher); + hint.hash(&mut hasher); } Item::Interrupted => { 7u8.hash(&mut hasher); @@ -307,6 +313,7 @@ pub(super) fn item_signature(item: &Item) -> u64 { ToolStatus::Done(outcome) => { 1u8.hash(&mut hasher); outcome.ok.hash(&mut hasher); + outcome.summary.hash(&mut hasher); } } } @@ -345,14 +352,11 @@ pub(super) fn item_rows( width, ) } + Item::Thinking { text, collapsed } => thinking_rows(text, *collapsed, theme, width), Item::Shell { command, status, .. } => shell_rows(command, status, theme, width), - Item::Error(text) => hang( - &plain_lines_styled(text, theme.error_body()), - Span::styled(symbols::marker::ERROR, theme.error()), - width, - ), + Item::Error { message, hint } => error_rows(message, hint.as_deref(), theme, width), Item::Interrupted => { let inner = width.saturating_sub(2); let line = Line::from(vec![ @@ -395,7 +399,7 @@ pub(super) fn item_rows( } => { let (marker, marker_style) = tool_marker(status, theme); let failed = matches!(status, ToolStatus::Done(o) if !o.ok); - tool_row(&ToolRowInput { + let mut rows = tool_row(&ToolRowInput { name, display_primary: &display.primary, marker, @@ -403,7 +407,15 @@ pub(super) fn item_rows( theme, width, line_ctx: ToolLineCtx { cwd, width, failed }, - }) + }); + if let ToolStatus::Done(outcome) = status + && outcome.ok + && let Some(summary) = outcome.summary.as_deref() + && is_diff_summary(summary) + { + rows.extend(diff_body_rows(summary, theme, width)); + } + rows } } } @@ -471,11 +483,117 @@ pub(super) fn shell_line_style(line: &str, theme: Theme) -> Style { theme.role_agent() } else if line.starts_with("- ") { theme.error() + } else { + theme.text() + } +} + +pub(super) fn is_diff_summary(summary: &str) -> bool { + summary + .lines() + .any(|line| line.starts_with("+ ") || line.starts_with("- ")) +} + +pub(super) fn diff_body_rows(summary: &str, theme: Theme, width: u16) -> Vec> { + let inner = width.saturating_sub(2); + let mut rows: Vec> = Vec::new(); + for line in summary.split('\n') { + let content = Line::from(Span::styled(line.to_owned(), diff_line_style(line, theme))); + for mut row in wrap::wrap_line(&content, inner) { + row.spans.insert(0, Span::raw(" ")); + rows.push(row); + } + } + rows +} + +fn diff_line_style(line: &str, theme: Theme) -> Style { + if line.starts_with("+ ") { + theme.role_agent() + } else if line.starts_with("- ") { + theme.error() } else { theme.muted() } } +fn error_rows(text: &str, hint: Option<&str>, theme: Theme, width: u16) -> Vec> { + let inner = width.saturating_sub(2); + let mut out: Vec> = Vec::new(); + for line in plain_lines_styled(text, theme.error_body()) { + for mut row in wrap::wrap_line(&line, inner) { + let gutter = if out.is_empty() { + symbols::marker::ERROR + } else { + symbols::ui::QUOTE_GUTTER + }; + row.spans.insert(0, Span::styled(gutter, theme.error())); + out.push(row); + } + } + if out.is_empty() { + out.push(Line::from(Span::styled( + symbols::marker::ERROR, + theme.error(), + ))); + } + if let Some(hint) = hint { + let line = Line::from(hint_spans(hint, theme)); + for mut row in wrap::wrap_line(&line, inner) { + row.spans + .insert(0, Span::styled(symbols::ui::QUOTE_GUTTER, theme.error())); + out.push(row); + } + } + out +} + +fn thinking_rows(text: &str, collapsed: bool, theme: Theme, width: u16) -> Vec> { + let marker = if collapsed { + symbols::ui::CHEVRON_RIGHT + } else { + symbols::ui::CHEVRON_DOWN + }; + let header = Line::from(vec![ + Span::styled(format!("{marker} "), theme.muted()), + Span::styled("Thought", theme.muted()), + ]); + if collapsed { + return vec![header]; + } + let inner = width.saturating_sub(2); + let mut out = vec![header]; + let body = text.trim_end(); + for line in body.split('\n') { + let content = Line::from(Span::styled(line.to_owned(), theme.muted())); + for mut row in wrap::wrap_line(&content, inner) { + row.spans + .insert(0, Span::styled(symbols::ui::QUOTE_GUTTER, theme.muted())); + out.push(row); + } + } + out +} + +fn hint_spans(hint: &str, theme: Theme) -> Vec> { + let mut spans = vec![Span::styled( + format!("{} ", symbols::key::ARROW_RIGHT), + theme.muted(), + )]; + for (i, word) in hint.split(' ').enumerate() { + if i > 0 { + spans.push(Span::styled(" ", theme.muted())); + } + let style = if word.starts_with('/') { + theme.accent() + } else { + theme.muted() + }; + spans.push(Span::styled(word.to_owned(), style)); + } + spans +} + pub(super) fn shell_rows( command: &str, status: &ShellStatus, @@ -539,3 +657,85 @@ pub(super) fn shell_rows( out.extend(rows); out } + +#[cfg(test)] +mod tests { + use super::{diff_body_rows, is_diff_summary}; + use crate::theme::Theme; + + #[test] + fn is_diff_summary_detects_change_lines() { + assert!(is_diff_summary("- old\n+ new")); + assert!(is_diff_summary("3 replacements\n- a\n+ b")); + assert!(!is_diff_summary("1 line")); + assert!(!is_diff_summary("wrote out.txt")); + } + + #[test] + fn diff_body_rows_color_change_lines() { + let theme = Theme::dark(); + let rows = diff_body_rows("- world\n+ there", theme, 40); + assert_eq!(rows.len(), 2); + let removed = &rows[0]; + let added = &rows[1]; + assert!(removed.spans.iter().any(|s| s.content.contains("- world"))); + assert!(added.spans.iter().any(|s| s.content.contains("+ there"))); + let removed_style = removed.spans.last().unwrap().style; + let added_style = added.spans.last().unwrap().style; + assert_eq!(removed_style.fg, theme.error().fg); + assert_eq!(added_style.fg, theme.role_agent().fg); + } + + #[test] + fn diff_body_rows_indent_two_columns() { + let rows = diff_body_rows("- x", Theme::dark(), 40); + assert_eq!(rows[0].spans[0].content.as_ref(), " "); + } + + #[test] + fn error_rows_rail_on_continuation() { + use super::{error_rows, symbols}; + let rows = error_rows("line one\nline two\nline three", None, Theme::dark(), 60); + assert_eq!(rows[0].spans[0].content.as_ref(), symbols::marker::ERROR); + assert_eq!(rows[1].spans[0].content.as_ref(), symbols::ui::QUOTE_GUTTER); + assert_eq!(rows[2].spans[0].content.as_ref(), symbols::ui::QUOTE_GUTTER); + } + + #[test] + fn thinking_rows_collapsed_is_single_line() { + use super::{symbols, thinking_rows}; + let rows = thinking_rows("some reasoning\nmore", true, Theme::dark(), 60); + assert_eq!(rows.len(), 1); + assert!( + rows[0].spans[0] + .content + .contains(symbols::ui::CHEVRON_RIGHT) + ); + assert!(rows[0].spans.iter().any(|s| s.content.contains("Thought"))); + } + + #[test] + fn thinking_rows_expanded_shows_body_with_gutter() { + use super::{symbols, thinking_rows}; + let rows = thinking_rows("line a\nline b", false, Theme::dark(), 60); + assert!(rows[0].spans[0].content.contains(symbols::ui::CHEVRON_DOWN)); + assert!(rows.len() >= 3); + assert_eq!(rows[1].spans[0].content.as_ref(), symbols::ui::QUOTE_GUTTER); + } + + #[test] + fn error_rows_render_action_hint_with_command_accent() { + use super::{error_rows, symbols}; + let theme = Theme::dark(); + let rows = error_rows("auth failed", Some("/config to re-login"), theme, 60); + let action = rows.last().unwrap(); + assert_eq!(action.spans[0].content.as_ref(), symbols::ui::QUOTE_GUTTER); + assert!(action.spans.iter().any(|s| s.content.contains('→'))); + let command = action + .spans + .iter() + .find(|s| s.content.as_ref() == "/config") + .expect("slash command span present"); + assert_eq!(command.style.fg, theme.accent().fg); + } +} diff --git a/crates/goat-tui/src/usage.rs b/crates/goat-tui/src/usage.rs index 2a50d27..450c2df 100644 --- a/crates/goat-tui/src/usage.rs +++ b/crates/goat-tui/src/usage.rs @@ -330,7 +330,7 @@ impl<'a> UsageView<'a> { if lines.is_empty() { lines.push(Line::from(Span::styled( - format!(" no accounts {} /config to add", symbols::ui::MIDDOT), + format!(" no accounts {} /config to add", symbols::key::ARROW_RIGHT), theme.muted(), ))); } diff --git a/crates/goat-tui/src/view.rs b/crates/goat-tui/src/view.rs index 9ffd6b8..0be4190 100644 --- a/crates/goat-tui/src/view.rs +++ b/crates/goat-tui/src/view.rs @@ -27,6 +27,31 @@ pub fn render(frame: &mut Frame, app: &mut App) { let composer_h = app.composer_height(area.width); + if let Overlay::ImageZoom(source) = app.overlay() { + let [body, hint] = + Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(area); + let img_area = body.inner(Margin { + horizontal: 2, + vertical: 1, + }); + if let Some(picker) = app.picker.as_ref() { + crate::screenshot::render_zoom(frame, img_area, picker, source); + } else { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + " image preview unavailable in this terminal ", + theme.muted(), + ))), + img_area, + ); + } + frame.render_widget( + Paragraph::new(overlay::hint_line(&[(symbols::key::ESC, "close")], theme)), + hint, + ); + return; + } + if let Overlay::Ask(picker, _) = app.overlay() { let panel_h = picker .desired_height() @@ -225,6 +250,51 @@ fn footer_visible(app: &App) -> bool { app.quit_armed() || app.is_busy() || app.clear_armed() } +fn render_selection(frame: &mut Frame, app: &mut App, theme: Theme) { + let Some(sel) = app.selection else { + return; + }; + if app.active_transcript().version() != app.selection_version { + app.selection = None; + return; + } + if sel.is_empty() { + return; + } + let area = app.transcript_area; + let scroll = app.scroll; + let (start, end) = sel.bounds(); + let left = area.x.saturating_add(PAD_X); + let right = area.x.saturating_add(area.width); + let buf = frame.buffer_mut(); + for line in start.0..=end.0 { + if line < scroll { + continue; + } + let rel = line - scroll; + if rel >= usize::from(area.height) { + break; + } + let y = area + .y + .saturating_add(u16::try_from(rel).unwrap_or(u16::MAX)); + let col_lo = if line == start.0 { start.1 } else { 0 }; + let col_hi = if line == end.0 { + end.1 + } else { + area.width.saturating_sub(PAD_X) + }; + let mut x = left.saturating_add(col_lo); + let x_end = left.saturating_add(col_hi).min(right); + while x < x_end { + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_style(theme.selection()); + } + x = x.saturating_add(1); + } + } +} + fn render_transcript(frame: &mut Frame, area: Rect, app: &mut App, theme: Theme) { let content = Rect { x: area.x, @@ -234,6 +304,7 @@ fn render_transcript(frame: &mut Frame, area: Rect, app: &mut App, theme: Theme) }; let body_width = content.width.saturating_sub(PAD_X); app.clamp_scroll(content.height, body_width); + app.transcript_area = content; let working = app.working_state(); let queued = app.queued_labels(); app.transcript().render( @@ -251,6 +322,7 @@ fn render_transcript(frame: &mut Frame, area: Rect, app: &mut App, theme: Theme) picker: app.picker.as_ref(), }, ); + render_selection(frame, app, theme); if app.follow() { return; } @@ -276,6 +348,23 @@ fn render_transcript(frame: &mut Frame, area: Rect, app: &mut App, theme: Theme) bar, &mut state, ); + let below = content_len.saturating_sub(app.scroll() + usize::from(content.height)); + if below > 0 { + let label = format!(" {} {below} below ", symbols::ui::MORE_BELOW); + let width = u16::try_from(label.chars().count()).unwrap_or(0); + let hint = Rect { + x: content + .x + .saturating_add(content.width.saturating_sub(width)), + y: content.y.saturating_add(content.height.saturating_sub(1)), + width, + height: 1, + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(label, theme.accent()))), + hint, + ); + } } fn fit_cwd(cwd: &str, max: usize) -> String { @@ -518,12 +607,12 @@ fn render_footer(frame: &mut Frame, area: Rect, app: &App, theme: Theme) { spans.push(Span::styled(symbols::key::ESC, theme.hint_key())); spans.push(Span::styled(" interrupt", theme.muted())); if !app.queued.is_empty() { - spans.push(Span::styled(symbols::ui::SEPARATOR, theme.muted())); + spans.push(Span::raw(" ")); spans.push(Span::styled(symbols::key::BACKSPACE, theme.hint_key())); spans.push(Span::styled(" edit queued", theme.muted())); } if !app.agent_runs().is_empty() { - spans.push(Span::styled(symbols::ui::SEPARATOR, theme.muted())); + spans.push(Span::raw(" ")); spans.push(Span::styled(symbols::key::ARROW_DOWN, theme.hint_key())); spans.push(Span::styled(" agents", theme.muted())); }