Unify TUI prompts with shared picker styling and layouts (#46695)

## Why

Prompt layouts use inconsistent selection styles, and long content can crowd out choices and keyboard hints in small terminals.

## What changed

- Apply shared picker surfaces, selection highlights, accent colors, and shortcut styling across approval, question, startup, and session prompts.
- Reuse numbered picker rows with wrapping and selected-item visibility. Bound folder paths to preserve trust disclosures and choices, prioritizing the repository root when space is limited.
- Give multiline prompts a shared layout for rendering and cursor placement, with Escape hints that reflect Vim mode.
- Present local providers as vertical choices with inline status, adding arrow-key and `j`/`k` navigation plus `1`/`2` selection shortcuts.

## Testing

Add snapshots and regression tests for narrow and short viewports, long trust paths, multiline cursor placement after resizing, selection styling, provider shortcuts, and confirmation behavior.

GitOrigin-RevId: 89bb133dfa0d085d568a3a84473afd9e2fb20838
This commit is contained in:
Eric Traut
2026-09-19 19:14:11 +00:00
committed by copyberry
parent e28fa3e7a4
commit c9d13e8c75
89 changed files with 1646 additions and 991 deletions
@@ -296,7 +296,9 @@ impl ApprovalOverlay {
};
let header = Box::new(ColumnRenderable::with([
Line::from(title.bold()).into(),
Paragraph::new(title.bold())
.wrap(Wrap { trim: false })
.into(),
Line::from("").into(),
header,
]));
@@ -312,12 +314,13 @@ impl ApprovalOverlay {
.collect();
let params = SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
footer_hint: Some(approval_footer_hint(request, approval_keymap, list_keymap)),
items,
header,
header_view_all_hint: approval_keymap
.primary_hint("open_fullscreen", &approval_keymap.open_fullscreen),
..Default::default()
..SelectionViewParams::picker()
};
(options, params)
@@ -716,7 +719,7 @@ fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
{
header.push(Line::from(vec![
"Permission rule: ".into(),
rule_line.cyan(),
rule_line.fg(crate::style::accent_color()),
]));
header.push(Line::from(""));
}
@@ -758,7 +761,7 @@ fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
if let Some(rule_line) = format_requested_permissions_rule(&request.permissions) {
header.push(Line::from(vec![
"Permission rule: ".into(),
rule_line.cyan(),
rule_line.fg(crate::style::accent_color()),
]));
}
Box::new(Paragraph::new(header).wrap(Wrap { trim: false }))
@@ -778,8 +781,7 @@ fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
Line::from(""),
Line::from(request.message.clone()),
]);
let header = Paragraph::new(lines).wrap(Wrap { trim: false });
Box::new(header)
Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))
}
}
}
@@ -55,7 +55,7 @@ fn clipped_exec_approval_opens_the_complete_command() {
.collect();
let elision = rows.iter().find(|row| row.contains("[…")).unwrap();
assert_eq!(elision.contains("ctrl+g view all"), binding.is_some());
assert!(rows.iter().any(|row| row.contains("esc")));
assert!(rows.last().unwrap().contains("cancel"));
assert!(!rows.iter().any(|row| row.contains("destructive_suffix")));
assert!(!rows.iter().any(|row| row.contains("reason_suffix")));
assert!(rows.iter().any(|row| row.contains("Yes, proceed")));
@@ -12,24 +12,24 @@ bound=true 80x14
Reason: A reason requiring review.A reason requiring review.A reason
requiring review.A reason requiring review.A reason requiring review.A
reason requiring review.A reason requiring review.A reason requiring
review.A reason requiring review.A reason requiring review.A reason
requiring review.A reason requiring review.A reason requiring review.A
[… 34 lines] ctrl+g view all
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
bound=true 40x10
Would you like to run the following
command?
[… 42 lines] ctrl+g view all
[… 43 lines] ctrl+g view all
› 1. Yes, proceed (y)
2. No, and tell Codex what to do
differently (esc)
Press enter to confirm or esc to cance
Press enter to confirm or esc to
cancel
bound=false 80x14
@@ -40,21 +40,21 @@ bound=false 80x14
Reason: A reason requiring review.A reason requiring review.A reason
requiring review.A reason requiring review.A reason requiring review.A
reason requiring review.A reason requiring review.A reason requiring
review.A reason requiring review.A reason requiring review.A reason
requiring review.A reason requiring review.A reason requiring review.A
[… 34 lines]
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
bound=false 40x10
Would you like to run the following
command?
[… 42 lines]
[… 43 lines]
› 1. Yes, proceed (y)
2. No, and tell Codex what to do
differently (esc)
Press enter to confirm or esc to cance
Press enter to confirm or esc to
cancel
@@ -184,6 +184,10 @@ impl AsyncQuestions {
let number = index + 1;
let prefix = format!("{prefix} {number}. ");
GenericDisplayRow {
category_tag: None,
// Other stays an inline editor with foreground-only focus.
selection_style: (index < self.options().len())
.then(super::picker_style::selection_style),
name: format!("{prefix}{label}"),
wrap_indent: Some(prefix.width()),
..Default::default()
@@ -449,6 +449,38 @@ fn question_navigation_resets_history_recall() {
assert_eq!(editor.composer.current_text(), "newer");
}
#[test]
fn named_choices_share_selection_while_other_keeps_its_inline_cursor() {
crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors {
fg: (30, 30, 30),
bg: (255, 255, 255),
},
|| {
let mut editor = editor();
editor.navigate(/*forward*/ true);
for width in [40, 80] {
editor.select_option(/*index*/ 0);
let buffer = render_editor(&editor, width, /*height*/ 16);
let cell = buffer
.content
.iter()
.find(|cell| cell.symbol() == "N")
.unwrap();
assert_eq!(cell.bg, crate::bottom_pane::selection_style().bg.unwrap());
editor.select_option(/*index*/ 1);
let buffer = render_editor(&editor, width, /*height*/ 16);
let cursor = editor.cursor_pos(buffer.area).unwrap();
assert_eq!(buffer[cursor].symbol(), "O");
assert_ne!(
buffer[cursor].bg,
crate::bottom_pane::selection_style().bg.unwrap()
);
}
},
);
}
#[test]
fn answered_questions_do_not_reopen_when_history_precedes_local_drafts() {
let mut original = editor();
@@ -1,3 +1,9 @@
//! Multiline prompt editing with shared-picker presentation.
//!
//! Presentation does not change submission, paste-burst, Vim, or suggestion state.
mod picker;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -5,7 +11,6 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::StatefulWidgetRef;
@@ -18,12 +23,8 @@ use crate::key_hint;
use crate::key_hint::has_ctrl_or_alt;
use crate::keymap::KeymapContextSet;
use crate::keymap::RuntimeKeymap;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::renderable::Renderable;
use super::popup_consts::accept_cancel_hint_line;
use super::popup_consts::standard_popup_hint_line;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::bottom_pane_view::ViewCompletion;
@@ -260,152 +261,17 @@ mod tests;
impl Renderable for CustomPromptView {
fn desired_height(&self, width: u16) -> u16 {
let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 };
1u16 + extra_top + self.input_height(width) + 3u16
self.picker_desired_height(width)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let input_height = self.input_height(area.width);
// Title line
let title_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
};
let title_spans: Vec<Span<'static>> = vec![gutter(), self.title.clone().bold()];
Paragraph::new(Line::from(title_spans)).render(title_area, buf);
// Optional context line
let mut input_y = area.y.saturating_add(1);
if let Some(context_label) = &self.context_label {
let context_area = Rect {
x: area.x,
y: input_y,
width: area.width,
height: 1,
};
let spans: Vec<Span<'static>> = vec![gutter(), context_label.clone().cyan()];
Paragraph::new(Line::from(spans)).render(context_area, buf);
input_y = input_y.saturating_add(1);
}
// Input line
let input_area = Rect {
x: area.x,
y: input_y,
width: area.width,
height: input_height,
};
if input_area.width >= 2 {
for row in 0..input_area.height {
Paragraph::new(Line::from(vec![gutter()])).render(
Rect {
x: input_area.x,
y: input_area.y.saturating_add(row),
width: 2,
height: 1,
},
buf,
);
}
let text_area_height = input_area.height.saturating_sub(1);
if text_area_height > 0 {
if input_area.width > 2 {
let blank_rect = Rect {
x: input_area.x.saturating_add(2),
y: input_area.y,
width: input_area.width.saturating_sub(2),
height: 1,
};
Clear.render(blank_rect, buf);
}
let textarea_rect = Rect {
x: input_area.x.saturating_add(2),
y: input_area.y.saturating_add(1),
width: input_area.width.saturating_sub(2),
height: text_area_height,
};
let mut state = self.textarea_state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
if self.textarea.text().is_empty() {
Paragraph::new(Line::from(self.placeholder.clone().dim()))
.render(textarea_rect, buf);
}
}
}
let hint_blank_y = input_area.y.saturating_add(input_height);
if hint_blank_y < area.y.saturating_add(area.height) {
let blank_area = Rect {
x: area.x,
y: hint_blank_y,
width: area.width,
height: 1,
};
Clear.render(blank_area, buf);
}
let hint_y = hint_blank_y.saturating_add(1);
if hint_y < area.y.saturating_add(area.height) {
let mut hint_line = if self.prefer_esc_to_handle_key_event() {
accept_cancel_hint_line(
Some(key_hint::plain(KeyCode::Enter).into()),
"to confirm",
Some(key_hint::plain(KeyCode::Esc).into()),
"to enter normal mode",
)
} else {
standard_popup_hint_line()
};
let hint_area = Rect {
x: area.x,
y: hint_y,
width: area.width,
height: 1,
};
let mut vim_mode = self.textarea.vim_mode_indicator_span().map(Line::from);
let available_hint_width = vim_mode
.as_ref()
.and_then(|line| max_left_width_for_right(hint_area, line.width() as u16))
.filter(|width| *width > 0);
if vim_mode.is_some() && available_hint_width.is_none() {
vim_mode = None;
}
if let Some(width) = available_hint_width {
hint_line = truncate_line_with_ellipsis_if_overflow(hint_line, width as usize);
}
Paragraph::new(hint_line).render(hint_area, buf);
if let Some(line) = vim_mode {
render_context_right(hint_area, buf, &line);
}
if !area.is_empty() {
self.render_picker(area, buf);
}
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if area.height < 2 || area.width <= 2 {
return None;
}
let text_area_height = self.input_height(area.width).saturating_sub(1);
if text_area_height == 0 {
return None;
}
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
let top_line_count = 1u16 + extra_offset;
let textarea_rect = Rect {
x: area.x.saturating_add(2),
y: area.y.saturating_add(top_line_count).saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
self.picker_cursor_pos(area)
}
fn cursor_style(&self, _area: Rect) -> crossterm::cursor::SetCursorStyle {
@@ -416,15 +282,3 @@ impl Renderable for CustomPromptView {
}
}
}
impl CustomPromptView {
fn input_height(&self, width: u16) -> u16 {
let usable_width = width.saturating_sub(2);
let text_height = self.textarea.desired_height(usable_width).clamp(1, 8);
text_height.saturating_add(1).min(9)
}
}
fn gutter() -> Span<'static> {
"▌ ".cyan()
}
@@ -0,0 +1,125 @@
//! Shared-picker prompt layout; rendering and cursor positioning use the same input rectangle.
use super::*;
use crate::style::accent_color;
use crate::style::user_message_style;
use ratatui::widgets::Block;
use ratatui::widgets::Wrap;
struct PromptAreas {
panel: Rect,
header: Rect,
input: Rect,
footer: Rect,
}
impl CustomPromptView {
fn picker_header(&self) -> Paragraph<'_> {
let mut lines = vec![Line::from(self.title.as_str().bold())];
if let Some(context) = &self.context_label {
lines.push(Line::from(context.as_str().fg(accent_color())));
}
Paragraph::new(lines).wrap(Wrap { trim: false })
}
pub(super) fn picker_desired_height(&self, width: u16) -> u16 {
let content_width = width.saturating_sub(/*rhs*/ 4);
self.picker_header()
.desired_height(content_width)
.saturating_add(
self.textarea
.desired_height(content_width)
.clamp(/*min*/ 1, /*max*/ 8),
)
.saturating_add(/*rhs*/ 4)
}
fn picker_areas(&self, area: Rect) -> PromptAreas {
let panel = Rect {
height: area.height.saturating_sub(/*rhs*/ 1),
..area
};
let width = area.width.saturating_sub(/*rhs*/ 4);
let top_gap = u16::from(panel.height >= 4);
let header_height = self
.picker_header()
.desired_height(width)
.min(panel.height.saturating_sub(top_gap + 1));
let remaining = panel.height.saturating_sub(top_gap + header_height);
let gap = u16::from(remaining >= 3);
let header = Rect {
x: area.x.saturating_add(/*rhs*/ 2),
y: area.y.saturating_add(top_gap),
width,
height: header_height,
};
PromptAreas {
panel,
header,
input: Rect {
y: header.bottom().saturating_add(gap),
height: remaining.saturating_sub(gap * 2),
..header
},
footer: Rect {
y: panel.bottom(),
height: u16::from(area.height > 0),
..header
},
}
}
pub(super) fn render_picker(&self, area: Rect, buf: &mut Buffer) {
let areas = self.picker_areas(area);
Clear.render(area, buf);
Block::default()
.style(user_message_style())
.render(areas.panel, buf);
self.picker_header().render(areas.header, buf);
if !areas.input.is_empty() {
let mut state = self.textarea_state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.textarea), areas.input, buf, &mut state);
if self.textarea.text().is_empty() {
Paragraph::new(self.placeholder.as_str().dim()).render(areas.input, buf);
}
Line::from("›".fg(accent_color())).render(
Rect {
x: area.x,
width: 1,
height: 1,
..areas.input
},
buf,
);
}
let cancel_label = if self.prefer_esc_to_handle_key_event() {
" normal mode"
} else {
" back"
};
let hint = Line::from(vec![
key_hint::plain(KeyCode::Enter).into(),
" submit · ".dim(),
key_hint::plain(KeyCode::Esc).into(),
cancel_label.dim(),
]);
let vim_mode = self.textarea.vim_mode_indicator_span().map(Line::from);
Paragraph::new(hint.clone()).render(areas.footer, buf);
if let Some(mode) = vim_mode
&& max_left_width_for_right(areas.footer, mode.width() as u16)
.is_some_and(|width| usize::from(width) >= hint.width())
{
render_context_right(areas.footer, buf, &mode);
}
}
pub(super) fn picker_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let input = self.picker_areas(area).input;
if input.is_empty() {
return None;
}
self.textarea
.cursor_pos_with_state(input, *self.textarea_state.borrow())
}
}
@@ -5,6 +5,44 @@ use crate::keymap::RuntimeKeymap;
use pretty_assertions::assert_eq;
use std::sync::mpsc::Receiver;
#[test]
fn picker_prompt_keeps_multiline_cursor_and_escape_hint_visible_after_resize() {
let (mut view, submitted_rx) = custom_prompt_view();
view.enable_vim_in_insert_mode();
view.handle_paste("Check the parser and preserve 日本語.\n".repeat(/*n*/ 12));
for (width, height) in [(80, 16), (40, 8), (80, 16), (12, 3)] {
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let (x, y) = view.cursor_pos(area).expect("editable row remains visible");
assert!(
x >= 2 && x < width - 2 && y < height - 1,
"cursor {x},{y} in {area:?}"
);
if width >= 40 {
let rows = (0..height)
.map(|y| {
(0..width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_owned()
})
.collect::<Vec<_>>();
assert!(rows.last().unwrap().contains("esc normal mode"));
insta::assert_snapshot!(
format!("picker_prompt_multiline_{width}x{height}"),
rows.join("\n")
);
}
}
view.handle_key_event(KeyEvent::from(KeyCode::Esc));
assert!(!view.is_complete(), "first Escape enters Vim normal mode");
view.handle_key_event(KeyEvent::from(KeyCode::Esc));
assert_eq!(view.completion(), Some(ViewCompletion::Cancelled));
assert!(submitted_rx.try_recv().is_err());
}
#[test]
fn paste_burst_newline_does_not_submit_short_first_line() {
let now = Instant::now();
@@ -97,7 +135,7 @@ fn vim_insert_escape_clears_paste_burst_before_enter() {
fn vim_prompt_hint_tracks_escape_behavior() {
let (mut view, _submitted_rx) = custom_prompt_view();
let rendered_hint = |view: &CustomPromptView, width: u16| {
let area = Rect::new(0, 0, width, 5);
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 5);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
(0..area.width)
@@ -107,24 +145,25 @@ fn vim_prompt_hint_tracks_escape_behavior() {
.to_string()
};
let vim_color = |view: &CustomPromptView| {
let area = Rect::new(0, 0, 80, 5);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 5,
);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
buf[(67, 4)].style().fg
};
insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to go back");
let mut hints = vec![rendered_hint(&view, /*width*/ 80)];
view.enable_vim_in_insert_mode();
insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to enter normal mode Vim: Insert");
insta::assert_snapshot!(rendered_hint(&view, /*width*/ 60), @"Press enter to confirm or esc to enter norm… Vim: Insert");
insta::assert_snapshot!(rendered_hint(&view, /*width*/ 14), @"Press enter to");
hints.extend([80, 60, 14].map(|width| rendered_hint(&view, width)));
assert_eq!(vim_color(&view), Some(ratatui::style::Color::Green));
view.handle_key_event(KeyEvent::from(KeyCode::Esc));
insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to go back Vim: Normal");
hints.push(rendered_hint(&view, /*width*/ 80));
assert_eq!(vim_color(&view), Some(ratatui::style::Color::Magenta));
view.handle_key_event(KeyEvent::from(KeyCode::Char('R')));
insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to enter normal mode Vim: Replace");
hints.push(rendered_hint(&view, /*width*/ 80));
insta::assert_snapshot!(hints.join("\n"));
}
#[test]
@@ -221,6 +221,8 @@ impl Renderable for FeedbackNoteView {
return;
}
buf.set_style(area, crate::style::user_message_style());
// Four rows leave room for one intro line; show the disclosure before submission.
let intro_content = if area.height == 4 {
IntroContent::DisclosureOnly
@@ -289,6 +291,7 @@ impl Renderable for FeedbackNoteView {
height: 1,
};
Clear.render(blank_rect, buf);
buf.set_style(blank_rect, crate::style::user_message_style());
}
let textarea_rect = Rect {
x: input_area.x.saturating_add(2),
@@ -314,6 +317,7 @@ impl Renderable for FeedbackNoteView {
height: 1,
};
Clear.render(blank_area, buf);
buf.set_style(blank_area, crate::style::user_message_style());
if max_scroll > 0 {
Paragraph::new(Line::from(vec![
key_hint::plain(KeyCode::PageUp).into(),
@@ -384,13 +388,13 @@ impl FeedbackNoteView {
);
lines.push(Line::from(vec![
"By submitting feedback, you agree that OpenAI can use your feedback for safety purposes and internal model training, as explained in more detail ".dim(),
"here".cyan().underlined(),
"here".fg(crate::style::accent_color()).underlined(),
". Please avoid sharing tented information under NDA, or privileged, HR-related, or sensitive personal information.".dim(),
]));
}
FeedbackAudience::External => lines.push(Line::from(vec![
"Your feedback can be used to improve ChatGPT. ".dim(),
"Learn more".cyan().underlined(),
"Learn more".fg(crate::style::accent_color()).underlined(),
".".dim(),
])),
}
@@ -404,7 +408,7 @@ impl FeedbackNoteView {
}
fn gutter() -> Span<'static> {
"▌ ".cyan()
"▌ ".fg(crate::style::accent_color())
}
fn feedback_title_and_placeholder(category: FeedbackCategory) -> (String, String) {
+15 -6
View File
@@ -68,7 +68,10 @@ pub(crate) fn feedback_success_cell(
Some(url) if feedback_audience == FeedbackAudience::OpenAiEmployee => {
lines.extend([
"".into(),
Line::from(vec![" ".into(), url.cyan().underlined()]),
Line::from(vec![
" ".into(),
url.fg(crate::style::accent_color()).underlined(),
]),
"".into(),
Line::from(vec![
" Sentry Feedback ID: ".into(),
@@ -77,7 +80,7 @@ pub(crate) fn feedback_success_cell(
Line::from(vec![
" Sentry URL: ".into(),
format!("https://go/codex-feedback/{thread_id}")
.cyan()
.fg(crate::style::accent_color())
.underlined(),
]),
]);
@@ -85,7 +88,10 @@ pub(crate) fn feedback_success_cell(
Some(url) => {
lines.extend([
"".into(),
Line::from(vec![" ".into(), url.cyan().underlined()]),
Line::from(vec![
" ".into(),
url.fg(crate::style::accent_color()).underlined(),
]),
"".into(),
Line::from(vec![
" Or mention your thread ID ".into(),
@@ -139,6 +145,7 @@ pub(crate) fn feedback_selection_params(
app_event_tx: AppEventSender,
) -> super::SelectionViewParams {
super::SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("How was this?".to_string()),
items: vec![
make_feedback_item(
@@ -172,13 +179,14 @@ pub(crate) fn feedback_selection_params(
FeedbackCategory::Other,
),
],
..Default::default()
..super::SelectionViewParams::picker()
}
}
/// Build the selection popup params shown when feedback is disabled.
pub(crate) fn feedback_disabled_params() -> super::SelectionViewParams {
super::SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Sending feedback is disabled".to_string()),
subtitle: Some("This action is disabled by configuration.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
@@ -187,7 +195,7 @@ pub(crate) fn feedback_disabled_params() -> super::SelectionViewParams {
dismiss_on_select: true,
..Default::default()
}],
..Default::default()
..super::SelectionViewParams::picker()
}
}
@@ -302,6 +310,7 @@ pub(crate) fn feedback_upload_consent_params(
}
super::SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
footer_hint: Some(standard_popup_hint_line()),
items: vec![
super::SelectionItem {
@@ -324,7 +333,7 @@ pub(crate) fn feedback_upload_consent_params(
header: Box::new(crate::render::renderable::ColumnRenderable::with(
header_lines,
)),
..Default::default()
..super::SelectionViewParams::picker()
}
}
@@ -49,6 +49,7 @@ use crate::bottom_pane::selection_popup_common::menu_surface_inset;
use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;
use crate::bottom_pane::selection_popup_common::render_menu_surface;
use crate::bottom_pane::selection_popup_common::render_rows;
use crate::footer_hint::shortcut;
use crate::key_hint::ShortcutHint;
use crate::keymap::KeymapContext;
use crate::keymap::ListAction;
@@ -184,28 +185,6 @@ struct McpServerElicitationAnswerState {
answer_committed: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct FooterTip {
text: String,
highlight: bool,
}
impl FooterTip {
fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
highlight: false,
}
}
fn highlighted(text: impl Into<String>) -> Self {
Self {
text: text.into(),
highlight: true,
}
}
}
impl McpServerElicitationFormRequest {
pub(crate) fn from_app_server_request(
thread_id: ThreadId,
@@ -942,6 +921,8 @@ impl McpServerElicitationOverlay {
let prefix_label = format!("{prefix} {number}. ");
let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str());
GenericDisplayRow {
category_tag: None,
selection_style: Some(crate::bottom_pane::selection_style()),
name: format!("{prefix_label}{}", option.label),
description: option.description.clone(),
wrap_indent: Some(wrap_indent),
@@ -988,7 +969,7 @@ impl McpServerElicitationOverlay {
sections.join("\n\n")
}
fn footer_tips(&self) -> Vec<FooterTip> {
fn footer_tips(&self) -> Vec<Line<'static>> {
let mut tips = Vec::new();
let is_last_field = self.current_index().saturating_add(1) >= self.field_count();
let submit_hint = if self.current_field_is_select() {
@@ -998,30 +979,34 @@ impl McpServerElicitationOverlay {
};
if let Some(submit_hint) = submit_hint.map(ShortcutHint::display_label) {
if self.field_count() == 1 {
tips.push(FooterTip::highlighted(format!("{submit_hint} to submit")));
tips.push(shortcut(&submit_hint, "to submit"));
} else if is_last_field {
tips.push(FooterTip::highlighted(format!(
"{submit_hint} to submit all"
)));
tips.push(shortcut(&submit_hint, "to submit all"));
} else {
tips.push(FooterTip::new(format!("{submit_hint} to submit answer")));
tips.push(shortcut(&submit_hint, "to submit answer"));
}
}
if self.field_count() > 1 {
if self.current_field_is_select() {
tips.push(FooterTip::new("←/→ to navigate fields"));
tips.push(shortcut("←/→", "to navigate fields"));
} else {
tips.push(FooterTip::new("ctrl + p / ctrl + n change field"));
tips.push(shortcut("ctrl+p / ctrl+n", "change field"));
}
}
tips.push(FooterTip::new("esc to cancel"));
tips.push(shortcut("esc", "to cancel"));
tips
}
fn footer_tip_lines(&self, width: u16) -> Vec<Vec<FooterTip>> {
fn footer_tip_lines(&self, width: u16) -> Vec<Vec<Line<'static>>> {
let mut tips = Vec::new();
if let Some(error) = self.validation_error.as_ref() {
tips.push(FooterTip::highlighted(error.clone()));
tips.push(Line::from(
error
.clone()
.fg(crate::style::accent_color())
.bold()
.not_dim(),
));
}
tips.extend(self.footer_tips());
wrap_footer_tips(width, tips)
@@ -1302,7 +1287,7 @@ impl McpServerElicitationOverlay {
let line = if answered {
Line::from(line.clone())
} else {
Line::from(line.clone()).cyan()
Line::from(line.clone()).fg(crate::style::accent_color())
};
Paragraph::new(line).render(
Rect {
@@ -1350,7 +1335,7 @@ impl McpServerElicitationOverlay {
let option_tip = if options_hidden {
let selected = self.selected_option_index().unwrap_or(0).saturating_add(1);
let total = self.options_len();
Some(FooterTip::new(format!("option {selected}/{total}")))
Some(Line::from(format!("option {selected}/{total}").dim()))
} else {
None
};
@@ -1372,11 +1357,7 @@ impl McpServerElicitationOverlay {
if tip_idx > 0 {
spans.push(FOOTER_SEPARATOR.into());
}
if tip.highlight {
spans.push(tip.text.cyan().bold().not_dim());
} else {
spans.push(tip.text.into());
}
spans.extend(tip.spans);
}
let line = Line::from(spans).dim();
Paragraph::new(line).render(
@@ -1715,12 +1696,12 @@ impl BottomPaneView for McpServerElicitationOverlay {
}
}
fn wrap_footer_tips(width: u16, tips: Vec<FooterTip>) -> Vec<Vec<FooterTip>> {
fn wrap_footer_tips(width: u16, tips: Vec<Line<'static>>) -> Vec<Vec<Line<'static>>> {
crate::footer_hint::wrap_hint_rows(
tips,
width,
UnicodeWidthStr::width(FOOTER_SEPARATOR),
|tip| UnicodeWidthStr::width(tip.text.as_str()),
Line::width,
)
}
@@ -2233,7 +2214,7 @@ mod tests {
overlay
.footer_tips()
.iter()
.all(|tip| !tip.text.contains("submit"))
.all(|tip| !tip.to_string().contains("submit"))
);
}
@@ -2321,7 +2302,7 @@ mod tests {
overlay
.footer_tips()
.iter()
.all(|tip| !tip.text.contains("submit"))
.all(|tip| !tip.to_string().contains("submit"))
);
}
+3
View File
@@ -176,7 +176,10 @@ pub(crate) use title_setup::preview_line_for_title_items;
mod paste_burst;
mod pending_input_preview;
mod pending_thread_approvals;
mod picker_option;
mod picker_presets;
pub(crate) use picker_option::picker_option_list;
pub(crate) use picker_option::picker_option_row;
mod picker_rows;
mod picker_style;
pub(crate) use picker_style::selection_style;
@@ -0,0 +1,96 @@
//! Shared numbered rows for standalone pickers that retain their own keyboard policy.
//!
//! Use the same measurement and rendering as selection menus without granting actions here.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use super::picker_rows;
use super::picker_style::selection_style;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::measure_rows_height;
use super::selection_popup_common::render_rows;
use crate::render::renderable::Renderable;
pub(crate) fn picker_option_row(
index: usize,
label: String,
is_selected: bool,
) -> Box<dyn Renderable> {
Box::new(PickerOptionRow {
rows: [numbered_row(index, label.into(), is_selected)],
state: ScrollState {
selected_idx: if is_selected { Some(0) } else { None },
..Default::default()
},
})
}
/// A bounded list of numbered choices; the caller retains its keyboard and confirmation policy.
pub(crate) fn picker_option_list(
labels: Vec<impl Into<Line<'static>>>,
selected_index: usize,
) -> Box<dyn Renderable> {
Box::new(PickerOptionList {
rows: labels
.into_iter()
.enumerate()
.map(|(index, label)| numbered_row(index, label.into(), index == selected_index))
.collect(),
state: ScrollState {
selected_idx: Some(selected_index),
..Default::default()
},
})
}
fn numbered_row(index: usize, label: Line<'static>, is_selected: bool) -> GenericDisplayRow {
let marker = if is_selected { '›' } else { ' ' };
let prefix = format!("{marker} {}. ", index + 1);
let prefix_width = prefix.chars().count();
let mut spans = vec![prefix.into()];
// Styled labels carry semantic status colors and accelerator underlines.
spans.extend(label.spans.into_iter().map(|mut span| {
span.style = label.style.patch(span.style);
span
}));
GenericDisplayRow {
category_tag: None,
name_prefix_spans: spans,
selection_style: Some(selection_style()),
wrap_indent: Some(prefix_width),
..Default::default()
}
}
struct PickerOptionRow {
rows: [GenericDisplayRow; 1],
state: ScrollState,
}
impl Renderable for PickerOptionRow {
fn render(&self, area: Rect, buf: &mut Buffer) {
render_rows(area, buf, &self.rows, &self.state, self.rows.len(), "");
}
fn desired_height(&self, width: u16) -> u16 {
measure_rows_height(&self.rows, &self.state, self.rows.len(), width)
}
}
struct PickerOptionList {
rows: Vec<GenericDisplayRow>,
state: ScrollState,
}
impl Renderable for PickerOptionList {
fn render(&self, area: Rect, buf: &mut Buffer) {
picker_rows::render_rows(area, buf, &self.rows, &self.state, self.rows.len(), "");
}
fn desired_height(&self, width: u16) -> u16 {
picker_rows::measure_rows_height(&self.rows, &self.state, self.rows.len(), width)
}
}
@@ -17,6 +17,8 @@ use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::style::Stylize;
use ratatui::text::Line;
mod layout;
pub(super) mod render;
@@ -30,6 +32,7 @@ use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::scroll_state::ScrollState;
use crate::bottom_pane::selection_popup_common::GenericDisplayRow;
use crate::bottom_pane::selection_popup_common::measure_rows_height;
use crate::footer_hint::shortcut;
use crate::history_cell;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
@@ -133,28 +136,6 @@ struct AnswerState {
notes_visible: bool,
}
#[derive(Clone, Debug)]
pub(super) struct FooterTip {
pub(super) text: String,
pub(super) highlight: bool,
}
impl FooterTip {
fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
highlight: false,
}
}
fn highlighted(text: impl Into<String>) -> Self {
Self {
text: text.into(),
highlight: true,
}
}
}
pub(crate) struct RequestUserInputOverlay {
app_event_tx: AppEventSender,
request: ToolRequestUserInputParams,
@@ -443,6 +424,8 @@ impl RequestUserInputOverlay {
let prefix_label = format!("{prefix} {number}. ");
let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str());
GenericDisplayRow {
category_tag: None,
selection_style: Some(crate::bottom_pane::selection_style()),
name: format!("{prefix_label}{label}"),
description: Some(opt.description.clone()),
wrap_indent: Some(wrap_indent),
@@ -459,6 +442,8 @@ impl RequestUserInputOverlay {
let prefix_label = format!("{prefix} {number}. ");
let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str());
rows.push(GenericDisplayRow {
category_tag: None,
selection_style: Some(crate::bottom_pane::selection_style()),
name: format!("{prefix_label}{OTHER_OPTION_LABEL}"),
description: Some(OTHER_OPTION_DESCRIPTION.to_string()),
wrap_indent: Some(wrap_indent),
@@ -586,15 +571,23 @@ impl RequestUserInputOverlay {
self.sync_composer_placeholder();
}
fn footer_tips(&self) -> Vec<FooterTip> {
fn footer_tips(&self) -> Vec<Line<'static>> {
let mut tips = Vec::new();
let notes_visible = self.notes_ui_visible();
if self.has_options() {
if self.selected_option_index().is_some() && !notes_visible {
tips.push(FooterTip::highlighted("tab to add notes"));
tips.push(shortcut("tab", "to add notes"));
}
if self.selected_option_index().is_some() && notes_visible {
tips.push(FooterTip::new("tab or esc to clear notes"));
tips.push(Line::from(
[
crate::key_hint::key_label_spans("tab"),
vec![" or ".dim()],
crate::key_hint::key_label_spans("esc"),
vec![" to clear notes".dim()],
]
.concat(),
));
}
}
@@ -609,19 +602,19 @@ impl RequestUserInputOverlay {
};
if let Some(submit_key) = submit_key {
let submit_tip = if question_count == 1 {
FooterTip::highlighted(format!("{submit_key} to submit answer"))
shortcut(&submit_key, "to submit answer")
} else if is_last_question {
FooterTip::highlighted(format!("{submit_key} to submit all"))
shortcut(&submit_key, "to submit all")
} else {
FooterTip::new(format!("{submit_key} to submit answer"))
shortcut(&submit_key, "to submit answer")
};
tips.push(submit_tip);
}
if question_count > 1 {
if self.has_options() && !self.focus_is_notes() {
tips.push(FooterTip::new("←/→ to navigate questions"));
tips.push(shortcut("←/→", "to navigate questions"));
} else if !self.has_options() {
tips.push(FooterTip::new("ctrl + p / ctrl + n change question"));
tips.push(shortcut("ctrl+p / ctrl+n", "change question"));
}
}
if let Some(interrupt_key) = self.interrupt_turn_hint
@@ -629,23 +622,20 @@ impl RequestUserInputOverlay {
&& notes_visible
&& interrupt_key == ShortcutHint::Single(crate::key_hint::plain(KeyCode::Esc)))
{
tips.push(FooterTip::new(format!(
"{} to interrupt",
interrupt_key.display_label()
)));
tips.push(shortcut(&interrupt_key.display_label(), "to interrupt"));
}
tips
}
pub(super) fn footer_tip_lines(&self, width: u16) -> Vec<Vec<FooterTip>> {
pub(super) fn footer_tip_lines(&self, width: u16) -> Vec<Vec<Line<'static>>> {
self.wrap_footer_tips(width, self.footer_tips())
}
pub(super) fn footer_tip_lines_with_prefix(
&self,
width: u16,
prefix: Option<FooterTip>,
) -> Vec<Vec<FooterTip>> {
prefix: Option<Line<'static>>,
) -> Vec<Vec<Line<'static>>> {
let mut tips = Vec::new();
if let Some(prefix) = prefix {
tips.push(prefix);
@@ -654,12 +644,12 @@ impl RequestUserInputOverlay {
self.wrap_footer_tips(width, tips)
}
fn wrap_footer_tips(&self, width: u16, tips: Vec<FooterTip>) -> Vec<Vec<FooterTip>> {
fn wrap_footer_tips(&self, width: u16, tips: Vec<Line<'static>>) -> Vec<Vec<Line<'static>>> {
crate::footer_hint::wrap_hint_rows(
tips,
width,
UnicodeWidthStr::width(TIP_SEPARATOR),
|tip| UnicodeWidthStr::width(tip.text.as_str()),
Line::width,
)
}
@@ -993,6 +983,8 @@ impl RequestUserInputOverlay {
let prefix = if idx == selected { '›' } else { ' ' };
let number = idx + 1;
GenericDisplayRow {
category_tag: None,
selection_style: Some(crate::bottom_pane::selection_style()),
name: format!("{prefix} {number}. {label}"),
description: Some(description.clone()),
..Default::default()
@@ -2444,7 +2436,7 @@ mod tests {
/*disable_paste_burst*/ false,
);
let tips = overlay.footer_tips();
let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>();
let tip_texts = tips.iter().map(ToString::to_string).collect::<Vec<_>>();
assert_eq!(
tip_texts,
vec![
@@ -2457,7 +2449,7 @@ mod tests {
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
let tips = overlay.footer_tips();
let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>();
let tip_texts = tips.iter().map(ToString::to_string).collect::<Vec<_>>();
assert_eq!(
tip_texts,
vec!["tab or esc to clear notes", "enter to submit answer",]
@@ -2483,12 +2475,12 @@ mod tests {
overlay.move_question(/*next*/ true);
let tips = overlay.footer_tips();
let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>();
let tip_texts = tips.iter().map(ToString::to_string).collect::<Vec<_>>();
assert_eq!(
tip_texts,
vec![
"enter to submit all",
"ctrl + p / ctrl + n change question",
"ctrl+p / ctrl+n change question",
"esc to interrupt",
]
);
@@ -2509,7 +2501,7 @@ mod tests {
);
let tips = overlay.footer_tips();
let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>();
let tip_texts = tips.iter().map(ToString::to_string).collect::<Vec<_>>();
assert_eq!(
tip_texts,
vec!["ctrl+j to submit answer", "esc to interrupt"]
@@ -2553,7 +2545,7 @@ mod tests {
let tips = overlay.footer_tips();
assert_eq!(
tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>(),
tips.iter().map(ToString::to_string).collect::<Vec<_>>(),
expected_tips
);
}
@@ -2577,7 +2569,7 @@ mod tests {
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
let tips = overlay.footer_tips();
let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>();
let tip_texts = tips.iter().map(ToString::to_string).collect::<Vec<_>>();
assert_eq!(
tip_texts,
vec![
@@ -3493,7 +3485,7 @@ mod tests {
let separator_width = UnicodeWidthStr::width(TIP_SEPARATOR);
for tips in lines {
let used = tips.iter().enumerate().fold(0usize, |acc, (idx, tip)| {
let tip_width = UnicodeWidthStr::width(tip.text.as_str()).min(width as usize);
let tip_width = tip.width().min(width as usize);
let extra = if idx == 0 {
tip_width
} else {
@@ -3755,16 +3747,16 @@ mod tests {
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(snapshot, @"
insta::assert_snapshot!(snapshot, @r"
Question 1/1 (1 unanswered)
Share details.
Question 1/1 (1 unanswered)
Share details.
› Type your answer (optional)
› Type your answer (optional)
ctrl+x enter to submit answer | esc to interrupt
ctrl+x enter to submit answer | esc to interrupt
");
}
@@ -288,7 +288,7 @@ impl RequestUserInputOverlay {
let question_line = if answered {
Line::from(line.clone())
} else {
Line::from(line.clone()).cyan()
Line::from(line.clone()).fg(crate::style::accent_color())
};
Paragraph::new(question_line).render(
Rect {
@@ -347,7 +347,7 @@ impl RequestUserInputOverlay {
let option_tip = if options_hidden {
let selected = self.selected_option_index().unwrap_or(0).saturating_add(1);
let total = self.options_len();
Some(super::FooterTip::new(format!("option {selected}/{total}")))
Some(Line::from(format!("option {selected}/{total}").dim()))
} else {
None
};
@@ -362,11 +362,7 @@ impl RequestUserInputOverlay {
if tip_idx > 0 {
spans.push(TIP_SEPARATOR.into());
}
if tip.highlight {
spans.push(tip.text.cyan().bold().not_dim());
} else {
spans.push(tip.text.into());
}
spans.extend(tip.spans);
}
let line = Line::from(spans).dim();
let line = truncate_line_word_boundary_with_ellipsis(line, footer_area.width as usize);
@@ -1,5 +1,6 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 3785
expression: "render_snapshot(&overlay, area)"
---
@@ -1,5 +1,6 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 3852
expression: "render_snapshot(&overlay, area)"
---
@@ -12,4 +13,4 @@ expression: "render_snapshot(&overlay, area)"
enter to submit all | ctrl + p / ctrl + n change question | esc to interrupt
enter to submit all | ctrl+p / ctrl+n change question | esc to interrupt
@@ -11,6 +11,7 @@ expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))"
$ cat /tmp/readme.txt
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
@@ -9,6 +9,7 @@ expression: "render_overlay_lines(&view, 80)"
$ echo hi
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
@@ -2,11 +2,13 @@
source: tui/src/bottom_pane/approval_overlay.rs
expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))"
---
Would you like to make the following edits?
Description: Apply proposed file edits
Destination: unavailable
› 1. Yes, proceed (y)
2. Yes, and don't ask again for these files (a)
3. No, and tell Codex what to do differently (esc)
@@ -9,6 +9,7 @@ expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))"
Permission rule: network; read `/tmp/readme.txt`; write `/tmp/out.txt`
› 1. Yes, grant these permissions for this turn (y)
2. Yes, grant for this turn with strict auto review (r)
3. Yes, grant these permissions for this session (a)
@@ -3,7 +3,7 @@ source: tui/src/bottom_pane/approval_overlay.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 100, height: 12 },
area: Rect { x: 0, y: 0, width: 100, height: 13 },
content: [
" ",
" Do you want to approve network access to "example.com"? ",
@@ -11,6 +11,7 @@ Buffer {
" Reason: network request blocked ",
" ",
" ",
" ",
"› 1. Yes, just this once (y) ",
" 2. Yes, and allow this host for this conversation (a) ",
" 3. Yes, and allow this host in the future (p) ",
@@ -24,18 +25,18 @@ Buffer {
x: 57, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 10, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 33, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 28, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 53, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 54, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 45, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 46, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 51, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 8, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 13, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 28, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 31, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 53, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 54, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 45, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 46, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 8, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 13, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 28, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 31, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}
@@ -0,0 +1,12 @@
---
source: tui/src/bottom_pane/custom_prompt_view_tests.rs
expression: "rows.join(\"\\n\")"
---
Edit goal
› Check the parser and preserve 日 本
語 .
enter submit · esc normal mode
@@ -0,0 +1,20 @@
---
source: tui/src/bottom_pane/custom_prompt_view_tests.rs
expression: "rows.join(\"\\n\")"
---
Edit goal
› Check the parser and preserve 日 本 語 .
Check the parser and preserve 日 本 語 .
Check the parser and preserve 日 本 語 .
Check the parser and preserve 日 本 語 .
Check the parser and preserve 日 本 語 .
Check the parser and preserve 日 本 語 .
Check the parser and preserve 日 本 語 .
Check the parser and preserve 日 本 語 .
Check the parser and preserve 日 本 語 .
Check the parser and preserve 日 本 語 .
enter submit · esc normal mode Vim: Insert
@@ -0,0 +1,11 @@
---
source: tui/src/bottom_pane/custom_prompt_view_tests.rs
assertion_line: 164
expression: "hints.join(\"\\n\")"
---
enter submit · esc back
enter submit · esc normal mode Vim: Insert
enter submit · esc normal mode Vim: Insert
enter subm
enter submit · esc back Vim: Normal
enter submit · esc normal mode Vim: Replace
@@ -10,7 +10,8 @@ expression: terminal.backend().vt100().screen().contents()
$ echo hello world
› 1. Yes, proceed (y)
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands that start with `echo hello world` (p)
3. No, and tell Codex what to do differently (esc)
@@ -10,7 +10,8 @@ expression: contents
print('hello')
PY
› 1. Yes, proceed (y)
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
@@ -8,7 +8,8 @@ expression: terminal.backend().vt100().screen().contents()
$ echo hello world
› 1. Yes, proceed (y)
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands that start with `echo hello world` (p)
3. No, and tell Codex what to do differently (esc)
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests/exec_flow.rs
assertion_line: 1743
expression: normalize_snapshot_paths(contents)
---
@@ -10,7 +9,8 @@ expression: normalize_snapshot_paths(contents)
Description: The model wants to apply changes
Destination: /tmp/project/README.md
› 1. Yes, proceed (y)
› 1. Yes, proceed (y)
2. Yes, and don't ask again for these files (a)
3. No, and tell Codex what to do differently (esc)
@@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests/exec_flow.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 80, height: 13 },
area: Rect { x: 0, y: 0, width: 80, height: 14 },
content: [
" ",
" ",
@@ -14,6 +14,7 @@ Buffer {
" ",
" $ echo hello world ",
" ",
" ",
"› 1. Yes, proceed (y) ",
" 2. No, and tell Codex what to do differently (esc) ",
" ",
@@ -30,14 +31,14 @@ Buffer {
x: 4, y: 7, fg: Rgb(137, 180, 250), bg: Reset, underline: Reset, modifier: NONE,
x: 8, y: 7, fg: Rgb(205, 214, 244), bg: Reset, underline: Reset, modifier: NONE,
x: 20, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 8, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 13, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 28, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 31, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 0, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 51, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 8, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 13, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 28, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 31, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}
@@ -12,8 +12,9 @@ expression: popup
• auto-review-rollout-thread-1.jsonl
• codex-connectivity-diagnostics.txt
› 1. Yes Share the current Codex session logs and diagnostics with the team
for troubleshooting.
› 1. Yes Share the current Codex session logs and diagnostics with the team for
troubleshooting.
2. No
Press enter to confirm or esc to go back
enter select · esc back
@@ -4,12 +4,12 @@ expression: popup
---
How was this?
› 1. bug Crash, error message, hang, or broken UI/behavior.
2. bad result Output was off-target, incorrect, incomplete, or unhelpful.
3. good result Helpful, correct, high‑quality, or delightful result worth
celebrating.
4. safety check Benign usage blocked due to safety checks or refusals.
5. other Slowness, feature suggestion, UX feedback, or anything
else.
5. other Slowness, feature suggestion, UX feedback, or anything else.
Press enter to confirm or esc to go back
enter select · esc back
@@ -17,8 +17,9 @@ expression: popup
- Proxy environment variables are set and may affect connectivity.
- HTTPS_PROXY = hello
› 1. Yes Share the current Codex session logs and diagnostics with the team
for troubleshooting.
› 1. Yes Share the current Codex session logs and diagnostics with the team for
troubleshooting.
2. No
Press enter to confirm or esc to go back
enter select · esc back
@@ -2,8 +2,8 @@
source: tui/src/chatwidget/tests/goal_menu.rs
expression: "render_bottom_popup(&chat, 100)"
---
▌ Edit goal
▌
▌ Keep improving the bare goal command until it feels calm and useful.
Edit goal
Press enter to confirm or esc to go back
› Keep improving the bare goal command until it feels calm and useful.
enter submit · esc back
@@ -2,8 +2,8 @@
source: tui/src/chatwidget/tests/slash_commands.rs
expression: popup
---
▌ Save conversation
▌
▌ codex-session-00000000-0000-0000-0000-000000000123.md
Save conversation
Press enter to confirm or esc to go back
› codex-session-00000000-0000-0000-0000-000000000123.md
enter submit · esc back
@@ -1,10 +1,9 @@
---
source: tui/src/chatwidget/tests/slash_commands.rs
assertion_line: 135
expression: popup
---
▌ Rename thread
▌
▌ Current project title
Rename thread
Press enter to confirm or esc to go back
› Current project title
enter submit · esc back
@@ -2,9 +2,9 @@
source: tui/src/chatwidget/tests/slash_commands.rs
expression: popup
---
▌ Rename thread
▌ Generating a title suggestion…
▌
▌ Current project title
Rename thread
Generating a title suggestion…
Press enter to confirm or esc to go back
› Current project title
enter submit · esc back
@@ -2,9 +2,9 @@
source: tui/src/chatwidget/tests/slash_commands.rs
expression: popup
---
▌ Rename thread
▌ Suggested from this conversation
▌
▌ Fix login timeout
Rename thread
Suggested from this conversation
Press enter to confirm or esc to go back
› Fix login timeout
enter submit · esc back
@@ -10,6 +10,7 @@ expression: normalized_backend_snapshot(terminal.backend())
" "
" $ echo 'hello world' "
" "
" "
"› 1. Yes, proceed (y) "
" 2. Yes, and don't ask again for commands that start with `echo 'hello world'` (p) "
" 3. No, and tell Codex what to do differently (esc) "
@@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests/approval_requests.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 80, height: 15 },
area: Rect { x: 0, y: 0, width: 80, height: 16 },
content: [
" ",
" ",
@@ -16,6 +16,7 @@ Buffer {
" ",
" $ echo hello world ",
" ",
" ",
"› 1. Yes, proceed (y) ",
" 2. No, and tell Codex what to do differently (esc) ",
" ",
@@ -34,14 +35,14 @@ Buffer {
x: 4, y: 9, fg: Rgb(137, 180, 250), bg: Reset, underline: Reset, modifier: NONE,
x: 8, y: 9, fg: Rgb(205, 214, 244), bg: Reset, underline: Reset, modifier: NONE,
x: 20, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 21, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 51, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 8, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 13, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 28, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 31, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 0, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 0, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 51, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 8, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 13, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 28, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 31, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}
@@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests/approval_requests.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 80, height: 10 },
area: Rect { x: 0, y: 0, width: 80, height: 11 },
content: [
" ",
" ",
@@ -11,6 +11,7 @@ Buffer {
" ",
" Input: "confirm\n" ",
" ",
" ",
"› 1. Yes, proceed (y) ",
" 2. No, and tell Codex what to do differently (esc) ",
" ",
@@ -20,14 +21,14 @@ Buffer {
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 21, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 51, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 8, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 13, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 28, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 31, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 51, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 8, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 13, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 28, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 31, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}
+69 -47
View File
@@ -1,14 +1,18 @@
//! Resume/fork directory choices with shared picker presentation and caller-owned persistence.
use std::path::Path;
use crate::bottom_pane::picker_option_list;
use crate::bottom_pane::render_menu_surface;
use crate::key_hint;
use crate::legacy_core::config::Config;
use crate::legacy_core::config::edit::ConfigEditsBuilder;
use crate::local_settings::LocalSettings;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::selection_list::selection_option_row;
use crate::render::renderable::RenderableItem;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
@@ -24,7 +28,9 @@ use ratatui::prelude::Widget;
use ratatui::style::Stylize as _;
use ratatui::text::Line;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use tokio_stream::StreamExt;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -275,50 +281,35 @@ impl CwdPromptScreen {
impl WidgetRef for &CwdPromptScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let mut column = ColumnRenderable::new();
let mut column = FlexRenderable::new();
let action_verb = self.action.verb();
let action_past = self.action.past_participle();
let current_cwd = self.current_cwd.as_str();
let session_cwd = self.session_cwd.as_str();
column.push("");
column.push(Line::from(vec![
"Choose working directory to ".into(),
action_verb.bold(),
" this session".into(),
]));
column.push("");
column.push(/*flex*/ 1, RenderableItem::Borrowed(&""));
column.push(
Line::from(format!(
"Session = latest cwd recorded in the {action_past} session"
/*flex*/ 0,
Paragraph::new(format!("Working directory · {action_verb}").bold())
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
column.push(
/*flex*/ 0,
Paragraph::new(format!(
"Session = latest cwd recorded in the {action_past} session\n\
Current = your current working directory"
))
.dim()
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
column.push(
Line::from("Current = your current working directory".dim()).inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
column.push("");
column.push(selection_option_row(
/*index*/ 0,
let mut labels = vec![
format!("Use session directory ({session_cwd})"),
self.highlighted == CwdSelection::Session,
));
column.push(selection_option_row(
/*index*/ 1,
format!("Use current directory ({current_cwd})"),
self.highlighted == CwdSelection::Current,
));
column.push(selection_option_row(
/*index*/ 2,
"Always use session directory".to_string(),
self.highlighted == CwdSelection::SessionAndRemember,
));
];
if self.allow_remember_current {
let label = if self.remembered_current_cwd == self.current_cwd {
"Always use current directory".to_string()
@@ -328,24 +319,35 @@ impl WidgetRef for &CwdPromptScreen {
self.remembered_current_cwd
)
};
column.push(selection_option_row(
/*index*/ 3,
label,
self.highlighted == CwdSelection::CurrentAndRemember,
));
labels.push(label);
}
column.push("");
let selected_index = match self.highlighted {
CwdSelection::Session => 0,
CwdSelection::Current => 1,
CwdSelection::SessionAndRemember => 2,
CwdSelection::CurrentAndRemember => 3,
};
column.push(/*flex*/ 1, picker_option_list(labels, selected_index));
column.push(
Line::from(vec![
"Press ".dim(),
/*flex*/ 0,
Paragraph::new(Line::from(vec![
key_hint::plain(KeyCode::Enter).into(),
" to continue".dim(),
])
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
" continue · ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" use session · ".dim(),
key_hint::ctrl(KeyCode::Char('c')).into(),
" quit".dim(),
]))
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
column.render(area, buf);
column.push(/*flex*/ 1, RenderableItem::Borrowed(&""));
let panel = Rect {
height: column.desired_height(area.width).min(area.height),
..area
};
render_menu_surface(panel, buf);
column.render(panel, buf);
}
}
@@ -464,6 +466,26 @@ mod tests {
assert_eq!(screen.selection(), Some(CwdSelection::Current));
}
#[test]
fn narrow_picker_keeps_selected_choice_and_escape_uses_session() {
let mut screen = new_prompt();
screen.session_cwd = "/workspace/projects/design-system/日本語/session".to_string();
screen.current_cwd = "/workspace/projects/design-system/Português/current".to_string();
screen.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let (width, height) = (40, 16);
let mut terminal = Terminal::new(VT100Backend::new(width, height)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
.expect("render resized directory picker");
let rendered = terminal.backend().to_string();
assert!(rendered.contains("› 4. Always use current directory"));
assert!(rendered.contains("esc use session"));
assert_eq!(screen.selection(), None);
insta::assert_snapshot!(format!("cwd_picker_selected_{width}x{height}"), rendered);
screen.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(screen.selection(), Some(CwdSelection::Session));
}
#[test]
fn cwd_prompt_omits_unusable_remembered_current_choice() {
let mut screen = CwdPromptScreen::new(
@@ -713,6 +713,7 @@ fn is_ctrl_exit_combo(key_event: KeyEvent) -> bool {
mod tests {
use super::ExternalAgentConfigMigrationOutcome;
use super::ExternalAgentConfigMigrationScreen;
use super::FocusArea;
use super::MigrationView;
use crate::custom_terminal::Terminal;
use crate::test_backend::VT100Backend;
@@ -941,6 +942,61 @@ mod tests {
assert_snapshot!("external_agent_config_migration_customize_action", rendered);
}
#[test]
fn action_rows_keep_single_line_allocation_and_focus_style() {
let items = sample_items();
let mut screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
screen.customize();
screen.highlighted_action = screen.available_actions()[0];
for focus in [FocusArea::Items, FocusArea::Actions] {
screen.focus = focus;
for width in [30, 80] {
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 30);
let mut buf = ratatui::buffer::Buffer::empty(area);
ratatui::widgets::WidgetRef::render_ref(&&screen, area, &mut buf);
let mut action_rows = Vec::new();
for (idx, action) in screen.available_actions().iter().enumerate() {
let marker =
if focus == FocusArea::Actions && *action == screen.highlighted_action {
'›'
} else {
' '
};
let expected = format!("{marker} {}. {}", idx + 1, action.label());
let y = (0..area.height)
.find(|&y| {
(0..width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
.contains(&expected)
})
.expect("action remains on one row");
action_rows.push(y);
assert_eq!(
buf[(2, y)].modifier.contains(ratatui::style::Modifier::DIM),
focus == FocusArea::Items
);
if marker == '›' {
assert_eq!(
buf[(2, y)].bg,
crate::bottom_pane::selection_style().bg.unwrap()
);
}
}
assert!(
action_rows
.windows(/*size*/ 2)
.all(|rows| rows[1] == rows[0] + 1)
);
}
}
}
#[test]
fn proceed_returns_selected_items() {
let items = sample_items();
@@ -1,13 +1,17 @@
//! Render import choices with shared picker focus while preserving import state.
use super::*;
use crate::bottom_pane::picker_option_row;
use crate::bottom_pane::selection_style;
use crate::key_hint;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::selection_list::selection_option_row_with_dim;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
@@ -48,8 +52,9 @@ impl ExternalAgentConfigMigrationScreen {
cursor.content = "› ".into();
}
line.spans.iter_mut().for_each(|span| {
span.style = span.style.cyan().bold();
span.style = selection_style();
});
line = line.style(selection_style());
} else if entry.kind != RenderLineKind::Item && !line.spans.is_empty() {
line.spans.iter_mut().for_each(|span| {
span.style = span.style.dim();
@@ -156,13 +161,12 @@ impl WidgetRef for &ExternalAgentConfigMigrationScreen {
.wrap(Wrap { trim: false })
.render(action_areas[0], buf);
for (idx, action) in actions.iter().enumerate() {
selection_option_row_with_dim(
idx,
action.label().to_string(),
self.focus == FocusArea::Actions && self.highlighted_action == *action,
/*dim*/ self.focus != FocusArea::Actions,
)
.render(action_areas[idx + 1], buf);
let selected = self.focus == FocusArea::Actions && self.highlighted_action == *action;
let line = picker_option_row(idx, action.label().to_string(), selected);
line.render(action_areas[idx + 1], buf);
if self.focus != FocusArea::Actions {
buf.set_style(action_areas[idx + 1], Style::default().dim());
}
}
let footer = match self.view {
@@ -174,7 +178,7 @@ impl WidgetRef for &ExternalAgentConfigMigrationScreen {
" to move, ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" to select, ".dim(),
"c".cyan(),
"c".fg(crate::style::accent_color()),
" to customize".dim(),
]),
MigrationView::Customize if self.focus == FocusArea::Actions => Line::from(vec![
@@ -185,7 +189,7 @@ impl WidgetRef for &ExternalAgentConfigMigrationScreen {
"/".dim(),
key_hint::plain(KeyCode::Down).into(),
" to move, ".dim(),
"b".cyan(),
"b".fg(crate::style::accent_color()),
" to go back".dim(),
]),
MigrationView::Customize => Line::from(vec![
@@ -196,7 +200,7 @@ impl WidgetRef for &ExternalAgentConfigMigrationScreen {
" to move, ".dim(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to toggle, ".dim(),
"b".cyan(),
"b".fg(crate::style::accent_color()),
" to go back".dim(),
]),
};
@@ -7,7 +7,7 @@ Choose an import source
Select the app whose setup you want to import.
› 1. Claude Code
› 1. Claude Code
2. Cursor
Press enter to continue
enter continue · esc cancel
@@ -17,7 +17,9 @@ use ratatui::prelude::Widget;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use tokio_stream::StreamExt;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -164,12 +166,14 @@ impl WidgetRef for &ExternalAgentConfigSourceScreen {
Clear.render(area, buf);
let mut column = ColumnRenderable::new();
column.push("");
column.push("Choose an import source".bold());
column.push(Paragraph::new("Choose an import source".bold()).wrap(Wrap { trim: false }));
column.push("");
column.push(
Line::from("Select the app whose setup you want to import.".dim()).inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
Paragraph::new("Select the app whose setup you want to import.".dim())
.wrap(Wrap { trim: false })
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
column.push("");
for (index, source) in self.sources.iter().enumerate() {
@@ -179,18 +183,19 @@ impl WidgetRef for &ExternalAgentConfigSourceScreen {
" "
};
let label = Span::from(format!("{prefix} {}. {}", index + 1, source.label()));
column.push(Line::from(if index == self.highlighted_idx {
label.cyan()
column.push(if index == self.highlighted_idx {
Line::from(label).style(crate::bottom_pane::selection_style())
} else {
label
}));
Line::from(label)
});
}
column.push("");
column.push(
Line::from(vec![
"Press ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" to continue".dim(),
" continue · ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" cancel".dim(),
])
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
-1
View File
@@ -186,7 +186,6 @@ mod render;
mod resize_reflow_cap;
mod resume_picker;
mod screen_reader;
mod selection_list;
mod service_tier_resolution;
mod session_archive_commands;
mod session_log;
+97 -65
View File
@@ -1,10 +1,16 @@
//! Model upgrade copy and choices with shared picker presentation.
//! Mandatory and optional migration outcomes retain their existing keyboard policy.
use crate::bottom_pane::picker_option_list;
use crate::bottom_pane::render_menu_surface;
use crate::key_hint;
use crate::markdown_render::render_markdown_text_with_width;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::selection_list::selection_option_row;
use crate::render::renderable::RenderableItem;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
@@ -12,6 +18,7 @@ use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::layout::Rect;
use ratatui::prelude::Stylize as _;
use ratatui::prelude::Widget;
use ratatui::text::Line;
@@ -110,7 +117,7 @@ pub(crate) fn migration_copy_for_models(
if let Some(model_link) = model_link {
content.push(Line::from(vec![
format!("{description_line} Learn more about {target_display_name} at ").into(),
model_link.cyan().underlined(),
model_link.fg(crate::style::accent_color()).underlined(),
]));
content.push(Line::from(""));
} else {
@@ -122,8 +129,6 @@ pub(crate) fn migration_copy_for_models(
content.push(Line::from(format!(
"You can continue using {current_model} if you prefer."
)));
} else {
content.push(Line::from("Press enter to continue".dim()));
}
ModelMigrationCopy {
@@ -255,20 +260,62 @@ impl WidgetRef for &ModelMigrationScreen {
fn render_ref(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
Clear.render(area, buf);
let mut column = ColumnRenderable::new();
column.push("");
let mut body = ColumnRenderable::new();
if let Some(markdown) = self.copy.markdown.as_ref() {
self.render_markdown_content(markdown, area.width, &mut column);
self.render_markdown_content(markdown, area.width, &mut body);
} else {
column.push(self.heading_line());
column.push(Line::from(""));
self.render_content(&mut column);
body.push(
Paragraph::new(Line::from(self.copy.heading.clone()))
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
body.push(Line::from(""));
self.render_content(&mut body);
}
let mut column = FlexRenderable::new();
column.push(/*flex*/ 1, RenderableItem::Borrowed(&""));
column.push(/*flex*/ 1, RenderableItem::Borrowed(&body));
if self.copy.can_opt_out {
self.render_menu(&mut column);
let selected_index = match self.highlighted_option {
MigrationMenuOption::TryNewModel => 0,
MigrationMenuOption::UseExistingModel => 1,
};
column.push(
/*flex*/ 0,
picker_option_list(
MigrationMenuOption::all()
.into_iter()
.map(|option| option.label().to_string())
.collect(),
selected_index,
),
);
}
column.render(area, buf);
let mut hints = key_hint::key_label_spans(&format!(
"{}/{}",
key_hint::plain(KeyCode::Enter).display_label(),
key_hint::plain(KeyCode::Esc).display_label()
));
hints.push(if self.copy.can_opt_out {
" confirm · ".dim()
} else {
" continue · ".dim()
});
hints.push(key_hint::ctrl(KeyCode::Char('c')).into());
hints.push(" quit".dim());
column.push(
/*flex*/ 0,
Paragraph::new(Line::from(hints))
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
column.push(/*flex*/ 1, RenderableItem::Borrowed(&""));
let panel = Rect {
height: column.desired_height(area.width).min(area.height),
..area
};
render_menu_surface(panel, buf);
column.render(panel, buf);
}
}
@@ -294,12 +341,6 @@ impl ModelMigrationScreen {
}
}
fn heading_line(&self) -> Line<'static> {
let mut heading = vec![Span::raw("> ")];
heading.extend(self.copy.heading.iter().cloned());
Line::from(heading)
}
fn render_content(&self, column: &mut ColumnRenderable) {
self.render_lines(&self.copy.content, column);
}
@@ -309,9 +350,7 @@ impl ModelMigrationScreen {
column.push(
Paragraph::new(line.clone())
.wrap(Wrap { trim: false })
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
}
}
@@ -323,58 +362,17 @@ impl ModelMigrationScreen {
column: &mut ColumnRenderable,
) {
let horizontal_inset = 2;
let content_width = area_width.saturating_sub(horizontal_inset);
let content_width = area_width.saturating_sub(horizontal_inset * 2);
let wrap_width = (content_width > 0).then_some(content_width as usize);
let rendered = render_markdown_text_with_width(markdown, wrap_width);
for line in rendered.lines {
column.push(
Paragraph::new(line)
.wrap(Wrap { trim: false })
.inset(Insets::tlbr(
/*top*/ 0,
horizontal_inset,
/*bottom*/ 0,
/*right*/ 0,
)),
.inset(Insets::vh(/*v*/ 0, horizontal_inset)),
);
}
}
fn render_menu(&self, column: &mut ColumnRenderable) {
column.push(Line::from(""));
column.push(
Paragraph::new("Choose how you'd like Codex to proceed.")
.wrap(Wrap { trim: false })
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
column.push(Line::from(""));
for (idx, option) in MigrationMenuOption::all().into_iter().enumerate() {
column.push(selection_option_row(
idx,
option.label().to_string(),
self.highlighted_option == option,
));
}
column.push(Line::from(""));
column.push(
Line::from(vec![
"Use ".dim(),
key_hint::plain(KeyCode::Up).into(),
"/".dim(),
key_hint::plain(KeyCode::Down).into(),
" to move, press ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" to confirm".dim(),
])
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
}
}
// Render the prompt on the terminal's alternate screen so exiting or cancelling
@@ -418,8 +416,11 @@ mod tests {
use crate::tui::FrameRequester;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::layout::Rect;
use ratatui::widgets::FrameExt;
#[test]
fn prompt_snapshot() {
@@ -626,4 +627,35 @@ mod tests {
"expected wrapped markdown URL tail to remain visible, got:\n{rendered}"
);
}
#[test]
fn migration_choice_and_hints_stay_visible_in_a_short_viewport() {
let mut screen = ModelMigrationScreen::new(
FrameRequester::test_dummy(),
migration_copy_for_models(
"gpt-old",
"gpt-new",
/*model_link*/ None,
/*migration_copy*/ None,
Some("# Introducing {model_to}\n\nA faster coding model with better reasoning.\n\nYou can keep using {model_from} if you prefer.".to_string()),
"GPT New".to_string(),
/*target_description*/ None,
/*can_opt_out*/ true,
),
);
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
let (width, height) = (28, 12);
let mut terminal =
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
.expect("render resized model migration picker");
let rendered = terminal.backend().to_string();
assert!(rendered.contains("› 2. Use existing model"));
assert!(rendered.contains("enter/esc confirm"));
assert!(!screen.is_done());
assert_snapshot!(format!("model_picker_selected_{width}x{height}"), rendered);
screen.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(screen.outcome(), super::ModelMigrationOutcome::Rejected);
}
}
@@ -1,9 +1,10 @@
---
source: tui/src/onboarding/trust_directory.rs
assertion_line: 355
expression: terminal.backend()
---
> You are in /workspace/project
Folder access
/workspace/project
This existing task may retain settings and history, including
project configuration or hooks loaded while it was trusted. To use
@@ -13,4 +14,4 @@ expression: terminal.backend()
› 1. Open existing task
2. Back to Agent Command Center
Press enter to continue; esc to go back
enter continue · esc back
@@ -0,0 +1,21 @@
---
source: tui/src/onboarding/trust_directory.rs
expression: terminal.backend()
---
Folder access
/workspace/project/long-nested-folde
r
Config, hooks, and exec policies
from untrusted folders stay
disabled. Trusted project folders
can still contribute settings.
Skills still load, and tools follow
your permission settings. Opening
will not change saved trust.
› 1. Open restricted
2. Back to Agent Command Center
enter continue · esc back
@@ -0,0 +1,17 @@
---
source: tui/src/onboarding/trust_directory.rs
expression: "terminal.backend().to_string().replace('\\\\', \"/\")"
---
Folder access
workspace/…/repository
Trust this folder? Codex can read,
edit, and run files here, subject to
your permission settings. Folder
settings can run code automatically,
even without a model request.
Continue only if you trust these
files. Your trust decision will be
saved.
› 1. Trust and continue
2. Quit
enter continue · esc quit
@@ -0,0 +1,21 @@
---
source: tui/src/onboarding/trust_directory.rs
expression: "terminal.backend().to_string().replace('\\\\', \"/\")"
---
Folder access
workspace/…/repository/checkout
Note: You’re in a subdirectory of a
Git project. Trusting will apply to
the repository root:
workspace/…/repository
Trust this folder? Codex can read,
edit, and run files here, subject to
your permission settings. Folder
settings can run code automatically,
even without a model request.
Continue only if you trust these
files. Your trust decision will be
saved.
› 1. Trust and continue
2. Quit
enter continue · esc quit
@@ -0,0 +1,20 @@
---
source: tui/src/onboarding/trust_directory.rs
expression: "terminal.backend().to_string().replace('\\\\', \"/\")"
---
Folder access
Note: You’re in a subdirectory of a
Git project. Trusting will apply to
the repository root:
workspace/…/repository
Trust this folder? Codex can read,
edit, and run files here, subject to
your permission settings. Folder
settings can run code automatically,
even without a model request.
Continue only if you trust these
files. Your trust decision will be
saved.
› 1. Trust and continue
2. Quit
enter continue · esc quit
@@ -1,9 +1,10 @@
---
source: tui/src/onboarding/trust_directory.rs
assertion_line: 357
expression: terminal.backend()
---
> You are in /workspace/project
Folder access
/workspace/project
Config, hooks, and exec policies from untrusted folders stay
disabled. Trusted project folders can still contribute settings.
@@ -13,4 +14,4 @@ expression: terminal.backend()
› 1. Open restricted
2. Back to Agent Command Center
Press enter to continue; esc to go back
enter continue · esc back
@@ -1,16 +1,17 @@
---
source: tui/src/onboarding/trust_directory.rs
assertion_line: 295
expression: terminal.backend()
---
> You are in /workspace/project
Trust this folder? Codex can read, edit, and run files here, subject
to your permission settings. Folder settings can run code
Folder access
/workspace/project
Trust this folder? Codex can read, edit, and run files here,
subject to your permission settings. Folder settings can run code
automatically, even without a model request. Continue only if you
trust these files. Your trust decision will be saved.
› 1. Trust and continue
2. Quit
Press enter to continue and create a sandbox...
enter continue and create sandbox · esc quit
@@ -1,19 +1,21 @@
---
source: tui/src/onboarding/trust_directory.rs
assertion_line: 314
expression: "terminal.backend().to_string().lines().map(str::trim_end).collect::<Vec<_>>().join(\"\\n\")"
---
> You are in /srv/remote/project/nested
Note: You’re in a subdirectory of a Git project. Trusting will apply
to the repository root: /srv/remote/project
Folder access
/srv/remote/project/nested
Trust this folder? Codex can read, edit, and run files here, subject
to your permission settings. Folder settings can run code
Note: You’re in a subdirectory of a Git project. Trusting will
apply to the repository root:
/srv/remote/project
Trust this folder? Codex can read, edit, and run files here,
subject to your permission settings. Folder settings can run code
automatically, even without a model request. Continue only if you
trust these files. Your trust decision will be saved.
› 1. Trust and continue
2. Back to Agent Command Center
Press enter to continue; esc to go back
enter continue · esc back
@@ -1,20 +1,21 @@
---
source: tui/src/onboarding/trust_directory.rs
assertion_line: 364
expression: terminal.backend()
---
> You are in /workspace/project
Trust this folder? Codex can read, edit, and run files here, subject
to your permission settings. Folder settings can run code
Folder access
/workspace/project
Trust this folder? Codex can read, edit, and run files here,
subject to your permission settings. Folder settings can run code
automatically, even without a model request. Continue only if you
trust these files. Your trust decision will be saved.
› 1. Trust and continue
2. Quit
Failed to set trust for /workspace/project: config/batchWrite failed
in TUI: Invalid configuration: features.fast_mode=true is not
supported; allowed set [fast_mode=false]
Failed to set trust for /workspace/project: config/batchWrite
failed in TUI: Invalid configuration: features.fast_mode=true is
not supported; allowed set [fast_mode=false]
Press enter to continue; esc to quit
enter continue · esc quit
+149 -61
View File
@@ -1,5 +1,6 @@
//! Folder trust disclosure and protected keyboard selection.
//! Folder trust disclosure with bounded paths, shared picker styling, and protected selection.
use std::path::Path;
use std::path::PathBuf;
use crossterm::event::KeyEvent;
@@ -12,15 +13,18 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use crate::bottom_pane::picker_option_row;
use crate::bottom_pane::render_menu_surface;
use crate::key_hint::KeyBindingListExt;
use crate::onboarding::keys;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::selection_list::selection_option_row;
use crate::render::renderable::RenderableItem;
use crate::text_formatting::center_truncate_path;
use super::onboarding_screen::StepState;
pub(crate) struct TrustDirectoryWidget {
@@ -50,33 +54,9 @@ pub enum TrustDirectorySelection {
impl WidgetRef for &TrustDirectoryWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let mut column = ColumnRenderable::new();
column.push(Line::from(vec![
"> ".into(),
"You are in ".bold(),
self.cwd.to_string_lossy().to_string().into(),
]));
column.push("");
if !self.restricted && self.cwd != self.trust_target {
#[allow(clippy::disallowed_methods)]
let git_root_warning = Paragraph::new(format!(
"Note: You’re in a subdirectory of a Git project. Trusting will apply to the repository root: {}",
self.trust_target.display()
))
.yellow();
column.push(
git_root_warning
.wrap(Wrap { trim: true })
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
column.push("");
}
column.push(
// Measure the mandatory content before giving any rows to variable-length paths.
let mut children = vec![(
0,
Paragraph::new(if self.restricted && self.existing_task {
"This existing task may retain settings \
and history, including project configuration or hooks loaded while it was trusted. \
@@ -92,11 +72,9 @@ impl WidgetRef for &TrustDirectoryWidget {
Your trust decision will be saved."
})
.wrap(Wrap { trim: true })
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
column.push("");
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
)];
children.push((1, RenderableItem::Borrowed(&"")));
let options: Vec<(&str, TrustDirectorySelection)> = vec![
(
@@ -119,47 +97,112 @@ impl WidgetRef for &TrustDirectoryWidget {
];
for (idx, (text, selection)) in options.iter().enumerate() {
column.push(selection_option_row(
idx,
text.to_string(),
self.highlighted == *selection,
children.push((
0,
picker_option_row(idx, text.to_string(), self.highlighted == *selection).into(),
));
}
column.push("");
children.push((1, RenderableItem::Borrowed(&"")));
if let Some(error) = &self.error {
column.push(
children.push((
0,
Paragraph::new(error.to_string())
.red()
.wrap(Wrap { trim: true })
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
column.push("");
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
));
children.push((1, RenderableItem::Borrowed(&"")));
}
column.push(
Line::from(vec![
"Press ".dim(),
children.push((
0,
Paragraph::new(Line::from(vec![
keys::CONFIRM[0].into(),
if self.show_windows_create_sandbox_hint && !self.restricted {
" to continue and create a sandbox...".dim()
" continue and create sandbox".dim()
} else {
match self.cancel {
TrustCancelAction::Quit => " to continue; esc to quit",
TrustCancelAction::AgentsOverview => " to continue; esc to go back",
}
.dim()
" continue".dim()
},
])
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
" · ".dim(),
keys::CANCEL[0].into(),
match self.cancel {
TrustCancelAction::Quit => " quit",
TrustCancelAction::AgentsOverview => " back",
}
.dim(),
]))
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
));
children.push((1, RenderableItem::Borrowed(&"")));
column.render(area, buf);
#[allow(clippy::disallowed_methods)]
let git_root_warning = (!self.restricted && self.cwd != self.trust_target).then(|| {
Paragraph::new(
"Note: You’re in a subdirectory of a Git project. Trusting will apply to the repository root:",
)
.yellow()
.wrap(Wrap { trim: true })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2))
});
let reserved_height = children
.iter()
.filter(|(flex, _)| *flex == 0)
.map(|(_, child)| child.desired_height(area.width))
.fold(/*init*/ 1u16, u16::saturating_add)
.saturating_add(git_root_warning.desired_height(area.width));
let path_height = area.height.saturating_sub(reserved_height);
// If only one path row fits, show the repository root receiving trust.
let cwd_height = if git_root_warning.is_some() {
path_height / 2
} else {
path_height
};
let path = |path: &Path, max_height: u16| {
let text = path.display().to_string();
let paragraph = Paragraph::new(text.clone()).wrap(Wrap { trim: false });
let width = area.width.saturating_sub(/*rhs*/ 4);
if paragraph.desired_height(width) <= max_height {
paragraph.dim().inset(Insets::vh(/*v*/ 0, /*h*/ 2))
} else {
// A single line bounds even paths containing embedded newlines.
Line::from(center_truncate_path(&text, usize::from(width)))
.dim()
.inset(Insets::vh(/*v*/ 0, /*h*/ 2))
}
};
let mut column = FlexRenderable::new();
column.push(/*flex*/ 1, RenderableItem::Borrowed(&""));
column.push(
/*flex*/ 0,
RenderableItem::Owned(Box::new(Line::from(" Folder access".bold()))),
);
if cwd_height > 0 {
column.push(/*flex*/ 0, path(&self.cwd, cwd_height));
}
column.push(/*flex*/ 1, RenderableItem::Borrowed(&""));
if let Some(warning) = git_root_warning {
column.push(/*flex*/ 0, warning);
let root_height = path_height.saturating_sub(cwd_height);
if root_height > 0 {
column.push(/*flex*/ 0, path(&self.trust_target, root_height));
}
column.push(/*flex*/ 1, RenderableItem::Borrowed(&""));
}
for (flex, child) in children {
column.push(flex, child);
}
let panel = Rect {
height: column.desired_height(area.width).min(area.height),
..area
};
render_menu_surface(panel, buf);
// The column owns flexible vertical padding; row highlights span the full panel.
column.render(panel, buf);
}
}
@@ -375,4 +418,49 @@ mod tests {
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn picker_wraps_paths_and_restricted_actions() {
let widget = TrustDirectoryWidget {
restricted: true,
cancel: TrustCancelAction::AgentsOverview,
cwd: PathBuf::from("/workspace/project/long-nested-folder"),
..widget(/*error*/ None)
};
let mut terminal =
Terminal::new(VT100Backend::new(/*width*/ 40, /*height*/ 24)).expect("terminal");
terminal
.draw(|frame| (&widget).render_ref(frame.area(), frame.buffer_mut()))
.expect("draw");
insta::assert_snapshot!("folder_picker_restricted_40x24", terminal.backend());
}
#[test]
fn long_paths_keep_disclosure_and_choices_visible() {
let trust_target: PathBuf = std::iter::once("workspace")
.chain(std::iter::repeat_n("nested-checkout", /*count*/ 60))
.chain(["repository"])
.collect();
for (subdirectory, height, snapshot) in [
(false, 13, "long_checkout_40x13"),
(true, 17, "long_repository_root_40x17"),
(true, 16, "only_repository_root_fits_40x16"),
] {
let widget = TrustDirectoryWidget {
cwd: if subdirectory {
trust_target.join("checkout")
} else {
trust_target.clone()
},
trust_target: trust_target.clone(),
..widget(/*error*/ None)
};
let mut terminal =
Terminal::new(VT100Backend::new(/*width*/ 40, height)).expect("terminal");
terminal
.draw(|frame| (&widget).render_ref(frame.area(), frame.buffer_mut()))
.expect("draw");
insta::assert_snapshot!(snapshot, terminal.backend().to_string().replace('\\', "/"));
}
}
}
+95 -134
View File
@@ -1,9 +1,19 @@
//! Local provider discovery and shared startup picker presentation.
//! Discovery and selection outcomes stay independent of the picker layout.
use std::io;
use std::sync::LazyLock;
use crate::bottom_pane::picker_option_list;
use crate::bottom_pane::render_menu_surface;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::render::Insets;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::render::renderable::RenderableItem;
use codex_http_client::HttpClient;
use codex_http_client::HttpClientBuilder;
use codex_model_provider_info::DEFAULT_LMSTUDIO_PORT;
@@ -24,30 +34,17 @@ use crossterm::terminal::enable_raw_mode;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Direction;
use ratatui::layout::HorizontalAlignment;
use ratatui::layout::Layout;
use ratatui::layout::Margin;
use ratatui::layout::Rect;
use ratatui::prelude::*;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use std::time::Duration;
#[derive(Clone)]
struct ProviderOption {
name: String,
status: ProviderStatus,
}
#[derive(Clone)]
pub(crate) enum ProviderStatus {
Running,
@@ -83,7 +80,7 @@ static OSS_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
});
// This startup wizard runs before the main TUI runtime keymap is available, so
// it mirrors the built-in horizontal list defaults instead of reading config.
// it retains the built-in horizontal shortcuts alongside vertical navigation.
// The shared matcher still covers raw C0 Ctrl-H/Ctrl-L terminal reports.
const MOVE_LEFT_KEYS: [KeyBinding; 2] = [
key_hint::plain(KeyCode::Left),
@@ -96,7 +93,7 @@ const MOVE_RIGHT_KEYS: [KeyBinding; 2] = [
pub struct OssSelectionWidget<'a> {
select_options: &'a Vec<SelectOption>,
confirmation_prompt: Paragraph<'a>,
provider_statuses: [ProviderStatus; 2],
/// Currently selected index in *select* mode.
selected_option: usize,
@@ -109,70 +106,17 @@ pub struct OssSelectionWidget<'a> {
}
impl OssSelectionWidget<'_> {
fn new(lmstudio_status: ProviderStatus, ollama_status: ProviderStatus) -> io::Result<Self> {
let providers = vec![
ProviderOption {
name: "LM Studio".to_string(),
status: lmstudio_status,
},
ProviderOption {
name: "Ollama (Responses)".to_string(),
status: ollama_status.clone(),
},
ProviderOption {
name: "Ollama (Chat)".to_string(),
status: ollama_status,
},
];
let mut contents: Vec<Line> = vec![
Line::from(vec![
"? ".fg(Color::Blue),
"Select an open-source provider".bold(),
]),
Line::from(""),
Line::from(" Choose which local AI server to use for your session."),
Line::from(""),
];
// Add status indicators for each provider
for provider in &providers {
let (status_symbol, status_color) = get_status_symbol_and_color(&provider.status);
contents.push(Line::from(vec![
Span::raw(" "),
Span::styled(status_symbol, Style::default().fg(status_color)),
Span::raw(format!(" {} ", provider.name)),
]));
}
contents.push(Line::from(""));
contents.push(Line::from(" ● Running ○ Not Running").add_modifier(Modifier::DIM));
contents.push(Line::from(""));
contents.push(
Line::from(" Press Enter to select • Ctrl+C to exit").add_modifier(Modifier::DIM),
);
let confirmation_prompt = Paragraph::new(contents).wrap(Wrap { trim: false });
Ok(Self {
fn new(lmstudio_status: ProviderStatus, ollama_status: ProviderStatus) -> Self {
Self {
select_options: &OSS_SELECT_OPTIONS,
confirmation_prompt,
provider_statuses: [lmstudio_status, ollama_status],
selected_option: 0,
done: false,
selection: None,
})
}
}
fn get_confirmation_prompt_height(&self, width: u16) -> u16 {
// Should cache this for last value of width.
self.confirmation_prompt.line_count(width) as u16
}
/// Process a `KeyEvent` coming from crossterm. Always consumes the event
/// while the modal is visible.
/// Process a key event originating from crossterm. As the modal fully
/// captures input while visible, we don't need to report whether the event
/// was consumed—callers can assume it always is.
/// Consume a press while the picker is visible, returning a completed decision.
pub fn handle_key_event(&mut self, key: KeyEvent) -> Option<String> {
if key.kind == KeyEventKind::Press {
self.handle_select_key(key);
@@ -203,11 +147,15 @@ impl OssSelectionWidget<'_> {
} if modifiers.contains(KeyModifiers::CONTROL) => {
self.send_decision("__CANCELLED__".to_string());
}
_ if MOVE_LEFT_KEYS.is_pressed(key_event) => {
_ if MOVE_LEFT_KEYS.is_pressed(key_event)
|| matches!(key_event.code, KeyCode::Up | KeyCode::Char('k')) =>
{
self.selected_option = (self.selected_option + self.select_options.len() - 1)
% self.select_options.len();
}
_ if MOVE_RIGHT_KEYS.is_pressed(key_event) => {
_ if MOVE_RIGHT_KEYS.is_pressed(key_event)
|| matches!(key_event.code, KeyCode::Down | KeyCode::Char('j')) =>
{
self.selected_option = (self.selected_option + 1) % self.select_options.len();
}
KeyEvent {
@@ -222,6 +170,13 @@ impl OssSelectionWidget<'_> {
} => {
self.send_decision(LMSTUDIO_OSS_PROVIDER_ID.to_string());
}
KeyEvent {
code: KeyCode::Char('1' | '2'),
..
} => {
let index = usize::from(key_event.code == KeyCode::Char('2'));
self.send_decision(self.select_options[index].provider_id.to_string());
}
KeyEvent { code, .. } => {
let other = code;
let normalized = Self::normalize_keycode(other);
@@ -246,70 +201,73 @@ impl OssSelectionWidget<'_> {
pub fn is_complete(&self) -> bool {
self.done
}
pub fn desired_height(&self, width: u16) -> u16 {
self.get_confirmation_prompt_height(width) + self.select_options.len() as u16
}
}
impl WidgetRef for &OssSelectionWidget<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let prompt_height = self.get_confirmation_prompt_height(area.width);
let [prompt_chunk, response_chunk] = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(prompt_height), Constraint::Min(0)])
.areas(area);
let lines: Vec<Line> = self
Clear.render(area, buf);
let mut column = FlexRenderable::new();
column.push(/*flex*/ 1, RenderableItem::Borrowed(&""));
column.push(
/*flex*/ 0,
Paragraph::new("Select an open-source provider".bold())
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
column.push(
/*flex*/ 1,
Paragraph::new("Choose a local AI server for this session.".dim())
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
let labels: Vec<_> = self
.select_options
.iter()
.enumerate()
.map(|(idx, opt)| {
let style = if idx == self.selected_option {
Style::new().bg(Color::Cyan).fg(Color::Black)
} else {
Style::new().bg(Color::DarkGray)
.zip(&self.provider_statuses)
.map(|(option, status)| {
let (label, color) = match status {
ProviderStatus::Running => ("● Running", Color::Green),
ProviderStatus::NotRunning => ("○ Not running", Color::Red),
ProviderStatus::Unknown => ("? Unknown", Color::Yellow),
};
opt.label
.clone()
.alignment(HorizontalAlignment::Center)
.style(style)
let mut line = option.label.clone();
line.spans.extend([" · ".dim(), label.fg(color)]);
line
})
.collect();
let [title_area, button_area, description_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(response_chunk.inner(Margin::new(1, 0)));
Line::from("Select provider?").render(title_area, buf);
self.confirmation_prompt.clone().render(prompt_chunk, buf);
let areas = Layout::horizontal(
lines
.iter()
.map(|l| Constraint::Length(l.width() as u16 + 2)),
)
.spacing(1)
.split(button_area);
for (idx, area) in areas.iter().enumerate() {
let line = &lines[idx];
line.render(*area, buf);
}
Line::from(self.select_options[self.selected_option].description)
.style(Style::new().italic().fg(Color::DarkGray))
.render(description_area.inner(Margin::new(1, 0)), buf);
}
}
fn get_status_symbol_and_color(status: &ProviderStatus) -> (&'static str, Color) {
match status {
ProviderStatus::Running => ("●", Color::Green),
ProviderStatus::NotRunning => ("○", Color::Red),
ProviderStatus::Unknown => ("?", Color::Yellow),
column.push(
/*flex*/ 0,
picker_option_list(labels, self.selected_option),
);
column.push(
/*flex*/ 1,
Paragraph::new(self.select_options[self.selected_option].description.dim())
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
column.push(/*flex*/ 1, RenderableItem::Borrowed(&""));
column.push(
/*flex*/ 0,
Paragraph::new(Line::from(vec![
"↑/↓".bold(),
" choose · ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" select · ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" LM Studio · ".dim(),
key_hint::ctrl(KeyCode::Char('c')).into(),
" exit".dim(),
]))
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
column.push(/*flex*/ 1, RenderableItem::Borrowed(&""));
let panel = Rect {
height: column.desired_height(area.width).min(area.height),
..area
};
render_menu_surface(panel, buf);
column.render(panel, buf);
}
}
@@ -366,7 +324,7 @@ pub(crate) async fn select_oss_provider(
lmstudio_status: ProviderStatus,
ollama_status: ProviderStatus,
) -> io::Result<OssProviderSelection> {
let mut widget = OssSelectionWidget::new(lmstudio_status, ollama_status)?;
let mut widget = OssSelectionWidget::new(lmstudio_status, ollama_status);
enable_raw_mode()?;
let mut stdout = io::stdout();
@@ -439,8 +397,7 @@ mod tests {
#[test]
fn ctrl_h_l_move_provider_selection() {
let mut widget = OssSelectionWidget::new(ProviderStatus::Unknown, ProviderStatus::Unknown)
.expect("widget should initialize");
let mut widget = OssSelectionWidget::new(ProviderStatus::Unknown, ProviderStatus::Unknown);
assert_eq!(widget.selected_option, 0);
widget.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
@@ -499,3 +456,7 @@ mod tests {
);
}
}
#[cfg(test)]
#[path = "oss_selection_tests.rs"]
mod presentation_tests;
+84
View File
@@ -0,0 +1,84 @@
//! Presentation and new keyboard navigation checks for the local-provider startup picker.
use super::*;
use crate::test_backend::VT100Backend;
use pretty_assertions::assert_eq;
use ratatui::widgets::FrameExt;
#[test]
fn provider_picker_snapshots() {
for (name, lmstudio, ollama, width, height, selected) in [
(
"oss_providers_stopped",
ProviderStatus::NotRunning,
ProviderStatus::NotRunning,
80,
24,
0,
),
(
"oss_providers_running",
ProviderStatus::Running,
ProviderStatus::Running,
80,
24,
1,
),
(
"oss_providers_unknown_narrow",
ProviderStatus::Unknown,
ProviderStatus::Unknown,
40,
16,
0,
),
(
"oss_providers_mixed_short",
ProviderStatus::NotRunning,
ProviderStatus::Running,
28,
12,
1,
),
] {
let mut widget = OssSelectionWidget::new(lmstudio, ollama);
widget.selected_option = selected;
let mut terminal = Terminal::new(VT100Backend::new(width, height)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&widget, frame.area()))
.expect("draw");
insta::assert_snapshot!(name, terminal.backend());
}
}
#[test]
fn navigation_wraps_without_confirming() {
for event in [
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
] {
let mut widget = OssSelectionWidget::new(ProviderStatus::Running, ProviderStatus::Running);
assert_eq!(widget.handle_key_event(event), None);
assert_eq!((widget.selected_option, widget.is_complete()), (1, false));
assert_eq!(widget.handle_key_event(event), None);
assert_eq!((widget.selected_option, widget.is_complete()), (0, false));
}
}
#[test]
fn shortcuts_select_the_provider_even_when_unavailable() {
for (key, provider) in [
('1', LMSTUDIO_OSS_PROVIDER_ID),
('2', OLLAMA_OSS_PROVIDER_ID),
] {
let mut widget =
OssSelectionWidget::new(ProviderStatus::NotRunning, ProviderStatus::NotRunning);
assert_eq!(
widget.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE)),
Some(provider.to_string())
);
assert!(widget.is_complete());
}
}
+5 -77
View File
@@ -404,80 +404,6 @@ impl<'a> Renderable for FlexRenderable<'a> {
}
}
pub struct RowRenderable<'a> {
children: Vec<(u16, RenderableItem<'a>)>,
}
impl Renderable for RowRenderable<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
let mut x = area.x;
for (width, child) in &self.children {
let available_width = area.width.saturating_sub(x - area.x);
let child_area = Rect::new(x, area.y, (*width).min(available_width), area.height);
if child_area.is_empty() {
break;
}
child.render(child_area, buf);
x = x.saturating_add(*width);
}
}
fn desired_height(&self, width: u16) -> u16 {
let mut max_height = 0;
let mut width_remaining = width;
for (child_width, child) in &self.children {
let w = (*child_width).min(width_remaining);
if w == 0 {
break;
}
let height = child.desired_height(w);
if height > max_height {
max_height = height;
}
width_remaining = width_remaining.saturating_sub(w);
}
max_height
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let mut x = area.x;
for (width, child) in &self.children {
let available_width = area.width.saturating_sub(x - area.x);
let child_area = Rect::new(x, area.y, (*width).min(available_width), area.height);
if !child_area.is_empty()
&& let Some(pos) = child.cursor_pos(child_area)
{
return Some(pos);
}
x = x.saturating_add(*width);
}
None
}
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
let mut x = area.x;
for (width, child) in &self.children {
let available_width = area.width.saturating_sub(x - area.x);
let child_area = Rect::new(x, area.y, (*width).min(available_width), area.height);
if !child_area.is_empty() && child.cursor_pos(child_area).is_some() {
return child.cursor_style(child_area);
}
x = x.saturating_add(*width);
}
SetCursorStyle::DefaultUserShape
}
}
impl<'a> RowRenderable<'a> {
pub fn new() -> Self {
Self { children: vec![] }
}
pub fn push(&mut self, width: u16, child: impl Into<Box<dyn Renderable>>) {
self.children
.push((width, RenderableItem::Owned(child.into())));
}
}
pub struct InsetRenderable<'a> {
child: RenderableItem<'a>,
insets: Insets,
@@ -488,9 +414,11 @@ impl<'a> Renderable for InsetRenderable<'a> {
self.child.render(area.inset(self.insets), buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.child
.desired_height(width - self.insets.left - self.insets.right)
+ self.insets.top
self.child.desired_height(
width
.saturating_sub(self.insets.left)
.saturating_sub(self.insets.right),
) + self.insets.top
+ self.insets.bottom
}
-45
View File
@@ -1,45 +0,0 @@
use crate::render::renderable::Renderable;
use crate::render::renderable::RowRenderable;
use ratatui::style::Style;
use ratatui::style::Styled as _;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use unicode_width::UnicodeWidthStr;
pub(crate) fn selection_option_row(
index: usize,
label: String,
is_selected: bool,
) -> Box<dyn Renderable> {
selection_option_row_with_dim(index, label, is_selected, /*dim*/ false)
}
pub(crate) fn selection_option_row_with_dim(
index: usize,
label: String,
is_selected: bool,
dim: bool,
) -> Box<dyn Renderable> {
let prefix = if is_selected {
format!("› {}. ", index + 1)
} else {
format!(" {}. ", index + 1)
};
let style = if is_selected {
Style::default().cyan()
} else if dim {
Style::default().dim()
} else {
Style::default()
};
let prefix_width = UnicodeWidthStr::width(prefix.as_str()) as u16;
let mut row = RowRenderable::new();
row.push(prefix_width, prefix.set_style(style));
row.push(
u16::MAX,
Paragraph::new(label)
.style(style)
.wrap(Wrap { trim: false }),
);
row.into()
}
@@ -7,6 +7,7 @@ expression: rendered
Description: Apply proposed file edits
Destination: /tmp/project/visible-target.md
› 1. Yes, proceed (y)
2. Yes, and don't ask again for these files (a)
3. No, and tell Codex what to do differently (esc)
@@ -0,0 +1,19 @@
---
source: tui/src/cwd_prompt.rs
expression: rendered
---
Working directory · resume
Session = latest cwd recorded in the
resumed session
Current = your current working
directory
↑
3. Always use session directory
› 4. Always use current directory (/
Users/example/current)
enter continue · esc use session ·
ctrl+c quit
@@ -3,14 +3,13 @@ source: tui/src/cwd_prompt.rs
expression: terminal.backend()
---
Choose working directory to fork this session
Session = latest cwd recorded in the forked session
Current = your current working directory
Working directory · fork
Session = latest cwd recorded in the forked session
Current = your current working directory
› 1. Use session directory (/Users/example/session)
2. Use current directory (/Users/example/current)
3. Always use session directory
4. Always use current directory
Press enter to continue
enter continue · esc use session · ctrl+c quit
@@ -3,14 +3,13 @@ source: tui/src/cwd_prompt.rs
expression: terminal.backend()
---
Choose working directory to resume this session
Session = latest cwd recorded in the resumed session
Current = your current working directory
Working directory · resume
Session = latest cwd recorded in the resumed session
Current = your current working directory
› 1. Use session directory (/Users/example/session)
2. Use current directory (/Users/example/current)
3. Always use session directory
4. Always use current directory
Press enter to continue
enter continue · esc use session · ctrl+c quit
@@ -3,8 +3,7 @@ source: tui/src/cwd_prompt.rs
expression: rendered
---
Choose working directory to resume this session
Working directory · resume
Session = latest cwd recorded in the resumed session
Current = your current working directory
@@ -13,4 +12,4 @@ Choose working directory to resume this session
3. Always use session directory
4. Always use current directory (/Users/example/launched)
Press enter to continue
enter continue · esc use session · ctrl+c quit
@@ -3,8 +3,7 @@ source: tui/src/cwd_prompt.rs
expression: rendered
---
Choose working directory to resume this session
Working directory · resume
Session = latest cwd recorded in the resumed session
Current = your current working directory
@@ -12,4 +11,4 @@ Choose working directory to resume this session
2. Use current directory (/Users/example/current)
3. Always use session directory
Press enter to continue
enter continue · esc use session · ctrl+c quit
@@ -3,16 +3,15 @@ source: tui/src/model_migration.rs
expression: terminal.backend()
---
> Codex just got an upgrade. Introducing gpt-5.1-codex-max.
Codex just got an upgrade. Introducing
gpt-5.1-codex-max.
Upgrade to gpt-5.2-codex for the latest and greatest
agentic coding model.
You can continue using gpt-5.1-codex-mini if you prefer.
Choose how you'd like Codex to proceed.
› 1. Try new model
› 1. Try new model
2. Use existing model
Use ↑/↓ to move, press enter to confirm
enter/esc confirm · ctrl+c quit
@@ -3,7 +3,8 @@ source: tui/src/model_migration.rs
expression: terminal.backend()
---
> Codex just got an upgrade. Introducing gpt-5.1-codex-max.
Codex just got an upgrade. Introducing
gpt-5.1-codex-max.
We recommend switching from gpt-5-codex to
gpt-5.1-codex-max.
@@ -12,4 +13,4 @@ expression: terminal.backend()
Learn more about gpt-5.1-codex-max at
https://www.codex.com/models/gpt-5.1-codex-max
Press enter to continue
enter/esc continue · ctrl+c quit
@@ -3,7 +3,8 @@ source: tui/src/model_migration.rs
expression: terminal.backend()
---
> Codex just got an upgrade. Introducing gpt-5.1-codex-mini.
Codex just got an upgrade. Introducing
gpt-5.1-codex-mini.
We recommend switching from gpt-5-codex-mini to
gpt-5.1-codex-mini.
@@ -12,4 +13,4 @@ expression: terminal.backend()
Learn more about gpt-5.1-codex-mini at
https://www.codex.com/models/gpt-5.1-codex-mini
Press enter to continue
enter/esc continue · ctrl+c quit
@@ -3,11 +3,11 @@ source: tui/src/model_migration.rs
expression: terminal.backend()
---
> Codex just got an upgrade. Introducing gpt-5.1.
Codex just got an upgrade. Introducing gpt-5.1.
We recommend switching from gpt-5 to gpt-5.1.
Broad world knowledge with strong general reasoning. Learn more
about gpt-5.1 at https://www.codex.com/models/gpt-5.1
Broad world knowledge with strong general reasoning. Learn
more about gpt-5.1 at https://www.codex.com/models/gpt-5.1
Press enter to continue
enter/esc continue · ctrl+c quit
@@ -0,0 +1,15 @@
---
source: tui/src/model_migration.rs
expression: rendered
---
# Introducing gpt-new
A faster coding model
with better reasoning.
1. Try new model
› 2. Use existing model
enter/esc confirm ·
ctrl+c quit
@@ -0,0 +1,14 @@
---
source: tui/src/oss_selection_tests.rs
expression: terminal.backend()
---
Select an open-source
provider
1. LM Studio · ○ Not
running
› 2. Ollama · ● Running
↑/↓ choose · enter
select · esc LM Studio ·
ctrl+c exit
@@ -0,0 +1,14 @@
---
source: tui/src/oss_selection_tests.rs
expression: terminal.backend()
---
Select an open-source provider
Choose a local AI server for this session.
1. LM Studio · ● Running
› 2. Ollama · ● Running
Local Ollama server (Responses API, default port 11434)
↑/↓ choose · enter select · esc LM Studio · ctrl+c exit
@@ -0,0 +1,14 @@
---
source: tui/src/oss_selection_tests.rs
expression: terminal.backend()
---
Select an open-source provider
Choose a local AI server for this session.
› 1. LM Studio · ○ Not running
2. Ollama · ○ Not running
Local LM Studio server (default port 1234)
↑/↓ choose · enter select · esc LM Studio · ctrl+c exit
@@ -0,0 +1,17 @@
---
source: tui/src/oss_selection_tests.rs
expression: terminal.backend()
---
Select an open-source provider
Choose a local AI server for this
session.
› 1. LM Studio · ? Unknown
2. Ollama · ? Unknown
Local LM Studio server (default port
1234)
↑/↓ choose · enter select · esc LM
Studio · ctrl+c exit
@@ -0,0 +1,16 @@
---
source: tui/src/startup_hooks_review.rs
expression: "render_lines(&view, 40)"
---
Hooks need review
2 hooks are new or changed.
Hooks can run outside the sandbox
after you trust them.
Trusting hooks...
› Review hooks
Trust all and continue
Continue without trusting (hooks
won't run)
@@ -1,14 +1,17 @@
---
source: tui/src/startup_hooks_review.rs
expression: "render_lines(&view, 80)"
expression: "render_lines(&view, 40)"
---
Hooks need review
2 hooks are new or changed.
Hooks can run outside the sandbox after you trust them.
Hooks can run outside the sandbox
after you trust them.
› 1. Review hooks
2. Trust all and continue
3. Continue without trusting (hooks won't run)
3. Continue without trusting (hooks
won't run)
Press enter to confirm or esc to go back
enter confirm · esc skip
@@ -10,8 +10,9 @@ expression: "render_lines(&view, 62)"
Invalid configuration: features.fast_mode=true is not
supported; allowed set [fast_mode=false]
› 1. Review hooks
2. Trust all and continue
3. Continue without trusting (hooks won't run)
Press enter to confirm or esc to go back
enter confirm · esc skip
@@ -1,15 +1,14 @@
---
source: tui/src/unarchive_prompt_tests.rs
assertion_line: 108
expression: render_inline(80)
---
$ codex fork saved-session
This conversation is archived
019e72f4-e09a-70f2-b2c2-a153a57b8cc0
This conversation is archived
019e72f4-e09a-70f2-b2c2-a153a57b8cc0
› 1. Unarchive and fork
2. Cancel
Press enter to continue or esc to cancel
enter continue · esc cancel
@@ -1,18 +1,17 @@
---
source: tui/src/unarchive_prompt_tests.rs
assertion_line: 113
expression: render_inline(28)
---
$ codex fork saved-session
This conversation is
archived
019e72f4-e09a-70f2-b2c2-a153
a57b8cc0
This conversation is
archived
019e72f4-e09a-70f2-b2c2-
a153a57b8cc0
› 1. Unarchive and fork
2. Cancel
Press enter to continue or
esc to cancel
enter continue · esc
cancel
@@ -0,0 +1,15 @@
---
source: tui/src/update_prompt.rs
expression: rendered
---
Update available · 0.0.0
→ 9.9.9
Release notes:
https://github.com/opena
i/codex/releases/latest
↑
› 3. Skip until next version
enter continue · esc
skip
@@ -2,12 +2,12 @@
source: tui/src/update_prompt.rs
expression: terminal.backend()
---
✨ Update available! 0.0.0 -> 9.9.9
Update available · 0.0.0 → 9.9.9
Release notes: https://github.com/openai/codex/releases/latest
› 1. Update now (runs `npm install -g @openai/codex@latest`)
› 1. Update now (runs `npm install -g @openai/codex`)
2. Skip
3. Skip until next version
Press enter to continue
enter continue · esc skip
+49 -8
View File
@@ -1,3 +1,5 @@
//! Startup hook consent using the shared picker while retaining explicit trust confirmation.
use color_eyre::eyre::Result;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
@@ -18,13 +20,13 @@ use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::ListSelectionView;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap;
use crate::config_update::format_config_error;
use crate::hooks_rpc::HookTrustUpdate;
use crate::hooks_rpc::fetch_hooks_list;
use crate::hooks_rpc::hook_needs_review;
use crate::hooks_rpc::hooks_list_entry_for_cwd;
use crate::hooks_rpc::write_hook_trusts;
use crate::keymap::ListAction;
use crate::keymap::RuntimeKeymap;
use crate::legacy_core::config::Config;
use crate::render::renderable::ColumnRenderable;
@@ -230,18 +232,38 @@ fn selection_view_params(
};
let mut header = ColumnRenderable::new();
header.push(Line::from("Hooks need review".bold()));
header.push(Line::from(count_line).yellow());
header.push(Line::from(
"Hooks can run outside the sandbox after you trust them.".dim(),
));
header.push(Paragraph::new(Line::from(count_line).yellow()).wrap(Wrap { trim: false }));
header.push(
Paragraph::new(Line::from(
"Hooks can run outside the sandbox after you trust them.".dim(),
))
.wrap(Wrap { trim: false }),
);
if let Some(error) = trust_all_error {
header.push(Paragraph::new(Line::from(error.to_string()).red()).wrap(Wrap { trim: false }));
} else if trusting_all {
header.push(Line::from("Trusting hooks...".dim()));
}
let mut hints = Vec::new();
if !trusting_all {
for (action, label) in [
(ListAction::Accept, "confirm"),
(ListAction::Cancel, "skip"),
] {
if let Some(hint) = keymap.list.primary_hint(action) {
if !hints.is_empty() {
hints.push(" · ".dim());
}
hints.extend(hint.spans());
hints.push(format!(" {label}").dim());
}
}
}
SelectionViewParams {
footer_hint: Some(standard_popup_hint_line_for_keymap(&keymap.list)),
appearance: crate::bottom_pane::SelectionAppearance::Picker,
footer_hint: Some(hints.into()),
items: vec![
selection_item("Review hooks", trusting_all),
SelectionItem {
@@ -251,7 +273,7 @@ fn selection_view_params(
selection_item("Continue without trusting (hooks won't run)", trusting_all),
],
header: Box::new(header),
..Default::default()
..SelectionViewParams::picker()
}
}
@@ -324,6 +346,7 @@ mod tests {
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use tokio::sync::mpsc::unbounded_channel;
@@ -461,7 +484,7 @@ mod tests {
assert_snapshot!(
"startup_hooks_review_prompt",
render_lines(&view, /*width*/ 80)
render_lines(&view, /*width*/ 40)
);
}
@@ -484,4 +507,22 @@ mod tests {
render_lines(&view, /*width*/ 62)
);
}
#[test]
fn picker_progress_blocks_selection() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let mut view = selection_view(
&entry(),
/*trust_all_error*/ None,
/*trusting_all*/ true,
AppEventSender::new(tx_raw),
&RuntimeKeymap::defaults(),
);
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(selected_choice(&mut view), None);
assert_snapshot!(
"startup_hooks_picker_progress_40",
render_lines(&view, /*width*/ 40)
);
}
}
+46 -24
View File
@@ -1,7 +1,13 @@
//! Archived-session recovery with shared picker rows and scoped terminal ownership.
use crate::bottom_pane::picker_option_list;
use crate::bottom_pane::render_menu_surface;
use crate::key_hint;
use crate::render::renderable::ColumnRenderable;
use crate::render::Insets;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::selection_list::selection_option_row;
use crate::render::renderable::RenderableExt as _;
use crate::render::renderable::RenderableItem;
use crate::session_start::SessionStartAction;
use crate::tui::Tui;
use crate::tui::TuiEvent;
@@ -158,37 +164,47 @@ impl UnarchivePrompt {
}
}
fn content(&self) -> ColumnRenderable<'static> {
let mut column = ColumnRenderable::new();
column.push("");
fn content(&self) -> FlexRenderable<'static> {
let mut column = FlexRenderable::new();
column.push(/*flex*/ 1, RenderableItem::Borrowed(&""));
column.push(
Paragraph::new("This conversation is archived".bold()).wrap(Wrap { trim: false }),
/*flex*/ 0,
Paragraph::new("This conversation is archived".bold())
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
column.push(
Paragraph::new(Line::from(self.thread_id.to_string()).dim()).wrap(Wrap { trim: false }),
/*flex*/ 0,
Paragraph::new(Line::from(self.thread_id.to_string()).dim())
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
column.push("");
column.push(selection_option_row(
/*index*/ 0,
format!("Unarchive and {}", self.action.verb()),
self.highlighted == UnarchiveChoice::Unarchive,
));
column.push(selection_option_row(
/*index*/ 1,
"Cancel".to_string(),
self.highlighted == UnarchiveChoice::Cancel,
));
column.push("");
let selected_index = match self.highlighted {
UnarchiveChoice::Unarchive => 0,
UnarchiveChoice::Cancel | UnarchiveChoice::Quit => 1,
};
column.push(
/*flex*/ 1,
picker_option_list(
vec![
format!("Unarchive and {}", self.action.verb()),
"Cancel".to_string(),
],
selected_index,
),
);
column.push(
/*flex*/ 0,
Paragraph::new(Line::from(vec![
"Press ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" to continue or ".dim(),
" continue · ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" to cancel".dim(),
" cancel".dim(),
]))
.wrap(Wrap { trim: false }),
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
column.push(/*flex*/ 1, RenderableItem::Borrowed(&""));
column
}
}
@@ -196,7 +212,13 @@ impl UnarchivePrompt {
impl WidgetRef for &UnarchivePrompt {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
self.content().render(area, buf);
let content = self.content();
let panel = Rect {
height: content.desired_height(area.width).min(area.height),
..area
};
render_menu_surface(panel, buf);
content.render(panel, buf);
}
}
+4 -4
View File
@@ -35,12 +35,12 @@ fn resume_prompt_snapshot() {
));
insta::assert_snapshot!(render(&screen), @"
This conversation is archived
019e72f4-e09a-70f2-b2c2-a153a57b8cc0
019e72f4-e09a-70f2-b2c2-a153a57b8cc0
› 1. Unarchive and resume
2. Cancel
Press enter to continue or esc to cancel
enter continue · esc cancel
");
}
@@ -55,12 +55,12 @@ fn fork_prompt_cancel_snapshot() {
);
insta::assert_snapshot!(render(&screen), @"
This conversation is archived
019e72f4-e09a-70f2-b2c2-a153a57b8cc0
019e72f4-e09a-70f2-b2c2-a153a57b8cc0
1. Unarchive and fork
› 2. Cancel
Press enter to continue or esc to cancel
enter continue · esc cancel
");
assert_eq!(
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
+96 -45
View File
@@ -1,17 +1,27 @@
#![cfg(not(debug_assertions))]
//! Shared picker presentation for the installed CLI's update choices.
//! Update discovery and execution remain disabled in debug builds.
#![cfg(any(not(debug_assertions), test))]
use crate::bottom_pane::picker_option_list;
use crate::bottom_pane::render_menu_surface;
use crate::key_hint;
#[cfg(not(debug_assertions))]
use crate::legacy_core::config::Config;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::selection_list::selection_option_row;
use crate::render::renderable::RenderableItem;
use crate::tui::FrameRequester;
#[cfg(not(debug_assertions))]
use crate::tui::Tui;
#[cfg(not(debug_assertions))]
use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
#[cfg(not(debug_assertions))]
use crate::updates;
#[cfg(not(debug_assertions))]
use color_eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -23,16 +33,21 @@ use ratatui::prelude::Widget;
use ratatui::style::Stylize as _;
use ratatui::text::Line;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
#[cfg(not(debug_assertions))]
use tokio_stream::StreamExt;
const RELEASE_NOTES_URL: &str = "https://github.com/openai/codex/releases/latest";
#[cfg(not(debug_assertions))]
pub(crate) enum UpdatePromptOutcome {
Continue,
RunUpdate(UpdateAction),
}
#[cfg(not(debug_assertions))]
pub(crate) async fn run_update_prompt_if_needed(
tui: &mut Tui,
config: &Config,
@@ -161,6 +176,7 @@ impl UpdatePromptScreen {
self.selection
}
#[cfg(not(debug_assertions))]
fn latest_version(&self) -> &str {
self.latest_version.as_str()
}
@@ -187,56 +203,69 @@ impl UpdateSelection {
impl WidgetRef for &UpdatePromptScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let mut column = ColumnRenderable::new();
let mut column = FlexRenderable::new();
let update_command = self.update_action.command_str();
column.push("");
column.push(Line::from(vec![
" ✨\u{200A}".bold().cyan(),
"Update available!".bold(),
" ".into(),
format!(
"{current} -> {latest}",
current = self.current_version,
latest = self.latest_version
)
.dim(),
]));
column.push("");
column.push(/*flex*/ 1, RenderableItem::Borrowed(&""));
column.push(
Line::from(vec![
/*flex*/ 0,
Paragraph::new(Line::from(vec![
"Update available".bold(),
" · ".dim(),
format!(
"{current} → {latest}",
current = self.current_version,
latest = self.latest_version
)
.dim(),
]))
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
column.push(
/*flex*/ 1,
Paragraph::new(Line::from(vec![
"Release notes: ".dim(),
RELEASE_NOTES_URL.dim().underlined(),
])
.inset(Insets::tlbr(0, 2, 0, 0)),
]))
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
column.push("");
column.push(selection_option_row(
0,
format!("Update now (runs `{update_command}`)"),
self.highlighted == UpdateSelection::UpdateNow,
));
column.push(selection_option_row(
1,
"Skip".to_string(),
self.highlighted == UpdateSelection::NotNow,
));
column.push(selection_option_row(
2,
"Skip until next version".to_string(),
self.highlighted == UpdateSelection::DontRemind,
));
column.push("");
let selected_index = match self.highlighted {
UpdateSelection::UpdateNow => 0,
UpdateSelection::NotNow => 1,
UpdateSelection::DontRemind => 2,
};
column.push(
Line::from(vec![
"Press ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" to continue".dim(),
])
.inset(Insets::tlbr(0, 2, 0, 0)),
/*flex*/ 1,
picker_option_list(
vec![
format!("Update now (runs `{update_command}`)"),
"Skip".to_string(),
"Skip until next version".to_string(),
],
selected_index,
),
);
column.render(area, buf);
column.push(
/*flex*/ 0,
Paragraph::new(Line::from(vec![
key_hint::plain(KeyCode::Enter).into(),
" continue · ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" skip".dim(),
]))
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
column.push(/*flex*/ 1, RenderableItem::Borrowed(&""));
let panel = Rect {
height: column.desired_height(area.width).min(area.height),
..area
};
render_menu_surface(panel, buf);
column.render(panel, buf);
crate::terminal_hyperlinks::mark_underlined_hyperlink(buf, area, RELEASE_NOTES_URL);
}
}
@@ -249,6 +278,7 @@ mod tests {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
use ratatui::widgets::FrameExt;
@@ -263,7 +293,8 @@ mod tests {
#[test]
fn update_prompt_snapshot() {
let screen = new_prompt();
let mut terminal = Terminal::new(VT100Backend::new(80, 12)).expect("terminal");
let mut terminal =
Terminal::new(VT100Backend::new(/*width*/ 80, /*height*/ 12)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
.expect("render update prompt");
@@ -313,4 +344,24 @@ mod tests {
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(screen.highlighted, UpdateSelection::UpdateNow);
}
#[test]
fn long_update_command_keeps_selected_skip_visible_in_a_short_viewport() {
let mut screen = new_prompt();
screen.update_action = UpdateAction::StandaloneWindows;
screen.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let (width, height) = (28, 12);
let mut terminal = Terminal::new(VT100Backend::new(width, height)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
.expect("render resized update picker");
let rendered = terminal.backend().to_string();
assert!(rendered.contains("› 3. Skip until next version"));
let words = rendered.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(words.contains("enter continue · esc skip"));
assert_eq!(screen.selection(), None);
insta::assert_snapshot!(format!("update_picker_selected_{width}x{height}"), rendered);
screen.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(screen.selection(), Some(UpdateSelection::NotNow));
}
}
+7 -5
View File
@@ -162,7 +162,7 @@ async fn connected_trust_cancellation_and_acceptance_control_task_creation() ->
("Launch-folder task", b"\x1b[Bn"),
(prompt, b"\x1b"),
("n new", b"n"),
("You are in", b"\x1b"),
("Folder access", b"\x1b"),
("o resume", b"o"),
("Resume a previous session", b"\x1b[C"),
("Untrusted saved task", b"\r"),
@@ -174,16 +174,18 @@ async fn connected_trust_cancellation_and_acceptance_control_task_creation() ->
let is_consent = expected == prompt
|| matches!(
expected,
"Open existing task" | "moved-folder" | "You are in"
"Open existing task" | "moved-folder" | "Folder access"
);
terminal.wait_for_screen(expected)?;
if expected == "You are in" {
if expected == "Folder access" {
assert_eq!(
terminal
.screen_contents()
.lines()
.find(|line| line.contains("You are in")),
Some(format!("> You are in {}", repo_root.display()).as_str())
.skip_while(|line| line.trim() != "Folder access")
.nth(/*n*/ 1)
.map(str::trim),
Some(repo_root.display().to_string().as_str())
);
}
if is_consent {