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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions crates/tui/src/tui/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 96 additions & 2 deletions crates/tui/src/tui/ui/activity_detail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> {
Expand Down Expand Up @@ -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
Expand All @@ -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::<Vec<_>>()
.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());
Expand Down Expand Up @@ -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;
Expand Down
30 changes: 30 additions & 0 deletions crates/tui/src/tui/ui/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) =>
{
Expand Down Expand Up @@ -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()
Expand Down
40 changes: 40 additions & 0 deletions crates/tui/src/tui/ui/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions docs/KEYBINDINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
Loading