Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/dispatch/prompt.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

1345 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 `kigi "<prompt>"`.
///
/// 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<Effect> {
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<Effect> {
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<Effect> {
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<Effect> {
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, &current_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<Effect> {
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<Effect> {
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<Effect> {
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<Effect> {
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<Effect> {
// 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
// `kigi/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::<String>(),
"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<Effect> {
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::<String>(),
"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<acp::PromptResponse, String>,
http_status: Option<u16>,
prompt_id: Option<String>,
) -> Vec<Effect> {
// 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 `kigi/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(|| "Kigi".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<Effect> {
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<Effect> {
// 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,
}]
}