Compare commits

...

22 Commits

Author SHA1 Message Date
Felipe Coury
aaa307378e test(tui): update transcript search keymap snapshot 2026-06-01 11:06:47 -03:00
Felipe Coury
b972f2397d fix(tui): address transcript search review feedback 2026-06-01 11:00:21 -03:00
Felipe Coury
ad01c93238 test(tui): update transcript loading footer snapshot 2026-06-01 11:00:21 -03:00
Felipe Coury
c198c4454d feat(tui): optimize transcript search queries 2026-06-01 11:00:21 -03:00
Felipe Coury
0299fe0429 fix(tui): align transcript search integration tests 2026-06-01 11:00:21 -03:00
Felipe Coury
6de180a420 fix(tui): keep transcript search hits visible 2026-06-01 11:00:21 -03:00
Felipe Coury
f2f5767c0c feat(tui): add transcript search 2026-06-01 11:00:20 -03:00
Felipe Coury
edc9effc01 perf(tui): load large transcripts asynchronously 2026-06-01 11:00:08 -03:00
Felipe Coury
daec0e2e76 fix(tui): label transcript overlay prompt test index 2026-06-01 11:00:07 -03:00
Felipe Coury
18d03f48a1 fix(tui): update transcript overlay test session fixture 2026-06-01 11:00:07 -03:00
Felipe Coury
78026a9bee fix(tui): box transcript overlay variants 2026-06-01 11:00:07 -03:00
Felipe Coury
6c002e00de fix(tui): scope transcript prompt selection to session 2026-06-01 11:00:07 -03:00
Felipe Coury
7ff1b28bd1 fix(tui): use ratatui terminal in perf probe 2026-06-01 11:00:07 -03:00
Felipe Coury
910b6f533f perf(tui): speed up transcript prompt selection 2026-06-01 11:00:07 -03:00
Felipe Coury
e1295afc3f test(tui): measure transcript prompt selection 2026-06-01 11:00:07 -03:00
Felipe Coury
e637bc763c fix(tui): select latest transcript prompt on open 2026-06-01 10:59:57 -03:00
Felipe Coury
d62afd9ce4 fix(tui): label transcript prompt test index args 2026-06-01 10:59:57 -03:00
Felipe Coury
e4dc5a701c fix(tui): align transcript selection with visible prompts 2026-06-01 10:59:57 -03:00
Felipe Coury
d9eb01a583 feat(tui): align transcript footer stats with session picker 2026-06-01 10:59:57 -03:00
Felipe Coury
e5e2425b71 feat(tui): show transcript copy status in footer 2026-06-01 10:59:57 -03:00
Felipe Coury
ff19a5286d feat(tui): refine transcript overlay navigation 2026-06-01 10:59:57 -03:00
Felipe Coury
378db994c5 feat(tui): improve transcript overlay 2026-06-01 10:59:57 -03:00
38 changed files with 4313 additions and 601 deletions

View File

@@ -315,6 +315,8 @@ pub struct TuiVimTextObjectKeymap {
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiPagerKeymap {
/// Start transcript search.
pub start_search: Option<KeybindingsSpec>,
/// Scroll up by one row.
pub scroll_up: Option<KeybindingsSpec>,
/// Scroll down by one row.
@@ -331,6 +333,14 @@ pub struct TuiPagerKeymap {
pub jump_top: Option<KeybindingsSpec>,
/// Jump to the end.
pub jump_bottom: Option<KeybindingsSpec>,
/// Move to the previous user prompt in transcript search mode.
pub previous_user_prompt: Option<KeybindingsSpec>,
/// Move to the next user prompt in transcript search mode.
pub next_user_prompt: Option<KeybindingsSpec>,
/// Move to the next transcript search match.
pub next_search_match: Option<KeybindingsSpec>,
/// Move to the previous transcript search match.
pub previous_search_match: Option<KeybindingsSpec>,
/// Close the pager overlay.
pub close: Option<KeybindingsSpec>,
/// Close the transcript overlay via its dedicated toggle key.

View File

@@ -2842,10 +2842,15 @@
"half_page_up": null,
"jump_bottom": null,
"jump_top": null,
"next_search_match": null,
"next_user_prompt": null,
"page_down": null,
"page_up": null,
"previous_search_match": null,
"previous_user_prompt": null,
"scroll_down": null,
"scroll_up": null
"scroll_up": null,
"start_search": null
},
"vim_normal": {
"append_after_cursor": null,
@@ -3526,10 +3531,15 @@
"half_page_up": null,
"jump_bottom": null,
"jump_top": null,
"next_search_match": null,
"next_user_prompt": null,
"page_down": null,
"page_up": null,
"previous_search_match": null,
"previous_user_prompt": null,
"scroll_down": null,
"scroll_up": null
"scroll_up": null,
"start_search": null
}
},
"vim_normal": {
@@ -3749,6 +3759,22 @@
],
"description": "Jump to the beginning."
},
"next_search_match": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move to the next transcript search match."
},
"next_user_prompt": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move to the next user prompt in transcript search mode."
},
"page_down": {
"allOf": [
{
@@ -3765,6 +3791,22 @@
],
"description": "Scroll up by one page."
},
"previous_search_match": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move to the previous transcript search match."
},
"previous_user_prompt": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move to the previous user prompt in transcript search mode."
},
"scroll_down": {
"allOf": [
{
@@ -3780,6 +3822,14 @@
}
],
"description": "Scroll up by one row."
},
"start_search": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Start transcript search."
}
},
"type": "object"

View File

@@ -62,6 +62,7 @@ use crate::multi_agents::format_agent_picker_item_name;
use crate::multi_agents::next_agent_shortcut_matches;
use crate::multi_agents::previous_agent_shortcut_matches;
use crate::pager_overlay::Overlay;
use crate::pager_overlay::TranscriptOverlayState;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::Renderable;
use crate::resume_picker::SessionSelection;
@@ -498,6 +499,7 @@ pub(crate) struct App {
// Pager overlay state (Transcript or Static like Diff)
pub(crate) overlay: Option<Overlay>,
pub(crate) transcript_overlay_state: TranscriptOverlayState,
pub(crate) deferred_history_lines: Vec<crate::terminal_hyperlinks::HyperlinkLine>,
has_emitted_history_lines: bool,
transcript_reflow: TranscriptReflowState,
@@ -982,6 +984,8 @@ Fix the config and retry.\n\
See the Codex keymap documentation for supported actions and examples."
)
})?;
let transcript_overlay_state =
TranscriptOverlayState::new(chat_widget.history_render_mode());
#[cfg(not(debug_assertions))]
let upgrade_version = crate::updates::get_upgrade_version(&config);
@@ -1003,6 +1007,7 @@ See the Codex keymap documentation for supported actions and examples."
keymap: runtime_keymap,
transcript_cells: Vec::new(),
overlay: None,
transcript_overlay_state,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),

View File

@@ -219,6 +219,13 @@ impl App {
);
}
}
AppEvent::TranscriptLayoutReady(result) => {
if let Some(Overlay::Transcript(transcript)) = &mut self.overlay
&& transcript.apply_layout_result(result)
{
tui.frame_requester().schedule_frame();
}
}
AppEvent::EndInitialHistoryReplayBuffer => {
self.finish_initial_history_replay_buffer(tui);
}

View File

@@ -83,6 +83,7 @@ impl App {
} else {
self.chat_widget.set_raw_output_mode(enabled);
}
self.transcript_overlay_state.render_mode = self.chat_widget.history_render_mode();
if let Err(err) = self.reflow_transcript_now(tui) {
tracing::warn!(error = %err, "failed to reflow transcript after raw output mode toggle");
self.chat_widget
@@ -166,14 +167,10 @@ impl App {
return;
}
if app_keymap_shortcuts_available && self.keymap.app.open_transcript.is_pressed(key_event) {
// Enter alternate screen and set viewport to full size.
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_transcript(
self.transcript_cells.clone(),
self.keymap.pager.clone(),
));
tui.frame_requester().schedule_frame();
if self.transcript_shortcut_available()
&& self.keymap.app.open_transcript.is_pressed(key_event)
{
self.open_transcript_overlay(tui);
return;
}
@@ -280,6 +277,10 @@ impl App {
self.overlay.is_none() && self.chat_widget.no_modal_or_popup_active()
}
fn transcript_shortcut_available(&self) -> bool {
self.overlay.is_none()
}
pub(super) fn refresh_status_line(&mut self) {
self.chat_widget.refresh_status_line();
}
@@ -298,5 +299,6 @@ mod tests {
app.chat_widget.open_keymap_debug(&keymap);
assert!(!app.app_keymap_shortcuts_available());
assert!(app.transcript_shortcut_available());
}
}

View File

@@ -30,6 +30,9 @@ pub(super) async fn make_test_app() -> App {
file_search,
transcript_cells: Vec::new(),
overlay: None,
transcript_overlay_state: crate::pager_overlay::TranscriptOverlayState::new(
crate::history_cell::HistoryRenderMode::Rich,
),
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),

View File

@@ -3775,6 +3775,9 @@ async fn make_test_app() -> App {
file_search,
transcript_cells: Vec::new(),
overlay: None,
transcript_overlay_state: crate::pager_overlay::TranscriptOverlayState::new(
crate::history_cell::HistoryRenderMode::Rich,
),
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
@@ -3838,6 +3841,9 @@ async fn make_test_app_with_channels() -> (
file_search,
transcript_cells: Vec::new(),
overlay: None,
transcript_overlay_state: crate::pager_overlay::TranscriptOverlayState::new(
crate::history_cell::HistoryRenderMode::Rich,
),
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
@@ -4960,6 +4966,9 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() {
app.overlay = Some(Overlay::new_transcript(
app.transcript_cells.clone(),
app.keymap.pager.clone(),
app.keymap.app.copy.clone(),
app.keymap.app.toggle_raw_output.clone(),
app.transcript_overlay_state,
));
app.deferred_history_lines = vec![Line::from("stale buffered line").into()];
app.backtrack.overlay_preview_active = true;
@@ -5495,6 +5504,11 @@ async fn clear_only_ui_reset_preserves_chat_session_state() {
app.overlay = Some(Overlay::new_transcript(
app.transcript_cells.clone(),
crate::keymap::RuntimeKeymap::defaults().pager,
Vec::new(),
Vec::new(),
crate::pager_overlay::TranscriptOverlayState::new(
crate::history_cell::HistoryRenderMode::Rich,
),
));
app.deferred_history_lines = vec![Line::from("stale buffered line").into()];
app.has_emitted_history_lines = true;

View File

@@ -35,7 +35,9 @@ use crate::app_event::AppEvent;
use crate::history_cell::AgentMessageCell;
use crate::history_cell::SessionInfoCell;
use crate::history_cell::UserHistoryCell;
use crate::key_hint::KeyBindingListExt;
use crate::pager_overlay::Overlay;
use crate::pager_overlay::TranscriptOverlayState;
use crate::tui;
use crate::tui::TuiEvent;
use codex_protocol::ThreadId;
@@ -104,14 +106,32 @@ pub(crate) struct PendingBacktrackRollback {
impl App {
/// Route overlay events while the transcript overlay is active.
///
/// If backtrack preview is active, Esc / Left steps selection, Right steps forward, Enter
/// confirms. Otherwise, Esc begins preview mode and all other events are forwarded to the
/// overlay.
/// If backtrack preview is active, Esc / previous-prompt steps selection, next-prompt steps
/// forward, Enter confirms. Otherwise, Esc or a prompt-selection key begins preview mode and
/// all other events are forwarded to the overlay.
pub(crate) async fn handle_backtrack_overlay_event(
&mut self,
tui: &mut tui::Tui,
event: TuiEvent,
) -> Result<bool> {
if let Some(Overlay::Transcript(transcript)) = &mut self.overlay {
let key_event = match event {
TuiEvent::Key(key_event) => Some(key_event),
_ => None,
};
if transcript.search_is_active() {
self.overlay_forward_event(tui, event)?;
return Ok(true);
}
if let Some(key_event) = key_event
&& transcript.is_start_search_key(key_event)
{
self.cancel_overlay_backtrack_preview();
self.overlay_forward_event(tui, TuiEvent::Key(key_event))?;
return Ok(true);
}
}
if self.backtrack.overlay_preview_active {
match event {
TuiEvent::Key(KeyEvent {
@@ -122,19 +142,15 @@ impl App {
self.overlay_step_backtrack(tui, event)?;
Ok(true)
}
TuiEvent::Key(KeyEvent {
code: KeyCode::Left,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
TuiEvent::Key(key_event)
if self.keymap.pager.previous_user_prompt.is_pressed(key_event) =>
{
self.overlay_step_backtrack(tui, event)?;
Ok(true)
}
TuiEvent::Key(KeyEvent {
code: KeyCode::Right,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
TuiEvent::Key(key_event)
if self.keymap.pager.next_user_prompt.is_pressed(key_event) =>
{
self.overlay_step_backtrack_forward(tui, event)?;
Ok(true)
}
@@ -151,14 +167,28 @@ impl App {
Ok(true)
}
}
} else if let TuiEvent::Key(KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) = event
} else if let TuiEvent::Key(key_event) = event
&& matches!(
key_event,
KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}
)
{
// First Esc in transcript overlay: begin backtrack preview at latest user message.
self.begin_overlay_backtrack_preview(tui);
self.begin_overlay_backtrack_preview(tui, OverlayBacktrackStart::Latest);
Ok(true)
} else if let TuiEvent::Key(key_event) = event
&& self.keymap.pager.previous_user_prompt.is_pressed(key_event)
{
self.begin_overlay_backtrack_preview(tui, OverlayBacktrackStart::Previous);
Ok(true)
} else if let TuiEvent::Key(key_event) = event
&& self.keymap.pager.next_user_prompt.is_pressed(key_event)
{
self.begin_overlay_backtrack_preview(tui, OverlayBacktrackStart::Next);
Ok(true)
} else {
// Not in backtrack mode: forward events to the overlay widget.
@@ -232,15 +262,29 @@ impl App {
/// Open transcript overlay (enters alternate screen and shows full transcript).
pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) {
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_transcript(
let transcript_overlay_state = transcript_overlay_state_for_open(
self.transcript_overlay_state,
&self.transcript_cells,
);
let mut overlay = Overlay::new_transcript(
self.transcript_cells.clone(),
self.keymap.pager.clone(),
));
self.keymap.app.copy.clone(),
self.keymap.app.toggle_raw_output.clone(),
transcript_overlay_state,
);
if let Overlay::Transcript(transcript) = &mut overlay {
transcript.set_highlight_cell(transcript_overlay_state.highlight_cell);
}
self.overlay = Some(overlay);
tui.frame_requester().schedule_frame();
}
/// Close transcript overlay and restore normal UI.
pub(crate) fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) {
if let Some(Overlay::Transcript(transcript)) = &self.overlay {
self.transcript_overlay_state = transcript.state();
}
let _ = tui.leave_alt_screen();
let was_backtrack = self.backtrack.overlay_preview_active;
if !self.deferred_history_lines.is_empty() {
@@ -304,8 +348,12 @@ impl App {
self.step_backtrack_and_highlight(tui);
}
/// When overlay is already open, begin preview mode and select latest user message.
fn begin_overlay_backtrack_preview(&mut self, tui: &mut tui::Tui) {
/// When overlay is already open, begin preview mode and select the requested user message.
fn begin_overlay_backtrack_preview(
&mut self,
tui: &mut tui::Tui,
start: OverlayBacktrackStart,
) {
if !has_backtrack_target(&self.transcript_cells) {
self.close_transcript_overlay(tui);
self.chat_widget
@@ -317,16 +365,16 @@ impl App {
self.backtrack.primed = true;
self.backtrack.base_id = self.chat_widget.thread_id();
self.backtrack.overlay_preview_active = true;
let count = user_count(&self.transcript_cells);
if let Some(last) = count.checked_sub(1) {
self.apply_backtrack_selection_internal(last);
let count = self.current_transcript_user_count();
if let Some(nth_user_message) = initial_overlay_backtrack_selection(count, start) {
self.apply_backtrack_selection_internal(nth_user_message);
}
tui.frame_requester().schedule_frame();
}
/// Step selection to the next older user message and update overlay.
fn step_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) {
let count = user_count(&self.transcript_cells);
let count = self.current_transcript_user_count();
if count == 0 {
return;
}
@@ -349,7 +397,7 @@ impl App {
/// Step selection to the next newer user message and update overlay.
fn step_forward_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) {
let count = user_count(&self.transcript_cells);
let count = self.current_transcript_user_count();
if count == 0 {
return;
}
@@ -370,16 +418,27 @@ impl App {
/// Apply a computed backtrack selection to the overlay and internal counter.
fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) {
if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) {
self.backtrack.nth_user_message = nth_user_message;
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.set_highlight_cell(Some(cell_idx));
}
} else {
self.backtrack.nth_user_message = usize::MAX;
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
if t.set_highlighted_user_prompt(nth_user_message).is_some() {
self.backtrack.nth_user_message = nth_user_message;
} else {
self.backtrack.nth_user_message = usize::MAX;
t.set_highlight_cell(/*cell*/ None);
}
return;
}
if nth_user_position(&self.transcript_cells, nth_user_message).is_some() {
self.backtrack.nth_user_message = nth_user_message;
} else {
self.backtrack.nth_user_message = usize::MAX;
}
}
fn current_transcript_user_count(&self) -> usize {
match &self.overlay {
Some(Overlay::Transcript(t)) => t.user_prompt_count(),
_ => user_count(&self.transcript_cells),
}
}
@@ -402,8 +461,10 @@ impl App {
{
let active_key = self.chat_widget.active_cell_transcript_key();
let chat_widget = &self.chat_widget;
let app_event_tx = self.app_event_tx.clone();
tui.draw(u16::MAX, |frame| {
let width = frame.area().width.max(1);
let width = frame.area().width.max(/*other*/ 1);
t.ensure_async_layout(width, app_event_tx.clone());
t.sync_live_tail(width, active_key, |w| {
chat_widget.active_cell_transcript_hyperlink_lines(w)
});
@@ -424,16 +485,77 @@ impl App {
return Ok(());
}
if let Some(overlay) = &mut self.overlay {
let (scroll_selection, copy_selection, copy_latest, close_overlay) = if let Some(overlay) =
&mut self.overlay
{
overlay.handle_event(tui, event)?;
if overlay.is_done() {
self.close_transcript_overlay(tui);
tui.frame_requester().schedule_frame();
}
let (scroll_selection, copy_selection, copy_latest) = match overlay {
Overlay::Transcript(transcript) => {
let scroll_selection = transcript.take_scroll_selected_user_cell();
if transcript.take_copy_requested() {
match transcript.selected_user_cell() {
Some(user_cell_idx) => (scroll_selection, Some(user_cell_idx), false),
None => (scroll_selection, None, true),
}
} else {
(scroll_selection, None, false)
}
}
Overlay::Static(_) => (None, None, false),
};
(
scroll_selection,
copy_selection,
copy_latest,
overlay.is_done(),
)
} else {
(None, None, false, false)
};
if let Some(user_cell_idx) = scroll_selection
&& self
.transcript_cells
.get(user_cell_idx)
.is_some_and(|cell| cell.is_user_prompt())
&& let Some(nth_user_message) =
user_count(&self.transcript_cells[..=user_cell_idx]).checked_sub(1)
{
self.backtrack.primed = true;
self.backtrack.base_id = self.chat_widget.thread_id();
self.backtrack.overlay_preview_active = true;
self.backtrack.nth_user_message = nth_user_message;
}
let copy_status = if let Some(user_cell_idx) = copy_selection {
Some(self.copy_transcript_turn(user_cell_idx))
} else if copy_latest {
Some(self.chat_widget.copy_last_agent_markdown_for_overlay())
} else {
None
};
if let Some(status) = copy_status
&& let Some(Overlay::Transcript(transcript)) = &mut self.overlay
{
transcript.show_copy_status(&status, tui);
}
if close_overlay {
self.close_transcript_overlay(tui);
tui.frame_requester().schedule_frame();
}
Ok(())
}
fn copy_transcript_turn(&mut self, user_cell_idx: usize) -> crate::chatwidget::CopyStatus {
let Some(user_cell) = self.transcript_cells.get(user_cell_idx).and_then(|cell| {
cell.as_any()
.downcast_ref::<crate::history_cell::UserHistoryCell>()
}) else {
return self.chat_widget.copy_last_agent_markdown_for_overlay();
};
let user_turn_count = user_count(&self.transcript_cells[..=user_cell_idx]);
self.chat_widget
.copy_agent_turn_markdown_for_overlay(user_turn_count, &user_cell.message)
}
/// Handle Enter in overlay backtrack preview: confirm selection and reset state.
fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) {
let nth_user_message = self.backtrack.nth_user_message;
@@ -455,7 +577,8 @@ impl App {
Ok(())
}
/// Handle Right in overlay backtrack preview: step selection forward if armed, else forward.
/// Handle next-prompt in overlay backtrack preview: step selection forward if armed, else
/// forward.
fn overlay_step_backtrack_forward(
&mut self,
tui: &mut tui::Tui,
@@ -486,6 +609,17 @@ impl App {
self.chat_widget.clear_esc_backtrack_hint();
}
fn cancel_overlay_backtrack_preview(&mut self) {
self.backtrack.overlay_preview_active = false;
self.backtrack.primed = false;
self.backtrack.base_id = None;
self.backtrack.nth_user_message = usize::MAX;
if let Some(Overlay::Transcript(transcript)) = &mut self.overlay {
transcript.set_highlight_cell(/*cell*/ None);
}
self.chat_widget.clear_esc_backtrack_hint();
}
pub(crate) fn apply_backtrack_selection(
&mut self,
tui: &mut tui::Tui,
@@ -651,6 +785,32 @@ fn has_backtrack_target(cells: &[Arc<dyn crate::history_cell::HistoryCell>]) ->
user_count(cells) > 0
}
#[derive(Clone, Copy)]
enum OverlayBacktrackStart {
Latest,
Previous,
Next,
}
fn initial_overlay_backtrack_selection(
user_count: usize,
start: OverlayBacktrackStart,
) -> Option<usize> {
let last = user_count.checked_sub(1)?;
Some(match start {
OverlayBacktrackStart::Latest | OverlayBacktrackStart::Next => last,
OverlayBacktrackStart::Previous => last.saturating_sub(1),
})
}
fn transcript_overlay_state_for_open(
mut state: TranscriptOverlayState,
cells: &[Arc<dyn crate::history_cell::HistoryCell>],
) -> TranscriptOverlayState {
state.highlight_cell = user_positions_iter(cells).last();
state
}
fn nth_user_position(
cells: &[Arc<dyn crate::history_cell::HistoryCell>],
nth: usize,
@@ -664,7 +824,6 @@ fn user_positions_iter(
cells: &[Arc<dyn crate::history_cell::HistoryCell>],
) -> impl Iterator<Item = usize> + '_ {
let session_start_type = TypeId::of::<SessionInfoCell>();
let user_type = TypeId::of::<UserHistoryCell>();
let type_of = |cell: &Arc<dyn crate::history_cell::HistoryCell>| cell.as_any().type_id();
let start = cells
@@ -676,7 +835,7 @@ fn user_positions_iter(
.iter()
.enumerate()
.skip(start)
.filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(idx))
.filter_map(move |(idx, cell)| cell.is_user_prompt().then_some(idx))
}
#[cfg(test)]
@@ -712,6 +871,7 @@ mod tests {
use super::*;
use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::HistoryRenderMode;
use pretty_assertions::assert_eq;
use ratatui::prelude::Line;
use std::sync::Arc;
@@ -962,6 +1122,104 @@ mod tests {
assert!(has_backtrack_target(&cells));
}
#[test]
fn transcript_overlay_open_selects_latest_user_prompt() {
let cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(UserHistoryCell {
message: "first".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("assistant")],
/*is_first_line*/ true,
)) as Arc<dyn HistoryCell>,
Arc::new(UserHistoryCell {
message: "second".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
];
let stale_state = TranscriptOverlayState {
scroll_offset: 4,
highlight_cell: Some(0),
render_mode: HistoryRenderMode::Raw,
};
let state = transcript_overlay_state_for_open(stale_state, &cells);
assert_eq!(
state,
TranscriptOverlayState {
scroll_offset: 4,
highlight_cell: Some(2),
render_mode: HistoryRenderMode::Raw,
}
);
}
#[test]
fn transcript_overlay_open_clears_stale_selection_without_user_prompt() {
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(AgentMessageCell::new(
vec![Line::from("assistant")],
/*is_first_line*/ true,
)) as Arc<dyn HistoryCell>];
let stale_state = TranscriptOverlayState {
scroll_offset: 4,
highlight_cell: Some(0),
render_mode: HistoryRenderMode::Raw,
};
let state = transcript_overlay_state_for_open(stale_state, &cells);
assert_eq!(
state,
TranscriptOverlayState {
scroll_offset: 4,
highlight_cell: None,
render_mode: HistoryRenderMode::Raw,
}
);
}
#[test]
fn first_previous_prompt_key_starts_on_previous_prompt() {
assert_eq!(
initial_overlay_backtrack_selection(
/*user_count*/ 3,
OverlayBacktrackStart::Previous,
),
Some(1)
);
assert_eq!(
initial_overlay_backtrack_selection(
/*user_count*/ 3,
OverlayBacktrackStart::Latest
),
Some(2)
);
assert_eq!(
initial_overlay_backtrack_selection(/*user_count*/ 3, OverlayBacktrackStart::Next),
Some(2)
);
assert_eq!(
initial_overlay_backtrack_selection(
/*user_count*/ 1,
OverlayBacktrackStart::Previous,
),
Some(0)
);
assert_eq!(
initial_overlay_backtrack_selection(
/*user_count*/ 0,
OverlayBacktrackStart::Previous,
),
None
);
}
#[test]
fn backtrack_unavailable_info_message_snapshot() {
let cell = crate::history_cell::new_info_event(

View File

@@ -38,6 +38,7 @@ use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::TerminalTitleItem;
use crate::chatwidget::UserMessage;
use crate::transcript_layout::TranscriptLayoutResult;
use codex_app_server_protocol::AskForApproval;
use codex_config::types::ApprovalsReviewer;
use codex_features::Feature;
@@ -576,6 +577,9 @@ pub(crate) enum AppEvent {
InsertHistoryCell(Box<dyn HistoryCell>),
/// Result of measuring the transcript overlay layout off the UI thread.
TranscriptLayoutReady(TranscriptLayoutResult),
/// Finish buffering initial resume replay after all replay events have been queued.
EndInitialHistoryReplayBuffer,

View File

@@ -193,6 +193,24 @@ use tracing::debug;
use tracing::warn;
const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading";
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum CopyStatus {
Success(String),
Error(String),
}
impl CopyStatus {
pub(crate) fn message(&self) -> &str {
match self {
Self::Success(message) | Self::Error(message) => message,
}
}
pub(crate) fn is_success(&self) -> bool {
matches!(self, Self::Success(_))
}
}
const MULTI_AGENT_ENABLE_TITLE: &str = "Enable subagents?";
const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable";
const MULTI_AGENT_ENABLE_NO: &str = "Not now";

View File

@@ -225,7 +225,25 @@ impl ChatWidget {
/// Copy the last agent response (raw markdown) to the system clipboard.
pub(crate) fn copy_last_agent_markdown(&mut self) {
self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard);
let status = self.copy_last_agent_markdown_for_overlay();
self.record_copy_status(&status);
self.request_redraw();
}
pub(crate) fn copy_last_agent_markdown_for_overlay(&mut self) -> CopyStatus {
self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard)
}
pub(crate) fn copy_agent_turn_markdown_for_overlay(
&mut self,
user_turn_count: usize,
user_prompt: &str,
) -> CopyStatus {
self.copy_agent_turn_markdown_with(
user_turn_count,
user_prompt,
crate::clipboard_copy::copy_to_clipboard,
)
}
pub(crate) fn truncate_agent_copy_history_to_user_turn_count(
@@ -240,30 +258,61 @@ impl ChatWidget {
pub(super) fn copy_last_agent_markdown_with(
&mut self,
copy_fn: impl FnOnce(&str) -> Result<Option<crate::clipboard_copy::ClipboardLease>, String>,
) {
) -> CopyStatus {
match self.transcript.last_agent_markdown.clone() {
Some(markdown) if !markdown.is_empty() => match copy_fn(&markdown) {
Ok(lease) => {
self.clipboard_lease = lease;
self.add_to_history(history_cell::new_info_event(
"Copied last message to clipboard".into(),
/*hint*/ None,
));
}
Err(error) => self.add_to_history(history_cell::new_error_event(format!(
"Copy failed: {error}"
))),
},
_ if self.transcript.copy_history_evicted_by_rollback => {
self.add_to_history(history_cell::new_error_event(format!(
"Cannot copy that response after rewinding. Only the most recent {MAX_AGENT_COPY_HISTORY} responses are available to /copy."
)));
Some(markdown) if !markdown.is_empty() => {
self.copy_markdown_result(&markdown, "Copied last message to clipboard", copy_fn)
}
_ => self.add_to_history(history_cell::new_error_event(
"No agent response to copy".into(),
_ if self.transcript.copy_history_evicted_by_rollback => CopyStatus::Error(format!(
"Cannot copy that response after rewinding. Only the most recent {MAX_AGENT_COPY_HISTORY} responses are available to /copy."
)),
_ => CopyStatus::Error("No agent response to copy".into()),
}
}
pub(super) fn copy_agent_turn_markdown_with(
&mut self,
user_turn_count: usize,
user_prompt: &str,
copy_fn: impl FnOnce(&str) -> Result<Option<crate::clipboard_copy::ClipboardLease>, String>,
) -> CopyStatus {
match self
.transcript
.agent_markdown_for_user_turn(user_turn_count)
{
Some(markdown) if !markdown.is_empty() => {
let markdown = format!("## User\n\n{user_prompt}\n\n## Assistant\n\n{markdown}");
self.copy_markdown_result(&markdown, "Copied selected turn to clipboard", copy_fn)
}
_ => CopyStatus::Error("No agent response to copy for selected prompt".into()),
}
}
fn copy_markdown_result(
&mut self,
markdown: &str,
success_message: &str,
copy_fn: impl FnOnce(&str) -> Result<Option<crate::clipboard_copy::ClipboardLease>, String>,
) -> CopyStatus {
match copy_fn(markdown) {
Ok(lease) => {
self.clipboard_lease = lease;
CopyStatus::Success(success_message.into())
}
Err(error) => CopyStatus::Error(format!("Copy failed: {error}")),
}
}
fn record_copy_status(&mut self, status: &CopyStatus) {
match status {
CopyStatus::Success(message) => self.add_to_history(history_cell::new_info_event(
message.clone(),
/*hint*/ None,
)),
CopyStatus::Error(message) => {
self.add_to_history(history_cell::new_error_event(message.clone()))
}
}
self.request_redraw();
}
#[cfg(test)]

View File

@@ -1443,32 +1443,66 @@ async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.transcript.last_agent_markdown = Some("copy me".to_string());
chat.copy_last_agent_markdown_with(|markdown| {
let status = chat.copy_last_agent_markdown_with(|markdown| {
assert_eq!(markdown, "copy me");
Ok(Some(crate::clipboard_copy::ClipboardLease::test()))
});
assert_eq!(
status,
crate::chatwidget::CopyStatus::Success("Copied last message to clipboard".into())
);
assert!(chat.clipboard_lease.is_some());
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one success message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Copied last message to clipboard"),
"expected success message, got {rendered:?}"
cells.is_empty(),
"expected overlay-style helper not to add history"
);
chat.copy_last_agent_markdown_with(|markdown| {
let status = chat.copy_last_agent_markdown_with(|markdown| {
assert_eq!(markdown, "copy me");
Err("blocked".into())
});
assert_eq!(
status,
crate::chatwidget::CopyStatus::Error("Copy failed: blocked".into())
);
assert!(chat.clipboard_lease.is_some());
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one failure message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Copy failed: blocked"),
"expected failure message, got {rendered:?}"
cells.is_empty(),
"expected overlay-style helper not to add history"
);
}
#[tokio::test]
async fn transcript_turn_copy_includes_user_prompt_and_agent_markdown() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.transcript.record_visible_user_turn();
chat.transcript
.record_agent_markdown("first response".to_string());
let status = chat.copy_agent_turn_markdown_with(
/*user_turn_count*/ 1,
"first prompt",
|markdown| {
assert_eq!(
markdown,
"## User\n\nfirst prompt\n\n## Assistant\n\nfirst response"
);
Ok(Some(crate::clipboard_copy::ClipboardLease::test()))
},
);
assert_eq!(
status,
crate::chatwidget::CopyStatus::Success("Copied selected turn to clipboard".into())
);
let cells = drain_insert_history(&mut rx);
assert!(
cells.is_empty(),
"expected overlay-style helper not to add history"
);
}

View File

@@ -106,6 +106,13 @@ impl TranscriptState {
self.saw_copy_source_this_turn = false;
}
pub(super) fn agent_markdown_for_user_turn(&self, user_turn_count: usize) -> Option<&str> {
self.agent_turn_markdowns
.iter()
.find(|entry| entry.user_turn_count == user_turn_count)
.map(|entry| entry.markdown.as_str())
}
pub(super) fn reset_turn_flags(&mut self) {
self.saw_copy_source_this_turn = false;
self.saw_plan_update_this_turn = false;

View File

@@ -0,0 +1,404 @@
use crate::color::is_light;
use crate::line_truncation::line_width;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::terminal_palette::default_bg;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Styled as _;
use ratatui::style::Stylize as _;
use ratatui::text::Line;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
const FOOTER_COMPACT_BREAKPOINT: u16 = 120;
const FOOTER_HINT_LEFT_PADDING: usize = 1;
const FOOTER_HINT_GAP: usize = 3;
#[derive(Clone, Debug)]
pub(crate) struct FooterHint {
key: String,
wide_label: String,
compact_label: String,
priority: u8,
}
impl FooterHint {
pub(crate) fn new(
key: impl Into<String>,
wide_label: impl Into<String>,
compact_label: impl Into<String>,
priority: u8,
) -> Self {
Self {
key: key.into(),
wide_label: wide_label.into(),
compact_label: compact_label.into(),
priority,
}
}
}
#[derive(Clone, Copy)]
enum FooterHintLabelMode {
Wide,
Compact,
KeyOnly,
}
pub(crate) fn footer_hint_line_for_row(hints: &[FooterHint], width: u16) -> Line<'static> {
if width >= FOOTER_COMPACT_BREAKPOINT
&& let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Wide, width)
{
return line;
}
if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Compact, width) {
return line;
}
if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::KeyOnly, width) {
return line;
}
let mut retained = (0..hints.len()).collect::<Vec<_>>();
retained.sort_by_key(|idx| hints[*idx].priority);
for retain_count in (1..=retained.len()).rev() {
let mut candidate_indices = retained[..retain_count].to_vec();
candidate_indices.sort_unstable();
let candidate = candidate_indices
.iter()
.map(|idx| &hints[*idx])
.collect::<Vec<_>>();
if let Some(line) = fit_footer_hint_refs(&candidate, FooterHintLabelMode::KeyOnly, width) {
return line;
}
}
Line::default()
}
pub(crate) fn render_footer_separator(area: Rect, buf: &mut Buffer, label: String) {
if area.width == 0 {
return;
}
Line::from("".repeat(area.width as usize).dim()).render_ref(area, buf);
if label.is_empty() {
return;
}
let label_width = UnicodeWidthStr::width(label.as_str()) as u16;
if label_width < area.width {
let label_area = Rect::new(
area.x + area.width - label_width - 1,
area.y,
label_width,
1,
);
Line::from(label.dim()).render_ref(label_area, buf);
}
}
pub(crate) fn first_fitting_right_label(width: u16, labels: &[String]) -> String {
labels
.iter()
.find(|label| UnicodeWidthStr::width(label.as_str()) < width as usize)
.cloned()
.unwrap_or_default()
}
pub(crate) fn render_footer_line_with_optional_right(
area: Rect,
buf: &mut Buffer,
left: Line<'static>,
right: Option<Line<'static>>,
) {
let Some(right) = right else {
left.render(area, buf);
return;
};
if area.width == 0 {
return;
}
let right_width = line_width(&right) as u16;
if right_width > area.width {
truncate_line_with_ellipsis_if_overflow(right, area.width as usize).render(area, buf);
return;
}
let left_width = line_width(&left) as u16;
let gap = u16::from(left_width > 0 && right_width > 0);
let left_area_width = area.width.saturating_sub(right_width).saturating_sub(gap);
if left_area_width == 0 {
right.render(
Rect {
x: area.x + area.width - right_width,
y: area.y,
width: right_width,
height: 1,
},
buf,
);
return;
}
truncate_line_with_ellipsis_if_overflow(left, left_area_width as usize).render(
Rect {
x: area.x,
y: area.y,
width: left_area_width,
height: 1,
},
buf,
);
right.render(
Rect {
x: area.x + area.width - right_width,
y: area.y,
width: right_width,
height: 1,
},
buf,
);
}
fn fit_footer_hints(
hints: &[FooterHint],
mode: FooterHintLabelMode,
width: u16,
) -> Option<Line<'static>> {
let hint_refs = hints.iter().collect::<Vec<_>>();
fit_footer_hint_refs(&hint_refs, mode, width)
}
fn fit_footer_hint_refs(
hints: &[&FooterHint],
mode: FooterHintLabelMode,
width: u16,
) -> Option<Line<'static>> {
let gap_width = FOOTER_HINT_GAP;
if footer_hints_width(hints, mode, gap_width) > width as usize {
return None;
}
let mut spans = vec![
" ".repeat(FOOTER_HINT_LEFT_PADDING)
.set_style(footer_hint_label_style()),
];
for (idx, hint) in hints.iter().enumerate() {
if idx > 0 {
spans.push(" ".repeat(gap_width).set_style(footer_hint_label_style()));
}
spans.push(hint.key.clone().set_style(footer_hint_key_style()));
let label = match mode {
FooterHintLabelMode::Wide => Some(hint.wide_label.as_str()),
FooterHintLabelMode::Compact => Some(hint.compact_label.as_str()),
FooterHintLabelMode::KeyOnly => None,
};
if let Some(label) = label {
spans.push(" ".set_style(footer_hint_label_style()));
spans.push(label.to_string().set_style(footer_hint_label_style()));
}
}
Some(spans.into())
}
fn footer_hint_key_style() -> Style {
if default_bg().is_some_and(is_light) {
Style::default().fg(Color::Black)
} else {
Style::default()
}
}
fn footer_hint_label_style() -> Style {
if default_bg().is_some_and(is_light) {
Style::default().fg(Color::DarkGray)
} else {
Style::default().dim()
}
}
fn footer_hints_width(hints: &[&FooterHint], mode: FooterHintLabelMode, gap_width: usize) -> usize {
FOOTER_HINT_LEFT_PADDING
+ hints
.iter()
.enumerate()
.map(|(idx, hint)| {
let label_width = match mode {
FooterHintLabelMode::Wide => {
1 + UnicodeWidthStr::width(hint.wide_label.as_str())
}
FooterHintLabelMode::Compact => {
1 + UnicodeWidthStr::width(hint.compact_label.as_str())
}
FooterHintLabelMode::KeyOnly => 0,
};
let hint_width = UnicodeWidthStr::width(hint.key.as_str()) + label_width;
if idx == 0 {
hint_width
} else {
hint_width + gap_width
}
})
.sum::<usize>()
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
fn buffer_text(buf: &Buffer, area: Rect) -> String {
let mut out = String::new();
for y in area.y..area.bottom() {
for x in area.x..area.right() {
let symbol = buf[(x, y)].symbol();
out.push(symbol.chars().next().unwrap_or(' '));
}
}
out
}
fn line_text(line: Line<'static>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
#[test]
fn footer_hint_line_uses_wide_labels_when_width_allows() {
let hints = [FooterHint::new(
"enter",
"resume session",
"resume",
/*priority*/ 0,
)];
let rendered = line_text(footer_hint_line_for_row(&hints, /*width*/ 140));
assert!(rendered.contains("enter resume session"));
}
#[test]
fn footer_hint_line_compacts_below_breakpoint() {
let hints = [FooterHint::new(
"enter",
"resume session",
"resume",
/*priority*/ 0,
)];
let rendered = line_text(footer_hint_line_for_row(&hints, /*width*/ 80));
assert!(rendered.contains("enter resume"));
assert!(!rendered.contains("resume session"));
}
#[test]
fn footer_hint_line_drops_low_priority_hints_when_narrow() {
let hints = [
FooterHint::new("a", "alpha", "alpha", /*priority*/ 0),
FooterHint::new("b", "bravo", "bravo", /*priority*/ 9),
FooterHint::new("c", "charlie", "charlie", /*priority*/ 1),
];
let rendered = line_text(footer_hint_line_for_row(&hints, /*width*/ 6));
assert!(rendered.contains('a'));
assert!(rendered.contains('c'));
assert!(!rendered.contains('b'));
}
#[test]
fn footer_line_renders_left_and_right_when_both_fit() {
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 1,
);
let mut buf = Buffer::empty(area);
render_footer_line_with_optional_right(
area,
&mut buf,
Line::from("left"),
Some(Line::from("right")),
);
assert_eq!(buffer_text(&buf, area), "left right");
}
#[test]
fn footer_line_truncates_left_when_right_fits() {
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 16, /*height*/ 1,
);
let mut buf = Buffer::empty(area);
render_footer_line_with_optional_right(
area,
&mut buf,
Line::from("long left status"),
Some(Line::from("ok")),
);
assert_eq!(buffer_text(&buf, area), "long left st… ok");
}
#[test]
fn footer_line_renders_right_only_when_space_is_tight() {
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 5, /*height*/ 1,
);
let mut buf = Buffer::empty(area);
render_footer_line_with_optional_right(
area,
&mut buf,
Line::from("left"),
Some(Line::from("right")),
);
assert_eq!(buffer_text(&buf, area), "right");
}
#[test]
fn footer_line_truncates_right_when_it_overflows_area() {
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 4, /*height*/ 1,
);
let mut buf = Buffer::empty(area);
render_footer_line_with_optional_right(
area,
&mut buf,
Line::from("left"),
Some(Line::from("status")),
);
assert_eq!(buffer_text(&buf, area), "sta…");
}
#[test]
fn first_fitting_right_label_picks_first_that_fits() {
let labels = vec![
" 10 / 120 · 55% ".to_string(),
" 10/120 · 55% ".to_string(),
" 55% ".to_string(),
];
assert_eq!(
first_fitting_right_label(/*width*/ 15, &labels),
" 10/120 · 55% "
);
}
#[test]
fn first_fitting_right_label_returns_empty_when_nothing_fits() {
let labels = vec![" 100% ".to_string()];
assert_eq!(first_fitting_right_label(/*width*/ 4, &labels), "");
}
}

View File

@@ -11,12 +11,15 @@
//! first drawn.
//! 4. Completed runs only persist when they have output or a non-success status.
use super::HistoryCell;
use super::plain_hyperlink_lines;
use super::plain_lines;
use crate::history_cell::HistoryRenderMode;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
use crate::motion::activity_indicator;
use crate::motion::shimmer_text;
use crate::render::renderable::Renderable;
use crate::terminal_hyperlinks::HyperlinkLine;
use codex_app_server_protocol::HookEventName;
use codex_app_server_protocol::HookOutputEntry;
use codex_app_server_protocol::HookOutputEntryKind;
@@ -344,6 +347,21 @@ impl HistoryCell for HookCell {
self.display_lines(width)
}
fn transcript_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec<Line<'static>> {
match mode {
HistoryRenderMode::Rich => self.rich_transcript_lines(width),
HistoryRenderMode::Raw => self.raw_lines(),
}
}
fn transcript_hyperlink_lines_for_mode(
&self,
width: u16,
mode: HistoryRenderMode,
) -> Vec<HyperlinkLine> {
plain_hyperlink_lines(self.transcript_lines_for_mode(width, mode))
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.display_lines(u16::MAX))
}
@@ -363,6 +381,45 @@ impl HistoryCell for HookCell {
}
}
impl HookCell {
fn rich_transcript_lines(&self, _width: u16) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let mut running_group: Option<RunningHookGroup> = None;
for run in &self.runs {
if !run.state.should_render() {
continue;
}
let Some(key) = run.running_group_key() else {
if let Some(group) = running_group.take() {
push_running_hook_group(&mut lines, &group, self.animations_enabled);
}
push_hook_line_separator(&mut lines);
run.push_rich_transcript_lines(&mut lines, self.animations_enabled);
continue;
};
if let Some(group) = running_group.as_mut()
&& group.key == key
{
group.count += 1;
group.start_time = earliest_instant(group.start_time, run.state.start_time());
continue;
}
if let Some(group) =
running_group.replace(RunningHookGroup::new(key, run.state.start_time()))
{
push_running_hook_group(&mut lines, &group, self.animations_enabled);
}
}
if let Some(group) = running_group {
push_running_hook_group(&mut lines, &group, self.animations_enabled);
}
lines
}
}
impl Renderable for HookCell {
fn render(&self, area: Rect, buf: &mut Buffer) {
let lines = self.display_lines(area.width);
@@ -463,6 +520,52 @@ impl HookRunCell {
HookRunState::PendingReveal { .. } => {}
}
}
fn push_rich_transcript_lines(&self, lines: &mut Vec<Line<'static>>, animations_enabled: bool) {
let label = hook_event_label(self.event_name);
match &self.state {
HookRunState::VisibleRunning { start_time, .. }
| HookRunState::QuietLinger { start_time, .. } => {
let hook_text = format!("Running {label} hook");
push_running_hook_header(
lines,
&hook_text,
Some(*start_time),
self.status_message.as_deref(),
animations_enabled,
);
}
HookRunState::Completed { status, entries } => {
let status_text = format!("{status:?}").to_lowercase();
let bullet = hook_completed_bullet(*status, entries);
lines.push(
vec![
bullet,
" ".into(),
format!("{label} hook ({status_text})").into(),
]
.into(),
);
for entry in entries {
if entry.kind == HookOutputEntryKind::Context {
let line_count = entry.text.lines().count().max(1);
let label = if line_count == 1 { "line" } else { "lines" };
lines.push(
format!(
" hook context: [collapsed in rich transcript; {line_count} {label}]"
)
.into(),
);
} else {
lines.push(
format!(" {}{}", hook_output_prefix(entry.kind), entry.text).into(),
);
}
}
}
HookRunState::PendingReveal { .. } => {}
}
}
}
impl HookRunState {
@@ -880,6 +983,58 @@ mod tests {
.collect::<String>()
}
#[test]
fn rich_transcript_collapses_hook_context_but_raw_keeps_it() {
let mut run = hook_run_summary("hook-1");
run.status = HookRunStatus::Completed;
run.status_message = None;
run.entries = vec![
HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: "line one\nline two".to_string(),
},
HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: "stay visible".to_string(),
},
];
let cell = HookCell::new_completed(run, /*animations_enabled*/ false);
let rich = cell
.transcript_lines_for_mode(/*width*/ 80, HistoryRenderMode::Rich)
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>();
let raw = cell
.transcript_lines_for_mode(/*width*/ 80, HistoryRenderMode::Raw)
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>();
assert!(
rich.iter()
.any(|line| line.contains("collapsed in rich transcript"))
);
assert!(
rich.iter()
.any(|line| line.contains("warning: stay visible"))
);
assert!(
raw.iter()
.any(|line| line.contains("hook context: line one"))
);
}
fn hook_run_summary(id: &str) -> HookRunSummary {
HookRunSummary {
id: id.to_string(),

View File

@@ -191,6 +191,47 @@ impl HistoryCell for UserHistoryCell {
}
lines
}
fn transcript_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec<Line<'static>> {
match mode {
HistoryRenderMode::Rich => self.injected_context_summary().map_or_else(
|| self.display_lines(width),
|summary| vec![Line::from(summary)],
),
HistoryRenderMode::Raw => self.raw_lines(),
}
}
fn transcript_hyperlink_lines_for_mode(
&self,
width: u16,
mode: HistoryRenderMode,
) -> Vec<HyperlinkLine> {
plain_hyperlink_lines(self.transcript_lines_for_mode(width, mode))
}
fn is_user_prompt(&self) -> bool {
self.injected_context_summary().is_none()
}
}
impl UserHistoryCell {
fn injected_context_summary(&self) -> Option<String> {
let label = if self.message.starts_with("# AGENTS.md instructions for ") {
"AGENTS.md instructions"
} else if self.message.starts_with("<environment_context>") {
"environment context"
} else if self.message.starts_with("<permissions instructions>") {
"permissions instructions"
} else {
return None;
};
let line_count = self.message.lines().count().max(1);
let noun = if line_count == 1 { "line" } else { "lines" };
Some(format!(
"{label}: [collapsed in rich transcript; {line_count} {noun}]"
))
}
}
#[derive(Debug)]

View File

@@ -253,13 +253,38 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
plain_hyperlink_lines(self.transcript_lines(width))
}
/// Returns transcript overlay lines for the selected render mode.
fn transcript_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec<Line<'static>> {
match mode {
HistoryRenderMode::Rich => self.transcript_lines(width),
HistoryRenderMode::Raw => self.raw_lines(),
}
}
/// Returns transcript-overlay lines plus terminal hyperlink metadata for the selected mode.
fn transcript_hyperlink_lines_for_mode(
&self,
width: u16,
mode: HistoryRenderMode,
) -> Vec<HyperlinkLine> {
match mode {
HistoryRenderMode::Rich => self.transcript_hyperlink_lines(width),
HistoryRenderMode::Raw => plain_hyperlink_lines(self.raw_lines()),
}
}
/// Returns the number of viewport rows for the transcript overlay.
///
/// Uses the same `Paragraph::line_count` measurement as
/// `desired_height`. Contains a workaround for a ratatui bug where
/// a single whitespace-only line reports 2 rows instead of 1.
#[allow(dead_code)]
fn desired_transcript_height(&self, width: u16) -> u16 {
let lines = visible_lines(self.transcript_hyperlink_lines(width));
self.desired_transcript_height_for_mode(width, HistoryRenderMode::Rich)
}
fn desired_transcript_height_for_mode(&self, width: u16, mode: HistoryRenderMode) -> u16 {
let lines = visible_lines(self.transcript_hyperlink_lines_for_mode(width, mode));
// Workaround: ratatui's line_count returns 2 for a single
// whitespace-only line. Clamp to 1 in that case.
if let [line] = &lines[..]
@@ -282,6 +307,10 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
false
}
fn is_user_prompt(&self) -> bool {
false
}
/// Returns a coarse "animation tick" when transcript output is time-dependent.
///
/// The transcript overlay caches the rendered output of the in-flight active cell, so cells

View File

@@ -2004,6 +2004,33 @@ fn user_history_cell_height_matches_rendered_lines_with_remote_images() {
assert_eq!(cell.desired_transcript_height(width), rendered_len);
}
#[test]
fn injected_context_collapses_only_in_rich_transcript_mode() {
let cell = UserHistoryCell {
message: "# AGENTS.md instructions for /tmp/example\nline one\nline two".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
};
let rich = render_lines(&cell.transcript_lines_for_mode(/*width*/ 80, HistoryRenderMode::Rich));
let raw = render_lines(&cell.transcript_lines_for_mode(/*width*/ 80, HistoryRenderMode::Raw));
assert_eq!(
rich,
vec!["AGENTS.md instructions: [collapsed in rich transcript; 3 lines]"]
);
assert_eq!(
raw,
vec![
"# AGENTS.md instructions for /tmp/example",
"line one",
"line two",
]
);
assert!(!cell.is_user_prompt());
}
#[test]
fn user_history_cell_trims_trailing_blank_message_lines() {
let cell = UserHistoryCell {

View File

@@ -211,6 +211,7 @@ pub(crate) struct VimTextObjectKeymap {
/// Pager/overlay keybindings for transcript and static help views.
#[derive(Clone, Debug)]
pub(crate) struct PagerKeymap {
pub(crate) start_search: Vec<KeyBinding>,
pub(crate) scroll_up: Vec<KeyBinding>,
pub(crate) scroll_down: Vec<KeyBinding>,
pub(crate) page_up: Vec<KeyBinding>,
@@ -219,6 +220,10 @@ pub(crate) struct PagerKeymap {
pub(crate) half_page_down: Vec<KeyBinding>,
pub(crate) jump_top: Vec<KeyBinding>,
pub(crate) jump_bottom: Vec<KeyBinding>,
pub(crate) previous_user_prompt: Vec<KeyBinding>,
pub(crate) next_user_prompt: Vec<KeyBinding>,
pub(crate) next_search_match: Vec<KeyBinding>,
pub(crate) previous_search_match: Vec<KeyBinding>,
pub(crate) close: Vec<KeyBinding>,
pub(crate) close_transcript: Vec<KeyBinding>,
}
@@ -739,6 +744,7 @@ impl RuntimeKeymap {
};
let pager = PagerKeymap {
start_search: resolve_local!(keymap, defaults, pager, start_search),
scroll_up: resolve_local!(keymap, defaults, pager, scroll_up),
scroll_down: resolve_local!(keymap, defaults, pager, scroll_down),
page_up: resolve_local!(keymap, defaults, pager, page_up),
@@ -747,6 +753,10 @@ impl RuntimeKeymap {
half_page_down: resolve_local!(keymap, defaults, pager, half_page_down),
jump_top: resolve_local!(keymap, defaults, pager, jump_top),
jump_bottom: resolve_local!(keymap, defaults, pager, jump_bottom),
previous_user_prompt: resolve_local!(keymap, defaults, pager, previous_user_prompt),
next_user_prompt: resolve_local!(keymap, defaults, pager, next_user_prompt),
next_search_match: resolve_local!(keymap, defaults, pager, next_search_match),
previous_search_match: resolve_local!(keymap, defaults, pager, previous_search_match),
close: resolve_local!(keymap, defaults, pager, close),
close_transcript: resolve_local!(keymap, defaults, pager, close_transcript),
};
@@ -1069,6 +1079,7 @@ impl RuntimeKeymap {
cancel: default_bindings![plain(KeyCode::Esc)],
},
pager: PagerKeymap {
start_search: default_bindings![plain(KeyCode::Char('/'))],
scroll_up: default_bindings![plain(KeyCode::Up), plain(KeyCode::Char('k'))],
scroll_down: default_bindings![plain(KeyCode::Down), plain(KeyCode::Char('j'))],
page_up: default_bindings![
@@ -1085,6 +1096,16 @@ impl RuntimeKeymap {
half_page_down: default_bindings![ctrl(KeyCode::Char('d'))],
jump_top: default_bindings![plain(KeyCode::Home)],
jump_bottom: default_bindings![plain(KeyCode::End)],
previous_user_prompt: default_bindings![plain(KeyCode::Left), alt(KeyCode::Up)],
next_user_prompt: default_bindings![plain(KeyCode::Right), alt(KeyCode::Down)],
next_search_match: default_bindings![
plain(KeyCode::Enter),
plain(KeyCode::Char('n'))
],
previous_search_match: default_bindings![
shift(KeyCode::Char('n')),
plain(KeyCode::Char('N'))
],
close: default_bindings![plain(KeyCode::Char('q')), ctrl(KeyCode::Char('c'))],
close_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
},
@@ -1553,12 +1574,18 @@ impl RuntimeKeymap {
("jump_bottom", self.pager.jump_bottom.as_slice()),
("close", self.pager.close.as_slice()),
("close_transcript", self.pager.close_transcript.as_slice()),
(
"previous_user_prompt",
self.pager.previous_user_prompt.as_slice(),
),
("next_user_prompt", self.pager.next_user_prompt.as_slice()),
],
)?;
validate_no_reserved(
validate_unique(
"pager",
[
("start_search", self.pager.start_search.as_slice()),
("scroll_up", self.pager.scroll_up.as_slice()),
("scroll_down", self.pager.scroll_down.as_slice()),
("page_up", self.pager.page_up.as_slice()),
@@ -1569,6 +1596,43 @@ impl RuntimeKeymap {
("jump_bottom", self.pager.jump_bottom.as_slice()),
("close", self.pager.close.as_slice()),
("close_transcript", self.pager.close_transcript.as_slice()),
(
"previous_user_prompt",
self.pager.previous_user_prompt.as_slice(),
),
("next_user_prompt", self.pager.next_user_prompt.as_slice()),
("next_search_match", self.pager.next_search_match.as_slice()),
(
"previous_search_match",
self.pager.previous_search_match.as_slice(),
),
],
)?;
validate_no_reserved(
"pager",
[
("start_search", self.pager.start_search.as_slice()),
("scroll_up", self.pager.scroll_up.as_slice()),
("scroll_down", self.pager.scroll_down.as_slice()),
("page_up", self.pager.page_up.as_slice()),
("page_down", self.pager.page_down.as_slice()),
("half_page_up", self.pager.half_page_up.as_slice()),
("half_page_down", self.pager.half_page_down.as_slice()),
("jump_top", self.pager.jump_top.as_slice()),
("jump_bottom", self.pager.jump_bottom.as_slice()),
("close", self.pager.close.as_slice()),
("close_transcript", self.pager.close_transcript.as_slice()),
(
"previous_user_prompt",
self.pager.previous_user_prompt.as_slice(),
),
("next_user_prompt", self.pager.next_user_prompt.as_slice()),
("next_search_match", self.pager.next_search_match.as_slice()),
(
"previous_search_match",
self.pager.previous_search_match.as_slice(),
),
],
TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS,
[],
@@ -1782,24 +1846,10 @@ const MAIN_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
),
];
const TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
(
"fixed.transcript_edit_previous",
key_hint::plain(KeyCode::Esc),
),
(
"fixed.transcript_edit_previous",
key_hint::plain(KeyCode::Left),
),
(
"fixed.transcript_edit_next",
key_hint::plain(KeyCode::Right),
),
(
"fixed.transcript_confirm_edit",
key_hint::plain(KeyCode::Enter),
),
];
const TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[(
"fixed.transcript_edit_previous",
key_hint::plain(KeyCode::Esc),
)];
/// Resolve one action with context -> global -> default precedence.
///
@@ -2626,11 +2676,28 @@ mod tests {
#[test]
fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() {
let mut keymap = TuiKeymap::default();
keymap.pager.close = Some(one("left"));
keymap.pager.close = Some(one("esc"));
expect_conflict(&keymap, "close", "fixed.transcript_edit_previous");
}
#[test]
fn pager_prompt_selection_defaults_to_left_and_right_arrows() {
let runtime = RuntimeKeymap::defaults();
assert_eq!(
runtime.pager.previous_user_prompt,
vec![key_hint::plain(KeyCode::Left), key_hint::alt(KeyCode::Up)]
);
assert_eq!(
runtime.pager.next_user_prompt,
vec![
key_hint::plain(KeyCode::Right),
key_hint::alt(KeyCode::Down)
]
);
}
#[test]
fn parses_function_keys_and_rejects_out_of_range_function_keys() {
assert_eq!(

View File

@@ -167,6 +167,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("vim_text_object", "Vim text object", "single_quote", "Target enclosing single quotes."),
action("vim_text_object", "Vim text object", "backtick", "Target enclosing backticks."),
action("vim_text_object", "Vim text object", "cancel", "Cancel the pending text object."),
action("pager", "Pager", "start_search", "Start transcript search."),
action("pager", "Pager", "scroll_up", "Scroll up by one row."),
action("pager", "Pager", "scroll_down", "Scroll down by one row."),
action("pager", "Pager", "page_up", "Scroll up by one page."),
@@ -175,6 +176,10 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("pager", "Pager", "half_page_down", "Scroll down by half a page."),
action("pager", "Pager", "jump_top", "Jump to the beginning."),
action("pager", "Pager", "jump_bottom", "Jump to the end."),
action("pager", "Pager", "previous_user_prompt", "Move to the previous user prompt in transcript search mode."),
action("pager", "Pager", "next_user_prompt", "Move to the next user prompt in transcript search mode."),
action("pager", "Pager", "next_search_match", "Move to the next transcript search match."),
action("pager", "Pager", "previous_search_match", "Move to the previous transcript search match."),
action("pager", "Pager", "close", "Close the pager overlay."),
action("pager", "Pager", "close_transcript", "Close the transcript overlay."),
action("list", "List", "move_up", "Move list selection up."),
@@ -310,6 +315,7 @@ pub(super) fn binding_slot<'a>(
("vim_text_object", "single_quote") => Some(&mut keymap.vim_text_object.single_quote),
("vim_text_object", "backtick") => Some(&mut keymap.vim_text_object.backtick),
("vim_text_object", "cancel") => Some(&mut keymap.vim_text_object.cancel),
("pager", "start_search") => Some(&mut keymap.pager.start_search),
("pager", "scroll_up") => Some(&mut keymap.pager.scroll_up),
("pager", "scroll_down") => Some(&mut keymap.pager.scroll_down),
("pager", "page_up") => Some(&mut keymap.pager.page_up),
@@ -318,6 +324,10 @@ pub(super) fn binding_slot<'a>(
("pager", "half_page_down") => Some(&mut keymap.pager.half_page_down),
("pager", "jump_top") => Some(&mut keymap.pager.jump_top),
("pager", "jump_bottom") => Some(&mut keymap.pager.jump_bottom),
("pager", "previous_user_prompt") => Some(&mut keymap.pager.previous_user_prompt),
("pager", "next_user_prompt") => Some(&mut keymap.pager.next_user_prompt),
("pager", "next_search_match") => Some(&mut keymap.pager.next_search_match),
("pager", "previous_search_match") => Some(&mut keymap.pager.previous_search_match),
("pager", "close") => Some(&mut keymap.pager.close),
("pager", "close_transcript") => Some(&mut keymap.pager.close_transcript),
("list", "move_up") => Some(&mut keymap.list.move_up),
@@ -435,6 +445,7 @@ pub(super) fn bindings_for_action<'a>(
("vim_text_object", "single_quote") => Some(runtime_keymap.vim_text_object.single_quote.as_slice()),
("vim_text_object", "backtick") => Some(runtime_keymap.vim_text_object.backtick.as_slice()),
("vim_text_object", "cancel") => Some(runtime_keymap.vim_text_object.cancel.as_slice()),
("pager", "start_search") => Some(runtime_keymap.pager.start_search.as_slice()),
("pager", "scroll_up") => Some(runtime_keymap.pager.scroll_up.as_slice()),
("pager", "scroll_down") => Some(runtime_keymap.pager.scroll_down.as_slice()),
("pager", "page_up") => Some(runtime_keymap.pager.page_up.as_slice()),
@@ -443,6 +454,10 @@ pub(super) fn bindings_for_action<'a>(
("pager", "half_page_down") => Some(runtime_keymap.pager.half_page_down.as_slice()),
("pager", "jump_top") => Some(runtime_keymap.pager.jump_top.as_slice()),
("pager", "jump_bottom") => Some(runtime_keymap.pager.jump_bottom.as_slice()),
("pager", "previous_user_prompt") => Some(runtime_keymap.pager.previous_user_prompt.as_slice()),
("pager", "next_user_prompt") => Some(runtime_keymap.pager.next_user_prompt.as_slice()),
("pager", "next_search_match") => Some(runtime_keymap.pager.next_search_match.as_slice()),
("pager", "previous_search_match") => Some(runtime_keymap.pager.previous_search_match.as_slice()),
("pager", "close") => Some(runtime_keymap.pager.close.as_slice()),
("pager", "close_transcript") => Some(runtime_keymap.pager.close_transcript.as_slice()),
("list", "move_up") => Some(runtime_keymap.list.move_up.as_slice()),

View File

@@ -133,6 +133,7 @@ mod external_agent_config_migration;
mod external_agent_config_migration_startup;
mod external_editor;
mod file_search;
mod footer_hints;
mod frames;
mod get_git_diff;
mod git_action_directives;
@@ -163,6 +164,7 @@ mod npm_registry;
pub(crate) mod onboarding;
mod oss_selection;
mod pager_overlay;
mod pager_overlay_search;
mod permission_compat;
pub(crate) mod public_widgets;
mod render;
@@ -191,6 +193,7 @@ mod text_formatting;
mod theme_picker;
mod token_usage;
mod tooltips;
mod transcript_layout;
mod transcript_reflow;
mod tui;
mod ui_consts;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,260 @@
use crate::history_cell::HistoryCell;
use crate::history_cell::HistoryRenderMode;
use crate::wrapping::wrap_ranges_trim;
use ratatui::text::Line;
use unicode_width::UnicodeWidthChar;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct SearchMatch {
pub(crate) renderable_index: usize,
pub(crate) line_index: usize,
pub(crate) scroll_line_index: usize,
pub(crate) start_col: u16,
pub(crate) end_col: u16,
pub(crate) owning_user_prompt_cell: Option<usize>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct SearchCorpusKey {
pub(crate) width: u16,
pub(crate) render_mode: HistoryRenderMode,
pub(crate) revision: u64,
}
pub(crate) struct SearchCorpus {
key: SearchCorpusKey,
lines: Vec<SearchLine>,
}
impl SearchCorpus {
pub(crate) fn new(key: SearchCorpusKey, lines: Vec<SearchLine>) -> Self {
Self { key, lines }
}
pub(crate) fn matches_key(&self, key: SearchCorpusKey) -> bool {
self.key == key
}
pub(crate) fn find_matches(&self, query: &str) -> Vec<SearchMatch> {
self.lines
.iter()
.flat_map(|line| line.find_matches(query))
.collect()
}
}
pub(crate) struct SearchLine {
renderable_index: usize,
line_index: usize,
scroll_line_index: usize,
width: u16,
folded_text: String,
plain_text: String,
display_cols_by_byte: Vec<u16>,
source_bytes_by_folded_byte: Vec<usize>,
owning_user_prompt_cell: Option<usize>,
}
impl SearchLine {
pub(crate) fn from_line(
renderable_index: usize,
line_index: usize,
scroll_line_index: usize,
width: u16,
line: &Line<'static>,
owning_user_prompt_cell: Option<usize>,
) -> Self {
let plain_text = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
Self {
renderable_index,
line_index,
scroll_line_index,
width,
folded_text: folded_text(&plain_text),
display_cols_by_byte: display_cols_by_folded_byte(&plain_text),
source_bytes_by_folded_byte: source_bytes_by_folded_byte(&plain_text),
plain_text,
owning_user_prompt_cell,
}
}
fn find_matches(&self, query: &str) -> Vec<SearchMatch> {
if query.is_empty() {
return Vec::new();
}
let mut matches = Vec::new();
let mut start = 0usize;
while let Some(found) = self.folded_text[start..].find(query) {
let match_start = start.saturating_add(found);
let match_end = match_start.saturating_add(query.len());
matches.push(SearchMatch {
renderable_index: self.renderable_index,
line_index: self.line_index,
scroll_line_index: self
.scroll_line_index
.saturating_add(self.wrapped_row(match_start)),
start_col: self.display_col(match_start),
end_col: self.display_col(match_end),
owning_user_prompt_cell: self.owning_user_prompt_cell,
});
let Some(ch) = self.folded_text[match_start..].chars().next() else {
break;
};
start = match_start.saturating_add(ch.len_utf8());
}
matches
}
fn display_col(&self, byte_index: usize) -> u16 {
self.display_cols_by_byte
.get(byte_index)
.copied()
.unwrap_or_default()
}
fn wrapped_row(&self, folded_byte_index: usize) -> usize {
let source_byte_index = self
.source_bytes_by_folded_byte
.get(folded_byte_index)
.copied()
.unwrap_or(self.plain_text.len());
wrap_ranges_trim(&self.plain_text, usize::from(self.width.max(/*other*/ 1)))
.iter()
.position(|range| range.contains(&source_byte_index))
.unwrap_or_default()
}
}
pub(crate) fn transcript_search_lines(
cells: &[std::sync::Arc<dyn HistoryCell>],
live_tail_lines: Option<&[Line<'static>]>,
render_mode: HistoryRenderMode,
width: u16,
rendered_line_height: impl Fn(&Line<'static>, u16) -> usize,
live_tail_has_top_padding: bool,
) -> Vec<SearchLine> {
let mut search_lines = Vec::new();
let mut owner_user_prompt = None;
for (idx, cell) in cells.iter().enumerate() {
if cell.is_user_prompt() {
owner_user_prompt = Some(idx);
}
let top_padding = usize::from(!cell.is_stream_continuation() && idx > 0);
let lines = cell.transcript_lines_for_mode(width, render_mode);
push_search_lines(
&mut search_lines,
idx,
&lines,
top_padding,
owner_user_prompt,
width,
&rendered_line_height,
);
}
if let Some(lines) = live_tail_lines {
push_search_lines(
&mut search_lines,
cells.len(),
lines,
usize::from(live_tail_has_top_padding),
owner_user_prompt,
width,
&rendered_line_height,
);
}
search_lines
}
fn push_search_lines(
search_lines: &mut Vec<SearchLine>,
renderable_index: usize,
lines: &[Line<'static>],
top_padding: usize,
owner_user_prompt: Option<usize>,
width: u16,
rendered_line_height: &impl Fn(&Line<'static>, u16) -> usize,
) {
let mut scroll_line_index = top_padding;
for (line_index, line) in lines.iter().enumerate() {
search_lines.push(SearchLine::from_line(
renderable_index,
line_index,
scroll_line_index,
width,
line,
owner_user_prompt,
));
scroll_line_index = scroll_line_index.saturating_add(rendered_line_height(line, width));
}
}
fn folded_text(text: &str) -> String {
text.chars().flat_map(char::to_lowercase).collect()
}
fn display_cols_by_folded_byte(text: &str) -> Vec<u16> {
let mut cols = vec![0];
let mut col = 0u16;
for ch in text.chars() {
let next_col =
col.saturating_add(u16::try_from(ch.width().unwrap_or(0)).unwrap_or(u16::MAX));
cols.extend(std::iter::repeat_n(
next_col,
ch.to_lowercase().map(char::len_utf8).sum(),
));
col = next_col;
}
cols
}
fn source_bytes_by_folded_byte(text: &str) -> Vec<usize> {
let mut source_bytes = vec![0];
for (source_byte_index, ch) in text.char_indices() {
let next_source_byte = source_byte_index.saturating_add(ch.len_utf8());
source_bytes.extend(std::iter::repeat_n(
next_source_byte,
ch.to_lowercase().map(char::len_utf8).sum(),
));
}
source_bytes
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn search_match_iteration_advances_on_utf8_boundaries() {
let line = SearchLine::from_line(
/*renderable_index*/ 0,
/*line_index*/ 0,
/*scroll_line_index*/ 0,
/*width*/ 80,
&Line::from("éé"),
/*owning_user_prompt_cell*/ None,
);
assert_eq!(line.find_matches("é").len(), 2);
}
#[test]
fn search_match_scroll_offset_uses_word_wrapping() {
let line = SearchLine::from_line(
/*renderable_index*/ 0,
/*line_index*/ 0,
/*scroll_line_index*/ 0,
/*width*/ 10,
&Line::from("aaaaa aaaaa needle"),
/*owning_user_prompt_cell*/ None,
);
assert_eq!(line.find_matches("needle")[0].scroll_line_index, 2);
}
}

View File

@@ -9,6 +9,10 @@ mod transcript;
use crate::app_server_session::AppServerSession;
use crate::color::blend;
use crate::color::is_light;
use crate::footer_hints::FooterHint;
use crate::footer_hints::first_fitting_right_label;
use crate::footer_hints::footer_hint_line_for_row;
use crate::footer_hints::render_footer_separator;
use crate::git_action_directives::parse_assistant_markdown;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_plain_text_key_event;
@@ -73,9 +77,6 @@ const SESSION_META_MIN_CWD_WIDTH: usize = 30;
const SESSION_META_MAX_CWD_WIDTH: usize = 72;
const SESSION_META_BRANCH_ICON: &str = "";
const SESSION_META_CWD_ICON: &str = "";
const FOOTER_COMPACT_BREAKPOINT: u16 = 120;
const FOOTER_HINT_LEFT_PADDING: usize = 1;
const FOOTER_HINT_GAP: usize = 3;
const PICKER_CHROME_HEIGHT: u16 = 8;
const PICKER_LIST_HORIZONTAL_INSET: u16 = 4;
@@ -980,6 +981,11 @@ impl PickerState {
self.overlay = Some(Overlay::new_transcript(
cells.clone(),
self.pager_keymap.clone(),
Vec::new(),
Vec::new(),
crate::pager_overlay::TranscriptOverlayState::new(
crate::history_cell::HistoryRenderMode::Rich,
),
));
self.pending_transcript_open = None;
self.transcript_loading_frame_shown = false;
@@ -2018,13 +2024,6 @@ fn filter_mode_label(filter_mode: SessionFilterMode) -> &'static str {
}
}
struct PickerFooterHint {
key: &'static str,
wide_label: String,
compact_label: String,
priority: u8,
}
fn render_picker_footer(
frame: &mut crate::custom_terminal::Frame,
area: Rect,
@@ -2036,9 +2035,9 @@ fn render_picker_footer(
}
let separator = Rect::new(area.x, area.y, area.width, 1);
render_picker_footer_separator(
frame,
render_footer_separator(
separator,
frame.buffer,
picker_footer_progress_label(state, list_height, area.width),
);
@@ -2052,30 +2051,6 @@ fn render_picker_footer(
}
}
fn render_picker_footer_separator(
frame: &mut crate::custom_terminal::Frame,
area: Rect,
progress_label: String,
) {
if area.width == 0 {
return;
}
let separator = "".repeat(area.width as usize);
frame.render_widget_ref(Line::from(separator.dim()), area);
let progress_width = UnicodeWidthStr::width(progress_label.as_str()) as u16;
if progress_width < area.width {
let percent_area = Rect::new(
area.x + area.width - progress_width - 1,
area.y,
progress_width,
1,
);
frame.render_widget_ref(Line::from(progress_label.dim()), percent_area);
}
}
fn picker_footer_progress_label(state: &PickerState, list_height: u16, width: u16) -> String {
let position = if state.filtered_rows.is_empty() {
0
@@ -2093,10 +2068,7 @@ fn picker_footer_progress_label(state: &PickerState, list_height: u16, width: u1
format!(" {position}/{total} · {percent}% "),
format!(" {percent}% "),
];
labels
.into_iter()
.find(|label| UnicodeWidthStr::width(label.as_str()) < width as usize)
.unwrap_or_default()
first_fitting_right_label(width, &labels)
}
fn picker_footer_percent(state: &PickerState, list_height: u16) -> u8 {
@@ -2142,23 +2114,10 @@ fn picker_footer_scroll_percent(state: &PickerState, list_height: u16) -> u8 {
fn footer_hint_lines(state: &PickerState, width: u16) -> Vec<Line<'static>> {
if state.is_transcript_loading() {
let hints = [
PickerFooterHint {
key: "loading",
wide_label: String::from("transcript"),
compact_label: String::from("transcript"),
priority: 0,
},
PickerFooterHint {
key: "ctrl+c",
wide_label: String::from("quit"),
compact_label: String::from("quit"),
priority: 1,
},
FooterHint::new("loading", "transcript", "transcript", /*priority*/ 0),
FooterHint::new("ctrl+c", "quit", "quit", /*priority*/ 1),
];
let line = fit_footer_hints(&hints, FooterHintLabelMode::Wide, width)
.or_else(|| fit_footer_hints(&hints, FooterHintLabelMode::Compact, width))
.or_else(|| fit_footer_hints(&hints, FooterHintLabelMode::KeyOnly, width))
.unwrap_or_default();
let line = footer_hint_line_for_row(&hints, width);
return vec![line, Line::default()];
}
@@ -2184,99 +2143,30 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec<Line<'static>> {
SessionListDensity::Dense => "comfy",
};
let first_row_hints = vec![
PickerFooterHint {
key: "enter",
wide_label: action_label.to_string(),
compact_label: action_label.to_string(),
priority: 0,
},
PickerFooterHint {
key: "esc",
wide_label: esc_label.to_string(),
compact_label: esc_compact_label.to_string(),
priority: 1,
},
PickerFooterHint {
key: "ctrl+c",
wide_label: ctrl_c_label.to_string(),
compact_label: ctrl_c_label.to_string(),
priority: 2,
},
PickerFooterHint {
key: "tab",
wide_label: String::from("focus sort/filter"),
compact_label: String::from("focus"),
priority: 7,
},
PickerFooterHint {
key: "←/→",
wide_label: String::from("change option"),
compact_label: String::from("option"),
priority: 8,
},
FooterHint::new("enter", action_label, action_label, /*priority*/ 0),
FooterHint::new("esc", esc_label, esc_compact_label, /*priority*/ 1),
FooterHint::new("ctrl+c", ctrl_c_label, ctrl_c_label, /*priority*/ 2),
FooterHint::new("tab", "focus sort/filter", "focus", /*priority*/ 7),
FooterHint::new("←/→", "change option", "option", /*priority*/ 8),
];
let second_row_hints = vec![
PickerFooterHint {
key: "ctrl+o",
wide_label: density_label.to_string(),
compact_label: density_compact_label.to_string(),
priority: 3,
},
PickerFooterHint {
key: "ctrl+t",
wide_label: String::from("transcript"),
compact_label: String::from("preview"),
priority: 4,
},
PickerFooterHint {
key: "ctrl+e",
wide_label: String::from("expand"),
compact_label: String::from("exp"),
priority: 6,
},
PickerFooterHint {
key: "↑/↓",
wide_label: String::from("browse"),
compact_label: String::from("browse"),
priority: 5,
},
FooterHint::new(
"ctrl+o",
density_label,
density_compact_label,
/*priority*/ 3,
),
FooterHint::new("ctrl+t", "transcript", "preview", /*priority*/ 4),
FooterHint::new("ctrl+e", "expand", "exp", /*priority*/ 6),
FooterHint::new("↑/↓", "browse", "browse", /*priority*/ 5),
];
vec![
hint_line_for_row(&first_row_hints, width),
hint_line_for_row(&second_row_hints, width),
footer_hint_line_for_row(&first_row_hints, width),
footer_hint_line_for_row(&second_row_hints, width),
]
}
fn hint_line_for_row(hints: &[PickerFooterHint], width: u16) -> Line<'static> {
if width >= FOOTER_COMPACT_BREAKPOINT
&& let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Wide, width)
{
return line;
}
if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Compact, width) {
return line;
}
if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::KeyOnly, width) {
return line;
}
let mut retained = (0..hints.len()).collect::<Vec<_>>();
retained.sort_by_key(|idx| hints[*idx].priority);
for retain_count in (1..=retained.len()).rev() {
let mut candidate_indices = retained[..retain_count].to_vec();
candidate_indices.sort_unstable();
let candidate = candidate_indices
.iter()
.map(|idx| &hints[*idx])
.collect::<Vec<_>>();
if let Some(line) = fit_footer_hint_refs(&candidate, FooterHintLabelMode::KeyOnly, width) {
return line;
}
}
Line::default()
}
fn render_transcript_loading_overlay(frame: &mut crate::custom_terminal::Frame, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
@@ -2326,99 +2216,6 @@ fn transcript_loading_overlay_style() -> Style {
Style::default().bg(best_color(blend(overlay, bg, alpha)))
}
#[derive(Clone, Copy)]
enum FooterHintLabelMode {
Wide,
Compact,
KeyOnly,
}
fn fit_footer_hints(
hints: &[PickerFooterHint],
mode: FooterHintLabelMode,
width: u16,
) -> Option<Line<'static>> {
let hint_refs = hints.iter().collect::<Vec<_>>();
fit_footer_hint_refs(&hint_refs, mode, width)
}
fn fit_footer_hint_refs(
hints: &[&PickerFooterHint],
mode: FooterHintLabelMode,
width: u16,
) -> Option<Line<'static>> {
let gap_width = FOOTER_HINT_GAP;
if footer_hints_width(hints, mode, gap_width) > width as usize {
return None;
}
let mut spans = vec![
" ".repeat(FOOTER_HINT_LEFT_PADDING)
.set_style(footer_hint_label_style()),
];
for (idx, hint) in hints.iter().enumerate() {
if idx > 0 {
spans.push(" ".repeat(gap_width).set_style(footer_hint_label_style()));
}
spans.push(hint.key.set_style(footer_hint_key_style()));
let label = match mode {
FooterHintLabelMode::Wide => Some(hint.wide_label.as_str()),
FooterHintLabelMode::Compact => Some(hint.compact_label.as_str()),
FooterHintLabelMode::KeyOnly => None,
};
if let Some(label) = label {
spans.push(" ".set_style(footer_hint_label_style()));
spans.push(label.to_string().set_style(footer_hint_label_style()));
}
}
Some(spans.into())
}
fn footer_hint_key_style() -> Style {
if default_bg().is_some_and(is_light) {
Style::default().fg(Color::Black)
} else {
Style::default()
}
}
fn footer_hint_label_style() -> Style {
if default_bg().is_some_and(is_light) {
Style::default().fg(Color::DarkGray)
} else {
Style::default().dim()
}
}
fn footer_hints_width(
hints: &[&PickerFooterHint],
mode: FooterHintLabelMode,
gap_width: usize,
) -> usize {
FOOTER_HINT_LEFT_PADDING
+ hints
.iter()
.enumerate()
.map(|(idx, hint)| {
let label_width = match mode {
FooterHintLabelMode::Wide => {
1 + UnicodeWidthStr::width(hint.wide_label.as_str())
}
FooterHintLabelMode::Compact => {
1 + UnicodeWidthStr::width(hint.compact_label.as_str())
}
FooterHintLabelMode::KeyOnly => 0,
};
let hint_width = UnicodeWidthStr::width(hint.key) + label_width;
if idx == 0 {
hint_width
} else {
hint_width + gap_width
}
})
.sum::<usize>()
}
fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState) {
if area.height == 0 {
return;

View File

@@ -151,6 +151,19 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
});
LOGGER.write_json_line(value);
}
AppEvent::TranscriptLayoutReady(result) => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "transcript_layout_ready",
"generation": result.key.generation,
"width": result.key.width,
"cell_count": result.key.cell_count,
"height_count": result.heights.len(),
"total_height": result.total_height,
});
LOGGER.write_json_line(value);
}
AppEvent::StartFileSearch(query) => {
let value = json!({
"ts": now_ts(),

View File

@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
108 actions, 1 customized, 2 unbound.
113 actions, 1 customized, 2 unbound.
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug

View File

@@ -0,0 +1,18 @@
---
source: tui/src/pager_overlay.rs
expression: "buffer_to_text(&buf, area)"
---
Transcript ────────────────────────────────────────────────
before-11
second
after-0
after-1
after-2
after-3
────────────────────────────────────────────── 2 / 2 · 65% ─
↑/↓ scroll ←/→ prompts pgup/pgdn page home/end jump
q ctrl + o ⌥ + r esc/← → enter/← prev / search

View File

@@ -0,0 +1,13 @@
---
source: tui/src/pager_overlay.rs
expression: "buffer_to_text(&buf, area)"
---
Transcript ────────────────────────────────────────────────────────────────────
Loading transcript...
─────────────────────────────────────────────────────────── 0 / 0 · loading... ─
↑/↓ scroll ←/→ prompts pgup/pgdn page home/end jump
q quit ctrl + o copy ⌥ + r raw esc/← prev / search

View File

@@ -2,13 +2,13 @@
source: tui/src/pager_overlay.rs
expression: term.backend()
---
"/ S T A T I C / / / / / / / / / / / / / "
" S T A T I C · 100% ────────────────────"
"one "
"two "
"three "
"~ "
"~ "
"───────────────────────────────── 100% ─"
" ↑/↓ to scroll pgup/pgdn to page hom"
" q to quit "
"────────────────────────────────────────"
" ↑/↓ pgup/pgdn home/end "
" q quit "
" "

View File

@@ -2,11 +2,11 @@
source: tui/src/pager_overlay.rs
expression: term.backend()
---
"/ S T A T I C / / / / / "
" S T A T I C · 0% ──────"
"a very long line that "
"should wrap when "
"rendered within a narrow"
"─────────────────── 0% ─"
" ↑/↓ to scroll pgup/pg"
" q to quit "
"────────────────────────"
" ↑/↓ pgup/pgdn "
" q quit "
" "

View File

@@ -2,7 +2,7 @@
source: tui/src/pager_overlay.rs
expression: snapshot
---
/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
Transcript ────────────────────────────────────────────────────────────────────
• Added foo.txt (+2 -0)
1 +hello
2 +world
@@ -10,6 +10,6 @@ expression: snapshot
• Added foo.txt (+2 -0)
1 +hello
2 +world
─────────────────────────────────────────────────────────────────────────── 0% ─
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev
─────────────────────────────────────────────────────────────────── 0 / 0 · 0% ─
↑/↓ scroll ←/→ prompts pgup/pgdn page home/end jump
q quit ctrl + o copy ⌥ + r raw esc/← prev / search

View File

@@ -0,0 +1,11 @@
---
source: tui/src/pager_overlay.rs
expression: "render_snapshot(&mut overlay, Rect::new(0, 0, 80, 8),)"
---
Transcript ────────────────────────────────────────────────────────────────────
prompt
───────────────────────────────────────────────────────────────── 1 / 1 · 100% ─
↑/↓ scroll ←/→ prompts pgup/pgdn page … Copied selected turn to clipboard
q quit ctrl + o copy ⌥ + r raw esc/← prev / search

View File

@@ -0,0 +1,11 @@
---
source: tui/src/pager_overlay.rs
expression: "render_snapshot(&mut overlay, Rect::new(0, 0, 28, 8),)"
---
Transcript ────────────────
prompt
───────────── 1 / 1 · 100% ─
No agent response to copy f…
q ctrl + o ⌥ + r

View File

@@ -2,13 +2,13 @@
source: tui/src/pager_overlay.rs
expression: term.backend()
---
"/ T R A N S C R I P T / / / / / / / / / "
" Transcript ────────────────────────────"
"alpha "
" "
"tail "
"~ "
"~ "
"───────────────────────────────── 100% ─"
" ↑/↓ to scroll pgup/pgdn to page hom"
" q to quit esc to edit prev "
"───────────────────────── 0 / 0 · 100% ─"
" ↑/↓ ←/→ pgup/pgdn home/end "
" q ctrl + o ⌥ + r esc/← / "
" "

View File

@@ -0,0 +1,14 @@
---
source: tui/src/pager_overlay.rs
expression: term.backend()
---
" Transcript ──────────────────────────────────────"
"Search: needle 1/1"
" find this prompt "
" "
" "
"• assistant needle result "
"─────────────────────────────────── 1 / 1 · 100% ─"
" ↑/↓ ←/→ pgup/pgdn home/end "
" ctrl + t esc enter shift + n / "
" "

View File

@@ -2,13 +2,13 @@
source: tui/src/pager_overlay.rs
expression: term.backend()
---
"/ T R A N S C R I P T / / / / / / / / / "
" Transcript ────────────────────────────"
"alpha "
" "
"beta "
" "
"gamma "
"───────────────────────────────── 100% ─"
" ↑/↓ to scroll pgup/pgdn to page hom"
" q to quit esc to edit prev "
"───────────────────────── 0 / 0 · 100% ─"
" ↑/↓ ←/→ pgup/pgdn home/end "
" q ctrl + o ⌥ + r esc/← / "
" "

View File

@@ -0,0 +1,15 @@
---
source: tui/src/pager_overlay.rs
expression: "buffer_to_text(&buf, area)"
---
Transcript ────────────────────────────────────────────────
answer-4
answer-5
answer-6
answer-7
second
────────────────────────────────────────────── 2 / 2 · 73% ─
↑/↓ scroll ←/→ prompts pgup/pgdn page home/end jump
q ctrl + o ⌥ + r esc/← → enter/← prev / search

View File

@@ -0,0 +1,158 @@
use std::fmt;
use std::sync::Arc;
use std::thread;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::history_cell::HistoryCell;
use crate::history_cell::HistoryRenderMode;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct TranscriptLayoutKey {
pub(crate) generation: u64,
pub(crate) width: u16,
pub(crate) render_mode: HistoryRenderMode,
pub(crate) cell_count: usize,
}
pub(crate) struct TranscriptLayoutResult {
pub(crate) key: TranscriptLayoutKey,
pub(crate) heights: Vec<usize>,
pub(crate) total_height: usize,
}
impl fmt::Debug for TranscriptLayoutResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TranscriptLayoutResult")
.field("key", &self.key)
.field("height_count", &self.heights.len())
.field("total_height", &self.total_height)
.finish()
}
}
pub(crate) fn spawn_transcript_layout_worker(
key: TranscriptLayoutKey,
cells: Vec<Arc<dyn HistoryCell>>,
app_event_tx: AppEventSender,
) {
thread::spawn(move || {
let heights = measure_transcript_heights(&cells, key.width, key.render_mode);
let total_height = heights.iter().copied().sum();
app_event_tx.send(AppEvent::TranscriptLayoutReady(TranscriptLayoutResult {
key,
heights,
total_height,
}));
});
}
fn measure_transcript_heights(
cells: &[Arc<dyn HistoryCell>],
width: u16,
render_mode: HistoryRenderMode,
) -> Vec<usize> {
let worker_count = layout_worker_count(cells.len());
if worker_count <= 1 {
return measure_transcript_height_range(cells, width, render_mode, /*start*/ 0);
}
let chunk_size = cells.len().div_ceil(worker_count);
let mut chunks = thread::scope(|scope| {
let mut handles = Vec::with_capacity(worker_count);
for (chunk_idx, chunk) in cells.chunks(chunk_size).enumerate() {
let start = chunk_idx.saturating_mul(chunk_size);
handles.push(scope.spawn(move || {
(
start,
measure_transcript_height_range(chunk, width, render_mode, start),
)
}));
}
handles
.into_iter()
.map(|handle| handle.join().unwrap_or_else(|_| (0, Vec::new())))
.collect::<Vec<_>>()
});
chunks.sort_by_key(|(start, _)| *start);
let mut heights = Vec::with_capacity(cells.len());
for (_, chunk_heights) in chunks {
heights.extend(chunk_heights);
}
heights
}
fn measure_transcript_height_range(
cells: &[Arc<dyn HistoryCell>],
width: u16,
render_mode: HistoryRenderMode,
start: usize,
) -> Vec<usize> {
cells
.iter()
.enumerate()
.map(|(offset, cell)| {
let idx = start.saturating_add(offset);
let spacing = usize::from(idx > 0 && !cell.is_stream_continuation());
spacing.saturating_add(cell.desired_transcript_height_for_mode(width, render_mode) as usize)
})
.collect()
}
fn layout_worker_count(cell_count: usize) -> usize {
if cell_count == 0 {
return 1;
}
let parallelism = thread::available_parallelism().map_or(/*default*/ 1, usize::from);
parallelism.min(/*other*/ 4).min(cell_count)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::text::Line;
#[derive(Debug)]
struct TestCell {
lines: Vec<Line<'static>>,
stream_continuation: bool,
}
impl HistoryCell for TestCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
}
fn raw_lines(&self) -> Vec<Line<'static>> {
self.lines.clone()
}
fn is_stream_continuation(&self) -> bool {
self.stream_continuation
}
}
#[test]
fn measures_transcript_heights_with_inter_cell_spacing() {
let cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(TestCell {
lines: vec![Line::from("first")],
stream_continuation: false,
}),
Arc::new(TestCell {
lines: vec![Line::from("second")],
stream_continuation: false,
}),
Arc::new(TestCell {
lines: vec![Line::from("continuation")],
stream_continuation: true,
}),
];
let heights =
measure_transcript_heights(&cells, /*width*/ 80, HistoryRenderMode::Rich);
assert_eq!(heights, vec![1, 2, 1]);
}
}