diff --git a/docs/superpowers/specs/2026-08-15-transcript-response-reader-design.md b/docs/superpowers/specs/2026-08-15-transcript-response-reader-design.md new file mode 100644 index 0000000..256cf34 --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-transcript-response-reader-design.md @@ -0,0 +1,93 @@ +# Transcript response reader + +## Purpose + +Completed assistant messages can be much taller than the terminal transcript +viewport. Their content is currently retained, but the only discovery path is +the transcript scroller. Add a focused reader so a completed answer is clearly +available without turning ordinary chat into a document viewer. + +## Scope + +- Improve the interactive Rust TUI transcript for completed assistant answers. +- Keep streamed output inline and keep the current transcript as the default + surface. +- Add an explicit, keyboard-first full-response reader. +- Preserve existing markdown rendering and message contents; do not alter + provider streaming, persistence, session format, tool execution, or exports. + +## Transcript card + +Every completed assistant response receives a compact completion footer: + +- `Response · N lines` where `N` is the rendered visual-line count. +- `Enter to read` as the primary affordance. +- Existing completion timing and collapsed tool summaries remain visible. + +The transcript continues to render the complete response normally. The footer +does not truncate or replace it; it makes the full-reader path discoverable. +The existing scrollbar remains the normal way to browse transcript history. + +## Reader interaction + +`Enter` opens the reader for the selected completed assistant response. If no +response is selected, the command applies to the latest completed assistant +response. The reader contains: + +- the full markdown-rendered assistant text; +- a stable position indicator (`line X / N` or equivalent); +- `PgUp`/`PgDn`, `j`/`k`, and `Home`/`End` navigation; +- in-reader text search and copy; +- `Esc` to return to the transcript at the same response and scroll position. + +Tool calls stay collapsed in the reader. The reader is a response-reading +surface, not a second execution history. + +## State and behavior + +- The reader is available for every completed assistant response, regardless + of length. This keeps the interaction predictable; the visible line count + communicates when it is useful. +- It never opens automatically when a turn finishes or when a background task + completes. +- Starting a new user turn closes the reader and returns focus to the prompt. +- While a turn is streaming, no reader affordance is shown for its unfinished + text. Completed prior responses remain readable. +- Empty assistant messages and assistant messages containing only tool calls + do not offer the reader. + +## Architecture + +Add a TUI-local `ResponseReaderState` that owns only: + +- the target message index; +- its vertical scroll offset; +- query/search state; and +- the transcript scroll position to restore on exit. + +The reader obtains content from the existing in-memory `App.messages` entry. +It reuses `render_markdown` and does not duplicate response text or modify the +persisted `ConversationSession`. Rendering and input handling are isolated in +a dedicated reader module, with the app routing commands based on reader +visibility. + +## Validation + +Add focused TUI tests that prove: + +1. A 6,000+ character completed assistant response retains all text in the + normal transcript render. +2. The transcript footer exposes the rendered line count and reader affordance. +3. Opening the reader targets the expected assistant message and renders the + complete response. +4. Reader navigation changes only reader scroll state. +5. `Esc` restores the prior transcript position; a new prompt exits reader + mode and submits normally. +6. Tool-only/empty assistant messages do not offer a reader. + +## Non-goals + +- Auto-opening answers, response summarization, content truncation, or a new + persistence format. +- Changing provider output, model behavior, or streamed-event handling. +- Replacing the transcript scrollbar with a separate response navigator. diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index e8f6cc7..8a17f2b 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -2599,6 +2599,15 @@ async fn run_interactive( continue; } if key.code == KeyCode::Enter && !app.is_streaming && !any_dialog_open { + // A plain Enter on an empty prompt opens the latest completed + // response reader. Route it through App before take_input(), + // which would otherwise consume the key as a no-op. + if app.prompt_input.text.is_empty() + && app.prompt_input.suggestion_index.is_none() + { + app.handle_key_event(key); + continue; + } // If a file-ref suggestion is active, accept it instead of submitting. if !app.prompt_input.suggestions.is_empty() && app.prompt_input.suggestion_index.is_some() @@ -3281,6 +3290,7 @@ async fn run_interactive( } // Start async query + app.close_response_reader(); app.is_streaming = true; app.streaming_text.clear(); diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 33aee9b..ca902ff 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -18,6 +18,7 @@ use crate::overlays::{ use crate::plugin_views::PluginHintBanner; use crate::prompt_input::{InputMode, PromptInputState, VimMode}; use crate::render; +use crate::response_reader::{response_reader_text, ResponseReaderState}; use crate::session_browser::SessionBrowserState; use crate::settings_screen::SettingsScreen; use crate::stats_dialog::StatsDialogState; @@ -762,6 +763,8 @@ pub struct App { pub input_history: Vec, pub history_index: Option, pub scroll_offset: usize, + /// Transcript message selected by a pointer interaction, when any. + pub selected_transcript_message: Option, pub is_streaming: bool, pub streaming_text: String, pub streaming_thinking: String, @@ -852,6 +855,8 @@ pub struct App { pub message_selector: MessageSelectorOverlay, /// Multi-step rewind flow overlay. pub rewind_flow: RewindFlowOverlay, + /// Full-screen reader for a completed assistant response. + pub response_reader: ResponseReaderState, /// Bridge connection state. pub bridge_state: BridgeConnectionState, /// Active notification queue. @@ -1354,6 +1359,7 @@ impl App { input_history: Vec::new(), history_index: None, scroll_offset: 0, + selected_transcript_message: None, is_streaming: false, streaming_text: String::new(), streaming_thinking: String::new(), @@ -1398,6 +1404,7 @@ impl App { global_search: GlobalSearchState::default(), message_selector: MessageSelectorOverlay::new(), rewind_flow: RewindFlowOverlay::new(), + response_reader: ResponseReaderState::default(), bridge_state: BridgeConnectionState::Disconnected, notifications: NotificationQueue::new(), error_modal_scroll_offset: 0, @@ -2439,7 +2446,9 @@ impl App { true } "clear" => { + self.close_response_reader(); self.messages.clear(); + self.selected_transcript_message = None; self.system_annotations.clear(); self.display_messages.clear(); self.streaming_text.clear(); @@ -2665,7 +2674,8 @@ impl App { /// (overage / voice / memory), which render as overlays but let the user /// keep typing underneath. pub fn any_blocking_modal_open(&self) -> bool { - self.permission_request.is_some() + self.response_reader.visible + || self.permission_request.is_some() || self.rate_limit_recovery.visible || self.rewind_flow.visible || self.tasks_overlay.visible @@ -2958,6 +2968,20 @@ impl App { pub fn replace_messages(&mut self, messages: Vec) { self.messages = messages; + + let reader_message_gone = self.response_reader.message_index.is_some_and(|index| { + self.messages + .get(index) + .map(|m| response_reader_text(m).trim().is_empty()) + .unwrap_or(true) + }); + if reader_message_gone { + self.close_response_reader(); + } + + self.selected_transcript_message = self + .selected_transcript_message + .filter(|&index| index < self.messages.len()); self.sync_turn_metadata_to_messages(); self.invalidate_transcript(); } @@ -2972,6 +2996,148 @@ impl App { self.on_new_message(); } + /// Open the most recent completed assistant message with text in the response reader. + pub fn open_latest_response_reader(&mut self) -> bool { + let is_eligible = |message: &Message| { + message.role == Role::Assistant && !response_reader_text(message).trim().is_empty() + }; + let selected = self + .selected_transcript_message + .filter(|&index| self.messages.get(index).is_some_and(is_eligible)); + let Some(message_index) = selected.or_else(|| { + self.messages + .iter() + .enumerate() + .rev() + .find(|(_, message)| is_eligible(message)) + .map(|(index, _)| index) + }) else { + return false; + }; + + self.response_reader.open(message_index, self.scroll_offset); + true + } + + /// Close the reader and restore the exact transcript position captured when it opened. + pub fn close_response_reader(&mut self) { + if let Some(offset) = self.response_reader.close() { + self.scroll_offset = offset; + } + } + + fn response_reader_text(&self) -> Option { + self.response_reader + .message_index + .and_then(|index| self.messages.get(index)) + .map(response_reader_text) + } + + fn response_reader_match(&self, start: usize) -> Option { + let query = self.response_reader.search_query.to_lowercase(); + if query.is_empty() { + return None; + } + + let area = self.last_selectable_area.get(); + let content_width = area.width.saturating_sub(6); + let text = self.response_reader_text()?; + let lines = crate::messages::render_markdown(&text, content_width); + let line_matches = |line: &ratatui::text::Line<'_>| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + .to_lowercase() + .contains(&query) + }; + + lines + .iter() + .enumerate() + .skip(start) + .chain(lines.iter().enumerate().take(start)) + .find_map(|(index, line)| line_matches(line).then_some(index)) + } + + fn update_response_reader_search(&mut self) { + if let Some(match_offset) = self.response_reader_match(0) { + self.response_reader.scroll_offset = match_offset; + } + } + + fn advance_response_reader_search(&mut self) { + let start = self.response_reader.scroll_offset.saturating_add(1); + if let Some(match_offset) = self.response_reader_match(start) { + self.response_reader.scroll_offset = match_offset; + } + } + + fn copy_response_reader_text(&mut self) { + let Some(text) = self.response_reader_text() else { + self.push_notification( + NotificationKind::Warning, + "No response text to copy.".to_string(), + Some(3), + ); + return; + }; + + if try_copy_to_clipboard(&text) { + self.push_notification( + NotificationKind::Info, + "Copied response to clipboard.".to_string(), + Some(3), + ); + } else { + self.push_notification( + NotificationKind::Info, + format!("Response: {} chars (clipboard unavailable)", text.len()), + Some(5), + ); + } + } + + fn handle_response_reader_key(&mut self, key: KeyEvent) { + let area = self.last_selectable_area.get(); + let viewport_height = usize::from(area.height.saturating_sub(8)).max(1); + let content_width = area.width.saturating_sub(6); + let line_count = self + .response_reader_text() + .map(|text| crate::messages::render_markdown(&text, content_width).len()) + .unwrap_or(0); + + match key.code { + KeyCode::Esc => self.close_response_reader(), + KeyCode::Char('/') if !self.response_reader.search_active => { + if self.response_reader.search_query.is_empty() { + self.response_reader.search_active = true; + } else { + self.advance_response_reader_search(); + } + } + KeyCode::Enter if self.response_reader.search_active => { + self.response_reader.search_active = false; + } + KeyCode::Backspace if self.response_reader.search_active => { + self.response_reader.search_query.pop(); + self.update_response_reader_search(); + } + KeyCode::Char(ch) if self.response_reader.search_active => { + self.response_reader.search_query.push(ch); + self.update_response_reader_search(); + } + KeyCode::Char('y') => self.copy_response_reader_text(), + KeyCode::PageUp | KeyCode::Char('k') => self.response_reader.page_up(viewport_height), + KeyCode::PageDown | KeyCode::Char('j') => { + self.response_reader.page_down(viewport_height, line_count) + } + KeyCode::Home => self.response_reader.home(), + KeyCode::End => self.response_reader.end(viewport_height, line_count), + _ => {} + } + } + /// Push a synthetic system annotation into the conversation pane. /// It will appear after the current last message. /// Push a notification and, for Error-kind notifications, reset the error @@ -3420,6 +3586,12 @@ impl App { /// Process a keyboard event. Returns `true` when the input should be /// submitted (Enter pressed with no blocking dialog). pub fn handle_key_event(&mut self, key: KeyEvent) -> bool { + // The reader owns its visible keys before any normal TUI interaction. + if self.response_reader.visible { + self.handle_response_reader_key(key); + return false; + } + // Permission requests render above other overlays, so they own input. if self.permission_request.is_some() { self.handle_permission_key(key); @@ -4400,6 +4572,18 @@ impl App { } } + // Plain Enter opens the latest completed response when the prompt is + // empty and no typeahead item is selected. This must run before the + // user keybinding resolver, which normally claims Enter as submit. + if key.code == KeyCode::Enter + && !self.is_streaming + && self.prompt_input.text.is_empty() + && self.prompt_input.suggestion_index.is_none() + && self.open_latest_response_reader() + { + return false; + } + // ---- Keybinding processor (runs AFTER all dialog checks) ---------- let key_context = self.current_key_context(); if let Some(keystroke) = key_event_to_keystroke(&key) { @@ -6513,6 +6697,7 @@ impl App { self.click_count = 0; } else if in_selectable { self.focus = FocusTarget::Transcript; + self.selected_transcript_message = self.message_index_at_row(mouse_event.row); let current_pos = (mouse_event.column, mouse_event.row); let now = std::time::Instant::now(); @@ -6637,6 +6822,7 @@ impl App { match event { QueryEvent::Stream(stream_evt) => { + self.close_response_reader(); if !self.is_streaming { let seed = self.frame_count as usize ^ (self.messages.len() * 17); self.spinner_verb = Some(sample_spinner_verb(seed).to_string()); @@ -7752,6 +7938,118 @@ role = "Research" assert_eq!(app.messages.len(), 0); } + #[test] + fn empty_plain_enter_opens_latest_completed_text_response() { + let mut app = make_app(); + app.add_message(Role::User, "hello".to_string()); + app.add_message(Role::Assistant, "first response".to_string()); + app.add_message(Role::Assistant, "latest response".to_string()); + app.scroll_offset = 37; + + assert!(app.prompt_input.text.is_empty()); + assert!(app.prompt_input.suggestion_index.is_none()); + assert!(!app.handle_key_event(press_key(KeyCode::Enter, KeyModifiers::NONE))); + assert!(app.response_reader.visible); + assert_eq!(app.response_reader.message_index, Some(2)); + assert_eq!(app.response_reader.restore_transcript_offset, 37); + } + + #[test] + fn empty_plain_enter_opens_the_selected_completed_response() { + let mut app = make_app(); + app.add_message(Role::User, "hello".to_string()); + app.add_message(Role::Assistant, "selected response".to_string()); + app.add_message(Role::Assistant, "latest response".to_string()); + app.selected_transcript_message = Some(1); + + assert!(!app.handle_key_event(press_key(KeyCode::Enter, KeyModifiers::NONE))); + assert_eq!(app.response_reader.message_index, Some(1)); + } + + #[test] + fn reader_escape_restores_exact_transcript_offset_and_preserves_prompt() { + let mut app = make_app(); + app.add_message(Role::Assistant, "completed response".to_string()); + app.set_prompt_text("draft prompt".to_string()); + app.scroll_offset = 19; + assert!(app.open_latest_response_reader()); + + app.scroll_offset = 0; + assert!(!app.handle_key_event(press_key(KeyCode::Esc, KeyModifiers::NONE))); + assert!(!app.response_reader.visible); + assert_eq!(app.scroll_offset, 19); + assert_eq!(app.prompt_input.text, "draft prompt"); + } + + #[test] + fn plain_enter_with_prompt_still_submits_without_opening_reader() { + let mut app = make_app(); + app.add_message(Role::Assistant, "completed response".to_string()); + app.set_prompt_text("send this".to_string()); + + assert!(app.handle_key_event(press_key(KeyCode::Enter, KeyModifiers::NONE))); + assert!(!app.response_reader.visible); + assert_eq!(app.prompt_input.text, "send this"); + } + + #[test] + fn reader_search_enters_query_and_repeats_to_the_next_match() { + let mut app = make_app(); + app.add_message( + Role::Assistant, + "intro\nneedle first\nspacing\nneedle second".to_string(), + ); + app.last_selectable_area + .set(ratatui::layout::Rect::new(0, 0, 80, 20)); + assert!(app.open_latest_response_reader()); + + app.handle_key_event(press_key(KeyCode::Char('/'), KeyModifiers::NONE)); + for ch in "needle".chars() { + app.handle_key_event(press_key(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert!(app.response_reader.search_active); + assert_eq!(app.response_reader.search_query, "needle"); + let first_match = app.response_reader.scroll_offset; + + app.handle_key_event(press_key(KeyCode::Enter, KeyModifiers::NONE)); + assert!(!app.response_reader.search_active); + app.handle_key_event(press_key(KeyCode::Char('/'), KeyModifiers::NONE)); + assert!(app.response_reader.scroll_offset > first_match); + } + + #[test] + fn reader_y_copies_its_target_response_and_notifies() { + let mut app = make_app(); + app.add_message(Role::Assistant, "copy this completed response".to_string()); + assert!(app.open_latest_response_reader()); + assert_eq!( + app.response_reader_text().as_deref(), + Some("copy this completed response") + ); + + app.handle_key_event(press_key(KeyCode::Char('y'), KeyModifiers::NONE)); + let notification = app.notifications.current().expect("copy should notify"); + assert!( + notification.message.contains("Copied") + || notification.message.contains("clipboard unavailable"), + "unexpected copy notification: {}", + notification.message + ); + } + + #[test] + fn reader_blocks_cli_submit_and_retains_a_preserved_draft() { + let mut app = make_app(); + app.add_message(Role::Assistant, "completed response".to_string()); + app.set_prompt_text("draft that must not submit".to_string()); + assert!(app.open_latest_response_reader()); + + assert!(app.any_blocking_modal_open()); + assert!(!app.handle_key_event(press_key(KeyCode::Enter, KeyModifiers::NONE))); + assert!(app.response_reader.visible); + assert_eq!(app.prompt_input.text, "draft that must not submit"); + } + #[test] fn test_exit_slash_command_sets_quit_flag() { let mut app = make_app(); diff --git a/src-rust/crates/tui/src/lib.rs b/src-rust/crates/tui/src/lib.rs index 882bb7d..8b41c8b 100644 --- a/src-rust/crates/tui/src/lib.rs +++ b/src-rust/crates/tui/src/lib.rs @@ -158,6 +158,8 @@ pub mod prompt_input; pub mod rate_limit_recovery; /// All ratatui rendering logic. pub mod render; +/// Full-response reader for completed assistant messages. +pub mod response_reader; /// Session branching overlay (Ctrl+B) — create and switch between conversation branches. pub mod session_branching; /// Session browser overlay (/session, /resume, /rename, /export). diff --git a/src-rust/crates/tui/src/render.rs b/src-rust/crates/tui/src/render.rs index 159b5b5..0cdd006 100644 --- a/src-rust/crates/tui/src/render.rs +++ b/src-rust/crates/tui/src/render.rs @@ -43,6 +43,7 @@ use crate::overlays::{ }; use crate::plugin_views::render_plugin_hints; use crate::prompt_input::{input_height, render_prompt_input, InputMode, TypeaheadSource, VimMode}; +use crate::response_reader::{render_response_reader, response_reader_text}; use crate::session_branching::render_session_branching; use crate::session_browser::render_session_browser; use crate::settings_screen::render_settings_screen; @@ -53,7 +54,7 @@ use crate::transcript_turn::{build_transcript_turns, TranscriptTurn}; use crate::virtual_list::{VirtualItem, VirtualList}; use crate::voice_mode_notice::render_voice_mode_notice; use claurst_core::constants::APP_VERSION; -use claurst_core::types::Role; +use claurst_core::types::{ContentBlock, Message, Role}; use ratatui::buffer::Buffer; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; @@ -526,6 +527,14 @@ pub fn render_app(frame: &mut Frame, app: &App) { // Overlays (rendered on top in Z-order) + if let Some(message) = app + .response_reader + .message_index + .and_then(|index| app.messages.get(index)) + { + render_response_reader(frame, &app.response_reader, message, size); + } + // Rewind flow (takes over screen) if app.rewind_flow.visible { render_rewind_flow(frame, &app.rewind_flow, size); @@ -1329,6 +1338,52 @@ fn render_live_thinking_lines( lines } +/// Build the reader affordance for a completed assistant message with visible text. +/// The line count follows the normal tagged assistant renderer's text-section +/// boundaries, which flush around thinking and tool blocks. +fn response_reader_footer(message: &Message, width: u16) -> Option> { + if response_reader_text(message).trim().is_empty() { + return None; + } + + let line_count = rendered_assistant_text_line_count(message, width); + Some(Line::from(Span::styled( + format!(" Response · {line_count} lines · Enter to read"), + Style::default().fg(COVEN_CODE_MUTED), + ))) +} + +/// Count only text lines exactly as `render_transcript_assistant_message_tagged` +/// renders them: adjacent text blocks are joined, while every non-text block +/// flushes the pending text section first. +fn rendered_assistant_text_line_count(message: &Message, width: u16) -> usize { + let mut pending_text = String::new(); + let mut line_count = 0; + + let flush = |pending_text: &mut String, line_count: &mut usize| { + if pending_text.is_empty() { + return; + } + *line_count += render_transcript_live_text(pending_text, width).len(); + pending_text.clear(); + }; + + for block in message.content_blocks() { + match block { + ContentBlock::Text { text } => { + if !pending_text.is_empty() { + pending_text.push('\n'); + } + pending_text.push_str(&text); + } + _ => flush(&mut pending_text, &mut line_count), + } + } + flush(&mut pending_text, &mut line_count); + + line_count +} + fn append_turn_items( items: &mut Vec, turn: &TranscriptTurn<'_>, @@ -1423,6 +1478,12 @@ fn append_turn_items( )); } } + + for (message_index, message) in &turn.assistant_messages { + if let Some(footer) = response_reader_footer(message, width) { + sections.push((SectionContent::Plain(vec![footer]), Some(*message_index))); + } + } } if !sections.is_empty() { @@ -3870,3 +3931,148 @@ mod welcome_tests { } } } + +#[cfg(test)] +mod transcript_response_reader_tests { + use super::*; + use claurst_core::{ + config::Config, + cost::CostTracker, + types::{ContentBlock, Message}, + }; + + fn make_app() -> App { + App::new(Config::default(), CostTracker::new()) + } + + fn rendered_transcript(app: &App, width: u16) -> String { + render_message_items(app, width) + .iter() + .map(|item| item.search_text.as_str()) + .collect::>() + .join("\n") + } + + #[test] + fn completed_text_response_keeps_all_lines_and_gets_reader_footer() { + let mut app = make_app(); + app.push_message(Message::user("show the complete result")); + let response = (0..1_500) + .map(|line| format!("response line {line:04}")) + .collect::>() + .join("\n"); + app.push_message(Message::assistant(response)); + + let items = render_message_items(&app, 120); + let rendered = items + .iter() + .map(|item| item.search_text.as_str()) + .collect::>() + .join("\n"); + + assert_eq!(rendered.matches("response line ").count(), 1_500); + for expected in [ + "response line 0000", + "response line 0750", + "response line 1499", + ] { + assert!(rendered.contains(expected), "missing {expected}"); + } + assert!( + rendered.contains("Response · 1500 lines · Enter to read"), + "missing reader footer: {rendered}" + ); + assert_eq!( + items + .iter() + .find(|item| item.search_text.contains("Enter to read")) + .and_then(|item| item.message_index), + Some(1), + "reader footer must target the final assistant message" + ); + } + + #[test] + fn completed_tool_only_and_empty_responses_do_not_get_reader_footer() { + let mut tool_only = make_app(); + tool_only.push_message(Message::user("run a tool")); + tool_only.push_message(Message::assistant_blocks(vec![ContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "Bash".to_string(), + input: serde_json::json!({"command": "true"}), + }])); + assert!( + !rendered_transcript(&tool_only, 120).contains("Enter to read"), + "tool-only responses must not advertise the reader" + ); + + let mut empty = make_app(); + empty.push_message(Message::user("respond with nothing")); + empty.push_message(Message::assistant(" \n\t")); + assert!( + !rendered_transcript(&empty, 120).contains("Enter to read"), + "empty responses must not advertise the reader" + ); + } + + #[test] + fn completed_multiblock_response_counts_normal_text_sections() { + let mut app = make_app(); + app.push_message(Message::user("separate the response sections")); + app.push_message(Message::assistant_blocks(vec![ + ContentBlock::Text { + text: "one".to_string(), + }, + ContentBlock::Thinking { + thinking: "work through it".to_string(), + signature: String::new(), + }, + ContentBlock::Text { + text: "two".to_string(), + }, + ])); + + let rendered = rendered_transcript(&app, 120); + + assert!(rendered.contains("Response · 2 lines · Enter to read")); + } + + #[test] + fn every_completed_assistant_message_gets_a_tagged_reader_footer() { + let mut app = make_app(); + app.push_message(Message::user("show two responses")); + app.push_message(Message::assistant("first completed response")); + app.push_message(Message::assistant("second completed response")); + + let footer_targets = render_message_items(&app, 120) + .iter() + .filter(|item| item.search_text.contains("Enter to read")) + .filter_map(|item| item.message_index) + .collect::>(); + + assert_eq!(footer_targets, vec![1, 2]); + } + + #[test] + fn active_and_thinking_only_responses_do_not_get_reader_footer() { + let mut active = make_app(); + active.push_message(Message::user("keep working")); + active.push_message(Message::assistant("partial response")); + active.is_streaming = true; + assert!( + !rendered_transcript(&active, 120).contains("Enter to read"), + "active responses must not advertise the reader" + ); + + let mut thinking_only = make_app(); + thinking_only.push_message(Message::user("think privately")); + thinking_only.push_message(Message::assistant_blocks(vec![ContentBlock::Thinking { + thinking: "private reasoning".to_string(), + signature: String::new(), + }])); + assert!( + !rendered_transcript(&thinking_only, 120).contains("Enter to read"), + "thinking-only responses must not advertise the reader" + ); + } +} diff --git a/src-rust/crates/tui/src/response_reader.rs b/src-rust/crates/tui/src/response_reader.rs new file mode 100644 index 0000000..7bcd2d4 --- /dev/null +++ b/src-rust/crates/tui/src/response_reader.rs @@ -0,0 +1,314 @@ +//! Full-screen reader for completed assistant response text. + +use claurst_core::types::{ContentBlock, Message}; +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::{ + messages::render_markdown, + overlays::{ + centered_rect, render_dark_overlay, render_dialog_bg, COVEN_CODE_ACCENT, COVEN_CODE_MUTED, + }, +}; + +/// TUI-local state for a reader opened from the transcript. +#[derive(Debug, Clone, Default)] +pub struct ResponseReaderState { + pub visible: bool, + pub message_index: Option, + pub scroll_offset: usize, + pub restore_transcript_offset: usize, + pub search_query: String, + pub search_active: bool, +} + +/// Reconstruct visible assistant text with the transcript's section boundaries. +/// Non-text blocks are omitted, but text that resumes after one starts on a new line. +pub fn response_reader_text(message: &Message) -> String { + message + .content_blocks() + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n") +} + +impl ResponseReaderState { + /// Open the reader for one transcript message, remembering where to return. + pub fn open(&mut self, message_index: usize, restore_offset: usize) { + self.visible = true; + self.message_index = Some(message_index); + self.scroll_offset = 0; + self.restore_transcript_offset = restore_offset; + self.search_query.clear(); + self.search_active = false; + } + + /// Close the reader and return the transcript offset captured on open. + pub fn close(&mut self) -> Option { + if !self.visible { + return None; + } + + self.visible = false; + self.message_index = None; + self.scroll_offset = 0; + let restore_offset = self.restore_transcript_offset; + self.restore_transcript_offset = 0; + self.search_query.clear(); + self.search_active = false; + Some(restore_offset) + } + + /// Scroll down one viewport without passing the last complete viewport. + pub fn page_down(&mut self, viewport_height: usize, line_count: usize) { + let max_offset = line_count.saturating_sub(viewport_height); + self.scroll_offset = self + .scroll_offset + .saturating_add(viewport_height) + .min(max_offset); + } + + /// Scroll up one viewport. + pub fn page_up(&mut self, viewport_height: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(viewport_height); + } + + /// Scroll to the first rendered line. + pub fn home(&mut self) { + self.scroll_offset = 0; + } + + /// Scroll to the final complete viewport. + pub fn end(&mut self, viewport_height: usize, line_count: usize) { + self.scroll_offset = line_count.saturating_sub(viewport_height); + } +} + +/// Render a response reader containing only the message's text content. +pub fn render_response_reader( + frame: &mut Frame, + state: &ResponseReaderState, + message: &Message, + area: Rect, +) { + if !state.visible { + return; + } + + let dialog_area = centered_rect( + area.width.saturating_sub(4), + area.height.saturating_sub(4), + area, + ); + render_dark_overlay(frame, area); + frame.render_widget(Clear, dialog_area); + render_dialog_bg(frame, dialog_area); + + let inner_area = Rect { + x: dialog_area.x.saturating_add(1), + y: dialog_area.y.saturating_add(1), + width: dialog_area.width.saturating_sub(2), + height: dialog_area.height.saturating_sub(2), + }; + let body_area = Rect { + x: inner_area.x, + y: inner_area.y.saturating_add(1), + width: inner_area.width, + height: inner_area.height.saturating_sub(2), + }; + let lines = render_markdown(&response_reader_text(message), body_area.width); + let line_count = lines.len(); + let scroll_offset = state + .scroll_offset + .min(line_count.saturating_sub(body_area.height as usize)); + let line_position = if line_count == 0 { + 0 + } else { + scroll_offset + 1 + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(COVEN_CODE_ACCENT)); + frame.render_widget(block, dialog_area); + let mut header = vec![ + Span::styled("Reader", Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + format!(" · line {line_position} / {line_count}"), + Style::default().fg(COVEN_CODE_MUTED), + ), + ]; + if !state.search_query.is_empty() { + header.push(Span::styled( + format!(" · /{}", state.search_query), + Style::default().fg(COVEN_CODE_MUTED), + )); + } + frame.render_widget( + Paragraph::new(Line::from(header)), + Rect { + x: inner_area.x, + y: inner_area.y, + width: inner_area.width, + height: 1, + }, + ); + let visible_lines: Vec<_> = lines + .into_iter() + .skip(scroll_offset) + .take(body_area.height as usize) + .collect(); + frame.render_widget(Paragraph::new(visible_lines), body_area); + frame.render_widget( + Paragraph::new(Line::styled( + if state.search_active { + "Type search Enter done Esc close" + } else { + "PgUp/PgDn j/k / search y copy Esc close" + }, + Style::default().fg(COVEN_CODE_MUTED), + )), + Rect { + x: inner_area.x, + y: inner_area.y.saturating_add(inner_area.height.saturating_sub(1)), + width: inner_area.width, + height: 1, + }, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use claurst_core::types::{ContentBlock, Message}; + use ratatui::{backend::TestBackend, Terminal}; + + #[test] + fn open_resets_reader_state_and_close_restores_transcript_offset() { + let mut state = ResponseReaderState { + visible: false, + message_index: Some(2), + scroll_offset: 7, + restore_transcript_offset: 0, + search_query: "old".to_string(), + search_active: true, + }; + + state.open(4, 12); + + assert!(state.visible); + assert_eq!(state.message_index, Some(4)); + assert_eq!(state.scroll_offset, 0); + assert_eq!(state.restore_transcript_offset, 12); + assert!(state.search_query.is_empty()); + assert!(!state.search_active); + assert_eq!(state.close(), Some(12)); + assert!(!state.visible); + assert_eq!(state.message_index, None); + assert_eq!(state.scroll_offset, 0); + assert_eq!(state.restore_transcript_offset, 0); + assert!(state.search_query.is_empty()); + assert!(!state.search_active); + } + + #[test] + fn navigation_stays_within_rendered_line_bounds() { + let mut state = ResponseReaderState::default(); + + state.page_down(4, 10); + assert_eq!(state.scroll_offset, 4); + state.page_down(4, 10); + assert_eq!(state.scroll_offset, 6); + state.page_up(4); + assert_eq!(state.scroll_offset, 2); + state.home(); + assert_eq!(state.scroll_offset, 0); + state.end(4, 10); + assert_eq!(state.scroll_offset, 6); + state.end(8, 3); + assert_eq!(state.scroll_offset, 0); + } + + #[test] + fn reader_text_separates_text_sections_around_non_text_blocks() { + let message = Message::assistant_blocks(vec![ + ContentBlock::Text { + text: "one".to_string(), + }, + ContentBlock::Thinking { + thinking: "internal work".to_string(), + signature: String::new(), + }, + ContentBlock::Text { + text: "two".to_string(), + }, + ]); + + assert_eq!(response_reader_text(&message), "one\ntwo"); + } + + #[test] + fn render_shows_text_lines_and_omits_tool_blocks() { + let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); + let mut state = ResponseReaderState::default(); + state.open(0, 3); + let message = Message::assistant_blocks(vec![ + ContentBlock::Text { + text: "# Response\n\nvisible reader text".to_string(), + }, + ContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "secret_tool".to_string(), + input: serde_json::json!({}), + }, + ]); + + terminal + .draw(|frame| render_response_reader(frame, &state, &message, frame.area())) + .unwrap(); + + let content: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + assert!(content.contains("Reader")); + assert!(content.contains("line 1 /")); + assert!(content.contains("visible reader text")); + assert!(!content.contains("secret_tool")); + assert!(content.contains("PgUp/PgDn")); + } + + #[test] + fn render_keeps_tail_visible_after_more_than_u16_lines() { + let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); + let mut state = ResponseReaderState::default(); + state.open(0, 0); + let message = Message::assistant(format!("{}TAIL MARKER", "line\n".repeat(70_000))); + state.end(22, 70_001); + + terminal + .draw(|frame| render_response_reader(frame, &state, &message, frame.area())) + .unwrap(); + + let content: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + assert!(content.contains("TAIL MARKER")); + } +}