18 KiB
Queue terminal commands alongside prompts — Tech Spec
See specs/gh-11912/PRODUCT.md for user-visible behavior. This layers command support onto the v2 queued-prompts machinery from specs/REMOTE-1543/ and specs/APP-4562/.
Context
The queued-prompts queue is an app-wide singleton, QueuedQueryModel (app/src/ai/blocklist/queued_query.rs), keyed by AIConversationId. Each conversation has a ConversationQueueState { queue: Vec<QueuedQuery>, editing, queue_next_prompt_override, command_in_flight }. A QueuedQuery carries id, text, origin: QueuedQueryOrigin, and a kind enum that is either a prompt with attachments or a command without attachments (app/src/ai/blocklist/queued_query.rs:47). Draining is mediated by peek_autofire → AutofireAction → remove_fired_row so the send path can read a row's attachments before the row is removed (app/src/ai/blocklist/queued_query.rs:439, app/src/ai/blocklist/queued_query.rs:468).
Submit/queue flow:
- The enter handler tries queue gates before executing:
maybe_queue_input_for_in_progress_conversationthenmaybe_queue_input_during_cloud_setup, then slash-command handling and the shell-execution fallback (app/src/terminal/input.rs:12871-12874, shell execution atapp/src/terminal/input.rs:13096). - Before #11912, shell mode made
maybe_queue_input_for_in_progress_conversationreturn false, so a shell command fell through totry_execute_commandand cancelled any in-progress conversation. The implemented gate now computesis_commandfrom the input mode (app/src/terminal/input.rs:13552) and allows shell command rows under theQueuedPromptsV2gate. try_execute_command(app/src/terminal/input.rs:6855) routes correctly per pane: for a shared-session viewer/executor (cloud-mode pane) it sends the command to the sharer viatry_execute_command_on_behalf_of_shared_session_participant; otherwise it runs locally viatry_execute_command_from_source.
Drain + submit:
TerminalView::handle_finished_conversation(app/src/terminal/view.rs:4804) →drain_queued_prompts(app/src/terminal/view.rs:5301). The clean path peeks the head and, forAutofireAction::Submit, callsInput::submit_queued_prompt_for_active_pane(app/src/terminal/input.rs:13453, routes cloud-followup / shared-session-viewer / local). The non-clean path (Error|Cancelled) restores the head into the editor viapop_front.- Local finish detection:
handle_ai_controller_event,FinishedReceivingOutputarm (app/src/terminal/view.rs:5139). It already defers finishing whilehas_active_subagent()is true (app/src/terminal/view.rs:5149,app/src/ai/agent/conversation.rs:3079) — this is the LRC guard. - Cloud finish detection:
handle_ai_history_model_event,UpdatedConversationStatusarm (app/src/terminal/view.rs:6163), gated onQueuedPromptsV2+is_ambient_agent_session, draining only on a transition to a terminal status.
Command completion signals (the new draining inputs) — local and shared-session are distinct and must both be handled:
- Local:
ModelEvent::AfterBlockCompletedis handled inTerminalView(app/src/terminal/view.rs:12020); the completed block isblock_type, andBlockType::User(UserBlockCompleted { serialized_block, was_part_of_agent_interaction, .. })exposes success viaserialized_block.exit_code.was_successful()(app/src/terminal/view.rs:12123). Agent-executed commands areBlockType::Userwithwas_part_of_agent_interaction == true. - Shared session / cloud: the sharer emits
OrderedTerminalEventType::CommandExecutionFinished { next_block_id }fromcommand_finished(app/src/terminal/model/terminal_model.rs:2829) on every command completion. The viewer event loop receives it and advances queued commands throughTerminalView::on_queued_command_finished(app/src/terminal/shared_session/viewer/event_loop.rs:292). This is the authoritative remote-command-finished signal for a cloud/viewer pane — it does not depend on the indirect, byte-parse-driven block completion on the viewer. (The viewer already keys its own per-command cleanup ofpending_command_execution_requestoff the synced block completion inInput::handle_block_completed_event,app/src/terminal/input.rs:14472.)
Panel: app/src/terminal/view/queued_prompts_panel.rs renders each row; the row render is where the ! prefix is added.
Feature flag: FeatureFlag::QueuedPromptsV2 (already wired).
Proposed changes
1. Model: a queued item is a prompt or a command (queued_query.rs)
Replace the flat attachments field with a kind enum so attachments are structurally impossible on a command:
pub enum QueuedQueryKind {
/// An agent prompt; may carry image/file attachments captured from the input.
Prompt { attachments: Vec<PendingAttachment> },
/// A shell command run in the terminal / via the shared session. Cannot hold attachments.
Command,
}
QueuedQuerykeeps the sharedid,text,originand gainskind: QueuedQueryKind.textstays shared (both kinds have a string); onlyattachmentsmove into thePromptvariant, so aCommandhas nowhere to put them. UseVec(notOption<Vec>): an empty vec already means "no attachments", soOptionwould add a redundantNone-vs-empty state.- Accessors:
is_command()=matches!(self.kind, QueuedQueryKind::Command);attachments()returns thePromptvariant's slice or&[]for aCommand. Constructors: keepnew/new_with_attachments(buildPrompt), addnew_command(text)(buildsCommand).originstays telemetry-only; firing branches onkind, notorigin. - Commands are normal mutable rows:
is_locked()stays false for them, so reorder/edit/delete/pop_frontbehave exactly as for prompts. - Extend the drain action: add
AutofireAction::ExecuteCommand { query_id, command }.peek_autofire(app/src/ai/blocklist/queued_query.rs:439) returnsExecuteCommandfor aCommandhead, andSubmit/PopFromEditModeotherwise. - Track the in-flight command on
ConversationQueueState: addcommand_in_flight: boolwitharm_command_in_flight,clear_command_in_flight, andhas_command_in_flight(conversation_id) -> bool(app/src/ai/blocklist/queued_query.rs:314). Storing this on the singleton lets theInputgate read it and bothTerminalViewand the viewer event loop set/clear it. Clear it indrop_conversationso lifecycle cleanup (app/src/ai/blocklist/queued_query.rs:286) resets it.
2. Queue gate: allow shell submissions to queue (input.rs)
In maybe_queue_input_for_in_progress_conversation (app/src/terminal/input.rs:13541):
- Remove the
!is_ai_input_enabled()early return. Computelet is_command = !self.ai_input_model.as_ref(ctx).is_ai_input_enabled();up front. - Extend the in-progress predicate so the queue stays active while a command runs (PRODUCT §14): queue when the conversation is in-progress/blocked or
QueuedQueryModel::as_ref(ctx).has_command_in_flight(conversation_id). - The
is_queue_next_prompt_enabled/ summarizing gate is unchanged: queuing is allowed when the auto-queue toggle is on, or when the conversation is summarizing underQueuedPromptsV2(PRODUCT §2, §7). - The
/queue-argument unwrap only applies in AI mode (a shell command won't matchcommands::QUEUE); skip it foris_command. - Append
QueuedQuery::new_command(prompt)whenis_command, else the existing prompt row. Do not take pending attachments for commands.
Because the enter handler already calls this gate before the shell-execution branch (app/src/terminal/input.rs:12871), a queued command never reaches try_execute_command, so the interrupt-the-agent path is bypassed. No change to maybe_queue_input_during_cloud_setup (queuing-while-running is the in-progress gate, not the setup gate).
3. Drain dispatch: execute commands (view.rs, input.rs)
In drain_queued_prompts (app/src/terminal/view.rs:5301), FinishReason::Complete arm, handle the new action:
AutofireAction::ExecuteCommand { query_id, command }: callremove_fired_row(row disappears immediately, PRODUCT §10), then dispatch viaInput::execute_queued_command(command, conversation_id, ctx). That helper uses a queued-command execution source (localQueuedCommand, or shared-sessionpreserve_input) so terminal routing is reused without treating the current editor buffer as the submitted command. It armscommand_in_flightonly after dispatch starts. Do not peek/drain the next item here — the next item fires on block completion (§4). If dispatch returns false (cannot execute, e.g. precmd not yet received), restorecommandinto the editor in shell mode if the editor is empty, matching the non-clean restore, so the queue never wedges.Submitarm unchanged.PopFromEditModenow carriesis_command(set fromfirst.is_command()inpeek_autofire): when restoring a command's text into an empty input, force shell mode viaInput::set_input_mode_terminal(true, ctx)and skip the attachment restore (commands have none), so an edited command that the clean drain pops into the input stays a command instead of being submitted as an agent prompt (PRODUCT §5).
The non-clean arm (Error|Cancelled) restores the head via pop_front + replace_buffer_content(query.text()); when query.is_command(), it also forces shell mode via set_input_mode_terminal(false, ctx) and skips the attachment restore, so a restored command stays a command (PRODUCT §17).
4. Command-finished trigger advances the queue (view.rs, viewer/event_loop.rs)
Add one shared entry point on TerminalView, on_queued_command_finished(&mut self, ctx): find the conversation owned by this terminal view whose queue has command_in_flight armed, call clear_command_in_flight(conv), then self.drain_queued_prompts(conv, FinishReason::Complete, ctx). Resolve the armed owner rather than the pane's currently active conversation so a conversation switch while the command runs does not wedge the original queue. Passing Complete regardless of exit status implements PRODUCT §12; going through drain_queued_prompts (not handle_finished_conversation) avoids re-running conversation-finished callbacks.
Fire it from both command-finished sources so local and cloud panes are each covered by their authoritative signal:
- Local — in the
ModelEvent::AfterBlockCompletedhandler (app/src/terminal/view.rs:12020), after the existingpending_command_queuehandling, whenblock_typeisBlockType::User(b)with!b.was_part_of_agent_interaction, callself.on_queued_command_finished(ctx)(app/src/terminal/view.rs:12166). - Shared session / cloud — handle
OrderedTerminalEventType::CommandExecutionFinishedin the viewer event loop (app/src/terminal/shared_session/viewer/event_loop.rs:292):self.terminal_view.upgrade(ctx)thenview.update(.., |view, ctx| view.on_queued_command_finished(ctx)). - Both are gated on the armed
command_in_flightflag, andon_queued_command_finishedclears it before draining, so it is naturally idempotent: if a viewer happens to observe both a syncedAfterBlockCompletedand aCommandExecutionFinished, only the first advances the queue (the second sees no armed flag and no-ops). The armed-only gate also keeps agent-executed command blocks / LRC snapshots from advancing the queue — they complete while the flag is unarmed, since the agent is idle whenever a queued command is in flight (PRODUCT §16). The same armed flag suppresses editor freeze/clear behavior on queued command start and completion, preserving any draft the user is composing. When the next item is a prompt,submit_queued_prompt_for_active_paneroutes it to the cloud-followup / local path as today.
5. LRC handling (no code change, documented)
For queued prompts that trigger an agent LRC, the existing has_active_subagent() guard in the FinishedReceivingOutput arm (app/src/terminal/view.rs:5149) already defers handle_finished_conversation until the subagent/LRC turn truly ends (PRODUCT §15); the cloud UpdatedConversationStatus path only drains on a terminal status, which the agent won't report mid-LRC. The new command-finished trigger (§4) cannot fire during an LRC because it is armed only while a queued command is in flight, i.e. when the agent is idle. No new code is required, but this reasoning is the reason the trigger is gated on the armed flag rather than on every User block completion.
6. Panel: blue ! prefix on command rows (queued_prompts_panel.rs)
In the row render, when query.is_command(), render a leading ! glyph before the preview text using the same blue color token the input uses for the shell-mode ! prefix (locate the shell-prefix color in the input/editor styling and reuse the token rather than hard-coding a color). All other affordances (drag handle, edit/delete buttons, collapse) are unchanged (PRODUCT §4, §5).
End-to-end flow (queue = [prompt A, command B, prompt C])
flowchart LR
A["A submitted to agent"] --> Afin["agent turn finishes (FinishedReceivingOutput / UpdatedConversationStatus)"]
Afin --> DB["drain_queued_prompts → ExecuteCommand B"]
DB --> Bexec["Input::execute_queued_command → preserve-input command source; arm command_in_flight"]
Bexec --> Bdone["command finished: AfterBlockCompleted (local) OR CommandExecutionFinished (cloud)"]
Bdone --> DC["on_queued_command_finished: clear flag → drain → Submit C"]
DC --> Cexec["submit_queued_prompt_for_active_pane (agent / cloud followup)"]
Testing and validation
Current automated coverage maps to PRODUCT.md invariants as follows; follow repo test conventions (*_tests.rs included via #[path]).
- §1 (flag) —
app/src/terminal/input_tests.rs:shell_submission_is_not_queued_when_v2_disabledasserts shell submissions do not become command rows whenQueuedPromptsV2is off. - §2, §7, §14 (gating) —
app/src/terminal/input_tests.rs:shell_submission_queues_as_command_row_when_gated_under_v2asserts shell submissions queue asCommandrows whilehas_command_in_flightkeeps the queue active even though the agent is idle. - §3–§5 (model) —
app/src/ai/blocklist/queued_query_tests.rs: command rows are structurally commands with empty attachments;peek_autofireyieldsExecuteCommandfor a command head; edited command heads pop withis_command; command rows are reorderable/editable/deletable;arm/clear/has_command_in_flight; conversation deletion clears in-flight state. - §5, §17 (restore keeps command kind) —
app/src/terminal/view/queued_prompts_tests.rsandapp/src/terminal/input_tests.rs: clean drain of an edited command, non-clean drain of a command, and delete-row restore all put the command text back in shell mode. - §9, §10, §14, §19 (dispatch and draft preservation) —
app/src/terminal/input_tests.rs,app/src/terminal/shared_session/viewer/event_loop_tests.rs, andapp/src/terminal/shared_session/viewer/terminal_manager_tests.rs: send-now command dispatch removes the row, arms in-flight state, and preserves drafts; queued command completion preserves local drafts; shared-sessionCommandExecutionStartedpreserves drafts while a queued command is armed;CommandExecutionFinishedclears in-flight state idempotently even after conversation switches; command request failure clears in-flight state. - §18 (lifecycle) —
app/src/ai/blocklist/queued_query_tests.rs: conversation deletion and terminal-view clearing drop queued state, including in-flight command state. - Behavior covered by implementation review rather than current focused tests —
app/src/terminal/view.rs: the clean drain command-head path removes the row, dispatchesexecute_queued_command, and does not drain the next item until completion;on_queued_command_finishedpassesFinishReason::Completeregardless of exit status; the local completion hook filterswas_part_of_agent_interaction;FinishedReceivingOutputstill uses thehas_active_subagent()guard for LRC turns. - Full
./script/formatandcargo clippy(per WARP.md) before PR; run the focused queue test suites (queued_query,queued_prompts, input/view tests). Do not run the app to test.
Parallelization
Single workstream. The change threads one set of shared types (QueuedQueryKind, AutofireAction, command_in_flight) through the model, the input gate, the view drain, both command-finished triggers, and the panel render; every edit depends on the model change landing first, and the files (queued_query.rs, input.rs, view.rs, shared_session/viewer/event_loop.rs, queued_prompts_panel.rs) overlap heavily with the in-flight changes. Splitting across child agents would create merge churn on the same files without reducing wall-clock time, so it is implemented directly.
Risks and mitigations
- Queue wedges if a command never starts: only arm
command_in_flightwhentry_execute_commandreturns true; on false, clear and restore text (§3). - Double-drain: the command-finished trigger is armed only during command-in-flight (agent idle), and the conversation-finish paths won't re-fire (the conversation already reached a terminal status before the command ran).
- Spurious advance from agent command blocks: filtered by
!was_part_of_agent_interaction(local) plus the armed flag, which is only set while the agent is idle (§4, §16). - Viewer completion reliability: cloud panes advance on the explicit
OrderedTerminalEventType::CommandExecutionFinishedevent rather than the indirect, byte-parse-driven block completion on the viewer. If both a syncedAfterBlockCompletedand aCommandExecutionFinishedreach a viewer, the armed-flag clear inon_queued_command_finisheddedupes them; validated by the §19 cloud test. originvskindconfusion:originstays telemetry-only; all firing decisions branch onkindto avoid conflating source with type.