//! Prompt and bash-command submission dispatchers and reload-window helpers. use super::auth::{scrollback_has_recent_context_too_large, scrollback_has_recent_reauth_prompt}; use super::ctx::with_active_agent; use super::interject; use super::permissions::drain_permission_queue; use super::queue::{ apply_turn_start_shim, drain_prompt_state_to_last_queued, immediate_server_send_eligible, maybe_drain_queue, push_server_queue_echo, retire_optimistic_echo, }; use super::router::dispatch; use super::session::fork::open_project_question; use super::session::lifecycle::skip_picker_and_create_session; use crate::app::actions::{Action, Effect}; use crate::app::agent::{AgentId, AgentState}; use crate::app::agent_view::AgentView; use crate::app::app_view::{ActiveView, AppView}; use crate::notifications::{NotificationEvent, NotificationEventKind}; use crate::scrollback::block::RenderBlock; use crate::scrollback::blocks::SessionEvent; use agent_client_protocol as acp; /// Chat kind for the next create: CLI `--chat` (`app.chat_mode`) or one-shot /// `/chat` (`deferred_startup.pending_chat`, consumed here). pub(super) fn consume_chat_kind(app: &mut AppView) -> bool { let pending = std::mem::take(&mut app.deferred_startup.pending_chat); app.chat_mode || pending } /// Enqueue a prompt and try to drain immediately. /// /// The prompt is always pushed to the queue first. If the agent is idle /// (and has a session), `maybe_drain_queue` pops the front prompt and /// sends it in the same dispatch call — no deferred ticks. /// Start (if needed) and submit the initial prompt from `grok ""`. /// /// Shared by the TUI startup path (already authenticated) and the post-login /// `AuthComplete` path (deferred via `deferred_startup.prompt`). It does nothing /// special for auth or session lifecycle: it reuses the exact `NewSession` / /// `SendPrompt` actions the welcome screen dispatches, so the normal /// session-creation + enqueue/drain machinery carries the prompt (the /// prompt waits in the queue until `SessionCreated`/`SessionLoaded` drains /// it). `NewSession` is only dispatched when no session is active yet — a /// `--resume`/`-c`/`-w` session started earlier in startup is reused. pub(crate) fn dispatch_initial_prompt(app: &mut AppView, prompt: String) -> Vec { let mut effects = Vec::new(); if !matches!(app.active_view, ActiveView::Agent(_)) { effects.extend(dispatch(Action::NewSession, app)); } effects.extend(dispatch(Action::SendPrompt(prompt), app)); effects } pub(super) fn dispatch_send_prompt(app: &mut AppView, text: String) -> Vec { crate::unified_log::info( "prompt.enqueue", None, Some(serde_json::json!({"len": text.len()})), ); dispatch_send_prompt_inner( app, text, /* consume_input */ true, /* literal */ false, /* is_follow_up */ false, ) } /// Clear the active prompt and record non-empty text in prompt history (Esc Esc). pub(super) fn dispatch_clear_prompt(app: &mut AppView) -> Vec { with_active_agent(app, |agent| { let text = agent.prompt.text().to_string(); // Same move-to-front / cap as send / interject. interject::record_interject_prompt_history(agent, &text); // Clears chips/images via PromptWidget::set_text empty path. agent.prompt.set_text(""); }); vec![] } /// Open the prompt-history search panel on the active agent (composer as /// filter query). Dispatched by `/history`; the slash pipeline has already /// cleared the composer, so the panel opens with an empty query. pub(super) fn dispatch_open_history_search(app: &mut AppView) -> Vec { with_active_agent(app, |agent| { let history = agent.combined_prompt_history(); let current_text = agent.prompt.text().to_string(); agent .prompt .history_search .activate(&history, ¤t_text); }); vec![] } /// Show the "ctrl+z to undo" hint after the user wiped a substantial draft. /// Gated by the per-tip `contextual_hints.undo` gate (default ON). The /// ephemeral-tip seen gate caps it at `UNDO_TIP_SEEN_CAP` shows per session /// (in-memory `app.tip_seen_counts`); nothing is persisted to disk. pub(super) fn dispatch_show_undo_tip(app: &mut AppView) -> Vec { if !app.contextual_hints.undo { return vec![]; } let ActiveView::Agent(id) = app.active_view else { return vec![]; }; let Some(agent) = app.agents.get_mut(&id) else { return vec![]; }; // Shows and increments the per-session count in place (no disk write). // Emit the impression only when the tip actually took the slot (mirrors // the `tip.shown` gate), so gated no-ops and TTL refreshes don't count. agent.show_ephemeral_tip( crate::tips::clear_detector::undo_tip(), &mut app.tip_seen_counts, ); vec![] } /// Show the one-shot "Tight on space? Try /compact-mode" hint after the first /// stable agent-view draw landed in the small-screen band (the trigger gates /// on band + user compact OFF; see `AppView::maybe_trigger_small_screen_tip`). /// Gated by the per-tip `contextual_hints.small_screen` gate (default ON). /// Seen-gated in-memory via `app.tip_seen_counts`; nothing persists to disk. /// /// Called directly from the draw-path trigger — not routed as an `Action`, /// so it returns `()` and "no effects from draw" holds structurally. pub(in crate::app) fn show_small_screen_tip(app: &mut AppView) { if !app.contextual_hints.small_screen { return; } let ActiveView::Agent(id) = app.active_view else { return; }; let Some(agent) = app.agents.get_mut(&id) else { return; }; // Impression only when the tip actually takes the slot (mirrors undo/plan). if agent.show_ephemeral_tip( crate::tips::small_screen::small_screen_tip(), &mut app.tip_seen_counts, ) {} } pub(super) fn dispatch_show_plan_nudge(app: &mut AppView) -> Vec { if !app.contextual_hints.plan_mode { return vec![]; } let ActiveView::Agent(id) = app.active_view else { return vec![]; }; let Some(agent) = app.agents.get_mut(&id) else { return vec![]; }; // Shows and increments the per-session count in place (no disk write). // Impression counts only on a real show (see `dispatch_show_undo_tip`). agent.show_ephemeral_tip( crate::tips::plan_nudge::plan_nudge_tip(), &mut app.tip_seen_counts, ); vec![] } /// After a fold/nav double-click on scrollback, tip that Word select lives in /// `/settings`. Gated by `contextual_hints.word_select` (default ON). pub(super) fn dispatch_show_word_select_tip(app: &mut AppView) -> Vec { if !app.contextual_hints.word_select { return vec![]; } // Already on word_select — tip would be wrong / redundant. if crate::appearance::cache::load_keep_text_selection().selects_word() { return vec![]; } let ActiveView::Agent(id) = app.active_view else { return vec![]; }; let Some(agent) = app.agents.get_mut(&id) else { return vec![]; }; agent.show_ephemeral_tip( crate::tips::word_select::word_select_tip(), &mut app.tip_seen_counts, ); // Snapshot the prompt as of this double-click (also on a same-key TTL // refresh — a new double-click is a new moment). Any later divergence // (typed, pasted, dropped) refuses the chord and retires the tip; a // seen-cap-gated no-show leaves the slot to another tip and skips this. if agent.ephemeral_tip.current_key() == Some(crate::tips::word_select::WORD_SELECT_TIP_KEY) { agent.word_select_tip_prompt_snapshot = Some(agent.prompt.text().to_string()); } vec![] } /// Accept the word-select tip via its advertised chord: flip /// `keep_text_selection` to `word_select` (cache + persist + toast, the same /// path as the settings modal) and retire the tip so one impression maps to /// at most one acceptance. No-op unless the tip is on screen — the chord is /// tip-scoped and must not become a global setting toggle. pub(super) fn dispatch_accept_word_select_tip(app: &mut AppView) -> Vec { let ActiveView::Agent(id) = app.active_view else { return vec![]; }; let Some(agent) = app.agents.get_mut(&id) else { return vec![]; }; if agent.ephemeral_tip.current_key() != Some(crate::tips::word_select::WORD_SELECT_TIP_KEY) { return vec![]; } agent .ephemeral_tip .clear(crate::tips::word_select::WORD_SELECT_TIP_KEY); agent.word_select_tip_prompt_snapshot = None; super::settings::setters::set_keep_text_selection( app, crate::appearance::TextSelection::WordSelect, ) } /// After queuing a follow-up mid-turn, tip that empty Enter force-sends the top /// queued item. Gated by the per-tip `contextual_hints.send_now` gate (default /// ON). Seen-gated in-memory via `app.tip_seen_counts`. fn maybe_show_send_now_tip(app: &mut AppView) { if !app.contextual_hints.send_now { return; } let ActiveView::Agent(id) = app.active_view else { return; }; let Some(agent) = app.agents.get_mut(&id) else { return; }; // Impression only when the tip actually takes the slot (mirrors undo/plan). if agent.show_ephemeral_tip( crate::tips::send_now::send_now_tip(), &mut app.tip_seen_counts, ) {} } /// Whether submitting `text` may open the project picker. Slash commands, /// `exit`/`quit` aliases, and empty input never send a prompt to the agent, /// so they must pass through untouched. pub(super) fn input_can_trigger_project_picker(text: &str) -> bool { let t = text.trim(); !t.is_empty() && !t.starts_with('/') && !t.starts_with('!') && !matches!(t, "exit" | "quit" | ":q" | ":q!" | ":wq" | ":wq!") } /// Body of [`dispatch_send_prompt`], parameterized over whether to consume /// the prompt textarea after the command is processed. /// /// `consume_input = true` (Enter from the prompt) wipes the textarea, drains /// pending prompt images into the queue, and inserts the text into the local /// up-arrow history. `consume_input = false` (modal-driven dispatch from the /// command palette or ArgPicker) preserves the user's draft, leaves prompt /// images attached, and skips the history insert. The slash-registry /// resolution and the downstream `Effect`s are identical in both cases. /// /// `literal = true` (follow-up chip click) submits `text` straight to the /// model: the slash-command, exit-alias, and project-picker branches are all /// skipped so server/model-controlled chip text can never execute a command /// nor be diverted into the project question. pub(super) fn dispatch_send_prompt_inner( app: &mut AppView, text: String, consume_input: bool, literal: bool, is_follow_up: bool, ) -> Vec { // Submitting is a fresh intent that retires any armed double-press. The // AppView pending-action check only resets on KEY events, so a submit with // no intervening key (mouse send, follow-up chip click `SubmitFollowUp`, // `SendSlashCommandPreservingDraft`) would otherwise leave a stale arm // (e.g. an idle-Esc `ClearPrompt`) that shadows the next Esc — firing stale // ClearPrompt|Rewind instead of the mid-turn swallow until TTL. Cleared in // the common funnel so every submit path is covered, before any early-return // guard below. app.pending_action = None; if app.reconnect_pending { app.show_toast("Reconnecting, please wait..."); return vec![]; } // The picker intercepts only real, user-authored prompts; slash commands, // exit aliases, empty input, and literal chip submissions pass through so // they never spawn it (a chip is a model suggestion for an already-running // session, not the first-prompt project choice). if !literal && input_can_trigger_project_picker(&text) && app.needs_project_picker() { return open_project_question(app, text); } let ActiveView::Agent(id) = app.active_view else { return vec![]; }; // Capture app-level fields before the mut-borrow on `agent`. let show_tips_from_app = app.show_tips; let auto_update_from_app = app.auto_update; let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds; let auto_mode_gate_from_app = app.auto_mode_gate; let ask_user_question_timeout_enabled_from_app = app.ask_user_question_timeout_enabled; // Set when a plain prompt is queued while a turn is running (local path); // shown after the agent borrow ends so we can re-enter via the tip helper. let mut tip_send_now_after_queue = false; let Some(agent) = app.agents.get_mut(&id) else { return vec![]; }; // Paste-then-immediate-send: an image probe from a just-pasted Cmd+V is // still off-thread. Stash this send and re-issue it once the probe completes // so the image is never dropped from the built content blocks. Scoped to // `consume_input` sends: only those clear the draft, so only they can drop a // not-yet-attached image — draft-preserving sends (follow-up chip, // slash-preserving) keep it in the draft for the next real send. if consume_input && agent.paste_probe_in_flight > 0 { agent.deferred_send = Some(crate::app::agent_view::AgentDeferredSend::SendPrompt); return vec![]; } // Submitting the prompt retires any edit-contextual ephemeral tip // (ambient tips live out their TTL across the submit). agent.ephemeral_tip.clear_on_submit(); let trimmed = text.trim(); let mut effects = Vec::new(); // ── Registry-based slash command execution ───────────────────── // If the text starts with `/`, run it through the slash registry. // The registry resolves builtins, ACP-advertised commands, and // unknown commands uniformly. Dispatch is the SOLE execution owner. // `literal` (chip click) skips this so chip text is never a command. if !literal && trimmed.starts_with('/') { use crate::slash::command::{CommandExecCtx, CommandResult}; use crate::slash::parse_invocation; // Build execution context. let exec_result = { let mut ctx = CommandExecCtx { models: &agent.session.models, session_id: agent.session.session_id.as_ref(), bundle_state: &app.bundle_state, screen_mode: app.screen_mode, // PAGER-owned snapshot for slash commands. pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: agent.multiline_mode, yolo_mode: agent.session.is_yolo(), auto_mode: agent.session.is_auto(), current_model_name: agent.session.models.current_model_name(), available_models: agent .session .models .available .iter() .map(|(id, info)| (info.name.clone(), id.clone())) .collect(), // Prefer optimistic pending over confirmed active. plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active), show_tips: show_tips_from_app, auto_update: auto_update_from_app, vim_mode: crate::appearance::cache::load_vim_mode(), scroll_speed: crate::appearance::cache::load_scroll_speed(), respect_manual_folds: respect_manual_folds_from_app, auto_mode_gate: auto_mode_gate_from_app, ask_user_question_timeout_enabled: ask_user_question_timeout_enabled_from_app, }, }; if let Some(invocation) = parse_invocation(trimmed) { // Bypasses only the menu-only hide (hard gates still // return `None`); see `CommandRegistry::get_for_dispatch`. let command = agent .prompt .slash_controller .registry() .get_for_dispatch(invocation.token) .cloned(); if let Some(command) = command { if ctx.screen_mode.is_minimal() && !command.available_in_minimal() { // Central minimal gate: commands that drive the deleted // fullscreen pane / dashboard (/find, /copy, /dashboard) // have nothing to act on in scrollback-native mode. // Surface a friendly system block instead of running them. CommandResult::Message(format!( "/{} is not available in minimal mode", invocation.token )) } else { agent .prompt .slash_controller .record_command_use(invocation.token, invocation.token); command.run(&mut ctx, invocation.args) } } else { // Unknown command -- pass through to shell. CommandResult::PassThrough(text.clone()) } } else { // Bare `/` or malformed -- pass through. CommandResult::PassThrough(text.clone()) } }; // Map CommandResult to pager behavior. (MRU persistence is queued // off-thread inside `record_command_use` above.) match exec_result { CommandResult::Handled | CommandResult::HandledNoOp => { if consume_input { agent.prompt.set_text(""); } return vec![]; } CommandResult::Error(msg) => { if consume_input { agent.prompt.set_text(""); } agent.scrollback.push_block(RenderBlock::system(msg)); return vec![]; } CommandResult::Message(msg) => { if consume_input { agent.prompt.set_text(""); } agent.scrollback.push_block(RenderBlock::system(msg)); return vec![]; } CommandResult::Action(Action::ExitSession) => { if consume_input { agent.prompt.set_text(""); } return dispatch(Action::ExitSession, app); } CommandResult::Action(action) => { if consume_input { agent.prompt.set_text(""); } return dispatch(action, app); } CommandResult::QueueCommand(cmd_text) => { agent.session.enqueue_command(cmd_text); } CommandResult::InjectSkill { display_text, prompt_blocks, display_as_skill, scheduled_task_preview, } => { // Enqueue with display text for scrollback but wire_blocks // for the actual prompt sent to the model. Leading skill // invocation: display_as_skill owns styling (no ranges). 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(prompt_blocks), display_as_skill, ..crate::app::agent::QueuedPrompt::plain( id, display_text, crate::app::agent::QueueEntryKind::Prompt, ) }); // Insert a provisional scheduled task so the tasks pane shows // it immediately, before the LLM round-trips through // scheduler_create. Keyed by a provisional ID; replaced when // the real ScheduledTaskCreated notification arrives. if let Some(preview) = scheduled_task_preview { use crate::app::agent::ScheduledTaskInfo; let provisional_id = format!("provisional-{}", id); agent.session.scheduled_tasks.insert( provisional_id.clone(), ScheduledTaskInfo { task_id: provisional_id, prompt: preview.prompt, human_schedule: preview.human_schedule, created_at: std::time::Instant::now(), next_fire_at: preview.next_fire_at, tag: preview.tag, }, ); } } CommandResult::PassThrough(pass_text) => { // A recognized token later in the passthrough text still styles the echo. let skill_token_ranges = agent .prompt .slash_controller .recognized_token_ranges(&pass_text, &agent.session.models); agent .session .enqueue_prompt_with_skill_tokens(pass_text, skill_token_ranges); } } if consume_input { // Drain prompt images before clearing prompt state. drain_prompt_state_to_last_queued(agent); agent.prompt.set_text(""); } // Every non-enqueue arm returned above. Queued slash work bypasses // the picker, so create the deferred session or it never drains. effects = skip_picker_and_create_session(app, id); } else if !literal && matches!(trimmed, "exit" | "quit" | ":q" | ":q!" | ":wq" | ":wq!") { if consume_input { agent.prompt.set_text(""); } return dispatch(Action::Quit, app); } else { // ── Server-authoritative immediate send (plain prompt only) ── // A plain prompt typed while a turn is RUNNING is sent to the agent // immediately instead of being held in the local drip-feed queue. The // agent appends it to its authoritative `pending_inputs` (no concurrent // turn starts — validated keystone) and drives the drain via // `x.ai/queue/changed`. We render an optimistic echo into the shared // queue keyed by `prompt_id`; the broadcast reconciles it by id. // // The IDLE case is unchanged (falls through to the local path below, // which drains instantly and renders the user block) — preserving the // byte-for-byte idle experience. Image/skill/editing/non-running cases // also stay local; they're out of immediate-send scope. // Plain prompts also require "no images" (image prompts stay local). // // A follow-up chip submission supersedes the current response's // suggestions: clear the visible chips here — INSIDE the send/enqueue // path, after the `reconnect_pending` and active-agent early-return // guards — so the chips are cleared ONLY when the suggestion actually // sends/enqueues. Placing it before those guards (the prior fix) cleared // the chips even when `reconnect_pending` aborted with a toast and no // send, losing both the chips and the submit. This single clear covers // BOTH the immediate-send and enqueue subpaths below; `clear_follow_ups` // is idempotent (so the immediate-send branch's own clear is a no-op) // and keeps `follow_up_seen` (a stale re-delivery stays rejected). // // Gate on a BOUND session: with no `session_id`, the enqueue subpath // below queues the text but `maybe_drain_queue` returns WITHOUT emitting // `SendPrompt` (nothing can drain to an unbound session), so clearing the // chips here would lose the click with nothing submitted. Leaving them // shown preserves the suggestion for a retry once the session binds. if is_follow_up && agent.session.session_id.is_some() { agent.clear_follow_ups(); } // If the user queues a follow-up while a turn is already running, surface // a short tip advertising send-now — plain Enter queues; Enter again on // the emptied composer sends the queued message now (cancel-and-send). let queued_while_running = agent.session.state.is_turn_running(); // Composer-recognized slash tokens at submit time: styles the // scrollback echo and rides the wire meta so replay restyles it. let skill_token_ranges = agent .prompt .slash_controller .recognized_token_ranges(&text, &agent.session.models); let immediate_server_send = immediate_server_send_eligible(agent) && agent.prompt.images.is_empty(); tracing::debug!( target: "qtrace", pid = std::process::id(), event = "send_route_plain", immediate = immediate_server_send, is_turn_running = agent.session.state.is_turn_running(), shared_queue_len = agent.shared_queue.len(), pending_len = agent.session.pending_prompts.len(), current_prompt_id = agent.session.current_prompt_id.as_deref().unwrap_or(""), session = agent.session.session_id.as_ref().map(|s| s.0.as_ref()).unwrap_or(""), images = agent.prompt.images.len(), text = %text.chars().take(48).collect::(), "plain prompt send routing decision", ); // Parked + held occupancy → append; empty held → cancel-and-send. let parked_sendable_wait = agent.is_parked_on_sendable_wait(); let hold_behind_existing_queue = parked_sendable_wait && agent.has_held_user_queue(); // Images can't ride immediate server-send; empty-held park still send-nows. if !immediate_server_send && immediate_server_send_eligible(agent) && !agent.prompt.images.is_empty() && parked_sendable_wait && !hold_behind_existing_queue { let images = agent.prompt.drain_images(); if consume_input { agent.prompt.set_text(""); } // A new prompt is taking the wheel (same contract as the // immediate-send branch below). agent.clear_follow_ups(); return interject::dispatch_send_prompt_now(app, text, images); } if immediate_server_send { let session_id = agent .session .session_id .clone() .expect("session_id is_some checked"); let agent_id = agent.session.id; let prompt_id = uuid::Uuid::new_v4().to_string(); // Self-originated: when this prompt becomes the running turn (via the // `running_prompt_id` adoption + turn-start shim), the ACP gate must // treat its deltas as ours, not adopt them as another client's turn. agent.note_self_originated_prompt(&prompt_id); if parked_sendable_wait && !hold_behind_existing_queue { agent.arm_send_now_expectation(prompt_id.clone()); agent.suppress_parked_marker_on_interject(); } if consume_input { // Plain prompt: no images to drain. Clear textarea + record // up-arrow history (same as the local path's history insert). agent.prompt.set_text(""); let trimmed_key = text.trim().to_string(); if !trimmed_key.is_empty() { agent .session .prompt_history .retain(|p| p.trim() != trimmed_key); agent.session.prompt_history.insert(0, text.clone()); if agent.session.prompt_history.len() > 200 { agent.session.prompt_history.truncate(200); } } } // A new prompt is taking the wheel: the previous response's // follow-up chips must not linger into it. The local drain // (`maybe_drain_queue`) and the turn-start shim clear them on // their paths; this immediate-send path returns early, so it must // clear them here too (notably a chip click, which submits while // a turn is running). `clear_follow_ups` keeps `follow_up_seen` // (turn-boundary semantics) so a stale re-delivery stays rejected. agent.clear_follow_ups(); // `agent` borrow ends here; push the optimistic echo via `app`. let sid_str = session_id.0.to_string(); push_server_queue_echo(app, agent_id, &sid_str, &prompt_id, &text, "prompt"); crate::unified_log::info( "prompt.send_server_authoritative", Some(&sid_str), Some(serde_json::json!({ "kind": "prompt", "len": text.len() })), ); if queued_while_running && !parked_sendable_wait { maybe_show_send_now_tip(app); } return vec![Effect::SendPrompt { agent_id, session_id, text, prompt_id, skill_token_ranges, }]; } agent .session .enqueue_prompt_with_skill_tokens(text.clone(), skill_token_ranges); if consume_input { // Drain prompt images before clearing prompt state. drain_prompt_state_to_last_queued(agent); agent.prompt.set_text(""); } // Local queue while a turn is running (e.g. images attached): tip after // this branch so the agent mut-borrow is released first. tip_send_now_after_queue = queued_while_running; } // Mid-turn local queue: advertise send-now via the ephemeral tip (skip during // a sendable wait — the inline hint already says it). if tip_send_now_after_queue { let inline_hint_shown = app .agents .get(&id) .is_some_and(|agent| agent.held_queue_count() > 0); if !inline_hint_shown { maybe_show_send_now_tip(app); } } { let Some(agent) = app.agents.get_mut(&id) else { return effects; }; // Insert into local prompt history (move-to-front dedup, cap at 200). // Skipped for modal-driven dispatch: the user didn't type these // commands and shouldn't see them in up-arrow history. if !consume_input { effects.extend(maybe_drain_queue(agent)); } else { let trimmed_key = text.trim().to_string(); if !trimmed_key.is_empty() { agent .session .prompt_history .retain(|p| p.trim() != trimmed_key); agent.session.prompt_history.insert(0, text.clone()); if agent.session.prompt_history.len() > 200 { agent.session.prompt_history.truncate(200); } } effects.extend(maybe_drain_queue(agent)); } } effects } /// Enqueue a bash command and try to drain immediately. /// /// Bash commands go through the same enqueue/drain pipeline as normal prompts, /// just with `QueueEntryKind::BashCommand`. No scrollback block is pushed here; /// the execute block from the shell IS the visual entry. pub(super) fn dispatch_send_bash_command(app: &mut AppView, command: String) -> Vec { if app.reconnect_pending { app.show_toast("Reconnecting, please wait..."); return vec![]; } let ActiveView::Agent(id) = app.active_view else { return vec![]; }; let Some(agent) = app.agents.get_mut(&id) else { return vec![]; }; // Submitting a bash command retires any edit-contextual ephemeral tip. agent.ephemeral_tip.clear_on_submit(); if agent.session.session_id.is_none() { let effects = skip_picker_and_create_session(app, id); if let Some(agent) = app.agents.get_mut(&id) { agent.session.enqueue_bash_command(command); agent.prompt.set_text(""); } return effects; } // Store in prompt history with `! ` prefix for restore semantics. let history_key = format!("! {}", command.trim()); agent .session .prompt_history .retain(|p| p.trim() != history_key); agent.session.prompt_history.insert(0, history_key); if agent.session.prompt_history.len() > 200 { agent.session.prompt_history.truncate(200); } // ── Server-authoritative immediate send for bash while running ── // A bash command typed while a turn is RUNNING is sent to the agent // immediately (it's already a `session/prompt` with bash meta) and echoed // into the shared queue with `kind="bash"`. On `running_prompt_id` // adoption the turn-start shim sets `bash_turn` (no user block). The IDLE // case is unchanged: enqueue locally + drain instantly. let bash_immediate = immediate_server_send_eligible(agent); tracing::debug!( target: "qtrace", pid = std::process::id(), event = "send_route_bash", immediate = bash_immediate, is_turn_running = agent.session.state.is_turn_running(), shared_queue_len = agent.shared_queue.len(), pending_len = agent.session.pending_prompts.len(), current_prompt_id = agent.session.current_prompt_id.as_deref().unwrap_or(""), session = agent.session.session_id.as_ref().map(|s| s.0.as_ref()).unwrap_or(""), text = %command.chars().take(48).collect::(), "bash command send routing decision", ); if bash_immediate { let session_id = agent .session .session_id .clone() .expect("session_id is_some checked"); let agent_id = agent.session.id; let prompt_id = uuid::Uuid::new_v4().to_string(); // Self-originated (see the plain immediate-send path): keep this turn's // deltas ours in the ACP gate once it becomes the running turn. agent.note_self_originated_prompt(&prompt_id); agent.prompt.set_text(""); let sid_str = session_id.0.to_string(); push_server_queue_echo(app, agent_id, &sid_str, &prompt_id, &command, "bash"); crate::unified_log::info( "prompt.send_server_authoritative", Some(&sid_str), Some(serde_json::json!({ "kind": "bash", "len": command.len() })), ); return vec![Effect::SendBashCommand { agent_id, session_id, command, prompt_id, }]; } agent.session.enqueue_bash_command(command.clone()); agent.prompt.set_text(""); maybe_drain_queue(agent) } /// Whether a load-result handler must stand down because a reconnect reload /// window is open on the agent. /// /// The window owns the agent's batch / `loading_replay` / turn state; a load /// result resolving mid-window (a stale fresh-view load, or `/resume` racing a /// reconnect) must not close it — flipping `loading_replay` would make the /// replay gate drop the rest of the reconnect replay, and a failure block /// would be pushed into staging state. The window finalize supersedes the /// result. pub(super) fn defer_to_open_reload_window( agent: &AgentView, agent_id: AgentId, result: &str, ) -> bool { if agent.session_reload.is_none() { return false; } tracing::warn!( agent = ?agent_id, result, "load result during an open reload window — deferring to the window finalize" ); true } /// The initiation-side counterpart of [`defer_to_open_reload_window`]: a load /// INITIATION that takes over the agent (fork/worktree-fork/remote-restore /// binding a session) finalizes any open reload window as failed first, so /// the new load owns the agent's batch/replay state and its results are not /// deferred. Unreachable through today's flows (these arms target freshly /// created `session_id: None` agents, which can never host a window) — /// defense in depth against future initiation paths on live agents. pub(super) fn supersede_open_reload_window( agent: &mut AgentView, agent_id: AgentId, initiation: &str, ) { if agent.session_reload.is_none() { return; } tracing::warn!( agent = ?agent_id, initiation, "load initiation supersedes an open reload window (finalizing as failed)" ); agent.abort_session_reload(); } // TaskResult handlers. pub(super) fn handle_prompt_response( app: &mut AppView, agent_id: AgentId, result: Result, http_status: Option, prompt_id: Option, ) -> Vec { // A server-authoritative queued prompt may have drained into // the running slot while this turn was still finishing (the leader's // `running_prompt_id` broadcast can arrive before this // `PromptResponse`). Take any stashed adoption now; it is applied // after `finish_turn` clears `current_prompt_id` below. let pending_adoption = app.pending_running_adoptions.remove(&agent_id); if let Some(agent) = app.agents.get_mut(&agent_id) { // Discard PromptResponses that don't belong to the currently // active prompt -- they belong to a turn the user rewound, or to // a queued prompt that never became the running turn. // // The prompt id comes from two places depending on the arm: // - `Ok`: the agent echoes `promptId` in PR meta. // - `Err`: an `acp::Error` carries NO meta, so we fall back to // the `prompt_id` the pager minted when it sent this // RPC (threaded through `TaskResult::PromptResponse`). // // Without the `Err` fallback, a queued prompt's RPC error has no // id to gate on and is misattributed to the running turn — e.g. // when a queued prompt is removed in leader mode and its // `respond_to` is dropped on the leader, the resulting // "session failed to respond" error would detonate an unrelated // in-flight turn with a spurious "Turn failed" (on the // submitter's screen, even when another client did the edit). let response_pid = match &result { Ok(pr) => pr .meta .as_ref() .and_then(|m| m.get("promptId")) .and_then(|v| v.as_str()) .map(str::to_string), Err(_) => prompt_id.clone(), }; // The turn-end RPC for this prompt arrived — disarm the // lost-response reconcile that `handle_prompt_complete` armed // for it (the broadcast is emitted before the RPC response, so // in the healthy path the marker lives only a few ms). if let Some(pending) = agent.pending_turn_end_reconcile.as_ref() && response_pid.as_deref() == Some(pending.prompt_id.as_str()) { agent.pending_turn_end_reconcile = None; } if let Some(response_pid) = response_pid.as_deref() && agent.session.current_prompt_id.as_deref() != Some(response_pid) { if (agent.session.current_prompt_id.is_none() || agent .session .current_prompt_id .as_deref() .is_some_and(crate::app::acp_handler::is_server_initiated_prompt)) && crate::app::acp_handler::is_server_initiated_prompt(response_pid) { // Server-initiated turn (auto-wake) — adopt. agent.session.current_prompt_id = Some(response_pid.to_string()); } else { // Not the running turn: this response (Ok rewound/stale, // or Err from a queued/removed prompt) must not touch the // active turn. Restore the adoption we popped above so a // genuinely-draining next prompt can still be adopted by // the real running turn's PromptResponse — unless it is the // stashed turn's own response: that spent the turn's only // exit, so consume (discard), never restore. if let Some(p) = pending_adoption { if p.prompt_id == response_pid { agent.discard_pending_adoption_updates(&p.prompt_id); } else { app.pending_running_adoptions.insert(agent_id, p); } } // Server-authoritative queue lifecycle: this prompt's RPC // resolved without becoming the running turn (removed, // cancelled, rewound). Retire its optimistic echo so a // later `x.ai/queue/changed` broadcast can't re-pin a // stale placeholder and reorder the queue. if let Some(sid) = agent.session.session_id.as_ref().map(|s| s.0.to_string()) { retire_optimistic_echo( &mut app.optimistic_prompt_echoes, &mut app.shared_prompt_queues, &sid, response_pid, ); agent.shared_queue.retain(|e| e.id != response_pid); agent.note_queue_echo_retired(response_pid); } // Resolved-without-running never adopts; explicit for the // session-less arm (no note_queue_echo_retired above). agent.retire_send_now_painted_block(response_pid); return vec![]; } } let was_cancelling = agent.session.state.is_cancelling() || matches!( &result, Ok(pr) if pr.stop_reason == acp::StopReason::Cancelled ); // Send-now cancel: suppress the "Turn cancelled by user" marker (the new // prompt follows right under the partial). Wire `cancelTrigger` wins, else // the client-side expectation; consumed at every turn end (no stale flag). let expected_send_now = agent.expect_send_now_cancel.take(); let wire_cancel_trigger = result.as_ref().ok().and_then(|pr| { pr.meta .as_ref()? .get("cancelTrigger")? .as_str() .map(str::to_string) }); let send_now_cancel = was_cancelling && match wire_cancel_trigger.as_deref() { Some(trigger) => trigger == "send_now", None => expected_send_now.is_some(), }; let rate_limited = agent.session.rate_limited; let model_incompatible = agent.session.model_incompatible; // Context overflow: the RetryState handler already pushed the actionable // block, so the generic TurnFailed + error toast are redundant. Derived // from the scrollback (mirrors reauth), not a session flag. let context_overflow = scrollback_has_recent_context_too_large(&agent.scrollback); // A 401/auth failure already surfaced an actionable // `ReAuthRequired` prompt via the RetryState handler (which // runs before this PromptResponse). Suppress the redundant // "Turn failed" block + error toast so only the prompt shows. let reauth_prompted = scrollback_has_recent_reauth_prompt(&agent.scrollback) || (http_status == Some(401) && result .as_ref() .err() .is_some_and(|e| e.contains("Unauthorized (401)"))); let elapsed = agent.turn_elapsed(); { let sid = agent.session.session_id.as_ref().map(|s| s.0.as_ref()); let elapsed_ms = elapsed.map(|d| d.as_millis() as u64).unwrap_or(0); let ok = result.is_ok(); crate::unified_log::info( "turn.complete", sid, Some(serde_json::json!({ "elapsed_ms": elapsed_ms, "ok": ok, "was_cancelling": was_cancelling, "send_now_cancel": send_now_cancel, })), ); } // Stash the prompt from a turn that failed on an // expired login (401 / re-auth). The AuthComplete handler // auto-resubmits it after a successful mid-session re-auth. // A non-rewindable turn (None) must not clobber an earlier stash. if reauth_prompted && let Some(prompt) = agent.session.in_flight_prompt.as_ref() { agent.reauth_stashed_prompt = Some(prompt.clone()); } // qtrace: turn end on this client. This clears current_prompt_id // and (briefly) returns the client to Idle — the start of the // leader-mode turn-end window where a freshly-sent prompt can be // wrongly local-drained before the next running-prompt broadcast // is adopted. tracing::debug!( target: "qtrace", pid = std::process::id(), event = "turn_end", prompt_id = prompt_id.as_deref().unwrap_or(""), was_cancelling, shared_queue_len = agent.shared_queue.len(), pending_len = agent.session.pending_prompts.len(), has_pending_adoption = pending_adoption.is_some(), session = agent.session.session_id.as_ref().map(|s| s.0.as_ref()).unwrap_or(""), "turn ended; client returning to idle", ); // Read before `finish_turn()` clears it; keys the pending stop-hook stash. let ending_prompt_id = agent .session .current_prompt_id .clone() .or_else(|| response_pid.clone()); agent.session.finish_turn(&mut agent.scrollback); // Insert session event message (skip TurnCompleted for bash-mode — no agent turn). let event = match (&result, was_cancelling) { // Send-now cancel: no marker (the new prompt is the next turn); the // `None` still flushes any held stop hooks standalone. (Ok(_), true) if send_now_cancel => None, (Ok(_), true) => Some(SessionEvent::TurnCancelled { elapsed: elapsed.unwrap_or_default(), }), (Ok(_), false) if agent.bash_turn => None, (Ok(_), false) => Some(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(elapsed.unwrap_or_default()), }), (Err(_), _) if rate_limited || model_incompatible || reauth_prompted || context_overflow => { // Skip TurnFailed when a dedicated prompt/modal shows instead // (rate limit, model incompatibility, 401 re-auth, or a // terminal context-window overflow). None } (Err(err), _) => Some(SessionEvent::TurnFailed { error: err.clone(), elapsed, }), }; crate::app::turn_completion::push_turn_terminal_marker( agent, event, ending_prompt_id.as_deref(), false, ); let notification = match (&result, was_cancelling) { (Ok(_), false) if !agent.bash_turn => { let body = match elapsed { Some(d) => { format!("Turn complete in {}.", crate::util::format_duration(d)) } None => String::from("Turn complete."), }; Some((NotificationEventKind::TurnComplete, body)) } (Err(err), _) if !rate_limited && !model_incompatible && !reauth_prompted && !context_overflow => { Some((NotificationEventKind::AgentError, format!("Error: {err}"))) } _ => None, }; agent.mark_turn_finished(); agent.activity_started_at = None; agent.last_activity = None; // Drain all queued permission requests — the turn is over, // so any pending permissions are stale. Send Cancelled to each. drain_permission_queue(agent); // Dismiss any active plan approval or review — the turn // that produced it has completed, so the state is stale. if let Some(mut pav) = agent.plan_approval_view.take() { pav.send_stale_cancel(); agent.plan_next_comment_id = pav.next_comment_id; agent.prompt.restore(pav.stashed_prompt); agent.line_viewer = None; } agent.cancel_turn_view = None; agent.cancel_turn_buttons.clear(); // After a bash-mode turn, scroll to bottom so the user sees // the command output, but keep focus on the prompt for // consistency with normal prompt behavior. let was_bash_turn = agent.bash_turn; if agent.bash_turn { agent.bash_turn = false; agent.scrollback.goto_bottom(); } agent.cron_task_id = None; // TurnComplete suppressed when queue is non-empty (badge // fires only after the final queued turn); AgentError always fires. if let Some((kind, body)) = notification { // A stashed server-authoritative adoption means the next // turn is about to start, so treat the queue as non-empty // (suppress the TurnComplete notification / idle escapes), // mirroring the local non-empty-queue behavior. let queue_empty = agent.session.pending_prompts.is_empty() && pending_adoption.is_none(); let session_name = agent .display_name .as_deref() .or(agent.generated_session_title.as_deref()); // Skip idle escapes when queue is non-empty — the next // turn starts immediately and would overwrite them (title flicker). if queue_empty { let cwd_str = app.cwd.to_string_lossy(); let model = agent.session.models.current_model_name(); let idle_title = crate::notifications::TitleState { session_name, model: model.as_deref(), activity: None, has_pending_permissions: false, cwd: Some(&cwd_str), turn_elapsed: None, is_busy: false, focused: true, }; app.pending_notification_escapes = app.notification_service.build_idle_escapes(&idle_title); } if kind != NotificationEventKind::TurnComplete || queue_empty { // Defer the notification so the terminal has time // to apply the idle title. Ghostty debounces // setTitle() by 75 ms (SurfaceView_AppKit.swift:576), // so we need >75 ms before the notification reads // self.title for the subtitle. 3 ticks × 33 ms ≈ 99 ms. let session_id = agent.session.session_id.as_ref().map(|s| s.0.to_string()); // Use the session name as the notification title so // terminals that show it (Ghostty/OSC 777) display // which session completed. For body-only protocols // (Warp, iTerm2/OSC 9), emit_notification folds the // title into the body automatically. let notif_title = session_name .map(|s| s.to_string()) .unwrap_or_else(|| "Grok".into()); app.deferred_notification = Some(( NotificationEvent { kind, title: notif_title, body, session_id, }, 3, )); } } if let Err(ref err) = result { tracing::error!(agent = ?agent_id, error = %err, "Prompt failed"); } // Predicted-next-prompt (tab autocomplete): wipe any stale suggestion // at every turn boundary. This must run before the reconnect / // paywall early returns below, which skip the fetch gate // entirely — a prior ghost would otherwise survive those paths. agent.prompt.prompt_suggestion.clear(); // Cancelled turns resume queue processing one item at a time // through the same drain path as normal completions. // `maybe_drain_queue` keeps the idle-only and editing-front // guards so we do not send from under the user. if app.reconnect_pending { if let Some(p) = pending_adoption { agent.discard_pending_adoption_updates(&p.prompt_id); } return vec![]; } // FIFO handoff: if a server-authoritative prompt drained // into the running slot during this turn's teardown, adopt it // now (finish_turn cleared current_prompt_id) and run the // turn-start shim. This sets `TurnRunning`, so the // `maybe_drain_queue` below no-ops rather than draining a local // prompt — the leader owns the drain order. if let Some(p) = pending_adoption && agent.session.current_prompt_id.is_none() { if response_pid.as_deref() != Some(p.prompt_id.as_str()) && agent.should_adopt_running_prompt(&p.prompt_id) { apply_turn_start_shim(agent, p.prompt_id, p.text, &p.kind); } else { agent.discard_pending_adoption_updates(&p.prompt_id); } } let mut effects = maybe_drain_queue(agent); // Predicted-next-prompt (tab autocomplete): fetch a fresh suggestion // (the stale one was wiped above) — but only after a clean, non-bash // agent turn that leaves the session idle with an empty prompt and no // queued work, local or server-side (a draft in progress or a draining // queue means the user is already mid-thought). Placed after // `maybe_drain_queue` so `is_idle` reflects a locally-drained next // turn. if crate::views::prompt_suggestion::resolve_enabled() && result.is_ok() && !was_cancelling && !was_bash_turn && agent.prompt.text().is_empty() && agent.session.pending_prompts.is_empty() && agent.shared_queue.is_empty() && agent.session.state.is_idle() && let Some(session_id) = agent.session.session_id.as_ref().map(|s| s.0.to_string()) { let generation = agent.prompt.prompt_suggestion.begin_fetch(); let model = crate::views::prompt_suggestion::resolve_model(&agent.session.models); effects.push(Effect::FetchPromptSuggestion { agent_id, generation, model, session_id: Some(session_id), }); } return effects; } vec![] } pub(super) fn handle_compact_complete( app: &mut AppView, agent_id: AgentId, result: Result<(), String>, ) -> Vec { if let Some(agent) = app.agents.get_mut(&agent_id) { // Defensive: only process if we're still in CommandRunning state. // This guards against state machine bugs or future cancellation support. if !matches!(agent.session.state, AgentState::CommandRunning { .. }) { tracing::debug!("Ignoring CompactComplete (not in CommandRunning state)"); return vec![]; } let elapsed = agent.turn_elapsed(); agent.session.finish_command(); match &result { Ok(()) => { agent.scrollback.push_block(RenderBlock::session_event( SessionEvent::CompactCompleted { elapsed: elapsed.unwrap_or_default(), }, )); } Err(err) => { tracing::error!(agent = ?agent_id, error = %err, "Compaction failed"); agent.scrollback.push_block(RenderBlock::session_event( SessionEvent::CompactionFailed { error: String::new(), }, )); } } agent.mark_turn_finished(); agent.activity_started_at = None; agent.last_activity = None; if app.reconnect_pending { return vec![]; } return maybe_drain_queue(agent); } vec![] } pub(super) fn handle_suggestion_debounce_expired( app: &mut AppView, agent_id: AgentId, generation: u64, ) -> Vec { // Route by the arming agent (the timer carries it), not the active // view: a view switch inside the debounce window must neither fire a // spurious fetch on another agent nor drop this one's. let Some(agent) = app.agents.get(&agent_id) else { return vec![]; }; // Bash-mode feature: a debounce that outlives the mode fetches nothing. if agent.prompt_input_mode != crate::app::agent_view::PromptInputMode::Bash { return vec![]; } if !agent.prompt.suggestions.on_debounce_expired(generation) { return vec![]; } let text = agent.prompt.text().to_owned(); let cursor = agent.prompt.cursor(); let cwd = agent.session.cwd.to_string_lossy().into_owned(); let include_ai = agent.prompt.suggestions.ai_enabled; let ai_model = agent.prompt.suggestions.ai_model.clone(); let session_id = agent.session.session_id.as_ref().map(|s| s.0.to_string()); vec![Effect::FetchShellSuggestions { agent_id, text, cursor, cwd, generation, limit: crate::views::suggestion_controller::SHELL_SUGGEST_WIRE_LIMIT, include_ai, ai_model, session_id, // The as-you-type (ghost) surface keeps history/AI providers. token_only: false, }] }