docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
This commit is contained in:
@@ -31,7 +31,6 @@ use crate::theme::Theme;
|
||||
struct StatusEntry {
|
||||
/// Identifier for hit-test lookup (e.g., "context", "badge").
|
||||
id: &'static str,
|
||||
/// Pre-built styled content.
|
||||
line: Line<'static>,
|
||||
/// Display width in columns.
|
||||
width: u16,
|
||||
@@ -49,7 +48,6 @@ pub struct AgentStatusBar<'a> {
|
||||
}
|
||||
|
||||
impl<'a> AgentStatusBar<'a> {
|
||||
/// Create a new empty status bar.
|
||||
pub fn new(theme: &'a Theme) -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
@@ -88,11 +86,10 @@ impl<'a> AgentStatusBar<'a> {
|
||||
return HashMap::new();
|
||||
}
|
||||
|
||||
// Fill background
|
||||
buf.set_style(area, Style::default().bg(self.theme.bg_base));
|
||||
|
||||
let sep = self.separator();
|
||||
let sep_w = sep.width() as u16; // 3
|
||||
let sep_w = sep.width() as u16;
|
||||
|
||||
// Total width: items plus the separators *between* them only — no
|
||||
// leading separator before the first item or trailing one after the
|
||||
@@ -116,7 +113,6 @@ impl<'a> AgentStatusBar<'a> {
|
||||
x += sep_w;
|
||||
}
|
||||
|
||||
// Render item
|
||||
buf.set_line(x, area.y, &entry.line, entry.width);
|
||||
areas.insert(
|
||||
entry.id,
|
||||
@@ -134,10 +130,6 @@ impl<'a> AgentStatusBar<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Goal status line
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a token count compactly: `500`, `1.5k`, `50k`, `1.5M`.
|
||||
pub(crate) fn format_tokens_compact(tokens: i64) -> String {
|
||||
let sign = if tokens < 0 { "-" } else { "" };
|
||||
@@ -282,10 +274,6 @@ pub fn goal_status_line(
|
||||
])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph status chip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the compact `/graph` status chip: node progress, the current
|
||||
/// node, and spend. Same chip idiom as [`goal_status_line`] — dim
|
||||
/// brackets, paused chips invert onto `theme.warning`, active chips
|
||||
@@ -351,10 +339,6 @@ pub fn graph_status_line(
|
||||
])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP connecting indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the compact MCP-connecting indicator for the agent status bar.
|
||||
///
|
||||
/// Format: `⠋ MCP (1/4)` — a braille spinner (driven by `tick`, same cadence as
|
||||
@@ -805,9 +789,6 @@ mod tests {
|
||||
assert_eq!(goal_phase_label(&g), "Executing");
|
||||
}
|
||||
|
||||
// The old deliverable-index parity test is removed because deliverables
|
||||
// are no longer part of the simplified goal model.
|
||||
|
||||
#[test]
|
||||
fn goal_line_contains_expected_text() {
|
||||
let g = make_goal(
|
||||
|
||||
@@ -2838,8 +2838,8 @@ mod tests {
|
||||
assert!(s.search_active, "{code:?} must activate Agents-tab search");
|
||||
}
|
||||
}
|
||||
/// Personas symmetry: both `/` and `i` activate the shared search (the
|
||||
/// Personas tab now answers `/` too, matching the Agents tab).
|
||||
/// Personas symmetry: both `/` and `i` activate the shared search,
|
||||
/// matching the Agents tab.
|
||||
#[test]
|
||||
fn personas_tab_slash_and_i_activate_search() {
|
||||
for code in [KeyCode::Char('/'), KeyCode::Char('i')] {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! Provides ListPane-based navigation, search, visual-select, and copy.
|
||||
//!
|
||||
//! Supports thinking/agent message blocks (markdown content).
|
||||
//! Execute and edit viewers will be added in later phases.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind};
|
||||
use kigi_workspace::permission::mcp_titleize_segment;
|
||||
@@ -27,10 +26,6 @@ use crate::views::list_pane::{
|
||||
use crate::views::modal_window::ModalWindowState;
|
||||
use crate::views::shortcuts_bar::HintItem;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ContentLine — generic ListItem for the viewer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single line of content displayed in the block viewer's ListPane.
|
||||
#[derive(Clone)]
|
||||
pub struct ContentLine {
|
||||
@@ -81,10 +76,6 @@ impl ListItem for ContentLine {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DiffLineMeta — per-item diff metadata for edit viewer patch copy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Metadata for a single diff line, stored parallel to `items` in the edit viewer.
|
||||
pub struct DiffLineMeta {
|
||||
pub tag: similar::ChangeTag,
|
||||
@@ -93,10 +84,6 @@ pub struct DiffLineMeta {
|
||||
pub ln: usize,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BlockViewerPane
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// What kind of block content the viewer is showing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ViewerKind {
|
||||
@@ -131,13 +118,10 @@ pub enum ViewerKind {
|
||||
pub struct BlockViewerPane {
|
||||
/// Which scrollback entry we are viewing.
|
||||
pub entry_id: EntryId,
|
||||
/// What kind of content.
|
||||
pub kind: ViewerKind,
|
||||
/// ListPane state (scroll, selection, search, follow).
|
||||
pub list_state: ListPaneState,
|
||||
/// Visual style for the ListPane.
|
||||
list_style: ListPaneStyle,
|
||||
/// Content items for the ListPane.
|
||||
items: Vec<ContentLine>,
|
||||
/// Cached content area from last render (for mouse hit-testing).
|
||||
last_content_area: Rect,
|
||||
@@ -304,7 +288,6 @@ impl BlockViewerPane {
|
||||
let items = Self::build_execute_items(exec.output.as_deref(), &theme);
|
||||
let last_output_len = exec.output.as_ref().map_or(0, |o| o.len());
|
||||
|
||||
// Dark background style for terminal output
|
||||
let list_style = ListPaneStyle {
|
||||
uniform_visual_bg: true,
|
||||
..ListPaneStyle::default()
|
||||
@@ -322,7 +305,8 @@ impl BlockViewerPane {
|
||||
copy_meta_pending: false,
|
||||
copy_content_pending: false,
|
||||
diff_meta: Vec::new(),
|
||||
last_generation: last_output_len as u64, // reuse generation field for output length
|
||||
// Reuse the generation field for output length.
|
||||
last_generation: last_output_len as u64,
|
||||
was_running: entry.is_running,
|
||||
bg_task_id: None,
|
||||
last_theme: Theme::current_kind(),
|
||||
@@ -887,8 +871,8 @@ impl BlockViewerPane {
|
||||
// Block-owned dispatch so the viewer paints the same highlight phase
|
||||
// (incl. the file-scoped upgrade) as the scrollback output.
|
||||
let rendered = edit.render_diff_lines(
|
||||
theme, 500, // wide width — NoWrap mode
|
||||
&config,
|
||||
theme, 500, &config,
|
||||
/* wide width — NoWrap mode */
|
||||
);
|
||||
|
||||
// Build a flat list of DiffLine references from all hunks, interleaving
|
||||
@@ -897,7 +881,7 @@ impl BlockViewerPane {
|
||||
let mut meta_source: Vec<Option<&crate::diff::DiffLine>> = Vec::new();
|
||||
for (i, hunk) in edit.hunks.iter().enumerate() {
|
||||
if i > 0 && !config.hunk_separator.is_empty() {
|
||||
meta_source.push(None); // separator line
|
||||
meta_source.push(None);
|
||||
}
|
||||
for diff_line in hunk {
|
||||
meta_source.push(Some(diff_line));
|
||||
@@ -1086,14 +1070,11 @@ impl BlockViewerPane {
|
||||
hints
|
||||
}
|
||||
|
||||
// -- Input handling ------------------------------------------------------
|
||||
|
||||
/// Check if a key is a close signal (Esc/q/Ctrl-F).
|
||||
///
|
||||
/// Separated from `handle_key` so the caller can close the viewer
|
||||
/// before routing the key (avoids borrow conflicts).
|
||||
pub fn is_close_key(&self, key: &KeyEvent) -> bool {
|
||||
// Ctrl-F: close viewer (toggle off)
|
||||
if key.code == KeyCode::Char('f') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return true;
|
||||
}
|
||||
@@ -1173,7 +1154,6 @@ impl BlockViewerPane {
|
||||
out.push_str(&format!("--- a/{path}\n"));
|
||||
out.push_str(&format!("+++ b/{path}\n"));
|
||||
|
||||
// Collect non-None entries in the range
|
||||
let entries: Vec<&DiffLineMeta> = range
|
||||
.filter_map(|i| self.diff_meta.get(i).and_then(|m| m.as_ref()))
|
||||
.collect();
|
||||
@@ -1679,8 +1659,6 @@ fn line_display_width_u16(line: &Line<'_>) -> u16 {
|
||||
}
|
||||
|
||||
impl BlockViewerPane {
|
||||
// -- Rendering -----------------------------------------------------------
|
||||
|
||||
/// Render the viewer content into the given area (provided by modal chrome).
|
||||
///
|
||||
/// `content_area` is the inner content rect returned by `render_modal_window`.
|
||||
|
||||
@@ -159,14 +159,17 @@ pub fn btw_panel_height(state: Option<&BtwOverlayState>, content_width: u16) ->
|
||||
None => 0,
|
||||
Some(BtwOverlayState::Loading { .. } | BtwOverlayState::Error { .. }) => 3,
|
||||
Some(BtwOverlayState::Done { content, .. }) => {
|
||||
let cw = content_width.saturating_sub(4) as usize; // border + pad
|
||||
// `4` accounts for the left/right border plus one column of padding
|
||||
// on each side.
|
||||
let cw = content_width.saturating_sub(4) as usize;
|
||||
let total = if cw > 0 {
|
||||
content.with_wrapped_lines(cw, |w| w.lines.len())
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let body = total.clamp(1, DONE_MAX_BODY_LINES as usize) as u16;
|
||||
2 + body // top border + body + bottom border
|
||||
// top border + body + bottom border
|
||||
2 + body
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,7 +220,6 @@ pub fn render_btw_panel(
|
||||
};
|
||||
let border_style = Style::default().fg(border_color).bg(bg);
|
||||
|
||||
// ── Clear area and draw rounded border ──
|
||||
Clear.render(area, buf);
|
||||
buf.set_style(area, Style::default().bg(bg));
|
||||
Block::default()
|
||||
@@ -227,7 +229,6 @@ pub fn render_btw_panel(
|
||||
.style(Style::default().bg(bg))
|
||||
.render(area, buf);
|
||||
|
||||
// ── Hint in top border (right side): scroll position + [Esc] ──
|
||||
// Built BEFORE the title so the title can reserve room for it and truncate
|
||||
// the question, rather than the question pushing [Esc] off-screen. The close
|
||||
// affordance ([Esc]) always stays visible: its columns are reserved here
|
||||
@@ -275,7 +276,6 @@ pub fn render_btw_panel(
|
||||
hint_x = (area.x + area.width).saturating_sub(1 + hint_w);
|
||||
}
|
||||
|
||||
// ── Title in top border: " /btw <question> " ──
|
||||
// Reserve the hint's columns (everything left of `hint_x`, minus the title's
|
||||
// own two padding spaces) so a long question truncates instead of hiding the
|
||||
// hint.
|
||||
@@ -310,7 +310,6 @@ pub fn render_btw_panel(
|
||||
let title_render_w = (title_text.width() as u16).min(hint_x.saturating_sub(title_x));
|
||||
buf.set_line(title_x, area.y, &title_line, title_render_w);
|
||||
|
||||
// ── Render the hint (always visible — its space was reserved above) ──
|
||||
if hint_w > 0 && hint_x >= title_x {
|
||||
let is_hovered = hit_close.as_ref().is_some_and(|h| h.hovered);
|
||||
let hint_style = if is_hovered {
|
||||
@@ -323,7 +322,6 @@ pub fn render_btw_panel(
|
||||
};
|
||||
let hint_line = Line::from(Span::styled(hint_text, hint_style));
|
||||
buf.set_line(hint_x, area.y, &hint_line, hint_w);
|
||||
// Set hit area for mouse click handling (top border row).
|
||||
if let Some(hit) = hit_close {
|
||||
hit.set(Some(Rect {
|
||||
x: hint_x,
|
||||
@@ -336,7 +334,6 @@ pub fn render_btw_panel(
|
||||
hit.clear();
|
||||
}
|
||||
|
||||
// ── Body (between borders) ──
|
||||
let body_y = area.y + 1;
|
||||
match state {
|
||||
BtwOverlayState::Loading { .. } => {
|
||||
@@ -550,7 +547,8 @@ mod tests {
|
||||
assert!(!range.lines.is_empty());
|
||||
for (i, line) in range.lines.iter().enumerate() {
|
||||
assert_eq!(line.block_line_idx, i);
|
||||
assert_eq!(line.screen_y, 1 + i as u16); // body_y = area.y + 1
|
||||
// body_y = area.y + 1
|
||||
assert_eq!(line.screen_y, 1 + i as u16);
|
||||
}
|
||||
assert!(
|
||||
!model.visible_blocks.is_empty(),
|
||||
@@ -827,7 +825,8 @@ mod tests {
|
||||
// that is far too wide for a 14-col panel.
|
||||
let response = hard_break_lines(50);
|
||||
let state = done_with_scroll(&response, 0);
|
||||
let width = 14; // below the full-hint width, above the 12-col minimum
|
||||
// below the full-hint width, above the 12-col minimum
|
||||
let width = 14;
|
||||
let buf = render_to_buffer(&state, width, 6);
|
||||
let top = row_text(&buf, width, 0);
|
||||
assert!(
|
||||
|
||||
@@ -34,7 +34,8 @@ pub fn dropdown_height(state: &CompletionDropdownState) -> u16 {
|
||||
return 0;
|
||||
}
|
||||
let item_rows = (state.items.len() as u16).min(MAX_VISIBLE_ROWS);
|
||||
1 + item_rows // separator + items
|
||||
// separator + items
|
||||
1 + item_rows
|
||||
}
|
||||
|
||||
/// Compute the scroll offset so the selected row stays centred.
|
||||
@@ -269,7 +270,8 @@ mod tests {
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(dropdown_height(&state), 3); // 1 separator + 2 items
|
||||
// 1 separator + 2 items
|
||||
assert_eq!(dropdown_height(&state), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -404,7 +406,8 @@ mod tests {
|
||||
assert!(!state.open);
|
||||
assert_eq!(state.selected, 0);
|
||||
assert!(state.hovered.is_none());
|
||||
assert_eq!(state.generation, 5); // generation preserved
|
||||
// generation preserved
|
||||
assert_eq!(state.generation, 5);
|
||||
assert!(state.items.is_empty());
|
||||
// Anchor left in place (inert without items); the next landing
|
||||
// overwrites it atomically with the new items.
|
||||
|
||||
@@ -13,10 +13,6 @@ use ratatui::text::{Line, Span};
|
||||
use super::progress_bar::progress_bar_spans;
|
||||
use crate::theme::Theme;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a percentage as a fixed-width 5-char string.
|
||||
///
|
||||
/// - `< 10`: `"X.XX%"` (e.g. `"0.00%"`, `"5.12%"`)
|
||||
@@ -53,10 +49,6 @@ pub fn fmt_tokens(n: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color blending
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A breakpoint for color blending: at `pct` percent, the bar color is `color`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ColorBreakpoint {
|
||||
@@ -146,24 +138,14 @@ fn color_to_rgb(c: Color) -> (u8, u8, u8) {
|
||||
crate::render::color::resolve_to_rgb(c).unwrap_or((198, 198, 198))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status bar separator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The separator character between status bar items.
|
||||
pub const SEPARATOR: &str = "│";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context bar line builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Width of the percentage field on hover (`fmt_pct5` always returns 5 chars).
|
||||
const PCT_WIDTH: u16 = 5;
|
||||
/// Width of the gap between the progress bar and the percentage on hover.
|
||||
const BAR_PCT_GAP: u16 = 1;
|
||||
|
||||
// BAR_BG removed — use theme.bg_highlight directly (already quantized).
|
||||
|
||||
/// Build the context usage bar as a `Line<'static>`.
|
||||
///
|
||||
/// Normal: `8.5K / 1.0M` — actual token usage, colored by the same percentage
|
||||
@@ -243,10 +225,6 @@ pub fn context_bar_line_for_session(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -261,7 +239,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_fmt_pct5_10_to_99() {
|
||||
assert_eq!(fmt_pct5(10.0), "10.0%");
|
||||
assert_eq!(fmt_pct5(20.16), "20.2%"); // rounds
|
||||
assert_eq!(fmt_pct5(20.16), "20.2%");
|
||||
assert_eq!(fmt_pct5(99.9), "99.9%");
|
||||
}
|
||||
|
||||
@@ -289,7 +267,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_fmt_tokens_thousands() {
|
||||
assert_eq!(fmt_tokens(1_200), "1.2K");
|
||||
assert_eq!(fmt_tokens(9_960), "10.0K"); // rounds up
|
||||
assert_eq!(fmt_tokens(9_960), "10.0K");
|
||||
assert_eq!(fmt_tokens(9_940), "9.9K");
|
||||
assert_eq!(fmt_tokens(12_000), "12K");
|
||||
assert_eq!(fmt_tokens(123_000), "123K");
|
||||
|
||||
@@ -175,12 +175,9 @@ pub fn compute_layout_with_dispatch(
|
||||
} else {
|
||||
1
|
||||
};
|
||||
// Standalone peek rect retired. Peek now renders
|
||||
// INSIDE the dispatch rect (which grows when `peek_visible`,
|
||||
// computed above). Kept as a zero-height field for ABI compat
|
||||
// with the existing call sites that still destructure
|
||||
// `layout.peek`; the field can be removed in a follow-up
|
||||
// cleanup.
|
||||
// Peek renders INSIDE the dispatch rect (which grows when
|
||||
// `peek_visible`, computed above); this field stays zero-height so
|
||||
// call sites that still destructure `layout.peek` keep compiling.
|
||||
let peek_h: u16 = 0;
|
||||
let remaining = area.height.saturating_sub(
|
||||
top_margin_h
|
||||
@@ -235,7 +232,7 @@ pub fn compute_layout_with_dispatch(
|
||||
};
|
||||
y += header_gap_h;
|
||||
|
||||
// Polish — inset the list by LIST_OUTER_HPAD on each side so the
|
||||
// Inset the list by LIST_OUTER_HPAD on each side so the
|
||||
// row content and group header rules have side breathing room.
|
||||
// The outer columns stay painted bg_base by the area-wide fill in
|
||||
// render_dashboard. Mirrors the dispatch inset pattern but with a
|
||||
@@ -470,7 +467,7 @@ mod tests {
|
||||
assert_eq!(layout.list.width, area.width - LIST_OUTER_HPAD * 2);
|
||||
}
|
||||
|
||||
/// Polish — the list rect is inset by LIST_OUTER_HPAD cols on each
|
||||
/// The list rect is inset by LIST_OUTER_HPAD cols on each
|
||||
/// side so row content (markers, rules, text) has breathing room
|
||||
/// and doesn't touch the terminal edges. The outer columns remain
|
||||
/// bg_base (painted by the top-level area fill).
|
||||
@@ -674,9 +671,8 @@ mod tests {
|
||||
assert_eq!(layout.peek.height, 0);
|
||||
}
|
||||
|
||||
/// The standalone peek rect was retired (peek now
|
||||
/// renders INSIDE the dispatch box). The peek rect is always
|
||||
/// zero-height; what changes when `peek_visible == true` is
|
||||
/// Peek renders INSIDE the dispatch box, so the peek rect is
|
||||
/// always zero-height; what changes when `peek_visible == true` is
|
||||
/// the dispatch rect, which grows from 3 to 5 rows to host
|
||||
/// the peek's status + reply input.
|
||||
#[test]
|
||||
|
||||
@@ -1182,10 +1182,6 @@ pub fn extract_first_user_message(agent: &AgentView) -> Option<String> {
|
||||
/// message had MORE than `count` non-empty lines — so the renderer can
|
||||
/// show a `…` continuation marker only when there's genuinely more.
|
||||
/// Returns `(vec![], false)` when the agent hasn't streamed any text.
|
||||
///
|
||||
/// This is the multi-line successor to the old single-line
|
||||
/// `extract_last_agent_message`: the peek panel now surfaces up to 3
|
||||
/// lines of the last response instead of just the first.
|
||||
pub fn extract_last_agent_lines(agent: &AgentView, count: usize) -> (Vec<String>, bool) {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::views::session_title::sanitize_display_text;
|
||||
@@ -1232,10 +1228,9 @@ pub fn extract_last_agent_lines(agent: &AgentView, count: usize) -> (Vec<String>
|
||||
/// Extract the last `count` short text descriptions from the given
|
||||
/// agent view's scrollback.
|
||||
///
|
||||
/// No more `format!("{:?}", entry.block)`, which leaked
|
||||
/// Rust Debug output (variant tags, struct field names, escaped
|
||||
/// strings — and worst of all the head of bash commands containing
|
||||
/// credentials).
|
||||
/// Never project via `format!("{:?}", entry.block)`: that leaks Rust
|
||||
/// Debug output (variant tags, struct field names, escaped strings —
|
||||
/// and worst of all the head of bash commands containing credentials).
|
||||
///
|
||||
/// Every projected string is run through
|
||||
/// `strip_ansi_escapes::strip_str` so embedded `\x1b[...]` sequences
|
||||
@@ -2047,7 +2042,8 @@ mod tests {
|
||||
reject_option: None,
|
||||
},
|
||||
);
|
||||
panel.selected_option = Some(1); // highlight the 2nd option
|
||||
// Highlight the 2nd option.
|
||||
panel.selected_option = Some(1);
|
||||
let mut reply = test_reply();
|
||||
let _ = render_peek_panel(
|
||||
&mut buf,
|
||||
@@ -2098,7 +2094,8 @@ mod tests {
|
||||
},
|
||||
);
|
||||
panel.selected_option = Some(1);
|
||||
panel.focused = false; // Tab → row nav
|
||||
// Tab → row nav.
|
||||
panel.focused = false;
|
||||
let mut reply = test_reply();
|
||||
let _ = render_peek_panel(
|
||||
&mut buf,
|
||||
@@ -2152,7 +2149,8 @@ mod tests {
|
||||
reject_option: Some(1),
|
||||
},
|
||||
);
|
||||
panel.selected_option = Some(1); // highlight the reject option
|
||||
// Highlight the reject option.
|
||||
panel.selected_option = Some(1);
|
||||
let mut reply = test_reply();
|
||||
reply.set_text("do it differently");
|
||||
let res = render_peek_panel(
|
||||
@@ -2214,7 +2212,8 @@ mod tests {
|
||||
reject_option: Some(1),
|
||||
},
|
||||
);
|
||||
panel.selected_option = Some(1); // highlight the "Other" row
|
||||
// Highlight the "Other" row.
|
||||
panel.selected_option = Some(1);
|
||||
let mut reply = test_reply();
|
||||
let _ = render_peek_panel(
|
||||
&mut buf,
|
||||
|
||||
@@ -73,8 +73,6 @@ pub fn render_dashboard(
|
||||
// empty body reads "Loading sessions…" instead of the "no agents
|
||||
// yet" hint so a fresh open doesn't flash an empty-looking screen.
|
||||
dashboard_sessions_loading: bool,
|
||||
// `_compact` removed in this version. Hide-chrome / shortened
|
||||
// activity strings are a Phase 5 polish item.
|
||||
) -> Option<(u16, u16)> {
|
||||
// Re-anchor selection BEFORE we build the rows so that the
|
||||
// visible set drives selection clamping.
|
||||
@@ -468,14 +466,13 @@ fn rename_cursor_pos(state: &DashboardState, rows: &[DashboardRow]) -> Option<(u
|
||||
}
|
||||
|
||||
/// Render the compact dashboard "banner" used when an agent is
|
||||
/// attached as a popup. Replaces the previous
|
||||
/// stacked-input-bars layout (where the dashboard's dispatch +
|
||||
/// footer rendered visibly BELOW the popup). The banner is a
|
||||
/// bordered panel containing the row list summary; the popup
|
||||
/// renders directly below it carrying the focused agent's full
|
||||
/// view (scrollback + prompt + shortcuts), so the user sees a
|
||||
/// single coherent surface with the agent's prompt as the only
|
||||
/// input bar.
|
||||
/// attached as a popup, instead of the dashboard's normal dispatch +
|
||||
/// footer (which would otherwise render visibly BELOW the popup as a
|
||||
/// second stacked input bar). The banner is a bordered panel
|
||||
/// containing the row list summary; the popup renders directly below
|
||||
/// it carrying the focused agent's full view (scrollback + prompt +
|
||||
/// shortcuts), so the user sees a single coherent surface with the
|
||||
/// agent's prompt as the only input bar.
|
||||
///
|
||||
/// Visual:
|
||||
///
|
||||
@@ -595,7 +592,7 @@ fn render_dashboard_banner(
|
||||
/// inflate the tallies if counted directly. Chips with zero count are
|
||||
/// suppressed.
|
||||
///
|
||||
/// Reintroduces a `[+ New Agent]` button on the right edge of
|
||||
/// A `[+ New Agent]` button sits on the right edge of
|
||||
/// the header. The button is the default cursor target whenever
|
||||
/// no row is selected — Up-arrow from the first row, Esc deselect,
|
||||
/// and dashboard-open-without-prior-agent all land here. While
|
||||
@@ -799,8 +796,7 @@ fn render_header(
|
||||
// Paint the current location — git branch + cwd (with worktree
|
||||
// label) — on the left, mirroring the session surfaces (welcome
|
||||
// top bar / agent status bar) so the dashboard shows WHERE a
|
||||
// dispatched session will run. Replaces the old bare "Agents"
|
||||
// label.
|
||||
// dispatched session will run.
|
||||
//
|
||||
// Width budget: from `area.x` up to the leftmost chip's leading
|
||||
// ` │ ` separator (3 cells, painted by `AgentStatusBar::render`
|
||||
@@ -964,7 +960,8 @@ fn render_location_picker(
|
||||
} else {
|
||||
"[worktree:off]"
|
||||
};
|
||||
let wt_w = wt_text.len() as u16; // ASCII → byte len == display width
|
||||
// ASCII → byte len == display width.
|
||||
let wt_w = wt_text.len() as u16;
|
||||
const WT_GAP: u16 = 1;
|
||||
const MIN_PATH_W: u16 = 16;
|
||||
let (path_w, wt_rect) = if show_worktree && content_area.width >= wt_w + WT_GAP + MIN_PATH_W
|
||||
@@ -1127,11 +1124,11 @@ fn render_location_picker(
|
||||
/// One line in the dashboard's vertical stack — either a state-group
|
||||
/// header or a content row.
|
||||
///
|
||||
/// Reintroduces explicit state group headers (`──
|
||||
/// Needs input (2) ──`). The per-row dot + state colour alone didn't
|
||||
/// communicate group boundaries clearly enough; users couldn't tell at
|
||||
/// a glance how many sessions were awaiting input vs working vs idle
|
||||
/// vs done. Headers are emitted on every top-level state transition
|
||||
/// Explicit state group headers (`──
|
||||
/// Needs input (2) ──`) exist because the per-row dot + state colour
|
||||
/// alone doesn't communicate group boundaries clearly enough; users
|
||||
/// can't tell at a glance how many sessions are awaiting input vs
|
||||
/// working vs idle vs done. Headers are emitted on every top-level state transition
|
||||
/// (subagent rows inherit their parent's group and never trigger a
|
||||
/// header) when `grouping == Grouping::State` and the filter isn't
|
||||
/// already pinned to a single state.
|
||||
@@ -2260,8 +2257,8 @@ fn render_narrow_rows(
|
||||
let viewport_h = area.height as usize;
|
||||
// The clamp follows whichever cursor is active — a row OR a section
|
||||
// header — so navigating onto a section title scrolls it into view,
|
||||
// matching the wide layout. Previously only a selected row was
|
||||
// tracked, so a selected header could stay off-screen.
|
||||
// matching the wide layout. Tracking only a selected row would leave
|
||||
// a selected header stranded off-screen.
|
||||
let selected_line_idx = lines.iter().position(|l| match l {
|
||||
DashboardLine::Row(r) => state.selected.as_ref().is_some_and(|s| r.id == *s),
|
||||
DashboardLine::PinnedHeader { .. } => state.selected_section == Some(SectionKey::Pinned),
|
||||
@@ -2476,23 +2473,6 @@ fn render_empty_state(buf: &mut Buffer, area: Rect, theme: &Theme, loading: bool
|
||||
);
|
||||
}
|
||||
|
||||
/// Paint a rounded-box
|
||||
/// chrome around the dispatch input so it reads as a real input
|
||||
/// field. On a 3-row rect the layout is:
|
||||
///
|
||||
/// ```text
|
||||
/// ╭──────────────────────────────────────────────────────────╮
|
||||
/// │ ❯ Dispatch a new agent │
|
||||
/// ╰──────────────────────────────────────────────────────────╯
|
||||
/// ```
|
||||
///
|
||||
/// On a 1-row rect (very short terminals) we fall back to the
|
||||
/// bare `❯ {text}` line so the input stays usable.
|
||||
///
|
||||
/// `reply_label` flips the placeholder between `Dispatch a new
|
||||
/// agent` (`None`) and `Reply to {label}` (`Some`) so the
|
||||
/// chrome reflects what Enter will do: dispatch a new session vs.
|
||||
/// enqueue / send a prompt to the currently-selected agent.
|
||||
/// Paint a short right-aligned feedback badge onto the dispatch box's
|
||||
/// **top border** (e.g. `✗ Session no longer exists`, `✓ Theme: Kigi
|
||||
/// Day`), in a neutral accent colour. The message is painted VERBATIM:
|
||||
@@ -2500,7 +2480,7 @@ fn render_empty_state(buf: &mut Buffer, area: Rect, theme: &Theme, loading: bool
|
||||
/// [`DashboardState::set_error_toast`] (`✗`), while successes / info
|
||||
/// arrive from the `show_toast` builders (`✓` / `⚠`). The badge
|
||||
/// therefore neither prepends a glyph nor forces a colour; doing so
|
||||
/// previously produced a doubled `✗ ✓ …` and painted non-errors (like
|
||||
/// would produce a doubled `✗ ✓ …` and paint non-errors (like
|
||||
/// "Session closed") red. The glyph, not the colour, conveys severity —
|
||||
/// mirroring the per-agent toast in [`crate::app::agent_view`]. The
|
||||
/// badge ends one column before the right corner (`╮`) and is truncated
|
||||
@@ -2609,6 +2589,17 @@ fn paint_dispatch_config_badge(
|
||||
.render_info_line(buf, info_rect, &info, theme.bg_base, theme, input_focused);
|
||||
}
|
||||
|
||||
/// Paint a rounded-box chrome around the dispatch input so it reads as a
|
||||
/// real input field. On a 3-row rect the layout is:
|
||||
///
|
||||
/// ```text
|
||||
/// ╭──────────────────────────────────────────────────────────╮
|
||||
/// │ ❯ Dispatch a new agent │
|
||||
/// ╰──────────────────────────────────────────────────────────╯
|
||||
/// ```
|
||||
///
|
||||
/// On a 1-row rect (very short terminals) we fall back to the
|
||||
/// bare `❯ {text}` line so the input stays usable.
|
||||
fn render_dispatch(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
@@ -3015,11 +3006,9 @@ fn render_file_search_dropdown_for(
|
||||
|
||||
/// Render the dashboard's footer / shortcuts hint row.
|
||||
///
|
||||
/// Switched to the shared `ShortcutsBar` widget so
|
||||
/// the dashboard's shortcut bar uses the same visual vocabulary as
|
||||
/// the agent view's bottom bar: `Key:label` with bold keys + dim
|
||||
/// ` │ ` separators on `bg_base`, instead of the previous custom
|
||||
/// `key label · key label` gray-only string. When a
|
||||
/// Built on the shared `ShortcutsBar` widget so the dashboard's shortcut
|
||||
/// bar uses the same visual vocabulary as the agent view's bottom bar:
|
||||
/// `Key:label` with bold keys + dim ` │ ` separators on `bg_base`. When a
|
||||
/// stop-confirm is armed, `ShortcutsBar::with_pending` paints the
|
||||
/// `press again to {label}` hint in place of the regular list,
|
||||
/// matching the agent view's identical mechanism.
|
||||
@@ -3233,8 +3222,8 @@ fn render_footer(
|
||||
registry.find(id).map(|d| d.default_key).unwrap_or(fallback)
|
||||
};
|
||||
let enter = key!(Enter);
|
||||
// "Send + open" is `Ctrl+S` (was `Shift+Enter`, which now inserts a
|
||||
// newline). Hardcoded in the dispatch / peek key handlers, not a
|
||||
// "Send + open" is fixed at `Ctrl+S` — Shift+Enter inserts a newline
|
||||
// instead. Hardcoded in the dispatch / peek key handlers, not a
|
||||
// registry action, so the chip is built directly.
|
||||
let send_open = key!('s', CONTROL);
|
||||
// Multiline: bare Enter inserts a newline; Shift+Enter (or Alt+Enter
|
||||
@@ -3528,38 +3517,17 @@ pub(crate) fn cached_home() -> Option<&'static str> {
|
||||
|
||||
static HOME: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Popup overlay (banner-style)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the rect for the attached-agent popup overlay.
|
||||
///
|
||||
/// The popup now takes the FULL bottom portion of
|
||||
/// the screen (no horizontal inset, no bottom inset) with only a
|
||||
/// dynamic top inset reserved for the dashboard banner.
|
||||
///
|
||||
/// The previous ~1/6-inset-on-all-sides design left the dashboard's
|
||||
/// dispatch input + footer visible BELOW the popup, producing two
|
||||
/// stacked input bars. The banner above the popup carries the live
|
||||
/// row list in a bordered panel.
|
||||
///
|
||||
/// (Historical legacy comment kept for context — the old layout
|
||||
/// description is no longer accurate; see the body of this function
|
||||
/// for the current banner-style layout.)
|
||||
/// terminal yields a ~164×48 popup with a ~36×12 dashboard frame
|
||||
/// visible around it.
|
||||
/// The popup takes the FULL bottom portion of the screen (no
|
||||
/// horizontal inset, no bottom inset) with only a dynamic top inset
|
||||
/// reserved for the dashboard banner, which carries the live row list
|
||||
/// in a bordered panel above the popup.
|
||||
///
|
||||
/// On terminals too small to honour the minimum inset, falls through
|
||||
/// to a 0-inset takeover (no escape: any non-zero inset would clip
|
||||
/// the agent's prompt below readability).
|
||||
pub fn popup_rect(view: Rect) -> Rect {
|
||||
// Popup takes the FULL bottom area (no
|
||||
// horizontal inset, no bottom inset) with only a small TOP
|
||||
// inset reserved for the dashboard banner that shows the in-flight
|
||||
// rows. The previous ~1/6-inset-on-all-sides design left the
|
||||
// dashboard's own dispatch input + footer visible BELOW the
|
||||
// popup, producing two visible input bars stacked vertically.
|
||||
//
|
||||
// The banner height is dynamic: ~30% of the screen up to a
|
||||
// BANNER_MAX_HEIGHT cap, with a BANNER_MIN_HEIGHT floor on tall
|
||||
// terminals so a 1-row banner doesn't crowd the rows out. Very
|
||||
@@ -4505,7 +4473,8 @@ mod tests {
|
||||
"x",
|
||||
Some((1, 2)),
|
||||
false,
|
||||
true, // hover_next
|
||||
// hover_next
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.expect("overlay must paint");
|
||||
@@ -4886,8 +4855,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── snap_offset_to_line_boundary unit tests ──────────────────────
|
||||
|
||||
/// An offset already on a boundary is returned unchanged.
|
||||
#[test]
|
||||
fn snap_offset_already_on_boundary_returns_input() {
|
||||
@@ -4948,10 +4915,7 @@ mod tests {
|
||||
|
||||
/// `popup_rect` takes the FULL bottom area
|
||||
/// (no horizontal inset, no bottom inset) with only a top inset
|
||||
/// reserved for the dashboard banner. Replaces the previous
|
||||
/// centred-inset design which left the dashboard's own dispatch
|
||||
/// input + footer visible below the popup, producing two
|
||||
/// stacked input bars.
|
||||
/// reserved for the dashboard banner.
|
||||
#[test]
|
||||
fn popup_rect_takes_full_bottom_area_with_top_banner() {
|
||||
let view = Rect::new(0, 0, 200, 80);
|
||||
@@ -5015,8 +4979,7 @@ mod tests {
|
||||
assert_eq!(popup.y, view.y);
|
||||
}
|
||||
|
||||
/// Replacing the home-rolled chrome
|
||||
/// with `picker::render_bordered_frame` means the divider sits
|
||||
/// `picker::render_bordered_frame` places the divider
|
||||
/// ABOVE the returned content rect. This test paints the chrome
|
||||
/// plus a "fake agent" pattern in the inner rect and verifies
|
||||
/// the divider's `─` glyph survives the inner paint.
|
||||
@@ -5209,10 +5172,9 @@ mod tests {
|
||||
|
||||
/// Pressing the help key returns the
|
||||
/// `DashboardOpenShortcutsHelp` action so the dispatcher can
|
||||
/// build the modal state. No `error_toast` is set (the
|
||||
/// an earlier polish iteration surfaced a hint via the dispatch
|
||||
/// input placeholder, which the user explicitly rejected
|
||||
/// because it conflicted with their typing slot).
|
||||
/// build the modal state. No `error_toast` is set — the modal
|
||||
/// itself carries the help; surfacing it via the dispatch input
|
||||
/// placeholder would conflict with the input's typing slot.
|
||||
#[test]
|
||||
fn dashboard_shortcuts_help_action_opens_modal() {
|
||||
use super::super::state::DashboardState;
|
||||
@@ -6047,7 +6009,8 @@ mod tests {
|
||||
fn render_rows_groups_off_uses_divider_not_pinned_header() {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 30));
|
||||
let mut state = DashboardState::new();
|
||||
state.grouping = Grouping::Directory; // groups off (Ctrl+G)
|
||||
// groups off (Ctrl+G)
|
||||
state.grouping = Grouping::Directory;
|
||||
let mut pinned = header_test_row(1, RowState::Idle, "pinned row");
|
||||
pinned.pinned = true;
|
||||
let rows = vec![pinned, header_test_row(2, RowState::Working, "working row")];
|
||||
@@ -6085,11 +6048,9 @@ mod tests {
|
||||
/// Idle → Completed → Failed order (matching
|
||||
/// `RowState::group_priority`).
|
||||
///
|
||||
/// Header chrome now uses Option A
|
||||
/// (` ● Label (N)`): a 2-col indent, a state-coloured dot, then
|
||||
/// the label + count in `gray_dim`. The previous full-row
|
||||
/// `── Label (N) ────────────────` chrome was dropped (the
|
||||
/// trailing dashes felt visually obnoxious — user complaint).
|
||||
/// Regression guard: header chrome is `Label N ──…` (bold label,
|
||||
/// dim count, trailing rule), NOT the parenthesised
|
||||
/// `Awaiting (1)` form.
|
||||
#[test]
|
||||
fn render_rows_emits_group_headers_in_state_order() {
|
||||
// Rows are 3 cells tall, headers 2 cells; 5 of each
|
||||
@@ -6231,7 +6192,8 @@ mod tests {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 100, 2));
|
||||
let theme = Theme::current();
|
||||
let mut state = DashboardState::new();
|
||||
state.spinner_tick = 8; // → dot_spinner_frames()[2] = `⸬`.
|
||||
// → dot_spinner_frames()[2] = `⸬`.
|
||||
state.spinner_tick = 8;
|
||||
let row = DashboardRow {
|
||||
id: DashboardRowId::TopLevel(crate::app::agent::AgentId(1)),
|
||||
label: "who are you?".to_string(),
|
||||
@@ -6564,10 +6526,6 @@ mod tests {
|
||||
|
||||
/// `Grouping::Directory` keeps cwd as the
|
||||
/// grouping primitive, so state headers are suppressed.
|
||||
///
|
||||
/// Header chrome marker updated to match Option
|
||||
/// A. The `(count)` parenthesis pattern is the new specific
|
||||
/// fingerprint for a state header.
|
||||
#[test]
|
||||
fn render_rows_skips_headers_when_grouping_is_directory() {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
|
||||
@@ -6597,9 +6555,6 @@ mod tests {
|
||||
|
||||
/// `Filter::State(_)` collapses the view to a
|
||||
/// single state, so the header would be redundant chrome.
|
||||
///
|
||||
/// Header chrome marker updated to match Option
|
||||
/// A (look for `Working (` instead of `── Working`).
|
||||
#[test]
|
||||
fn render_rows_skips_headers_when_filter_is_state() {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
|
||||
@@ -6833,10 +6788,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Header redesign tests
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Basename of the test process's cwd — the one deterministic
|
||||
/// fragment of the header's location label. The full label depends
|
||||
/// on global git caches (`git_info::*`) that parallel tests may
|
||||
@@ -6962,11 +6913,12 @@ mod tests {
|
||||
fn underline_location_on_hover_excludes_branch_icon() {
|
||||
let icon = "\u{e0a0}";
|
||||
let plain = Style::default();
|
||||
// leading inset, git (icon + branch), git↔path separator, path.
|
||||
let spans = vec![
|
||||
Span::styled(" ".to_string(), plain), // leading inset
|
||||
Span::styled(format!("{icon} main"), plain), // git: icon + branch
|
||||
Span::styled(" ".to_string(), plain), // git↔path separator
|
||||
Span::styled("/home/me/repo".to_string(), plain), // path
|
||||
Span::styled(" ".to_string(), plain),
|
||||
Span::styled(format!("{icon} main"), plain),
|
||||
Span::styled(" ".to_string(), plain),
|
||||
Span::styled("/home/me/repo".to_string(), plain),
|
||||
];
|
||||
let out = underline_location_on_hover(spans, icon);
|
||||
|
||||
@@ -7495,7 +7447,8 @@ mod tests {
|
||||
&state,
|
||||
®istry,
|
||||
None,
|
||||
true, // peek_active
|
||||
// peek_active
|
||||
true,
|
||||
None,
|
||||
);
|
||||
let content = buf_to_text(&buf);
|
||||
@@ -7553,7 +7506,8 @@ mod tests {
|
||||
&state,
|
||||
®istry,
|
||||
None,
|
||||
true, // peek_active
|
||||
// peek_active
|
||||
true,
|
||||
None,
|
||||
);
|
||||
let content = buf_to_text(&buf);
|
||||
@@ -7570,7 +7524,8 @@ mod tests {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||||
let theme = Theme::current();
|
||||
let mut state = DashboardState::new();
|
||||
state.list_focused = true; // used to steal the footer before the peek fix
|
||||
// Regression guard: this used to steal the footer before the peek fix.
|
||||
state.list_focused = true;
|
||||
state.peek = Some(crate::views::dashboard::peek::PeekPanelState::new(
|
||||
DashboardRowId::TopLevel(crate::app::agent::AgentId(0)),
|
||||
crate::views::dashboard::peek::PeekFields {
|
||||
@@ -7706,7 +7661,8 @@ mod tests {
|
||||
state,
|
||||
®istry,
|
||||
None,
|
||||
true, // peek_active
|
||||
// peek_active
|
||||
true,
|
||||
None,
|
||||
);
|
||||
buf_to_text(&buf)
|
||||
@@ -7792,10 +7748,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// When a row (NeedsInput or otherwise) is selected
|
||||
/// with an empty prompt, the footer shows `Enter:open`.
|
||||
/// The previous `see details` label is folded into the
|
||||
/// unified "row selected → open" semantics — every row's
|
||||
/// detail view is the answer surface for any user-input
|
||||
/// with an empty prompt, the footer shows `Enter:open` — every
|
||||
/// row's detail view is the answer surface for any user-input
|
||||
/// state, including `NeedsInput`.
|
||||
#[test]
|
||||
fn render_footer_row_selected_empty_prompt_shows_enter_open() {
|
||||
|
||||
@@ -1258,10 +1258,9 @@ mod tests {
|
||||
assert_eq!(rows[1].state, RowState::Working);
|
||||
assert_eq!(rows[2].state, RowState::Idle);
|
||||
}
|
||||
/// Renamed from `sort_deterministic_with_equal_keys`.
|
||||
/// The original name implied a tiebreak guarantee that
|
||||
/// `sort_cluster_key` does NOT provide; documents the actual
|
||||
/// behavioural contract: idempotent on identical inputs.
|
||||
/// `sort_cluster_key` does not guarantee a full tiebreak by itself;
|
||||
/// this asserts the weaker contract it does provide: idempotent on
|
||||
/// identical inputs.
|
||||
#[test]
|
||||
fn sort_is_idempotent_for_identical_inputs() {
|
||||
let now = SystemTime::now();
|
||||
@@ -1737,9 +1736,8 @@ mod tests {
|
||||
AgentView::new(session, ScrollbackState::new())
|
||||
}
|
||||
/// An idle local agent with no last message has a BLANK second line —
|
||||
/// the model is no longer used as a fallback there (it now shows in
|
||||
/// the peek panel's bottom-border badge for the selected row, keeping
|
||||
/// the list uncluttered).
|
||||
/// the model appears in the peek panel's bottom-border badge for the
|
||||
/// selected row instead, keeping the list uncluttered.
|
||||
#[test]
|
||||
fn idle_local_agent_without_message_has_blank_secondary() {
|
||||
let agent = make_idle_agent_with_model(Some("kigi-4.5"));
|
||||
|
||||
@@ -1498,27 +1498,18 @@ impl DashboardState {
|
||||
/// agent/subagent no longer exists is silently dropped. Avoids edge
|
||||
/// case 20 (a pinned row whose agent was deleted).
|
||||
///
|
||||
/// Also clears an in-flight `rename` whose row
|
||||
/// disappeared (parent closed, subagent finished, etc.), so a
|
||||
/// Commit-Enter doesn't silently drop the draft on a phantom row.
|
||||
///
|
||||
/// Extends the gc to `peek`, `hovered_row`, and
|
||||
/// `last_click`. The previous version only cleared `pinned`,
|
||||
/// `reorder`, `rename`, and `selected`. A stale `peek` would
|
||||
/// render cached content for a dead row; a stale `last_click`
|
||||
/// could trigger a double-click "attach" against whatever new
|
||||
/// row took the cell.
|
||||
///
|
||||
/// Drop the "Row no longer exists; rename
|
||||
/// cancelled" toast on stale rename. The toast fired on every
|
||||
/// dashboard open if a row was closed externally, surprising the
|
||||
/// user (they hadn't done anything since). The clear itself is
|
||||
/// preserved — silently clearing the rename matches the silent
|
||||
/// gc on `pinned` / `reorder` / `selected` / `peek` /
|
||||
/// `hovered_row` / `last_click`. the earlier invariant ("Commit
|
||||
/// Enter can't dispatch against a phantom row") is preserved
|
||||
/// because the renamed row can't render the overlay if it's
|
||||
/// gone, so Enter can't reach the commit path.
|
||||
/// Also silently clears `rename`, `peek`, `hovered_row`, and
|
||||
/// `last_click` when their row disappeared (parent closed, subagent
|
||||
/// finished, etc.):
|
||||
/// - A stale `rename` is cleared without a "Row no longer exists;
|
||||
/// rename cancelled" toast, so a row closed elsewhere doesn't
|
||||
/// surprise the user with a toast on the next dashboard open.
|
||||
/// Commit-Enter still can't dispatch against a phantom row, because
|
||||
/// the renamed row can't render the overlay once it's gone.
|
||||
/// - A stale `peek` would otherwise render cached content for a dead
|
||||
/// row.
|
||||
/// - A stale `last_click` could trigger a double-click "attach"
|
||||
/// against whatever new row took the cell.
|
||||
pub fn gc_stale_refs(&mut self, alive: &dyn Fn(&DashboardRowId) -> bool) {
|
||||
self.pinned.retain(|id| alive(id));
|
||||
self.reorder.retain(|id| alive(id));
|
||||
@@ -2516,7 +2507,7 @@ impl DashboardState {
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.reject_option == selected);
|
||||
match (key.code, selected) {
|
||||
// ── No option selected → navigate agents / open ──
|
||||
// No option selected: navigate agents / open.
|
||||
(KeyCode::Up, None) => {
|
||||
return Some(InputOutcome::Action(Action::DashboardSelectPrev));
|
||||
}
|
||||
@@ -2542,7 +2533,7 @@ impl DashboardState {
|
||||
.unwrap_or(InputOutcome::Unchanged),
|
||||
);
|
||||
}
|
||||
// ── Option selected → move within options (spill at edges) ──
|
||||
// Option selected: move within options (spill at edges).
|
||||
(KeyCode::Up, Some(0)) => {
|
||||
return Some(InputOutcome::Action(Action::DashboardSelectPrev));
|
||||
}
|
||||
@@ -2882,11 +2873,11 @@ impl DashboardState {
|
||||
// (the early return at the top of this function), so by the time
|
||||
// execution reaches here the peek panel is guaranteed closed.
|
||||
|
||||
// ── Ctrl+V / Cmd+V paste ────────────────────────────────────────
|
||||
// Read the pbpaste text once and route through the shared deferred
|
||||
// paste pipeline: a file path wins synchronously, else the clipboard
|
||||
// image/file-url probe defers off the event loop. Mirrors `AgentView`
|
||||
// — without this, Ctrl+V on the dashboard did nothing useful.
|
||||
// Ctrl+V / Cmd+V paste: read the pbpaste text once and route through
|
||||
// the shared deferred paste pipeline: a file path wins synchronously,
|
||||
// else the clipboard image/file-url probe defers off the event loop.
|
||||
// Mirrors `AgentView` — without this, Ctrl+V on the dashboard did
|
||||
// nothing useful.
|
||||
if crate::input::key::is_paste_key(key) {
|
||||
let clipboard_text = crate::app::actions::ClipboardTextRead::from_result(
|
||||
crate::clipboard::system_clipboard_read_text(),
|
||||
@@ -2894,11 +2885,10 @@ impl DashboardState {
|
||||
return self.handle_paste_key_deferred(clipboard_text, /* peek */ false);
|
||||
}
|
||||
|
||||
// ── @-file-search intercept ─────────────────────────────────────
|
||||
// The dispatch input offers a session-less `@` context picker
|
||||
// rooted at the pager's launch cwd. While its dropdown is
|
||||
// visible, the prompt widget owns Up/Down/Tab/Enter/Esc — route
|
||||
// the key there BEFORE the dashboard's row-nav / Enter / Esc
|
||||
// @-file-search intercept: the dispatch input offers a session-less
|
||||
// `@` context picker rooted at the pager's launch cwd. While its
|
||||
// dropdown is visible, the prompt widget owns Up/Down/Tab/Enter/Esc
|
||||
// — route the key there BEFORE the dashboard's row-nav / Enter / Esc
|
||||
// handlers, mirroring `agent_view::handle_prompt_key`.
|
||||
if self.dispatch.file_search_visible() {
|
||||
match self.dispatch.handle_key(key) {
|
||||
@@ -4112,9 +4102,8 @@ impl DashboardState {
|
||||
if let Some(sel) = self.selected.as_ref()
|
||||
&& !selectable.iter().any(|r| r.id == *sel)
|
||||
{
|
||||
// The previously selected row was filtered out / closed
|
||||
// / lost its parent. Drop the cursor — re-selecting is
|
||||
// the user's job.
|
||||
// The selected row was filtered out / closed / lost its
|
||||
// parent. Drop the cursor — re-selecting is the user's job.
|
||||
self.selected = None;
|
||||
}
|
||||
}
|
||||
@@ -4292,10 +4281,6 @@ fn handle_rename_key(draft: &mut RenameDraft, key: &KeyEvent) -> InputOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter parser (edge case 11)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a filter expression from the dispatch input.
|
||||
///
|
||||
/// Rules per edge case 11:
|
||||
@@ -4367,10 +4352,6 @@ pub fn parse_row_state_token(s: &str) -> Option<RowState> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistence I/O
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read the persisted `[dashboard].enabled` flag (defaults to `true`).
|
||||
///
|
||||
/// Lenient: any error or unparseable value returns `None`, which the
|
||||
@@ -4583,10 +4564,6 @@ fn parse_persist_key_list(item: &toml_edit::Item) -> Vec<PersistedRowId> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: relative path display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compact a `Path` for display against `$HOME`, returning a `String`.
|
||||
///
|
||||
/// Used by the row renderer + filter substring search to keep cwd
|
||||
@@ -5333,10 +5310,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// handle_key tests (Esc cascade, Enter routing).
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
fn make_state_with_selection() -> DashboardState {
|
||||
let mut s = DashboardState::new();
|
||||
s.selected = Some(DashboardRowId::TopLevel(AgentId(0)));
|
||||
@@ -5724,7 +5697,8 @@ mod tests {
|
||||
p.options = vec![("a".into(), "A".into()), ("other".into(), "Other".into())];
|
||||
p.reject_option = Some(1);
|
||||
p.selected_option = Some(1);
|
||||
p.request_id = None; // Ask tool (not a permission)
|
||||
// Ask tool (not a permission).
|
||||
p.request_id = None;
|
||||
}
|
||||
let reg = crate::actions::ActionRegistry::defaults();
|
||||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||||
@@ -5910,7 +5884,8 @@ mod tests {
|
||||
let mut state = state_with_open_peek();
|
||||
let reg = crate::actions::ActionRegistry::defaults();
|
||||
state.peek_reply.set_text("a draft");
|
||||
state.peek.as_mut().unwrap().focused = false; // Tab → row nav
|
||||
// Tab → row nav.
|
||||
state.peek.as_mut().unwrap().focused = false;
|
||||
assert!(matches!(
|
||||
state.handle_key(&KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), ®),
|
||||
InputOutcome::Action(Action::DashboardSelectNext)
|
||||
@@ -6388,7 +6363,8 @@ mod tests {
|
||||
("__other__".into(), "Other".into()),
|
||||
];
|
||||
f.reject_option = Some(2);
|
||||
f.request_id = None; // ← marks it as an ask question, not a permission
|
||||
// Marks it as an ask question, not a permission.
|
||||
f.request_id = None;
|
||||
state.peek = Some(super::super::peek::PeekPanelState::new(
|
||||
DashboardRowId::TopLevel(AgentId(0)),
|
||||
f,
|
||||
@@ -7010,12 +6986,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Reconciled dispatch-input features: slash commands, Alt+Enter
|
||||
// multiline, vim-gated j/k, and paste — layered on top of the
|
||||
// reply-mode / search-mode base.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// A `/command` Enter routes through the session-less slash
|
||||
/// dispatcher instead of becoming a new session's prompt.
|
||||
#[test]
|
||||
@@ -7421,7 +7391,8 @@ mod tests {
|
||||
let mut state = DashboardState::new();
|
||||
assert!(state.new_agent_button_focused);
|
||||
state.dispatch.set_text("fix the bug");
|
||||
state.list_focused = true; // e.g. after an Esc blur
|
||||
// e.g. after an Esc blur.
|
||||
state.list_focused = true;
|
||||
match state.handle_key(&key, ®) {
|
||||
InputOutcome::Action(Action::DashboardDispatch { text, attach }) => {
|
||||
assert_eq!(text, "fix the bug");
|
||||
@@ -7808,7 +7779,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// The clipboard raster/file-url probe (osascript) + image decode +
|
||||
// session persist run OFF the event loop. A paste that would probe
|
||||
// enqueues a `ProbeClipboardAttachment` effect and returns without an
|
||||
@@ -7816,7 +7786,6 @@ mod tests {
|
||||
// attaches later via `complete_clipboard_attachment_paste`. Snapshot /
|
||||
// support are faked via the test-only seam; plain text with no
|
||||
// raster stays fully synchronous (no defer).
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
fn probe_image_data() -> crate::clipboard::ImageData {
|
||||
crate::clipboard::ImageData {
|
||||
@@ -8252,7 +8221,8 @@ mod tests {
|
||||
/// instead of inserting into the now-hidden reply buffer.
|
||||
#[test]
|
||||
fn completion_peek_dropped_when_panel_closed() {
|
||||
let mut state = DashboardState::new(); // no peek open
|
||||
// No peek open.
|
||||
let mut state = DashboardState::new();
|
||||
state.paste_probe_in_flight = 1;
|
||||
let completion = state.complete_clipboard_attachment_paste(
|
||||
completion_ctx(None, true),
|
||||
@@ -8279,7 +8249,8 @@ mod tests {
|
||||
/// instead of replying to the newly peeked agent.
|
||||
#[test]
|
||||
fn completion_peek_dropped_when_row_changed() {
|
||||
let mut state = state_with_open_peek(); // peeks TopLevel(AgentId(0))
|
||||
// Peeks TopLevel(AgentId(0)).
|
||||
let mut state = state_with_open_peek();
|
||||
state.paste_probe_in_flight = 1;
|
||||
state.deferred_peek_send = Some(DeferredPeekSend {
|
||||
row: DashboardRowId::TopLevel(AgentId(0)),
|
||||
@@ -8418,7 +8389,8 @@ mod tests {
|
||||
/// draft stays in the widget.
|
||||
#[test]
|
||||
fn stashed_peek_reply_dropped_when_question_active() {
|
||||
let mut state = state_with_open_peek(); // peeks TopLevel(AgentId(0))
|
||||
// Peeks TopLevel(AgentId(0)).
|
||||
let mut state = state_with_open_peek();
|
||||
state.peek_reply.set_text("please look");
|
||||
state.deferred_peek_send = Some(DeferredPeekSend {
|
||||
row: DashboardRowId::TopLevel(AgentId(0)),
|
||||
@@ -8444,12 +8416,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// `/` literal + `Ctrl+/` search mode (replaces the old `/`→filter
|
||||
// behaviour that silently swallowed prompts starting with a
|
||||
// filter prefix).
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// `/` types a literal slash into the prompt — it no longer
|
||||
/// enters a filter mode. (Filtering moved to `Ctrl+/`.)
|
||||
#[test]
|
||||
@@ -8690,7 +8656,8 @@ mod tests {
|
||||
let click = MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: 10,
|
||||
row: 5, // inside BOTH dropdown and row_rects
|
||||
// Inside BOTH dropdown and row_rects.
|
||||
row: 5,
|
||||
modifiers: crossterm::event::KeyModifiers::NONE,
|
||||
};
|
||||
let outcome = state.handle_mouse(&click);
|
||||
@@ -9171,7 +9138,8 @@ mod tests {
|
||||
]),
|
||||
mode: crate::views::shortcuts_help::ShortcutsHelpMode::Browse,
|
||||
});
|
||||
modal.state.selected = 1; // first registry-backed hint
|
||||
// First registry-backed hint.
|
||||
modal.state.selected = 1;
|
||||
state.shortcuts_modal = Some(modal);
|
||||
|
||||
let snapshot = |s: &DashboardState| {
|
||||
@@ -9765,10 +9733,6 @@ mod tests {
|
||||
assert_eq!(s.viewport_offset, 5);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Mouse wheel decoupled from selection
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// `handle_scroll` flags `manual_scroll_active` so the next
|
||||
/// `clamp_viewport` knows to skip the snap-to-selection
|
||||
/// pull-back.
|
||||
@@ -9886,8 +9850,6 @@ mod tests {
|
||||
unsafe { std::env::remove_var("KIGI_AGENT_DASHBOARD") };
|
||||
}
|
||||
|
||||
// ── Location picker ─────────────────────────────────────────────
|
||||
|
||||
fn location_candidate(path: &str, label: &str) -> LocationCandidate {
|
||||
LocationCandidate {
|
||||
path: PathBuf::from(path),
|
||||
@@ -10046,8 +10008,10 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn location_path_completion_tags_symlinked_worktree() {
|
||||
let real = tempfile::tempdir().unwrap(); // the real worktree target
|
||||
let parent = tempfile::tempdir().unwrap(); // the dir we list
|
||||
// The real worktree target.
|
||||
let real = tempfile::tempdir().unwrap();
|
||||
// The dir we list.
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
std::os::unix::fs::symlink(real.path(), parent.path().join("link")).unwrap();
|
||||
|
||||
// Index keyed by the real (canonical) path, as the worktree DB is.
|
||||
|
||||
@@ -335,10 +335,6 @@ pub fn prev_visible_hook(
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab enum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which tab is active in the hooks/plugins modal.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExtensionsTab {
|
||||
@@ -382,10 +378,6 @@ impl ExtensionsTab {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Filter items by enabled/disabled status.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum StatusFilter {
|
||||
@@ -421,10 +413,6 @@ impl StatusFilter {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Button actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// What a button does when activated (clicked or keyboard shortcut).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ButtonAction {
|
||||
@@ -1360,9 +1348,6 @@ fn longest_common_prefix(strings: &[String]) -> String {
|
||||
.map(|(a, _)| a)
|
||||
.collect()
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Word boundary helpers (for readline-style editing in modal input fields)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Byte offset of the start of the previous word.
|
||||
///
|
||||
@@ -1556,10 +1541,6 @@ fn parse_mcp_add_fields(name: &str, url_or_cmd: &str) -> Option<ButtonAction> {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-tab data fetching lifecycle.
|
||||
#[derive(Debug)]
|
||||
pub enum TabDataState<T> {
|
||||
@@ -1574,8 +1555,7 @@ pub enum TabDataState<T> {
|
||||
/// State for the hooks/plugins modal popup.
|
||||
pub struct ExtensionsModalState {
|
||||
/// Shared modal window chrome state (close button, tabs, footer
|
||||
/// shortcuts, popup area). Replaces the former `last_popup_area`,
|
||||
/// `tab_areas`, `close_button_area`, `close_hovered` fields.
|
||||
/// shortcuts, popup area).
|
||||
pub window: ModalWindowState,
|
||||
/// Currently active tab (source of truth).
|
||||
///
|
||||
@@ -1650,7 +1630,7 @@ pub struct ExtensionsModalState {
|
||||
/// Status filter for the skills tab.
|
||||
pub skills_filter: StatusFilter,
|
||||
/// Unified picker state for tabs managed by `render_picker_content`.
|
||||
/// Search query and search_active live here (previously duplicated).
|
||||
/// Search query and search_active live here.
|
||||
pub picker_state: picker::PickerState,
|
||||
/// Maps picker entry index → original data index (for action dispatch).
|
||||
/// Rebuilt every render. `None` for headers or error entries.
|
||||
@@ -1765,10 +1745,6 @@ impl ExtensionsModalState {
|
||||
self.picker_state.hovered = None;
|
||||
}
|
||||
|
||||
/// Whether a group header at picker index `sel` with the given
|
||||
/// `group_key` is currently expanded (children visible).
|
||||
///
|
||||
/// The answer depends on the active tab: Hooks use
|
||||
/// Seed the all-collapsed default for plugin source groups exactly once.
|
||||
///
|
||||
/// Called from both plugin-data delivery channels (list fetch and the
|
||||
@@ -1782,6 +1758,10 @@ impl ExtensionsModalState {
|
||||
self.plugins_groups_seeded = true;
|
||||
}
|
||||
|
||||
/// Whether a group header at picker index `sel` with the given
|
||||
/// `group_key` is currently expanded (children visible).
|
||||
///
|
||||
/// The answer depends on the active tab: Hooks use
|
||||
/// `hooks_collapsed_groups`, Plugins use `plugins_collapsed_groups`,
|
||||
/// and other tabs use `picker_state.expanded`.
|
||||
pub fn is_group_expanded(&self, sel: usize, group_key: &str) -> bool {
|
||||
@@ -1789,7 +1769,7 @@ impl ExtensionsModalState {
|
||||
|
||||
match self.active_tab {
|
||||
// During active search we force all hook groups open so matches
|
||||
// inside previously-collapsed groups are visible.
|
||||
// inside collapsed groups are still visible.
|
||||
ExtensionsTab::Hooks => searching || !self.hooks_collapsed_groups.contains(group_key),
|
||||
ExtensionsTab::Plugins => {
|
||||
searching || !self.plugins_collapsed_groups.contains(group_key)
|
||||
@@ -1904,10 +1884,10 @@ pub fn build_entry_non_selectable(
|
||||
entry_is_header.to_vec()
|
||||
}
|
||||
|
||||
/// MCP section labels are now keyboard-selectable, so no rows need the
|
||||
/// MCP section labels are keyboard-selectable, so no rows need the
|
||||
/// "non-selectable but clickable" treatment. Kept as a function so callers
|
||||
/// can continue to pass a slice to the picker without per-call allocation
|
||||
/// changes; the returned mask is all `false`.
|
||||
/// can pass a slice to the picker without per-call allocation changes;
|
||||
/// the returned mask is all `false`.
|
||||
pub fn build_entry_non_selectable_clickable(entry_group_keys: &[Option<String>]) -> Vec<bool> {
|
||||
vec![false; entry_group_keys.len()]
|
||||
}
|
||||
@@ -2087,10 +2067,6 @@ pub fn derive_source_label(source_dir: &str) -> (String, bool) {
|
||||
(display, true)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry builders — convert tab data into Vec<PickerEntry> for render_picker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Data needed to build entries for a tab. Avoids borrow conflicts with state.
|
||||
struct SkillsEntryData {
|
||||
/// (skill_index, is_name_match)
|
||||
@@ -2194,10 +2170,6 @@ fn build_plugin_fields(plugin: &kigi_hooks_plugins_types::PluginInfo) -> Vec<Str
|
||||
components
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the hooks/plugins modal popup as a centered overlay.
|
||||
///
|
||||
/// Uses the shared [`ModalWindow`](super::modal_window) for chrome
|
||||
@@ -2264,7 +2236,6 @@ pub fn render_extensions_modal(
|
||||
// Rebuild the entry list *before* footer action labels so Space
|
||||
// enable/disable can use this frame's mapping (passed as locals to
|
||||
// `action_key_footer_desc_for_mapping`), not last frame's filter/tab/query.
|
||||
// ── Build PickerEntry list for current tab ──
|
||||
// We build owned data here and reference it for the picker.
|
||||
let mut entry_labels: Vec<String> = Vec::new();
|
||||
let mut entry_right_labels: Vec<String> = Vec::new();
|
||||
@@ -2380,7 +2351,8 @@ pub fn render_extensions_modal(
|
||||
entry_desc_lines.push(vec![]);
|
||||
entry_summary_lines.push(vec![]);
|
||||
entry_fields.push(vec![]);
|
||||
entry_is_header.push(false); // group header, but selectable
|
||||
// Group header, but selectable.
|
||||
entry_is_header.push(false);
|
||||
entry_dimmed.push(false);
|
||||
entry_indent.push(0);
|
||||
entry_data_indices.push(None);
|
||||
@@ -2478,8 +2450,9 @@ pub fn render_extensions_modal(
|
||||
entry_desc_lines.push(vec![]);
|
||||
entry_summary_lines.push(vec![]);
|
||||
entry_fields.push(vec![]);
|
||||
entry_is_header.push(false); // group header, but selectable
|
||||
entry_dimmed.push(false); // headers
|
||||
// Group header, but selectable.
|
||||
entry_is_header.push(false);
|
||||
entry_dimmed.push(false);
|
||||
entry_indent.push(0);
|
||||
entry_data_indices.push(None);
|
||||
entry_group_keys.push(Some(source_dir.clone()));
|
||||
@@ -2928,7 +2901,8 @@ pub fn render_extensions_modal(
|
||||
&theme,
|
||||
&state.picker_state.query,
|
||||
search_active_render,
|
||||
true, // show_search_hint
|
||||
// show_search_hint
|
||||
true,
|
||||
state.picker_state.query_cursor,
|
||||
Some(theme.bg_base),
|
||||
);
|
||||
@@ -3059,11 +3033,13 @@ pub fn render_extensions_modal(
|
||||
// handle_picker_input (used for content-level events) works.
|
||||
let filter_rect = state.picker_state.filter_area;
|
||||
state.picker_state.hit_areas = Some(picker::PickerHitAreas {
|
||||
close_button: Rect::default(), // handled by ModalWindow
|
||||
// handled by ModalWindow
|
||||
close_button: Rect::default(),
|
||||
search_bar: search_bar_rect,
|
||||
item_rects,
|
||||
entry_indices,
|
||||
tab_rects: vec![], // handled by ModalWindow
|
||||
// handled by ModalWindow
|
||||
tab_rects: vec![],
|
||||
filter_rect,
|
||||
});
|
||||
state.entry_data_indices = entry_data_indices;
|
||||
@@ -3309,7 +3285,8 @@ fn render_input_form(buf: &mut Buffer, area: Rect, input: &ModalInput, theme: &T
|
||||
// (top border + content + bottom border).
|
||||
let field_count = input.fields.len() as u16;
|
||||
const ROWS_PER_FIELD: u16 = 4;
|
||||
let separators = field_count.saturating_sub(1); // 1 blank row between fields
|
||||
// 1 blank row between fields.
|
||||
let separators = field_count.saturating_sub(1);
|
||||
let form_rows = field_count * ROWS_PER_FIELD + separators;
|
||||
// Reserve room for an inline error row when present (1 spacer + 1 line).
|
||||
let error_rows: u16 = if input.error.is_some() { 2 } else { 0 };
|
||||
@@ -3434,7 +3411,8 @@ fn render_input_form(buf: &mut Buffer, area: Rect, input: &ModalInput, theme: &T
|
||||
}
|
||||
}
|
||||
|
||||
cur_y += 3; // top border + content + bottom border
|
||||
// top border + content + bottom border
|
||||
cur_y += 3;
|
||||
|
||||
// Blank separator between fields (skip after last field).
|
||||
if fi + 1 < input.fields.len() {
|
||||
@@ -3498,7 +3476,7 @@ mod tests {
|
||||
Some("mcp-tools:0".into()),
|
||||
None,
|
||||
]);
|
||||
// Sections are now keyboard-selectable, so clicks go through the
|
||||
// Sections are keyboard-selectable, so clicks go through the
|
||||
// normal Selected → toggle_fold path; no row needs the
|
||||
// non-selectable-but-clickable treatment.
|
||||
assert_eq!(mask, vec![false, false, false]);
|
||||
@@ -3817,8 +3795,6 @@ mod tests {
|
||||
assert_eq!(state.selected_mcp_tool(), None);
|
||||
}
|
||||
|
||||
// ── fuzzy_matches ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn fuzzy_matches_empty_query_matches_everything() {
|
||||
assert!(fuzzy_matches("anything", ""));
|
||||
@@ -3828,23 +3804,25 @@ mod tests {
|
||||
fn fuzzy_matches_substring() {
|
||||
assert!(fuzzy_matches("rust-check", "check"));
|
||||
assert!(fuzzy_matches("rust-check", "rust"));
|
||||
assert!(fuzzy_matches("Rust-Check", "check")); // case insensitive
|
||||
// case insensitive
|
||||
assert!(fuzzy_matches("Rust-Check", "check"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_matches_subsequence() {
|
||||
assert!(fuzzy_matches("rust-check", "rc")); // r...c
|
||||
assert!(fuzzy_matches("frontend-design", "fd")); // f...d
|
||||
// r...c
|
||||
assert!(fuzzy_matches("rust-check", "rc"));
|
||||
// f...d
|
||||
assert!(fuzzy_matches("frontend-design", "fd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_matches_rejects_non_matching() {
|
||||
assert!(!fuzzy_matches("hello", "xyz"));
|
||||
assert!(!fuzzy_matches("abc", "abdc")); // query longer than would match
|
||||
// query longer than would match
|
||||
assert!(!fuzzy_matches("abc", "abdc"));
|
||||
}
|
||||
|
||||
// ── Skills search: substring-only, title-first ordering ─────────
|
||||
|
||||
fn make_skill(name: &str, desc: &str) -> kigi_tools::implementations::skills::types::SkillInfo {
|
||||
kigi_tools::implementations::skills::types::SkillInfo {
|
||||
name: name.to_string(),
|
||||
@@ -3913,9 +3891,12 @@ mod tests {
|
||||
#[test]
|
||||
fn skills_search_title_matches_first() {
|
||||
let skills = [
|
||||
make_skill("some-tool", "Run lint check"), // desc match only
|
||||
make_skill("check", "Run lint check"), // name match
|
||||
make_skill("rust-check", "Rust pre-push checks"), // name match
|
||||
// desc match only
|
||||
make_skill("some-tool", "Run lint check"),
|
||||
// name match
|
||||
make_skill("check", "Run lint check"),
|
||||
// name match
|
||||
make_skill("rust-check", "Rust pre-push checks"),
|
||||
];
|
||||
let query = "check";
|
||||
let query_lower = query.to_lowercase();
|
||||
@@ -3945,8 +3926,6 @@ mod tests {
|
||||
assert!(!matches[2].1, "third result should be a desc-only match");
|
||||
}
|
||||
|
||||
// ── Hooks: search forces groups expanded ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn hooks_collapsed_groups_ignored_during_search() {
|
||||
let mut collapsed = std::collections::HashSet::new();
|
||||
@@ -3963,8 +3942,6 @@ mod tests {
|
||||
assert!(!is_collapsed_with_query);
|
||||
}
|
||||
|
||||
// ── Skills: plugin skills appear in filter results ─────────────
|
||||
|
||||
fn make_plugin_skill(
|
||||
name: &str,
|
||||
desc: &str,
|
||||
@@ -4027,7 +4004,8 @@ mod tests {
|
||||
];
|
||||
let result = filter_and_sort_skills(&skills, "hello", StatusFilter::All);
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
assert_eq!(result.matches[0].0, 1); // index of hello
|
||||
// index of hello
|
||||
assert_eq!(result.matches[0].0, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4042,8 +4020,6 @@ mod tests {
|
||||
assert_eq!(by_name.matches.len(), 1);
|
||||
}
|
||||
|
||||
// ── Skills: selection clamping after filter ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn skills_selection_clamped_after_filter() {
|
||||
// User had selected index 10, but after filtering only 3 match.
|
||||
@@ -4053,7 +4029,8 @@ mod tests {
|
||||
if match_count > 0 {
|
||||
selected = selected.min(match_count - 1);
|
||||
}
|
||||
assert_eq!(selected, 2); // clamped to last valid index
|
||||
// clamped to last valid index
|
||||
assert_eq!(selected, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4067,8 +4044,6 @@ mod tests {
|
||||
assert_eq!(selected, 0);
|
||||
}
|
||||
|
||||
// ── Plugin fixtures ─────────────────────────────────────────────
|
||||
|
||||
fn make_plugin(name: &str) -> kigi_hooks_plugins_types::PluginInfo {
|
||||
test_plugin_info(name, None)
|
||||
}
|
||||
@@ -4080,8 +4055,6 @@ mod tests {
|
||||
test_plugin_info(name, Some(origin))
|
||||
}
|
||||
|
||||
// ── StatusFilter unit tests ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn status_filter_next_cycles() {
|
||||
assert_eq!(StatusFilter::All.next(), StatusFilter::Enabled);
|
||||
@@ -4214,8 +4187,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab navigation ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tab_next_wraps_around() {
|
||||
assert_eq!(ExtensionsTab::Hooks.next(), ExtensionsTab::Plugins);
|
||||
@@ -4237,8 +4208,6 @@ mod tests {
|
||||
assert_eq!(ExtensionsTab::ALL.len(), 4);
|
||||
}
|
||||
|
||||
// ── Modal state init ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn modal_state_starts_loading() {
|
||||
let state = ExtensionsModalState::new(ExtensionsTab::McpServers);
|
||||
@@ -4255,8 +4224,6 @@ mod tests {
|
||||
assert_eq!(state.skills_selected, 0);
|
||||
}
|
||||
|
||||
// ── Bracketed paste ─────────────────────────────────────────────
|
||||
|
||||
fn single_field_input(prefix: &str) -> ModalInput {
|
||||
ModalInput::from_specs(
|
||||
prefix.into(),
|
||||
@@ -4359,7 +4326,8 @@ mod tests {
|
||||
fn apply_paste_targets_focused_field_in_multi_field() {
|
||||
let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers);
|
||||
let mut input = mcp_add_input();
|
||||
input.focused = 0; // URL field (first in the new order)
|
||||
// URL field
|
||||
input.focused = 0;
|
||||
state.input = Some(input);
|
||||
assert!(state.apply_paste("https://example.com"));
|
||||
let fields = &state.input.as_ref().unwrap().fields;
|
||||
@@ -4383,14 +4351,12 @@ mod tests {
|
||||
assert_eq!(input.fields.len(), 2);
|
||||
assert!(input.fields[0].text.is_empty());
|
||||
assert!(input.fields[1].text.is_empty());
|
||||
// New order: [URL (required), Name (optional)].
|
||||
// Field order: [URL (required), Name (optional)].
|
||||
assert!(input.fields[0].required);
|
||||
assert!(!input.fields[1].required);
|
||||
assert_eq!(input.focused, 0);
|
||||
}
|
||||
|
||||
// ── Word boundary helpers ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn prev_word_boundary_basic() {
|
||||
assert_eq!(prev_word_boundary("hello world", 11), 6);
|
||||
@@ -4448,8 +4414,6 @@ mod tests {
|
||||
assert_eq!(prev_word_boundary("a b", 3), 0);
|
||||
}
|
||||
|
||||
// ── delete_word_backward ────────────────────────────────────────
|
||||
|
||||
fn make_field(text: &str, cursor: usize) -> ModalInputField {
|
||||
ModalInputField {
|
||||
label: String::new(),
|
||||
@@ -4484,8 +4448,6 @@ mod tests {
|
||||
assert_eq!(f.cursor, 0);
|
||||
}
|
||||
|
||||
// ── build_action_from_input / parse_mcp_add_fields ──────────────
|
||||
|
||||
// Field order in submission: [URL / Command, Name]. URL is required.
|
||||
|
||||
#[test]
|
||||
@@ -4563,8 +4525,6 @@ mod tests {
|
||||
assert!(build_action_from_input("unknown", &texts).is_none());
|
||||
}
|
||||
|
||||
// ── Key dispatch (ModalInput::handle_key) ───────────────────────
|
||||
|
||||
fn key_event(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
|
||||
KeyEvent::new(code, modifiers)
|
||||
}
|
||||
@@ -4657,7 +4617,7 @@ mod tests {
|
||||
#[test]
|
||||
fn handle_key_submit_succeeds() {
|
||||
let mut input = mcp_add_input();
|
||||
// URL is the first (required) field in the new order.
|
||||
// URL is the first (required) field.
|
||||
input.fields[0].text = "https://example.com".into();
|
||||
let result = input.handle_key(&key_event(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(matches!(result, ModalInputOutcome::Submit { .. }));
|
||||
@@ -4674,8 +4634,6 @@ mod tests {
|
||||
assert_eq!(input.fields[0].cursor, 5);
|
||||
}
|
||||
|
||||
// ── Hook helpers with StatusFilter ───────────────────────────────
|
||||
|
||||
fn make_hook(
|
||||
name: &str,
|
||||
source_dir: &str,
|
||||
@@ -4697,9 +4655,9 @@ mod tests {
|
||||
#[test]
|
||||
fn next_visible_hook_filter_enabled() {
|
||||
let hooks = vec![
|
||||
make_hook("a", "/src", true), // disabled
|
||||
make_hook("b", "/src", false), // enabled
|
||||
make_hook("c", "/src", true), // disabled
|
||||
make_hook("a", "/src", true),
|
||||
make_hook("b", "/src", false),
|
||||
make_hook("c", "/src", true),
|
||||
];
|
||||
let collapsed = std::collections::HashSet::new();
|
||||
// From index 0, next enabled hook is index 1.
|
||||
@@ -4717,9 +4675,9 @@ mod tests {
|
||||
#[test]
|
||||
fn prev_visible_hook_filter_enabled() {
|
||||
let hooks = vec![
|
||||
make_hook("a", "/src", false), // enabled
|
||||
make_hook("b", "/src", true), // disabled
|
||||
make_hook("c", "/src", false), // enabled
|
||||
make_hook("a", "/src", false),
|
||||
make_hook("b", "/src", true),
|
||||
make_hook("c", "/src", false),
|
||||
];
|
||||
let collapsed = std::collections::HashSet::new();
|
||||
// From index 2, prev enabled hook is index 0.
|
||||
@@ -4737,9 +4695,9 @@ mod tests {
|
||||
#[test]
|
||||
fn next_visible_hook_filter_disabled() {
|
||||
let hooks = vec![
|
||||
make_hook("a", "/src", false), // enabled
|
||||
make_hook("b", "/src", true), // disabled
|
||||
make_hook("c", "/src", false), // enabled
|
||||
make_hook("a", "/src", false),
|
||||
make_hook("b", "/src", true),
|
||||
make_hook("c", "/src", false),
|
||||
];
|
||||
let collapsed = std::collections::HashSet::new();
|
||||
// From index 0, next disabled hook is index 1.
|
||||
@@ -4770,8 +4728,8 @@ mod tests {
|
||||
#[test]
|
||||
fn next_visible_hook_filter_across_groups() {
|
||||
let hooks = vec![
|
||||
make_hook("a", "/src1", true), // disabled, group 1
|
||||
make_hook("b", "/src2", false), // enabled, group 2
|
||||
make_hook("a", "/src1", true),
|
||||
make_hook("b", "/src2", false),
|
||||
];
|
||||
let collapsed = std::collections::HashSet::new();
|
||||
// With Enabled filter, hook 0 is excluded. Only hook 1 is in groups.
|
||||
@@ -4785,9 +4743,9 @@ mod tests {
|
||||
#[test]
|
||||
fn build_hook_groups_respects_filter() {
|
||||
let hooks = vec![
|
||||
make_hook("a", "/src", false), // enabled
|
||||
make_hook("b", "/src", true), // disabled
|
||||
make_hook("c", "/other", false), // enabled
|
||||
make_hook("a", "/src", false),
|
||||
make_hook("b", "/src", true),
|
||||
make_hook("c", "/other", false),
|
||||
];
|
||||
let groups = build_hook_groups(&hooks, StatusFilter::Enabled, "");
|
||||
// Two groups: /src with [0], /other with [2]. Hook 1 excluded.
|
||||
@@ -4822,8 +4780,6 @@ mod tests {
|
||||
count
|
||||
}
|
||||
|
||||
// ── Plugins: origin grouping ─────────────────────────────────────
|
||||
|
||||
fn plugins_modal_state(
|
||||
plugins: Vec<kigi_hooks_plugins_types::PluginInfo>,
|
||||
) -> ExtensionsModalState {
|
||||
|
||||
@@ -73,12 +73,10 @@ pub fn detect_with_drill(
|
||||
cursor: usize,
|
||||
drill_prefix: Option<&str>,
|
||||
) -> Option<AtContext> {
|
||||
// Cursor must be within text bounds and on a char boundary.
|
||||
if cursor > text.len() || !text.is_char_boundary(cursor) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find the rightmost `@` before the cursor.
|
||||
let at_idx = text[..cursor].rfind('@')?;
|
||||
|
||||
// Reject if `@` is preceded by alphanumeric or underscore (email-like).
|
||||
@@ -88,7 +86,6 @@ pub fn detect_with_drill(
|
||||
return None;
|
||||
}
|
||||
|
||||
// Path content starts after `@` (+ optional `!` hidden-mode marker).
|
||||
let content_start = at_idx + 1;
|
||||
let after_bang = if text[content_start..].starts_with('!') {
|
||||
content_start + 1
|
||||
@@ -102,7 +99,6 @@ pub fn detect_with_drill(
|
||||
.map(|_| after_bang + prefix.len())
|
||||
});
|
||||
|
||||
// Find the end of the @-token: first whitespace, comma, or semicolon after `@`.
|
||||
let token_end = text[at_idx + 1..]
|
||||
.char_indices()
|
||||
.find_map(|(offset, ch)| {
|
||||
@@ -117,7 +113,6 @@ pub fn detect_with_drill(
|
||||
})
|
||||
.unwrap_or(text.len());
|
||||
|
||||
// Cursor must be within the @-token.
|
||||
if cursor > token_end {
|
||||
return None;
|
||||
}
|
||||
@@ -278,8 +273,6 @@ mod tests {
|
||||
assert_eq!(ctx.path_range(), 7..10);
|
||||
}
|
||||
|
||||
// ── Drill-aware detection (whitespace inside a drilled dir name) ─────
|
||||
|
||||
#[test]
|
||||
fn drill_prefix_allows_internal_space() {
|
||||
let ctx = detect_with_drill("@my dir", 7, Some("my dir")).unwrap();
|
||||
|
||||
@@ -76,8 +76,6 @@ pub fn render_dropdown(buf: &mut Buffer, area: Rect, file_search: &FileSearchSta
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scrollbar ───────────────────────────────────────────────────────
|
||||
|
||||
if needs_scrollbar {
|
||||
let scrollbar_area = Rect {
|
||||
x: area.x + area.width - 1,
|
||||
@@ -105,7 +103,8 @@ pub fn dropdown_height(file_search: &FileSearchState, max_rows: u16) -> u16 {
|
||||
return 0;
|
||||
}
|
||||
let result_rows = (file_search.result_count() as u16).min(max_rows);
|
||||
1 + result_rows // separator + results
|
||||
// separator + results
|
||||
1 + result_rows
|
||||
}
|
||||
|
||||
/// Non-selected prefix — same width as the arrow, just spaces.
|
||||
@@ -147,7 +146,6 @@ fn render_fuzzy_item(
|
||||
Modifier::empty()
|
||||
};
|
||||
|
||||
// Fill the row with background.
|
||||
for col in x..x + width {
|
||||
if let Some(cell) = buf.cell_mut((col, y)) {
|
||||
cell.set_char(' ');
|
||||
@@ -176,15 +174,12 @@ fn render_fuzzy_item(
|
||||
}
|
||||
}
|
||||
|
||||
// Styles: primary FG for text (not dimmed), BLUE for match chars.
|
||||
// Selected rows get bold via the modifier.
|
||||
let match_style = Style::default()
|
||||
.fg(embed.map_or(theme.fuzzy_accent, |e| e.fg(theme.fuzzy_accent)))
|
||||
.bg(row_bg)
|
||||
.add_modifier(bold);
|
||||
let normal_style = Style::default().fg(text_fg).bg(row_bg).add_modifier(bold);
|
||||
|
||||
// Render path characters after prefix, with match highlighting.
|
||||
let mut indices = &item.indices[..];
|
||||
let mut col = x + PREFIX_WIDTH;
|
||||
let max_col = x + width;
|
||||
@@ -208,7 +203,6 @@ fn render_fuzzy_item(
|
||||
|
||||
let style = if is_match { match_style } else { normal_style };
|
||||
|
||||
// Write the character.
|
||||
let ch_str = &path[byte_idx..byte_idx + ch.len_utf8()];
|
||||
if let Some(cell) = buf.cell_mut((col, y)) {
|
||||
cell.set_symbol(ch_str);
|
||||
@@ -226,7 +220,6 @@ fn render_fuzzy_item(
|
||||
col += ch_width;
|
||||
}
|
||||
|
||||
// In dir mode, append '/' after the path.
|
||||
if dir_mode
|
||||
&& col < max_col
|
||||
&& let Some(cell) = buf.cell_mut((col, y))
|
||||
|
||||
@@ -32,8 +32,6 @@ use crate::views::list_pane::{
|
||||
|
||||
use kigi_ratatui_textarea::ElementId;
|
||||
|
||||
// ── Line item ───────────────────────────────────────────────────────────
|
||||
|
||||
/// A single source line for the line viewer.
|
||||
///
|
||||
/// In normal mode, each item has one `content` line (syntax-highlighted source).
|
||||
@@ -273,8 +271,6 @@ impl ListItem for SourceLine {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Comment lines ─────────────────────────────────────────────────────
|
||||
|
||||
/// An inline review comment displayed between source lines.
|
||||
pub struct CommentLine {
|
||||
pub comment_id: u64,
|
||||
@@ -421,8 +417,6 @@ impl ListItem for CommentLine {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plan viewer item ──────────────────────────────────────────────────
|
||||
|
||||
/// A viewer item: either a source line or an inline review comment.
|
||||
pub enum PlanViewerItem {
|
||||
Source(Box<SourceLine>),
|
||||
@@ -523,8 +517,6 @@ impl ListItem for PlanViewerItem {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Viewer state ────────────────────────────────────────────────────────
|
||||
|
||||
/// What kind of content the line viewer is showing.
|
||||
///
|
||||
/// Replaces string-based type sniffing (`title_override == Some("plan.md")`)
|
||||
@@ -772,7 +764,8 @@ impl LineViewerState {
|
||||
};
|
||||
|
||||
let prefix_width = digit_count(source_line_count(content).max(1)) + 1;
|
||||
let scrollbar_width = SCROLLBAR_TOTAL_COLS as usize; // gap + track
|
||||
// gap + track
|
||||
let scrollbar_width = SCROLLBAR_TOTAL_COLS as usize;
|
||||
let content_width = (width as usize)
|
||||
.saturating_sub(prefix_width)
|
||||
.saturating_sub(scrollbar_width);
|
||||
@@ -851,7 +844,8 @@ impl LineViewerState {
|
||||
if let Some(range) = self.initial_scroll_range.take() {
|
||||
let vp = height as usize;
|
||||
let total = self.lines.len();
|
||||
let pad = 3usize; // inner padding (lines of context above/below)
|
||||
// inner padding (lines of context above/below)
|
||||
let pad = 3usize;
|
||||
|
||||
if total <= vp {
|
||||
// Entire file fits — no scrolling needed.
|
||||
@@ -983,8 +977,6 @@ impl LineViewerState {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Syntax highlighting ─────────────────────────────────────────────────
|
||||
|
||||
/// Build syntax-highlighted source lines from file content.
|
||||
fn build_source_lines(path: &Path, content: &str) -> Vec<SourceLine> {
|
||||
let syntect = get_syntect();
|
||||
@@ -1160,8 +1152,6 @@ fn digit_count(n: usize) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering helpers ───────────────────────────────────────────────────
|
||||
|
||||
/// Build a single review-footer shortcut button styled to match the
|
||||
/// shortcut hints in `modal_window::render_modal_shortcuts`:
|
||||
/// bold key in the primary text color + dim label, with a
|
||||
@@ -1298,7 +1288,8 @@ pub fn render_line_viewer(
|
||||
&rel_path_str,
|
||||
line_range.as_deref(),
|
||||
theme,
|
||||
false, // no @ prefix in viewer title
|
||||
// no @ prefix in viewer title
|
||||
false,
|
||||
);
|
||||
// Add bg to all spans (title sits on the border).
|
||||
for span in &mut title.spans {
|
||||
@@ -1334,13 +1325,14 @@ pub fn render_line_viewer(
|
||||
let mut right_edge = popup_area.x + popup_area.width - 1;
|
||||
|
||||
if !viewer.feedback_active() {
|
||||
let close_text = crate::glyphs::ballot_x(); // ✗ (ASCII on legacy ConHost)
|
||||
// ✗ (ASCII on legacy ConHost)
|
||||
let close_text = crate::glyphs::ballot_x();
|
||||
// Label is `[✗] ` (trailing space, no leading space). The
|
||||
// fullscreen button's label has no trailing space when the
|
||||
// close is visible, so the two buttons abut flush as `[↗][✗]`,
|
||||
// tucked under the top-right corner with one space inside the
|
||||
// frame on each side: ` [↗][✗] `.
|
||||
let close_w: u16 = 4; // "[✗] "
|
||||
let close_w: u16 = 4;
|
||||
if popup_area.width > close_w + 2 {
|
||||
let close_x = right_edge - close_w;
|
||||
let close_style = if viewer.close_hovered {
|
||||
@@ -1370,7 +1362,8 @@ pub fn render_line_viewer(
|
||||
// `[↗][✗]`. When the close is hidden (plan-review mode) the
|
||||
// fullscreen keeps its trailing space so it doesn't crowd the
|
||||
// corner `╮`.
|
||||
let fs_icon = crate::glyphs::enlarge(); // ↗ (ASCII on legacy ConHost)
|
||||
// ↗ (ASCII on legacy ConHost)
|
||||
let fs_icon = crate::glyphs::enlarge();
|
||||
let close_visible = viewer.close_button_area.is_some();
|
||||
let (fs_label, fs_w): (String, u16) = if close_visible {
|
||||
(format!(" [{fs_icon}]"), 4)
|
||||
@@ -1541,7 +1534,8 @@ pub fn render_line_viewer(
|
||||
let badge_style = Style::default().fg(theme.accent_plan).bg(theme.bg_base);
|
||||
|
||||
let separator = " | ";
|
||||
let sep_w: u16 = 5; // separator is fixed-width ASCII; matches modal_window.rs:565
|
||||
// separator is fixed-width ASCII; matches modal_window.rs:565
|
||||
let sep_w: u16 = 5;
|
||||
let sep_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
|
||||
|
||||
// Total width: [action] + (sep + revise)? + sep + comment[badge?] + (sep + quit)?
|
||||
|
||||
@@ -94,8 +94,6 @@ impl FileSearchState {
|
||||
&self.root
|
||||
}
|
||||
|
||||
// ── Visibility ──────────────────────────────────────────────────────
|
||||
|
||||
/// Whether the dropdown should be visible.
|
||||
pub fn is_visible(&self) -> bool {
|
||||
self.context.is_some() && !self.results.topk.is_empty()
|
||||
@@ -139,8 +137,6 @@ impl FileSearchState {
|
||||
self.context.as_ref().is_some_and(|c| c.is_dir_mode())
|
||||
}
|
||||
|
||||
// ── Context updates ─────────────────────────────────────────────────
|
||||
|
||||
/// Anchor (or clear) the drilled directory for whitespace-aware detection.
|
||||
pub fn set_drill_prefix(&mut self, prefix: Option<String>) {
|
||||
self.drill_prefix = prefix;
|
||||
@@ -210,8 +206,6 @@ impl FileSearchState {
|
||||
self.results = FuzzyMatcherDaemonResults::default();
|
||||
}
|
||||
|
||||
// ── Tick / polling ──────────────────────────────────────────────────
|
||||
|
||||
/// Poll the daemon for new results. Returns `true` if results changed.
|
||||
///
|
||||
/// Should be called on every tick (~4ms) while the dropdown is potentially visible.
|
||||
@@ -244,8 +238,6 @@ impl FileSearchState {
|
||||
false
|
||||
}
|
||||
|
||||
// ── Navigation ──────────────────────────────────────────────────────
|
||||
|
||||
/// Move selection by `delta` items (negative = up, positive = down).
|
||||
pub fn move_selection(&mut self, delta: isize) {
|
||||
let len = self.results.topk.len();
|
||||
@@ -275,8 +267,6 @@ impl FileSearchState {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selection / replacement ─────────────────────────────────────────
|
||||
|
||||
/// Select the hovered item (for click-to-accept).
|
||||
/// Returns `true` if there was a valid hovered item to select.
|
||||
pub fn select_hovered(&mut self) -> bool {
|
||||
@@ -323,7 +313,7 @@ impl FileSearchState {
|
||||
}
|
||||
dismiss = true;
|
||||
} else {
|
||||
dismiss = false; // Stay in completion mode (drill-down).
|
||||
dismiss = false;
|
||||
}
|
||||
} else {
|
||||
// File mode: append trailing space if at end of input.
|
||||
|
||||
@@ -66,7 +66,6 @@ impl FpsHud {
|
||||
last_refresh: None,
|
||||
}
|
||||
}
|
||||
/// Whether the HUD is currently enabled.
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
@@ -42,10 +42,6 @@ fn per_model_row_count(models: &[(String, u64)]) -> u16 {
|
||||
(shown + overflow) as u16
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token budget color
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Choose the progress bar fill color based on usage percentage.
|
||||
fn budget_color(pct: f32, theme: &Theme) -> Color {
|
||||
if pct > 0.80 {
|
||||
@@ -73,10 +69,6 @@ fn format_elapsed(ms: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status label
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn status_label(goal: &GoalDisplayState) -> (&'static str, Color, String) {
|
||||
let theme = Theme::current();
|
||||
match goal.status {
|
||||
@@ -91,10 +83,6 @@ fn status_label(goal: &GoalDisplayState) -> (&'static str, Color, String) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wrapping helpers — pause-message reason block
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wrap a string into rows of at most `width` terminal columns.
|
||||
///
|
||||
/// Splits on whitespace first, then hard-splits any token wider than
|
||||
@@ -200,7 +188,8 @@ fn truncate_to_width(text: &str, budget: usize) -> String {
|
||||
if UnicodeWidthStr::width(text) <= budget {
|
||||
return text.to_owned();
|
||||
}
|
||||
let target = budget.saturating_sub(1); // room for ellipsis
|
||||
// Room for the ellipsis.
|
||||
let target = budget.saturating_sub(1);
|
||||
let mut out = String::new();
|
||||
let mut w = 0usize;
|
||||
for ch in text.chars() {
|
||||
@@ -247,10 +236,6 @@ fn format_pause_reason(msg: &str) -> String {
|
||||
format!("Reason: {}", strip_control_chars(msg, true))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// True when the goal carries at least one signal from the
|
||||
/// completion classifier — gates rendering of the modal's
|
||||
/// "Completion review" section so a goal that has never been
|
||||
@@ -407,7 +392,8 @@ pub fn goal_detail_area(screen: Rect, goal: &GoalDisplayState, todos: &[TodoItem
|
||||
0
|
||||
};
|
||||
let todo_lines = if todos.is_empty() {
|
||||
1u16 // "No progress items yet"
|
||||
// "No progress items yet"
|
||||
1u16
|
||||
} else {
|
||||
let item_count = todos.len().min(MAX_TODO_DISPLAY) as u16;
|
||||
let overflow = if todos.len() > MAX_TODO_DISPLAY {
|
||||
@@ -415,7 +401,8 @@ pub fn goal_detail_area(screen: Rect, goal: &GoalDisplayState, todos: &[TodoItem
|
||||
} else {
|
||||
0
|
||||
};
|
||||
1 + item_count + overflow // header + items + optional "+N more"
|
||||
// header + items + optional "+N more"
|
||||
1 + item_count + overflow
|
||||
};
|
||||
let subagent_lines = if goal.current_subagent_role.is_some() {
|
||||
// blank + role line, plus the detail line ONLY when there's a live
|
||||
@@ -438,7 +425,8 @@ pub fn goal_detail_area(screen: Rect, goal: &GoalDisplayState, todos: &[TodoItem
|
||||
0
|
||||
};
|
||||
let history_lines = if goal.last_event.is_some() {
|
||||
3u16 // blank + header + event line
|
||||
// blank + header + event line
|
||||
3u16
|
||||
} else {
|
||||
0
|
||||
};
|
||||
@@ -539,12 +527,14 @@ pub fn render_goal_detail(
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let title_cols = close_x.saturating_sub(area.x + 3) as usize; // 1-col gap before [✗]
|
||||
// 1-col gap before [✗].
|
||||
let title_cols = close_x.saturating_sub(area.x + 3) as usize;
|
||||
// Leading + trailing space around the objective text.
|
||||
let objective_budget = title_cols
|
||||
.saturating_sub(unicode_width::UnicodeWidthStr::width(
|
||||
spinner_prefix.as_str(),
|
||||
))
|
||||
.saturating_sub(2); // leading + trailing space
|
||||
.saturating_sub(2);
|
||||
let cleaned = sanitize_title(&goal.objective);
|
||||
let objective = if cleaned.is_empty() {
|
||||
"Active Goal".to_owned()
|
||||
@@ -583,7 +573,6 @@ pub fn render_goal_detail(
|
||||
let x = inner.x + 1;
|
||||
let w = inner.width.saturating_sub(2);
|
||||
|
||||
// ── Status line ──
|
||||
let (status_text, status_color, phase_text) = status_label(goal);
|
||||
let mut status_spans = vec![
|
||||
Span::styled("Status: ", Style::default().fg(theme.gray)),
|
||||
@@ -608,7 +597,6 @@ pub fn render_goal_detail(
|
||||
return Some(close_rect);
|
||||
}
|
||||
|
||||
// ── Pause hint (only for any paused variant) ──
|
||||
if goal.status.is_paused() {
|
||||
let hint = format!(
|
||||
"Status: {} \u{2014} type /goal resume to continue",
|
||||
@@ -627,8 +615,6 @@ pub fn render_goal_detail(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reason block (only when paused AND pause_message is set) ──
|
||||
//
|
||||
// Double-gate on `is_paused()`: the shell clears `pause_message` on
|
||||
// every transition out of a paused state, but defending against a
|
||||
// stale value on the wire is cheap and means a future shell bug
|
||||
@@ -651,7 +637,6 @@ pub fn render_goal_detail(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Budget / tokens line with optional progress bar ──
|
||||
let tokens_str =
|
||||
format_tokens_compact(goal.live_tokens_used(context_used, active_subagent_tokens));
|
||||
let elapsed_str = format_elapsed(goal.live_elapsed_ms());
|
||||
@@ -686,7 +671,6 @@ pub fn render_goal_detail(
|
||||
return Some(close_rect);
|
||||
}
|
||||
|
||||
// Progress bar — only when a budget is set.
|
||||
if has_budget {
|
||||
let bar_w = w.min(30);
|
||||
let fg = budget_color(pct, &theme);
|
||||
@@ -708,14 +692,12 @@ pub fn render_goal_detail(
|
||||
return Some(close_rect);
|
||||
}
|
||||
|
||||
// ── Blank separator ──
|
||||
y += 1;
|
||||
|
||||
if y >= inner.y + inner.height {
|
||||
return Some(close_rect);
|
||||
}
|
||||
|
||||
// ── Progress section (todo items) ──
|
||||
if todos.is_empty() {
|
||||
buf.set_line_safe(
|
||||
x,
|
||||
@@ -784,7 +766,6 @@ pub fn render_goal_detail(
|
||||
return Some(close_rect);
|
||||
}
|
||||
|
||||
// ── Active subagent metrics (with a leading blank separator) ──
|
||||
if let Some(ref role) = goal.current_subagent_role {
|
||||
// Leading blank — budgeted in `subagent_lines` (renders only with the block).
|
||||
y += 1;
|
||||
@@ -811,7 +792,6 @@ pub fn render_goal_detail(
|
||||
y += 1;
|
||||
|
||||
if y < inner.y + inner.height {
|
||||
// Subagent detail line.
|
||||
let mut detail_parts: Vec<String> = Vec::new();
|
||||
if let Some(tok) = goal.live_subagent_tokens {
|
||||
detail_parts.push(format!(
|
||||
@@ -887,9 +867,7 @@ pub fn render_goal_detail(
|
||||
return Some(close_rect);
|
||||
}
|
||||
|
||||
// ── Completion review (only when classifier has run at least once) ──
|
||||
if has_classifier_activity(goal) {
|
||||
// Blank separator.
|
||||
y += 1;
|
||||
if y >= inner.y + inner.height {
|
||||
return Some(close_rect);
|
||||
@@ -968,7 +946,6 @@ pub fn render_goal_detail(
|
||||
return Some(close_rect);
|
||||
}
|
||||
|
||||
// ── Recent history (with a leading blank separator) ──
|
||||
if goal.last_event.is_some() {
|
||||
// Leading blank — budgeted in `history_lines` (renders only with the block).
|
||||
y += 1;
|
||||
@@ -1012,7 +989,6 @@ pub fn render_goal_detail(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commands hint ──
|
||||
if y < inner.y + inner.height {
|
||||
let hint_style = Style::default().fg(theme.gray_dim);
|
||||
buf.set_line_safe(
|
||||
@@ -1029,10 +1005,6 @@ pub fn render_goal_detail(
|
||||
Some(close_rect)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1565,7 +1537,8 @@ mod tests {
|
||||
// display width (not bytes); the full id must not appear and the
|
||||
// ellipsis marker must be present.
|
||||
let long_id = "x".repeat(200);
|
||||
let cjk_id = "宽".repeat(120); // each glyph is 2 display columns
|
||||
// Each glyph is 2 display columns.
|
||||
let cjk_id = "宽".repeat(120);
|
||||
let mut goal = make_goal();
|
||||
goal.live_tokens_by_model = vec![(long_id.clone(), 12_000), (cjk_id.clone(), 8_000)];
|
||||
// render_to_text renders at width 100; mirror that exactly so the
|
||||
@@ -1960,8 +1933,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Todo rendering tests -----------------------------------------------
|
||||
|
||||
fn make_todo(content: &str, status: TodoStatus) -> TodoItem {
|
||||
TodoItem {
|
||||
content: content.to_owned(),
|
||||
@@ -2068,13 +2039,12 @@ mod tests {
|
||||
assert!(result.ends_with('\u{2026}'));
|
||||
}
|
||||
|
||||
// -- objective in the modal title ---------------------------------------
|
||||
|
||||
#[test]
|
||||
fn modal_title_renders_objective() {
|
||||
// The objective must appear in the modal title (top border) so the
|
||||
// user can see which goal is running — not a static placeholder.
|
||||
let goal = make_goal(); // objective = "Implement dark mode"
|
||||
// objective = "Implement dark mode"
|
||||
let goal = make_goal();
|
||||
let text = render_to_text(&goal);
|
||||
assert!(
|
||||
text.contains("Implement dark mode"),
|
||||
@@ -2109,7 +2079,8 @@ mod tests {
|
||||
// can't overflow the title columns into the close button / border;
|
||||
// the close button must survive.
|
||||
let mut goal = make_goal();
|
||||
goal.objective = "宽".repeat(120); // 240 display columns
|
||||
// 240 display columns.
|
||||
goal.objective = "宽".repeat(120);
|
||||
let screen = Rect::new(0, 0, 100, 40);
|
||||
let mut buf = ratatui::buffer::Buffer::empty(screen);
|
||||
let area = goal_detail_area(screen, &goal, &[]);
|
||||
@@ -2126,8 +2097,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- commands hint must not be clipped ----------------------------------
|
||||
|
||||
#[test]
|
||||
fn commands_hint_visible_without_subagent_or_history() {
|
||||
// With no active subagent and no recent-history event, the height
|
||||
@@ -2159,8 +2128,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- details path existence check ---------------------------------------
|
||||
|
||||
#[test]
|
||||
fn classifier_details_display_handles_missing_present_and_none() {
|
||||
// Existence is a precomputed bool, so the display is pure: no path →
|
||||
@@ -2180,7 +2147,8 @@ mod tests {
|
||||
fn modal_details_row_shows_unavailable_for_missing_file() {
|
||||
// A reported path whose cached existence is false (fail-open may not
|
||||
// have written it) must render "(unavailable)" not a dangling path.
|
||||
let mut goal = make_goal(); // make_goal default: last_classifier_details_exists = false
|
||||
// make_goal default: last_classifier_details_exists = false
|
||||
let mut goal = make_goal();
|
||||
goal.last_classifier_verdict = Some(GoalClassifierVerdict::Achieved);
|
||||
goal.last_classifier_details_path = Some("/no/such/path/zzz-details.md".into());
|
||||
let text = render_to_text(&goal);
|
||||
@@ -2194,8 +2162,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Attempts em-dash branch --------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn modal_attempts_shows_em_dash_when_classifier_active_without_counts() {
|
||||
// Completion review renders (a verdict is present) but no run counter
|
||||
@@ -2215,8 +2181,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Recent-History humanization ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn humanize_goal_event_maps_wire_vocabulary() {
|
||||
assert_eq!(
|
||||
@@ -2343,8 +2307,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- title control-char / boundary handling -----------------------------
|
||||
|
||||
#[test]
|
||||
fn modal_title_collapses_control_chars_to_one_row() {
|
||||
// A newline in the objective must be collapsed to a space so the whole
|
||||
@@ -2362,7 +2324,8 @@ mod tests {
|
||||
#[test]
|
||||
fn modal_title_blank_objective_falls_back_to_active_goal() {
|
||||
let mut goal = make_goal();
|
||||
goal.objective = " \n\t ".into(); // whitespace/control only
|
||||
// whitespace/control only
|
||||
goal.objective = " \n\t ".into();
|
||||
let text = render_to_text(&goal);
|
||||
assert!(
|
||||
text.contains("Active Goal"),
|
||||
@@ -2377,7 +2340,8 @@ mod tests {
|
||||
// One column over → truncated with the ellipsis.
|
||||
assert_eq!(truncate_to_width("abcde", 4), "abc\u{2026}");
|
||||
// Zero-width combining marks don't consume the budget.
|
||||
let combining = "a\u{0301}b\u{0301}"; // 2 display columns
|
||||
// 2 display columns.
|
||||
let combining = "a\u{0301}b\u{0301}";
|
||||
assert_eq!(
|
||||
unicode_width::UnicodeWidthStr::width(combining),
|
||||
2,
|
||||
@@ -2388,8 +2352,6 @@ mod tests {
|
||||
assert_eq!(truncate_to_width("x", 0), "\u{2026}");
|
||||
}
|
||||
|
||||
// -- subagent / classifier height combos --------------------------------
|
||||
|
||||
#[test]
|
||||
fn subagent_just_spawned_budgets_no_detail_row() {
|
||||
// A subagent with no live metrics yet renders blank + role (2 rows, no
|
||||
|
||||
@@ -18,10 +18,6 @@ use nucleo::{
|
||||
pattern::{CaseMatching, MultiPattern, Normalization},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public data types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single entry in the prompt history.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HistoryEntry {
|
||||
@@ -35,20 +31,12 @@ pub struct HistoryMatchResult {
|
||||
pub indices: Vec<u32>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared state (daemon → UI)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Snapshot {
|
||||
items: Arc<[HistoryMatchResult]>,
|
||||
generation: usize,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Daemon messages (UI → daemon)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum Msg {
|
||||
SetItems(Vec<String>),
|
||||
SetItemsAndQuery(Vec<String>, String),
|
||||
@@ -56,10 +44,6 @@ enum Msg {
|
||||
Stop,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background daemon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Daemon {
|
||||
shared: Arc<Mutex<Snapshot>>,
|
||||
tx: SyncSender<Msg>,
|
||||
@@ -265,10 +249,6 @@ impl Drop for Daemon {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HistorySearchState (UI-thread side)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// UI-side state for the history search overlay.
|
||||
///
|
||||
/// The UI thread never runs nucleo. All matching happens on the daemon
|
||||
@@ -507,10 +487,6 @@ impl HistorySearchState {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -610,7 +586,8 @@ mod tests {
|
||||
"",
|
||||
);
|
||||
assert_eq!(state.result_count(), 5);
|
||||
assert_eq!(state.selected, 4); // bottom (most recent) selected on open
|
||||
// bottom (most recent) selected on open
|
||||
assert_eq!(state.selected, 4);
|
||||
|
||||
// Navigate up off the bottom — selection is no longer sticky.
|
||||
state.move_up();
|
||||
|
||||
@@ -31,7 +31,6 @@ const SHORTCUT_ID_SELECT_NONE: usize = 1;
|
||||
const SHORTCUT_ID_CONFIRM: usize = 2;
|
||||
const SHORTCUT_ID_CANCEL: usize = 3;
|
||||
|
||||
/// State for the import-claude modal.
|
||||
pub struct ImportClaudeModalState {
|
||||
/// The full plan as scanned from `.claude/` sources.
|
||||
pub plan: ImportPlan,
|
||||
@@ -92,7 +91,8 @@ enum Row {
|
||||
Item {
|
||||
scope: Scope,
|
||||
item_index: usize,
|
||||
flat_index: usize, // index into `selected`
|
||||
/// Index into `selected`.
|
||||
flat_index: usize,
|
||||
},
|
||||
/// Blank spacer.
|
||||
Blank,
|
||||
@@ -319,7 +319,7 @@ impl ImportClaudeModalState {
|
||||
.then(|| section_key.clone())
|
||||
}
|
||||
Row::TypeHeader { section_key, .. } => {
|
||||
let indicator_start = area.x + 2; // indent=2
|
||||
let indicator_start = area.x + 2;
|
||||
(column >= indicator_start && column < indicator_start + 2)
|
||||
.then(|| section_key.clone())
|
||||
}
|
||||
@@ -986,7 +986,7 @@ fn render_item_line<'a>(
|
||||
let label = format_item_label(item);
|
||||
let label_style = with_bg(Style::default().fg(theme.text_primary), focused, theme);
|
||||
// Items live under TypeHeaders (indent 2) under ScopeHeaders (indent 0).
|
||||
// Indent items at 4 spaces total so they visually nest below their group.
|
||||
// Indent items at 6 spaces total so they visually nest below their group.
|
||||
Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("[", bracket_style),
|
||||
@@ -1236,9 +1236,9 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Regression: clicking on an item row with the mouse must toggle that
|
||||
/// item's selection. After the inline-shortcut refactor, the handler
|
||||
/// now consults `state.shortcuts` first; verify a click on a row that
|
||||
/// is NOT a shortcut still falls through to the row-toggle path.
|
||||
/// item's selection. The chrome handler consults `state.shortcuts`
|
||||
/// first; verify a click on a row that is NOT a shortcut still falls
|
||||
/// through to the row-toggle path.
|
||||
#[test]
|
||||
fn mouse_click_on_item_row_toggles() {
|
||||
use crossterm::event::{MouseButton, MouseEventKind};
|
||||
@@ -1258,7 +1258,7 @@ mod tests {
|
||||
.expect("sample plan has items");
|
||||
// With scroll_offset=0 the item appears at content_area.y + item_row_index.
|
||||
let click_y = m.content_area.unwrap().y + item_row_index as u16;
|
||||
let click_x = m.content_area.unwrap().x + 5; // anywhere in the row
|
||||
let click_x = m.content_area.unwrap().x + 5;
|
||||
// Capture initial selection state for that item.
|
||||
let flat_idx = match &rows[item_row_index] {
|
||||
Row::Item { flat_index, .. } => *flat_index,
|
||||
@@ -1287,7 +1287,7 @@ mod tests {
|
||||
// First row is the Global ScopeHeader (sample_plan has Global items).
|
||||
let rows = build_rows(&m.plan, &m.cwd, &m.collapsed);
|
||||
assert!(matches!(rows.first(), Some(Row::ScopeHeader { .. })));
|
||||
let click_y = m.content_area.unwrap().y; // top row
|
||||
let click_y = m.content_area.unwrap().y;
|
||||
let click_x = m.content_area.unwrap().x + 5;
|
||||
// All items start selected.
|
||||
assert!(m.selected.iter().all(|&s| s));
|
||||
|
||||
@@ -41,10 +41,6 @@ pub enum ListLayoutCache {
|
||||
}
|
||||
|
||||
impl ListLayoutCache {
|
||||
// -----------------------------------------------------------------------
|
||||
// Constructors
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Create a fixed-height cache for `count` items (all height 1).
|
||||
pub fn fixed(count: usize) -> Self {
|
||||
Self::FixedHeight { count }
|
||||
@@ -91,10 +87,6 @@ impl ListLayoutCache {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Queries
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Total height in visual lines.
|
||||
pub fn total_height(&self) -> usize {
|
||||
match self {
|
||||
@@ -146,15 +138,15 @@ impl ListLayoutCache {
|
||||
}
|
||||
Self::Variable { prefix_sums, .. } => {
|
||||
if prefix_sums.len() <= 1 {
|
||||
return None; // empty
|
||||
return None;
|
||||
}
|
||||
// Binary search: find the largest i such that prefix_sums[i] <= y.
|
||||
// partition_point returns the first index where prefix_sums[i] > y,
|
||||
// so we subtract 1.
|
||||
let pos = prefix_sums.partition_point(|&s| s <= y);
|
||||
let idx = pos.saturating_sub(1);
|
||||
// Clamp to valid item range
|
||||
let max_idx = prefix_sums.len() - 2; // last valid item index
|
||||
// Clamp to valid item range: last valid item index.
|
||||
let max_idx = prefix_sums.len() - 2;
|
||||
Some(idx.min(max_idx))
|
||||
}
|
||||
}
|
||||
@@ -169,10 +161,6 @@ impl ListLayoutCache {
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -257,7 +245,8 @@ mod tests {
|
||||
assert_eq!(cache.virtual_y(0), 0);
|
||||
assert_eq!(cache.item_at_y(0), Some(0));
|
||||
assert_eq!(cache.item_at_y(4), Some(0));
|
||||
assert_eq!(cache.item_at_y(5), Some(0)); // clamped
|
||||
// Clamped.
|
||||
assert_eq!(cache.item_at_y(5), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,10 +26,6 @@ use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::text::Line;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListPaneStyle — configurable colors for the framework's post-pass overlays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Visual style configuration for a `ListPane`.
|
||||
///
|
||||
/// Controls colors for selection highlighting, input bar, and other
|
||||
@@ -107,10 +103,6 @@ impl Default for ListPaneStyle {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListItem trait
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Trait that items in a `ListPane` must implement.
|
||||
///
|
||||
/// Items are owned by the **model** (not the view). The view borrows them
|
||||
@@ -133,10 +125,6 @@ impl Default for ListPaneStyle {
|
||||
/// rendering; the default [`render()`] and [`desired_height()`] are derived
|
||||
/// automatically. Items that override [`render()`] bypass the framework.
|
||||
pub trait ListItem {
|
||||
// =======================================================================
|
||||
// Content-based API (preferred)
|
||||
// =======================================================================
|
||||
|
||||
/// The styled content to display — one logical line of text.
|
||||
///
|
||||
/// The framework handles wrapping (Wrap mode) and truncation (NoWrap mode)
|
||||
@@ -184,10 +172,6 @@ pub trait ListItem {
|
||||
None
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Custom rendering API (escape hatch)
|
||||
// =======================================================================
|
||||
|
||||
/// Render this item into the given area.
|
||||
///
|
||||
/// Override this **only** when the content/prefix model doesn't fit.
|
||||
@@ -236,10 +220,6 @@ pub trait ListItem {
|
||||
(textwrap::wrap(&flat, opts).len() as u16).max(1)
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Identity & behavior
|
||||
// =======================================================================
|
||||
|
||||
/// Stable identity that survives insertions, removals, and reordering.
|
||||
///
|
||||
/// Must be unique within the list. Used so that selection state persists
|
||||
@@ -266,10 +246,6 @@ pub trait ListItem {
|
||||
false
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Search / filter
|
||||
// =======================================================================
|
||||
|
||||
/// Plain text for search/filter matching.
|
||||
///
|
||||
/// The framework calls `regex.is_match(item.search_text())` during
|
||||
|
||||
@@ -61,7 +61,6 @@ pub struct ListPane<'a, T: ListItem> {
|
||||
}
|
||||
|
||||
impl<'a, T: ListItem> ListPane<'a, T> {
|
||||
/// Create a new `ListPane` widget borrowing the given items.
|
||||
pub fn new(items: &'a [T]) -> Self {
|
||||
Self {
|
||||
items,
|
||||
@@ -70,13 +69,11 @@ impl<'a, T: ListItem> ListPane<'a, T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set whether this pane has keyboard focus.
|
||||
pub fn focused(mut self, focused: bool) -> Self {
|
||||
self.focused = focused;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the visual style for selection/highlight overlays.
|
||||
pub fn style(mut self, style: ListPaneStyle) -> Self {
|
||||
self.style = style;
|
||||
self
|
||||
@@ -124,16 +121,13 @@ impl<T: ListItem> StatefulWidget for ListPane<'_, T> {
|
||||
let scaled_total = (total_height / scale) as u16;
|
||||
let scaled_offset = (state.scroll_offset() / scale) as u16;
|
||||
|
||||
// Split area for scrollbar if content overflows.
|
||||
let (content_area, scrollbar_area) = maybe_split_for_scrollbar(list_area, scaled_total);
|
||||
|
||||
// Store scrollbar area for click/scroll hit-testing.
|
||||
state.set_scrollbar_area(scrollbar_area);
|
||||
|
||||
// Render items into the content area.
|
||||
self.render_items(content_area, buf, state);
|
||||
|
||||
// Render corner overlay indicators.
|
||||
render_corner_indicators(content_area, buf, state, &self.style);
|
||||
|
||||
// Render "Copied!" toast (bottom-right corner, briefly after y-copy).
|
||||
@@ -153,7 +147,6 @@ impl<T: ListItem> StatefulWidget for ListPane<'_, T> {
|
||||
}
|
||||
}
|
||||
|
||||
// Render scrollbar with style colors.
|
||||
let track_style = Style::default().bg(self.style.scrollbar_bg);
|
||||
let thumb_style = Style::default()
|
||||
.fg(self.style.scrollbar_fg)
|
||||
@@ -239,7 +232,6 @@ impl<T: ListItem> ListPane<'_, T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the visible items into the content area.
|
||||
fn render_items(&self, area: Rect, buf: &mut Buffer, state: &ListPaneState) {
|
||||
let visible = state.visible_range();
|
||||
if visible.is_empty() {
|
||||
@@ -271,7 +263,6 @@ impl<T: ListItem> ListPane<'_, T> {
|
||||
// How many rows to skip at the top of this item (only for the first item).
|
||||
let skip = if vi == first_vi { skip_rows } else { 0 };
|
||||
|
||||
// How many rows of this item are actually visible.
|
||||
let visible_h = item_h.saturating_sub(skip);
|
||||
let rows_available = viewport_bottom.saturating_sub(cursor_y);
|
||||
let rows_to_render = visible_h.min(rows_available);
|
||||
@@ -295,7 +286,6 @@ impl<T: ListItem> ListPane<'_, T> {
|
||||
// If partially visible (skip > 0 or truncated at bottom), render
|
||||
// into a scratch area and blit the visible portion.
|
||||
if skip == 0 && rows_to_render == item_h {
|
||||
// Fast path: render directly into buf.
|
||||
let item_area = Rect {
|
||||
x: area.x,
|
||||
y: cursor_y,
|
||||
@@ -315,7 +305,6 @@ impl<T: ListItem> ListPane<'_, T> {
|
||||
item.render(item_area, buf, is_selected, self.focused);
|
||||
}
|
||||
} else {
|
||||
// Slow path: render into a temp buffer, then blit the visible rows.
|
||||
let full_area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -356,11 +345,10 @@ impl<T: ListItem> ListPane<'_, T> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Post-pass 1: Selection background overlay ---
|
||||
// Patches only the bg of each cell, preserving fg, content, and
|
||||
// modifiers. Applied after item render so items don't need to
|
||||
// know about selection colors.
|
||||
// Shown when focused, or when `show_selection_when_unfocused` is set.
|
||||
// Selection background overlay: patches only the bg of each cell,
|
||||
// preserving fg, content, and modifiers. Applied after item render
|
||||
// so items don't need to know about selection colors. Shown when
|
||||
// focused, or when `show_selection_when_unfocused` is set.
|
||||
let show_sel = self.focused || state.show_selection_when_unfocused();
|
||||
if is_selected && show_sel {
|
||||
// Use different bg for visual range vs cursor line.
|
||||
@@ -381,10 +369,10 @@ impl<T: ListItem> ListPane<'_, T> {
|
||||
buf.set_style(sel_area, Style::default().bg(bg));
|
||||
}
|
||||
|
||||
// --- Post-pass 2: Match highlight overlay ---
|
||||
// Invert (REVERSED) the cells covering each match of the active
|
||||
// query. Gated on `show_highlights` so callers can suppress the
|
||||
// overlay (e.g. after accepting a filter, where every line matches).
|
||||
// Match highlight overlay: inverts (REVERSED) the cells covering
|
||||
// each match of the active query. Gated on `show_highlights` so
|
||||
// callers can suppress the overlay (e.g. after accepting a
|
||||
// filter, where every line matches).
|
||||
if state.show_highlights
|
||||
&& let Some(matcher) = state.matcher()
|
||||
{
|
||||
@@ -402,11 +390,11 @@ impl<T: ListItem> ListPane<'_, T> {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Post-pass 3: Truncation ellipsis ---
|
||||
// If the item's full wrapped height exceeds its allocated layout
|
||||
// height, place "…" on the last rendered row. This only triggers
|
||||
// in NoWrap mode (where item_h == 1 regardless of content length).
|
||||
// Viewport clipping does NOT trigger this — only true text truncation.
|
||||
// Truncation ellipsis: if the item's full wrapped height exceeds
|
||||
// its allocated layout height, place "…" on the last rendered
|
||||
// row. This only triggers in NoWrap mode (where item_h == 1
|
||||
// regardless of content length). Viewport clipping does NOT
|
||||
// trigger this — only true text truncation.
|
||||
if item.desired_height(area.width) > item_h && rows_to_render > 0 {
|
||||
let last_y = cursor_y + rows_to_render - 1;
|
||||
render_truncation_ellipsis(buf, last_y, area.x, area.width);
|
||||
@@ -417,10 +405,6 @@ impl<T: ListItem> ListPane<'_, T> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Truncation ellipsis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Place a `…` at the end of text on row `y` to indicate truncation.
|
||||
///
|
||||
/// Scans from right to left for the rightmost non-space cell. If there is
|
||||
@@ -435,7 +419,8 @@ fn render_truncation_ellipsis(buf: &mut Buffer, y: u16, x_start: u16, width: u16
|
||||
return;
|
||||
}
|
||||
|
||||
let x_end = x_start + width; // exclusive
|
||||
// exclusive
|
||||
let x_end = x_start + width;
|
||||
|
||||
// Find rightmost non-space cell.
|
||||
let mut last_text_x: Option<u16> = None;
|
||||
@@ -447,9 +432,12 @@ fn render_truncation_ellipsis(buf: &mut Buffer, y: u16, x_start: u16, width: u16
|
||||
}
|
||||
|
||||
let (ellipsis_x, donor_x) = match last_text_x {
|
||||
Some(x) if x + 1 < x_end => (x + 1, x), // append after text, inherit from text
|
||||
Some(x) => (x, x), // replace last char, keep its style
|
||||
None => return, // entire row is blank
|
||||
// append after text, inherit from text
|
||||
Some(x) if x + 1 < x_end => (x + 1, x),
|
||||
// replace last char, keep its style
|
||||
Some(x) => (x, x),
|
||||
// entire row is blank
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Inherit fg from the donor cell, preserve bg of the target cell.
|
||||
@@ -459,10 +447,6 @@ fn render_truncation_ellipsis(buf: &mut Buffer, y: u16, x_start: u16, width: u16
|
||||
cell.fg = fg;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Corner overlay indicators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render single-character corner indicators for scroll position / follow mode.
|
||||
///
|
||||
/// - Top-right: `▲` (dim) when content is scrolled down (more above).
|
||||
@@ -505,7 +489,6 @@ fn render_corner_indicators(
|
||||
buf[(pos.0 - 1, pos.1)].set_symbol(" ");
|
||||
}
|
||||
}
|
||||
// Indicator: set symbol + fg, preserve bg
|
||||
buf[pos].set_symbol(symbol);
|
||||
buf[pos].fg = fg;
|
||||
buf[pos].modifier = ratatui::style::Modifier::empty();
|
||||
@@ -528,10 +511,6 @@ fn render_corner_indicators(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input bar rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the bottom bar: active input bar or accepted matcher status.
|
||||
///
|
||||
/// When the input bar is open: left-aligned editable `search: ` or `filter: ` + textarea.
|
||||
@@ -549,7 +528,6 @@ fn render_bottom_bar(
|
||||
return;
|
||||
}
|
||||
|
||||
// Background for the entire bar row.
|
||||
buf.set_style(area, Style::default().bg(style.input_bar_bg));
|
||||
|
||||
if let Some(mode) = state.input_mode() {
|
||||
@@ -590,17 +568,12 @@ fn render_bottom_bar(
|
||||
.bg(style.input_bar_bg)
|
||||
.add_modifier(Modifier::DIM);
|
||||
|
||||
// Right-align.
|
||||
let x = area.x.saturating_add(area.width.saturating_sub(status_w));
|
||||
let status_line = Line::from(Span::styled(status, dim_style));
|
||||
buf.set_line_safe(x, area.y, &status_line, area.width);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -663,7 +636,8 @@ mod tests {
|
||||
}
|
||||
|
||||
fn search_text_col_offset(&self) -> u16 {
|
||||
1 // prefix ">" or " "
|
||||
// prefix ">" or " "
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -732,7 +706,6 @@ mod tests {
|
||||
let area = Rect::new(0, 0, 20, 3);
|
||||
state.prepare_layout(&items, area.width, area.height);
|
||||
|
||||
// Scroll down by 5.
|
||||
state.scroll_down(5);
|
||||
|
||||
let mut buf = Buffer::empty(area);
|
||||
@@ -783,7 +756,6 @@ mod tests {
|
||||
let mut state = ListPaneState::new(WrapMode::NoWrap, false);
|
||||
let area = Rect::new(0, 0, 20, 5);
|
||||
|
||||
// Filter to items containing "alph".
|
||||
state.set_filter(Some(FilterMatcher::substring("alph")));
|
||||
state.prepare_layout(&items, area.width, area.height);
|
||||
|
||||
@@ -803,7 +775,8 @@ mod tests {
|
||||
// Text "hello" (6 chars with prefix " ") in a 20-char-wide area,
|
||||
// so the "…" should be appended at position 6.
|
||||
let items = vec![
|
||||
RenderTestItem::new(0, "hello").with_height(3), // would be 3 lines tall
|
||||
// would be 3 lines tall
|
||||
RenderTestItem::new(0, "hello").with_height(3),
|
||||
];
|
||||
let mut state = ListPaneState::new(WrapMode::NoWrap, false);
|
||||
let area = Rect::new(0, 0, 20, 5);
|
||||
@@ -862,8 +835,6 @@ mod tests {
|
||||
assert_eq!(row, ">short");
|
||||
}
|
||||
|
||||
// -- Match highlight tests ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn highlight_match_inverts_correct_cells() {
|
||||
// Items: "alpha", "beta", "alphabet"
|
||||
@@ -1000,7 +971,7 @@ mod tests {
|
||||
StatefulWidget::render(pane, area, &mut buf, &mut state);
|
||||
|
||||
// Find where "tool" appears visually in the buffer.
|
||||
// The match is at byte offset 32 in the plain text.
|
||||
// The match is at byte offset 33 in the plain text.
|
||||
let byte_pos = text.find("tool").unwrap();
|
||||
assert_eq!(byte_pos, 33);
|
||||
|
||||
@@ -1248,10 +1219,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Long line wrapping bug regression tests
|
||||
// =========================================================================
|
||||
|
||||
/// A content-based test item (uses the framework's wrapping).
|
||||
#[derive(Debug)]
|
||||
struct ContentTestItem {
|
||||
@@ -1304,10 +1271,6 @@ mod tests {
|
||||
s
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Long line wrapping regression tests
|
||||
// =========================================================================
|
||||
|
||||
#[test]
|
||||
fn long_line_desired_height_is_accurate() {
|
||||
let width: u16 = 112;
|
||||
@@ -1585,10 +1548,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scrollbar width mismatch bug (regression tests for the fix)
|
||||
// =========================================================================
|
||||
|
||||
#[test]
|
||||
fn scrollbar_width_mismatch_bug_repro() {
|
||||
// BUG REPRODUCTION: Documents the scrollbar width mismatch issue.
|
||||
@@ -1728,7 +1687,8 @@ mod tests {
|
||||
let mut items = vec![
|
||||
RenderTestItem::new(0, "row-0"),
|
||||
RenderTestItem::new(1, "row-1"),
|
||||
RenderTestItem::new(2, "zzz"), // filtered out
|
||||
// filtered out
|
||||
RenderTestItem::new(2, "zzz"),
|
||||
RenderTestItem::new(3, "row-3"),
|
||||
RenderTestItem::new(4, "row-4"),
|
||||
];
|
||||
|
||||
@@ -23,7 +23,8 @@ impl ListPaneState {
|
||||
layout: ListLayoutCache::fixed(0),
|
||||
wrap_mode,
|
||||
last_stamp: None,
|
||||
filter_dirty: true, // force first build
|
||||
// Force the first build.
|
||||
filter_dirty: true,
|
||||
last_item_count: 0,
|
||||
scroll_anchor: None,
|
||||
scroll_screen_y: None,
|
||||
@@ -54,10 +55,6 @@ impl ListPaneState {
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Public accessors
|
||||
// =======================================================================
|
||||
|
||||
pub fn scroll_offset(&self) -> usize {
|
||||
self.scroll_offset
|
||||
}
|
||||
@@ -84,7 +81,8 @@ impl ListPaneState {
|
||||
/// The index will be resolved on the next `prepare_layout` call.
|
||||
pub fn select_by_id(&mut self, id: u64) {
|
||||
self.selected_id = Some(id);
|
||||
self.selected_index = None; // will be resolved in prepare_layout
|
||||
// Will be resolved in prepare_layout.
|
||||
self.selected_index = None;
|
||||
}
|
||||
|
||||
pub fn multi_range(&self) -> Option<Range<usize>> {
|
||||
@@ -230,7 +228,7 @@ impl ListPaneState {
|
||||
&& std::time::Instant::now() >= t
|
||||
{
|
||||
self.copy_toast_until = None;
|
||||
return true; // toast just expired, need redraw
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -282,10 +280,6 @@ impl ListPaneState {
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Visual select mode
|
||||
// =======================================================================
|
||||
|
||||
/// Enter visual selection mode, anchored at the current selection.
|
||||
///
|
||||
/// If in follow mode, exits follow first (materializes cursor),
|
||||
@@ -321,10 +315,6 @@ impl ListPaneState {
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Clipboard (copy)
|
||||
// =======================================================================
|
||||
|
||||
/// Replace the clipboard provider (e.g. with a system clipboard).
|
||||
pub fn set_clipboard_provider(&mut self, provider: Box<dyn ClipboardProvider>) {
|
||||
self.clipboard = provider;
|
||||
@@ -405,10 +395,6 @@ impl ListPaneState {
|
||||
true
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Scrollbar interaction
|
||||
// =======================================================================
|
||||
|
||||
/// Set scroll offset to a specific value and select the nearest item
|
||||
/// at viewport center.
|
||||
///
|
||||
@@ -472,10 +458,6 @@ impl ListPaneState {
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Wrap mode
|
||||
// =======================================================================
|
||||
|
||||
/// Toggle wrap mode: NoWrap ↔ Wrap.
|
||||
///
|
||||
/// Records a scroll anchor so that `prepare_layout` will keep the
|
||||
@@ -500,10 +482,6 @@ impl ListPaneState {
|
||||
// Wrap mode change forces full rebuild on next prepare_layout.
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Filter
|
||||
// =======================================================================
|
||||
|
||||
/// Set or clear the matcher (filter or search).
|
||||
///
|
||||
/// Caller should call `prepare_layout` afterward to recompute
|
||||
@@ -518,10 +496,6 @@ impl ListPaneState {
|
||||
self.set_matcher(matcher);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Match navigation (n / N)
|
||||
// =======================================================================
|
||||
|
||||
/// Jump to the next match after the current selection.
|
||||
///
|
||||
/// In Filter mode, moves to the next filtered item.
|
||||
@@ -585,10 +559,6 @@ impl ListPaneState {
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// prepare_layout — the ONE generic entry point
|
||||
// =======================================================================
|
||||
|
||||
/// Recompute layout, resolve stable-ID selection → indices, clamp scroll.
|
||||
///
|
||||
/// Call this once per frame before rendering. `items` is the full
|
||||
@@ -617,7 +587,7 @@ impl ListPaneState {
|
||||
viewport_height
|
||||
};
|
||||
|
||||
// -- Build visible-item index map (filter) ----------------------------
|
||||
// Build visible-item index map (filter).
|
||||
let filter_changed = self.filter_dirty;
|
||||
if filter_changed {
|
||||
self.filter_dirty = false;
|
||||
@@ -661,7 +631,7 @@ impl ListPaneState {
|
||||
}
|
||||
};
|
||||
|
||||
// -- SCROLLBAR WIDTH FIX: Determine effective width for layout ---------
|
||||
// SCROLLBAR WIDTH FIX: determine effective width for layout.
|
||||
//
|
||||
// In Wrap mode, the scrollbar takes SCROLLBAR_TOTAL_COLS (2) columns.
|
||||
// If we compute heights at full width but render at narrow width (due to
|
||||
@@ -682,7 +652,7 @@ impl ListPaneState {
|
||||
width
|
||||
};
|
||||
|
||||
// -- Maintain per-physical-item height cache (ALL modes) ----------------
|
||||
// Maintain per-physical-item height cache (all modes).
|
||||
//
|
||||
// The height cache stores `desired_height(effective_width)` for every
|
||||
// physical item, regardless of the current wrap mode. It is:
|
||||
@@ -715,7 +685,7 @@ impl ListPaneState {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Decide whether we can skip / incrementally update the layout -----
|
||||
// Decide whether we can skip / incrementally update the layout.
|
||||
let (width_same, mode_same, count_grew, old_count) = match self.last_stamp {
|
||||
Some(s) => (
|
||||
s.width == effective_width,
|
||||
@@ -757,7 +727,7 @@ impl ListPaneState {
|
||||
}
|
||||
// else: count_same + same width/mode/filter → cache is still valid.
|
||||
|
||||
// -- SCROLLBAR WIDTH FIX Phase 2: Check if we guessed wrong ------------
|
||||
// SCROLLBAR WIDTH FIX Phase 2: check if we guessed wrong.
|
||||
//
|
||||
// If we computed at full width but total_height > viewport (scrollbar
|
||||
// will actually be shown), recompute at narrower width.
|
||||
@@ -784,14 +754,13 @@ impl ListPaneState {
|
||||
}
|
||||
}
|
||||
|
||||
// Update dirty-tracking stamp with effective_width.
|
||||
self.last_stamp = Some(LayoutStamp {
|
||||
width: effective_width,
|
||||
count: vis_count,
|
||||
wrap: self.wrap_mode,
|
||||
});
|
||||
|
||||
// -- Resolve selected_id → selected_index -----------------------------
|
||||
// Resolve selected_id → selected_index.
|
||||
self.selected_index = self
|
||||
.selected_id
|
||||
.and_then(|sid| (0..vis_count).find(|&vi| items[to_physical(vi)].stable_id() == sid));
|
||||
@@ -801,14 +770,14 @@ impl ListPaneState {
|
||||
self.selected_id = None;
|
||||
}
|
||||
|
||||
// -- Visual mode: sync multi_selected_ids from anchor + cursor --------
|
||||
// Visual mode: sync multi_selected_ids from anchor + cursor.
|
||||
if self.visual_mode
|
||||
&& let (Some(anchor), Some(cursor)) = (self.visual_anchor_id, self.selected_id)
|
||||
{
|
||||
self.multi_selected_ids = Some((anchor, cursor));
|
||||
}
|
||||
|
||||
// -- Resolve multi_selected_ids → multi_range -------------------------
|
||||
// Resolve multi_selected_ids → multi_range.
|
||||
self.multi_range = self.multi_selected_ids.and_then(|(a, b)| {
|
||||
let a_idx = (0..vis_count).find(|&vi| items[to_physical(vi)].stable_id() == a);
|
||||
let b_idx = (0..vis_count).find(|&vi| items[to_physical(vi)].stable_id() == b);
|
||||
@@ -822,7 +791,7 @@ impl ListPaneState {
|
||||
}
|
||||
});
|
||||
|
||||
// -- Apply scroll anchor (wrap toggle y-stability) --------------------
|
||||
// Apply scroll anchor (wrap toggle y-stability).
|
||||
// If a scroll anchor was set (e.g. by cycle_wrap_mode), adjust
|
||||
// scroll_offset so the selected item appears at the recorded screen-y.
|
||||
if let Some(desired_screen_y) = self.scroll_anchor.take()
|
||||
@@ -832,7 +801,7 @@ impl ListPaneState {
|
||||
self.scroll_offset = new_item_y.saturating_sub(desired_screen_y);
|
||||
}
|
||||
|
||||
// -- Follow mode: auto-scroll to bottom, no cursor ----------------------
|
||||
// Follow mode: auto-scroll to bottom, no cursor.
|
||||
if self.follow_mode {
|
||||
let total = self.layout.total_height();
|
||||
let vp = self.viewport_height as usize;
|
||||
@@ -842,7 +811,7 @@ impl ListPaneState {
|
||||
self.selected_index = None;
|
||||
}
|
||||
|
||||
// -- Auto-select if nothing selected (NAV mode only) --------------------
|
||||
// Auto-select if nothing selected (NAV mode only).
|
||||
// A list with items but no selection feels broken (no highlight, j/k do
|
||||
// nothing visible). Auto-select the first selectable item.
|
||||
// In follow mode, selection is always None — skip this block.
|
||||
@@ -860,10 +829,9 @@ impl ListPaneState {
|
||||
// (which uses `to_physical` closure that borrows `vis`).
|
||||
self.vis_map = vis;
|
||||
|
||||
// -- Clamp scroll -------------------------------------------------------
|
||||
self.clamp_scroll();
|
||||
|
||||
// -- Keep selection visible after eviction --------------------------------
|
||||
// Keep selection visible after eviction.
|
||||
// Only when items SHRINK (eviction from front), which shifts indices and
|
||||
// can push the selection off-screen. NOT on append (which just extends
|
||||
// below) — that would fight with user's click/scroll position via margin.
|
||||
@@ -873,10 +841,6 @@ impl ListPaneState {
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Scroll
|
||||
// =======================================================================
|
||||
|
||||
/// Scroll down by `n` visual lines (viewport only, no follow logic).
|
||||
///
|
||||
/// This is a low-level primitive. Higher-level methods
|
||||
@@ -931,16 +895,17 @@ impl ListPaneState {
|
||||
fn scroll_keeping_screen_y<T: ListItem>(&mut self, delta: isize, items: &[T]) {
|
||||
let is_down = delta > 0;
|
||||
|
||||
// -- Handle FOLLOW mode -----------------------------------------------
|
||||
// Handle FOLLOW mode.
|
||||
if self.follow_mode {
|
||||
if is_down {
|
||||
return; // no-op: already at the bottom
|
||||
// No-op: already at the bottom.
|
||||
return;
|
||||
}
|
||||
// Upward: exit follow, materialize cursor, then scroll.
|
||||
self.exit_follow(items);
|
||||
}
|
||||
|
||||
// -- NAV mode scroll --------------------------------------------------
|
||||
// NAV mode scroll.
|
||||
if !is_down {
|
||||
self.reset_edge_state();
|
||||
}
|
||||
@@ -966,17 +931,19 @@ impl ListPaneState {
|
||||
}
|
||||
|
||||
let actual_scroll = self.scroll_offset as isize - offset_before as isize;
|
||||
let leftover = delta - actual_scroll; // lines the viewport couldn't consume
|
||||
// Lines the viewport couldn't consume.
|
||||
let leftover = delta - actual_scroll;
|
||||
|
||||
// Restore selection at the pinned screen-y, then apply leftover.
|
||||
if let Some(sy) = screen_y {
|
||||
self.scroll_screen_y = Some(sy); // persist for next scroll
|
||||
// Persist for next scroll.
|
||||
self.scroll_screen_y = Some(sy);
|
||||
// Target virtual-y: pinned screen-y + leftover movement.
|
||||
let target_y = (self.scroll_offset + sy).saturating_add_signed(leftover);
|
||||
self.select_nearest_at_y(target_y, items);
|
||||
}
|
||||
|
||||
// -- One-past logic for downward scrolls ------------------------------
|
||||
// One-past logic for downward scrolls.
|
||||
// Suppressed in visual mode (don't snap to follow mid-selection).
|
||||
if self.config.follow_enabled && !self.visual_mode && is_down && self.is_at_bottom() {
|
||||
if self.at_content_edge {
|
||||
@@ -1083,10 +1050,11 @@ impl ListPaneState {
|
||||
// Mouse wheel exits visual mode (mouse = single-select interaction).
|
||||
self.clear_visual_if_active();
|
||||
|
||||
// -- Handle FOLLOW mode -----------------------------------------------
|
||||
// Handle FOLLOW mode.
|
||||
if self.follow_mode {
|
||||
if is_down {
|
||||
return; // no-op: already at the bottom
|
||||
// No-op: already at the bottom.
|
||||
return;
|
||||
}
|
||||
// Upward: exit follow, materialize cursor, then scroll.
|
||||
self.exit_follow(items);
|
||||
@@ -1128,7 +1096,7 @@ impl ListPaneState {
|
||||
self.select_nearest_at_y(target_y, items);
|
||||
}
|
||||
|
||||
// -- Overscroll counter for mouse wheel -------------------------------
|
||||
// Overscroll counter for mouse wheel.
|
||||
// Suppressed in visual mode.
|
||||
if self.config.follow_enabled && !self.visual_mode && is_down && self.is_at_bottom() {
|
||||
let scroll_amount = lines.unsigned_abs().min(u8::MAX as u32) as u8;
|
||||
@@ -1200,10 +1168,6 @@ impl ListPaneState {
|
||||
total <= vp || self.scroll_offset >= total.saturating_sub(vp)
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Follow ↔ NAV transitions
|
||||
// =======================================================================
|
||||
|
||||
/// Engage follow mode: jump viewport to bottom, hide cursor.
|
||||
///
|
||||
/// No-op when `follow_enabled` is false in config.
|
||||
@@ -1254,10 +1218,6 @@ impl ListPaneState {
|
||||
self.overscroll_ticks = 0;
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Selection
|
||||
// =======================================================================
|
||||
|
||||
/// Select the next selectable item (downward) — `j` / `↓`.
|
||||
///
|
||||
/// Behavior:
|
||||
@@ -1266,7 +1226,8 @@ impl ListPaneState {
|
||||
/// one-past logic applies (second j at end → engage follow).
|
||||
pub fn select_next<T: ListItem>(&mut self, items: &[T]) {
|
||||
if self.follow_mode {
|
||||
return; // no-op: already at the bottom
|
||||
// No-op: already at the bottom.
|
||||
return;
|
||||
}
|
||||
|
||||
self.scroll_screen_y = None;
|
||||
@@ -1371,7 +1332,8 @@ impl ListPaneState {
|
||||
pub fn select_last<T: ListItem>(&mut self, items: &[T]) {
|
||||
if self.config.follow_enabled && !self.visual_mode {
|
||||
if self.follow_mode {
|
||||
return; // already following — no-op
|
||||
// Already following — no-op.
|
||||
return;
|
||||
}
|
||||
self.engage_follow();
|
||||
return;
|
||||
@@ -1452,10 +1414,6 @@ impl ListPaneState {
|
||||
self.clamp_scroll();
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Visible range (for rendering)
|
||||
// =======================================================================
|
||||
|
||||
/// Return the range of visible-item indices that overlap the viewport.
|
||||
///
|
||||
/// For `FixedHeight`, this is `scroll_offset .. scroll_offset + viewport_height`
|
||||
@@ -1496,10 +1454,6 @@ impl ListPaneState {
|
||||
(self.scroll_offset - first_y) as u16
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Keyboard input
|
||||
// =======================================================================
|
||||
|
||||
/// Handle a key event for navigation, search, and filter.
|
||||
///
|
||||
/// Returns `true` if the key was consumed (state changed), `false` if
|
||||
@@ -1516,7 +1470,7 @@ impl ListPaneState {
|
||||
event: &crossterm::event::KeyEvent,
|
||||
items: &[T],
|
||||
) -> bool {
|
||||
// -- Input bar active: intercept Enter/Esc, route typing to textarea --
|
||||
// Input bar active: intercept Enter/Esc, route typing to textarea.
|
||||
if let Some(mode) = self.input_mode {
|
||||
// GotoLine mode has its own Enter/Esc/text handling.
|
||||
if mode == InputBarMode::GotoLine {
|
||||
@@ -1568,7 +1522,8 @@ impl ListPaneState {
|
||||
return true;
|
||||
}
|
||||
if key!(Enter).matches(event) || key!(Esc).matches(event) {
|
||||
return false; // let the caller handle
|
||||
// Let the caller handle Enter/Esc.
|
||||
return false;
|
||||
}
|
||||
// Shift+Enter / Alt+Enter: insert a literal newline.
|
||||
if key!(Enter, SHIFT).matches(event) || key!(Enter, ALT).matches(event) {
|
||||
@@ -1628,10 +1583,11 @@ impl ListPaneState {
|
||||
if self.input_textarea.text() != old_text {
|
||||
self.apply_input_buffer(items);
|
||||
}
|
||||
return true; // always consume when input bar is open
|
||||
// Always consume when input bar is open.
|
||||
return true;
|
||||
}
|
||||
|
||||
// -- Normal mode: check for search/filter/follow keys first -----------
|
||||
// Normal mode: check for search/filter/follow keys first.
|
||||
|
||||
// '/' → open search bar (clears visual mode)
|
||||
if self.config.search_enabled && key!('/').matches(event) {
|
||||
@@ -1834,10 +1790,6 @@ impl ListPaneState {
|
||||
false
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Input bar lifecycle
|
||||
// =======================================================================
|
||||
|
||||
/// Open the input bar in the given mode.
|
||||
fn open_input<T: ListItem>(&mut self, mode: InputBarMode, items: &[T]) {
|
||||
self.input_mode = Some(mode);
|
||||
@@ -1891,11 +1843,8 @@ impl ListPaneState {
|
||||
self.cancel_input();
|
||||
}
|
||||
|
||||
// ── Goto-line mode ────────────────────────────────────────────────
|
||||
|
||||
/// Open the goto-line input bar. Saves a snapshot for cancel/restore.
|
||||
fn open_goto_line<T: ListItem>(&mut self, items: &[T]) {
|
||||
// Save snapshot.
|
||||
self.goto_line_snapshot = Some(GotoLineSnapshot {
|
||||
scroll_offset: self.scroll_offset,
|
||||
selected_id: self.selected_id,
|
||||
@@ -1926,7 +1875,8 @@ impl ListPaneState {
|
||||
self.selected_id = snap.selected_id;
|
||||
self.visual_mode = snap.visual_mode;
|
||||
self.visual_anchor_id = snap.visual_anchor_id;
|
||||
self.selected_index = None; // will be resolved in prepare_layout
|
||||
// Will be resolved in prepare_layout.
|
||||
self.selected_index = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2163,10 +2113,6 @@ impl ListPaneState {
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Mouse event handling
|
||||
// =======================================================================
|
||||
|
||||
/// Handle a mouse event within this pane's area.
|
||||
///
|
||||
/// `pane_area` is the screen `Rect` where the pane was rendered (used
|
||||
|
||||
@@ -17,10 +17,6 @@ use crate::key;
|
||||
use crate::render::scrollbar::SCROLLBAR_TOTAL_COLS;
|
||||
use crate::search::{QueryKind, TextMatcher};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListMatcher — unified filter / search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Whether the matcher hides non-matching items or just highlights them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MatchMode {
|
||||
@@ -40,7 +36,6 @@ pub enum MatchMode {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ListMatcher {
|
||||
text: TextMatcher,
|
||||
/// Whether this matcher hides or highlights.
|
||||
pub mode: MatchMode,
|
||||
/// Physical indices of items whose `search_text()` matches, sorted ascending.
|
||||
pub match_indices: Vec<usize>,
|
||||
@@ -122,10 +117,6 @@ impl ListMatcher {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward-compatible aliases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Backward-compatible alias for [`ListMatcher`].
|
||||
///
|
||||
/// Existing code that constructs `FilterMatcher::substring(...)` or
|
||||
@@ -167,10 +158,6 @@ impl ListFilter {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InputBarMode — search vs filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which mode the input bar is in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InputBarMode {
|
||||
@@ -199,7 +186,8 @@ impl InputBarMode {
|
||||
match self {
|
||||
InputBarMode::Search => MatchMode::Search,
|
||||
InputBarMode::Filter => MatchMode::Filter,
|
||||
InputBarMode::GotoLine => MatchMode::Search, // unused, but needed for exhaustive match
|
||||
// Unused, but needed for exhaustive match.
|
||||
InputBarMode::GotoLine => MatchMode::Search,
|
||||
InputBarMode::Comment => MatchMode::Search,
|
||||
}
|
||||
}
|
||||
@@ -215,10 +203,6 @@ struct GotoLineSnapshot {
|
||||
visual_anchor_id: Option<u64>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LayoutStamp — dirty-tracking for prepare_layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Snapshot of parameters used to build the current layout cache.
|
||||
///
|
||||
/// Stored in [`ListPaneState`] and compared each frame to decide whether
|
||||
@@ -234,24 +218,18 @@ struct LayoutStamp {
|
||||
wrap: WrapMode,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListPaneState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// View state for a scrollable list pane.
|
||||
///
|
||||
/// **Non-generic** — the item type `T: ListItem` only appears at the
|
||||
/// boundaries: [`prepare_layout`] and `ListPane<'a, T>` (the widget).
|
||||
#[derive(Debug)]
|
||||
pub struct ListPaneState {
|
||||
// -- Scroll ---------------------------------------------------------------
|
||||
/// Scroll offset in visual lines from the top of the content.
|
||||
scroll_offset: usize,
|
||||
|
||||
/// Viewport height in terminal rows (set by [`prepare_layout`]).
|
||||
viewport_height: u16,
|
||||
|
||||
// -- Selection (stable IDs) -----------------------------------------------
|
||||
/// Currently selected item, stored as a stable ID.
|
||||
/// Resolved to `selected_index` in [`prepare_layout`].
|
||||
selected_id: Option<u64>,
|
||||
@@ -274,14 +252,12 @@ pub struct ListPaneState {
|
||||
/// The range is [anchor, cursor] (cursor = `selected_id`).
|
||||
visual_anchor_id: Option<u64>,
|
||||
|
||||
// -- Layout ---------------------------------------------------------------
|
||||
/// Layout cache (heights + prefix sums, or fixed-height).
|
||||
layout: ListLayoutCache,
|
||||
|
||||
/// Current wrap mode.
|
||||
wrap_mode: WrapMode,
|
||||
|
||||
// -- Layout dirty tracking ------------------------------------------------
|
||||
/// Parameters of the last layout build (for incremental / skip logic).
|
||||
/// `None` if no layout has been computed yet.
|
||||
last_stamp: Option<LayoutStamp>,
|
||||
@@ -310,12 +286,10 @@ pub struct ListPaneState {
|
||||
/// by any intentional selection movement (j/k, click, g/G, etc.).
|
||||
scroll_screen_y: Option<usize>,
|
||||
|
||||
// -- Modes ----------------------------------------------------------------
|
||||
/// Follow mode: auto-scroll to bottom when new items appear.
|
||||
/// In follow mode the cursor is hidden (no selection highlight).
|
||||
pub follow_mode: bool,
|
||||
|
||||
// -- Follow / NAV edge tracking -------------------------------------------
|
||||
/// NAV only: true when the last downward action was clamped at the
|
||||
/// content bottom (viewport or selection at the end). The next
|
||||
/// downward action with this flag set engages follow ("one-past").
|
||||
@@ -343,7 +317,6 @@ pub struct ListPaneState {
|
||||
/// `None` when scrollbar is not shown (content fits viewport).
|
||||
last_scrollbar_area: Option<Rect>,
|
||||
|
||||
// -- Highlight visibility -------------------------------------------------
|
||||
/// Whether the highlight post-pass should render match inversions.
|
||||
///
|
||||
/// Callers set this to `false` after accepting a filter (Enter) to avoid
|
||||
@@ -353,7 +326,6 @@ pub struct ListPaneState {
|
||||
/// Defaults to `true` (highlights always shown).
|
||||
pub show_highlights: bool,
|
||||
|
||||
// -- Height cache (Wrap mode) ---------------------------------------------
|
||||
/// Per-physical-item height cache for Wrap mode.
|
||||
///
|
||||
/// Indexed by physical item index. Computed once when the width changes
|
||||
@@ -367,11 +339,9 @@ pub struct ListPaneState {
|
||||
/// Width at which `height_cache` was computed.
|
||||
height_cache_width: u16,
|
||||
|
||||
// -- Config ---------------------------------------------------------------
|
||||
/// Feature flags controlling which behaviors are active.
|
||||
config: ListPaneConfig,
|
||||
|
||||
// -- Input bar (search / filter) ------------------------------------------
|
||||
/// Active input bar mode, or `None` if the bar is closed.
|
||||
input_mode: Option<InputBarMode>,
|
||||
|
||||
@@ -384,11 +354,9 @@ pub struct ListPaneState {
|
||||
/// Cached screen position of the input bar cursor (set during render).
|
||||
input_cursor_screen_pos: Option<(u16, u16)>,
|
||||
|
||||
// -- Mouse / scrollbar ----------------------------------------------------
|
||||
/// Whether a scrollbar drag is in progress.
|
||||
scrollbar_dragging: bool,
|
||||
|
||||
// -- Clipboard ------------------------------------------------------------
|
||||
/// Clipboard provider for `y` (copy). Default is `InternalClipboard`
|
||||
/// (in-memory). Host app can inject system clipboard via
|
||||
/// [`set_clipboard_provider`].
|
||||
@@ -410,10 +378,6 @@ pub struct ListPaneState {
|
||||
/// Tunable — start with 1 (easy to trigger), increase if too twitchy.
|
||||
const MOUSE_OVERSCROLL_THRESHOLD: u8 = 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListPaneConfig — feature flags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Configuration flags for a `ListPaneState`.
|
||||
///
|
||||
/// Controls which features are available. Use-case examples:
|
||||
@@ -515,10 +479,6 @@ impl ListPaneConfig {
|
||||
|
||||
mod methods;
|
||||
|
||||
// ===========================================================================
|
||||
// Goto-line input parsing
|
||||
// ===========================================================================
|
||||
|
||||
/// Parsed result of goto-line input.
|
||||
enum GotoTarget {
|
||||
/// Single line number (1-based, clamped to item count).
|
||||
@@ -571,10 +531,6 @@ fn parse_goto_input(text: &str, max_lines: usize) -> GotoTarget {
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -651,8 +607,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Scroll tests ---------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn scroll_basics() {
|
||||
let items: Vec<TestItem> = (0..20).map(TestItem::new).collect();
|
||||
@@ -670,7 +624,7 @@ mod tests {
|
||||
|
||||
// Can't scroll past content
|
||||
state.scroll_down(100);
|
||||
assert_eq!(state.scroll_offset(), 10); // 20 - 10
|
||||
assert_eq!(state.scroll_offset(), 10);
|
||||
|
||||
state.scroll_up(100);
|
||||
assert_eq!(state.scroll_offset(), 0);
|
||||
@@ -710,12 +664,12 @@ mod tests {
|
||||
let mut state = new_streaming(WrapMode::NoWrap, true);
|
||||
state.prepare_layout(&items, 80, 5);
|
||||
// follow mode → scroll to bottom
|
||||
assert_eq!(state.scroll_offset(), 5); // 10 - 5
|
||||
assert_eq!(state.scroll_offset(), 5);
|
||||
|
||||
// Add more items
|
||||
items.extend((10..15).map(TestItem::new));
|
||||
state.prepare_layout(&items, 80, 5);
|
||||
assert_eq!(state.scroll_offset(), 10); // 15 - 5
|
||||
assert_eq!(state.scroll_offset(), 10);
|
||||
|
||||
// Manual scroll breaks follow mode
|
||||
state.scroll_up(3);
|
||||
@@ -746,8 +700,6 @@ mod tests {
|
||||
assert_eq!(state.scroll_offset(), 0);
|
||||
}
|
||||
|
||||
// -- Selection tests ------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn select_next_prev() {
|
||||
let items: Vec<TestItem> = (0..5).map(TestItem::new).collect();
|
||||
@@ -805,7 +757,7 @@ mod tests {
|
||||
// G / select_last now engages follow (no cursor).
|
||||
state.select_last(&items);
|
||||
assert!(state.follow_mode);
|
||||
assert_eq!(state.selected_index(), None); // no cursor in follow
|
||||
assert_eq!(state.selected_index(), None);
|
||||
|
||||
// g / select_first exits follow, selects first.
|
||||
state.select_first(&items);
|
||||
@@ -867,8 +819,6 @@ mod tests {
|
||||
assert!(state.selected_index().is_some());
|
||||
}
|
||||
|
||||
// -- Filter + selection tests ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn select_with_filter() {
|
||||
let items = vec![
|
||||
@@ -916,8 +866,6 @@ mod tests {
|
||||
assert_eq!(state.selected_id(), Some(2));
|
||||
}
|
||||
|
||||
// -- Visible range tests --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn visible_range_fixed_height() {
|
||||
let items: Vec<TestItem> = (0..20).map(TestItem::new).collect();
|
||||
@@ -955,8 +903,6 @@ mod tests {
|
||||
assert_eq!(state.first_item_skip_rows(), 1);
|
||||
}
|
||||
|
||||
// -- Filter tests ---------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn filter_reduces_visible_items() {
|
||||
let items = vec![
|
||||
@@ -985,8 +931,6 @@ mod tests {
|
||||
assert_eq!(state.visible_count(), 4);
|
||||
}
|
||||
|
||||
// -- Wrap mode tests ------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn wrap_mode_variable_heights() {
|
||||
let items = vec![
|
||||
@@ -997,7 +941,7 @@ mod tests {
|
||||
let mut state = ListPaneState::new(WrapMode::Wrap, false);
|
||||
state.prepare_layout(&items, 80, 10);
|
||||
|
||||
assert_eq!(state.total_height(), 6); // 3 + 2 + 1
|
||||
assert_eq!(state.total_height(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1014,8 +958,6 @@ mod tests {
|
||||
assert_eq!(state.total_height(), 3);
|
||||
}
|
||||
|
||||
// -- Keyboard input tests -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn key_j_k_selects() {
|
||||
let items: Vec<TestItem> = (0..10).map(TestItem::new).collect();
|
||||
@@ -1072,7 +1014,7 @@ mod tests {
|
||||
state.prepare_layout(&items, 80, 10);
|
||||
|
||||
assert!(state.handle_key_event(&key!('d', CONTROL).to_key_event(), &items));
|
||||
assert_eq!(state.scroll_offset(), 5); // half of 10
|
||||
assert_eq!(state.scroll_offset(), 5);
|
||||
|
||||
assert!(state.handle_key_event(&key!('u', CONTROL).to_key_event(), &items));
|
||||
assert_eq!(state.scroll_offset(), 0);
|
||||
@@ -1150,8 +1092,6 @@ mod tests {
|
||||
assert!(!state.handle_key_event(&key!('x').to_key_event(), &items));
|
||||
}
|
||||
|
||||
// -- Selection follows scroll (vim/lnav screen-y preservation) ----------
|
||||
|
||||
#[test]
|
||||
fn ctrl_d_selection_stays_at_same_screen_y() {
|
||||
let items: Vec<TestItem> = (0..30).map(TestItem::new).collect();
|
||||
@@ -1166,7 +1106,7 @@ mod tests {
|
||||
// Selection should move from 3 to 8 (same screen-y = 3).
|
||||
assert!(state.handle_key_event(&key!('d', CONTROL).to_key_event(), &items));
|
||||
assert_eq!(state.scroll_offset(), 5);
|
||||
assert_eq!(state.selected_index(), Some(8)); // 3 + 5
|
||||
assert_eq!(state.selected_index(), Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1184,7 +1124,7 @@ mod tests {
|
||||
// Selection should move from 13 to 8 (same screen-y = 3).
|
||||
assert!(state.handle_key_event(&key!('u', CONTROL).to_key_event(), &items));
|
||||
assert_eq!(state.scroll_offset(), 5);
|
||||
assert_eq!(state.selected_index(), Some(8)); // 13 - 5
|
||||
assert_eq!(state.selected_index(), Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1213,12 +1153,12 @@ mod tests {
|
||||
// Mouse wheel down 3 — selection follows at same screen-y.
|
||||
state.scroll_lines(3, &items);
|
||||
assert_eq!(state.scroll_offset(), 3);
|
||||
assert_eq!(state.selected_index(), Some(8)); // 5 + 3
|
||||
assert_eq!(state.selected_index(), Some(8));
|
||||
|
||||
// Mouse wheel up 2
|
||||
state.scroll_lines(-2, &items);
|
||||
assert_eq!(state.scroll_offset(), 1);
|
||||
assert_eq!(state.selected_index(), Some(6)); // 8 - 2
|
||||
assert_eq!(state.selected_index(), Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1313,16 +1253,15 @@ mod tests {
|
||||
// Ctrl-j scrolls down 1 line
|
||||
assert!(state.handle_key_event(&key!('j', CONTROL).to_key_event(), &items));
|
||||
assert_eq!(state.scroll_offset(), 1);
|
||||
assert_eq!(state.selected_index(), Some(5)); // 4 + 1, screen-y still 4
|
||||
// Screen-y stays 4.
|
||||
assert_eq!(state.selected_index(), Some(5));
|
||||
|
||||
// Ctrl-k scrolls up 1 line
|
||||
assert!(state.handle_key_event(&key!('k', CONTROL).to_key_event(), &items));
|
||||
assert_eq!(state.scroll_offset(), 0);
|
||||
assert_eq!(state.selected_index(), Some(4)); // back to 4
|
||||
assert_eq!(state.selected_index(), Some(4));
|
||||
}
|
||||
|
||||
// -- Dirty flag / incremental append tests --------------------------------
|
||||
|
||||
#[test]
|
||||
fn prepare_layout_skips_rebuild_when_clean() {
|
||||
// With Wrap mode + variable heights, calling prepare_layout with
|
||||
@@ -1354,7 +1293,7 @@ mod tests {
|
||||
// Append an item → incremental path
|
||||
items.push(TestItem::new(2).with_height(4));
|
||||
state.prepare_layout(&items, 80, 10);
|
||||
assert_eq!(state.total_height(), 9); // 3 + 2 + 4
|
||||
assert_eq!(state.total_height(), 9);
|
||||
assert_eq!(state.visible_count(), 3);
|
||||
}
|
||||
|
||||
@@ -1408,7 +1347,8 @@ mod tests {
|
||||
|
||||
// Selection should survive (stable id).
|
||||
assert_eq!(state.selected_id(), Some(10));
|
||||
assert_eq!(state.selected_index(), Some(5)); // shifted
|
||||
// Selection shifts because item id=2 moved to a new index.
|
||||
assert_eq!(state.selected_index(), Some(5));
|
||||
assert_eq!(state.visible_count(), 15);
|
||||
assert_eq!(state.total_height(), 15);
|
||||
}
|
||||
@@ -1454,8 +1394,6 @@ mod tests {
|
||||
assert_eq!(state.visible_count(), 7);
|
||||
}
|
||||
|
||||
// -- Center selected tests ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn center_selected_places_item_mid_viewport() {
|
||||
let items: Vec<TestItem> = (0..30).map(TestItem::new).collect();
|
||||
@@ -1491,8 +1429,6 @@ mod tests {
|
||||
assert_eq!(state.scroll_offset(), 10);
|
||||
}
|
||||
|
||||
// -- Click-to-select tests ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn select_at_y_selectable() {
|
||||
let items: Vec<TestItem> = (0..10).map(TestItem::new).collect();
|
||||
@@ -1522,8 +1458,6 @@ mod tests {
|
||||
assert_eq!(state.selected_index(), Some(0));
|
||||
}
|
||||
|
||||
// -- Scroll past non-selectable (viewport-constrained) --------------------
|
||||
|
||||
#[test]
|
||||
fn scroll_past_non_selectable_stays_in_viewport() {
|
||||
// Items: 0, 1, 2(non-sel), 3, 4, 5, 6, 7, 8, 9
|
||||
@@ -1549,8 +1483,6 @@ mod tests {
|
||||
assert_eq!(sel, 3);
|
||||
}
|
||||
|
||||
// -- Ctrl-d/u edge cases: cursor continues when viewport is clamped -----
|
||||
|
||||
#[test]
|
||||
fn ctrl_d_at_bottom_moves_cursor_past_viewport_clamp() {
|
||||
// 20 items, viewport 10. Max scroll = 10.
|
||||
@@ -1567,8 +1499,10 @@ mod tests {
|
||||
// Actual scroll = 2. Leftover = 3.
|
||||
// Target virtual-y = (10 + 3) + 3 = 16 → item 16.
|
||||
state.half_page_down(&items);
|
||||
assert_eq!(state.scroll_offset(), 10); // clamped at max
|
||||
assert_eq!(state.selected_index(), Some(16)); // cursor kept going
|
||||
// Clamped at max.
|
||||
assert_eq!(state.scroll_offset(), 10);
|
||||
// Cursor kept going.
|
||||
assert_eq!(state.selected_index(), Some(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1587,8 +1521,10 @@ mod tests {
|
||||
// Actual scroll = -3. Leftover = -2.
|
||||
// Target virtual-y = (0 + 3) - 2 = 1 → item 1.
|
||||
state.half_page_up(&items);
|
||||
assert_eq!(state.scroll_offset(), 0); // clamped at min
|
||||
assert_eq!(state.selected_index(), Some(1)); // cursor kept going
|
||||
// Clamped at min.
|
||||
assert_eq!(state.scroll_offset(), 0);
|
||||
// Cursor kept going.
|
||||
assert_eq!(state.selected_index(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1672,8 +1608,6 @@ mod tests {
|
||||
assert_eq!(state.selected_index(), Some(3));
|
||||
}
|
||||
|
||||
// -- ListMatcher tests ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn matcher_substring_builds_match_indices() {
|
||||
let items = vec![
|
||||
@@ -1706,7 +1640,8 @@ mod tests {
|
||||
let mut m = ListMatcher::new("[invalid", QueryKind::Regex, MatchMode::Filter);
|
||||
assert!(m.is_error());
|
||||
m.rebuild_matches(&items);
|
||||
assert!(m.match_indices.is_empty()); // bad regex matches nothing
|
||||
// Bad regex matches nothing.
|
||||
assert!(m.match_indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1823,8 +1758,6 @@ mod tests {
|
||||
assert_eq!(state.selected_index(), Some(3));
|
||||
}
|
||||
|
||||
// -- Follow mode: one-past and overscroll tests --------------------------
|
||||
|
||||
#[test]
|
||||
fn j_one_past_engages_follow() {
|
||||
// j on last item twice → follow.
|
||||
@@ -1837,17 +1770,20 @@ mod tests {
|
||||
state.select_next(&items);
|
||||
}
|
||||
assert_eq!(state.selected_index(), Some(9));
|
||||
assert!(!state.follow_mode); // at last item, but NOT follow yet
|
||||
// At last item, but NOT follow yet.
|
||||
assert!(!state.follow_mode);
|
||||
|
||||
// First j at end → at_content_edge = true, no mode change.
|
||||
state.select_next(&items);
|
||||
assert!(!state.follow_mode);
|
||||
assert_eq!(state.selected_index(), Some(9)); // still there
|
||||
// Still there.
|
||||
assert_eq!(state.selected_index(), Some(9));
|
||||
|
||||
// Second j at end → engage follow.
|
||||
state.select_next(&items);
|
||||
assert!(state.follow_mode);
|
||||
assert_eq!(state.selected_index(), None); // no cursor in follow
|
||||
// No cursor in follow.
|
||||
assert_eq!(state.selected_index(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1892,7 +1828,8 @@ mod tests {
|
||||
// Second ctrl-d → scroll to offset 10 (max). at_content_edge = true.
|
||||
state.half_page_down(&items);
|
||||
assert_eq!(state.scroll_offset(), 10);
|
||||
assert!(!state.follow_mode); // one-past: not yet
|
||||
// One-past: not yet.
|
||||
assert!(!state.follow_mode);
|
||||
|
||||
// Third ctrl-d → at bottom + at_content_edge → follow.
|
||||
state.half_page_down(&items);
|
||||
@@ -1925,7 +1862,8 @@ mod tests {
|
||||
// Scroll to bottom.
|
||||
state.scroll_lines(10, &items);
|
||||
assert_eq!(state.scroll_offset(), 10);
|
||||
assert!(!state.follow_mode); // first hit: at_content_edge = true
|
||||
// First hit: at_content_edge = true.
|
||||
assert!(!state.follow_mode);
|
||||
|
||||
// Another scroll at bottom → overscroll counter fires.
|
||||
state.scroll_lines(3, &items);
|
||||
@@ -2010,12 +1948,10 @@ mod tests {
|
||||
|
||||
items.extend((20..25).map(TestItem::new));
|
||||
state.prepare_layout(&items, 80, 10);
|
||||
assert_eq!(state.scroll_offset(), 15); // 25 - 10
|
||||
assert_eq!(state.scroll_offset(), 15);
|
||||
assert!(state.follow_mode);
|
||||
}
|
||||
|
||||
// -- Follow mode: no cursor -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn follow_mode_has_no_selection() {
|
||||
let items: Vec<TestItem> = (0..10).map(TestItem::new).collect();
|
||||
@@ -2032,7 +1968,8 @@ mod tests {
|
||||
let items: Vec<TestItem> = (0..10).map(TestItem::new).collect();
|
||||
let mut state = new_streaming(WrapMode::NoWrap, false);
|
||||
state.prepare_layout(&items, 80, 5);
|
||||
assert_eq!(state.selected_index(), Some(0)); // auto-select in NAV
|
||||
// Auto-select in NAV.
|
||||
assert_eq!(state.selected_index(), Some(0));
|
||||
|
||||
// Engage follow.
|
||||
state.select_last(&items);
|
||||
@@ -2044,8 +1981,6 @@ mod tests {
|
||||
assert_eq!(state.selected_index(), None);
|
||||
}
|
||||
|
||||
// -- Follow → NAV transitions ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn j_in_follow_is_noop() {
|
||||
let items: Vec<TestItem> = (0..10).map(TestItem::new).collect();
|
||||
@@ -2071,7 +2006,8 @@ mod tests {
|
||||
assert!(!state.follow_mode);
|
||||
// Should be one item above the last visible.
|
||||
let sel = state.selected_index().unwrap();
|
||||
assert!(sel < 9); // moved up from the last visible
|
||||
// Moved up from the last visible.
|
||||
assert!(sel < 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2089,8 +2025,6 @@ mod tests {
|
||||
assert!(state.selected_index().is_some());
|
||||
}
|
||||
|
||||
// -- NAV mode: new items don't reset edge state ---------------------------
|
||||
|
||||
#[test]
|
||||
fn new_items_dont_reset_edge_state() {
|
||||
let mut items: Vec<TestItem> = (0..10).map(TestItem::new).collect();
|
||||
@@ -2161,16 +2095,16 @@ mod tests {
|
||||
|
||||
// n → next match = physical 2 (vis 1).
|
||||
state.next_match(&items);
|
||||
assert_eq!(state.selected_index(), Some(1)); // vis index 1
|
||||
assert_eq!(state.selected_id(), Some(2)); // physical id 2
|
||||
// Vis index 1.
|
||||
assert_eq!(state.selected_index(), Some(1));
|
||||
// Physical id 2.
|
||||
assert_eq!(state.selected_id(), Some(2));
|
||||
|
||||
// n → wraps to physical 0 (vis 0).
|
||||
state.next_match(&items);
|
||||
assert_eq!(state.selected_index(), Some(0));
|
||||
}
|
||||
|
||||
// -- Config gating tests --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn follow_disabled_g_selects_last_item() {
|
||||
let config = ListPaneConfig {
|
||||
@@ -2194,7 +2128,8 @@ mod tests {
|
||||
..ListPaneConfig::default()
|
||||
};
|
||||
let state = ListPaneState::new_with_config(WrapMode::NoWrap, true, config);
|
||||
assert!(!state.follow_mode); // forced off by config
|
||||
// Forced off by config.
|
||||
assert!(!state.follow_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2238,7 +2173,8 @@ mod tests {
|
||||
state.half_page_down(&items);
|
||||
}
|
||||
assert!(!state.follow_mode);
|
||||
assert_eq!(state.scroll_offset(), 10); // at bottom
|
||||
// At bottom.
|
||||
assert_eq!(state.scroll_offset(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2288,8 +2224,6 @@ mod tests {
|
||||
assert_eq!(state.wrap_mode(), WrapMode::NoWrap);
|
||||
}
|
||||
|
||||
// -- Toggle follow tests --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn toggle_follow_from_nav_engages() {
|
||||
let items: Vec<TestItem> = (0..20).map(TestItem::new).collect();
|
||||
@@ -2315,8 +2249,6 @@ mod tests {
|
||||
assert!(state.selected_index().is_some());
|
||||
}
|
||||
|
||||
// -- Copy tests -----------------------------------------------------------
|
||||
|
||||
/// Helper: create a state with copy enabled.
|
||||
fn new_with_copy() -> ListPaneState {
|
||||
ListPaneState::new_with_config(
|
||||
@@ -2485,8 +2417,6 @@ mod tests {
|
||||
assert!(!state.copy_selected(&items));
|
||||
}
|
||||
|
||||
// -- Visual select tests --------------------------------------------------
|
||||
|
||||
/// Helper: create a state with visual select + copy enabled.
|
||||
fn new_with_visual() -> ListPaneState {
|
||||
ListPaneState::new_with_config(
|
||||
@@ -2536,11 +2466,12 @@ mod tests {
|
||||
// Select item 3, enter visual, move down to 5.
|
||||
state.select_at(3, &items);
|
||||
state.enter_visual_mode(&items);
|
||||
state.select_next(&items); // → 4
|
||||
state.select_next(&items); // → 5
|
||||
state.prepare_layout(&items, 80, 10); // resolve range
|
||||
state.select_next(&items);
|
||||
state.select_next(&items);
|
||||
// Resolve range.
|
||||
state.prepare_layout(&items, 80, 10);
|
||||
|
||||
assert_eq!(state.multi_range(), Some(3..6)); // [3, 4, 5]
|
||||
assert_eq!(state.multi_range(), Some(3..6));
|
||||
assert_eq!(state.selected_index(), Some(5));
|
||||
}
|
||||
|
||||
@@ -2553,11 +2484,11 @@ mod tests {
|
||||
// Select item 5, enter visual, move up to 3.
|
||||
state.select_at(5, &items);
|
||||
state.enter_visual_mode(&items);
|
||||
state.select_prev(&items); // → 4
|
||||
state.select_prev(&items); // → 3
|
||||
state.select_prev(&items);
|
||||
state.select_prev(&items);
|
||||
state.prepare_layout(&items, 80, 10);
|
||||
|
||||
assert_eq!(state.multi_range(), Some(3..6)); // [3, 4, 5]
|
||||
assert_eq!(state.multi_range(), Some(3..6));
|
||||
assert_eq!(state.selected_index(), Some(3));
|
||||
}
|
||||
|
||||
@@ -2577,7 +2508,7 @@ mod tests {
|
||||
assert_eq!(state.selected_index(), Some(2));
|
||||
|
||||
state.prepare_layout(&items, 80, 10);
|
||||
assert_eq!(state.multi_range(), Some(0..3)); // [0, 1, 2]
|
||||
assert_eq!(state.multi_range(), Some(0..3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2603,9 +2534,10 @@ mod tests {
|
||||
// Select items 1..=3 visually.
|
||||
state.select_at(1, &items);
|
||||
state.enter_visual_mode(&items);
|
||||
state.select_next(&items); // → 2
|
||||
state.select_next(&items); // → 3
|
||||
state.prepare_layout(&items, 80, 10); // resolve range
|
||||
state.select_next(&items);
|
||||
state.select_next(&items);
|
||||
// Resolve range.
|
||||
state.prepare_layout(&items, 80, 10);
|
||||
|
||||
// y copies and clears visual mode.
|
||||
state.handle_key_event(&key!('y').to_key_event(), &items);
|
||||
@@ -2673,7 +2605,8 @@ mod tests {
|
||||
fn select_next_scrolls_in_small_viewport() {
|
||||
let items: Vec<TestItem> = (0..12).map(TestItem::new).collect();
|
||||
let mut state = ListPaneState::new(WrapMode::NoWrap, false);
|
||||
state.prepare_layout(&items, 80, 4); // width=80, viewport_height=4
|
||||
// width=80, viewport_height=4.
|
||||
state.prepare_layout(&items, 80, 4);
|
||||
|
||||
for step in 0..12 {
|
||||
state.select_next(&items);
|
||||
@@ -2700,7 +2633,8 @@ mod tests {
|
||||
|
||||
for step in 0..12 {
|
||||
// simulate render: prepare_layout first
|
||||
state.prepare_layout(&items, 80, 4); // width=80, viewport_height=4
|
||||
// width=80, viewport_height=4.
|
||||
state.prepare_layout(&items, 80, 4);
|
||||
// simulate key: select_next
|
||||
state.select_next(&items);
|
||||
let idx = state.selected_index.unwrap();
|
||||
@@ -2721,8 +2655,10 @@ mod tests {
|
||||
fn scroll_lines_works_in_small_viewport() {
|
||||
let items: Vec<TestItem> = (0..12).map(TestItem::new).collect();
|
||||
let mut state = ListPaneState::new(WrapMode::NoWrap, false);
|
||||
state.prepare_layout(&items, 80, 4); // width=80, viewport_height=4
|
||||
state.select_next(&items); // select first
|
||||
// width=80, viewport_height=4.
|
||||
state.prepare_layout(&items, 80, 4);
|
||||
// Select first.
|
||||
state.select_next(&items);
|
||||
|
||||
let total = state.layout.total_height();
|
||||
let vp = state.viewport_height;
|
||||
|
||||
@@ -591,7 +591,6 @@ fn render_file_list(buf: &mut Buffer, area: Rect, state: &mut MemoryModalState,
|
||||
}
|
||||
}
|
||||
|
||||
// List scrollbar.
|
||||
render_scrollbar(
|
||||
buf,
|
||||
sb_area,
|
||||
@@ -1220,8 +1219,9 @@ mod tests {
|
||||
|
||||
let indices = state.filtered_indices();
|
||||
assert_eq!(indices.len(), 2);
|
||||
assert_eq!(indices[0], 0); // Global header
|
||||
assert_eq!(indices[1], 1); // MEMORY.md
|
||||
// indices[0] is the Global header, indices[1] is MEMORY.md.
|
||||
assert_eq!(indices[0], 0);
|
||||
assert_eq!(indices[1], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1301,17 +1301,22 @@ mod tests {
|
||||
#[test]
|
||||
fn truncate_to_width_handles_multibyte() {
|
||||
// CJK characters are 2 columns wide.
|
||||
let s = "\u{4F60}\u{597D}world"; // 你好world — 2+2+5 = 9 cols
|
||||
assert_eq!(truncate_to_width(s, 4), "\u{4F60}\u{597D}"); // 2+2 = 4
|
||||
assert_eq!(truncate_to_width(s, 3), "\u{4F60}"); // 2, next char is 2 → exceeds 3
|
||||
assert_eq!(truncate_to_width(s, 9), s); // fits
|
||||
// 你好world — 2+2+5 = 9 cols.
|
||||
let s = "\u{4F60}\u{597D}world";
|
||||
// 2+2 = 4.
|
||||
assert_eq!(truncate_to_width(s, 4), "\u{4F60}\u{597D}");
|
||||
// 2, next char is 2 → exceeds 3.
|
||||
assert_eq!(truncate_to_width(s, 3), "\u{4F60}");
|
||||
// Fits.
|
||||
assert_eq!(truncate_to_width(s, 9), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_filter_updates_on_invalidate() {
|
||||
let entries = build_test_entries();
|
||||
let mut state = MemoryModalState::new(entries);
|
||||
assert_eq!(state.filtered_indices().len(), 4); // all entries
|
||||
// All entries.
|
||||
assert_eq!(state.filtered_indices().len(), 4);
|
||||
|
||||
state.query = "session".to_string();
|
||||
state.invalidate_filter();
|
||||
@@ -1446,13 +1451,13 @@ mod tests {
|
||||
let mut state = MemoryModalState::new(entries);
|
||||
state.preview_scroll = 5;
|
||||
|
||||
// Ctrl+D should NOT scroll preview (removed hotkey).
|
||||
// Ctrl+D does not scroll the preview.
|
||||
let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
|
||||
let result = handle_memory_key(&mut state, &key);
|
||||
assert!(matches!(result, InputOutcome::Unchanged));
|
||||
assert_eq!(state.preview_scroll, 5);
|
||||
|
||||
// Ctrl+U should NOT scroll preview (removed hotkey).
|
||||
// Ctrl+U does not scroll the preview.
|
||||
let key = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
|
||||
let result = handle_memory_key(&mut state, &key);
|
||||
assert!(matches!(result, InputOutcome::Unchanged));
|
||||
|
||||
@@ -22,10 +22,6 @@ use unicode_width::UnicodeWidthStr;
|
||||
use crate::render::line_utils::byte_offset_at_width;
|
||||
use crate::theme::Theme;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub use crate::modal_window_state::{ModalWindowState, ShortcutHitArea};
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -56,7 +52,6 @@ pub fn embedded() -> bool {
|
||||
pub struct EmbeddedRowStyle {
|
||||
/// Row background: always transparent (`Color::Reset`).
|
||||
pub bg: Color,
|
||||
/// True when this row is the selected row.
|
||||
pub selected: bool,
|
||||
selected_fg: Color,
|
||||
}
|
||||
@@ -92,7 +87,6 @@ pub struct ModalWindowConfig<'a> {
|
||||
pub tabs: Option<&'a [&'a str]>,
|
||||
/// Footer shortcuts to render inline at the bottom.
|
||||
pub shortcuts: &'a [Shortcut<'a>],
|
||||
/// Sizing parameters.
|
||||
pub sizing: ModalSizing,
|
||||
/// Fold state of the currently focused entry. When provided,
|
||||
/// Left/Right/h/l return specific fold outcomes instead of
|
||||
@@ -137,7 +131,7 @@ impl Default for ModalSizing {
|
||||
|
||||
impl ModalSizing {
|
||||
/// Medium popup: ~60% width, standard padding. Good for picker lists.
|
||||
/// Used by: cloud_modal and other pickers (verified: values match exactly).
|
||||
/// Used by: cloud_modal and other pickers.
|
||||
pub fn medium() -> Self {
|
||||
Self {
|
||||
width_pct: 0.60,
|
||||
@@ -268,10 +262,6 @@ pub enum ModalWindowOutcome {
|
||||
Unhandled,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the modal window chrome and return the content area for the
|
||||
/// caller to render into.
|
||||
///
|
||||
@@ -391,7 +381,6 @@ pub fn render_modal_window(
|
||||
if let Some(tabs) = config.tabs {
|
||||
tab_bar_height = render_tab_bar(buf, inner, state, tabs, theme);
|
||||
tab_divider_height = 1;
|
||||
// Full-width divider below tab bar.
|
||||
let div_y = inner.y + tab_bar_height;
|
||||
if div_y < inner.y + inner.height {
|
||||
let div_bg = if is_embedded {
|
||||
@@ -446,7 +435,6 @@ pub fn render_modal_window(
|
||||
height: footer_height,
|
||||
};
|
||||
|
||||
// Render footer shortcuts.
|
||||
state.shortcut_hits = render_modal_shortcuts(
|
||||
buf,
|
||||
footer_area,
|
||||
@@ -649,7 +637,7 @@ pub(crate) fn shortcuts_rows_needed(shortcuts: &[Shortcut<'_>], width: u16) -> u
|
||||
return 0;
|
||||
}
|
||||
let avail = width as usize;
|
||||
let sep_w = " | ".width(); // 5
|
||||
let sep_w = " | ".width();
|
||||
let mut rows = 1u16;
|
||||
let mut cur_row_w: usize = 0;
|
||||
for shortcut in shortcuts {
|
||||
@@ -738,7 +726,6 @@ pub fn render_modal_shortcuts(
|
||||
}
|
||||
}
|
||||
|
||||
// Limit to available height.
|
||||
rows.truncate(area.height as usize);
|
||||
|
||||
// Render rows bottom-aligned: last row at the bottom of the area.
|
||||
@@ -749,7 +736,6 @@ pub fn render_modal_shortcuts(
|
||||
for (row_idx, row_indices) in rows.iter().enumerate() {
|
||||
let y = area.y + area.height - num_rows + row_idx as u16;
|
||||
|
||||
// Compute this row's total width for centering.
|
||||
let row_total: usize = row_indices
|
||||
.iter()
|
||||
.map(|&i| shortcuts[i].label.width())
|
||||
@@ -774,7 +760,6 @@ pub fn render_modal_shortcuts(
|
||||
let visible_w = display.width() as u16;
|
||||
let is_hovered = hovered == Some(shortcut_idx);
|
||||
|
||||
// Underlay: fill cell bg with bg_highlight on hover.
|
||||
if is_hovered {
|
||||
let hover_bg = Style::default().bg(theme.bg_highlight);
|
||||
for x in cur_x..cur_x + visible_w {
|
||||
@@ -784,10 +769,6 @@ pub fn render_modal_shortcuts(
|
||||
}
|
||||
}
|
||||
|
||||
// Split the label at the first whitespace: the leading token
|
||||
// is the "key" (rendered bold in text_secondary) and the rest
|
||||
// is the descriptive label (rendered in gray, the tertiary
|
||||
// shade). Single-token labels render entirely as the key.
|
||||
let (key_part, label_part) = split_shortcut_label(display);
|
||||
|
||||
let mut key_style = Style::default()
|
||||
@@ -820,7 +801,6 @@ pub fn render_modal_shortcuts(
|
||||
});
|
||||
|
||||
cur_x += visible_w;
|
||||
// Separator after every shortcut except the last in this row.
|
||||
if local_idx + 1 < row_indices.len() {
|
||||
let sep_remaining = row_end.saturating_sub(cur_x) as usize;
|
||||
if sep_remaining == 0 {
|
||||
@@ -836,10 +816,6 @@ pub fn render_modal_shortcuts(
|
||||
hits
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Centered tip footer (Settings / How-to Guides)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// First candidate that fits `width`, else truncate the last.
|
||||
pub(crate) fn fit_tip_line<'a>(candidates: &[&'a str], width: usize) -> std::borrow::Cow<'a, str> {
|
||||
if width == 0 {
|
||||
@@ -910,10 +886,6 @@ pub(crate) fn footer_lines_with_tip_gap(
|
||||
.max(2)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fold indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render a fold indicator glyph at position `(x, y)`.
|
||||
///
|
||||
/// Draws `▶ ` (collapsed) or `▼ ` (expanded) in `gray_dim` with optional
|
||||
@@ -974,10 +946,6 @@ pub fn render_fold_indicator(
|
||||
width
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Process a key event against the modal chrome.
|
||||
///
|
||||
/// Returns:
|
||||
@@ -1045,14 +1013,12 @@ pub fn handle_modal_mouse(
|
||||
|
||||
let on_close = state.close_button_rect.is_some_and(&in_rect);
|
||||
|
||||
// Check if on a tab.
|
||||
let on_tab: Option<usize> = state
|
||||
.tab_rects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(i, r)| r.filter(|r| in_rect(*r)).map(|_| i));
|
||||
|
||||
// Check if on a clickable shortcut (for click dispatch).
|
||||
let on_shortcut: Option<usize> = state
|
||||
.shortcut_hits
|
||||
.iter()
|
||||
@@ -1274,8 +1240,6 @@ mod tests {
|
||||
assert!(fit_tip_line(&["abcdef", "xy"], 1).as_ref().width() <= 1);
|
||||
}
|
||||
|
||||
// -- ModalSizing::with_compact tests --
|
||||
|
||||
#[test]
|
||||
fn modal_sizing_with_compact_reduces_margins_aggressively() {
|
||||
let base = ModalSizing {
|
||||
@@ -1316,8 +1280,6 @@ mod tests {
|
||||
assert_eq!(unchanged.h_pad, 3);
|
||||
}
|
||||
|
||||
// -- compute_modal_dims --
|
||||
|
||||
#[test]
|
||||
fn modal_width_never_exceeds_narrow_terminal() {
|
||||
// Regression: the min_width floor used to re-inflate the modal past a narrow buffer.
|
||||
@@ -1329,8 +1291,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- ModalWindowState construction --
|
||||
|
||||
#[test]
|
||||
fn new_defaults() {
|
||||
let s = ModalWindowState::new();
|
||||
@@ -1360,8 +1320,6 @@ mod tests {
|
||||
assert_eq!(a.close_hovered, b.close_hovered);
|
||||
}
|
||||
|
||||
// -- handle_modal_key --
|
||||
|
||||
#[test]
|
||||
fn key_esc_returns_close_requested() {
|
||||
let mut state = ModalWindowState::new();
|
||||
@@ -1428,8 +1386,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- handle_modal_key with FoldInfo --
|
||||
|
||||
fn config_with_fold<'a>(fold_info: FoldInfo) -> ModalWindowConfig<'a> {
|
||||
ModalWindowConfig {
|
||||
title: "Test",
|
||||
@@ -1610,8 +1566,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- FoldInfo precedence & edge cases --
|
||||
|
||||
#[test]
|
||||
fn left_collapse_group_wins_over_collapse_details() {
|
||||
// When both collapsible+expanded AND has_details+details_expanded
|
||||
@@ -1722,8 +1676,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- handle_modal_mouse --
|
||||
|
||||
#[test]
|
||||
fn click_on_close_button_returns_close_requested() {
|
||||
let mut state = ModalWindowState::new();
|
||||
@@ -1890,8 +1842,6 @@ mod tests {
|
||||
assert_eq!(state.hovered_shortcut, Some(3));
|
||||
}
|
||||
|
||||
// -- ModalSizing presets --
|
||||
|
||||
#[test]
|
||||
fn modal_sizing_medium_has_expected_values() {
|
||||
let m = ModalSizing::medium();
|
||||
@@ -1909,8 +1859,6 @@ mod tests {
|
||||
assert_eq!(ModalSizing::large(), ModalSizing::default());
|
||||
}
|
||||
|
||||
// -- split_shortcut_label --
|
||||
|
||||
#[test]
|
||||
fn split_shortcut_label_basic_ascii() {
|
||||
assert_eq!(split_shortcut_label("Esc cancel"), ("Esc", " cancel"));
|
||||
|
||||
@@ -58,7 +58,6 @@ pub fn render_new_worktree_dialog(area: Rect, buf: &mut Buffer, state: &NewWorkt
|
||||
.flex(Flex::Center)
|
||||
.areas(dialog_h);
|
||||
|
||||
// Draw background
|
||||
let bg_style = Style::default().bg(theme.bg_dark);
|
||||
for y in dialog.y..dialog.y + dialog.height {
|
||||
for x in dialog.x..dialog.x + dialog.width {
|
||||
@@ -69,9 +68,7 @@ pub fn render_new_worktree_dialog(area: Rect, buf: &mut Buffer, state: &NewWorkt
|
||||
}
|
||||
}
|
||||
|
||||
// Draw border
|
||||
let border_style = Style::default().fg(theme.gray_dim).bg(theme.bg_dark);
|
||||
// Top border
|
||||
if let Some(cell) = buf.cell_mut((dialog.x, dialog.y)) {
|
||||
cell.set_char('\u{256D}');
|
||||
cell.set_style(border_style);
|
||||
@@ -86,7 +83,6 @@ pub fn render_new_worktree_dialog(area: Rect, buf: &mut Buffer, state: &NewWorkt
|
||||
cell.set_char('\u{256E}');
|
||||
cell.set_style(border_style);
|
||||
}
|
||||
// Bottom border
|
||||
let bottom = dialog.y + dialog.height - 1;
|
||||
if let Some(cell) = buf.cell_mut((dialog.x, bottom)) {
|
||||
cell.set_char('\u{2570}');
|
||||
@@ -102,7 +98,6 @@ pub fn render_new_worktree_dialog(area: Rect, buf: &mut Buffer, state: &NewWorkt
|
||||
cell.set_char('\u{256F}');
|
||||
cell.set_style(border_style);
|
||||
}
|
||||
// Side borders
|
||||
for y in dialog.y + 1..dialog.y + dialog.height - 1 {
|
||||
if let Some(cell) = buf.cell_mut((dialog.x, y)) {
|
||||
cell.set_char('\u{2502}');
|
||||
@@ -187,7 +182,8 @@ fn visible_input_suffix(label: &str, budget: usize) -> String {
|
||||
return "…".to_string();
|
||||
}
|
||||
|
||||
let suffix_budget = budget - 1; // reserve one column for leading …
|
||||
// Reserve one column for the leading …
|
||||
let suffix_budget = budget - 1;
|
||||
let mut width = 0usize;
|
||||
let mut start = label.len();
|
||||
let graphemes: Vec<(usize, &str)> = label.grapheme_indices(true).collect();
|
||||
@@ -228,7 +224,7 @@ mod tests {
|
||||
#[test]
|
||||
fn empty_dialog_uses_minimum_width() {
|
||||
assert_eq!(dialog_width_for(120, ""), MIN_DIALOG_WIDTH);
|
||||
assert_eq!(dialog_width_for(40, ""), 36); // area.width - 4
|
||||
assert_eq!(dialog_width_for(40, ""), 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -252,7 +248,7 @@ mod tests {
|
||||
fn dialog_clamps_to_terminal_width() {
|
||||
let label = "x".repeat(100);
|
||||
let width = dialog_width_for(60, &label);
|
||||
assert_eq!(width, 56); // 60 - 4
|
||||
assert_eq!(width, 56);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -132,22 +132,18 @@ impl OverlayState {
|
||||
OverlayAction::Changed
|
||||
}
|
||||
|
||||
/// Show and focus (e.g. auto-show when items arrive).
|
||||
/// Show (does not change focus). Used e.g. when items arrive.
|
||||
pub fn show(&mut self) {
|
||||
self.visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle structural keys for any focused overlay pane.
|
||||
/// Handle structural keys that fire even when an input bar has focus.
|
||||
///
|
||||
/// Processes Tab, Esc, q, Space, and Ctrl-F consistently. Returns
|
||||
/// `Some(action)` if a structural key was consumed, `None` to let the
|
||||
/// pane's content handler process the key.
|
||||
///
|
||||
/// When `has_input_bar` is true, only Ctrl-F is processed (the input
|
||||
/// bar handles Esc/Tab/etc. itself).
|
||||
/// Currently just Ctrl-F. Returns `Some(action)` if consumed, `None` to
|
||||
/// let the pane's content handler process the key. See
|
||||
/// [`handle_overlay_nav_key`] for the keys gated behind `has_input_bar`.
|
||||
pub fn handle_overlay_key(state: &mut OverlayState, key: &KeyEvent) -> Option<OverlayAction> {
|
||||
// Ctrl-F: toggle fullscreen (works even with input bar open).
|
||||
if key.code == KeyCode::Char('f') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return Some(state.toggle_fullscreen());
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Shared prompt-area list overlay: accent bar, bold title, and a
|
||||
//! scrollable single-line row list with a cursor.
|
||||
//!
|
||||
//! One source of truth for the row geometry that `/rewind`'s picker phase
|
||||
//! and `/jump` previously each kept in sync by hand across their render,
|
||||
//! hit-test, and height functions. Row *content* stays with the caller
|
||||
//! (a closure); this owns chrome, cursor styling, and the scroll window.
|
||||
//! One source of truth for the row geometry shared by `/rewind`'s picker
|
||||
//! phase and `/jump` across their render, hit-test, and height paths. Row
|
||||
//! *content* stays with the caller (a closure); this owns chrome, cursor
|
||||
//! styling, and the scroll window.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -29,7 +29,6 @@ pub struct RowCtx {
|
||||
pub is_cursor: bool,
|
||||
/// Resolved row background (cursor rows get the visual-selection bg).
|
||||
pub row_bg: Color,
|
||||
/// Width available for the row's content.
|
||||
pub content_width: u16,
|
||||
}
|
||||
|
||||
@@ -210,12 +209,15 @@ mod tests {
|
||||
len: 2,
|
||||
selected: 0,
|
||||
};
|
||||
assert_eq!(two.height(40), 5); // title + 2 rows + padding
|
||||
// title + 2 rows + padding
|
||||
assert_eq!(two.height(40), 5);
|
||||
let many = ListOverlay {
|
||||
len: 30,
|
||||
selected: 0,
|
||||
};
|
||||
assert_eq!(many.height(40), 18); // 15-row cap
|
||||
assert_eq!(many.height(12), 8); // 60% screen cap
|
||||
// 15-row cap
|
||||
assert_eq!(many.height(40), 18);
|
||||
// 60% screen cap
|
||||
assert_eq!(many.height(12), 8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,6 @@ use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::theme::Theme;
|
||||
|
||||
// ── Enums ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Interaction mode for the permission overlay.
|
||||
///
|
||||
/// Mirrors [`QuestionFocus`](crate::views::question_view::QuestionFocus) from
|
||||
@@ -96,8 +94,6 @@ impl McpScopeState {
|
||||
}
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// A queued permission request awaiting user response.
|
||||
///
|
||||
/// The pager maintains a `VecDeque` of these on `AgentView`. Only the front
|
||||
@@ -114,11 +110,9 @@ pub struct PermissionViewState {
|
||||
/// `perm_req_id`). Used to guard against stale resolution attempts.
|
||||
pub id: usize,
|
||||
|
||||
// -- Interaction mode --
|
||||
/// Current focus mode. Determines input routing and rendering.
|
||||
pub focus: PermissionFocus,
|
||||
|
||||
// -- Options --
|
||||
/// All permission options from the request (cloned from
|
||||
/// `request.options` so the request can be moved into the struct).
|
||||
pub options: Vec<acp::PermissionOption>,
|
||||
@@ -126,7 +120,6 @@ pub struct PermissionViewState {
|
||||
/// Currently focused option index (only meaningful for the front request).
|
||||
pub active_idx: usize,
|
||||
|
||||
// -- Bash command selection --
|
||||
/// Parsed bash highlights from request meta (None for non-bash
|
||||
/// permissions). Imported from `kigi-shell`, NOT duplicated locally.
|
||||
pub bash_highlights: Option<BashCommandHighlights>,
|
||||
@@ -141,14 +134,12 @@ pub struct PermissionViewState {
|
||||
/// (complex commands that tree-sitter cannot decompose).
|
||||
pub bash_command_raw: Option<String>,
|
||||
|
||||
// -- MCP scope selection --
|
||||
/// MCP scope toggle state. `None` for non-MCP prompts. Populated when the
|
||||
/// request carries an `allow-always-mcp` option whose meta deserializes
|
||||
/// as `McpToolPermission`. Mutually exclusive with the bash flow at the
|
||||
/// per-request level.
|
||||
pub mcp_scope: Option<McpScopeState>,
|
||||
|
||||
// -- Display content (precomputed on creation) --
|
||||
/// Title text (e.g. agent-provided bash description, or "Allow Edit?").
|
||||
pub title: String,
|
||||
|
||||
@@ -166,18 +157,13 @@ pub struct PermissionViewState {
|
||||
/// Scroll offset for description area.
|
||||
pub desc_scroll: u16,
|
||||
|
||||
// -- Subagent provenance --
|
||||
/// If this permission was requested by a subagent, its descriptive label.
|
||||
/// Derived from matching `request.session_id` against known subagent
|
||||
/// sessions. Displayed as a provenance line above the title.
|
||||
pub subagent_label: Option<String>,
|
||||
|
||||
// -- Prompt stash (queue-level, not per-request) --
|
||||
// NOTE: prompt stash is NOT on PermissionViewState.
|
||||
// It lives on AgentView as `permission_stashed_prompt`.
|
||||
// See the "Queue-level prompt stashing" section in the plan.
|
||||
|
||||
// -- Layout cache --
|
||||
// Prompt stash is queue-level, not per-request, so it is NOT a field
|
||||
// here — it lives on AgentView as `permission_stashed_prompt`.
|
||||
/// Cached options area height (for scroll calculations).
|
||||
pub options_area_height: usize,
|
||||
|
||||
@@ -223,13 +209,9 @@ fn shortcut_label(index: usize) -> &'static str {
|
||||
.unwrap_or(SHORTCUT_LABELS[0])
|
||||
}
|
||||
|
||||
// ── Subagent tracking ──────────────────────────────────────────────────
|
||||
|
||||
// SubagentInfo lives in app::subagent — re-export for backward compat.
|
||||
// SubagentInfo lives in app::subagent; re-exported here for backward compat.
|
||||
pub use crate::app::subagent::SubagentInfo;
|
||||
|
||||
// ── Height calculation ─────────────────────────────────────────────────
|
||||
|
||||
/// Chrome height for the permission view as actually rendered.
|
||||
///
|
||||
/// Public version for mouse hit-testing in agent_view. Takes `area_h`
|
||||
@@ -255,11 +237,14 @@ pub fn permission_chrome_height_pub(
|
||||
/// applying a height cap to the overall permission view.
|
||||
fn permission_chrome_height(state: &PermissionViewState, content_w: usize) -> u16 {
|
||||
let bash_line_count = bash_display_line_count(state, content_w) as u16;
|
||||
let mut h: u16 = 1; // vpad top
|
||||
// vpad top
|
||||
let mut h: u16 = 1;
|
||||
if state.subagent_label.is_some() {
|
||||
h += 1; // provenance line
|
||||
// provenance line
|
||||
h += 1;
|
||||
}
|
||||
h += 1; // title line
|
||||
// title line
|
||||
h += 1;
|
||||
h += bash_line_count;
|
||||
// Planned MCP arguments: same `mcp_args_visible_rows` budget as the
|
||||
// render. Clamp before the cast (`as u16` wraps) and saturate the adds
|
||||
@@ -274,7 +259,8 @@ fn permission_chrome_height(state: &PermissionViewState, content_w: usize) -> u1
|
||||
if state.has_adjustable_scope() {
|
||||
h = h.saturating_add(1);
|
||||
}
|
||||
h.saturating_add(1) // gap before options
|
||||
// gap before options
|
||||
h.saturating_add(1)
|
||||
}
|
||||
|
||||
/// Compute the total height the permission view should occupy.
|
||||
@@ -365,8 +351,6 @@ fn bash_display_line_count(state: &PermissionViewState, content_w: usize) -> usi
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────
|
||||
|
||||
fn hovered_bg(theme: &Theme) -> ratatui::style::Color {
|
||||
theme.bg_hover
|
||||
}
|
||||
@@ -400,8 +384,10 @@ pub struct InlinePromptArea {
|
||||
/// (`"<n> (●) ❯ "` = 8 chars). Matches the `text_w` computed during
|
||||
/// rendering so `desired_height` wraps at the same width as the draw area.
|
||||
pub fn inline_text_width(area_width: u16) -> u16 {
|
||||
const LEFT_PAD: u16 = 3; // accent column + 2 padding
|
||||
const PREFIX_W: u16 = 8; // "x (●) ❯ " = 2 + 4 + 2
|
||||
// accent column + 2 padding
|
||||
const LEFT_PAD: u16 = 3;
|
||||
// "x (●) ❯ " = 2 + 4 + 2
|
||||
const PREFIX_W: u16 = 8;
|
||||
area_width.saturating_sub(LEFT_PAD + PREFIX_W)
|
||||
}
|
||||
|
||||
@@ -439,7 +425,7 @@ pub fn render_permission_view(
|
||||
let accent_style = Style::default().fg(theme.accent_user);
|
||||
for row in area.y..area.y + area.height {
|
||||
if let Some(cell) = buf.cell_mut((area.x, row)) {
|
||||
cell.set_symbol(crate::glyphs::accent_bar()); // ┃
|
||||
cell.set_symbol(crate::glyphs::accent_bar());
|
||||
cell.set_style(accent_style);
|
||||
}
|
||||
}
|
||||
@@ -452,8 +438,6 @@ pub fn render_permission_view(
|
||||
// Vertical padding at the top.
|
||||
y += 1;
|
||||
|
||||
// ── Chrome header ──
|
||||
|
||||
// Bottom of the drawable area. The chrome rows below are written at
|
||||
// increasing `y`; when the overlay is squeezed into a 1-2 row area at the
|
||||
// bottom of a short terminal they must not write past it (ratatui's
|
||||
@@ -549,8 +533,8 @@ pub fn render_permission_view(
|
||||
}
|
||||
if show_scope_hint && y < area.y + area.height {
|
||||
// Readable secondary text, arrows highlighted in accent for
|
||||
// scannability. Previously used `theme.gray` + `Modifier::DIM`,
|
||||
// which was unreadable on several theme backgrounds.
|
||||
// scannability: plain `theme.gray` + `Modifier::DIM` is unreadable
|
||||
// on several theme backgrounds.
|
||||
let hint_style = Style::default()
|
||||
.fg(theme.text_secondary)
|
||||
.add_modifier(Modifier::DIM);
|
||||
@@ -566,7 +550,6 @@ pub fn render_permission_view(
|
||||
// Gap before options.
|
||||
y += 1;
|
||||
|
||||
// ── Option rows ──
|
||||
let visible_bottom = area.y + area.height;
|
||||
let hover_bg = hovered_bg(theme);
|
||||
|
||||
@@ -586,11 +569,13 @@ pub fn render_permission_view(
|
||||
// In FollowupInput mode, skip the RejectOnce static row —
|
||||
// the caller will render the inline prompt widget at this position.
|
||||
if is_followup && option.kind == acp::PermissionOptionKind::RejectOnce {
|
||||
let row_bg = theme.bg_visual; // always focused bg for the input row
|
||||
// always focused bg for the input row
|
||||
let row_bg = theme.bg_visual;
|
||||
|
||||
// Fill the FULL row width including padding between accent ┃ and content.
|
||||
let full_row = Rect {
|
||||
x: area.x + 1, // after the accent symbol
|
||||
// after the accent symbol
|
||||
x: area.x + 1,
|
||||
y,
|
||||
width: area.width.saturating_sub(1),
|
||||
height: 1,
|
||||
@@ -629,8 +614,10 @@ pub fn render_permission_view(
|
||||
// Tell the caller where to render the prompt widget text.
|
||||
// Use full width to the right edge (not the 2-col-padded content_width)
|
||||
// so the scrollbar sits flush against the border — matching Q/A panel.
|
||||
let prefix_w: u16 = 8; // "x (●) ❯ " = 2 + 4 + 2 = 8
|
||||
let full_w = area.width.saturating_sub(3); // only left padding (accent + 2)
|
||||
// "x (●) ❯ " = 2 + 4 + 2 = 8
|
||||
let prefix_w: u16 = 8;
|
||||
// only left padding (accent + 2)
|
||||
let full_w = area.width.saturating_sub(3);
|
||||
inline_prompt_result = Some(InlinePromptArea {
|
||||
text_x: content_x + prefix_w,
|
||||
y,
|
||||
@@ -1010,11 +997,13 @@ fn bash_quote_aware_wrap(line: &str, width: usize) -> Vec<&str> {
|
||||
|
||||
let mut rows: Vec<&str> = Vec::new();
|
||||
let mut row_start = 0usize;
|
||||
let mut last_break = 0usize; // exclusive end of content if we break here
|
||||
// exclusive end of content if we break here
|
||||
let mut last_break = 0usize;
|
||||
|
||||
// Consider each break point as a candidate end for the current row.
|
||||
let mut candidates = break_after;
|
||||
candidates.push(line.len()); // allow ending at EOL
|
||||
// allow ending at EOL
|
||||
candidates.push(line.len());
|
||||
|
||||
for &b in &candidates {
|
||||
if b <= row_start {
|
||||
@@ -1101,7 +1090,8 @@ fn quote_aware_break_points(line: &str) -> Vec<usize> {
|
||||
}
|
||||
if in_double {
|
||||
if c == b'\\' && i + 1 < bytes.len() {
|
||||
i += 2; // skip escape
|
||||
// skip escape
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if c == b'"' {
|
||||
@@ -1160,7 +1150,8 @@ fn build_raw_bash_lines(command: &str, content_width: usize) -> Vec<Line<'static
|
||||
let mut offset = 0usize;
|
||||
for (idx, physical) in text.split('\n').enumerate() {
|
||||
if idx > 0 {
|
||||
offset += 1; // the '\n'
|
||||
// the '\n'
|
||||
offset += 1;
|
||||
}
|
||||
out.extend(soft_wrap_physical_line(
|
||||
physical,
|
||||
@@ -1380,7 +1371,8 @@ fn build_bash_lines_with_selection(
|
||||
let mut offset = 0usize;
|
||||
for (line_idx, physical) in display.split('\n').enumerate() {
|
||||
if line_idx > 0 {
|
||||
offset += 1; // the '\n'
|
||||
// the '\n'
|
||||
offset += 1;
|
||||
}
|
||||
let line_start = offset;
|
||||
|
||||
|
||||
@@ -18,10 +18,6 @@ use crate::views::modal_window::{
|
||||
self, ModalContentArea, ModalSizing, ModalWindowConfig, ModalWindowState, Shortcut,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field enum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Navigable fields in the persona detail view.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PersonaField {
|
||||
@@ -67,7 +63,6 @@ impl PersonaField {
|
||||
Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
|
||||
}
|
||||
|
||||
/// True for fields that support inline text editing.
|
||||
fn is_editable(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
@@ -76,10 +71,6 @@ impl PersonaField {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode state machine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PersonaDetailMode {
|
||||
Browse,
|
||||
@@ -91,10 +82,6 @@ pub enum PersonaDetailMode {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outcome
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PersonaDetailOutcome {
|
||||
/// Normal handled event.
|
||||
@@ -107,10 +94,6 @@ pub enum PersonaDetailOutcome {
|
||||
EditInEditor { path: PathBuf },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I/O entry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PersonaIOEntry {
|
||||
pub name: String,
|
||||
@@ -119,10 +102,6 @@ pub struct PersonaIOEntry {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct PersonaDetailState {
|
||||
pub window: ModalWindowState,
|
||||
pub name: String,
|
||||
@@ -148,7 +127,6 @@ pub struct PersonaDetailState {
|
||||
}
|
||||
|
||||
impl PersonaDetailState {
|
||||
/// Load persona state from a TOML file on disk.
|
||||
pub fn from_toml_file(path: &Path, editable: bool, scope_label: &str) -> Option<Self> {
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let table: toml::Value = toml::from_str(&content).ok()?;
|
||||
@@ -287,7 +265,6 @@ impl PersonaDetailState {
|
||||
.parse()
|
||||
.map_err(|e| format!("Failed to parse TOML: {e}"))?;
|
||||
|
||||
// Update simple string fields.
|
||||
let fields: &[(&str, &str)] = &[
|
||||
("name", &self.name),
|
||||
("description", &self.description),
|
||||
@@ -310,11 +287,6 @@ impl PersonaDetailState {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the persona detail modal.
|
||||
pub fn render_persona_detail(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
@@ -342,9 +314,8 @@ pub fn render_persona_detail(
|
||||
let w = content_area.width as usize;
|
||||
let mut y = content_area.y;
|
||||
let max_y = content_area.y + content_area.height;
|
||||
let label_w = 14u16; // column width for field labels
|
||||
let label_w = 14u16;
|
||||
|
||||
// Message line
|
||||
if let Some(ref msg) = state.message
|
||||
&& y < max_y
|
||||
{
|
||||
@@ -357,7 +328,6 @@ pub fn render_persona_detail(
|
||||
y += 2;
|
||||
}
|
||||
|
||||
// Render each field row.
|
||||
for &field in PersonaField::ALL {
|
||||
if y >= max_y {
|
||||
break;
|
||||
@@ -367,14 +337,12 @@ pub fn render_persona_detail(
|
||||
let label = field.label();
|
||||
let value = state.field_value(field);
|
||||
|
||||
// Background highlight for selected row.
|
||||
let row_bg = if is_selected {
|
||||
Some(theme.bg_highlight)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Label
|
||||
let label_style = if is_selected {
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
@@ -383,7 +351,6 @@ pub fn render_persona_detail(
|
||||
Style::default().fg(theme.gray)
|
||||
};
|
||||
if let Some(bg) = row_bg {
|
||||
// Fill the row background.
|
||||
let blank: String = " ".repeat(w);
|
||||
buf.set_string(content_area.x, y, &blank, Style::default().bg(bg));
|
||||
}
|
||||
@@ -392,13 +359,11 @@ pub fn render_persona_detail(
|
||||
let value_x = content_area.x + label_w;
|
||||
let value_w = w.saturating_sub(label_w as usize);
|
||||
|
||||
// Check if we're in editing mode for this field.
|
||||
if is_selected
|
||||
&& let PersonaDetailMode::Editing {
|
||||
ref buffer, cursor, ..
|
||||
} = state.mode
|
||||
{
|
||||
// Render inline editor.
|
||||
let display: String = buffer.chars().take(value_w).collect();
|
||||
let field_style = if let Some(bg) = row_bg {
|
||||
Style::default().fg(theme.text_primary).bg(bg)
|
||||
@@ -406,7 +371,6 @@ pub fn render_persona_detail(
|
||||
Style::default().fg(theme.text_primary)
|
||||
};
|
||||
buf.set_string(value_x, y, &display, field_style);
|
||||
// Cursor
|
||||
let cursor_x = value_x + buffer[..cursor.min(buffer.len())].width() as u16;
|
||||
if cursor_x < content_area.x + content_area.width
|
||||
&& let Some(cell) = buf.cell_mut((cursor_x, y))
|
||||
@@ -431,7 +395,7 @@ pub fn render_persona_detail(
|
||||
// Reserve 1 line for the hint at the bottom.
|
||||
let avail_lines = (max_y.saturating_sub(y)) as usize;
|
||||
let viewport_h = if is_long {
|
||||
avail_lines.saturating_sub(1) // room for hint
|
||||
avail_lines.saturating_sub(1)
|
||||
} else {
|
||||
avail_lines
|
||||
};
|
||||
@@ -481,7 +445,6 @@ pub fn render_persona_detail(
|
||||
buf.set_string(x_pos, y + i as u16, line, val_style);
|
||||
}
|
||||
y += visible.len().saturating_sub(1) as u16;
|
||||
// Hint line.
|
||||
y += 1;
|
||||
if y < max_y {
|
||||
let pos_hint = if total > viewport_h {
|
||||
@@ -512,7 +475,6 @@ pub fn render_persona_detail(
|
||||
};
|
||||
buf.set_string(value_x, y, "\u{2014}", empty_style);
|
||||
} else if value.width() <= value_w {
|
||||
// Fits on one line.
|
||||
let val_style = if let Some(bg) = row_bg {
|
||||
Style::default().fg(theme.text_primary).bg(bg)
|
||||
} else {
|
||||
@@ -520,7 +482,6 @@ pub fn render_persona_detail(
|
||||
};
|
||||
buf.set_string(value_x, y, value, val_style);
|
||||
} else {
|
||||
// Word-wrap long values.
|
||||
let val_style = if let Some(bg) = row_bg {
|
||||
Style::default().fg(theme.text_primary).bg(bg)
|
||||
} else {
|
||||
@@ -541,10 +502,10 @@ pub fn render_persona_detail(
|
||||
y += lines.len().saturating_sub(1) as u16;
|
||||
}
|
||||
|
||||
y += 2; // spacing between fields
|
||||
// Blank row between fields.
|
||||
y += 2;
|
||||
}
|
||||
|
||||
// I/O sections
|
||||
for (section, items) in [("Inputs", &state.inputs), ("Outputs", &state.outputs)] {
|
||||
if items.is_empty() || y >= max_y {
|
||||
continue;
|
||||
@@ -573,7 +534,6 @@ pub fn render_persona_detail(
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
if !entry.description.is_empty() {
|
||||
// Wrap the description across multiple lines below the header.
|
||||
let indent = 4usize;
|
||||
let desc_w = w.saturating_sub(indent);
|
||||
if desc_w > 0 {
|
||||
@@ -601,7 +561,6 @@ pub fn render_persona_detail(
|
||||
y += 1;
|
||||
}
|
||||
|
||||
// Source path
|
||||
if y < max_y
|
||||
&& let Some(ref path) = state.source_path
|
||||
{
|
||||
@@ -672,10 +631,6 @@ fn build_shortcuts(state: &PersonaDetailState) -> Vec<Shortcut<'static>> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn handle_persona_detail_key(
|
||||
state: &mut PersonaDetailState,
|
||||
key: &KeyEvent,
|
||||
@@ -775,12 +730,10 @@ fn handle_editing_key(state: &mut PersonaDetailState, key: &KeyEvent) -> Persona
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
// Cancel — restore original.
|
||||
state.mode = PersonaDetailMode::Browse;
|
||||
PersonaDetailOutcome::Changed
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Save the edit.
|
||||
let new_value = buffer.clone();
|
||||
let changed = new_value != *original;
|
||||
state.set_field_value(field, new_value);
|
||||
@@ -864,10 +817,6 @@ pub fn handle_persona_detail_mouse(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn word_wrap_lines(text: &str, max_width: usize) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
for raw_line in text.lines() {
|
||||
|
||||
@@ -44,10 +44,6 @@ fn picker_base_bg(bg: Option<Color>, theme: &Theme) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single entry in a picker list — either a section header or a selectable row.
|
||||
pub enum PickerEntry<'a> {
|
||||
/// Non-selectable section header (rendered as `── label ──`).
|
||||
@@ -147,7 +143,6 @@ pub fn render_picker_frame(
|
||||
match mode {
|
||||
PickerMode::Floating => render_floating_frame(buf, area, theme, close_hovered),
|
||||
PickerMode::Popup(popup_cfg) => {
|
||||
// Use the popup frame (configurable dimensions), then wrap in PickerFrame.
|
||||
let inner = render_popup_frame(buf, area, theme, &popup_cfg)?;
|
||||
// In Popup mode, the close button is rendered later (by tab bar or search bar),
|
||||
// so return a default close_button rect that will be overwritten.
|
||||
@@ -160,10 +155,6 @@ pub fn render_picker_frame(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scroll computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute minimal scroll offset to keep `selected` visible in a window of `visible` items
|
||||
/// out of `total`.
|
||||
pub fn compute_scroll_offset(
|
||||
@@ -184,9 +175,6 @@ pub fn compute_scroll_offset(
|
||||
centered.min(max_scroll)
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render a search bar row: ` search: {query}_` or ` / to search` hint.
|
||||
///
|
||||
@@ -337,10 +325,6 @@ pub fn render_search_bar_with_label(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Divider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render a horizontal `─` divider.
|
||||
pub fn render_divider(
|
||||
buf: &mut Buffer,
|
||||
@@ -370,10 +354,6 @@ pub fn render_divider(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab bar (shared)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Hit areas returned by [`render_tab_bar`].
|
||||
pub struct TabBarHitAreas {
|
||||
/// One rect per tab label; `None` if the tab didn't fit.
|
||||
@@ -419,10 +399,11 @@ pub fn render_tab_bar(
|
||||
|
||||
// Tab labels.
|
||||
let mut tab_rects = Vec::with_capacity(labels.len());
|
||||
let mut cx = x + 1; // 1 char left padding
|
||||
let mut cx = x + 1;
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
let label_w = label.width() as u16;
|
||||
let tab_w = label_w + 2; // " Label "
|
||||
// " Label "
|
||||
let tab_w = label_w + 2;
|
||||
|
||||
if cx + tab_w > close_rect.x.saturating_sub(1) {
|
||||
tab_rects.push(None);
|
||||
@@ -443,7 +424,8 @@ pub fn render_tab_bar(
|
||||
buf.set_span(cx + 1 + label_w, y, &Span::styled(" ", pad), 1);
|
||||
|
||||
tab_rects.push(Some(Rect::new(cx, y, tab_w, 1)));
|
||||
cx += tab_w + 2; // 2 chars gap between tabs
|
||||
// 2 chars gap between tabs
|
||||
cx += tab_w + 2;
|
||||
}
|
||||
|
||||
TabBarHitAreas {
|
||||
@@ -452,10 +434,6 @@ pub fn render_tab_bar(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Popup frame (shared)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Configuration for a centered popup frame.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PopupConfig {
|
||||
@@ -537,10 +515,6 @@ pub fn render_popup_frame(
|
||||
Some(inner)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search bar filter indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render a right-aligned filter indicator on a search bar row.
|
||||
///
|
||||
/// Draws e.g. `Enabled f` at the right edge. Used by plugin/hooks modal.
|
||||
@@ -562,7 +536,8 @@ pub fn render_filter_indicator(
|
||||
) -> Rect {
|
||||
let label_w = label.width() as u16;
|
||||
let hint_w = key_hint.width() as u16;
|
||||
let total_w = label_w + 1 + hint_w + 1; // "Label k "
|
||||
// "Label k "
|
||||
let total_w = label_w + 1 + hint_w + 1;
|
||||
let start_x = x + width.saturating_sub(total_w + 1);
|
||||
|
||||
let label_fg = if hovered {
|
||||
@@ -597,18 +572,6 @@ pub fn render_filter_indicator(
|
||||
Rect::new(start_x, y, total_w, 1)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Picker rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render a single picker row with unified visual style:
|
||||
/// - Selected: `\u{276f} label` in `text_primary+BOLD`, `bg_visual` background.
|
||||
/// - Normal: ` label` in `gray_bright`.
|
||||
/// - Right text: right-aligned in `gray_dim` (or `gray+bg_visual` when selected).
|
||||
///
|
||||
/// Compute the exact number of visual rows a picker row will consume
|
||||
/// at the given width. Used for scroll offset calculation.
|
||||
/// Return the byte offset in `s` that covers at most `max_width` display columns.
|
||||
/// Parse `[bracket]` highlight markers in a string into styled spans.
|
||||
/// Text inside `[...]` gets `highlight_style`, the rest gets `base_style`.
|
||||
/// Brackets are stripped from the output.
|
||||
@@ -662,6 +625,7 @@ fn render_styled_spans(buf: &mut Buffer, spans: &Line<'_>, x: u16, y: u16, max_w
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the byte offset in `s` that covers at most `max_width` display columns.
|
||||
fn byte_offset_for_width(s: &str, max_width: usize) -> usize {
|
||||
let mut w = 0usize;
|
||||
for (i, ch) in s.char_indices() {
|
||||
@@ -686,8 +650,11 @@ fn description_visual_rows(desc: &str, max_w: usize) -> usize {
|
||||
/// Left indent (columns) for picker row description and detail lines.
|
||||
const DESC_INDENT: u16 = 4;
|
||||
|
||||
/// Compute the exact number of visual rows a picker row will consume
|
||||
/// at the given width. Used for scroll offset calculation.
|
||||
pub fn compute_row_height(row: &PickerRow<'_>, width: u16) -> usize {
|
||||
let mut rows = 1usize; // main label line
|
||||
// main label line
|
||||
let mut rows = 1usize;
|
||||
let indent = DESC_INDENT;
|
||||
let max_w = width.saturating_sub(indent) as usize;
|
||||
if !row.expanded {
|
||||
@@ -706,7 +673,8 @@ pub fn compute_row_height(row: &PickerRow<'_>, width: u16) -> usize {
|
||||
rows += description_visual_rows(desc, max_w);
|
||||
}
|
||||
// Expanded field values (word-wrapped).
|
||||
let label_col = 13usize; // "{:<12} "
|
||||
// "{:<12} "
|
||||
let label_col = 13usize;
|
||||
let val_w = max_w.saturating_sub(label_col).max(1);
|
||||
for field in row.fields {
|
||||
let val_len = field.value.width();
|
||||
@@ -718,15 +686,20 @@ pub fn compute_row_height(row: &PickerRow<'_>, width: u16) -> usize {
|
||||
}
|
||||
rows
|
||||
}
|
||||
///
|
||||
/// When `row.expanded && !row.fields.is_empty()`, renders key-value detail lines
|
||||
/// below (indented, label in `gray`, value in `gray_bright`).
|
||||
///
|
||||
|
||||
/// Rows consumed by a rendered picker row/entry.
|
||||
pub struct RenderedRow {
|
||||
pub rows: u16,
|
||||
}
|
||||
|
||||
/// Render a single picker row with unified visual style:
|
||||
/// - Selected: `\u{276f} label` in `text_primary+BOLD`, `bg_visual` background.
|
||||
/// - Normal: ` label` in `gray_bright`.
|
||||
/// - Right text: right-aligned in `gray_dim` (or `gray+bg_visual` when selected).
|
||||
///
|
||||
/// When `row.expanded && !row.fields.is_empty()`, renders key-value detail lines
|
||||
/// below (indented, label in `gray`, value in `gray_bright`).
|
||||
///
|
||||
/// `max_rows` caps rendering to available vertical space; detail fields beyond
|
||||
/// that limit are not drawn.
|
||||
///
|
||||
@@ -765,7 +738,7 @@ pub fn render_picker_row(
|
||||
};
|
||||
buf.set_style(row_rect, Style::default().bg(row_bg));
|
||||
|
||||
// Left side: indent + cursor indicator + fold indicator + label.
|
||||
// Left side: indent + fold indicator + label.
|
||||
let indent_str = if row.indent > 0 {
|
||||
" ".repeat(row.indent as usize)
|
||||
} else {
|
||||
@@ -777,7 +750,8 @@ pub fn render_picker_row(
|
||||
// (no ❯ cursor glyph), matching the import-claude modal's style.
|
||||
let is_expandable =
|
||||
row.collapsible || !row.fields.is_empty() || !row.description_lines.is_empty();
|
||||
let fold_width: u16 = 2; // "› " or "◆ "
|
||||
// "› " or "◆ "
|
||||
let fold_width: u16 = 2;
|
||||
let label_style = if row.selected {
|
||||
Style::default()
|
||||
.fg(embed.map_or(theme.text_primary, |e| e.fg(theme.text_primary)))
|
||||
@@ -790,12 +764,14 @@ pub fn render_picker_row(
|
||||
};
|
||||
|
||||
let prefix_width = indent_str.width() as u16 + fold_width;
|
||||
let trailing_pad = 1u16; // space before border/scrollbar
|
||||
// space before border/scrollbar
|
||||
let trailing_pad = 1u16;
|
||||
// +1 space before badge
|
||||
let badge_width = if row.badge.is_empty() {
|
||||
0u16
|
||||
} else {
|
||||
row.badge.width() as u16 + 1
|
||||
}; // +1 space before badge
|
||||
};
|
||||
let right_width = row.right_label.width() as u16;
|
||||
let gap = if right_width > 0 { 2u16 } else { 0 };
|
||||
let max_label_width = width
|
||||
@@ -833,8 +809,10 @@ pub fn render_picker_row(
|
||||
buf,
|
||||
cur_x,
|
||||
y,
|
||||
!row.expanded, // collapsed = !expanded
|
||||
false, // picker rows don't track fold hover
|
||||
// collapsed = !expanded
|
||||
!row.expanded,
|
||||
// picker rows don't track fold hover
|
||||
false,
|
||||
Some(row_bg),
|
||||
theme,
|
||||
);
|
||||
@@ -1027,10 +1005,6 @@ pub fn render_picker_entry(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Close button
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render a `[\u{2717}]` close button right-aligned at `(x..x+width, y)`.
|
||||
///
|
||||
/// Returns the `Rect` for mouse hit-testing.
|
||||
@@ -1065,10 +1039,6 @@ pub fn render_close_button(
|
||||
Rect::new(bx, y, w, 1)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floating frame
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the floating popup frame: dim background, rounded border, close button.
|
||||
///
|
||||
/// Returns `None` if the area is too small to render anything.
|
||||
@@ -1084,14 +1054,14 @@ pub fn render_floating_frame(
|
||||
return None;
|
||||
}
|
||||
|
||||
// Dim background.
|
||||
crate::views::file_search::line_viewer::dim_area(buf, area, theme.bg_base, 0.5);
|
||||
|
||||
// Compute popup area (65% width, fixed height for 20 entries).
|
||||
let popup_w = ((area.width as f32 * 0.65) as u16).max(44).min(area.width);
|
||||
let popup_h = (4 + 20).min(area.height.saturating_sub(2));
|
||||
let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2;
|
||||
let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 3; // bias upward
|
||||
// bias upward
|
||||
let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 3;
|
||||
let popup_area = Rect::new(popup_x, popup_y, popup_w, popup_h);
|
||||
|
||||
// Clear and draw bordered popup.
|
||||
@@ -1126,10 +1096,6 @@ pub fn render_floating_frame(
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bordered frame primitive
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Layout returned by [`render_bordered_frame`].
|
||||
pub struct BorderedFrame {
|
||||
/// Title row area (between top border and separator). Caller fills this.
|
||||
@@ -1168,7 +1134,6 @@ pub fn render_bordered_frame(
|
||||
let base_style = Style::default().fg(border_color).bg(bg);
|
||||
let border_style = Style::default().fg(border_color).bg(bg);
|
||||
|
||||
// Clear area.
|
||||
Clear.render(area, buf);
|
||||
buf.set_style(area, Style::default().bg(bg));
|
||||
|
||||
@@ -1221,10 +1186,6 @@ pub fn render_bordered_frame(
|
||||
Some(BorderedFrame { title_row, content })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full-screen frame
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render a full-screen bordered picker panel using [`render_bordered_frame`].
|
||||
///
|
||||
/// Fills the title row with optional title text and a close button.
|
||||
@@ -1290,10 +1251,6 @@ pub fn render_fullscreen_frame(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified picker: state, config, outcome, render, input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Persistent picker state -- callers own this and pass `&mut` to input.
|
||||
///
|
||||
/// Fields used by both the `render_picker()` path (welcome screen) and
|
||||
@@ -1748,7 +1705,6 @@ fn render_picker_content_inner(
|
||||
loading: bool,
|
||||
scrollbar_x_override: Option<u16>,
|
||||
) -> PickerContentHitAreas {
|
||||
// Cleared each paint; set below if a row underlines its last description line.
|
||||
let is_clickable_non_sel = |i: usize| non_selectable_clickable.get(i).copied().unwrap_or(false);
|
||||
let empty_hit = PickerContentHitAreas {
|
||||
item_rects: vec![],
|
||||
@@ -2006,9 +1962,8 @@ pub fn render_picker(
|
||||
raw_content
|
||||
};
|
||||
|
||||
// ── Tab bar (optional) ──
|
||||
// When tabs are configured, render a tab bar on the first row of the content
|
||||
// area and advance the content origin downward.
|
||||
// Tab bar (optional): when tabs are configured, render a tab bar on the
|
||||
// first row of the content area and advance the content origin downward.
|
||||
let mut close_button = frame.close_button;
|
||||
let mut tab_rects_out: Vec<Option<Rect>> = vec![];
|
||||
let content = if let Some(tabs) = config.tabs {
|
||||
@@ -2025,12 +1980,14 @@ pub fn render_picker(
|
||||
close_button = tab_hit.close_button;
|
||||
tab_rects_out = tab_hit.tab_rects;
|
||||
// Advance content below tab bar + divider.
|
||||
let tab_rows = 1u16; // tab bar
|
||||
// tab bar
|
||||
let tab_rows = 1u16;
|
||||
let div_y = content.y + tab_rows;
|
||||
if div_y < content.y + content.height {
|
||||
render_divider(buf, content.x, div_y, content.width, theme, bg);
|
||||
}
|
||||
let used = tab_rows + 1; // tab bar + divider
|
||||
// tab bar + divider
|
||||
let used = tab_rows + 1;
|
||||
Rect {
|
||||
y: content.y + used,
|
||||
height: content.height.saturating_sub(used),
|
||||
@@ -2050,7 +2007,8 @@ pub fn render_picker(
|
||||
// Search bar width: floating/popup mode subtracts space for close button in same row.
|
||||
// When tabs are present, close button is in the tab bar row, not the search row.
|
||||
let search_width = if config.tabs.is_some() {
|
||||
content.width // close button already in tab bar
|
||||
// close button already in tab bar
|
||||
content.width
|
||||
} else {
|
||||
match state.mode {
|
||||
PickerMode::Floating | PickerMode::Popup(_) => content.width.saturating_sub(4),
|
||||
@@ -2118,7 +2076,9 @@ pub fn render_picker(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Cursor tracks focus (`search_active`) for every picker — like the Settings pane; `show_search_hint` is input-only and no longer forces an always-on cursor.
|
||||
// Cursor visibility tracks `search_active` for every picker, like the
|
||||
// Settings pane; `show_search_hint` only controls whether the
|
||||
// "/ to search" placeholder is shown, not the cursor.
|
||||
render_search_bar(
|
||||
buf,
|
||||
content.x,
|
||||
@@ -2133,7 +2093,7 @@ pub fn render_picker(
|
||||
);
|
||||
}
|
||||
|
||||
// ── Filter indicator (optional) ──
|
||||
// Filter indicator (optional).
|
||||
let filter_rect_out = if let Some(filter_label) = config.filter_label {
|
||||
let key_hint = config.filter_key_hint.unwrap_or("f");
|
||||
let rect = render_filter_indicator(
|
||||
@@ -2281,7 +2241,6 @@ pub fn handle_picker_input(
|
||||
// Clamp selected to valid range — entries may have changed since last input
|
||||
// (e.g., query filter reduced the list).
|
||||
if entry_count > 0 {
|
||||
// Clamp selected into valid range first — entries may have shrunk.
|
||||
state.selected = state.selected.min(entry_count.saturating_sub(1));
|
||||
// Skip non-selectable items (e.g., section headers)
|
||||
while is_non_sel(state.selected) && state.selected < entry_count - 1 {
|
||||
@@ -2318,7 +2277,7 @@ pub fn handle_picker_input(
|
||||
if is_non_sel(s) { entry_count - 1 } else { s }
|
||||
};
|
||||
|
||||
// ── Mouse handling (hit area based) ──
|
||||
// Mouse handling (hit area based).
|
||||
if let Event::Mouse(mouse) = ev
|
||||
&& let Some(ref hit) = state.hit_areas
|
||||
{
|
||||
@@ -2362,7 +2321,8 @@ pub fn handle_picker_input(
|
||||
return PickerOutcome::Selected(entry_idx);
|
||||
}
|
||||
}
|
||||
return PickerOutcome::Changed; // consume click
|
||||
// consume click
|
||||
return PickerOutcome::Changed;
|
||||
}
|
||||
MouseEventKind::Moved => {
|
||||
let mut changed = false;
|
||||
@@ -2461,7 +2421,7 @@ pub fn handle_picker_input(
|
||||
PickerOutcome::Changed
|
||||
}
|
||||
|
||||
// ── Key handling ──
|
||||
// Key handling.
|
||||
if let Event::Key(key) = ev {
|
||||
if key.kind == KeyEventKind::Release {
|
||||
return PickerOutcome::Unchanged;
|
||||
@@ -2478,7 +2438,7 @@ pub fn handle_picker_input(
|
||||
return PickerOutcome::Unchanged;
|
||||
}
|
||||
|
||||
// ── Left/Right cursor movement (only when search input is focused) ──
|
||||
// Left/Right cursor movement (only when search input is focused).
|
||||
let search_input_active =
|
||||
!config.disable_search && (state.search_active || !config.show_search_hint);
|
||||
if search_input_active && !state.query.is_empty() {
|
||||
@@ -2728,7 +2688,8 @@ pub fn handle_picker_input(
|
||||
state.scroll_offset = None;
|
||||
state.selected = 0;
|
||||
state.selection_hidden = false;
|
||||
state.expanded.clear(); // back to default collapsed when search ends
|
||||
// back to default collapsed when search ends
|
||||
state.expanded.clear();
|
||||
state.tabs_focused = false;
|
||||
return PickerOutcome::Changed;
|
||||
}
|
||||
@@ -2834,7 +2795,7 @@ pub fn handle_picker_input(
|
||||
return PickerOutcome::Changed;
|
||||
}
|
||||
|
||||
// ── Custom action keys (checked first — override built-in expand/copy) ──
|
||||
// Custom action keys (checked first — override built-in expand/copy).
|
||||
// Only when not in search mode.
|
||||
if !state.search_active {
|
||||
for &(action_char, _) in config.action_keys {
|
||||
@@ -2874,8 +2835,7 @@ pub fn handle_picker_input(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab switching ──
|
||||
// Tab/Shift-Tab (and BackTab) always cycle tabs when configured
|
||||
// Tab switching. Tab/Shift-Tab (and BackTab) always cycle tabs when configured
|
||||
// (and not in search).
|
||||
//
|
||||
// When the tab bar region has been focused via Up/Down arrows
|
||||
@@ -2917,8 +2877,7 @@ pub fn handle_picker_input(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filter cycling ──
|
||||
// 'f' key (not in search mode) toggles the filter.
|
||||
// Filter cycling: 'f' key (not in search mode) toggles the filter.
|
||||
if config.filter_label.is_some()
|
||||
&& !state.search_active
|
||||
&& key.code == KeyCode::Char('f')
|
||||
@@ -3037,10 +2996,11 @@ pub fn handle_picker_input(
|
||||
}
|
||||
}
|
||||
|
||||
return PickerOutcome::Unchanged; // unhandled key — no state change
|
||||
// unhandled key — no state change
|
||||
return PickerOutcome::Unchanged;
|
||||
}
|
||||
|
||||
// ── Paste ──
|
||||
// Paste.
|
||||
if let Event::Paste(text) = ev {
|
||||
return handle_paste(state, text, config);
|
||||
}
|
||||
@@ -3329,7 +3289,8 @@ mod tests {
|
||||
fn vim_k_at_top_clamps_without_opening_search() {
|
||||
for hint in [true, false] {
|
||||
let config = cfg(hint, true);
|
||||
let mut state = PickerState::default(); // selected = 0 (top)
|
||||
// selected = 0 (top)
|
||||
let mut state = PickerState::default();
|
||||
let outcome = handle_picker_input(&press('k'), &mut state, 3, &config);
|
||||
assert_eq!(state.selected, 0, "hint={hint}");
|
||||
assert!(!state.search_active, "hint={hint}");
|
||||
|
||||
@@ -404,7 +404,6 @@ mod tests {
|
||||
plan_approval_status_label(false),
|
||||
"No plan written — approve or request changes"
|
||||
);
|
||||
// Placeholder must be non-empty so the line viewer accepts it.
|
||||
assert!(!EMPTY_PLAN_PLACEHOLDER.trim().is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -138,10 +138,9 @@ mod tests {
|
||||
fn test_partial_block() {
|
||||
let area = Rect::new(0, 0, 10, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
// 25% of 4 cells = 1 full block (8 eighths). Actually 0.25*4*8 = 8 = 1 full.
|
||||
// Let's use 12.5% of 4 cells = 0.125*4*8 = 4 eighths = half block on cell 0
|
||||
// 0.125 * 4 * 8 = 4 eighths = half block on cell 0.
|
||||
render_progress_bar(&mut buf, 0, 0, 4, 0.125, Color::White, Color::Black);
|
||||
assert_eq!(buf[(0, 0)].symbol(), "▌"); // 4/8 = half
|
||||
assert_eq!(buf[(0, 0)].symbol(), "▌");
|
||||
assert_eq!(buf[(1, 0)].symbol(), " ");
|
||||
}
|
||||
|
||||
@@ -164,7 +163,8 @@ mod tests {
|
||||
// The two glyph tables must share the same index domain so call
|
||||
// sites can swap them without branching on the host.
|
||||
assert_eq!(BLOCKS.len(), SHADES.len());
|
||||
assert_eq!(BLOCKS[0], SHADES[0]); // both empty
|
||||
assert_eq!(BLOCKS[8], SHADES[8]); // both full block
|
||||
// Index 0 is empty, index 8 is the full block, in both tables.
|
||||
assert_eq!(BLOCKS[0], SHADES[0]);
|
||||
assert_eq!(BLOCKS[8], SHADES[8]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,7 +252,8 @@ impl PromptStyle {
|
||||
|
||||
/// Info block height: rows reserved for the bottom divider line.
|
||||
pub fn info_block(&self, has_info: bool) -> u16 {
|
||||
if has_info { 1 } else { 0 } // bottom divider only
|
||||
// Bottom divider only.
|
||||
if has_info { 1 } else { 0 }
|
||||
}
|
||||
|
||||
/// Mode-tinted accent: the override (e.g. plan mode) when set,
|
||||
@@ -420,8 +421,8 @@ pub struct PromptWidget {
|
||||
/// Last input delta for the flight recorder (read by AgentView after handle_key).
|
||||
pub(crate) last_input_delta: crate::input_log::LastInputDelta,
|
||||
/// Live preview state for slash commands that support it.
|
||||
/// Stores the display text of the previously-active value so we can
|
||||
/// revert on Esc. `None` = no preview in progress.
|
||||
/// Stores the display text of the value active before the preview began,
|
||||
/// so Esc can revert to it. `None` = no preview in progress.
|
||||
pub(crate) slash_preview_original: Option<String>,
|
||||
|
||||
/// Shell command suggestion controller (ghost text + progressive matching).
|
||||
@@ -434,7 +435,6 @@ pub struct PromptWidget {
|
||||
/// bash/remember/feedback input modes, or while editing a queued prompt.
|
||||
pub(crate) prompt_suggestion_active: bool,
|
||||
|
||||
// -- Image paste state ---------------------------------------------------
|
||||
/// Images attached to the current prompt.
|
||||
pub images: Vec<PastedImage>,
|
||||
/// Images removed during undo that can be restored on redo.
|
||||
@@ -600,8 +600,6 @@ impl PromptWidget {
|
||||
self.suggestions.clear_ghost();
|
||||
}
|
||||
|
||||
// -- Predicted-next-prompt suggestion (tab autocomplete) -----------------
|
||||
|
||||
/// The prompt-suggestion ghost to render for the current text, if the
|
||||
/// per-frame gate is open and no other completion UI owns the row.
|
||||
/// Requires the cursor at end-of-text so the ghost visually continues
|
||||
@@ -651,8 +649,6 @@ impl PromptWidget {
|
||||
self.suggestions.try_progressive_match(new_text)
|
||||
}
|
||||
|
||||
// -- Completion dropdown ------------------------------------------------
|
||||
|
||||
/// Whether the completion dropdown is currently open.
|
||||
pub fn completion_dropdown_open(&self) -> bool {
|
||||
self.suggestions.dropdown.open
|
||||
@@ -931,8 +927,6 @@ impl PromptWidget {
|
||||
self.update_file_search_context();
|
||||
}
|
||||
|
||||
// -- Slash command state sync -------------------------------------------
|
||||
|
||||
/// Refresh the slash snapshot from current text + cursor.
|
||||
///
|
||||
/// Called by `AgentView` after every `PromptEvent::Edited`.
|
||||
@@ -1176,8 +1170,6 @@ impl PromptWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Slash preview -------------------------------------------------------
|
||||
|
||||
/// Trigger live preview for the currently selected slash arg suggestion.
|
||||
///
|
||||
/// Called after `slash_move_selection` when the dropdown is in the args
|
||||
@@ -1239,8 +1231,6 @@ impl PromptWidget {
|
||||
self.slash_preview_original = None;
|
||||
}
|
||||
|
||||
// -- File search --------------------------------------------------------
|
||||
|
||||
/// Poll the file search daemon for new results. Returns `true` if changed.
|
||||
pub fn poll_file_search(&mut self) -> bool {
|
||||
self.file_search.poll()
|
||||
@@ -1496,7 +1486,6 @@ impl PromptWidget {
|
||||
// Reset flight recorder delta (overwritten if key reaches textarea).
|
||||
self.last_input_delta = crate::input_log::LastInputDelta::default();
|
||||
|
||||
// ── File search key handling (when dropdown is visible) ─────────
|
||||
if self.file_search.is_visible() {
|
||||
match self.handle_file_search_key(key) {
|
||||
FileSearchKeyResult::Handled => return PromptEvent::Edited,
|
||||
@@ -1529,7 +1518,6 @@ impl PromptWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ctrl-L / : on element → open line viewer ────────────────────
|
||||
// Ctrl-L when cursor is on or adjacent to a file ref element,
|
||||
// or ':' typed right at element boundary → open viewer.
|
||||
if key!('l', CONTROL).matches(key)
|
||||
@@ -1554,8 +1542,6 @@ impl PromptWidget {
|
||||
}
|
||||
// Not at element boundary — fall through to type ':' normally.
|
||||
|
||||
// ── Normal key handling ─────────────────────────────────────────
|
||||
|
||||
// Newline: Shift-Enter or Alt-Enter
|
||||
if key!(Enter, SHIFT).matches(key) || key!(Enter, ALT).matches(key) {
|
||||
self.textarea.insert_str("\n");
|
||||
@@ -1650,8 +1636,7 @@ impl PromptWidget {
|
||||
return PromptEvent::Edited;
|
||||
}
|
||||
|
||||
// Everything else: delegate to textarea.
|
||||
// Track whether it actually changed anything.
|
||||
// Delegate to textarea, tracking whether it actually changed anything.
|
||||
let old_text = self.textarea.text().to_owned();
|
||||
let old_cursor = self.textarea.cursor();
|
||||
let old_has_selection = self.textarea.selection_range().is_some();
|
||||
@@ -1699,34 +1684,27 @@ impl PromptWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ── File search key dispatch ────────────────────────────────────────
|
||||
|
||||
/// Handle navigation/selection keys when the file search dropdown is visible.
|
||||
fn handle_file_search_key(&mut self, key: &KeyEvent) -> FileSearchKeyResult {
|
||||
if key!(Up).matches(key)
|
||||
|| key!('p', CONTROL).matches(key)
|
||||
|| key!('k', CONTROL).matches(key)
|
||||
{
|
||||
// Navigation: up.
|
||||
self.file_search.move_selection(-1);
|
||||
FileSearchKeyResult::Handled
|
||||
} else if key!(Down).matches(key)
|
||||
|| key!('n', CONTROL).matches(key)
|
||||
|| key!('j', CONTROL).matches(key)
|
||||
{
|
||||
// Navigation: down.
|
||||
self.file_search.move_selection(1);
|
||||
FileSearchKeyResult::Handled
|
||||
} else if key!(PageUp).matches(key) || key!('u', CONTROL).matches(key) {
|
||||
// Page up.
|
||||
self.file_search.page_move(-1, 8);
|
||||
FileSearchKeyResult::Handled
|
||||
} else if key!(PageDown).matches(key) || key!('d', CONTROL).matches(key) {
|
||||
// Page down.
|
||||
self.file_search.page_move(1, 8);
|
||||
FileSearchKeyResult::Handled
|
||||
} else if key!(Tab).matches(key) || key!(Enter).matches(key) {
|
||||
// Accept.
|
||||
if file_search_has_selection(&self.file_search) {
|
||||
FileSearchKeyResult::Accepted
|
||||
} else {
|
||||
@@ -1768,10 +1746,8 @@ impl PromptWidget {
|
||||
FileSearchKeyResult::PassThrough
|
||||
}
|
||||
} else if key!(Esc).matches(key) {
|
||||
// Dismiss.
|
||||
FileSearchKeyResult::Dismissed
|
||||
} else {
|
||||
// Everything else: pass through to normal handling.
|
||||
FileSearchKeyResult::PassThrough
|
||||
}
|
||||
}
|
||||
@@ -2169,8 +2145,6 @@ impl PromptWidget {
|
||||
PromptEvent::Edited
|
||||
}
|
||||
|
||||
// ── Image chip support ──────────────────────────────────────────
|
||||
|
||||
/// Maximum number of image chips allowed in a single prompt (v1).
|
||||
pub const IMAGE_CAP: usize = 10;
|
||||
|
||||
@@ -2347,7 +2321,6 @@ impl PromptWidget {
|
||||
new_stash.sort_by_key(|img| img.display_number);
|
||||
let evict_count = new_stash.len() - stash_cap;
|
||||
for img in new_stash.drain(..evict_count) {
|
||||
// stash eviction
|
||||
crate::prompt_images::cleanup_temp_file(&img);
|
||||
}
|
||||
}
|
||||
@@ -2811,11 +2784,11 @@ impl PromptWidget {
|
||||
for x in area.x..area.x + area.width {
|
||||
if let Some(cell) = buf.cell_mut((x, div_y)) {
|
||||
let ch = if x == left_x {
|
||||
'\u{256d}' // ╭
|
||||
'\u{256d}'
|
||||
} else if x == right_x {
|
||||
'\u{256e}' // ╮
|
||||
'\u{256e}'
|
||||
} else {
|
||||
'\u{2500}' // ─
|
||||
'\u{2500}'
|
||||
};
|
||||
cell.set_char(ch);
|
||||
cell.set_style(div_style);
|
||||
@@ -2871,7 +2844,6 @@ impl PromptWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// TextArea content
|
||||
let ta_area = Rect {
|
||||
x: text_area_rect.x + prefix_w,
|
||||
y: text_area_rect.y,
|
||||
@@ -2984,7 +2956,6 @@ impl PromptWidget {
|
||||
(snap.active, snap.inline_ghost.is_some())
|
||||
};
|
||||
|
||||
// Placeholder text when empty and unfocused
|
||||
if self.textarea.text().is_empty() && ta_area.width > 0 && !style.focused {
|
||||
let placeholder = style.placeholder_override.unwrap_or("Build anything");
|
||||
buf.set_string(
|
||||
@@ -3002,11 +2973,11 @@ impl PromptWidget {
|
||||
let right_x = area.x + area.width.saturating_sub(1);
|
||||
for y in text_area_rect.y..text_area_rect.y + text_area_rect.height {
|
||||
if let Some(cell) = buf.cell_mut((left_x, y)) {
|
||||
cell.set_char('\u{2502}'); // │
|
||||
cell.set_char('\u{2502}');
|
||||
cell.set_style(div_style);
|
||||
}
|
||||
if let Some(cell) = buf.cell_mut((right_x, y)) {
|
||||
cell.set_char('\u{2502}'); // │
|
||||
cell.set_char('\u{2502}');
|
||||
cell.set_style(div_style);
|
||||
}
|
||||
}
|
||||
@@ -3024,11 +2995,11 @@ impl PromptWidget {
|
||||
for x in area.x..area.x + area.width {
|
||||
if let Some(cell) = buf.cell_mut((x, div_y)) {
|
||||
let ch = if x == left_x {
|
||||
'\u{2570}' // ╰
|
||||
'\u{2570}'
|
||||
} else if x == right_x {
|
||||
'\u{256f}' // ╯
|
||||
'\u{256f}'
|
||||
} else {
|
||||
'\u{2500}' // ─
|
||||
'\u{2500}'
|
||||
};
|
||||
cell.set_char(ch);
|
||||
cell.set_style(div_style);
|
||||
@@ -3235,8 +3206,8 @@ impl PromptWidget {
|
||||
let right_w = right_line.width() as u16;
|
||||
let left_line = Line::from(left_spans);
|
||||
let left_w = (left_line.width() as u16).min(area.width.saturating_sub(right_w + 1));
|
||||
// Right-align both parts: [left][gap][right]
|
||||
let total_w = left_w + 1 + right_w; // 1 for gap
|
||||
// Right-align both parts: [left][gap][right]. +1 accounts for the gap.
|
||||
let total_w = left_w + 1 + right_w;
|
||||
let x = area.x + area.width.saturating_sub(total_w);
|
||||
buf.set_line_safe(x, area.y, &left_line, left_w);
|
||||
let rx = area.x + area.width.saturating_sub(right_w);
|
||||
@@ -3273,8 +3244,6 @@ fn parse_line_range(s: &str) -> Option<std::ops::Range<usize>> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Element display helpers ────────────────────────────────────────────
|
||||
|
||||
/// Build the styled display `Line` for a file reference element.
|
||||
///
|
||||
/// Renders as: `@foo/bar.rs` or `@foo/bar.rs:10-12`
|
||||
|
||||
@@ -432,7 +432,8 @@
|
||||
#[test]
|
||||
fn cmd_a_is_noop_when_gate_is_disabled() {
|
||||
let mut pw = PromptWidget::new();
|
||||
pw.cmd_a_select_all_enabled = false; // simulate non-Ghostty
|
||||
// simulate non-Ghostty
|
||||
pw.cmd_a_select_all_enabled = false;
|
||||
pw.textarea.insert_str("hello world");
|
||||
let cursor_before = pw.textarea.cursor();
|
||||
|
||||
@@ -493,7 +494,8 @@
|
||||
chrome: false,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(pw.desired_height(80, &style, true, 20), 3); // top_divider(1)+text(1)+bot_divider(1)
|
||||
// top_divider(1)+text(1)+bot_divider(1)
|
||||
assert_eq!(pw.desired_height(80, &style, true, 20), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -503,7 +505,8 @@
|
||||
chrome: false,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(pw.desired_height(80, &style, false, 20), 2); // vpad(1)+text(1)
|
||||
// vpad(1)+text(1)
|
||||
assert_eq!(pw.desired_height(80, &style, false, 20), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -514,7 +517,8 @@
|
||||
chrome: false,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(pw.desired_height(80, &style, true, 20), 5); // top_divider(1)+text(3)+bot_divider(1)
|
||||
// top_divider(1)+text(3)+bot_divider(1)
|
||||
assert_eq!(pw.desired_height(80, &style, true, 20), 5);
|
||||
}
|
||||
|
||||
/// While history BROWSE mode is active the composer height is frozen at
|
||||
@@ -533,10 +537,11 @@
|
||||
}],
|
||||
"",
|
||||
);
|
||||
pw.set_text("line1\nline2\nline3"); // populated multi-line entry
|
||||
pw.set_text("line1\nline2\nline3");
|
||||
// frozen: top_divider(1)+text(1)+bot_divider(1)
|
||||
assert_eq!(
|
||||
pw.desired_height(80, &style, true, 20),
|
||||
3, // frozen: top_divider(1)+text(1)+bot_divider(1)
|
||||
3,
|
||||
);
|
||||
|
||||
// Detach (deactivate) → the box resizes to fit the text.
|
||||
@@ -563,7 +568,8 @@
|
||||
chrome: false,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(pw.desired_height(80, &style, true, 20), 2); // text(1)+bot_divider(1)
|
||||
// text(1)+bot_divider(1)
|
||||
assert_eq!(pw.desired_height(80, &style, true, 20), 2);
|
||||
}
|
||||
|
||||
/// Regression test: inline prompt `desired_height` must use the narrower
|
||||
@@ -575,7 +581,8 @@
|
||||
fn desired_height_inline_prompt_uses_render_width() {
|
||||
let mut pw = PromptWidget::new();
|
||||
// Insert text that fits on one line at width 80 but wraps at 69.
|
||||
let text: String = "a ".repeat(36); // 72 chars
|
||||
// 72 chars
|
||||
let text: String = "a ".repeat(36);
|
||||
pw.textarea.insert_str(&text);
|
||||
|
||||
let inline_style = PromptStyle::inline(ratatui::style::Color::Reset);
|
||||
@@ -620,7 +627,7 @@
|
||||
let mut pw = PromptWidget::new();
|
||||
pw.handle_key(&key!('a').to_key_event());
|
||||
pw.handle_key(&key!('b').to_key_event());
|
||||
pw.handle_key(&key!('z', CONTROL).to_key_event()); // undo
|
||||
pw.handle_key(&key!('z', CONTROL).to_key_event());
|
||||
let before = pw.textarea.text().to_string();
|
||||
assert_eq!(
|
||||
pw.handle_key(&key!('r', CONTROL).to_key_event()),
|
||||
@@ -633,7 +640,7 @@
|
||||
fn ctrl_shift_z_redoes() {
|
||||
let mut pw = PromptWidget::new();
|
||||
pw.handle_key(&key!('x').to_key_event());
|
||||
pw.handle_key(&key!('z', CONTROL).to_key_event()); // undo
|
||||
pw.handle_key(&key!('z', CONTROL).to_key_event());
|
||||
let before = pw.textarea.text().to_string();
|
||||
assert_eq!(
|
||||
pw.handle_key(&key!('z', CONTROL | SHIFT).to_key_event()),
|
||||
@@ -707,21 +714,20 @@
|
||||
#[test]
|
||||
fn can_send_basic() {
|
||||
let mut pw = PromptWidget::new();
|
||||
assert!(!pw.can_send()); // empty
|
||||
assert!(!pw.can_send());
|
||||
pw.textarea.insert_str("hello");
|
||||
assert!(pw.can_send());
|
||||
pw.textarea.set_text(" ");
|
||||
assert!(!pw.can_send()); // whitespace only
|
||||
assert!(!pw.can_send());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_send_backslash() {
|
||||
let mut pw = PromptWidget::new();
|
||||
pw.textarea.insert_str("hello\\");
|
||||
assert!(!pw.can_send()); // trailing backslash
|
||||
assert!(!pw.can_send());
|
||||
}
|
||||
|
||||
// ── Paste element tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn paste_single_line_inline() {
|
||||
@@ -802,7 +808,8 @@
|
||||
fn paste_large_single_line_chip_shows_size_not_lines() {
|
||||
// A byte-triggered chip shows a size label, not a misleading "1 line".
|
||||
let mut pw = PromptWidget::new();
|
||||
let text = "x".repeat(12 * 1024); // 12 KB, single line
|
||||
// 12 KB, single line
|
||||
let text = "x".repeat(12 * 1024);
|
||||
assert_eq!(pw.handle_paste(&text), PromptEvent::Edited);
|
||||
let elems = pw.textarea.elements();
|
||||
assert_eq!(elems.len(), 1);
|
||||
@@ -840,7 +847,8 @@
|
||||
// byte size. A large paste should read as its size regardless of how
|
||||
// many lines it has.
|
||||
let mut pw = PromptWidget::new();
|
||||
let text = "lorem ipsum dolor\n".repeat(2000); // ~36 KB across 2000 lines
|
||||
// ~36 KB across 2000 lines
|
||||
let text = "lorem ipsum dolor\n".repeat(2000);
|
||||
assert!(text.len() > PASTE_CHIP_DISPLAY_BYTES && text.lines().count() >= 4);
|
||||
assert_eq!(pw.handle_paste(&text), PromptEvent::Edited);
|
||||
let elems = pw.textarea.elements();
|
||||
@@ -949,7 +957,6 @@
|
||||
assert_eq!(pw.paste_element_for_preview(), None);
|
||||
}
|
||||
|
||||
// ── Image preview activation (paste-chip parity) ─────────────────
|
||||
|
||||
#[test]
|
||||
fn image_for_preview_shows_right_after_insert() {
|
||||
@@ -1397,7 +1404,6 @@
|
||||
assert_eq!(&raw[mapped..mapped + 2], "/x");
|
||||
}
|
||||
|
||||
// -- PromptStyle prefix_override tests --
|
||||
|
||||
#[test]
|
||||
fn prompt_style_default_has_no_prefix_override() {
|
||||
@@ -1412,7 +1418,6 @@
|
||||
}
|
||||
|
||||
|
||||
// ── Slash state integration tests ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn refresh_slash_produces_snapshot_for_slash_input() {
|
||||
@@ -1512,7 +1517,6 @@
|
||||
);
|
||||
}
|
||||
|
||||
// ── Slash completion acceptance tests ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn accept_completion_inserts_alias_for_alias() {
|
||||
@@ -1729,7 +1733,6 @@
|
||||
);
|
||||
}
|
||||
|
||||
// ── Regression tests ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sync_acp_then_refresh_ordering() {
|
||||
@@ -1796,7 +1799,6 @@
|
||||
assert!(!snap.open);
|
||||
}
|
||||
|
||||
// ── CR normalization tests ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn paste_bare_cr_becomes_lf() {
|
||||
@@ -1835,7 +1837,6 @@
|
||||
assert_eq!(pw.textarea.text(), "no carriage returns\nhere");
|
||||
}
|
||||
|
||||
// ── Paste chip threshold boundary tests ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn paste_3_lines_inline_normal_mode() {
|
||||
@@ -1883,7 +1884,6 @@
|
||||
assert!(pw.textarea.elements().is_empty());
|
||||
}
|
||||
|
||||
// ── normalize_cr tests ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn normalize_cr_bare_cr() {
|
||||
@@ -1905,7 +1905,6 @@
|
||||
assert_eq!(normalize_cr("no cr\nhere"), "no cr\nhere");
|
||||
}
|
||||
|
||||
// ── Inline paste (handle_paste without element) ──────────────
|
||||
|
||||
#[test]
|
||||
fn inline_paste_multiline_no_element() {
|
||||
@@ -1936,12 +1935,11 @@
|
||||
assert!(!snap.open, "cleared text should close dropdown");
|
||||
}
|
||||
|
||||
// ── Image chip tests ──────────────────────────────────────────
|
||||
|
||||
/// Helper: create a minimal `PastedImage` for testing.
|
||||
fn test_image() -> PastedImage {
|
||||
PastedImage {
|
||||
element_id: kigi_ratatui_textarea::ElementId::from_raw(0), // overwritten by insert_image
|
||||
// overwritten by insert_image
|
||||
element_id: kigi_ratatui_textarea::ElementId::from_raw(0),
|
||||
display_number: 0,
|
||||
mime_type: "image/png".into(),
|
||||
dimensions: Some((100, 80)),
|
||||
@@ -2223,8 +2221,8 @@
|
||||
#[test]
|
||||
fn three_drops_in_same_prompt_yield_sequential_numbers() {
|
||||
let mut pw = PromptWidget::new();
|
||||
pw.insert_image(test_image()).unwrap(); // #1
|
||||
pw.insert_image(test_image()).unwrap(); // #2
|
||||
pw.insert_image(test_image()).unwrap();
|
||||
pw.insert_image(test_image()).unwrap();
|
||||
|
||||
// Transient delete of `[Image #2]` mid-prompt.
|
||||
let id2 = pw.textarea.elements()[1].id;
|
||||
@@ -2233,7 +2231,8 @@
|
||||
pw.textarea.inline_element(id2);
|
||||
pw.sync_images_with_textarea();
|
||||
|
||||
pw.insert_image(test_image()).unwrap(); // MUST be #3
|
||||
// MUST be #3
|
||||
pw.insert_image(test_image()).unwrap();
|
||||
|
||||
let numbers: Vec<usize> = pw.images.iter().map(|i| i.display_number).collect();
|
||||
assert_eq!(
|
||||
@@ -2264,8 +2263,8 @@
|
||||
#[test]
|
||||
fn sync_handles_two_images_sharing_display_number() {
|
||||
let mut pw = PromptWidget::new();
|
||||
pw.insert_image(test_image()).unwrap(); // #1
|
||||
pw.insert_image(test_image()).unwrap(); // #2
|
||||
pw.insert_image(test_image()).unwrap();
|
||||
pw.insert_image(test_image()).unwrap();
|
||||
assert_eq!(pw.images.len(), 2);
|
||||
assert_ne!(
|
||||
pw.images[0].element_id, pw.images[1].element_id,
|
||||
@@ -2294,7 +2293,6 @@
|
||||
);
|
||||
}
|
||||
|
||||
// ── set_images: identity-based pairing ───────────────────────────
|
||||
|
||||
/// Two restored chips with identical placeholder byte length must
|
||||
/// get distinct `element_id`s after `set_images`. A naive
|
||||
@@ -2614,7 +2612,6 @@
|
||||
);
|
||||
}
|
||||
|
||||
// ── parse_image_display_number ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_image_display_number_bracketed_form() {
|
||||
@@ -2681,7 +2678,8 @@
|
||||
let mut pw = PromptWidget::new();
|
||||
let mut img1 = test_image();
|
||||
img1.source_path = Some(foo_path.clone());
|
||||
img1.display_number = 0; // overwritten by insert_image
|
||||
// overwritten by insert_image
|
||||
img1.display_number = 0;
|
||||
pw.insert_image(img1).unwrap();
|
||||
|
||||
let mut img2 = test_image();
|
||||
@@ -2882,7 +2880,8 @@
|
||||
// observes a peak >= FIRE_PEAK_LEN and its last_len matches the
|
||||
// on-screen length — the precondition under which a shrink fires.
|
||||
pw.handle_key(&key!('@').to_key_event());
|
||||
type_chars(&mut pw, 24); // "@" + 24 = 25 chars
|
||||
// "@" + 24 = 25 chars
|
||||
type_chars(&mut pw, 24);
|
||||
assert!(!pw.take_undo_tip_fire(), "typing must not fire");
|
||||
|
||||
// Force the dropdown visible with a SHORT file result so accepting
|
||||
@@ -3140,7 +3139,6 @@
|
||||
assert!(!snap.matches.is_empty());
|
||||
}
|
||||
|
||||
// ── T8: lifecycle edge-case tests ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_clears_image_state() {
|
||||
@@ -3319,7 +3317,6 @@
|
||||
assert_eq!(pw.images[0].display_number, 2);
|
||||
}
|
||||
|
||||
// ── File search Right Arrow (drill-down) ────────────────────────────
|
||||
|
||||
/// Build a `FuzzyMatchResult` for use in test fixtures.
|
||||
fn fuzzy_result(path: &str, is_dir: bool) -> kigi_workspace::file_system::FuzzyMatchResult {
|
||||
@@ -3533,7 +3530,8 @@
|
||||
// from a space-free parent, so no residual anchor masks it. Without the
|
||||
// Tab `set_drill_prefix`, the space terminates and the `.expect` panics.
|
||||
let mut pw = PromptWidget::new();
|
||||
seed_at_completion(&mut pw, "src/", "src/sub dir", true); // dir mode, no prior anchor
|
||||
// dir mode, no prior anchor
|
||||
seed_at_completion(&mut pw, "src/", "src/sub dir", true);
|
||||
assert!(pw.file_search.is_dir_mode());
|
||||
|
||||
pw.handle_key(&key!(Tab).to_key_event());
|
||||
@@ -3552,7 +3550,8 @@
|
||||
// `@my dir` would re-detect as a context.
|
||||
let mut pw = PromptWidget::new();
|
||||
seed_at_completion(&mut pw, "my", "my dir", true);
|
||||
pw.handle_key(&key!(Right).to_key_event()); // → "@my dir", anchor "my dir"
|
||||
// → "@my dir", anchor "my dir"
|
||||
pw.handle_key(&key!(Right).to_key_event());
|
||||
assert!(pw.file_search.context().is_some());
|
||||
|
||||
pw.handle_key(&key!(Esc).to_key_event());
|
||||
@@ -3571,7 +3570,8 @@
|
||||
// The `(Some, None)` leaving-@-mode arm must drop the anchor with the context.
|
||||
let mut pw = PromptWidget::new();
|
||||
seed_at_completion(&mut pw, "my", "my dir", true);
|
||||
pw.handle_key(&key!(Right).to_key_event()); // → "@my dir", anchor "my dir"
|
||||
// → "@my dir", anchor "my dir"
|
||||
pw.handle_key(&key!(Right).to_key_event());
|
||||
assert!(pw.file_search.context().is_some());
|
||||
|
||||
// Cursor before `@` → leaving @-mode.
|
||||
@@ -3596,7 +3596,8 @@
|
||||
// typing, instead of silently re-matching the stale anchor.
|
||||
let mut pw = PromptWidget::new();
|
||||
seed_at_completion(&mut pw, "my", "my dir", true);
|
||||
pw.handle_key(&key!(Right).to_key_event()); // → "@my dir", anchor "my dir"
|
||||
// → "@my dir", anchor "my dir"
|
||||
pw.handle_key(&key!(Right).to_key_event());
|
||||
assert_eq!(pw.textarea.text(), "@my dir");
|
||||
assert!(pw.file_search.context().is_some());
|
||||
|
||||
@@ -3623,7 +3624,8 @@
|
||||
// Files now show under a `path/` query; Tab/Enter on a file must
|
||||
// reference it as an atomic element, not append `/` to descend into it.
|
||||
let mut pw = PromptWidget::new();
|
||||
seed_at_completion(&mut pw, "src/", "src/main.rs", false); // file in dir-mode
|
||||
// file in dir-mode
|
||||
seed_at_completion(&mut pw, "src/", "src/main.rs", false);
|
||||
assert!(pw.file_search.is_dir_mode());
|
||||
|
||||
pw.handle_key(&key!(Tab).to_key_event());
|
||||
@@ -3737,7 +3739,6 @@
|
||||
assert_eq!(pw.textarea.cursor(), "@README.md ".len());
|
||||
}
|
||||
|
||||
// ── Ghost text tests ────────────────────────────────────────────
|
||||
|
||||
/// Chromeless prompt style for rendering tests (no borders, no prefix,
|
||||
/// no vpad — textarea starts at area origin).
|
||||
@@ -3978,7 +3979,6 @@
|
||||
assert_eq!(buf_text_at(&buf, 5, 10, 0).trim(), "");
|
||||
}
|
||||
|
||||
// --- paint_slash_token_highlight (wrap-aware token painting) ---
|
||||
|
||||
/// Sentinel highlight color — never produced by the textarea's own render.
|
||||
const TOKEN_FG: ratatui::style::Color = ratatui::style::Color::Rgb(9, 99, 199);
|
||||
@@ -4103,7 +4103,6 @@
|
||||
assert!(!pw.has_ghost_text());
|
||||
}
|
||||
|
||||
// -- Ghost acceptance through PromptWidget --------------------------------
|
||||
|
||||
#[test]
|
||||
fn accept_ghost_full_appends_to_textarea() {
|
||||
@@ -4160,7 +4159,6 @@
|
||||
assert!(!pw.has_ghost_text());
|
||||
}
|
||||
|
||||
// -- completion accept / splice application ---------------------------------
|
||||
|
||||
/// Wire-shaped token item: whole-line `insert_text`, `token_text` span
|
||||
/// replacement (what a range-emitting shell sends).
|
||||
@@ -4255,7 +4253,6 @@
|
||||
assert_eq!(pw.text(), "echo something else");
|
||||
}
|
||||
|
||||
// -- apply_completion_fill ---------------------------------------------
|
||||
|
||||
/// The widget-level fill writes the decided LCP over the typed token and
|
||||
/// parks the cursor after it (the decision matrix lives in
|
||||
@@ -4300,7 +4297,6 @@
|
||||
}
|
||||
|
||||
|
||||
// -- Predicted-next-prompt suggestion through PromptWidget ----------------
|
||||
|
||||
/// Widget with an active gate and a loaded suggestion — the state right
|
||||
/// after a turn ends with `kigi/suggestPrompt` resolved.
|
||||
@@ -4405,7 +4401,7 @@
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let area = Rect::new(0, 0, 40, 3); // 3 rows tall
|
||||
let area = Rect::new(0, 0, 40, 3);
|
||||
let mut buf = Buffer::empty(area);
|
||||
pw.draw(&mut buf, area, None, &ghost_test_style(), None);
|
||||
|
||||
@@ -4465,7 +4461,6 @@
|
||||
assert!(!pw.has_ghost_text());
|
||||
}
|
||||
|
||||
// ── Inline title on the top border ──────────────────────────────
|
||||
|
||||
/// Bordered chrome style (the agent-view prompt shape) with an optional
|
||||
/// session title.
|
||||
|
||||
@@ -45,8 +45,6 @@ fn hovered_bg(theme: &Theme) -> ratatui::style::Color {
|
||||
theme.bg_hover
|
||||
}
|
||||
|
||||
// ── Enums ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Per-question selection state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum QuestionSelection {
|
||||
@@ -114,8 +112,6 @@ pub enum LocalQuestionKind {
|
||||
},
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Complete state for the question view overlay.
|
||||
///
|
||||
/// Created when an `kigi/ask_user_question` ext-method request arrives;
|
||||
@@ -126,13 +122,11 @@ pub enum LocalQuestionKind {
|
||||
pub struct QuestionViewState {
|
||||
/// The tool call ID of the `AskUserQuestion` invocation.
|
||||
pub tool_call_id: String,
|
||||
/// The questions to present.
|
||||
pub questions: Vec<Question>,
|
||||
/// Which question is currently shown (0-based index).
|
||||
pub active_tab: usize,
|
||||
/// Per-question selection state (same length as `questions`).
|
||||
pub selections: Vec<QuestionSelection>,
|
||||
/// Current focus mode.
|
||||
pub focus: QuestionFocus,
|
||||
/// Whether fullscreen mode is active (removes height cap).
|
||||
pub fullscreen: bool,
|
||||
@@ -151,13 +145,13 @@ pub struct QuestionViewState {
|
||||
/// text. Independent of the text content — text is preserved on untoggle.
|
||||
pub per_question_freeform_selected: Vec<bool>,
|
||||
|
||||
// ── Cached chrome caps (recomputed on resize / question switch) ──
|
||||
// Cached chrome caps, recomputed on resize / question switch.
|
||||
/// Cached cap on description lines in chrome (capped in non-fullscreen).
|
||||
pub cached_desc_cap: u16,
|
||||
/// Cached cap on preview lines in chrome (capped in non-fullscreen).
|
||||
pub cached_preview_cap: u16,
|
||||
|
||||
// ── ACP response channel (TS-04) ──
|
||||
// ACP response channel (TS-04).
|
||||
/// Stashed ACP response sender. When the user submits/cancels, the
|
||||
/// pager serializes the response and sends it here. `take()` ensures
|
||||
/// we never send twice.
|
||||
@@ -187,8 +181,6 @@ pub struct QuestionViewState {
|
||||
pub no_freeform: bool,
|
||||
}
|
||||
|
||||
// ── Constructor & basic helpers ────────────────────────────────────────
|
||||
|
||||
impl QuestionViewState {
|
||||
/// Create a new question view state.
|
||||
///
|
||||
@@ -335,12 +327,10 @@ impl QuestionViewState {
|
||||
|
||||
let mut new_scroll = scroll;
|
||||
|
||||
// If cursor is above the visible window, scroll up.
|
||||
if cursor_top < new_scroll {
|
||||
new_scroll = cursor_top;
|
||||
}
|
||||
|
||||
// If cursor is below the visible window, scroll down.
|
||||
if cursor_bottom > new_scroll + visible_h {
|
||||
new_scroll = cursor_bottom.saturating_sub(visible_h);
|
||||
}
|
||||
@@ -527,8 +517,6 @@ pub fn item_index_at_screen_row(
|
||||
))
|
||||
}
|
||||
|
||||
// ── Layout helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/// Compute the aligned label column width.
|
||||
///
|
||||
/// The column fits the longest label, capped at 60% of the available
|
||||
@@ -671,8 +659,6 @@ fn split_question_label_desc(text: &str) -> (&str, &str) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selection helpers ──────────────────────────────────────────────────
|
||||
|
||||
impl QuestionViewState {
|
||||
/// Toggle an option for a question.
|
||||
///
|
||||
@@ -798,8 +784,7 @@ impl QuestionViewState {
|
||||
}
|
||||
}
|
||||
|
||||
// ── ACP response builders (TS-05) ─────────────────────────────────────
|
||||
|
||||
// ACP response builders (TS-05).
|
||||
impl QuestionViewState {
|
||||
/// Build the `Accepted` ext-method response from the current state.
|
||||
///
|
||||
@@ -911,8 +896,6 @@ impl QuestionViewState {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab cycling ────────────────────────────────────────────────────────
|
||||
|
||||
impl QuestionViewState {
|
||||
/// Advance to the next question (clamped, no wrap).
|
||||
pub fn next_question(&mut self) {
|
||||
@@ -927,8 +910,6 @@ impl QuestionViewState {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Desired height for the question view overlay.
|
||||
///
|
||||
/// Height cap: 33% of `screen_h`, clamped to min 8, max 80%.
|
||||
@@ -997,7 +978,8 @@ pub fn question_view_height(state: &mut QuestionViewState, screen_h: u16, conten
|
||||
let label_lines = crate::render::wrapping::word_wrap_line(&raw_line, content_w.max(1))
|
||||
.len()
|
||||
.max(1) as u16;
|
||||
let fixed_overhead = 1 + label_lines + 1 + 1; // vpad + label + blank + bottom gap
|
||||
// vpad + label + blank + bottom gap
|
||||
let fixed_overhead = 1 + label_lines + 1 + 1;
|
||||
|
||||
// Compute actual description line count so unused desc budget can
|
||||
// be reallocated to preview instead of being wasted.
|
||||
@@ -1088,7 +1070,8 @@ pub const QUESTION_VIEW_HPAD: u16 = 5;
|
||||
/// Multi: `X [✓] ` = 1 + 1 + 3 + 1 = 6
|
||||
/// Single: `X (●) ` = 1 + 1 + 3 + 1 = 6
|
||||
pub fn option_prefix_w(_question: &Question) -> usize {
|
||||
6 // both multi and single use 3-char markers now
|
||||
// Both multi and single use 3-char markers.
|
||||
6
|
||||
}
|
||||
|
||||
/// Width available for inline prompt text given the full area width.
|
||||
@@ -1098,9 +1081,12 @@ pub fn option_prefix_w(_question: &Question) -> usize {
|
||||
/// Matches the `text_w` computed during rendering so `desired_height`
|
||||
/// wraps at the same width as the draw area.
|
||||
pub fn inline_text_width(area_width: u16) -> u16 {
|
||||
const LEFT_PAD: u16 = 3; // accent column + 2 padding
|
||||
const OPTION_PREFIX_W: u16 = 6; // shortcut + marker ("z [x] ")
|
||||
const PROMPT_INDICATOR_W: u16 = 2; // "❯ "
|
||||
// accent column + 2 padding
|
||||
const LEFT_PAD: u16 = 3;
|
||||
// shortcut + marker ("z [x] ")
|
||||
const OPTION_PREFIX_W: u16 = 6;
|
||||
// "❯ "
|
||||
const PROMPT_INDICATOR_W: u16 = 2;
|
||||
area_width.saturating_sub(LEFT_PAD + OPTION_PREFIX_W + PROMPT_INDICATOR_W)
|
||||
}
|
||||
|
||||
@@ -1365,7 +1351,6 @@ fn build_single_option_lines(
|
||||
Modifier::empty()
|
||||
});
|
||||
|
||||
// Build prefix spans (number + marker/checkbox)
|
||||
let prefix_spans: Vec<Span<'static>> = if is_multi {
|
||||
let (checkbox, cb_style) = if is_selected {
|
||||
(
|
||||
@@ -1389,7 +1374,8 @@ fn build_single_option_lines(
|
||||
// Single-select: radio buttons (●) / (○)
|
||||
let (radio, radio_style) = if is_selected {
|
||||
(
|
||||
format!("({})", crate::glyphs::filled_dot()), // (●) → (•) on legacy ConHost
|
||||
// (●) → (•) on legacy ConHost
|
||||
format!("({})", crate::glyphs::filled_dot()),
|
||||
Style::default()
|
||||
.fg(fg(theme.text_primary))
|
||||
.bg(row_bg)
|
||||
@@ -1397,7 +1383,7 @@ fn build_single_option_lines(
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"(\u{25cb})".to_string(), // (○)
|
||||
"(\u{25cb})".to_string(),
|
||||
Style::default().fg(fg(theme.gray)).bg(row_bg),
|
||||
)
|
||||
};
|
||||
@@ -1600,7 +1586,8 @@ pub fn render_question_view(
|
||||
let accent_style = Style::default().fg(theme.accent_user);
|
||||
for row in area.y..area.y + area.height {
|
||||
if let Some(cell) = buf.cell_mut((area.x, row)) {
|
||||
cell.set_symbol(crate::glyphs::accent_bar()); // ┃ → │ on legacy ConHost
|
||||
// ┃ → │ on legacy ConHost
|
||||
cell.set_symbol(crate::glyphs::accent_bar());
|
||||
cell.set_style(accent_style);
|
||||
}
|
||||
}
|
||||
@@ -1613,7 +1600,6 @@ pub fn render_question_view(
|
||||
// Vertical padding at the top.
|
||||
y += 1;
|
||||
|
||||
// ── Question chrome (label + counter + description) ──
|
||||
// Clip to the panel bottom: when the accounted height disagrees with the
|
||||
// rendered height (wrap-width drift, stale caps), the chrome must degrade
|
||||
// to truncation instead of writing past the area — set_line past the
|
||||
@@ -1632,12 +1618,10 @@ pub fn render_question_view(
|
||||
state.cached_preview_cap,
|
||||
);
|
||||
|
||||
// ── Gap ──
|
||||
y += 1;
|
||||
|
||||
let options_start_y = y;
|
||||
|
||||
// ── Option rows (scrollable) + sticky freeform row ──
|
||||
let visible_bottom = area.y + area.height;
|
||||
let scroll = state.per_question_scroll.get(q_idx).copied().unwrap_or(0) as usize;
|
||||
let cursor = state.cursor();
|
||||
@@ -1668,7 +1652,7 @@ pub fn render_question_view(
|
||||
hovered_item,
|
||||
&state.selections[q_idx],
|
||||
theme,
|
||||
false, // never in scroll list
|
||||
false,
|
||||
freeform_text,
|
||||
freeform_selected,
|
||||
focused,
|
||||
@@ -1690,7 +1674,6 @@ pub fn render_question_view(
|
||||
y += 1;
|
||||
}
|
||||
|
||||
// ── Sticky freeform row at the bottom ──
|
||||
if sticky_freeform {
|
||||
let freeform_y = visible_bottom.saturating_sub(1);
|
||||
if freeform_y >= y {
|
||||
@@ -1828,7 +1811,6 @@ fn render_question_chrome(
|
||||
// Split into label (first paragraph) and description (rest).
|
||||
let (label_text, desc_text) = split_question_label_desc(&question.question);
|
||||
|
||||
// ── Label (bold, primary text, word-wrapped) ──
|
||||
let label_style = Style::default()
|
||||
.fg(theme.text_primary)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
@@ -1846,7 +1828,6 @@ fn render_question_chrome(
|
||||
// Blank line after label.
|
||||
cur_y += 1;
|
||||
|
||||
// ── Description (dimmed, markdown-rendered) ──
|
||||
if !desc_text.is_empty() {
|
||||
let desc_lines = styled_description_lines(
|
||||
&QuestionOption {
|
||||
@@ -1887,7 +1868,6 @@ fn render_question_chrome(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preview for focused option (dimmed, word-wrapped) ──
|
||||
if let Some(preview_text) = state.focused_preview()
|
||||
&& !preview_text.is_empty()
|
||||
{
|
||||
@@ -1951,8 +1931,6 @@ fn render_question_chrome(
|
||||
cur_y
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2022,11 +2000,13 @@ mod tests {
|
||||
vec![gb3747_question()],
|
||||
StashedPrompt::default(),
|
||||
);
|
||||
let inner_width = w.saturating_sub(4); // hpad_left 2 + hpad_right 2
|
||||
// hpad_left 2 + hpad_right 2
|
||||
let inner_width = w.saturating_sub(4);
|
||||
// Pre-fix draw() bug: full inner width (no HPAD subtraction).
|
||||
let qv_h = question_view_height(&mut state, h, inner_width as usize);
|
||||
let question_footer_h: u16 = 3;
|
||||
let reserved = 1 + 5 + 1 + 3; // draw()'s overcommit clamp
|
||||
// draw()'s overcommit clamp
|
||||
let reserved = 1 + 5 + 1 + 3;
|
||||
let prompt_height = (qv_h + question_footer_h)
|
||||
.max(3)
|
||||
.min(h.saturating_sub(reserved));
|
||||
@@ -2101,8 +2081,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Regression: on the terminal-native palette (`bg_visual = Reset`) the
|
||||
/// embedded cursor row used to be indistinguishable except for a bold
|
||||
/// label.
|
||||
/// embedded cursor row must stay distinguishable by more than just a
|
||||
/// bold label.
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn embedded_cursor_row_takes_selection_accent() {
|
||||
@@ -2212,8 +2192,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── new() ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn new_initializes_vectors_correctly() {
|
||||
let q1 = make_question("Pick one?", &["A", "B", "C"], false);
|
||||
@@ -2254,8 +2232,6 @@ mod tests {
|
||||
assert!(state.per_question_scroll.iter().all(|&s| s == 0));
|
||||
}
|
||||
|
||||
// ── toggle_option ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn toggle_option_multi_toggles_in_out() {
|
||||
let q = make_question("Pick?", &["A", "B", "C"], true);
|
||||
@@ -2276,8 +2252,6 @@ mod tests {
|
||||
assert_eq!(state.selected_labels(0), vec!["A"]);
|
||||
}
|
||||
|
||||
// ── select_option ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn select_option_single_replaces_previous() {
|
||||
let q = make_question("Pick?", &["A", "B", "C"], false);
|
||||
@@ -2290,17 +2264,15 @@ mod tests {
|
||||
assert_eq!(state.selected_labels(0), vec!["C"]);
|
||||
}
|
||||
|
||||
// ── selected_labels ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn selected_labels_mixed_selections() {
|
||||
let q1 = make_question("Single?", &["X", "Y"], false);
|
||||
let q2 = make_question("Multi?", &["P", "Q", "R"], true);
|
||||
let mut state = QuestionViewState::new("tc".into(), vec![q1, q2], StashedPrompt::default());
|
||||
|
||||
state.select_option(0, 1); // Y
|
||||
state.toggle_option(1, 0); // P
|
||||
state.toggle_option(1, 2); // R
|
||||
state.select_option(0, 1);
|
||||
state.toggle_option(1, 0);
|
||||
state.toggle_option(1, 2);
|
||||
|
||||
assert_eq!(state.selected_labels(0), vec!["Y"]);
|
||||
let mut multi = state.selected_labels(1);
|
||||
@@ -2308,8 +2280,6 @@ mod tests {
|
||||
assert_eq!(multi, vec!["P", "R"]);
|
||||
}
|
||||
|
||||
// ── next_question / prev_question ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn question_cycling_clamps_at_boundaries() {
|
||||
let qs = vec![
|
||||
@@ -2325,18 +2295,16 @@ mod tests {
|
||||
state.next_question();
|
||||
assert_eq!(state.active_tab, 2);
|
||||
state.next_question();
|
||||
assert_eq!(state.active_tab, 2); // clamped at end
|
||||
assert_eq!(state.active_tab, 2);
|
||||
|
||||
state.prev_question();
|
||||
assert_eq!(state.active_tab, 1);
|
||||
state.prev_question();
|
||||
assert_eq!(state.active_tab, 0);
|
||||
state.prev_question();
|
||||
assert_eq!(state.active_tab, 0); // clamped at start
|
||||
assert_eq!(state.active_tab, 0);
|
||||
}
|
||||
|
||||
// ── compute_max_label_w ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn compute_max_label_w_caps_long_labels_at_60_percent() {
|
||||
let options = vec![
|
||||
@@ -2503,8 +2471,6 @@ mod tests {
|
||||
assert_eq!(heights as usize, lines.len());
|
||||
}
|
||||
|
||||
// ── is_on_freeform_row ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn is_on_freeform_row_returns_true_at_end() {
|
||||
let q = make_question("Pick?", &["A", "B"], false);
|
||||
@@ -2518,8 +2484,6 @@ mod tests {
|
||||
assert!(state.is_on_freeform_row());
|
||||
}
|
||||
|
||||
// ── cursor / set_cursor ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn set_cursor_clamps_to_valid_range() {
|
||||
let q = make_question("Pick?", &["A", "B"], false);
|
||||
@@ -2533,17 +2497,13 @@ mod tests {
|
||||
assert_eq!(state.cursor(), 0);
|
||||
}
|
||||
|
||||
// ── total_items ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn total_items_counts_options_plus_freeform() {
|
||||
let q = make_question("Pick?", &["A", "B", "C"], false);
|
||||
let state = QuestionViewState::new("tc".into(), vec![q], StashedPrompt::default());
|
||||
assert_eq!(state.total_items(0), 4); // 3 options + 1 freeform
|
||||
assert_eq!(state.total_items(0), 4);
|
||||
}
|
||||
|
||||
// ── no_freeform ────────────────────────────────────────────────────
|
||||
|
||||
/// `no_freeform` questions (e.g. the subscription upsell) have no "Other"
|
||||
/// row, so activating freeform input must be impossible: focus stays in
|
||||
/// Navigation and nothing gets marked selected. Regression test for the
|
||||
@@ -2595,8 +2555,6 @@ mod tests {
|
||||
assert_eq!(h_with, h_without + 1);
|
||||
}
|
||||
|
||||
// ── option_visual_height ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn option_visual_height_unfocused_always_1() {
|
||||
let opt = QuestionOption {
|
||||
@@ -2621,8 +2579,6 @@ mod tests {
|
||||
assert!(h >= 3, "expected >= 3, got {h}");
|
||||
}
|
||||
|
||||
// ── chrome_height ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn split_question_label_desc_no_break() {
|
||||
let (label, desc) = split_question_label_desc("Which database engine?");
|
||||
@@ -2664,8 +2620,8 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
let desc_part = "Choose the primary data store for the backend service.";
|
||||
// vpad(1) + label(1) + gap(1) + desc lines + gap(1)
|
||||
let desc_lines = desc_part.len().div_ceil(80).max(1) as u16; // 1 line at width 80
|
||||
// vpad(1) + label(1) + gap(1) + desc lines + gap(1); 1 line at width 80
|
||||
let desc_lines = desc_part.len().div_ceil(80).max(1) as u16;
|
||||
assert_eq!(
|
||||
chrome_height(
|
||||
&q,
|
||||
@@ -2862,8 +2818,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── focused_preview ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn focused_preview_returns_preview_when_on_option() {
|
||||
let q = Question {
|
||||
@@ -2899,8 +2853,6 @@ mod tests {
|
||||
assert_eq!(state.focused_preview(), None);
|
||||
}
|
||||
|
||||
// ── toggle on Single ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn toggle_option_single_deselects_when_same() {
|
||||
let q = make_question("Pick?", &["A", "B"], false);
|
||||
@@ -2937,7 +2889,8 @@ mod tests {
|
||||
};
|
||||
|
||||
let content_w = 20;
|
||||
let cursor = 0; // focus first option so it gets full height
|
||||
// Focus the first option so it gets full height.
|
||||
let cursor = 0;
|
||||
let heights = option_heights(&q, content_w, cursor);
|
||||
assert!(heights[0] > 1);
|
||||
|
||||
@@ -2966,8 +2919,6 @@ mod tests {
|
||||
assert_eq!(state.per_question_scroll[0], expected_max);
|
||||
}
|
||||
|
||||
// ── truncation cap tests ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn chrome_height_caps_long_description() {
|
||||
// 10-line description using CommonMark hard breaks (` \n`) so each
|
||||
@@ -3024,8 +2975,6 @@ mod tests {
|
||||
assert_eq!(h, 5);
|
||||
}
|
||||
|
||||
// ── question_view_height / minimum visible option rows ─────────────
|
||||
|
||||
/// Helper: build a QuestionViewState for height tests.
|
||||
fn make_state_for_height(
|
||||
question_text: &str,
|
||||
@@ -3116,8 +3065,6 @@ mod tests {
|
||||
assert_eq!(state.cached_preview_cap, u16::MAX);
|
||||
}
|
||||
|
||||
// ── focus-driven option height ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn unfocused_option_is_one_line_focused_is_full() {
|
||||
let opt = QuestionOption {
|
||||
@@ -3168,13 +3115,14 @@ mod tests {
|
||||
id: None,
|
||||
};
|
||||
let content_w = 30;
|
||||
let cursor = 1; // focus Beta
|
||||
// Focus Beta.
|
||||
let cursor = 1;
|
||||
let theme = Theme::default();
|
||||
let sel = QuestionSelection::Single(None);
|
||||
|
||||
// args: show freeform, freeform text, freeform selected, panel focused
|
||||
let lines = build_flat_option_lines(
|
||||
&q, content_w, cursor, None, &sel, &theme, true, // show freeform
|
||||
"", false, true, // panel focused
|
||||
&q, content_w, cursor, None, &sel, &theme, true, "", false, true,
|
||||
);
|
||||
let heights = option_heights(&q, content_w, cursor);
|
||||
let expected_total: u16 = heights.iter().sum();
|
||||
|
||||
@@ -31,10 +31,6 @@ pub(crate) fn visible_held_server_row(
|
||||
id != running_id && id != send_now_id && !painted_pending.contains_key(key)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QueuedPromptEntry — ListItem wrapper around QueuedPrompt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Where a rendered queue row originates, which determines how an edit
|
||||
/// (delete / reorder) is routed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -111,7 +107,6 @@ pub fn kind_from_wire(kind: &str) -> QueueEntryKind {
|
||||
impl QueuedPromptEntry {
|
||||
/// Create a new entry from a `QueuedPrompt` and its current position.
|
||||
pub fn new(prompt: &QueuedPrompt, position: usize) -> Self {
|
||||
// Show first non-empty line, trimmed.
|
||||
let first_line = prompt
|
||||
.text
|
||||
.lines()
|
||||
@@ -193,7 +188,6 @@ impl QueuedPromptEntry {
|
||||
let theme = Theme::current();
|
||||
let extra_lines = line_count.saturating_sub(1);
|
||||
|
||||
// Build the suffix for multiline prompts: " (+N lines)" or " (+1 line)"
|
||||
let suffix = if extra_lines > 0 {
|
||||
if extra_lines == 1 {
|
||||
" (+1 line)".to_string()
|
||||
@@ -205,8 +199,6 @@ impl QueuedPromptEntry {
|
||||
};
|
||||
let suffix_width = suffix.width();
|
||||
|
||||
// Determine how much space we have for the first line content.
|
||||
// Reserve space for the suffix if multiline.
|
||||
let content_max_width = max_width.map(|w| {
|
||||
if extra_lines > 0 {
|
||||
w.saturating_sub(suffix_width)
|
||||
@@ -238,8 +230,8 @@ impl QueuedPromptEntry {
|
||||
// Slash commands: `/command` in magenta, args (if any) in bright gray.
|
||||
let trimmed = first_line.trim();
|
||||
|
||||
// For commands, we need to be smarter about truncation.
|
||||
// Truncate the whole thing first, then split.
|
||||
// Truncate the whole thing first, then split into cmd/args,
|
||||
// so a truncated command keeps a coherent boundary.
|
||||
let truncated = if let Some(max_w) = content_max_width {
|
||||
truncate_str(trimmed, max_w)
|
||||
} else {
|
||||
@@ -332,10 +324,6 @@ impl ListItem for QueuedPromptEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QueuePane — self-contained pane owning entries, state, and rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -420,7 +408,8 @@ impl QueuePane {
|
||||
let config = ListPaneConfig {
|
||||
follow_enabled: false,
|
||||
wrap_toggle_enabled: false,
|
||||
search_enabled: false, // Queue is small, no search needed
|
||||
// Queue is small, no search needed.
|
||||
search_enabled: false,
|
||||
copy_enabled: true,
|
||||
show_selection_when_unfocused: false,
|
||||
visual_select_enabled: false,
|
||||
@@ -447,8 +436,6 @@ impl QueuePane {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Data management -----------------------------------------------------
|
||||
|
||||
/// Rebuild the queue rows from the **union** of the local drip-feed queue
|
||||
/// (`local`) and the server-authoritative shared queue (`server`), tagging
|
||||
/// each row with its origin so edits route correctly.
|
||||
@@ -556,8 +543,6 @@ impl QueuePane {
|
||||
(self.entries.len() as u16).clamp(1, MAX_QUEUE_HEIGHT)
|
||||
}
|
||||
|
||||
// -- Input handling ------------------------------------------------------
|
||||
|
||||
/// Handle a key event when the queue pane is focused.
|
||||
///
|
||||
/// Returns `Some(QueueEvent)` for queue-specific actions (delete, edit,
|
||||
@@ -738,7 +723,7 @@ impl QueuePane {
|
||||
}
|
||||
|
||||
/// Clear the `[Interject]` hover (mouse left the queue pane). Returns
|
||||
/// `true` if it was previously hovered (caller should redraw).
|
||||
/// `true` if a row was hovered before this call (caller should redraw).
|
||||
pub fn clear_send_now_hover(&mut self) -> bool {
|
||||
if self.hovered_send_now_id.is_some() {
|
||||
self.hovered_send_now_id = None;
|
||||
@@ -762,7 +747,7 @@ impl QueuePane {
|
||||
}
|
||||
|
||||
/// Clear the hovered row (mouse left the queue pane). Returns `true` if a
|
||||
/// row was previously hovered (caller should redraw).
|
||||
/// row was hovered before this call (caller should redraw).
|
||||
pub fn clear_row_hover(&mut self) -> bool {
|
||||
if self.hovered_row_id.is_some() {
|
||||
self.hovered_row_id = None;
|
||||
@@ -786,8 +771,6 @@ impl QueuePane {
|
||||
self.entries.get(idx).map(|e| e.id)
|
||||
}
|
||||
|
||||
// -- Rendering -----------------------------------------------------------
|
||||
|
||||
/// Compute the inner content area for the queue rows.
|
||||
///
|
||||
/// Indents one column less than scrollback content (which uses
|
||||
@@ -841,11 +824,11 @@ impl QueuePane {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rebuild styled content with proper width for truncation.
|
||||
// Account for prefix width: "#N " where N is the position (1-based).
|
||||
// Max position determines prefix width: #1-#9 = 3 chars, #10-#99 = 4 chars, etc.
|
||||
// Rebuild styled content with proper width for truncation. Reserve
|
||||
// prefix width for "#N ", which grows with the number of digits in
|
||||
// the highest position (#1-#9 = 3 chars, #10-#99 = 4 chars, etc.).
|
||||
let max_pos = self.entries.len();
|
||||
let prefix_width = 2 + digit_count(max_pos); // "#" + digits + " "
|
||||
let prefix_width = 2 + digit_count(max_pos);
|
||||
let content_width = (inner.width as usize).saturating_sub(prefix_width);
|
||||
for entry in &mut self.entries {
|
||||
entry.rebuild_styled_for_width(content_width as u16);
|
||||
@@ -987,10 +970,6 @@ impl QueuePane {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Count the number of decimal digits in a number.
|
||||
fn digit_count(n: usize) -> usize {
|
||||
if n == 0 {
|
||||
@@ -1000,10 +979,6 @@ fn digit_count(n: usize) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1316,7 +1291,8 @@ mod tests {
|
||||
let styled = QueuedPromptEntry::build_styled("first line", 2, QueueEntryKind::Prompt, None);
|
||||
let text: String = styled.spans.iter().map(|s| s.content.as_ref()).collect();
|
||||
assert!(text.contains("(+1 line)"));
|
||||
assert!(!text.contains("lines)")); // Should be singular
|
||||
// Should be singular.
|
||||
assert!(!text.contains("lines)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1345,7 +1321,8 @@ mod tests {
|
||||
"hello world",
|
||||
10,
|
||||
QueueEntryKind::Prompt,
|
||||
Some(15), // " (+9 lines)" is 11 chars, leaving 4 for content
|
||||
// " (+9 lines)" is 11 chars, leaving 4 for content.
|
||||
Some(15),
|
||||
);
|
||||
let text: String = styled.spans.iter().map(|s| s.content.as_ref()).collect();
|
||||
assert!(text.contains("(+9 lines)"));
|
||||
@@ -1359,8 +1336,6 @@ mod tests {
|
||||
assert!(text.contains("(+2 lines)"));
|
||||
}
|
||||
|
||||
// -- Bash command queue pane tests --
|
||||
|
||||
#[test]
|
||||
fn test_bash_command_has_bang_prefix() {
|
||||
let styled =
|
||||
@@ -1382,8 +1357,6 @@ mod tests {
|
||||
assert!(text.contains("(+2 lines)"));
|
||||
}
|
||||
|
||||
// -- Cron queue pane tests --
|
||||
|
||||
#[test]
|
||||
fn test_cron_has_recycle_prefix() {
|
||||
let styled = QueuedPromptEntry::build_styled("check status", 1, QueueEntryKind::Cron, None);
|
||||
@@ -1429,8 +1402,6 @@ mod tests {
|
||||
// Total should fit within width (prefix "! " is 2 chars, content truncated to 13)
|
||||
}
|
||||
|
||||
// -- Action-button rendering (hover + layout) ----------------------------
|
||||
|
||||
/// The `[Interject]` and `[cancel]` buttons render flush against each other
|
||||
/// so the queued message behind the row can't leak through a seam between
|
||||
/// them (no gap).
|
||||
|
||||
@@ -1076,9 +1076,6 @@ fn render_radio_row(
|
||||
return;
|
||||
}
|
||||
|
||||
// Contract: callers MUST push rows in `active_idx` order and place
|
||||
// any disabled rows at the tail of a phase, because the mouse
|
||||
|
||||
let marker = if is_cursor {
|
||||
crate::glyphs::filled_dot()
|
||||
} else {
|
||||
@@ -1394,7 +1391,7 @@ mod tests {
|
||||
selected: 0,
|
||||
};
|
||||
assert!(set_rewind_cursor(&mut phase, 1));
|
||||
assert!(!set_rewind_cursor(&mut phase, 1)); // no change
|
||||
assert!(!set_rewind_cursor(&mut phase, 1));
|
||||
// Clamp out-of-range to last point (already at last → no change).
|
||||
assert!(!set_rewind_cursor(&mut phase, 99));
|
||||
if let RewindPhase::Picker { selected, .. } = phase {
|
||||
@@ -1503,7 +1500,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn esc_dismisses_from_picker_and_other_phases() {
|
||||
// Picker
|
||||
let s = RewindState {
|
||||
phase: RewindPhase::Picker {
|
||||
points: vec![],
|
||||
@@ -1518,14 +1514,12 @@ mod tests {
|
||||
RewindInput::Dismissed
|
||||
));
|
||||
|
||||
// ModeSelect
|
||||
let s = RewindState::new_mode_select(0, 1, true, true, None);
|
||||
assert!(matches!(
|
||||
handle_rewind_key(&s, &key(KeyCode::Esc)),
|
||||
RewindInput::Dismissed
|
||||
));
|
||||
|
||||
// CancelOffer
|
||||
let s = RewindState::new_cancel_offer(0, None, None);
|
||||
assert!(matches!(
|
||||
handle_rewind_key(&s, &key(KeyCode::Esc)),
|
||||
|
||||
@@ -49,7 +49,6 @@ impl ScrollDebugHud {
|
||||
Self { enabled: env_on }
|
||||
}
|
||||
|
||||
/// Whether the HUD is currently enabled.
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
@@ -12,10 +12,6 @@ use indexmap::IndexMap;
|
||||
use crate::app::app_view::SessionPickerEntry;
|
||||
use crate::views::picker::{PickerEntry, PickerField, PickerRow, PickerState};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Offset added to content-hit indices in the picker `expanded` set so
|
||||
/// they don't collide with fuzzy-entry indices.
|
||||
pub const CONTENT_EXPAND_OFFSET: usize = 100_000;
|
||||
@@ -67,10 +63,6 @@ fn order_repo_groups(groups: &mut IndexMap<&str, Vec<usize>>, current_repo: Opti
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which underlying data a picker position maps to.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PickerItem {
|
||||
@@ -117,10 +109,6 @@ impl SessionPickerLanes {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Filter session entries by native, remote, or external source.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SourceFilter {
|
||||
@@ -287,10 +275,6 @@ fn selectable_fallback<T>(map: &[Option<T>], preferred: usize) -> Option<usize>
|
||||
.or_else(|| (0..preferred).rev().find(|index| map[*index].is_some()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Case-insensitive substring match (callers pass a pre-lowercased query).
|
||||
///
|
||||
/// Deliberately not an ordered-chars subsequence match: that matched so
|
||||
@@ -348,10 +332,6 @@ pub(crate) fn filter_session_entries(
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry map building
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a flat list of picker items from fuzzy + content results,
|
||||
/// deduplicating content hits that already appear in the fuzzy list.
|
||||
pub(crate) fn build_virtual_list(
|
||||
@@ -418,7 +398,8 @@ pub(crate) fn build_entry_map(
|
||||
}
|
||||
order_repo_groups(&mut groups, current_repo);
|
||||
for (_repo, members) in &groups {
|
||||
map.push(None); // repo group header
|
||||
// repo group header
|
||||
map.push(None);
|
||||
for &orig_idx in members {
|
||||
map.push(Some(PickerItem::Fuzzy {
|
||||
original_index: orig_idx,
|
||||
@@ -453,7 +434,8 @@ pub(crate) fn build_entry_map(
|
||||
&& content_loading
|
||||
&& !query.trim().is_empty());
|
||||
if show_content_header {
|
||||
map.push(None); // content header
|
||||
// content header
|
||||
map.push(None);
|
||||
}
|
||||
for hit_idx in content_items {
|
||||
map.push(Some(PickerItem::Content { hit_index: hit_idx }));
|
||||
@@ -478,21 +460,19 @@ pub(crate) fn build_entry_map(
|
||||
let mut map = Vec::with_capacity(virtual_list.len() + usize::from(has_header));
|
||||
for (i, item) in virtual_list.into_iter().enumerate() {
|
||||
if has_header && i == fuzzy_count {
|
||||
map.push(None); // content header
|
||||
// content header
|
||||
map.push(None);
|
||||
}
|
||||
map.push(Some(item));
|
||||
}
|
||||
if has_header && content_count == 0 {
|
||||
map.push(None); // loading header with no results yet
|
||||
// loading header with no results yet
|
||||
map.push(None);
|
||||
}
|
||||
map
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session entry data building
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build owned rendering data for each session entry in the filtered list.
|
||||
///
|
||||
/// The caller zips the result with `PickerField` slices and builds
|
||||
@@ -582,8 +562,6 @@ pub(crate) fn build_grouped_picker_entries<'a>(
|
||||
state: &PickerState,
|
||||
current_repo: Option<&str>,
|
||||
) -> (Vec<PickerEntry<'a>>, Vec<bool>) {
|
||||
// Group filtered entries by repo_name, sort alphabetically, then pin the
|
||||
// current working directory's repo group to the top.
|
||||
let mut groups: IndexMap<&str, Vec<usize>> = IndexMap::new();
|
||||
for (fi, &orig_idx) in filtered_indices.iter().enumerate() {
|
||||
let repo = entries_data[orig_idx].repo_name.as_str();
|
||||
@@ -597,12 +575,10 @@ pub(crate) fn build_grouped_picker_entries<'a>(
|
||||
// Track the grouped position (including headers) to correctly compute selection.
|
||||
let mut grouped_pos: usize = 0;
|
||||
for (repo_name, member_indices) in &groups {
|
||||
// Insert a non-selectable header for this repo group.
|
||||
non_selectable.push(true);
|
||||
result.push(PickerEntry::Header { label: repo_name });
|
||||
grouped_pos += 1;
|
||||
|
||||
// Insert each session row indented under the header.
|
||||
for &fi in member_indices {
|
||||
let b = &built[fi];
|
||||
let fields = &fields_vecs[fi];
|
||||
@@ -630,10 +606,6 @@ pub(crate) fn build_grouped_picker_entries<'a>(
|
||||
(result, non_selectable)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content search helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build owned rendering data for content search (deep search) result rows.
|
||||
///
|
||||
/// Deduplicates hits that already appear in the fuzzy results. The returned
|
||||
@@ -732,10 +704,6 @@ pub(crate) fn build_content_header_label(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a timestamp as a human-readable relative time.
|
||||
pub(crate) fn format_time_ago(dt: chrono::DateTime<chrono::Utc>) -> String {
|
||||
let now = chrono::Utc::now();
|
||||
@@ -756,10 +724,6 @@ pub(crate) fn format_time_ago(dt: chrono::DateTime<chrono::Utc>) -> String {
|
||||
format!("{:>8}", raw)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1235,10 +1199,12 @@ mod tests {
|
||||
assert_eq!(all, vec![0, 1, 2, 3, 4, 5]);
|
||||
|
||||
let local = filter_session_entries(Some(&entries), "", SourceFilter::Local);
|
||||
assert_eq!(local, vec![0, 2]); // local + both
|
||||
// local + both
|
||||
assert_eq!(local, vec![0, 2]);
|
||||
|
||||
let remote = filter_session_entries(Some(&entries), "", SourceFilter::Remote);
|
||||
assert_eq!(remote, vec![1, 2]); // remote + both
|
||||
// remote + both
|
||||
assert_eq!(remote, vec![1, 2]);
|
||||
|
||||
let external = filter_session_entries(Some(&entries), "", SourceFilter::External);
|
||||
assert_eq!(external, vec![3, 4, 5]);
|
||||
@@ -1332,12 +1298,14 @@ mod tests {
|
||||
);
|
||||
// repo-a header + s0 + repo-b header + s2 = 4
|
||||
assert_eq!(map.len(), 4);
|
||||
assert!(map[0].is_none()); // repo-a header
|
||||
// repo-a header
|
||||
assert!(map[0].is_none());
|
||||
assert!(matches!(
|
||||
map[1],
|
||||
Some(PickerItem::Fuzzy { original_index: 0 })
|
||||
));
|
||||
assert!(map[2].is_none()); // repo-b header
|
||||
// repo-b header
|
||||
assert!(map[2].is_none());
|
||||
assert!(matches!(
|
||||
map[3],
|
||||
Some(PickerItem::Fuzzy { original_index: 2 })
|
||||
@@ -1354,12 +1322,14 @@ mod tests {
|
||||
None,
|
||||
);
|
||||
assert_eq!(map.len(), 4);
|
||||
assert!(map[0].is_none()); // repo-a header
|
||||
// repo-a header
|
||||
assert!(map[0].is_none());
|
||||
assert!(matches!(
|
||||
map[1],
|
||||
Some(PickerItem::Fuzzy { original_index: 1 })
|
||||
));
|
||||
assert!(map[2].is_none()); // repo-b header
|
||||
// repo-b header
|
||||
assert!(map[2].is_none());
|
||||
assert!(matches!(
|
||||
map[3],
|
||||
Some(PickerItem::Fuzzy { original_index: 2 })
|
||||
|
||||
@@ -128,8 +128,6 @@ pub(crate) fn format_relative_time(elapsed: Duration) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── sanitize_display_text ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sanitize_passes_through_clean_ascii_unchanged_no_alloc() {
|
||||
let s = "session foo bar";
|
||||
@@ -193,8 +191,6 @@ mod tests {
|
||||
assert!(matches!(out, Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
// ── truncate_title ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn truncate_title_keeps_short_strings() {
|
||||
assert_eq!(truncate_title("hello"), "hello");
|
||||
@@ -221,8 +217,6 @@ mod tests {
|
||||
assert_eq!(out.chars().count(), MAX_TITLE_CHARS + 3);
|
||||
}
|
||||
|
||||
// ── format_relative_time ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_sub_second_is_now() {
|
||||
assert_eq!(format_relative_time(Duration::from_millis(0)), "now");
|
||||
|
||||
@@ -46,17 +46,14 @@ use crate::views::modal_window::{
|
||||
|
||||
use kigi_shell::agent::config::UiConfig;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Public display title of the modal — also used by
|
||||
/// `views/modal.rs::ActiveModal::message` so renames stay in one place.
|
||||
pub const MODAL_TITLE: &str = "Settings";
|
||||
|
||||
/// Width of the `"─ "` leading decoration before the title in the
|
||||
/// modal's top border. Used to compute the breadcrumb hit-rect x offset.
|
||||
const TITLE_LEADING_DECORATION_W: u16 = 2; // `─ `: 1 cell box-drawing + 1 cell space.
|
||||
// `─ `: 1 cell box-drawing + 1 cell space.
|
||||
const TITLE_LEADING_DECORATION_W: u16 = 2;
|
||||
|
||||
// Descriptions are now expand-on-demand via Right/Left arrows;
|
||||
// see `render_expanded_description`.
|
||||
@@ -91,10 +88,6 @@ pub enum SettingsKeyOutcome {
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One row in the visible flat list — either a category header (non-
|
||||
/// selectable) or a setting row (selectable, dispatchable).
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -159,7 +152,7 @@ pub struct SettingsModalState {
|
||||
/// Row indices matching `query`, recomputed per mutation (not per frame).
|
||||
filtered_cache: Vec<usize>,
|
||||
|
||||
// -- Mouse hit-test rects (populated by render) --
|
||||
// Mouse hit-test rects, populated by render.
|
||||
pub list_area: Rect,
|
||||
/// Click-hit rect per row, parallel to `rows`.
|
||||
pub row_rects: Vec<Rect>,
|
||||
@@ -875,10 +868,6 @@ fn validate_int(buffer: &str, min: i64, max: i64) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Overlay for the reset-confirm dialog. Overrides chrome breadcrumb,
|
||||
/// footer, and search bar with the confirmation prompt.
|
||||
pub struct ResetConfirmOverlay<'a> {
|
||||
@@ -1062,7 +1051,8 @@ pub fn render_settings_modal(
|
||||
state.window.popup_area.map(|popup| {
|
||||
let title_w = title.width() as u16;
|
||||
// Clamp to not extend past the close button.
|
||||
let max_w = popup.width.saturating_sub(2 + 2); // borders + " ─" trailing decoration
|
||||
// borders + " ─" trailing decoration
|
||||
let max_w = popup.width.saturating_sub(2 + 2);
|
||||
Rect {
|
||||
x: popup.x + 1 + TITLE_LEADING_DECORATION_W,
|
||||
y: popup.y,
|
||||
@@ -1164,7 +1154,8 @@ fn render_reset_confirm_overlay(
|
||||
&& y >= ys
|
||||
&& y < ye
|
||||
{
|
||||
continue; // inside the target row's y range — stays full intensity
|
||||
// Inside the target row's y range — stays full intensity.
|
||||
continue;
|
||||
}
|
||||
let strip = Rect {
|
||||
x: list_area.x,
|
||||
@@ -1316,7 +1307,8 @@ fn render_rows(buf: &mut Buffer, area: Rect, state: &mut SettingsModalState, the
|
||||
if total_visible == 0 {
|
||||
if !state.query.is_empty() {
|
||||
let prefix = "No matches for ";
|
||||
let suffix_quote_w = 2u16; // surrounding "" chars
|
||||
// surrounding "" chars
|
||||
let suffix_quote_w = 2u16;
|
||||
let available_for_query = (area.width as usize)
|
||||
.saturating_sub(prefix.width())
|
||||
.saturating_sub(suffix_quote_w as usize);
|
||||
@@ -1579,7 +1571,8 @@ fn render_rows(buf: &mut Buffer, area: Rect, state: &mut SettingsModalState, the
|
||||
x: area.x,
|
||||
y: y_cursor,
|
||||
width: area.width,
|
||||
height: desc_height.min(8), // cap at 8 lines per row to keep scroll sane
|
||||
// cap at 8 lines per row to keep scroll sane
|
||||
height: desc_height.min(8),
|
||||
};
|
||||
render_expanded_description(buf, desc_rect, meta, theme);
|
||||
// Re-measure how many lines the wrapped description
|
||||
@@ -1764,7 +1757,7 @@ fn render_sub_pane_header(
|
||||
description: &str,
|
||||
min_non_desc_rows: u16,
|
||||
) -> u16 {
|
||||
// ── Row 0: title (truncated with `…`). ────────────────────────
|
||||
// Row 0: title (truncated with `…`).
|
||||
let title_style = Style::default()
|
||||
.fg(theme.text_primary)
|
||||
.bg(theme.bg_base)
|
||||
@@ -1782,7 +1775,7 @@ fn render_sub_pane_header(
|
||||
title_w,
|
||||
);
|
||||
|
||||
// ── Row 1+: word-wrapped description ──────────────────────────
|
||||
// Row 1+: word-wrapped description
|
||||
let description_wrapped = wrap_description(description, area.width);
|
||||
let desc_rows: u16 = description_wrapped.len() as u16;
|
||||
let has_description =
|
||||
@@ -1863,14 +1856,14 @@ fn render_picking_enum(buf: &mut Buffer, area: Rect, state: &SettingsModalState,
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Per-choice wrapped layout ─────────────────────────────────
|
||||
// Per-choice wrapped layout
|
||||
let layouts: Vec<PickerChoiceLayout> = choices
|
||||
.iter()
|
||||
.map(|choice| compute_picker_choice_layout(choice, area.width))
|
||||
.collect();
|
||||
let total_h: u16 = layouts.iter().map(|l| l.height).sum();
|
||||
|
||||
// ── Scroll offset (variable per-choice height) ────────────────
|
||||
// Scroll offset (variable per-choice height)
|
||||
let needs_overflow = total_h as usize > max_choices_h;
|
||||
let available_h: u16 = if needs_overflow {
|
||||
(max_choices_h as u16).saturating_sub(1).max(1)
|
||||
@@ -1893,12 +1886,13 @@ fn render_picking_enum(buf: &mut Buffer, area: Rect, state: &SettingsModalState,
|
||||
if visible_end <= choices_idx {
|
||||
visible_end = choices_idx + 1;
|
||||
}
|
||||
let _ = consumed_h; // height bookkeeping kept for future tuning
|
||||
// height bookkeeping kept for future tuning
|
||||
let _ = consumed_h;
|
||||
|
||||
// ── Hit-rect bookkeeping ──────────────────────────────────────
|
||||
// Hit-rect bookkeeping
|
||||
let mut picker_choice_rects: Vec<Rect> = vec![Rect::default(); choices.len()];
|
||||
|
||||
// ── Choice rows ───────────────────────────────────────────────
|
||||
// Choice rows
|
||||
let fg_primary = theme.text_primary;
|
||||
let fg_gray = theme.gray;
|
||||
let fg_accent = theme.accent_user;
|
||||
@@ -1945,7 +1939,7 @@ fn render_picking_enum(buf: &mut Buffer, area: Rect, state: &SettingsModalState,
|
||||
buf.set_style(block_rect, Style::default().bg(bg));
|
||||
picker_choice_rects[choice_i] = block_rect;
|
||||
|
||||
// ── Line 1: prefix + display + (· + first wrap line) ──────
|
||||
// Line 1: prefix + display + (· + first wrap line)
|
||||
let y = y_cursor;
|
||||
if area.width > 0 {
|
||||
// Leading space (col 0 of the row).
|
||||
@@ -2064,8 +2058,8 @@ fn render_picking_enum(buf: &mut Buffer, area: Rect, state: &SettingsModalState,
|
||||
y_cursor = y_cursor.saturating_add(layout.height);
|
||||
}
|
||||
|
||||
// ── Overflow indicator: "… N more" on the row right below the
|
||||
// last rendered choice. ─────────────────────────────────────
|
||||
// Overflow indicator: "… N more" on the row right below the
|
||||
// last rendered choice.
|
||||
if needs_overflow && visible_end < choices.len() {
|
||||
let more_count = choices.len() - visible_end;
|
||||
let overflow_y = y_cursor;
|
||||
@@ -2097,7 +2091,8 @@ fn render_picking_enum(buf: &mut Buffer, area: Rect, state: &SettingsModalState,
|
||||
PICKER_RECTS_SCRATCH.with(|cell| {
|
||||
*cell.borrow_mut() = picker_choice_rects;
|
||||
});
|
||||
let _ = total_h; // suppress unused-var warning on some builds
|
||||
// suppress unused-var warning on some builds
|
||||
let _ = total_h;
|
||||
}
|
||||
|
||||
// Thread-local scratch to ferry hit-rects out of `render_picking_enum`
|
||||
@@ -2152,7 +2147,7 @@ fn render_picking_group(
|
||||
let mut y = area.y + header_rows;
|
||||
let area_end = area.y + area.height;
|
||||
|
||||
// ── Child toggle rows. ────────────────────────────────────────
|
||||
// Child toggle rows.
|
||||
let mut rects: Vec<Rect> = vec![Rect::default(); children.len()];
|
||||
for (i, child_key) in children.iter().enumerate() {
|
||||
if y >= area_end {
|
||||
@@ -2479,7 +2474,7 @@ fn render_editing_value(
|
||||
}
|
||||
let input_y = area.y + header_rows;
|
||||
|
||||
// ── Row 3: input line. ────────────────────────────────────────
|
||||
// Row 3: input line.
|
||||
let has_error = validation_error.is_some();
|
||||
let input_bg = theme.bg_visual;
|
||||
let input_fg = if has_error {
|
||||
@@ -2502,7 +2497,8 @@ fn render_editing_value(
|
||||
let buffer_room_end_x = area.x + area.width;
|
||||
let buffer_room = buffer_room_end_x.saturating_sub(input_x) as usize;
|
||||
if buffer_room == 0 {
|
||||
return; // No room to render the buffer.
|
||||
// No room to render the buffer.
|
||||
return;
|
||||
}
|
||||
|
||||
let input_strip_rect = Rect {
|
||||
@@ -2609,7 +2605,7 @@ fn render_editing_value(
|
||||
);
|
||||
}
|
||||
|
||||
// ── Row 4: validation error. ──────────────────────────────────
|
||||
// Row 4: validation error.
|
||||
if area.height > header_rows + 1
|
||||
&& let Some(err) = validation_error
|
||||
{
|
||||
@@ -2650,7 +2646,7 @@ fn render_int_stepper(
|
||||
}
|
||||
let stepper_y = area.y + header_rows;
|
||||
|
||||
// ── Row 3: centered stepper "‹ N ›". ────────────────────────
|
||||
// Row 3: centered stepper "‹ N ›".
|
||||
let value_text = if buffer.is_empty() {
|
||||
// Defensive — try_enter_editing_value seeds buffer from the
|
||||
// current value, so this branch should be unreachable, but
|
||||
@@ -2727,18 +2723,15 @@ fn render_int_stepper(
|
||||
);
|
||||
}
|
||||
|
||||
// **In-pane hint dropped.** Earlier revisions
|
||||
// rendered a centered `↑/↓ +/-5 ←/→ +/-10 Enter commit · Esc
|
||||
// cancel` strip here, but the chrome footer's
|
||||
// `build_int_editor_shortcuts` already exposes the same content
|
||||
// at the bottom of the modal. On tall viewports both rendered
|
||||
// simultaneously — same keys, different separator (`·` vs `|`),
|
||||
// duplicate visual noise. We rely on the chrome footer alone
|
||||
// now; if the chrome ever fails to render its shortcut row (a
|
||||
// future regression), the user can still discover the keys via
|
||||
// the shortcuts cheatsheet (`?`).
|
||||
// No in-pane `↑/↓ +/-5 ←/→ +/-10 Enter commit · Esc cancel`
|
||||
// hint here: the chrome footer's `build_int_editor_shortcuts`
|
||||
// already exposes the same keys at the bottom of the modal, and
|
||||
// rendering both produced duplicate strips (same keys, different
|
||||
// separator) on tall viewports. If the chrome footer ever fails
|
||||
// to render, the keys are still discoverable via the shortcuts
|
||||
// cheatsheet (`?`).
|
||||
|
||||
// ── Live wrap preview for max_thoughts_width. ─────────────────
|
||||
// Live wrap preview for max_thoughts_width.
|
||||
//
|
||||
// When the user is stepping `max_thoughts_width`, render a
|
||||
// sample thinking-text preview directly below the stepper that
|
||||
@@ -2934,7 +2927,7 @@ fn render_preview_block(
|
||||
// this function.
|
||||
let title_y = area.y.saturating_add(1);
|
||||
|
||||
// ── Title row. ────────────────────────────────────────────────
|
||||
// Title row.
|
||||
let title_bg = theme.bg_visual;
|
||||
let content_bg = theme.bg_highlight;
|
||||
let title_fg = theme.text_primary;
|
||||
@@ -2981,7 +2974,7 @@ fn render_preview_block(
|
||||
title_w,
|
||||
);
|
||||
|
||||
// ── Content rows. ─────────────────────────────────────────────
|
||||
// Content rows.
|
||||
let content_style = Style::default()
|
||||
.fg(content_fg)
|
||||
.bg(content_bg)
|
||||
@@ -3011,7 +3004,7 @@ fn render_preview_block(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clamped note (optional, height-permitting). ───────────────
|
||||
// Clamped note (optional, height-permitting).
|
||||
//
|
||||
// When `clamped`, surface the clamp in a low-key note row
|
||||
// immediately below the last content row. The note is
|
||||
@@ -3124,7 +3117,8 @@ const ROW_RIGHT_PAD_W: u16 = 1;
|
||||
const ROW_CHEVRON_W: u16 = 2;
|
||||
/// Chevron column width — reserved for all rows for alignment.
|
||||
const ROW_CHEVRON_COL_W: u16 = ROW_CHEVRON_W;
|
||||
const ROW_RESTART_PILL_W: u16 = 10; // " · restart" — used for layout budgeting only.
|
||||
// " · restart" — used for layout budgeting only.
|
||||
const ROW_RESTART_PILL_W: u16 = 10;
|
||||
|
||||
/// Per-row layout decision.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -3257,7 +3251,8 @@ fn render_setting_row(
|
||||
| (SettingKind::String { .. }, _)
|
||||
| (SettingKind::DynamicEnum { .. }, _)
|
||||
);
|
||||
let chevron_str = format!(" {}", crate::glyphs::chevron()); // › → > on legacy ConHost
|
||||
// › → > on legacy ConHost
|
||||
let chevron_str = format!(" {}", crate::glyphs::chevron());
|
||||
let chevron_w = if show_chevron {
|
||||
chevron_str.width() as u16
|
||||
} else {
|
||||
@@ -3298,7 +3293,7 @@ fn render_setting_row(
|
||||
};
|
||||
let _ = max_label_w;
|
||||
|
||||
// ── Compute right-side x positions (shared across layouts). ──
|
||||
// Compute right-side x positions (shared across layouts).
|
||||
// Layout (right-to-left): [restart pill][space][chevron][space][value]
|
||||
// The 1-cell right pad is baked into `restart_x`.
|
||||
let restart_x_line1 = (area.x + area.width).saturating_sub(restart_w + 1);
|
||||
@@ -3366,12 +3361,13 @@ fn render_setting_row(
|
||||
}
|
||||
}
|
||||
RowLayout::TwoLine | RowLayout::TwoLineWithLabelTruncation => {
|
||||
// ── Line 1: triangle + label + (restart pill) ──
|
||||
// Line 1: triangle + label + (restart pill)
|
||||
// Compute how much horizontal space is available to the
|
||||
// label before colliding with the restart pill.
|
||||
// restart pill + right pad
|
||||
let label_avail = area
|
||||
.width
|
||||
.saturating_sub(restart_w + 1) // restart pill + right pad
|
||||
.saturating_sub(restart_w + 1)
|
||||
.saturating_sub(ROW_TRIANGLE_PREFIX_W);
|
||||
|
||||
let label_text_owned: String;
|
||||
@@ -3410,30 +3406,27 @@ fn render_setting_row(
|
||||
);
|
||||
}
|
||||
|
||||
// ── Line 2: right-aligned value + chevron column ──
|
||||
// Line 2: right-aligned value + chevron column
|
||||
//
|
||||
// The chevron column is reserved
|
||||
// for ALL rows so the `›` glyph is at a constant
|
||||
// offset; Bool rows leave it empty but the value
|
||||
// still right-aligns to the column's left edge.
|
||||
// An earlier version anchored Bool rows on line 2 to
|
||||
// `area.right - value_w - 1` (no chevron column
|
||||
// reserved), shifting their `on`/`off` text 2 cells
|
||||
// to the right of chevron rows' values — a
|
||||
// visual misalignment.
|
||||
// The chevron column is reserved for ALL rows so the `›`
|
||||
// glyph is at a constant offset; Bool rows leave it empty
|
||||
// but the value still right-aligns to the column's left
|
||||
// edge. Anchoring Bool rows instead to
|
||||
// `area.right - value_w - 1` (no chevron column reserved)
|
||||
// shifts their `on`/`off` text 2 cells right of chevron
|
||||
// rows' values, a visual misalignment.
|
||||
//
|
||||
// Anchor line-2's
|
||||
// chevron-column LEFT EDGE at the same column the
|
||||
// one-line layout uses: `area.right - ROW_RIGHT_PAD_W
|
||||
// - ROW_CHEVRON_COL_W` (i.e. `restart_x_line1 -
|
||||
// ROW_CHEVRON_COL_W` when no restart pill is on
|
||||
// line 2). The earlier version anchored at
|
||||
// `area.right - ROW_CHEVRON_COL_W`, so on a row
|
||||
// that flipped from one-line to two-line layout the
|
||||
// `›` glyph would jump 1 cell rightward — producing
|
||||
// a staircase between mixed-layout rows. Subtracting
|
||||
// `ROW_RIGHT_PAD_W` here brings line 2 into pixel
|
||||
// parity with line 1.
|
||||
// Line-2's chevron-column LEFT EDGE must land on the
|
||||
// same column the one-line layout uses:
|
||||
// `area.right - ROW_RIGHT_PAD_W - ROW_CHEVRON_COL_W`
|
||||
// (i.e. `restart_x_line1 - ROW_CHEVRON_COL_W` when no
|
||||
// restart pill is on line 2). Anchoring at
|
||||
// `area.right - ROW_CHEVRON_COL_W` instead — omitting
|
||||
// `ROW_RIGHT_PAD_W` — makes the `›` glyph jump 1 cell
|
||||
// rightward on a row that flips from one-line to
|
||||
// two-line layout, producing a staircase between
|
||||
// mixed-layout rows. Subtracting `ROW_RIGHT_PAD_W` here
|
||||
// brings line 2 into pixel parity with line 1.
|
||||
let y2 = area.y + 1;
|
||||
let chevron_x_line2 = (area.x + area.width)
|
||||
.saturating_sub(ROW_RIGHT_PAD_W + ROW_CHEVRON_COL_W)
|
||||
@@ -3791,10 +3784,6 @@ fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'static>> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handle a key event in the settings modal.
|
||||
///
|
||||
/// F2/Ctrl+,/Cmd+, always close regardless of mode. Esc behavior is
|
||||
@@ -4707,10 +4696,6 @@ fn handle_filter_focused(state: &mut SettingsModalState, key: &KeyEvent) -> Sett
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mouse handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handle a mouse event in the modal content area.
|
||||
///
|
||||
/// Mirrors `memory_modal::handle_memory_mouse` for parity:
|
||||
@@ -5114,10 +5099,6 @@ fn rect_contains(r: Rect, column: u16, row: u16) -> bool {
|
||||
&& row < r.y.saturating_add(r.height)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -5535,11 +5516,14 @@ mod tests {
|
||||
area,
|
||||
&meta,
|
||||
&SettingValue::Bool(false),
|
||||
15, // max_label_w — kept for API compatibility, no longer used.
|
||||
// max_label_w — kept for API compatibility, no longer used.
|
||||
15,
|
||||
false,
|
||||
&theme,
|
||||
false, // is_expanded
|
||||
false, // is_hovered
|
||||
// is_expanded
|
||||
false,
|
||||
// is_hovered
|
||||
false,
|
||||
);
|
||||
let mut rendered = String::new();
|
||||
for x in 0..area.width {
|
||||
@@ -5982,8 +5966,6 @@ mod tests {
|
||||
assert!(matches!(outcome, SettingsKeyOutcome::Unchanged));
|
||||
}
|
||||
|
||||
// ---------- mouse hover highlight ----------
|
||||
|
||||
#[test]
|
||||
fn settings_list_row_bg_terminal_native_elevates_selection() {
|
||||
let theme = Theme::terminal_default();
|
||||
@@ -6395,8 +6377,6 @@ mod tests {
|
||||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||||
}
|
||||
|
||||
// -- routing scaffold tests --
|
||||
//
|
||||
// The enum chooser and string/int editor declare their
|
||||
// mode variants alongside Browse and route Esc → Browse so the
|
||||
// scaffold doesn't ship dead `unimplemented!()` panics. These
|
||||
@@ -6442,11 +6422,15 @@ mod tests {
|
||||
area,
|
||||
&meta,
|
||||
&SettingValue::Bool(false),
|
||||
10, // max_label_w
|
||||
false, // is_selected
|
||||
// max_label_w
|
||||
10,
|
||||
// is_selected
|
||||
false,
|
||||
&theme,
|
||||
true, // is_expanded — gate on
|
||||
false, // is_hovered
|
||||
// is_expanded — gate on
|
||||
true,
|
||||
// is_hovered
|
||||
false,
|
||||
);
|
||||
let mut rendered = String::new();
|
||||
for x in 0..area.width {
|
||||
@@ -6465,12 +6449,15 @@ mod tests {
|
||||
&mut buf,
|
||||
area,
|
||||
&meta,
|
||||
&SettingValue::Bool(true), // edited from default `false`
|
||||
// edited from default `false`
|
||||
&SettingValue::Bool(true),
|
||||
10,
|
||||
false,
|
||||
&theme,
|
||||
false, // is_expanded — off
|
||||
false, // is_hovered
|
||||
// is_expanded — off
|
||||
false,
|
||||
// is_hovered
|
||||
false,
|
||||
);
|
||||
let mut rendered = String::new();
|
||||
for x in 0..area.width {
|
||||
@@ -6517,8 +6504,10 @@ mod tests {
|
||||
10,
|
||||
false,
|
||||
&theme,
|
||||
false, // is_expanded
|
||||
false, // is_hovered
|
||||
// is_expanded
|
||||
false,
|
||||
// is_hovered
|
||||
false,
|
||||
);
|
||||
let mut rendered = String::new();
|
||||
for x in 0..area.width {
|
||||
@@ -6596,7 +6585,8 @@ mod tests {
|
||||
/// buffers that fit entirely within the visible window.
|
||||
#[test]
|
||||
fn render_editing_value_cursor_at_logical_position_when_buffer_fits() {
|
||||
let mut s = editor_render_fixture("Kigi Test", 4); // cursor between "Kigi" and " Test"
|
||||
// cursor between "Kigi" and " Test"
|
||||
let mut s = editor_render_fixture("Kigi Test", 4);
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -6854,8 +6844,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Int stepper key + render contracts ----------
|
||||
|
||||
// Int stepper key + render contracts
|
||||
/// Helper: build a `SettingsModalState` directly in EditingValue
|
||||
/// mode for a registered Int setting with the given starting value.
|
||||
fn int_stepper_fixture_for(key: &'static str, value: i64) -> SettingsModalState {
|
||||
@@ -6891,14 +6880,22 @@ mod tests {
|
||||
fn int_step_sizes_table_pins_range_policy() {
|
||||
// (min, max, expected_small, expected_large)
|
||||
let cases = [
|
||||
(1, 10, 1, 1), // scroll_lines (span 9)
|
||||
(1, 100, 1, 5), // scroll_speed (span 99)
|
||||
(40, 500, 5, 10), // max_thoughts_width (span 460)
|
||||
(0, 0, 1, 1), // degenerate span
|
||||
(1, 21, 1, 4), // span 20 still narrow: large = span/5
|
||||
(1, 22, 1, 5), // span 21 → mid band
|
||||
(1, 101, 1, 5), // span 100 still mid
|
||||
(1, 102, 5, 10), // span 101 → wide band
|
||||
// scroll_lines (span 9)
|
||||
(1, 10, 1, 1),
|
||||
// scroll_speed (span 99)
|
||||
(1, 100, 1, 5),
|
||||
// max_thoughts_width (span 460)
|
||||
(40, 500, 5, 10),
|
||||
// degenerate span
|
||||
(0, 0, 1, 1),
|
||||
// span 20 still narrow: large = span/5
|
||||
(1, 21, 1, 4),
|
||||
// span 21 → mid band
|
||||
(1, 22, 1, 5),
|
||||
// span 100 still mid
|
||||
(1, 101, 1, 5),
|
||||
// span 101 → wide band
|
||||
(1, 102, 5, 10),
|
||||
];
|
||||
for (min, max, want_small, want_large) in cases {
|
||||
assert_eq!(
|
||||
@@ -7347,7 +7344,7 @@ mod tests {
|
||||
assert!(matches!(s.mode, SettingsModalMode::Browse));
|
||||
}
|
||||
|
||||
// -- picker machinery tests --
|
||||
// picker machinery tests
|
||||
//
|
||||
// When the chooser sub-pane ships with no Enum entries in
|
||||
// `default_settings()`, these tests build a
|
||||
@@ -7509,9 +7506,8 @@ mod tests {
|
||||
/// land real `Action::SetTheme(...)` Action variants — exercised
|
||||
/// by the e2e tests at `tests/settings_e2e.rs`.
|
||||
///
|
||||
/// Enter used to be a no-op
|
||||
/// (relying on the most-recent preview being the committed
|
||||
/// value); now it explicitly emits a commit Action so the
|
||||
/// Enter explicitly emits a commit Action (rather than relying on
|
||||
/// the most-recent preview being the committed value) so the
|
||||
/// persist path runs once per picker open → close cycle.
|
||||
#[test]
|
||||
fn picker_enter_returns_to_browse() {
|
||||
@@ -7715,8 +7711,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- try_enter_picking_enum coverage --
|
||||
|
||||
// try_enter_picking_enum coverage
|
||||
/// Browse-mode Enter on an Enum row transitions to PickingEnum
|
||||
/// mode with `choices_idx` seeded from the row's current value
|
||||
/// (resolved by `current_value_for`).
|
||||
@@ -7763,7 +7758,8 @@ mod tests {
|
||||
/// fallthrough Bool-toggle path takes over.
|
||||
#[test]
|
||||
fn browse_enter_on_bool_row_does_not_enter_picking_enum() {
|
||||
let mut s = make_state(); // default registry — all Bool.
|
||||
// default registry — all Bool.
|
||||
let mut s = make_state();
|
||||
// compact_mode is the initial selection.
|
||||
let outcome =
|
||||
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
@@ -7840,8 +7836,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- render_picking_enum narrow-terminal coverage --
|
||||
|
||||
// render_picking_enum narrow-terminal coverage
|
||||
#[test]
|
||||
fn render_picker_with_zero_height_is_noop() {
|
||||
let s = picker_test_state();
|
||||
@@ -8835,8 +8830,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- render_settings_modal routing coverage --
|
||||
|
||||
// render_settings_modal routing coverage
|
||||
/// `render_settings_modal` branches on mode → picker render path.
|
||||
/// Verifies that the search-bar placeholder text is NOT present
|
||||
/// (proves the picker branch fired and the Browse path was
|
||||
@@ -8898,8 +8892,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- mouse + catch-all coverage --
|
||||
|
||||
// mouse + catch-all coverage
|
||||
/// Scroll wheel during PickingEnum mode is a no-op AND does NOT
|
||||
/// mutate `state.selected` (the underlying Browse selection).
|
||||
/// Regression test.
|
||||
@@ -9021,8 +9014,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- helper-function coverage --
|
||||
|
||||
// helper-function coverage
|
||||
/// `picker_choices_len` returns 0 for an unknown key, a non-Enum
|
||||
/// key, and a zero-choice Enum.
|
||||
#[test]
|
||||
@@ -9062,7 +9054,7 @@ mod tests {
|
||||
assert!(matches!(s.mode, SettingsModalMode::Browse));
|
||||
}
|
||||
|
||||
// -- Direct unit tests for compute_filtered --
|
||||
// Direct unit tests for compute_filtered
|
||||
//
|
||||
// The free function is module-private; integration tests can only
|
||||
// reach it through the key-press surface. These unit tests pin
|
||||
@@ -9219,7 +9211,7 @@ mod tests {
|
||||
assert_eq!(s.selected, simple_idx);
|
||||
}
|
||||
|
||||
// -- blank line above category section headers --
|
||||
// blank line above category section headers
|
||||
//
|
||||
// The renderer reserves one empty visual line ABOVE every section
|
||||
// header EXCEPT the one that lands first in the viewport. These
|
||||
@@ -9424,7 +9416,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- two-line layout when label + value don't fit --
|
||||
// two-line layout when label + value don't fit
|
||||
//
|
||||
// The `row_layout` helper decides one-line vs two-line vs
|
||||
// two-line-with-label-truncation based on the full label width.
|
||||
@@ -9472,10 +9464,8 @@ mod tests {
|
||||
|
||||
fn synthetic_long_label_meta() -> SettingMeta {
|
||||
// Fixed 31-cell label kept for the two-line threshold tests
|
||||
// below. Previously matched the literal `simple_mode` label
|
||||
// (now renamed to "Disable vim input mode" — 22 cells); the
|
||||
// longer literal stays to exercise the wrap path that the
|
||||
// shorter rename no longer triggers organically.
|
||||
// below; real setting labels are shorter and don't trigger
|
||||
// the wrap path organically.
|
||||
SettingMeta {
|
||||
key: "test-long-label",
|
||||
category: SettingCategory::Appearance,
|
||||
@@ -9531,11 +9521,13 @@ mod tests {
|
||||
area,
|
||||
&meta,
|
||||
&SettingValue::Bool(false),
|
||||
24, // max_label_w — ignored for layout.
|
||||
// max_label_w — ignored for layout.
|
||||
24,
|
||||
false,
|
||||
&theme,
|
||||
false,
|
||||
false, // is_hovered
|
||||
// is_hovered
|
||||
false,
|
||||
);
|
||||
let line1 = buf_row_text(&buf, 0, area.x, area.width);
|
||||
let line2 = buf_row_text(&buf, 1, area.x, area.width);
|
||||
@@ -9598,7 +9590,8 @@ mod tests {
|
||||
false,
|
||||
&theme,
|
||||
false,
|
||||
false, // is_hovered
|
||||
// is_hovered
|
||||
false,
|
||||
);
|
||||
let line1 = buf_row_text(&buf, 0, area.x, area.width);
|
||||
let line2 = buf_row_text(&buf, 1, area.x, area.width);
|
||||
@@ -9639,7 +9632,8 @@ mod tests {
|
||||
false,
|
||||
&theme,
|
||||
false,
|
||||
false, // is_hovered
|
||||
// is_hovered
|
||||
false,
|
||||
);
|
||||
let line1 = buf_row_text(&buf, 0, area.x, area.width);
|
||||
let line2 = buf_row_text(&buf, 1, area.x, area.width);
|
||||
@@ -9841,8 +9835,10 @@ mod tests {
|
||||
/// fits picks `OneLine`; one cell narrower picks `TwoLine`.
|
||||
#[test]
|
||||
fn row_layout_threshold_is_exact() {
|
||||
let label = "Coding data sharing"; // 19 cells
|
||||
let value = "Opt out"; // 7 cells
|
||||
// 19 cells
|
||||
let label = "Coding data sharing";
|
||||
// 7 cells
|
||||
let value = "Opt out";
|
||||
// chrome (triangle + gap + chevron + right pad) = 2 + 1 + 2 + 1 = 6
|
||||
// total = 19 + 7 + 6 = 32 cells (chevron-enabled).
|
||||
assert_eq!(row_layout(32, label, value, false), RowLayout::OneLine);
|
||||
@@ -9852,17 +9848,15 @@ mod tests {
|
||||
/// Sanity: `row_layout` handles bool-without-chevron rows
|
||||
/// (Bool kind, no `›` suffix). The chevron
|
||||
/// column is reserved even for Bool rows, so the chrome cost
|
||||
/// is the same with and without the glyph.
|
||||
///
|
||||
/// The dead
|
||||
/// `has_chevron` parameter has been removed; `row_layout` now
|
||||
/// always reserves the chevron column. The Bool / Enum
|
||||
/// is the same with and without the glyph. The Bool / Enum
|
||||
/// distinction at the renderer is purely whether to paint
|
||||
/// the `›` glyph in the (always-reserved) column.
|
||||
#[test]
|
||||
fn row_layout_bool_without_chevron() {
|
||||
let label = "Disable vim mode (experimental)"; // 31 cells
|
||||
let value = "off"; // 3 cells
|
||||
// 31 cells
|
||||
let label = "Disable vim mode (experimental)";
|
||||
// 3 cells
|
||||
let value = "off";
|
||||
// chrome (triangle + gap + reserved chevron col + right pad)
|
||||
// = 2 + 1 + 2 + 1 = 6 cells, identical to the
|
||||
// chevron-enabled case.
|
||||
@@ -10113,8 +10107,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- palette consistency --
|
||||
|
||||
// palette consistency
|
||||
/// Section headers render in the palette's style: ` {label} `
|
||||
/// in `gray + BOLD` followed by `─` separator cells in
|
||||
/// `gray_dim`. Asserts that (a) the header label cell carries
|
||||
@@ -10294,8 +10287,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- value color + chevron column + docs footer --
|
||||
|
||||
// value color + chevron column + docs footer
|
||||
/// Bool `off` values render in the muted `gray` color while
|
||||
/// Bool `on` values keep the active `accent_user`: the inactive
|
||||
/// state should read as visually subordinate.
|
||||
@@ -10762,8 +10754,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- sub-pane polish --
|
||||
|
||||
// sub-pane polish
|
||||
/// Helper: open the picker for the named enum/dyn-enum key in
|
||||
/// `make_state()`. Returns the state with PickingEnum mode
|
||||
/// armed. Panics if the key isn't found or isn't an enum.
|
||||
@@ -11456,7 +11447,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- max_thoughts_width live wrap preview ----------
|
||||
// max_thoughts_width live wrap preview
|
||||
//
|
||||
// The preview block renders below the Int stepper inside the
|
||||
// EditingValue sub-pane when the active setting key is
|
||||
@@ -12263,9 +12254,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Auto-widen tests for max_thoughts_width EditingValue mode.
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// At a wide terminal (200 cols), entering EditingValue mode
|
||||
/// for `max_thoughts_width` widens the rendered modal so that
|
||||
|
||||
@@ -178,7 +178,6 @@ impl Widget for ShortcutsBar<'_> {
|
||||
.bg(theme.bg_base)
|
||||
.remove_modifier(Modifier::BOLD | Modifier::DIM);
|
||||
|
||||
// If pending confirmation, show only "press again to {label}"
|
||||
if let Some(pending) = &self.pending_confirmation {
|
||||
let key_text = pending.shortcut.display();
|
||||
let label = format!("press again to {}", pending.label);
|
||||
@@ -197,7 +196,8 @@ impl Widget for ShortcutsBar<'_> {
|
||||
let action_span = Span::styled(&label, action_style);
|
||||
let action_width = label.width() as u16;
|
||||
buf.set_span(x, area.y, &action_span, action_width);
|
||||
let _ = x + action_width; // suppress unused
|
||||
// suppress unused
|
||||
let _ = x + action_width;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -209,7 +209,6 @@ impl Widget for ShortcutsBar<'_> {
|
||||
|
||||
let mut x = area.x;
|
||||
|
||||
// Build the effective hint list (compact-aware).
|
||||
let effective = compute_effective_hints(self.hints, self.compact.as_ref());
|
||||
|
||||
for (i, hint) in effective.iter().enumerate() {
|
||||
@@ -248,7 +247,6 @@ impl Widget for ShortcutsBar<'_> {
|
||||
x += action_width;
|
||||
}
|
||||
|
||||
// Right-aligned text (team name etc.)
|
||||
if let Some(text) = self.right_text {
|
||||
let right_style = Style::default().fg(theme.gray).bg(theme.bg_base);
|
||||
let display = format!("{text} ");
|
||||
@@ -326,7 +324,8 @@ mod tests {
|
||||
help_hint: Some(help),
|
||||
};
|
||||
let out = compute_effective_hints(&hints, Some(&cfg));
|
||||
assert_eq!(out.len(), 3); // 2 + help
|
||||
// 2 + help
|
||||
assert_eq!(out.len(), 3);
|
||||
assert_eq!(out[0].label, "a");
|
||||
assert_eq!(out[1].label, "b");
|
||||
assert_eq!(out[2].label, "shortcuts");
|
||||
|
||||
@@ -26,10 +26,6 @@ use crate::input::key::KeyShortcut;
|
||||
use crate::views::picker::{PickerConfig, PickerOutcome, PickerState, handle_picker_input};
|
||||
use crate::views::shortcuts_bar::HintItem;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Key for pattern-A inline expand state (`expanded_ids`).
|
||||
///
|
||||
/// Registry rows use [`ExpandKey::Action`]; display-only rows that ship
|
||||
@@ -70,10 +66,6 @@ impl ShortcutsHelpEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal state construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Category display order and labels for the cheatsheet.
|
||||
const CATEGORY_ORDER: &[(Category, &str)] = &[
|
||||
(Category::GettingStarted, "Essentials"),
|
||||
@@ -303,10 +295,6 @@ pub fn build_initial_picker_state(entries: &[ShortcutsHelpEntry]) -> PickerState
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Filter ShortcutsHelp entries by search query.
|
||||
///
|
||||
/// Returns the original-index list of entries that pass the filter.
|
||||
@@ -428,10 +416,6 @@ fn hint_description(h: &HintItem) -> String {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn selected_original_entry<'a>(
|
||||
filtered: &[usize],
|
||||
entries: &'a [ShortcutsHelpEntry],
|
||||
@@ -468,10 +452,6 @@ fn picker_config(non_sel: &[bool]) -> PickerConfig<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Outcome of an input event delivered to the cheatsheet modal.
|
||||
///
|
||||
/// The caller is responsible for mutating `AgentView` state — closing the
|
||||
@@ -980,10 +960,6 @@ pub fn handle_mouse(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal rendering + chrome integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Footer hints painted along the bottom border of the cheatsheet
|
||||
/// modal. Identical visual vocabulary for the agent view and the
|
||||
/// dashboard so muscle memory ports across surfaces.
|
||||
@@ -1413,10 +1389,6 @@ pub fn handle_modal_key(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2100,8 +2072,6 @@ mod tests {
|
||||
assert_eq!(state.selected, 1, "selected should land on first Hint");
|
||||
}
|
||||
|
||||
// ── handle_input tests ───────────────────────────────────────
|
||||
|
||||
fn make_key(code: crossterm::event::KeyCode) -> crossterm::event::KeyEvent {
|
||||
crossterm::event::KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
|
||||
}
|
||||
@@ -2114,7 +2084,7 @@ mod tests {
|
||||
hint("nav", key!('j')),
|
||||
];
|
||||
let mut state = build_initial_picker_state(&entries);
|
||||
state.selected = 0; // select the header
|
||||
state.selected = 0;
|
||||
(entries, state)
|
||||
}
|
||||
|
||||
@@ -2155,7 +2125,7 @@ mod tests {
|
||||
// Pseudo/legacy hints have no action_id — Enter does not close or open detail.
|
||||
let entries = vec![header("Nav", 0, 1), hint("send", key!(Enter))];
|
||||
let mut state = build_initial_picker_state(&entries);
|
||||
state.selected = 1; // select the hint
|
||||
state.selected = 1;
|
||||
let mut mode = browse_mode();
|
||||
let result = handle_input(
|
||||
&make_key(crossterm::event::KeyCode::Enter),
|
||||
@@ -2877,8 +2847,6 @@ mod tests {
|
||||
assert_eq!(state.query, "j", "printables must type in active search");
|
||||
}
|
||||
|
||||
// ── vim_mode tests ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn vim_mode_jk_navigate_without_starting_search() {
|
||||
let _vim_mode = VimModeGuard::set(true);
|
||||
|
||||
@@ -143,10 +143,9 @@ pub fn render_dropdown(
|
||||
content_w
|
||||
};
|
||||
|
||||
// Compute aligned label column width across all items.
|
||||
let label_col_w = compute_label_column_w(items, row_w.saturating_sub(PREFIX_W));
|
||||
|
||||
// Build flat line list (multi-line descriptions produce multiple lines per item).
|
||||
// Multi-line descriptions produce multiple lines per item.
|
||||
let (flat_lines, item_starts) =
|
||||
build_flat_lines(items, selected, hovered, label_col_w, row_w, theme);
|
||||
|
||||
@@ -191,7 +190,6 @@ pub fn render_dropdown(
|
||||
buf.set_line_safe(area.x, y, line, row_w as u16);
|
||||
}
|
||||
|
||||
// ── Scrollbar ───────────────────────────────────────────────────────
|
||||
if needs_scrollbar {
|
||||
// Intersect with the frame buffer so a resize race cannot paint past
|
||||
// `buf.area` (same failure mode as item rows).
|
||||
@@ -307,14 +305,12 @@ fn build_item_lines(
|
||||
let label_w = label.width();
|
||||
let padding = label_col_w.saturating_sub(label_w);
|
||||
|
||||
// Build per-character spans for the label with fuzzy highlight.
|
||||
let label_spans = build_highlighted_spans(&label, &item.indices, normal_style, match_style);
|
||||
|
||||
// Description column indent (prefix + label + gap).
|
||||
let desc_indent = PREFIX_W + label_col_w + LABEL_DESC_GAP;
|
||||
let desc_w = total_w.saturating_sub(desc_indent).max(1);
|
||||
|
||||
// Word-wrap description into lines of `desc_w` width.
|
||||
let desc_lines = if item.description.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -409,7 +405,6 @@ fn simple_word_wrap(text: &str, width: usize) -> Vec<String> {
|
||||
return vec![text.to_string()];
|
||||
}
|
||||
let mut lines = Vec::new();
|
||||
// Normalize: collapse newlines into spaces.
|
||||
let normalized = text.replace('\n', " ");
|
||||
let mut remaining = normalized.as_str();
|
||||
while !remaining.is_empty() {
|
||||
@@ -431,7 +426,6 @@ fn simple_word_wrap(text: &str, width: usize) -> Vec<String> {
|
||||
last_space = Some(i);
|
||||
}
|
||||
}
|
||||
// Prefer word boundary; fall back to hard break at width.
|
||||
last_space.map(|i| i + 1).unwrap_or_else(|| {
|
||||
remaining
|
||||
.char_indices()
|
||||
|
||||
@@ -11,7 +11,6 @@ use crate::theme::Theme;
|
||||
/// Status bar showing context information.
|
||||
///
|
||||
/// Displays: token count, current turn, view mode, etc.
|
||||
/// Respects layout: first 3 cols and last 2 cols are empty.
|
||||
pub struct StatusBar<'a> {
|
||||
/// Left-aligned content (e.g., "Context: 5.2k tokens")
|
||||
pub left: &'a str,
|
||||
@@ -31,13 +30,11 @@ impl<'a> StatusBar<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add center content.
|
||||
pub fn center(mut self, text: &'a str) -> Self {
|
||||
self.center = Some(text);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add right content.
|
||||
pub fn right(mut self, text: &'a str) -> Self {
|
||||
self.right = Some(text);
|
||||
self
|
||||
@@ -52,8 +49,6 @@ impl Widget for StatusBar<'_> {
|
||||
|
||||
let theme = Theme::current();
|
||||
|
||||
// Layout: outer block already has 2-char horizontal padding
|
||||
// No additional margins needed
|
||||
let left_margin = 0u16;
|
||||
let right_margin = 0u16;
|
||||
let content_x = area.x + left_margin;
|
||||
@@ -65,14 +60,11 @@ impl Widget for StatusBar<'_> {
|
||||
|
||||
let style = Style::default().fg(theme.gray).bg(theme.bg_base);
|
||||
|
||||
// Fill background (the whole row)
|
||||
buf.set_style(area, Style::default().bg(theme.bg_base));
|
||||
|
||||
// Left content
|
||||
let left_span = Span::styled(self.left, style);
|
||||
buf.set_span(content_x, area.y, &left_span, content_width);
|
||||
|
||||
// Center content (if fits)
|
||||
if let Some(center) = self.center {
|
||||
let center_width = center.len() as u16;
|
||||
let center_x = content_x + (content_width.saturating_sub(center_width)) / 2;
|
||||
@@ -82,7 +74,6 @@ impl Widget for StatusBar<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
// Right content
|
||||
if let Some(right) = self.right {
|
||||
let right_width = right.len() as u16;
|
||||
let right_x = content_x + content_width.saturating_sub(right_width);
|
||||
|
||||
@@ -24,10 +24,6 @@ use super::list_pane::{
|
||||
};
|
||||
use super::overlay::OverlayState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CatalogEntry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct CatalogEntry {
|
||||
id: u64,
|
||||
label: String,
|
||||
@@ -72,10 +68,6 @@ fn lookup_description<'a>(kind: &str, name: &str, state: &'a BundleState) -> Opt
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SubagentCatalogPane
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_CATALOG_HEIGHT: u16 = 8;
|
||||
const MAX_CATALOG_FRACTION: f32 = 0.15;
|
||||
|
||||
@@ -113,8 +105,6 @@ impl SubagentCatalogPane {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Data sync -----------------------------------------------------------
|
||||
|
||||
pub fn sync_from_bundle(&mut self, state: &BundleState) {
|
||||
self.entries.clear();
|
||||
if !state.has_cache {
|
||||
@@ -172,8 +162,6 @@ impl SubagentCatalogPane {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Visibility ----------------------------------------------------------
|
||||
|
||||
pub fn is_visible(&self) -> bool {
|
||||
self.overlay.visible
|
||||
}
|
||||
@@ -212,8 +200,6 @@ impl SubagentCatalogPane {
|
||||
Some((entry.kind?, &entry.label))
|
||||
}
|
||||
|
||||
// -- Input handling ------------------------------------------------------
|
||||
|
||||
pub fn handle_key(&mut self, key: &KeyEvent) -> bool {
|
||||
if self.entries.is_empty() {
|
||||
return false;
|
||||
@@ -240,8 +226,6 @@ impl SubagentCatalogPane {
|
||||
.handle_mouse_event(kind, col, row, area, &self.entries)
|
||||
}
|
||||
|
||||
// -- Rendering -----------------------------------------------------------
|
||||
|
||||
fn content_area(area: Rect, layout_cfg: &LayoutConfig) -> Rect {
|
||||
let pad_left = HorizontalLayout::ACCENT + layout_cfg.block_pad_left;
|
||||
let pad_right = layout_cfg.block_pad_right;
|
||||
@@ -402,11 +386,13 @@ mod tests {
|
||||
let mut pane = SubagentCatalogPane::new();
|
||||
let state1 = make_state(&["a", "b", "c"], &[], &[]);
|
||||
pane.sync_from_bundle(&state1);
|
||||
assert_eq!(pane.entries.len(), 4); // 1 header + 3
|
||||
// 1 header + 3 items
|
||||
assert_eq!(pane.entries.len(), 4);
|
||||
|
||||
let state2 = make_state(&["x"], &[], &[]);
|
||||
pane.sync_from_bundle(&state2);
|
||||
assert_eq!(pane.entries.len(), 2); // 1 header + 1
|
||||
// 1 header + 1 item
|
||||
assert_eq!(pane.entries.len(), 2);
|
||||
assert_eq!(pane.entries[1].label, "x");
|
||||
}
|
||||
|
||||
@@ -504,11 +490,12 @@ mod tests {
|
||||
let state = make_state(&["researcher"], &["reviewer"], &["default"]);
|
||||
pane.sync_from_bundle(&state);
|
||||
|
||||
assert_eq!(pane.entries[0].kind, None); // header
|
||||
// [0], [2], [4] are group headers.
|
||||
assert_eq!(pane.entries[0].kind, None);
|
||||
assert_eq!(pane.entries[1].kind, Some("persona"));
|
||||
assert_eq!(pane.entries[2].kind, None); // header
|
||||
assert_eq!(pane.entries[2].kind, None);
|
||||
assert_eq!(pane.entries[3].kind, Some("role"));
|
||||
assert_eq!(pane.entries[4].kind, None); // header
|
||||
assert_eq!(pane.entries[4].kind, None);
|
||||
assert_eq!(pane.entries[5].kind, Some("agent"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
// -- accept_ghost ---------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn accept_full_returns_entire_ghost_and_clears() {
|
||||
let mut sc = SuggestionController::new();
|
||||
@@ -96,8 +94,6 @@ fn accept_one_word_progressive() {
|
||||
assert!(!sc.has_ghost());
|
||||
}
|
||||
|
||||
// -- progressive matching -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn progressive_match_trims_matching_char() {
|
||||
let mut sc = SuggestionController::new();
|
||||
@@ -195,8 +191,6 @@ fn progressive_match_empty_suffix_clears() {
|
||||
assert!(!sc.has_ghost());
|
||||
}
|
||||
|
||||
// -- set_ghost / clear_ghost / generation ---------------------------------
|
||||
|
||||
#[test]
|
||||
fn set_ghost_increments_generation() {
|
||||
let mut sc = SuggestionController::new();
|
||||
@@ -232,8 +226,6 @@ fn clear_ghost_resets_all_fields() {
|
||||
assert_eq!(sc.ghost.source, SuggestionSource::None);
|
||||
}
|
||||
|
||||
// -- text_changed ---------------------------------------------------------
|
||||
|
||||
fn enabled_controller() -> SuggestionController {
|
||||
let mut sc = SuggestionController::new();
|
||||
sc.enabled = true;
|
||||
@@ -350,8 +342,6 @@ fn text_changed_increments_generation_on_debounce() {
|
||||
assert!(g2 > g1);
|
||||
}
|
||||
|
||||
// -- on_debounce_expired --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn debounce_expired_matching_generation_returns_true() {
|
||||
let mut sc = enabled_controller();
|
||||
@@ -371,8 +361,6 @@ fn debounce_expired_stale_generation_returns_false() {
|
||||
assert!(!sc.on_debounce_expired(stale_gen));
|
||||
}
|
||||
|
||||
// -- on_suggestions_loaded ------------------------------------------------
|
||||
|
||||
fn make_response(
|
||||
generation: u64,
|
||||
ghost_suffix: Option<&str>,
|
||||
@@ -449,8 +437,6 @@ fn suggestions_loaded_replaces_existing_ghost() {
|
||||
assert_eq!(sc.ghost.source, SuggestionSource::AI);
|
||||
}
|
||||
|
||||
// -- on_suggestions_loaded: dropdown population ----------------------------
|
||||
|
||||
#[test]
|
||||
fn suggestions_loaded_populates_dropdown() {
|
||||
let mut sc = enabled_controller();
|
||||
@@ -553,8 +539,6 @@ fn accept_ghost_closes_dropdown() {
|
||||
assert!(sc.dropdown.items.is_empty());
|
||||
}
|
||||
|
||||
// -- SuggestResponseParsed::from_json -------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_response_with_ghost_and_completions() {
|
||||
let json = serde_json::json!({
|
||||
@@ -774,8 +758,6 @@ fn parse_completion_truncated_flag() {
|
||||
assert!(!malformed.truncated);
|
||||
}
|
||||
|
||||
// -- validated_replace_range -----------------------------------------------
|
||||
|
||||
fn anchored_controller(request_text: &str) -> SuggestionController {
|
||||
let mut sc = enabled_controller();
|
||||
sc.dropdown.request_text = request_text.to_owned();
|
||||
@@ -870,8 +852,6 @@ fn validated_range_mid_char_boundary_rejects() {
|
||||
);
|
||||
}
|
||||
|
||||
// -- common_prefix_fill ------------------------------------------------
|
||||
|
||||
fn span_item(
|
||||
token: &str,
|
||||
range: std::ops::Range<usize>,
|
||||
@@ -992,8 +972,6 @@ fn common_prefix_fill_multibyte_boundary_trim() {
|
||||
assert!(sc.common_prefix_fill("cat caf").is_none());
|
||||
}
|
||||
|
||||
// -- tab_decision --------------------------------------------------------
|
||||
|
||||
/// Anchored controller whose items are current for `request_text` typed
|
||||
/// with the cursor at its end — the state right after a landing.
|
||||
fn decision_controller(request_text: &str) -> SuggestionController {
|
||||
@@ -1164,8 +1142,6 @@ fn tab_decision_lcp_with_complete_escape_fills() {
|
||||
);
|
||||
}
|
||||
|
||||
// -- accept_completion: splice resolution ---------------------------------
|
||||
|
||||
fn accept_controller(request_text: &str, items: Vec<CompletionItemParsed>) -> SuggestionController {
|
||||
let mut sc = anchored_controller(request_text);
|
||||
sc.dropdown.items = items;
|
||||
@@ -1249,8 +1225,6 @@ fn accept_completion_stale_range_resolves_stale() {
|
||||
assert!(sc.dropdown.items.is_empty());
|
||||
}
|
||||
|
||||
// -- accept_completion / async-race invalidation ---------------------------
|
||||
|
||||
fn item(text: &str, source: SuggestionSource) -> CompletionItemParsed {
|
||||
CompletionItemParsed {
|
||||
display: text.to_owned(),
|
||||
@@ -1374,8 +1348,6 @@ fn non_matching_edit_tears_down_ghostless_dropdown() {
|
||||
assert!(sc.dropdown.items.is_empty());
|
||||
}
|
||||
|
||||
// -- always-on Tab completion (no KIGI_SUGGESTIONS) ------------------------
|
||||
|
||||
/// Tab-triggered fetches work with the as-you-type pipeline OFF — the
|
||||
/// arming bumps the generation and the landing response still installs
|
||||
/// its dropdown items.
|
||||
@@ -1490,8 +1462,6 @@ fn disabled_controller_ignores_response_ghost() {
|
||||
assert_eq!(sc.dropdown.items.len(), 1);
|
||||
}
|
||||
|
||||
// -- SuggestionSource::parse_source ---------------------------------------
|
||||
|
||||
#[test]
|
||||
fn source_parse_known_values() {
|
||||
assert_eq!(
|
||||
@@ -1518,23 +1488,18 @@ fn source_parse_unknown_returns_none() {
|
||||
);
|
||||
}
|
||||
|
||||
// -- end-to-end pipeline: text_changed → debounce → loaded ----------------
|
||||
|
||||
#[test]
|
||||
fn full_pipeline_text_change_debounce_load() {
|
||||
let mut sc = enabled_controller();
|
||||
|
||||
// 1. User types "g"
|
||||
let action = sc.text_changed("g", false, false);
|
||||
let current_gen = match action {
|
||||
Some(SuggestionAction::Debounce { generation }) => generation,
|
||||
other => panic!("expected Debounce, got {other:?}"),
|
||||
};
|
||||
|
||||
// 2. Debounce expires — generation still matches
|
||||
assert!(sc.on_debounce_expired(current_gen));
|
||||
|
||||
// 3. Response arrives with matching generation
|
||||
sc.set_last_request_text("g");
|
||||
sc.on_suggestions_loaded(
|
||||
make_response(current_gen, Some("it commit"), SuggestionSource::History),
|
||||
@@ -1543,7 +1508,6 @@ fn full_pipeline_text_change_debounce_load() {
|
||||
);
|
||||
assert_eq!(sc.ghost_text(), Some("it commit"));
|
||||
|
||||
// 4. User types "i" — progressive match trims ghost
|
||||
let action = sc.text_changed("gi", false, false);
|
||||
assert_eq!(action, Some(SuggestionAction::Matched));
|
||||
assert_eq!(sc.ghost_text(), Some("t commit"));
|
||||
@@ -1553,14 +1517,12 @@ fn full_pipeline_text_change_debounce_load() {
|
||||
fn rapid_typing_discards_stale_debounce() {
|
||||
let mut sc = enabled_controller();
|
||||
|
||||
// User types "g"
|
||||
let action1 = sc.text_changed("g", false, false);
|
||||
let gen1 = match action1 {
|
||||
Some(SuggestionAction::Debounce { generation }) => generation,
|
||||
_ => panic!("expected Debounce"),
|
||||
};
|
||||
|
||||
// User types "gi" before debounce fires
|
||||
let action2 = sc.text_changed("gi", false, false);
|
||||
let gen2 = match action2 {
|
||||
Some(SuggestionAction::Debounce { generation }) => generation,
|
||||
@@ -1578,10 +1540,8 @@ fn rapid_typing_discards_stale_debounce() {
|
||||
fn slash_during_pending_debounce_suppresses() {
|
||||
let mut sc = enabled_controller();
|
||||
|
||||
// User types "git"
|
||||
sc.text_changed("git", false, false);
|
||||
|
||||
// User types "/" — slash becomes active
|
||||
let result = sc.text_changed("/", true, false);
|
||||
assert!(result.is_none());
|
||||
assert!(!sc.has_ghost());
|
||||
|
||||
@@ -31,16 +31,8 @@ use super::list_pane::{
|
||||
};
|
||||
use super::overlay::OverlayState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spinner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SPINNER_DIVISOR: u64 = 4;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell command syntax highlighting (used by other modules too)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Highlight a shell command string into styled spans.
|
||||
///
|
||||
/// Uses syntect with the best available grammar for the platform: tries
|
||||
@@ -116,10 +108,6 @@ fn dim_spans(spans: &[Span<'static>], blend_factor: f32) -> Vec<Span<'static>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Line count badge formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format an stdout line count as a compact `(N)` badge with SI scaling.
|
||||
///
|
||||
/// Returns an empty string for `0` so callers can treat that as "no badge".
|
||||
@@ -160,10 +148,6 @@ fn format_line_count_badge(count: usize, truncated: bool) -> String {
|
||||
format!("({}M{suffix})", count / 1_000_000)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskEntryId — identifies which entry a button belongs to
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TaskEntryId {
|
||||
BgTask(String),
|
||||
@@ -203,10 +187,6 @@ impl GroupKind {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskEntry — unified entry for the combined list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TaskEntry {
|
||||
BgTask {
|
||||
@@ -560,7 +540,6 @@ impl TaskEntry {
|
||||
TaskEntry::Header { group, styled }
|
||||
}
|
||||
|
||||
/// Which collapsible group this entry belongs to.
|
||||
fn group_kind(&self) -> GroupKind {
|
||||
match self {
|
||||
TaskEntry::Agent { .. } => GroupKind::Subagents,
|
||||
@@ -652,10 +631,6 @@ impl ListItem for TaskEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TasksPane
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Temporary data for the overlay pass (avoids borrowing entries during mutation).
|
||||
enum OverlayEntryData {
|
||||
BgTask(String),
|
||||
@@ -793,8 +768,6 @@ impl TasksPane {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Data sync -----------------------------------------------------------
|
||||
|
||||
/// Sync entries from bg tasks, subagent sessions, and scheduled tasks.
|
||||
pub fn sync(
|
||||
&mut self,
|
||||
@@ -817,7 +790,6 @@ impl TasksPane {
|
||||
|
||||
self.items.clear();
|
||||
|
||||
// Add bg task items
|
||||
for task in bg_tasks.values() {
|
||||
if self.show_done || task.status == BgTaskStatus::Running {
|
||||
self.items
|
||||
@@ -825,14 +797,13 @@ impl TasksPane {
|
||||
}
|
||||
}
|
||||
|
||||
// Add subagent items
|
||||
for info in subagents.values() {
|
||||
if self.show_done || info.is_running() {
|
||||
self.items.push(TaskEntry::from_subagent(info));
|
||||
}
|
||||
}
|
||||
|
||||
// Add scheduled task items (always "running")
|
||||
// Scheduled tasks have no "done" state, so they're never filtered by `show_done`.
|
||||
for info in scheduled.values() {
|
||||
self.items.push(TaskEntry::from_scheduled(
|
||||
info,
|
||||
@@ -898,7 +869,6 @@ impl TasksPane {
|
||||
.retain(|g| present[g.order() as usize]);
|
||||
}
|
||||
|
||||
// Build the display list: group headers + (non-collapsed) items.
|
||||
self.rebuild_entries();
|
||||
|
||||
// Count running for edge detection. Replay-restored bg tasks are
|
||||
@@ -1016,8 +986,6 @@ impl TasksPane {
|
||||
+ scheduled.len()
|
||||
}
|
||||
|
||||
// -- Visibility ----------------------------------------------------------
|
||||
|
||||
pub fn show_done(&self) -> bool {
|
||||
self.show_done
|
||||
}
|
||||
@@ -1057,8 +1025,6 @@ impl TasksPane {
|
||||
(count as u16).min(max).max(1) + bar
|
||||
}
|
||||
|
||||
// -- Tick ----------------------------------------------------------------
|
||||
|
||||
pub fn tick(&mut self) -> bool {
|
||||
self.tick += 1;
|
||||
self.entries.iter().any(|e| e.is_running())
|
||||
@@ -1072,8 +1038,6 @@ impl TasksPane {
|
||||
self.entries.iter().any(|e| e.is_running())
|
||||
}
|
||||
|
||||
// -- Input handling ------------------------------------------------------
|
||||
|
||||
pub fn handle_key(&mut self, key: &KeyEvent) -> bool {
|
||||
if crate::key!('h').matches(key) && self.list_state.input_mode().is_none() {
|
||||
self.show_done = !self.show_done;
|
||||
@@ -1104,13 +1068,11 @@ impl TasksPane {
|
||||
.handle_mouse_event(kind, col, row, area, &self.entries)
|
||||
}
|
||||
|
||||
/// Get the selected entry (if any).
|
||||
pub fn selected_entry(&self) -> Option<&TaskEntry> {
|
||||
let sel = self.list_state.selected_index()?;
|
||||
self.entries.get(sel)
|
||||
}
|
||||
|
||||
/// Get the task_id if the selected entry is a BgTask.
|
||||
pub fn selected_task_id(&self) -> Option<&str> {
|
||||
match self.selected_entry()? {
|
||||
TaskEntry::BgTask { task_id, .. } => Some(task_id),
|
||||
@@ -1118,7 +1080,6 @@ impl TasksPane {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the subagent_id if the selected entry is an Agent.
|
||||
pub fn selected_subagent_id(&self) -> Option<&str> {
|
||||
match self.selected_entry()? {
|
||||
TaskEntry::Agent { subagent_id, .. } => Some(subagent_id),
|
||||
@@ -1126,7 +1087,6 @@ impl TasksPane {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the child_session_id if the selected entry is an Agent.
|
||||
pub fn selected_child_session_id(&self) -> Option<&str> {
|
||||
match self.selected_entry()? {
|
||||
TaskEntry::Agent {
|
||||
@@ -1136,8 +1096,6 @@ impl TasksPane {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Rendering -----------------------------------------------------------
|
||||
|
||||
fn content_area(area: Rect, layout_cfg: &LayoutConfig) -> Rect {
|
||||
let pad_left = HorizontalLayout::ACCENT + layout_cfg.block_pad_left;
|
||||
let pad_right = layout_cfg.block_pad_right;
|
||||
@@ -1405,7 +1363,6 @@ impl TasksPane {
|
||||
};
|
||||
let lines_w = lines_text.width() as u16;
|
||||
|
||||
// Clear overlay area to prevent label text bleeding through.
|
||||
let right_text_w = right_text.width() as u16;
|
||||
let bg_kill_w: u16 = if task.status == BgTaskStatus::Running {
|
||||
3
|
||||
@@ -1532,7 +1489,6 @@ impl TasksPane {
|
||||
|
||||
buf.set_span(area.x, y, &Span::styled(icon, icon_style), 2);
|
||||
|
||||
// Clear overlay area to prevent label text bleeding through.
|
||||
let badge = format_context_badge(info);
|
||||
let model_text = info
|
||||
.model
|
||||
@@ -2251,9 +2207,9 @@ mod tests {
|
||||
#[test]
|
||||
fn render_loop_row_truncates_before_kill_button() {
|
||||
// A non-scrollable loop row with a long prompt must truncate before
|
||||
// the `[✗]` kill button — nothing may render to its right. Regression:
|
||||
// the scrollbar-padding column used to be filled with label text when
|
||||
// the list wasn't scrollable, bleeding one cell past `[✗]`.
|
||||
// the `[✗]` kill button — nothing may render to its right. Regression
|
||||
// guard: on a non-scrollable list the scrollbar-padding column must
|
||||
// stay empty, or label text bleeds one cell past `[✗]`.
|
||||
let mut pane = TasksPane::new();
|
||||
pane.overlay.show();
|
||||
|
||||
@@ -2458,7 +2414,8 @@ mod tests {
|
||||
bg_tasks.insert("m1".into(), mon);
|
||||
|
||||
let mut subagents = HashMap::new();
|
||||
subagents.insert("cs-1".into(), make_info()); // running subagent
|
||||
// running subagent
|
||||
subagents.insert("cs-1".into(), make_info());
|
||||
|
||||
pane.sync(
|
||||
&bg_tasks,
|
||||
@@ -2585,7 +2542,8 @@ mod tests {
|
||||
let mut bg_tasks = std::collections::BTreeMap::new();
|
||||
bg_tasks.insert("t1".into(), make_bg_task("t1", "ls", BgTaskStatus::Running));
|
||||
let mut subagents = HashMap::new();
|
||||
subagents.insert("cs-1".into(), make_info()); // running subagent
|
||||
// running subagent
|
||||
subagents.insert("cs-1".into(), make_info());
|
||||
|
||||
pane.sync(
|
||||
&bg_tasks,
|
||||
@@ -3026,7 +2984,8 @@ mod tests {
|
||||
fn scheduled_unicode_prompt_safe_no_panic() {
|
||||
let mut pane = TasksPane::new();
|
||||
let mut scheduled = HashMap::new();
|
||||
let unicode_prompt = "测试emoji🚀".repeat(20); // multi-byte >60 bytes
|
||||
// multi-byte >60 bytes
|
||||
let unicode_prompt = "测试emoji🚀".repeat(20);
|
||||
scheduled.insert(
|
||||
"uni".into(),
|
||||
make_scheduled_info("uni", "every 1s", &unicode_prompt, None),
|
||||
|
||||
@@ -9,16 +9,10 @@ use ratatui::text::{Line, Span};
|
||||
|
||||
use super::list_pane::ListItem;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TodoPaneStyle — per-status colors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Visual style for each todo status.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TodoStatusStyle {
|
||||
/// Color for the status icon.
|
||||
pub icon_fg: Color,
|
||||
/// Style for the content text.
|
||||
pub text_style: Style,
|
||||
}
|
||||
|
||||
@@ -61,10 +55,6 @@ impl Default for TodoPaneStyle {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TodoListEntry — ListItem wrapper around TodoItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A `TodoItem` wrapped for display in a `ListPane`.
|
||||
///
|
||||
/// Caches the styled `Line` for `content()` and generates a status-icon
|
||||
@@ -73,16 +63,13 @@ impl Default for TodoPaneStyle {
|
||||
pub struct TodoListEntry {
|
||||
/// Unique ID (index in the todo list, or a stable ID from the model).
|
||||
pub id: u64,
|
||||
/// The canonical todo item.
|
||||
pub item: TodoItem,
|
||||
/// Cached styled content line.
|
||||
styled: Line<'static>,
|
||||
/// The style to use for this entry's status.
|
||||
status_style: TodoStatusStyle,
|
||||
}
|
||||
|
||||
impl TodoListEntry {
|
||||
/// Create a new entry from a `TodoItem`.
|
||||
pub fn new(id: u64, item: TodoItem, style: &TodoPaneStyle) -> Self {
|
||||
let status_style = match item.status {
|
||||
TodoStatus::Pending => style.pending,
|
||||
@@ -99,7 +86,6 @@ impl TodoListEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Status icon for the current status.
|
||||
fn icon(&self) -> &'static str {
|
||||
match self.item.status {
|
||||
TodoStatus::Pending => "□",
|
||||
@@ -132,10 +118,6 @@ impl ListItem for TodoListEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TodoPane — self-contained pane owning items, state, and rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, MouseEventKind};
|
||||
@@ -149,10 +131,6 @@ use crate::theme::ThemeKind;
|
||||
use super::list_pane::{ListPane, ListPaneConfig, ListPaneState, ListPaneStyle, WrapMode};
|
||||
use super::overlay::OverlayState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TodoCounts — aggregate status counts for the badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Counts of todo items by status.
|
||||
///
|
||||
/// Used by the status bar badge to show plan progress at a glance.
|
||||
@@ -222,7 +200,8 @@ pub struct TodoPane {
|
||||
pub overlay: OverlayState,
|
||||
/// Previous counts snapshot for flash-on-change detection.
|
||||
prev_counts: TodoCounts,
|
||||
/// When the badge flash animation expires (500ms after a count change).
|
||||
/// When the badge flash animation expires (`BADGE_FLASH_DURATION` after
|
||||
/// a count change).
|
||||
badge_flash_until: Option<Instant>,
|
||||
/// Last theme kind seen — used to detect theme switches and restyle.
|
||||
last_theme: ThemeKind,
|
||||
@@ -264,9 +243,6 @@ impl TodoPane {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Data management -----------------------------------------------------
|
||||
|
||||
/// Read-only access to the current todo items.
|
||||
pub fn todos(&self) -> &[TodoItem] {
|
||||
&self.todos
|
||||
}
|
||||
@@ -287,7 +263,6 @@ impl TodoPane {
|
||||
self.todos = items;
|
||||
}
|
||||
|
||||
/// Compute status counts from a list of items.
|
||||
fn compute_counts(items: &[TodoItem]) -> TodoCounts {
|
||||
let mut c = TodoCounts::default();
|
||||
for item in items {
|
||||
@@ -306,7 +281,6 @@ impl TodoPane {
|
||||
self.prev_counts
|
||||
}
|
||||
|
||||
/// Whether the badge flash animation is currently active.
|
||||
pub fn badge_flash_active(&self) -> bool {
|
||||
self.badge_flash_until.is_some_and(|t| Instant::now() < t)
|
||||
}
|
||||
@@ -414,8 +388,6 @@ impl TodoPane {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Input handling ------------------------------------------------------
|
||||
|
||||
/// Handle a key event when the todo pane is focused.
|
||||
///
|
||||
/// Returns `true` if the event was consumed.
|
||||
@@ -457,8 +429,6 @@ impl TodoPane {
|
||||
.handle_mouse_event(kind, col, row, area, &self.entries)
|
||||
}
|
||||
|
||||
// -- Rendering -----------------------------------------------------------
|
||||
|
||||
/// Compute the inner content area with horizontal padding matching
|
||||
/// the scrollback's `HorizontalLayout` (accent + block_pad_left on
|
||||
/// the left, block_pad_right on the right).
|
||||
@@ -497,7 +467,6 @@ impl TodoPane {
|
||||
self.rebuild_entries();
|
||||
let inner = Self::content_area(area, layout_cfg);
|
||||
if self.entries.is_empty() {
|
||||
// Empty state: placeholder message in muted style.
|
||||
if inner.height > 0 && inner.width > 0 {
|
||||
let msg = empty_placeholder_message(self.todos.is_empty(), self.counts());
|
||||
let theme = crate::theme::Theme::current();
|
||||
|
||||
@@ -62,10 +62,6 @@ pub(crate) fn pending_diamond_color(theme: &Theme, accent: Color, tick: u64) ->
|
||||
.unwrap_or(accent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Output from rendering the turn status line.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TurnStatusOutput {
|
||||
@@ -247,7 +243,6 @@ pub fn render_turn_status(
|
||||
// Special case: drain is blocked (user editing front prompt, agent idle).
|
||||
// No cancel button in this state.
|
||||
if drain_blocked && state.is_idle() {
|
||||
// Pulsing diamond in accent_user, blending toward bg.
|
||||
let diamond_color = pending_diamond_color(&theme, theme.accent_user, tick);
|
||||
let spans = vec![
|
||||
Span::styled(
|
||||
@@ -287,17 +282,13 @@ pub fn render_turn_status(
|
||||
return TurnStatusOutput::default();
|
||||
}
|
||||
|
||||
// Determine if cancel button should be shown.
|
||||
// Show when: TurnRunning or CommandRunning.
|
||||
// Hide when: Idle, Cancelling (already cancelling), or a keyboard-only host
|
||||
// (no clickable buttons — see `buttons`).
|
||||
// Hidden for a keyboard-only host (no clickable buttons — see `buttons`).
|
||||
let show_cancel = show_buttons
|
||||
&& matches!(
|
||||
state,
|
||||
AgentState::TurnRunning | AgentState::CommandRunning { .. }
|
||||
);
|
||||
|
||||
// ── Compute activity style and label ──
|
||||
let (activity_style, label, is_tool) =
|
||||
compute_activity(&theme, state, activity, is_bash_turn, goal_verifying);
|
||||
|
||||
@@ -306,7 +297,7 @@ pub fn render_turn_status(
|
||||
return TurnStatusOutput::default();
|
||||
}
|
||||
|
||||
// ── Build right-aligned content first (to know how much space is left) ──
|
||||
// Built first so its width is known before computing space left for the label.
|
||||
// Format: `1m20s` or `1m20s ⇣12k` (with tokens).
|
||||
let turn_timer_str = match (turn_elapsed, total_tokens) {
|
||||
(Some(d), Some(tokens)) if tokens > 0 => {
|
||||
@@ -349,7 +340,6 @@ pub fn render_turn_status(
|
||||
|
||||
let right_width = turn_timer_width + bg_width + cancel_width;
|
||||
|
||||
// ── Build components ──
|
||||
// While a tool is blocked on a permission prompt or `ask_user_question`,
|
||||
// swap the spinning moon for a pulsing `◆`. Same animation shape the
|
||||
// drain-blocked and plan-approval indicators already use, so every
|
||||
@@ -412,7 +402,6 @@ pub fn render_turn_status(
|
||||
.saturating_sub(min_gap)
|
||||
.saturating_sub(right_width);
|
||||
|
||||
// ── Render left side: spinner + label (truncated) + phase_timer + queued_hint ──
|
||||
let mut left_spans: Vec<Span<'static>> = Vec::with_capacity(5);
|
||||
|
||||
// Spinner color: usually inherits the activity color (green for tools,
|
||||
@@ -429,7 +418,6 @@ pub fn render_turn_status(
|
||||
};
|
||||
left_spans.push(Span::styled(spinner_str, spinner_style));
|
||||
|
||||
// Activity label (potentially truncated)
|
||||
let mut queued_hint: Option<Span<'static>> = None;
|
||||
if is_tool {
|
||||
if let Some(TurnActivity::ToolRunning { title, description }) = activity {
|
||||
@@ -516,7 +504,6 @@ pub fn render_turn_status(
|
||||
}
|
||||
}
|
||||
|
||||
// Phase timer (gray, never truncates)
|
||||
if !phase_timer_str.is_empty() {
|
||||
left_spans.push(Span::styled(phase_timer_str, timer_style));
|
||||
}
|
||||
@@ -526,11 +513,9 @@ pub fn render_turn_status(
|
||||
left_spans.push(hint);
|
||||
}
|
||||
|
||||
// Render left side
|
||||
let left_line = Line::from(left_spans);
|
||||
buf.set_line(area.x, area.y, &left_line, area.width);
|
||||
|
||||
// ── Render right side: turn_timer + bg + cancel ──
|
||||
let right_start_x = area.x + area.width.saturating_sub(right_width as u16);
|
||||
|
||||
// Helper: build a fully-specified right-side style (fg + bg + clear mods).
|
||||
@@ -541,7 +526,6 @@ pub fn render_turn_status(
|
||||
.remove_modifier(Modifier::all())
|
||||
};
|
||||
|
||||
// Turn timer (gray)
|
||||
let mut x = right_start_x;
|
||||
if !turn_timer_str.is_empty() {
|
||||
let span = Span::styled(turn_timer_str.clone(), timer_style);
|
||||
@@ -772,24 +756,21 @@ fn format_tokens_short(tokens: u64) -> String {
|
||||
if tokens < 1000 {
|
||||
format!("{tokens}")
|
||||
} else if tokens < 100_000 {
|
||||
// 1k-99.9k: show one or two decimals for precision
|
||||
let k = tokens as f64 / 1000.0;
|
||||
if tokens < 10_000 {
|
||||
format!("{k:.2}k") // 1.23k
|
||||
format!("{k:.2}k")
|
||||
} else {
|
||||
format!("{k:.1}k") // 10.1k
|
||||
format!("{k:.1}k")
|
||||
}
|
||||
} else if tokens < 1_000_000 {
|
||||
// 100k-999k: whole thousands
|
||||
let k = tokens / 1000;
|
||||
format!("{k}k")
|
||||
} else {
|
||||
// 1m+: show with decimal
|
||||
let m = tokens as f64 / 1_000_000.0;
|
||||
if tokens < 10_000_000 {
|
||||
format!("{m:.2}m") // 1.23m
|
||||
format!("{m:.2}m")
|
||||
} else {
|
||||
format!("{m:.1}m") // 10.1m
|
||||
format!("{m:.1}m")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1410,7 +1391,8 @@ mod tests {
|
||||
assert_eq!(format_tokens_short(1230), "1.23k");
|
||||
assert_eq!(format_tokens_short(1500), "1.50k");
|
||||
assert_eq!(format_tokens_short(9990), "9.99k");
|
||||
assert_eq!(format_tokens_short(9999), "10.00k"); // rounds up
|
||||
// rounds up
|
||||
assert_eq!(format_tokens_short(9999), "10.00k");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1418,7 +1400,8 @@ mod tests {
|
||||
assert_eq!(format_tokens_short(10000), "10.0k");
|
||||
assert_eq!(format_tokens_short(10100), "10.1k");
|
||||
assert_eq!(format_tokens_short(12345), "12.3k");
|
||||
assert_eq!(format_tokens_short(99999), "100.0k"); // rounds up
|
||||
// rounds up
|
||||
assert_eq!(format_tokens_short(99999), "100.0k");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1433,7 +1416,8 @@ mod tests {
|
||||
fn format_tokens_millions() {
|
||||
assert_eq!(format_tokens_short(1_000_000), "1.00m");
|
||||
assert_eq!(format_tokens_short(1_230_000), "1.23m");
|
||||
assert_eq!(format_tokens_short(9_999_000), "10.00m"); // rounds
|
||||
// rounds
|
||||
assert_eq!(format_tokens_short(9_999_000), "10.00m");
|
||||
assert_eq!(format_tokens_short(10_000_000), "10.0m");
|
||||
assert_eq!(format_tokens_short(10_100_000), "10.1m");
|
||||
}
|
||||
|
||||
@@ -119,7 +119,8 @@ pub(super) fn compute_hero_box(
|
||||
Constraint::Length(gap_after_error),
|
||||
Constraint::Length(error_height),
|
||||
Constraint::Length(hero_box_height),
|
||||
Constraint::Min(1), // flex gap
|
||||
// flex gap
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(tip_height),
|
||||
Constraint::Length(tip_gap),
|
||||
Constraint::Length(PROMPT_HEIGHT),
|
||||
|
||||
@@ -59,14 +59,22 @@ const RING_INNER_SQ: f32 = 0.82;
|
||||
/// on the dark limb it is drawn in the same gray, which reads as a faint
|
||||
/// light patch against the empty limb.
|
||||
const MARIA: &[(f32, f32, f32)] = &[
|
||||
(-0.40, -0.42, 0.018), // Imbrium
|
||||
(0.12, -0.50, 0.008), // Serenitatis
|
||||
(0.40, -0.22, 0.012), // Tranquillitatis
|
||||
(0.55, 0.20, 0.005), // Fecunditatis
|
||||
(0.62, -0.42, 0.004), // Crisium
|
||||
(-0.58, 0.05, 0.010), // Procellarum
|
||||
(-0.28, 0.40, 0.006), // Nubium
|
||||
(0.08, 0.12, 0.004), // Vaporum
|
||||
// Imbrium
|
||||
(-0.40, -0.42, 0.018),
|
||||
// Serenitatis
|
||||
(0.12, -0.50, 0.008),
|
||||
// Tranquillitatis
|
||||
(0.40, -0.22, 0.012),
|
||||
// Fecunditatis
|
||||
(0.55, 0.20, 0.005),
|
||||
// Crisium
|
||||
(0.62, -0.42, 0.004),
|
||||
// Procellarum
|
||||
(-0.58, 0.05, 0.010),
|
||||
// Nubium
|
||||
(-0.28, 0.40, 0.006),
|
||||
// Vaporum
|
||||
(0.08, 0.12, 0.004),
|
||||
];
|
||||
|
||||
fn in_mare(dx: f32, dy: f32) -> bool {
|
||||
|
||||
@@ -73,9 +73,11 @@ pub fn render_menu(
|
||||
let mut off = scroll.min(total - visible);
|
||||
if let Some(sel) = selected {
|
||||
if sel < off {
|
||||
off = sel; // scroll up just enough
|
||||
// scroll up just enough
|
||||
off = sel;
|
||||
} else if sel >= off + visible {
|
||||
off = sel + 1 - visible; // scroll down just enough
|
||||
// scroll down just enough
|
||||
off = sel + 1 - visible;
|
||||
}
|
||||
}
|
||||
off
|
||||
|
||||
@@ -202,7 +202,8 @@ impl WelcomeLayout {
|
||||
let gap_after_logo = if error_height > 0 { 1 } else { 0 };
|
||||
let tip_gap = if tip_height > 0 { 1u16 } else { 0 };
|
||||
let fixed_below = Self::fixed_below(tip_height);
|
||||
let fixed_above = logo_rows + 1 + gap_after_logo + error_height; // +1 for gap after logo
|
||||
// +1 for gap after logo
|
||||
let fixed_above = logo_rows + 1 + gap_after_logo + error_height;
|
||||
// Compute top_pad using the *default* menu height (4 items = 7 rows) so
|
||||
// the logo position stays constant regardless of picker/focus state.
|
||||
let top_pad = if compact {
|
||||
@@ -231,7 +232,8 @@ impl WelcomeLayout {
|
||||
let [_, logo, _, _, error, menu, _, tip, _, prompt, _, version] = Layout::vertical([
|
||||
Constraint::Length(top_pad),
|
||||
Constraint::Length(logo_rows),
|
||||
Constraint::Length(logo_gap), // gap after logo
|
||||
// gap after logo
|
||||
Constraint::Length(logo_gap),
|
||||
Constraint::Length(gap_after_logo),
|
||||
Constraint::Length(error_height),
|
||||
Constraint::Length(menu_height),
|
||||
@@ -240,7 +242,8 @@ impl WelcomeLayout {
|
||||
Constraint::Length(tip_gap),
|
||||
Constraint::Length(PROMPT_HEIGHT),
|
||||
Constraint::Length(VERSION_GAP),
|
||||
Constraint::Length(1), // version
|
||||
// version
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(content_area);
|
||||
Self {
|
||||
@@ -867,7 +870,8 @@ fn auth_copy_line(theme: &Theme) -> Line<'static> {
|
||||
/// Number of physical rows the header + blank occupy before the copy line.
|
||||
fn auth_copy_preceding_rows(header: &str, inner_width: u16) -> u16 {
|
||||
let header_rows = (header.len() as u16).div_ceil(inner_width);
|
||||
header_rows + 1 // header + blank
|
||||
// header + blank
|
||||
header_rows + 1
|
||||
}
|
||||
|
||||
/// Number of physical rows the copy line occupies when wrapped.
|
||||
@@ -955,7 +959,8 @@ fn render_raw_url_mode(
|
||||
let url_lines = auth_url
|
||||
.map(|u| (u.len() as u16).div_ceil(full_width))
|
||||
.unwrap_or(0);
|
||||
let msg_height = 1 + 1 + url_lines; // hint + blank + URL
|
||||
// hint + blank + URL
|
||||
let msg_height = 1 + 1 + url_lines;
|
||||
let [_, logo_area, _, msg_area, _, hint_area, _] = Layout::vertical([
|
||||
Constraint::Length(top_pad),
|
||||
Constraint::Length(logo_line_count),
|
||||
@@ -969,7 +974,6 @@ fn render_raw_url_mode(
|
||||
|
||||
render_logo(logo_area, buf, theme, content_area.height);
|
||||
|
||||
// Render hint above the URL.
|
||||
let hint = Line::from(Span::styled(
|
||||
"Select the URL below with your mouse and copy manually.",
|
||||
Style::default().fg(theme.gray),
|
||||
@@ -993,7 +997,8 @@ fn render_raw_url_mode(
|
||||
// inject leading spaces into the selection).
|
||||
if let Some(url) = auth_url {
|
||||
let url_style = Style::default().fg(theme.accent_user);
|
||||
let url_y = msg_area.y + 2; // after hint + blank
|
||||
// after hint + blank
|
||||
let url_y = msg_area.y + 2;
|
||||
// Control characters are skipped below to prevent terminal escape
|
||||
// injection, so measure the URL without them.
|
||||
let url_len = url.chars().filter(|c| !c.is_control()).count() as u16;
|
||||
@@ -1031,7 +1036,8 @@ fn render_raw_url_mode(
|
||||
let hints = Line::from(hint_spans).alignment(Alignment::Center);
|
||||
Paragraph::new(hints).render(hint_area, buf);
|
||||
|
||||
(None, None) // no click rects — mouse capture is disabled
|
||||
// no click rects — mouse capture is disabled
|
||||
(None, None)
|
||||
}
|
||||
|
||||
/// Which "browser opened, now waiting" arm to render; owns the header,
|
||||
@@ -1081,7 +1087,8 @@ fn render_browser_status_arm(
|
||||
let header_rows = (header.len() as u16).div_ceil(inner_width);
|
||||
let code_extra = if user_code.is_some() {
|
||||
let caption_rows = (DEVICE_CODE_CAPTION.len() as u16).div_ceil(inner_width);
|
||||
1 + 1 + 1 + caption_rows // blank + code + blank + caption
|
||||
// blank + code + blank + caption
|
||||
1 + 1 + 1 + caption_rows
|
||||
} else {
|
||||
0
|
||||
};
|
||||
@@ -1090,15 +1097,20 @@ fn render_browser_status_arm(
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let msg_height = header_rows + code_extra + copy_extra + 1 + 1; // blank + waiting
|
||||
// blank + waiting
|
||||
let msg_height = header_rows + code_extra + copy_extra + 1 + 1;
|
||||
|
||||
let [_, logo_area, _, msg_area, _, hint_area, _] = Layout::vertical([
|
||||
Constraint::Length(top_pad),
|
||||
Constraint::Length(logo_line_count),
|
||||
Constraint::Length(2), // gap
|
||||
Constraint::Length(msg_height), // status message
|
||||
Constraint::Min(1), // gap
|
||||
Constraint::Length(1), // hints
|
||||
// gap
|
||||
Constraint::Length(2),
|
||||
// status message
|
||||
Constraint::Length(msg_height),
|
||||
// gap
|
||||
Constraint::Min(1),
|
||||
// hints
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(content_area);
|
||||
@@ -1195,19 +1207,24 @@ fn render_welcome_authenticating(
|
||||
let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([
|
||||
Constraint::Length(top_pad),
|
||||
Constraint::Length(logo_line_count),
|
||||
Constraint::Length(1), // gap
|
||||
Constraint::Length(msg_height), // instruction + copy prompt
|
||||
Constraint::Min(1), // gap
|
||||
Constraint::Length(5), // prompt box
|
||||
Constraint::Length(1), // gap
|
||||
Constraint::Length(1), // hints
|
||||
// gap
|
||||
Constraint::Length(1),
|
||||
// instruction + copy prompt
|
||||
Constraint::Length(msg_height),
|
||||
// gap
|
||||
Constraint::Min(1),
|
||||
// prompt box
|
||||
Constraint::Length(5),
|
||||
// gap
|
||||
Constraint::Length(1),
|
||||
// hints
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(content_area);
|
||||
|
||||
render_logo(logo_area, buf, theme, content_area.height);
|
||||
|
||||
// Instruction text
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
if auth_url.is_some() {
|
||||
lines.push(
|
||||
@@ -1286,12 +1303,18 @@ fn render_welcome_authenticating(
|
||||
let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([
|
||||
Constraint::Length(top_pad),
|
||||
Constraint::Length(logo_line_count),
|
||||
Constraint::Length(1), // gap
|
||||
Constraint::Length(msg_height), // instruction
|
||||
Constraint::Min(1), // gap
|
||||
Constraint::Length(5), // prompt box
|
||||
Constraint::Length(1), // gap
|
||||
Constraint::Length(1), // hints
|
||||
// gap
|
||||
Constraint::Length(1),
|
||||
// instruction
|
||||
Constraint::Length(msg_height),
|
||||
// gap
|
||||
Constraint::Min(1),
|
||||
// prompt box
|
||||
Constraint::Length(5),
|
||||
// gap
|
||||
Constraint::Length(1),
|
||||
// hints
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(content_area);
|
||||
@@ -1430,13 +1453,15 @@ fn render_welcome_done(
|
||||
let hint_height = p.startup_warnings.first().map_or(0u16, |w| {
|
||||
let msg_lines = w.message.lines().count() as u16;
|
||||
let action_line = if w.action.is_some() { 1 } else { 0 };
|
||||
msg_lines + action_line + 1 // +1 for buffer spacing
|
||||
// +1 for buffer spacing
|
||||
msg_lines + action_line + 1
|
||||
});
|
||||
let has_update_tip = p.pending_update_version.is_some();
|
||||
let has_resume_tip = !has_update_tip && p.foreign_resume_hint.is_some();
|
||||
let tip_height = if !show_picker {
|
||||
if has_update_tip || has_resume_tip {
|
||||
1u16 // update/resume tips are short, always 1 row
|
||||
// update/resume tips are short, always 1 row
|
||||
1u16
|
||||
} else if let Some(tip_text) = p.tip {
|
||||
let inset = prompt::prompt_inset(welcome_compact);
|
||||
let tip_width = content_area.width.saturating_sub(inset * 2);
|
||||
@@ -1486,7 +1511,8 @@ fn render_welcome_done(
|
||||
if p.session_picker_loading {
|
||||
1
|
||||
} else {
|
||||
(picker_count as u16).min(15) + 3 // +3 for title + search + gap
|
||||
// +3 for title + search + gap
|
||||
(picker_count as u16).min(15) + 3
|
||||
}
|
||||
} else {
|
||||
0
|
||||
@@ -1746,7 +1772,8 @@ pub(crate) fn render_session_picker(
|
||||
let filtered_indices =
|
||||
crate::app::app_view::filter_session_entries(ctx.sessions, filter_query, ctx.source_filter);
|
||||
|
||||
let content_width = area.width; // approximate for truncation
|
||||
// approximate for truncation
|
||||
let content_width = area.width;
|
||||
let built = build_session_entry_data(entries_data, &filtered_indices, ctx.state, content_width);
|
||||
|
||||
// Build PickerEntry refs that borrow from `built`.
|
||||
@@ -2309,13 +2336,17 @@ mod tests {
|
||||
&mut buf,
|
||||
&theme,
|
||||
logo_line_count(area.height),
|
||||
None, // auth_url — none in key-entry mode
|
||||
// auth_url — none in key-entry mode
|
||||
None,
|
||||
AuthMode::ApiKeyEntry(crate::app::app_view::PlatformLogin(
|
||||
kigi_shell::models::PlatformId::MoonshotCn,
|
||||
)),
|
||||
"", // auth_code_input
|
||||
false, // clipboard_copied
|
||||
false, // show_raw_url
|
||||
// auth_code_input
|
||||
"",
|
||||
// clipboard_copied
|
||||
false,
|
||||
// show_raw_url
|
||||
false,
|
||||
);
|
||||
|
||||
let text = buffer_text(&buf);
|
||||
@@ -2615,7 +2646,8 @@ mod tests {
|
||||
let (result, non_sel) =
|
||||
build_grouped_picker_entries(&entries, &indices, &built, &fields_vecs, &state, None);
|
||||
|
||||
assert_eq!(result.len(), 3); // 1 header + 2 rows
|
||||
// 1 header + 2 rows
|
||||
assert_eq!(result.len(), 3);
|
||||
assert!(non_sel[0]);
|
||||
assert!(!non_sel[1]);
|
||||
assert!(!non_sel[2]);
|
||||
@@ -2976,9 +3008,12 @@ mod tests {
|
||||
logo_line_count(area.height),
|
||||
Some(url),
|
||||
AuthMode::Device,
|
||||
"", // auth_code_input — unused in device mode
|
||||
false, // clipboard_copied
|
||||
false, // show_raw_url
|
||||
// auth_code_input — unused in device mode
|
||||
"",
|
||||
// clipboard_copied
|
||||
false,
|
||||
// show_raw_url
|
||||
false,
|
||||
);
|
||||
|
||||
let text = buffer_text(&buf);
|
||||
@@ -3032,7 +3067,8 @@ mod tests {
|
||||
AuthMode::Device,
|
||||
"",
|
||||
false,
|
||||
true, // show_raw_url
|
||||
// show_raw_url
|
||||
true,
|
||||
);
|
||||
|
||||
let text = buffer_text(&buf);
|
||||
@@ -3058,7 +3094,8 @@ mod tests {
|
||||
AuthMode::Device,
|
||||
"",
|
||||
false,
|
||||
true, // show_raw_url
|
||||
// show_raw_url
|
||||
true,
|
||||
);
|
||||
|
||||
let text = buffer_text(&buf);
|
||||
@@ -3095,7 +3132,8 @@ mod tests {
|
||||
AuthMode::Device,
|
||||
"",
|
||||
false,
|
||||
true, // show_raw_url
|
||||
// show_raw_url
|
||||
true,
|
||||
);
|
||||
|
||||
let text = buffer_text(&buf);
|
||||
@@ -3132,9 +3170,12 @@ mod tests {
|
||||
logo_line_count(area.height),
|
||||
Some(url),
|
||||
AuthMode::Command,
|
||||
"", // auth_code_input — unused
|
||||
false, // clipboard_copied
|
||||
false, // show_raw_url
|
||||
// auth_code_input — unused
|
||||
"",
|
||||
// clipboard_copied
|
||||
false,
|
||||
// show_raw_url
|
||||
false,
|
||||
);
|
||||
|
||||
let text = buffer_text(&buf);
|
||||
|
||||
Reference in New Issue
Block a user