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:
grokkybara[bot] 2026-07-18 19:48:28 +01:00
commit 7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions

File diff suppressed because it is too large Load diff

View file

@ -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.

View file

@ -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,
&registry,
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)),
&registry,
);
for _ in 0..20 {
let _ = state.handle_input(
&Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
&registry,
);
}
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,

View file

@ -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

View file

@ -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)),
&registry,
);
let outcome = state.handle_input(&Event::Paste("\r\n".to_owned()), &registry);
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)),
&registry,
);
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

View file

@ -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)
}

View file

@ -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

View file

@ -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");
}
}

View file

@ -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;

View file

@ -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()

View file

@ -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",
);
}
}

View file

@ -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(&current);
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;

View 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

View file

@ -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();

View file

@ -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");

View file

@ -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)
}
// ---------------------------------------------------------------------------

View file

@ -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;

View file

@ -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
],
}
}
// ---------------------------------------------------------------------------

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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"
);
}

View file

@ -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
);