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).
1858 lines
77 KiB
Rust
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(), ®istry);
|
|
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(), ®istry);
|
|
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(), ®istry);
|
|
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(), ®istry);
|
|
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(), ®istry);
|
|
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(), ®istry);
|
|
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(), ®istry);
|
|
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(), ®istry);
|
|
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(), ®istry);
|
|
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(), ®istry);
|
|
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(), ®istry);
|
|
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(), ®istry);
|
|
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(), ®istry);
|
|
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(), ®istry);
|
|
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(), ®istry);
|
|
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),
|
|
®istry,
|
|
);
|
|
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(), ®istry);
|
|
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(), ®istry);
|
|
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(), ®istry);
|
|
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(), ®istry);
|
|
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,
|
|
}
|
|
);
|
|
}
|
|
}
|