M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,727 @@
|
||||
use super::*;
|
||||
|
||||
/// Route a `ToolCallUpdate` stdout chunk to the central bg task store.
|
||||
///
|
||||
/// Returns `true` if the update was consumed (belongs to a bg task),
|
||||
/// `false` if it should be passed to the normal tracker.
|
||||
pub(super) fn route_bg_task_stdout(
|
||||
tcu: &acp::ToolCallUpdate,
|
||||
session: &mut super::super::agent::AgentSession,
|
||||
) -> bool {
|
||||
let tc_id = tcu.tool_call_id.0.to_string();
|
||||
|
||||
// Check if this tool_call_id maps to a bg task
|
||||
let task_id = match session.bg_tool_call_to_task.get(&tc_id) {
|
||||
Some(tid) => tid.clone(),
|
||||
None => return false,
|
||||
};
|
||||
|
||||
// Extract stdout from the raw_output BashOutput
|
||||
if let Some(ref raw_output) = tcu.fields.raw_output {
|
||||
// The shell sends full cumulative output buffer — just overwrite.
|
||||
// Check for BashOutput type
|
||||
if raw_output.get("type").and_then(|v| v.as_str()) == Some("Bash") {
|
||||
// Try output_for_prompt first (pre-stripped string)
|
||||
let stdout =
|
||||
if let Some(s) = raw_output.get("output_for_prompt").and_then(|v| v.as_str()) {
|
||||
s.to_string()
|
||||
} else if let Some(arr) = raw_output.get("output").and_then(|v| v.as_array()) {
|
||||
// Decode output bytes (Vec<u8> serialized as JSON array)
|
||||
let bytes: Vec<u8> = arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_u64().map(|n| n as u8))
|
||||
.collect();
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
} else {
|
||||
return true; // Consumed but no extractable output
|
||||
};
|
||||
|
||||
// Capture the shell-side `truncated` flag — once true, it stays
|
||||
// true for the rest of the task (the rolling buffer can't
|
||||
// "un-truncate").
|
||||
let chunk_truncated = raw_output
|
||||
.get("truncated")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Don't overwrite with empty stdout (shell clears buffer on completion).
|
||||
// `set_stdout` handles the BG_TASK_MAX_STDOUT trim, flips
|
||||
// `truncated` on TUI-side overflow, and refreshes the cached
|
||||
// `stdout_line_count` in one shot.
|
||||
if !stdout.is_empty()
|
||||
&& let Some(bg_task) = session.bg_tasks.get_mut(&task_id)
|
||||
{
|
||||
bg_task.set_stdout(stdout);
|
||||
if chunk_truncated {
|
||||
bg_task.truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true // Consumed — don't pass to tracker
|
||||
}
|
||||
|
||||
/// Handle `x.ai/task_backgrounded` — a bash command transitioned to background.
|
||||
///
|
||||
/// Creates a `BgTaskState` in the central store and sets up the
|
||||
/// `tool_call_id → task_id` correlation for stdout routing.
|
||||
///
|
||||
/// If the tool already has an Execute block in scrollback (demotion),
|
||||
/// the existing block is replaced in-place with a `BgTask` and the
|
||||
/// entry's running state is cleared. Otherwise a fresh `BgTask` block
|
||||
/// is pushed.
|
||||
pub(super) fn handle_task_backgrounded(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
// Parse the SessionNotification envelope
|
||||
let Ok(session_notif) = serde_json::from_str::<SessionNotification>(notif.params.get()) else {
|
||||
tracing::warn!("Failed to parse x.ai/task_backgrounded");
|
||||
return false;
|
||||
};
|
||||
|
||||
// Extract TaskBackgrounded fields
|
||||
let (tool_call_id, task_id, command, cwd, output_file, monitor_description, notif_description) =
|
||||
match session_notif.update {
|
||||
XaiSessionUpdate::TaskBackgrounded {
|
||||
tool_call_id,
|
||||
task_id,
|
||||
command,
|
||||
cwd,
|
||||
output_file,
|
||||
monitor_description,
|
||||
description,
|
||||
} => (
|
||||
tool_call_id,
|
||||
task_id,
|
||||
command,
|
||||
cwd,
|
||||
output_file,
|
||||
monitor_description,
|
||||
description,
|
||||
),
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// Replayed (`session/load`) restores are historical context, not new
|
||||
// activity: mark them so the tasks pane doesn't auto-open on resume.
|
||||
let meta = NotificationMeta::from_json(session_notif.meta.as_ref().and_then(|v| v.as_object()));
|
||||
let restored_from_replay = meta.is_replay;
|
||||
|
||||
let (matched, is_active, agent) = match resolve_notif_agent(app, &session_notif.session_id) {
|
||||
Some(t) => t,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
tool_call_id = %tool_call_id,
|
||||
task_id = %task_id,
|
||||
restored_from_replay,
|
||||
"Background task started"
|
||||
);
|
||||
|
||||
let child_sid: &str = session_notif.session_id.0.as_ref();
|
||||
let Some((session, scrollback)) = resolve_target_view(agent, matched, child_sid) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check if this is a demotion (foreground→background): the execute block
|
||||
// already exists in scrollback as a pending tool in the tracker.
|
||||
let demotion_eid = session.tracker.pending_tool_entry_id(&tool_call_id);
|
||||
|
||||
// A monitor is identified by the structured `monitor_description` field
|
||||
// (current path). Fallback: reparented monitors (subagent session sharing)
|
||||
// and backends predating that field still bake a "[monitor] <desc>" prefix
|
||||
// into the command — detect it and strip the prefix so those render as a
|
||||
// "Monitor" row instead of a bash-highlighted "[monitor] …" under Tasks.
|
||||
let monitor_prefix = command.strip_prefix("[monitor] ").map(str::to_string);
|
||||
let is_monitor = monitor_description.is_some() || monitor_prefix.is_some();
|
||||
// Always drain the deferred-tool suppression key now that routing is being
|
||||
// set up — even when we end up preferring the wire `description`. This entry
|
||||
// also suppresses late stdout ToolCallUpdates (see tracker), so leaving it
|
||||
// behind would leak per bg task and keep dropping updates for the session.
|
||||
let deferred_description = session
|
||||
.tracker
|
||||
.bg_deferred_tools
|
||||
.remove(&tool_call_id)
|
||||
.flatten();
|
||||
// Prefer monitor label, then notification/tool description, then deferred
|
||||
// raw_input description (late is_background detection). Blank/whitespace
|
||||
// values count as absent so an empty wire `description` can't shadow a real
|
||||
// fallback. On demotion we also fall back to the Execute block's description.
|
||||
let non_blank = |d: Option<String>| d.filter(|s| !s.trim().is_empty());
|
||||
let mut description = non_blank(monitor_description)
|
||||
.or_else(|| non_blank(monitor_prefix))
|
||||
.or_else(|| non_blank(notif_description))
|
||||
.or_else(|| non_blank(deferred_description));
|
||||
|
||||
// Create central bg task state (description may still be filled from the
|
||||
// Execute block on demotion before we insert into the map).
|
||||
let mut bg_task = BgTaskState {
|
||||
task_id: task_id.clone(),
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
command: command.clone(),
|
||||
description: None,
|
||||
cwd,
|
||||
output_file,
|
||||
status: BgTaskStatus::Running,
|
||||
start_time: std::time::SystemTime::now(),
|
||||
end_time: None,
|
||||
exit_code: None,
|
||||
signal: None,
|
||||
stdout: String::new(),
|
||||
stdout_line_count: 0,
|
||||
truncated: false,
|
||||
pending_kill: false,
|
||||
kill_requested_at: None,
|
||||
scrollback_entry_id: None,
|
||||
is_monitor,
|
||||
restored_from_replay,
|
||||
};
|
||||
|
||||
let entry_id = if let Some(eid) = demotion_eid {
|
||||
// Demotion: extract stdout and swap the block in a single mutable borrow.
|
||||
if let Some(entry) = scrollback.get_by_id_mut(eid) {
|
||||
if let RenderBlock::ToolCall(crate::scrollback::blocks::ToolCallBlock::Execute(exec)) =
|
||||
&mut entry.block
|
||||
{
|
||||
// Use `set_stdout` so the cached `stdout_line_count` and the
|
||||
// TUI-side trim/truncated flag invariants stay in sync.
|
||||
bg_task.set_stdout(exec.output.take().unwrap_or_default());
|
||||
if description.as_ref().is_none_or(|d| d.trim().is_empty()) {
|
||||
description = exec.description.take();
|
||||
}
|
||||
}
|
||||
let block = crate::scrollback::blocks::BgTaskBlock::started(&command, &task_id)
|
||||
.with_description(description.clone());
|
||||
entry.block = RenderBlock::BgTask(block);
|
||||
entry.display_mode = crate::scrollback::types::DisplayMode::Collapsed;
|
||||
entry.display_mode_pinned = false;
|
||||
entry.invalidate_cache();
|
||||
scrollback.mark_height_dirty(eid);
|
||||
scrollback.finish_running(eid);
|
||||
session.tracker.remove_pending_tool(&tool_call_id);
|
||||
eid
|
||||
} else {
|
||||
// Entry was removed between the tracker lookup and now (compaction,
|
||||
// clear, etc.). Create a fresh BgTask so the task has UI presence.
|
||||
session.tracker.remove_pending_tool(&tool_call_id);
|
||||
let block = crate::scrollback::blocks::BgTaskBlock::started(&command, &task_id)
|
||||
.with_description(description.clone());
|
||||
let fallback = scrollback.push_block(RenderBlock::BgTask(block));
|
||||
scrollback.set_last_running(true);
|
||||
fallback
|
||||
}
|
||||
} else {
|
||||
let block = crate::scrollback::blocks::BgTaskBlock::started(&command, &task_id)
|
||||
.with_description(description.clone());
|
||||
let eid = scrollback.push_block(RenderBlock::BgTask(block));
|
||||
scrollback.set_last_running(true);
|
||||
eid
|
||||
};
|
||||
|
||||
bg_task.description = description;
|
||||
|
||||
session.bg_tasks.insert(task_id.clone(), bg_task);
|
||||
session
|
||||
.bg_tool_call_to_task
|
||||
.insert(tool_call_id.clone(), task_id.clone());
|
||||
|
||||
if let Some(bg) = session.bg_tasks.get_mut(&task_id) {
|
||||
bg.scrollback_entry_id = Some(entry_id);
|
||||
}
|
||||
|
||||
// Ext notifications reorder vs session updates: work registering after
|
||||
// its awaiting wait must re-evaluate the skipped park. Root only — child
|
||||
// tasks never enter root `bg_tasks`.
|
||||
if !matches!(matched, SessionMatch::Child(_))
|
||||
&& let Some((_, _, agent)) = resolve_notif_agent(app, &session_notif.session_id)
|
||||
{
|
||||
agent.maybe_push_parked_marker();
|
||||
}
|
||||
|
||||
is_active
|
||||
}
|
||||
|
||||
/// Handle `x.ai/monitor_event` — background task or monitor emitted new output.
|
||||
pub(super) fn handle_monitor_event(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
let Ok(session_notif) = serde_json::from_str::<SessionNotification>(notif.params.get()) else {
|
||||
return false;
|
||||
};
|
||||
let (task_id, _description, event_text) = match session_notif.update {
|
||||
XaiSessionUpdate::MonitorEvent {
|
||||
task_id,
|
||||
description,
|
||||
event_text,
|
||||
} => (task_id, description, event_text),
|
||||
_ => return false,
|
||||
};
|
||||
let (matched, is_active, agent) = match resolve_notif_agent(app, &session_notif.session_id) {
|
||||
Some(t) => t,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let child_sid: &str = session_notif.session_id.0.as_ref();
|
||||
let session = if matches!(matched, SessionMatch::Child(_)) {
|
||||
match agent.subagent_views.get_mut(child_sid) {
|
||||
Some(child_view) => &mut child_view.session,
|
||||
None => return false,
|
||||
}
|
||||
} else {
|
||||
&mut agent.session
|
||||
};
|
||||
|
||||
// Append the event text to the bg task's stdout buffer so the
|
||||
// block viewer shows it (same as bash output chunks for bg tasks).
|
||||
// `append_stdout` handles the trim, flips `truncated` on overflow,
|
||||
// and refreshes `stdout_line_count`.
|
||||
if let Some(task) = session.bg_tasks.get_mut(&task_id) {
|
||||
task.append_stdout(&event_text);
|
||||
}
|
||||
|
||||
is_active
|
||||
}
|
||||
|
||||
pub(super) fn handle_scheduled_task_created(
|
||||
notif: &acp::ExtNotification,
|
||||
app: &mut AppView,
|
||||
) -> bool {
|
||||
let Ok(session_notif) = serde_json::from_str::<SessionNotification>(notif.params.get()) else {
|
||||
return false;
|
||||
};
|
||||
let (task_id, prompt, human_schedule, next_fire_at) = match session_notif.update {
|
||||
XaiSessionUpdate::ScheduledTaskCreated {
|
||||
task_id,
|
||||
prompt,
|
||||
human_schedule,
|
||||
next_fire_at,
|
||||
} => (task_id, prompt, human_schedule, next_fire_at),
|
||||
_ => return false,
|
||||
};
|
||||
let matched = match find_session_match(app, &session_notif.session_id) {
|
||||
Some(m) => m,
|
||||
None => return false,
|
||||
};
|
||||
let agent_id = matched.agent_id();
|
||||
let is_active = is_matched_agent_active(app, agent_id);
|
||||
let agent = app
|
||||
.agents
|
||||
.get_mut(&agent_id)
|
||||
.expect("find_session_match returned an existing AgentId");
|
||||
|
||||
// Remove provisional entries (created by /loop for instant UI feedback).
|
||||
agent
|
||||
.session
|
||||
.scheduled_tasks
|
||||
.retain(|k, _| !k.starts_with("provisional-"));
|
||||
|
||||
agent
|
||||
.session
|
||||
.scheduled_tasks
|
||||
.entry(task_id.clone())
|
||||
.or_insert_with(|| crate::app::agent::ScheduledTaskInfo {
|
||||
task_id,
|
||||
prompt,
|
||||
human_schedule,
|
||||
created_at: std::time::Instant::now(),
|
||||
next_fire_at,
|
||||
tag: "loop".into(),
|
||||
});
|
||||
|
||||
is_active
|
||||
}
|
||||
|
||||
pub(super) fn handle_scheduled_task_fired(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
let Ok(session_notif) = serde_json::from_str::<SessionNotification>(notif.params.get()) else {
|
||||
return false;
|
||||
};
|
||||
let (task_id, prompt, human_schedule, next_fire_at) = match session_notif.update {
|
||||
XaiSessionUpdate::ScheduledTaskFired {
|
||||
task_id,
|
||||
prompt,
|
||||
human_schedule,
|
||||
next_fire_at,
|
||||
} => (task_id, prompt, human_schedule, next_fire_at),
|
||||
_ => return false,
|
||||
};
|
||||
let matched = match find_session_match(app, &session_notif.session_id) {
|
||||
Some(m) => m,
|
||||
None => return false,
|
||||
};
|
||||
let agent_id = matched.agent_id();
|
||||
let is_active = is_matched_agent_active(app, agent_id);
|
||||
let agent = app
|
||||
.agents
|
||||
.get_mut(&agent_id)
|
||||
.expect("find_session_match returned an existing AgentId");
|
||||
|
||||
// Self-heal: if the task is unknown (e.g. a regression of the shell-side
|
||||
// re-announce on session restore), insert a fresh entry from the fire
|
||||
// payload so the tasks pane still shows the loop.
|
||||
match agent.session.scheduled_tasks.entry(task_id) {
|
||||
Entry::Occupied(mut e) => {
|
||||
e.get_mut().next_fire_at = next_fire_at;
|
||||
}
|
||||
Entry::Vacant(e) => {
|
||||
// next_fire_at: None marks a missed-one-shot fire from
|
||||
// handle_missed_tasks(); a ScheduledTaskRemoved follows
|
||||
// immediately. Skip the insert to avoid a one-frame flicker.
|
||||
if next_fire_at.is_none() {
|
||||
return is_active;
|
||||
}
|
||||
let task_id = e.key().clone();
|
||||
e.insert(crate::app::agent::ScheduledTaskInfo {
|
||||
task_id,
|
||||
prompt,
|
||||
human_schedule,
|
||||
created_at: std::time::Instant::now(),
|
||||
next_fire_at,
|
||||
tag: "loop".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
is_active
|
||||
}
|
||||
|
||||
pub(super) fn handle_scheduled_task_deleted(
|
||||
notif: &acp::ExtNotification,
|
||||
app: &mut AppView,
|
||||
) -> bool {
|
||||
let Ok(session_notif) = serde_json::from_str::<SessionNotification>(notif.params.get()) else {
|
||||
return false;
|
||||
};
|
||||
let task_id = match session_notif.update {
|
||||
XaiSessionUpdate::ScheduledTaskDeleted { task_id } => task_id,
|
||||
_ => return false,
|
||||
};
|
||||
let matched = match find_session_match(app, &session_notif.session_id) {
|
||||
Some(m) => m,
|
||||
None => return false,
|
||||
};
|
||||
let agent_id = matched.agent_id();
|
||||
let is_active = is_matched_agent_active(app, agent_id);
|
||||
let agent = app
|
||||
.agents
|
||||
.get_mut(&agent_id)
|
||||
.expect("find_session_match returned an existing AgentId");
|
||||
|
||||
agent.session.scheduled_tasks.remove(&task_id);
|
||||
is_active
|
||||
}
|
||||
|
||||
pub(super) fn handle_scheduled_task_inject_prompt(
|
||||
notif: &acp::ExtNotification,
|
||||
app: &mut AppView,
|
||||
) -> bool {
|
||||
let payload: serde_json::Value = match serde_json::from_str(notif.params.get()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Failed to parse x.ai/scheduled_task_inject_prompt");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let Some(session_id) = payload["sessionId"].as_str() else {
|
||||
tracing::warn!("x.ai/scheduled_task_inject_prompt: missing or non-string sessionId");
|
||||
return false;
|
||||
};
|
||||
let Some(prompt) = payload["prompt"].as_str().filter(|s| !s.is_empty()) else {
|
||||
tracing::warn!("x.ai/scheduled_task_inject_prompt: missing or empty prompt");
|
||||
return false;
|
||||
};
|
||||
let task_id = payload["taskId"].as_str().unwrap_or("unknown");
|
||||
let human_schedule = payload["humanSchedule"].as_str().unwrap_or("unknown");
|
||||
tracing::debug!(task_id, human_schedule, "Enqueuing scheduled cron prompt");
|
||||
|
||||
let agent = app.agents.values_mut().find(|a| {
|
||||
a.session
|
||||
.session_id
|
||||
.as_ref()
|
||||
.is_some_and(|sid| sid.0.as_ref() == session_id)
|
||||
});
|
||||
let Some(agent) = agent else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Only the driver injects + runs the scheduled prompt. In leader mode the
|
||||
// `x.ai/scheduled_task_inject_prompt` notification is routed by the leader
|
||||
// to the SINGLE session driver (see `is_scheduled_task_inject_prompt` in
|
||||
// leader/server.rs), so any client that receives it IS the driver and must
|
||||
// enqueue + run it — including a client that attached via `session/load`
|
||||
// (`attached_as_viewer == true`) but is the designated driver. We therefore
|
||||
// do NOT skip on `attached_as_viewer` here: that latched flag wrongly
|
||||
// suppressed cron on an attacher-driver, leaving the loop stuck with no
|
||||
// output. The other clients render the resulting turn from the broadcast
|
||||
// deltas. (The de-dup guards below still prevent a double enqueue.)
|
||||
|
||||
// Skip if this specific task is already running or queued.
|
||||
if agent.cron_task_id.as_deref() == Some(task_id) {
|
||||
tracing::debug!(task_id, "cron prompt skipped: task already running");
|
||||
return true;
|
||||
}
|
||||
let already_queued = agent
|
||||
.session
|
||||
.pending_prompts
|
||||
.iter()
|
||||
.any(|p| p.task_id.as_deref() == Some(task_id));
|
||||
if already_queued {
|
||||
tracing::debug!(task_id, "cron prompt already queued, skipping duplicate");
|
||||
return true;
|
||||
}
|
||||
|
||||
agent.session.enqueue_cron_prompt(
|
||||
prompt.to_string(),
|
||||
task_id.to_string(),
|
||||
human_schedule.to_string(),
|
||||
);
|
||||
let effects = super::super::dispatch::maybe_drain_queue(agent);
|
||||
app.pending_effects.extend(effects);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Derive the effective CWD and worktree flag for a child session.
|
||||
///
|
||||
/// Each field is derived independently: `child_cwd` controls the path,
|
||||
/// `worktree_path` controls the worktree flag. Either can be present
|
||||
/// without the other.
|
||||
pub(super) fn derive_child_cwd(
|
||||
parent_cwd: &std::path::Path,
|
||||
info: Option<&crate::app::subagent::SubagentInfo>,
|
||||
) -> (PathBuf, bool) {
|
||||
let cwd = info
|
||||
.and_then(|i| i.child_cwd.as_deref())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| parent_cwd.to_path_buf());
|
||||
let is_worktree = info.is_some_and(|i| i.worktree_path.is_some());
|
||||
(cwd, is_worktree)
|
||||
}
|
||||
|
||||
/// Updates the cached branch/worktree display on the matching agent so the
|
||||
/// status bar can render without spawning `git` on every frame.
|
||||
pub(super) fn handle_git_head_changed(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
let Ok(params) =
|
||||
serde_json::from_str::<kigi_workspace::session::git::GitHeadChanged>(notif.params.get())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Find the agent by ACP session id (not local AgentId) and update its git display cache
|
||||
if let Some(agent) = app.agents.values_mut().find(|a| {
|
||||
a.session
|
||||
.session_id
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.0.as_ref() == params.session_id.as_str())
|
||||
}) {
|
||||
// Refresh the shared per-cwd git cache so views keyed on this
|
||||
// directory (the header / top bar when it's the process cwd) pick
|
||||
// up the new branch without spawning subprocesses; the agent's own
|
||||
// fields below drive its status bar / dashboard row directly.
|
||||
crate::git_info::update_from_notification(
|
||||
&agent.session.cwd,
|
||||
params.branch.as_deref(),
|
||||
params.main_repo.clone(),
|
||||
);
|
||||
agent.current_branch = params.branch;
|
||||
agent.is_worktree = params.is_worktree;
|
||||
agent.main_repo = params.main_repo;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: check child subagent views.
|
||||
for agent in app.agents.values_mut() {
|
||||
if let Some(child_view) = agent.subagent_views.values_mut().find(|cv| {
|
||||
cv.session
|
||||
.session_id
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.0.as_ref() == params.session_id.as_str())
|
||||
}) {
|
||||
crate::git_info::update_from_notification(
|
||||
&child_view.session.cwd,
|
||||
params.branch.as_deref(),
|
||||
params.main_repo.clone(),
|
||||
);
|
||||
child_view.current_branch = params.branch;
|
||||
child_view.is_worktree = params.is_worktree;
|
||||
child_view.main_repo = params.main_repo;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
// The payload is a SessionNotification wrapping TaskCompleted { task_snapshot }
|
||||
let Ok(session_notif) = serde_json::from_str::<SessionNotification>(notif.params.get()) else {
|
||||
tracing::warn!("Failed to parse x.ai/task_completed");
|
||||
return false;
|
||||
};
|
||||
|
||||
let (task_snapshot, will_wake) = match session_notif.update {
|
||||
XaiSessionUpdate::TaskCompleted {
|
||||
task_snapshot,
|
||||
will_wake,
|
||||
} => (task_snapshot, will_wake),
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
let (matched, is_active, agent) = match resolve_notif_agent(app, &session_notif.session_id) {
|
||||
Some(t) => t,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let task_id = &task_snapshot.task_id;
|
||||
let exit_code = task_snapshot.exit_code;
|
||||
let signal = task_snapshot.signal.clone();
|
||||
|
||||
tracing::info!(
|
||||
task_id = %task_id,
|
||||
exit_code = ?exit_code,
|
||||
signal = ?signal,
|
||||
"Background task completed"
|
||||
);
|
||||
|
||||
// Determine success once, reused for both bg_task status and scrollback block.
|
||||
let success = exit_code == Some(0) || (exit_code.is_none() && signal.is_none());
|
||||
|
||||
// Synthetic completion emitted by the agent's cold-load reconciliation
|
||||
// (`reconcile_stale_background_tasks`): the task's process died with a
|
||||
// previous session lifetime — it did not fail NOW. Finalize pane/state
|
||||
// quietly instead of pushing a fresh red "Task failed" block into the
|
||||
// resumed scrollback (one per dead task — pure noise on every resume).
|
||||
let stale_on_load = signal.as_deref() == Some("session_restart");
|
||||
|
||||
let child_sid: &str = session_notif.session_id.0.as_ref();
|
||||
let Some((session, scrollback)) = resolve_target_view(agent, matched, child_sid) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Compute elapsed duration from the bg task state (if we have it).
|
||||
// Prefer the human description for "Task completed/failed: …" labels
|
||||
// (same as "Task started"), falling back to the raw command only when
|
||||
// no description was supplied.
|
||||
let (command, elapsed, mut description, scrollback_entry_id, was_running) =
|
||||
if let Some(bg_task) = session.bg_tasks.get_mut(task_id) {
|
||||
let was_running = bg_task.status == BgTaskStatus::Running;
|
||||
bg_task.status = if success {
|
||||
BgTaskStatus::Done
|
||||
} else {
|
||||
BgTaskStatus::Failed
|
||||
};
|
||||
bg_task.exit_code = exit_code;
|
||||
bg_task.signal = signal.clone();
|
||||
bg_task.end_time = Some(std::time::SystemTime::now());
|
||||
bg_task.pending_kill = false;
|
||||
bg_task.kill_requested_at = None;
|
||||
(
|
||||
bg_task.command.clone(),
|
||||
bg_task.elapsed(),
|
||||
bg_task.description.clone(),
|
||||
bg_task.scrollback_entry_id,
|
||||
was_running,
|
||||
)
|
||||
} else {
|
||||
// Task we didn't know about — use snapshot data. Prefer
|
||||
// display_command when it differs from the raw command (monitors /
|
||||
// isolation-wrapped shells); treat equal values as non-labels.
|
||||
let command = task_snapshot.command.clone();
|
||||
let elapsed = task_snapshot
|
||||
.end_time
|
||||
.and_then(|end| end.duration_since(task_snapshot.start_time).ok())
|
||||
.unwrap_or_default();
|
||||
let description = task_snapshot.display_command.clone().and_then(|d| {
|
||||
// Strip the baked "[monitor] " prefix so the completed label
|
||||
// matches the "Task started" path (which uses the bare
|
||||
// monitor description), not "[monitor] …".
|
||||
let d = d
|
||||
.strip_prefix("[monitor] ")
|
||||
.map(str::to_string)
|
||||
.unwrap_or(d);
|
||||
let t = d.trim();
|
||||
if t.is_empty() || t == command.trim() {
|
||||
None
|
||||
} else {
|
||||
Some(d)
|
||||
}
|
||||
});
|
||||
// Unknown task: it never counted toward the parked marker's
|
||||
// running total, so its completion is not a countdown edge.
|
||||
(command, elapsed, description, None, false)
|
||||
};
|
||||
|
||||
// Finish the "Task started" scrollback entry (stops bullet animation).
|
||||
// Also sync description onto that block so the historical "Task started"
|
||||
// line shows the label if it was missing at background time.
|
||||
if let Some(entry_id) = scrollback_entry_id {
|
||||
if let Some(entry) = scrollback.get_by_id_mut(entry_id)
|
||||
&& let RenderBlock::BgTask(bg) = &mut entry.block
|
||||
{
|
||||
if description.as_ref().is_none_or(|d| d.trim().is_empty()) {
|
||||
if let Some(d) = bg.description.clone().filter(|d| !d.trim().is_empty()) {
|
||||
description = Some(d);
|
||||
}
|
||||
} else if bg.description.as_ref().is_none_or(|d| d.trim().is_empty()) {
|
||||
bg.description = description.clone();
|
||||
entry.invalidate_cache();
|
||||
}
|
||||
}
|
||||
scrollback.finish_running(entry_id);
|
||||
}
|
||||
|
||||
// Keep central store in sync when we recovered a description from the
|
||||
// scrollback block (or display_command fallback).
|
||||
if let Some(bg_task) = session.bg_tasks.get_mut(task_id)
|
||||
&& bg_task
|
||||
.description
|
||||
.as_ref()
|
||||
.is_none_or(|d| d.trim().is_empty())
|
||||
&& let Some(ref d) = description
|
||||
&& !d.trim().is_empty()
|
||||
{
|
||||
bg_task.description = Some(d.clone());
|
||||
}
|
||||
|
||||
if stale_on_load {
|
||||
// The replayed "Task started" block above is finished (static
|
||||
// bullet); the pane row leaves the running filter via the status
|
||||
// update. No new scrollback block: nothing happened in THIS session.
|
||||
return is_active;
|
||||
}
|
||||
|
||||
// Emit "Task completed/failed" scrollback block — uses description when
|
||||
// present so the label matches "Task started: <desc>" (not raw command).
|
||||
let block = if success {
|
||||
RenderBlock::bg_task_completed(&command, task_id, elapsed)
|
||||
.with_bg_task_description(description)
|
||||
} else {
|
||||
RenderBlock::bg_task_failed(&command, task_id, elapsed, exit_code, signal)
|
||||
.with_bg_task_description(description)
|
||||
};
|
||||
scrollback.push_block(block);
|
||||
|
||||
// Parked countdown: a Running command just finished under the parked
|
||||
// "Worked for … still running" story. Root sessions only: a subagent-local
|
||||
// task never counted toward the root marker's total. Re-borrow the
|
||||
// agent — `resolve_target_view` consumed the earlier `&mut`.
|
||||
if was_running
|
||||
&& !matches!(matched, SessionMatch::Child(_))
|
||||
&& let Some(agent) = app.agents.get_mut(&matched.agent_id())
|
||||
{
|
||||
agent.maybe_push_parked_marker();
|
||||
}
|
||||
|
||||
// Between turns, a root-session completion re-emits the work-only status
|
||||
// line so the story stays chronological (zero left: no line). When a wake
|
||||
// response follows (`will_wake`, stamped by the shell), the wake turn's
|
||||
// end marker carries the fresh counts instead — skip the line. Child
|
||||
// (subagent) tasks route their chip to the child view above and never
|
||||
// count toward the root marker — no root status line for them. Mutually
|
||||
// exclusive with the parked tick above: parked means the turn is still
|
||||
// running, which `maybe_push_work_status`'s busy gate refuses.
|
||||
if !will_wake
|
||||
&& !matches!(matched, SessionMatch::Child(_))
|
||||
&& let Some(agent) = app.agents.get_mut(&matched.agent_id())
|
||||
{
|
||||
agent.maybe_push_work_status();
|
||||
}
|
||||
|
||||
is_active
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use super::*;
|
||||
|
||||
/// Max follow-up chips kept from a single (server-controlled) notification.
|
||||
pub(super) const MAX_FOLLOW_UPS: usize = 6;
|
||||
|
||||
/// Max chars kept per (server-controlled) suggestion label.
|
||||
pub(super) const MAX_FOLLOW_UP_LABEL: usize = 256;
|
||||
|
||||
/// Max `response_id` length accepted; longer ids are rejected (not truncated —
|
||||
/// truncation could collide ids) so an oversized server id can't bloat the
|
||||
/// retained `follow_up_seen` ring.
|
||||
pub(super) const MAX_RESPONSE_ID_LEN: usize = 128;
|
||||
|
||||
/// Deserialize shape of the `x.ai/follow_ups` params emitted by the shell
|
||||
/// translator: `{ response_id, suggestions: [{ label, .. }] }`. The keys are
|
||||
/// prost-derived snake_case — NOT camelCase like most other pager
|
||||
/// notification payloads — so this struct must match snake_case verbatim.
|
||||
/// Every field defaults so a malformed/partial payload degrades to "no
|
||||
/// chips" instead of erroring.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(super) struct FollowUpsParams {
|
||||
#[serde(default)]
|
||||
response_id: String,
|
||||
#[serde(default)]
|
||||
suggestions: Vec<FollowUpSuggestionParam>,
|
||||
/// Turn identity stamped by the shell (the same `promptId` it puts on every
|
||||
/// `session/update`). OPTIONAL — older shells omit it; when present it makes
|
||||
/// viewer-adoption dedup deterministic (see
|
||||
/// [`AgentView::apply_follow_ups_with_prompt`]). camelCase to match the
|
||||
/// `promptId` convention the shell uses on `session/update` meta.
|
||||
#[serde(default, rename = "promptId")]
|
||||
prompt_id: Option<String>,
|
||||
/// Reserved replay marker carrier. Absent in v1 (the shell never sets
|
||||
/// it); honored from day one so future replay producers need no pager
|
||||
/// change. Parsed loosely as a JSON value to read the `"x.ai/replayed"`
|
||||
/// key (a slash-bearing key prost cannot model as a field).
|
||||
#[serde(default, rename = "_meta")]
|
||||
meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// A single `x.ai/follow_ups` suggestion. Only the human-facing `label` is
|
||||
/// consumed; `properties` / `tool_overrides` (also in the wire shape) are
|
||||
/// ignored.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(super) struct FollowUpSuggestionParam {
|
||||
#[serde(default)]
|
||||
label: String,
|
||||
}
|
||||
|
||||
/// Sanitize a server-supplied suggestion label for safe chip rendering and
|
||||
/// submission: drop control + bidi/format characters
|
||||
/// ([`is_unsafe_display_char`](crate::render::line_utils::is_unsafe_display_char)),
|
||||
/// bound the length, and trim surrounding whitespace.
|
||||
pub(super) fn sanitize_suggestion(label: &str) -> String {
|
||||
let cleaned: String = label
|
||||
.chars()
|
||||
.filter(|c| !crate::render::line_utils::is_unsafe_display_char(*c))
|
||||
.take(MAX_FOLLOW_UP_LABEL)
|
||||
.collect();
|
||||
cleaned.trim().to_owned()
|
||||
}
|
||||
|
||||
/// Handle `x.ai/follow_ups` — render follow-up suggestion chips for the
|
||||
/// latest assistant response.
|
||||
///
|
||||
/// Newest-response-wins keying lives in [`AgentView::apply_follow_ups`]. The
|
||||
/// reserved `_meta["x.ai/replayed"] == true` marker suppresses rendering (it
|
||||
/// is absent today and treated as optional). The params carry no session id,
|
||||
/// so chips target the active agent; a background agent's follow-ups would
|
||||
/// mis-route — a forwarding obligation for the shell to add a session id.
|
||||
/// Server-controlled count and label length are bounded and labels sanitized
|
||||
/// at ingestion. Malformed/partial payloads are ignored (no chip, no panic).
|
||||
pub(super) fn handle_follow_ups(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
let Ok(params) = serde_json::from_str::<FollowUpsParams>(notif.params.get()) else {
|
||||
return false;
|
||||
};
|
||||
if params
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("x.ai/replayed"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// The response id is the newest-wins key (and is retained in the seen
|
||||
// ring); drop a notification with a missing or oversized one.
|
||||
if params.response_id.is_empty() || params.response_id.len() > MAX_RESPONSE_ID_LEN {
|
||||
return false;
|
||||
}
|
||||
// Bound count + length at ingestion (a stored cap, not just render-time).
|
||||
let suggestions: Vec<String> = params
|
||||
.suggestions
|
||||
.into_iter()
|
||||
.take(MAX_FOLLOW_UPS)
|
||||
.filter_map(|s| {
|
||||
let label = sanitize_suggestion(&s.label);
|
||||
(!label.is_empty()).then_some(label)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return false;
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return false;
|
||||
};
|
||||
agent.apply_follow_ups_with_prompt(params.response_id, params.prompt_id.as_deref(), suggestions)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
use super::*;
|
||||
|
||||
/// Handle `x.ai/ask_user_question` ext-method.
|
||||
///
|
||||
/// Parses the typed request, creates a `QuestionViewState` with the
|
||||
/// `response_tx` stashed, and opens the question overlay. The pager does
|
||||
/// NOT respond immediately — the response is sent later when the user
|
||||
/// submits, cancels, or is replaced by another question.
|
||||
///
|
||||
/// If a question is already active, the old one is cancelled first
|
||||
/// (`Cancelled` is sent on its stashed `response_tx`).
|
||||
pub(crate) fn handle_ask_user_question(
|
||||
ext: kigi_acp_lib::AcpArgs<acp::ExtRequest>,
|
||||
app: &mut AppView,
|
||||
) -> bool {
|
||||
use crate::views::question_view::QuestionViewState;
|
||||
use kigi_tools::implementations::grok_build::ask_user_question::{
|
||||
AskUserQuestionExtRequest, AskUserQuestionExtResponse,
|
||||
};
|
||||
|
||||
// Parse the typed request from the ext-method params.
|
||||
let ext_req: AskUserQuestionExtRequest = match serde_json::from_str(ext.request.params.get()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to parse AskUserQuestionExtRequest");
|
||||
ext.response_tx
|
||||
.send(Err(acp::Error::new(-32602, format!("Invalid params: {e}"))))
|
||||
.ok();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Route by the request's session id (like `session/update`), so a question
|
||||
// raised by a BACKGROUND session lands on its own view even when the user is
|
||||
// on the dashboard or another session — rather than failing because the
|
||||
// user hasn't entered the session yet.
|
||||
let Some(id) = interaction_target_agent(app, &ext_req.session_id) else {
|
||||
// No local view for this session. Do NOT send an error — that would FAIL
|
||||
// the tool (rendered red). Leave the reverse-request unanswered: the
|
||||
// agent keeps awaiting and the leader replays it when a client attaches
|
||||
// via `session/load`.
|
||||
tracing::info!(
|
||||
session_id = %ext_req.session_id,
|
||||
"ask_user_question for a session with no local view; parked for leader replay-on-attach"
|
||||
);
|
||||
drop(ext.response_tx);
|
||||
return false;
|
||||
};
|
||||
let is_active = is_matched_agent_active(app, id);
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
// `interaction_target_agent` only returns ids that exist; defensive.
|
||||
tracing::warn!("ask_user_question: agent {id:?} not found");
|
||||
drop(ext.response_tx);
|
||||
return false;
|
||||
};
|
||||
|
||||
// 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();
|
||||
tracing::warn!(
|
||||
old_tool_call_id = %old_qv.tool_call_id,
|
||||
new_tool_call_id = %ext_req.tool_call_id,
|
||||
"Replacing active question - cancelling previous"
|
||||
);
|
||||
if let Some(old_tx) = old_qv.response_tx.take() {
|
||||
let cancelled = AskUserQuestionExtResponse::Cancelled;
|
||||
let raw = serde_json::value::to_raw_value(&cancelled)
|
||||
.expect("Cancelled serialization should not fail");
|
||||
old_tx.send(Ok(acp::ExtResponse::new(raw.into()))).ok();
|
||||
}
|
||||
// Restore the old stashed prompt before stashing the new one.
|
||||
agent.prompt.restore(old_qv.stashed_prompt);
|
||||
// Inverse-collision: the displaced question was a
|
||||
// local one (e.g. /fork, /new) -- surface a system-block marker so
|
||||
// the user understands why their modal vanished. The directive
|
||||
// payload (if any) is dropped; the user can re-issue the command
|
||||
// after answering the model's question.
|
||||
if let Some(ref kind) = old_qv.local_kind {
|
||||
use crate::views::question_view::LocalQuestionKind;
|
||||
let cmd = match kind {
|
||||
LocalQuestionKind::Fork { .. } => "/fork",
|
||||
LocalQuestionKind::NewSession => "/new",
|
||||
LocalQuestionKind::CreditLimitUpsell => "credit-limit upsell",
|
||||
LocalQuestionKind::FreeUsageUpsell => "SuperGrok upsell",
|
||||
LocalQuestionKind::AgentTypeMismatch { .. } => "model switch",
|
||||
LocalQuestionKind::ProjectSelect { .. } => "project select",
|
||||
};
|
||||
agent.scrollback.push_block(RenderBlock::system(format!(
|
||||
"{cmd} cancelled by model question"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Stash the current prompt and create the question view.
|
||||
agent.question_view = Some(QuestionViewState::with_response_tx(
|
||||
ext_req.tool_call_id,
|
||||
ext_req.questions,
|
||||
agent.prompt.stash(),
|
||||
Some(ext.response_tx),
|
||||
ext_req.mode,
|
||||
));
|
||||
|
||||
// Clear prompt for question interaction.
|
||||
agent.prompt.set_text("");
|
||||
|
||||
// Stamp the "last activity" anchor so the
|
||||
// dashboard's NeedsInput row reflects "time since this question
|
||||
// arrived" rather than the previous turn's end time.
|
||||
agent.last_active_at = Some(std::time::Instant::now());
|
||||
|
||||
tracing::info!(
|
||||
mode = ?ext_req.mode,
|
||||
question_count = agent.question_view.as_ref().map(|q| q.questions.len()).unwrap_or(0),
|
||||
target_active = is_active,
|
||||
"Opened question view from ext_method"
|
||||
);
|
||||
|
||||
// Only the currently-displayed view needs an immediate redraw; a question
|
||||
// parked on a background agent surfaces via the roster `NeedsInput` delta
|
||||
// and renders when the user switches to that session.
|
||||
is_active
|
||||
}
|
||||
|
||||
/// Handle an `x.ai/exit_plan_mode` ext_method request.
|
||||
///
|
||||
/// Creates a `PlanApprovalViewState` overlay for interactive approval.
|
||||
///
|
||||
/// Follows the `handle_ask_user_question` pattern: parse → guard → cancel old
|
||||
/// → stash prompt → create state → clear prompt → return true.
|
||||
pub(super) fn handle_exit_plan_mode(
|
||||
ext: kigi_acp_lib::AcpArgs<acp::ExtRequest>,
|
||||
app: &mut AppView,
|
||||
) -> bool {
|
||||
use crate::views::plan_approval_view::{ExitPlanModeExtRequest, PlanApprovalViewState};
|
||||
|
||||
// 1. Parse typed request from raw JSON params.
|
||||
let params: ExitPlanModeExtRequest = match serde_json::from_str(ext.request.params.get()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to parse ExitPlanModeExtRequest: {e}");
|
||||
ext.response_tx
|
||||
.send(Err(acp::Error::new(
|
||||
-32602,
|
||||
format!("Invalid exit_plan_mode params: {e}"),
|
||||
)))
|
||||
.ok();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Route by the request's session id (like `session/update`), so a
|
||||
// plan-approval raised by a BACKGROUND session lands on its own view even
|
||||
// when the user isn't currently focused on it — rather than failing.
|
||||
let Some(id) = interaction_target_agent(app, ¶ms.session_id) else {
|
||||
// No local view for this session. Do NOT error (that fails the tool):
|
||||
// leave the reverse-request unanswered and rely on the leader's
|
||||
// replay-on-attach.
|
||||
tracing::info!(
|
||||
session_id = %params.session_id,
|
||||
"exit_plan_mode for a session with no local view; parked for leader replay-on-attach"
|
||||
);
|
||||
drop(ext.response_tx);
|
||||
return false;
|
||||
};
|
||||
let is_active = is_matched_agent_active(app, id);
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
// `interaction_target_agent` only returns ids that exist; defensive.
|
||||
tracing::warn!("exit_plan_mode: agent {id:?} not found");
|
||||
drop(ext.response_tx);
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Some(mut old) = agent.plan_approval_view.take() {
|
||||
tracing::warn!(
|
||||
old_tool_call_id = %old.tool_call_id,
|
||||
new_tool_call_id = %params.tool_call_id,
|
||||
"Replacing active plan approval — dismissing previous"
|
||||
);
|
||||
old.send_stale_cancel();
|
||||
agent.plan_next_comment_id = old.next_comment_id;
|
||||
agent.prompt.restore(old.stashed_prompt);
|
||||
agent.line_viewer = None;
|
||||
}
|
||||
|
||||
// Dismiss competing overlays so plan approval owns the screen.
|
||||
// - active_modal: draw returns before line_viewer (plan never paints);
|
||||
// keys still route to the invisible plan viewer.
|
||||
// - block_viewer: draw returns on line_viewer (plan visible) but
|
||||
// handle_scroll prefers block_viewer, so wheel hits the hidden Edit pane.
|
||||
agent.active_modal = None;
|
||||
agent.block_viewer = None;
|
||||
|
||||
let source = plan_review_source_for_tool(¶ms.tool_call_id, agent);
|
||||
|
||||
// If the user was mid-casual-comment when this new plan-approval
|
||||
// request arrived, restore the pre-comment prompt first so the
|
||||
// upcoming `stash()` captures the user's original text rather
|
||||
// than the in-progress comment draft. Also clears the now-stale
|
||||
// `casual_stashed_prompt` so it doesn't dangle into the next
|
||||
// casual entry.
|
||||
if let Some(stashed) = agent.casual_stashed_prompt.take() {
|
||||
agent.prompt.restore(stashed);
|
||||
}
|
||||
|
||||
let stashed = agent.prompt.stash();
|
||||
let state = PlanApprovalViewState::with_source(params, source, stashed, ext.response_tx);
|
||||
|
||||
agent.plan_comments.clear();
|
||||
agent.plan_next_comment_id = 0;
|
||||
|
||||
if state.source == PlanReviewSource::Inline {
|
||||
agent.latest_inline_plan_content = state.plan_content.clone();
|
||||
} else {
|
||||
agent.latest_inline_plan_content = None;
|
||||
}
|
||||
agent.plan_approval_view = Some(state);
|
||||
agent.prompt.set_text("");
|
||||
|
||||
agent.casual_commenting_range = None;
|
||||
agent.casual_editing_comment_id = None;
|
||||
|
||||
agent.show_plan_preview_if_available();
|
||||
|
||||
if agent.line_viewer.is_some() {
|
||||
if let Some(ref mut viewer) = agent.line_viewer {
|
||||
viewer.plan_mut().feedback_active = true;
|
||||
}
|
||||
} else if let Some(ref mut pav) = agent.plan_approval_view {
|
||||
pav.focus = crate::views::plan_approval_view::PlanApprovalFocus::Prompt;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target_active = is_active,
|
||||
"Opened plan approval view from ext_method"
|
||||
);
|
||||
|
||||
// Background-parked approval renders when the user switches to the session;
|
||||
// only the active view needs an immediate redraw.
|
||||
is_active
|
||||
}
|
||||
|
||||
pub(super) fn plan_review_source_for_tool(
|
||||
tool_call_id: &str,
|
||||
agent: &AgentView,
|
||||
) -> PlanReviewSource {
|
||||
agent
|
||||
.session
|
||||
.tracker
|
||||
.tool_title(tool_call_id)
|
||||
.filter(|title| *title == "CreatePlan" || *title == "Plan: Submit for approval")
|
||||
.map_or(PlanReviewSource::FileBacked, |_| PlanReviewSource::Inline)
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
use super::*;
|
||||
|
||||
/// Cached `mcp.push_server_status` flag resolution.
|
||||
///
|
||||
/// Resolution mirrors the `mcp.liveness_watchers` flag pattern but
|
||||
/// only stacks the env+default layers — the pager process does not load
|
||||
/// `config.toml` / `requirements.toml`. The first call performs one
|
||||
/// `BoolFlag::env` read; every subsequent call is a pure
|
||||
/// `OnceLock::get` (single atomic load). Default `true`; set
|
||||
/// `KIGI_MCP_PUSH_SERVER_STATUS=0` to disable.
|
||||
pub(super) fn push_server_status_enabled() -> bool {
|
||||
use std::sync::OnceLock;
|
||||
static ENABLED: OnceLock<bool> = OnceLock::new();
|
||||
*ENABLED.get_or_init(|| {
|
||||
kigi_shell::util::config::resolve_mcp_push_server_status(
|
||||
/* requirements */ None, /* user */ None, /* managed */ None,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_mcp_init_progress(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Payload {
|
||||
total: u32,
|
||||
connected: u32,
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
}
|
||||
let Ok(payload) = serde_json::from_str::<Payload>(notif.params.get()) else {
|
||||
return false;
|
||||
};
|
||||
let Some((is_active, agent)) = mcp_target_agent(app, payload.session_id.as_deref()) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(ref mut progress) = agent.mcp_init_progress {
|
||||
progress.total = payload.total;
|
||||
progress.connected = payload.connected;
|
||||
} else {
|
||||
agent.mcp_init_progress = Some(super::super::agent_view::McpInitProgress {
|
||||
total: payload.total,
|
||||
connected: payload.connected,
|
||||
started_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
is_active
|
||||
}
|
||||
|
||||
/// Handle `x.ai/mcp/tools_changed` and `x.ai/mcp_initialized`.
|
||||
///
|
||||
/// Routing rules (verified against the four shell emit sites in
|
||||
/// `kigi-shell/src/session/acp_session.rs` — toggle-tool ~L6661,
|
||||
/// `emit_mcp_tools_changed_notifications` ~L8997 and post-handshake
|
||||
/// ~L10156, plus `mcp_initialized` ~L10157):
|
||||
///
|
||||
/// 1. Try `notif.params.sessionId`. All `tools_changed` emit
|
||||
/// sites carry `sessionId` (the typed
|
||||
/// [`kigi_shell::extensions::mcp::McpToolsChanged`] struct), and
|
||||
/// `mcp_initialized` already carried it. So the sessionId branch
|
||||
/// is the primary path for current builds.
|
||||
///
|
||||
/// 2. Older shells / forward-compat (**`tools_changed` only**): a
|
||||
/// payload with no `sessionId` falls back to
|
||||
/// `app.active_view`. Older shells emit `tools_changed` as
|
||||
/// `{serverName, tools}` with no `sessionId`; the fallback keeps
|
||||
/// those in-flight payloads working. The `mcp_initialized`
|
||||
/// variant does NOT need this fallback — its emitter already
|
||||
/// carries `sessionId`, so
|
||||
/// the sessionId branch (step 1) is the only matched-build path
|
||||
/// for `mcp_initialized`.
|
||||
///
|
||||
/// 3. When the owning agent has an open extensions modal, schedules
|
||||
/// a debounced [`Effect::FetchMcpsList`] coalesced **per-agent**
|
||||
/// (see [`agent_has_pending_mcps_fetch`]). A pending fetch
|
||||
/// on agent A does NOT drop a notification for agent B.
|
||||
///
|
||||
/// Always clears `mcp_init_progress` on the `mcp_initialized` variant.
|
||||
pub(super) fn handle_mcp_tools_changed(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
let method = notif.method.as_ref();
|
||||
|
||||
// Both `x.ai/mcp_initialized` and (newer shell)
|
||||
// `x.ai/mcp/tools_changed` carry `sessionId`. Route by it so a
|
||||
// background agent's notification updates *its* state — not
|
||||
// whichever agent is foregrounded. Unknown and subagent (child)
|
||||
// sessions are dropped; a missing sessionId falls back to the
|
||||
// active agent (legacy shells).
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Payload {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
}
|
||||
let session_id = serde_json::from_str::<Payload>(notif.params.get())
|
||||
.ok()
|
||||
.and_then(|p| p.session_id);
|
||||
let target: Option<(bool, AgentId)> = match session_id.as_deref() {
|
||||
Some(sid) => {
|
||||
let sid = acp::SessionId::new(sid);
|
||||
match find_session_match(app, &sid) {
|
||||
// Subagent (child) sessions don't own the top-level MCP
|
||||
// modal / connecting indicator — drop them.
|
||||
Some(SessionMatch::Child(_)) => None,
|
||||
Some(matched) => {
|
||||
let id = matched.agent_id();
|
||||
Some((is_matched_agent_active(app, id), id))
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
None => match app.active_view {
|
||||
ActiveView::Agent(id) => Some((true, id)),
|
||||
_ => None,
|
||||
},
|
||||
};
|
||||
let Some((is_active, id)) = target else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut redraw = false;
|
||||
|
||||
// `mcp_initialized` clears the matched agent's connecting indicator.
|
||||
if method == "x.ai/mcp_initialized"
|
||||
&& let Some(agent) = app.agents.get_mut(&id)
|
||||
&& agent.mcp_init_progress.take().is_some()
|
||||
{
|
||||
redraw |= is_active;
|
||||
}
|
||||
|
||||
// Modal refresh: schedule a debounced refetch for the OWNING agent
|
||||
// (routed by sessionId — was active_view), per-agent coalesced.
|
||||
let modal_open = app
|
||||
.agents
|
||||
.get(&id)
|
||||
.is_some_and(|a| a.extensions_modal.is_some());
|
||||
if modal_open
|
||||
&& !agent_has_pending_mcps_fetch(app, id)
|
||||
&& let Some(session_id) = app
|
||||
.agents
|
||||
.get(&id)
|
||||
.and_then(|a| a.session.session_id.clone())
|
||||
{
|
||||
app.pending_effects.push(Effect::FetchMcpsList {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
cache: true,
|
||||
});
|
||||
redraw |= is_active;
|
||||
}
|
||||
redraw
|
||||
}
|
||||
|
||||
/// Per-agent coalescing test for [`Effect::FetchMcpsList`].
|
||||
/// An earlier approach used `matches!(e, FetchMcpsList { .. })`
|
||||
/// which collapsed across agents — a pending fetch on agent A would
|
||||
/// drop the push for agent B. Now we key on `agent_id` so each
|
||||
/// agent's refetch is independently debounced.
|
||||
pub(super) fn agent_has_pending_mcps_fetch(app: &AppView, agent_id: AgentId) -> bool {
|
||||
app.pending_effects.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
Effect::FetchMcpsList { agent_id: a, .. } if *a == agent_id
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Handle `x.ai/mcp/server_status`.
|
||||
///
|
||||
/// Routes by the notification's `sessionId` via
|
||||
/// [`find_session_match`] — the matched agent's extensions modal is
|
||||
/// patched in-place via [`crate::views::mcps_modal::patch_server_row`]
|
||||
/// using the per-row delta, avoiding the full `mcp/list` round trip
|
||||
/// the legacy `tools_changed` debounced refetch path requires.
|
||||
///
|
||||
/// No-ops when:
|
||||
/// - the `sessionId` does not match any known agent (drop),
|
||||
/// - the matched agent has no extensions modal open (cheap path —
|
||||
/// the next `/mcps` open will pull fresh data anyway),
|
||||
/// - the modal's `mcps_data` is not yet `Loaded` (Loading / Error
|
||||
/// states would produce incoherent patches; the in-flight fetch
|
||||
/// will land a consistent snapshot shortly),
|
||||
/// - the named server is not present in the cached `servers` vec
|
||||
/// ([`patch_server_row`] silently returns).
|
||||
///
|
||||
/// Re-uses the shell's canonical wire types
|
||||
/// ([`kigi_shell::extensions::mcp::McpServerStatusPayload`] +
|
||||
/// [`kigi_shell::extensions::mcp::McpServerStatus`]) instead of
|
||||
/// re-declaring a parallel pager enum. Later variants (e.g.
|
||||
/// `RestartSucceeded` / `RestartFailed`) ride through automatically
|
||||
/// without a pager code change.
|
||||
///
|
||||
/// `status` is **not** `serde(default)`; a malformed
|
||||
/// payload falls into the `tracing::warn!` arm rather than silently
|
||||
/// re-painting the row red.
|
||||
///
|
||||
/// `tools` is decoded loosely as
|
||||
/// `Option<serde_json::Value>` so a future non-array shape doesn't
|
||||
/// drop the entire push — `status` still applies, and `tools` is
|
||||
/// silently skipped (warn-logged) on shape mismatch.
|
||||
///
|
||||
/// Returns `true` (request redraw) only when the row mutation
|
||||
/// happened AND the matched agent is the currently active view.
|
||||
pub(super) fn handle_mcp_server_status(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
use crate::views::extensions_modal::TabDataState;
|
||||
use crate::views::mcps_modal::{McpServerDisplayStatus, McpToolDetail, patch_server_row};
|
||||
use kigi_shell::extensions::mcp::{McpServerStatus, McpServerStatusPayload, McpToolEntry};
|
||||
|
||||
let Ok(payload) = serde_json::from_str::<McpServerStatusPayload>(notif.params.get()) else {
|
||||
tracing::warn!(
|
||||
"Failed to parse x.ai/mcp/server_status: {}",
|
||||
¬if.params.get()
|
||||
[..crate::render::line_utils::floor_char_boundary(notif.params.get(), 100)]
|
||||
);
|
||||
return false;
|
||||
};
|
||||
|
||||
let session_id = acp::SessionId::new(payload.session_id);
|
||||
let Some(matched) = find_session_match(app, &session_id) else {
|
||||
return false;
|
||||
};
|
||||
let id = matched.agent_id();
|
||||
let is_active = is_matched_agent_active(app, id);
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return false;
|
||||
};
|
||||
// Cheap path: modal closed. Drop the push — the next `/mcps`
|
||||
// open will fetch a fresh full list.
|
||||
let Some(modal) = agent.extensions_modal.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
// Cheap path: list still loading / errored. Patching would
|
||||
// produce incoherent state; the in-flight fetch will land
|
||||
// a consistent snapshot momentarily.
|
||||
let TabDataState::Loaded(ref mut servers) = modal.mcps_data else {
|
||||
return false;
|
||||
};
|
||||
let display_status = match payload.status {
|
||||
McpServerStatus::Ready => McpServerDisplayStatus::Ready,
|
||||
McpServerStatus::Initializing => McpServerDisplayStatus::Initializing,
|
||||
McpServerStatus::Unavailable => McpServerDisplayStatus::Unavailable,
|
||||
McpServerStatus::NeedsAuth => McpServerDisplayStatus::NeedsAuth,
|
||||
};
|
||||
// Decode `tools` loosely. Shell types this as
|
||||
// `Option<serde_json::Value>` (always `null` today;
|
||||
// reserved). If the value is present but not an array of
|
||||
// `McpToolEntry`-isomorphic objects we drop ONLY the tools
|
||||
// update and still apply the status — the previous strict
|
||||
// typing would have dropped the whole push on any shape
|
||||
// mismatch.
|
||||
let new_tools = payload.tools.and_then(|raw| {
|
||||
match serde_json::from_value::<Vec<McpToolEntry>>(raw) {
|
||||
Ok(entries) => Some(
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|t| McpToolDetail {
|
||||
name: t.name,
|
||||
display_name: t.display_name,
|
||||
description: t.description,
|
||||
enabled: t.enabled,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
server = %payload.name,
|
||||
error = %e,
|
||||
"x.ai/mcp/server_status: tools field present but not Vec<McpToolEntry>; status still applied"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
let mutated = patch_server_row(servers, &payload.name, display_status, new_tools);
|
||||
mutated && is_active
|
||||
}
|
||||
|
||||
/// Handle `x.ai/mcp/servers_updated`.
|
||||
///
|
||||
/// Emitted by the shell from `MvpAgent` on managed-config resolve and
|
||||
/// on config reload (`crates/codegen/kigi-shell/src/agent/mvp_agent.rs`
|
||||
/// → `notify_servers_updated`). The shell's
|
||||
/// `McpServersUpdated` wire shape (`{ mcpServers: [...] }`) is
|
||||
/// intentionally session-agnostic by design
|
||||
/// An attempt to route by
|
||||
/// `sessionId` therefore always fell back to `app.active_view` and
|
||||
/// re-created the multi-agent bug.
|
||||
///
|
||||
/// Routing now correctly broadcasts: every agent with an open
|
||||
/// extensions modal gets a per-agent debounced [`Effect::FetchMcpsList`].
|
||||
/// Per-agent coalescing keeps a second push from displacing an
|
||||
/// in-flight fetch on the same agent. Agents without an open modal
|
||||
/// drop the push (cheap path).
|
||||
pub(super) fn handle_mcp_servers_updated(_notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
// `_notif` is intentionally unread. The shell's
|
||||
// `McpServersUpdated` payload is `{ mcpServers: [...] }` with no
|
||||
// `sessionId` (the protocol forbids extending it), so there is
|
||||
// nothing in the notification body the broadcast model needs.
|
||||
// Do NOT "fix" this back to per-session routing without
|
||||
// re-reading the rustdoc above.
|
||||
//
|
||||
// Snapshot (agent_id, session_id, modal_open) up front so the
|
||||
// mutable `pending_effects` borrow can proceed without
|
||||
// aliasing `app.agents`.
|
||||
let targets: Vec<(AgentId, acp::SessionId)> = app
|
||||
.agents
|
||||
.iter()
|
||||
.filter_map(|(id, agent)| {
|
||||
if agent.extensions_modal.is_some() {
|
||||
agent.session.session_id.clone().map(|sid| (*id, sid))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if targets.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut redraw = false;
|
||||
for (id, session_id) in targets {
|
||||
if agent_has_pending_mcps_fetch(app, id) {
|
||||
continue;
|
||||
}
|
||||
let is_active = is_matched_agent_active(app, id);
|
||||
app.pending_effects.push(Effect::FetchMcpsList {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
cache: true,
|
||||
});
|
||||
redraw |= is_active;
|
||||
}
|
||||
redraw
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
//! ACP message handling.
|
||||
//!
|
||||
//! Routes incoming [`AcpClientMessage`] notifications to the appropriate
|
||||
//! agent's tracker, queues permission requests for interactive handling,
|
||||
//! and xAI session extension notifications (`x.ai/session_notification` and
|
||||
//! replay-path `x.ai/session/update`).
|
||||
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_acp_lib::AcpClientMessage;
|
||||
|
||||
use super::actions::Effect;
|
||||
use kigi_shell::extensions::notification::{
|
||||
SessionNotification, SessionUpdate as XaiSessionUpdate, is_reauthable_failure,
|
||||
};
|
||||
use kigi_shell::tools::todo::todo_item_from_plan_entry;
|
||||
use kigi_workspace::permission::bash_command_splitting::BashCommandHighlights;
|
||||
|
||||
use crate::acp::meta::NotificationMeta;
|
||||
use crate::acp::tracker::AcpUpdateTracker;
|
||||
use crate::acp::tracker::TurnActivity;
|
||||
use crate::app::agent::{
|
||||
AgentId, AgentSession, AgentState, BgTaskState, BgTaskStatus, GoalDisplayPhase,
|
||||
GoalDisplayState, GoalDisplayStatus,
|
||||
};
|
||||
use crate::notifications::{NotificationEvent, NotificationEventKind};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::SessionEvent;
|
||||
use crate::views::permission_view::{
|
||||
McpScope, McpScopeState, PermissionFocus, PermissionViewState, SubagentInfo,
|
||||
};
|
||||
use crate::views::plan_approval_view::PlanReviewSource;
|
||||
|
||||
use super::agent_view::{AgentView, InputMode};
|
||||
use super::app_view::{ActiveView, AppView};
|
||||
|
||||
mod background;
|
||||
mod follow_ups;
|
||||
mod interactions;
|
||||
mod mcp;
|
||||
mod permissions;
|
||||
mod prompt_origin;
|
||||
mod queue;
|
||||
mod routing;
|
||||
mod session_notification;
|
||||
mod settings;
|
||||
mod subagent_activity;
|
||||
|
||||
#[cfg(test)]
|
||||
use permissions::{MCP_ARGS_MAX_LINE_CHARS, MCP_ARGS_MAX_LINES, mcp_args_lines};
|
||||
use permissions::{apply_recap_block, handle_permission_request, should_drop_late_auto_recap};
|
||||
|
||||
// Hub + child modules (via `use super::*`) need sibling symbols in this scope.
|
||||
use routing::{
|
||||
SessionMatch, find_session_match, interaction_target_agent, is_matched_agent_active,
|
||||
mcp_target_agent, resolve_notif_agent, resolve_target_view,
|
||||
};
|
||||
|
||||
pub(crate) use prompt_origin::{
|
||||
is_server_initiated_prompt, is_wake_prompt, should_adopt_running_prompt,
|
||||
};
|
||||
use prompt_origin::{push_wake_end_marker, viewer_turn_anchor, wake_turn_elapsed};
|
||||
|
||||
pub(crate) use subagent_activity::finalize_killed_subagent;
|
||||
use subagent_activity::{subagent_activity_label, sync_subagent_activity};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use session_notification::apply_session_event_for_test;
|
||||
use session_notification::{
|
||||
advance_reconnect_cursor, confirm_context_used, detect_plan_mode_change,
|
||||
drop_unexpected_replay, handle_session_notification,
|
||||
};
|
||||
|
||||
pub(crate) use queue::PendingRunningAdoption;
|
||||
use queue::{handle_prompt_complete, handle_queue_changed};
|
||||
|
||||
use background::{
|
||||
derive_child_cwd, handle_git_head_changed, handle_monitor_event, handle_scheduled_task_created,
|
||||
handle_scheduled_task_deleted, handle_scheduled_task_fired,
|
||||
handle_scheduled_task_inject_prompt, handle_task_backgrounded, handle_task_completed,
|
||||
route_bg_task_stdout,
|
||||
};
|
||||
use follow_ups::handle_follow_ups;
|
||||
pub(crate) use interactions::handle_ask_user_question;
|
||||
use interactions::handle_exit_plan_mode;
|
||||
use mcp::{
|
||||
handle_mcp_init_progress, handle_mcp_server_status, handle_mcp_servers_updated,
|
||||
handle_mcp_tools_changed, push_server_status_enabled,
|
||||
};
|
||||
use settings::{handle_models_update, handle_sessions_changed, handle_settings_update};
|
||||
|
||||
// Test-only bare-name surface for `tests/*` (`use super::*`).
|
||||
#[cfg(test)]
|
||||
#[allow(unused_imports)]
|
||||
use background::*;
|
||||
#[cfg(test)]
|
||||
#[allow(unused_imports)]
|
||||
use follow_ups::*;
|
||||
#[cfg(test)]
|
||||
#[allow(unused_imports)]
|
||||
use interactions::*;
|
||||
#[cfg(test)]
|
||||
#[allow(unused_imports)]
|
||||
use mcp::*;
|
||||
#[cfg(test)]
|
||||
#[allow(unused_imports)]
|
||||
use prompt_origin::*;
|
||||
#[cfg(test)]
|
||||
#[allow(unused_imports)]
|
||||
use queue::*;
|
||||
#[cfg(test)]
|
||||
#[allow(unused_imports)]
|
||||
use routing::*;
|
||||
#[cfg(test)]
|
||||
#[allow(unused_imports)]
|
||||
use session_notification::*;
|
||||
#[cfg(test)]
|
||||
#[allow(unused_imports)]
|
||||
use settings::*;
|
||||
#[cfg(test)]
|
||||
#[allow(unused_imports)]
|
||||
use subagent_activity::*;
|
||||
|
||||
/// Handle an ACP notification (session update, permission request, etc.).
|
||||
///
|
||||
/// Returns `true` if the active view was visually affected (needs redraw).
|
||||
/// Notifications are routed to the agent whose `session_id` matches, even when
|
||||
/// that agent is not the currently active view -- streaming chunks for a
|
||||
/// background agent must still land in its own scrollback so the user sees
|
||||
/// the full turn after switching back.
|
||||
pub(crate) fn handle(msg: AcpClientMessage, app: &mut AppView) -> bool {
|
||||
match msg {
|
||||
AcpClientMessage::SessionNotification(notif) => {
|
||||
let mut meta = NotificationMeta::from_json(notif.request.meta.as_ref());
|
||||
|
||||
// Wait-state bookkeeping after the agent borrow ends (parked marker).
|
||||
let mut wait_state_agent: Option<AgentId> = None;
|
||||
|
||||
let affected = match find_session_match(app, ¬if.request.session_id) {
|
||||
Some(SessionMatch::Root(id)) => {
|
||||
let is_active = is_matched_agent_active(app, id);
|
||||
wait_state_agent = Some(id);
|
||||
// Read before the agent borrow below.
|
||||
let stashed_adoption_pid = app
|
||||
.pending_running_adoptions
|
||||
.get(&id)
|
||||
.map(|p| p.prompt_id.clone());
|
||||
let agent = app
|
||||
.agents
|
||||
.get_mut(&id)
|
||||
.expect("find_session_match returned an existing AgentId");
|
||||
|
||||
// Live-only dedup: a per-session `eventId` highwater drops
|
||||
// re-delivered live duplicates (leader fan-out, reconnect
|
||||
// re-emit). Replay is EXEMPT — the per-process counter resets
|
||||
// each resume, so persisted history concatenates non-monotonic
|
||||
// 0..N runs; gating it by the highwater would latch a pre-reset
|
||||
// peak and truncate the restored transcript. Replayed
|
||||
// history is authoritative + ordered, so it always renders and
|
||||
// never seeds the highwater.
|
||||
//
|
||||
// Premise: ACP-stream live delivery is in id order —
|
||||
// actor ACP lines (chunks and the plan-mode
|
||||
// `CurrentModeUpdate`s) are stamped at `event_tx` enqueue
|
||||
// time and drained FIFO. The xAI stream is direct-emitted
|
||||
// and keeps a SEPARATE highwater (see the xAI dedup in
|
||||
// `handle_session_notification`). Residual class: ACP
|
||||
// lines that skip `event_tx` — the bridge's bash stdout
|
||||
// (no `event_tx` surface) and the turn-start user echo —
|
||||
// can mint an id after, but deliver before, queued
|
||||
// lower-id lines; with chunk buffering off on pager
|
||||
// sessions that window is one actor drain hop (accepted).
|
||||
let dedup_drop = !meta.is_replay
|
||||
&& meta.event_seq.is_some_and(|seq| {
|
||||
agent.last_applied_event_seq.is_some_and(|last| seq <= last)
|
||||
});
|
||||
if let Some(seq) = meta.event_seq
|
||||
&& !meta.is_replay
|
||||
&& !dedup_drop
|
||||
{
|
||||
agent.last_applied_event_seq = Some(seq);
|
||||
}
|
||||
|
||||
if drop_unexpected_replay(
|
||||
agent,
|
||||
&meta,
|
||||
notif.request.session_id.0.as_ref(),
|
||||
"session/update",
|
||||
) {
|
||||
notif.response_tx.send(Ok(())).ok();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-derive the per-turn viewer flag from prompt-id
|
||||
// ownership BEFORE the adopt/drop gate below.
|
||||
//
|
||||
// `attached_as_viewer` starts true on a `session/load`
|
||||
// attach and is cleared when this client sends its own
|
||||
// prompt — but a client that has driven a turn can later
|
||||
// VIEW a turn ANOTHER client drives (a `/loop` cron, or a
|
||||
// plain prompt typed in a different pane). Left sticky-false,
|
||||
// the gate dropped those deltas and the pane rendered
|
||||
// nothing. A non-synthetic prompt id this client never
|
||||
// originated is another client's turn → view it; one it
|
||||
// originated is its own → drive it (strict gate).
|
||||
//
|
||||
// Server-initiated / auto-wake turns (synthetic prompt ids)
|
||||
// are excluded: they have no client finish path, so they
|
||||
// must not flip the role (see the adopt gate below).
|
||||
//
|
||||
// Only re-derive on a real, non-replay, non-duplicate delta
|
||||
// that does NOT match the active turn.
|
||||
if !dedup_drop
|
||||
&& !meta.is_replay
|
||||
&& let Some(notif_pid) = meta.prompt_id.as_deref()
|
||||
&& agent.session.current_prompt_id.as_deref() != Some(notif_pid)
|
||||
&& !is_server_initiated_prompt(notif_pid)
|
||||
{
|
||||
agent.attached_as_viewer = !agent.is_self_originated_prompt(notif_pid);
|
||||
}
|
||||
|
||||
// Store context usage and turn timing on agent state.
|
||||
//
|
||||
// Gate on `!dedup_drop`: a deduped delta is an
|
||||
// already-applied or stale out-of-order event (its
|
||||
// `eventId` is `<=` the highwater). A fresher event has
|
||||
// already advanced the highwater and set newer `totalTokens`
|
||||
// / `turnStartMs`, so applying the stale values here would
|
||||
// REGRESS them. This is the replay/live-overlap case (leader
|
||||
// fan-out, reconnect, re-emit after the gate): a historical
|
||||
// replay delta carrying a LOWER `totalTokens` arriving after
|
||||
// a live one would otherwise drop the context bar below the
|
||||
// real usage. The dedup already drops the render; the
|
||||
// token/timing state must respect it too.
|
||||
if !dedup_drop {
|
||||
if let Some(tokens) = meta.total_tokens {
|
||||
confirm_context_used(agent, tokens);
|
||||
}
|
||||
if let Some(ts) = meta.turn_start_ms {
|
||||
agent.turn_start_ms = Some(ts);
|
||||
// A wake turn's end marker derives elapsed from its
|
||||
// deltas — non-adopted turns have no other timing
|
||||
// source. `turnStartMs` is constant per turn, so
|
||||
// record once per pid (the equality check also
|
||||
// skips the classifier on the turn's later deltas).
|
||||
if let Some(pid) = meta.prompt_id.as_deref()
|
||||
&& agent.wake_turn_start.as_ref().map(|(p, _)| p.as_str())
|
||||
!= Some(pid)
|
||||
&& is_wake_prompt(pid)
|
||||
{
|
||||
agent.wake_turn_start = Some((pid.to_string(), ts));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track CurrentModeUpdate to refresh settings modals
|
||||
// after the per-agent borrow releases.
|
||||
let mut plan_mode_modal_refresh_needed = false;
|
||||
|
||||
// Extract Plan updates before passing to tracker (tracker skips them).
|
||||
let mutated = if dedup_drop {
|
||||
tracing::debug!(
|
||||
session_id = notif.request.session_id.0.as_ref(),
|
||||
event_seq = meta.event_seq,
|
||||
last_applied = agent.last_applied_event_seq,
|
||||
is_replay = meta.is_replay,
|
||||
"load-race: session/update DROPPED by dedup highwater (event_seq <= last_applied)"
|
||||
);
|
||||
// Already-applied event delivered again — drop it (do not
|
||||
// re-render). Not a mutation, so no redraw.
|
||||
false
|
||||
} else if let acp::SessionUpdate::Plan(plan) = notif.request.update {
|
||||
let items: Vec<_> = plan
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(todo_item_from_plan_entry)
|
||||
.collect();
|
||||
agent.todo.update_todos(items);
|
||||
agent.mark_reload_todo_update();
|
||||
advance_reconnect_cursor(agent, &mut meta);
|
||||
!meta.is_replay && !agent.session.loading_replay
|
||||
} else if let acp::SessionUpdate::ToolCallUpdate(ref tcu) = notif.request.update
|
||||
&& route_bg_task_stdout(tcu, &mut agent.session)
|
||||
{
|
||||
// Stdout chunk for a bg task — routed to central store,
|
||||
// not to the scrollback tracker.
|
||||
advance_reconnect_cursor(agent, &mut meta);
|
||||
!meta.is_replay && !agent.session.loading_replay
|
||||
} else if !meta.is_replay
|
||||
&& let Some(notif_pid) = meta.prompt_id.as_ref()
|
||||
&& agent.session.current_prompt_id.as_ref() != Some(notif_pid)
|
||||
&& !agent.attached_as_viewer
|
||||
&& stashed_adoption_pid.as_deref() == Some(notif_pid.as_str())
|
||||
{
|
||||
// FIFO handoff: the server already promoted this
|
||||
// prompt but its adoption waits on the previous turn's
|
||||
// PromptResponse — buffer for the shim's flush. Not
|
||||
// applied, so the reconnect cursor does not advance.
|
||||
if agent.pending_adoption_updates.len()
|
||||
< super::agent_view::MAX_PENDING_ADOPTION_UPDATES
|
||||
{
|
||||
tracing::debug!(
|
||||
target: "qtrace",
|
||||
pid = std::process::id(),
|
||||
event = "adoption_update_buffered",
|
||||
prompt_id = %notif_pid,
|
||||
"buffering session/update for the stashed pending adoption",
|
||||
);
|
||||
agent.pending_adoption_updates.push((
|
||||
notif_pid.clone(),
|
||||
notif.request.update,
|
||||
meta.clone(),
|
||||
));
|
||||
} else {
|
||||
tracing::debug!(
|
||||
prompt_id = %notif_pid,
|
||||
"pending-adoption buffer full; dropping update (kept prefix)",
|
||||
);
|
||||
}
|
||||
false
|
||||
} else if !meta.is_replay
|
||||
&& let Some(notif_pid) = meta.prompt_id.as_ref()
|
||||
&& agent.session.current_prompt_id.as_ref() != Some(notif_pid)
|
||||
&& !((agent.session.current_prompt_id.is_none()
|
||||
|| agent
|
||||
.session
|
||||
.current_prompt_id
|
||||
.as_deref()
|
||||
.is_some_and(is_server_initiated_prompt))
|
||||
&& is_server_initiated_prompt(notif_pid))
|
||||
&& !agent.attached_as_viewer
|
||||
{
|
||||
tracing::debug!(
|
||||
session_id = notif.request.session_id.0.as_ref(),
|
||||
notif_prompt_id = meta.prompt_id.as_deref(),
|
||||
current_prompt_id = agent.session.current_prompt_id.as_deref(),
|
||||
attached_as_viewer = agent.attached_as_viewer,
|
||||
loading_replay = agent.session.loading_replay,
|
||||
"load-race: session/update DROPPED by promptId-mismatch gate on a non-viewer (stale/rewound-turn guard)"
|
||||
);
|
||||
// The notification's `promptId` does not match the
|
||||
// currently-active prompt. Drop — belongs to a rewound
|
||||
// turn or stale in-flight work.
|
||||
//
|
||||
// EXCEPTION (multi-client / leader mode): a viewer
|
||||
// (`attached_as_viewer`) is watching a session another
|
||||
// client is driving. It has no turn of its own, so a
|
||||
// mismatching `promptId` is NOT stale — it is the
|
||||
// driver's live (or next) turn. Fall through to the
|
||||
// adoption branch below so the delta renders instead of
|
||||
// freezing the viewer at its load snapshot. This is
|
||||
// scoped to viewers so a locally-created driver's
|
||||
// post-rewind stale-chunk drop is preserved (a driver
|
||||
// always has `attached_as_viewer == false`).
|
||||
!agent.session.loading_replay
|
||||
} else {
|
||||
// Adopt a mismatching `promptId` so subsequent chunks for
|
||||
// the same turn match and render — but ONLY for a viewer
|
||||
// watching another client's turn.
|
||||
//
|
||||
// Server-initiated / auto-wake turns (synthetic prompt
|
||||
// ids) are deliberately NOT adopted here: they have no
|
||||
// client finish path (no PromptResponse, no
|
||||
// prompt_complete), so occupying `current_prompt_id`
|
||||
// would strand the turn-status and make later turns'
|
||||
// PromptResponses get discarded. Their content still
|
||||
// renders — the drop gate above passes synthetic deltas
|
||||
// through when `current_prompt_id` is None/synthetic.
|
||||
//
|
||||
// (Cron `scheduler-fired-…` turns ARE client-driven and
|
||||
// have a `prompt_complete` exit; a viewer enters their
|
||||
// running chrome via the `queue/changed` shim adoption
|
||||
// in `handle_queue_changed`, not here.)
|
||||
if let Some(notif_pid) = meta.prompt_id.as_ref()
|
||||
&& agent.session.current_prompt_id.as_ref() != Some(notif_pid)
|
||||
&& agent.attached_as_viewer
|
||||
{
|
||||
// The driver's next turn closes the between-turns
|
||||
// status window on this pane too.
|
||||
agent.end_work_announced = false;
|
||||
agent.session.current_prompt_id = Some(notif_pid.clone());
|
||||
// A viewer adopting another client's new turn: drop
|
||||
// the prior turn's chips but KEEP the seen ring so a
|
||||
// stale prior-turn replay stays rejected. The adopted
|
||||
// turn's own follow_ups (if already applied then
|
||||
// cleared here) still re-render: `apply_follow_ups`
|
||||
// matches their stamped `promptId` to the now-current
|
||||
// `current_prompt_id` set just above.
|
||||
agent.clear_follow_ups();
|
||||
// The adopted turn's follow_ups may have arrived on
|
||||
// the ext channel BEFORE this session/update (separate
|
||||
// channels) and been buffered — render them now that
|
||||
// the turn is current.
|
||||
agent.flush_pending_follow_ups(notif_pid);
|
||||
}
|
||||
// Detect plan mode transitions from tool call completions.
|
||||
plan_mode_modal_refresh_needed |=
|
||||
detect_plan_mode_change(¬if.request.update, agent);
|
||||
|
||||
let had_activity_before = agent.session.tracker.activity().is_some();
|
||||
agent.session.handle_update(
|
||||
notif.request.update,
|
||||
&meta,
|
||||
&mut agent.scrollback,
|
||||
);
|
||||
// Once the server has emitted any activity (chunk, tool,
|
||||
// retry, etc.), the in-flight prompt can no longer be
|
||||
// "rewound" by Ctrl+C. Clear the stash on the transition.
|
||||
if !had_activity_before && agent.session.tracker.activity().is_some() {
|
||||
agent.session.in_flight_prompt = None;
|
||||
|
||||
// Log initial TTFA once per turn (activity flips None→Some each loop).
|
||||
if let Some(started) = agent.turn_started_at
|
||||
&& agent.first_activity_logged_for != Some(started)
|
||||
{
|
||||
agent.first_activity_logged_for = Some(started);
|
||||
let activity_label = agent
|
||||
.session
|
||||
.tracker
|
||||
.activity()
|
||||
.map(|a| a.as_label())
|
||||
.unwrap_or("unknown");
|
||||
let ttfa_ms = started.elapsed().as_millis() as u64;
|
||||
let sid = agent.session.session_id.as_ref().map(|s| s.0.as_ref());
|
||||
crate::unified_log::info(
|
||||
"turn.first_activity",
|
||||
sid,
|
||||
Some(serde_json::json!({
|
||||
"ttfa_ms": ttfa_ms,
|
||||
"activity": activity_label,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Drain pending ACP commands immediately after handle_update.
|
||||
// This is the SINGLE generation bump site — ensures exactly
|
||||
// one bump per AvailableCommandsUpdate received.
|
||||
if let Some(commands) = agent.session.tracker.take_pending_acp_commands() {
|
||||
agent.session.available_commands = commands;
|
||||
agent.session.available_commands_generation += 1;
|
||||
}
|
||||
// Tools list arrives in the same update's `meta` payload.
|
||||
// Stash it on the session so the per-frame sync in
|
||||
// `app_view.rs` can push it through to the slash registry
|
||||
// alongside the command catalog.
|
||||
if let Some(tools) = agent.session.tracker.take_pending_acp_tools() {
|
||||
agent.session.available_tools = Some(tools.into_iter().collect());
|
||||
}
|
||||
for entry_id in agent.session.tracker.take_pending_edit_hl() {
|
||||
agent.submit_edit_highlight(entry_id);
|
||||
}
|
||||
|
||||
// Viewer chrome (leader / multi-client). A viewer has no
|
||||
// turn of its own and never calls start_turn(), so it
|
||||
// would stay `Idle` — hiding the "⠿ Responding…" status
|
||||
// line, the elapsed/token counter, and the Ctrl+c:cancel
|
||||
// / Ctrl+Enter:interject footer hints (all gated on
|
||||
// `AgentState::TurnRunning`). Enter TurnRunning whenever a
|
||||
// turn is in flight (a prompt id is adopted) and we are
|
||||
// not already running.
|
||||
//
|
||||
// This is placed AFTER `handle_update` (not in the adopt
|
||||
// block above) on purpose: the adopt block only fires on
|
||||
// a prompt-id MISMATCH and is suppressed during the
|
||||
// `loading_replay` window. A client that reattaches
|
||||
// MID-turn adopts the running id during its replay window
|
||||
// (TurnRunning suppressed there) and then receives
|
||||
// post-load deltas that MATCH `current_prompt_id` — which
|
||||
// skip the adopt block — so it would never flip to
|
||||
// TurnRunning. Checking here on every applied live viewer
|
||||
// delta closes that gap, independent of whether the load
|
||||
// response conveyed `runningPromptId`, of delta ordering,
|
||||
// and of whether a given delta carries a prompt id.
|
||||
//
|
||||
// Do NOT call start_turn(): it resets the tracker and
|
||||
// arms `expect_user_echo()`, which would corrupt the
|
||||
// driver's live stream. We only flip state + stamp the
|
||||
// elapsed timer (monotonic: only on the Idle→TurnRunning
|
||||
// transition).
|
||||
//
|
||||
// Enter TurnRunning only for an adoptable prompt — see
|
||||
// `should_adopt_running_prompt` (true iff the turn has a
|
||||
// terminal `prompt_complete` exit). This is what lets a
|
||||
// viewer (and the dashboard's locally-tracked row, which
|
||||
// reads live turn state) show a running `/loop` session as
|
||||
// Working without stranding "Responding…" forever on an
|
||||
// exit-less auto-wake / server-initiated turn.
|
||||
if agent.attached_as_viewer
|
||||
&& !meta.is_replay
|
||||
&& !agent.session.loading_replay
|
||||
&& agent
|
||||
.session
|
||||
.current_prompt_id
|
||||
.as_deref()
|
||||
.is_some_and(should_adopt_running_prompt)
|
||||
&& !matches!(agent.session.state, AgentState::TurnRunning)
|
||||
{
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
// Back-date from the authoritative `turnStartMs` so a
|
||||
// viewer's elapsed matches the driver's instead of
|
||||
// starting at the time-to-first-delta.
|
||||
agent.turn_started_at = Some(viewer_turn_anchor(agent.turn_start_ms));
|
||||
}
|
||||
|
||||
advance_reconnect_cursor(agent, &mut meta);
|
||||
|
||||
!meta.is_replay && !agent.session.loading_replay
|
||||
};
|
||||
|
||||
if plan_mode_modal_refresh_needed {
|
||||
crate::app::dispatch::refresh_open_settings_modals(app);
|
||||
}
|
||||
|
||||
// Mutation always happens; redraw only when the matched
|
||||
// agent is the visible one.
|
||||
mutated && is_active
|
||||
}
|
||||
Some(SessionMatch::Child(parent_id)) => {
|
||||
let is_active = is_matched_agent_active(app, parent_id);
|
||||
let parent = app
|
||||
.agents
|
||||
.get_mut(&parent_id)
|
||||
.expect("find_session_match returned an existing AgentId");
|
||||
// Re-derive the &str key to avoid making SessionMatch::Child
|
||||
// carry an owned String (see find_session_match docs).
|
||||
let child_key: &str = notif.request.session_id.0.as_ref();
|
||||
|
||||
let activity_label = {
|
||||
let child_view = parent
|
||||
.subagent_views
|
||||
.get_mut(child_key)
|
||||
.expect("find_session_match returned an existing subagent_views key");
|
||||
if let Some(tokens) = meta.total_tokens {
|
||||
confirm_context_used(child_view, tokens);
|
||||
}
|
||||
if let Some(ts) = meta.turn_start_ms {
|
||||
child_view.turn_start_ms = Some(ts);
|
||||
}
|
||||
child_view.session.handle_update(
|
||||
notif.request.update,
|
||||
&meta,
|
||||
&mut child_view.scrollback,
|
||||
);
|
||||
for entry_id in child_view.session.tracker.take_pending_edit_hl() {
|
||||
child_view.submit_edit_highlight(entry_id);
|
||||
}
|
||||
subagent_activity_label(child_view)
|
||||
};
|
||||
|
||||
sync_subagent_activity(parent, child_key, activity_label);
|
||||
|
||||
is_active
|
||||
}
|
||||
None => {
|
||||
tracing::debug!(
|
||||
session_id = notif.request.session_id.0.as_ref(),
|
||||
agent_count = app.agents.len(),
|
||||
"load-race: session/update DROPPED — no agent matches session_id (view not loaded yet?)"
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
if let Some(aid) = wait_state_agent {
|
||||
// Parked marker (any tab — the update that created the wait state stamps the park time).
|
||||
if let Some(agent) = app.agents.get_mut(&aid) {
|
||||
agent.maybe_push_parked_marker();
|
||||
}
|
||||
}
|
||||
notif.response_tx.send(Ok(())).ok();
|
||||
affected
|
||||
}
|
||||
AcpClientMessage::RequestPermission(perm) => handle_permission_request(perm, app),
|
||||
AcpClientMessage::ExtNotification(ext) => {
|
||||
let affected = handle_ext_notification(&ext.request, app);
|
||||
ext.response_tx.send(Ok(())).ok();
|
||||
affected
|
||||
}
|
||||
AcpClientMessage::ExtMethod(ext) => handle_ext_method(ext, app),
|
||||
AcpClientMessage::WaitForTerminalExit(args) => {
|
||||
args.response_tx
|
||||
.send(Err(crate::acp::wait_for_exit_not_supported("pager")))
|
||||
.ok();
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an xAI extension notification.
|
||||
///
|
||||
/// Dispatches on method string:
|
||||
/// - `x.ai/session_notification` / `x.ai/session/update` → per-agent session updates
|
||||
fn handle_ext_notification(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
match notif.method.as_ref() {
|
||||
"x.ai/session_notification" | "x.ai/session/update" => {
|
||||
handle_session_notification(notif, app)
|
||||
}
|
||||
"x.ai/follow_ups" => handle_follow_ups(notif, app),
|
||||
"x.ai/task_backgrounded" => handle_task_backgrounded(notif, app),
|
||||
"x.ai/task_completed" => handle_task_completed(notif, app),
|
||||
"x.ai/models/update" => handle_models_update(notif, app),
|
||||
"x.ai/settings/update" => handle_settings_update(notif, app),
|
||||
"x.ai/sessions/changed" => handle_sessions_changed(notif, app),
|
||||
"x.ai/queue/changed" => handle_queue_changed(notif, app),
|
||||
// TODO(prompt_complete-deprecation): Legacy removal (gated): durable turn_completed is already consumed via finalize_turn_from_terminal; keep & re-point the lost-RPC reconcile to the durable rail before deleting.
|
||||
"x.ai/session/prompt_complete" => handle_prompt_complete(notif, app),
|
||||
"x.ai/session/interjection" => handle_interjection(notif, app),
|
||||
"x.ai/monitor_event" => handle_monitor_event(notif, app),
|
||||
"x.ai/scheduled_task_created" => handle_scheduled_task_created(notif, app),
|
||||
"x.ai/scheduled_task_fired" => handle_scheduled_task_fired(notif, app),
|
||||
"x.ai/scheduled_task_deleted" => handle_scheduled_task_deleted(notif, app),
|
||||
"x.ai/scheduled_task_inject_prompt" => handle_scheduled_task_inject_prompt(notif, app),
|
||||
"x.ai/git_head_changed" => handle_git_head_changed(notif, app),
|
||||
"x.ai/mcp/init_progress" => handle_mcp_init_progress(notif, app),
|
||||
"x.ai/mcp/tools_changed" | "x.ai/mcp_initialized" => handle_mcp_tools_changed(notif, app),
|
||||
"x.ai/mcp/server_status" if push_server_status_enabled() => {
|
||||
handle_mcp_server_status(notif, app)
|
||||
}
|
||||
"x.ai/mcp/servers_updated" => handle_mcp_servers_updated(notif, app),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `x.ai/session/interjection` — the leader broadcasts this
|
||||
/// sessionId-bearing notification to every attached client when a mid-turn
|
||||
/// interjection is queued (emitted from the session actor's `Interject`
|
||||
/// command handler). Each client renders the interjection as a scrollback
|
||||
/// block.
|
||||
///
|
||||
/// The originating pager renders an optimistic block immediately in
|
||||
/// `dispatch_interject` and records the interjection id in
|
||||
/// `self_interjection_ids`; when its own broadcast echoes back here it is
|
||||
/// deduped (dropped) by that id. Other panes (which never minted the id) render
|
||||
/// the block — fixing the multi-client bug where an interjection typed in one
|
||||
/// pane was invisible in the others. A `null`/absent id (older shell) always
|
||||
/// renders, so legacy shells degrade to "render everywhere" rather than drop.
|
||||
fn handle_interjection(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(notif.params.get()) else {
|
||||
tracing::warn!("Failed to parse x.ai/session/interjection");
|
||||
return false;
|
||||
};
|
||||
let Some(session_id) = parsed.get("sessionId").and_then(|v| v.as_str()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(text) = parsed.get("text").and_then(|v| v.as_str()) else {
|
||||
return false;
|
||||
};
|
||||
let interjection_id = parsed.get("interjectionId").and_then(|v| v.as_str());
|
||||
|
||||
let sid = acp::SessionId::new(session_id.to_string());
|
||||
let Some(SessionMatch::Root(id)) = find_session_match(app, &sid) else {
|
||||
return false;
|
||||
};
|
||||
let is_active = is_matched_agent_active(app, id);
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Dedup our own optimistic echo: if we minted this id we already rendered
|
||||
// the block locally — drop the broadcast copy (and forget the id).
|
||||
if let Some(iid) = interjection_id
|
||||
&& agent.self_interjection_ids.remove(iid)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::interjection_prompt(text));
|
||||
// Interjecting into a parked wait continues the turn below this block —
|
||||
// the withheld "Worked for …" marker must not fire late beneath it
|
||||
// (shared-queue interjects render only via this broadcast, and the shell
|
||||
// emits the queue-emptying `x.ai/queue/changed` right after it).
|
||||
agent.suppress_parked_marker_on_interject();
|
||||
is_active
|
||||
}
|
||||
|
||||
/// Handle an ACP `ext_method` request (blocking request that expects a response).
|
||||
///
|
||||
/// Dispatches on method string. Unknown methods get `method_not_found` error.
|
||||
/// The response sender is stashed (for `ask_user_question`) or replied to
|
||||
/// immediately (for unknown methods).
|
||||
fn handle_ext_method(ext: kigi_acp_lib::AcpArgs<acp::ExtRequest>, app: &mut AppView) -> bool {
|
||||
match ext.request.method.as_ref() {
|
||||
"x.ai/ask_user_question" => handle_ask_user_question(ext, app),
|
||||
"x.ai/exit_plan_mode" => handle_exit_plan_mode(ext, app),
|
||||
unknown => {
|
||||
tracing::warn!("Unknown ext_method: {unknown}");
|
||||
ext.response_tx
|
||||
.send(Err(acp::Error::new(
|
||||
-32601,
|
||||
format!("Method not found: {unknown}"),
|
||||
)))
|
||||
.ok();
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,424 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permission request handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Route a permission request to the agent that owns its `session_id`, queue
|
||||
/// it on that agent's view, and return whether the active view needs a redraw.
|
||||
///
|
||||
/// Permissions are routed by `session_id` so that requests for an inactive
|
||||
/// agent still queue on the owning agent's view. When the user switches back
|
||||
/// to that agent, the queued permission is visible and can be answered.
|
||||
///
|
||||
/// If no agent owns the `session_id` (e.g. session was just cleaned up), the
|
||||
/// request is cancelled rather than left dangling.
|
||||
///
|
||||
/// YOLO mode is honored on the owning agent regardless of which agent is
|
||||
/// currently active, so background turns aren't blocked waiting for an
|
||||
/// always-yes answer the user has already given.
|
||||
pub(super) fn handle_permission_request(
|
||||
perm: kigi_acp_lib::AcpArgs<acp::RequestPermissionRequest>,
|
||||
app: &mut AppView,
|
||||
) -> bool {
|
||||
// 1. Look up the owning agent by session_id (root or subagent view).
|
||||
let matched = match find_session_match(app, &perm.request.session_id) {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
session_id = %perm.request.session_id.0,
|
||||
"Permission request for unknown session_id; cancelling"
|
||||
);
|
||||
cancel_permission(perm);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let owning_agent_id = matched.agent_id();
|
||||
let is_active = is_matched_agent_active(app, owning_agent_id);
|
||||
let Some(agent) = app.agents.get_mut(&owning_agent_id) else {
|
||||
cancel_permission(perm);
|
||||
return false;
|
||||
};
|
||||
|
||||
// 2. YOLO mode: auto-approve immediately on the owning agent so background
|
||||
// turns aren't blocked waiting for the user to switch back.
|
||||
//
|
||||
// If no `AllowOnce` option exists, falls through to
|
||||
// `enqueue_permission` even in YOLO mode (won't pick
|
||||
// `AllowAlways` by default).
|
||||
if agent.session.is_yolo()
|
||||
&& let Some(allow) = perm
|
||||
.request
|
||||
.options
|
||||
.iter()
|
||||
.find(|o| o.kind == acp::PermissionOptionKind::AllowOnce)
|
||||
{
|
||||
let option_id = allow.option_id.clone();
|
||||
perm.response_tx
|
||||
.send(Ok(acp::RequestPermissionResponse::new(
|
||||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
|
||||
option_id,
|
||||
)),
|
||||
)))
|
||||
.ok();
|
||||
return false; // no redraw needed
|
||||
}
|
||||
|
||||
// 3. Fire notification so the user notices the pending approval.
|
||||
// Rate-limit: only fire the bell/popup on the empty→non-empty
|
||||
// transition to avoid stacking notifications during concurrent
|
||||
// permission requests.
|
||||
if !app
|
||||
.notification_service
|
||||
.should_suppress_permission_notification()
|
||||
{
|
||||
app.notification_service.notify(NotificationEvent {
|
||||
kind: NotificationEventKind::ApprovalRequired,
|
||||
title: "Grok".into(),
|
||||
body: NotificationEventKind::ApprovalRequired.as_str().into(),
|
||||
session_id: Some(perm.request.session_id.0.to_string()),
|
||||
});
|
||||
app.notification_service.mark_permission_notified();
|
||||
}
|
||||
|
||||
// 4. Queue on the owning agent's view. Subagent provenance for display
|
||||
// is still resolved via subagent_sessions in enqueue_permission().
|
||||
// Redraw is only needed when the owning agent is currently visible.
|
||||
let needs_redraw = enqueue_permission(perm, agent);
|
||||
needs_redraw && is_active
|
||||
}
|
||||
|
||||
/// Enqueue a permission request on an agent view.
|
||||
///
|
||||
/// Parses bash highlights, builds display content, stashes the prompt on
|
||||
/// queue transition, and pushes the request onto the FIFO queue.
|
||||
fn enqueue_permission(
|
||||
perm: kigi_acp_lib::AcpArgs<acp::RequestPermissionRequest>,
|
||||
agent: &mut AgentView,
|
||||
) -> bool {
|
||||
// 1. Parse bash highlights from request meta (imported from kigi-shell).
|
||||
let bash_highlights: Option<BashCommandHighlights> = perm
|
||||
.request
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| serde_json::from_value(serde_json::Value::Object(meta.clone())).ok());
|
||||
let bash_selection_count = bash_highlights
|
||||
.as_ref()
|
||||
.map(|h| kigi_workspace::permission::default_always_allow_scope(&h.highlighted_words))
|
||||
.unwrap_or(0);
|
||||
|
||||
// 1b. Parse MCP scope state from the `allow-always-mcp` option's meta.
|
||||
// Mutually exclusive with the bash flow at the per-request level —
|
||||
// the same prompt cannot carry both.
|
||||
let mcp_scope = perm
|
||||
.request
|
||||
.options
|
||||
.iter()
|
||||
.find(|o| o.option_id.0.as_ref() == "allow-always-mcp")
|
||||
.and_then(|opt| opt.meta.as_ref())
|
||||
.and_then(|m| {
|
||||
serde_json::from_value::<kigi_workspace::permission::McpToolPermission>(
|
||||
serde_json::Value::Object(m.clone()),
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.map(|perm| McpScopeState {
|
||||
tool_name: perm.tool_name,
|
||||
server_prefix: perm.server_prefix,
|
||||
selected: McpScope::Tool,
|
||||
});
|
||||
|
||||
// 2. Build subagent provenance label.
|
||||
// If session_id differs from the root session, look up subagent info.
|
||||
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());
|
||||
|
||||
// 4. Assign a monotonic ID.
|
||||
let perm_id = agent.next_perm_req_id;
|
||||
agent.next_perm_req_id += 1;
|
||||
|
||||
// 5. Stash prompt on queue transition: empty -> non-empty.
|
||||
// Do NOT stash again if the queue is already non-empty (that would
|
||||
// capture followup text from the current permission as the "original"
|
||||
// prompt, losing the user's real input).
|
||||
if agent.permission_queue.is_empty() && agent.permission_stashed_prompt.is_none() {
|
||||
agent.permission_stashed_prompt = Some(agent.prompt.stash());
|
||||
agent.prompt.set_text("");
|
||||
}
|
||||
|
||||
// 6. Clone options before moving perm into the struct.
|
||||
let options = perm.request.options.clone();
|
||||
|
||||
// 7. Cursor preselection (sticky last-used → configured default → the
|
||||
// enable-always-approve row → index 0). See `permission_cursor`.
|
||||
let active_idx = crate::appearance::permission_cursor::resolve_initial_cursor(&options);
|
||||
|
||||
// 8. Queue the request FIFO (do NOT replace/cancel existing requests).
|
||||
agent.permission_queue.push_back(PermissionViewState {
|
||||
request: perm,
|
||||
id: perm_id,
|
||||
focus: PermissionFocus::Options,
|
||||
options,
|
||||
active_idx,
|
||||
bash_highlights,
|
||||
bash_selection_count,
|
||||
bash_command_raw,
|
||||
mcp_scope,
|
||||
title,
|
||||
description,
|
||||
args_expanded: false,
|
||||
desc_scroll: 0,
|
||||
subagent_label,
|
||||
options_area_height: 0,
|
||||
options_scroll_offset: 0,
|
||||
});
|
||||
|
||||
// Stamp the agent's "last activity" anchor so
|
||||
// the dashboard's age column for `NeedsInput` rows reflects
|
||||
// "time since permission arrived" rather than "time since last
|
||||
// turn ended". The same field powers the dashboard relative-time label.
|
||||
agent.last_active_at = Some(std::time::Instant::now());
|
||||
|
||||
true // needs redraw
|
||||
}
|
||||
|
||||
/// Build a subagent provenance label for display.
|
||||
///
|
||||
/// Two tiers of provenance quality:
|
||||
///
|
||||
/// 1. **Tracked provenance** (`SubagentSpawned` was received): renders as
|
||||
/// `Subagent "Find endpoints" (explore):` with description and type
|
||||
/// from the tracked `SubagentInfo`. This is the trusted path.
|
||||
///
|
||||
/// 2. **Opaque non-root session**: the session_id does not match root and
|
||||
/// is not in the tracked subagent map. Renders as
|
||||
/// `Child session (untracked):` to signal reduced confidence.
|
||||
///
|
||||
/// Returns `None` for root session (no provenance needed).
|
||||
fn resolve_subagent_label(agent: &AgentView, session_id: &acp::SessionId) -> Option<String> {
|
||||
let sid = session_id.0.as_ref();
|
||||
// Check if this is the root session (no provenance needed).
|
||||
if let Some(ref root_sid) = agent.session.session_id
|
||||
&& root_sid.0.as_ref() == sid
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Tier 1: tracked subagent with full metadata.
|
||||
if let Some(info) = agent.subagent_sessions.get(sid) {
|
||||
return Some(format!(
|
||||
"Subagent \"{}\" ({}):",
|
||||
info.description, info.subagent_type
|
||||
));
|
||||
}
|
||||
// Tier 2: non-root session with no tracked info.
|
||||
Some("Child session (untracked):".to_string())
|
||||
}
|
||||
|
||||
/// Build title, description lines, and optional raw command for a permission request.
|
||||
///
|
||||
/// Deserializes `raw_input` into the shared [`BashToolInput`] from
|
||||
/// `kigi-tools` for typed access to `command` and `description`.
|
||||
/// Falls back to ACP-level `title`/`kind` fields when deserialization fails.
|
||||
///
|
||||
/// Returns `(title, description, bash_command_raw)`.
|
||||
fn build_permission_display(
|
||||
req: &acp::RequestPermissionRequest,
|
||||
bash_highlights: Option<&BashCommandHighlights>,
|
||||
) -> (String, Vec<String>, Option<String>) {
|
||||
let is_bash = bash_highlights.is_some();
|
||||
|
||||
let bash_input = req.tool_call.fields.raw_input.as_ref().and_then(|v| {
|
||||
serde_json::from_value::<kigi_tools::implementations::BashToolInput>(v.clone()).ok()
|
||||
});
|
||||
|
||||
let raw_command = bash_input.as_ref().map(|b| b.command.clone()).or_else(|| {
|
||||
req.tool_call
|
||||
.fields
|
||||
.title
|
||||
.as_deref()
|
||||
.and_then(|t| t.strip_prefix("Execute `"))
|
||||
.and_then(|t| t.strip_suffix('`'))
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let bash_description = bash_input.map(|b| b.description);
|
||||
|
||||
let is_execute = is_bash
|
||||
|| req.tool_call.fields.kind == Some(acp::ToolKind::Execute)
|
||||
|| raw_command.is_some();
|
||||
|
||||
let title = if is_execute {
|
||||
bash_description
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(|t| t.to_string())
|
||||
.unwrap_or_else(
|
||||
|| match bash_highlights.and_then(|h| h.highlighted_words.first()) {
|
||||
Some(bin) => format!("Allow `{bin}`?"),
|
||||
None => "Allow Execute?".to_string(),
|
||||
},
|
||||
)
|
||||
} else if is_edit_permission(req) {
|
||||
let file_path = req
|
||||
.tool_call
|
||||
.fields
|
||||
.raw_input
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("file_path"))
|
||||
.and_then(|v| v.as_str());
|
||||
if let Some(path) = file_path {
|
||||
format!("Allow Edit to {}?", path)
|
||||
} else if let Some(ref t) = req.tool_call.fields.title {
|
||||
format!(
|
||||
"Allow {}?",
|
||||
kigi_workspace::permission::mcp_pretty_name_if_qualified(t)
|
||||
)
|
||||
} else {
|
||||
"Allow Edit?".to_string()
|
||||
}
|
||||
} else if let Some(ref t) = req.tool_call.fields.title {
|
||||
format!(
|
||||
"Allow {}?",
|
||||
kigi_workspace::permission::mcp_pretty_name_if_qualified(t)
|
||||
)
|
||||
} else {
|
||||
match req.tool_call.fields.kind {
|
||||
Some(acp::ToolKind::Edit) => "Allow Edit?".to_string(),
|
||||
Some(acp::ToolKind::Execute) => "Allow Execute?".to_string(),
|
||||
Some(acp::ToolKind::Delete) => "Allow Delete?".to_string(),
|
||||
_ => "Allow?".to_string(),
|
||||
}
|
||||
};
|
||||
|
||||
let description = mcp_args_lines(req);
|
||||
let bash_cmd = if is_execute { raw_command } else { None };
|
||||
(title, description, bash_cmd)
|
||||
}
|
||||
|
||||
/// Maximum stored lines for the MCP planned-arguments display. The overlay
|
||||
/// clips further (options always stay visible); this only bounds memory for
|
||||
/// pathologically large inputs.
|
||||
pub(super) const MCP_ARGS_MAX_LINES: usize = 200;
|
||||
|
||||
/// Maximum stored characters per argument line. Bounds the per-frame
|
||||
/// wrap/render cost for pathological single-line values (e.g. an embedded
|
||||
/// base64 blob); anything longer is elided with a marker.
|
||||
pub(super) const MCP_ARGS_MAX_LINE_CHARS: usize = 2000;
|
||||
|
||||
/// Pretty-printed JSON lines of the arguments an MCP tool call plans to
|
||||
/// send, taken from the serialized `ToolInput` the shell puts in
|
||||
/// `tool_call.fields.raw_input` (`{"variant": "UseTool"|"MCPTool",
|
||||
/// "tool_name": …, "tool_input": …}`). The prompt otherwise names the tool
|
||||
/// without what it would do — the payload is what an approval (especially
|
||||
/// an always-approve) is judged on.
|
||||
///
|
||||
/// Empty for non-MCP requests (bash/edit have dedicated displays) and for
|
||||
/// MCP requests without a JSON `tool_input`.
|
||||
pub(super) fn mcp_args_lines(req: &acp::RequestPermissionRequest) -> Vec<String> {
|
||||
let Some(raw) = req.tool_call.fields.raw_input.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Match by serde tag rather than deserializing the whole enum, so args
|
||||
// still display when the shell adds variants this build doesn't know.
|
||||
let is_mcp = matches!(
|
||||
raw.get("variant").and_then(|v| v.as_str()),
|
||||
Some("UseTool") | Some("MCPTool")
|
||||
);
|
||||
if !is_mcp {
|
||||
return Vec::new();
|
||||
}
|
||||
let args = match raw.get("tool_input") {
|
||||
Some(serde_json::Value::Null) | None => return Vec::new(),
|
||||
Some(args) => args,
|
||||
};
|
||||
let pretty = match serde_json::to_string_pretty(args) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let mut lines: Vec<String> = pretty
|
||||
.lines()
|
||||
.map(|l| match l.char_indices().nth(MCP_ARGS_MAX_LINE_CHARS) {
|
||||
Some((byte_idx, _)) => format!("{}…", &l[..byte_idx]),
|
||||
None => l.to_owned(),
|
||||
})
|
||||
.collect();
|
||||
if lines.len() > MCP_ARGS_MAX_LINES {
|
||||
let hidden = lines.len() - MCP_ARGS_MAX_LINES;
|
||||
lines.truncate(MCP_ARGS_MAX_LINES);
|
||||
lines.push(format!("… (+{hidden} more lines)"));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Check if this is an edit permission by looking at option names.
|
||||
///
|
||||
/// The shell's edit options include "allow all edits" in the AllowAlways
|
||||
/// option name. This is reliable even when tool_call.fields.kind is None.
|
||||
fn is_edit_permission(req: &acp::RequestPermissionRequest) -> bool {
|
||||
req.options.iter().any(|o| {
|
||||
o.kind == acp::PermissionOptionKind::AllowAlways && o.name.to_lowercase().contains("edit")
|
||||
})
|
||||
}
|
||||
|
||||
/// Cancel a permission request by sending `Cancelled` on the response channel.
|
||||
fn cancel_permission(perm: kigi_acp_lib::AcpArgs<acp::RequestPermissionRequest>) {
|
||||
perm.response_tx
|
||||
.send(Ok(acp::RequestPermissionResponse::new(
|
||||
acp::RequestPermissionOutcome::Cancelled,
|
||||
)))
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Live auto recap arrived while the agent is busy (turn or command in
|
||||
/// flight) — drop so it cannot land under newer output. Manual `/recap` and
|
||||
/// history replay always apply.
|
||||
pub(super) fn should_drop_late_auto_recap(auto: bool, is_replay: bool, agent_idle: bool) -> bool {
|
||||
auto && !is_replay && !agent_idle
|
||||
}
|
||||
|
||||
/// Land a `SessionRecap` block: fill a manual `/recap`'s in-flight loading
|
||||
/// spinner in place (and stop its animation) when one is showing, otherwise
|
||||
/// append a fresh block. An automatic recap never consumes the manual loading
|
||||
/// slot (`auto`) — it would orphan the in-flight manual response into a second
|
||||
/// block — so it always appends.
|
||||
///
|
||||
/// Minimal (scrollback-native) mode may have already printed the loading
|
||||
/// spinner into the terminal's native scrollback (the idle commit pass consumes
|
||||
/// it print-once). Filling that entry in place would mutate state the terminal
|
||||
/// never re-reads — the recap text would exist only in `/transcript`, never on
|
||||
/// screen. Re-print instead: drop the stale committed entry from state (its
|
||||
/// printed copy can't be un-printed, matching the K10 re-print semantics) and
|
||||
/// append the real recap as a fresh block so the commit pass emits it.
|
||||
/// `is_committed` is always false outside minimal, so the fill-in-place path is
|
||||
/// unchanged for the alt-screen / inline modes.
|
||||
pub(super) fn apply_recap_block(agent: &mut AgentView, auto: bool, recap_block: RenderBlock) {
|
||||
let fill_id = if auto {
|
||||
None
|
||||
} else {
|
||||
agent
|
||||
.pending_recap_entry
|
||||
.take()
|
||||
.filter(|&id| agent.scrollback.get_by_id(id).is_some())
|
||||
};
|
||||
match fill_id {
|
||||
Some(id) if agent.scrollback.is_committed(id) => {
|
||||
agent.scrollback.remove_entry(id);
|
||||
agent.scrollback.push_block(recap_block);
|
||||
}
|
||||
Some(id) => {
|
||||
// Existence just confirmed; scope the `&mut` borrow so it
|
||||
// ends before `finish_running` re-borrows the scrollback.
|
||||
if let Some(entry) = agent.scrollback.get_by_id_mut(id) {
|
||||
entry.block = recap_block;
|
||||
}
|
||||
agent.scrollback.finish_running(id);
|
||||
}
|
||||
None => {
|
||||
agent.scrollback.push_block(recap_block);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use super::*;
|
||||
|
||||
/// Returns true if the prompt_id was generated by the shell for a
|
||||
/// server-initiated auto-wake turn (background task or subagent completion).
|
||||
pub(crate) fn is_server_initiated_prompt(prompt_id: &str) -> bool {
|
||||
kigi_shell::session::PromptOrigin::from_prompt_id(prompt_id).is_synthetic()
|
||||
}
|
||||
|
||||
/// Returns true if the prompt_id is a scheduled-task (`/loop`) fire.
|
||||
///
|
||||
/// Cron turns are synthetic (so [`is_server_initiated_prompt`] is also true for
|
||||
/// them), but UNLIKE auto-wake / subagent-completion turns they are
|
||||
/// CLIENT-driven via `MvpAgent::prompt()` and therefore DO emit a matching
|
||||
/// `x.ai/session/prompt_complete` turn-end signal. A viewer can thus safely
|
||||
/// enter `TurnRunning` for them (the exit exists, so it won't strand) — which is
|
||||
/// what lets the dashboard show a running `/loop` session as Working.
|
||||
pub(crate) fn is_scheduler_fired_prompt(prompt_id: &str) -> bool {
|
||||
matches!(
|
||||
kigi_shell::session::PromptOrigin::from_prompt_id(prompt_id),
|
||||
kigi_shell::session::PromptOrigin::SchedulerFired
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns true for the auto-wake turn families (`task-completed-…`,
|
||||
/// `subagent-completed-…`, `notifications-…`). These run non-adopted — no
|
||||
/// `PromptResponse`, no viewer finalize — so their durable `TurnCompleted` is
|
||||
/// the only signal marking the back-to-idle point, and it pushes the turn-end
|
||||
/// marker directly. Deliberately narrower than "non-adopted synthetic": goal
|
||||
/// turns render through the goal chip/loop chrome and `plan-resume-…` keeps
|
||||
/// its current markerless shape.
|
||||
pub(crate) fn is_wake_prompt(prompt_id: &str) -> bool {
|
||||
matches!(
|
||||
kigi_shell::session::PromptOrigin::from_prompt_id(prompt_id),
|
||||
kigi_shell::session::PromptOrigin::TaskCompleted { .. }
|
||||
| kigi_shell::session::PromptOrigin::SubagentCompleted { .. }
|
||||
| kigi_shell::session::PromptOrigin::NotificationDrain
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a running `prompt_id` is adoptable — i.e. safe to bind as the
|
||||
/// viewer's `current_prompt_id` and show as a live `TurnRunning`. The invariant:
|
||||
/// adoptable iff the turn emits a terminal `x.ai/session/prompt_complete`, the
|
||||
/// only non-interactive way a viewer leaves `TurnRunning`. That holds for
|
||||
/// user-driven turns and `/loop` (`scheduler-fired-…`) fires — both run via
|
||||
/// `MvpAgent::prompt()` — and is false for actor-run synthetic turns
|
||||
/// (task-completed / subagent-completion / notification-drain / goal-*), which
|
||||
/// have no such exit, so adopting one strands the viewer in `TurnRunning`.
|
||||
///
|
||||
/// This is the pure pid-only synthetic-turn guard. It serves the live-delta
|
||||
/// viewer gate and the `queue/changed` turn-start shim (the latter negated, as a
|
||||
/// skip) directly. The two adoption-on-load paths (`SessionLoaded` in
|
||||
/// `dispatch.rs`, the reconnect-reload `finalize_reload_and_maybe_adopt`) instead
|
||||
/// call the agent-aware [`AgentView::should_adopt_running_prompt`], which ANDs
|
||||
/// this guard with "not terminal-in-replay" so an already-ended turn replayed on
|
||||
/// reattach is not re-adopted.
|
||||
///
|
||||
/// [`AgentView::should_adopt_running_prompt`]: crate::app::agent_view::AgentView::should_adopt_running_prompt
|
||||
pub(crate) fn should_adopt_running_prompt(prompt_id: &str) -> bool {
|
||||
!is_server_initiated_prompt(prompt_id) || is_scheduler_fired_prompt(prompt_id)
|
||||
}
|
||||
|
||||
/// Compute the monotonic anchor a VIEWER should use as its turn-start time.
|
||||
///
|
||||
/// A viewer adopts the driver's turn mid-stream, so stamping `Instant::now()`
|
||||
/// would make its elapsed counter — and the final "Worked for X" marker
|
||||
/// (both read off `turn_started_at` via [`AgentView::turn_elapsed`]) — read
|
||||
/// LESS than the driver's by the time-to-first-delta. The shell records the
|
||||
/// authoritative wall-clock turn start in `meta.turnStartMs` (UTC ms, stamped
|
||||
/// at the top of the turn), so back-date the anchor from it; the viewer's
|
||||
/// elapsed then matches the driver's, both live and on completion. Falls back
|
||||
/// to `now` when `turnStartMs` is absent (older shell) or the wall clock is
|
||||
/// skewed forward.
|
||||
pub(super) fn viewer_turn_anchor(turn_start_ms: Option<i64>) -> std::time::Instant {
|
||||
let now = std::time::Instant::now();
|
||||
let Some(start_ms) = turn_start_ms else {
|
||||
return now;
|
||||
};
|
||||
let elapsed_ms = chrono::Utc::now()
|
||||
.timestamp_millis()
|
||||
.saturating_sub(start_ms);
|
||||
if elapsed_ms <= 0 {
|
||||
return now;
|
||||
}
|
||||
now.checked_sub(std::time::Duration::from_millis(elapsed_ms as u64))
|
||||
.unwrap_or(now)
|
||||
}
|
||||
|
||||
/// Elapsed for a wake turn's end marker: its delta-borne `turnStartMs`
|
||||
/// ([`AgentView::wake_turn_start`], consumed here on a pid match) to the
|
||||
/// terminal's `agentTimestampMs` — both stamped by the shell clock, so client
|
||||
/// skew cancels (fall back to client now when the stamp is missing). `None`
|
||||
/// — no tracked start (old shells / no deltas seen) or a nonsensical negative
|
||||
/// span — renders the marker without a duration rather than lying with
|
||||
/// "0.0s".
|
||||
pub(super) fn wake_turn_elapsed(
|
||||
agent: &mut AgentView,
|
||||
prompt_id: &str,
|
||||
end_ms: Option<i64>,
|
||||
) -> Option<std::time::Duration> {
|
||||
let (_, start_ms) = agent.wake_turn_start.take_if(|(pid, _)| pid == prompt_id)?;
|
||||
let end_ms = end_ms.unwrap_or_else(|| chrono::Utc::now().timestamp_millis());
|
||||
u64::try_from(end_ms - start_ms)
|
||||
.ok()
|
||||
.map(std::time::Duration::from_millis)
|
||||
}
|
||||
|
||||
/// Push a wake turn's end marker via the shared terminal-marker helper so the
|
||||
/// wake turn's OWN stop hooks (pid-matched stash) render inline on the marker
|
||||
/// instead of as a stray block. A REAL turn's leftover stash (pid mismatch)
|
||||
/// must stay pending for its own marker rail — never fold into, nor flush
|
||||
/// standalone on, an unrelated wake — hence `preserve_mismatched_stash`.
|
||||
pub(super) fn push_wake_end_marker(
|
||||
agent: &mut AgentView,
|
||||
prompt_id: &str,
|
||||
elapsed: Option<std::time::Duration>,
|
||||
) {
|
||||
// Wake turns skip PromptResponse; finish streaming so a trailing ` is flushed.
|
||||
agent.session.tracker.finish_turn(&mut agent.scrollback);
|
||||
|
||||
crate::app::turn_completion::push_turn_terminal_marker(
|
||||
agent,
|
||||
Some(SessionEvent::TurnCompleted { elapsed }),
|
||||
Some(prompt_id),
|
||||
/* preserve_mismatched_stash */ true,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
use super::*;
|
||||
|
||||
/// A server-authoritative running prompt that drained into the running slot
|
||||
/// while the previous turn was still finishing locally (FIFO handoff
|
||||
/// race). Stashed on [`AppView::pending_running_adoptions`] and consumed by the
|
||||
/// `PromptResponse` handler after `finish_turn` clears `current_prompt_id`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingRunningAdoption {
|
||||
/// The `prompt_id` the leader reported as `running_prompt_id`.
|
||||
pub prompt_id: String,
|
||||
/// The queued prompt's text (for the turn-start shim's user block), if the
|
||||
/// pager knew about the prompt. `None` for prompts queued by other clients.
|
||||
pub text: Option<String>,
|
||||
/// The adopted entry's `kind` (`"prompt"`/`"bash"`/`"verification"`/…),
|
||||
/// which selects the turn-start shim's display block + focus flag.
|
||||
pub kind: String,
|
||||
/// Set when a `running=None` broadcast spares this stash (one-shot: the
|
||||
/// next `running=None` tears it down).
|
||||
pub turn_ended: bool,
|
||||
}
|
||||
|
||||
/// Wire payload of `x.ai/session/prompt_complete`, emitted by
|
||||
/// `MvpAgent::prompt()` on the shell after every turn.
|
||||
///
|
||||
/// `Serialize` is derived so tests construct payloads through the same type
|
||||
/// they are parsed into (shape drift fails at compile time, not at runtime).
|
||||
/// Unknown fields (e.g. `turnId`, future additions) are ignored; every field
|
||||
/// except `sessionId` is optional for wire compatibility with older shells —
|
||||
/// in particular `promptId` only exists on shells with the lost-response fix.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct PromptCompletePayload {
|
||||
pub(super) session_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) stop_reason: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) prompt_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) agent_result: Option<String>,
|
||||
/// What triggered a cancelled turn's cancel (`"send_now"` suppresses the
|
||||
/// "Turn cancelled" marker); stamped top-level, absent on older shells.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) cancel_trigger: Option<String>,
|
||||
/// `_meta` extension point — parsed defensively as a trigger fallback.
|
||||
#[serde(default, rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub(super) meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl PromptCompletePayload {
|
||||
/// The cancel trigger, wherever it was stamped: the top-level
|
||||
/// `cancelTrigger` field (the shell's emission), falling back to
|
||||
/// `_meta.cancelTrigger` (the envelope shape of the durable rail).
|
||||
/// `None` (older shells) means a normal cancel.
|
||||
pub(super) fn cancel_trigger(&self) -> Option<&str> {
|
||||
self.cancel_trigger
|
||||
.as_deref()
|
||||
.or_else(|| self.meta.as_ref()?.get("cancelTrigger")?.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_queue_changed(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
let Ok(changed) =
|
||||
serde_json::from_str::<crate::app::prompt_queue::QueueChanged>(notif.params.get())
|
||||
else {
|
||||
tracing::warn!("Failed to parse x.ai/queue/changed");
|
||||
return false;
|
||||
};
|
||||
|
||||
let running_prompt_id = changed.running_prompt_id.clone();
|
||||
let session_id = changed.session_id.clone();
|
||||
|
||||
// Capture the running prompt's text + kind from the currently-known queue
|
||||
// (an optimistic echo, or a prior authoritative entry) BEFORE the broadcast
|
||||
// is applied — the new broadcast drops the now-running item from `entries`,
|
||||
// so it's gone afterward. The turn-start shim uses them to render the right
|
||||
// display block.
|
||||
let running_entry = running_prompt_id.as_ref().and_then(|pid| {
|
||||
app.shared_prompt_queue(&session_id)
|
||||
.and_then(|q| q.iter().find(|e| &e.id == pid).cloned())
|
||||
});
|
||||
let running_text: Option<String> = running_entry.as_ref().map(|e| e.text.clone());
|
||||
let running_kind: String = running_entry
|
||||
.as_ref()
|
||||
.map(|e| e.kind.clone())
|
||||
.unwrap_or_else(|| "prompt".to_string());
|
||||
|
||||
// Resolve the owning agent before the queue is replaced.
|
||||
let sid = acp::SessionId::new(session_id.clone());
|
||||
let agent_id = match find_session_match(app, &sid) {
|
||||
Some(SessionMatch::Root(id)) => Some(id),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let recv_entry_ids: Vec<&str> = changed.entries.iter().map(|e| e.id.as_str()).collect();
|
||||
// Raw (pre-merge) broadcast rows for the optimistic-echo reconcile: the
|
||||
// post-apply snapshot re-pins unconfirmed echoes, so only the broadcast
|
||||
// itself can prove a row landed shell-side.
|
||||
let raw_entries: Vec<(String, u64)> = changed
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| (e.id.clone(), e.version))
|
||||
.collect();
|
||||
let local_current_prompt_id = agent_id
|
||||
.and_then(|aid| app.agents.get(&aid))
|
||||
.and_then(|a| a.session.current_prompt_id.clone())
|
||||
.unwrap_or_default();
|
||||
tracing::debug!(
|
||||
target: "qtrace",
|
||||
pid = std::process::id(),
|
||||
event = "queue_changed_recv",
|
||||
session = %session_id,
|
||||
running_prompt_id = running_prompt_id.as_deref().unwrap_or(""),
|
||||
local_current_prompt_id = %local_current_prompt_id,
|
||||
entry_count = changed.entries.len(),
|
||||
entries = ?recv_entry_ids,
|
||||
"received x.ai/queue/changed broadcast",
|
||||
);
|
||||
|
||||
let rekeyed_echo_ids = app.apply_queue_changed(changed);
|
||||
|
||||
// Mirror the reconciled shared queue into the owning agent so the queue
|
||||
// pane can render the union of local + server rows without needing
|
||||
// `AppView` access during draw / input handling.
|
||||
if let Some(aid) = agent_id {
|
||||
let snapshot = app
|
||||
.shared_prompt_queue(&session_id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
// Stashed adoption: its painted block is about to be consumed.
|
||||
let stashed_pid = app
|
||||
.pending_running_adoptions
|
||||
.get(&aid)
|
||||
.map(|p| p.prompt_id.clone());
|
||||
if let Some(agent) = app.agents.get_mut(&aid) {
|
||||
agent.shared_queue = snapshot;
|
||||
// A re-keyed echo's id is dead everywhere (only its content
|
||||
// matched the broadcast): drop it from the optimistic set and
|
||||
// any send-now parked on it — the row is visible under its new
|
||||
// id, so a fresh Enter sends it normally. A painted send-now
|
||||
// block moves to the new id (the message still runs there).
|
||||
for (old_id, new_id) in &rekeyed_echo_ids {
|
||||
agent.note_queue_echo_rekeyed(old_id, new_id);
|
||||
}
|
||||
|
||||
// A painted-pending prompt the broadcast no longer lists — and
|
||||
// is neither running nor a stashed adoption — was removed and
|
||||
// will never adopt: retire its block. Unconfirmed optimistic ids
|
||||
// are exempt (their RPC is in flight; absence is expected).
|
||||
let removed_painted: Vec<String> = agent
|
||||
.send_now_painted_blocks
|
||||
.keys()
|
||||
.filter(|pid| {
|
||||
running_prompt_id.as_deref() != Some(pid.as_str())
|
||||
&& stashed_pid.as_deref() != Some(pid.as_str())
|
||||
&& !raw_entries.iter().any(|(eid, _)| eid == *pid)
|
||||
&& !agent.optimistic_queue_ids.contains(*pid)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for pid in &removed_painted {
|
||||
agent.retire_send_now_painted_block(pid);
|
||||
}
|
||||
|
||||
// Cleanup hook: if the user is editing a server-origin row and
|
||||
// that row is no longer in the broadcast (started draining,
|
||||
// removed by another client, etc.), exit editing mode so the
|
||||
// composer isn't stranded on a ghost row. Don't dispatch any
|
||||
// follow-up Action — the broadcast already reconciled the
|
||||
// queue state for every other client.
|
||||
let stranded_server_id = match &agent.prompt_mode {
|
||||
super::super::agent_view::PromptMode::EditingQueued {
|
||||
server_id: Some(sid),
|
||||
..
|
||||
} if !agent.shared_queue.iter().any(|e| &e.id == sid) => Some(sid.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(sid) = stranded_server_id {
|
||||
tracing::debug!(
|
||||
server_id = %sid,
|
||||
"exiting EditingQueued: row is no longer in the shared queue"
|
||||
);
|
||||
agent.cancel_editing_queued_for_lost_row();
|
||||
}
|
||||
}
|
||||
// Resolve a queue-row send-now that was parked while its row was
|
||||
// still an optimistic echo: the broadcast just confirmed the row, so
|
||||
// fire the interject with the authoritative version (racing it
|
||||
// earlier would have no-opped shell-side and dropped the send-now).
|
||||
let fire = app.agents.get_mut(&aid).and_then(|agent| {
|
||||
agent.resolve_send_now_awaiting_confirm(&raw_entries, running_prompt_id.as_deref())
|
||||
});
|
||||
if let Some((id, expected_version)) = fire {
|
||||
if let Some(agent) = app.agents.get_mut(&aid) {
|
||||
// Same arming contract as `dispatch_queue_interject_shared`.
|
||||
super::super::dispatch::arm_send_now_and_paint(agent, &id, None);
|
||||
}
|
||||
crate::unified_log::info(
|
||||
"prompt.queue_send_now_confirmed",
|
||||
Some(&session_id),
|
||||
Some(serde_json::json!({ "prompt_id": id, "version": expected_version })),
|
||||
);
|
||||
app.pending_effects
|
||||
.push(crate::app::actions::Effect::QueueInterject {
|
||||
session_id: sid.clone(),
|
||||
id,
|
||||
expected_version,
|
||||
new_text: None,
|
||||
});
|
||||
}
|
||||
// A queue change can empty the visible queue mid-wait — the marker
|
||||
// may become eligible now (see `maybe_push_parked_marker`).
|
||||
if let Some(agent) = app.agents.get_mut(&aid) {
|
||||
agent.maybe_push_parked_marker();
|
||||
}
|
||||
}
|
||||
|
||||
// Adoption / turn-start correlation.
|
||||
//
|
||||
// Single-client idle path stays inert: the pager sets `current_prompt_id`
|
||||
// locally at `start_turn`, so when the confirming broadcast arrives with
|
||||
// `running_prompt_id == current_prompt_id`, the `Some(c) if c == pid` arm
|
||||
// makes this a no-op.
|
||||
match (running_prompt_id, agent_id) {
|
||||
// No turn running on the server — drop any stale pending adoption.
|
||||
// Exception (`turn_ended`, one-shot): a turn ending inside the handoff
|
||||
// window must leave the stash for the previous turn's PromptResponse,
|
||||
// regardless of buffer occupancy — this ext broadcast can overtake the
|
||||
// turn's `session/update`s (separate, reorderable channels).
|
||||
(None, Some(aid)) => {
|
||||
let retain = app
|
||||
.pending_running_adoptions
|
||||
.get(&aid)
|
||||
.is_some_and(|p| !p.turn_ended);
|
||||
if retain {
|
||||
if let Some(p) = app.pending_running_adoptions.get_mut(&aid) {
|
||||
p.turn_ended = true;
|
||||
}
|
||||
} else if let Some(p) = app.pending_running_adoptions.remove(&aid)
|
||||
&& let Some(agent) = app.agents.get_mut(&aid)
|
||||
{
|
||||
agent.discard_pending_adoption_updates(&p.prompt_id);
|
||||
}
|
||||
}
|
||||
// Non-adoptable running prompt (see `AgentView::should_adopt_running_prompt`):
|
||||
// either an actor-run synthetic turn with no `prompt_complete` /
|
||||
// `PromptResponse` exit (nothing would ever call `finish_turn`), or a turn
|
||||
// whose durable `TurnCompleted` already arrived in THIS load's replay
|
||||
// (terminal-in-replay — it already ended). Adopting either via
|
||||
// `apply_turn_start_shim` would `start_turn()` → `AgentState::TurnRunning`
|
||||
// and strand the pager on "Responding…"/"Waiting…" forever. The agent-aware
|
||||
// check is load-bearing here: `replayed_terminal_prompts` stays populated
|
||||
// after a load, so a later `queue/changed` re-reporting the already-ended
|
||||
// `running_prompt_id` must NOT re-adopt the turn the `SessionLoaded` /
|
||||
// reconnect adoption already correctly skipped. Skip the turn-start
|
||||
// adoption; a live synthetic turn's streaming content still renders via the
|
||||
// live-delta path in `handle` WITHOUT calling `start_turn`.
|
||||
(Some(pid), Some(aid))
|
||||
if app
|
||||
.agents
|
||||
.get(&aid)
|
||||
.is_some_and(|a| !a.should_adopt_running_prompt(&pid)) =>
|
||||
{
|
||||
tracing::debug!(
|
||||
target: "qtrace",
|
||||
pid = std::process::id(),
|
||||
prompt_id = %pid,
|
||||
"queue/changed: skipping turn-start adoption for non-adoptable running \
|
||||
prompt (synthetic turn with no prompt_complete exit, or terminal-in-replay)",
|
||||
);
|
||||
}
|
||||
(Some(pid), Some(aid)) => {
|
||||
let current = app
|
||||
.agents
|
||||
.get(&aid)
|
||||
.and_then(|a| a.session.current_prompt_id.clone());
|
||||
match current {
|
||||
// Already tracking this running prompt — inert.
|
||||
Some(c) if c == pid => {}
|
||||
// Nothing running locally: adopt now + run the turn-start shim
|
||||
// (render the queued prompt's user block, set `TurnRunning`).
|
||||
None => {
|
||||
if let Some(agent) = app.agents.get_mut(&aid) {
|
||||
super::super::dispatch::apply_turn_start_shim(
|
||||
agent,
|
||||
pid,
|
||||
running_text,
|
||||
&running_kind,
|
||||
);
|
||||
}
|
||||
}
|
||||
// A different prompt is still finishing locally (FIFO handoff
|
||||
// race — the next broadcast can arrive before the previous
|
||||
// turn's `PromptResponse`). Stash it; the `PromptResponse`
|
||||
// handler adopts it after `finish_turn` clears
|
||||
// `current_prompt_id`. Never corrupt the in-flight turn.
|
||||
Some(_) => {
|
||||
// The leader emits this prompt's user-echo (no `promptId`,
|
||||
// so the gate can't drop it) right after this broadcast but
|
||||
// before the previous turn's `PromptResponse` runs the
|
||||
// deferred shim. Arm the echo-skip now so it doesn't render
|
||||
// a duplicate user block — but ONLY when THIS client will
|
||||
// actually paint that block via the deferred shim.
|
||||
//
|
||||
// The deferred shim is run exclusively by the `PromptResponse`
|
||||
// handler, which fires only for the client that DROVE the
|
||||
// currently-finishing turn (`!attached_as_viewer`). A viewer
|
||||
// of that turn ends it via `prompt_complete`, which clears
|
||||
// (and removes) the stash without ever running the shim — so
|
||||
// on a viewer the echo is the ONLY source of the user block
|
||||
// and must not be swallowed.
|
||||
//
|
||||
// Key the guard on driver-vs-viewer of the *current* turn,
|
||||
// NOT on who originated the draining prompt: a client can be
|
||||
// `attached_as_viewer` on another client's turn yet
|
||||
// immediate-send (self-originate) a queued prompt of its own.
|
||||
// That client still won't run the shim, so an
|
||||
// `is_self_originated`-based guard would wrongly swallow the
|
||||
// echo and drop the block. Symmetric
|
||||
// hazard: a driver adopting ANOTHER client's drained prompt
|
||||
// DOES run the shim, so it must swallow the echo — which an
|
||||
// origination-based guard would miss, double-rendering.
|
||||
let drives_current_turn =
|
||||
app.agents.get(&aid).is_some_and(|a| !a.attached_as_viewer);
|
||||
let will_render_own_block = drives_current_turn
|
||||
&& super::super::dispatch::shim_renders_own_user_block(
|
||||
&running_kind,
|
||||
running_text.as_deref(),
|
||||
);
|
||||
if will_render_own_block && let Some(agent) = app.agents.get_mut(&aid) {
|
||||
agent.session.tracker.expect_user_echo();
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "qtrace",
|
||||
pid = std::process::id(),
|
||||
event = "adoption_stashed",
|
||||
prompt_id = %pid,
|
||||
"stashing running-prompt adoption (FIFO handoff race)",
|
||||
);
|
||||
// A rebroadcast for the SAME running prompt (every queue
|
||||
// edit/no-op rebroadcasts) must not clobber the stash: the
|
||||
// first broadcast consumed the drained row from the mirror,
|
||||
// so this pass re-derives `text: None` and the deferred
|
||||
// shim would render no user block (and the echo-skip armed
|
||||
// above already swallowed the shell's echo).
|
||||
if app
|
||||
.pending_running_adoptions
|
||||
.get(&aid)
|
||||
.is_some_and(|p| p.prompt_id == pid)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// A newer running prompt supersedes any earlier stash.
|
||||
if let Some(prev) = app.pending_running_adoptions.insert(
|
||||
aid,
|
||||
PendingRunningAdoption {
|
||||
prompt_id: pid.clone(),
|
||||
text: running_text,
|
||||
kind: running_kind,
|
||||
turn_ended: false,
|
||||
},
|
||||
) && let Some(agent) = app.agents.get_mut(&aid)
|
||||
{
|
||||
agent.discard_pending_adoption_updates(&prev.prompt_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `prompt_complete` carries `sessionId`, `stopReason`, `agentResult`,
|
||||
/// `turnId`, and (shells ≥ the lost-response fix) `promptId`; for viewers,
|
||||
/// turns are serialized per session, so "finish the running viewer turn for
|
||||
/// this session" is unambiguous even without the prompt id.
|
||||
///
|
||||
/// This is the one-release compat rail (kept until every leader emits the
|
||||
/// durable [`XaiSessionUpdate::TurnCompleted`]): it parses the payload and
|
||||
/// delegates the turn-finalize to
|
||||
/// [`finalize_turn_from_terminal`](super::super::turn_completion::finalize_turn_from_terminal),
|
||||
/// which carries the driver-arm / viewer-finish behavior verbatim.
|
||||
///
|
||||
/// TODO(prompt_complete-deprecation): Legacy removal (gated): durable turn_completed is already consumed via finalize_turn_from_terminal; keep & re-point the lost-RPC reconcile to the durable rail before deleting.
|
||||
pub(super) fn handle_prompt_complete(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
let Ok(payload) = serde_json::from_str::<PromptCompletePayload>(notif.params.get()) else {
|
||||
tracing::warn!("Failed to parse x.ai/session/prompt_complete");
|
||||
return false;
|
||||
};
|
||||
let session_id = payload.session_id.as_str();
|
||||
|
||||
let sid = acp::SessionId::new(session_id.to_string());
|
||||
let Some(SessionMatch::Root(id)) = find_session_match(app, &sid) else {
|
||||
return false;
|
||||
};
|
||||
let is_active = is_matched_agent_active(app, id);
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Finalize on the agent, then map the outcome to the return bool in the one
|
||||
// shared place both terminal rails use (returns it directly — arming reports
|
||||
// a change unconditionally so a background tab still wakes the reconcile tick).
|
||||
let outcome = super::super::turn_completion::finalize_turn_from_terminal(
|
||||
agent,
|
||||
session_id,
|
||||
payload.prompt_id.as_deref(),
|
||||
payload.stop_reason.as_deref(),
|
||||
payload.agent_result.as_deref(),
|
||||
payload.cancel_trigger(),
|
||||
);
|
||||
super::super::turn_completion::apply_terminal_outcome(outcome, app, id, is_active)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use super::*;
|
||||
|
||||
/// Result of looking up which view a notification's `session_id` targets.
|
||||
///
|
||||
/// The matched view's mutation must happen on the agent identified here,
|
||||
/// regardless of which view the user is currently looking at.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum SessionMatch {
|
||||
/// The session_id matches the root session of this agent.
|
||||
Root(AgentId),
|
||||
/// The session_id matches a subagent view child of this agent
|
||||
/// (i.e. an entry in `agent.subagent_views`). The child's key is the
|
||||
/// notification's `session_id.0.as_ref()`; the caller re-derives it
|
||||
/// to avoid an extra allocation.
|
||||
Child(AgentId),
|
||||
}
|
||||
|
||||
impl SessionMatch {
|
||||
/// The owning agent's id, regardless of variant.
|
||||
///
|
||||
/// For `Root`, this is the agent whose root session matched. For `Child`,
|
||||
/// this is the parent agent that owns the matching `subagent_views` entry.
|
||||
/// Callers that only need to look up the owning agent (without
|
||||
/// distinguishing root vs child) should use this instead of duplicating
|
||||
/// the `match { Root(id) | Child(id) => id }` pattern.
|
||||
pub(super) fn agent_id(self) -> AgentId {
|
||||
match self {
|
||||
SessionMatch::Root(id) | SessionMatch::Child(id) => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the agent that owns a notification's `session_id` and whether the
|
||||
/// active view is affected.
|
||||
///
|
||||
/// Convenience wrapper around `find_session_match` + `is_matched_agent_active`
|
||||
/// + `agents.get_mut()`, used by the bg-task notification handlers.
|
||||
pub(super) fn resolve_notif_agent<'a>(
|
||||
app: &'a mut AppView,
|
||||
session_id: &acp::SessionId,
|
||||
) -> Option<(SessionMatch, bool, &'a mut AgentView)> {
|
||||
let matched = find_session_match(app, session_id)?;
|
||||
let parent_id = matched.agent_id();
|
||||
let is_active = is_matched_agent_active(app, parent_id);
|
||||
let agent = app.agents.get_mut(&parent_id)?;
|
||||
Some((matched, is_active, agent))
|
||||
}
|
||||
|
||||
/// Resolve the agent an MCP-lifecycle notification (`init_progress` /
|
||||
/// `mcp_initialized`) targets.
|
||||
///
|
||||
/// Routes by the payload's `sessionId` so a background session's progress
|
||||
/// updates and completion signal land on *its* agent rather than whichever
|
||||
/// agent happens to be foregrounded — otherwise a background agent's
|
||||
/// "Connecting MCPs (N/M)…" spinner is never cleared and sticks forever.
|
||||
/// Falls back to the active agent when the payload omits a `sessionId`.
|
||||
///
|
||||
/// Returns the owning agent plus whether it is the currently displayed one
|
||||
/// (used to decide whether the notification warrants a redraw).
|
||||
///
|
||||
/// Only resolves to a `Root` agent: `mcp_init_progress` is a per-root-agent
|
||||
/// indicator with no per-subagent slot, so notifications whose sessionId
|
||||
/// matches a subagent (`Child`) are dropped — otherwise a subagent's own MCP
|
||||
/// init would clobber its parent's spinner.
|
||||
pub(super) fn mcp_target_agent<'a>(
|
||||
app: &'a mut AppView,
|
||||
session_id: Option<&str>,
|
||||
) -> Option<(bool, &'a mut AgentView)> {
|
||||
match session_id {
|
||||
Some(sid) => {
|
||||
let sid = acp::SessionId::new(sid);
|
||||
let (matched, is_active, agent) = resolve_notif_agent(app, &sid)?;
|
||||
if matches!(matched, SessionMatch::Child(_)) {
|
||||
return None;
|
||||
}
|
||||
Some((is_active, agent))
|
||||
}
|
||||
None => {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return None;
|
||||
};
|
||||
let agent = app.agents.get_mut(&id)?;
|
||||
Some((true, agent))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a matched session and the owning agent, borrow the correct
|
||||
/// `(session, scrollback)` pair — the child view's when the notification
|
||||
/// targets a subagent, the root agent's otherwise.
|
||||
pub(super) fn resolve_target_view<'a>(
|
||||
agent: &'a mut AgentView,
|
||||
matched: SessionMatch,
|
||||
child_sid: &str,
|
||||
) -> Option<(
|
||||
&'a mut AgentSession,
|
||||
&'a mut crate::scrollback::state::ScrollbackState,
|
||||
)> {
|
||||
if matches!(matched, SessionMatch::Child(_)) {
|
||||
let child_view = agent.subagent_views.get_mut(child_sid)?;
|
||||
Some((&mut child_view.session, &mut child_view.scrollback))
|
||||
} else {
|
||||
Some((&mut agent.session, &mut agent.scrollback))
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate the agent (or subagent view) a notification's `session_id` belongs to.
|
||||
///
|
||||
/// Search order:
|
||||
/// 1. Exact root match: an agent whose `session.session_id` equals `session_id`.
|
||||
/// 2. Subagent view: any agent whose `subagent_views` map contains `session_id`
|
||||
/// as a key.
|
||||
/// 3. Race-window fallback: when no exact match exists AND the currently active
|
||||
/// agent has no `session_id` yet, route to it. Notifications can race ahead
|
||||
/// of `TaskResult::SessionCreated`, and the only agent that could possibly
|
||||
/// own such a pre-assignment notification is the one the user just created
|
||||
/// (which is necessarily active and has `session_id == None`).
|
||||
///
|
||||
/// Returns `None` when the notification cannot be associated with any agent;
|
||||
/// the caller should drop it (sending an empty Ok response if applicable).
|
||||
///
|
||||
/// All ACP-notification handlers must route through this function rather than
|
||||
/// gating on `app.active_view` directly; see the `handle_scheduled_task_*`
|
||||
/// family for the legacy active-view pattern still pending migration.
|
||||
pub(super) fn find_session_match(
|
||||
app: &AppView,
|
||||
session_id: &acp::SessionId,
|
||||
) -> Option<SessionMatch> {
|
||||
// Single pass over `app.agents`: prefer an exact root match (returned
|
||||
// immediately, since root takes precedence) but track the first child
|
||||
// match seen as a fallback used after the full scan completes.
|
||||
//
|
||||
// Comparing `Option<&SessionId>` to `Some(&session_id)` borrows both
|
||||
// sides -- no SessionId clone. The HashMap lookup uses the inner `&str`
|
||||
// directly via the `Borrow<str>` impl on `String`, so no allocation
|
||||
// either. This preserves the previous two-pass semantics (root wins
|
||||
// when both could match) while halving the iteration cost on the hot
|
||||
// notification path.
|
||||
let child_key: &str = session_id.0.as_ref();
|
||||
let mut child_match: Option<AgentId> = None;
|
||||
for (id, agent) in &app.agents {
|
||||
if agent.session.session_id.as_ref() == Some(session_id) {
|
||||
return Some(SessionMatch::Root(*id));
|
||||
}
|
||||
if child_match.is_none() && agent.subagent_views.contains_key(child_key) {
|
||||
child_match = Some(*id);
|
||||
}
|
||||
}
|
||||
if let Some(id) = child_match {
|
||||
return Some(SessionMatch::Child(id));
|
||||
}
|
||||
// Pass 3: race-window fallback for notifications that arrive before the
|
||||
// root session_id has been assigned. Only the active agent is eligible,
|
||||
// and only when its `session_id` is still `None` -- otherwise we would
|
||||
// misroute a stranger's notification to whichever agent happens to be
|
||||
// foregrounded.
|
||||
if let ActiveView::Agent(active_id) = app.active_view
|
||||
&& let Some(agent) = app.agents.get(&active_id)
|
||||
&& agent.session.session_id.is_none()
|
||||
{
|
||||
return Some(SessionMatch::Root(active_id));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether the matched agent is the one currently displayed.
|
||||
pub(super) fn is_matched_agent_active(app: &AppView, matched_agent: AgentId) -> bool {
|
||||
matches!(app.active_view, ActiveView::Agent(id) if id == matched_agent)
|
||||
}
|
||||
|
||||
/// Resolve the `AgentId` that should own an interactive modal
|
||||
/// (`ask_user_question` / `exit_plan_mode`) for `session_id`.
|
||||
///
|
||||
/// Routes by the request's session id via [`find_session_match`] — exactly like
|
||||
/// `session/update` notifications — so a modal raised by a **background**
|
||||
/// session lands on its own view even when the user is on the dashboard or a
|
||||
/// different session, instead of being gated on `app.active_view`. A child
|
||||
/// (subagent) match resolves to its parent agent, which owns the overlay.
|
||||
///
|
||||
/// Returns `None` when no local view exists for that session; the caller must
|
||||
/// then leave the reverse-request unanswered (drop, do NOT error) and rely on
|
||||
/// the leader's replay-on-attach.
|
||||
pub(super) fn interaction_target_agent(app: &AppView, session_id: &str) -> Option<AgentId> {
|
||||
let sid = acp::SessionId::new(session_id.to_owned());
|
||||
match find_session_match(app, &sid) {
|
||||
Some(SessionMatch::Root(id) | SessionMatch::Child(id)) => Some(id),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,445 @@
|
||||
use super::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Handle `x.ai/models/update` — model list changed (etag-triggered refresh).
|
||||
pub(super) fn handle_models_update(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
if let Ok(model_state) = serde_json::from_str::<acp::SessionModelState>(notif.params.get()) {
|
||||
use crate::acp::model_state::ModelState;
|
||||
let new_models = ModelState::from(Some(model_state));
|
||||
tracing::info!(
|
||||
count = new_models.available.len(),
|
||||
"models updated via x.ai/models/update"
|
||||
);
|
||||
|
||||
let shell_fallback_current = new_models.current.clone();
|
||||
|
||||
// Override app-level default with the active agent's model.
|
||||
let mut app_models = new_models.clone();
|
||||
if let ActiveView::Agent(id) = app.active_view
|
||||
&& let Some(agent) = app.agents.get(&id)
|
||||
&& let Some(ref agent_model) = agent.session.models.current
|
||||
&& app_models.available.contains_key(agent_model)
|
||||
{
|
||||
app_models.current = Some(agent_model.clone());
|
||||
}
|
||||
|
||||
app.models = app_models;
|
||||
|
||||
for agent in app.agents.values_mut() {
|
||||
// Log when an update drops the agent's active model — this is the
|
||||
// moment the status bar visibly "switches model mid-conversation"
|
||||
// (the agent falls back to the shell's current model below).
|
||||
if let Some(ref current) = agent.session.models.current
|
||||
&& !new_models.available.contains_key(current)
|
||||
{
|
||||
tracing::warn!(
|
||||
current_model = %current.0,
|
||||
fallback = ?shell_fallback_current.as_ref().map(|m| m.0.as_ref()),
|
||||
available_count = new_models.available.len(),
|
||||
"models update removed this agent's current model; falling back"
|
||||
);
|
||||
}
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.update_catalog(new_models.available.clone(), shell_fallback_current.clone());
|
||||
}
|
||||
true
|
||||
} else {
|
||||
tracing::warn!("Failed to parse x.ai/models/update");
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `x.ai/settings/update` — remote settings refreshed on `/new`.
|
||||
pub(super) fn handle_settings_update(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
let Ok(update) = serde_json::from_str::<PagerSettingsUpdate>(notif.params.get()) else {
|
||||
tracing::warn!("Failed to parse x.ai/settings/update");
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Some(v) = update.auto_permission_mode_enabled {
|
||||
// Keep the pager's auto-permission-mode gate live with the remote settings
|
||||
// remote tier (the leader caches it agent-side; the pager process needs
|
||||
// its own copy). Refresh the startup snapshot so the Shift+Tab cycle and
|
||||
// the settings modal both reflect a remote-only enablement/kill-switch
|
||||
// without a restart.
|
||||
kigi_shell::util::config::cache_remote_auto_permission_mode_enabled(Some(v));
|
||||
app.auto_mode_gate = kigi_shell::util::config::auto_permission_mode_enabled_from_disk();
|
||||
// Mid-session kill switch: when the gate just went off, drop displayed
|
||||
// Auto to Ask + clear every agent's per-session flag (shared with the
|
||||
// startup reconcile), AND tell live sessions to leave Auto. Clearing only
|
||||
// the display would let the agent keep classifier-approving while the UI
|
||||
// shows "Ask" — the emergency-off must actually disable enforcement.
|
||||
if !app.auto_mode_gate {
|
||||
// Sessions to notify: agents that HAD Auto on (capture before the
|
||||
// downgrade clears the flag) and have a live session id.
|
||||
let leaving_auto: Vec<acp::SessionId> = app
|
||||
.agents
|
||||
.values()
|
||||
.filter(|a| a.session.is_auto())
|
||||
.filter_map(|a| a.session.session_id.clone())
|
||||
.collect();
|
||||
super::super::dispatch::downgrade_displayed_auto_if_gated(app);
|
||||
notify_sessions_leave_auto(app, &leaving_auto);
|
||||
}
|
||||
// Reveal/hide `/auto` on every slash surface in lockstep with the gate
|
||||
// (covers both a mid-session kill-switch and re-enablement).
|
||||
app.sync_permission_mode_slash_gate();
|
||||
}
|
||||
|
||||
// `permission_mode` is presence-aware (omit / null / string). While the
|
||||
// soft default still owns the mode, a push re-arms `default_yolo` + UI for
|
||||
// the next `/new`; once the user claims a mode (Shift+Tab / settings /
|
||||
// `/mode`) the latch is cleared and pushes leave it alone.
|
||||
if let Some(remote_opt) = update.permission_mode.as_ref()
|
||||
&& app.permission_mode_from_soft_default
|
||||
{
|
||||
// One config read at the I/O boundary; the applier is deterministic.
|
||||
let root = kigi_shell::config::load_effective_config().ok();
|
||||
apply_soft_default_permission_mode(
|
||||
app,
|
||||
root.as_ref().and_then(|r| r.get("ui")),
|
||||
remote_opt.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(v) = update.show_resolved_model {
|
||||
app.show_resolved_model = v;
|
||||
}
|
||||
if let Some(v) = update.sharing_enabled {
|
||||
app.sharing_enabled = v;
|
||||
// Propagate to existing agents so slash-command registries stay
|
||||
// in sync (same fan-out pattern used when creating new agents).
|
||||
for agent in app.agents.values_mut() {
|
||||
agent.set_sharing_enabled(v);
|
||||
}
|
||||
}
|
||||
// Always recompute is_api_key_auth from the tier so a later Free/SuperGrok
|
||||
// stamp does not leave API-key bypass / hidden `/usage` stuck.
|
||||
if let Some(v) = update.subscription_tier_display {
|
||||
let is_key = super::super::app_view::is_api_key_label(&v);
|
||||
app.is_api_key_auth = is_key;
|
||||
app.usage_visible = !is_key && app.team_name.is_none();
|
||||
app.subscription_tier = Some(v);
|
||||
app.apply_tier_restrictions();
|
||||
}
|
||||
// TODO: extract resolve_session_picker_grouped helper (duplicates event_loop.rs:143-160)
|
||||
// Respect env var > config > remote precedence (mirrors event_loop.rs startup).
|
||||
if let Some(remote_val) = update.session_picker_grouped {
|
||||
let resolved = std::env::var("KIGI_SESSION_PICKER_GROUPED")
|
||||
.ok()
|
||||
.and_then(|v| match v.as_str() {
|
||||
"1" | "true" => Some(true),
|
||||
"0" | "false" => Some(false),
|
||||
_ => None,
|
||||
})
|
||||
.or_else(|| {
|
||||
kigi_shell::config::load_effective_config()
|
||||
.ok()
|
||||
.and_then(|cfg| cfg.get("cli")?.get("session_picker_grouped")?.as_bool())
|
||||
})
|
||||
.unwrap_or(remote_val);
|
||||
app.session_picker_grouped = resolved;
|
||||
}
|
||||
if let Some(v) = update.subscription_watch_interval_secs {
|
||||
app.subscription_watch_interval_secs = Some(v);
|
||||
}
|
||||
|
||||
// Gate update logic:
|
||||
// - allow_access == Some(true): explicitly granted → lift the gate
|
||||
// - gate_message.is_some(): server sent a new message → impose/update
|
||||
// - Neither condition met: don't touch the gate. In particular,
|
||||
// allow_access=Some(false) without a gate_message must NOT clear the
|
||||
// gate (gate_from_settings returns None when gate_message is absent,
|
||||
// which would incorrectly lift an existing gate).
|
||||
if update.allow_access == Some(true) {
|
||||
let effs = app.lift_gate();
|
||||
app.pending_effects.extend(effs);
|
||||
} else if let Some(msg) = update.gate_message.as_ref()
|
||||
&& !msg.is_empty()
|
||||
{
|
||||
// (An empty gate_message would only clear the gate message text, NOT
|
||||
// access, so it intentionally does not touch the gate here.)
|
||||
let effs = app.impose_gate(kigi_shell::auth::GateInfo {
|
||||
message: msg.clone(),
|
||||
url: update.gate_url.clone(),
|
||||
label: update.gate_label.clone(),
|
||||
});
|
||||
app.pending_effects.extend(effs);
|
||||
}
|
||||
|
||||
// Load config layers once for tips + group_tool_verbs +
|
||||
// collapsed_edit_blocks resolution. Loaded unconditionally: the UI flags
|
||||
// re-resolve on every update (see below), and updates are rare (post-auth
|
||||
// refresh, `/new`), so three small TOML reads are fine.
|
||||
let (requirements, user_config, managed_config) = (
|
||||
kigi_shell::config::load_merged_requirements(),
|
||||
kigi_shell::config::load_from_disk().ok(),
|
||||
kigi_shell::config::load_managed_config().ok(),
|
||||
);
|
||||
|
||||
// Local layers may beat remote — re-resolve the full chain into the render
|
||||
// cache (mirrors the event_loop.rs startup resolve). Runs on None too: the
|
||||
// shell always publishes this field from its live remote tier, so None
|
||||
// means remote settings cleared it (or an older shell that cannot deliver the
|
||||
// remote tier at all) — either way resolving without a remote value is
|
||||
// correct, and it reverts a previously cached remote enable back to the
|
||||
// local/default (off) resolution instead of leaving Some(true) stuck
|
||||
// until restart.
|
||||
let remote = kigi_shell::util::config::RemoteSettings {
|
||||
group_tool_verbs: update.group_tool_verbs,
|
||||
..Default::default()
|
||||
};
|
||||
let resolved = kigi_shell::util::config::resolve_group_tool_verbs(
|
||||
requirements.as_ref(),
|
||||
user_config.as_ref(),
|
||||
managed_config.as_ref(),
|
||||
Some(&remote),
|
||||
)
|
||||
.value;
|
||||
// On a real flip, re-fold every live transcript (mirrors dispatch's
|
||||
// set_group_tool_verbs_inner); unchanged values keep `/new` cheap.
|
||||
// Stale expansion ids describe the old grouping shape — drop them so the
|
||||
// re-fold can't reopen a verb slot expanded or mark a coincident dense
|
||||
// group expanded (see `clear_group_expansion`).
|
||||
if resolved != crate::appearance::cache::load_group_tool_verbs() {
|
||||
crate::appearance::cache::set_group_tool_verbs(resolved);
|
||||
for agent in app.agents.values_mut() {
|
||||
agent.scrollback.clear_group_expansion();
|
||||
agent.scrollback.invalidate_heights();
|
||||
for child in agent.subagent_views.values_mut() {
|
||||
child.scrollback.clear_group_expansion();
|
||||
child.scrollback.invalidate_heights();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same None-reverts contract as group_tool_verbs above: re-resolve the
|
||||
// full local chain with the pushed remote tier so a cleared remote settings
|
||||
// field falls back to local/default instead of staying latched.
|
||||
let remote = kigi_shell::util::config::RemoteSettings {
|
||||
collapsed_edit_blocks: update.collapsed_edit_blocks,
|
||||
..Default::default()
|
||||
};
|
||||
let resolved = kigi_shell::util::config::resolve_collapsed_edit_blocks(
|
||||
requirements.as_ref(),
|
||||
user_config.as_ref(),
|
||||
managed_config.as_ref(),
|
||||
Some(&remote),
|
||||
)
|
||||
.value;
|
||||
// On a real flip, re-materialize on-default Edit rows + repaint suffixes
|
||||
// in every live transcript (mirrors dispatch's
|
||||
// set_collapsed_edit_blocks_inner); unchanged values keep `/new` cheap.
|
||||
let prev = crate::appearance::cache::load_collapsed_edit_blocks();
|
||||
if resolved != prev {
|
||||
crate::appearance::cache::set_collapsed_edit_blocks(resolved);
|
||||
for agent in app.agents.values_mut() {
|
||||
agent
|
||||
.scrollback
|
||||
.apply_collapsed_edit_blocks_flip(prev, resolved);
|
||||
for child in agent.subagent_views.values_mut() {
|
||||
child
|
||||
.scrollback
|
||||
.apply_collapsed_edit_blocks_flip(prev, resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve tips from config layers + the updated remote tips.
|
||||
if let Some(remote_tips) = update.tips {
|
||||
use kigi_shell::util::config::resolve_tips;
|
||||
|
||||
app.tips = resolve_tips(
|
||||
requirements.as_ref(),
|
||||
user_config.as_ref(),
|
||||
managed_config.as_ref(),
|
||||
Some(&remote_tips),
|
||||
);
|
||||
if !app.tips.is_empty() {
|
||||
let kigi_home = kigi_tools::util::kigi_home::kigi_home();
|
||||
app.tip = kigi_shell::util::tips::pick_and_advance(&app.tips, &kigi_home);
|
||||
} else {
|
||||
app.tip = None;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("settings updated via x.ai/settings/update");
|
||||
true
|
||||
}
|
||||
|
||||
/// Re-arm the soft-defaulted launch mode from a pushed `permission_mode`
|
||||
/// (TOML `[ui]` > remote > Ask), for the next `/new` only — live sessions are
|
||||
/// untouched and nothing is persisted. `effective_ui` is injected so the
|
||||
/// resolve is deterministic under test. Enforcement gating reuses the app's
|
||||
/// startup snapshots (`yolo_policy_block`, `auto_mode_gate`); the agent's
|
||||
/// permission manager re-clamps authoritatively at decision time.
|
||||
pub(super) fn apply_soft_default_permission_mode(
|
||||
app: &mut AppView,
|
||||
effective_ui: Option<&toml::Value>,
|
||||
remote: Option<&str>,
|
||||
) {
|
||||
let mode = kigi_shell::util::config::resolve_permission_mode(effective_ui, remote);
|
||||
app.default_yolo = mode.is_always_approve() && app.yolo_policy_block.is_none();
|
||||
let auto = mode.is_auto() && app.auto_mode_gate && !app.default_yolo;
|
||||
app.current_ui.permission_mode = Some(if auto {
|
||||
"auto".to_string()
|
||||
} else if app.default_yolo {
|
||||
"always-approve".to_string()
|
||||
} else {
|
||||
kigi_shell::util::config::resolved_display_permission_mode(effective_ui, remote).to_string()
|
||||
});
|
||||
}
|
||||
|
||||
/// Tell live sessions to leave Auto on the mid-session kill-switch: fire the
|
||||
/// `x.ai/yolo_mode_changed` notification the agent maps to
|
||||
/// `SetAutoMode { enabled: false }`, fire-and-forget over the shared ACP channel.
|
||||
/// The notification is CLIENT-scoped (the agent applies it to every session of
|
||||
/// the sending client), so one send covers all affected sessions. `yolo_mode` is
|
||||
/// deliberately OMITTED — the agent skips the yolo branch when the key is absent,
|
||||
/// so a sibling tab's always-approve is preserved; only auto is cleared.
|
||||
pub(super) fn notify_sessions_leave_auto(app: &AppView, session_ids: &[acp::SessionId]) {
|
||||
if session_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let params = serde_json::json!({
|
||||
"auto_mode": false,
|
||||
"permission_mode": "ask",
|
||||
});
|
||||
let notification = acp::ExtNotification::new(
|
||||
"x.ai/yolo_mode_changed",
|
||||
serde_json::value::to_raw_value(¶ms)
|
||||
.expect("serialize yolo_mode_changed params")
|
||||
.into(),
|
||||
);
|
||||
let (response_tx, _response_rx) = tokio::sync::oneshot::channel();
|
||||
let args = kigi_acp_lib::AcpArgs {
|
||||
request: notification,
|
||||
response_tx,
|
||||
};
|
||||
let _ = app.acp_tx.send(args.into());
|
||||
}
|
||||
|
||||
/// Handle `x.ai/sessions/changed` — the leader broadcasts roster
|
||||
/// upserts/removals to all clients (FleetView dashboard).
|
||||
pub(super) fn handle_sessions_changed(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
let Ok(changed) = serde_json::from_str::<crate::app::roster::RosterChanged>(notif.params.get())
|
||||
else {
|
||||
tracing::warn!("Failed to parse x.ai/sessions/changed");
|
||||
return false;
|
||||
};
|
||||
let mut affected = false;
|
||||
for entry in changed.upserted {
|
||||
app.upsert_roster_entry(entry);
|
||||
affected = true;
|
||||
}
|
||||
for sid in changed.removed {
|
||||
app.remove_roster_entry(&sid);
|
||||
affected = true;
|
||||
}
|
||||
affected
|
||||
}
|
||||
|
||||
/// Deserialization type for the `x.ai/settings/update` notification payload.
|
||||
///
|
||||
/// This is intentionally a separate struct from `SettingsUpdateNotification` in
|
||||
/// `kigi-shell/src/agent/mvp_agent.rs`. The shell side derives `Serialize`
|
||||
/// and owns the canonical field set from `RemoteSettings`; this pager side
|
||||
/// derives `Deserialize` and selectively consumes only the fields relevant to
|
||||
/// the TUI. Keeping them separate avoids coupling the pager to shell internals
|
||||
/// and lets each side evolve independently (e.g. adding a shell-only field
|
||||
/// doesn't require a pager change). All fields are `Option` with
|
||||
/// `#[serde(default)]` so that partial updates and forward-compatible additions
|
||||
/// are handled gracefully.
|
||||
///
|
||||
/// **Keep in sync** with field names/types in `SettingsUpdateNotification` at
|
||||
/// `kigi-shell/src/agent/mvp_agent.rs` when adding fields that both sides
|
||||
/// need.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(super) struct PagerSettingsUpdate {
|
||||
#[serde(default)]
|
||||
show_resolved_model: Option<bool>,
|
||||
#[serde(default)]
|
||||
sharing_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
session_picker_grouped: Option<bool>,
|
||||
#[serde(default)]
|
||||
tips: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
gate_message: Option<String>,
|
||||
#[serde(default)]
|
||||
gate_url: Option<String>,
|
||||
#[serde(default)]
|
||||
gate_label: Option<String>,
|
||||
#[serde(default)]
|
||||
allow_access: Option<bool>,
|
||||
#[serde(default)]
|
||||
subscription_tier_display: Option<String>,
|
||||
#[serde(default)]
|
||||
auto_permission_mode_enabled: Option<bool>,
|
||||
/// Soft-default permission mode. Presence-aware: omit = no update,
|
||||
/// `null` = recompute with remote=None, string = that soft-default.
|
||||
/// Omission happens with older shells that predate the field (they can
|
||||
/// never clear a mode they don't know about) — that version skew is why
|
||||
/// this is tri-state instead of a plain `Option`.
|
||||
#[serde(default, deserialize_with = "deserialize_presence_aware_string")]
|
||||
permission_mode: Option<Option<String>>,
|
||||
#[serde(default)]
|
||||
group_tool_verbs: Option<bool>,
|
||||
#[serde(default)]
|
||||
collapsed_edit_blocks: Option<bool>,
|
||||
#[serde(default)]
|
||||
subscription_watch_interval_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// Presence-aware string: omit → `None` (`#[serde(default)]`), null →
|
||||
/// `Some(None)`, string → `Some(Some(_))`.
|
||||
fn deserialize_presence_aware_string<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Option<String>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Ok(Some(Option::<String>::deserialize(deserializer)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod presence_aware_dto_tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Probe {
|
||||
#[serde(default, deserialize_with = "deserialize_presence_aware_string")]
|
||||
permission_mode: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_mode_dto_distinguishes_omit_from_null() {
|
||||
let omit: Probe = serde_json::from_value(serde_json::json!({
|
||||
"show_resolved_model": true,
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(omit.permission_mode, None, "omit must be None (no update)");
|
||||
|
||||
let null_v: Probe = serde_json::from_value(serde_json::json!({
|
||||
"permission_mode": null,
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
null_v.permission_mode,
|
||||
Some(None),
|
||||
"explicit null must be Some(None)"
|
||||
);
|
||||
|
||||
let some_v: Probe = serde_json::from_value(serde_json::json!({
|
||||
"permission_mode": "always-approve",
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
some_v.permission_mode,
|
||||
Some(Some("always-approve".into())),
|
||||
"string must be Some(Some(_))"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use super::*;
|
||||
|
||||
/// Update the activity label on a subagent's collapsed scrollback block.
|
||||
///
|
||||
/// Skips the write (and cache invalidation) when the label hasn't changed,
|
||||
/// so the per-delta common case ("Responding" stays "Responding") allocates
|
||||
/// nothing.
|
||||
pub(super) fn sync_activity_label(
|
||||
scrollback: &mut crate::scrollback::state::ScrollbackState,
|
||||
entry_id: Option<crate::scrollback::entry::EntryId>,
|
||||
activity_label: Option<&str>,
|
||||
) {
|
||||
if let Some(eid) = entry_id
|
||||
&& let Some(entry) = scrollback.get_by_id_mut(eid)
|
||||
&& let RenderBlock::Subagent(ref mut sb) = entry.block
|
||||
&& sb.activity_label.as_deref() != activity_label
|
||||
{
|
||||
sb.activity_label = activity_label.map(str::to_owned);
|
||||
entry.invalidate_cache();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fan a subagent's computed activity label out to both surfaces that show
|
||||
/// it — the collapsed scrollback block and the [`SubagentInfo`] backing the
|
||||
/// tasks pane / dashboard rows — so the two can't drift.
|
||||
pub(super) fn sync_subagent_activity(
|
||||
parent: &mut AgentView,
|
||||
child_key: &str,
|
||||
activity_label: Option<String>,
|
||||
) {
|
||||
let Some(info) = parent.subagent_sessions.get_mut(child_key) else {
|
||||
return;
|
||||
};
|
||||
sync_activity_label(
|
||||
&mut parent.scrollback,
|
||||
info.scrollback_entry_id,
|
||||
activity_label.as_deref(),
|
||||
);
|
||||
info.activity_label = activity_label;
|
||||
}
|
||||
|
||||
/// Resolve a subagent child view's live activity into the display label the
|
||||
/// fan-out stamps ("Waiting" while the child is busy between activities).
|
||||
pub(super) fn subagent_activity_label(child_view: &AgentView) -> Option<String> {
|
||||
match child_view.resolve_turn_activity() {
|
||||
Some(a) => Some(crate::app::subagent::format_activity_label(&a)),
|
||||
None if child_view.session.state.is_busy() => Some("Waiting".to_string()),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthesize a finish for a stuck row when a kill found nothing live to stop
|
||||
/// (else `pending_kill` times out → "running"). `status` is the real terminal
|
||||
/// status for an already-finished orphan, else `"cancelled"`.
|
||||
pub(crate) fn finalize_killed_subagent(
|
||||
app: &mut AppView,
|
||||
session_id: &acp::SessionId,
|
||||
subagent_id: &str,
|
||||
status: &str,
|
||||
) -> bool {
|
||||
let Some(SessionMatch::Root(agent_id)) = find_session_match(app, session_id) else {
|
||||
return false;
|
||||
};
|
||||
let Some(agent) = app.agents.get(&agent_id) else {
|
||||
return false;
|
||||
};
|
||||
// Idempotency: skip if already finished.
|
||||
let Some(child_session_id) = agent
|
||||
.subagent_sessions
|
||||
.values()
|
||||
.find(|i| i.subagent_id.as_ref() == subagent_id && !i.finished)
|
||||
.map(|i| i.child_session_id.to_string())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let payload = SessionNotification {
|
||||
session_id: session_id.clone(),
|
||||
update: XaiSessionUpdate::SubagentFinished {
|
||||
subagent_id: subagent_id.to_string(),
|
||||
child_session_id,
|
||||
// An already-finished orphan may be "failed", but the cancel response
|
||||
// carries no failure reason (lost across the resume window), so
|
||||
// `error` stays None.
|
||||
status: status.to_string(),
|
||||
error: None,
|
||||
tool_calls: 0,
|
||||
turns: 0,
|
||||
// Real run time is unknown for an already-gone orphan (the row's
|
||||
// started_at is stamped at resume, not the real spawn), so emit 0.
|
||||
duration_ms: 0,
|
||||
tokens_used: 0,
|
||||
output: None,
|
||||
will_wake: false,
|
||||
},
|
||||
meta: None,
|
||||
};
|
||||
let Ok(params) = serde_json::value::to_raw_value(&payload) else {
|
||||
return false;
|
||||
};
|
||||
let notif = acp::ExtNotification::new("x.ai/session/update", params.into());
|
||||
handle_ext_notification(¬if, app)
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
/// Regression (resume sync): the on-disk replay stream re-emits persisted
|
||||
/// notifications through the generic `x.ai/session/update` envelope. A
|
||||
/// background `monitor`/bash task (`TaskBackgrounded`) must restore into
|
||||
/// `bg_tasks` on a resumed / second terminal — not be dropped by the
|
||||
/// default match arm — so the idle "watching" status line and the Tasks pane
|
||||
/// match the originating terminal. (Before this routing only subagents
|
||||
/// survived resume.)
|
||||
#[test]
|
||||
fn ext_session_update_replay_restores_bg_task() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let id = AgentId(0);
|
||||
assert!(app.agents[&id].session.bg_tasks.is_empty());
|
||||
|
||||
let update = XaiSessionUpdate::TaskBackgrounded {
|
||||
tool_call_id: "tc-mon".into(),
|
||||
task_id: "mon-1".into(),
|
||||
command: "tail -f deploy.log".into(),
|
||||
cwd: "/tmp".into(),
|
||||
output_file: "/tmp/mon-1.log".into(),
|
||||
monitor_description: Some("errors in deploy.log".into()),
|
||||
description: None,
|
||||
};
|
||||
handle(
|
||||
make_ext_session_notification_with_method("sess-1", "x.ai/session/update", update),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let task = app.agents[&id]
|
||||
.session
|
||||
.bg_tasks
|
||||
.get("mon-1")
|
||||
.expect("replayed TaskBackgrounded must restore bg_tasks on resume");
|
||||
assert!(task.is_monitor, "monitor_description must mark is_monitor");
|
||||
assert_eq!(task.status, BgTaskStatus::Running);
|
||||
}
|
||||
|
||||
/// Companion for scheduled `/loop`s: a replayed `ScheduledTaskCreated` must
|
||||
/// restore `scheduled_tasks`, and a later `ScheduledTaskDeleted` must net it
|
||||
/// back out — so a resumed terminal's loop count matches instead of staying
|
||||
/// empty until the next live fire. (Pairs with the shell-side persistence of
|
||||
/// these notifications in `notification_bridge.rs`.)
|
||||
#[test]
|
||||
fn ext_session_update_replay_restores_then_removes_scheduled_task() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let id = AgentId(0);
|
||||
assert!(app.agents[&id].session.scheduled_tasks.is_empty());
|
||||
|
||||
handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-1",
|
||||
"x.ai/session/update",
|
||||
XaiSessionUpdate::ScheduledTaskCreated {
|
||||
task_id: "loop-1".into(),
|
||||
prompt: "check deploy".into(),
|
||||
human_schedule: "every 5 minutes".into(),
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
},
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
app.agents[&id]
|
||||
.session
|
||||
.scheduled_tasks
|
||||
.contains_key("loop-1"),
|
||||
"replayed ScheduledTaskCreated must restore scheduled_tasks on resume"
|
||||
);
|
||||
|
||||
handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-1",
|
||||
"x.ai/session/update",
|
||||
XaiSessionUpdate::ScheduledTaskDeleted {
|
||||
task_id: "loop-1".into(),
|
||||
},
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
app.agents[&id].session.scheduled_tasks.is_empty(),
|
||||
"replayed ScheduledTaskDeleted must remove the loop on resume"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: demotion path (foreground Execute → BgTask) must call
|
||||
/// finish_running() so the entry is removed from the running set.
|
||||
/// Without this, the entry stays orphaned as "running" forever.
|
||||
#[test]
|
||||
fn task_backgrounded_demotion_clears_running_state() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let tc_id = "call-abc-123";
|
||||
|
||||
setup_pending_execute_tool(&mut app, tc_id);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.scrollback.len(), 1);
|
||||
assert!(agent.scrollback.needs_animation());
|
||||
assert!(agent.session.tracker.pending_tool_entry_id(tc_id).is_some());
|
||||
|
||||
let notif = make_task_backgrounded_notif("sess-1", tc_id, "task-001", "sleep 9999");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
!agent.scrollback.needs_animation(),
|
||||
"entry must not be in the running set after demotion"
|
||||
);
|
||||
let entry = agent.scrollback.get(0).unwrap();
|
||||
assert!(
|
||||
matches!(entry.block, RenderBlock::BgTask(_)),
|
||||
"block should be demoted to BgTask"
|
||||
);
|
||||
assert!(!entry.is_running);
|
||||
assert!(agent.session.tracker.pending_tool_entry_id(tc_id).is_none());
|
||||
}
|
||||
|
||||
/// Regression: late-detected is_background=true (raw_input arrives after the
|
||||
/// Execute block exists) followed by task_backgrounded must correctly demote
|
||||
/// the existing Execute block — not create a duplicate BgTask.
|
||||
#[test]
|
||||
fn task_backgrounded_late_detection_demotes_existing_entry() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let tc_id = "call-late-bg-42";
|
||||
|
||||
setup_pending_execute_tool(&mut app, tc_id);
|
||||
send_late_bg_detection(&mut app, tc_id);
|
||||
|
||||
// Tool should be in BOTH pending_tools and bg_deferred_tools
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.tracker.pending_tool_entry_id(tc_id).is_some());
|
||||
assert!(agent.session.tracker.bg_deferred_tools.contains_key(tc_id));
|
||||
|
||||
let notif = make_task_backgrounded_notif("sess-1", tc_id, "task-late-001", "sleep 9999");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.scrollback.len(), 1, "must not create duplicate");
|
||||
let entry = agent.scrollback.get(0).unwrap();
|
||||
assert!(matches!(entry.block, RenderBlock::BgTask(_)));
|
||||
assert!(!entry.is_running);
|
||||
assert!(!agent.scrollback.needs_animation());
|
||||
assert!(agent.session.tracker.pending_tool_entry_id(tc_id).is_none());
|
||||
assert!(!agent.session.tracker.bg_deferred_tools.contains_key(tc_id));
|
||||
}
|
||||
|
||||
/// Regression: even when the wire notification carries its own
|
||||
/// `description`, the deferred-tool suppression key must still be drained
|
||||
/// (it is preferred but the entry otherwise leaks and keeps dropping late
|
||||
/// stdout updates for the rest of the session).
|
||||
#[test]
|
||||
fn task_backgrounded_with_wire_description_still_drains_deferred_tool() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let tc_id = "call-late-bg-desc";
|
||||
|
||||
setup_pending_execute_tool(&mut app, tc_id);
|
||||
send_late_bg_detection(&mut app, tc_id);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.tracker.bg_deferred_tools.contains_key(tc_id));
|
||||
|
||||
let notif = SessionNotification {
|
||||
session_id: acp::SessionId::new("sess-1"),
|
||||
update: XaiSessionUpdate::TaskBackgrounded {
|
||||
tool_call_id: tc_id.into(),
|
||||
task_id: "task-late-desc".into(),
|
||||
command: "sleep 9999".into(),
|
||||
cwd: "/tmp".into(),
|
||||
output_file: "/tmp/output.log".into(),
|
||||
monitor_description: None,
|
||||
description: Some("Wait a while".into()),
|
||||
},
|
||||
meta: None,
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(¬if).unwrap();
|
||||
let notif = acp::ExtNotification::new("x.ai/task_backgrounded", raw.into());
|
||||
assert!(handle_task_backgrounded(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
!agent.session.tracker.bg_deferred_tools.contains_key(tc_id),
|
||||
"deferred-tool key must be drained even when wire description wins"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: a blank/whitespace wire `description` must not shadow a
|
||||
/// non-blank deferred (raw_input) description when there is no Execute
|
||||
/// block to recover from (fresh BgTask path). The label must come from the
|
||||
/// deferred description, not the blank wire value (which would otherwise
|
||||
/// fall back to the raw command).
|
||||
#[test]
|
||||
fn task_backgrounded_blank_wire_description_prefers_deferred() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let tc_id = "call-blank-wire";
|
||||
|
||||
// Simulate late is_background detection having stashed a real
|
||||
// description, with the placeholder entry dropped (no pending tool).
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent
|
||||
.session
|
||||
.tracker
|
||||
.bg_deferred_tools
|
||||
.insert(tc_id.to_string(), Some("deploy the server".to_string()));
|
||||
}
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.tracker
|
||||
.pending_tool_entry_id(tc_id)
|
||||
.is_none(),
|
||||
"no Execute entry to recover from — exercises the merge chain"
|
||||
);
|
||||
|
||||
let notif = SessionNotification {
|
||||
session_id: acp::SessionId::new("sess-1"),
|
||||
update: XaiSessionUpdate::TaskBackgrounded {
|
||||
tool_call_id: tc_id.into(),
|
||||
task_id: "task-blank-wire".into(),
|
||||
command: "sleep 9999".into(),
|
||||
cwd: "/tmp".into(),
|
||||
output_file: "/tmp/output.log".into(),
|
||||
monitor_description: None,
|
||||
description: Some(" ".into()),
|
||||
},
|
||||
meta: None,
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(¬if).unwrap();
|
||||
let notif = acp::ExtNotification::new("x.ai/task_backgrounded", raw.into());
|
||||
assert!(handle_task_backgrounded(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
!agent.session.tracker.bg_deferred_tools.contains_key(tc_id),
|
||||
"deferred-tool key drained"
|
||||
);
|
||||
assert_eq!(agent.scrollback.len(), 1);
|
||||
match &agent.scrollback.get(0).unwrap().block {
|
||||
RenderBlock::BgTask(bg) => assert_eq!(
|
||||
bg.description.as_deref(),
|
||||
Some("deploy the server"),
|
||||
"blank wire description must not shadow the deferred description"
|
||||
),
|
||||
other => panic!("expected BgTask, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_backgrounded_routes_to_child_session() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif =
|
||||
make_task_backgrounded_notif("child-sess", "tc-child-1", "task-child-1", "sleep 100");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
// Parent scrollback must NOT have the bg task block.
|
||||
assert_eq!(agent.scrollback.len(), 0, "parent scrollback must be empty");
|
||||
assert!(
|
||||
agent.session.bg_tasks.is_empty(),
|
||||
"parent session must not have the bg task"
|
||||
);
|
||||
|
||||
// Child view must have the bg task.
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(child.scrollback.len(), 1);
|
||||
assert!(child.session.bg_tasks.contains_key("task-child-1"));
|
||||
assert!(
|
||||
child
|
||||
.session
|
||||
.bg_tool_call_to_task
|
||||
.contains_key("tc-child-1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_backgrounded_root_still_routes_to_parent() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif =
|
||||
make_task_backgrounded_notif("parent-sess", "tc-root-1", "task-root-1", "ls -la");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.scrollback.len(), 1);
|
||||
assert!(agent.session.bg_tasks.contains_key("task-root-1"));
|
||||
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(
|
||||
child.scrollback.len(),
|
||||
0,
|
||||
"child scrollback must not be affected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_backgrounded_monitor_prefix_marks_is_monitor() {
|
||||
// Reparented monitor / older backend: the command carries the
|
||||
// "[monitor] <desc>" prefix but the notification has no
|
||||
// monitor_description. The pager must still mark it a monitor and use
|
||||
// the stripped text as the description so it renders as a Monitor row.
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif = make_task_backgrounded_notif(
|
||||
"parent-sess",
|
||||
"tc-mon-1",
|
||||
"task-mon-1",
|
||||
"[monitor] event counter",
|
||||
);
|
||||
handle_task_backgrounded(¬if, &mut app);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let task = agent
|
||||
.session
|
||||
.bg_tasks
|
||||
.get("task-mon-1")
|
||||
.expect("bg task registered");
|
||||
assert!(
|
||||
task.is_monitor,
|
||||
"a `[monitor] ` command prefix should mark the task as a monitor"
|
||||
);
|
||||
assert_eq!(task.description.as_deref(), Some("event counter"));
|
||||
}
|
||||
|
||||
/// Resume regression: the agent's cold-load reconciliation
|
||||
/// completes replay-restored dead tasks with `signal: "session_restart"`.
|
||||
/// That synthetic completion must finalize state QUIETLY — finish the
|
||||
/// replayed "Task started" entry and mark the task not-running — without
|
||||
/// pushing a fresh red "Task failed" block into the resumed scrollback.
|
||||
#[test]
|
||||
fn session_restart_completion_finalizes_without_failure_block() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let replayed =
|
||||
make_replayed_task_backgrounded_notif("sess-1", "tc-r", "task-r", "tail -f x.log");
|
||||
handle_task_backgrounded(&replayed, &mut app);
|
||||
{
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.scrollback.len(), 1, "replay restores started block");
|
||||
assert!(agent.scrollback.needs_animation(), "restored row runs");
|
||||
}
|
||||
|
||||
let notif = make_task_completed_notif_with_signal(
|
||||
"sess-1",
|
||||
"task-r",
|
||||
"tail -f x.log",
|
||||
None,
|
||||
Some("session_restart"),
|
||||
);
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
1,
|
||||
"stale-on-load completion must not push a 'Task failed' block"
|
||||
);
|
||||
assert!(
|
||||
!agent.scrollback.needs_animation(),
|
||||
"the replayed started entry must be finished (no running accent)"
|
||||
);
|
||||
let task = &agent.session.bg_tasks["task-r"];
|
||||
assert_eq!(
|
||||
task.status,
|
||||
BgTaskStatus::Failed,
|
||||
"state still records the task as not running"
|
||||
);
|
||||
}
|
||||
|
||||
/// Guard that the quiet path is NARROW: any other kill signal keeps the
|
||||
/// live behavior of pushing a completion/failure block.
|
||||
#[test]
|
||||
fn non_restart_signal_completion_still_pushes_failure_block() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let bg = make_task_backgrounded_notif("sess-1", "tc-k", "task-k", "sleep 999");
|
||||
handle_task_backgrounded(&bg, &mut app);
|
||||
|
||||
let notif = make_task_completed_notif_with_signal(
|
||||
"sess-1",
|
||||
"task-k",
|
||||
"sleep 999",
|
||||
None,
|
||||
Some("SIGKILL"),
|
||||
);
|
||||
handle_task_completed(¬if, &mut app);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
2,
|
||||
"a real kill must still render started + failed blocks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_completed_routes_to_child_session() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// First, background a task on the child.
|
||||
let bg_notif =
|
||||
make_task_backgrounded_notif("child-sess", "tc-child-2", "task-child-2", "echo hi");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
// Now complete it.
|
||||
let notif = make_task_completed_notif("child-sess", "task-child-2", "echo hi", Some(0));
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
// Parent must NOT have a completion block.
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
0,
|
||||
"parent scrollback must not have completion block"
|
||||
);
|
||||
|
||||
// Child must have both the started and completed blocks.
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(child.scrollback.len(), 2, "child: started + completed");
|
||||
let bg = child.session.bg_tasks.get("task-child-2").unwrap();
|
||||
assert!(matches!(bg.status, BgTaskStatus::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_completed_root_still_routes_to_parent() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// Background and complete a task on the parent.
|
||||
let bg_notif =
|
||||
make_task_backgrounded_notif("parent-sess", "tc-root-2", "task-root-2", "echo root");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
let notif = make_task_completed_notif("parent-sess", "task-root-2", "echo root", Some(0));
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
2,
|
||||
"parent: started + completed blocks"
|
||||
);
|
||||
let bg = agent.session.bg_tasks.get("task-root-2").unwrap();
|
||||
assert!(matches!(bg.status, BgTaskStatus::Done));
|
||||
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(
|
||||
child.scrollback.len(),
|
||||
0,
|
||||
"child scrollback must not be affected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_completed_failure_routes_to_child_session() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
let bg_notif = make_task_backgrounded_notif("child-sess", "tc-fail", "task-fail", "exit 1");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
let notif = make_task_completed_notif("child-sess", "task-fail", "exit 1", Some(1));
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
0,
|
||||
"parent scrollback must not have failure block"
|
||||
);
|
||||
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(child.scrollback.len(), 2, "child: started + failed");
|
||||
let bg = child.session.bg_tasks.get("task-fail").unwrap();
|
||||
assert!(matches!(bg.status, BgTaskStatus::Failed));
|
||||
assert_eq!(bg.exit_code, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_event_routes_to_child_session() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// Background a task on the child so monitor event has somewhere to land.
|
||||
let bg_notif =
|
||||
make_task_backgrounded_notif("child-sess", "tc-child-3", "task-child-3", "tail -f");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
let notif = make_monitor_event_notif("child-sess", "task-child-3", "new log line");
|
||||
let changed = handle_monitor_event(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.session.bg_tasks.is_empty(),
|
||||
"parent must not have the bg task"
|
||||
);
|
||||
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
let task = child.session.bg_tasks.get("task-child-3").unwrap();
|
||||
assert_eq!(task.stdout, "new log line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_event_root_still_routes_to_parent() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// Background a task on the parent.
|
||||
let bg_notif =
|
||||
make_task_backgrounded_notif("parent-sess", "tc-root-3", "task-root-3", "tail -f");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
let notif = make_monitor_event_notif("parent-sess", "task-root-3", "root event");
|
||||
let changed = handle_monitor_event(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let task = agent.session.bg_tasks.get("task-root-3").unwrap();
|
||||
assert_eq!(task.stdout, "root event");
|
||||
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert!(
|
||||
child.session.bg_tasks.is_empty(),
|
||||
"child must not have the bg task"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_backgrounded_child_inactive_returns_false_but_mutates_state() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
// Insert a second agent and switch to it so the first agent is inactive.
|
||||
let other = make_agent(Some("other-sess"));
|
||||
app.agents.insert(AgentId(1), other);
|
||||
crate::app::dispatch::switch_to_agent(
|
||||
&mut app,
|
||||
AgentId(1),
|
||||
crate::app::dispatch::SwitchCause::New,
|
||||
);
|
||||
assert!(matches!(app.active_view, ActiveView::Agent(AgentId(1))));
|
||||
|
||||
let notif =
|
||||
make_task_backgrounded_notif("child-sess", "tc-bg-inact", "task-bg-inact", "sleep 1");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
// Active view was NOT affected — should return false.
|
||||
assert!(!changed);
|
||||
|
||||
// But the bg task state must still land in the child view.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert!(child.session.bg_tasks.contains_key("task-bg-inact"));
|
||||
assert_eq!(child.scrollback.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_completed_child_inactive_returns_false_but_mutates_state() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// Background a task on the child first.
|
||||
let bg_notif = make_task_backgrounded_notif(
|
||||
"child-sess",
|
||||
"tc-compl-inact",
|
||||
"task-compl-inact",
|
||||
"echo",
|
||||
);
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
// Now switch away.
|
||||
let other = make_agent(Some("other-sess"));
|
||||
app.agents.insert(AgentId(1), other);
|
||||
crate::app::dispatch::switch_to_agent(
|
||||
&mut app,
|
||||
AgentId(1),
|
||||
crate::app::dispatch::SwitchCause::New,
|
||||
);
|
||||
|
||||
let notif = make_task_completed_notif("child-sess", "task-compl-inact", "echo", Some(0));
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
let bg = child.session.bg_tasks.get("task-compl-inact").unwrap();
|
||||
assert!(matches!(bg.status, BgTaskStatus::Done));
|
||||
assert_eq!(child.scrollback.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_backgrounded_unknown_session_returns_false() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif =
|
||||
make_task_backgrounded_notif("unknown-sess", "tc-unknown", "task-unknown", "sleep 1");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_completed_unknown_session_returns_false() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif = make_task_completed_notif("unknown-sess", "task-unknown", "echo x", Some(0));
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_event_unknown_session_returns_false() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif = make_monitor_event_notif("unknown-sess", "task-unknown", "event");
|
||||
let changed = handle_monitor_event(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replayed_task_backgrounded_marks_restored_from_replay() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let replayed =
|
||||
make_replayed_task_backgrounded_notif("sess-1", "tc-r", "task-r", "tail -f x.log");
|
||||
handle_task_backgrounded(&replayed, &mut app);
|
||||
let live = make_task_backgrounded_notif("sess-1", "tc-l", "task-l", "sleep 5");
|
||||
handle_task_backgrounded(&live, &mut app);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.session.bg_tasks["task-r"].restored_from_replay,
|
||||
"isReplay-stamped TaskBackgrounded must mark restored_from_replay"
|
||||
);
|
||||
assert!(
|
||||
!agent.session.bg_tasks["task-l"].restored_from_replay,
|
||||
"live TaskBackgrounded must not mark restored_from_replay"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn follow_ups_render_chips_on_active_agent() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let affected = handle_ext_notification(
|
||||
&follow_ups_ext("resp-1", &["Tell me more", "Summarize"]),
|
||||
&mut app,
|
||||
);
|
||||
assert!(affected, "fresh chips on the active agent warrant a redraw");
|
||||
let fu = app.agents[&AgentId(0)]
|
||||
.follow_ups
|
||||
.as_ref()
|
||||
.expect("chips set on the active agent");
|
||||
assert_eq!(fu.response_id, "resp-1");
|
||||
assert_eq!(fu.suggestions, vec!["Tell me more", "Summarize"]);
|
||||
}
|
||||
|
||||
/// End-to-end through the wire: the stamped `promptId` flows from the
|
||||
/// notification params into the dedup. (a) a re-delivery of the active
|
||||
/// turn's follow_ups re-renders after a clear; (b) a prior turn's replay is
|
||||
/// rejected.
|
||||
#[test]
|
||||
fn follow_ups_prompt_id_makes_dedup_deterministic() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.current_prompt_id = Some("p1".into());
|
||||
|
||||
// Active turn (p1) chips applied via the wire.
|
||||
assert!(handle_ext_notification(
|
||||
&follow_ups_ext_with_prompt("resp-1", "p1", &["a"]),
|
||||
&mut app
|
||||
));
|
||||
// Turn-boundary clear (keeps the seen ring).
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().clear_follow_ups();
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
|
||||
// (a) Re-delivery of the active turn re-renders.
|
||||
assert!(
|
||||
handle_ext_notification(
|
||||
&follow_ups_ext_with_prompt("resp-1", "p1", &["a"]),
|
||||
&mut app
|
||||
),
|
||||
"active-turn re-delivery must re-render via promptId match"
|
||||
);
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)]
|
||||
.follow_ups
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.response_id,
|
||||
"resp-1"
|
||||
);
|
||||
|
||||
// Adopt a new turn p2; clear.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.current_prompt_id = Some("p2".into());
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().clear_follow_ups();
|
||||
|
||||
// (b) Prior turn (p1) replay must NOT revive.
|
||||
assert!(
|
||||
!handle_ext_notification(
|
||||
&follow_ups_ext_with_prompt("resp-1", "p1", &["a"]),
|
||||
&mut app
|
||||
),
|
||||
"prior-turn replay must be rejected"
|
||||
);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_replayed_meta_suppresses_chips() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let params = serde_json::json!({
|
||||
"response_id": "resp-1",
|
||||
"suggestions": [{ "label": "x" }],
|
||||
"_meta": { "x.ai/replayed": true },
|
||||
});
|
||||
let notif = acp::ExtNotification::new(
|
||||
"x.ai/follow_ups",
|
||||
serde_json::value::to_raw_value(¶ms).unwrap().into(),
|
||||
);
|
||||
let affected = handle_ext_notification(¬if, &mut app);
|
||||
assert!(!affected, "a replayed chunk must not render chips");
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_malformed_params_are_ignored() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let bad = [
|
||||
serde_json::Value::String("not an object".into()),
|
||||
serde_json::json!([1, 2, 3]),
|
||||
serde_json::json!({ "suggestions": 7 }),
|
||||
serde_json::json!({}),
|
||||
];
|
||||
for params in bad {
|
||||
let notif = acp::ExtNotification::new(
|
||||
"x.ai/follow_ups",
|
||||
serde_json::value::to_raw_value(¶ms).unwrap().into(),
|
||||
);
|
||||
let affected = handle_ext_notification(¬if, &mut app);
|
||||
assert!(!affected, "malformed params must be ignored: {params}");
|
||||
}
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_sanitizes_control_characters() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
// A label carrying an ESC-based SGR sequence and a newline: control
|
||||
// characters are stripped so a chip cannot inject terminal escapes.
|
||||
handle_ext_notification(
|
||||
&follow_ups_ext("resp-1", &["safe\u{1b}[31mred\nmore"]),
|
||||
&mut app,
|
||||
);
|
||||
let fu = app.agents[&AgentId(0)].follow_ups.as_ref().unwrap();
|
||||
assert_eq!(fu.suggestions, vec!["safe[31mredmore"]);
|
||||
assert!(!fu.suggestions[0].contains('\u{1b}'));
|
||||
assert!(!fu.suggestions[0].contains('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_empty_response_id_is_ignored() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let affected = handle_ext_notification(&follow_ups_ext("", &["x"]), &mut app);
|
||||
assert!(
|
||||
!affected,
|
||||
"without a response_id there is no newest-wins key"
|
||||
);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_blank_labels_yield_no_chips() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let affected = handle_ext_notification(&follow_ups_ext("resp-1", &[" ", ""]), &mut app);
|
||||
assert!(!affected);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_strips_bidi_and_zero_width() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
// U+202E RIGHT-TO-LEFT OVERRIDE + U+200B ZERO WIDTH SPACE: stripped so
|
||||
// server text cannot visually disguise a leading `/` (Trojan Source).
|
||||
handle_ext_notification(
|
||||
&follow_ups_ext("resp-1", &["\u{202e}/rm\u{200b}-rf"]),
|
||||
&mut app,
|
||||
);
|
||||
let fu = app.agents[&AgentId(0)].follow_ups.as_ref().unwrap();
|
||||
assert_eq!(fu.suggestions, vec!["/rm-rf"]);
|
||||
assert!(!fu.suggestions[0].contains('\u{202e}'));
|
||||
assert!(!fu.suggestions[0].contains('\u{200b}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_caps_count_and_label_length() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let labels: Vec<String> = (0..20).map(|i| format!("s{i}")).collect();
|
||||
let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
|
||||
handle_ext_notification(&follow_ups_ext("resp-1", &refs), &mut app);
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)]
|
||||
.follow_ups
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.suggestions
|
||||
.len(),
|
||||
super::MAX_FOLLOW_UPS,
|
||||
"suggestion count capped at ingestion"
|
||||
);
|
||||
let long = "x".repeat(10_000);
|
||||
handle_ext_notification(&follow_ups_ext("resp-2", &[&long]), &mut app);
|
||||
let label = &app.agents[&AgentId(0)]
|
||||
.follow_ups
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.suggestions[0];
|
||||
assert!(
|
||||
label.len() <= super::MAX_FOLLOW_UP_LABEL,
|
||||
"label length clamped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_oversized_response_id_is_rejected() {
|
||||
// An oversized response_id is rejected (not truncated — that
|
||||
// could collide ids) so it can't bloat the retained seen ring.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let big = "r".repeat(super::MAX_RESPONSE_ID_LEN + 1);
|
||||
let affected = handle_ext_notification(&follow_ups_ext(&big, &["x"]), &mut app);
|
||||
assert!(!affected, "an oversized response_id must be rejected");
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
// A sane-length id still works.
|
||||
let ok = "r".repeat(super::MAX_RESPONSE_ID_LEN);
|
||||
assert!(handle_ext_notification(
|
||||
&follow_ups_ext(&ok, &["x"]),
|
||||
&mut app
|
||||
));
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_replayed_meta_false_renders() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let params = serde_json::json!({
|
||||
"response_id": "resp-1",
|
||||
"suggestions": [{ "label": "x" }],
|
||||
"_meta": { "x.ai/replayed": false },
|
||||
});
|
||||
let notif = acp::ExtNotification::new(
|
||||
"x.ai/follow_ups",
|
||||
serde_json::value::to_raw_value(¶ms).unwrap().into(),
|
||||
);
|
||||
assert!(
|
||||
handle_ext_notification(¬if, &mut app),
|
||||
"_meta replayed=false must still render"
|
||||
);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_per_element_malformed_is_ignored() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
for bad in [
|
||||
serde_json::json!({ "response_id": "r", "suggestions": [{ "label": 7 }] }),
|
||||
serde_json::json!({ "response_id": "r", "suggestions": [null] }),
|
||||
] {
|
||||
let notif = acp::ExtNotification::new(
|
||||
"x.ai/follow_ups",
|
||||
serde_json::value::to_raw_value(&bad).unwrap().into(),
|
||||
);
|
||||
assert!(
|
||||
!handle_ext_notification(¬if, &mut app),
|
||||
"a malformed suggestion element drops the notification: {bad}"
|
||||
);
|
||||
}
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_empty_array_renders_no_chips() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let affected = handle_ext_notification(&follow_ups_ext("resp-1", &[]), &mut app);
|
||||
assert!(!affected);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_empty_for_current_response_clears_chips() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
handle_ext_notification(&follow_ups_ext("resp-1", &["a"]), &mut app);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_some());
|
||||
let affected = handle_ext_notification(&follow_ups_ext("resp-1", &[]), &mut app);
|
||||
assert!(affected, "empty for the shown response clears the chips");
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_viewer_turn_transition_renders_newer_chips() {
|
||||
// A viewer holding resp-1's chips adopts the driver's NEXT
|
||||
// turn via a live delta (which clears the prior chips), then resp-2's
|
||||
// follow_ups render — not suppressed by the held resp-1.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent.attached_as_viewer = true;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
agent.apply_follow_ups("resp-1".into(), vec!["old".into()]);
|
||||
}
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let request = acp::SessionNotification::new(
|
||||
acp::SessionId::new("sess-1"),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
|
||||
acp::TextContent::new("resp-2 streaming"),
|
||||
))),
|
||||
)
|
||||
.meta(
|
||||
serde_json::json!({ "promptId": "p2", "agentTimestampMs": 1 })
|
||||
.as_object()
|
||||
.cloned(),
|
||||
);
|
||||
handle(
|
||||
AcpClientMessage::SessionNotification(kigi_acp_lib::AcpArgs {
|
||||
request,
|
||||
response_tx: tx,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
app.agents[&id].follow_ups.is_none(),
|
||||
"viewer adopting a new turn must clear the prior chips"
|
||||
);
|
||||
let affected = handle_ext_notification(&follow_ups_ext("resp-2", &["new"]), &mut app);
|
||||
assert!(affected);
|
||||
let fu = app.agents[&id].follow_ups.as_ref().unwrap();
|
||||
assert_eq!(fu.response_id, "resp-2");
|
||||
assert_eq!(fu.suggestions, vec!["new"]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
// ── derive_child_cwd ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn derive_child_cwd_uses_child_cwd_from_info() {
|
||||
let parent_cwd = PathBuf::from("/parent/cwd");
|
||||
let mut info = make_subagent_info("child-1");
|
||||
info.child_cwd = Some("/child/worktree".into());
|
||||
info.worktree_path = Some("/child/worktree".into());
|
||||
|
||||
let (cwd, is_wt) = derive_child_cwd(&parent_cwd, Some(&info));
|
||||
assert_eq!(cwd, PathBuf::from("/child/worktree"));
|
||||
assert!(is_wt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_child_cwd_falls_back_to_parent_when_child_cwd_is_none() {
|
||||
let parent_cwd = PathBuf::from("/parent/cwd");
|
||||
let info = make_subagent_info("child-2");
|
||||
|
||||
let (cwd, is_wt) = derive_child_cwd(&parent_cwd, Some(&info));
|
||||
assert_eq!(cwd, PathBuf::from("/parent/cwd"));
|
||||
assert!(!is_wt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_child_cwd_worktree_independent_of_child_cwd() {
|
||||
let parent_cwd = PathBuf::from("/parent/cwd");
|
||||
let mut info = make_subagent_info("child-3");
|
||||
info.child_cwd = None;
|
||||
info.worktree_path = Some("/some/worktree".into());
|
||||
|
||||
let (cwd, is_wt) = derive_child_cwd(&parent_cwd, Some(&info));
|
||||
assert_eq!(cwd, PathBuf::from("/parent/cwd"), "falls back to parent");
|
||||
assert!(
|
||||
is_wt,
|
||||
"worktree flag must be set even when child_cwd is None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_child_cwd_no_info_falls_back() {
|
||||
let parent_cwd = PathBuf::from("/parent/cwd");
|
||||
let (cwd, is_wt) = derive_child_cwd(&parent_cwd, None);
|
||||
assert_eq!(cwd, PathBuf::from("/parent/cwd"));
|
||||
assert!(!is_wt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_head_changed_updates_root_agent() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let notif = make_git_head_changed_notif("sess-A", Some("feature/x"), false, None);
|
||||
let changed = handle_git_head_changed(¬if, &mut app);
|
||||
|
||||
assert!(changed);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.current_branch.as_deref(), Some("feature/x"));
|
||||
assert!(!agent.is_worktree);
|
||||
assert!(agent.main_repo.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_head_changed_routes_to_child_subagent_view() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let child_sid = "child-sess-1";
|
||||
{
|
||||
let parent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
parent
|
||||
.subagent_views
|
||||
.insert(child_sid.into(), Box::new(make_agent(Some(child_sid))));
|
||||
}
|
||||
|
||||
let notif = make_git_head_changed_notif(
|
||||
child_sid,
|
||||
Some("worktree-branch"),
|
||||
true,
|
||||
Some("main-repo"),
|
||||
);
|
||||
let changed = handle_git_head_changed(¬if, &mut app);
|
||||
|
||||
assert!(changed);
|
||||
let parent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let child_view = parent.subagent_views.get(child_sid).unwrap();
|
||||
assert_eq!(
|
||||
child_view.current_branch.as_deref(),
|
||||
Some("worktree-branch")
|
||||
);
|
||||
assert!(child_view.is_worktree);
|
||||
assert_eq!(child_view.main_repo.as_deref(), Some("main-repo"));
|
||||
// Parent must not be affected.
|
||||
assert!(parent.current_branch.is_none());
|
||||
assert!(!parent.is_worktree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_head_changed_unknown_session_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let notif = make_git_head_changed_notif("unknown-sess", Some("main"), false, None);
|
||||
let changed = handle_git_head_changed(¬if, &mut app);
|
||||
|
||||
assert!(!changed);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.current_branch.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_head_changed_root_agent_not_affected_when_child_matches() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let child_sid = "child-sess-2";
|
||||
{
|
||||
let parent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
parent
|
||||
.subagent_views
|
||||
.insert(child_sid.into(), Box::new(make_agent(Some(child_sid))));
|
||||
parent.current_branch = Some("parent-branch".into());
|
||||
}
|
||||
|
||||
let notif = make_git_head_changed_notif(child_sid, Some("child-branch"), true, None);
|
||||
handle_git_head_changed(¬if, &mut app);
|
||||
|
||||
let parent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parent.current_branch.as_deref(),
|
||||
Some("parent-branch"),
|
||||
"parent's branch must not change when child is updated"
|
||||
);
|
||||
let child_view = parent.subagent_views.get(child_sid).unwrap();
|
||||
assert_eq!(child_view.current_branch.as_deref(), Some("child-branch"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn goal_updated_ignores_unknown_json_fields_via_serde() {
|
||||
// Serde-side half of the forward-compat story: a payload that
|
||||
// carries an extra JSON field absent on today's
|
||||
// `SessionUpdate::GoalUpdated` (no `deny_unknown_fields` on the
|
||||
// variant) must still deserialize and drive a full
|
||||
// `GoalDisplayState`. This guards against someone later adding
|
||||
// `#[serde(deny_unknown_fields)]` to the variant, which would
|
||||
// silently break wire compatibility with older shells.
|
||||
//
|
||||
// The complementary Rust-level half — that the destructure with
|
||||
// trailing `..` keeps absent additive `Option<T>` fields landing
|
||||
// as `None` in the mapped `GoalDisplayState` — is exercised by
|
||||
// `goal_updated_absent_optional_fields_deserialize_to_none`.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
let raw_payload = serde_json::json!({
|
||||
"sessionId": "sess-A",
|
||||
"update": {
|
||||
"sessionUpdate": "goal_updated",
|
||||
"goal_id": "g-ext",
|
||||
"objective": "build forward-compat tolerance",
|
||||
"status": "active",
|
||||
"phase": "executing",
|
||||
"token_budget": 200_000,
|
||||
"tokens_used": 12_345,
|
||||
"elapsed_ms": 750,
|
||||
"total_deliverables": 2,
|
||||
"completed_deliverables": 1,
|
||||
"current_deliverable_idx": 1,
|
||||
"current_deliverable_title": "Wire compat",
|
||||
"current_subagent_role": "verifier",
|
||||
"total_worker_rounds": 5,
|
||||
"total_verify_rounds": 2,
|
||||
"token_baseline": 100,
|
||||
"finished_subagent_tokens": 99,
|
||||
"live_subagent_tokens": 4_321,
|
||||
"live_tokens_by_model": [["grok-4", 6_000], ["grok-3", 4_000]],
|
||||
"live_context_pct": 42,
|
||||
"live_turn_count": 7,
|
||||
"live_tool_call_count": 11,
|
||||
"last_event": "verify_started",
|
||||
"last_event_detail": "round 2 of 3",
|
||||
"last_event_timestamp": "2026-05-24T00:00:00Z",
|
||||
// Field absent on today's `SessionUpdate::GoalUpdated` — simulates
|
||||
// a future shell adding a new wire field. With trailing `..` in
|
||||
// the destructure and no `deny_unknown_fields` on the variant,
|
||||
// this must parse and the pager must still produce a
|
||||
// GoalDisplayState mapped from the known subset.
|
||||
"future_field_for_pr5": "ignored-by-todays-pager"
|
||||
}
|
||||
});
|
||||
let raw = serde_json::value::to_raw_value(&raw_payload).unwrap();
|
||||
let request = acp::ExtNotification::new("x.ai/session_notification", raw.into());
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let msg = AcpClientMessage::ExtNotification(kigi_acp_lib::AcpArgs {
|
||||
request,
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
assert!(
|
||||
affected,
|
||||
"GoalUpdated for the active agent must request a redraw"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let goal = agent
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.expect("GoalUpdated should populate goal_state even with unknown wire fields");
|
||||
assert_eq!(goal.goal_id, "g-ext");
|
||||
assert_eq!(goal.objective, "build forward-compat tolerance");
|
||||
assert_eq!(goal.status, GoalDisplayStatus::Active);
|
||||
assert_eq!(goal.phase, GoalDisplayPhase::Executing);
|
||||
assert_eq!(goal.token_budget, Some(200_000));
|
||||
assert_eq!(goal.tokens_used, 12_345);
|
||||
assert_eq!(goal.elapsed_ms, 750);
|
||||
assert_eq!(goal.total_deliverables, 2);
|
||||
assert_eq!(goal.completed_deliverables, 1);
|
||||
assert_eq!(goal.current_deliverable_id, Some(1));
|
||||
assert_eq!(
|
||||
goal.current_deliverable_title.as_deref(),
|
||||
Some("Wire compat")
|
||||
);
|
||||
assert_eq!(goal.current_subagent_role.as_deref(), Some("verifier"));
|
||||
assert_eq!(goal.total_worker_rounds, 5);
|
||||
assert_eq!(goal.total_verify_rounds, 2);
|
||||
assert_eq!(goal.token_baseline, 100);
|
||||
assert_eq!(goal.finished_subagent_tokens, 99);
|
||||
assert_eq!(goal.live_subagent_tokens, Some(4_321));
|
||||
assert_eq!(
|
||||
goal.live_tokens_by_model,
|
||||
vec![("grok-4".to_owned(), 6_000), ("grok-3".to_owned(), 4_000)],
|
||||
"populated per-model breakdown must round-trip wire->display"
|
||||
);
|
||||
assert_eq!(goal.live_context_pct, Some(42));
|
||||
assert_eq!(goal.live_turn_count, Some(7));
|
||||
assert_eq!(goal.live_tool_call_count, Some(11));
|
||||
assert_eq!(goal.last_event.as_deref(), Some("verify_started"));
|
||||
assert_eq!(goal.last_event_detail.as_deref(), Some("round 2 of 3"));
|
||||
assert_eq!(
|
||||
goal.last_event_timestamp.as_deref(),
|
||||
Some("2026-05-24T00:00:00Z")
|
||||
);
|
||||
assert_eq!(goal.pause_message, None);
|
||||
// Classifier fields default to `None` / `false` when absent.
|
||||
assert_eq!(goal.classifier_runs_attempted, None);
|
||||
assert_eq!(goal.classifier_max_runs, None);
|
||||
assert_eq!(goal.last_classifier_verdict, None);
|
||||
assert_eq!(goal.last_classifier_details_path, None);
|
||||
assert!(!goal.verifying_completion);
|
||||
assert!(!goal.planning);
|
||||
assert!(
|
||||
goal.deliverables.is_empty(),
|
||||
"deliverables is wire-compat-only in the simplified goal model"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_complete_transition_pushes_end_to_end_marker_once() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
let send = |app: &mut AppView, status: &str, elapsed_ms: u64| {
|
||||
let raw_payload = serde_json::json!({
|
||||
"sessionId": "sess-A",
|
||||
"update": {
|
||||
"sessionUpdate": "goal_updated",
|
||||
"goal_id": "g1",
|
||||
"objective": "obj",
|
||||
"status": status,
|
||||
"phase": "executing",
|
||||
"tokens_used": 0,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"total_deliverables": 0,
|
||||
"completed_deliverables": 0,
|
||||
"total_worker_rounds": 0,
|
||||
"total_verify_rounds": 0,
|
||||
"token_baseline": 0,
|
||||
"finished_subagent_tokens": 0,
|
||||
}
|
||||
});
|
||||
let raw = serde_json::value::to_raw_value(&raw_payload).unwrap();
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
handle(
|
||||
AcpClientMessage::ExtNotification(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtNotification::new("x.ai/session_notification", raw.into()),
|
||||
response_tx: tx,
|
||||
}),
|
||||
app,
|
||||
);
|
||||
};
|
||||
|
||||
let goal_markers = |app: &AppView| -> Vec<std::time::Duration> {
|
||||
let sb = &app.agents.get(&AgentId(0)).unwrap().scrollback;
|
||||
(0..sb.len())
|
||||
.filter_map(|i| match sb.get(i).map(|e| &e.block) {
|
||||
Some(RenderBlock::SessionEvent(b)) => match &b.event {
|
||||
SessionEvent::GoalCompleted { elapsed } => Some(*elapsed),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
send(&mut app, "active", 1_000);
|
||||
assert!(goal_markers(&app).is_empty(), "no marker while Active");
|
||||
|
||||
send(&mut app, "complete", 619_000);
|
||||
assert_eq!(
|
||||
goal_markers(&app),
|
||||
vec![std::time::Duration::from_millis(619_000)],
|
||||
"transition to Complete pushes one e2e marker with the goal's total time",
|
||||
);
|
||||
|
||||
// A repeat Complete update (e.g. a late notification) must not
|
||||
// duplicate the marker.
|
||||
send(&mut app, "complete", 620_000);
|
||||
assert_eq!(
|
||||
goal_markers(&app).len(),
|
||||
1,
|
||||
"repeat Complete must not push a second marker",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_elapsed_is_monotonic_across_updates() {
|
||||
// The displayed elapsed must never tick backward when a notification's
|
||||
// authoritative base is below the already-extrapolated value;
|
||||
// `elapsed_floor_ms` clamps it.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
assert!(send_goal_update(&mut app, "g1", "active", 10_000));
|
||||
let a = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.live_elapsed_ms();
|
||||
assert!(a >= 10_000);
|
||||
|
||||
// Same goal, but a LOWER authoritative base (extrapolation outran the
|
||||
// shell's flush point).
|
||||
send_goal_update(&mut app, "g1", "active", 8_000);
|
||||
let b = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.live_elapsed_ms();
|
||||
assert!(b >= a, "elapsed must not tick backward: {b} < {a}");
|
||||
assert!(b >= 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleared_goal_is_not_resurrected_by_late_update() {
|
||||
// After a goal is cleared, a late in-flight GoalUpdated for the same
|
||||
// goal_id (queued before the clear) must be dropped so the "Done"
|
||||
// chip / modal stay cleared and don't resurrect.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
send_goal_update(&mut app, "g1", "complete", 5_000);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().goal_state.is_some(),
|
||||
"goal present after complete"
|
||||
);
|
||||
|
||||
// Clear (the cleared event itself carries an empty goal_id).
|
||||
send_goal_update(&mut app, "", "cleared", 0);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().goal_state.is_none(),
|
||||
"chip cleared on cleared status"
|
||||
);
|
||||
|
||||
// A stale late update for the cleared goal must not resurrect it.
|
||||
let affected = send_goal_update(&mut app, "g1", "complete", 5_000);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().goal_state.is_none(),
|
||||
"cleared goal must not resurrect"
|
||||
);
|
||||
assert!(!affected, "ignored stale update must not request a redraw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_goal_after_clear_is_not_suppressed() {
|
||||
// A genuinely new goal (different id) after a clear must start
|
||||
// normally — the cleared-id guard only drops the SAME id.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
send_goal_update(&mut app, "g1", "active", 1_000);
|
||||
send_goal_update(&mut app, "", "cleared", 0);
|
||||
assert!(send_goal_update(&mut app, "g2", "active", 500));
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.goal_state.as_ref().expect("new goal present").goal_id,
|
||||
"g2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_switch_resets_elapsed_floor() {
|
||||
// A NEW goal (different id) must start its own clock and NOT inherit
|
||||
// the prior goal's carried elapsed floor.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
send_goal_update(&mut app, "g1", "active", 10_000);
|
||||
// Switch directly to a different goal with a small elapsed base.
|
||||
send_goal_update(&mut app, "g2", "active", 500);
|
||||
let elapsed = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.live_elapsed_ms();
|
||||
assert!(
|
||||
elapsed < 5_000,
|
||||
"new goal must start from its own base, not the prior 10s floor: {elapsed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_updated_resolves_details_path_existence_on_receipt() {
|
||||
// The handler resolves last_classifier_details_path's existence ONCE
|
||||
// on receipt into the cached bool (no per-frame stat).
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
// A real on-disk path → cached exists = true.
|
||||
let f = tempfile::NamedTempFile::new().unwrap();
|
||||
let real_path = f.path().to_string_lossy().into_owned();
|
||||
let mut update = goal_update_value("g1", "active", 0);
|
||||
update["last_classifier_details_path"] = serde_json::json!(real_path);
|
||||
dispatch_goal_update(&mut app, update);
|
||||
let g = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(
|
||||
g.last_classifier_details_exists,
|
||||
"existing details path must cache exists = true"
|
||||
);
|
||||
assert_eq!(
|
||||
g.last_classifier_details_path.as_deref(),
|
||||
Some(real_path.as_str())
|
||||
);
|
||||
|
||||
// A missing path → cached exists = false (modal renders "(unavailable)").
|
||||
let mut update = goal_update_value("g1", "active", 0);
|
||||
update["last_classifier_details_path"] = serde_json::json!("/no/such/details-xyz.md");
|
||||
dispatch_goal_update(&mut app, update);
|
||||
let g = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!g.last_classifier_details_exists,
|
||||
"missing details path must cache exists = false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_updated_absent_optional_fields_deserialize_to_none() {
|
||||
// Rust-level forward-compat half: every additive
|
||||
// `Option<T>` field on `SessionUpdate::GoalUpdated` is allowed to
|
||||
// be omitted from the wire payload and must surface as `None` in
|
||||
// the destructured arm — i.e. the pager keeps mapping the known
|
||||
// subset cleanly when the shell-side struct grows or when an
|
||||
// older shell omits newer optional fields. Drop a handful of
|
||||
// optional keys from the payload and assert they materialise as
|
||||
// `None` on the resulting `GoalDisplayState`.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
let raw_payload = serde_json::json!({
|
||||
"sessionId": "sess-A",
|
||||
"update": {
|
||||
"sessionUpdate": "goal_updated",
|
||||
"goal_id": "g-min",
|
||||
"objective": "minimal payload",
|
||||
"status": "active",
|
||||
"phase": "idle",
|
||||
// token_budget omitted — Option<i64> must default to None.
|
||||
"tokens_used": 0,
|
||||
"elapsed_ms": 0,
|
||||
"total_deliverables": 0,
|
||||
"completed_deliverables": 0,
|
||||
// current_deliverable_idx omitted — Option<u32> -> None.
|
||||
// current_deliverable_title omitted — Option<String> -> None.
|
||||
// current_subagent_role omitted — Option<String> -> None.
|
||||
"total_worker_rounds": 0,
|
||||
"total_verify_rounds": 0,
|
||||
"token_baseline": 0,
|
||||
"finished_subagent_tokens": 0,
|
||||
// live_subagent_tokens omitted — Option<u64> -> None.
|
||||
// live_context_pct omitted — Option<u8> -> None.
|
||||
// live_turn_count omitted — Option<u32> -> None.
|
||||
// live_tool_call_count omitted — Option<u32> -> None.
|
||||
// last_event omitted — Option<String> -> None.
|
||||
// last_event_detail omitted — Option<String> -> None.
|
||||
// last_event_timestamp omitted — Option<String> -> None.
|
||||
// pause_message omitted — Option<String> -> None.
|
||||
}
|
||||
});
|
||||
let raw = serde_json::value::to_raw_value(&raw_payload).unwrap();
|
||||
let request = acp::ExtNotification::new("x.ai/session_notification", raw.into());
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let msg = AcpClientMessage::ExtNotification(kigi_acp_lib::AcpArgs {
|
||||
request,
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
assert!(
|
||||
affected,
|
||||
"minimal GoalUpdated for the active agent must request a redraw"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let goal = agent
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.expect("GoalUpdated must populate goal_state even with all Option fields omitted");
|
||||
|
||||
// Required fields landed as sent.
|
||||
assert_eq!(goal.goal_id, "g-min");
|
||||
assert_eq!(goal.objective, "minimal payload");
|
||||
assert_eq!(goal.status, GoalDisplayStatus::Active);
|
||||
assert_eq!(goal.phase, GoalDisplayPhase::Idle);
|
||||
assert_eq!(goal.tokens_used, 0);
|
||||
assert_eq!(goal.elapsed_ms, 0);
|
||||
assert_eq!(goal.total_deliverables, 0);
|
||||
assert_eq!(goal.completed_deliverables, 0);
|
||||
assert_eq!(goal.total_worker_rounds, 0);
|
||||
assert_eq!(goal.total_verify_rounds, 0);
|
||||
assert_eq!(goal.token_baseline, 0);
|
||||
assert_eq!(goal.finished_subagent_tokens, 0);
|
||||
|
||||
// Every omitted Option<T> wire field must surface as None — this
|
||||
// is the property that keeps the destructure stable as the shell
|
||||
// grows additive optional fields.
|
||||
assert_eq!(goal.token_budget, None, "token_budget");
|
||||
assert_eq!(goal.current_deliverable_id, None, "current_deliverable_id");
|
||||
assert_eq!(
|
||||
goal.current_deliverable_title, None,
|
||||
"current_deliverable_title"
|
||||
);
|
||||
assert_eq!(goal.current_subagent_role, None, "current_subagent_role");
|
||||
assert_eq!(goal.live_subagent_tokens, None, "live_subagent_tokens");
|
||||
assert!(
|
||||
goal.live_tokens_by_model.is_empty(),
|
||||
"omitted live_tokens_by_model must default to empty via #[serde(default)]"
|
||||
);
|
||||
assert_eq!(goal.live_context_pct, None, "live_context_pct");
|
||||
assert_eq!(goal.live_turn_count, None, "live_turn_count");
|
||||
assert_eq!(goal.live_tool_call_count, None, "live_tool_call_count");
|
||||
assert_eq!(goal.last_event, None, "last_event");
|
||||
assert_eq!(goal.last_event_detail, None, "last_event_detail");
|
||||
assert_eq!(goal.last_event_timestamp, None, "last_event_timestamp");
|
||||
assert_eq!(goal.pause_message, None, "pause_message");
|
||||
assert_eq!(
|
||||
goal.classifier_runs_attempted, None,
|
||||
"classifier_runs_attempted"
|
||||
);
|
||||
assert_eq!(goal.classifier_max_runs, None, "classifier_max_runs");
|
||||
assert_eq!(
|
||||
goal.last_classifier_verdict, None,
|
||||
"last_classifier_verdict"
|
||||
);
|
||||
assert_eq!(
|
||||
goal.last_classifier_details_path, None,
|
||||
"last_classifier_details_path"
|
||||
);
|
||||
assert!(
|
||||
!goal.verifying_completion,
|
||||
"verifying_completion defaults to false"
|
||||
);
|
||||
assert!(!goal.planning, "planning defaults to false");
|
||||
assert!(
|
||||
goal.deliverables.is_empty(),
|
||||
"deliverables is wire-compat-only in the simplified goal model"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn interaction_resolved_dismisses_matching_permission() {
|
||||
// A peer answered a shared permission → this pane retracts its copy.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let (msg, _rx) = make_permission_message("sess-1");
|
||||
handle(msg, &mut app);
|
||||
assert_eq!(app.agents[&AgentId(0)].permission_queue.len(), 1);
|
||||
|
||||
let changed = handle_session_notification(
|
||||
&interaction_resolved_ext("sess-1", "call-perm-1"),
|
||||
&mut app,
|
||||
);
|
||||
assert!(changed, "dismissing a visible permission must redraw");
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].permission_queue.is_empty(),
|
||||
"the resolved permission must be removed from the queue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_resolved_dismisses_matching_question() {
|
||||
use crate::views::question_view::QuestionViewState;
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let stashed = agent.prompt.stash();
|
||||
agent.question_view = Some(QuestionViewState::new("call-q".into(), vec![], stashed));
|
||||
}
|
||||
|
||||
let changed =
|
||||
handle_session_notification(&interaction_resolved_ext("sess-1", "call-q"), &mut app);
|
||||
assert!(changed, "dismissing a visible question must redraw");
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].question_view.is_none(),
|
||||
"the resolved question must be cleared"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_resolved_dismisses_matching_plan_approval() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let (ext, _rx) = make_exit_plan_ext_with_tool_call_id("call-plan", Some("# Plan"));
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
assert!(app.agents[&AgentId(0)].plan_approval_view.is_some());
|
||||
|
||||
let changed =
|
||||
handle_session_notification(&interaction_resolved_ext("sess-1", "call-plan"), &mut app);
|
||||
assert!(changed, "dismissing a visible plan approval must redraw");
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].plan_approval_view.is_none(),
|
||||
"the resolved plan approval must be cleared"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_resolved_is_noop_for_unknown_tool_call_id() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let (msg, _rx) = make_permission_message("sess-1");
|
||||
handle(msg, &mut app);
|
||||
|
||||
let changed = handle_session_notification(
|
||||
&interaction_resolved_ext("sess-1", "some-other-call"),
|
||||
&mut app,
|
||||
);
|
||||
assert!(!changed, "an unknown tool_call_id must be a silent no-op");
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)].permission_queue.len(),
|
||||
1,
|
||||
"an unrelated pending modal must be left intact"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_for_inactive_agent_queues_on_owning_agent() {
|
||||
// The headline behavior change in handle_permission_request:
|
||||
// permissions for an inactive owning agent now QUEUE (not cancel)
|
||||
// so the user sees them on switching back.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let (msg, mut rx) = make_permission_message("sess-A");
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.permission_queue.len(),
|
||||
1,
|
||||
"permission for inactive A must queue on A's permission_queue"
|
||||
);
|
||||
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert_eq!(
|
||||
agent_b.permission_queue.len(),
|
||||
0,
|
||||
"active B's permission_queue must remain empty"
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"permission queued on a non-active agent must not request a redraw"
|
||||
);
|
||||
// Permission is still pending; the response_tx must still be alive
|
||||
// (no auto-cancel was sent).
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"permission must NOT have been answered yet (queued, not cancelled)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_user_question_routes_to_background_session_not_active_view() {
|
||||
// Repro of the dashboard bug: a session started but not entered asks a
|
||||
// question. Active view is agent A (sess-A); the question is for the
|
||||
// BACKGROUND agent B (sess-B). It must land on B, not fail or land on A.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
assert_eq!(app.active_view, ActiveView::Agent(AgentId(0)));
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let raw = serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"sessionId": "sess-B",
|
||||
"toolCallId": "tc-bg",
|
||||
"questions": [],
|
||||
"mode": "default",
|
||||
}))
|
||||
.unwrap();
|
||||
let msg = AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/ask_user_question", raw.into()),
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(
|
||||
!affected,
|
||||
"a background-session question must not redraw the active view"
|
||||
);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(1)).unwrap().question_view.is_some(),
|
||||
"question must be parked on the session that asked (background agent B)"
|
||||
);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().question_view.is_none(),
|
||||
"question must NOT land on the unrelated active agent A"
|
||||
);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"response must NOT be sent yet (parked, waiting for user)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_user_question_unknown_session_parks_without_error() {
|
||||
// No local view for the session, and the active agent HAS a session_id
|
||||
// (so the race-window fallback does not fire). The reverse-request must
|
||||
// be left UNANSWERED (dropped) — NOT failed with an error, which would
|
||||
// render the tool red. Leader replay-on-attach handles it later.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let raw = serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"sessionId": "sess-unknown",
|
||||
"toolCallId": "tc-unknown",
|
||||
"questions": [],
|
||||
"mode": "default",
|
||||
}))
|
||||
.unwrap();
|
||||
let msg = AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/ask_user_question", raw.into()),
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(!affected);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().question_view.is_none(),
|
||||
"must not attach the question to an unrelated active agent"
|
||||
);
|
||||
// A dropped oneshot sender yields `Closed`; `Empty` would mean still
|
||||
// held open, `Ok` would mean a (failing) response was sent.
|
||||
match rx.try_recv() {
|
||||
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {}
|
||||
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
|
||||
panic!("response_tx must be dropped (parked), not held open")
|
||||
}
|
||||
Ok(_) => panic!("must NOT send any response — that would fail/resolve the tool"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_for_inactive_yolo_agent_auto_approves() {
|
||||
// YOLO mode is honored on the OWNING agent, not the active one,
|
||||
// so background turns aren't blocked waiting for a switch.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.yolo_mode = true;
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let (msg, rx) = make_permission_message("sess-A");
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(!affected, "YOLO auto-approve never needs a redraw");
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.permission_queue.len(),
|
||||
0,
|
||||
"YOLO must auto-approve in place of queueing"
|
||||
);
|
||||
let response = rx
|
||||
.blocking_recv()
|
||||
.expect("YOLO must have sent a response on response_tx");
|
||||
let resp = response.expect("YOLO response must be Ok");
|
||||
match resp.outcome {
|
||||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome {
|
||||
option_id,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(option_id.0.as_ref(), "allow-once");
|
||||
}
|
||||
other => panic!("expected Selected, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_for_unknown_session_id_is_cancelled() {
|
||||
// No agent owns the session and the active agent already has a
|
||||
// session_id (so the race-window fallback does not fire). The
|
||||
// permission must be cancelled rather than queued anywhere.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
// make_app_with_agent already activated AgentId(0); no switch needed.
|
||||
|
||||
let (msg, rx) = make_permission_message("sess-unknown");
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(!affected);
|
||||
for id in [AgentId(0), AgentId(1)] {
|
||||
assert_eq!(
|
||||
app.agents.get(&id).unwrap().permission_queue.len(),
|
||||
0,
|
||||
"no agent should have queued the unknown-session permission",
|
||||
);
|
||||
}
|
||||
let response = rx
|
||||
.blocking_recv()
|
||||
.expect("cancel_permission must have sent a response");
|
||||
let resp = response.expect("response must be Ok");
|
||||
assert!(
|
||||
matches!(resp.outcome, acp::RequestPermissionOutcome::Cancelled),
|
||||
"unknown session_id permissions must be cancelled, got {:?}",
|
||||
resp.outcome,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plan approval persistence tests ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn close_viewer_preserves_plan_approval_state() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-A".into(),
|
||||
tool_call_id: "tc-persist".into(),
|
||||
plan_content: Some("# Plan\nDo stuff".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
handle(
|
||||
AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.plan_approval_view.is_some(), "approval should be set");
|
||||
|
||||
// Close the viewer (simulates Esc / close button).
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.cancel_line_viewer();
|
||||
|
||||
// Approval state must survive the close.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.plan_approval_view.is_some(),
|
||||
"plan_approval_view must persist after viewer close"
|
||||
);
|
||||
assert!(agent.line_viewer.is_none(), "viewer should be closed");
|
||||
|
||||
// Response must NOT have been sent (still waiting for user).
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"response must not be sent on viewer close"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_viewer_restores_approval_buttons() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
// Seed a CreatePlan tool so the source is Inline (plan content
|
||||
// is carried in the ext_method params, not read from disk).
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "tc-reopen", "CreatePlan");
|
||||
}
|
||||
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-A".into(),
|
||||
tool_call_id: "tc-reopen".into(),
|
||||
plan_content: Some("# Plan\nStep 1".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
handle(
|
||||
AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Close viewer.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.cancel_line_viewer();
|
||||
assert!(agent.line_viewer.is_none());
|
||||
|
||||
// Reopen plan preview — inline content is in plan_approval_view.plan_content.
|
||||
agent.show_plan_preview();
|
||||
|
||||
assert!(agent.line_viewer.is_some(), "viewer should reopen");
|
||||
assert!(
|
||||
agent.line_viewer.as_ref().unwrap().feedback_active(),
|
||||
"feedback_active must be true after reopen"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approve_after_reopen_does_not_overwrite_prompt() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "tc-prompt", "CreatePlan");
|
||||
}
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-A".into(),
|
||||
tool_call_id: "tc-prompt".into(),
|
||||
plan_content: Some("# Plan\nDo things".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
handle(
|
||||
AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Close viewer.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.cancel_line_viewer();
|
||||
|
||||
// User types new text in the prompt while viewer is closed.
|
||||
agent.prompt.set_text("my new prompt text");
|
||||
|
||||
agent.reopen_plan_approval();
|
||||
agent.approve_plan();
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.prompt.text(),
|
||||
"my new prompt text",
|
||||
"stashed prompt should be restored after reopen + approve"
|
||||
);
|
||||
|
||||
// Response should be approved.
|
||||
let response = rx.blocking_recv().expect("should have sent response");
|
||||
let raw = response.expect("should be Ok");
|
||||
let parsed: serde_json::Value = serde_json::from_str(raw.0.get()).unwrap();
|
||||
assert_eq!(parsed["outcome"], "approved");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,812 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
/// Regression: a shared-queue interjection renders only via the broadcast,
|
||||
/// and the shell emits the queue-emptying `x.ai/queue/changed` right after
|
||||
/// it — which used to fire the withheld parked marker BELOW the just-
|
||||
/// rendered user message ("Worked for …" under the follow-up, flipped
|
||||
/// transcript order). The broadcast must consume the marker slot instead.
|
||||
#[test]
|
||||
fn interjection_broadcast_mid_park_forgoes_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
simulate_task_output_wait(agent, "bg-1");
|
||||
assert!(agent.is_parked_on_sendable_wait());
|
||||
}
|
||||
|
||||
assert!(handle_ext_notification(
|
||||
&interjection_broadcast("sess-park", "queued follow-up"),
|
||||
&mut app,
|
||||
));
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.parked_wait_marker_for,
|
||||
Some(crate::app::agent_view::ParkedMarkerSlot::Forgone(
|
||||
"p1".into()
|
||||
)),
|
||||
"broadcast render must consume the parked-marker slot as Forgone"
|
||||
);
|
||||
// The queue-changed following the broadcast must not fire it late.
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(
|
||||
count_parked(agent),
|
||||
0,
|
||||
"no late 'Worked for …' marker under the interjection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: a Forgone slot (interjection continued
|
||||
/// the parked turn, no marker on screen) must also silence the countdown
|
||||
/// — a full "Worked for …" tick under the interjected message would
|
||||
/// recreate the flipped transcript. Rendered slots keep ticking.
|
||||
#[test]
|
||||
fn forgone_slot_suppresses_countdown_ticks() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
insert_running_task(agent, "t15", "sleep 15");
|
||||
simulate_task_output_wait(agent, "t15");
|
||||
// The parked drain interjected a queued row before the marker
|
||||
// became eligible: slot consumed WITHOUT a marker.
|
||||
agent.suppress_parked_marker_on_interject();
|
||||
assert!(agent.renders_parked(), "forgone slot keeps parked chrome");
|
||||
assert_eq!(count_parked(agent), 0, "no marker on screen");
|
||||
}
|
||||
|
||||
// A task completing in the still-parked window must stay silent.
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
count_parked(agent),
|
||||
0,
|
||||
"no 'Worked for …' tick under the interjection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Feature: "sleep 10, 15, 20 in the background" — while the turn is
|
||||
/// parked, each task completion appends a fresh FULL marker with the
|
||||
/// remaining count, so the user watches it tick down (3 → 2 → 1), each
|
||||
/// line a complete "Worked for X. N commands still running.".
|
||||
/// The last completion pushes nothing (0/0): the wait returns and the
|
||||
/// real completion marker narrates the end. (Elapsed renders as "0.0s":
|
||||
/// `turn_started_at` is unset in this fixture.)
|
||||
#[test]
|
||||
fn parked_countdown_ticks_down_as_tasks_complete() {
|
||||
use crate::app::agent_view::test_fixtures::simulate_task_output_wait;
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
insert_running_task(agent, "t15", "sleep 15");
|
||||
insert_running_task(agent, "t20", "sleep 20");
|
||||
simulate_task_output_wait(agent, "t20");
|
||||
agent.maybe_push_parked_marker();
|
||||
assert!(agent.renders_parked());
|
||||
}
|
||||
|
||||
// sleep 10 exits → full marker with "2 commands still running."
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
// Duplicate completion for the same task: not a Running→Done edge.
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
// sleep 15 exits → full marker with "1 command still running."
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t15", "sleep 15", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
// sleep 20 exits → nothing left; no "0 commands" line.
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t20", "sleep 20", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec![
|
||||
"Worked for 0.0s. 3 commands still running.".to_string(),
|
||||
"Worked for 0.0s. 2 commands still running.".to_string(),
|
||||
"Worked for 0.0s. 1 command still running.".to_string(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_subagent_finishes_refresh_one_uncommitted_marker() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
park_on_subagents(agent, &["child-1", "child-2", "child-3"])
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 2 subagents still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
|
||||
// Re-delivered finish for an already-finished subagent: not an edge.
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 2 subagents still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-2")),
|
||||
&mut app,
|
||||
);
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-3")),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 subagent still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_text_thought_and_tool_output_start_new_subagent_segments() {
|
||||
use crate::acp::meta::NotificationMeta;
|
||||
use crate::app::agent_view::test_fixtures::simulate_task_output_wait_call;
|
||||
|
||||
crate::appearance::cache::set_show_thinking_blocks(true);
|
||||
for output_kind in ["text", "thought", "tool"] {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let first_marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
for child_id in ["child-1", "child-2", "child-3"] {
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_id.into(), make_subagent_info(child_id));
|
||||
}
|
||||
if output_kind == "tool" {
|
||||
assert!(agent.session.tracker.handle_update(
|
||||
acp::SessionUpdate::ToolCall(
|
||||
acp::ToolCall::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("parent-tool")),
|
||||
"read_file",
|
||||
)
|
||||
.kind(acp::ToolKind::Read)
|
||||
.status(acp::ToolCallStatus::InProgress)
|
||||
.content(vec![])
|
||||
.locations(vec![]),
|
||||
),
|
||||
&NotificationMeta::default(),
|
||||
&mut agent.scrollback,
|
||||
));
|
||||
}
|
||||
simulate_task_output_wait_call(agent, "wait-1", "not-ours", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
parked_marker_ids(agent)[0]
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let output = match output_kind {
|
||||
"text" => acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
|
||||
acp::ContentBlock::Text(acp::TextContent::new("parent text")),
|
||||
)),
|
||||
"thought" => acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
|
||||
acp::ContentBlock::Text(acp::TextContent::new("parent thought")),
|
||||
)),
|
||||
"tool" => acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("parent-tool")),
|
||||
acp::ToolCallUpdateFields::new()
|
||||
.status(Some(acp::ToolCallStatus::Completed)),
|
||||
)),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(agent.session.tracker.handle_update(
|
||||
output,
|
||||
&NotificationMeta::default(),
|
||||
&mut agent.scrollback,
|
||||
));
|
||||
simulate_task_output_wait_call(agent, "wait-2", "not-ours", 30_000);
|
||||
}
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-2")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec![
|
||||
"Worked for 0.0s. 2 subagents still running.".to_string(),
|
||||
"Worked for 0.0s. 1 subagent still running.".to_string(),
|
||||
],
|
||||
"{output_kind} output must start a new segment",
|
||||
);
|
||||
let marker_ids = parked_marker_ids(agent);
|
||||
assert_eq!(marker_ids.len(), 2);
|
||||
assert_eq!(marker_ids[0], first_marker_id);
|
||||
assert_ne!(marker_ids[0], marker_ids[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_subagent_marker_appends_fallback() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let first_marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let marker_id = park_on_subagents(agent, &["child-1", "child-2"]);
|
||||
let marker_index = (0..agent.scrollback.len())
|
||||
.find(|&index| agent.scrollback.get(index).is_some_and(|entry| entry.id == marker_id))
|
||||
.unwrap();
|
||||
agent.scrollback.mark_committed(marker_index);
|
||||
marker_id
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec![
|
||||
"Worked for 0.0s. 2 subagents still running.".to_string(),
|
||||
"Worked for 0.0s. 1 subagent still running.".to_string(),
|
||||
],
|
||||
);
|
||||
let marker_ids = parked_marker_ids(agent);
|
||||
assert_eq!(marker_ids.len(), 2);
|
||||
assert_eq!(marker_ids[0], first_marker_id);
|
||||
assert_ne!(marker_ids[0], marker_ids[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_subagent_marker_handle_appends_fallback() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let old_marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
park_on_subagents(agent, &["child-1", "child-2"])
|
||||
};
|
||||
assert!(app
|
||||
.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.scrollback
|
||||
.remove_entry(old_marker_id));
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 subagent still running.".to_string()],
|
||||
);
|
||||
let marker_ids = parked_marker_ids(agent);
|
||||
assert_eq!(marker_ids.len(), 1);
|
||||
assert_ne!(marker_ids[0], old_marker_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_suppresses_later_subagent_refresh() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
park_on_subagents(agent, &["child-1", "child-2", "child-3"])
|
||||
};
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
assert!(handle_ext_notification(
|
||||
&interjection_broadcast("sess-park", "continue differently"),
|
||||
&mut app,
|
||||
));
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-2")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 2 subagents still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replayed_subagent_finish_does_not_refresh_marker() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let marker_id = park_on_subagents(agent, &["child-1", "child-2"]);
|
||||
agent.session.loading_replay = true;
|
||||
marker_id
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 2 subagents still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imminent_subagent_wait_does_not_refresh_marker() {
|
||||
use crate::app::agent_view::test_fixtures::simulate_task_output_wait;
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
for child_id in ["child-1", "child-2"] {
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_id.into(), make_subagent_info(child_id));
|
||||
}
|
||||
simulate_task_output_wait(agent, "child-1");
|
||||
agent.maybe_push_parked_marker();
|
||||
parked_marker_ids(agent)[0]
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 2 subagents still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
}
|
||||
|
||||
/// Synthetic completions from cold-load reconciliation (`session_restart`
|
||||
/// signal) finalize quietly — no countdown line, mirroring the suppressed
|
||||
/// "Task failed" block: nothing happened in THIS session.
|
||||
#[test]
|
||||
fn stale_on_load_completion_pushes_no_countdown() {
|
||||
use crate::app::agent_view::test_fixtures::simulate_task_output_wait;
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
insert_running_task(agent, "t15", "sleep 15");
|
||||
simulate_task_output_wait(agent, "t15");
|
||||
agent.maybe_push_parked_marker();
|
||||
assert!(agent.renders_parked());
|
||||
}
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif_with_signal(
|
||||
"sess-park",
|
||||
"t10",
|
||||
"sleep 10",
|
||||
None,
|
||||
Some("session_restart"),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
// Only the initial parked marker — no countdown re-push.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(parked_marker_messages(agent).len(), 1);
|
||||
}
|
||||
|
||||
/// Task completions with no parked look (running turn chrome is up, or
|
||||
/// the turn already ended) must not emit countdown lines — the Tasks
|
||||
/// pane and completion blocks already narrate those states.
|
||||
#[test]
|
||||
fn task_completion_without_parked_look_pushes_no_countdown() {
|
||||
let mut app = make_app_with_agent("sess-live");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
insert_running_task(agent, "t15", "sleep 15");
|
||||
// No wait, no parked marker: chrome is the live turn.
|
||||
}
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-live", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert!(parked_marker_messages(agent).is_empty());
|
||||
}
|
||||
|
||||
// -- imminent waits do not park (awaited work already finished) ----------
|
||||
|
||||
/// Waiting on a task that already completed: no marker, slot stays free.
|
||||
#[test]
|
||||
fn wait_on_already_completed_task_pushes_no_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
agent.session.bg_tasks.get_mut("t10").unwrap().status = BgTaskStatus::Done;
|
||||
|
||||
simulate_task_output_wait(agent, "t10");
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 0, "imminent wait must not park");
|
||||
assert!(
|
||||
agent.parked_wait_marker_for.is_none(),
|
||||
"slot must stay free for a later genuine park"
|
||||
);
|
||||
assert!(!agent.renders_parked());
|
||||
}
|
||||
|
||||
/// A skipped wait leaves the slot free: a later wait on running work in
|
||||
/// the same turn still parks.
|
||||
#[test]
|
||||
fn later_genuine_wait_still_parks_after_imminent_wait_skip() {
|
||||
use crate::app::agent_view::test_fixtures::{
|
||||
complete_task_output_wait_call, count_parked, simulate_task_output_wait_call,
|
||||
};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "done", "sleep 1");
|
||||
agent.session.bg_tasks.get_mut("done").unwrap().status = BgTaskStatus::Done;
|
||||
|
||||
simulate_task_output_wait_call(agent, "wait-1", "done", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0);
|
||||
|
||||
complete_task_output_wait_call(agent, "wait-1");
|
||||
insert_running_task(agent, "live", "sleep 99");
|
||||
simulate_task_output_wait_call(agent, "wait-2", "live", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 1, "genuine park still renders");
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 command still running.".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Failed` is terminal for imminence, not just `Done`.
|
||||
#[test]
|
||||
fn wait_on_failed_task_pushes_no_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
agent.session.bg_tasks.get_mut("t10").unwrap().status = BgTaskStatus::Failed;
|
||||
|
||||
simulate_task_output_wait(agent, "t10");
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 0, "failed task wait must not park");
|
||||
assert!(agent.parked_wait_marker_for.is_none());
|
||||
}
|
||||
|
||||
/// Finished-subagent waits do not park — resolved by subagent id, then by
|
||||
/// child session id.
|
||||
#[test]
|
||||
fn wait_on_finished_subagent_pushes_no_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{
|
||||
complete_task_output_wait_call, count_parked, simulate_task_output_wait_call,
|
||||
};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
let mut info = make_subagent_info("child-1");
|
||||
info.finished = true;
|
||||
agent.subagent_sessions.insert("child-1".into(), info);
|
||||
|
||||
simulate_task_output_wait_call(agent, "wait-1", "sa-child-1", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "finished subagent wait must not park");
|
||||
assert!(agent.parked_wait_marker_for.is_none());
|
||||
|
||||
complete_task_output_wait_call(agent, "wait-1");
|
||||
simulate_task_output_wait_call(agent, "wait-2", "child-1", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "child-session-id wait must not park");
|
||||
assert!(agent.parked_wait_marker_for.is_none());
|
||||
}
|
||||
|
||||
/// One unresolvable id among terminal ones keeps the park.
|
||||
#[test]
|
||||
fn wait_including_unknown_id_still_parks() {
|
||||
use crate::acp::meta::NotificationMeta;
|
||||
use crate::app::agent_view::test_fixtures::count_parked;
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "done", "sleep 1");
|
||||
agent.session.bg_tasks.get_mut("done").unwrap().status = BgTaskStatus::Done;
|
||||
|
||||
let meta = NotificationMeta::default();
|
||||
agent.session.handle_update(
|
||||
acp::SessionUpdate::ToolCall(
|
||||
acp::ToolCall::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("wait-1")),
|
||||
"get_command_or_subagent_output",
|
||||
)
|
||||
.kind(acp::ToolKind::Other)
|
||||
.status(acp::ToolCallStatus::Pending)
|
||||
.content(vec![])
|
||||
.locations(vec![]),
|
||||
),
|
||||
&meta,
|
||||
&mut agent.scrollback,
|
||||
);
|
||||
agent.session.handle_update(
|
||||
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("wait-1")),
|
||||
acp::ToolCallUpdateFields::new().raw_input(Some(serde_json::json!({
|
||||
"task_ids": ["done", "not-ours"],
|
||||
"timeout_ms": 30_000,
|
||||
}))),
|
||||
)),
|
||||
&meta,
|
||||
&mut agent.scrollback,
|
||||
);
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 1, "unresolvable id keeps the park");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_all_with_zero_work_pushes_no_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 0, "zero-work wait-all must not park");
|
||||
assert!(agent.parked_wait_marker_for.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_all_with_running_work_still_parks() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 1, "wait-all on live work parks");
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 command still running.".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
/// `SubagentSpawned` arriving after the skipped zero-work wait
|
||||
/// re-evaluates and restores the park.
|
||||
#[test]
|
||||
fn subagent_spawn_after_zero_work_wait_all_restores_park() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "zero-work wait-all skipped");
|
||||
}
|
||||
|
||||
handle(
|
||||
make_ext_session_notification(
|
||||
"sess-park",
|
||||
test_subagent_spawned("sess-park", "child-1"),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(count_parked(agent), 1, "spawn re-evaluates the skipped park");
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 subagent still running.".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
/// `x.ai/task_backgrounded` arriving after the skipped zero-work wait
|
||||
/// re-evaluates and restores the park.
|
||||
#[test]
|
||||
fn task_backgrounded_after_zero_work_wait_all_restores_park() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "zero-work wait-all skipped");
|
||||
}
|
||||
|
||||
handle_ext_notification(
|
||||
&make_task_backgrounded_notif("sess-park", "tc-late", "t-late", "sleep 99"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
count_parked(agent),
|
||||
1,
|
||||
"task registration re-evaluates the skipped park"
|
||||
);
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 command still running.".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_pushes_block_to_matching_session() {
|
||||
// Multi-client fix: an interjection typed in one pane is broadcast by
|
||||
// the shell as x.ai/session/interjection; EVERY attached pane (incl.
|
||||
// the originator, which no longer pushes a local block) renders it.
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
let affected =
|
||||
handle_ext_notification(&interjection_ext("sess-view", "also add tests"), &mut app);
|
||||
assert!(affected, "rendering into the active agent should redraw");
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
last_interjection_text(&agent.scrollback).as_deref(),
|
||||
Some("also add tests"),
|
||||
"the interjection block must be pushed from the broadcast"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_for_unknown_session_is_ignored() {
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
let affected = handle_ext_notification(&interjection_ext("sess-other", "stray"), &mut app);
|
||||
assert!(!affected, "an unmatched session must be a no-op");
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
last_interjection_text(&agent.scrollback).is_none(),
|
||||
"no interjection block must be pushed for an unknown session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_renders_for_a_viewer() {
|
||||
// A viewer (attached_as_viewer) watching another client's session must
|
||||
// also render interjections broadcast for that session.
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().attached_as_viewer = true;
|
||||
let affected =
|
||||
handle_ext_notification(&interjection_ext("sess-view", "viewer sees this"), &mut app);
|
||||
assert!(affected);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
last_interjection_text(&agent.scrollback).as_deref(),
|
||||
Some("viewer sees this"),
|
||||
"a viewer must render interjections broadcast for its session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_dedups_originators_own_echo() {
|
||||
// The originator rendered an optimistic block in dispatch_interject and
|
||||
// recorded the id; its own broadcast echo must be dropped (no dup) and
|
||||
// the id forgotten.
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.self_interjection_ids
|
||||
.insert("ij-1".to_string());
|
||||
|
||||
let affected = handle_ext_notification(
|
||||
&interjection_ext_with_id("sess-view", "my own", Some("ij-1")),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"an originator's own echo must be a no-op (already rendered locally)"
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
last_interjection_text(&agent.scrollback).is_none(),
|
||||
"the echo must not push a duplicate block"
|
||||
);
|
||||
assert!(
|
||||
!agent.self_interjection_ids.contains("ij-1"),
|
||||
"the id must be forgotten after dedup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_with_foreign_id_renders() {
|
||||
// A broadcast carrying an id this client did NOT mint (another pane's
|
||||
// interjection) must render — only the originator dedups by its own id.
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
let affected = handle_ext_notification(
|
||||
&interjection_ext_with_id("sess-view", "from another pane", Some("other-id")),
|
||||
&mut app,
|
||||
);
|
||||
assert!(affected);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
last_interjection_text(&agent.scrollback).as_deref(),
|
||||
Some("from another pane"),
|
||||
"an interjection from another pane must render"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,727 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mcp_init_progress_updates_seeded_progress_in_place() {
|
||||
// When a session is seeded with mcp_init_progress{0,0}, a
|
||||
// subsequent init_progress notification must update total and
|
||||
// connected IN PLACE — preserving started_at for timer accuracy.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 0,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
let started_at = agent.mcp_init_progress.as_ref().unwrap().started_at;
|
||||
|
||||
let notif = make_mcp_init_progress_notif(5, 0);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let progress = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!(progress.total, 5, "total must be updated from shell");
|
||||
assert_eq!(progress.connected, 0);
|
||||
assert_eq!(
|
||||
progress.started_at, started_at,
|
||||
"started_at must be preserved (timer anchoring)",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_status_routes_to_owning_agent() {
|
||||
use crate::views::extensions_modal::TabDataState;
|
||||
use crate::views::mcps_modal::McpServerDisplayStatus;
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
|
||||
// Agent 0 owns sess-owner; Agent 1 is foregrounded.
|
||||
let mut app = make_app_two_agents();
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
// Give the active agent its own modal so we can prove it's untouched.
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(vec![
|
||||
crate::views::mcps_modal::McpServerInfo {
|
||||
name: "alpha".into(),
|
||||
display_name: None,
|
||||
status: McpServerDisplayStatus::Initializing,
|
||||
tool_count: 0,
|
||||
auth_required: false,
|
||||
tools: Vec::new(),
|
||||
enabled: true,
|
||||
source: "local".into(),
|
||||
wire_source: crate::views::mcps_modal::McpWireSource::Local,
|
||||
plugin_name: None,
|
||||
is_managed_gateway: false,
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
let tools = Some(serde_json::json!([
|
||||
{ "name": "t1", "description": "one", "enabled": true },
|
||||
{ "name": "t2", "enabled": true },
|
||||
]));
|
||||
let notif = make_server_status_notif("sess-owner", "alpha", McpServerStatus::Ready, tools);
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(
|
||||
!redraw,
|
||||
"owner is background — mutation must not request a redraw"
|
||||
);
|
||||
|
||||
// Owner mutated.
|
||||
let owner_modal = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let TabDataState::Loaded(ref owner_servers) = owner_modal.mcps_data else {
|
||||
panic!("owner modal must still be in Loaded state");
|
||||
};
|
||||
assert_eq!(owner_servers[0].status, McpServerDisplayStatus::Ready);
|
||||
assert_eq!(owner_servers[0].tool_count, 2);
|
||||
assert_eq!(owner_servers[0].tools.len(), 2);
|
||||
|
||||
// Active agent's modal must be untouched.
|
||||
let active_modal = app
|
||||
.agents
|
||||
.get(&AgentId(1))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let TabDataState::Loaded(ref active_servers) = active_modal.mcps_data else {
|
||||
panic!("active modal must still be in Loaded state");
|
||||
};
|
||||
assert_eq!(
|
||||
active_servers[0].status,
|
||||
McpServerDisplayStatus::Initializing,
|
||||
"active-view agent must not absorb the owning agent's push"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_init_progress_creates_when_none() {
|
||||
// When mcp_init_progress is None (no seed), init_progress
|
||||
// creates a fresh McpInitProgress.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
assert!(app.agents[&AgentId(0)].mcp_init_progress.is_none());
|
||||
|
||||
let notif = make_mcp_init_progress_notif(3, 1);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let progress = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!(progress.total, 3);
|
||||
assert_eq!(progress.connected, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_initialized_clears_progress() {
|
||||
// x.ai/mcp_initialized must set mcp_init_progress to None.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 3,
|
||||
connected: 3,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
|
||||
let notif = make_mcp_initialized_notif("sess-1");
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(changed);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_none(),
|
||||
"mcp_initialized must clear mcp_init_progress",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_full_lifecycle_seed_to_clear() {
|
||||
// Full N-server lifecycle:
|
||||
// seed(0/0) → init_progress(0/3) → init_progress(2/3)
|
||||
// → init_progress(3/3) → mcp_initialized → None
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 0,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
|
||||
// Shell reports real count.
|
||||
handle_ext_notification(&make_mcp_init_progress_notif(3, 0), &mut app);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((p.total, p.connected), (3, 0));
|
||||
|
||||
// Incremental progress.
|
||||
handle_ext_notification(&make_mcp_init_progress_notif(3, 2), &mut app);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((p.total, p.connected), (3, 2));
|
||||
|
||||
// All connected.
|
||||
handle_ext_notification(&make_mcp_init_progress_notif(3, 3), &mut app);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((p.total, p.connected), (3, 3));
|
||||
|
||||
// mcp_initialized clears everything.
|
||||
handle_ext_notification(&make_mcp_initialized_notif("sess-1"), &mut app);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_none(),
|
||||
"mcp_initialized must clear progress after full lifecycle",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_zero_server_lifecycle() {
|
||||
// 0-server lifecycle (the bug scenario):
|
||||
// seed(0/0) → init_progress(0/0) → mcp_initialized → None
|
||||
// Previously mcp_initialized was never sent for 0 servers,
|
||||
// leaving a stuck progress indicator.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 0,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
|
||||
// Shell sends 0/0 for the 0-server case.
|
||||
handle_ext_notification(&make_mcp_init_progress_notif(0, 0), &mut app);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((p.total, p.connected), (0, 0));
|
||||
|
||||
// Shell now sends mcp_initialized (root-cause fix).
|
||||
handle_ext_notification(&make_mcp_initialized_notif("sess-1"), &mut app);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_none(),
|
||||
"0-server mcp_initialized must clear progress",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_init_progress_routes_to_background_session() {
|
||||
// init_progress carrying a background session's sessionId must update
|
||||
// *that* agent's indicator, not the foregrounded one, and must not
|
||||
// force a redraw (the background spinner isn't visible).
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.insert(AgentId(1), make_agent(Some("sess-B")));
|
||||
|
||||
let notif = make_mcp_init_progress_notif_for(4, 1, "sess-B");
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
!changed,
|
||||
"background-session progress must not force a redraw"
|
||||
);
|
||||
|
||||
let bg = app.agents[&AgentId(1)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((bg.total, bg.connected), (4, 1));
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_none(),
|
||||
"foreground agent must be untouched by a background session's progress",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_initialized_routes_to_background_session() {
|
||||
// mcp_initialized for a background session must clear *that* agent's
|
||||
// indicator while leaving the foreground agent's intact. Previously
|
||||
// the clear was applied to whichever agent was active, so a
|
||||
// background agent's spinner could stick forever.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.insert(AgentId(1), make_agent(Some("sess-B")));
|
||||
for id in [AgentId(0), AgentId(1)] {
|
||||
app.agents.get_mut(&id).unwrap().mcp_init_progress =
|
||||
Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 2,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
let notif = make_mcp_initialized_notif_for("sess-B");
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
!changed,
|
||||
"clearing a background spinner must not force a redraw",
|
||||
);
|
||||
assert!(
|
||||
app.agents[&AgentId(1)].mcp_init_progress.is_none(),
|
||||
"background session's spinner must be cleared",
|
||||
);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_some(),
|
||||
"foreground agent's spinner must NOT be cleared by another session",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_initialized_unknown_session_is_dropped() {
|
||||
// An mcp_initialized for a session that matches no agent must not
|
||||
// clear anyone's indicator (no misrouting to the active agent).
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().mcp_init_progress =
|
||||
Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 1,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
|
||||
let notif = make_mcp_initialized_notif_for("sess-unknown");
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_some(),
|
||||
"unknown-session mcp_initialized must not clear the active agent",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_lifecycle_notif_for_subagent_session_is_dropped() {
|
||||
// A subagent runs its own MCP init, emitting init_progress /
|
||||
// mcp_initialized under the *child* session id. Those must NOT write to
|
||||
// or clear the parent agent's mcp_init_progress — it's a per-root-agent
|
||||
// indicator with no subagent slot, so a subagent's init must not
|
||||
// clobber the parent's spinner.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().mcp_init_progress =
|
||||
Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 2,
|
||||
connected: 1,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
// Register a subagent child view keyed by the child session id.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.subagent_views
|
||||
.insert(
|
||||
"child-sess".to_string(),
|
||||
Box::new(make_agent(Some("child-sess"))),
|
||||
);
|
||||
|
||||
// init_progress for the child session must leave the parent untouched.
|
||||
let changed = handle_ext_notification(
|
||||
&make_mcp_init_progress_notif_for(5, 0, "child-sess"),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
!changed,
|
||||
"subagent init_progress must not redraw the parent"
|
||||
);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(p.total, p.connected),
|
||||
(2, 1),
|
||||
"parent spinner must be untouched by a subagent's init",
|
||||
);
|
||||
|
||||
// mcp_initialized for the child session must not clear the parent.
|
||||
let changed =
|
||||
handle_ext_notification(&make_mcp_initialized_notif_for("child-sess"), &mut app);
|
||||
assert!(!changed);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_some(),
|
||||
"subagent mcp_initialized must not clear the parent's spinner",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_status_handler_noop_when_modal_closed_background() {
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
// Owner is background and has NO modal open. server_status
|
||||
// must be a silent no-op (no Effect scheduling, no redraw).
|
||||
let notif = make_server_status_notif("sess-owner", "alpha", McpServerStatus::Ready, None);
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(!redraw, "closed-modal cheap path must not request a redraw");
|
||||
assert!(
|
||||
app.pending_effects.is_empty(),
|
||||
"closed-modal cheap path must not schedule any effects"
|
||||
);
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.is_none(),
|
||||
"owner modal must remain closed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pin the cheap-path semantics for the FOREGROUND case too.
|
||||
/// Without this, the background case passes trivially regardless
|
||||
/// of how the cheap path is gated on `is_active`.
|
||||
#[test]
|
||||
fn server_status_handler_noop_when_modal_closed_foreground() {
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
// Foreground = agent 1 (sess-active). Send a push targeting
|
||||
// the foregrounded agent, no modal open.
|
||||
let notif = make_server_status_notif("sess-active", "alpha", McpServerStatus::Ready, None);
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(
|
||||
!redraw,
|
||||
"foreground + closed-modal must still be a no-op (no row → no redraw)"
|
||||
);
|
||||
assert!(
|
||||
app.pending_effects.is_empty(),
|
||||
"server_status NEVER schedules an effect (push is per-row, not list-refetch)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pin the cheap-path semantics when the modal is open but the
|
||||
/// data is still loading. The `TabDataState::Loaded`
|
||||
/// gate must early-return without panicking.
|
||||
#[test]
|
||||
fn server_status_handler_noop_when_modal_data_still_loading() {
|
||||
use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab, TabDataState};
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
{
|
||||
let owner = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let mut modal = ExtensionsModalState::new(ExtensionsTab::McpServers);
|
||||
modal.mcps_data = TabDataState::Loading;
|
||||
owner.extensions_modal = Some(modal);
|
||||
}
|
||||
let notif = make_server_status_notif("sess-owner", "alpha", McpServerStatus::Ready, None);
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(!redraw, "Loading state must skip the patch");
|
||||
assert!(app.pending_effects.is_empty());
|
||||
}
|
||||
|
||||
/// A malformed `status` field must NOT silently coerce to
|
||||
/// `Unavailable`. The whole payload must fail to parse and
|
||||
/// warn-log instead.
|
||||
#[test]
|
||||
fn server_status_malformed_status_does_not_silently_repaint() {
|
||||
use crate::views::extensions_modal::TabDataState;
|
||||
use crate::views::mcps_modal::McpServerDisplayStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
// Send a payload with the `status` field missing entirely —
|
||||
// a defaulting decoder would silently coerce this to Unavailable.
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-owner",
|
||||
"name": "alpha",
|
||||
"source": "local",
|
||||
"reason": "initialized",
|
||||
// NB: no `status`.
|
||||
});
|
||||
let raw = serde_json::value::to_raw_value(&payload).unwrap();
|
||||
let notif = acp::ExtNotification::new("x.ai/mcp/server_status", raw.into());
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(!redraw, "malformed payload must not request a redraw");
|
||||
|
||||
let modal = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let TabDataState::Loaded(ref servers) = modal.mcps_data else {
|
||||
panic!("modal still Loaded");
|
||||
};
|
||||
assert_eq!(
|
||||
servers[0].status,
|
||||
McpServerDisplayStatus::Initializing,
|
||||
"malformed status must NOT silently map to Unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
/// A present-but-non-array `tools` field must drop ONLY the tools
|
||||
/// update. The `status` field still applies — we do not drop the
|
||||
/// whole push.
|
||||
#[test]
|
||||
fn server_status_lenient_tools_decoding_still_applies_status() {
|
||||
use crate::views::extensions_modal::TabDataState;
|
||||
use crate::views::mcps_modal::McpServerDisplayStatus;
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
|
||||
// tools = arbitrary non-array shape (a future shell might emit
|
||||
// something like `{ added: [], removed: [] }`). Must NOT take
|
||||
// down the status update.
|
||||
let bad_tools = Some(serde_json::json!({"added": [], "removed": []}));
|
||||
let notif =
|
||||
make_server_status_notif("sess-owner", "alpha", McpServerStatus::Ready, bad_tools);
|
||||
let _ = handle_mcp_server_status(¬if, &mut app);
|
||||
|
||||
let modal = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let TabDataState::Loaded(ref servers) = modal.mcps_data else {
|
||||
panic!("modal still Loaded");
|
||||
};
|
||||
assert_eq!(
|
||||
servers[0].status,
|
||||
McpServerDisplayStatus::Ready,
|
||||
"malformed tools must not take down the status update"
|
||||
);
|
||||
// tool_count / tools were preserved (we dropped the tools
|
||||
// update, not overwrote with empty).
|
||||
assert_eq!(servers[0].tool_count, 0);
|
||||
assert!(servers[0].tools.is_empty());
|
||||
}
|
||||
|
||||
/// Pin that the pager deserializes against the *shell's*
|
||||
/// `McpServerStatus` enum, so a future new variant doesn't need a
|
||||
/// pager change to be recognized. Round-trip through
|
||||
/// `serde_json::to_string` of the shell type itself.
|
||||
#[test]
|
||||
fn server_status_round_trips_shell_canonical_type() {
|
||||
use kigi_shell::extensions::mcp::{
|
||||
McpServerSource, McpServerStatus, McpServerStatusPayload, McpServerStatusReason,
|
||||
};
|
||||
let payload = McpServerStatusPayload {
|
||||
session_id: "s".into(),
|
||||
name: "alpha".into(),
|
||||
source: McpServerSource::Local,
|
||||
status: McpServerStatus::NeedsAuth,
|
||||
reason: McpServerStatusReason::AuthExpired,
|
||||
detail: Some("token expired".into()),
|
||||
tools: None,
|
||||
};
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
let roundtripped: McpServerStatusPayload = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(payload, roundtripped);
|
||||
// `needsAuth` must be on the wire as the lowercase form, not
|
||||
// mixed-case — verifies the rename_all = lowercase contract.
|
||||
assert!(
|
||||
json.contains("\"needsauth\""),
|
||||
"wire form must be lowercase 'needsauth'; got {json}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `servers_updated` has NO `sessionId` on the wire. The handler
|
||||
/// must broadcast to every agent with an open modal, NOT fall
|
||||
/// through to `active_view`.
|
||||
#[test]
|
||||
fn servers_updated_broadcasts_to_every_agent_with_open_modal() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Open modals on BOTH agents — broadcast must hit both.
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(vec![
|
||||
crate::views::mcps_modal::McpServerInfo {
|
||||
name: "beta".into(),
|
||||
display_name: None,
|
||||
status: crate::views::mcps_modal::McpServerDisplayStatus::Initializing,
|
||||
tool_count: 0,
|
||||
auth_required: false,
|
||||
tools: Vec::new(),
|
||||
enabled: true,
|
||||
source: "local".into(),
|
||||
wire_source: crate::views::mcps_modal::McpWireSource::Local,
|
||||
plugin_name: None,
|
||||
is_managed_gateway: false,
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
let notif = make_servers_updated_notif();
|
||||
let redraw = handle_mcp_servers_updated(¬if, &mut app);
|
||||
|
||||
assert!(
|
||||
redraw,
|
||||
"active agent had its modal open — redraw must be requested"
|
||||
);
|
||||
assert_eq!(
|
||||
app.pending_effects.len(),
|
||||
2,
|
||||
"servers_updated must broadcast: one FetchMcpsList per agent with an open modal"
|
||||
);
|
||||
let mut targets: Vec<usize> = app
|
||||
.pending_effects
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Effect::FetchMcpsList {
|
||||
agent_id, cache, ..
|
||||
} => {
|
||||
assert!(*cache, "broadcast must use the debounced cache=true path");
|
||||
Some(agent_id.0)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
targets.sort();
|
||||
assert_eq!(targets, vec![0, 1]);
|
||||
}
|
||||
|
||||
/// Agents without an open modal must NOT receive a refetch (cheap
|
||||
/// path) even though they are eligible to receive the broadcast in
|
||||
/// principle.
|
||||
#[test]
|
||||
fn servers_updated_skips_agents_with_closed_modal() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Only agent 1 (foregrounded) has a modal.
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(Vec::new()));
|
||||
}
|
||||
let notif = make_servers_updated_notif();
|
||||
let _ = handle_mcp_servers_updated(¬if, &mut app);
|
||||
assert_eq!(
|
||||
app.pending_effects.len(),
|
||||
1,
|
||||
"agent 0 has no modal open — must NOT receive a refetch"
|
||||
);
|
||||
let Effect::FetchMcpsList { agent_id, .. } = app.pending_effects.first().unwrap() else {
|
||||
panic!();
|
||||
};
|
||||
assert_eq!(
|
||||
*agent_id,
|
||||
AgentId(1),
|
||||
"only the agent with an open modal gets the refetch"
|
||||
);
|
||||
}
|
||||
|
||||
/// A second `servers_updated` push that lands before agent A's
|
||||
/// pending fetch drains must coalesce for agent A but still
|
||||
/// schedule fresh for agent B if B's first push was never seen.
|
||||
#[test]
|
||||
fn servers_updated_per_agent_coalescing() {
|
||||
let mut app = make_app_two_agents();
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
let notif = make_servers_updated_notif();
|
||||
let _ = handle_mcp_servers_updated(¬if, &mut app);
|
||||
assert_eq!(app.pending_effects.len(), 1);
|
||||
|
||||
// Second push: owner is now coalesced (agent 0 has pending);
|
||||
// but we add a modal to agent 1 — that agent's push must NOT
|
||||
// be dropped just because agent 0 has a pending fetch.
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(Vec::new()));
|
||||
}
|
||||
let _ = handle_mcp_servers_updated(¬if, &mut app);
|
||||
assert_eq!(
|
||||
app.pending_effects.len(),
|
||||
2,
|
||||
"agent 1's first push must schedule a fetch despite agent 0 having a pending one"
|
||||
);
|
||||
let mut targets: Vec<usize> = app
|
||||
.pending_effects
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Effect::FetchMcpsList { agent_id, .. } => Some(agent_id.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
targets.sort();
|
||||
assert_eq!(targets, vec![0, 1]);
|
||||
}
|
||||
|
||||
/// `mcp_initialized` wire shape `{ sessionId, mcpToolCount, elapsedMs }`
|
||||
/// — sessionId routing applies; the matched agent's
|
||||
/// `mcp_init_progress` overlay must be cleared.
|
||||
#[test]
|
||||
fn mcp_initialized_clears_init_progress_on_owner() {
|
||||
use crate::app::agent_view::McpInitProgress;
|
||||
let mut app = make_app_two_agents();
|
||||
// Seed init progress on the OWNER (agent 0) and on the
|
||||
// active view (agent 1). The push must clear only the
|
||||
// owner's overlay.
|
||||
{
|
||||
let owner = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
owner.mcp_init_progress = Some(McpInitProgress {
|
||||
total: 5,
|
||||
connected: 3,
|
||||
started_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.mcp_init_progress = Some(McpInitProgress {
|
||||
total: 5,
|
||||
connected: 3,
|
||||
started_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
let notif = make_mcp_initialized_notif_for("sess-owner");
|
||||
let _ = handle_mcp_tools_changed(¬if, &mut app);
|
||||
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.mcp_init_progress
|
||||
.is_none(),
|
||||
"owner's init progress overlay must be cleared"
|
||||
);
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(1))
|
||||
.unwrap()
|
||||
.mcp_init_progress
|
||||
.is_some(),
|
||||
"active-view agent's init progress must NOT be cleared by a background-agent init"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_changed_post_h2_routes_to_owning_agent_not_active_view() {
|
||||
let mut app = make_app_two_agents();
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
// Active agent has NO modal — a route-by-active-view path
|
||||
// would no-op. Routing by sessionId must schedule a fetch
|
||||
// against the OWNER agent (agent 0).
|
||||
let notif = make_tools_changed_notif_post_h2("sess-owner");
|
||||
let _ = handle_mcp_tools_changed(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.pending_effects.len(),
|
||||
1,
|
||||
"tools_changed with sessionId must route to the owner"
|
||||
);
|
||||
let Effect::FetchMcpsList { agent_id, .. } =
|
||||
app.pending_effects.first().expect("effect scheduled")
|
||||
else {
|
||||
panic!("expected FetchMcpsList");
|
||||
};
|
||||
assert_eq!(
|
||||
*agent_id,
|
||||
AgentId(0),
|
||||
"tools_changed with sessionId must route to the owner, not the active view"
|
||||
);
|
||||
}
|
||||
|
||||
/// Forward/backward-compat guard: older shells emit
|
||||
/// `{ serverName, tools }` with NO sessionId. The pager must
|
||||
/// gracefully fall back to `active_view`.
|
||||
|
||||
#[test]
|
||||
fn tools_changed_pre_h2_falls_back_to_active_view() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Active agent (agent 1) gets a modal; owner (agent 0) does not.
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(Vec::new()));
|
||||
}
|
||||
let notif = make_tools_changed_notif_pre_h2();
|
||||
let _ = handle_mcp_tools_changed(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.pending_effects.len(),
|
||||
1,
|
||||
"legacy payload without sessionId falls back to active view"
|
||||
);
|
||||
let Effect::FetchMcpsList { agent_id, .. } = app.pending_effects.first().unwrap() else {
|
||||
panic!();
|
||||
};
|
||||
assert_eq!(
|
||||
*agent_id,
|
||||
AgentId(1),
|
||||
"legacy fallback targets the foregrounded agent"
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,444 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
/// Regression: a machine-wide `x.ai/models/update` broadcast
|
||||
/// carries each model's static catalog-default effort (`high`), not the
|
||||
/// session's chosen `xhigh`, and must not clobber the per-session choice.
|
||||
#[test]
|
||||
fn models_update_preserves_user_reasoning_effort() {
|
||||
use kigi_shell::sampling::types::ReasoningEffort;
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let id = acp::ModelId::new(std::sync::Arc::from("reason-model"));
|
||||
let mut info = make_model_info("reason-model");
|
||||
info.meta = serde_json::json!({
|
||||
"supportsReasoningEffort": true,
|
||||
"reasoningEffort": "high",
|
||||
})
|
||||
.as_object()
|
||||
.cloned();
|
||||
agent.session.models.available.insert(id.clone(), info);
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.set_current(id, Some(ReasoningEffort::Xhigh));
|
||||
assert_eq!(
|
||||
agent.session.models.reasoning_effort,
|
||||
Some(ReasoningEffort::Xhigh)
|
||||
);
|
||||
|
||||
let notif = make_reasoning_models_update_notif("reason-model", "high");
|
||||
assert!(handle_models_update(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.session.models.reasoning_effort,
|
||||
Some(ReasoningEffort::Xhigh),
|
||||
"models/update broadcast must not clobber a user-set per-session effort"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_update_preserves_active_agent_model() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let id_3 = acp::ModelId::new(std::sync::Arc::from("grok-3"));
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.available
|
||||
.insert(id_3.clone(), make_model_info("grok-3"));
|
||||
agent.session.models.current = Some(id_3);
|
||||
|
||||
let notif = make_models_update_notif("grok-4", &["grok-3", "grok-4"]);
|
||||
handle_models_update(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"app.models.current must preserve active agent's model, not remote settings default"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"agent's per-session model must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_update_uses_shell_default_when_agent_model_removed() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let id_3 = acp::ModelId::new(std::sync::Arc::from("grok-3"));
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.available
|
||||
.insert(id_3.clone(), make_model_info("grok-3"));
|
||||
agent.session.models.current = Some(id_3);
|
||||
|
||||
// grok-3 removed from catalog.
|
||||
let notif = make_models_update_notif("grok-4.3", &["grok-4.3", "grok-4.5"]);
|
||||
handle_models_update(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
||||
Some("grok-4.3"),
|
||||
"app.models.current must use shell default when agent model removed"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-4.3"),
|
||||
"agent must fall back to shell default when its model is removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_update_without_active_agent_uses_shell_default() {
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = AppView::new(tx, ModelState::default(), Vec::new());
|
||||
|
||||
let notif = make_models_update_notif("grok-4", &["grok-3", "grok-4"]);
|
||||
handle_models_update(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
||||
Some("grok-4"),
|
||||
"without an active agent, shell default must be used"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_update_noop_when_agent_matches_shell_default() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let id_4 = acp::ModelId::new(std::sync::Arc::from("grok-4"));
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.available
|
||||
.insert(id_4.clone(), make_model_info("grok-4"));
|
||||
agent.session.models.current = Some(id_4);
|
||||
|
||||
let notif = make_models_update_notif("grok-4", &["grok-3", "grok-4"]);
|
||||
handle_models_update(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
||||
Some("grok-4"),
|
||||
"app.models.current must be grok-4 when agent and shell agree"
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-4"),
|
||||
"agent model must remain grok-4"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_update_non_active_agent_uses_shell_fallback_not_active_model() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
|
||||
{
|
||||
let agent_a = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let id_3 = acp::ModelId::new(std::sync::Arc::from("grok-3"));
|
||||
agent_a
|
||||
.session
|
||||
.models
|
||||
.available
|
||||
.insert(id_3.clone(), make_model_info("grok-3"));
|
||||
agent_a.session.models.current = Some(id_3);
|
||||
}
|
||||
|
||||
{
|
||||
let agent_b = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
let id_5 = acp::ModelId::new(std::sync::Arc::from("grok-4.5"));
|
||||
agent_b
|
||||
.session
|
||||
.models
|
||||
.available
|
||||
.insert(id_5.clone(), make_model_info("grok-4.5"));
|
||||
agent_b.session.models.current = Some(id_5);
|
||||
}
|
||||
|
||||
// grok-5 removed from catalog.
|
||||
let notif = make_models_update_notif("grok-4", &["grok-3", "grok-4"]);
|
||||
handle_models_update(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
);
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"agent A's model must be preserved"
|
||||
);
|
||||
|
||||
// B's grok-5 was removed — must fall back to shell's grok-4, not A's grok-3.
|
||||
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert_eq!(
|
||||
agent_b
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-4"),
|
||||
"inactive agent must fall back to shell default, not active agent's model"
|
||||
);
|
||||
}
|
||||
|
||||
/// A follower client (no in-flight switch of its own) receives the
|
||||
/// leader's `ModelChanged` broadcast and silently mirrors the new model
|
||||
/// into its local state — no scrollback entry, no toast, just enough
|
||||
/// state for the status bar / `/model` dropdown to render correctly.
|
||||
#[test]
|
||||
fn model_changed_updates_state_silently_on_follower() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "grok-3", &["grok-3", "grok-4"]);
|
||||
let scrollback_before = agent.scrollback.len();
|
||||
// Follower: no local switch in flight.
|
||||
assert!(!agent.session.model_switch_pending);
|
||||
|
||||
let notif = model_changed_ext("sess-1", "grok-4", None);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
changed,
|
||||
"follower's state changed → handler must request a redraw"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-4"),
|
||||
"follower must mirror the remote switch into its local model state",
|
||||
);
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
scrollback_before,
|
||||
"follower must NOT push a 'Switched to' scrollback entry — that is \
|
||||
the invoking client's job (SwitchModelComplete owns the system message)"
|
||||
);
|
||||
assert!(
|
||||
!agent.session.model_switch_pending,
|
||||
"follower's pending flag must stay false (no local switch was issued)"
|
||||
);
|
||||
}
|
||||
|
||||
/// A live remote `ModelChanged` (leader-mode fan-out from another client)
|
||||
/// must apply even when this client already has a local
|
||||
/// `user_model_preference` — otherwise the status bar desyncs from the
|
||||
/// gateway session. Preference is updated to track the new live model.
|
||||
/// (History-replay silent-revert is suppressed on the shell side via
|
||||
/// `ReconnectState::user_selected_model`, not by permanently blocking
|
||||
/// remote ModelChanged here.)
|
||||
#[test]
|
||||
fn model_changed_applies_and_updates_user_model_preference() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "heavy", &["auto", "heavy"]);
|
||||
agent.session.user_model_preference =
|
||||
Some(acp::ModelId::new(std::sync::Arc::from("heavy")));
|
||||
assert!(!agent.session.model_switch_pending);
|
||||
|
||||
let notif = model_changed_ext("sess-1", "auto", None);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
changed,
|
||||
"remote live ModelChanged must apply despite prior local preference"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("auto"),
|
||||
"selector must mirror the remote switch"
|
||||
);
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.user_model_preference
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("auto"),
|
||||
"preference must track the applied remote switch"
|
||||
);
|
||||
}
|
||||
|
||||
/// The invoking client is also a subscriber to its own session and so
|
||||
/// receives the broadcast it triggered. Its in-flight
|
||||
/// `SetSessionModelResponse` is the authority for its local state +
|
||||
/// the single "Switched to X" scrollback entry, so the broadcast handler
|
||||
/// must be a no-op here — gated on `model_switch_pending == true`.
|
||||
///
|
||||
/// Concretely we verify the broadcast does NOT touch
|
||||
/// `models.current` (preserving the pre-response snapshot) — that
|
||||
/// snapshot is what `SwitchModelComplete`'s `unchanged` check compares
|
||||
/// against to decide whether to render the "Switched to X" message. If
|
||||
/// the broadcast optimistically updated state here, the response
|
||||
/// handler would see `prev == new`, mark it unchanged, and suppress the
|
||||
/// user-facing message entirely.
|
||||
#[test]
|
||||
fn model_changed_skipped_when_local_switch_in_flight() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "grok-3", &["grok-3", "grok-4"]);
|
||||
// Invoker: a local switch is in flight (set by Action::SwitchModel /
|
||||
// set_default_model before the SetSessionModelRequest is sent).
|
||||
agent.session.model_switch_pending = true;
|
||||
let scrollback_before = agent.scrollback.len();
|
||||
|
||||
let notif = model_changed_ext("sess-1", "grok-4", None);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
!changed,
|
||||
"broadcast must be a no-op while local switch is pending"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"models.current must stay at the pre-response snapshot — \
|
||||
SwitchModelComplete owns the final apply + system message"
|
||||
);
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
scrollback_before,
|
||||
"broadcast must not push any scrollback entry on the invoker"
|
||||
);
|
||||
assert!(
|
||||
agent.session.model_switch_pending,
|
||||
"pending flag must remain set until SwitchModelComplete arrives"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `ModelChanged` broadcast carrying a model id the local catalog
|
||||
/// doesn't know about must be dropped — applying it would render an
|
||||
/// unresolvable id in the status bar and desync the `/model` dropdown.
|
||||
/// This can happen when leader and a follower client briefly disagree
|
||||
/// on the model catalog (etag drift, custom-model config skew).
|
||||
#[test]
|
||||
fn model_changed_dropped_when_model_unknown_to_catalog() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "grok-3", &["grok-3", "grok-4"]);
|
||||
|
||||
let notif = model_changed_ext("sess-1", "grok-99-unknown", None);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
!changed,
|
||||
"unknown model must NOT trigger a redraw — no state changed"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"models.current must stay on the previously-known model"
|
||||
);
|
||||
}
|
||||
|
||||
/// `reasoning_effort` round-trips through the broadcast: the follower
|
||||
/// applies it alongside the model id so the prompt header / status bar
|
||||
/// show the right effort without waiting for a subsequent
|
||||
/// `x.ai/models/update`.
|
||||
#[test]
|
||||
fn model_changed_applies_reasoning_effort_on_follower() {
|
||||
use kigi_shell::sampling::types::ReasoningEffort;
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "grok-3", &["grok-3", "grok-4"]);
|
||||
|
||||
let notif = model_changed_ext("sess-1", "grok-4", Some("high"));
|
||||
assert!(handle_ext_notification(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.session.models.reasoning_effort,
|
||||
Some(ReasoningEffort::High),
|
||||
"follower must mirror the broadcast's reasoning_effort"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ModelChanged` for a session this client doesn't own / hasn't loaded
|
||||
/// must be dropped — `find_session_match` returns `None`. The bug-flavored
|
||||
/// version of this would be: leader-mode A switches model on session X
|
||||
/// (which this client never opened) and we accidentally apply the change
|
||||
/// to the active agent.
|
||||
#[test]
|
||||
fn model_changed_dropped_for_unknown_session_id() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "grok-3", &["grok-3", "grok-4"]);
|
||||
|
||||
let notif = model_changed_ext("sess-OTHER", "grok-4", None);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"unrelated-session broadcast must not touch this agent's model"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
/// The permission prompt must surface the payload an MCP call would
|
||||
/// send — both `UseTool` (meta-dispatch) and `MCPTool` (natively
|
||||
/// registered) raw_input shapes.
|
||||
#[test]
|
||||
fn mcp_args_lines_extracts_planned_tool_input() {
|
||||
for variant in ["UseTool", "MCPTool"] {
|
||||
let req = permission_req_with_raw_input(Some(serde_json::json!({
|
||||
"variant": variant,
|
||||
"tool_name": "jira__AddjiraComment",
|
||||
"tool_input": {"issue": "ABC-123", "body": "hello"},
|
||||
})));
|
||||
let lines = mcp_args_lines(&req);
|
||||
let joined = lines.join("\n");
|
||||
assert!(
|
||||
joined.contains("\"issue\": \"ABC-123\""),
|
||||
"{variant}: {joined}"
|
||||
);
|
||||
assert!(
|
||||
joined.contains("\"body\": \"hello\""),
|
||||
"{variant}: {joined}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-MCP raw_input (bash, edit, gateway `{command}` shapes) must not
|
||||
/// grow a JSON dump — those prompts have dedicated displays.
|
||||
#[test]
|
||||
fn mcp_args_lines_empty_for_non_mcp_shapes() {
|
||||
for raw in [
|
||||
None,
|
||||
Some(serde_json::json!({"variant": "Bash", "command": "ls", "description": "d"})),
|
||||
Some(serde_json::json!({"command": "rm -rf /"})),
|
||||
Some(serde_json::json!({"file_path": "/tmp/x"})),
|
||||
Some(serde_json::json!("not-an-object")),
|
||||
] {
|
||||
let req = permission_req_with_raw_input(raw.clone());
|
||||
assert!(
|
||||
mcp_args_lines(&req).is_empty(),
|
||||
"expected empty for {raw:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `tool_input` that is missing or JSON null renders nothing rather
|
||||
/// than a misleading `null`.
|
||||
#[test]
|
||||
fn mcp_args_lines_empty_for_missing_or_null_input() {
|
||||
for raw in [
|
||||
serde_json::json!({"variant": "UseTool", "tool_name": "t"}),
|
||||
serde_json::json!({"variant": "UseTool", "tool_name": "t", "tool_input": null}),
|
||||
] {
|
||||
let req = permission_req_with_raw_input(Some(raw));
|
||||
assert!(mcp_args_lines(&req).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// A pathological single-line value (e.g. an embedded base64 blob) is
|
||||
/// elided at `MCP_ARGS_MAX_LINE_CHARS` so per-frame wrap cost stays
|
||||
/// bounded. Uses a multi-byte char to pin char (not byte) slicing.
|
||||
#[test]
|
||||
fn mcp_args_lines_caps_line_length() {
|
||||
let req = permission_req_with_raw_input(Some(serde_json::json!({
|
||||
"variant": "UseTool",
|
||||
"tool_name": "t",
|
||||
"tool_input": {"blob": "é".repeat(MCP_ARGS_MAX_LINE_CHARS * 2)},
|
||||
})));
|
||||
let lines = mcp_args_lines(&req);
|
||||
let long = lines
|
||||
.iter()
|
||||
.find(|l| l.contains("é"))
|
||||
.expect("blob line present");
|
||||
assert_eq!(long.chars().count(), MCP_ARGS_MAX_LINE_CHARS + 1);
|
||||
assert!(long.ends_with('…'));
|
||||
}
|
||||
|
||||
/// Pathologically large payloads are capped in storage with an explicit
|
||||
/// hidden-line count (the overlay clips further at render time).
|
||||
#[test]
|
||||
fn mcp_args_lines_caps_stored_lines() {
|
||||
let big: serde_json::Map<String, serde_json::Value> = (0..MCP_ARGS_MAX_LINES + 50)
|
||||
.map(|i| (format!("k{i:04}"), serde_json::Value::from(i)))
|
||||
.collect();
|
||||
let req = permission_req_with_raw_input(Some(serde_json::json!({
|
||||
"variant": "UseTool",
|
||||
"tool_name": "t",
|
||||
"tool_input": big,
|
||||
})));
|
||||
let lines = mcp_args_lines(&req);
|
||||
assert_eq!(lines.len(), MCP_ARGS_MAX_LINES + 1);
|
||||
let last = lines.last().unwrap();
|
||||
assert!(
|
||||
last.starts_with("… (+") && last.ends_with(" more lines)"),
|
||||
"unexpected tail: {last}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Manual recap with an uncommitted in-flight spinner: filled in place
|
||||
/// (no second block), animation stopped.
|
||||
#[test]
|
||||
fn recap_fills_uncommitted_spinner_in_place() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
let spinner = agent
|
||||
.scrollback
|
||||
.push(crate::scrollback::entry::ScrollbackEntry::running(
|
||||
recap_block(""),
|
||||
));
|
||||
agent.pending_recap_entry = Some(spinner);
|
||||
|
||||
apply_recap_block(&mut agent, false, recap_block("THE RECAP"));
|
||||
|
||||
assert_eq!(agent.scrollback.len(), 1, "filled in place, not appended");
|
||||
let entry = agent.scrollback.get_by_id(spinner).expect("entry kept");
|
||||
assert!(!entry.is_running, "spinner animation stopped");
|
||||
assert!(agent.pending_recap_entry.is_none());
|
||||
}
|
||||
|
||||
/// Regression (minimal mode): the spinner was already committed into
|
||||
/// native scrollback (print-once) — an in-place fill would never reach the
|
||||
/// terminal. The stale committed entry is dropped from state and the recap
|
||||
/// appended as a fresh (uncommitted) block so the commit pass prints it.
|
||||
#[test]
|
||||
fn recap_reprints_fresh_block_when_spinner_already_committed() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
let spinner = agent
|
||||
.scrollback
|
||||
.push(crate::scrollback::entry::ScrollbackEntry::running(
|
||||
recap_block(""),
|
||||
));
|
||||
agent.pending_recap_entry = Some(spinner);
|
||||
// The minimal idle commit pass consumed the spinner.
|
||||
agent.scrollback.finish_running(spinner);
|
||||
agent.scrollback.mark_committed(0);
|
||||
agent.scrollback.set_commit_scan_cursor(1);
|
||||
assert!(agent.scrollback.is_committed(spinner));
|
||||
|
||||
apply_recap_block(&mut agent, false, recap_block("THE RECAP"));
|
||||
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
1,
|
||||
"stale committed spinner dropped, fresh block appended"
|
||||
);
|
||||
let fresh = agent.scrollback.get(0).expect("fresh block");
|
||||
assert_ne!(fresh.id, spinner, "a NEW entry, not the committed one");
|
||||
assert!(
|
||||
!agent.scrollback.is_committed(fresh.id),
|
||||
"fresh block is uncommitted so the commit pass will print it"
|
||||
);
|
||||
}
|
||||
|
||||
/// An automatic recap never consumes the manual loading slot — it always
|
||||
/// appends its own block and leaves the pending spinner alone.
|
||||
#[test]
|
||||
fn auto_recap_appends_and_leaves_manual_spinner_pending() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
let spinner = agent
|
||||
.scrollback
|
||||
.push(crate::scrollback::entry::ScrollbackEntry::running(
|
||||
recap_block(""),
|
||||
));
|
||||
agent.pending_recap_entry = Some(spinner);
|
||||
|
||||
apply_recap_block(&mut agent, true, recap_block("AUTO RECAP"));
|
||||
|
||||
assert_eq!(agent.scrollback.len(), 2, "auto recap appended");
|
||||
assert_eq!(agent.pending_recap_entry, Some(spinner));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_auto_recap_dropped_when_agent_not_idle() {
|
||||
assert!(should_drop_late_auto_recap(true, false, false));
|
||||
assert!(
|
||||
!should_drop_late_auto_recap(true, false, true),
|
||||
"idle agent: show auto recap"
|
||||
);
|
||||
assert!(
|
||||
!should_drop_late_auto_recap(false, false, false),
|
||||
"manual /recap always shown"
|
||||
);
|
||||
assert!(
|
||||
!should_drop_late_auto_recap(true, true, false),
|
||||
"history replay rebuilds scrollback even mid-turn"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_auto_opens_inline_cursor_plan_preview() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "create-plan-call", "CreatePlan");
|
||||
}
|
||||
let (ext, _rx) =
|
||||
make_exit_plan_ext_with_tool_call_id("create-plan-call", Some("# Cursor Plan"));
|
||||
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
|
||||
assert!(agent.plan_approval_view.is_some());
|
||||
assert_eq!(
|
||||
agent
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.and_then(|v| v.markdown_content_for_test()),
|
||||
Some("# Cursor Plan")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_keeps_inline_plan_preview_available() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "create-plan-call", "CreatePlan");
|
||||
}
|
||||
let (ext, _rx) =
|
||||
make_exit_plan_ext_with_tool_call_id("create-plan-call", Some("# First Plan"));
|
||||
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.plan_approval_view.as_ref().map(|s| s.source),
|
||||
Some(crate::views::plan_approval_view::PlanReviewSource::Inline)
|
||||
);
|
||||
agent.line_viewer = None;
|
||||
agent.show_plan_preview();
|
||||
assert_eq!(
|
||||
agent
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.and_then(|v| v.markdown_content_for_test()),
|
||||
Some("# First Plan")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_without_inline_content_uses_file_backed_source() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let (ext, _rx) = make_exit_plan_ext(Some("# File Plan"));
|
||||
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert!(agent.latest_inline_plan_content.is_none());
|
||||
|
||||
assert_eq!(
|
||||
agent.plan_approval_view.as_ref().map(|s| s.source),
|
||||
Some(crate::views::plan_approval_view::PlanReviewSource::FileBacked)
|
||||
);
|
||||
// File-backed bodies still open via request plan_content even when
|
||||
// plan.md is not on disk under the agent's cwd.
|
||||
assert_eq!(
|
||||
agent
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.and_then(|v| v.markdown_content_for_test()),
|
||||
Some("# File Plan")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_empty_opens_placeholder_preview() {
|
||||
// Empty plan.md must still surface a decision UI — otherwise the user
|
||||
// only sees "Waiting on plan approval" with a dead Tab:plan and thinks
|
||||
// the session is stuck.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let (ext, _rx) = make_exit_plan_ext(None);
|
||||
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
|
||||
let pav = agent
|
||||
.plan_approval_view
|
||||
.as_ref()
|
||||
.expect("plan_approval_view must be set");
|
||||
assert!(!pav.has_plan);
|
||||
assert_eq!(
|
||||
pav.focus,
|
||||
crate::views::plan_approval_view::PlanApprovalFocus::Preview,
|
||||
"empty approval must keep Preview focus once the placeholder opens"
|
||||
);
|
||||
assert_eq!(
|
||||
agent
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.and_then(|v| v.markdown_content_for_test()),
|
||||
Some(crate::views::plan_approval_view::EMPTY_PLAN_PLACEHOLDER)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_dismisses_open_modal() {
|
||||
// Regression: if the user has Ctrl+P command palette open when the
|
||||
// agent calls exit_plan_mode, the modal must be dismissed so the
|
||||
// plan preview is visible and input routes correctly. Otherwise the
|
||||
// modal hides the line viewer in draw order while input gets
|
||||
// routed to the invisible line viewer, leaving the user stuck.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "create-plan-call", "CreatePlan");
|
||||
agent.active_modal = Some(crate::views::modal::ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(agent.sharing_enabled),
|
||||
state: crate::views::picker::PickerState::input_active(),
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let (ext, _rx) =
|
||||
make_exit_plan_ext_with_tool_call_id("create-plan-call", Some("# Cursor Plan"));
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.active_modal.is_none(),
|
||||
"exit_plan_mode must dismiss the open modal so the plan preview is visible"
|
||||
);
|
||||
assert!(agent.plan_approval_view.is_some());
|
||||
assert!(agent.line_viewer.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_dismisses_open_block_viewer() {
|
||||
// Regression: if the user has an Edit/tool block_viewer open when
|
||||
// exit_plan_mode opens, dismiss it so wheel scroll reaches the plan
|
||||
// line_viewer. Draw returns on line_viewer (plan visible) but
|
||||
// handle_scroll prefers block_viewer while it remains in state.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "create-plan-call", "CreatePlan");
|
||||
agent.block_viewer = Some(crate::views::block_viewer::BlockViewerPane::for_plain_text(
|
||||
"edit",
|
||||
"diff content",
|
||||
));
|
||||
}
|
||||
|
||||
let (ext, _rx) =
|
||||
make_exit_plan_ext_with_tool_call_id("create-plan-call", Some("# Cursor Plan"));
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.block_viewer.is_none(),
|
||||
"exit_plan_mode must dismiss open block_viewer so the plan can scroll"
|
||||
);
|
||||
assert!(agent.plan_approval_view.is_some());
|
||||
assert!(agent.line_viewer.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_empty_exit_plan_request_clears_stale_inline_plan() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "create-plan-call", "CreatePlan");
|
||||
}
|
||||
let (first, _first_rx) =
|
||||
make_exit_plan_ext_with_tool_call_id("create-plan-call", Some("# First Plan"));
|
||||
let (second, _second_rx) = make_exit_plan_ext(None);
|
||||
|
||||
assert!(handle_exit_plan_mode(first, &mut app));
|
||||
{
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.latest_inline_plan_content.as_deref(),
|
||||
Some("# First Plan")
|
||||
);
|
||||
}
|
||||
assert!(handle_exit_plan_mode(second, &mut app));
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert!(agent.latest_inline_plan_content.is_none());
|
||||
// Empty approval still opens the placeholder decision surface (not a
|
||||
// silent "no plan" toast) so the user always sees a way to proceed.
|
||||
assert_eq!(
|
||||
agent
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.and_then(|v| v.markdown_content_for_test()),
|
||||
Some(crate::views::plan_approval_view::EMPTY_PLAN_PLACEHOLDER)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_shows_overlay() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
assert!(!app.agents.get(&AgentId(0)).unwrap().session.is_yolo());
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-A".into(),
|
||||
tool_call_id: "tc-normal".into(),
|
||||
plan_content: Some("# Plan\nDo stuff".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
let msg = AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(affected, "opening the overlay should need a redraw");
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.plan_approval_view.is_some(),
|
||||
"plan_approval_view must be set for interactive approval"
|
||||
);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"response must NOT have been sent yet (waiting for user)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_shows_overlay_even_in_yolo() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.yolo_mode = true;
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-A".into(),
|
||||
tool_call_id: "tc-yolo".into(),
|
||||
plan_content: Some("# Plan\nDo stuff".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
let msg = AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(affected, "overlay should open even in yolo mode");
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.plan_approval_view.is_some(),
|
||||
"plan_approval_view must be set even in always-approve mode"
|
||||
);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"response must NOT have been sent yet (waiting for user)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_routes_to_background_session_not_active_view() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-B".into(),
|
||||
tool_call_id: "tc-bg-plan".into(),
|
||||
plan_content: Some("# Plan".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
let msg = AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(
|
||||
!affected,
|
||||
"a background-session plan approval must not redraw the active view"
|
||||
);
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(1))
|
||||
.unwrap()
|
||||
.plan_approval_view
|
||||
.is_some(),
|
||||
"plan approval must be parked on the session that asked (background agent B)"
|
||||
);
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.plan_approval_view
|
||||
.is_none(),
|
||||
"plan approval must NOT land on the unrelated active agent A"
|
||||
);
|
||||
assert!(rx.try_recv().is_err(), "response must NOT be sent yet");
|
||||
}
|
||||
|
||||
/// Regression: tool-call titles containing `"enter_plan_mode"` must not
|
||||
/// flip plan mode (the substring matcher used to brick sessions on any
|
||||
/// tool mentioning the phrase, e.g. a Grep with that pattern).
|
||||
#[test]
|
||||
fn tool_call_with_enter_plan_mode_substring_does_not_activate_plan_mode() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
assert!(!agent.plan_mode_active);
|
||||
|
||||
let updates = [
|
||||
make_tool_call("enter_plan_mode"),
|
||||
make_tool_call_update("enter_plan_mode"),
|
||||
make_tool_call("Execute `rg enter_plan_mode`"),
|
||||
make_tool_call_update("Execute `rg enter_plan_mode`"),
|
||||
make_tool_call_update("Plan mode entered"),
|
||||
make_tool_call("mcp__foo__enter_plan_mode"),
|
||||
];
|
||||
for update in &updates {
|
||||
let refresh_needed = detect_plan_mode_change(update, &mut agent);
|
||||
assert!(
|
||||
!refresh_needed,
|
||||
"tool-call title (not a CurrentModeUpdate) must not request refresh"
|
||||
);
|
||||
assert!(
|
||||
!agent.plan_mode_active,
|
||||
"tool-call title must not flip plan mode"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Symmetric: tool-call titles containing `"exit_plan_mode"` must not
|
||||
/// deactivate plan mode either. Exit is signaled by `CurrentModeUpdate`.
|
||||
#[test]
|
||||
fn tool_call_with_exit_plan_mode_substring_does_not_deactivate_plan_mode() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent.plan_mode_active = true;
|
||||
|
||||
let updates = [
|
||||
make_tool_call("exit_plan_mode"),
|
||||
make_tool_call_update("exit_plan_mode"),
|
||||
make_tool_call_update("Plan mode exited"),
|
||||
make_tool_call("Execute `rg exit_plan_mode`"),
|
||||
];
|
||||
for update in &updates {
|
||||
let refresh_needed = detect_plan_mode_change(update, &mut agent);
|
||||
assert!(!refresh_needed);
|
||||
assert!(
|
||||
agent.plan_mode_active,
|
||||
"tool-call title must not flip plan mode"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_mode_update_plan_activates_plan_mode() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
assert!(!agent.plan_mode_active);
|
||||
|
||||
let refresh_needed = detect_plan_mode_change(&make_current_mode_update("plan"), &mut agent);
|
||||
assert!(refresh_needed);
|
||||
assert!(agent.plan_mode_active);
|
||||
assert!(agent.plan_mode_pending.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_mode_update_default_deactivates_plan_mode() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent.plan_mode_active = true;
|
||||
agent.plan_mode_pending = Some(true);
|
||||
|
||||
let refresh_needed =
|
||||
detect_plan_mode_change(&make_current_mode_update("default"), &mut agent);
|
||||
assert!(refresh_needed);
|
||||
assert!(!agent.plan_mode_active);
|
||||
assert!(agent.plan_mode_pending.is_none());
|
||||
}
|
||||
|
||||
/// Unknown mode ids (e.g. a custom agent definition name like
|
||||
/// `"browser_use"`) parse to `SessionMode::Default` and deactivate
|
||||
/// plan mode.
|
||||
#[test]
|
||||
fn current_mode_update_unknown_id_treated_as_default() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent.plan_mode_active = true;
|
||||
|
||||
let refresh_needed =
|
||||
detect_plan_mode_change(&make_current_mode_update("browser_use"), &mut agent);
|
||||
assert!(refresh_needed);
|
||||
assert!(!agent.plan_mode_active);
|
||||
}
|
||||
|
||||
/// Idempotent CurrentModeUpdate still signals refresh because
|
||||
/// `plan_mode_pending` was cleared (affects effective state).
|
||||
#[test]
|
||||
fn current_mode_update_signals_refresh_even_on_no_op_active_change() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent.plan_mode_active = true;
|
||||
agent.plan_mode_pending = Some(true);
|
||||
|
||||
let refresh_needed = detect_plan_mode_change(&make_current_mode_update("plan"), &mut agent);
|
||||
assert!(
|
||||
refresh_needed,
|
||||
"CurrentModeUpdate must always signal refresh — pending was cleared"
|
||||
);
|
||||
assert!(agent.plan_mode_active);
|
||||
assert!(agent.plan_mode_pending.is_none());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plugins_changed_update_refreshes_data_without_reseeding_collapse_state() {
|
||||
use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab, TabDataState};
|
||||
|
||||
let mut app = make_app_with_agent("sess-plugins");
|
||||
let mut modal = ExtensionsModalState::new(ExtensionsTab::Plugins);
|
||||
modal.plugins_data =
|
||||
TabDataState::Loaded(kigi_hooks_plugins_types::PluginsListResponse { plugins: vec![] });
|
||||
modal.plugins_groups_seeded = true;
|
||||
modal
|
||||
.plugins_collapsed_groups
|
||||
.insert("origin:user-claude".into());
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().extensions_modal = Some(modal);
|
||||
|
||||
let handled = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-plugins",
|
||||
XaiSessionUpdate::PluginsChanged {
|
||||
plugins: vec![crate::views::extensions_modal::test_plugin_info(
|
||||
"user-tool",
|
||||
Some(kigi_hooks_plugins_types::PluginOrigin::UserGrok),
|
||||
)],
|
||||
},
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(handled);
|
||||
|
||||
let modal = app.agents[&AgentId(0)].extensions_modal.as_ref().unwrap();
|
||||
match &modal.plugins_data {
|
||||
TabDataState::Loaded(response) => {
|
||||
assert_eq!(response.plugins.len(), 1);
|
||||
assert_eq!(response.plugins[0].name, "user-tool");
|
||||
}
|
||||
other => panic!("expected Loaded plugins data, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
modal.plugins_collapsed_groups,
|
||||
std::collections::HashSet::from(["origin:user-claude".to_string()]),
|
||||
"live PluginsChanged refresh must not touch collapse state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_changed_seeds_collapse_when_it_wins_the_first_load_race() {
|
||||
use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab, TabDataState};
|
||||
|
||||
// Fresh modal: the push lands before the initial list fetch returns.
|
||||
let mut app = make_app_with_agent("sess-plugins");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().extensions_modal =
|
||||
Some(ExtensionsModalState::new(ExtensionsTab::Plugins));
|
||||
|
||||
let handled = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-plugins",
|
||||
XaiSessionUpdate::PluginsChanged {
|
||||
plugins: vec![
|
||||
crate::views::extensions_modal::test_plugin_info(
|
||||
"user-tool",
|
||||
Some(kigi_hooks_plugins_types::PluginOrigin::UserGrok),
|
||||
),
|
||||
crate::views::extensions_modal::test_plugin_info(
|
||||
"claude-tool",
|
||||
Some(kigi_hooks_plugins_types::PluginOrigin::UserClaude),
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(handled);
|
||||
|
||||
let modal = app.agents[&AgentId(0)].extensions_modal.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
modal.plugins_collapsed_groups,
|
||||
std::collections::HashSet::from([
|
||||
"origin:user".to_string(),
|
||||
"origin:user-claude".to_string()
|
||||
]),
|
||||
"push winning the first-load race must seed the collapsed default"
|
||||
);
|
||||
match &modal.plugins_data {
|
||||
TabDataState::Loaded(response) => assert_eq!(response.plugins.len(), 2),
|
||||
other => panic!("expected Loaded plugins data, got {other:?}"),
|
||||
}
|
||||
|
||||
// The push counts as the one seeding: expand a group, deliver again,
|
||||
// and the expansion must survive.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.plugins_collapsed_groups
|
||||
.remove("origin:user");
|
||||
let handled = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-plugins",
|
||||
XaiSessionUpdate::PluginsChanged {
|
||||
plugins: vec![crate::views::extensions_modal::test_plugin_info(
|
||||
"user-tool",
|
||||
Some(kigi_hooks_plugins_types::PluginOrigin::UserGrok),
|
||||
)],
|
||||
},
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(handled);
|
||||
let modal = app.agents[&AgentId(0)].extensions_modal.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
modal.plugins_collapsed_groups,
|
||||
std::collections::HashSet::from(["origin:user-claude".to_string()]),
|
||||
"deliveries after the push-seed must preserve expand state"
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,492 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_happy_path_enqueues_and_drains() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"taskId": "task-42",
|
||||
"prompt": "/pr-babysit check",
|
||||
"humanSchedule": "every 5m",
|
||||
});
|
||||
let notif = make_inject_notif(&payload);
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(result);
|
||||
|
||||
// Agent should now be in TurnRunning (drain happened, prompt was sent).
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.state.is_turn_running());
|
||||
assert!(agent.session.pending_prompts.is_empty());
|
||||
|
||||
// Scrollback should have a cron prompt block.
|
||||
assert!(!agent.scrollback.is_empty());
|
||||
|
||||
// pending_effects should contain a SendPromptBlocks with system-reminder framing,
|
||||
// displayText/displayAsCron meta, and a scheduler-fired- prompt_id prefix.
|
||||
match &app.pending_effects[0] {
|
||||
Effect::SendPromptBlocks {
|
||||
blocks, prompt_id, ..
|
||||
} => {
|
||||
let text = match &blocks[0] {
|
||||
acp::ContentBlock::Text(t) => &t.text,
|
||||
_ => panic!("expected Text block"),
|
||||
};
|
||||
assert!(
|
||||
text.contains("<system-reminder>"),
|
||||
"missing system-reminder framing"
|
||||
);
|
||||
assert!(text.contains("task-42"), "missing task_id");
|
||||
assert!(text.contains("every 5m"), "missing schedule");
|
||||
assert!(text.contains("/pr-babysit check"), "missing prompt");
|
||||
assert!(
|
||||
prompt_id.starts_with("scheduler-fired-"),
|
||||
"cron prompt_id must start with 'scheduler-fired-' for data pipeline tagging, got: {prompt_id}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected SendPromptBlocks, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_drives_even_when_attached_as_viewer() {
|
||||
// The leader routes `x.ai/scheduled_task_inject_prompt` to the SINGLE
|
||||
// session driver, so any client that receives it IS the driver and must
|
||||
// enqueue + run it — even one that attached via `session/load`
|
||||
// (`attached_as_viewer == true`). Previously this handler latched on
|
||||
// `attached_as_viewer` and skipped, which stranded the cron loop with no
|
||||
// output whenever the designated driver was an attacher (the sticky-flag
|
||||
// bug). Pin the corrected behavior: the inject drives the turn.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().attached_as_viewer = true;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"taskId": "task-42",
|
||||
"prompt": "echo hello",
|
||||
"humanSchedule": "every 1m",
|
||||
});
|
||||
let result = handle_scheduled_task_inject_prompt(&make_inject_notif(&payload), &mut app);
|
||||
assert!(result);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.session.state.is_turn_running(),
|
||||
"the designated driver must start the cron turn even when it attached as a viewer"
|
||||
);
|
||||
assert!(
|
||||
agent.session.pending_prompts.is_empty(),
|
||||
"the cron prompt must drain (not linger queued) on the driver"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
app.pending_effects.first(),
|
||||
Some(Effect::SendPromptBlocks { .. })
|
||||
),
|
||||
"the driver must emit a SendPromptBlocks effect for the cron turn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_malformed_json_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let raw = serde_json::value::to_raw_value(&"not a json object").unwrap();
|
||||
let notif = acp::ExtNotification::new("x.ai/scheduled_task_inject_prompt", raw.into());
|
||||
|
||||
// The JSON is valid (a string), but sessionId/prompt fields won't exist.
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_empty_prompt_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"prompt": "",
|
||||
});
|
||||
let notif = make_inject_notif(&payload);
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(!result);
|
||||
// Nothing should be enqueued.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.pending_prompts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_no_matching_agent_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-other",
|
||||
"prompt": "do something",
|
||||
});
|
||||
let notif = make_inject_notif(&payload);
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(!result);
|
||||
// Agent should still be idle, nothing enqueued.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.state.is_idle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_busy_agent_enqueues_without_draining() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
// Make the agent busy.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"prompt": "/pr-babysit check",
|
||||
});
|
||||
let notif = make_inject_notif(&payload);
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(result);
|
||||
|
||||
// Prompt should be queued but not drained (agent was busy).
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.session.pending_prompts.len(), 1);
|
||||
assert_eq!(
|
||||
agent.session.pending_prompts[0].kind,
|
||||
crate::app::agent::QueueEntryKind::Cron
|
||||
);
|
||||
// No effects produced (drain was a no-op since agent was busy).
|
||||
assert!(app.pending_effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_skips_duplicate_while_queued() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
// Busy with an unrelated turn so the cron prompt queues instead of draining.
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.state = AgentState::TurnRunning;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"taskId": "loop-7",
|
||||
"prompt": "check deploy",
|
||||
"humanSchedule": "every 1m",
|
||||
});
|
||||
|
||||
assert!(handle_scheduled_task_inject_prompt(
|
||||
&make_inject_notif(&payload),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.pending_prompts
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
// A re-fire of the same task while it is still queued must not pile up.
|
||||
assert!(handle_scheduled_task_inject_prompt(
|
||||
&make_inject_notif(&payload),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.pending_prompts
|
||||
.len(),
|
||||
1,
|
||||
"a re-fire of the same loop must dedupe in the queue, not accumulate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_skips_duplicate_while_running() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"taskId": "loop-7",
|
||||
"prompt": "check deploy",
|
||||
"humanSchedule": "every 1m",
|
||||
});
|
||||
|
||||
// First fire on an idle agent drains into a running cron turn.
|
||||
assert!(handle_scheduled_task_inject_prompt(
|
||||
&make_inject_notif(&payload),
|
||||
&mut app
|
||||
));
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.state.is_turn_running());
|
||||
assert!(agent.session.pending_prompts.is_empty());
|
||||
|
||||
// A re-fire while that same loop turn is running must be skipped, not queued.
|
||||
assert!(handle_scheduled_task_inject_prompt(
|
||||
&make_inject_notif(&payload),
|
||||
&mut app
|
||||
));
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.pending_prompts
|
||||
.is_empty(),
|
||||
"a re-fire while the loop turn runs must not enqueue a duplicate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_missing_session_id_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let payload = serde_json::json!({
|
||||
"prompt": "do something",
|
||||
});
|
||||
let notif = make_inject_notif(&payload);
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fired_known_task_updates_next_fire_at_only() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let original_created_at = Instant::now() - std::time::Duration::from_secs(60);
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.scheduled_tasks.insert(
|
||||
"task-1".into(),
|
||||
crate::app::agent::ScheduledTaskInfo {
|
||||
task_id: "task-1".into(),
|
||||
prompt: "original prompt".into(),
|
||||
human_schedule: "every 5 minutes".into(),
|
||||
created_at: original_created_at,
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let notif = make_fired_notif(
|
||||
"sess-1",
|
||||
"task-1",
|
||||
// Different field values to verify they are NOT copied over.
|
||||
"DIFFERENT",
|
||||
"every 1 hour",
|
||||
Some("2026-02-02T02:02:02Z"),
|
||||
);
|
||||
assert!(handle_scheduled_task_fired(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.session.scheduled_tasks.get("task-1").unwrap();
|
||||
assert_eq!(info.prompt, "original prompt", "prompt must not change");
|
||||
assert_eq!(
|
||||
info.human_schedule, "every 5 minutes",
|
||||
"human_schedule must not change"
|
||||
);
|
||||
assert_eq!(
|
||||
info.created_at, original_created_at,
|
||||
"created_at must not change"
|
||||
);
|
||||
assert_eq!(
|
||||
info.next_fire_at.as_deref(),
|
||||
Some("2026-02-02T02:02:02Z"),
|
||||
"next_fire_at must be updated"
|
||||
);
|
||||
assert_eq!(
|
||||
agent.session.scheduled_tasks.len(),
|
||||
1,
|
||||
"no extra entry should be inserted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fired_unknown_task_inserts_entry_from_payload() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let before = Instant::now();
|
||||
let notif = make_fired_notif(
|
||||
"sess-1",
|
||||
"task-new",
|
||||
"scheduled prompt",
|
||||
"every 10 minutes",
|
||||
Some("2026-03-03T03:03:03Z"),
|
||||
);
|
||||
assert!(handle_scheduled_task_fired(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.session.scheduled_tasks.len(), 1);
|
||||
let info = agent.session.scheduled_tasks.get("task-new").unwrap();
|
||||
assert_eq!(info.task_id, "task-new");
|
||||
assert_eq!(info.prompt, "scheduled prompt");
|
||||
assert_eq!(info.human_schedule, "every 10 minutes");
|
||||
assert_eq!(info.next_fire_at.as_deref(), Some("2026-03-03T03:03:03Z"));
|
||||
assert!(
|
||||
info.created_at >= before && info.created_at <= Instant::now(),
|
||||
"created_at should be set to roughly now"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fired_unknown_task_with_none_next_fire_skips_insert() {
|
||||
// Mirrors handle_missed_tasks output: a missed one-shot fires with
|
||||
// next_fire_at: None and is immediately removed. The pane should not
|
||||
// flicker an entry that the Removed will instantly drop.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let notif = make_fired_notif(
|
||||
"sess-1",
|
||||
"missed-1",
|
||||
"missed prompt",
|
||||
"every 1 minute",
|
||||
None,
|
||||
);
|
||||
assert!(handle_scheduled_task_fired(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.session.scheduled_tasks.is_empty(),
|
||||
"no entry should be inserted for a Vacant + None fire"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fired_known_task_with_none_next_fire_clears_field() {
|
||||
// The Vacant short-circuit on next_fire_at: None must NOT apply to
|
||||
// Occupied — clearing an existing countdown is correct behaviour.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.scheduled_tasks.insert(
|
||||
"task-1".into(),
|
||||
crate::app::agent::ScheduledTaskInfo {
|
||||
task_id: "task-1".into(),
|
||||
prompt: "p".into(),
|
||||
human_schedule: "every 1 minute".into(),
|
||||
created_at: Instant::now(),
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let notif = make_fired_notif("sess-1", "task-1", "p", "every 1 minute", None);
|
||||
assert!(handle_scheduled_task_fired(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.session.scheduled_tasks.get("task-1").unwrap();
|
||||
assert!(info.next_fire_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fired_updates_correct_agent_when_active_view_differs() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Seed a known task on agent 0.
|
||||
{
|
||||
let agent0 = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent0.session.scheduled_tasks.insert(
|
||||
"task-owner".into(),
|
||||
crate::app::agent::ScheduledTaskInfo {
|
||||
task_id: "task-owner".into(),
|
||||
prompt: "check PR".into(),
|
||||
human_schedule: "every 5m".into(),
|
||||
created_at: Instant::now(),
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Fire notification targets agent 0's session, but active_view
|
||||
// points to agent 1. Return value is "needs redraw" — false is
|
||||
// correct when the mutated agent is not the active view.
|
||||
let notif = make_fired_notif(
|
||||
"sess-owner",
|
||||
"task-owner",
|
||||
"check PR",
|
||||
"every 5m",
|
||||
Some("2026-06-01T12:00:00Z"),
|
||||
);
|
||||
let needs_redraw = handle_scheduled_task_fired(¬if, &mut app);
|
||||
assert!(
|
||||
!needs_redraw,
|
||||
"non-active agent mutation should not trigger redraw"
|
||||
);
|
||||
|
||||
// Agent 0's next_fire_at must be updated.
|
||||
let agent0 = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent0.session.scheduled_tasks.get("task-owner").unwrap();
|
||||
assert_eq!(
|
||||
info.next_fire_at.as_deref(),
|
||||
Some("2026-06-01T12:00:00Z"),
|
||||
"next_fire_at must update on the owning agent, not the active one"
|
||||
);
|
||||
|
||||
// Agent 1 must be completely untouched.
|
||||
let agent1 = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert!(
|
||||
agent1.session.scheduled_tasks.is_empty(),
|
||||
"non-owning agent must not receive the update"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_updates_correct_agent_when_active_view_differs() {
|
||||
let mut app = make_app_two_agents();
|
||||
let notif = make_created_ext_notif(
|
||||
"sess-owner",
|
||||
"task-new",
|
||||
"check PR",
|
||||
"every 5m",
|
||||
Some("2026-06-01T12:00:00Z"),
|
||||
);
|
||||
let needs_redraw = handle_scheduled_task_created(¬if, &mut app);
|
||||
assert!(
|
||||
!needs_redraw,
|
||||
"non-active agent mutation should not trigger redraw"
|
||||
);
|
||||
|
||||
let agent0 = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent0.session.scheduled_tasks.contains_key("task-new"),
|
||||
"task must be created on the owning agent"
|
||||
);
|
||||
|
||||
let agent1 = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert!(
|
||||
agent1.session.scheduled_tasks.is_empty(),
|
||||
"non-owning agent must not receive the task"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_removes_from_correct_agent_when_active_view_differs() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Seed task on agent 0.
|
||||
{
|
||||
let agent0 = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent0.session.scheduled_tasks.insert(
|
||||
"task-rm".into(),
|
||||
crate::app::agent::ScheduledTaskInfo {
|
||||
task_id: "task-rm".into(),
|
||||
prompt: "check PR".into(),
|
||||
human_schedule: "every 5m".into(),
|
||||
created_at: Instant::now(),
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let notif = make_deleted_ext_notif("sess-owner", "task-rm");
|
||||
let needs_redraw = handle_scheduled_task_deleted(¬if, &mut app);
|
||||
assert!(
|
||||
!needs_redraw,
|
||||
"non-active agent mutation should not trigger redraw"
|
||||
);
|
||||
|
||||
let agent0 = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent0.session.scheduled_tasks.is_empty(),
|
||||
"task must be removed from the owning agent"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,843 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
// ── apply_session_event ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn apply_compaction_started_sets_activity() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "hi".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(1),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
let update = XaiSessionUpdate::AutoCompactStarted {
|
||||
tokens_used: 90000,
|
||||
context_window: 131072,
|
||||
percentage: 85,
|
||||
reason: "threshold".into(),
|
||||
};
|
||||
assert!(apply_session_event(&update, &mut session, &mut scrollback, false));
|
||||
assert!(
|
||||
session.in_flight_prompt.is_none(),
|
||||
"compaction start implies server activity — cancel must not rewind prompt"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ImageDropped` joins notes with `\n` and pushes a system block.
|
||||
/// Pin the `\n` separator so a `notes.join(" ")` regression is caught.
|
||||
#[test]
|
||||
fn apply_image_dropped_pushes_scrollback_block() {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
let before = scrollback.len();
|
||||
let notes = vec![
|
||||
"Image 1 was dropped: corrupt.".to_string(),
|
||||
"Image 2 was dropped: too small (4×3).".to_string(),
|
||||
];
|
||||
let update = XaiSessionUpdate::ImageDropped {
|
||||
notes: notes.clone(),
|
||||
};
|
||||
let changed = apply_session_event(&update, &mut session, &mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(changed);
|
||||
assert_eq!(scrollback.len(), before + 1);
|
||||
let entry = scrollback.entries_mut().last().expect("entry pushed");
|
||||
match &entry.block {
|
||||
RenderBlock::System(b) => {
|
||||
assert!(b.text.contains(¬es[0]));
|
||||
assert!(b.text.contains(¬es[1]));
|
||||
assert!(
|
||||
b.text.contains('\n'),
|
||||
"expected \\n separator between dropped notes, got: {:?}",
|
||||
b.text
|
||||
);
|
||||
}
|
||||
other => panic!("expected System block, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A successful compression needs no user action: log-only — no toast,
|
||||
/// no scrollback block, no redraw. Same live and on session replay.
|
||||
#[test]
|
||||
fn image_compressed_is_invisible_in_tui() {
|
||||
for replay in [false, true] {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent.session.loading_replay = replay;
|
||||
assert!(!apply_image_compressed(
|
||||
&mut agent,
|
||||
&[compressed_entry(1), compressed_entry(2)],
|
||||
"Compressed Image 1: 4.2 MB (3024x1964) \u{2192} 780 KB (1568x1018)",
|
||||
));
|
||||
assert!(agent.toast.is_none(), "no toast (replay={replay})");
|
||||
assert_eq!(agent.scrollback.len(), 0, "no block (replay={replay})");
|
||||
}
|
||||
}
|
||||
|
||||
/// The re-encode fallback (empty `images`) means the oversized original
|
||||
/// was kept — a persistent warning line, not a transient toast.
|
||||
#[test]
|
||||
fn image_compressed_fallback_warning_stays_in_scrollback() {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
let msg = "Image 1 could not be re-encoded under the 1.5 MB limit; the original attachment was kept.";
|
||||
assert!(apply_image_compressed(&mut agent, &[], msg));
|
||||
assert!(agent.toast.is_none(), "warning must not be transient");
|
||||
let entry = agent.scrollback.entries_mut().last().expect("block pushed");
|
||||
match &entry.block {
|
||||
RenderBlock::System(b) => assert_eq!(b.text, msg),
|
||||
other => panic!("expected System block, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_retrying_clears_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "retry me".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(2),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
let retry = RetryState::Retrying {
|
||||
attempt: 1,
|
||||
max_retries: 3,
|
||||
reason: "rate limited".into(),
|
||||
};
|
||||
apply_retry_state(&retry, &mut session, &mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_none(),
|
||||
"RetryState bypasses session/update in_flight hook"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_exhausted_rate_limited_sets_flag() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
|
||||
assert!(!session.rate_limited);
|
||||
apply_retry_state(
|
||||
&RetryState::Exhausted {
|
||||
attempts: 3,
|
||||
reason: "rate limited".into(),
|
||||
is_rate_limited: true,
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.rate_limited,
|
||||
"rate_limited flag must be set when is_rate_limited is true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_exhausted_rate_limited_message_is_auth_aware() {
|
||||
use kigi_shell::sampling::error::{
|
||||
RATE_LIMITED_USER_MESSAGE_API_KEY, RATE_LIMITED_USER_MESSAGE_OAUTH,
|
||||
};
|
||||
|
||||
let exhausted = RetryState::Exhausted {
|
||||
attempts: 3,
|
||||
reason: "rate limited".into(),
|
||||
is_rate_limited: true,
|
||||
};
|
||||
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(&exhausted, &mut session, &mut scrollback, false);
|
||||
match last_session_event(&scrollback) {
|
||||
Some(SessionEvent::RetryFailed { error, .. }) => {
|
||||
assert_eq!(error, RATE_LIMITED_USER_MESSAGE_OAUTH);
|
||||
}
|
||||
other => panic!("expected OAuth rate-limit RetryFailed, got {other:?}"),
|
||||
}
|
||||
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(&exhausted, &mut session, &mut scrollback, true);
|
||||
match last_session_event(&scrollback) {
|
||||
Some(SessionEvent::RetryFailed { error, .. }) => {
|
||||
assert_eq!(error, RATE_LIMITED_USER_MESSAGE_API_KEY);
|
||||
}
|
||||
other => panic!("expected API-key rate-limit RetryFailed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_exhausted_non_rate_limited_does_not_set_flag() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
|
||||
apply_retry_state(
|
||||
&RetryState::Exhausted {
|
||||
attempts: 3,
|
||||
reason: "server error".into(),
|
||||
is_rate_limited: false,
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
!session.rate_limited,
|
||||
"rate_limited flag must not be set when is_rate_limited is false"
|
||||
);
|
||||
}
|
||||
|
||||
/// A rate-limit exhaustion whose flattened reason carries the
|
||||
/// free-usage code sets both flags and pushes NO generic block (the
|
||||
/// driver shows the paywall modal on PromptResponse; viewers keep no
|
||||
/// marker).
|
||||
#[test]
|
||||
fn retry_exhausted_free_usage_sets_paywall_flag_without_marker() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "try me again".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(2),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
|
||||
apply_retry_state(
|
||||
&RetryState::Exhausted {
|
||||
attempts: 0,
|
||||
reason: "API error (status 429 Too Many Requests): \
|
||||
subscription:free-usage-exhausted: You have used all your free usage."
|
||||
.into(),
|
||||
is_rate_limited: true,
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.rate_limited,
|
||||
"free-usage keeps rate_limited (TurnFailed/toast suppression)"
|
||||
);
|
||||
assert!(session.free_usage_blocked);
|
||||
assert_eq!(
|
||||
scrollback.len(),
|
||||
0,
|
||||
"no RetryFailed marker — the paywall modal shows instead"
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_none(),
|
||||
"free-usage exhaustion clears in_flight_prompt like other failures"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_credit_limit_exhausted_preserves_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "stash me".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(2),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Exhausted {
|
||||
attempts: 3,
|
||||
reason: "status 403: run out of credits".into(),
|
||||
is_rate_limited: false,
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.credit_limit_blocked,
|
||||
"credit_limit_blocked must be set for credit-limit 403"
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_some(),
|
||||
"in_flight_prompt must be preserved so PromptResponse handler can stash it"
|
||||
);
|
||||
assert_eq!(session.in_flight_prompt.unwrap().text, "stash me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_credit_limit_failed_preserves_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "stash me too".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(3),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "proxy_error".into(),
|
||||
message: "status 403: run out of credits".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.credit_limit_blocked,
|
||||
"credit_limit_blocked must be set for credit-limit 403"
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_some(),
|
||||
"in_flight_prompt must be preserved so PromptResponse handler can stash it"
|
||||
);
|
||||
assert_eq!(session.in_flight_prompt.unwrap().text, "stash me too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_pool_402_sets_credit_limit_blocked() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "pool blocked".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(5),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "proxy_error".into(),
|
||||
message:
|
||||
"API error (status 402 Payment Required): Grok Build usage balance exhausted"
|
||||
.into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.credit_limit_blocked,
|
||||
"credit_limit_blocked must be set for pool 402 balance exhausted"
|
||||
);
|
||||
assert!(session.in_flight_prompt.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_non_credit_limit_failed_clears_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "gone".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(4),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "server_error".into(),
|
||||
message: "internal server error".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
!session.credit_limit_blocked,
|
||||
"credit_limit_blocked must NOT be set for non-credit-limit errors"
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_none(),
|
||||
"in_flight_prompt must be cleared for non-credit-limit errors"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_reauthable_failure_matrix() {
|
||||
assert!(is_reauthable_failure(Some("auth"), "Unauthorized (401)"));
|
||||
assert!(is_reauthable_failure(
|
||||
Some("api"),
|
||||
"Unauthorized (401) from https://proxy/v1/responses"
|
||||
));
|
||||
assert!(is_reauthable_failure(None, "Unauthorized (401)"));
|
||||
// legacy_auth carries its own migration guidance — excluded.
|
||||
assert!(!is_reauthable_failure(
|
||||
Some("legacy_auth"),
|
||||
"Unauthorized (401) ... deprecated authentication method"
|
||||
));
|
||||
// Unrelated failures must not be treated as re-authable.
|
||||
assert!(!is_reauthable_failure(
|
||||
Some("server_error"),
|
||||
"internal server error"
|
||||
));
|
||||
assert!(!is_reauthable_failure(Some("api"), "model not found"));
|
||||
}
|
||||
|
||||
/// A 401 with `error_type == "auth"` surfaces the actionable re-auth
|
||||
/// prompt instead of the raw "Retry failed: Unauthorized (401) …" dump.
|
||||
#[test]
|
||||
fn apply_retry_state_auth_failure_pushes_reauth_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "auth".into(),
|
||||
message: "Unauthorized (401) from https://cli-chat-proxy.kigi.com/v1/messages: \
|
||||
no auth context"
|
||||
.into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::ReAuthRequired)
|
||||
),
|
||||
"auth 401 must surface the actionable re-auth prompt"
|
||||
);
|
||||
assert!(!session.credit_limit_blocked);
|
||||
}
|
||||
|
||||
/// A recoverable auth failure preserves `in_flight_prompt` so the
|
||||
/// PromptResponse handler can stash it for auto-resubmit after re-auth.
|
||||
#[test]
|
||||
fn apply_retry_state_auth_failure_preserves_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "retry after login".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(5),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "auth".into(),
|
||||
message: "Unauthorized (401) from https://proxy/v1/messages".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_some(),
|
||||
"in_flight_prompt must be preserved on a recoverable auth failure"
|
||||
);
|
||||
assert_eq!(session.in_flight_prompt.unwrap().text, "retry after login");
|
||||
}
|
||||
|
||||
/// A 401 reported with a non-auth `error_type` but an "Unauthorized
|
||||
/// (401)" message (the `SamplingErrorKind::Api` path) also prompts.
|
||||
#[test]
|
||||
fn apply_retry_state_401_message_without_auth_type_prompts_reauth() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "api".into(),
|
||||
message: "Unauthorized (401) from https://proxy/v1/responses: invalid credentials"
|
||||
.into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::ReAuthRequired)
|
||||
));
|
||||
}
|
||||
|
||||
/// Legacy WebLogin auth keeps its verbose message (with `grok logout` /
|
||||
/// `grok login` guidance), not the generic re-auth prompt.
|
||||
#[test]
|
||||
fn apply_retry_state_legacy_auth_keeps_detailed_message() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "legacy_auth".into(),
|
||||
message: "Unauthorized (401) ... deprecated authentication method (WebLogin) ... \
|
||||
run `grok logout` then `grok login`"
|
||||
.into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::RetryFailed { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// Non-auth terminal failures still render the standard RetryFailed.
|
||||
#[test]
|
||||
fn apply_retry_state_generic_failure_still_shows_retry_failed() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "server_error".into(),
|
||||
message: "internal server error".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::RetryFailed { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// A context overflow surfaces the actionable `ContextTooLarge` prompt (not the
|
||||
/// raw `RetryFailed`); `PromptResponse` then suppresses the redundant `TurnFailed`.
|
||||
#[test]
|
||||
fn apply_retry_state_context_length_shows_context_too_large() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "context_length".into(),
|
||||
message: "API error (status 500): the prompt is too long for this model's \
|
||||
context window"
|
||||
.into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::ContextTooLarge)
|
||||
),
|
||||
"context overflow must surface the actionable ContextTooLarge prompt"
|
||||
);
|
||||
}
|
||||
|
||||
/// When the compaction handler already showed its "too large to compact" message,
|
||||
/// the overflow path does NOT stack a second `ContextTooLarge` prompt on top.
|
||||
#[test]
|
||||
fn apply_retry_state_context_length_does_not_duplicate_compaction_failed() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
scrollback.push_block(RenderBlock::session_event(SessionEvent::CompactionFailed {
|
||||
error: "this conversation is too large to compact.".into(),
|
||||
}));
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "context_length".into(),
|
||||
message: "the prompt is too long for this model's context window".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::CompactionFailed { .. })
|
||||
),
|
||||
"must not push a duplicate prompt on top of CompactionFailed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_compaction_completed_defers_message_until_turn_end() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.set_compaction_activity(Some(TurnActivity::AutoCompacting));
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(858_000),
|
||||
tokens_after: 66_000,
|
||||
elapsed_ms: Some(500),
|
||||
summary_preview: None,
|
||||
};
|
||||
assert!(apply_session_event(&update, &mut session, &mut scrollback, false));
|
||||
assert_eq!(
|
||||
scrollback.len(),
|
||||
0,
|
||||
"live compaction completion must be deferred, not pushed immediately"
|
||||
);
|
||||
|
||||
session.note_context_used(43_000);
|
||||
|
||||
session.finish_turn(&mut scrollback);
|
||||
match last_session_event(&scrollback) {
|
||||
Some(SessionEvent::CompactionCompleted {
|
||||
tokens_before,
|
||||
tokens_after,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(tokens_before, Some(858_000));
|
||||
assert_eq!(
|
||||
tokens_after, 43_000,
|
||||
"must flush the model-confirmed count, not the 66k estimate"
|
||||
);
|
||||
}
|
||||
other => panic!("expected deferred CompactionCompleted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_compaction_completed_falls_back_to_estimate_without_confirmation() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(90_000),
|
||||
tokens_after: 20_000,
|
||||
elapsed_ms: Some(500),
|
||||
summary_preview: None,
|
||||
};
|
||||
assert!(apply_session_event(&update, &mut session, &mut scrollback, false));
|
||||
session.finish_turn(&mut scrollback);
|
||||
match last_session_event(&scrollback) {
|
||||
Some(SessionEvent::CompactionCompleted { tokens_after, .. }) => {
|
||||
assert_eq!(
|
||||
tokens_after, 20_000,
|
||||
"fallback to estimate when unconfirmed"
|
||||
);
|
||||
}
|
||||
other => panic!("expected fallback CompactionCompleted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_compaction_completed_renders_immediately_during_replay() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
session.loading_replay = true;
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(90_000),
|
||||
tokens_after: 20_000,
|
||||
elapsed_ms: Some(500),
|
||||
summary_preview: None,
|
||||
};
|
||||
assert!(apply_session_event(&update, &mut session, &mut scrollback, false));
|
||||
match last_session_event(&scrollback) {
|
||||
Some(SessionEvent::CompactionCompleted { tokens_after, .. }) => {
|
||||
assert_eq!(
|
||||
tokens_after, 20_000,
|
||||
"replay renders the recorded count immediately"
|
||||
);
|
||||
}
|
||||
other => panic!("expected immediate CompactionCompleted on replay, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_compaction_flushes_confirmed_count_over_estimate_refresh() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent
|
||||
.session
|
||||
.set_compaction_activity(Some(TurnActivity::AutoCompacting));
|
||||
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(858_000),
|
||||
tokens_after: 66_000,
|
||||
elapsed_ms: Some(500),
|
||||
summary_preview: None,
|
||||
};
|
||||
assert!(apply_session_event(
|
||||
&update,
|
||||
&mut agent.session,
|
||||
&mut agent.scrollback,
|
||||
false,
|
||||
));
|
||||
|
||||
refresh_context_used(&mut agent, 66_000);
|
||||
confirm_context_used(&mut agent, 43_000);
|
||||
|
||||
agent.session.finish_turn(&mut agent.scrollback);
|
||||
match last_session_event(&agent.scrollback) {
|
||||
Some(SessionEvent::CompactionCompleted {
|
||||
tokens_before,
|
||||
tokens_after,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(tokens_before, Some(858_000));
|
||||
assert_eq!(
|
||||
tokens_after, 43_000,
|
||||
"deferred line must flush the confirmed 43k, not the 66k \
|
||||
estimate refresh that updated the bar first"
|
||||
);
|
||||
}
|
||||
other => panic!("expected deferred CompactionCompleted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_unhandled_event_returns_false() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
let update = XaiSessionUpdate::MemoryFlushStarted;
|
||||
assert!(!apply_session_event(&update, &mut session, &mut scrollback, false));
|
||||
}
|
||||
|
||||
// ── handle_child_session_notification ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn child_compact_completed_updates_subagent_info() {
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
let child_sid = "child-sess-1";
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_sid.into(), make_subagent_info(child_sid));
|
||||
let child_view = make_agent(Some(child_sid));
|
||||
agent
|
||||
.subagent_views
|
||||
.insert(child_sid.into(), Box::new(child_view));
|
||||
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(90000),
|
||||
tokens_after: 25000,
|
||||
elapsed_ms: Some(300),
|
||||
summary_preview: None,
|
||||
};
|
||||
let changed = handle_child_session_notification(update, child_sid, &mut agent, false);
|
||||
assert!(changed);
|
||||
|
||||
let info = agent.subagent_sessions.get(child_sid).unwrap();
|
||||
assert_eq!(info.tokens_used, Some(25000));
|
||||
// 25000 / 131072 * 100 ~= 19
|
||||
assert_eq!(info.context_usage_pct, Some(19));
|
||||
|
||||
// The child view's context_state.used (context-bar numerator) must
|
||||
// also be reset — see the comment in handle_child_session_notification.
|
||||
let child_view = agent.subagent_views.get(child_sid).unwrap();
|
||||
assert_eq!(
|
||||
child_view.context_state.as_ref().map(|c| c.used),
|
||||
Some(25000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_compact_started_does_not_reset_context_used() {
|
||||
// Sibling variants in the same outer arm must not touch the numerator;
|
||||
// guards against accidental widening of the AutoCompactCompleted gate.
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
let child_sid = "child-sess-3";
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_sid.into(), make_subagent_info(child_sid));
|
||||
let mut child_view = make_agent(Some(child_sid));
|
||||
child_view.context_state = Some(kigi_shell::session::ContextInfo::from_notification(
|
||||
90_000, 131_072,
|
||||
));
|
||||
agent
|
||||
.subagent_views
|
||||
.insert(child_sid.into(), Box::new(child_view));
|
||||
|
||||
let update = XaiSessionUpdate::AutoCompactStarted {
|
||||
tokens_used: 95_000,
|
||||
context_window: 131_072,
|
||||
percentage: 72,
|
||||
reason: "threshold".into(),
|
||||
};
|
||||
let _ = handle_child_session_notification(update, child_sid, &mut agent, false);
|
||||
|
||||
let child_view = agent.subagent_views.get(child_sid).unwrap();
|
||||
assert_eq!(
|
||||
child_view.context_state.as_ref().map(|c| c.used),
|
||||
Some(90_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_notification_without_view_returns_false() {
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
// No child view registered.
|
||||
let update = XaiSessionUpdate::AutoCompactStarted {
|
||||
tokens_used: 90000,
|
||||
context_window: 131072,
|
||||
percentage: 85,
|
||||
reason: "threshold".into(),
|
||||
};
|
||||
let changed = handle_child_session_notification(update, "unknown-child", &mut agent, false);
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_compact_completed_without_view_returns_false() {
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
let child_sid = "child-sess-2";
|
||||
// SubagentInfo exists but no child view (race between notification and spawn).
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_sid.into(), make_subagent_info(child_sid));
|
||||
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(90000),
|
||||
tokens_after: 25000,
|
||||
elapsed_ms: Some(300),
|
||||
summary_preview: None,
|
||||
};
|
||||
let changed = handle_child_session_notification(update, child_sid, &mut agent, false);
|
||||
// No child_view means nothing visible changed — must not trigger redraw.
|
||||
assert!(!changed);
|
||||
// SubagentInfo should still be updated (data correctness).
|
||||
let info = agent.subagent_sessions.get(child_sid).unwrap();
|
||||
assert_eq!(info.tokens_used, Some(25000));
|
||||
assert_eq!(info.context_usage_pct, Some(19));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_unknown_event_returns_false() {
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
let update = XaiSessionUpdate::MemoryFlushStarted;
|
||||
let changed = handle_child_session_notification(update, "child-1", &mut agent, false);
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
// ── apply_retry_state ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn retry_failed_encrypted_content_sets_model_incompatible() {
|
||||
use kigi_shell::extensions::notification::RetryState;
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
|
||||
assert!(!session.model_incompatible);
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "encrypted_content_mismatch".into(),
|
||||
message: "incompatible history".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.model_incompatible,
|
||||
"encrypted_content_mismatch should set model_incompatible flag"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_failed_other_type_does_not_set_model_incompatible() {
|
||||
use kigi_shell::extensions::notification::RetryState;
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "api_400".into(),
|
||||
message: "bad request".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
!session.model_incompatible,
|
||||
"non-encrypted_content error types must not set model_incompatible"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn acp_chunk_for_inactive_agent_lands_in_its_scrollback() {
|
||||
// Regression: switching away from a streaming agent must not
|
||||
// discard chunks bound for that agent. Before this fix, only
|
||||
// `TaskResult::PromptResponse` survived, so the user saw a bare
|
||||
// "Worked for X.Xs" with no body text.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let affected = handle(make_agent_chunk_message("sess-A", "hello from A"), &mut app);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_message_text(agent_a),
|
||||
"hello from A",
|
||||
"chunk for inactive agent A must land in A's scrollback"
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"chunk routed to a non-active agent must not request a redraw"
|
||||
);
|
||||
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert!(
|
||||
agent_b.scrollback.is_empty(),
|
||||
"active agent B's scrollback must remain untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_chunk_for_active_agent_returns_affected_true() {
|
||||
// Baseline: chunk for the visible agent triggers a redraw.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let affected = handle(make_agent_chunk_message("sess-B", "hello from B"), &mut app);
|
||||
|
||||
assert!(affected, "chunk for active agent must request a redraw");
|
||||
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert_eq!(agent_message_text(agent_b), "hello from B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_chunk_for_subagent_routes_through_parent() {
|
||||
// Subagent (child) chunk must land in the parent's
|
||||
// `subagent_views[child_sid]` even when a different agent is
|
||||
// currently active.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let child_sid = "sess-A-child";
|
||||
{
|
||||
let parent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
parent
|
||||
.subagent_sessions
|
||||
.insert(child_sid.into(), make_subagent_info(child_sid));
|
||||
parent
|
||||
.subagent_views
|
||||
.insert(child_sid.into(), Box::new(make_agent(Some(child_sid))));
|
||||
}
|
||||
|
||||
let affected = handle(
|
||||
make_agent_chunk_message(child_sid, "hello from subagent"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let parent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let child_view = parent
|
||||
.subagent_views
|
||||
.get(child_sid)
|
||||
.expect("child view must still exist");
|
||||
assert_eq!(
|
||||
agent_message_text(child_view),
|
||||
"hello from subagent",
|
||||
"subagent chunk must land in subagent_views[child_sid]"
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"subagent chunk for non-active parent must not request a redraw"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_chunk_with_unknown_session_id_is_dropped_and_no_redraw() {
|
||||
// No agent owns the session_id and the active agent already has a
|
||||
// session_id assigned (so the race-window fallback does not fire).
|
||||
// The notification must be dropped silently.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
// make_app_with_agent already activated AgentId(0); no switch needed.
|
||||
|
||||
let affected = handle(
|
||||
make_agent_chunk_message("sess-unknown", "stray text"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(!affected, "unknown session_id must not request a redraw");
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().scrollback.is_empty(),
|
||||
"agent A must not have absorbed a notification for sess-unknown"
|
||||
);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(1)).unwrap().scrollback.is_empty(),
|
||||
"agent B must not have absorbed a notification for sess-unknown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_none_race_window_routes_to_active_agent() {
|
||||
// Pin the existing race-window semantics: notifications that arrive
|
||||
// before `TaskResult::SessionCreated` (active agent has no session_id
|
||||
// yet) must still land on the active agent.
|
||||
|
||||
// Case 1: active agent A has session_id == None; everyone else has
|
||||
// a real id. Stray notification routes to A.
|
||||
{
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.session_id = None;
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
// make_app_with_agent already activated AgentId(0); no switch needed.
|
||||
|
||||
let _ = handle(
|
||||
make_agent_chunk_message("not-yet-assigned", "racing chunk"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
agent_message_text(app.agents.get(&AgentId(0)).unwrap()),
|
||||
"racing chunk",
|
||||
"race-window fallback should land on active agent A"
|
||||
);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(1)).unwrap().scrollback.is_empty(),
|
||||
"non-active agent B must not absorb the race chunk"
|
||||
);
|
||||
}
|
||||
|
||||
// Case 2: both A and B have session_id == None; the active one wins.
|
||||
{
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.session_id = None;
|
||||
insert_agent(&mut app, AgentId(1), None);
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let _ = handle(
|
||||
make_agent_chunk_message("not-yet-assigned", "racing chunk"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().scrollback.is_empty(),
|
||||
"non-active agent A must not absorb the race chunk"
|
||||
);
|
||||
assert_eq!(
|
||||
agent_message_text(app.agents.get(&AgentId(1)).unwrap()),
|
||||
"racing chunk",
|
||||
"race-window fallback must prefer the active agent (B)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_update_for_inactive_agent_lands_in_its_todo() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
// Sanity: A's todo starts empty.
|
||||
assert_eq!(
|
||||
app.agents.get(&AgentId(0)).unwrap().todo.counts().total(),
|
||||
0,
|
||||
);
|
||||
|
||||
let _ = handle(make_plan_message("sess-A", &["task1", "task2"]), &mut app);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.todo.counts().total(),
|
||||
2,
|
||||
"Plan update must mutate A's todo even when B is active"
|
||||
);
|
||||
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert_eq!(
|
||||
agent_b.todo.counts().total(),
|
||||
0,
|
||||
"active agent B's todo must not absorb A's plan"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_update_for_inactive_agent_bumps_its_generation() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
let initial_gen_a = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.available_commands_generation;
|
||||
|
||||
let _ = handle(
|
||||
make_commands_update_message("sess-A", &["compact", "fork"]),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.session.available_commands.len(),
|
||||
2,
|
||||
"AvailableCommandsUpdate must replace A's commands list"
|
||||
);
|
||||
assert_eq!(
|
||||
agent_a.session.available_commands_generation,
|
||||
initial_gen_a + 1,
|
||||
"AvailableCommandsUpdate must bump A's generation counter"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bg_task_stdout_for_inactive_agent_lands_in_its_bg_tasks() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
// Pre-register a bg task on A so route_bg_task_stdout has a target.
|
||||
let task_id = "task-A-1";
|
||||
let tool_call_id = "call-A-1";
|
||||
{
|
||||
let agent_a = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent_a.session.bg_tasks.insert(
|
||||
task_id.into(),
|
||||
BgTaskState {
|
||||
task_id: task_id.into(),
|
||||
tool_call_id: tool_call_id.into(),
|
||||
command: "sleep 5".into(),
|
||||
description: None,
|
||||
cwd: "/tmp".into(),
|
||||
output_file: "/tmp/out".into(),
|
||||
status: BgTaskStatus::Running,
|
||||
start_time: std::time::SystemTime::now(),
|
||||
end_time: None,
|
||||
exit_code: None,
|
||||
signal: None,
|
||||
stdout: String::new(),
|
||||
stdout_line_count: 0,
|
||||
truncated: false,
|
||||
pending_kill: false,
|
||||
kill_requested_at: None,
|
||||
scrollback_entry_id: None,
|
||||
is_monitor: false,
|
||||
restored_from_replay: false,
|
||||
},
|
||||
);
|
||||
agent_a
|
||||
.session
|
||||
.bg_tool_call_to_task
|
||||
.insert(tool_call_id.into(), task_id.into());
|
||||
}
|
||||
|
||||
let _ = handle(
|
||||
make_bash_stdout_message("sess-A", tool_call_id, "stdout-from-A"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.session.bg_tasks.get(task_id).unwrap().stdout,
|
||||
"stdout-from-A",
|
||||
"Bash stdout must land in A's bg_tasks even when B is active"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_chunks_for_two_agents_dont_cross_contaminate() {
|
||||
// Send chunks to both A and B in sequence; each landing in its own
|
||||
// scrollback proves the demux works in both directions regardless
|
||||
// of which agent is currently active.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let _ = handle(make_agent_chunk_message("sess-A", "A only"), &mut app);
|
||||
let _ = handle(make_agent_chunk_message("sess-B", "B only"), &mut app);
|
||||
|
||||
assert_eq!(
|
||||
agent_message_text(app.agents.get(&AgentId(0)).unwrap()),
|
||||
"A only",
|
||||
);
|
||||
assert_eq!(
|
||||
agent_message_text(app.agents.get(&AgentId(1)).unwrap()),
|
||||
"B only",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_non_api_key_tier_clears_stale_api_key_flag() {
|
||||
let mut app = make_app_with_agent("sess-stale-key");
|
||||
assert!(handle_ext_notification(
|
||||
&tier_settings_update("API Key"),
|
||||
&mut app
|
||||
));
|
||||
assert!(app.is_api_key_auth);
|
||||
assert!(!app.usage_visible);
|
||||
assert!(app.tier_restricted_commands.is_empty());
|
||||
|
||||
// Later personal Free stamp must not keep the API-key bypass.
|
||||
assert!(handle_ext_notification(
|
||||
&tier_settings_update("Free"),
|
||||
&mut app
|
||||
));
|
||||
assert!(!app.is_api_key_auth);
|
||||
assert!(app.usage_visible);
|
||||
assert!(!app.tier_restricted_commands.is_empty());
|
||||
|
||||
// A paid tier after API Key clears the api-key flag and tier limits.
|
||||
let mut app = make_app_with_agent("sess-paid-tier");
|
||||
assert!(handle_ext_notification(
|
||||
&tier_settings_update("API Key"),
|
||||
&mut app
|
||||
));
|
||||
assert!(handle_ext_notification(
|
||||
&tier_settings_update("SuperGrok"),
|
||||
&mut app
|
||||
));
|
||||
assert!(!app.is_api_key_auth);
|
||||
assert!(app.tier_restricted_commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_update_clearing_group_tool_verbs_reverts_to_default() {
|
||||
// Expected values come from the same chain the handler resolves, so the
|
||||
// test holds regardless of host config/env (a local `[ui]` or env
|
||||
// override legitimately beats the remote tier on both legs).
|
||||
let requirements = kigi_shell::config::load_merged_requirements();
|
||||
let user_config = kigi_shell::config::load_from_disk().ok();
|
||||
let managed_config = kigi_shell::config::load_managed_config().ok();
|
||||
let resolve = |remote_val: Option<bool>| {
|
||||
let remote = kigi_shell::util::config::RemoteSettings {
|
||||
group_tool_verbs: remote_val,
|
||||
..Default::default()
|
||||
};
|
||||
kigi_shell::util::config::resolve_group_tool_verbs(
|
||||
requirements.as_ref(),
|
||||
user_config.as_ref(),
|
||||
managed_config.as_ref(),
|
||||
Some(&remote),
|
||||
)
|
||||
.value
|
||||
};
|
||||
let expect_on = resolve(Some(true));
|
||||
let expect_cleared = resolve(None);
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
// Remote enable arrives (redundant with the on-default, still latched).
|
||||
assert!(handle_ext_notification(
|
||||
&group_tool_verbs_settings_update(Some(true)),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
crate::appearance::cache::load_group_tool_verbs(),
|
||||
expect_on,
|
||||
"remote Some(true) must re-resolve into the cache"
|
||||
);
|
||||
|
||||
// remote settings clears the remote tier (field absent → None). Seed the
|
||||
// cache opposite to the expected outcome — the latched remote enable —
|
||||
// so only a real re-resolve can pass; the update must revert it to the
|
||||
// local/default resolution instead of skipping the field. An old
|
||||
// payload without the field takes this same path.
|
||||
crate::appearance::cache::set_group_tool_verbs(!expect_cleared);
|
||||
assert!(handle_ext_notification(
|
||||
&group_tool_verbs_settings_update(None),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
crate::appearance::cache::load_group_tool_verbs(),
|
||||
expect_cleared,
|
||||
"cleared remote tier must re-resolve the full chain, not stay latched"
|
||||
);
|
||||
// Restore default (on) for other tests that share the process cache.
|
||||
crate::appearance::cache::set_group_tool_verbs(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_update_clearing_collapsed_edit_blocks_reverts_to_default() {
|
||||
// Expected values come from the same chain the handler resolves, so the
|
||||
// test holds regardless of host config/env (a local `[ui]` or env
|
||||
// override legitimately beats the remote tier on both legs).
|
||||
let requirements = kigi_shell::config::load_merged_requirements();
|
||||
let user_config = kigi_shell::config::load_from_disk().ok();
|
||||
let managed_config = kigi_shell::config::load_managed_config().ok();
|
||||
let resolve = |remote_val: Option<bool>| {
|
||||
let remote = kigi_shell::util::config::RemoteSettings {
|
||||
collapsed_edit_blocks: remote_val,
|
||||
..Default::default()
|
||||
};
|
||||
kigi_shell::util::config::resolve_collapsed_edit_blocks(
|
||||
requirements.as_ref(),
|
||||
user_config.as_ref(),
|
||||
managed_config.as_ref(),
|
||||
Some(&remote),
|
||||
)
|
||||
.value
|
||||
};
|
||||
let expect_on = resolve(Some(true));
|
||||
let expect_cleared = resolve(None);
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
// remote settings enable arrives (the team rollout path).
|
||||
assert!(handle_ext_notification(
|
||||
&collapsed_edit_blocks_settings_update(Some(true)),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
crate::appearance::cache::load_collapsed_edit_blocks(),
|
||||
expect_on,
|
||||
"remote Some(true) must re-resolve into the cache"
|
||||
);
|
||||
|
||||
// remote settings clears the remote tier (field absent → None). Seed the
|
||||
// cache opposite to the expected outcome — the latched remote enable —
|
||||
// so only a real re-resolve can pass; the update must revert it to the
|
||||
// local/default resolution instead of skipping the field. An old
|
||||
// payload without the field takes this same path.
|
||||
crate::appearance::cache::set_collapsed_edit_blocks(!expect_cleared);
|
||||
assert!(handle_ext_notification(
|
||||
&collapsed_edit_blocks_settings_update(None),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
crate::appearance::cache::load_collapsed_edit_blocks(),
|
||||
expect_cleared,
|
||||
"cleared remote tier must re-resolve the full chain, not stay latched"
|
||||
);
|
||||
// Restore default (off) for other tests that share the process cache.
|
||||
crate::appearance::cache::set_collapsed_edit_blocks(false);
|
||||
}
|
||||
|
||||
/// A remote collapsed_edit_blocks flip re-materializes on-default Edit
|
||||
/// rows in the live transcript (the same policy the settings toggle
|
||||
/// applies via `apply_collapsed_edit_blocks_flip`).
|
||||
#[test]
|
||||
fn settings_update_collapsed_edit_blocks_flip_refolds_live_edits() {
|
||||
use crate::scrollback::types::DisplayMode;
|
||||
|
||||
crate::appearance::cache::set_collapsed_edit_blocks(false);
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let id = {
|
||||
let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
|
||||
sb.push_block(crate::scrollback::block::RenderBlock::ToolCall(
|
||||
crate::scrollback::blocks::tool::ToolCallBlock::Edit(
|
||||
crate::scrollback::blocks::tool::EditToolCallBlock::new("f.rs", vec![]),
|
||||
),
|
||||
))
|
||||
};
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)].scrollback.get_by_id(id).unwrap().display_mode,
|
||||
DisplayMode::Expanded,
|
||||
"flag off materializes expanded"
|
||||
);
|
||||
|
||||
assert!(handle_ext_notification(
|
||||
&collapsed_edit_blocks_settings_update(Some(true)),
|
||||
&mut app
|
||||
));
|
||||
if !crate::appearance::cache::load_collapsed_edit_blocks() {
|
||||
// A host-level env/config override outranked the remote value, so
|
||||
// no real flip occurred and the re-fold didn't run — nothing to
|
||||
// assert on this machine (CI runs with clean layers).
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)].scrollback.get_by_id(id).unwrap().display_mode,
|
||||
DisplayMode::Collapsed,
|
||||
"remote enable must collapse the on-default Edit row"
|
||||
);
|
||||
// Restore default (off) for other tests that share the process cache.
|
||||
crate::appearance::cache::set_collapsed_edit_blocks(false);
|
||||
}
|
||||
|
||||
/// The live-refresh flip mirrors `set_group_tool_verbs_inner`'s stale
|
||||
/// group-expansion cleanup: a previously expanded verb slot must not
|
||||
/// survive a remote flip as an expanded header.
|
||||
#[test]
|
||||
fn settings_update_flip_resets_stale_group_expansion() {
|
||||
crate::appearance::cache::set_group_tool_verbs(true);
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
|
||||
for i in 0..3 {
|
||||
sb.push_block(crate::scrollback::block::RenderBlock::read(
|
||||
format!("f{i}.rs"),
|
||||
None,
|
||||
));
|
||||
}
|
||||
sb.prepare_layout(80, 40);
|
||||
sb.set_selected(Some(0));
|
||||
assert!(sb.toggle_group_expansion());
|
||||
sb.prepare_layout(80, 40);
|
||||
let info = sb.get_cached_entry_layouts().unwrap()[0];
|
||||
assert!(info.group_collapse_header, "expanded verb slot armed");
|
||||
}
|
||||
|
||||
assert!(handle_ext_notification(
|
||||
&group_tool_verbs_settings_update(Some(false)),
|
||||
&mut app
|
||||
));
|
||||
if crate::appearance::cache::load_group_tool_verbs() {
|
||||
// A host-level env/config override outranked the remote value, so
|
||||
// no real flip occurred and the cleanup path didn't run — nothing
|
||||
// to assert on this machine (CI runs with clean layers).
|
||||
return;
|
||||
}
|
||||
let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
|
||||
sb.prepare_layout(80, 40);
|
||||
let info = sb.get_cached_entry_layouts().unwrap()[0];
|
||||
assert!(
|
||||
!info.group_collapse_header,
|
||||
"remote flip must drop the stale expansion"
|
||||
);
|
||||
assert!(
|
||||
sb.get_cached_entry_height(1).unwrap_or(0) > 0,
|
||||
"rows render individually after the flip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_gate_killswitch_clears_all_agents_regardless_of_active_mirror() {
|
||||
// Two agents both in auto; the active tab's global mirror reads "ask"
|
||||
// (a tab switch / Shift+Tab re-anchored it away from auto). A
|
||||
// mid-session gate kill-switch (`auto_permission_mode_enabled=false`)
|
||||
// must clear the per-session auto flag on BOTH agents. The old code
|
||||
// gated this fan-out on `current_ui.permission_mode == "auto"`, so it
|
||||
// skipped background agents and left stale `auto_mode` that
|
||||
// `switch_to_agent` could re-anchor back to "auto" on return.
|
||||
let mut app = make_app_two_agents();
|
||||
app.auto_mode_gate = true;
|
||||
for agent in app.agents.values_mut() {
|
||||
agent.session.auto_mode = true;
|
||||
}
|
||||
// Active tab's mirror is NOT "auto" — the old bug's skip condition.
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
|
||||
let killswitch = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(
|
||||
&serde_json::json!({ "auto_permission_mode_enabled": false }),
|
||||
)
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
let _ = handle_ext_notification(&killswitch, &mut app);
|
||||
|
||||
assert!(!app.auto_mode_gate, "gate must be off after kill-switch");
|
||||
for (id, agent) in &app.agents {
|
||||
assert!(
|
||||
!agent.session.auto_mode,
|
||||
"agent {id:?} auto_mode must be cleared by the kill-switch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_gate_killswitch_notifies_agents_to_leave_auto() {
|
||||
// The kill-switch must tell live sessions to leave Auto, else the agent
|
||||
// keeps classifier-approving while the UI shows "Ask". The notification is
|
||||
// CLIENT-scoped, so exactly ONE fires regardless of how many tabs were in
|
||||
// auto; it omits `yolo_mode` so a sibling always-approve tab is preserved.
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = AppView::new(tx, ModelState::default(), Vec::new());
|
||||
// Two auto agents + one always-approve sibling, all with live sessions.
|
||||
app.agents.insert(AgentId(0), make_agent(Some("sess-0")));
|
||||
app.agents.insert(AgentId(1), make_agent(Some("sess-1")));
|
||||
app.agents.insert(AgentId(2), make_agent(Some("sess-yolo")));
|
||||
app.auto_mode_gate = true;
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.auto_mode = true;
|
||||
app.agents.get_mut(&AgentId(1)).unwrap().session.auto_mode = true;
|
||||
app.agents.get_mut(&AgentId(2)).unwrap().session.yolo_mode = true;
|
||||
|
||||
let killswitch = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(
|
||||
&serde_json::json!({ "auto_permission_mode_enabled": false }),
|
||||
)
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
let _ = handle_ext_notification(&killswitch, &mut app);
|
||||
|
||||
assert!(!app.auto_mode_gate, "gate must be off after kill-switch");
|
||||
// Sibling always-approve is untouched — the kill-switch clears only auto.
|
||||
assert!(
|
||||
app.agents[&AgentId(2)].session.is_yolo(),
|
||||
"sibling always-approve must stay yolo after the auto kill-switch"
|
||||
);
|
||||
|
||||
let mut leave_auto_notifs = 0;
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
if let kigi_acp_lib::AcpAgentMessage::ExtNotification(args) = msg {
|
||||
if args.request.method.as_ref() != "x.ai/yolo_mode_changed" {
|
||||
continue;
|
||||
}
|
||||
let params: serde_json::Value =
|
||||
serde_json::from_str(args.request.params.get()).unwrap();
|
||||
assert_eq!(params["auto_mode"], serde_json::json!(false));
|
||||
assert_eq!(params["permission_mode"], serde_json::json!("ask"));
|
||||
assert!(
|
||||
params.get("yolo_mode").is_none(),
|
||||
"yolo_mode must be omitted so a sibling always-approve session is preserved"
|
||||
);
|
||||
leave_auto_notifs += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
leave_auto_notifs, 1,
|
||||
"exactly one client-scoped leave-auto notification, regardless of agent count"
|
||||
);
|
||||
}
|
||||
|
||||
/// User-owned mode must not re-arm default_yolo or rewrite UI from remote.
|
||||
#[test]
|
||||
fn permission_mode_user_claim_blocks_default_yolo_rearm() {
|
||||
let mut app = make_app_with_agent("sess-user-claim");
|
||||
app.auto_mode_gate = true;
|
||||
app.permission_mode_from_soft_default = false;
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
app.default_yolo = false;
|
||||
|
||||
let apply_yolo = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"permission_mode": "always-approve",
|
||||
}))
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
let _ = handle_ext_notification(&apply_yolo, &mut app);
|
||||
assert!(
|
||||
!app.default_yolo,
|
||||
"user-claimed mode must not re-arm default_yolo from remote always-approve"
|
||||
);
|
||||
assert_eq!(
|
||||
app.current_ui.permission_mode.as_deref(),
|
||||
Some("ask"),
|
||||
"user-claimed UI must not be rewritten by remote soft-default"
|
||||
);
|
||||
assert!(
|
||||
!app.permission_mode_from_soft_default,
|
||||
"user claim origin stays false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_mode_omitted_does_not_clear_soft_default() {
|
||||
let mut app = make_app_with_agent("sess-omit-pm");
|
||||
app.permission_mode_from_soft_default = true;
|
||||
app.current_ui.permission_mode = Some("auto".into());
|
||||
app.default_yolo = false;
|
||||
app.auto_mode_gate = true;
|
||||
|
||||
let unrelated = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"show_resolved_model": true,
|
||||
}))
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
let _ = handle_ext_notification(&unrelated, &mut app);
|
||||
assert_eq!(
|
||||
app.current_ui.permission_mode.as_deref(),
|
||||
Some("auto"),
|
||||
"omitted permission_mode must not clear soft-applied UI mode"
|
||||
);
|
||||
assert!(
|
||||
app.permission_mode_from_soft_default,
|
||||
"origin must stay SoftDefault when field is omitted"
|
||||
);
|
||||
assert!(
|
||||
!app.default_yolo,
|
||||
"omitted permission_mode must not recompute default_yolo"
|
||||
);
|
||||
}
|
||||
|
||||
/// Positive wiring: a permission_mode-bearing push with the latch held
|
||||
/// must reach the applier through the real handler. The handler's ambient
|
||||
/// effective-config read decides WHICH mode wins (exact outcomes are
|
||||
/// pinned on the applier with injected TOML), so this asserts the
|
||||
/// applier's host-independent signature instead: the non-canonical
|
||||
/// sentinel display is rewritten to a canonical mode, latch preserved.
|
||||
#[test]
|
||||
fn permission_mode_soft_default_push_reaches_applier() {
|
||||
let mut app = make_app_with_agent("sess-wire-pm");
|
||||
app.auto_mode_gate = true;
|
||||
app.permission_mode_from_soft_default = true;
|
||||
// Outside the applier's output alphabet — only the applier rewrites it.
|
||||
app.current_ui.permission_mode = Some("sentinel-not-a-mode".into());
|
||||
|
||||
let push = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"permission_mode": "always-approve",
|
||||
}))
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
let _ = handle_ext_notification(&push, &mut app);
|
||||
let display = app
|
||||
.current_ui
|
||||
.permission_mode
|
||||
.as_deref()
|
||||
.expect("applier always writes a display mode");
|
||||
assert!(
|
||||
matches!(display, "ask" | "auto" | "always-approve" | "default"),
|
||||
"soft push must rewrite the sentinel display via the applier, got {display:?}"
|
||||
);
|
||||
assert!(
|
||||
app.permission_mode_from_soft_default,
|
||||
"a soft re-arm must keep SoftDefault origin"
|
||||
);
|
||||
}
|
||||
|
||||
/// Soft-origin recompute with injected TOML (deterministic — no host
|
||||
/// config): remote always-approve arms default_yolo + UI, keeps the soft
|
||||
/// latch, and persists nothing.
|
||||
#[test]
|
||||
fn permission_mode_soft_default_applies_remote_always_approve() {
|
||||
let mut app = make_app_with_agent("sess-pm");
|
||||
app.auto_mode_gate = true;
|
||||
app.permission_mode_from_soft_default = true;
|
||||
app.current_ui.permission_mode = None;
|
||||
app.default_yolo = false;
|
||||
|
||||
super::super::settings::apply_soft_default_permission_mode(
|
||||
&mut app,
|
||||
None,
|
||||
Some("always-approve"),
|
||||
);
|
||||
assert!(app.default_yolo, "remote always-approve must arm default_yolo");
|
||||
assert_eq!(
|
||||
app.current_ui.permission_mode.as_deref(),
|
||||
Some("always-approve"),
|
||||
);
|
||||
assert!(
|
||||
app.permission_mode_from_soft_default,
|
||||
"a soft re-arm must keep SoftDefault origin"
|
||||
);
|
||||
assert!(
|
||||
app.pending_effects.is_empty(),
|
||||
"a soft default must never be persisted to disk"
|
||||
);
|
||||
}
|
||||
|
||||
/// Explicit `null` recomputes with remote=None (unlike field omission):
|
||||
/// with no TOML permission key the soft always-approve drops back to Ask.
|
||||
#[test]
|
||||
fn permission_mode_explicit_null_clears_soft_always_approve() {
|
||||
let mut app = make_app_with_agent("sess-null-pm");
|
||||
app.auto_mode_gate = true;
|
||||
app.permission_mode_from_soft_default = true;
|
||||
app.current_ui.permission_mode = Some("always-approve".into());
|
||||
app.default_yolo = true;
|
||||
|
||||
super::super::settings::apply_soft_default_permission_mode(&mut app, None, None);
|
||||
assert!(!app.default_yolo, "remote null must disarm a soft always-approve");
|
||||
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
|
||||
assert!(app.permission_mode_from_soft_default);
|
||||
assert!(
|
||||
app.pending_effects.is_empty(),
|
||||
"a soft default must never be persisted to disk"
|
||||
);
|
||||
}
|
||||
|
||||
/// Policy pin and auto gate clamp a soft re-arm to Ask enforcement/display.
|
||||
#[test]
|
||||
fn permission_mode_soft_default_respects_pin_and_gate() {
|
||||
let mut app = make_app_with_agent("sess-pin-pm");
|
||||
app.permission_mode_from_soft_default = true;
|
||||
app.yolo_policy_block = Some("pinned");
|
||||
app.default_yolo = false;
|
||||
super::super::settings::apply_soft_default_permission_mode(
|
||||
&mut app,
|
||||
None,
|
||||
Some("always-approve"),
|
||||
);
|
||||
assert!(!app.default_yolo, "policy pin must block a remote always-approve");
|
||||
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
|
||||
|
||||
let mut app = make_app_with_agent("sess-gate-pm");
|
||||
app.permission_mode_from_soft_default = true;
|
||||
app.auto_mode_gate = false;
|
||||
super::super::settings::apply_soft_default_permission_mode(&mut app, None, Some("auto"));
|
||||
assert!(!app.default_yolo);
|
||||
assert_eq!(
|
||||
app.current_ui.permission_mode.as_deref(),
|
||||
Some("ask"),
|
||||
"gated-off Auto must display as Ask"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
/// On resume, a replayed spawn+finish pair leaves the subagent terminal.
|
||||
#[test]
|
||||
fn replayed_subagent_finished_marks_orphan_terminal() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
let spawned = subagent_ext_replay(
|
||||
"sess-1",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "subagent_spawned",
|
||||
"subagent_id": "sa-1",
|
||||
"parent_session_id": "sess-1",
|
||||
"child_session_id": "child-1",
|
||||
"subagent_type": "general-purpose",
|
||||
"description": "orphan review",
|
||||
}),
|
||||
"sess-1-1",
|
||||
);
|
||||
handle_ext_notification(&spawned, &mut app);
|
||||
|
||||
let finished = subagent_ext_replay(
|
||||
"sess-1",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "subagent_finished",
|
||||
"subagent_id": "sa-1",
|
||||
"child_session_id": "child-1",
|
||||
"status": "cancelled",
|
||||
"error": "interrupted by process restart",
|
||||
"tool_calls": 0,
|
||||
"turns": 0,
|
||||
"duration_ms": 1000,
|
||||
"tokens_used": 0,
|
||||
}),
|
||||
"sess-1-2",
|
||||
);
|
||||
handle_ext_notification(&finished, &mut app);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent
|
||||
.subagent_sessions
|
||||
.get("child-1")
|
||||
.expect("subagent present after replay");
|
||||
assert!(
|
||||
info.finished,
|
||||
"orphan must be terminal after replayed subagent_finished"
|
||||
);
|
||||
assert_eq!(info.status.as_deref(), Some("cancelled"));
|
||||
}
|
||||
|
||||
/// `cancelled = false` must finalize the row, not revert "killing" to "running".
|
||||
#[test]
|
||||
fn kill_finalizes_orphan_when_shell_reports_not_cancelled() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
let spawned = subagent_ext_replay(
|
||||
"sess-1",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "subagent_spawned",
|
||||
"subagent_id": "sa-1",
|
||||
"parent_session_id": "sess-1",
|
||||
"child_session_id": "child-1",
|
||||
"subagent_type": "general-purpose",
|
||||
"description": "orphan review",
|
||||
}),
|
||||
"sess-1-1",
|
||||
);
|
||||
handle_ext_notification(&spawned, &mut app);
|
||||
|
||||
// User clicks kill after load.
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.loading_replay = false;
|
||||
let info = agent.subagent_sessions.get_mut("child-1").unwrap();
|
||||
assert!(!info.finished);
|
||||
info.pending_kill = true;
|
||||
info.kill_requested_at = Some(std::time::Instant::now());
|
||||
}
|
||||
|
||||
// Shell: cancelled=false (nothing live), no real status → "cancelled".
|
||||
let finalized = finalize_killed_subagent(
|
||||
&mut app,
|
||||
&acp::SessionId::new("sess-1".to_owned()),
|
||||
"sa-1",
|
||||
"cancelled",
|
||||
);
|
||||
assert!(finalized, "row should have been finalized");
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.subagent_sessions.get("child-1").unwrap();
|
||||
assert!(info.finished, "kill must finalize the stuck orphan row");
|
||||
assert_eq!(info.status.as_deref(), Some("cancelled"));
|
||||
assert!(
|
||||
!info.pending_kill,
|
||||
"pending_kill must clear so it can't revert"
|
||||
);
|
||||
assert!(info.kill_requested_at.is_none());
|
||||
}
|
||||
|
||||
/// An already-finished subagent killed → finalize stamps the REAL terminal
|
||||
/// status (e.g. "completed"), not a forced "cancelled".
|
||||
#[test]
|
||||
fn kill_finalizes_orphan_with_real_status_when_already_finished() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
let spawned = subagent_ext_replay(
|
||||
"sess-1",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "subagent_spawned",
|
||||
"subagent_id": "sa-1",
|
||||
"parent_session_id": "sess-1",
|
||||
"child_session_id": "child-1",
|
||||
"subagent_type": "general-purpose",
|
||||
"description": "orphan review",
|
||||
}),
|
||||
"sess-1-1",
|
||||
);
|
||||
handle_ext_notification(&spawned, &mut app);
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.loading_replay = false;
|
||||
let info = agent.subagent_sessions.get_mut("child-1").unwrap();
|
||||
info.pending_kill = true;
|
||||
}
|
||||
|
||||
let finalized = finalize_killed_subagent(
|
||||
&mut app,
|
||||
&acp::SessionId::new("sess-1".to_owned()),
|
||||
"sa-1",
|
||||
"completed",
|
||||
);
|
||||
assert!(finalized, "row should have been finalized");
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.subagent_sessions.get("child-1").unwrap();
|
||||
assert!(info.finished);
|
||||
assert_eq!(
|
||||
info.status.as_deref(),
|
||||
Some("completed"),
|
||||
"already-finished kill must stamp the real terminal status"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: replay from `updates.jsonl` emits `x.ai/session/update` (not
|
||||
/// `session_notification`). Subagent lifecycle events must still populate
|
||||
/// `subagent_sessions` and the parent scrollback `SubagentBlock`.
|
||||
#[test]
|
||||
fn ext_session_update_replay_handles_subagent_spawned_and_finished() {
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
let child_sid = "child-sess-replay";
|
||||
|
||||
let affected = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-parent",
|
||||
"x.ai/session/update",
|
||||
test_subagent_spawned("sess-parent", child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
affected,
|
||||
"SubagentSpawned on the active agent must request a redraw"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.expect("SubagentSpawned must register subagent_sessions");
|
||||
assert_eq!(info.description.as_ref(), "scan src/");
|
||||
assert_eq!(info.subagent_type.as_ref(), "explore");
|
||||
assert!(
|
||||
agent.subagent_views.contains_key(child_sid),
|
||||
"SubagentSpawned must create subagent_views eagerly"
|
||||
);
|
||||
let entry_id = info
|
||||
.scrollback_entry_id
|
||||
.expect("spawn must stash scrollback_entry_id on SubagentInfo");
|
||||
assert_eq!(agent.scrollback.len(), 1);
|
||||
let entry = agent.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("SubagentSpawned must push a SubagentBlock to parent scrollback");
|
||||
};
|
||||
assert_eq!(sb.child_session_id, child_sid);
|
||||
assert!(matches!(sb.kind, SubagentBlockKind::Started));
|
||||
assert!(agent.scrollback.needs_animation());
|
||||
|
||||
let affected = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-parent",
|
||||
"x.ai/session/update",
|
||||
test_subagent_finished(child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
affected,
|
||||
"SubagentFinished on the active agent must request a redraw"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.subagent_sessions.get(child_sid).unwrap();
|
||||
assert!(info.finished);
|
||||
assert_eq!(info.status.as_deref(), Some("completed"));
|
||||
assert_eq!(info.tool_calls, Some(2));
|
||||
assert_eq!(info.turns, Some(1));
|
||||
assert_eq!(info.duration_ms, Some(500));
|
||||
assert_eq!(info.scrollback_entry_id, Some(entry_id));
|
||||
|
||||
let entry = agent.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("finished subagent must keep the started scrollback entry");
|
||||
};
|
||||
match &sb.kind {
|
||||
SubagentBlockKind::Completed { elapsed } => {
|
||||
assert_eq!(*elapsed, std::time::Duration::from_millis(500));
|
||||
}
|
||||
other => {
|
||||
panic!("blocking subagent must mutate started block to Completed, got {other:?}")
|
||||
}
|
||||
}
|
||||
assert!(!entry.is_running, "finish_running must clear running flag");
|
||||
assert!(
|
||||
!agent.scrollback.needs_animation(),
|
||||
"finished subagent entry must not keep scrollback animation"
|
||||
);
|
||||
}
|
||||
|
||||
/// The live activity label fans out to `SubagentInfo` (tasks pane /
|
||||
/// dashboard rows) alongside the scrollback block — from both the child
|
||||
/// session/update path and the `SubagentProgress` path — and
|
||||
/// `SubagentFinished` clears both surfaces.
|
||||
#[test]
|
||||
fn subagent_activity_label_stamps_info_and_clears_on_finish() {
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
let child_sid = "child-activity";
|
||||
let _ = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-parent",
|
||||
test_subagent_spawned("sess-parent", child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// A live child message chunk resolves "Responding" and stamps both
|
||||
// the block and the info.
|
||||
let _ = handle(
|
||||
make_agent_chunk_with_event(child_sid, "child text", "p-child", None),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.subagent_sessions.get(child_sid).unwrap();
|
||||
assert_eq!(info.activity_label.as_deref(), Some("Responding"));
|
||||
let entry_id = info.scrollback_entry_id.unwrap();
|
||||
let entry = agent.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("expected Subagent block");
|
||||
};
|
||||
assert_eq!(sb.activity_label, info.activity_label);
|
||||
|
||||
// SubagentProgress recomputes from the child tracker and restamps.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.subagent_sessions
|
||||
.get_mut(child_sid)
|
||||
.unwrap()
|
||||
.activity_label = None;
|
||||
let _ = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-parent",
|
||||
test_subagent_progress("sess-parent", child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.unwrap()
|
||||
.activity_label
|
||||
.as_deref(),
|
||||
Some("Responding")
|
||||
);
|
||||
|
||||
let _ = handle(
|
||||
make_ext_session_notification("sess-parent", test_subagent_finished(child_sid)),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.subagent_sessions.get(child_sid).unwrap();
|
||||
assert!(
|
||||
info.activity_label.is_none(),
|
||||
"finish must clear the info label"
|
||||
);
|
||||
let entry = agent.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("expected Subagent block");
|
||||
};
|
||||
assert!(
|
||||
sb.activity_label.is_none(),
|
||||
"finish must clear the block label"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: replayed SubagentSpawned (resumed_from unset) must load child
|
||||
/// updates.jsonl so fullscreen scrollback is not prompt-only.
|
||||
#[test]
|
||||
fn subagent_spawned_replays_child_updates_without_resumed_from() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-with-updates";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
spawn_subagent_with_optional_updates(
|
||||
&mut app,
|
||||
child_sid,
|
||||
Some(&(child_tool_line(child_sid) + "\n")),
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
1,
|
||||
"spawn must replay exactly one tool call"
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| i.child_updates_replayed),
|
||||
"spawn must set child_updates_replayed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Resume: a `SubagentSpawned` during `loading_replay` must defer the child
|
||||
/// transcript load (the dominant large-session resume cost) to first open.
|
||||
#[test]
|
||||
fn subagent_spawned_during_resume_defers_child_replay_until_open() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-resume-defer";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
// Simulate resume: the parent agent is replaying its own session.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
spawn_subagent_with_optional_updates(
|
||||
&mut app,
|
||||
child_sid,
|
||||
Some(&(child_tool_line(child_sid) + "\n")),
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
0,
|
||||
"resume spawn must NOT eagerly replay the child transcript"
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| !i.child_updates_replayed),
|
||||
"resume spawn must leave child_updates_replayed unset for lazy load"
|
||||
);
|
||||
|
||||
// Opening the subagent later triggers the deferred (lazy) replay.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.open_subagent_fullscreen(child_sid.to_string());
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
1,
|
||||
"opening the subagent after resume must lazily replay its transcript"
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| i.child_updates_replayed),
|
||||
"lazy open must set child_updates_replayed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Regression (resume): a subagent that already finished must still show its
|
||||
/// full transcript on open. The finished handler's `TurnCompleted` push is
|
||||
/// suppressed during replay — otherwise it vetoes the deferred load
|
||||
/// (`subagent_child_needs_replay`), leaving a permanently empty transcript.
|
||||
#[test]
|
||||
fn subagent_resume_finished_then_open_shows_full_transcript() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-resume-finished";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
spawn_subagent_with_optional_updates(
|
||||
&mut app,
|
||||
child_sid,
|
||||
Some(&(child_tool_line(child_sid) + "\n")),
|
||||
);
|
||||
let _ = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-parent",
|
||||
"x.ai/session/update",
|
||||
test_subagent_finished(child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
0,
|
||||
"resume must not eagerly load the finished subagent transcript"
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| !i.child_updates_replayed),
|
||||
"finished-during-resume must leave child_updates_replayed unset"
|
||||
);
|
||||
// Even deferred, a finished subagent must not show a running spinner.
|
||||
assert!(
|
||||
matches!(
|
||||
agent.subagent_views.get(child_sid).unwrap().session.state,
|
||||
AgentState::Idle
|
||||
),
|
||||
"finished subagent must be Idle after resume, not TurnRunning"
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.open_subagent_fullscreen(child_sid.to_string());
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
1,
|
||||
"opening a finished subagent after resume must show its transcript"
|
||||
);
|
||||
// The lazy load reapplies the "Worked for" footer (live parity).
|
||||
let child = agent.subagent_views.get(child_sid).unwrap();
|
||||
assert!(
|
||||
(0..child.scrollback.len()).any(|i| child
|
||||
.scrollback
|
||||
.entry(i)
|
||||
.is_some_and(|e| matches!(e.block, RenderBlock::SessionEvent(_)))),
|
||||
"opened finished subagent must show a TurnCompleted footer"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Regression (resume): with a meta.json task prompt AND a persisted child
|
||||
/// transcript that echoes that prompt, opening after resume shows the task
|
||||
/// exactly once — the deferred open must dedup the replayed prompt echo.
|
||||
#[test]
|
||||
fn subagent_resume_with_meta_prompt_shows_task_once_after_open() {
|
||||
with_replay_disk_home(|home| {
|
||||
let parent_sid = "sess-parent";
|
||||
let child_sid = "child-resume-meta";
|
||||
let task = "scan src/ for auth";
|
||||
write_subagent_meta_json(home, parent_sid, child_sid, task);
|
||||
|
||||
let mut app = make_app_with_agent(parent_sid);
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
let updates = format!(
|
||||
"{}\n{}",
|
||||
child_user_message_line(child_sid, task),
|
||||
child_tool_line(child_sid)
|
||||
);
|
||||
spawn_subagent_with_optional_updates(&mut app, child_sid, Some(&updates));
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.open_subagent_fullscreen(child_sid.to_string());
|
||||
assert_eq!(
|
||||
child_scrollback_matching_prompt_count(agent, child_sid, task),
|
||||
1,
|
||||
"task prompt must appear exactly once after resume + open"
|
||||
);
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 1);
|
||||
});
|
||||
}
|
||||
|
||||
/// Regression: replayed user_message_chunk + meta prompt must not duplicate via injection.
|
||||
#[test]
|
||||
fn subagent_spawn_replay_and_meta_prompt_shows_task_once() {
|
||||
with_replay_disk_home(|home| {
|
||||
let parent_sid = "sess-parent";
|
||||
let child_sid = "child-prompt-once";
|
||||
let task = "scan src/ for auth";
|
||||
write_subagent_meta_json(home, parent_sid, child_sid, task);
|
||||
|
||||
let mut app = make_app_with_agent(parent_sid);
|
||||
let updates = format!(
|
||||
"{}\n{}",
|
||||
child_user_message_line(child_sid, task),
|
||||
child_tool_line(child_sid)
|
||||
);
|
||||
spawn_subagent_with_optional_updates(&mut app, child_sid, Some(&updates));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_matching_prompt_count(agent, child_sid, task),
|
||||
1,
|
||||
"task prompt must appear exactly once in child scrollback"
|
||||
);
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 1);
|
||||
assert!(
|
||||
!child_tracker_expects_user_echo(agent, child_sid),
|
||||
"replay path must not set expect_user_echo when injection is skipped"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Live spawn: meta prompt without updates.jsonl still injects the task once.
|
||||
#[test]
|
||||
fn subagent_spawn_live_injects_meta_prompt_once_without_updates() {
|
||||
with_replay_disk_home(|home| {
|
||||
let parent_sid = "sess-parent";
|
||||
let child_sid = "child-live-prompt";
|
||||
let task = "explore handlers only";
|
||||
write_subagent_meta_json(home, parent_sid, child_sid, task);
|
||||
|
||||
let mut app = make_app_with_agent(parent_sid);
|
||||
spawn_subagent_with_optional_updates(&mut app, child_sid, None);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_matching_prompt_count(agent, child_sid, task),
|
||||
1,
|
||||
"live spawn must inject meta prompt when updates.jsonl is absent"
|
||||
);
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 0);
|
||||
assert!(
|
||||
child_tracker_expects_user_echo(agent, child_sid),
|
||||
"live spawn must set expect_user_echo after injecting meta prompt"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_spawn_skips_injection_for_whitespace_only_meta_prompt() {
|
||||
with_replay_disk_home(|home| {
|
||||
let parent_sid = "sess-parent";
|
||||
let child_sid = "child-empty-meta";
|
||||
write_subagent_meta_json(home, parent_sid, child_sid, " ");
|
||||
|
||||
let mut app = make_app_with_agent(parent_sid);
|
||||
spawn_subagent_with_optional_updates(&mut app, child_sid, None);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_matching_prompt_count(agent, child_sid, " "),
|
||||
0,
|
||||
"whitespace-only meta prompt must not inject a user block"
|
||||
);
|
||||
assert!(
|
||||
!child_tracker_expects_user_echo(agent, child_sid),
|
||||
"whitespace-only meta prompt must not set expect_user_echo"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_spawn_without_updates_jsonl_is_noop() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-no-updates";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
spawn_subagent_with_optional_updates(&mut app, child_sid, None);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 0);
|
||||
assert_eq!(
|
||||
agent
|
||||
.subagent_views
|
||||
.get(child_sid)
|
||||
.unwrap()
|
||||
.scrollback
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| i.child_updates_replayed)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_spawn_and_open_replay_is_idempotent() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-idempotent";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
spawn_subagent_with_optional_updates(
|
||||
&mut app,
|
||||
child_sid,
|
||||
Some(&(child_tool_line(child_sid) + "\n")),
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 1);
|
||||
agent.open_subagent_fullscreen(child_sid.to_string());
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
1,
|
||||
"open must not duplicate spawn replay when child_updates_replayed is set"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_subagent_fullscreen_replays_when_flag_false_and_prompt_only() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-open-replay";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
spawn_subagent_with_optional_updates(
|
||||
&mut app,
|
||||
child_sid,
|
||||
Some(&(child_tool_line(child_sid) + "\n")),
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
if let Some(child) = agent.subagent_views.get_mut(child_sid) {
|
||||
child.scrollback.clear();
|
||||
child
|
||||
.scrollback
|
||||
.push_block(RenderBlock::user_prompt("task only"));
|
||||
}
|
||||
if let Some(info) = agent.subagent_sessions.get_mut(child_sid) {
|
||||
info.child_updates_replayed = false;
|
||||
}
|
||||
|
||||
agent.open_subagent_fullscreen(child_sid.to_string());
|
||||
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 1);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| i.child_updates_replayed)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_session_notification_and_update_equivalent_for_subagent_spawned() {
|
||||
let child_sid = "child-equiv";
|
||||
let (spawn_notif, finish_notif) =
|
||||
run_subagent_lifecycle_via_method("x.ai/session_notification", child_sid);
|
||||
let (spawn_update, finish_update) =
|
||||
run_subagent_lifecycle_via_method("x.ai/session/update", child_sid);
|
||||
|
||||
assert_eq!(spawn_notif.description, spawn_update.description);
|
||||
assert_eq!(spawn_notif.subagent_type, spawn_update.subagent_type);
|
||||
assert_eq!(spawn_notif.has_child_view, spawn_update.has_child_view);
|
||||
assert_eq!(spawn_notif.scrollback_len, spawn_update.scrollback_len);
|
||||
assert_eq!(spawn_notif.child_session_id, child_sid);
|
||||
assert_eq!(spawn_update.child_session_id, child_sid);
|
||||
assert!(matches!(spawn_notif.block_kind, SubagentBlockKind::Started));
|
||||
assert!(matches!(
|
||||
spawn_update.block_kind,
|
||||
SubagentBlockKind::Started
|
||||
));
|
||||
assert_eq!(
|
||||
spawn_notif.scrollback_entry_id,
|
||||
spawn_update.scrollback_entry_id
|
||||
);
|
||||
assert!(spawn_notif.scrollback_entry_id.is_some());
|
||||
|
||||
assert!(finish_notif.finished);
|
||||
assert!(finish_update.finished);
|
||||
assert_eq!(finish_notif.status.as_deref(), Some("completed"));
|
||||
assert_eq!(finish_update.status.as_deref(), Some("completed"));
|
||||
assert_eq!(finish_notif.tool_calls, Some(2));
|
||||
assert_eq!(finish_update.tool_calls, Some(2));
|
||||
assert_eq!(finish_notif.turns, Some(1));
|
||||
assert_eq!(finish_update.turns, Some(1));
|
||||
assert_eq!(finish_notif.duration_ms, Some(500));
|
||||
assert_eq!(finish_update.duration_ms, Some(500));
|
||||
assert!(matches!(
|
||||
finish_notif.block_kind,
|
||||
SubagentBlockKind::Completed { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
finish_update.block_kind,
|
||||
SubagentBlockKind::Completed { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_session_update_for_inactive_agent_registers_subagent_without_redraw() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let child_sid = "child-inactive";
|
||||
let affected = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-A",
|
||||
"x.ai/session/update",
|
||||
test_subagent_spawned("sess-A", child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent_a
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.expect("SubagentSpawned must register on inactive agent A");
|
||||
assert!(
|
||||
agent_a.subagent_views.contains_key(child_sid),
|
||||
"SubagentSpawned must create subagent_views on inactive agent A"
|
||||
);
|
||||
assert_eq!(agent_a.scrollback.len(), 1);
|
||||
let entry_id = info
|
||||
.scrollback_entry_id
|
||||
.expect("inactive spawn must stash scrollback_entry_id");
|
||||
let entry = agent_a.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("inactive spawn must push SubagentBlock");
|
||||
};
|
||||
assert!(matches!(sb.kind, SubagentBlockKind::Started));
|
||||
assert!(
|
||||
!affected,
|
||||
"SubagentSpawned on inactive agent must not request a redraw"
|
||||
);
|
||||
|
||||
let affected = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-A",
|
||||
"x.ai/session/update",
|
||||
test_subagent_finished(child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"SubagentFinished on inactive agent must not request a redraw"
|
||||
);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent_a.subagent_sessions.get(child_sid).unwrap();
|
||||
assert!(info.finished);
|
||||
assert_eq!(info.status.as_deref(), Some("completed"));
|
||||
let entry = agent_a.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("inactive finish must keep SubagentBlock");
|
||||
};
|
||||
assert!(matches!(sb.kind, SubagentBlockKind::Completed { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_session_update_unknown_session_subagent_spawned_no_op() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let affected = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-unknown",
|
||||
"x.ai/session/update",
|
||||
test_subagent_spawned("sess-unknown", "child-unknown"),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(!affected, "unknown session_id must not request a redraw");
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.subagent_sessions.is_empty(),
|
||||
"SubagentSpawned for unknown session must not register subagent_sessions"
|
||||
);
|
||||
assert!(
|
||||
agent.scrollback.is_empty(),
|
||||
"SubagentSpawned for unknown session must not push scrollback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_session_update_malformed_params_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
// Valid JSON but not a SessionNotification — parse must fail quietly.
|
||||
let raw =
|
||||
serde_json::value::to_raw_value(&serde_json::json!({"unexpected": true})).unwrap();
|
||||
let request = acp::ExtNotification::new("x.ai/session/update", raw.into());
|
||||
let msg = AcpClientMessage::ExtNotification(kigi_acp_lib::AcpArgs {
|
||||
request,
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(
|
||||
!affected,
|
||||
"malformed x.ai/session/update params must not redraw"
|
||||
);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().scrollback.is_empty(),
|
||||
"malformed notification must not mutate scrollback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_session_notification_for_inactive_agent_updates_its_context_used() {
|
||||
// AutoCompactCompleted on the xAI ext path resets the context bar
|
||||
// numerator via refresh_context_used. That side effect must run on
|
||||
// the matched agent regardless of which view is currently active.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
// Seed A with a stale context-used reading so we can prove the
|
||||
// notification reset it.
|
||||
{
|
||||
let agent_a = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent_a.apply_context_used(90_000, 131_072);
|
||||
}
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let affected = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-A",
|
||||
XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(90_000),
|
||||
tokens_after: 25_000,
|
||||
elapsed_ms: Some(300),
|
||||
summary_preview: None,
|
||||
},
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.context_state.as_ref().map(|c| c.used),
|
||||
Some(25_000),
|
||||
"AutoCompactCompleted must reset A's context_used even when B is active"
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"ext notification routed to a non-active agent must not request a redraw"
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,361 @@
|
||||
//! Prompt-suggestion gate and follow-up chips: the tab-autocomplete ghost
|
||||
//! gate plus the follow-up chip lifecycle.
|
||||
|
||||
use super::{AgentView, FollowUps, MAX_PENDING_FOLLOW_UPS};
|
||||
|
||||
impl AgentView {
|
||||
/// Refresh the gate for the predicted-next-prompt ghost (tab
|
||||
/// autocomplete): it only shows on an idle session's normal prompt.
|
||||
/// Called before key dispatch and before each draw so a turn starting
|
||||
/// or an input-mode switch hides the ghost immediately. Also re-reads
|
||||
/// the enabled state so a `/settings` toggle applies live.
|
||||
pub(crate) fn refresh_prompt_suggestion_gate(&mut self) {
|
||||
self.prompt.prompt_suggestion.enabled = crate::views::prompt_suggestion::resolve_enabled();
|
||||
self.prompt.prompt_suggestion_active = self.prompt_input_mode
|
||||
== super::PromptInputMode::Normal
|
||||
&& matches!(self.prompt_mode, super::PromptMode::Normal)
|
||||
&& !self.session.state.is_busy();
|
||||
}
|
||||
|
||||
/// Notify the suggestion controller that the prompt text changed.
|
||||
/// Returns an Effect to dispatch if the controller wants a debounce.
|
||||
///
|
||||
/// Shell suggestions are a bash-mode (`!`) feature: outside it the
|
||||
/// pipeline never fires (no shell-history ghosts over natural-language
|
||||
/// chat text) and any leftover ghost/dropdown is torn down.
|
||||
pub(crate) fn notify_suggestion_text_changed(&mut self) -> Option<super::actions::Effect> {
|
||||
use crate::views::suggestion_controller::SuggestionAction;
|
||||
|
||||
if self.prompt_input_mode != super::PromptInputMode::Bash {
|
||||
self.prompt.suggestions.clear_ghost();
|
||||
return None;
|
||||
}
|
||||
|
||||
let snap = self.prompt.slash_state.snapshot();
|
||||
let slash_active = snap.active;
|
||||
let has_inline_ghost = snap.inline_ghost.is_some();
|
||||
// Copy text before passing to text_changed to satisfy the borrow checker.
|
||||
let text = self.prompt.text().to_owned();
|
||||
let action = self
|
||||
.prompt
|
||||
.suggestions
|
||||
.text_changed(&text, slash_active, has_inline_ghost)?;
|
||||
|
||||
match action {
|
||||
SuggestionAction::Matched => None,
|
||||
SuggestionAction::Debounce { generation } => {
|
||||
Some(super::actions::Effect::DebounceSuggestions {
|
||||
agent_id: self.session.id,
|
||||
generation,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an `x.ai/follow_ups` notification, keyed by `response_id`
|
||||
/// (newest-response-wins).
|
||||
///
|
||||
/// Monotonic accept-the-newer: a never-seen `response_id` is strictly newer
|
||||
/// than any previously accepted one, so it supersedes the shown chips; a
|
||||
/// re-delivery of an already-accepted (hence older) response is ignored, so
|
||||
/// a buffer-replay or duplicate cannot clobber the newest chips on any
|
||||
/// turn-boundary path, with no reliance on a clear being wired there and no
|
||||
/// eviction window that could let a stale id pass as new. A re-delivery of
|
||||
/// the currently-shown response refreshes it in place (no-op when
|
||||
/// identical); empty `suggestions` retracts that response's chips. Returns
|
||||
/// `true` when the displayed chips changed (a redraw is warranted).
|
||||
/// Backward-compatible shim used by tests that don't exercise the turn
|
||||
/// identity: equivalent to a follow_ups notification with no stamped
|
||||
/// `promptId` (the older-shell / replay path). Production always routes
|
||||
/// through [`apply_follow_ups_with_prompt`] from `handle_follow_ups`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn apply_follow_ups(
|
||||
&mut self,
|
||||
response_id: String,
|
||||
suggestions: Vec<String>,
|
||||
) -> bool {
|
||||
self.apply_follow_ups_with_prompt(response_id, None, suggestions)
|
||||
}
|
||||
|
||||
/// `apply_follow_ups` with the turn identity (`prompt_id`) the shell stamps
|
||||
/// on each `x.ai/follow_ups` notification (the same `promptId` it stamps on
|
||||
/// every `session/update`). The identity makes viewer-adoption dedup
|
||||
/// DETERMINISTIC:
|
||||
///
|
||||
/// - A re-delivery of the CURRENTLY-ADOPTED turn's follow-ups (its
|
||||
/// `prompt_id` equals `session.current_prompt_id`) re-renders even when its
|
||||
/// chips were cleared by turn adoption — so chips that were applied then
|
||||
/// cleared reappear instead of being lost until reload.
|
||||
/// - A buffer-replayed `x.ai/follow_ups` for a PRIOR turn's `response_id`
|
||||
/// stays rejected by the seen-ring (its `prompt_id` is not the active one),
|
||||
/// so stale chips are never revived on the new turn.
|
||||
///
|
||||
/// `prompt_id == None` (older shells, or a replay path that lacks it) is
|
||||
/// treated as "not provably the current turn" → it falls back to the
|
||||
/// monotonic newest-wins seen-ring and NEVER revives a cleared prior turn.
|
||||
pub(crate) fn apply_follow_ups_with_prompt(
|
||||
&mut self,
|
||||
response_id: String,
|
||||
prompt_id: Option<&str>,
|
||||
suggestions: Vec<String>,
|
||||
) -> bool {
|
||||
// Re-delivery of the currently-shown response: refresh in place.
|
||||
if self
|
||||
.follow_ups
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.response_id == response_id)
|
||||
{
|
||||
if self
|
||||
.follow_ups
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.suggestions == suggestions)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.follow_up_chips.clear();
|
||||
self.hovered_follow_up_chip = None;
|
||||
if suggestions.is_empty() {
|
||||
// Empty retraction of the currently-shown chips: drop this id
|
||||
// from the seen-ring so a later NON-empty delivery for the SAME
|
||||
// response can be re-accepted and re-rendered. Otherwise the id
|
||||
// (recorded when first accepted) would make the re-delivery hit
|
||||
// the `follow_up_seen` reject below and never display. This only
|
||||
// ever affects the currently-shown (newest) id — a genuinely
|
||||
// older/superseded id is never the shown one, so it never
|
||||
// reaches this branch and stays rejected (newest-wins intact).
|
||||
self.follow_up_seen.remove(&response_id);
|
||||
self.follow_ups = None;
|
||||
self.follow_up_shown_prompt_id = None;
|
||||
} else {
|
||||
self.follow_ups = Some(FollowUps {
|
||||
response_id,
|
||||
suggestions,
|
||||
});
|
||||
self.follow_up_shown_prompt_id = prompt_id.map(str::to_owned);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Does this notification belong to the turn the client has currently
|
||||
// adopted? Deterministic when the shell stamped the `promptId`; `false`
|
||||
// for older shells / replay paths without one (those rely on the
|
||||
// newest-wins seen-ring below and never revive a prior turn).
|
||||
let current_prompt_id = self.session.current_prompt_id.as_deref();
|
||||
let is_current_turn =
|
||||
matches!((prompt_id, current_prompt_id), (Some(pid), Some(cur)) if pid == cur);
|
||||
// A stamped `promptId` that names a DIFFERENT turn than the one
|
||||
// currently adopted: this is a non-current turn's follow_ups (a PRIOR
|
||||
// turn's late first-time arrival, or a not-yet-adopted turn). It must
|
||||
// never render — as a re-delivery OR as "newest" — while another turn is
|
||||
// active, or its chips would appear over the running turn.
|
||||
//
|
||||
// Guarded on `current == Some`: a `None` `promptId` (older shells) has
|
||||
// no turn identity → newest-wins fallback; and `current == None` (e.g. a
|
||||
// just-finished turn whose trailing follow_ups arrive after
|
||||
// `current_prompt_id` was cleared) is NOT a mismatch, so those chips
|
||||
// still render.
|
||||
let names_other_active_turn =
|
||||
matches!((prompt_id, current_prompt_id), (Some(pid), Some(cur)) if pid != cur);
|
||||
|
||||
if self.follow_up_seen.contains_key(&response_id) {
|
||||
// Already accepted. Normally this is an older, superseded response →
|
||||
// reject (newest-wins; a stale prior-turn buffer-replay must NOT
|
||||
// revive chips). EXCEPTION: if this IS the currently-adopted turn
|
||||
// (its `prompt_id` matches the active turn) and it carries chips, a
|
||||
// re-delivery whose chips were cleared by turn adoption must
|
||||
// re-render — scoped deterministically to the active turn so a prior
|
||||
// turn is never revived.
|
||||
if is_current_turn && !suggestions.is_empty() {
|
||||
self.follow_up_chips.clear();
|
||||
self.hovered_follow_up_chip = None;
|
||||
self.follow_ups = Some(FollowUps {
|
||||
response_id,
|
||||
suggestions,
|
||||
});
|
||||
self.follow_up_shown_prompt_id = prompt_id.map(str::to_owned);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// First-time (never-seen) arrival for a turn that is NOT the active one.
|
||||
// It must not render NOW (it would draw over the running turn), but it
|
||||
// may be a not-yet-adopted FUTURE turn whose follow_ups raced ahead of
|
||||
// the `session/update` that adopts it. Dropping it would lose the chips
|
||||
// forever if it is the only delivery. Instead BUFFER it keyed by its
|
||||
// `promptId`; [`flush_pending_follow_ups`] renders it if/when that turn
|
||||
// becomes current. A genuinely prior turn's `promptId` never becomes
|
||||
// current again, so its buffered entry is never flushed (no stale
|
||||
// revival) and is eventually FIFO-evicted by the cap.
|
||||
if names_other_active_turn {
|
||||
if let Some(pid) = prompt_id
|
||||
&& !suggestions.is_empty()
|
||||
{
|
||||
self.buffer_pending_follow_ups(pid.to_owned(), response_id, suggestions);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Strictly newer response: supersede the prior chips (already recorded
|
||||
// in `follow_up_seen` at its own acceptance, so no re-record needed).
|
||||
let had_chips = self.follow_ups.take().is_some();
|
||||
self.follow_up_shown_prompt_id = None;
|
||||
self.follow_up_chips.clear();
|
||||
self.hovered_follow_up_chip = None;
|
||||
if suggestions.is_empty() {
|
||||
// An empty payload for a never-seen response is a no-op retraction
|
||||
// and is deliberately NOT recorded, so a later non-empty delivery
|
||||
// for the same response still renders.
|
||||
return had_chips;
|
||||
}
|
||||
self.follow_up_seen
|
||||
.insert(response_id.clone(), self.follow_up_next_gen);
|
||||
self.follow_up_next_gen += 1;
|
||||
self.follow_ups = Some(FollowUps {
|
||||
response_id,
|
||||
suggestions,
|
||||
});
|
||||
self.follow_up_shown_prompt_id = prompt_id.map(str::to_owned);
|
||||
true
|
||||
}
|
||||
|
||||
/// Buffer a stamped `x.ai/follow_ups` for a turn that is not yet current,
|
||||
/// keyed by its `promptId`. A newer delivery for the same `promptId`
|
||||
/// overwrites the earlier one (keep the latest); the FIFO order list bounds
|
||||
/// the map to [`MAX_PENDING_FOLLOW_UPS`], evicting only the oldest entry.
|
||||
fn buffer_pending_follow_ups(
|
||||
&mut self,
|
||||
prompt_id: String,
|
||||
response_id: String,
|
||||
suggestions: Vec<String>,
|
||||
) {
|
||||
let is_new_key = self
|
||||
.follow_up_pending
|
||||
.insert(
|
||||
prompt_id.clone(),
|
||||
FollowUps {
|
||||
response_id,
|
||||
suggestions,
|
||||
},
|
||||
)
|
||||
.is_none();
|
||||
if is_new_key {
|
||||
self.follow_up_pending_order.push_back(prompt_id);
|
||||
if self.follow_up_pending_order.len() > MAX_PENDING_FOLLOW_UPS
|
||||
&& let Some(evicted) = self.follow_up_pending_order.pop_front()
|
||||
{
|
||||
self.follow_up_pending.remove(&evicted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush a buffered `x.ai/follow_ups` for `prompt_id` (a turn that has just
|
||||
/// become current). Renders the chips through [`apply_follow_ups_with_prompt`]
|
||||
/// — now that `current_prompt_id == prompt_id`, the stamped delivery is
|
||||
/// accepted as the active turn's. Returns whether chips were rendered. A
|
||||
/// no-op when nothing is buffered for `prompt_id`. Callers invoke this AFTER
|
||||
/// setting `current_prompt_id` to `prompt_id` at every turn-adoption site.
|
||||
pub(crate) fn flush_pending_follow_ups(&mut self, prompt_id: &str) -> bool {
|
||||
let Some(pending) = self.follow_up_pending.remove(prompt_id) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(pos) = self
|
||||
.follow_up_pending_order
|
||||
.iter()
|
||||
.position(|p| p == prompt_id)
|
||||
{
|
||||
self.follow_up_pending_order.remove(pos);
|
||||
}
|
||||
self.apply_follow_ups_with_prompt(pending.response_id, Some(prompt_id), pending.suggestions)
|
||||
}
|
||||
|
||||
/// Drop the shown follow-up chips at a turn start (UX: they belong to the
|
||||
/// previous response). The response stays recorded in `follow_up_seen`, so a
|
||||
/// stale re-delivery stays rejected; the active turn's own re-delivery still
|
||||
/// re-renders via the `prompt_id` match in [`apply_follow_ups_with_prompt`],
|
||||
/// so this is used for BOTH viewer-adoption and self-driven turn starts.
|
||||
pub(crate) fn clear_follow_ups(&mut self) {
|
||||
self.follow_ups = None;
|
||||
self.follow_up_shown_prompt_id = None;
|
||||
self.follow_up_chips.clear();
|
||||
self.hovered_follow_up_chip = None;
|
||||
}
|
||||
|
||||
/// Full follow-up reset for a session reload. Unlike [`clear_follow_ups`]
|
||||
/// (turn boundary — keeps `follow_up_seen` so a stale re-delivery stays
|
||||
/// rejected), a reload starts a fresh streaming session: follow-ups never
|
||||
/// persist, so the prior session's seen ids must also be dropped or they
|
||||
/// would suppress chips streamed after the reload.
|
||||
pub(crate) fn reset_follow_ups_for_reload(&mut self) {
|
||||
self.reset_follow_ups_for_reload_preserving(None);
|
||||
}
|
||||
|
||||
/// Reload reset that PRESERVES the running turn's follow-ups for
|
||||
/// `keep_prompt_id` (the turn the load is about to adopt). On `SessionLoaded`
|
||||
/// the running turn's `x.ai/follow_ups` arrive on the ext channel DURING
|
||||
/// `loading_replay`; an unconditional reset would drop them before adoption
|
||||
/// could re-render them, so the chips would never appear unless the server
|
||||
/// resent them. The running turn's chips live in ONE of two places at reset
|
||||
/// time:
|
||||
///
|
||||
/// * [`follow_up_pending`](Self::follow_up_pending) — buffered, never
|
||||
/// displayed (the turn was not current when the chips arrived); OR
|
||||
/// * [`follow_ups`](Self::follow_ups) — already ON SCREEN, because
|
||||
/// `current_prompt_id` was unset or already equalled the running turn, so
|
||||
/// the delivery took the newest-wins / current-turn render path instead
|
||||
/// of the buffer.
|
||||
///
|
||||
/// Both are preserved (the on-screen copy is the live, latest state, so it
|
||||
/// wins) by re-buffering the survivor into `follow_up_pending` keyed by
|
||||
/// `keep_prompt_id`; [`adopt_running_prompt`](Self::adopt_running_prompt)
|
||||
/// then flushes it. All other state — every OTHER turn's buffer, the seen
|
||||
/// ring, on-screen chips of any other turn — is still cleared, so a reload
|
||||
/// never leaves stale chips behind. `None` is a full reset (the
|
||||
/// reconnect-reload finalize path, which has no running turn to adopt).
|
||||
pub(crate) fn reset_follow_ups_for_reload_preserving(&mut self, keep_prompt_id: Option<&str>) {
|
||||
// Capture the running turn's follow_ups BEFORE wiping state. Prefer the
|
||||
// on-screen copy (it rendered, so it is the latest accepted delivery);
|
||||
// fall back to the pending buffer.
|
||||
let kept = keep_prompt_id.and_then(|keep| {
|
||||
let displayed = self
|
||||
.follow_up_shown_prompt_id
|
||||
.as_deref()
|
||||
.filter(|shown| *shown == keep)
|
||||
.and_then(|_| self.follow_ups.clone());
|
||||
displayed
|
||||
.or_else(|| self.follow_up_pending.get(keep).cloned())
|
||||
.map(|entry| (keep.to_owned(), entry))
|
||||
});
|
||||
|
||||
self.follow_ups = None;
|
||||
self.follow_up_shown_prompt_id = None;
|
||||
self.follow_up_chips.clear();
|
||||
self.hovered_follow_up_chip = None;
|
||||
self.follow_up_seen.clear();
|
||||
self.follow_up_next_gen = 0;
|
||||
self.follow_up_pending.clear();
|
||||
self.follow_up_pending_order.clear();
|
||||
if let Some((pid, entry)) = kept {
|
||||
self.follow_up_pending.insert(pid.clone(), entry);
|
||||
self.follow_up_pending_order.push_back(pid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the follow-up chip under a screen position, if any. Used by
|
||||
/// the mouse handler to submit the clicked suggestion as a literal prompt.
|
||||
pub(crate) fn follow_up_chip_at(&self, col: u16, row: u16) -> Option<usize> {
|
||||
self.follow_up_chips
|
||||
.iter()
|
||||
.position(|r| r.contains((col, row).into()))
|
||||
}
|
||||
|
||||
/// Update hover highlight for follow-up chips. Returns true if the hover
|
||||
/// index changed (caller should re-render).
|
||||
pub(crate) fn set_hovered_follow_up_chip(&mut self, idx: Option<usize>) -> bool {
|
||||
if self.hovered_follow_up_chip == idx {
|
||||
return false;
|
||||
}
|
||||
self.hovered_follow_up_chip = idx;
|
||||
true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
//! `/jump` picker: transcript preview syncing and key/mouse handling.
|
||||
|
||||
use super::AgentView;
|
||||
use crate::app::actions::Action;
|
||||
use crate::app::app_view::InputOutcome;
|
||||
use crate::views::jump::{
|
||||
JumpInput, JumpRestore, handle_jump_key, jump_activate, jump_row_at, move_cursor,
|
||||
set_jump_cursor,
|
||||
};
|
||||
use crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
|
||||
|
||||
impl AgentView {
|
||||
/// Close the `/jump` picker (if open) and restore the viewport it opened
|
||||
/// from. Shared by the `Esc` dismiss path and the rewind / inline-edit entry
|
||||
/// points, so a shadowed picker can't reappear stale.
|
||||
pub(crate) fn dismiss_jump_picker(&mut self) {
|
||||
if let Some(js) = self.jump_state.take() {
|
||||
self.restore_jump_viewport(js.restore);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-pin the viewport the picker captured (width-stable bookmark), restore
|
||||
/// the prior selection, and re-arm follow mode. Shared by `Esc` dismiss and
|
||||
/// the failed-jump restore, so both stay consistent under a resize.
|
||||
pub(crate) fn restore_jump_viewport(&mut self, restore: JumpRestore) {
|
||||
self.scrollback.set_selected(restore.selected);
|
||||
if let Some(bookmark) = restore.bookmark {
|
||||
self.scrollback.restore_scroll_bookmark(bookmark);
|
||||
}
|
||||
if restore.follow_mode {
|
||||
self.scrollback.enable_follow();
|
||||
}
|
||||
}
|
||||
|
||||
/// True when another prompt overlay owns the input slot, so the `/jump`
|
||||
/// picker must not open and an open one must be dismissed: rewind, inline
|
||||
/// edit, the `/btw` panel, or a pending permission / question / cancel-turn /
|
||||
/// plan-approval overlay. One predicate keeps dispatch, key, mouse, and
|
||||
/// scroll routing from disagreeing on the owner.
|
||||
pub(crate) fn jump_slot_taken(&self) -> bool {
|
||||
self.rewind_state.is_some()
|
||||
|| self.inline_edit.is_some()
|
||||
|| self.btw_state.is_some()
|
||||
|| !self.no_input_overlay_pending()
|
||||
}
|
||||
|
||||
/// Drop the picker when another overlay owns the input slot
|
||||
/// ([`Self::jump_slot_taken`]), so it can't eat wheel/keys while hidden.
|
||||
/// Returns whether it dropped one, so an `Esc` caller can spend that key
|
||||
/// here rather than let it also dismiss the overlay shadowing the picker
|
||||
/// (e.g. the `/btw` panel). Called at the input and scroll entry points.
|
||||
pub(super) fn dismiss_jump_picker_if_suppressed(&mut self) -> bool {
|
||||
if self.jump_state.is_some() && self.jump_slot_taken() {
|
||||
self.dismiss_jump_picker();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Live-scroll the transcript to the turn under the picker cursor,
|
||||
/// anchored at the viewport TOP — where `jump_to_turn` lands — so the
|
||||
/// preview shows exactly what Enter commits to. (Rewind centers
|
||||
/// instead: it previews a cut point and needs both sides visible.)
|
||||
pub(super) fn sync_jump_preview(&mut self) {
|
||||
let Some(prompt_id) = self
|
||||
.jump_state
|
||||
.as_ref()
|
||||
.and_then(|js| js.entries.get(js.selected))
|
||||
.map(|entry| entry.prompt_entry_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// Resolve the stable id at the boundary; a removal since capture just
|
||||
// means no preview scroll rather than landing on the wrong block.
|
||||
if let Some(idx) = self.scrollback.index_of_id(prompt_id) {
|
||||
self.scrollback.scroll_to_entry_top(idx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_jump_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
let Some(ref state) = self.jump_state else {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
match handle_jump_key(state, key) {
|
||||
JumpInput::MoveUp => {
|
||||
if let Some(ref mut js) = self.jump_state {
|
||||
move_cursor(js, -1);
|
||||
self.sync_jump_preview();
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
JumpInput::MoveDown => {
|
||||
if let Some(ref mut js) = self.jump_state {
|
||||
move_cursor(js, 1);
|
||||
self.sync_jump_preview();
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
other => Self::jump_input_to_outcome(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a terminal `JumpInput` to its `InputOutcome`. Shared by the key,
|
||||
/// mouse, and wheel paths so they can't drift.
|
||||
fn jump_input_to_outcome(input: JumpInput) -> InputOutcome {
|
||||
match input {
|
||||
JumpInput::Select(id) => InputOutcome::Action(Action::JumpPickerSelect(id)),
|
||||
JumpInput::Dismissed => InputOutcome::Action(Action::JumpDismiss),
|
||||
JumpInput::MoveUp | JumpInput::MoveDown | JumpInput::Consumed => InputOutcome::Changed,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Moved` moves the cursor (and previews); `Down(Left)` activates the
|
||||
/// row (Enter-equivalent). Row geometry comes from `jump_row_at`.
|
||||
pub(super) fn handle_jump_mouse(&mut self, mouse: &MouseEvent) -> InputOutcome {
|
||||
let Some(js) = self.jump_state.as_mut() else {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
|
||||
let area = self.pane_areas.prompt;
|
||||
let Some(idx) = jump_row_at(js, area, mouse.column, mouse.row) else {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
|
||||
match mouse.kind {
|
||||
MouseEventKind::Moved => {
|
||||
if set_jump_cursor(js, idx) {
|
||||
self.sync_jump_preview();
|
||||
InputOutcome::Changed
|
||||
} else {
|
||||
InputOutcome::Unchanged
|
||||
}
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
set_jump_cursor(js, idx);
|
||||
let activated = jump_activate(js);
|
||||
Self::jump_input_to_outcome(activated)
|
||||
}
|
||||
_ => InputOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::views::jump::{JumpRestore, JumpState};
|
||||
|
||||
#[test]
|
||||
fn preview_scrolls_to_cursor_turn() {
|
||||
let mut agent = crate::test_util::make_agent_view(None, "/tmp");
|
||||
agent.scrollback.push_block(RenderBlock::user_prompt("Q1"));
|
||||
for i in 0..20 {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::agent_message(format!("para {i}")));
|
||||
}
|
||||
agent.scrollback.push_block(RenderBlock::user_prompt("Q2"));
|
||||
agent.scrollback.push_block(RenderBlock::agent_message("a"));
|
||||
agent.scrollback.prepare_layout(80, 6);
|
||||
agent.scrollback.goto_bottom();
|
||||
let at_bottom = agent.scrollback.scroll_offset();
|
||||
|
||||
agent.jump_state = Some(JumpState {
|
||||
entries: agent.scrollback.timeline_entries(),
|
||||
selected: 0,
|
||||
restore: JumpRestore {
|
||||
bookmark: agent.scrollback.capture_scroll_bookmark(),
|
||||
selected: None,
|
||||
follow_mode: true,
|
||||
},
|
||||
});
|
||||
|
||||
agent.sync_jump_preview();
|
||||
assert!(
|
||||
agent.scrollback.scroll_offset() < at_bottom,
|
||||
"previewing turn 1 scrolls the transcript up"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,800 @@
|
||||
//! Inline media: image/video viewer keys, playback state, media click
|
||||
//! handling, and mermaid diagram affordances.
|
||||
|
||||
use super::{AgentView, InlineVideoState};
|
||||
use crate::app::app_view::InputOutcome;
|
||||
use crate::render::SafeBuf;
|
||||
use crate::terminal::overlay::{self, PostFlush};
|
||||
use crate::theme::Theme;
|
||||
use crossterm::event::{KeyEvent, MouseEvent};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
|
||||
impl AgentView {
|
||||
// -- Image viewer input --------------------------------------------------
|
||||
|
||||
/// Handle a key event in the image viewer modal.
|
||||
pub(super) fn handle_image_viewer_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
if self.image_viewer.is_none() {
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => {
|
||||
// Clear the Kitty image before closing.
|
||||
// Old code bypassed STDERR_OUTPUT_LOCK which could interleave
|
||||
// mid-frame. Safe to revert: content is valid escapes, not raw text.
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
let clear = PostFlush::from(overlay::clear_kitty());
|
||||
let _ = clear.write_to(stderr);
|
||||
});
|
||||
self.image_viewer = None;
|
||||
self.image_load_rx = None;
|
||||
// The viewer's decoded/re-encoded overlay image (tens of MB
|
||||
// for screenshots/renders) just dropped; input path, so a
|
||||
// synchronous purge lands between interactions.
|
||||
crate::memory_release::release_retained_memory_with("image-viewer-close");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
|
||||
// -- Inline media rendering -----------------------------------------------
|
||||
|
||||
/// Build Kitty/iTerm2 escape sequences for an inline media placement.
|
||||
pub(super) fn build_inline_media_escapes(
|
||||
&mut self,
|
||||
placement: &crate::scrollback::render::InlineMediaPlacement,
|
||||
) -> Option<String> {
|
||||
use crate::prompt_images::decode_image_dimensions;
|
||||
|
||||
let path = &placement.info.path;
|
||||
|
||||
// During inline video playback, transmit the current frame.
|
||||
let is_video_playing = self.inline_video.as_ref().is_some_and(|v| v.path == *path);
|
||||
if is_video_playing {
|
||||
let vid_id = self.get_or_alloc_media_id(path);
|
||||
let video = self.inline_video.as_ref()?;
|
||||
let frame_data = &video.frames[video.current_frame];
|
||||
let (w, h) = decode_image_dimensions(frame_data)
|
||||
.unwrap_or((placement.info.width, placement.info.height));
|
||||
let transmit = crate::terminal::image::transmit_inline_image(frame_data, vid_id)?;
|
||||
let place = crate::terminal::image::place_inline_image(
|
||||
frame_data,
|
||||
w,
|
||||
h,
|
||||
placement.screen_rect,
|
||||
placement.full_rows,
|
||||
placement.top_crop_rows,
|
||||
vid_id,
|
||||
true,
|
||||
)?;
|
||||
return Some(format!("{transmit}{place}"));
|
||||
}
|
||||
|
||||
// Static image or video poster frame.
|
||||
// Allocate the Kitty id only *after* bytes are in hand: a not-yet-written
|
||||
// path (or a failed read) must return `None` without recording an id, or
|
||||
// the next time the path is seen `needs_transmit` would be false and only
|
||||
// `place` (no `transmit`) would emit — leaving a blank image.
|
||||
let needs_transmit = !self.inline_media_ids.contains_key(path);
|
||||
let mut transmit_esc = String::new();
|
||||
|
||||
if needs_transmit {
|
||||
// Load bytes from disk (or use cached bytes if available).
|
||||
if !self.inline_media_cache.contains_key(path) {
|
||||
let bytes = if placement.info.is_video {
|
||||
let (frame_bytes, _, _) = crate::prompt_images::extract_poster_frame(path)?;
|
||||
crate::terminal::image::prepare_overlay_image_bytes(&frame_bytes)?
|
||||
} else {
|
||||
let raw = std::fs::read(path).ok()?;
|
||||
crate::terminal::image::prepare_overlay_image_bytes(&raw)?
|
||||
};
|
||||
// Bound the cache: a long image-heavy session must not pin
|
||||
// every encoded image for its lifetime. Evicting drops only
|
||||
// CPU-side bytes — Kitty placements already transmitted stay
|
||||
// valid on the GPU (`inline_media_ids` is kept); an evicted
|
||||
// path re-reads from disk if it needs a re-transmit.
|
||||
const INLINE_MEDIA_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
let incoming = bytes.len();
|
||||
if incoming < INLINE_MEDIA_CACHE_MAX_BYTES {
|
||||
let mut total: usize = self
|
||||
.inline_media_cache
|
||||
.values()
|
||||
.map(Vec::len)
|
||||
.sum::<usize>()
|
||||
+ incoming;
|
||||
while total > INLINE_MEDIA_CACHE_MAX_BYTES {
|
||||
// HashMap iteration order is arbitrary — treat as random eviction.
|
||||
let Some(victim) = self.inline_media_cache.keys().next().cloned() else {
|
||||
break;
|
||||
};
|
||||
if let Some(evicted) = self.inline_media_cache.remove(&victim) {
|
||||
total -= evicted.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.inline_media_cache.insert(path.clone(), bytes);
|
||||
}
|
||||
let image_id = self.get_or_alloc_media_id(path);
|
||||
let bytes = self.inline_media_cache.get(path)?;
|
||||
transmit_esc = crate::terminal::image::transmit_inline_image(bytes, image_id)?;
|
||||
}
|
||||
|
||||
let image_id = self.get_or_alloc_media_id(path);
|
||||
let image_data = self.inline_media_cache.get(path)?;
|
||||
let (w, h) = decode_image_dimensions(image_data)
|
||||
.unwrap_or((placement.info.width, placement.info.height));
|
||||
|
||||
// iTerm2 has no place-only escape — re-emit when placement moves.
|
||||
let emit_iterm = self
|
||||
.inline_media_iterm_emitted
|
||||
.get(path)
|
||||
.is_none_or(|last| *last != placement.screen_rect);
|
||||
let place_esc = crate::terminal::image::place_inline_image(
|
||||
image_data,
|
||||
w,
|
||||
h,
|
||||
placement.screen_rect,
|
||||
placement.full_rows,
|
||||
placement.top_crop_rows,
|
||||
image_id,
|
||||
emit_iterm,
|
||||
)?;
|
||||
if emit_iterm
|
||||
&& crate::terminal::image::detect_graphics_protocol()
|
||||
== crate::terminal::image::GraphicsProtocol::ITerm2
|
||||
{
|
||||
self.inline_media_iterm_emitted
|
||||
.insert(path.clone(), placement.screen_rect);
|
||||
}
|
||||
|
||||
Some(format!("{transmit_esc}{place_esc}"))
|
||||
}
|
||||
|
||||
/// Paint each visible Mermaid affordance row (`◇ mermaid [Open Image]
|
||||
/// [Copy Image Path] [Copy Source]`) and register its click hit-rects.
|
||||
///
|
||||
/// The leading `◇ mermaid` label is a dim, non-clickable marker. Every button
|
||||
/// is always clickable (`[Open]`/`[Copy path]` render lazily on click); a
|
||||
/// button whose hit-rect is under the mouse is highlighted, the rest are dim.
|
||||
/// A trailing dim `rendering…` hint follows the buttons while an on-click
|
||||
/// render for that diagram is in flight. The whole layout (label + button +
|
||||
/// hint columns) comes from
|
||||
/// [`affordance_row`](crate::scrollback::blocks::mermaid_content::affordance_row)
|
||||
/// so the painted labels and the hit-rects can't drift, and each segment is
|
||||
/// clipped to `screen_rect.width` (which excludes the timestamp reserve).
|
||||
pub(super) fn paint_diagram_affordances(
|
||||
&mut self,
|
||||
buf: &mut Buffer,
|
||||
placements: Vec<crate::scrollback::render::DiagramAffordancePlacement>,
|
||||
theme: &Theme,
|
||||
) {
|
||||
use crate::scrollback::blocks::mermaid_content::affordance_row;
|
||||
use ratatui::style::Modifier;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
let (hover_col, hover_row) = self.last_mouse_pos;
|
||||
for aff in placements {
|
||||
let crate::scrollback::render::DiagramAffordancePlacement {
|
||||
screen_rect: rect,
|
||||
source,
|
||||
} = aff;
|
||||
// The transient `rendering…` hint shows only while an on-click render
|
||||
// for this diagram is in flight.
|
||||
let rendering = self.diagram_is_rendering(&source);
|
||||
let row = affordance_row(rendering);
|
||||
// A segment is drawn only if it fits wholly within the row width
|
||||
// (which already excludes the timestamp reserve), so labels never
|
||||
// spill past the content area and hit-rects stay inside the row.
|
||||
let fits =
|
||||
|col: u16, label: &str| col + UnicodeWidthStr::width(label) as u16 <= rect.width;
|
||||
|
||||
// Leading dim, non-clickable `◇ mermaid` label.
|
||||
let (label_col, label_text) = row.label;
|
||||
if fits(label_col, label_text) {
|
||||
buf.set_string_safe(
|
||||
rect.x.saturating_add(label_col),
|
||||
rect.y,
|
||||
label_text,
|
||||
Style::default().fg(theme.gray_dim),
|
||||
);
|
||||
}
|
||||
|
||||
// Register the diagram's source once — moved, not cloned (the
|
||||
// placement is owned and used only here) — when at least one button
|
||||
// fits; every fitting button below indexes into it for click routing.
|
||||
let source_idx = if row.buttons.iter().any(|b| fits(b.col, b.label)) {
|
||||
let idx = self.inline_media_hits.mermaid_sources.len();
|
||||
self.inline_media_hits.mermaid_sources.push(source);
|
||||
Some(idx)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for btn in row.buttons {
|
||||
if !fits(btn.col, btn.label) {
|
||||
continue;
|
||||
}
|
||||
let bx = rect.x.saturating_add(btn.col);
|
||||
let width = UnicodeWidthStr::width(btn.label) as u16;
|
||||
let hit = Rect {
|
||||
x: bx,
|
||||
y: rect.y,
|
||||
width,
|
||||
height: 1,
|
||||
};
|
||||
// Hovered button is highlighted; idle buttons stay at the normal
|
||||
// `gray` (brighter than the dim `◇ mermaid` label) so they remain
|
||||
// discoverable at rest.
|
||||
let style = if hit.contains((hover_col, hover_row).into()) {
|
||||
Style::default()
|
||||
.fg(theme.text_primary)
|
||||
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
|
||||
} else {
|
||||
Style::default().fg(theme.gray)
|
||||
};
|
||||
buf.set_string_safe(bx, rect.y, btn.label, style);
|
||||
if let Some(idx) = source_idx {
|
||||
self.inline_media_hits
|
||||
.mermaid_buttons
|
||||
.push((hit, btn.kind, idx));
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing dim `rendering…` hint after the buttons (not clickable).
|
||||
if let Some((col, status)) = row.status
|
||||
&& fits(col, status)
|
||||
{
|
||||
buf.set_string_safe(
|
||||
rect.x.saturating_add(col),
|
||||
rect.y,
|
||||
status,
|
||||
Style::default().fg(theme.gray_dim),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the diagram with `source` has an on-click render in flight (drives
|
||||
/// the affordance row's transient `rendering…` hint).
|
||||
fn diagram_is_rendering(&self, source: &str) -> bool {
|
||||
self.mermaid_is_rendering(source)
|
||||
}
|
||||
|
||||
/// Get or allocate a Kitty image ID for the given media path.
|
||||
fn get_or_alloc_media_id(&mut self, path: &std::path::Path) -> u32 {
|
||||
if let Some(&id) = self.inline_media_ids.get(path) {
|
||||
return id;
|
||||
}
|
||||
let id = self.next_inline_media_id;
|
||||
self.next_inline_media_id += 1;
|
||||
self.inline_media_ids.insert(path.to_path_buf(), id);
|
||||
id
|
||||
}
|
||||
|
||||
/// Drain this agent's inline-media placement tracking and return the
|
||||
/// Kitty delete escapes for every image it has placed on the GPU.
|
||||
///
|
||||
/// Kitty graphics are independent of the cell grid: they survive
|
||||
/// redraws until explicitly deleted, and every regular clear path
|
||||
/// lives inside [`AgentView::draw`]. When another view takes over the
|
||||
/// frame (e.g. the agent dashboard), those per-frame clears stop
|
||||
/// running, so the caller uses this to delete whatever this agent
|
||||
/// left on screen. Resetting `inline_media_ids` forces a fresh
|
||||
/// transmit when this agent next draws; any active inline playback
|
||||
/// is stopped, mirroring the scrolled-off-screen clear path.
|
||||
///
|
||||
/// Returns `None` when this agent (and its subagent views) has no
|
||||
/// placements.
|
||||
pub(crate) fn take_inline_media_clear_escapes(&mut self) -> Option<String> {
|
||||
let mut clear_esc = self
|
||||
.take_own_inline_media_clear_escapes()
|
||||
.unwrap_or_default();
|
||||
if let Some(esc) = self.take_subagent_inline_media_clear_escapes() {
|
||||
clear_esc.push_str(&esc);
|
||||
}
|
||||
(!clear_esc.is_empty()).then_some(clear_esc)
|
||||
}
|
||||
|
||||
/// This view's own placements only, leaving `subagent_views` untouched.
|
||||
/// Used by the fullscreen-subagent takeover in [`AgentView::draw`]: the
|
||||
/// parent's images must be deleted, but the child is about to draw and
|
||||
/// manages its own placements — draining it too would just force a
|
||||
/// re-transmit.
|
||||
pub(super) fn take_own_inline_media_clear_escapes(&mut self) -> Option<String> {
|
||||
// Also proceed when only playback state remains (`inline_video` Some
|
||||
// with no active placements — e.g. frames finished loading after the
|
||||
// media scrolled off): the drain must still stop the ticking video,
|
||||
// or it keeps holding the animation gate open invisibly and its
|
||||
// eventual drop is never purged.
|
||||
if !self.inline_media_active
|
||||
&& self.inline_media_ids.is_empty()
|
||||
&& self.inline_video.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.inline_media_active = false;
|
||||
self.stop_inline_playback();
|
||||
let mut clear_esc = String::new();
|
||||
for &id in self.inline_media_ids.values() {
|
||||
clear_esc.push_str(&crate::terminal::image::clear_kitty_image(id));
|
||||
}
|
||||
self.inline_media_ids.clear();
|
||||
self.inline_media_iterm_emitted.clear();
|
||||
self.last_placed_ids.clear();
|
||||
(!clear_esc.is_empty()).then_some(clear_esc)
|
||||
}
|
||||
|
||||
/// Stop inline video playback, dropping the pre-extracted frame set
|
||||
/// (~50–300 MB), and request a post-draw purge for it. Returns whether a
|
||||
/// video was actually playing — callers on the draw path rely on the
|
||||
/// deferred request (never a synchronous purge mid-frame), and image-only
|
||||
/// paths (`None` here) must not purge at all.
|
||||
pub(super) fn stop_inline_playback(&mut self) -> bool {
|
||||
let had_video = self.inline_video.take().is_some();
|
||||
if had_video {
|
||||
crate::memory_release::request_release_after_draw_with("inline-video-stop");
|
||||
}
|
||||
had_video
|
||||
}
|
||||
|
||||
/// Install freshly-extracted inline video frames, dropping (and
|
||||
/// requesting a post-draw purge for) any previous playback's frame set.
|
||||
/// Called from the tick path when the background extraction completes.
|
||||
pub(crate) fn replace_inline_video(&mut self, video: crate::app::agent_view::InlineVideoState) {
|
||||
if self.inline_video.replace(video).is_some() {
|
||||
// Switching videos: the previous frame set just dropped.
|
||||
crate::memory_release::request_release_after_draw_with("inline-video-replace");
|
||||
}
|
||||
}
|
||||
|
||||
/// Subagent fullscreen views render inline media with their own ids —
|
||||
/// drain those (recursively), leaving this view's placements alone.
|
||||
pub(super) fn take_subagent_inline_media_clear_escapes(&mut self) -> Option<String> {
|
||||
let mut clear_esc = String::new();
|
||||
for child in self.subagent_views.values_mut() {
|
||||
if let Some(esc) = child.take_inline_media_clear_escapes() {
|
||||
clear_esc.push_str(&esc);
|
||||
}
|
||||
}
|
||||
(!clear_esc.is_empty()).then_some(clear_esc)
|
||||
}
|
||||
|
||||
/// Refresh [`Self::media_link_paths`] — the absolute paths of media
|
||||
/// generated in this transcript — from scrollback, but only when its
|
||||
/// generation has changed. The model prints short session-relative paths
|
||||
/// (`images/1.jpg`); resolving them against the actual generated files ties
|
||||
/// each link to the file its message produced (correct across forks) and
|
||||
/// never opens an out-of-session or arbitrary file.
|
||||
pub(crate) fn ensure_media_link_paths(&mut self) {
|
||||
let generation = self.scrollback.generation();
|
||||
if self.media_link_paths_gen == Some(generation) {
|
||||
return;
|
||||
}
|
||||
self.media_link_paths_gen = Some(generation);
|
||||
self.media_link_paths.clear();
|
||||
self.media_link_paths.extend(
|
||||
self.scrollback
|
||||
.iter_entries()
|
||||
.filter_map(|(_, entry)| entry.block.media_ref_path()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Open a media file in the OS-native default application (Preview,
|
||||
/// default video player, etc.). Shared by the `[Open]` button, the
|
||||
/// inline-image click target, and the Enter-key handler.
|
||||
pub(crate) fn open_media_natively(&mut self, path: &std::path::Path) -> bool {
|
||||
if crate::app::link_opener::open_path(path) {
|
||||
self.show_toast("Opening in default app\u{2026}");
|
||||
true
|
||||
} else {
|
||||
self.show_toast("Could not open file");
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Start or restart inline video playback. If already playing for this
|
||||
/// path, restarts from the beginning. Frames are extracted via ffmpeg in
|
||||
/// a background thread so the UI never blocks.
|
||||
pub(crate) fn start_inline_video_playback(&mut self, path: &std::path::Path) {
|
||||
// If already loaded for this path, just restart.
|
||||
if let Some(ref mut video) = self.inline_video
|
||||
&& video.path == path
|
||||
{
|
||||
video.current_frame = 0;
|
||||
video.finished = false;
|
||||
video.last_frame_time = std::time::Instant::now();
|
||||
return;
|
||||
}
|
||||
// Extract frames in a background thread to avoid blocking the UI.
|
||||
let path_owned = path.to_path_buf();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
self.video_load_rx = Some(rx);
|
||||
self.show_toast("Loading video\u{2026}");
|
||||
std::thread::spawn(move || {
|
||||
let result =
|
||||
crate::prompt_images::VideoViewerState::open_from_path(&path_owned).map(|viewer| {
|
||||
InlineVideoState {
|
||||
path: path_owned,
|
||||
frames: viewer.frames,
|
||||
current_frame: 0,
|
||||
last_frame_time: std::time::Instant::now(),
|
||||
fps: viewer.fps,
|
||||
finished: false,
|
||||
}
|
||||
});
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
}
|
||||
|
||||
// -- Inline media click handling -----------------------------------------
|
||||
|
||||
/// Handle a click on inline media buttons. Returns `Some(InputOutcome)` if
|
||||
/// the click was consumed, `None` to fall through to normal handling.
|
||||
pub(in crate::app) fn handle_inline_media_click(
|
||||
&mut self,
|
||||
col: u16,
|
||||
row: u16,
|
||||
) -> Option<InputOutcome> {
|
||||
let pos = ratatui::layout::Position::new(col, row);
|
||||
|
||||
// [Open] button or inline image → open natively. Checked before the
|
||||
// play targets so a video's [Open] button opens rather than plays.
|
||||
let open_target = self
|
||||
.inline_media_hits
|
||||
.open_buttons
|
||||
.iter()
|
||||
.chain(self.inline_media_hits.media_areas.iter())
|
||||
.find(|(rect, _)| rect.contains(pos))
|
||||
.map(|(_, path)| path.clone());
|
||||
if let Some(path) = open_target {
|
||||
self.open_media_natively(&path);
|
||||
return Some(InputOutcome::Changed);
|
||||
}
|
||||
|
||||
// [Play] button or video poster → start/restart inline playback.
|
||||
let play_target = self
|
||||
.inline_media_hits
|
||||
.play_buttons
|
||||
.iter()
|
||||
.chain(self.inline_media_hits.video_play_areas.iter())
|
||||
.find(|(rect, _)| rect.contains(pos))
|
||||
.map(|(_, path)| path.clone());
|
||||
if let Some(path) = play_target {
|
||||
self.start_inline_video_playback(&path);
|
||||
return Some(InputOutcome::Changed);
|
||||
}
|
||||
|
||||
// [Copy] button → copy image to clipboard (async).
|
||||
if let Some((_, path)) = self
|
||||
.inline_media_hits
|
||||
.copy_image_buttons
|
||||
.iter()
|
||||
.find(|(rect, _)| rect.contains(pos))
|
||||
{
|
||||
let path = path.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = kigi_shell::util::clipboard::set_image_file(&path) {
|
||||
tracing::debug!("copy image failed: {e}");
|
||||
}
|
||||
});
|
||||
self.show_toast("Copied image");
|
||||
return Some(InputOutcome::Changed);
|
||||
}
|
||||
|
||||
// Click on filepath line → copy path to clipboard.
|
||||
if let Some((_, path)) = self
|
||||
.inline_media_hits
|
||||
.filepath_areas
|
||||
.iter()
|
||||
.find(|(rect, _)| rect.contains(pos))
|
||||
{
|
||||
let path_str = path.display().to_string();
|
||||
self.copy_to_clipboard(&path_str);
|
||||
return Some(InputOutcome::Changed);
|
||||
}
|
||||
|
||||
// Mermaid affordance row → render-on-click (Open/Copy path) or copy
|
||||
// source. Resolve the kind + source index first so the `mermaid_buttons`
|
||||
// borrow ends before the `&mut self` dispatch below.
|
||||
let mermaid_hit = self
|
||||
.inline_media_hits
|
||||
.mermaid_buttons
|
||||
.iter()
|
||||
.find(|(rect, _, _)| rect.contains(pos))
|
||||
.map(|&(_, kind, idx)| (kind, idx));
|
||||
if let Some((kind, idx)) = mermaid_hit {
|
||||
let source = self
|
||||
.inline_media_hits
|
||||
.mermaid_sources
|
||||
.get(idx)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
self.on_mermaid_affordance_click(kind, source);
|
||||
return Some(InputOutcome::Changed);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Route a Mermaid affordance-row click. `[Copy source]` copies the diagram
|
||||
/// source (no render); `[Open]`/`[Copy path]` render it lazily at the live
|
||||
/// theme/width and then open the PNG / copy its path. `source` is moved into
|
||||
/// the renderer, never cloned. `copy_to_clipboard` owns the copy toast.
|
||||
fn on_mermaid_affordance_click(
|
||||
&mut self,
|
||||
kind: crate::scrollback::blocks::mermaid_content::AffordanceKind,
|
||||
source: String,
|
||||
) {
|
||||
use crate::scrollback::blocks::mermaid_content::AffordanceKind;
|
||||
match kind {
|
||||
AffordanceKind::CopySource => {
|
||||
if !self.copy_to_clipboard(&source) {
|
||||
crate::unified_log::error(
|
||||
"mermaid.copy_source.failed",
|
||||
self.session.session_id.as_ref().map(|s| s.0.as_ref()),
|
||||
Some(serde_json::json!({ "source_len": source.len() })),
|
||||
);
|
||||
}
|
||||
}
|
||||
AffordanceKind::Open | AffordanceKind::CopyPath => {
|
||||
let action = if matches!(kind, AffordanceKind::Open) {
|
||||
crate::app::mermaid_worker::MermaidClickAction::Open
|
||||
} else {
|
||||
crate::app::mermaid_worker::MermaidClickAction::CopyPath
|
||||
};
|
||||
self.request_mermaid_render(source, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Video viewer input --------------------------------------------------
|
||||
|
||||
/// Handle a key event in the video viewer modal.
|
||||
pub(super) fn handle_video_viewer_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
let Some(ref mut viewer) = self.video_viewer else {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => {
|
||||
// Clear the Kitty image before closing.
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
let clear = PostFlush::from(overlay::clear_kitty());
|
||||
let _ = clear.write_to(stderr);
|
||||
});
|
||||
self.video_viewer = None;
|
||||
// The viewer's pre-extracted frame set (~50–300 MB for a
|
||||
// typical clip) just dropped; return the pages to the OS.
|
||||
crate::memory_release::release_retained_memory_with("video-viewer-close");
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
viewer.toggle_play_pause();
|
||||
}
|
||||
KeyCode::Right | KeyCode::Char('l') => {
|
||||
viewer.seek_forward();
|
||||
}
|
||||
KeyCode::Left | KeyCode::Char('h') => {
|
||||
viewer.seek_backward();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
|
||||
// -- /gboom easter egg input ------------------------------------------------
|
||||
|
||||
/// Handle a key event in the `/gboom` game modal.
|
||||
pub(super) fn handle_gboom_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
let Some(ref mut gboom) = self.gboom else {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
match gboom.handle_key(key) {
|
||||
crate::gboom::GboomKeyOutcome::Close => {
|
||||
// Clear the kitty image before closing (same as the video
|
||||
// viewer) so no stale frame lingers in the cell grid.
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
let clear = PostFlush::from(overlay::clear_kitty());
|
||||
let _ = clear.write_to(stderr);
|
||||
});
|
||||
self.gboom = None;
|
||||
}
|
||||
crate::gboom::GboomKeyOutcome::Changed => {}
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
|
||||
/// Handle a key-release in the `/gboom` modal (un-latch movement).
|
||||
pub(super) fn handle_gboom_release(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
if let Some(ref mut gboom) = self.gboom {
|
||||
gboom.handle_release(key);
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
|
||||
pub(super) fn handle_gboom_mouse(&mut self, mouse: &MouseEvent) -> InputOutcome {
|
||||
if let Some(ref mut gboom) = self.gboom {
|
||||
gboom.handle_mouse(mouse);
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::memory_release::test_support;
|
||||
|
||||
fn make_agent() -> crate::app::agent_view::AgentView {
|
||||
crate::test_util::make_agent_view(None, "/tmp")
|
||||
}
|
||||
|
||||
fn stub_inline_video() -> crate::app::agent_view::InlineVideoState {
|
||||
crate::app::agent_view::InlineVideoState {
|
||||
path: std::path::PathBuf::from("/tmp/clip.mp4"),
|
||||
frames: vec![Vec::new()],
|
||||
current_frame: 0,
|
||||
last_frame_time: std::time::Instant::now(),
|
||||
fps: 1.0,
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Closing the video viewer modal drops the pre-extracted frame set —
|
||||
/// the purge must fire on close and never on other viewer keys.
|
||||
#[test]
|
||||
fn video_viewer_close_releases_retained_memory() {
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
test_support::install_counting_hook();
|
||||
|
||||
let mut agent = make_agent();
|
||||
agent.video_viewer = Some(crate::prompt_images::VideoViewerState::test_stub());
|
||||
|
||||
// A non-close key keeps the viewer (and its frames) → no purge.
|
||||
let before = test_support::calls();
|
||||
agent.handle_video_viewer_key(&KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert!(agent.video_viewer.is_some());
|
||||
assert_eq!(
|
||||
test_support::calls(),
|
||||
before,
|
||||
"play/pause drops nothing and must not purge"
|
||||
);
|
||||
|
||||
// Esc closes → frames drop → one purge.
|
||||
let before = test_support::calls();
|
||||
agent.handle_video_viewer_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(agent.video_viewer.is_none());
|
||||
assert_eq!(
|
||||
test_support::calls(),
|
||||
before + 1,
|
||||
"closing the viewer must purge after the frame set drops"
|
||||
);
|
||||
}
|
||||
|
||||
/// Draining inline-media placements requests a POST-DRAW purge only when
|
||||
/// live playback (a frame set) was actually dropped — image-only clears
|
||||
/// must not, and the purge must never run synchronously (these paths sit
|
||||
/// inside `draw`). Serialized: the deferred-request flag is process-wide.
|
||||
#[test]
|
||||
#[serial_test::serial(MEMORY_RELEASE_DEFER)]
|
||||
fn inline_media_clear_defers_release_only_for_video() {
|
||||
test_support::install_counting_hook();
|
||||
// Drain any stale request left by an earlier test in this group.
|
||||
crate::memory_release::run_deferred_release();
|
||||
|
||||
let mut agent = make_agent();
|
||||
|
||||
// Image-only placements active: clear drops no frames → no request.
|
||||
agent.inline_media_active = true;
|
||||
let before = test_support::calls();
|
||||
let _ = agent.take_inline_media_clear_escapes();
|
||||
crate::memory_release::run_deferred_release();
|
||||
assert_eq!(
|
||||
test_support::calls(),
|
||||
before,
|
||||
"an image-only media clear must not purge"
|
||||
);
|
||||
|
||||
// Active inline playback: sync no purge; the drain runs it → one.
|
||||
agent.inline_media_active = true;
|
||||
agent.inline_video = Some(stub_inline_video());
|
||||
let before = test_support::calls();
|
||||
let _ = agent.take_inline_media_clear_escapes();
|
||||
assert!(agent.inline_video.is_none());
|
||||
assert_eq!(
|
||||
test_support::calls(),
|
||||
before,
|
||||
"draw-path video stop must never purge synchronously"
|
||||
);
|
||||
crate::memory_release::run_deferred_release();
|
||||
assert_eq!(
|
||||
test_support::calls(),
|
||||
before + 1,
|
||||
"the post-draw drain must purge the dropped frame set"
|
||||
);
|
||||
|
||||
// Orphaned playback (frames finished loading after the media
|
||||
// scrolled off: no active flag, no placements): the drain must still
|
||||
// stop the video and request its purge.
|
||||
agent.inline_media_active = false;
|
||||
agent.inline_video = Some(stub_inline_video());
|
||||
let before = test_support::calls();
|
||||
assert!(agent.take_inline_media_clear_escapes().is_none());
|
||||
assert!(
|
||||
agent.inline_video.is_none(),
|
||||
"orphaned playback must be stopped by the drain"
|
||||
);
|
||||
crate::memory_release::run_deferred_release();
|
||||
assert_eq!(test_support::calls(), before + 1);
|
||||
|
||||
// Nothing at all: the early no-placement return → no request.
|
||||
let before = test_support::calls();
|
||||
let _ = agent.take_inline_media_clear_escapes();
|
||||
crate::memory_release::run_deferred_release();
|
||||
assert_eq!(
|
||||
test_support::calls(),
|
||||
before,
|
||||
"a no-op clear must not purge"
|
||||
);
|
||||
}
|
||||
|
||||
/// Installing freshly-extracted frames purges the PREVIOUS playback's
|
||||
/// frame set (deferred), and never purges on first install.
|
||||
#[test]
|
||||
#[serial_test::serial(MEMORY_RELEASE_DEFER)]
|
||||
fn replace_inline_video_defers_release_only_when_replacing() {
|
||||
test_support::install_counting_hook();
|
||||
crate::memory_release::run_deferred_release();
|
||||
|
||||
let mut agent = make_agent();
|
||||
|
||||
// First install: nothing drops → no request.
|
||||
let before = test_support::calls();
|
||||
agent.replace_inline_video(stub_inline_video());
|
||||
crate::memory_release::run_deferred_release();
|
||||
assert_eq!(
|
||||
test_support::calls(),
|
||||
before,
|
||||
"first frame-set install drops nothing and must not purge"
|
||||
);
|
||||
|
||||
// Replacement: the old frame set drops → deferred purge.
|
||||
let before = test_support::calls();
|
||||
agent.replace_inline_video(stub_inline_video());
|
||||
assert_eq!(
|
||||
test_support::calls(),
|
||||
before,
|
||||
"tick-path replacement must never purge synchronously"
|
||||
);
|
||||
crate::memory_release::run_deferred_release();
|
||||
assert_eq!(test_support::calls(), before + 1);
|
||||
}
|
||||
|
||||
/// Closing the image viewer drops the decoded overlay image — purge
|
||||
/// synchronously (input path), exactly once.
|
||||
#[test]
|
||||
fn image_viewer_close_releases_retained_memory() {
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
test_support::install_counting_hook();
|
||||
|
||||
let mut agent = make_agent();
|
||||
agent.image_viewer = Some(
|
||||
crate::prompt_images::ImageViewerState::open_from_path_deferred(std::path::Path::new(
|
||||
"x.png",
|
||||
)),
|
||||
);
|
||||
let before = test_support::calls();
|
||||
agent.handle_image_viewer_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(agent.image_viewer.is_none());
|
||||
assert_eq!(
|
||||
test_support::calls(),
|
||||
before + 1,
|
||||
"closing the image viewer must purge after the image drops"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
||||
//! Transient user feedback: toasts, ephemeral tips, mode-switch banners,
|
||||
//! terminal-size notes, clipboard-copy feedback, and their tick timers.
|
||||
|
||||
use super::{
|
||||
ActivePane, AgentView, CLIPBOARD_TOAST_DEBOUNCE_MS, MODE_BANNER_TOTAL_TICKS, PromptInputMode,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use super::{AgentPane, test_fixtures};
|
||||
use crate::app::actions::Action;
|
||||
use std::time::Instant;
|
||||
|
||||
impl AgentView {
|
||||
/// Show a brief toast message (e.g., "Copied!").
|
||||
///
|
||||
/// Displayed for ~3 seconds (90 ticks at 30fps). Previous transient toast
|
||||
/// is replaced; [`Self::sticky_toast`] is preserved and returns after this
|
||||
/// expires or is dismissed.
|
||||
pub fn show_toast(&mut self, msg: &str) {
|
||||
let msg = crate::glyphs::legacy_glyph_fallback(msg).into_owned();
|
||||
self.toast = Some((msg, 90));
|
||||
}
|
||||
|
||||
/// Show an ephemeral tip in the banner row above the prompt, gated by the
|
||||
/// app-level per-session `seen_counts` map (`AppView::tip_seen_counts`).
|
||||
/// Returns true when the tip was newly shown (and the per-session count
|
||||
/// incremented in place — never persisted to disk).
|
||||
///
|
||||
/// No-op while the row cannot paint (an occluding view — permission,
|
||||
/// question, modal, subagent takeover, fullscreen viewer, `/gboom`, the
|
||||
/// extensions/agents modals, the goal-detail overlay, or an open prompt
|
||||
/// dropdown — a short terminal, the unknown size before the first draw, or a
|
||||
/// pending re-measure right after a resize event) so counts, TTL, and
|
||||
/// telemetry never burn on an invisible tip.
|
||||
pub fn show_ephemeral_tip(
|
||||
&mut self,
|
||||
tip: crate::tips::EphemeralTip,
|
||||
seen_counts: &mut std::collections::HashMap<&'static str, u32>,
|
||||
) -> bool {
|
||||
if !self.ephemeral_tip_renderable(self.last_terminal_size.1) {
|
||||
return false;
|
||||
}
|
||||
self.ephemeral_tip.show(tip, seen_counts)
|
||||
}
|
||||
|
||||
/// Whether the tip row could paint right now (last drawn size). Lets
|
||||
/// app-level triggers skip work that the show gate would refuse anyway.
|
||||
pub(crate) fn ephemeral_tip_can_render(&self) -> bool {
|
||||
self.ephemeral_tip_renderable(self.last_terminal_size.1)
|
||||
}
|
||||
|
||||
/// Single definition of the agent-level eligibility for the clipboard-image
|
||||
/// tip: the tip row can paint, no image chips are already attached, and the
|
||||
/// current model accepts image input.
|
||||
pub(crate) fn clipboard_image_tip_eligible(&self) -> bool {
|
||||
self.ephemeral_tip_can_render()
|
||||
&& self.prompt.images.is_empty()
|
||||
&& self.session.models.current_model_accepts_images()
|
||||
}
|
||||
|
||||
/// One-shot undo-tip show signal from the last `PromptWidget::handle_key`,
|
||||
/// routed as an action so dispatch can reach `app.tip_seen_counts`. Fires
|
||||
/// only on a qualifying wipe (set exclusively on `PromptEvent::Edited`).
|
||||
pub(super) fn take_prompt_tip_signal(&mut self) -> Option<Action> {
|
||||
// Undo (wipe-to-empty) takes precedence; the wipe and the typed-keyword
|
||||
// nudge are mutually exclusive on a single keypress, so one
|
||||
// `Option<Action>` suffices. The plan nudge is suppressed when already
|
||||
// in plan mode (the optimistic read), while the turn is busy, or in a
|
||||
// special prompt input mode (bash/feedback/remember).
|
||||
if self.prompt.take_undo_tip_fire() {
|
||||
return Some(Action::ShowUndoTip);
|
||||
}
|
||||
let in_plan = self.plan_mode_pending.unwrap_or(self.plan_mode_active);
|
||||
if self.prompt.take_plan_nudge_fire()
|
||||
&& !in_plan
|
||||
&& self.session.state.is_idle()
|
||||
&& self.prompt_input_mode == PromptInputMode::Normal
|
||||
{
|
||||
return Some(Action::ShowPlanNudge);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether the ephemeral tip needs tick / animation this frame.
|
||||
/// Ambient tips freeze under EVERY occluder (permission ask, modal,
|
||||
/// dropdown): their TTL burns only while the row can paint, so an
|
||||
/// occluder pauses rather than expires them off-screen.
|
||||
pub(crate) fn ephemeral_tip_needs_tick(&self) -> bool {
|
||||
self.ephemeral_tip.is_active()
|
||||
&& (!self.ephemeral_tip.active_is_ambient() || self.ephemeral_tip_can_render())
|
||||
}
|
||||
|
||||
/// Advance tip TTL only when the tip is allowed to tick (see
|
||||
/// [`Self::ephemeral_tip_needs_tick`]).
|
||||
pub(crate) fn tick_ephemeral_tip(&mut self) -> bool {
|
||||
// Word-select tip lifecycle: any prompt divergence since the tip was
|
||||
// shown (typed, pasted, dropped — every edit path funnels into the
|
||||
// prompt text) retires it, and the snapshot drops once the tip is
|
||||
// gone for any reason. A visible tip is always ticking (it arms the
|
||||
// metronome), so the sweep runs within a frame of the edit.
|
||||
if self.ephemeral_tip.current_key() == Some(crate::tips::word_select::WORD_SELECT_TIP_KEY) {
|
||||
if self.word_select_tip_prompt_snapshot.as_deref() != Some(self.prompt.text()) {
|
||||
self.ephemeral_tip
|
||||
.clear(crate::tips::word_select::WORD_SELECT_TIP_KEY);
|
||||
self.word_select_tip_prompt_snapshot = None;
|
||||
return true;
|
||||
}
|
||||
} else if self.word_select_tip_prompt_snapshot.is_some() {
|
||||
self.word_select_tip_prompt_snapshot = None;
|
||||
}
|
||||
if !self.ephemeral_tip_needs_tick() {
|
||||
return false;
|
||||
}
|
||||
self.ephemeral_tip.tick()
|
||||
}
|
||||
|
||||
/// Unified visibility for the ephemeral tip row: no occluding view, a
|
||||
/// tall-enough screen, and no resize since the height was measured. Shared
|
||||
/// by the show gate and the draw path (reserve + paint), so a view opening
|
||||
/// over an already-shown tip also stops the row's reservation until it
|
||||
/// closes.
|
||||
///
|
||||
/// Most occluders leave an edit-contextual tip active with TTL still
|
||||
/// burning (tip may repaint on close). AMBIENT tips freeze under any
|
||||
/// occluder: paint yields **and** [`Self::tick_ephemeral_tip`] freezes TTL
|
||||
/// so a long-lived occluder cannot burn the tip off-screen or keep
|
||||
/// `needs_animation` hot.
|
||||
///
|
||||
/// An occluder is anything that, later in the same frame, keeps the banner
|
||||
/// row from reaching the user. The transient mode-switch banner and the
|
||||
/// inline `/btw` panel are deliberately NOT occluders: the banner owns the
|
||||
/// slot ~2 s while the tip's TTL ticks, and `/btw` has its own layout slot
|
||||
/// above the banner.
|
||||
///
|
||||
/// Drift warning: banner-covering views are also enumerated in two sibling
|
||||
/// hand-maintained lists — the pre-overlay inline-media clear in `draw` and
|
||||
/// the per-frame `frame_occluder_rects` (dropdowns + goal detail). A new
|
||||
/// banner-covering view must be added here too.
|
||||
pub(super) fn ephemeral_tip_renderable(&self, screen_height: u16) -> bool {
|
||||
let occluded = !self.permission_queue.is_empty()
|
||||
|| self.question_view.is_some()
|
||||
|| self.active_modal.is_some()
|
||||
// Subagent fullscreen takeover: draw early-returns into
|
||||
// draw_subagent_fullscreen and never paints the parent banner.
|
||||
|| self.active_subagent.is_some()
|
||||
// Fullscreen viewers render after the banner paints: image/video/
|
||||
// block dim the whole region down to the shortcuts row (banner
|
||||
// included). line_viewer's overlay stops at turn_status.y when a
|
||||
// turn status shows, so it does NOT always cover the banner — kept
|
||||
// anyway as a safe over-refusal (the gate cannot know layout
|
||||
// heights, and a tip during viewer reading is unwanted regardless).
|
||||
|| self.line_viewer.is_some()
|
||||
|| self.image_viewer.is_some()
|
||||
|| self.video_viewer.is_some()
|
||||
|| self.block_viewer.is_some()
|
||||
// /gboom dims the same down-to-shortcuts region as the video viewer.
|
||||
|| self.gboom.is_some()
|
||||
// Extensions/agents modals are centered popups (render_modal_window)
|
||||
// that capture all input and early-return out of draw; distinct
|
||||
// from active_modal. persona_detail only renders atop the agents
|
||||
// modal. A tip could at most peek beside the modal, so refuse.
|
||||
|| self.extensions_modal.is_some()
|
||||
|| self.agents_modal.is_some()
|
||||
// Goal-detail is a vertically-centered overlay painted after the
|
||||
// tip; its box only reaches the banner row for tall/content-rich
|
||||
// goals, but kept unconditional as a safe over-refusal (like the
|
||||
// modals and line_viewer) since a tip during goal reading is
|
||||
// unwanted regardless.
|
||||
|| (self.show_goal_detail && self.goal_state.is_some())
|
||||
// Prompt dropdowns (@/slash/completion/history) render in the
|
||||
// row directly above the prompt — the banner row — clearing it.
|
||||
|| self.prompt.any_dropdown_open();
|
||||
!self.terminal_size_stale && crate::tips::tip_row_renderable(occluded, screen_height)
|
||||
}
|
||||
|
||||
/// Draw-path re-measure: record the size of the rect this view painted
|
||||
/// into, invalidating Kitty image IDs when it changed (terminals clear
|
||||
/// GPU data on resize), and mark the measurement fresh again.
|
||||
///
|
||||
/// Only draw calls this — the rect can be smaller than the terminal
|
||||
/// (dashboard overlay header band/popup, dev tracing split), so a
|
||||
/// resize event must NOT write an extrapolated size here; it flags
|
||||
/// staleness via `note_terminal_resize` instead and the next draw
|
||||
/// re-measures.
|
||||
pub(crate) fn note_terminal_size(&mut self, size: (u16, u16)) {
|
||||
if self.last_terminal_size != (0, 0) && self.last_terminal_size != size {
|
||||
self.inline_media_ids.clear();
|
||||
self.inline_media_iterm_emitted.clear();
|
||||
crate::terminal::overlay::reset_owner();
|
||||
}
|
||||
self.last_terminal_size = size;
|
||||
self.terminal_size_stale = false;
|
||||
}
|
||||
|
||||
/// Event-path resize note: the terminal changed size, so the height in
|
||||
/// `last_terminal_size` no longer describes what this view can paint —
|
||||
/// chrome (dashboard overlay header/popup, dev tracing split) means the
|
||||
/// view's rect is not derivable from the event's full-terminal size.
|
||||
/// The ephemeral-tip show gate refuses until the next draw re-measures;
|
||||
/// resize draws are debounced (`RESIZE_DEBOUNCE`), so that window is a
|
||||
/// frame's worth of events, and a refusal burns nothing.
|
||||
pub(crate) fn note_terminal_resize(&mut self) {
|
||||
self.terminal_size_stale = true;
|
||||
}
|
||||
|
||||
/// Set or clear the sticky status banner (process-wide indicators should
|
||||
/// use [`Self::set_sticky_toast_recursive`] on every agent view).
|
||||
pub fn set_sticky_toast(&mut self, msg: Option<&str>) {
|
||||
self.sticky_toast = msg.map(|m| crate::glyphs::legacy_glyph_fallback(m).into_owned());
|
||||
}
|
||||
|
||||
/// Propagate sticky status to this view and every nested subagent view.
|
||||
pub fn set_sticky_toast_recursive(&mut self, msg: Option<&str>) {
|
||||
self.set_sticky_toast(msg);
|
||||
for child in self.subagent_views.values_mut() {
|
||||
child.set_sticky_toast_recursive(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a toast with an explicit tick duration.
|
||||
pub fn show_toast_ticks(&mut self, msg: &str, ticks: u8) {
|
||||
let msg = crate::glyphs::legacy_glyph_fallback(msg).into_owned();
|
||||
self.toast = Some((msg, ticks));
|
||||
}
|
||||
|
||||
/// Message currently drawn in the toast slot: transient wins while active,
|
||||
/// otherwise sticky status (if any).
|
||||
pub(super) fn active_toast_message(&self) -> Option<&str> {
|
||||
if let Some((ref msg, _)) = self.toast {
|
||||
return Some(msg.as_str());
|
||||
}
|
||||
let sticky = self.sticky_toast.as_deref()?;
|
||||
// The mouse-off banner advertises how to re-enable. `Ctrl+R` only works
|
||||
// from scrollback, so when the prompt is focused show the
|
||||
// `/toggle-mouse-reporting` command instead (it toggles from any pane).
|
||||
// Storage keeps the scrollback form; swap the displayed text here.
|
||||
if sticky == crate::app::MOUSE_OFF_HINT_SCROLLBACK && self.active_pane == ActivePane::Prompt
|
||||
{
|
||||
return Some(crate::app::MOUSE_OFF_HINT_PROMPT);
|
||||
}
|
||||
Some(sticky)
|
||||
}
|
||||
|
||||
/// Show a transient "Switched to mode: ..." banner above the prompt.
|
||||
///
|
||||
/// Triggered on Shift+Tab mode cycles.
|
||||
/// Renders at full visibility for 2 s, then fades out over the final 0.3 s.
|
||||
pub fn show_mode_switch_banner(&mut self, mode_name: &str) {
|
||||
let msg = format!("Switched to mode: {}", mode_name);
|
||||
self.mode_switch_banner = Some((msg, MODE_BANNER_TOTAL_TICKS));
|
||||
}
|
||||
|
||||
/// Tick the mode-switch banner timer. Returns true if redraw needed
|
||||
/// (active or just expired).
|
||||
pub fn tick_mode_banner(&mut self) -> bool {
|
||||
if let Some((_, ref mut remaining)) = self.mode_switch_banner {
|
||||
if *remaining == 0 {
|
||||
self.mode_switch_banner = None;
|
||||
return true;
|
||||
}
|
||||
*remaining = remaining.saturating_sub(1);
|
||||
return true; // redraw to advance fade
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Copy text to clipboard and show the result toast.
|
||||
pub fn copy_to_clipboard(&mut self, text: &str) -> bool {
|
||||
let r = crate::clipboard::copy_text(text);
|
||||
self.show_toast_ticks(r.message, r.ticks);
|
||||
r.success
|
||||
}
|
||||
|
||||
/// Like [`copy_to_clipboard`] but debounces the toast to prevent
|
||||
/// rapid flickering during quick word/line selections.
|
||||
pub(super) fn copy_to_clipboard_debounced(&mut self, text: &str) {
|
||||
let now = Instant::now();
|
||||
let too_soon = self
|
||||
.last_clipboard_toast_at
|
||||
.is_some_and(|t| now.duration_since(t).as_millis() < CLIPBOARD_TOAST_DEBOUNCE_MS);
|
||||
if too_soon {
|
||||
// Still copy, just skip the toast.
|
||||
let _ = crate::clipboard::copy_text(text);
|
||||
return;
|
||||
}
|
||||
self.last_clipboard_toast_at = Some(now);
|
||||
self.copy_to_clipboard(text);
|
||||
}
|
||||
|
||||
/// Returns `true` if the terminal can render pixel images. Shows a
|
||||
/// toast and returns `false` when no graphics protocol is available.
|
||||
pub(crate) fn guard_image_support(&mut self) -> bool {
|
||||
if crate::terminal::image::detect_graphics_protocol().supports_images() {
|
||||
return true;
|
||||
}
|
||||
let msg = match crate::terminal::terminal_context().graphics_protocol_skip_reason() {
|
||||
Some("tmux") => "Inline images disabled within tmux.",
|
||||
_ => "Image rendering not supported in this terminal",
|
||||
};
|
||||
self.show_toast_ticks(msg, 60);
|
||||
false
|
||||
}
|
||||
|
||||
/// Tick the transient toast timer. Call once per animation tick.
|
||||
/// Returns true if the transient toast was removed (needs redraw so a
|
||||
/// sticky banner can reappear).
|
||||
pub fn tick_toast(&mut self) -> bool {
|
||||
if let Some((_, ref mut remaining)) = self.toast {
|
||||
if *remaining == 0 {
|
||||
self.toast = None;
|
||||
return true;
|
||||
}
|
||||
*remaining = remaining.saturating_sub(1);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Tick the extensions modal's transient result notice. Returns true if it
|
||||
/// just expired (needs a redraw to erase the badge / status line).
|
||||
pub fn tick_extensions_result_notice(&mut self) -> bool {
|
||||
self.extensions_modal
|
||||
.as_mut()
|
||||
.is_some_and(|m| m.tick_result_notice())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mouse_off_banner_tests {
|
||||
use super::test_fixtures::make_running_agent;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mouse_off_banner_key_swaps_with_focused_pane() {
|
||||
let mut view = make_running_agent();
|
||||
view.set_sticky_toast(Some(crate::app::MOUSE_OFF_HINT_SCROLLBACK));
|
||||
|
||||
// Scrollback focus: Ctrl+R works there, so advertise it.
|
||||
view.active_pane = AgentPane::Scrollback;
|
||||
assert_eq!(
|
||||
view.active_toast_message(),
|
||||
Some(crate::app::MOUSE_OFF_HINT_SCROLLBACK)
|
||||
);
|
||||
|
||||
// Prompt focus: the toggle chord is scrollback-only, so advertise the command.
|
||||
view.active_pane = AgentPane::Prompt;
|
||||
assert_eq!(
|
||||
view.active_toast_message(),
|
||||
Some(crate::app::MOUSE_OFF_HINT_PROMPT)
|
||||
);
|
||||
|
||||
// A transient toast still wins over the sticky banner, regardless of pane.
|
||||
view.show_toast("Copied!");
|
||||
assert_eq!(view.active_toast_message(), Some("Copied!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_mouse_sticky_banner_is_not_swapped() {
|
||||
let mut view = make_running_agent();
|
||||
view.set_sticky_toast(Some("Reconnecting"));
|
||||
view.active_pane = AgentPane::Prompt;
|
||||
assert_eq!(view.active_toast_message(), Some("Reconnecting"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
//! Secondary pane input: scrollback keys and search, todo/tool-usage panes,
|
||||
//! background tasks, subagent catalog, and the pane-aware scroll router.
|
||||
use super::{ActivePane, AgentPane, AgentView, overlay_action_to_outcome, resolve_action};
|
||||
use crate::actions::{ActionId, ActionRegistry, When};
|
||||
use crate::app::actions::Action;
|
||||
use crate::app::app_view::InputOutcome;
|
||||
use crate::key;
|
||||
use crate::scrollback::ScrollbackSearchState;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
|
||||
impl AgentView {
|
||||
/// Scrollback-focused key handling.
|
||||
///
|
||||
/// When the block viewer is open, routes keys to the viewer.
|
||||
/// Otherwise, uses ActionRegistry for keybinding lookup.
|
||||
pub(super) fn handle_scrollback_key(
|
||||
&mut self,
|
||||
key: &KeyEvent,
|
||||
registry: &ActionRegistry,
|
||||
) -> InputOutcome {
|
||||
if let Some(outcome) = self.handle_scrollback_search_key(key) {
|
||||
return outcome;
|
||||
}
|
||||
let viewer_has_input = self
|
||||
.block_viewer
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.list_state.input_mode().is_some());
|
||||
let allow_i_alt = self.vim_mode;
|
||||
if !viewer_has_input
|
||||
&& (matches!(key.code, KeyCode::Tab | KeyCode::Char(' '))
|
||||
|| (allow_i_alt && matches!(key.code, KeyCode::Char('i'))))
|
||||
{
|
||||
if self.question_view.is_some() {
|
||||
self.set_active_pane(AgentPane::Prompt, false);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if key.code == KeyCode::Tab
|
||||
&& self.tasks.overlay.visible
|
||||
&& self.set_active_pane(AgentPane::Tasks, false)
|
||||
{
|
||||
self.tasks.overlay.focused = true;
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
return InputOutcome::Action(Action::FocusPrompt);
|
||||
}
|
||||
if key!(Enter).matches(key)
|
||||
&& let Some(url) = self.highlighted_link_url().map(String::from)
|
||||
{
|
||||
self.highlighted_link_idx = None;
|
||||
return InputOutcome::Action(Action::OpenUrl(url));
|
||||
}
|
||||
if key!(Enter).matches(key)
|
||||
&& !self.scrollback.is_selected_group_header()
|
||||
&& let Some(idx) = self.scrollback.selected()
|
||||
&& self
|
||||
.scrollback
|
||||
.entry(idx)
|
||||
.is_some_and(|e| e.block.is_user_prompt())
|
||||
&& self.enter_inline_edit(idx)
|
||||
{
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if key!(Enter).matches(key)
|
||||
&& !self.scrollback.is_selected_group_header()
|
||||
&& let Some(idx) = self.scrollback.selected()
|
||||
&& let Some(entry) = self.scrollback.entry(idx)
|
||||
&& let crate::scrollback::block::RenderBlock::Subagent(ref sb) = entry.block
|
||||
{
|
||||
let child_sid = sb.child_session_id.clone();
|
||||
if self.subagent_views.contains_key(&child_sid) {
|
||||
self.open_subagent_fullscreen(child_sid);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
if self.vim_mode
|
||||
&& key!('x').matches(key)
|
||||
&& !self.scrollback.is_selected_group_header()
|
||||
&& let Some(idx) = self.scrollback.selected()
|
||||
&& let Some(entry) = self.scrollback.entry(idx)
|
||||
&& let crate::scrollback::block::RenderBlock::BgTask(ref bt) = entry.block
|
||||
&& self
|
||||
.session
|
||||
.bg_tasks
|
||||
.get(&bt.task_id)
|
||||
.is_some_and(|t| t.status == crate::app::agent::BgTaskStatus::Running)
|
||||
{
|
||||
return InputOutcome::Action(Action::KillBgTask(bt.task_id.clone()));
|
||||
}
|
||||
if key.code == KeyCode::Esc
|
||||
&& key.modifiers.is_empty()
|
||||
&& self.persistent_text_selection.take().is_some()
|
||||
{
|
||||
self.table_selection_geometry = None;
|
||||
self.selection_created_at = None;
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if key.code == KeyCode::Esc
|
||||
&& key.modifiers.is_empty()
|
||||
&& self.highlighted_link_idx.take().is_some()
|
||||
{
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if self.vim_mode
|
||||
&& key!('/').matches(key)
|
||||
&& self.no_input_overlay_pending()
|
||||
&& self.btw_state.is_none()
|
||||
{
|
||||
if self.scrollback.is_empty() {
|
||||
return InputOutcome::ActionThenForward(Action::FocusPrompt);
|
||||
}
|
||||
self.open_scrollback_search(None);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if key!('r', CONTROL).matches(key)
|
||||
&& (registry.find(ActionId::ToggleMouseCapture).is_some()
|
||||
|| crate::app::mouse_reporting_toggle_enabled())
|
||||
{
|
||||
return InputOutcome::Action(Action::ToggleMouseCapture);
|
||||
}
|
||||
if let Some(outcome) =
|
||||
resolve_action(registry.lookup_with_mode(key, When::ScrollbackFocused, self.vim_mode))
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
if !self.vim_mode
|
||||
&& let KeyCode::Char(c) = key.code
|
||||
&& (c.is_ascii_alphabetic() || c == '/')
|
||||
&& (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT)
|
||||
{
|
||||
return InputOutcome::ActionThenForward(Action::FocusPrompt);
|
||||
}
|
||||
InputOutcome::Unchanged
|
||||
}
|
||||
/// Focus the scrollback pane and open an incremental search over it.
|
||||
///
|
||||
/// Shared by the vim `/` key and the `/find` slash command so both entry
|
||||
/// points land in the same state, refocusing scrollback when `/find` is run
|
||||
/// from the prompt in simple mode.
|
||||
///
|
||||
/// Only opens the search if the pane switch succeeds: a dirty queued-prompt
|
||||
/// edit blocks the switch (showing the confirm modal) and returns false, so
|
||||
/// opening search then would strand an invisible session on the prompt — the
|
||||
/// search bar and key handling are gated on scrollback being focused.
|
||||
///
|
||||
/// `initial_query` (the `/find <word>` argument) is fed through the same
|
||||
/// keystroke path so a pre-filled search behaves identically to typing the
|
||||
/// word into the bar: a composing regex query with immediate highlights.
|
||||
pub(crate) fn open_scrollback_search(&mut self, initial_query: Option<&str>) {
|
||||
if self.set_active_pane(AgentPane::Scrollback, false) {
|
||||
self.scrollback_search = Some(ScrollbackSearchState::open());
|
||||
if let Some(query) = initial_query {
|
||||
self.set_scrollback_search_query(query);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Step to the next (`forward`) or previous match and scroll it into view.
|
||||
/// Shared by the `n`/`N` keys and the `↓`/`↑` arrows.
|
||||
fn navigate_search(&mut self, forward: bool) -> Option<InputOutcome> {
|
||||
if let Some(search) = self.scrollback_search.as_mut() {
|
||||
if forward {
|
||||
search.next();
|
||||
} else {
|
||||
search.prev();
|
||||
}
|
||||
}
|
||||
self.reveal_current_search_match();
|
||||
Some(InputOutcome::Changed)
|
||||
}
|
||||
/// Bottom scrollback rows to reserve for the search UI (divider + bar):
|
||||
/// two when search is active, clamped to the rows that actually exist so a
|
||||
/// very short region never pushes the bar below the scrollback rect.
|
||||
pub(super) fn search_reserved_rows(scrollback_height: u16, search_active: bool) -> u16 {
|
||||
if search_active {
|
||||
scrollback_height.min(2)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
/// Handle a key while the scrollback search overlay is open.
|
||||
///
|
||||
/// Returns `None` when search isn't open (or, while browsing, for keys that
|
||||
/// should fall through to normal scrollback handling). While composing the
|
||||
/// query the bar is modal and swallows other keys.
|
||||
fn handle_scrollback_search_key(&mut self, key: &KeyEvent) -> Option<InputOutcome> {
|
||||
let composing = self.scrollback_search.as_ref()?.is_composing();
|
||||
let non_text = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER;
|
||||
if key.code == KeyCode::Esc {
|
||||
self.scrollback_search = None;
|
||||
return Some(InputOutcome::Changed);
|
||||
}
|
||||
if key.modifiers.is_empty() {
|
||||
match key.code {
|
||||
KeyCode::Down => return self.navigate_search(true),
|
||||
KeyCode::Up => return self.navigate_search(false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if composing {
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
if self.scrollback_search.as_ref()?.query().is_empty() {
|
||||
self.scrollback_search = None;
|
||||
} else {
|
||||
if let Some(search) = self.scrollback_search.as_mut() {
|
||||
search.accept();
|
||||
}
|
||||
self.reveal_current_search_match();
|
||||
}
|
||||
Some(InputOutcome::Changed)
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
let mut q = self.scrollback_search.as_ref()?.query().to_string();
|
||||
q.pop();
|
||||
self.set_scrollback_search_query(&q);
|
||||
Some(InputOutcome::Changed)
|
||||
}
|
||||
KeyCode::Char(c) if !key.modifiers.intersects(non_text) => {
|
||||
let mut q = self.scrollback_search.as_ref()?.query().to_string();
|
||||
q.push(c);
|
||||
self.set_scrollback_search_query(&q);
|
||||
Some(InputOutcome::Changed)
|
||||
}
|
||||
_ => Some(InputOutcome::Unchanged),
|
||||
}
|
||||
} else {
|
||||
match key.code {
|
||||
KeyCode::Char('n') if key.modifiers.is_empty() => self.navigate_search(true),
|
||||
KeyCode::Char('N') if !key.modifiers.intersects(non_text) => {
|
||||
self.navigate_search(false)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Enqueue `query` for the background scan. Results (and the reveal) arrive
|
||||
/// later via [`poll_scrollback_search`](Self::poll_scrollback_search); the
|
||||
/// highlight updates immediately because it reads the UI-side matcher.
|
||||
fn set_scrollback_search_query(&mut self, query: &str) {
|
||||
if let Some(search) = self.scrollback_search.as_mut() {
|
||||
search.update_query(query, &self.scrollback);
|
||||
}
|
||||
}
|
||||
/// Poll the background search daemon for new results, revealing the freshly
|
||||
/// parked match when they change. Returns `true` if the UI should redraw.
|
||||
pub(crate) fn poll_scrollback_search(&mut self) -> bool {
|
||||
let changed = self.scrollback_search.as_mut().is_some_and(|s| s.poll());
|
||||
if changed {
|
||||
self.reveal_current_search_match();
|
||||
}
|
||||
changed
|
||||
}
|
||||
/// Scroll the current search match into view via `reveal_entry_line`.
|
||||
fn reveal_current_search_match(&mut self) {
|
||||
let target = self
|
||||
.scrollback_search
|
||||
.as_ref()
|
||||
.and_then(|s| s.current())
|
||||
.map(|m| (m.entry_id, m.line_in_entry));
|
||||
if let Some((id, line)) = target
|
||||
&& let Some(idx) = self.scrollback.index_of_id(id)
|
||||
{
|
||||
self.scrollback.reveal_entry_line(idx, line);
|
||||
}
|
||||
}
|
||||
/// Todo-pane-focused key handling.
|
||||
///
|
||||
/// Routes structural keys through the shared overlay handler, then
|
||||
/// content keys through `TodoPane::handle_key`.
|
||||
pub(super) fn handle_todo_key(
|
||||
&mut self,
|
||||
key: &KeyEvent,
|
||||
_registry: &ActionRegistry,
|
||||
) -> InputOutcome {
|
||||
use crate::views::overlay::{handle_overlay_key, handle_overlay_nav_key};
|
||||
if key!('t', CONTROL).matches(key) {
|
||||
self.todo.overlay.toggle();
|
||||
self.todo.on_state_change();
|
||||
if !self.todo.overlay.focused {
|
||||
return InputOutcome::Action(Action::FocusScrollback);
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
let has_input = self.todo.list_state.input_mode().is_some();
|
||||
let action = handle_overlay_key(&mut self.todo.overlay, key).or_else(|| {
|
||||
if !has_input {
|
||||
handle_overlay_nav_key(&mut self.todo.overlay, key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(action) = action {
|
||||
self.todo.on_state_change();
|
||||
if !self.todo.overlay.visible || !self.todo.overlay.focused {
|
||||
self.set_active_pane(AgentPane::Scrollback, false);
|
||||
}
|
||||
return overlay_action_to_outcome(action);
|
||||
}
|
||||
if self.todo.handle_key(key) {
|
||||
InputOutcome::Changed
|
||||
} else {
|
||||
InputOutcome::Unchanged
|
||||
}
|
||||
}
|
||||
/// Bg-task-pane-focused key handling.
|
||||
pub(super) fn handle_bg_tasks_key(
|
||||
&mut self,
|
||||
key: &KeyEvent,
|
||||
_registry: &ActionRegistry,
|
||||
) -> InputOutcome {
|
||||
use crate::views::overlay::{handle_overlay_key, handle_overlay_nav_key};
|
||||
use crate::views::tasks_pane::TaskEntry;
|
||||
if key!('b', CONTROL).matches(key) {
|
||||
self.tasks.overlay.toggle();
|
||||
self.tasks.on_state_change();
|
||||
if !self.tasks.overlay.focused {
|
||||
return InputOutcome::Action(Action::FocusScrollback);
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if self.tasks.list_state.input_mode().is_none()
|
||||
&& let Some(group) = self.tasks.selected_header_group()
|
||||
{
|
||||
if key!(Right).matches(key) {
|
||||
self.tasks.set_group_collapsed(group, false);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if key!(Left).matches(key) {
|
||||
self.tasks.set_group_collapsed(group, true);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
let is_open_key = self.tasks.list_state.input_mode().is_none()
|
||||
&& (key!(Enter).matches(key) || key!('f', CONTROL).matches(key));
|
||||
if is_open_key {
|
||||
if let Some(group) = self.tasks.selected_header_group() {
|
||||
self.tasks.toggle_group(group);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
match self.tasks.selected_entry() {
|
||||
Some(TaskEntry::BgTask { task_id, .. }) => {
|
||||
let task_id = task_id.clone();
|
||||
if let Some(task) = self.session.bg_tasks.get(&task_id) {
|
||||
let entry_id = task
|
||||
.scrollback_entry_id
|
||||
.unwrap_or_else(|| crate::scrollback::entry::EntryId::new(0));
|
||||
let is_running = task.status == crate::app::agent::BgTaskStatus::Running;
|
||||
self.block_viewer =
|
||||
Some(crate::views::block_viewer::BlockViewerPane::for_bg_task(
|
||||
entry_id,
|
||||
&task_id,
|
||||
&task.stdout,
|
||||
is_running,
|
||||
));
|
||||
self.set_active_pane(AgentPane::Scrollback, true);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
Some(TaskEntry::Agent {
|
||||
child_session_id, ..
|
||||
}) => {
|
||||
let child_sid = child_session_id.clone();
|
||||
if self.subagent_views.contains_key(&child_sid) {
|
||||
self.open_subagent_fullscreen(child_sid);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
Some(TaskEntry::Scheduled { .. }) => {}
|
||||
Some(TaskEntry::Header { .. }) => {}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if key!('x').matches(key) && self.tasks.list_state.input_mode().is_none() {
|
||||
match self.tasks.selected_entry() {
|
||||
Some(TaskEntry::BgTask { task_id, .. }) => {
|
||||
let task_id = task_id.clone();
|
||||
if self
|
||||
.session
|
||||
.bg_tasks
|
||||
.get(&task_id)
|
||||
.is_some_and(|t| t.status == crate::app::agent::BgTaskStatus::Running)
|
||||
{
|
||||
return InputOutcome::Action(Action::KillBgTask(task_id));
|
||||
}
|
||||
}
|
||||
Some(TaskEntry::Agent { subagent_id, .. }) => {
|
||||
let subagent_id = subagent_id.clone();
|
||||
if self.subagent_sessions.values().any(|s| {
|
||||
s.subagent_id.as_ref() == subagent_id && s.is_running() && !s.pending_kill
|
||||
}) {
|
||||
return InputOutcome::Action(Action::KillSubagent(subagent_id));
|
||||
}
|
||||
}
|
||||
Some(TaskEntry::Scheduled { task_id, .. }) => {
|
||||
return InputOutcome::Action(Action::CancelScheduledTask(task_id.clone()));
|
||||
}
|
||||
Some(TaskEntry::Header { .. }) => {}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if key!('y').matches(key)
|
||||
&& self.tasks.list_state.input_mode().is_none()
|
||||
&& let Some(task_id) = self.tasks.selected_task_id().map(|s| s.to_string())
|
||||
&& let Some(task) = self.session.bg_tasks.get(&task_id)
|
||||
&& !task.stdout.is_empty()
|
||||
{
|
||||
let text = task.stdout.clone();
|
||||
self.copy_to_clipboard(&text);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if key!(Tab).matches(key) && self.tasks.list_state.input_mode().is_none() {
|
||||
self.tasks.overlay.focused = false;
|
||||
return InputOutcome::Action(Action::FocusPrompt);
|
||||
}
|
||||
let has_input = self.tasks.list_state.input_mode().is_some();
|
||||
let action = handle_overlay_key(&mut self.tasks.overlay, key).or_else(|| {
|
||||
if !has_input {
|
||||
handle_overlay_nav_key(&mut self.tasks.overlay, key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(action) = action {
|
||||
self.tasks.on_state_change();
|
||||
if !self.tasks.overlay.visible || !self.tasks.overlay.focused {
|
||||
self.set_active_pane(AgentPane::Scrollback, false);
|
||||
}
|
||||
return overlay_action_to_outcome(action);
|
||||
}
|
||||
if self.tasks.handle_key(key) {
|
||||
InputOutcome::Changed
|
||||
} else {
|
||||
InputOutcome::Unchanged
|
||||
}
|
||||
}
|
||||
/// Subagent-pane-focused key handling.
|
||||
pub(super) fn handle_catalog_key(
|
||||
&mut self,
|
||||
key: &KeyEvent,
|
||||
_registry: &ActionRegistry,
|
||||
) -> InputOutcome {
|
||||
use crate::views::overlay::{handle_overlay_key, handle_overlay_nav_key};
|
||||
let has_input = self.catalog.list_state.input_mode().is_some();
|
||||
let action = handle_overlay_key(&mut self.catalog.overlay, key).or_else(|| {
|
||||
if !has_input {
|
||||
handle_overlay_nav_key(&mut self.catalog.overlay, key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(action) = action {
|
||||
self.catalog.on_state_change();
|
||||
if !self.catalog.overlay.visible || !self.catalog.overlay.focused {
|
||||
self.set_active_pane(AgentPane::Scrollback, false);
|
||||
}
|
||||
return overlay_action_to_outcome(action);
|
||||
}
|
||||
if key.code == crossterm::event::KeyCode::Enter
|
||||
&& key.modifiers == crossterm::event::KeyModifiers::NONE
|
||||
{
|
||||
if let Some((kind, name)) = self.catalog.selected_entry() {
|
||||
return InputOutcome::Action(Action::ViewCatalogEntry {
|
||||
kind: kind.to_owned(),
|
||||
name: name.to_owned(),
|
||||
});
|
||||
}
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if self.catalog.handle_key(key) {
|
||||
InputOutcome::Changed
|
||||
} else {
|
||||
InputOutcome::Unchanged
|
||||
}
|
||||
}
|
||||
/// Handle a normalized scroll event at a screen position.
|
||||
///
|
||||
/// Hit-tests against pane areas to decide what to scroll:
|
||||
/// - Scrollback area → scroll the scrollback (uses accelerated line count)
|
||||
/// - Prompt area → forward to textarea (which has its own scroll logic)
|
||||
///
|
||||
/// Positive `lines` = scroll down, negative = scroll up.
|
||||
pub fn handle_scroll(&mut self, lines: i32, col: u16, row: u16) {
|
||||
if let Some(ref mut modal) = self.active_modal {
|
||||
use crate::views::modal::ActiveModal;
|
||||
match modal {
|
||||
ActiveModal::CommandPalette { state, .. }
|
||||
| ActiveModal::ArgPicker { state, .. }
|
||||
| ActiveModal::SessionPicker { state, .. }
|
||||
| ActiveModal::DocPicker { state, .. } => {
|
||||
let delta = lines.unsigned_abs() as usize;
|
||||
let current = state.scroll_offset.unwrap_or(0);
|
||||
let new_offset = if lines > 0 {
|
||||
current + delta
|
||||
} else {
|
||||
current.saturating_sub(delta)
|
||||
};
|
||||
state.scroll_offset = Some(new_offset);
|
||||
state.hovered = None;
|
||||
return;
|
||||
}
|
||||
ActiveModal::DocViewer { scroll, .. }
|
||||
| ActiveModal::RememberNoteReview { scroll, .. } => {
|
||||
crate::views::modal::apply_doc_scroll_delta(scroll, lines);
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(ref mut viewer) = self.block_viewer {
|
||||
viewer.handle_scroll(lines);
|
||||
return;
|
||||
}
|
||||
if self.rewind_state.is_some() {
|
||||
if let Some(ref mut rw) = self.rewind_state {
|
||||
crate::views::rewind::move_cursor(&mut rw.phase, lines.signum());
|
||||
self.sync_rewind_anchor_to_picker();
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.dismiss_jump_picker_if_suppressed();
|
||||
if let Some(ref mut js) = self.jump_state {
|
||||
crate::views::jump::move_cursor(js, lines.signum());
|
||||
self.sync_jump_preview();
|
||||
return;
|
||||
}
|
||||
if let Some(ref mut viewer) = self.line_viewer {
|
||||
if let Some(area) = viewer.last_popup_area
|
||||
&& area.contains((col, row).into())
|
||||
{
|
||||
viewer
|
||||
.list_state
|
||||
.handle_scroll_event(lines, col, row, &viewer.lines);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(ref mut btw) = self.btw_state
|
||||
&& matches!(btw, crate::views::btw_overlay::BtwOverlayState::Done { .. })
|
||||
&& self.last_btw_area.area() > 0
|
||||
&& self.last_btw_area.contains((col, row).into())
|
||||
{
|
||||
use crate::views::btw_overlay::DONE_MAX_BODY_LINES;
|
||||
let max_body = DONE_MAX_BODY_LINES as usize;
|
||||
let content_width = self.last_btw_area.width.saturating_sub(4) as usize;
|
||||
let max_off = btw.max_scroll_offset(content_width, max_body);
|
||||
if lines > 0 {
|
||||
btw.scroll_down(lines as usize, max_off);
|
||||
} else {
|
||||
btw.scroll_up((-lines) as usize);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(hd_area) = self.history_dropdown_area
|
||||
&& hd_area.contains((col, row).into())
|
||||
&& self.prompt.history_search.is_active()
|
||||
{
|
||||
let moved = if lines > 0 {
|
||||
self.prompt.history_search.move_down()
|
||||
} else if lines < 0 {
|
||||
self.prompt.history_search.move_up()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if moved && self.prompt.history_search.is_browse() {
|
||||
self.populate_prompt_from_history_selection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(dd_area) = self.dropdown_items_area
|
||||
&& dd_area.contains((col, row).into())
|
||||
{
|
||||
self.prompt
|
||||
.file_search
|
||||
.move_selection(lines.signum() as isize);
|
||||
return;
|
||||
}
|
||||
if let Some(dd_area) = self.slash_dropdown_items_area
|
||||
&& dd_area.contains((col, row).into())
|
||||
{
|
||||
self.prompt.slash_scroll_selection(lines.signum() as isize);
|
||||
self.prompt.slash_preview_current_selection();
|
||||
return;
|
||||
}
|
||||
if let Some(dd_area) = self.completion_dropdown_items_area
|
||||
&& dd_area.contains((col, row).into())
|
||||
{
|
||||
self.prompt
|
||||
.completion_dropdown_scroll(lines.signum() as isize);
|
||||
return;
|
||||
}
|
||||
if self.question_view.is_some() && self.pane_areas.prompt.contains((col, row).into()) {
|
||||
if self
|
||||
.inline_prompt_area
|
||||
.is_some_and(|r| r.contains((col, row).into()))
|
||||
{
|
||||
let kind = if lines > 0 {
|
||||
MouseEventKind::ScrollDown
|
||||
} else {
|
||||
MouseEventKind::ScrollUp
|
||||
};
|
||||
let event = MouseEvent {
|
||||
kind,
|
||||
column: col,
|
||||
row,
|
||||
modifiers: crossterm::event::KeyModifiers::NONE,
|
||||
};
|
||||
let _ = self.prompt.handle_mouse(&event);
|
||||
} else if let Some((scroll_top, scroll_bottom)) = self.question_scroll_region
|
||||
&& row >= scroll_top
|
||||
&& row < scroll_bottom
|
||||
{
|
||||
self.apply_question_scroll(lines);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let target = self
|
||||
.pane_areas
|
||||
.hit_test(col, row)
|
||||
.unwrap_or(ActivePane::Scrollback);
|
||||
match target {
|
||||
ActivePane::Scrollback => {
|
||||
if lines > 0 {
|
||||
self.scrollback.scroll_down(lines as u16);
|
||||
} else {
|
||||
self.scrollback.scroll_up((-lines) as u16);
|
||||
}
|
||||
}
|
||||
ActivePane::Todo => {
|
||||
self.todo.handle_scroll(lines, col, row);
|
||||
}
|
||||
ActivePane::Queue => {
|
||||
self.queue.handle_scroll(lines, col, row);
|
||||
}
|
||||
ActivePane::Tasks => {
|
||||
self.tasks.handle_scroll(lines, col, row);
|
||||
}
|
||||
ActivePane::Catalog => {
|
||||
self.catalog.handle_scroll(lines, col, row);
|
||||
}
|
||||
ActivePane::Prompt => {
|
||||
if self.question_view.is_some() {
|
||||
return;
|
||||
}
|
||||
let kind = if lines > 0 {
|
||||
MouseEventKind::ScrollDown
|
||||
} else {
|
||||
MouseEventKind::ScrollUp
|
||||
};
|
||||
let event = MouseEvent {
|
||||
kind,
|
||||
column: col,
|
||||
row,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
};
|
||||
self.prompt.handle_mouse(&event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod scroll_granularity_tests {
|
||||
use super::super::test_fixtures::make_agent;
|
||||
use crate::views::suggestion_controller::{
|
||||
CompletionDropdownState, CompletionItemParsed, SuggestionSource,
|
||||
};
|
||||
use ratatui::layout::Rect;
|
||||
/// Selection dropdowns step exactly one item per wheel dispatch: a
|
||||
/// 3-line notch (or accelerated trackpad flush) must not skip items.
|
||||
#[test]
|
||||
fn wheel_notch_over_slash_dropdown_moves_selection_one_step() {
|
||||
let mut agent = make_agent();
|
||||
agent.prompt.set_text("/");
|
||||
agent.prompt.refresh_slash(&agent.session.models);
|
||||
assert!(agent.prompt.slash_open(), "precondition: dropdown open");
|
||||
assert!(
|
||||
agent.prompt.slash_snapshot().matches.len() >= 3,
|
||||
"precondition: enough builtin commands to skip over"
|
||||
);
|
||||
assert_eq!(agent.prompt.slash_snapshot().selected, 0);
|
||||
agent.slash_dropdown_items_area = Some(Rect::new(0, 0, 40, 8));
|
||||
agent.handle_scroll(3, 5, 4);
|
||||
assert_eq!(
|
||||
agent.prompt.slash_snapshot().selected,
|
||||
1,
|
||||
"3-line wheel notch must move the slash selection by exactly 1"
|
||||
);
|
||||
agent.handle_scroll(-3, 5, 4);
|
||||
assert_eq!(
|
||||
agent.prompt.slash_snapshot().selected,
|
||||
0,
|
||||
"-3-line wheel notch must move the slash selection by exactly -1"
|
||||
);
|
||||
}
|
||||
fn completion_item(label: &str) -> CompletionItemParsed {
|
||||
CompletionItemParsed {
|
||||
display: label.into(),
|
||||
description: String::new(),
|
||||
insert_text: label.into(),
|
||||
source: SuggestionSource::History,
|
||||
priority: 0,
|
||||
replace_range: None,
|
||||
token_text: None,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn wheel_notch_over_completion_dropdown_moves_selection_one_step() {
|
||||
let mut agent = make_agent();
|
||||
agent.prompt.suggestions.dropdown = CompletionDropdownState {
|
||||
open: true,
|
||||
items: vec![
|
||||
completion_item("a"),
|
||||
completion_item("b"),
|
||||
completion_item("c"),
|
||||
],
|
||||
selected: 0,
|
||||
..Default::default()
|
||||
};
|
||||
agent.completion_dropdown_items_area = Some(Rect::new(0, 0, 40, 8));
|
||||
agent.handle_scroll(3, 5, 4);
|
||||
assert_eq!(
|
||||
agent.prompt.suggestions.dropdown.selected, 1,
|
||||
"3-line wheel notch must move the completion selection by exactly 1"
|
||||
);
|
||||
agent.handle_scroll(-3, 5, 4);
|
||||
assert_eq!(
|
||||
agent.prompt.suggestions.dropdown.selected, 0,
|
||||
"-3-line wheel notch must move the completion selection by exactly -1"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,855 @@
|
||||
//! Plan surfaces: plan chip/preview, plan approval + feedback, and casual
|
||||
//! plan commenting (incl. the casual-commenting test fixture).
|
||||
use super::AgentView;
|
||||
#[cfg(test)]
|
||||
use super::{ActivePane, InputMode, test_fixtures};
|
||||
#[cfg(test)]
|
||||
use crate::actions::ActionRegistry;
|
||||
use crate::app::actions::Action;
|
||||
use crate::app::app_view::InputOutcome;
|
||||
use crate::views::file_search::line_viewer::LineViewerState;
|
||||
use crate::views::list_pane::ListItem;
|
||||
use crate::views::plan_approval_view::{PlanApprovalFocus, PlanComment, PlanReviewSource};
|
||||
use crate::views::prompt_widget::{EnterOutcome, PromptEvent};
|
||||
#[cfg(test)]
|
||||
use crossterm::event::KeyModifiers;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
impl AgentView {
|
||||
/// Resolve the absolute path to the plan file for this session.
|
||||
fn plan_file_path(&self) -> Option<std::path::PathBuf> {
|
||||
let session_id = self.session.session_id.as_ref()?;
|
||||
let cwd_str = self.session.cwd.to_string_lossy().into_owned();
|
||||
let encoded_cwd = urlencoding::encode(&cwd_str);
|
||||
Some(
|
||||
kigi_shell::util::kigi_home::kigi_home()
|
||||
.join("sessions")
|
||||
.join(encoded_cwd.as_ref())
|
||||
.join(session_id.0.as_ref())
|
||||
.join("plan.md"),
|
||||
)
|
||||
}
|
||||
/// Whether the current line viewer is showing a plan preview.
|
||||
pub(super) fn is_plan_viewer(&self) -> bool {
|
||||
self.line_viewer.as_ref().is_some_and(|v| {
|
||||
v.kind == crate::views::file_search::line_viewer::LineViewerKind::PlanPreview
|
||||
})
|
||||
}
|
||||
/// Whether the user is currently composing a comment via the prompt
|
||||
/// input inside the *casual* plan preview (the modal opened with no
|
||||
/// `plan_approval_view`). Mirrors the `pav.focus == Commenting`
|
||||
/// check used by the plan-approval path so the prompt/footer
|
||||
/// behaves identically across both modes.
|
||||
pub(super) fn is_casual_commenting(&self) -> bool {
|
||||
self.plan_approval_view.is_none()
|
||||
&& self.is_plan_viewer()
|
||||
&& self.casual_commenting_range.is_some()
|
||||
}
|
||||
/// Whether the prompt "auto" (LLM classifier mode) flag should render.
|
||||
/// Extracted for unit testing the precedence: auto shows only when the
|
||||
/// session is in auto mode and neither yolo (always-approve wins) nor plan
|
||||
/// is active.
|
||||
pub(super) fn auto_flag_visible(&self, effective_plan: bool) -> bool {
|
||||
self.session.is_auto() && !self.session.is_yolo() && !effective_plan
|
||||
}
|
||||
/// Whether plan content is available for preview.
|
||||
fn plan_preview_available(&self) -> bool {
|
||||
self.plan_body_for_preview().is_some()
|
||||
}
|
||||
/// Whether the "plan" status-bar chip should be rendered.
|
||||
///
|
||||
/// Visible while plan mode is active, or always when the user has set
|
||||
/// `show_plan_chip = true` in `pager.toml`. Hidden by default once the
|
||||
/// user exits plan mode.
|
||||
pub(super) fn should_show_plan_chip(
|
||||
&self,
|
||||
appearance: &crate::appearance::AppearanceConfig,
|
||||
) -> bool {
|
||||
(self.plan_mode_active || appearance.show_plan_chip) && self.plan_preview_available()
|
||||
}
|
||||
fn inline_plan_content(&self) -> Option<&str> {
|
||||
self.plan_approval_view
|
||||
.as_ref()
|
||||
.filter(|p| p.source == PlanReviewSource::Inline)
|
||||
.and_then(|p| p.plan_content.as_deref())
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
/// Resolve the plan body for the line-viewer preview.
|
||||
///
|
||||
/// Prefers content carried on the approval request (inline plan-creation or
|
||||
/// the shell-read file body), then falls back to the on-disk plan file.
|
||||
/// Request body first keeps file-backed previews working when the path
|
||||
/// resolution fails or the file disappears between intercept and open.
|
||||
fn plan_body_for_preview(&self) -> Option<String> {
|
||||
if let Some(content) = self
|
||||
.plan_approval_view
|
||||
.as_ref()
|
||||
.and_then(|p| p.plan_content.as_deref())
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
{
|
||||
return Some(content.to_owned());
|
||||
}
|
||||
if let Some(content) = self
|
||||
.latest_inline_plan_content
|
||||
.as_deref()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
{
|
||||
return Some(content.to_owned());
|
||||
}
|
||||
self.plan_file_path()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
/// Open the plan preview when content exists, or when plan approval is
|
||||
/// parked with an empty body (so the decision surface always pops).
|
||||
pub(crate) fn show_plan_preview_if_available(&mut self) {
|
||||
if self.plan_preview_available() || self.plan_approval_view.is_some() {
|
||||
self.show_plan_preview();
|
||||
}
|
||||
}
|
||||
/// Show the plan in the line viewer overlay or a "no plan" toast.
|
||||
///
|
||||
/// When plan approval is parked without a body, opens a placeholder
|
||||
/// preview so the user always sees a decision surface (a/s/q) instead of
|
||||
/// a dead "Waiting on plan approval" line with a no-op Tab:plan.
|
||||
pub fn show_plan_preview(&mut self) {
|
||||
let body = self.plan_body_for_preview();
|
||||
let approval_empty = self
|
||||
.plan_approval_view
|
||||
.as_ref()
|
||||
.is_some_and(|p| !p.has_plan);
|
||||
let Some(mut viewer) = (if let Some(content) = body {
|
||||
LineViewerState::open_markdown_content("plan.md", content, None)
|
||||
} else if approval_empty {
|
||||
LineViewerState::open_markdown_content(
|
||||
"plan.md",
|
||||
crate::views::plan_approval_view::EMPTY_PLAN_PLACEHOLDER.to_owned(),
|
||||
None,
|
||||
)
|
||||
} else if let Some(plan_path) = self.plan_file_path() {
|
||||
LineViewerState::open_markdown(&plan_path, None)
|
||||
} else {
|
||||
None
|
||||
}) else {
|
||||
self.show_toast("No plan written yet.");
|
||||
return;
|
||||
};
|
||||
viewer.kind = crate::views::file_search::line_viewer::LineViewerKind::PlanPreview;
|
||||
viewer.title_override = Some(if approval_empty {
|
||||
"plan.md (empty)".to_string()
|
||||
} else {
|
||||
"plan.md".to_string()
|
||||
});
|
||||
viewer.fullscreen = true;
|
||||
{
|
||||
let plan = viewer.plan_mut();
|
||||
plan.show_action_buttons = self.plan_approval_view.is_none();
|
||||
plan.feedback_active = self.plan_approval_view.is_some();
|
||||
}
|
||||
if let Some(ref pav) = self.plan_approval_view
|
||||
&& !pav.comments.is_empty()
|
||||
{
|
||||
viewer.rebuild_with_comments(&pav.comments);
|
||||
} else if !self.plan_comments.is_empty() {
|
||||
viewer.rebuild_with_comments(&self.plan_comments);
|
||||
}
|
||||
self.line_viewer = Some(viewer);
|
||||
}
|
||||
/// Test fixture: drive the agent into casual-commenting state
|
||||
/// (line viewer open in plan-preview mode + `casual_commenting_range`
|
||||
/// armed) so the `Event::Paste` plan-feedback arm at ~1539 is
|
||||
/// reachable from a unit test without spawning the real
|
||||
/// keystroke pipeline. Consolidates three field mutations into
|
||||
/// one helper so a future refactor of casual-commenting state
|
||||
/// only has to update this fixture rather than every test that
|
||||
/// reaches into the fields by name.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn enter_casual_commenting_for_test(&mut self) {
|
||||
let mut viewer =
|
||||
crate::views::file_search::line_viewer::LineViewerState::open_markdown_content(
|
||||
"test.md",
|
||||
"hello\n".to_owned(),
|
||||
None,
|
||||
)
|
||||
.expect("fixture must open the line viewer");
|
||||
viewer.kind = crate::views::file_search::line_viewer::LineViewerKind::PlanPreview;
|
||||
self.line_viewer = Some(viewer);
|
||||
self.casual_commenting_range = Some(0..1);
|
||||
}
|
||||
pub(crate) fn approve_plan(&mut self) -> InputOutcome {
|
||||
let Some(mut pav) = self.plan_approval_view.take() else {
|
||||
return InputOutcome::Changed;
|
||||
};
|
||||
let review_comments = if !pav.comments.is_empty() {
|
||||
let formatted = pav.format_feedback(None);
|
||||
if formatted.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"The user approved the plan with the following review comments:\n\n{}",
|
||||
formatted
|
||||
))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
pav.send_approved();
|
||||
self.latest_inline_plan_content = None;
|
||||
self.plan_next_comment_id = pav.next_comment_id;
|
||||
self.prompt.restore(pav.stashed_prompt);
|
||||
self.line_viewer = None;
|
||||
self.casual_commenting_range = None;
|
||||
self.casual_editing_comment_id = None;
|
||||
{}
|
||||
if let Some(text) = review_comments {
|
||||
return InputOutcome::Action(Action::Interject {
|
||||
text,
|
||||
images: vec![],
|
||||
});
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
pub(crate) fn abandon_plan(&mut self) -> InputOutcome {
|
||||
let Some(mut pav) = self.plan_approval_view.take() else {
|
||||
return InputOutcome::Changed;
|
||||
};
|
||||
pav.send_abandoned();
|
||||
self.plan_mode_pending = Some(false);
|
||||
self.latest_inline_plan_content = None;
|
||||
self.plan_next_comment_id = pav.next_comment_id;
|
||||
self.prompt.restore(pav.stashed_prompt);
|
||||
self.line_viewer = None;
|
||||
self.casual_commenting_range = None;
|
||||
self.casual_editing_comment_id = None;
|
||||
{}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
fn send_plan_feedback(&mut self, feedback: Option<String>) -> InputOutcome {
|
||||
let Some(mut pav) = self.plan_approval_view.take() else {
|
||||
return InputOutcome::Changed;
|
||||
};
|
||||
let formatted = pav.format_feedback(feedback.as_deref());
|
||||
let to_send = if formatted.trim().is_empty() {
|
||||
feedback
|
||||
} else {
|
||||
Some(formatted)
|
||||
};
|
||||
if crate::app::minimal_mode_active()
|
||||
&& let Some(msg) = to_send.as_deref().map(str::trim).filter(|s| !s.is_empty())
|
||||
{
|
||||
self.scrollback
|
||||
.push_block(crate::scrollback::RenderBlock::user_prompt(msg.to_string()));
|
||||
}
|
||||
pav.send_cancelled(to_send);
|
||||
if pav.source == PlanReviewSource::Inline {
|
||||
self.latest_inline_plan_content = None;
|
||||
}
|
||||
self.plan_next_comment_id = pav.next_comment_id;
|
||||
self.prompt.restore(pav.stashed_prompt);
|
||||
self.line_viewer = None;
|
||||
self.prompt.textarea.cancel_undo_group();
|
||||
self.show_toast("Plan revision sent.");
|
||||
{}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
pub(crate) fn reopen_plan_approval(&mut self) {
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.stashed_prompt = self.prompt.stash();
|
||||
pav.focus = PlanApprovalFocus::Preview;
|
||||
}
|
||||
self.prompt.set_text("");
|
||||
self.show_plan_preview_if_available();
|
||||
if self.line_viewer.is_none() {
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.focus = PlanApprovalFocus::Prompt;
|
||||
}
|
||||
} else if let Some(ref mut viewer) = self.line_viewer {
|
||||
viewer.plan_mut().feedback_active = true;
|
||||
}
|
||||
}
|
||||
/// Discard an in-progress comment draft: clear the prompt text and
|
||||
/// drop the selected line range + pending edit + stashed feedback.
|
||||
/// Used whenever focus leaves the prompt without an explicit save
|
||||
/// or cancel (e.g. Tab back to Preview, click into the modal).
|
||||
fn discard_in_progress_comment(&mut self) {
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.commenting_range = None;
|
||||
pav.editing_comment_id = None;
|
||||
pav.stashed_feedback_prompt = None;
|
||||
}
|
||||
self.prompt.set_text("");
|
||||
}
|
||||
pub(super) fn handle_plan_feedback_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
let is_commenting = self
|
||||
.plan_approval_view
|
||||
.as_ref()
|
||||
.is_some_and(|pav| pav.focus == PlanApprovalFocus::Commenting);
|
||||
if key.code == KeyCode::Tab && key.modifiers.is_empty() {
|
||||
let focus = self.plan_approval_view.as_ref().map(|p| p.focus);
|
||||
match focus {
|
||||
Some(PlanApprovalFocus::Prompt) | Some(PlanApprovalFocus::Commenting) => {
|
||||
if self.line_viewer.is_none() {
|
||||
self.show_plan_preview_if_available();
|
||||
}
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.focus = PlanApprovalFocus::Preview;
|
||||
}
|
||||
if let Some(ref mut viewer) = self.line_viewer {
|
||||
viewer.plan_mut().feedback_active = true;
|
||||
}
|
||||
}
|
||||
Some(PlanApprovalFocus::Preview) => {
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.focus = PlanApprovalFocus::Prompt;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if is_commenting {
|
||||
self.discard_in_progress_comment();
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if key.code == KeyCode::Esc {
|
||||
if self.prompt.file_search_visible() {
|
||||
self.prompt.file_search.clear_context();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if is_commenting {
|
||||
let stashed = if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.focus = PlanApprovalFocus::Preview;
|
||||
pav.editing_comment_id = None;
|
||||
pav.commenting_range = None;
|
||||
pav.stashed_feedback_prompt.take()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(stashed) = stashed {
|
||||
self.prompt.restore(stashed);
|
||||
} else {
|
||||
self.prompt.set_text("");
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.focus = PlanApprovalFocus::Preview;
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
match self.prompt.route_enter(key) {
|
||||
EnterOutcome::NewlineInserted => return InputOutcome::Changed,
|
||||
EnterOutcome::Submit => {
|
||||
if is_commenting {
|
||||
return self.save_plan_comment();
|
||||
}
|
||||
let text = self.prompt.text().to_string();
|
||||
let has_comments = self
|
||||
.plan_approval_view
|
||||
.as_ref()
|
||||
.is_some_and(|pav| !pav.comments.is_empty());
|
||||
let prompt_focused = self
|
||||
.plan_approval_view
|
||||
.as_ref()
|
||||
.is_some_and(|pav| pav.focus == PlanApprovalFocus::Prompt);
|
||||
if prompt_focused {
|
||||
if text.trim().is_empty() && !has_comments {
|
||||
return self.approve_plan();
|
||||
}
|
||||
let freeform = if text.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(text)
|
||||
};
|
||||
return self.send_plan_feedback(freeform);
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
EnterOutcome::PassThrough => {}
|
||||
}
|
||||
match self.prompt.handle_key(key) {
|
||||
PromptEvent::Edited => {
|
||||
if let Some(req) = self.prompt.pending_viewer_request.take() {
|
||||
self.open_line_viewer(&req.path, req.initial_range);
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
PromptEvent::Ignored => InputOutcome::Changed,
|
||||
}
|
||||
}
|
||||
pub(super) fn enter_plan_commenting(&mut self) -> InputOutcome {
|
||||
let viewer = match self.line_viewer.as_mut() {
|
||||
Some(v) => v,
|
||||
None => return InputOutcome::Changed,
|
||||
};
|
||||
if let Some(vi) = viewer.list_state.selected_index() {
|
||||
let pi = viewer.list_state.to_physical(vi);
|
||||
if let Some(comment_id) = viewer.lines.get(pi).and_then(|item| item.comment_id())
|
||||
&& let Some(pav) = self.plan_approval_view.as_mut()
|
||||
&& let Some(comment) = pav.comments.iter().find(|c| c.id == comment_id)
|
||||
{
|
||||
let comment_text = comment.text.clone();
|
||||
let comment_range = comment.line_range.clone();
|
||||
pav.stashed_feedback_prompt = Some(self.prompt.stash());
|
||||
pav.editing_comment_id = Some(comment_id);
|
||||
pav.commenting_range = Some(comment_range);
|
||||
pav.focus = PlanApprovalFocus::Commenting;
|
||||
self.prompt.set_text(&comment_text);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
let range = viewer.selected_line_range();
|
||||
let Some(range) = range else {
|
||||
return InputOutcome::Changed;
|
||||
};
|
||||
if viewer.list_state.visual_mode {
|
||||
let start_vi = viewer.list_state.multi_range().map(|r| r.start);
|
||||
if let Some(start_vi) = start_vi {
|
||||
let start_pi = viewer.list_state.to_physical(start_vi);
|
||||
let start_id = viewer.lines.get(start_pi).map(|l| l.stable_id());
|
||||
viewer.list_state.exit_visual_mode();
|
||||
if let Some(id) = start_id {
|
||||
viewer.list_state.select_by_id(id);
|
||||
}
|
||||
} else {
|
||||
viewer.list_state.exit_visual_mode();
|
||||
}
|
||||
}
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.stashed_feedback_prompt = Some(self.prompt.stash());
|
||||
pav.commenting_range = Some(range);
|
||||
pav.editing_comment_id = None;
|
||||
pav.focus = PlanApprovalFocus::Commenting;
|
||||
}
|
||||
self.prompt.set_text("");
|
||||
InputOutcome::Changed
|
||||
}
|
||||
fn save_plan_comment(&mut self) -> InputOutcome {
|
||||
let text = self.prompt.text().to_string();
|
||||
if text.trim().is_empty() {
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
let pav = match self.plan_approval_view.as_mut() {
|
||||
Some(pav) => pav,
|
||||
None => return InputOutcome::Changed,
|
||||
};
|
||||
let range = match pav.commenting_range.take() {
|
||||
Some(r) => r,
|
||||
None => return InputOutcome::Changed,
|
||||
};
|
||||
if let Some(edit_id) = pav.editing_comment_id.take() {
|
||||
if let Some(comment) = pav.comments.iter_mut().find(|c| c.id == edit_id) {
|
||||
comment.text = text;
|
||||
comment.line_range = range;
|
||||
}
|
||||
} else {
|
||||
let id = pav.next_comment_id;
|
||||
pav.next_comment_id += 1;
|
||||
pav.comments.push(PlanComment {
|
||||
id,
|
||||
line_range: range,
|
||||
text,
|
||||
});
|
||||
}
|
||||
pav.focus = PlanApprovalFocus::Preview;
|
||||
let comments = pav.comments.clone();
|
||||
if let Some(ref mut viewer) = self.line_viewer {
|
||||
viewer.rebuild_with_comments(&comments);
|
||||
}
|
||||
if let Some(stashed) = pav.stashed_feedback_prompt.take() {
|
||||
self.prompt.restore(stashed);
|
||||
} else {
|
||||
self.prompt.set_text("");
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
pub(super) fn delete_plan_comment_at_cursor(&mut self) -> InputOutcome {
|
||||
let viewer = match self.line_viewer.as_ref() {
|
||||
Some(v) => v,
|
||||
None => return InputOutcome::Changed,
|
||||
};
|
||||
let vi = match viewer.list_state.selected_index() {
|
||||
Some(vi) => vi,
|
||||
None => return InputOutcome::Changed,
|
||||
};
|
||||
let pi = viewer.list_state.to_physical(vi);
|
||||
let comment_id = match viewer.lines.get(pi).and_then(|item| item.comment_id()) {
|
||||
Some(id) => id,
|
||||
None => return InputOutcome::Changed,
|
||||
};
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.comments.retain(|c| c.id != comment_id);
|
||||
let comments = pav.comments.clone();
|
||||
if let Some(ref mut viewer) = self.line_viewer {
|
||||
viewer.rebuild_with_comments(&comments);
|
||||
}
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
/// Enter casual commenting mode from the plan preview.
|
||||
///
|
||||
/// If the cursor is on a comment line, enter edit mode for that comment.
|
||||
/// If the cursor is on a source line, capture the line range and enter
|
||||
/// new-comment mode.
|
||||
pub(super) fn enter_casual_plan_commenting(&mut self) -> InputOutcome {
|
||||
let viewer = match self.line_viewer.as_mut() {
|
||||
Some(v) => v,
|
||||
None => return InputOutcome::Changed,
|
||||
};
|
||||
if let Some(vi) = viewer.list_state.selected_index() {
|
||||
let pi = viewer.list_state.to_physical(vi);
|
||||
if let Some(comment_id) = viewer.lines.get(pi).and_then(|item| item.comment_id())
|
||||
&& let Some(comment) = self.plan_comments.iter().find(|c| c.id == comment_id)
|
||||
{
|
||||
let comment_text = comment.text.clone();
|
||||
let comment_range = comment.line_range.clone();
|
||||
if self.casual_stashed_prompt.is_none() {
|
||||
self.casual_stashed_prompt = Some(self.prompt.stash());
|
||||
}
|
||||
self.casual_editing_comment_id = Some(comment_id);
|
||||
self.casual_commenting_range = Some(comment_range);
|
||||
self.prompt.set_text(&comment_text);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
let range = viewer.selected_line_range();
|
||||
let Some(range) = range else {
|
||||
return InputOutcome::Changed;
|
||||
};
|
||||
if viewer.list_state.visual_mode {
|
||||
let start_vi = viewer.list_state.multi_range().map(|r| r.start);
|
||||
if let Some(start_vi) = start_vi {
|
||||
let start_pi = viewer.list_state.to_physical(start_vi);
|
||||
let start_id = viewer.lines.get(start_pi).map(|l| l.stable_id());
|
||||
viewer.list_state.exit_visual_mode();
|
||||
if let Some(id) = start_id {
|
||||
viewer.list_state.select_by_id(id);
|
||||
}
|
||||
} else {
|
||||
viewer.list_state.exit_visual_mode();
|
||||
}
|
||||
}
|
||||
if self.casual_stashed_prompt.is_none() {
|
||||
self.casual_stashed_prompt = Some(self.prompt.stash());
|
||||
}
|
||||
self.casual_commenting_range = Some(range);
|
||||
self.casual_editing_comment_id = None;
|
||||
self.prompt.set_text("");
|
||||
InputOutcome::Changed
|
||||
}
|
||||
/// Save the current casual comment (new or edited) and rebuild the viewer.
|
||||
pub(super) fn save_casual_plan_comment(&mut self) -> InputOutcome {
|
||||
let text = self.prompt.text().to_owned();
|
||||
if text.trim().is_empty() {
|
||||
return self.cancel_casual_plan_commenting();
|
||||
}
|
||||
let range = match self.casual_commenting_range.take() {
|
||||
Some(r) => r,
|
||||
None => return self.cancel_casual_plan_commenting(),
|
||||
};
|
||||
if let Some(edit_id) = self.casual_editing_comment_id.take() {
|
||||
if let Some(comment) = self.plan_comments.iter_mut().find(|c| c.id == edit_id) {
|
||||
comment.text = text;
|
||||
comment.line_range = range;
|
||||
}
|
||||
} else {
|
||||
let id = self.plan_next_comment_id;
|
||||
self.plan_next_comment_id += 1;
|
||||
self.plan_comments.push(PlanComment {
|
||||
id,
|
||||
line_range: range,
|
||||
text,
|
||||
});
|
||||
}
|
||||
if let Some(stashed) = self.casual_stashed_prompt.take() {
|
||||
self.prompt.restore(stashed);
|
||||
} else {
|
||||
self.prompt.set_text("");
|
||||
}
|
||||
let comments = self.plan_comments.clone();
|
||||
if let Some(ref mut viewer) = self.line_viewer {
|
||||
viewer.rebuild_with_comments(&comments);
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
/// Cancel casual plan commenting without saving.
|
||||
pub(super) fn cancel_casual_plan_commenting(&mut self) -> InputOutcome {
|
||||
self.casual_commenting_range = None;
|
||||
self.casual_editing_comment_id = None;
|
||||
if let Some(stashed) = self.casual_stashed_prompt.take() {
|
||||
self.prompt.restore(stashed);
|
||||
} else {
|
||||
self.prompt.set_text("");
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
/// Key handler used while the user is composing a casual plan
|
||||
/// comment via the prompt input. Mirrors `handle_plan_feedback_key`
|
||||
/// (which serves the plan-approval Commenting focus) so the UX is
|
||||
/// identical: Enter saves, Esc cancels, Tab cancels back to the
|
||||
/// modal, and everything else routes to the prompt textarea.
|
||||
pub(super) fn handle_casual_plan_feedback_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
if key.code == KeyCode::Esc {
|
||||
if self.prompt.file_search_visible() {
|
||||
self.prompt.file_search.clear_context();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
return self.cancel_casual_plan_commenting();
|
||||
}
|
||||
match self.prompt.route_enter(key) {
|
||||
EnterOutcome::NewlineInserted => return InputOutcome::Changed,
|
||||
EnterOutcome::Submit => return self.save_casual_plan_comment(),
|
||||
EnterOutcome::PassThrough => {}
|
||||
}
|
||||
if key.code == KeyCode::Tab && key.modifiers.is_empty() {
|
||||
return self.cancel_casual_plan_commenting();
|
||||
}
|
||||
match self.prompt.handle_key(key) {
|
||||
PromptEvent::Edited => {
|
||||
if let Some(req) = self.prompt.pending_viewer_request.take() {
|
||||
self.open_line_viewer(&req.path, req.initial_range);
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
PromptEvent::Ignored => InputOutcome::Changed,
|
||||
}
|
||||
}
|
||||
/// Delete the casual comment under the cursor in the plan preview.
|
||||
pub(super) fn delete_casual_plan_comment_at_cursor(&mut self) -> InputOutcome {
|
||||
let viewer = match self.line_viewer.as_ref() {
|
||||
Some(v) => v,
|
||||
None => return InputOutcome::Unchanged,
|
||||
};
|
||||
let vi = match viewer.list_state.selected_index() {
|
||||
Some(vi) => vi,
|
||||
None => return InputOutcome::Unchanged,
|
||||
};
|
||||
let pi = viewer.list_state.to_physical(vi);
|
||||
let comment_id = match viewer.lines.get(pi).and_then(|item| item.comment_id()) {
|
||||
Some(id) => id,
|
||||
None => return InputOutcome::Unchanged,
|
||||
};
|
||||
self.plan_comments.retain(|c| c.id != comment_id);
|
||||
let comments = self.plan_comments.clone();
|
||||
if let Some(ref mut viewer) = self.line_viewer {
|
||||
viewer.rebuild_with_comments(&comments);
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
pub(super) fn send_casual_plan_comments(&mut self) -> InputOutcome {
|
||||
if self.plan_comments.is_empty() {
|
||||
self.show_toast("No comments to send.");
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
let plan_content = self.inline_plan_content().map(str::to_owned).or_else(|| {
|
||||
let path = self.plan_file_path()?;
|
||||
std::fs::read_to_string(path).ok()
|
||||
});
|
||||
let body = crate::views::plan_approval_view::format_plan_comments(
|
||||
&self.plan_comments,
|
||||
plan_content.as_deref(),
|
||||
);
|
||||
let text = format!("Plan feedback:\n\n{body}");
|
||||
self.plan_comments.clear();
|
||||
self.plan_next_comment_id = 0;
|
||||
self.cancel_line_viewer();
|
||||
self.show_toast("Plan feedback sent.");
|
||||
InputOutcome::Action(Action::SendPrompt(text))
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod prompt_flag_tests {
|
||||
use super::test_fixtures::make_agent;
|
||||
/// The prompt "auto" (classifier) mode flag shows only when the session is
|
||||
/// in Auto and neither yolo (always-approve wins) nor plan is active.
|
||||
#[test]
|
||||
fn auto_flag_visible_precedence() {
|
||||
let mut agent = make_agent();
|
||||
assert!(!agent.auto_flag_visible(false));
|
||||
agent.session.auto_mode = true;
|
||||
assert!(agent.auto_flag_visible(false));
|
||||
assert!(!agent.auto_flag_visible(true));
|
||||
agent.session.yolo_mode = true;
|
||||
assert!(!agent.auto_flag_visible(false));
|
||||
agent.session.yolo_mode = false;
|
||||
assert!(agent.auto_flag_visible(false));
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod plan_chip_tests {
|
||||
use super::*;
|
||||
use crate::acp::model_state::ModelState;
|
||||
use crate::app::agent::{AgentId, AgentSession, AgentState};
|
||||
use crate::appearance::AppearanceConfig;
|
||||
use crate::scrollback::state::ScrollbackState;
|
||||
fn make_agent() -> AgentView {
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
AgentView::new(
|
||||
AgentSession {
|
||||
id: AgentId(0),
|
||||
acp_tx: tx,
|
||||
session_id: None,
|
||||
models: ModelState::default(),
|
||||
state: AgentState::Idle,
|
||||
tracker: crate::acp::tracker::AcpUpdateTracker::new(),
|
||||
cwd: std::path::PathBuf::from("/tmp"),
|
||||
is_worktree: false,
|
||||
forked_from: None,
|
||||
pending_prompts: std::collections::VecDeque::new(),
|
||||
next_queue_id: 0,
|
||||
yolo_mode: false,
|
||||
auto_mode: false,
|
||||
prompt_history: Vec::new(),
|
||||
prompt_history_loading: false,
|
||||
loading_replay: false,
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
model_switch_pending: false,
|
||||
user_model_preference: None,
|
||||
deferred_model_switch: None,
|
||||
bg_tasks: std::collections::BTreeMap::new(),
|
||||
bg_tool_call_to_task: std::collections::HashMap::new(),
|
||||
scheduled_tasks: std::collections::HashMap::new(),
|
||||
in_flight_prompt: None,
|
||||
current_prompt_id: None,
|
||||
created_via_new: false,
|
||||
},
|
||||
ScrollbackState::new(),
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn plan_chip_hidden_after_exit_by_default() {
|
||||
let mut agent = make_agent();
|
||||
agent.plan_mode_active = false;
|
||||
let appearance = AppearanceConfig::default();
|
||||
assert!(!appearance.show_plan_chip);
|
||||
assert!(!agent.should_show_plan_chip(&appearance));
|
||||
}
|
||||
#[test]
|
||||
fn plan_chip_visible_while_plan_mode_active() {
|
||||
let mut agent = make_agent();
|
||||
agent.plan_mode_active = true;
|
||||
let appearance = AppearanceConfig::default();
|
||||
assert!(!agent.should_show_plan_chip(&appearance));
|
||||
}
|
||||
#[test]
|
||||
fn plan_chip_visible_when_config_overrides() {
|
||||
let mut agent = make_agent();
|
||||
agent.plan_mode_active = false;
|
||||
let appearance = AppearanceConfig {
|
||||
show_plan_chip: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!agent.should_show_plan_chip(&appearance));
|
||||
}
|
||||
#[test]
|
||||
fn set_input_mode_vim_empty_prompt_switches_to_scrollback_and_j_selects_next() {
|
||||
crate::appearance::cache::set_simple_mode(true);
|
||||
let mut agent = make_agent();
|
||||
agent.vim_mode = true;
|
||||
agent.set_active_pane(ActivePane::Prompt, true);
|
||||
agent.set_input_mode(InputMode::Vim);
|
||||
assert_eq!(agent.active_pane, ActivePane::Scrollback);
|
||||
assert!(!agent.is_simple_mode());
|
||||
let registry = ActionRegistry::defaults();
|
||||
let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
|
||||
let outcome = agent.handle_scrollback_key(&j, ®istry);
|
||||
assert!(matches!(outcome, InputOutcome::Action(Action::SelectNext)));
|
||||
}
|
||||
#[test]
|
||||
fn set_input_mode_vim_nonempty_prompt_keeps_pane() {
|
||||
let mut agent = make_agent();
|
||||
agent.set_active_pane(ActivePane::Prompt, true);
|
||||
agent.prompt.set_text("draft");
|
||||
agent.set_input_mode(InputMode::Vim);
|
||||
assert_eq!(agent.active_pane, ActivePane::Prompt);
|
||||
}
|
||||
#[test]
|
||||
fn set_input_mode_simple_from_scrollback_leaves_pane_unchanged() {
|
||||
let mut agent = make_agent();
|
||||
agent.vim_mode = true;
|
||||
agent.set_active_pane(ActivePane::Scrollback, true);
|
||||
agent.set_input_mode(InputMode::Simple);
|
||||
assert_eq!(agent.active_pane, ActivePane::Scrollback);
|
||||
assert!(agent.is_simple_mode());
|
||||
let registry = ActionRegistry::defaults();
|
||||
let x = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
|
||||
let outcome = agent.handle_scrollback_key(&x, ®istry);
|
||||
assert_eq!(agent.active_pane, ActivePane::Scrollback);
|
||||
assert!(matches!(outcome, InputOutcome::Unchanged));
|
||||
}
|
||||
#[test]
|
||||
fn new_agent_respects_persisted_simple_mode_for_mode_and_pane() {
|
||||
crate::appearance::cache::set_simple_mode(true);
|
||||
let a1 = make_agent();
|
||||
assert!(a1.is_simple_mode());
|
||||
assert_eq!(a1.active_pane, ActivePane::Prompt);
|
||||
crate::appearance::cache::set_simple_mode(false);
|
||||
let a2 = make_agent();
|
||||
assert!(!a2.is_simple_mode());
|
||||
assert_eq!(a2.active_pane, ActivePane::Scrollback);
|
||||
}
|
||||
#[test]
|
||||
fn set_input_mode_reconciles_pane_orthogonal_to_active_modal_field() {
|
||||
let mut agent = make_agent();
|
||||
agent.set_active_pane(ActivePane::Prompt, true);
|
||||
agent.active_modal = None;
|
||||
agent.set_input_mode(InputMode::Vim);
|
||||
assert_eq!(agent.active_pane, ActivePane::Scrollback);
|
||||
assert!(agent.active_modal.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn scrollback_j_with_vim_mode_off_forwards_to_prompt() {
|
||||
crate::appearance::cache::set_vim_mode(false);
|
||||
let mut agent = make_agent();
|
||||
agent.vim_mode = false;
|
||||
agent.set_active_pane(ActivePane::Scrollback, true);
|
||||
let registry = ActionRegistry::defaults();
|
||||
let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
|
||||
let outcome = agent.handle_scrollback_key(&j, ®istry);
|
||||
assert!(
|
||||
matches!(
|
||||
outcome,
|
||||
InputOutcome::ActionThenForward(Action::FocusPrompt)
|
||||
),
|
||||
"vim-off: bare 'j' in scrollback must forward to prompt; got {outcome:?}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn scrollback_j_with_vim_mode_on_selects_next() {
|
||||
crate::appearance::cache::set_vim_mode(true);
|
||||
let mut agent = make_agent();
|
||||
agent.vim_mode = true;
|
||||
agent.set_active_pane(ActivePane::Scrollback, true);
|
||||
let registry = ActionRegistry::defaults();
|
||||
let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
|
||||
let outcome = agent.handle_scrollback_key(&j, ®istry);
|
||||
assert!(
|
||||
matches!(outcome, InputOutcome::Action(Action::SelectNext)),
|
||||
"vim-on: bare 'j' in scrollback must dispatch SelectNext; got {outcome:?}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn scrollback_arrow_down_works_in_both_modes() {
|
||||
let registry = ActionRegistry::defaults();
|
||||
let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
|
||||
let mut a_off = make_agent();
|
||||
a_off.vim_mode = false;
|
||||
a_off.set_active_pane(ActivePane::Scrollback, true);
|
||||
assert!(matches!(
|
||||
a_off.handle_scrollback_key(&down, ®istry),
|
||||
InputOutcome::Action(Action::SelectNext)
|
||||
));
|
||||
let mut a_on = make_agent();
|
||||
a_on.vim_mode = true;
|
||||
a_on.set_active_pane(ActivePane::Scrollback, true);
|
||||
assert!(matches!(
|
||||
a_on.handle_scrollback_key(&down, ®istry),
|
||||
InputOutcome::Action(Action::SelectNext)
|
||||
));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,330 @@
|
||||
//! Rewind picker: anchor syncing, dim ranges, and key/mouse handling.
|
||||
use super::AgentView;
|
||||
use crate::app::actions::Action;
|
||||
use crate::app::app_view::InputOutcome;
|
||||
use crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
|
||||
impl AgentView {
|
||||
pub(super) fn sync_rewind_anchor_to_picker(&mut self) {
|
||||
let prompt_index = {
|
||||
let Some(ref rw) = self.rewind_state else {
|
||||
return;
|
||||
};
|
||||
let crate::views::rewind::RewindPhase::Picker {
|
||||
ref points,
|
||||
selected,
|
||||
} = rw.phase
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(point) = points.get(selected) else {
|
||||
return;
|
||||
};
|
||||
point.prompt_index
|
||||
};
|
||||
let entry_idx = crate::app::dispatch::find_user_prompt_entry_for_shell_index(
|
||||
&self.scrollback,
|
||||
prompt_index,
|
||||
);
|
||||
if let Some(ref mut rw) = self.rewind_state {
|
||||
rw.anchor_entry_idx = entry_idx.unwrap_or(0);
|
||||
}
|
||||
if let Some(idx) = entry_idx {
|
||||
self.scrollback.scroll_to_entry_center(idx);
|
||||
}
|
||||
}
|
||||
pub(super) fn rewind_dim_from_entry(&self) -> Option<usize> {
|
||||
let rw = self.rewind_state.as_ref()?;
|
||||
match &rw.phase {
|
||||
crate::views::rewind::RewindPhase::Picker { .. }
|
||||
| crate::views::rewind::RewindPhase::ModeSelect { .. }
|
||||
| crate::views::rewind::RewindPhase::Previewing { .. }
|
||||
| crate::views::rewind::RewindPhase::Confirm { .. }
|
||||
| crate::views::rewind::RewindPhase::ConversationOnlyConfirm { .. }
|
||||
| crate::views::rewind::RewindPhase::Executing { .. } => Some(rw.anchor_entry_idx),
|
||||
crate::views::rewind::RewindPhase::Loading
|
||||
| crate::views::rewind::RewindPhase::CancelOffer { .. }
|
||||
| crate::views::rewind::RewindPhase::Error { .. } => None,
|
||||
}
|
||||
}
|
||||
/// Refresh the scrollback's "awaiting user input" marks so the renderer
|
||||
/// can swap the running-spinner bullet for a pulsing-circle bullet on
|
||||
/// tool entries that are blocked on a permission prompt or
|
||||
/// `ask_user_question`.
|
||||
///
|
||||
/// Recomputed every frame because the queue/question state is fully
|
||||
/// owned by `AgentView` and changes asynchronously; doing a fresh
|
||||
/// clear+rebuild keeps the mark and the view of record from drifting
|
||||
/// out of sync (e.g. on Cancelled requests we never observe a
|
||||
/// matching "pop" event).
|
||||
///
|
||||
/// Cheap: O(entries) for the clear plus O(permission_queue +
|
||||
/// question_view) lookups via the tracker, both tiny in practice.
|
||||
///
|
||||
/// Called once per frame from `AgentView::draw` in the full TUI; minimal
|
||||
/// mode bypasses that draw path, so its commit pass
|
||||
/// ([`crate::minimal::commit::commit_active`]) calls this itself to keep a
|
||||
/// tool blocked on a permission/question out of the committed frontier.
|
||||
pub(crate) fn sync_pending_user_input_marks(&mut self) {
|
||||
self.scrollback.clear_all_pending_user_input();
|
||||
for perm in &self.permission_queue {
|
||||
let tc_id = perm.request.request.tool_call.tool_call_id.0.as_ref();
|
||||
if let Some(entry_id) = self.session.tracker.pending_tool_entry_id(tc_id) {
|
||||
self.scrollback.set_pending_user_input(entry_id, true);
|
||||
}
|
||||
}
|
||||
if let Some(qv) = self.question_view.as_ref()
|
||||
&& let Some(entry_id) = self.session.tracker.pending_tool_entry_id(&qv.tool_call_id)
|
||||
{
|
||||
self.scrollback.set_pending_user_input(entry_id, true);
|
||||
}
|
||||
}
|
||||
pub(super) fn handle_rewind_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
let Some(ref state) = self.rewind_state else {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
let input = crate::views::rewind::handle_rewind_key(state, key);
|
||||
match input {
|
||||
crate::views::rewind::RewindInput::MoveUp => {
|
||||
if let Some(ref mut rw) = self.rewind_state {
|
||||
crate::views::rewind::move_cursor(&mut rw.phase, -1);
|
||||
self.sync_rewind_anchor_to_picker();
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
crate::views::rewind::RewindInput::MoveDown => {
|
||||
if let Some(ref mut rw) = self.rewind_state {
|
||||
crate::views::rewind::move_cursor(&mut rw.phase, 1);
|
||||
self.sync_rewind_anchor_to_picker();
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
crate::views::rewind::RewindInput::ConfirmCursor => {
|
||||
let Some(ref state) = self.rewind_state else {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
let resolved = crate::views::rewind::confirm_cursor(&state.phase);
|
||||
Self::rewind_input_to_outcome(resolved)
|
||||
}
|
||||
other => Self::rewind_input_to_outcome(other),
|
||||
}
|
||||
}
|
||||
/// Map a terminal `RewindInput` (one that doesn't itself move the cursor)
|
||||
/// to the corresponding `InputOutcome`. Shared by the key and mouse paths
|
||||
/// so the two can't drift.
|
||||
fn rewind_input_to_outcome(input: crate::views::rewind::RewindInput) -> InputOutcome {
|
||||
use crate::views::rewind::RewindInput;
|
||||
match input {
|
||||
RewindInput::Dismissed => InputOutcome::Action(Action::RewindDismiss),
|
||||
RewindInput::CancelTurnThenProceed => InputOutcome::Action(Action::RewindCancelOffer),
|
||||
RewindInput::SelectMode(mode, target) => {
|
||||
InputOutcome::Action(Action::RewindSelectMode(mode, target))
|
||||
}
|
||||
RewindInput::Confirm(target, mode) => {
|
||||
InputOutcome::Action(Action::RewindConfirm(target, mode))
|
||||
}
|
||||
RewindInput::BackToModeSelect => InputOutcome::Action(Action::RewindBackToModeSelect),
|
||||
RewindInput::DismissError => InputOutcome::Action(Action::RewindDismissError),
|
||||
RewindInput::ConversationOnlyConfirm(target) => {
|
||||
InputOutcome::Action(Action::RewindConversationOnlyConfirm(target))
|
||||
}
|
||||
RewindInput::PickerSelect(prompt_index) => {
|
||||
InputOutcome::Action(Action::RewindPickerSelect(prompt_index))
|
||||
}
|
||||
RewindInput::MoveUp
|
||||
| RewindInput::MoveDown
|
||||
| RewindInput::ConfirmCursor
|
||||
| RewindInput::Consumed => InputOutcome::Changed,
|
||||
}
|
||||
}
|
||||
/// Mouse handler for the rewind overlay. `Moved` moves the cursor
|
||||
/// (`selected` for picker, `active_idx` for radio phases) and syncs
|
||||
/// the scrollback preview on the picker. `Down(Left)` either
|
||||
/// dispatches a synthesized key (radio) or `PickerSelect` (picker).
|
||||
/// Mouse handler for the rewind overlay. `Moved` moves the cursor
|
||||
/// to the row under the pointer; `Down(Left)` moves the cursor then
|
||||
/// activates that row (Enter-equivalent). Geometry comes from
|
||||
/// `rewind_row_at`, which mirrors `render_rewind_overlay`'s layout.
|
||||
pub(super) fn handle_rewind_mouse(&mut self, mouse: &MouseEvent) -> InputOutcome {
|
||||
use crate::views::rewind::{rewind_activate, rewind_row_at, set_rewind_cursor};
|
||||
let Some(rw) = self.rewind_state.as_mut() else {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
let area = self.pane_areas.prompt;
|
||||
let Some(idx) = rewind_row_at(&rw.phase, area, mouse.column, mouse.row) else {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
match mouse.kind {
|
||||
MouseEventKind::Moved => {
|
||||
if set_rewind_cursor(&mut rw.phase, idx) {
|
||||
InputOutcome::Changed
|
||||
} else {
|
||||
InputOutcome::Unchanged
|
||||
}
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
set_rewind_cursor(&mut rw.phase, idx);
|
||||
let is_picker =
|
||||
matches!(rw.phase, crate::views::rewind::RewindPhase::Picker { .. });
|
||||
let activated = rewind_activate(&rw.phase);
|
||||
if is_picker {
|
||||
self.sync_rewind_anchor_to_picker();
|
||||
}
|
||||
Self::rewind_input_to_outcome(activated)
|
||||
}
|
||||
_ => InputOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod sync_rewind_anchor_to_picker_tests {
|
||||
use super::*;
|
||||
use crate::acp::model_state::ModelState;
|
||||
use crate::app::agent::{AgentId, AgentSession, AgentState};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::UserPromptBlock;
|
||||
use crate::scrollback::state::ScrollbackState;
|
||||
fn make_agent() -> AgentView {
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
AgentView::new(
|
||||
AgentSession {
|
||||
id: AgentId(0),
|
||||
acp_tx: tx,
|
||||
session_id: None,
|
||||
models: ModelState::default(),
|
||||
state: AgentState::Idle,
|
||||
tracker: crate::acp::tracker::AcpUpdateTracker::new(),
|
||||
cwd: std::path::PathBuf::from("/tmp"),
|
||||
is_worktree: false,
|
||||
forked_from: None,
|
||||
pending_prompts: std::collections::VecDeque::new(),
|
||||
next_queue_id: 0,
|
||||
yolo_mode: false,
|
||||
auto_mode: false,
|
||||
prompt_history: Vec::new(),
|
||||
prompt_history_loading: false,
|
||||
loading_replay: false,
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
model_switch_pending: false,
|
||||
user_model_preference: None,
|
||||
deferred_model_switch: None,
|
||||
bg_tasks: std::collections::BTreeMap::new(),
|
||||
bg_tool_call_to_task: std::collections::HashMap::new(),
|
||||
scheduled_tasks: std::collections::HashMap::new(),
|
||||
in_flight_prompt: None,
|
||||
current_prompt_id: None,
|
||||
created_via_new: false,
|
||||
},
|
||||
ScrollbackState::new(),
|
||||
)
|
||||
}
|
||||
fn user_block(text: &str, pi: Option<usize>) -> RenderBlock {
|
||||
let mut b = UserPromptBlock::new(text);
|
||||
b.prompt_index = pi;
|
||||
RenderBlock::UserPrompt(b)
|
||||
}
|
||||
fn run_with_indices(prompt_indices: [Option<usize>; 3]) -> (AgentView, usize, usize, usize) {
|
||||
let mut agent = make_agent();
|
||||
let alpha = agent
|
||||
.scrollback
|
||||
.push_block(user_block("alpha", prompt_indices[0]));
|
||||
agent.scrollback.push_block(RenderBlock::agent_message("a"));
|
||||
let bravo = agent
|
||||
.scrollback
|
||||
.push_block(user_block("bravo", prompt_indices[1]));
|
||||
agent.scrollback.push_block(RenderBlock::agent_message("b"));
|
||||
let charlie = agent
|
||||
.scrollback
|
||||
.push_block(user_block("charlie", prompt_indices[2]));
|
||||
agent.scrollback.push_block(RenderBlock::agent_message("c"));
|
||||
let alpha_idx = agent.scrollback.index_of_id(alpha).unwrap();
|
||||
let bravo_idx = agent.scrollback.index_of_id(bravo).unwrap();
|
||||
let charlie_idx = agent.scrollback.index_of_id(charlie).unwrap();
|
||||
(agent, alpha_idx, bravo_idx, charlie_idx)
|
||||
}
|
||||
fn set_selected(agent: &mut AgentView, sel: usize) {
|
||||
use crate::views::rewind::RewindPhase;
|
||||
if let Some(rw) = agent.rewind_state.as_mut()
|
||||
&& let RewindPhase::Picker { selected, .. } = &mut rw.phase
|
||||
{
|
||||
*selected = sel;
|
||||
}
|
||||
}
|
||||
fn install_picker(agent: &mut AgentView) {
|
||||
use crate::views::rewind::{RewindPhase, RewindPointInfo, RewindState};
|
||||
let pt = |pi: usize, preview: &str| RewindPointInfo {
|
||||
prompt_index: pi,
|
||||
created_at: String::new(),
|
||||
num_file_snapshots: 0,
|
||||
has_file_changes: false,
|
||||
prompt_preview: Some(preview.into()),
|
||||
};
|
||||
let points = vec![pt(2, "charlie"), pt(1, "bravo"), pt(0, "alpha")];
|
||||
agent.rewind_state = Some(RewindState {
|
||||
phase: RewindPhase::Picker {
|
||||
points,
|
||||
selected: 0,
|
||||
},
|
||||
anchor_entry_idx: 0,
|
||||
stashed_draft: None,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
fn anchor_tracks_each_picker_row_when_prompt_index_is_set() {
|
||||
let (mut agent, alpha_idx, bravo_idx, charlie_idx) =
|
||||
run_with_indices([Some(0), Some(1), Some(2)]);
|
||||
install_picker(&mut agent);
|
||||
agent.sync_rewind_anchor_to_picker();
|
||||
assert_eq!(
|
||||
agent.rewind_state.as_ref().unwrap().anchor_entry_idx,
|
||||
charlie_idx,
|
||||
"selected=0 → charlie"
|
||||
);
|
||||
set_selected(&mut agent, 1);
|
||||
agent.sync_rewind_anchor_to_picker();
|
||||
assert_eq!(
|
||||
agent.rewind_state.as_ref().unwrap().anchor_entry_idx,
|
||||
bravo_idx,
|
||||
"selected=1 → bravo"
|
||||
);
|
||||
set_selected(&mut agent, 2);
|
||||
agent.sync_rewind_anchor_to_picker();
|
||||
assert_eq!(
|
||||
agent.rewind_state.as_ref().unwrap().anchor_entry_idx,
|
||||
alpha_idx,
|
||||
"selected=2 → alpha"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn anchor_tracks_each_picker_row_when_prompt_index_is_missing() {
|
||||
let (mut agent, alpha_idx, bravo_idx, charlie_idx) = run_with_indices([None, None, None]);
|
||||
install_picker(&mut agent);
|
||||
agent.sync_rewind_anchor_to_picker();
|
||||
assert_eq!(
|
||||
agent.rewind_state.as_ref().unwrap().anchor_entry_idx,
|
||||
charlie_idx,
|
||||
"fallback: selected=0 → charlie"
|
||||
);
|
||||
set_selected(&mut agent, 1);
|
||||
agent.sync_rewind_anchor_to_picker();
|
||||
assert_eq!(
|
||||
agent.rewind_state.as_ref().unwrap().anchor_entry_idx,
|
||||
bravo_idx,
|
||||
"fallback: selected=1 → bravo (regression: was alpha before fix)"
|
||||
);
|
||||
set_selected(&mut agent, 2);
|
||||
agent.sync_rewind_anchor_to_picker();
|
||||
assert_eq!(
|
||||
agent.rewind_state.as_ref().unwrap().anchor_entry_idx,
|
||||
alpha_idx,
|
||||
"fallback: selected=2 → alpha"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,923 @@
|
||||
//! Bash-mode shell completion: the always-on Tab surface (deterministic
|
||||
//! fetch arming, terminal Tab semantics execution) and the dropdown accept
|
||||
//! path shared by Tab/Enter/mouse.
|
||||
|
||||
#[cfg(test)]
|
||||
use super::test_fixtures;
|
||||
use super::{AgentView, PromptInputMode};
|
||||
use crate::views::suggestion_controller::TabAction;
|
||||
|
||||
impl AgentView {
|
||||
/// Accept the selected completion-dropdown item into the prompt (the
|
||||
/// what-to-write policy lives in `CompletionSplice`). Returns whether
|
||||
/// the key was consumed; `false` only for the empty-items race (callers
|
||||
/// keep their close-and-fall-through arm).
|
||||
pub(in crate::app) fn accept_completion_dropdown_item(&mut self) -> bool {
|
||||
let had_items = !self.prompt.suggestions.dropdown.items.is_empty();
|
||||
// The SELECTED splice would clip an atomic element (paste chip):
|
||||
// committing would consume the candidates and then be declined by
|
||||
// the write path — honest no-op instead (nothing safe to write;
|
||||
// the dropdown stays up so another selection can still accept).
|
||||
if self.prompt.completion_accept_would_clip_element() {
|
||||
return true;
|
||||
}
|
||||
let Some(splice) = self.prompt.completion_dropdown_accept() else {
|
||||
// Stale-generation refusal closed the dropdown; swallow the key
|
||||
// (the refreshed fetch is in flight) instead of falling through
|
||||
// to focus-cycling or send.
|
||||
return had_items;
|
||||
};
|
||||
if self.prompt.apply_completion_splice(splice) {
|
||||
self.prompt_input_mode = PromptInputMode::Bash;
|
||||
// Re-fetch for the accepted text so accepting a directory
|
||||
// (trailing `/`) lets the NEXT Tab complete inside it.
|
||||
self.kick_shell_suggest_refetch();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Terminal-like Tab over a closed dropdown's completion items: decide
|
||||
/// via `SuggestionController::tab_decision`, then execute. Used by the
|
||||
/// pending-Tab landing (where `Nothing` — stale/empty items — must do
|
||||
/// nothing rather than fetch again).
|
||||
pub(in crate::app) fn shell_completion_tab(&mut self) {
|
||||
let action = self
|
||||
.prompt
|
||||
.suggestions
|
||||
.tab_decision(self.prompt.text(), self.prompt.cursor());
|
||||
self.execute_tab_action(action);
|
||||
}
|
||||
|
||||
/// View-side executor for a [`TabAction`] (the policy lives in the
|
||||
/// controller's `tab_decision`).
|
||||
pub(super) fn execute_tab_action(&mut self, action: TabAction) {
|
||||
match action {
|
||||
TabAction::InstaAccept => {
|
||||
// A splice clipping an atomic element (paste chip) would be
|
||||
// declined AFTER the accept consumed the sole candidate —
|
||||
// every Tab would then refetch the same set. Show it instead.
|
||||
if self.prompt.completion_accept_would_clip_element() {
|
||||
self.prompt.completion_dropdown_open_if_available();
|
||||
} else {
|
||||
self.accept_completion_dropdown_item();
|
||||
}
|
||||
}
|
||||
TabAction::Fill(range, fill) => {
|
||||
if self.prompt.apply_completion_fill(range, &fill) {
|
||||
// A fill is typing: refresh the candidate set for the longer
|
||||
// token (the next Tab opens the dropdown on the refreshed set).
|
||||
self.kick_shell_suggest_refetch();
|
||||
} else {
|
||||
// Declined (range clips an atomic element): show the
|
||||
// candidates instead of respinning fill+refetch every Tab.
|
||||
self.prompt.completion_dropdown_open_if_available();
|
||||
}
|
||||
}
|
||||
TabAction::Open => {
|
||||
self.prompt.completion_dropdown_open_if_available();
|
||||
}
|
||||
TabAction::Nothing => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire a deterministic (`includeAi: false`) completion fetch for the
|
||||
/// current draft, bypassing the env-gated as-you-type debounce — the
|
||||
/// always-on Tab path. `run_tab_on_load` makes the landing response run
|
||||
/// the terminal Tab semantics once (a Tab that found no usable items
|
||||
/// still completes when its candidates arrive).
|
||||
pub(super) fn request_shell_tab_completion(&mut self, run_tab_on_load: bool) {
|
||||
// Repeat Tab while the armed fetch is still in flight: keep the
|
||||
// marker (its landing runs the Tab semantics) — no second RPC.
|
||||
if run_tab_on_load && self.prompt.suggestions.tab_fetch_pending() {
|
||||
return;
|
||||
}
|
||||
let generation = self
|
||||
.prompt
|
||||
.suggestions
|
||||
.begin_tab_completion(run_tab_on_load);
|
||||
self.pending_effects
|
||||
.push(super::actions::Effect::FetchShellSuggestions {
|
||||
agent_id: self.session.id,
|
||||
text: self.prompt.text().to_owned(),
|
||||
cursor: self.prompt.cursor(),
|
||||
cwd: self.session.cwd.to_string_lossy().into_owned(),
|
||||
generation,
|
||||
limit: crate::views::suggestion_controller::SHELL_SUGGEST_WIRE_LIMIT,
|
||||
include_ai: false,
|
||||
ai_model: None,
|
||||
session_id: self.session.session_id.as_ref().map(|s| s.0.to_string()),
|
||||
// Deterministic Tab surface: token providers only (a
|
||||
// history row would make the set mixed and kill
|
||||
// insta-accept/LCP).
|
||||
token_only: true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Refresh the candidate set after an accept or a prefix fill changed
|
||||
/// the draft: through the debounced as-you-type pipeline when enabled,
|
||||
/// else a direct deterministic fetch. Either way the refreshed items
|
||||
/// land silently and the NEXT Tab consumes them.
|
||||
fn kick_shell_suggest_refetch(&mut self) {
|
||||
if self.prompt.suggestions.enabled {
|
||||
if let Some(eff) = self.notify_suggestion_text_changed() {
|
||||
self.pending_effects.push(eff);
|
||||
}
|
||||
} else {
|
||||
self.request_shell_tab_completion(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shell_suggestion_key_tests {
|
||||
use super::*;
|
||||
use crate::app::actions::{Action, Effect};
|
||||
use crate::app::app_view::InputOutcome;
|
||||
use crate::views::suggestion_controller::{CompletionItemParsed, SuggestionSource};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
/// Wire-shaped token item: `insert_text` is the compat whole line,
|
||||
/// `token_text` the span replacement (what a new shell sends).
|
||||
fn token_item(line: &str, token: &str, range: std::ops::Range<usize>) -> CompletionItemParsed {
|
||||
CompletionItemParsed {
|
||||
display: token.to_owned(),
|
||||
description: String::new(),
|
||||
insert_text: line.to_owned(),
|
||||
source: SuggestionSource::PathExecutable,
|
||||
priority: 0,
|
||||
replace_range: Some(range),
|
||||
token_text: Some(token.to_owned()),
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn item(insert: &str, range: Option<std::ops::Range<usize>>) -> CompletionItemParsed {
|
||||
CompletionItemParsed {
|
||||
display: insert.to_owned(),
|
||||
description: String::new(),
|
||||
insert_text: insert.to_owned(),
|
||||
source: SuggestionSource::PathExecutable,
|
||||
priority: 0,
|
||||
replace_range: range,
|
||||
token_text: None,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire-shaped FILE token item (what the file provider sends).
|
||||
fn file_item(line: &str, token: &str, range: std::ops::Range<usize>) -> CompletionItemParsed {
|
||||
CompletionItemParsed {
|
||||
display: token.to_owned(),
|
||||
description: String::new(),
|
||||
insert_text: line.to_owned(),
|
||||
source: SuggestionSource::FilePath,
|
||||
priority: 0,
|
||||
replace_range: Some(range),
|
||||
token_text: Some(token.to_owned()),
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole-line history item (insert_text doubles as the span replacement).
|
||||
fn history_item(line: &str, range: std::ops::Range<usize>) -> CompletionItemParsed {
|
||||
CompletionItemParsed {
|
||||
display: line.to_owned(),
|
||||
description: String::new(),
|
||||
insert_text: line.to_owned(),
|
||||
source: SuggestionSource::History,
|
||||
priority: 10,
|
||||
replace_range: Some(range),
|
||||
token_text: None,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bash-mode agent with the env-gated as-you-type pipeline ON and
|
||||
/// `text` typed (the dropdown's request-text anchor pinned to it — the
|
||||
/// state right after a suggest response landed for the draft).
|
||||
fn bash_agent(text: &str) -> AgentView {
|
||||
let mut agent = bash_agent_always_on(text);
|
||||
agent.prompt.suggestions.enabled = true;
|
||||
agent
|
||||
}
|
||||
|
||||
/// Same, with the pipeline OFF (`KIGI_SUGGESTIONS` unset) — the
|
||||
/// always-on Tab surface under test.
|
||||
fn bash_agent_always_on(text: &str) -> AgentView {
|
||||
let mut agent = super::test_fixtures::make_agent();
|
||||
agent.prompt_input_mode = PromptInputMode::Bash;
|
||||
agent.prompt.suggestions.enabled = false;
|
||||
agent.prompt.textarea.insert_str(text);
|
||||
agent.prompt.suggestions.dropdown.request_text = text.to_owned();
|
||||
agent.prompt.suggestions.dropdown.request_cursor = text.len();
|
||||
agent
|
||||
}
|
||||
|
||||
/// THE acceptance regression: accepting a $PATH item after `ls | gr`
|
||||
/// edits the token in place — never replaces the whole line with `grep`.
|
||||
#[test]
|
||||
fn dropdown_tab_accept_replaces_token_in_place() {
|
||||
let mut agent = bash_agent("ls | gr");
|
||||
agent.prompt.suggestions.dropdown.open = true;
|
||||
agent.prompt.suggestions.dropdown.items = vec![token_item("ls | grep", "grep", 5..7)];
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(agent.prompt.text(), "ls | grep");
|
||||
assert_eq!(agent.prompt.cursor(), "ls | grep".len());
|
||||
assert_eq!(agent.prompt_input_mode, PromptInputMode::Bash);
|
||||
assert!(!agent.prompt.completion_dropdown_open());
|
||||
}
|
||||
|
||||
/// Enter accepts the same way (both arms share the accept helper).
|
||||
#[test]
|
||||
fn dropdown_enter_accept_replaces_token_in_place() {
|
||||
let mut agent = bash_agent("ls | gr");
|
||||
agent.prompt.suggestions.dropdown.open = true;
|
||||
agent.prompt.suggestions.dropdown.items = vec![token_item("ls | grep", "grep", 5..7)];
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Enter));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(agent.prompt.text(), "ls | grep");
|
||||
assert_eq!(agent.prompt_input_mode, PromptInputMode::Bash);
|
||||
}
|
||||
|
||||
/// The accept works identically with the as-you-type pipeline OFF —
|
||||
/// in-place acceptance is not env-gated.
|
||||
#[test]
|
||||
fn dropdown_accept_works_without_env_flag() {
|
||||
let mut agent = bash_agent_always_on("ls | gr");
|
||||
agent.prompt.suggestions.dropdown.open = true;
|
||||
agent.prompt.suggestions.dropdown.items = vec![token_item("ls | grep", "grep", 5..7)];
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(agent.prompt.text(), "ls | grep");
|
||||
}
|
||||
|
||||
/// A ranged item whose range no longer fits the draft is a NO-OP accept
|
||||
/// — the draft survives untouched, the dropdown closes, the key is
|
||||
/// consumed (never a whole-line clobber, never a send).
|
||||
#[test]
|
||||
fn dropdown_accept_stale_range_is_a_draft_preserving_noop() {
|
||||
let mut agent = bash_agent("ls | gr");
|
||||
agent.prompt.set_text("totally different");
|
||||
agent.prompt.suggestions.dropdown.open = true;
|
||||
agent.prompt.suggestions.dropdown.items = vec![token_item("ls | grep", "grep", 5..7)];
|
||||
// Pass the generation gate (`set_text` bumped it) so this pins the
|
||||
// range-validation no-op, not the staleness gate. The "ls | gr"
|
||||
// anchor from `bash_agent` survives the swap (close() keeps it).
|
||||
agent.prompt.suggestions.dropdown.generation = agent.prompt.suggestions.generation();
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(agent.prompt.text(), "totally different");
|
||||
assert!(!agent.prompt.completion_dropdown_open());
|
||||
}
|
||||
|
||||
/// Items populated for a superseded generation refuse the accept
|
||||
/// wholesale: dropdown closes, draft untouched, Enter does not fall
|
||||
/// through to send.
|
||||
#[test]
|
||||
fn dropdown_accept_stale_generation_is_a_noop() {
|
||||
let mut agent = bash_agent("ls | gr");
|
||||
agent.prompt.suggestions.dropdown.open = true;
|
||||
agent.prompt.suggestions.dropdown.items = vec![token_item("ls | grep", "grep", 5..7)];
|
||||
// A newer edit bumped the controller past the items' generation.
|
||||
agent.prompt.suggestions.dropdown.generation = 3;
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Enter));
|
||||
assert!(
|
||||
matches!(outcome, InputOutcome::Changed),
|
||||
"stale accept must consume the key, got {outcome:?}"
|
||||
);
|
||||
assert_eq!(agent.prompt.text(), "ls | gr");
|
||||
assert!(!agent.prompt.completion_dropdown_open());
|
||||
}
|
||||
|
||||
/// Rangeless items (older shells) keep the whole-line behavior.
|
||||
#[test]
|
||||
fn dropdown_accept_without_range_sets_whole_line() {
|
||||
let mut agent = bash_agent("git st");
|
||||
agent.prompt.suggestions.dropdown.open = true;
|
||||
agent.prompt.suggestions.dropdown.items = vec![item("git status --porcelain", None)];
|
||||
|
||||
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert_eq!(agent.prompt.text(), "git status --porcelain");
|
||||
assert_eq!(agent.prompt.cursor(), agent.prompt.text().len());
|
||||
}
|
||||
|
||||
/// Tab opens the dropdown whenever items exist — a ghost is NOT required
|
||||
/// (pure path/file completions never carry one). Two candidates with no
|
||||
/// shared prefix beyond the typed token = the plain-open path (a single
|
||||
/// candidate insta-accepts instead — see the terminal-Tab tests below).
|
||||
#[test]
|
||||
fn tab_opens_dropdown_without_ghost() {
|
||||
let mut agent = bash_agent("ls | gr");
|
||||
agent.prompt.suggestions.dropdown.items =
|
||||
vec![item("grep", Some(5..7)), item("grip", Some(5..7))];
|
||||
assert!(!agent.prompt.has_ghost_text());
|
||||
assert!(!agent.prompt.completion_dropdown_open());
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert!(agent.prompt.completion_dropdown_open());
|
||||
assert_eq!(
|
||||
agent.prompt.text(),
|
||||
"ls | gr",
|
||||
"no fill without a longer LCP"
|
||||
);
|
||||
}
|
||||
|
||||
// -- always-on Tab fetch (no KIGI_SUGGESTIONS) --------------------------
|
||||
|
||||
/// Tab in bash mode with no fetched candidates fires a deterministic
|
||||
/// fetch — no env flag, no AI, dropdown-scale limit.
|
||||
#[test]
|
||||
fn tab_without_items_fires_deterministic_fetch() {
|
||||
let mut agent = bash_agent_always_on("cat no");
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
|
||||
let fetch = agent.pending_effects.iter().find_map(|e| match e {
|
||||
Effect::FetchShellSuggestions {
|
||||
include_ai,
|
||||
generation,
|
||||
limit,
|
||||
text,
|
||||
token_only,
|
||||
..
|
||||
} => Some((*include_ai, *generation, *limit, text.clone(), *token_only)),
|
||||
_ => None,
|
||||
});
|
||||
let (include_ai, generation, limit, text, token_only) =
|
||||
fetch.expect("Tab must fire a fetch");
|
||||
assert!(!include_ai, "Tab completion is deterministic (no AI)");
|
||||
assert!(token_only, "Tab fetches run only the token providers");
|
||||
assert_eq!(limit, 50);
|
||||
assert_eq!(text, "cat no");
|
||||
assert_eq!(generation, agent.prompt.suggestions.generation());
|
||||
}
|
||||
|
||||
/// Repeat Tab while the armed fetch is still in flight is a no-op: one
|
||||
/// RPC, one landing that runs the Tab semantics once.
|
||||
#[test]
|
||||
fn repeat_tab_fires_single_fetch_while_pending() {
|
||||
let mut agent = bash_agent_always_on("cat no");
|
||||
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
|
||||
let fetches = agent
|
||||
.pending_effects
|
||||
.iter()
|
||||
.filter(|e| matches!(e, Effect::FetchShellSuggestions { .. }))
|
||||
.count();
|
||||
assert_eq!(fetches, 1, "the second Tab must not fire a second RPC");
|
||||
assert!(
|
||||
agent.prompt.suggestions.tab_fetch_pending(),
|
||||
"the pending-Tab marker survives the repeat press"
|
||||
);
|
||||
}
|
||||
|
||||
/// Items outdated by an edit (stale generation) refetch instead of
|
||||
/// completing over the old candidate set.
|
||||
#[test]
|
||||
fn tab_with_stale_items_refetches() {
|
||||
let mut agent = bash_agent_always_on("cat no");
|
||||
agent.prompt.suggestions.dropdown.items = vec![file_item("cat notes.md", "notes.md", 4..6)];
|
||||
agent.prompt.suggestions.dropdown.generation = 7;
|
||||
|
||||
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert_eq!(agent.prompt.text(), "cat no", "no accept from stale items");
|
||||
assert!(
|
||||
agent
|
||||
.pending_effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::FetchShellSuggestions { .. })),
|
||||
"stale items must refetch"
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty bash draft has no token to complete: Tab keeps its
|
||||
/// focus-cycling fallthrough.
|
||||
#[test]
|
||||
fn tab_on_empty_bash_draft_falls_through_to_focus_scrollback() {
|
||||
let mut agent = bash_agent_always_on("");
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
InputOutcome::Action(Action::FocusScrollback)
|
||||
));
|
||||
assert!(agent.pending_effects.is_empty());
|
||||
}
|
||||
|
||||
/// The normal (chat) prompt keeps its Tab behavior: no fetch, no
|
||||
/// completion — the surface is bash-mode-only.
|
||||
#[test]
|
||||
fn tab_in_normal_mode_does_not_fetch() {
|
||||
let mut agent = super::test_fixtures::make_agent();
|
||||
agent.prompt.textarea.insert_str("cat no");
|
||||
|
||||
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(
|
||||
!agent
|
||||
.pending_effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::FetchShellSuggestions { .. })),
|
||||
"normal-mode Tab must not fetch completions"
|
||||
);
|
||||
}
|
||||
|
||||
// -- terminal-like Tab (single-candidate accept / common-prefix fill) --
|
||||
|
||||
/// Exactly one token candidate: Tab accepts it immediately — no
|
||||
/// dropdown flash — and the accept re-fetch keeps the pipeline alive.
|
||||
#[test]
|
||||
fn tab_single_token_candidate_accepts_without_dropdown_flash() {
|
||||
let mut agent = bash_agent("cat no");
|
||||
agent.prompt.suggestions.dropdown.items = vec![file_item("cat notes.md", "notes.md", 4..6)];
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(agent.prompt.text(), "cat notes.md");
|
||||
assert_eq!(agent.prompt.cursor(), "cat notes.md".len());
|
||||
assert!(!agent.prompt.completion_dropdown_open());
|
||||
}
|
||||
|
||||
/// The same insta-accept with the pipeline OFF: the refetch kick is a
|
||||
/// direct deterministic fetch instead of a debounce.
|
||||
#[test]
|
||||
fn tab_single_candidate_accepts_and_kicks_fetch_always_on() {
|
||||
let mut agent = bash_agent_always_on("cat no");
|
||||
agent.prompt.suggestions.dropdown.items = vec![file_item("cat notes.md", "notes.md", 4..6)];
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(agent.prompt.text(), "cat notes.md");
|
||||
assert!(
|
||||
agent.pending_effects.iter().any(|e| matches!(
|
||||
e,
|
||||
Effect::FetchShellSuggestions {
|
||||
include_ai: false,
|
||||
..
|
||||
}
|
||||
)),
|
||||
"accept must kick a deterministic refetch"
|
||||
);
|
||||
}
|
||||
|
||||
/// A single HISTORY item keeps the plain dropdown-open behavior:
|
||||
/// terminal Tab semantics apply to token completions only.
|
||||
#[test]
|
||||
fn tab_single_history_item_opens_dropdown() {
|
||||
let mut agent = bash_agent("git st");
|
||||
agent.prompt.suggestions.dropdown.items =
|
||||
vec![history_item("git status --porcelain", 0..6)];
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert!(agent.prompt.completion_dropdown_open());
|
||||
assert_eq!(agent.prompt.text(), "git st");
|
||||
}
|
||||
|
||||
/// THE legacy-shell compatibility case: a rangeless `path` row (old
|
||||
/// shells send `insertText: "grep"`, no range) must never insta-accept
|
||||
/// — its whole-line fallback would replace `ls | gr` with `grep`. Tab
|
||||
/// plain-opens instead, sole match or not.
|
||||
#[test]
|
||||
fn tab_sole_rangeless_path_row_opens_dropdown_never_accepts() {
|
||||
let mut agent = bash_agent("ls | gr");
|
||||
agent.prompt.suggestions.dropdown.items = vec![item("grep", None)];
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(agent.prompt.text(), "ls | gr", "draft must survive");
|
||||
assert!(agent.prompt.completion_dropdown_open());
|
||||
}
|
||||
|
||||
/// Any rangeless row in a MIXED set (legacy PATH row next to a ranged
|
||||
/// file row) forces plain-open too — no insta-accept, no fill.
|
||||
#[test]
|
||||
fn tab_mixed_rangeless_and_ranged_rows_open_dropdown() {
|
||||
let mut agent = bash_agent("ls | gr");
|
||||
agent.prompt.suggestions.dropdown.items = vec![
|
||||
item("grep", None),
|
||||
file_item("ls | grokfile", "grokfile", 5..7),
|
||||
];
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert!(agent.prompt.completion_dropdown_open());
|
||||
assert_eq!(agent.prompt.text(), "ls | gr", "no accept, no fill");
|
||||
}
|
||||
|
||||
/// A MIXED set (any non-token item alongside file/path rows) disables
|
||||
/// terminal-Tab semantics wholesale: no insta-accept, no fill — Tab
|
||||
/// plain-opens so the user sees every candidate, history included.
|
||||
#[test]
|
||||
fn tab_mixed_file_and_history_items_opens_dropdown() {
|
||||
let mut agent = bash_agent("cat no");
|
||||
agent.prompt.suggestions.dropdown.items = vec![
|
||||
history_item("cat notes.md --verbose", 0..6),
|
||||
file_item("cat notes.md", "notes.md", 4..6),
|
||||
];
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert!(agent.prompt.completion_dropdown_open());
|
||||
assert_eq!(agent.prompt.text(), "cat no", "no accept, no fill");
|
||||
}
|
||||
|
||||
/// Whole-line history sets never prefix-fill (half a history line is
|
||||
/// not a command) — Tab plain-opens.
|
||||
#[test]
|
||||
fn tab_whole_line_history_items_open_dropdown_not_fill() {
|
||||
let mut agent = bash_agent("git st");
|
||||
agent.prompt.suggestions.dropdown.items = vec![
|
||||
history_item("git status --porcelain-A", 0..6),
|
||||
history_item("git status --porcelain-B", 0..6),
|
||||
];
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert!(agent.prompt.completion_dropdown_open());
|
||||
assert_eq!(agent.prompt.text(), "git st");
|
||||
}
|
||||
|
||||
/// Multiple candidates sharing a prefix longer than the typed token:
|
||||
/// the first Tab fills the common prefix in place (no dropdown) and
|
||||
/// re-fetches; when the refreshed items land, the second Tab opens the
|
||||
/// dropdown.
|
||||
#[test]
|
||||
fn tab_fills_common_prefix_then_opens_dropdown_on_refresh() {
|
||||
let mut agent = bash_agent("cat al");
|
||||
agent.prompt.suggestions.dropdown.items = vec![
|
||||
file_item("cat alpha_one.txt", "alpha_one.txt", 4..6),
|
||||
file_item("cat alpha_two.txt", "alpha_two.txt", 4..6),
|
||||
];
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(agent.prompt.text(), "cat alpha_");
|
||||
assert_eq!(agent.prompt.cursor(), "cat alpha_".len());
|
||||
assert!(
|
||||
!agent.prompt.completion_dropdown_open(),
|
||||
"first Tab fills; the dropdown waits for the second"
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.pending_effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::DebounceSuggestions { .. })),
|
||||
"the fill re-fetches candidates for the longer prefix"
|
||||
);
|
||||
|
||||
// The refreshed response lands for the filled text…
|
||||
let generation = agent.prompt.suggestions.generation();
|
||||
agent.prompt.suggestions.on_suggestions_loaded(
|
||||
crate::views::suggestion_controller::SuggestResponseParsed {
|
||||
ghost: None,
|
||||
completions: vec![
|
||||
file_item("cat alpha_one.txt", "alpha_one.txt", 4..10),
|
||||
file_item("cat alpha_two.txt", "alpha_two.txt", 4..10),
|
||||
],
|
||||
generation,
|
||||
},
|
||||
"cat alpha_",
|
||||
"cat alpha_".len(),
|
||||
);
|
||||
|
||||
// …and the second Tab opens the dropdown (LCP no longer extends).
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert!(agent.prompt.completion_dropdown_open());
|
||||
assert_eq!(agent.prompt.text(), "cat alpha_");
|
||||
}
|
||||
|
||||
/// The fill's refetch with the pipeline OFF is a direct deterministic
|
||||
/// fetch (no debounce to ride on).
|
||||
#[test]
|
||||
fn tab_fill_kicks_deterministic_fetch_always_on() {
|
||||
let mut agent = bash_agent_always_on("cat al");
|
||||
agent.prompt.suggestions.dropdown.items = vec![
|
||||
file_item("cat alpha_one.txt", "alpha_one.txt", 4..6),
|
||||
file_item("cat alpha_two.txt", "alpha_two.txt", 4..6),
|
||||
];
|
||||
|
||||
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert_eq!(agent.prompt.text(), "cat alpha_");
|
||||
assert!(
|
||||
agent.pending_effects.iter().any(|e| matches!(
|
||||
e,
|
||||
Effect::FetchShellSuggestions {
|
||||
include_ai: false,
|
||||
..
|
||||
}
|
||||
)),
|
||||
"fill must kick a deterministic refetch"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bash-mode agent whose draft is a paste CHIP (atomic element), with
|
||||
/// the dropdown anchor pinned to it — the state a landing would leave
|
||||
/// when the shell's token range points into the chip's raw text.
|
||||
fn chip_agent(items: Vec<CompletionItemParsed>) -> (AgentView, String) {
|
||||
let mut agent = super::test_fixtures::make_agent();
|
||||
agent.prompt_input_mode = PromptInputMode::Bash;
|
||||
agent.prompt.suggestions.enabled = false;
|
||||
agent
|
||||
.prompt
|
||||
.handle_paste("line one\nline two\nline three\nline four");
|
||||
let text = agent.prompt.text().to_owned();
|
||||
agent.prompt.suggestions.dropdown.request_text = text.clone();
|
||||
agent.prompt.suggestions.dropdown.request_cursor = agent.prompt.cursor();
|
||||
agent.prompt.suggestions.dropdown.items = items;
|
||||
(agent, text)
|
||||
}
|
||||
|
||||
fn suggest_fetch_count(agent: &AgentView) -> usize {
|
||||
agent
|
||||
.pending_effects
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
Effect::FetchShellSuggestions { .. } | Effect::DebounceSuggestions { .. }
|
||||
)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
/// BugBot: a Fill whose range clips a paste chip used to no-op the
|
||||
/// write and STILL kick a refetch — every Tab spun fill+refetch with no
|
||||
/// draft change. The declined fill now degrades to opening the
|
||||
/// dropdown: candidates visible, nothing fetched, chip intact, and the
|
||||
/// second Tab rides the normal open-dropdown handling.
|
||||
#[test]
|
||||
fn tab_fill_clipping_paste_chip_opens_dropdown_without_refetch() {
|
||||
// Two candidates whose shared range (chip bytes 0..2, "li") fills
|
||||
// to "lima_" — a valid Fill decision over an unwritable span.
|
||||
let (mut agent, text) = chip_agent(vec![
|
||||
file_item("lima_one.txt", "lima_one.txt", 0..2),
|
||||
file_item("lima_two.txt", "lima_two.txt", 0..2),
|
||||
]);
|
||||
let gen_before = agent.prompt.suggestions.generation();
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(agent.prompt.text(), text, "chip must survive the fill");
|
||||
assert!(agent.prompt.completion_dropdown_open());
|
||||
assert_eq!(
|
||||
agent.prompt.suggestions.generation(),
|
||||
gen_before,
|
||||
"a declined fill must not invalidate anything"
|
||||
);
|
||||
assert_eq!(suggest_fetch_count(&agent), 0, "no refetch kick");
|
||||
|
||||
// Second Tab goes through the open dropdown (accept path), never
|
||||
// the fetch arm — no spin.
|
||||
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert_eq!(agent.prompt.text(), text);
|
||||
assert_eq!(suggest_fetch_count(&agent), 0);
|
||||
}
|
||||
|
||||
/// Same hole on the insta-accept arm: committing would consume the
|
||||
/// sole candidate and THEN decline the splice, leaving every Tab to
|
||||
/// refetch the same set. The probe degrades to showing the candidate.
|
||||
#[test]
|
||||
fn tab_insta_accept_clipping_paste_chip_opens_dropdown_without_refetch() {
|
||||
let (mut agent, text) = chip_agent(vec![file_item("lima_one.txt", "lima_one.txt", 0..2)]);
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(agent.prompt.text(), text, "chip must survive");
|
||||
assert!(agent.prompt.completion_dropdown_open());
|
||||
assert_eq!(
|
||||
agent.prompt.suggestions.dropdown.items.len(),
|
||||
1,
|
||||
"the candidate must not be consumed"
|
||||
);
|
||||
assert_eq!(suggest_fetch_count(&agent), 0, "no refetch kick");
|
||||
}
|
||||
|
||||
/// BugBot sibling hole: the OPEN-dropdown accept (Tab/Enter/mouse all
|
||||
/// share the helper) used to consume the candidates and close before
|
||||
/// the write path declined the chip-clipping splice — leaving nothing.
|
||||
/// The probe now makes it an honest no-op: nothing consumed, dropdown
|
||||
/// up, chip/draft/generation untouched, no kick — and Enter must not
|
||||
/// fall through to send.
|
||||
#[test]
|
||||
fn dropdown_accept_clipping_paste_chip_keeps_candidates() {
|
||||
let (mut agent, text) = chip_agent(vec![
|
||||
file_item("lima_one.txt", "lima_one.txt", 0..2),
|
||||
file_item("lima_two.txt", "lima_two.txt", 0..2),
|
||||
]);
|
||||
agent.prompt.suggestions.dropdown.open = true;
|
||||
let gen_before = agent.prompt.suggestions.generation();
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Enter));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(agent.prompt.text(), text, "chip must survive");
|
||||
assert!(
|
||||
agent.prompt.completion_dropdown_open(),
|
||||
"candidates stay up"
|
||||
);
|
||||
assert_eq!(
|
||||
agent.prompt.suggestions.dropdown.items.len(),
|
||||
2,
|
||||
"nothing consumed"
|
||||
);
|
||||
assert_eq!(agent.prompt.suggestions.generation(), gen_before);
|
||||
assert_eq!(suggest_fetch_count(&agent), 0, "no refetch kick");
|
||||
|
||||
// Tab rides the same helper.
|
||||
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert_eq!(agent.prompt.suggestions.dropdown.items.len(), 2);
|
||||
assert_eq!(agent.prompt.text(), text);
|
||||
assert_eq!(suggest_fetch_count(&agent), 0);
|
||||
}
|
||||
|
||||
/// The probe peeks the SELECTED item: with a chip-clipping row next to
|
||||
/// a plain-text row, acceptance follows the selection — no-op on the
|
||||
/// clipping one, normal accept after Down moves to the safe one.
|
||||
#[test]
|
||||
fn dropdown_accept_respects_selection_over_mixed_clip_ranges() {
|
||||
let (mut agent, _) = chip_agent(vec![]);
|
||||
agent.prompt.textarea.insert_str(" li");
|
||||
let text = agent.prompt.text().to_owned();
|
||||
agent.prompt.suggestions.dropdown.request_text = text.clone();
|
||||
agent.prompt.suggestions.dropdown.request_cursor = agent.prompt.cursor();
|
||||
let tok = text.len() - 2;
|
||||
agent.prompt.suggestions.dropdown.items = vec![
|
||||
file_item("lima_one.txt", "lima_one.txt", 0..2),
|
||||
file_item("lima_two.txt", "lima_two.txt", tok..text.len()),
|
||||
];
|
||||
agent.prompt.suggestions.dropdown.open = true;
|
||||
|
||||
// Selected = the chip-clipping row: honest no-op.
|
||||
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Enter));
|
||||
assert_eq!(agent.prompt.suggestions.dropdown.items.len(), 2);
|
||||
assert_eq!(agent.prompt.text(), text);
|
||||
|
||||
// Down selects the plain-text row: accepts normally.
|
||||
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Down));
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert!(
|
||||
agent.prompt.text().ends_with(" lima_two.txt"),
|
||||
"safe selection must splice: {}",
|
||||
agent.prompt.text()
|
||||
);
|
||||
assert!(!agent.prompt.completion_dropdown_open());
|
||||
}
|
||||
|
||||
/// Accepting a directory completion (trailing `/`) must re-fetch so the
|
||||
/// NEXT Tab completes inside it — drill-down chaining.
|
||||
#[test]
|
||||
fn dir_accept_kicks_refetch_for_drill_down() {
|
||||
let mut agent = bash_agent("cat no");
|
||||
agent.prompt.suggestions.dropdown.open = true;
|
||||
agent.prompt.suggestions.dropdown.items =
|
||||
vec![file_item("cat Notes\\ Archive/", "Notes\\ Archive/", 4..6)];
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(agent.prompt.text(), "cat Notes\\ Archive/");
|
||||
assert!(
|
||||
agent
|
||||
.pending_effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::DebounceSuggestions { .. })),
|
||||
"dir accept must kick a fresh fetch for the drill-down"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Bash-mode gating of the as-you-type pipeline ------------------------
|
||||
|
||||
/// Typing in the normal (chat) prompt never fires the suggest pipeline;
|
||||
/// the same keystroke in bash mode debounces a request.
|
||||
#[test]
|
||||
fn pipeline_fires_only_in_bash_mode() {
|
||||
let mut agent = super::test_fixtures::make_agent();
|
||||
agent.prompt.suggestions.enabled = true;
|
||||
|
||||
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Char('g')));
|
||||
assert!(
|
||||
!agent
|
||||
.pending_effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::DebounceSuggestions { .. })),
|
||||
"normal-mode typing must not reach the suggest pipeline"
|
||||
);
|
||||
|
||||
let mut agent = super::test_fixtures::make_agent();
|
||||
agent.prompt.suggestions.enabled = true;
|
||||
agent.prompt_input_mode = PromptInputMode::Bash;
|
||||
|
||||
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Char('g')));
|
||||
assert!(
|
||||
agent
|
||||
.pending_effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::DebounceSuggestions { .. })),
|
||||
"bash-mode typing debounces a suggest request"
|
||||
);
|
||||
}
|
||||
|
||||
/// Esc closes a dropdown the Tab-armed landing opened (the always-on
|
||||
/// dismissal path), and the draft survives.
|
||||
#[test]
|
||||
fn esc_closes_tab_fetched_dropdown() {
|
||||
let mut agent = bash_agent_always_on("git st");
|
||||
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
let generation = agent.prompt.suggestions.generation();
|
||||
agent.prompt.suggestions.on_suggestions_loaded(
|
||||
crate::views::suggestion_controller::SuggestResponseParsed {
|
||||
ghost: None,
|
||||
completions: vec![
|
||||
history_item("git status --porcelain-A", 0..6),
|
||||
history_item("git status --porcelain-B", 0..6),
|
||||
],
|
||||
generation,
|
||||
},
|
||||
"git st",
|
||||
"git st".len(),
|
||||
);
|
||||
assert!(agent.prompt.suggestions.take_pending_tab(generation));
|
||||
agent.shell_completion_tab();
|
||||
assert!(agent.prompt.completion_dropdown_open());
|
||||
|
||||
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Esc));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert!(!agent.prompt.completion_dropdown_open());
|
||||
assert_eq!(agent.prompt.text(), "git st");
|
||||
assert_eq!(agent.prompt_input_mode, PromptInputMode::Bash);
|
||||
}
|
||||
|
||||
/// With the pipeline OFF, typing invalidates Tab-fetched state instead:
|
||||
/// the landing response for the pre-edit text is stale.
|
||||
#[test]
|
||||
fn typing_invalidates_tab_state_always_on() {
|
||||
let mut agent = bash_agent_always_on("cat no");
|
||||
agent.prompt.suggestions.dropdown.items = vec![file_item("cat notes.md", "notes.md", 4..6)];
|
||||
let gen_before = agent.prompt.suggestions.generation();
|
||||
|
||||
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Char('x')));
|
||||
assert!(
|
||||
agent.prompt.suggestions.generation() > gen_before,
|
||||
"the edit must invalidate Tab-fetched state"
|
||||
);
|
||||
assert!(agent.prompt.suggestions.dropdown.items.is_empty());
|
||||
assert!(
|
||||
!agent
|
||||
.pending_effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::DebounceSuggestions { .. })),
|
||||
"no as-you-type fetch without the env flag"
|
||||
);
|
||||
}
|
||||
|
||||
/// THE stale-anchor regression: a mouse click repositions the cursor
|
||||
/// with no text change, so it must invalidate cached completion items
|
||||
/// exactly like a typed edit — the next Tab fetches for the token under
|
||||
/// the clicked cursor instead of completing the old one.
|
||||
#[test]
|
||||
fn prompt_click_invalidates_cached_items_before_tab() {
|
||||
use crate::app::agent_view::AgentPane;
|
||||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||
let mut agent = bash_agent_always_on("cat no");
|
||||
agent.prompt.suggestions.dropdown.items = vec![file_item("cat notes.md", "notes.md", 4..6)];
|
||||
agent.pane_areas.prompt = ratatui::layout::Rect::new(0, 40, 80, 5);
|
||||
// Already focused: an unfocused-collapse click only refocuses and
|
||||
// never reaches the textarea (the exact bug needs a focused click).
|
||||
agent.active_pane = AgentPane::Prompt;
|
||||
let gen_before = agent.prompt.suggestions.generation();
|
||||
|
||||
let click = MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: 2,
|
||||
row: 41,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
};
|
||||
let _ = agent.handle_mouse(&click);
|
||||
assert!(
|
||||
agent.prompt.suggestions.generation() > gen_before,
|
||||
"a prompt click must invalidate cached completion state"
|
||||
);
|
||||
assert!(agent.prompt.suggestions.dropdown.items.is_empty());
|
||||
|
||||
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
|
||||
assert_eq!(agent.prompt.text(), "cat no", "old token must not complete");
|
||||
assert!(
|
||||
agent
|
||||
.pending_effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::FetchShellSuggestions { .. })),
|
||||
"Tab must refetch for the clicked position"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,982 @@
|
||||
//! Line and block viewer popups plus the /btw panel: open/confirm/dismiss
|
||||
//! and their key/mouse handlers.
|
||||
|
||||
use super::{AgentView, render_char_buttons};
|
||||
use crate::app::app_view::InputOutcome;
|
||||
use crate::key;
|
||||
use crate::scrollback::selection::SelectionBox;
|
||||
use crate::scrollback::types::DisplayMode;
|
||||
use crate::theme::Theme;
|
||||
use crate::views::btw_overlay::BTW_OVERLAY_ENTRY_IDX;
|
||||
use crate::views::file_search::line_viewer::LineViewerState;
|
||||
use crate::views::list_pane::ListItem;
|
||||
use crate::views::plan_approval_view::PlanApprovalFocus;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
|
||||
impl AgentView {
|
||||
// ── Line viewer methods ────────────────────────────────────────────
|
||||
|
||||
/// Open the line viewer for a file path with optional initial line range.
|
||||
pub(in crate::app) fn open_line_viewer(
|
||||
&mut self,
|
||||
path: &std::path::Path,
|
||||
initial_range: Option<std::ops::Range<usize>>,
|
||||
) {
|
||||
// Resolve path relative to cwd.
|
||||
let full_path = if path.is_relative() {
|
||||
self.session.cwd.join(path)
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
|
||||
// Get the element ID of the last file ref element (just created).
|
||||
let element_id = self
|
||||
.prompt
|
||||
.textarea
|
||||
.elements()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|e| e.kind == crate::views::prompt_widget::KIND_FILE_REF)
|
||||
.map(|e| e.id);
|
||||
|
||||
if let Some(mut viewer) = LineViewerState::open(&full_path, element_id) {
|
||||
// If we have an initial line range, scroll to it and select.
|
||||
if let Some(range) = initial_range {
|
||||
viewer.set_initial_selection(range);
|
||||
}
|
||||
self.line_viewer = Some(viewer);
|
||||
} else {
|
||||
// File couldn't be read — cancel the undo group.
|
||||
self.prompt.textarea.cancel_undo_group();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key event while the line viewer is open.
|
||||
pub(super) fn handle_line_viewer_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
let in_plan_approval = self.plan_approval_view.is_some();
|
||||
|
||||
let input_bar_active = self
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.list_state.input_mode().is_some());
|
||||
|
||||
// When the search/filter/goto input bar is active, let ListPane
|
||||
// handle everything. Comment mode is special: Enter/Esc are not
|
||||
// consumed by the list state (it returns false), so we handle
|
||||
// save/cancel here.
|
||||
if input_bar_active {
|
||||
let is_comment_mode = self.line_viewer.as_ref().is_some_and(|v| {
|
||||
v.list_state.input_mode() == Some(crate::views::list_pane::InputBarMode::Comment)
|
||||
});
|
||||
if is_comment_mode {
|
||||
if key!(Enter).matches(key) {
|
||||
return self.save_casual_plan_comment();
|
||||
}
|
||||
if key!(Esc).matches(key) {
|
||||
return self.cancel_casual_plan_commenting();
|
||||
}
|
||||
}
|
||||
if let Some(ref mut viewer) = self.line_viewer {
|
||||
viewer.list_state.handle_key_event(key, &viewer.lines);
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
|
||||
if in_plan_approval && key.code == KeyCode::Tab && key.modifiers.is_empty() {
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.focus = PlanApprovalFocus::Prompt;
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
|
||||
// Plan-approval `Esc` doesn't close the viewer (use `q` / `Ctrl+\`),
|
||||
// but it still clears a transient visual selection or accepted search
|
||||
// matcher first, so the graduated dashboard-overlay back-out (which
|
||||
// declines to fire while a matcher is active) isn't left dead-ended.
|
||||
if in_plan_approval && key!(Esc).matches(key) {
|
||||
if let Some(ref mut viewer) = self.line_viewer {
|
||||
if viewer.list_state.visual_mode {
|
||||
viewer.list_state.exit_visual_mode();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if viewer.list_state.matcher().is_some() {
|
||||
viewer.list_state.handle_key_event(key, &viewer.lines);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
|
||||
// Ctrl+F: toggle fullscreen.
|
||||
if key.code == KeyCode::Char('f') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
if let Some(ref mut viewer) = self.line_viewer {
|
||||
viewer.fullscreen = !viewer.fullscreen;
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
|
||||
if in_plan_approval && key!('c').matches(key) {
|
||||
return self.enter_plan_commenting();
|
||||
}
|
||||
|
||||
// Casual mode: same `c` / `s` shortcuts as plan approval so the
|
||||
// footer hints actually work.
|
||||
if !in_plan_approval && self.is_plan_viewer() && key!('c').matches(key) {
|
||||
return self.enter_casual_plan_commenting();
|
||||
}
|
||||
if !in_plan_approval
|
||||
&& self.is_plan_viewer()
|
||||
&& key!('s').matches(key)
|
||||
&& !self.plan_comments.is_empty()
|
||||
{
|
||||
return self.send_casual_plan_comments();
|
||||
}
|
||||
|
||||
if in_plan_approval && key!('a').matches(key) {
|
||||
return self.approve_plan();
|
||||
}
|
||||
|
||||
// s: switch to prompt so the user can type an overall revision
|
||||
// message before submitting. Enter from Prompt does the actual send.
|
||||
if in_plan_approval && key!('s').matches(key) {
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.focus = PlanApprovalFocus::Prompt;
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
|
||||
if in_plan_approval && key!('q').matches(key) {
|
||||
return self.abandon_plan();
|
||||
}
|
||||
|
||||
if !in_plan_approval
|
||||
&& self.is_plan_viewer()
|
||||
&& !self.plan_comments.is_empty()
|
||||
&& key.code == KeyCode::Enter
|
||||
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
{
|
||||
return self.send_casual_plan_comments();
|
||||
}
|
||||
|
||||
if key!(Enter).matches(key) {
|
||||
if in_plan_approval {
|
||||
return self.enter_plan_commenting();
|
||||
}
|
||||
if self.is_plan_viewer() {
|
||||
return self.enter_casual_plan_commenting();
|
||||
}
|
||||
let has_visual = self
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.list_state.visual_mode);
|
||||
self.confirm_line_viewer(has_visual);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if key!('x').matches(key) {
|
||||
if in_plan_approval {
|
||||
return self.delete_plan_comment_at_cursor();
|
||||
}
|
||||
if self.is_plan_viewer() {
|
||||
return self.delete_casual_plan_comment_at_cursor();
|
||||
}
|
||||
self.confirm_line_viewer(false);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// y: copy selected line(s) to system clipboard.
|
||||
if key!('y').matches(key) {
|
||||
if let Some(ref viewer) = self.line_viewer {
|
||||
let text = if viewer.list_state.visual_mode {
|
||||
if let Some(ref range) = viewer.list_state.multi_range() {
|
||||
let lines: Vec<String> = (range.start..range.end)
|
||||
.filter_map(|vi| {
|
||||
let pi = viewer.list_state.to_physical(vi);
|
||||
viewer.lines.get(pi)
|
||||
})
|
||||
.map(|item| item.copy_text())
|
||||
.collect();
|
||||
Some(lines.join("\n"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
viewer
|
||||
.list_state
|
||||
.selected_index()
|
||||
.and_then(|vi| {
|
||||
let pi = viewer.list_state.to_physical(vi);
|
||||
viewer.lines.get(pi)
|
||||
})
|
||||
.map(|item| item.copy_text())
|
||||
};
|
||||
if let Some(text) = text
|
||||
&& !text.is_empty()
|
||||
{
|
||||
self.copy_to_clipboard(&text);
|
||||
}
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// Y: copy filename to clipboard.
|
||||
if key!('Y').matches(key) {
|
||||
if let Some(ref viewer) = self.line_viewer {
|
||||
let name = viewer
|
||||
.title_override
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| {
|
||||
viewer
|
||||
.path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
})
|
||||
.to_owned();
|
||||
self.copy_to_clipboard(&name);
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if key!(Esc).matches(key) || key!('q').matches(key) || key!('c', CONTROL).matches(key) {
|
||||
if in_plan_approval {
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// In the plan viewer, Esc first clears visual selection / search
|
||||
// before closing. q and Ctrl-C always close immediately.
|
||||
if key!(Esc).matches(key)
|
||||
&& let Some(ref mut viewer) = self.line_viewer
|
||||
{
|
||||
if viewer.list_state.visual_mode {
|
||||
viewer.list_state.exit_visual_mode();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if viewer.list_state.matcher().is_some() {
|
||||
viewer.list_state.handle_key_event(key, &viewer.lines);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
self.cancel_line_viewer();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// All other keys (including Ctrl-D/U for page nav): forward to ListPaneState.
|
||||
if let Some(ref mut viewer) = self.line_viewer {
|
||||
viewer.list_state.handle_key_event(key, &viewer.lines);
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
|
||||
/// Confirm line viewer: update the element, optionally with a line range.
|
||||
///
|
||||
/// `include_range`: if true and visual mode is active, appends `:N-M`.
|
||||
/// If false, confirms with just the file path (strips any existing range).
|
||||
fn confirm_line_viewer(&mut self, include_range: bool) {
|
||||
if let Some(viewer) = self.line_viewer.take() {
|
||||
if let Some(elem_id) = viewer.element_id {
|
||||
let rel_path = viewer
|
||||
.path
|
||||
.strip_prefix(&self.session.cwd)
|
||||
.unwrap_or(&viewer.path);
|
||||
|
||||
let suffix = if include_range {
|
||||
viewer.line_range_suffix().unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let path_display = format!("{}{suffix}", rel_path.display());
|
||||
let new_text = format!("@{path_display}");
|
||||
let display = crate::views::prompt_widget::file_ref_display(&path_display);
|
||||
|
||||
if let Some(elem) = self
|
||||
.prompt
|
||||
.textarea
|
||||
.elements()
|
||||
.iter()
|
||||
.find(|e| e.id == elem_id)
|
||||
{
|
||||
let range = elem.range.clone();
|
||||
self.prompt.textarea.replace_range_with_element(
|
||||
range,
|
||||
&new_text,
|
||||
crate::views::prompt_widget::KIND_FILE_REF,
|
||||
Some(display),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Close the undo group.
|
||||
self.prompt.textarea.insert_str(" ");
|
||||
self.prompt.textarea.end_undo_group();
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel line viewer: revert all changes.
|
||||
pub(crate) fn cancel_line_viewer(&mut self) {
|
||||
self.line_viewer = None;
|
||||
self.prompt.textarea.cancel_undo_group();
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.focus = PlanApprovalFocus::Preview;
|
||||
}
|
||||
// If a casual plan comment was in progress when the modal
|
||||
// closed (via [✗], click-outside, or any other path that
|
||||
// doesn't route through `cancel_casual_plan_commenting`),
|
||||
// restore the pre-comment prompt text so the user's original
|
||||
// text isn't lost behind the in-progress comment draft.
|
||||
// Mirrors `cancel_casual_plan_commenting`.
|
||||
if let Some(stashed) = self.casual_stashed_prompt.take() {
|
||||
self.prompt.restore(stashed);
|
||||
}
|
||||
self.casual_commenting_range = None;
|
||||
self.casual_editing_comment_id = None;
|
||||
}
|
||||
|
||||
/// Dismiss the /btw panel. If Done, flush response to scrollback first.
|
||||
pub(super) fn dismiss_btw_panel(&mut self) -> InputOutcome {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::BtwBlock;
|
||||
use crate::views::btw_overlay::BtwOverlayState;
|
||||
if let Some(BtwOverlayState::Done {
|
||||
question, content, ..
|
||||
}) = self.btw_state.take()
|
||||
{
|
||||
self.scrollback
|
||||
.push_block(RenderBlock::Btw(BtwBlock::new(question, content.text())));
|
||||
} else {
|
||||
self.btw_state = None;
|
||||
}
|
||||
self.btw_focused = false;
|
||||
self.clear_btw_drag_state();
|
||||
InputOutcome::Changed
|
||||
}
|
||||
|
||||
pub(super) fn clear_btw_drag_state(&mut self) {
|
||||
let is_btw = self
|
||||
.pending_text_drag
|
||||
.is_some_and(|p| p.anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX)
|
||||
|| self
|
||||
.drag_selection
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX);
|
||||
if is_btw {
|
||||
self.pending_text_drag = None;
|
||||
self.drag_selection = None;
|
||||
self.drag_autoscroll = None;
|
||||
self.last_drag_mouse = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle mouse events while the line viewer is open.
|
||||
pub(super) fn handle_line_viewer_mouse(
|
||||
&mut self,
|
||||
mouse: &crossterm::event::MouseEvent,
|
||||
) -> InputOutcome {
|
||||
use crossterm::event::{MouseButton, MouseEventKind};
|
||||
|
||||
let Some(ref mut viewer) = self.line_viewer else {
|
||||
return InputOutcome::Changed;
|
||||
};
|
||||
|
||||
// `popup_area` is the list-rendered area (excludes the divider
|
||||
// + footer rows in plan modes); used for dispatching mouse
|
||||
// events into `ListPaneState`. `modal_area` is the full inner
|
||||
// rect of the modal frame (includes the footer); used by the
|
||||
// click-outside-modal check so that clicks on the divider or
|
||||
// the empty space between footer buttons don't accidentally
|
||||
// close the modal.
|
||||
let popup_area = viewer.last_popup_area;
|
||||
let modal_area = viewer.last_modal_area;
|
||||
|
||||
let close_area = viewer.close_button_area;
|
||||
let fs_area = viewer.fullscreen_button_area;
|
||||
let send_area = viewer.plan_ref().and_then(|p| p.send_button_area);
|
||||
let abandon_area = viewer.plan_ref().and_then(|p| p.abandon_button_area);
|
||||
let approve_area = viewer.plan_ref().and_then(|p| p.approve_button_area);
|
||||
let comment_btn_area = viewer.plan_ref().and_then(|p| p.comment_button_area);
|
||||
// Cached `is_plan_viewer()` so we don't need to call self while
|
||||
// the line_viewer is mutably borrowed below.
|
||||
let is_plan_preview =
|
||||
viewer.kind == crate::views::file_search::line_viewer::LineViewerKind::PlanPreview;
|
||||
|
||||
match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
// Click on close button -> cancel.
|
||||
if close_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into())) {
|
||||
if self.plan_approval_view.is_none() {
|
||||
self.cancel_line_viewer();
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// Click on fullscreen button -> toggle fullscreen.
|
||||
if fs_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into())) {
|
||||
if let Some(ref mut v) = self.line_viewer {
|
||||
v.fullscreen = !v.fullscreen;
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if abandon_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into())) {
|
||||
return self.abandon_plan();
|
||||
}
|
||||
if approve_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into())) {
|
||||
if self.plan_approval_view.is_some() {
|
||||
return self.approve_plan();
|
||||
} else if is_plan_preview && !self.plan_comments.is_empty() {
|
||||
// Casual mode: the only action button shown is
|
||||
// `s send` (when there are comments to send).
|
||||
return self.send_casual_plan_comments();
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if comment_btn_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into())) {
|
||||
if self.plan_approval_view.is_some() {
|
||||
return self.enter_plan_commenting();
|
||||
}
|
||||
if is_plan_preview {
|
||||
return self.enter_casual_plan_commenting();
|
||||
}
|
||||
// The comment button is only set on plan viewers,
|
||||
// so the two arms above are exhaustive in practice.
|
||||
// Return here to make the dead fall-through
|
||||
// explicit and to match the abandon/approve hit
|
||||
// patterns just above.
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if send_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into())) {
|
||||
if self.plan_approval_view.is_some() {
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.focus = PlanApprovalFocus::Prompt;
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
return self.send_casual_plan_comments();
|
||||
}
|
||||
if modal_area.is_none_or(|a| !a.contains((mouse.column, mouse.row).into())) {
|
||||
if self.plan_approval_view.is_some()
|
||||
&& self
|
||||
.pane_areas
|
||||
.prompt
|
||||
.contains((mouse.column, mouse.row).into())
|
||||
{
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.focus = PlanApprovalFocus::Prompt;
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if self.plan_approval_view.is_some() {
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
self.cancel_line_viewer();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
let was_commenting = self
|
||||
.plan_approval_view
|
||||
.as_ref()
|
||||
.is_some_and(|pav| pav.focus == PlanApprovalFocus::Commenting);
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.focus = PlanApprovalFocus::Preview;
|
||||
if was_commenting {
|
||||
// Same rule as Tab: clicking back into the modal
|
||||
// discards the in-progress comment draft.
|
||||
pav.commenting_range = None;
|
||||
pav.editing_comment_id = None;
|
||||
pav.stashed_feedback_prompt = None;
|
||||
}
|
||||
}
|
||||
if was_commenting {
|
||||
self.prompt.set_text("");
|
||||
}
|
||||
// Forward below.
|
||||
}
|
||||
MouseEventKind::Moved => {
|
||||
let mut changed = false;
|
||||
let close_hover =
|
||||
close_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into()));
|
||||
if close_hover != viewer.close_hovered {
|
||||
viewer.close_hovered = close_hover;
|
||||
changed = true;
|
||||
}
|
||||
let fs_hover =
|
||||
fs_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into()));
|
||||
if fs_hover != viewer.fullscreen_hovered {
|
||||
viewer.fullscreen_hovered = fs_hover;
|
||||
changed = true;
|
||||
}
|
||||
let send_hover =
|
||||
send_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into()));
|
||||
let prev_send = viewer.plan_ref().is_some_and(|p| p.send_hovered);
|
||||
if send_hover != prev_send {
|
||||
viewer.plan_mut().send_hovered = send_hover;
|
||||
changed = true;
|
||||
}
|
||||
let abandon_hover =
|
||||
abandon_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into()));
|
||||
let prev_abandon = viewer.plan_ref().is_some_and(|p| p.abandon_hovered);
|
||||
if abandon_hover != prev_abandon {
|
||||
viewer.plan_mut().abandon_hovered = abandon_hover;
|
||||
changed = true;
|
||||
}
|
||||
let approve_hover =
|
||||
approve_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into()));
|
||||
let prev_approve = viewer.plan_ref().is_some_and(|p| p.approve_hovered);
|
||||
if approve_hover != prev_approve {
|
||||
viewer.plan_mut().approve_hovered = approve_hover;
|
||||
changed = true;
|
||||
}
|
||||
let comment_btn_hover =
|
||||
comment_btn_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into()));
|
||||
let prev_comment_btn = viewer.plan_ref().is_some_and(|p| p.comment_hovered);
|
||||
if comment_btn_hover != prev_comment_btn {
|
||||
viewer.plan_mut().comment_hovered = comment_btn_hover;
|
||||
changed = true;
|
||||
}
|
||||
if self.plan_approval_view.is_some()
|
||||
&& let Some(area) = popup_area
|
||||
&& area.contains((mouse.column, mouse.row).into())
|
||||
&& mouse.row >= area.y
|
||||
{
|
||||
let ry = (mouse.row - area.y) as usize;
|
||||
let vy = viewer.list_state.scroll_offset() + ry;
|
||||
if viewer.list_state.layout().item_at_y(vy).is_some()
|
||||
&& viewer.list_state.select_at_y(vy, &viewer.lines)
|
||||
{
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return if changed {
|
||||
InputOutcome::Changed
|
||||
} else {
|
||||
InputOutcome::Unchanged
|
||||
};
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
// Drag-to-extend works in both plan-approval and casual
|
||||
// plan-preview modes (anywhere the PlanPreview viewer is
|
||||
// showing).
|
||||
if is_plan_preview
|
||||
&& let Some(area) = popup_area
|
||||
&& let Some(ln) = viewer.source_line_at_screen_row(mouse.row, area)
|
||||
{
|
||||
let has_start = viewer
|
||||
.plan_ref()
|
||||
.is_some_and(|p| p.gutter_drag_start.is_some());
|
||||
if has_start {
|
||||
viewer.plan_mut().gutter_drag_end = Some(ln);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
if let Some(area) = popup_area
|
||||
&& area.contains((mouse.column, mouse.row).into())
|
||||
{
|
||||
viewer.list_state.handle_mouse_event(
|
||||
mouse.kind,
|
||||
mouse.column,
|
||||
mouse.row,
|
||||
area,
|
||||
&viewer.lines,
|
||||
);
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
if is_plan_preview {
|
||||
let drag_start = viewer.plan_ref().and_then(|p| p.gutter_drag_start);
|
||||
let drag_end = viewer.plan_ref().and_then(|p| p.gutter_drag_end);
|
||||
viewer.plan_mut().gutter_drag_start = None;
|
||||
viewer.plan_mut().gutter_drag_end = None;
|
||||
if let (Some(start), Some(end)) = (drag_start, drag_end)
|
||||
&& start != end
|
||||
{
|
||||
let lo = start.min(end);
|
||||
let hi = start.max(end);
|
||||
let range = lo..hi + 1;
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.stashed_feedback_prompt = Some(self.prompt.stash());
|
||||
pav.commenting_range = Some(range);
|
||||
pav.editing_comment_id = None;
|
||||
pav.focus = PlanApprovalFocus::Commenting;
|
||||
self.prompt.set_text("");
|
||||
} else {
|
||||
// First-entry-only stash; see
|
||||
// `enter_casual_plan_commenting` for the
|
||||
// same guard rationale.
|
||||
if self.casual_stashed_prompt.is_none() {
|
||||
self.casual_stashed_prompt = Some(self.prompt.stash());
|
||||
}
|
||||
self.casual_commenting_range = Some(range);
|
||||
self.casual_editing_comment_id = None;
|
||||
self.prompt.set_text("");
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
if let Some(area) = popup_area
|
||||
&& area.contains((mouse.column, mouse.row).into())
|
||||
{
|
||||
viewer.list_state.handle_mouse_event(
|
||||
mouse.kind,
|
||||
mouse.column,
|
||||
mouse.row,
|
||||
area,
|
||||
&viewer.lines,
|
||||
);
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => {}
|
||||
_ => return InputOutcome::Changed,
|
||||
}
|
||||
|
||||
// Forward to ListPaneState if inside the popup area.
|
||||
let mut should_enter_commenting = false;
|
||||
let mut should_enter_plan_commenting = false;
|
||||
if let Some(area) = popup_area
|
||||
&& area.contains((mouse.column, mouse.row).into())
|
||||
{
|
||||
viewer.list_state.handle_mouse_event(
|
||||
mouse.kind,
|
||||
mouse.column,
|
||||
mouse.row,
|
||||
area,
|
||||
&viewer.lines,
|
||||
);
|
||||
|
||||
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
|
||||
let clicked_line = viewer.source_line_at_screen_row(mouse.row, area);
|
||||
// Drag selection works in both modes whenever the
|
||||
// plan preview is showing — but only on source rows
|
||||
// (we need a 1-based line number as the drag anchor).
|
||||
if is_plan_preview && let Some(ln) = clicked_line {
|
||||
viewer.plan_mut().gutter_drag_start = Some(ln);
|
||||
viewer.plan_mut().gutter_drag_end = Some(ln);
|
||||
}
|
||||
|
||||
viewer.plan_mut().last_click_at = Some(std::time::Instant::now());
|
||||
|
||||
// A single click on any list row — source line OR
|
||||
// existing comment annotation — enters commenting (or
|
||||
// edit-comment) for that row. Same shortcut as
|
||||
// selecting + pressing `c` / Enter. Works for both
|
||||
// plan-approval and casual plan-preview modes.
|
||||
let on_list_row = mouse.row >= area.y && {
|
||||
let ry = (mouse.row - area.y) as usize;
|
||||
let vy = viewer.list_state.scroll_offset() + ry;
|
||||
viewer.list_state.layout().item_at_y(vy).is_some()
|
||||
};
|
||||
// Skip the click-to-comment trigger if the user is
|
||||
// already composing a comment. Without this guard, any
|
||||
// click on a list row would re-enter commenting and
|
||||
// re-stash the (now-comment) prompt, clobbering the
|
||||
// user's pre-comment text and preventing the mouse from
|
||||
// being used to reposition the cursor without
|
||||
// committing to a fresh comment.
|
||||
let in_pav_commenting = self
|
||||
.plan_approval_view
|
||||
.as_ref()
|
||||
.is_some_and(|pav| pav.focus == PlanApprovalFocus::Commenting);
|
||||
let in_casual_commenting =
|
||||
self.plan_approval_view.is_none() && self.casual_commenting_range.is_some();
|
||||
if on_list_row
|
||||
&& is_plan_preview
|
||||
&& viewer.list_state.input_mode().is_none()
|
||||
&& !in_pav_commenting
|
||||
&& !in_casual_commenting
|
||||
{
|
||||
if self.plan_approval_view.is_some() {
|
||||
should_enter_plan_commenting = true;
|
||||
} else {
|
||||
should_enter_commenting = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if should_enter_commenting {
|
||||
return self.enter_casual_plan_commenting();
|
||||
}
|
||||
if should_enter_plan_commenting {
|
||||
return self.enter_plan_commenting();
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
|
||||
// -- Scrollback selection box buttons -------------------------------------
|
||||
|
||||
/// Render ⧉ (copy) and ↗ (view) buttons on the scrollback selection box.
|
||||
///
|
||||
/// Two modes:
|
||||
/// - **Corner row** (expanded or ungrouped): buttons on the `╭...╮` row.
|
||||
/// - **Inline** (collapsed + grouped): buttons on the selected entry's row,
|
||||
/// overlaying content at the right edge.
|
||||
pub(super) fn render_selection_buttons(
|
||||
&mut self,
|
||||
buf: &mut Buffer,
|
||||
selection_box: &SelectionBox,
|
||||
selected_entry_area: Option<Rect>,
|
||||
theme: &Theme,
|
||||
) {
|
||||
// Gated by appearance config (opt-in while testing).
|
||||
if !self
|
||||
.scrollback
|
||||
.appearance()
|
||||
.scrollback
|
||||
.display
|
||||
.selection_buttons
|
||||
{
|
||||
self.hit_sb_copy.clear();
|
||||
self.hit_sb_view.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(selected_idx) = self.scrollback.selected() else {
|
||||
self.hit_sb_copy.clear();
|
||||
self.hit_sb_view.clear();
|
||||
return;
|
||||
};
|
||||
let Some(entry) = self.scrollback.entry(selected_idx) else {
|
||||
self.hit_sb_copy.clear();
|
||||
self.hit_sb_view.clear();
|
||||
return;
|
||||
};
|
||||
|
||||
let header_selected = self.scrollback.entry_content_hidden_by_group(selected_idx);
|
||||
let has_copy = entry.block.supports_copy() && !header_selected;
|
||||
let has_view = entry.block.supports_fullscreen() && !header_selected;
|
||||
if !has_copy && !has_view {
|
||||
self.hit_sb_copy.clear();
|
||||
self.hit_sb_view.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine inline vs corner mode.
|
||||
// Inline: entry is collapsed AND part of a group (group_range > 1).
|
||||
let split_mode = self
|
||||
.scrollback
|
||||
.appearance()
|
||||
.scrollback
|
||||
.display
|
||||
.group_selection_split;
|
||||
let group_range = self.scrollback.group_range_of(selected_idx, split_mode);
|
||||
let is_grouped = group_range.len() > 1;
|
||||
let is_collapsed = entry.display_mode == DisplayMode::Collapsed;
|
||||
let inline = is_collapsed && is_grouped;
|
||||
|
||||
let sel = &selection_box.inner_area;
|
||||
let right_x = sel.x + sel.width.saturating_sub(1);
|
||||
|
||||
let btn_base = Style::default().fg(theme.selection_border);
|
||||
let btn_hover = Style::default().fg(theme.text_primary);
|
||||
|
||||
// Build button array based on capabilities.
|
||||
if has_copy && has_view {
|
||||
let (btn_right_x, y) = if inline {
|
||||
// Inline: buttons on the selected entry's content row.
|
||||
let entry_y = selected_entry_area.map(|r| r.y).unwrap_or(sel.y);
|
||||
// Place inside the right border (right_x has │).
|
||||
(right_x.saturating_sub(2), entry_y)
|
||||
} else {
|
||||
// Corner row: buttons to the left of ╮.
|
||||
let corner_y = sel.y.saturating_sub(1);
|
||||
(right_x.saturating_sub(2), corner_y)
|
||||
};
|
||||
if !selection_box.top_clipped || inline {
|
||||
let areas = render_char_buttons(
|
||||
buf,
|
||||
btn_right_x,
|
||||
y,
|
||||
[
|
||||
(crate::glyphs::copy_icon(), self.hit_sb_copy.hovered),
|
||||
(crate::glyphs::enlarge(), self.hit_sb_view.hovered),
|
||||
],
|
||||
btn_base,
|
||||
btn_hover,
|
||||
1,
|
||||
);
|
||||
self.hit_sb_copy.set(Some(areas[0]));
|
||||
self.hit_sb_view.set(Some(areas[1]));
|
||||
} else {
|
||||
self.hit_sb_copy.clear();
|
||||
self.hit_sb_view.clear();
|
||||
}
|
||||
} else if has_copy {
|
||||
let (btn_right_x, y) = if inline {
|
||||
let entry_y = selected_entry_area.map(|r| r.y).unwrap_or(sel.y);
|
||||
(right_x.saturating_sub(2), entry_y)
|
||||
} else {
|
||||
let corner_y = sel.y.saturating_sub(1);
|
||||
(right_x.saturating_sub(2), corner_y)
|
||||
};
|
||||
if !selection_box.top_clipped || inline {
|
||||
let areas = render_char_buttons(
|
||||
buf,
|
||||
btn_right_x,
|
||||
y,
|
||||
[(crate::glyphs::copy_icon(), self.hit_sb_copy.hovered)],
|
||||
btn_base,
|
||||
btn_hover,
|
||||
0,
|
||||
);
|
||||
self.hit_sb_copy.set(Some(areas[0]));
|
||||
} else {
|
||||
self.hit_sb_copy.clear();
|
||||
}
|
||||
self.hit_sb_view.clear();
|
||||
} else {
|
||||
// has_view only
|
||||
let (btn_right_x, y) = if inline {
|
||||
let entry_y = selected_entry_area.map(|r| r.y).unwrap_or(sel.y);
|
||||
(right_x.saturating_sub(2), entry_y)
|
||||
} else {
|
||||
let corner_y = sel.y.saturating_sub(1);
|
||||
(right_x.saturating_sub(2), corner_y)
|
||||
};
|
||||
if !selection_box.top_clipped || inline {
|
||||
let areas = render_char_buttons(
|
||||
buf,
|
||||
btn_right_x,
|
||||
y,
|
||||
[(crate::glyphs::enlarge(), self.hit_sb_view.hovered)],
|
||||
btn_base,
|
||||
btn_hover,
|
||||
0,
|
||||
);
|
||||
self.hit_sb_view.set(Some(areas[0]));
|
||||
} else {
|
||||
self.hit_sb_view.clear();
|
||||
}
|
||||
self.hit_sb_copy.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// -- Block viewer input handling ------------------------------------------
|
||||
|
||||
/// Handle a key event when the block viewer is open.
|
||||
///
|
||||
/// Returns `Changed` if consumed, `Unchanged` if the key should bubble up.
|
||||
pub(super) fn handle_block_viewer_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
let Some(ref mut viewer) = self.block_viewer else {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
|
||||
// Check for close signals first (Esc/q/Ctrl-F)
|
||||
if viewer.is_close_key(key) {
|
||||
self.block_viewer = None;
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
|
||||
// Route to viewer — returns whether the key was consumed
|
||||
if !viewer.handle_key(key) {
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
|
||||
// Handle raw toggle: capture old source map, toggle, rebuild with stability
|
||||
if viewer.raw_toggle_pending {
|
||||
viewer.raw_toggle_pending = false;
|
||||
// Record scroll anchor BEFORE toggle so the selected line stays
|
||||
// at the same screen position after the rebuild.
|
||||
viewer.list_state.set_scroll_anchor();
|
||||
// Capture source map BEFORE toggle for cursor mapping
|
||||
let old_source_line = self
|
||||
.scrollback
|
||||
.get_by_id(viewer.entry_id)
|
||||
.and_then(|entry| {
|
||||
viewer.list_state.selected_id().and_then(|id| {
|
||||
crate::views::block_viewer::BlockViewerPane::source_line_for_id(
|
||||
&entry.block,
|
||||
id,
|
||||
)
|
||||
})
|
||||
});
|
||||
// Toggle raw mode on the entry
|
||||
if let Some(entry) = self.scrollback.get_by_id_mut(viewer.entry_id) {
|
||||
entry.toggle_raw();
|
||||
}
|
||||
// Re-borrow immutably to rebuild items (avoids clone)
|
||||
if let Some(entry) = self.scrollback.get_by_id(viewer.entry_id) {
|
||||
viewer.rebuild_items(entry);
|
||||
viewer.jump_to_source_line(entry, old_source_line);
|
||||
}
|
||||
}
|
||||
|
||||
// Process pending copy actions (logic lives in BlockViewerPane)
|
||||
let entry_id = viewer.entry_id;
|
||||
if let Some(entry) = self.scrollback.get_by_id(entry_id)
|
||||
&& let Some(text) = viewer.process_pending_copy(entry)
|
||||
{
|
||||
self.copy_to_clipboard(&text);
|
||||
}
|
||||
|
||||
InputOutcome::Changed
|
||||
}
|
||||
|
||||
/// Handle a mouse event when the block viewer modal is open.
|
||||
pub(in crate::app) fn handle_block_viewer_mouse(
|
||||
&mut self,
|
||||
mouse: &crossterm::event::MouseEvent,
|
||||
) -> InputOutcome {
|
||||
use crate::views::modal_window::{ModalWindowOutcome, handle_modal_mouse};
|
||||
use crossterm::event::{MouseButton, MouseEventKind};
|
||||
|
||||
let Some(ref mut viewer) = self.block_viewer else {
|
||||
return InputOutcome::Changed;
|
||||
};
|
||||
|
||||
// Route to modal chrome first (close button, click-outside).
|
||||
let modal_outcome =
|
||||
handle_modal_mouse(&mut viewer.modal, mouse.kind, mouse.column, mouse.row);
|
||||
match modal_outcome {
|
||||
ModalWindowOutcome::CloseRequested => {
|
||||
self.block_viewer = None;
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
ModalWindowOutcome::Handled => return InputOutcome::Changed,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Content interaction (scroll, click, drag).
|
||||
match mouse.kind {
|
||||
MouseEventKind::ScrollDown => viewer.handle_scroll(3),
|
||||
MouseEventKind::ScrollUp => viewer.handle_scroll(-3),
|
||||
MouseEventKind::Down(MouseButton::Left)
|
||||
| MouseEventKind::Drag(MouseButton::Left)
|
||||
| MouseEventKind::Up(MouseButton::Left) => {
|
||||
viewer.handle_mouse(mouse.kind, mouse.column, mouse.row);
|
||||
}
|
||||
MouseEventKind::Moved => {
|
||||
// Update hover state for content area.
|
||||
viewer.handle_mouse(mouse.kind, mouse.column, mouse.row);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Collect any pending copy text: drag-release auto-copy (like
|
||||
// scrollback finish_text_drag) or Y/y key handler copy.
|
||||
let drag_text = viewer.drag_copy_text.take();
|
||||
let entry_id = viewer.entry_id;
|
||||
let key_text = if drag_text.is_none() {
|
||||
self.scrollback
|
||||
.get_by_id(entry_id)
|
||||
.and_then(|entry| viewer.process_pending_copy(entry))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// viewer borrow ends here — clipboard + toast can use &mut self.
|
||||
if let Some(text) = drag_text.or(key_text) {
|
||||
self.copy_to_clipboard(&text);
|
||||
}
|
||||
|
||||
InputOutcome::Changed
|
||||
}
|
||||
|
||||
/// Dynamic fold label for the shortcuts bar hint.
|
||||
///
|
||||
/// Returns "expand" if the selected entry is collapsed/truncated,
|
||||
/// "collapse" if expanded, or `None` if the selected entry isn't foldable.
|
||||
pub(super) fn selected_fold_label(&self) -> Option<&'static str> {
|
||||
let idx = self.scrollback.selected()?;
|
||||
let entry = self.scrollback.get(idx)?;
|
||||
if !entry.is_foldable() {
|
||||
return None;
|
||||
}
|
||||
Some(match entry.display_mode() {
|
||||
DisplayMode::Expanded => "collapse",
|
||||
_ => "expand",
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
//! Bundle status state and response types.
|
||||
//!
|
||||
//! Pager-side cache of what `kigi-shell` reports from
|
||||
//! `x.ai/bundle/status`. The shell now performs the actual bundle download in
|
||||
//! the background post-auth; the pager only reads the resulting on-disk
|
||||
//! catalog so it can populate the welcome-screen subagent pane.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Pager-local snapshot of bundle availability on disk.
|
||||
///
|
||||
/// Populated from `x.ai/bundle/status` ACP responses.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct BundleState {
|
||||
pub has_cache: bool,
|
||||
pub version: String,
|
||||
pub personas: Vec<String>,
|
||||
pub roles: Vec<String>,
|
||||
pub agents: Vec<String>,
|
||||
pub skills: Vec<String>,
|
||||
pub persona_details: Vec<PersonaDetail>,
|
||||
pub role_details: Vec<RoleDetail>,
|
||||
}
|
||||
|
||||
/// Deserialized response from `x.ai/bundle/status`.
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BundleStatusResult {
|
||||
pub has_cache: bool,
|
||||
/// `None` when `has_cache` is false (shell omits the field).
|
||||
pub version: Option<String>,
|
||||
pub personas: Vec<String>,
|
||||
pub roles: Vec<String>,
|
||||
pub agents: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub skills: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub persona_details: Vec<PersonaDetail>,
|
||||
#[serde(default)]
|
||||
pub role_details: Vec<RoleDetail>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonaDetail {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub has_inputs: bool,
|
||||
pub has_outputs: bool,
|
||||
/// Absolute path when the persona was loaded from disk (user/project).
|
||||
#[serde(default)]
|
||||
pub source_path: Option<String>,
|
||||
/// `"user"` or `"project"` for local personas; omitted for bundled catalog entries.
|
||||
#[serde(default)]
|
||||
pub scope_label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RoleDetail {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Deserialized response from `x.ai/bundle/entry/get`.
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EntryGetResult {
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deserialize_status_result_without_details() {
|
||||
let json = r#"{
|
||||
"hasCache": true,
|
||||
"version": "v1",
|
||||
"personas": ["researcher", "auditor"],
|
||||
"roles": ["reviewer"],
|
||||
"agents": ["default", "plan"]
|
||||
}"#;
|
||||
let r: BundleStatusResult = serde_json::from_str(json).expect("parse");
|
||||
assert!(r.has_cache);
|
||||
assert_eq!(r.version.as_deref(), Some("v1"));
|
||||
assert_eq!(r.personas, vec!["researcher", "auditor"]);
|
||||
assert_eq!(r.roles, vec!["reviewer"]);
|
||||
assert_eq!(r.agents, vec!["default", "plan"]);
|
||||
assert!(r.persona_details.is_empty());
|
||||
assert!(r.role_details.is_empty());
|
||||
assert!(r.skills.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_status_result_with_details() {
|
||||
let json = r#"{
|
||||
"hasCache": true,
|
||||
"version": "v2",
|
||||
"personas": ["researcher"],
|
||||
"roles": ["reviewer"],
|
||||
"agents": [],
|
||||
"skills": ["commit", "design"],
|
||||
"personaDetails": [{
|
||||
"name": "researcher",
|
||||
"description": "thorough researcher",
|
||||
"hasInputs": true,
|
||||
"hasOutputs": false
|
||||
}],
|
||||
"roleDetails": [{
|
||||
"name": "reviewer",
|
||||
"description": "code reviewer"
|
||||
}]
|
||||
}"#;
|
||||
let r: BundleStatusResult = serde_json::from_str(json).expect("parse");
|
||||
assert_eq!(r.persona_details.len(), 1);
|
||||
assert_eq!(r.persona_details[0].name, "researcher");
|
||||
assert_eq!(
|
||||
r.persona_details[0].description.as_deref(),
|
||||
Some("thorough researcher")
|
||||
);
|
||||
assert!(r.persona_details[0].has_inputs);
|
||||
assert!(!r.persona_details[0].has_outputs);
|
||||
assert_eq!(r.role_details.len(), 1);
|
||||
assert_eq!(r.role_details[0].name, "reviewer");
|
||||
assert_eq!(r.role_details[0].description, "code reviewer");
|
||||
assert_eq!(r.skills, vec!["commit", "design"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_status_result_empty() {
|
||||
let json = r#"{
|
||||
"hasCache": false,
|
||||
"version": "",
|
||||
"personas": [],
|
||||
"roles": [],
|
||||
"agents": []
|
||||
}"#;
|
||||
let r: BundleStatusResult = serde_json::from_str(json).expect("parse");
|
||||
assert!(!r.has_cache);
|
||||
assert!(r.personas.is_empty());
|
||||
assert!(r.skills.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_status_result_no_version_field() {
|
||||
// The shell omits `version` entirely when has_cache is false.
|
||||
let json = r#"{
|
||||
"hasCache": false,
|
||||
"personas": [],
|
||||
"roles": [],
|
||||
"agents": []
|
||||
}"#;
|
||||
let r: BundleStatusResult = serde_json::from_str(json).expect("parse");
|
||||
assert!(!r.has_cache);
|
||||
assert!(r.version.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_entry_get_result() {
|
||||
let json = r#"{
|
||||
"kind": "persona",
|
||||
"name": "researcher",
|
||||
"content": "instructions = \"dig deep\""
|
||||
}"#;
|
||||
let r: EntryGetResult = serde_json::from_str(json).expect("parse");
|
||||
assert_eq!(r.kind, "persona");
|
||||
assert_eq!(r.name, "researcher");
|
||||
assert_eq!(r.content, "instructions = \"dig deep\"");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,687 @@
|
||||
//! CSI fragment filter for the input event channel. Sibling of [`super::xt_filter`],
|
||||
//! which does the same reassembly for the XTVERSION DCS reply. See
|
||||
//! [`CsiFragmentFilter`].
|
||||
|
||||
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
|
||||
use super::event_loop::is_bare_esc_press;
|
||||
|
||||
/// Persistent filter that reassembles CSI fragments leaked by crossterm when a
|
||||
/// control sequence splits across `read()` boundaries — SGR mouse reports
|
||||
/// `\e[<…M/m` and focus reports `\e[I`/`\e[O`. Carries state across
|
||||
/// `drain_and_process` calls so a mouse report split across batches is still
|
||||
/// caught (its `\x1b` in batch N, `[<…M` in batch N+1). A fragmented focus
|
||||
/// report is reassembled into its `Event::FocusGained`/`Event::FocusLost` only
|
||||
/// when its bare `\e` and `[I`/`[O` arrive in the same batch: a lone `\e` can't
|
||||
/// be held across batches (a lone `[` must render at once), so a focus report
|
||||
/// whose `\e` was isolated in a prior batch still leaks.
|
||||
pub(super) struct CsiFragmentFilter {
|
||||
state: CsiFragmentState,
|
||||
tentative: Vec<Event>,
|
||||
}
|
||||
|
||||
impl CsiFragmentFilter {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
state: CsiFragmentState::Idle,
|
||||
tentative: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a batch of events, filtering any CSI fragments.
|
||||
/// Partial matches are held in `self.tentative` until the next call.
|
||||
/// The `esc_before_run` pop is per-call only (can't retract across batches).
|
||||
pub(super) fn filter(&mut self, events: Vec<Event>) -> Vec<Event> {
|
||||
let mut result = Vec::with_capacity(self.tentative.len() + events.len());
|
||||
let mut esc_before_run = false;
|
||||
let mut filtered_count = 0usize;
|
||||
|
||||
for ev in events {
|
||||
if is_bare_esc_press(&ev) {
|
||||
result.append(&mut self.tentative);
|
||||
self.state = CsiFragmentState::Idle;
|
||||
result.push(ev);
|
||||
esc_before_run = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
match csi_filterable_char(&ev) {
|
||||
Some(ch) => match self.state.advance(ch) {
|
||||
CsiAdvance::Continue(next) => {
|
||||
self.state = next;
|
||||
self.tentative.push(ev);
|
||||
}
|
||||
CsiAdvance::Complete => {
|
||||
filtered_count += 1;
|
||||
self.tentative.clear();
|
||||
if esc_before_run {
|
||||
result.pop();
|
||||
}
|
||||
esc_before_run = false;
|
||||
self.state = CsiFragmentState::Idle;
|
||||
}
|
||||
CsiAdvance::CompleteFocus => {
|
||||
if esc_before_run {
|
||||
// bare \e then [I/[O in one drain batch is treated as a focus report; a typed pair rarely lands in one batch (same assumption as the mouse Complete arm)
|
||||
filtered_count += 1;
|
||||
self.tentative.clear();
|
||||
result.pop(); // retract the bare Esc
|
||||
// translate the reassembled report into its focus event so focus-driven UX (prompt refocus, recap away-timer, /gboom key-release) still fires over SSH
|
||||
result.push(if ch == 'I' {
|
||||
Event::FocusGained
|
||||
} else {
|
||||
Event::FocusLost
|
||||
});
|
||||
esc_before_run = false;
|
||||
self.state = CsiFragmentState::Idle;
|
||||
} else {
|
||||
// typed `[I` / `[O` (e.g. arr[I]) — pass through
|
||||
result.append(&mut self.tentative);
|
||||
self.state = CsiFragmentState::Idle;
|
||||
result.push(ev);
|
||||
}
|
||||
}
|
||||
CsiAdvance::Reject => {
|
||||
result.append(&mut self.tentative);
|
||||
esc_before_run = false;
|
||||
self.state = CsiFragmentState::Idle;
|
||||
match CsiFragmentState::Idle.advance(ch) {
|
||||
CsiAdvance::Continue(next) => {
|
||||
self.state = next;
|
||||
self.tentative.push(ev);
|
||||
}
|
||||
_ => result.push(ev),
|
||||
}
|
||||
}
|
||||
},
|
||||
None => {
|
||||
result.append(&mut self.tentative);
|
||||
self.state = CsiFragmentState::Idle;
|
||||
esc_before_run = false;
|
||||
result.push(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if filtered_count > 0 {
|
||||
tracing::debug!(filtered_count, "filtered CSI fragments");
|
||||
}
|
||||
|
||||
// A lone typed `[` is indistinguishable from the start of a CSI fragment —
|
||||
// an SGR mouse report `[<…M` or a focus report `[I`/`[O` — but user input
|
||||
// must render immediately. Real leaked fragments arrive with the
|
||||
// byte after `[` in the same read(); carrying only `Bracket` across batches
|
||||
// is unnecessary and holds the key until the next keystroke. Deeper partial
|
||||
// states (`[<…`) still persist for cross-batch continuation.
|
||||
if matches!(self.state, CsiFragmentState::Bracket) {
|
||||
result.append(&mut self.tentative);
|
||||
self.state = CsiFragmentState::Idle;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// States for recognizing SGR mouse `[<digits;digits;digits{M,m}` and focus `[I`/`[O`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum CsiFragmentState {
|
||||
Idle,
|
||||
Bracket,
|
||||
LessThan,
|
||||
Digits1,
|
||||
Semi1,
|
||||
Digits2,
|
||||
Semi2,
|
||||
Digits3,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum CsiAdvance {
|
||||
Continue(CsiFragmentState),
|
||||
Complete,
|
||||
CompleteFocus,
|
||||
Reject,
|
||||
}
|
||||
|
||||
impl CsiFragmentState {
|
||||
fn advance(self, ch: char) -> CsiAdvance {
|
||||
use CsiFragmentState::*;
|
||||
match (self, ch) {
|
||||
(Idle, '[') => CsiAdvance::Continue(Bracket),
|
||||
(Bracket, '<') => CsiAdvance::Continue(LessThan),
|
||||
// \e[I / \e[O focus report finals
|
||||
(Bracket, 'I') | (Bracket, 'O') => CsiAdvance::CompleteFocus,
|
||||
(LessThan | Digits1, c) if c.is_ascii_digit() => CsiAdvance::Continue(Digits1),
|
||||
(Digits1, ';') => CsiAdvance::Continue(Semi1),
|
||||
(Semi1 | Digits2, c) if c.is_ascii_digit() => CsiAdvance::Continue(Digits2),
|
||||
(Digits2, ';') => CsiAdvance::Continue(Semi2),
|
||||
(Semi2 | Digits3, c) if c.is_ascii_digit() => CsiAdvance::Continue(Digits3),
|
||||
(Digits3, 'M' | 'm') => CsiAdvance::Complete,
|
||||
_ => CsiAdvance::Reject,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn csi_filterable_char(ev: &Event) -> Option<char> {
|
||||
match ev {
|
||||
Event::Key(ke)
|
||||
if ke.kind == KeyEventKind::Press
|
||||
&& (ke.modifiers == KeyModifiers::NONE || ke.modifiers == KeyModifiers::SHIFT) =>
|
||||
{
|
||||
if let KeyCode::Char(c) = ke.code {
|
||||
Some(c)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::{KeyEvent, KeyEventState};
|
||||
|
||||
fn press_mods(code: KeyCode, modifiers: KeyModifiers) -> Event {
|
||||
Event::Key(KeyEvent {
|
||||
code,
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press,
|
||||
state: KeyEventState::NONE,
|
||||
})
|
||||
}
|
||||
|
||||
fn press(code: KeyCode) -> Event {
|
||||
press_mods(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
fn press_shift(code: KeyCode) -> Event {
|
||||
press_mods(code, KeyModifiers::SHIFT)
|
||||
}
|
||||
|
||||
// ── SGR mouse fragment filter tests ──────────────────────────────
|
||||
|
||||
/// Build key events matching crossterm's actual output for a fragmented
|
||||
/// SGR mouse report `[<btn;col;row{M|m}]`.
|
||||
fn sgr_fragment(btn: &str, col: &str, row: &str, term: char) -> Vec<Event> {
|
||||
let mut events = vec![press(KeyCode::Char('[')), press(KeyCode::Char('<'))];
|
||||
for c in btn.chars() {
|
||||
events.push(press(KeyCode::Char(c)));
|
||||
}
|
||||
events.push(press(KeyCode::Char(';')));
|
||||
for c in col.chars() {
|
||||
events.push(press(KeyCode::Char(c)));
|
||||
}
|
||||
events.push(press(KeyCode::Char(';')));
|
||||
for c in row.chars() {
|
||||
events.push(press(KeyCode::Char(c)));
|
||||
}
|
||||
if term.is_uppercase() {
|
||||
events.push(press_shift(KeyCode::Char(term)));
|
||||
} else {
|
||||
events.push(press(KeyCode::Char(term)));
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_empty() {
|
||||
assert!(CsiFragmentFilter::new().filter(vec![]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_normal_keys_unchanged() {
|
||||
let events = vec![
|
||||
press(KeyCode::Char('h')),
|
||||
press(KeyCode::Char('i')),
|
||||
press(KeyCode::Enter),
|
||||
];
|
||||
let result = CsiFragmentFilter::new().filter(events);
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0], press(KeyCode::Char('h')));
|
||||
assert_eq!(result[1], press(KeyCode::Char('i')));
|
||||
assert_eq!(result[2], press(KeyCode::Enter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_single_fragment_removed() {
|
||||
let events = sgr_fragment("35", "261", "67", 'M');
|
||||
assert!(CsiFragmentFilter::new().filter(events).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_multiple_fragments_removed() {
|
||||
let mut events = sgr_fragment("35", "261", "67", 'M');
|
||||
events.extend(sgr_fragment("35", "263", "64", 'M'));
|
||||
assert!(CsiFragmentFilter::new().filter(events).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_esc_before_fragment_removed() {
|
||||
let mut events = vec![press(KeyCode::Esc)];
|
||||
events.extend(sgr_fragment("35", "261", "67", 'M'));
|
||||
assert!(CsiFragmentFilter::new().filter(events).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_partial_fragment_held() {
|
||||
// Partial SGR fragment (no terminating M/m) is held in the
|
||||
// persistent filter's tentative buffer, not emitted yet.
|
||||
let events = vec![
|
||||
press(KeyCode::Char('[')),
|
||||
press(KeyCode::Char('<')),
|
||||
press(KeyCode::Char('3')),
|
||||
press(KeyCode::Char('5')),
|
||||
press(KeyCode::Char(';')),
|
||||
press(KeyCode::Char('2')),
|
||||
press(KeyCode::Char('6')),
|
||||
press(KeyCode::Char('1')),
|
||||
press(KeyCode::Char(';')),
|
||||
];
|
||||
let mut f = CsiFragmentFilter::new();
|
||||
let result = f.filter(events);
|
||||
assert!(result.is_empty(), "partial fragment should be held");
|
||||
// A follow-up non-SGR event flushes the held events.
|
||||
let result2 = f.filter(vec![press(KeyCode::Enter)]);
|
||||
assert_eq!(result2.len(), 10); // 9 held + 1 new
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_mixed_normal_and_fragment() {
|
||||
let mut events = vec![press(KeyCode::Char('h')), press(KeyCode::Char('i'))];
|
||||
events.extend(sgr_fragment("35", "261", "67", 'M'));
|
||||
events.push(press(KeyCode::Char('!')));
|
||||
let result = CsiFragmentFilter::new().filter(events);
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0], press(KeyCode::Char('h')));
|
||||
assert_eq!(result[1], press(KeyCode::Char('i')));
|
||||
assert_eq!(result[2], press(KeyCode::Char('!')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_lowercase_m_removed() {
|
||||
let events = sgr_fragment("35", "261", "67", 'm');
|
||||
assert!(CsiFragmentFilter::new().filter(events).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_non_key_events_preserved() {
|
||||
let mut events = vec![Event::Resize(80, 24)];
|
||||
events.extend(sgr_fragment("35", "261", "67", 'M'));
|
||||
events.push(Event::Resize(100, 30));
|
||||
let result = CsiFragmentFilter::new().filter(events);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert!(matches!(result[0], Event::Resize(80, 24)));
|
||||
assert!(matches!(result[1], Event::Resize(100, 30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_esc_not_immediately_before_fragment_kept() {
|
||||
let mut events = vec![press(KeyCode::Esc), press(KeyCode::Char('x'))];
|
||||
events.extend(sgr_fragment("35", "261", "67", 'M'));
|
||||
let result = CsiFragmentFilter::new().filter(events);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0], press(KeyCode::Esc));
|
||||
assert_eq!(result[1], press(KeyCode::Char('x')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_esc_and_fragment_pairs() {
|
||||
let mut events = vec![press(KeyCode::Esc)];
|
||||
events.extend(sgr_fragment("35", "261", "67", 'M'));
|
||||
events.push(press(KeyCode::Esc));
|
||||
events.extend(sgr_fragment("35", "263", "64", 'M'));
|
||||
assert!(CsiFragmentFilter::new().filter(events).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_reject_re_evaluates_bracket() {
|
||||
// [<35 then [ restarts; second fragment completes.
|
||||
let mut events = vec![
|
||||
press(KeyCode::Char('[')),
|
||||
press(KeyCode::Char('<')),
|
||||
press(KeyCode::Char('3')),
|
||||
press(KeyCode::Char('5')),
|
||||
];
|
||||
events.extend(sgr_fragment("0", "0", "0", 'M'));
|
||||
let result = CsiFragmentFilter::new().filter(events);
|
||||
assert_eq!(result.len(), 4); // [, <, 3, 5 preserved
|
||||
}
|
||||
|
||||
/// A typed `[` must be emitted in the same batch, not held until
|
||||
/// the next keystroke (which made the cursor look stuck / "laggy").
|
||||
#[test]
|
||||
fn csi_filter_lone_bracket_emitted_same_batch() {
|
||||
let mut f = CsiFragmentFilter::new();
|
||||
let first = f.filter(vec![press(KeyCode::Char('['))]);
|
||||
assert_eq!(first, vec![press(KeyCode::Char('['))]);
|
||||
// Must not carry Bracket state into the next batch.
|
||||
let second = f.filter(vec![press(KeyCode::Char('a'))]);
|
||||
assert_eq!(second, vec![press(KeyCode::Char('a'))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_min_coordinates() {
|
||||
assert!(
|
||||
CsiFragmentFilter::new()
|
||||
.filter(sgr_fragment("0", "0", "0", 'M'))
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_large_coordinates() {
|
||||
assert!(
|
||||
CsiFragmentFilter::new()
|
||||
.filter(sgr_fragment("999", "9999", "9999", 'M'))
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_empty_digit_field_kept() {
|
||||
// [<;1;1M — missing button digits, not a valid SGR fragment.
|
||||
let events = vec![
|
||||
press(KeyCode::Char('[')),
|
||||
press(KeyCode::Char('<')),
|
||||
press(KeyCode::Char(';')),
|
||||
press(KeyCode::Char('1')),
|
||||
press(KeyCode::Char(';')),
|
||||
press(KeyCode::Char('1')),
|
||||
press_shift(KeyCode::Char('M')),
|
||||
];
|
||||
let mut f = CsiFragmentFilter::new();
|
||||
let result = f.filter(events);
|
||||
// The `[` starts a potential SGR match but `;` rejects at LessThan.
|
||||
// After rejection, `;` doesn't restart, so it and remaining chars
|
||||
// pass through. The leading `[<` is flushed on reject.
|
||||
// However `[` was held in tentative while matching. Let's just
|
||||
// verify all 7 events come out (some from this call, rest flushed
|
||||
// on the follow-up).
|
||||
let result2 = f.filter(vec![]);
|
||||
let total = result.len() + result2.len();
|
||||
assert_eq!(total, 7);
|
||||
}
|
||||
|
||||
// ── Cross-batch SGR filtering tests ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn csi_filter_cross_batch_esc_then_fragment() {
|
||||
// Esc arrives in batch 1, SGR fragment chars in batch 2.
|
||||
// This is the exact scenario from the bug report.
|
||||
let mut f = CsiFragmentFilter::new();
|
||||
|
||||
// Batch 1: just the Esc
|
||||
let r1 = f.filter(vec![press(KeyCode::Esc)]);
|
||||
// Esc is emitted (can't be retracted across batches)
|
||||
assert_eq!(r1.len(), 1);
|
||||
assert_eq!(r1[0], press(KeyCode::Esc));
|
||||
|
||||
// Batch 2: the remaining SGR fragment chars
|
||||
let r2 = f.filter(sgr_fragment("64", "91", "51", 'M'));
|
||||
// Fragment is filtered — no garbage in the prompt
|
||||
assert!(r2.is_empty(), "SGR fragment chars should be filtered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_cross_batch_partial_then_rest() {
|
||||
// Fragment split mid-sequence across two batches.
|
||||
let mut f = CsiFragmentFilter::new();
|
||||
|
||||
// Batch 1: partial fragment [<64;
|
||||
let r1 = f.filter(vec![
|
||||
press(KeyCode::Char('[')),
|
||||
press(KeyCode::Char('<')),
|
||||
press(KeyCode::Char('6')),
|
||||
press(KeyCode::Char('4')),
|
||||
press(KeyCode::Char(';')),
|
||||
]);
|
||||
assert!(r1.is_empty(), "partial fragment should be held");
|
||||
|
||||
// Batch 2: remaining 91;51M — uppercase M arrives with SHIFT
|
||||
// (crossterm legacy parser sets SHIFT for uppercase chars).
|
||||
let r2 = f.filter(vec![
|
||||
press(KeyCode::Char('9')),
|
||||
press(KeyCode::Char('1')),
|
||||
press(KeyCode::Char(';')),
|
||||
press(KeyCode::Char('5')),
|
||||
press(KeyCode::Char('1')),
|
||||
press_shift(KeyCode::Char('M')),
|
||||
]);
|
||||
assert!(r2.is_empty(), "completed fragment should be filtered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_uppercase_m_with_shift_modifier() {
|
||||
// Regression test for the actual crossterm behavior: when a raw 'M' byte
|
||||
// (0x4D) arrives as a standalone character, crossterm's `char_code_to_event`
|
||||
// sets `KeyModifiers::SHIFT` because `'M'.is_uppercase()` is true.
|
||||
// The SGR filter must accept SHIFT-modified chars to catch these fragments.
|
||||
let events = vec![
|
||||
press(KeyCode::Esc),
|
||||
press(KeyCode::Char('[')),
|
||||
press(KeyCode::Char('<')),
|
||||
press(KeyCode::Char('6')),
|
||||
press(KeyCode::Char('4')),
|
||||
press(KeyCode::Char(';')),
|
||||
press(KeyCode::Char('1')),
|
||||
press(KeyCode::Char('1')),
|
||||
press(KeyCode::Char('2')),
|
||||
press(KeyCode::Char(';')),
|
||||
press(KeyCode::Char('6')),
|
||||
press(KeyCode::Char('3')),
|
||||
press_shift(KeyCode::Char('M')), // crossterm adds SHIFT for uppercase
|
||||
];
|
||||
let result = CsiFragmentFilter::new().filter(events);
|
||||
assert!(
|
||||
result.is_empty(),
|
||||
"SGR fragment with SHIFT on 'M' must be filtered, got {} events",
|
||||
result.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_many_uppercase_m_fragments() {
|
||||
let mut events = Vec::new();
|
||||
for _ in 0..20 {
|
||||
events.push(press(KeyCode::Esc));
|
||||
events.extend(sgr_fragment("64", "112", "63", 'M'));
|
||||
}
|
||||
let result = CsiFragmentFilter::new().filter(events);
|
||||
assert!(
|
||||
result.is_empty(),
|
||||
"all SGR fragments with SHIFT-M must be filtered, got {} events",
|
||||
result.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_cross_batch_partial_then_reject() {
|
||||
// Partial fragment in batch 1, rejected in batch 2.
|
||||
let mut f = CsiFragmentFilter::new();
|
||||
|
||||
// Batch 1: [<6
|
||||
let r1 = f.filter(vec![
|
||||
press(KeyCode::Char('[')),
|
||||
press(KeyCode::Char('<')),
|
||||
press(KeyCode::Char('6')),
|
||||
]);
|
||||
assert!(r1.is_empty(), "partial should be held");
|
||||
|
||||
// Batch 2: starts with 'a' which rejects the match
|
||||
let r2 = f.filter(vec![press(KeyCode::Char('a'))]);
|
||||
// Held events + new event are all emitted
|
||||
assert_eq!(r2.len(), 4); // [, <, 6, a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_cross_batch_multiple_scroll_events() {
|
||||
// Multiple rapid scroll events split across batches (the exact
|
||||
// bug scenario: scrolling during worktree creation).
|
||||
let mut f = CsiFragmentFilter::new();
|
||||
|
||||
// Batch 1: Esc from first scroll
|
||||
let r1 = f.filter(vec![press(KeyCode::Esc)]);
|
||||
assert_eq!(r1.len(), 1); // Esc emitted
|
||||
|
||||
// Batch 2: fragment + Esc + fragment (two scroll events)
|
||||
let mut batch2 = sgr_fragment("64", "91", "51", 'M');
|
||||
batch2.push(press(KeyCode::Esc));
|
||||
batch2.extend(sgr_fragment("64", "91", "51", 'M'));
|
||||
let r2 = f.filter(batch2);
|
||||
assert!(r2.is_empty(), "all fragments and Esc should be filtered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_cross_batch_one_event_at_a_time() {
|
||||
// A lone typed `[` must not be held across batches, so
|
||||
// one-event-per-batch delivery of `[` alone is emitted (not filtered).
|
||||
// Real leaked fragments deliver `[<…` in the same read/batch; verify
|
||||
// that shape still filters when split only after `[<` is established.
|
||||
let mut f = CsiFragmentFilter::new();
|
||||
|
||||
let r = f.filter(vec![press(KeyCode::Esc)]);
|
||||
assert_eq!(r.len(), 1);
|
||||
|
||||
// Lone `[` batch — user input path, not held.
|
||||
let r = f.filter(vec![press(KeyCode::Char('['))]);
|
||||
assert_eq!(r, vec![press(KeyCode::Char('['))]);
|
||||
|
||||
// Same-batch partial after `[<` is still held across batches.
|
||||
let mut f2 = CsiFragmentFilter::new();
|
||||
let partial = vec![
|
||||
press(KeyCode::Char('[')),
|
||||
press(KeyCode::Char('<')),
|
||||
press(KeyCode::Char('6')),
|
||||
press(KeyCode::Char('4')),
|
||||
];
|
||||
assert!(f2.filter(partial).is_empty());
|
||||
let rest = vec![
|
||||
press(KeyCode::Char(';')),
|
||||
press(KeyCode::Char('9')),
|
||||
press(KeyCode::Char('1')),
|
||||
press(KeyCode::Char(';')),
|
||||
press(KeyCode::Char('5')),
|
||||
press(KeyCode::Char('1')),
|
||||
press(KeyCode::Char('M')),
|
||||
];
|
||||
assert!(
|
||||
f2.filter(rest).is_empty(),
|
||||
"completing fragment should discard"
|
||||
);
|
||||
}
|
||||
|
||||
// ── CSI focus report filtering tests ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn csi_filter_focus_in_after_esc_translated() {
|
||||
// Split \e[I focus-in (Esc, [, I — uppercase I arrives with SHIFT) is
|
||||
// reassembled into a FocusGained event, not dropped.
|
||||
let events = vec![
|
||||
press(KeyCode::Esc),
|
||||
press(KeyCode::Char('[')),
|
||||
press_shift(KeyCode::Char('I')),
|
||||
];
|
||||
assert_eq!(
|
||||
CsiFragmentFilter::new().filter(events),
|
||||
vec![Event::FocusGained]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_focus_out_after_esc_translated() {
|
||||
// Split \e[O focus-out (Esc, [, O — uppercase O arrives with SHIFT) is
|
||||
// reassembled into a FocusLost event, not dropped.
|
||||
let events = vec![
|
||||
press(KeyCode::Esc),
|
||||
press(KeyCode::Char('[')),
|
||||
press_shift(KeyCode::Char('O')),
|
||||
];
|
||||
assert_eq!(
|
||||
CsiFragmentFilter::new().filter(events),
|
||||
vec![Event::FocusLost]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_typed_bracket_i_kept() {
|
||||
// Typed `[I` (e.g. arr[I]) has no preceding bare Esc — pass through.
|
||||
let events = vec![press(KeyCode::Char('[')), press(KeyCode::Char('I'))];
|
||||
let result = CsiFragmentFilter::new().filter(events);
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![press(KeyCode::Char('[')), press(KeyCode::Char('I'))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_typed_bracket_o_kept() {
|
||||
// Typed `[O` has no preceding bare Esc — pass through.
|
||||
let events = vec![press(KeyCode::Char('[')), press_shift(KeyCode::Char('O'))];
|
||||
let result = CsiFragmentFilter::new().filter(events);
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![press(KeyCode::Char('[')), press_shift(KeyCode::Char('O'))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_ss3_not_eaten() {
|
||||
// SS3 \eOA has no `[`, so it never enters Bracket — leave it intact.
|
||||
let events = vec![
|
||||
press(KeyCode::Esc),
|
||||
press(KeyCode::Char('O')),
|
||||
press(KeyCode::Char('A')),
|
||||
];
|
||||
let result = CsiFragmentFilter::new().filter(events);
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![
|
||||
press(KeyCode::Esc),
|
||||
press(KeyCode::Char('O')),
|
||||
press(KeyCode::Char('A')),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_focus_among_normal_keys() {
|
||||
// Split \e[I focus-in surrounded by typed keys — keys survive and the
|
||||
// report is translated to FocusGained in place.
|
||||
let events = vec![
|
||||
press(KeyCode::Char('a')),
|
||||
press(KeyCode::Esc),
|
||||
press(KeyCode::Char('[')),
|
||||
press_shift(KeyCode::Char('I')),
|
||||
press(KeyCode::Char('b')),
|
||||
];
|
||||
let result = CsiFragmentFilter::new().filter(events);
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![
|
||||
press(KeyCode::Char('a')),
|
||||
Event::FocusGained,
|
||||
press(KeyCode::Char('b')),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_cross_batch_focus_not_retracted() {
|
||||
// known limitation: only a same-batch report is reassembled (and translated); one split across drain batches still leaks, since a lone Esc can't be held across batches
|
||||
let mut f = CsiFragmentFilter::new();
|
||||
// Batch 1: lone Esc is emitted (a lone Esc can't be held across batches).
|
||||
let r1 = f.filter(vec![press(KeyCode::Esc)]);
|
||||
assert_eq!(r1, vec![press(KeyCode::Esc)]);
|
||||
// Batch 2: `[` then SHIFT-I come through — the focus report is not retracted.
|
||||
let r2 = f.filter(vec![
|
||||
press(KeyCode::Char('[')),
|
||||
press_shift(KeyCode::Char('I')),
|
||||
]);
|
||||
assert_eq!(
|
||||
r2,
|
||||
vec![press(KeyCode::Char('[')), press_shift(KeyCode::Char('I'))]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
//! Login, logout, account switching, and auth-code submission dispatchers.
|
||||
|
||||
use super::ctx::{restore_auth_return_view, show_welcome};
|
||||
use super::queue::maybe_drain_queue;
|
||||
use super::router::dispatch;
|
||||
use super::session::lifecycle::{clear_startup_actions, drain_startup_actions};
|
||||
use crate::app::actions::{Action, Effect};
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::SessionEvent;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `/logout` -- ask the shell to clear auth, then return to the login screen.
|
||||
pub(super) fn dispatch_logout(_app: &mut AppView) -> Vec<Effect> {
|
||||
vec![Effect::Logout]
|
||||
}
|
||||
|
||||
/// Ensure `login_method_id` is populated from stored auth methods.
|
||||
/// On the eager-auth path (cached token), login_method_id is never set
|
||||
/// because the user skipped the login screen.
|
||||
///
|
||||
/// Does **not** invent `grok.com` when no interactive method is advertised
|
||||
/// (e.g. `preferred_method=api_key` with no key — empty `auth_methods`).
|
||||
/// Callers already surface "No login method available" when this leaves
|
||||
/// `login_method_id` unset.
|
||||
pub(super) fn ensure_login_method(app: &mut AppView) {
|
||||
if app.login_method_id.is_some() {
|
||||
return;
|
||||
}
|
||||
let (label, method_id, start_mode) =
|
||||
crate::acp::find_interactive_login_method(&app.auth_methods);
|
||||
if let Some(id) = method_id {
|
||||
app.login_label = label;
|
||||
app.login_method_id = Some(id);
|
||||
app.auth_start_mode = match start_mode {
|
||||
crate::acp::AuthStartMode::Pending => AuthMode::Pending,
|
||||
crate::acp::AuthStartMode::Command => AuthMode::Command,
|
||||
};
|
||||
}
|
||||
// No interactive method: leave login_method_id unset (fail-closed).
|
||||
}
|
||||
|
||||
/// Error when no interactive login method is available (empty auth_methods,
|
||||
/// e.g. `preferred_method=api_key` with no credentials). Prefer the shell's
|
||||
/// pin-unavailable copy when the list is empty.
|
||||
fn no_login_method_error(app: &AppView) -> String {
|
||||
if app.auth_methods.is_empty() {
|
||||
kigi_shell::agent::auth_method::PREFERRED_API_KEY_UNAVAILABLE.to_string()
|
||||
} else {
|
||||
"No login method available".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Log out, then start a new login flow in a single sequential task.
|
||||
pub(super) fn dispatch_switch_account(app: &mut AppView) -> Vec<Effect> {
|
||||
ensure_login_method(app);
|
||||
|
||||
let Some(method_id) = app.login_method_id.clone() else {
|
||||
app.auth_state = AuthState::Pending {
|
||||
error: Some(no_login_method_error(app)),
|
||||
};
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let request_seq = app.next_auth_request_seq;
|
||||
app.next_auth_request_seq += 1;
|
||||
app.auth_code_input.clear();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: app.auth_start_mode,
|
||||
};
|
||||
|
||||
vec![
|
||||
Effect::SwitchAccount {
|
||||
request_seq,
|
||||
method_id,
|
||||
use_oauth: app.auth_use_oauth,
|
||||
},
|
||||
Effect::PollAuthUrl { request_seq },
|
||||
]
|
||||
}
|
||||
|
||||
/// Scan the trailing run of session-event / system blocks for a
|
||||
/// [`SessionEvent::ReAuthRequired`] prompt. Used by the `PromptResponse`
|
||||
/// handler to suppress the redundant "Turn failed" block after a 401 — the
|
||||
/// re-auth prompt is pushed by the `RetryState` handler, which runs first.
|
||||
pub(super) fn scrollback_has_recent_reauth_prompt(
|
||||
scrollback: &crate::scrollback::state::ScrollbackState,
|
||||
) -> bool {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
for idx in (0..scrollback.len()).rev() {
|
||||
match scrollback.entry(idx).map(|e| &e.block) {
|
||||
Some(RenderBlock::SessionEvent(ev)) => {
|
||||
if matches!(ev.event, SessionEvent::ReAuthRequired) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Tolerate interleaved system messages in the trailing run.
|
||||
Some(RenderBlock::System(_)) => {}
|
||||
// Stop at the first substantive block: any re-auth prompt for
|
||||
// this turn lives in the trailing events pushed just before the
|
||||
// PromptResponse arrived.
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// True if the trailing run of session/system blocks contains a terminal
|
||||
/// context-overflow block ([`SessionEvent::ContextTooLarge`] or `CompactionFailed`).
|
||||
/// Lets `PromptResponse` suppress the redundant `TurnFailed`, mirroring reauth.
|
||||
pub(super) fn scrollback_has_recent_context_too_large(
|
||||
scrollback: &crate::scrollback::state::ScrollbackState,
|
||||
) -> bool {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
for idx in (0..scrollback.len()).rev() {
|
||||
match scrollback.entry(idx).map(|e| &e.block) {
|
||||
Some(RenderBlock::SessionEvent(ev)) => {
|
||||
if matches!(
|
||||
ev.event,
|
||||
SessionEvent::ContextTooLarge | SessionEvent::CompactionFailed { .. }
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Tolerate interleaved system messages in the trailing run.
|
||||
Some(RenderBlock::System(_)) => {}
|
||||
// Stop at the first substantive block.
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Strip the trailing run of auth-error blocks — the `ReAuthRequired`
|
||||
/// prompt plus any stale `RetryFailed` / `TurnFailed` — from an agent's
|
||||
/// scrollback. Called after a successful mid-session re-auth so the prompt
|
||||
/// disappears once the user returns to the session. Mirrors the
|
||||
/// credit-limit upsell's stale-block strip.
|
||||
pub(super) fn strip_trailing_auth_error_blocks(agent: &mut AgentView) {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
let mut to_remove = Vec::new();
|
||||
for idx in (0..agent.scrollback.len()).rev() {
|
||||
match agent.scrollback.entry(idx).map(|e| &e.block) {
|
||||
Some(RenderBlock::SessionEvent(ev))
|
||||
if matches!(
|
||||
&ev.event,
|
||||
SessionEvent::ReAuthRequired
|
||||
| SessionEvent::RetryFailed { .. }
|
||||
| SessionEvent::TurnFailed { .. }
|
||||
) =>
|
||||
{
|
||||
to_remove.push(idx);
|
||||
}
|
||||
// Skip over other trailing session-event / system blocks.
|
||||
Some(RenderBlock::SessionEvent(_) | RenderBlock::System(_)) => continue,
|
||||
// Stop at the first substantive block.
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
for idx in to_remove {
|
||||
agent.scrollback.remove_from(idx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start an interactive login flow. Triggered by pressing 'l' on the
|
||||
/// welcome screen or by the `/login` slash command.
|
||||
///
|
||||
/// When invoked mid-session (the active view is an agent/dashboard rather
|
||||
/// than the welcome screen), the auth UI — including the external auth
|
||||
/// provider's sign-in URL and status — is only rendered by the welcome
|
||||
/// view. We therefore stash the caller's view in `auth_return_view` and
|
||||
/// switch to `Welcome` so the flow is actually visible; the prior view is
|
||||
/// restored once auth completes or is cancelled. Without this, `/login`
|
||||
/// with an external auth provider configured appeared to do nothing.
|
||||
pub(super) fn dispatch_login(app: &mut AppView) -> Vec<Effect> {
|
||||
ensure_login_method(app);
|
||||
let Some(method_id) = app.login_method_id.clone() else {
|
||||
app.auth_state = AuthState::Pending {
|
||||
error: Some(no_login_method_error(app)),
|
||||
};
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Surface the auth UI when triggered from inside a session. `show_welcome`
|
||||
// resets ephemeral state here, covering the AuthComplete / cancel-login
|
||||
// fallbacks too (`auth_return_view` is only ever set here).
|
||||
if !matches!(app.active_view, ActiveView::Welcome) {
|
||||
app.auth_return_view = Some(app.active_view);
|
||||
show_welcome(app);
|
||||
}
|
||||
|
||||
let request_seq = app.next_auth_request_seq;
|
||||
app.next_auth_request_seq += 1;
|
||||
app.auth_code_input.clear();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: app.auth_start_mode,
|
||||
};
|
||||
|
||||
vec![
|
||||
Effect::Authenticate {
|
||||
request_seq,
|
||||
method_id,
|
||||
use_oauth: app.auth_use_oauth,
|
||||
force_interactive: true,
|
||||
},
|
||||
Effect::PollAuthUrl { request_seq },
|
||||
]
|
||||
}
|
||||
|
||||
/// Cancel a login that was started from inside a session and restore the
|
||||
/// caller's view. Only meaningful when `auth_return_view` is set (a
|
||||
/// mid-session `/login` or 401 re-auth prompt). Any in-flight auth task is
|
||||
/// left to finish in the background — its `AuthComplete`/`AuthFailed`
|
||||
/// result is ignored because we move `auth_state` out of `Authenticating`
|
||||
/// here (the request-seq/state guard in those handlers drops stale results)
|
||||
/// and bump the seq so a fresh login does not collide.
|
||||
pub(super) fn dispatch_cancel_login(app: &mut AppView) -> Vec<Effect> {
|
||||
let Some(return_view) = app.auth_return_view.take() else {
|
||||
return vec![];
|
||||
};
|
||||
app.next_auth_request_seq += 1;
|
||||
app.auth_state = AuthState::Done;
|
||||
app.auth_show_raw_url = false;
|
||||
app.auth_code_input.clear();
|
||||
restore_auth_return_view(app, return_view);
|
||||
// The user bailed out of re-auth — drop stashed prompts and strip the
|
||||
// stale re-auth prompt from scrollback (on all agents: the login may
|
||||
// have been started from the dashboard). Clearing the stash alone is
|
||||
// not enough: a leftover `ReAuthRequired` block would let a later
|
||||
// `PromptResponse` re-detect it via `scrollback_has_recent_reauth_prompt`
|
||||
// and re-stash the prompt, so a subsequent unrelated login could
|
||||
// silently resubmit it. Mirrors the strip in the `AuthComplete` path.
|
||||
for agent in app.agents.values_mut() {
|
||||
agent.reauth_stashed_prompt = None;
|
||||
strip_trailing_auth_error_blocks(agent);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// User submitted a manually-pasted auth token in loopback mode.
|
||||
pub(super) fn dispatch_submit_auth_code(app: &mut AppView, code: String) -> Vec<Effect> {
|
||||
let request_seq = match &app.auth_state {
|
||||
AuthState::Authenticating { request_seq, .. } => *request_seq,
|
||||
_ => return vec![],
|
||||
};
|
||||
|
||||
vec![Effect::SubmitAuthCode { request_seq, code }]
|
||||
}
|
||||
|
||||
// TaskResult handlers.
|
||||
|
||||
pub(super) fn handle_auth_complete(
|
||||
app: &mut AppView,
|
||||
request_seq: u64,
|
||||
meta: Option<serde_json::Value>,
|
||||
) -> Vec<Effect> {
|
||||
if let AuthState::Authenticating {
|
||||
request_seq: current_seq,
|
||||
..
|
||||
} = &app.auth_state
|
||||
&& *current_seq == request_seq
|
||||
{
|
||||
if let Some(meta_val) = meta.as_ref()
|
||||
&& let Ok(auth_meta) =
|
||||
serde_json::from_value::<kigi_shell::auth::AuthMeta>(meta_val.clone())
|
||||
{
|
||||
app.apply_auth_meta(&auth_meta);
|
||||
}
|
||||
|
||||
app.auth_state = AuthState::Done;
|
||||
app.auth_show_raw_url = false;
|
||||
app.welcome_prompt_focused = !app.is_access_blocked();
|
||||
app.auth_code_input.clear();
|
||||
|
||||
// Mid-session re-auth (`/login` or a 401 prompt): restore the
|
||||
// view the user was on instead of running the startup
|
||||
// load-session flow. The session state lives in `app.agents`,
|
||||
// independent of `active_view`, so it is preserved across the
|
||||
// auth detour.
|
||||
if let Some(return_view) = app.auth_return_view.take() {
|
||||
restore_auth_return_view(app, return_view);
|
||||
// Mid-session re-auth returns to the existing session, NOT
|
||||
// the startup flow, so discard any deferred startup stash
|
||||
// (e.g. an incidental `Ctrl+N` pressed during /login that the
|
||||
// chokepoint deferred) rather than leaving it to fire later.
|
||||
clear_startup_actions(app);
|
||||
// Re-auth succeeded — hide the now-stale re-auth prompt
|
||||
// (and any trailing error blocks) so the user returns to
|
||||
// a clean session. Mirrors the credit-limit upsell's
|
||||
// stale-block strip.
|
||||
// Auth is global, so handle every agent (the login may
|
||||
// have been started from the dashboard, not the agent
|
||||
// that 401'd).
|
||||
let mut retry_effects = Vec::new();
|
||||
for agent in app.agents.values_mut() {
|
||||
strip_trailing_auth_error_blocks(agent);
|
||||
// Auto-resubmit the prompt that failed on the expired
|
||||
// login so the user doesn't have to retype it. The
|
||||
// user couldn't have queued another prompt during the
|
||||
// auth detour, so a plain front-enqueue + drain is safe.
|
||||
if let Some(prompt) = agent.reauth_stashed_prompt.take() {
|
||||
agent.scrollback.push_block(RenderBlock::system(
|
||||
"Re-authenticated. Retrying\u{2026}".to_string(),
|
||||
));
|
||||
agent.session.enqueue_in_flight_prompt_front(prompt);
|
||||
retry_effects.extend(maybe_drain_queue(agent));
|
||||
}
|
||||
}
|
||||
let mut effects = dispatch(Action::RequestBundleStatus, app);
|
||||
if app.usage_visible {
|
||||
effects.push(Effect::FetchAppBilling);
|
||||
}
|
||||
effects.extend(retry_effects);
|
||||
return effects;
|
||||
}
|
||||
|
||||
// status only; shell auto-syncs post-auth
|
||||
let mut effects = dispatch(Action::RequestBundleStatus, app);
|
||||
|
||||
// Start auto-checking subscription if gated.
|
||||
// Check immediately (don't wait 5s) then schedule the timer.
|
||||
if !app.has_access() {
|
||||
app.paywall_check_started = Some(std::time::Instant::now());
|
||||
effects.push(Effect::CheckSubscription { verify: None });
|
||||
effects.push(Effect::SchedulePaywallCheck);
|
||||
}
|
||||
// Fetch billing so the welcome screen can show a credit warning.
|
||||
if app.usage_visible {
|
||||
effects.push(Effect::FetchAppBilling);
|
||||
}
|
||||
// Fetch changelog (mirrors startup path for interactive login).
|
||||
effects.push(Effect::FetchChangelog);
|
||||
|
||||
// ZDR-blocked users stay on the welcome screen — discard any
|
||||
// deferred startup (they cannot start a session).
|
||||
if app.is_zdr_blocked() {
|
||||
clear_startup_actions(app);
|
||||
return effects;
|
||||
}
|
||||
|
||||
// Replay deferred session startup once BOTH gates are open. Auth
|
||||
// is now Done, so `session_startup_allowed()` here means "is trust
|
||||
// also resolved?" -- if trust is still Pending its question renders
|
||||
// next and its answer drains instead. Same predicate the trust
|
||||
// handlers use, so the deferred startup runs exactly once after
|
||||
// whichever gate resolves last.
|
||||
if app.session_startup_allowed() {
|
||||
effects.extend(drain_startup_actions(app));
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_auth_url_ready(
|
||||
app: &mut AppView,
|
||||
request_seq: u64,
|
||||
auth_url: Option<String>,
|
||||
external: bool,
|
||||
mode: Option<String>,
|
||||
) -> Vec<Effect> {
|
||||
if let AuthState::Authenticating {
|
||||
request_seq: current_seq,
|
||||
auth_url: current_url,
|
||||
mode: current_mode,
|
||||
..
|
||||
} = &mut app.auth_state
|
||||
&& *current_seq == request_seq
|
||||
{
|
||||
*current_url = auth_url;
|
||||
// Prefer `mode`; fall back to `external` for older agents. An
|
||||
// old-agent device login lands on Loopback (harmless paste box;
|
||||
// the background poll still completes).
|
||||
*current_mode = match mode.as_deref() {
|
||||
Some("device") => AuthMode::Device,
|
||||
Some("command") => AuthMode::Command,
|
||||
Some("loopback") => AuthMode::Loopback,
|
||||
_ if external => AuthMode::Command,
|
||||
_ => AuthMode::Loopback,
|
||||
};
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_mcp_auth_trigger_done(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
server_name: String,
|
||||
result: Result<(), String>,
|
||||
) -> Vec<Effect> {
|
||||
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
if let Some(ref mut modal) = agent.extensions_modal {
|
||||
modal.pending_action = None;
|
||||
modal.pending_entry_index = None;
|
||||
if let Err(e) = result {
|
||||
// String-match heuristic: directive vs name-embedded vs generic.
|
||||
// Brittle if the shell ever quotes a name shape that doesn't
|
||||
// match `server_name` here — replace with a structured
|
||||
// discriminator on McpAuthTriggerResponse if that happens.
|
||||
let msg = if e.starts_with("To authenticate") {
|
||||
format!("{server_name}: {e}")
|
||||
} else if e.contains(&server_name) {
|
||||
format!("Auth failed: {e}")
|
||||
} else {
|
||||
format!("{server_name} auth failed: {e}")
|
||||
};
|
||||
modal.modal_message = Some(crate::views::extensions_modal::ModalMessage::Error(msg));
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
// No toast on success: the row transition from the FetchMcpsList
|
||||
// refresh below is the confirmation.
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
return vec![];
|
||||
};
|
||||
vec![Effect::FetchMcpsList {
|
||||
agent_id,
|
||||
session_id,
|
||||
cache: false,
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
//! Subscription tier checks, credit-limit upsells, and auto-topup handling.
|
||||
|
||||
use super::queue::maybe_drain_queue;
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::AppView;
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long the pager auto-checks subscription status before stopping.
|
||||
/// After this, the user can still manually check via the [Refresh] button.
|
||||
pub(super) const PAYWALL_AUTO_CHECK_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// Whether the user is at the highest subscription tier (SuperGrok Heavy).
|
||||
///
|
||||
/// Returns `true` only when `subscription_tier` **positively matches** a
|
||||
/// known max-tier identifier. When the tier is unknown (`None`) or any
|
||||
/// other value, returns `false` — the user gets the Q&A modal so lower-
|
||||
/// tier users always see the upgrade option.
|
||||
pub(super) fn is_max_tier(subscription_tier: Option<&str>) -> bool {
|
||||
let Some(t) = subscription_tier else {
|
||||
return false; // Unknown — default to Q&A.
|
||||
};
|
||||
// Normalize: lowercase + spaces→underscores to match both JWT-derived
|
||||
// keys ("supergrok_heavy") and CCP display names ("SuperGrok Heavy").
|
||||
t.to_ascii_lowercase().replace(' ', "_") == "supergrok_heavy"
|
||||
}
|
||||
|
||||
/// URL for upgrading the subscription tier.
|
||||
pub(crate) const UPSELL_URL_UPGRADE: &str = "https://grok.com/supergrok?referrer=grok-build";
|
||||
|
||||
/// URL for managing pay-as-you-go / on-demand spending / purchasing credits.
|
||||
pub(crate) const UPSELL_URL_PAYG: &str = "https://grok.com?_s=usage";
|
||||
|
||||
/// Billing mode for credit-limit upsell copy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum CreditLimitUpsellMode {
|
||||
/// Unified usage pool — suggest purchasing prepaid credits.
|
||||
UnifiedCredits,
|
||||
/// Legacy on-demand / PAYG (`enabled` = on-demand cap already active).
|
||||
LegacyPayg { enabled: bool },
|
||||
}
|
||||
|
||||
/// Resolve upsell copy mode from credits config.
|
||||
///
|
||||
/// Prefers explicit `is_unified_billing_user` (`Option` — do not treat a
|
||||
/// missing field as legacy). Positive `pay_as_you_go` (on-demand cap > 0)
|
||||
/// only selects legacy when the unified flag is absent. Unknown defaults to
|
||||
/// unified (buy credits) so pool users never get “enable on-demand” wrongly.
|
||||
pub(super) fn credit_limit_upsell_mode(
|
||||
balance: Option<&crate::views::credit_bar::CreditBalance>,
|
||||
) -> CreditLimitUpsellMode {
|
||||
match balance {
|
||||
Some(b) if b.is_unified_billing_user == Some(true) => CreditLimitUpsellMode::UnifiedCredits,
|
||||
Some(b) if b.is_unified_billing_user == Some(false) => CreditLimitUpsellMode::LegacyPayg {
|
||||
enabled: b.pay_as_you_go,
|
||||
},
|
||||
// Flag absent: only treat as legacy PAYG when we have a positive
|
||||
// on-demand cap (pay_as_you_go is derived from cap > 0).
|
||||
Some(b) if b.pay_as_you_go => CreditLimitUpsellMode::LegacyPayg { enabled: true },
|
||||
_ => CreditLimitUpsellMode::UnifiedCredits,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an API / retry error is a credit-limit / spend-block denial.
|
||||
///
|
||||
/// - **402** Payment Required — always credit/spend block on this surface
|
||||
/// (Build pool and IC spend blocks); no message filter.
|
||||
/// - **403** — only when the body contains "run out of credits" (legacy IC
|
||||
/// spend wording); other 403s (content-safety, ZDR, …) are excluded.
|
||||
pub(crate) fn is_credit_limit_error(http_status: Option<u16>, message: &str) -> bool {
|
||||
let m = message.to_ascii_lowercase();
|
||||
let legacy = m.contains("run out of credits");
|
||||
match http_status {
|
||||
Some(402) => true,
|
||||
Some(403) if legacy => true,
|
||||
// Retry notifications embed "status 402" / "status 403" in the body
|
||||
// without a separate status field.
|
||||
None | Some(_) => m.contains("status 402") || (m.contains("status 403") && legacy),
|
||||
}
|
||||
}
|
||||
|
||||
/// Well-known error code CCP returns (HTTP 429, flat body
|
||||
/// `{"code": "...", "error": "..."}`) when a free-tier user exhausts the
|
||||
/// free usage quota. Kept in sync with the shared well-known error code
|
||||
/// `SUBSCRIPTION_FREE_USAGE_EXHAUSTED`. sampling-types' `parse_error_bytes` prepends the flat
|
||||
/// `code` to the flattened message, so the code reaches the pager embedded
|
||||
/// in `RetryState::Exhausted.reason` and the -32003 error's data string.
|
||||
pub(crate) const FREE_USAGE_EXHAUSTED_ERROR_CODE: &str = "subscription:free-usage-exhausted";
|
||||
|
||||
/// Whether a rate-limit error is the free-usage-quota exhaustion (paywall)
|
||||
/// rather than transient throttling. Text-sniff on the flattened message,
|
||||
/// same precedent as [`is_credit_limit_error`].
|
||||
pub(crate) fn is_free_usage_exhausted_error(reason: &str) -> bool {
|
||||
reason.contains(FREE_USAGE_EXHAUSTED_ERROR_CODE)
|
||||
}
|
||||
|
||||
/// Whether a rate-limited (-32003) ACP error is the free-usage exhaustion.
|
||||
/// `data` may be a bare string or the `{message, promptUsage?}` object
|
||||
/// `attach_prompt_usage` produces — always read via the shared detail helper.
|
||||
pub(crate) fn acp_error_is_free_usage_exhausted(err: &agent_client_protocol::Error) -> bool {
|
||||
err.data
|
||||
.as_ref()
|
||||
.and_then(kigi_shell::sampling::error::error_detail_from_data)
|
||||
.as_deref()
|
||||
.is_some_and(is_free_usage_exhausted_error)
|
||||
}
|
||||
|
||||
/// User-facing message for free-usage exhaustion. Shown by headless mode and
|
||||
/// `format_acp_error` in place of auth-aware rate-limit copy. Deliberately
|
||||
/// promises no reset duration — the quota window is backend-config-driven.
|
||||
pub(crate) const FREE_USAGE_USER_MESSAGE: &str = "You\u{2019}ve reached your free Grok Build usage limit for now. Get SuperGrok for much higher limits, or try again later: https://grok.com/supergrok?referrer=grok-build";
|
||||
|
||||
/// Open the credit-limit upsell on the given agent.
|
||||
///
|
||||
/// **`max_tier = false`** (default): shows the Q&A question modal with
|
||||
/// two options ("Upgrade tier" + buy-credits or PAYG). Each option's `id`
|
||||
/// carries the target URL so the submit handler is position-independent.
|
||||
///
|
||||
/// **`max_tier = true`** (positively identified as SuperGrok Heavy):
|
||||
/// pushes an inline scrollback card (`CreditLimitBlock`) with a single
|
||||
/// continue action. No Q&A modal — the user can't upgrade further.
|
||||
pub(super) fn open_credit_limit_upsell(
|
||||
agent: &mut AgentView,
|
||||
mode: CreditLimitUpsellMode,
|
||||
max_tier: bool,
|
||||
) {
|
||||
use crate::scrollback::blocks::CreditLimitCardAction;
|
||||
|
||||
let (heading, upgrade_tier_desc, secondary_label, secondary_desc, card_action): (
|
||||
&str,
|
||||
&str,
|
||||
&str,
|
||||
&str,
|
||||
CreditLimitCardAction,
|
||||
) = match mode {
|
||||
CreditLimitUpsellMode::UnifiedCredits => (
|
||||
"You hit your weekly limit.",
|
||||
"Upgrade to a higher tier for more usage",
|
||||
"Buy more credits",
|
||||
"Purchase credits to keep using Grok Build",
|
||||
CreditLimitCardAction::PurchaseCredits,
|
||||
),
|
||||
CreditLimitUpsellMode::LegacyPayg { enabled: true } => (
|
||||
"You\u{2019}ve hit your spending cap.",
|
||||
"Upgrade to a higher tier for more credits",
|
||||
"Increase limit",
|
||||
"Raise your pay-as-you-go spending cap",
|
||||
CreditLimitCardAction::IncreasePaygLimit,
|
||||
),
|
||||
CreditLimitUpsellMode::LegacyPayg { enabled: false } => (
|
||||
"You\u{2019}ve hit the credit limit for your plan.",
|
||||
"Upgrade to a higher tier for more credits",
|
||||
"Pay as you go",
|
||||
"Enable pay-as-you-go credits for on-demand usage",
|
||||
CreditLimitCardAction::EnablePayg,
|
||||
),
|
||||
};
|
||||
|
||||
// ── Max tier: inline scrollback card ─────────────────────────
|
||||
if max_tier {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
agent.scrollback.push_block(RenderBlock::credit_limit_card(
|
||||
heading,
|
||||
card_action,
|
||||
UPSELL_URL_PAYG,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Default: Q&A question modal with two options ────────────────
|
||||
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
|
||||
use kigi_tools::implementations::grok_build::ask_user_question::{Question, QuestionOption};
|
||||
|
||||
if agent.question_view.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let question = Question {
|
||||
question: heading.into(),
|
||||
options: vec![
|
||||
QuestionOption {
|
||||
label: "Upgrade tier".into(),
|
||||
description: upgrade_tier_desc.into(),
|
||||
preview: None,
|
||||
id: Some(UPSELL_URL_UPGRADE.into()),
|
||||
},
|
||||
QuestionOption {
|
||||
label: secondary_label.into(),
|
||||
description: secondary_desc.into(),
|
||||
preview: None,
|
||||
id: Some(UPSELL_URL_PAYG.into()),
|
||||
},
|
||||
],
|
||||
multi_select: Some(false),
|
||||
id: None,
|
||||
};
|
||||
|
||||
let stashed = agent.prompt.stash();
|
||||
let state = QuestionViewState::new(
|
||||
format!("credit-limit-upsell-{}", uuid::Uuid::new_v4()),
|
||||
vec![question],
|
||||
stashed,
|
||||
)
|
||||
.with_local_kind(LocalQuestionKind::CreditLimitUpsell)
|
||||
.with_no_freeform();
|
||||
agent.question_view = Some(state);
|
||||
agent.prompt.set_text("");
|
||||
}
|
||||
|
||||
/// Open the free-usage paywall on the given agent: a Q&A modal in the
|
||||
/// [`open_credit_limit_upsell`] style with two upgrade options. Each
|
||||
/// option's `id` carries its target URL so the submit handler is
|
||||
/// position-independent.
|
||||
///
|
||||
/// Driver-only by construction (called from the PromptResponse handler,
|
||||
/// which viewers never receive).
|
||||
pub(super) fn open_free_usage_upsell(agent: &mut AgentView) {
|
||||
open_supergrok_upsell(agent, UpsellReason::FreeUsageLimit);
|
||||
}
|
||||
|
||||
/// Open the SuperGrok upsell for a tier-restricted slash command
|
||||
/// (`/usage`, `/imagine`, …). Returns whether the modal opened (`false`
|
||||
/// when another question modal is already up) so the caller can decide
|
||||
/// whether to consume the input that triggered it.
|
||||
pub(super) fn open_restricted_command_upsell(agent: &mut AgentView) -> bool {
|
||||
open_supergrok_upsell(agent, UpsellReason::RestrictedCommand)
|
||||
}
|
||||
|
||||
/// Which situation opened the SuperGrok upsell modal. Controls the heading.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum UpsellReason {
|
||||
/// Free-usage quota exhausted (429 paywall).
|
||||
FreeUsageLimit,
|
||||
/// A tier-restricted slash command was invoked.
|
||||
RestrictedCommand,
|
||||
}
|
||||
|
||||
/// Shared builder behind [`open_free_usage_upsell`] /
|
||||
/// [`open_restricted_command_upsell`]: a Q&A modal in the
|
||||
/// [`open_credit_limit_upsell`] style. Upgrade options carry their target
|
||||
/// URL in the option `id` (position-independent submit handling).
|
||||
fn open_supergrok_upsell(agent: &mut AgentView, reason: UpsellReason) -> bool {
|
||||
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
|
||||
use kigi_tools::implementations::grok_build::ask_user_question::{Question, QuestionOption};
|
||||
|
||||
// Never displace an already-open question modal. Callers that consume
|
||||
// input on open must check this `false` and keep the input instead.
|
||||
if agent.question_view.is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let (heading, modal_id_prefix) = match reason {
|
||||
UpsellReason::FreeUsageLimit => ("You hit your free usage limit.", "free-usage-upsell"),
|
||||
UpsellReason::RestrictedCommand => (
|
||||
"Unlock all features with SuperGrok.",
|
||||
"restricted-command-upsell",
|
||||
),
|
||||
};
|
||||
|
||||
let options = vec![
|
||||
QuestionOption {
|
||||
label: "Upgrade to SuperGrok".into(),
|
||||
description: "For everyday coding and productivity tasks".into(),
|
||||
preview: None,
|
||||
id: Some(UPSELL_URL_UPGRADE.into()),
|
||||
},
|
||||
QuestionOption {
|
||||
label: "Upgrade to SuperGrok Heavy".into(),
|
||||
description: "Get the most out of Grok Build. Highest usage limits.".into(),
|
||||
preview: None,
|
||||
// No Heavy-specific URL exists; the /supergrok page lists
|
||||
// both plans, so both upgrade options land there.
|
||||
id: Some(UPSELL_URL_UPGRADE.into()),
|
||||
},
|
||||
];
|
||||
let question = Question {
|
||||
question: heading.into(),
|
||||
options,
|
||||
multi_select: Some(false),
|
||||
id: None,
|
||||
};
|
||||
|
||||
let stashed = agent.prompt.stash();
|
||||
let state = QuestionViewState::new(
|
||||
format!("{modal_id_prefix}-{}", uuid::Uuid::new_v4()),
|
||||
vec![question],
|
||||
stashed,
|
||||
)
|
||||
.with_local_kind(LocalQuestionKind::FreeUsageUpsell)
|
||||
.with_no_freeform();
|
||||
agent.question_view = Some(state);
|
||||
agent.prompt.set_text("");
|
||||
true
|
||||
}
|
||||
|
||||
/// Apply an [`AutoTopupFetch`] outcome to a cached `auto_topup` slot: `Resolved`
|
||||
/// sets it, `Cleared` resets it to "unknown" (no credits), and `Unchanged` keeps
|
||||
/// the last-known-good value (the fetch failed).
|
||||
pub(super) fn apply_auto_topup(
|
||||
slot: &mut Option<crate::views::credit_bar::AutoTopupInfo>,
|
||||
fetch: &crate::views::credit_bar::AutoTopupFetch,
|
||||
) {
|
||||
use crate::views::credit_bar::AutoTopupFetch;
|
||||
match fetch {
|
||||
AutoTopupFetch::Resolved(rule) => *slot = Some(rule.clone()),
|
||||
AutoTopupFetch::Cleared => *slot = None,
|
||||
AutoTopupFetch::Unchanged => {}
|
||||
}
|
||||
}
|
||||
|
||||
// TaskResult handlers.
|
||||
|
||||
pub(super) fn handle_billing_fetched(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
balance: Option<crate::views::credit_bar::CreditBalance>,
|
||||
silent: bool,
|
||||
subscription_tier: Option<String>,
|
||||
autotopup: crate::views::credit_bar::AutoTopupFetch,
|
||||
) -> Vec<Effect> {
|
||||
// Parse/transport failures route to `BillingError`, so a `None`
|
||||
// balance here means the response carried no billing config. Clear
|
||||
// the cached balance + polling so the status bar agrees with the
|
||||
// "No billing data available." message rather than showing a stale
|
||||
// value.
|
||||
app.credit_balance = balance.clone();
|
||||
// `Resolved` updates the cached rule, `Cleared` resets it to unknown
|
||||
// (no credits), `Unchanged` keeps the last-known-good (fetch failed).
|
||||
apply_auto_topup(&mut app.auto_topup, &autotopup);
|
||||
app.billing_poll_wanted = balance
|
||||
.as_ref()
|
||||
.map(|b| b.usage_pct >= 99.0)
|
||||
.unwrap_or(false);
|
||||
if let Some(tier) = subscription_tier {
|
||||
app.subscription_tier = Some(tier);
|
||||
}
|
||||
// Render the `/usage` summary from the now-current cached rule.
|
||||
let summary_topup = app.auto_topup.clone();
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
// Gateway/chat-kind: do not attach Build coding credits.
|
||||
let mut topup = agent.auto_topup.clone();
|
||||
apply_auto_topup(&mut topup, &autotopup);
|
||||
agent.apply_credit_balance(balance.clone(), topup);
|
||||
if !silent && !agent.chat_kind {
|
||||
let msg = match &balance {
|
||||
Some(bal) => {
|
||||
crate::views::credit_bar::format_usage_summary(bal, summary_topup.as_ref())
|
||||
}
|
||||
None => "No billing data available.".to_string(),
|
||||
};
|
||||
agent.scrollback.push_block(RenderBlock::System(
|
||||
crate::scrollback::blocks::SystemMessageBlock::new(msg),
|
||||
));
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_gate_refreshed(
|
||||
app: &mut AppView,
|
||||
settings: Option<kigi_shell::util::config::RemoteSettings>,
|
||||
) -> Vec<Effect> {
|
||||
let Some(rs) = settings else {
|
||||
return vec![];
|
||||
};
|
||||
app.usage_billing_redirect_url = rs.usage_billing_redirect_url.clone();
|
||||
if let Some(secs) = rs.subscription_watch_interval_secs {
|
||||
app.subscription_watch_interval_secs = Some(secs);
|
||||
}
|
||||
match AppView::gate_from_settings(&rs) {
|
||||
Some(gate) => app.impose_gate(gate),
|
||||
None => app.lift_gate(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `x.ai/auth/check_subscription` completed. Meta is authoritative
|
||||
/// (`apply_auth_meta` also drops any deferred gate). A failed check only
|
||||
/// promotes the deferred gate it was verifying (`verify` generation);
|
||||
/// generic watch/focus/paywall-chain failures never touch it.
|
||||
pub(super) fn handle_check_subscription_complete(
|
||||
app: &mut AppView,
|
||||
verify: Option<u64>,
|
||||
meta: Option<serde_json::Value>,
|
||||
) -> Vec<Effect> {
|
||||
let was_blocked = !app.has_access();
|
||||
let applied = match meta {
|
||||
Some(meta_val) => {
|
||||
match serde_json::from_value::<kigi_shell::auth::AuthMeta>(meta_val) {
|
||||
Ok(auth_meta) => {
|
||||
app.apply_auth_meta(&auth_meta);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
// Shell sent meta we can't decode — a protocol bug, not
|
||||
// a transient failure. The check result is lost, so a
|
||||
// verify deferral falls through to promotion below.
|
||||
crate::unified_log::error(
|
||||
"subscription.check.meta_parse_failed",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"verify": verify,
|
||||
"error": e.to_string(),
|
||||
})),
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
// meta: None = shell reports "not authenticated" or the check RPC
|
||||
// failed (already logged as subscription.check.rpc_failed).
|
||||
None => false,
|
||||
};
|
||||
if !applied && let Some(generation) = verify {
|
||||
app.promote_deferred_gate(generation, "check_failed");
|
||||
}
|
||||
crate::unified_log::info(
|
||||
"subscription.check.complete",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"verify": verify,
|
||||
"meta_applied": applied,
|
||||
"was_blocked": was_blocked,
|
||||
"gated": !app.has_access(),
|
||||
"tier": app.subscription_tier,
|
||||
})),
|
||||
);
|
||||
maybe_start_paywall_chain(app, was_blocked)
|
||||
}
|
||||
|
||||
/// Safety net for a hung verification check: show the still-pending
|
||||
/// deferred gate (err on blocking).
|
||||
pub(super) fn handle_gate_verify_timeout(app: &mut AppView, generation: u64) -> Vec<Effect> {
|
||||
let was_blocked = !app.has_access();
|
||||
app.promote_deferred_gate(generation, "verify_timeout");
|
||||
maybe_start_paywall_chain(app, was_blocked)
|
||||
}
|
||||
|
||||
/// Arm the 5s paywall auto-check chain on an ungated→gated transition, so a
|
||||
/// paywall shown by verify-before-paywall self-lifts exactly like the
|
||||
/// login-path one. Guarded so steady-state paywall-poller responses and
|
||||
/// repeated checks can't fan out extra timers.
|
||||
fn maybe_start_paywall_chain(app: &mut AppView, was_blocked: bool) -> Vec<Effect> {
|
||||
if !was_blocked && !app.has_access() && app.paywall_check_started.is_none() {
|
||||
app.paywall_check_started = Some(std::time::Instant::now());
|
||||
return vec![Effect::SchedulePaywallCheck];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_credit_limit_recheck_complete(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
meta: Option<serde_json::Value>,
|
||||
) -> Vec<Effect> {
|
||||
let old_tier = app.subscription_tier.clone();
|
||||
if let Some(meta_val) = meta
|
||||
&& let Ok(auth_meta) = serde_json::from_value::<kigi_shell::auth::AuthMeta>(meta_val)
|
||||
{
|
||||
app.apply_auth_meta(&auth_meta);
|
||||
}
|
||||
let tier_changed = app.subscription_tier != old_tier && app.subscription_tier.is_some();
|
||||
|
||||
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// If the user already submitted another prompt while the
|
||||
// recheck was in flight, don't retry the stashed one — they've
|
||||
// moved on. The tier update (above) still takes effect.
|
||||
let user_moved_on = !agent.session.state.is_idle() || !agent.session.pending_prompts.is_empty();
|
||||
|
||||
if tier_changed && !user_moved_on {
|
||||
if let Some(prompt) = agent.credit_limit_stashed_prompt.take() {
|
||||
let tier_name = app.subscription_tier.as_deref().unwrap_or("a higher tier");
|
||||
agent.scrollback.push_block(RenderBlock::system(format!(
|
||||
"Subscription upgraded to {tier_name}. Retrying\u{2026}"
|
||||
)));
|
||||
agent.session.enqueue_in_flight_prompt_front(prompt);
|
||||
}
|
||||
} else if !user_moved_on {
|
||||
let balance = agent
|
||||
.credit_balance
|
||||
.as_ref()
|
||||
.or(app.credit_balance.as_ref());
|
||||
let mode = credit_limit_upsell_mode(balance);
|
||||
let max_tier = is_max_tier(app.subscription_tier.as_deref());
|
||||
open_credit_limit_upsell(agent, mode, max_tier);
|
||||
}
|
||||
// Either way, drop the stashed prompt.
|
||||
agent.credit_limit_stashed_prompt = None;
|
||||
|
||||
let mut effects = maybe_drain_queue(agent);
|
||||
effects.push(Effect::FetchBilling {
|
||||
agent_id,
|
||||
silent: true,
|
||||
});
|
||||
effects
|
||||
}
|
||||
|
||||
// Action handlers.
|
||||
|
||||
pub(super) fn dispatch_open_supergrok_url(app: &mut AppView) -> Vec<Effect> {
|
||||
let url = app
|
||||
.gate
|
||||
.as_ref()
|
||||
.and_then(|g| g.url.as_deref())
|
||||
.unwrap_or("https://grok.com/supergrok?referrer=grok-build");
|
||||
// Funnel attribution: tag CLI-originated SuperGrok upsell clicks
|
||||
// with `referrer=grok-build`, matching the OAuth consent flow and
|
||||
// x.ai/cli marketing links. Applied even when the URL came from
|
||||
// remote settings's `gate_url`, so we don't depend on the remote flag
|
||||
// being correctly configured. If the URL already specifies a
|
||||
// referrer it's left alone.
|
||||
let url = crate::app::link_opener::ensure_query_param(url, "referrer", "grok-build");
|
||||
crate::app::link_opener::open_url(&url);
|
||||
vec![]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn free_usage_dual_read_string_and_wrapped_object_data() {
|
||||
let free = "subscription:free-usage-exhausted quota hit";
|
||||
let string_err = agent_client_protocol::Error::new(-32003, "Rate limited").data(free);
|
||||
assert!(acp_error_is_free_usage_exhausted(&string_err));
|
||||
|
||||
// attach_prompt_usage wraps string data as {"message": ..., "promptUsage": ...}.
|
||||
let wrapped =
|
||||
agent_client_protocol::Error::new(-32003, "Rate limited").data(serde_json::json!({
|
||||
"message": free,
|
||||
"promptUsage": { "inputTokens": 1, "outputTokens": 0, "numTurns": 1 }
|
||||
}));
|
||||
assert!(acp_error_is_free_usage_exhausted(&wrapped));
|
||||
assert!(!wrapped.data.as_ref().unwrap().is_string());
|
||||
|
||||
let other = agent_client_protocol::Error::new(-32003, "Rate limited").data("throttled");
|
||||
assert!(!acp_error_is_free_usage_exhausted(&other));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Active-agent lookup and view-context helpers shared across dispatch modules.
|
||||
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use crate::scrollback::state::ScrollbackState;
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
/// The active agent's root session id, if any. Used to scope server-queue
|
||||
/// edit Effects to the foregrounded session.
|
||||
pub(super) fn active_agent_session_id(app: &AppView) -> Option<acp::SessionId> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return None;
|
||||
};
|
||||
app.agents.get(&id)?.session.session_id.clone()
|
||||
}
|
||||
|
||||
/// Apply a closure to the active agent (if any).
|
||||
///
|
||||
/// When a subagent view is active, resolves to the **child** view so
|
||||
/// actions like SelectNext, GotoBottom, etc. target the visible view.
|
||||
pub(super) fn with_active_agent(app: &mut AppView, f: impl FnOnce(&mut AgentView)) {
|
||||
if let ActiveView::Agent(id) = app.active_view
|
||||
&& let Some(agent) = app.agents.get_mut(&id)
|
||||
{
|
||||
if let Some(child_sid) = agent.active_subagent.clone()
|
||||
&& let Some(child) = agent.subagent_views.get_mut(&child_sid)
|
||||
{
|
||||
f(child);
|
||||
return;
|
||||
}
|
||||
f(agent);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a shared reference to the active agent view (if any).
|
||||
pub(super) fn get_active_agent(app: &AppView) -> Option<&AgentView> {
|
||||
if let ActiveView::Agent(id) = app.active_view
|
||||
&& let Some(agent) = app.agents.get(&id)
|
||||
{
|
||||
if let Some(ref child_sid) = agent.active_subagent
|
||||
&& let Some(child) = agent.subagent_views.get(child_sid)
|
||||
{
|
||||
return Some(child);
|
||||
}
|
||||
return Some(agent);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the active agent view (if any).
|
||||
pub(super) fn get_active_agent_mut(app: &mut AppView) -> Option<&mut AgentView> {
|
||||
if let ActiveView::Agent(id) = app.active_view
|
||||
&& let Some(agent) = app.agents.get_mut(&id)
|
||||
{
|
||||
if let Some(child_sid) = agent.active_subagent.clone()
|
||||
&& agent.subagent_views.contains_key(&child_sid)
|
||||
{
|
||||
return agent.subagent_views.get_mut(&child_sid).map(|b| &mut **b);
|
||||
}
|
||||
return Some(agent);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Apply a closure to the active agent's scrollback (if any).
|
||||
///
|
||||
/// Resolves through `active_subagent` — see [`with_active_agent`].
|
||||
pub(super) fn with_scrollback(app: &mut AppView, f: impl FnOnce(&mut ScrollbackState)) {
|
||||
with_active_agent(app, |agent| f(&mut agent.scrollback));
|
||||
}
|
||||
|
||||
/// Navigate the scrollback and clear any persistent text selection.
|
||||
///
|
||||
/// Used by navigation actions (j/k/g/G/PageUp/PageDown/Ctrl-D/Ctrl-U) where
|
||||
/// scrolling away from the selected region should dismiss the highlight.
|
||||
pub(super) fn navigate_clearing_selection(app: &mut AppView, f: impl FnOnce(&mut ScrollbackState)) {
|
||||
with_active_agent(app, |agent| {
|
||||
agent.persistent_text_selection = None;
|
||||
agent.table_selection_geometry = None;
|
||||
agent.selection_created_at = None;
|
||||
agent.highlighted_link_idx = None;
|
||||
f(&mut agent.scrollback);
|
||||
});
|
||||
}
|
||||
|
||||
/// Synchronize the sleep inhibitor with the aggregate agent state.
|
||||
///
|
||||
/// Inhibits idle sleep when any agent is busy; releases when all are idle.
|
||||
/// Called after every `AgentState` transition in dispatch.
|
||||
pub(super) fn sync_sleep_inhibitor(app: &AppView) {
|
||||
let any_busy = app.agents.values().any(|a| !a.session.state.is_idle());
|
||||
if any_busy {
|
||||
app.notification_service.sleep_inhibitor.inhibit();
|
||||
} else {
|
||||
app.notification_service.sleep_inhibitor.release();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reseed_tip_for_new_session(app: &mut AppView) {
|
||||
if !matches!(app.active_view, ActiveView::Agent(_)) || app.tips.is_empty() {
|
||||
return;
|
||||
}
|
||||
let kigi_home = kigi_tools::util::kigi_home::kigi_home();
|
||||
app.tip = kigi_shell::util::tips::pick_and_advance(&app.tips, &kigi_home);
|
||||
}
|
||||
|
||||
/// Switch to the welcome screen. Use for every return-to-welcome transition.
|
||||
pub(super) fn show_welcome(app: &mut AppView) {
|
||||
app.active_view = ActiveView::Welcome;
|
||||
}
|
||||
|
||||
/// Restore the view a mid-session auth flow launched from, falling back to the
|
||||
/// welcome screen (via `show_welcome`) when the original agent is gone. Shared by
|
||||
/// cancel-login and AuthComplete so they can't diverge.
|
||||
pub(super) fn restore_auth_return_view(app: &mut AppView, return_view: ActiveView) {
|
||||
match return_view {
|
||||
ActiveView::Agent(id) if app.agents.contains_key(&id) => {
|
||||
app.active_view = ActiveView::Agent(id)
|
||||
}
|
||||
ActiveView::AgentDashboard => {
|
||||
app.active_view = ActiveView::AgentDashboard;
|
||||
}
|
||||
_ => show_welcome(app),
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a switch from one [`ActiveView::Agent`] to another is happening.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SwitchCause {
|
||||
/// Triggered by `/fork` (post-resolution) creating + switching to a
|
||||
/// child.
|
||||
Fork,
|
||||
/// Triggered by `/new` (fresh agent).
|
||||
New,
|
||||
/// Triggered by `/resume` (resuming a prior session) and the
|
||||
/// welcome-screen session picker.
|
||||
Load,
|
||||
/// Triggered by the agent picker (dashboard attach / switch).
|
||||
Picker,
|
||||
// `SwitchCause::Dashboard` was added
|
||||
// for the dashboard attach path but the earlier popup overlay
|
||||
// never reaches `switch_to_agent`, so the variant was dead. YAGNI —
|
||||
// any future caller can re-add it. The dashboard's attach path
|
||||
// sets `DashboardState::attached_agent` directly.
|
||||
}
|
||||
|
||||
/// Surface a launch-blocked `--yolo` once on the first agent view (the TUI owns
|
||||
/// the terminal, so stderr is gone); idempotent via `.take()`. Dashboard flows
|
||||
/// that bypass [`switch_to_agent`] call it directly.
|
||||
pub(super) fn surface_yolo_launch_block_notice(app: &mut AppView, target: AgentId) {
|
||||
if let Some(warning) = app.yolo_launch_block_notice.take()
|
||||
&& let Some(agent) = app.agents.get_mut(&target)
|
||||
{
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(crate::scrollback::block::RenderBlock::system(
|
||||
warning.to_string(),
|
||||
));
|
||||
agent.show_toast(warning);
|
||||
}
|
||||
surface_screen_mode_switch_hint(app, target);
|
||||
}
|
||||
|
||||
/// Surface a one-shot switch-back toast after a screen-mode relaunch (fullscreen only).
|
||||
pub(super) fn surface_screen_mode_switch_hint(app: &mut AppView, target: AgentId) {
|
||||
if let Some(hint) = app.screen_mode_switch_hint.take()
|
||||
&& !app.screen_mode.is_minimal()
|
||||
&& let Some(agent) = app.agents.get_mut(&target)
|
||||
{
|
||||
agent.show_toast(hint);
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch the active agent — the primary funnel for assigning `ActiveView::Agent`
|
||||
/// (new, resume, picker, fork); also fires [`surface_yolo_launch_block_notice`].
|
||||
/// No-op if `target` is unknown or already active. Dashboard-first flows that
|
||||
/// assign `Agent` directly must call the notice themselves.
|
||||
pub(crate) fn switch_to_agent(app: &mut AppView, target: AgentId, _cause: SwitchCause) {
|
||||
// Structural backstop for the auth + folder-trust session gate. This is the
|
||||
// single funnel every FRESH-agent creator routes through (New/Load/Fork —
|
||||
// `Picker` switches to an already-created, post-gate agent), so asserting the
|
||||
// gate here makes "no session is created while `TrustState::Pending`" a
|
||||
// property of the flow rather than of each call site: any future creator
|
||||
// that forgets the deferring chokepoint gate trips this in debug/tests. The
|
||||
// deferring chokepoints (`dispatch_new_session`/`_worktree_session`/
|
||||
// `_load_session_inner`) stash+return BEFORE reaching here, so this never
|
||||
// fires on the reachable gated paths. (`dispatch_project_selected` re-creates
|
||||
// an already-active, post-gate agent without switching, so it is exempt.)
|
||||
// `_cause` stays underscored so it isn't flagged unused once `debug_assert!`
|
||||
// compiles out in release.
|
||||
debug_assert!(
|
||||
matches!(_cause, SwitchCause::Picker) || app.session_startup_allowed(),
|
||||
"session creation via {_cause:?} requires the startup gate open (auth + folder trust)"
|
||||
);
|
||||
if !app.agents.contains_key(&target) {
|
||||
return;
|
||||
}
|
||||
if matches!(app.active_view, ActiveView::Agent(current) if current == target) {
|
||||
return;
|
||||
}
|
||||
app.active_view = ActiveView::Agent(target);
|
||||
// Re-anchor the global permission-mode mirror to the now-active agent so the
|
||||
// cycle's `sync_active_auto_flag` (which derives from the global) can't copy a
|
||||
// different agent's stale Auto/Always-Approve onto this one. Per-session
|
||||
// yolo/auto are the source of truth; the global is a write-only mirror.
|
||||
if let Some(agent) = app.agents.get(&target) {
|
||||
let (is_yolo, is_auto) = (agent.session.is_yolo(), agent.session.is_auto());
|
||||
let reanchor = if is_yolo {
|
||||
Some("always-approve")
|
||||
} else if is_auto && app.auto_mode_gate {
|
||||
// Gate-aware: never re-anchor the global mirror to "auto" when the
|
||||
// feature gate is off, even if a stale per-session `auto_mode`
|
||||
// survived (defense-in-depth with the settings kill-switch fan-out).
|
||||
Some("auto")
|
||||
} else if matches!(
|
||||
app.current_ui.permission_mode.as_deref(),
|
||||
Some("always-approve") | Some("auto")
|
||||
) {
|
||||
// Non-yolo/non-auto agent: clear a stale yolo/auto mirror left by a
|
||||
// different agent; preserve an existing ask/default distinction.
|
||||
Some("ask")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(c) = reanchor {
|
||||
app.current_ui.permission_mode = Some(c.to_string());
|
||||
}
|
||||
}
|
||||
// Seed the auto feature gate on the (possibly new) active agent's slash
|
||||
// registry.
|
||||
app.sync_permission_mode_slash_gate();
|
||||
surface_yolo_launch_block_notice(app, target);
|
||||
}
|
||||
|
||||
pub(super) fn find_agent_id_by_session_id(
|
||||
agents: &indexmap::IndexMap<AgentId, AgentView>,
|
||||
session_id: &str,
|
||||
) -> Option<AgentId> {
|
||||
agents.iter().find_map(|(id, a)| {
|
||||
a.session
|
||||
.session_id
|
||||
.as_ref()
|
||||
.is_some_and(|sid| &*sid.0 == session_id)
|
||||
.then_some(*id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Root session match (for async kill-result routing off the active view).
|
||||
pub(super) fn find_agent_by_session_id<'a>(
|
||||
agents: &'a mut indexmap::IndexMap<AgentId, AgentView>,
|
||||
session_id: &str,
|
||||
) -> Option<&'a mut AgentView> {
|
||||
let id = find_agent_id_by_session_id(agents, session_id)?;
|
||||
agents.get_mut(&id)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
//! Claude session import dispatchers.
|
||||
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::app_view::AppView;
|
||||
|
||||
/// Open the interactive Claude-import modal on the welcome screen.
|
||||
///
|
||||
/// Scans for importable items. If empty, shows a brief startup warning and
|
||||
/// marks dismissed. Otherwise stores modal state on AppView so welcome
|
||||
/// rendering shows the modal.
|
||||
pub(super) fn dispatch_import_claude(app: &mut AppView) -> Vec<Effect> {
|
||||
let cwd = app.cwd.clone();
|
||||
let plan = kigi_shell::claude_import::scan_importable_settings(&cwd);
|
||||
|
||||
if plan.is_empty() {
|
||||
kigi_shell::claude_import_state::mark_dismissed(&cwd);
|
||||
// Always write the [claude_compat] imported = true marker so the user's
|
||||
// opt-in is recorded even on an empty plan.
|
||||
if let Err(e) = kigi_shell::claude_import::mark_claude_imported() {
|
||||
tracing::warn!(error = %e, "Failed to write Claude import marker");
|
||||
}
|
||||
app.has_claude_import = false;
|
||||
app.startup_warnings
|
||||
.retain(|w| !w.message.contains("Claude settings"));
|
||||
app.startup_warnings.push(crate::startup::StartupWarning {
|
||||
severity: crate::startup::WarningSeverity::Info,
|
||||
message: "No Claude settings found to import.".into(),
|
||||
action: None,
|
||||
});
|
||||
return vec![];
|
||||
}
|
||||
|
||||
app.import_claude_modal =
|
||||
Some(crate::views::import_claude_modal::ImportClaudeModalState::new(plan, cwd));
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Apply the user's selection from the import modal and close it.
|
||||
pub(super) fn dispatch_import_claude_confirm(app: &mut AppView) -> Vec<Effect> {
|
||||
let Some(modal) = app.import_claude_modal.take() else {
|
||||
return vec![];
|
||||
};
|
||||
let cwd = modal.cwd.clone();
|
||||
let total_in_modal = modal.total_count();
|
||||
let filtered = modal.filtered_plan();
|
||||
let selected_count = filtered.global_items.len() + filtered.project_items.len();
|
||||
|
||||
let mut summary = if selected_count == 0 {
|
||||
"No items selected.".to_string()
|
||||
} else {
|
||||
filtered.summary(&cwd).trim_end().to_string()
|
||||
};
|
||||
|
||||
if selected_count > 0 {
|
||||
match kigi_shell::claude_import::apply_import(&filtered, &cwd) {
|
||||
Ok(result) => {
|
||||
summary.push_str(&format!(
|
||||
"\nImported {} of {} setting(s).",
|
||||
result.total(),
|
||||
total_in_modal
|
||||
));
|
||||
for path in &result.modified_files {
|
||||
summary.push_str(&format!("\n Updated: {}", path));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.startup_warnings.push(crate::startup::StartupWarning {
|
||||
severity: crate::startup::WarningSeverity::Warning,
|
||||
message: format!("Failed to import Claude settings: {}", e),
|
||||
action: None,
|
||||
});
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark current Claude state as seen so the startup warning won't re-fire
|
||||
// for the same content. Skipped items remain importable via re-running
|
||||
// the slash command.
|
||||
kigi_shell::claude_import_state::mark_imported(&cwd);
|
||||
if let Err(e) = kigi_shell::claude_import::mark_claude_imported() {
|
||||
tracing::warn!(error = %e, "Failed to write Claude import marker");
|
||||
}
|
||||
app.has_claude_import = false;
|
||||
app.startup_warnings
|
||||
.retain(|w| !w.message.contains("Claude settings"));
|
||||
app.startup_warnings.push(crate::startup::StartupWarning {
|
||||
severity: crate::startup::WarningSeverity::Info,
|
||||
message: summary,
|
||||
action: None,
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Cancel the import modal without applying anything.
|
||||
pub(super) fn dispatch_import_claude_cancel(app: &mut AppView) -> Vec<Effect> {
|
||||
app.import_claude_modal = None;
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Hide the Claude-import menu row by recording the current `.claude/`
|
||||
/// content hash. The startup detection compares the saved hash on next
|
||||
/// launch — if it matches (no new Claude content), the menu stays hidden.
|
||||
pub(super) fn dispatch_dismiss_claude_import(app: &mut AppView) -> Vec<Effect> {
|
||||
let cwd = app.cwd.clone();
|
||||
// Record the current `.claude/` content hash so the welcome menu row
|
||||
// doesn't reappear next session unless the content actually changes.
|
||||
kigi_shell::claude_import_state::mark_dismissed(&cwd);
|
||||
// Also set the [claude_compat] imported = true marker so runtime
|
||||
// fallback paths (perms, env, MCP servers, hooks, plugins) stop
|
||||
// reading .claude/ and ~/.claude.json. Dismiss = "I've decided I want
|
||||
// nothing from .claude/", so don't keep silently reading it at runtime.
|
||||
if let Err(e) = kigi_shell::claude_import::mark_claude_imported() {
|
||||
tracing::warn!(error = %e, "Failed to write Claude import marker on dismiss");
|
||||
}
|
||||
app.has_claude_import = false;
|
||||
// Reset the welcome menu selection: removing a row shifts indices, so a
|
||||
// stale selection (e.g. user had `Worktree mode` highlighted at index 1)
|
||||
// would now point to a different row.
|
||||
app.welcome_menu_index = None;
|
||||
app.startup_warnings
|
||||
.retain(|w| !w.message.contains("Claude settings"));
|
||||
vec![]
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Mid-turn interjection dispatch: optimistic local echo, the
|
||||
//! `x.ai/interject` effect, and prompt-history recording. Split out of
|
||||
//! `dispatch.rs` verbatim (pure code motion).
|
||||
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
|
||||
/// Send a mid-turn interjection. Pushes a standard user prompt block locally
|
||||
/// for instant feedback, records the text in prompt history, clears the
|
||||
/// prompt, and fires the `x.ai/interject` ext method carrying a client-minted
|
||||
/// id.
|
||||
///
|
||||
/// The shell broadcasts `x.ai/session/interjection` to every attached pane so
|
||||
/// other clients viewing the same session render it too (multi-client /
|
||||
/// dashboard mode). Our own broadcast echoes back carrying the same id; the id
|
||||
/// is recorded in `self_interjection_ids` so `handle_interjection` drops the
|
||||
/// echo instead of rendering a duplicate. Other panes lack the id and render
|
||||
/// it. (Optimistic-echo + reconcile-by-id, mirroring the shared prompt queue.)
|
||||
pub(super) fn dispatch_interject(
|
||||
app: &mut AppView,
|
||||
text: String,
|
||||
images: Vec<crate::prompt_images::PastedImage>,
|
||||
) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Submitting an interjection retires any edit-contextual ephemeral tip —
|
||||
// even when there is no active session, matching the prompt/bash/
|
||||
// feedback/remember paths.
|
||||
agent.ephemeral_tip.clear_on_submit();
|
||||
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
agent.show_toast("No active session");
|
||||
return vec![];
|
||||
};
|
||||
|
||||
record_interject_prompt_history(agent, &text);
|
||||
|
||||
// Push a standard user prompt block locally for instant feedback, and
|
||||
// record its id so the broadcast echo (`x.ai/session/interjection`) is
|
||||
// deduped instead of rendering a second copy on this pane.
|
||||
let interjection_id = uuid::Uuid::new_v4().to_string();
|
||||
agent.self_interjection_ids.insert(interjection_id.clone());
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::interjection_prompt(&text));
|
||||
// Interjecting into a parked wait continues the turn below this block —
|
||||
// the withheld "Worked for …" marker must not fire late beneath it.
|
||||
agent.suppress_parked_marker_on_interject();
|
||||
|
||||
// The composer is NOT touched here: the producer that consumed composer
|
||||
// text (the InterjectPrompt registry arm) clears it at the call site;
|
||||
// every other producer (Send now, edit-interject, plan review comments)
|
||||
// carries non-composer text and must keep the user's draft/stash.
|
||||
agent.show_toast("Interjection sent");
|
||||
|
||||
// Image-bearing interjection: build text + image content blocks via the
|
||||
// same helper as the queued-prompt drain path (orphan-placeholder
|
||||
// recovery, allowlist, size cap). Text-only stays on the legacy wire.
|
||||
let blocks = if images.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(crate::prompt_images::build_content_blocks_with_workspace(
|
||||
text.clone(),
|
||||
images,
|
||||
Some(std::path::Path::new(&agent.session.cwd)),
|
||||
))
|
||||
};
|
||||
|
||||
vec![Effect::SendInterject {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
text,
|
||||
interjection_id,
|
||||
blocks,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Cancel-and-send: send `text` (+ images) as a fresh `sendNow` prompt so the
|
||||
/// shell cancels the running turn and runs it next. The user block paints at
|
||||
/// dispatch (the arm hides the queue echo; the adoption reuses the block).
|
||||
pub(super) fn dispatch_send_prompt_now(
|
||||
app: &mut AppView,
|
||||
text: String,
|
||||
images: Vec<crate::prompt_images::PastedImage>,
|
||||
) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let reconnect_pending = app.reconnect_pending;
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Mid-outage guard (mirrors the plain prompt path): the producers already
|
||||
// consumed the payload (composer text / queue row), so requeue it locally
|
||||
// instead of firing into a dead channel and losing the message.
|
||||
if reconnect_pending {
|
||||
let queue_id = agent.session.next_queue_id;
|
||||
agent.session.next_queue_id += 1;
|
||||
agent
|
||||
.session
|
||||
.pending_prompts
|
||||
.push_front(crate::app::agent::QueuedPrompt {
|
||||
images,
|
||||
..crate::app::agent::QueuedPrompt::plain(
|
||||
queue_id,
|
||||
&text,
|
||||
crate::app::agent::QueueEntryKind::Prompt,
|
||||
)
|
||||
});
|
||||
agent.show_toast("Reconnecting, please wait...");
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Submitting retires any edit-contextual ephemeral tip.
|
||||
agent.ephemeral_tip.clear_on_submit();
|
||||
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
agent.show_toast("No active session");
|
||||
return vec![];
|
||||
};
|
||||
|
||||
record_interject_prompt_history(agent, &text);
|
||||
|
||||
let prompt_id = uuid::Uuid::new_v4().to_string();
|
||||
// Self-originated: the ACP gate must treat this prompt's deltas as ours.
|
||||
agent.note_self_originated_prompt(&prompt_id);
|
||||
// Expect the shell's send-now cancel so the turn-end rails suppress its
|
||||
// marker — only when the shell will actually cancel (goal turns promote
|
||||
// without cancelling; a stale arm would mute a later real cancel marker).
|
||||
if agent.expects_send_now_cancel() {
|
||||
agent.arm_send_now_expectation(prompt_id.clone());
|
||||
// The arm hides the queue echo pushed below — paint the block now.
|
||||
super::queue::push_send_now_user_block(agent, &prompt_id, "prompt", &text, false);
|
||||
}
|
||||
agent.suppress_parked_marker_on_interject();
|
||||
|
||||
let blocks = crate::prompt_images::build_content_blocks_with_workspace(
|
||||
text.clone(),
|
||||
images,
|
||||
Some(std::path::Path::new(&agent.session.cwd)),
|
||||
);
|
||||
|
||||
// Optimistic queue-pane echo, reconciled by the shell's queue broadcast.
|
||||
let sid_str = session_id.0.to_string();
|
||||
super::queue::push_server_queue_echo(app, id, &sid_str, &prompt_id, &text, "prompt");
|
||||
crate::unified_log::info(
|
||||
"prompt.send_now",
|
||||
Some(&sid_str),
|
||||
Some(serde_json::json!({ "len": text.len(), "prompt_id": prompt_id })),
|
||||
);
|
||||
|
||||
vec![Effect::SendPromptNow {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
blocks,
|
||||
prompt_id,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Record an interjection in prompt history (Ctrl+R finds interjections).
|
||||
/// Shared by `dispatch_interject` and the edited-queued-interject arm — the
|
||||
/// user typed both, so both must be recallable.
|
||||
pub(super) fn record_interject_prompt_history(agent: &mut AgentView, text: &str) {
|
||||
let trimmed_key = text.trim().to_string();
|
||||
if trimmed_key.is_empty() {
|
||||
return;
|
||||
}
|
||||
agent
|
||||
.session
|
||||
.prompt_history
|
||||
.retain(|p| p.trim() != trimmed_key);
|
||||
agent.session.prompt_history.insert(0, text.to_string());
|
||||
if agent.session.prompt_history.len() > 200 {
|
||||
agent.session.prompt_history.truncate(200);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::actions::Action;
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::dispatch::router::dispatch;
|
||||
use crate::app::dispatch::tests::test_app_with_agent;
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
/// Composer-clear ownership: dispatch NEVER touches the composer. The
|
||||
/// only composer-text producer (the InterjectPrompt registry arm) clears
|
||||
/// it at the call site; every other producer (Send now, edit-interject,
|
||||
/// plan review comments) carries non-composer text whose draft/stash
|
||||
/// must survive dispatch — even when it happens to equal the interjected
|
||||
/// text (provenance is not inferred by value equality).
|
||||
#[test]
|
||||
fn interject_dispatch_never_touches_the_composer() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
|
||||
// Unrelated draft survives a plain interject.
|
||||
app.agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.prompt
|
||||
.set_text("stashed draft");
|
||||
let effects = dispatch(
|
||||
Action::Interject {
|
||||
text: "edited body".into(),
|
||||
images: vec![],
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
assert!(matches!(effects.as_slice(), [Effect::SendInterject { .. }]));
|
||||
assert_eq!(app.agents.get(&id).unwrap().prompt.text(), "stashed draft");
|
||||
|
||||
// Edited-queued interject: fire-and-forget, composer untouched.
|
||||
let effects = dispatch(
|
||||
Action::QueueInterjectShared {
|
||||
id: "p1".into(),
|
||||
expected_version: 1,
|
||||
new_text: Some("edited body".into()),
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
assert!(matches!(
|
||||
effects.as_slice(),
|
||||
[Effect::QueueInterject { .. }]
|
||||
));
|
||||
assert_eq!(app.agents.get(&id).unwrap().prompt.text(), "stashed draft");
|
||||
|
||||
// Even a composer that equals the interjected text is preserved —
|
||||
// the InterjectPrompt arm already cleared it for the composer path.
|
||||
app.agents.get_mut(&id).unwrap().prompt.set_text("send me");
|
||||
let _ = dispatch(
|
||||
Action::Interject {
|
||||
text: "send me".into(),
|
||||
images: vec![],
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
assert_eq!(app.agents.get(&id).unwrap().prompt.text(), "send me");
|
||||
}
|
||||
|
||||
/// Interjecting is a submit: it retires the active ephemeral tip.
|
||||
#[test]
|
||||
fn interject_clears_active_ephemeral_tip() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
let _ = agent.ephemeral_tip.show(
|
||||
crate::tips::EphemeralTip::new("t", ratatui::text::Line::from("hint")),
|
||||
&mut std::collections::HashMap::new(),
|
||||
);
|
||||
assert!(agent.ephemeral_tip.is_active());
|
||||
|
||||
let _ = dispatch(
|
||||
Action::Interject {
|
||||
text: "mid-turn note".into(),
|
||||
images: vec![],
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
!app.agents.get(&id).unwrap().ephemeral_tip.is_active(),
|
||||
"interject submit must clear the tip"
|
||||
);
|
||||
}
|
||||
|
||||
/// A no-session interject still retires the tip: the clear now runs before
|
||||
/// the "No active session" early return, matching the other submit paths.
|
||||
#[test]
|
||||
fn interject_without_session_still_clears_ephemeral_tip() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent.session.session_id = None;
|
||||
let _ = agent.ephemeral_tip.show(
|
||||
crate::tips::EphemeralTip::new("t", ratatui::text::Line::from("hint")),
|
||||
&mut std::collections::HashMap::new(),
|
||||
);
|
||||
assert!(agent.ephemeral_tip.is_active());
|
||||
|
||||
let effects = dispatch(
|
||||
Action::Interject {
|
||||
text: "mid-turn note".into(),
|
||||
images: vec![],
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(
|
||||
!agent.ephemeral_tip.is_active(),
|
||||
"no-session interject must still clear the tip"
|
||||
);
|
||||
assert!(
|
||||
effects.is_empty(),
|
||||
"no-session interject dispatches no effects"
|
||||
);
|
||||
assert_eq!(
|
||||
agent.toast.as_ref().map(|(m, _)| m.as_str()),
|
||||
Some("No active session"),
|
||||
"no-session interject takes the 'No active session' path"
|
||||
);
|
||||
}
|
||||
|
||||
/// Image-bearing interject builds structured blocks (Text first with the
|
||||
/// placeholder intact, then one Image block); no-image stays legacy
|
||||
/// (`blocks: None`) so the wire shape is byte-identical.
|
||||
#[test]
|
||||
fn interject_with_images_builds_blocks_text_first() {
|
||||
let mut app = test_app_with_agent();
|
||||
|
||||
let mut img = crate::prompt_images::from_clipboard_data(&crate::clipboard::ImageData {
|
||||
data: vec![1, 2, 3],
|
||||
mime_type: "image/png".into(),
|
||||
});
|
||||
img.display_number = 1;
|
||||
|
||||
let effects = dispatch(
|
||||
Action::Interject {
|
||||
text: "look at [Image #1] please".into(),
|
||||
images: vec![img],
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
match effects.as_slice() {
|
||||
[
|
||||
Effect::SendInterject {
|
||||
text,
|
||||
blocks: Some(blocks),
|
||||
..
|
||||
},
|
||||
] => {
|
||||
assert_eq!(text, "look at [Image #1] please");
|
||||
assert_eq!(blocks.len(), 2);
|
||||
match &blocks[0] {
|
||||
acp::ContentBlock::Text(tb) => {
|
||||
assert!(tb.text.contains("[Image #1]"), "got {:?}", tb.text)
|
||||
}
|
||||
other => panic!("expected Text first, got {other:?}"),
|
||||
}
|
||||
assert!(matches!(&blocks[1], acp::ContentBlock::Image(_)));
|
||||
}
|
||||
other => panic!("expected SendInterject with blocks, got {other:?}"),
|
||||
}
|
||||
|
||||
let effects = dispatch(
|
||||
Action::Interject {
|
||||
text: "plain".into(),
|
||||
images: vec![],
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
assert!(matches!(
|
||||
effects.as_slice(),
|
||||
[Effect::SendInterject { blocks: None, .. }]
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! `/jump` picker dispatchers: pure client-side turn navigation.
|
||||
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use crate::scrollback::entry::EntryId;
|
||||
use crate::views::jump::{JumpRestore, JumpState};
|
||||
|
||||
pub(super) fn dispatch_jump_show_picker(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
// Refuse if another prompt overlay owns the input slot (rewind, inline-edit,
|
||||
// /btw, or a pending permission/question/cancel-turn/plan overlay) — an
|
||||
// opened picker would be hidden but still eat input.
|
||||
if agent.jump_slot_taken() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let entries = agent.scrollback.timeline_entries();
|
||||
if entries.len() < 2 {
|
||||
app.show_toast("Nothing to jump to yet");
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let restore = JumpRestore {
|
||||
bookmark: agent.scrollback.capture_scroll_bookmark(),
|
||||
selected: agent.scrollback.selected(),
|
||||
follow_mode: agent.scrollback.is_follow_mode(),
|
||||
};
|
||||
// Open on the turn currently at the viewport top (rows are oldest-first,
|
||||
// so the row index is the turn index).
|
||||
let selected = agent
|
||||
.scrollback
|
||||
.active_turn_for_viewport()
|
||||
.unwrap_or(entries.len() - 1)
|
||||
.min(entries.len() - 1);
|
||||
|
||||
let preview_id = entries[selected].prompt_entry_id;
|
||||
agent.jump_state = Some(JumpState {
|
||||
entries,
|
||||
selected,
|
||||
restore,
|
||||
});
|
||||
// Same top anchor that cursor moves preview and Enter lands on.
|
||||
if let Some(idx) = agent.scrollback.index_of_id(preview_id) {
|
||||
agent.scrollback.scroll_to_entry_top(idx);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_jump_picker_select(app: &mut AppView, prompt_id: EntryId) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(js) = agent.jump_state.take() else {
|
||||
return vec![];
|
||||
};
|
||||
// The stable id resolves at the boundary; it fails only if the prompt was
|
||||
// removed (async clear/rewind) while the picker was open. Restore the
|
||||
// captured viewport so a failed jump never strands the transcript at the
|
||||
// last preview scroll.
|
||||
if !agent.scrollback.jump_to_entry(prompt_id) {
|
||||
agent.restore_jump_viewport(js.restore);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_jump_dismiss(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
agent.dismiss_jump_picker();
|
||||
vec![]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Synchronous state dispatch: [`Action`](crate::app::actions::Action) → state mutations + [`Effect`](crate::app::actions::Effect)s.
|
||||
//!
|
||||
//! This is the core business logic of the application. It takes an action,
|
||||
//! mutates application state, and returns a list of async effects to execute.
|
||||
//!
|
||||
//! **Invariants:**
|
||||
//! - This module never touches the terminal, network, or filesystem.
|
||||
//! - All mutations are synchronous and deterministic.
|
||||
//! - Async work is described as [`Effect`](crate::app::actions::Effect) values, not executed.
|
||||
//! - This makes dispatch fully testable without tokio or a terminal.
|
||||
//!
|
||||
//! Imports in this tree use at most one `super::` hop (absolute `crate::` paths
|
||||
//! otherwise); tests/ shares a fixture prelude via `use super::*;`.
|
||||
|
||||
mod auth;
|
||||
mod billing;
|
||||
mod ctx;
|
||||
mod dashboard;
|
||||
mod import_claude;
|
||||
mod interject;
|
||||
mod jump;
|
||||
mod modes;
|
||||
mod notes;
|
||||
mod permissions;
|
||||
mod prompt;
|
||||
mod queue;
|
||||
mod rewind;
|
||||
mod router;
|
||||
mod session;
|
||||
mod settings;
|
||||
mod status;
|
||||
mod task_result;
|
||||
mod transcript;
|
||||
mod turn;
|
||||
|
||||
pub(crate) use billing::{
|
||||
FREE_USAGE_USER_MESSAGE, UPSELL_URL_PAYG, UPSELL_URL_UPGRADE,
|
||||
acp_error_is_free_usage_exhausted, is_credit_limit_error, is_free_usage_exhausted_error,
|
||||
};
|
||||
pub(crate) use modes::{downgrade_displayed_auto_if_gated, effective_auto};
|
||||
pub(crate) use notes::{recap_unavailable_toast, scrollback_has_user_messages};
|
||||
pub(crate) use permissions::resolve_permission_queue_transition;
|
||||
pub(crate) use prompt::dispatch_initial_prompt;
|
||||
pub(in crate::app) use prompt::show_small_screen_tip;
|
||||
pub(super) use queue::{
|
||||
apply_turn_start_shim, arm_send_now_and_paint, maybe_drain_queue, shim_renders_own_user_block,
|
||||
};
|
||||
pub(in crate::app) use rewind::{find_user_prompt_entry_for_shell_index, shell_prompt_index_at};
|
||||
pub(crate) use router::dispatch;
|
||||
pub(crate) use settings::ui::refresh_open_settings_modals;
|
||||
pub(crate) use status::commit_minimal_update_notice;
|
||||
pub(crate) use turn::reconcile_overdue_turn_ends;
|
||||
|
||||
// Test-only consumers (cfg(test) mods elsewhere in the crate); a plain
|
||||
// re-export trips -D unused-imports in the lib build.
|
||||
#[cfg(test)]
|
||||
pub(crate) use ctx::{SwitchCause, switch_to_agent};
|
||||
#[cfg(test)]
|
||||
pub(crate) use settings::ui::{ROLLBACK_NO_ARM_TOAST, build_pager_snapshot};
|
||||
#[cfg(test)]
|
||||
pub(crate) use turn::TURN_END_RECONCILE_GRACE;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,961 @@
|
||||
//! Plan, yolo, auto, and permission mode transitions and toasts.
|
||||
|
||||
use super::ctx::with_active_agent;
|
||||
use super::queue::maybe_drain_queue;
|
||||
use super::session::lifecycle::skip_picker_and_create_session;
|
||||
use super::settings::ui::{refresh_open_settings_modals, save_success_toast};
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
/// Show the current plan: if a plan file exists, open it in the preview
|
||||
/// overlay popover. If no plan has been written yet, show a toast.
|
||||
///
|
||||
/// Delegates to `AgentView::show_plan_preview()` which reads the plan file
|
||||
/// from `~/.kigi/sessions/<urlencoded_cwd>/<session_id>/plan.md`.
|
||||
pub(super) fn dispatch_show_plan(app: &mut AppView) -> Vec<Effect> {
|
||||
with_active_agent(app, |agent| {
|
||||
if agent.plan_approval_view.is_some() {
|
||||
agent.reopen_plan_approval();
|
||||
} else {
|
||||
agent.show_plan_preview();
|
||||
}
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Enter plan mode via `/plan`.
|
||||
///
|
||||
/// When not in plan mode: emits `SetSessionMode` (or `SetModeThenPrompt`
|
||||
/// if a description is provided). When already in plan mode: no-op with toast.
|
||||
/// Use `/view-plan` to open the current saved plan preview.
|
||||
///
|
||||
/// When a description is present, the mode switch and prompt send must be
|
||||
/// ordered: the mode switch ACP call must complete before the prompt is
|
||||
/// dispatched. `SetModeThenPrompt` bundles both into a single spawned task
|
||||
/// to guarantee this ordering.
|
||||
pub(super) fn dispatch_enter_plan_mode(
|
||||
app: &mut AppView,
|
||||
description: Option<String>,
|
||||
) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let in_plan = agent.plan_mode_pending.unwrap_or(agent.plan_mode_active);
|
||||
if in_plan {
|
||||
app.show_toast("Already in plan mode. Use /view-plan to view the current plan.");
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
agent.show_toast("No active session");
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Set optimistic pending state (same pattern as dispatch_cycle_mode).
|
||||
agent.plan_mode_pending = Some(true);
|
||||
tracing::info!("Plan mode entered via /plan slash command");
|
||||
|
||||
let mode_id = acp::SessionModeId::new("plan");
|
||||
|
||||
if let Some(desc) = description {
|
||||
// Enqueue and drain: maybe_drain_queue does all synchronous turn
|
||||
// setup (scrollback, start_turn, prompt_id) and returns a SendPrompt.
|
||||
// We combine it with the mode switch into a single sequential effect
|
||||
// so the mode switch completes before the prompt is sent.
|
||||
// The description is a plain prompt: capture composer-recognized
|
||||
// tokens like the normal submit path (offsets recomputed against
|
||||
// `desc` since the leading `/plan ` was stripped).
|
||||
let skill_token_ranges = agent
|
||||
.prompt
|
||||
.slash_controller
|
||||
.recognized_token_ranges(&desc, &agent.session.models);
|
||||
agent
|
||||
.session
|
||||
.enqueue_prompt_with_skill_tokens(desc, skill_token_ranges);
|
||||
let drain = maybe_drain_queue(agent);
|
||||
let mut effects = Vec::with_capacity(1);
|
||||
for eff in drain {
|
||||
match eff {
|
||||
Effect::SendPrompt {
|
||||
agent_id,
|
||||
text,
|
||||
prompt_id,
|
||||
skill_token_ranges,
|
||||
..
|
||||
} => {
|
||||
effects.push(Effect::SetModeThenPrompt {
|
||||
session_id: session_id.clone(),
|
||||
mode_id: mode_id.clone(),
|
||||
agent_id,
|
||||
text,
|
||||
prompt_id,
|
||||
skill_token_ranges,
|
||||
});
|
||||
}
|
||||
other => effects.push(other),
|
||||
}
|
||||
}
|
||||
// If drain was empty (not idle), just emit the mode switch — the
|
||||
// prompt stays queued and will drain naturally when the agent idles.
|
||||
if effects.is_empty() {
|
||||
effects.push(Effect::SetSessionMode {
|
||||
session_id,
|
||||
mode_id,
|
||||
});
|
||||
}
|
||||
effects
|
||||
} else {
|
||||
vec![Effect::SetSessionMode {
|
||||
session_id,
|
||||
mode_id,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
/// Set plan mode (on / off). PAGER-owned + ACP-mediated, per-session.
|
||||
///
|
||||
/// Optimistic flow: captures effective state (`pending.or(active)`),
|
||||
/// sets `plan_mode_pending`, refreshes modals, toasts, then emits
|
||||
/// `Effect::SetSessionMode`. Shell confirms via `CurrentModeUpdate`.
|
||||
///
|
||||
/// No explicit rollback — `SetSessionMode` has no failure surface.
|
||||
/// If the ACP transport drops, `plan_mode_pending` stays set until
|
||||
/// the next `CurrentModeUpdate` or session restart.
|
||||
///
|
||||
/// Idempotent: same value toasts but skips the ACP round-trip.
|
||||
pub(super) fn set_plan_mode(
|
||||
app: &mut AppView,
|
||||
kind: crate::app::actions::PlanModeKind,
|
||||
) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
agent.show_toast("No active session");
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Effective state: prefer optimistic pending over confirmed
|
||||
// active. Mirrors `dispatch_cycle_mode`'s `in_plan` read so
|
||||
// rapid toggles don't double-send.
|
||||
let prev = agent.plan_mode_pending.unwrap_or(agent.plan_mode_active);
|
||||
let new = kind.to_bool();
|
||||
|
||||
// Idempotent: toast but skip the ACP round-trip.
|
||||
if prev == new {
|
||||
app.show_toast(&plan_mode_toast(kind));
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Optimistic mutation: pager-side pending flag, then UI feedback,
|
||||
// then effect. The shell's `CurrentModeUpdate` broadcast will
|
||||
// confirm + clear `plan_mode_pending` via `detect_plan_mode_change`.
|
||||
agent.plan_mode_pending = Some(new);
|
||||
refresh_open_settings_modals(app);
|
||||
app.show_toast(&plan_mode_toast(kind));
|
||||
|
||||
tracing::info!(
|
||||
target: "settings",
|
||||
key = "plan_mode",
|
||||
value = new,
|
||||
"setting changed",
|
||||
);
|
||||
|
||||
// OFF targets `SessionMode::Default`, not the user's prior mode.
|
||||
// If the user was in `Ask` (shell-injection only), that preference
|
||||
// is silently dropped. See `PLAN_MODE_CHOICES` in `settings/defs.rs`.
|
||||
let mode_id = acp::SessionModeId::new(if new {
|
||||
kigi_tools::types::SessionMode::Plan.as_id()
|
||||
} else {
|
||||
kigi_tools::types::SessionMode::Default.as_id()
|
||||
});
|
||||
|
||||
vec![Effect::SetSessionMode {
|
||||
session_id,
|
||||
mode_id,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Format the `Plan mode` toast. Non-destructive in both directions
|
||||
/// (unlike YOLO), so both ON and OFF use the uniform ✓ glyph.
|
||||
/// Uses lowercase "on"/"off" via `save_success_toast`.
|
||||
fn plan_mode_toast(kind: crate::app::actions::PlanModeKind) -> String {
|
||||
save_success_toast("Plan mode", kind.to_bool())
|
||||
}
|
||||
|
||||
/// The single gate for client paths that ENABLE always-approve: `Some(reason)`
|
||||
/// iff `enabling` and the pin (`app.yolo_policy_block`) is set. Every enabling
|
||||
/// path routes through here (or [`refuse_if_yolo_locked`]) so new paths stay
|
||||
/// gated by default; callers must NOT persist on a refusal.
|
||||
pub(super) fn yolo_enable_blocked(app: &AppView, enabling: bool) -> Option<&'static str> {
|
||||
if enabling {
|
||||
app.yolo_policy_block
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `Vec<Effect>` wrapper for the persisting setters: on a refusal, toast and
|
||||
/// return `Some(vec![])` (no persist); `None` means proceed.
|
||||
fn refuse_if_yolo_locked(app: &mut AppView, enabling: bool) -> Option<Vec<Effect>> {
|
||||
let warning = yolo_enable_blocked(app, enabling)?;
|
||||
app.show_toast(warning);
|
||||
Some(vec![])
|
||||
}
|
||||
|
||||
/// Canonical "auto wins only when yolo is off" precedence — the single source
|
||||
/// of truth for the yolo-over-auto rule applied at every reconnect / seed / meta
|
||||
/// site. Callers pass the already-resolved auto signal (a per-session flag or a
|
||||
/// `permission_mode == Some("auto")` test).
|
||||
pub(crate) fn effective_auto(yolo: bool, auto: bool) -> bool {
|
||||
!yolo && auto
|
||||
}
|
||||
|
||||
/// When the auto gate is off, force the displayed permission mode off Auto and
|
||||
/// clear every agent's per-session auto flag, so the UI / Shift+Tab cycle /
|
||||
/// settings snapshot and each tab's badge never show Auto while the feature is
|
||||
/// disabled. Shared by the startup reconcile and the mid-session kill-switch.
|
||||
/// Clearing every agent (not just when the global mirror still reads "auto")
|
||||
/// matters because `switch_to_agent` re-anchors the mirror to the active tab.
|
||||
pub(crate) fn downgrade_displayed_auto_if_gated(app: &mut AppView) {
|
||||
if app.auto_mode_gate {
|
||||
return;
|
||||
}
|
||||
for agent in app.agents.values_mut() {
|
||||
agent.session.auto_mode = false;
|
||||
}
|
||||
if app.current_ui.permission_mode.as_deref() == Some("auto") {
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a newly created session should start with the Auto display flag set:
|
||||
/// the gate is on, the current UI mode is Auto, and yolo is not winning. Mirrors
|
||||
/// the canonical `auto && !yolo` precedence used on the wire (`ClientCapabilities`
|
||||
/// / `SessionFlags`). The `auto_mode_gate` check is defense-in-depth so a stale
|
||||
/// `current_ui == "auto"` can never seed a new session into Auto when gated off.
|
||||
pub(super) fn inherit_auto_mode(app: &AppView) -> bool {
|
||||
app.auto_mode_gate
|
||||
&& effective_auto(
|
||||
app.default_yolo,
|
||||
app.current_ui.permission_mode.as_deref() == Some("auto"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Keep the active session's `auto_mode` display flag in lockstep with the
|
||||
/// applied canonical permission mode. The canonical (`app.current_ui
|
||||
/// .permission_mode`) is the single value every mode-change path finalizes —
|
||||
/// the cycle, the settings setter, and the rollback all write it — so deriving
|
||||
/// the flag from it (and clearing it under yolo, which wins) keeps the prompt
|
||||
/// "auto" indicator correct regardless of which seam applied the mode.
|
||||
pub(super) fn sync_active_auto_flag(app: &mut AppView) {
|
||||
let is_auto = app.current_ui.permission_mode.as_deref() == Some("auto");
|
||||
if let ActiveView::Agent(id) = app.active_view
|
||||
&& let Some(agent) = app.agents.get_mut(&id)
|
||||
{
|
||||
agent.session.auto_mode = effective_auto(agent.session.is_yolo(), is_auto);
|
||||
}
|
||||
// Keep `/auto` feature-gate visibility in lockstep across slash surfaces.
|
||||
app.sync_permission_mode_slash_gate();
|
||||
}
|
||||
|
||||
/// State-only `permission_mode` (YOLO) mutation; also called from rollback.
|
||||
/// Flips to ON are refused while the pin is set.
|
||||
pub(super) fn set_yolo_mode_inner(app: &mut AppView, new: bool) {
|
||||
if yolo_enable_blocked(app, new).is_some() {
|
||||
tracing::warn!("always-approve enable blocked by managed policy");
|
||||
return;
|
||||
}
|
||||
// Global mirrors update unconditionally (even if the user navigated
|
||||
// away from the agent mid-rollback). Per-agent state is gated below.
|
||||
app.default_yolo = new;
|
||||
app.permission_mode_from_soft_default = false;
|
||||
// Write-only mirror — see fn doc-comment.
|
||||
app.current_ui.permission_mode = Some(if new { "always-approve" } else { "ask" }.to_string());
|
||||
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return;
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let previous_state = agent.session.is_yolo();
|
||||
|
||||
// Drain ordering invariant: flag flip BEFORE the drain (see fn
|
||||
// doc-comment). Do NOT reorder these without re-reading the
|
||||
// contract.
|
||||
agent.session.yolo_mode = new;
|
||||
|
||||
if new {
|
||||
// YOLO ON: auto-approve all queued permissions. Drain runs
|
||||
// even on idempotent re-dispatch. Prefers `AllowOnce`; falls
|
||||
// back to `Cancelled` (never `AllowAlways`).
|
||||
agent.last_permission_click = None;
|
||||
for perm in agent.permission_queue.drain(..) {
|
||||
if let Some(allow) = perm
|
||||
.options
|
||||
.iter()
|
||||
.find(|o| o.kind == acp::PermissionOptionKind::AllowOnce)
|
||||
{
|
||||
perm.request
|
||||
.response_tx
|
||||
.send(Ok(acp::RequestPermissionResponse::new(
|
||||
acp::RequestPermissionOutcome::Selected(
|
||||
acp::SelectedPermissionOutcome::new(allow.option_id.clone()),
|
||||
),
|
||||
)))
|
||||
.ok();
|
||||
} else {
|
||||
perm.request
|
||||
.response_tx
|
||||
.send(Ok(acp::RequestPermissionResponse::new(
|
||||
acp::RequestPermissionOutcome::Cancelled,
|
||||
)))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
// Restore stashed prompt since queue is now empty.
|
||||
if let Some(stashed) = agent.permission_stashed_prompt.take() {
|
||||
agent.prompt.restore(stashed);
|
||||
}
|
||||
}
|
||||
|
||||
// Telemetry + tracing guarded on real state change only.
|
||||
if previous_state != new {
|
||||
tracing::info!(target: "settings", key = "permission_mode", value = new, "setting changed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Set YOLO (`permission_mode`). SHELL-owned, emits
|
||||
/// `Effect::PersistPermissionMode` with rollback. The drain runs
|
||||
/// unconditionally on YOLO=ON (even duplicate dispatches) because
|
||||
/// a permission could arrive between dispatches.
|
||||
fn capture_prev_permission_canonical(app: &AppView, prev_yolo: bool) -> &'static str {
|
||||
if prev_yolo {
|
||||
"always-approve"
|
||||
} else {
|
||||
match app.current_ui.permission_mode.as_deref() {
|
||||
Some("default") => "default",
|
||||
Some("auto") => "auto",
|
||||
_ => "ask",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_yolo_mode(app: &mut AppView, new: bool) -> Vec<Effect> {
|
||||
// Managed policy pins always-approve off — no state change, no persist.
|
||||
if let Some(blocked) = refuse_if_yolo_locked(app, new) {
|
||||
return blocked;
|
||||
}
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
// Capture LIVE yolo + plan state and session_id atomically for rollback.
|
||||
let (prev_yolo, session_id, effective_plan) = app
|
||||
.agents
|
||||
.get(&id)
|
||||
.map(|a| {
|
||||
(
|
||||
a.session.is_yolo(),
|
||||
a.session.session_id.clone(),
|
||||
a.plan_mode_pending.unwrap_or(a.plan_mode_active),
|
||||
)
|
||||
})
|
||||
.unwrap_or((false, None, false));
|
||||
let prev_canonical = capture_prev_permission_canonical(app, prev_yolo);
|
||||
|
||||
set_yolo_mode_inner(app, new);
|
||||
|
||||
// Refresh modal snapshots so the indicator reflects the new value.
|
||||
refresh_open_settings_modals(app);
|
||||
// Toggling yolo always lands on ask/always-approve (never auto); keep the
|
||||
// per-session auto display flag in sync (clears it).
|
||||
sync_active_auto_flag(app);
|
||||
|
||||
// Toast on every save. YOLO ON gets a weightier visual; under an active
|
||||
// plan mode, say the plan edit gate stays binding — "all tool actions
|
||||
// auto-run" would overpromise while the shell rejects non-plan-file edits.
|
||||
if new && effective_plan {
|
||||
app.show_toast(YOLO_ON_UNDER_PLAN_TOAST);
|
||||
} else {
|
||||
app.show_toast(&yolo_toast(new));
|
||||
}
|
||||
|
||||
// Forward write is always "ask" or "always-approve" (bool entry
|
||||
// point). Rollback uses `prev_canonical` with LIVE precedence.
|
||||
let canonical: &'static str = if new { "always-approve" } else { "ask" };
|
||||
vec![Effect::PersistPermissionMode {
|
||||
canonical,
|
||||
session_id,
|
||||
persist: crate::app::actions::PermissionModePersist::WithRollback(prev_canonical),
|
||||
}]
|
||||
}
|
||||
|
||||
/// Set permission mode by typed kind. Entry point from the settings
|
||||
/// modal. Mirrors `set_yolo_mode` but preserves the canonical string
|
||||
/// (the inner collapses "default" onto "ask"; this setter restores
|
||||
/// the distinction by overriding `app.current_ui.permission_mode`
|
||||
/// after the inner call). Rollback uses LIVE-precedence canonical.
|
||||
pub(super) fn set_permission_mode(
|
||||
app: &mut AppView,
|
||||
kind: crate::app::actions::PermissionModeKind,
|
||||
) -> Vec<Effect> {
|
||||
// Feature gate: a commit to Auto is inert when the auto permission-mode
|
||||
// feature is disabled. Reading `app.auto_mode_gate` here (the same source
|
||||
// the Shift+Tab cycle uses) keeps the settings modal and the cycle in
|
||||
// lockstep — both degrade Auto → Ask when the gate is off.
|
||||
let kind =
|
||||
if matches!(kind, crate::app::actions::PermissionModeKind::Auto) && !app.auto_mode_gate {
|
||||
crate::app::actions::PermissionModeKind::Ask
|
||||
} else {
|
||||
kind
|
||||
};
|
||||
// Managed policy pins always-approve off — keep the modal on live state.
|
||||
if let Some(blocked) = refuse_if_yolo_locked(app, kind.is_always_approve()) {
|
||||
refresh_open_settings_modals(app);
|
||||
return blocked;
|
||||
}
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
// Capture LIVE yolo + plan state and session_id atomically for rollback.
|
||||
let (prev_yolo, session_id, effective_plan) = app
|
||||
.agents
|
||||
.get(&id)
|
||||
.map(|a| {
|
||||
(
|
||||
a.session.is_yolo(),
|
||||
a.session.session_id.clone(),
|
||||
a.plan_mode_pending.unwrap_or(a.plan_mode_active),
|
||||
)
|
||||
})
|
||||
.unwrap_or((false, None, false));
|
||||
let prev_canonical = capture_prev_permission_canonical(app, prev_yolo);
|
||||
|
||||
// State mutation via shared inner. We overwrite the canonical
|
||||
// below for the Default case. Inner clears the soft-default latch.
|
||||
set_yolo_mode_inner(app, kind.is_always_approve());
|
||||
|
||||
// Restore the "default" distinction the inner's bool-projection
|
||||
// collapses. No-op for `AlwaysApprove` and `Ask`.
|
||||
app.current_ui.permission_mode = Some(kind.as_canonical().to_string());
|
||||
|
||||
// Refresh modal so its snapshot reflects the overridden canonical.
|
||||
refresh_open_settings_modals(app);
|
||||
// Keep the per-session auto display flag in sync with the applied canonical
|
||||
// (`kind` was already degraded to Ask when the gate is off, so a remaining
|
||||
// Auto here means the gate passed).
|
||||
sync_active_auto_flag(app);
|
||||
|
||||
// Toast on every save (plan-aware for AlwaysApprove, mirroring
|
||||
// `set_yolo_mode` — the plan edit gate stays binding under yolo).
|
||||
if kind.is_always_approve() && effective_plan {
|
||||
app.show_toast(YOLO_ON_UNDER_PLAN_TOAST);
|
||||
} else {
|
||||
app.show_toast(&permission_mode_toast(kind));
|
||||
}
|
||||
|
||||
vec![Effect::PersistPermissionMode {
|
||||
canonical: kind.as_canonical(),
|
||||
session_id,
|
||||
persist: crate::app::actions::PermissionModePersist::WithRollback(prev_canonical),
|
||||
}]
|
||||
}
|
||||
|
||||
/// Build the toast for a `permission_mode` commit. `AlwaysApprove`
|
||||
/// reuses `yolo_toast(true)` (destructive). `Ask` and `Default` get
|
||||
/// dedicated "Permission mode: ..." toasts matching the picker brand.
|
||||
pub(super) fn permission_mode_toast(kind: crate::app::actions::PermissionModeKind) -> String {
|
||||
use crate::app::actions::PermissionModeKind;
|
||||
match kind {
|
||||
PermissionModeKind::AlwaysApprove => yolo_toast(true),
|
||||
PermissionModeKind::Auto => "\u{2713} Permission mode: Auto (classifier)".to_string(),
|
||||
PermissionModeKind::Ask => "\u{2713} Permission mode: Ask".to_string(),
|
||||
PermissionModeKind::Default => "\u{2713} Permission mode: Default".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// YOLO-ON toast when plan mode is active: always-approve arms the permission
|
||||
/// fast path, but the shell's plan-mode gate still rejects non-plan-file
|
||||
/// edits, so the standard "all tool actions auto-run" would overpromise.
|
||||
pub(super) const YOLO_ON_UNDER_PLAN_TOAST: &str =
|
||||
"\u{26A0} Always-approve ON: plan mode still blocks file edits until you exit plan mode";
|
||||
|
||||
/// Build the YOLO toast — ⚠ on ON (destructive), ✓ on OFF (safe default).
|
||||
fn yolo_toast(new: bool) -> String {
|
||||
if new {
|
||||
// Warning glyph + consequence — only post-commit feedback.
|
||||
"\u{26A0} Always-approve ON: all tool actions auto-run".to_string()
|
||||
} else {
|
||||
// OFF restores safe default — uniform ✓ glyph.
|
||||
save_success_toast("Always-approve", false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle YOLO mode (Ctrl+O keybinding path). Delegates to the
|
||||
/// registry-driven `set_yolo_mode` so permission-queue draining,
|
||||
/// telemetry, and persistence all flow through a single code path.
|
||||
pub(super) fn dispatch_toggle_yolo(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let new = !agent.session.yolo_mode;
|
||||
set_yolo_mode(app, new)
|
||||
}
|
||||
|
||||
/// Shift+Tab mode cycle from the agent chat view: the shared cycle body plus
|
||||
/// plan-nudge acceptance telemetry (the nudge advertises this chord). The
|
||||
/// dashboard peek calls [`dispatch_cycle_mode_and_sync`] instead, so a peeked
|
||||
/// agent — whose prompt the user is not looking at — never attributes an accept
|
||||
/// and never collapses Auto/Always-Approve for the nudge jump.
|
||||
pub(super) fn dispatch_cycle_mode(app: &mut AppView) -> Vec<Effect> {
|
||||
// Capture the pre-cycle nudge visibility + plan state so only a transition
|
||||
// into Plan taken while the nudge is on screen attributes as an acceptance;
|
||||
// a disabled/absent nudge never emits.
|
||||
let (nudge_showing, in_plan_before) = active_agent_plan_nudge_state(app);
|
||||
// Tip copy promises one Shift+Tab → Plan; collapse Auto/Always-Approve to
|
||||
// ask first so the ring's Normal→Plan arm is the sole Plan entry.
|
||||
let mut effects = collapse_to_ask_for_nudge_jump(app).unwrap_or_default();
|
||||
effects.extend(dispatch_cycle_mode_and_sync(app));
|
||||
// Re-read only `in_plan`, via the same mut agent handle used to retire the
|
||||
// nudge: entering Plan with the nudge up is an acceptance.
|
||||
if nudge_showing
|
||||
&& !in_plan_before
|
||||
&& let ActiveView::Agent(id) = app.active_view
|
||||
&& let Some(agent) = app.agents.get_mut(&id)
|
||||
&& agent.plan_mode_pending.unwrap_or(agent.plan_mode_active)
|
||||
{
|
||||
// Retire the now-stale nudge so one impression maps to at most one
|
||||
// acceptance — a full mode loop back to Plan within the ~3s TTL would
|
||||
// otherwise re-emit — unifying with the undo/image tips' clear-on-accept.
|
||||
agent
|
||||
.ephemeral_tip
|
||||
.clear(crate::tips::plan_nudge::PLAN_NUDGE_KEY);
|
||||
}
|
||||
effects
|
||||
}
|
||||
|
||||
/// When the plan nudge is showing and the active agent is in Auto or
|
||||
/// Always-Approve, collapse permission to ask (no banner / no Plan effects)
|
||||
/// so the subsequent ring step is Normal→Plan. Returns `None` when the ring
|
||||
/// should run alone (Normal, absent nudge, already-in-plan, or no session).
|
||||
/// Agent-view only — peek never calls this.
|
||||
fn collapse_to_ask_for_nudge_jump(app: &mut AppView) -> Option<Vec<Effect>> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return None;
|
||||
};
|
||||
let agent = app.agents.get(&id)?;
|
||||
if agent.ephemeral_tip.current_key() != Some(crate::tips::plan_nudge::PLAN_NUDGE_KEY) {
|
||||
return None;
|
||||
}
|
||||
let in_plan = agent.plan_mode_pending.unwrap_or(agent.plan_mode_active);
|
||||
if in_plan {
|
||||
return None;
|
||||
}
|
||||
let in_yolo = agent.session.is_yolo();
|
||||
let in_auto = agent.session.is_auto();
|
||||
// Normal → Plan is already a single ring step; only collapse Auto / yolo.
|
||||
if !in_yolo && !in_auto {
|
||||
return None;
|
||||
}
|
||||
let session_id = agent.session.session_id.clone()?;
|
||||
|
||||
if in_yolo {
|
||||
set_yolo_mode_inner(app, false);
|
||||
}
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
sync_active_auto_flag(app);
|
||||
tracing::info!("Mode cycle: collapse to ask for plan nudge jump");
|
||||
Some(vec![Effect::PersistPermissionMode {
|
||||
canonical: "ask",
|
||||
session_id: Some(session_id),
|
||||
persist: crate::app::actions::PermissionModePersist::BestEffort,
|
||||
}])
|
||||
}
|
||||
|
||||
/// The Shift+Tab cycle body shared by the agent view and the dashboard peek:
|
||||
/// apply the mode, then keep the per-session `auto_mode` display flag in sync
|
||||
/// with the freshly written canonical mode — covering every arm (including the
|
||||
/// pre-session and policy-pin early returns) without per-arm edits. Deliberately
|
||||
/// telemetry-free: the dashboard peek reuses it so it can't attribute a
|
||||
/// plan-nudge acceptance for an agent the user isn't viewing.
|
||||
pub(super) fn dispatch_cycle_mode_and_sync(app: &mut AppView) -> Vec<Effect> {
|
||||
app.permission_mode_from_soft_default = false;
|
||||
let effects = dispatch_cycle_mode_inner(app);
|
||||
sync_active_auto_flag(app);
|
||||
effects
|
||||
}
|
||||
|
||||
/// The active agent's `(plan nudge visible, optimistically in plan mode)`, or
|
||||
/// `(false, false)` with no active agent. Lets [`dispatch_cycle_mode`] attribute
|
||||
/// a shift+tab that turns plan mode on while the nudge shows as an acceptance.
|
||||
pub(super) fn active_agent_plan_nudge_state(app: &AppView) -> (bool, bool) {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return (false, false);
|
||||
};
|
||||
match app.agents.get(&id) {
|
||||
Some(agent) => (
|
||||
agent.ephemeral_tip.current_key() == Some(crate::tips::plan_nudge::PLAN_NUDGE_KEY),
|
||||
agent.plan_mode_pending.unwrap_or(agent.plan_mode_active),
|
||||
),
|
||||
None => (false, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle session mode: Normal → Plan → Always-Approve → Normal.
|
||||
///
|
||||
/// Uses `plan_mode_pending` (optimistic) when available, falling back to
|
||||
/// `plan_mode_active` (confirmed by ACP). This prevents double-sends when
|
||||
/// the user presses Shift+Tab faster than the ACP round-trip.
|
||||
fn dispatch_cycle_mode_inner(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
// Capture the pin before borrowing `agent`: the "→ Always-Approve" arms are
|
||||
// yolo-enabling, but a `yolo_enable_blocked(app, _)` call would conflict with
|
||||
// the live `&mut agent`. This is the same predicate (enabling = true here).
|
||||
let yolo_locked = app.yolo_policy_block;
|
||||
// Feature gate (default ON): when the auto permission mode is disabled, the
|
||||
// Shift+Tab cycle skips Auto entirely (legacy Normal→Plan→Always-Approve→
|
||||
// Normal), so Auto is never reachable from the cycle. Resolved once at
|
||||
// startup into `app.auto_mode_gate`.
|
||||
let auto_gate = app.auto_mode_gate;
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
// Per-session (symmetric with the `in_yolo` reads below), not the global UI
|
||||
// mirror, so the cycle and the prompt "auto" indicator agree per agent.
|
||||
let in_auto = agent.session.is_auto();
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
// No session yet (Shift+Tab forwarded from the welcome screen or a
|
||||
// fresh tab): cycle the mode locally and stash the ACP push in
|
||||
// `deferred_session_mode` — consumed by the `SessionCreated`
|
||||
// handlers, same mechanism as the dashboard's staged plan mode.
|
||||
// Cycle: Normal → Plan → Auto → Always-Approve → Normal (Auto skipped
|
||||
// when always-approve is the only remaining arm under a yolo pin).
|
||||
// Each arm yields the canonical permission mode to persist (`None`
|
||||
// when it is untouched, i.e. Normal → Plan); see the push below.
|
||||
let in_plan = agent.plan_mode_pending.unwrap_or(agent.plan_mode_active);
|
||||
let in_yolo = agent.session.is_yolo();
|
||||
let persist_canonical: Option<&'static str> = match (in_plan, in_auto, in_yolo) {
|
||||
// Normal → Plan
|
||||
(false, false, false) => {
|
||||
agent.plan_mode_pending = Some(true);
|
||||
agent.deferred_session_mode = Some(kigi_tools::types::SessionMode::Plan);
|
||||
agent.show_mode_switch_banner("Plan");
|
||||
tracing::info!("Mode cycle (pre-session): Normal → Plan");
|
||||
None
|
||||
}
|
||||
// Plan → Auto (or Plan → Always-Approve when the auto feature is
|
||||
// gated off, matching the legacy Normal→Plan→Always-Approve cycle).
|
||||
(true, false, false) => {
|
||||
agent.plan_mode_pending = Some(false);
|
||||
agent.deferred_session_mode = None;
|
||||
if auto_gate {
|
||||
// Clear any launch-seeded yolo so the created session isn't
|
||||
// started in yolo while the UI shows Auto (SessionFlags reads
|
||||
// default_yolo at CreateSession).
|
||||
agent.session.yolo_mode = false;
|
||||
app.default_yolo = false;
|
||||
app.current_ui.permission_mode = Some("auto".into());
|
||||
agent.show_mode_switch_banner("Auto");
|
||||
tracing::info!("Mode cycle (pre-session): Plan → Auto");
|
||||
Some("auto")
|
||||
} else if let Some(warning) = yolo_locked {
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
agent.session.yolo_mode = false;
|
||||
app.default_yolo = false;
|
||||
agent.show_toast(warning);
|
||||
agent.show_mode_switch_banner("Normal");
|
||||
tracing::info!("Mode cycle (pre-session): Plan → Normal (auto gated, policy)");
|
||||
Some("ask")
|
||||
} else {
|
||||
agent.session.yolo_mode = true;
|
||||
app.default_yolo = true;
|
||||
app.current_ui.permission_mode = Some("always-approve".into());
|
||||
agent.show_mode_switch_banner("Always-Approve");
|
||||
tracing::info!("Mode cycle (pre-session): Plan → Always-Approve (auto gated)");
|
||||
Some("always-approve")
|
||||
}
|
||||
}
|
||||
// Auto → Always-Approve (or Normal if pinned)
|
||||
(false, true, false) => {
|
||||
if let Some(warning) = yolo_locked {
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
agent.session.yolo_mode = false;
|
||||
app.default_yolo = false;
|
||||
agent.show_toast(warning);
|
||||
agent.show_mode_switch_banner("Normal");
|
||||
tracing::info!("Mode cycle (pre-session): Auto → Normal (policy)");
|
||||
Some("ask")
|
||||
} else {
|
||||
agent.session.yolo_mode = true;
|
||||
app.default_yolo = true;
|
||||
app.current_ui.permission_mode = Some("always-approve".into());
|
||||
agent.show_mode_switch_banner("Always-Approve");
|
||||
tracing::info!("Mode cycle (pre-session): Auto → Always-Approve");
|
||||
Some("always-approve")
|
||||
}
|
||||
}
|
||||
// Always-Approve → Normal
|
||||
(false, _, true) => {
|
||||
agent.session.yolo_mode = false;
|
||||
app.default_yolo = false;
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
agent.show_mode_switch_banner("Normal");
|
||||
tracing::info!("Mode cycle (pre-session): Always-Approve → Normal");
|
||||
Some("ask")
|
||||
}
|
||||
// Plan + Auto → Auto (exit plan, keep the classifier), matching the
|
||||
// with-session `(true, true, false, …)` arm. Every other plan+weird
|
||||
// state (notably Plan+yolo) resets to Normal, matching the
|
||||
// with-session catch-all — both paths MUST agree on the same input.
|
||||
// Clear stale yolo so enforcement matches the displayed mode.
|
||||
(true, _, _) => {
|
||||
agent.plan_mode_pending = Some(false);
|
||||
agent.deferred_session_mode = None;
|
||||
agent.session.yolo_mode = false;
|
||||
app.default_yolo = false;
|
||||
if auto_gate && in_auto && !in_yolo {
|
||||
app.current_ui.permission_mode = Some("auto".into());
|
||||
agent.show_mode_switch_banner("Auto");
|
||||
tracing::info!("Mode cycle (pre-session): Plan+Auto → Auto");
|
||||
Some("auto")
|
||||
} else {
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
agent.show_mode_switch_banner("Normal");
|
||||
tracing::info!("Mode cycle (pre-session): Plan(*) → Normal");
|
||||
Some("ask")
|
||||
}
|
||||
}
|
||||
};
|
||||
refresh_open_settings_modals(app);
|
||||
let mut effects = Vec::new();
|
||||
// Persist the displayed mode to disk like the with-session arms do —
|
||||
// otherwise a restart re-reads the stale launch value (e.g. cycling
|
||||
// Always-Approve off pre-session still relaunched in yolo).
|
||||
// `session_id: None` skips the ACP yolo_mode_changed push (nothing to
|
||||
// notify yet; the created session takes its mode from the explicit
|
||||
// `_meta` seeds — see `SessionFlags::to_meta`).
|
||||
if let Some(canonical) = persist_canonical {
|
||||
effects.push(Effect::PersistPermissionMode {
|
||||
canonical,
|
||||
session_id: None,
|
||||
persist: crate::app::actions::PermissionModePersist::BestEffort,
|
||||
});
|
||||
}
|
||||
effects.extend(skip_picker_and_create_session(app, id));
|
||||
return effects;
|
||||
};
|
||||
|
||||
// Effective plan state: prefer optimistic pending over confirmed active.
|
||||
let in_plan = agent.plan_mode_pending.unwrap_or(agent.plan_mode_active);
|
||||
let in_yolo = agent.session.is_yolo();
|
||||
|
||||
match (in_plan, in_auto, in_yolo) {
|
||||
// Normal → Plan
|
||||
(false, false, false) => {
|
||||
agent.plan_mode_pending = Some(true);
|
||||
agent.show_mode_switch_banner("Plan");
|
||||
refresh_open_settings_modals(app);
|
||||
tracing::info!("Mode cycle: Normal → Plan");
|
||||
vec![Effect::SetSessionMode {
|
||||
session_id,
|
||||
mode_id: acp::SessionModeId::new(kigi_tools::types::SessionMode::Plan.as_id()),
|
||||
}]
|
||||
}
|
||||
// Plan → Auto (classifier mode; exit plan, not always-approve).
|
||||
// When the auto feature is gated off, Plan → Always-Approve (skip Auto),
|
||||
// matching the legacy cycle and respecting the yolo policy pin.
|
||||
(true, false, false) => {
|
||||
agent.plan_mode_pending = Some(false);
|
||||
if !auto_gate {
|
||||
if let Some(warning) = yolo_locked {
|
||||
set_yolo_mode_inner(app, false);
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
refresh_open_settings_modals(app);
|
||||
if let Some(a) = app.agents.get_mut(&id) {
|
||||
a.show_toast(warning);
|
||||
a.show_mode_switch_banner("Normal");
|
||||
}
|
||||
tracing::info!(
|
||||
"Mode cycle: Plan → Normal (auto gated, always-approve blocked by policy)"
|
||||
);
|
||||
// Exit Plan on the agent too; a policy pin must not strand the session in Plan.
|
||||
return vec![
|
||||
Effect::SetSessionMode {
|
||||
session_id: session_id.clone(),
|
||||
mode_id: acp::SessionModeId::new(
|
||||
kigi_tools::types::SessionMode::Default.as_id(),
|
||||
),
|
||||
},
|
||||
Effect::PersistPermissionMode {
|
||||
canonical: "ask",
|
||||
session_id: Some(session_id),
|
||||
persist: crate::app::actions::PermissionModePersist::BestEffort,
|
||||
},
|
||||
];
|
||||
}
|
||||
set_yolo_mode_inner(app, true);
|
||||
app.current_ui.permission_mode = Some("always-approve".into());
|
||||
refresh_open_settings_modals(app);
|
||||
if let Some(a) = app.agents.get_mut(&id) {
|
||||
a.show_mode_switch_banner("Always-Approve");
|
||||
}
|
||||
tracing::info!("Mode cycle: Plan → Always-Approve (auto gated)");
|
||||
return vec![
|
||||
Effect::SetSessionMode {
|
||||
session_id: session_id.clone(),
|
||||
mode_id: acp::SessionModeId::new(
|
||||
kigi_tools::types::SessionMode::Default.as_id(),
|
||||
),
|
||||
},
|
||||
Effect::PersistPermissionMode {
|
||||
canonical: "always-approve",
|
||||
session_id: Some(session_id),
|
||||
persist: crate::app::actions::PermissionModePersist::BestEffort,
|
||||
},
|
||||
];
|
||||
}
|
||||
set_yolo_mode_inner(app, false);
|
||||
app.current_ui.permission_mode = Some("auto".into());
|
||||
refresh_open_settings_modals(app);
|
||||
if let Some(a) = app.agents.get_mut(&id) {
|
||||
a.show_mode_switch_banner("Auto");
|
||||
}
|
||||
tracing::info!("Mode cycle: Plan → Auto");
|
||||
vec![
|
||||
Effect::SetSessionMode {
|
||||
session_id: session_id.clone(),
|
||||
mode_id: acp::SessionModeId::new(
|
||||
kigi_tools::types::SessionMode::Default.as_id(),
|
||||
),
|
||||
},
|
||||
Effect::PersistPermissionMode {
|
||||
canonical: "auto",
|
||||
session_id: Some(session_id),
|
||||
persist: crate::app::actions::PermissionModePersist::BestEffort,
|
||||
},
|
||||
]
|
||||
}
|
||||
// Auto → Always-Approve (or Normal when policy pins yolo off)
|
||||
(false, true, false) => {
|
||||
if let Some(warning) = yolo_locked {
|
||||
set_yolo_mode_inner(app, false);
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
refresh_open_settings_modals(app);
|
||||
if let Some(a) = app.agents.get_mut(&id) {
|
||||
a.show_toast(warning);
|
||||
a.show_mode_switch_banner("Normal");
|
||||
}
|
||||
tracing::info!("Mode cycle: Auto → Normal (always-approve blocked by policy)");
|
||||
return vec![Effect::PersistPermissionMode {
|
||||
canonical: "ask",
|
||||
session_id: Some(session_id),
|
||||
persist: crate::app::actions::PermissionModePersist::BestEffort,
|
||||
}];
|
||||
}
|
||||
set_yolo_mode_inner(app, true);
|
||||
app.current_ui.permission_mode = Some("always-approve".into());
|
||||
refresh_open_settings_modals(app);
|
||||
if let Some(a) = app.agents.get_mut(&id) {
|
||||
a.show_mode_switch_banner("Always-Approve");
|
||||
}
|
||||
tracing::info!("Mode cycle: Auto → Always-Approve");
|
||||
vec![Effect::PersistPermissionMode {
|
||||
canonical: "always-approve",
|
||||
session_id: Some(session_id),
|
||||
persist: crate::app::actions::PermissionModePersist::BestEffort,
|
||||
}]
|
||||
}
|
||||
// Always-Approve → Normal
|
||||
(false, _, true) => {
|
||||
set_yolo_mode_inner(app, false);
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
refresh_open_settings_modals(app);
|
||||
if let Some(a) = app.agents.get_mut(&id) {
|
||||
a.show_mode_switch_banner("Normal");
|
||||
}
|
||||
tracing::info!("Mode cycle: Always-Approve → Normal");
|
||||
vec![Effect::PersistPermissionMode {
|
||||
canonical: "ask",
|
||||
session_id: Some(session_id),
|
||||
persist: crate::app::actions::PermissionModePersist::BestEffort,
|
||||
}]
|
||||
}
|
||||
|
||||
// Plan + Auto → Auto: exit plan but keep the classifier. Without this
|
||||
// explicit arm the state falls to `_` and would reset to Normal/ask.
|
||||
(true, true, false) => {
|
||||
agent.plan_mode_pending = Some(false);
|
||||
app.current_ui.permission_mode = Some("auto".into());
|
||||
refresh_open_settings_modals(app);
|
||||
if let Some(a) = app.agents.get_mut(&id) {
|
||||
a.show_mode_switch_banner("Auto");
|
||||
}
|
||||
tracing::info!("Mode cycle: Plan+Auto → Auto (exit plan, keep classifier)");
|
||||
vec![
|
||||
Effect::SetSessionMode {
|
||||
session_id: session_id.clone(),
|
||||
mode_id: acp::SessionModeId::new(
|
||||
kigi_tools::types::SessionMode::Default.as_id(),
|
||||
),
|
||||
},
|
||||
Effect::PersistPermissionMode {
|
||||
canonical: "auto",
|
||||
session_id: Some(session_id),
|
||||
persist: crate::app::actions::PermissionModePersist::BestEffort,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Any other combination → reset to Normal.
|
||||
// YOLO inner only called when actually in YOLO (avoids
|
||||
// spurious telemetry).
|
||||
_ => {
|
||||
agent.plan_mode_pending = Some(false);
|
||||
// NLL releases the `agent` borrow after the assignment
|
||||
// above; `set_yolo_mode_inner(app, …)` can reborrow below.
|
||||
if in_yolo {
|
||||
set_yolo_mode_inner(app, false);
|
||||
}
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
refresh_open_settings_modals(app);
|
||||
if let Some(a) = app.agents.get_mut(&id) {
|
||||
a.show_mode_switch_banner("Normal");
|
||||
}
|
||||
tracing::info!("Mode cycle: mixed state → Normal");
|
||||
let mut effects = vec![];
|
||||
if in_plan {
|
||||
effects.push(Effect::SetSessionMode {
|
||||
session_id: session_id.clone(),
|
||||
mode_id: acp::SessionModeId::new(
|
||||
kigi_tools::types::SessionMode::Default.as_id(),
|
||||
),
|
||||
});
|
||||
}
|
||||
if in_yolo || in_auto {
|
||||
effects.push(Effect::PersistPermissionMode {
|
||||
canonical: "ask",
|
||||
session_id: Some(session_id),
|
||||
persist: crate::app::actions::PermissionModePersist::BestEffort,
|
||||
});
|
||||
}
|
||||
effects
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
//! Feedback, remember-note, btw, and recap dispatchers.
|
||||
|
||||
use super::ctx::with_active_agent;
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::agent_view::{AgentView, PromptInputMode};
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::{SessionEvent, ToolCallBlock};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Monotonic counter for correlating async rewrite responses with the modal
|
||||
/// that requested them. Prevents stale results from populating a different
|
||||
/// note's review modal when the user closes and re-opens quickly.
|
||||
static REWRITE_NONCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn next_rewrite_nonce() -> u64 {
|
||||
REWRITE_NONCE.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Enter feedback mode: visual change to prompt bar (teal accent, pencil prefix).
|
||||
/// No side effects — the user types feedback text and presses Enter to send.
|
||||
pub(super) fn dispatch_enter_feedback_mode(app: &mut AppView) -> Vec<Effect> {
|
||||
with_active_agent(app, |agent| {
|
||||
agent.prompt_input_mode = PromptInputMode::Feedback;
|
||||
agent.prompt.set_text("");
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Enter remember mode: visual change to prompt bar (remember accent, `#` prefix).
|
||||
/// No side effects — the user types a memory note and presses Enter to send.
|
||||
pub(super) fn dispatch_enter_remember_mode(app: &mut AppView) -> Vec<Effect> {
|
||||
with_active_agent(app, |agent| {
|
||||
agent.prompt_input_mode = PromptInputMode::Remember;
|
||||
agent.prompt.set_text("");
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Send feedback text to the server. Shows a thank-you message immediately
|
||||
/// and fires the HTTP POST as a background effect.
|
||||
pub(super) fn dispatch_send_feedback(app: &mut AppView, text: String) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
agent.prompt_input_mode = PromptInputMode::Normal;
|
||||
agent.prompt.set_text("");
|
||||
// Submitting feedback retires any edit-contextual ephemeral tip.
|
||||
agent.ephemeral_tip.clear_on_submit();
|
||||
|
||||
let trimmed = text.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
agent.scrollback.push_block(RenderBlock::system(
|
||||
"Please provide feedback text.".to_string(),
|
||||
));
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system("No active session.".to_string()));
|
||||
return vec![];
|
||||
};
|
||||
|
||||
agent.scrollback.push_block(RenderBlock::system(
|
||||
"Thanks for the feedback! The Grok Build team is on it.".to_string(),
|
||||
));
|
||||
|
||||
vec![Effect::SendFeedback {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
feedback_text: trimmed,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Send a raw remember note for LLM-powered rewriting via `x.ai/memory/rewrite`.
|
||||
/// Clears remember mode and prompts the LLM to reformat the note with session
|
||||
/// context. Falls back to direct `SaveMemoryNote` when no session is available.
|
||||
pub(super) fn dispatch_send_remember_note(app: &mut AppView, text: String) -> Vec<Effect> {
|
||||
use crate::views::modal::ActiveModal;
|
||||
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
agent.prompt_input_mode = PromptInputMode::Normal;
|
||||
agent.prompt.set_text("");
|
||||
// Submitting a memory note retires any edit-contextual ephemeral tip.
|
||||
agent.ephemeral_tip.clear_on_submit();
|
||||
|
||||
let trimmed = text.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
agent.scrollback.push_block(RenderBlock::system(
|
||||
"Please provide a memory note.".to_string(),
|
||||
));
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let cwd = agent.session.cwd.clone();
|
||||
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
// No session — open modal with raw content only (no LLM rewrite).
|
||||
agent.active_modal = Some(ActiveModal::RememberNoteReview {
|
||||
raw_content: trimmed.clone(),
|
||||
enhanced_content: None, // no session → no LLM rewrite, Tab disabled
|
||||
showing_enhanced: false,
|
||||
scroll: 0,
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
cached_lines: None,
|
||||
cwd,
|
||||
agent_id: id,
|
||||
rewrite_nonce: 0, // no rewrite in flight, nonce unused
|
||||
});
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Open modal with raw content, LLM rewrite in flight.
|
||||
let nonce = next_rewrite_nonce();
|
||||
agent.active_modal = Some(ActiveModal::RememberNoteReview {
|
||||
raw_content: trimmed.clone(),
|
||||
enhanced_content: None,
|
||||
showing_enhanced: false,
|
||||
scroll: 0,
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
cached_lines: None,
|
||||
cwd: cwd.clone(),
|
||||
agent_id: id,
|
||||
rewrite_nonce: nonce,
|
||||
});
|
||||
|
||||
let context_summary = extract_session_context(agent);
|
||||
|
||||
vec![Effect::RewriteMemoryNote {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
raw_text: trimmed,
|
||||
context_summary,
|
||||
nonce,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Save the currently displayed remember note from the review modal.
|
||||
pub(super) fn dispatch_save_remember_note_from_modal(app: &mut AppView) -> Vec<Effect> {
|
||||
use crate::views::modal::ActiveModal;
|
||||
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let (content, cwd) = if let Some(ActiveModal::RememberNoteReview {
|
||||
ref raw_content,
|
||||
ref enhanced_content,
|
||||
showing_enhanced,
|
||||
ref cwd,
|
||||
..
|
||||
}) = agent.active_modal
|
||||
{
|
||||
let text = if showing_enhanced {
|
||||
enhanced_content.as_deref().unwrap_or(raw_content)
|
||||
} else {
|
||||
raw_content
|
||||
};
|
||||
(text.trim().to_string(), cwd.clone())
|
||||
} else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
agent.active_modal = None;
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system("Saving memory note...".to_string()));
|
||||
|
||||
vec![Effect::SaveMemoryNote {
|
||||
agent_id: id,
|
||||
text: content,
|
||||
cwd,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Extract session context for the LLM memory rewrite request.
|
||||
///
|
||||
/// Walks scrollback in reverse, collecting:
|
||||
/// - Last 5 user prompts
|
||||
/// - File paths from recent tool calls (Read, Edit, ListDir)
|
||||
/// - CWD and git branch
|
||||
fn extract_session_context(agent: &AgentView) -> String {
|
||||
let mut user_prompts: Vec<String> = Vec::new();
|
||||
let mut file_paths: Vec<String> = Vec::new();
|
||||
|
||||
// Walk scrollback entries in reverse to collect recent context.
|
||||
let len = agent.scrollback.len();
|
||||
for i in (0..len).rev() {
|
||||
let Some(entry) = agent.scrollback.entry(i) else {
|
||||
continue;
|
||||
};
|
||||
match &entry.block {
|
||||
RenderBlock::UserPrompt(prompt) => {
|
||||
if user_prompts.len() < 5 {
|
||||
let text = if prompt.text.len() > 200 {
|
||||
let end = prompt
|
||||
.text
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.take_while(|&i| i <= 200)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
format!("{}...", &prompt.text[..end])
|
||||
} else {
|
||||
prompt.text.clone()
|
||||
};
|
||||
user_prompts.push(text);
|
||||
}
|
||||
}
|
||||
RenderBlock::ToolCall(tc) if file_paths.len() < 20 => match tc {
|
||||
ToolCallBlock::Read(b) => {
|
||||
file_paths.push(b.path.clone());
|
||||
}
|
||||
ToolCallBlock::Edit(b) => {
|
||||
file_paths.push(b.path.clone());
|
||||
}
|
||||
ToolCallBlock::ListDir(b) => {
|
||||
file_paths.push(b.path.clone());
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
// Stop early once we have enough context.
|
||||
if user_prompts.len() >= 5 && file_paths.len() >= 20 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
// CWD
|
||||
parts.push(format!("CWD: {}", agent.session.cwd.display()));
|
||||
|
||||
// Git branch
|
||||
if let Some(ref branch) = agent.current_branch {
|
||||
parts.push(format!("Branch: {branch}"));
|
||||
}
|
||||
|
||||
// Recent prompts (chronological order)
|
||||
if !user_prompts.is_empty() {
|
||||
user_prompts.reverse();
|
||||
parts.push("Recent prompts:".to_string());
|
||||
for p in &user_prompts {
|
||||
parts.push(format!("- {p}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Recent file paths (deduplicated, preserving first-seen order)
|
||||
if !file_paths.is_empty() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
file_paths.retain(|p| seen.insert(p.clone()));
|
||||
parts.push("Recent files:".to_string());
|
||||
for p in &file_paths {
|
||||
parts.push(format!("- {p}"));
|
||||
}
|
||||
}
|
||||
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
/// Send a /btw side question. Bypasses the prompt queue — works even while
|
||||
/// the agent is mid-turn. Fires an ACP ext method and shows a loading overlay.
|
||||
pub(super) fn dispatch_send_btw(app: &mut AppView, question: String) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
agent.show_toast("No active session");
|
||||
return vec![];
|
||||
};
|
||||
|
||||
agent.prompt.set_text("");
|
||||
agent.btw_state = Some(crate::views::btw_overlay::BtwOverlayState::Loading {
|
||||
question: question.clone(),
|
||||
});
|
||||
// Prompt keeps focus while the answer is in flight (panel focuses on Done).
|
||||
agent.btw_focused = false;
|
||||
|
||||
vec![Effect::SendBtw {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
question,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Toast when a manual `/recap` produces no summary. Empty sessions get a clear
|
||||
/// empty-state message; anything else (model failure, empty summary, etc.) keeps
|
||||
/// the generic failure toast.
|
||||
pub(crate) fn recap_unavailable_toast(has_user_messages: bool) -> &'static str {
|
||||
if has_user_messages {
|
||||
"Couldn't generate recap"
|
||||
} else {
|
||||
"No messages yet"
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether scrollback already has a user prompt. Scans entries (not
|
||||
/// `turn_count`) so it stays correct during `begin_batch`/`end_batch` session
|
||||
/// load, when `push` defers `rebuild_turns` and `turn_count` can stay 0 while
|
||||
/// replayed prompts are already present.
|
||||
pub(crate) fn scrollback_has_user_messages(
|
||||
scrollback: &crate::scrollback::state::ScrollbackState,
|
||||
) -> bool {
|
||||
scrollback
|
||||
.iter_entries()
|
||||
.any(|(_, entry)| entry.block.is_user_prompt())
|
||||
}
|
||||
|
||||
/// Request a session recap. Bypasses the prompt queue — works even while the
|
||||
/// agent is mid-turn. Fires the `x.ai/recap` ext method; the recap arrives
|
||||
/// asynchronously as a `SessionRecap` notification (rendered in scrollback).
|
||||
///
|
||||
/// `auto` is `false` for an explicit `/recap` and `true` for the automatic
|
||||
/// return-from-away recap. For the manual path we clear the prompt and, when
|
||||
/// no session exists yet, surface a toast; the auto path is best-effort and
|
||||
/// silently no-ops without an active session.
|
||||
pub(super) fn dispatch_send_recap(app: &mut AppView, auto: bool) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Shell is authoritative (remote settings / config / env). Skip client requests
|
||||
// entirely when the feature is off so we never hit `x.ai/recap`.
|
||||
if !app.session_recap_available {
|
||||
if !auto {
|
||||
agent.show_toast("Session recap is not enabled");
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
if !auto {
|
||||
agent.show_toast("No active session");
|
||||
}
|
||||
return vec![];
|
||||
};
|
||||
|
||||
if !auto {
|
||||
agent.prompt.set_text("");
|
||||
// Nothing to summarize yet — show a clear empty-state toast instead of
|
||||
// a spinner that ends in "Couldn't generate recap".
|
||||
//
|
||||
// Skip the short-circuit while session replay is still loading (prompts
|
||||
// may not have arrived yet). Prefer an entry scan over `turn_count()`
|
||||
// so mid-batch resume (deferred `rebuild_turns`) still sees history.
|
||||
if !agent.session.loading_replay && !scrollback_has_user_messages(&agent.scrollback) {
|
||||
agent.show_toast(recap_unavailable_toast(false));
|
||||
return vec![];
|
||||
}
|
||||
// Show an immediate loading block with the animated "running" sidebar so
|
||||
// the user has feedback that a recap is being generated. The
|
||||
// `SessionRecap` handler fills this entry in and stops the animation.
|
||||
// Reuse an existing in-flight loading block instead of stacking spinners
|
||||
// when `/recap` is pressed repeatedly.
|
||||
let already_loading = agent.pending_recap_entry.is_some_and(|eid| {
|
||||
agent
|
||||
.scrollback
|
||||
.get_by_id(eid)
|
||||
.is_some_and(|entry| entry.is_running)
|
||||
});
|
||||
if !already_loading {
|
||||
let entry_id =
|
||||
agent
|
||||
.scrollback
|
||||
.push(crate::scrollback::entry::ScrollbackEntry::running(
|
||||
RenderBlock::session_event(SessionEvent::Recap {
|
||||
summary: String::new(),
|
||||
auto: false,
|
||||
}),
|
||||
));
|
||||
agent.pending_recap_entry = Some(entry_id);
|
||||
}
|
||||
} else {
|
||||
// Retry backoff only — do not consume the away period on dispatch.
|
||||
// The shell often no-ops auto recap until ≥3 min since the last main
|
||||
// turn; mark_recap_shown runs when any SessionRecap arrives (auto or
|
||||
// manual `/recap`).
|
||||
app.notification_service
|
||||
.focus_tracker
|
||||
.note_auto_recap_attempt();
|
||||
}
|
||||
|
||||
vec![Effect::SendRecap { session_id, auto }]
|
||||
}
|
||||
|
||||
// TaskResult handlers.
|
||||
|
||||
pub(super) fn handle_memory_note_saved(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
result: Result<(), String>,
|
||||
) -> Vec<Effect> {
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(crate::scrollback::block::RenderBlock::system(format!(
|
||||
"Memory saved to {}",
|
||||
crate::util::display_user_grok_path("memory/MEMORY.md")
|
||||
)));
|
||||
}
|
||||
Err(error) => {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(crate::scrollback::block::RenderBlock::system(format!(
|
||||
"Couldn't save memory note: {error}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_btw_response(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
result: Result<String, String>,
|
||||
) -> Vec<Effect> {
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
use crate::views::btw_overlay::BtwOverlayState;
|
||||
let question = match &agent.btw_state {
|
||||
Some(BtwOverlayState::Loading { question }) => question.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
match result {
|
||||
Ok(response) => {
|
||||
// Answer arrived: show it (until Esc) and focus the panel
|
||||
// so Up/Down scroll it until the user returns to the prompt.
|
||||
agent.btw_state = Some(BtwOverlayState::done(question, response));
|
||||
agent.btw_focused = true;
|
||||
}
|
||||
Err(error) => {
|
||||
// Error stays until Esc; nothing to scroll, keep prompt focus.
|
||||
agent.btw_state = Some(BtwOverlayState::Error { question, error });
|
||||
agent.btw_focused = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Permission request selection, follow-up, cancellation, and queue draining.
|
||||
|
||||
use super::modes::set_yolo_mode;
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permission dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handle permission option selection (AllowOnce, AllowAlways, RejectAlways).
|
||||
///
|
||||
/// Pops the front request, sends the response, and handles queue transitions
|
||||
/// (prompt restore on empty, prompt clear on next-front).
|
||||
///
|
||||
/// Special case for [`kigi_workspace::permission::ENABLE_ALWAYS_APPROVE_OPTION_ID`]:
|
||||
/// when the user picks the prepended "Yes, and don't ask again for anything"
|
||||
/// option, this dispatcher (a) sends the standard `Selected` response so the
|
||||
/// in-flight request is allowed once (the shell's `map_selected_outcome`
|
||||
/// resolves the id to `PromptOutcome::AllowOnce`), then (b) reuses the
|
||||
/// existing `set_yolo_mode(true)` flow to flip the local YOLO state, drain
|
||||
/// any remaining queued permissions, persist `[ui] permission_mode =
|
||||
/// "always-approve"` to `~/.kigi/config.toml`, and fire the
|
||||
/// `x.ai/yolo_mode_changed` ACP notification. See the option-id constant
|
||||
/// doc-comment for the full client/shell split. Under a managed-policy
|
||||
/// pin step (b) is refused with a toast — the request is still allowed once.
|
||||
pub(super) fn dispatch_permission_select(
|
||||
app: &mut AppView,
|
||||
option_id: acp::PermissionOptionId,
|
||||
) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(perm) = agent.permission_queue.pop_front() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Detect the "enable always-approve mode" id BEFORE moving option_id
|
||||
// into the response. Cheap str compare on the `Arc<str>` interior.
|
||||
let enable_always_approve =
|
||||
option_id.0.as_ref() == kigi_workspace::permission::ENABLE_ALWAYS_APPROVE_OPTION_ID;
|
||||
|
||||
// Remember the user's choice (by option kind) so the next prompt's cursor
|
||||
// sticks to it. Allow-flavored choices only — a rejection must not steer a
|
||||
// later prompt's cursor onto a reject row. Also skip the two options that
|
||||
// aren't per-prompt choices:
|
||||
// - the global always-approve (YOLO) option flips global auto-approve, so
|
||||
// there will be no subsequent prompt to land on;
|
||||
// - "allow all edits during this session" is edit-scoped (kind
|
||||
// `AllowAlways`) — letting it stick would steer an unrelated later
|
||||
// prompt onto its "always allow this command" row, escalating scope.
|
||||
let steers_next_cursor = !enable_always_approve
|
||||
&& option_id.0.as_ref() != kigi_workspace::permission::ALLOW_EDITS_SESSION_OPTION_ID;
|
||||
if steers_next_cursor
|
||||
&& let Some(kind) = perm
|
||||
.options
|
||||
.iter()
|
||||
.find(|o| o.option_id == option_id)
|
||||
.map(|o| o.kind)
|
||||
&& matches!(
|
||||
kind,
|
||||
acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways
|
||||
)
|
||||
{
|
||||
crate::appearance::permission_cursor::set_last_used_permission(
|
||||
crate::appearance::permission_cursor::DefaultSelectedPermission::from_kind(&kind),
|
||||
);
|
||||
}
|
||||
|
||||
// Build response meta. MCP and bash flows are mutually exclusive at
|
||||
// the per-request level; check MCP first because it owns the
|
||||
// `allow-always-mcp` option id and the bash branch is the existing
|
||||
// fallback.
|
||||
let meta = if let Some(scope) = perm
|
||||
.mcp_scope
|
||||
.as_ref()
|
||||
.filter(|_| option_id.0.as_ref() == "allow-always-mcp")
|
||||
{
|
||||
let selection = match scope.selected {
|
||||
crate::views::permission_view::McpScope::Tool => {
|
||||
kigi_workspace::permission::McpScopeSelection::Tool {
|
||||
tool_name: scope.tool_name.clone(),
|
||||
}
|
||||
}
|
||||
crate::views::permission_view::McpScope::Server => match &scope.server_prefix {
|
||||
Some(prefix) => kigi_workspace::permission::McpScopeSelection::Server {
|
||||
server: prefix.clone(),
|
||||
},
|
||||
// Defensive: render path should disable Server when no prefix.
|
||||
None => kigi_workspace::permission::McpScopeSelection::Tool {
|
||||
tool_name: scope.tool_name.clone(),
|
||||
},
|
||||
},
|
||||
};
|
||||
serde_json::to_value(selection)
|
||||
.ok()
|
||||
.and_then(|v| v.as_object().cloned())
|
||||
} else if let Some(ref h) = perm.bash_highlights
|
||||
&& perm.bash_selection_count > 0
|
||||
{
|
||||
let parts: Vec<String> = h.highlighted_words[..perm.bash_selection_count].to_vec();
|
||||
serde_json::to_value(kigi_workspace::permission::BashCommandSelectedTerms {
|
||||
command_parts: parts,
|
||||
})
|
||||
.ok()
|
||||
.and_then(|v| v.as_object().cloned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
perm.request
|
||||
.response_tx
|
||||
.send(Ok(acp::RequestPermissionResponse::new(
|
||||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(option_id)),
|
||||
)
|
||||
.meta(meta)))
|
||||
.ok();
|
||||
|
||||
// Queue transition: restore prompt if queue is now empty, clear if next-front.
|
||||
resolve_permission_queue_transition(agent);
|
||||
|
||||
// "Enable always-approve" side effect: flip YOLO + persist + notify.
|
||||
// Reuses the existing `set_yolo_mode` pipeline so telemetry, queue
|
||||
// drain, toast, modal refresh, config persistence, and ACP
|
||||
// notification all flow through one well-tested code path.
|
||||
//
|
||||
// Idempotency: if YOLO is already on, the pager auto-approves in
|
||||
// `handle_permission_request` before the panel is shown, so the
|
||||
// user couldn't have selected this option. The `is_yolo()` guard
|
||||
// is defensive — a redundant call would re-emit the toast and a
|
||||
// duplicate `PersistPermissionMode` effect, but is otherwise safe.
|
||||
if enable_always_approve {
|
||||
let already_on = app
|
||||
.agents
|
||||
.get(&id)
|
||||
.map(|a| a.session.is_yolo())
|
||||
.unwrap_or(false);
|
||||
if !already_on {
|
||||
return set_yolo_mode(app, true);
|
||||
}
|
||||
}
|
||||
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Handle permission followup message (RejectOnce with user-typed text).
|
||||
pub(super) fn dispatch_permission_followup(app: &mut AppView, text: String) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(perm) = agent.permission_queue.pop_front() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Find the RejectOnce option.
|
||||
let option_id = perm
|
||||
.options
|
||||
.iter()
|
||||
.find(|o| o.kind == acp::PermissionOptionKind::RejectOnce)
|
||||
.map(|o| o.option_id.clone());
|
||||
|
||||
let Some(option_id) = option_id else {
|
||||
// No RejectOnce option — cancel instead.
|
||||
perm.request
|
||||
.response_tx
|
||||
.send(Ok(acp::RequestPermissionResponse::new(
|
||||
acp::RequestPermissionOutcome::Cancelled,
|
||||
)))
|
||||
.ok();
|
||||
resolve_permission_queue_transition(agent);
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Include followup message in meta.
|
||||
let meta = if !text.trim().is_empty() {
|
||||
serde_json::json!({
|
||||
"followup_message": text,
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
perm.request
|
||||
.response_tx
|
||||
.send(Ok(acp::RequestPermissionResponse::new(
|
||||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(option_id)),
|
||||
)
|
||||
.meta(meta)))
|
||||
.ok();
|
||||
|
||||
resolve_permission_queue_transition(agent);
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Handle permission cancel (Ctrl-C / Esc — cancels front request only).
|
||||
pub(super) fn dispatch_permission_cancel(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(perm) = agent.permission_queue.pop_front() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
perm.request
|
||||
.response_tx
|
||||
.send(Ok(acp::RequestPermissionResponse::new(
|
||||
acp::RequestPermissionOutcome::Cancelled,
|
||||
)))
|
||||
.ok();
|
||||
|
||||
resolve_permission_queue_transition(agent);
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Drain all queued permission requests, sending `Cancelled` to each.
|
||||
///
|
||||
/// Called on turn-end and turn-cancel. After draining, restores the stashed
|
||||
/// prompt text (if any). This is distinct from `dispatch_permission_cancel`
|
||||
/// which cancels only the front request.
|
||||
pub(super) fn drain_permission_queue(agent: &mut AgentView) {
|
||||
agent.last_permission_click = None;
|
||||
if agent.permission_queue.is_empty() {
|
||||
return;
|
||||
}
|
||||
for perm in agent.permission_queue.drain(..) {
|
||||
perm.request
|
||||
.response_tx
|
||||
.send(Ok(acp::RequestPermissionResponse::new(
|
||||
acp::RequestPermissionOutcome::Cancelled,
|
||||
)))
|
||||
.ok();
|
||||
}
|
||||
// Queue is now empty — restore stashed prompt.
|
||||
if let Some(stashed) = agent.permission_stashed_prompt.take() {
|
||||
agent.prompt.restore(stashed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle queue transition after resolving (select/followup/cancel) the front
|
||||
/// permission request.
|
||||
///
|
||||
/// - Queue now empty → restore stashed prompt text.
|
||||
/// - Queue still has items → clear prompt text (for next followup input)
|
||||
/// and reset next front's focus to Options.
|
||||
pub(crate) fn resolve_permission_queue_transition(agent: &mut AgentView) {
|
||||
agent.last_permission_click = None;
|
||||
if agent.permission_queue.is_empty() {
|
||||
// Restore original prompt.
|
||||
if let Some(stashed) = agent.permission_stashed_prompt.take() {
|
||||
agent.prompt.restore(stashed);
|
||||
}
|
||||
} else {
|
||||
// Clear any followup text from the just-resolved permission so it
|
||||
// doesn't leak into the next permission's UI.
|
||||
agent.prompt.set_text("");
|
||||
// Reset next front's focus to Options.
|
||||
if let Some(next) = agent.permission_queue.front_mut() {
|
||||
next.focus = crate::views::permission_view::PermissionFocus::Options;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,867 @@
|
||||
//! Conversation rewind dispatchers and prompt-entry lookup helpers.
|
||||
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::state::ScrollbackState;
|
||||
use crate::views::prompt_widget::{PromptWidget, StashedPrompt};
|
||||
|
||||
/// User prompt that participates in the shell's prompt numbering.
|
||||
/// Interjections render as user prompts but the shell never numbers them,
|
||||
/// so counting them would skew the positional prompt↔entry mapping.
|
||||
///
|
||||
/// Known approximation: an interjection the shell converted into its own
|
||||
/// `interject-fallback-` turn IS shell-numbered, but its live block (rendered
|
||||
/// from the interjection broadcast) is flagged `is_interjection` and carries
|
||||
/// no index, so the positional fallback under-counts around it until a
|
||||
/// resume replays it as an indexed prompt. The primary path (explicit
|
||||
/// `prompt_index` matches) is unaffected.
|
||||
fn is_indexed_user_prompt(block: &RenderBlock) -> bool {
|
||||
matches!(block, RenderBlock::UserPrompt(b) if !b.is_interjection)
|
||||
}
|
||||
|
||||
fn stash_prompt(prompt: &mut PromptWidget) -> Option<StashedPrompt> {
|
||||
if prompt.text().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(prompt.stash())
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::app) fn shell_prompt_index_at(
|
||||
scrollback: &ScrollbackState,
|
||||
entry_idx: usize,
|
||||
) -> Option<usize> {
|
||||
for idx in (0..=entry_idx).rev() {
|
||||
if let Some(e) = scrollback.get(idx)
|
||||
&& let RenderBlock::UserPrompt(ref block) = e.block
|
||||
{
|
||||
// A mid-turn interjection belongs to the enclosing turn — keep
|
||||
// walking back to that turn's starting prompt.
|
||||
if block.is_interjection {
|
||||
continue;
|
||||
}
|
||||
if let Some(pi) = block.prompt_index {
|
||||
return Some(pi);
|
||||
}
|
||||
let count = (0..=idx)
|
||||
.filter(|&i| {
|
||||
scrollback
|
||||
.get(i)
|
||||
.is_some_and(|e2| is_indexed_user_prompt(&e2.block))
|
||||
})
|
||||
.count();
|
||||
return if count > 0 { Some(count - 1) } else { None };
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(in crate::app) fn find_user_prompt_entry_for_shell_index(
|
||||
scrollback: &ScrollbackState,
|
||||
target_prompt_index: usize,
|
||||
) -> Option<usize> {
|
||||
for idx in (0..scrollback.len()).rev() {
|
||||
if let Some(entry) = scrollback.get(idx)
|
||||
&& let RenderBlock::UserPrompt(ref block) = entry.block
|
||||
&& block.prompt_index == Some(target_prompt_index)
|
||||
{
|
||||
return Some(idx);
|
||||
}
|
||||
}
|
||||
let mut count = 0usize;
|
||||
for idx in 0..scrollback.len() {
|
||||
if let Some(e) = scrollback.get(idx)
|
||||
&& is_indexed_user_prompt(&e.block)
|
||||
{
|
||||
if count == target_prompt_index {
|
||||
return Some(idx);
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
app.show_toast("No active session");
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Rewind takes input priority over the `/jump` picker; close a lingering
|
||||
// one first so it can't reappear (stale) after rewind finishes.
|
||||
agent.dismiss_jump_picker();
|
||||
|
||||
let selected_idx = agent.scrollback.selected();
|
||||
let selected_shell_idx =
|
||||
selected_idx.and_then(|idx| shell_prompt_index_at(&agent.scrollback, idx));
|
||||
|
||||
if agent.session.state.is_busy() {
|
||||
let anchor = agent.scrollback.len().saturating_sub(1);
|
||||
let draft = stash_prompt(&mut agent.prompt);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState::new_cancel_offer(
|
||||
anchor,
|
||||
draft,
|
||||
selected_shell_idx,
|
||||
));
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let draft = stash_prompt(&mut agent.prompt);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Loading,
|
||||
anchor_entry_idx: selected_idx.unwrap_or(0),
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: selected_shell_idx,
|
||||
});
|
||||
|
||||
vec![Effect::FetchRewindPoints {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
}]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_show_picker(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
app.show_toast("No active session");
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Rewind takes input priority over the `/jump` picker; close a lingering
|
||||
// one first so it can't reappear (stale) after rewind finishes.
|
||||
agent.dismiss_jump_picker();
|
||||
|
||||
if agent.session.state.is_busy() {
|
||||
let anchor = agent.scrollback.len().saturating_sub(1);
|
||||
let draft = stash_prompt(&mut agent.prompt);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState::new_cancel_offer(
|
||||
anchor, draft, None,
|
||||
));
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let draft = stash_prompt(&mut agent.prompt);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Loading,
|
||||
anchor_entry_idx: 0,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
|
||||
vec![Effect::FetchRewindPoints {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
}]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_picker_select(app: &mut AppView, prompt_index: usize) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let point = agent.rewind_points.as_ref().and_then(
|
||||
|pts: &Vec<crate::views::rewind::RewindPointInfo>| {
|
||||
pts.iter().find(|p| p.prompt_index == prompt_index)
|
||||
},
|
||||
);
|
||||
let has_file_changes = point.map(|p| p.has_file_changes).unwrap_or(false);
|
||||
|
||||
let anchor = find_user_prompt_entry_for_shell_index(&agent.scrollback, prompt_index);
|
||||
if let Some(entry_idx) = anchor {
|
||||
agent.scrollback.set_selected(Some(entry_idx));
|
||||
}
|
||||
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::ModeSelect {
|
||||
target_prompt_index: prompt_index,
|
||||
has_file_changes,
|
||||
// Inline edit-and-resubmit: the conversation rewind is a given,
|
||||
// so a files-only option makes no sense there.
|
||||
offer_files_only: agent.inline_edit.is_none(),
|
||||
active_idx: 0,
|
||||
},
|
||||
anchor_entry_idx: anchor.unwrap_or(0),
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: Some(prompt_index),
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_cancel_offer(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let selected = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.and_then(|s| s.selected_prompt_index);
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Loading,
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: selected,
|
||||
});
|
||||
let mut effects = vec![Effect::CancelTurn {
|
||||
session_id: session_id.clone(),
|
||||
cancel_subagents: true,
|
||||
trigger: None,
|
||||
// The rewind picker owns history via `handle_rewind`; this pre-cancel
|
||||
// must not also pop the in-flight prompt.
|
||||
rewind_if_pristine: false,
|
||||
}];
|
||||
effects.push(Effect::FetchRewindPoints {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
});
|
||||
effects
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_select_mode(
|
||||
app: &mut AppView,
|
||||
mode: crate::views::rewind::RewindMode,
|
||||
target: usize,
|
||||
) -> Vec<Effect> {
|
||||
use crate::views::rewind::{RewindMode, RewindPhase, RewindState};
|
||||
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
match mode {
|
||||
RewindMode::ConversationOnly if target == 0 => {
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let preview = agent
|
||||
.rewind_points
|
||||
.as_ref()
|
||||
.and_then(|pts| pts.iter().find(|p| p.prompt_index == target))
|
||||
.and_then(|p| p.prompt_preview.clone());
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(RewindState {
|
||||
phase: RewindPhase::ConversationOnlyConfirm {
|
||||
target_prompt_index: target,
|
||||
active_idx: 0,
|
||||
prompt_preview: preview,
|
||||
},
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
RewindMode::ConversationOnly => {
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(RewindState {
|
||||
phase: RewindPhase::Executing {
|
||||
target_prompt_index: target,
|
||||
mode,
|
||||
},
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
stash_inline_resubmit_if_editing(agent);
|
||||
vec![Effect::RewindExecute {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
target_prompt_index: target,
|
||||
mode,
|
||||
}]
|
||||
}
|
||||
RewindMode::All | RewindMode::FilesOnly => {
|
||||
let has_files = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.and_then(|s| match &s.phase {
|
||||
RewindPhase::ModeSelect {
|
||||
has_file_changes, ..
|
||||
} => Some(*has_file_changes),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
|
||||
if !has_files {
|
||||
agent.rewind_state = Some(RewindState {
|
||||
phase: RewindPhase::Executing {
|
||||
target_prompt_index: target,
|
||||
mode,
|
||||
},
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
stash_inline_resubmit_if_editing(agent);
|
||||
vec![Effect::RewindExecute {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
target_prompt_index: target,
|
||||
mode,
|
||||
}]
|
||||
} else {
|
||||
agent.rewind_state = Some(RewindState {
|
||||
phase: RewindPhase::Previewing {
|
||||
target_prompt_index: target,
|
||||
mode,
|
||||
},
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
vec![Effect::RewindPreview {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
target_prompt_index: target,
|
||||
mode,
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_confirm(
|
||||
app: &mut AppView,
|
||||
target: usize,
|
||||
mode: crate::views::rewind::RewindMode,
|
||||
) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
return vec![];
|
||||
};
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Executing {
|
||||
target_prompt_index: target,
|
||||
mode,
|
||||
},
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
stash_inline_resubmit_if_editing(agent);
|
||||
vec![Effect::RewindExecute {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
target_prompt_index: target,
|
||||
mode,
|
||||
}]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_conversation_only_confirm(
|
||||
app: &mut AppView,
|
||||
target: usize,
|
||||
) -> Vec<Effect> {
|
||||
dispatch_rewind_confirm(
|
||||
app,
|
||||
target,
|
||||
crate::views::rewind::RewindMode::ConversationOnly,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_dismiss(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
if let Some(d) = draft {
|
||||
agent.prompt.restore(d);
|
||||
}
|
||||
agent.rewind_points = None;
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_back_to_mode_select(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
if let Some(ref state) = agent.rewind_state {
|
||||
let anchor = state.anchor_entry_idx;
|
||||
let sel_pi = state.selected_prompt_index;
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
|
||||
let (target, has_file_changes) = agent
|
||||
.rewind_points
|
||||
.as_ref()
|
||||
.and_then(|pts| {
|
||||
sel_pi
|
||||
.and_then(|pi| pts.iter().find(|p| p.prompt_index == pi))
|
||||
.or_else(|| pts.iter().max_by_key(|p| p.prompt_index))
|
||||
})
|
||||
.map(|p| (p.prompt_index, p.has_file_changes))
|
||||
.unwrap_or((0, false));
|
||||
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::ModeSelect {
|
||||
target_prompt_index: target,
|
||||
has_file_changes,
|
||||
// Re-derive the inline context: while the inline editor is
|
||||
// open the files-only row stays hidden on the way back too.
|
||||
offer_files_only: agent.inline_edit.is_none(),
|
||||
active_idx: 0,
|
||||
},
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: sel_pi,
|
||||
});
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_dismiss_error(app: &mut AppView) -> Vec<Effect> {
|
||||
dispatch_rewind_dismiss(app)
|
||||
}
|
||||
|
||||
/// The single place the inline-edit resubmit gets armed: called right
|
||||
/// before every `Effect::RewindExecute` emission in the rewind flow. If the
|
||||
/// inline editor is open, the (trimmed) edited text is stashed for
|
||||
/// `dispatch_rewind_success` to resubmit after the rewind lands. Dismiss /
|
||||
/// error / empty-points paths never arm it, so they need no clearing — the
|
||||
/// editor simply stays open there.
|
||||
fn stash_inline_resubmit_if_editing(agent: &mut crate::app::agent_view::AgentView) {
|
||||
if let Some(ref edit) = agent.inline_edit {
|
||||
agent.pending_inline_resubmit = Some(edit.textarea.text().trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit an inline edit: enter the exact same rewind flow as `/rewind`,
|
||||
/// pre-targeted at the edited prompt (points fetch → ModeSelect with the
|
||||
/// file-revert question → optional preview/confirm → execute; cancel-offer
|
||||
/// first when a turn is running). The editor stays open behind the rewind
|
||||
/// overlays; `stash_inline_resubmit_if_editing` arms the resubmit only when
|
||||
/// a rewind actually executes, and `dispatch_rewind_success` sends the
|
||||
/// edited text from the rewound point.
|
||||
pub(super) fn dispatch_inline_edit_submit(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
app.show_toast("No active session");
|
||||
return vec![];
|
||||
};
|
||||
let Some(edit) = agent.inline_edit.as_ref() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Unchanged/empty edits have nothing to submit: just close the editor.
|
||||
let text = edit.textarea.text().trim().to_string();
|
||||
if text.is_empty() || text == edit.original.trim() {
|
||||
agent.exit_inline_edit();
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let target = edit.prompt_index;
|
||||
let anchor = agent
|
||||
.scrollback
|
||||
.index_of_id(edit.entry_id)
|
||||
.or_else(|| agent.scrollback.selected())
|
||||
.unwrap_or(0);
|
||||
let draft = stash_prompt(&mut agent.prompt);
|
||||
|
||||
if agent.session.state.is_busy() {
|
||||
// Mid-turn submit: the same cancel-offer `/rewind` raises, over the
|
||||
// still-open editor. Confirm cancels the turn and re-enters the
|
||||
// flow; dismiss returns to the editor.
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState::new_cancel_offer(
|
||||
anchor,
|
||||
draft,
|
||||
Some(target),
|
||||
));
|
||||
return vec![];
|
||||
}
|
||||
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Loading,
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: Some(target),
|
||||
});
|
||||
|
||||
vec![Effect::FetchRewindPoints {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
}]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_success(
|
||||
app: &mut AppView,
|
||||
agent_id: crate::app::agent::AgentId,
|
||||
response: crate::views::rewind::RewindResponse,
|
||||
) -> Vec<Effect> {
|
||||
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Inline-edit resubmit text; taken unconditionally so a failed rewind
|
||||
// drops it.
|
||||
let inline_resubmit = agent.pending_inline_resubmit.take();
|
||||
|
||||
if !response.success {
|
||||
let err = response.error.unwrap_or_else(|| "unknown error".into());
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Error { message: err },
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
// Note: the inline editor (if any) stays open — dismissing the
|
||||
// error returns to editing.
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// The rewind went through: the inline editor's job is done. Close it
|
||||
// before the truncation below removes its entry.
|
||||
if inline_resubmit.is_some() {
|
||||
agent.inline_edit = None;
|
||||
agent.scrollback.set_inline_edit_height(None);
|
||||
}
|
||||
|
||||
let mode_str = response.mode.as_deref().unwrap_or("all");
|
||||
let target = response.target_prompt_index;
|
||||
let is_files_only = mode_str == "files_only";
|
||||
|
||||
let stashed_draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
|
||||
if !is_files_only {
|
||||
let target_idx = find_user_prompt_entry_for_shell_index(&agent.scrollback, target);
|
||||
if let Some(anchor_idx) = target_idx {
|
||||
let removed = agent.scrollback.remove_from(anchor_idx);
|
||||
// Explicit drop BEFORE the purge: the rewound tail (entries +
|
||||
// their render caches — potentially most of a long transcript)
|
||||
// must be freed for the release below to return its pages.
|
||||
drop(removed);
|
||||
crate::memory_release::release_retained_memory_with("rewind-truncate");
|
||||
}
|
||||
}
|
||||
|
||||
// An inline resubmit skips the confirmation — the edited prompt
|
||||
// re-appearing at the same spot is self-explanatory. (Files-only keeps
|
||||
// it: nothing is resubmitted there, so the revert needs its signal.)
|
||||
if inline_resubmit.is_none() || is_files_only {
|
||||
let msg = match mode_str {
|
||||
"conversation_only" => "Reverted conversation",
|
||||
"files_only" => "Reverted file changes",
|
||||
_ => "Reverted conversation and file changes",
|
||||
};
|
||||
if app.screen_mode.is_minimal() {
|
||||
// Minimal has no toast surface and can't erase committed lines, so the confirmation stays in scrollback there.
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system(msg.to_string()));
|
||||
} else {
|
||||
agent.show_toast(msg);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref text) = inline_resubmit
|
||||
&& is_files_only
|
||||
{
|
||||
// Files-only: no conversation rewind happened, so there is nothing
|
||||
// to resubmit from — surface the edited text in the composer
|
||||
// instead of silently dropping the edit.
|
||||
agent.prompt.set_text(text);
|
||||
} else if inline_resubmit.is_some() {
|
||||
// Restore the full draft before a non-consuming resubmit.
|
||||
if let Some(draft) = stashed_draft {
|
||||
agent.prompt.restore(draft);
|
||||
}
|
||||
} else if let Some(ref prompt_text) = response.prompt_text
|
||||
&& !is_files_only
|
||||
{
|
||||
agent.prompt.set_text(prompt_text);
|
||||
} else if let Some(draft) = stashed_draft {
|
||||
agent.prompt.restore(draft);
|
||||
}
|
||||
|
||||
if !is_files_only {
|
||||
agent.set_active_pane(crate::app::agent_view::ActivePane::Prompt, false);
|
||||
}
|
||||
|
||||
agent.rewind_points = None;
|
||||
agent.scrollback.goto_bottom();
|
||||
|
||||
if let Some(text) = inline_resubmit
|
||||
&& !is_files_only
|
||||
{
|
||||
if app.active_view == ActiveView::Agent(agent_id) {
|
||||
// Resubmit from the rewound point; `consume_input=false` keeps
|
||||
// the composer draft, `literal=true` sends slash-lookalike text
|
||||
// as a prompt (the transcript is already truncated — running it
|
||||
// as a command would swallow the resubmit).
|
||||
return super::prompt::dispatch_send_prompt_inner(
|
||||
app, text, /* consume_input */ false, /* literal */ true,
|
||||
/* is_follow_up */ false,
|
||||
);
|
||||
}
|
||||
// View switched mid-rewind: fall back to prefilling that composer,
|
||||
// appending so an existing draft isn't clobbered.
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
if agent.prompt.text().trim().is_empty() {
|
||||
agent.prompt.set_text(&text);
|
||||
} else {
|
||||
agent.prompt.append_text(&format!("\n{text}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vec![]
|
||||
}
|
||||
|
||||
// TaskResult handlers.
|
||||
|
||||
pub(super) fn handle_rewind_points_loaded(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
points: Vec<crate::views::rewind::RewindPointInfo>,
|
||||
) -> Vec<Effect> {
|
||||
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
agent.rewind_points = Some(points.clone());
|
||||
|
||||
let desired_target = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.and_then(|s| s.selected_prompt_index);
|
||||
let stashed = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
|
||||
if points.is_empty() {
|
||||
if let Some(stashed) = stashed {
|
||||
agent.prompt.restore(stashed);
|
||||
}
|
||||
app.show_toast("No undoable prompts");
|
||||
return vec![];
|
||||
}
|
||||
|
||||
if let Some(dt) = desired_target {
|
||||
let resolved = points
|
||||
.iter()
|
||||
.find(|p| p.prompt_index == dt)
|
||||
.or_else(|| points.iter().max_by_key(|p| p.prompt_index))
|
||||
.cloned();
|
||||
|
||||
if let Some(point) = resolved {
|
||||
let target = point.prompt_index;
|
||||
let has_file_changes = point.has_file_changes;
|
||||
let anchor = find_user_prompt_entry_for_shell_index(&agent.scrollback, target);
|
||||
let draft = stashed.or_else(|| stash_prompt(&mut agent.prompt));
|
||||
if let Some(entry_idx) = anchor {
|
||||
agent.scrollback.set_selected(Some(entry_idx));
|
||||
}
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState::new_mode_select(
|
||||
anchor.unwrap_or(0),
|
||||
target,
|
||||
has_file_changes,
|
||||
// Inline edit-and-resubmit: the conversation rewind is a
|
||||
// given — hide the "File changes only" row entirely.
|
||||
agent.inline_edit.is_none(),
|
||||
draft,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let mut sorted = points.clone();
|
||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.prompt_index));
|
||||
let draft = stashed.or_else(|| stash_prompt(&mut agent.prompt));
|
||||
let initial_anchor = sorted
|
||||
.first()
|
||||
.map(|p| {
|
||||
find_user_prompt_entry_for_shell_index(&agent.scrollback, p.prompt_index)
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Picker {
|
||||
points: sorted,
|
||||
selected: 0,
|
||||
},
|
||||
anchor_entry_idx: initial_anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
agent.scrollback.scroll_to_entry_center(initial_anchor);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_rewind_preview_complete(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
response: crate::views::rewind::RewindResponse,
|
||||
target_prompt_index: usize,
|
||||
mode: crate::views::rewind::RewindMode,
|
||||
) -> Vec<Effect> {
|
||||
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
if response.error.is_some() && response.clean_files.is_empty() && response.conflicts.is_empty()
|
||||
{
|
||||
let err = response.error.unwrap_or_default();
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Error { message: err },
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
return vec![];
|
||||
}
|
||||
let conflicts: Vec<_> = response
|
||||
.conflicts
|
||||
.iter()
|
||||
.map(crate::views::rewind::ConflictDisplay::from_conflict)
|
||||
.collect();
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let preview = agent
|
||||
.rewind_points
|
||||
.as_ref()
|
||||
.and_then(|pts| pts.iter().find(|p| p.prompt_index == target_prompt_index))
|
||||
.and_then(|p| p.prompt_preview.clone());
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Confirm {
|
||||
target_prompt_index,
|
||||
mode,
|
||||
clean_files: response.clean_files,
|
||||
conflicts,
|
||||
active_idx: 0,
|
||||
prompt_preview: preview,
|
||||
},
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_rewind_preview_failed(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
error: String,
|
||||
) -> Vec<Effect> {
|
||||
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Error { message: error },
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_rewind_execute_failed(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
error: String,
|
||||
) -> Vec<Effect> {
|
||||
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
// A pending inline resubmit dies with its rewind; the editor itself
|
||||
// stays open so dismissing the error returns to editing.
|
||||
agent.pending_inline_resubmit = None;
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Error { message: error },
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,410 @@
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::app_view::{AppView, SessionPickerEntry};
|
||||
use crate::app::dispatch::ctx::get_active_agent_mut;
|
||||
use crate::app::effects::ConversationsPartial;
|
||||
use crate::views::modal::ActiveModal;
|
||||
use crate::views::picker::PickerState;
|
||||
use crate::views::session_picker::{
|
||||
PickerSelectionAnchor, SessionPickerLanes, SessionPickerPendingNotice, SourceFilter,
|
||||
capture_picker_selection, effective_filter_query, repo_name_from_cwd, restore_picker_selection,
|
||||
};
|
||||
|
||||
type SearchHit = kigi_shell::extensions::session_search::SearchSessionHit;
|
||||
|
||||
struct PickerSurface<'a> {
|
||||
entries: &'a mut Option<Vec<SessionPickerEntry>>,
|
||||
loading: &'a mut bool,
|
||||
lanes: &'a mut SessionPickerLanes,
|
||||
state: &'a mut PickerState,
|
||||
content_results: &'a mut Option<Vec<SearchHit>>,
|
||||
content_loading: &'a mut bool,
|
||||
entries_query: &'a mut Option<String>,
|
||||
source_filter: SourceFilter,
|
||||
grouped: bool,
|
||||
current_repo: String,
|
||||
}
|
||||
|
||||
impl PickerSurface<'_> {
|
||||
fn capture_selection(&self) -> PickerSelectionAnchor {
|
||||
capture_picker_selection(
|
||||
self.entries.as_deref(),
|
||||
self.content_results.as_deref(),
|
||||
self.state,
|
||||
effective_filter_query(&self.state.query, self.entries_query.as_deref()),
|
||||
self.grouped,
|
||||
*self.content_loading,
|
||||
self.source_filter,
|
||||
Some(&self.current_repo),
|
||||
)
|
||||
}
|
||||
|
||||
fn restore_selection(&mut self, anchor: PickerSelectionAnchor) {
|
||||
let filter_query =
|
||||
effective_filter_query(&self.state.query, self.entries_query.as_deref()).to_owned();
|
||||
restore_picker_selection(
|
||||
anchor,
|
||||
self.entries.as_deref(),
|
||||
self.content_results.as_deref(),
|
||||
self.state,
|
||||
&filter_query,
|
||||
self.grouped,
|
||||
*self.content_loading,
|
||||
self.source_filter,
|
||||
Some(&self.current_repo),
|
||||
);
|
||||
self.state.expanded.clear();
|
||||
}
|
||||
|
||||
fn native_loaded(
|
||||
&mut self,
|
||||
sessions: Vec<SessionPickerEntry>,
|
||||
query: Option<String>,
|
||||
chat_mode: bool,
|
||||
empty_notice: String,
|
||||
partial_notice: Option<&'static str>,
|
||||
) -> Option<String> {
|
||||
let anchor = self.capture_selection();
|
||||
let is_search = query.is_some();
|
||||
*self.loading = false;
|
||||
if is_search {
|
||||
*self.content_loading = false;
|
||||
}
|
||||
*self.entries_query = query;
|
||||
if chat_mode {
|
||||
*self.entries = (!sessions.is_empty()).then_some(sessions);
|
||||
} else {
|
||||
crate::app::foreign_sessions::replace_native_entries(self.entries, sessions);
|
||||
}
|
||||
if is_search && self.entries.is_none() {
|
||||
*self.entries = Some(Vec::new());
|
||||
}
|
||||
let notice = if self.entries.is_none() && !is_search {
|
||||
if self.lanes.foreign_loading {
|
||||
self.lanes.pending_notice = Some(SessionPickerPendingNotice::Empty(empty_notice));
|
||||
None
|
||||
} else {
|
||||
self.lanes.pending_notice = None;
|
||||
Some(empty_notice)
|
||||
}
|
||||
} else {
|
||||
self.lanes.pending_notice = None;
|
||||
if chat_mode {
|
||||
partial_notice.map(str::to_owned)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
self.restore_selection(anchor);
|
||||
notice
|
||||
}
|
||||
|
||||
fn native_failed(
|
||||
&mut self,
|
||||
error_notice: String,
|
||||
is_search: bool,
|
||||
chat_mode: bool,
|
||||
) -> Option<String> {
|
||||
let anchor = self.capture_selection();
|
||||
*self.loading = false;
|
||||
if is_search {
|
||||
*self.content_loading = false;
|
||||
}
|
||||
if chat_mode {
|
||||
*self.entries = None;
|
||||
} else {
|
||||
crate::app::foreign_sessions::replace_native_entries(self.entries, Vec::new());
|
||||
}
|
||||
*self.entries_query = None;
|
||||
let notice = if self.lanes.foreign_loading {
|
||||
self.lanes.pending_notice = Some(SessionPickerPendingNotice::Error(error_notice));
|
||||
None
|
||||
} else {
|
||||
self.lanes.pending_notice = None;
|
||||
Some(error_notice)
|
||||
};
|
||||
self.restore_selection(anchor);
|
||||
notice
|
||||
}
|
||||
|
||||
fn foreign_loaded(&mut self, scanned: Vec<SessionPickerEntry>) -> Option<String> {
|
||||
let anchor = self.capture_selection();
|
||||
crate::app::foreign_sessions::replace_foreign_entries(self.entries, scanned);
|
||||
self.lanes.foreign_loading = false;
|
||||
let notice = self.lanes.take_ready_notice(self.entries.is_some());
|
||||
self.restore_selection(anchor);
|
||||
notice
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
app.session_picker_entries = None;
|
||||
app.session_picker_state.selected = 0;
|
||||
app.session_picker_state.query.clear();
|
||||
app.session_picker_state.query_cursor = 0;
|
||||
app.session_picker_state.search_active = false;
|
||||
app.session_picker_state.expanded.clear();
|
||||
app.session_picker_content_results = None;
|
||||
app.session_picker_content_loading = false;
|
||||
app.session_picker_entries_query = None;
|
||||
if app.chat_mode {
|
||||
app.session_picker_list_seq += 1;
|
||||
}
|
||||
app.foreign_session_scan_seq += 1;
|
||||
let foreign_seq = app.foreign_session_scan_seq;
|
||||
let mut effects = vec![Effect::FetchSessionList {
|
||||
query: None,
|
||||
seq: app.session_picker_list_seq,
|
||||
}];
|
||||
let foreign_effect = if app.chat_mode {
|
||||
app.foreign_scan_coordinator.begin_request(foreign_seq);
|
||||
None
|
||||
} else {
|
||||
let kigi_home = kigi_tools::util::kigi_home::kigi_home();
|
||||
crate::app::foreign_sessions::scan_effect(
|
||||
&app.cwd,
|
||||
app.foreign_session_compat,
|
||||
&kigi_home,
|
||||
app.foreign_scan_coordinator.clone(),
|
||||
foreign_seq,
|
||||
)
|
||||
};
|
||||
let foreign_loading = foreign_effect.is_some();
|
||||
let mut modal_lanes_set = false;
|
||||
if let Some(agent) = get_active_agent_mut(app)
|
||||
&& let Some(ActiveModal::SessionPicker { lanes, .. }) = agent.active_modal.as_mut()
|
||||
{
|
||||
lanes.foreign_loading = foreign_loading;
|
||||
lanes.pending_notice = None;
|
||||
modal_lanes_set = true;
|
||||
}
|
||||
app.session_picker_lanes.foreign_loading = foreign_loading && !modal_lanes_set;
|
||||
app.session_picker_lanes.pending_notice = None;
|
||||
effects.extend(foreign_effect);
|
||||
effects
|
||||
}
|
||||
|
||||
pub(in crate::app::dispatch) fn handle_session_list_loaded(
|
||||
app: &mut AppView,
|
||||
sessions: Vec<SessionPickerEntry>,
|
||||
partial: Option<ConversationsPartial>,
|
||||
seq: u64,
|
||||
query: Option<String>,
|
||||
) -> Vec<Effect> {
|
||||
if seq != app.session_picker_list_seq {
|
||||
return vec![];
|
||||
}
|
||||
app.session_picker_detail_generation += 1;
|
||||
if let Some(partial) = partial {
|
||||
crate::unified_log::warn(
|
||||
"session.list.partial",
|
||||
None,
|
||||
Some(serde_json::json!({ "reason": format!("{partial:?}") })),
|
||||
);
|
||||
}
|
||||
let empty_notice = partial.map_or_else(
|
||||
|| "No sessions found for this directory".to_owned(),
|
||||
|partial| partial.picker_notice().to_owned(),
|
||||
);
|
||||
let partial_notice = partial.map(ConversationsPartial::picker_notice);
|
||||
let chat_mode = app.chat_mode;
|
||||
let mut sessions = Some(sessions);
|
||||
let mut notice = None;
|
||||
if let Some(agent) = get_active_agent_mut(app) {
|
||||
let current_repo = repo_name_from_cwd(&agent.session.cwd.to_string_lossy());
|
||||
if let Some(ActiveModal::SessionPicker {
|
||||
entries,
|
||||
loading,
|
||||
lanes,
|
||||
state,
|
||||
content_results,
|
||||
content_loading,
|
||||
entries_query,
|
||||
source_filter,
|
||||
..
|
||||
}) = agent.active_modal.as_mut()
|
||||
{
|
||||
notice = PickerSurface {
|
||||
entries,
|
||||
loading,
|
||||
lanes,
|
||||
state,
|
||||
content_results,
|
||||
content_loading,
|
||||
entries_query,
|
||||
source_filter: *source_filter,
|
||||
grouped: true,
|
||||
current_repo,
|
||||
}
|
||||
.native_loaded(
|
||||
sessions.take().unwrap_or_default(),
|
||||
query.clone(),
|
||||
chat_mode,
|
||||
empty_notice.clone(),
|
||||
partial_notice,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(sessions) = sessions {
|
||||
let current_repo = repo_name_from_cwd(&app.cwd.to_string_lossy());
|
||||
notice = PickerSurface {
|
||||
entries: &mut app.session_picker_entries,
|
||||
loading: &mut app.session_picker_loading,
|
||||
lanes: &mut app.session_picker_lanes,
|
||||
state: &mut app.session_picker_state,
|
||||
content_results: &mut app.session_picker_content_results,
|
||||
content_loading: &mut app.session_picker_content_loading,
|
||||
entries_query: &mut app.session_picker_entries_query,
|
||||
source_filter: app.session_picker_source_filter,
|
||||
grouped: app.session_picker_grouped,
|
||||
current_repo,
|
||||
}
|
||||
.native_loaded(sessions, query, chat_mode, empty_notice, partial_notice);
|
||||
}
|
||||
if let Some(notice) = notice {
|
||||
app.show_toast(¬ice);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(in crate::app::dispatch) fn handle_session_list_failed(
|
||||
app: &mut AppView,
|
||||
error: String,
|
||||
seq: u64,
|
||||
query: Option<String>,
|
||||
) -> Vec<Effect> {
|
||||
if seq != app.session_picker_list_seq {
|
||||
return vec![];
|
||||
}
|
||||
app.session_picker_detail_generation += 1;
|
||||
tracing::warn!(error = %error, "session list fetch failed");
|
||||
let error_notice = format!("Couldn't load sessions: {error}");
|
||||
let is_search = query.is_some();
|
||||
let chat_mode = app.chat_mode;
|
||||
let mut handled = false;
|
||||
let mut notice = None;
|
||||
if let Some(agent) = get_active_agent_mut(app) {
|
||||
let current_repo = repo_name_from_cwd(&agent.session.cwd.to_string_lossy());
|
||||
if let Some(ActiveModal::SessionPicker {
|
||||
entries,
|
||||
loading,
|
||||
lanes,
|
||||
state,
|
||||
content_results,
|
||||
content_loading,
|
||||
entries_query,
|
||||
source_filter,
|
||||
..
|
||||
}) = agent.active_modal.as_mut()
|
||||
{
|
||||
notice = PickerSurface {
|
||||
entries,
|
||||
loading,
|
||||
lanes,
|
||||
state,
|
||||
content_results,
|
||||
content_loading,
|
||||
entries_query,
|
||||
source_filter: *source_filter,
|
||||
grouped: true,
|
||||
current_repo,
|
||||
}
|
||||
.native_failed(error_notice.clone(), is_search, chat_mode);
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
if !handled {
|
||||
let current_repo = repo_name_from_cwd(&app.cwd.to_string_lossy());
|
||||
notice = PickerSurface {
|
||||
entries: &mut app.session_picker_entries,
|
||||
loading: &mut app.session_picker_loading,
|
||||
lanes: &mut app.session_picker_lanes,
|
||||
state: &mut app.session_picker_state,
|
||||
content_results: &mut app.session_picker_content_results,
|
||||
content_loading: &mut app.session_picker_content_loading,
|
||||
entries_query: &mut app.session_picker_entries_query,
|
||||
source_filter: app.session_picker_source_filter,
|
||||
grouped: app.session_picker_grouped,
|
||||
current_repo,
|
||||
}
|
||||
.native_failed(error_notice, is_search, chat_mode);
|
||||
}
|
||||
if let Some(notice) = notice {
|
||||
app.show_toast(¬ice);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(in crate::app::dispatch) fn handle_foreign_sessions_scanned(
|
||||
app: &mut AppView,
|
||||
scanned: Vec<SessionPickerEntry>,
|
||||
seq: u64,
|
||||
) -> Vec<Effect> {
|
||||
if app.chat_mode || seq != app.foreign_session_scan_seq {
|
||||
return vec![];
|
||||
}
|
||||
app.session_picker_detail_generation += 1;
|
||||
let mut scanned = Some(scanned);
|
||||
let mut notice = None;
|
||||
let mut handled = false;
|
||||
if let Some(agent) = get_active_agent_mut(app) {
|
||||
let current_repo = repo_name_from_cwd(&agent.session.cwd.to_string_lossy());
|
||||
if let Some(ActiveModal::SessionPicker {
|
||||
entries,
|
||||
loading,
|
||||
lanes,
|
||||
state,
|
||||
content_results,
|
||||
content_loading,
|
||||
entries_query,
|
||||
source_filter,
|
||||
..
|
||||
}) = agent.active_modal.as_mut()
|
||||
&& lanes.foreign_loading
|
||||
{
|
||||
handled = true;
|
||||
notice = PickerSurface {
|
||||
entries,
|
||||
loading,
|
||||
lanes,
|
||||
state,
|
||||
content_results,
|
||||
content_loading,
|
||||
entries_query,
|
||||
source_filter: *source_filter,
|
||||
grouped: true,
|
||||
current_repo,
|
||||
}
|
||||
.foreign_loaded(scanned.take().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
if !handled && app.session_picker_lanes.foreign_loading {
|
||||
let current_repo = repo_name_from_cwd(&app.cwd.to_string_lossy());
|
||||
notice = PickerSurface {
|
||||
entries: &mut app.session_picker_entries,
|
||||
loading: &mut app.session_picker_loading,
|
||||
lanes: &mut app.session_picker_lanes,
|
||||
state: &mut app.session_picker_state,
|
||||
content_results: &mut app.session_picker_content_results,
|
||||
content_loading: &mut app.session_picker_content_loading,
|
||||
entries_query: &mut app.session_picker_entries_query,
|
||||
source_filter: app.session_picker_source_filter,
|
||||
grouped: app.session_picker_grouped,
|
||||
current_repo,
|
||||
}
|
||||
.foreign_loaded(scanned.unwrap_or_default());
|
||||
}
|
||||
if let Some(notice) = notice {
|
||||
app.show_toast(¬ice);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(in crate::app::dispatch) fn invalidate_foreign_picker(app: &mut AppView) {
|
||||
app.foreign_session_scan_seq += 1;
|
||||
app.foreign_scan_coordinator
|
||||
.begin_request(app.foreign_session_scan_seq);
|
||||
app.session_picker_lanes = Default::default();
|
||||
app.session_picker_detail_generation += 1;
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
//! Fork and project-selection dispatchers and fork placeholder builders.
|
||||
use super::lifecycle::{dispatch_new_session_inner_with_id, refuse_chat_mode_build_agent};
|
||||
use crate::acp::tracker::AcpUpdateTracker;
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent::{AgentCommand, AgentId, AgentSession, AgentState};
|
||||
use crate::app::agent_view::{AgentView, McpInitProgress};
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use crate::app::dispatch::ctx::{SwitchCause, switch_to_agent};
|
||||
use crate::app::dispatch::modes::inherit_auto_mode;
|
||||
use crate::app::dispatch::prompt::{
|
||||
consume_chat_kind, dispatch_send_prompt, supersede_open_reload_window,
|
||||
};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::SessionEvent;
|
||||
use crate::scrollback::state::ScrollbackState;
|
||||
use agent_client_protocol as acp;
|
||||
use std::time::Instant;
|
||||
/// Top-level `/fork` dispatcher. Resolves the worktree decision: an
|
||||
/// explicit `--worktree` / `--no-worktree` flag short-circuits to
|
||||
/// [`dispatch_fork_resolved`]. When no flag is given and a persisted
|
||||
/// `fork_worktree_mode` preference is set (`Always` / `Never`), the
|
||||
/// popup is skipped and the corresponding path is taken directly. The
|
||||
/// `Ask` default opens the [`open_fork_question`] modal so the user is
|
||||
/// asked.
|
||||
///
|
||||
/// When the parent session's working directory is **not** inside a git
|
||||
/// repository (indicated by the absence of a `git_head_changed`
|
||||
/// notification — `current_branch` is `None`):
|
||||
/// - `--worktree` is rejected with a toast (nothing to create a worktree from).
|
||||
/// - No flag (regardless of `fork_worktree_mode`): the worktree question
|
||||
/// is skipped and the fork proceeds with `worktree = false`.
|
||||
///
|
||||
/// Note: if the notification has not arrived yet (rare — user forks
|
||||
/// before the shell sends `git_head_changed`), the fallback to
|
||||
/// `worktree = false` is safe and the worktree can be created manually
|
||||
/// afterwards.
|
||||
///
|
||||
/// Two failure surfaces:
|
||||
/// - Active view is not an agent: toast and return.
|
||||
/// - Active agent has no `session_id` (still being created): toast and
|
||||
/// return. Both rejections are deliberate -- queueing the fork until
|
||||
/// `SessionLoaded` would require persisting `ForkArgs` across the
|
||||
/// `TaskResult` and is deferred to v2.
|
||||
pub(in crate::app::dispatch) fn dispatch_fork(
|
||||
app: &mut AppView,
|
||||
args: crate::slash::commands::fork::ForkArgs,
|
||||
) -> Vec<Effect> {
|
||||
let ActiveView::Agent(parent_id) = app.active_view else {
|
||||
app.show_toast("/fork only works inside a session");
|
||||
return vec![];
|
||||
};
|
||||
let (has_session, in_git_repo) = app
|
||||
.agents
|
||||
.get(&parent_id)
|
||||
.map(|a| (a.session.session_id.is_some(), a.current_branch.is_some()))
|
||||
.unwrap_or((false, false));
|
||||
if !has_session {
|
||||
app.show_toast("Cannot fork: session is still being created");
|
||||
return vec![];
|
||||
}
|
||||
match args.worktree_override {
|
||||
Some(true) if !in_git_repo => {
|
||||
app.show_toast("Cannot create worktree: not in a git repository");
|
||||
vec![]
|
||||
}
|
||||
Some(worktree) => dispatch_fork_resolved(app, worktree, args.directive),
|
||||
None => {
|
||||
if in_git_repo {
|
||||
use crate::app::app_view::WorktreeMode;
|
||||
match app.fork_worktree_mode {
|
||||
WorktreeMode::Always => dispatch_fork_resolved(app, true, args.directive),
|
||||
WorktreeMode::Never => dispatch_fork_resolved(app, false, args.directive),
|
||||
WorktreeMode::Ask => open_fork_question(app, args.directive),
|
||||
}
|
||||
} else {
|
||||
dispatch_fork_resolved(app, false, args.directive)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// If `persist_mode` is `Some`, write `mode` into `*field` and append
|
||||
/// a [`Effect::PersistWorktreeMode`] to `effects` with the given
|
||||
/// `config_key`.
|
||||
pub(in crate::app::dispatch) fn apply_persist_worktree_mode(
|
||||
field: &mut crate::app::app_view::WorktreeMode,
|
||||
effects: &mut Vec<Effect>,
|
||||
persist_mode: Option<crate::app::app_view::WorktreeMode>,
|
||||
config_key: &'static str,
|
||||
) {
|
||||
if let Some(mode) = persist_mode {
|
||||
*field = mode;
|
||||
effects.push(Effect::PersistWorktreeMode { mode, config_key });
|
||||
}
|
||||
}
|
||||
/// Build the two persistence options shared by the fork and new-session
|
||||
/// worktree question modals ("Always worktree" / "Never worktree").
|
||||
pub(super) fn worktree_persist_options()
|
||||
-> [kigi_tools::implementations::grok_build::ask_user_question::QuestionOption; 2] {
|
||||
use kigi_tools::implementations::grok_build::ask_user_question::QuestionOption;
|
||||
[
|
||||
QuestionOption {
|
||||
label: "Always worktree".into(),
|
||||
description: "Use worktree and stop asking (reset in config.toml)".into(),
|
||||
preview: None,
|
||||
id: None,
|
||||
},
|
||||
QuestionOption {
|
||||
label: "Never worktree".into(),
|
||||
description: "Skip worktree and stop asking (reset in config.toml)".into(),
|
||||
preview: None,
|
||||
id: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
/// Open the local worktree question modal on the active agent. Refuses
|
||||
/// if a question (ACP or local) is already on screen, surfacing a toast
|
||||
/// instead -- the modal-collision protocol.
|
||||
fn open_fork_question(app: &mut AppView, directive: Option<String>) -> Vec<Effect> {
|
||||
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
|
||||
use kigi_tools::implementations::grok_build::ask_user_question::{Question, QuestionOption};
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
if agent.question_view.is_some() {
|
||||
app.show_toast("Finish answering the current question first");
|
||||
return vec![];
|
||||
}
|
||||
let mut options = vec![
|
||||
QuestionOption {
|
||||
label: "Yes".into(),
|
||||
description: "Fork in a new isolated git worktree".into(),
|
||||
preview: None,
|
||||
id: None,
|
||||
},
|
||||
QuestionOption {
|
||||
label: "No".into(),
|
||||
description: "Fork in the current cwd".into(),
|
||||
preview: None,
|
||||
id: None,
|
||||
},
|
||||
];
|
||||
options.extend(worktree_persist_options());
|
||||
let question = Question {
|
||||
question: "Run this fork in an isolated git worktree?".into(),
|
||||
id: None,
|
||||
options,
|
||||
multi_select: Some(false),
|
||||
};
|
||||
let agent = app.agents.get_mut(&id).expect("agent present (re-borrow)");
|
||||
let stashed = agent.prompt.stash();
|
||||
let state = QuestionViewState::new(
|
||||
format!("fork-{}", uuid::Uuid::new_v4()),
|
||||
vec![question],
|
||||
stashed,
|
||||
)
|
||||
.with_local_kind(LocalQuestionKind::Fork { directive });
|
||||
agent.question_view = Some(state);
|
||||
agent.prompt.set_text("");
|
||||
vec![]
|
||||
}
|
||||
/// Construct the placeholder agent, push discoverability markers, flip
|
||||
/// the discovery gate, switch to the new agent, and emit the appropriate
|
||||
/// fork effect (worktree or no-worktree path).
|
||||
///
|
||||
/// `worktree == true` reuses the existing
|
||||
/// [`Effect::CreateWorktreeSession`] pipeline (with `load_session_id`
|
||||
/// set to the parent session id). `worktree == false` emits the new
|
||||
/// [`Effect::ForkSession`] which calls `x.ai/session/fork` directly.
|
||||
pub(in crate::app::dispatch) fn dispatch_fork_resolved(
|
||||
app: &mut AppView,
|
||||
worktree: bool,
|
||||
directive: Option<String>,
|
||||
) -> Vec<Effect> {
|
||||
let ActiveView::Agent(parent_id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(parent) = app.agents.get(&parent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(parent_session_id) = parent.session.session_id.clone() else {
|
||||
app.show_toast("Cannot fork: session not yet created");
|
||||
return vec![];
|
||||
};
|
||||
let parent_cwd = parent.session.cwd.clone();
|
||||
let parent_is_worktree = parent.session.is_worktree;
|
||||
let new_id = AgentId(app.next_agent_id);
|
||||
app.next_agent_id += 1;
|
||||
let new_agent = build_fork_placeholder(app, new_id, parent_id, &parent_cwd, worktree);
|
||||
let parent_marker = match directive.as_deref() {
|
||||
Some(d) => format!("Forked: {d}"),
|
||||
None => "Forked".to_string(),
|
||||
};
|
||||
let parent_chat_kind = parent.chat_kind || app.chat_mode;
|
||||
app.agents.insert(new_id, new_agent);
|
||||
{
|
||||
let agent = app
|
||||
.agents
|
||||
.get_mut(&new_id)
|
||||
.expect("just-inserted agent missing");
|
||||
agent.prompt.set_compact(app.appearance.prompt.compact);
|
||||
agent.prompt.adopt_slash_mru(app.slash_mru.clone());
|
||||
agent
|
||||
.prompt
|
||||
.set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
|
||||
agent.set_session_recap_available(app.session_recap_available);
|
||||
agent.apply_app_scoped_gates(
|
||||
app.sharing_enabled,
|
||||
app.usage_visible,
|
||||
app.chat_mode,
|
||||
app.screen_mode,
|
||||
&app.tier_restricted_commands,
|
||||
);
|
||||
agent.chat_kind = parent_chat_kind;
|
||||
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
|
||||
agent
|
||||
.prompt
|
||||
.slash_controller
|
||||
.registry_mut()
|
||||
.set_plugins_visible(!app.appearance.disable_plugins);
|
||||
agent.pending_fork_banner = Some(crate::app::agent_view::PendingForkBanner {
|
||||
parent_sid: parent_session_id.0.to_string(),
|
||||
worktree,
|
||||
});
|
||||
if worktree {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system("Creating worktree\u{2026}".to_string()));
|
||||
}
|
||||
agent.pending_first_prompt = directive;
|
||||
}
|
||||
if let Some(parent_mut) = app.agents.get_mut(&parent_id) {
|
||||
parent_mut
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system(parent_marker));
|
||||
}
|
||||
switch_to_agent(app, new_id, SwitchCause::Fork);
|
||||
if worktree {
|
||||
vec![Effect::CreateWorktreeSession {
|
||||
agent_id: new_id,
|
||||
load_session_id: Some(parent_session_id.0.to_string()),
|
||||
label: None,
|
||||
git_ref: None,
|
||||
model_id: None,
|
||||
preferred_session_id: None,
|
||||
chat_kind: parent_chat_kind,
|
||||
}]
|
||||
} else {
|
||||
vec![Effect::ForkSession {
|
||||
agent_id: new_id,
|
||||
parent_session_id,
|
||||
parent_cwd,
|
||||
parent_is_worktree,
|
||||
new_session_id: None,
|
||||
}]
|
||||
}
|
||||
}
|
||||
pub(in crate::app::dispatch) fn open_project_question(
|
||||
app: &mut AppView,
|
||||
prompt_text: String,
|
||||
) -> Vec<Effect> {
|
||||
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
if agent.question_view.is_some() {
|
||||
return vec![];
|
||||
}
|
||||
let recent_dirs = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current()
|
||||
.block_on(crate::project_picker::sources::collect_recent_dirs(10))
|
||||
});
|
||||
let pq = crate::project_picker::build_project_question(&recent_dirs, &app.cwd);
|
||||
if pq.resolved_paths.len() <= 1 {
|
||||
return dispatch_project_selected(app, app.cwd.clone(), prompt_text, false);
|
||||
}
|
||||
let stashed = agent.prompt.stash();
|
||||
let state = QuestionViewState::new(
|
||||
format!("project-select-{}", uuid::Uuid::new_v4()),
|
||||
vec![pq.question],
|
||||
stashed,
|
||||
)
|
||||
.with_local_kind(LocalQuestionKind::ProjectSelect {
|
||||
resolved_paths: pq.resolved_paths,
|
||||
original_cwd: app.cwd.clone(),
|
||||
stashed_prompt: prompt_text,
|
||||
dont_ask_index: pq.dont_ask_index,
|
||||
});
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
agent.question_view = Some(state);
|
||||
agent.prompt.set_text("");
|
||||
crate::unified_log::info("project_picker.opened", None, None);
|
||||
vec![]
|
||||
}
|
||||
pub(in crate::app::dispatch) fn dispatch_project_selected(
|
||||
app: &mut AppView,
|
||||
path: std::path::PathBuf,
|
||||
stashed_prompt: String,
|
||||
disable_picker: bool,
|
||||
) -> Vec<Effect> {
|
||||
crate::unified_log::info(
|
||||
"project_picker.selected",
|
||||
None,
|
||||
Some(serde_json::json!(
|
||||
{ "path" : path.display().to_string(), "prompt_len" : stashed_prompt
|
||||
.len(), "disable_picker" : disable_picker }
|
||||
)),
|
||||
);
|
||||
app.mark_project_picker_done();
|
||||
let mut effects = Vec::new();
|
||||
if disable_picker {
|
||||
app.project_picker_disabled = true;
|
||||
app.show_toast("Won't ask about project directory again (reset in config.toml)");
|
||||
effects.push(Effect::PersistProjectPickerDisabled { disabled: true });
|
||||
}
|
||||
let path = if path.is_dir() {
|
||||
path
|
||||
} else {
|
||||
app.show_toast("Directory not found, continuing in current directory");
|
||||
app.cwd.clone()
|
||||
};
|
||||
app.cwd = path.clone();
|
||||
crate::git_info::populate_from_cwd_async(path.clone());
|
||||
effects.push(Effect::SetWorkingDir { path: path.clone() });
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
effects.extend(dispatch_send_prompt(app, stashed_prompt));
|
||||
return effects;
|
||||
};
|
||||
if let Some(agent) = app.agents.get_mut(&id) {
|
||||
let changed = agent.session.cwd != path;
|
||||
agent.session.cwd = path.clone();
|
||||
if changed {
|
||||
let display = crate::project_picker::sources::display_path(&path);
|
||||
agent.show_toast(&format!("Updated working directory to {display}"));
|
||||
}
|
||||
}
|
||||
if let Some(agent) = app.agents.get_mut(&id) {
|
||||
agent.mcp_init_progress = Some(McpInitProgress {
|
||||
total: 0,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
agent.session.prompt_history_loading = true;
|
||||
}
|
||||
let preferred_session_id = app.deferred_startup.preferred_session_id.take();
|
||||
let chat_kind = consume_chat_kind(app);
|
||||
if let Some(agent) = app.agents.get_mut(&id) {
|
||||
agent.chat_kind = chat_kind;
|
||||
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
|
||||
}
|
||||
effects.push(Effect::CreateSession {
|
||||
agent_id: id,
|
||||
cwd: path,
|
||||
model_id: None,
|
||||
preferred_session_id,
|
||||
chat_kind,
|
||||
});
|
||||
effects.extend(dispatch_send_prompt(app, stashed_prompt));
|
||||
effects
|
||||
}
|
||||
/// Build the placeholder [`AgentView`] for a fork. Centralises the
|
||||
/// `AgentSession`/spinner construction shared by both worktree and
|
||||
/// no-worktree branches so the parallel struct literal does not drift.
|
||||
fn build_fork_placeholder(
|
||||
app: &AppView,
|
||||
new_id: AgentId,
|
||||
parent_id: AgentId,
|
||||
parent_cwd: &std::path::Path,
|
||||
worktree: bool,
|
||||
) -> AgentView {
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
scrollback.set_appearance(app.appearance.clone());
|
||||
let mut agent = AgentView::new(
|
||||
AgentSession {
|
||||
id: new_id,
|
||||
acp_tx: app.acp_tx.clone(),
|
||||
session_id: None,
|
||||
models: app.models.clone(),
|
||||
state: AgentState::Idle,
|
||||
tracker: AcpUpdateTracker::new(),
|
||||
cwd: parent_cwd.to_path_buf(),
|
||||
is_worktree: false,
|
||||
forked_from: Some(parent_id),
|
||||
pending_prompts: std::collections::VecDeque::new(),
|
||||
next_queue_id: 0,
|
||||
yolo_mode: app.default_yolo,
|
||||
auto_mode: inherit_auto_mode(app),
|
||||
prompt_history: Vec::new(),
|
||||
prompt_history_loading: false,
|
||||
loading_replay: false,
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: app.bootstrap_acp_commands.clone(),
|
||||
available_commands_generation: 1,
|
||||
available_tools: None,
|
||||
model_switch_pending: false,
|
||||
user_model_preference: None,
|
||||
deferred_model_switch: app.deferred_model_switch_from_cli(),
|
||||
bg_tasks: std::collections::BTreeMap::new(),
|
||||
bg_tool_call_to_task: std::collections::HashMap::new(),
|
||||
scheduled_tasks: std::collections::HashMap::new(),
|
||||
in_flight_prompt: None,
|
||||
current_prompt_id: None,
|
||||
created_via_new: false,
|
||||
},
|
||||
scrollback,
|
||||
);
|
||||
let cmd = if worktree {
|
||||
AgentCommand::CreateWorktree
|
||||
} else {
|
||||
AgentCommand::ForkSession
|
||||
};
|
||||
agent.session.start_command(cmd);
|
||||
agent.turn_started_at = Some(Instant::now());
|
||||
agent
|
||||
}
|
||||
/// Build the discoverability banner for the child agent. Includes the
|
||||
/// child's session id, the full parent session id, and — when
|
||||
/// `switch_hint` names a command (the caller's
|
||||
/// [`crate::views::dashboard::session_switch_hint_command`]: `/dashboard`
|
||||
/// normally, `/resume` in minimal mode where the dashboard is refused) —
|
||||
/// a session-switch tip so the user knows how to switch back. No-worktree
|
||||
/// case appends the dim continuation `(both agents share cwd)`.
|
||||
///
|
||||
/// Called in `TaskResult::SessionLoaded` (not at dispatch time) because
|
||||
/// the child's session id is not known until the backend responds.
|
||||
pub(in crate::app::dispatch) fn build_child_fork_marker(
|
||||
session_id: &str,
|
||||
parent_sid: &str,
|
||||
worktree: bool,
|
||||
switch_hint: Option<&str>,
|
||||
) -> String {
|
||||
let header = if let Some(cmd) = switch_hint {
|
||||
format!(
|
||||
"Session {session_id} (forked from {parent_sid}) \u{2014} use {cmd} to switch between sessions",
|
||||
)
|
||||
} else {
|
||||
format!("Session {session_id} (forked from {parent_sid})")
|
||||
};
|
||||
if worktree {
|
||||
header
|
||||
} else {
|
||||
format!("{header}\n (both agents share cwd)")
|
||||
}
|
||||
}
|
||||
pub(in crate::app::dispatch) fn dispatch_startup_fork_session(
|
||||
app: &mut AppView,
|
||||
parent_session_id: String,
|
||||
parent_cwd: Option<std::path::PathBuf>,
|
||||
new_session_id: Option<String>,
|
||||
) -> Vec<Effect> {
|
||||
if !app.session_startup_allowed() {
|
||||
app.deferred_startup.session =
|
||||
Some(crate::app::session_startup::DeferredSessionStartup::Fork {
|
||||
parent_session_id,
|
||||
parent_cwd,
|
||||
new_session_id,
|
||||
});
|
||||
return vec![];
|
||||
}
|
||||
let (_agent_id, mut effects) = dispatch_new_session_inner_with_id(app, None);
|
||||
let agent_id = app
|
||||
.agents
|
||||
.keys()
|
||||
.next_back()
|
||||
.copied()
|
||||
.expect("fork placeholder agent");
|
||||
effects.retain(|e| !matches!(e, Effect::CreateSession { .. }));
|
||||
let cwd = parent_cwd.unwrap_or_else(|| app.cwd.clone());
|
||||
let parent_is_worktree =
|
||||
crate::app::session_startup::parent_session_is_worktree(&parent_session_id, &cwd);
|
||||
effects.push(Effect::ForkSession {
|
||||
agent_id,
|
||||
parent_session_id: acp::SessionId::new(parent_session_id),
|
||||
parent_cwd: cwd,
|
||||
parent_is_worktree,
|
||||
new_session_id,
|
||||
});
|
||||
effects
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(in crate::app::dispatch) fn handle_worktree_forked(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
session_id: acp::SessionId,
|
||||
worktree_path: std::path::PathBuf,
|
||||
session_cwd: std::path::PathBuf,
|
||||
code_restored: bool,
|
||||
restore_summary: Option<String>,
|
||||
restore_degree: Option<kigi_workspace::session::git::RestoreDegree>,
|
||||
) -> Vec<Effect> {
|
||||
let session_id_str = session_id.0.to_string();
|
||||
let pending_entry = std::mem::take(&mut app.deferred_startup.pending_chat);
|
||||
let agent_entry = app.agents.get(&agent_id).is_some_and(|a| a.chat_kind);
|
||||
let conversation_entry = pending_entry || agent_entry;
|
||||
if crate::app::session_startup::chat_mode_refuses_local_build_load(
|
||||
app.chat_mode,
|
||||
conversation_entry,
|
||||
&session_id_str,
|
||||
&app.cwd,
|
||||
) {
|
||||
refuse_chat_mode_build_agent(app, agent_id);
|
||||
return vec![];
|
||||
}
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
supersede_open_reload_window(agent, agent_id, "WorktreeForked");
|
||||
agent.session.finish_command();
|
||||
agent.mark_turn_finished();
|
||||
agent.bind_session_id(session_id);
|
||||
agent.scrollback.begin_batch();
|
||||
agent.begin_replay_window();
|
||||
agent.session.restore_degree = restore_degree;
|
||||
agent.session.cwd = session_cwd.clone();
|
||||
agent.session.is_worktree = true;
|
||||
app.restore_code = None;
|
||||
agent.prompt.file_search.retarget(&session_cwd);
|
||||
agent.scrollback.push_block(RenderBlock::system(format!(
|
||||
"Worktree ready: {}",
|
||||
worktree_path.display()
|
||||
)));
|
||||
match (code_restored, restore_summary.as_deref()) {
|
||||
(true, Some(s)) => {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system(format!("\u{2713} Code restored: {s}")));
|
||||
}
|
||||
(false, Some(s)) => {
|
||||
agent.scrollback.push_block(RenderBlock::system(format!(
|
||||
"\u{26A0} Code restore failed: {s}"
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let effective_chat = conversation_entry || app.chat_mode;
|
||||
agent.chat_kind = effective_chat;
|
||||
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
|
||||
return vec![Effect::LoadSession {
|
||||
agent_id,
|
||||
session_id: session_id_str,
|
||||
session_cwd: Some(session_cwd),
|
||||
chat_kind: conversation_entry,
|
||||
}];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
pub(in crate::app::dispatch) fn handle_fork_session_ready(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
new_session_id: acp::SessionId,
|
||||
cwd: std::path::PathBuf,
|
||||
) -> Vec<Effect> {
|
||||
let session_id_str = new_session_id.0.to_string();
|
||||
let pending_entry = std::mem::take(&mut app.deferred_startup.pending_chat);
|
||||
let agent_entry = app.agents.get(&agent_id).is_some_and(|a| a.chat_kind);
|
||||
let conversation_entry = pending_entry || agent_entry;
|
||||
if crate::app::session_startup::chat_mode_refuses_local_build_load(
|
||||
app.chat_mode,
|
||||
conversation_entry,
|
||||
&session_id_str,
|
||||
&app.cwd,
|
||||
) {
|
||||
refuse_chat_mode_build_agent(app, agent_id);
|
||||
return vec![];
|
||||
}
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
supersede_open_reload_window(agent, agent_id, "ForkSessionReady");
|
||||
agent.session.finish_command();
|
||||
agent.mark_turn_finished();
|
||||
agent.bind_session_id(new_session_id);
|
||||
agent.scrollback.begin_batch();
|
||||
agent.begin_replay_window();
|
||||
agent.session.cwd = cwd.clone();
|
||||
let effective_chat = conversation_entry || app.chat_mode;
|
||||
agent.chat_kind = effective_chat;
|
||||
return vec![Effect::LoadSession {
|
||||
agent_id,
|
||||
session_id: session_id_str,
|
||||
session_cwd: Some(cwd),
|
||||
chat_kind: conversation_entry,
|
||||
}];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
pub(in crate::app::dispatch) fn handle_fork_session_failed(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
error: String,
|
||||
) -> Vec<Effect> {
|
||||
tracing::error!(agent = ? agent_id, error = % error, "Fork session failed");
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
agent.pending_extensions_fetch = false;
|
||||
agent.session.finish_command();
|
||||
let elapsed = agent.turn_elapsed();
|
||||
agent.mark_turn_finished();
|
||||
agent.pending_first_prompt = None;
|
||||
agent.pending_fork_banner = None;
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::session_event(SessionEvent::TurnFailed {
|
||||
error,
|
||||
elapsed,
|
||||
}));
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
//! Session lifecycle, loading, picking, modal, and fork dispatchers.
|
||||
|
||||
pub(in crate::app::dispatch) mod foreign;
|
||||
pub(in crate::app::dispatch) mod fork;
|
||||
pub(in crate::app::dispatch) mod lifecycle;
|
||||
pub(in crate::app::dispatch) mod load;
|
||||
pub(in crate::app::dispatch) mod modal;
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Session rename / close helpers (shared with the dashboard).
|
||||
//!
|
||||
//! The `/sessions` picker modal was removed; rename-via-slash and
|
||||
//! dashboard close still use these dispatchers.
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use crate::app::dispatch::ctx::{SwitchCause, show_welcome, switch_to_agent};
|
||||
use crate::app::dispatch::task_result::unregister_session_effect;
|
||||
/// Remove an agent and clean up all references to it:
|
||||
/// `forked_from` pointers on surviving agents.
|
||||
pub(in crate::app::dispatch) fn remove_agent_and_cleanup(app: &mut AppView, agent_id: AgentId) {
|
||||
let removed = app.agents.shift_remove(&agent_id);
|
||||
for agent in app.agents.values_mut() {
|
||||
if agent.session.forked_from == Some(agent_id) {
|
||||
agent.session.forked_from = None;
|
||||
}
|
||||
}
|
||||
if removed.is_some() {
|
||||
drop(removed);
|
||||
crate::memory_release::release_retained_memory_with("agent-close");
|
||||
}
|
||||
}
|
||||
/// Close (drop from this pager's in-memory list) the given agent.
|
||||
///
|
||||
/// Order matters:
|
||||
/// 1. Refuse to close the only alive agent (toast "Cannot close the
|
||||
/// only session -- use /home to exit"). The user has nothing to
|
||||
/// fall back to inside the agent shell.
|
||||
/// 2. If the closed agent is currently active, switch first to a
|
||||
/// surviving peer (parent via `forked_from` if alive, else the
|
||||
/// first surviving entry) using `SwitchCause::Picker`. If no peer
|
||||
/// survives, fall back to Welcome (already covered by case 1 --
|
||||
/// this is a defensive belt).
|
||||
/// 3. Drop the agent from `app.agents` (`shift_remove` to preserve
|
||||
/// insertion order on every other entry) and clear `forked_from`
|
||||
/// references on surviving agents so dangling parent pointers
|
||||
/// cannot resurface.
|
||||
pub(in crate::app::dispatch) fn dispatch_sessions_confirm_close(
|
||||
app: &mut AppView,
|
||||
closed_id: AgentId,
|
||||
) -> Vec<Effect> {
|
||||
if !app.agents.contains_key(&closed_id) {
|
||||
return vec![];
|
||||
}
|
||||
if app.agents.len() == 1 {
|
||||
app.show_toast("Cannot close the only session -- use /home to exit");
|
||||
return vec![];
|
||||
}
|
||||
if matches!(app.active_view, ActiveView::Agent(id) if id == closed_id) {
|
||||
let parent = app
|
||||
.agents
|
||||
.get(&closed_id)
|
||||
.and_then(|a| a.session.forked_from)
|
||||
.filter(|p| app.agents.contains_key(p));
|
||||
let fallback = parent.or_else(|| app.agents.keys().copied().find(|id| *id != closed_id));
|
||||
if let Some(target) = fallback {
|
||||
switch_to_agent(app, target, SwitchCause::Picker);
|
||||
} else {
|
||||
show_welcome(app);
|
||||
}
|
||||
}
|
||||
let effects = unregister_session_effect(
|
||||
app.agents
|
||||
.get(&closed_id)
|
||||
.and_then(|a| a.session.session_id.clone()),
|
||||
);
|
||||
remove_agent_and_cleanup(app, closed_id);
|
||||
effects
|
||||
}
|
||||
/// Rename the current session via x.ai/session/rename.
|
||||
///
|
||||
/// Produces Effect::RenameSession which spawns an async ACP ext request.
|
||||
/// On completion, TaskResult::RenameSessionComplete shows the result.
|
||||
pub(in crate::app::dispatch) fn dispatch_rename_session(
|
||||
app: &mut AppView,
|
||||
title: String,
|
||||
) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
return vec![];
|
||||
};
|
||||
agent.display_name = Some(title.clone());
|
||||
vec![Effect::RenameSession {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
title,
|
||||
cwd: agent.session.cwd.clone(),
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Settings setters and settings UI dispatchers.
|
||||
|
||||
pub(in crate::app::dispatch) mod setters;
|
||||
pub(in crate::app::dispatch) mod ui;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,508 @@
|
||||
//! Session status, sharing, privacy, usage, and info dispatchers.
|
||||
|
||||
use super::ctx::get_active_agent;
|
||||
use super::settings::ui::refresh_open_settings_modals;
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use crate::notifications::{NotificationEvent, NotificationEventKind};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
|
||||
/// Toggle YOLO mode (auto-approve all permissions).
|
||||
///
|
||||
/// When turning ON: auto-approve all currently queued permissions and
|
||||
/// restore the stashed prompt. Future incoming permissions will be
|
||||
/// auto-approved in `handle_permission_request`.
|
||||
///
|
||||
/// Share the current session via a public URL.
|
||||
///
|
||||
/// Produces Effect::ShareSession which spawns an async ACP ext request.
|
||||
/// On completion, TaskResult::ShareSessionComplete shows the URL in scrollback.
|
||||
pub(super) fn dispatch_share_session(app: &mut AppView) -> Vec<Effect> {
|
||||
if !app.sharing_enabled {
|
||||
app.show_toast("Sharing is disabled");
|
||||
return vec![];
|
||||
}
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
// No active session — error should have been caught by slash command,
|
||||
// but guard here just in case.
|
||||
return vec![];
|
||||
};
|
||||
|
||||
vec![Effect::ShareSession {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Show session info: fetch via x.ai/session/info and display in scrollback.
|
||||
///
|
||||
/// Produces Effect::ShowSessionInfo which spawns an async ACP ext request.
|
||||
/// On completion, TaskResult::SessionInfoComplete shows the formatted info.
|
||||
pub(super) fn dispatch_show_session_info(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
// No active session — error should have been caught by slash command,
|
||||
// but guard here just in case.
|
||||
return vec![];
|
||||
};
|
||||
|
||||
vec![Effect::ShowSessionInfo {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
show_resolved_model: app.show_resolved_model,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Show privacy and data retention status as a system message in scrollback.
|
||||
///
|
||||
/// Three-state display: Enterprise ZDR, coding data sharing opted out,
|
||||
/// or opted in. Labels align with `CODING_DATA_SHARING_CHOICES` in
|
||||
/// `settings/defs.rs` and the `coding_data_sharing_toast` format.
|
||||
pub(super) fn dispatch_show_privacy_info(app: &mut AppView) -> Vec<Effect> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
if app.is_zdr {
|
||||
// Enterprise ZDR -- the team has disabled retention entirely.
|
||||
lines.push(" Zero Data Retention: enabled");
|
||||
lines.push(" Your data is not retained or used for training (ZDR enabled).");
|
||||
} else if app.coding_data_retention_opt_out {
|
||||
// Coding data sharing opted out -- matches desktop's "Privacy mode" state.
|
||||
lines.push(" Privacy: privacy mode");
|
||||
lines.push(" Your code data will not be trained on or used to improve the product.");
|
||||
lines.push("");
|
||||
lines.push(" Use /privacy opt-in to share data and help improve the product.");
|
||||
} else {
|
||||
// Coding data sharing opted in -- matches desktop's "Share data" state.
|
||||
lines.push(" Privacy: share data");
|
||||
lines.push(" Usage and code data may be used by SpaceXAI to improve the product.");
|
||||
lines.push("");
|
||||
lines.push(" Use /privacy opt-out to enable privacy mode.");
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(" Learn more: https://x.ai/legal");
|
||||
let text = lines.join("\n");
|
||||
push_system_to_any_agent(app, &text);
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// State-only mutation for `coding_data_sharing`. SHELL-owned.
|
||||
pub(super) fn set_coding_data_sharing_inner(app: &mut AppView, opted_in: bool) {
|
||||
app.coding_data_retention_opt_out = !opted_in;
|
||||
}
|
||||
|
||||
/// Set coding-data-sharing preference. SHELL-owned, auth-metadata-backed
|
||||
/// (persists via ACP ext-request, NOT `~/.kigi/config.toml`).
|
||||
pub(super) fn set_coding_data_sharing(app: &mut AppView, opted_in: bool) -> Vec<Effect> {
|
||||
// ── Guard 1: Enterprise ZDR ──────────────────────────────────────
|
||||
if app.is_zdr {
|
||||
app.show_toast("\u{2717} Cannot change: Zero Data Retention enabled");
|
||||
return vec![];
|
||||
}
|
||||
// ── Guard 2: Non-admin team member ───────────────────────────────
|
||||
if app.team_name.is_some() {
|
||||
let is_admin = app
|
||||
.team_role
|
||||
.as_deref()
|
||||
.is_some_and(|r| r.eq_ignore_ascii_case("admin"));
|
||||
if !is_admin {
|
||||
app.show_toast("\u{2717} Data sharing is controlled by your team admin");
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
// ── Guard 3: an agent must exist to thread the ACP call through ──
|
||||
let agent_id = match app.active_view {
|
||||
crate::app::app_view::ActiveView::Agent(id) => id,
|
||||
_ => match app.agents.keys().next().copied() {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "settings",
|
||||
key = "coding_data_sharing",
|
||||
opted_in,
|
||||
"set_coding_data_sharing called with no agents — unreachable in \
|
||||
practice; returning empty (no toast: app.show_toast would no-op)",
|
||||
);
|
||||
return vec![];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let prev = !app.coding_data_retention_opt_out;
|
||||
|
||||
// ── Idempotent path: toast but skip the ACP round-trip. ──────────
|
||||
if prev == opted_in {
|
||||
app.show_toast(&coding_data_sharing_toast(opted_in));
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// ── Optimistic mutation: state, then UI feedback, then effect. ───
|
||||
set_coding_data_sharing_inner(app, opted_in);
|
||||
refresh_open_settings_modals(app);
|
||||
app.show_toast(&coding_data_sharing_toast(opted_in));
|
||||
|
||||
tracing::info!(
|
||||
target: "settings",
|
||||
key = "coding_data_sharing",
|
||||
opted_in,
|
||||
"setting changed",
|
||||
);
|
||||
|
||||
vec![Effect::SetCodingDataSharing {
|
||||
agent_id,
|
||||
opted_in,
|
||||
rollback_to_opted_in: prev,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Format the `Coding data sharing` toast. Asymmetric: opt-in
|
||||
/// (privacy-degrading) uses ⚠ + consequence text; opt-out (safe
|
||||
/// default) uses ✓. Uses display names from the registry catalog.
|
||||
pub(super) fn coding_data_sharing_toast(opted_in: bool) -> String {
|
||||
let display = display_for_coding_data_sharing_canonical(opted_in);
|
||||
if opted_in {
|
||||
// Privacy-degrading: warn glyph + spelled-out consequence.
|
||||
format!(
|
||||
"\u{26A0} Coding data sharing: {display} \u{2014} code samples may be retained \
|
||||
for training"
|
||||
)
|
||||
} else {
|
||||
// Safe default — uniform ✓ glyph.
|
||||
format!("\u{2713} Coding data sharing: {display}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Display string for the canonical bool. Keep aligned with
|
||||
/// `CODING_DATA_SHARING_CHOICES` in `settings/defs.rs`.
|
||||
fn display_for_coding_data_sharing_canonical(opted_in: bool) -> &'static str {
|
||||
if opted_in { "Opt in" } else { "Opt out" }
|
||||
}
|
||||
|
||||
/// Scrub an untrusted error string for toast display. Substitutes a
|
||||
/// generic placeholder when the input exceeds 120 chars or contains
|
||||
/// control / bidi-override characters (prevents escape-sequence
|
||||
/// injection and visual spoofing). Full error stays in tracing logs.
|
||||
pub(super) fn scrub_error_for_toast(error: &str) -> String {
|
||||
const MAX_TOAST_ERROR_LEN: usize = 120;
|
||||
if error.len() > MAX_TOAST_ERROR_LEN
|
||||
|| error
|
||||
.chars()
|
||||
.any(crate::render::line_utils::is_unsafe_display_char)
|
||||
{
|
||||
"server error (see logs for details)".to_string()
|
||||
} else {
|
||||
error.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a system message to the active agent's scrollback, or to any available
|
||||
/// agent if on the welcome screen.
|
||||
fn push_system_to_any_agent(app: &mut AppView, msg: &str) {
|
||||
let block = crate::scrollback::block::RenderBlock::system(msg.to_string());
|
||||
if let ActiveView::Agent(id) = app.active_view
|
||||
&& let Some(agent) = app.agents.get_mut(&id)
|
||||
{
|
||||
agent.scrollback.push_block(block);
|
||||
return;
|
||||
}
|
||||
if let Some(agent) = app.agents.values_mut().next() {
|
||||
agent.scrollback.push_block(block);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show context info: fetch via x.ai/session/info and display rich breakdown.
|
||||
///
|
||||
/// Produces Effect::ShowContextInfo which spawns an async ACP ext request.
|
||||
/// On completion, TaskResult::ContextInfoComplete shows the formatted info.
|
||||
pub(super) fn dispatch_show_context_info(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
vec![Effect::ShowContextInfo {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Show credit usage: fetch billing data and display inline.
|
||||
///
|
||||
/// When the remote settings `grok_build_usage_redirect_url` flag is set (delivered via
|
||||
/// RemoteSettings, targeted at personal-team users), skip the backend fetch and
|
||||
/// just point the user at that URL instead. This is a kill switch for the
|
||||
/// personal-team billing path while it is unreliable.
|
||||
pub(super) fn dispatch_show_usage(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
if let Some(url) = app.usage_billing_redirect_url.clone() {
|
||||
if let Some(agent) = app.agents.get_mut(&id) {
|
||||
agent.scrollback.push_block(RenderBlock::System(
|
||||
crate::scrollback::blocks::SystemMessageBlock::new(format!(
|
||||
"Please check your usage on {url}"
|
||||
)),
|
||||
));
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
// Non-silent fetch: the effect also pulls the auto top-up rule so the
|
||||
// summary can render usage, prepaid credits, and auto top-up together.
|
||||
vec![Effect::FetchBilling {
|
||||
agent_id: id,
|
||||
silent: false,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Commit a one-line "update available" notice into the active agent's
|
||||
/// scrollback. Minimal mode has no welcome screen (the full TUI's update
|
||||
/// surface), so the background update check's result is shown here instead
|
||||
/// No-op when there is no active agent.
|
||||
pub(crate) fn commit_minimal_update_notice(app: &mut AppView, latest_version: &str) {
|
||||
if let ActiveView::Agent(id) = app.active_view
|
||||
&& let Some(agent) = app.agents.get_mut(&id)
|
||||
{
|
||||
agent.scrollback.push_block(RenderBlock::system(format!(
|
||||
"Update available: v{latest_version} — restart to apply."
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
/// `/queue` — commit a read-only list of the queued prompts as a system block.
|
||||
/// The text is built by [`crate::app::status_blocks::queue_block_text`]; this
|
||||
/// just resolves the active agent and pushes it. Works in every render mode; the
|
||||
/// primary inspection surface in minimal, which has no interactive `QueuePane`.
|
||||
pub(super) fn dispatch_show_queue(app: &mut AppView) -> Vec<Effect> {
|
||||
if let ActiveView::Agent(id) = app.active_view
|
||||
&& let Some(agent) = app.agents.get_mut(&id)
|
||||
{
|
||||
let text = crate::app::status_blocks::queue_block_text(agent);
|
||||
agent.scrollback.push_block(RenderBlock::system(text));
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// `/tasks` — commit a read-only list of background tasks, subagents, and
|
||||
/// scheduled (`/loop`) tasks as a system block. The text is built by
|
||||
/// [`crate::app::status_blocks::tasks_block_text`]; this just resolves the
|
||||
/// active agent and pushes it. Works in every render mode; the primary snapshot
|
||||
/// surface in minimal, which has no interactive `TasksPane`.
|
||||
pub(super) fn dispatch_show_tasks(app: &mut AppView) -> Vec<Effect> {
|
||||
if let ActiveView::Agent(id) = app.active_view
|
||||
&& let Some(agent) = app.agents.get_mut(&id)
|
||||
{
|
||||
let text = crate::app::status_blocks::tasks_block_text(agent);
|
||||
agent.scrollback.push_block(RenderBlock::system(text));
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Open the hidden `/gboom` easter egg as a modal over the active agent
|
||||
/// view. Requires a graphics-capable terminal (kitty protocol or iTerm2);
|
||||
/// otherwise a toast explains why nothing happened. On session-less
|
||||
/// surfaces (dashboard, welcome) this is a silent no-op.
|
||||
///
|
||||
/// Targets the top-level agent view (where the prompt lives), not a
|
||||
/// focused subagent view: the modal's tick/draw plumbing runs on the
|
||||
/// top-level view, mirroring the video viewer.
|
||||
pub(super) fn dispatch_open_gboom(app: &mut AppView) -> Vec<Effect> {
|
||||
use crate::terminal::image::{GraphicsProtocol, detect_graphics_protocol};
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
if detect_graphics_protocol() == GraphicsProtocol::None {
|
||||
agent.show_toast(
|
||||
"No demons here \u{2014} GBOOM needs a graphics-capable terminal \
|
||||
(kitty, Ghostty, WezTerm, iTerm2)",
|
||||
);
|
||||
return vec![];
|
||||
}
|
||||
// Close other media modals: they share the kitty placement id. Drop the
|
||||
// image viewer's in-flight loader too (its close path clears both —
|
||||
// a leaked rx would mis-feed the next image viewer's poll loop).
|
||||
agent.image_viewer = None;
|
||||
agent.image_load_rx = None;
|
||||
agent.video_viewer = None;
|
||||
agent.gboom = Some(crate::gboom::GboomState::new());
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Emit a `SessionReady` notification for the given agent.
|
||||
///
|
||||
/// Takes `&NotificationService` separately from `&AgentView` to avoid
|
||||
/// borrow-checker conflicts when `agent` is borrowed from `app.agents`.
|
||||
pub(super) fn notify_session_ready(
|
||||
notification_service: &crate::notifications::NotificationService,
|
||||
agent: &AgentView,
|
||||
) {
|
||||
notification_service.notify(NotificationEvent {
|
||||
kind: NotificationEventKind::SessionReady,
|
||||
title: "Grok".into(),
|
||||
body: NotificationEventKind::SessionReady.as_str().into(),
|
||||
session_id: agent.session.session_id.as_ref().map(|s| s.0.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// TaskResult handlers.
|
||||
|
||||
pub(super) fn handle_coding_data_sharing_updated(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
opted_in: bool,
|
||||
) -> Vec<Effect> {
|
||||
// Re-anchor mirror to server-confirmed value (defense-in-
|
||||
// depth against server reshaping the boolean). `agent_id`
|
||||
// discarded — privacy is app-level, not per-agent.
|
||||
set_coding_data_sharing_inner(app, opted_in);
|
||||
refresh_open_settings_modals(app);
|
||||
// Re-toast on confirmation. Without this, a slow ACP
|
||||
// round-trip would leave the user with only the
|
||||
// optimistic toast (already faded) and no
|
||||
// server-confirmed feedback.
|
||||
app.show_toast(&coding_data_sharing_toast(opted_in));
|
||||
tracing::info!(
|
||||
target: "settings",
|
||||
key = "coding_data_sharing",
|
||||
?agent_id,
|
||||
opted_in,
|
||||
"ACP update confirmed; mirror re-anchored",
|
||||
);
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_coding_data_sharing_failed(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
error: String,
|
||||
rollback_to_opted_in: bool,
|
||||
) -> Vec<Effect> {
|
||||
// Revert optimistic mutation: inner → refresh → toast.
|
||||
//
|
||||
// `agent_id` discarded — privacy is global.
|
||||
set_coding_data_sharing_inner(app, rollback_to_opted_in);
|
||||
refresh_open_settings_modals(app);
|
||||
// Scrub long/unsafe error strings before toasting.
|
||||
let scrubbed = scrub_error_for_toast(&error);
|
||||
app.show_toast(&format!(
|
||||
"\u{2717} Couldn't update coding data sharing: {scrubbed}"
|
||||
));
|
||||
tracing::warn!(
|
||||
target: "settings",
|
||||
key = "coding_data_sharing",
|
||||
?agent_id,
|
||||
rollback_to_opted_in,
|
||||
%error,
|
||||
"ACP update failed; reverted optimistic mutation",
|
||||
);
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_context_info_complete(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
info: Box<kigi_shell::session::SessionInfoResponse>,
|
||||
) -> Vec<Effect> {
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
let model = info.data.model.as_deref().unwrap_or("unknown").to_string();
|
||||
// Take ownership of the snapshot once, hand a clone to the
|
||||
// agent's running counters, then move the original into the
|
||||
// scrollback block (which keeps it for theme-reactive
|
||||
// re-rendering). This still costs one clone but reads as
|
||||
// "the agent needs a copy" rather than "the block needs a
|
||||
// copy", which matches the lifetime story.
|
||||
let snapshot = info.data.context;
|
||||
agent.apply_full_context_info(snapshot.clone());
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(crate::scrollback::block::RenderBlock::context_info(
|
||||
snapshot, model,
|
||||
));
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
// Action handlers.
|
||||
|
||||
pub(super) fn dispatch_copy_session_id(app: &mut AppView, index: usize) -> Vec<Effect> {
|
||||
use crate::views::modal::ActiveModal;
|
||||
// Try agent modal first, then fall back to app fields (welcome screen).
|
||||
let id = get_active_agent(app)
|
||||
.and_then(|agent| {
|
||||
if let Some(ActiveModal::SessionPicker {
|
||||
entries: Some(ref e),
|
||||
..
|
||||
}) = agent.active_modal
|
||||
{
|
||||
e.get(index).map(|entry| entry.id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
app.session_picker_entries
|
||||
.as_ref()
|
||||
.and_then(|s| s.get(index))
|
||||
.map(|e| e.id.clone())
|
||||
});
|
||||
if let Some(id) = id {
|
||||
let r = crate::clipboard::copy_text(&id);
|
||||
app.show_toast(r.message);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_show_release_notes(
|
||||
app: &mut AppView,
|
||||
title: String,
|
||||
content: String,
|
||||
) -> Vec<Effect> {
|
||||
match app.active_view {
|
||||
ActiveView::Agent(id) => {
|
||||
if let Some(agent) = app.agents.get_mut(&id) {
|
||||
agent.active_modal = Some(crate::views::modal::ActiveModal::DocViewer {
|
||||
title,
|
||||
content,
|
||||
scroll: 0,
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
cached_lines: None,
|
||||
previous_palette: None,
|
||||
standalone: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
ActiveView::Welcome => {
|
||||
app.welcome_doc_viewer = Some(crate::views::modal::ActiveModal::DocViewer {
|
||||
title,
|
||||
content,
|
||||
scroll: 0,
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
cached_lines: None,
|
||||
previous_palette: None,
|
||||
standalone: true,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,320 @@
|
||||
//! Tests for login, logout, account switching, and auth-code dispatchers.
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── agent-bound kinds (bash) ─────────
|
||||
|
||||
/// A bash command typed while a turn is RUNNING takes the
|
||||
/// server-authoritative immediate path (Effect + optimistic echo, no local
|
||||
/// queue entry).
|
||||
#[test]
|
||||
fn bash_while_running_is_server_authoritative() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning;
|
||||
|
||||
let effects = dispatch(Action::SendBashCommand("ls -la".into()), &mut app);
|
||||
let pid = match &effects[0] {
|
||||
Effect::SendBashCommand {
|
||||
command, prompt_id, ..
|
||||
} => {
|
||||
assert_eq!(command, "ls -la");
|
||||
prompt_id.clone()
|
||||
}
|
||||
other => panic!("expected immediate SendBashCommand, got {other:?}"),
|
||||
};
|
||||
// Not in the local queue.
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 0);
|
||||
// Optimistic echo present with kind="bash".
|
||||
let q = app
|
||||
.shared_prompt_queue("test-session")
|
||||
.expect("echo present");
|
||||
assert_eq!(q.len(), 1);
|
||||
assert_eq!(q[0].id, pid);
|
||||
assert_eq!(q[0].kind, "bash");
|
||||
assert_eq!(q[0].text, "ls -la");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_complete_triggers_bundle_status_fetch() {
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq: 1,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::Pending,
|
||||
};
|
||||
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::AuthComplete {
|
||||
request_seq: 1,
|
||||
meta: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(matches!(app.auth_state, AuthState::Done));
|
||||
// Pager only refreshes the on-disk catalog snapshot; the actual
|
||||
// bundle download now runs inside the shell post-auth.
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::FetchBundleStatus))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_complete_with_deferred_load_also_fetches_status() {
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq: 1,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::Pending,
|
||||
};
|
||||
app.deferred_startup.session =
|
||||
Some(crate::app::session_startup::DeferredSessionStartup::Load {
|
||||
session_id: "test-session".into(),
|
||||
session_cwd: None,
|
||||
chat_kind: false,
|
||||
});
|
||||
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::AuthComplete {
|
||||
request_seq: 1,
|
||||
meta: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::FetchBundleStatus))
|
||||
);
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::LoadSession { .. }))
|
||||
);
|
||||
assert!(app.deferred_startup.session.is_none());
|
||||
}
|
||||
|
||||
/// `/login` from the welcome screen (startup / logged-out) must NOT
|
||||
/// stash a return view — the normal login-then-load flow is preserved.
|
||||
#[test]
|
||||
fn login_from_welcome_does_not_stash_return_view() {
|
||||
let mut app = test_app();
|
||||
assert_eq!(app.active_view, ActiveView::Welcome);
|
||||
|
||||
dispatch(Action::Login, &mut app);
|
||||
|
||||
assert_eq!(app.active_view, ActiveView::Welcome);
|
||||
assert_eq!(app.auth_return_view, None);
|
||||
}
|
||||
|
||||
/// A second auth-failed turn with no rewindable prompt
|
||||
/// (`in_flight_prompt == None`) must not clobber the stash from an
|
||||
/// earlier 401.
|
||||
#[test]
|
||||
fn second_auth_failure_does_not_clobber_reauth_stash() {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent.reauth_stashed_prompt = Some(crate::app::agent::InFlightPrompt {
|
||||
text: "first prompt".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: crate::scrollback::EntryId::new(0),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::session_event(SessionEvent::ReAuthRequired));
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.turn_started_at = Some(std::time::Instant::now());
|
||||
agent.session.in_flight_prompt = None;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
Action::TaskComplete(TaskResult::PromptResponse {
|
||||
agent_id: id,
|
||||
result: Err("Unauthorized (401)".to_string()),
|
||||
http_status: Some(401),
|
||||
prompt_id: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
app.agents[&id]
|
||||
.reauth_stashed_prompt
|
||||
.as_ref()
|
||||
.map(|prompt| prompt.text.as_str()),
|
||||
Some("first prompt"),
|
||||
"a None in_flight_prompt must not wipe an earlier stash"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancelling a mid-session re-auth drops the stashed prompt so it is
|
||||
/// not silently resubmitted on a later, unrelated login.
|
||||
#[test]
|
||||
fn cancel_login_drops_reauth_stashed_prompt() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
app.agents.get_mut(&id).unwrap().reauth_stashed_prompt =
|
||||
Some(crate::app::agent::InFlightPrompt {
|
||||
text: "stale".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: crate::scrollback::EntryId::new(0),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
|
||||
dispatch(Action::Login, &mut app);
|
||||
dispatch(Action::CancelLogin, &mut app);
|
||||
|
||||
assert!(
|
||||
app.agents[&id].reauth_stashed_prompt.is_none(),
|
||||
"cancelling re-auth must drop the stashed prompt"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancelling a mid-session re-auth strips the stale `ReAuthRequired`
|
||||
/// prompt from scrollback so a later `PromptResponse` cannot re-detect
|
||||
/// it and re-stash the prompt for silent resubmission.
|
||||
#[test]
|
||||
fn cancel_login_strips_reauth_prompt_from_scrollback() {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent.reauth_stashed_prompt = Some(crate::app::agent::InFlightPrompt {
|
||||
text: "stale".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: crate::scrollback::EntryId::new(0),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::session_event(SessionEvent::ReAuthRequired));
|
||||
}
|
||||
|
||||
dispatch(Action::Login, &mut app);
|
||||
dispatch(Action::CancelLogin, &mut app);
|
||||
|
||||
let sb = &app.agents[&id].scrollback;
|
||||
let has_reauth = (0..sb.len()).any(|i| {
|
||||
matches!(
|
||||
sb.entry(i).map(|e| &e.block),
|
||||
Some(RenderBlock::SessionEvent(ev)) if matches!(ev.event, SessionEvent::ReAuthRequired)
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
!has_reauth,
|
||||
"cancelling re-auth must strip the stale re-auth prompt from scrollback"
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty `auth_methods` (preferred_method pin unavailable) must not invent
|
||||
/// `grok.com` or start an OIDC flow the agent did not advertise.
|
||||
#[test]
|
||||
fn login_with_empty_auth_methods_fails_closed() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.auth_methods.clear();
|
||||
app.login_method_id = None;
|
||||
|
||||
let effects = dispatch(Action::Login, &mut app);
|
||||
|
||||
assert!(
|
||||
effects.is_empty(),
|
||||
"must not start Authenticate without an advertised method"
|
||||
);
|
||||
assert_eq!(
|
||||
app.active_view,
|
||||
ActiveView::Agent(AgentId(0)),
|
||||
"must stay on the session view"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
&app.auth_state,
|
||||
AuthState::Pending { error: Some(msg) }
|
||||
if msg.contains("preferred_method=api_key")
|
||||
),
|
||||
"must surface pin-unavailable error, got {:?}",
|
||||
app.auth_state
|
||||
);
|
||||
assert!(app.login_method_id.is_none());
|
||||
}
|
||||
|
||||
/// Cancelling a mid-session login returns to the session rather than
|
||||
/// quitting the app, and clears the stashed view + auth state.
|
||||
#[test]
|
||||
fn cancel_login_restores_view() {
|
||||
let mut app = test_app_with_agent();
|
||||
dispatch(Action::Login, &mut app);
|
||||
assert_eq!(app.active_view, ActiveView::Welcome);
|
||||
|
||||
let effects = dispatch(Action::CancelLogin, &mut app);
|
||||
|
||||
assert!(effects.is_empty(), "cancel is pure state, no effects");
|
||||
assert_eq!(app.active_view, ActiveView::Agent(AgentId(0)));
|
||||
assert_eq!(app.auth_return_view, None);
|
||||
assert!(matches!(app.auth_state, AuthState::Done));
|
||||
}
|
||||
|
||||
/// `CancelLogin` outside a mid-session login is a no-op (must not move
|
||||
/// off the welcome screen or panic).
|
||||
#[test]
|
||||
fn cancel_login_noop_without_stashed_view() {
|
||||
let mut app = test_app();
|
||||
let effects = dispatch(Action::CancelLogin, &mut app);
|
||||
assert!(effects.is_empty());
|
||||
assert_eq!(app.active_view, ActiveView::Welcome);
|
||||
assert_eq!(app.auth_return_view, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_complete_extracts_show_resolved_model_from_meta() {
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq: 1,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::Pending,
|
||||
};
|
||||
assert!(app.show_resolved_model);
|
||||
|
||||
dispatch(
|
||||
Action::TaskComplete(TaskResult::AuthComplete {
|
||||
request_seq: 1,
|
||||
meta: Some(serde_json::json!({ "show_resolved_model": false })),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(!app.show_resolved_model);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_complete_preserves_show_resolved_model_when_absent() {
|
||||
let mut app = test_app();
|
||||
app.show_resolved_model = false;
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq: 1,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::Pending,
|
||||
};
|
||||
|
||||
dispatch(
|
||||
Action::TaskComplete(TaskResult::AuthComplete {
|
||||
request_seq: 1,
|
||||
meta: Some(serde_json::to_value(kigi_shell::auth::AuthMeta::default()).unwrap()),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(!app.show_resolved_model);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,414 @@
|
||||
//! Tests for the `/jump` picker dispatchers.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn push_turns(app: &mut AppView, id: AgentId, n: usize) {
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
for i in 0..n {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::user_prompt(format!("question {i}")));
|
||||
let tall = (0..8)
|
||||
.map(|p| format!("answer {i} para {p}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::agent_message(tall));
|
||||
}
|
||||
agent.scrollback.prepare_layout(80, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_picker_needs_two_turns() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 1);
|
||||
|
||||
let effects = dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(effects.is_empty());
|
||||
assert!(
|
||||
app.agents[&id].jump_state.is_none(),
|
||||
"a single turn has nothing to jump to"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_picker_snapshots_viewport_and_opens_on_active_turn() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
let js = agent.jump_state.as_ref().expect("picker open");
|
||||
assert_eq!(js.entries.len(), 3);
|
||||
assert_eq!(js.entries[0].preview, "question 0");
|
||||
assert_eq!(js.selected, 2, "opens on the turn at the viewport top");
|
||||
assert!(
|
||||
js.restore.bookmark.is_some(),
|
||||
"captured a viewport bookmark"
|
||||
);
|
||||
assert!(js.restore.follow_mode, "goto_bottom left follow on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_picker_refused_while_rewind_open() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().rewind_state = Some(
|
||||
crate::views::rewind::RewindState::new_cancel_offer(0, None, None),
|
||||
);
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(app.agents[&id].jump_state.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_picker_refused_while_inline_edit_open() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
assert!(
|
||||
app.agents.get_mut(&id).unwrap().enter_inline_edit(0),
|
||||
"entered inline edit on the first prompt"
|
||||
);
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(
|
||||
app.agents[&id].jump_state.is_none(),
|
||||
"picker must not stack on an open inline edit (wheel scroll would leak)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_picker_refused_while_input_overlay_pending() {
|
||||
// A pending permission / question / cancel-turn / plan-approval overlay
|
||||
// suppresses the picker's rendering, so opening one would be invisible but
|
||||
// still eat wheel/keys — `/jump` must refuse.
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().cancel_turn_view =
|
||||
Some(crate::views::modal::CancelTurnViewState {
|
||||
active_idx: 0,
|
||||
running_count: 1,
|
||||
});
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(
|
||||
app.agents[&id].jump_state.is_none(),
|
||||
"/jump must not open behind a pending input overlay"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_drops_hidden_jump_picker_behind_input_overlay() {
|
||||
// If an input overlay arrives (async) after the picker opened, the picker
|
||||
// is hidden but `jump_state` lingers; a wheel event must drop it instead of
|
||||
// scrolling a cursor the user can't see (and shifting the transcript).
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(app.agents[&id].jump_state.is_some(), "picker opened");
|
||||
|
||||
app.agents.get_mut(&id).unwrap().cancel_turn_view =
|
||||
Some(crate::views::modal::CancelTurnViewState {
|
||||
active_idx: 0,
|
||||
running_count: 1,
|
||||
});
|
||||
app.agents.get_mut(&id).unwrap().handle_scroll(1, 0, 0);
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
assert!(
|
||||
agent.jump_state.is_none(),
|
||||
"a hidden picker is dropped on scroll, not driven"
|
||||
);
|
||||
assert!(
|
||||
agent.cancel_turn_view.is_some(),
|
||||
"the suppressing overlay is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_drops_hidden_jump_picker_behind_input_overlay() {
|
||||
// The key-path mirror: with an input overlay pending (and, as here, the
|
||||
// scrollback pane focused so the pane-gated cancel-turn panel is skipped),
|
||||
// a key must drop the hidden picker instead of the picker handling it.
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(app.agents[&id].jump_state.is_some(), "picker opened");
|
||||
|
||||
app.agents.get_mut(&id).unwrap().cancel_turn_view =
|
||||
Some(crate::views::modal::CancelTurnViewState {
|
||||
active_idx: 0,
|
||||
running_count: 1,
|
||||
});
|
||||
let reg = crate::actions::ActionRegistry::defaults();
|
||||
let ev = Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
let _ = app.agents.get_mut(&id).unwrap().handle_input(&ev, ®);
|
||||
|
||||
assert!(
|
||||
app.agents[&id].jump_state.is_none(),
|
||||
"a hidden picker is dropped before it can handle keys"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_stays_cancellable_with_jump_open() {
|
||||
use crate::app::agent::AgentState;
|
||||
use crate::app::app_view::InputOutcome;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
// /jump must not swallow the advertised Ctrl+C while a turn is running.
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning;
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(app.agents[&id].jump_state.is_some(), "picker opened");
|
||||
|
||||
let reg = crate::actions::ActionRegistry::defaults();
|
||||
let ev = Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
let outcome = app.agents.get_mut(&id).unwrap().handle_input(&ev, ®);
|
||||
|
||||
assert!(
|
||||
app.agents[&id].jump_state.is_none(),
|
||||
"Ctrl+C dismissed the picker"
|
||||
);
|
||||
assert!(
|
||||
matches!(outcome, InputOutcome::Action(Action::CancelTurn)),
|
||||
"and cancelled the running turn, got {outcome:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_picker_refused_while_btw_open() {
|
||||
// /btw owns the prompt slot; opening /jump behind it would split input.
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().btw_state = Some(
|
||||
crate::views::btw_overlay::BtwOverlayState::done("q".into(), "a".into()),
|
||||
);
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(
|
||||
app.agents[&id].jump_state.is_none(),
|
||||
"/jump must not open behind /btw"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_reload_dismisses_jump_picker() {
|
||||
// jump_state indexes the pre-reload transcript; a reconnect must drop it.
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(app.agents[&id].jump_state.is_some(), "picker opened");
|
||||
|
||||
app.agents.get_mut(&id).unwrap().begin_session_reload(1);
|
||||
assert!(
|
||||
app.agents[&id].jump_state.is_none(),
|
||||
"reload cleared the picker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_select_jumps_and_closes() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
let target_id = app.agents[&id].jump_state.as_ref().unwrap().entries[0].prompt_entry_id;
|
||||
let target_entry = app.agents[&id].scrollback.index_of_id(target_id).unwrap();
|
||||
|
||||
dispatch(Action::JumpPickerSelect(target_id), &mut app);
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
assert!(agent.jump_state.is_none(), "picker closed");
|
||||
assert_eq!(agent.scrollback.selected(), Some(target_entry));
|
||||
assert_eq!(agent.scrollback.current_turn(), Some(0));
|
||||
assert!(!agent.scrollback.is_follow_mode());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_select_uses_stable_id_across_removal() {
|
||||
// The picker carries a stable EntryId, so removing an earlier entry (which
|
||||
// shifts every positional index) still lands the jump on the intended
|
||||
// prompt — a positional turn index would target the wrong block.
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 4);
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
|
||||
let (first_id, target_id) = {
|
||||
let entries = &app.agents[&id].jump_state.as_ref().unwrap().entries;
|
||||
(
|
||||
entries[0].prompt_entry_id,
|
||||
entries.last().unwrap().prompt_entry_id,
|
||||
)
|
||||
};
|
||||
// Remove the first turn's prompt, shifting the positional indices.
|
||||
app.agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.scrollback
|
||||
.remove_entry(first_id);
|
||||
|
||||
dispatch(Action::JumpPickerSelect(target_id), &mut app);
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
assert!(agent.jump_state.is_none(), "picker closed");
|
||||
let expected = agent
|
||||
.scrollback
|
||||
.index_of_id(target_id)
|
||||
.expect("target prompt still present");
|
||||
assert_eq!(
|
||||
agent.scrollback.selected(),
|
||||
Some(expected),
|
||||
"stable id lands on the intended prompt even after indices shifted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_select_restores_viewport_on_out_of_range_turn() {
|
||||
// A turn index can go stale if the turn list shrank (async clear/rewind)
|
||||
// while the picker was open; selecting it must restore the captured
|
||||
// viewport instead of stranding the transcript at the last preview.
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
|
||||
let at_bottom = app.agents[&id].scrollback.scroll_offset();
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
// Move the preview far from the snapshot so a restore is observable.
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
let first_id = agent.jump_state.as_ref().unwrap().entries[0].prompt_entry_id;
|
||||
let first = agent.scrollback.index_of_id(first_id).unwrap();
|
||||
agent.scrollback.scroll_to_entry_center(first);
|
||||
}
|
||||
assert_ne!(app.agents[&id].scrollback.scroll_offset(), at_bottom);
|
||||
|
||||
dispatch(
|
||||
Action::JumpPickerSelect(crate::scrollback::entry::EntryId::new(999_999)),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
assert!(agent.jump_state.is_none(), "picker closed");
|
||||
assert_eq!(
|
||||
agent.scrollback.scroll_offset(),
|
||||
at_bottom,
|
||||
"a failed jump restores the captured viewport"
|
||||
);
|
||||
assert!(agent.scrollback.is_follow_mode(), "follow restored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_dismisses_open_jump_picker() {
|
||||
// The mirror of `show_picker_refused_while_rewind_open`: starting rewind
|
||||
// while the picker is open must dismiss it (and restore its viewport), so
|
||||
// the input-shadowed picker can't reappear stale once rewind closes.
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
|
||||
let before_offset = app.agents[&id].scrollback.scroll_offset();
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
// Preview a far turn so the viewport actually moved under the picker.
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
let first_id = agent.jump_state.as_ref().unwrap().entries[0].prompt_entry_id;
|
||||
let first = agent.scrollback.index_of_id(first_id).unwrap();
|
||||
agent.scrollback.scroll_to_entry_center(first);
|
||||
}
|
||||
assert!(app.agents[&id].jump_state.is_some());
|
||||
assert_ne!(app.agents[&id].scrollback.scroll_offset(), before_offset);
|
||||
|
||||
dispatch(Action::Rewind, &mut app);
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
assert!(
|
||||
agent.jump_state.is_none(),
|
||||
"rewind dismissed the jump picker"
|
||||
);
|
||||
assert!(agent.rewind_state.is_some(), "rewind opened");
|
||||
assert_eq!(
|
||||
agent.scrollback.scroll_offset(),
|
||||
before_offset,
|
||||
"the jump viewport was restored before rewind took over"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_edit_dismisses_open_jump_picker() {
|
||||
// The mirror of `show_picker_refused_while_inline_edit_open`: entering
|
||||
// inline edit while the picker is open dismisses it so it can't reappear
|
||||
// stale. (Inline edit re-centers on the edited entry, so only the picker
|
||||
// teardown is asserted, not the viewport.)
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(app.agents[&id].jump_state.is_some());
|
||||
|
||||
let entered = app.agents.get_mut(&id).unwrap().enter_inline_edit(0);
|
||||
assert!(entered, "entered inline edit on the first prompt");
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
assert!(
|
||||
agent.jump_state.is_none(),
|
||||
"entering inline edit dismissed the jump picker"
|
||||
);
|
||||
assert!(agent.inline_edit.is_some(), "inline edit opened");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismiss_restores_viewport() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
{
|
||||
let sb = &mut app.agents.get_mut(&id).unwrap().scrollback;
|
||||
sb.goto_bottom();
|
||||
}
|
||||
let before_offset = app.agents[&id].scrollback.scroll_offset();
|
||||
let before_selected = app.agents[&id].scrollback.selected();
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
// Preview a far-away turn so the transcript actually moved.
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
let first_id = agent.jump_state.as_ref().unwrap().entries[0].prompt_entry_id;
|
||||
let first = agent.scrollback.index_of_id(first_id).unwrap();
|
||||
agent.scrollback.scroll_to_entry_center(first);
|
||||
}
|
||||
assert_ne!(app.agents[&id].scrollback.scroll_offset(), before_offset);
|
||||
|
||||
dispatch(Action::JumpDismiss, &mut app);
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
assert!(agent.jump_state.is_none());
|
||||
assert_eq!(agent.scrollback.scroll_offset(), before_offset);
|
||||
assert_eq!(agent.scrollback.selected(), before_selected);
|
||||
assert!(agent.scrollback.is_follow_mode(), "follow restored");
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
//! Tests for the dispatch module tree: shared fixtures and per-domain test modules.
|
||||
mod auth;
|
||||
mod billing;
|
||||
mod dashboard;
|
||||
mod jump;
|
||||
mod modes;
|
||||
mod notes;
|
||||
mod permissions;
|
||||
mod prompt;
|
||||
mod rewind;
|
||||
mod router;
|
||||
mod session;
|
||||
mod settings;
|
||||
mod status;
|
||||
mod task_result;
|
||||
mod transcript;
|
||||
mod turn;
|
||||
use super::billing::{
|
||||
CreditLimitUpsellMode, credit_limit_upsell_mode, is_max_tier, open_credit_limit_upsell,
|
||||
open_free_usage_upsell,
|
||||
};
|
||||
use super::ctx::{find_agent_by_session_id, get_active_agent, get_active_agent_mut};
|
||||
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,
|
||||
};
|
||||
use super::modes::{
|
||||
YOLO_ON_UNDER_PLAN_TOAST, active_agent_plan_nudge_state, dispatch_cycle_mode_and_sync,
|
||||
permission_mode_toast,
|
||||
};
|
||||
use super::permissions::drain_permission_queue;
|
||||
use super::prompt::{
|
||||
dispatch_send_prompt, dispatch_send_prompt_inner, input_can_trigger_project_picker,
|
||||
};
|
||||
use super::session::fork::build_child_fork_marker;
|
||||
use super::session::lifecycle::{dispatch_new_session_inner, drain_startup_actions, finish_trust};
|
||||
use super::session::load::{dispatch_load_session_with_restore, reanchor_grouped_selection};
|
||||
use super::session::modal::{dispatch_rename_session, dispatch_sessions_confirm_close};
|
||||
use super::settings::setters::set_default_model_inner;
|
||||
use super::settings::ui::{action_for_reset, apply_setting_rollback};
|
||||
use super::status::scrub_error_for_toast;
|
||||
use super::task_result::dispatch_task_result;
|
||||
use super::*;
|
||||
use crate::acp::model_state::ModelState;
|
||||
use crate::acp::tracker::AcpUpdateTracker;
|
||||
use crate::app::actions::{Action, Effect, SubagentKillOutcome, SwitchModelError, TaskResult};
|
||||
use crate::app::agent::{AgentId, AgentSession, AgentState};
|
||||
use crate::app::agent_view::{ActivePane, AgentView, PromptMode};
|
||||
use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState, TrustState};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::{SessionEvent, ToolCallBlock};
|
||||
use crate::scrollback::state::ScrollbackState;
|
||||
use agent_client_protocol as acp;
|
||||
use indexmap::IndexMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
fn test_app() -> AppView {
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
AppView {
|
||||
active_view: ActiveView::Welcome,
|
||||
auth_return_view: None,
|
||||
agents: IndexMap::new(),
|
||||
next_agent_id: 0,
|
||||
models: ModelState::default(),
|
||||
registry: crate::actions::ActionRegistry::defaults(),
|
||||
settings_registry: std::sync::Arc::new(crate::settings::SettingsRegistry::defaults()),
|
||||
current_ui: kigi_shell::agent::config::UiConfig::default(),
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
project_picker_shown: true,
|
||||
project_picker_disabled: false,
|
||||
cwd_has_git_ancestor: false,
|
||||
acp_tx: tx,
|
||||
scratch: crate::scrollback::render::ScratchBuffer::new(),
|
||||
cursor: crate::render::draw::CursorState::new(),
|
||||
pending_action: None,
|
||||
exit_session_pending: None,
|
||||
scroll_state: crate::input::mouse::MouseScrollState::default(),
|
||||
scroll_config: crate::input::mouse::ScrollConfig::default(),
|
||||
appearance: crate::appearance::AppearanceConfig::default(),
|
||||
notification_service: crate::notifications::NotificationService::new(Default::default()),
|
||||
pending_notification_escapes: None,
|
||||
deferred_notification: None,
|
||||
tracing_rx: None,
|
||||
changelog_markdown: None,
|
||||
changelog_bullets: Vec::new(),
|
||||
tips: Vec::new(),
|
||||
tip: None,
|
||||
cli_model_override: None,
|
||||
cli_effort_token: None,
|
||||
default_yolo: false,
|
||||
permission_mode_from_soft_default: true,
|
||||
auto_mode_gate: true,
|
||||
yolo_policy_block: None,
|
||||
yolo_launch_block_notice: None,
|
||||
screen_mode_switch_hint: None,
|
||||
require_plan_approval: false,
|
||||
plan_mode: false,
|
||||
chat_mode: false,
|
||||
subagents: false,
|
||||
ask_user: false,
|
||||
mouse_captured: true,
|
||||
new_worktree_dialog: None,
|
||||
contextual_hints: Default::default(),
|
||||
remote_contextual_hints: None,
|
||||
tip_seen_counts: Default::default(),
|
||||
last_known_terminal_rows: 0,
|
||||
small_screen_tip_evaluated: false,
|
||||
clipboard_focus_tip: Default::default(),
|
||||
new_session_worktree_mode: crate::app::app_view::WorktreeMode::Never,
|
||||
fork_worktree_mode: crate::app::app_view::WorktreeMode::Ask,
|
||||
restore_code: None,
|
||||
agent_override: None,
|
||||
bootstrap_acp_commands: Vec::new(),
|
||||
auth_methods: vec![acp::AuthMethod::Agent(acp::AuthMethodAgent::new(
|
||||
acp::AuthMethodId::new("grok.com"),
|
||||
"Grok".to_string(),
|
||||
))],
|
||||
auth_state: AuthState::Done,
|
||||
trust_state: TrustState::Done,
|
||||
login_label: None,
|
||||
login_method_id: None,
|
||||
auth_start_mode: AuthMode::Pending,
|
||||
auth_code_input: String::new(),
|
||||
next_auth_request_seq: 1,
|
||||
deferred_startup: Default::default(),
|
||||
auth_use_oauth: false,
|
||||
auth_clipboard_copied: false,
|
||||
team_id: None,
|
||||
team_name: None,
|
||||
is_zdr: false,
|
||||
team_role: None,
|
||||
coding_data_retention_opt_out: false,
|
||||
show_tips: None,
|
||||
auto_update: None,
|
||||
ask_user_question_timeout_enabled: None,
|
||||
zdr_access_enabled: false,
|
||||
usage_billing_redirect_url: None,
|
||||
access_gate_shown_logged: false,
|
||||
gate: None,
|
||||
subscription_tier: None,
|
||||
paywall_check_started: None,
|
||||
last_subscription_check_at: None,
|
||||
subscription_watch_interval_secs: None,
|
||||
pending_gate_verification: None,
|
||||
gate_verify_gen: 0,
|
||||
bundle_state: crate::app::bundle::BundleState::default(),
|
||||
scroll_debug_hud: crate::views::scroll_debug_hud::ScrollDebugHud::new(),
|
||||
fps_hud: crate::views::fps_hud::FpsHud::new(),
|
||||
welcome_prompt: crate::views::prompt_widget::PromptWidget::new(),
|
||||
slash_mru: std::rc::Rc::new(std::cell::RefCell::new(
|
||||
crate::slash::mru::SlashMru::new_in_memory(),
|
||||
)),
|
||||
welcome_prompt_focused: false,
|
||||
welcome_tip_typing_dismissed: false,
|
||||
welcome_menu_index: None,
|
||||
welcome_menu_rects: Vec::new(),
|
||||
welcome_show_changelog_action: false,
|
||||
welcome_import_banner_rect: None,
|
||||
last_mouse_pos: None,
|
||||
last_scroll_pos: None,
|
||||
last_cache_evict_at: None,
|
||||
welcome_prompt_rect: None,
|
||||
welcome_auth_url_rect: None,
|
||||
welcome_on_auth_url: false,
|
||||
welcome_on_changelog_cta: false,
|
||||
welcome_auth_fallback_rect: None,
|
||||
welcome_refresh_rect: None,
|
||||
welcome_gate_url_rect: None,
|
||||
welcome_changelog_cta_rect: None,
|
||||
auth_show_raw_url: false,
|
||||
auth_mouse_disabled: false,
|
||||
session_picker_entries: None,
|
||||
session_picker_loading: false,
|
||||
session_picker_state: crate::views::picker::PickerState::with_mode(
|
||||
crate::views::picker::PickerMode::FullScreen,
|
||||
),
|
||||
session_picker_source_filter: crate::views::session_picker::SourceFilter::default(),
|
||||
session_picker_content_results: None,
|
||||
session_picker_content_loading: false,
|
||||
session_picker_deep_search_seq: 0,
|
||||
session_picker_list_seq: 0,
|
||||
foreign_session_compat: Default::default(),
|
||||
foreign_session_scan_seq: 0,
|
||||
foreign_scan_coordinator: Default::default(),
|
||||
session_picker_lanes: Default::default(),
|
||||
session_picker_detail_generation: 0,
|
||||
session_picker_entries_query: None,
|
||||
welcome_tick: 0,
|
||||
welcome_shimmer_frame: 0,
|
||||
startup_warnings: Vec::new(),
|
||||
is_api_key_auth: false,
|
||||
pending_update_version: None,
|
||||
foreign_resume_launch_generation: 0,
|
||||
foreign_resume_launch: None,
|
||||
quit_for_update: false,
|
||||
relaunch: None,
|
||||
import_claude_modal: None,
|
||||
welcome_doc_viewer: None,
|
||||
screen_mode: crate::app::ScreenMode::Inline,
|
||||
pending_effects: Vec::new(),
|
||||
pending_editor_path: None,
|
||||
pending_agents_modal_refresh: None,
|
||||
pending_pager_path: None,
|
||||
pending_pager_ansi: false,
|
||||
minimal_state: crate::minimal_api::MinimalState::default(),
|
||||
reconnect_pending: false,
|
||||
show_resolved_model: true,
|
||||
sharing_enabled: false,
|
||||
usage_visible: true,
|
||||
tier_restricted_commands: Vec::new(),
|
||||
leader_mode: true,
|
||||
credit_balance: None,
|
||||
auto_topup: None,
|
||||
billing_poll_wanted: false,
|
||||
leader_roster: Vec::new(),
|
||||
dashboard_local_sessions: Vec::new(),
|
||||
dashboard_sessions_loading: false,
|
||||
shared_prompt_queues: std::collections::HashMap::new(),
|
||||
optimistic_prompt_echoes: std::collections::HashMap::new(),
|
||||
pending_running_adoptions: std::collections::HashMap::new(),
|
||||
session_picker_grouped: false,
|
||||
cancel_rewind_enabled: true,
|
||||
session_recap_available: false,
|
||||
dashboard: None,
|
||||
dashboard_persisted: None,
|
||||
keyboard_normalizer: crate::input::KeyboardNormalizer::from_terminal_context(),
|
||||
has_claude_import: false,
|
||||
}
|
||||
}
|
||||
/// Build a default `AgentSession` for
|
||||
/// tests. Centralises the fixture so new fields on `AgentSession`
|
||||
/// don't break every test that constructs one by hand. The
|
||||
/// `acp_tx` is cloned from the test `AppView`; the
|
||||
/// `deferred_model_switch` is pulled from the `AppView`'s CLI
|
||||
/// overrides for parity with `dispatch_new_session_inner`.
|
||||
fn make_test_agent_session(app: &AppView, id: AgentId, sid: &str) -> AgentSession {
|
||||
AgentSession {
|
||||
id,
|
||||
acp_tx: app.acp_tx.clone(),
|
||||
session_id: Some(sid.to_string().into()),
|
||||
models: ModelState::default(),
|
||||
state: AgentState::Idle,
|
||||
tracker: AcpUpdateTracker::new(),
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
is_worktree: false,
|
||||
forked_from: None,
|
||||
pending_prompts: std::collections::VecDeque::new(),
|
||||
next_queue_id: 0,
|
||||
yolo_mode: false,
|
||||
auto_mode: false,
|
||||
prompt_history: Vec::new(),
|
||||
prompt_history_loading: false,
|
||||
loading_replay: false,
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
model_switch_pending: false,
|
||||
user_model_preference: None,
|
||||
deferred_model_switch: app.deferred_model_switch_from_cli(),
|
||||
bg_tasks: std::collections::BTreeMap::new(),
|
||||
bg_tool_call_to_task: std::collections::HashMap::new(),
|
||||
scheduled_tasks: std::collections::HashMap::new(),
|
||||
in_flight_prompt: None,
|
||||
current_prompt_id: None,
|
||||
created_via_new: false,
|
||||
}
|
||||
}
|
||||
pub(super) fn test_app_with_agent() -> AppView {
|
||||
let mut app = test_app();
|
||||
let id = AgentId(0);
|
||||
let session = make_test_agent_session(&app, id, "test-session");
|
||||
let mut agent = AgentView::new(session, ScrollbackState::new());
|
||||
agent.active_pane = ActivePane::Scrollback;
|
||||
app.agents.insert(id, agent);
|
||||
app.next_agent_id = 1;
|
||||
switch_to_agent(&mut app, id, SwitchCause::New);
|
||||
app
|
||||
}
|
||||
/// Give a test agent a generated title so the dashboard renders it.
|
||||
///
|
||||
/// The dashboard hides empty (no-real-turn) sessions
|
||||
/// (`views::dashboard::row::is_empty_top_level`); nav/render tests that
|
||||
/// rely on their placeholder agents being visible call this to opt in.
|
||||
fn mark_agent_nonempty(app: &mut AppView, id: AgentId) {
|
||||
if let Some(a) = app.agents.get_mut(&id) {
|
||||
a.generated_session_title = Some(format!("Session {}", id.0));
|
||||
}
|
||||
}
|
||||
/// Push a plain prompt directly onto the LOCAL drip-feed queue
|
||||
/// (`pending_prompts`), bypassing the server-authoritative
|
||||
/// immediate-send routing. Used by tests that exercise the local
|
||||
/// `maybe_drain_queue` / editing / `DrainQueue` machinery, which is still
|
||||
/// the path for image/skill/bash/editing prompts and idle drains.
|
||||
pub(super) fn enqueue_local(app: &mut AppView, id: AgentId, text: &str) {
|
||||
app.agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.session
|
||||
.enqueue_prompt(text.to_string());
|
||||
}
|
||||
fn make_test_subagent(child_sid: &str, sa_id: &str) -> crate::app::subagent::SubagentInfo {
|
||||
crate::app::subagent::SubagentInfo {
|
||||
subagent_id: Arc::from(sa_id),
|
||||
child_session_id: Arc::from(child_sid),
|
||||
description: Arc::from("test subagent"),
|
||||
subagent_type: Arc::from("general-purpose"),
|
||||
persona: None,
|
||||
role: None,
|
||||
model: None,
|
||||
context_source: None,
|
||||
resumed_from: None,
|
||||
capability_mode: None,
|
||||
context_normalized: false,
|
||||
parent_prompt_id: None,
|
||||
started_at: std::time::Instant::now(),
|
||||
last_progress_at: std::time::Instant::now(),
|
||||
finished: false,
|
||||
status: None,
|
||||
error: None,
|
||||
duration_ms: None,
|
||||
tool_calls: None,
|
||||
turns: None,
|
||||
turn_count: None,
|
||||
tool_call_count: None,
|
||||
tokens_used: None,
|
||||
context_window_tokens: None,
|
||||
context_usage_pct: None,
|
||||
tools_used: Vec::new(),
|
||||
error_count: None,
|
||||
activity_label: None,
|
||||
is_background: false,
|
||||
pending_kill: false,
|
||||
kill_requested_at: None,
|
||||
scrollback_entry_id: None,
|
||||
prompt: None,
|
||||
child_cwd: None,
|
||||
worktree_path: None,
|
||||
child_updates_replayed: false,
|
||||
}
|
||||
}
|
||||
fn arm_reconcile(
|
||||
app: &mut AppView,
|
||||
id: AgentId,
|
||||
prompt_id: &str,
|
||||
stop_reason: &str,
|
||||
age: std::time::Duration,
|
||||
) {
|
||||
arm_reconcile_with_trigger(app, id, prompt_id, stop_reason, None, age);
|
||||
}
|
||||
/// [`arm_reconcile`] with an explicit `_meta.cancelTrigger`.
|
||||
fn arm_reconcile_with_trigger(
|
||||
app: &mut AppView,
|
||||
id: AgentId,
|
||||
prompt_id: &str,
|
||||
stop_reason: &str,
|
||||
cancel_trigger: Option<&str>,
|
||||
age: std::time::Duration,
|
||||
) {
|
||||
app.agents.get_mut(&id).unwrap().pending_turn_end_reconcile =
|
||||
Some(crate::app::agent_view::PendingTurnEnd {
|
||||
prompt_id: prompt_id.into(),
|
||||
stop_reason: Some(stop_reason.into()),
|
||||
agent_result: None,
|
||||
cancel_trigger: cancel_trigger.map(str::to_string),
|
||||
received_at: std::time::Instant::now() - age,
|
||||
});
|
||||
}
|
||||
pub(super) fn end_turn() -> Action {
|
||||
Action::TaskComplete(TaskResult::PromptResponse {
|
||||
agent_id: AgentId(0),
|
||||
result: Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)),
|
||||
http_status: None,
|
||||
prompt_id: None,
|
||||
})
|
||||
}
|
||||
/// Plant a Build session under the process `kigi_home()` (OnceLock-cached;
|
||||
/// do not rely on setting `KIGI_SHARE_DIR` mid-process). Caller must remove `sess_dir`.
|
||||
fn plant_local_build_session(cwd: &std::path::Path, session_id: &str) -> std::path::PathBuf {
|
||||
let home = kigi_shell::util::kigi_home::kigi_home();
|
||||
let encoded = kigi_shell::util::kigi_home::encode_cwd_dirname(&cwd.to_string_lossy());
|
||||
let sess_dir = home.join("sessions").join(encoded).join(session_id);
|
||||
std::fs::create_dir_all(&sess_dir).expect("plant session dir");
|
||||
std::fs::write(sess_dir.join("summary.json"), b"{}").expect("plant summary");
|
||||
sess_dir
|
||||
}
|
||||
/// Extract the in-flight auth request sequence, panicking if the auth
|
||||
/// state is not `Authenticating`.
|
||||
fn authenticating_seq(app: &AppView) -> u64 {
|
||||
match app.auth_state {
|
||||
AuthState::Authenticating { request_seq, .. } => request_seq,
|
||||
ref other => panic!("expected Authenticating, got {other:?}"),
|
||||
}
|
||||
}
|
||||
/// Extract text from the last system message in an agent's scrollback.
|
||||
fn last_system_text(app: &AppView, id: AgentId) -> String {
|
||||
system_text_from_end(app, id, 0)
|
||||
}
|
||||
/// Like [`last_system_text`] but takes an offset from the end.
|
||||
/// `offset = 0` is the last entry, `offset = 1` is second-to-last, etc.
|
||||
fn system_text_from_end(app: &AppView, id: AgentId, offset: usize) -> String {
|
||||
let sb = &app.agents[&id].scrollback;
|
||||
let idx = sb.len() - 1 - offset;
|
||||
let entry = sb.get(idx).expect("scrollback index out of bounds");
|
||||
match &entry.block {
|
||||
RenderBlock::System(sys) => sys.text.clone(),
|
||||
other => panic!("expected System block at index {idx}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
/// Insert a placeholder agent at `id` so `switch_to_agent` recognises
|
||||
/// it (the helper's defensive check uses `app.agents.contains_key`).
|
||||
/// `session_id` and `active_pane` are populated to mirror the
|
||||
/// existing `test_app_with_agent` setup; these tests do not read
|
||||
/// either field.
|
||||
fn insert_placeholder_agent(app: &mut AppView, id: AgentId) {
|
||||
let mut agent = AgentView::new(
|
||||
AgentSession {
|
||||
id,
|
||||
acp_tx: app.acp_tx.clone(),
|
||||
session_id: Some("placeholder".into()),
|
||||
models: ModelState::default(),
|
||||
state: AgentState::Idle,
|
||||
tracker: AcpUpdateTracker::new(),
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
is_worktree: false,
|
||||
forked_from: None,
|
||||
pending_prompts: std::collections::VecDeque::new(),
|
||||
next_queue_id: 0,
|
||||
yolo_mode: false,
|
||||
auto_mode: false,
|
||||
prompt_history: Vec::new(),
|
||||
prompt_history_loading: false,
|
||||
loading_replay: false,
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
model_switch_pending: false,
|
||||
user_model_preference: None,
|
||||
deferred_model_switch: None,
|
||||
bg_tasks: std::collections::BTreeMap::new(),
|
||||
bg_tool_call_to_task: std::collections::HashMap::new(),
|
||||
scheduled_tasks: std::collections::HashMap::new(),
|
||||
in_flight_prompt: None,
|
||||
current_prompt_id: None,
|
||||
created_via_new: false,
|
||||
},
|
||||
ScrollbackState::new(),
|
||||
);
|
||||
agent.active_pane = ActivePane::Scrollback;
|
||||
app.agents.insert(id, agent);
|
||||
}
|
||||
/// Build an app with three agents (ids 0, 1, 2) and `active_view` set
|
||||
/// to agent 0.
|
||||
fn three_agent_app() -> AppView {
|
||||
let mut app = test_app_with_agent();
|
||||
insert_placeholder_agent(&mut app, AgentId(1));
|
||||
insert_placeholder_agent(&mut app, AgentId(2));
|
||||
app
|
||||
}
|
||||
use crate::slash::commands::fork::ForkArgs;
|
||||
fn fork_args(worktree_override: Option<bool>, directive: Option<&str>) -> ForkArgs {
|
||||
ForkArgs {
|
||||
worktree_override,
|
||||
directive: directive.map(String::from),
|
||||
}
|
||||
}
|
||||
/// Build a single-agent app for the `/fork` dispatcher tests.
|
||||
///
|
||||
/// Sets `current_branch` to `Some("main")` so the agent appears to be
|
||||
/// inside a git repo. This is required because `dispatch_fork` skips
|
||||
/// the worktree question when `current_branch` is `None` (non-git cwd).
|
||||
fn fork_test_app() -> AppView {
|
||||
let mut app = test_app_with_agent();
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().current_branch = Some("main".into());
|
||||
app
|
||||
}
|
||||
/// Build a minimal `AcpArgs<acp::ExtRequest>` for an
|
||||
/// `x.ai/ask_user_question` ext-method request. Returns the args
|
||||
/// plus the receiver half of the response oneshot so the test can
|
||||
/// assert the handler completes the ACP roundtrip.
|
||||
fn make_ask_user_question_args(
|
||||
tool_call_id: &str,
|
||||
) -> (
|
||||
kigi_acp_lib::AcpArgs<acp::ExtRequest>,
|
||||
tokio::sync::oneshot::Receiver<kigi_acp_lib::AcpResult<acp::ExtResponse>>,
|
||||
) {
|
||||
use kigi_tools::implementations::grok_build::ask_user_question::{
|
||||
AskUserQuestionExtRequest, Question, QuestionOption,
|
||||
};
|
||||
let req = AskUserQuestionExtRequest {
|
||||
session_id: "test-session".into(),
|
||||
tool_call_id: tool_call_id.into(),
|
||||
mode:
|
||||
kigi_tools::implementations::grok_build::ask_user_question::AskUserQuestionMode::Default,
|
||||
questions: vec![Question {
|
||||
question: "ACP-driven question".into(),
|
||||
options: vec![QuestionOption {
|
||||
label: "ok".into(),
|
||||
description: "ok".into(),
|
||||
preview: None,
|
||||
id: None,
|
||||
}],
|
||||
multi_select: Some(false),
|
||||
id: None,
|
||||
}],
|
||||
};
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let ext = acp::ExtRequest::new(
|
||||
"x.ai/ask_user_question",
|
||||
serde_json::value::to_raw_value(&req)
|
||||
.expect("serialize AskUserQuestionExtRequest")
|
||||
.into(),
|
||||
);
|
||||
(
|
||||
kigi_acp_lib::AcpArgs {
|
||||
request: ext,
|
||||
response_tx: tx,
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
fn set_forked_from(app: &mut AppView, child: AgentId, parent: AgentId) {
|
||||
if let Some(agent) = app.agents.get_mut(&child) {
|
||||
agent.session.forked_from = Some(parent);
|
||||
}
|
||||
}
|
||||
fn make_bg_task(task_id: &str) -> crate::app::agent::BgTaskState {
|
||||
crate::app::agent::BgTaskState {
|
||||
task_id: task_id.into(),
|
||||
tool_call_id: String::new(),
|
||||
command: "sleep 99".into(),
|
||||
description: None,
|
||||
cwd: String::new(),
|
||||
output_file: String::new(),
|
||||
status: crate::app::agent::BgTaskStatus::Running,
|
||||
start_time: std::time::SystemTime::now(),
|
||||
end_time: None,
|
||||
exit_code: None,
|
||||
signal: None,
|
||||
stdout: String::new(),
|
||||
stdout_line_count: 0,
|
||||
truncated: false,
|
||||
pending_kill: false,
|
||||
kill_requested_at: None,
|
||||
scrollback_entry_id: None,
|
||||
is_monitor: false,
|
||||
restored_from_replay: false,
|
||||
}
|
||||
}
|
||||
/// Set up a two-agent app: agent 0 is active with "sess-A",
|
||||
/// agent 1 is inactive with "sess-B" and a bg task.
|
||||
fn two_agent_app_with_bg_task() -> AppView {
|
||||
let mut app = test_app_with_agent();
|
||||
app.agents[&AgentId(0)].session.session_id = Some(acp::SessionId::new("sess-A"));
|
||||
let id1 = AgentId(1);
|
||||
let mut agent1 = AgentView::new(
|
||||
AgentSession {
|
||||
id: id1,
|
||||
acp_tx: app.acp_tx.clone(),
|
||||
session_id: Some(acp::SessionId::new("sess-B")),
|
||||
models: ModelState::default(),
|
||||
state: AgentState::Idle,
|
||||
tracker: AcpUpdateTracker::new(),
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
is_worktree: false,
|
||||
forked_from: None,
|
||||
pending_prompts: std::collections::VecDeque::new(),
|
||||
next_queue_id: 0,
|
||||
yolo_mode: false,
|
||||
auto_mode: false,
|
||||
prompt_history: Vec::new(),
|
||||
prompt_history_loading: false,
|
||||
loading_replay: false,
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
model_switch_pending: false,
|
||||
user_model_preference: None,
|
||||
deferred_model_switch: None,
|
||||
bg_tasks: std::collections::BTreeMap::new(),
|
||||
bg_tool_call_to_task: std::collections::HashMap::new(),
|
||||
scheduled_tasks: std::collections::HashMap::new(),
|
||||
in_flight_prompt: None,
|
||||
current_prompt_id: None,
|
||||
created_via_new: false,
|
||||
},
|
||||
ScrollbackState::new(),
|
||||
);
|
||||
let mut task = make_bg_task("task-B-1");
|
||||
task.pending_kill = true;
|
||||
task.kill_requested_at = Some(std::time::Instant::now());
|
||||
agent1.session.bg_tasks.insert("task-B-1".into(), task);
|
||||
app.agents.insert(id1, agent1);
|
||||
app.next_agent_id = 2;
|
||||
assert!(matches!(app.active_view, ActiveView::Agent(AgentId(0))));
|
||||
app
|
||||
}
|
||||
fn project_picker_app() -> AppView {
|
||||
let mut app = test_app();
|
||||
app.cwd = PathBuf::from("/tmp");
|
||||
app.project_picker_shown = false;
|
||||
app
|
||||
}
|
||||
/// Test helper: open Settings then OpenResetConfirm for `key`.
|
||||
/// Extracted so individual tests don't have to repeat the
|
||||
/// open-then-open ritual.
|
||||
fn setup_reset_confirm_open(app: &mut AppView, key: crate::settings::SettingKey) {
|
||||
use crate::views::modal::ActiveModal;
|
||||
let _ = dispatch(Action::OpenSettings, app);
|
||||
let _ = dispatch(Action::OpenResetConfirm { key }, app);
|
||||
let agent = app.agents.get(&AgentId(0)).expect("agent must exist");
|
||||
assert!(
|
||||
matches!(
|
||||
agent.active_modal,
|
||||
Some(ActiveModal::ResetSettingsConfirm { .. })
|
||||
),
|
||||
"setup_reset_confirm_open: ResetSettingsConfirm must be active",
|
||||
);
|
||||
}
|
||||
fn make_picker_entry(id: &str, cwd: &str) -> crate::app::app_view::SessionPickerEntry {
|
||||
crate::app::app_view::SessionPickerEntry {
|
||||
id: id.into(),
|
||||
summary: id.into(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
created_at: chrono::Utc::now(),
|
||||
cwd: cwd.into(),
|
||||
hostname: None,
|
||||
source: "local".into(),
|
||||
model_id: None,
|
||||
num_messages: 0,
|
||||
last_active_at: None,
|
||||
branch: None,
|
||||
repo_name: "repo".into(),
|
||||
worktree_label: None,
|
||||
card_detail: None,
|
||||
}
|
||||
}
|
||||
fn make_conversation_entry(id: &str) -> crate::app::app_view::SessionPickerEntry {
|
||||
let mut e = make_picker_entry(id, "");
|
||||
e.source = "conversation".into();
|
||||
e
|
||||
}
|
||||
/// Open a SessionPicker modal on the active agent seeded with `entries`.
|
||||
fn open_session_picker_with(
|
||||
app: &mut AppView,
|
||||
entries: Vec<crate::app::app_view::SessionPickerEntry>,
|
||||
) {
|
||||
use crate::views::modal::ActiveModal;
|
||||
let agent = get_active_agent_mut(app).expect("active agent");
|
||||
agent.active_modal = Some(ActiveModal::SessionPicker {
|
||||
state: crate::views::picker::PickerState::default(),
|
||||
entries: Some(entries),
|
||||
loading: false,
|
||||
lanes: Default::default(),
|
||||
previous_palette: None,
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
content_results: None,
|
||||
content_loading: false,
|
||||
deep_search_seq: 0,
|
||||
entries_query: None,
|
||||
source_filter: crate::views::session_picker::SourceFilter::default(),
|
||||
pending_delete: None,
|
||||
});
|
||||
}
|
||||
/// Toast strings match the expected format and contain on/off
|
||||
/// status.
|
||||
fn read_toast(app: &AppView) -> String {
|
||||
let agent = app.agents.get(&AgentId(0)).expect("agent must exist");
|
||||
agent
|
||||
.toast
|
||||
.as_ref()
|
||||
.map(|(s, _)| s.clone())
|
||||
.expect("toast should be set")
|
||||
}
|
||||
/// Helper: enqueue a single permission containing the new
|
||||
/// "enable-always-approve" option (AllowOnce kind, position 0 —
|
||||
/// default-selected by the real `enqueue_permission` helper),
|
||||
/// a regular "opt-allow-once" (AllowOnce kind, position 1), and
|
||||
/// a "opt-reject-once" (RejectOnce, position 2). Mirrors the
|
||||
/// option list the shell builds for TUI/Pager/Desktop.
|
||||
/// Returns the response receiver for the injected permission.
|
||||
fn enqueue_permission_with_enable_always_approve(
|
||||
app: &mut AppView,
|
||||
) -> tokio::sync::oneshot::Receiver<acp::Result<acp::RequestPermissionResponse>> {
|
||||
use crate::views::permission_view::{PermissionFocus, PermissionViewState};
|
||||
use std::sync::Arc;
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
||||
let request = acp::RequestPermissionRequest::new(
|
||||
acp::SessionId::new(Arc::from("test-sess")),
|
||||
acp::ToolCallUpdate::new(
|
||||
acp::ToolCallId::new(Arc::from("tc-enable-aa-1")),
|
||||
acp::ToolCallUpdateFields::default(),
|
||||
),
|
||||
vec![
|
||||
acp::PermissionOption::new(
|
||||
acp::PermissionOptionId::new(Arc::from(
|
||||
kigi_workspace::permission::ENABLE_ALWAYS_APPROVE_OPTION_ID,
|
||||
)),
|
||||
"Yes, and don't ask again for anything",
|
||||
acp::PermissionOptionKind::AllowOnce,
|
||||
),
|
||||
acp::PermissionOption::new(
|
||||
acp::PermissionOptionId::new(Arc::from("opt-allow-once")),
|
||||
"Yes, proceed",
|
||||
acp::PermissionOptionKind::AllowOnce,
|
||||
),
|
||||
acp::PermissionOption::new(
|
||||
acp::PermissionOptionId::new(Arc::from("opt-reject-once")),
|
||||
"No",
|
||||
acp::PermissionOptionKind::RejectOnce,
|
||||
),
|
||||
],
|
||||
);
|
||||
let options = request.options.clone();
|
||||
agent.permission_queue.push_back(PermissionViewState {
|
||||
request: kigi_acp_lib::AcpArgs {
|
||||
request,
|
||||
response_tx,
|
||||
},
|
||||
id: 1,
|
||||
focus: PermissionFocus::Options,
|
||||
options,
|
||||
active_idx: 0,
|
||||
bash_highlights: None,
|
||||
bash_selection_count: 0,
|
||||
bash_command_raw: None,
|
||||
mcp_scope: None,
|
||||
title: "test-enable-always-approve".to_string(),
|
||||
description: vec![],
|
||||
args_expanded: false,
|
||||
desc_scroll: 0,
|
||||
subagent_label: None,
|
||||
options_area_height: 0,
|
||||
options_scroll_offset: 0,
|
||||
});
|
||||
response_rx
|
||||
}
|
||||
const POLICY_WARNING: &str = kigi_workspace::permission::resolution::YOLO_PIN_REASON_REQUIREMENTS;
|
||||
fn agent_toast(app: &AppView) -> Option<String> {
|
||||
app.agents[&AgentId(0)]
|
||||
.toast
|
||||
.as_ref()
|
||||
.map(|(s, _)| s.clone())
|
||||
}
|
||||
/// Use the `theme_cache::test_lock` to serialize tests that touch
|
||||
/// the in-memory theme state (single mutable global). Mirrors the
|
||||
/// pattern used by `theme::cache::tests`.
|
||||
fn with_theme_test_env(f: impl FnOnce()) {
|
||||
let _guard = crate::theme::cache::test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
crate::theme::cache::reset_for_test();
|
||||
crate::theme::cache::seed_auto_theme_defaults_for_test();
|
||||
crate::theme::cache::set(crate::theme::ThemeKind::GrokNight);
|
||||
crate::theme::system_appearance::clear_mock();
|
||||
f();
|
||||
crate::theme::system_appearance::clear_mock();
|
||||
crate::theme::cache::reset_for_test();
|
||||
}
|
||||
fn agent_scrollback_len(app: &AppView) -> usize {
|
||||
app.agents.get(&AgentId(0)).unwrap().scrollback.len()
|
||||
}
|
||||
use crate::scrollback::blocks::UserPromptBlock;
|
||||
/// Helper: open the dashboard against an existing `app`.
|
||||
fn open_dashboard(app: &mut AppView) {
|
||||
let _ = dispatch_open_dashboard(app);
|
||||
}
|
||||
/// Display-order list of selectable row ids — the same order
|
||||
/// `dashboard_neighbor_row` and the renderer walk. Test-only mirror
|
||||
/// of the row build in `dispatch_dashboard_select`.
|
||||
fn dashboard_row_order(app: &AppView) -> Vec<crate::views::dashboard::DashboardRowId> {
|
||||
let d = app.dashboard.as_ref().unwrap();
|
||||
let home = crate::views::dashboard::render::cached_home();
|
||||
let roster: &[crate::app::roster::RosterEntry] = if app.leader_mode {
|
||||
&app.leader_roster
|
||||
} else {
|
||||
&app.dashboard_local_sessions
|
||||
};
|
||||
let rows = crate::views::dashboard::build_rows_with_roster(
|
||||
&app.agents,
|
||||
&d.pinned,
|
||||
&d.reorder,
|
||||
None,
|
||||
d.grouping,
|
||||
&d.filter,
|
||||
home,
|
||||
roster,
|
||||
);
|
||||
crate::views::dashboard::render::focusables(
|
||||
&rows,
|
||||
d.grouping,
|
||||
&d.filter,
|
||||
&d.collapsed_sections,
|
||||
d.idle_show_all,
|
||||
d.search_mode,
|
||||
)
|
||||
.into_iter()
|
||||
.filter_map(|f| match f {
|
||||
crate::views::dashboard::Focusable::Row(id) => Some(id),
|
||||
crate::views::dashboard::Focusable::Section(_)
|
||||
| crate::views::dashboard::Focusable::IdleOverflow => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Build a synthetic `PermissionViewState` with the given id and
|
||||
/// options. Pushes it to the agent's permission_queue.
|
||||
///
|
||||
/// Returns the response receiver so tests can verify
|
||||
/// the response was actually `send`'d through the oneshot. The
|
||||
/// previous version dropped the receiver (`_rx`), which let
|
||||
/// "happy-path" tests assert the queue was popped but masked
|
||||
/// regressions where the pop happened without the corresponding
|
||||
/// send.
|
||||
fn push_synthetic_permission(
|
||||
agent: &mut crate::app::agent_view::AgentView,
|
||||
id: usize,
|
||||
options: Vec<(&str, &str)>,
|
||||
) -> tokio::sync::oneshot::Receiver<Result<acp::RequestPermissionResponse, acp::Error>> {
|
||||
use crate::views::permission_view::{PermissionFocus, PermissionViewState};
|
||||
let (tx, rx) =
|
||||
tokio::sync::oneshot::channel::<Result<acp::RequestPermissionResponse, acp::Error>>();
|
||||
let request = kigi_acp_lib::AcpArgs {
|
||||
request: acp::RequestPermissionRequest::new(
|
||||
acp::SessionId::new(std::sync::Arc::from("sess-1")),
|
||||
acp::ToolCallUpdate::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("tc-1")),
|
||||
acp::ToolCallUpdateFields::default(),
|
||||
),
|
||||
options
|
||||
.iter()
|
||||
.map(|(oid, name)| {
|
||||
acp::PermissionOption::new(
|
||||
acp::PermissionOptionId::new(std::sync::Arc::from(*oid)),
|
||||
name.to_string(),
|
||||
if *oid == "reject" {
|
||||
acp::PermissionOptionKind::RejectOnce
|
||||
} else {
|
||||
acp::PermissionOptionKind::AllowOnce
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
response_tx: tx,
|
||||
};
|
||||
let opts = request.request.options.clone();
|
||||
let state = PermissionViewState {
|
||||
request,
|
||||
id,
|
||||
focus: PermissionFocus::Options,
|
||||
options: opts,
|
||||
active_idx: 0,
|
||||
bash_highlights: None,
|
||||
bash_selection_count: 0,
|
||||
bash_command_raw: None,
|
||||
mcp_scope: None,
|
||||
title: "Test permission".to_string(),
|
||||
description: Vec::new(),
|
||||
args_expanded: false,
|
||||
desc_scroll: 0,
|
||||
subagent_label: None,
|
||||
options_area_height: 0,
|
||||
options_scroll_offset: 0,
|
||||
};
|
||||
agent.permission_queue.push_back(state);
|
||||
rx
|
||||
}
|
||||
const MOUSE_OFF_STICKY: &str = crate::app::MOUSE_OFF_HINT_SCROLLBACK;
|
||||
fn reset_mouse_capture_enabled(on: bool) {
|
||||
crate::app::MOUSE_CAPTURE_ENABLED.store(on, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
fn mouse_capture_is_enabled() -> bool {
|
||||
crate::app::MOUSE_CAPTURE_ENABLED.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
/// Build a minimal `CreditBalance` for billing dispatch tests.
|
||||
fn test_bal(usage_pct: f64) -> crate::views::credit_bar::CreditBalance {
|
||||
crate::views::credit_bar::CreditBalance {
|
||||
usage_pct,
|
||||
effective_usage_pct: usage_pct,
|
||||
period_end_display: None,
|
||||
pay_as_you_go: false,
|
||||
on_demand_cap_cents: None,
|
||||
on_demand_used_cents: None,
|
||||
prepaid_balance_cents: None,
|
||||
period_type: None,
|
||||
is_unified_billing_user: None,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
//! Tests for feedback / remember / btw / recap dispatchers.
|
||||
|
||||
use super::*;
|
||||
use crate::app::dispatch::{recap_unavailable_toast, scrollback_has_user_messages};
|
||||
|
||||
#[test]
|
||||
fn recap_unavailable_toast_empty_vs_with_messages() {
|
||||
assert_eq!(recap_unavailable_toast(false), "No messages yet");
|
||||
assert_eq!(recap_unavailable_toast(true), "Couldn't generate recap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_recap_with_no_messages_toasts_empty_state_and_skips_request() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.session_recap_available = true;
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent.prompt.set_text("/recap");
|
||||
assert!(!scrollback_has_user_messages(&agent.scrollback));
|
||||
}
|
||||
|
||||
let effects = dispatch(Action::SendRecap { auto: false }, &mut app);
|
||||
|
||||
assert!(
|
||||
effects.is_empty(),
|
||||
"empty session must not fire x.ai/recap: {effects:?}"
|
||||
);
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(agent.pending_recap_entry.is_none(), "no loading spinner");
|
||||
assert_eq!(
|
||||
agent.toast.as_ref().map(|(s, _)| s.as_str()),
|
||||
Some("No messages yet"),
|
||||
"empty session should say No messages yet, not Couldn't generate recap"
|
||||
);
|
||||
assert_eq!(agent.prompt.text(), "", "slash command text is cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_recap_with_messages_requests_and_shows_spinner() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.session_recap_available = true;
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::user_prompt("hello"));
|
||||
assert!(scrollback_has_user_messages(&agent.scrollback));
|
||||
}
|
||||
|
||||
let effects = dispatch(Action::SendRecap { auto: false }, &mut app);
|
||||
|
||||
assert!(
|
||||
matches!(effects.as_slice(), [Effect::SendRecap { auto: false, .. }]),
|
||||
"expected SendRecap effect, got {effects:?}"
|
||||
);
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(
|
||||
agent.pending_recap_entry.is_some(),
|
||||
"manual recap shows a loading spinner when there is something to summarize"
|
||||
);
|
||||
assert!(agent.toast.is_none());
|
||||
}
|
||||
|
||||
/// Regression: during session/load, scrollback is batched so
|
||||
/// `turn_count()` stays 0 until `end_batch`, but UserPrompt entries may already
|
||||
/// be present. Manual `/recap` must still request a recap.
|
||||
#[test]
|
||||
fn manual_recap_during_batch_load_with_prompts_still_requests() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.session_recap_available = true;
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent.scrollback.begin_batch();
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::user_prompt("hello from resume"));
|
||||
// Batched push defers rebuild_turns — turn index is stale, entries aren't.
|
||||
assert_eq!(agent.scrollback.turn_count(), 0);
|
||||
assert!(scrollback_has_user_messages(&agent.scrollback));
|
||||
}
|
||||
|
||||
let effects = dispatch(Action::SendRecap { auto: false }, &mut app);
|
||||
|
||||
assert!(
|
||||
matches!(effects.as_slice(), [Effect::SendRecap { auto: false, .. }]),
|
||||
"batched resume with user prompts must still fire x.ai/recap: {effects:?}"
|
||||
);
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(agent.pending_recap_entry.is_some());
|
||||
assert!(agent.toast.is_none());
|
||||
// Clean up batch for the test fixture (not required for the assertion).
|
||||
app.agents.get_mut(&id).unwrap().scrollback.end_batch();
|
||||
}
|
||||
|
||||
/// While session replay is still streaming, don't claim "No messages yet" even
|
||||
/// if scrollback looks empty — history may arrive on the next notification.
|
||||
#[test]
|
||||
fn manual_recap_while_loading_replay_still_requests() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.session_recap_available = true;
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent.session.loading_replay = true;
|
||||
assert!(!scrollback_has_user_messages(&agent.scrollback));
|
||||
}
|
||||
|
||||
let effects = dispatch(Action::SendRecap { auto: false }, &mut app);
|
||||
|
||||
assert!(
|
||||
matches!(effects.as_slice(), [Effect::SendRecap { auto: false, .. }]),
|
||||
"loading_replay must not short-circuit to No messages yet: {effects:?}"
|
||||
);
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(agent.pending_recap_entry.is_some());
|
||||
assert!(agent.toast.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recap_request_transport_failure_with_no_turns_uses_empty_toast() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let session_id = app.agents[&id].session.session_id.clone().unwrap();
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
let spinner = agent
|
||||
.scrollback
|
||||
.push(crate::scrollback::entry::ScrollbackEntry::running(
|
||||
RenderBlock::session_event(SessionEvent::Recap {
|
||||
summary: String::new(),
|
||||
auto: false,
|
||||
}),
|
||||
));
|
||||
agent.pending_recap_entry = Some(spinner);
|
||||
assert!(!scrollback_has_user_messages(&agent.scrollback));
|
||||
}
|
||||
|
||||
dispatch(
|
||||
Action::TaskComplete(TaskResult::RecapRequested {
|
||||
session_id,
|
||||
auto: false,
|
||||
error: Some("transport down".into()),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(agent.pending_recap_entry.is_none());
|
||||
assert_eq!(
|
||||
agent.toast.as_ref().map(|(s, _)| s.as_str()),
|
||||
Some("No messages yet")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recap_request_transport_failure_with_turns_uses_generic_toast() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let session_id = app.agents[&id].session.session_id.clone().unwrap();
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::user_prompt("hello"));
|
||||
let spinner = agent
|
||||
.scrollback
|
||||
.push(crate::scrollback::entry::ScrollbackEntry::running(
|
||||
RenderBlock::session_event(SessionEvent::Recap {
|
||||
summary: String::new(),
|
||||
auto: false,
|
||||
}),
|
||||
));
|
||||
agent.pending_recap_entry = Some(spinner);
|
||||
assert!(scrollback_has_user_messages(&agent.scrollback));
|
||||
}
|
||||
|
||||
dispatch(
|
||||
Action::TaskComplete(TaskResult::RecapRequested {
|
||||
session_id,
|
||||
auto: false,
|
||||
error: Some("transport down".into()),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(agent.pending_recap_entry.is_none());
|
||||
assert_eq!(
|
||||
agent.toast.as_ref().map(|(s, _)| s.as_str()),
|
||||
Some("Couldn't generate recap")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
//! Tests for permission request selection, follow-ups, and queue draining.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `ConfirmResetSetting
|
||||
/// { Reset }` on `permission_mode` (the security-critical SHELL Enum)
|
||||
/// dispatches `Action::SetPermissionMode(PermissionModeKind::Ask)`
|
||||
/// (the typed Action, per the modal-commit ↔ typed-setter
|
||||
/// rule) via recursive dispatch. Emits
|
||||
/// `Effect::PersistPermissionMode` — verifies the recursive
|
||||
/// dispatch reaches the YOLO pipeline through
|
||||
/// `set_permission_mode` rather than the legacy `set_yolo_mode`.
|
||||
#[test]
|
||||
fn dispatch_confirm_reset_setting_reset_dispatches_set_permission_mode_for_permission_mode() {
|
||||
use crate::views::modal::ResetSettingsResult;
|
||||
let mut app = test_app_with_agent();
|
||||
// Flip yolo on first (default is OFF = "ask").
|
||||
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
||||
assert!(app.agents[&AgentId(0)].session.is_yolo());
|
||||
|
||||
setup_reset_confirm_open(&mut app, "permission_mode");
|
||||
|
||||
let effects = dispatch(
|
||||
Action::ConfirmResetSetting {
|
||||
choice: ResetSettingsResult::Reset,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Recursive dispatch into Action::SetYoloMode(false) emits a
|
||||
// PersistPermissionMode effect.
|
||||
let has_persist = effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::PersistPermissionMode { .. }));
|
||||
assert!(
|
||||
has_persist,
|
||||
"Reset of permission_mode must emit PersistPermissionMode, got {effects:?}",
|
||||
);
|
||||
// Agent's yolo flag is reset to default (off).
|
||||
assert!(
|
||||
!app.agents[&AgentId(0)].session.is_yolo(),
|
||||
"agent.session.yolo_mode must be reset to default (off)",
|
||||
);
|
||||
}
|
||||
|
||||
/// **Security-critical:** YOLO ON must drain the per-agent
|
||||
/// `permission_queue` with `AllowOnce` responses. If this drain
|
||||
/// path regresses (e.g., the setter falls back to `Cancelled`
|
||||
/// without an `AllowOnce` lookup), the user enables YOLO and
|
||||
/// their queued permissions silently get rejected.
|
||||
#[test]
|
||||
fn set_yolo_mode_on_drains_permission_queue_with_allow_once() {
|
||||
use crate::views::permission_view::{PermissionFocus, PermissionViewState};
|
||||
use std::sync::Arc;
|
||||
|
||||
let mut app = test_app_with_agent();
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
|
||||
// Inject a fake queued permission. The drain semantics use
|
||||
// `find(|o| o.kind == AllowOnce)` so we need ≥1 AllowOnce
|
||||
// option for the test to exercise the happy path.
|
||||
let (response_tx, mut response_rx) = tokio::sync::oneshot::channel();
|
||||
let request = acp::RequestPermissionRequest::new(
|
||||
acp::SessionId::new(Arc::from("test-sess")),
|
||||
acp::ToolCallUpdate::new(
|
||||
acp::ToolCallId::new(Arc::from("tc-1")),
|
||||
acp::ToolCallUpdateFields::default(),
|
||||
),
|
||||
vec![
|
||||
acp::PermissionOption::new(
|
||||
acp::PermissionOptionId::new(Arc::from("opt-allow-once")),
|
||||
"Allow once",
|
||||
acp::PermissionOptionKind::AllowOnce,
|
||||
),
|
||||
acp::PermissionOption::new(
|
||||
acp::PermissionOptionId::new(Arc::from("opt-reject")),
|
||||
"Reject",
|
||||
acp::PermissionOptionKind::RejectOnce,
|
||||
),
|
||||
],
|
||||
);
|
||||
let options = request.options.clone();
|
||||
agent.permission_queue.push_back(PermissionViewState {
|
||||
request: kigi_acp_lib::AcpArgs {
|
||||
request,
|
||||
response_tx,
|
||||
},
|
||||
id: 1,
|
||||
focus: PermissionFocus::Options,
|
||||
options,
|
||||
active_idx: 0,
|
||||
bash_highlights: None,
|
||||
bash_selection_count: 0,
|
||||
bash_command_raw: None,
|
||||
mcp_scope: None,
|
||||
title: "test".to_string(),
|
||||
description: vec![],
|
||||
args_expanded: false,
|
||||
desc_scroll: 0,
|
||||
subagent_label: None,
|
||||
options_area_height: 0,
|
||||
options_scroll_offset: 0,
|
||||
});
|
||||
assert_eq!(agent.permission_queue.len(), 1);
|
||||
|
||||
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
||||
|
||||
// Queue is drained.
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].permission_queue.is_empty(),
|
||||
"YOLO ON must drain the permission_queue",
|
||||
);
|
||||
// Verify the `AllowOnce` response was actually sent (NOT
|
||||
// `Cancelled`). The drain semantics use `find(|o| o.kind ==
|
||||
// AllowOnce)` — a regression to `Cancelled` here would
|
||||
// silently reject every queued permission when the user
|
||||
// enables YOLO, which is the exact security failure mode
|
||||
// this test prevents.
|
||||
match response_rx.try_recv() {
|
||||
Ok(Ok(acp::RequestPermissionResponse {
|
||||
outcome:
|
||||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome {
|
||||
option_id,
|
||||
..
|
||||
}),
|
||||
..
|
||||
})) => {
|
||||
assert_eq!(
|
||||
option_id,
|
||||
acp::PermissionOptionId::new(Arc::from("opt-allow-once")),
|
||||
"the drain must select the AllowOnce option (NOT Cancelled / RejectOnce)",
|
||||
);
|
||||
}
|
||||
other => panic!(
|
||||
"queue drain must send an `AllowOnce` Selected response, got {other:?} — \
|
||||
security regression: queued permissions are NOT being auto-approved on YOLO ON",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_select_clears_double_click_tracker_for_next_prompt() {
|
||||
use crate::views::permission_view::PermissionFocus;
|
||||
use std::sync::Arc;
|
||||
|
||||
let mut app = test_app_with_agent();
|
||||
let _rx_front = enqueue_permission_with_enable_always_approve(&mut app);
|
||||
let _rx_next = enqueue_permission_with_enable_always_approve(&mut app);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.permission_queue.get_mut(1).unwrap().focus = PermissionFocus::FollowupInput;
|
||||
agent.last_permission_click = Some((Instant::now(), 1));
|
||||
|
||||
let _ = dispatch(
|
||||
Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from("opt-allow-once"))),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = &app.agents[&AgentId(0)];
|
||||
assert_eq!(agent.permission_queue.len(), 1);
|
||||
assert!(
|
||||
agent.last_permission_click.is_none(),
|
||||
"armed click on the resolved prompt must not pair with a click on the next prompt"
|
||||
);
|
||||
assert_eq!(
|
||||
agent.permission_queue.front().unwrap().focus,
|
||||
PermissionFocus::Options,
|
||||
"next front must be reset to Options"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_permission_queue_clears_double_click_tracker() {
|
||||
let mut app = test_app_with_agent();
|
||||
let _rx = enqueue_permission_with_enable_always_approve(&mut app);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.last_permission_click = Some((Instant::now(), 1));
|
||||
|
||||
drain_permission_queue(agent);
|
||||
|
||||
assert!(agent.permission_queue.is_empty());
|
||||
assert!(
|
||||
agent.last_permission_click.is_none(),
|
||||
"turn-end/turn-cancel drain must invalidate the armed click"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_permission_mode_always_approve_blocked_by_policy_pin() {
|
||||
use crate::app::actions::PermissionModeKind;
|
||||
use crate::views::modal::ActiveModal;
|
||||
let mut app = test_app_with_agent();
|
||||
app.yolo_policy_block = Some(POLICY_WARNING);
|
||||
// Open the settings modal so the blocked path's snapshot refresh is
|
||||
// exercised: the modal must keep showing the live (non-yolo) value.
|
||||
let _ = dispatch(Action::OpenSettings, &mut app);
|
||||
|
||||
let effects = dispatch(
|
||||
Action::SetPermissionMode(PermissionModeKind::AlwaysApprove),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(
|
||||
effects.is_empty(),
|
||||
"blocked modal commit must not persist, got {effects:?}",
|
||||
);
|
||||
assert!(!app.agents[&AgentId(0)].session.is_yolo());
|
||||
assert_eq!(
|
||||
app.current_ui.permission_mode, None,
|
||||
"canonical mirror must stay untouched"
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
|
||||
panic!("Settings modal must remain open across the blocked dispatch")
|
||||
};
|
||||
assert!(
|
||||
!state.pager_snapshot.yolo_mode,
|
||||
"modal snapshot must show the live (non-yolo) value after the block",
|
||||
);
|
||||
assert_ne!(
|
||||
state.ui_snapshot.permission_mode.as_deref(),
|
||||
Some("always-approve"),
|
||||
"modal canonical must not show the refused mode",
|
||||
);
|
||||
assert_eq!(agent_toast(&app).as_deref(), Some(POLICY_WARNING));
|
||||
|
||||
// Non-yolo kinds still commit under the pin.
|
||||
let effects = dispatch(Action::SetPermissionMode(PermissionModeKind::Ask), &mut app);
|
||||
assert_eq!(effects.len(), 1, "Ask must persist under the pin");
|
||||
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
|
||||
}
|
||||
|
||||
/// SetPermissionMode(Auto) persists auto and does not enable yolo.
|
||||
#[test]
|
||||
fn set_permission_mode_auto_persists_without_yolo() {
|
||||
use crate::app::actions::PermissionModeKind;
|
||||
let mut app = test_app_with_agent();
|
||||
let effects = dispatch(
|
||||
Action::SetPermissionMode(PermissionModeKind::Auto),
|
||||
&mut app,
|
||||
);
|
||||
assert!(!app.agents[&AgentId(0)].session.is_yolo());
|
||||
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("auto"));
|
||||
assert!(
|
||||
effects.iter().any(|e| matches!(
|
||||
e,
|
||||
Effect::PersistPermissionMode {
|
||||
canonical: "auto",
|
||||
..
|
||||
}
|
||||
)),
|
||||
"expected PersistPermissionMode(auto), got {effects:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Feature gate OFF: a SetPermissionMode(Auto) commit (e.g. from the
|
||||
/// settings modal) degrades to Ask — same `app.auto_mode_gate` source the
|
||||
/// Shift+Tab cycle uses, so the two never disagree.
|
||||
#[test]
|
||||
fn set_permission_mode_auto_degrades_to_ask_when_gated_off() {
|
||||
use crate::app::actions::PermissionModeKind;
|
||||
let mut app = test_app_with_agent();
|
||||
app.auto_mode_gate = false;
|
||||
let effects = dispatch(
|
||||
Action::SetPermissionMode(PermissionModeKind::Auto),
|
||||
&mut app,
|
||||
);
|
||||
assert_eq!(
|
||||
app.current_ui.permission_mode.as_deref(),
|
||||
Some("ask"),
|
||||
"gate OFF: Auto commit must land on Ask, not auto"
|
||||
);
|
||||
assert!(
|
||||
!effects.iter().any(|e| matches!(
|
||||
e,
|
||||
Effect::PersistPermissionMode {
|
||||
canonical: "auto",
|
||||
..
|
||||
}
|
||||
)),
|
||||
"gate OFF: must not persist 'auto', got {effects:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Rollback with an unknown canonical: defensively defaults to
|
||||
/// "ask" (the safe fallback — fewer prompts on a corrupt
|
||||
/// rollback value is worse, more prompts is safer).
|
||||
///
|
||||
/// The previous docstring claimed "logs a
|
||||
/// warning and defaults to 'ask'" — the warning log is fired via
|
||||
/// `tracing::warn!` in `apply_setting_rollback`'s arm, but the
|
||||
/// test doesn't capture/assert it. The fix is documentary: the
|
||||
/// test pins the OBSERVABLE behaviour (state defaults to "ask")
|
||||
/// and acknowledges that the warn-log is best-effort visibility
|
||||
/// for developers, not a contract surface the test enforces.
|
||||
/// `tracing_test::traced_test` capture would be more rigorous
|
||||
/// but is not currently used in this crate.
|
||||
#[test]
|
||||
fn rollback_permission_mode_unknown_canonical_defaults_to_ask() {
|
||||
use crate::settings::SettingValue;
|
||||
let mut app = test_app_with_agent();
|
||||
// Pre-set to true.
|
||||
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
||||
|
||||
// Garbage canonical rolls back to "ask" (the safe default).
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SettingPersistFailed {
|
||||
key: "permission_mode",
|
||||
rollback_value: SettingValue::Enum("garbage-value"),
|
||||
error: "test-error".into(),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(
|
||||
!app.agents[&AgentId(0)].session.is_yolo(),
|
||||
"unknown canonical → safe default (ask = no auto-approve)",
|
||||
);
|
||||
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
|
||||
// The failure toast is the standard
|
||||
// `✗ Could not save permission_mode: …` format. A future
|
||||
// enhancement could differentiate "schema corruption" from
|
||||
// "real disk failure" in the toast text, but currently the
|
||||
// user sees the same wording; pinned here so a future
|
||||
// divergence is intentional.
|
||||
}
|
||||
|
||||
/// Rollback path refreshes open modal
|
||||
/// snapshots in the same way the success path does. Mirror of
|
||||
/// `set_yolo_mode_refreshes_open_modal_snapshots` for the
|
||||
/// `apply_setting_rollback` entry into `set_yolo_mode_inner`.
|
||||
/// Without this, a modal that's open when a disk write fails
|
||||
/// shows a stale "always-approve" indicator after the state
|
||||
/// has rolled back to "ask".
|
||||
#[test]
|
||||
fn rollback_permission_mode_refreshes_open_modal_snapshots() {
|
||||
use crate::settings::SettingValue;
|
||||
use crate::views::modal::ActiveModal;
|
||||
|
||||
let mut app = test_app_with_agent();
|
||||
// Pre-set yolo=true via the typed setter so the rollback
|
||||
// captures real prior state.
|
||||
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
||||
// Open the modal AFTER the optimistic toggle so the open-time
|
||||
// snapshot reflects yolo=true.
|
||||
let _ = dispatch(Action::OpenSettings, &mut app);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
|
||||
panic!("expected Settings modal");
|
||||
};
|
||||
assert!(
|
||||
state.pager_snapshot.yolo_mode,
|
||||
"pre-rollback snapshot reflects optimistic state (yolo=true)",
|
||||
);
|
||||
|
||||
// Simulate disk-write failure → rollback to "ask".
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SettingPersistFailed {
|
||||
key: "permission_mode",
|
||||
rollback_value: SettingValue::Enum("ask"),
|
||||
error: "test-error".into(),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// The modal's snapshot MUST refresh to the rolled-back value.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
|
||||
panic!("modal must stay open after rollback");
|
||||
};
|
||||
assert!(
|
||||
!state.pager_snapshot.yolo_mode,
|
||||
"rollback path MUST refresh pager_snapshot.yolo_mode (false after revert)",
|
||||
);
|
||||
assert_eq!(
|
||||
state.ui_snapshot.permission_mode.as_deref(),
|
||||
Some("ask"),
|
||||
"rollback path MUST refresh ui_snapshot.permission_mode to 'ask'",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_permission_mode_ask_emits_brand_consistent_toast() {
|
||||
use crate::app::actions::PermissionModeKind;
|
||||
let mut app = test_app_with_agent();
|
||||
// Pre-set to AlwaysApprove so the Ask dispatch is a real
|
||||
// transition (avoids idempotent fast-path).
|
||||
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
||||
// Clear toast so we observe the Ask dispatch's fresh toast.
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().toast = None;
|
||||
|
||||
let effects = dispatch(Action::SetPermissionMode(PermissionModeKind::Ask), &mut app);
|
||||
|
||||
assert!(!app.agents[&AgentId(0)].session.is_yolo());
|
||||
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
|
||||
|
||||
// Toast brands as "Permission mode" not
|
||||
// "Always-approve". Previously the Ask arm reused `yolo_toast(false)`
|
||||
// which produced "✓ Always-approve: off" — a brand mismatch.
|
||||
let toast = app.agents[&AgentId(0)]
|
||||
.toast
|
||||
.as_ref()
|
||||
.map(|(s, _)| s.clone())
|
||||
.expect("toast must be set");
|
||||
assert_eq!(
|
||||
toast, "\u{2713} Permission mode: Ask",
|
||||
"PR 11 R1 G-3 #11: Ask toast must brand as 'Permission mode' not 'Always-approve'",
|
||||
);
|
||||
|
||||
// Effect carries the new canonical + the prior canonical
|
||||
// (was "always-approve" from the test-setup pre-set).
|
||||
assert_eq!(effects.len(), 1);
|
||||
match &effects[0] {
|
||||
Effect::PersistPermissionMode {
|
||||
canonical, persist, ..
|
||||
} => {
|
||||
assert_eq!(*canonical, "ask");
|
||||
assert_eq!(
|
||||
*persist,
|
||||
crate::app::actions::PermissionModePersist::WithRollback("always-approve"),
|
||||
"prior canonical was 'always-approve' (pre-set by SetYoloMode(true))",
|
||||
);
|
||||
}
|
||||
other => panic!("expected PersistPermissionMode, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test. A `--yolo`
|
||||
/// startup sets `agent.session.yolo_mode = true` but leaves
|
||||
/// `app.current_ui.permission_mode` at `None`. Without the
|
||||
/// LIVE-precedence capture, dispatching `SetPermissionMode(Default)`
|
||||
/// would produce `WithRollback("ask")` — diverging the pager from
|
||||
/// the shell on disk failure (the ACP suppress-on-failure gate
|
||||
/// keeps the shell at YOLO, but the pager would roll back to
|
||||
/// non-YOLO). This test pins the LIVE-precedence fix.
|
||||
#[test]
|
||||
fn set_permission_mode_with_live_yolo_and_no_ui_mirror_rolls_back_to_always_approve() {
|
||||
use crate::app::actions::PermissionModeKind;
|
||||
let mut app = test_app_with_agent();
|
||||
// Simulate `--yolo` startup: agent yolo + default_yolo set,
|
||||
// but `current_ui.permission_mode = None` (config has no
|
||||
// `[ui] permission_mode` setting).
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.yolo_mode = true;
|
||||
app.default_yolo = true;
|
||||
app.current_ui.permission_mode = None;
|
||||
|
||||
let effects = dispatch(
|
||||
Action::SetPermissionMode(PermissionModeKind::Default),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// The dispatch flipped yolo off (Default projects onto
|
||||
// bool=false) and set the canonical to "default".
|
||||
assert!(!app.agents[&AgentId(0)].session.is_yolo());
|
||||
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("default"));
|
||||
|
||||
// **Rollback contract.** Rollback must target
|
||||
// "always-approve" (the LIVE state at dispatch time), NOT
|
||||
// "ask" (a bool-projected guess from the None mirror).
|
||||
match &effects[0] {
|
||||
Effect::PersistPermissionMode { persist, .. } => {
|
||||
assert_eq!(
|
||||
*persist,
|
||||
crate::app::actions::PermissionModePersist::WithRollback("always-approve"),
|
||||
"PR 11 R1 Security #8: LIVE yolo state must take precedence over the \
|
||||
None on-disk mirror when computing the rollback canonical — \
|
||||
otherwise a `--yolo` startup + Default-commit + disk-failure diverges \
|
||||
the pager from the shell",
|
||||
);
|
||||
}
|
||||
other => panic!("expected PersistPermissionMode, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `apply_setting_rollback("permission_mode",
|
||||
/// Enum("default"))` — the rollback arm that preserves the
|
||||
/// "default" canonical through a failed-persist. The headline
|
||||
/// architectural contract: rolling back to "default" must NOT
|
||||
/// collapse onto "ask" via the inner's bool projection.
|
||||
#[test]
|
||||
fn rollback_permission_mode_default_canonical_preserves_default() {
|
||||
use crate::settings::SettingValue;
|
||||
let mut app = test_app_with_agent();
|
||||
// Pre-flip to YOLO so the rollback has somewhere to roll
|
||||
// back FROM.
|
||||
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
||||
assert!(app.agents[&AgentId(0)].session.is_yolo());
|
||||
assert_eq!(
|
||||
app.current_ui.permission_mode.as_deref(),
|
||||
Some("always-approve"),
|
||||
);
|
||||
|
||||
// Simulate disk-write failure with `rollback_value =
|
||||
// Enum("default")`.
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::SettingPersistFailed {
|
||||
key: "permission_mode",
|
||||
rollback_value: SettingValue::Enum("default"),
|
||||
error: "simulated".into(),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Rollback path MUST NOT re-emit any Effect — that would
|
||||
// loop on persistent disk failure.
|
||||
assert!(
|
||||
effects.is_empty(),
|
||||
"rollback path must not re-emit Effects, got {effects:?}",
|
||||
);
|
||||
|
||||
// Yolo flipped to false (Default projects onto bool=false).
|
||||
assert!(
|
||||
!app.agents[&AgentId(0)].session.is_yolo(),
|
||||
"Default projects onto yolo=false; agent.session.yolo_mode must flip back",
|
||||
);
|
||||
// Canonical preserved as "default" — the headline
|
||||
// contract. Without the post-inner override in the rollback
|
||||
// arm, the inner's bool-projection write would leave this
|
||||
// at "ask".
|
||||
assert_eq!(
|
||||
app.current_ui.permission_mode.as_deref(),
|
||||
Some("default"),
|
||||
"PR 11 R1 Tests #22: rollback to 'default' canonical must NOT collapse \
|
||||
onto 'ask' — the post-inner override restores the canonical",
|
||||
);
|
||||
}
|
||||
|
||||
/// Non-empty permission_queue → NeedsInput.
|
||||
#[test]
|
||||
fn classify_top_level_permission_queue_non_empty_is_needs_input() {
|
||||
use crate::views::dashboard::{RowState, classify_top_level};
|
||||
let mut app = test_app_with_agent();
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let _rx = push_synthetic_permission(agent, 1, vec![("allow", "Allow")]);
|
||||
assert_eq!(classify_top_level(agent), RowState::NeedsInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_select_reject_does_not_steer_sticky_cursor() {
|
||||
use crate::appearance::permission_cursor::{
|
||||
DefaultSelectedPermission, last_used_permission, set_last_used_permission,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
let mut app = test_app_with_agent();
|
||||
let _rx_allow = enqueue_permission_with_enable_always_approve(&mut app);
|
||||
let _rx_reject = enqueue_permission_with_enable_always_approve(&mut app);
|
||||
|
||||
set_last_used_permission(DefaultSelectedPermission::AlwaysAllowAllSessions);
|
||||
let _ = dispatch(
|
||||
Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from("opt-allow-once"))),
|
||||
&mut app,
|
||||
);
|
||||
assert_eq!(
|
||||
last_used_permission(),
|
||||
DefaultSelectedPermission::AllowOnce,
|
||||
"allow selection records the sticky cursor target"
|
||||
);
|
||||
|
||||
let _ = dispatch(
|
||||
Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from("opt-reject-once"))),
|
||||
&mut app,
|
||||
);
|
||||
assert_eq!(
|
||||
last_used_permission(),
|
||||
DefaultSelectedPermission::AllowOnce,
|
||||
"reject selection must not steer the sticky cursor"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
//! Tests for session lifecycle, loading, pickers, modals, forking, and trust.
|
||||
|
||||
use super::*;
|
||||
|
||||
mod foreign;
|
||||
mod fork;
|
||||
mod lifecycle;
|
||||
mod load;
|
||||
mod modal;
|
||||
mod take_deferred;
|
||||
|
||||
/// Like [`test_app`] but with `cwd` set to this crate's directory,
|
||||
/// which lives inside the git repo. Worktree tests require a git
|
||||
/// ancestor to pass the `has_git_ancestor` pre-check.
|
||||
fn test_app_git() -> AppView {
|
||||
let mut app = test_app();
|
||||
app.cwd = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
app.cwd_has_git_ancestor = true;
|
||||
app
|
||||
}
|
||||
|
||||
fn count_extension_fetches(effects: &[Effect]) -> usize {
|
||||
effects
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
Effect::FetchHooksList { .. }
|
||||
| Effect::FetchPluginsList { .. }
|
||||
| Effect::FetchMcpsList { .. }
|
||||
| Effect::FetchSkillsList { .. }
|
||||
)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Build a single-agent app for the `/new` dispatcher tests.
|
||||
///
|
||||
/// Sets `current_branch` to `Some("main")` so the agent appears to be
|
||||
/// inside a git repo (mirrors `fork_test_app`).
|
||||
fn new_session_test_app() -> AppView {
|
||||
let mut app = test_app_with_agent();
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().current_branch = Some("main".into());
|
||||
app.cwd_has_git_ancestor = true;
|
||||
app
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user