Compare commits

...

7 Commits

Author SHA1 Message Date
Felipe Coury
61ac6bf526 fix(tui): scope transcript prompt selection to session 2026-05-18 20:08:08 -03:00
Felipe Coury
8144d71409 fix(tui): use ratatui terminal in perf probe 2026-05-18 16:02:35 -03:00
Felipe Coury
df9769d450 perf(tui): speed up transcript prompt selection 2026-05-18 15:51:55 -03:00
Felipe Coury
779a2ea084 test(tui): measure transcript prompt selection 2026-05-18 15:51:55 -03:00
Felipe Coury
702c534301 feat(tui): show transcript copy status in footer 2026-05-18 15:50:09 -03:00
Felipe Coury
afa78c9258 feat(tui): refine transcript overlay navigation 2026-05-18 14:18:59 -03:00
Felipe Coury
3c36838dfb feat(tui): improve transcript overlay 2026-05-18 12:20:27 -03:00
27 changed files with 1948 additions and 520 deletions

View File

@@ -295,6 +295,10 @@ pub struct TuiPagerKeymap {
pub close: Option<KeybindingsSpec>,
/// Close the transcript overlay via its dedicated toggle key.
pub close_transcript: Option<KeybindingsSpec>,
/// Jump to the previous user prompt in the transcript overlay.
pub previous_user_prompt: Option<KeybindingsSpec>,
/// Jump to the next user prompt in the transcript overlay.
pub next_user_prompt: Option<KeybindingsSpec>,
}
/// List selection context keybindings for popup-style selectable lists.

View File

@@ -2653,8 +2653,10 @@
"half_page_up": null,
"jump_bottom": null,
"jump_top": null,
"next_user_prompt": null,
"page_down": null,
"page_up": null,
"previous_user_prompt": null,
"scroll_down": null,
"scroll_up": null
},
@@ -3312,8 +3314,10 @@
"half_page_up": null,
"jump_bottom": null,
"jump_top": null,
"next_user_prompt": null,
"page_down": null,
"page_up": null,
"previous_user_prompt": null,
"scroll_down": null,
"scroll_up": null
}
@@ -3512,6 +3516,14 @@
],
"description": "Jump to the beginning."
},
"next_user_prompt": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Jump to the next user prompt in the transcript overlay."
},
"page_down": {
"allOf": [
{
@@ -3528,6 +3540,14 @@
],
"description": "Scroll up by one page."
},
"previous_user_prompt": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Jump to the previous user prompt in the transcript overlay."
},
"scroll_down": {
"allOf": [
{

View File

@@ -59,6 +59,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;
@@ -475,6 +476,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<Line<'static>>,
has_emitted_history_lines: bool,
transcript_reflow: TranscriptReflowState,
@@ -888,6 +890,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);
@@ -910,6 +914,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

@@ -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

@@ -31,6 +31,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

@@ -3838,6 +3838,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(),
@@ -3901,6 +3904,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(),
@@ -4985,6 +4991,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")];
app.backtrack.overlay_preview_active = true;
@@ -5226,6 +5235,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")];
app.has_emitted_history_lines = true;

View File

@@ -35,6 +35,7 @@ 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::tui;
use crate::tui::TuiEvent;
@@ -104,9 +105,9 @@ 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,
@@ -122,19 +123,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,13 +148,19 @@ 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,
..
}
) || self.keymap.pager.previous_user_prompt.is_pressed(key_event)
|| self.keymap.pager.next_user_prompt.is_pressed(key_event))
{
// First Esc in transcript overlay: begin backtrack preview at latest user message.
// First Esc / prompt-selection key in transcript overlay: begin backtrack preview at
// latest user message.
self.begin_overlay_backtrack_preview(tui);
Ok(true)
} else {
@@ -235,12 +238,18 @@ impl App {
self.overlay = Some(Overlay::new_transcript(
self.transcript_cells.clone(),
self.keymap.pager.clone(),
self.keymap.app.copy.clone(),
self.keymap.app.toggle_raw_output.clone(),
self.transcript_overlay_state,
));
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() {
@@ -311,7 +320,7 @@ 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);
let count = self.current_transcript_user_count();
if let Some(last) = count.checked_sub(1) {
self.apply_backtrack_selection_internal(last);
}
@@ -320,7 +329,7 @@ impl App {
/// 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;
}
@@ -343,7 +352,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;
}
@@ -364,16 +373,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),
}
}
@@ -418,16 +438,57 @@ impl App {
return Ok(());
}
if let Some(overlay) = &mut self.overlay {
let (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 (copy_selection, copy_latest) = match overlay {
Overlay::Transcript(transcript) => {
if transcript.take_copy_requested() {
match transcript.selected_user_cell() {
Some(user_cell_idx) => (Some(user_cell_idx), false),
None => (None, true),
}
} else {
(None, false)
}
}
Overlay::Static(_) => (None, false),
};
(copy_selection, copy_latest, overlay.is_done())
} else {
(None, false, false)
};
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;
@@ -449,7 +510,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,
@@ -658,7 +720,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
@@ -670,7 +731,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)]

View File

@@ -188,6 +188,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

@@ -226,7 +226,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(
@@ -241,30 +259,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

@@ -1417,32 +1417,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,375 @@
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 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…");
}
}

View File

@@ -12,6 +12,7 @@
//! 4. Completed runs only persist when they have output or a non-success status.
use super::HistoryCell;
use super::plain_lines;
use crate::history_cell::HistoryRenderMode;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
use crate::motion::activity_indicator;
@@ -341,6 +342,13 @@ 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 raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.display_lines(u16::MAX))
}
@@ -360,6 +368,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);
@@ -452,6 +499,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 {
@@ -802,6 +895,58 @@ mod tests {
);
}
#[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,39 @@ 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 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

@@ -223,13 +223,26 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
self.display_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 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 = self.transcript_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 = self.transcript_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[..]
@@ -252,6 +265,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

@@ -1960,6 +1960,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

@@ -198,6 +198,8 @@ pub(crate) struct PagerKeymap {
pub(crate) jump_bottom: Vec<KeyBinding>,
pub(crate) close: Vec<KeyBinding>,
pub(crate) close_transcript: Vec<KeyBinding>,
pub(crate) previous_user_prompt: Vec<KeyBinding>,
pub(crate) next_user_prompt: Vec<KeyBinding>,
}
/// Generic list picker keybindings shared across popup list views.
@@ -517,6 +519,8 @@ impl RuntimeKeymap {
jump_bottom: resolve_local!(keymap, defaults, pager, jump_bottom),
close: resolve_local!(keymap, defaults, pager, close),
close_transcript: resolve_local!(keymap, defaults, pager, close_transcript),
previous_user_prompt: resolve_local!(keymap, defaults, pager, previous_user_prompt),
next_user_prompt: resolve_local!(keymap, defaults, pager, next_user_prompt),
};
let approval = ApprovalKeymap {
@@ -828,6 +832,8 @@ impl RuntimeKeymap {
jump_bottom: default_bindings![plain(KeyCode::End)],
close: default_bindings![plain(KeyCode::Char('q')), ctrl(KeyCode::Char('c'))],
close_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
previous_user_prompt: default_bindings![plain(KeyCode::Left), alt(KeyCode::Up)],
next_user_prompt: default_bindings![plain(KeyCode::Right), alt(KeyCode::Down)],
},
list: ListKeymap {
move_up: default_bindings![
@@ -1239,6 +1245,11 @@ 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()),
],
)?;
@@ -1255,6 +1266,11 @@ 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()),
],
TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS,
)?;
@@ -1462,14 +1478,6 @@ 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),
@@ -2167,9 +2175,26 @@ 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("enter"));
expect_conflict(&keymap, "close", "fixed.transcript_edit_previous");
expect_conflict(&keymap, "close", "fixed.transcript_confirm_edit");
}
#[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]

View File

@@ -126,6 +126,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;

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,9 @@ 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::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;
@@ -74,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;
@@ -970,6 +970,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;
@@ -1999,13 +2004,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,
@@ -2017,9 +2015,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),
);
@@ -2033,30 +2031,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
@@ -2123,23 +2097,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()];
}
@@ -2165,99 +2126,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;
@@ -2307,99 +2199,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

@@ -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 · 0 prompts · 0% ───────────────────────────────────────────────────
• 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
───────────────────────────────────────────────────────────────────────────────
↑/↓ scroll ←/→ prompts pgup/pgdn page home/end jump
q quit ctrl + o copy ⌥ + r raw esc/← prev

View File

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

View File

@@ -0,0 +1,11 @@
---
source: tui/src/pager_overlay.rs
expression: "render_snapshot(&mut overlay, Rect::new(0, 0, 28, 8),)"
---
Transcript · 1 prompt · 100
prompt
────────────────────────────
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 · 0 prompts · 100% ─────────"
"alpha "
" "
"tail "
"~ "
"~ "
"───────────────────────────────── 100% ─"
" ↑/↓ to scroll pgup/pgdn to page hom"
" q to quit esc to edit prev "
"────────────────────────────────────────"
" ↑/↓ ←/→ pgup/pgdn home/end "
" q ctrl + o ⌥ + r esc/← "
" "

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 · 0 prompts · 100% ─────────"
"alpha "
" "
"beta "
" "
"gamma "
"───────────────────────────────── 100% ─"
" ↑/↓ to scroll pgup/pgdn to page hom"
" q to quit esc to edit prev "
"────────────────────────────────────────"
" ↑/↓ ←/→ pgup/pgdn home/end "
" q ctrl + o ⌥ + r esc/← "
" "