mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
+1
-1
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+7
@@ -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
|
||||
+7
@@ -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
|
||||
+21
@@ -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
|
||||
+21
@@ -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 · ↑/↓ …
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/mentions_v2/render_tests.rs
|
||||
expression: text(&buffer)
|
||||
---
|
||||
› plain.rs src/ File
|
||||
+12
@@ -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 · ↑/↓ …
|
||||
+21
@@ -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)
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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![
|
||||
|
||||
+7
-6
@@ -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 "
|
||||
|
||||
+1
-1
@@ -9,5 +9,5 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" loading... "
|
||||
" "
|
||||
|
||||
+1
-1
@@ -9,5 +9,5 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" loading... "
|
||||
" "
|
||||
|
||||
+1
-1
@@ -9,5 +9,5 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" loading... "
|
||||
" "
|
||||
|
||||
+2
-2
@@ -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 "
|
||||
|
||||
+2
-2
@@ -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 "
|
||||
|
||||
+2
-2
@@ -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 "
|
||||
|
||||
+2
-2
@@ -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 "
|
||||
|
||||
+2
-2
@@ -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 "
|
||||
|
||||
+2
-2
@@ -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 "
|
||||
|
||||
+2
-2
@@ -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 "
|
||||
|
||||
+1
-1
@@ -6,4 +6,4 @@ expression: terminal.backend()
|
||||
"› /ar "
|
||||
" "
|
||||
" "
|
||||
" /archive archive this session "
|
||||
"› /archive archive this session "
|
||||
|
||||
+1
-1
@@ -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 "
|
||||
|
||||
+1
-1
@@ -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 "
|
||||
|
||||
+1
-1
@@ -6,4 +6,4 @@ expression: terminal.backend()
|
||||
"› /pet "
|
||||
" "
|
||||
" "
|
||||
" /pets choose or hide the terminal pet "
|
||||
"› /pets choose or hide the terminal pet "
|
||||
|
||||
+1
-1
@@ -7,4 +7,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" /resume resume a saved chat "
|
||||
"› /resume resume a saved chat "
|
||||
|
||||
+1
-1
@@ -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 "
|
||||
|
||||
+7
-6
@@ -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 "
|
||||
|
||||
+7
-6
@@ -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 "
|
||||
|
||||
+1
-1
@@ -10,4 +10,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" /diff show git diff (including untracked files) "
|
||||
"› /diff show git diff (including untracked files) "
|
||||
|
||||
+5
-4
@@ -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
@@ -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
-4
@@ -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,
|
||||
|
||||
+4
-4
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
+15
-13
@@ -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,
|
||||
|
||||
+57
@@ -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
|
||||
+114
@@ -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,
|
||||
]
|
||||
}
|
||||
+19
-15
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
+15
-11
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user