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:
2026-07-23 16:55:39 -04:00
parent ff0fb56c67
commit a02b555e66
1458 changed files with 10729 additions and 21750 deletions
@@ -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,
&registry,
None,
true, // peek_active
// peek_active
true,
None,
);
let content = buf_to_text(&buf);
@@ -7553,7 +7506,8 @@ mod tests {
&state,
&registry,
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,
&registry,
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), &reg),
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, &reg) {
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]
+5 -9
View File
@@ -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() {
+63 -102
View File
@@ -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();
+14 -43
View File
@@ -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).
+1 -7
View File
@@ -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());
+10 -51
View File
@@ -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),
+2 -33
View File
@@ -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);