Synced from monorepo

Synced from monorepo

Changes:
- Release a shell session's resources in one drop
- Make the tools blocking-wait cap client-configurable and self-describing
- Recognize API "exceeds budget" errors as context overflow
- Retry /btw on model overload
- Carry running background tasks and subagents across compaction
- Require round-trip time for SDK liveness checks
- Background-subagent completion reminders with a selectable delivery surface
- Make a PTY shell reap itself until it reaches the registry
- Recover the OS error code from a TLS-phase connection reset
- Consume the attached-client signal and report why idle is withheld
- Treat `.grok/sandbox.toml` edits as protected so auto mode prompts before writing
- Surface history/search in the Ctrl+. cheatsheet and keep it working in history view
- Delete sessions from the dashboard and welcome list
- Release a session's activity record when the session ends
- Stop charging auth-retry budget for fail-closed 401s; reset it across suspends
- Scope skills watches on project vendor roots
- Make [stop] cancel in-flight compaction
- Make the leader soak measure the leader, not its harness

Source-Revision: 8d69c91f02bcacf01e98d5aebbf2f92547c45738
This commit is contained in:
grokkybara[bot] 2026-07-31 18:08:03 +00:00
commit a422116582
165 changed files with 15161 additions and 1969 deletions

View file

@ -38,7 +38,7 @@ pub use render::{
};
pub use row::{
DashboardRow, RowBadge, build_rows, build_rows_with_roster, classify_subagent,
classify_top_level, sort_rows,
classify_top_level, roster_activity_to_state, sort_rows,
};
pub use state::{
DashboardDispatchMode, DashboardRowId, DashboardState, Filter, FilterValue, Focusable,

View file

@ -153,6 +153,13 @@ pub fn render_dashboard(
home,
roster,
);
// Chat-conversation roster rows can't be deleted from the dashboard
// yet — record them so the `[✗]` and Ctrl+X arm both skip them.
state.conversation_row_ids = roster
.iter()
.filter(|e| e.origin.kind == "conversation")
.map(|e| e.session_id.clone())
.collect();
state.reanchor_selection(&rows);
// DO NOT GC pinned/reorder at render time. The old
@ -592,6 +599,7 @@ fn render_dashboard_banner(
use ratatui::widgets::{Block, Borders, Widget};
state.row_rects.clear();
state.row_delete_rects.clear();
state.section_rects.clear();
if area.area() == 0 || area.height < 3 {
return;
@ -1548,6 +1556,7 @@ fn render_rows(
state: &mut DashboardState,
) {
state.row_rects.clear();
state.row_delete_rects.clear();
state.section_rects.clear();
state.idle_overflow_rect = None;
if area.area() == 0 {
@ -2157,7 +2166,7 @@ fn render_row(
rect: Rect,
theme: &Theme,
row: &DashboardRow,
state: &DashboardState,
state: &mut DashboardState,
) {
if rect.area() == 0 {
return;
@ -2293,25 +2302,57 @@ fn render_row(
Style::default().bg(bg).fg(icon_color),
);
// Age column — reserve up to 8 cells on the right edge (to fit
// "just now"). Uses coarse buckets: just now / m / h / d / mo / y.
let armed_delete = state.armed_delete_row_ref();
let show_delete = !row.is_more_placeholder
&& !row.id.is_subagent()
&& row.state.allows_delete()
&& !state.row_is_conversation(&row.id)
&& (state.hovered_row.as_ref() == Some(&row.id) || armed_delete == Some(&row.id));
let delete_label = crate::glyphs::ballot_x_button();
let delete_w = UnicodeWidthStr::width(delete_label) as u16;
let age = format_time_ago(row.last_change_at.elapsed().unwrap_or_default());
let age_str = format!("{age:>6}");
let age_w = UnicodeWidthStr::width(age_str.as_str()) as u16;
let age_x = rect.x + rect.width.saturating_sub(age_w + 1);
if age_x > content_start_x {
buf.set_string(
age_x,
title_y,
&age_str,
Style::default().bg(bg).fg(theme.gray),
);
let right_w = if show_delete { delete_w } else { age_w };
let right_x = rect.x + rect.width.saturating_sub(right_w + 1);
if right_x > content_start_x {
if show_delete {
let fg = if state.hovered_delete.as_ref() == Some(&row.id)
|| armed_delete == Some(&row.id)
{
theme.accent_error
} else {
theme.text_secondary
};
buf.set_string(
right_x,
title_y,
delete_label,
Style::default().bg(bg).fg(fg),
);
state.row_delete_rects.push((
row.id.clone(),
Rect {
x: right_x,
y: title_y,
width: delete_w,
height: 1,
},
));
} else {
buf.set_string(
right_x,
title_y,
&age_str,
Style::default().bg(bg).fg(theme.gray),
);
}
}
// Title text: `{label}` (bright) + ` · {subtitle}` (dim) +
// optional `[badge]` chips for failed / pinned.
// Trimmed to fit between the icon and the age column.
let title_avail = age_x.saturating_sub(content_start_x).saturating_sub(2);
let title_avail = right_x.saturating_sub(content_start_x).saturating_sub(2);
let mut cx = content_start_x;
if title_avail > 0 {
let label_style = Style::default().bg(bg).fg(if row.is_more_placeholder {
@ -2353,9 +2394,9 @@ fn render_row(
// Subtitle: ` · xai my-branch-2 worktree`.
if let Some(sub) = row.subtitle.as_deref()
&& cx + 4 < age_x
&& cx + 4 < right_x
{
let remaining = age_x.saturating_sub(cx).saturating_sub(2) as usize;
let remaining = right_x.saturating_sub(cx).saturating_sub(2) as usize;
let sub_str = format!(" \u{00B7} {sub}");
let sub_trunc = truncate_str(&sub_str, remaining);
let sub_w = UnicodeWidthStr::width(&sub_trunc[..]) as u16;
@ -2387,7 +2428,7 @@ fn render_row(
};
let chip = format!(" [{label}]");
let cw = UnicodeWidthStr::width(chip.as_str()) as u16;
if cx + cw + 1 < age_x {
if cx + cw + 1 < right_x {
buf.set_string(
cx,
title_y,
@ -2458,6 +2499,7 @@ fn render_narrow_rows(
state: &mut DashboardState,
) {
state.row_rects.clear();
state.row_delete_rects.clear();
state.section_rects.clear();
state.idle_overflow_rect = None;
if area.area() == 0 {
@ -2619,7 +2661,18 @@ fn render_narrow_rows(
let indent_w = UnicodeWidthStr::width(indent.as_str()) as u16;
let gap_after_marker = 1u16;
let chrome = marker_w + gap_after_marker + indent_w + icon_w + 1;
let label = truncate_str(&row.label, body_width.saturating_sub(chrome) as usize);
let armed_here = state.armed_delete_row_ref() == Some(&row.id);
let show_delete = !row.is_more_placeholder
&& !row.id.is_subagent()
&& row.state.allows_delete()
&& !state.row_is_conversation(&row.id)
&& (hovered || armed_here);
let delete_label = crate::glyphs::ballot_x_button();
let delete_w = UnicodeWidthStr::width(delete_label) as u16;
let label_budget = body_width
.saturating_sub(chrome)
.saturating_sub(if show_delete { delete_w + 1 } else { 0 });
let label = truncate_str(&row.label, label_budget as usize);
let line = format!("{marker} {indent}{icon} {label}");
buf.set_string(
area.x,
@ -2627,6 +2680,18 @@ fn render_narrow_rows(
line,
Style::default().fg(theme.text_primary).bg(bg),
);
if show_delete && body_width > chrome + delete_w {
let dx = area.x + body_width.saturating_sub(delete_w);
let fg = if state.hovered_delete.as_ref() == Some(&row.id) || armed_here {
theme.accent_error
} else {
theme.text_secondary
};
buf.set_string(dx, y, delete_label, Style::default().fg(fg).bg(bg));
state
.row_delete_rects
.push((row.id.clone(), Rect::new(dx, y, delete_w, 1)));
}
}
if !row.is_more_placeholder {
state.row_rects.push((row.id.clone(), line_rect));
@ -3303,19 +3368,11 @@ fn render_file_search_dropdown_for(
/// (no inline approve/reject yet — punted per the user's note
/// "maybe its just easier to hit enter and go details view";
/// the dashboard is intentionally a navigator, not a permission UI).
/// - Anything else → `Enter:open · Ctrl+x:stop|close · ?:shortcuts`.
/// - Anything else → `Enter:open · Ctrl+x:stop|delete · ?:shortcuts`.
///
/// The Ctrl+x chip label follows the selected agent's state: `stop`
/// for an agent with a live turn (Working, or NeedsInput — paused but
/// still running, so the first Ctrl+x cancels), `close` for an idle /
/// quiet one.
///
/// The ↑/↓ nav chip is intentionally omitted from every state — the
/// list is obviously arrow-navigable, and dropping it frees space so
/// the Ctrl+x chip stays visible while an agent is selected.
///
/// Stop-confirm still routes through `with_pending` so the canonical
/// `press again to close this session` message takes over.
/// still running, so the first Ctrl+x cancels), `delete` otherwise.
#[allow(clippy::too_many_arguments)]
fn render_footer(
buf: &mut Buffer,
@ -3358,27 +3415,31 @@ fn render_footer(
return;
}
// Only paint the "press again" hint while the confirm window is
// actually live — the dispatcher re-arms (rather than closes) on a
// press after [`super::state::STOP_CONFIRM_WINDOW`], so an expired
// confirm must not keep claiming the footer (e.g. after a mouse
// click moved the selection without a keypress to disarm it).
let stop_confirm_live = state
.stop_confirm
.as_ref()
.is_some_and(|(_, t)| t.elapsed() < super::state::STOP_CONFIRM_WINDOW);
if stop_confirm_live {
let stop_key = registry
.find(crate::actions::ActionId::DashboardStop)
.map(|d| d.default_key)
.unwrap_or_else(|| key!('x', CONTROL));
let pending = PendingHint {
shortcut: stop_key,
label: "close this session",
};
ShortcutsBar::new(&[])
.with_pending(Some(pending))
.render(inner, buf);
// A live delete-confirm owns the footer: `y`/`n` when the list is
// focused, else the second-`Ctrl+X` "press again" hint. An expired arm
// falls through to the normal hints.
if state.armed_delete_row_ref().is_some() {
if state.list_focused {
let hints = vec![
HintItem::new(key!('y'), "confirm delete"),
HintItem::new(key!('n'), "cancel"),
];
ShortcutsBar::new(&hints)
.compact(4, None)
.render(inner, buf);
} else {
let stop_key = registry
.find(crate::actions::ActionId::DashboardStop)
.map(|d| d.default_key)
.unwrap_or_else(|| key!('x', CONTROL));
let pending = PendingHint {
shortcut: stop_key,
label: "delete this session",
};
ShortcutsBar::new(&[])
.with_pending(Some(pending))
.render(inner, buf);
}
return;
}
@ -3410,23 +3471,16 @@ fn render_footer(
return;
}
// A selected Inactive row is roster-only (owned by another pager
// process, never loaded here) — there's nothing running to stop,
// so every branch below suppresses its `stop` chip.
let stoppable = selected_state != Some(RowState::Inactive);
// Ctrl+x cancels the live turn for a busy agent, else closes the
// session — mirroring `dispatch_dashboard_stop` (cancel-if-running,
// else close). A `NeedsInput` row keeps a paused-but-running turn (the
// permission/Q&A prompt suspends it, never idles it), so its first
// Ctrl+x cancels too — label it `stop`, not `close`.
let show_ctrl_x = selected_state.is_some_and(|s| {
matches!(s, RowState::Working | RowState::NeedsInput) || s.allows_delete()
});
let stop_label = if matches!(
selected_state,
Some(RowState::Working | RowState::NeedsInput)
) {
"stop"
} else {
"close"
"delete"
};
// Overview list focused (via Tab) — navigation hints: arrows / j-k
@ -3488,11 +3542,10 @@ fn render_footer(
HintItem::new(key!(Enter), "open"),
HintItem::new(key!(Tab), "input"),
];
if stoppable {
// Pinned so the stop chip always survives compact
// truncation while an agent row is selected.
if show_ctrl_x {
hints.push(HintItem::new(stop, stop_label).pinned());
}
ShortcutsBar::new(&hints)
.compact(4, Some(HintItem::new(help, "shortcuts")))
.render(inner, buf);
@ -3590,7 +3643,7 @@ fn render_footer(
tab_hint,
esc_hint,
];
if stoppable {
if show_ctrl_x {
h.push(HintItem::new(stop, stop_label).pinned());
}
h
@ -3611,7 +3664,7 @@ fn render_footer(
if !reply_empty {
h.insert(1, HintItem::new(send_open, "send+open"));
}
if stoppable {
if show_ctrl_x {
h.push(HintItem::new(stop, stop_label).pinned());
}
h
@ -3623,7 +3676,7 @@ fn render_footer(
tab_hint,
esc_hint,
];
if stoppable {
if show_ctrl_x {
h.push(HintItem::new(stop, stop_label).pinned());
}
h
@ -3639,7 +3692,7 @@ fn render_footer(
// bare Enter still attaches.
let open_key = if peek_focused { send_key } else { enter };
let mut h = vec![HintItem::new(open_key, "open"), tab_hint, esc_hint];
if stoppable {
if show_ctrl_x {
h.push(HintItem::new(stop, stop_label).pinned());
}
h
@ -3711,22 +3764,13 @@ fn render_footer(
h.push(HintItem::new(send_key, "send"));
h.push(HintItem::new(send_open, "send+open"));
}
if stoppable {
// Pinned so Ctrl+x always shows while an agent row is
// selected, even if earlier chips would otherwise fill the
// compact bar.
if show_ctrl_x {
h.push(HintItem::new(stop, stop_label).pinned());
}
h
} else {
// Defensive — neither the button nor a row is focused.
// Should never happen given the invariant on
// `DashboardState`, but a fall-through keeps the bar
// populated rather than silently empty.
vec![
HintItem::new(send_key, "create"),
HintItem::new(stop, stop_label),
]
vec![HintItem::new(send_key, "create")]
};
ShortcutsBar::new(&hints)
@ -4437,6 +4481,57 @@ mod tests {
);
}
/// Hover `[✗]` paints only on settled rows, never on a busy one.
#[test]
fn render_dashboard_hover_shows_delete_x_only_for_settled_rows() {
use crate::app::roster::{RosterActivity, RosterEntry, RosterOrigin};
let ballot = crate::glyphs::ballot_x_button();
let render_with = |activity: RosterActivity| -> String {
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-hover".into(),
title: Some("Hover me".into()),
cwd: "/repo/work".into(),
is_worktree: false,
model_id: None,
yolo: false,
activity,
resident: true,
last_change_unix_ms: 1_725_000_000_000,
origin: RosterOrigin::default(),
}];
state.hovered_row = Some(DashboardRowId::Roster {
session_id: "sess-hover".into(),
});
let _ = render_dashboard(
&mut buf,
area,
&mut state,
&mut agents,
&registry,
None,
&roster,
false,
None,
);
buf_to_text(&buf)
};
assert!(
render_with(RosterActivity::Completed).contains(ballot),
"hovering a settled (completed) row must show the [✗] delete affordance",
);
assert!(
!render_with(RosterActivity::Working).contains(ballot),
"hovering a busy (working) row must NOT show the [✗] delete affordance",
);
}
/// While the local session roster is still loading the empty body
/// shows a loading hint instead of the "no agents" copy.
#[test]
@ -5257,12 +5352,12 @@ mod tests {
#[test]
fn render_row_centers_title_only_content() {
let theme = Theme::current();
let state = DashboardState::new();
let mut state = DashboardState::new();
// Title-only → centered on the middle line.
let row = header_test_row(1, RowState::Idle, "solo");
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3));
render_row(&mut buf, Rect::new(0, 0, 40, 3), &theme, &row, &state);
render_row(&mut buf, Rect::new(0, 0, 40, 3), &theme, &row, &mut state);
assert_eq!(buf[(4, 1)].symbol(), "s", "title must sit on line 1");
assert_eq!(buf[(4, 0)].symbol(), " ", "line 0 must be padding");
assert_eq!(buf[(4, 2)].symbol(), " ", "line 2 must be padding");
@ -5271,7 +5366,7 @@ mod tests {
let mut row = header_test_row(2, RowState::Working, "pair");
row.secondary_line = Some("Responding".to_string());
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3));
render_row(&mut buf, Rect::new(0, 0, 40, 3), &theme, &row, &state);
render_row(&mut buf, Rect::new(0, 0, 40, 3), &theme, &row, &mut state);
assert_eq!(buf[(4, 0)].symbol(), "p", "title must sit on line 0");
assert_eq!(buf[(4, 1)].symbol(), "R", "secondary must sit on line 1");
assert_eq!(buf[(4, 2)].symbol(), " ", "line 2 must be padding");
@ -6787,7 +6882,7 @@ mod tests {
is_more_placeholder: false,
more_count: 0,
};
render_row(&mut buf, Rect::new(0, 0, 100, 2), &theme, &row, &state);
render_row(&mut buf, Rect::new(0, 0, 100, 2), &theme, &row, &mut state);
// Title row.
assert_eq!(
@ -6877,13 +6972,13 @@ mod tests {
// Unselected → dim secondary.
let mut buf = Buffer::empty(Rect::new(0, 0, 100, 2));
let state_unselected = DashboardState::new();
let mut state_unselected = DashboardState::new();
render_row(
&mut buf,
Rect::new(0, 0, 100, 2),
&theme,
&row,
&state_unselected,
&mut state_unselected,
);
assert_eq!(
buf[(4, 1)].fg,
@ -6900,7 +6995,7 @@ mod tests {
Rect::new(0, 0, 100, 2),
&theme,
&row,
&state_selected,
&mut state_selected,
);
assert_eq!(
buf[(4, 1)].fg,
@ -6946,7 +7041,7 @@ mod tests {
Rect::new(0, 0, 100, 2),
&theme,
&make_row(),
&state,
&mut state,
);
buf
};
@ -7007,7 +7102,7 @@ mod tests {
use std::time::SystemTime;
let mut buf = Buffer::empty(Rect::new(0, 0, 100, 2));
let theme = Theme::current();
let state = DashboardState::new();
let mut state = DashboardState::new();
let row = DashboardRow {
id: DashboardRowId::TopLevel(crate::app::agent::AgentId(1)),
label: "New session #abc12345".to_string(),
@ -7027,7 +7122,7 @@ mod tests {
is_more_placeholder: false,
more_count: 0,
};
render_row(&mut buf, Rect::new(0, 0, 100, 2), &theme, &row, &state);
render_row(&mut buf, Rect::new(0, 0, 100, 2), &theme, &row, &mut state);
// Title starts at col 4: "New session" (11 chars, cols 4..15) then
// " #abc12345" (suffix from col 15).
@ -8030,10 +8125,6 @@ mod tests {
);
}
/// When peek is active, the footer flips to peek-
/// mode hints (`enter:open · esc:New Agent · ctrl+x:close`). The
/// nav chip is dropped (saving space) and the Ctrl+x chip stays
/// visible while the agent is selected.
#[test]
fn render_footer_peek_mode_shows_peek_hints() {
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
@ -8046,8 +8137,8 @@ mod tests {
&theme,
&state,
&registry,
None,
true, // peek_active
Some(RowState::Idle),
true,
None,
);
let content = buf_to_text(&buf);
@ -8055,11 +8146,9 @@ mod tests {
content.contains(":open") && content.contains(":New Agent"),
"peek-mode footer must include open + New Agent (unselect) hints, got: {content:?}",
);
// The stop chip stays visible while an agent is selected. With
// no row state passed (None) the label is the idle-style `close`.
assert!(
content.contains(":close"),
"peek-mode footer must keep the Ctrl+x stop chip, got: {content:?}",
content.contains(":delete"),
"peek-mode footer must keep the Ctrl+x delete chip, got: {content:?}",
);
// The nav chip is dropped to save bottom-bar space.
assert!(
@ -8373,10 +8462,8 @@ mod tests {
);
}
/// An Inactive (roster-only) selection has nothing running to stop —
/// the stop chip is suppressed in both focus modes.
#[test]
fn render_footer_inactive_row_hides_stop() {
fn render_footer_inactive_row_shows_delete() {
let theme = Theme::current();
let registry = crate::actions::ActionRegistry::defaults();
@ -8396,15 +8483,14 @@ mod tests {
);
let content = buf_to_text(&buf);
assert!(
!content.contains(":stop") && !content.contains(":close"),
"inactive row footer must NOT show the stop chip, got: {content:?}",
content.contains(":delete"),
"list-focused idle-row footer must show the delete chip, got: {content:?}",
);
assert!(
content.contains(":open"),
"inactive row footer keeps the open chip, got: {content:?}",
"list-focused idle-row footer must show the open chip, got: {content:?}",
);
// List focused (Tab) — same suppression.
state.list_focused = true;
let mut buf2 = Buffer::empty(Rect::new(0, 0, 200, 1));
render_footer(
@ -8418,13 +8504,8 @@ mod tests {
None,
);
let content2 = buf_to_text(&buf2);
assert!(
!content2.contains(":stop") && !content2.contains(":close"),
"list-focused inactive footer must NOT show the stop chip, got: {content2:?}",
);
assert!(content2.contains(":delete"), "{content2:?}");
// Control: an Idle selection keeps the stop chip in both modes —
// labelled `close` (the session is idle, so Ctrl+x closes it).
state.list_focused = false;
let mut buf3 = Buffer::empty(Rect::new(0, 0, 200, 1));
render_footer(
@ -8438,16 +8519,9 @@ mod tests {
None,
);
let content3 = buf_to_text(&buf3);
assert!(
content3.contains(":close"),
"idle row footer must keep the stop chip labelled `close`, got: {content3:?}",
);
assert!(content3.contains(":delete"), "{content3:?}");
}
/// The Ctrl+x chip label follows the selected agent's state: a
/// Working or NeedsInput agent shows `stop` (cancel the turn — a
/// NeedsInput row keeps a paused-but-running turn), while an idle /
/// quiet one shows `close` (close the session).
#[test]
fn render_footer_stop_label_follows_state() {
let theme = Theme::current();
@ -8492,7 +8566,7 @@ mod tests {
"NeedsInput agent footer must label Ctrl+x as `stop`, got: {needs_input:?}",
);
// Idle → `close`.
// Idle → `delete`.
let mut buf2 = Buffer::empty(Rect::new(0, 0, 200, 1));
render_footer(
&mut buf2,
@ -8506,8 +8580,8 @@ mod tests {
);
let idle = buf_to_text(&buf2);
assert!(
idle.contains(":close") && !idle.contains(":stop"),
"Idle agent footer must label Ctrl+x as `close`, got: {idle:?}",
idle.contains(":delete") && !idle.contains(":stop"),
"Idle agent footer must label Ctrl+x as `delete`, got: {idle:?}",
);
}
@ -8832,17 +8906,14 @@ mod tests {
);
}
/// Stop-confirm armed routes through `ShortcutsBar::with_pending`.
/// Delete-confirm armed while the input is focused routes through
/// `ShortcutsBar::with_pending` ("press Ctrl+x again to delete").
#[test]
fn render_footer_stop_confirm_uses_pending_hint() {
use std::time::Instant;
fn render_footer_delete_confirm_uses_pending_hint() {
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
let theme = Theme::current();
let mut state = DashboardState::new();
state.stop_confirm = Some((
DashboardRowId::TopLevel(crate::app::agent::AgentId(1)),
Instant::now(),
));
state.arm_delete(DashboardRowId::TopLevel(crate::app::agent::AgentId(1)));
let registry = crate::actions::ActionRegistry::defaults();
render_footer(
&mut buf,
@ -8860,26 +8931,26 @@ mod tests {
"stop-confirm footer must say `press again`, got: {content:?}",
);
assert!(
content.to_lowercase().contains("close this session"),
"stop-confirm footer must mention closing the session, got: {content:?}",
content.to_lowercase().contains("delete this session"),
"delete-confirm footer must name the action, got: {content:?}",
);
}
/// An EXPIRED stop-confirm (older than `STOP_CONFIRM_WINDOW`) must
/// An EXPIRED delete-confirm (older than `CONFIRM_WINDOW`) must
/// not claim the footer — the dispatcher would re-arm rather than
/// close on the next press, so "press again" would lie. Regular
/// delete on the next press, so "press again" would lie. Regular
/// hints render instead (e.g. after a mouse click moved the
/// selection without a keypress to disarm the confirm).
#[test]
fn render_footer_expired_stop_confirm_shows_regular_hints() {
fn render_footer_expired_delete_confirm_shows_regular_hints() {
use std::time::{Duration, Instant};
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
let theme = Theme::current();
let mut state = DashboardState::new();
state.focus_row(DashboardRowId::TopLevel(crate::app::agent::AgentId(1)));
state.stop_confirm = Some((
state.delete_confirm = Some((
DashboardRowId::TopLevel(crate::app::agent::AgentId(1)),
Instant::now() - (super::super::state::STOP_CONFIRM_WINDOW + Duration::from_secs(1)),
Instant::now() - (super::super::state::CONFIRM_WINDOW + Duration::from_secs(1)),
));
let registry = crate::actions::ActionRegistry::defaults();
render_footer(

View file

@ -253,7 +253,9 @@ fn build_local_rows(
rows
}
/// Map a leader [`RosterActivity`] to the dashboard's coarse [`RowState`].
fn roster_activity_to_state(activity: RosterActivity) -> RowState {
/// Public so the dispatcher can gate roster-row deletion through the very
/// same `RowState::allows_delete` predicate the renderer paints `[✗]` with.
pub fn roster_activity_to_state(activity: RosterActivity) -> RowState {
match activity {
RosterActivity::Working => RowState::Working,
RosterActivity::NeedsInput => RowState::NeedsInput,

View file

@ -181,11 +181,10 @@ impl PersistedRowId {
}
}
/// Window within which a second `Ctrl+X` press confirms closing the
/// selected agent. Shared by the dispatcher (which gates the actual
/// close) and the footer (which only paints the "press again" hint
/// while the window is live).
pub const STOP_CONFIRM_WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
/// Window within which a second confirming gesture (`Ctrl+X`, a `[✗]`
/// click, or `y`) deletes the armed row. Also reused by the
/// dashboard-overlay stop for its double-press close confirm.
pub const CONFIRM_WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
/// Coarse state used for the dashboard grouping.
///
@ -215,6 +214,17 @@ pub enum RowState {
}
impl RowState {
/// The one predicate for "may be deleted", shared by the renderer's
/// `[✗]` and the dispatcher: only settled rows qualify. `Working` /
/// `NeedsInput` are excluded so an in-flight turn is never wiped —
/// `Ctrl+X` cancels those instead.
pub fn allows_delete(self) -> bool {
matches!(
self,
Self::Idle | Self::Inactive | Self::Completed | Self::Failed
)
}
/// Sort priority used inside a state group: higher = floats up.
/// Pinned rows always float to the absolute top regardless of state.
pub fn group_priority(self) -> u8 {
@ -493,11 +503,10 @@ pub struct DashboardState {
/// exists"). Rendered verbatim by `paint_dispatch_feedback_badge`;
/// error messages are built via [`Self::set_error_toast`].
pub error_toast: Option<String>,
/// Pending stop confirmation. `Some((row, set_at))` after the first
/// `Ctrl+X` press on a top-level row. The second press within
/// [`STOP_CONFIRM_WINDOW`] closes the agent. Mirrors the session-close
/// close-confirm pattern.
pub stop_confirm: Option<(DashboardRowId, Instant)>,
/// Row armed for delete, and when. A second gesture on the same row
/// within [`CONFIRM_WINDOW`] deletes it (see [`Self::armed_delete_row`]);
/// otherwise it lapses. Cleared on any focus change.
pub delete_confirm: Option<(DashboardRowId, Instant)>,
/// Tick counter for spinner animation. The
/// counter is bumped by [`crate::app::app_view::AppView::tick`]
/// (NOT the renderer, which is read-only).
@ -508,6 +517,15 @@ pub struct DashboardState {
/// mouse handling to map (col, row) → row id without scanning the
/// row list a second time.
pub row_rects: Vec<(DashboardRowId, Rect)>,
/// Per-row `[✗]` hit areas, rebuilt each render; maps a click onto the
/// delete gesture instead of a row select.
pub row_delete_rects: Vec<(DashboardRowId, Rect)>,
/// Row whose `[✗]` the mouse is over, so the renderer can tint it.
pub hovered_delete: Option<DashboardRowId>,
/// Roster session ids whose origin is a chat `conversation` — those
/// can't be deleted from the dashboard yet, so they get no `[✗]` and
/// don't arm. Rebuilt each render from the roster.
pub conversation_row_ids: std::collections::HashSet<String>,
/// Last frame's section-header hit areas keyed by [`SectionKey`].
/// Used by mouse handling to map (col, row) → section for
/// click-to-toggle and hover. Rebuilt every render.
@ -1351,9 +1369,12 @@ impl DashboardState {
peek_reply_target_cwd: None,
rename: None,
error_toast: None,
stop_confirm: None,
delete_confirm: None,
spinner_tick: 0,
row_rects: Vec::new(),
row_delete_rects: Vec::new(),
hovered_delete: None,
conversation_row_ids: std::collections::HashSet::new(),
section_rects: Vec::new(),
idle_overflow_rect: None,
last_area: Rect::default(),
@ -1474,6 +1495,7 @@ impl DashboardState {
self.selected = None;
self.selected_section = None;
self.selected_idle_overflow = false;
self.delete_confirm = None;
}
/// Focus the row identified by `id`. Clears the
@ -1483,6 +1505,13 @@ impl DashboardState {
/// risk — the invariant only holds when both fields are
/// written through here.
pub fn focus_row(&mut self, id: DashboardRowId) {
if self
.delete_confirm
.as_ref()
.is_some_and(|(armed, _)| armed != &id)
{
self.delete_confirm = None;
}
self.selected = Some(id);
self.new_agent_button_focused = false;
self.selected_section = None;
@ -1497,6 +1526,7 @@ impl DashboardState {
self.selected = None;
self.new_agent_button_focused = false;
self.selected_idle_overflow = false;
self.delete_confirm = None;
}
/// Focus the Idle group's "N more" overflow toggle —
@ -1507,6 +1537,62 @@ impl DashboardState {
self.selected = None;
self.selected_section = None;
self.new_agent_button_focused = false;
self.delete_confirm = None;
}
fn set_list_focused(&mut self, focused: bool) {
self.list_focused = focused;
if !focused {
self.delete_confirm = None;
}
}
/// The armed row while its [`CONFIRM_WINDOW`] is still live, clearing
/// an expired arm as a side effect. The accessor the dispatcher and
/// mouse handler share so "armed on screen" and "armed for delete"
/// never diverge.
pub fn armed_delete_row(&mut self) -> Option<DashboardRowId> {
match &self.delete_confirm {
Some((id, at)) if at.elapsed() < CONFIRM_WINDOW => Some(id.clone()),
Some(_) => {
self.delete_confirm = None;
None
}
None => None,
}
}
/// Read-only counterpart of [`Self::armed_delete_row`] for the
/// renderer (does not clear an expired arm).
pub fn armed_delete_row_ref(&self) -> Option<&DashboardRowId> {
self.delete_confirm
.as_ref()
.filter(|(_, at)| at.elapsed() < CONFIRM_WINDOW)
.map(|(id, _)| id)
}
pub fn arm_delete(&mut self, id: DashboardRowId) {
self.delete_confirm = Some((id, Instant::now()));
}
/// Whether `id` is a chat-conversation roster row, which the dashboard
/// can't delete yet (see [`Self::conversation_row_ids`]).
pub fn row_is_conversation(&self, id: &DashboardRowId) -> bool {
matches!(id, DashboardRowId::Roster { session_id }
if self.conversation_row_ids.contains(session_id))
}
/// Enforce the invariant that a delete arm belongs to the selected
/// row. Selection changes routed through the focus helpers already
/// disarm, but `reanchor_selection` / `gc_stale_refs` can drop or move
/// `selected` directly — without this a stale arm would let a later
/// `y` delete a row that is no longer selected.
fn sync_delete_confirm_to_selection(&mut self) {
if let Some((armed, _)) = self.delete_confirm.as_ref()
&& self.selected.as_ref() != Some(armed)
{
self.delete_confirm = None;
}
}
/// Toggle whether the Idle group shows every agent (`true`) or caps
@ -1683,6 +1769,7 @@ impl DashboardState {
// holds at every close site, not just here.
self.close_popup();
}
self.sync_delete_confirm_to_selection();
}
/// Switch grouping (`Ctrl+G`).
@ -3001,8 +3088,37 @@ impl DashboardState {
InputOutcome::Action(Action::DashboardDispatch { text, attach })
}
/// List-focused `y`/`n` confirm for an already-armed delete (arming is
/// via `Ctrl+X` / `[✗]`, not `d`). When the list isn't focused,
/// disarming is left to the caller so a second `Ctrl+X` reaches the
/// dispatcher.
fn handle_delete_confirm_key(&mut self, key: &KeyEvent) -> Option<InputOutcome> {
if key.kind == KeyEventKind::Release {
return None;
}
if !self.list_focused {
return None;
}
self.armed_delete_row()?;
if !key.modifiers.is_empty() {
self.delete_confirm = None;
return None;
}
match key.code {
KeyCode::Char('y') => Some(InputOutcome::Action(Action::DashboardDelete)),
KeyCode::Char('n') => {
self.delete_confirm = None;
Some(InputOutcome::Changed)
}
_ => {
self.delete_confirm = None;
None
}
}
}
fn handle_key(&mut self, key: &KeyEvent, registry: &ActionRegistry) -> InputOutcome {
// Resolve the registry binding up-front — the toast / stop-confirm
// Resolve the registry binding up-front — the toast / delete-confirm
// clear below needs to know whether this key IS the stop key, and
// it must run before the peek intercept (the lookup itself is a
// pure read; the action is honoured further down).
@ -3017,37 +3133,26 @@ impl DashboardState {
let from_registry =
registry.lookup_with_mode(key, crate::actions::When::DashboardFocused, vim_mode);
// Clear `error_toast` at the TOP of the
// handler so any subsequent keypress dismisses the toast,
// regardless of which branch handles the key (including keys
// the peek panel consumes — peek is open by default for a
// selected row, so nav keys route through it).
//
// When the toast is cleared, the linked
// `stop_confirm` armed state is also cleared. The two state
// bits are semantically linked: the user saw "Press Ctrl+X
// again", that hint is now gone, so re-arm rather than let a
// stale confirm window silently close the wrong session.
//
// The clear is SKIPPED when the resolved
// action is `DashboardStop`. Without this skip, the second
// Ctrl+X press would wipe the just-armed `stop_confirm`
// before `dispatch_dashboard_stop` could observe it, and the
// session would never close (the dispatcher kept re-arming a
// fresh confirm on every press). The Ctrl+X path owns
// `stop_confirm` and `error_toast` end-to-end: the first
// press arms both, the second press observes them and closes.
let preserve_stop_state =
matches!(from_registry, Some(crate::actions::ActionId::DashboardStop));
if !preserve_stop_state {
// Clear `error_toast` on any keypress so it never lingers; kept for
// `Ctrl+X` so the arm path's own messaging survives its first press.
let is_stop_key = matches!(from_registry, Some(crate::actions::ActionId::DashboardStop));
if !is_stop_key {
self.error_toast = None;
// The disarm is NOT gated on `error_toast` being set (the
// Ctrl+X arm path deliberately plants no toast): a pending
// stop confirmation is bound to the row that was selected
// when Ctrl+X was pressed, so any other key — nav included —
// must disarm it. Otherwise the footer's "press again to
// close" hint lingers while the cursor moves to other agents.
self.stop_confirm = None;
}
// Disarm delete-confirm on any non-confirming key. Two gestures are
// preserved: `Ctrl+X` (its second press is the confirm, read by the
// dispatcher) and a list-focused bare `y`/`n` (handled just below).
let confirm_via_yn = self.list_focused
&& self.armed_delete_row().is_some()
&& key.modifiers.is_empty()
&& matches!(key.code, KeyCode::Char('y') | KeyCode::Char('n'));
if !is_stop_key && !confirm_via_yn {
self.delete_confirm = None;
}
if !is_stop_key && let Some(outcome) = self.handle_delete_confirm_key(key) {
return outcome;
}
// Free-tier override: Ctrl+O opens the pinned upgrade CTA (when one is
@ -3405,6 +3510,13 @@ impl DashboardState {
}
_ => true,
};
// Never let an auto-repeat (held key) drive the destructive
// Ctrl+X arm→confirm — holding the key would arm and immediately
// confirm a delete. Require discrete presses, like the picker's
// `y` confirm. Non-destructive actions may still repeat.
if id == crate::actions::ActionId::DashboardStop && key.kind == KeyEventKind::Repeat {
return InputOutcome::Unchanged;
}
if honor && let Some(outcome) = dashboard_action_for_id(id, &mut self.error_toast) {
return outcome;
}
@ -3483,7 +3595,7 @@ impl DashboardState {
// slash / `@` dropdowns are open the intercepts above already
// consumed Tab (accept completion), so this only fires otherwise.
if matches!(key.code, KeyCode::Tab) && key.modifiers.is_empty() {
self.list_focused = !self.list_focused;
self.set_list_focused(!self.list_focused);
// Re-engage selection-follow so the viewport tracks the
// cursor once the list takes focus.
self.clear_manual_scroll();
@ -3502,12 +3614,12 @@ impl DashboardState {
{
if vim_mode {
if key.code == KeyCode::Char('i') && key.modifiers.is_empty() {
self.list_focused = false;
self.set_list_focused(false);
return InputOutcome::Changed;
}
return InputOutcome::Unchanged;
}
self.list_focused = false;
self.set_list_focused(false);
// fall through to the widget so the char is typed.
} else {
// Non-printable (Backspace/Home/…) while the overview is
@ -3636,6 +3748,20 @@ impl DashboardState {
self.hovered_row = new_hover;
changed = true;
}
let new_hover_delete = self
.row_delete_rects
.iter()
.find(|(_, r)| {
mouse.column >= r.x
&& mouse.column < r.x + r.width
&& mouse.row >= r.y
&& mouse.row < r.y + r.height
})
.map(|(id, _)| id.clone());
if new_hover_delete != self.hovered_delete {
self.hovered_delete = new_hover_delete;
changed = true;
}
// Section-header hover → the renderer brightens its text.
let new_hover_section = self
.section_rects
@ -3773,7 +3899,7 @@ impl DashboardState {
self.dispatch.accept_slash_completion(&self.models);
}
}
self.list_focused = false;
self.set_list_focused(false);
return InputOutcome::Changed;
}
@ -3821,7 +3947,29 @@ impl DashboardState {
}
}
}
self.list_focused = false;
self.set_list_focused(false);
return InputOutcome::Changed;
}
if let Some(id) = self
.row_delete_rects
.iter()
.find(|(_, r)| {
mouse.column >= r.x
&& mouse.column < r.x + r.width
&& mouse.row >= r.y
&& mouse.row < r.y + r.height
})
.map(|(id, _)| id.clone())
{
self.manual_scroll_active = false;
// Second `[✗]` click within the window confirms; else re-arm.
if self.armed_delete_row().as_ref() == Some(&id) {
return InputOutcome::Action(Action::DashboardDelete);
}
self.focus_row(id.clone());
self.set_list_focused(true);
self.arm_delete(id);
return InputOutcome::Changed;
}
@ -3934,7 +4082,7 @@ impl DashboardState {
&& mouse.row >= rect.y
&& mouse.row < rect.y + rect.height
{
self.list_focused = false;
self.set_list_focused(false);
// Forward the click so the caret lands where the user
// clicked. Skipped in search mode, where the prompt
// renders its own single-line cursor with a `Search:`
@ -4334,6 +4482,7 @@ impl DashboardState {
rows.iter().filter(|r| !r.is_more_placeholder).collect();
if selectable.is_empty() {
self.selected = None;
self.delete_confirm = None;
return;
}
if let Some(sel) = self.selected.as_ref()
@ -4344,6 +4493,7 @@ impl DashboardState {
// the user's job.
self.selected = None;
}
self.sync_delete_confirm_to_selection();
}
}
@ -9586,33 +9736,33 @@ mod tests {
);
}
/// An armed stop confirmation is bound to the row that was selected
/// An armed delete confirmation is bound to the row that was selected
/// when `Ctrl+X` was pressed — any other key (nav included) must
/// disarm it, otherwise the footer's "press again to close" hint
/// disarm it, otherwise the footer's "press again to delete" hint
/// lingers while the cursor moves to other agents. The disarm must
/// NOT depend on `error_toast` (the Ctrl+X arm path plants none).
#[test]
fn nav_key_disarms_pending_stop_confirm() {
fn nav_key_disarms_pending_delete_confirm() {
let mut state = DashboardState::new();
let reg = crate::actions::ActionRegistry::defaults();
state.focus_row(DashboardRowId::TopLevel(AgentId(0)));
state.stop_confirm = Some((DashboardRowId::TopLevel(AgentId(0)), Instant::now()));
state.arm_delete(DashboardRowId::TopLevel(AgentId(0)));
assert!(state.error_toast.is_none(), "arm path plants no toast");
let _ = state.handle_key(&KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &reg);
assert!(
state.stop_confirm.is_none(),
"a nav keypress must disarm the pending stop confirm",
state.delete_confirm.is_none(),
"a nav keypress must disarm the pending delete confirm",
);
// Control — Ctrl+X itself preserves the armed confirm so the
// dispatcher can observe it and close.
state.stop_confirm = Some((DashboardRowId::TopLevel(AgentId(0)), Instant::now()));
// dispatcher can observe it and delete.
state.arm_delete(DashboardRowId::TopLevel(AgentId(0)));
let _ = state.handle_key(
&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
&reg,
);
assert!(
state.stop_confirm.is_some(),
state.delete_confirm.is_some(),
"Ctrl+X must preserve the armed confirm for the dispatcher",
);
@ -9620,18 +9770,117 @@ mod tests {
// row, and `handle_peek_key` CONSUMES Up/Down (agent switch) —
// the disarm must sit above that intercept or nav keys never
// reach it and the footer hint lingers.
state.stop_confirm = Some((DashboardRowId::TopLevel(AgentId(0)), Instant::now()));
state.arm_delete(DashboardRowId::TopLevel(AgentId(0)));
state.peek = Some(super::super::peek::PeekPanelState::new(
DashboardRowId::TopLevel(AgentId(0)),
peek_fields_for_test("Idle"),
));
let _ = state.handle_key(&KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &reg);
assert!(
state.stop_confirm.is_none(),
state.delete_confirm.is_none(),
"a nav keypress consumed by the peek panel must still disarm the confirm",
);
}
#[test]
fn click_delete_control_arms_then_confirms() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut state = DashboardState::new();
let id = DashboardRowId::TopLevel(AgentId(0));
state
.row_delete_rects
.push((id.clone(), Rect::new(10, 2, 3, 1)));
let click = |col, row| MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::NONE,
};
// First `[✗]` click only arms — it must not open/attach the session.
let first = state.handle_mouse(&click(11, 2));
assert!(matches!(first, InputOutcome::Changed), "got {first:?}");
assert!(!matches!(
first,
InputOutcome::Action(Action::DashboardAttach(_))
));
assert_eq!(state.armed_delete_row_ref(), Some(&id));
// Second click confirms.
assert!(matches!(
state.handle_mouse(&click(11, 2)),
InputOutcome::Action(Action::DashboardDelete)
));
}
#[test]
fn focus_change_disarms_delete_confirm() {
let mut state = DashboardState::new();
let a = DashboardRowId::TopLevel(AgentId(0));
let b = DashboardRowId::TopLevel(AgentId(1));
state.focus_row(a.clone());
state.arm_delete(a.clone());
state.focus_row(a.clone());
assert_eq!(state.armed_delete_row_ref(), Some(&a));
state.focus_row(b);
assert!(state.delete_confirm.is_none());
state.arm_delete(DashboardRowId::TopLevel(AgentId(0)));
state.focus_new_agent_button();
assert!(state.delete_confirm.is_none());
state.focus_row(a.clone());
state.list_focused = true;
state.arm_delete(a);
state.dispatch_rect = Some(Rect::new(0, 10, 40, 1));
let _ = state.handle_mouse(&crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: 2,
row: 10,
modifiers: KeyModifiers::NONE,
});
assert!(state.delete_confirm.is_none());
assert!(!state.list_focused);
}
/// An auto-repeat (held) Ctrl+X must not drive the destructive
/// arm→confirm: only discrete presses count, so holding the key can't
/// arm and immediately confirm a delete.
#[test]
fn ctrl_x_key_repeat_is_ignored() {
let mut state = DashboardState::new();
let reg = crate::actions::ActionRegistry::defaults();
state.focus_row(DashboardRowId::TopLevel(AgentId(0)));
let repeat = Event::Key(crossterm::event::KeyEvent {
code: KeyCode::Char('x'),
modifiers: KeyModifiers::CONTROL,
kind: crossterm::event::KeyEventKind::Repeat,
state: crossterm::event::KeyEventState::NONE,
});
assert!(matches!(
state.handle_input(&repeat, &reg),
InputOutcome::Unchanged
));
// A real press still resolves to the stop action.
let press = Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL));
assert!(matches!(
state.handle_input(&press, &reg),
InputOutcome::Action(Action::DashboardStop)
));
}
/// `gc_stale_refs` dropping the selected row (session left the list)
/// must also disarm delete, so a later `y` can't delete a phantom row.
#[test]
fn gc_stale_refs_disarms_delete_when_selection_dropped() {
let mut state = DashboardState::new();
let a = DashboardRowId::TopLevel(AgentId(0));
state.focus_row(a.clone());
state.arm_delete(a.clone());
assert!(state.armed_delete_row_ref().is_some());
// The armed row is no longer alive → gc drops selection AND disarms.
state.gc_stale_refs(&|_| false);
assert!(state.selected.is_none());
assert!(state.delete_confirm.is_none(), "stale arm must be cleared");
}
/// Section header selected while the LIST is focused — the input is
/// inactive, so Enter / Left / Right operate on the section even
/// when a draft is sitting in the (unfocused) dispatch input.

View file

@ -229,11 +229,9 @@ pub enum ActiveModal {
entries_query: Option<String>,
/// Source filter for the modal session picker.
source_filter: crate::views::session_picker::SourceFilter,
/// Session armed for delete, captured as `(source, session_id, cwd)` when
/// `d` is pressed so the `y` confirm always has a valid cwd even if
/// the picker lists change underneath it. `Some` only while the
/// focused row is armed; cleared on cancel / completion.
pending_delete: Option<(String, String, String)>,
/// Session armed for delete via `d` (see
/// [`crate::views::session_picker::PendingDelete`]).
pending_delete: Option<crate::views::session_picker::PendingDelete>,
},
/// How-to documentation list modal (wider picker style).
DocPicker {

View file

@ -201,6 +201,11 @@ pub struct QuestionViewState {
/// while the user is answering questions — the time spent in the
/// question view is subtracted from the turn elapsed display.
pub opened_at: Instant,
/// Wall-clock twin of `opened_at` (UTC ms). `Instant` is suspend-blind,
/// so a pause netted against the wall-anchored turn span must itself be
/// measured on the wall clock, or a suspend during an open question
/// would read as worked time.
pub opened_at_wall_ms: i64,
/// When `true`, the freeform "Other" input row is hidden. Used by
/// locally-driven questions (e.g. credit-limit upsell) that only
/// offer fixed options with no free-text fallback.
@ -272,6 +277,7 @@ impl QuestionViewState {
bottom_panel_index: None,
local_kind: None,
opened_at: Instant::now(),
opened_at_wall_ms: chrono::Utc::now().timestamp_millis(),
no_freeform: false,
}
}

View file

@ -78,6 +78,89 @@ pub enum PickerItem {
Content { hit_index: usize },
}
/// A session armed for deletion, captured on `d` so the `y` confirm keeps
/// a valid `(source, session_id, cwd)` even if the lists shift. Shared by
/// the welcome and modal `/resume` pickers so they can't drift apart.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingDelete {
pub source: String,
pub session_id: String,
pub cwd: String,
}
/// Outcome of routing a key through an armed [`PendingDelete`] confirm.
pub(crate) enum PendingDeleteKey {
/// `y`: caller should delete this session.
Confirm(PendingDelete),
/// `n`: arm cleared; caller should redraw.
Cancel,
/// Other key: arm cleared, but the key should still be processed.
Disarmed,
/// Nothing armed, or not an unmodified key press.
NotArmed,
}
/// Arm a [`PendingDelete`] from the selected row, or `None` if it can't be
/// deleted (foreign source or non-selectable position).
pub(crate) fn pending_delete_from_selection(
selected: usize,
entry_map: &[Option<PickerItem>],
entries: Option<&[SessionPickerEntry]>,
content_results: Option<&[xai_grok_shell::extensions::session_search::SearchSessionHit]>,
) -> Option<PendingDelete> {
match entry_map.get(selected).and_then(|e| e.as_ref())? {
PickerItem::Fuzzy { original_index } => entries
.and_then(|e| e.get(*original_index))
.filter(|entry| !crate::app::is_foreign_picker_source(&entry.source))
.map(|e| PendingDelete {
source: e.source.clone(),
session_id: e.id.clone(),
cwd: e.cwd.clone(),
}),
PickerItem::Content { hit_index } => {
content_results
.and_then(|h| h.get(*hit_index))
.map(|h| PendingDelete {
source: "local".into(),
session_id: h.session_id.clone(),
cwd: h.cwd.clone(),
})
}
}
}
/// Route a key through an armed [`PendingDelete`]: `y` confirms, `n`
/// cancels, any other unmodified key disarms and falls through.
pub(crate) fn handle_pending_delete_key(
pending: &mut Option<PendingDelete>,
ev: &crossterm::event::Event,
) -> PendingDeleteKey {
use crossterm::event::{Event, KeyCode, KeyEventKind};
if pending.is_none() {
return PendingDeleteKey::NotArmed;
}
let Event::Key(k) = ev else {
return PendingDeleteKey::NotArmed;
};
if k.kind != KeyEventKind::Press || !k.modifiers.is_empty() {
return PendingDeleteKey::NotArmed;
}
match k.code {
KeyCode::Char('y') => pending
.take()
.map(PendingDeleteKey::Confirm)
.unwrap_or(PendingDeleteKey::Cancel),
KeyCode::Char('n') => {
*pending = None;
PendingDeleteKey::Cancel
}
_ => {
*pending = None;
PendingDeleteKey::Disarmed
}
}
}
/// Owned data for a single session picker row. Built once per frame and
/// then borrowed by `PickerEntry` / `PickerField` slices. Shared between
/// the welcome-screen `render_session_picker` and the

View file

@ -121,6 +121,23 @@ const REDO_LONG_HELP: &str = "\
Redoes the last undone change in the prompt editor.\n\
Ctrl+Shift+Z is primary; Ctrl+R is an alternate.";
// Prompt history is not an ActionRegistry entry: Up is an inline key handler and
// /history is a slash command. Surface both here for discoverability.
const HISTORY_LONG_HELP: &str = "\
Recalls previously sent prompts.\n\
Press Up on an empty prompt to browse earlier prompts, newest first; each move \
live-populates the composer so you can edit and resend.\n\
Run /history to open a searchable history panel and filter by text.";
// Scrollback search has no ActionRegistry entry: it's the vim `/` inline handler,
// or the /find slash command in simple mode. Surface both triggers here.
const SCROLLBACK_SEARCH_LONG_HELP: &str = "\
Searches the conversation scrollback for text and jumps between matches.\n\
In the prompt input, run /find to search. In vim mode, you can also press / \
while the scrollback is focused.\n\
Type a query, then use n and N (or the arrow keys) to step through matches. \
Press Enter to jump to a match and Esc to dismiss.";
/// Build the entries vector for the modal, grouped by category.
///
/// All registered actions are included, grouped by category. Actions
@ -273,7 +290,25 @@ pub fn build_entries(
item,
dimmed,
action_id: None,
long_help: None,
long_help: Some(SCROLLBACK_SEARCH_LONG_HELP),
});
}
// Simple mode reaches scrollback search via the `/find` slash command,
// not a keystroke: use a null key + custom display so the raw key list
// stays empty of `/`.
if !vim_mode && cat == Category::ConversationNav {
let mut item = HintItem::new(crate::key!(Null), "search");
item.custom_display = Some("/find");
item.description = Some("Search scrollback".into());
// `/find` is a slash command typed at the prompt (not a scrollback
// keystroke like the vim `/` above), so it is available when the
// prompt is focused — dim on `!PromptFocused`, not scrollback.
let dimmed = !active_contexts.contains(&When::PromptFocused);
entries.push(ShortcutsHelpEntry::Hint {
item,
dimmed,
action_id: None,
long_help: Some(SCROLLBACK_SEARCH_LONG_HELP),
});
}
// Clipboard + textarea chords not in ActionRegistry. Super/Cmd omitted
@ -308,6 +343,19 @@ pub fn build_entries(
redo.description = Some("Redo the last undone prompt edit".into());
redo.keys.push(crate::key!('r', CONTROL));
push_pseudo(&mut entries, redo, Some(REDO_LONG_HELP));
// Prompt history (Up / /history). Not part of the shared paste/undo/redo
// `dimmed`: that also lights on DashboardFocused, but Up-history is
// prompt-only, so give it its own PromptFocused-scoped dim.
let mut history = HintItem::new(crate::key!(Up), "history");
history.description = Some("Prompt history".into());
let history_dimmed = !active_contexts.contains(&When::PromptFocused);
entries.push(ShortcutsHelpEntry::Hint {
item: history,
dimmed: history_dimmed,
action_id: None,
long_help: Some(HISTORY_LONG_HELP),
});
}
let count = entries.len() - header_idx - 1;
if count == 0 {
@ -556,7 +604,7 @@ impl ShortcutsHelpMode {
/// Build detail mode state from a cheatsheet entry (title/keys/body for the man page).
///
/// Registry rows always open. Pseudo-rows (`action_id: None`) open only when they
/// ship `long_help` so list-only rows like scrollback search stay browse-only.
/// ship `long_help`; one without it stays list-only (browse-only).
pub fn detail_from_entry(entry: &ShortcutsHelpEntry) -> Option<ShortcutsHelpMode> {
let ShortcutsHelpEntry::Hint {
item,
@ -1902,6 +1950,26 @@ mod tests {
})
}
fn has_find_search(entries: &[ShortcutsHelpEntry]) -> bool {
entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.custom_display == Some("/find")
)
})
}
fn history_row(entries: &[ShortcutsHelpEntry]) -> Option<&ShortcutsHelpEntry> {
entries.iter().find(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, action_id: None, .. }
if item.label == "history"
)
})
}
#[test]
fn build_entries_includes_scrollback_search_in_vim_mode() {
let registry = ActionRegistry::defaults();
@ -1910,15 +1978,71 @@ mod tests {
has_scrollback_search(&entries),
"vim cheatsheet should list / search"
);
assert!(
!has_find_search(&entries),
"vim mode uses the `/` key row, not the /find slash row"
);
}
#[test]
fn build_entries_omits_scrollback_search_in_simple_mode() {
fn build_entries_includes_find_search_in_simple_mode() {
let registry = ActionRegistry::defaults();
let entries = build_entries(&all_contexts(), &registry, false);
assert!(
has_find_search(&entries),
"simple mode should list the /find scrollback search"
);
assert!(
!has_scrollback_search(&entries),
"simple mode does not bind / to search, so it must not be listed"
"simple mode must not list the bare `/` key row"
);
}
#[test]
fn build_entries_includes_history_row_in_both_modes() {
let registry = ActionRegistry::defaults();
for vim in [true, false] {
let entries = build_entries(&all_contexts(), &registry, vim);
assert!(
history_row(&entries).is_some(),
"history row should appear in vim={vim} mode"
);
}
}
#[test]
fn history_row_lit_only_by_prompt_focus() {
let registry = ActionRegistry::defaults();
let entries = build_entries(&[When::PromptFocused], &registry, false);
let ShortcutsHelpEntry::Hint { dimmed, .. } =
history_row(&entries).expect("history row present")
else {
unreachable!();
};
assert!(
!*dimmed,
"history row must be lit when the prompt is focused"
);
let entries = build_entries(&[When::ScrollbackFocused], &registry, false);
let ShortcutsHelpEntry::Hint { dimmed, .. } =
history_row(&entries).expect("history row present")
else {
unreachable!();
};
assert!(*dimmed, "history row must be dimmed without prompt focus");
// Dashboard focus alone must not light it (unlike paste/undo/redo).
let entries = build_entries(&[When::DashboardFocused], &registry, false);
let ShortcutsHelpEntry::Hint { dimmed, .. } =
history_row(&entries).expect("history row present")
else {
unreachable!();
};
assert!(
*dimmed,
"dashboard focus alone must not light the history row"
);
}
@ -2170,16 +2294,27 @@ mod tests {
fn build_entries_overlay_stop_wins_dedup_and_shadows_cheatsheet_ctrl_x() {
let registry = ActionRegistry::defaults();
let ctrl_x = crate::key!('x', CONTROL);
// Match the two Ctrl+X rows by ActionId: the list and overlay
// stops carry different labels ("delete" vs "stop").
let is_stop = |action_id: &Option<ActionId>| {
matches!(
action_id,
Some(ActionId::DashboardStop | ActionId::DashboardOverlayStop)
)
};
let stop_rows = |entries: &[ShortcutsHelpEntry]| -> Vec<(String, bool)> {
entries
.iter()
.filter_map(|e| match e {
ShortcutsHelpEntry::Hint { item, dimmed, .. } if item.label == "stop" => {
Some((
item.description.as_deref().unwrap_or_default().to_string(),
*dimmed,
))
}
ShortcutsHelpEntry::Hint {
item,
dimmed,
action_id,
..
} if is_stop(action_id) => Some((
item.description.as_deref().unwrap_or_default().to_string(),
*dimmed,
)),
_ => None,
})
.collect()
@ -2188,9 +2323,9 @@ mod tests {
entries
.iter()
.find_map(|e| match e {
ShortcutsHelpEntry::Hint {
item, action_id, ..
} if item.label == "stop" => Some(*action_id),
ShortcutsHelpEntry::Hint { action_id, .. } if is_stop(action_id) => {
Some(*action_id)
}
_ => None,
})
.flatten()
@ -2212,8 +2347,7 @@ mod tests {
let list = build_entries(&[When::DashboardFocused, When::Always], &registry, true);
assert_eq!(
stop_rows(&list),
vec![("Stop / Close agent".to_string(), false)],
"the dashboard list must show exactly the list `stop`, lit",
vec![("Stop / Delete agent".to_string(), false)],
);
assert_eq!(
stop_id(&list),
@ -2729,7 +2863,7 @@ mod tests {
/// Search has no long_help — Enter stays in browse.
#[test]
fn enter_on_search_pseudo_row_does_not_open_detail() {
fn enter_on_search_pseudo_row_opens_detail() {
let registry = ActionRegistry::defaults();
let entries = build_entries(&all_contexts(), &registry, true);
let idx = entries
@ -2737,11 +2871,24 @@ mod tests {
.position(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, action_id: None, .. }
if item.label == "search"
ShortcutsHelpEntry::Hint {
item,
action_id: None,
long_help: Some(_),
..
} if item.label == "search"
)
})
.expect("vim-mode entries include the `/`-search pseudo-row");
assert_eq!(
detail_from_entry(&entries[idx])
.and_then(|m| match m {
ShortcutsHelpMode::Detail { body, .. } => Some(body),
_ => None,
})
.as_deref(),
Some(SCROLLBACK_SEARCH_LONG_HELP)
);
let mut state = build_initial_picker_state(&entries);
state.selected = idx;
let mut mode = browse_mode();
@ -2754,11 +2901,8 @@ mod tests {
&no_expanded(),
&mut mode,
);
assert_eq!(out, ShortcutsHelpOutcome::Unchanged);
assert!(
mode.is_browse(),
"search pseudo-row Enter must not open detail"
);
assert_eq!(out, ShortcutsHelpOutcome::Changed);
assert!(mode.is_detail(), "search pseudo-row Enter opens detail");
}
#[test]
@ -3273,6 +3417,9 @@ mod tests {
let paste_key = key!('v', CONTROL);
let undo_key = key!('z', CONTROL);
let redo_key = key!('z', CONTROL | SHIFT);
// Prompt history (Up / /history) is an inline key handler + slash
// command, not an ActionRegistry entry, so it stays display-only too.
let history_key = key!(Up);
for entry in &entries {
let ShortcutsHelpEntry::Hint {
item, action_id, ..
@ -3285,6 +3432,7 @@ mod tests {
"paste" => item.keys.contains(&paste_key),
"undo" => item.keys.contains(&undo_key),
"redo" => item.keys.contains(&redo_key),
"history" => item.keys.contains(&history_key),
_ => false,
};
if is_pseudo {
@ -3407,7 +3555,7 @@ mod tests {
}
#[test]
fn search_pseudo_row_does_not_expand() {
fn search_pseudo_row_expands() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let registry = ActionRegistry::defaults();
let entries = build_entries(&all_contexts(), &registry, true);
@ -3437,8 +3585,8 @@ mod tests {
);
assert_eq!(
out,
ShortcutsHelpOutcome::Unchanged,
"search pseudo-row must stay inert for {code:?}, got {out:?}"
ShortcutsHelpOutcome::ToggleExpand(ExpandKey::Pseudo("search")),
"search pseudo-row must expand for {code:?}, got {out:?}"
);
}
}

View file

@ -290,6 +290,8 @@ pub(super) struct HeroBoxRects {
pub(super) announcement_rect: Option<Rect>,
/// Promo upgrade CTA `[label]` button rect (click → open), if drawn.
pub(super) upgrade_cta_rect: Option<Rect>,
#[cfg(feature = "local-workspace")]
pub(super) workspace_mode_rects: super::WorkspaceModeHitRects,
}
/// Render the bordered hero box with logo left, version + subtitle + menu right.
@ -306,6 +308,11 @@ pub(super) fn render_hero_box(
changelog_bullets: &[String],
changelog_has_full_notes: bool,
upgrade_cta: Option<&str>,
#[cfg(feature = "local-workspace")] workspace_mode: Option<(
super::WelcomeWorkspaceMode,
bool,
bool,
)>,
) -> HeroBoxRects {
// Dim the box border toward the background for a softer, dimmer gray.
let border_color = crate::render::color::blend_color(theme.bg_base, theme.gray_dim, 0.45)
@ -371,14 +378,45 @@ pub(super) fn render_hero_box(
}
}
#[cfg(feature = "local-workspace")]
let (menu_area, workspace_mode_rects) =
if let Some((mode, locked, ack_pending)) = workspace_mode {
let picker_rect = Rect {
height: 1.min(layout.hero_menu.height),
..layout.hero_menu
};
let rects = super::render_workspace_mode_picker(
picker_rect,
buf,
theme,
mode,
mouse_pos,
locked,
ack_pending,
);
let menu_area = Rect {
y: layout.hero_menu.y + super::workspace_mode::WORKSPACE_MODE_MENU_ROWS,
height: layout
.hero_menu
.height
.saturating_sub(super::workspace_mode::WORKSPACE_MODE_MENU_ROWS),
..layout.hero_menu
};
(menu_area, rects)
} else {
(layout.hero_menu, super::WorkspaceModeHitRects::default())
};
#[cfg(not(feature = "local-workspace"))]
let menu_area = layout.hero_menu;
let menu_rects = super::menu::render_menu(
layout.hero_menu,
menu_area,
buf,
theme,
menu_items,
selected,
mouse_pos,
layout.hero_menu.width,
menu_area.width,
);
HeroBoxRects {
menu_rects,
@ -386,6 +424,8 @@ pub(super) fn render_hero_box(
announcement_truncated,
announcement_rect,
upgrade_cta_rect,
#[cfg(feature = "local-workspace")]
workspace_mode_rects,
}
}

View file

@ -24,6 +24,8 @@ mod menu;
mod prompt;
mod toast;
mod top_bar;
#[cfg(feature = "local-workspace")]
pub(crate) mod workspace_mode;
pub(crate) use logo::shimmer_frame;
use logo::{logo_line_count, render_logo};
@ -31,6 +33,11 @@ use menu::render_menu;
pub(crate) use toast::paint_welcome_toast;
pub(crate) use top_bar::location_line_at;
use top_bar::render_top_bar;
#[cfg(feature = "local-workspace")]
pub use workspace_mode::{
WelcomeWorkspaceMode, WorkspaceModeHitRects, hit_test_workspace_mode,
render_workspace_mode_picker,
};
/// True for VS Code and xterm.js embeds (VS Code-family IDEs and Zed) where
/// quit is `Ctrl+D` (canonical: [`TerminalName::is_vscode_family`]).
@ -123,6 +130,9 @@ pub struct WelcomeRenderResult {
pub privacy_banner_opt_out_rect: Option<Rect>,
pub privacy_banner_terms_rect: Option<Rect>,
pub privacy_banner_policy_rect: Option<Rect>,
/// Hit-test rects for the chat workspace-mode segmented control.
#[cfg(feature = "local-workspace")]
pub workspace_mode_rects: WorkspaceModeHitRects,
}
use hero_box::HERO_BOX_MIN_WIDTH;
@ -631,6 +641,7 @@ pub struct WelcomeRenderParams<'a> {
pub session_picker_grouped: bool,
/// Source filter for the session picker.
pub session_picker_source_filter: crate::views::session_picker::SourceFilter,
pub session_picker_pending_delete: bool,
/// Process-wide `--chat`: the picker lists backend conversations only, so
/// the source filter and local deep search are hidden.
pub chat_mode: bool,
@ -656,6 +667,15 @@ pub struct WelcomeRenderParams<'a> {
pub upgrade_cta: Option<&'a str>,
/// Non-blocking welcome privacy banner above the prompt.
pub privacy_banner: bool,
/// Chat-mode workspace picker selection (`local-workspace` feature).
#[cfg(feature = "local-workspace")]
pub workspace_mode: WelcomeWorkspaceMode,
/// CLI/env already stamped local workspace — picker is display-only.
#[cfg(feature = "local-workspace")]
pub workspace_mode_startup_locked: bool,
/// In-TUI ACK confirm pending for Local.
#[cfg(feature = "local-workspace")]
pub workspace_mode_ack_pending: bool,
}
/// Render the welcome screen.
@ -720,22 +740,7 @@ pub fn render_welcome(
cursor_pos: None,
post_flush_escapes,
menu_rects,
prompt_rect: None,
session_picker_hit_areas: None,
import_banner_rect: None,
auth_url_rect: None,
auth_fallback_rect: None,
refresh_rect: None,
gate_url_rect: None,
changelog_action_present: false,
changelog_cta_rect: None,
announcement_truncated: false,
announcement_rect: None,
upgrade_cta_rect: None,
privacy_banner_opt_in_rect: None,
privacy_banner_opt_out_rect: None,
privacy_banner_terms_rect: None,
privacy_banner_policy_rect: None,
..Default::default()
}
}
AuthState::Authenticating { auth_url, mode, .. } => {
@ -753,25 +758,9 @@ pub fn render_welcome(
params.show_raw_url,
);
WelcomeRenderResult {
cursor_pos: None,
post_flush_escapes: None,
menu_rects: vec![],
prompt_rect: None,
session_picker_hit_areas: None,
import_banner_rect: None,
auth_url_rect: url_rect,
auth_fallback_rect: fallback_rect,
refresh_rect: None,
gate_url_rect: None,
changelog_action_present: false,
changelog_cta_rect: None,
announcement_truncated: false,
announcement_rect: None,
upgrade_cta_rect: None,
privacy_banner_opt_in_rect: None,
privacy_banner_opt_out_rect: None,
privacy_banner_terms_rect: None,
privacy_banner_policy_rect: None,
..Default::default()
}
}
AuthState::Done if params.is_zdr_blocked => {
@ -790,25 +779,9 @@ pub fn render_welcome(
params.compact,
);
WelcomeRenderResult {
cursor_pos: None,
post_flush_escapes,
menu_rects,
prompt_rect: None,
session_picker_hit_areas: None,
import_banner_rect: None,
auth_url_rect: None,
auth_fallback_rect: None,
refresh_rect: None,
gate_url_rect: None,
changelog_action_present: false,
changelog_cta_rect: None,
announcement_truncated: false,
announcement_rect: None,
upgrade_cta_rect: None,
privacy_banner_opt_in_rect: None,
privacy_banner_opt_out_rect: None,
privacy_banner_terms_rect: None,
privacy_banner_policy_rect: None,
..Default::default()
}
}
// Folder-trust question: shown after auth, before any session is
@ -1792,10 +1765,25 @@ fn render_welcome_done(
owned_menu.as_slice()
};
#[cfg(feature = "local-workspace")]
// Keep the segmented control (and ACK y/N) visible when history is open
// if first-run Local ACK is pending — otherwise the confirm is unpainted
// while the ACK handler still swallows keys.
let show_workspace_picker =
p.chat_mode && p.has_access && (!show_picker || p.workspace_mode_ack_pending);
#[cfg(feature = "local-workspace")]
let workspace_picker_rows = if show_workspace_picker {
workspace_mode::WORKSPACE_MODE_MENU_ROWS
} else {
0
};
#[cfg(not(feature = "local-workspace"))]
let workspace_picker_rows = 0u16;
let menu_height = if show_picker {
0
} else {
menu_items.len() as u16
menu_items.len() as u16 + workspace_picker_rows
};
// Session picker height: 1 row per entry (no dividers), scrollable.
@ -1843,6 +1831,8 @@ fn render_welcome_done(
let mut announcement_rect: Option<Rect> = None;
let mut upgrade_cta_rect: Option<Rect> = None;
#[cfg(feature = "local-workspace")]
let mut workspace_mode_rects = WorkspaceModeHitRects::default();
let (menu_rects, picker_close_button) = if show_picker {
// Use the full area since logo/menu are hidden and shortcuts
// are now rendered inside the picker content area.
@ -1868,6 +1858,7 @@ fn render_welcome_done(
tick: p.welcome_tick,
grouped: p.session_picker_grouped,
source_filter: p.session_picker_source_filter,
pending_delete: p.session_picker_pending_delete,
chat_mode: p.chat_mode,
cwd: p.cwd,
},
@ -1887,11 +1878,21 @@ fn render_welcome_done(
p.changelog_bullets,
p.changelog_has_full_notes,
p.upgrade_cta,
#[cfg(feature = "local-workspace")]
show_workspace_picker.then_some((
p.workspace_mode,
p.workspace_mode_startup_locked,
p.workspace_mode_ack_pending,
)),
);
changelog_cta_rect = rects.changelog_cta_rect;
announcement_truncated = rects.announcement_truncated;
announcement_rect = rects.announcement_rect;
upgrade_cta_rect = rects.upgrade_cta_rect;
#[cfg(feature = "local-workspace")]
{
workspace_mode_rects = rects.workspace_mode_rects;
}
(rects.menu_rects, None)
} else {
// Narrow layout: stacked logo above, menu below. Inset the menu the
@ -1899,6 +1900,28 @@ fn render_welcome_done(
// instead of touching the window edge on narrow terminals.
render_logo(layout.logo, buf, theme, content_area.height);
let menu_area = inset_horizontal(layout.menu, prompt::prompt_inset(p.compact));
#[cfg(feature = "local-workspace")]
let menu_area = if show_workspace_picker {
let picker_rect = workspace_mode::picker_area(menu_area);
workspace_mode_rects = render_workspace_mode_picker(
picker_rect,
buf,
theme,
p.workspace_mode,
p.mouse_pos,
p.workspace_mode_startup_locked,
p.workspace_mode_ack_pending,
);
Rect {
y: menu_area.y + workspace_mode::WORKSPACE_MODE_MENU_ROWS,
height: menu_area
.height
.saturating_sub(workspace_mode::WORKSPACE_MODE_MENU_ROWS),
..menu_area
}
} else {
menu_area
};
(
render_menu(
menu_area,
@ -2228,6 +2251,8 @@ fn render_welcome_done(
privacy_banner_opt_out_rect,
privacy_banner_terms_rect,
privacy_banner_policy_rect,
#[cfg(feature = "local-workspace")]
workspace_mode_rects,
}
}
@ -2252,6 +2277,7 @@ pub(crate) struct SessionPickerRenderCtx<'a> {
pub(crate) grouped: bool,
/// Source filter for filtering session entries.
pub(crate) source_filter: crate::views::session_picker::SourceFilter,
pub(crate) pending_delete: bool,
/// Process-wide `--chat`: hides the source-filter chip and the
/// deep-search/filter footer hints (see `WelcomeRenderParams::chat_mode`).
pub(crate) chat_mode: bool,
@ -2448,7 +2474,23 @@ pub(crate) fn render_session_picker(
description: None,
pinned: false,
});
if !ctx.chat_mode {
if ctx.pending_delete {
default_shortcuts.clear();
default_shortcuts.push(HintItem {
keys: vec![],
label: "confirm delete".into(),
custom_display: Some("y"),
description: None,
pinned: false,
});
default_shortcuts.push(HintItem {
keys: vec![],
label: "cancel".into(),
custom_display: Some("n"),
description: None,
pinned: false,
});
} else if !ctx.chat_mode {
default_shortcuts.push(HintItem {
keys: vec![],
label: "filter".into(),
@ -2456,6 +2498,13 @@ pub(crate) fn render_session_picker(
description: None,
pinned: false,
});
default_shortcuts.push(HintItem {
keys: vec![],
label: "delete".into(),
custom_display: Some("d"),
description: None,
pinned: false,
});
}
let config = PickerConfig {
@ -2474,7 +2523,11 @@ pub(crate) fn render_session_picker(
filter_key_hint: (!ctx.chat_mode).then_some("f"),
filter_active: !ctx.chat_mode && ctx.source_filter.is_active(),
header_note: hidden_hint.as_deref(),
action_keys: &[],
action_keys: if ctx.chat_mode || ctx.pending_delete {
&[]
} else {
&[('d', "delete")]
},
disable_search: false,
compact_bottom_bar: false,
search_only_on_slash: false,
@ -2789,6 +2842,7 @@ mod tests {
subscription_tier: None,
session_picker_grouped: false,
session_picker_source_filter: crate::views::session_picker::SourceFilter::default(),
session_picker_pending_delete: false,
chat_mode: false,
cwd: std::path::Path::new("/repo"),
credit_balance: None,
@ -2799,6 +2853,12 @@ mod tests {
welcome_announcement_expanded: false,
upgrade_cta: None,
privacy_banner: false,
#[cfg(feature = "local-workspace")]
workspace_mode: WelcomeWorkspaceMode::Sandbox,
#[cfg(feature = "local-workspace")]
workspace_mode_startup_locked: false,
#[cfg(feature = "local-workspace")]
workspace_mode_ack_pending: false,
}
}
@ -2964,6 +3024,7 @@ mod tests {
tick: 0,
grouped: false,
source_filter: crate::views::session_picker::SourceFilter::default(),
pending_delete: false,
chat_mode: true,
},
);
@ -3039,6 +3100,7 @@ mod tests {
tick: 0,
grouped: false,
source_filter: crate::views::session_picker::SourceFilter::default(),
pending_delete: false,
chat_mode,
},
);

View file

@ -0,0 +1,841 @@
//! Welcome Sandbox | Local picker under `--chat`. CLI/env stamp wins at startup.
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Flex, Layout, Position, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use unicode_width::UnicodeWidthStr;
use crate::theme::Theme;
/// Welcome-screen workspace selection (in-memory until session start).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WelcomeWorkspaceMode {
/// Backend sandbox / product-chat default.
#[default]
Sandbox,
/// Local Computer Hub workspace server (own mode; replaces sandbox).
LocalWorkspace,
}
impl WelcomeWorkspaceMode {
/// Modes shown on the welcome picker under `--chat`.
pub const ALL: [Self; 2] = [Self::Sandbox, Self::LocalWorkspace];
pub fn cycle_next(self) -> Self {
match self {
Self::Sandbox => Self::LocalWorkspace,
Self::LocalWorkspace => Self::Sandbox,
}
}
pub fn cycle_prev(self) -> Self {
self.cycle_next()
}
pub fn label(self) -> &'static str {
match self {
Self::Sandbox => "Sandbox",
Self::LocalWorkspace => "Local workspace",
}
}
pub fn hint(self) -> &'static str {
match self {
Self::Sandbox => "backend sandbox",
Self::LocalWorkspace => "this machine · Computer Hub",
}
}
/// Compact in-session / status-bar label.
pub fn status_label(self, cli_locked: bool) -> &'static str {
match (self, cli_locked) {
(Self::Sandbox, _) => "Sandbox",
(Self::LocalWorkspace, true) => "Local·CLI",
(Self::LocalWorkspace, false) => "Local",
}
}
/// Unified-list `kind`: Sandbox → `chat`, Local → `build`.
pub fn history_kind_filter(self) -> &'static str {
match self {
Self::Sandbox => "chat",
Self::LocalWorkspace => "build",
}
}
/// Conversation/gateway → Sandbox; other sources → Local.
pub fn from_history_source(source: &str) -> Self {
if source == "conversation" {
Self::Sandbox
} else {
Self::LocalWorkspace
}
}
pub fn index(self) -> usize {
match self {
Self::Sandbox => 0,
Self::LocalWorkspace => 1,
}
}
pub fn from_index(i: usize) -> Self {
Self::ALL[i % Self::ALL.len()]
}
}
/// Structured log target for welcome / in-session workspace mode events.
pub const WORKSPACE_MODE_LOG: &str = "grok.pager.workspace_mode";
/// Log a welcome picker selection change (Ctrl+E cycle or click).
pub fn log_welcome_mode_selected(
mode: WelcomeWorkspaceMode,
via: &'static str,
startup_locked: bool,
) {
tracing::info!(
target: WORKSPACE_MODE_LOG,
event = "welcome_mode_selected",
mode = mode.label(),
history_kind = mode.history_kind_filter(),
via,
startup_locked,
"welcome workspace mode selected"
);
}
/// Log Local ACK confirm or cancel.
pub fn log_welcome_ack(outcome: &'static str) {
tracing::info!(
target: WORKSPACE_MODE_LOG,
event = "welcome_local_ack",
outcome,
"welcome local-workspace ACK"
);
}
/// Log one-shot / process stamp application for a new welcome session.
pub fn log_welcome_intent_applied(
mode: WelcomeWorkspaceMode,
startup_locked: bool,
one_shot: &'static str,
process_stamp: &'static str,
) {
tracing::info!(
target: WORKSPACE_MODE_LOG,
event = "welcome_intent_applied",
mode = mode.label(),
startup_locked,
one_shot,
process_stamp,
"welcome workspace intent applied for NewSession"
);
}
/// Log CLI/env lock applied at startup (before any welcome selection).
pub fn log_cli_lock_applied(mode: WelcomeWorkspaceMode) {
tracing::info!(
target: WORKSPACE_MODE_LOG,
event = "cli_lock_applied",
mode = mode.label(),
"CLI/env local-workspace lock applied at startup"
);
}
/// Log CLI/env lock winning over a differing welcome selection.
pub fn log_cli_lock_wins(mode: WelcomeWorkspaceMode) {
tracing::info!(
target: WORKSPACE_MODE_LOG,
event = "cli_lock_wins",
mode = mode.label(),
"CLI/env local-workspace lock wins; welcome selection ignored"
);
}
/// In-session indicator: history bypass / local intent → Local; else Sandbox.
pub fn indicator_for_opening_session(
chat_kind: bool,
history_load_as_build: bool,
cli_locked: bool,
local_workspace_intent: bool,
) -> (WelcomeWorkspaceMode, bool) {
if history_load_as_build {
return (WelcomeWorkspaceMode::LocalWorkspace, cli_locked);
}
if chat_kind && !local_workspace_intent {
return (WelcomeWorkspaceMode::Sandbox, false);
}
if cli_locked {
return (WelcomeWorkspaceMode::LocalWorkspace, true);
}
if local_workspace_intent {
return (WelcomeWorkspaceMode::LocalWorkspace, false);
}
(WelcomeWorkspaceMode::Sandbox, false)
}
/// Log session-list kind filter / history-source switch.
pub fn log_history_source(
event: &'static str,
mode: Option<WelcomeWorkspaceMode>,
kind_filter: Option<&[String]>,
source: Option<&str>,
) {
tracing::info!(
target: WORKSPACE_MODE_LOG,
event,
mode = mode.map(WelcomeWorkspaceMode::label),
kind_filter = ?kind_filter,
history_source = source,
"workspace history source"
);
}
/// Hit-test rects for each segmented option (Sandbox, Local).
#[derive(Debug, Clone, Default)]
pub struct WorkspaceModeHitRects {
pub options: [Option<Rect>; 2],
pub row: Option<Rect>,
}
/// Rows reserved above the welcome menu for the picker (content + gap).
pub const WORKSPACE_MODE_MENU_ROWS: u16 = 2;
/// Paint the segmented workspace control into `area`.
///
/// Layout:
/// `Workspace [ Sandbox ] [ Local workspace ] ctrl+e`
/// or when locked: `Workspace [ Local workspace ] locked by CLI`
pub fn render_workspace_mode_picker(
area: Rect,
buf: &mut Buffer,
theme: &Theme,
selected: WelcomeWorkspaceMode,
mouse_pos: Option<(u16, u16)>,
startup_locked: bool,
ack_pending: bool,
) -> WorkspaceModeHitRects {
if area.height == 0 || area.width < 20 {
return WorkspaceModeHitRects::default();
}
let row = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
};
let label_style = Style::default().fg(theme.gray);
let key_style = Style::default().fg(theme.gray_bright);
let inactive = Style::default().fg(theme.gray_bright);
let active = Style::default()
.fg(theme.bg_base)
.bg(theme.accent_user)
.add_modifier(Modifier::BOLD);
let hover = Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD);
let locked_style = Style::default().fg(theme.gray);
buf.set_span(row.x, row.y, &Span::styled("Workspace ", label_style), 11);
let mut x = row.x.saturating_add(11);
let mut options = [None; 2];
let modes: &[WelcomeWorkspaceMode] = if startup_locked {
// Locked: show the effective mode only (CLI/env stamp).
match selected {
WelcomeWorkspaceMode::LocalWorkspace => &[WelcomeWorkspaceMode::LocalWorkspace],
WelcomeWorkspaceMode::Sandbox => &[WelcomeWorkspaceMode::Sandbox],
}
} else {
&WelcomeWorkspaceMode::ALL
};
for (slot, mode) in modes.iter().enumerate() {
if x >= row.x + row.width {
break;
}
let text = if *mode == selected {
format!("{} ", mode.label())
} else {
format!(" {} ", mode.label())
};
let w = UnicodeWidthStr::width(text.as_str()) as u16;
if x + w > row.x + row.width {
break;
}
let rect = Rect {
x,
y: row.y,
width: w,
height: 1,
};
let hovered = !startup_locked
&& !ack_pending
&& mouse_pos.is_some_and(|(mx, my)| rect.contains(Position::new(mx, my)));
let style = if *mode == selected {
active
} else if hovered {
hover
} else {
inactive
};
buf.set_span(x, row.y, &Span::styled(text, style), w);
if slot < options.len() {
// Map by mode index so hit-test stays stable.
options[mode.index()] = Some(rect);
}
x = x.saturating_add(w);
if slot + 1 < modes.len() && x + 1 < row.x + row.width {
buf.set_span(x, row.y, &Span::styled("", label_style), 1);
x = x.saturating_add(1);
}
}
let trailing = if ack_pending {
" confirm local workspace? y/N"
} else if startup_locked {
" locked by CLI"
} else {
" ctrl+e"
};
let trailing_style = if ack_pending {
Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD)
} else if startup_locked {
locked_style
} else {
key_style
};
if !trailing.is_empty() && x + trailing.len() as u16 <= row.x + row.width {
buf.set_span(
row.x + row.width - trailing.len() as u16,
row.y,
&Span::styled(trailing, trailing_style),
trailing.len() as u16,
);
} else if ack_pending && row.width > 20 {
// Narrow terminals: paint confirm over the right side so it stays visible.
let short = " y/N confirm local";
let start = row.x + row.width.saturating_sub(short.len() as u16);
buf.set_span(
start,
row.y,
&Span::styled(short, trailing_style),
short.len() as u16,
);
}
WorkspaceModeHitRects {
options,
row: Some(row),
}
}
/// Hit-test a click against option rects. Returns the selected mode if hit.
pub fn hit_test_workspace_mode(
rects: &WorkspaceModeHitRects,
column: u16,
row: u16,
) -> Option<WelcomeWorkspaceMode> {
let pos = Position::new(column, row);
for (i, rect) in rects.options.iter().enumerate() {
if rect.is_some_and(|r| r.contains(pos)) {
return Some(WelcomeWorkspaceMode::from_index(i));
}
}
None
}
/// Result of preparing welcome workspace intent for a new session.
#[cfg(feature = "local-workspace")]
#[derive(Debug)]
pub enum WelcomeWorkspacePrepare {
/// Continue. `session_override`: `Some(None)` sandbox, `Some(Some)` local, `None` keep stamp.
Continue {
session_override: Option<Option<crate::app::session_startup::LocalWorkspaceConfig>>,
warning: Option<String>,
},
/// Stay on welcome; show in-TUI ACK confirm before stamping Local.
AwaitAck,
}
/// Prepare welcome Sandbox/Local for NewSession. Local may return `AwaitAck`.
#[cfg(feature = "local-workspace")]
pub fn prepare_welcome_workspace_for_new_session(
selection: WelcomeWorkspaceMode,
startup_locked: bool,
chat_mode: bool,
cwd: &std::path::Path,
agents_alive: bool,
) -> anyhow::Result<WelcomeWorkspacePrepare> {
use crate::app::session_startup::{
local_workspace_ack_satisfied, resolve_local_workspace_config, set_active_local_workspace,
};
if startup_locked || !chat_mode {
if startup_locked {
log_cli_lock_wins(selection);
}
return Ok(WelcomeWorkspacePrepare::Continue {
session_override: None,
warning: None,
});
}
match selection {
WelcomeWorkspaceMode::Sandbox => {
if !agents_alive {
// Safe: no live session still reading the process stamp.
set_active_local_workspace(None)?;
}
log_welcome_intent_applied(
selection,
startup_locked,
"sandbox_none",
if agents_alive { "kept" } else { "cleared" },
);
Ok(WelcomeWorkspacePrepare::Continue {
session_override: Some(None),
warning: None,
})
}
WelcomeWorkspaceMode::LocalWorkspace => {
if !local_workspace_ack_satisfied() {
tracing::info!(
target: WORKSPACE_MODE_LOG,
event = "welcome_local_ack",
outcome = "await",
"welcome Local requires ACK confirm"
);
return Ok(WelcomeWorkspacePrepare::AwaitAck);
}
let cfg = resolve_local_workspace_config(true, Some(None), None, Some(cwd))?
.ok_or_else(|| {
anyhow::anyhow!(
"local-workspace resolve returned no config after own-mode request"
)
})?;
// Live sessions still read the process stamp; oneshot-only then.
if !agents_alive {
set_active_local_workspace(Some(cfg.clone()))?;
}
log_welcome_intent_applied(
selection,
startup_locked,
"own_oneshot",
if agents_alive { "kept" } else { "stamped_own" },
);
Ok(WelcomeWorkspacePrepare::Continue {
session_override: Some(Some(cfg)),
warning: None,
})
}
}
}
/// Confirm Local ACK. If `agents_alive`, return oneshot only (keep process stamp).
#[cfg(feature = "local-workspace")]
pub fn confirm_welcome_local_workspace_ack(
cwd: &std::path::Path,
agents_alive: bool,
) -> anyhow::Result<crate::app::session_startup::LocalWorkspaceConfig> {
use crate::app::session_startup::{
resolve_local_workspace_config, set_active_local_workspace, write_local_workspace_ack,
};
let cfg = resolve_local_workspace_config(true, Some(None), None, Some(cwd))?
.ok_or_else(|| anyhow::anyhow!("local-workspace resolve returned no config after ack"))?;
if !agents_alive {
set_active_local_workspace(Some(cfg.clone()))?;
}
write_local_workspace_ack();
log_welcome_ack("confirmed");
Ok(cfg)
}
/// Sync UI selection from a startup-locked stamp (Own/Attach → Local).
#[cfg(feature = "local-workspace")]
pub fn mode_from_active_stamp(
stamp: Option<&crate::app::session_startup::LocalWorkspaceConfig>,
) -> WelcomeWorkspaceMode {
match stamp {
Some(_) => WelcomeWorkspaceMode::LocalWorkspace,
None => WelcomeWorkspaceMode::Sandbox,
}
}
/// Whether keyboard/mouse should mutate the welcome selection.
///
/// Same surface as ACK + render: chat mode, access, auth Done, not ZDR,
/// not CLI-startup-locked, and history picker closed (Ctrl+E/click would
/// otherwise mutate with no on-screen control).
pub fn picker_interactive(
chat_mode: bool,
has_access: bool,
auth_done: bool,
zdr_blocked: bool,
session_picker_open: bool,
startup_locked: bool,
) -> bool {
chat_mode && has_access && auth_done && !zdr_blocked && !startup_locked && !session_picker_open
}
/// Center the picker within `menu_area` the same way the menu is inset.
pub fn picker_area(menu_area: Rect) -> Rect {
let [_, centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(menu_area.width),
Constraint::Min(0),
])
.flex(Flex::Start)
.areas(menu_area);
Rect {
height: 1.min(centered.height),
..centered
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cycle_walks_sandbox_and_local() {
let mut mode = WelcomeWorkspaceMode::Sandbox;
mode = mode.cycle_next();
assert_eq!(mode, WelcomeWorkspaceMode::LocalWorkspace);
mode = mode.cycle_next();
assert_eq!(mode, WelcomeWorkspaceMode::Sandbox);
assert_eq!(
WelcomeWorkspaceMode::LocalWorkspace.cycle_prev(),
WelcomeWorkspaceMode::Sandbox
);
}
#[test]
fn labels_are_stable() {
assert_eq!(WelcomeWorkspaceMode::Sandbox.label(), "Sandbox");
assert_eq!(
WelcomeWorkspaceMode::LocalWorkspace.label(),
"Local workspace"
);
assert!(
WelcomeWorkspaceMode::LocalWorkspace
.hint()
.contains("Computer Hub")
);
assert_eq!(WelcomeWorkspaceMode::Sandbox.status_label(false), "Sandbox");
assert_eq!(
WelcomeWorkspaceMode::LocalWorkspace.status_label(false),
"Local"
);
assert_eq!(
WelcomeWorkspaceMode::LocalWorkspace.status_label(true),
"Local·CLI"
);
assert_eq!(WelcomeWorkspaceMode::Sandbox.history_kind_filter(), "chat");
assert_eq!(
WelcomeWorkspaceMode::LocalWorkspace.history_kind_filter(),
"build"
);
assert_eq!(
WelcomeWorkspaceMode::from_history_source("conversation"),
WelcomeWorkspaceMode::Sandbox
);
assert_eq!(
WelcomeWorkspaceMode::from_history_source("local"),
WelcomeWorkspaceMode::LocalWorkspace
);
}
#[test]
fn index_roundtrip() {
for mode in WelcomeWorkspaceMode::ALL {
assert_eq!(WelcomeWorkspaceMode::from_index(mode.index()), mode);
}
}
#[test]
fn hit_test_prefers_option_rects() {
let rects = WorkspaceModeHitRects {
options: [Some(Rect::new(10, 5, 9, 1)), Some(Rect::new(20, 5, 17, 1))],
row: Some(Rect::new(0, 5, 80, 1)),
};
assert_eq!(
hit_test_workspace_mode(&rects, 12, 5),
Some(WelcomeWorkspaceMode::Sandbox)
);
assert_eq!(
hit_test_workspace_mode(&rects, 25, 5),
Some(WelcomeWorkspaceMode::LocalWorkspace)
);
assert_eq!(hit_test_workspace_mode(&rects, 0, 5), None);
assert_eq!(hit_test_workspace_mode(&rects, 12, 6), None);
}
#[test]
fn render_assigns_option_rects() {
let area = Rect::new(0, 0, 100, 2);
let mut buf = Buffer::empty(area);
let theme = Theme::current();
let hits = render_workspace_mode_picker(
area,
&mut buf,
&theme,
WelcomeWorkspaceMode::LocalWorkspace,
None,
false,
false,
);
assert!(hits.options[0].is_some());
assert!(hits.options[1].is_some());
assert!(hits.row.is_some());
let cell = buf.cell((0, 0)).expect("cell");
assert_eq!(cell.symbol(), "W");
let selected = hits.options[1].expect("local selected rect");
let selected_text = format!("{} ", WelcomeWorkspaceMode::LocalWorkspace.label());
assert_eq!(
selected.width,
UnicodeWidthStr::width(selected_text.as_str()) as u16,
"option width must be display columns, not UTF-8 bytes"
);
assert!(
selected.width < selected_text.len() as u16,
"bullet U+2022 is 3 bytes / 1 column: {selected_text:?}"
);
}
#[test]
fn render_ack_pending_shows_durable_confirm() {
let area = Rect::new(0, 0, 120, 1);
let mut buf = Buffer::empty(area);
let theme = Theme::current();
let _ = render_workspace_mode_picker(
area,
&mut buf,
&theme,
WelcomeWorkspaceMode::LocalWorkspace,
None,
false,
true,
);
let line: String = (0..area.width)
.filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol().to_string()))
.collect();
assert!(
line.contains("y/N") || line.contains("confirm"),
"ack-pending UI must stay visible: {line:?}"
);
}
#[test]
fn picker_interactive_matrix() {
assert!(picker_interactive(true, true, true, false, false, false));
assert!(!picker_interactive(true, true, true, false, false, true));
assert!(
!picker_interactive(true, true, true, false, true, false),
"history open: Ctrl+E/click must not mutate a hidden control"
);
assert!(!picker_interactive(true, false, true, false, false, false));
assert!(!picker_interactive(false, true, true, false, false, false));
assert!(
!picker_interactive(true, true, false, false, false, false),
"login / authenticating must not cycle mode"
);
assert!(
!picker_interactive(true, true, true, true, false, false),
"ZDR-blocked welcome must not cycle mode"
);
}
#[test]
fn indicator_derives_from_opened_session() {
assert_eq!(
indicator_for_opening_session(true, false, false, false),
(WelcomeWorkspaceMode::Sandbox, false)
);
assert_eq!(
indicator_for_opening_session(false, true, false, false),
(WelcomeWorkspaceMode::LocalWorkspace, false)
);
// Conversation / chat_kind without this-session local intent → Sandbox
// even when the process has a CLI lock (LoadSession strips stamp).
assert_eq!(
indicator_for_opening_session(true, false, true, false),
(WelcomeWorkspaceMode::Sandbox, false)
);
assert_eq!(
indicator_for_opening_session(true, false, true, true),
(WelcomeWorkspaceMode::LocalWorkspace, true)
);
assert_eq!(
indicator_for_opening_session(false, true, true, false),
(WelcomeWorkspaceMode::LocalWorkspace, true)
);
assert_eq!(WelcomeWorkspaceMode::Sandbox.status_label(true), "Sandbox");
}
}
#[cfg(all(test, feature = "local-workspace"))]
mod apply_tests {
use super::*;
use crate::app::session_startup::{
GROK_CHAT_LOCAL_WORKSPACE_ACK_ENV, LocalWorkspaceMode, set_active_local_workspace,
};
#[test]
fn startup_lock_skips_override() {
set_active_local_workspace(None).unwrap();
let tmp = tempfile::tempdir().unwrap();
set_active_local_workspace(Some(crate::app::session_startup::LocalWorkspaceConfig {
mode: LocalWorkspaceMode::Attach,
cwd: Some(tmp.path().to_path_buf()),
server_id: Some("cli-srv".into()),
}))
.unwrap();
let out = prepare_welcome_workspace_for_new_session(
WelcomeWorkspaceMode::Sandbox,
true,
true,
tmp.path(),
false,
)
.unwrap();
match out {
WelcomeWorkspacePrepare::Continue {
session_override, ..
} => {
assert!(session_override.is_none());
}
WelcomeWorkspacePrepare::AwaitAck => panic!("locked must continue"),
}
let stamp = crate::app::session_startup::active_local_workspace()
.unwrap()
.expect("cli stamp kept");
assert_eq!(stamp.mode, LocalWorkspaceMode::Attach);
set_active_local_workspace(None).unwrap();
}
#[test]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ACK)]
fn welcome_local_one_shot_only_when_agents_alive() {
let _ack = xai_grok_test_support::EnvGuard::set(GROK_CHAT_LOCAL_WORKSPACE_ACK_ENV, "1");
set_active_local_workspace(None).unwrap();
let tmp = tempfile::tempdir().unwrap();
let out = prepare_welcome_workspace_for_new_session(
WelcomeWorkspaceMode::LocalWorkspace,
false,
true,
tmp.path(),
true, // agents alive
)
.unwrap();
let WelcomeWorkspacePrepare::Continue {
session_override, ..
} = out
else {
panic!("expected continue");
};
assert!(session_override.flatten().is_some());
assert!(
crate::app::session_startup::active_local_workspace()
.unwrap()
.is_none(),
"must not overwrite process stamp while other agents are alive"
);
}
#[test]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ACK)]
fn welcome_local_stamps_own_mode() {
let _ack = xai_grok_test_support::EnvGuard::set(GROK_CHAT_LOCAL_WORKSPACE_ACK_ENV, "1");
set_active_local_workspace(None).unwrap();
let tmp = tempfile::tempdir().unwrap();
let out = prepare_welcome_workspace_for_new_session(
WelcomeWorkspaceMode::LocalWorkspace,
false,
true,
tmp.path(),
false,
)
.unwrap();
let WelcomeWorkspacePrepare::Continue {
session_override, ..
} = out
else {
panic!("expected continue");
};
let cfg = session_override.flatten().expect("own stamp override");
assert_eq!(cfg.mode, LocalWorkspaceMode::Own);
assert_eq!(cfg.cwd.as_deref(), Some(tmp.path()));
assert!(cfg.server_id.is_none());
set_active_local_workspace(None).unwrap();
}
#[test]
fn sandbox_does_not_clear_stamp_when_agents_alive() {
set_active_local_workspace(None).unwrap();
let tmp = tempfile::tempdir().unwrap();
set_active_local_workspace(Some(crate::app::session_startup::LocalWorkspaceConfig {
mode: LocalWorkspaceMode::Own,
cwd: Some(tmp.path().to_path_buf()),
server_id: None,
}))
.unwrap();
let out = prepare_welcome_workspace_for_new_session(
WelcomeWorkspaceMode::Sandbox,
false,
true,
tmp.path(),
true, // agents alive
)
.unwrap();
let WelcomeWorkspacePrepare::Continue {
session_override, ..
} = out
else {
panic!("expected continue");
};
assert_eq!(session_override, Some(None));
assert!(
crate::app::session_startup::active_local_workspace()
.unwrap()
.is_some(),
"process stamp must remain for live agents"
);
set_active_local_workspace(None).unwrap();
}
#[test]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ACK)]
fn local_without_ack_awaits_confirm() {
let _ack = xai_grok_test_support::EnvGuard::unset(GROK_CHAT_LOCAL_WORKSPACE_ACK_ENV);
// Isolate ack file from developer machine.
let home = tempfile::tempdir().unwrap();
let _home =
xai_grok_test_support::EnvGuard::set("GROK_HOME", home.path().to_str().unwrap());
set_active_local_workspace(None).unwrap();
let tmp = tempfile::tempdir().unwrap();
let out = prepare_welcome_workspace_for_new_session(
WelcomeWorkspaceMode::LocalWorkspace,
false,
true,
tmp.path(),
false,
)
.unwrap();
assert!(matches!(out, WelcomeWorkspacePrepare::AwaitAck));
assert!(
crate::app::session_startup::active_local_workspace()
.unwrap()
.is_none()
);
}
}