Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/agent_view/queue.rs
T
ZacharyZhang-NY d6c20fc13f 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).
2026-07-17 05:31:01 -04:00

1858 lines
77 KiB
Rust

//! Prompt-queue pane: visibility toggles, key handling, row removal, and
//! server-order reconciliation.
#[cfg(test)]
use super::test_fixtures;
use super::{AgentPane, AgentView, ParkedMarkerSlot, PromptMode, overlay_action_to_outcome};
use crate::actions::ActionRegistry;
use crate::app::actions::Action;
use crate::app::app_view::InputOutcome;
use crossterm::event::KeyEvent;
impl AgentView {
/// Remove a local queue row: fix selection, drop the entry, hide the
/// pane if the merged view emptied. Returns the removed prompt, if any.
pub(in crate::app) fn remove_local_queue_row(
&mut self,
id: u64,
) -> Option<crate::app::agent::QueuedPrompt> {
let pos = self
.session
.pending_prompts
.iter()
.position(|p| p.id == id)?;
// Deleting the row being edited discards the edit — and must exit
// BEFORE the removal so a potential auto-hide pane switch can't hit
// the editing lock (see queue_edit.rs ordering invariant).
if matches!(
self.prompt_mode,
PromptMode::EditingQueued { id: editing_id, server_id: None, .. } if editing_id == id
) {
self.exit_editing_mode();
}
self.queue.select_after_delete(id);
let prompt = self.session.pending_prompts.remove(pos);
if self.visible_queue_is_empty() {
self.hide_queue_pane();
}
prompt
}
/// Force-send a queued follow-up mid-turn from the prompt (empty composer).
///
/// Always the **top** visible row (first under the server-then-local merge
/// order — the next item that would drain). Bare Enter and the send-now
/// chord share this path; queue-pane selection / mouse "Send now" keep
/// intentional selection. Returns `None` when there is nothing to send.
pub(super) fn try_send_now_queued_from_prompt(&mut self) -> Option<InputOutcome> {
if !self.session.state.is_turn_running() {
return None;
}
self.sync_queue_pane();
let ids = self.queue.entry_ids();
let id = *ids.first()?;
let outcome = self.force_interject_queue_row(id);
// Acting on the prompt-path send-now while its tip is up is the user
// accepting the hint — mirrors the undo / image-input funnels so the
// send_now `shown → accepted` conversion is measurable.
if matches!(outcome, InputOutcome::Action(_))
&& self.ephemeral_tip.current_key() == Some(crate::tips::send_now::SEND_NOW_TIP_KEY)
{
self.ephemeral_tip
.clear(crate::tips::send_now::SEND_NOW_TIP_KEY);
}
Some(outcome)
}
/// The turn is parked in a wait the shell aborts as soon as the user
/// sends anything (blocking `get_task_output` / `wait_tasks` / `Await*`,
/// or a blocked foreground subagent await — see
/// [`crate::views::turn_status::is_sendable_wait`]), and the goal loop is
/// inactive (the shell suppresses the abort during goal runs, so treating
/// the wait as user-interruptible would lie there).
///
/// Gates Enter interjecting instead of queueing and the parked queue
/// drain. The stopped-session *rendering* additionally requires the
/// parked-marker slot to be consumed — see [`Self::renders_parked`].
/// Purely view-derived — reading it has no turn-lifecycle side effects.
pub(crate) fn is_parked_on_sendable_wait(&self) -> bool {
crate::views::turn_status::is_sendable_wait(&self.resolve_turn_activity())
&& !self
.goal_state
.as_ref()
.is_some_and(|g| matches!(g.status, crate::app::agent::GoalDisplayStatus::Active))
}
/// Whether an explicit send-now dispatched right now will actually cancel
/// the running turn shell-side (`cancel_running_turn = send_now &&
/// turn_running && !goal_active`) — the arming predicate for
/// [`Self::expect_send_now_cancel`]. During an active goal the shell only
/// promotes the prompt (no cancel), so arming would leave a stale
/// expectation that suppresses a later real cancel's marker.
pub(crate) fn expects_send_now_cancel(&self) -> bool {
self.session.state.is_turn_running()
&& !self
.goal_state
.as_ref()
.is_some_and(|g| matches!(g.status, crate::app::agent::GoalDisplayStatus::Active))
}
/// Arm cancel-marker + no-entry-top pin. Gate with [`Self::expects_send_now_cancel`].
pub(crate) fn arm_send_now_expectation(&mut self, prompt_id: String) {
self.follow_without_jump_prompt_id = Some(prompt_id.clone());
self.expect_send_now_cancel = Some(prompt_id);
}
/// Clear cancel-marker + no-entry-top pin (failure / interactive cancel / reload).
pub(crate) fn clear_send_now_expectation(&mut self) {
self.expect_send_now_cancel = None;
self.follow_without_jump_prompt_id = None;
}
/// The current wait is a foreground subagent await — sendable, but excluded
/// from the parked marker (the parent is blocked, not completed; the
/// subagent reports its own progress).
pub(crate) fn is_waiting_on_subagent(&self) -> bool {
use crate::acp::tracker::{TurnActivity, WaitingReason};
matches!(
self.resolve_turn_activity(),
Some(TurnActivity::Waiting(WaitingReason::Subagent))
)
}
/// The wait can only return imminently: every awaited id is already
/// terminal, or a wait-all sees zero running work. Unknown ids and Sleep
/// are never imminent. Callers must pre-gate on
/// `is_parked_on_sendable_wait` — this predicate ignores `waits`.
fn parked_wait_resolves_imminently(&self) -> bool {
use crate::acp::tracker::{TurnActivity, WaitingReason};
match self.resolve_turn_activity() {
Some(TurnActivity::Waiting(WaitingReason::TaskOutput { task_ids, .. })) => {
!task_ids.is_empty() && task_ids.iter().all(|id| self.awaited_id_is_terminal(id))
}
// The tracker drops wait_commands_or_subagents' explicit task_ids;
// zero visible work is the only signal available here.
Some(TurnActivity::Waiting(WaitingReason::TasksComplete)) => {
self.current_end_work().nonzero().is_none()
}
_ => false,
}
}
/// Terminal work behind an awaited id: bg task by task id, else subagent
/// by child session id or subagent id. Unknown → `false`.
fn awaited_id_is_terminal(&self, id: &str) -> bool {
if let Some(task) = self.session.bg_tasks.get(id) {
return task.status != crate::app::agent::BgTaskStatus::Running;
}
self.subagent_sessions
.get(id)
.or_else(|| {
self.subagent_sessions
.values()
.find(|info| info.subagent_id.as_ref() == id)
})
.is_some_and(|info| !info.is_running())
}
/// Push a "Worked for X. {k} … still running…" marker when the turn is
/// parked on a sendable wait and the transcript tail is not already this
/// turn's marker with current counts — the tail keeps explaining the
/// idle-looking chrome across parks, restatements, and re-parks. Our own
/// push satisfies the check, so markers never self-chain.
///
/// Called from the ACP notification path — not the draw path — so
/// background tabs and minimal mode stamp the park at its true moment;
/// each push is append-only (minimal mode commits print-once). The
/// subagent-only refresh path may mutate an uncommitted parked row. A
/// [`ParkedMarkerSlot::Forgone`] slot stays silent for the rest of the
/// turn (see [`Self::suppress_parked_marker_on_interject`]). UI-only: no
/// turn-lifecycle event, no stop hooks; the real completion prints its
/// own marker.
pub(crate) fn maybe_push_parked_marker(&mut self) {
if !self.is_parked_on_sendable_wait()
|| self.is_waiting_on_subagent()
|| self.has_held_user_queue()
{
return;
}
let Some(prompt_id) = self.session.current_prompt_id.clone() else {
return;
};
match &self.parked_wait_marker_for {
// Interjection ordering: forgone is final for the turn.
Some(ParkedMarkerSlot::Forgone(pid)) if *pid == prompt_id => return,
// A tail user prompt after a rendered marker is an interjection:
// a "still running" line beneath it would flip the transcript.
Some(ParkedMarkerSlot::Rendered { prompt_id: pid, .. })
if *pid == prompt_id && self.tail_is_user_prompt() =>
{
return;
}
_ => {}
}
if self.parked_marker_is_current_tail(&prompt_id) {
return;
}
// Below the tail dedupe: a rendered park would otherwise log a false
// "skipped" on every subsequent update.
if self.parked_wait_resolves_imminently() {
tracing::debug!(
target: "prompt.parked_marker",
"parked marker skipped: awaited work already finished, wait resolves imminently"
);
return;
}
self.push_parked_marker_block(prompt_id);
}
/// Refresh the current uncommitted parked marker after a live subagent completion.
pub(crate) fn maybe_refresh_parked_subagent_marker(&mut self) {
if !self.renders_parked()
|| self.has_held_user_queue()
|| self.tail_is_user_prompt()
|| self.parked_wait_resolves_imminently()
{
return;
}
let end_work = self.current_end_work();
if end_work.nonzero().is_none() {
return;
}
let Some(prompt_id) = self.session.current_prompt_id.clone() else {
return;
};
let Some((entry_id, marker_epoch)) = self
.parked_wait_marker_for
.as_ref()
.and_then(ParkedMarkerSlot::rendered_marker)
else {
return;
};
let agent_output_epoch = self.session.tracker.agent_output_epoch();
let elapsed = self.turn_elapsed().unwrap_or_default();
if marker_epoch == agent_output_epoch
&& self
.scrollback
.refresh_parked_subagent_marker(entry_id, &prompt_id, elapsed, end_work)
{
return;
}
self.maybe_push_parked_marker();
}
/// The transcript tail is this turn's parked marker carrying the
/// current work counts.
fn parked_marker_is_current_tail(&self, prompt_id: &str) -> bool {
match self.scrollback.last().map(|entry| &entry.block) {
Some(crate::scrollback::block::RenderBlock::SessionEvent(b)) => {
b.parked
&& b.prompt_id.as_deref() == Some(prompt_id)
&& b.end_work == self.current_end_work().nonzero()
}
_ => false,
}
}
/// The transcript tail is a user-authored prompt row.
fn tail_is_user_prompt(&self) -> bool {
matches!(
self.scrollback.last().map(|entry| &entry.block),
Some(crate::scrollback::block::RenderBlock::UserPrompt(_))
)
}
/// The parked marker block shape: a `TurnCompleted` marker flagged
/// `parked` (renders mid-turn, never accepts stop hooks) with the work
/// counts snapshotted at push time.
fn push_parked_marker_block(&mut self, prompt_id: String) {
let agent_output_epoch = self.session.tracker.agent_output_epoch();
let mut block = crate::scrollback::blocks::SessionEventBlock::new(
crate::scrollback::blocks::SessionEvent::TurnCompleted {
// Legacy copy on purpose: unknown elapsed keeps the "in 0.0s"
// form here — only wake markers use the honest `None` form.
elapsed: Some(self.turn_elapsed().unwrap_or_default()),
},
);
block.parked = true;
block.prompt_id = Some(prompt_id.clone());
// Park never touches `end_work_announced` (the turn is still running
// shell-side; the window is a between-turns concept) — so it does not
// use `push_end_marker_block`, only the field-normalization rule.
block.end_work = self.current_end_work().nonzero();
let entry_id = self
.scrollback
.push_block(crate::scrollback::block::RenderBlock::SessionEvent(block));
self.parked_wait_marker_for = Some(ParkedMarkerSlot::Rendered {
prompt_id,
entry_id,
agent_output_epoch,
});
}
/// Consume the parked-marker slot as forgone when an interjection lands
/// while the turn is parked on a sendable wait: the turn visibly continues
/// below the user's message, so the withheld "Worked for … still
/// running." marker must never render under it (it would read as the turn
/// completing *after* the user's follow-up — flipped ordering). A no-op
/// when the marker already rendered (slot already stamped) or the turn is
/// not parked (a plain mid-turn interjection keeps a later park's marker).
///
/// Accepted edge: if the interject send later FAILS while the wait is
/// still parked (`TaskResult::InterjectFailed` requeues the payload), the
/// slot stays consumed — idle chrome without a marker until the wait
/// ends. Un-consuming would recreate the flipped ordering under the
/// already-rendered optimistic block.
pub(crate) fn suppress_parked_marker_on_interject(&mut self) {
if self.is_parked_on_sendable_wait()
&& let Some(prompt_id) = self.session.current_prompt_id.clone()
{
// Never downgrade a Rendered slot: with the marker on screen the
// ordering is already correct, and its countdown may keep ticking.
if self
.parked_wait_marker_for
.as_ref()
.is_some_and(|slot| slot.prompt_id() == prompt_id)
{
return;
}
tracing::debug!(
target: "prompt.auto_interject",
"parked marker forgone: interjection continued the parked turn"
);
self.parked_wait_marker_for = Some(ParkedMarkerSlot::Forgone(prompt_id));
}
}
/// Visible held rows for the "N queued" hint. 0 outside sendable waits.
pub(crate) fn held_queue_count(&self) -> usize {
// Goal-gated via `is_parked_on_sendable_wait` (0 during a goal — shell exempts goal turns).
if !self.is_parked_on_sendable_wait() {
return 0;
}
self.visible_held_queue_len()
}
/// Pane-visible held rows (excludes running + send-now echo).
pub(crate) fn visible_held_queue_len(&self) -> usize {
let running = self.session.current_prompt_id.as_deref();
let send_now = self.expect_send_now_cancel.as_deref();
let server = self
.shared_queue
.iter()
.filter(|e| {
crate::views::queue_pane::visible_held_server_row(
&e.id,
running,
send_now,
&self.send_now_painted_blocks,
)
})
.count();
server + self.session.pending_prompts.len()
}
/// Shell-style held occupancy (includes send-now echo; unlike pane count).
pub(crate) fn has_held_user_queue(&self) -> bool {
let running = self.session.current_prompt_id.as_deref();
// An armed send-now counts as occupancy only until its own turn adopts:
// once the armed id IS the running turn, nothing is held behind it (the
// arm lingers only for cancel-marker suppression). Excluding the running
// id here matches the `shared_queue` filter below.
if self
.expect_send_now_cancel
.as_deref()
.is_some_and(|arm| Some(arm) != running)
{
return true;
}
if !self.session.pending_prompts.is_empty() {
return true;
}
self.shared_queue
.iter()
.any(|e| Some(e.id.as_str()) != running)
}
/// Whether bare Enter on the empty composer would actually send the TOP
/// visible held row — the "Enter to send now" half of the inline hint.
/// Server rows always send now; a local top row only when prompt-like
/// (`force_interject_queue_row` refuses bash / client-expanded rows with
/// a toast, so advertising Enter for them would over-promise).
pub(crate) fn held_queue_top_sendable(&self) -> bool {
let running = self.session.current_prompt_id.as_deref();
let send_now = self.expect_send_now_cancel.as_deref();
// Merge order: server rows render (and send) first.
if self.shared_queue.iter().any(|e| {
crate::views::queue_pane::visible_held_server_row(
&e.id,
running,
send_now,
&self.send_now_painted_blocks,
)
}) {
return true;
}
self.session.pending_prompts.front().is_some_and(|p| {
p.kind == crate::app::agent::QueueEntryKind::Prompt && p.wire_matches_display()
})
}
/// Rebuild the queue pane via [`visible_held_server_row`] excludes.
pub(crate) fn sync_queue_pane(&mut self) {
self.queue.sync_from_merged(
&self.session.pending_prompts,
&self.shared_queue,
self.session.current_prompt_id.as_deref(),
self.expect_send_now_cancel.as_deref(),
&self.send_now_painted_blocks,
);
}
/// Whether the stopped-session look is active: the parked-marker slot for
/// the current turn was consumed (marker pushed, or forgone because an
/// interjection continued the parked turn) and the turn is still in its
/// sendable wait. Drives hiding the turn-status row and the idle keybar;
/// flips back off (the running chrome returns) the moment the wait ends
/// and the turn resumes.
pub(crate) fn renders_parked(&self) -> bool {
self.parked_wait_marker_for
.as_ref()
.zip(self.session.current_prompt_id.as_deref())
.is_some_and(|(slot, pid)| slot.prompt_id() == pid)
&& self.is_parked_on_sendable_wait()
// Subagent waits keep running chrome — exclude them from the stopped look.
&& !self.is_waiting_on_subagent()
}
/// Live background work on this agent's root session, split the way the
/// marker copy reads it (monitors apart from commands).
pub(crate) fn current_end_work(&self) -> crate::scrollback::blocks::EndWork {
let mut work = crate::scrollback::blocks::EndWork::default();
for task in self
.session
.bg_tasks
.values()
.filter(|t| t.status == crate::app::agent::BgTaskStatus::Running)
{
if task.is_monitor {
work.running_monitors += 1;
} else {
work.running_commands += 1;
}
}
work.running_subagents = self
.subagent_sessions
.values()
.filter(|s| s.is_running())
.count();
work
}
/// Shared tail of every turn-end marker push — the real-turn rail
/// (`push_turn_terminal_marker`) and the wake rail (`push_wake_end_marker`):
/// snapshot the current counts into `end_work` (zero → `None` → legacy
/// text) and apply the announce window's single assignment — a counted
/// marker opens the between-turns status window, a workless one closes it.
pub(crate) fn push_end_marker_block(
&mut self,
event: crate::scrollback::blocks::SessionEvent,
stop_hooks: Vec<(String, Vec<crate::scrollback::blocks::tool::HookRunEntry>)>,
prompt_id: Option<String>,
) {
let end_work = self.current_end_work().nonzero();
// The marker keeps its turn's pid for the tail-merge attribution check.
let mut block = crate::scrollback::blocks::SessionEventBlock::with_stop_hooks(
event, stop_hooks, prompt_id,
);
block.end_work = end_work;
self.scrollback
.push_block(crate::scrollback::block::RenderBlock::SessionEvent(block));
self.end_work_announced = end_work.is_some();
}
/// Push the work-only status line ("2 commands still running") after a
/// background completion that lands between turns AND is not followed by
/// a wake response — the no-wake fallback. Wake-bound completions
/// (`will_wake` stamped by the shell) skip this line: the wake turn's end
/// marker carries the fresh counts instead (see `push_wake_end_marker`).
/// Without a wake the story stays chronological the old way: marker(3) →
/// chip → status(2) → chip → status(1) → chip → nothing (zero left: the
/// last chip closes it). Gated on the last turn-end marker having
/// announced work and on no real turn running — mid-turn completions push
/// chips only. Returns whether a line was pushed (callers needing a
/// redraw verdict use it; chip sites ignore it).
pub(crate) fn maybe_push_work_status(&mut self) -> bool {
if !self.end_work_announced || self.session.state.is_busy() {
return false;
}
let Some(text) = self.current_end_work().still_running_text() else {
return false;
};
self.scrollback
.push_block(crate::scrollback::block::RenderBlock::system(text));
true
}
/// `Some(is_prompt_like)` for a resolvable merged-queue row; `None` when it
/// can't be resolved. Prompt-like rows may interject: plain prompts, plus
/// raw skill slash rows (`/find-session args`) whose wire payload IS the
/// display text — the shell expands those at the interjection drain. Rows
/// with a client-expanded payload (`/imagine`, `/loop`) and non-prompt
/// kinds stay queued: interjecting them would send the display text, not
/// the payload.
pub(in crate::app) fn queue_row_prompt_like(&self, id: u64) -> Option<bool> {
use crate::app::agent::QueueEntryKind;
use crate::views::queue_pane::{QueueRowOrigin, kind_from_wire};
if let Some(local) = self.session.pending_prompts.iter().find(|p| p.id == id) {
return Some(local.kind == QueueEntryKind::Prompt && local.wire_matches_display());
}
let row = self.queue.row_ref(id)?;
if row.origin != QueueRowOrigin::Server {
return None;
}
let server_id = row.server_id?;
let wire = self.shared_queue.iter().find(|e| e.id == server_id)?;
Some(kind_from_wire(&wire.kind) == QueueEntryKind::Prompt)
}
/// Send one merged-queue row now (cancel-and-send), by selection id. The
/// shell cancels the running turn and runs this row as the next turn.
pub(in crate::app) fn force_interject_queue_row(&mut self, id: u64) -> InputOutcome {
if !self.session.state.is_turn_running() {
self.show_toast("No turn running — prompt will send when ready");
return InputOutcome::Changed;
}
let row = self.queue.row_ref(id);
let is_server = matches!(
row.as_ref().map(|r| r.origin),
Some(crate::views::queue_pane::QueueRowOrigin::Server)
);
if is_server {
// Server row: the agent promotes it to run next (`x.ai/queue/interject`); any kind may send now.
if let Some(row) = row.as_ref()
&& let Some(server_id) = row.server_id.clone()
{
// Still an optimistic echo: its `session/prompt` RPC is in
// flight, so an interject fired now would overtake the row
// shell-side and silently no-op (dropping the send-now and
// hiding the row behind the armed cancel expectation). Park
// the intent; the confirming `x.ai/queue/changed` broadcast
// fires it with the row's authoritative version (see
// `resolve_send_now_awaiting_confirm`).
if self.optimistic_queue_ids.contains(&server_id) {
self.send_now_awaiting_confirm = Some(server_id);
return InputOutcome::Changed;
}
return InputOutcome::Action(Action::QueueInterjectShared {
id: server_id,
expected_version: row.version,
new_text: None,
});
}
return InputOutcome::Changed;
}
// Local rows: only plain prompts / raw skill rows can re-send (others would send display text, not payload).
if self.queue_row_prompt_like(id) != Some(true) {
self.show_toast("Can't send this now — it runs when the current turn ends");
return InputOutcome::Changed;
}
if let Some(prompt) = self.remove_local_queue_row(id) {
return InputOutcome::Action(Action::SendPromptNow {
text: prompt.text,
images: prompt.images,
});
}
InputOutcome::Changed
}
/// Reconcile this client's optimistic queue echoes against a raw
/// `x.ai/queue/changed` broadcast (pre-merge entries — the mirrored
/// snapshot re-pins unconfirmed echoes, so it can't tell confirmation
/// apart), and resolve a parked queue-row send-now
/// ([`Self::send_now_awaiting_confirm`]).
///
/// Returns `Some((id, version))` when the parked row is now confirmed as
/// QUEUED — the caller fires `x.ai/queue/interject` with that
/// authoritative version. A parked row confirmed as RUNNING clears the
/// park with nothing to do (the natural drain won the race). A row in
/// neither set stays parked (its RPC is still in flight).
pub(crate) fn resolve_send_now_awaiting_confirm(
&mut self,
broadcast_entries: &[(String, u64)],
running_prompt_id: Option<&str>,
) -> Option<(String, u64)> {
// Confirmed ids (queued or running) leave the optimistic set.
self.optimistic_queue_ids.retain(|id| {
running_prompt_id != Some(id.as_str())
&& !broadcast_entries.iter().any(|(eid, _)| eid == id)
});
let awaiting = self.send_now_awaiting_confirm.as_deref()?;
if running_prompt_id == Some(awaiting) {
self.send_now_awaiting_confirm = None;
return None;
}
if let Some((id, version)) = broadcast_entries.iter().find(|(eid, _)| eid == awaiting) {
self.send_now_awaiting_confirm = None;
return Some((id.clone(), *version));
}
None
}
/// A server-queue echo resolved without landing (RPC failed / removed /
/// cancelled): forget it, and drop any send-now parked on it — there is
/// no row left to promote.
pub(crate) fn note_queue_echo_retired(&mut self, prompt_id: &str) {
self.optimistic_queue_ids.remove(prompt_id);
if self.send_now_awaiting_confirm.as_deref() == Some(prompt_id) {
self.send_now_awaiting_confirm = None;
}
// Retired ids never adopt — drop the painted block with the id.
// (Re-keys route through `note_queue_echo_rekeyed` instead.)
self.retire_send_now_painted_block(prompt_id);
}
/// Re-key: `old_id` is dead but the message lives on under `new_id` —
/// move (never retire) its painted block so the new adoption reuses it.
pub(crate) fn note_queue_echo_rekeyed(&mut self, old_id: &str, new_id: &str) {
self.optimistic_queue_ids.remove(old_id);
if self.send_now_awaiting_confirm.as_deref() == Some(old_id) {
self.send_now_awaiting_confirm = None;
}
if let Some(entry) = self.send_now_painted_blocks.remove(old_id) {
match self.send_now_painted_blocks.entry(new_id.to_string()) {
std::collections::hash_map::Entry::Vacant(slot) => {
slot.insert(entry);
}
// Re-key collision (identical texts): remove the losing
// block instead of orphaning it.
std::collections::hash_map::Entry::Occupied(_) => {
self.scrollback.remove_entry(entry.0);
}
}
}
}
/// Remove the optimistic block for a send-now'd prompt that will never
/// run (send failure, removal) — a leftover would duplicate on requeue.
pub(crate) fn retire_send_now_painted_block(&mut self, prompt_id: &str) {
if let Some((id, _)) = self.send_now_painted_blocks.remove(prompt_id) {
self.scrollback.remove_entry(id);
}
}
/// Apply the updates buffered for `prompt_id`; other pids' entries are dropped.
pub(crate) fn flush_pending_adoption_updates(&mut self, prompt_id: &str) {
if self.pending_adoption_updates.is_empty() {
return;
}
for (pid, update, mut meta) in std::mem::take(&mut self.pending_adoption_updates) {
if pid == prompt_id {
// Forward-only: the xAI rail shares this cursor and may have
// applied later events during the buffering window — assigning
// a buffered (older) id would re-deliver those on reconnect.
let cur_seq = self
.last_seen_event_id
.as_deref()
.and_then(|s| s.rsplit('-').next())
.and_then(|c| c.parse::<u64>().ok());
if let (Some(seq), Some(id)) = (meta.event_seq, meta.event_id.take())
&& cur_seq.is_none_or(|cur| seq > cur)
{
self.last_seen_event_id = Some(id);
}
self.session
.handle_update(update, &meta, &mut self.scrollback);
}
}
}
/// Drop buffered updates for `prompt_id`; the un-advanced cursor lets a
/// reconnect replay re-deliver them.
pub(crate) fn discard_pending_adoption_updates(&mut self, prompt_id: &str) {
self.pending_adoption_updates
.retain(|(pid, _, _)| pid != prompt_id);
}
/// Toggle queue pane visibility (shared by Ctrl-; shortcut and badge click).
pub(in crate::app) fn toggle_queue_pane(&mut self) {
self.queue.overlay.toggle();
self.queue.on_state_change();
if self.queue.overlay.focused {
self.set_active_pane(AgentPane::Queue, false);
} else if self.active_pane == AgentPane::Queue {
self.set_active_pane(AgentPane::Scrollback, false);
}
}
/// Queue-pane-focused key handling.
///
/// Routes through: overlay structural keys → queue actions → navigation.
pub(in crate::app) fn handle_queue_key(
&mut self,
key: &KeyEvent,
registry: &ActionRegistry,
) -> InputOutcome {
use crate::views::overlay::{handle_overlay_key, handle_overlay_nav_key};
use crate::views::queue_pane::{QueueEvent, QueueRowOrigin};
// Structural keys through shared handler (Esc, Ctrl-F, etc.).
let action = handle_overlay_key(&mut self.queue.overlay, key)
.or_else(|| handle_overlay_nav_key(&mut self.queue.overlay, key));
if let Some(action) = action {
self.queue.on_state_change();
// Overlay dismiss skips hide_queue_pane; reset edge when queue is empty.
if !self.queue.overlay.visible && self.visible_queue_is_empty() {
self.queue.reset_auto_show_edge();
}
if !self.queue.overlay.visible || !self.queue.overlay.focused {
self.set_active_pane(AgentPane::Scrollback, false);
}
return overlay_action_to_outcome(action);
}
// Queue-specific actions (delete, edit, reorder). `x`/Delete = row delete.
if let Some(event) = self.queue.handle_key(key, registry) {
// Resolve the selected row's origin so edits route correctly:
// Server-origin rows go to the agent as `x.ai/queue/*`
// commands (the rebroadcast is the source of truth); Local rows
// keep today's in-place mutation.
let row = self.queue.row_ref(Self::queue_event_id(&event));
let is_server = matches!(row.as_ref().map(|r| r.origin), Some(QueueRowOrigin::Server));
match event {
QueueEvent::DeleteSelected { id } => {
if is_server {
// Optimistic remove; server rebroadcast is authoritative.
if let (Some(_sid), Some(row)) = (self.session.session_id.as_ref(), row)
&& let Some(server_id) = row.server_id
{
self.shared_queue.retain(|e| e.id != server_id);
if self.visible_queue_is_empty() {
self.hide_queue_pane();
}
// Deleting the last held row can flip the parked
// look on now (the ACP rebroadcast re-checks too,
// but the optimistic remove shouldn't lag).
self.maybe_push_parked_marker();
return InputOutcome::Action(Action::QueueRemoveShared {
id: server_id,
expected_version: row.version,
});
}
return InputOutcome::Changed;
}
// No drain kick (cf. mouse [cancel]): queue focus is unreachable mid-edit.
self.remove_local_queue_row(id);
// A LOCAL delete has no server rebroadcast to re-evaluate
// the parked look — deleting the last held row must flip
// the stopped chrome on immediately, not on the next
// unrelated notification.
self.maybe_push_parked_marker();
}
QueueEvent::EditSelected { id } => {
// Entry into editing mode lives in `queue_edit.rs`.
self.enter_queue_edit(id, is_server, row);
}
QueueEvent::SwapUp { id } => {
if is_server {
if let Some(ordered_ids) = self.server_queue_reordered(id, true) {
return InputOutcome::Action(Action::QueueReorderShared {
ordered_ids,
});
}
return InputOutcome::Changed;
}
self.session.swap_prompt_up(id);
}
QueueEvent::SwapDown { id } => {
if is_server {
if let Some(ordered_ids) = self.server_queue_reordered(id, false) {
return InputOutcome::Action(Action::QueueReorderShared {
ordered_ids,
});
}
return InputOutcome::Changed;
}
self.session.swap_prompt_down(id);
}
QueueEvent::ForceInterject { id } => {
return self.force_interject_queue_row(id);
}
}
return InputOutcome::Changed;
}
// Navigation keys (j/k, y to copy, etc.).
if self.queue.handle_navigation_key(key) {
InputOutcome::Changed
} else {
InputOutcome::Unchanged
}
}
/// The selection id carried by a [`QueueEvent`].
fn queue_event_id(event: &crate::views::queue_pane::QueueEvent) -> u64 {
use crate::views::queue_pane::QueueEvent;
match event {
QueueEvent::DeleteSelected { id }
| QueueEvent::EditSelected { id }
| QueueEvent::SwapUp { id }
| QueueEvent::SwapDown { id }
| QueueEvent::ForceInterject { id } => *id,
}
}
/// True when the pane would show zero rows.
pub(in crate::app) fn visible_queue_is_empty(&self) -> bool {
self.visible_held_queue_len() == 0
}
/// Hide the queue pane. Only steals focus when the queue pane was active —
/// prompt-path send-now of the last local row must not yank the user out
/// of the composer into scrollback.
pub(in crate::app) fn hide_queue_pane(&mut self) {
self.queue.overlay.visible = false;
self.queue.overlay.focused = false;
// External hide skips sync auto-hide; reset so next enqueue can auto-show.
self.queue.reset_auto_show_edge();
if self.active_pane == AgentPane::Queue {
self.set_active_pane(AgentPane::Scrollback, false);
}
}
/// Reorder payload for `x.ai/queue/reorder`. Omit only running; include
/// send-now in the list but do not swap past it (shell ranks missing ids last).
fn server_queue_reordered(&self, selection_id: u64, up: bool) -> Option<Vec<String>> {
let server_id = self.queue.row_ref(selection_id)?.server_id?;
let running = self.session.current_prompt_id.as_deref();
let send_now = self.expect_send_now_cancel.as_deref();
let all_ids: Vec<String> = self
.shared_queue
.iter()
.filter(|e| Some(e.id.as_str()) != running)
.map(|e| e.id.clone())
.collect();
let mut swappable: Vec<String> = all_ids
.iter()
.filter(|id| {
crate::views::queue_pane::visible_held_server_row(
id,
running,
send_now,
&self.send_now_painted_blocks,
)
})
.cloned()
.collect();
let pos = swappable.iter().position(|x| x == &server_id)?;
let swap_with = if up {
pos.checked_sub(1)?
} else {
let next = pos + 1;
if next >= swappable.len() {
return None;
}
next
};
swappable.swap(pos, swap_with);
let mut swap_iter = swappable.into_iter();
let ordered: Vec<String> = all_ids
.into_iter()
.map(|id| {
if crate::views::queue_pane::visible_held_server_row(
&id,
running,
send_now,
&self.send_now_painted_blocks,
) {
swap_iter
.next()
.expect("swappable count matches visible slots")
} else {
id
}
})
.collect();
Some(ordered)
}
}
#[cfg(test)]
mod queue_edit_routing_tests {
use super::test_fixtures::{
force_interject_key, make_running_agent, non_vscode_registry, running_agent_local_only,
test_pasted_image, vscode_family_registry, vscode_interject_key,
};
use super::*;
use crate::app::actions::Action;
use crate::app::agent::AgentState;
use crate::app::app_view::InputOutcome;
use crate::app::prompt_queue::QueueEntryWire;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn delete_key() -> KeyEvent {
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)
}
#[test]
fn delete_routes_server_to_action_and_local_to_mutation() {
let mut agent = make_running_agent();
let registry = ActionRegistry::defaults();
let ids = agent.queue.entry_ids();
assert_eq!(ids.len(), 2);
// Server row is rendered first (documented merge order).
agent.queue.list_state.select_by_id(ids[0]);
let outcome = agent.handle_queue_key(&delete_key(), &registry);
match outcome {
InputOutcome::Action(Action::QueueRemoveShared {
id,
expected_version,
}) => {
assert_eq!(id, "p1");
assert_eq!(expected_version, 2);
}
other => panic!("expected QueueRemoveShared action, got {other:?}"),
}
assert_eq!(agent.session.pending_prompts.len(), 1);
assert!(agent.shared_queue.is_empty());
assert!(agent.queue.overlay.visible);
assert!(agent.queue.overlay.focused);
agent.queue.list_state.select_by_id(ids[1]);
let outcome = agent.handle_queue_key(&delete_key(), &registry);
assert!(matches!(outcome, InputOutcome::Changed));
assert!(agent.session.pending_prompts.is_empty());
assert!(!agent.queue.overlay.visible);
assert!(!agent.queue.overlay.focused);
}
fn server_wire(id: &str, position: usize) -> QueueEntryWire {
QueueEntryWire {
id: id.into(),
version: 1,
owner: None,
last_editor: None,
kind: "prompt".into(),
text: format!("server {id}"),
position,
}
}
/// `visible_queue_is_empty` reflects the *merged* pane view, excluding the
/// in-flight turn — the invariant the three pane-hide sites depend on.
#[test]
fn visible_queue_is_empty_reflects_merged_view_minus_running() {
let mut agent = make_running_agent();
// 1 non-running server row + 1 local row → not empty.
assert!(!agent.visible_queue_is_empty());
// Drop the server row; the lone local row keeps it non-empty.
agent.shared_queue.clear();
assert!(!agent.visible_queue_is_empty());
// Both queues empty → empty.
agent.session.pending_prompts.clear();
assert!(agent.visible_queue_is_empty());
// A queued (non-running) server row → not empty.
agent.shared_queue = vec![server_wire("p1", 0)];
agent.session.current_prompt_id = None;
assert!(!agent.visible_queue_is_empty());
// The only server row IS the running turn → counts as empty.
agent.session.current_prompt_id = Some("p1".to_string());
assert!(agent.visible_queue_is_empty());
// Running row plus a second queued server row → not empty again.
agent.shared_queue.push(server_wire("p2", 1));
assert!(!agent.visible_queue_is_empty());
}
/// Keyboard-deleting the last *local* row while a server row remains keeps
/// the pane open and focused (regression: it previously force-hid the pane
/// and stranded the server rows).
#[test]
fn delete_last_local_row_keeps_pane_open_when_server_remains() {
let mut agent = make_running_agent();
agent.active_pane = AgentPane::Queue;
let registry = ActionRegistry::defaults();
let ids = agent.queue.entry_ids();
// ids[1] is the only local row.
agent.queue.list_state.select_by_id(ids[1]);
let outcome = agent.handle_queue_key(&delete_key(), &registry);
assert!(matches!(outcome, InputOutcome::Changed));
assert!(agent.session.pending_prompts.is_empty());
assert_eq!(agent.shared_queue.len(), 1);
assert!(agent.queue.overlay.visible);
assert!(agent.queue.overlay.focused);
assert_eq!(agent.active_pane, AgentPane::Queue);
// Through the handler: selection lands on the surviving server
// row (ids[0]) across the merge boundary, not back at the top.
assert_eq!(agent.queue.selected_id(), Some(ids[0]));
}
/// Deleting down to a truly-empty merged view hides the pane. The lone
/// server row is the in-flight turn, so it does not count as a visible row.
#[test]
fn delete_to_empty_hides_pane_excluding_running_server_row() {
let mut agent = make_running_agent();
agent.active_pane = AgentPane::Queue;
// Make the lone server row the in-flight turn (excluded from the view).
agent.session.current_prompt_id = Some("p1".to_string());
agent.queue.sync_from_merged(
&agent.session.pending_prompts,
&agent.shared_queue,
agent.session.current_prompt_id.as_deref(),
agent.expect_send_now_cancel.as_deref(),
&agent.send_now_painted_blocks,
);
agent.queue.overlay.visible = true;
agent.queue.overlay.focused = true;
let registry = non_vscode_registry();
let ids = agent.queue.entry_ids();
// Only the local row is a visible queued row (running p1 excluded).
assert_eq!(ids.len(), 1);
agent.queue.list_state.select_by_id(ids[0]);
let outcome = agent.handle_queue_key(&delete_key(), &registry);
assert!(matches!(outcome, InputOutcome::Changed));
assert!(agent.session.pending_prompts.is_empty());
assert!(agent.visible_queue_is_empty());
assert!(!agent.queue.overlay.visible);
assert!(!agent.queue.overlay.focused);
assert_eq!(agent.active_pane, AgentPane::Scrollback);
}
/// Keyboard force-interject of the last local row keeps the pane open when
/// a server row remains (mirrors the delete path's visibility treatment).
#[test]
fn force_interject_last_local_row_keeps_pane_open_when_server_remains() {
let mut agent = make_running_agent();
agent.active_pane = AgentPane::Queue;
let registry = non_vscode_registry();
let ids = agent.queue.entry_ids();
agent.queue.list_state.select_by_id(ids[1]);
let outcome = agent.handle_queue_key(&force_interject_key(), &registry);
match outcome {
InputOutcome::Action(Action::SendPromptNow { text, .. }) => {
assert_eq!(text, "local one")
}
other => panic!("expected SendPromptNow action, got {other:?}"),
}
assert!(agent.session.pending_prompts.is_empty());
assert_eq!(agent.shared_queue.len(), 1);
assert!(agent.queue.overlay.visible);
assert!(agent.queue.overlay.focused);
assert_eq!(agent.active_pane, AgentPane::Queue);
}
/// Hide via the keyboard delete path (site 1) with a *literally empty*
/// `shared_queue` and no running prompt: emptying the local queue empties
/// the merged view → pane hides and focus returns to scrollback.
#[test]
fn delete_last_local_row_hides_pane_when_shared_queue_empty() {
let mut agent = running_agent_local_only();
let registry = ActionRegistry::defaults();
let ids = agent.queue.entry_ids();
assert_eq!(ids.len(), 1);
agent.queue.list_state.select_by_id(ids[0]);
let outcome = agent.handle_queue_key(&delete_key(), &registry);
assert!(matches!(outcome, InputOutcome::Changed));
assert!(agent.session.pending_prompts.is_empty());
assert!(agent.shared_queue.is_empty());
assert!(!agent.queue.overlay.visible);
assert!(!agent.queue.overlay.focused);
assert_eq!(agent.active_pane, AgentPane::Scrollback);
}
#[test]
fn delete_last_then_requeue_auto_shows_pane() {
let mut agent = running_agent_local_only();
let registry = ActionRegistry::defaults();
// Prime prev_len via sync (mirrors a rendered frame with one row).
agent.queue.sync_from_merged(
&agent.session.pending_prompts,
&agent.shared_queue,
agent.session.current_prompt_id.as_deref(),
agent.expect_send_now_cancel.as_deref(),
&agent.send_now_painted_blocks,
);
assert!(agent.queue.overlay.visible);
let ids = agent.queue.entry_ids();
agent.queue.list_state.select_by_id(ids[0]);
let _ = agent.handle_queue_key(&delete_key(), &registry);
assert!(!agent.queue.overlay.visible);
agent.session.enqueue_prompt("replacement".into());
agent.queue.sync_from_merged(
&agent.session.pending_prompts,
&agent.shared_queue,
agent.session.current_prompt_id.as_deref(),
agent.expect_send_now_cancel.as_deref(),
&agent.send_now_painted_blocks,
);
assert!(
agent.queue.overlay.visible,
"new queued prompt must be visible after delete+requeue"
);
assert_eq!(agent.queue.entry_ids().len(), 1);
}
/// Hide via the keyboard force-interject path (site 2): with no server rows
/// left, interjecting the last local row empties the merged view → hide.
#[test]
fn force_interject_last_local_row_hides_pane_when_shared_queue_empty() {
let mut agent = running_agent_local_only();
let registry = non_vscode_registry();
let ids = agent.queue.entry_ids();
assert_eq!(ids.len(), 1);
agent.queue.list_state.select_by_id(ids[0]);
let outcome = agent.handle_queue_key(&force_interject_key(), &registry);
match outcome {
InputOutcome::Action(Action::SendPromptNow { text, .. }) => {
assert_eq!(text, "local one")
}
other => panic!("expected SendPromptNow action, got {other:?}"),
}
assert!(agent.session.pending_prompts.is_empty());
assert!(!agent.queue.overlay.visible);
assert!(!agent.queue.overlay.focused);
assert_eq!(agent.active_pane, AgentPane::Scrollback);
}
/// Interjecting a Server-origin row routes to `Action::QueueInterjectShared`
/// (the agent atomically removes it + merges it into the running turn); a
/// Local-origin row interjects its text directly via `Action::Interject`
/// after removing it from the client-owned queue.
#[test]
fn force_interject_routes_server_to_action_and_local_to_interject() {
let mut agent = make_running_agent();
let registry = non_vscode_registry();
// Stored image must ride the action (regression: silent drop).
agent.session.pending_prompts[0]
.images
.push(test_pasted_image());
let ids = agent.queue.entry_ids();
assert_eq!(ids.len(), 2);
// Server row first (documented merge order).
agent.queue.list_state.select_by_id(ids[0]);
let outcome = agent.handle_queue_key(&force_interject_key(), &registry);
match outcome {
InputOutcome::Action(Action::QueueInterjectShared {
id,
expected_version,
new_text: None,
}) => {
assert_eq!(id, "p1");
assert_eq!(expected_version, 2);
}
other => panic!("expected QueueInterjectShared action, got {other:?}"),
}
// Server interject does NOT mutate the local queue.
assert_eq!(agent.session.pending_prompts.len(), 1);
// The local row interjects its text (and stored images) directly.
agent.queue.list_state.select_by_id(ids[1]);
let outcome = agent.handle_queue_key(&force_interject_key(), &registry);
match outcome {
InputOutcome::Action(Action::SendPromptNow { text, images }) => {
assert_eq!(text, "local one");
assert_eq!(images.len(), 1, "row image must ride the interject");
}
other => panic!("expected SendPromptNow action, got {other:?}"),
}
// Local interject removed it from the client-owned queue.
assert!(agent.session.pending_prompts.is_empty());
}
/// A running agent whose only queued row is a local bash command.
fn running_agent_with_local_bash(command: &str) -> AgentView {
let mut agent = running_agent_local_only();
agent.session.pending_prompts.clear();
agent.session.enqueue_bash_command(command.into());
agent.queue.sync_from_merged(
&agent.session.pending_prompts,
&agent.shared_queue,
agent.session.current_prompt_id.as_deref(),
agent.expect_send_now_cancel.as_deref(),
&agent.send_now_painted_blocks,
);
agent
}
/// Force-sending a local bash row is a guarded no-op.
#[test]
fn force_interject_local_bash_row_keeps_it_queued() {
let mut agent = running_agent_with_local_bash("ls -la");
let registry = non_vscode_registry();
let ids = agent.queue.entry_ids();
assert_eq!(ids.len(), 1);
agent.queue.list_state.select_by_id(ids[0]);
let outcome = agent.handle_queue_key(&force_interject_key(), &registry);
assert!(
matches!(outcome, InputOutcome::Changed),
"bash force-send must be a guarded no-op, got {outcome:?}"
);
assert_eq!(agent.session.pending_prompts.len(), 1, "row must stay");
assert!(agent.toast.is_some(), "guard must explain itself");
}
/// A server bash row can send now (promoted to run as its own turn).
#[test]
fn force_interject_server_bash_row_promotes_via_queue_interject() {
let mut agent = make_running_agent();
agent.shared_queue[0].kind = "bash".into();
agent.queue.sync_from_merged(
&agent.session.pending_prompts,
&agent.shared_queue,
agent.session.current_prompt_id.as_deref(),
agent.expect_send_now_cancel.as_deref(),
&agent.send_now_painted_blocks,
);
let registry = non_vscode_registry();
let ids = agent.queue.entry_ids();
agent.queue.list_state.select_by_id(ids[0]);
let outcome = agent.handle_queue_key(&force_interject_key(), &registry);
match outcome {
InputOutcome::Action(Action::QueueInterjectShared { id, .. }) => {
assert_eq!(id, "p1");
}
other => panic!("expected QueueInterjectShared for server bash row, got {other:?}"),
}
}
/// Empty-Enter send-now must not convert a bash top row into an interjection.
#[test]
fn enter_empty_from_prompt_does_not_convert_bash_top_row() {
let mut agent = running_agent_with_local_bash("git status");
agent.active_pane = AgentPane::Prompt;
agent.queue.overlay.focused = false;
agent.prompt.set_text("");
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let outcome = agent.handle_prompt_key_for_test(&enter);
assert!(
matches!(outcome, InputOutcome::Changed),
"empty Enter on a bash top row must not interject, got {outcome:?}"
);
assert_eq!(
agent.session.pending_prompts.len(),
1,
"bash row must stay queued"
);
}
/// A running agent whose only queued row is a local skill-injected row
/// (kind Prompt + wire_blocks), mirroring the `InjectSkill` enqueue.
fn running_agent_with_local_skill(display: &str, wire: &str) -> AgentView {
use agent_client_protocol as acp;
let mut agent = running_agent_local_only();
agent.session.pending_prompts.clear();
let id = agent.session.next_queue_id;
agent.session.next_queue_id += 1;
agent
.session
.pending_prompts
.push_back(crate::app::agent::QueuedPrompt {
wire_blocks: Some(vec![acp::ContentBlock::Text(acp::TextContent::new(wire))]),
display_as_skill: true,
..crate::app::agent::QueuedPrompt::plain(
id,
display,
crate::app::agent::QueueEntryKind::Prompt,
)
});
agent.queue.sync_from_merged(
&agent.session.pending_prompts,
&agent.shared_queue,
agent.session.current_prompt_id.as_deref(),
agent.expect_send_now_cancel.as_deref(),
&agent.send_now_painted_blocks,
);
agent
}
/// Force-sending a raw skill row (wire payload == display text, the ACP
/// skill-command shape) interjects its slash text — the shell expands it
/// at the interjection drain.
#[test]
fn force_interject_local_raw_skill_row_interjects_text() {
let mut agent = running_agent_with_local_skill("/find-session", "/find-session");
let registry = non_vscode_registry();
let ids = agent.queue.entry_ids();
assert_eq!(ids.len(), 1);
agent.queue.list_state.select_by_id(ids[0]);
let outcome = agent.handle_queue_key(&force_interject_key(), &registry);
match outcome {
InputOutcome::Action(Action::SendPromptNow { text, .. }) => {
assert_eq!(text, "/find-session")
}
other => panic!("expected SendPromptNow action, got {other:?}"),
}
assert!(
agent.session.pending_prompts.is_empty(),
"row must leave the queue"
);
}
/// A client-expanded row (`/imagine`-shaped: wire payload != display
/// text) stays queued — interjecting it would send the display text,
/// not the payload.
#[test]
fn force_interject_local_expanded_row_keeps_it_queued() {
let mut agent =
running_agent_with_local_skill("/imagine a cat", "<expanded imagine instructions>");
let registry = non_vscode_registry();
let ids = agent.queue.entry_ids();
agent.queue.list_state.select_by_id(ids[0]);
let outcome = agent.handle_queue_key(&force_interject_key(), &registry);
assert!(
matches!(outcome, InputOutcome::Changed),
"expanded-payload force-send must be a guarded no-op, got {outcome:?}"
);
assert_eq!(agent.session.pending_prompts.len(), 1, "row must stay");
assert!(agent.toast.is_some(), "guard must explain itself");
}
/// The reported bug: empty-Enter send-now on a queued raw skill row
/// (`/find-session` queued as a mid-turn follow-up) must interject it
/// instead of toasting "Can't send this mid-turn".
#[test]
fn enter_empty_from_prompt_sends_raw_skill_top_row() {
let mut agent = running_agent_with_local_skill("/find-session", "/find-session");
agent.active_pane = AgentPane::Prompt;
agent.queue.overlay.focused = false;
agent.prompt.set_text("");
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let outcome = agent.handle_prompt_key_for_test(&enter);
match outcome {
InputOutcome::Action(Action::SendPromptNow { text, .. }) => {
assert_eq!(text, "/find-session")
}
other => panic!("expected SendPromptNow action, got {other:?}"),
}
assert!(agent.session.pending_prompts.is_empty());
}
/// Composer interject carries pasted images on the action — no
/// "not supported" toast, and the composer image list is drained.
#[test]
fn interject_key_normal_mode_carries_composer_images() {
let mut agent = make_running_agent();
agent.prompt.set_text("look at this");
let len = agent.prompt.textarea().text().len();
agent.prompt.textarea.set_cursor(len);
agent.prompt.insert_image(test_pasted_image()).unwrap();
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
match outcome {
InputOutcome::Action(Action::SendPromptNow { images, .. }) => {
assert_eq!(images.len(), 1);
}
other => panic!("expected SendPromptNow with images, got {other:?}"),
}
assert!(agent.prompt.images.is_empty());
assert!(agent.toast.is_none(), "no drop toast expected");
assert_eq!(agent.prompt.text(), "");
}
/// Force-interject with no turn running is a guarded no-op (toast only) — it
/// must never emit a server interject for an idle session.
#[test]
fn force_interject_noop_when_idle() {
let mut agent = make_running_agent();
agent.session.state = AgentState::Idle;
let registry = non_vscode_registry();
let ids = agent.queue.entry_ids();
agent.queue.list_state.select_by_id(ids[0]);
let outcome = agent.handle_queue_key(&force_interject_key(), &registry);
assert!(
matches!(outcome, InputOutcome::Changed),
"idle force-interject must be a no-op, got {outcome:?}"
);
// Nothing left the queue.
assert_eq!(agent.shared_queue.len(), 1);
assert_eq!(agent.session.pending_prompts.len(), 1);
}
/// Reordering a Server row emits `Action::QueueReorderShared` with the
/// swapped server id order.
#[test]
fn swap_up_routes_server_reorder() {
let mut agent = make_running_agent();
// Two server rows so a swap is possible.
agent.shared_queue = vec![
QueueEntryWire {
id: "p1".into(),
version: 0,
owner: None,
last_editor: None,
kind: "prompt".into(),
text: "first".into(),
position: 0,
},
QueueEntryWire {
id: "p2".into(),
version: 0,
owner: None,
last_editor: None,
kind: "prompt".into(),
text: "second".into(),
position: 1,
},
];
agent.session.pending_prompts.clear();
agent.queue.sync_from_merged(
&agent.session.pending_prompts,
&agent.shared_queue,
None,
None,
&agent.send_now_painted_blocks,
);
agent.queue.overlay.visible = true;
agent.queue.overlay.focused = true;
let registry = ActionRegistry::defaults();
// Select the second server row and swap it up.
let ids = agent.queue.entry_ids();
agent.queue.list_state.select_by_id(ids[1]);
let outcome = agent.handle_queue_key(
&KeyEvent::new(KeyCode::Char('K'), KeyModifiers::SHIFT),
&registry,
);
match outcome {
InputOutcome::Action(Action::QueueReorderShared { ordered_ids }) => {
assert_eq!(ordered_ids, vec!["p2".to_string(), "p1".to_string()]);
}
other => panic!("expected QueueReorderShared action, got {other:?}"),
}
}
#[test]
fn server_reorder_keeps_send_now_echo_in_ordered_ids() {
let mut agent = make_running_agent();
agent.session.current_prompt_id = Some("running".into());
agent.expect_send_now_cancel = Some("send-now".into());
agent.shared_queue = vec![
server_wire("send-now", 0),
server_wire("held-1", 1),
server_wire("held-2", 2),
];
agent.session.pending_prompts.clear();
agent.sync_queue_pane();
let ids = agent.queue.entry_ids();
assert_eq!(ids.len(), 2, "pane hides the send-now echo");
assert!(
agent.server_queue_reordered(ids[0], true).is_none(),
"SwapUp on first visible held must not demote send-now"
);
agent.queue.list_state.select_by_id(ids[1]);
let ordered = agent
.server_queue_reordered(ids[1], true)
.expect("swap up among held rows");
assert_eq!(
ordered,
vec![
"send-now".to_string(),
"held-2".to_string(),
"held-1".to_string(),
],
"send-now must stay front-most among queueable server rows"
);
}
/// Normal-mode interject: the InterjectPrompt arm owns the composer
/// clear — the text came from the composer, so it is cleared at the
/// call site (dispatch never touches the composer).
#[test]
fn interject_key_normal_mode_clears_composer_at_handler() {
let mut agent = make_running_agent();
agent.prompt.set_text("hello there");
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
match outcome {
InputOutcome::Action(Action::SendPromptNow { text, .. }) => {
assert_eq!(text, "hello there")
}
other => panic!("expected Interject, got {other:?}"),
}
assert_eq!(agent.prompt.text(), "");
}
/// Idle interject key: with no running turn there's nothing to interject
/// into — the key is a no-op (does not send like Enter).
#[test]
fn interject_key_when_idle_is_noop() {
let mut agent = make_running_agent();
agent.session.state = AgentState::Idle;
agent.prompt.set_text("hello there");
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
assert!(
matches!(outcome, InputOutcome::Changed),
"idle interject must be a no-op, got {outcome:?}"
);
assert_eq!(agent.prompt.text(), "hello there");
}
/// Running turn but empty composer and empty queue: interject key is a no-op.
#[test]
fn interject_key_when_running_empty_is_noop() {
let mut agent = make_running_agent();
// make_running_agent seeds a local queued row — clear it so this test
// only covers the empty-composer + empty-queue no-op.
agent.session.pending_prompts.clear();
agent.shared_queue.clear();
agent.queue.sync_from_merged(
&agent.session.pending_prompts,
&[],
None,
None,
&Default::default(),
);
agent.prompt.set_text(" ");
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
assert!(
matches!(outcome, InputOutcome::Changed),
"empty interject must be a no-op, got {outcome:?}"
);
}
/// Empty composer + mid-turn queue: send-now from the *prompt* force-sends
/// the top queued follow-up (no need to focus the queue pane) and keeps
/// Prompt focus even when the pane hides.
#[test]
fn interject_key_from_prompt_force_sends_top_queued_when_empty() {
let mut agent = running_agent_local_only();
agent.active_pane = AgentPane::Prompt;
agent.queue.overlay.focused = false;
agent.prompt.set_text("");
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
match outcome {
InputOutcome::Action(Action::SendPromptNow { text, .. }) => {
assert_eq!(text, "local one");
}
other => panic!("expected Interject of queued follow-up, got {other:?}"),
}
assert!(
agent.session.pending_prompts.is_empty(),
"queued row must be consumed"
);
assert_eq!(
agent.active_pane,
AgentPane::Prompt,
"prompt-path send-now must not steal focus to scrollback"
);
}
/// Bare Enter on an empty prompt mid-turn force-sends the top queued row
/// (same path as the interject chord with an empty composer).
#[test]
fn enter_empty_from_prompt_force_sends_top_queued() {
let mut agent = running_agent_local_only();
agent.active_pane = AgentPane::Prompt;
agent.queue.overlay.focused = false;
agent.prompt.set_text("");
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let outcome = agent.handle_prompt_key_for_test(&enter);
match outcome {
InputOutcome::Action(Action::SendPromptNow { text, .. }) => {
assert_eq!(text, "local one");
}
other => panic!("expected Interject of top queued follow-up, got {other:?}"),
}
assert!(
agent.session.pending_prompts.is_empty(),
"queued row must be consumed"
);
assert_eq!(
agent.active_pane,
AgentPane::Prompt,
"empty-Enter send-now must not steal focus to scrollback"
);
}
/// Multiline mode: empty bare Enter still send-nows (does not insert a
/// blank line). Enter-with-text remains newline-only in multiline.
#[test]
fn multiline_enter_empty_from_prompt_force_sends_top_queued() {
let mut agent = running_agent_local_only();
agent.multiline_mode = true;
agent.active_pane = AgentPane::Prompt;
agent.queue.overlay.focused = false;
agent.prompt.set_text("");
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let outcome = agent.handle_prompt_key_for_test(&enter);
match outcome {
InputOutcome::Action(Action::SendPromptNow { text, .. }) => {
assert_eq!(text, "local one");
}
other => panic!("multiline empty Enter must send-now top queued row, got {other:?}"),
}
assert!(
agent.session.pending_prompts.is_empty(),
"queued row must be consumed"
);
assert_eq!(
agent.prompt.text(),
"",
"send-now must not leave a blank line in the composer"
);
}
/// Multiline + non-empty composer: bare Enter still inserts a newline
/// (does not queue/send), even mid-turn with a queue present.
#[test]
fn multiline_enter_with_text_inserts_newline_not_send_now() {
let mut agent = running_agent_local_only();
agent.multiline_mode = true;
agent.active_pane = AgentPane::Prompt;
agent.queue.overlay.focused = false;
agent.prompt.set_text("draft line");
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let outcome = agent.handle_prompt_key_for_test(&enter);
assert!(
matches!(outcome, InputOutcome::Changed),
"multiline Enter with text must insert newline, got {outcome:?}"
);
assert!(
agent.prompt.text().contains('\n'),
"expected newline insertion, got {:?}",
agent.prompt.text()
);
assert_eq!(
agent.session.pending_prompts.len(),
1,
"queued follow-up must remain (text Enter is not send-now)"
);
}
/// When the composer has text, that wins over a queued follow-up.
#[test]
fn interject_key_composer_text_wins_over_queued_follow_up() {
let mut agent = running_agent_local_only();
agent.active_pane = AgentPane::Prompt;
agent.prompt.set_text("composer wins");
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
match outcome {
InputOutcome::Action(Action::SendPromptNow { text, .. }) => {
assert_eq!(text, "composer wins");
}
other => panic!("expected composer Interject, got {other:?}"),
}
assert_eq!(
agent.session.pending_prompts.len(),
1,
"queue must stay when composer text is interjected"
);
}
/// Prompt-path send-now always takes the top visible row (merge order),
/// even if a later row is selected in the queue pane.
#[test]
fn interject_key_from_prompt_ignores_selection_sends_top() {
let mut agent = make_running_agent();
agent.active_pane = AgentPane::Prompt;
agent.queue.overlay.focused = false;
agent.prompt.set_text("");
// Select the local row (last in merge order); top is server.
let ids = agent.queue.entry_ids();
assert!(ids.len() >= 2);
agent.queue.list_state.select_by_id(*ids.last().unwrap());
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
match outcome {
InputOutcome::Action(Action::QueueInterjectShared { id, .. }) => {
assert_eq!(
id, "p1",
"prompt-path must send top (server), not selected local"
);
}
other => panic!("expected QueueInterjectShared of top server row, got {other:?}"),
}
}
/// Bare Enter empty with multi-row queue also sends the top row, not the
/// last or selected one.
#[test]
fn enter_empty_from_prompt_sends_top_not_last() {
let mut agent = make_running_agent();
agent.active_pane = AgentPane::Prompt;
agent.queue.overlay.focused = false;
agent.prompt.set_text("");
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let outcome = agent.handle_prompt_key_for_test(&enter);
match outcome {
InputOutcome::Action(Action::QueueInterjectShared { id, .. }) => {
assert_eq!(id, "p1", "empty Enter must send top (server) row");
}
other => panic!("expected QueueInterjectShared of top server row, got {other:?}"),
}
}
/// Backslash continuation mid-turn must only insert the newline — it must
/// NOT be mistaken for an empty composer and force-send a queued follow-up.
/// `try_send()` returns `None` in both the empty and continuation cases, so
/// the send-now path is guarded on an actually-empty composer.
#[test]
fn enter_backslash_continuation_does_not_force_send_queued() {
let mut agent = running_agent_local_only();
agent.active_pane = AgentPane::Prompt;
agent.queue.overlay.focused = false;
// Trailing backslash with the cursor at end (insert_str advances it).
agent.prompt.set_text("");
agent.prompt.textarea.insert_str("wip\\");
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let outcome = agent.handle_prompt_key_for_test(&enter);
assert!(
matches!(outcome, InputOutcome::Changed),
"backslash continuation must insert a newline, not fire send-now; got {outcome:?}"
);
assert_eq!(
agent.prompt.text(),
"wip\n",
"the backslash must be replaced with a newline (continuation applied)"
);
assert_eq!(
agent.session.pending_prompts.len(),
1,
"queued follow-up must remain (continuation is not send-now)"
);
}
/// VS Code family: Ctrl+L interjects when running + nonempty (pinned registry).
#[test]
fn vscode_ctrl_l_interjects_when_running_nonempty() {
let mut agent = make_running_agent();
agent.prompt.set_text("steer please");
let registry = vscode_family_registry();
let outcome =
agent.handle_prompt_key_with_registry_for_test(&vscode_interject_key(), &registry);
match outcome {
InputOutcome::Action(Action::SendPromptNow { text, .. }) => {
assert_eq!(text, "steer please")
}
other => panic!("expected Interject, got {other:?}"),
}
}
/// VS Code family: idle Ctrl+L is a no-op (not send, not extensions).
#[test]
fn vscode_ctrl_l_idle_is_noop() {
let mut agent = make_running_agent();
agent.session.state = AgentState::Idle;
agent.prompt.set_text("draft");
let registry = vscode_family_registry();
let outcome =
agent.handle_prompt_key_with_registry_for_test(&vscode_interject_key(), &registry);
assert!(
matches!(outcome, InputOutcome::Changed),
"idle VS Ctrl+L must be a no-op, got {outcome:?}"
);
assert_eq!(agent.prompt.text(), "draft");
}
/// VS Code family queue force-interject uses Ctrl+L (not Ctrl+Enter).
#[test]
fn vscode_ctrl_l_force_interjects_queue_row() {
let mut agent = make_running_agent();
agent.active_pane = AgentPane::Queue;
let registry = vscode_family_registry();
let ids = agent.queue.entry_ids();
agent.queue.list_state.select_by_id(ids[1]);
let outcome = agent.handle_queue_key(&vscode_interject_key(), &registry);
match outcome {
InputOutcome::Action(Action::SendPromptNow { text, .. }) => {
assert_eq!(text, "local one")
}
other => panic!("expected Interject, got {other:?}"),
}
// Ctrl+Enter must not force-interject on VS family (no alt).
let outcome = agent.handle_queue_key(&force_interject_key(), &registry);
assert!(
!matches!(outcome, InputOutcome::Action(Action::Interject { .. })),
"Ctrl+Enter must not be VS force-interject, got {outcome:?}"
);
}
}
#[cfg(test)]
mod end_work_tests {
use super::super::test_agent_view;
use crate::scrollback::blocks::EndWork;
fn insert_bg_task(
agent: &mut crate::app::agent_view::AgentView,
task_id: &str,
is_monitor: bool,
) {
agent.session.bg_tasks.insert(
task_id.into(),
crate::app::agent::BgTaskState {
task_id: task_id.into(),
tool_call_id: format!("call-{task_id}"),
command: "sleep 5".into(),
description: None,
cwd: "/tmp".into(),
output_file: "/tmp/out".into(),
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,
restored_from_replay: false,
},
);
}
#[test]
fn current_end_work_counts_monitors_apart_from_commands() {
let mut agent = test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"));
insert_bg_task(&mut agent, "bg-1", false);
insert_bg_task(&mut agent, "mon-1", true);
insert_bg_task(&mut agent, "done-1", false);
agent.session.bg_tasks.get_mut("done-1").unwrap().status =
crate::app::agent::BgTaskStatus::Done;
assert_eq!(
agent.current_end_work(),
EndWork {
running_commands: 1,
running_monitors: 1,
running_subagents: 0,
}
);
}
}