mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
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:
@@ -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
-12
@@ -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) {
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/request_user_input/mod.rs
|
||||
assertion_line: 3785
|
||||
expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+1
@@ -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)
|
||||
|
||||
|
||||
+1
@@ -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
@@ -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)
|
||||
|
||||
+1
@@ -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)
|
||||
|
||||
+15
-14
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
+12
@@ -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
|
||||
+20
@@ -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
|
||||
+11
@@ -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
|
||||
+2
-1
@@ -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)
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
|
||||
+11
-10
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+4
-5
@@ -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
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+1
@@ -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) "
|
||||
|
||||
+11
-10
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
+11
-10
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]),
|
||||
};
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+21
@@ -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
|
||||
+17
@@ -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
|
||||
+21
@@ -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
|
||||
+20
@@ -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
|
||||
+4
-3
@@ -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
|
||||
|
||||
+6
-5
@@ -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
|
||||
|
||||
+9
-7
@@ -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
|
||||
|
||||
+9
-8
@@ -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
|
||||
|
||||
@@ -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('\\', "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+19
@@ -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
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
+4
-5
@@ -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
-2
@@ -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
-2
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+15
@@ -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
|
||||
+14
@@ -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
|
||||
+14
@@ -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
|
||||
+14
@@ -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
|
||||
+17
@@ -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
|
||||
+16
@@ -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)
|
||||
+7
-4
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+6
-7
@@ -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
|
||||
|
||||
+15
@@ -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
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user