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.
1345 lines
58 KiB
Rust
1345 lines
58 KiB
Rust
//! 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, ¤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<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,
|
||
}]
|
||
}
|