mirror of
https://github.com/openai/codex.git
synced 2026-05-21 19:45:26 +00:00
Compare commits
7 Commits
ee/pause-q
...
fcoury/opt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61ac6bf526 | ||
|
|
8144d71409 | ||
|
|
df9769d450 | ||
|
|
779a2ea084 | ||
|
|
702c534301 | ||
|
|
afa78c9258 | ||
|
|
3c36838dfb |
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
375
codex-rs/tui/src/footer_hints.rs
Normal file
375
codex-rs/tui/src/footer_hints.rs
Normal 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…");
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 "
|
||||
" "
|
||||
|
||||
@@ -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 "
|
||||
" "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/← "
|
||||
" "
|
||||
|
||||
@@ -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/← "
|
||||
" "
|
||||
|
||||
Reference in New Issue
Block a user