From d0f925217c100af7a47b37ce882e9da44d190a20 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Thu, 14 May 2026 01:50:09 +0800 Subject: [PATCH] Add TUI transcript pager overlay --- crates/tui/src/chatwidget.rs | 111 +++++-- crates/tui/src/chatwidget_tests.rs | 84 +++++ crates/tui/src/exec_cell/render.rs | 4 +- crates/tui/src/host.rs | 33 +- crates/tui/src/host_overlay.rs | 107 +++++++ crates/tui/src/lib.rs | 3 + crates/tui/src/pager_overlay.rs | 443 ++++++++++++++++++++++++++ crates/tui/src/tool_result_cell.rs | 141 ++++++++ crates/tui/src/tui.rs | 5 - crates/tui/src/tui/event_stream.rs | 11 +- crates/tui/src/tui/frame_requester.rs | 5 - docs/spec-interactive-tui-v2.md | 8 +- 12 files changed, 898 insertions(+), 57 deletions(-) create mode 100644 crates/tui/src/host_overlay.rs create mode 100644 crates/tui/src/pager_overlay.rs create mode 100644 crates/tui/src/tool_result_cell.rs diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index 9700431..0409d3f 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -75,6 +75,7 @@ use crate::streaming::commit_tick::CommitTickScope; use crate::streaming::commit_tick::run_commit_tick; use crate::streaming::controller::StreamController; use crate::theme::ThemeSet; +use crate::tool_result_cell::ToolResultCell; use crate::tui::frame_requester::FrameRequester; use devo_utils::ansi_escape::ansi_escape_line; @@ -1101,12 +1102,6 @@ impl ChatWidget { self.set_status_message("Select an action"); } - pub(crate) fn handle_mouse_event(&mut self, _mouse: crossterm::event::MouseEvent) { - // Mouse events are only actionable in alt-screen mode. - // In inline mode, they are ignored. - self.frame_requester.schedule_frame(); - } - pub(crate) fn handle_paste(&mut self, text: String) { if self.resume_browser.is_some() { return; @@ -1292,6 +1287,7 @@ impl ChatWidget { }; self.active_tool_calls .insert(tool_use_id.clone(), tool_call.clone()); + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); self.add_history_entry_without_redraw(Box::new( history_cell::AgentMessageCell::new_with_prefix( tool_call.lines, @@ -1307,6 +1303,8 @@ impl ChatWidget { if let Some(tool_call) = self.active_tool_calls.get_mut(&tool_use_id) { let line = Line::from(delta.clone()).patch_style(Self::tool_text_style()); tool_call.lines.push(line); + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + self.frame_requester.schedule_frame(); } } WorkerEvent::ToolResult { @@ -1336,21 +1334,17 @@ impl ChatWidget { .filter(|tool_title| !tool_title.is_empty()) .unwrap_or(title); - let mut lines = Vec::new(); - let mut preview_lines = self.tool_preview_lines(&preview); - if truncated && preview_lines.is_empty() { - preview_lines.push(Line::from("…").patch_style(Self::tool_text_style())); - } - if !resolved_title.is_empty() { - lines.push(Self::ran_tool_line(&resolved_title)); - } - lines.extend(preview_lines); - if !lines.is_empty() { - self.add_to_history(history_cell::AgentMessageCell::new_with_prefix( - lines, + let title_line = + (!resolved_title.is_empty()).then(|| Self::ran_tool_line(&resolved_title)); + if title_line.is_some() || !preview.is_empty() || truncated { + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + self.add_to_history(ToolResultCell::new( + title_line, + preview, self.dot_prefix(dot_status), - " ", - false, + Line::from(" "), + Self::tool_text_style(), + truncated, )); } self.set_status_message(if is_error { @@ -2070,16 +2064,14 @@ impl ChatWidget { )); } TranscriptItemKind::ToolResult => { - let mut lines = vec![Self::ran_tool_line(&item.title)]; - lines.extend(self.tool_preview_lines(&item.body)); - self.add_history_entry_without_redraw(Box::new( - history_cell::AgentMessageCell::new_with_prefix( - lines, - Self::tool_dot_prefix(), - " ", - false, - ), - )); + self.add_history_entry_without_redraw(Box::new(ToolResultCell::new( + (!item.title.is_empty()).then(|| Self::ran_tool_line(&item.title)), + item.body, + Self::tool_dot_prefix(), + Line::from(" "), + Self::tool_text_style(), + false, + ))); } TranscriptItemKind::Error => self.add_history_entry_without_redraw(Box::new( history_cell::new_error_event_with_hint(item.body, Some(item.title)), @@ -2388,6 +2380,7 @@ impl ChatWidget { TextItemKind::Reasoning => self.reasoning_active_cell(&self.active_text_items[index]), }; self.active_text_items[index].cell = cell; + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); } fn assistant_active_cell( @@ -2746,6 +2739,24 @@ impl ChatWidget { .unwrap_or_default() } + pub(crate) fn transcript_overlay_lines(&self, width: u16) -> Vec> { + let width = width.max(1); + let mut lines = Vec::new(); + for cell in &self.history { + Self::extend_lines_with_separator(&mut lines, cell.transcript_lines(width)); + } + Self::extend_lines_with_separator(&mut lines, self.live_transcript_lines(width)); + Self::trim_trailing_blank_lines(&mut lines); + lines + } + + pub(crate) fn transcript_overlay_has_live_tail(&self) -> bool { + self.active_cell.is_some() + || !self.active_text_items.is_empty() + || !self.active_tool_calls.is_empty() + || !self.pending_tool_calls.is_empty() + } + pub(crate) fn external_editor_state(&self) -> ExternalEditorState { self.external_editor_state } @@ -2797,6 +2808,46 @@ impl ChatWidget { lines } + fn live_transcript_lines(&self, width: u16) -> Vec> { + let mut lines = Vec::new(); + if let Some(cell) = &self.active_cell { + Self::extend_lines_with_separator(&mut lines, cell.transcript_lines(width)); + } + for item in &self.active_text_items { + if let Some(cell) = &item.cell { + Self::extend_lines_with_separator(&mut lines, cell.transcript_lines(width)); + } + } + let mut tool_calls = self.active_tool_calls.values().collect::>(); + tool_calls.sort_by(|left, right| left.tool_use_id.cmp(&right.tool_use_id)); + for tool_call in tool_calls { + Self::extend_lines_with_separator( + &mut lines, + history_cell::AgentMessageCell::new_with_prefix( + tool_call.lines.clone(), + Self::pending_dot_prefix(), + " ", + false, + ) + .transcript_lines(width), + ); + } + for pending in &self.pending_tool_calls { + Self::extend_lines_with_separator( + &mut lines, + history_cell::AgentMessageCell::new_with_prefix( + pending.lines.clone(), + Self::pending_dot_prefix(), + " ", + false, + ) + .transcript_lines(width), + ); + } + Self::trim_trailing_blank_lines(&mut lines); + lines + } + fn extend_lines_with_separator(target: &mut Vec>, mut next: Vec>) { if next.is_empty() { return; diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index 4bf7808..f6821ca 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -2282,3 +2282,87 @@ fn live_reasoning_cell_renders_without_duplication() { "reasoning should appear exactly once, got {occurrences}:\n{before}" ); } + +#[test] +fn transcript_overlay_lines_include_full_completed_tool_output() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let output = (1..=8) + .map(|index| format!("line {index}")) + .collect::>() + .join("\n"); + + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "bash".to_string(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-1".to_string(), + title: "bash".to_string(), + preview: output, + is_error: false, + truncated: false, + }); + + let inline = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + let transcript = widget + .transcript_overlay_lines(80) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + !inline.contains("line 5"), + "inline output should stay compact: {inline}" + ); + assert!( + transcript.contains("line 5") && transcript.contains("line 8"), + "transcript output should include the full tool output: {transcript}" + ); +} + +#[test] +fn transcript_overlay_lines_include_running_tool_output_delta() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "bash".to_string(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolOutputDelta { + tool_use_id: "tool-1".to_string(), + delta: "streamed output line".to_string(), + }); + + let transcript = widget + .transcript_overlay_lines(80) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + transcript.contains("streamed output line"), + "transcript output should include running tool deltas: {transcript}" + ); +} diff --git a/crates/tui/src/exec_cell/render.rs b/crates/tui/src/exec_cell/render.rs index f0be3b5..c422e0d 100644 --- a/crates/tui/src/exec_cell/render.rs +++ b/crates/tui/src/exec_cell/render.rs @@ -30,9 +30,7 @@ pub(crate) const TOOL_CALL_MAX_LINES: usize = 2; const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50; const MAX_INTERACTION_PREVIEW_CHARS: usize = 80; -/// TODO: Transcript functionality still not implemented. -// const TRANSCRIPT_HINT: &str = "ctrl + t to view transcript"; -const TRANSCRIPT_HINT: &str = "..."; +const TRANSCRIPT_HINT: &str = "ctrl + t to view transcript"; pub(crate) struct OutputLinesParams { pub(crate) line_limit: usize, diff --git a/crates/tui/src/host.rs b/crates/tui/src/host.rs index 7aada57..efcec3e 100644 --- a/crates/tui/src/host.rs +++ b/crates/tui/src/host.rs @@ -22,6 +22,7 @@ use crate::chatwidget::ChatWidget; use crate::chatwidget::ChatWidgetInit; use crate::chatwidget::TuiSessionState; use crate::events::WorkerEvent; +use crate::host_overlay::OverlayState; use crate::onboarding::save_last_used_model; use crate::onboarding::save_onboarding_config; use crate::onboarding::save_project_permission_preset; @@ -57,6 +58,7 @@ struct InteractiveLoopState { // replacement session has been restored into widget state. session_switch_pending: bool, last_ctrl_c_at: Option, + overlay: OverlayState, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -306,6 +308,16 @@ fn handle_tui_event( return Ok(LoopAction::ClearAndExit); }; + if loop_state.overlay.is_active() { + if matches!(tui_event, TuiEvent::Draw) { + chat_widget.pre_draw_tick(); + } + loop_state + .overlay + .handle_tui_event(tui_event, tui, chat_widget)?; + return Ok(LoopAction::Continue); + } + match tui_event { TuiEvent::Draw => { if loop_state.session_switch_pending { @@ -317,6 +329,7 @@ fn handle_tui_event( if !chat_widget.is_resume_browser_open() && !loop_state.resume_browser_pending + && !loop_state.overlay.is_active() && tui.is_alt_screen_active() { tui.leave_alt_screen()?; @@ -362,21 +375,17 @@ fn handle_tui_event( return Ok(LoopAction::Continue); } - if key.code == KeyCode::Char('t') && key.modifiers.contains(KeyModifiers::CONTROL) { - if tui.is_alt_screen_active() { - tui.leave_alt_screen()?; - } else { - tui.enter_alt_screen()?; - } + if key.code == KeyCode::Char('t') + && key.modifiers.contains(KeyModifiers::CONTROL) + && !chat_widget.is_resume_browser_open() + { + loop_state.overlay.open_transcript(tui, chat_widget)?; return Ok(LoopAction::Continue); } loop_state.last_ctrl_c_at = None; chat_widget.handle_key_event(key); } - TuiEvent::Mouse(mouse_event) => { - chat_widget.handle_mouse_event(mouse_event); - } TuiEvent::Paste(pasted) => { // Many terminals convert newlines to \r when pasting (e.g., iTerm2), // but tui-textarea expects \n. Normalize CR to LF. @@ -437,6 +446,12 @@ fn handle_app_event( handle_app_command(command, worker, chat_widget, tui, loop_state, context)?; return Ok(LoopAction::Continue); } + + if let AppEvent::DiffResult(text) = app_event { + loop_state.overlay.open_diff(tui, chat_widget, text)?; + return Ok(LoopAction::Continue); + } + chat_widget.handle_app_event(app_event); Ok(LoopAction::Continue) diff --git a/crates/tui/src/host_overlay.rs b/crates/tui/src/host_overlay.rs new file mode 100644 index 0000000..a66f3b9 --- /dev/null +++ b/crates/tui/src/host_overlay.rs @@ -0,0 +1,107 @@ +//! Host-side lifecycle helpers for alternate-screen overlays. + +use anyhow::Result; +use devo_utils::ansi_escape::ansi_escape_line; +use ratatui::style::Stylize; +use ratatui::text::Line; +use std::time::Duration; + +use crate::chatwidget::ChatWidget; +use crate::pager_overlay::Overlay; +use crate::tui::Tui; +use crate::tui::TuiEvent; + +#[derive(Debug, Default)] +pub(crate) struct OverlayState { + overlay: Option, +} + +impl OverlayState { + pub(crate) fn is_active(&self) -> bool { + self.overlay.is_some() + } + + pub(crate) fn handle_tui_event( + &mut self, + tui_event: TuiEvent, + tui: &mut Tui, + chat_widget: &mut ChatWidget, + ) -> Result<()> { + let Some(overlay) = self.overlay.as_mut() else { + return Ok(()); + }; + + if matches!(tui_event, TuiEvent::Draw) { + let width = tui.terminal.size()?.width.max(1); + overlay.set_transcript_lines(chat_widget.transcript_overlay_lines(width)); + } + + overlay.handle_event(tui, tui_event)?; + if overlay.is_done() { + self.overlay = None; + tui.leave_alt_screen()?; + tui.frame_requester().schedule_frame(); + } else if chat_widget.transcript_overlay_has_live_tail() { + tui.frame_requester() + .schedule_frame_in(Duration::from_millis(50)); + } + + Ok(()) + } + + pub(crate) fn open_transcript( + &mut self, + tui: &mut Tui, + chat_widget: &ChatWidget, + ) -> Result<()> { + let width = tui.terminal.size()?.width.max(1); + tui.enter_alt_screen()?; + self.overlay = Some(Overlay::new_transcript( + chat_widget.transcript_overlay_lines(width), + )); + tui.frame_requester().schedule_frame(); + Ok(()) + } + + pub(crate) fn open_diff( + &mut self, + tui: &mut Tui, + chat_widget: &mut ChatWidget, + text: String, + ) -> Result<()> { + tui.enter_alt_screen()?; + self.overlay = Some(Overlay::new_static_with_lines( + diff_overlay_lines(&text), + "D I F F".to_string(), + )); + chat_widget.set_status_message("Diff shown"); + tui.frame_requester().schedule_frame(); + Ok(()) + } +} + +fn diff_overlay_lines(text: &str) -> Vec> { + if text.trim().is_empty() { + vec!["No changes detected.".italic().into()] + } else { + text.lines().map(ansi_escape_line).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn diff_overlay_lines_render_empty_diff_message() { + let lines = diff_overlay_lines(""); + assert_eq!(1, lines.len()); + let text = lines[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert_eq!("No changes detected.", text); + } +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index a63b111..0dc47a2 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -23,6 +23,7 @@ mod exec_command; mod get_git_diff; mod history_cell; mod host; +mod host_overlay; mod insert_history; mod key_hint; mod line_truncation; @@ -31,6 +32,7 @@ mod markdown; pub mod markdown_render; mod markdown_stream; mod onboarding; +mod pager_overlay; mod render; mod shimmer; mod slash_command; @@ -43,6 +45,7 @@ mod terminal_palette; mod test_backend; mod text_formatting; mod theme; +mod tool_result_cell; mod tui; mod ui_consts; mod version; diff --git a/crates/tui/src/pager_overlay.rs b/crates/tui/src/pager_overlay.rs new file mode 100644 index 0000000..f07e86c --- /dev/null +++ b/crates/tui/src/pager_overlay.rs @@ -0,0 +1,443 @@ +//! Pager-style overlays rendered in the terminal alternate screen. +//! +//! These overlays are intentionally small: they own full-screen scrolling UI, +//! while the host owns when alternate screen mode is entered and left. + +use std::io::Result; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::text::Text; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; + +use crate::tui; +use crate::tui::TuiEvent; + +pub(crate) enum Overlay { + Transcript(TranscriptOverlay), + Static(StaticOverlay), +} + +impl std::fmt::Debug for Overlay { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Transcript(_) => f.write_str("Overlay::Transcript"), + Self::Static(_) => f.write_str("Overlay::Static"), + } + } +} + +impl Overlay { + pub(crate) fn new_transcript(lines: Vec>) -> Self { + Self::Transcript(TranscriptOverlay::new(lines)) + } + + pub(crate) fn new_static_with_lines(lines: Vec>, title: String) -> Self { + Self::Static(StaticOverlay::new(lines, title)) + } + + pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + match self { + Overlay::Transcript(overlay) => overlay.handle_event(tui, event), + Overlay::Static(overlay) => overlay.handle_event(tui, event), + } + } + + pub(crate) fn is_done(&self) -> bool { + match self { + Overlay::Transcript(overlay) => overlay.is_done(), + Overlay::Static(overlay) => overlay.is_done(), + } + } + + pub(crate) fn set_transcript_lines(&mut self, lines: Vec>) { + if let Overlay::Transcript(overlay) = self { + overlay.set_lines(lines); + } + } +} + +#[derive(Debug)] +struct PagerView { + lines: Vec>, + scroll_offset: usize, + title: String, + last_content_height: Option, + last_rendered_height: Option, +} + +impl PagerView { + fn new(lines: Vec>, title: String, scroll_offset: usize) -> Self { + Self { + lines, + scroll_offset, + title, + last_content_height: None, + last_rendered_height: None, + } + } + + fn set_lines(&mut self, lines: Vec>) { + let follow_bottom = self.is_scrolled_to_bottom(); + self.lines = lines; + if follow_bottom { + self.scroll_offset = usize::MAX; + } + } + + fn content_height(&self, width: u16) -> usize { + Paragraph::new(Text::from(self.lines.clone())) + .wrap(Wrap { trim: false }) + .line_count(width.max(1)) + } + + fn render(&mut self, area: Rect, buf: &mut Buffer) { + Clear.render(area, buf); + if area.is_empty() { + return; + } + + self.render_header(area, buf); + let content_area = self.content_area(area); + self.last_content_height = Some(content_area.height as usize); + let content_height = self.content_height(content_area.width.max(1)); + self.last_rendered_height = Some(content_height); + + let max_scroll = content_height.saturating_sub(content_area.height as usize); + self.scroll_offset = self.scroll_offset.min(max_scroll); + + Paragraph::new(Text::from(self.lines.clone())) + .wrap(Wrap { trim: false }) + .scroll((u16::try_from(self.scroll_offset).unwrap_or(u16::MAX), 0)) + .render(content_area, buf); + + self.render_bottom_bar(area, content_area, buf, content_height); + } + + fn render_header(&self, area: Rect, buf: &mut Buffer) { + let header = format!("/ {}", self.title); + Span::from("/ ".repeat(area.width as usize / 2)) + .dim() + .render_ref(Rect::new(area.x, area.y, area.width, 1), buf); + Span::from(header) + .dim() + .render_ref(Rect::new(area.x, area.y, area.width, 1), buf); + } + + fn render_bottom_bar( + &self, + full_area: Rect, + content_area: Rect, + buf: &mut Buffer, + total_len: usize, + ) { + if full_area.height == 0 { + return; + } + + let y = content_area + .bottom() + .min(full_area.bottom().saturating_sub(1)); + let rect = Rect::new(full_area.x, y, full_area.width, 1); + Span::from("-".repeat(rect.width as usize)) + .dim() + .render_ref(rect, buf); + + let hints = " Q/Ctrl+C/ESC close Up/Down scroll PgUp/PgDn page "; + Span::from(hints) + .dim() + .render_ref(Rect::new(rect.x, rect.y, rect.width, 1), buf); + + let percent = self.scroll_percent(total_len, content_area.height as usize); + let pct_text = format!(" {percent}% "); + let pct_w = pct_text.chars().count() as u16; + if rect.width > pct_w { + let pct_x = rect.x + rect.width.saturating_sub(pct_w); + Span::from(pct_text) + .dim() + .render_ref(Rect::new(pct_x, rect.y, pct_w, 1), buf); + } + } + + fn scroll_percent(&self, total_len: usize, visible_len: usize) -> u8 { + let max_scroll = total_len.saturating_sub(visible_len); + if max_scroll == 0 { + 100 + } else { + (((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round() + as u8 + } + } + + fn handle_key_event(&mut self, key_event: KeyEvent, viewport_area: Rect) -> bool { + if !is_press_or_repeat(key_event) { + return false; + } + + match key_event.code { + KeyCode::Up | KeyCode::Char('k') => { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + self.scroll_offset = self.scroll_offset.saturating_add(1); + } + KeyCode::PageUp | KeyCode::Char('b') + if key_event.modifiers.contains(KeyModifiers::CONTROL) => + { + self.scroll_offset = self + .scroll_offset + .saturating_sub(self.page_height(viewport_area)); + } + KeyCode::PageDown | KeyCode::Char('f') + if key_event.modifiers.contains(KeyModifiers::CONTROL) => + { + self.scroll_offset = self + .scroll_offset + .saturating_add(self.page_height(viewport_area)); + } + KeyCode::Char(' ') + if key_event.modifiers.is_empty() || key_event.modifiers == KeyModifiers::NONE => + { + self.scroll_offset = self + .scroll_offset + .saturating_add(self.page_height(viewport_area)); + } + KeyCode::Char(' ') if key_event.modifiers.contains(KeyModifiers::SHIFT) => { + self.scroll_offset = self + .scroll_offset + .saturating_sub(self.page_height(viewport_area)); + } + KeyCode::Char('d') if key_event.modifiers.contains(KeyModifiers::CONTROL) => { + let half_page = self.page_height(viewport_area).saturating_add(1) / 2; + self.scroll_offset = self.scroll_offset.saturating_add(half_page); + } + KeyCode::Char('u') if key_event.modifiers.contains(KeyModifiers::CONTROL) => { + let half_page = self.page_height(viewport_area).saturating_add(1) / 2; + self.scroll_offset = self.scroll_offset.saturating_sub(half_page); + } + KeyCode::Home => { + self.scroll_offset = 0; + } + KeyCode::End => { + self.scroll_offset = usize::MAX; + } + _ => return false, + } + true + } + + fn page_height(&self, viewport_area: Rect) -> usize { + self.last_content_height + .unwrap_or_else(|| self.content_area(viewport_area).height as usize) + .max(1) + } + + fn content_area(&self, area: Rect) -> Rect { + Rect::new( + area.x, + area.y.saturating_add(1), + area.width, + area.height.saturating_sub(2), + ) + } + + fn is_scrolled_to_bottom(&self) -> bool { + if self.scroll_offset == usize::MAX { + return true; + } + let Some(visible_height) = self.last_content_height else { + return false; + }; + let Some(total_height) = self.last_rendered_height else { + return false; + }; + if total_height <= visible_height { + return true; + } + self.scroll_offset >= total_height.saturating_sub(visible_height) + } +} + +#[derive(Debug)] +pub(crate) struct TranscriptOverlay { + view: PagerView, + is_done: bool, +} + +impl TranscriptOverlay { + fn new(lines: Vec>) -> Self { + Self { + view: PagerView::new(lines, "T R A N S C R I P T".to_string(), usize::MAX), + is_done: false, + } + } + + fn set_lines(&mut self, lines: Vec>) { + self.view.set_lines(lines); + } + + fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + match event { + TuiEvent::Key(key_event) => { + if close_key(key_event) || ctrl_t_key(key_event) { + self.is_done = true; + } else if self + .view + .handle_key_event(key_event, tui.terminal.viewport_area) + { + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + } + } + TuiEvent::Draw => { + tui.draw(u16::MAX, |frame| { + self.view.render(frame.area(), frame.buffer_mut()); + })?; + } + TuiEvent::Paste(_) => {} + } + Ok(()) + } + + fn is_done(&self) -> bool { + self.is_done + } +} + +#[derive(Debug)] +pub(crate) struct StaticOverlay { + view: PagerView, + is_done: bool, +} + +impl StaticOverlay { + fn new(lines: Vec>, title: String) -> Self { + Self { + view: PagerView::new(lines, title, 0), + is_done: false, + } + } + + fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + match event { + TuiEvent::Key(key_event) => { + if close_key(key_event) || ctrl_t_key(key_event) { + self.is_done = true; + } else if self + .view + .handle_key_event(key_event, tui.terminal.viewport_area) + { + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + } + } + TuiEvent::Draw => { + tui.draw(u16::MAX, |frame| { + self.view.render(frame.area(), frame.buffer_mut()); + })?; + } + TuiEvent::Paste(_) => {} + } + Ok(()) + } + + fn is_done(&self) -> bool { + self.is_done + } +} + +fn is_press_or_repeat(key_event: KeyEvent) -> bool { + matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) +} + +fn close_key(key_event: KeyEvent) -> bool { + is_press_or_repeat(key_event) + && (matches!(key_event.code, KeyCode::Char('q') | KeyCode::Esc) + || (key_event.code == KeyCode::Char('c') + && key_event.modifiers.contains(KeyModifiers::CONTROL))) +} + +fn ctrl_t_key(key_event: KeyEvent) -> bool { + is_press_or_repeat(key_event) + && key_event.code == KeyCode::Char('t') + && key_event.modifiers.contains(KeyModifiers::CONTROL) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn buffer_to_text(buf: &Buffer, area: Rect) -> String { + (area.y..area.bottom()) + .map(|y| { + (area.x..area.right()) + .map(|x| buf[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") + } + + #[test] + fn static_overlay_renders_title_content_and_percent() { + let mut overlay = StaticOverlay::new( + vec![Line::from("diff --git a/file b/file"), Line::from("+added")], + "D I F F".to_string(), + ); + let area = Rect::new(0, 0, 60, 8); + let mut buf = Buffer::empty(area); + + overlay.view.render(area, &mut buf); + + let rendered = buffer_to_text(&buf, area); + assert!(rendered.contains("D I F F")); + assert!(rendered.contains("diff --git")); + assert!(rendered.contains("100%")); + } + + #[test] + fn pager_scrolls_down_and_back_up() { + let mut view = PagerView::new( + (0..20) + .map(|index| Line::from(format!("line {index}"))) + .collect(), + "T E S T".to_string(), + 0, + ); + let area = Rect::new(0, 0, 30, 6); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + assert_eq!(0, view.scroll_offset); + assert!(view.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), area)); + view.render(area, &mut buf); + assert_eq!(1, view.scroll_offset); + + assert!(view.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), area)); + view.render(area, &mut buf); + assert_eq!(0, view.scroll_offset); + } + + #[test] + fn transcript_overlay_closes_with_ctrl_t() { + let mut overlay = TranscriptOverlay::new(vec![Line::from("hello")]); + + let key = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL); + assert!(ctrl_t_key(key)); + overlay.is_done = ctrl_t_key(key); + + assert!(overlay.is_done()); + } +} diff --git a/crates/tui/src/tool_result_cell.rs b/crates/tui/src/tool_result_cell.rs new file mode 100644 index 0000000..4592573 --- /dev/null +++ b/crates/tui/src/tool_result_cell.rs @@ -0,0 +1,141 @@ +//! History cell for completed generic tool calls. +//! +//! Inline rendering keeps tool output compact, while transcript rendering keeps +//! the full output available for the Ctrl+T pager. + +use devo_utils::ansi_escape::ansi_escape_line; +use ratatui::style::Style; +use ratatui::text::Line; + +use crate::exec_cell::truncated_tool_output_preview; +use crate::history_cell::AgentMessageCell; +use crate::history_cell::HistoryCell; + +#[derive(Debug)] +pub(crate) struct ToolResultCell { + title_line: Option>, + output: String, + dot_prefix: Line<'static>, + subsequent_prefix: Line<'static>, + output_style: Style, + show_empty_ellipsis: bool, +} + +impl ToolResultCell { + pub(crate) fn new( + title_line: Option>, + output: String, + dot_prefix: Line<'static>, + subsequent_prefix: Line<'static>, + output_style: Style, + show_empty_ellipsis: bool, + ) -> Self { + Self { + title_line, + output, + dot_prefix, + subsequent_prefix, + output_style, + show_empty_ellipsis, + } + } + + fn inline_lines(&self, width: u16) -> Vec> { + let mut lines = self.title_line.iter().cloned().collect::>(); + let mut preview_lines = truncated_tool_output_preview(&self.output, width, 2); + for line in &mut preview_lines { + line.spans = line + .spans + .clone() + .into_iter() + .map(|span| span.patch_style(self.output_style)) + .collect(); + } + if self.show_empty_ellipsis && preview_lines.is_empty() { + preview_lines.push(Line::from("...").patch_style(self.output_style)); + } + lines.extend(preview_lines); + lines + } + + fn full_output_lines(&self) -> Vec> { + self.output + .lines() + .map(|line| { + let mut line = ansi_escape_line(line); + line.spans = line + .spans + .into_iter() + .map(|span| span.patch_style(self.output_style)) + .collect(); + line + }) + .collect() + } + + fn prefixed_cell(&self, lines: Vec>) -> AgentMessageCell { + AgentMessageCell::new_with_prefix( + lines, + self.dot_prefix.clone(), + self.subsequent_prefix.clone(), + /*is_stream_continuation*/ false, + ) + } +} + +impl HistoryCell for ToolResultCell { + fn display_lines(&self, width: u16) -> Vec> { + self.prefixed_cell(self.inline_lines(width)) + .display_lines(width) + } + + fn transcript_lines(&self, width: u16) -> Vec> { + let mut lines = self.title_line.iter().cloned().collect::>(); + lines.extend(self.full_output_lines()); + if self.show_empty_ellipsis && lines.len() == self.title_line.iter().count() { + lines.push(Line::from("...").patch_style(self.output_style)); + } + self.prefixed_cell(lines).display_lines(width) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::text::Span; + + fn plain(lines: Vec>) -> Vec { + lines + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect() + } + + #[test] + fn inline_output_is_truncated_but_transcript_output_is_full() { + let output = (1..=8) + .map(|index| format!("line {index}")) + .collect::>() + .join("\n"); + let cell = ToolResultCell::new( + Some(Line::from("Ran test")), + output, + Line::from(vec![Span::from("| ")]), + Line::from(" "), + Style::default(), + false, + ); + + let inline = plain(cell.display_lines(80)).join("\n"); + let transcript = plain(cell.transcript_lines(80)).join("\n"); + + assert!(!inline.contains("line 5")); + assert!(transcript.contains("line 5")); + assert!(transcript.contains("line 8")); + } +} diff --git a/crates/tui/src/tui.rs b/crates/tui/src/tui.rs index b2ef4da..4686dab 100644 --- a/crates/tui/src/tui.rs +++ b/crates/tui/src/tui.rs @@ -57,10 +57,8 @@ use crossterm::Command; use crossterm::SynchronizedUpdate; use crossterm::event::DisableBracketedPaste; use crossterm::event::DisableFocusChange; -use crossterm::event::DisableMouseCapture; use crossterm::event::EnableBracketedPaste; use crossterm::event::EnableFocusChange; -use crossterm::event::EnableMouseCapture; use crossterm::event::KeyEvent; use crossterm::event::KeyboardEnhancementFlags; use crossterm::event::PopKeyboardEnhancementFlags; @@ -325,7 +323,6 @@ fn set_panic_hook() { pub enum TuiEvent { Key(KeyEvent), Paste(String), - Mouse(crossterm::event::MouseEvent), Draw, } @@ -485,7 +482,6 @@ impl Tui { let _ = execute!(self.terminal.backend_mut(), EnterAlternateScreen); // Enable "alternate scroll" so terminals may translate wheel to arrows let _ = execute!(self.terminal.backend_mut(), EnableAlternateScroll); - let _ = execute!(self.terminal.backend_mut(), EnableMouseCapture); if let Ok(size) = self.terminal.size() { self.alt_saved_viewport = Some(self.terminal.viewport_area); self.terminal.set_viewport_area(ratatui::layout::Rect::new( @@ -509,7 +505,6 @@ impl Tui { return Ok(()); } // Disable alternate scroll when leaving alt-screen - let _ = execute!(self.terminal.backend_mut(), DisableMouseCapture); let _ = execute!(self.terminal.backend_mut(), DisableAlternateScroll); let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen); if let Some(saved) = self.alt_saved_viewport.take() { diff --git a/crates/tui/src/tui/event_stream.rs b/crates/tui/src/tui/event_stream.rs index 02cf0f8..1baa717 100644 --- a/crates/tui/src/tui/event_stream.rs +++ b/crates/tui/src/tui/event_stream.rs @@ -249,7 +249,7 @@ impl TuiEventStream { } Event::Resize(_, _) => Some(TuiEvent::Draw), Event::Paste(pasted) => Some(TuiEvent::Paste(pasted)), - Event::Mouse(mouse_event) => Some(TuiEvent::Mouse(mouse_event)), + Event::Mouse(_) => None, Event::FocusGained => { self.terminal_focused.store(true, Ordering::Relaxed); self.needs_full_repaint.store(true, Ordering::Relaxed); @@ -303,6 +303,9 @@ mod tests { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; + use crossterm::event::MouseButton; + use crossterm::event::MouseEvent; + use crossterm::event::MouseEventKind; use pretty_assertions::assert_eq; use std::task::Context; use std::task::Poll; @@ -411,6 +414,12 @@ mod tests { let mut stream = make_stream(broker, draw_rx, terminal_focused, needs_full_repaint); handle.send(Ok(Event::FocusLost)); + handle.send(Ok(Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }))); handle.send(Ok(Event::Key(KeyEvent::new( KeyCode::Char('a'), KeyModifiers::NONE, diff --git a/crates/tui/src/tui/frame_requester.rs b/crates/tui/src/tui/frame_requester.rs index 6350900..c12f384 100644 --- a/crates/tui/src/tui/frame_requester.rs +++ b/crates/tui/src/tui/frame_requester.rs @@ -102,7 +102,6 @@ impl FrameScheduler { tokio::pin!(deadline); tokio::select! { - biased; draw_at = self.receiver.recv() => { let Some(draw_at) = draw_at else { // All senders dropped; exit the scheduler. @@ -114,10 +113,6 @@ impl FrameScheduler { // Do not send a draw immediately here. By continuing the loop, // we recompute the sleep target so the draw fires once via the // sleep branch, coalescing multiple requests into a single draw. - // - // The select is biased toward the deadline branch, so once the - // scheduled draw time is due it will not be starved by a hot - // stream of additional frame requests. continue; } _ = &mut deadline => { diff --git a/docs/spec-interactive-tui-v2.md b/docs/spec-interactive-tui-v2.md index ec4fd6e..2b66856 100644 --- a/docs/spec-interactive-tui-v2.md +++ b/docs/spec-interactive-tui-v2.md @@ -154,7 +154,7 @@ The TUI supports two screen modes toggled by `Ctrl+T`: **Inline mode (default)** — the TUI renders directly into the terminal scrollback. Mouse interaction is not available. Tool output cells are collapsed by default and can be expanded via keyboard (`Enter` on a selected cell). This mode preserves usable terminal scrollback after exit. -**Alternative screen mode** (`Ctrl+T` to enter) — the TUI switches to the terminal's alternate screen buffer. The transcript, composer, and all cells render identically to inline mode. Mouse events are captured so tool output cells can be clicked to expand or collapse. `Ctrl+T` toggles back to inline mode. +**Alternative screen mode** (`Ctrl+T` to enter) — the TUI switches to the terminal's alternate screen buffer and opens a read-only pager overlay. The pager renders the full transcript, including full tool output and any live in-flight output, without replacing the inline composer. `Ctrl+T`, `Esc`, `q`, or `Ctrl+C` closes the overlay and returns to inline mode. On exit, if the TUI is in alternative screen mode, it must switch back to inline mode first, then run the same terminal-safe teardown used by normal inline exit. @@ -296,8 +296,8 @@ Rules: | `Alt+Up` / `Alt+Down` | any | enter selection mode; move between user cells | | `Enter` | selection mode (on a user cell) | open action menu (Rollback / Fork / Cancel) | | `Esc` | selection mode | exit selection mode, return to composer | -| Mouse click | tool cell (alt-screen mode) | expand / collapse tool output | -| `Ctrl+T` | any | toggle inline / alternative screen mode | +| `Ctrl+T` | main view | open full transcript pager in alternative screen mode | +| `Ctrl+T` / `Esc` / `q` | transcript or diff pager | close pager and return to inline mode | | `Ctrl+C` | any | exit TUI | | `/exit` | composer | exit TUI | | `/` | composer | open slash command list | @@ -569,7 +569,7 @@ Each cell (user message, thinking, tool-ran, assistant reply) has: - `Thinking:` is italic with a distinct color - the rest of the text is gray -Tool-ran cells follow the same convention. Tool output is rendered collapsed by default. In inline mode, use keyboard (`Enter` on a selected cell) to expand or collapse. In alternative screen mode, the cell is clickable to expand or collapse. +Tool-ran cells follow the same convention. Tool output is rendered compactly in inline mode. Press `Ctrl+T` to open the transcript pager and view the full output. The tool cell distinguishes success and failure: