Unify TUI picker styling and improve compact layouts (#46692)

## What changed

- Apply shared picker styling to apps, plugins, settings, skills, hooks, and multi-select views, with full-width selection highlights and compact key hints.
- Share row measurement and layout helpers, show scroll indicators, and let optional spacing yield on short screens to keep selected rows visible.
- Wrap headers and footers, simplify search fields, and use a compact hooks event table at narrow widths.
- Reserve plugin result rows while filtering and cap the plugin name column width.

## Testing

Add regression coverage for selected rows in short multi-select views, compact hooks navigation, and checkbox, preview, and overflow rendering. Extend experimental-feature and memory-reset wrapping snapshots to narrower widths and update affected popup snapshots.

GitOrigin-RevId: 0eedbbff307c84cc858629e578091bbbd59b2423
This commit is contained in:
Eric Traut
2026-09-19 19:12:18 +00:00
committed by copyberry
parent 79d2144ec6
commit 018687bd8c
91 changed files with 1452 additions and 721 deletions
+32 -18
View File
@@ -629,7 +629,12 @@ impl AppLinkView {
}
.dim(),
]));
let url_line = Line::from(vec![self.url.clone().cyan().underlined()]);
let url_line = Line::from(vec![
self.url
.clone()
.fg(crate::style::accent_color())
.underlined(),
]);
lines.extend(adaptive_wrap_lines(
vec![url_line],
RtOptions::new(usable_width),
@@ -649,6 +654,8 @@ impl AppLinkView {
' '
};
GenericDisplayRow {
category_tag: None,
selection_style: Some(crate::bottom_pane::selection_style()),
name: format!("{prefix} {}. {label}", index + 1),
..Default::default()
}
@@ -669,19 +676,25 @@ impl AppLinkView {
}
fn hint_line(&self) -> Line<'static> {
let mut spans = vec!["Use ".into(), key_hint::plain(KeyCode::Tab).into()];
let mut spans = vec![key_hint::plain(KeyCode::Tab).into()];
if let Some(move_up) = self.list_keymap.primary_hint(ListAction::MoveUp) {
spans.extend([" / ".into(), move_up.into()]);
spans.push(" / ".into());
spans.extend(move_up.spans());
}
if let Some(move_down) = self.list_keymap.primary_hint(ListAction::MoveDown) {
spans.extend([" ".into(), move_down.into()]);
spans.push(" ".into());
spans.extend(move_down.spans());
}
spans.push(" to move".into());
spans.push(" move".into());
if let Some(accept) = self.list_keymap.primary_hint(ListAction::Accept) {
spans.extend([", ".into(), accept.into(), " to select".into()]);
spans.push(" · ".into());
spans.extend(accept.spans());
spans.push(" select".into());
}
if let Some(cancel) = self.list_keymap.primary_hint(ListAction::Cancel) {
spans.extend([", ".into(), cancel.into(), " to close".into()]);
spans.push(" · ".into());
spans.extend(cancel.spans());
spans.push(" close".into());
}
Line::from(spans)
}
@@ -811,8 +824,11 @@ impl crate::render::renderable::Renderable for AppLinkView {
.wrap(Wrap { trim: false })
.line_count(content_width)
.max(1) as u16;
let action_rows_height = self.action_rows_height(content_width);
content_rows + action_rows_height + 3
let action_rows_height = self.action_rows_height(width);
let hint_rows = Paragraph::new(self.hint_line())
.wrap(Wrap { trim: false })
.line_count(width.saturating_sub(/*rhs*/ 2)) as u16;
content_rows + action_rows_height + hint_rows + 2
}
fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -824,11 +840,15 @@ impl crate::render::renderable::Renderable for AppLinkView {
.style(user_message_style())
.render(area, buf);
let actions_height = self.action_rows_height(area.width.saturating_sub(4));
let actions_height = self.action_rows_height(area.width);
let hint = Paragraph::new(self.hint_line())
.dim()
.wrap(Wrap { trim: false });
let hint_height = hint.line_count(area.width.saturating_sub(/*rhs*/ 2)) as u16;
let [content_area, actions_area, hint_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(actions_height),
Constraint::Length(1),
Constraint::Length(hint_height),
])
.areas(area);
@@ -841,12 +861,6 @@ impl crate::render::renderable::Renderable for AppLinkView {
crate::terminal_hyperlinks::mark_url_hyperlink(buf, inner, &self.url);
if actions_area.height > 0 {
let actions_area = Rect {
x: actions_area.x.saturating_add(2),
y: actions_area.y,
width: actions_area.width.saturating_sub(2),
height: actions_area.height,
};
let action_rows = self.action_rows();
let action_state = self.action_state();
render_rows(
@@ -866,7 +880,7 @@ impl crate::render::renderable::Renderable for AppLinkView {
width: hint_area.width.saturating_sub(2),
height: hint_area.height,
};
self.hint_line().dim().render(hint_area, buf);
hint.render(hint_area, buf);
}
}
}
@@ -22,7 +22,9 @@ use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -30,19 +32,17 @@ use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::picker_rows::measure_rows_height;
use super::picker_rows::render_rows;
use super::popup_consts::MAX_POPUP_ROWS;
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;
pub(crate) struct ExperimentalFeatureItem {
pub key: String,
@@ -102,13 +102,15 @@ impl ExperimentalFeaturesView {
fn header(&self, width: u16) -> impl Renderable {
let mut header = ColumnRenderable::new();
header.push(Line::from("Experimental features".bold()));
header.push(
Paragraph::new(Line::from("Experimental features".bold())).wrap(Wrap { trim: false }),
);
for text in [
"Checked features are configured on. Some experimental features take effect only in new tasks or after restarting the Codex server.",
self.discovery_status.as_str(),
].into_iter().filter(|text| !text.is_empty()) {
for line in textwrap::wrap(text, usize::from(width.max(1))) {
header.push(Line::from(line.into_owned().dim()));
header.push(Paragraph::new(Line::from(line.into_owned().dim())).wrap(Wrap { trim: false }));
}
}
header
@@ -122,6 +124,16 @@ impl ExperimentalFeaturesView {
}
}
fn current_hint(&self) -> Line<'static> {
if self.write_rx.is_some() {
Line::from("Saving… Closing this popup will not cancel the write.")
} else if !self.unconfirmed.is_empty() {
Line::from("Selections retained. Save to retry, or cancel to close.")
} else {
self.footer_hint.clone()
}
}
fn visible_len(&self) -> usize {
self.features.len()
}
@@ -139,6 +151,9 @@ impl ExperimentalFeaturesView {
let read_only = if item.writable { "" } else { " (read-only)" };
let name = format!("{prefix} [{marker}] {}{read_only}", item.name);
rows.push(GenericDisplayRow {
category_tag: None,
selection_style: Some(super::picker_style::selection_style()),
wrap_indent: Some(6),
name,
description: Some(item.description.clone()),
is_disabled: !item.writable || self.write_rx.is_some(),
@@ -256,10 +271,6 @@ impl ExperimentalFeaturesView {
self.complete = true;
}
}
fn rows_width(total_width: u16) -> u16 {
total_width.saturating_sub(2)
}
}
impl BottomPaneView for ExperimentalFeaturesView {
@@ -410,8 +421,16 @@ impl Renderable for ExperimentalFeaturesView {
return;
}
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
let hint = self.current_hint();
let hint_lines = super::selection_popup_common::wrap_styled_line(
&hint,
area.width.saturating_sub(/*rhs*/ 2),
);
let [content_area, footer_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(hint_lines.len() as u16),
])
.areas(area);
Block::default()
.style(user_message_style())
@@ -420,24 +439,14 @@ impl Renderable for ExperimentalFeaturesView {
let header = self.header(content_area.width.saturating_sub(4));
let header_height = header.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_width = Self::rows_width(content_area.width);
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, rows_width);
let [header_area, _, list_area] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
Constraint::Length(rows_height),
])
.areas(content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2)));
let rows_height =
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, content_area.width);
let [header_area, _, render_area] =
super::picker_rows::layout(content_area, header_height, /*search*/ 0, rows_height);
header.render(header_area, buf);
if list_area.height > 0 && (!rows.is_empty() || self.catalog_rx.is_none()) {
let render_area = Rect {
x: list_area.x.saturating_sub(2),
y: list_area.y,
width: rows_width.max(1),
height: list_area.height,
};
if render_area.height > 0 && (!rows.is_empty() || self.catalog_rx.is_none()) {
render_rows(
render_area,
buf,
@@ -454,37 +463,38 @@ impl Renderable for ExperimentalFeaturesView {
width: footer_area.width.saturating_sub(2),
height: footer_area.height,
};
let hint = if self.write_rx.is_some() {
Line::from("Saving… Closing this popup will not cancel the write.")
} else if !self.unconfirmed.is_empty() {
Line::from("Selections retained. Save to retry, or cancel to close.")
} else {
self.footer_hint.clone()
};
hint.dim().render(hint_area, buf);
Paragraph::new(hint_lines).dim().render(hint_area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
let rows = self.build_rows();
let rows_width = Self::rows_width(width);
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, rows_width);
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
let mut height = self
.header(width.saturating_sub(4))
.desired_height(width.saturating_sub(4));
height = height.saturating_add(rows_height + 3);
height.saturating_add(1)
height.saturating_add(
super::selection_popup_common::wrap_styled_line(
&self.current_hint(),
width.saturating_sub(/*rhs*/ 2),
)
.len() as u16,
)
}
}
fn experimental_popup_hint_line(keymap: &ListKeymap) -> Line<'static> {
let mut spans = vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to select".into(),
];
let mut spans = vec![key_hint::plain(KeyCode::Char(' ')).into(), " toggle".into()];
if let Some(accept) = keymap.primary_hint(ListAction::Accept) {
spans.extend([" or ".into(), accept.into(), " to save".into()]);
spans.push(" · ".into());
spans.extend(accept.spans());
spans.push(" save".into());
}
if let Some(cancel) = keymap.primary_hint(ListAction::Cancel) {
spans.push(" · ".into());
spans.extend(cancel.spans());
spans.push(" save/close".into());
}
Line::from(spans)
}
@@ -28,7 +28,7 @@ fn experimental_features_keep_following_choice_at_wrap_boundary() {
);
let mut snapshots = Vec::new();
for width in [70, 71, 72] {
for width in [68, 69, 70, 71, 72] {
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
@@ -0,0 +1,162 @@
//! Responsive hook browsing presentation; trust and toggle policies stay in the view.
use super::*;
use crate::bottom_pane::picker_style;
use crate::bottom_pane::selection_popup_common::RenderedRows;
use crate::render::Insets;
use crate::render::RectExt;
use ratatui::widgets::Wrap;
impl HooksBrowserView {
// Keep the existing yellow warning treatment for hooks awaiting review.
#[allow(clippy::disallowed_methods)]
fn event_parts(&self, width: u16) -> (Vec<Line<'static>>, Vec<Line<'static>>) {
let events = self.event_rows();
let mut header = self.event_page_lines();
let mut rows = header.split_off(header.len().saturating_sub(events.len()));
if width < 72 {
header.pop();
header.push("Event Active/Installed".dim().into());
rows = events
.iter()
.enumerate()
.map(|(index, row)| {
let name = event_label(row.event_name);
let review = if row.needs_review > 0 { " !" } else { "" };
let line = Line::from(format!(
"{name:<18} {}/{}{review}",
row.active, row.installed
));
if self.state.selected_idx == Some(index) {
line.style(selection_style())
} else if row.needs_review > 0 {
line.yellow()
} else {
line
}
})
.collect();
}
for (index, line) in rows.iter_mut().enumerate() {
let prefix = if self.state.selected_idx == Some(index) {
"› "
} else {
" "
};
line.spans.insert(/*index*/ 0, prefix.into());
}
(header, rows)
}
}
fn render_line_rows(
area: Rect,
buf: &mut Buffer,
rows: Vec<Line<'static>>,
mut state: ScrollState,
) {
let body = area.inset(Insets::vh(u16::from(area.height >= 3), /*h*/ 0));
let visible = usize::from(body.height);
state.ensure_visible(rows.len(), visible.max(/*other*/ 1));
for (offset, line) in rows.iter().skip(state.scroll_top).take(visible).enumerate() {
let row_area = Rect::new(
body.x,
body.y + offset as u16,
body.width,
/*height*/ 1,
);
line.clone().render(row_area, buf);
if line.style.bg.is_some() {
buf.set_style(row_area, line.style);
}
}
picker_style::render_scroll_indicators(
area,
buf,
RenderedRows {
has_above: state.scroll_top > 0,
has_below: state.scroll_top + visible < rows.len(),
..Default::default()
},
);
}
impl Renderable for HooksBrowserView {
fn desired_height(&self, width: u16) -> u16 {
let inner_width = width.saturating_sub(/*rhs*/ 4).max(/*other*/ 1);
let (header, rows, detail) = match self.page {
HooksBrowserPage::Events => {
let (header, rows) = self.event_parts(inner_width);
(header, rows.len(), 0)
}
HooksBrowserPage::Handlers(event) => {
let rows = self.handlers_for_event(event).count();
let detail = if rows == 0 {
0
} else {
self.detail_lines(event, usize::from(inner_width)).len() + 1
};
(
Self::handler_header_lines(event, self.review_needed_count(event)),
rows,
detail,
)
}
};
let header_height = Paragraph::new(header)
.wrap(Wrap { trim: false })
.line_count(inner_width);
u16::try_from(header_height + rows.clamp(/*min*/ 1, MAX_POPUP_ROWS) + detail + 5)
.unwrap_or(u16::MAX)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
let [panel, footer] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
let content = render_menu_surface(panel, buf);
if content.is_empty() {
self.render_footer(footer, buf);
return;
}
let (header, rows) = match self.page {
HooksBrowserPage::Events => self.event_parts(content.width),
HooksBrowserPage::Handlers(event) => (
Self::handler_header_lines(event, self.review_needed_count(event)),
self.handler_row_lines(event, usize::from(area.width)),
),
};
let header = Paragraph::new(header).wrap(Wrap { trim: false });
let header_height =
(header.line_count(content.width) as u16).min(content.height.saturating_sub(/*rhs*/ 3));
let [header_area, body] =
Layout::vertical([Constraint::Length(header_height), Constraint::Fill(1)])
.areas(content);
header.render(header_area, buf);
let body = Rect::new(area.x, body.y, area.width, body.height);
match self.page {
HooksBrowserPage::Events => render_line_rows(body, buf, rows, self.state),
HooksBrowserPage::Handlers(event) => {
if rows.is_empty() {
Paragraph::new(" No hooks installed for this event.")
.dim()
.render(body, buf);
} else {
let desired = rows.len().min(MAX_POPUP_ROWS) as u16 + 2;
let list_height = desired
.min(body.height.saturating_sub(/*rhs*/ 3).max(/*other*/ 3))
.min(body.height);
let [list, details] =
Layout::vertical([Constraint::Length(list_height), Constraint::Fill(1)])
.areas(body);
render_line_rows(list, buf, rows, self.state);
Paragraph::new(self.detail_lines(event, usize::from(content.width)))
.render(details.inset(Insets::vh(/*v*/ 0, /*h*/ 2)), buf);
}
}
}
self.render_footer(footer, buf);
}
}
@@ -22,6 +22,7 @@ use unicode_width::UnicodeWidthStr;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::picker_style::selection_style;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::render_menu_surface;
@@ -36,7 +37,6 @@ use crate::keymap::ListKeymap;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::renderable::Renderable;
use crate::status::format_directory_display;
use crate::style::accent_style;
const EVENT_COLUMN_WIDTH: usize = 22;
const COUNT_COLUMN_WIDTH: usize = 12;
@@ -371,7 +371,7 @@ impl HooksBrowserView {
row_line.push(Span::from(event_description(row.event_name)));
if selected {
let style = accent_style();
let style = selection_style();
for span in &mut row_line {
*span = span.clone().set_style(style);
}
@@ -384,7 +384,11 @@ impl HooksBrowserView {
let description_idx = row_line.len() - 1;
row_line[description_idx] = row_line[description_idx].clone().dim();
}
lines.push(Line::from(row_line));
lines.push(if selected {
Line::from(row_line).style(selection_style())
} else {
Line::from(row_line)
});
}
lines
}
@@ -451,15 +455,16 @@ impl HooksBrowserView {
format!("[{marker}] {}", hook_title(idx))
}
};
let mut line = Line::from(row);
let prefix = if self.state.selected_idx == Some(idx) {
"› "
} else {
" "
};
let mut line = Line::from(format!("{prefix}{row}"));
line = truncate_line_with_ellipsis_if_overflow(line, width);
let needs_review = hook_needs_review(hook);
if self.state.selected_idx == Some(idx) {
if needs_review {
line = line.yellow().bold();
} else {
line = line.patch_style(accent_style());
}
line = line.patch_style(selection_style());
} else if needs_review {
line = line.yellow();
} else if hook.is_managed {
@@ -540,56 +545,45 @@ impl HooksBrowserView {
.keymap
.primary_hint(ListAction::Cancel)
.unwrap_or_else(|| key_hint::plain(KeyCode::Esc).into());
let footer = match self.page {
HooksBrowserPage::Events if self.review_needed_total_count() > 0 => {
let mut spans = vec![
"Press ".into(),
key_hint::plain(KeyCode::Char('t')).into(),
" to trust all; ".into(),
];
if let Some(accept) = accept {
spans.extend([accept.into(), " to review hooks; ".into()]);
}
spans.extend([cancel.into(), " to close".into()]);
Line::from(spans)
}
let mut spans = Vec::new();
match self.page {
HooksBrowserPage::Events => {
let mut spans = vec!["Press ".into()];
if let Some(accept) = accept {
spans.extend([accept.into(), " to view hooks; ".into()]);
if self.review_needed_total_count() > 0 {
spans.extend(key_hint::plain(KeyCode::Char('t')).spans());
spans.push(" trust all · ".dim());
if let Some(accept) = accept {
spans.extend(accept.spans());
spans.push(" review · ".dim());
}
} else if let Some(accept) = accept {
spans.extend(accept.spans());
spans.push(" details · ".dim());
}
spans.extend([cancel.into(), " to close".into()]);
Line::from(spans)
spans.extend(cancel.spans());
spans.push(" close".dim());
}
HooksBrowserPage::Handlers(event_name) => {
let selected_hook = self.selected_hook(event_name);
if selected_hook.is_none() {
Line::from(vec!["Press ".into(), cancel.into(), " to go back".into()])
} else if selected_hook.is_some_and(|hook| hook.is_managed) {
Line::from(vec![
"Managed hooks are always on; press ".into(),
cancel.into(),
" to go back".into(),
])
} else if selected_hook.is_some_and(hook_needs_review) {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Char('t')).into(),
" to trust; ".into(),
cancel.into(),
" to go back".into(),
])
} else {
let mut spans =
vec!["Press ".into(), key_hint::plain(KeyCode::Char(' ')).into()];
if let Some(accept) = accept {
spans.extend([" or ".into(), accept.into()]);
if let Some(hook) = self.selected_hook(event_name) {
if hook.is_managed {
spans.push("Managed (always on) · ".dim());
} else if hook_needs_review(hook) {
spans.extend(key_hint::plain(KeyCode::Char('t')).spans());
spans.push(" trust · ".dim());
} else {
let mut toggle = key_hint::plain(KeyCode::Char(' ')).display_label();
if let Some(accept) = accept {
toggle.push('/');
toggle.push_str(&accept.display_label());
}
spans.extend(key_hint::key_label_spans(&toggle));
spans.push(" toggle · ".dim());
}
spans.extend([" to toggle; ".into(), cancel.into(), " to go back".into()]);
Line::from(spans)
}
spans.extend(cancel.spans());
spans.push(" back".dim());
}
};
}
let footer = Line::from(spans);
footer.dim().render(hint_area, buf);
}
}
@@ -656,83 +650,8 @@ impl BottomPaneView for HooksBrowserView {
}
}
impl Renderable for HooksBrowserView {
fn desired_height(&self, width: u16) -> u16 {
let content_width = width.saturating_sub(4) as usize;
let height = match self.page {
HooksBrowserPage::Events => self.event_page_lines().len(),
HooksBrowserPage::Handlers(event_name) => {
let row_count = self.handler_row_lines(event_name, content_width).len();
let header_line_count =
Self::handler_header_lines(event_name, self.review_needed_count(event_name))
.len();
if row_count == 0 {
header_line_count + 2
} else {
let visible_row_count = row_count.min(MAX_POPUP_ROWS);
header_line_count
+ 1
+ visible_row_count
+ 1
+ self.detail_lines(event_name, content_width).len()
}
}
};
(height + 3).try_into().unwrap_or(u16::MAX)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
let content_area = render_menu_surface(content_area, buf);
let width = content_area.width as usize;
let lines = match self.page {
HooksBrowserPage::Events => self.event_page_lines(),
HooksBrowserPage::Handlers(event_name) => {
let mut lines =
Self::handler_header_lines(event_name, self.review_needed_count(event_name));
let rows = self.handler_row_lines(event_name, width);
if rows.is_empty() {
lines.push(Line::default());
lines.push(Line::from(
"No hooks installed for this event.".dim().italic(),
));
lines.push(Line::default());
Paragraph::new(lines).render(content_area, buf);
self.render_footer(footer_area, buf);
return;
}
let list_height = rows.len().clamp(1, MAX_POPUP_ROWS) as u16;
lines.push(Line::default());
let header_height = lines.len() as u16;
let [header_area, list_area, detail_area] = Layout::vertical([
Constraint::Length(header_height),
Constraint::Length(list_height),
Constraint::Fill(1),
])
.areas(content_area);
Paragraph::new(lines.clone()).render(header_area, buf);
let visible_rows = rows
.into_iter()
.skip(self.state.scroll_top)
.take(list_height as usize)
.collect::<Vec<_>>();
Paragraph::new(visible_rows).render(list_area, buf);
let mut detail_lines = vec![Line::default()];
detail_lines.extend(self.detail_lines(event_name, width));
Paragraph::new(detail_lines).render(detail_area, buf);
self.render_footer(footer_area, buf);
return;
}
};
Paragraph::new(lines).render(content_area, buf);
self.render_footer(footer_area, buf);
}
}
#[path = "hooks_browser_render.rs"]
mod render;
fn hook_is_active(hook: &HookMetadata) -> bool {
hook.enabled
@@ -1045,14 +964,44 @@ mod tests {
let mut view = view();
assert_snapshot!("hooks_browser_events", render_lines(&view, /*width*/ 112));
view.keymap.accept.clear();
assert!(!render_lines(&view, /*width*/ 112).contains("enter to view hooks"));
assert!(!render_lines(&view, /*width*/ 112).contains("enter details"));
}
#[test]
fn selected_event_rows_use_the_shared_accent_style() {
fn compact_hooks_keep_the_last_event_and_selection_visible() {
let mut view = view();
view.state.selected_idx = Some(view.event_rows().len() - 1);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 16,
);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let text = (0..area.height)
.map(|y| {
(0..area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_owned()
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(text);
let selected_y = text
.lines()
.position(|line| line.starts_with("› "))
.unwrap() as u16;
assert_eq!(buf[(0, selected_y)].bg, selection_style().bg.unwrap());
assert_eq!(buf[(39, selected_y)].bg, selection_style().bg.unwrap());
assert!(text.contains("Interrupt"));
assert!(text.contains("Active/Installed"));
}
#[test]
fn selected_event_rows_use_the_shared_selection_style() {
let view = view();
let buf = render_buffer(&view, /*width*/ 112);
let expected = accent_style();
let expected = selection_style();
let selected_cell = buf
.content
@@ -1093,7 +1042,16 @@ mod tests {
"hooks_browser_events_with_review_column",
render_lines(&view, /*width*/ 112)
);
assert_eq!(view.event_table_lines()[1].spans[3].style, accent_style());
assert_eq!(
view.event_table_lines()[1].spans[3].style.fg,
selection_style().fg
);
assert!(
view.event_table_lines()[1].spans[3]
.style
.add_modifier
.contains(ratatui::style::Modifier::BOLD)
);
}
#[test]
@@ -8,11 +8,13 @@ use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap;
use crate::bottom_pane::popup_consts::picker_hint_line_for_keymap;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListAction;
@@ -25,11 +27,11 @@ use crate::style::user_message_style;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::picker_rows::measure_rows_height;
use super::picker_rows::render_rows;
use super::popup_consts::MAX_POPUP_ROWS;
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;
const MEMORIES_DOC_URL: &str = "https://developers.openai.com/codex/memories";
@@ -101,7 +103,9 @@ impl MemoriesSettingsView {
app_event_tx,
docs_link: Line::from(vec![
"Learn more: ".dim(),
MEMORIES_DOC_URL.cyan().underlined(),
MEMORIES_DOC_URL
.fg(crate::style::accent_color())
.underlined(),
]),
keymap,
};
@@ -115,20 +119,29 @@ impl MemoriesSettingsView {
fn settings_header(&self) -> ColumnRenderable<'_> {
let mut header = ColumnRenderable::new();
header.push(Line::from("Memories".bold()));
header.push(Line::from(
"Choose how Codex uses and creates memories. Changes are saved to config.toml".dim(),
));
header.push(Paragraph::new(Line::from("Memories".bold())).wrap(Wrap { trim: false }));
header.push(
Paragraph::new(Line::from(
"Choose how Codex uses and creates memories. Changes are saved to config.toml"
.dim(),
))
.wrap(Wrap { trim: false }),
);
header
}
fn reset_confirmation_header(&self) -> ColumnRenderable<'_> {
let mut header = ColumnRenderable::new();
header.push(Line::from("Reset all memories?".bold()));
header.push(Line::from(
"This clears local memory files and rollout summaries for the current Codex home."
.dim(),
));
header.push(
Paragraph::new(Line::from("Reset all memories?".bold())).wrap(Wrap { trim: false }),
);
header.push(
Paragraph::new(Line::from(
"This clears local memory files and rollout summaries for the current Codex home."
.dim(),
))
.wrap(Wrap { trim: false }),
);
header
}
@@ -154,6 +167,9 @@ impl MemoriesSettingsView {
.into_iter()
.enumerate()
.map(|(idx, name)| GenericDisplayRow {
category_tag: None,
selection_style: Some(super::picker_style::selection_style()),
wrap_indent: Some(2),
name: if state.selected_idx == Some(idx) {
format!("› {name}")
} else {
@@ -194,6 +210,13 @@ impl MemoriesSettingsView {
} => (format!("{prefix} {name}"), description),
};
GenericDisplayRow {
category_tag: None,
selection_style: Some(super::picker_style::selection_style()),
wrap_indent: Some(if matches!(item, MemoriesMenuItem::Setting { .. }) {
6
} else {
2
}),
name,
description: Some((*description).to_string()),
..Default::default()
@@ -260,10 +283,6 @@ impl MemoriesSettingsView {
}
}
fn rows_width(total_width: u16) -> u16 {
total_width.saturating_sub(2)
}
fn current_setting(&self, setting: MemoriesSetting) -> bool {
self.items
.iter()
@@ -291,7 +310,7 @@ impl MemoriesSettingsView {
fn footer_hint(&self) -> Line<'static> {
if self.reset_confirmation.is_some() {
standard_popup_hint_line_for_keymap(&self.keymap)
picker_hint_line_for_keymap(&self.keymap)
} else {
memories_settings_hint_line(&self.keymap)
}
@@ -376,8 +395,16 @@ impl Renderable for MemoriesSettingsView {
return;
}
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
let hint = self.footer_hint();
let hint_lines = super::selection_popup_common::wrap_styled_line(
&hint,
area.width.saturating_sub(/*rhs*/ 2),
);
let [content_area, footer_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(hint_lines.len() as u16),
])
.areas(area);
Block::default()
.style(user_message_style())
@@ -390,27 +417,26 @@ impl Renderable for MemoriesSettingsView {
};
let header_height = header.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_width = Self::rows_width(content_area.width);
let rows_height =
measure_rows_height(&rows, self.active_state(), MAX_POPUP_ROWS, rows_width);
let [header_area, _, list_area, _, docs_area] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
Constraint::Length(rows_height),
Constraint::Max(1),
Constraint::Length(1),
])
.areas(content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2)));
let rows_height = measure_rows_height(
&rows,
self.active_state(),
MAX_POPUP_ROWS,
content_area.width,
);
let docs_height = if self.reset_confirmation.is_some() {
0
} else {
2
};
let [body_area, docs_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(docs_height)])
.areas(content_area);
let [header_area, _, render_area] =
super::picker_rows::layout(body_area, header_height, /*search*/ 0, rows_height);
header.render(header_area, buf);
if list_area.height > 0 {
let render_area = Rect {
x: list_area.x.saturating_sub(2),
y: list_area.y,
width: rows_width.max(1),
height: list_area.height,
};
if render_area.height > 0 {
render_rows(
render_area,
buf,
@@ -421,6 +447,9 @@ impl Renderable for MemoriesSettingsView {
);
}
if self.reset_confirmation.is_none() {
let docs_area = docs_area.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 2,
));
self.docs_link.clone().render(docs_area, buf);
crate::terminal_hyperlinks::mark_url_hyperlink(buf, docs_area, MEMORIES_DOC_URL);
}
@@ -431,7 +460,7 @@ impl Renderable for MemoriesSettingsView {
width: footer_area.width.saturating_sub(2),
height: footer_area.height,
};
self.footer_hint().render(hint_area, buf);
Paragraph::new(hint_lines).dim().render(hint_area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
@@ -441,9 +470,7 @@ impl Renderable for MemoriesSettingsView {
self.settings_header()
};
let rows = self.build_rows();
let rows_width = Self::rows_width(width);
let rows_height =
measure_rows_height(&rows, self.active_state(), MAX_POPUP_ROWS, rows_width);
let rows_height = measure_rows_height(&rows, self.active_state(), MAX_POPUP_ROWS, width);
let docs_height = if self.reset_confirmation.is_some() {
0
@@ -452,18 +479,27 @@ impl Renderable for MemoriesSettingsView {
};
let mut height = header.desired_height(width.saturating_sub(4));
height = height.saturating_add(rows_height + 4 + docs_height);
height.saturating_add(1)
height.saturating_add(
super::selection_popup_common::wrap_styled_line(
&self.footer_hint(),
width.saturating_sub(/*rhs*/ 2),
)
.len() as u16,
)
}
}
fn memories_settings_hint_line(keymap: &ListKeymap) -> Line<'static> {
let mut spans = vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to toggle".into(),
];
let mut spans = vec![key_hint::plain(KeyCode::Char(' ')).into(), " toggle".into()];
if let Some(accept) = keymap.primary_hint(ListAction::Accept) {
spans.extend(["; ".into(), accept.into(), " to save or select".into()]);
spans.push(" · ".into());
spans.extend(accept.spans());
spans.push(" save/select".into());
}
if let Some(cancel) = keymap.primary_hint(ListAction::Cancel) {
spans.push(" · ".into());
spans.extend(cancel.spans());
spans.push(" cancel".into());
}
Line::from(spans)
}
@@ -14,7 +14,7 @@ fn reset_confirmation_keeps_both_choices_at_wrap_boundary() {
view.open_reset_confirmation();
let mut snapshots = Vec::new();
for width in [70, 71, 72] {
for width in [68, 69, 70, 71, 72] {
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
+2
View File
@@ -177,7 +177,9 @@ mod paste_burst;
mod pending_input_preview;
mod pending_thread_approvals;
mod picker_presets;
mod picker_rows;
mod picker_style;
pub(crate) use picker_style::selection_style;
pub(crate) mod popup_consts;
mod scroll_state;
mod selection_picker_layout;
@@ -8,11 +8,13 @@
//! - **Reordering**: Optional left/right arrow support to reorder items
//! - **Live preview**: Optional callback to show a preview of current selections
//! - **Callbacks**: Hooks for change, confirm, and cancel events
//! - **Picker appearance**: Full-width focus with overflow hints in spacer rows;
//! checkboxes remain independent of the focused row
//!
//! # Example
//!
//! ```ignore
//! let picker = MultiSelectPicker::new(
//! let picker = MultiSelectPicker::builder(
//! "Select Items".to_string(),
//! Some("Choose which items to enable".to_string()),
//! app_event_tx,
@@ -51,15 +53,21 @@ use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use super::picker_rows;
use super::picker_style;
use super::selection_popup_common::ColumnWidthConfig;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::wrap_styled_line;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS;
use crate::bottom_pane::scroll_state::ScrollState;
use crate::bottom_pane::selection_popup_common::render_rows_single_line;
use crate::bottom_pane::selection_popup_common::render_rows_single_line_with_col_width_mode;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_plain_text_key_event;
@@ -80,9 +88,6 @@ const ITEM_NAME_TRUNCATE_LEN: usize = 21;
/// Placeholder text shown in the search input when empty.
const SEARCH_PLACEHOLDER: &str = "Type to search";
/// Prefix displayed before the search query (mimics a command prompt).
const SEARCH_PROMPT_PREFIX: &str = "> ";
const SECTION_BREAK_ROW: &str = " ───────────────────────";
/// Direction for reordering items in the list.
@@ -158,7 +163,7 @@ struct BuiltRows {
/// - Press Escape to cancel and close
/// - Use Left/Right arrows to reorder items (if ordering is enabled)
///
/// Create instances using the builder pattern via [`MultiSelectPicker::new`].
/// Create instances using the builder pattern via [`MultiSelectPicker::builder`].
pub(crate) struct MultiSelectPicker {
/// All items in the picker (unfiltered).
items: Vec<MultiSelectItem>,
@@ -281,18 +286,19 @@ impl MultiSelectPicker {
MAX_POPUP_ROWS.min(len.max(1))
}
/// Calculates the width available for row content (accounts for borders).
fn rows_width(total_width: u16) -> u16 {
total_width.saturating_sub(2)
fn footer_lines(&self, width: u16) -> Vec<Line<'_>> {
wrap_styled_line(&self.footer_hint, width.saturating_sub(/*rhs*/ 2))
}
/// Calculates the height needed for the row list area.
fn rows_height(&self, rows: &BuiltRows) -> u16 {
rows.rows
let height: u16 = rows
.rows
.len()
.clamp(1, MAX_POPUP_ROWS)
.clamp(/*min*/ 1, MAX_POPUP_ROWS)
.try_into()
.unwrap_or(1)
.unwrap_or(/*default*/ 1);
height.saturating_add(/*rhs*/ 2)
}
/// Builds the display rows for all currently visible (filtered) items.
@@ -313,13 +319,16 @@ impl MultiSelectPicker {
let item_name = truncate_text(&item.name, ITEM_NAME_TRUNCATE_LEN);
let name = format!("{prefix} [{marker}] {item_name}");
rows.push(GenericDisplayRow {
category_tag: None,
name,
description: item.description.clone(),
selection_style: Some(picker_style::selection_style()),
..Default::default()
});
if item.section_break_after && visible_idx + 1 < self.filtered_indices.len() {
rows.push(GenericDisplayRow {
category_tag: None,
name: SECTION_BREAK_ROW.to_string(),
is_disabled: true,
..Default::default()
@@ -601,8 +610,7 @@ impl Renderable for MultiSelectPicker {
let mut height = self.header.desired_height(width.saturating_sub(4));
height = height.saturating_add(rows_height + 3);
height = height.saturating_add(2);
height.saturating_add(1 + preview_height)
height.saturating_add(self.footer_lines(width).len() as u16 + preview_height)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -612,7 +620,9 @@ impl Renderable for MultiSelectPicker {
// Reserve the footer line for the key-hint row.
let preview_height = if self.preview_line.is_some() { 1 } else { 0 };
let footer_height = 1 + preview_height;
let hint_lines = self.footer_lines(area.width);
let hint_height = hint_lines.len() as u16;
let footer_height = hint_height + preview_height;
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_height)]).areas(area);
@@ -624,33 +634,12 @@ impl Renderable for MultiSelectPicker {
.header
.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_width = Self::rows_width(content_area.width);
let rows_height = self.rows_height(&rows);
let [header_area, _, search_area, list_area] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
Constraint::Length(2),
Constraint::Length(rows_height),
])
.areas(content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2)));
let [header_area, search_area, list_area] =
picker_rows::layout(content_area, header_height, /*search*/ 1, rows_height);
self.header.render(header_area, buf);
// Render the search prompt as two lines to mimic the composer.
if search_area.height >= 2 {
let [placeholder_area, input_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(search_area);
Line::from(SEARCH_PLACEHOLDER.dim()).render(placeholder_area, buf);
let line = if self.search_query.is_empty() {
Line::from(vec![SEARCH_PROMPT_PREFIX.dim()])
} else {
Line::from(vec![
SEARCH_PROMPT_PREFIX.dim(),
self.search_query.clone().into(),
])
};
line.render(input_area, buf);
} else if search_area.height > 0 {
if search_area.height > 0 {
let query_span = if self.search_query.is_empty() {
SEARCH_PLACEHOLDER.dim()
} else {
@@ -660,25 +649,23 @@ impl Renderable for MultiSelectPicker {
}
if list_area.height > 0 {
let render_area = Rect {
x: list_area.x.saturating_sub(2),
y: list_area.y,
width: rows_width.max(1),
height: list_area.height,
};
render_rows_single_line(
render_area,
let body = list_area.inset(Insets::vh(u16::from(list_area.height >= 3), /*h*/ 0));
let rendered = render_rows_single_line_with_col_width_mode(
body,
buf,
&rows.rows,
&rows.state,
render_area.height as usize,
usize::from(body.height),
"no matches",
ColumnWidthConfig::default(),
);
picker_style::render_scroll_indicators(list_area, buf, rendered);
}
let hint_area = if let Some(preview_line) = &self.preview_line {
let [preview_area, hint_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(footer_area);
Layout::vertical([Constraint::Length(1), Constraint::Length(hint_height)])
.areas(footer_area);
let preview_area = Rect {
x: preview_area.x + 2,
y: preview_area.y,
@@ -699,7 +686,7 @@ impl Renderable for MultiSelectPicker {
width: hint_area.width.saturating_sub(2),
height: hint_area.height,
};
self.footer_hint.clone().dim().render(hint_area, buf);
Paragraph::new(hint_lines).dim().render(hint_area, buf);
}
}
@@ -708,7 +695,7 @@ impl Renderable for MultiSelectPicker {
/// # Example
///
/// ```ignore
/// let picker = MultiSelectPicker::new("Title".into(), None, tx)
/// let picker = MultiSelectPicker::builder("Title".into(), /*subtitle*/ None, tx)
/// .items(items)
/// .enable_ordering()
/// .on_preview(|items| Some(Line::from("Preview")))
@@ -818,39 +805,33 @@ impl MultiSelectPickerBuilder {
/// preview line if a preview callback was set.
pub fn build(self) -> MultiSelectPicker {
let mut header = ColumnRenderable::new();
header.push(Line::from(self.title.bold()));
header.push(Paragraph::new(self.title.bold()).wrap(Wrap { trim: false }));
if let Some(subtitle) = self.subtitle {
header.push(Line::from(subtitle.dim()));
header.push(Paragraph::new(subtitle.dim()).wrap(Wrap { trim: false }));
}
let instructions = if self.instructions.is_empty() {
let mut spans = vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to toggle".into(),
];
let mut spans = vec![key_hint::plain(KeyCode::Char(' ')).into(), " toggle".dim()];
if self.ordering_enabled
&& let (Some(move_left), Some(move_right)) = (
&& let (Some(left), Some(right)) = (
self.keymap.primary_hint(ListAction::MoveLeft),
self.keymap.primary_hint(ListAction::MoveRight),
)
{
spans.push("; ".into());
spans.push(move_left.into());
spans.push("/".into());
spans.push(move_right.into());
spans.push(" to move".into());
spans.push(" · ".dim());
spans.extend(key_hint::key_label_spans(&format!(
"{}/{}",
left.display_label(),
right.display_label()
)));
spans.push(" reorder".dim());
}
if let Some(accept) = self.keymap.primary_hint(ListAction::Accept) {
spans.push("; ".into());
spans.push(accept.into());
spans.push(" to confirm and close".into());
}
if let Some(cancel) = self.keymap.primary_hint(ListAction::Cancel) {
spans.push("; ".into());
spans.push(cancel.into());
spans.push(" to close".into());
for (action, label) in [(ListAction::Accept, "save"), (ListAction::Cancel, "cancel")] {
if let Some(hint) = self.keymap.primary_hint(action) {
spans.push(" · ".dim());
spans.extend(hint.spans());
spans.push(format!(" {label}").dim());
}
}
spans
} else {
@@ -1096,4 +1077,110 @@ mod tests {
picker.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL));
assert_eq!(picker.state.selected_idx, Some(0));
}
#[test]
fn short_picker_preserves_selected_row_when_overflow_spacers_yield() {
let mut picker = test_picker(
(0..12)
.map(|index| {
item(
&format!("field-{index:02}"),
/*orderable*/ true,
/*section_break_after*/ false,
)
})
.collect(),
);
picker.header = Box::new(());
picker.jump_bottom();
let mut snapshots = Vec::new();
for height in 1..=7 {
let area = Rect::new(/*x*/ 0, /*y*/ 0, /*width*/ 80, height);
let mut buf = Buffer::empty(area);
picker.render(area, &mut buf);
let text = buf
.content
.chunks(usize::from(area.width))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n");
if height >= 4 {
assert!(text.contains("› [ ] field-11"), "{text}");
}
assert_eq!(picker.state.selected_idx, Some(11));
snapshots.push(format!("height={height}\n{text}"));
}
picker.toggle_selected();
assert_eq!(
picker
.items
.iter()
.filter(|item| item.enabled)
.map(|item| item.id.as_str())
.collect::<Vec<_>>(),
["field-11"]
);
insta::assert_snapshot!(snapshots.join("\n\n"));
}
#[test]
fn picker_appearance_renders_checkboxes_preview_and_overflow() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let mut picker = MultiSelectPicker::builder(
"Status line".to_string(),
/*subtitle*/ None,
AppEventSender::new(tx),
)
.items(
(0..12)
.map(|index| MultiSelectItem {
id: format!("field-{index}"),
name: format!("Field {index}"),
description: Some("Status detail".to_string()),
enabled: index == 0,
section_break_after: index == 0,
..Default::default()
})
.collect(),
)
.enable_ordering()
.on_preview(|items| {
Some(Line::from(
items
.iter()
.filter(|item| item.enabled)
.map(|item| item.name.as_str())
.collect::<Vec<_>>()
.join(" · "),
))
})
.build();
let render = |picker: &MultiSelectPicker| {
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
/*width*/ 48,
picker.desired_height(/*width*/ 48),
);
let mut buf = Buffer::empty(area);
picker.render(area, &mut buf);
buf
};
let first = render(&picker);
picker.jump_bottom();
picker.toggle_selected();
picker.move_selected_item(Direction::Up);
let last = render(&picker);
picker.handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
let empty = render(&picker);
insta::assert_snapshot!(format!(
"First page:\n{first:?}\nLast page:\n{last:?}\nNo matches:\n{empty:?}"
));
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Shared row presentation for dedicated picker workflows.
//!
//! These adapters keep domain-specific state and save policies with their views.
//! Measurement and painting share columns, wrapping, and overflow spacer rows.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use super::picker_style;
use super::scroll_state::ScrollState;
use super::selection_picker_layout::PickerLayoutSizes;
use super::selection_picker_layout::picker_areas;
use super::selection_popup_common as rows;
use super::selection_popup_common::ColumnWidthConfig;
use super::selection_popup_common::ColumnWidthMode;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_row_layout::SelectionDescriptionLayout;
use crate::render::Insets;
use crate::render::RectExt;
const COLUMNS: ColumnWidthConfig = ColumnWidthConfig {
mode: ColumnWidthMode::AutoAllRows,
name_column_width: None,
description_layout: SelectionDescriptionLayout::HideWhenNarrow {
min_description_width: 24,
},
};
pub(super) fn measure_rows_height(
rows: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
width: u16,
) -> u16 {
rows::measure_rows_height_with_col_width_mode(rows, state, max_results, width, COLUMNS)
.saturating_add(/*rhs*/ 2)
}
pub(super) fn render_rows(
area: Rect,
buf: &mut Buffer,
rows: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
empty_message: &str,
) -> u16 {
let body = area.inset(Insets::vh(u16::from(area.height >= 3), /*h*/ 0));
let rendered = rows::render_rows_with_col_width_mode(
body,
buf,
rows,
state,
max_results,
empty_message,
COLUMNS,
);
picker_style::render_scroll_indicators(area, buf, rendered);
rendered.lines.saturating_add(/*rhs*/ 2).min(area.height)
}
pub(super) fn render_rows_single_line(
area: Rect,
buf: &mut Buffer,
rows: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
empty_message: &str,
) -> u16 {
let body = area.inset(Insets::vh(u16::from(area.height >= 3), /*h*/ 0));
let rendered = rows::render_rows_single_line_with_col_width_mode(
body,
buf,
rows,
state,
max_results,
empty_message,
COLUMNS,
);
picker_style::render_scroll_indicators(area, buf, rendered);
rendered.lines.saturating_add(/*rhs*/ 2).min(area.height)
}
/// Keep headers and search above a list, yielding optional gaps on short screens.
pub(super) fn layout(area: Rect, header: u16, search: u16, rows: u16) -> [Rect; 3] {
let [header, _, _, _, search, above, rows, below, _, _] = picker_areas(
area.inset(Insets::tlbr(
/*top*/ 1, /*left*/ 2, /*bottom*/ 0, /*right*/ 2,
)),
PickerLayoutSizes {
header,
header_gap: 1,
tabs: 0,
search,
rows: rows.saturating_sub(/*rhs*/ 2),
side: 0,
},
);
[
header,
search,
Rect::new(
area.x,
above.y,
area.width,
above.height + rows.height + below.height,
),
]
}
@@ -9,7 +9,9 @@ use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -19,8 +21,6 @@ use crate::key_hint::ShortcutHint;
use crate::key_hint::is_plain_text_key_event;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::skills_helpers::match_skill;
@@ -28,13 +28,12 @@ use crate::style::user_message_style;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::picker_rows::render_rows_single_line;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows_single_line;
const SEARCH_PLACEHOLDER: &str = "Type to search skills";
const SEARCH_PROMPT_PREFIX: &str = "> ";
pub(crate) struct SkillsToggleItem {
pub name: String,
@@ -63,10 +62,15 @@ impl SkillsToggleView {
keymap: ListKeymap,
) -> Self {
let mut header = ColumnRenderable::new();
header.push(Line::from("Enable/Disable Skills".bold()));
header.push(Line::from(
"Turn skills on or off. Your changes are saved automatically.".dim(),
));
header.push(
Paragraph::new(Line::from("Enable/Disable Skills".bold())).wrap(Wrap { trim: false }),
);
header.push(
Paragraph::new(Line::from(
"Turn skills on or off. Your changes are saved automatically.".dim(),
))
.wrap(Wrap { trim: false }),
);
let mut view = Self {
items,
@@ -148,6 +152,8 @@ impl SkillsToggleView {
let item_name = &item.name;
let name = format!("{prefix} [{marker}] {item_name}");
GenericDisplayRow {
category_tag: None,
selection_style: Some(super::picker_style::selection_style()),
name,
description: Some(item.description.clone()),
..Default::default()
@@ -223,12 +229,8 @@ impl SkillsToggleView {
.list_skills(Vec::new(), /*force_reload*/ true);
}
fn rows_width(total_width: u16) -> u16 {
total_width.saturating_sub(2)
}
fn rows_height(&self, rows: &[GenericDisplayRow]) -> u16 {
rows.len().clamp(1, MAX_POPUP_ROWS).try_into().unwrap_or(1)
(rows.len().clamp(/*min*/ 1, MAX_POPUP_ROWS) as u16).saturating_add(/*rhs*/ 2)
}
}
@@ -309,7 +311,13 @@ impl Renderable for SkillsToggleView {
let mut height = self.header.desired_height(width.saturating_sub(4));
height = height.saturating_add(rows_height + 3);
height = height.saturating_add(2);
height.saturating_add(1)
height.saturating_add(
super::selection_popup_common::wrap_styled_line(
&self.footer_hint,
width.saturating_sub(/*rhs*/ 2),
)
.len() as u16,
)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -317,9 +325,15 @@ impl Renderable for SkillsToggleView {
return;
}
// Reserve the footer line for the key-hint row.
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
let hint_lines = super::selection_popup_common::wrap_styled_line(
&self.footer_hint,
area.width.saturating_sub(/*rhs*/ 2),
);
let [content_area, footer_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(hint_lines.len() as u16),
])
.areas(area);
Block::default()
.style(user_message_style())
@@ -329,33 +343,13 @@ impl Renderable for SkillsToggleView {
.header
.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_width = Self::rows_width(content_area.width);
let rows_height = self.rows_height(&rows);
let [header_area, _, search_area, list_area] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
Constraint::Length(2),
Constraint::Length(rows_height),
])
.areas(content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2)));
let [header_area, search_area, render_area] =
super::picker_rows::layout(content_area, header_height, /*search*/ 1, rows_height);
self.header.render(header_area, buf);
// Render the search prompt as two lines to mimic the composer.
if search_area.height >= 2 {
let [placeholder_area, input_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(search_area);
Line::from(SEARCH_PLACEHOLDER.dim()).render(placeholder_area, buf);
let line = if self.search_query.is_empty() {
Line::from(vec![SEARCH_PROMPT_PREFIX.dim()])
} else {
Line::from(vec![
SEARCH_PROMPT_PREFIX.dim(),
self.search_query.clone().into(),
])
};
line.render(input_area, buf);
} else if search_area.height > 0 {
if search_area.height > 0 {
let query_span = if self.search_query.is_empty() {
SEARCH_PLACEHOLDER.dim()
} else {
@@ -364,13 +358,7 @@ impl Renderable for SkillsToggleView {
Line::from(query_span).render(search_area, buf);
}
if list_area.height > 0 {
let render_area = Rect {
x: list_area.x.saturating_sub(2),
y: list_area.y,
width: rows_width.max(1),
height: list_area.height,
};
if render_area.height > 0 {
render_rows_single_line(
render_area,
buf,
@@ -387,7 +375,7 @@ impl Renderable for SkillsToggleView {
width: footer_area.width.saturating_sub(2),
height: footer_area.height,
};
self.footer_hint.clone().dim().render(hint_area, buf);
Paragraph::new(hint_lines).dim().render(hint_area, buf);
}
}
@@ -398,32 +386,19 @@ fn skills_toggle_hint_line(keymap: &ListKeymap) -> Line<'static> {
.filter(|binding| *binding != ShortcutHint::Single(space));
let cancel = keymap.primary_hint(ListAction::Cancel);
match (accept, cancel) {
(Some(accept), Some(cancel)) => Line::from(vec![
"Press ".into(),
space.into(),
" or ".into(),
accept.into(),
" to toggle; ".into(),
cancel.into(),
" to close".into(),
]),
(Some(accept), None) => Line::from(vec![
"Press ".into(),
space.into(),
" or ".into(),
accept.into(),
" to toggle".into(),
]),
(None, Some(cancel)) => Line::from(vec![
"Press ".into(),
space.into(),
" to toggle; ".into(),
cancel.into(),
" to close".into(),
]),
(None, None) => Line::from(vec!["Press ".into(), space.into(), " to toggle".into()]),
let mut toggle = space.display_label();
if let Some(accept) = accept {
toggle.push('/');
toggle.push_str(&accept.display_label());
}
let mut spans = key_hint::key_label_spans(&toggle);
spans.push(" toggle".dim());
if let Some(cancel) = cancel {
spans.push(" · ".dim());
spans.extend(cancel.spans());
spans.push(" close".dim());
}
spans.into()
}
#[cfg(test)]
@@ -13,6 +13,6 @@ expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72))
Sign in to this app in your browser, then return here.
› 1. Open sign-in URL
2. Back
Use tab / ↑ ↓ to move, enter to select, esc to close
› 1. Open sign-in URL
2. Back
tab / ↑ ↓ move · enter select · esc close
@@ -14,7 +14,7 @@ expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72))
Newly installed apps can take a few minutes to appear in /apps.
› 1. Manage on ChatGPT
2. Enable app
3. Back
Use tab / ↑ ↓ to move, enter to select, esc to close
› 1. Manage on ChatGPT
2. Enable app
3. Back
tab / ↑ ↓ move · enter select · esc close
@@ -14,6 +14,6 @@ expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72))
Complete the requested action in your browser, then return here.
› 1. Open link
2. Back
Use tab / ↑ ↓ to move, enter to select, esc to close
› 1. Open link
2. Back
tab / ↑ ↓ move · enter select · esc close
@@ -12,6 +12,6 @@ expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72))
Link:
https://payments.example/checkout/123
› 1. I finished
2. Back
Use tab / ↑ ↓ to move, enter to select, esc to close
› 1. I finished
2. Back
tab / ↑ ↓ move · enter select · esc close
@@ -13,6 +13,6 @@ expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72))
After installed, use $ to insert this app into the prompt.
› 1. Install on ChatGPT
2. Back
Use tab / ↑ ↓ to move, enter to select, esc to close
› 1. Install on ChatGPT
2. Back
tab / ↑ ↓ move · enter select · esc close
@@ -2,13 +2,16 @@
source: tui/src/bottom_pane/experimental_features_view_tests.rs
expression: buffer_text(&buffer)
---
Experimental features
Checked features are configured on. Some experimental features take effect
only in new tasks or after restarting the Codex server.
› [ ] Automatically start the background server Use the shared local server
for new, resumed, and forked
› [ ] Automatically start the background server Use the shared local server for
new, resumed, and forked
sessions. Takes effect next
launch.
Press space to select or enter to save
space toggle · enter save · esc save/close
@@ -7,7 +7,9 @@ expression: buffer_text(&buffer)
Checked features are configured on. Some experimental features take effect
only in new tasks or after restarting the Codex server.
› [ ] Analytics plan history Preview five-hour and weekly allowance history
for consumer accounts in /analytics.
Press space to select or enter to save
› [ ] Analytics plan history Preview five-hour and weekly allowance history for
consumer accounts in /analytics.
space toggle · enter save · esc save/close
@@ -7,6 +7,8 @@ expression: buffer_text(&buffer)
Checked features are configured on. Some experimental features take effect
only in new tasks or after restarting the Codex server.
› [x] Server network_proxy Description supplied by the server.
Press space to select or enter to save
space toggle · enter save · esc save/close
@@ -8,6 +8,8 @@ expression: buffer_text(&buffer)
only in new tasks or after restarting the Codex server.
No server experiments available.
No experimental features available for now
Press space to select or enter to save
space toggle · enter save · esc save/close
@@ -2,6 +2,36 @@
source: tui/src/bottom_pane/experimental_features_view_tests.rs
expression: "snapshots.join(\"\\n\\n\")"
---
width=68
Experimental features
Checked features are configured on. Some experimental features
take effect only in new tasks or after restarting the Codex
server.
› [x] First feature A description that fits exactly at this
boundary.
[ ] Last feature Keep this choice visible.
space toggle · enter save · esc save/close
width=69
Experimental features
Checked features are configured on. Some experimental features
take effect only in new tasks or after restarting the Codex
server.
› [x] First feature A description that fits exactly at this
boundary.
[ ] Last feature Keep this choice visible.
space toggle · enter save · esc save/close
width=70
Experimental features
@@ -9,11 +39,12 @@ width=70
take effect only in new tasks or after restarting the Codex
server.
› [x] First feature A description that fits exactly at this
boundary.
› [x] First feature A description that fits exactly at this boundary.
[ ] Last feature Keep this choice visible.
Press space to select or enter to save
space toggle · enter save · esc save/close
width=71
@@ -21,11 +52,12 @@ width=71
Checked features are configured on. Some experimental features take
effect only in new tasks or after restarting the Codex server.
› [x] First feature A description that fits exactly at this
boundary.
› [x] First feature A description that fits exactly at this boundary.
[ ] Last feature Keep this choice visible.
Press space to select or enter to save
space toggle · enter save · esc save/close
width=72
@@ -33,7 +65,9 @@ width=72
Checked features are configured on. Some experimental features take
effect only in new tasks or after restarting the Codex server.
› [x] First feature A description that fits exactly at this boundary.
[ ] Last feature Keep this choice visible.
Press space to select or enter to save
space toggle · enter save · esc save/close
@@ -8,8 +8,10 @@ expression: buffer_text(&buffer)
only in new tasks or after restarting the Codex server.
Saved but readback failed
› [ ] Server network_proxy Description supplied by the server.
[ ] Server future_feature Description supplied by the server.
[ ] Server prevent_idle_sleep Description supplied by the server.
Selections retained. Save to retry, or cancel to close.
@@ -8,8 +8,10 @@ expression: buffer_text(&buffer)
only in new tasks or after restarting the Codex server.
Saving experimental features…
› [ ] Server network_proxy Description supplied by the server.
[ ] Server future_feature Description supplied by the server.
[ ] Server prevent_idle_sleep Description supplied by the server.
Saving… Closing this popup will not cancel the write.
@@ -7,8 +7,10 @@ expression: buffer_text(&buffer)
Checked features are configured on. Some experimental features take effect only in new tasks or
after restarting the Codex server.
› [x] Server network_proxy Description supplied by the server.
[x] Server future_feature Description supplied by the server.
[x] Server prevent_idle_sleep Description supplied by the server.
Press space to select or enter to save
space toggle · enter save · esc save/close
@@ -9,6 +9,8 @@ expression: buffer_text(&buffer)
Server experiments unavailable. Reopen /experimental to retry; restart this
Codex client if requests remain unanswered.
No experimental features available for now
Press space to select or enter to save
space toggle · enter save · esc save/close
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/experimental_features_view_tests.rs
assertion_line: 208
expression: buffer_text(&buffer)
---
@@ -8,6 +7,8 @@ expression: buffer_text(&buffer)
Checked features are configured on. Some experimental features take effect
only in new tasks or after restarting the Codex server.
› [x] Server realtime_conversation Description supplied by the server.
Press space to select or enter to save
space toggle · enter save · esc save/close
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/experimental_features_view_tests.rs
assertion_line: 208
expression: buffer_text(&buffer)
---
@@ -9,6 +8,8 @@ expression: buffer_text(&buffer)
only in new tasks or after restarting the Codex server.
No server experiments available.
No experimental features available for now
Press space to select or enter to save
space toggle · enter save · esc save/close
@@ -0,0 +1,20 @@
---
source: tui/src/bottom_pane/hooks_browser_view.rs
expression: text
---
Hooks
Lifecycle hooks from config and
enabled plugins.
Event Active/Installed
↑
SessionEnd 0/0
UserPromptSubmit 0/0
SubagentStart 0/0
SubagentStop 0/0
Stop 0/0
› Interrupt 0/0
enter details · esc close
@@ -6,7 +6,7 @@ expression: "render_lines(&view, 112)"
PreToolUse hooks
Turn hooks on or off. Your changes are saved automatically.
[x] Hook 1
› [x] Hook 1
Event PreToolUse
Matcher Bash
@@ -17,4 +17,5 @@ expression: "render_lines(&view, 112)"
Context unlimited
Trust Trusted
Press space or enter to toggle; esc to go back
space/enter toggle · esc back
@@ -4,9 +4,10 @@ expression: "render_lines(&view, 44)"
---
PreToolUse hooks
Turn hooks on or off. Your changes are s
Turn hooks on or off. Your changes are
saved automatically.
[x] Hook 1
› [x] Hook 1
Event PreToolUse
Matcher Bash
@@ -18,4 +19,5 @@ expression: "render_lines(&view, 44)"
Timeout 30s
Trust Trusted
Press space or enter to toggle; esc to go
space/enter toggle · esc back
@@ -5,7 +5,8 @@ expression: "render_lines(&view, 112)"
PermissionRequest hooks
Turn hooks on or off. Your changes are saved automatically.
No hooks installed for this event.
Press esc to go back
esc back
@@ -7,7 +7,8 @@ expression: "render_lines(&view, 112)"
Lifecycle hooks from config and enabled plugins.
Event Installed Active Description
PreToolUse 2 1 Before a tool executes
› PreToolUse 2 1 Before a tool executes
PermissionRequest 1 1 When permission is requested
PostToolUse 0 0 After a tool executes
PreCompact 0 0 Before context compaction
@@ -15,9 +16,6 @@ expression: "render_lines(&view, 112)"
SessionStart 0 0 When a new session starts
SessionEnd 0 0 Right before a session ends
UserPromptSubmit 0 0 When the user submits a prompt
SubagentStart 0 0 When a subagent is created
SubagentStop 0 0 Right before a subagent ends its turn
Stop 0 0 Right before Codex ends its turn
Interrupt 0 0 Right before an interrupted turn is aborted
↓
Press enter to view hooks; esc to close
enter details · esc close
@@ -11,7 +11,8 @@ expression: "render_lines(&view, 112)"
■ /tmp/hooks.json: failed to parse hooks config
Event Installed Active Description
PreToolUse 0 0 Before a tool executes
› PreToolUse 0 0 Before a tool executes
PermissionRequest 0 0 When permission is requested
PostToolUse 0 0 After a tool executes
PreCompact 0 0 Before context compaction
@@ -19,9 +20,6 @@ expression: "render_lines(&view, 112)"
SessionStart 0 0 When a new session starts
SessionEnd 0 0 Right before a session ends
UserPromptSubmit 0 0 When the user submits a prompt
SubagentStart 0 0 When a subagent is created
SubagentStop 0 0 Right before a subagent ends its turn
Stop 0 0 Right before Codex ends its turn
Interrupt 0 0 Right before an interrupted turn is aborted
↓
Press enter to view hooks; esc to close
enter details · esc close
@@ -9,7 +9,8 @@ expression: "render_lines(&view, 112)"
⚠ 1 hook needs review before it can run.
Event Installed Active Review Description
PreToolUse 1 0 1 Before a tool executes
› PreToolUse 1 0 1 Before a tool executes
PermissionRequest 0 0 0 When permission is requested
PostToolUse 0 0 0 After a tool executes
PreCompact 0 0 0 Before context compaction
@@ -17,9 +18,6 @@ expression: "render_lines(&view, 112)"
SessionStart 0 0 0 When a new session starts
SessionEnd 0 0 0 Right before a session ends
UserPromptSubmit 0 0 0 When the user submits a prompt
SubagentStart 0 0 0 When a subagent is created
SubagentStop 0 0 0 Right before a subagent ends its turn
Stop 0 0 0 Right before Codex ends its turn
Interrupt 0 0 0 Right before an interrupted turn is aborted
↓
Press t to trust all; enter to review hooks; esc to close
t trust all · enter review · esc close
@@ -6,7 +6,7 @@ expression: "render_lines(&view, 112)"
PreToolUse hooks
Turn hooks on or off. Your changes are saved automatically.
[x] Hook 1
› [x] Hook 1
[ ] Hook 2
Event PreToolUse
@@ -17,4 +17,5 @@ expression: "render_lines(&view, 112)"
Timeout 30s
Trust Trusted
Press space or enter to toggle; esc to go back
space/enter toggle · esc back
@@ -6,7 +6,7 @@ expression: "render_lines(&view, 112)"
PermissionRequest hooks
Turn hooks on or off. Your changes are saved automatically.
[x] Hook 1
› [x] Hook 1
Event PermissionRequest
Matcher Bash
@@ -16,4 +16,5 @@ expression: "render_lines(&view, 112)"
Timeout 30s
Trust Managed
Managed hooks are always on; press esc to go back
Managed (always on) · esc back
@@ -6,7 +6,7 @@ expression: "render_lines(&view, 112)"
PreToolUse hooks
Turn hooks on or off. Your changes are saved automatically.
[x] Hook 1
› [x] Hook 1
Event PreToolUse
Matcher Bash
@@ -16,4 +16,5 @@ expression: "render_lines(&view, 112)"
Timeout 30s
Trust Trusted
Press space or enter to toggle; esc to go back
space/enter toggle · esc back
@@ -6,7 +6,7 @@ expression: "render_lines(&view, 112)"
PreToolUse hooks
1 hook needs review before it can run.
[!] Hook 1 · new
› [!] Hook 1 · new
Event PreToolUse
Matcher Bash
@@ -16,4 +16,5 @@ expression: "render_lines(&view, 112)"
Timeout 30s
Trust New hook - review required
Press t to trust; esc to go back
t trust · esc back
@@ -5,7 +5,7 @@ expression: "render_lines(&view, 112)"
PreToolUse hooks
Turn hooks on or off. Your changes are saved automatically.
↑
[x] Hook 2
[x] Hook 3
[x] Hook 4
@@ -13,7 +13,7 @@ expression: "render_lines(&view, 112)"
[x] Hook 6
[x] Hook 7
[x] Hook 8
[x] Hook 9
› [x] Hook 9
Event PreToolUse
Matcher Bash
@@ -23,4 +23,5 @@ expression: "render_lines(&view, 112)"
Timeout 30s
Trust Trusted
Press space or enter to toggle; esc to go back
space/enter toggle · esc back
@@ -7,7 +7,7 @@ expression: "render_lines(&view, 112)"
Turn hooks on or off. Your changes are saved automatically.
[x] Hook 1
[x] Hook 2
› [x] Hook 2
Event PreToolUse
Matcher Bash
@@ -17,4 +17,5 @@ expression: "render_lines(&view, 112)"
Timeout 30s
Trust Managed
Managed hooks are always on; press esc to go back
Managed (always on) · esc back
@@ -6,7 +6,7 @@ expression: "render_lines(&view, 112)"
PreToolUse hooks
1 hook needs review before it can run.
[!] Hook 1 · new
› [!] Hook 1 · new
Event PreToolUse
Matcher Bash
@@ -16,4 +16,5 @@ expression: "render_lines(&view, 112)"
Timeout 30s
Trust New hook - review required
Press t to trust; esc to go back
t trust · esc back
@@ -2,37 +2,74 @@
source: tui/src/bottom_pane/memories_settings_view_tests.rs
expression: "snapshots.join(\"\\n\\n\")"
---
width=68
Reset all memories?
This clears local memory files and rollout summaries for the
current Codex home.
› Reset all memories Delete local memory files and rollout
summaries.
Go back Return to memory settings.
enter select · esc back
width=69
Reset all memories?
This clears local memory files and rollout summaries for the
current Codex home.
› Reset all memories Delete local memory files and rollout
summaries.
Go back Return to memory settings.
enter select · esc back
width=70
Reset all memories?
This clears local memory files and rollout summaries for the
current Codex home.
› Reset all memories Delete local memory files and rollout
summaries.
Go back Return to memory settings.
Press enter to confirm or esc to go back
width=71
Reset all memories?
› Reset all memories Delete local memory files and rollout
summaries.
Go back Return to memory settings.
Press enter to confirm or esc to go back
width=72
Reset all memories?
› Reset all memories Delete local memory files and rollout summaries.
Go back Return to memory settings.
Press enter to confirm or esc to go back
enter select · esc back
width=71
Reset all memories?
This clears local memory files and rollout summaries for the
current Codex home.
› Reset all memories Delete local memory files and rollout summaries.
Go back Return to memory settings.
enter select · esc back
width=72
Reset all memories?
This clears local memory files and rollout summaries for the current
Codex home.
› Reset all memories Delete local memory files and rollout summaries.
Go back Return to memory settings.
enter select · esc back
@@ -0,0 +1,152 @@
---
source: tui/src/bottom_pane/multi_select_picker.rs
expression: "format!(\"First page:\\n{first:?}\\nLast page:\\n{last:?}\\nNo matches:\\n{empty:?}\")"
---
First page:
Buffer {
area: Rect { x: 0, y: 0, width: 48, height: 17 },
content: [
" ",
" Status line ",
" ",
" Type to search ",
" ",
"› [x] Field 0 Status detail ",
" ─────────────────────── ",
" [ ] Field 1 Status detail ",
" [ ] Field 2 Status detail ",
" [ ] Field 3 Status detail ",
" [ ] Field 4 Status detail ",
" [ ] Field 5 Status detail ",
" [ ] Field 6 Status detail ",
"↓ ",
" Field 0 ",
" space toggle · ←/→ reorder · enter save · esc ",
" cancel ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 16, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 27, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 40, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 25, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 27, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 40, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 27, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 40, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 27, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 40, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 27, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 40, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 27, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 40, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 27, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 40, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 17, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 20, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 31, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 36, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 44, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 47, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 0, y: 16, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 16, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}
Last page:
Buffer {
area: Rect { x: 0, y: 0, width: 48, height: 17 },
content: [
" ",
" Status line ",
" ",
" Type to search ",
"↑ ",
" [ ] Field 4 Status detail ",
" [ ] Field 5 Status detail ",
" [ ] Field 6 Status detail ",
" [ ] Field 7 Status detail ",
" [ ] Field 8 Status detail ",
" [ ] Field 9 Status detail ",
"› [x] Field 11 Status detail ",
" [ ] Field 10 Status detail ",
" ",
" Field 0 · Field 11 ",
" space toggle · ←/→ reorder · enter save · esc ",
" cancel ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 16, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 16, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 29, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 16, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 16, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 29, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 16, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 29, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 16, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 29, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 16, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 29, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 16, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 29, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 0, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 16, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 29, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 17, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 20, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 31, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 36, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 44, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 47, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 0, y: 16, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 16, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}
No matches:
Buffer {
area: Rect { x: 0, y: 0, width: 48, height: 10 },
content: [
" ",
" Status line ",
" ",
" z ",
" ",
"no matches ",
" ",
" Field 0 · Field 11 ",
" space toggle · ←/→ reorder · enter save · esc ",
" cancel ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 10, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 17, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 20, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 31, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 36, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 44, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 47, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}
@@ -0,0 +1,45 @@
---
source: tui/src/bottom_pane/multi_select_picker.rs
expression: "snapshots.join(\"\\n\\n\")"
---
height=1
space toggle · ←/→ reorder · enter save · esc cancel
height=2
space toggle · ←/→ reorder · enter save · esc cancel
height=3
Type to search
space toggle · ←/→ reorder · enter save · esc cancel
height=4
Type to search
› [ ] field-11
space toggle · ←/→ reorder · enter save · esc cancel
height=5
Type to search
[ ] field-10
› [ ] field-11
space toggle · ←/→ reorder · enter save · esc cancel
height=6
Type to search
↑
› [ ] field-11
space toggle · ←/→ reorder · enter save · esc cancel
height=7
Type to search
↑
[ ] field-10
› [ ] field-11
space toggle · ←/→ reorder · enter save · esc cancel
@@ -7,8 +7,10 @@ expression: "render_lines(&view, 72)"
Turn skills on or off. Your changes are saved automatically.
Type to search skills
>
› [x] Repo Scout Summarize the repo layout
[ ] Changelog Writer Draft release notes
Press space or enter to toggle; esc to close
space/enter toggle · esc close
@@ -1,14 +1,18 @@
---
source: tui/src/bottom_pane/skills_toggle_view.rs
assertion_line: 569
expression: "render_lines(&view, 48)"
---
Enable/Disable Skills
Turn skills on or off. Your changes are save
Turn skills on or off. Your changes are
saved automatically.
Type to search skills
>
› [x] superpowers-systematic-d… Find root cau…
[ ] superpowers-verification… Verify comple…
Press space or enter to toggle; esc to close
› [x] superpowers-systematic-debugging (polish)
[ ] superpowers-verification-before-completio…
space/enter toggle · esc close
@@ -7,8 +7,10 @@ expression: "render_lines(&view, 96)"
Turn skills on or off. Your changes are saved automatically.
Type to search skills
>
› [x] superpowers-systematic-debugging (polish) Find root causes before fixing bugs
[ ] superpowers-verification-before-completion (polish) Verify completion before claiming …
Press space or enter to toggle; esc to close
› [x] superpowers-systematic-debugging (polish) Find root causes before fixing bugs
[ ] superpowers-verification-before-completion (polish) Verify completion before claiming su…
space/enter toggle · esc close
@@ -7,7 +7,7 @@ expression: "render_lines(&view,\n100).lines().map(str::trim_end).collect::<Vec<
Select which items to display in the status line.
Type to search
>
› [x] Use theme colors Apply colors from the active /theme
───────────────────────
[x] hostname Current machine hostname (omitted when unavailable)
@@ -16,6 +16,6 @@ expression: "render_lines(&view,\n100).lines().map(str::trim_end).collect::<Vec<
[ ] model-with-reasoning Current model name with reasoning level
[ ] reasoning Current reasoning level
[ ] project-name Project name (omitted when unavailable)
↓
ssh-build-01.example.com · ~/codex-rs
Press space to toggle; ←/→ to move; enter to confirm and close; esc to close
space toggle · ←/→ reorder · enter save · esc cancel
@@ -7,15 +7,15 @@ expression: "render_lines(&view, 100)"
Select which items to display in the status line.
Type to search
>
› [x] Use theme colors Apply colors from the active /theme
───────────────────────
[x] thread-credits Estimated current-thread credits (Enterprise workspaces only; omitte…
[x] estimated-thread-cost Estimated current-thread cost in USD (Enterprise workspaces only; om…
[x] thread-credits Estimated current-thread credits (Enterprise workspaces only; omitted …
[x] estimated-thread-cost Estimated current-thread cost in USD (Enterprise workspaces only; omit…
[ ] model Current model name
[ ] model-with-reasoning Current model name with reasoning level
[ ] reasoning Current reasoning level
[ ] current-dir Current working directory
↓
5.2 credits · ~$1.82
Press space to toggle; ←/→ to move; enter to confirm and close; esc to close
space toggle · ←/→ reorder · enter save · esc cancel
@@ -7,15 +7,15 @@ expression: "render_lines(&view, 72)"
Select which items to display in the status line.
Type to search
>
› [x] Use theme colors Apply colors from the active /theme
───────────────────────
[x] model Current model name
[x] current-dir Current working directory
[x] git-branch Current Git branch (omitted when unavaila…
[x] git-branch Current Git branch (omitted when unavailabl…
[ ] model-with-reasoning Current model name with reasoning level
[ ] reasoning Current reasoning level
[ ] project-name Project name (omitted when unavailable)
↓
gpt-5-codex · ~/codex-rs · jif/statusline-preview
Press space to toggle; ←/→ to move; enter to confirm and close; esc to
space toggle · ←/→ reorder · enter save · esc cancel
@@ -7,7 +7,7 @@ expression: "render_lines(&view, 84)"
Select which items to display in the terminal title.
Type to search
>
› [x] project-name Project name (falls back to current directory name)
[x] activity Spinner while working, action-required message while blocked.
[x] run-state Compact session run-state text (Ready, Working, Thinking)
@@ -16,6 +16,6 @@ expression: "render_lines(&view, 84)"
[ ] current-dir Current working directory
[ ] thread-name Current thread name (omitted when unnamed)
[ ] git-branch Current Git branch (omitted when unavailable)
↓
[ ! ] Action Required | my-project | Working | thread title
Press space to toggle; ←/→ to move; enter to confirm and close; esc to close
space toggle · ←/→ reorder · enter save · esc cancel
-1
View File
@@ -305,7 +305,6 @@ use crate::key_hint::KeyBindingListExt;
use crate::keymap::ChatKeymap;
use crate::keymap::RuntimeKeymap;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt;
+16 -23
View File
@@ -213,32 +213,27 @@ impl ChatWidget {
}
fn connectors_loading_popup_params(&self) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Apps".bold()));
header.push(Line::from("Loading installed and available apps...".dim()));
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(CONNECTORS_SELECTION_VIEW_ID),
header: Box::new(header),
title: Some("Apps".to_string()),
subtitle: Some("Loading installed and available apps...".to_string()),
items: vec![SelectionItem {
name: "Loading apps...".to_string(),
description: Some("This updates when the full list is ready.".to_string()),
is_disabled: true,
..Default::default()
}],
..Default::default()
..SelectionViewParams::picker()
}
}
fn connectors_error_popup_params(&self) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Apps".bold()));
header.push(Line::from("Failed to load apps.".dim()));
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(CONNECTORS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(self.bottom_pane.standard_popup_hint_line()),
title: Some("Apps".to_string()),
subtitle: Some("Failed to load apps.".to_string()),
items: vec![
SelectionItem {
name: "App directory unavailable".to_string(),
@@ -260,7 +255,7 @@ impl ChatWidget {
..Default::default()
},
],
..Default::default()
..SelectionViewParams::picker()
}
}
@@ -274,14 +269,12 @@ impl ChatWidget {
.iter()
.filter(|connector| connector.is_accessible)
.count();
let mut header = ColumnRenderable::new();
header.push(Line::from("Apps".bold()));
header.push(Line::from(
"Use $ to insert an installed app into your prompt.".dim(),
));
header.push(Line::from(
format!("Installed {installed} of {total} available apps.").dim(),
));
let header = Paragraph::new(vec![
Line::from("Apps".bold()),
Line::from("Use $ to insert an installed app into your prompt.".dim()),
Line::from(format!("Installed {installed} of {total} available apps.").dim()),
])
.wrap(Wrap { trim: false });
let initial_selected_idx = selected_connector_id.and_then(|selected_connector_id| {
connectors
.iter()
@@ -351,15 +344,15 @@ impl ChatWidget {
}
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(CONNECTORS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(self.bottom_pane.standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search apps".to_string()),
col_width_mode: ColumnWidthMode::AutoAllRows,
initial_selected_idx,
..Default::default()
..SelectionViewParams::picker()
}
}
+78 -81
View File
@@ -340,6 +340,7 @@ impl Renderable for PluginDisclosureLine {
impl ChatWidget {
pub(super) fn plugins_loading_popup_params(&self) -> SelectionViewParams {
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(DelayedLoadingHeader::new(
self.frame_requester.clone(),
@@ -353,12 +354,13 @@ impl ChatWidget {
is_disabled: true,
..Default::default()
}],
..Default::default()
..SelectionViewParams::picker()
}
}
pub(super) fn marketplace_add_loading_popup_params(&self) -> SelectionViewParams {
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(DelayedLoadingHeader::new(
self.frame_requester.clone(),
@@ -374,7 +376,7 @@ impl ChatWidget {
is_disabled: true,
..Default::default()
}],
..Default::default()
..SelectionViewParams::picker()
}
}
@@ -384,14 +386,12 @@ impl ChatWidget {
marketplace_name: String,
marketplace_display_name: String,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("Remove {marketplace_display_name} marketplace?").dim(),
));
header.push(Line::from(
"This removes the configured marketplace from Codex.".dim(),
));
let header = Paragraph::new(vec![
Line::from("Plugins".bold()),
Line::from(format!("Remove {marketplace_display_name} marketplace?").dim()),
Line::from("This removes the configured marketplace from Codex.".dim()),
])
.wrap(Wrap { trim: false });
let cwd_for_remove = self.config.cwd.to_path_buf();
let cwd_for_cancel = self.config.cwd.to_path_buf();
@@ -400,6 +400,7 @@ impl ChatWidget {
let plugins_response_for_on_cancel = plugins_response.clone();
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(Line::from(vec![
@@ -449,7 +450,7 @@ impl ChatWidget {
response: plugins_response_for_on_cancel.clone(),
});
})),
..Default::default()
..SelectionViewParams::picker()
}
}
@@ -457,22 +458,18 @@ impl ChatWidget {
&self,
marketplace_display_name: &str,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("Removing {marketplace_display_name}...").dim(),
));
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
title: Some("Plugins".to_string()),
subtitle: Some(format!("Removing {marketplace_display_name}...")),
items: vec![SelectionItem {
name: "Removing marketplace...".to_string(),
description: Some("This updates when marketplace removal completes.".to_string()),
is_disabled: true,
..Default::default()
}],
..Default::default()
..SelectionViewParams::picker()
}
}
@@ -484,6 +481,7 @@ impl ChatWidget {
.map(|name| format!("Upgrading {name} marketplace..."))
.unwrap_or_else(|| "Upgrading marketplaces...".to_string());
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(DelayedLoadingHeader::new(
self.frame_requester.clone(),
@@ -497,7 +495,7 @@ impl ChatWidget {
is_disabled: true,
..Default::default()
}],
..Default::default()
..SelectionViewParams::picker()
}
}
@@ -506,6 +504,7 @@ impl ChatWidget {
plugin_display_name: &str,
) -> SelectionViewParams {
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(DelayedLoadingHeader::new(
self.frame_requester.clone(),
@@ -519,7 +518,7 @@ impl ChatWidget {
is_disabled: true,
..Default::default()
}],
..Default::default()
..SelectionViewParams::picker()
}
}
@@ -527,22 +526,18 @@ impl ChatWidget {
&self,
plugin_display_name: &str,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("Installing {plugin_display_name}...").dim(),
));
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
title: Some("Plugins".to_string()),
subtitle: Some(format!("Installing {plugin_display_name}...")),
items: vec![SelectionItem {
name: "Installing plugin...".to_string(),
description: Some("This updates when plugin installation completes.".to_string()),
is_disabled: true,
..Default::default()
}],
..Default::default()
..SelectionViewParams::picker()
}
}
@@ -550,48 +545,38 @@ impl ChatWidget {
&self,
plugin_display_name: &str,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("Uninstalling {plugin_display_name}...").dim(),
));
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
title: Some("Plugins".to_string()),
subtitle: Some(format!("Uninstalling {plugin_display_name}...")),
items: vec![SelectionItem {
name: "Uninstalling plugin...".to_string(),
description: Some("This updates when the plugin removal completes.".to_string()),
is_disabled: true,
..Default::default()
}],
..Default::default()
..SelectionViewParams::picker()
}
}
pub(super) fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Failed to load plugins.".dim()));
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
title: Some("Plugins".to_string()),
subtitle: Some("Failed to load plugins.".to_string()),
items: vec![SelectionItem {
name: "Plugin marketplace unavailable".to_string(),
description: Some(err.to_string()),
is_disabled: true,
..Default::default()
}],
..Default::default()
..SelectionViewParams::picker()
}
}
pub(super) fn marketplace_add_error_popup_params(&self) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Failed to add marketplace.".dim()));
let mut items = vec![
SelectionItem {
name: "Marketplace add failed".to_string(),
@@ -629,11 +614,13 @@ impl ChatWidget {
}
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
title: Some("Plugins".to_string()),
subtitle: Some("Failed to add marketplace.".to_string()),
footer_hint: Some(plugin_detail_hint_line()),
items,
..Default::default()
..SelectionViewParams::picker()
}
}
@@ -642,10 +629,6 @@ impl ChatWidget {
marketplace_name: &str,
marketplace_display_name: &str,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Failed to remove marketplace.".dim()));
let marketplace_name = marketplace_name.to_string();
let marketplace_display_name = marketplace_display_name.to_string();
let mut items = vec![
@@ -686,11 +669,13 @@ impl ChatWidget {
}
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
title: Some("Plugins".to_string()),
subtitle: Some("Failed to remove marketplace.".to_string()),
footer_hint: Some(plugin_detail_hint_line()),
items,
..Default::default()
..SelectionViewParams::picker()
}
}
@@ -699,10 +684,6 @@ impl ChatWidget {
err: &str,
plugins_response: Option<&PluginListResponse>,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Failed to load plugin details.".dim()));
let mut items = vec![SelectionItem {
name: "Plugin detail unavailable".to_string(),
description: Some(err.to_string()),
@@ -726,11 +707,13 @@ impl ChatWidget {
}
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
title: Some("Plugins".to_string()),
subtitle: Some("Failed to load plugin details.".to_string()),
footer_hint: Some(plugin_detail_hint_line()),
items,
..Default::default()
..SelectionViewParams::picker()
}
}
@@ -755,7 +738,8 @@ impl ChatWidget {
PLUGIN_ROW_PREFIX_WIDTH + UnicodeWidthStr::width(display_name.as_str())
})
.chain([UnicodeWidthStr::width("Add marketplace")])
.max();
.max()
.map(|width| width.min(/*other*/ 36));
let installed_entries = all_entries
.iter()
.filter(|(_, plugin, _)| plugin.installed)
@@ -950,6 +934,7 @@ impl ChatWidget {
active_tab_id.and_then(|tab_id| plugin_tab_id_matching_saved_id(&tab_id, &tabs));
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(()),
footer_hint: Some(plugins_popup_hint_line(
@@ -957,6 +942,7 @@ impl ChatWidget {
)),
tab_footer_hints,
tabs,
reserve_result_rows: true,
initial_tab_id,
is_searchable: true,
search_placeholder: Some("Type to search plugins".to_string()),
@@ -964,7 +950,7 @@ impl ChatWidget {
row_display: SelectionRowDisplay::SingleLine,
name_column_width,
initial_selected_idx,
..Default::default()
..SelectionViewParams::picker()
}
}
@@ -1007,10 +993,16 @@ impl ChatWidget {
let display_name = plugin_display_name(&plugin.summary);
let detail_status_label = plugin_detail_status_label(&plugin.summary);
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("{display_name} · {detail_status_label} · {marketplace_label}").bold(),
));
header.push(
ratatui::widgets::Paragraph::new(Line::from("Plugins".bold()))
.wrap(ratatui::widgets::Wrap { trim: false }),
);
header.push(
ratatui::widgets::Paragraph::new(Line::from(
format!("{display_name} · {detail_status_label} · {marketplace_label}").bold(),
))
.wrap(ratatui::widgets::Wrap { trim: false }),
);
if !plugin.summary.installed {
header.push(PluginDisclosureLine {
line: Line::from(vec![
@@ -1025,7 +1017,10 @@ impl ChatWidget {
});
}
if let Some(description) = plugin_detail_description(plugin) {
header.push(Line::from(description.dim()));
header.push(
ratatui::widgets::Paragraph::new(Line::from(description.dim()))
.wrap(ratatui::widgets::Wrap { trim: false }),
);
}
let cwd = self.config.cwd.to_path_buf();
@@ -1156,12 +1151,13 @@ impl ChatWidget {
});
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(plugin_detail_hint_line()),
items,
col_width_mode: ColumnWidthMode::AutoAllRows,
..Default::default()
..SelectionViewParams::picker()
}
}
@@ -1302,30 +1298,31 @@ fn plugins_popup_hint_line(
) -> Line<'static> {
match (can_remove_marketplace, can_upgrade_marketplace) {
(true, true) => Line::from(
"ctrl + u upgrade · ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close",
"ctrl+u upgrade · ctrl+r remove · space toggle · ←/→ tabs · enter details · esc close",
),
(true, false) => {
Line::from("ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close")
Line::from("ctrl+r remove · space toggle · ←/→ tabs · enter details · esc close")
}
(false, true) => {
Line::from("ctrl + u upgrade · space toggle · ←/→ tabs · enter details · esc close")
Line::from("ctrl+u upgrade · space toggle · ←/→ tabs · enter details · esc close")
}
(false, false) => Line::from(
"space enable/disable · ←/→ select marketplace · enter view details · esc close",
),
(false, false) => Line::from("←/→ tabs · enter details · space toggle · esc close"),
}
}
pub(super) fn plugin_detail_hint_line() -> Line<'static> {
Line::from("Press esc to close.")
Line::from("esc close")
}
pub(super) fn plugins_header(subtitle: String, count_line: String) -> Box<dyn Renderable> {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(subtitle.dim()));
header.push(Line::from(count_line.dim()));
Box::new(header)
Box::new(
Paragraph::new(vec![
Line::from("Plugins".bold()),
Line::from(subtitle.dim()),
Line::from(count_line.dim()),
])
.wrap(Wrap { trim: false }),
)
}
fn dedupe_plugin_entries<'a>(
+22 -9
View File
@@ -865,14 +865,26 @@ impl ChatWidget {
"Install the required Apps in ChatGPT to continue:"
};
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("{} plugin installed.", flow.plugin_display_name).bold(),
));
header.push(Line::from(
format!("App setup {current}/{total}: {}", app.name).dim(),
));
header.push(Line::from(status_label.dim()));
header.push(
ratatui::widgets::Paragraph::new(Line::from("Plugins".bold()))
.wrap(ratatui::widgets::Wrap { trim: false }),
);
header.push(
ratatui::widgets::Paragraph::new(Line::from(
format!("{} plugin installed.", flow.plugin_display_name).bold(),
))
.wrap(ratatui::widgets::Wrap { trim: false }),
);
header.push(
ratatui::widgets::Paragraph::new(Line::from(
format!("App setup {current}/{total}: {}", app.name).dim(),
))
.wrap(ratatui::widgets::Wrap { trim: false }),
);
header.push(
ratatui::widgets::Paragraph::new(Line::from(status_label.dim()))
.wrap(ratatui::widgets::Wrap { trim: false }),
);
let mut items = Vec::new();
@@ -943,12 +955,13 @@ impl ChatWidget {
});
Some(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(plugin_detail_hint_line()),
items,
col_width_mode: ColumnWidthMode::AutoAllRows,
..Default::default()
..SelectionViewParams::picker()
})
}
@@ -1,5 +1,6 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
assertion_line: 2317
expression: snapshot
---
› $
@@ -14,9 +15,10 @@ expression: snapshot
App setup 1/1: % Arabica UAE
Already installed in this session.
ChatGPT apps link unavailable This app did not provide an install/manage
URL.
› 1. Continue Advance to the next app.
2. Skip remaining app setup Stop this follow-up flow for this plugin.
Press esc to close.
esc close
@@ -1,13 +1,13 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
assertion_line: 2796
expression: popup
---
Apps
Failed to load apps.
App directory unavailable The app directory request failed. Retry, or
press Esc to continue.
› 1. Retry Reload installed and available apps.
Press enter to confirm or esc to go back
enter select · esc back
@@ -5,6 +5,7 @@ expression: before
Apps
Loading installed and available apps...
› Loading apps... This updates when the full list is ready.
Press enter to confirm or esc to go back
enter select · esc back
@@ -8,9 +8,10 @@ Before account change:
Installed 1 of 1 available apps.
Type to search apps
› Previous Account App Installed. App link unavailable.
Press enter to confirm or esc to go back
enter select · esc back
After account change:
› Ask Codex to do anything
@@ -1,16 +1,17 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
assertion_line: 3142
assertion_line: 3203
expression: popup
---
Experimental features
Checked features are configured on. Some experimental features take effect
only in new tasks or after restarting the Codex server.
› [ ] Network proxy Apply network proxy restrictions to
sandboxed sessions that already have
network access.
› [ ] Network proxy Apply network proxy restrictions to sandboxed
sessions that already have network access.
[ ] Prevent sleep while running Keep your computer awake while Codex is
running a thread.
Press space to select or enter to save
space toggle · enter save · esc save/close
@@ -1,5 +1,6 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
assertion_line: 3167
expression: "render_bottom_popup(&chat, 80)"
---
Experimental features
@@ -9,4 +10,6 @@ expression: "render_bottom_popup(&chat, 80)"
Press space to select or enter to save
space toggle · enter save · esc save/close
@@ -1,16 +1,17 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
assertion_line: 3083
expression: popup
---
Experimental features
Checked features are configured on. Some experimental features take effect
only in new tasks or after restarting the Codex server.
› [ ] JavaScript REPL Enable a persistent Node-backed JavaScript REPL for
interactive website debugging and other inline
JavaScript execution capabilities.
[x] Shell tool Allow the model to run shell commands.
[ ] Voice conversations Talk with Codex using /voice.
Press space to select or enter to save
space toggle · enter save · esc save/close
@@ -6,6 +6,8 @@ expression: popup
Checked features are configured on. Some experimental features take effect
only in new tasks or after restarting the Codex server.
› [x] Shell tool Allow the model to run shell commands.
Press space to select or ctrl+x enter to save
space toggle · ctrl+x enter save · esc save/close
@@ -10,7 +10,8 @@ expression: popup
■ /tmp/hooks.json: failed to parse hooks config
Event Installed Active Description
PreToolUse 0 0 Before a tool executes
› PreToolUse 0 0 Before a tool executes
PermissionRequest 0 0 When permission is requested
PostToolUse 0 0 After a tool executes
PreCompact 0 0 Before context compaction
@@ -18,9 +19,6 @@ expression: popup
SessionStart 0 0 When a new session starts
SessionEnd 0 0 Right before a session ends
UserPromptSubmit 0 0 When the user submits a prompt
SubagentStart 0 0 When a subagent is created
SubagentStop 0 0 Right before a subagent ends its turn
Stop 0 0 Right before Codex ends its turn
Interrupt 0 0 Right before an interrupted turn is aborted
↓
Press enter to view hooks; esc to close
enter details · esc close
@@ -3,10 +3,13 @@ source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Reset all memories?
This clears local memory files and rollout summaries for the current Codex
home.
› Reset all memories Delete local memory files and rollout summaries.
Go back Return to memory settings.
Press enter to confirm or esc to go back
enter select · esc back
@@ -5,13 +5,15 @@ expression: popup
Memories
Choose how Codex uses and creates memories. Changes are saved to config.toml
› [x] Use memories Use memories in the following threads. Applied at
next thread.
› [x] Use memories Use memories in the following threads. Applied at next
thread.
[ ] Generate memories Generate memories from the following threads. Current
thread included.
Reset all memories Clear local memory files and summaries. Existing
threads stay intact.
Learn more: https://developers.openai.com/codex/memories
Press space to toggle; enter to save or select
space toggle · enter save/select · esc cancel
@@ -5,7 +5,8 @@ expression: popup
Plugins
Failed to load plugin details.
Plugin detail unavailable Failed to load plugin details.
› 1. Back to plugins Return to the plugin list.
Press esc to close.
esc close
@@ -8,6 +8,7 @@ expression: strip_osc8_for_snapshot(&popup)
more.
Turn Figma files into implementation context.
› 1. Back to plugins Return to the plugin list.
2. Install plugin Install this plugin now.
Source Local
@@ -17,4 +18,4 @@ expression: strip_osc8_for_snapshot(&popup)
Apps Figma, Slack
MCP Servers figma-mcp, docs-mcp
Press esc to close.
esc close
@@ -6,6 +6,7 @@ expression: strip_osc8_for_snapshot(&popup)
Figma · Installed by admin · ChatGPT Marketplace
Turn Figma files into implementation context.
› 1. Back to plugins Return to the plugin list.
Installed by admin This plugin is installed by your workspace admin.
Source Local
@@ -15,4 +16,4 @@ expression: strip_osc8_for_snapshot(&popup)
Apps Figma, Slack
MCP Servers figma-mcp, docs-mcp
Press esc to close.
esc close
@@ -7,6 +7,7 @@ expression: strip_osc8_for_snapshot(&popup)
Data shared with this app is subject to the app's terms of service and privacy policy. Learn more.
Workspace docs.
› 1. Back to plugins Return to the plugin list.
2. Install plugin Install this plugin now.
Source Local
@@ -15,5 +16,5 @@ expression: strip_osc8_for_snapshot(&popup)
Sharing Private · creator Test User · https://chatgpt.com/codex/plugins/share/docs
Skills No plugin skills.
Hooks No plugin hooks.
Press esc to close.
↓
esc close
@@ -8,6 +8,7 @@ expression: strip_osc8_for_snapshot(&popup)
more.
Turn Figma files into implementation context.
› 1. Back to plugins Return to the plugin list.
2. Install plugin Install this plugin now.
Source npm · @acme/figma-plugin@^1.2.0
@@ -17,4 +18,4 @@ expression: strip_osc8_for_snapshot(&popup)
Apps Figma, Slack
MCP Servers figma-mcp, docs-mcp
Press esc to close.
esc close
@@ -6,9 +6,17 @@ expression: popup
Browse plugins from available marketplaces.
Installed 1 of 1 available plugins.
[All Plugins] Installed (1) OpenAI Curated Workspace Add Marketplace
All Plugins Installed (1) OpenAI Curated Workspace Add Marketplace
Type to search plugins
› [!] Admin Blocked Disabled Press Enter to view plugin details.
space enable/disable · ←/→ select marketplace · enter view details · esc close
←/→ tabs · enter details · space toggle · esc close
@@ -6,12 +6,17 @@ expression: popup
Browse plugins from available marketplaces.
Installed 1 of 4 available plugins.
[All Plugins] Installed (1) OpenAI Curated Workspace Repo Marketplace Add Marketplace
All Plugins Installed (1) OpenAI Curated Workspace Repo Marketplace Add Marketplace
Type to search plugins
› [ ] Alpha Sync Disabled Space to enable; Enter view details.
[-] Bravo Search Available · OpenAI Curated · Search docs and tickets.
[-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins.
[-] Starter Admin assigned · OpenAI Curated · Included by default.
space enable/disable · ←/→ select marketplace · enter view details · esc close
←/→ tabs · enter details · space toggle · esc close
@@ -6,9 +6,17 @@ expression: loaded_popup
Workspace.
This section loaded successfully.
All Plugins Installed (0) OpenAI Curated [Workspace] Add Marketplace
All Plugins Installed (0) OpenAI Curated Workspace Add Marketplace
Type to search plugins
› No workspace plugins available No workspace directory plugins are available.
space enable/disable · ←/→ select marketplace · enter view details · esc close
←/→ tabs · enter details · space toggle · esc close
@@ -6,6 +6,7 @@ expression: popup
Loading available plugins...
This updates when the marketplace list is ready.
› Loading plugins... This updates when the marketplace list is ready.
Press enter to confirm or esc to go back
enter select · esc back
@@ -1,12 +1,12 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
assertion_line: 411
expression: confirmation
---
Plugins
Remove Repo Marketplace marketplace?
This removes the configured marketplace from Codex.
› 1. Remove marketplace Remove this marketplace from the available plugin list.
2. Back to plugins Keep this marketplace installed.
@@ -1,15 +1,22 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
assertion_line: 473
expression: popup
---
Plugins
Debug Marketplace installed successfully.
Select the plugins you want to use and press Enter to install or view details.
All Plugins Installed (0) OpenAI Curated Workspace [Debug Marketplace] Add Marketplace
All Plugins Installed (0) OpenAI Curated Workspace Debug Marketplace Add Marketplace
Type to search plugins
› [-] Debug Plugin Available Press Enter to install or view plugin details.
ctrl + u upgrade · ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close
ctrl+u upgrade · ctrl+r remove · space toggle · ←/→ tabs · enter details · esc close
@@ -1,15 +1,22 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
assertion_line: 1684
expression: popup
---
Plugins
Browse plugins from available marketplaces.
Installed 0 of 3 available plugins.
[All Plugins] Installed (0) OpenAI Curated Workspace Add Marketplace
All Plugins Installed (0) OpenAI Curated Workspace Add Marketplace
sla
› [-] Slack Available Press Enter to install or view plugin details.
space enable/disable · ←/→ select marketplace · enter view details · esc close
←/→ tabs · enter details · space toggle · esc close
@@ -6,7 +6,7 @@ expression: status_line_popup_snapshot(&mut chat)
Select which items to display in the status line.
Type to search
>
› [x] Use theme colors Apply colors from the active /theme
───────────────────────
[x] project-name Project name (omitted when unavailable)
@@ -15,6 +15,6 @@ expression: status_line_popup_snapshot(&mut chat)
[ ] model Current model name
[ ] model-with-reasoning Current model name with reasoning level
[ ] reasoning Current reasoning level
↓
my-project · feat/awesome-feature · thread title
Press space to toggle; ←/→ to move; enter to confirm and close; esc to close
space toggle · ←/→ reorder · enter save · esc cancel
@@ -6,7 +6,7 @@ expression: status_line_popup_snapshot(&mut chat)
Select which items to display in the status line.
Type to search
>
› [x] Use theme colors Apply colors from the active /theme
───────────────────────
[x] project-name Project name (omitted when unavailable)
@@ -15,6 +15,6 @@ expression: status_line_popup_snapshot(&mut chat)
[ ] model Current model name
[ ] model-with-reasoning Current model name with reasoning level
[ ] reasoning Current reasoning level
↓
preview-live-root · feature/live-preview-branch · Live preview thread
Press space to toggle; ←/→ to move; enter to confirm and close; esc to close
space toggle · ←/→ reorder · enter save · esc cancel
@@ -6,7 +6,7 @@ expression: status_line_popup_snapshot(&mut chat)
Select which items to display in the status line.
Type to search
>
› [x] Use theme colors Apply colors from the active /theme
───────────────────────
[x] project-name Project name (omitted when unavailable)
@@ -15,6 +15,6 @@ expression: status_line_popup_snapshot(&mut chat)
[ ] model Current model name
[ ] model-with-reasoning Current model name with reasoning level
[ ] reasoning Current reasoning level
↓
my-project · feature/mixed-preview · Mixed preview thread
Press space to toggle; ←/→ to move; enter to confirm and close; esc to close
space toggle · ←/→ reorder · enter save · esc cancel
@@ -6,7 +6,7 @@ expression: status_line_popup_snapshot(&mut chat)
Select which items to display in the status line.
Type to search
>
› [x] Use theme colors Apply colors from the active /theme
───────────────────────
[x] monthly-limit Remaining usage on the monthly usage limit (omitted when unavailable)
@@ -15,6 +15,6 @@ expression: status_line_popup_snapshot(&mut chat)
[ ] model-with-reasoning Current model name with reasoning level
[ ] reasoning Current reasoning level
[ ] current-dir Current working directory
↓
monthly 65% left · weekly 50% left
Press space to toggle; ←/→ to move; enter to confirm and close; esc to close
space toggle · ←/→ reorder · enter save · esc cancel
@@ -6,15 +6,15 @@ expression: status_line_popup_snapshot(&mut chat)
Select which items to display in the status line.
Type to search
>
› [x] Use theme colors Apply colors from the active /theme
───────────────────────
[x] workspace-headline Workspace notification headline (Enterprise workspaces only; omitted …
[x] workspace-headline Workspace notification headline (Enterprise workspaces only; omitted wh…
[ ] model Current model name
[ ] model-with-reasoning Current model name with reasoning level
[ ] reasoning Current reasoning level
[ ] current-dir Current working directory
[ ] project-name Project name (omitted when unavailable)
↓
Workspace maintenance starts at 5pm
Press space to toggle; ←/→ to move; enter to confirm and close; esc to close
space toggle · ←/→ reorder · enter save · esc cancel
@@ -6,7 +6,7 @@ expression: terminal_title_popup_snapshot(&mut chat)
Select which items to display in the terminal title.
Type to search
>
› [x] thread-title Current thread title, or thread identifier when unnamed
[x] git-branch Current Git branch (omitted when unavailable)
[x] task-progress Latest task progress from update_plan (omitted until available)
@@ -15,6 +15,6 @@ expression: terminal_title_popup_snapshot(&mut chat)
[ ] current-dir Current working directory
[ ] activity Spinner while working, action-required message while blocked.
[ ] run-state Compact session run-state text (Ready, Working, Thinking)
↓
thread title | feat/awesome-feature | Tasks 0/0
Press space to toggle; ←/→ to move; enter to confirm and close; esc to close
space toggle · ←/→ reorder · enter save · esc cancel
@@ -6,7 +6,7 @@ expression: terminal_title_popup_snapshot(&mut chat)
Select which items to display in the terminal title.
Type to search
>
› [x] project-name Project name (falls back to current directory name)
[x] thread-title Current thread title, or thread identifier when unnamed
[x] git-branch Current Git branch (omitted when unavailable)
@@ -15,6 +15,6 @@ expression: terminal_title_popup_snapshot(&mut chat)
[ ] current-dir Current working directory
[ ] activity Spinner while working, action-required message while blocked.
[ ] run-state Compact session run-state text (Ready, Working, Thinking)
↓
preview-live-root | Live preview thread | feature/live-preview-branch | Tasks 2/5
Press space to toggle; ←/→ to move; enter to confirm and close; esc to close
space toggle · ←/→ reorder · enter save · esc cancel
@@ -6,7 +6,7 @@ expression: terminal_title_popup_snapshot(&mut chat)
Select which items to display in the terminal title.
Type to search
>
› [x] project-name Project name (falls back to current directory name)
[x] thread-title Current thread title, or thread identifier when unnamed
[x] task-progress Latest task progress from update_plan (omitted until available)
@@ -15,6 +15,6 @@ expression: terminal_title_popup_snapshot(&mut chat)
[ ] activity Spinner while working, action-required message while blocked.
[ ] run-state Compact session run-state text (Ready, Working, Thinking)
[ ] thread-name Current thread name (omitted when unnamed)
↓
project | Mixed preview thread | Tasks 0/0
Press space to toggle; ←/→ to move; enter to confirm and close; esc to close
space toggle · ←/→ reorder · enter save · esc cancel
@@ -6,7 +6,7 @@ expression: terminal_title_popup_snapshot(&mut chat)
Select which items to display in the terminal title.
Type to search
>
› [x] monthly-limit Remaining usage on the monthly usage limit (omitted when unavailable)
[x] weekly-limit Remaining usage on the weekly usage limit (omitted when unavailable)
[ ] app-name Codex app name
@@ -15,6 +15,6 @@ expression: terminal_title_popup_snapshot(&mut chat)
[ ] activity Spinner while working, action-required message while blocked.
[ ] run-state Compact session run-state text (Ready, Working, Thinking)
[ ] thread-name Current thread name (omitted when unnamed)
↓
monthly 65% left | weekly 50% left
Press space to toggle; ←/→ to move; enter to confirm and close; esc to close
space toggle · ←/→ reorder · enter save · esc cancel
@@ -6,15 +6,15 @@ expression: terminal_title_popup_snapshot(&mut chat)
Select which items to display in the terminal title.
Type to search
>
› [x] thread-credits Estimated current-thread credits (Enterprise workspaces only; omitte…
[x] estimated-thread-cost Estimated current-thread cost (Enterprise workspaces only; omitted w…
› [x] thread-credits Estimated current-thread credits (Enterprise workspaces only; omitted …
[x] estimated-thread-cost Estimated current-thread cost (Enterprise workspaces only; omitted whe…
[ ] app-name Codex app name
[ ] project-name Project name (falls back to current directory name)
[ ] current-dir Current working directory
[ ] activity Spinner while working, action-required message while blocked.
[ ] run-state Compact session run-state text (Ready, Working, Thinking)
[ ] thread-name Current thread name (omitted when unnamed)
↓
5.2 credits | ~$1.82
Press space to toggle; ←/→ to move; enter to confirm and close; esc to close
space toggle · ←/→ reorder · enter save · esc cancel
@@ -84,7 +84,7 @@ async fn plugins_popup_uses_product_labels_for_remote_and_personal_tabs() {
]
.into_iter()
.map(|(selected_tab, product_label, plugin_name, raw_label)| {
let popup = select_plugins_tab_containing(&mut chat, /*width*/ 120, selected_tab);
let popup = select_plugins_tab_containing(&mut chat, /*width*/ 120, product_label);
assert!(
popup.contains(product_label)
&& popup.contains(plugin_name)
@@ -277,7 +277,7 @@ async fn plugins_popup_truncates_long_descriptions_in_list_rows() {
.expect("expected verbose plugin row in popup");
insta::assert_snapshot!(
verbose_row,
@" [-] Verbose Plugin Available · OpenAI Curated · This description…"
@" [-] Verbose Plugin Available · OpenAI Curated · This description k…"
);
assert!(
!popup
@@ -379,8 +379,8 @@ async fn plugins_popup_upgrades_user_configured_git_marketplace_from_marketplace
let popup = select_plugins_tab_containing(&mut chat, /*width*/ 100, "Repo Marketplace.");
assert!(
popup.contains("Repo Marketplace.")
&& popup.contains("ctrl + u upgrade")
&& popup.contains("ctrl + r remove")
&& popup.contains("ctrl+u upgrade")
&& popup.contains("ctrl+r remove")
&& popup.contains("Debug Plugin"),
"expected upgradeable user-configured marketplace tab, got:\n{popup}"
);
@@ -476,8 +476,8 @@ async fn marketplace_add_success_refreshes_to_new_marketplace_tab() {
assert_chatwidget_snapshot!("plugins_popup_newly_installed_marketplace", popup);
assert!(
popup.contains("Debug Marketplace installed successfully.")
&& popup.contains("ctrl + u upgrade")
&& popup.contains("ctrl + r remove")
&& popup.contains("ctrl+u upgrade")
&& popup.contains("ctrl+r remove")
&& popup.contains("Debug Plugin"),
"expected marketplace add refresh to switch to the new marketplace tab, got:\n{popup}"
);
@@ -487,7 +487,7 @@ async fn marketplace_add_success_refreshes_to_new_marketplace_tab() {
let reopened_popup = (0..8)
.find_map(|_| {
let popup = render_bottom_popup(&chat, /*width*/ 100);
if popup.contains("[Debug Marketplace]") {
if popup.contains("Installed 0 of 1 Debug Marketplace plugins.") {
Some(popup)
} else {
chat.handle_key_event(KeyEvent::from(KeyCode::Right));
@@ -542,8 +542,8 @@ async fn plugins_popup_removes_user_configured_marketplace_flow() {
select_plugins_tab_containing(&mut chat, /*width*/ 100, "Repo Marketplace.");
assert!(
repo_tab.contains("Repo Marketplace.")
&& repo_tab.contains("ctrl + u upgrade")
&& repo_tab.contains("ctrl + r remove")
&& repo_tab.contains("ctrl+u upgrade")
&& repo_tab.contains("ctrl+r remove")
&& repo_tab.contains("Debug Plugin"),
"expected removable user-configured marketplace tab, got:\n{repo_tab}"
);
@@ -611,7 +611,7 @@ async fn plugins_popup_removes_user_configured_marketplace_flow() {
refreshed.contains("Browse plugins from available marketplaces.")
&& !refreshed.contains("Repo Marketplace")
&& !refreshed.contains("Debug Plugin")
&& !refreshed.contains("ctrl + r remove"),
&& !refreshed.contains("ctrl+r remove"),
"expected refreshed plugin list without removed marketplace, got:\n{refreshed}"
);
}
@@ -1,6 +1,8 @@
//! Windows sandbox prompts and warning surfaces for `ChatWidget`.
use super::*;
#[cfg(any(target_os = "windows", test))]
use crate::render::renderable::ColumnRenderable;
impl ChatWidget {
#[cfg(any(target_os = "windows", test))]