Synced from monorepo
Changes: - Gate session-lifecycle heap steady state with a dhat soak - Unbreak merge lifecycle e2e after default model → grok-4.5 - Scan home-scope rules dirs at <root>/rules - Complete text-input paste and terminal parity - Gate project roles and personas - Use canonical editing in dialogs - Use canonical editing in search bars - Reject ambiguous MCP tool IDs - Harden Git operands for plugins - Simplify queue drain API - Pass RFC 9207 iss through MCP OAuth token exchange - Show leader roster when local agents map is empty - Use canonical editing in Persona views - Remove marketplace default-skills auto-install and purge old installs - Use canonical editing in extension forms - Add canonical dashboard text editing - Use canonical editing in settings - Add /summarize as a /recap alias - Restore previous agent when exiting dashboard - Use tool_choice auto for compaction - Settings toggle for snap-prompt-to-top on send - Update default models to grok-4.5 - Source login shell once for local bash (env + alias/function snapshot) - Template hardcoded param names in server-native tool descriptions - Fix System-Reminder XML tag injection in CLAUDE.md via agents_md - Fix remote workspace-server hardcoding LSP trust (repo code execution risk) - Clear orphaned tool-call updates at turn end - Suppress task wake after cancel - Send x-grok-client-identifier on direct API tool calls - Harden dashboard peek lease transitions - Host /btw side panel in live region (minimal mode) - Bound scroll presentation latency - Highlight multi-line constructs correctly in diffs and the file viewer - Block web_fetch non-public IPs; local opt-in is explicit-host only - Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness - Follow up clipboard delivery feedback - Use canonical editing in pickers - Route TextArea through canonical editor - Persistent "watching" status row; quieter turn markers - Gate sensitive edit targets - Expose agent registry counts and gate session churn on them - Default coding data sharing to opt-out until server preference applies - Wire chat attachment ids through gateway prompts - On auth refresh failure, issue retry - Forward preview provenance and computer lifecycle state - Document independent privacy controls and scope /privacy output - Strip SamplingError Display prefix on rate-limit UI copy - Stop dumping Cloudflare HTML into Retry failed - Disable in-place prompt edit (scroll jank on enter) - Strip forced ANSI color from gh pr view JSON - Plumb bash tool description onto ToolUsageCard wire
This commit is contained in:
parent
98c3b2438a
commit
7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1161,6 +1161,15 @@ impl BlockViewerPane {
|
|||
consumed
|
||||
}
|
||||
|
||||
pub fn handle_paste(&mut self, text: &str) -> bool {
|
||||
self.rebuild_unified_cache();
|
||||
let consumed = self.list_state.handle_paste(text, &self.cached_unified);
|
||||
if consumed {
|
||||
self.text_drag = None;
|
||||
}
|
||||
consumed
|
||||
}
|
||||
|
||||
/// Generate a patch string from the diff metadata in the given item range.
|
||||
///
|
||||
/// Returns `None` if this isn't an edit viewer or the range has no diff lines.
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@ use ratatui::buffer::Buffer;
|
|||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::layout::{MIN_DASHBOARD_WIDTH, compute_layout};
|
||||
use super::row::{DashboardRow, RowBadge, build_rows_with_roster};
|
||||
use super::state::{
|
||||
DashboardRowId, DashboardState, Filter, Focusable, Grouping, LocationPickerState, RowState,
|
||||
SectionKey,
|
||||
DashboardRowId, DashboardState, Filter, Focusable, Grouping, LocationPickerState, RenameDraft,
|
||||
RowState, SectionKey,
|
||||
};
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::agent_view::AgentView;
|
||||
|
|
@ -286,16 +287,13 @@ pub fn render_dashboard(
|
|||
// Header.
|
||||
render_header(buf, layout.header, &theme, &rows, state, upgrade_cta);
|
||||
|
||||
// Body.
|
||||
//
|
||||
// Three distinct branches:
|
||||
// (a) no agents at all → "no agents yet" hint
|
||||
// (b) agents exist but filter hides all → "no match" hint
|
||||
// (c) otherwise → render rows
|
||||
if agents.is_empty() {
|
||||
render_empty_state(buf, layout.list, &theme, dashboard_sessions_loading);
|
||||
} else if rows.is_empty() {
|
||||
render_no_match(buf, layout.list, &theme, &state.filter);
|
||||
// Body: key off visible rows (local agents + roster), not the local map alone.
|
||||
if rows.is_empty() {
|
||||
if state.filter.is_active() {
|
||||
render_no_match(buf, layout.list, &theme, &state.filter);
|
||||
} else {
|
||||
render_empty_state(buf, layout.list, &theme, dashboard_sessions_loading);
|
||||
}
|
||||
} else if area.width < MIN_DASHBOARD_WIDTH {
|
||||
render_narrow_rows(buf, layout.list, &theme, &rows, state);
|
||||
} else {
|
||||
|
|
@ -480,63 +478,78 @@ pub fn render_dashboard(
|
|||
return None;
|
||||
}
|
||||
|
||||
// Return a visible cursor for the dispatch input
|
||||
// / rename overlay so the user sees where typing lands.
|
||||
// Rename takes precedence — it paints over the row list.
|
||||
//
|
||||
// Cursor width is computed from the SANITISED
|
||||
// draft (matching what the renderer paints). The previous
|
||||
// `rn.draft.as_str()` form drifted right of the actual end of
|
||||
// typed text when the draft contained control characters.
|
||||
//
|
||||
// Clamp `cx` to the row's right edge so a
|
||||
// wider-than-rect draft doesn't park the cursor off the row.
|
||||
// An active rename replaces the dispatch caret with its row-local editor caret.
|
||||
if let Some(pos) = rename_cursor_pos(state, &rows) {
|
||||
return Some(pos);
|
||||
}
|
||||
dispatch_cursor
|
||||
}
|
||||
|
||||
/// Cursor position for the in-flight rename overlay: one cell past the
|
||||
/// end of the typed draft, on the renamed row's title line. `None` when
|
||||
/// no rename is active (or its row isn't on screen).
|
||||
///
|
||||
/// The overlay paints `rename: {draft}` at the title's own column —
|
||||
/// past the marker (1) + gap (1) + indent + icon + gap (1) chrome — in
|
||||
/// BOTH layouts (`render_row` / `render_narrow_rows` share the
|
||||
/// formula). The cursor mirrors that chrome math; anything else parks
|
||||
/// it over the `rename:` prefix or past the typed text. Indent and
|
||||
/// icon width come from the live row so a Working spinner or a
|
||||
/// (future) indented renameable row can't drift the formula.
|
||||
///
|
||||
/// Width is computed from the SANITISED draft (matching
|
||||
/// what the renderer paints). Clamped to the row's right
|
||||
/// edge so a wider-than-rect draft doesn't park the cursor off the row.
|
||||
const RENAME_PREFIX: &str = "rename: ";
|
||||
|
||||
fn rename_editor_view(draft: &RenameDraft, width: u16) -> (&str, u16) {
|
||||
let prefix_width = UnicodeWidthStr::width(RENAME_PREFIX) as u16;
|
||||
let editor_width = width.saturating_sub(prefix_width);
|
||||
let viewport = draft.viewport(editor_width as usize);
|
||||
let visible = &draft.text()[viewport.visible_byte_range];
|
||||
let cursor_offset = prefix_width
|
||||
.saturating_add(viewport.cursor_display_column as u16)
|
||||
.min(width.saturating_sub(1));
|
||||
(visible, cursor_offset)
|
||||
}
|
||||
|
||||
fn render_rename_editor(
|
||||
buf: &mut Buffer,
|
||||
x: u16,
|
||||
y: u16,
|
||||
width: u16,
|
||||
style: Style,
|
||||
draft: &RenameDraft,
|
||||
) {
|
||||
if width == 0 {
|
||||
return;
|
||||
}
|
||||
let prefix_width = UnicodeWidthStr::width(RENAME_PREFIX) as u16;
|
||||
buf.set_span(
|
||||
x,
|
||||
y,
|
||||
&Span::styled(RENAME_PREFIX, style),
|
||||
prefix_width.min(width),
|
||||
);
|
||||
let (visible, _) = rename_editor_view(draft, width);
|
||||
if !visible.is_empty() && prefix_width < width {
|
||||
buf.set_span(
|
||||
x + prefix_width,
|
||||
y,
|
||||
&Span::styled(visible, style),
|
||||
width - prefix_width,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the in-flight rename caret when its row is visible.
|
||||
fn rename_cursor_pos(state: &DashboardState, rows: &[DashboardRow]) -> Option<(u16, u16)> {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
let rn = state.rename.as_ref()?;
|
||||
let (_, rect) = state.row_rects.iter().find(|(id, _)| *id == rn.row)?;
|
||||
let safe_draft = crate::views::session_title::sanitize_display_text(&rn.draft);
|
||||
let prefix_w = UnicodeWidthStr::width("rename: ") as u16;
|
||||
let draft_w = UnicodeWidthStr::width(safe_draft.as_ref()) as u16;
|
||||
let (indent_w, icon_w) = rows
|
||||
let (marker_width, indent_width, icon_width) = rows
|
||||
.iter()
|
||||
.find(|r| r.id == rn.row)
|
||||
.map(|r| {
|
||||
(
|
||||
UnicodeWidthStr::width(crate::glyphs::selection_bar()) as u16,
|
||||
(r.indent as u16) * 2,
|
||||
UnicodeWidthStr::width(state_icon(r.state, state.spinner_tick)) as u16,
|
||||
)
|
||||
})
|
||||
.unwrap_or((0, 1));
|
||||
let chrome_w = 1 + 1 + indent_w + icon_w + 1;
|
||||
let unbounded_cx = rect
|
||||
.x
|
||||
.saturating_add(chrome_w)
|
||||
.saturating_add(prefix_w)
|
||||
.saturating_add(draft_w);
|
||||
let cx_max = rect.x.saturating_add(rect.width.saturating_sub(1));
|
||||
Some((unbounded_cx.min(cx_max), rect.y))
|
||||
.unwrap_or((1, 0, 1));
|
||||
let chrome_width = marker_width + 1 + indent_width + icon_width + 1;
|
||||
let content_x = rect.x.saturating_add(chrome_width);
|
||||
let content_width = rect.x.saturating_add(rect.width).saturating_sub(content_x);
|
||||
let (_, cursor_offset) = rename_editor_view(rn, content_width);
|
||||
let cursor_x = content_x
|
||||
.saturating_add(cursor_offset)
|
||||
.min(rect.x.saturating_add(rect.width.saturating_sub(1)));
|
||||
Some((cursor_x, rect.y))
|
||||
}
|
||||
|
||||
/// Render the compact dashboard "banner" used when an agent is
|
||||
|
|
@ -685,7 +698,6 @@ fn render_header(
|
|||
upgrade_cta: Option<HeaderUpgradeCta<'_>>,
|
||||
) {
|
||||
use ratatui::text::{Line, Span};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::views::agent_status::AgentStatusBar;
|
||||
|
||||
|
|
@ -1004,7 +1016,8 @@ fn render_location_picker(
|
|||
ModalSizing, ModalWindowConfig, Shortcut, push_vim_nav_search_hint, render_modal_window,
|
||||
};
|
||||
use crate::views::picker::{
|
||||
PickerEntry, PickerRow, render_divider, render_picker_content, render_search_bar_with_label,
|
||||
PickerEntry, PickerRow, render_divider, render_picker_content,
|
||||
render_picker_search_bar_with_label,
|
||||
};
|
||||
|
||||
let mut shortcuts = vec![
|
||||
|
|
@ -1090,17 +1103,16 @@ fn render_location_picker(
|
|||
} else {
|
||||
(content_area.width, None)
|
||||
};
|
||||
render_search_bar_with_label(
|
||||
render_picker_search_bar_with_label(
|
||||
buf,
|
||||
content_area.x,
|
||||
content_area.y,
|
||||
path_w,
|
||||
theme,
|
||||
" path: ",
|
||||
&modal.picker.query,
|
||||
&modal.picker,
|
||||
/* active */ false,
|
||||
/* show_hint */ false,
|
||||
modal.picker.query_cursor,
|
||||
Some(theme.bg_base),
|
||||
);
|
||||
modal.worktree_hit.set(wt_rect);
|
||||
|
|
@ -1176,7 +1188,6 @@ fn render_location_picker(
|
|||
// `render_picker_row`'s layout (fold prefix 2, gap 2, trailing 1); the
|
||||
// `-1` conservatively reserves a scrollbar column.
|
||||
let details: Vec<String> = {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
const PREFIX: u16 = 2;
|
||||
const GAP: u16 = 2;
|
||||
const TRAILING: u16 = 1;
|
||||
|
|
@ -1757,7 +1768,6 @@ fn render_group_header(
|
|||
selected: bool,
|
||||
hovered: bool,
|
||||
) {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
let bg = Style::default().bg(theme.bg_base);
|
||||
let fill = " ".repeat(rect.width as usize);
|
||||
buf.set_string(rect.x, rect.y, fill, bg);
|
||||
|
|
@ -2043,7 +2053,6 @@ fn render_row(
|
|||
row: &DashboardRow,
|
||||
state: &DashboardState,
|
||||
) {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
if rect.area() == 0 {
|
||||
return;
|
||||
}
|
||||
|
|
@ -2127,19 +2136,17 @@ fn render_row(
|
|||
icon,
|
||||
Style::default().fg(icon_color).bg(bg),
|
||||
);
|
||||
let prefix = "rename: ";
|
||||
let safe_draft = crate::views::session_title::sanitize_display_text(&rn.draft).into_owned();
|
||||
let line = format!("{prefix}{safe_draft}");
|
||||
let avail = (rect.x + rect.width).saturating_sub(content_start_x + 1);
|
||||
let truncated = truncate_str(&line, avail as usize);
|
||||
buf.set_string(
|
||||
let available = (rect.x + rect.width).saturating_sub(content_start_x);
|
||||
render_rename_editor(
|
||||
buf,
|
||||
content_start_x,
|
||||
title_y,
|
||||
truncated,
|
||||
available,
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.bg(bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
rn,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -2358,7 +2365,6 @@ fn render_narrow_rows(
|
|||
// form would push too many rows off-screen on a 40-col terminal).
|
||||
// We still emit group headers and the selection marker so the
|
||||
// visual vocabulary stays consistent.
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
let lines = build_dashboard_lines(
|
||||
rows,
|
||||
state.grouping,
|
||||
|
|
@ -2486,18 +2492,16 @@ fn render_narrow_rows(
|
|||
&chrome,
|
||||
Style::default().fg(theme.text_primary).bg(bg),
|
||||
);
|
||||
let safe_draft =
|
||||
crate::views::session_title::sanitize_display_text(&rn.draft).into_owned();
|
||||
let line = format!("rename: {safe_draft}");
|
||||
let truncated = truncate_str(&line, body_width.saturating_sub(chrome_w) as usize);
|
||||
buf.set_string(
|
||||
render_rename_editor(
|
||||
buf,
|
||||
area.x + chrome_w,
|
||||
y,
|
||||
truncated,
|
||||
body_width.saturating_sub(chrome_w),
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.bg(bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
rn,
|
||||
);
|
||||
} else {
|
||||
let marker = if selected {
|
||||
|
|
@ -2621,8 +2625,6 @@ fn paint_dispatch_feedback_badge(
|
|||
theme: &Theme,
|
||||
error_toast: Option<&str>,
|
||||
) {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
let Some(err) = error_toast else {
|
||||
return;
|
||||
};
|
||||
|
|
@ -2746,7 +2748,6 @@ fn render_dispatch(
|
|||
overlay_area: Option<Rect>,
|
||||
) -> Option<(u16, u16)> {
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Widget};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::views::prompt_widget::PromptStyle;
|
||||
|
||||
|
|
@ -2806,7 +2807,7 @@ fn render_dispatch(
|
|||
height: 1,
|
||||
}
|
||||
};
|
||||
if content.width < 4 {
|
||||
if content.width == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
|
@ -2817,36 +2818,63 @@ fn render_dispatch(
|
|||
if state.search_mode {
|
||||
let prefix = "Search: ";
|
||||
let prefix_w = UnicodeWidthStr::width(prefix) as u16;
|
||||
buf.set_string(
|
||||
let painted_prefix_w = prefix_w.min(content.width);
|
||||
buf.set_span(
|
||||
content.x,
|
||||
content.y,
|
||||
prefix,
|
||||
Style::default()
|
||||
.fg(theme.warning)
|
||||
.bg(theme.bg_base)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
&Span::styled(
|
||||
prefix,
|
||||
Style::default()
|
||||
.fg(theme.warning)
|
||||
.bg(theme.bg_base)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
painted_prefix_w,
|
||||
);
|
||||
let avail = content.width.saturating_sub(prefix_w);
|
||||
let (to_show, style) = if state.dispatch.text().is_empty() {
|
||||
(
|
||||
"Type to filter sessions\u{2026}".to_string(),
|
||||
Style::default().fg(theme.gray_dim).bg(theme.bg_base),
|
||||
)
|
||||
let editor_x = content.x + painted_prefix_w;
|
||||
let avail = content.width - painted_prefix_w;
|
||||
let cursor_column = if state.dispatch.text().is_empty() {
|
||||
if avail > 0 {
|
||||
let placeholder = truncate_str("Type to filter sessions\u{2026}", avail as usize);
|
||||
buf.set_string(
|
||||
editor_x,
|
||||
content.y,
|
||||
placeholder,
|
||||
Style::default().fg(theme.gray_dim).bg(theme.bg_base),
|
||||
);
|
||||
}
|
||||
0
|
||||
} else {
|
||||
(
|
||||
state.dispatch.text().to_string(),
|
||||
Style::default().fg(theme.text_primary).bg(theme.bg_base),
|
||||
let viewport = xai_ratatui_textarea::EditBuffer::from_parts(
|
||||
state.dispatch.text(),
|
||||
state.dispatch.cursor(),
|
||||
)
|
||||
.single_line_viewport(avail as usize);
|
||||
let visible = &state.dispatch.text()[viewport.visible_byte_range];
|
||||
if avail > 0 {
|
||||
buf.set_span(
|
||||
editor_x,
|
||||
content.y,
|
||||
&Span::styled(
|
||||
visible,
|
||||
Style::default().fg(theme.text_primary).bg(theme.bg_base),
|
||||
),
|
||||
(UnicodeWidthStr::width(visible) as u16).min(avail),
|
||||
);
|
||||
}
|
||||
viewport.cursor_display_column as u16
|
||||
};
|
||||
let trunc = truncate_str(&to_show, avail as usize);
|
||||
buf.set_string(content.x + prefix_w, content.y, trunc, style);
|
||||
let text_disp_w: u16 = UnicodeWidthStr::width(state.dispatch.text())
|
||||
.try_into()
|
||||
.unwrap_or(u16::MAX);
|
||||
let cx = content.x + prefix_w + text_disp_w.min(avail.saturating_sub(1));
|
||||
let cursor_offset = painted_prefix_w
|
||||
.saturating_add(cursor_column)
|
||||
.min(content.width - 1);
|
||||
let cx = content.x + cursor_offset;
|
||||
return input_focused.then_some((cx, content.y));
|
||||
}
|
||||
|
||||
if content.width < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prefix = "\u{276F} ";
|
||||
let prefix_w = UnicodeWidthStr::width(prefix) as u16;
|
||||
|
||||
|
|
@ -3825,7 +3853,6 @@ pub fn render_popup_overlay(
|
|||
|
||||
let title_text = format!(" \u{2771} {title_label} ");
|
||||
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
let close_label = crate::glyphs::ballot_x_button();
|
||||
let close_w = UnicodeWidthStr::width(close_label) as u16;
|
||||
// Reserve close-affordance width + a 1-cell gap on the right;
|
||||
|
|
@ -4047,8 +4074,6 @@ fn paint_session_title_bar(
|
|||
left_inset: u16,
|
||||
right_inset: u16,
|
||||
) -> (Option<Rect>, Option<Rect>, Option<Rect>) {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
// `‹` / `›` / `✗` are all painted as plain bracketed text
|
||||
// (no button background fills). Hover only changes the fg
|
||||
// color (`text_primary` vs `gray`) for subtle clickability
|
||||
|
|
@ -4267,6 +4292,51 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_dashboard_shows_roster_when_local_agents_empty() {
|
||||
use crate::app::roster::{RosterActivity, RosterEntry, RosterOrigin};
|
||||
|
||||
let area = Rect::new(0, 0, 100, 24);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut agents: IndexMap<AgentId, AgentView> = IndexMap::new();
|
||||
let mut state = DashboardState::new();
|
||||
let registry = crate::actions::ActionRegistry::defaults();
|
||||
let roster = [RosterEntry {
|
||||
session_id: "sess-fleet-1".into(),
|
||||
title: Some("Fix fleet dashboard".into()),
|
||||
cwd: "/repo/work".into(),
|
||||
is_worktree: false,
|
||||
model_id: None,
|
||||
yolo: false,
|
||||
activity: RosterActivity::Working,
|
||||
resident: true,
|
||||
last_change_unix_ms: 1_725_000_000_000,
|
||||
origin: RosterOrigin::default(),
|
||||
}];
|
||||
|
||||
let _ = render_dashboard(
|
||||
&mut buf,
|
||||
area,
|
||||
&mut state,
|
||||
&mut agents,
|
||||
®istry,
|
||||
None,
|
||||
&roster,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
let content = buf_to_text(&buf);
|
||||
assert!(
|
||||
content.contains("Fix fleet dashboard"),
|
||||
"roster-only working session must paint when local agents are empty, got: {content:?}"
|
||||
);
|
||||
assert!(
|
||||
!content.contains("No agents yet"),
|
||||
"must not show empty-state while roster rows exist, got: {content:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// While the local session roster is still loading the empty body
|
||||
/// shows a loading hint instead of the "no agents" copy.
|
||||
#[test]
|
||||
|
|
@ -5385,13 +5455,10 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// The in-flight rename overlay sanitises the
|
||||
/// draft before painting so a smuggled ANSI escape never lands in
|
||||
/// the buffer (test wide-mode and narrow-mode separately).
|
||||
/// RenameDraft sanitation keeps control characters out of both render paths.
|
||||
#[test]
|
||||
fn render_rename_overlay_strips_control_chars_from_live_draft() {
|
||||
fn sanitized_rename_draft_is_safe_in_both_render_paths() {
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::views::dashboard::state::RenameDraft;
|
||||
let id = DashboardRowId::TopLevel(AgentId(7));
|
||||
let row = DashboardRow {
|
||||
id: id.clone(),
|
||||
|
|
@ -5420,10 +5487,7 @@ mod tests {
|
|||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 3));
|
||||
let mut state = DashboardState::new();
|
||||
state.selected = Some(id.clone());
|
||||
state.rename = Some(RenameDraft {
|
||||
row: id.clone(),
|
||||
draft: "a\x1b[31m".to_string(),
|
||||
});
|
||||
state.rename = Some(RenameDraft::new(id.clone(), "a\x1b[31m"));
|
||||
render_rows(&mut buf, Rect::new(0, 0, 80, 3), &theme, &rows, &mut state);
|
||||
let content = buf_to_text(&buf);
|
||||
assert!(
|
||||
|
|
@ -5441,10 +5505,7 @@ mod tests {
|
|||
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 3));
|
||||
let mut state = DashboardState::new();
|
||||
state.selected = Some(id.clone());
|
||||
state.rename = Some(RenameDraft {
|
||||
row: id.clone(),
|
||||
draft: "a\x1b[31m".to_string(),
|
||||
});
|
||||
state.rename = Some(RenameDraft::new(id.clone(), "a\x1b[31m"));
|
||||
render_narrow_rows(&mut buf, Rect::new(0, 0, 30, 3), &theme, &rows, &mut state);
|
||||
let content = buf_to_text(&buf);
|
||||
assert!(
|
||||
|
|
@ -5458,13 +5519,10 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// The rename overlay keeps the row's chrome (state icon) and paints
|
||||
/// `rename:` at the title's own column, so the editing row stays
|
||||
/// aligned with its neighbours (wide and narrow layouts).
|
||||
/// Rename rendering preserves row chrome and title alignment in both layouts.
|
||||
#[test]
|
||||
fn render_rename_overlay_aligns_with_title_and_keeps_icon() {
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::views::dashboard::state::RenameDraft;
|
||||
let id = DashboardRowId::TopLevel(AgentId(7));
|
||||
let row = DashboardRow {
|
||||
id: id.clone(),
|
||||
|
|
@ -5506,10 +5564,7 @@ mod tests {
|
|||
{
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 5));
|
||||
let mut state = DashboardState::new();
|
||||
state.rename = Some(RenameDraft {
|
||||
row: id.clone(),
|
||||
draft: "new name".to_string(),
|
||||
});
|
||||
state.rename = Some(RenameDraft::new(id.clone(), "new name"));
|
||||
render_rows(&mut buf, Rect::new(0, 0, 80, 5), &theme, &rows, &mut state);
|
||||
let line = row_text(&buf, 2, 80);
|
||||
assert_eq!(
|
||||
|
|
@ -5533,10 +5588,7 @@ mod tests {
|
|||
);
|
||||
// With an empty draft the cursor sits immediately after
|
||||
// `rename: ` (the position typing lands at).
|
||||
state.rename = Some(RenameDraft {
|
||||
row: id.clone(),
|
||||
draft: String::new(),
|
||||
});
|
||||
state.rename = Some(RenameDraft::new(id.clone(), ""));
|
||||
assert_eq!(
|
||||
rename_cursor_pos(&state, &rows),
|
||||
Some((title_col + prefix_w, 2)),
|
||||
|
|
@ -5556,10 +5608,7 @@ mod tests {
|
|||
{
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 3));
|
||||
let mut state = DashboardState::new();
|
||||
state.rename = Some(RenameDraft {
|
||||
row: id.clone(),
|
||||
draft: "nn".to_string(),
|
||||
});
|
||||
state.rename = Some(RenameDraft::new(id.clone(), "nn"));
|
||||
render_narrow_rows(&mut buf, Rect::new(0, 0, 30, 3), &theme, &rows, &mut state);
|
||||
let line = row_text(&buf, 1, 30);
|
||||
assert_eq!(
|
||||
|
|
@ -5575,6 +5624,97 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_viewport_handles_long_unicode_in_wide_and_narrow_rows() {
|
||||
use crate::app::agent::AgentId;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
let id = DashboardRowId::TopLevel(AgentId(7));
|
||||
let row = DashboardRow {
|
||||
id: id.clone(),
|
||||
label: "row label".to_string(),
|
||||
subtitle: None,
|
||||
state: RowState::Idle,
|
||||
activity: None,
|
||||
secondary_line: None,
|
||||
cwd_display: String::new(),
|
||||
cwd: std::path::PathBuf::from("/tmp"),
|
||||
last_change_at: std::time::SystemTime::now(),
|
||||
pinned: false,
|
||||
is_active: false,
|
||||
badges: Vec::new(),
|
||||
context_pct: None,
|
||||
indent: 0,
|
||||
parent_label: None,
|
||||
is_more_placeholder: false,
|
||||
more_count: 0,
|
||||
};
|
||||
let rows = vec![row];
|
||||
let text = format!("{}中e\u{301}👩🏽\u{200d}💻", "x".repeat(90));
|
||||
let theme = Theme::current();
|
||||
let registry = crate::actions::ActionRegistry::defaults();
|
||||
|
||||
for (width, narrow, row_y) in [(80, false, 2), (30, true, 1)] {
|
||||
let area = Rect::new(0, 0, width, if narrow { 3 } else { 5 });
|
||||
let mut buffer = Buffer::empty(area);
|
||||
let mut state = DashboardState::new();
|
||||
state.rename = Some(RenameDraft::new(id.clone(), text.clone()));
|
||||
if narrow {
|
||||
render_narrow_rows(&mut buffer, area, &theme, &rows, &mut state);
|
||||
} else {
|
||||
render_rows(&mut buffer, area, &theme, &rows, &mut state);
|
||||
}
|
||||
let line = (0..width)
|
||||
.map(|x| buffer[(x, row_y)].symbol().to_string())
|
||||
.collect::<String>();
|
||||
assert!(line.contains('中'), "CJK tail missing: {line:?}");
|
||||
assert!(line.contains("e\u{301}"), "combining tail split: {line:?}");
|
||||
assert!(line.contains("👩🏽\u{200d}💻"), "ZWJ tail split: {line:?}",);
|
||||
let end_cursor = rename_cursor_pos(&state, &rows).expect("end cursor");
|
||||
|
||||
let _ = state.handle_input(
|
||||
&Event::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)),
|
||||
®istry,
|
||||
);
|
||||
for _ in 0..20 {
|
||||
let _ = state.handle_input(
|
||||
&Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
|
||||
®istry,
|
||||
);
|
||||
}
|
||||
let mut middle_buffer = Buffer::empty(area);
|
||||
if narrow {
|
||||
render_narrow_rows(&mut middle_buffer, area, &theme, &rows, &mut state);
|
||||
} else {
|
||||
render_rows(&mut middle_buffer, area, &theme, &rows, &mut state);
|
||||
}
|
||||
let middle_cursor = rename_cursor_pos(&state, &rows).expect("middle cursor");
|
||||
assert_ne!(
|
||||
state.rename.as_ref().expect("rename draft").cursor_byte(),
|
||||
text.len()
|
||||
);
|
||||
if !narrow {
|
||||
assert_ne!(middle_cursor, end_cursor);
|
||||
}
|
||||
let prefix_x = (0..width)
|
||||
.find(|x| middle_buffer[(*x, row_y)].symbol() == "r")
|
||||
.expect("rename prefix");
|
||||
let row_rect = state
|
||||
.row_rects
|
||||
.iter()
|
||||
.find(|(row_id, _)| row_id == &id)
|
||||
.map(|(_, rect)| *rect)
|
||||
.expect("rename row rect");
|
||||
let editor_x = prefix_x + RENAME_PREFIX.len() as u16;
|
||||
let editor_width = row_rect
|
||||
.x
|
||||
.saturating_add(row_rect.width)
|
||||
.saturating_sub(editor_x);
|
||||
let expected_cursor = editor_x + 20u16.min(editor_width.saturating_sub(1));
|
||||
assert_eq!(middle_cursor, (expected_cursor, row_y));
|
||||
}
|
||||
}
|
||||
|
||||
/// On a 3-row rect the dispatch input
|
||||
/// paints a rounded-box chrome so it reads as a real input
|
||||
/// field. The text row contains the `❯` prefix.
|
||||
|
|
@ -5598,6 +5738,52 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_search_mode_uses_textarea_cursor_not_text_end() {
|
||||
let area = Rect::new(0, 0, 40, 3);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
let theme = Theme::current();
|
||||
let mut state = DashboardState::new();
|
||||
state.search_mode = true;
|
||||
state.dispatch.set_text("abcdef");
|
||||
state.dispatch.set_cursor(2);
|
||||
|
||||
let cursor = render_dispatch(&mut buffer, area, &theme, &mut state, None)
|
||||
.expect("focused search cursor");
|
||||
let prefix_x = (0..area.width)
|
||||
.find(|x| buffer[(*x, cursor.1)].symbol() == "S")
|
||||
.expect("Search prefix");
|
||||
assert_eq!(cursor.0, prefix_x + "Search: ".len() as u16 + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_search_mode_clips_prefix_and_cursor_at_widths_one_through_nine() {
|
||||
let theme = Theme::current();
|
||||
for width in 1..=9 {
|
||||
let full = Rect::new(0, 0, 14, 1);
|
||||
let area = Rect::new(2, 0, width, 1);
|
||||
let mut buffer = Buffer::empty(full);
|
||||
buffer.set_string(0, 0, "#".repeat(full.width as usize), Style::default());
|
||||
let mut state = DashboardState::new();
|
||||
state.search_mode = true;
|
||||
state.dispatch.set_text("abcdef");
|
||||
state.dispatch.set_cursor(2);
|
||||
|
||||
let cursor = render_dispatch(&mut buffer, area, &theme, &mut state, None)
|
||||
.expect("focused narrow search cursor");
|
||||
assert!(cursor.0 >= area.x && cursor.0 < area.x + area.width);
|
||||
for x in 0..full.width {
|
||||
if x < area.x || x >= area.x + area.width {
|
||||
assert_eq!(
|
||||
buffer[(x, 0)].symbol(),
|
||||
"#",
|
||||
"width {width} wrote outside at column {x}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_dispatch_keeps_generic_paste_preview_but_suppresses_image_preview() {
|
||||
let area = Rect::new(0, 17, 80, 3);
|
||||
|
|
@ -7340,8 +7526,7 @@ mod tests {
|
|||
std::path::PathBuf::from("/base"),
|
||||
std::collections::HashMap::new(),
|
||||
);
|
||||
modal.picker.query = "/tmp/zzz".to_string();
|
||||
modal.picker.query_cursor = modal.picker.query.len();
|
||||
modal.picker.set_query("/tmp/zzz");
|
||||
render_location_picker(&mut buf, area, &theme, &mut modal);
|
||||
let content = buf_to_text(&buf);
|
||||
assert!(
|
||||
|
|
@ -8255,21 +8440,16 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// An in-flight rename swaps the footer for its two actions —
|
||||
/// Enter saves, Esc cancels — and hides the normal nav/stop chips.
|
||||
/// Rename mode shows only save and cancel actions.
|
||||
#[test]
|
||||
fn render_footer_rename_shows_save_and_cancel() {
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::views::dashboard::state::RenameDraft;
|
||||
let theme = Theme::current();
|
||||
let registry = crate::actions::ActionRegistry::defaults();
|
||||
let mut state = DashboardState::new();
|
||||
let id = DashboardRowId::TopLevel(AgentId(0));
|
||||
state.focus_row(id.clone());
|
||||
state.rename = Some(RenameDraft {
|
||||
row: id,
|
||||
draft: String::new(),
|
||||
});
|
||||
state.rename = Some(RenameDraft::new(id, ""));
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||||
render_footer(
|
||||
&mut buf,
|
||||
|
|
|
|||
|
|
@ -389,9 +389,9 @@ pub fn classify_top_level(agent: &AgentView) -> RowState {
|
|||
/// (`run_terminal_command` with `background=true`), a running `monitor`
|
||||
/// (a background task with `is_monitor`), or an active scheduled `/loop`.
|
||||
/// Mirrors the agent view's idle "watching" cue
|
||||
/// (`crate::views::turn_status::Watchers`) but also counts plain
|
||||
/// background tasks — any in-flight background work the user dispatched
|
||||
/// should read as "Working" on the dashboard.
|
||||
/// (`crate::views::turn_status::Watchers`, minus subagents — the dashboard
|
||||
/// lists those as their own rows) — any in-flight background work the user
|
||||
/// dispatched should read as "Working" on the dashboard.
|
||||
pub fn has_background_work(agent: &AgentView) -> bool {
|
||||
agent
|
||||
.session
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use crate::actions::ActionRegistry;
|
|||
use crate::app::actions::Action;
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::app_view::InputOutcome;
|
||||
use crate::input::line_editor::{LineEditOutcome, LineEditor};
|
||||
use crate::key;
|
||||
use crate::views::prompt_widget::PromptWidget;
|
||||
|
||||
|
|
@ -56,7 +57,7 @@ impl DashboardRowId {
|
|||
pub(crate) struct PeekViewportLease {
|
||||
pub row: DashboardRowId,
|
||||
pub snapshot: crate::scrollback::state::ViewportSnapshot,
|
||||
pub page_flip_entry: Option<usize>,
|
||||
pub page_flip_entry: Option<crate::scrollback::EntryId>,
|
||||
}
|
||||
|
||||
pub(crate) fn scrollback_mut_for_row<'a>(
|
||||
|
|
@ -766,7 +767,51 @@ pub struct ShortcutsModalState {
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct RenameDraft {
|
||||
pub row: DashboardRowId,
|
||||
pub draft: String,
|
||||
editor: LineEditor,
|
||||
}
|
||||
|
||||
const MAX_RENAME_SCALARS: usize = 100;
|
||||
|
||||
impl RenameDraft {
|
||||
pub fn new(row: DashboardRowId, text: impl Into<String>) -> Self {
|
||||
let mut draft = Self {
|
||||
row,
|
||||
editor: LineEditor::default(),
|
||||
};
|
||||
draft.set_text(text);
|
||||
draft
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
self.editor.text()
|
||||
}
|
||||
|
||||
pub fn cursor_byte(&self) -> usize {
|
||||
self.editor.cursor_byte()
|
||||
}
|
||||
|
||||
pub(crate) fn viewport(&self, width: usize) -> xai_ratatui_textarea::SingleLineViewport {
|
||||
self.editor.viewport(width)
|
||||
}
|
||||
|
||||
pub(crate) fn set_text(&mut self, text: impl Into<String>) {
|
||||
let text = text
|
||||
.into()
|
||||
.chars()
|
||||
.filter(|character| rename_wire_character_allowed(*character))
|
||||
.take(MAX_RENAME_SCALARS)
|
||||
.collect::<String>();
|
||||
self.editor.set_text(text);
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_character_allowed(character: char) -> bool {
|
||||
!crate::render::line_utils::is_unsafe_display_char(character)
|
||||
}
|
||||
|
||||
fn rename_wire_character_allowed(character: char) -> bool {
|
||||
// Preserve an existing emoji ZWJ sequence; interactive inserts still reject format chars.
|
||||
character == '\u{200d}' || rename_character_allowed(character)
|
||||
}
|
||||
|
||||
/// One selectable directory in the location picker (see
|
||||
|
|
@ -844,10 +889,7 @@ impl LocationPickerState {
|
|||
base_cwd: PathBuf,
|
||||
worktrees: std::collections::HashMap<PathBuf, String>,
|
||||
) -> Self {
|
||||
let picker = crate::views::picker::PickerState {
|
||||
search_active: true,
|
||||
..crate::views::picker::PickerState::default()
|
||||
};
|
||||
let picker = crate::views::picker::PickerState::input_active();
|
||||
Self {
|
||||
picker,
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
|
|
@ -880,7 +922,7 @@ impl LocationPickerState {
|
|||
/// Whether the current query should be treated as a filesystem path
|
||||
/// (directory completion) rather than a fuzzy filter over recents.
|
||||
pub fn query_is_path(&self) -> bool {
|
||||
let q = &self.picker.query;
|
||||
let q = self.picker.query();
|
||||
q.starts_with('/')
|
||||
|| q.starts_with('~')
|
||||
|| q.contains('/')
|
||||
|
|
@ -896,7 +938,7 @@ impl LocationPickerState {
|
|||
/// home; relative parents join [`Self::base_cwd`]. The separator is `/`
|
||||
/// on all hosts and additionally `\` on Windows.
|
||||
fn path_query_parts(&self) -> (PathBuf, String) {
|
||||
let q = self.picker.query.as_str();
|
||||
let q = self.picker.query();
|
||||
// Last path separator: `/` always; `\` additionally on Windows.
|
||||
let sep = match (q.rfind('/'), cfg!(windows).then(|| q.rfind('\\')).flatten()) {
|
||||
(Some(a), Some(b)) => Some(a.max(b)),
|
||||
|
|
@ -963,7 +1005,7 @@ impl LocationPickerState {
|
|||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
let q = self.picker.query.trim().to_lowercase();
|
||||
let q = self.picker.query().trim().to_lowercase();
|
||||
self.recents
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
|
|
@ -986,7 +1028,7 @@ impl LocationPickerState {
|
|||
if let Some(c) = visible.get(self.picker.selected) {
|
||||
return Some(c.path.to_string_lossy().into_owned());
|
||||
}
|
||||
let q = self.picker.query.trim();
|
||||
let q = self.picker.query().trim();
|
||||
if !q.is_empty() {
|
||||
return Some(q.to_string());
|
||||
}
|
||||
|
|
@ -1778,7 +1820,9 @@ impl DashboardState {
|
|||
let w = lease.snapshot.last_width;
|
||||
let h = lease.snapshot.viewport_height;
|
||||
sb.restore_viewport_snapshot(lease.snapshot);
|
||||
if let Some(idx) = page_flip {
|
||||
if let Some(entry_id) = page_flip
|
||||
&& let Some(idx) = sb.index_of_id(entry_id)
|
||||
{
|
||||
if w > 0 && h > 0 {
|
||||
sb.prepare_layout(w, h);
|
||||
}
|
||||
|
|
@ -1814,32 +1858,11 @@ impl DashboardState {
|
|||
});
|
||||
}
|
||||
|
||||
pub fn note_page_flip_for_lease(
|
||||
pub(crate) fn note_page_flip_for_lease(
|
||||
&mut self,
|
||||
agent_id: AgentId,
|
||||
agents: &mut indexmap::IndexMap<AgentId, crate::app::agent_view::AgentView>,
|
||||
) {
|
||||
let Some(row) = self
|
||||
.peek_viewport
|
||||
.as_ref()
|
||||
.filter(|lease| lease.row.matches_top_level_agent(agent_id))
|
||||
.map(|lease| lease.row.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(sb) = scrollback_mut_for_row(&row, agents) else {
|
||||
return;
|
||||
};
|
||||
let selected = sb.selected();
|
||||
let current_turn = sb.current_turn();
|
||||
self.note_page_flip_from_scroll(agent_id, selected, current_turn);
|
||||
}
|
||||
|
||||
pub(crate) fn note_page_flip_from_scroll(
|
||||
&mut self,
|
||||
agent_id: AgentId,
|
||||
selected: Option<usize>,
|
||||
current_turn: Option<usize>,
|
||||
entry_id: crate::scrollback::EntryId,
|
||||
agents: &indexmap::IndexMap<AgentId, crate::app::agent_view::AgentView>,
|
||||
) {
|
||||
let Some(lease) = self.peek_viewport.as_mut() else {
|
||||
return;
|
||||
|
|
@ -1847,11 +1870,16 @@ impl DashboardState {
|
|||
if !lease.row.matches_top_level_agent(agent_id) {
|
||||
return;
|
||||
}
|
||||
lease.page_flip_entry = selected;
|
||||
lease.snapshot.follow_mode = true;
|
||||
lease.snapshot.follow_preserve_scroll = true;
|
||||
lease.snapshot.selected = selected;
|
||||
lease.snapshot.current_turn = current_turn;
|
||||
let Some(sb) = agents.get(&agent_id).map(|agent| &agent.scrollback) else {
|
||||
return;
|
||||
};
|
||||
if sb.index_of_id(entry_id).is_none() {
|
||||
return;
|
||||
}
|
||||
if !sb.is_follow_preserve_scroll() {
|
||||
return;
|
||||
}
|
||||
lease.page_flip_entry = Some(entry_id);
|
||||
}
|
||||
|
||||
/// Clear the peek reply draft AND its undo history.
|
||||
|
|
@ -1989,12 +2017,14 @@ impl DashboardState {
|
|||
return self.handle_worktree_dialog_input(ev);
|
||||
}
|
||||
|
||||
// Rename mode owns the keyboard until Enter / Esc.
|
||||
// Rename mode owns input until committed or cancelled.
|
||||
if let Some(ref mut rn) = self.rename {
|
||||
if let Event::Key(key) = ev
|
||||
&& key.kind != KeyEventKind::Release
|
||||
{
|
||||
return handle_rename_key(rn, key);
|
||||
match ev {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
return handle_rename_key(rn, key);
|
||||
}
|
||||
Event::Paste(text) => return handle_rename_paste(rn, text),
|
||||
_ => {}
|
||||
}
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
|
|
@ -3478,7 +3508,7 @@ impl DashboardState {
|
|||
|
||||
// Forward to the prompt widget (single-line).
|
||||
let old = self.dispatch.text().to_string();
|
||||
let _ = self.dispatch.handle_key(key);
|
||||
let event = self.dispatch.handle_key(key);
|
||||
let new = self.dispatch.text().to_string();
|
||||
if old != new {
|
||||
// Live-update the filter as the user types ONLY in search
|
||||
|
|
@ -3511,6 +3541,8 @@ impl DashboardState {
|
|||
self.manual_scroll_active = false;
|
||||
}
|
||||
InputOutcome::Changed
|
||||
} else if event == crate::views::prompt_widget::PromptEvent::Edited {
|
||||
InputOutcome::Changed
|
||||
} else {
|
||||
InputOutcome::Unchanged
|
||||
}
|
||||
|
|
@ -3958,8 +3990,7 @@ impl DashboardState {
|
|||
if !filled.ends_with('/') {
|
||||
filled.push('/');
|
||||
}
|
||||
lp.picker.query = filled;
|
||||
lp.picker.query_cursor = lp.picker.query.len();
|
||||
lp.picker.set_query(filled);
|
||||
lp.picker.selected = 0;
|
||||
lp.picker.scroll_offset = None;
|
||||
// The path changed — drop any stale "Not a directory" error.
|
||||
|
|
@ -3970,23 +4001,22 @@ impl DashboardState {
|
|||
|
||||
let entry_count = lp.visible_candidates().len();
|
||||
let config = location_picker_config();
|
||||
let query_before = lp.picker.query.clone();
|
||||
let outcome =
|
||||
crate::views::picker::handle_picker_input(ev, &mut lp.picker, entry_count, &config);
|
||||
// When the user edits the path, drop the stale validation error so a
|
||||
// corrected (possibly valid) path isn't shown next to a red
|
||||
// "Not a directory" left over from the previous failed attempt.
|
||||
if lp.picker.query != query_before {
|
||||
if matches!(&outcome, crate::views::picker::PickerOutcome::QueryChanged) {
|
||||
lp.error = None;
|
||||
// Re-list only when the edited path changes; cursor motion is redraw-only.
|
||||
lp.refresh_suggestions();
|
||||
}
|
||||
// The query may have changed (typing / backspace / Ctrl+U) — re-list
|
||||
// the parent directory if its path-mode parent moved.
|
||||
lp.refresh_suggestions();
|
||||
match outcome {
|
||||
crate::views::picker::PickerOutcome::Closed => {
|
||||
InputOutcome::Action(Action::DashboardCloseLocationPicker)
|
||||
}
|
||||
crate::views::picker::PickerOutcome::Changed => InputOutcome::Changed,
|
||||
crate::views::picker::PickerOutcome::Changed
|
||||
| crate::views::picker::PickerOutcome::QueryChanged => InputOutcome::Changed,
|
||||
_ => InputOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
|
|
@ -4078,14 +4108,12 @@ impl DashboardState {
|
|||
let Some(dialog) = self.worktree_dialog.as_mut() else {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
let Event::Key(key) = ev else {
|
||||
// Consume mouse / resize while the dialog is modal.
|
||||
return InputOutcome::Unchanged;
|
||||
let outcome = match ev {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => dialog.handle_key(key),
|
||||
Event::Paste(text) => dialog.insert_paste(text),
|
||||
_ => return InputOutcome::Unchanged,
|
||||
};
|
||||
if key.kind == KeyEventKind::Release {
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
match dialog.handle_key(key) {
|
||||
match outcome {
|
||||
NewWorktreeDialogOutcome::Submitted(label) => {
|
||||
self.worktree_dialog = None;
|
||||
InputOutcome::Action(Action::DashboardConfirmWorktree { label })
|
||||
|
|
@ -4121,7 +4149,7 @@ impl DashboardState {
|
|||
/// chrome + picker pipeline via `handle_modal_key`.
|
||||
fn handle_shortcuts_modal_input(&mut self, ev: &Event) -> InputOutcome {
|
||||
use crate::views::shortcuts_help::{
|
||||
ModalKeyOutcome, ShortcutsHelpOutcome, handle_modal_key, handle_mouse,
|
||||
ModalKeyOutcome, ShortcutsHelpOutcome, handle_modal_key, handle_mouse, handle_paste,
|
||||
toggle_membership,
|
||||
};
|
||||
|
||||
|
|
@ -4213,6 +4241,10 @@ impl DashboardState {
|
|||
ShortcutsHelpOutcome::Unchanged => InputOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
Event::Paste(text) => match handle_paste(text, &mut modal.state, &modal.mode) {
|
||||
ShortcutsHelpOutcome::Changed => InputOutcome::Changed,
|
||||
_ => InputOutcome::Unchanged,
|
||||
},
|
||||
_ => InputOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
|
|
@ -4440,40 +4472,43 @@ fn dashboard_action_for_id(
|
|||
|
||||
fn handle_rename_key(draft: &mut RenameDraft, key: &KeyEvent) -> InputOutcome {
|
||||
use crate::input::key::is_altgr;
|
||||
// Reject Ctrl/Alt-modified character keys so
|
||||
// Ctrl+R / Ctrl+A / Ctrl+V don't smuggle a bare letter into the
|
||||
// draft. Ctrl+C is explicitly mapped to cancel.
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !is_altgr(key.modifiers)
|
||||
&& let KeyCode::Char(c) = key.code
|
||||
{
|
||||
if c == 'c' {
|
||||
match key.code {
|
||||
KeyCode::Esc => return InputOutcome::Action(Action::DashboardCancelRename),
|
||||
KeyCode::Enter if key.modifiers.is_empty() => {
|
||||
return InputOutcome::Action(Action::DashboardCommitRename);
|
||||
}
|
||||
KeyCode::Char('c')
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) && !is_altgr(key.modifiers) =>
|
||||
{
|
||||
return InputOutcome::Action(Action::DashboardCancelRename);
|
||||
}
|
||||
return InputOutcome::Unchanged;
|
||||
_ => {}
|
||||
}
|
||||
if key.modifiers.contains(KeyModifiers::ALT) && !is_altgr(key.modifiers) {
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Esc => InputOutcome::Action(Action::DashboardCancelRename),
|
||||
KeyCode::Enter => InputOutcome::Action(Action::DashboardCommitRename),
|
||||
KeyCode::Backspace => {
|
||||
draft.draft.pop();
|
||||
InputOutcome::Action(Action::DashboardRenameInput(draft.draft.clone()))
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
// Reject control characters and zero-width chars.
|
||||
if c.is_control() {
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
// Cap at 100 chars to match the worktree dialog input.
|
||||
if draft.draft.chars().count() < 100 {
|
||||
draft.draft.push(c);
|
||||
}
|
||||
InputOutcome::Action(Action::DashboardRenameInput(draft.draft.clone()))
|
||||
}
|
||||
_ => InputOutcome::Unchanged,
|
||||
|
||||
let can_insert = draft.text().chars().count() < MAX_RENAME_SCALARS;
|
||||
let outcome = draft
|
||||
.editor
|
||||
.handle_key_with_insert_policy(key, |character| {
|
||||
can_insert && rename_character_allowed(character)
|
||||
});
|
||||
rename_edit_outcome(outcome)
|
||||
}
|
||||
|
||||
fn handle_rename_paste(draft: &mut RenameDraft, text: &str) -> InputOutcome {
|
||||
let remaining = MAX_RENAME_SCALARS.saturating_sub(draft.text().chars().count());
|
||||
let outcome =
|
||||
draft
|
||||
.editor
|
||||
.insert_paste_with_policy(text, rename_wire_character_allowed, remaining);
|
||||
rename_edit_outcome(outcome)
|
||||
}
|
||||
|
||||
fn rename_edit_outcome(outcome: LineEditOutcome) -> InputOutcome {
|
||||
match outcome {
|
||||
LineEditOutcome::TextChanged
|
||||
| LineEditOutcome::HandledNoChange
|
||||
| LineEditOutcome::CursorChanged => InputOutcome::Changed,
|
||||
LineEditOutcome::Unhandled => InputOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5388,45 +5423,38 @@ mod tests {
|
|||
/// Rename cap is honored exactly.
|
||||
#[test]
|
||||
fn rename_at_cap_drops_extra_char() {
|
||||
let mut draft = RenameDraft {
|
||||
row: DashboardRowId::TopLevel(AgentId(0)),
|
||||
draft: "a".repeat(100),
|
||||
};
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "a".repeat(100));
|
||||
let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE);
|
||||
let _ = handle_rename_key(&mut draft, &key);
|
||||
assert_eq!(draft.draft.chars().count(), 100);
|
||||
let outcome = handle_rename_key(&mut draft, &key);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text().chars().count(), 100);
|
||||
assert!(
|
||||
draft.draft.ends_with('a'),
|
||||
draft.text().ends_with('a'),
|
||||
"char at cap should NOT be replaced: got {:?}",
|
||||
draft.draft
|
||||
draft.text()
|
||||
);
|
||||
}
|
||||
|
||||
/// under-cap appends correctly.
|
||||
#[test]
|
||||
fn rename_under_cap_appends() {
|
||||
let mut draft = RenameDraft {
|
||||
row: DashboardRowId::TopLevel(AgentId(0)),
|
||||
draft: "a".repeat(99),
|
||||
};
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "a".repeat(99));
|
||||
let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE);
|
||||
let _ = handle_rename_key(&mut draft, &key);
|
||||
assert_eq!(draft.draft.chars().count(), 100);
|
||||
assert!(draft.draft.ends_with('b'));
|
||||
let outcome = handle_rename_key(&mut draft, &key);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text().chars().count(), 100);
|
||||
assert!(draft.text().ends_with('b'));
|
||||
}
|
||||
|
||||
/// Ctrl+letter in rename mode rejected (does not type
|
||||
/// the bare letter into the draft); Ctrl+C cancels.
|
||||
#[test]
|
||||
fn rename_rejects_ctrl_chars() {
|
||||
let mut draft = RenameDraft {
|
||||
row: DashboardRowId::TopLevel(AgentId(0)),
|
||||
draft: "hello".to_string(),
|
||||
};
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "hello");
|
||||
let ctrl_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
|
||||
let outcome = handle_rename_key(&mut draft, &ctrl_r);
|
||||
assert!(matches!(outcome, InputOutcome::Unchanged));
|
||||
assert_eq!(draft.draft, "hello", "draft must not gain 'r'");
|
||||
assert_eq!(draft.text(), "hello", "draft must not gain 'r'");
|
||||
// Ctrl+C → cancel.
|
||||
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
|
||||
let outcome = handle_rename_key(&mut draft, &ctrl_c);
|
||||
|
|
@ -5436,6 +5464,125 @@ mod tests {
|
|||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_word_motion_is_canonical_and_cursor_only() {
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Left, KeyModifiers::ALT),
|
||||
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT),
|
||||
KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL),
|
||||
] {
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "hello-world");
|
||||
let outcome = handle_rename_key(&mut draft, &key);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text(), "hello-world");
|
||||
assert_eq!(draft.cursor_byte(), "hello-".len());
|
||||
}
|
||||
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Right, KeyModifiers::ALT),
|
||||
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT),
|
||||
] {
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "hello-world");
|
||||
let _ = handle_rename_key(
|
||||
&mut draft,
|
||||
&KeyEvent::new(KeyCode::Home, KeyModifiers::NONE),
|
||||
);
|
||||
let outcome = handle_rename_key(&mut draft, &key);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.cursor_byte(), "hello".len());
|
||||
}
|
||||
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "hello-world");
|
||||
let outcome = handle_rename_key(
|
||||
&mut draft,
|
||||
&KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT),
|
||||
);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text(), "hello-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_grapheme_delete_and_middle_insert() {
|
||||
let grapheme = "👩🏽\u{200d}💻";
|
||||
let mut draft = RenameDraft::new(
|
||||
DashboardRowId::TopLevel(AgentId(0)),
|
||||
format!("a{grapheme}b"),
|
||||
);
|
||||
let _ = handle_rename_key(
|
||||
&mut draft,
|
||||
&KeyEvent::new(KeyCode::Home, KeyModifiers::NONE),
|
||||
);
|
||||
let _ = handle_rename_key(
|
||||
&mut draft,
|
||||
&KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
|
||||
);
|
||||
let outcome = handle_rename_key(
|
||||
&mut draft,
|
||||
&KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text(), "ab");
|
||||
|
||||
let outcome = handle_rename_key(
|
||||
&mut draft,
|
||||
&KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text(), "aXb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_policy_and_paste_preserve_scalar_cap() {
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "a".repeat(99));
|
||||
let outcome = handle_rename_key(
|
||||
&mut draft,
|
||||
&KeyEvent::new(KeyCode::Char('\u{202e}'), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text().chars().count(), 99);
|
||||
|
||||
let outcome = handle_rename_paste(&mut draft, "中\r\n文");
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text().chars().count(), 100);
|
||||
assert!(draft.text().ends_with('中'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_enter_does_not_commit_rename() {
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "name");
|
||||
for modifiers in [KeyModifiers::ALT, KeyModifiers::SHIFT] {
|
||||
let outcome = handle_rename_key(&mut draft, &KeyEvent::new(KeyCode::Enter, modifiers));
|
||||
assert!(!matches!(
|
||||
outcome,
|
||||
InputOutcome::Action(Action::DashboardCommitRename)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_paste_preserves_emoji_zwj_sequences() {
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "");
|
||||
let outcome = handle_rename_paste(&mut draft, "👩💻");
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text(), "👩💻");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_mode_routes_bracketed_paste_only_to_rename_editor() {
|
||||
let mut state = DashboardState::new();
|
||||
state.dispatch.set_text("hidden dispatch");
|
||||
state.rename = Some(RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "ab"));
|
||||
let registry = crate::actions::ActionRegistry::defaults();
|
||||
let _ = state.handle_input(
|
||||
&Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)),
|
||||
®istry,
|
||||
);
|
||||
let outcome = state.handle_input(&Event::Paste("中\r\n".to_owned()), ®istry);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(state.rename.as_ref().map(RenameDraft::text), Some("a中b"));
|
||||
assert_eq!(state.dispatch.text(), "hidden dispatch");
|
||||
}
|
||||
|
||||
/// Esc-cancelling the worktree-label dialog must restore the stashed
|
||||
/// prompt (from the prompt-send path) to the dispatch input instead of
|
||||
/// silently discarding the user's typed text. Mirrors the restore in
|
||||
|
|
@ -8694,6 +8841,25 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_mode_cursor_only_edit_redraws_without_filter_change() {
|
||||
let mut state = DashboardState::new();
|
||||
let registry = crate::actions::ActionRegistry::defaults();
|
||||
state.enter_search_mode();
|
||||
state.dispatch.set_text("auth");
|
||||
state.dispatch.set_cursor(0);
|
||||
state.filter = Filter::Substring("auth".to_owned());
|
||||
|
||||
let outcome = state.handle_input(
|
||||
&Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
|
||||
®istry,
|
||||
);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(state.dispatch.text(), "auth");
|
||||
assert_eq!(state.dispatch.cursor(), 1);
|
||||
assert!(matches!(&state.filter, Filter::Substring(text) if text == "auth"));
|
||||
}
|
||||
|
||||
/// Esc in search mode CANCELS: clears the filter and exits.
|
||||
#[test]
|
||||
fn search_mode_esc_cancels_and_clears_filter() {
|
||||
|
|
@ -9361,7 +9527,7 @@ mod tests {
|
|||
let m = s.shortcuts_modal.as_ref().unwrap();
|
||||
(
|
||||
m.state.selected,
|
||||
m.state.query.clone(),
|
||||
m.state.query().to_owned(),
|
||||
m.filter_active,
|
||||
m.collapsed_sections.clone(),
|
||||
m.expanded_ids.clone(),
|
||||
|
|
@ -10119,7 +10285,7 @@ mod tests {
|
|||
location_candidate("/home/me/alpha", "alpha"),
|
||||
location_candidate("/home/me/beta", "beta"),
|
||||
]);
|
||||
lp.picker.query = "bet".to_string();
|
||||
lp.picker.set_query("bet");
|
||||
assert_eq!(visible_labels(&lp), vec!["beta"]);
|
||||
}
|
||||
|
||||
|
|
@ -10127,11 +10293,11 @@ mod tests {
|
|||
fn location_query_is_path_detection() {
|
||||
let mut lp = location_picker(vec![]);
|
||||
for q in ["/abs", "~/x", "rel/sub", "~"] {
|
||||
lp.picker.query = q.to_string();
|
||||
lp.picker.set_query(q);
|
||||
assert!(lp.query_is_path(), "`{q}` should be path mode");
|
||||
}
|
||||
for q in ["", "alpha", "bet"] {
|
||||
lp.picker.query = q.to_string();
|
||||
lp.picker.set_query(q);
|
||||
assert!(!lp.query_is_path(), "`{q}` should be recents mode");
|
||||
}
|
||||
}
|
||||
|
|
@ -10154,7 +10320,7 @@ mod tests {
|
|||
fn location_chosen_input_falls_back_to_typed_path() {
|
||||
let mut lp = location_picker(vec![location_candidate("/home/me/alpha", "alpha")]);
|
||||
// A path with no matching suggestion → the raw typed path is used.
|
||||
lp.picker.query = "/no/such/dir".to_string();
|
||||
lp.picker.set_query("/no/such/dir");
|
||||
assert_eq!(lp.chosen_input().as_deref(), Some("/no/such/dir"));
|
||||
}
|
||||
|
||||
|
|
@ -10177,7 +10343,7 @@ mod tests {
|
|||
let mut lp = location_picker(vec![]);
|
||||
|
||||
// Trailing slash → list the (non-hidden) subdirs.
|
||||
lp.picker.query = format!("{}/", tmp.path().display());
|
||||
lp.picker.set_query(format!("{}/", tmp.path().display()));
|
||||
lp.refresh_suggestions();
|
||||
let labels = visible_labels(&lp);
|
||||
assert!(labels.contains(&"alpha".to_string()), "got: {labels:?}");
|
||||
|
|
@ -10188,12 +10354,12 @@ mod tests {
|
|||
);
|
||||
|
||||
// Prefix filter on the final segment.
|
||||
lp.picker.query = format!("{}/al", tmp.path().display());
|
||||
lp.picker.set_query(format!("{}/al", tmp.path().display()));
|
||||
lp.refresh_suggestions();
|
||||
assert_eq!(visible_labels(&lp), vec!["alpha"]);
|
||||
|
||||
// A leading dot in the partial reveals dot-directories.
|
||||
lp.picker.query = format!("{}/.h", tmp.path().display());
|
||||
lp.picker.set_query(format!("{}/.h", tmp.path().display()));
|
||||
lp.refresh_suggestions();
|
||||
assert_eq!(visible_labels(&lp), vec![".hidden"]);
|
||||
}
|
||||
|
|
@ -10209,7 +10375,7 @@ mod tests {
|
|||
worktrees.insert(canon.join("wt"), "my-feature".to_string());
|
||||
|
||||
let mut lp = location_picker_with_worktrees(vec![], worktrees);
|
||||
lp.picker.query = format!("{}/", tmp.path().display());
|
||||
lp.picker.set_query(format!("{}/", tmp.path().display()));
|
||||
lp.refresh_suggestions();
|
||||
|
||||
let visible = lp.visible_candidates();
|
||||
|
|
@ -10239,7 +10405,7 @@ mod tests {
|
|||
worktrees.insert(real_canon, "linked-wt".to_string());
|
||||
|
||||
let mut lp = location_picker_with_worktrees(vec![], worktrees);
|
||||
lp.picker.query = format!("{}/", parent.path().display());
|
||||
lp.picker.set_query(format!("{}/", parent.path().display()));
|
||||
lp.refresh_suggestions();
|
||||
|
||||
let link = lp
|
||||
|
|
@ -10351,8 +10517,8 @@ mod tests {
|
|||
InputOutcome::Changed
|
||||
));
|
||||
let lp = state.location_picker.as_ref().unwrap();
|
||||
assert_eq!(lp.picker.query, "/opt/projects/beta/");
|
||||
assert_eq!(lp.picker.query_cursor, lp.picker.query.len());
|
||||
assert_eq!(lp.picker.query(), "/opt/projects/beta/");
|
||||
assert_eq!(lp.picker.query_cursor(), lp.picker.query().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -10360,7 +10526,7 @@ mod tests {
|
|||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("alpha")).unwrap();
|
||||
let mut lp = location_picker(vec![]);
|
||||
lp.picker.query = format!("{}/al", tmp.path().display());
|
||||
lp.picker.set_query(format!("{}/al", tmp.path().display()));
|
||||
lp.refresh_suggestions();
|
||||
|
||||
let mut state = DashboardState::new();
|
||||
|
|
@ -10458,19 +10624,21 @@ mod tests {
|
|||
let (id, mut agents) = lease_fixture_agent();
|
||||
let mut dash = DashboardState::new();
|
||||
dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents);
|
||||
{
|
||||
let page_flip_entry = {
|
||||
let sb = &mut agents.get_mut(&id).unwrap().scrollback;
|
||||
sb.prepare_layout(40, 6);
|
||||
let last = sb.len().saturating_sub(1);
|
||||
let entry_id = sb.entry(last).unwrap().id;
|
||||
sb.set_selected(Some(last));
|
||||
sb.scroll_to_entry_top(last);
|
||||
sb.enable_follow_with_preserve();
|
||||
}
|
||||
entry_id
|
||||
};
|
||||
assert!(agents[&id].scrollback.is_follow_preserve_scroll());
|
||||
dash.note_page_flip_for_lease(id, &mut agents);
|
||||
dash.note_page_flip_for_lease(id, page_flip_entry, &agents);
|
||||
assert_eq!(
|
||||
dash.peek_viewport.as_ref().and_then(|l| l.page_flip_entry),
|
||||
Some(agents[&id].scrollback.len().saturating_sub(1))
|
||||
Some(page_flip_entry)
|
||||
);
|
||||
|
||||
dash.restore_peek_viewport(&mut agents);
|
||||
|
|
@ -10513,11 +10681,17 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn note_page_flip_from_scroll_only_when_row_matches() {
|
||||
fn note_page_flip_only_when_row_and_entry_match() {
|
||||
let (id, mut agents) = lease_fixture_agent();
|
||||
let mut dash = DashboardState::new();
|
||||
dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents);
|
||||
dash.note_page_flip_from_scroll(AgentId(99), Some(3), Some(1));
|
||||
let entry_id = agents[&id].scrollback.entry(3).unwrap().id;
|
||||
agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.scrollback
|
||||
.enable_follow_with_preserve();
|
||||
dash.note_page_flip_for_lease(AgentId(99), entry_id, &agents);
|
||||
assert!(
|
||||
dash.peek_viewport
|
||||
.as_ref()
|
||||
|
|
@ -10525,11 +10699,45 @@ mod tests {
|
|||
.page_flip_entry
|
||||
.is_none()
|
||||
);
|
||||
dash.note_page_flip_from_scroll(id, Some(3), Some(1));
|
||||
dash.note_page_flip_for_lease(id, crate::scrollback::EntryId::new(u64::MAX), &agents);
|
||||
assert!(
|
||||
dash.peek_viewport
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.page_flip_entry
|
||||
.is_none()
|
||||
);
|
||||
dash.note_page_flip_for_lease(id, entry_id, &agents);
|
||||
let lease = dash.peek_viewport.as_ref().unwrap();
|
||||
assert_eq!(lease.page_flip_entry, Some(3));
|
||||
assert!(lease.snapshot.follow_preserve_scroll);
|
||||
assert_eq!(lease.snapshot.selected, Some(3));
|
||||
assert_eq!(lease.page_flip_entry, Some(entry_id));
|
||||
assert!(!lease.snapshot.follow_preserve_scroll);
|
||||
assert_eq!(lease.snapshot.selected, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_ignores_page_flip_entry_removed_during_lease() {
|
||||
let (id, mut agents) = lease_fixture_agent();
|
||||
let pre = agents[&id].scrollback.capture_viewport_snapshot();
|
||||
let mut dash = DashboardState::new();
|
||||
dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents);
|
||||
let entry_id = agents[&id].scrollback.entry(2).unwrap().id;
|
||||
agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.scrollback
|
||||
.enable_follow_with_preserve();
|
||||
dash.note_page_flip_for_lease(id, entry_id, &agents);
|
||||
agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.scrollback
|
||||
.remove_entry(entry_id);
|
||||
|
||||
dash.restore_peek_viewport(&mut agents);
|
||||
|
||||
assert!(dash.peek_viewport.is_none());
|
||||
assert_eq!(agents[&id].scrollback.selected(), pre.selected);
|
||||
assert_eq!(agents[&id].scrollback.is_follow_mode(), pre.follow_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -10549,21 +10757,22 @@ mod tests {
|
|||
},
|
||||
&mut agents,
|
||||
);
|
||||
dash.note_page_flip_from_scroll(id, Some(3), Some(1));
|
||||
let entry_id = agents[&id].scrollback.entry(3).unwrap().id;
|
||||
dash.note_page_flip_for_lease(id, entry_id, &agents);
|
||||
assert!(
|
||||
dash.peek_viewport
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.page_flip_entry
|
||||
.is_none(),
|
||||
"parent drain must not write parent indices onto a subagent lease"
|
||||
"parent drain must not write parent entries onto a subagent lease"
|
||||
);
|
||||
agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.scrollback
|
||||
.enable_follow_with_preserve();
|
||||
dash.note_page_flip_for_lease(id, &mut agents);
|
||||
dash.note_page_flip_for_lease(id, entry_id, &agents);
|
||||
assert!(
|
||||
dash.peek_viewport
|
||||
.as_ref()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1007,13 +1007,13 @@ fn build_source_lines(path: &Path, content: &str) -> Vec<SourceLine> {
|
|||
.enumerate()
|
||||
.map(|(i, text)| {
|
||||
let line_number = i + 1;
|
||||
let styled_line = if text.is_empty() {
|
||||
// Empty line — still needs a Line (for line number prefix).
|
||||
Line::from(" ".to_owned())
|
||||
} else if let Some(ref mut hl) = highlighter {
|
||||
highlight_to_ratatui_line(hl, text, &syntect.syntax_set)
|
||||
} else {
|
||||
Line::from((*text).to_owned())
|
||||
// Feed every line, blank ones included, through the highlighter so
|
||||
// its parse state stays in sync. Skipping blanks corrupts constructs
|
||||
// that span multiple lines (block comments, multi-line strings).
|
||||
let styled_line = match highlighter.as_mut() {
|
||||
Some(hl) => highlight_to_ratatui_line(hl, text, &syntect.syntax_set),
|
||||
None if text.is_empty() => Line::from(" ".to_owned()),
|
||||
None => Line::from((*text).to_owned()),
|
||||
};
|
||||
SourceLine::new(line_number, styled_line, (*text).to_owned(), max_digits)
|
||||
})
|
||||
|
|
@ -1128,19 +1128,31 @@ fn highlight_to_ratatui_line(
|
|||
text: &str,
|
||||
syntax_set: &syntect::parsing::SyntaxSet,
|
||||
) -> Line<'static> {
|
||||
let highlighted = match hl.highlight_line(text, syntax_set) {
|
||||
// syntect needs the trailing newline to recognize line-spanning constructs.
|
||||
// Feed it, then strip the newline back out of the rendered spans.
|
||||
let with_newline = format!("{text}\n");
|
||||
let highlighted = match hl.highlight_line(&with_newline, syntax_set) {
|
||||
Ok(h) => h,
|
||||
Err(_) if text.is_empty() => return Line::from(" ".to_owned()),
|
||||
Err(_) => return Line::from(text.to_owned()),
|
||||
};
|
||||
|
||||
let spans: Vec<Span<'static>> = highlighted
|
||||
.into_iter()
|
||||
.map(|(style, content)| {
|
||||
let fg = syntect_to_ratatui_color(style.foreground);
|
||||
Span::styled(content.to_owned(), Style::default().fg(fg))
|
||||
})
|
||||
.collect();
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
for (style, segment) in highlighted {
|
||||
let mut piece = segment.to_owned();
|
||||
while piece.ends_with('\n') || piece.ends_with('\r') {
|
||||
piece.pop();
|
||||
}
|
||||
if piece.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let fg = syntect_to_ratatui_color(style.foreground);
|
||||
spans.push(Span::styled(piece, Style::default().fg(fg)));
|
||||
}
|
||||
|
||||
if spans.is_empty() {
|
||||
return Line::from(" ".to_owned());
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1500,6 +1500,29 @@ impl ListPaneState {
|
|||
// Keyboard input
|
||||
// =======================================================================
|
||||
|
||||
/// Paste into the active input bar. Returns `false` when no editor is open.
|
||||
pub fn handle_paste<T: ListItem>(&mut self, text: &str, items: &[T]) -> bool {
|
||||
let Some(mode) = self.input_mode else {
|
||||
return false;
|
||||
};
|
||||
let old_text = self.input_textarea.text().to_owned();
|
||||
if mode == InputBarMode::Comment {
|
||||
self.input_textarea.insert_str(text);
|
||||
} else {
|
||||
let cleaned = crate::input::line_editor::sanitize_single_line(text);
|
||||
self.input_textarea.insert_str(&cleaned);
|
||||
}
|
||||
if self.input_textarea.text() == old_text {
|
||||
return false;
|
||||
}
|
||||
match mode {
|
||||
InputBarMode::GotoLine => self.apply_goto_line_live(items),
|
||||
InputBarMode::Search | InputBarMode::Filter => self.apply_input_buffer(items),
|
||||
InputBarMode::Comment => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Handle a key event for navigation, search, and filter.
|
||||
///
|
||||
/// Returns `true` if the key was consumed (state changed), `false` if
|
||||
|
|
|
|||
|
|
@ -2747,4 +2747,32 @@ mod tests {
|
|||
state.scroll_offset,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_targets_only_active_list_input_and_preserves_comment_newlines() {
|
||||
let items = vec![
|
||||
TestItem::new(0).with_text("alpha"),
|
||||
TestItem::new(1).with_text("beta"),
|
||||
];
|
||||
let mut state =
|
||||
ListPaneState::new_with_config(WrapMode::NoWrap, false, ListPaneConfig::streaming());
|
||||
state.prepare_layout(&items, 80, 4);
|
||||
assert!(state.handle_key_event(&key!('/').to_key_event(), &items));
|
||||
assert!(state.handle_key_event(&key!('a').to_key_event(), &items));
|
||||
assert!(state.handle_key_event(&key!('b').to_key_event(), &items));
|
||||
assert!(state.handle_key_event(&key!(Left).to_key_event(), &items));
|
||||
assert!(state.handle_paste("中\r\n", &items));
|
||||
assert_eq!(state.input_text(), "a中b");
|
||||
assert_eq!(state.matcher().map(ListMatcher::query), Some("a中b"));
|
||||
|
||||
assert!(!state.handle_paste("\r\n", &items));
|
||||
assert_eq!(state.input_text(), "a中b");
|
||||
|
||||
state.close_input_bar();
|
||||
assert!(!state.handle_paste("ignored", &items));
|
||||
|
||||
state.open_comment_input("");
|
||||
assert!(state.handle_paste("a\nb", &items));
|
||||
assert_eq!(state.input_text(), "a\nb");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ use unicode_width::UnicodeWidthStr;
|
|||
|
||||
use crate::app::actions::Action;
|
||||
use crate::app::app_view::InputOutcome;
|
||||
use crate::input::line_editor::{LineEditOutcome, LineEditor};
|
||||
|
||||
use crate::render::SafeBuf;
|
||||
use crate::render::scrollbar::{ScrollbarClickResult, render_scrollbar, scrollbar_click_to_offset};
|
||||
|
|
@ -69,7 +70,7 @@ pub struct MemoryModalState {
|
|||
pub preview_markdown: Option<MarkdownContent>,
|
||||
pub preview_scroll: usize,
|
||||
pub mode: MemoryModalMode,
|
||||
pub query: String,
|
||||
query: LineEditor,
|
||||
/// Whether memory is currently enabled for this session.
|
||||
pub memory_enabled: bool,
|
||||
/// Whether the modal is rendered in fullscreen mode (persisted to config).
|
||||
|
|
@ -96,7 +97,7 @@ impl MemoryModalState {
|
|||
preview_markdown: None,
|
||||
preview_scroll: 0,
|
||||
mode: MemoryModalMode::Browse,
|
||||
query: String::new(),
|
||||
query: LineEditor::default(),
|
||||
memory_enabled: true,
|
||||
fullscreen: load_fullscreen_pref(),
|
||||
filtered_cache,
|
||||
|
|
@ -115,8 +116,31 @@ impl MemoryModalState {
|
|||
&self.filtered_cache
|
||||
}
|
||||
|
||||
pub fn query(&self) -> &str {
|
||||
self.query.text()
|
||||
}
|
||||
|
||||
pub fn query_cursor_byte(&self) -> usize {
|
||||
self.query.cursor_byte()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn set_query(&mut self, query: impl Into<String>) {
|
||||
self.query.set_text(query);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn set_query_cursor_byte(&mut self, cursor_byte: usize) -> LineEditOutcome {
|
||||
self.query.set_cursor_byte(cursor_byte)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn query_viewport(&self, width: usize) -> xai_ratatui_textarea::SingleLineViewport {
|
||||
self.query.viewport(width)
|
||||
}
|
||||
|
||||
fn invalidate_filter(&mut self) {
|
||||
self.filtered_cache = compute_filtered(&self.entries, &self.query);
|
||||
self.filtered_cache = compute_filtered(&self.entries, self.query());
|
||||
}
|
||||
|
||||
pub fn selected_entry(&self) -> Option<&MemoryFileEntry> {
|
||||
|
|
@ -448,40 +472,46 @@ pub fn render_memory_modal(
|
|||
fn render_file_list(buf: &mut Buffer, area: Rect, state: &mut MemoryModalState, theme: &Theme) {
|
||||
let search_y = area.y;
|
||||
let filter_focused = matches!(state.mode, MemoryModalMode::FilterFocused);
|
||||
let (query_display, query_style) = if state.query.is_empty() {
|
||||
let viewport = state.query.viewport(area.width as usize);
|
||||
if state.query().is_empty() {
|
||||
let placeholder = if filter_focused {
|
||||
"type to filter..."
|
||||
} else {
|
||||
"/ to filter..."
|
||||
};
|
||||
(
|
||||
placeholder,
|
||||
Style::default().fg(theme.gray_dim).bg(theme.bg_base),
|
||||
)
|
||||
buf.set_span(
|
||||
area.x,
|
||||
search_y,
|
||||
&Span::styled(
|
||||
placeholder,
|
||||
Style::default().fg(theme.gray_dim).bg(theme.bg_base),
|
||||
),
|
||||
area.width,
|
||||
);
|
||||
} else {
|
||||
(
|
||||
state.query.as_str(),
|
||||
Style::default().fg(theme.text_primary).bg(theme.bg_base),
|
||||
)
|
||||
};
|
||||
buf.set_span(
|
||||
area.x,
|
||||
search_y,
|
||||
&Span::styled(query_display, query_style),
|
||||
area.width,
|
||||
);
|
||||
let leading;
|
||||
let visible = if filter_focused {
|
||||
&state.query()[viewport.visible_byte_range.clone()]
|
||||
} else {
|
||||
leading = crate::render::line_utils::truncate_str(state.query(), area.width as usize);
|
||||
&leading
|
||||
};
|
||||
buf.set_span(
|
||||
area.x,
|
||||
search_y,
|
||||
&Span::styled(
|
||||
visible,
|
||||
Style::default().fg(theme.text_primary).bg(theme.bg_base),
|
||||
),
|
||||
area.width,
|
||||
);
|
||||
}
|
||||
if filter_focused {
|
||||
let cursor_x = area.x + state.query.width() as u16;
|
||||
if cursor_x < area.x + area.width {
|
||||
buf.set_span(
|
||||
cursor_x,
|
||||
search_y,
|
||||
&Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default().fg(theme.accent_user).bg(theme.bg_base),
|
||||
),
|
||||
1,
|
||||
);
|
||||
let cursor_x = area.x + viewport.cursor_display_column as u16;
|
||||
if cursor_x < area.x + area.width
|
||||
&& let Some(cell) = buf.cell_mut((cursor_x, search_y))
|
||||
{
|
||||
cell.set_style(Style::default().fg(theme.bg_base).bg(theme.text_primary));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -710,6 +740,14 @@ pub fn handle_memory_key(state: &mut MemoryModalState, key: &KeyEvent) -> InputO
|
|||
}
|
||||
}
|
||||
|
||||
pub fn handle_memory_paste(state: &mut MemoryModalState, text: &str) -> InputOutcome {
|
||||
if state.mode != MemoryModalMode::FilterFocused {
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
let outcome = state.query.insert_paste(text);
|
||||
finish_filter_edit(state, outcome)
|
||||
}
|
||||
|
||||
/// Saturating cast from `usize` to `u16` (caps at `u16::MAX`).
|
||||
fn sat_u16(v: usize) -> u16 {
|
||||
v.min(u16::MAX as usize) as u16
|
||||
|
|
@ -880,22 +918,23 @@ fn handle_filter_focused(state: &mut MemoryModalState, key: &KeyEvent) -> InputO
|
|||
state.select_prev();
|
||||
InputOutcome::Changed
|
||||
}
|
||||
KeyCode::Char(c) if crate::input::key::is_text_input_key(key) => {
|
||||
state.query.push(c);
|
||||
_ => {
|
||||
let outcome = state.query.handle_key(key);
|
||||
|
||||
finish_filter_edit(state, outcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_filter_edit(state: &mut MemoryModalState, outcome: LineEditOutcome) -> InputOutcome {
|
||||
match outcome {
|
||||
LineEditOutcome::TextChanged => {
|
||||
state.invalidate_filter();
|
||||
state.clamp_selected();
|
||||
InputOutcome::Changed
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if state.query.pop().is_some() {
|
||||
state.invalidate_filter();
|
||||
state.clamp_selected();
|
||||
InputOutcome::Changed
|
||||
} else {
|
||||
InputOutcome::Unchanged
|
||||
}
|
||||
}
|
||||
_ => InputOutcome::Unchanged,
|
||||
LineEditOutcome::CursorChanged | LineEditOutcome::HandledNoChange => InputOutcome::Changed,
|
||||
LineEditOutcome::Unhandled => InputOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -974,7 +1013,7 @@ fn handle_browse(state: &mut MemoryModalState, key: &KeyEvent) -> InputOutcome {
|
|||
InputOutcome::Changed
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if state.query.pop().is_some() {
|
||||
if state.query.delete_last_grapheme() == LineEditOutcome::TextChanged {
|
||||
state.invalidate_filter();
|
||||
state.clamp_selected();
|
||||
InputOutcome::Changed
|
||||
|
|
@ -1215,7 +1254,7 @@ mod tests {
|
|||
fn filtered_indices_preserves_headers_for_matching_entries() {
|
||||
let entries = build_test_entries();
|
||||
let mut state = MemoryModalState::new(entries);
|
||||
state.query = "memory".to_string();
|
||||
state.set_query("memory");
|
||||
state.invalidate_filter();
|
||||
|
||||
let indices = state.filtered_indices();
|
||||
|
|
@ -1313,12 +1352,12 @@ mod tests {
|
|||
let mut state = MemoryModalState::new(entries);
|
||||
assert_eq!(state.filtered_indices().len(), 4); // all entries
|
||||
|
||||
state.query = "session".to_string();
|
||||
state.set_query("session");
|
||||
state.invalidate_filter();
|
||||
// Only the Sessions header + session-log.md should match.
|
||||
assert_eq!(state.filtered_indices().len(), 2);
|
||||
|
||||
state.query.clear();
|
||||
state.set_query("");
|
||||
state.invalidate_filter();
|
||||
assert_eq!(state.filtered_indices().len(), 4);
|
||||
}
|
||||
|
|
@ -1459,6 +1498,143 @@ mod tests {
|
|||
assert_eq!(state.preview_scroll, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_text_changes_recompute_preview_but_cursor_moves_do_not() {
|
||||
let mut state = MemoryModalState::new(build_test_entries());
|
||||
state.mode = MemoryModalMode::FilterFocused;
|
||||
state.preview_scroll = 7;
|
||||
|
||||
let outcome = handle_memory_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(state.query(), "m");
|
||||
assert_eq!(state.preview_scroll, 0);
|
||||
let filtered = state.filtered_indices().to_vec();
|
||||
|
||||
state.preview_scroll = 7;
|
||||
let outcome = handle_memory_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(state.query(), "m");
|
||||
assert_eq!(state.query_cursor_byte(), 0);
|
||||
assert_eq!(state.filtered_indices(), filtered);
|
||||
assert_eq!(state.preview_scroll, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_paste_recomputes_once_and_consumes_empty_input() {
|
||||
let mut state = MemoryModalState::new(build_test_entries());
|
||||
state.mode = MemoryModalMode::FilterFocused;
|
||||
state.preview_scroll = 7;
|
||||
let outcome = handle_memory_paste(&mut state, "mem\r\n");
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(state.query(), "mem");
|
||||
assert_eq!(state.preview_scroll, 0);
|
||||
|
||||
state.preview_scroll = 7;
|
||||
let outcome = handle_memory_paste(&mut state, "\r\n");
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(state.query(), "mem");
|
||||
assert_eq!(state.preview_scroll, 7);
|
||||
|
||||
state.mode = MemoryModalMode::Browse;
|
||||
let outcome = handle_memory_paste(&mut state, "ignored");
|
||||
assert!(matches!(outcome, InputOutcome::Unchanged));
|
||||
assert_eq!(state.query(), "mem");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_escape_preserves_query_and_enter_stays_focused() {
|
||||
let mut state = MemoryModalState::new(build_test_entries());
|
||||
state.mode = MemoryModalMode::FilterFocused;
|
||||
state.set_query("memory");
|
||||
|
||||
let outcome = handle_memory_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(matches!(outcome, InputOutcome::Unchanged));
|
||||
assert_eq!(state.mode, MemoryModalMode::FilterFocused);
|
||||
assert_eq!(state.query(), "memory");
|
||||
|
||||
let outcome =
|
||||
handle_memory_key(&mut state, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(state.mode, MemoryModalMode::Browse);
|
||||
assert_eq!(state.query(), "memory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_uses_canonical_word_and_grapheme_editing() {
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Left, KeyModifiers::ALT),
|
||||
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT),
|
||||
KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL),
|
||||
] {
|
||||
let mut state = MemoryModalState::new(build_test_entries());
|
||||
state.mode = MemoryModalMode::FilterFocused;
|
||||
state.set_query("hello-world");
|
||||
let outcome = handle_memory_key(&mut state, &key);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(state.query(), "hello-world");
|
||||
assert_eq!(state.query_cursor_byte(), "hello-".len());
|
||||
}
|
||||
|
||||
let grapheme = "👩🏽\u{200d}💻";
|
||||
let mut state = MemoryModalState::new(build_test_entries());
|
||||
state.mode = MemoryModalMode::FilterFocused;
|
||||
state.set_query(format!("a{grapheme}b"));
|
||||
let _ = state.set_query_cursor_byte(1);
|
||||
let outcome = handle_memory_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(state.query(), "ab");
|
||||
assert_eq!(state.query_cursor_byte(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browse_backspace_deletes_trailing_grapheme_independent_of_cursor_and_modifiers() {
|
||||
let mut state = MemoryModalState::new(build_test_entries());
|
||||
let grapheme = "👩🏽\u{200d}💻";
|
||||
state.set_query(format!("a{grapheme}"));
|
||||
let _ = state.set_query_cursor_byte(0);
|
||||
let outcome = handle_memory_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(state.query(), "a");
|
||||
assert_eq!(state.query_cursor_byte(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_render_keeps_unicode_query_and_cursor_visible() {
|
||||
let mut state = MemoryModalState::new(build_test_entries());
|
||||
state.mode = MemoryModalMode::FilterFocused;
|
||||
let grapheme = "👩🏽\u{200d}💻";
|
||||
let text = format!("123456789012中e\u{301}{grapheme}z");
|
||||
state.set_query(&text);
|
||||
let _ = state.set_query_cursor_byte(text.len() - 1);
|
||||
let area = Rect::new(0, 0, 12, 3);
|
||||
let theme = Theme::current();
|
||||
let mut buffer = Buffer::empty(area);
|
||||
let viewport = state.query_viewport(area.width as usize);
|
||||
let visible = &state.query()[viewport.visible_byte_range.clone()];
|
||||
assert!(visible.contains('中'));
|
||||
assert!(visible.contains("e\u{301}"));
|
||||
assert!(visible.contains(grapheme));
|
||||
|
||||
render_file_list(&mut buffer, area, &mut state, &theme);
|
||||
let cursor_x = viewport.cursor_display_column as u16;
|
||||
assert_eq!(buffer[(cursor_x, 0)].bg, theme.text_primary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_scrollbar_jump_edges() {
|
||||
let mut offset = 50;
|
||||
|
|
|
|||
|
|
@ -1019,10 +1019,10 @@ pub fn render_doc_picker_overlay(
|
|||
render_centered_tip_footer, split_content_for_tip_footer,
|
||||
};
|
||||
use super::picker::{self, PickerEntry, PickerRow};
|
||||
let filtered: Vec<_> = if state.query.is_empty() {
|
||||
let filtered: Vec<_> = if state.query().is_empty() {
|
||||
entries.iter().enumerate().collect()
|
||||
} else {
|
||||
let q = state.query.to_lowercase();
|
||||
let q = state.query().to_lowercase();
|
||||
entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ use ratatui::layout::{Constraint, Flex, Layout, Rect};
|
|||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Widget;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::app::app_view::NewWorktreeDialogState;
|
||||
|
|
@ -20,14 +19,12 @@ const LABEL_PREFIX: &str = "Name (optional): ";
|
|||
|
||||
/// Render the new-worktree popup dialog centered on screen.
|
||||
///
|
||||
/// The dialog grows with the typed label (up to the available terminal
|
||||
/// width) so long names stay fully visible. When the terminal itself is
|
||||
/// too narrow for the full name, the input scrolls to keep the cursor
|
||||
/// (end of the label) in view, with a leading `…` when scrolled.
|
||||
/// The dialog grows with the typed label up to the available width, then
|
||||
/// scrolls the input viewport to keep the live cursor visible.
|
||||
pub fn render_new_worktree_dialog(area: Rect, buf: &mut Buffer, state: &NewWorktreeDialogState) {
|
||||
let theme = Theme::current();
|
||||
|
||||
let dialog_width = dialog_width_for(area.width, &state.label_input);
|
||||
let dialog_width = dialog_width_for(area.width, state.label());
|
||||
|
||||
if area.height < DIALOG_HEIGHT || area.width < 20 {
|
||||
// Too small to render — draw a minimal "resize" hint so the user
|
||||
|
|
@ -126,19 +123,22 @@ pub fn render_new_worktree_dialog(area: Rect, buf: &mut Buffer, state: &NewWorkt
|
|||
));
|
||||
title.render(Rect::new(inner_x, dialog.y + 1, inner_width, 1), buf);
|
||||
|
||||
// Row 2: Label input — grow with content; scroll when still too wide.
|
||||
// Row 2: Label input.
|
||||
let prefix_w = LABEL_PREFIX.width() as u16;
|
||||
let cursor_w = 1u16;
|
||||
let input_budget = inner_width
|
||||
.saturating_sub(prefix_w)
|
||||
.saturating_sub(cursor_w) as usize;
|
||||
let visible_input = visible_input_suffix(&state.label_input, input_budget);
|
||||
let input_width = inner_width.saturating_sub(prefix_w);
|
||||
let viewport = state.viewport(input_width as usize);
|
||||
let visible_input = &state.label()[viewport.visible_byte_range];
|
||||
|
||||
let prefix_span = Span::styled(LABEL_PREFIX, Style::default().fg(theme.gray_bright));
|
||||
let input_span = Span::styled(visible_input, Style::default().fg(theme.text_primary));
|
||||
let cursor_span = Span::styled("\u{2588}", Style::default().fg(theme.accent_user));
|
||||
let input_line = Line::from(vec![prefix_span, input_span, cursor_span]);
|
||||
let input_line = Line::from(vec![prefix_span, input_span]);
|
||||
input_line.render(Rect::new(inner_x, dialog.y + 2, inner_width, 1), buf);
|
||||
if input_width > 0 {
|
||||
let cursor_x = inner_x + prefix_w + viewport.cursor_display_column as u16;
|
||||
if let Some(cell) = buf.cell_mut((cursor_x, dialog.y + 2)) {
|
||||
cell.set_style(Style::default().fg(theme.bg_dark).bg(theme.text_primary));
|
||||
}
|
||||
}
|
||||
|
||||
// Row 3: Hints
|
||||
let hints = Line::from(vec![
|
||||
|
|
@ -168,40 +168,6 @@ fn dialog_width_for(area_width: u16, label: &str) -> u16 {
|
|||
needed.max(MIN_DIALOG_WIDTH).min(max_width)
|
||||
}
|
||||
|
||||
/// Return the visible portion of `label` for an end-anchored input field.
|
||||
///
|
||||
/// When `label` fits in `budget` columns, returns it unchanged. Otherwise
|
||||
/// returns a leading `…` plus the suffix that fits, so the cursor at the
|
||||
/// end of the label stays visible while typing a long name.
|
||||
///
|
||||
/// Walks Unicode grapheme clusters (not scalar values) so combining marks
|
||||
/// and ZWJ sequences are never split across the scroll boundary.
|
||||
fn visible_input_suffix(label: &str, budget: usize) -> String {
|
||||
if budget == 0 {
|
||||
return String::new();
|
||||
}
|
||||
if label.width() <= budget {
|
||||
return label.to_string();
|
||||
}
|
||||
if budget == 1 {
|
||||
return "…".to_string();
|
||||
}
|
||||
|
||||
let suffix_budget = budget - 1; // reserve one column for leading …
|
||||
let mut width = 0usize;
|
||||
let mut start = label.len();
|
||||
let graphemes: Vec<(usize, &str)> = label.grapheme_indices(true).collect();
|
||||
for &(i, g) in graphemes.iter().rev() {
|
||||
let cw = UnicodeWidthStr::width(g);
|
||||
if width + cw > suffix_budget {
|
||||
break;
|
||||
}
|
||||
width += cw;
|
||||
start = i;
|
||||
}
|
||||
format!("…{}", &label[start..])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -210,9 +176,8 @@ mod tests {
|
|||
|
||||
fn render_to_text(area: Rect, label: &str) -> String {
|
||||
let mut buf = Buffer::empty(area);
|
||||
let state = NewWorktreeDialogState {
|
||||
label_input: label.to_string(),
|
||||
};
|
||||
let mut state = NewWorktreeDialogState::new();
|
||||
state.set_label(label);
|
||||
render_new_worktree_dialog(area, &mut buf, &state);
|
||||
let mut lines = Vec::new();
|
||||
for y in 0..area.height {
|
||||
|
|
@ -255,52 +220,6 @@ mod tests {
|
|||
assert_eq!(width, 56); // 60 - 4
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_suffix_keeps_end_when_scrolled() {
|
||||
let label = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let visible = visible_input_suffix(label, 10);
|
||||
assert!(
|
||||
visible.starts_with('…'),
|
||||
"expected leading ellipsis: {visible}"
|
||||
);
|
||||
assert!(
|
||||
visible.ends_with("0123456789") || visible.ends_with("123456789"),
|
||||
"expected end of label visible: {visible}"
|
||||
);
|
||||
assert_eq!(visible.width(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_suffix_unchanged_when_fits() {
|
||||
assert_eq!(visible_input_suffix("short", 20), "short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_suffix_does_not_split_grapheme_clusters() {
|
||||
// "e" + combining acute (U+0301) is one grapheme; pad so we must scroll.
|
||||
let cluster = "e\u{0301}";
|
||||
let label = format!("{}{}", "x".repeat(20), cluster);
|
||||
let visible = visible_input_suffix(&label, 8);
|
||||
assert!(
|
||||
visible.starts_with('…'),
|
||||
"expected leading ellipsis: {visible}"
|
||||
);
|
||||
// Either the full cluster is present, or it was dropped as a unit —
|
||||
// never a lone combining mark after the ellipsis.
|
||||
let after_ellipsis = &visible[visible.char_indices().nth(1).map(|(i, _)| i).unwrap_or(0)..];
|
||||
assert!(
|
||||
!after_ellipsis.starts_with('\u{0301}'),
|
||||
"must not start scrolled suffix on a combining mark: {visible:?}"
|
||||
);
|
||||
if after_ellipsis.contains('e') {
|
||||
assert!(
|
||||
after_ellipsis.contains(cluster),
|
||||
"base 'e' must keep its combining mark: {visible:?}"
|
||||
);
|
||||
}
|
||||
assert!(visible.width() <= 8, "width overflow: {visible:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_name_fully_visible_on_wide_terminal() {
|
||||
let area = Rect::new(0, 0, 100, 20);
|
||||
|
|
@ -329,4 +248,24 @@ mod tests {
|
|||
"expected scrolled indicator or tail:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_dialog_keeps_middle_unicode_cursor_visible() {
|
||||
let area = Rect::new(0, 0, 40, 12);
|
||||
let grapheme = "👩🏽\u{200d}💻";
|
||||
let label = format!("xxxxxxxxxxxx中e\u{301}{grapheme}tail");
|
||||
let mut state = NewWorktreeDialogState::new();
|
||||
state.set_label(&label);
|
||||
let cursor_byte = "xxxxxxxxxxxx中e\u{301}".len();
|
||||
let _ = state.set_cursor_byte(cursor_byte);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
render_new_worktree_dialog(area, &mut buffer, &state);
|
||||
|
||||
assert!(
|
||||
(0..area.height).any(|y| {
|
||||
(0..area.width).any(|x| buffer[(x, y)].bg == Theme::current().text_primary)
|
||||
}),
|
||||
"live cursor cell must remain visible",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@
|
|||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent};
|
||||
use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::input::line_editor::{LineEditOutcome, LineEditor};
|
||||
use crate::theme::Theme;
|
||||
use crate::views::modal_window::{
|
||||
self, ModalContentArea, ModalSizing, ModalWindowConfig, ModalWindowState, Shortcut,
|
||||
|
|
@ -81,12 +82,11 @@ impl PersonaField {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PersonaDetailMode {
|
||||
enum PersonaDetailMode {
|
||||
Browse,
|
||||
Editing {
|
||||
field: PersonaField,
|
||||
buffer: String,
|
||||
cursor: usize,
|
||||
editor: LineEditor,
|
||||
original: String,
|
||||
},
|
||||
}
|
||||
|
|
@ -139,7 +139,7 @@ pub struct PersonaDetailState {
|
|||
pub scope_label: String,
|
||||
pub selected_field: PersonaField,
|
||||
pub scroll_offset: usize,
|
||||
pub mode: PersonaDetailMode,
|
||||
mode: PersonaDetailMode,
|
||||
pub dirty: bool,
|
||||
pub instructions_expanded: bool,
|
||||
/// Scroll offset within expanded instructions (line index of first visible line).
|
||||
|
|
@ -276,6 +276,43 @@ impl PersonaDetailState {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn is_editing(&self) -> bool {
|
||||
matches!(&self.mode, PersonaDetailMode::Editing { .. })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn editing_editor(&self) -> Option<&LineEditor> {
|
||||
match &self.mode {
|
||||
PersonaDetailMode::Editing { editor, .. } => Some(editor),
|
||||
PersonaDetailMode::Browse => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn editing_viewport(&self, width: usize) -> Option<xai_ratatui_textarea::SingleLineViewport> {
|
||||
self.editing_editor().map(|editor| editor.viewport(width))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn editing_text(&self) -> Option<&str> {
|
||||
self.editing_editor().map(LineEditor::text)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn set_editing_text(&mut self, text: impl Into<String>) {
|
||||
if let PersonaDetailMode::Editing { editor, .. } = &mut self.mode {
|
||||
editor.set_text(text);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn set_editing_cursor_byte(&mut self, cursor_byte: usize) -> LineEditOutcome {
|
||||
match &mut self.mode {
|
||||
PersonaDetailMode::Editing { editor, .. } => editor.set_cursor_byte(cursor_byte),
|
||||
PersonaDetailMode::Browse => LineEditOutcome::Unhandled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Save current state back to the TOML file using toml_edit to preserve formatting.
|
||||
fn save_to_file(&self) -> Result<(), String> {
|
||||
let Some(ref path) = self.source_path else {
|
||||
|
|
@ -314,6 +351,26 @@ impl PersonaDetailState {
|
|||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn render_detail_editor(
|
||||
buf: &mut Buffer,
|
||||
x: u16,
|
||||
y: u16,
|
||||
width: usize,
|
||||
editor: &LineEditor,
|
||||
style: Style,
|
||||
theme: &Theme,
|
||||
) {
|
||||
let viewport = editor.viewport(width);
|
||||
let visible = &editor.text()[viewport.visible_byte_range];
|
||||
buf.set_string(x, y, visible, style);
|
||||
if width > 0 {
|
||||
let cursor_x = x + viewport.cursor_display_column as u16;
|
||||
if let Some(cell) = buf.cell_mut((cursor_x, y)) {
|
||||
cell.set_style(Style::default().fg(theme.bg_base).bg(theme.text_primary));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the persona detail modal.
|
||||
pub fn render_persona_detail(
|
||||
buf: &mut Buffer,
|
||||
|
|
@ -395,24 +452,18 @@ pub fn render_persona_detail(
|
|||
// Check if we're in editing mode for this field.
|
||||
if is_selected
|
||||
&& let PersonaDetailMode::Editing {
|
||||
ref buffer, cursor, ..
|
||||
} = state.mode
|
||||
field: editing_field,
|
||||
editor,
|
||||
..
|
||||
} = &state.mode
|
||||
&& *editing_field == field
|
||||
{
|
||||
// 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)
|
||||
} else {
|
||||
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))
|
||||
{
|
||||
cell.set_style(Style::default().fg(theme.bg_base).bg(theme.text_primary));
|
||||
}
|
||||
render_detail_editor(buf, value_x, y, value_w, editor, field_style, theme);
|
||||
} else if field == PersonaField::Instructions {
|
||||
// Multi-line instructions with expand/collapse and scroll.
|
||||
if value.is_empty() {
|
||||
|
|
@ -630,7 +681,7 @@ fn persona_detail_sizing(compact: bool) -> ModalSizing {
|
|||
}
|
||||
|
||||
fn build_shortcuts(state: &PersonaDetailState) -> Vec<Shortcut<'static>> {
|
||||
if matches!(state.mode, PersonaDetailMode::Editing { .. }) {
|
||||
if state.is_editing() {
|
||||
vec![
|
||||
Shortcut {
|
||||
label: "Enter save",
|
||||
|
|
@ -682,12 +733,28 @@ pub fn handle_persona_detail_key(
|
|||
) -> PersonaDetailOutcome {
|
||||
state.message = None;
|
||||
|
||||
match &state.mode {
|
||||
PersonaDetailMode::Editing { .. } => handle_editing_key(state, key),
|
||||
PersonaDetailMode::Browse => handle_browse_key(state, key),
|
||||
if state.is_editing() {
|
||||
handle_editing_key(state, key)
|
||||
} else {
|
||||
handle_browse_key(state, key)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_persona_detail_paste(
|
||||
state: &mut PersonaDetailState,
|
||||
text: &str,
|
||||
) -> PersonaDetailOutcome {
|
||||
if !state.is_editing() {
|
||||
return PersonaDetailOutcome::Unchanged;
|
||||
}
|
||||
state.message = None;
|
||||
let outcome = match &mut state.mode {
|
||||
PersonaDetailMode::Editing { editor, .. } => editor.insert_paste(text),
|
||||
PersonaDetailMode::Browse => unreachable!("editing mode changed before paste"),
|
||||
};
|
||||
finish_edit(outcome)
|
||||
}
|
||||
|
||||
fn handle_browse_key(state: &mut PersonaDetailState, key: &KeyEvent) -> PersonaDetailOutcome {
|
||||
// When instructions are expanded and selected, j/k scrolls within them.
|
||||
let instr_scrolling =
|
||||
|
|
@ -739,11 +806,18 @@ fn handle_browse_key(state: &mut PersonaDetailState, key: &KeyEvent) -> PersonaD
|
|||
return PersonaDetailOutcome::Changed;
|
||||
}
|
||||
let current = state.field_value(field).to_owned();
|
||||
if current.contains(['\n', '\r']) {
|
||||
state.message =
|
||||
Some("Multiline values must be edited in the source file".to_string());
|
||||
return PersonaDetailOutcome::Changed;
|
||||
}
|
||||
let mut editor = LineEditor::default();
|
||||
editor.set_text(¤t);
|
||||
let original = current;
|
||||
state.mode = PersonaDetailMode::Editing {
|
||||
field,
|
||||
cursor: current.len(),
|
||||
original: current.clone(),
|
||||
buffer: current,
|
||||
editor,
|
||||
original,
|
||||
};
|
||||
PersonaDetailOutcome::Changed
|
||||
}
|
||||
|
|
@ -763,91 +837,47 @@ fn handle_browse_key(state: &mut PersonaDetailState, key: &KeyEvent) -> PersonaD
|
|||
}
|
||||
|
||||
fn handle_editing_key(state: &mut PersonaDetailState, key: &KeyEvent) -> PersonaDetailOutcome {
|
||||
let PersonaDetailMode::Editing {
|
||||
field,
|
||||
ref mut buffer,
|
||||
ref mut cursor,
|
||||
ref original,
|
||||
} = state.mode
|
||||
else {
|
||||
return PersonaDetailOutcome::Unchanged;
|
||||
};
|
||||
|
||||
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;
|
||||
if key.code == KeyCode::Esc {
|
||||
state.mode = PersonaDetailMode::Browse;
|
||||
return PersonaDetailOutcome::Changed;
|
||||
}
|
||||
if key.code == KeyCode::Enter {
|
||||
let mode = std::mem::replace(&mut state.mode, PersonaDetailMode::Browse);
|
||||
let PersonaDetailMode::Editing {
|
||||
field,
|
||||
editor,
|
||||
original,
|
||||
} = mode
|
||||
else {
|
||||
return PersonaDetailOutcome::Unchanged;
|
||||
};
|
||||
let new_value = editor.text().to_owned();
|
||||
let changed = new_value != original;
|
||||
if changed {
|
||||
state.set_field_value(field, new_value);
|
||||
state.mode = PersonaDetailMode::Browse;
|
||||
if changed {
|
||||
state.dirty = true;
|
||||
if let Err(e) = state.save_to_file() {
|
||||
state.message = Some(format!("Save failed: {e}"));
|
||||
} else {
|
||||
state.message = Some("Saved".to_string());
|
||||
}
|
||||
state.dirty = true;
|
||||
if let Err(e) = state.save_to_file() {
|
||||
state.message = Some(format!("Save failed: {e}"));
|
||||
} else {
|
||||
state.message = Some("Saved".to_string());
|
||||
}
|
||||
PersonaDetailOutcome::Changed
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if *cursor > 0 {
|
||||
let prev = buffer[..*cursor]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0);
|
||||
buffer.remove(prev);
|
||||
*cursor = prev;
|
||||
}
|
||||
PersonaDetailOutcome::Changed
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if *cursor > 0 {
|
||||
let prev = buffer[..*cursor]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0);
|
||||
*cursor = prev;
|
||||
}
|
||||
PersonaDetailOutcome::Changed
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if *cursor < buffer.len() {
|
||||
let next = buffer[*cursor..]
|
||||
.char_indices()
|
||||
.nth(1)
|
||||
.map(|(i, _)| *cursor + i)
|
||||
.unwrap_or(buffer.len());
|
||||
*cursor = next;
|
||||
}
|
||||
PersonaDetailOutcome::Changed
|
||||
}
|
||||
KeyCode::Home => {
|
||||
*cursor = 0;
|
||||
PersonaDetailOutcome::Changed
|
||||
}
|
||||
KeyCode::End => {
|
||||
*cursor = buffer.len();
|
||||
PersonaDetailOutcome::Changed
|
||||
}
|
||||
KeyCode::Char(c)
|
||||
if !key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
|
||||
|| crate::input::key::is_altgr(key.modifiers) =>
|
||||
{
|
||||
buffer.insert(*cursor, c);
|
||||
*cursor += c.len_utf8();
|
||||
PersonaDetailOutcome::Changed
|
||||
}
|
||||
_ => PersonaDetailOutcome::Unchanged,
|
||||
return PersonaDetailOutcome::Changed;
|
||||
}
|
||||
|
||||
let outcome = match &mut state.mode {
|
||||
PersonaDetailMode::Editing { editor, .. } => editor.handle_key(key),
|
||||
PersonaDetailMode::Browse => return PersonaDetailOutcome::Unchanged,
|
||||
};
|
||||
finish_edit(outcome)
|
||||
}
|
||||
|
||||
fn finish_edit(outcome: LineEditOutcome) -> PersonaDetailOutcome {
|
||||
match outcome {
|
||||
LineEditOutcome::TextChanged
|
||||
| LineEditOutcome::CursorChanged
|
||||
| LineEditOutcome::HandledNoChange => PersonaDetailOutcome::Changed,
|
||||
LineEditOutcome::Unhandled => PersonaDetailOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -896,3 +926,6 @@ fn word_wrap_lines(text: &str, max_width: usize) -> Vec<String> {
|
|||
}
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
189
crates/codegen/xai-grok-pager/src/views/persona_detail/tests.rs
Normal file
189
crates/codegen/xai-grok-pager/src/views/persona_detail/tests.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
use super::*;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
fn editable_state() -> (tempfile::TempDir, PathBuf, PersonaDetailState) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("reviewer.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"name = \"reviewer\"\n",
|
||||
"description = \"old description\"\n",
|
||||
"model = \"grok\"\n",
|
||||
"reasoning_effort = \"high\"\n",
|
||||
"default_isolation = \"worktree\"\n",
|
||||
"instructions = \"read only instructions\"\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let state = PersonaDetailState::from_toml_file(&path, true, "project").unwrap();
|
||||
(directory, path, state)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_edit_save_updates_state_and_toml() {
|
||||
let (_directory, path, mut state) = editable_state();
|
||||
state.selected_field = PersonaField::Description;
|
||||
let _ = handle_persona_detail_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
let _ = handle_persona_detail_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Home, KeyModifiers::NONE),
|
||||
);
|
||||
let _ = handle_persona_detail_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL),
|
||||
);
|
||||
for ch in "new description".chars() {
|
||||
let _ = handle_persona_detail_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
|
||||
);
|
||||
}
|
||||
let _ = handle_persona_detail_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
|
||||
assert!(!state.is_editing());
|
||||
assert_eq!(state.description, "new description");
|
||||
assert!(state.dirty);
|
||||
let saved = std::fs::read_to_string(path).unwrap();
|
||||
assert!(saved.contains("description = \"new description\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_edit_cancel_preserves_original_and_file() {
|
||||
let (_directory, path, mut state) = editable_state();
|
||||
let before = std::fs::read_to_string(&path).unwrap();
|
||||
state.selected_field = PersonaField::Name;
|
||||
let _ = handle_persona_detail_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
let _ = handle_persona_detail_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE),
|
||||
);
|
||||
let _ = handle_persona_detail_key(&mut state, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert!(!state.is_editing());
|
||||
assert_eq!(state.name, "reviewer");
|
||||
assert!(!state.dirty);
|
||||
assert_eq!(std::fs::read_to_string(path).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_unchanged_edit_does_not_write_or_mark_dirty() {
|
||||
let (_directory, path, mut state) = editable_state();
|
||||
let before = std::fs::read_to_string(&path).unwrap();
|
||||
state.selected_field = PersonaField::Model;
|
||||
let _ = handle_persona_detail_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
let _ = handle_persona_detail_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
|
||||
assert!(!state.is_editing());
|
||||
assert!(!state.dirty);
|
||||
assert!(state.message.is_none());
|
||||
assert_eq!(std::fs::read_to_string(path).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_values_require_source_file_editing() {
|
||||
let (_directory, _path, mut state) = editable_state();
|
||||
state.description = "first line\nsecond line".to_owned();
|
||||
state.selected_field = PersonaField::Description;
|
||||
|
||||
let outcome = handle_persona_detail_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(matches!(outcome, PersonaDetailOutcome::Changed));
|
||||
assert!(!state.is_editing());
|
||||
assert_eq!(state.description, "first line\nsecond line");
|
||||
assert_eq!(
|
||||
state.message.as_deref(),
|
||||
Some("Multiline values must be edited in the source file")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_instructions_remain_read_only_inline() {
|
||||
let (_directory, _path, mut state) = editable_state();
|
||||
state.selected_field = PersonaField::Instructions;
|
||||
let outcome = handle_persona_detail_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(matches!(outcome, PersonaDetailOutcome::Changed));
|
||||
assert!(!state.is_editing());
|
||||
assert!(state.instructions_expanded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_paste_targets_only_active_editor_and_sanitizes() {
|
||||
let (_directory, _path, mut state) = editable_state();
|
||||
state.selected_field = PersonaField::Model;
|
||||
let _ = handle_persona_detail_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
state.set_editing_text("ab");
|
||||
let _ = state.set_editing_cursor_byte(1);
|
||||
let outcome = handle_persona_detail_paste(&mut state, "中\r\n");
|
||||
assert!(matches!(outcome, PersonaDetailOutcome::Changed));
|
||||
assert_eq!(state.editing_text(), Some("a中b"));
|
||||
|
||||
let _ = handle_persona_detail_key(&mut state, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
let outcome = handle_persona_detail_paste(&mut state, "ignored");
|
||||
assert!(matches!(outcome, PersonaDetailOutcome::Unchanged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_editor_uses_canonical_graphemes_and_keeps_cursor_visible() {
|
||||
let (_directory, _path, mut state) = editable_state();
|
||||
state.selected_field = PersonaField::Model;
|
||||
let _ = handle_persona_detail_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
let grapheme = "👩🏽\u{200d}💻";
|
||||
state.set_editing_text(format!("a{grapheme}b"));
|
||||
let _ = state.set_editing_cursor_byte(1);
|
||||
let _ = handle_persona_detail_key(
|
||||
&mut state,
|
||||
&KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(state.editing_text(), Some("ab"));
|
||||
|
||||
let text = format!("123456中e\u{301}{grapheme}z");
|
||||
state.set_editing_text(&text);
|
||||
let _ = state.set_editing_cursor_byte(text.len() - 1);
|
||||
|
||||
let width = 10usize;
|
||||
let theme = Theme::current();
|
||||
let mut buffer = Buffer::empty(Rect::new(0, 0, width as u16, 1));
|
||||
let viewport = state.editing_viewport(width).unwrap();
|
||||
let visible = &state.editing_text().unwrap()[viewport.visible_byte_range.clone()];
|
||||
assert!(visible.contains('中'));
|
||||
assert!(visible.contains("e\u{301}"));
|
||||
assert!(visible.contains(grapheme));
|
||||
render_detail_editor(
|
||||
&mut buffer,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
state.editing_editor().unwrap(),
|
||||
Style::default(),
|
||||
&theme,
|
||||
);
|
||||
let cursor_x = viewport.cursor_display_column as u16;
|
||||
assert_eq!(buffer[(cursor_x, 0)].bg, theme.text_primary);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -616,6 +616,10 @@ impl QueuePane {
|
|||
self.list_state.handle_key_event(key, &self.entries)
|
||||
}
|
||||
|
||||
pub fn handle_paste(&mut self, text: &str) -> bool {
|
||||
self.list_state.handle_paste(text, &self.entries)
|
||||
}
|
||||
|
||||
/// Get the stable ID of the currently selected entry, if any.
|
||||
pub fn selected_id(&self) -> Option<u64> {
|
||||
self.list_state.selected_id()
|
||||
|
|
@ -1028,6 +1032,18 @@ mod tests {
|
|||
QueuedPrompt::plain(id, text, QueueEntryKind::Prompt)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_routes_to_active_list_input() {
|
||||
let mut pane = QueuePane::new();
|
||||
let mut local = std::collections::VecDeque::new();
|
||||
local.push_back(local_prompt(1, "first"));
|
||||
pane.sync_from_merged(&local, &[], None, None, &Default::default());
|
||||
pane.list_state.open_comment_input("");
|
||||
|
||||
assert!(pane.handle_paste("queued text"));
|
||||
assert_eq!(pane.list_state.input_text(), "queued text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_auto_show_edge_allows_requeue_after_external_hide() {
|
||||
let mut pane = QueuePane::new();
|
||||
|
|
|
|||
|
|
@ -389,12 +389,25 @@ pub(crate) fn build_virtual_list(
|
|||
items
|
||||
}
|
||||
|
||||
/// Build a position-indexed entry map for the session picker.
|
||||
///
|
||||
/// Each element is `Some(item)` for selectable rows or `None` for
|
||||
/// non-selectable headers. When `grouped` is true, repo-group headers
|
||||
/// are interleaved so indices match what the renderer stores in hit areas.
|
||||
/// `current_repo` pins the matching repo group to the top of the list.
|
||||
/// Rebuild expansion keys in the backing-data index space used by session rendering.
|
||||
pub(crate) fn expand_all_mapped_session_items(
|
||||
state: &mut PickerState,
|
||||
entry_map: &[Option<PickerItem>],
|
||||
) {
|
||||
state.expanded.clear();
|
||||
if state.query().is_empty() {
|
||||
return;
|
||||
}
|
||||
for item in entry_map.iter().flatten() {
|
||||
let key = match item {
|
||||
PickerItem::Fuzzy { original_index } => *original_index,
|
||||
PickerItem::Content { hit_index } => CONTENT_EXPAND_OFFSET + hit_index,
|
||||
};
|
||||
state.expanded.insert(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the position-indexed session map, including non-selectable headers.
|
||||
pub(crate) fn build_entry_map(
|
||||
entries: Option<&[SessionPickerEntry]>,
|
||||
content_results: Option<&[xai_grok_shell::extensions::session_search::SearchSessionHit]>,
|
||||
|
|
@ -489,6 +502,77 @@ pub(crate) fn build_entry_map(
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum SessionPickerWorktreeSelection {
|
||||
Fuzzy(usize),
|
||||
Content { session_id: String, cwd: String },
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Resolve Ctrl+W before generic editing because the line editor binds it to delete-word.
|
||||
pub(crate) fn session_picker_worktree_selection(
|
||||
key: &crossterm::event::KeyEvent,
|
||||
state: &mut PickerState,
|
||||
entry_map: &[Option<PickerItem>],
|
||||
non_selectable: &[bool],
|
||||
entries: Option<&[SessionPickerEntry]>,
|
||||
content_results: Option<&[xai_grok_shell::extensions::session_search::SearchSessionHit]>,
|
||||
) -> Option<SessionPickerWorktreeSelection> {
|
||||
if key.kind != crossterm::event::KeyEventKind::Press || !crate::key!('w', CONTROL).matches(key)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if entry_map.is_empty() {
|
||||
return Some(SessionPickerWorktreeSelection::Unavailable);
|
||||
}
|
||||
crate::views::picker::clamp_picker_selection(state, entry_map.len(), non_selectable);
|
||||
Some(
|
||||
match entry_map
|
||||
.get(state.selected)
|
||||
.and_then(|entry| entry.as_ref())
|
||||
{
|
||||
Some(PickerItem::Fuzzy { original_index }) => entries
|
||||
.and_then(|entries| entries.get(*original_index))
|
||||
.filter(|entry| !crate::app::is_foreign_picker_source(&entry.source))
|
||||
.map_or(SessionPickerWorktreeSelection::Unavailable, |_| {
|
||||
SessionPickerWorktreeSelection::Fuzzy(*original_index)
|
||||
}),
|
||||
Some(PickerItem::Content { hit_index }) => content_results
|
||||
.and_then(|results| results.get(*hit_index))
|
||||
.map_or(SessionPickerWorktreeSelection::Unavailable, |hit| {
|
||||
SessionPickerWorktreeSelection::Content {
|
||||
session_id: hit.session_id.clone(),
|
||||
cwd: hit.cwd.clone(),
|
||||
}
|
||||
}),
|
||||
None => SessionPickerWorktreeSelection::Unavailable,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Rebuild backing-index expansion after a session query changes.
|
||||
pub(crate) fn sync_session_picker_query_expansion(
|
||||
entries: Option<&[SessionPickerEntry]>,
|
||||
content_results: Option<&[xai_grok_shell::extensions::session_search::SearchSessionHit]>,
|
||||
entries_query: Option<&str>,
|
||||
state: &mut PickerState,
|
||||
grouped: bool,
|
||||
content_loading: bool,
|
||||
source_filter: SourceFilter,
|
||||
current_repo: Option<&str>,
|
||||
) {
|
||||
let entry_map = build_entry_map(
|
||||
entries,
|
||||
content_results,
|
||||
effective_filter_query(state.query(), entries_query),
|
||||
grouped,
|
||||
content_loading,
|
||||
source_filter,
|
||||
current_repo,
|
||||
);
|
||||
expand_all_mapped_session_items(state, &entry_map);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session entry data building
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1096,6 +1180,28 @@ mod tests {
|
|||
assert!(matches!(map[3], Some(PickerItem::Content { hit_index: 0 })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_all_mapped_session_items_uses_backing_indices() {
|
||||
let entries = vec![make_entry("zero", "repo-a"), make_entry("needle", "repo-b")];
|
||||
let hits = vec![make_content_hit("content")];
|
||||
let map = build_entry_map(
|
||||
Some(&entries),
|
||||
Some(&hits),
|
||||
"needle",
|
||||
true,
|
||||
false,
|
||||
SourceFilter::All,
|
||||
None,
|
||||
);
|
||||
let mut state = PickerState::default();
|
||||
state.set_query("needle");
|
||||
|
||||
expand_all_mapped_session_items(&mut state, &map);
|
||||
|
||||
assert_eq!(state.expanded, HashSet::from([1, CONTENT_EXPAND_OFFSET]),);
|
||||
assert!(!state.expanded.contains(&0), "group header is not an item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_id_does_not_suppress_native_content_result() {
|
||||
let mut foreign = make_entry("shared", "repo");
|
||||
|
|
|
|||
|
|
@ -5,12 +5,15 @@ use ratatui::layout::Rect;
|
|||
|
||||
use super::render::int_step_sizes;
|
||||
use super::state::{
|
||||
RowEntry, SettingsKeyOutcome, SettingsModalMode, SettingsModalState, action_for_bool,
|
||||
action_for_enum, action_for_enum_commit, action_for_int, action_for_string,
|
||||
effective_enum_choices, group_children, validate_int, validate_string,
|
||||
RowEntry, SettingsKeyOutcome, SettingsModalState, SettingsMode, SettingsModeKind,
|
||||
action_for_bool, action_for_enum, action_for_enum_commit, action_for_int, action_for_string,
|
||||
effective_enum_choices, group_children, validate_string,
|
||||
};
|
||||
use crate::app::actions::Action;
|
||||
use crate::settings::{SettingKey, SettingKind, SettingValue, dynamic_enum_choices};
|
||||
use crate::input::line_editor::LineEditOutcome;
|
||||
use crate::settings::{
|
||||
SettingKey, SettingKind, SettingValue, StringValidator, dynamic_enum_choices,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key handling
|
||||
|
|
@ -38,22 +41,50 @@ pub fn handle_settings_key(state: &mut SettingsModalState, key: &KeyEvent) -> Se
|
|||
}
|
||||
|
||||
// Exhaustive per-mode dispatch.
|
||||
match state.mode {
|
||||
SettingsModalMode::Browse => handle_browse(state, key),
|
||||
SettingsModalMode::FilterFocused => handle_filter_focused(state, key),
|
||||
SettingsModalMode::PickingEnum { .. } => handle_picking_enum(state, key),
|
||||
SettingsModalMode::PickingGroup { .. } => handle_picking_group(state, key),
|
||||
SettingsModalMode::EditingValue { .. } => handle_editing_value(state, key),
|
||||
match state.state.mode_kind() {
|
||||
SettingsModeKind::Browse => handle_browse(state, key),
|
||||
SettingsModeKind::FilterFocused => handle_filter_focused(state, key),
|
||||
SettingsModeKind::PickingEnum => handle_picking_enum(state, key),
|
||||
SettingsModeKind::PickingGroup => handle_picking_group(state, key),
|
||||
SettingsModeKind::EditingString | SettingsModeKind::EditingInt => {
|
||||
handle_editing_value(state, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_settings_paste(state: &mut SettingsModalState, text: &str) -> SettingsKeyOutcome {
|
||||
match state.state.mode_kind() {
|
||||
SettingsModeKind::FilterFocused => {
|
||||
let outcome = state.state.filter.insert_paste(text);
|
||||
apply_filter_edit(state, outcome)
|
||||
}
|
||||
SettingsModeKind::EditingString => {
|
||||
let (validator, outcome) = {
|
||||
let SettingsMode::EditingString {
|
||||
editor, validator, ..
|
||||
} = &mut state.state.mode
|
||||
else {
|
||||
unreachable!("mode kind changed before paste")
|
||||
};
|
||||
(
|
||||
*validator,
|
||||
editor.insert_paste_with_policy(text, safe_settings_char, usize::MAX),
|
||||
)
|
||||
};
|
||||
apply_string_edit(state, validator, outcome)
|
||||
}
|
||||
SettingsModeKind::Browse
|
||||
| SettingsModeKind::PickingEnum
|
||||
| SettingsModeKind::PickingGroup
|
||||
| SettingsModeKind::EditingInt => SettingsKeyOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enum chooser key routing. Up/Down dispatches preview actions,
|
||||
/// Enter commits current choice, Esc reverts to original value.
|
||||
fn handle_picking_enum(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyOutcome {
|
||||
// Snapshot the current picker state under an immutable borrow so
|
||||
// the subsequent `state.mode = ...` writes are unambiguous.
|
||||
let (setting_key, choices_idx, original_value, supports_preview) = match &state.mode {
|
||||
SettingsModalMode::PickingEnum {
|
||||
let (setting_key, choices_idx, original_value, supports_preview) = match &state.state.mode {
|
||||
SettingsMode::PickingEnum {
|
||||
key,
|
||||
choices_idx,
|
||||
original_value,
|
||||
|
|
@ -64,7 +95,7 @@ fn handle_picking_enum(state: &mut SettingsModalState, key: &KeyEvent) -> Settin
|
|||
original_value.clone(),
|
||||
*supports_preview,
|
||||
),
|
||||
_ => return SettingsKeyOutcome::Unchanged,
|
||||
_ => unreachable!("picker handler requires PickingEnum state"),
|
||||
};
|
||||
|
||||
match key.code {
|
||||
|
|
@ -170,9 +201,9 @@ fn handle_picking_enum(state: &mut SettingsModalState, key: &KeyEvent) -> Settin
|
|||
/// Space/Enter toggles the focused child in place (the sheet stays open);
|
||||
/// Esc returns to Browse.
|
||||
fn handle_picking_group(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyOutcome {
|
||||
let (group_key, child_idx) = match &state.mode {
|
||||
SettingsModalMode::PickingGroup { key, child_idx } => (*key, *child_idx),
|
||||
_ => return SettingsKeyOutcome::Unchanged,
|
||||
let (group_key, child_idx) = match &state.state.mode {
|
||||
SettingsMode::PickingGroup { key, child_idx } => (*key, *child_idx),
|
||||
_ => unreachable!("group handler requires PickingGroup state"),
|
||||
};
|
||||
let children = group_children(state, group_key);
|
||||
if children.is_empty() {
|
||||
|
|
@ -186,20 +217,14 @@ fn handle_picking_group(state: &mut SettingsModalState, key: &KeyEvent) -> Setti
|
|||
if child_idx + 1 >= children.len() {
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
}
|
||||
state.mode = SettingsModalMode::PickingGroup {
|
||||
key: group_key,
|
||||
child_idx: child_idx + 1,
|
||||
};
|
||||
state.transition_to_picking_group(group_key, child_idx + 1);
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if child_idx == 0 {
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
}
|
||||
state.mode = SettingsModalMode::PickingGroup {
|
||||
key: group_key,
|
||||
child_idx: child_idx - 1,
|
||||
};
|
||||
state.transition_to_picking_group(group_key, child_idx - 1);
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
// Space/Enter toggle the focused child Bool and stay in the sheet so the
|
||||
|
|
@ -245,12 +270,7 @@ pub(super) fn set_picker_idx(
|
|||
// for refactor safety.
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
}
|
||||
state.mode = SettingsModalMode::PickingEnum {
|
||||
key: setting_key,
|
||||
choices_idx: new_idx,
|
||||
supports_preview,
|
||||
original_value,
|
||||
};
|
||||
state.transition_to_picking_enum(setting_key, new_idx, original_value, supports_preview);
|
||||
// Preview dispatch for static Enums with preview support.
|
||||
if supports_preview
|
||||
&& let Some(new_canonical) = picker_choice_at(state, setting_key, new_idx)
|
||||
|
|
@ -265,157 +285,112 @@ pub(super) fn set_picker_idx(
|
|||
/// String mode: free-form text with cursor. Int mode: range-aware stepper
|
||||
/// (Up/Down small, Left/Right large; see [`int_step_sizes`]), clamped to [min,max].
|
||||
fn handle_editing_value(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyOutcome {
|
||||
// Snapshot mode payload under an immutable borrow.
|
||||
let (setting_key, buffer, cursor_byte, validation_error) = match &state.mode {
|
||||
SettingsModalMode::EditingValue {
|
||||
key,
|
||||
buffer,
|
||||
cursor_byte,
|
||||
validation_error,
|
||||
} => (*key, buffer.clone(), *cursor_byte, validation_error.clone()),
|
||||
_ => return SettingsKeyOutcome::Unchanged,
|
||||
};
|
||||
|
||||
// Look up the registered kind so we know how to handle this
|
||||
// edit. The lookup is `&self`-only.
|
||||
let Some(meta) = state.registry.find(setting_key) else {
|
||||
// Registry skew — log and exit. The CI guards catch this.
|
||||
tracing::error!(
|
||||
target: "settings",
|
||||
key = setting_key,
|
||||
"EditingValue mode references an unregistered key — exiting to Browse",
|
||||
);
|
||||
state.transition_to_browse();
|
||||
return SettingsKeyOutcome::Changed;
|
||||
};
|
||||
let kind_snapshot = meta.kind.clone();
|
||||
|
||||
// Int settings dispatch through a stepper-only
|
||||
// handler. All char-input / cursor-pan / Backspace / Delete /
|
||||
// Home / End keys are rejected; only Up/Down/Left/Right (and
|
||||
// j/k/h/l aliases), Enter, and Esc do anything.
|
||||
if matches!(kind_snapshot, SettingKind::Int { .. }) {
|
||||
return handle_int_stepper(state, key, setting_key, &buffer, &kind_snapshot);
|
||||
if let SettingsMode::EditingInt {
|
||||
key: setting_key,
|
||||
buffer,
|
||||
min,
|
||||
max,
|
||||
} = &state.state.mode
|
||||
{
|
||||
let setting_key = *setting_key;
|
||||
let buffer = buffer.clone();
|
||||
return handle_int_stepper(state, key, setting_key, &buffer, *min, *max);
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
state.transition_to_browse();
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Commit gate: re-validate against the current buffer.
|
||||
// On failure, refresh the inline error and stay in
|
||||
// EditingValue.
|
||||
let error = match &kind_snapshot {
|
||||
SettingKind::String { validator, .. } => {
|
||||
validate_string(*validator, &buffer, &state.pager_snapshot.available_models)
|
||||
}
|
||||
_ => return SettingsKeyOutcome::Unchanged,
|
||||
let (setting_key, validator) = match &state.state.mode {
|
||||
SettingsMode::EditingString { key, validator, .. } => (*key, *validator),
|
||||
_ => unreachable!("editing handler requires String or Int state"),
|
||||
};
|
||||
|
||||
if key.code == KeyCode::Enter {
|
||||
let SettingsMode::EditingString { editor, .. } = &state.state.mode else {
|
||||
unreachable!("String editor state changed during commit");
|
||||
};
|
||||
let text = editor.text().to_owned();
|
||||
let error = validate_string(validator, &text, &state.pager_snapshot.available_models);
|
||||
if error.is_some() {
|
||||
let SettingsMode::EditingString {
|
||||
validation_error, ..
|
||||
} = &mut state.state.mode
|
||||
else {
|
||||
unreachable!("String editor state changed during validation");
|
||||
};
|
||||
if error.is_some() {
|
||||
update_editing_value_buffer(state, buffer, cursor_byte, error);
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
*validation_error = error;
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
}
|
||||
let action = action_for_string(setting_key, text, &state.pager_snapshot);
|
||||
state.transition_to_browse();
|
||||
return match action {
|
||||
Some(action) => SettingsKeyOutcome::Action(action),
|
||||
None => {
|
||||
tracing::error!(
|
||||
target: "settings",
|
||||
key = setting_key,
|
||||
"EditingValue commit has no action_for_string arm — registry skew",
|
||||
);
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
// Dispatch the typed Action and transition to Browse.
|
||||
let action_opt = match &kind_snapshot {
|
||||
SettingKind::String { .. } => {
|
||||
action_for_string(setting_key, buffer.clone(), &state.pager_snapshot)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
if key.code == KeyCode::Esc {
|
||||
state.transition_to_browse();
|
||||
return SettingsKeyOutcome::Changed;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
key.code,
|
||||
KeyCode::Up
|
||||
| KeyCode::Down
|
||||
| KeyCode::PageUp
|
||||
| KeyCode::PageDown
|
||||
| KeyCode::Tab
|
||||
| KeyCode::BackTab
|
||||
) {
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
}
|
||||
|
||||
let outcome = {
|
||||
let SettingsMode::EditingString { editor, .. } = &mut state.state.mode else {
|
||||
unreachable!("String editor state changed before key handling");
|
||||
};
|
||||
editor.handle_key_with_insert_policy(key, safe_settings_char)
|
||||
};
|
||||
apply_string_edit(state, validator, outcome)
|
||||
}
|
||||
|
||||
fn apply_string_edit(
|
||||
state: &mut SettingsModalState,
|
||||
validator: StringValidator,
|
||||
outcome: LineEditOutcome,
|
||||
) -> SettingsKeyOutcome {
|
||||
match outcome {
|
||||
LineEditOutcome::TextChanged => {
|
||||
let SettingsMode::EditingString { editor, .. } = &state.state.mode else {
|
||||
unreachable!("String editor state changed after text mutation");
|
||||
};
|
||||
state.transition_to_browse();
|
||||
match action_opt {
|
||||
Some(action) => SettingsKeyOutcome::Action(action),
|
||||
None => {
|
||||
tracing::error!(
|
||||
target: "settings",
|
||||
key = setting_key,
|
||||
"EditingValue commit has no action_for_string arm — registry skew",
|
||||
);
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if cursor_byte == 0 {
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
}
|
||||
let mut new_buf = buffer.clone();
|
||||
// Find the prev char boundary.
|
||||
let prev = (0..cursor_byte)
|
||||
.rev()
|
||||
.find(|&i| new_buf.is_char_boundary(i))
|
||||
.unwrap_or(0);
|
||||
new_buf.replace_range(prev..cursor_byte, "");
|
||||
let new_cursor = prev;
|
||||
let new_error = recompute_validation(&kind_snapshot, &new_buf, state);
|
||||
update_editing_value_buffer(state, new_buf, new_cursor, new_error);
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if cursor_byte >= buffer.len() {
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
}
|
||||
let mut new_buf = buffer.clone();
|
||||
// Find next char boundary.
|
||||
let next = (cursor_byte + 1..=new_buf.len())
|
||||
.find(|&i| new_buf.is_char_boundary(i))
|
||||
.unwrap_or(new_buf.len());
|
||||
new_buf.replace_range(cursor_byte..next, "");
|
||||
let new_error = recompute_validation(&kind_snapshot, &new_buf, state);
|
||||
update_editing_value_buffer(state, new_buf, cursor_byte, new_error);
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if cursor_byte == 0 {
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
}
|
||||
let prev = (0..cursor_byte)
|
||||
.rev()
|
||||
.find(|&i| buffer.is_char_boundary(i))
|
||||
.unwrap_or(0);
|
||||
update_editing_value_buffer(state, buffer, prev, validation_error);
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if cursor_byte >= buffer.len() {
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
}
|
||||
let next = (cursor_byte + 1..=buffer.len())
|
||||
.find(|&i| buffer.is_char_boundary(i))
|
||||
.unwrap_or(buffer.len());
|
||||
update_editing_value_buffer(state, buffer, next, validation_error);
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
KeyCode::Home => {
|
||||
update_editing_value_buffer(state, buffer, 0, validation_error);
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
KeyCode::End => {
|
||||
let end = buffer.len();
|
||||
update_editing_value_buffer(state, buffer, end, validation_error);
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
KeyCode::Char(c) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => {
|
||||
// Defense-in-depth for the (currently unused) String editor path:
|
||||
// reject control + bidi/format chars so a future `String` setting
|
||||
// can't reintroduce the Trojan-Source surface.
|
||||
let accept = match &kind_snapshot {
|
||||
SettingKind::String { .. } => !crate::render::line_utils::is_unsafe_display_char(c),
|
||||
_ => false,
|
||||
let error = validate_string(
|
||||
validator,
|
||||
editor.text(),
|
||||
&state.pager_snapshot.available_models,
|
||||
);
|
||||
let SettingsMode::EditingString {
|
||||
validation_error, ..
|
||||
} = &mut state.state.mode
|
||||
else {
|
||||
unreachable!("String editor state changed during validation");
|
||||
};
|
||||
if !accept {
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
}
|
||||
let mut new_buf = buffer.clone();
|
||||
new_buf.insert(cursor_byte, c);
|
||||
let new_cursor = cursor_byte + c.len_utf8();
|
||||
let new_error = recompute_validation(&kind_snapshot, &new_buf, state);
|
||||
update_editing_value_buffer(state, new_buf, new_cursor, new_error);
|
||||
*validation_error = error;
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
_ => SettingsKeyOutcome::Unchanged,
|
||||
LineEditOutcome::HandledNoChange | LineEditOutcome::CursorChanged => {
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
LineEditOutcome::Unhandled => SettingsKeyOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -427,22 +402,18 @@ fn handle_int_stepper(
|
|||
key: &KeyEvent,
|
||||
setting_key: SettingKey,
|
||||
buffer: &str,
|
||||
kind: &SettingKind,
|
||||
min: i64,
|
||||
max: i64,
|
||||
) -> SettingsKeyOutcome {
|
||||
let SettingKind::Int { min, max, .. } = kind else {
|
||||
// Caller pre-checked the kind; defensive bail.
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
};
|
||||
|
||||
let (small_step, large_step) = int_step_sizes(*min, *max);
|
||||
let (small_step, large_step) = int_step_sizes(min, max);
|
||||
let step_delta = |dir: i64, large: bool| -> i64 {
|
||||
let magnitude = if large { large_step } else { small_step };
|
||||
dir * magnitude
|
||||
};
|
||||
|
||||
let apply_step = |state: &mut SettingsModalState, delta: i64| -> SettingsKeyOutcome {
|
||||
let cur = buffer.parse::<i64>().unwrap_or(*min);
|
||||
let new = cur.saturating_add(delta).clamp(*min, *max);
|
||||
let cur = buffer.parse::<i64>().unwrap_or(min);
|
||||
let new = cur.saturating_add(delta).clamp(min, max);
|
||||
if new == cur {
|
||||
// Already clamped — no visible change. Report
|
||||
// Unchanged so the test for `clamps_to_min/max` can
|
||||
|
|
@ -450,8 +421,7 @@ fn handle_int_stepper(
|
|||
return SettingsKeyOutcome::Unchanged;
|
||||
}
|
||||
let new_buf = new.to_string();
|
||||
let new_cursor = new_buf.len();
|
||||
update_editing_value_buffer(state, new_buf, new_cursor, None);
|
||||
update_int_buffer(state, new_buf);
|
||||
SettingsKeyOutcome::Changed
|
||||
};
|
||||
|
||||
|
|
@ -514,43 +484,11 @@ fn handle_int_stepper(
|
|||
}
|
||||
}
|
||||
|
||||
/// Helper: rewrite the EditingValue mode payload with new buffer +
|
||||
/// cursor + validation. Centralised so future variants don't need
|
||||
/// to repeat the pattern-construction boilerplate.
|
||||
fn update_editing_value_buffer(
|
||||
state: &mut SettingsModalState,
|
||||
buffer: String,
|
||||
cursor_byte: usize,
|
||||
validation_error: Option<String>,
|
||||
) {
|
||||
let SettingsModalMode::EditingValue { key, .. } = state.mode else {
|
||||
// Caller-provided key was lost on mode shift; this is the
|
||||
// belt-and-suspenders fallback for a future refactor.
|
||||
return;
|
||||
fn update_int_buffer(state: &mut SettingsModalState, new_buffer: String) {
|
||||
let SettingsMode::EditingInt { buffer, .. } = &mut state.state.mode else {
|
||||
unreachable!("Int update requires EditingInt state");
|
||||
};
|
||||
state.mode = SettingsModalMode::EditingValue {
|
||||
key,
|
||||
buffer,
|
||||
cursor_byte,
|
||||
validation_error,
|
||||
};
|
||||
}
|
||||
|
||||
/// Helper: recompute the validation error for the current buffer
|
||||
/// against the registered validator. Called on every buffer mutation
|
||||
/// so the inline error indicator stays in sync.
|
||||
fn recompute_validation(
|
||||
kind: &SettingKind,
|
||||
buffer: &str,
|
||||
state: &SettingsModalState,
|
||||
) -> Option<String> {
|
||||
match kind {
|
||||
SettingKind::String { validator, .. } => {
|
||||
validate_string(*validator, buffer, &state.pager_snapshot.available_models)
|
||||
}
|
||||
SettingKind::Int { min, max, .. } => validate_int(buffer, *min, *max),
|
||||
_ => None,
|
||||
}
|
||||
*buffer = new_buffer;
|
||||
}
|
||||
|
||||
/// Number of choices for the picker. Handles both
|
||||
|
|
@ -740,16 +678,30 @@ fn handle_browse(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyO
|
|||
}
|
||||
SettingsKeyOutcome::Unchanged
|
||||
}
|
||||
KeyCode::Char(' ') | KeyCode::Enter => {
|
||||
KeyCode::Char(' ') => {
|
||||
if let Some(action) = state.toggle_focused_bool() {
|
||||
SettingsKeyOutcome::Action(action)
|
||||
} else {
|
||||
SettingsKeyOutcome::Unchanged
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Group row → open its sub-sheet of child toggles.
|
||||
if state.try_enter_picking_group() {
|
||||
return SettingsKeyOutcome::Changed;
|
||||
}
|
||||
// For Bool, Enter behaves like Space (the keyboard
|
||||
// map gives both keys the toggle semantics).
|
||||
if let Some(action) = state.toggle_focused_bool() {
|
||||
return SettingsKeyOutcome::Action(action);
|
||||
}
|
||||
// Enum row → enter PickingEnum mode. The picker's chooser
|
||||
// sub-pane takes over rendering and key routing from here.
|
||||
if state.try_enter_picking_enum() {
|
||||
return SettingsKeyOutcome::Changed;
|
||||
}
|
||||
// String / Int row → enter EditingValue mode. The
|
||||
// inline editor takes over rendering and key routing.
|
||||
if state.try_enter_editing_value() {
|
||||
return SettingsKeyOutcome::Changed;
|
||||
}
|
||||
|
|
@ -757,7 +709,7 @@ fn handle_browse(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyO
|
|||
}
|
||||
// `i` aliases `/` (vim-nav "press i to search").
|
||||
KeyCode::Char('/') | KeyCode::Char('i') if key.modifiers.is_empty() => {
|
||||
state.mode = SettingsModalMode::FilterFocused;
|
||||
state.focus_filter();
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
KeyCode::Char('d') if key.modifiers.is_empty() => {
|
||||
|
|
@ -788,19 +740,12 @@ fn handle_browse(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyO
|
|||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
// Continue editing the query from Browse mode (the commit
|
||||
// path via Enter preserves the query, so Browse can be
|
||||
// entered with a non-empty query). Pop one char and
|
||||
// re-broaden the filter without switching modes. Mirrors
|
||||
// `memory_modal::handle_browse`'s Backspace arm.
|
||||
if state.query.pop().is_some() {
|
||||
state.query_cursor = state.query.len();
|
||||
state.invalidate_filter();
|
||||
state.clamp_selected_to_visible();
|
||||
SettingsKeyOutcome::Changed
|
||||
} else {
|
||||
SettingsKeyOutcome::Unchanged
|
||||
// Continue editing a committed query without refocusing the filter.
|
||||
if state.query().is_empty() {
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
}
|
||||
let outcome = state.state.filter.delete_last_grapheme();
|
||||
apply_filter_edit(state, outcome)
|
||||
}
|
||||
_ => SettingsKeyOutcome::Unchanged,
|
||||
}
|
||||
|
|
@ -809,10 +754,11 @@ fn handle_browse(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyO
|
|||
fn handle_filter_focused(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyOutcome {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
state.query.clear();
|
||||
state.query_cursor = 0;
|
||||
state.invalidate_filter();
|
||||
state.clamp_selected_to_visible();
|
||||
if !state.query().is_empty() {
|
||||
state.state.filter.reset();
|
||||
state.invalidate_filter();
|
||||
state.clamp_selected_to_visible();
|
||||
}
|
||||
state.transition_to_browse();
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
|
|
@ -843,59 +789,48 @@ fn handle_filter_focused(state: &mut SettingsModalState, key: &KeyEvent) -> Sett
|
|||
}
|
||||
changed_if(moved)
|
||||
}
|
||||
KeyCode::Tab => SettingsKeyOutcome::Unchanged,
|
||||
KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => {
|
||||
// Clears entire query (not cursor-to-start) to match picker behavior.
|
||||
if state.query.is_empty() {
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
if !state.query().is_empty() {
|
||||
state.state.filter.reset();
|
||||
state.invalidate_filter();
|
||||
state.clamp_selected_to_visible();
|
||||
}
|
||||
state.query.clear();
|
||||
state.query_cursor = 0;
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
_ => {
|
||||
let outcome = state
|
||||
.state
|
||||
.filter
|
||||
.handle_key_with_insert_policy(key, safe_settings_char);
|
||||
apply_filter_edit(state, outcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_settings_char(character: char) -> bool {
|
||||
!crate::render::line_utils::is_unsafe_display_char(character)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn set_filter_cursor(state: &mut SettingsModalState, cursor_byte: usize) {
|
||||
let _ = state.state.filter.set_cursor_byte(cursor_byte);
|
||||
}
|
||||
|
||||
fn apply_filter_edit(
|
||||
state: &mut SettingsModalState,
|
||||
outcome: LineEditOutcome,
|
||||
) -> SettingsKeyOutcome {
|
||||
match outcome {
|
||||
LineEditOutcome::TextChanged => {
|
||||
state.invalidate_filter();
|
||||
state.clamp_selected_to_visible();
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
KeyCode::Char(c) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => {
|
||||
state.query.insert(state.query_cursor, c);
|
||||
state.query_cursor += c.len_utf8();
|
||||
state.invalidate_filter();
|
||||
state.clamp_selected_to_visible();
|
||||
LineEditOutcome::HandledNoChange | LineEditOutcome::CursorChanged => {
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if state.query_cursor == 0 {
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
}
|
||||
let prev = state.query[..state.query_cursor]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map_or(0, |(i, _)| i);
|
||||
state.query.drain(prev..state.query_cursor);
|
||||
state.query_cursor = prev;
|
||||
state.invalidate_filter();
|
||||
state.clamp_selected_to_visible();
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if state.query_cursor == 0 {
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
}
|
||||
state.query_cursor = state.query[..state.query_cursor]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map_or(0, |(i, _)| i);
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if state.query_cursor >= state.query.len() {
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
}
|
||||
state.query_cursor = state.query[state.query_cursor..]
|
||||
.char_indices()
|
||||
.nth(1)
|
||||
.map_or(state.query.len(), |(i, _)| state.query_cursor + i);
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
_ => SettingsKeyOutcome::Unchanged,
|
||||
LineEditOutcome::Unhandled => SettingsKeyOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -937,14 +872,14 @@ pub fn handle_settings_mouse(
|
|||
&& rect_contains(rect, column, row)
|
||||
{
|
||||
let synthetic = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||||
match state.mode {
|
||||
SettingsModalMode::PickingEnum { .. } => {
|
||||
match state.state.mode_kind() {
|
||||
SettingsModeKind::PickingEnum => {
|
||||
return handle_picking_enum(state, &synthetic);
|
||||
}
|
||||
SettingsModalMode::PickingGroup { .. } => {
|
||||
SettingsModeKind::PickingGroup => {
|
||||
return handle_picking_group(state, &synthetic);
|
||||
}
|
||||
SettingsModalMode::EditingValue { .. } => {
|
||||
SettingsModeKind::EditingString | SettingsModeKind::EditingInt => {
|
||||
return handle_editing_value(state, &synthetic);
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -981,21 +916,24 @@ pub fn handle_settings_mouse(
|
|||
// when in EditingValue mode AND the row is an Int. All other
|
||||
// events in EditingValue (scrolls, off-adornment clicks) are
|
||||
// no-ops.
|
||||
if matches!(state.mode, SettingsModalMode::EditingValue { .. }) {
|
||||
if matches!(
|
||||
state.state.mode_kind(),
|
||||
SettingsModeKind::EditingString | SettingsModeKind::EditingInt
|
||||
) {
|
||||
let outcome = handle_editor_mouse(state, kind, column, row);
|
||||
return upgrade_if_breadcrumb_flipped(outcome, breadcrumb_hover_flipped);
|
||||
}
|
||||
|
||||
// PickingEnum: click-to-pick on choice rects, scroll wheel is a
|
||||
// no-op (the picker is bounded; scroll there could surprise).
|
||||
if matches!(state.mode, SettingsModalMode::PickingEnum { .. }) {
|
||||
if state.state.mode_kind() == SettingsModeKind::PickingEnum {
|
||||
let outcome = handle_picker_mouse(state, kind, column, row);
|
||||
return upgrade_if_breadcrumb_flipped(outcome, breadcrumb_hover_flipped);
|
||||
}
|
||||
|
||||
// PickingGroup: hover tracks the child rects; a click toggles the clicked
|
||||
// child in place (same bounded-viewport, scroll-is-a-no-op contract).
|
||||
if matches!(state.mode, SettingsModalMode::PickingGroup { .. }) {
|
||||
if state.state.mode_kind() == SettingsModeKind::PickingGroup {
|
||||
let outcome = handle_group_mouse(state, kind, column, row);
|
||||
return upgrade_if_breadcrumb_flipped(outcome, breadcrumb_hover_flipped);
|
||||
}
|
||||
|
|
@ -1147,8 +1085,7 @@ fn handle_picker_mouse(
|
|||
) -> SettingsKeyOutcome {
|
||||
// Hover highlight for picker choices. Tracks the
|
||||
// choice index under the cursor in `state.hover_row` (same
|
||||
// field as the row-list path; the field is mode-aware via the
|
||||
// active `state.mode`).
|
||||
// field as the row-list path; the field is mode-aware).
|
||||
if matches!(kind, MouseEventKind::Moved) {
|
||||
let new_hover = state
|
||||
.picker_choice_rects
|
||||
|
|
@ -1164,9 +1101,9 @@ fn handle_picker_mouse(
|
|||
let MouseEventKind::Down(crossterm::event::MouseButton::Left) = kind else {
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
};
|
||||
// Snapshot the picker payload under the immutable borrow.
|
||||
let (setting_key, current_idx, original_value, supports_preview) = match &state.mode {
|
||||
SettingsModalMode::PickingEnum {
|
||||
// Snapshot the picker payload before mutating the state.
|
||||
let (setting_key, current_idx, original_value, supports_preview) = match &state.state.mode {
|
||||
SettingsMode::PickingEnum {
|
||||
key,
|
||||
choices_idx,
|
||||
original_value,
|
||||
|
|
@ -1177,7 +1114,7 @@ fn handle_picker_mouse(
|
|||
original_value.clone(),
|
||||
*supports_preview,
|
||||
),
|
||||
_ => return SettingsKeyOutcome::Unchanged,
|
||||
_ => unreachable!("picker mouse handler requires PickingEnum state"),
|
||||
};
|
||||
let clicked_idx = state
|
||||
.picker_choice_rects
|
||||
|
|
@ -1229,9 +1166,9 @@ fn handle_group_mouse(
|
|||
let MouseEventKind::Down(crossterm::event::MouseButton::Left) = kind else {
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
};
|
||||
let group_key = match &state.mode {
|
||||
SettingsModalMode::PickingGroup { key, .. } => *key,
|
||||
_ => return SettingsKeyOutcome::Unchanged,
|
||||
let group_key = match &state.state.mode {
|
||||
SettingsMode::PickingGroup { key, .. } => *key,
|
||||
_ => unreachable!("group mouse handler requires PickingGroup state"),
|
||||
};
|
||||
let children = group_children(state, group_key);
|
||||
let clicked_idx = state
|
||||
|
|
@ -1241,10 +1178,7 @@ fn handle_group_mouse(
|
|||
let Some(idx) = clicked_idx else {
|
||||
return SettingsKeyOutcome::Unchanged;
|
||||
};
|
||||
state.mode = SettingsModalMode::PickingGroup {
|
||||
key: group_key,
|
||||
child_idx: idx,
|
||||
};
|
||||
state.transition_to_picking_group(group_key, idx);
|
||||
let Some(child_key) = children.get(idx).copied() else {
|
||||
return SettingsKeyOutcome::Changed;
|
||||
};
|
||||
|
|
@ -1305,5 +1239,3 @@ fn rect_contains(r: Rect, column: u16, row: u16) -> bool {
|
|||
&& row >= r.y
|
||||
&& row < r.y.saturating_add(r.height)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ mod state;
|
|||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use input::{handle_settings_key, handle_settings_mouse};
|
||||
pub use input::{handle_settings_key, handle_settings_mouse, handle_settings_paste};
|
||||
pub use render::{ResetConfirmOverlay, render_settings_modal};
|
||||
#[allow(unused_imports)] // re-export for crate path; used by settings/registry tests
|
||||
pub(crate) use state::MAX_PICKER_CHOICES;
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ use unicode_width::UnicodeWidthStr;
|
|||
|
||||
use super::state::{
|
||||
CONTENT_MIN_WIDTH, MAX_THOUGHTS_WIDTH_WIDENED_MARGIN, MODAL_TITLE, RowEntry,
|
||||
STANDARD_MAX_WIDTH, SettingsModalMode, SettingsModalState, TITLE_LEADING_DECORATION_W,
|
||||
effective_enum_choices, group_children,
|
||||
STANDARD_MAX_WIDTH, SettingsModalState, SettingsMode, SettingsModeKind,
|
||||
TITLE_LEADING_DECORATION_W, effective_enum_choices, group_children,
|
||||
};
|
||||
use crate::render::line_utils::truncate_str;
|
||||
use crate::settings::{
|
||||
|
|
@ -61,8 +61,8 @@ pub fn render_settings_modal(
|
|||
);
|
||||
&breadcrumb_owned
|
||||
} else {
|
||||
match &state.mode {
|
||||
SettingsModalMode::PickingEnum { key, .. } => {
|
||||
match &state.state.mode {
|
||||
SettingsMode::PickingEnum { key, .. } => {
|
||||
if let Some(meta) = state.registry.find(key) {
|
||||
breadcrumb_owned =
|
||||
format!("{MODAL_TITLE} {} {}", crate::glyphs::chevron(), meta.label);
|
||||
|
|
@ -72,7 +72,7 @@ pub fn render_settings_modal(
|
|||
}
|
||||
}
|
||||
|
||||
SettingsModalMode::EditingValue { key, .. } => {
|
||||
SettingsMode::EditingString { key, .. } | SettingsMode::EditingInt { key, .. } => {
|
||||
if let Some(meta) = state.registry.find(key) {
|
||||
breadcrumb_owned =
|
||||
format!("{MODAL_TITLE} {} {}", crate::glyphs::chevron(), meta.label);
|
||||
|
|
@ -81,7 +81,7 @@ pub fn render_settings_modal(
|
|||
MODAL_TITLE
|
||||
}
|
||||
}
|
||||
SettingsModalMode::PickingGroup { key, .. } => {
|
||||
SettingsMode::PickingGroup { key, .. } => {
|
||||
if let Some(meta) = state.registry.find(key) {
|
||||
breadcrumb_owned =
|
||||
format!("{MODAL_TITLE} {} {}", crate::glyphs::chevron(), meta.label);
|
||||
|
|
@ -99,8 +99,8 @@ pub fn render_settings_modal(
|
|||
// docs footer. Widen the modal when editing `max_thoughts_width`
|
||||
// so the wrap preview is useful at widths above STANDARD_MAX_WIDTH.
|
||||
let widen_for_max_thoughts_width = matches!(
|
||||
&state.mode,
|
||||
SettingsModalMode::EditingValue { key, .. }
|
||||
&state.state.mode,
|
||||
SettingsMode::EditingInt { key, .. }
|
||||
if *key == crate::settings::defs::MAX_THOUGHTS_WIDTH_KEY
|
||||
);
|
||||
let widened_candidate = full_area
|
||||
|
|
@ -122,7 +122,10 @@ pub fn render_settings_modal(
|
|||
footer_lines: 2,
|
||||
}
|
||||
.with_compact(compact);
|
||||
let has_tip_footer = !matches!(state.mode, SettingsModalMode::EditingValue { .. });
|
||||
let has_tip_footer = !matches!(
|
||||
state.state.mode_kind(),
|
||||
SettingsModeKind::EditingString | SettingsModeKind::EditingInt
|
||||
);
|
||||
let footer_lines = if has_tip_footer {
|
||||
modal_window::footer_lines_with_tip_gap(full_area, &sizing, shortcuts)
|
||||
} else {
|
||||
|
|
@ -165,34 +168,35 @@ pub fn render_settings_modal(
|
|||
return true;
|
||||
}
|
||||
|
||||
let (inner_area, docs_footer_area) = match state.mode {
|
||||
SettingsModalMode::EditingValue { .. } => (content_area, None),
|
||||
let (inner_area, docs_footer_area) = match state.state.mode_kind() {
|
||||
SettingsModeKind::EditingString | SettingsModeKind::EditingInt => (content_area, None),
|
||||
_ => modal_window::split_content_for_tip_footer(content_area),
|
||||
};
|
||||
|
||||
// Per-mode render dispatch (exhaustive to catch new variants).
|
||||
let mode_is_sub_pane = matches!(
|
||||
state.mode,
|
||||
SettingsModalMode::PickingEnum { .. }
|
||||
| SettingsModalMode::PickingGroup { .. }
|
||||
| SettingsModalMode::EditingValue { .. }
|
||||
state.state.mode_kind(),
|
||||
SettingsModeKind::PickingEnum
|
||||
| SettingsModeKind::PickingGroup
|
||||
| SettingsModeKind::EditingString
|
||||
| SettingsModeKind::EditingInt
|
||||
);
|
||||
match state.mode {
|
||||
SettingsModalMode::PickingEnum { .. } => {
|
||||
match state.state.mode_kind() {
|
||||
SettingsModeKind::PickingEnum => {
|
||||
state.reset_hit_rects();
|
||||
render_picking_enum(buf, inner_area, state, &theme);
|
||||
state.picker_choice_rects = take_picker_choice_rects();
|
||||
}
|
||||
SettingsModalMode::PickingGroup { .. } => {
|
||||
SettingsModeKind::PickingGroup => {
|
||||
state.reset_hit_rects();
|
||||
let rects = render_picking_group(buf, inner_area, state, &theme);
|
||||
state.picker_choice_rects = rects;
|
||||
}
|
||||
SettingsModalMode::EditingValue { .. } => {
|
||||
SettingsModeKind::EditingString | SettingsModeKind::EditingInt => {
|
||||
state.reset_hit_rects();
|
||||
render_editing_value(buf, inner_area, state, &theme);
|
||||
}
|
||||
SettingsModalMode::Browse | SettingsModalMode::FilterFocused => {
|
||||
SettingsModeKind::Browse | SettingsModeKind::FilterFocused => {
|
||||
// Clear sub-pane hit-rects from prior frames.
|
||||
state.picker_choice_rects.clear();
|
||||
state.editor_adornment_rects = (Rect::default(), Rect::default());
|
||||
|
|
@ -351,13 +355,13 @@ fn build_reset_confirm_shortcuts() -> Vec<Shortcut<'static>> {
|
|||
}
|
||||
|
||||
/// Render the row list with a search bar at the top (Browse/FilterFocused).
|
||||
fn render_row_list_with_search_bar(
|
||||
pub(super) fn render_row_list_with_search_bar(
|
||||
buf: &mut Buffer,
|
||||
content_area: Rect,
|
||||
state: &mut SettingsModalState,
|
||||
theme: &Theme,
|
||||
) {
|
||||
let filter_focused = matches!(state.mode, SettingsModalMode::FilterFocused);
|
||||
let filter_focused = state.state.mode_kind() == SettingsModeKind::FilterFocused;
|
||||
if content_area.height >= 3 {
|
||||
// row 0: search bar, row 1: divider, row 2+: list.
|
||||
let search_area = Rect {
|
||||
|
|
@ -366,16 +370,15 @@ fn render_row_list_with_search_bar(
|
|||
width: content_area.width,
|
||||
height: 1,
|
||||
};
|
||||
crate::views::picker::render_search_bar(
|
||||
crate::views::picker::render_line_editor_search_bar(
|
||||
buf,
|
||||
search_area.x,
|
||||
search_area.y,
|
||||
search_area.width,
|
||||
theme,
|
||||
&state.query,
|
||||
&state.state.filter,
|
||||
filter_focused,
|
||||
true,
|
||||
state.query_cursor,
|
||||
Some(theme.bg_base),
|
||||
);
|
||||
crate::views::picker::render_divider(
|
||||
|
|
@ -403,16 +406,15 @@ fn render_row_list_with_search_bar(
|
|||
width: content_area.width,
|
||||
height: 1,
|
||||
};
|
||||
crate::views::picker::render_search_bar(
|
||||
crate::views::picker::render_line_editor_search_bar(
|
||||
buf,
|
||||
search_area.x,
|
||||
search_area.y,
|
||||
search_area.width,
|
||||
theme,
|
||||
&state.query,
|
||||
&state.state.filter,
|
||||
filter_focused,
|
||||
true,
|
||||
state.query_cursor,
|
||||
Some(theme.bg_base),
|
||||
);
|
||||
let list_area = Rect {
|
||||
|
|
@ -465,16 +467,16 @@ pub(super) fn render_rows(
|
|||
|
||||
// Empty filter — show "No matches for <query>".
|
||||
if total_visible == 0 {
|
||||
if !state.query.is_empty() {
|
||||
if !state.query().is_empty() {
|
||||
let prefix = "No matches for ";
|
||||
let suffix_quote_w = 2u16; // surrounding "" chars
|
||||
let available_for_query = (area.width as usize)
|
||||
.saturating_sub(prefix.width())
|
||||
.saturating_sub(suffix_quote_w as usize);
|
||||
let q_disp = if state.query.width() <= available_for_query {
|
||||
state.query.clone()
|
||||
let q_disp = if state.query().width() <= available_for_query {
|
||||
state.query().to_owned()
|
||||
} else {
|
||||
truncate_str(&state.query, available_for_query)
|
||||
truncate_str(state.query(), available_for_query)
|
||||
};
|
||||
let msg = format!("{prefix}\"{q_disp}\"");
|
||||
let style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
|
||||
|
|
@ -970,11 +972,11 @@ pub(super) fn render_picking_enum(
|
|||
"PICKER_SEPARATOR_W drifted from PICKER_SEPARATOR width",
|
||||
);
|
||||
|
||||
let (setting_key, choices_idx) = match &state.mode {
|
||||
SettingsModalMode::PickingEnum {
|
||||
let (setting_key, choices_idx) = match &state.state.mode {
|
||||
SettingsMode::PickingEnum {
|
||||
key, choices_idx, ..
|
||||
} => (*key, *choices_idx),
|
||||
_ => return,
|
||||
_ => unreachable!("picker renderer requires PickingEnum state"),
|
||||
};
|
||||
let Some(meta) = state.registry.find(setting_key) else {
|
||||
return;
|
||||
|
|
@ -1274,9 +1276,9 @@ fn render_picking_group(
|
|||
state: &SettingsModalState,
|
||||
theme: &Theme,
|
||||
) -> Vec<Rect> {
|
||||
let (group_key, child_idx) = match &state.mode {
|
||||
SettingsModalMode::PickingGroup { key, child_idx } => (*key, *child_idx),
|
||||
_ => return Vec::new(),
|
||||
let (group_key, child_idx) = match &state.state.mode {
|
||||
SettingsMode::PickingGroup { key, child_idx } => (*key, *child_idx),
|
||||
_ => unreachable!("group renderer requires PickingGroup state"),
|
||||
};
|
||||
let Some(group_meta) = state.registry.find(group_key) else {
|
||||
return Vec::new();
|
||||
|
|
@ -1570,32 +1572,14 @@ pub(super) fn render_editing_value(
|
|||
|
||||
state.editor_adornment_rects = (Rect::default(), Rect::default());
|
||||
|
||||
// Snapshot mode payload to avoid borrow conflicts with mut state.
|
||||
let (setting_key, buffer_owned, cursor_byte, validation_error_owned, kind_is_int) = {
|
||||
let (setting_key, buffer, cursor_byte, validation_error) = match &state.mode {
|
||||
SettingsModalMode::EditingValue {
|
||||
key,
|
||||
buffer,
|
||||
cursor_byte,
|
||||
validation_error,
|
||||
} => (*key, buffer.clone(), *cursor_byte, validation_error.clone()),
|
||||
_ => return,
|
||||
};
|
||||
let kind_is_int = state
|
||||
.registry
|
||||
.find(setting_key)
|
||||
.map(|m| matches!(m.kind, SettingKind::Int { .. }))
|
||||
.unwrap_or(false);
|
||||
(
|
||||
setting_key,
|
||||
buffer,
|
||||
cursor_byte,
|
||||
validation_error,
|
||||
kind_is_int,
|
||||
)
|
||||
};
|
||||
|
||||
if kind_is_int {
|
||||
if let SettingsMode::EditingInt {
|
||||
key: setting_key,
|
||||
buffer,
|
||||
..
|
||||
} = &state.state.mode
|
||||
{
|
||||
let setting_key = *setting_key;
|
||||
let buffer = buffer.clone();
|
||||
let Some(meta) = state.registry.find(setting_key) else {
|
||||
return;
|
||||
};
|
||||
|
|
@ -1609,14 +1593,24 @@ pub(super) fn render_editing_value(
|
|||
setting_key,
|
||||
label,
|
||||
description,
|
||||
&buffer_owned,
|
||||
&buffer,
|
||||
theme,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let buffer = buffer_owned.as_str();
|
||||
let validation_error = validation_error_owned.as_deref();
|
||||
let SettingsMode::EditingString {
|
||||
key: setting_key,
|
||||
editor,
|
||||
validation_error,
|
||||
..
|
||||
} = &state.state.mode
|
||||
else {
|
||||
unreachable!("editor renderer requires String or Int state");
|
||||
};
|
||||
let setting_key = *setting_key;
|
||||
let buffer = editor.text();
|
||||
let validation_error = validation_error.as_deref();
|
||||
let Some(meta) = state.registry.find(setting_key) else {
|
||||
return;
|
||||
};
|
||||
|
|
@ -1700,56 +1694,18 @@ pub(super) fn render_editing_value(
|
|||
1,
|
||||
);
|
||||
} else {
|
||||
// Cursor-following pan.
|
||||
let cursor_col = buffer[..cursor_byte.min(buffer.len())].width();
|
||||
let buffer_w = buffer.width();
|
||||
let view_offset = if buffer_w <= visible_buffer_w {
|
||||
0
|
||||
} else if cursor_col >= visible_buffer_w {
|
||||
cursor_col + 1 - visible_buffer_w
|
||||
} else {
|
||||
// Cursor fits within the first window; no scroll.
|
||||
0
|
||||
};
|
||||
|
||||
let start_byte = if view_offset == 0 {
|
||||
0
|
||||
} else {
|
||||
let mut acc = 0usize;
|
||||
buffer
|
||||
.char_indices()
|
||||
.find_map(|(idx, ch)| {
|
||||
if acc >= view_offset {
|
||||
Some(idx)
|
||||
} else {
|
||||
acc += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(buffer.len())
|
||||
};
|
||||
|
||||
// Render the visible tail.
|
||||
let tail = &buffer[start_byte..];
|
||||
// The visible portion may still be wider than the room when
|
||||
// a wide grapheme straddles the right boundary; cap with
|
||||
// `truncate_str` defensively.
|
||||
let tail_text: std::borrow::Cow<'_, str> = if tail.width() <= visible_buffer_w {
|
||||
std::borrow::Cow::Borrowed(tail)
|
||||
} else {
|
||||
std::borrow::Cow::Owned(truncate_str(tail, visible_buffer_w))
|
||||
};
|
||||
let tail_w = (tail_text.width() as u16).min(visible_buffer_w as u16);
|
||||
let viewport = editor.viewport(buffer_room);
|
||||
let visible = &buffer[viewport.visible_byte_range];
|
||||
let visible_width = (visible.width() as u16).min(buffer_room as u16);
|
||||
buf.set_span(
|
||||
input_x,
|
||||
input_y,
|
||||
&Span::styled(tail_text.as_ref(), input_style),
|
||||
tail_w,
|
||||
&Span::styled(visible, input_style),
|
||||
visible_width,
|
||||
);
|
||||
|
||||
// Cursor lands at the logical column relative to the view.
|
||||
let cursor_visual_col = cursor_col.saturating_sub(view_offset);
|
||||
let cursor_x = input_x + (cursor_visual_col as u16).min(buffer_room as u16 - 1);
|
||||
let cursor_x =
|
||||
input_x + (viewport.cursor_display_column as u16).min(buffer_room as u16 - 1);
|
||||
buf.set_span(
|
||||
cursor_x,
|
||||
input_y,
|
||||
|
|
@ -2355,12 +2311,14 @@ pub(super) fn render_setting_row(
|
|||
is_hovered: bool,
|
||||
) -> Rect {
|
||||
let bg = settings_list_row_bg(theme, is_selected, is_hovered);
|
||||
// Paint the row bg across the full area (1 or 2 lines).
|
||||
buf.set_style(area, Style::default().bg(bg));
|
||||
|
||||
let mut label_style = Style::default().fg(theme.text_primary).bg(bg);
|
||||
if is_selected {
|
||||
label_style = label_style.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
// Bool(false) renders muted; all other values use accent.
|
||||
let value_style = Style::default().fg(theme.accent_user).bg(bg);
|
||||
let chevron_style = Style::default().fg(theme.gray).bg(bg);
|
||||
let restart_style = Style::default()
|
||||
|
|
@ -2755,10 +2713,14 @@ fn render_setting_group_row(
|
|||
}
|
||||
}
|
||||
|
||||
/// Browse footer is fixed (same wrap height on every focused row kind).
|
||||
/// Build the footer shortcut row. Enter label varies by focused row kind.
|
||||
pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'static>> {
|
||||
match state.mode {
|
||||
SettingsModalMode::Browse => {
|
||||
match &state.state.mode {
|
||||
SettingsMode::Browse => {
|
||||
let enter_label = match state.focused_setting() {
|
||||
Some((_, meta)) if matches!(meta.kind, SettingKind::Bool { .. }) => "Enter toggle",
|
||||
_ => "Enter edit",
|
||||
};
|
||||
let mut shortcuts = vec![
|
||||
Shortcut {
|
||||
label: "\u{2191}/\u{2193}/j/k nav",
|
||||
|
|
@ -2771,7 +2733,12 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'stati
|
|||
id: 0,
|
||||
},
|
||||
Shortcut {
|
||||
label: "Space/Enter",
|
||||
label: "Space toggle",
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
Shortcut {
|
||||
label: enter_label,
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
|
|
@ -2801,7 +2768,7 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'stati
|
|||
modal_window::push_vim_nav_search_hint(&mut shortcuts, false);
|
||||
shortcuts
|
||||
}
|
||||
SettingsModalMode::FilterFocused => vec![
|
||||
SettingsMode::FilterFocused => vec![
|
||||
Shortcut {
|
||||
label: "type to filter",
|
||||
clickable: false,
|
||||
|
|
@ -2828,17 +2795,17 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'stati
|
|||
id: 0,
|
||||
},
|
||||
],
|
||||
SettingsModalMode::PickingEnum {
|
||||
SettingsMode::PickingEnum {
|
||||
supports_preview: sp,
|
||||
..
|
||||
} => {
|
||||
// Labels depend on whether the Enum supports live preview.
|
||||
let nav_label = if sp {
|
||||
let nav_label = if *sp {
|
||||
"\u{2191}/\u{2193} try"
|
||||
} else {
|
||||
"\u{2191}/\u{2193} nav"
|
||||
};
|
||||
let esc_label = if sp { "Esc revert" } else { "Esc cancel" };
|
||||
let esc_label = if *sp { "Esc revert" } else { "Esc cancel" };
|
||||
vec![
|
||||
Shortcut {
|
||||
label: nav_label,
|
||||
|
|
@ -2863,48 +2830,16 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'stati
|
|||
]
|
||||
}
|
||||
|
||||
SettingsModalMode::EditingValue { key, .. } => {
|
||||
// Int stepper: step-only hints with range-aware deltas.
|
||||
if let Some(SettingKind::Int { min, max, .. }) =
|
||||
state.registry.find(key).map(|m| &m.kind)
|
||||
{
|
||||
let (small_label, large_label) = int_step_footer_labels(*min, *max);
|
||||
return vec![
|
||||
Shortcut {
|
||||
label: small_label,
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
Shortcut {
|
||||
label: large_label,
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
Shortcut {
|
||||
label: "Enter commit",
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
Shortcut {
|
||||
label: "Esc cancel",
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
Shortcut {
|
||||
label: "d reset",
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
SettingsMode::EditingInt { min, max, .. } => {
|
||||
let (small_label, large_label) = int_step_footer_labels(*min, *max);
|
||||
vec![
|
||||
Shortcut {
|
||||
label: "type to edit",
|
||||
label: small_label,
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
Shortcut {
|
||||
label: "\u{2190}/\u{2192} cursor",
|
||||
label: large_label,
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
|
|
@ -2918,9 +2853,36 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'stati
|
|||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
Shortcut {
|
||||
label: "d reset",
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
SettingsModalMode::PickingGroup { .. } => vec![
|
||||
SettingsMode::EditingString { .. } => vec![
|
||||
Shortcut {
|
||||
label: "type to edit",
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
Shortcut {
|
||||
label: "\u{2190}/\u{2192} cursor",
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
Shortcut {
|
||||
label: "Enter commit",
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
Shortcut {
|
||||
label: "Esc cancel",
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
],
|
||||
SettingsMode::PickingGroup { .. } => vec![
|
||||
Shortcut {
|
||||
label: "\u{2191}/\u{2193}/j/k nav",
|
||||
clickable: false,
|
||||
|
|
@ -2939,5 +2901,3 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'stati
|
|||
],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use std::sync::Arc;
|
|||
use ratatui::layout::Rect;
|
||||
|
||||
use crate::app::actions::Action;
|
||||
use crate::input::line_editor::LineEditor;
|
||||
use crate::settings::{
|
||||
EnumChoice, OwnedEnumChoice, PagerLocalSnapshot, SettingCategory, SettingKey, SettingKind,
|
||||
SettingMeta, SettingValue, SettingsRegistry, StringValidator, current_value_for,
|
||||
|
|
@ -71,7 +72,7 @@ pub enum RowEntry {
|
|||
Setting { key: SettingKey, meta_index: usize },
|
||||
}
|
||||
|
||||
/// Mode state for the modal.
|
||||
/// Read-only projection of the modal's private discriminated state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SettingsModalMode {
|
||||
Browse,
|
||||
|
|
@ -93,16 +94,67 @@ pub enum SettingsModalMode {
|
|||
key: SettingKey,
|
||||
child_idx: usize,
|
||||
},
|
||||
/// Inline string/int editor. `cursor_byte` is always on a char
|
||||
/// boundary. `validation_error` shows live feedback; commit
|
||||
/// re-validates before dispatching. No `original_value` — these
|
||||
/// settings have no live preview, so Esc is a pure cancel.
|
||||
/// Inline string/int editor. No live preview; Esc is a pure cancel.
|
||||
EditingValue {
|
||||
key: SettingKey,
|
||||
buffer: String,
|
||||
cursor_byte: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct SettingsState {
|
||||
pub(super) filter: LineEditor,
|
||||
pub(super) mode: SettingsMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum SettingsModeKind {
|
||||
Browse,
|
||||
FilterFocused,
|
||||
PickingEnum,
|
||||
PickingGroup,
|
||||
EditingString,
|
||||
EditingInt,
|
||||
}
|
||||
|
||||
impl SettingsState {
|
||||
pub(super) fn mode_kind(&self) -> SettingsModeKind {
|
||||
match &self.mode {
|
||||
SettingsMode::Browse => SettingsModeKind::Browse,
|
||||
SettingsMode::FilterFocused => SettingsModeKind::FilterFocused,
|
||||
SettingsMode::PickingEnum { .. } => SettingsModeKind::PickingEnum,
|
||||
SettingsMode::PickingGroup { .. } => SettingsModeKind::PickingGroup,
|
||||
SettingsMode::EditingString { .. } => SettingsModeKind::EditingString,
|
||||
SettingsMode::EditingInt { .. } => SettingsModeKind::EditingInt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum SettingsMode {
|
||||
Browse,
|
||||
FilterFocused,
|
||||
PickingEnum {
|
||||
key: SettingKey,
|
||||
choices_idx: usize,
|
||||
original_value: SettingValue,
|
||||
supports_preview: bool,
|
||||
},
|
||||
PickingGroup {
|
||||
key: SettingKey,
|
||||
child_idx: usize,
|
||||
},
|
||||
EditingString {
|
||||
key: SettingKey,
|
||||
editor: LineEditor,
|
||||
validator: StringValidator,
|
||||
validation_error: Option<String>,
|
||||
},
|
||||
EditingInt {
|
||||
key: SettingKey,
|
||||
buffer: String,
|
||||
min: i64,
|
||||
max: i64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Settings modal state. Boxed inside `ActiveModal::Settings` to
|
||||
|
|
@ -119,11 +171,7 @@ pub struct SettingsModalState {
|
|||
pub selected: usize,
|
||||
/// Vertical scroll offset (line-granular).
|
||||
pub scroll_offset: usize,
|
||||
pub mode: SettingsModalMode,
|
||||
/// Filter query. Persists across FilterFocused→Browse on Enter; cleared by Esc.
|
||||
pub query: String,
|
||||
/// Byte offset of the editing cursor within `query`.
|
||||
pub query_cursor: usize,
|
||||
pub(super) state: SettingsState,
|
||||
/// Row indices matching `query`, recomputed per mutation (not per frame).
|
||||
pub(super) filtered_cache: Vec<usize>,
|
||||
|
||||
|
|
@ -178,9 +226,10 @@ impl SettingsModalState {
|
|||
rows,
|
||||
selected,
|
||||
scroll_offset: 0,
|
||||
mode: SettingsModalMode::Browse,
|
||||
query: String::new(),
|
||||
query_cursor: 0,
|
||||
state: SettingsState {
|
||||
filter: LineEditor::default(),
|
||||
mode: SettingsMode::Browse,
|
||||
},
|
||||
filtered_cache,
|
||||
list_area: Rect::default(),
|
||||
row_rects: Vec::new(),
|
||||
|
|
@ -214,11 +263,12 @@ impl SettingsModalState {
|
|||
/// Keeps focus on the same key when possible; exits sub-panes if the key vanished.
|
||||
pub fn rebuild_rows(&mut self) {
|
||||
let prev_key = self.focused_setting().map(|(k, _)| k);
|
||||
let subpane_key = match &self.mode {
|
||||
SettingsModalMode::PickingEnum { key, .. }
|
||||
| SettingsModalMode::PickingGroup { key, .. }
|
||||
| SettingsModalMode::EditingValue { key, .. } => Some(*key),
|
||||
SettingsModalMode::Browse | SettingsModalMode::FilterFocused => None,
|
||||
let subpane_key = match &self.state.mode {
|
||||
SettingsMode::PickingEnum { key, .. }
|
||||
| SettingsMode::PickingGroup { key, .. }
|
||||
| SettingsMode::EditingString { key, .. }
|
||||
| SettingsMode::EditingInt { key, .. } => Some(*key),
|
||||
SettingsMode::Browse | SettingsMode::FilterFocused => None,
|
||||
};
|
||||
|
||||
self.rows = build_rows(&self.registry);
|
||||
|
|
@ -230,9 +280,7 @@ impl SettingsModalState {
|
|||
.iter()
|
||||
.any(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key));
|
||||
if !still_visible {
|
||||
self.mode = SettingsModalMode::Browse;
|
||||
self.settings_breadcrumb_rect = None;
|
||||
self.picker_choice_rects.clear();
|
||||
self.transition_to_browse();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -255,9 +303,73 @@ impl SettingsModalState {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> SettingsModalMode {
|
||||
match &self.state.mode {
|
||||
SettingsMode::Browse => SettingsModalMode::Browse,
|
||||
SettingsMode::FilterFocused => SettingsModalMode::FilterFocused,
|
||||
SettingsMode::PickingEnum {
|
||||
key,
|
||||
choices_idx,
|
||||
original_value,
|
||||
supports_preview,
|
||||
} => SettingsModalMode::PickingEnum {
|
||||
key,
|
||||
choices_idx: *choices_idx,
|
||||
original_value: original_value.clone(),
|
||||
supports_preview: *supports_preview,
|
||||
},
|
||||
SettingsMode::PickingGroup { key, child_idx } => SettingsModalMode::PickingGroup {
|
||||
key,
|
||||
child_idx: *child_idx,
|
||||
},
|
||||
SettingsMode::EditingString { key, .. } | SettingsMode::EditingInt { key, .. } => {
|
||||
SettingsModalMode::EditingValue { key }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query(&self) -> &str {
|
||||
self.state.filter.text()
|
||||
}
|
||||
|
||||
pub fn query_cursor(&self) -> usize {
|
||||
self.state.filter.cursor_byte()
|
||||
}
|
||||
|
||||
pub fn set_query(&mut self, query: impl Into<String>) {
|
||||
self.state.filter.set_text(query);
|
||||
self.invalidate_filter();
|
||||
self.clamp_selected_to_visible();
|
||||
}
|
||||
|
||||
pub fn editing_buffer(&self) -> Option<&str> {
|
||||
match &self.state.mode {
|
||||
SettingsMode::EditingString { editor, .. } => Some(editor.text()),
|
||||
SettingsMode::EditingInt { buffer, .. } => Some(buffer),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn editing_cursor_byte(&self) -> Option<usize> {
|
||||
match &self.state.mode {
|
||||
SettingsMode::EditingString { editor, .. } => Some(editor.cursor_byte()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn editing_validation_error(&self) -> Option<&str> {
|
||||
match &self.state.mode {
|
||||
SettingsMode::EditingString {
|
||||
validation_error, ..
|
||||
} => validation_error.as_deref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Recompute `filtered_cache` from the current `query`.
|
||||
pub(super) fn invalidate_filter(&mut self) {
|
||||
self.filtered_cache = compute_filtered(&self.rows, &self.registry, &self.query);
|
||||
self.filtered_cache =
|
||||
compute_filtered(&self.rows, &self.registry, self.state.filter.text());
|
||||
}
|
||||
|
||||
/// Snap `selected` to the first visible setting if filtered out.
|
||||
|
|
@ -358,12 +470,65 @@ impl SettingsModalState {
|
|||
/// Transition to Browse, clearing sub-pane hover/breadcrumb state
|
||||
/// to prevent stale hit-rects across mode changes.
|
||||
pub(crate) fn transition_to_browse(&mut self) {
|
||||
self.mode = SettingsModalMode::Browse;
|
||||
self.state.mode = SettingsMode::Browse;
|
||||
self.hover_row = None;
|
||||
self.settings_breadcrumb_rect = None;
|
||||
self.breadcrumb_hovered = false;
|
||||
}
|
||||
|
||||
pub fn focus_filter(&mut self) {
|
||||
self.state.mode = SettingsMode::FilterFocused;
|
||||
}
|
||||
|
||||
pub(super) fn transition_to_picking_enum(
|
||||
&mut self,
|
||||
key: SettingKey,
|
||||
choices_idx: usize,
|
||||
original_value: SettingValue,
|
||||
supports_preview: bool,
|
||||
) {
|
||||
self.state.mode = SettingsMode::PickingEnum {
|
||||
key,
|
||||
choices_idx,
|
||||
original_value,
|
||||
supports_preview,
|
||||
};
|
||||
}
|
||||
|
||||
pub(super) fn transition_to_picking_group(&mut self, key: SettingKey, child_idx: usize) {
|
||||
self.state.mode = SettingsMode::PickingGroup { key, child_idx };
|
||||
}
|
||||
|
||||
pub(super) fn transition_to_editing_string(
|
||||
&mut self,
|
||||
key: SettingKey,
|
||||
editor: LineEditor,
|
||||
validator: StringValidator,
|
||||
validation_error: Option<String>,
|
||||
) {
|
||||
self.state.mode = SettingsMode::EditingString {
|
||||
key,
|
||||
editor,
|
||||
validator,
|
||||
validation_error,
|
||||
};
|
||||
}
|
||||
|
||||
pub(super) fn transition_to_editing_int(
|
||||
&mut self,
|
||||
key: SettingKey,
|
||||
buffer: String,
|
||||
min: i64,
|
||||
max: i64,
|
||||
) {
|
||||
self.state.mode = SettingsMode::EditingInt {
|
||||
key,
|
||||
buffer,
|
||||
min,
|
||||
max,
|
||||
};
|
||||
}
|
||||
|
||||
/// Transition to `PickingEnum` if the focused row is Enum/DynamicEnum.
|
||||
/// Returns `false` if the focused row is another kind.
|
||||
pub fn try_enter_picking_enum(&mut self) -> bool {
|
||||
|
|
@ -469,12 +634,7 @@ impl SettingsModalState {
|
|||
_ => SettingValue::Enum(""),
|
||||
}
|
||||
});
|
||||
self.mode = SettingsModalMode::PickingEnum {
|
||||
key,
|
||||
choices_idx,
|
||||
supports_preview,
|
||||
original_value,
|
||||
};
|
||||
self.transition_to_picking_enum(key, choices_idx, original_value, supports_preview);
|
||||
self.hover_row = None;
|
||||
true
|
||||
}
|
||||
|
|
@ -489,7 +649,7 @@ impl SettingsModalState {
|
|||
if !matches!(meta.kind, SettingKind::Group { .. }) {
|
||||
return false;
|
||||
}
|
||||
self.mode = SettingsModalMode::PickingGroup { key, child_idx: 0 };
|
||||
self.transition_to_picking_group(key, 0);
|
||||
self.hover_row = None;
|
||||
true
|
||||
}
|
||||
|
|
@ -499,31 +659,36 @@ impl SettingsModalState {
|
|||
let Some((key, meta)) = self.focused_setting() else {
|
||||
return false;
|
||||
};
|
||||
let buffer = match (&meta.kind, self.value_for(key)) {
|
||||
(SettingKind::String { .. }, Some(SettingValue::String(s))) => s,
|
||||
(SettingKind::Int { .. }, Some(SettingValue::Int(i))) => i.to_string(),
|
||||
// Fallback for registry skew — seed from default.
|
||||
(SettingKind::String { default, .. }, _) => default.to_string(),
|
||||
(SettingKind::Int { default, .. }, _) => default.to_string(),
|
||||
_ => return false,
|
||||
};
|
||||
let cursor_byte = buffer.len();
|
||||
|
||||
// Validate the seed value upfront.
|
||||
let validation_error = match &meta.kind {
|
||||
SettingKind::String { validator, .. } => {
|
||||
validate_string(*validator, &buffer, &self.pager_snapshot.available_models)
|
||||
let kind = meta.kind.clone();
|
||||
let value = self.value_for(key);
|
||||
match kind {
|
||||
SettingKind::String {
|
||||
default, validator, ..
|
||||
} => {
|
||||
let text = match value {
|
||||
Some(SettingValue::String(text)) => text,
|
||||
_ => default.to_string(),
|
||||
};
|
||||
let mut editor = LineEditor::default();
|
||||
editor.set_text(text);
|
||||
let validation_error = validate_string(
|
||||
validator,
|
||||
editor.text(),
|
||||
&self.pager_snapshot.available_models,
|
||||
);
|
||||
self.transition_to_editing_string(key, editor, validator, validation_error);
|
||||
}
|
||||
SettingKind::Int { min, max, .. } => validate_int(&buffer, *min, *max),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
self.mode = SettingsModalMode::EditingValue {
|
||||
key,
|
||||
buffer,
|
||||
cursor_byte,
|
||||
validation_error,
|
||||
};
|
||||
SettingKind::Int {
|
||||
default, min, max, ..
|
||||
} => {
|
||||
let buffer = match value {
|
||||
Some(SettingValue::Int(value)) => value.to_string(),
|
||||
_ => default.to_string(),
|
||||
};
|
||||
self.transition_to_editing_int(key, buffer, min, max);
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
self.hover_row = None;
|
||||
true
|
||||
}
|
||||
|
|
@ -687,6 +852,7 @@ pub(super) fn action_for_bool(key: SettingKey, new: bool) -> Option<Action> {
|
|||
"collapsed_edit_blocks" => Some(Action::SetCollapsedEditBlocks(new)),
|
||||
"prompt_suggestions" => Some(Action::SetPromptSuggestions(new)),
|
||||
"respect_manual_folds" => Some(Action::SetRespectManualFolds(new)),
|
||||
"page_flip_on_send" => Some(Action::SetPageFlipOnSend(new)),
|
||||
"invert_scroll" => Some(Action::SetInvertScroll(new)),
|
||||
"show_tips" => Some(Action::SetShowTips(new)),
|
||||
"auto_update" => Some(Action::SetAutoUpdate(new)),
|
||||
|
|
@ -856,18 +1022,6 @@ pub(super) fn validate_string(
|
|||
}
|
||||
}
|
||||
|
||||
/// Validate an Int buffer against `(min, max)` bounds.
|
||||
pub(super) fn validate_int(buffer: &str, min: i64, max: i64) -> Option<String> {
|
||||
if buffer.is_empty() {
|
||||
return Some("Value cannot be empty".to_string());
|
||||
}
|
||||
match buffer.parse::<i64>() {
|
||||
Ok(v) if v >= min && v <= max => None,
|
||||
Ok(v) => Some(format!("Value out of range ({min}\u{2013}{max}): {v}")),
|
||||
Err(_) => Some(format!("Not a valid integer: \"{buffer}\"")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Soft product cap on static Enum choices (settings unit tests enforce it).
|
||||
///
|
||||
/// The chooser already scrolls within the viewport when the focused choice
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -301,16 +301,14 @@ pub fn build_entries(
|
|||
/// key + label columns.
|
||||
pub fn build_initial_picker_state(entries: &[ShortcutsHelpEntry]) -> PickerState {
|
||||
use crate::views::picker::{PickerMode, PopupConfig};
|
||||
PickerState {
|
||||
selected: entries.iter().position(|e| e.is_hint()).unwrap_or(0),
|
||||
mode: PickerMode::Popup(PopupConfig {
|
||||
width_pct: 0.6,
|
||||
height_pct: 0.7,
|
||||
min_width: 60,
|
||||
min_height: 16,
|
||||
}),
|
||||
..PickerState::default()
|
||||
}
|
||||
let mut state = PickerState::with_mode(PickerMode::Popup(PopupConfig {
|
||||
width_pct: 0.6,
|
||||
height_pct: 0.7,
|
||||
min_width: 60,
|
||||
min_height: 16,
|
||||
}));
|
||||
state.selected = entries.iter().position(|e| e.is_hint()).unwrap_or(0);
|
||||
state
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -577,8 +575,7 @@ pub fn detail_from_entry(entry: &ShortcutsHelpEntry) -> Option<ShortcutsHelpMode
|
|||
/// detail returns to an unfiltered browse and closes with one more press.
|
||||
fn enter_detail(state: &mut PickerState, entry: &ShortcutsHelpEntry) -> Option<ShortcutsHelpMode> {
|
||||
let detail = detail_from_entry(entry)?;
|
||||
state.query.clear();
|
||||
state.query_cursor = 0;
|
||||
state.set_query("");
|
||||
state.search_active = false;
|
||||
Some(detail)
|
||||
}
|
||||
|
|
@ -813,7 +810,7 @@ pub fn handle_input(
|
|||
return ShortcutsHelpOutcome::Unchanged;
|
||||
}
|
||||
|
||||
let searching = state.search_active || !state.query.is_empty();
|
||||
let searching = state.search_active || !state.query().is_empty();
|
||||
let vim_mode = crate::appearance::cache::load_vim_mode();
|
||||
|
||||
if !searching {
|
||||
|
|
@ -827,7 +824,7 @@ pub fn handle_input(
|
|||
if key.code == KeyCode::Char('f') {
|
||||
return ShortcutsHelpOutcome::ToggleFilter;
|
||||
}
|
||||
let filtered = filter_entries(entries, &state.query, hide_dimmed, collapsed);
|
||||
let filtered = filter_entries(entries, state.query(), hide_dimmed, collapsed);
|
||||
if let Some(ShortcutsHelpEntry::SectionHeader { category_idx, .. }) =
|
||||
selected_original_entry(&filtered, entries, state.selected)
|
||||
{
|
||||
|
|
@ -891,6 +888,9 @@ pub fn handle_input(
|
|||
return match handle_picker_input(&ev, state, filtered.len(), &config) {
|
||||
PickerOutcome::Selected(_) | PickerOutcome::Closed => ShortcutsHelpOutcome::Close,
|
||||
PickerOutcome::Unchanged => ShortcutsHelpOutcome::Unchanged,
|
||||
PickerOutcome::Changed | PickerOutcome::QueryChanged => {
|
||||
ShortcutsHelpOutcome::Changed
|
||||
}
|
||||
_ => ShortcutsHelpOutcome::Changed,
|
||||
};
|
||||
}
|
||||
|
|
@ -898,14 +898,13 @@ pub fn handle_input(
|
|||
}
|
||||
|
||||
if key.code == KeyCode::Esc {
|
||||
state.query.clear();
|
||||
state.query_cursor = 0;
|
||||
state.set_query("");
|
||||
state.search_active = false;
|
||||
state.selected = 0;
|
||||
return ShortcutsHelpOutcome::Changed;
|
||||
}
|
||||
|
||||
let filtered = filter_entries(entries, &state.query, hide_dimmed, collapsed);
|
||||
let filtered = filter_entries(entries, state.query(), hide_dimmed, collapsed);
|
||||
let non_sel: Vec<bool> = non_selectable_mask(&filtered, entries);
|
||||
let config = picker_config(&non_sel);
|
||||
|
||||
|
|
@ -930,6 +929,7 @@ pub fn handle_input(
|
|||
}
|
||||
PickerOutcome::Closed => ShortcutsHelpOutcome::Close,
|
||||
PickerOutcome::Unchanged => ShortcutsHelpOutcome::Unchanged,
|
||||
PickerOutcome::Changed | PickerOutcome::QueryChanged => ShortcutsHelpOutcome::Changed,
|
||||
_ => ShortcutsHelpOutcome::Changed,
|
||||
}
|
||||
}
|
||||
|
|
@ -960,7 +960,7 @@ pub fn handle_mouse(
|
|||
}
|
||||
}
|
||||
|
||||
let filtered = filter_entries(entries, &state.query, hide_dimmed, collapsed);
|
||||
let filtered = filter_entries(entries, state.query(), hide_dimmed, collapsed);
|
||||
let non_sel: Vec<bool> = non_selectable_mask(&filtered, entries);
|
||||
let config = picker_config(&non_sel);
|
||||
|
||||
|
|
@ -986,6 +986,7 @@ pub fn handle_mouse(
|
|||
}
|
||||
PickerOutcome::Closed => ShortcutsHelpOutcome::Close,
|
||||
PickerOutcome::Unchanged => ShortcutsHelpOutcome::Unchanged,
|
||||
PickerOutcome::Changed | PickerOutcome::QueryChanged => ShortcutsHelpOutcome::Changed,
|
||||
_ => ShortcutsHelpOutcome::Changed,
|
||||
}
|
||||
}
|
||||
|
|
@ -1267,7 +1268,7 @@ pub fn render_modal(
|
|||
return;
|
||||
}
|
||||
|
||||
let rows = CheatsheetRows::build(entries, &state.query, filter_active, collapsed_sections);
|
||||
let rows = CheatsheetRows::build(entries, state.query(), filter_active, collapsed_sections);
|
||||
let help_refs = rows.help_refs();
|
||||
let picker_entries = rows.picker_entries(state, expanded_ids, &help_refs);
|
||||
let non_sel: Vec<bool> = vec![false; picker_entries.len()];
|
||||
|
|
@ -1285,19 +1286,18 @@ pub fn render_modal(
|
|||
let content_area = mca.content;
|
||||
let inner_x = mca.inner_x;
|
||||
let inner_width = mca.inner_width;
|
||||
let searching = state.search_active || !state.query.is_empty();
|
||||
let searching = state.search_active || !state.query().is_empty();
|
||||
let show_search_hint = !searching;
|
||||
|
||||
picker::render_search_bar(
|
||||
picker::render_picker_search_bar(
|
||||
buf,
|
||||
content_area.x,
|
||||
content_area.y,
|
||||
content_area.width,
|
||||
theme,
|
||||
&state.query,
|
||||
state,
|
||||
searching,
|
||||
show_search_hint,
|
||||
state.query_cursor,
|
||||
Some(theme.bg_base),
|
||||
);
|
||||
let sep_y = content_area.y + 1;
|
||||
|
|
@ -1380,10 +1380,9 @@ pub fn handle_modal_key(
|
|||
use crate::views::modal_window as mw;
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
let searching = state.search_active || !state.query.is_empty();
|
||||
let searching = state.search_active || !state.query().is_empty();
|
||||
if mode.is_browse() && searching && key.code == KeyCode::Esc {
|
||||
state.query.clear();
|
||||
state.query_cursor = 0;
|
||||
state.set_query("");
|
||||
state.search_active = false;
|
||||
state.selected = 0;
|
||||
return ModalKeyOutcome::Changed;
|
||||
|
|
@ -1426,6 +1425,29 @@ pub fn handle_modal_key(
|
|||
}
|
||||
}
|
||||
|
||||
pub fn handle_paste(
|
||||
text: &str,
|
||||
state: &mut PickerState,
|
||||
mode: &ShortcutsHelpMode,
|
||||
) -> ShortcutsHelpOutcome {
|
||||
if mode.is_detail() || !state.search_active {
|
||||
return ShortcutsHelpOutcome::Unchanged;
|
||||
}
|
||||
match state.paste_query(text) {
|
||||
crate::input::line_editor::LineEditOutcome::TextChanged => {
|
||||
state.selected = 0;
|
||||
state.selection_hidden = false;
|
||||
state.scroll_offset = None;
|
||||
ShortcutsHelpOutcome::Changed
|
||||
}
|
||||
crate::input::line_editor::LineEditOutcome::HandledNoChange
|
||||
| crate::input::line_editor::LineEditOutcome::CursorChanged => {
|
||||
ShortcutsHelpOutcome::Changed
|
||||
}
|
||||
crate::input::line_editor::LineEditOutcome::Unhandled => ShortcutsHelpOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2220,8 +2242,7 @@ mod tests {
|
|||
];
|
||||
let mut state = build_initial_picker_state(&entries);
|
||||
// Active search matching the hint, selection on the matching row.
|
||||
state.query = "send".to_string();
|
||||
state.query_cursor = state.query.len();
|
||||
state.set_query("send");
|
||||
state.search_active = true;
|
||||
state.selected = 1;
|
||||
let mut mode = browse_mode();
|
||||
|
|
@ -2237,7 +2258,7 @@ mod tests {
|
|||
assert_eq!(result, ShortcutsHelpOutcome::Changed);
|
||||
assert!(mode.is_detail(), "Enter from search opens the detail page");
|
||||
assert!(
|
||||
state.query.is_empty(),
|
||||
state.query().is_empty(),
|
||||
"opening detail clears the search query"
|
||||
);
|
||||
assert!(!state.search_active, "opening detail clears search_active");
|
||||
|
|
@ -2257,11 +2278,10 @@ mod tests {
|
|||
];
|
||||
let mut state = build_initial_picker_state(&entries);
|
||||
// Active search that still matches the hint row.
|
||||
state.query = "send".to_string();
|
||||
state.query_cursor = state.query.len();
|
||||
state.set_query("send");
|
||||
state.search_active = true;
|
||||
// Map a click at row 2 to the hint's position in the filtered view.
|
||||
let filtered = filter_entries(&entries, &state.query, false, &no_collapsed());
|
||||
let filtered = filter_entries(&entries, state.query(), false, &no_collapsed());
|
||||
let hint_pos = filtered
|
||||
.iter()
|
||||
.position(|&i| matches!(entries[i], ShortcutsHelpEntry::Hint { .. }))
|
||||
|
|
@ -2292,7 +2312,7 @@ mod tests {
|
|||
assert_eq!(result, ShortcutsHelpOutcome::Changed);
|
||||
assert!(mode.is_detail(), "clicking a hint from search opens detail");
|
||||
assert!(
|
||||
state.query.is_empty(),
|
||||
state.query().is_empty(),
|
||||
"click-open detail clears the search query"
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -2875,7 +2895,7 @@ mod tests {
|
|||
);
|
||||
assert_eq!(enter_search, ShortcutsHelpOutcome::Changed);
|
||||
assert!(state.search_active, "`i` must activate cheatsheet search");
|
||||
assert!(state.query.is_empty(), "`i` must not enter search text");
|
||||
assert!(state.query().is_empty(), "`i` must not enter search text");
|
||||
|
||||
let type_j = handle_input(
|
||||
&make_key(crossterm::event::KeyCode::Char('j')),
|
||||
|
|
@ -2887,7 +2907,7 @@ mod tests {
|
|||
&mut mode,
|
||||
);
|
||||
assert_eq!(type_j, ShortcutsHelpOutcome::Changed);
|
||||
assert_eq!(state.query, "j", "printables must type in active search");
|
||||
assert_eq!(state.query(), "j", "printables must type in active search");
|
||||
}
|
||||
|
||||
// ── vim_mode tests ───────────────────────────────────────────
|
||||
|
|
@ -2915,7 +2935,7 @@ mod tests {
|
|||
);
|
||||
assert_eq!(down, ShortcutsHelpOutcome::Changed);
|
||||
assert_eq!(state.selected, 2, "`j` must select the next row");
|
||||
assert!(state.query.is_empty(), "`j` must not enter search text");
|
||||
assert!(state.query().is_empty(), "`j` must not enter search text");
|
||||
assert!(!state.search_active, "`j` must leave search inactive");
|
||||
|
||||
let up = handle_input(
|
||||
|
|
@ -2929,7 +2949,7 @@ mod tests {
|
|||
);
|
||||
assert_eq!(up, ShortcutsHelpOutcome::Changed);
|
||||
assert_eq!(state.selected, 1, "`k` must select the previous row");
|
||||
assert!(state.query.is_empty(), "`k` must not enter search text");
|
||||
assert!(state.query().is_empty(), "`k` must not enter search text");
|
||||
assert!(!state.search_active, "`k` must leave search inactive");
|
||||
}
|
||||
|
||||
|
|
@ -2960,7 +2980,7 @@ mod tests {
|
|||
ShortcutsHelpOutcome::Changed,
|
||||
"non-vim `{ch}` must start search"
|
||||
);
|
||||
assert_eq!(state.query, ch.to_string(), "non-vim `{ch}` must type");
|
||||
assert_eq!(state.query(), ch.to_string(), "non-vim `{ch}` must type");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3227,7 +3247,7 @@ mod tests {
|
|||
ShortcutsHelpOutcome::Unchanged,
|
||||
"vim h on a collapsed action hint must be inert"
|
||||
);
|
||||
assert!(state.query.is_empty(), "vim h must not enter search text");
|
||||
assert!(state.query().is_empty(), "vim h must not enter search text");
|
||||
|
||||
let key_id = ExpandKey::Action(ActionId::SendPrompt);
|
||||
let expanded = std::collections::HashSet::from([key_id]);
|
||||
|
|
@ -3245,7 +3265,7 @@ mod tests {
|
|||
ShortcutsHelpOutcome::ToggleExpand(key_id),
|
||||
"vim h must collapse an expanded action hint"
|
||||
);
|
||||
assert!(state.query.is_empty(), "vim h must not enter search text");
|
||||
assert!(state.query().is_empty(), "vim h must not enter search text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -3324,7 +3344,7 @@ mod tests {
|
|||
ShortcutsHelpOutcome::ToggleExpand(key_id),
|
||||
"vim l must expand the paste pseudo-row"
|
||||
);
|
||||
assert!(state.query.is_empty(), "vim l must not enter search text");
|
||||
assert!(state.query().is_empty(), "vim l must not enter search text");
|
||||
|
||||
let expanded = std::collections::HashSet::from([key_id]);
|
||||
let collapse = handle_input(
|
||||
|
|
@ -3341,7 +3361,7 @@ mod tests {
|
|||
ShortcutsHelpOutcome::ToggleExpand(key_id),
|
||||
"vim h must collapse the expanded paste pseudo-row"
|
||||
);
|
||||
assert!(state.query.is_empty(), "vim h must not enter search text");
|
||||
assert!(state.query().is_empty(), "vim h must not enter search text");
|
||||
}
|
||||
|
||||
/// `handle_modal_key` (chrome + picker pipeline) maps the hint-row expand to
|
||||
|
|
|
|||
|
|
@ -221,6 +221,10 @@ impl SubagentCatalogPane {
|
|||
self.list_state.handle_key_event(key, &self.entries)
|
||||
}
|
||||
|
||||
pub fn handle_paste(&mut self, text: &str) -> bool {
|
||||
self.list_state.handle_paste(text, &self.entries)
|
||||
}
|
||||
|
||||
pub fn handle_scroll(&mut self, lines: i32, col: u16, row: u16) {
|
||||
let max = match self.list_state.viewport_height() {
|
||||
0..=5 => 1,
|
||||
|
|
|
|||
|
|
@ -1085,6 +1085,10 @@ impl TasksPane {
|
|||
self.list_state.handle_key_event(key, &self.entries)
|
||||
}
|
||||
|
||||
pub fn handle_paste(&mut self, text: &str) -> bool {
|
||||
self.list_state.handle_paste(text, &self.entries)
|
||||
}
|
||||
|
||||
pub fn handle_scroll(&mut self, lines: i32, col: u16, row: u16) {
|
||||
let max = match self.list_state.viewport_height() {
|
||||
0..=5 => 1,
|
||||
|
|
|
|||
|
|
@ -433,6 +433,10 @@ impl TodoPane {
|
|||
self.list_state.handle_key_event(key, &self.entries)
|
||||
}
|
||||
|
||||
pub fn handle_paste(&mut self, text: &str) -> bool {
|
||||
self.list_state.handle_paste(text, &self.entries)
|
||||
}
|
||||
|
||||
/// Handle a mouse scroll event over the todo pane area.
|
||||
///
|
||||
/// Caps scroll speed for small viewports — the app-level scroll
|
||||
|
|
|
|||
|
|
@ -91,48 +91,54 @@ pub struct MouseButtons {
|
|||
}
|
||||
|
||||
/// Counts of idle-surviving "watcher" work — background jobs that can wake
|
||||
/// the agent for a new turn while it sits idle. Running `monitor` tasks emit
|
||||
/// events, scheduled `/loop` tasks fire prompts on a timer, and running
|
||||
/// background subagents inject a `subagent-completed-…` turn when they finish
|
||||
/// — each can start a new turn, so they share one persistent "watching" cue
|
||||
/// above the prompt. Broader than the tasks-pane `Watchers` group (which is
|
||||
/// monitors + loops only); subagents are included here because they too
|
||||
/// auto-wake the agent.
|
||||
/// the agent for a new turn while it sits idle (commands and monitors on
|
||||
/// completion/events, `/loop` tasks on a timer, background subagents on
|
||||
/// finish). They share one persistent "watching" cue above the prompt.
|
||||
/// Broader than the tasks-pane `Watchers` group (monitors + loops only).
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Watchers {
|
||||
/// Running background commands (non-monitor `background: true` tasks).
|
||||
pub commands: usize,
|
||||
/// Running `monitor` background tasks.
|
||||
pub monitors: usize,
|
||||
/// Active scheduled `/loop` tasks.
|
||||
pub loops: usize,
|
||||
/// Running background subagents (they auto-wake the parent on completion).
|
||||
/// While the agent is idle, any running subagent is a background one — a
|
||||
/// foreground subagent would keep the parent in `TurnRunning`.
|
||||
/// Running background subagents. While the agent is idle, any running
|
||||
/// subagent is a background one — a foreground subagent would keep the
|
||||
/// parent in `TurnRunning`.
|
||||
pub subagents: usize,
|
||||
}
|
||||
|
||||
impl Watchers {
|
||||
/// Total watcher count across all kinds.
|
||||
pub fn total(self) -> usize {
|
||||
self.monitors + self.loops + self.subagents
|
||||
self.commands + self.monitors + self.loops + self.subagents
|
||||
}
|
||||
|
||||
/// Awaitable in-flight work — the kinds a blocking `wait_tasks` /
|
||||
/// `get_task_output` wait can resolve on (commands, monitors, subagents;
|
||||
/// scheduled `/loop` tasks are timers, not awaitable work).
|
||||
pub fn awaitable_work(self) -> usize {
|
||||
self.commands + self.monitors + self.subagents
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the "watching · …" label for the idle watcher cue, listing only the
|
||||
/// non-zero kinds with correct singular/plural nouns — e.g.
|
||||
/// `"watching · 2 monitors · 1 loop · 1 subagent"`. Assumes
|
||||
/// `"watching · 1 command · 2 monitors · 1 loop · 1 subagent"`. Assumes
|
||||
/// `watchers.total() > 0`.
|
||||
///
|
||||
/// Every scheduled task can wake the agent, so all of them are counted as
|
||||
/// `loops` (today every scheduled task is `/loop`-tagged; see
|
||||
/// `ScheduledTaskInfo`). The label is built in a single `String` — no
|
||||
/// intermediate `Vec`/`join` — because the turn-status line re-renders every
|
||||
/// frame, keeping the idle cue's churn to one allocation (cf. the
|
||||
/// static-`&str` right-side arms below).
|
||||
fn watching_label(watchers: Watchers) -> String {
|
||||
use std::fmt::Write as _;
|
||||
// "watching" stem, then " · N noun" appended for each non-zero kind.
|
||||
let mut label = String::with_capacity(32);
|
||||
label.push_str("watching");
|
||||
if watchers.commands > 0 {
|
||||
let noun = if watchers.commands == 1 {
|
||||
"command"
|
||||
} else {
|
||||
"commands"
|
||||
};
|
||||
let _ = write!(label, " \u{00b7} {} {noun}", watchers.commands);
|
||||
}
|
||||
if watchers.monitors > 0 {
|
||||
let noun = if watchers.monitors == 1 {
|
||||
"monitor"
|
||||
|
|
@ -191,6 +197,10 @@ pub fn is_sendable_wait(activity: &Option<TurnActivity>) -> bool {
|
|||
/// `[stop]` / `[↓]` buttons with their hover state; `None` for a keyboard-only
|
||||
/// host (minimal mode — no mouse capture), which suppresses both buttons.
|
||||
/// - `total_tokens`: Total tokens used (context window usage), shown as `⇣Nk`.
|
||||
/// - `parked`: the turn is parked on a sendable wait and renders the stopped
|
||||
/// look (`AgentView::renders_parked`). The running-turn chrome is suppressed;
|
||||
/// only the "watching · …" cue renders (the parked turn is by definition
|
||||
/// waiting on background work, so the cue explains the idle-looking chrome).
|
||||
/// - `flat_background`: when `true`, right-side timer/buttons use a transparent
|
||||
/// (`Color::Reset`) background instead of `theme.bg_base`, so the row blends
|
||||
/// with the terminal's own background (minimal mode).
|
||||
|
|
@ -215,6 +225,7 @@ pub fn render_turn_status(
|
|||
is_pending_user_input: bool,
|
||||
goal_verifying: bool,
|
||||
watchers: Watchers,
|
||||
parked: bool,
|
||||
flat_background: bool,
|
||||
held_queue: usize,
|
||||
held_queue_top_sendable: bool,
|
||||
|
|
@ -263,14 +274,10 @@ pub fn render_turn_status(
|
|||
return TurnStatusOutput::default();
|
||||
}
|
||||
|
||||
// Special case: agent idle but background watchers (monitors, scheduled
|
||||
// `/loop` tasks, and/or running background subagents) are still alive.
|
||||
// Monitors emit events, loops fire prompts on a timer, and subagents
|
||||
// inject a completion turn on finish — any can wake the agent for another
|
||||
// turn — so keep a persistent cue above the prompt (unlike a scrollback
|
||||
// line, it never scrolls away). Lower priority than the starting-session
|
||||
// and drain-blocked cues handled above.
|
||||
if state.is_idle() && watchers.total() > 0 {
|
||||
// Idle or parked with watchers: persistent watching cue (not scrollback
|
||||
// — it must never scroll away). Lower priority than the starting-session
|
||||
// and drain-blocked cues above.
|
||||
if (state.is_idle() || parked) && watchers.total() > 0 {
|
||||
// Pulsing concentric circle (○ ◎ ◉ ◎) on a calm ambient cadence:
|
||||
// the agent is idle, so this "watching" breath runs slower than the
|
||||
// active turn spinner (see MONITOR_PULSE_DIVISOR).
|
||||
|
|
@ -287,6 +294,13 @@ pub fn render_turn_status(
|
|||
return TurnStatusOutput::default();
|
||||
}
|
||||
|
||||
// Parked with no watchers left: render nothing. The stopped look must
|
||||
// never fall through to the running-turn chrome (spinner/timers/[stop])
|
||||
// — the wait aborts the moment the user types, so that chrome would lie.
|
||||
if parked {
|
||||
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
|
||||
|
|
@ -738,9 +752,13 @@ fn render_starting_session(
|
|||
/// is blocked (agent idle, waiting on user edit), while the MCP startup seed
|
||||
/// is showing "Starting session…" (a fresh `total == 0` seed), or when the
|
||||
/// agent is idle but background watchers are still running
|
||||
/// (`watchers.total() > 0`) — running monitors emit events, scheduled `/loop`
|
||||
/// tasks fire prompts, and background subagents inject a completion turn, any
|
||||
/// of which can start a new turn.
|
||||
/// (`watchers.total() > 0`) — running commands and monitors wake the agent on
|
||||
/// completion/events, scheduled `/loop` tasks fire prompts, and background
|
||||
/// subagents inject a completion turn, any of which can start a new turn.
|
||||
///
|
||||
/// A parked turn (`parked` — the stopped look while blocked on a sendable
|
||||
/// wait) suppresses the running-turn chrome entirely: the row shows only when
|
||||
/// watchers exist, rendering the "watching · …" cue.
|
||||
///
|
||||
/// Real MCP progress (`total > 0`) renders as a compact chip in the top status
|
||||
/// bar instead, so it does not affect this row.
|
||||
|
|
@ -749,7 +767,11 @@ pub fn should_show(
|
|||
drain_blocked: bool,
|
||||
mcp_init_progress: Option<&McpInitProgress>,
|
||||
watchers: Watchers,
|
||||
parked: bool,
|
||||
) -> bool {
|
||||
if parked {
|
||||
return watchers.total() > 0;
|
||||
}
|
||||
!state.is_idle()
|
||||
|| drain_blocked
|
||||
|| starting_session_visible(mcp_init_progress)
|
||||
|
|
@ -949,19 +971,22 @@ mod tests {
|
|||
&AgentState::TurnRunning,
|
||||
false,
|
||||
None,
|
||||
Watchers::default()
|
||||
Watchers::default(),
|
||||
false
|
||||
));
|
||||
assert!(should_show(
|
||||
&AgentState::TurnCancelling,
|
||||
false,
|
||||
None,
|
||||
Watchers::default()
|
||||
Watchers::default(),
|
||||
false
|
||||
));
|
||||
assert!(!should_show(
|
||||
&AgentState::Idle,
|
||||
false,
|
||||
None,
|
||||
Watchers::default()
|
||||
Watchers::default(),
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -971,47 +996,66 @@ mod tests {
|
|||
&AgentState::Idle,
|
||||
true,
|
||||
None,
|
||||
Watchers::default()
|
||||
Watchers::default(),
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_show_when_watchers_running() {
|
||||
// Idle but a watcher (monitor, loop, or subagent) is still running →
|
||||
// row stays visible so the persistent "watching · …" cue can show.
|
||||
assert!(should_show(
|
||||
&AgentState::Idle,
|
||||
false,
|
||||
None,
|
||||
// Idle but a watcher (command, monitor, loop, or subagent) is still
|
||||
// running → row stays visible so the persistent "watching · …" cue
|
||||
// can show.
|
||||
for watchers in [
|
||||
Watchers {
|
||||
commands: 1,
|
||||
..Watchers::default()
|
||||
},
|
||||
Watchers {
|
||||
monitors: 1,
|
||||
..Watchers::default()
|
||||
}
|
||||
));
|
||||
assert!(should_show(
|
||||
&AgentState::Idle,
|
||||
false,
|
||||
None,
|
||||
},
|
||||
Watchers {
|
||||
loops: 1,
|
||||
..Watchers::default()
|
||||
}
|
||||
));
|
||||
assert!(should_show(
|
||||
&AgentState::Idle,
|
||||
false,
|
||||
None,
|
||||
},
|
||||
Watchers {
|
||||
subagents: 1,
|
||||
..Watchers::default()
|
||||
}
|
||||
));
|
||||
},
|
||||
] {
|
||||
assert!(should_show(&AgentState::Idle, false, None, watchers, false));
|
||||
}
|
||||
// Idle with no watchers and nothing else pending → hidden.
|
||||
assert!(!should_show(
|
||||
&AgentState::Idle,
|
||||
false,
|
||||
None,
|
||||
Watchers::default()
|
||||
Watchers::default(),
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_show_parked_only_with_watchers() {
|
||||
// Parked (turn running but rendering the stopped look): the row shows
|
||||
// only to carry the "watching · …" cue — never the running chrome.
|
||||
assert!(should_show(
|
||||
&AgentState::TurnRunning,
|
||||
false,
|
||||
None,
|
||||
Watchers {
|
||||
commands: 1,
|
||||
..Watchers::default()
|
||||
},
|
||||
true
|
||||
));
|
||||
assert!(!should_show(
|
||||
&AgentState::TurnRunning,
|
||||
false,
|
||||
None,
|
||||
Watchers::default(),
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -1027,7 +1071,8 @@ mod tests {
|
|||
&AgentState::Idle,
|
||||
false,
|
||||
Some(&seed),
|
||||
Watchers::default()
|
||||
Watchers::default(),
|
||||
false
|
||||
));
|
||||
|
||||
// Real progress (total > 0) is the top-bar chip — it must NOT drive
|
||||
|
|
@ -1041,7 +1086,8 @@ mod tests {
|
|||
&AgentState::Idle,
|
||||
false,
|
||||
Some(&connecting),
|
||||
Watchers::default()
|
||||
Watchers::default(),
|
||||
false
|
||||
));
|
||||
|
||||
// An expired seed must not drive the row either.
|
||||
|
|
@ -1054,7 +1100,8 @@ mod tests {
|
|||
&AgentState::Idle,
|
||||
false,
|
||||
Some(&expired),
|
||||
Watchers::default()
|
||||
Watchers::default(),
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -1092,6 +1139,7 @@ mod tests {
|
|||
false,
|
||||
Watchers::default(),
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
|
|
@ -1121,6 +1169,37 @@ mod tests {
|
|||
false,
|
||||
watchers,
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
buffer_text(&buf, area)
|
||||
}
|
||||
|
||||
/// Invoke `render_turn_status` for a PARKED running turn (the stopped
|
||||
/// look) with the given watcher counts.
|
||||
fn render_parked_with_watchers(watchers: Watchers) -> String {
|
||||
let area = Rect::new(0, 0, 60, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_turn_status(
|
||||
&mut buf,
|
||||
area,
|
||||
&AgentState::TurnRunning,
|
||||
&Some(TurnActivity::Waiting(WaitingReason::TasksComplete)),
|
||||
Some(Duration::from_secs(5)),
|
||||
None,
|
||||
0,
|
||||
false,
|
||||
Some(MouseButtons::default()),
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
watchers,
|
||||
true,
|
||||
false,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
|
|
@ -1243,19 +1322,72 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn idle_with_all_watcher_kinds_lists_all() {
|
||||
// Monitors, loops, and subagents present → one cue lists all three in
|
||||
// order (monitors → loops → subagents), middle-dot separated.
|
||||
// Commands, monitors, loops, and subagents present → one cue lists
|
||||
// all four in order, middle-dot separated.
|
||||
let text = render_idle_with_watchers(Watchers {
|
||||
commands: 1,
|
||||
monitors: 2,
|
||||
loops: 1,
|
||||
subagents: 3,
|
||||
});
|
||||
assert!(
|
||||
text.contains("watching \u{00b7} 2 monitors \u{00b7} 1 loop \u{00b7} 3 subagents"),
|
||||
text.contains(
|
||||
"watching \u{00b7} 1 command \u{00b7} 2 monitors \u{00b7} 1 loop \u{00b7} 3 subagents"
|
||||
),
|
||||
"all kinds must be listed in one cue, got: {text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_with_commands_renders_watching_line() {
|
||||
// Plain background commands (non-monitor bg tasks) count as watchers:
|
||||
// they wake the agent with a task-completed turn, so the cue must show.
|
||||
let text = render_idle_with_watchers(Watchers {
|
||||
commands: 2,
|
||||
..Watchers::default()
|
||||
});
|
||||
assert!(
|
||||
text.contains("watching \u{00b7} 2 commands"),
|
||||
"idle with bg commands must render the watching cue, got: {text:?}"
|
||||
);
|
||||
let text = render_idle_with_watchers(Watchers {
|
||||
commands: 1,
|
||||
..Watchers::default()
|
||||
});
|
||||
assert!(
|
||||
text.contains("watching \u{00b7} 1 command") && !text.contains("commands"),
|
||||
"single command must use the singular noun, got: {text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parked_with_watchers_renders_watching_not_running_chrome() {
|
||||
// A parked running turn renders the watching cue — never the busy
|
||||
// spinner/timers/[stop] chrome (the wait aborts as soon as the user
|
||||
// types, so that chrome would lie).
|
||||
let text = render_parked_with_watchers(Watchers {
|
||||
commands: 2,
|
||||
..Watchers::default()
|
||||
});
|
||||
assert!(
|
||||
text.contains("watching \u{00b7} 2 commands"),
|
||||
"parked with bg work must render the watching cue, got: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("Waiting") && !text.contains("[stop]"),
|
||||
"parked must not render the running-turn chrome, got: {text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parked_without_watchers_renders_nothing() {
|
||||
let text = render_parked_with_watchers(Watchers::default());
|
||||
assert!(
|
||||
text.trim().is_empty(),
|
||||
"parked with no watchers must render nothing, got: {text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_with_no_watchers_renders_nothing() {
|
||||
let text = render_idle_with_watchers(Watchers::default());
|
||||
|
|
@ -1287,6 +1419,7 @@ mod tests {
|
|||
false,
|
||||
Watchers::default(),
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
true,
|
||||
);
|
||||
|
|
@ -1299,6 +1432,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn watching_label_lists_only_nonzero_kinds() {
|
||||
assert_eq!(
|
||||
watching_label(Watchers {
|
||||
commands: 2,
|
||||
..Watchers::default()
|
||||
}),
|
||||
"watching \u{00b7} 2 commands"
|
||||
);
|
||||
assert_eq!(
|
||||
watching_label(Watchers {
|
||||
monitors: 2,
|
||||
|
|
@ -1330,11 +1470,12 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
watching_label(Watchers {
|
||||
commands: 1,
|
||||
monitors: 1,
|
||||
loops: 1,
|
||||
subagents: 2,
|
||||
}),
|
||||
"watching \u{00b7} 1 monitor \u{00b7} 1 loop \u{00b7} 2 subagents"
|
||||
"watching \u{00b7} 1 command \u{00b7} 1 monitor \u{00b7} 1 loop \u{00b7} 2 subagents"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ use ratatui::layout::{Alignment, Constraint, Flex, Layout, Position, Rect};
|
|||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::app::app_view::{AuthMode, AuthState, SessionPickerEntry, TrustState};
|
||||
use crate::startup::StartupWarning;
|
||||
|
|
@ -589,7 +591,8 @@ pub struct WelcomeRenderParams<'a> {
|
|||
pub trust_state: &'a TrustState,
|
||||
pub login_label: Option<&'a str>,
|
||||
pub auth_code_input: &'a str,
|
||||
pub clipboard_copied: bool,
|
||||
pub auth_code_cursor_byte: usize,
|
||||
pub clipboard_delivery: Option<crate::clipboard::ClipboardDelivery>,
|
||||
pub show_raw_url: bool,
|
||||
pub announcement: Option<&'a xai_grok_announcements::RemoteAnnouncement>,
|
||||
pub tip: Option<&'a str>,
|
||||
|
|
@ -733,7 +736,8 @@ pub fn render_welcome(
|
|||
auth_url.as_deref(),
|
||||
*mode,
|
||||
params.auth_code_input,
|
||||
params.clipboard_copied,
|
||||
params.auth_code_cursor_byte,
|
||||
params.clipboard_delivery,
|
||||
params.show_raw_url,
|
||||
);
|
||||
WelcomeRenderResult {
|
||||
|
|
@ -1062,18 +1066,30 @@ fn auth_fallback_line(theme: &Theme) -> Line<'static> {
|
|||
.alignment(Alignment::Center)
|
||||
}
|
||||
|
||||
/// Push the shared copy-prompt block: the "click here to copy" line, a "copied!"
|
||||
/// slot (kept blank when not copied so the height is stable), and the
|
||||
/// show-full-URL fallback link.
|
||||
fn push_auth_copy_block(lines: &mut Vec<Line<'static>>, theme: &Theme, clipboard_copied: bool) {
|
||||
/// Push the shared copy-prompt block, stable feedback slot, and raw-URL fallback.
|
||||
fn push_auth_copy_block(
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
theme: &Theme,
|
||||
clipboard_delivery: Option<crate::clipboard::ClipboardDelivery>,
|
||||
) {
|
||||
lines.push(Line::default());
|
||||
lines.push(auth_copy_line(theme));
|
||||
lines.push(Line::default());
|
||||
lines.push(if clipboard_copied {
|
||||
Line::from(Span::styled("copied!", Style::default().fg(theme.gray)))
|
||||
.alignment(Alignment::Center)
|
||||
} else {
|
||||
Line::default()
|
||||
lines.push(match clipboard_delivery {
|
||||
Some(crate::clipboard::ClipboardDelivery::Confirmed) => {
|
||||
Line::from(Span::styled("copied!", Style::default().fg(theme.gray)))
|
||||
.alignment(Alignment::Center)
|
||||
}
|
||||
Some(crate::clipboard::ClipboardDelivery::Unverified) => Line::from(Span::styled(
|
||||
"copy sent—verify paste",
|
||||
Style::default().fg(theme.gray),
|
||||
))
|
||||
.alignment(Alignment::Center),
|
||||
Some(crate::clipboard::ClipboardDelivery::Failed) => {
|
||||
Line::from(Span::styled("copy failed", Style::default().fg(theme.gray)))
|
||||
.alignment(Alignment::Center)
|
||||
}
|
||||
None => Line::default(),
|
||||
});
|
||||
lines.push(Line::default());
|
||||
lines.push(auth_fallback_line(theme));
|
||||
|
|
@ -1231,7 +1247,7 @@ fn render_browser_status_arm(
|
|||
logo_line_count: u16,
|
||||
auth_url: Option<&str>,
|
||||
show_raw_url: bool,
|
||||
clipboard_copied: bool,
|
||||
clipboard_delivery: Option<crate::clipboard::ClipboardDelivery>,
|
||||
kind: BrowserStatusKind,
|
||||
) -> (Option<Rect>, Option<Rect>) {
|
||||
let h_pad: u16 = content_area.width / 6;
|
||||
|
|
@ -1303,7 +1319,7 @@ fn render_browser_status_arm(
|
|||
);
|
||||
}
|
||||
if auth_url.is_some() {
|
||||
push_auth_copy_block(&mut lines, theme, clipboard_copied);
|
||||
push_auth_copy_block(&mut lines, theme, clipboard_delivery);
|
||||
}
|
||||
lines.push(Line::default());
|
||||
lines.push(
|
||||
|
|
@ -1337,7 +1353,8 @@ fn render_welcome_authenticating(
|
|||
auth_url: Option<&str>,
|
||||
mode: AuthMode,
|
||||
auth_code_input: &str,
|
||||
clipboard_copied: bool,
|
||||
auth_code_cursor_byte: usize,
|
||||
clipboard_delivery: Option<crate::clipboard::ClipboardDelivery>,
|
||||
show_raw_url: bool,
|
||||
) -> (Option<Rect>, Option<Rect>) {
|
||||
let top_pad = content_area.height.saturating_sub(logo_line_count) / 10;
|
||||
|
|
@ -1390,7 +1407,7 @@ fn render_welcome_authenticating(
|
|||
))
|
||||
.alignment(Alignment::Center),
|
||||
);
|
||||
push_auth_copy_block(&mut lines, theme, clipboard_copied);
|
||||
push_auth_copy_block(&mut lines, theme, clipboard_delivery);
|
||||
} else {
|
||||
lines.push(
|
||||
Line::from(Span::styled(
|
||||
|
|
@ -1420,7 +1437,13 @@ fn render_welcome_authenticating(
|
|||
])
|
||||
.flex(Flex::Center)
|
||||
.areas(prompt_area);
|
||||
render_auth_input_box(prompt_centered, buf, theme, auth_code_input);
|
||||
render_auth_input_box(
|
||||
prompt_centered,
|
||||
buf,
|
||||
theme,
|
||||
auth_code_input,
|
||||
auth_code_cursor_byte,
|
||||
);
|
||||
|
||||
// Hints
|
||||
let mut hint_spans = vec![
|
||||
|
|
@ -1447,7 +1470,7 @@ fn render_welcome_authenticating(
|
|||
logo_line_count,
|
||||
auth_url,
|
||||
show_raw_url,
|
||||
clipboard_copied,
|
||||
clipboard_delivery,
|
||||
BrowserStatusKind::Command,
|
||||
),
|
||||
|
||||
|
|
@ -1459,7 +1482,7 @@ fn render_welcome_authenticating(
|
|||
logo_line_count,
|
||||
auth_url,
|
||||
show_raw_url,
|
||||
clipboard_copied,
|
||||
clipboard_delivery,
|
||||
BrowserStatusKind::Device,
|
||||
),
|
||||
|
||||
|
|
@ -2188,7 +2211,7 @@ pub(crate) fn render_session_picker(
|
|||
// this render disagrees with `handle_welcome_input`'s `build_entry_map`
|
||||
// (which receives the effective query) on row indices.
|
||||
let filter_query =
|
||||
crate::views::session_picker::effective_filter_query(&ctx.state.query, ctx.entries_query);
|
||||
crate::views::session_picker::effective_filter_query(ctx.state.query(), ctx.entries_query);
|
||||
let filtered_indices =
|
||||
crate::app::app_view::filter_session_entries(ctx.sessions, filter_query, ctx.source_filter);
|
||||
|
||||
|
|
@ -2392,7 +2415,13 @@ pub(crate) fn render_session_picker(
|
|||
}
|
||||
|
||||
/// Render the auth token input box (loopback mode).
|
||||
fn render_auth_input_box(area: Rect, buf: &mut Buffer, theme: &Theme, input: &str) {
|
||||
fn render_auth_input_box(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
theme: &Theme,
|
||||
input: &str,
|
||||
cursor_byte: usize,
|
||||
) {
|
||||
let prompt_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.accent_user))
|
||||
|
|
@ -2406,7 +2435,11 @@ fn render_auth_input_box(area: Rect, buf: &mut Buffer, theme: &Theme, input: &st
|
|||
prompt_block.render(area, buf);
|
||||
|
||||
if inner.height > 0 && inner.width > 2 {
|
||||
let display = mask_auth_token_for_display(input);
|
||||
let prompt = crate::glyphs::prompt_arrow();
|
||||
let prompt_width = prompt.width() as u16;
|
||||
let input_width = inner.width.saturating_sub(prompt_width);
|
||||
let (display, cursor_column) =
|
||||
masked_auth_token_view(input, cursor_byte, input_width as usize);
|
||||
|
||||
let style = if input.is_empty() {
|
||||
Style::default().fg(theme.gray_dim)
|
||||
|
|
@ -2415,13 +2448,16 @@ fn render_auth_input_box(area: Rect, buf: &mut Buffer, theme: &Theme, input: &st
|
|||
};
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(
|
||||
crate::glyphs::prompt_arrow(),
|
||||
Style::default().fg(theme.accent_user),
|
||||
),
|
||||
Span::styled(prompt, Style::default().fg(theme.accent_user)),
|
||||
Span::styled(display, style),
|
||||
]);
|
||||
buf.set_line(inner.x, inner.y, &line, inner.width);
|
||||
if input_width > 0 {
|
||||
let cursor_x = inner.x + prompt_width + cursor_column as u16;
|
||||
if let Some(cell) = buf.cell_mut((cursor_x, inner.y)) {
|
||||
cell.set_style(Style::default().fg(theme.bg_base).bg(theme.text_primary));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2471,20 +2507,48 @@ fn render_startup_warnings(
|
|||
None
|
||||
}
|
||||
|
||||
fn mask_auth_token_for_display(input: &str) -> String {
|
||||
use crate::render::line_utils::floor_char_boundary;
|
||||
fn auth_token_grapheme_visible(index: usize, total: usize) -> bool {
|
||||
total <= 8 || index + 4 >= total
|
||||
}
|
||||
|
||||
struct MaskedAuthToken {
|
||||
display: String,
|
||||
cursor_byte: usize,
|
||||
}
|
||||
|
||||
fn build_masked_auth_token(input: &str, cursor_byte: usize) -> MaskedAuthToken {
|
||||
let graphemes: Vec<(usize, &str)> = input.grapheme_indices(true).collect();
|
||||
let total = graphemes.len();
|
||||
let mut display = String::new();
|
||||
let mut mapped_cursor = None;
|
||||
for (index, (byte, grapheme)) in graphemes.into_iter().enumerate() {
|
||||
if byte == cursor_byte {
|
||||
mapped_cursor = Some(display.len());
|
||||
}
|
||||
if auth_token_grapheme_visible(index, total) {
|
||||
display.push_str(grapheme);
|
||||
} else {
|
||||
display.push('\u{2022}');
|
||||
}
|
||||
}
|
||||
MaskedAuthToken {
|
||||
cursor_byte: mapped_cursor.unwrap_or(display.len()),
|
||||
display,
|
||||
}
|
||||
}
|
||||
|
||||
fn masked_auth_token_view(input: &str, cursor_byte: usize, width: usize) -> (String, usize) {
|
||||
if input.is_empty() {
|
||||
return "Paste your token here...".to_string();
|
||||
return ("Paste your token here...".to_string(), 0);
|
||||
}
|
||||
let len = input.len();
|
||||
if len <= 8 {
|
||||
return input.to_string();
|
||||
}
|
||||
let boundary = floor_char_boundary(input, len - 4);
|
||||
let visible = &input[boundary..];
|
||||
let masked_count = input[..boundary].chars().count();
|
||||
format!("{}{}", "\u{2022}".repeat(masked_count), visible)
|
||||
let masked = build_masked_auth_token(input, cursor_byte);
|
||||
let buffer =
|
||||
xai_ratatui_textarea::EditBuffer::from_parts(masked.display.as_str(), masked.cursor_byte);
|
||||
let viewport = buffer.single_line_viewport(width);
|
||||
(
|
||||
masked.display[viewport.visible_byte_range].to_owned(),
|
||||
viewport.cursor_display_column,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -2495,17 +2559,97 @@ mod tests {
|
|||
use crate::views::session_picker::{build_grouped_picker_entries, build_session_entry_data};
|
||||
|
||||
#[test]
|
||||
fn mask_auth_token_cases() {
|
||||
assert_eq!(mask_auth_token_for_display(""), "Paste your token here...");
|
||||
assert_eq!(mask_auth_token_for_display("12345678"), "12345678");
|
||||
fn auth_copy_feedback_covers_delivery_states() {
|
||||
let theme = Theme::current();
|
||||
for (delivery, expected) in [
|
||||
(crate::clipboard::ClipboardDelivery::Confirmed, "copied!"),
|
||||
(
|
||||
crate::clipboard::ClipboardDelivery::Unverified,
|
||||
"copy sent—verify paste",
|
||||
),
|
||||
(crate::clipboard::ClipboardDelivery::Failed, "copy failed"),
|
||||
] {
|
||||
let mut lines = Vec::new();
|
||||
push_auth_copy_block(&mut lines, &theme, Some(delivery));
|
||||
let feedback = lines[3]
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>();
|
||||
assert_eq!(feedback, expected);
|
||||
}
|
||||
}
|
||||
|
||||
let masked = mask_auth_token_for_display("abcdefghij");
|
||||
assert!(masked.ends_with("ghij"));
|
||||
assert!(masked.starts_with("\u{2022}"));
|
||||
#[test]
|
||||
fn masked_auth_token_preserves_reveal_policy() {
|
||||
assert_eq!(
|
||||
masked_auth_token_view("", 0, 24),
|
||||
("Paste your token here...".to_string(), 0)
|
||||
);
|
||||
assert_eq!(build_masked_auth_token("12345678", 8).display, "12345678");
|
||||
assert_eq!(build_masked_auth_token("123456789", 9).display, "•••••6789");
|
||||
|
||||
// Regression: multi-byte input panicked on byte-index slicing
|
||||
let masked = mask_auth_token_for_display("测试令牌一二三四五六");
|
||||
assert!(masked.starts_with("\u{2022}"));
|
||||
let input = "abcdefghMIDDLEwxyz";
|
||||
let masked = build_masked_auth_token(input, input.len()).display;
|
||||
assert!(masked.starts_with("••••"));
|
||||
assert!(masked.ends_with("wxyz"));
|
||||
assert!(!masked.contains("MIDDLE"));
|
||||
assert!(masked.contains("\u{2022}"));
|
||||
|
||||
let input = "测试令牌一二三四五六七八九十";
|
||||
let masked = build_masked_auth_token(input, input.len()).display;
|
||||
assert!(masked.starts_with("••••"));
|
||||
assert!(masked.contains("\u{2022}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_auth_mapping_handles_zero_width_combining_and_zwj_middle() {
|
||||
let prefix = "abcdefgh";
|
||||
let hidden = "\u{200b}e\u{301}👩🏽\u{200d}💻MID";
|
||||
let suffix = "wxyz";
|
||||
let token = format!("{prefix}{hidden}{suffix}");
|
||||
let before = prefix.len();
|
||||
let inside = prefix.len() + "\u{200b}e\u{301}".len();
|
||||
let after = prefix.len() + hidden.len();
|
||||
let expected = format!("{}{}", "\u{2022}".repeat(14), suffix);
|
||||
|
||||
let before_masked = build_masked_auth_token(&token, before);
|
||||
let inside_masked = build_masked_auth_token(&token, inside);
|
||||
let after_masked = build_masked_auth_token(&token, after);
|
||||
assert_eq!(before_masked.display, expected);
|
||||
assert_eq!(inside_masked.display, expected);
|
||||
assert_eq!(after_masked.display, expected);
|
||||
assert_eq!(before_masked.cursor_byte, "\u{2022}".len() * 8);
|
||||
assert_eq!(inside_masked.cursor_byte, "\u{2022}".len() * 10);
|
||||
assert_eq!(after_masked.cursor_byte, "\u{2022}".len() * 14);
|
||||
|
||||
for width in [1, 2, 5] {
|
||||
for cursor in [before, inside, after] {
|
||||
let (view, cursor_column) = masked_auth_token_view(&token, cursor, width);
|
||||
assert!(view.width() <= width);
|
||||
assert!(cursor_column < width);
|
||||
assert!(!view.contains('\u{200b}'));
|
||||
assert!(!view.contains("e\u{301}"));
|
||||
assert!(!view.contains("👩🏽\u{200d}💻"));
|
||||
assert!(!view.contains("MID"));
|
||||
}
|
||||
}
|
||||
|
||||
let wide_prefix = "中bcdefgh";
|
||||
let wide_token = format!("{wide_prefix}HIDDEN{suffix}");
|
||||
let (_, cursor_column) = masked_auth_token_view(&wide_token, wide_prefix.len(), 40);
|
||||
assert_eq!(cursor_column, wide_prefix.graphemes(true).count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_auth_render_keeps_narrow_caret_visible() {
|
||||
let token = "abcdefghSECRET-MIDDLEwxyz";
|
||||
let cursor = "abcdefghSECRET".len();
|
||||
let area = Rect::new(0, 0, 9, 3);
|
||||
let theme = Theme::current();
|
||||
let mut buffer = Buffer::empty(area);
|
||||
render_auth_input_box(area, &mut buffer, &theme, token, cursor);
|
||||
assert!((0..area.width).any(|x| buffer[(x, 1)].bg == theme.text_primary));
|
||||
}
|
||||
|
||||
fn make_entry(id: &str, summary: &str, repo_name: &str) -> SessionPickerEntry {
|
||||
|
|
@ -2538,7 +2682,8 @@ mod tests {
|
|||
trust_state,
|
||||
login_label: None,
|
||||
auth_code_input: "",
|
||||
clipboard_copied: false,
|
||||
auth_code_cursor_byte: 0,
|
||||
clipboard_delivery: None,
|
||||
show_raw_url: false,
|
||||
announcement: None,
|
||||
tip: None,
|
||||
|
|
@ -2721,10 +2866,8 @@ mod tests {
|
|||
|
||||
let render = |entries_query: Option<&str>| -> String {
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut state = PickerState {
|
||||
query: "hit".into(),
|
||||
..PickerState::default()
|
||||
};
|
||||
let mut state = PickerState::default();
|
||||
state.set_query("hit");
|
||||
render_session_picker(
|
||||
area,
|
||||
&mut buf,
|
||||
|
|
@ -2944,15 +3087,12 @@ mod tests {
|
|||
use crate::views::picker::{PickerOutcome, handle_picker_input};
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
let mut state = PickerState {
|
||||
search_active: true,
|
||||
..PickerState::default()
|
||||
};
|
||||
let mut state = PickerState::input_active();
|
||||
let config = resume_picker_config();
|
||||
let ev = Event::Key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
|
||||
let outcome = handle_picker_input(&ev, &mut state, 3, &config);
|
||||
assert!(matches!(outcome, PickerOutcome::Changed));
|
||||
assert_eq!(state.query, "e");
|
||||
assert!(matches!(outcome, PickerOutcome::QueryChanged));
|
||||
assert_eq!(state.query(), "e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -3466,8 +3606,9 @@ mod tests {
|
|||
logo_line_count(area.height),
|
||||
Some(url),
|
||||
AuthMode::Device,
|
||||
"", // auth_code_input — unused in device mode
|
||||
false, // clipboard_copied
|
||||
"", // auth_code_input — unused in device mode
|
||||
0,
|
||||
None, // clipboard_delivery
|
||||
false, // show_raw_url
|
||||
);
|
||||
|
||||
|
|
@ -3521,7 +3662,8 @@ mod tests {
|
|||
Some(url),
|
||||
AuthMode::Device,
|
||||
"",
|
||||
false,
|
||||
0,
|
||||
None,
|
||||
true, // show_raw_url
|
||||
);
|
||||
|
||||
|
|
@ -3547,7 +3689,8 @@ mod tests {
|
|||
Some(url),
|
||||
AuthMode::Device,
|
||||
"",
|
||||
false,
|
||||
0,
|
||||
None,
|
||||
true, // show_raw_url
|
||||
);
|
||||
|
||||
|
|
@ -3584,7 +3727,8 @@ mod tests {
|
|||
Some(url),
|
||||
AuthMode::Device,
|
||||
"",
|
||||
false,
|
||||
0,
|
||||
None,
|
||||
true, // show_raw_url
|
||||
);
|
||||
|
||||
|
|
@ -3622,8 +3766,9 @@ mod tests {
|
|||
logo_line_count(area.height),
|
||||
Some(url),
|
||||
AuthMode::Command,
|
||||
"", // auth_code_input — unused
|
||||
false, // clipboard_copied
|
||||
"", // auth_code_input — unused
|
||||
0,
|
||||
None, // clipboard_delivery
|
||||
false, // show_raw_url
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue