Unify TUI completion popup styling and preserve result identity (#46694)

## Why

Narrow completion popups can hide distinguishing filenames, parent paths, or category tags. Unified mentions also need a stable result viewport across searches and filters.

## What changed

- Use shared selection styling and `›` markers across command, file, skill, and unified mention popups.
- Give unified mentions a fixed-height picker layout with a header, filter tabs, query display, scroll indicators, and keyboard hints.
- Bound mention name columns to leave room for parent paths and measure alignment in terminal cells.
- Truncate file paths while retaining filenames and recalculating visible fuzzy highlights, preserving the full path for insertion.
- Keep category tags visible when skill and app descriptions are hidden.

## Testing

Add snapshots and assertions for narrow and clipped layouts, scrolling, empty filters, full-row selection styling, Unicode column alignment, long paths, preserved file selections, and category tags.

GitOrigin-RevId: 54c34cc2472b43bac22011206efed3041eb77308
This commit is contained in:
Eric Traut
2026-09-19 19:13:46 +00:00
committed by copyberry
parent 018687bd8c
commit e28fa3e7a4
50 changed files with 1037 additions and 341 deletions
@@ -6,7 +6,7 @@ command during startup:
› /status
/status show current session configuration and token usage
› /status show current session configuration and token usage
/statusline configure which items appear in the status line
after command:
+12 -14
View File
@@ -2,6 +2,7 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use super::picker_style::selection_style;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::ColumnWidthConfig;
@@ -13,15 +14,12 @@ use super::slash_commands::BuiltinCommandFlags;
use super::slash_commands::ServiceTierCommand;
use super::slash_commands::SlashCommandItem;
use super::slash_commands::commands_for_input;
use crate::render::Insets;
use crate::render::RectExt;
use crate::slash_command::SlashCommand;
// Hide alias commands in the default popup list so each unique action appears once.
// `quit` is an alias of `exit`, and `btw` is an alias of `side`, so we skip
// those aliases here.
const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Btw];
const COMMAND_LEFT_INSET: u16 = 2;
const COMMAND_COLUMN_WIDTH: ColumnWidthConfig = ColumnWidthConfig::new(
ColumnWidthMode::AutoAllRows,
/*name_column_width*/ None,
@@ -138,7 +136,7 @@ impl CommandPopup {
&rows,
&self.state,
MAX_POPUP_ROWS,
width.saturating_sub(COMMAND_LEFT_INSET),
width,
COMMAND_COLUMN_WIDTH,
)
}
@@ -206,17 +204,22 @@ impl CommandPopup {
) -> Vec<GenericDisplayRow> {
matches
.into_iter()
.map(|(item, indices)| {
.enumerate()
.map(|(index, (item, indices))| {
let name = format!("/{}", item.command());
let description = item.description().to_string();
GenericDisplayRow {
selection_style: None,
category_tag: None,
name,
name_prefix_spans: Vec::new(),
name_prefix_spans: vec![if self.state.selected_idx == Some(index) {
"› ".into()
} else {
" ".into()
}],
selection_style: Some(selection_style()),
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
display_shortcut: None,
description: Some(description),
category_tag: None,
wrap_indent: None,
is_disabled: false,
disabled_reason: None,
@@ -269,12 +272,7 @@ impl WidgetRef for CommandPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let rows = self.rows_from_matches(self.filtered());
render_rows_with_col_width_mode(
area.inset(Insets::tlbr(
/*top*/ 0,
COMMAND_LEFT_INSET,
/*bottom*/ 0,
/*right*/ 0,
)),
area,
buf,
&rows,
&self.state,
+147 -26
View File
@@ -1,17 +1,22 @@
//! Display file completions while preserving the full path used for insertion.
//! Narrow rows retain the filename; fuzzy highlights refer to the displayed text.
use std::path::PathBuf;
use codex_file_search::FileMatch;
use codex_utils_fuzzy_match::fuzzy_match;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::WidgetRef;
use crate::render::Insets;
use crate::render::RectExt;
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;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::text_formatting::center_truncate_path;
use crate::width::display_width;
/// Visual state for the file-search popup.
pub(crate) struct FileSearchPopup {
@@ -105,7 +110,7 @@ impl FileSearchPopup {
// up to MAX_RESULTS regardless of the waiting flag so the list
// remains stable while a newer search is in-flight.
self.matches.len().clamp(1, MAX_POPUP_ROWS) as u16
self.matches.len().clamp(/*min*/ 1, MAX_POPUP_ROWS) as u16 + 2
}
}
@@ -117,34 +122,69 @@ impl WidgetRef for &FileSearchPopup {
} else {
self.matches
.iter()
.map(|m| GenericDisplayRow {
selection_style: None,
name: m.path.to_string_lossy().to_string(),
name_prefix_spans: Vec::new(),
match_indices: m
.indices
.as_ref()
.map(|v| v.iter().map(|&i| i as usize).collect()),
display_shortcut: None,
description: None,
category_tag: None,
wrap_indent: None,
is_disabled: false,
disabled_reason: None,
.enumerate()
.map(|(idx, m)| {
let path = m.path.to_string_lossy();
let width = usize::from(area.width.saturating_sub(/*rhs*/ 2));
let name = if display_width(&path) <= width {
path.to_string()
} else {
let filename = m
.path
.file_name()
.unwrap_or(m.path.as_os_str())
.to_string_lossy();
let filename_width = display_width(&filename);
if let Some(parent) = m.path.parent()
&& filename_width + 2 <= width
{
let parent = truncate_line_with_ellipsis_if_overflow(
Line::from(parent.to_string_lossy().into_owned()),
width - filename_width - 1,
);
format!("{parent}{}{filename}", std::path::MAIN_SEPARATOR)
} else {
center_truncate_path(&filename, width)
}
};
let match_indices = if name == path {
m.indices
.as_ref()
.map(|indices| indices.iter().map(|&index| index as usize).collect())
} else {
fuzzy_match(&name, &self.display_query).map(|(indices, _)| indices)
};
GenericDisplayRow {
category_tag: None,
selection_style: Some(super::picker_style::selection_style()),
name,
name_prefix_spans: vec![
if self.state.selected_idx == Some(idx) {
"› "
} else {
" "
}
.into(),
],
match_indices,
display_shortcut: None,
description: None,
wrap_indent: None,
is_disabled: false,
disabled_reason: None,
}
})
.collect()
};
let empty_message = if self.waiting {
"loading..."
" loading..."
} else {
"no matches"
" no matches"
};
render_rows(
area.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
render_rows_single_line(
area,
buf,
&rows_all,
&self.state,
@@ -170,6 +210,87 @@ mod tests {
}
}
#[test]
fn long_paths_keep_distinguishing_filenames_and_original_selection() {
let mut snapshots = Vec::new();
for components in [
vec!["src", "shared", "long_directory"],
vec!["long_directory_name_that_fills_row"],
vec!["長いディレクトリ名", "e\u{301}tudes"],
] {
let parent: PathBuf = components.iter().collect();
let paths: Vec<PathBuf> = ["parser_alpha.rs", "parser_beta.rs"]
.into_iter()
.map(|name| parent.join(name))
.collect();
let mut popup = FileSearchPopup::new();
popup.set_query("parser");
popup.set_matches(
"parser",
paths
.iter()
.enumerate()
.map(|(index, path)| {
let mut matched = file_match(index);
matched.path = path.clone();
matched.indices =
fuzzy_match(&path.to_string_lossy(), "parser").map(|(indices, _)| {
indices.into_iter().map(|index| index as u32).collect()
});
matched
})
.collect(),
);
for (selected, path) in paths.iter().enumerate() {
for width in [28, 40, 80] {
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
width,
popup.calculate_required_height(),
);
let mut buf = Buffer::empty(area);
(&popup).render_ref(area, &mut buf);
let text = buf
.content
.chunks(usize::from(width))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.trim_end()
.replace(std::path::MAIN_SEPARATOR, "/")
})
.collect::<Vec<_>>()
.join("\n");
assert!(
text.contains("parser_alpha.rs") && text.contains("parser_beta.rs"),
"{text}"
);
assert_eq!(popup.selected_match(), Some(path));
let unselected_y = if selected == 0 { 2 } else { 1 };
let parser_x = (0..width.saturating_sub(/*rhs*/ 5))
.find(|&x| {
(x..x + 6)
.map(|column| buf[(column, unselected_y)].symbol())
.eq(["p", "a", "r", "s", "e", "r"])
})
.expect("visible filename match");
assert!(
buf[(parser_x, unselected_y)]
.modifier
.contains(ratatui::style::Modifier::BOLD)
);
if selected == 0 {
snapshots.push(format!("{components:?}, width {width}\n{text}"));
}
}
popup.move_down();
}
}
insta::assert_snapshot!(snapshots.join("\n\n"));
}
#[test]
fn set_matches_keeps_only_the_first_page_of_results() {
let mut popup = FileSearchPopup::new();
@@ -180,6 +301,6 @@ mod tests {
popup.matches,
(0..MAX_POPUP_ROWS).map(file_match).collect::<Vec<_>>()
);
assert_eq!(popup.calculate_required_height(), MAX_POPUP_ROWS as u16);
assert_eq!(popup.calculate_required_height(), MAX_POPUP_ROWS as u16 + 2);
}
}
@@ -31,7 +31,7 @@ impl MentionType {
pub(super) fn span(self, base_style: Style) -> Span<'static> {
let style = match self {
Self::Plugin => base_style.magenta(),
Self::Plugin => base_style.fg(crate::style::accent_color()),
Self::Skill => base_style.dim(),
Self::Task => base_style.cyan(),
Self::File => base_style.cyan(),
@@ -1,3 +1,5 @@
//! Keyboard help for the live mention popup; filter tabs live above the results.
use crossterm::event::KeyCode;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -8,74 +10,22 @@ use ratatui::widgets::Widget;
use crate::key_hint;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use super::search_mode::SearchMode;
pub(super) fn render_footer(area: Rect, buf: &mut Buffer, search_mode: SearchMode) {
let right_line = search_mode_indicator_line(search_mode);
let right_width = right_line.width() as u16;
let gap = u16::from(right_width > 0);
let left_width = area.width.saturating_sub(right_width).saturating_sub(gap);
let left_line =
truncate_line_with_ellipsis_if_overflow(footer_hint_line(), left_width as usize);
left_line.render(
Rect {
x: area.x,
y: area.y,
width: left_width,
height: 1,
},
buf,
);
if right_width > 0 && right_width <= area.width {
right_line.render(
Rect {
x: area.x + area.width - right_width,
y: area.y,
width: right_width,
height: 1,
},
buf,
);
pub(super) fn render_footer(area: Rect, buf: &mut Buffer) {
let mut spans = Vec::new();
for (keys, label) in [
(vec![KeyCode::Enter, KeyCode::Tab], " insert · "),
(vec![KeyCode::Esc], " close · "),
(vec![KeyCode::Up, KeyCode::Down], " select · "),
(vec![KeyCode::Left, KeyCode::Right], " filter"),
] {
let keys = keys
.into_iter()
.map(|key| key_hint::plain(key).display_label())
.collect::<Vec<_>>()
.join("/");
spans.extend(key_hint::key_label_spans(&keys));
spans.push(label.dim());
}
}
fn footer_hint_line() -> Line<'static> {
Line::from(vec![
key_hint::plain(KeyCode::Enter).into(),
" insert · ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" close · ".dim(),
key_hint::plain(KeyCode::Left).into(),
"/".dim(),
key_hint::plain(KeyCode::Right).into(),
" switch search modes".dim(),
])
}
fn search_mode_indicator_line(active_search_mode: SearchMode) -> Line<'static> {
let modes = [
SearchMode::Results,
SearchMode::FilesystemOnly,
SearchMode::Tools,
];
let mut spans = Vec::with_capacity(modes.len() * 2 - 1);
for (index, search_mode) in modes.into_iter().enumerate() {
if index > 0 {
spans.push(" ".dim());
}
if search_mode == active_search_mode {
let label = format!("[{}]", search_mode.label());
let span = match search_mode {
SearchMode::Results | SearchMode::FilesystemOnly => label.cyan().bold(),
SearchMode::Tools => label.magenta().bold(),
};
spans.push(span);
} else {
spans.push(format!(" {} ", search_mode.label()).dim());
}
}
Line::from(spans)
let line = Line::from(spans);
truncate_line_with_ellipsis_if_overflow(line, usize::from(area.width)).render(area, buf);
}
@@ -1,3 +1,5 @@
//! Own live mention candidates and asynchronous file search while reserving a stable picker viewport.
use codex_file_search::FileMatch;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -7,6 +9,7 @@ use super::candidate::Candidate;
use super::candidate::SearchResult;
use super::candidate::Selection;
use super::filter::filtered_candidates;
use super::render::POPUP_HEIGHT;
use super::render::render_popup;
use super::search_mode::SearchMode;
use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS;
@@ -92,8 +95,7 @@ impl Popup {
}
pub(crate) fn calculate_required_height(&self, _width: u16) -> u16 {
let visible = self.rows.len().clamp(1, MAX_POPUP_ROWS);
(visible as u16).saturating_add(2)
POPUP_HEIGHT
}
/// Rebuilds cached rows and keeps selection valid after search inputs change.
@@ -120,6 +122,7 @@ impl WidgetRef for Popup {
&self.state,
self.file_search.empty_message(),
self.search_mode,
&self.query,
);
}
}
@@ -1,3 +1,9 @@
//! Render unified mentions with the shared picker frame and the live result catalog.
//!
//! Reserve the same result viewport across filters and searches. Preserve the
//! filesystem columns, fuzzy matches and right-aligned type metadata. Bound the
//! primary column so long names cannot crowd out distinguishing parent paths.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
@@ -7,17 +13,26 @@ use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthStr;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::style::accent_style;
use super::candidate::MentionType;
use super::candidate::SearchResult;
use super::candidate::Selection;
use super::footer::render_footer;
use super::search_mode::SearchMode;
use crate::bottom_pane::picker_style::render_scroll_indicators;
use crate::bottom_pane::picker_style::selection_style;
use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS;
use crate::bottom_pane::scroll_state::ScrollState;
use crate::bottom_pane::selection_picker_layout::PickerLayoutSizes;
use crate::bottom_pane::selection_picker_layout::picker_areas;
use crate::bottom_pane::selection_popup_common::RenderedRows;
use crate::bottom_pane::selection_tabs::render_filled_tab_bar;
/// Two header lines, header/tab gaps, tabs, query, two overflow rows and help.
pub(super) const POPUP_HEIGHT: u16 = MAX_POPUP_ROWS as u16 + 9;
pub(super) fn render_popup(
area: Rect,
@@ -26,36 +41,95 @@ pub(super) fn render_popup(
state: &ScrollState,
empty_message: &str,
search_mode: SearchMode,
query: &str,
) {
let (list_area, hint_area) = if area.height > 2 {
let hint_area = Rect {
x: area.x,
y: area.y + area.height - 1,
width: area.width,
height: 1,
};
let list_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height - 2,
};
(list_area, Some(hint_area))
} else {
(area, None)
};
render_rows(list_area, buf, rows, state, empty_message);
if let Some(hint_area) = hint_area {
let hint_area = Rect {
x: hint_area.x + 2,
y: hint_area.y,
width: hint_area.width.saturating_sub(2),
height: hint_area.height,
};
render_footer(hint_area, buf, search_mode);
ratatui::widgets::Clear.render(area, buf);
if area.is_empty() {
return;
}
// At very short heights, retain the selected result before adding controls.
if area.height < 4 {
render_rows(area, buf, rows, state, empty_message);
return;
}
let content = Rect {
height: area.height - 1,
..area
};
let [header, _, tabs, _, search, above, list, below, _, _] = picker_areas(
content,
PickerLayoutSizes {
header: 2,
header_gap: 1,
tabs: 1,
search: 1,
rows: MAX_POPUP_ROWS as u16,
side: 0,
},
);
let inset = |rect: Rect| Rect {
x: rect.x.saturating_add(/*rhs*/ 2).min(rect.right()),
width: rect.width.saturating_sub(/*rhs*/ 4),
..rect
};
for (offset, line) in [
Line::from("Mentions".bold()),
Line::from("Files, directories, plugins, skills and tasks".dim()),
]
.into_iter()
.take(usize::from(header.height))
.enumerate()
{
line.render(
inset(Rect {
y: header.y + offset as u16,
height: 1,
..header
}),
buf,
);
}
let modes = [
SearchMode::Results,
SearchMode::FilesystemOnly,
SearchMode::Tools,
];
let active = modes
.iter()
.position(|mode| *mode == search_mode)
.unwrap_or_default();
render_filled_tab_bar(&modes.map(SearchMode::label), active, inset(tabs), buf);
let query = if query.is_empty() {
"Type to search mentions"
} else {
query
};
truncate_line_with_ellipsis_if_overflow(
Line::from(query.to_owned().dim()),
usize::from(inset(search).width),
)
.render(inset(search), buf);
let rendered = render_rows(list, buf, rows, state, empty_message);
if above.height > 0 && below.height > 0 {
render_scroll_indicators(
Rect {
y: above.y,
height: below.bottom() - above.y,
..area
},
buf,
rendered,
);
}
render_footer(
inset(Rect {
y: area.bottom() - 1,
height: 1,
..area
}),
buf,
);
}
fn render_rows(
@@ -64,19 +138,22 @@ fn render_rows(
rows: &[SearchResult],
state: &ScrollState,
empty_message: &str,
) {
if area.height == 0 {
return;
) -> RenderedRows {
if area.is_empty() {
return RenderedRows::default();
}
if rows.is_empty() {
Line::from(vec![" ".into(), empty_message.italic()]).render(area, buf);
return;
return RenderedRows {
lines: 1,
..Default::default()
};
}
let visible_items = MAX_POPUP_ROWS
.min(rows.len())
.min(area.height.max(1) as usize);
let mut start_idx = state.scroll_top.min(rows.len().saturating_sub(1));
let visible_items = MAX_POPUP_ROWS.min(rows.len()).min(usize::from(area.height));
let mut start_idx = state
.scroll_top
.min(rows.len().saturating_sub(visible_items));
if let Some(sel) = state.selected_idx {
if sel < start_idx {
start_idx = sel;
@@ -91,18 +168,16 @@ fn render_rows(
let mut cur_y = area.y;
let primary_column_width = rows
.iter()
.skip(start_idx)
.take(visible_items)
.map(primary_text_width)
.max()
.unwrap_or(0);
.unwrap_or(/*default*/ 0);
for (idx, row) in rows.iter().enumerate().skip(start_idx).take(visible_items) {
if cur_y >= area.y + area.height {
break;
}
let selected = Some(idx) == state.selected_idx;
let line = build_line(row, selected, area.width as usize, primary_column_width);
let line = build_line(row, selected, usize::from(area.width), primary_column_width);
line.render(
Rect {
x: area.x,
@@ -112,7 +187,13 @@ fn render_rows(
},
buf,
);
cur_y = cur_y.saturating_add(1);
cur_y = cur_y.saturating_add(/*rhs*/ 1);
}
RenderedRows {
lines: visible_items as u16,
items: visible_items,
has_above: start_idx > 0,
has_below: start_idx + visible_items < rows.len(),
}
}
@@ -126,10 +207,13 @@ fn build_line(
let dim_style = Style::default().dim();
let tag = row.mention_type.span(base_style);
let tag_width = tag.width();
let gutter = if selected { "> " } else { " " };
let gutter_width = gutter.len();
let gutter = if selected { "› " } else { " " };
let gutter_width = UnicodeWidthStr::width(gutter);
let content_width =
width.saturating_sub(gutter_width.saturating_add(tag_width).saturating_add(2));
// Keep room for secondary text even when an off-screen result has a long name.
let primary_column_width =
primary_column_width.min(content_width.saturating_sub(/*rhs*/ 2) / 2);
let content = truncate_line_with_ellipsis_if_overflow(
content_line(row, base_style, dim_style, primary_column_width),
content_width,
@@ -147,11 +231,16 @@ fn build_line(
}
spans.push(tag);
if selected {
let style = accent_style();
let style = selection_style();
spans.iter_mut().for_each(|span| span.style = style);
}
Line::from(spans)
let line = Line::from(spans);
if selected {
line.style(selection_style())
} else {
line
}
}
fn content_line(
@@ -160,17 +249,19 @@ fn content_line(
dim_style: Style,
primary_column_width: usize,
) -> Line<'static> {
let mut spans = Vec::new();
spans.extend(primary_spans(row, base_style));
let primary = Line::from(primary_spans(row, base_style));
if let Some(secondary) = secondary_line(row, base_style, dim_style) {
let primary = truncate_line_with_ellipsis_if_overflow(primary, primary_column_width);
let padding = primary_column_width
.saturating_sub(primary_text_width(row))
.saturating_add(2);
.saturating_sub(primary.width())
.saturating_add(/*rhs*/ 2);
let mut spans = primary.spans;
spans.push(" ".repeat(padding).set_style(dim_style));
spans.extend(secondary.spans);
Line::from(spans)
} else {
primary
}
Line::from(spans)
}
fn primary_spans(row: &SearchResult, base_style: Style) -> Vec<Span<'static>> {
@@ -185,7 +276,7 @@ fn primary_spans(row: &SearchResult, base_style: Style) -> Vec<Span<'static>> {
let mut spans = Vec::with_capacity(row.display_name.len());
let name_style = match row.mention_type {
MentionType::Plugin => base_style.magenta(),
MentionType::Plugin => base_style.fg(crate::style::accent_color()),
MentionType::Skill => base_style.dim(),
MentionType::Task => base_style.cyan(),
MentionType::File | MentionType::Directory => base_style,
@@ -267,8 +358,8 @@ fn path_spans(row: &SearchResult, base_style: Style) -> Vec<Span<'static>> {
fn primary_text_width(row: &SearchResult) -> usize {
file_name(row)
.map(|file_name| file_name.chars().count())
.unwrap_or_else(|| row.display_name.chars().count())
.map(UnicodeWidthStr::width)
.unwrap_or_else(|| UnicodeWidthStr::width(row.display_name.as_str()))
}
fn file_name(row: &SearchResult) -> Option<&str> {
@@ -299,3 +390,7 @@ fn file_name_start(row: &SearchResult) -> usize {
Selection::File(_) | Selection::Tool { .. } => usize::MAX,
}
}
#[cfg(test)]
#[path = "render_tests.rs"]
mod tests;
@@ -0,0 +1,203 @@
//! Render the live mention frame at full, clipped and narrow dimensions.
use super::*;
use pretty_assertions::assert_eq;
fn rows() -> Vec<SearchResult> {
let mut rows = (0..10)
.map(|index| SearchResult {
display_name: format!("Plugin {index:02}"),
description: Some("Tools and workflows".to_string()),
mention_type: MentionType::Plugin,
selection: Selection::Tool {
insert_text: format!("plugin-{index}"),
path: None,
},
match_indices: Some(vec![0]),
score: 0,
})
.collect::<Vec<_>>();
for name in ["src/界.rs", "src/plain.rs"] {
rows.push(SearchResult {
display_name: name.to_string(),
description: None,
mention_type: MentionType::File,
selection: Selection::File(name.into()),
match_indices: None,
score: 0,
});
}
rows
}
fn text(buffer: &Buffer) -> String {
(buffer.area.y..buffer.area.bottom())
.map(|y| {
let mut line = String::new();
let mut x = buffer.area.x;
while x < buffer.area.right() {
let symbol = buffer[(x, y)].symbol();
line.push_str(symbol);
x += UnicodeWidthStr::width(symbol).max(/*other*/ 1) as u16;
}
line.trim_end().to_owned()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn live_picker_frame_snapshots() {
let rows = rows();
for (name, width, height, selected) in [
("wide", 80, POPUP_HEIGHT, 0),
("narrow_scrolled", 40, POPUP_HEIGHT, rows.len() - 1),
("short", 40, 7, rows.len() - 1),
("one_row", 40, 1, rows.len() - 1),
] {
let mut buffer = Buffer::empty(Rect::new(/*x*/ 0, /*y*/ 0, width, height));
render_popup(
buffer.area,
&mut buffer,
&rows,
&ScrollState {
selected_idx: Some(selected),
scroll_top: 0,
},
"no matches",
SearchMode::Results,
"",
);
insta::assert_snapshot!(name, text(&buffer));
}
}
#[test]
fn selection_covers_the_row_and_empty_filter_clears_previous_results() {
let mut buffer = Buffer::empty(Rect::new(
/*x*/ 0,
/*y*/ 0,
/*width*/ 80,
POPUP_HEIGHT,
));
render_popup(
buffer.area,
&mut buffer,
&rows(),
&ScrollState {
selected_idx: Some(0),
scroll_top: 0,
},
"no matches",
SearchMode::Results,
"",
);
let selected_y = (0..buffer.area.height)
.find(|y| buffer[(0, *y)].symbol() == "›")
.expect("selected result");
let selected_styles = (0..buffer.area.width)
.map(|x| {
let cell = &buffer[(x, selected_y)];
(cell.fg, cell.bg, cell.modifier)
})
.collect::<Vec<_>>();
let style = selection_style();
let expected = (
style.fg.unwrap_or(Color::Reset),
style.bg.unwrap_or(Color::Reset),
style.add_modifier,
);
assert_eq!(
selected_styles,
vec![expected; usize::from(buffer.area.width)]
);
render_popup(
buffer.area,
&mut buffer,
&[],
&ScrollState::new(),
"loading...",
SearchMode::FilesystemOnly,
"new-query",
);
insta::assert_snapshot!("empty_filter", text(&buffer));
assert!(buffer.content.iter().all(|cell| {
cell.bg == Color::Reset
|| cell.bg
== crate::bottom_pane::picker_style::active_tab_style()
.bg
.unwrap_or(Color::Reset)
}));
}
#[test]
fn filesystem_columns_use_terminal_cell_widths() {
let rows = rows()
.into_iter()
.filter(|row| row.mention_type == MentionType::File)
.collect::<Vec<_>>();
let mut buffer = Buffer::empty(Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 2,
));
render_rows(
buffer.area,
&mut buffer,
&rows,
&ScrollState::new(),
"no matches",
);
let columns = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width - 3)
.find(|x| {
(0..4)
.map(|offset| buffer[(*x + offset, y)].symbol())
.collect::<String>()
== "src/"
})
.expect("parent path")
})
.collect::<Vec<_>>();
assert_eq!(columns, vec![12, 12]);
}
#[test]
fn long_mentions_leave_room_for_distinguishing_parent_paths() {
let mut rows = rows();
rows[0].display_name = "Long off-screen plugin name ".repeat(/*n*/ 16);
for name in [
"src/界.rs",
"tests/界.rs",
"deep/a_very_long_filename_that_exceeds_the_column.rs",
] {
rows.push(SearchResult {
display_name: name.to_string(),
description: None,
mention_type: MentionType::File,
selection: Selection::File(name.into()),
match_indices: None,
score: 0,
});
}
let first_file = rows.len() - 3;
for (name, width) in [
("bounded_filesystem_columns_narrow", 40),
("bounded_filesystem_columns_wide", 80),
] {
let mut buffer = Buffer::empty(Rect::new(
/*x*/ 0, /*y*/ 0, width, /*height*/ 3,
));
render_rows(
buffer.area,
&mut buffer,
&rows,
&ScrollState {
selected_idx: Some(first_file),
scroll_top: first_file,
},
"no matches",
);
insta::assert_snapshot!(name, text(&buffer));
}
}
@@ -0,0 +1,7 @@
---
source: tui/src/bottom_pane/mentions_v2/render_tests.rs
expression: text(&buffer)
---
› 界.rs src/ File
界.rs tests/ File
a_very_long_f… deep/ File
@@ -0,0 +1,7 @@
---
source: tui/src/bottom_pane/mentions_v2/render_tests.rs
expression: text(&buffer)
---
› 界.rs src/ File
界.rs tests/ File
a_very_long_filename_that_exceeds… deep/ File
@@ -0,0 +1,21 @@
---
source: tui/src/bottom_pane/mentions_v2/render_tests.rs
expression: text(&buffer)
---
Mentions
Files, directories, plugins, skills and tasks
All Results Filesystem Only Plugins
new-query
loading...
enter/tab insert · esc close · ↑/↓ select · ←/→ filter
@@ -0,0 +1,21 @@
---
source: tui/src/bottom_pane/mentions_v2/render_tests.rs
expression: text(&buffer)
---
Mentions
Files, directories, plugins, skills
All Results Filesystem Only ›
Type to search mentions
↑
Plugin 04 Tools and workflows Plugin
Plugin 05 Tools and workflows Plugin
Plugin 06 Tools and workflows Plugin
Plugin 07 Tools and workflows Plugin
Plugin 08 Tools and workflows Plugin
Plugin 09 Tools and workflows Plugin
界.rs src/ File
› plain.rs src/ File
enter/tab insert · esc close · ↑/↓ …
@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/mentions_v2/render_tests.rs
expression: text(&buffer)
---
› plain.rs src/ File
@@ -0,0 +1,12 @@
---
source: tui/src/bottom_pane/mentions_v2/render_tests.rs
assertion_line: 71
expression: text(&buffer)
---
Mentions
Files, directories, plugins, skills
All Results Filesystem Only ›
Type to search mentions
界.rs src/ File
› plain.rs src/ File
enter/tab insert · esc close · ↑/↓ …
@@ -0,0 +1,21 @@
---
source: tui/src/bottom_pane/mentions_v2/render_tests.rs
expression: text(&buffer)
---
Mentions
Files, directories, plugins, skills and tasks
All Results Filesystem Only Plugins
Type to search mentions
› Plugin 00 Tools and workflows Plugin
Plugin 01 Tools and workflows Plugin
Plugin 02 Tools and workflows Plugin
Plugin 03 Tools and workflows Plugin
Plugin 04 Tools and workflows Plugin
Plugin 05 Tools and workflows Plugin
Plugin 06 Tools and workflows Plugin
Plugin 07 Tools and workflows Plugin
↓
enter/tab insert · esc close · ↑/↓ select · ←/→ filter
@@ -38,6 +38,7 @@ pub(crate) struct GenericDisplayRow {
pub display_shortcut: Option<ShortcutHint>,
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
pub description: Option<String>, // optional grey text after the name
/// Required result identity, retained even when the description column hides.
pub category_tag: Option<String>,
pub disabled_reason: Option<String>, // optional disabled message
pub is_disabled: bool,
@@ -277,7 +278,7 @@ fn wrap_standard_row(
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
let full_line = build_full_line(row, desc_col, description_layout);
let full_line = build_full_line(row, desc_col, width, description_layout);
let continuation_indent = wrap_indent(row, desc_col, width);
let options = RtOptions::new(width.max(1) as usize)
.initial_indent(Line::from(""))
@@ -616,31 +617,6 @@ pub(crate) fn render_rows_with_col_width_mode(
)
}
/// Render rows as a single line each (no wrapping), truncating overflow with an ellipsis.
///
/// This path always uses viewport-local width alignment and is best for dense
/// list UIs where multi-line descriptions would add too much vertical churn.
/// Returns the number of terminal lines actually rendered.
pub(crate) fn render_rows_single_line(
area: Rect,
buf: &mut Buffer,
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
empty_message: &str,
) -> u16 {
render_rows_single_line_with_col_width_mode(
area,
buf,
rows_all,
state,
max_results,
empty_message,
ColumnWidthConfig::default(),
)
.lines
}
/// Render a list of rows as a single line each (no wrapping), truncating overflow with an
/// ellipsis while honoring the configured column width behavior.
/// Returns the terminal lines and selectable items actually rendered.
@@ -695,7 +671,8 @@ pub(crate) fn render_rows_single_line_with_col_width_mode(
break;
}
let mut full_line = build_full_line(row, desc_col, column_width.description_layout);
let mut full_line =
build_full_line(row, desc_col, area.width, column_width.description_layout);
apply_row_state_style(
std::slice::from_mut(&mut full_line),
Some(i) == state.selected_idx && !row.is_disabled,
@@ -957,8 +934,13 @@ mod tests {
match_indices: match_index.map(|index| vec![index]),
..Default::default()
};
let text =
build_full_line(&row, desc_col, SelectionDescriptionLayout::Columns).to_string();
let text = build_full_line(
&row,
desc_col,
/*width*/ 80,
SelectionDescriptionLayout::Columns,
)
.to_string();
assert!(text.starts_with(expected), "unexpected row: {text:?}");
}
@@ -987,6 +969,7 @@ mod tests {
let line = build_full_line(
row,
/*desc_col*/ 4,
area.width,
SelectionDescriptionLayout::Columns,
);
let name = line.spans.first().expect("fuzzy-matched name span");
@@ -118,27 +118,64 @@ fn append_shortcut(row: &GenericDisplayRow, spans: &mut Vec<Span<'static>>) {
pub(super) fn build_full_line(
row: &GenericDisplayRow,
desc_col: usize,
width: u16,
description_layout: SelectionDescriptionLayout,
) -> Line<'static> {
let description = (desc_col > 0)
.then(|| combined_description(row, description_layout))
.flatten();
let name_prefix_width = line_width(&Line::from(row.name_prefix_spans.clone()));
let name_limit = description
.as_ref()
.map(|_| desc_col.saturating_sub(2).saturating_sub(name_prefix_width))
// Category tags identify the result even when secondary descriptions hide.
// Reserve their cells before truncating the name on narrow rows.
let metadata_col = row
.category_tag
.as_deref()
.map(|tag| {
let desired_col = if desc_col > 0 {
desc_col
} else {
name_prefix_width + display_width(&row.name) + 2
};
desired_col.min(usize::from(width).saturating_sub(display_width(tag)))
})
.or_else(|| description.as_ref().map(|_| desc_col));
let name_limit = metadata_col
.map(|column| {
column
.saturating_sub(/*rhs*/ 2)
.saturating_sub(name_prefix_width)
})
.unwrap_or(usize::MAX);
let name_spans = build_name_spans(row, name_limit);
let name_limit = if row.category_tag.is_some() && display_width(&row.name) > name_limit {
name_limit.saturating_sub(/*rhs*/ 1)
} else {
name_limit
};
let name_spans = if row.category_tag.is_some()
&& metadata_col.is_some_and(|column| column <= name_prefix_width)
{
Vec::new()
} else {
build_name_spans(row, name_limit)
};
let name_width = name_prefix_width + line_width(&Line::from(name_spans.clone()));
let mut spans = row.name_prefix_spans.clone();
spans.extend(name_spans);
append_shortcut(row, &mut spans);
if let Some(description) = description {
let gap = desc_col.saturating_sub(name_width);
if let Some(metadata_col) = metadata_col {
let gap = metadata_col.saturating_sub(name_width);
if gap > 0 {
spans.push(" ".repeat(gap).into());
}
}
if let Some(tag) = &row.category_tag {
spans.push(tag.clone().dim());
if description.is_some() {
spans.push(" ".dim());
}
}
if let Some(description) = description {
spans.push(description.dim());
}
Line::from(spans)
+11 -3
View File
@@ -82,10 +82,15 @@ pub(super) fn render_tab_bar(
}
}
/// Render a fixed-height tab strip while keeping the active tab visible.
fn render_filled_tab_bar(labels: &[&str], active_idx: usize, area: Rect, buf: &mut Buffer) {
/// Render a fixed-height tab strip and return the visible tab hit targets.
pub(crate) fn render_filled_tab_bar(
labels: &[&str],
active_idx: usize,
area: Rect,
buf: &mut Buffer,
) -> Vec<(usize, Rect)> {
if labels.is_empty() || area.is_empty() {
return;
return Vec::new();
}
let active_idx = active_idx.min(labels.len() - 1);
let widths = labels
@@ -117,6 +122,7 @@ fn render_filled_tab_bar(labels: &[&str], active_idx: usize, area: Rect, buf: &m
x += 2;
}
let right = area.right().saturating_sub(u16::from(show_right) * 2);
let mut regions = Vec::new();
for idx in start..end {
let width = widths[idx].min(usize::from(right.saturating_sub(x))) as u16;
let line = Line::from(format!(" {} ", labels[idx]));
@@ -128,6 +134,7 @@ fn render_filled_tab_bar(labels: &[&str], active_idx: usize, area: Rect, buf: &m
};
let tab = Rect::new(x, area.y, width, /*height*/ 1);
line.render(tab, buf);
regions.push((idx, tab));
x = x.saturating_add(width).saturating_add(/*rhs*/ 1);
}
if show_right {
@@ -141,6 +148,7 @@ fn render_filled_tab_bar(labels: &[&str], active_idx: usize, area: Rect, buf: &m
buf,
);
}
regions
}
fn tab_bar_lines(tabs: &[SelectionTab], active_idx: usize, width: u16) -> Vec<Line<'static>> {
+54 -36
View File
@@ -3,17 +3,16 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
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;
use crate::key_hint;
use crate::render::Insets;
use crate::render::RectExt;
use crate::text_formatting::truncate_text;
use codex_utils_fuzzy_match::fuzzy_match;
@@ -88,7 +87,7 @@ impl SkillPopup {
pub(crate) fn calculate_required_height(&self, _width: u16) -> u16 {
let rows = self.rows_from_matches(self.filtered());
let visible = rows.len().clamp(1, MAX_POPUP_ROWS);
(visible as u16).saturating_add(2)
(visible as u16).saturating_add(/*rhs*/ 3)
}
pub(crate) fn move_up(&mut self) {
@@ -126,30 +125,29 @@ impl SkillPopup {
) -> Vec<GenericDisplayRow> {
matches
.into_iter()
.map(|(idx, indices, _score)| {
.enumerate()
.map(|(visible_idx, (idx, indices, _score))| {
let mention = &self.mentions[idx];
let name = truncate_text(&mention.display_name, MENTION_NAME_TRUNCATE_LEN);
let description = match (
mention.category_tag.as_deref(),
mention.description.as_deref(),
) {
(Some(tag), Some(description)) if !description.is_empty() => {
Some(format!("{tag} {description}"))
}
(Some(tag), _) => Some(tag.to_string()),
(None, Some(description)) if !description.is_empty() => {
Some(description.to_string())
}
_ => None,
};
GenericDisplayRow {
selection_style: None,
category_tag: mention.category_tag.clone(),
selection_style: Some(super::picker_style::selection_style()),
name,
name_prefix_spans: Vec::new(),
name_prefix_spans: vec![
if self.state.selected_idx == Some(visible_idx) {
"› "
} else {
" "
}
.into(),
],
match_indices: indices,
display_shortcut: None,
description,
category_tag: None,
description: mention
.description
.as_ref()
.filter(|description| !description.is_empty())
.cloned(),
is_disabled: false,
disabled_reason: None,
wrap_indent: None,
@@ -201,22 +199,17 @@ impl SkillPopup {
impl WidgetRef for SkillPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let (list_area, hint_area) = if area.height > 2 {
let [list_area, _spacer_area, hint_area] = Layout::vertical([
Constraint::Length(area.height - 2),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(area);
let (list_area, hint_area) = if area.height > 1 {
let [list_area, hint_area] =
Layout::vertical([Constraint::Length(area.height - 1), Constraint::Length(1)])
.areas(area);
(list_area, Some(hint_area))
} else {
(area, None)
};
let rows = self.rows_from_matches(self.filtered());
render_rows_single_line(
list_area.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
list_area,
buf,
&rows,
&self.state,
@@ -237,11 +230,10 @@ impl WidgetRef for SkillPopup {
fn skill_popup_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to insert or ".into(),
" insert · ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
" close".dim(),
])
}
@@ -310,7 +302,7 @@ mod tests {
);
assert_eq!(
popup.calculate_required_height(72),
(MAX_POPUP_ROWS as u16) + 2
(MAX_POPUP_ROWS as u16) + 3
);
}
@@ -332,6 +324,32 @@ mod tests {
insta::assert_snapshot!("skill_popup_scrolled", render_popup(&popup, /*width*/ 72));
}
#[test]
fn category_tags_distinguish_mentions_when_descriptions_hide() {
let mut snapshots = Vec::new();
for name in ["Shared", "Shared mention with a very long name"] {
let mut popup = SkillPopup::new(
[("[App]", 0), ("[Skill]", 1)]
.into_iter()
.map(|(tag, rank)| MentionItem {
description: Some("Secondary details".to_string()),
..ranked_mention_item(name, &[], tag, rank)
})
.collect(),
);
popup.set_query("Shared");
for width in [32, 72] {
let rendered = render_popup(&popup, width);
assert!(rendered.contains("[App]"), "{rendered}");
assert!(rendered.contains("[Skill]"), "{rendered}");
assert_eq!(rendered.contains("Secondary details"), width == 72);
snapshots.push(format!("name={name:?}, width={width}\n{rendered}"));
}
}
insta::assert_snapshot!("skill_popup_category_tags", snapshots.join("\n\n"));
}
#[test]
fn lowercase_expansion_preserves_match_ranking_and_highlighting() {
let mut popup = SkillPopup::new(vec![
@@ -1,13 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5348
expression: terminal.backend()
---
" "
"› @sa "
" "
" "
"> Sample Plugin Plugin with skills and an MCP server Plugin"
" other (sample) Example skill used in tests. Skill "
" notes (sample) Example skill used in tests. Skill "
" "
" enter insert · esc close · ←/→ switch search modes [All Results] Filesystem Only Plugins "
" Mentions "
" Files, directories, plugins, skills and tasks "
" All Results Filesystem Only Plugins "
" sa "
"› Sample Plugin Plugin with skills and an MCP server Plugin"
" enter/tab insert · esc close · ↑/↓ select · ←/→ filter "
@@ -9,5 +9,5 @@ expression: terminal.backend()
" "
" "
" "
" "
" loading... "
" "
@@ -9,5 +9,5 @@ expression: terminal.backend()
" "
" "
" "
" "
" loading... "
" "
@@ -9,5 +9,5 @@ expression: terminal.backend()
" "
" "
" "
" "
" loading... "
" "
@@ -7,7 +7,7 @@ expression: terminal.backend()
" "
" "
" "
" Google Calendar [Skill] Find availability and plan event changes "
"› Google Calendar [Skill] Find availability and plan event changes "
" Google Calendar [App] Look up events and availability "
" "
" Press enter to insert or esc to close "
" enter insert · esc close "
@@ -8,6 +8,6 @@ expression: terminal.backend()
" "
" "
" "
" Sample Plugin [Plugin] Plugin that includes the Figma MCP server and Skills for common workflows "
"› Sample Plugin [Plugin] Plugin that includes the Figma MCP server and Skills for common workflows "
" "
" Press enter to insert or esc to close "
" enter insert · esc close "
@@ -8,6 +8,6 @@ expression: terminal.backend()
" "
" "
" "
" 1password [Skill] Example skill used in tests. "
"› 1password [Skill] Example skill used in tests. "
" "
" Press enter to insert or esc to close "
" enter insert · esc close "
@@ -8,6 +8,6 @@ expression: terminal.backend()
" "
" "
" "
" left [Skill] Example skill used in tests. "
"› left [Skill] Example skill used in tests. "
" "
" Press enter to insert or esc to close "
" enter insert · esc close "
@@ -8,6 +8,6 @@ expression: terminal.backend()
" "
" "
" "
" deploy (plugin) [Skill] Example skill used in tests. "
"› deploy (plugin) [Skill] Example skill used in tests. "
" "
" Press enter to insert or esc to close "
" enter insert · esc close "
@@ -8,6 +8,6 @@ expression: terminal.backend()
" "
" "
" "
" unbound-skill [Skill] Example skill used in tests. "
"› unbound-skill [Skill] Example skill used in tests. "
" "
" Press enter to insert or esc to close "
" enter insert · esc close "
@@ -8,6 +8,6 @@ expression: terminal.backend()
" "
" "
" "
" unbound-skill [Skill] Example skill used in tests. "
"› unbound-skill [Skill] Example skill used in tests. "
" "
" Press enter to insert or esc to close "
" enter insert · esc close "
@@ -6,4 +6,4 @@ expression: terminal.backend()
"› /ar "
" "
" "
" /archive archive this session "
"› /archive archive this session "
@@ -6,4 +6,4 @@ expression: terminal.backend()
"› /bt "
" "
" "
" /btw start a side conversation in an ephemeral fork "
"› /btw start a side conversation in an ephemeral fork "
@@ -6,4 +6,4 @@ expression: terminal.backend()
"› /mo "
" "
" "
" /model choose what model and reasoning effort to use "
"› /model choose what model and reasoning effort to use "
@@ -6,4 +6,4 @@ expression: terminal.backend()
"› /pet "
" "
" "
" /pets choose or hide the terminal pet "
"› /pets choose or hide the terminal pet "
@@ -7,4 +7,4 @@ expression: terminal.backend()
" "
" "
" "
" /resume resume a saved chat "
"› /resume resume a saved chat "
@@ -6,4 +6,4 @@ expression: terminal.backend()
"› /si "
" "
" "
" /side start a side conversation in an ephemeral fork "
"› /side start a side conversation in an ephemeral fork "
@@ -1,13 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5348
expression: terminal.backend()
---
" "
"› @migr "
" "
" "
" "
"> Review database migration /workspace/project Task "
" migration /another/project Task "
" "
" enter insert · esc close · ←/→ switch search modes [All Results] Filesystem Only Plugins "
" Mentions "
" Files, directories, plugins, skills and tasks "
" All Results Filesystem Only Plugins "
" migr "
"› Review database migration /workspace/project Task "
" enter/tab insert · esc close · ↑/↓ select · ←/→ filter "
@@ -1,13 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5348
expression: terminal.backend()
---
" "
"› @left @bound "
" "
" "
" "
" "
"> left Plugin used to test bound mention fallback. Plugin"
" "
" enter insert · esc close · ←/→ switch search modes [All Results] Filesystem Only Plugins "
" Mentions "
" Files, directories, plugins, skills and tasks "
" All Results Filesystem Only Plugins "
" left "
"› left Plugin used to test bound mention fallback. Plugin"
" enter/tab insert · esc close · ↑/↓ select · ←/→ filter "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" /diff show git diff (including untracked files) "
"› /diff show git diff (including untracked files) "
@@ -5,13 +5,14 @@ expression: "format!(\"{buf:?}\")"
Buffer {
area: Rect { x: 0, y: 0, width: 72, height: 2 },
content: [
" /app continue this session in the Desktop app ",
"› /app continue this session in the Desktop app ",
" /approve approve one retry of a recent auto-review denial ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 52, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 12, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 52, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 6, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 12, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
@@ -1,5 +1,6 @@
---
source: tui/src/bottom_pane/command_popup.rs
assertion_line: 523
expression: commands
---
/model - choose what model and reasoning effort to use
@@ -5,14 +5,15 @@ expression: "format!(\"{buf:?}\")"
Buffer {
area: Rect { x: 0, y: 0, width: 72, height: 3 },
content: [
" /status show current session configuration and token usage ",
"› /status show current session configuration and token usage ",
" /statusline configure which items appear in the status line ",
" /stop stop all background terminals ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 65, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 15, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 65, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 5, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 15, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
@@ -5,11 +5,11 @@ expression: "format!(\"{buf:?}\")"
Buffer {
area: Rect { x: 0, y: 0, width: 72, height: 1 },
content: [
" /voice start or stop voice; use /voice settings to choose a voice ",
"› /voice start or stop voice; use /voice settings to choose a voice ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 68, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 10, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 68, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
]
}
@@ -6,16 +6,17 @@ width 32
Buffer {
area: Rect { x: 0, y: 0, width: 32, height: 3 },
content: [
" /tier-one Use faster ",
"› /tier-one Use faster ",
" inference ",
" /tier-two Keep default speed ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 23, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 22, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 13, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 23, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 22, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
@@ -27,12 +28,12 @@ width 33
Buffer {
area: Rect { x: 0, y: 0, width: 33, height: 2 },
content: [
" /tier-one Use faster inference",
"› /tier-one Use faster inference",
" /tier-two Keep default speed ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 13, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
@@ -45,13 +46,14 @@ width 34
Buffer {
area: Rect { x: 0, y: 0, width: 34, height: 2 },
content: [
" /tier-one Use faster inference ",
"› /tier-one Use faster inference ",
" /tier-two Keep default speed ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 33, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 13, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 33, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
@@ -0,0 +1,57 @@
---
source: tui/src/bottom_pane/file_search_popup.rs
assertion_line: 291
expression: "snapshots.join(\"\\n\\n\")"
---
["src", "shared", "long_directory"], width 28
› src/share…/parser_alpha.rs
src/shared…/parser_beta.rs
["src", "shared", "long_directory"], width 40
› src/shared/long_direc…/parser_alpha.rs
src/shared/long_direct…/parser_beta.rs
["src", "shared", "long_directory"], width 80
› src/shared/long_directory/parser_alpha.rs
src/shared/long_directory/parser_beta.rs
["long_directory_name_that_fills_row"], width 28
› long_dire…/parser_alpha.rs
long_direc…/parser_beta.rs
["long_directory_name_that_fills_row"], width 40
› long_directory_name_t…/parser_alpha.rs
long_directory_name_th…/parser_beta.rs
["long_directory_name_that_fills_row"], width 80
› long_directory_name_that_fills_row/parser_alpha.rs
long_directory_name_that_fills_row/parser_beta.rs
["長いディレクトリ名", "e\u{301}tudes"], width 28
› 長 い デ ィ …/parser_alpha.rs
長 い デ ィ レ …/parser_beta.rs
["長いディレクトリ名", "e\u{301}tudes"], width 40
› 長 い デ ィ レ ク ト リ 名 /ét…/parser_alpha.rs
長 い デ ィ レ ク ト リ 名 /étu…/parser_beta.rs
["長いディレクトリ名", "e\u{301}tudes"], width 80
› 長 い デ ィ レ ク ト リ 名 /études/parser_alpha.rs
長 い デ ィ レ ク ト リ 名 /études/parser_beta.rs
@@ -0,0 +1,114 @@
---
source: tui/src/bottom_pane/skill_popup.rs
expression: "snapshots.join(\"\\n\\n\")"
---
name="Shared", width=32
Buffer {
area: Rect { x: 0, y: 0, width: 32, height: 5 },
content: [
" ",
"› Shared [App] ",
" Shared [Skill] ",
" ",
" enter insert · esc close ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 10, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 15, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 8, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 10, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 17, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 17, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 20, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 26, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
name="Shared", width=72
Buffer {
area: Rect { x: 0, y: 0, width: 72, height: 5 },
content: [
" ",
"› Shared [App] Secondary details ",
" Shared [Skill] Secondary details ",
" ",
" enter insert · esc close ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 10, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 33, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 8, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 10, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 35, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 17, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 20, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 26, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
name="Shared mention with a very long name", width=32
Buffer {
area: Rect { x: 0, y: 0, width: 32, height: 5 },
content: [
" ",
"› Shared mention with a … [App]",
" Shared mention with … [Skill]",
" ",
" enter insert · esc close ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 8, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 25, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 17, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 20, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 26, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
name="Shared mention with a very long name", width=72
Buffer {
area: Rect { x: 0, y: 0, width: 72, height: 5 },
content: [
" ",
"› Shared mention with a ver... [App] Secondary details ",
" Shared mention with a ver... [Skill] Secondary details ",
" ",
" enter insert · esc close ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 32, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 55, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 8, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 32, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 57, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 17, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 20, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 26, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -3,34 +3,38 @@ source: tui/src/bottom_pane/skill_popup.rs
expression: "render_popup(&popup, 48)"
---
Buffer {
area: Rect { x: 0, y: 0, width: 48, height: 6 },
area: Rect { x: 0, y: 0, width: 48, height: 7 },
content: [
" ai̇x [Skill] ",
" ",
"› ai̇x [Skill] ",
" aİx [Skill] ",
" i̇x [Skill] ",
" İx [Skill] ",
" ",
" Press enter to insert or esc to close ",
" enter insert · esc close ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 14, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 5, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 7, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 7, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 5, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 7, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 7, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 8, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 13, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 27, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 30, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 4, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 7, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 17, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 20, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 26, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -3,8 +3,9 @@ source: tui/src/bottom_pane/skill_popup.rs
expression: "render_popup(&popup, 72)"
---
Buffer {
area: Rect { x: 0, y: 0, width: 72, height: 10 },
area: Rect { x: 0, y: 0, width: 72, height: 11 },
content: [
"↑ ",
" Mention 01 [Skill] Description 01 ",
" Mention 02 [Skill] Description 02 ",
" Mention 03 [Skill] Description 03 ",
@@ -12,14 +13,12 @@ Buffer {
" Mention 05 [Skill] Description 05 ",
" Mention 06 [Skill] Description 06 ",
" Mention 07 [Skill] Description 07 ",
" Mention 08 [Skill] Description 08 ",
" ",
" Press enter to insert or esc to close ",
"› Mention 08 [Skill] Description 08 ",
"↓ ",
" enter insert · esc close ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
@@ -32,11 +31,16 @@ Buffer {
x: 36, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 14, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 8, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 13, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 27, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 30, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 14, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 36, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 17, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 20, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 26, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -1,13 +1,13 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
assertion_line: 2317
expression: snapshot
---
› $
Google Drive [App] Connected files
Press enter to insert or esc to close
› Google Drive [App] Connected files
enter insert · esc close
--- plugin authentication ---
Plugins
@@ -1,8 +1,7 @@
---
source: tui/src/chatwidget/tests/slash_commands.rs
assertion_line: 3091
source: tui/src/chatwidget/tests/worktree_picker_tests.rs
expression: popup
---
› /work
/worktree start or continue a conversation in a new worktree
› /worktree start or continue a conversation in a new worktree