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

@ -963,8 +963,8 @@ pub(super) fn default_actions(
},
ActionDef {
id: ActionId::DashboardStop,
label: "stop",
description: "Stop / Close agent",
label: "delete",
description: "Stop / Delete agent",
default_key: key!('x', CONTROL),
alt_keys: vec![],
category: Category::Dashboard,
@ -973,7 +973,7 @@ pub(super) fn default_actions(
hint_key_display: None,
requires_confirmation: false,
long_help: Some(
"Stops the selected agent and removes its row from the dashboard; a running turn is interrupted first.\nUse it to clear finished or unwanted agents without attaching to them.\nThe in-overlay equivalent (Ctrl+X) confirms before stopping.",
"On a busy top-level row, Ctrl+X cancels the running turn. Once the row is idle, press Ctrl+X again within 2s to permanently delete the session.\nOn a subagent row, Ctrl+X kills the subagent.",
),
},
ActionDef {

View file

@ -56,7 +56,7 @@ pub(crate) fn handle_ask_user_question(
// If a question is already active, cancel it before replacing.
if let Some(mut old_qv) = agent.question_view.take() {
agent.turn_paused_duration += old_qv.opened_at.elapsed();
agent.record_question_pause(&old_qv);
tracing::warn!(
old_tool_call_id = %old_qv.tool_call_id,
new_tool_call_id = %ext_req.tool_call_id,

View file

@ -133,8 +133,17 @@ fn enqueue_permission(
let subagent_label = resolve_subagent_label(agent, &perm.request.session_id);
// 3. Build title and description from the tool call.
let (title, description, bash_command_raw) =
build_permission_display(&perm.request, bash_highlights.as_ref());
let (title, description, bash_command_raw) = build_permission_display(
&perm.request,
bash_highlights.as_ref(),
#[cfg(feature = "local-workspace")]
matches!(
agent.workspace_mode,
crate::views::welcome::WelcomeWorkspaceMode::LocalWorkspace
),
#[cfg(not(feature = "local-workspace"))]
false,
);
// 4. Assign a monotonic ID.
let perm_id = agent.next_perm_req_id;
@ -236,6 +245,7 @@ fn resolve_subagent_label(agent: &AgentView, session_id: &acp::SessionId) -> Opt
fn build_permission_display(
req: &acp::RequestPermissionRequest,
bash_highlights: Option<&BashCommandHighlights>,
session_local_workspace: bool,
) -> (String, Vec<String>, Option<String>) {
let is_bash = bash_highlights.is_some();
@ -303,11 +313,29 @@ fn build_permission_display(
}
};
let title = qualify_permission_title_for_local_workspace(title, session_local_workspace);
let description = permission_description_lines(req);
let bash_cmd = if is_execute { raw_command } else { None };
(title, description, bash_cmd)
}
/// Per-session HITL copy — not process-global CLI stamp.
fn qualify_permission_title_for_local_workspace(
title: String,
session_local_workspace: bool,
) -> String {
if !session_local_workspace {
return title;
}
if title.contains("on your machine") {
return title;
}
if let Some(stripped) = title.strip_suffix('?') {
return format!("{stripped} (on your machine)?");
}
format!("{title} (on your machine)")
}
/// Lines shown under the permission title: protected-edit note (if any), then
/// MCP planned-argument lines (empty for bash/edit).
fn permission_description_lines(req: &acp::RequestPermissionRequest) -> Vec<String> {
@ -450,3 +478,20 @@ pub(super) fn apply_recap_block(agent: &mut AgentView, auto: bool, recap_block:
}
}
}
#[cfg(all(test, feature = "local-workspace"))]
mod tests {
use super::*;
#[test]
fn permission_title_qualifies_for_local_workspace() {
assert_eq!(
qualify_permission_title_for_local_workspace("Allow Edit?".into(), false),
"Allow Edit?"
);
assert_eq!(
qualify_permission_title_for_local_workspace("Allow Edit?".into(), true),
"Allow Edit (on your machine)?"
);
}
}

View file

@ -129,6 +129,9 @@ pub enum Action {
/// load effect; under `--chat`, local Build disk rows are refused in
/// dispatch (never coerced).
LoadSession(String, Option<std::path::PathBuf>, bool),
/// Welcome Local workspace ACK confirmed (y); write ack + start session.
#[cfg(feature = "local-workspace")]
ConfirmWelcomeLocalWorkspaceAck,
/// Create a new session with a client-chosen session ID (`--session-id`).
NewSessionWithId(String),
/// Startup `--fork-session`: fork `parent` then load the child.
@ -822,9 +825,12 @@ pub enum Action {
DashboardCommitRename,
/// Cancel an in-progress rename without committing.
DashboardCancelRename,
/// Stop / kill the selected row (top-level: cancel turn → close;
/// subagent: kill). Double-press protected for top-level rows.
/// Ctrl+X on the selected row. Top-level: cancels a running turn on a
/// busy row, else double-press permanently deletes an idle row.
/// Subagent: kills the subagent.
DashboardStop,
/// Confirm permanent delete of the armed dashboard row.
DashboardDelete,
/// Cycle the dispatch input's mode for the next spawned agent
/// (Normal → Plan → Always-Approve → Normal). Bound to Shift+Tab.
DashboardCycleMode,
@ -1380,6 +1386,8 @@ pub enum AfterSessionDelete {
Stay,
/// `/delete` — return to welcome.
Welcome,
/// Stay on the dashboard.
Dashboard,
}
#[derive(Debug)]
pub enum Effect {
@ -1470,6 +1478,10 @@ pub enum Effect {
/// the response is dropped when no longer current, so out-of-order
/// completions can't clobber newer results.
seq: u64,
/// Optional unified-list `kind` facet filter (`"chat"` / `"build"`).
/// When set, stamped as `_meta["x.ai/facetFilters"].kind` so the shell
/// honors multi-source history under `--chat` instead of forcing chat-only.
kind_filter: Option<Vec<String>>,
},
/// Coalesce picker search keystrokes: fires
/// [`TaskResult::SessionSearchDebounceExpired`] after a short sleep; the

View file

@ -580,6 +580,16 @@ impl AgentState {
pub fn is_turn_running(&self) -> bool {
matches!(self, Self::TurnRunning)
}
/// Manual `/compact` is in flight (stoppable via session/cancel).
pub fn is_compact_running(&self) -> bool {
matches!(
self,
Self::CommandRunning {
command: AgentCommand::Compact,
..
}
)
}
/// Either a turn or command cancel is in progress.
pub fn is_cancelling(&self) -> bool {
matches!(self, Self::TurnCancelling | Self::CommandCancelling { .. })
@ -873,6 +883,18 @@ impl AgentSession {
pub fn finish_command(&mut self) {
self.state = AgentState::Idle;
}
/// Mark an in-flight `/compact` as cancelling (waiting for CompactComplete).
pub fn cancel_compact_command(&mut self) {
if let AgentState::CommandRunning {
command: AgentCommand::Compact,
..
} = &self.state
{
self.state = AgentState::CommandCancelling {
command: AgentCommand::Compact,
};
}
}
/// Push a prompt onto the back of the queue. Returns the assigned ID.
pub fn enqueue_prompt(&mut self, text: String) -> u64 {
self.enqueue_entry(text, QueueEntryKind::Prompt)

View file

@ -989,6 +989,7 @@ impl AgentView {
}
if registry.matches_id(ActionId::CancelTurn, key)
&& (self.session.state.is_turn_running()
|| self.session.state.is_compact_running()
|| self.session.state.is_cancelling())
{
self.dismiss_jump_picker();
@ -1273,7 +1274,7 @@ impl AgentView {
) -> InputOutcome {
match action_id {
ActionId::CancelTurn => {
if self.session.state.is_turn_running() {
if self.session.state.is_turn_running() || self.session.state.is_compact_running() {
self.cancel_trigger_hint = Some(crate::app::actions::CancelTrigger::CtrlC);
return InputOutcome::Action(Action::CancelTurn);
}
@ -1541,6 +1542,40 @@ mod background_and_tasks_shortcut_tests {
}
}
#[test]
fn shortcuts_key_tears_down_history_and_opens_cheatsheet() {
use crate::views::modal::ActiveModal;
let registry = ActionRegistry::defaults();
let history = [HistoryEntry {
text: "earlier prompt".into(),
}];
for key in [
KeyEvent::new(KeyCode::Char('.'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
] {
for browse in [true, false] {
let mut agent = make_agent();
agent.set_active_pane(AgentPane::Prompt, true);
if browse {
agent.prompt.history_search.activate_browse(&history, "");
agent.prompt.set_text("earlier prompt");
} else {
agent.prompt.history_search.activate(&history, "query");
agent.prompt.set_text("query");
}
let out = agent.handle_prompt_key_with_registry_for_test(&key, &registry);
assert!(matches!(out, InputOutcome::Changed));
assert!(
matches!(agent.active_modal, Some(ActiveModal::ShortcutsHelp { .. })),
"shortcuts key must open the cheatsheet"
);
assert!(
!agent.prompt.history_search.is_active(),
"history overlay must be torn down first"
);
}
}
}
#[test]
fn ctrl_b_preempts_file_search_without_mutating_it() {
let registry = ActionRegistry::defaults();
let mut agent = make_agent();
@ -2278,7 +2313,12 @@ mod esc_would_cancel_turn_tests {
mod jump_backout_key_tests {
use super::test_fixtures::make_agent;
use super::{AgentPane, AgentView};
use crate::actions::ActionRegistry;
use crate::app::actions::Action;
use crate::app::agent::{AgentCommand, AgentState};
use crate::app::app_view::InputOutcome;
use crate::views::jump::{JumpRestore, JumpState};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
fn open_jump(agent: &mut AgentView) {
agent.jump_state = Some(JumpState {
entries: Vec::new(),
@ -2290,6 +2330,9 @@ mod jump_backout_key_tests {
},
});
}
fn ctrl_c() -> Event {
Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))
}
/// In the dashboard overlay, a bare Esc backs out via
/// `no_esc_consumer_pending`; the open `/jump` picker must count as a
/// consumer so Esc dismisses it (restoring the viewport) instead of
@ -2323,6 +2366,26 @@ mod jump_backout_key_tests {
"an open /jump picker owns Esc/Left in the overlay back-out"
);
}
/// `/jump` must not swallow Ctrl+C while `/compact` is running — same
/// hatch as a running turn.
#[test]
fn jump_picker_ctrl_c_cancels_compact() {
let mut agent = make_agent();
agent.session.state = AgentState::CommandRunning {
command: AgentCommand::Compact,
started_at: std::time::Instant::now(),
};
open_jump(&mut agent);
let outcome = agent.handle_input(&ctrl_c(), &ActionRegistry::defaults());
assert!(
agent.jump_state.is_none(),
"Ctrl+C during /compact must dismiss the jump picker"
);
assert!(
matches!(outcome, InputOutcome::Action(Action::CancelTurn)),
"Ctrl+C during /compact with /jump open must cancel, got {outcome:?}"
);
}
}
#[cfg(test)]
mod voice_stop_click_during_plan_review_tests {

View file

@ -1056,7 +1056,7 @@ impl AgentView {
return self.submit_question_answers(true);
}
if let Some(qv) = self.question_view.take() {
self.turn_paused_duration += qv.opened_at.elapsed();
self.record_question_pause(&qv);
self.prompt.restore(qv.stashed_prompt);
}
self.cleanup_question_state();
@ -1133,7 +1133,7 @@ impl AgentView {
let Some(mut qv) = self.question_view.take() else {
return InputOutcome::Changed;
};
self.turn_paused_duration += qv.opened_at.elapsed();
self.record_question_pause(&qv);
if let Some(kind) = qv.local_kind.take() {
let is_doctor_fix = matches!(
kind,
@ -1262,7 +1262,7 @@ impl AgentView {
self.question_view = Some(qv);
return PeekAnswerOutcome::Advanced;
}
self.turn_paused_duration += qv.opened_at.elapsed();
self.record_question_pause(&qv);
let response = qv.build_accepted_response();
qv.send_ext_response(response);
self.prompt.restore(qv.stashed_prompt);

View file

@ -881,6 +881,12 @@ pub struct AgentView {
/// Unlike `chat_kind`, stays `false` for a `/chat` one-shot session in
/// a Build process, whose picker still lists local sessions.
pub app_chat_mode: bool,
/// Durable workspace mode for the in-session status indicator (`--chat`).
#[cfg(feature = "local-workspace")]
pub workspace_mode: crate::views::welcome::WelcomeWorkspaceMode,
/// True when CLI/env locked local workspace at startup for this session.
#[cfg(feature = "local-workspace")]
pub workspace_mode_cli_locked: bool,
/// Mocked credit balance for the status bar indicator.
pub credit_balance: Option<crate::views::credit_bar::CreditBalance>,
/// Auto top-up rule paired with `credit_balance` for the prompt warning.
@ -925,6 +931,11 @@ pub struct AgentView {
/// Accumulated duration the turn timer was paused (while the user was
/// answering questions via `AskUserQuestion`). Reset when the turn ends.
pub turn_paused_duration: std::time::Duration,
/// Wall-clock twin of `turn_paused_duration`: the same pauses measured on
/// the wall clock, which keeps counting through OS suspend while `Instant`
/// does not. Netted against the wall-anchored turn span so a suspend
/// during an open question isn't reported as worked time.
pub turn_paused_wall: std::time::Duration,
/// IDs of interjections this client sent and already rendered locally
/// (optimistic echo). The shell broadcasts `x.ai/session/interjection` to
/// every attached pane; when our own broadcast echoes back carrying an id

View file

@ -116,6 +116,13 @@ impl AgentView {
// dropdown state derived from that text would otherwise steal the
// arrows mid-browse.
if self.prompt.history_search.is_active() {
// Tear down the history overlay before opening the cheatsheet: it
// renders unconditionally and would bleed around the popup, and Esc
// would otherwise silently resume the browse instead of closing help.
if registry.matches_id(ActionId::ShortcutsHelp, key) {
self.close_history_restoring_saved();
return self.handle_agent_action_with_registry(ActionId::ShortcutsHelp, registry);
}
return self.handle_history_search_key(key);
}

View file

@ -1326,6 +1326,20 @@ impl AgentView {
}) {
status.push("mcp", mcp_line);
}
#[cfg(feature = "local-workspace")]
if self.chat_kind || self.app_chat_mode {
let label = self
.workspace_mode
.status_label(self.workspace_mode_cli_locked);
let mut mode_style = Style::default().fg(theme.accent_user).bg(theme.bg_base);
if self.workspace_mode_cli_locked {
mode_style = mode_style.add_modifier(ratatui::style::Modifier::DIM);
}
status.push(
"workspace_mode",
Line::from(Span::styled(label, mode_style)),
);
}
let ctx_used = self.context_state.as_ref().map(|c| c.used);
let model_window = self.session.models.get_context_window();
let ctx_total = self

View file

@ -117,6 +117,10 @@ impl AgentView {
context_state: None,
chat_kind: false,
app_chat_mode: false,
#[cfg(feature = "local-workspace")]
workspace_mode: crate::views::welcome::WelcomeWorkspaceMode::Sandbox,
#[cfg(feature = "local-workspace")]
workspace_mode_cli_locked: false,
credit_balance: None,
auto_topup: None,
goal_state: None,
@ -134,6 +138,7 @@ impl AgentView {
turn_started_at: None,
first_activity_logged_for: None,
turn_paused_duration: std::time::Duration::ZERO,
turn_paused_wall: std::time::Duration::ZERO,
self_interjection_ids: std::collections::HashSet::new(),
last_active_at: Some(Instant::now()),
current_branch: None,
@ -350,17 +355,35 @@ impl AgentView {
child_view.mark_as_subagent_view();
self.subagent_views.insert(child_sid, child_view);
}
/// Clear `turn_started_at` and stamp `last_active_at` to "now".
/// Clear the turn-timing fields and stamp `last_active_at` to "now".
///
/// Call this from every site that ends a turn (success, failure,
/// cancellation, reconnect cleanup). Centralised so the two
/// fields cannot drift apart at the ~10 termination call sites
/// across `dispatch.rs` and `event_loop.rs`.
/// cancellation, reconnect cleanup). Centralised so the fields cannot
/// drift apart at the ~10 termination call sites across `dispatch.rs`
/// and `event_loop.rs`. The wall anchor is cleared so a later turn that
/// reuses a prompt id (stash-and-resubmit after `/login`) can never
/// wall-max against a previous attempt's anchor in
/// [`honest_turn_elapsed`].
pub fn mark_turn_finished(&mut self) {
self.turn_started_at = None;
self.turn_paused_duration = std::time::Duration::ZERO;
self.turn_paused_wall = std::time::Duration::ZERO;
self.turn_start_ms = None;
self.turn_start_ms_prompt = None;
self.last_active_at = Some(Instant::now());
}
/// Absorb a closing/replaced question view's open span into the turn's
/// pause totals, on both clocks — a close site that updated only the
/// `Instant` pause would resurface suspend time as worked time in
/// [`honest_turn_elapsed`].
pub(crate) fn record_question_pause(
&mut self,
qv: &crate::views::question_view::QuestionViewState,
) {
self.turn_paused_duration += qv.opened_at.elapsed();
self.turn_paused_wall +=
wall_since_ms(qv.opened_at_wall_ms, chrono::Utc::now().timestamp_millis());
}
/// Invalidate and clear a minimal `/btw` lifecycle at a session boundary.
pub(crate) fn clear_minimal_btw_lifecycle(&mut self) {
crate::minimal_api::clear_minimal_btw(self);
@ -637,18 +660,26 @@ impl AgentView {
self.reset_follow_ups_for_reload();
dropped_heavy
}
/// Effective turn elapsed time, excluding time spent in question views.
///
/// Subtracts both the accumulated `turn_paused_duration` (from previously
/// closed question views) and the time elapsed since the current question
/// view opened (if one is active).
/// Effective turn elapsed time, excluding time spent in question views
/// (accumulated pauses plus the currently open one, on both clocks).
pub fn turn_elapsed(&self) -> Option<std::time::Duration> {
let raw = self.turn_started_at?.elapsed();
let mut paused = self.turn_paused_duration;
let instant_elapsed = self.turn_started_at?.elapsed();
let now_ms = chrono::Utc::now().timestamp_millis();
let mut instant_paused = self.turn_paused_duration;
let mut wall_paused = self.turn_paused_wall;
if let Some(qv) = &self.question_view {
paused += qv.opened_at.elapsed();
instant_paused += qv.opened_at.elapsed();
wall_paused += wall_since_ms(qv.opened_at_wall_ms, now_ms);
}
Some(raw.saturating_sub(paused))
Some(honest_turn_elapsed(TurnElapsedParams {
instant_elapsed,
instant_paused,
wall_anchor_ms: self.turn_start_ms,
wall_paused,
anchor_prompt: self.turn_start_ms_prompt.as_deref(),
current_prompt: self.session.current_prompt_id.as_deref(),
now_ms,
}))
}
/// Turn activity for the status spinner, with the implicit "no activity"
/// gap during a running inference turn resolved into an explicit
@ -979,6 +1010,168 @@ impl AgentView {
self.prompt.set_voice_visible(available);
}
}
/// Inputs for [`honest_turn_elapsed`]: the turn span and pause total measured
/// on each clock, plus the wire anchor's provenance. `now_ms` is injected so
/// tests control the wall clock.
struct TurnElapsedParams<'a> {
instant_elapsed: std::time::Duration,
instant_paused: std::time::Duration,
/// `turnStartMs` wire anchor (UTC ms) and the prompt id it was stamped
/// for; the anchor counts only when that id matches the running prompt
/// (interleaved deltas can re-stamp it with another prompt's anchor).
wall_anchor_ms: Option<i64>,
wall_paused: std::time::Duration,
anchor_prompt: Option<&'a str>,
current_prompt: Option<&'a str>,
now_ms: i64,
}
/// Turn elapsed for [`AgentView::turn_elapsed`], honest across OS suspends
/// (`Instant` pauses while the machine sleeps; the wall clock keeps
/// counting). Each span is netted against pauses measured on its own clock,
/// and the larger net wins; the tests below enumerate the guard cases.
fn honest_turn_elapsed(params: TurnElapsedParams<'_>) -> std::time::Duration {
let instant_net = params.instant_elapsed.saturating_sub(params.instant_paused);
let (Some(start_ms), Some(anchor_prompt), Some(current_prompt)) = (
params.wall_anchor_ms,
params.anchor_prompt,
params.current_prompt,
) else {
return instant_net;
};
if anchor_prompt != current_prompt {
return instant_net;
}
let wall_net = wall_since_ms(start_ms, params.now_ms).saturating_sub(params.wall_paused);
instant_net.max(wall_net)
}
/// Wall-clock span since `start_ms`, clamped to zero when `start_ms`
/// postdates `now_ms` (skew) so a wall span can never go negative.
fn wall_since_ms(start_ms: i64, now_ms: i64) -> std::time::Duration {
std::time::Duration::from_millis(u64::try_from(now_ms.saturating_sub(start_ms)).unwrap_or(0))
}
#[cfg(test)]
mod honest_turn_elapsed_tests {
use super::*;
use std::time::Duration;
const NOW_MS: i64 = 1_700_000_000_000;
const MIN: u64 = 60;
const HOUR: u64 = 3_600;
/// Valid same-prompt anchor context with zero spans; tests override the
/// fields under test via struct-update syntax.
fn base() -> TurnElapsedParams<'static> {
TurnElapsedParams {
instant_elapsed: Duration::ZERO,
instant_paused: Duration::ZERO,
wall_anchor_ms: None,
wall_paused: Duration::ZERO,
anchor_prompt: Some("p1"),
current_prompt: Some("p1"),
now_ms: NOW_MS,
}
}
#[test]
fn no_wall_anchor_keeps_instant_net() {
assert_eq!(
honest_turn_elapsed(TurnElapsedParams {
instant_elapsed: Duration::from_secs(5 * MIN),
instant_paused: Duration::from_secs(MIN),
..base()
}),
Duration::from_secs(4 * MIN)
);
}
#[test]
fn suspend_outside_questions_defers_to_wall_net() {
assert_eq!(
honest_turn_elapsed(TurnElapsedParams {
instant_elapsed: Duration::from_secs(4 * MIN),
wall_anchor_ms: Some(NOW_MS - 2 * HOUR as i64 * 1_000),
..base()
}),
Duration::from_secs(2 * HOUR)
);
}
#[test]
fn suspend_while_question_open_is_not_worked_time() {
assert_eq!(
honest_turn_elapsed(TurnElapsedParams {
instant_elapsed: Duration::from_secs(10 * MIN),
instant_paused: Duration::from_secs(5 * MIN),
wall_anchor_ms: Some(NOW_MS - (2 * HOUR as i64 + 10 * MIN as i64) * 1_000),
wall_paused: Duration::from_secs(2 * HOUR + 5 * MIN),
..base()
}),
Duration::from_secs(5 * MIN)
);
}
#[test]
fn instant_net_bounds_below_after_backward_wall_jump() {
assert_eq!(
honest_turn_elapsed(TurnElapsedParams {
instant_elapsed: Duration::from_secs(5 * MIN),
wall_anchor_ms: Some(NOW_MS - 1_000),
..base()
}),
Duration::from_secs(5 * MIN)
);
}
#[test]
fn foreign_prompt_anchor_falls_back_to_instant_net() {
assert_eq!(
honest_turn_elapsed(TurnElapsedParams {
instant_elapsed: Duration::from_secs(10 * MIN),
instant_paused: Duration::from_secs(4 * MIN),
wall_anchor_ms: Some(NOW_MS - 2 * HOUR as i64 * 1_000),
anchor_prompt: Some("p-other"),
..base()
}),
Duration::from_secs(6 * MIN)
);
}
#[test]
fn missing_current_prompt_ignores_anchor() {
assert_eq!(
honest_turn_elapsed(TurnElapsedParams {
instant_elapsed: Duration::from_secs(MIN),
wall_anchor_ms: Some(NOW_MS - 2 * HOUR as i64 * 1_000),
current_prompt: None,
..base()
}),
Duration::from_secs(MIN)
);
}
#[test]
fn future_wall_anchor_is_ignored() {
assert_eq!(
honest_turn_elapsed(TurnElapsedParams {
instant_elapsed: Duration::from_secs(MIN),
wall_anchor_ms: Some(NOW_MS + 60_000),
..base()
}),
Duration::from_secs(MIN)
);
}
#[test]
fn turn_elapsed_reflects_wall_span_for_current_prompt() {
let mut view = test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"));
view.turn_started_at = Some(Instant::now());
view.turn_start_ms = Some(chrono::Utc::now().timestamp_millis() - 60_000);
view.turn_start_ms_prompt = Some("p1".to_string());
view.session.current_prompt_id = Some("p1".to_string());
assert!(view.turn_elapsed().unwrap() >= Duration::from_secs(59));
}
#[test]
fn turn_elapsed_nets_wall_pauses_against_wall_span() {
let mut view = test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"));
view.turn_started_at = Some(Instant::now());
view.turn_start_ms = Some(chrono::Utc::now().timestamp_millis() - 60_000);
view.turn_start_ms_prompt = Some("p1".to_string());
view.session.current_prompt_id = Some("p1".to_string());
view.turn_paused_wall = Duration::from_secs(45);
let elapsed = view.turn_elapsed().unwrap();
assert!(elapsed >= Duration::from_secs(14) && elapsed <= Duration::from_secs(16));
}
}
#[cfg(test)]
mod resolve_turn_activity_tests {
use super::*;

View file

@ -499,7 +499,7 @@ impl PendingAction {
}
/// Like [`Self::new`] but with an explicit confirm window. Used by
/// the dashboard-overlay stop (Ctrl+X), which mirrors the
/// dashboard's [`crate::views::dashboard::state::STOP_CONFIRM_WINDOW`]
/// dashboard's [`crate::views::dashboard::state::CONFIRM_WINDOW`]
/// rather than the default double-press TTL.
pub fn with_ttl(
action: Action,
@ -865,6 +865,12 @@ pub struct AppView {
pub welcome_privacy_banner_opt_out_rect: Option<ratatui::layout::Rect>,
pub welcome_privacy_banner_terms_rect: Option<ratatui::layout::Rect>,
pub welcome_privacy_banner_policy_rect: Option<ratatui::layout::Rect>,
/// Hit-test rects for the welcome workspace-mode picker.
#[cfg(feature = "local-workspace")]
pub welcome_workspace_mode_rects: crate::views::welcome::WorkspaceModeHitRects,
/// Sticky hover flag for the workspace-mode picker (redraw on enter/leave).
#[cfg(feature = "local-workspace")]
pub welcome_on_workspace_mode: bool,
/// Transient welcome toast: (message, wall-clock expiry).
pub welcome_toast: Option<(String, std::time::Instant)>,
/// Sticky hover flag for the privacy banner buttons (redraw on enter/leave).
@ -918,6 +924,7 @@ pub struct AppView {
/// [`crate::views::session_picker::effective_filter_query`], skips the
/// local fuzzy re-filter for server search results.
pub session_picker_entries_query: Option<String>,
pub session_picker_pending_delete: Option<crate::views::session_picker::PendingDelete>,
/// Tick counter for welcome screen spinner animation.
pub welcome_tick: u64,
/// Last shimmer frame drawn on the welcome screen. Lets `tick` throttle the
@ -964,6 +971,22 @@ pub struct AppView {
/// profiles on create/load while set. `/chat` does **not** set this
/// (uses [`Self::deferred_startup`] one-shot state instead).
pub chat_mode: bool,
/// Welcome picker mode; ignored when `local_workspace_startup_locked`.
#[cfg(feature = "local-workspace")]
pub welcome_workspace_mode: crate::views::welcome::WelcomeWorkspaceMode,
/// CLI/env already stamped local workspace; welcome must not override.
#[cfg(feature = "local-workspace")]
pub local_workspace_startup_locked: bool,
/// One-shot next-session stamp: `Some(None)` sandbox, `Some(cfg)` local.
#[cfg(feature = "local-workspace")]
pub welcome_session_local_workspace:
Option<Option<crate::app::session_startup::LocalWorkspaceConfig>>,
/// First-run Local ACK still pending in the TUI.
#[cfg(feature = "local-workspace")]
pub welcome_local_workspace_ack_pending: bool,
/// Next welcome history load is local-disk/build (does not set `chat_mode`).
#[cfg(feature = "local-workspace")]
pub welcome_history_load_as_build: bool,
/// Whether mouse capture is currently enabled. Disabled during the
/// Authenticating state so the terminal handles native text selection.
pub mouse_captured: bool,
@ -1442,6 +1465,10 @@ impl AppView {
welcome_privacy_banner_opt_out_rect: None,
welcome_privacy_banner_terms_rect: None,
welcome_privacy_banner_policy_rect: None,
#[cfg(feature = "local-workspace")]
welcome_workspace_mode_rects: Default::default(),
#[cfg(feature = "local-workspace")]
welcome_on_workspace_mode: false,
welcome_toast: None,
welcome_on_privacy_banner: false,
welcome_on_upgrade_cta: false,
@ -1465,6 +1492,7 @@ impl AppView {
session_picker_lanes: Default::default(),
session_picker_detail_generation: 0,
session_picker_entries_query: None,
session_picker_pending_delete: None,
welcome_tick: 0,
welcome_shimmer_frame: 0,
cli_model_override: None,
@ -1480,6 +1508,16 @@ impl AppView {
subagents: false,
ask_user: false,
chat_mode: false,
#[cfg(feature = "local-workspace")]
welcome_workspace_mode: crate::views::welcome::WelcomeWorkspaceMode::Sandbox,
#[cfg(feature = "local-workspace")]
local_workspace_startup_locked: false,
#[cfg(feature = "local-workspace")]
welcome_session_local_workspace: None,
#[cfg(feature = "local-workspace")]
welcome_local_workspace_ack_pending: false,
#[cfg(feature = "local-workspace")]
welcome_history_load_as_build: false,
mouse_captured: true,
new_worktree_dialog: None,
contextual_hints: Default::default(),
@ -2429,6 +2467,8 @@ impl AppView {
self.session_picker_loading,
&self.session_picker_lanes,
);
#[cfg(feature = "local-workspace")]
let session_picker_open = self.session_picker_entries.is_some() || sp_loading;
let outcome = match self.active_view {
ActiveView::Welcome => handle_welcome_input(
ev,
@ -2492,7 +2532,24 @@ impl AppView {
cwd_has_git_ancestor: self.cwd_has_git_ancestor,
session_picker_grouped: self.session_picker_grouped,
sp_source_filter: &mut self.session_picker_source_filter,
sp_pending_delete: &mut self.session_picker_pending_delete,
chat_mode: self.chat_mode,
#[cfg(feature = "local-workspace")]
workspace_mode: &mut self.welcome_workspace_mode,
#[cfg(feature = "local-workspace")]
workspace_mode_rects: &self.welcome_workspace_mode_rects,
#[cfg(feature = "local-workspace")]
on_workspace_mode: &mut self.welcome_on_workspace_mode,
#[cfg(feature = "local-workspace")]
workspace_mode_startup_locked: self.local_workspace_startup_locked,
#[cfg(feature = "local-workspace")]
workspace_mode_ack_pending: &mut self.welcome_local_workspace_ack_pending,
#[cfg(feature = "local-workspace")]
history_load_as_build: &mut self.welcome_history_load_as_build,
#[cfg(feature = "local-workspace")]
deferred_startup: &mut self.deferred_startup,
#[cfg(feature = "local-workspace")]
session_picker_open,
},
),
ActiveView::Agent(id) => {
@ -2537,18 +2594,17 @@ impl AppView {
return InputOutcome::Action(Action::DashboardOverlayNext);
}
Some(crate::actions::ActionId::DashboardOverlayStop) => {
if self
.agents
.get(&id)
.is_some_and(|a| a.session.state.is_turn_running())
{
if self.agents.get(&id).is_some_and(|a| {
a.session.state.is_turn_running()
|| a.session.state.is_compact_running()
}) {
return InputOutcome::Action(Action::CancelTurn);
}
self.pending_action = Some(PendingAction::with_ttl(
Action::DashboardOverlayStop,
KeyShortcut::from(*key),
Some("close this session"),
crate::views::dashboard::state::STOP_CONFIRM_WINDOW,
crate::views::dashboard::state::CONFIRM_WINDOW,
));
return InputOutcome::Changed;
}
@ -3109,9 +3165,26 @@ struct WelcomeInputCtx<'a> {
cwd_has_git_ancestor: bool,
session_picker_grouped: bool,
sp_source_filter: &'a mut crate::views::session_picker::SourceFilter,
sp_pending_delete: &'a mut Option<crate::views::session_picker::PendingDelete>,
/// Process-wide `--chat`: the session picker hides its source filter
/// (conversations-only list), so `f` must not cycle it.
chat_mode: bool,
#[cfg(feature = "local-workspace")]
workspace_mode: &'a mut crate::views::welcome::WelcomeWorkspaceMode,
#[cfg(feature = "local-workspace")]
workspace_mode_rects: &'a crate::views::welcome::WorkspaceModeHitRects,
#[cfg(feature = "local-workspace")]
on_workspace_mode: &'a mut bool,
#[cfg(feature = "local-workspace")]
workspace_mode_startup_locked: bool,
#[cfg(feature = "local-workspace")]
workspace_mode_ack_pending: &'a mut bool,
#[cfg(feature = "local-workspace")]
history_load_as_build: &'a mut bool,
#[cfg(feature = "local-workspace")]
deferred_startup: &'a mut crate::app::session_startup::DeferredStartupActions,
#[cfg(feature = "local-workspace")]
session_picker_open: bool,
}
/// Welcome view input -- auth-state-aware routing.
fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutcome {
@ -3252,6 +3325,83 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
}
return InputOutcome::Unchanged;
}
#[cfg(feature = "local-workspace")]
if *ctx.workspace_mode_ack_pending
&& matches!(ctx.auth_state, AuthState::Done)
&& ctx.has_access
&& !ctx.is_zdr_blocked
{
if let Event::Key(key) = ev {
if key.kind == KeyEventKind::Release {
return InputOutcome::Unchanged;
}
if key!('y').matches(key) || key!('Y').matches(key) || key!(Enter).matches(key) {
return InputOutcome::Action(Action::ConfirmWelcomeLocalWorkspaceAck);
}
if key!('n').matches(key) || key!('N').matches(key) || key!(Esc).matches(key) {
*ctx.workspace_mode_ack_pending = false;
*ctx.workspace_mode = crate::views::welcome::WelcomeWorkspaceMode::Sandbox;
let was_worktree = ctx.deferred_startup.worktree;
ctx.deferred_startup.worktree = false;
ctx.deferred_startup.worktree_label = None;
ctx.deferred_startup.worktree_ref = None;
if was_worktree {
ctx.deferred_startup.session = None;
ctx.deferred_startup.preferred_session_id = None;
}
*ctx.history_load_as_build = false;
ctx.deferred_startup.history_load_as_build = false;
crate::views::welcome::workspace_mode::log_welcome_ack("cancelled");
return InputOutcome::Changed;
}
return InputOutcome::Unchanged;
}
if matches!(ev, Event::Resize(_, _)) {
return InputOutcome::Changed;
}
return InputOutcome::Unchanged;
}
#[cfg(feature = "local-workspace")]
if crate::views::welcome::workspace_mode::picker_interactive(
ctx.chat_mode,
ctx.has_access,
matches!(ctx.auth_state, AuthState::Done),
ctx.is_zdr_blocked,
ctx.session_picker_open,
ctx.workspace_mode_startup_locked,
) {
if let Event::Key(key) = ev
&& key.kind != KeyEventKind::Release
&& key!('e', CONTROL).matches(key)
{
*ctx.workspace_mode = ctx.workspace_mode.cycle_next();
crate::views::welcome::workspace_mode::log_welcome_mode_selected(
*ctx.workspace_mode,
"ctrl_e",
ctx.workspace_mode_startup_locked,
);
return InputOutcome::Changed;
}
if let Event::Mouse(mouse) = ev
&& matches!(
mouse.kind,
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left)
)
&& let Some(mode) = crate::views::welcome::hit_test_workspace_mode(
ctx.workspace_mode_rects,
mouse.column,
mouse.row,
)
{
*ctx.workspace_mode = mode;
crate::views::welcome::workspace_mode::log_welcome_mode_selected(
mode,
"click",
ctx.workspace_mode_startup_locked,
);
return InputOutcome::Changed;
}
}
if (ctx.sp_entries.is_some() || ctx.sp_loading) && matches!(ctx.auth_state, AuthState::Done) {
use crate::views::picker::{PickerConfig, PickerOutcome, handle_picker_input};
let source_filter = *ctx.sp_source_filter;
@ -3271,6 +3421,19 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
);
let entry_count = entry_map.len();
let non_selectable_flags: Vec<bool> = entry_map.iter().map(|e| e.is_none()).collect();
let focused_is_foreign = match entry_map
.get(ctx.sp_state.selected)
.and_then(|entry| entry.as_ref())
{
Some(PickerItem::Fuzzy { original_index }) => ctx
.sp_entries
.as_ref()
.and_then(|entries| entries.get(*original_index))
.is_some_and(|entry| {
crate::app::foreign_sessions::is_foreign_picker_source(&entry.source)
}),
_ => false,
};
let config = PickerConfig {
title: Some("Resume session"),
show_search_hint: true,
@ -3287,12 +3450,30 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
filter_key_hint: (!ctx.chat_mode).then_some("f"),
filter_active: !ctx.chat_mode && source_filter.is_active(),
header_note: None,
action_keys: &[],
action_keys: if ctx.chat_mode || focused_is_foreign {
&[]
} else {
&[('d', "delete")]
},
disable_search: false,
compact_bottom_bar: false,
search_only_on_slash: false,
vim_normal_first: crate::appearance::cache::load_vim_mode(),
};
match crate::views::session_picker::handle_pending_delete_key(ctx.sp_pending_delete, ev) {
crate::views::session_picker::PendingDeleteKey::Confirm(pd) => {
return InputOutcome::Action(Action::DeleteSession {
source: pd.source,
session_id: pd.session_id,
cwd: pd.cwd,
});
}
crate::views::session_picker::PendingDeleteKey::Cancel => {
return InputOutcome::Changed;
}
crate::views::session_picker::PendingDeleteKey::Disarmed
| crate::views::session_picker::PendingDeleteKey::NotArmed => {}
}
if let Event::Key(key) = ev {
if key.kind == KeyEventKind::Press
&& (key!('c', CONTROL).matches(key) || key!('d', CONTROL).matches(key))
@ -3320,7 +3501,11 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
});
}
}
let selected_before = ctx.sp_state.selected;
let outcome = handle_picker_input(ev, ctx.sp_state, entry_count, &config);
if ctx.sp_pending_delete.is_some() && ctx.sp_state.selected != selected_before {
*ctx.sp_pending_delete = None;
}
match outcome {
PickerOutcome::Selected(i) => match entry_map.get(i).and_then(|e| e.as_ref()) {
Some(PickerItem::Fuzzy { original_index }) => {
@ -3350,6 +3535,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
*ctx.sp_entries = None;
ctx.sp_state.reset();
*ctx.sp_source_filter = crate::views::session_picker::SourceFilter::default();
*ctx.sp_pending_delete = None;
return InputOutcome::Action(Action::SessionPickerClosed);
}
PickerOutcome::Expand(i) => {
@ -3443,6 +3629,16 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
PickerOutcome::FilterCycled => {
return InputOutcome::Action(Action::CycleSessionSourceFilter);
}
PickerOutcome::Action('d') => {
*ctx.sp_pending_delete =
crate::views::session_picker::pending_delete_from_selection(
ctx.sp_state.selected,
&entry_map,
ctx.sp_entries.as_deref(),
ctx.sp_content_results.as_deref(),
);
return InputOutcome::Changed;
}
PickerOutcome::NonSelectableClick(_)
| PickerOutcome::TabChanged(_)
| PickerOutcome::Action(_) => {
@ -3801,6 +3997,20 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
*ctx.on_upgrade_cta = over_upgrade;
return InputOutcome::Changed;
}
#[cfg(feature = "local-workspace")]
{
let over_ws = ctx
.workspace_mode_rects
.row
.is_some_and(|r| r.contains(pos));
if over_ws != *ctx.on_workspace_mode {
*ctx.on_workspace_mode = over_ws;
return InputOutcome::Changed;
}
if over_ws {
return InputOutcome::Changed;
}
}
let over_banner = ctx
.privacy_banner_opt_in_rect
.is_some_and(|r| r.contains(pos))
@ -4332,6 +4542,9 @@ impl AppView {
subscription_tier: self.subscription_tier.as_deref(),
session_picker_grouped: self.session_picker_grouped,
session_picker_source_filter: self.session_picker_source_filter,
session_picker_pending_delete: self
.session_picker_pending_delete
.is_some(),
chat_mode: self.chat_mode,
credit_balance: self.credit_balance.as_ref(),
auto_topup: self.auto_topup.as_ref(),
@ -4342,6 +4555,12 @@ impl AppView {
welcome_announcement_expanded: self.welcome_announcement.expanded,
upgrade_cta: hero_cta.map(|(_owner, label, _)| label),
privacy_banner,
#[cfg(feature = "local-workspace")]
workspace_mode: self.welcome_workspace_mode,
#[cfg(feature = "local-workspace")]
workspace_mode_startup_locked: self.local_workspace_startup_locked,
#[cfg(feature = "local-workspace")]
workspace_mode_ack_pending: self.welcome_local_workspace_ack_pending,
};
let result = crate::views::welcome::render_welcome(
view_area,
@ -4364,6 +4583,10 @@ impl AppView {
result.privacy_banner_opt_out_rect;
self.welcome_privacy_banner_terms_rect = result.privacy_banner_terms_rect;
self.welcome_privacy_banner_policy_rect = result.privacy_banner_policy_rect;
#[cfg(feature = "local-workspace")]
{
self.welcome_workspace_mode_rects = result.workspace_mode_rects;
}
self.welcome_changelog_cta_rect = result.changelog_cta_rect;
if let Some((ref msg, _)) = self.welcome_toast {
crate::views::welcome::paint_welcome_toast(
@ -5728,6 +5951,16 @@ pub(crate) mod tests {
subagents: false,
ask_user: false,
chat_mode: false,
#[cfg(feature = "local-workspace")]
welcome_workspace_mode: crate::views::welcome::WelcomeWorkspaceMode::Sandbox,
#[cfg(feature = "local-workspace")]
local_workspace_startup_locked: false,
#[cfg(feature = "local-workspace")]
welcome_session_local_workspace: None,
#[cfg(feature = "local-workspace")]
welcome_local_workspace_ack_pending: false,
#[cfg(feature = "local-workspace")]
welcome_history_load_as_build: false,
mouse_captured: true,
new_worktree_dialog: None,
contextual_hints: Default::default(),
@ -5812,6 +6045,10 @@ pub(crate) mod tests {
welcome_privacy_banner_opt_out_rect: None,
welcome_privacy_banner_terms_rect: None,
welcome_privacy_banner_policy_rect: None,
#[cfg(feature = "local-workspace")]
welcome_workspace_mode_rects: Default::default(),
#[cfg(feature = "local-workspace")]
welcome_on_workspace_mode: false,
welcome_toast: None,
welcome_on_privacy_banner: false,
welcome_on_upgrade_cta: false,
@ -5835,6 +6072,7 @@ pub(crate) mod tests {
session_picker_lanes: Default::default(),
session_picker_detail_generation: 0,
session_picker_entries_query: None,
session_picker_pending_delete: None,
welcome_tick: 0,
welcome_shimmer_frame: 0,
startup_warnings: Vec::new(),
@ -11405,6 +11643,25 @@ pub(crate) mod tests {
"Ctrl+X must be intercepted before the agent sees it",
);
}
/// Overlay Ctrl+X during `/compact` cancels compaction (same as `[stop]`).
#[test]
fn overlay_ctrl_x_compact_running_cancels_without_arming() {
use crate::app::agent::{AgentCommand, AgentState};
let (mut app, id) = neutral_overlay_app();
app.agents.get_mut(&id).unwrap().session.state = AgentState::CommandRunning {
command: AgentCommand::Compact,
started_at: std::time::Instant::now(),
};
let outcome = app.handle_input(&key_event(KeyCode::Char('x'), KeyModifiers::CONTROL));
assert!(
matches!(outcome, InputOutcome::Action(Action::CancelTurn)),
"Ctrl+X during /compact must cancel, got {outcome:?}",
);
assert!(
app.pending_action.is_none(),
"Ctrl+X during /compact must not arm close confirm",
);
}
/// Overlay Ctrl+X on a non-turn busy agent (command in flight,
/// cancel pending) — `Action::CancelTurn` would no-op for these
/// states, so the press arms the two-press close instead of
@ -11413,10 +11670,6 @@ pub(crate) mod tests {
fn overlay_ctrl_x_command_or_cancelling_agent_arms_close_confirm() {
use crate::app::agent::{AgentCommand, AgentState};
let states = [
AgentState::CommandRunning {
command: AgentCommand::Compact,
started_at: std::time::Instant::now(),
},
AgentState::TurnCancelling,
AgentState::CommandCancelling {
command: AgentCommand::Compact,
@ -11739,4 +11992,183 @@ pub(crate) mod tests {
InputOutcome::Action(Action::CycleSessionSourceFilter)
));
}
#[cfg(feature = "local-workspace")]
#[test]
fn welcome_ctrl_e_cycles_workspace_mode() {
use crate::views::welcome::WelcomeWorkspaceMode;
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.auth_state = AuthState::Done;
app.trust_state = TrustState::Done;
assert_eq!(app.welcome_workspace_mode, WelcomeWorkspaceMode::Sandbox);
let key = Event::Key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL));
let outcome = app.handle_input(&key);
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(
app.welcome_workspace_mode,
WelcomeWorkspaceMode::LocalWorkspace
);
let _ = app.handle_input(&key);
assert_eq!(app.welcome_workspace_mode, WelcomeWorkspaceMode::Sandbox);
}
#[cfg(feature = "local-workspace")]
#[test]
fn welcome_ack_cancel_clears_history_bypass() {
use crate::views::welcome::WelcomeWorkspaceMode;
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.auth_state = AuthState::Done;
app.trust_state = TrustState::Done;
app.welcome_local_workspace_ack_pending = true;
app.welcome_workspace_mode = WelcomeWorkspaceMode::LocalWorkspace;
app.welcome_history_load_as_build = true;
app.deferred_startup.worktree = true;
app.deferred_startup.history_load_as_build = true;
let outcome = app.handle_input(&key_event(KeyCode::Char('n'), KeyModifiers::NONE));
assert!(matches!(outcome, InputOutcome::Changed));
assert!(!app.welcome_local_workspace_ack_pending);
assert_eq!(app.welcome_workspace_mode, WelcomeWorkspaceMode::Sandbox);
assert!(
!app.welcome_history_load_as_build,
"ACK cancel must drop history bypass"
);
assert!(!app.deferred_startup.history_load_as_build);
assert!(!app.deferred_startup.worktree);
}
#[cfg(feature = "local-workspace")]
#[test]
fn welcome_workspace_click_selects_mode() {
use crate::views::welcome::{WelcomeWorkspaceMode, WorkspaceModeHitRects};
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.auth_state = AuthState::Done;
app.trust_state = TrustState::Done;
app.welcome_workspace_mode_rects = WorkspaceModeHitRects {
options: [
Some(ratatui::layout::Rect::new(10, 5, 9, 1)),
Some(ratatui::layout::Rect::new(20, 5, 17, 1)),
],
row: Some(ratatui::layout::Rect::new(0, 5, 80, 1)),
};
let click = Event::Mouse(crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: 25,
row: 5,
modifiers: KeyModifiers::NONE,
});
let outcome = app.handle_input(&click);
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(
app.welcome_workspace_mode,
WelcomeWorkspaceMode::LocalWorkspace
);
}
#[cfg(feature = "local-workspace")]
#[test]
fn welcome_workspace_locked_ignores_cycle_and_click() {
use crate::views::welcome::{WelcomeWorkspaceMode, WorkspaceModeHitRects};
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.auth_state = AuthState::Done;
app.trust_state = TrustState::Done;
app.local_workspace_startup_locked = true;
app.welcome_workspace_mode = WelcomeWorkspaceMode::LocalWorkspace;
app.welcome_workspace_mode_rects = WorkspaceModeHitRects {
options: [
Some(ratatui::layout::Rect::new(10, 5, 9, 1)),
Some(ratatui::layout::Rect::new(20, 5, 17, 1)),
],
row: Some(ratatui::layout::Rect::new(0, 5, 80, 1)),
};
let key = Event::Key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL));
assert!(matches!(
app.handle_input(&key),
InputOutcome::Unchanged | InputOutcome::Changed
));
assert_eq!(
app.welcome_workspace_mode,
WelcomeWorkspaceMode::LocalWorkspace
);
let click = Event::Mouse(crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: 12,
row: 5,
modifiers: KeyModifiers::NONE,
});
let _ = app.handle_input(&click);
assert_eq!(
app.welcome_workspace_mode,
WelcomeWorkspaceMode::LocalWorkspace,
"locked picker must not change selection"
);
}
#[cfg(feature = "local-workspace")]
#[test]
fn welcome_ctrl_e_ignored_while_history_picker_open() {
use crate::views::welcome::WelcomeWorkspaceMode;
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.auth_state = AuthState::Done;
app.trust_state = TrustState::Done;
app.session_picker_entries = Some(vec![]);
app.session_picker_state.set_query("keep-me");
let before = app.welcome_workspace_mode;
let key = Event::Key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL));
let outcome = app.handle_input(&key);
assert_eq!(app.welcome_workspace_mode, before);
assert!(
!matches!(outcome, InputOutcome::Action(Action::ForceDeepSearch)),
"history open: Ctrl+E must not cycle or soft-refresh: {outcome:?}"
);
assert_eq!(app.session_picker_state.query(), "keep-me");
let _ = WelcomeWorkspaceMode::Sandbox;
}
#[cfg(feature = "local-workspace")]
#[test]
fn welcome_ctrl_e_ignored_while_authenticating() {
use crate::views::welcome::WelcomeWorkspaceMode;
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.auth_state = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::Command,
};
app.trust_state = TrustState::Done;
assert_eq!(app.welcome_workspace_mode, WelcomeWorkspaceMode::Sandbox);
let key = Event::Key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL));
let _ = app.handle_input(&key);
assert_eq!(
app.welcome_workspace_mode,
WelcomeWorkspaceMode::Sandbox,
"Ctrl+E must not cycle mode before auth is Done"
);
}
#[cfg(feature = "local-workspace")]
#[test]
fn welcome_ctrl_e_ignored_when_zdr_blocked() {
use crate::views::welcome::WelcomeWorkspaceMode;
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.auth_state = AuthState::Done;
app.trust_state = TrustState::Done;
app.is_zdr = true;
app.zdr_access_enabled = false;
assert_eq!(app.welcome_workspace_mode, WelcomeWorkspaceMode::Sandbox);
let key = Event::Key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL));
let _ = app.handle_input(&key);
assert_eq!(
app.welcome_workspace_mode,
WelcomeWorkspaceMode::Sandbox,
"Ctrl+E must not cycle mode on ZDR-blocked welcome"
);
}
}

View file

@ -606,6 +606,32 @@ pub struct PagerArgs {
/// Disable plan mode.
#[arg(long = "no-plan")]
pub no_plan: bool,
/// Own a local `workspace_server` (replaces remote sandbox). Requires `--chat`.
///
/// Compiled only with `--features local-workspace` (not implied by `chat`).
#[cfg(feature = "local-workspace")]
#[arg(
long = "local-workspace",
num_args = 0..= 1,
value_name = "CWD",
conflicts_with = "local_workspace_attach",
requires = "chat"
)]
pub local_workspace: Option<Option<PathBuf>>,
/// Attach an existing local `workspace_server` by `server_id`,
/// replacing the chat sandbox (ExistingWorkspace only). Requires `--chat`.
#[cfg(feature = "local-workspace")]
#[arg(
long = "local-workspace-attach",
value_name = "SERVER_ID",
conflicts_with = "local_workspace",
requires = "chat"
)]
pub local_workspace_attach: Option<String>,
/// Cwd override for local-workspace attach/own. Requires `--chat`.
#[cfg(feature = "local-workspace")]
#[arg(long = "local-workspace-cwd", value_name = "PATH", requires = "chat")]
pub local_workspace_cwd: Option<PathBuf>,
/// Disable subagent spawning.
#[arg(long = "no-subagents")]
pub no_subagents: bool,
@ -824,6 +850,21 @@ impl PagerArgs {
pub fn chat(&self) -> bool {
false
}
/// `--local-workspace[=cwd]` own-mode flag.
#[cfg(feature = "local-workspace")]
pub fn local_workspace(&self) -> Option<Option<&std::path::Path>> {
self.local_workspace.as_ref().map(|inner| inner.as_deref())
}
/// `--local-workspace-attach=<server_id>`.
#[cfg(feature = "local-workspace")]
pub fn local_workspace_attach(&self) -> Option<&str> {
self.local_workspace_attach.as_deref()
}
/// `--local-workspace-cwd=<path>`.
#[cfg(feature = "local-workspace")]
pub fn local_workspace_cwd(&self) -> Option<&std::path::Path> {
self.local_workspace_cwd.as_deref()
}
/// Get the session ID to resume, from either --resume or --load (hidden alias).
///
/// Returns `None` when `--resume` was used without a value (the empty-string

View file

@ -136,6 +136,16 @@ pub(super) fn reseed_tip_for_new_session(app: &mut AppView) {
pub(super) fn show_welcome(app: &mut AppView) {
app.active_view = ActiveView::Welcome;
app.welcome_announcement = WelcomeAnnouncementState::default();
// Drop stale welcome workspace one-shot / ACK so a later create/load
// cannot inherit an override from a deferred or abandoned NewSession.
#[cfg(feature = "local-workspace")]
{
app.welcome_session_local_workspace = None;
app.welcome_local_workspace_ack_pending = false;
// Abandoning a session/restore must not leak history bypass into the
// next welcome LoadSession / SessionFlags.chat_mode batch.
app.welcome_history_load_as_build = false;
}
}
/// Restore the view a mid-session auth flow launched from, falling back to the

View file

@ -499,7 +499,7 @@ fn clear_pending_overlay_stop(app: &mut AppView) {
/// session.
/// - First press, any other state (idle, command in flight, cancel
/// pending) → arms `AppView::pending_action` with the dashboard's
/// 2s `STOP_CONFIRM_WINDOW`; the shortcuts bar paints "press Ctrl+x
/// 2s `CONFIRM_WINDOW`; the shortcuts bar paints "press Ctrl+x
/// again to close this session". Cancel can't help in the non-idle
/// variants of this arm — `dispatch_cancel_turn` no-ops unless a
/// turn is running, and command cancellation isn't implemented (see
@ -522,7 +522,7 @@ pub(super) fn dispatch_dashboard_overlay_stop(app: &mut AppView) -> Vec<Effect>
if app
.agents
.get(&id)
.is_some_and(|a| a.session.state.is_turn_running())
.is_some_and(|a| a.session.state.is_turn_running() || a.session.state.is_compact_running())
{
return dispatch_cancel_turn(app);
}
@ -1865,7 +1865,7 @@ pub(super) fn dispatch_dashboard_commit_rename(app: &mut AppView) -> Vec<Effect>
/// Without this, closing the selected agent leaves a stale cursor that
/// `reanchor_selection` drops to `None`, and the next ↑/↓ restarts from
/// the top of the list — a jarring jump.
fn dashboard_neighbor_row(
pub(super) fn dashboard_neighbor_row(
app: &AppView,
closed: &crate::views::dashboard::DashboardRowId,
) -> Option<crate::views::dashboard::DashboardRowId> {
@ -1911,6 +1911,16 @@ fn dashboard_neighbor_row(
})
}
/// Ctrl+X on the selected dashboard row, keyed off the row's `RowState`
/// (the same `allows_delete` the renderer paints `[✗]` with):
/// - Deletable row: first press arms, a second within the window deletes.
/// - Busy top-level row: stop what keeps it busy (running turn, background
/// tasks/monitors/`/loop`s, or queued prompts), never arm (a roster row
/// has no local work to stop, so it just reports it must be stopped first).
/// - Subagent row: kill the subagent.
///
/// Delete only ever runs on an idle row, so it is never queued alongside
/// a `CancelTurn`.
pub(super) fn dispatch_dashboard_stop(app: &mut AppView) -> Vec<Effect> {
use crate::views::dashboard::DashboardRowId;
use std::time::Instant;
@ -1921,76 +1931,27 @@ pub(super) fn dispatch_dashboard_stop(app: &mut AppView) -> Vec<Effect> {
match &sel {
DashboardRowId::TopLevel(id) => {
let id = *id;
let Some(agent) = app.agents.get(&id) else {
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
let now = Instant::now();
// `t.elapsed()` is the idiomatic Instant API
// for "how long since this Instant". Behaviour identical
// to `now.duration_since(*t)` when `t <= now`, which is
// the only case the dispatcher constructs.
let already_confirming = app
.dashboard
.as_ref()
.and_then(|d| d.stop_confirm.as_ref())
.is_some_and(|(prev, t)| {
*prev == sel
&& t.elapsed() < crate::views::dashboard::state::STOP_CONFIRM_WINDOW
});
if already_confirming {
// Pick the cursor's next home BEFORE the row vanishes, so
// closing moves the selection down 1 instead of letting it
// go stale (which `reanchor_selection` drops to `None`,
// bouncing the next ↑/↓ back to the top of the list).
let neighbor = dashboard_neighbor_row(app, &sel);
if !crate::views::dashboard::classify_top_level(agent).allows_delete() {
// Busy row: stop what keeps it out of Idle — a running turn,
// background work (bg tasks, monitors, scheduled `/loop`s),
// or queued prompts. Never arms; once the row settles to
// idle, Ctrl+X twice deletes it.
let stopped = stop_top_level_activity(agent);
if let Some(d) = app.dashboard.as_mut() {
d.stop_confirm = None;
d.delete_confirm = None;
}
// Second press: close the agent.
let effects = dispatch_sessions_confirm_close(app, id);
// Only move the cursor if the close actually happened
// (it's refused for the last remaining session).
if !app.agents.contains_key(&id)
&& let Some(d) = app.dashboard.as_mut()
{
match neighbor {
Some(n) => d.focus_row(n),
// No neighbour left — land on the always-present
// `[+ New Agent]` button via the focus helper so the
// "exactly one cursor active" invariant holds (a bare
// `selected = None` would leave no cursor and drop the
// footer into its defensive fallback).
None => d.focus_new_agent_button(),
return match stopped {
Some(effects) => effects,
None => {
app.show_toast("Stop the session before deleting");
vec![]
}
}
return effects;
};
}
// First press: cancel turn if running, plant confirmation.
let mut effects = Vec::new();
if !agent.session.state.is_idle()
&& let Some(sid) = agent.session.session_id.clone()
{
effects.push(Effect::CancelTurn {
session_id: sid,
cancel_subagents: true,
trigger: None,
// Dashboard first-press cancel — no local prompt rewind.
rewind_if_pristine: false,
});
}
if let Some(d) = app.dashboard.as_mut() {
// The footer's `ShortcutsBar::with_pending` already
// paints the "press Ctrl+X again to close this
// session" prompt in the bottom bar. Surfacing the
// same line via `error_toast` would also bleed it
// into the dispatch input placeholder — two copies
// of the same hint, in two different places, with
// the dispatch one stealing visual weight from the
// user's typing area. The footer hint is the
// canonical surface.
d.stop_confirm = Some((sel, now));
}
effects
arm_or_delete(app, sel)
}
DashboardRowId::Subagent {
parent,
@ -2014,9 +1975,200 @@ pub(super) fn dispatch_dashboard_stop(app: &mut AppView) -> Vec<Effect> {
.into_iter()
.collect()
}
// Roster-only rows are hosted elsewhere — this client can't stop
// them.
DashboardRowId::Roster { .. } => vec![],
DashboardRowId::Roster { session_id } => {
let entry = app
.leader_roster
.iter()
.chain(app.dashboard_local_sessions.iter())
.find(|e| e.session_id == session_id.as_str());
match entry {
None => {
app.show_toast("Session is no longer in the list");
vec![]
}
// Chat conversations can't be deleted from here yet, so
// don't arm a confirm that could never succeed.
Some(e) if e.origin.kind == "conversation" => {
app.show_toast("Deleting chat conversations isn't supported yet");
vec![]
}
// No local turn to cancel, so a busy roster row can't delete.
Some(e)
if !crate::views::dashboard::roster_activity_to_state(e.activity)
.allows_delete() =>
{
app.show_toast("Stop the session before deleting");
vec![]
}
Some(_) => arm_or_delete(app, sel),
}
}
}
}
/// Stop everything keeping a busy top-level row out of Idle: a running
/// turn, running background tasks/monitors, scheduled `/loop`s, and queued
/// (unsent) prompts. Marks local state optimistically (mirroring the agent
/// view's own kill paths). Returns `Some(effects)` when it stopped
/// something — the effects may be empty if the only thing to stop was the
/// local prompt queue — or `None` when there was nothing stoppable (so the
/// caller can explain why).
fn stop_top_level_activity(agent: &mut crate::app::agent_view::AgentView) -> Option<Vec<Effect>> {
let session_id = agent.session.session_id.clone();
let mut effects = Vec::new();
// Turn / background work need a session id to reach the backend.
if let Some(session_id) = session_id {
if !agent.session.state.is_idle() {
effects.push(Effect::CancelTurn {
session_id: session_id.clone(),
cancel_subagents: true,
trigger: None,
rewind_if_pristine: false,
});
}
let running: Vec<String> = agent
.session
.bg_tasks
.values()
.filter(|t| t.status == crate::app::agent::BgTaskStatus::Running)
.map(|t| t.task_id.clone())
.collect();
for task_id in running {
if let Some(task) = agent.session.bg_tasks.get_mut(&task_id) {
task.pending_kill = true;
task.kill_requested_at = Some(std::time::Instant::now());
}
effects.push(Effect::KillBgTask {
session_id: session_id.clone(),
task_id,
});
}
let scheduled: Vec<String> = agent.session.scheduled_tasks.keys().cloned().collect();
for task_id in scheduled {
agent.session.scheduled_tasks.remove(&task_id);
effects.push(Effect::DeleteScheduledTask {
session_id: session_id.clone(),
task_id,
});
}
}
// Queued prompts are local (unsent), so dropping them needs no effect
// and works even before the session exists (a just-dispatched row).
let dropped_queue = !agent.session.pending_prompts.is_empty();
if dropped_queue {
agent.session.pending_prompts.clear();
agent.sync_queue_pane();
}
(!effects.is_empty() || dropped_queue).then_some(effects)
}
/// A live arm on `sel` confirms and deletes; otherwise (re)arm.
fn arm_or_delete(app: &mut AppView, sel: crate::views::dashboard::DashboardRowId) -> Vec<Effect> {
let armed = app
.dashboard
.as_mut()
.and_then(|d| d.armed_delete_row())
.as_ref()
== Some(&sel);
if armed {
return delete_dashboard_row(app, sel);
}
if let Some(d) = app.dashboard.as_mut() {
d.arm_delete(sel);
}
vec![]
}
pub(super) fn dispatch_dashboard_delete(app: &mut AppView) -> Vec<Effect> {
let Some(d) = app.dashboard.as_mut() else {
return vec![];
};
// The `y` confirm: only fire on a row whose arm is still live AND is
// still the selected row. Reanchor / gc can drop `selected` without
// going through the focus helpers, which would otherwise let `y`
// delete a row the cursor has left.
let Some(sel) = d.armed_delete_row() else {
return vec![];
};
if d.selected.as_ref() != Some(&sel) {
d.delete_confirm = None;
return vec![];
}
delete_dashboard_row(app, sel)
}
/// Delete `row`, which the caller has confirmed is idle and armed. Takes
/// `row` as a parameter (not read back off `delete_confirm`) and never
/// cancels a turn or kills a task — delete is a settled-row operation.
fn delete_dashboard_row(
app: &mut AppView,
row: crate::views::dashboard::DashboardRowId,
) -> Vec<Effect> {
use crate::views::dashboard::DashboardRowId;
if let Some(d) = app.dashboard.as_mut() {
d.delete_confirm = None;
}
match row {
DashboardRowId::TopLevel(id) => {
let Some(agent) = app.agents.get(&id) else {
return vec![];
};
// Defensive re-check via the SAME predicate the renderer and
// arm path use: state can change between arming and confirming
// (a new turn, `/loop`, queued prompt, replay, needs-input, or
// bg task), and delete must never run on a non-settled row.
if !crate::views::dashboard::classify_top_level(agent).allows_delete() {
app.show_toast("Stop the session before deleting");
return vec![];
}
let Some(session_id) = agent.session.session_id.clone() else {
app.show_toast("No session history to delete");
return vec![];
};
let cwd = agent.session.cwd.display().to_string();
app.show_toast("Deleting session\u{2026}");
vec![Effect::DeleteSession {
source: "current".into(),
session_id: session_id.to_string(),
cwd,
after: crate::app::actions::AfterSessionDelete::Dashboard,
}]
}
DashboardRowId::Subagent { .. } => {
app.show_toast("Subagent rows can't be deleted from the dashboard");
vec![]
}
DashboardRowId::Roster { session_id } => {
let Some(entry) = app
.leader_roster
.iter()
.chain(app.dashboard_local_sessions.iter())
.find(|e| e.session_id == session_id)
.cloned()
else {
app.show_toast("Session is no longer in the list");
return vec![];
};
if entry.origin.kind == "conversation" {
app.show_toast("Deleting chat conversations isn't supported yet");
return vec![];
}
if !crate::views::dashboard::roster_activity_to_state(entry.activity).allows_delete() {
app.show_toast("Stop the session before deleting");
return vec![];
}
app.show_toast("Deleting session\u{2026}");
vec![Effect::DeleteSession {
source: "local".into(),
session_id,
cwd: entry.cwd,
after: crate::app::actions::AfterSessionDelete::Dashboard,
}]
}
}
}

View file

@ -14,7 +14,7 @@ use super::session::fork::open_project_question;
use super::session::lifecycle::skip_picker_and_create_session;
use super::voice::{merge_prompt_with_voice_interim, voice_stop_on_submit};
use crate::app::actions::{Action, DoctorFixTarget, Effect};
use crate::app::agent::{AgentId, AgentState};
use crate::app::agent::{AgentCommand, AgentId, AgentState};
use crate::app::agent_view::AgentView;
use crate::app::app_view::{ActiveView, AppView};
use crate::notifications::{NotificationEvent, NotificationEventKind};
@ -1614,10 +1614,23 @@ pub(super) fn handle_compact_complete(
result: Result<(), String>,
) -> Vec<Effect> {
if let Some(agent) = app.agents.get_mut(&agent_id) {
// Defensive: only process if we're still in CommandRunning state.
// This guards against state machine bugs or future cancellation support.
if !matches!(agent.session.state, AgentState::CommandRunning { .. }) {
tracing::debug!("Ignoring CompactComplete (not in CommandRunning state)");
// Defensive: only process if we're still in a compact command state.
let was_cancelling = matches!(
agent.session.state,
AgentState::CommandCancelling {
command: AgentCommand::Compact,
}
);
if !matches!(
agent.session.state,
AgentState::CommandRunning {
command: AgentCommand::Compact,
..
} | AgentState::CommandCancelling {
command: AgentCommand::Compact,
}
) {
tracing::debug!("Ignoring CompactComplete (not in compact command state)");
return vec![];
}
@ -1632,6 +1645,11 @@ pub(super) fn handle_compact_complete(
},
));
}
Err(err) if was_cancelling || err.contains("compact cancelled") => {
agent.scrollback.push_block(RenderBlock::session_event(
SessionEvent::CompactionCancelled,
));
}
Err(err) => {
tracing::error!(agent = ?agent_id, error = %err, "Compaction failed");
agent.scrollback.push_block(RenderBlock::session_event(

View file

@ -11,16 +11,17 @@ use super::ctx::{
use super::dashboard::{
dispatch_dashboard_attach, dispatch_dashboard_begin_rename, dispatch_dashboard_change_location,
dispatch_dashboard_commit_rename, dispatch_dashboard_confirm_worktree,
dispatch_dashboard_create_new_agent_with_detail, dispatch_dashboard_dispatch,
dispatch_dashboard_dispatch_slash, dispatch_dashboard_open_location_picker,
dispatch_dashboard_open_shortcuts_help, dispatch_dashboard_overlay_cycle,
dispatch_dashboard_overlay_exit, dispatch_dashboard_overlay_stop,
dispatch_dashboard_peek_cycle_mode, dispatch_dashboard_peek_reply,
dispatch_dashboard_permission_followup, dispatch_dashboard_permission_select,
dispatch_dashboard_question_answer, dispatch_dashboard_reorder, dispatch_dashboard_select,
dispatch_dashboard_stop, dispatch_dashboard_toggle_auto_approve,
dispatch_dashboard_toggle_grouping, dispatch_dashboard_toggle_pin,
dispatch_dashboard_toggle_worktree, dispatch_exit_dashboard, dispatch_open_dashboard,
dispatch_dashboard_create_new_agent_with_detail, dispatch_dashboard_delete,
dispatch_dashboard_dispatch, dispatch_dashboard_dispatch_slash,
dispatch_dashboard_open_location_picker, dispatch_dashboard_open_shortcuts_help,
dispatch_dashboard_overlay_cycle, dispatch_dashboard_overlay_exit,
dispatch_dashboard_overlay_stop, dispatch_dashboard_peek_cycle_mode,
dispatch_dashboard_peek_reply, dispatch_dashboard_permission_followup,
dispatch_dashboard_permission_select, dispatch_dashboard_question_answer,
dispatch_dashboard_reorder, dispatch_dashboard_select, dispatch_dashboard_stop,
dispatch_dashboard_toggle_auto_approve, dispatch_dashboard_toggle_grouping,
dispatch_dashboard_toggle_pin, dispatch_dashboard_toggle_worktree, dispatch_exit_dashboard,
dispatch_open_dashboard,
};
use super::import_claude::{
dispatch_dismiss_claude_import, dispatch_import_claude, dispatch_import_claude_cancel,
@ -193,6 +194,55 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
effects
}
Action::NewSession => dispatch_new_session(app),
#[cfg(feature = "local-workspace")]
Action::ConfirmWelcomeLocalWorkspaceAck => {
match crate::views::welcome::workspace_mode::confirm_welcome_local_workspace_ack(
&app.cwd, false,
) {
Ok(cfg) => {
app.welcome_workspace_mode =
crate::views::welcome::WelcomeWorkspaceMode::LocalWorkspace;
app.welcome_session_local_workspace = Some(Some(cfg));
app.welcome_local_workspace_ack_pending = false;
let effects = if app.deferred_startup.worktree {
app.deferred_startup.worktree = false;
let label = app.deferred_startup.worktree_label.take();
let git_ref = app.deferred_startup.worktree_ref.take();
let load_session_id = match app.deferred_startup.session.take() {
Some(crate::app::session_startup::DeferredSessionStartup::Load {
session_id,
..
}) => Some(session_id),
other => {
app.deferred_startup.session = other;
None
}
};
let preferred = app.deferred_startup.preferred_session_id.take();
dispatch_new_worktree_session(
app,
load_session_id,
label,
None,
None,
git_ref,
preferred,
)
} else {
dispatch_new_session(app)
};
if !crate::app::event_loop::welcome_oneshot_applies_to_effects(&effects) {
app.welcome_session_local_workspace = None;
}
effects
}
Err(err) => {
tracing::warn!("welcome local-workspace ack: {err}");
app.show_toast(&format!("Local workspace: {err}"));
vec![]
}
}
}
Action::ChooseNewSessionMode => open_new_session_question(app),
Action::ExitSession | Action::ExitSessionConfirmed => dispatch_exit_session(app),
Action::DeleteCurrentSession => open_delete_current_session_question(app),
@ -1260,6 +1310,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
vec![]
}
Action::DashboardStop => dispatch_dashboard_stop(app),
Action::DashboardDelete => dispatch_dashboard_delete(app),
Action::DashboardCycleMode => {
let policy_block = app.yolo_policy_block;
if let Some(d) = app.dashboard.as_mut() {

View file

@ -138,6 +138,23 @@ impl PickerSurface<'_> {
}
}
/// Kind facet for welcome multi-source history under `--chat`.
///
/// Sandbox → `chat` (gateway); Local → `build` (local-disk). Modal / non-welcome
/// fetches leave this `None` so the shell keeps its default chat-mode force.
pub(in crate::app::dispatch) fn welcome_history_kind_filter(app: &AppView) -> Option<Vec<String>> {
#[cfg(feature = "local-workspace")]
{
if app.chat_mode && matches!(app.active_view, crate::app::app_view::ActiveView::Welcome) {
return Some(vec![
app.welcome_workspace_mode.history_kind_filter().to_string(),
]);
}
}
let _ = app;
None
}
pub(in crate::app::dispatch) fn dispatch_fetch_session_list(app: &mut AppView) -> Vec<Effect> {
app.session_picker_detail_generation += 1;
app.session_picker_loading = true;
@ -154,9 +171,18 @@ pub(in crate::app::dispatch) fn dispatch_fetch_session_list(app: &mut AppView) -
}
app.foreign_session_scan_seq += 1;
let foreign_seq = app.foreign_session_scan_seq;
let kind_filter = welcome_history_kind_filter(app);
#[cfg(feature = "local-workspace")]
crate::views::welcome::workspace_mode::log_history_source(
"session_list_fetch_dispatch",
Some(app.welcome_workspace_mode),
kind_filter.as_deref(),
None,
);
let mut effects = vec![Effect::FetchSessionList {
query: None,
seq: app.session_picker_list_seq,
kind_filter,
}];
let foreign_effect = if app.chat_mode {
app.foreign_scan_coordinator.begin_request(foreign_seq);
@ -269,20 +295,19 @@ pub(in crate::app::dispatch) fn handle_session_list_loaded(
app.show_toast(&notice);
} else if scope.is_relaxed()
&& app.session_picker_relaxed_notified_for.as_deref() != Some(app.cwd.as_path())
{
// Welcome view drops toasts; don't consume the one-shot notice unless
// it can render.
if !matches!(app.active_view, crate::app::app_view::ActiveView::Welcome) {
// Notify once per directory; the browse is scoped to `app.cwd`.
app.session_picker_relaxed_notified_for = Some(app.cwd.clone());
let message = match scope {
ListScope::Repo => {
"No sessions in this directory. Showing other sessions from this repository."
}
_ => "No sessions in this directory. Showing sessions from other directories.",
};
app.show_toast(message);
}
&& !matches!(app.active_view, crate::app::app_view::ActiveView::Welcome)
{
// Notify once per directory; the browse is scoped to `app.cwd`.
app.session_picker_relaxed_notified_for = Some(app.cwd.clone());
let message = match scope {
ListScope::Repo => {
"No sessions in this directory. Showing other sessions from this repository."
}
_ => "No sessions in this directory. Showing sessions from other directories.",
};
app.show_toast(message);
}
// A cwd-scoped browse clears the latch so a later relax re-notifies; search
// responses leave it alone.

View file

@ -127,6 +127,13 @@ pub(in crate::app::dispatch) fn dispatch_new_session(app: &mut AppView) -> Vec<E
app.deferred_startup.new_session = true;
return vec![];
}
#[cfg(feature = "local-workspace")]
if matches!(app.active_view, ActiveView::Welcome) {
let skip_apply = app.welcome_session_local_workspace.is_some();
if !skip_apply && let Err(effects) = apply_welcome_workspace_on_new_session(app) {
return effects;
}
}
let in_git_repo = get_active_agent(app)
.map(|a| a.current_branch.is_some())
.unwrap_or(app.cwd_has_git_ancestor);
@ -261,6 +268,53 @@ pub(in crate::app::dispatch) fn open_agent_type_mismatch_question(
/// Core new-session logic: create a placeholder agent, push the
/// `/dashboard` tip, and return the `CreateSession` effect.
///
/// Apply welcome workspace selection before creating a session from Welcome.
///
/// Returns `Err(effects)` when session creation should abort (ACK pending or
/// empty effects). `Ok(())` means continue into the normal new-session path.
#[cfg(feature = "local-workspace")]
fn apply_welcome_workspace_on_new_session(app: &mut AppView) -> Result<(), Vec<Effect>> {
use crate::views::welcome::workspace_mode::{
WelcomeWorkspaceMode, WelcomeWorkspacePrepare, prepare_welcome_workspace_for_new_session,
};
match prepare_welcome_workspace_for_new_session(
app.welcome_workspace_mode,
app.local_workspace_startup_locked,
app.chat_mode,
&app.cwd,
false,
) {
Ok(WelcomeWorkspacePrepare::Continue {
session_override,
warning,
}) => {
if let Some(msg) = warning {
tracing::warn!("{msg}");
app.show_toast(&msg);
}
if let Some(override_cfg) = session_override {
app.welcome_session_local_workspace = Some(override_cfg);
}
Ok(())
}
Ok(WelcomeWorkspacePrepare::AwaitAck) => {
app.welcome_local_workspace_ack_pending = true;
app.session_picker_entries = None;
app.session_picker_loading = false;
app.session_picker_list_seq = app.session_picker_list_seq.saturating_add(1);
Err(vec![])
}
Err(err) => {
tracing::warn!("welcome workspace mode: {err}");
app.show_toast(&format!(
"Local workspace unavailable ({err}); using sandbox"
));
app.welcome_session_local_workspace = Some(None);
app.welcome_workspace_mode = WelcomeWorkspaceMode::Sandbox;
Ok(())
}
}
}
/// Factored out of [`dispatch_new_session`] so the worktree-question
/// "No" path can call it directly without re-opening the modal.
pub(in crate::app::dispatch) fn dispatch_new_session_inner(
@ -362,6 +416,26 @@ pub(in crate::app::dispatch) fn dispatch_new_session_inner_with_id(
let chat_kind = consume_chat_kind(app);
if let Some(agent) = app.agents.get_mut(&agent_id) {
agent.chat_kind = chat_kind;
#[cfg(feature = "local-workspace")]
{
let local_intent = match &app.welcome_session_local_workspace {
Some(Some(_)) => true,
Some(None) => false,
None => crate::app::session_startup::active_local_workspace()
.ok()
.flatten()
.is_some(),
};
let (mode, locked) =
crate::views::welcome::workspace_mode::indicator_for_opening_session(
agent.chat_kind,
false,
app.local_workspace_startup_locked,
local_intent,
);
agent.workspace_mode = mode;
agent.workspace_mode_cli_locked = locked;
}
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
agent.mcp_init_progress = Some(McpInitProgress {
total: 0,
@ -549,6 +623,8 @@ pub(in crate::app::dispatch) fn drain_startup_actions(app: &mut AppView) -> Vec<
prompt,
open_dashboard,
pending_chat,
#[cfg(feature = "local-workspace")]
history_load_as_build,
} = app.deferred_startup.take();
let mut effects = Vec::new();
match deferred {
@ -569,6 +645,10 @@ pub(in crate::app::dispatch) fn drain_startup_actions(app: &mut AppView) -> Vec<
session_cwd,
chat_kind,
}) => {
#[cfg(feature = "local-workspace")]
{
app.welcome_history_load_as_build = history_load_as_build;
}
if worktree {
if chat_kind || pending_chat {
app.deferred_startup.pending_chat = true;
@ -672,6 +752,33 @@ pub(in crate::app::dispatch) fn dispatch_new_worktree_session(
git_ref: Option<String>,
preferred_session_id: Option<String>,
) -> Vec<Effect> {
#[cfg(feature = "local-workspace")]
if load_session_id.is_none()
&& matches!(app.active_view, crate::app::app_view::ActiveView::Welcome)
{
let skip_apply = app.welcome_session_local_workspace.is_some();
if !skip_apply && let Err(effects) = apply_welcome_workspace_on_new_session(app) {
app.deferred_startup.worktree = true;
if let Some(ref label) = label {
app.deferred_startup.worktree_label = Some(label.clone());
}
if let Some(ref git_ref) = git_ref {
app.deferred_startup.worktree_ref = Some(git_ref.clone());
}
if let Some(sid) = load_session_id.clone() {
app.deferred_startup.session =
Some(crate::app::session_startup::DeferredSessionStartup::Load {
session_id: sid,
session_cwd: None,
chat_kind: app.deferred_startup.pending_chat,
});
}
if let Some(id) = preferred_session_id.clone() {
app.deferred_startup.preferred_session_id = Some(id);
}
return effects;
}
}
let preferred_session_id =
preferred_session_id.or_else(|| app.deferred_startup.preferred_session_id.take());
if !app.session_startup_allowed() {
@ -710,6 +817,11 @@ pub(in crate::app::dispatch) fn dispatch_new_worktree_session(
action: None,
});
}
#[cfg(feature = "local-workspace")]
{
app.welcome_session_local_workspace = None;
app.welcome_history_load_as_build = false;
}
return vec![];
}
if load_session_id.is_none() {
@ -791,6 +903,26 @@ pub(in crate::app::dispatch) fn dispatch_new_worktree_session(
&app.tier_restricted_commands,
);
agent.chat_kind = chat_kind;
#[cfg(feature = "local-workspace")]
{
let local_intent = match &app.welcome_session_local_workspace {
Some(Some(_)) => true,
Some(None) => false,
None => crate::app::session_startup::active_local_workspace()
.ok()
.flatten()
.is_some(),
};
let (mode, locked) =
crate::views::welcome::workspace_mode::indicator_for_opening_session(
agent.chat_kind,
app.welcome_history_load_as_build,
app.local_workspace_startup_locked,
local_intent,
);
agent.workspace_mode = mode;
agent.workspace_mode_cli_locked = locked;
}
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
agent
.prompt
@ -880,6 +1012,26 @@ pub(in crate::app::dispatch) fn skip_picker_and_create_session(
let chat_kind = consume_chat_kind(app);
if let Some(agent) = app.agents.get_mut(&agent_id) {
agent.chat_kind = chat_kind;
#[cfg(feature = "local-workspace")]
{
let local_intent = match &app.welcome_session_local_workspace {
Some(Some(_)) => true,
Some(None) => false,
None => crate::app::session_startup::active_local_workspace()
.ok()
.flatten()
.is_some(),
};
let (mode, locked) =
crate::views::welcome::workspace_mode::indicator_for_opening_session(
agent.chat_kind,
false,
app.local_workspace_startup_locked,
local_intent,
);
agent.workspace_mode = mode;
agent.workspace_mode_cli_locked = locked;
}
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
agent.mcp_init_progress = Some(McpInitProgress {
total: 0,

View file

@ -9,6 +9,8 @@ use crate::acp::tracker::AcpUpdateTracker;
use crate::app::actions::{Action, Effect};
use crate::app::agent::{AgentCommand, AgentId, AgentSession, AgentState};
use crate::app::agent_view::AgentView;
#[cfg(feature = "local-workspace")]
use crate::app::app_view::ActiveView;
use crate::app::app_view::AppView;
use crate::app::dispatch::ctx::{
SwitchCause, get_active_agent, get_active_agent_mut, switch_to_agent, with_active_agent,
@ -34,6 +36,11 @@ pub(in crate::app::dispatch) fn dispatch_load_session(
chat_kind: bool,
) -> Vec<Effect> {
if !app.session_startup_allowed() {
#[cfg(feature = "local-workspace")]
{
app.deferred_startup.history_load_as_build = app.welcome_history_load_as_build;
app.welcome_history_load_as_build = false;
}
app.deferred_startup.session =
Some(crate::app::session_startup::DeferredSessionStartup::Load {
session_id,
@ -117,17 +124,31 @@ fn dispatch_load_session_ungated(
session_cwd: Option<std::path::PathBuf>,
chat_kind: bool,
) -> Vec<Effect> {
if crate::app::session_startup::chat_mode_refuses_local_build_load(
app.chat_mode,
chat_kind,
&session_id,
&app.cwd,
) {
#[cfg(feature = "local-workspace")]
let bypass_chat_refusal = app.welcome_history_load_as_build;
#[cfg(not(feature = "local-workspace"))]
let bypass_chat_refusal = false;
if !bypass_chat_refusal
&& crate::app::session_startup::chat_mode_refuses_local_build_load(
app.chat_mode,
chat_kind,
&session_id,
&app.cwd,
)
{
#[cfg(feature = "local-workspace")]
{
app.welcome_history_load_as_build = false;
}
app.show_toast(crate::app::session_startup::CHAT_MODE_LOCAL_BUILD_REFUSAL);
return vec![];
}
invalidate_picker_fetch_on_dismiss(app);
if focus_if_session_already_open(app, &session_id, chat_kind).is_some() {
#[cfg(feature = "local-workspace")]
{
app.welcome_history_load_as_build = false;
}
return vec![];
}
let acp_session_id = clear_stale_session_id(app, &session_id);
@ -210,6 +231,33 @@ fn dispatch_load_session_ungated(
&app.tier_restricted_commands,
);
agent_mut.chat_kind = chat_kind || app.chat_mode;
#[cfg(feature = "local-workspace")]
{
let history_build = app.welcome_history_load_as_build;
let local_intent = match &app.welcome_session_local_workspace {
Some(Some(_)) => true,
Some(None) => false,
None => {
if chat_kind {
false
} else {
crate::app::session_startup::active_local_workspace()
.ok()
.flatten()
.is_some()
}
}
};
let (mode, cli_locked) =
crate::views::welcome::workspace_mode::indicator_for_opening_session(
chat_kind,
history_build,
app.local_workspace_startup_locked,
local_intent,
);
agent_mut.workspace_mode = mode;
agent_mut.workspace_mode_cli_locked = cli_locked;
}
agent_mut.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
agent_mut
.prompt
@ -313,6 +361,29 @@ pub(in crate::app::dispatch) fn dispatch_pick_session(
return effects;
}
let chat_kind = source == "conversation";
#[cfg(feature = "local-workspace")]
if app.chat_mode && matches!(app.active_view, ActiveView::Welcome) {
if app.local_workspace_startup_locked {
crate::views::welcome::workspace_mode::log_cli_lock_wins(app.welcome_workspace_mode);
} else {
let mode = crate::views::welcome::WelcomeWorkspaceMode::from_history_source(&source);
if app.welcome_workspace_mode != mode {
crate::views::welcome::workspace_mode::log_history_source(
"history_auto_switch",
Some(mode),
None,
Some(source.as_str()),
);
app.welcome_workspace_mode = mode;
}
if chat_kind {
app.welcome_session_local_workspace = None;
}
}
if !chat_kind {
app.welcome_history_load_as_build = true;
}
}
if chat_kind {
return dispatch_load_session(app, session_id, None, true);
}
@ -331,11 +402,19 @@ pub(in crate::app::dispatch) fn dispatch_pick_session(
}
if source == "remote" || source == "both" {
if focus_if_session_already_open(app, &session_id, false).is_some() {
#[cfg(feature = "local-workspace")]
{
app.welcome_history_load_as_build = false;
}
return vec![];
}
app.show_toast("Restoring session from remote...");
dispatch_load_session_with_restore(app, session_id, cwd)
} else {
#[cfg(feature = "local-workspace")]
{
app.welcome_history_load_as_build = false;
}
app.show_toast("Session not found locally");
vec![]
}
@ -412,6 +491,24 @@ pub(in crate::app::dispatch) fn dispatch_pick_session_in_worktree(
app.show_toast("Chat conversations can't be resumed in a worktree");
return vec![];
}
#[cfg(feature = "local-workspace")]
if app.chat_mode && matches!(app.active_view, ActiveView::Welcome) {
if app.local_workspace_startup_locked {
crate::views::welcome::workspace_mode::log_cli_lock_wins(app.welcome_workspace_mode);
} else {
let mode = crate::views::welcome::WelcomeWorkspaceMode::from_history_source(&source);
if app.welcome_workspace_mode != mode {
crate::views::welcome::workspace_mode::log_history_source(
"history_auto_switch",
Some(mode),
None,
Some(source.as_str()),
);
app.welcome_workspace_mode = mode;
}
}
app.welcome_history_load_as_build = true;
}
dispatch_new_worktree_session(app, Some(session_id), None, None, None, None, None)
}
fn keep_picker_entry(
@ -454,9 +551,7 @@ pub(in crate::app::dispatch) fn remove_session_from_pickers(
{
if pending_delete
.as_ref()
.is_some_and(|(pending_source, pending_id, _)| {
pending_source == source && pending_id == session_id
})
.is_some_and(|pd| pd.source == source && pd.session_id == session_id)
{
*pending_delete = None;
}
@ -482,6 +577,13 @@ pub(in crate::app::dispatch) fn remove_session_from_pickers(
);
reanchor_grouped_selection(state, &map);
}
if app
.session_picker_pending_delete
.as_ref()
.is_some_and(|pd| pd.source == source && pd.session_id == session_id)
{
app.session_picker_pending_delete = None;
}
if let Some(list) = app.session_picker_entries.as_mut() {
list.retain(|entry| keep_picker_entry(entry, source, session_id, match_id_only));
}
@ -653,13 +755,18 @@ fn dispatch_chat_search_refetch(app: &mut AppView, force: bool) -> Vec<Effect> {
let seq = app.session_picker_list_seq;
if query.is_empty() {
set_chat_search_loading(app, false);
return vec![Effect::FetchSessionList { query: None, seq }];
return vec![Effect::FetchSessionList {
query: None,
seq,
kind_filter: super::foreign::welcome_history_kind_filter(app),
}];
}
set_chat_search_loading(app, true);
if force {
vec![Effect::FetchSessionList {
query: Some(query),
seq,
kind_filter: super::foreign::welcome_history_kind_filter(app),
}]
} else {
vec![Effect::DebounceSessionSearch { query, seq }]
@ -789,16 +896,30 @@ pub(in crate::app::dispatch) fn dispatch_load_session_with_restore(
session_id: String,
session_cwd: String,
) -> Vec<Effect> {
if crate::app::session_startup::chat_mode_refuses_local_build_load(
app.chat_mode,
false,
&session_id,
&app.cwd,
) {
#[cfg(feature = "local-workspace")]
let bypass_chat_refusal = app.welcome_history_load_as_build;
#[cfg(not(feature = "local-workspace"))]
let bypass_chat_refusal = false;
if !bypass_chat_refusal
&& crate::app::session_startup::chat_mode_refuses_local_build_load(
app.chat_mode,
false,
&session_id,
&app.cwd,
)
{
#[cfg(feature = "local-workspace")]
{
app.welcome_history_load_as_build = false;
}
app.show_toast(crate::app::session_startup::CHAT_MODE_LOCAL_BUILD_REFUSAL);
return vec![];
}
if focus_if_session_already_open(app, &session_id, false).is_some() {
#[cfg(feature = "local-workspace")]
{
app.welcome_history_load_as_build = false;
}
return vec![];
}
let agent_id = AgentId(app.next_agent_id);
@ -870,6 +991,27 @@ pub(in crate::app::dispatch) fn dispatch_load_session_with_restore(
&app.tier_restricted_commands,
);
agent.chat_kind = app.chat_mode;
#[cfg(feature = "local-workspace")]
{
let history_build = app.welcome_history_load_as_build;
let local_intent = match &app.welcome_session_local_workspace {
Some(Some(_)) => true,
Some(None) => false,
None => crate::app::session_startup::active_local_workspace()
.ok()
.flatten()
.is_some(),
};
let (mode, cli_locked) =
crate::views::welcome::workspace_mode::indicator_for_opening_session(
false,
history_build,
app.local_workspace_startup_locked,
local_intent,
);
agent.workspace_mode = mode;
agent.workspace_mode_cli_locked = cli_locked;
}
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
agent
.prompt
@ -1068,6 +1210,7 @@ pub(in crate::app::dispatch) fn handle_session_search_debounce_expired(
return vec![Effect::FetchSessionList {
query: (!query.is_empty()).then_some(query),
seq,
kind_filter: super::foreign::welcome_history_kind_filter(app),
}];
}
if live_deep_search_seq(app) != Some(seq) {
@ -1136,12 +1279,22 @@ pub(in crate::app::dispatch) fn handle_session_restored(
agent_id: AgentId,
local_session_id: String,
) -> Vec<Effect> {
if crate::app::session_startup::chat_mode_refuses_local_build_load(
app.chat_mode,
false,
&local_session_id,
&app.cwd,
) {
#[cfg(feature = "local-workspace")]
let bypass_chat_refusal = app.welcome_history_load_as_build;
#[cfg(not(feature = "local-workspace"))]
let bypass_chat_refusal = false;
if !bypass_chat_refusal
&& crate::app::session_startup::chat_mode_refuses_local_build_load(
app.chat_mode,
false,
&local_session_id,
&app.cwd,
)
{
#[cfg(feature = "local-workspace")]
{
app.welcome_history_load_as_build = false;
}
refuse_chat_mode_build_agent(app, agent_id);
return vec![];
}
@ -1150,6 +1303,27 @@ pub(in crate::app::dispatch) fn handle_session_restored(
supersede_open_reload_window(agent, agent_id, "SessionRestored");
agent.bind_session_id(sid);
agent.chat_kind = app.chat_mode;
#[cfg(feature = "local-workspace")]
{
let history_build = app.welcome_history_load_as_build;
let local_intent = match &app.welcome_session_local_workspace {
Some(Some(_)) => true,
Some(None) => false,
None => crate::app::session_startup::active_local_workspace()
.ok()
.flatten()
.is_some(),
};
let (mode, cli_locked) =
crate::views::welcome::workspace_mode::indicator_for_opening_session(
false,
history_build,
app.local_workspace_startup_locked,
local_intent,
);
agent.workspace_mode = mode;
agent.workspace_mode_cli_locked = cli_locked;
}
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
agent.scrollback.push_block(RenderBlock::system(format!(
"Session restored. Loading {local_session_id}..."
@ -1170,6 +1344,10 @@ pub(in crate::app::dispatch) fn handle_session_restore_failed(
error: String,
) -> Vec<Effect> {
tracing::error!(agent = ?agent_id, error = %error, "Session restore failed");
#[cfg(feature = "local-workspace")]
{
app.welcome_history_load_as_build = false;
}
if let Some(agent) = app.agents.get_mut(&agent_id) {
if defer_to_open_reload_window(agent, agent_id, "SessionRestoreFailed") {
return vec![];

View file

@ -910,6 +910,10 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
after != AfterSessionDelete::Stay,
);
if after == AfterSessionDelete::Stay {
app.dashboard_local_sessions
.retain(|entry| entry.session_id != session_id);
app.leader_roster
.retain(|entry| entry.session_id != session_id);
app.show_toast("Session deleted");
return vec![];
}
@ -922,11 +926,49 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
.collect();
let foreground =
matches!(app.active_view, ActiveView::Agent(id) if to_remove.contains(&id));
let roster_row = crate::views::dashboard::DashboardRowId::Roster {
session_id: session_id.clone(),
};
let closed_rows: Vec<_> = to_remove
.iter()
.copied()
.map(crate::views::dashboard::DashboardRowId::TopLevel)
.chain(std::iter::once(roster_row))
.collect();
let selected = app.dashboard.as_ref().and_then(|d| d.selected.clone());
let neighbor = if after == AfterSessionDelete::Dashboard
&& let Some(sel) = selected.as_ref().filter(|sel| closed_rows.contains(sel))
{
super::dashboard::dashboard_neighbor_row(app, sel)
} else {
None
};
app.dashboard_local_sessions
.retain(|entry| entry.session_id != session_id);
app.leader_roster
.retain(|entry| entry.session_id != session_id);
for id in to_remove {
remove_agent_and_cleanup(app, id);
}
let mut effects = unregister_session_effect(Some(sid));
if foreground && after == AfterSessionDelete::Welcome {
if after == AfterSessionDelete::Dashboard {
if let Some(d) = app.dashboard.as_mut() {
d.delete_confirm = None;
let selected_closed = d
.selected
.as_ref()
.is_some_and(|sel| closed_rows.contains(sel));
match (selected_closed, neighbor) {
(true, Some(n)) => d.focus_row(n),
(true, None) => d.focus_new_agent_button(),
_ => {}
}
}
if foreground {
super::dashboard::ensure_dashboard_state(app);
app.active_view = ActiveView::AgentDashboard;
}
} else if foreground && after == AfterSessionDelete::Welcome {
effects.extend(dispatch_exit_session(app));
}
app.show_toast("Session deleted");

View file

@ -3038,14 +3038,11 @@ fn dashboard_overlay_stop_busy_agent_cancels_instead_of_closing() {
"the overlay attachment must survive",
);
}
/// A COMMAND in flight at confirm time must NOT downgrade to a
/// cancel — `dispatch_cancel_turn` no-ops for command states, which
/// would silently eat the confirmed press. The close proceeds: it
/// is the only termination the user can reach (commands can't be
/// cancelled).
/// `/compact` in flight: overlay stop cancels compaction instead of
/// closing the session (same as a running turn).
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
#[test]
fn dashboard_overlay_stop_command_running_closes_session() {
fn dashboard_overlay_stop_compact_running_cancels() {
let mut app = test_app_with_agent();
mark_agent_nonempty(&mut app, AgentId(0));
let id2 = AgentId(1);
@ -3062,15 +3059,27 @@ fn dashboard_overlay_stop_command_running_closes_session() {
command: crate::app::agent::AgentCommand::Compact,
started_at: std::time::Instant::now(),
};
let _ = dispatch_dashboard_overlay_stop(&mut app);
app.agents.get_mut(&id).unwrap().session.session_id = Some(acp::SessionId::new("sess-compact"));
let effects = dispatch_dashboard_overlay_stop(&mut app);
assert!(
!app.agents.contains_key(&id),
"a command in flight must not block the confirmed close",
app.agents.contains_key(&id),
"stop during /compact must not close the session",
);
assert!(
matches!(app.active_view, ActiveView::AgentDashboard),
"the confirmed close must land on the dashboard, got {:?}",
app.active_view,
matches!(
app.agents.get(&id).unwrap().session.state,
AgentState::CommandCancelling {
command: crate::app::agent::AgentCommand::Compact,
}
),
"stop during /compact must enter CommandCancelling, got {:?}",
app.agents.get(&id).unwrap().session.state,
);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::CancelTurn { .. })),
"stop during /compact must emit CancelTurn, got {effects:?}",
);
}
/// An armed overlay stop-confirm is bound to "this overlay, this
@ -4010,25 +4019,27 @@ fn dashboard_open_drops_pinned_ids_for_missing_agents() {
let d = app.dashboard.as_ref().unwrap();
assert!(d.pinned.is_empty(), "stale pin should be gc'd at open");
}
/// Ctrl+X first press arms confirm, second
/// press within 2s closes. We don't sleep — we manually rewind
/// `stop_confirm.1` to a recent instant and check the second
/// press is honoured.
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
#[test]
fn dashboard_stop_double_press_closes_top_level() {
fn dashboard_stop_double_press_deletes_top_level() {
let mut app = test_app();
let _ = dispatch_new_session_inner(&mut app, None);
let _ = dispatch_new_session_inner(&mut app, None);
for (i, a) in app.agents.values_mut().enumerate() {
a.session.session_id = Some(acp::SessionId::new(format!("s{i}")));
}
open_dashboard(&mut app);
let target = *app.agents.keys().next().unwrap();
if let Some(d) = app.dashboard.as_mut() {
d.selected = Some(crate::views::dashboard::DashboardRowId::TopLevel(target));
}
let _ = dispatch_dashboard_stop(&mut app);
assert!(app.dashboard.as_ref().unwrap().stop_confirm.is_some());
let _ = dispatch_dashboard_stop(&mut app);
assert!(!app.agents.contains_key(&target));
assert!(app.dashboard.as_ref().unwrap().delete_confirm.is_some());
let effects = dispatch_dashboard_stop(&mut app);
assert!(matches!(
effects.last(),
Some(crate::app::actions::Effect::DeleteSession { .. })
));
}
/// Closing the selected agent moves the cursor DOWN one row (onto the
/// agent that shifts up into its place) instead of dropping it to
@ -4042,6 +4053,7 @@ fn dashboard_stop_moves_selection_down_one() {
let _ = dispatch_new_session_inner(&mut app, None);
for (i, agent) in app.agents.values_mut().enumerate() {
agent.display_name = Some(format!("agent-{i}"));
agent.session.session_id = Some(acp::SessionId::new(format!("s{i}")));
}
open_dashboard(&mut app);
let order = dashboard_row_order(&app);
@ -4052,19 +4064,33 @@ fn dashboard_stop_moves_selection_down_one() {
d.focus_row(first.clone());
}
let _ = dispatch_dashboard_stop(&mut app);
let _ = dispatch_dashboard_stop(&mut app);
let crate::views::dashboard::DashboardRowId::TopLevel(first_id) = first else {
let effects = dispatch_dashboard_stop(&mut app);
let crate::views::dashboard::DashboardRowId::TopLevel(first_id) = &first else {
panic!("first row should be top-level");
};
let session_id = app.agents[first_id]
.session
.session_id
.as_ref()
.expect("session id")
.to_string();
assert!(
!app.agents.contains_key(&first_id),
"closed agent must be gone"
matches!(
effects.last(),
Some(crate::app::actions::Effect::DeleteSession { .. })
),
"second Ctrl+X must delete, got {effects:?}"
);
assert_eq!(
app.dashboard.as_ref().unwrap().selected,
Some(second),
"closing the top row should select the next row down, not revert to top",
let _ = dispatch_task_result(
crate::app::actions::TaskResult::DeleteSessionComplete {
source: "current".into(),
session_id,
after: crate::app::actions::AfterSessionDelete::Dashboard,
},
&mut app,
);
assert!(!app.agents.contains_key(first_id));
assert_eq!(app.dashboard.as_ref().unwrap().selected, Some(second));
}
/// Closing the LAST row has no row below it, so the cursor falls back
/// to the previous row rather than disappearing.
@ -4077,6 +4103,7 @@ fn dashboard_stop_last_row_falls_back_to_previous() {
let _ = dispatch_new_session_inner(&mut app, None);
for (i, agent) in app.agents.values_mut().enumerate() {
agent.display_name = Some(format!("agent-{i}"));
agent.session.session_id = Some(acp::SessionId::new(format!("s{i}")));
}
open_dashboard(&mut app);
let order = dashboard_row_order(&app);
@ -4087,25 +4114,36 @@ fn dashboard_stop_last_row_falls_back_to_previous() {
d.focus_row(last.clone());
}
let _ = dispatch_dashboard_stop(&mut app);
let _ = dispatch_dashboard_stop(&mut app);
let crate::views::dashboard::DashboardRowId::TopLevel(last_id) = last else {
let effects = dispatch_dashboard_stop(&mut app);
let crate::views::dashboard::DashboardRowId::TopLevel(last_id) = &last else {
panic!("last row should be top-level");
};
assert!(
!app.agents.contains_key(&last_id),
"closed agent must be gone"
);
assert_eq!(
app.dashboard.as_ref().unwrap().selected,
Some(prev),
"closing the last row should select the previous row",
let session_id = app.agents[last_id]
.session
.session_id
.as_ref()
.expect("session id")
.to_string();
assert!(matches!(
effects.last(),
Some(crate::app::actions::Effect::DeleteSession { .. })
));
let _ = dispatch_task_result(
crate::app::actions::TaskResult::DeleteSessionComplete {
source: "current".into(),
session_id,
after: crate::app::actions::AfterSessionDelete::Dashboard,
},
&mut app,
);
assert!(!app.agents.contains_key(last_id));
assert_eq!(app.dashboard.as_ref().unwrap().selected, Some(prev));
}
/// First Ctrl+X must NOT plant an `error_toast`. The
/// dispatch-input placeholder is reserved for the user's typing
/// target — the footer's `ShortcutsBar::with_pending` already
/// surfaces the "press Ctrl+X again to close this session"
/// hint via `stop_confirm` and is the canonical place for it.
/// hint via `delete_confirm` and is the canonical place for it.
/// Two copies of the same hint in two different surfaces
/// confused the user (the prompt one stole visual weight).
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
@ -4122,8 +4160,8 @@ fn dashboard_stop_does_not_plant_error_toast() {
let _ = dispatch_dashboard_stop(&mut app);
let d = app.dashboard.as_ref().unwrap();
assert!(
d.stop_confirm.is_some(),
"first Ctrl+X must arm stop_confirm (footer reads from this)",
d.delete_confirm.is_some(),
"first Ctrl+X on an idle row must arm delete_confirm (footer reads from this)",
);
assert!(
d.error_toast.is_none(),
@ -4488,14 +4526,14 @@ fn dashboard_stop_double_press_after_2s_rearms() {
}
let _ = dispatch_dashboard_stop(&mut app);
if let Some(d) = app.dashboard.as_mut()
&& let Some((_row, at)) = d.stop_confirm.as_mut()
&& let Some((_row, at)) = d.delete_confirm.as_mut()
{
*at = Instant::now() - Duration::from_secs(3);
}
let before_count = app.agents.len();
let _ = dispatch_dashboard_stop(&mut app);
assert_eq!(app.agents.len(), before_count);
assert!(app.dashboard.as_ref().unwrap().stop_confirm.is_some());
assert!(app.dashboard.as_ref().unwrap().delete_confirm.is_some());
}
/// Subagent Ctrl+X bypasses confirm and emits KillSubagent.
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
@ -4519,7 +4557,248 @@ fn dashboard_stop_subagent_emits_kill_subagent_effect() {
effects.as_slice(),
[Effect::KillSubagent { subagent_id, .. }] if subagent_id == "sa-xyz"
));
assert!(app.dashboard.as_ref().unwrap().stop_confirm.is_none());
assert!(app.dashboard.as_ref().unwrap().delete_confirm.is_none());
}
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
#[test]
fn dashboard_delete_complete_returns_from_foreground_agent() {
let mut app = test_app_with_agent();
let id = AgentId(0);
app.agents.get_mut(&id).unwrap().session.session_id = Some(acp::SessionId::new("sess-dash"));
open_dashboard(&mut app);
app.active_view = ActiveView::Agent(id);
let _ = dispatch_task_result(
crate::app::actions::TaskResult::DeleteSessionComplete {
source: "current".into(),
session_id: "sess-dash".into(),
after: crate::app::actions::AfterSessionDelete::Dashboard,
},
&mut app,
);
assert!(!app.agents.contains_key(&id));
assert!(matches!(app.active_view, ActiveView::AgentDashboard));
}
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
#[test]
fn dashboard_stop_busy_roster_toasts_without_arming() {
let mut app = test_app();
let mut entry = idle_roster_entry("busy-sess", "Busy row");
entry.activity = crate::app::roster::RosterActivity::Working;
app.dashboard_local_sessions = vec![entry];
open_dashboard(&mut app);
if let Some(d) = app.dashboard.as_mut() {
d.selected = Some(crate::views::dashboard::DashboardRowId::Roster {
session_id: "busy-sess".into(),
});
}
assert!(dispatch_dashboard_stop(&mut app).is_empty());
let d = app.dashboard.as_ref().unwrap();
assert!(d.delete_confirm.is_none());
assert_eq!(
d.error_toast.as_deref(),
Some("Stop the session before deleting"),
);
}
/// A busy top-level row: Ctrl+X cancels the turn and never arms delete.
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
#[test]
fn dashboard_stop_busy_top_level_cancels_without_arming() {
let mut app = test_app();
let _ = dispatch_new_session_inner(&mut app, None);
let _ = dispatch_new_session_inner(&mut app, None);
let target = *app.agents.keys().next().unwrap();
{
let agent = app.agents.get_mut(&target).unwrap();
agent.session.session_id = Some(acp::SessionId::new("busy-top"));
agent.session.state = AgentState::TurnRunning;
}
open_dashboard(&mut app);
if let Some(d) = app.dashboard.as_mut() {
d.selected = Some(crate::views::dashboard::DashboardRowId::TopLevel(target));
}
let effects = dispatch_dashboard_stop(&mut app);
assert!(
matches!(effects.as_slice(), [Effect::CancelTurn { .. }]),
"busy top-level Ctrl+X must cancel the turn, got {effects:?}",
);
assert!(
app.dashboard.as_ref().unwrap().delete_confirm.is_none(),
"busy top-level Ctrl+X must NOT arm delete",
);
let effects = dispatch_dashboard_stop(&mut app);
assert!(
!effects
.iter()
.any(|e| matches!(e, Effect::DeleteSession { .. })),
"a busy row must never emit DeleteSession, got {effects:?}",
);
assert!(app.agents.contains_key(&target), "busy row must survive");
}
/// A row that's `Working` only due to background work (turn idle, a
/// scheduled `/loop` live): Ctrl+X stops the background work rather than
/// toasting, and never arms delete — so the row can settle to idle and
/// then be deleted.
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
#[test]
fn dashboard_stop_bg_work_row_stops_without_arming() {
let mut app = test_app();
let _ = dispatch_new_session_inner(&mut app, None);
let _ = dispatch_new_session_inner(&mut app, None);
let target = *app.agents.keys().next().unwrap();
{
let agent = app.agents.get_mut(&target).unwrap();
agent.session.session_id = Some(acp::SessionId::new("bg-loop"));
agent.session.state = AgentState::Idle;
agent.session.scheduled_tasks.insert(
"loop-1".into(),
crate::app::agent::ScheduledTaskInfo {
task_id: "loop-1".into(),
prompt: "keep going".into(),
human_schedule: "every 5m".into(),
created_at: std::time::Instant::now(),
next_fire_at: None,
tag: "loop".into(),
last_subagent_id: None,
},
);
}
open_dashboard(&mut app);
if let Some(d) = app.dashboard.as_mut() {
d.selected = Some(crate::views::dashboard::DashboardRowId::TopLevel(target));
}
let effects = dispatch_dashboard_stop(&mut app);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::DeleteScheduledTask { .. })),
"Ctrl+X must stop the scheduled loop, got {effects:?}",
);
assert!(
!effects
.iter()
.any(|e| matches!(e, Effect::DeleteSession { .. })),
"must not delete a bg-work row, got {effects:?}",
);
let d = app.dashboard.as_ref().unwrap();
assert!(d.delete_confirm.is_none(), "must not arm delete");
assert!(d.error_toast.is_none(), "stopped work, so no toast");
}
/// A row that's `Working` only because of a queued (unsent) prompt: Ctrl+X
/// drops the queue (local, no effect) rather than toasting, and never arms
/// — so the row settles to idle and can then be deleted.
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
#[test]
fn dashboard_stop_queued_prompt_row_drops_queue_without_arming() {
let mut app = test_app();
let _ = dispatch_new_session_inner(&mut app, None);
let _ = dispatch_new_session_inner(&mut app, None);
let target = *app.agents.keys().next().unwrap();
{
let agent = app.agents.get_mut(&target).unwrap();
agent.session.session_id = Some(acp::SessionId::new("queued"));
agent.session.state = AgentState::Idle;
agent.session.enqueue_prompt("do the thing".into());
}
open_dashboard(&mut app);
if let Some(d) = app.dashboard.as_mut() {
d.selected = Some(crate::views::dashboard::DashboardRowId::TopLevel(target));
}
let effects = dispatch_dashboard_stop(&mut app);
assert!(
!effects
.iter()
.any(|e| matches!(e, Effect::DeleteSession { .. })),
"must not delete a queued-prompt row, got {effects:?}",
);
assert!(
app.agents[&target].session.pending_prompts.is_empty(),
"the queued prompt must be dropped",
);
let d = app.dashboard.as_ref().unwrap();
assert!(d.delete_confirm.is_none(), "must not arm delete");
assert!(d.error_toast.is_none(), "dropped the queue, so no toast");
}
/// The `y` / second-`[✗]` confirm re-checks deletability: a row that
/// became busy between arming and confirming must not be deleted.
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
#[test]
fn dashboard_delete_confirm_rechecks_settled_row() {
let mut app = test_app();
let _ = dispatch_new_session_inner(&mut app, None);
let _ = dispatch_new_session_inner(&mut app, None);
let target = *app.agents.keys().next().unwrap();
{
let agent = app.agents.get_mut(&target).unwrap();
agent.session.session_id = Some(acp::SessionId::new("recheck"));
agent.session.state = AgentState::Idle;
}
open_dashboard(&mut app);
if let Some(d) = app.dashboard.as_mut() {
d.selected = Some(crate::views::dashboard::DashboardRowId::TopLevel(target));
}
let _ = dispatch_dashboard_stop(&mut app);
assert!(app.dashboard.as_ref().unwrap().delete_confirm.is_some());
app.agents.get_mut(&target).unwrap().session.state = AgentState::TurnRunning;
let effects = dispatch_dashboard_delete(&mut app);
assert!(
!effects
.iter()
.any(|e| matches!(e, Effect::DeleteSession { .. })),
"a row that went busy between gestures must not delete, got {effects:?}",
);
assert_eq!(
app.dashboard.as_ref().unwrap().error_toast.as_deref(),
Some("Stop the session before deleting"),
);
}
/// A settled chat-conversation roster row must not arm on Ctrl+X — delete
/// isn't supported for conversations, so a confirm could never succeed.
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
#[test]
fn dashboard_stop_conversation_row_does_not_arm() {
let mut app = test_app();
let mut entry = idle_roster_entry("conv-1", "Chat row");
entry.origin.kind = "conversation".into();
app.dashboard_local_sessions = vec![entry];
open_dashboard(&mut app);
if let Some(d) = app.dashboard.as_mut() {
d.selected = Some(crate::views::dashboard::DashboardRowId::Roster {
session_id: "conv-1".into(),
});
}
assert!(dispatch_dashboard_stop(&mut app).is_empty());
let d = app.dashboard.as_ref().unwrap();
assert!(d.delete_confirm.is_none(), "conversation row must not arm");
assert_eq!(
d.error_toast.as_deref(),
Some("Deleting chat conversations isn't supported yet"),
);
}
/// A row with no session id toasts instead of emitting a delete.
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
#[test]
fn dashboard_delete_top_level_without_session_id_toasts() {
let mut app = test_app();
let _ = dispatch_new_session_inner(&mut app, None);
let _ = dispatch_new_session_inner(&mut app, None);
let target = *app.agents.keys().next().unwrap();
app.agents.get_mut(&target).unwrap().session.session_id = None;
open_dashboard(&mut app);
if let Some(d) = app.dashboard.as_mut() {
d.selected = Some(crate::views::dashboard::DashboardRowId::TopLevel(target));
}
let _ = dispatch_dashboard_stop(&mut app);
let effects = dispatch_dashboard_stop(&mut app);
assert!(
!effects
.iter()
.any(|e| matches!(e, Effect::DeleteSession { .. })),
"a row without a session id must not delete, got {effects:?}",
);
assert_eq!(
app.dashboard.as_ref().unwrap().error_toast.as_deref(),
Some("No session history to delete"),
);
}
/// Happy path — matching ids → no panic, queue popped.
/// Also assert the response was actually sent through

View file

@ -29,13 +29,14 @@ use super::ctx::{find_agent_by_session_id, get_active_agent, get_active_agent_mu
use super::dashboard::{
apply_pending_dispatch_config, dispatch_dashboard_attach, dispatch_dashboard_begin_rename,
dispatch_dashboard_commit_rename, dispatch_dashboard_confirm_worktree,
dispatch_dashboard_create_new_agent_with_detail, dispatch_dashboard_dispatch,
dispatch_dashboard_dispatch_slash, dispatch_dashboard_overlay_cycle,
dispatch_dashboard_overlay_exit, dispatch_dashboard_overlay_stop,
dispatch_dashboard_peek_reply, dispatch_dashboard_permission_followup,
dispatch_dashboard_permission_select, dispatch_dashboard_question_answer,
dispatch_dashboard_stop, dispatch_dashboard_toggle_auto_approve, dispatch_exit_dashboard,
dispatch_open_dashboard, ensure_dashboard_state, resolve_location_input,
dispatch_dashboard_create_new_agent_with_detail, dispatch_dashboard_delete,
dispatch_dashboard_dispatch, dispatch_dashboard_dispatch_slash,
dispatch_dashboard_overlay_cycle, dispatch_dashboard_overlay_exit,
dispatch_dashboard_overlay_stop, dispatch_dashboard_peek_reply,
dispatch_dashboard_permission_followup, dispatch_dashboard_permission_select,
dispatch_dashboard_question_answer, dispatch_dashboard_stop,
dispatch_dashboard_toggle_auto_approve, dispatch_exit_dashboard, dispatch_open_dashboard,
ensure_dashboard_state, resolve_location_input,
};
use super::modes::{
YOLO_ON_UNDER_PLAN_TOAST, active_agent_plan_nudge_state, dispatch_cycle_mode_and_sync,
@ -118,6 +119,16 @@ fn test_app() -> AppView {
require_plan_approval: false,
plan_mode: false,
chat_mode: false,
#[cfg(feature = "local-workspace")]
welcome_workspace_mode: crate::views::welcome::WelcomeWorkspaceMode::Sandbox,
#[cfg(feature = "local-workspace")]
local_workspace_startup_locked: false,
#[cfg(feature = "local-workspace")]
welcome_session_local_workspace: None,
#[cfg(feature = "local-workspace")]
welcome_local_workspace_ack_pending: false,
#[cfg(feature = "local-workspace")]
welcome_history_load_as_build: false,
subagents: false,
ask_user: false,
mouse_captured: true,
@ -206,6 +217,10 @@ fn test_app() -> AppView {
welcome_privacy_banner_opt_out_rect: None,
welcome_privacy_banner_terms_rect: None,
welcome_privacy_banner_policy_rect: None,
#[cfg(feature = "local-workspace")]
welcome_workspace_mode_rects: Default::default(),
#[cfg(feature = "local-workspace")]
welcome_on_workspace_mode: false,
welcome_toast: None,
welcome_on_privacy_banner: false,
welcome_on_upgrade_cta: false,
@ -228,6 +243,7 @@ fn test_app() -> AppView {
session_picker_lanes: Default::default(),
session_picker_detail_generation: 0,
session_picker_entries_query: None,
session_picker_pending_delete: None,
welcome_tick: 0,
welcome_shimmer_frame: 0,
startup_warnings: Vec::new(),

View file

@ -2107,6 +2107,7 @@ fn dashboard_stop_with_peek_open_moves_selection_and_peek_down_one() {
let _ = dispatch_new_session_inner(&mut app, None);
for (i, agent) in app.agents.values_mut().enumerate() {
agent.display_name = Some(format!("agent-{i}"));
agent.session.session_id = Some(acp::SessionId::new(format!("s{i}")));
}
open_dashboard(&mut app);
let order = dashboard_row_order(&app);
@ -2151,17 +2152,27 @@ fn dashboard_stop_with_peek_open_moves_selection_and_peek_down_one() {
other => panic!("Ctrl+X must produce DashboardStop, got {other:?}"),
}
}
let crate::views::dashboard::DashboardRowId::TopLevel(first_id) = first else {
let crate::views::dashboard::DashboardRowId::TopLevel(first_id) = &first else {
panic!("first row should be top-level");
};
assert!(
!app.agents.contains_key(&first_id),
"closed agent must be gone"
let session_id = app.agents[first_id]
.session
.session_id
.as_ref()
.expect("session id")
.to_string();
let _ = dispatch_task_result(
crate::app::actions::TaskResult::DeleteSessionComplete {
source: "current".into(),
session_id,
after: crate::app::actions::AfterSessionDelete::Dashboard,
},
&mut app,
);
assert!(!app.agents.contains_key(first_id));
assert_eq!(
app.dashboard.as_ref().unwrap().selected,
Some(second.clone()),
"closing with peek open should still select the next row down",
);
render(&mut app);
assert_eq!(
@ -2177,24 +2188,25 @@ fn dashboard_stop_with_peek_open_moves_selection_and_peek_down_one() {
}
/// Regression — the same Ctrl+X double-press path driven
/// END-TO-END through `DashboardState::handle_input` (which the
/// existing `dashboard_stop_double_press_closes_top_level` test
/// existing `dashboard_stop_double_press_deletes_top_level` test
/// bypasses by calling `dispatch_dashboard_stop` directly).
///
/// Without the fix, the second `handle_input` call
/// runs the top-of-`handle_key` toast/confirm clear BEFORE the
/// registry resolves the key to `DashboardStop`, wiping the
/// just-armed `stop_confirm`. The dispatcher then sees a fresh
/// state and re-arms instead of closing. The session never closes
/// just-armed `delete_confirm`. The dispatcher then sees a fresh
/// state and re-arms instead of deleting. The session never deletes
/// no matter how many times the user presses Ctrl+X.
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
#[test]
fn dashboard_stop_double_press_via_handle_key_closes_top_level() {
fn dashboard_stop_double_press_via_handle_key_deletes_top_level() {
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app();
let _ = dispatch_new_session_inner(&mut app, None);
let _ = dispatch_new_session_inner(&mut app, None);
open_dashboard(&mut app);
let target = *app.agents.keys().next().unwrap();
app.agents.get_mut(&target).unwrap().session.session_id = Some(acp::SessionId::new("s-target"));
if let Some(d) = app.dashboard.as_mut() {
d.selected = Some(crate::views::dashboard::DashboardRowId::TopLevel(target));
}
@ -2211,8 +2223,8 @@ fn dashboard_stop_double_press_via_handle_key_closes_top_level() {
other => panic!("first Ctrl+X must produce DashboardStop, got {other:?}"),
}
assert!(
app.dashboard.as_ref().unwrap().stop_confirm.is_some(),
"first Ctrl+X must arm stop_confirm"
app.dashboard.as_ref().unwrap().delete_confirm.is_some(),
"first Ctrl+X must arm delete_confirm"
);
let outcome2 = app
.dashboard
@ -2221,14 +2233,27 @@ fn dashboard_stop_double_press_via_handle_key_closes_top_level() {
.handle_input(&ctrl_x, &app.registry);
match outcome2 {
crate::app::app_view::InputOutcome::Action(crate::app::actions::Action::DashboardStop) => {
let _ = dispatch(crate::app::actions::Action::DashboardStop, &mut app);
let effects = dispatch(crate::app::actions::Action::DashboardStop, &mut app);
assert!(matches!(effects.last(), Some(Effect::DeleteSession { .. })));
}
other => panic!("second Ctrl+X must produce DashboardStop, got {other:?}"),
}
assert!(
!app.agents.contains_key(&target),
"second Ctrl+X via handle_input must close the target agent (Issue 300 regression)",
assert!(app.dashboard.as_ref().unwrap().delete_confirm.is_none());
let session_id = app.agents[&target]
.session
.session_id
.as_ref()
.expect("session id")
.to_string();
let _ = dispatch_task_result(
crate::app::actions::TaskResult::DeleteSessionComplete {
source: "current".into(),
session_id,
after: crate::app::actions::AfterSessionDelete::Dashboard,
},
&mut app,
);
assert!(!app.agents.contains_key(&target));
}
/// Top-level resolver round-trip via real AgentView.
#[test]
@ -2274,3 +2299,884 @@ fn session_id_resolver_round_trip_subagent() {
let back = resolver.to_persisted(&live).expect("must reverse");
assert_eq!(back, pid);
}
#[cfg(feature = "local-workspace")]
mod welcome_workspace_mode {
use super::*;
use crate::app::session_startup::{
LocalWorkspaceConfig, LocalWorkspaceMode, set_active_local_workspace,
};
use crate::views::welcome::WelcomeWorkspaceMode;
#[test]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ACK)]
fn welcome_new_session_sets_own_override() {
let _ack = xai_grok_test_support::EnvGuard::set(
crate::app::session_startup::GROK_CHAT_LOCAL_WORKSPACE_ACK_ENV,
"1",
);
set_active_local_workspace(None).unwrap();
let tmp = tempfile::tempdir().unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.cwd = tmp.path().to_path_buf();
app.welcome_workspace_mode = WelcomeWorkspaceMode::LocalWorkspace;
let _ = dispatch(Action::NewSession, &mut app);
let override_cfg = app
.welcome_session_local_workspace
.clone()
.flatten()
.expect("welcome Local must set one-shot own override");
assert_eq!(override_cfg.mode, LocalWorkspaceMode::Own);
assert_eq!(override_cfg.cwd.as_deref(), Some(tmp.path()));
set_active_local_workspace(None).unwrap();
}
#[test]
fn new_session_ignores_history_bypass_for_indicator() {
set_active_local_workspace(None).unwrap();
let tmp = tempfile::tempdir().unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.cwd = tmp.path().to_path_buf();
app.cwd_has_git_ancestor = false;
app.welcome_workspace_mode = WelcomeWorkspaceMode::Sandbox;
app.welcome_history_load_as_build = true;
let effects = dispatch(Action::NewSession, &mut app);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::CreateSession { .. })),
"new session must create: {effects:?}"
);
assert!(
app.welcome_history_load_as_build,
"create must not consume history bypass (restore+load still owns it)"
);
let agent = app.agents.values().next().expect("new agent");
assert!(agent.chat_kind);
assert_eq!(
agent.workspace_mode,
WelcomeWorkspaceMode::Sandbox,
"create must not stamp Local from leftover history bypass"
);
set_active_local_workspace(None).unwrap();
}
#[test]
fn fork_from_welcome_with_local_selection_creates_placeholder() {
set_active_local_workspace(None).unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.welcome_workspace_mode = WelcomeWorkspaceMode::LocalWorkspace;
assert!(app.agents.is_empty());
let effects = crate::app::dispatch::session::fork::dispatch_startup_fork_session(
&mut app,
"parent-1".into(),
None,
None,
);
assert!(
!app.agents.is_empty(),
"fork must still create a placeholder agent"
);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::ForkSession { .. })),
"fork effect expected: {effects:?}"
);
set_active_local_workspace(None).unwrap();
}
#[test]
fn startup_lock_prevents_sandbox_from_clearing_cli_stamp() {
let tmp = tempfile::tempdir().unwrap();
set_active_local_workspace(Some(LocalWorkspaceConfig {
mode: LocalWorkspaceMode::Attach,
cwd: Some(tmp.path().to_path_buf()),
server_id: Some("cli-srv".into()),
}))
.unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.local_workspace_startup_locked = true;
app.welcome_workspace_mode = WelcomeWorkspaceMode::Sandbox;
app.cwd = tmp.path().to_path_buf();
let _ = dispatch(Action::NewSession, &mut app);
let stamp = crate::app::session_startup::active_local_workspace()
.unwrap()
.expect("CLI stamp must remain");
assert_eq!(stamp.mode, LocalWorkspaceMode::Attach);
assert!(
app.welcome_session_local_workspace.is_none(),
"locked path must not set a one-shot override"
);
set_active_local_workspace(None).unwrap();
}
#[test]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ACK)]
fn confirm_ack_skips_reapply_and_sets_oneshot() {
let _ack = xai_grok_test_support::EnvGuard::unset(
crate::app::session_startup::GROK_CHAT_LOCAL_WORKSPACE_ACK_ENV,
);
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 mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.cwd = tmp.path().to_path_buf();
app.welcome_workspace_mode = WelcomeWorkspaceMode::LocalWorkspace;
app.welcome_local_workspace_ack_pending = true;
let effects = dispatch(Action::ConfirmWelcomeLocalWorkspaceAck, &mut app);
assert!(
!app.welcome_local_workspace_ack_pending,
"confirm must clear pending"
);
assert!(
app.welcome_session_local_workspace
.clone()
.flatten()
.is_some(),
"one-shot Own override must be set before CreateSession"
);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::CreateSession { .. })),
"confirm must create without re-entering AwaitAck: {effects:?}"
);
set_active_local_workspace(None).unwrap();
}
#[test]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ACK)]
fn welcome_local_worktree_always_keeps_oneshot_until_create() {
let _ack = xai_grok_test_support::EnvGuard::set(
crate::app::session_startup::GROK_CHAT_LOCAL_WORKSPACE_ACK_ENV,
"1",
);
set_active_local_workspace(None).unwrap();
let tmp = tempfile::tempdir().unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.cwd = tmp.path().to_path_buf();
app.cwd_has_git_ancestor = true;
app.new_session_worktree_mode = crate::app::app_view::WorktreeMode::Always;
app.welcome_workspace_mode = WelcomeWorkspaceMode::LocalWorkspace;
let live = test_app_with_agent();
let (id, agent) = live.agents.into_iter().next().unwrap();
app.agents.insert(id, agent);
let effects = dispatch(Action::NewSession, &mut app);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::CreateWorktreeSession { .. })),
"Always worktree must emit CreateWorktreeSession: {effects:?}"
);
assert!(
app.welcome_session_local_workspace
.clone()
.flatten()
.is_some(),
"one-shot must remain until process_effects consumes CreateWorktreeSession"
);
assert!(
crate::app::session_startup::active_local_workspace()
.unwrap()
.is_some(),
"welcome Local stamps process-wide Own (agents map treated as stale)"
);
set_active_local_workspace(None).unwrap();
}
#[test]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ACK)]
fn failed_worktree_create_clears_welcome_oneshot() {
let _ack = xai_grok_test_support::EnvGuard::set(
crate::app::session_startup::GROK_CHAT_LOCAL_WORKSPACE_ACK_ENV,
"1",
);
set_active_local_workspace(None).unwrap();
let tmp = tempfile::tempdir().unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.cwd = tmp.path().to_path_buf();
app.cwd_has_git_ancestor = false;
app.welcome_workspace_mode = WelcomeWorkspaceMode::LocalWorkspace;
app.welcome_history_load_as_build = true;
let effects = dispatch(
Action::NewWorktreeSession {
load_session_id: None,
label: None,
git_ref: None,
},
&mut app,
);
assert!(effects.is_empty(), "expected hard-fail, got {effects:?}");
assert!(
app.welcome_session_local_workspace.is_none(),
"failed worktree must drop one-shot so next create re-applies picker"
);
assert!(
!app.welcome_history_load_as_build,
"failed worktree must not leak history bypass"
);
set_active_local_workspace(None).unwrap();
}
#[test]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ACK)]
fn confirm_ack_honors_worktree_always() {
let _ack = xai_grok_test_support::EnvGuard::unset(
crate::app::session_startup::GROK_CHAT_LOCAL_WORKSPACE_ACK_ENV,
);
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 mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.cwd = tmp.path().to_path_buf();
app.cwd_has_git_ancestor = true;
app.new_session_worktree_mode = crate::app::app_view::WorktreeMode::Always;
app.welcome_workspace_mode = WelcomeWorkspaceMode::LocalWorkspace;
app.welcome_local_workspace_ack_pending = true;
let effects = dispatch(Action::ConfirmWelcomeLocalWorkspaceAck, &mut app);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::CreateWorktreeSession { .. })),
"confirm must honor WorktreeMode::Always: {effects:?}"
);
assert!(
app.welcome_session_local_workspace
.clone()
.flatten()
.is_some()
);
set_active_local_workspace(None).unwrap();
}
#[test]
fn welcome_fetch_session_list_filters_by_workspace_mode() {
set_active_local_workspace(None).unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.welcome_workspace_mode = WelcomeWorkspaceMode::Sandbox;
let effects = dispatch(Action::FetchSessionList, &mut app);
match &effects[..] {
[Effect::FetchSessionList { kind_filter, .. }] => {
assert_eq!(
kind_filter.as_deref(),
Some(["chat".to_string()].as_slice())
);
}
other => panic!("expected FetchSessionList, got {other:?}"),
}
app.welcome_workspace_mode = WelcomeWorkspaceMode::LocalWorkspace;
let effects = dispatch(Action::FetchSessionList, &mut app);
match &effects[..] {
[Effect::FetchSessionList { kind_filter, .. }] => {
assert_eq!(
kind_filter.as_deref(),
Some(["build".to_string()].as_slice())
);
}
other => panic!("expected FetchSessionList, got {other:?}"),
}
set_active_local_workspace(None).unwrap();
}
#[test]
fn pick_conversation_auto_switches_to_sandbox() {
set_active_local_workspace(None).unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.welcome_workspace_mode = WelcomeWorkspaceMode::LocalWorkspace;
app.session_picker_entries = Some(vec![crate::app::app_view::SessionPickerEntry {
id: "conv-1".into(),
summary: "hello".into(),
updated_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
cwd: String::new(),
hostname: None,
source: "conversation".into(),
model_id: None,
num_messages: 1,
last_active_at: None,
branch: None,
repo_name: String::new(),
worktree_label: None,
card_detail: None,
}]);
let effects = dispatch(Action::PickSession(0), &mut app);
assert_eq!(app.welcome_workspace_mode, WelcomeWorkspaceMode::Sandbox);
assert!(
app.welcome_session_local_workspace.is_none(),
"conversation pick must drop (not force-clear) local one-shot"
);
assert!(
effects.iter().any(|e| matches!(
e,
Effect::LoadSession {
chat_kind: true,
..
}
)),
"conversation must load as chat: {effects:?}"
);
assert!(!app.welcome_history_load_as_build);
set_active_local_workspace(None).unwrap();
}
#[test]
fn pick_local_disk_auto_switches_to_local_and_bypasses_chat_refusal() {
set_active_local_workspace(None).unwrap();
let tmp = tempfile::tempdir().unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.cwd = tmp.path().to_path_buf();
app.welcome_workspace_mode = WelcomeWorkspaceMode::Sandbox;
let sess_dir = super::super::super::plant_local_build_session(tmp.path(), "build-1");
app.session_picker_entries = Some(vec![crate::app::app_view::SessionPickerEntry {
id: "build-1".into(),
summary: "local work".into(),
updated_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
cwd: tmp.path().display().to_string(),
hostname: None,
source: "local".into(),
model_id: None,
num_messages: 1,
last_active_at: None,
branch: None,
repo_name: String::new(),
worktree_label: None,
card_detail: None,
}]);
let effects = dispatch(Action::PickSession(0), &mut app);
assert_eq!(
app.welcome_workspace_mode,
WelcomeWorkspaceMode::LocalWorkspace
);
assert!(app.chat_mode, "sticky --chat remains");
assert!(
effects.iter().any(|e| matches!(
e,
Effect::LoadSession {
chat_kind: false,
..
}
)),
"local-disk pick must load as build: {effects:?}"
);
assert!(
app.welcome_history_load_as_build,
"bypass stays until process_effects LoadSession"
);
let agent = app.agents.values().next().expect("placeholder agent");
assert!(
agent.chat_kind,
"sticky --chat keeps agent.chat_kind for already-open focus matching"
);
assert_eq!(
agent.workspace_mode,
WelcomeWorkspaceMode::LocalWorkspace,
"Local UX is the workspace_mode indicator"
);
let _ = std::fs::remove_dir_all(sess_dir);
set_active_local_workspace(None).unwrap();
}
#[test]
fn pick_local_disk_in_worktree_sets_history_bypass() {
set_active_local_workspace(None).unwrap();
let tmp = tempfile::tempdir().unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.cwd = tmp.path().to_path_buf();
app.cwd_has_git_ancestor = true;
app.welcome_workspace_mode = WelcomeWorkspaceMode::Sandbox;
app.session_picker_entries = Some(vec![crate::app::app_view::SessionPickerEntry {
id: "build-wt".into(),
summary: "local work".into(),
updated_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
cwd: tmp.path().display().to_string(),
hostname: None,
source: "local".into(),
model_id: None,
num_messages: 1,
last_active_at: None,
branch: None,
repo_name: String::new(),
worktree_label: None,
card_detail: None,
}]);
let _ = dispatch(Action::PickSessionInWorktree(0), &mut app);
assert_eq!(
app.welcome_workspace_mode,
WelcomeWorkspaceMode::LocalWorkspace
);
assert!(
app.welcome_history_load_as_build,
"worktree pick of build row must set history bypass"
);
set_active_local_workspace(None).unwrap();
}
#[test]
fn pick_in_worktree_resume_skips_local_ack() {
set_active_local_workspace(None).unwrap();
let tmp = tempfile::tempdir().unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.cwd = tmp.path().to_path_buf();
app.cwd_has_git_ancestor = true;
app.welcome_workspace_mode = WelcomeWorkspaceMode::LocalWorkspace;
app.session_picker_entries = Some(vec![crate::app::app_view::SessionPickerEntry {
id: "build-wt".into(),
summary: "local work".into(),
updated_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
cwd: tmp.path().display().to_string(),
hostname: None,
source: "local".into(),
model_id: None,
num_messages: 1,
last_active_at: None,
branch: None,
repo_name: String::new(),
worktree_label: None,
card_detail: None,
}]);
let effects = dispatch(Action::PickSessionInWorktree(0), &mut app);
assert!(
!app.welcome_local_workspace_ack_pending,
"worktree resume must not block on Local ACK"
);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::CreateWorktreeSession { .. })),
"worktree resume must create worktree without ACK: {effects:?}"
);
set_active_local_workspace(None).unwrap();
}
#[test]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ACK)]
fn pick_in_worktree_no_git_clears_history_bypass() {
let _ack = xai_grok_test_support::EnvGuard::set(
crate::app::session_startup::GROK_CHAT_LOCAL_WORKSPACE_ACK_ENV,
"1",
);
set_active_local_workspace(None).unwrap();
let tmp = tempfile::tempdir().unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.cwd = tmp.path().to_path_buf();
app.cwd_has_git_ancestor = false;
app.welcome_workspace_mode = WelcomeWorkspaceMode::Sandbox;
app.session_picker_entries = Some(vec![crate::app::app_view::SessionPickerEntry {
id: "build-wt".into(),
summary: "local work".into(),
updated_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
cwd: tmp.path().display().to_string(),
hostname: None,
source: "local".into(),
model_id: None,
num_messages: 1,
last_active_at: None,
branch: None,
repo_name: String::new(),
worktree_label: None,
card_detail: None,
}]);
let effects = dispatch(Action::PickSessionInWorktree(0), &mut app);
assert!(
effects.is_empty(),
"no-git worktree must hard-fail: {effects:?}"
);
assert!(
!app.welcome_history_load_as_build,
"no-git worktree fail must not leak history bypass"
);
set_active_local_workspace(None).unwrap();
}
#[test]
fn cli_lock_still_sets_history_bypass_without_rewriting_mode() {
let tmp = tempfile::tempdir().unwrap();
set_active_local_workspace(Some(LocalWorkspaceConfig {
mode: LocalWorkspaceMode::Attach,
cwd: Some(tmp.path().to_path_buf()),
server_id: Some("cli-srv".into()),
}))
.unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.cwd = tmp.path().to_path_buf();
app.local_workspace_startup_locked = true;
app.welcome_workspace_mode = WelcomeWorkspaceMode::Sandbox;
let sess_dir = super::super::super::plant_local_build_session(tmp.path(), "build-lock");
app.session_picker_entries = Some(vec![crate::app::app_view::SessionPickerEntry {
id: "build-lock".into(),
summary: "local work".into(),
updated_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
cwd: tmp.path().display().to_string(),
hostname: None,
source: "local".into(),
model_id: None,
num_messages: 1,
last_active_at: None,
branch: None,
repo_name: String::new(),
worktree_label: None,
card_detail: None,
}]);
let effects = dispatch(Action::PickSession(0), &mut app);
assert_eq!(
app.welcome_workspace_mode,
WelcomeWorkspaceMode::Sandbox,
"CLI lock must not rewrite welcome mode from Sandbox"
);
assert!(
app.welcome_history_load_as_build,
"CLI lock must still set local-disk load bypass"
);
assert!(
effects.iter().any(|e| matches!(
e,
Effect::LoadSession {
chat_kind: false,
..
}
)),
"locked local-disk pick must still load: {effects:?}"
);
let _ = std::fs::remove_dir_all(sess_dir);
set_active_local_workspace(None).unwrap();
}
#[test]
fn failed_local_pick_clears_history_bypass() {
set_active_local_workspace(None).unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.welcome_workspace_mode = WelcomeWorkspaceMode::Sandbox;
app.session_picker_entries = Some(vec![crate::app::app_view::SessionPickerEntry {
id: "missing-build".into(),
summary: "gone".into(),
updated_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
cwd: String::new(),
hostname: None,
source: "local".into(),
model_id: None,
num_messages: 1,
last_active_at: None,
branch: None,
repo_name: String::new(),
worktree_label: None,
card_detail: None,
}]);
let effects = dispatch(Action::PickSession(0), &mut app);
assert!(effects.is_empty());
assert!(
!app.welcome_history_load_as_build,
"failed/no-op pick must not leak bypass"
);
set_active_local_workspace(None).unwrap();
}
#[test]
fn deferred_history_bypass_survives_startup_gate() {
set_active_local_workspace(None).unwrap();
let tmp = tempfile::tempdir().unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.trust_state = crate::app::app_view::TrustState::Pending {
workspace: tmp.path().to_path_buf(),
};
app.welcome_history_load_as_build = true;
let effects = dispatch(Action::LoadSession("sid".into(), None, false), &mut app);
assert!(effects.is_empty());
assert!(
!app.welcome_history_load_as_build,
"live flag moved onto deferred startup"
);
assert!(app.deferred_startup.history_load_as_build);
let effects = finish_trust(&mut app);
assert!(
app.welcome_history_load_as_build,
"drain must re-apply bypass before LoadSession"
);
assert!(
effects.iter().any(|e| matches!(
e,
Effect::LoadSession {
chat_kind: false,
..
}
)),
"deferred drain must emit LoadSession: {effects:?}"
);
set_active_local_workspace(None).unwrap();
}
#[test]
fn cli_lock_conversation_pick_does_not_rewrite_sandbox_mode() {
let tmp = tempfile::tempdir().unwrap();
set_active_local_workspace(Some(LocalWorkspaceConfig {
mode: LocalWorkspaceMode::Attach,
cwd: Some(tmp.path().to_path_buf()),
server_id: Some("cli-srv".into()),
}))
.unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.local_workspace_startup_locked = true;
app.welcome_workspace_mode = WelcomeWorkspaceMode::Sandbox;
app.session_picker_entries = Some(vec![crate::app::app_view::SessionPickerEntry {
id: "conv-lock".into(),
summary: "hello".into(),
updated_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
cwd: String::new(),
hostname: None,
source: "conversation".into(),
model_id: None,
num_messages: 1,
last_active_at: None,
branch: None,
repo_name: String::new(),
worktree_label: None,
card_detail: None,
}]);
let effects = dispatch(Action::PickSession(0), &mut app);
assert_eq!(
app.welcome_workspace_mode,
WelcomeWorkspaceMode::Sandbox,
"CLI lock must not auto-switch welcome mode on conversation pick"
);
assert!(!app.welcome_history_load_as_build);
assert!(
effects.iter().any(|e| matches!(
e,
Effect::LoadSession {
chat_kind: true,
..
}
)),
"conversation must still load: {effects:?}"
);
let agent = app.agents.values().next().expect("agent");
assert_eq!(
agent.workspace_mode,
WelcomeWorkspaceMode::Sandbox,
"conversation without session-local intent → Sandbox (not Local·CLI)"
);
assert!(
!agent.workspace_mode_cli_locked,
"CLI lock must not badge conversation LoadSession"
);
set_active_local_workspace(None).unwrap();
}
#[test]
fn session_restore_failed_clears_history_bypass() {
set_active_local_workspace(None).unwrap();
let mut app = test_app_with_agent();
app.chat_mode = true;
app.welcome_history_load_as_build = true;
let id = AgentId(0);
let effects = dispatch(
Action::TaskComplete(TaskResult::SessionRestoreFailed {
agent_id: id,
error: "boom".into(),
}),
&mut app,
);
assert!(effects.is_empty());
assert!(
!app.welcome_history_load_as_build,
"failed restore must not leak bypass into the next load"
);
set_active_local_workspace(None).unwrap();
}
#[test]
fn restore_and_load_sets_local_workspace_indicator() {
set_active_local_workspace(None).unwrap();
let mut app = test_app();
app.chat_mode = true;
app.active_view = ActiveView::Welcome;
app.welcome_workspace_mode = WelcomeWorkspaceMode::Sandbox;
app.session_picker_entries = Some(vec![crate::app::app_view::SessionPickerEntry {
id: "remote-1".into(),
summary: "remote row".into(),
updated_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
cwd: "/other".into(),
hostname: None,
source: "remote".into(),
model_id: None,
num_messages: 1,
last_active_at: None,
branch: None,
repo_name: String::new(),
worktree_label: None,
card_detail: None,
}]);
let effects = dispatch(Action::PickSession(0), &mut app);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::RestoreAndLoadSession { .. })),
"remote pick must restore: {effects:?}"
);
assert!(
app.welcome_history_load_as_build,
"bypass kept until follow-up LoadSession"
);
let agent = app.agents.values().next().expect("restore placeholder");
assert_eq!(
agent.workspace_mode,
WelcomeWorkspaceMode::LocalWorkspace,
"restore placeholder must show Local indicator"
);
set_active_local_workspace(None).unwrap();
}
#[test]
fn history_build_bypass_only_applies_to_load_session_batch() {
use crate::app::event_loop::welcome_history_build_bypass_applies;
assert!(!welcome_history_build_bypass_applies(&[], true));
assert!(!welcome_history_build_bypass_applies(
&[Effect::FetchSessionList {
query: None,
seq: 0,
kind_filter: None,
}],
true
));
assert!(welcome_history_build_bypass_applies(
&[Effect::LoadSession {
agent_id: AgentId(0),
session_id: "s".into(),
session_cwd: None,
chat_kind: false,
}],
true
));
assert!(welcome_history_build_bypass_applies(
&[Effect::RestoreAndLoadSession {
agent_id: AgentId(0),
session_id: "s".into(),
session_cwd: "/tmp".into(),
}],
true
));
assert!(welcome_history_build_bypass_applies(
&[Effect::CreateWorktreeSession {
agent_id: AgentId(0),
load_session_id: Some("s".into()),
label: None,
git_ref: None,
model_id: None,
preferred_session_id: None,
chat_kind: false,
}],
true
));
assert!(
crate::app::event_loop::welcome_history_build_bypass_consume(
&[Effect::CreateWorktreeSession {
agent_id: AgentId(0),
load_session_id: Some("s".into()),
label: None,
git_ref: None,
model_id: None,
preferred_session_id: None,
chat_kind: false,
}],
true
),
"worktree-resume batch consumes bypass (single-batch create)"
);
assert!(
!crate::app::event_loop::welcome_history_build_bypass_consume(
&[Effect::RestoreAndLoadSession {
agent_id: AgentId(0),
session_id: "s".into(),
session_cwd: "/tmp".into(),
}],
true
),
"restore-only batch keeps bypass for follow-up LoadSession"
);
assert!(
crate::app::event_loop::welcome_history_build_bypass_consume(
&[Effect::LoadSession {
agent_id: AgentId(0),
session_id: "s".into(),
session_cwd: None,
chat_kind: false,
}],
true
)
);
}
#[test]
fn fetch_session_list_kind_filter_only_on_welcome_chat() {
set_active_local_workspace(None).unwrap();
let mut welcome = test_app();
welcome.chat_mode = true;
welcome.active_view = ActiveView::Welcome;
welcome.welcome_workspace_mode = WelcomeWorkspaceMode::LocalWorkspace;
match &dispatch(Action::FetchSessionList, &mut welcome)[..] {
[Effect::FetchSessionList { kind_filter, .. }] => {
assert_eq!(
kind_filter.as_deref(),
Some(["build".to_string()].as_slice())
);
}
other => panic!("{other:?}"),
}
let mut in_session = test_app_with_agent();
in_session.chat_mode = true;
match &dispatch(Action::FetchSessionList, &mut in_session)[..] {
[Effect::FetchSessionList { kind_filter, .. }] => {
assert!(kind_filter.is_none())
}
other => panic!("{other:?}"),
}
set_active_local_workspace(None).unwrap();
}
#[test]
fn in_session_new_does_not_clear_process_stamp() {
let tmp = tempfile::tempdir().unwrap();
set_active_local_workspace(Some(LocalWorkspaceConfig {
mode: LocalWorkspaceMode::Own,
cwd: Some(tmp.path().to_path_buf()),
server_id: None,
}))
.unwrap();
let mut app = test_app_with_agent();
app.chat_mode = true;
app.cwd = tmp.path().to_path_buf();
app.welcome_workspace_mode = WelcomeWorkspaceMode::Sandbox;
let _ = dispatch(Action::NewSession, &mut app);
assert!(
crate::app::session_startup::active_local_workspace()
.unwrap()
.is_some(),
"in-session /new must not clear the process stamp"
);
set_active_local_workspace(None).unwrap();
}
}

View file

@ -1647,7 +1647,7 @@ fn chat_mode_debounce_expiry_fetches_current_and_drops_stale() {
assert!(
matches!(
&effects[..],
[Effect::FetchSessionList { query: Some(q), seq: 1 }] if q == "abc"
[Effect::FetchSessionList { query: Some(q), seq: 1, .. }] if q == "abc"
),
"current debounce expiry must fetch with the query, got {effects:?}"
);
@ -1879,7 +1879,7 @@ fn chat_mode_force_search_fetches_immediately_and_empty_query_unfilters() {
assert!(
matches!(
&effects[..],
[Effect::FetchSessionList { query: Some(q), seq: 1 }] if q == "abc"
[Effect::FetchSessionList { query: Some(q), seq: 1, .. }] if q == "abc"
),
"forced search must fetch without debouncing, got {effects:?}"
);
@ -1894,7 +1894,8 @@ fn chat_mode_force_search_fetches_immediately_and_empty_query_unfilters() {
&effects[..],
[Effect::FetchSessionList {
query: None,
seq: 2
seq: 2,
..
}]
),
"cleared query must refetch the unfiltered list immediately (no debounce), got {effects:?}"
@ -2575,7 +2576,8 @@ fn build_mode_rapid_plain_fetches_keep_last_write_wins() {
&effects[..],
[Effect::FetchSessionList {
query: None,
seq: 0
seq: 0,
..
}]
),
"Build-mode plain fetch must not bump the seq, got {effects:?}"
@ -2634,7 +2636,8 @@ fn plain_picker_fetch_carries_no_query_and_bumps_seq() {
&effects[..],
[Effect::FetchSessionList {
query: None,
seq: 2
seq: 2,
..
}]
),
"picker fetch must be unfiltered and supersede the search, got {effects:?}"

View file

@ -1694,7 +1694,11 @@ fn delete_session_complete_removes_only_matching_source_and_id() {
.active_modal
.as_mut()
{
*pending_delete = Some(("local".into(), "s1".into(), "/r".into()));
*pending_delete = Some(crate::views::session_picker::PendingDelete {
source: "local".into(),
session_id: "s1".into(),
cwd: "/r".into(),
});
}
let _ = dispatch_task_result(

View file

@ -93,10 +93,13 @@ pub(super) fn dispatch_cancel_turn(app: &mut AppView) -> Vec<Effect> {
rewind_if_pristine: false,
}];
}
if !agent.session.state.is_turn_running() {
if !agent.session.state.is_turn_running() && !agent.session.state.is_compact_running() {
return vec![];
}
if let Some(stop) = resolved_pref {
if agent.session.state.is_compact_running() {
// No subagent picker for `/compact` — just stop the generation.
resolved_pref.or(Some(true))
} else if let Some(stop) = resolved_pref {
Some(stop)
} else {
// Check all running subagents, not just those from the current turn.
@ -183,6 +186,22 @@ pub(super) fn do_cancel_turn(app: &mut AppView, cancel_subagents: bool) -> Vec<E
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
if agent.session.state.is_compact_running() {
agent.session.cancel_compact_command();
agent.cancel_turn_view = None;
agent.cancel_turn_buttons.clear();
drain_permission_queue(agent);
let Some(session_id) = agent.session.session_id.clone() else {
return vec![];
};
agent.clear_send_now_expectation();
return vec![Effect::CancelTurn {
session_id,
cancel_subagents,
trigger: agent.cancel_trigger_hint.take(),
rewind_if_pristine: false,
}];
}
if !agent.session.state.is_turn_running() {
return vec![];
}
@ -541,12 +560,6 @@ pub(super) fn dispatch_demote_to_background(app: &mut AppView) -> Vec<Effect> {
}]
}
// TODO: Add dispatch_cancel_command() once xai-grok-shell supports proper
// server-side cancellation for /compact. Currently, the compaction handler
// uses spawn_local with no cancellation token, and blindly replaces the
// conversation history when done — so prompts sent after a client-side
// cancel would be lost.
// TaskResult handlers.
pub(super) fn handle_bg_task_killed(

View file

@ -271,6 +271,9 @@ pub(crate) struct SessionFlags {
/// Mutual exclusivity with Build plan profiles: profiles are omitted and a
/// warn is logged when plan flags are also set (K12).
pub chat_mode: bool,
/// Local-workspace stamp for ACP `_meta` (scrub still strips envId / Direct hub).
#[cfg(feature = "local-workspace")]
pub local_workspace: Option<crate::app::session_startup::LocalWorkspaceConfig>,
/// Effective screen mode label (`ScreenMode::meta_label`), stamped into
/// every `PromptRequest._meta.screenMode` for minimal-vs-regular usage
/// telemetry. `None` (key omitted) only under `Default` in tests; real
@ -327,6 +330,10 @@ impl SessionFlags {
}
if self.chat_mode {
meta.insert("x.ai/session".into(), serde_json::json!({ "kind": "chat" }));
#[cfg(feature = "local-workspace")]
if let Some(ref lw) = self.local_workspace {
stamp_local_workspace_meta(&mut meta, lw);
}
}
if !self.ask_user {
meta.insert("askUserQuestion".into(), serde_json::json!(false));
@ -346,33 +353,107 @@ impl SessionFlags {
///
/// `x.ai/cloud_existing_workspace` is intentionally omitted: scrub keeps it
/// iff `x.ai/local_workspace.mode == "attach"`.
#[allow(dead_code)]
pub(super) const CHAT_FORBIDDEN_WORKSPACE_BIND_KEYS: &[&str] = &[
"envId",
"x.ai/cloud_server_id",
];
/// FS-only tool ids for local existing workspace (chat attach/own).
#[cfg(feature = "local-workspace")]
pub(super) const LOCAL_WORKSPACE_FS_ONLY_TOOL_IDS: &[&str] = &[
"workspace.fs_list",
"workspace.fs_exists",
"workspace.fs_read_file",
"workspace.fs_write_file",
"workspace.fs_delete_file",
"workspace.put_files",
"workspace.get_files",
];
/// Stamp `_meta["x.ai/session"].kind = "chat"` and strip Build `agentProfile` (K12).
pub(super) fn apply_chat_kind_meta(meta: &mut Option<acp::Meta>) {
let obj = meta.get_or_insert_with(acp::Meta::new);
obj.insert("x.ai/session".into(), serde_json::json!({ "kind": "chat" }));
obj.remove("agentProfile");
}
/// Stamp chat+local intent. Attach also stamps `x.ai/cloud_existing_workspace`.
/// Own leaves `server_id` unset — shell supervisor mints before handshake.
///
/// Never stamps `envId` or `x.ai/cloud_server_id`.
#[cfg(feature = "local-workspace")]
pub(super) fn stamp_local_workspace_meta(
meta: &mut serde_json::Map<String, serde_json::Value>,
cfg: &crate::app::session_startup::LocalWorkspaceConfig,
) {
use crate::app::session_startup::LocalWorkspaceMode;
let mut local = serde_json::Map::new();
let mode = match cfg.mode {
LocalWorkspaceMode::Attach => "attach",
LocalWorkspaceMode::Own => "own",
};
local.insert("mode".into(), serde_json::json!(mode));
if let Some(ref sid) = cfg.server_id {
local.insert("server_id".into(), serde_json::json!(sid));
}
if let Some(ref cwd) = cfg.cwd {
local
.insert("cwd".into(), serde_json::json!(cwd.to_string_lossy().into_owned()));
}
meta.insert("x.ai/local_workspace".into(), serde_json::Value::Object(local));
tracing::info!(
target: crate::views::welcome::workspace_mode::WORKSPACE_MODE_LOG,
event = "acp_meta_stamped",
mode,
server_id = cfg.server_id.as_deref(),
cwd = cfg.cwd.as_ref().map(|p| p.display().to_string()),
"stamped x.ai/local_workspace onto session meta"
);
if cfg.mode == LocalWorkspaceMode::Attach && let Some(ref sid) = cfg.server_id {
let mut existing = serde_json::Map::new();
existing.insert("server_id".into(), serde_json::json!(sid));
if let Some(ref cwd) = cfg.cwd {
existing
.insert(
"cwd".into(),
serde_json::json!(cwd.to_string_lossy().into_owned()),
);
}
meta.insert(
"x.ai/cloud_existing_workspace".into(),
serde_json::Value::Object(existing),
);
}
}
/// Apply [`stamp_local_workspace_meta`] onto optional ACP meta.
#[cfg(feature = "local-workspace")]
pub(super) fn apply_local_workspace_meta(
meta: &mut Option<acp::Meta>,
cfg: &crate::app::session_startup::LocalWorkspaceConfig,
) {
let obj = meta.get_or_insert_with(acp::Meta::new);
stamp_local_workspace_meta(obj, cfg);
}
/// Shared chat create/load/worktree meta finalize: kind + local stamp + scrub.
pub(super) fn finalize_chat_session_meta(
meta: &mut Option<acp::Meta>,
is_chat_path: bool,
#[allow(unused_variables)]
#[cfg_attr(not(feature = "local-workspace"), allow(unused_variables))]
session_flags: &SessionFlags,
) {
if !is_chat_path {
return;
}
apply_chat_kind_meta(meta);
#[cfg(feature = "local-workspace")]
if let Some(ref lw) = session_flags.local_workspace {
apply_local_workspace_meta(meta, lw);
}
scrub_chat_workspace_bind_meta(meta);
}
/// Remove client workspace-bind keys from chat create/load meta (defense in depth).
///
/// Narrow scrub exception: keep `x.ai/cloud_existing_workspace` when local
/// intent is attach. Never keep `envId` or Direct hub `x.ai/cloud_server_id`.
/// intent is **attach**. Own stamps intent only (shell mints `server_id`).
/// Never keep `envId` or Direct hub `x.ai/cloud_server_id`.
pub(super) fn scrub_chat_workspace_bind_meta(meta: &mut Option<acp::Meta>) {
let Some(obj) = meta.as_mut() else {
return;
@ -380,10 +461,80 @@ pub(super) fn scrub_chat_workspace_bind_meta(meta: &mut Option<acp::Meta>) {
for key in CHAT_FORBIDDEN_WORKSPACE_BIND_KEYS {
obj.remove(*key);
}
#[cfg(feature = "local-workspace")]
{
let allow_existing_attach = obj
.get("x.ai/local_workspace")
.and_then(|v| v.get("mode"))
.and_then(|m| m.as_str()) == Some("attach");
if !allow_existing_attach {
obj.remove("x.ai/cloud_existing_workspace");
}
}
{
obj.remove("x.ai/cloud_existing_workspace");
}
}
/// Params for shell ACP `x.ai/session/add_local_workspace`.
///
/// v1 surface is **shell ACP-only** (no pager slash/command wiring). Pager
/// dogfood / headless clients call the extension directly with this payload.
/// No remove path until session end.
#[cfg(feature = "local-workspace")]
#[allow(dead_code)]
pub(crate) fn mid_session_add_local_workspace_params(
session_id: &str,
cfg: &crate::app::session_startup::LocalWorkspaceConfig,
) -> serde_json::Value {
let mut meta = serde_json::Map::new();
stamp_local_workspace_meta(&mut meta, cfg);
let mut opt = Some(meta);
scrub_chat_workspace_bind_meta(&mut opt);
serde_json::json!({
"sessionId": session_id,
"meta": opt.unwrap_or_default(),
})
}
/// Fail closed on operator attestation outside the FS-only allowlist.
/// `None` / empty attested set → uncheckable → refuse. Live server is not probed.
#[cfg(feature = "local-workspace")]
pub(crate) fn reject_non_fs_only_advertised_tools(
advertised_tool_ids: Option<&[&str]>,
) -> Result<(), String> {
let Some(ids) = advertised_tool_ids else {
return Err(
"operator attestation GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS is unset \
(uncheckable); refuse attach. Live workspace_server was not inspected set \
the env to a comma-separated FS-only catalog."
.into(),
);
};
if ids.is_empty() {
return Err(
"operator attestation GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS is empty \
(uncheckable); refuse attach. Live workspace_server was not inspected."
.into(),
);
}
let forbidden: Vec<&str> = ids
.iter()
.copied()
.filter(|id| !LOCAL_WORKSPACE_FS_ONLY_TOOL_IDS.contains(id))
.collect();
if forbidden.is_empty() {
Ok(())
} else {
Err(
format!(
"operator attestation lists tools outside the FS-only allowlist: {}. \
Live workspace_server was not inspected. Fix \
GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS or restart workspace_server \
with --require-explicit-toolset and an FS-only catalog.",
forbidden.join(", ")
),
)
}
}
/// Metadata returned from effect execution so the event loop can patch
/// state that requires a spawned task handle (e.g., auth AbortHandle).
#[derive(Default)]

View file

@ -18,6 +18,8 @@ pub(crate) use helpers::{
EffectMeta, RestoreProgressMsg, SessionFlags, persist_permission_mode_and_notify,
persist_setting, sanitize_user_error,
};
#[cfg(feature = "local-workspace")]
pub(crate) use helpers::reject_non_fs_only_advertised_tools;
use helpers::*;
use std::path::{Path, PathBuf};
use agent_client_protocol as acp;
@ -707,7 +709,7 @@ pub(crate) fn execute(
}
});
}
Effect::FetchSessionList { query, seq } => {
Effect::FetchSessionList { query, seq, kind_filter } => {
let tx = acp_tx.clone();
let cwd = cwd.to_path_buf();
tasks
@ -721,6 +723,19 @@ pub(crate) fn execute(
} else {
params["allowRelax"] = serde_json::Value::Bool(true);
}
if let Some(kinds) = &kind_filter {
params["_meta"] = serde_json::json!({
"x.ai/facetFilters": { "kind": kinds },
});
tracing::info!(
target: "grok.pager.workspace_mode",
event = "session_list_fetch",
kind_filter = ?kinds,
query = ?query,
seq,
"FetchSessionList with kind facet filter"
);
}
let request = acp::ExtRequest::new(
"x.ai/session/list",
serde_json::value::to_raw_value(&params)
@ -3543,6 +3558,7 @@ pub(crate) fn execute(
}
Effect::SendBtw { agent_id, session_id, question, minimal_request_id } => {
let tx = acp_tx.clone();
let is_api_key_auth = session_flags.is_api_key_auth;
tasks
.spawn(async move {
let request = acp::ExtRequest::new(
@ -3577,9 +3593,7 @@ pub(crate) fn execute(
Err(e) => {
TaskResult::BtwResponse {
agent_id,
result: Err(
sanitize_user_error(&format!("side question failed: {e}")),
),
result: Err(format_acp_error(&e, is_api_key_auth)),
minimal_request_id,
}
}

View file

@ -1523,6 +1523,7 @@ async fn fetch_session_list_pushes_query_and_echoes_seq() {
let mut tasks = run(Effect::FetchSessionList {
query: Some("hit".into()),
seq: 7,
kind_filter: None,
});
match tasks.join_next().await.expect("task").expect("no panic") {
TaskResult::SessionListLoaded { sessions, scope, seq, query, .. } => {
@ -1539,6 +1540,7 @@ async fn fetch_session_list_pushes_query_and_echoes_seq() {
let mut tasks = run(Effect::FetchSessionList {
query: None,
seq: 8,
kind_filter: None,
});
match tasks.join_next().await.expect("task").expect("no panic") {
TaskResult::SessionListLoaded { scope, seq, query, .. } => {
@ -1554,6 +1556,7 @@ async fn fetch_session_list_pushes_query_and_echoes_seq() {
let mut tasks = run(Effect::FetchSessionList {
query: Some("fail-me".into()),
seq: 9,
kind_filter: None,
});
match tasks.join_next().await.expect("task").expect("no panic") {
TaskResult::SessionListFailed { error, seq, query } => {
@ -1589,6 +1592,50 @@ async fn fetch_session_list_pushes_query_and_echoes_seq() {
assert_eq!(captured[2]["query"], "fail-me");
}
#[tokio::test]
async fn fetch_session_list_sends_kind_facet_filter() {
use std::sync::{Arc, Mutex};
use xai_acp_lib::AcpAgentMessage;
let captured: Arc<Mutex<Vec<serde_json::Value>>> = Arc::default();
let captured_for_task = captured.clone();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if let AcpAgentMessage::ExtMethod(args) = msg {
let params: serde_json::Value = serde_json::from_str(
args.request.params.get(),
)
.expect("params JSON");
captured_for_task.lock().unwrap().push(params);
let body = serde_json::json!({ "result": { "sessions": [] } });
let raw = serde_json::value::RawValue::from_string(body.to_string())
.expect("ser");
let _ = args.response_tx.send(Ok(acp::ExtResponse::new(Arc::from(raw))));
}
}
});
let (progress_tx, _progress_rx) = tokio::sync::mpsc::unbounded_channel();
let mut tasks = JoinSet::new();
execute(
Effect::FetchSessionList {
query: None,
seq: 1,
kind_filter: Some(vec!["build".into()]),
},
&mut tasks,
&tx,
Path::new("."),
&SessionFlags::default(),
&progress_tx,
);
let _ = tasks.join_next().await;
let captured = captured.lock().unwrap();
assert_eq!(captured.len(), 1);
assert_eq!(
captured[0]["_meta"]["x.ai/facetFilters"]["kind"],
serde_json::json!(["build"])
);
}
#[tokio::test]
async fn fetch_workflows_list_sends_session_id() {
use std::sync::{Arc, Mutex};
use xai_acp_lib::AcpAgentMessage;
@ -1979,7 +2026,7 @@ fn to_meta_chat_mode_stamps_kind_and_omits_agent_profile() {
..Default::default()
};
let meta = flags.to_meta().expect("chat_mode must emit meta");
assert_eq!(meta["x.ai/session"] ["kind"], "chat");
assert_eq!(meta["x.ai/session"]["kind"], "chat");
assert!(
meta.get("agentProfile").is_none(),
"K12: chat mode must omit Build agentProfile"
@ -2006,7 +2053,7 @@ fn load_meta_chat_kind_alone_stamps_kind_and_strips_profile() {
scrub_chat_workspace_bind_meta(&mut meta);
}
let meta = meta.expect("chat_kind must produce meta");
assert_eq!(meta["x.ai/session"] ["kind"], "chat");
assert_eq!(meta["x.ai/session"]["kind"], "chat");
assert!(
meta.get("agentProfile").is_none(),
"entry chat_kind must strip Build agentProfile"
@ -2040,7 +2087,7 @@ fn chat_create_meta_never_includes_workspace_bind_keys_when_cloud_fields_set() {
apply_chat_kind_meta(&mut meta);
scrub_chat_workspace_bind_meta(&mut meta);
let meta = meta.expect("chat create must emit meta");
assert_eq!(meta["x.ai/session"] ["kind"], "chat");
assert_eq!(meta["x.ai/session"]["kind"], "chat");
assert_chat_meta_has_no_workspace_bind_keys(
&serde_json::Value::Object(meta.clone()),
);
@ -2064,11 +2111,165 @@ fn chat_load_meta_never_includes_workspace_bind_keys() {
}
scrub_chat_workspace_bind_meta(&mut meta);
let meta = meta.expect("chat load must emit meta");
assert_eq!(meta["x.ai/session"] ["kind"], "chat");
assert_eq!(meta["x.ai/session"]["kind"], "chat");
assert_chat_meta_has_no_workspace_bind_keys(
&serde_json::Value::Object(meta.clone()),
);
}
/// Attach stamp keeps existing workspace + local intent; envId / Direct hub stay stripped.
#[cfg(feature = "local-workspace")]
#[test]
fn scrub_chat_workspace_matrix_attach_exception() {
use crate::app::session_startup::{LocalWorkspaceConfig, LocalWorkspaceMode};
let mut meta = Some(acp::Meta::new());
{
let obj = meta.as_mut().unwrap();
obj.insert("envId".into(), serde_json::json!("env-x"));
obj.insert("x.ai/cloud_server_id".into(), serde_json::json!("hub-x"));
obj.insert(
"x.ai/cloud_existing_workspace".into(),
serde_json::json!({"server_id": "srv-x", "cwd": "/ws"}),
);
}
scrub_chat_workspace_bind_meta(&mut meta);
let scrubbed = meta.as_ref().unwrap();
assert!(scrubbed.get("envId").is_none());
assert!(scrubbed.get("x.ai/cloud_server_id").is_none());
assert!(scrubbed.get("x.ai/cloud_existing_workspace").is_none());
let mut meta = Some(acp::Meta::new());
apply_local_workspace_meta(
&mut meta,
&LocalWorkspaceConfig {
mode: LocalWorkspaceMode::Attach,
cwd: Some(std::path::PathBuf::from("/tmp/repo")),
server_id: Some("srv-dogfood".into()),
},
);
{
let obj = meta.as_mut().unwrap();
obj.insert("envId".into(), serde_json::json!("env-must-go"));
obj.insert("x.ai/cloud_server_id".into(), serde_json::json!("hub-must-go"));
}
scrub_chat_workspace_bind_meta(&mut meta);
let scrubbed = meta.as_ref().unwrap();
assert!(scrubbed.get("envId").is_none(), "envId must stay scrubbed");
assert!(
scrubbed.get("x.ai/cloud_server_id").is_none(),
"Direct hub must stay scrubbed"
);
assert_eq!(
scrubbed["x.ai/cloud_existing_workspace"]["server_id"],
"srv-dogfood"
);
assert_eq!(scrubbed["x.ai/local_workspace"]["mode"], "attach");
assert_eq!(scrubbed["x.ai/local_workspace"]["server_id"], "srv-dogfood");
assert_eq!(scrubbed["x.ai/local_workspace"]["cwd"], "/tmp/repo");
}
#[cfg(feature = "local-workspace")]
#[test]
fn to_meta_chat_attach_stamps_local_and_existing() {
use crate::app::session_startup::{LocalWorkspaceConfig, LocalWorkspaceMode};
let flags = SessionFlags {
chat_mode: true,
local_workspace: Some(LocalWorkspaceConfig {
mode: LocalWorkspaceMode::Attach,
cwd: Some(std::path::PathBuf::from("/tmp/repo")),
server_id: Some("srv-1".into()),
}),
..Default::default()
};
let meta = flags.to_meta().expect("meta");
assert_eq!(meta["x.ai/session"]["kind"], "chat");
assert_eq!(meta["x.ai/local_workspace"]["mode"], "attach");
assert_eq!(meta["x.ai/cloud_existing_workspace"]["server_id"], "srv-1");
assert!(meta.get("envId").is_none());
assert!(meta.get("x.ai/cloud_server_id").is_none());
}
#[cfg(feature = "local-workspace")]
#[test]
fn to_meta_chat_own_stamps_intent_without_existing() {
use crate::app::session_startup::{LocalWorkspaceConfig, LocalWorkspaceMode};
let flags = SessionFlags {
chat_mode: true,
local_workspace: Some(LocalWorkspaceConfig {
mode: LocalWorkspaceMode::Own,
cwd: Some(std::path::PathBuf::from("/tmp/repo-own")),
server_id: None,
}),
..Default::default()
};
let meta = flags.to_meta().expect("meta");
assert_eq!(meta["x.ai/local_workspace"]["mode"], "own");
assert_eq!(meta["x.ai/local_workspace"]["cwd"], "/tmp/repo-own");
assert!(meta["x.ai/local_workspace"].get("server_id").is_none());
assert!(
meta.get("x.ai/cloud_existing_workspace").is_none(),
"own must not stamp existing; shell mints server_id"
);
assert!(meta.get("envId").is_none());
}
#[cfg(feature = "local-workspace")]
#[test]
fn mid_session_add_params_scrub_envid() {
use crate::app::session_startup::{LocalWorkspaceConfig, LocalWorkspaceMode};
let params = mid_session_add_local_workspace_params(
"sess-1",
&LocalWorkspaceConfig {
mode: LocalWorkspaceMode::Attach,
cwd: Some(std::path::PathBuf::from("/tmp/repo")),
server_id: Some("srv-add".into()),
},
);
assert_eq!(params["sessionId"], "sess-1");
assert_eq!(params["meta"]["x.ai/local_workspace"]["mode"], "attach");
assert_eq!(
params["meta"]["x.ai/cloud_existing_workspace"]["server_id"],
"srv-add"
);
assert!(params["meta"].get("envId").is_none());
}
#[cfg(feature = "local-workspace")]
#[test]
fn reject_non_fs_only_advertised_tools_matrix() {
let fs_only = ["workspace.fs_list", "workspace.fs_read_file", "workspace.put_files"];
assert!(reject_non_fs_only_advertised_tools(Some(&fs_only[..])).is_ok());
assert!(
reject_non_fs_only_advertised_tools(None)
.unwrap_err()
.contains("uncheckable")
);
assert!(
reject_non_fs_only_advertised_tools(Some(&[][..]))
.unwrap_err()
.contains("empty")
);
let with_exec = ["workspace.fs_list", "workspace.bash", "terminal.exec"];
let err = reject_non_fs_only_advertised_tools(Some(&with_exec[..])).unwrap_err();
assert!(err.contains("FS-only"), "{err}");
assert!(err.contains("workspace.bash"), "{err}");
assert!(err.contains("terminal.exec"), "{err}");
}
#[cfg(feature = "local-workspace")]
#[test]
fn finalize_chat_session_meta_stamps_attach_on_worktree_path() {
use crate::app::session_startup::{LocalWorkspaceConfig, LocalWorkspaceMode};
let flags = SessionFlags {
chat_mode: false,
local_workspace: Some(LocalWorkspaceConfig {
mode: LocalWorkspaceMode::Attach,
cwd: Some(std::path::PathBuf::from("/tmp/repo")),
server_id: Some("srv-wt".into()),
}),
..Default::default()
};
let mut meta = flags.to_meta();
finalize_chat_session_meta(&mut meta, true, &flags);
let meta = meta.expect("meta");
assert_eq!(meta["x.ai/session"]["kind"], "chat");
assert_eq!(meta["x.ai/local_workspace"]["mode"], "attach");
assert_eq!(meta["x.ai/cloud_existing_workspace"]["server_id"], "srv-wt");
assert!(meta.get("envId").is_none());
}
#[test]
fn to_meta_yolo_suppresses_auto_mode() {
let flags = SessionFlags {

File diff suppressed because it is too large Load diff

View file

@ -29,19 +29,13 @@
//! seams), where env is set before any process-global's first touch.
//!
//! Unix-only: the leader transport here is a unix socket.
use super::actions::{Action, TaskResult};
use super::agent::AgentState;
use super::agent_view::AgentView;
use super::app_view::{AppView, AuthState, TrustState};
use super::{acp_handler, dispatch, effects};
use crate::acp::leader_bridge::bridge_channels;
use crate::acp::model_state::ModelState;
use crate::scrollback::block::RenderBlock;
use agent_client_protocol as acp;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize};
use std::time::Duration;
use agent_client_protocol as acp;
use tempfile::TempDir;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
@ -52,8 +46,19 @@ use xai_grok_shell::leader::{
LeaderServerControlState, LeaderServerMetadata, ReconnectPolicy, run_leader_server,
};
use xai_grok_test_support::MockInferenceServer;
use super::actions::{Action, TaskResult};
use super::agent::AgentState;
use super::agent_view::AgentView;
use super::app_view::{AppView, AuthState, TrustState};
use super::{acp_handler, dispatch, effects};
use crate::acp::leader_bridge::bridge_channels;
use crate::acp::model_state::ModelState;
use crate::scrollback::block::RenderBlock;
const PUMP_TICK: Duration = Duration::from_millis(10);
const TURN_BUDGET: Duration = Duration::from_secs(60);
/// Await a bring-up step with a hard budget so an on-demand run that hangs
/// names its phase instead of parking until the test-runner kill.
async fn bounded<T>(what: &str, fut: impl std::future::Future<Output = T>) -> T {
@ -61,11 +66,13 @@ async fn bounded<T>(what: &str, fut: impl std::future::Future<Output = T>) -> T
.await
.unwrap_or_else(|_| panic!("leader-cluster bring-up timed out: {what}"))
}
/// The grok home the agent actually persisted under: `grok_home()` is
/// process-cached, so an earlier test in this binary may have pinned it.
fn effective_grok_home() -> PathBuf {
xai_grok_config::grok_home()
}
/// Concatenated agent-message text across a view's scrollback (copy of the
/// acp_handler tests' helper; that one is test-mod private).
fn agent_message_text(view: &AgentView) -> String {
@ -79,6 +86,7 @@ fn agent_message_text(view: &AgentView) -> String {
}
out
}
/// One pager client: a full `AppView` behind the production leader bridge.
struct ClusterClient {
app: AppView,
@ -91,6 +99,7 @@ struct ClusterClient {
/// generation bumps after a leader kill/respawn.
status_rx: Option<tokio::sync::watch::Receiver<ConnectionStatus>>,
}
impl ClusterClient {
/// Drain everything currently ready (inbound ACP + finished tasks).
/// Returns whether anything was processed.
@ -110,31 +119,18 @@ impl ClusterClient {
}
progressed
}
fn drain_pending_effects(&mut self) {
if !self.app.pending_effects.is_empty() {
let effs = std::mem::take(&mut self.app.pending_effects);
self.process_effects(effs);
}
}
/// The event loop's `process_effects`, minus terminal/auth-handle wiring
/// (that fn is event_loop-private; this mirrors its body).
fn process_effects(&mut self, effs: Vec<super::actions::Effect>) {
let flags = effects::SessionFlags {
plan_mode: self.app.plan_mode,
subagents: self.app.subagents,
ask_user: self.app.ask_user,
restore_code: self.app.restore_code,
agent_override: self.app.agent_override.clone(),
yolo_mode: self.app.default_yolo,
auto_mode: dispatch::effective_auto(
self.app.default_yolo,
matches!(self.app.current_ui.permission_mode.as_deref(), Some("auto")),
),
chat_mode: self.app.chat_mode,
screen_mode_label: Some(self.app.screen_mode.meta_label()),
is_api_key_auth: self.app.is_api_key_auth,
resume_local_miss: self.app.resume_local_miss.clone(),
};
let flags = super::event_loop::session_flags_for_effects(&mut self.app, &effs);
for eff in effs {
let (_quit, _meta) = effects::execute(
eff,
@ -147,17 +143,20 @@ impl ClusterClient {
}
self.drain_pending_effects();
}
/// Dispatch a user action and run its effects.
fn act(&mut self, action: Action) {
let effs = dispatch::dispatch(action, &mut self.app);
self.process_effects(effs);
}
/// Pump until `pred(app)` holds, within [`TURN_BUDGET`]. No fixed sleeps
/// beyond the pump tick; panics with `what` on expiry. Single-client sugar
/// over [`pump_clients_until`] so there is exactly one pump loop.
async fn pump_until(&mut self, what: &str, pred: impl Fn(&AppView) -> bool) {
pump_clients_until(&mut [self], what, |clients| pred(&clients[0].app)).await;
}
/// The most recently created agent view (scenarios add tabs in order).
fn latest_agent(&self) -> &AgentView {
self.app
@ -166,6 +165,7 @@ impl ClusterClient {
.last()
.expect("client has no agent view yet")
}
fn agent_for_session(&self, sid: &str) -> &AgentView {
self.app
.agents
@ -178,6 +178,7 @@ impl ClusterClient {
})
.unwrap_or_else(|| panic!("no agent view for session {sid}"))
}
/// Create a new session through the real dispatch → effect → agent path.
async fn new_session(&mut self) -> String {
self.act(Action::NewSession);
@ -195,6 +196,7 @@ impl ClusterClient {
.0
.to_string()
}
/// Attach to an existing session (viewer path) and wait for the replay to
/// land.
async fn load_session(&mut self, sid: &str) {
@ -211,6 +213,7 @@ impl ClusterClient {
})
.await;
}
/// Drive one full turn on the active agent and wait until it lands
/// (sentinel visible + agent back to Idle).
async fn run_turn(&mut self, prompt: &str, sentinel: &str) {
@ -224,10 +227,12 @@ impl ClusterClient {
})
.await;
}
fn sever(self) {
self.bridge_cancel.cancel();
}
}
/// Pump several clients until `pred` holds across them, within
/// [`TURN_BUDGET`]; panics with `what` on expiry.
async fn pump_clients_until(
@ -250,6 +255,7 @@ async fn pump_clients_until(
tokio::time::sleep(PUMP_TICK).await;
}
}
/// The cluster: leader server + real agent, plus knobs to kill/respawn the
/// leader generation under the same socket path.
struct PagerLeaderCluster {
@ -275,15 +281,18 @@ struct PagerLeaderCluster {
_env: Vec<crate::test_util::EnvVarGuard>,
_grok_home: TempDir,
}
impl PagerLeaderCluster {
/// Stand up the cluster. Callers MUST be `#[serial_test::serial(GROK_HOME)]`
/// (env mutation) and run inside a current-thread `LocalSet`.
async fn start() -> Self {
let _ = rustls::crypto::ring::default_provider().install_default();
let server = MockInferenceServer::start().await.expect("mock server");
let grok_home = TempDir::new().unwrap();
let workdir = TempDir::new().unwrap();
let sock_path = grok_home.path().join("leader-cluster.sock");
let env = vec![
crate::test_util::EnvVarGuard::set("GROK_HOME", grok_home.path()),
crate::test_util::EnvVarGuard::set("GROK_CLI_CHAT_PROXY_BASE_URL", server.url()),
@ -296,12 +305,15 @@ impl PagerLeaderCluster {
// connect_or_spawn) to this cluster's socket.
crate::test_util::EnvVarGuard::set(LEADER_SOCKET_ENV, &sock_path),
];
// Hold the flock for the cluster's lifetime (see field doc).
let mut flock = LeaderLock::new("");
assert!(
flock.try_acquire().expect("acquire cluster flock"),
"cluster flock unexpectedly held"
);
flock.write_pid().expect("stamp cluster flock");
let client_count = Arc::new(AtomicUsize::new(0));
let mut cluster = Self {
sock_path,
@ -318,6 +330,7 @@ impl PagerLeaderCluster {
cluster.spawn_leader_generation().await;
cluster
}
/// Bind a fresh leader-server generation at the fixed socket path and
/// wire a fresh REAL agent behind it.
async fn spawn_leader_generation(&mut self) {
@ -326,11 +339,15 @@ impl PagerLeaderCluster {
let (response_tx, response_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let cancel = CancellationToken::new();
self.server_cancel = cancel.clone();
let control_state = LeaderServerControlState::new(LeaderServerMetadata {
pid: std::process::id(),
socket_path: self.sock_path.clone(),
lock_path: self.sock_path.with_extension("lock"),
ws_url_suffix: String::new(),
// MUST be the client-side comparison source (xai_grok_version), not
// this crate's version: a reconnecting client evicts strictly-older
// leaders, and "evict" here would signal THIS test process.
leader_binary_version: xai_grok_version::VERSION.to_string(),
});
let sock_for_server = self.sock_path.clone();
@ -355,17 +372,20 @@ impl PagerLeaderCluster {
)
.await;
}));
generation_tasks.extend(xai_grok_shell::leader::in_process::spawn_agent(
acp_rx,
response_tx,
));
self.generation_tasks = generation_tasks;
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
while !self.sock_path.exists() && tokio::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(self.sock_path.exists(), "leader socket never bound");
}
/// Kill the current leader generation (server + agent die together, like
/// a real leader process crash) and wait for the socket to vanish.
async fn kill_leader(&mut self) {
@ -374,19 +394,32 @@ impl PagerLeaderCluster {
while self.sock_path.exists() && tokio::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
// Fail HERE if the old generation never released the socket: its late
// shutdown cleanup would otherwise delete the respawned generation's
// fresh socket from under it (same-path race), which surfaces as a
// confusing reconnect-budget expiry downstream.
assert!(
!self.sock_path.exists(),
"old leader generation never released the socket"
);
// Abort + drain the generation's agent/bridge tasks (the server task
// has already run its socket cleanup above). Channel-closure teardown
// is only eventual; without this drain an old agent task could still
// be running against the same GROK_HOME when the next generation's
// agent starts — two writers on one updates.jsonl, the corruption
// class the real leader's flock prevents.
for task in self.generation_tasks.drain(..) {
task.abort();
let _ = task.await;
}
// The next generation's agent must re-authenticate its ACP surface.
self.authenticated = false;
}
async fn respawn_leader(&mut self) {
self.spawn_leader_generation().await;
}
/// Connect a pager client. With `reconnect: true` the bridge gets a real
/// `LeaderReconnector` (socket pinned via `GROK_LEADER_SOCKET`, flock held
/// by the cluster, so reconnects always adopt the in-process server).
@ -406,6 +439,7 @@ impl PagerLeaderCluster {
.await
.expect("cluster client connect");
let (leader_tx, leader_rx) = conn.into_channels();
let cancel = CancellationToken::new();
let (reconnector, status_rx) = if reconnect {
let (status_tx, status_rx) = LeaderReconnector::status_channel();
@ -426,6 +460,7 @@ impl PagerLeaderCluster {
} else {
(None, None)
};
let bridge = bridge_channels(
leader_tx,
leader_rx,
@ -436,6 +471,8 @@ impl PagerLeaderCluster {
.expect("bridge spawn");
let tx = bridge.channel.tx;
let rx = bridge.channel.rx;
// Same handshake the pager performs after bridging (spawn path).
let _init: acp::InitializeResponse = bounded(
"initialize",
acp_send(
@ -476,12 +513,14 @@ impl PagerLeaderCluster {
.expect("authenticate through bridge");
self.authenticated = true;
}
let mut app = AppView::new(tx, ModelState::default(), Vec::new());
app.leader_mode = true;
app.auth_state = AuthState::Done;
app.trust_state = TrustState::Done;
app.project_picker_shown = true;
app.cwd = self.workdir.path().to_path_buf();
let (progress_tx, progress_rx) = tokio::sync::mpsc::unbounded_channel();
ClusterClient {
app,
@ -493,6 +532,7 @@ impl PagerLeaderCluster {
status_rx,
}
}
/// Inference request count (chat/responses/messages only), for
/// no-turn-was-re-driven invariants.
fn inference_request_count(&self) -> usize {
@ -507,15 +547,20 @@ impl PagerLeaderCluster {
.count()
}
}
impl Drop for PagerLeaderCluster {
fn drop(&mut self) {
self.server_cancel.cancel();
// Best-effort (Drop cannot await): stop the generation's tasks so they
// never outlive the env guards / temp dirs dropping right after.
for task in self.generation_tasks.drain(..) {
task.abort();
}
}
}
fn occurrences(haystack: &str, needle: &str) -> usize {
haystack.matches(needle).count()
}
mod scenarios;

View file

@ -652,6 +652,19 @@ pub async fn run(
{
anyhow::bail!("{err}");
}
#[cfg(feature = "local-workspace")]
{
let lw = session_startup::resolve_local_workspace_config(
args.chat(),
args.local_workspace(),
args.local_workspace_attach(),
args.local_workspace_cwd(),
)?;
if let Some(ref cfg) = lw {
session_startup::emit_local_workspace_startup_ux(cfg)?;
}
session_startup::set_active_local_workspace(lw)?;
}
let intent = args
.session_startup_intent()
.map_err(|e| anyhow::anyhow!("{e}"))?;
@ -2018,6 +2031,47 @@ mod tests {
fn cli_chat_flag_rejected_without_feature() {
assert!(try_parse_pager(&["grok-pager", "--chat"]).is_err());
}
#[cfg(feature = "local-workspace")]
#[test]
fn cli_local_workspace_attach_requires_chat() {
assert!(
try_parse_pager(&["grok-pager", "--local-workspace-attach=srv"]).is_err(),
"attach without --chat must clap-error"
);
let args =
try_parse_pager(&["grok-pager", "--chat", "--local-workspace-attach=srv"]).unwrap();
assert_eq!(args.local_workspace_attach(), Some("srv"));
}
#[cfg(feature = "local-workspace")]
#[test]
fn cli_local_workspace_own_conflicts_with_attach() {
assert!(
try_parse_pager(&[
"grok-pager",
"--chat",
"--local-workspace=/tmp/a",
"--local-workspace-attach=srv",
])
.is_err(),
"own + attach must clap-conflict"
);
}
#[cfg(feature = "local-workspace")]
#[test]
fn cli_local_workspace_cwd_requires_chat() {
assert!(try_parse_pager(&["grok-pager", "--local-workspace-cwd=/tmp/a"]).is_err());
let args = try_parse_pager(&[
"grok-pager",
"--chat",
"--local-workspace-attach=srv",
"--local-workspace-cwd=/tmp/repo",
])
.unwrap();
assert_eq!(
args.local_workspace_cwd(),
Some(std::path::Path::new("/tmp/repo"))
);
}
#[test]
fn cli_local_workspace_flags_rejected_without_feature() {
assert!(try_parse_pager(&["grok-pager", "--local-workspace-attach=srv"]).is_err());

View file

@ -1022,41 +1022,19 @@ impl AgentView {
vim_normal_first: crate::appearance::cache::load_vim_mode(),
};
// Delete-confirmation flow: `d` arms a confirmation on the
// focused row, then `y` confirms and `n` (or any other key)
// cancels. y/n are intercepted here — before the picker
// handler — only while armed, so the rest of the time `y`
// keeps its normal meaning (copy the session id).
if pending_delete.is_some()
&& let crossterm::event::Event::Key(k) = ev
&& k.kind == KeyEventKind::Press
&& k.modifiers.is_empty()
{
match k.code {
crossterm::event::KeyCode::Char('y') => {
// Confirm. The cwd was captured when the row was
// armed, so this can't be foiled by an async
// picker-list update (e.g. a deep-search result)
// landing between `d` and `y`.
if let Some((source, session_id, cwd)) = pending_delete.take() {
return InputOutcome::Action(Action::DeleteSession {
source,
session_id,
cwd,
});
}
return InputOutcome::Changed;
}
crossterm::event::KeyCode::Char('n') => {
*pending_delete = None;
return InputOutcome::Changed;
}
_ => {
// Any other key cancels, then falls through to its
// normal handling below.
*pending_delete = None;
}
match crate::views::session_picker::handle_pending_delete_key(pending_delete, ev) {
crate::views::session_picker::PendingDeleteKey::Confirm(pd) => {
return InputOutcome::Action(Action::DeleteSession {
source: pd.source,
session_id: pd.session_id,
cwd: pd.cwd,
});
}
crate::views::session_picker::PendingDeleteKey::Cancel => {
return InputOutcome::Changed;
}
crate::views::session_picker::PendingDeleteKey::Disarmed
| crate::views::session_picker::PendingDeleteKey::NotArmed => {}
}
if let crossterm::event::Event::Key(key) = ev
@ -1082,7 +1060,12 @@ impl AgentView {
});
}
match handle_picker_input(ev, state, entry_count, &config) {
let selected_before = state.selected;
let outcome = handle_picker_input(ev, state, entry_count, &config);
if pending_delete.is_some() && state.selected != selected_before {
*pending_delete = None;
}
match outcome {
PickerOutcome::Selected(i) => {
match entry_map.get(i).and_then(|e| e.as_ref()) {
Some(PickerItem::Fuzzy { original_index }) => {
@ -1225,28 +1208,13 @@ impl AgentView {
InputOutcome::Action(Action::CycleSessionSourceFilter)
}
PickerOutcome::Action('d') => {
// Arm a delete confirmation on the highlighted row,
// capturing source, id, and cwd now (the row is present at
// this moment) so the `y` confirm can't be foiled by an
// async picker-list update. `y` confirms / `n` cancels
// on the next key press (intercepted above).
*pending_delete =
match entry_map.get(state.selected).and_then(|e| e.as_ref()) {
Some(PickerItem::Fuzzy { original_index }) => entries
.as_ref()
.and_then(|e| e.get(*original_index))
.filter(|entry| {
!crate::app::foreign_sessions::is_foreign_picker_source(
&entry.source,
)
})
.map(|e| (e.source.clone(), e.id.clone(), e.cwd.clone())),
Some(PickerItem::Content { hit_index }) => content_results
.as_ref()
.and_then(|h| h.get(*hit_index))
.map(|h| ("local".into(), h.session_id.clone(), h.cwd.clone())),
None => None,
};
crate::views::session_picker::pending_delete_from_selection(
state.selected,
&entry_map,
entries.as_deref(),
content_results.as_deref(),
);
InputOutcome::Changed
}
PickerOutcome::NonSelectableClick(_)
@ -2403,7 +2371,7 @@ mod session_picker_delete_tests {
use crate::app::agent_view::test_fixtures::make_agent;
use crate::app::app_view::{InputOutcome, SessionPickerEntry};
use crate::views::modal::ActiveModal;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
fn entry(id: &str) -> SessionPickerEntry {
SessionPickerEntry {
@ -2447,9 +2415,9 @@ mod session_picker_delete_tests {
fn pending(agent: &AgentView) -> Option<String> {
match agent.active_modal.as_ref() {
Some(ActiveModal::SessionPicker { pending_delete, .. }) => pending_delete
.as_ref()
.map(|(_, session_id, _)| session_id.clone()),
Some(ActiveModal::SessionPicker { pending_delete, .. }) => {
pending_delete.as_ref().map(|pd| pd.session_id.clone())
}
_ => None,
}
}
@ -2509,6 +2477,20 @@ mod session_picker_delete_tests {
);
}
#[test]
fn mouse_move_keeps_pending_delete() {
let mut agent = make_agent();
open_picker(&mut agent, vec![entry("s0"), entry("s1")]);
agent.handle_palette_or_arg_input(&key('d'));
agent.handle_palette_or_arg_input(&Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
}));
assert_eq!(pending(&agent).as_deref(), Some("s0"));
}
#[test]
fn y_without_armed_confirmation_does_not_delete() {
let mut agent = make_agent();

View file

@ -44,6 +44,9 @@ pub struct DeferredStartupActions {
pub prompt: Option<String>,
pub open_dashboard: bool,
pub pending_chat: bool,
/// Welcome history local-disk bypass persisted across the startup gate.
#[cfg(feature = "local-workspace")]
pub history_load_as_build: bool,
}
impl DeferredStartupActions {
pub fn is_empty(&self) -> bool {
@ -300,9 +303,321 @@ pub fn chat_mode_flag_conflict(
}
None
}
/// Env: enable local workspace without CLI flags (`1`). Mode defaults to `own`
/// unless `GROK_CHAT_LOCAL_WORKSPACE_MODE` / attach server id is set.
#[cfg(feature = "local-workspace")]
pub const GROK_CHAT_LOCAL_WORKSPACE_ENV: &str = "GROK_CHAT_LOCAL_WORKSPACE";
#[cfg(feature = "local-workspace")]
pub const GROK_CHAT_LOCAL_WORKSPACE_CWD_ENV: &str = "GROK_CHAT_LOCAL_WORKSPACE_CWD";
#[cfg(feature = "local-workspace")]
pub const GROK_CHAT_LOCAL_WORKSPACE_MODE_ENV: &str = "GROK_CHAT_LOCAL_WORKSPACE_MODE";
#[cfg(feature = "local-workspace")]
pub const GROK_CHAT_LOCAL_WORKSPACE_SERVER_ID_ENV: &str = "GROK_CHAT_LOCAL_WORKSPACE_SERVER_ID";
#[cfg(feature = "local-workspace")]
pub const GROK_CHAT_LOCAL_WORKSPACE_ALLOW_HOME_ENV: &str = "GROK_CHAT_LOCAL_WORKSPACE_ALLOW_HOME";
/// Skip interactive first-run confirm (still prints the banner).
#[cfg(feature = "local-workspace")]
pub const GROK_CHAT_LOCAL_WORKSPACE_ACK_ENV: &str = "GROK_CHAT_LOCAL_WORKSPACE_ACK";
/// Startup banner / first-run copy.
#[cfg(feature = "local-workspace")]
pub const LOCAL_WORKSPACE_BANNER: &str =
"Local workspace runs tools on this machine (FS confined to <cwd>).";
#[cfg(feature = "local-workspace")]
pub const LOCAL_WORKSPACE_ATTACH_NEEDS_SERVER_ID: &str = "local-workspace attach requires --local-workspace-attach=<server_id> \
(or GROK_CHAT_LOCAL_WORKSPACE_SERVER_ID)";
#[cfg(feature = "local-workspace")]
pub const LOCAL_WORKSPACE_REQUIRES_CHAT: &str = "local-workspace flags/env require --chat";
#[cfg(feature = "local-workspace")]
pub const LOCAL_WORKSPACE_HOME_DENIED: &str =
"local-workspace cwd may not be / or $HOME unless GROK_CHAT_LOCAL_WORKSPACE_ALLOW_HOME=1";
#[cfg(feature = "local-workspace")]
pub const LOCAL_WORKSPACE_HITL_HINT: &str = "Permission prompts for local workspace tools apply to your machine. \
Local workspace replaces the chat sandbox.";
#[cfg(feature = "local-workspace")]
pub const LOCAL_WORKSPACE_ACK_REQUIRED: &str =
"local-workspace requires interactive confirm, GROK_CHAT_LOCAL_WORKSPACE_ACK=1, or an ack file";
/// Declared advertised tool ids for attach FS-only check (comma-separated).
#[cfg(feature = "local-workspace")]
pub const GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS_ENV: &str =
"GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS";
#[cfg(feature = "local-workspace")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalWorkspaceMode {
Own,
Attach,
}
#[cfg(feature = "local-workspace")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalWorkspaceConfig {
pub mode: LocalWorkspaceMode,
pub cwd: Option<std::path::PathBuf>,
pub server_id: Option<String>,
}
#[cfg(feature = "local-workspace")]
static ACTIVE_LOCAL_WORKSPACE: std::sync::Mutex<Option<LocalWorkspaceConfig>> =
std::sync::Mutex::new(None);
#[cfg(feature = "local-workspace")]
pub fn set_active_local_workspace(cfg: Option<LocalWorkspaceConfig>) -> anyhow::Result<()> {
let mut guard = ACTIVE_LOCAL_WORKSPACE.lock().map_err(|_| {
anyhow::anyhow!("local-workspace intent mutex poisoned; refuse attach (fail closed)")
})?;
tracing::info!(
target: crate::views::welcome::workspace_mode::WORKSPACE_MODE_LOG,
event = if cfg.is_some() {
"process_stamp_set"
} else {
"process_stamp_cleared"
},
mode = cfg.as_ref().map(|c| format!("{:?}", c.mode)),
server_id = cfg.as_ref().and_then(|c| c.server_id.as_deref()),
cwd = cfg.as_ref().and_then(|c| c.cwd.as_ref().map(|p| p.display().to_string())),
"local-workspace process-wide intent stamp"
);
*guard = cfg;
Ok(())
}
#[cfg(feature = "local-workspace")]
pub fn active_local_workspace() -> anyhow::Result<Option<LocalWorkspaceConfig>> {
ACTIVE_LOCAL_WORKSPACE
.lock()
.map(|g| g.clone())
.map_err(|_| {
anyhow::anyhow!("local-workspace intent mutex poisoned; refuse attach (fail closed)")
})
}
#[cfg(not(feature = "local-workspace"))]
pub fn active_local_workspace() -> anyhow::Result<Option<()>> {
Ok(None)
}
#[cfg(feature = "local-workspace")]
fn env_truthy(name: &str) -> bool {
std::env::var(name)
.ok()
.is_some_and(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
}
#[cfg(feature = "local-workspace")]
fn env_nonempty(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// Resolve CLI > env local-workspace intent (own or attach).
///
/// Returns `Ok(None)` when local workspace is not requested.
#[cfg(feature = "local-workspace")]
pub fn resolve_local_workspace_config(
chat: bool,
cli_own: Option<Option<&std::path::Path>>,
cli_attach: Option<&str>,
cli_cwd: Option<&std::path::Path>,
) -> anyhow::Result<Option<LocalWorkspaceConfig>> {
let env_enable = env_truthy(GROK_CHAT_LOCAL_WORKSPACE_ENV);
let env_mode = env_nonempty(GROK_CHAT_LOCAL_WORKSPACE_MODE_ENV);
let env_server_id = env_nonempty(GROK_CHAT_LOCAL_WORKSPACE_SERVER_ID_ENV);
let env_cwd = env_nonempty(GROK_CHAT_LOCAL_WORKSPACE_CWD_ENV).map(std::path::PathBuf::from);
let cli_attach = cli_attach.map(str::trim).filter(|s| !s.is_empty());
let cli_requested = cli_own.is_some() || cli_attach.is_some();
let env_requested = env_enable || env_mode.is_some() || env_server_id.is_some();
if !cli_requested && !env_requested {
return Ok(None);
}
if !chat {
anyhow::bail!("{LOCAL_WORKSPACE_REQUIRES_CHAT}");
}
let mode = if cli_attach.is_some() {
LocalWorkspaceMode::Attach
} else if cli_own.is_some() {
LocalWorkspaceMode::Own
} else if let Some(ref m) = env_mode {
match m.as_str() {
"attach" => LocalWorkspaceMode::Attach,
"own" => LocalWorkspaceMode::Own,
other => {
anyhow::bail!(
"invalid {GROK_CHAT_LOCAL_WORKSPACE_MODE_ENV}={other:?}; expected own|attach"
)
}
}
} else if env_server_id.is_some() {
LocalWorkspaceMode::Attach
} else {
LocalWorkspaceMode::Own
};
let cwd = cli_cwd
.map(std::path::Path::to_path_buf)
.or_else(|| cli_own.and_then(|inner| inner.map(std::path::Path::to_path_buf)))
.or(env_cwd)
.unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
});
let cwd = if cwd.is_absolute() {
cwd
} else {
std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."))
.join(cwd)
};
let cwd = validate_local_workspace_cwd(&cwd)?;
match mode {
LocalWorkspaceMode::Own => Ok(Some(LocalWorkspaceConfig {
mode,
cwd: Some(cwd),
server_id: None,
})),
LocalWorkspaceMode::Attach => {
let server_id = cli_attach
.map(str::to_owned)
.or(env_server_id)
.filter(|s| !s.is_empty());
let Some(server_id) = server_id else {
anyhow::bail!("{LOCAL_WORKSPACE_ATTACH_NEEDS_SERVER_ID}");
};
ensure_attach_fs_only_toolset(&server_id)?;
Ok(Some(LocalWorkspaceConfig {
mode,
cwd: Some(cwd),
server_id: Some(server_id),
}))
}
}
}
/// Canonicalize `path` and enforce the `/` + `$HOME` denylist.
///
/// Returns the canonical directory so callers stamp/persist what was actually
/// checked (symlinks / `..` must not diverge from validation).
#[cfg(feature = "local-workspace")]
pub fn validate_local_workspace_cwd(path: &std::path::Path) -> anyhow::Result<std::path::PathBuf> {
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."))
.join(path)
};
let canon = abs.canonicalize().map_err(|e| {
anyhow::anyhow!(
"local workspace cwd must exist and be canonicalizable: {}: {e}",
abs.display()
)
})?;
if !canon.is_dir() {
anyhow::bail!(
"local workspace cwd must be an existing directory: {}",
canon.display()
);
}
if env_truthy(GROK_CHAT_LOCAL_WORKSPACE_ALLOW_HOME_ENV) {
return Ok(canon);
}
if canon == std::path::Path::new("/") {
anyhow::bail!("{LOCAL_WORKSPACE_HOME_DENIED}");
}
if let Some(home_path) = dirs::home_dir().or_else(|| std::env::var_os("HOME").map(Into::into)) {
let home_canon = home_path.canonicalize().unwrap_or(home_path);
if canon == home_canon {
anyhow::bail!("{LOCAL_WORKSPACE_HOME_DENIED}");
}
}
Ok(canon)
}
/// Banner + first-run confirm for local-workspace own/attach.
///
/// Skip confirm only with `GROK_CHAT_LOCAL_WORKSPACE_ACK=1` or a prior ack file.
/// Non-TTY without ACK refuses (fail closed).
#[cfg(feature = "local-workspace")]
pub fn emit_local_workspace_startup_ux(cfg: &LocalWorkspaceConfig) -> anyhow::Result<()> {
use std::io::IsTerminal;
emit_local_workspace_startup_ux_with(cfg, std::io::stdin().is_terminal())
}
/// Testable UX gate: `stdin_is_terminal` is injected.
#[cfg(feature = "local-workspace")]
pub fn emit_local_workspace_startup_ux_with(
cfg: &LocalWorkspaceConfig,
stdin_is_terminal: bool,
) -> anyhow::Result<()> {
let cwd_display = cfg
.cwd
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<session cwd>".to_string());
let banner = LOCAL_WORKSPACE_BANNER.replace("<cwd>", &cwd_display);
eprintln!("{banner}");
eprintln!("{LOCAL_WORKSPACE_HITL_HINT}");
if local_workspace_ack_satisfied() {
return Ok(());
}
if !stdin_is_terminal {
anyhow::bail!("{LOCAL_WORKSPACE_ACK_REQUIRED}");
}
eprint!("Continue with local workspace on this machine? [y/N] ");
use std::io::Write;
let _ = std::io::stderr().flush();
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let ok = matches!(line.trim(), "y" | "Y" | "yes" | "YES");
if !ok {
anyhow::bail!("local workspace cancelled");
}
write_local_workspace_ack();
Ok(())
}
/// True when ACK env or ack file already authorizes local workspace.
#[cfg(feature = "local-workspace")]
pub fn local_workspace_ack_satisfied() -> bool {
if env_truthy(GROK_CHAT_LOCAL_WORKSPACE_ACK_ENV) {
return true;
}
local_workspace_ack_path().is_some_and(|p| p.is_file())
}
/// Persist the first-run local-workspace ACK file (best-effort).
#[cfg(feature = "local-workspace")]
pub fn write_local_workspace_ack() {
if let Some(path) = local_workspace_ack_path() {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, "1\n");
}
}
/// Fail closed unless advertised tools are FS-only.
///
/// Until diag exposes a real tool catalog, attach trusts operator attestation
/// via `GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS` (comma-separated ids).
/// Unset / empty → refuse.
#[cfg(feature = "local-workspace")]
pub fn ensure_attach_fs_only_toolset(_server_id: &str) -> anyhow::Result<()> {
let advertised = probe_advertised_tool_ids();
let refs: Option<Vec<&str>> = advertised
.as_ref()
.map(|ids| ids.iter().map(String::as_str).collect());
crate::app::effects::reject_non_fs_only_advertised_tools(refs.as_deref())
.map_err(|e| anyhow::anyhow!("{e}"))
}
/// Operator-attested advertised tool ids for attach (env only; no fake diag probe).
#[cfg(feature = "local-workspace")]
pub fn probe_advertised_tool_ids() -> Option<Vec<String>> {
let raw = env_nonempty(GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS_ENV)?;
let ids: Vec<String> = raw
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Some(ids)
}
#[cfg(feature = "local-workspace")]
fn local_workspace_ack_path() -> Option<std::path::PathBuf> {
let home = std::env::var("GROK_HOME")
.ok()
.filter(|s| !s.trim().is_empty())
.map(std::path::PathBuf::from)
.or_else(|| {
dirs::home_dir()
.or_else(|| std::env::var_os("HOME").map(Into::into))
.map(|h| h.join(".grok"))
})?;
Some(home.join("local_workspace_ack"))
}
/// Conservative shape check for a chat-mode `--resume <id>` passthrough.
///
/// The id skips disk/GCS resolution and flows to the gateway, but it is also
@ -1333,4 +1648,200 @@ mod tests {
}
}
}
#[cfg(feature = "local-workspace")]
fn advertised_tools_env() -> xai_grok_test_support::EnvGuard {
xai_grok_test_support::EnvGuard::set(
GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS_ENV,
"workspace.fs_list,workspace.fs_read_file,workspace.fs_write_file,workspace.fs_exists,workspace.fs_delete_file,workspace.put_files,workspace.get_files",
)
}
#[cfg(feature = "local-workspace")]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS)]
#[test]
fn resolve_local_workspace_attach_from_cli() {
let _env = advertised_tools_env();
let tmp = tempfile::tempdir().unwrap();
let cfg = resolve_local_workspace_config(true, None, Some("srv-dogfood"), Some(tmp.path()))
.unwrap()
.expect("attach config");
assert_eq!(cfg.mode, LocalWorkspaceMode::Attach);
assert_eq!(cfg.server_id.as_deref(), Some("srv-dogfood"));
let canon = tmp.path().canonicalize().unwrap();
assert_eq!(cfg.cwd.as_deref(), Some(canon.as_path()));
}
#[cfg(feature = "local-workspace")]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS)]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_SERVER_ID)]
#[test]
fn resolve_local_workspace_empty_cli_attach_falls_back_to_env() {
let _env = advertised_tools_env();
let _sid = xai_grok_test_support::EnvGuard::set(
GROK_CHAT_LOCAL_WORKSPACE_SERVER_ID_ENV,
"srv-from-env",
);
let tmp = tempfile::tempdir().unwrap();
let cfg = resolve_local_workspace_config(true, None, Some(""), Some(tmp.path()))
.unwrap()
.expect("empty CLI attach should fall back to env server id");
assert_eq!(cfg.mode, LocalWorkspaceMode::Attach);
assert_eq!(cfg.server_id.as_deref(), Some("srv-from-env"));
}
#[cfg(feature = "local-workspace")]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_CWD)]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE)]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_MODE)]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_SERVER_ID)]
#[test]
fn resolve_local_workspace_cwd_only_is_not_a_request() {
let tmp = tempfile::tempdir().unwrap();
let _cwd = xai_grok_test_support::EnvGuard::set(
GROK_CHAT_LOCAL_WORKSPACE_CWD_ENV,
tmp.path().to_str().unwrap(),
);
let _enable = xai_grok_test_support::EnvGuard::unset(GROK_CHAT_LOCAL_WORKSPACE_ENV);
let _mode = xai_grok_test_support::EnvGuard::unset(GROK_CHAT_LOCAL_WORKSPACE_MODE_ENV);
let _sid = xai_grok_test_support::EnvGuard::unset(GROK_CHAT_LOCAL_WORKSPACE_SERVER_ID_ENV);
let cfg = resolve_local_workspace_config(true, None, None, Some(tmp.path())).unwrap();
assert!(
cfg.is_none(),
"cwd-only CLI/env must not activate local workspace: {cfg:?}"
);
}
#[cfg(feature = "local-workspace")]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS)]
#[test]
fn resolve_local_workspace_own_from_cli() {
let _env = advertised_tools_env();
let tmp = tempfile::tempdir().unwrap();
let cfg = resolve_local_workspace_config(true, Some(Some(tmp.path())), None, None)
.unwrap()
.expect("own config");
assert_eq!(cfg.mode, LocalWorkspaceMode::Own);
assert!(
cfg.server_id.is_none(),
"own leaves server_id to supervisor"
);
let canon = tmp.path().canonicalize().unwrap();
assert_eq!(cfg.cwd.as_deref(), Some(canon.as_path()));
}
#[cfg(feature = "local-workspace")]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS)]
#[test]
fn resolve_local_workspace_own_env_defaults() {
let _env = advertised_tools_env();
let _enable = xai_grok_test_support::EnvGuard::set(GROK_CHAT_LOCAL_WORKSPACE_ENV, "1");
let _mode = xai_grok_test_support::EnvGuard::unset(GROK_CHAT_LOCAL_WORKSPACE_MODE_ENV);
let _sid = xai_grok_test_support::EnvGuard::unset(GROK_CHAT_LOCAL_WORKSPACE_SERVER_ID_ENV);
let cwd = tempfile::tempdir().unwrap();
let _cwd = xai_grok_test_support::EnvGuard::set(
GROK_CHAT_LOCAL_WORKSPACE_CWD_ENV,
cwd.path().to_str().unwrap(),
);
let cfg = resolve_local_workspace_config(true, None, None, None)
.unwrap()
.expect("env own");
assert_eq!(cfg.mode, LocalWorkspaceMode::Own);
assert!(cfg.server_id.is_none());
let canon = cwd.path().canonicalize().unwrap();
assert_eq!(cfg.cwd.as_deref(), Some(canon.as_path()));
}
#[cfg(feature = "local-workspace")]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS)]
#[test]
fn resolve_local_workspace_requires_chat() {
let _env = advertised_tools_env();
let err = resolve_local_workspace_config(false, None, Some("srv"), None).unwrap_err();
assert!(
err.to_string().contains("require --chat"),
"unexpected: {err}"
);
}
#[cfg(feature = "local-workspace")]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS)]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ALLOW_HOME)]
#[serial_test::serial(HOME)]
#[serial_test::serial(USERPROFILE)]
#[test]
fn resolve_local_workspace_defaults_cwd_and_denies_home() {
let _tools = xai_grok_test_support::EnvGuard::set(
GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS_ENV,
"workspace.fs_list",
);
let _allow =
xai_grok_test_support::EnvGuard::unset(GROK_CHAT_LOCAL_WORKSPACE_ALLOW_HOME_ENV);
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_str().unwrap();
let _home = xai_grok_test_support::EnvGuard::set("HOME", home_str);
let _userprofile = xai_grok_test_support::EnvGuard::set("USERPROFILE", home_str);
let err =
resolve_local_workspace_config(true, None, Some("srv"), Some(home.path())).unwrap_err();
assert!(err.to_string().contains("ALLOW_HOME"), "unexpected: {err}");
}
#[cfg(feature = "local-workspace")]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS)]
#[test]
fn resolve_local_workspace_refuses_uncheckable_toolset() {
let _tools =
xai_grok_test_support::EnvGuard::unset(GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS_ENV);
let tmp = tempfile::tempdir().unwrap();
let err =
resolve_local_workspace_config(true, None, Some("srv"), Some(tmp.path())).unwrap_err();
assert!(
err.to_string().contains("uncheckable") || err.to_string().contains("FS-only"),
"unexpected: {err}"
);
}
#[cfg(feature = "local-workspace")]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS)]
#[test]
fn resolve_local_workspace_refuses_non_fs_toolset() {
let _tools = xai_grok_test_support::EnvGuard::set(
GROK_CHAT_LOCAL_WORKSPACE_ADVERTISED_TOOLS_ENV,
"workspace.fs_list,workspace.bash",
);
let tmp = tempfile::tempdir().unwrap();
let err =
resolve_local_workspace_config(true, None, Some("srv"), Some(tmp.path())).unwrap_err();
assert!(err.to_string().contains("FS-only"), "unexpected: {err}");
assert!(
err.to_string().contains("workspace.bash"),
"unexpected: {err}"
);
}
#[cfg(feature = "local-workspace")]
#[test]
fn local_workspace_banner_mentions_local_machine() {
assert!(LOCAL_WORKSPACE_BANNER.contains("on this machine"));
assert!(LOCAL_WORKSPACE_HITL_HINT.contains("your machine"));
assert!(LOCAL_WORKSPACE_HITL_HINT.contains("replaces the chat sandbox"));
}
#[cfg(feature = "local-workspace")]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ACK)]
#[serial_test::serial(GROK_HOME)]
#[test]
fn local_workspace_non_tty_requires_ack() {
let _ack = xai_grok_test_support::EnvGuard::unset(GROK_CHAT_LOCAL_WORKSPACE_ACK_ENV);
let home = tempfile::tempdir().unwrap();
let _home =
xai_grok_test_support::EnvGuard::set("GROK_HOME", home.path().to_str().unwrap());
let cfg = LocalWorkspaceConfig {
mode: LocalWorkspaceMode::Attach,
cwd: Some(std::path::PathBuf::from("/tmp/repo")),
server_id: Some("srv".into()),
};
let err = emit_local_workspace_startup_ux_with(&cfg, false).unwrap_err();
assert!(
err.to_string().contains("ACK") || err.to_string().contains("ack"),
"unexpected: {err}"
);
}
#[cfg(feature = "local-workspace")]
#[serial_test::serial(GROK_CHAT_LOCAL_WORKSPACE_ALLOW_HOME)]
#[test]
fn validate_local_workspace_cwd_denies_root() {
let _allow =
xai_grok_test_support::EnvGuard::unset(GROK_CHAT_LOCAL_WORKSPACE_ALLOW_HOME_ENV);
let err = validate_local_workspace_cwd(std::path::Path::new("/")).unwrap_err();
assert!(err.to_string().contains("ALLOW_HOME"), "{err}");
}
}

View file

@ -12,7 +12,7 @@ use super::event_loop::{TimedInputEvent, is_bare_esc_press};
const XT_ARM_WINDOW: Duration = Duration::from_secs(5);
/// How long a held partial reply waits for its remaining fragments before
/// being resolved (pi-mono uses 150ms).
/// being resolved (other terminal UI stacks use 150ms).
pub(super) const XT_FRAGMENT_TIMEOUT: Duration = Duration::from_millis(150);
/// Total bound on one hold, so a terminal trickling valid payload chars

View file

@ -66,7 +66,8 @@ pub(crate) fn run_wrapped_command(program: &str, args: &[String]) -> Result<i32>
cmd.env("GROK_OSC52_SINK", "1");
cmd.env("LC_GROK_OSC52_SINK", "1");
// Spawn child in the PTY slave.
// Not session-scoped: this is the wrapped process itself.
#[allow(clippy::disallowed_methods)]
let mut child = pair.slave.spawn_command(cmd)?;
// Drop the slave so we get EOF when child exits.
drop(pair.slave);

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