Synced from monorepo
Changes: - Gate session-lifecycle heap steady state with a dhat soak - Unbreak merge lifecycle e2e after default model → grok-4.5 - Scan home-scope rules dirs at <root>/rules - Complete text-input paste and terminal parity - Gate project roles and personas - Use canonical editing in dialogs - Use canonical editing in search bars - Reject ambiguous MCP tool IDs - Harden Git operands for plugins - Simplify queue drain API - Pass RFC 9207 iss through MCP OAuth token exchange - Show leader roster when local agents map is empty - Use canonical editing in Persona views - Remove marketplace default-skills auto-install and purge old installs - Use canonical editing in extension forms - Add canonical dashboard text editing - Use canonical editing in settings - Add /summarize as a /recap alias - Restore previous agent when exiting dashboard - Use tool_choice auto for compaction - Settings toggle for snap-prompt-to-top on send - Update default models to grok-4.5 - Source login shell once for local bash (env + alias/function snapshot) - Template hardcoded param names in server-native tool descriptions - Fix System-Reminder XML tag injection in CLAUDE.md via agents_md - Fix remote workspace-server hardcoding LSP trust (repo code execution risk) - Clear orphaned tool-call updates at turn end - Suppress task wake after cancel - Send x-grok-client-identifier on direct API tool calls - Harden dashboard peek lease transitions - Host /btw side panel in live region (minimal mode) - Bound scroll presentation latency - Highlight multi-line constructs correctly in diffs and the file viewer - Block web_fetch non-public IPs; local opt-in is explicit-host only - Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness - Follow up clipboard delivery feedback - Use canonical editing in pickers - Route TextArea through canonical editor - Persistent "watching" status row; quieter turn markers - Gate sensitive edit targets - Expose agent registry counts and gate session churn on them - Default coding data sharing to opt-out until server preference applies - Wire chat attachment ids through gateway prompts - On auth refresh failure, issue retry - Forward preview provenance and computer lifecycle state - Document independent privacy controls and scope /privacy output - Strip SamplingError Display prefix on rate-limit UI copy - Stop dumping Cloudflare HTML into Retry failed - Disable in-place prompt edit (scroll jank on enter) - Strip forced ANSI color from gh pr view JSON - Plumb bash tool description onto ToolUsageCard wire
This commit is contained in:
parent
98c3b2438a
commit
7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions
|
|
@ -5,12 +5,13 @@ use ratatui::buffer::Buffer;
|
|||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::layout::{MIN_DASHBOARD_WIDTH, compute_layout};
|
||||
use super::row::{DashboardRow, RowBadge, build_rows_with_roster};
|
||||
use super::state::{
|
||||
DashboardRowId, DashboardState, Filter, Focusable, Grouping, LocationPickerState, RowState,
|
||||
SectionKey,
|
||||
DashboardRowId, DashboardState, Filter, Focusable, Grouping, LocationPickerState, RenameDraft,
|
||||
RowState, SectionKey,
|
||||
};
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::agent_view::AgentView;
|
||||
|
|
@ -286,16 +287,13 @@ pub fn render_dashboard(
|
|||
// Header.
|
||||
render_header(buf, layout.header, &theme, &rows, state, upgrade_cta);
|
||||
|
||||
// Body.
|
||||
//
|
||||
// Three distinct branches:
|
||||
// (a) no agents at all → "no agents yet" hint
|
||||
// (b) agents exist but filter hides all → "no match" hint
|
||||
// (c) otherwise → render rows
|
||||
if agents.is_empty() {
|
||||
render_empty_state(buf, layout.list, &theme, dashboard_sessions_loading);
|
||||
} else if rows.is_empty() {
|
||||
render_no_match(buf, layout.list, &theme, &state.filter);
|
||||
// Body: key off visible rows (local agents + roster), not the local map alone.
|
||||
if rows.is_empty() {
|
||||
if state.filter.is_active() {
|
||||
render_no_match(buf, layout.list, &theme, &state.filter);
|
||||
} else {
|
||||
render_empty_state(buf, layout.list, &theme, dashboard_sessions_loading);
|
||||
}
|
||||
} else if area.width < MIN_DASHBOARD_WIDTH {
|
||||
render_narrow_rows(buf, layout.list, &theme, &rows, state);
|
||||
} else {
|
||||
|
|
@ -480,63 +478,78 @@ pub fn render_dashboard(
|
|||
return None;
|
||||
}
|
||||
|
||||
// Return a visible cursor for the dispatch input
|
||||
// / rename overlay so the user sees where typing lands.
|
||||
// Rename takes precedence — it paints over the row list.
|
||||
//
|
||||
// Cursor width is computed from the SANITISED
|
||||
// draft (matching what the renderer paints). The previous
|
||||
// `rn.draft.as_str()` form drifted right of the actual end of
|
||||
// typed text when the draft contained control characters.
|
||||
//
|
||||
// Clamp `cx` to the row's right edge so a
|
||||
// wider-than-rect draft doesn't park the cursor off the row.
|
||||
// An active rename replaces the dispatch caret with its row-local editor caret.
|
||||
if let Some(pos) = rename_cursor_pos(state, &rows) {
|
||||
return Some(pos);
|
||||
}
|
||||
dispatch_cursor
|
||||
}
|
||||
|
||||
/// Cursor position for the in-flight rename overlay: one cell past the
|
||||
/// end of the typed draft, on the renamed row's title line. `None` when
|
||||
/// no rename is active (or its row isn't on screen).
|
||||
///
|
||||
/// The overlay paints `rename: {draft}` at the title's own column —
|
||||
/// past the marker (1) + gap (1) + indent + icon + gap (1) chrome — in
|
||||
/// BOTH layouts (`render_row` / `render_narrow_rows` share the
|
||||
/// formula). The cursor mirrors that chrome math; anything else parks
|
||||
/// it over the `rename:` prefix or past the typed text. Indent and
|
||||
/// icon width come from the live row so a Working spinner or a
|
||||
/// (future) indented renameable row can't drift the formula.
|
||||
///
|
||||
/// Width is computed from the SANITISED draft (matching
|
||||
/// what the renderer paints). Clamped to the row's right
|
||||
/// edge so a wider-than-rect draft doesn't park the cursor off the row.
|
||||
const RENAME_PREFIX: &str = "rename: ";
|
||||
|
||||
fn rename_editor_view(draft: &RenameDraft, width: u16) -> (&str, u16) {
|
||||
let prefix_width = UnicodeWidthStr::width(RENAME_PREFIX) as u16;
|
||||
let editor_width = width.saturating_sub(prefix_width);
|
||||
let viewport = draft.viewport(editor_width as usize);
|
||||
let visible = &draft.text()[viewport.visible_byte_range];
|
||||
let cursor_offset = prefix_width
|
||||
.saturating_add(viewport.cursor_display_column as u16)
|
||||
.min(width.saturating_sub(1));
|
||||
(visible, cursor_offset)
|
||||
}
|
||||
|
||||
fn render_rename_editor(
|
||||
buf: &mut Buffer,
|
||||
x: u16,
|
||||
y: u16,
|
||||
width: u16,
|
||||
style: Style,
|
||||
draft: &RenameDraft,
|
||||
) {
|
||||
if width == 0 {
|
||||
return;
|
||||
}
|
||||
let prefix_width = UnicodeWidthStr::width(RENAME_PREFIX) as u16;
|
||||
buf.set_span(
|
||||
x,
|
||||
y,
|
||||
&Span::styled(RENAME_PREFIX, style),
|
||||
prefix_width.min(width),
|
||||
);
|
||||
let (visible, _) = rename_editor_view(draft, width);
|
||||
if !visible.is_empty() && prefix_width < width {
|
||||
buf.set_span(
|
||||
x + prefix_width,
|
||||
y,
|
||||
&Span::styled(visible, style),
|
||||
width - prefix_width,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the in-flight rename caret when its row is visible.
|
||||
fn rename_cursor_pos(state: &DashboardState, rows: &[DashboardRow]) -> Option<(u16, u16)> {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
let rn = state.rename.as_ref()?;
|
||||
let (_, rect) = state.row_rects.iter().find(|(id, _)| *id == rn.row)?;
|
||||
let safe_draft = crate::views::session_title::sanitize_display_text(&rn.draft);
|
||||
let prefix_w = UnicodeWidthStr::width("rename: ") as u16;
|
||||
let draft_w = UnicodeWidthStr::width(safe_draft.as_ref()) as u16;
|
||||
let (indent_w, icon_w) = rows
|
||||
let (marker_width, indent_width, icon_width) = rows
|
||||
.iter()
|
||||
.find(|r| r.id == rn.row)
|
||||
.map(|r| {
|
||||
(
|
||||
UnicodeWidthStr::width(crate::glyphs::selection_bar()) as u16,
|
||||
(r.indent as u16) * 2,
|
||||
UnicodeWidthStr::width(state_icon(r.state, state.spinner_tick)) as u16,
|
||||
)
|
||||
})
|
||||
.unwrap_or((0, 1));
|
||||
let chrome_w = 1 + 1 + indent_w + icon_w + 1;
|
||||
let unbounded_cx = rect
|
||||
.x
|
||||
.saturating_add(chrome_w)
|
||||
.saturating_add(prefix_w)
|
||||
.saturating_add(draft_w);
|
||||
let cx_max = rect.x.saturating_add(rect.width.saturating_sub(1));
|
||||
Some((unbounded_cx.min(cx_max), rect.y))
|
||||
.unwrap_or((1, 0, 1));
|
||||
let chrome_width = marker_width + 1 + indent_width + icon_width + 1;
|
||||
let content_x = rect.x.saturating_add(chrome_width);
|
||||
let content_width = rect.x.saturating_add(rect.width).saturating_sub(content_x);
|
||||
let (_, cursor_offset) = rename_editor_view(rn, content_width);
|
||||
let cursor_x = content_x
|
||||
.saturating_add(cursor_offset)
|
||||
.min(rect.x.saturating_add(rect.width.saturating_sub(1)));
|
||||
Some((cursor_x, rect.y))
|
||||
}
|
||||
|
||||
/// Render the compact dashboard "banner" used when an agent is
|
||||
|
|
@ -685,7 +698,6 @@ fn render_header(
|
|||
upgrade_cta: Option<HeaderUpgradeCta<'_>>,
|
||||
) {
|
||||
use ratatui::text::{Line, Span};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::views::agent_status::AgentStatusBar;
|
||||
|
||||
|
|
@ -1004,7 +1016,8 @@ fn render_location_picker(
|
|||
ModalSizing, ModalWindowConfig, Shortcut, push_vim_nav_search_hint, render_modal_window,
|
||||
};
|
||||
use crate::views::picker::{
|
||||
PickerEntry, PickerRow, render_divider, render_picker_content, render_search_bar_with_label,
|
||||
PickerEntry, PickerRow, render_divider, render_picker_content,
|
||||
render_picker_search_bar_with_label,
|
||||
};
|
||||
|
||||
let mut shortcuts = vec![
|
||||
|
|
@ -1090,17 +1103,16 @@ fn render_location_picker(
|
|||
} else {
|
||||
(content_area.width, None)
|
||||
};
|
||||
render_search_bar_with_label(
|
||||
render_picker_search_bar_with_label(
|
||||
buf,
|
||||
content_area.x,
|
||||
content_area.y,
|
||||
path_w,
|
||||
theme,
|
||||
" path: ",
|
||||
&modal.picker.query,
|
||||
&modal.picker,
|
||||
/* active */ false,
|
||||
/* show_hint */ false,
|
||||
modal.picker.query_cursor,
|
||||
Some(theme.bg_base),
|
||||
);
|
||||
modal.worktree_hit.set(wt_rect);
|
||||
|
|
@ -1176,7 +1188,6 @@ fn render_location_picker(
|
|||
// `render_picker_row`'s layout (fold prefix 2, gap 2, trailing 1); the
|
||||
// `-1` conservatively reserves a scrollbar column.
|
||||
let details: Vec<String> = {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
const PREFIX: u16 = 2;
|
||||
const GAP: u16 = 2;
|
||||
const TRAILING: u16 = 1;
|
||||
|
|
@ -1757,7 +1768,6 @@ fn render_group_header(
|
|||
selected: bool,
|
||||
hovered: bool,
|
||||
) {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
let bg = Style::default().bg(theme.bg_base);
|
||||
let fill = " ".repeat(rect.width as usize);
|
||||
buf.set_string(rect.x, rect.y, fill, bg);
|
||||
|
|
@ -2043,7 +2053,6 @@ fn render_row(
|
|||
row: &DashboardRow,
|
||||
state: &DashboardState,
|
||||
) {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
if rect.area() == 0 {
|
||||
return;
|
||||
}
|
||||
|
|
@ -2127,19 +2136,17 @@ fn render_row(
|
|||
icon,
|
||||
Style::default().fg(icon_color).bg(bg),
|
||||
);
|
||||
let prefix = "rename: ";
|
||||
let safe_draft = crate::views::session_title::sanitize_display_text(&rn.draft).into_owned();
|
||||
let line = format!("{prefix}{safe_draft}");
|
||||
let avail = (rect.x + rect.width).saturating_sub(content_start_x + 1);
|
||||
let truncated = truncate_str(&line, avail as usize);
|
||||
buf.set_string(
|
||||
let available = (rect.x + rect.width).saturating_sub(content_start_x);
|
||||
render_rename_editor(
|
||||
buf,
|
||||
content_start_x,
|
||||
title_y,
|
||||
truncated,
|
||||
available,
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.bg(bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
rn,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -2358,7 +2365,6 @@ fn render_narrow_rows(
|
|||
// form would push too many rows off-screen on a 40-col terminal).
|
||||
// We still emit group headers and the selection marker so the
|
||||
// visual vocabulary stays consistent.
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
let lines = build_dashboard_lines(
|
||||
rows,
|
||||
state.grouping,
|
||||
|
|
@ -2486,18 +2492,16 @@ fn render_narrow_rows(
|
|||
&chrome,
|
||||
Style::default().fg(theme.text_primary).bg(bg),
|
||||
);
|
||||
let safe_draft =
|
||||
crate::views::session_title::sanitize_display_text(&rn.draft).into_owned();
|
||||
let line = format!("rename: {safe_draft}");
|
||||
let truncated = truncate_str(&line, body_width.saturating_sub(chrome_w) as usize);
|
||||
buf.set_string(
|
||||
render_rename_editor(
|
||||
buf,
|
||||
area.x + chrome_w,
|
||||
y,
|
||||
truncated,
|
||||
body_width.saturating_sub(chrome_w),
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.bg(bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
rn,
|
||||
);
|
||||
} else {
|
||||
let marker = if selected {
|
||||
|
|
@ -2621,8 +2625,6 @@ fn paint_dispatch_feedback_badge(
|
|||
theme: &Theme,
|
||||
error_toast: Option<&str>,
|
||||
) {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
let Some(err) = error_toast else {
|
||||
return;
|
||||
};
|
||||
|
|
@ -2746,7 +2748,6 @@ fn render_dispatch(
|
|||
overlay_area: Option<Rect>,
|
||||
) -> Option<(u16, u16)> {
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Widget};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::views::prompt_widget::PromptStyle;
|
||||
|
||||
|
|
@ -2806,7 +2807,7 @@ fn render_dispatch(
|
|||
height: 1,
|
||||
}
|
||||
};
|
||||
if content.width < 4 {
|
||||
if content.width == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
|
@ -2817,36 +2818,63 @@ fn render_dispatch(
|
|||
if state.search_mode {
|
||||
let prefix = "Search: ";
|
||||
let prefix_w = UnicodeWidthStr::width(prefix) as u16;
|
||||
buf.set_string(
|
||||
let painted_prefix_w = prefix_w.min(content.width);
|
||||
buf.set_span(
|
||||
content.x,
|
||||
content.y,
|
||||
prefix,
|
||||
Style::default()
|
||||
.fg(theme.warning)
|
||||
.bg(theme.bg_base)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
&Span::styled(
|
||||
prefix,
|
||||
Style::default()
|
||||
.fg(theme.warning)
|
||||
.bg(theme.bg_base)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
painted_prefix_w,
|
||||
);
|
||||
let avail = content.width.saturating_sub(prefix_w);
|
||||
let (to_show, style) = if state.dispatch.text().is_empty() {
|
||||
(
|
||||
"Type to filter sessions\u{2026}".to_string(),
|
||||
Style::default().fg(theme.gray_dim).bg(theme.bg_base),
|
||||
)
|
||||
let editor_x = content.x + painted_prefix_w;
|
||||
let avail = content.width - painted_prefix_w;
|
||||
let cursor_column = if state.dispatch.text().is_empty() {
|
||||
if avail > 0 {
|
||||
let placeholder = truncate_str("Type to filter sessions\u{2026}", avail as usize);
|
||||
buf.set_string(
|
||||
editor_x,
|
||||
content.y,
|
||||
placeholder,
|
||||
Style::default().fg(theme.gray_dim).bg(theme.bg_base),
|
||||
);
|
||||
}
|
||||
0
|
||||
} else {
|
||||
(
|
||||
state.dispatch.text().to_string(),
|
||||
Style::default().fg(theme.text_primary).bg(theme.bg_base),
|
||||
let viewport = xai_ratatui_textarea::EditBuffer::from_parts(
|
||||
state.dispatch.text(),
|
||||
state.dispatch.cursor(),
|
||||
)
|
||||
.single_line_viewport(avail as usize);
|
||||
let visible = &state.dispatch.text()[viewport.visible_byte_range];
|
||||
if avail > 0 {
|
||||
buf.set_span(
|
||||
editor_x,
|
||||
content.y,
|
||||
&Span::styled(
|
||||
visible,
|
||||
Style::default().fg(theme.text_primary).bg(theme.bg_base),
|
||||
),
|
||||
(UnicodeWidthStr::width(visible) as u16).min(avail),
|
||||
);
|
||||
}
|
||||
viewport.cursor_display_column as u16
|
||||
};
|
||||
let trunc = truncate_str(&to_show, avail as usize);
|
||||
buf.set_string(content.x + prefix_w, content.y, trunc, style);
|
||||
let text_disp_w: u16 = UnicodeWidthStr::width(state.dispatch.text())
|
||||
.try_into()
|
||||
.unwrap_or(u16::MAX);
|
||||
let cx = content.x + prefix_w + text_disp_w.min(avail.saturating_sub(1));
|
||||
let cursor_offset = painted_prefix_w
|
||||
.saturating_add(cursor_column)
|
||||
.min(content.width - 1);
|
||||
let cx = content.x + cursor_offset;
|
||||
return input_focused.then_some((cx, content.y));
|
||||
}
|
||||
|
||||
if content.width < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prefix = "\u{276F} ";
|
||||
let prefix_w = UnicodeWidthStr::width(prefix) as u16;
|
||||
|
||||
|
|
@ -3825,7 +3853,6 @@ pub fn render_popup_overlay(
|
|||
|
||||
let title_text = format!(" \u{2771} {title_label} ");
|
||||
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
let close_label = crate::glyphs::ballot_x_button();
|
||||
let close_w = UnicodeWidthStr::width(close_label) as u16;
|
||||
// Reserve close-affordance width + a 1-cell gap on the right;
|
||||
|
|
@ -4047,8 +4074,6 @@ fn paint_session_title_bar(
|
|||
left_inset: u16,
|
||||
right_inset: u16,
|
||||
) -> (Option<Rect>, Option<Rect>, Option<Rect>) {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
// `‹` / `›` / `✗` are all painted as plain bracketed text
|
||||
// (no button background fills). Hover only changes the fg
|
||||
// color (`text_primary` vs `gray`) for subtle clickability
|
||||
|
|
@ -4267,6 +4292,51 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_dashboard_shows_roster_when_local_agents_empty() {
|
||||
use crate::app::roster::{RosterActivity, RosterEntry, RosterOrigin};
|
||||
|
||||
let area = Rect::new(0, 0, 100, 24);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut agents: IndexMap<AgentId, AgentView> = IndexMap::new();
|
||||
let mut state = DashboardState::new();
|
||||
let registry = crate::actions::ActionRegistry::defaults();
|
||||
let roster = [RosterEntry {
|
||||
session_id: "sess-fleet-1".into(),
|
||||
title: Some("Fix fleet dashboard".into()),
|
||||
cwd: "/repo/work".into(),
|
||||
is_worktree: false,
|
||||
model_id: None,
|
||||
yolo: false,
|
||||
activity: RosterActivity::Working,
|
||||
resident: true,
|
||||
last_change_unix_ms: 1_725_000_000_000,
|
||||
origin: RosterOrigin::default(),
|
||||
}];
|
||||
|
||||
let _ = render_dashboard(
|
||||
&mut buf,
|
||||
area,
|
||||
&mut state,
|
||||
&mut agents,
|
||||
®istry,
|
||||
None,
|
||||
&roster,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
let content = buf_to_text(&buf);
|
||||
assert!(
|
||||
content.contains("Fix fleet dashboard"),
|
||||
"roster-only working session must paint when local agents are empty, got: {content:?}"
|
||||
);
|
||||
assert!(
|
||||
!content.contains("No agents yet"),
|
||||
"must not show empty-state while roster rows exist, got: {content:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// While the local session roster is still loading the empty body
|
||||
/// shows a loading hint instead of the "no agents" copy.
|
||||
#[test]
|
||||
|
|
@ -5385,13 +5455,10 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// The in-flight rename overlay sanitises the
|
||||
/// draft before painting so a smuggled ANSI escape never lands in
|
||||
/// the buffer (test wide-mode and narrow-mode separately).
|
||||
/// RenameDraft sanitation keeps control characters out of both render paths.
|
||||
#[test]
|
||||
fn render_rename_overlay_strips_control_chars_from_live_draft() {
|
||||
fn sanitized_rename_draft_is_safe_in_both_render_paths() {
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::views::dashboard::state::RenameDraft;
|
||||
let id = DashboardRowId::TopLevel(AgentId(7));
|
||||
let row = DashboardRow {
|
||||
id: id.clone(),
|
||||
|
|
@ -5420,10 +5487,7 @@ mod tests {
|
|||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 3));
|
||||
let mut state = DashboardState::new();
|
||||
state.selected = Some(id.clone());
|
||||
state.rename = Some(RenameDraft {
|
||||
row: id.clone(),
|
||||
draft: "a\x1b[31m".to_string(),
|
||||
});
|
||||
state.rename = Some(RenameDraft::new(id.clone(), "a\x1b[31m"));
|
||||
render_rows(&mut buf, Rect::new(0, 0, 80, 3), &theme, &rows, &mut state);
|
||||
let content = buf_to_text(&buf);
|
||||
assert!(
|
||||
|
|
@ -5441,10 +5505,7 @@ mod tests {
|
|||
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 3));
|
||||
let mut state = DashboardState::new();
|
||||
state.selected = Some(id.clone());
|
||||
state.rename = Some(RenameDraft {
|
||||
row: id.clone(),
|
||||
draft: "a\x1b[31m".to_string(),
|
||||
});
|
||||
state.rename = Some(RenameDraft::new(id.clone(), "a\x1b[31m"));
|
||||
render_narrow_rows(&mut buf, Rect::new(0, 0, 30, 3), &theme, &rows, &mut state);
|
||||
let content = buf_to_text(&buf);
|
||||
assert!(
|
||||
|
|
@ -5458,13 +5519,10 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// The rename overlay keeps the row's chrome (state icon) and paints
|
||||
/// `rename:` at the title's own column, so the editing row stays
|
||||
/// aligned with its neighbours (wide and narrow layouts).
|
||||
/// Rename rendering preserves row chrome and title alignment in both layouts.
|
||||
#[test]
|
||||
fn render_rename_overlay_aligns_with_title_and_keeps_icon() {
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::views::dashboard::state::RenameDraft;
|
||||
let id = DashboardRowId::TopLevel(AgentId(7));
|
||||
let row = DashboardRow {
|
||||
id: id.clone(),
|
||||
|
|
@ -5506,10 +5564,7 @@ mod tests {
|
|||
{
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 5));
|
||||
let mut state = DashboardState::new();
|
||||
state.rename = Some(RenameDraft {
|
||||
row: id.clone(),
|
||||
draft: "new name".to_string(),
|
||||
});
|
||||
state.rename = Some(RenameDraft::new(id.clone(), "new name"));
|
||||
render_rows(&mut buf, Rect::new(0, 0, 80, 5), &theme, &rows, &mut state);
|
||||
let line = row_text(&buf, 2, 80);
|
||||
assert_eq!(
|
||||
|
|
@ -5533,10 +5588,7 @@ mod tests {
|
|||
);
|
||||
// With an empty draft the cursor sits immediately after
|
||||
// `rename: ` (the position typing lands at).
|
||||
state.rename = Some(RenameDraft {
|
||||
row: id.clone(),
|
||||
draft: String::new(),
|
||||
});
|
||||
state.rename = Some(RenameDraft::new(id.clone(), ""));
|
||||
assert_eq!(
|
||||
rename_cursor_pos(&state, &rows),
|
||||
Some((title_col + prefix_w, 2)),
|
||||
|
|
@ -5556,10 +5608,7 @@ mod tests {
|
|||
{
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 3));
|
||||
let mut state = DashboardState::new();
|
||||
state.rename = Some(RenameDraft {
|
||||
row: id.clone(),
|
||||
draft: "nn".to_string(),
|
||||
});
|
||||
state.rename = Some(RenameDraft::new(id.clone(), "nn"));
|
||||
render_narrow_rows(&mut buf, Rect::new(0, 0, 30, 3), &theme, &rows, &mut state);
|
||||
let line = row_text(&buf, 1, 30);
|
||||
assert_eq!(
|
||||
|
|
@ -5575,6 +5624,97 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_viewport_handles_long_unicode_in_wide_and_narrow_rows() {
|
||||
use crate::app::agent::AgentId;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
let id = DashboardRowId::TopLevel(AgentId(7));
|
||||
let row = DashboardRow {
|
||||
id: id.clone(),
|
||||
label: "row label".to_string(),
|
||||
subtitle: None,
|
||||
state: RowState::Idle,
|
||||
activity: None,
|
||||
secondary_line: None,
|
||||
cwd_display: String::new(),
|
||||
cwd: std::path::PathBuf::from("/tmp"),
|
||||
last_change_at: std::time::SystemTime::now(),
|
||||
pinned: false,
|
||||
is_active: false,
|
||||
badges: Vec::new(),
|
||||
context_pct: None,
|
||||
indent: 0,
|
||||
parent_label: None,
|
||||
is_more_placeholder: false,
|
||||
more_count: 0,
|
||||
};
|
||||
let rows = vec![row];
|
||||
let text = format!("{}中e\u{301}👩🏽\u{200d}💻", "x".repeat(90));
|
||||
let theme = Theme::current();
|
||||
let registry = crate::actions::ActionRegistry::defaults();
|
||||
|
||||
for (width, narrow, row_y) in [(80, false, 2), (30, true, 1)] {
|
||||
let area = Rect::new(0, 0, width, if narrow { 3 } else { 5 });
|
||||
let mut buffer = Buffer::empty(area);
|
||||
let mut state = DashboardState::new();
|
||||
state.rename = Some(RenameDraft::new(id.clone(), text.clone()));
|
||||
if narrow {
|
||||
render_narrow_rows(&mut buffer, area, &theme, &rows, &mut state);
|
||||
} else {
|
||||
render_rows(&mut buffer, area, &theme, &rows, &mut state);
|
||||
}
|
||||
let line = (0..width)
|
||||
.map(|x| buffer[(x, row_y)].symbol().to_string())
|
||||
.collect::<String>();
|
||||
assert!(line.contains('中'), "CJK tail missing: {line:?}");
|
||||
assert!(line.contains("e\u{301}"), "combining tail split: {line:?}");
|
||||
assert!(line.contains("👩🏽\u{200d}💻"), "ZWJ tail split: {line:?}",);
|
||||
let end_cursor = rename_cursor_pos(&state, &rows).expect("end cursor");
|
||||
|
||||
let _ = state.handle_input(
|
||||
&Event::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)),
|
||||
®istry,
|
||||
);
|
||||
for _ in 0..20 {
|
||||
let _ = state.handle_input(
|
||||
&Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
|
||||
®istry,
|
||||
);
|
||||
}
|
||||
let mut middle_buffer = Buffer::empty(area);
|
||||
if narrow {
|
||||
render_narrow_rows(&mut middle_buffer, area, &theme, &rows, &mut state);
|
||||
} else {
|
||||
render_rows(&mut middle_buffer, area, &theme, &rows, &mut state);
|
||||
}
|
||||
let middle_cursor = rename_cursor_pos(&state, &rows).expect("middle cursor");
|
||||
assert_ne!(
|
||||
state.rename.as_ref().expect("rename draft").cursor_byte(),
|
||||
text.len()
|
||||
);
|
||||
if !narrow {
|
||||
assert_ne!(middle_cursor, end_cursor);
|
||||
}
|
||||
let prefix_x = (0..width)
|
||||
.find(|x| middle_buffer[(*x, row_y)].symbol() == "r")
|
||||
.expect("rename prefix");
|
||||
let row_rect = state
|
||||
.row_rects
|
||||
.iter()
|
||||
.find(|(row_id, _)| row_id == &id)
|
||||
.map(|(_, rect)| *rect)
|
||||
.expect("rename row rect");
|
||||
let editor_x = prefix_x + RENAME_PREFIX.len() as u16;
|
||||
let editor_width = row_rect
|
||||
.x
|
||||
.saturating_add(row_rect.width)
|
||||
.saturating_sub(editor_x);
|
||||
let expected_cursor = editor_x + 20u16.min(editor_width.saturating_sub(1));
|
||||
assert_eq!(middle_cursor, (expected_cursor, row_y));
|
||||
}
|
||||
}
|
||||
|
||||
/// On a 3-row rect the dispatch input
|
||||
/// paints a rounded-box chrome so it reads as a real input
|
||||
/// field. The text row contains the `❯` prefix.
|
||||
|
|
@ -5598,6 +5738,52 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_search_mode_uses_textarea_cursor_not_text_end() {
|
||||
let area = Rect::new(0, 0, 40, 3);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
let theme = Theme::current();
|
||||
let mut state = DashboardState::new();
|
||||
state.search_mode = true;
|
||||
state.dispatch.set_text("abcdef");
|
||||
state.dispatch.set_cursor(2);
|
||||
|
||||
let cursor = render_dispatch(&mut buffer, area, &theme, &mut state, None)
|
||||
.expect("focused search cursor");
|
||||
let prefix_x = (0..area.width)
|
||||
.find(|x| buffer[(*x, cursor.1)].symbol() == "S")
|
||||
.expect("Search prefix");
|
||||
assert_eq!(cursor.0, prefix_x + "Search: ".len() as u16 + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_search_mode_clips_prefix_and_cursor_at_widths_one_through_nine() {
|
||||
let theme = Theme::current();
|
||||
for width in 1..=9 {
|
||||
let full = Rect::new(0, 0, 14, 1);
|
||||
let area = Rect::new(2, 0, width, 1);
|
||||
let mut buffer = Buffer::empty(full);
|
||||
buffer.set_string(0, 0, "#".repeat(full.width as usize), Style::default());
|
||||
let mut state = DashboardState::new();
|
||||
state.search_mode = true;
|
||||
state.dispatch.set_text("abcdef");
|
||||
state.dispatch.set_cursor(2);
|
||||
|
||||
let cursor = render_dispatch(&mut buffer, area, &theme, &mut state, None)
|
||||
.expect("focused narrow search cursor");
|
||||
assert!(cursor.0 >= area.x && cursor.0 < area.x + area.width);
|
||||
for x in 0..full.width {
|
||||
if x < area.x || x >= area.x + area.width {
|
||||
assert_eq!(
|
||||
buffer[(x, 0)].symbol(),
|
||||
"#",
|
||||
"width {width} wrote outside at column {x}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_dispatch_keeps_generic_paste_preview_but_suppresses_image_preview() {
|
||||
let area = Rect::new(0, 17, 80, 3);
|
||||
|
|
@ -7340,8 +7526,7 @@ mod tests {
|
|||
std::path::PathBuf::from("/base"),
|
||||
std::collections::HashMap::new(),
|
||||
);
|
||||
modal.picker.query = "/tmp/zzz".to_string();
|
||||
modal.picker.query_cursor = modal.picker.query.len();
|
||||
modal.picker.set_query("/tmp/zzz");
|
||||
render_location_picker(&mut buf, area, &theme, &mut modal);
|
||||
let content = buf_to_text(&buf);
|
||||
assert!(
|
||||
|
|
@ -8255,21 +8440,16 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// An in-flight rename swaps the footer for its two actions —
|
||||
/// Enter saves, Esc cancels — and hides the normal nav/stop chips.
|
||||
/// Rename mode shows only save and cancel actions.
|
||||
#[test]
|
||||
fn render_footer_rename_shows_save_and_cancel() {
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::views::dashboard::state::RenameDraft;
|
||||
let theme = Theme::current();
|
||||
let registry = crate::actions::ActionRegistry::defaults();
|
||||
let mut state = DashboardState::new();
|
||||
let id = DashboardRowId::TopLevel(AgentId(0));
|
||||
state.focus_row(id.clone());
|
||||
state.rename = Some(RenameDraft {
|
||||
row: id,
|
||||
draft: String::new(),
|
||||
});
|
||||
state.rename = Some(RenameDraft::new(id, ""));
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||||
render_footer(
|
||||
&mut buf,
|
||||
|
|
|
|||
|
|
@ -389,9 +389,9 @@ pub fn classify_top_level(agent: &AgentView) -> RowState {
|
|||
/// (`run_terminal_command` with `background=true`), a running `monitor`
|
||||
/// (a background task with `is_monitor`), or an active scheduled `/loop`.
|
||||
/// Mirrors the agent view's idle "watching" cue
|
||||
/// (`crate::views::turn_status::Watchers`) but also counts plain
|
||||
/// background tasks — any in-flight background work the user dispatched
|
||||
/// should read as "Working" on the dashboard.
|
||||
/// (`crate::views::turn_status::Watchers`, minus subagents — the dashboard
|
||||
/// lists those as their own rows) — any in-flight background work the user
|
||||
/// dispatched should read as "Working" on the dashboard.
|
||||
pub fn has_background_work(agent: &AgentView) -> bool {
|
||||
agent
|
||||
.session
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use crate::actions::ActionRegistry;
|
|||
use crate::app::actions::Action;
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::app_view::InputOutcome;
|
||||
use crate::input::line_editor::{LineEditOutcome, LineEditor};
|
||||
use crate::key;
|
||||
use crate::views::prompt_widget::PromptWidget;
|
||||
|
||||
|
|
@ -56,7 +57,7 @@ impl DashboardRowId {
|
|||
pub(crate) struct PeekViewportLease {
|
||||
pub row: DashboardRowId,
|
||||
pub snapshot: crate::scrollback::state::ViewportSnapshot,
|
||||
pub page_flip_entry: Option<usize>,
|
||||
pub page_flip_entry: Option<crate::scrollback::EntryId>,
|
||||
}
|
||||
|
||||
pub(crate) fn scrollback_mut_for_row<'a>(
|
||||
|
|
@ -766,7 +767,51 @@ pub struct ShortcutsModalState {
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct RenameDraft {
|
||||
pub row: DashboardRowId,
|
||||
pub draft: String,
|
||||
editor: LineEditor,
|
||||
}
|
||||
|
||||
const MAX_RENAME_SCALARS: usize = 100;
|
||||
|
||||
impl RenameDraft {
|
||||
pub fn new(row: DashboardRowId, text: impl Into<String>) -> Self {
|
||||
let mut draft = Self {
|
||||
row,
|
||||
editor: LineEditor::default(),
|
||||
};
|
||||
draft.set_text(text);
|
||||
draft
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
self.editor.text()
|
||||
}
|
||||
|
||||
pub fn cursor_byte(&self) -> usize {
|
||||
self.editor.cursor_byte()
|
||||
}
|
||||
|
||||
pub(crate) fn viewport(&self, width: usize) -> xai_ratatui_textarea::SingleLineViewport {
|
||||
self.editor.viewport(width)
|
||||
}
|
||||
|
||||
pub(crate) fn set_text(&mut self, text: impl Into<String>) {
|
||||
let text = text
|
||||
.into()
|
||||
.chars()
|
||||
.filter(|character| rename_wire_character_allowed(*character))
|
||||
.take(MAX_RENAME_SCALARS)
|
||||
.collect::<String>();
|
||||
self.editor.set_text(text);
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_character_allowed(character: char) -> bool {
|
||||
!crate::render::line_utils::is_unsafe_display_char(character)
|
||||
}
|
||||
|
||||
fn rename_wire_character_allowed(character: char) -> bool {
|
||||
// Preserve an existing emoji ZWJ sequence; interactive inserts still reject format chars.
|
||||
character == '\u{200d}' || rename_character_allowed(character)
|
||||
}
|
||||
|
||||
/// One selectable directory in the location picker (see
|
||||
|
|
@ -844,10 +889,7 @@ impl LocationPickerState {
|
|||
base_cwd: PathBuf,
|
||||
worktrees: std::collections::HashMap<PathBuf, String>,
|
||||
) -> Self {
|
||||
let picker = crate::views::picker::PickerState {
|
||||
search_active: true,
|
||||
..crate::views::picker::PickerState::default()
|
||||
};
|
||||
let picker = crate::views::picker::PickerState::input_active();
|
||||
Self {
|
||||
picker,
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
|
|
@ -880,7 +922,7 @@ impl LocationPickerState {
|
|||
/// Whether the current query should be treated as a filesystem path
|
||||
/// (directory completion) rather than a fuzzy filter over recents.
|
||||
pub fn query_is_path(&self) -> bool {
|
||||
let q = &self.picker.query;
|
||||
let q = self.picker.query();
|
||||
q.starts_with('/')
|
||||
|| q.starts_with('~')
|
||||
|| q.contains('/')
|
||||
|
|
@ -896,7 +938,7 @@ impl LocationPickerState {
|
|||
/// home; relative parents join [`Self::base_cwd`]. The separator is `/`
|
||||
/// on all hosts and additionally `\` on Windows.
|
||||
fn path_query_parts(&self) -> (PathBuf, String) {
|
||||
let q = self.picker.query.as_str();
|
||||
let q = self.picker.query();
|
||||
// Last path separator: `/` always; `\` additionally on Windows.
|
||||
let sep = match (q.rfind('/'), cfg!(windows).then(|| q.rfind('\\')).flatten()) {
|
||||
(Some(a), Some(b)) => Some(a.max(b)),
|
||||
|
|
@ -963,7 +1005,7 @@ impl LocationPickerState {
|
|||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
let q = self.picker.query.trim().to_lowercase();
|
||||
let q = self.picker.query().trim().to_lowercase();
|
||||
self.recents
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
|
|
@ -986,7 +1028,7 @@ impl LocationPickerState {
|
|||
if let Some(c) = visible.get(self.picker.selected) {
|
||||
return Some(c.path.to_string_lossy().into_owned());
|
||||
}
|
||||
let q = self.picker.query.trim();
|
||||
let q = self.picker.query().trim();
|
||||
if !q.is_empty() {
|
||||
return Some(q.to_string());
|
||||
}
|
||||
|
|
@ -1778,7 +1820,9 @@ impl DashboardState {
|
|||
let w = lease.snapshot.last_width;
|
||||
let h = lease.snapshot.viewport_height;
|
||||
sb.restore_viewport_snapshot(lease.snapshot);
|
||||
if let Some(idx) = page_flip {
|
||||
if let Some(entry_id) = page_flip
|
||||
&& let Some(idx) = sb.index_of_id(entry_id)
|
||||
{
|
||||
if w > 0 && h > 0 {
|
||||
sb.prepare_layout(w, h);
|
||||
}
|
||||
|
|
@ -1814,32 +1858,11 @@ impl DashboardState {
|
|||
});
|
||||
}
|
||||
|
||||
pub fn note_page_flip_for_lease(
|
||||
pub(crate) fn note_page_flip_for_lease(
|
||||
&mut self,
|
||||
agent_id: AgentId,
|
||||
agents: &mut indexmap::IndexMap<AgentId, crate::app::agent_view::AgentView>,
|
||||
) {
|
||||
let Some(row) = self
|
||||
.peek_viewport
|
||||
.as_ref()
|
||||
.filter(|lease| lease.row.matches_top_level_agent(agent_id))
|
||||
.map(|lease| lease.row.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(sb) = scrollback_mut_for_row(&row, agents) else {
|
||||
return;
|
||||
};
|
||||
let selected = sb.selected();
|
||||
let current_turn = sb.current_turn();
|
||||
self.note_page_flip_from_scroll(agent_id, selected, current_turn);
|
||||
}
|
||||
|
||||
pub(crate) fn note_page_flip_from_scroll(
|
||||
&mut self,
|
||||
agent_id: AgentId,
|
||||
selected: Option<usize>,
|
||||
current_turn: Option<usize>,
|
||||
entry_id: crate::scrollback::EntryId,
|
||||
agents: &indexmap::IndexMap<AgentId, crate::app::agent_view::AgentView>,
|
||||
) {
|
||||
let Some(lease) = self.peek_viewport.as_mut() else {
|
||||
return;
|
||||
|
|
@ -1847,11 +1870,16 @@ impl DashboardState {
|
|||
if !lease.row.matches_top_level_agent(agent_id) {
|
||||
return;
|
||||
}
|
||||
lease.page_flip_entry = selected;
|
||||
lease.snapshot.follow_mode = true;
|
||||
lease.snapshot.follow_preserve_scroll = true;
|
||||
lease.snapshot.selected = selected;
|
||||
lease.snapshot.current_turn = current_turn;
|
||||
let Some(sb) = agents.get(&agent_id).map(|agent| &agent.scrollback) else {
|
||||
return;
|
||||
};
|
||||
if sb.index_of_id(entry_id).is_none() {
|
||||
return;
|
||||
}
|
||||
if !sb.is_follow_preserve_scroll() {
|
||||
return;
|
||||
}
|
||||
lease.page_flip_entry = Some(entry_id);
|
||||
}
|
||||
|
||||
/// Clear the peek reply draft AND its undo history.
|
||||
|
|
@ -1989,12 +2017,14 @@ impl DashboardState {
|
|||
return self.handle_worktree_dialog_input(ev);
|
||||
}
|
||||
|
||||
// Rename mode owns the keyboard until Enter / Esc.
|
||||
// Rename mode owns input until committed or cancelled.
|
||||
if let Some(ref mut rn) = self.rename {
|
||||
if let Event::Key(key) = ev
|
||||
&& key.kind != KeyEventKind::Release
|
||||
{
|
||||
return handle_rename_key(rn, key);
|
||||
match ev {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
return handle_rename_key(rn, key);
|
||||
}
|
||||
Event::Paste(text) => return handle_rename_paste(rn, text),
|
||||
_ => {}
|
||||
}
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
|
|
@ -3478,7 +3508,7 @@ impl DashboardState {
|
|||
|
||||
// Forward to the prompt widget (single-line).
|
||||
let old = self.dispatch.text().to_string();
|
||||
let _ = self.dispatch.handle_key(key);
|
||||
let event = self.dispatch.handle_key(key);
|
||||
let new = self.dispatch.text().to_string();
|
||||
if old != new {
|
||||
// Live-update the filter as the user types ONLY in search
|
||||
|
|
@ -3511,6 +3541,8 @@ impl DashboardState {
|
|||
self.manual_scroll_active = false;
|
||||
}
|
||||
InputOutcome::Changed
|
||||
} else if event == crate::views::prompt_widget::PromptEvent::Edited {
|
||||
InputOutcome::Changed
|
||||
} else {
|
||||
InputOutcome::Unchanged
|
||||
}
|
||||
|
|
@ -3958,8 +3990,7 @@ impl DashboardState {
|
|||
if !filled.ends_with('/') {
|
||||
filled.push('/');
|
||||
}
|
||||
lp.picker.query = filled;
|
||||
lp.picker.query_cursor = lp.picker.query.len();
|
||||
lp.picker.set_query(filled);
|
||||
lp.picker.selected = 0;
|
||||
lp.picker.scroll_offset = None;
|
||||
// The path changed — drop any stale "Not a directory" error.
|
||||
|
|
@ -3970,23 +4001,22 @@ impl DashboardState {
|
|||
|
||||
let entry_count = lp.visible_candidates().len();
|
||||
let config = location_picker_config();
|
||||
let query_before = lp.picker.query.clone();
|
||||
let outcome =
|
||||
crate::views::picker::handle_picker_input(ev, &mut lp.picker, entry_count, &config);
|
||||
// When the user edits the path, drop the stale validation error so a
|
||||
// corrected (possibly valid) path isn't shown next to a red
|
||||
// "Not a directory" left over from the previous failed attempt.
|
||||
if lp.picker.query != query_before {
|
||||
if matches!(&outcome, crate::views::picker::PickerOutcome::QueryChanged) {
|
||||
lp.error = None;
|
||||
// Re-list only when the edited path changes; cursor motion is redraw-only.
|
||||
lp.refresh_suggestions();
|
||||
}
|
||||
// The query may have changed (typing / backspace / Ctrl+U) — re-list
|
||||
// the parent directory if its path-mode parent moved.
|
||||
lp.refresh_suggestions();
|
||||
match outcome {
|
||||
crate::views::picker::PickerOutcome::Closed => {
|
||||
InputOutcome::Action(Action::DashboardCloseLocationPicker)
|
||||
}
|
||||
crate::views::picker::PickerOutcome::Changed => InputOutcome::Changed,
|
||||
crate::views::picker::PickerOutcome::Changed
|
||||
| crate::views::picker::PickerOutcome::QueryChanged => InputOutcome::Changed,
|
||||
_ => InputOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
|
|
@ -4078,14 +4108,12 @@ impl DashboardState {
|
|||
let Some(dialog) = self.worktree_dialog.as_mut() else {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
let Event::Key(key) = ev else {
|
||||
// Consume mouse / resize while the dialog is modal.
|
||||
return InputOutcome::Unchanged;
|
||||
let outcome = match ev {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => dialog.handle_key(key),
|
||||
Event::Paste(text) => dialog.insert_paste(text),
|
||||
_ => return InputOutcome::Unchanged,
|
||||
};
|
||||
if key.kind == KeyEventKind::Release {
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
match dialog.handle_key(key) {
|
||||
match outcome {
|
||||
NewWorktreeDialogOutcome::Submitted(label) => {
|
||||
self.worktree_dialog = None;
|
||||
InputOutcome::Action(Action::DashboardConfirmWorktree { label })
|
||||
|
|
@ -4121,7 +4149,7 @@ impl DashboardState {
|
|||
/// chrome + picker pipeline via `handle_modal_key`.
|
||||
fn handle_shortcuts_modal_input(&mut self, ev: &Event) -> InputOutcome {
|
||||
use crate::views::shortcuts_help::{
|
||||
ModalKeyOutcome, ShortcutsHelpOutcome, handle_modal_key, handle_mouse,
|
||||
ModalKeyOutcome, ShortcutsHelpOutcome, handle_modal_key, handle_mouse, handle_paste,
|
||||
toggle_membership,
|
||||
};
|
||||
|
||||
|
|
@ -4213,6 +4241,10 @@ impl DashboardState {
|
|||
ShortcutsHelpOutcome::Unchanged => InputOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
Event::Paste(text) => match handle_paste(text, &mut modal.state, &modal.mode) {
|
||||
ShortcutsHelpOutcome::Changed => InputOutcome::Changed,
|
||||
_ => InputOutcome::Unchanged,
|
||||
},
|
||||
_ => InputOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
|
|
@ -4440,40 +4472,43 @@ fn dashboard_action_for_id(
|
|||
|
||||
fn handle_rename_key(draft: &mut RenameDraft, key: &KeyEvent) -> InputOutcome {
|
||||
use crate::input::key::is_altgr;
|
||||
// Reject Ctrl/Alt-modified character keys so
|
||||
// Ctrl+R / Ctrl+A / Ctrl+V don't smuggle a bare letter into the
|
||||
// draft. Ctrl+C is explicitly mapped to cancel.
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !is_altgr(key.modifiers)
|
||||
&& let KeyCode::Char(c) = key.code
|
||||
{
|
||||
if c == 'c' {
|
||||
match key.code {
|
||||
KeyCode::Esc => return InputOutcome::Action(Action::DashboardCancelRename),
|
||||
KeyCode::Enter if key.modifiers.is_empty() => {
|
||||
return InputOutcome::Action(Action::DashboardCommitRename);
|
||||
}
|
||||
KeyCode::Char('c')
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) && !is_altgr(key.modifiers) =>
|
||||
{
|
||||
return InputOutcome::Action(Action::DashboardCancelRename);
|
||||
}
|
||||
return InputOutcome::Unchanged;
|
||||
_ => {}
|
||||
}
|
||||
if key.modifiers.contains(KeyModifiers::ALT) && !is_altgr(key.modifiers) {
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Esc => InputOutcome::Action(Action::DashboardCancelRename),
|
||||
KeyCode::Enter => InputOutcome::Action(Action::DashboardCommitRename),
|
||||
KeyCode::Backspace => {
|
||||
draft.draft.pop();
|
||||
InputOutcome::Action(Action::DashboardRenameInput(draft.draft.clone()))
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
// Reject control characters and zero-width chars.
|
||||
if c.is_control() {
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
// Cap at 100 chars to match the worktree dialog input.
|
||||
if draft.draft.chars().count() < 100 {
|
||||
draft.draft.push(c);
|
||||
}
|
||||
InputOutcome::Action(Action::DashboardRenameInput(draft.draft.clone()))
|
||||
}
|
||||
_ => InputOutcome::Unchanged,
|
||||
|
||||
let can_insert = draft.text().chars().count() < MAX_RENAME_SCALARS;
|
||||
let outcome = draft
|
||||
.editor
|
||||
.handle_key_with_insert_policy(key, |character| {
|
||||
can_insert && rename_character_allowed(character)
|
||||
});
|
||||
rename_edit_outcome(outcome)
|
||||
}
|
||||
|
||||
fn handle_rename_paste(draft: &mut RenameDraft, text: &str) -> InputOutcome {
|
||||
let remaining = MAX_RENAME_SCALARS.saturating_sub(draft.text().chars().count());
|
||||
let outcome =
|
||||
draft
|
||||
.editor
|
||||
.insert_paste_with_policy(text, rename_wire_character_allowed, remaining);
|
||||
rename_edit_outcome(outcome)
|
||||
}
|
||||
|
||||
fn rename_edit_outcome(outcome: LineEditOutcome) -> InputOutcome {
|
||||
match outcome {
|
||||
LineEditOutcome::TextChanged
|
||||
| LineEditOutcome::HandledNoChange
|
||||
| LineEditOutcome::CursorChanged => InputOutcome::Changed,
|
||||
LineEditOutcome::Unhandled => InputOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5388,45 +5423,38 @@ mod tests {
|
|||
/// Rename cap is honored exactly.
|
||||
#[test]
|
||||
fn rename_at_cap_drops_extra_char() {
|
||||
let mut draft = RenameDraft {
|
||||
row: DashboardRowId::TopLevel(AgentId(0)),
|
||||
draft: "a".repeat(100),
|
||||
};
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "a".repeat(100));
|
||||
let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE);
|
||||
let _ = handle_rename_key(&mut draft, &key);
|
||||
assert_eq!(draft.draft.chars().count(), 100);
|
||||
let outcome = handle_rename_key(&mut draft, &key);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text().chars().count(), 100);
|
||||
assert!(
|
||||
draft.draft.ends_with('a'),
|
||||
draft.text().ends_with('a'),
|
||||
"char at cap should NOT be replaced: got {:?}",
|
||||
draft.draft
|
||||
draft.text()
|
||||
);
|
||||
}
|
||||
|
||||
/// under-cap appends correctly.
|
||||
#[test]
|
||||
fn rename_under_cap_appends() {
|
||||
let mut draft = RenameDraft {
|
||||
row: DashboardRowId::TopLevel(AgentId(0)),
|
||||
draft: "a".repeat(99),
|
||||
};
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "a".repeat(99));
|
||||
let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE);
|
||||
let _ = handle_rename_key(&mut draft, &key);
|
||||
assert_eq!(draft.draft.chars().count(), 100);
|
||||
assert!(draft.draft.ends_with('b'));
|
||||
let outcome = handle_rename_key(&mut draft, &key);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text().chars().count(), 100);
|
||||
assert!(draft.text().ends_with('b'));
|
||||
}
|
||||
|
||||
/// Ctrl+letter in rename mode rejected (does not type
|
||||
/// the bare letter into the draft); Ctrl+C cancels.
|
||||
#[test]
|
||||
fn rename_rejects_ctrl_chars() {
|
||||
let mut draft = RenameDraft {
|
||||
row: DashboardRowId::TopLevel(AgentId(0)),
|
||||
draft: "hello".to_string(),
|
||||
};
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "hello");
|
||||
let ctrl_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
|
||||
let outcome = handle_rename_key(&mut draft, &ctrl_r);
|
||||
assert!(matches!(outcome, InputOutcome::Unchanged));
|
||||
assert_eq!(draft.draft, "hello", "draft must not gain 'r'");
|
||||
assert_eq!(draft.text(), "hello", "draft must not gain 'r'");
|
||||
// Ctrl+C → cancel.
|
||||
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
|
||||
let outcome = handle_rename_key(&mut draft, &ctrl_c);
|
||||
|
|
@ -5436,6 +5464,125 @@ mod tests {
|
|||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_word_motion_is_canonical_and_cursor_only() {
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Left, KeyModifiers::ALT),
|
||||
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT),
|
||||
KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL),
|
||||
] {
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "hello-world");
|
||||
let outcome = handle_rename_key(&mut draft, &key);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text(), "hello-world");
|
||||
assert_eq!(draft.cursor_byte(), "hello-".len());
|
||||
}
|
||||
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Right, KeyModifiers::ALT),
|
||||
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT),
|
||||
] {
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "hello-world");
|
||||
let _ = handle_rename_key(
|
||||
&mut draft,
|
||||
&KeyEvent::new(KeyCode::Home, KeyModifiers::NONE),
|
||||
);
|
||||
let outcome = handle_rename_key(&mut draft, &key);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.cursor_byte(), "hello".len());
|
||||
}
|
||||
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "hello-world");
|
||||
let outcome = handle_rename_key(
|
||||
&mut draft,
|
||||
&KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT),
|
||||
);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text(), "hello-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_grapheme_delete_and_middle_insert() {
|
||||
let grapheme = "👩🏽\u{200d}💻";
|
||||
let mut draft = RenameDraft::new(
|
||||
DashboardRowId::TopLevel(AgentId(0)),
|
||||
format!("a{grapheme}b"),
|
||||
);
|
||||
let _ = handle_rename_key(
|
||||
&mut draft,
|
||||
&KeyEvent::new(KeyCode::Home, KeyModifiers::NONE),
|
||||
);
|
||||
let _ = handle_rename_key(
|
||||
&mut draft,
|
||||
&KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
|
||||
);
|
||||
let outcome = handle_rename_key(
|
||||
&mut draft,
|
||||
&KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text(), "ab");
|
||||
|
||||
let outcome = handle_rename_key(
|
||||
&mut draft,
|
||||
&KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text(), "aXb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_policy_and_paste_preserve_scalar_cap() {
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "a".repeat(99));
|
||||
let outcome = handle_rename_key(
|
||||
&mut draft,
|
||||
&KeyEvent::new(KeyCode::Char('\u{202e}'), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text().chars().count(), 99);
|
||||
|
||||
let outcome = handle_rename_paste(&mut draft, "中\r\n文");
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text().chars().count(), 100);
|
||||
assert!(draft.text().ends_with('中'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_enter_does_not_commit_rename() {
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "name");
|
||||
for modifiers in [KeyModifiers::ALT, KeyModifiers::SHIFT] {
|
||||
let outcome = handle_rename_key(&mut draft, &KeyEvent::new(KeyCode::Enter, modifiers));
|
||||
assert!(!matches!(
|
||||
outcome,
|
||||
InputOutcome::Action(Action::DashboardCommitRename)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_paste_preserves_emoji_zwj_sequences() {
|
||||
let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "");
|
||||
let outcome = handle_rename_paste(&mut draft, "👩💻");
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(draft.text(), "👩💻");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_mode_routes_bracketed_paste_only_to_rename_editor() {
|
||||
let mut state = DashboardState::new();
|
||||
state.dispatch.set_text("hidden dispatch");
|
||||
state.rename = Some(RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "ab"));
|
||||
let registry = crate::actions::ActionRegistry::defaults();
|
||||
let _ = state.handle_input(
|
||||
&Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)),
|
||||
®istry,
|
||||
);
|
||||
let outcome = state.handle_input(&Event::Paste("中\r\n".to_owned()), ®istry);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(state.rename.as_ref().map(RenameDraft::text), Some("a中b"));
|
||||
assert_eq!(state.dispatch.text(), "hidden dispatch");
|
||||
}
|
||||
|
||||
/// Esc-cancelling the worktree-label dialog must restore the stashed
|
||||
/// prompt (from the prompt-send path) to the dispatch input instead of
|
||||
/// silently discarding the user's typed text. Mirrors the restore in
|
||||
|
|
@ -8694,6 +8841,25 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_mode_cursor_only_edit_redraws_without_filter_change() {
|
||||
let mut state = DashboardState::new();
|
||||
let registry = crate::actions::ActionRegistry::defaults();
|
||||
state.enter_search_mode();
|
||||
state.dispatch.set_text("auth");
|
||||
state.dispatch.set_cursor(0);
|
||||
state.filter = Filter::Substring("auth".to_owned());
|
||||
|
||||
let outcome = state.handle_input(
|
||||
&Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
|
||||
®istry,
|
||||
);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(state.dispatch.text(), "auth");
|
||||
assert_eq!(state.dispatch.cursor(), 1);
|
||||
assert!(matches!(&state.filter, Filter::Substring(text) if text == "auth"));
|
||||
}
|
||||
|
||||
/// Esc in search mode CANCELS: clears the filter and exits.
|
||||
#[test]
|
||||
fn search_mode_esc_cancels_and_clears_filter() {
|
||||
|
|
@ -9361,7 +9527,7 @@ mod tests {
|
|||
let m = s.shortcuts_modal.as_ref().unwrap();
|
||||
(
|
||||
m.state.selected,
|
||||
m.state.query.clone(),
|
||||
m.state.query().to_owned(),
|
||||
m.filter_active,
|
||||
m.collapsed_sections.clone(),
|
||||
m.expanded_ids.clone(),
|
||||
|
|
@ -10119,7 +10285,7 @@ mod tests {
|
|||
location_candidate("/home/me/alpha", "alpha"),
|
||||
location_candidate("/home/me/beta", "beta"),
|
||||
]);
|
||||
lp.picker.query = "bet".to_string();
|
||||
lp.picker.set_query("bet");
|
||||
assert_eq!(visible_labels(&lp), vec!["beta"]);
|
||||
}
|
||||
|
||||
|
|
@ -10127,11 +10293,11 @@ mod tests {
|
|||
fn location_query_is_path_detection() {
|
||||
let mut lp = location_picker(vec![]);
|
||||
for q in ["/abs", "~/x", "rel/sub", "~"] {
|
||||
lp.picker.query = q.to_string();
|
||||
lp.picker.set_query(q);
|
||||
assert!(lp.query_is_path(), "`{q}` should be path mode");
|
||||
}
|
||||
for q in ["", "alpha", "bet"] {
|
||||
lp.picker.query = q.to_string();
|
||||
lp.picker.set_query(q);
|
||||
assert!(!lp.query_is_path(), "`{q}` should be recents mode");
|
||||
}
|
||||
}
|
||||
|
|
@ -10154,7 +10320,7 @@ mod tests {
|
|||
fn location_chosen_input_falls_back_to_typed_path() {
|
||||
let mut lp = location_picker(vec![location_candidate("/home/me/alpha", "alpha")]);
|
||||
// A path with no matching suggestion → the raw typed path is used.
|
||||
lp.picker.query = "/no/such/dir".to_string();
|
||||
lp.picker.set_query("/no/such/dir");
|
||||
assert_eq!(lp.chosen_input().as_deref(), Some("/no/such/dir"));
|
||||
}
|
||||
|
||||
|
|
@ -10177,7 +10343,7 @@ mod tests {
|
|||
let mut lp = location_picker(vec![]);
|
||||
|
||||
// Trailing slash → list the (non-hidden) subdirs.
|
||||
lp.picker.query = format!("{}/", tmp.path().display());
|
||||
lp.picker.set_query(format!("{}/", tmp.path().display()));
|
||||
lp.refresh_suggestions();
|
||||
let labels = visible_labels(&lp);
|
||||
assert!(labels.contains(&"alpha".to_string()), "got: {labels:?}");
|
||||
|
|
@ -10188,12 +10354,12 @@ mod tests {
|
|||
);
|
||||
|
||||
// Prefix filter on the final segment.
|
||||
lp.picker.query = format!("{}/al", tmp.path().display());
|
||||
lp.picker.set_query(format!("{}/al", tmp.path().display()));
|
||||
lp.refresh_suggestions();
|
||||
assert_eq!(visible_labels(&lp), vec!["alpha"]);
|
||||
|
||||
// A leading dot in the partial reveals dot-directories.
|
||||
lp.picker.query = format!("{}/.h", tmp.path().display());
|
||||
lp.picker.set_query(format!("{}/.h", tmp.path().display()));
|
||||
lp.refresh_suggestions();
|
||||
assert_eq!(visible_labels(&lp), vec![".hidden"]);
|
||||
}
|
||||
|
|
@ -10209,7 +10375,7 @@ mod tests {
|
|||
worktrees.insert(canon.join("wt"), "my-feature".to_string());
|
||||
|
||||
let mut lp = location_picker_with_worktrees(vec![], worktrees);
|
||||
lp.picker.query = format!("{}/", tmp.path().display());
|
||||
lp.picker.set_query(format!("{}/", tmp.path().display()));
|
||||
lp.refresh_suggestions();
|
||||
|
||||
let visible = lp.visible_candidates();
|
||||
|
|
@ -10239,7 +10405,7 @@ mod tests {
|
|||
worktrees.insert(real_canon, "linked-wt".to_string());
|
||||
|
||||
let mut lp = location_picker_with_worktrees(vec![], worktrees);
|
||||
lp.picker.query = format!("{}/", parent.path().display());
|
||||
lp.picker.set_query(format!("{}/", parent.path().display()));
|
||||
lp.refresh_suggestions();
|
||||
|
||||
let link = lp
|
||||
|
|
@ -10351,8 +10517,8 @@ mod tests {
|
|||
InputOutcome::Changed
|
||||
));
|
||||
let lp = state.location_picker.as_ref().unwrap();
|
||||
assert_eq!(lp.picker.query, "/opt/projects/beta/");
|
||||
assert_eq!(lp.picker.query_cursor, lp.picker.query.len());
|
||||
assert_eq!(lp.picker.query(), "/opt/projects/beta/");
|
||||
assert_eq!(lp.picker.query_cursor(), lp.picker.query().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -10360,7 +10526,7 @@ mod tests {
|
|||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("alpha")).unwrap();
|
||||
let mut lp = location_picker(vec![]);
|
||||
lp.picker.query = format!("{}/al", tmp.path().display());
|
||||
lp.picker.set_query(format!("{}/al", tmp.path().display()));
|
||||
lp.refresh_suggestions();
|
||||
|
||||
let mut state = DashboardState::new();
|
||||
|
|
@ -10458,19 +10624,21 @@ mod tests {
|
|||
let (id, mut agents) = lease_fixture_agent();
|
||||
let mut dash = DashboardState::new();
|
||||
dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents);
|
||||
{
|
||||
let page_flip_entry = {
|
||||
let sb = &mut agents.get_mut(&id).unwrap().scrollback;
|
||||
sb.prepare_layout(40, 6);
|
||||
let last = sb.len().saturating_sub(1);
|
||||
let entry_id = sb.entry(last).unwrap().id;
|
||||
sb.set_selected(Some(last));
|
||||
sb.scroll_to_entry_top(last);
|
||||
sb.enable_follow_with_preserve();
|
||||
}
|
||||
entry_id
|
||||
};
|
||||
assert!(agents[&id].scrollback.is_follow_preserve_scroll());
|
||||
dash.note_page_flip_for_lease(id, &mut agents);
|
||||
dash.note_page_flip_for_lease(id, page_flip_entry, &agents);
|
||||
assert_eq!(
|
||||
dash.peek_viewport.as_ref().and_then(|l| l.page_flip_entry),
|
||||
Some(agents[&id].scrollback.len().saturating_sub(1))
|
||||
Some(page_flip_entry)
|
||||
);
|
||||
|
||||
dash.restore_peek_viewport(&mut agents);
|
||||
|
|
@ -10513,11 +10681,17 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn note_page_flip_from_scroll_only_when_row_matches() {
|
||||
fn note_page_flip_only_when_row_and_entry_match() {
|
||||
let (id, mut agents) = lease_fixture_agent();
|
||||
let mut dash = DashboardState::new();
|
||||
dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents);
|
||||
dash.note_page_flip_from_scroll(AgentId(99), Some(3), Some(1));
|
||||
let entry_id = agents[&id].scrollback.entry(3).unwrap().id;
|
||||
agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.scrollback
|
||||
.enable_follow_with_preserve();
|
||||
dash.note_page_flip_for_lease(AgentId(99), entry_id, &agents);
|
||||
assert!(
|
||||
dash.peek_viewport
|
||||
.as_ref()
|
||||
|
|
@ -10525,11 +10699,45 @@ mod tests {
|
|||
.page_flip_entry
|
||||
.is_none()
|
||||
);
|
||||
dash.note_page_flip_from_scroll(id, Some(3), Some(1));
|
||||
dash.note_page_flip_for_lease(id, crate::scrollback::EntryId::new(u64::MAX), &agents);
|
||||
assert!(
|
||||
dash.peek_viewport
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.page_flip_entry
|
||||
.is_none()
|
||||
);
|
||||
dash.note_page_flip_for_lease(id, entry_id, &agents);
|
||||
let lease = dash.peek_viewport.as_ref().unwrap();
|
||||
assert_eq!(lease.page_flip_entry, Some(3));
|
||||
assert!(lease.snapshot.follow_preserve_scroll);
|
||||
assert_eq!(lease.snapshot.selected, Some(3));
|
||||
assert_eq!(lease.page_flip_entry, Some(entry_id));
|
||||
assert!(!lease.snapshot.follow_preserve_scroll);
|
||||
assert_eq!(lease.snapshot.selected, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_ignores_page_flip_entry_removed_during_lease() {
|
||||
let (id, mut agents) = lease_fixture_agent();
|
||||
let pre = agents[&id].scrollback.capture_viewport_snapshot();
|
||||
let mut dash = DashboardState::new();
|
||||
dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents);
|
||||
let entry_id = agents[&id].scrollback.entry(2).unwrap().id;
|
||||
agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.scrollback
|
||||
.enable_follow_with_preserve();
|
||||
dash.note_page_flip_for_lease(id, entry_id, &agents);
|
||||
agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.scrollback
|
||||
.remove_entry(entry_id);
|
||||
|
||||
dash.restore_peek_viewport(&mut agents);
|
||||
|
||||
assert!(dash.peek_viewport.is_none());
|
||||
assert_eq!(agents[&id].scrollback.selected(), pre.selected);
|
||||
assert_eq!(agents[&id].scrollback.is_follow_mode(), pre.follow_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -10549,21 +10757,22 @@ mod tests {
|
|||
},
|
||||
&mut agents,
|
||||
);
|
||||
dash.note_page_flip_from_scroll(id, Some(3), Some(1));
|
||||
let entry_id = agents[&id].scrollback.entry(3).unwrap().id;
|
||||
dash.note_page_flip_for_lease(id, entry_id, &agents);
|
||||
assert!(
|
||||
dash.peek_viewport
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.page_flip_entry
|
||||
.is_none(),
|
||||
"parent drain must not write parent indices onto a subagent lease"
|
||||
"parent drain must not write parent entries onto a subagent lease"
|
||||
);
|
||||
agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.scrollback
|
||||
.enable_follow_with_preserve();
|
||||
dash.note_page_flip_for_lease(id, &mut agents);
|
||||
dash.note_page_flip_for_lease(id, entry_id, &agents);
|
||||
assert!(
|
||||
dash.peek_viewport
|
||||
.as_ref()
|
||||
|
|
|
|||
Loading…
Reference in a new issue