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:
parent
dd04f397b1
commit
a422116582
165 changed files with 15161 additions and 1969 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)?"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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, ®istry);
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(¬ice);
|
||||
} 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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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![];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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(¶ms)
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
®istry,
|
||||
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,
|
||||
®istry,
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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), ®);
|
||||
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),
|
||||
®,
|
||||
);
|
||||
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), ®);
|
||||
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, ®),
|
||||
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, ®),
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(), ®istry, 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(), ®istry, 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], ®istry, 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], ®istry, 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], ®istry, 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], ®istry, 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(), ®istry, 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(), ®istry, 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue