diff --git a/CHANGELOG.md b/CHANGELOG.md index d174539a7c..632d2bc263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Session token totals now include display-only per-model-call deltas while a turn is running, including input/output and cache-class counters; the authoritative `TurnComplete` totals still reconcile exactly once (#5581). +- Transcript focus now exposes per-block actions: `y` copies content, `Y` + copies the rendered metadata view, Enter opens a fullscreen block pager, and + `r` opens raw detail; the existing Tasks rail shortcuts remain unchanged + (#5551). - Provider neutrality (#5588): model resolution of omitted/aliased models is now provider-relative, OpenAI-native defaults no longer route through another provider's table, CLI credentials stay provider-scoped, and NVIDIA diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 4c77456357..78660ce285 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -83,6 +83,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Session token totals now include display-only per-model-call deltas while a turn is running, including input/output and cache-class counters; the authoritative `TurnComplete` totals still reconcile exactly once (#5581). +- Transcript focus now exposes per-block actions: `y` copies content, `Y` + copies the rendered metadata view, Enter opens a fullscreen block pager, and + `r` opens raw detail; the existing Tasks rail shortcuts remain unchanged + (#5551). - Provider neutrality (#5588): model resolution of omitted/aliased models is now provider-relative, OpenAI-native defaults no longer route through another provider's table, CLI credentials stay provider-scoped, and NVIDIA diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 8613cc1661..cbe3055c55 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -163,11 +163,12 @@ use super::widgets::{ChatWidget, ComposerWidget, Renderable}; // import the ui-internal entry points used from this file's own body. pub(crate) use self::activity_detail::{ completed_assistant_answer_text, copy_cell_to_clipboard, detail_target_label, - open_details_pager_for_cell, turn_handoff_markdown, + open_details_pager_for_cell, open_focused_cell_pager, turn_handoff_markdown, }; use self::activity_detail::{ - copy_focused_cell, detail_target_cell_index, extract_reasoning_header, - open_reasoning_detail_pager, open_tool_details_pager, open_turn_inspector_pager, + copy_focused_cell, copy_focused_cell_metadata, detail_target_cell_index, + extract_reasoning_header, open_reasoning_detail_pager, open_tool_details_pager, + open_turn_inspector_pager, }; // Ctrl+O now opens the full recorded Reasoning Detail for the selected or // current reasoning block. The whole-turn Turn Inspector moved to Ctrl+Alt+O diff --git a/crates/tui/src/tui/ui/activity_detail.rs b/crates/tui/src/tui/ui/activity_detail.rs index 770181c8a1..15d354d74c 100644 --- a/crates/tui/src/tui/ui/activity_detail.rs +++ b/crates/tui/src/tui/ui/activity_detail.rs @@ -11,10 +11,10 @@ use crate::localization::{MessageId, tr}; use crate::snapshot::SnapshotRepo; use crate::tui::app::App; use crate::tui::footer_ui::one_line_summary; -use crate::tui::history::{HistoryCell, ToolCell, ToolStatus}; +use crate::tui::history::{HistoryCell, ToolCell, ToolStatus, TranscriptRenderOptions}; use crate::tui::pager::{PagerPage, PagerView}; use crate::tui::ui_text::{ - history_cell_to_clipboard_text, history_cell_to_text, truncate_line_to_width, + history_cell_to_clipboard_text, history_cell_to_text, line_to_plain, truncate_line_to_width, }; fn selected_transcript_cell_index(app: &App) -> Option { @@ -503,6 +503,38 @@ pub(crate) fn open_details_pager_for_cell(app: &mut App, cell_index: usize) -> b true } +/// Open the focused transcript cell as a full-screen readable pager. +pub(crate) fn open_focused_cell_pager(app: &mut App) -> bool { + let Some(cell_index) = detail_target_cell_index(app) else { + return false; + }; + let Some(cell) = app.cell_at_virtual_index(cell_index) else { + return false; + }; + let title = match cell { + HistoryCell::User { .. } => "You", + HistoryCell::Assistant { .. } => "Assistant", + HistoryCell::System { .. } => "Note", + HistoryCell::Error { .. } => "Error", + HistoryCell::Thinking { .. } => "Reasoning", + HistoryCell::Tool(_) => "Tool", + HistoryCell::SubAgent(_) => "Sub-agent", + HistoryCell::ArchivedContext { .. } => "Archived Context", + }; + let width = app + .viewport + .last_transcript_area + .map(|area| area.width) + .unwrap_or(80); + let content = history_cell_to_text(cell, width); + let mut pager = PagerView::from_text(title, &content, width.saturating_sub(2)); + if let Some(answer) = completed_assistant_answer_text(cell, width) { + pager = pager.with_copy_answer(answer); + } + app.view_stack.push(pager); + true +} + /// Copy the "focused" transcript cell to the system clipboard. /// The focused cell is determined by the detail-target heuristic /// (viewport centre or most recent cell). Returns true when text @@ -515,6 +547,40 @@ pub(super) fn copy_focused_cell(app: &mut App) -> bool { copy_cell_to_clipboard(app, index) } +/// Copy the focused cell with the transcript's role/metadata presentation. +/// Unlike content copy, this retains the metadata prefixes used by the +/// transcript surface and is useful for sharing a receipt or event record. +pub(super) fn copy_focused_cell_metadata(app: &mut App) -> bool { + let Some(index) = detail_target_cell_index(app) else { + return false; + }; + let Some(cell) = app.cell_at_virtual_index(index) else { + return false; + }; + let width = app + .viewport + .last_transcript_area + .map(|area| area.width) + .unwrap_or(80); + let text = cell + .lines_with_copy_metadata(width, TranscriptRenderOptions::default()) + .into_iter() + .map(|line| line_to_plain(&line.line)) + .collect::>() + .join("\n"); + if text.trim().is_empty() { + app.status_message = Some("Message is empty".to_string()); + return false; + } + if app.clipboard.write_text(&text).is_ok() { + app.status_message = Some("Message metadata copied".to_string()); + true + } else { + app.status_message = Some("Copy failed".to_string()); + false + } +} + pub(crate) fn copy_cell_to_clipboard(app: &mut App, cell_index: usize) -> bool { let Some(cell) = app.cell_at_virtual_index(cell_index) else { app.status_message = Some("No message at that line".to_string()); @@ -1935,6 +2001,34 @@ mod tests { assert_eq!(app.clipboard.last_written_text(), Some(content)); } + #[test] + fn focused_pager_and_metadata_copy_use_the_same_cell_target() { + let mut app = test_app(); + app.history = vec![HistoryCell::Assistant { + content: "focused markdown **answer**".to_string(), + streaming: false, + }]; + app.resync_history_revisions(); + app.viewport.last_transcript_area = Some(ratatui::layout::Rect { + x: 0, + y: 0, + width: 80, + height: 24, + }); + + assert!(open_focused_cell_pager(&mut app)); + assert_eq!( + app.view_stack.top_kind(), + Some(crate::tui::views::ModalKind::Pager) + ); + app.view_stack.pop(); + assert!(copy_focused_cell_metadata(&mut app)); + assert_eq!( + app.clipboard.last_written_text(), + Some("● focused markdown answer") + ); + } + #[test] fn turn_inspector_copy_answer_copies_only_the_latest_completed_answer() { use crate::tui::history::GenericToolCell; diff --git a/crates/tui/src/tui/ui/event_loop.rs b/crates/tui/src/tui/ui/event_loop.rs index b1e094461e..b78ecb7f33 100644 --- a/crates/tui/src/tui/ui/event_loop.rs +++ b/crates/tui/src/tui/ui/event_loop.rs @@ -112,6 +112,23 @@ pub(super) fn handle_plain_key_before_composer( crate::tui::paste::handle_paste_burst_key(app, key, now) } +/// Handle transcript actions after the paste-burst ambiguity window has +/// resolved a typed character. The real transcript selection is required; +/// `detail_target_cell_index` alone falls back to the latest cell and would +/// arm these shortcuts while the composer is simply being typed into. +fn handle_focused_transcript_action_char(app: &mut App, ch: char) -> bool { + if !app.input.is_empty() || !app.viewport.transcript_selection.is_active() { + return false; + } + match ch { + 'y' => copy_focused_cell(app), + 'Y' => copy_focused_cell_metadata(app), + 'r' => detail_target_cell_index(app) + .is_some_and(|index| open_details_pager_for_cell(app, index)), + _ => false, + } +} + /// Flush a raw-paste ambiguity window without losing a leading Space. /// /// `FlushResult::Paste` is always composer payload. A lone typed Space is a @@ -123,6 +140,11 @@ pub(super) fn flush_paste_burst_before_composer(app: &mut App, now: Instant) -> app.insert_str(&text); true } + crate::tui::paste_burst::FlushResult::Typed(ch) + if handle_focused_transcript_action_char(app, ch) => + { + true + } crate::tui::paste_burst::FlushResult::Typed(' ') if app.input.is_empty() && handle_transcript_space(app) => { @@ -4695,6 +4717,14 @@ pub(crate) async fn run_event_loop( { continue; } + KeyCode::Enter + if key.modifiers == KeyModifiers::NONE + && app.input.is_empty() + && detail_target_cell_index(app).is_some() + && open_focused_cell_pager(app) => + { + continue; + } KeyCode::Enter if key.modifiers == KeyModifiers::NONE && app.input.is_empty() diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 905b483133..ab29254cf0 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -4530,6 +4530,46 @@ fn active_raw_paste_keeps_space_as_payload_over_reasoning_action() { assert_eq!(app.input, "a "); } +#[test] +fn typed_command_burst_keeps_r_and_y_out_of_transcript_actions() { + let mut app = create_test_app(); + app.use_paste_burst_detection = true; + app.bracketed_paste_seen = false; + app.history = vec![HistoryCell::Assistant { + content: "previous command output".to_string(), + streaming: false, + }]; + app.resync_history_revisions(); + let _ = render_underwater_test_app(&mut app, 60, 16); + select_original_cell(&mut app, 0); + assert!( + app.viewport.transcript_selection.is_active(), + "precondition: a standing transcript selection must arm block actions" + ); + + let now = Instant::now(); + let command = "/plugin trust demo"; + for (offset, ch) in command.chars().enumerate() { + let at = now + Duration::from_millis(offset as u64); + let key = KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE); + let _ = flush_paste_burst_before_composer(&mut app, at); + assert!( + handle_plain_key_before_composer(&mut app, &key, at), + "paste-burst input should retain {ch:?}" + ); + } + assert!(flush_paste_burst_before_composer( + &mut app, + now + Duration::from_millis(500), + )); + + assert_eq!(app.input, command); + assert!( + app.view_stack.is_empty(), + "typed command must not open a pager" + ); +} + #[test] fn active_streaming_reasoning_keeps_its_visible_owner_across_a_delta() { let mut app = create_test_app(); diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index 098a2fd913..4d6a3424c0 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -154,6 +154,10 @@ When `[memory] enabled = true`, typing `# foo` and pressing `Enter` appends `foo | `Alt-[` / `Alt-]` | Jump between tool output blocks | | `Esc Esc` | Backtrack to a previous user message (`←`/`→` steps, `Enter` rewinds) | | `Esc` | Return focus to composer | +| `y` | Copy the focused transcript block content | +| `Y` | Copy the focused transcript block with metadata | +| `Enter` | Open the focused transcript block fullscreen | +| `r` | Open the focused block's raw markdown/detail view | | Mouse drag | Select transcript text in Codewhale | | `Ctrl-C` | Copy an active Codewhale selection | | `Cmd-click` (macOS) / `Ctrl-click` (Linux/Windows) | Open an OSC 8 link in a supporting terminal (terminal-owned) |