Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/actions.rs
T
ZacharyZhang-NY 1e57cd9225 fix(tui): route each OAuth picker row to its own provider, not kimi.com
Root cause: PendingMenuItem::Login carried only a label, so every provider
row collapsed to the id-less Action::Login, and dispatch_login resolved
the FIRST advertised interactive method — the Kimi device flow. Selecting
Grok/Claude/Copilot/Codex all opened kimi.com. (The shell side was already
correct: authenticate() dispatches each method id to its own OAuth flow.)

- PendingMenuItem::Login now carries the advertised method id (None only
  on the no-interactive-method fallback row).
- New Action::LoginWith(AuthMethodId); the picker dispatches it with the
  row's own id. Action::Login keeps its meaning (resolved/default method)
  for /login, auto-login, and re-auth.
- dispatch_login_with resolves the id against the advertised methods and
  FAILS CLOSED on an unknown id — no silent first-method fallback — then
  adopts the method's label and start mode.

Tests: picker rows pinned to their method ids; LoginWith(claude-pro-max)
must authenticate with claude-pro-max even when kimi-code was previously
resolved; unknown ids surface an error and start nothing.

Verified: kigi-tui 6867 tests green, clippy clean.
2026-07-22 15:51:46 -04:00

2515 lines
104 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.
//! Application actions, effects, and task results.
//!
//! This module defines the three enums that form the backbone of the
//! event→dispatch→effect pipeline:
//!
//! - [`Action`] — produced by input handling, consumed by dispatch (sync).
//! - [`Effect`] — produced by dispatch, consumed by the event loop (async).
//! - [`TaskResult`] — produced by spawned tasks, fed back into dispatch.
use super::agent::AgentId;
use crate::scrollback::entry::EntryId;
use agent_client_protocol as acp;
use kigi_shell::sampling::types::ReasoningEffort;
/// Typed error for model switch failures. Replaces the raw `String` in
/// `TaskResult::SwitchModelComplete` so dispatch can match on the variant
/// instead of parsing strings.
#[derive(Debug, Clone)]
pub enum SwitchModelError {
/// The target model requires a different agent harness than the
/// active session. The pager should offer to start a new session.
/// Deserialized from `ModelSwitchIncompatibleAgentError` in
/// `acp::Error.data`.
IncompatibleAgent {
error: kigi_shell::agent::config::ModelSwitchIncompatibleAgentError,
/// The model that was active before the optimistic UI update
/// (if any). Used to roll back `models.current` when the user
/// declines to start a new session.
prev_model_id: Option<acp::ModelId>,
},
/// Any other failure (network, auth, server error, etc.).
Other(String),
}
/// Synchronous, side-effect-free user intent.
///
/// Produced by [`super::input`] from key/mouse events.
/// Consumed by [`super::dispatch::dispatch`] to mutate state and return effects.
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum Action {
/// Quit the application.
Quit,
/// Restart the binary to pick up a downloaded update.
QuitForUpdate,
/// Resume the recent foreign session offered on the launch welcome screen.
ResumeForeignSession,
/// Re-exec into the other screen mode (`true` = minimal).
RelaunchInScreenMode {
minimal: bool,
},
/// Quit without double-press confirmation (e.g., from command palette or pre-login screens).
QuitConfirmed,
/// Create a new session from the welcome screen.
NewSession,
/// Ask whether the new session should use a git worktree.
ChooseNewSessionMode,
/// Exit the current session and return to the welcome screen.
ExitSession,
/// Exit session without double-press confirmation (e.g., from command palette).
ExitSessionConfirmed,
/// Open an arbitrary URL in the system browser (with scheme validation).
OpenUrl(String),
/// Cycle to the next visible link (or highlight the first if none selected).
OpenNextLink,
/// Cycle to the previous visible link.
OpenPrevLink,
/// Fetch the session list for the session picker on the welcome screen.
FetchSessionList,
/// Cycle the active session picker's source filter.
CycleSessionSourceFilter,
/// Load a selected session from the session picker.
PickSession(usize),
/// Load a selected session from the session picker into a new worktree.
PickSessionInWorktree(usize),
/// Copy the selected session's ID to the clipboard.
CopySessionId(usize),
/// Toggle expanded card view for a session in the picker.
ExpandSessionCard {
source: String,
session_id: String,
},
/// Open the session picker overlay (from within an active session via /resume).
ShowSessionPicker,
/// The session picker overlay was dismissed without a pick: invalidate any
/// in-flight list/search/foreign scan so a late response can't fall
/// through to the welcome picker fields.
SessionPickerClosed,
/// Create a new session in a git worktree from the welcome screen.
/// `load_session_id`: when `Some`, loads that session in the new worktree
/// instead of creating a fresh one (`--resume` + `--worktree`).
/// `label`: optional human-readable label from CLI `-w <label>` or dialog.
NewWorktreeSession {
load_session_id: Option<String>,
label: Option<String>,
/// Optional branch/tag/commit to base the worktree on (CLI `--ref` / `--worktree-ref`).
git_ref: Option<String>,
},
/// Open the "New Worktree" popup dialog on the welcome screen.
OpenNewWorktreeDialog,
/// Open the interactive import-claude modal on the welcome screen.
ImportClaudeSettings,
/// User confirmed the import modal — apply selected items.
ImportClaudeConfirm,
/// User cancelled the import modal — close without applying.
ImportClaudeCancel,
/// Hide the import-claude menu row by recording the current `.claude/`
/// content hash as "seen". Doesn't import anything, doesn't change
/// runtime fallback behavior. The menu reappears only if `.claude/`
/// content changes.
DismissClaudeImport,
/// Load (resume) an existing session by ID (strict — never create).
/// The optional `PathBuf` overrides the CWD for sessions stored under a
/// different directory (e.g., a worktree).
///
/// `chat_kind` is the **conversation-entry** bit only (`source ==
/// "conversation"` / restore preserve) — **not** sticky `--chat`.
/// Process-wide chat mode still stamps kind=chat via SessionFlags in the
/// load effect; under `--chat`, local Build disk rows are refused in
/// dispatch (never coerced).
LoadSession(String, Option<std::path::PathBuf>, bool),
/// Create a new session with a client-chosen session ID (`--session-id`).
NewSessionWithId(String),
/// Startup `--fork-session`: fork `parent` then load the child.
/// Optional second string is the desired new session ID.
StartupForkSession {
parent_session_id: String,
parent_cwd: Option<std::path::PathBuf>,
new_session_id: Option<String>,
},
/// Send the current prompt text to the agent.
SendPrompt(String),
/// Submit a clicked follow-up suggestion chip as a LITERAL model prompt.
/// The suggestion text is server/model-controlled, so it must bypass
/// slash-command and exit-alias resolution (a `/always-approve` or `/quit`
/// chip must never execute as a command).
SubmitFollowUp(String),
/// Execute a slash command without consuming the prompt textarea.
///
/// Used by modal-driven slash dispatchers (command palette, ArgPicker)
/// where the user's draft text in the prompt should be preserved
/// rather than wiped as a side effect of the command. Behaves
/// identically to `SendPrompt` otherwise — same registry resolution,
/// same effect outputs — but skips the `prompt.set_text("")` calls.
SendSlashCommandPreservingDraft(String),
/// Send a mid-turn interjection without canceling the running turn.
/// Reserved for text that answers the running turn (plan-review comments,
/// permission follow-ups); user send-now takes [`Self::SendPromptNow`].
Interject {
text: String,
/// Pasted images riding along with the interjection. Empty for
/// producers that carry plain text (plan-review comments, etc.).
images: Vec<crate::prompt_images::PastedImage>,
},
/// Cancel-and-send: cancel the running turn (background tasks and queued
/// rows survive shell-side) and run this text as the next prompt turn.
/// The send-now chord, empty-composer Enter on a queued local row, and
/// the deferred-paste re-issue produce this.
SendPromptNow {
text: String,
/// Pasted images riding along with the prompt.
images: Vec<crate::prompt_images::PastedImage>,
},
/// Send a direct bash command (bypasses agent loop).
SendBashCommand(String),
/// The user wiped a substantial prompt draft: show the seen-gated
/// "ctrl+z to undo" ephemeral tip on the active agent. Gated by the per-tip
/// `contextual_hints.undo` gate.
ShowUndoTip,
/// The user typed a planning keyword into the prompt: show the seen-gated
/// "Planning? Check out plan mode via shift+tab" ephemeral tip on the active
/// agent. Gated by the per-tip `contextual_hints.plan_mode` gate.
ShowPlanNudge,
/// The user double-clicked scrollback while Text selection is fold/nav:
/// show the seen-gated "/settings → Text selection → Word select" tip.
/// Gated by the per-tip `contextual_hints.word_select` gate.
ShowWordSelectTip,
/// Accept the word-select tip (its advertised chord, pressed while the
/// tip is on screen): flip `keep_text_selection` to `word_select`,
/// persist it, and retire the tip.
AcceptWordSelectTip,
/// Try to drain the next queued prompt (after editing completes, etc.).
DrainQueue,
/// Remove a server-authoritative (shared) queued prompt by its stable
/// `prompt_id`. Routed to the agent as `kigi/queue/remove`;
/// the resulting `kigi/queue/changed` rebroadcast is the source of truth.
QueueRemoveShared {
id: String,
expected_version: u64,
},
/// Reorder the server-authoritative (shared) queued prompts to match
/// `ordered_ids`. Routed as `kigi/queue/reorder`.
QueueReorderShared {
ordered_ids: Vec<String>,
},
/// Clear the caller's server-authoritative (shared) queued prompts.
/// Routed as `kigi/queue/clear`.
QueueClearShared,
/// Replace the text of a server-authoritative (shared) queued prompt.
/// Routed to the agent as `kigi/queue/edit`; the rebroadcast of
/// `kigi/queue/changed` is the source of truth. Last write wins via the
/// session actor's serialized mailbox; no client-side conflict resolution.
QueueEditShared {
id: String,
new_text: String,
},
/// Interject a server-authoritative (shared) queued prompt into the running
/// turn: the agent atomically removes it from the queue and
/// merges its text into the in-flight turn. Routed as `kigi/queue/interject`;
/// the `kigi/session/interjection` + `kigi/queue/changed` rebroadcasts are
/// the source of truth (no optimistic client-side block). Mirrors the local
/// "Send now" / `Ctrl+Enter` path, which uses [`Interject`](Self::Interject)
/// directly because the local queue is client-owned.
QueueInterjectShared {
id: String,
expected_version: u64,
/// Locally-edited replacement text (the edit-interject key while in
/// `PromptMode::EditingQueued`): without it the agent would interject
/// the original server-side text, not the edit. Atomicity semantics
/// live on [`Effect::QueueInterject`].
new_text: Option<String>,
},
/// Focus the prompt pane.
FocusPrompt,
/// Focus the scrollback pane (leave prompt).
FocusScrollback,
/// Clear the prompt (history-aware). Armed by idle Esc double-press via
/// [`super::app_view::InputOutcome::ArmPending`] (no ActionDef; not a keybinding).
ClearPrompt,
/// Focus the scrollback pane and open an incremental search over it.
/// Drives the `/find` slash command so simple-mode users (where a bare
/// `/` goes to the prompt) reach the same search as the vim `/` key.
/// Carries the optional `/find <word>` argument to pre-fill the bar.
OpenScrollbackSearch(Option<String>),
/// Select next entry in scrollback.
SelectNext,
/// Select previous entry.
SelectPrev,
/// Jump to next turn boundary.
NextTurn,
/// Jump to previous turn boundary.
PrevTurn,
/// Jump to next assistant response.
NextResponse,
/// Jump to previous assistant response.
PrevResponse,
/// Scroll up by N lines.
ScrollUp(u16),
/// Scroll down by N lines.
ScrollDown(u16),
/// Go to top of scrollback.
GotoTop,
/// Go to bottom of scrollback.
GotoBottom,
/// Half page up.
HalfPageUp,
/// Half page down.
HalfPageDown,
/// Full page up.
PageUp,
/// Full page down.
PageDown,
/// Collapse selected entry (no-op if already collapsed or not foldable).
Collapse,
/// Expand selected entry (no-op if already expanded or not foldable).
Expand,
/// Toggle fold on selected entry.
ToggleFold,
/// Smart expand/collapse all: expand all if any collapsed, else collapse all.
ToggleExpandAll,
/// Expand all thinking blocks (toggle: expand if any collapsed, else collapse all).
ExpandAllThinking,
/// Toggle raw markdown on selected entry.
ToggleRaw,
/// Toggle terminal mouse reporting (mouse capture). Disabling it lets
/// the terminal handle native click-drag text selection / copy-paste;
/// re-enabling restores in-app mouse handling. Bound to Ctrl+R while the
/// scrollback pane is focused.
ToggleMouseCapture,
/// Toggle the scroll-diagnostics HUD (hidden `/scroll-debug` command,
/// also `/debug scroll`; `KIGI_SCROLL_DEBUG=1` enables it from startup).
ToggleScrollDebugHud,
/// Toggle the release-safe FPS HUD (`/debug fps`).
ToggleFpsHud,
/// Toggle the scroll flight recorder at runtime (`/debug log`;
/// `KIGI_SCROLL_LOG=1` enables it from startup).
ToggleScrollLog,
/// Print the `/debug` toggles and their on/off state to the transcript.
ShowDebugStatus,
/// Copy selected block's content to clipboard.
CopyBlockContent,
/// Copy the Nth most recent assistant message to clipboard (1 = latest).
CopyAssistantMessage {
n: usize,
},
/// Export the active (sub)agent's conversation transcript as Markdown.
/// `None` => copy to clipboard (with route-aware toast + stats); `Some(p)` => write UTF-8 file
/// (all ~ expansion, parent dir creation, and fs::write live in the dispatch handler).
ExportConversation {
file_path: Option<std::path::PathBuf>,
},
/// Render the active (sub)agent's full transcript to a temp Markdown file and
/// open it in `$PAGER` (default `less`), suspending the inline TUI for the
/// duration. The dispatch handler renders + writes the file and arms
/// `AppView::pending_pager_path`; the event loop does the suspend/restore.
OpenTranscriptPager,
/// Minimal mode (`kigi --minimal`): re-print the most-recently committed
/// folded block (collapsed reasoning / truncated tool output) into native
/// scrollback, fully expanded, below the conversation (design decision K10).
/// Bound to `Ctrl+E` and the `/expand` command. No-op outside minimal mode
/// or when nothing folded remains to expand.
MinimalExpandLast,
/// Copy selected block's metadata (e.g., command for execute blocks).
CopyBlockMeta,
/// Open the selected block in the fullscreen viewer.
OpenBlockViewer,
/// Open the extensions modal dialog on a specific tab.
OpenExtensionsModal {
tab: crate::views::extensions_modal::ExtensionsTab,
},
/// Open the agents modal (listing all agent definitions).
/// Optionally opens directly on a specific tab.
OpenConfigAgentsModal(Option<crate::views::agents_modal::AgentsTab>),
/// Trigger OAuth for an MCP server from the modal.
McpAuthTrigger {
server_name: String,
},
/// Reload skills list from the modal.
ReloadSkills,
/// Refresh MCP server list from the modal.
RefreshMcpList,
/// Execute a hooks management action from the modal.
ExecuteHooksAction(kigi_hooks_plugins_types::HooksAction),
/// Execute a plugins management action from the modal.
ExecutePluginsAction(kigi_hooks_plugins_types::PluginsAction),
/// Add or update an MCP server via kigi/mcp/upsert.
UpsertMcpServer {
name: String,
config: Box<kigi_shell::util::config::McpServerConfig>,
},
/// Delete an MCP server via kigi/mcp/delete.
DeleteMcpServer {
server_name: String,
},
/// Live-toggle an MCP server (enable/disable without session restart).
ToggleMcpServer {
server_name: String,
enabled: bool,
},
/// Toggle a skill enable/disable via kigi/skills/toggle.
ToggleSkill {
skill_name: String,
enabled: bool,
},
/// Toggle a single MCP tool within a server (enable/disable).
ToggleMcpTool {
server_name: String,
tool_name: String,
enabled: bool,
},
/// Cycle to next model.
NextModel,
/// Switch active model.
SwitchModel {
model_id: acp::ModelId,
effort: Option<ReasoningEffort>,
},
/// Cancel the currently running turn.
CancelTurn,
/// User confirmed a cancel-turn choice from the panel.
CancelTurnChoice(crate::views::modal::CancelTurnChoice),
/// Kill a background task by task_id.
KillBgTask(String),
/// Kill (cancel) a subagent by subagent_id.
KillSubagent(String),
CancelScheduledTask(String),
/// Demote the currently running execute tool to a background task.
DemoteToBackground,
/// Request current bundle cache status via `kigi/bundle/status`.
RequestBundleStatus,
/// View a catalog entry's raw content in the block viewer.
ViewCatalogEntry {
kind: String,
name: String,
},
/// Cycle session mode (Shift+Tab): Normal → Plan → Always-Approve → Normal.
/// Plan mode sends a signal to the shell; always-approve is local.
CycleMode,
/// Toggle YOLO mode (auto-approve all permissions). Ctrl+O.
ToggleYolo,
/// Set YOLO (auto-approve / `always-approve`) mode.
SetYoloMode(bool),
/// Set the permission mode by canonical kind (`always-approve` /
/// `ask` / `default`). Typed wrapper over [`Action::SetYoloMode`]
/// that preserves the `default` canonical (the `bool` variant
/// collapses `default` to `ask`).
SetPermissionMode(PermissionModeKind),
/// Toggle multiline input mode (swap Enter and Shift+Enter behavior).
ToggleMultiline,
/// Set multiline input mode (swap Enter and Shift+Enter behavior).
/// Pager-owned, NOT persisted to disk — reset each session.
SetMultilineMode(bool),
/// Open the prompt-history search panel on the active agent (composer
/// as filter query). Dispatched by `/history`.
OpenHistorySearch,
/// Set how ` ```mermaid ` code blocks are rendered (auto/on/off).
/// SHELL-owned: updates the process-wide cache mirror and persists to
/// `[ui].render_mermaid` in config.toml via `Effect::PersistSetting`.
SetRenderMermaid(crate::appearance::RenderMermaid),
/// Toggle vim-style scrollback keybindings (j/k, h/l, g/G, y/Y, etc.).
/// Delegates to `set_vim_mode` so the new value is persisted to
/// `[ui].vim_mode` in config.toml — same path as the settings modal.
ToggleVimMode,
/// Set vim-style scrollback keybindings. SHELL-owned: persisted to
/// `[ui].vim_mode` in config.toml via `Effect::PersistSetting`.
/// Used by the settings modal; the `ToggleVimMode` variant covers
/// the `/vim-mode` slash-command path.
SetVimMode(bool),
/// Toggle the per-tool "Always allow …" prompt options. SHELL-owned;
/// persisted to `[ui].remember_tool_approvals`. Applies to new sessions.
SetRememberToolApprovals(bool),
/// Toggle the ask_user_question timeout. SHELL-owned; persisted to
/// `[toolset.ask_user_question].timeout_enabled`. Applies to new sessions.
SetAskUserQuestionTimeoutEnabled(bool),
/// SHELL-owned `keep_text_selection` (`flash` | `hold`); cache + persist.
SetKeepTextSelection(crate::appearance::TextSelection),
/// Set the mouse-wheel scroll speed multiplier (1-100). Pager-owned
/// ephemeral — process-wide cache, no `Effect::PersistSetting`.
SetScrollSpeed(i64),
/// Force scroll input classification (`auto` | `wheel` | `trackpad`).
/// SHELL-owned: cache mirror + `[ui].scroll_mode` via `Effect::PersistSetting`.
SetScrollMode(crate::appearance::ScrollMode),
/// Invert vertical scroll direction. SHELL-owned: cache mirror +
/// `[ui].invert_scroll` via `Effect::PersistSetting`.
SetInvertScroll(bool),
/// Set lines-per-tick for both wheel and trackpad (1-10). SHELL-owned:
/// cache mirror + `[ui].scroll_lines` via `Effect::PersistSetting`.
SetScrollLines(i64),
/// Set whether agent thinking blocks are shown. SHELL-owned: updates the
/// process-wide cache mirror and persists to `[ui].show_thinking_blocks`
/// via `Effect::PersistSetting`.
SetShowThinkingBlocks(bool),
/// Set whether runs of consecutive non-destructive tool calls and
/// subagent rows are grouped into one row. SHELL-owned: updates the
/// process-wide cache mirror and persists to `[ui].group_tool_verbs`
/// via `Effect::PersistSetting`.
SetGroupToolVerbs(bool),
/// Set whether Edit blocks default to the collapsed one-line diffstat
/// summary. SHELL-owned: updates the process-wide cache mirror and
/// persists to `[ui].collapsed_edit_blocks` via `Effect::PersistSetting`.
SetCollapsedEditBlocks(bool),
/// Set whether the predicted-next-prompt ghost text (tab autocomplete)
/// is offered after each turn. SHELL-owned: updates the process-wide
/// cache mirror and persists to `[ui].prompt_suggestions` via
/// `Effect::PersistSetting`.
SetPromptSuggestions(bool),
/// Set `[scrollback.scroll].respect_manual_folds`. PAGER-owned:
/// live-applied via `AppView::set_appearance` and persisted to
/// pager.toml via `Effect::PersistSetting`.
SetRespectManualFolds(bool),
/// Set the canonical for `[ui].default_selected_permission`. Persists
/// via `Effect::PersistSetting`. Payload is the registry's canonical
/// string (`default` | `allow_once` | `allow_always` | `reject`).
SetDefaultSelectedPermission(String),
/// Set the hunk-tracker mode. Payload is the registry canonical string.
SetHunkTrackerMode(String),
/// Set default screen mode (`fullscreen` | `minimal`); restart-required.
SetScreenMode(String),
/// Toggle timestamp display on messages.
ToggleTimestamps,
/// Toggle compact mode (reduce user message padding).
ToggleCompactMode,
/// Set compact mode (reduce user message padding).
SetCompactMode(bool),
/// Set timestamp display on messages.
SetTimestamps(bool),
/// Set timeline sidebar visibility (per-turn tick rail).
SetTimeline(bool),
/// Set simple mode (ASCII / minimal glyphs). Persists via `Effect::PersistSetting`.
SetSimpleMode(bool),
/// Set the per-tip contextual-hint user config (`[ui.contextual_hints]`).
/// Each persists via `Effect::PersistSetting` and re-resolves + re-propagates
/// the gates to every agent's prompt immediately (runtime live-apply).
SetContextualHintUndo(bool),
SetContextualHintPlanMode(bool),
SetContextualHintImageInput(bool),
SetContextualHintSendNow(bool),
SetContextualHintSmallScreen(bool),
SetContextualHintWordSelect(bool),
/// Commit the active theme (canonical name, e.g. `"kiginight"`, `"auto"`).
SetTheme(String),
/// Commit the theme used when the OS is in dark mode. Only updates
/// the live display when `theme = "auto"` AND system is in dark mode.
SetAutoDarkTheme(String),
/// Commit the theme used when the OS is in light mode.
SetAutoLightTheme(String),
/// Commit the user's default model. Payload is a resolved `ModelId`
/// (NOT a free-form string). The dispatcher switches the active
/// session and persists via `Effect::PersistSetting`. Does not
/// carry effort — use `Action::SwitchModel` for that.
SetDefaultModel(acp::ModelId),
/// Clear the persisted default model (`cfg.models.default = None`).
/// Active session's model is unchanged; next session resolves
/// via the shell's default-resolution chain.
ClearDefaultModel,
/// Commit the max-thoughts-width (column budget for the thoughts panel).
/// Payload is `i64`; clamped to `u16` at the shell helper boundary.
SetMaxThoughtsWidth(i64),
/// Commit the fork-secondary model. Typed `ModelId` payload,
/// persisted to `[ui].fork_secondary_model`. Rebroadcast via
/// `ConfigUpdate::Ui` so running agents pick up the change.
SetForkSecondaryModel(acp::ModelId),
/// Clear the persisted fork-secondary model — restores to built-in
/// default. Active agent keeps its value; next fork uses the default.
ClearForkSecondaryModel,
/// Commit the `show_tips` preference. Persisted to `[cli].show_tips`.
/// Restart-required — tips are resolved once at startup.
SetShowTips(bool),
/// Commit the `auto_update` preference. Persisted to `[cli].auto_update`.
/// Restart-required — auto-update check fires once at startup.
SetAutoUpdate(bool),
/// Commit `[ui.display_refresh].auto_cadence_enabled`. Restart-required —
/// cadence is pinned once at startup.
SetDisplayRefreshAutoCadence(bool),
/// Preview a theme without persisting — updates the live display
/// only. Used by the picker on Up/Down and Esc (revert).
PreviewTheme(String),
/// Preview `auto_dark_theme` without persisting. Only applies when
/// `theme = "auto"` AND system is in dark mode.
PreviewAutoDarkTheme(String),
/// Preview `auto_light_theme` without persisting.
PreviewAutoLightTheme(String),
/// Open the settings modal (F2, `/settings`, command palette).
/// If already open, closes it instead of stacking.
OpenSettings,
/// Open the command palette (`/help`). The keybinding path (Ctrl+P) opens it
/// directly in `handle_agent_action`; this lets a slash command reach the
/// same modal through dispatch.
OpenCommandPalette,
/// Open the in-TUI How-to Guides doc picker (`/docs`, palette "How-to Guides").
OpenHowtoGuides,
/// Open the reset-settings confirmation dialog for a specific key.
/// Moves the Settings modal state into `ResetSettingsConfirm` so
/// the underlying modal survives the confirm dialog.
OpenResetConfirm {
key: crate::settings::SettingKey,
},
/// Resolve the reset-settings confirmation modal.
/// `Reset` dispatches the default value via the typed `SetX` action.
/// `Cancel` restores the underlying Settings modal unchanged.
ConfirmResetSetting {
choice: crate::views::modal::ResetSettingsResult,
},
/// Dump the input flight recorder to a debug file.
DumpInputLog,
/// User selected a permission option (AllowOnce, AllowAlways, RejectAlways).
PermissionSelect(acp::PermissionOptionId),
/// User typed a followup message and submitted it (RejectOnce with message).
PermissionFollowup(String),
/// User cancelled the front permission request (Ctrl-C / Esc in Options mode).
PermissionCancel,
/// Log out: remove credentials and return to the login screen.
Logout,
/// Log out and immediately start a new login flow.
SwitchAccount,
/// User pressed login on the welcome screen.
Login,
/// User chose a specific OAuth provider row on the login picker. Routes
/// the flow to THAT advertised method — `Login` alone resolves to the
/// first interactive method, which sent every provider row to the Kimi
/// device flow.
LoginWith(acp::AuthMethodId),
/// Cancel an in-progress login that was started from inside a session
/// (`/login` or a 401 re-auth prompt) and return to the previous view.
/// Distinct from `Quit`: abandoning a mid-session re-auth must not exit
/// the app or lose the open session.
CancelLogin,
/// User submitted a manually-pasted auth token (loopback mode).
SubmitAuthCode(String),
/// User selected a Moonshot row on the welcome login picker: switch the
/// welcome screen into API-key entry for that platform.
BeginPlatformKeyEntry(crate::app::app_view::PlatformLogin),
/// Esc from the API-key entry box: return to the login picker.
CancelPlatformKeyEntry,
/// User submitted a pasted platform API key: persist it to auth.json
/// under the platform-id scope, then authenticate with the platform's
/// method id. The key must never be logged.
SubmitPlatformApiKey(String),
/// Copy the auth URL to the clipboard during authentication.
CopyAuthUrl,
/// Show the raw auth URL with mouse capture disabled for manual copy.
ShowRawAuthUrl,
/// Hide the raw auth URL and re-enable mouse capture.
HideRawAuthUrl,
/// User accepted the folder-trust question: persist the grant for the cwd's
/// workspace, mark trust resolved, and replay any deferred session startup.
/// (Declining quits via [`Action::Quit`]; there is no decline action.)
TrustFolder,
/// A spawned task completed.
TaskComplete(TaskResult),
/// Show session info (ID, cwd, model, context usage) instantly.
ShowSessionInfo,
/// Show release notes in a modal.
ShowReleaseNotes {
title: String,
content: String,
},
/// Rename the current session's title/summary.
RenameSession {
title: String,
},
/// Show detailed context usage (progress bar, token breakdown, stats).
ShowContextInfo,
/// Show Kimi usage/quota via the /usage command.
ShowUsage,
/// Commit a read-only list of the queued prompts as a system block
/// (`/queue`). The surface minimal mode uses in place of the `QueuePane`.
ShowQueue,
/// Commit a read-only list of background tasks, subagents, and scheduled
/// tasks as a system block (`/tasks`). The surface minimal mode uses in
/// place of the `TasksPane`.
ShowTasks,
/// Show the current plan: preview popover if exists, toast if not.
ShowPlan,
/// Enter plan mode. If a description is provided, also start a turn
/// with that text as the prompt.
EnterPlanMode {
description: Option<String>,
},
/// Set plan mode on/off. Per-session, ACP-mediated (not persisted
/// to config.toml). `/plan <desc>` uses `EnterPlanMode` instead
/// because it also starts a turn.
SetPlanMode(PlanModeKind),
/// Enter remember mode (visual prompt change, not a send).
EnterRememberMode,
/// Send a remember note from # mode. Routes through LLM rewrite when a
/// session is active; falls back to direct save otherwise.
SendRememberNote(String),
/// Save the currently displayed remember note from the review modal.
SaveRememberNoteFromModal,
/// Send a /btw side question (bypasses queue, works while agent is busy).
SendBtw(String),
/// Request a session recap ("where was I" summary). `auto` is `true` for
/// the automatic return-from-away recap, `false` for an explicit `/recap`.
/// Bypasses the prompt queue (works while the agent is busy).
SendRecap {
auto: bool,
},
/// Pick a session from content (deep search) results.
PickContentSession {
session_id: String,
cwd: String,
},
/// Pick a session from content (deep search) results and resume in a worktree.
PickContentSessionInWorktree {
session_id: String,
cwd: String,
},
/// Delete a session from history (local + remote). Fired from the
/// session picker: `d` arms delete confirmation on the focused row,
/// then `y` confirms (or `n`/other cancels).
DeleteSession {
source: String,
session_id: String,
cwd: String,
},
/// Trigger a deep content search for sessions matching the picker query.
TriggerDeepSearch,
/// Force an immediate deep content search, skipping the debounce.
ForceDeepSearch,
/// `/fork` slash command: parsed args produced by
/// [`crate::slash::commands::fork::parse_fork_args`]. The dispatcher
/// resolves the worktree question (via flag or the local
/// QuestionView modal) before constructing the placeholder.
Fork(crate::slash::commands::fork::ForkArgs),
/// Submit-path action emitted by the local fork worktree question
/// modal. Routes directly to `dispatch_fork_resolved`.
ForkAnswered {
worktree: bool,
directive: Option<String>,
/// When `Some`, also persist this worktree mode preference so
/// future `/fork` invocations skip the popup.
persist_mode: Option<crate::app::app_view::WorktreeMode>,
},
/// Submit-path action emitted by the local `/new` worktree question
/// modal. `worktree: true` creates the new session in a worktree;
/// `worktree: false` creates it in the current cwd.
NewSessionAnswered {
worktree: bool,
/// When `Some`, also persist this worktree mode preference so
/// future `/new` invocations skip the popup.
persist_mode: Option<crate::app::app_view::WorktreeMode>,
},
/// Answer from the agent-type-mismatch question modal. Shown when
/// the shell rejects a model switch because the target model requires
/// a different agent harness.
AgentTypeMismatchAnswered {
/// `true` = start a new session with the target model.
/// `false` = cancel, return to current session.
start_new: bool,
model_id: acp::ModelId,
effort: Option<ReasoningEffort>,
},
/// User selected a project directory from the project picker.
ProjectSelected {
path: std::path::PathBuf,
stashed_prompt: String,
/// "Don't ask me again" was chosen: persist the opt-out.
disable_picker: bool,
},
/// Persist the memory modal fullscreen preference to config.toml.
PersistMemoryFullscreen(bool),
/// Open the Agent Dashboard view (`/dashboard`, `Ctrl+\`, `kigi dashboard`).
OpenDashboard,
/// Close the dashboard, returning to the previous `ActiveView`.
ExitDashboard,
/// Attach to a dashboard row — switches to the parent agent and
/// (for subagent rows) sets the parent's `active_subagent`.
DashboardAttach(crate::views::dashboard::DashboardRowId),
/// Submit the dispatch-input contents: either a filter, a new
/// top-level agent, or a no-op when too short.
/// `attach` mirrors Ctrl+S (dispatch + attach / "send + open").
DashboardDispatch {
text: String,
attach: bool,
},
/// Submit a slash command from the dashboard's dispatch input.
/// The text starts with `/`. The dispatcher resolves it through
/// the slash registry (builtin / ACP / unknown) without a
/// per-agent session context — commands like `/dashboard`,
/// `/exit`, `/theme`, `/settings`, `/help` work without a
/// session; commands that need one return a friendly toast.
DashboardDispatchSlash {
text: String,
},
/// Pin/unpin the currently selected row.
DashboardTogglePin,
/// Enter inline-rename mode on the selected row.
DashboardBeginRename,
/// Commit a rename draft. Empty draft cancels.
DashboardCommitRename,
/// Cancel an in-progress rename without committing.
DashboardCancelRename,
/// Apply a single keystroke to the in-progress rename draft.
DashboardRenameInput(String),
/// Stop / kill the selected row (top-level: cancel turn → close;
/// subagent: kill). Double-press protected for top-level rows.
DashboardStop,
/// Cycle the dispatch input's mode for the next spawned agent
/// (Normal → Plan → Always-Approve → Normal). Bound to Shift+Tab.
DashboardCycleMode,
/// Cycle the PEEKED agent's live mode (Normal → Plan → Always-Approve
/// → Normal) — the peek-panel counterpart to [`Self::DashboardCycleMode`].
/// Unlike that staged dispatch mode, this changes the existing agent
/// directly (same effect as Shift+Tab inside the agent's chat view).
/// Emitted when Shift+Tab fires while the peek panel is open.
DashboardPeekCycleMode,
/// Toggle grouping (State ↔ Directory).
DashboardToggleGrouping,
/// Set the live filter (typically driven by the dispatch input).
DashboardSetFilter(crate::views::dashboard::FilterValue),
/// Move selection cursor one row down.
DashboardSelectNext,
/// Move selection cursor one row up.
DashboardSelectPrev,
/// Reorder the selected row one slot up (Shift+↑).
DashboardReorderUp,
/// Reorder the selected row one slot down (Shift+↓).
DashboardReorderDown,
/// Exit the dashboard's session-overlay (the bordered
/// `[Prev] [Next] [✗]` chrome wrapped around an attached
/// agent view). Returns to the dashboard with the cursor on
/// the previously attached row. Bound to Esc / Ctrl+\\ /
/// `[✗]` click inside the overlay.
DashboardOverlayExit,
/// Cycle the dashboard's session-overlay to the previous
/// top-level agent in the row list (`[Prev]` click or
/// Ctrl+\[).
DashboardOverlayPrev,
/// Cycle the dashboard's session-overlay to the next
/// top-level agent in the row list (`[Next]` click or
/// Ctrl+\]).
DashboardOverlayNext,
/// Confirmed stop from inside the dashboard's session-overlay:
/// close the attached session and return to the dashboard. State
/// machine documented at `dispatch_dashboard_overlay_stop`.
DashboardOverlayStop,
/// Toggle auto-approve (YOLO mode) on the selected row's
/// owning agent. Mirrors `Action::ToggleYolo` but targets
/// the dashboard's selected row instead of the active-view
/// agent.
DashboardToggleAutoApprove,
/// Toggle worktree-dispatch mode: when on, the next agent dispatched
/// from the dashboard spawns in a fresh git worktree (and the `[+ New
/// Agent]` button reads `[+ New Worktree]`). Bound to Ctrl+W. Gated on
/// the cwd being a git repo — worktrees require one, so the toggle is a
/// no-op (with an explanatory toast) outside a repo.
DashboardToggleWorktree,
/// Open the dashboard's shortcuts cheatsheet modal — the
/// same searchable picker as the agent view's
/// `ShortcutsHelp`, scoped to dashboard-context bindings.
/// Bound to Ctrl+. (default) and `?` (alt). Dispatch wires
/// it to the modal-state field on `DashboardState`; the
/// renderer paints it on top of the row list.
DashboardOpenShortcutsHelp,
/// Close the dashboard's shortcuts cheatsheet modal. Routed
/// from the modal-chrome `CloseRequested` outcome and from
/// Esc when the modal is open.
DashboardCloseShortcutsHelp,
/// Focus the header's `[+ New Agent]` button. Mirrors a
/// row-selection action — the button becomes the cursor
/// target, the previous row selection (if any) clears, and
/// the dispatch input's placeholder flips back to the
/// new-session form. Wired to Esc deselect tier, Up-arrow
/// from the first row, and the button's mouse-click handler.
DashboardFocusNewAgentButton,
/// Create a new session AND open its detail view. Routed
/// from `[+ New Agent]` click and from Enter-on-empty-prompt
/// while the button is focused. Distinct from
/// `DashboardDispatch` (which queues a prompt + stays on the
/// dashboard) because the no-prompt path has no text to
/// enqueue and the user expects to land inside the new
/// agent's view immediately.
DashboardCreateNewAgentWithDetail,
/// Open the dashboard's location picker — a floating modal that
/// lists recent project directories (plus the current cwd) and
/// accepts a typed path, letting the user change where newly
/// dispatched sessions run. Bound to Ctrl+L (default), a click on
/// the header location label, and `/cd` with no argument.
DashboardOpenLocationPicker,
/// Close the dashboard's location picker modal without changing
/// the working directory. Routed from Esc, the modal-chrome
/// `CloseRequested` outcome, and a click outside the modal.
DashboardCloseLocationPicker,
/// Change the working directory for newly dispatched dashboard
/// sessions. `input` is the raw path text — a picker row's path, a
/// path typed into the picker's query field, or the `/cd <path>`
/// argument. The dispatcher resolves `~` / relative paths against
/// `app.cwd`, validates the target is a directory, and on success
/// updates `app.cwd` + the process cwd (via `Effect::SetWorkingDir`).
DashboardChangeLocation {
input: String,
},
/// Confirm the dashboard worktree-label dialog: create the next
/// dashboard agent in a fresh git worktree (rooted at `app.cwd`) using
/// `label` (`None` → auto-generated). Any prompt stashed when the dialog
/// opened (a prompt-send) is replayed into the new agent. Routed from
/// the dialog's Enter submit.
DashboardConfirmWorktree {
label: Option<String>,
},
/// Answer a permission request via the dashboard peek panel.
///
/// Carries `request_id` captured at peek-toggle time so that a
/// stale answer (the front-of-queue changed between snapshot and
/// number-key press) can be detected and dropped instead of
/// popping the wrong permission.
DashboardPermissionSelect {
row: crate::views::dashboard::DashboardRowId,
request_id: usize,
option_id: acp::PermissionOptionId,
},
/// Reject the peeked agent's pending permission with a typed
/// feedback message (the peek panel's "No, type to add feedback"
/// path). Resolves the front request with the `RejectOnce` option
/// and the text attached as `followup_message` meta — mirroring the
/// agent view's `PermissionFollowup`. `request_id` guards against a
/// stale answer if the queue rotated.
DashboardPermissionFollowup {
row: crate::views::dashboard::DashboardRowId,
request_id: usize,
text: String,
},
/// Answer the peeked agent's pending `AskUserQuestion` (the Ask tool)
/// from the dashboard peek panel. `option_idx` selects an option;
/// `None` with non-empty `freeform` submits the "Other" free-text
/// answer. Only valid for a single-question, single-select ext ask.
DashboardQuestionAnswer {
row: crate::views::dashboard::DashboardRowId,
option_idx: Option<usize>,
freeform: String,
},
/// Send / queue a reply to the peeked agent from the dashboard
/// peek panel's ` reply` input. The reply targets `row`'s owning
/// top-level agent: when it is idle the prompt is sent immediately
/// (a turn starts), when it is mid-turn the prompt is queued and
/// drains after the current turn finishes. `attach` (Ctrl+S)
/// additionally walks into the agent's detail view.
DashboardPeekReply {
row: crate::views::dashboard::DashboardRowId,
text: String,
attach: bool,
},
/// Open the memory browser modal.
OpenMemoryModal,
/// Open the hidden `/gboom` easter egg (DOOM-style raycaster modal).
OpenGboom,
/// Suspend the TUI and open a file in $EDITOR.
SuspendForEditor {
path: std::path::PathBuf,
/// Reload `/config-agents` list after the editor exits (when set).
refresh_agents_modal: Option<crate::views::agents_modal::AgentsTab>,
},
/// Toggle the expanded goal detail overlay.
ToggleGoalDetail,
Rewind,
RewindShowPicker,
RewindPickerSelect(usize),
RewindSelectMode(crate::views::rewind::RewindMode, usize),
RewindConfirm(usize, crate::views::rewind::RewindMode),
RewindConversationOnlyConfirm(usize),
RewindCancelOffer,
RewindDismiss,
RewindBackToModeSelect,
RewindDismissError,
/// Submit an inline edit: conversation-only rewind to that prompt, then
/// resubmit the edited text (state lives on `AgentView::inline_edit`).
InlineEditSubmit,
/// Open the `/jump` turn picker.
JumpShowPicker,
/// Jump to a turn by its prompt's stable id and close the picker.
JumpPickerSelect(EntryId),
/// Close the picker and restore the stashed viewport.
JumpDismiss,
}
/// Persist-and-notify semantics for [`Effect::PersistPermissionMode`].
///
/// Both variants write to `~/.kigi/config.toml` and route ACP
/// `kigi/yolo_mode_changed` notifications. The ACP notification is
/// gated on disk-write success when `WithRollback` is used.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionModePersist {
/// Typed-setter path: on disk-write failure, revert in-memory state
/// to the prior canonical (`&'static str`). ACP notification is
/// suppressed on failure so the agent never sees the optimistic value.
/// The soft-default latch is NOT restored — a failed persist leaves the
/// mode user-claimed until restart, matching the cycle path.
WithRollback(&'static str),
/// Cycle-mode path: no clean single-field rollback. On disk failure,
/// logs a warning and leaves in-memory state at the optimistic value.
/// ACP notification fires unconditionally.
BestEffort,
}
/// Canonical permission-mode state for the `permission_mode` setting.
///
/// `Default` and `Ask` both project onto `yolo_mode = false` at runtime
/// but are distinct on disk — `Default` expresses "use the agent's
/// default" while `Ask` is the explicit "prompt me every time".
/// `Auto` uses the LLM classifier (not full always-approve).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionModeKind {
/// Agent's default behavior (prompt). `yolo_mode = false`.
Default,
/// Explicit prompt-every-time. `yolo_mode = false`.
Ask,
/// LLM classifier for non-fast-path tools. `yolo_mode = false`, `auto_mode = true`.
Auto,
/// Auto-approve all tool actions. `yolo_mode = true`.
AlwaysApprove,
}
impl PermissionModeKind {
/// Canonical persisted/wire string for the kind. Matches the
/// `EnumChoice.canonical` values in
/// `settings/defs.rs::PERMISSION_MODE_CHOICES`.
pub fn as_canonical(self) -> &'static str {
match self {
Self::Default => "default",
Self::Ask => "ask",
Self::Auto => "auto",
Self::AlwaysApprove => "always-approve",
}
}
/// Bool projection onto the YOLO runtime flag — `AlwaysApprove
/// → true`, everything else → `false`. Used by `set_yolo_mode_inner`
/// to perform the actual state mutation (`agent.session.yolo_mode`,
/// `app.default_yolo`, permission_queue drain) without caring
/// about the canonical distinction. The canonical is restored
/// afterwards by `set_permission_mode`.
pub fn is_always_approve(self) -> bool {
matches!(self, Self::AlwaysApprove)
}
/// LLM classifier mode (distinct from always-approve).
pub fn is_auto(self) -> bool {
matches!(self, Self::Auto)
}
/// Construct from a canonical string. Returns `None` for unknown
/// strings. Used by `apply_setting_rollback("permission_mode", _)`
/// to recover the typed kind from the `SettingValue::Enum(canonical)`
/// rollback payload.
pub fn from_canonical(s: &str) -> Option<Self> {
match s {
"default" => Some(Self::Default),
"ask" => Some(Self::Ask),
"auto" => Some(Self::Auto),
"always-approve" => Some(Self::AlwaysApprove),
_ => None,
}
}
}
#[cfg(test)]
mod permission_mode_kind_tests {
use super::PermissionModeKind;
#[test]
fn auto_is_distinct_from_always_approve() {
let auto = PermissionModeKind::Auto;
assert_eq!(auto.as_canonical(), "auto");
assert!(!auto.is_always_approve());
assert!(auto.is_auto());
assert_eq!(
PermissionModeKind::from_canonical("auto"),
Some(PermissionModeKind::Auto)
);
assert_ne!(
PermissionModeKind::Auto.as_canonical(),
PermissionModeKind::AlwaysApprove.as_canonical()
);
}
#[test]
fn permission_mode_choices_include_auto_in_catalog() {
for c in ["default", "ask", "auto", "always-approve"] {
assert!(
PermissionModeKind::from_canonical(c).is_some(),
"catalog canonical {c} must parse"
);
}
}
}
/// Canonical on/off state for `plan_mode`. Binary today (single bit
/// on `agent.plan_mode_active`); typed enum so a future third state
/// can be added without churning dispatcher arms.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanModeKind {
/// Agent in `SessionMode::Plan` — no tool writes, plan-first.
On,
/// Agent in `SessionMode::Default`.
Off,
}
impl PlanModeKind {
/// Canonical persisted/wire string for the kind. Matches the
/// `EnumChoice.canonical` values in
/// `settings/defs.rs::PLAN_MODE_CHOICES`.
pub fn as_canonical(self) -> &'static str {
match self {
Self::On => "on",
Self::Off => "off",
}
}
/// Display label used in the toast and the picker. Mirrors
/// `EnumChoice.display`.
pub fn as_display(self) -> &'static str {
match self {
Self::On => "On",
Self::Off => "Off",
}
}
/// Bool projection — `On → true`, `Off → false`. Used by the
/// dispatcher's idempotency check against `plan_mode_active`.
pub fn to_bool(self) -> bool {
matches!(self, Self::On)
}
/// Construct from a bool (the inverse of [`Self::to_bool`]).
pub fn from_bool(b: bool) -> Self {
if b { Self::On } else { Self::Off }
}
}
/// Async side effect produced by [`super::dispatch::dispatch`].
///
/// The event loop spawns these into a `JoinSet`. When they complete,
/// the result is wrapped in [`TaskResult`] and fed back through
/// `Action::TaskComplete`.
/// What user gesture triggered a turn cancel. Recorded on `session/cancel`'s
/// `_meta.cancelTrigger` so the agent's `mid_turn_abort` telemetry can tell
/// ESC from Ctrl+C (and a mouse click on the cancel button) apart. Free-form
/// on the wire (the agent stores it in `cancellation_context`), so adding a
/// variant needs no agent/schema change.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CancelTrigger {
/// Wire value `"esc"` (set only by the Esc cancel-retry while
/// TurnCancelling; a bare Esc no longer starts a cancel).
Esc,
/// `Ctrl+C` pressed (the default cancel keybinding).
CtrlC,
/// The on-screen cancel button was clicked.
Mouse,
}
impl CancelTrigger {
/// Snake_case wire string sent as `_meta.cancelTrigger`.
pub fn as_wire_str(self) -> &'static str {
match self {
Self::Esc => "esc",
Self::CtrlC => "ctrl_c",
Self::Mouse => "mouse",
}
}
}
/// Where a deferred clipboard-attachment paste lands once the off-thread probe
/// finishes (see [`Effect::ProbeClipboardAttachment`]).
#[derive(Debug, Clone)]
pub enum ClipboardPasteTarget {
/// Agent prompt input. `images_dir` is the session images dir used for the
/// off-thread persist (`None` when no session exists yet).
AgentPrompt {
agent_id: AgentId,
images_dir: Option<std::path::PathBuf>,
},
/// Dashboard new-session dispatch input.
DashboardDispatch,
/// Dashboard peek reply input. `row` is the peeked row at enqueue time; the
/// completion drops the paste if the panel closed or moved to another row,
/// so the attachment can't land in a different agent's reply.
DashboardPeek {
row: crate::views::dashboard::DashboardRowId,
},
}
impl ClipboardPasteTarget {
/// Telemetry surface label for the empty-clipboard paste-key event.
pub fn surface_str(&self) -> &'static str {
match self {
Self::AgentPrompt { .. } => "agent",
Self::DashboardDispatch => "dashboard",
Self::DashboardPeek { .. } => "peek",
}
}
}
/// Result of the originating CLIPBOARD text read.
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum ClipboardTextRead {
/// The text backend completed; `None` means it successfully found no text.
Success(Option<String>),
/// The text backend failed, so emptiness is unknown.
Failed,
}
impl ClipboardTextRead {
pub fn from_result<E>(result: Result<Option<String>, E>) -> Self {
match result {
Ok(text) => Self::Success(text),
Err(_) => Self::Failed,
}
}
pub fn as_deref(&self) -> Option<&str> {
match self {
Self::Success(text) => text.as_deref(),
Self::Failed => None,
}
}
}
/// Result of inserting clipboard text into a target.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClipboardTextInsertion {
/// The target accepted and applied the text operation.
Inserted,
/// The payload had no pasteable text.
Empty,
/// The target rejected a non-empty text operation.
Failed,
}
#[derive(Debug, Clone)]
pub enum ClipboardPasteSource {
/// Ctrl/Cmd+V: CLIPBOARD text was read before the attachment probe.
ClipboardKey {
text: ClipboardTextRead,
tip_showing: bool,
},
/// Dashboard bracketed text waits for the attachment probe's image-wins decision.
BracketedDeferred { text: String },
/// Agent bracketed text was inserted synchronously before probing attachments.
BracketedInserted {
text: String,
insertion: ClipboardTextInsertion,
},
}
impl ClipboardPasteSource {
pub fn text(&self) -> Option<&str> {
match self {
Self::ClipboardKey { text, .. } => text.as_deref(),
Self::BracketedDeferred { text } | Self::BracketedInserted { text, .. } => Some(text),
}
}
pub fn is_clipboard_key(&self) -> bool {
matches!(self, Self::ClipboardKey { .. })
}
pub fn is_bracketed(&self) -> bool {
!self.is_clipboard_key()
}
pub fn tip_showing(&self) -> bool {
matches!(
self,
Self::ClipboardKey {
tip_showing: true,
..
}
)
}
pub fn text_read_failed(&self) -> bool {
matches!(
self,
Self::ClipboardKey {
text: ClipboardTextRead::Failed,
..
}
)
}
pub fn text_to_insert_on_miss(&self) -> Option<&str> {
match self {
Self::ClipboardKey { text, .. } => text.as_deref(),
Self::BracketedDeferred { text } => Some(text),
Self::BracketedInserted { .. } => None,
}
}
pub fn synchronous_insertion(&self) -> Option<ClipboardTextInsertion> {
match self {
Self::BracketedInserted { insertion, .. } => Some(*insertion),
_ => None,
}
}
}
/// Completion context carried through one deferred clipboard-attachment paste.
#[derive(Debug, Clone)]
pub struct ClipboardPasteContext {
pub target: ClipboardPasteTarget,
pub source: ClipboardPasteSource,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClipboardPasteFailure {
/// CLIPBOARD text could not be read.
TextRead,
/// Image/file attachment probing failed.
AttachmentRead,
/// The target rejected text without showing its own message.
TargetInsertion,
/// The target already showed a persistence/insertion message.
AlreadyReported,
}
/// Result after the originating target applies a clipboard probe.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClipboardPasteCompletion {
/// Text, image, or file URL reached the target.
Handled,
/// All local reads completed successfully with no payload.
FullMiss,
/// The result was intentionally discarded because its target or baseline went stale.
Dropped,
/// The probe, persistence, or target insertion failed.
Failed(ClipboardPasteFailure),
}
pub fn reduce_clipboard_paste_completion(
source: &ClipboardPasteSource,
attachment: ClipboardPasteCompletion,
file: Option<ClipboardPasteCompletion>,
text: Option<ClipboardTextInsertion>,
) -> ClipboardPasteCompletion {
match attachment {
ClipboardPasteCompletion::FullMiss => {}
other => return other,
}
if let Some(file) = file {
return file;
}
if source.text_read_failed() {
return ClipboardPasteCompletion::Failed(ClipboardPasteFailure::TextRead);
}
if let Some(insertion) = text.or(source.synchronous_insertion()) {
return match insertion {
ClipboardTextInsertion::Inserted => ClipboardPasteCompletion::Handled,
ClipboardTextInsertion::Empty => ClipboardPasteCompletion::FullMiss,
ClipboardTextInsertion::Failed => {
ClipboardPasteCompletion::Failed(ClipboardPasteFailure::TargetInsertion)
}
};
}
ClipboardPasteCompletion::FullMiss
}
/// Outcome of the off-thread image portion of a clipboard attachment probe.
#[derive(Debug)]
pub enum ProbedAttachment {
/// Decoded (and, for the agent, persisted) image ready to insert.
Image(crate::prompt_images::PastedImage),
/// The session persist failed; the completion shows a toast.
PersistFailed(String),
/// The pasteboard probe completed normally without raster data.
NoRaster,
/// The attachment result was intentionally discarded because its baseline went stale.
ProbeDropped,
/// The attachment probe task failed or timed out.
ProbeFailed,
}
#[derive(Debug)]
pub enum Effect {
/// Create a new ACP session.
CreateSession {
agent_id: AgentId,
cwd: std::path::PathBuf,
/// When set, injected as `_meta.modelId` into the
/// `NewSessionRequest` so the shell spawns the session with
/// the correct model and agent type from the start — avoids a
/// follow-up `SetSessionModel` roundtrip.
model_id: Option<acp::ModelId>,
/// Client-chosen session ID (`--session-id` / `meta.sessionId`).
preferred_session_id: Option<String>,
/// Gateway light-frontend for **this** session only (`/chat` one-shot
/// or CLI `--chat` via `SessionFlags.chat_mode`). Does not sticky-set
/// process-wide mode.
chat_kind: bool,
},
/// Change the process working directory (project-picker selection).
SetWorkingDir { path: std::path::PathBuf },
/// Create a git worktree and then create or load an ACP session in it.
/// When `load_session_id` is `Some`, loads that session in the new worktree
/// instead of creating a fresh one (`--resume` + `--worktree` combination).
CreateWorktreeSession {
agent_id: AgentId,
load_session_id: Option<String>,
label: Option<String>,
/// Optional branch/tag/commit to base the worktree on (CLI `--ref`).
git_ref: Option<String>,
/// Staged dashboard `/model` selection, injected as `_meta.modelId`
/// into the worktree's `NewSessionRequest` so it spawns with the
/// right model — mirrors [`Effect::CreateSession::model_id`]. `None`
/// for the welcome / CLI / fork paths.
model_id: Option<acp::ModelId>,
/// Client-chosen session ID (`--session-id` with `--worktree`) used as
/// the worktree/session id and `meta.sessionId` on fresh create.
/// Ignored when `load_session_id` is set (resume path owns the id).
preferred_session_id: Option<String>,
/// One-shot `/chat` or sticky `--chat` — stamp `_meta` kind=chat on
/// fresh create (resume uses `LoadSession.chat_kind` instead).
chat_kind: bool,
},
/// Load (resume) an existing ACP session by ID.
///
/// `session_cwd` overrides the CWD sent in the `LoadSessionRequest`.
/// This is needed when resuming a session that was created in a different
/// CWD (e.g., a worktree) than the one the user is currently in.
///
/// Strict load — does not create if missing.
///
/// `chat_kind` is the conversation-entry bit; effects also stamp kind=chat
/// when [`SessionFlags::chat_mode`] (`--chat`) is set.
LoadSession {
agent_id: AgentId,
session_id: String,
session_cwd: Option<std::path::PathBuf>,
/// Conversation-entry bit (`source == "conversation"`), not sticky `--chat`.
chat_kind: bool,
},
/// Scan enabled foreign session stores without delaying the native list.
ScanForeignSessions {
cwd: std::path::PathBuf,
compat: kigi_workspace::foreign_sessions::EnabledForeignSessionSources,
kigi_home: std::path::PathBuf,
coordinator: crate::app::ForeignScanCoordinator,
seq: u64,
},
/// Canonicalize the launch cwd off the event-loop thread before store access.
CanonicalizeForeignResumeCwd {
requested_cwd: std::path::PathBuf,
launch_token: u64,
},
/// Detect the newest resumable foreign session without delaying first paint.
DetectForeignResumeHint {
canonical_cwd: std::path::PathBuf,
compat: kigi_workspace::foreign_sessions::EnabledForeignSessionSources,
kigi_home: std::path::PathBuf,
launch_token: u64,
},
/// Fetch session list for the welcome screen session picker.
FetchSessionList {
/// Text search pushed down to `kigi/session/list` as `query` (chat
/// mode: forwarded to the backend conversations search). `None`
/// fetches the unfiltered list.
query: Option<String>,
/// Snapshot of [`crate::app::app_view::AppView::session_picker_list_seq`];
/// the response is dropped when no longer current, so out-of-order
/// completions can't clobber newer results.
seq: u64,
},
/// Coalesce picker search keystrokes: fires
/// [`TaskResult::SessionSearchDebounceExpired`] after a short sleep; the
/// expiry acts only if `seq` is still current (Build: FTS5 deep search
/// against the deep-search seq; chat: server refetch against the list seq).
DebounceSessionSearch { query: String, seq: u64 },
/// Fetch the leader session roster (FleetView dashboard) via
/// `kigi/sessions/list`. Only issued in leader mode while the
/// dashboard is open.
FetchRoster,
/// Fetch the local on-disk session list (dormant/idle sessions) for the
/// dashboard via `kigi/session/list` — the non-leader fallback for the
/// FleetView roster. Issued while the dashboard is open and NOT in leader
/// mode so the dashboard shows idle sessions instead of being empty.
FetchDashboardSessions,
/// Load card detail for a specific session (lazy, reads chat history from disk).
LoadCardDetail {
source: String,
session_id: String,
cwd: String,
generation: u64,
},
/// Restore a remote session from GCS then load it. Only Build rows reach
/// this effect: conversation rows have no GCS archive.
RestoreAndLoadSession {
agent_id: AgentId,
session_id: String,
session_cwd: String,
},
/// Send a prompt to the agent.
SendPrompt {
agent_id: AgentId,
session_id: acp::SessionId,
text: String,
/// Client-generated UUID echoed back on every notification + the
/// PromptResponse so the client can correlate them to this prompt.
prompt_id: String,
/// Recognized slash-token byte ranges into `text`, stamped into the
/// content block `_meta` (`skillTokenRanges`) when non-empty so
/// replay restyles the echo like the composer did. Contract: the
/// offsets index the block's `text` displayed verbatim — never
/// combined with a `displayText` override.
skill_token_ranges: Vec<std::ops::Range<usize>>,
},
/// Send a direct bash command to the agent (with typed PromptBlockMeta).
SendBashCommand {
agent_id: AgentId,
session_id: acp::SessionId,
command: String,
/// See [`Effect::SendPrompt::prompt_id`].
prompt_id: String,
},
/// Cancel the current turn.
CancelTurn {
session_id: acp::SessionId,
cancel_subagents: bool,
/// What user gesture triggered the cancel (ESC / Ctrl+C / mouse), sent
/// on `session/cancel` as `_meta.cancelTrigger` so the agent's
/// `mid_turn_abort` telemetry can distinguish them. `None` for
/// programmatic cancels (login/reauth flows).
trigger: Option<CancelTrigger>,
/// Ask the shell to trim the in-flight prompt from session history when
/// the turn is still pristine (no server activity), sent as
/// `_meta.rewindIfPristine`. Set true ONLY when the pager has locally
/// rewound the prompt back into the composer, so the shell's history
/// matches the UI. Without it the shell keeps the prompt plus an
/// interruption marker, and a later resend pairs the kept copy with the
/// resend — the send+Ctrl+C double-prompt bug.
rewind_if_pristine: bool,
},
/// Run a manual `/compact` command.
Compact {
agent_id: AgentId,
session_id: acp::SessionId,
},
/// Kill a background task.
KillBgTask {
session_id: acp::SessionId,
task_id: String,
},
/// Cancel a subagent via `kigi/subagent/cancel`.
KillSubagent {
session_id: acp::SessionId,
subagent_id: String,
},
DeleteScheduledTask {
session_id: acp::SessionId,
task_id: String,
},
/// Demote a foreground execute tool to background.
DemoteToBackground {
session_id: acp::SessionId,
tool_call_id: String,
},
/// Switch active model.
SwitchModel {
agent_id: AgentId,
session_id: acp::SessionId,
model_id: acp::ModelId,
effort: Option<ReasoningEffort>,
/// The model that was active before the optimistic UI update
/// in `set_default_model`. `None` for `Action::SwitchModel`
/// (no optimistic update). Threaded through to
/// `SwitchModelComplete` so `IncompatibleAgent` can roll back.
prev_model_id: Option<acp::ModelId>,
},
/// Persist memory modal fullscreen preference to `[hints]` in config.toml.
PersistMemoryFullscreen { fullscreen: bool },
/// Persist the project-picker opt-out to `[hints] project_picker_disabled`.
PersistProjectPickerDisabled { disabled: bool },
/// Persist the dashboard's `[dashboard]` configuration to `~/.kigi/config.toml`.
/// Edge case 15: multi-pager safe via `config_toml_edit::read_config_document_for_edit`,
/// which loads → modifies → writes the whole document. Concurrent
/// pagers may produce last-writer-wins behaviour but never corrupt
/// the file.
PersistDashboard(crate::views::dashboard::PersistedDashboard),
/// Persist a per-command worktree mode preference to `[hints]` in
/// config.toml. `config_key` is the TOML key under `[hints]`
/// (`"new_session_worktree_mode"` or `"fork_worktree_mode"`).
PersistWorktreeMode {
mode: crate::app::app_view::WorktreeMode,
config_key: &'static str,
},
/// Persist preferred model (and effort if Some) to config.toml.
PersistPreferredModel {
model_id: acp::ModelId,
reasoning_effort: Option<ReasoningEffort>,
},
/// Persist the permission mode to config.toml and notify the agent
/// via ACP. See [`PermissionModePersist`] for rollback semantics.
PersistPermissionMode {
/// One of `"ask"`, `"always-approve"`, or `"default"`.
canonical: &'static str,
session_id: Option<acp::SessionId>,
persist: PermissionModePersist,
},
/// Persist a typed setting to `~/.kigi/config.toml`. On failure,
/// rolls the in-memory cache back to `rollback_value`.
PersistSetting {
key: crate::settings::SettingKey,
value: crate::settings::SettingValue,
rollback_value: crate::settings::SettingValue,
},
/// Send structured prompt blocks to the agent.
/// Used for skill injection where the prompt consists of
/// multiple content blocks (metadata + skill body).
SendPromptBlocks {
agent_id: AgentId,
session_id: acp::SessionId,
blocks: Vec<acp::ContentBlock>,
/// See [`Effect::SendPrompt::prompt_id`].
prompt_id: String,
},
/// Cancel-and-send: `session/prompt` stamped with `_meta.sendNow`, so the
/// shell cancels the running turn and runs this prompt next (background
/// tasks and the rest of the queue survive). Carries structured blocks so
/// pasted images ride along.
SendPromptNow {
agent_id: AgentId,
session_id: acp::SessionId,
blocks: Vec<acp::ContentBlock>,
/// See [`Effect::SendPrompt::prompt_id`].
prompt_id: String,
},
/// Toggle plan mode — fire-and-forget signal to the shell.
TogglePlanMode { session_id: acp::SessionId },
/// Remove a server-owned queued prompt: fire-and-forget
/// `kigi/queue/remove`. The agent re-broadcasts the authoritative queue.
QueueRemove {
session_id: acp::SessionId,
id: String,
expected_version: u64,
},
/// Reorder server-owned queued prompts: fire-and-forget `kigi/queue/reorder`.
QueueReorder {
session_id: acp::SessionId,
ordered_ids: Vec<String>,
},
/// Clear the caller's server-owned queued prompts: fire-and-forget
/// `kigi/queue/clear`.
QueueClear { session_id: acp::SessionId },
/// Replace the text of a server-owned queued prompt in place: fire-and-forget
/// `kigi/queue/edit`. The session actor's serialized mailbox makes this
/// last-writer-wins for concurrent edits; the rebroadcast of
/// `kigi/queue/changed` is the truth signal.
QueueEdit {
session_id: acp::SessionId,
id: String,
new_text: String,
},
/// Interject a server-owned queued prompt into the running turn:
/// fire-and-forget `kigi/queue/interject`. The session actor atomically
/// removes it from the queue and merges its text into the in-flight turn,
/// then broadcasts both the interjection and the authoritative queue.
/// `new_text` (when `Some`, serialized as `newText`) replaces the stored
/// queue text in the interjection — same single version check, so a stale
/// version no-ops the edit too. If the turn already ended, the agent
/// saves a version-matching `new_text` to the row as an LWW edit instead
/// (the edit survives and drains; it is never silently lost).
QueueInterject {
session_id: acp::SessionId,
id: String,
expected_version: u64,
new_text: Option<String>,
},
/// Set the session mode via ACP `session/set_mode`.
SetSessionMode {
session_id: acp::SessionId,
mode_id: acp::SessionModeId,
},
/// Set session mode then send a prompt, sequentially in one task.
/// Used by `/plan <desc>` to guarantee the mode switch ACP call
/// completes before the prompt is dispatched.
SetModeThenPrompt {
session_id: acp::SessionId,
mode_id: acp::SessionModeId,
agent_id: AgentId,
text: String,
prompt_id: String,
/// See [`Effect::SendPrompt::skill_token_ranges`].
skill_token_ranges: Vec<std::ops::Range<usize>>,
},
/// Fetch prompt history for the current session from the ACP agent.
/// `session_id` scopes the per-CWD history file to this session (the agent's
/// `filter_session_id` param), so up-arrow recall and the `/history`
/// panel show only the current session's prompts.
FetchPromptHistory {
agent_id: AgentId,
cwd: std::path::PathBuf,
session_id: String,
},
/// Resolve the running agent name for a session (`kigi/session/info`).
FetchSessionAgentName {
agent_id: AgentId,
session_id: acp::SessionId,
},
/// Send AuthenticateRequest to the agent.
Authenticate {
request_seq: u64,
method_id: acp::AuthMethodId,
use_oauth: bool,
force_interactive: bool,
},
/// Poll for auth URL from the agent (ext request).
PollAuthUrl { request_seq: u64 },
/// Submit a manually-pasted auth code (ext request).
SubmitAuthCode { request_seq: u64, code: String },
/// Persist a platform API key to auth.json (platform-id scope), then
/// send AuthenticateRequest with the platform's method id. SECURITY: the
/// key must never appear in logs or errors.
PersistPlatformApiKeyAndAuthenticate {
request_seq: u64,
target: crate::app::app_view::PlatformLogin,
key: String,
},
/// Fetch MCP server list from the shell (kigi/mcp/list).
FetchMcpsList {
agent_id: AgentId,
session_id: acp::SessionId,
cache: bool,
},
/// Trigger MCP OAuth for a server (kigi/mcp/auth_trigger).
McpAuthTrigger {
agent_id: AgentId,
session_id: acp::SessionId,
server_name: String,
},
/// Fetch hooks list from the shell (kigi/hooks/list).
FetchHooksList {
agent_id: AgentId,
session_id: acp::SessionId,
},
/// Fetch plugins list from the shell (kigi/plugins/list).
FetchPluginsList {
agent_id: AgentId,
session_id: acp::SessionId,
},
/// Execute a hooks management action via ACP.
HooksAction {
agent_id: AgentId,
session_id: acp::SessionId,
action: kigi_hooks_plugins_types::HooksAction,
},
/// Execute a plugins management action via ACP.
PluginsAction {
agent_id: AgentId,
session_id: acp::SessionId,
action: kigi_hooks_plugins_types::PluginsAction,
},
/// Fetch skills list from the shell (kigi/skills/list).
FetchSkillsList {
agent_id: AgentId,
session_id: acp::SessionId,
},
/// Toggle a skill via kigi/skills/toggle (enable/disable without restart).
ToggleSkill {
agent_id: AgentId,
session_id: acp::SessionId,
skill_name: String,
enabled: bool,
},
/// Upsert an MCP server via kigi/mcp/upsert.
UpsertMcpServer {
agent_id: AgentId,
session_id: acp::SessionId,
name: String,
config: Box<kigi_shell::util::config::McpServerConfig>,
},
/// Delete an MCP server via kigi/mcp/delete.
DeleteMcpServer {
agent_id: AgentId,
session_id: acp::SessionId,
server_name: String,
},
/// Live-toggle an MCP server via kigi/mcp/toggle (no restart needed).
ToggleMcpServer {
agent_id: AgentId,
session_id: acp::SessionId,
server_name: String,
enabled: bool,
},
/// Toggle a single MCP tool via kigi/mcp/toggle_tool.
ToggleMcpTool {
agent_id: AgentId,
session_id: acp::SessionId,
server_name: String,
tool_name: String,
enabled: bool,
},
/// Fetch and display session info via kigi/session/info.
ShowSessionInfo {
agent_id: AgentId,
session_id: acp::SessionId,
show_resolved_model: bool,
},
/// Fetch and display detailed context usage via kigi/session/info.
ShowContextInfo {
agent_id: AgentId,
session_id: acp::SessionId,
},
/// Fetch current bundle cache status via `kigi/bundle/status`.
FetchBundleStatus,
/// Fetch a bundled entry's raw content via `kigi/bundle/entry/get`.
FetchCatalogEntry { kind: String, name: String },
/// Save a remember note to global MEMORY.md (async file write).
SaveMemoryNote {
agent_id: AgentId,
text: String,
cwd: std::path::PathBuf,
},
/// Send raw note to kigi/memory/rewrite for LLM-powered reformatting.
/// On success, the rewritten text populates the prompt for inline review.
/// On failure, falls back to showing the raw text for review.
RewriteMemoryNote {
agent_id: AgentId,
session_id: acp::SessionId,
raw_text: String,
context_summary: String,
/// Monotonic nonce to correlate this request with the modal that
/// opened it, so stale results don't populate a different review.
nonce: u64,
},
/// Re-fetch available commands (including skills) from the shell.
///
/// Sent after `SessionCreated` / `WorktreeSessionCreated` to work around
/// a race where the shell's `AvailableCommandsUpdate` notification arrives
/// before the pager has set `session_id`, causing it to be silently dropped.
RefreshAvailableCommands {
agent_id: AgentId,
cwd: std::path::PathBuf,
},
/// Fire a /btw side question via kigi/btw ext method.
SendBtw {
agent_id: AgentId,
session_id: acp::SessionId,
question: String,
},
/// Request a session recap via the kigi/recap ext method. Fire-and-forget:
/// the recap arrives later as a `SessionRecap` notification.
SendRecap {
session_id: acp::SessionId,
auto: bool,
},
/// Send a mid-turn interjection via kigi/interject ext method.
SendInterject {
agent_id: AgentId,
session_id: acp::SessionId,
text: String,
/// Client-minted id echoed back on the `kigi/session/interjection`
/// broadcast so the originator can dedup its optimistic local block.
interjection_id: String,
/// Structured text + image content blocks. `None` for text-only
/// interjections — the wire shape stays byte-identical to legacy.
blocks: Option<Vec<acp::ContentBlock>>,
},
/// Log out via `kigi/auth/logout` (shell clears auth.json + in-memory state).
Logout,
/// Log out then authenticate sequentially in one task.
SwitchAccount {
request_seq: u64,
method_id: acp::AuthMethodId,
use_oauth: bool,
},
/// Clear the "copied!" feedback after a delay.
ScheduleClearAuthCopied,
/// Register the current session in the active-sessions crash-recovery
/// registry (`~/.kigi/active_sessions.json`).
RegisterActiveSession {
session_id: acp::SessionId,
cwd: String,
},
/// Unregister a session from the active-sessions registry (clean exit).
UnregisterActiveSession { session_id: acp::SessionId },
/// Quit the application.
Quit,
/// Rename the current session.
RenameSession {
agent_id: AgentId,
session_id: acp::SessionId,
title: String,
cwd: std::path::PathBuf,
},
/// Delete a session's stored data (local + remote) via
/// `kigi/session/delete`.
DeleteSession {
source: String,
session_id: String,
cwd: String,
},
/// Deep-search sessions by content (FTS via ACP).
DeepSearchSessions { query: String, seq: u64 },
/// Call `kigi/session/fork` to create a peer session that resumes
/// from `parent_session_id` in the same cwd (no worktree). Mirror of
/// the worktree branch of [`Effect::CreateWorktreeSession`]; the
/// worktree-fork path reuses `CreateWorktreeSession { load_session_id }`
/// directly so we get worktree creation + code restore for free.
ForkSession {
agent_id: AgentId,
parent_session_id: acp::SessionId,
parent_cwd: std::path::PathBuf,
/// Whether the parent session lives in a git worktree. When `true`,
/// the fork payload sets `sourceWorkspaceDir` so the shell preserves
/// prompt-display provenance.
parent_is_worktree: bool,
/// Optional client-chosen ID for the forked session (`--session-id`
/// with `--fork-session`).
new_session_id: Option<String>,
},
/// Read the display title (and its `/rename` manual-ness) from local
/// `summary.json` (post-resume UI label + prompt-border title).
HydrateSessionTitleFromDisk {
agent_id: AgentId,
session_id: acp::SessionId,
cwd: std::path::PathBuf,
},
FetchRewindPoints {
agent_id: AgentId,
session_id: acp::SessionId,
},
RewindPreview {
agent_id: AgentId,
session_id: acp::SessionId,
target_prompt_index: usize,
mode: crate::views::rewind::RewindMode,
},
RewindExecute {
agent_id: AgentId,
session_id: acp::SessionId,
target_prompt_index: usize,
mode: crate::views::rewind::RewindMode,
},
/// Fetch Kimi usage/quota rows from the agent's `kigi/billing`
/// extension (`GET {base}/usages` shell-side) for the `/usage` view.
FetchUsage { agent_id: AgentId },
/// Spawn a debounce sleep task for shell suggestions. `agent_id` rides
/// to the expiry so the fetch is built from the arming agent, not
/// whatever view is active when the timer fires.
DebounceSuggestions { agent_id: AgentId, generation: u64 },
/// Send an ACP `kigi/suggest` request to the shell. `agent_id` is echoed
/// on the result so the response routes to the agent that fetched, not
/// whatever view is active when it lands.
FetchShellSuggestions {
agent_id: AgentId,
text: String,
cursor: usize,
cwd: String,
generation: u64,
limit: usize,
include_ai: bool,
ai_model: Option<String>,
session_id: Option<String>,
/// Deterministic Tab fetches run only the shell's token providers
/// (path/file); the as-you-type surface keeps all of them.
token_only: bool,
},
/// Send an ACP `kigi/suggestPrompt` request to the shell — predict the
/// user's likely next prompt after a completed turn (tab autocomplete
/// ghost text).
FetchPromptSuggestion {
agent_id: AgentId,
generation: u64,
/// Suggestion model resolved by the pager (`kigi-0.1` when the
/// catalog offers it); `None` = shell falls back to the session model.
model: Option<String>,
session_id: Option<String>,
},
/// Probe the clipboard for an attachment off the event-loop thread
/// (osascript image/file-url read + image decode + session persist), then
/// attach the chip via [`TaskResult::ClipboardAttachmentProbed`]. Keeps the
/// paste handler from blocking the render thread on that I/O.
ProbeClipboardAttachment {
ctx: ClipboardPasteContext,
/// Pasteboard `changeCount` at enqueue time; the off-thread probe bails
/// (no image) if it no longer matches — a clipboard change or a second
/// racing paste can't attach the wrong image.
change_count: Option<u64>,
},
/// Prepare terminal preview bytes off the event-loop thread.
PreparePromptImagePreview {
preparation: crate::prompt_images::PromptImagePreviewPreparation,
},
}
/// Outcome of an `kigi/subagent/cancel` request, telling dispatch whether the
/// pager must finalize the subagent row itself.
#[derive(Debug)]
pub enum SubagentKillOutcome {
/// Shell stopped a live subagent — a real `SubagentFinished` is coming.
StoppedLive,
/// Nothing live to stop (orphan / already finished) — no finish coming, so
/// the pager finalizes the row. `status` = the real terminal status for an
/// already-finished orphan, else `None` (unknown id / older shell) →
/// "cancelled".
NothingLive { status: Option<String> },
/// The cancel RPC failed; the subagent may still be running, so leave the
/// row alone rather than show a false terminal state.
RpcFailed,
}
/// Result from a completed async [`Effect`].
///
/// Wrapped in `Action::TaskComplete` and dispatched synchronously.
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum TaskResult {
/// Session was created successfully.
SessionCreated {
agent_id: AgentId,
session_id: acp::SessionId,
models: Option<acp::SessionModelState>,
},
/// Session creation failed.
SessionFailed {
agent_id: AgentId,
error: String,
},
/// Worktree session was created successfully (worktree + ACP session).
WorktreeSessionCreated {
agent_id: AgentId,
session_id: acp::SessionId,
/// Root of the created worktree (for display).
worktree_path: std::path::PathBuf,
/// Effective cwd inside the worktree (preserves subdirectory offset).
session_cwd: std::path::PathBuf,
models: Option<acp::SessionModelState>,
},
/// Worktree created and session forked, but not yet loaded.
/// The dispatch handler sets session_id eagerly, then emits LoadSession.
WorktreeForked {
agent_id: AgentId,
session_id: acp::SessionId,
worktree_path: std::path::PathBuf,
session_cwd: std::path::PathBuf,
code_restored: bool,
restore_summary: Option<String>,
restore_degree: Option<kigi_workspace::session::git::RestoreDegree>,
},
/// Worktree session creation failed.
WorktreeSessionFailed {
agent_id: AgentId,
error: String,
},
/// Session was loaded (resumed) successfully.
SessionLoaded {
agent_id: AgentId,
session_id: acp::SessionId,
models: Option<acp::SessionModelState>,
code_restored: bool,
restore_summary: Option<String>,
restore_degree: Option<kigi_workspace::session::git::RestoreDegree>,
/// The session's in-flight running prompt id (from the load response
/// `_meta["kigi/runningPromptId"]`), present only when the session was
/// loaded MID-turn (another client is driving). The loader adopts it to
/// pass the live `session/update` gate without re-rendering the user
/// block (replay already rendered it).
running_prompt_id: Option<String>,
},
/// Session load (resume) failed.
SessionLoadFailed {
agent_id: AgentId,
session_id: acp::SessionId,
error: String,
},
/// Local `summary.json` title read for [`Effect::HydrateSessionTitleFromDisk`].
SessionTitleFromDisk {
agent_id: AgentId,
/// The display title paired with whether it came from a manual
/// `/rename` (`summary.title_is_manual`, restores the prompt-border
/// title) — manual-ness cannot exist without a title.
title: Option<(String, bool)>,
},
/// Session list fetched for the welcome screen picker.
SessionListLoaded {
sessions: Vec<crate::app::app_view::SessionPickerEntry>,
/// Echo of [`Effect::FetchSessionList::seq`]; stale results are dropped.
seq: u64,
/// Echo of [`Effect::FetchSessionList::query`]. `Some` marks the
/// sessions as server-side search results: stamped so the local fuzzy
/// re-filter doesn't hide content-only hits, with zero hits a normal
/// outcome rather than an empty-directory error.
query: Option<String>,
},
/// A background foreign-session scan completed.
ForeignSessionsScanned {
entries: Vec<crate::app::app_view::SessionPickerEntry>,
seq: u64,
},
/// Launch cwd canonicalization completed before foreign store access.
ForeignResumeCwdCanonicalized {
requested_cwd: std::path::PathBuf,
canonical_cwd: Option<std::path::PathBuf>,
launch_token: u64,
},
/// Launch-time foreign resume detection completed.
ForeignResumeHintDetected {
canonical_cwd: std::path::PathBuf,
launch_token: u64,
hint: Option<kigi_workspace::foreign_sessions::RecentForeignSession>,
},
/// Session list fetch failed.
SessionListFailed {
error: String,
/// Echo of [`Effect::FetchSessionList::seq`]; stale failures are dropped.
seq: u64,
/// Echo of [`Effect::FetchSessionList::query`]. `Some` (a failed
/// search) clears the search in-flight indicator; `None` must leave
/// it alone — in Build mode the flag belongs to the FTS5 deep search.
query: Option<String>,
},
/// Picker search debounce elapsed ([`Effect::DebounceSessionSearch`]).
SessionSearchDebounceExpired {
query: String,
seq: u64,
},
/// Leader session roster loaded via `kigi/sessions/list`.
RosterLoaded {
sessions: Vec<crate::app::roster::RosterEntry>,
},
/// Leader session roster fetch failed (silent — no error modal).
RosterFailed {
error: String,
},
/// Local on-disk session list loaded for the dashboard (non-leader
/// fallback). Entries are pre-converted to `RosterEntry` (activity
/// `Dormant`) so they reuse the roster-row rendering path. A fetch
/// failure yields an empty list (silent — the next poll retries).
DashboardSessionsLoaded {
sessions: Vec<crate::app::roster::RosterEntry>,
},
/// Card detail loaded for a session in the picker.
CardDetailLoaded {
source: String,
session_id: String,
generation: u64,
detail: crate::app::app_view::CardDetail,
},
/// Remote session restored successfully — now load it. Always a Build
/// disk row (see [`Effect::RestoreAndLoadSession`]).
SessionRestored {
agent_id: AgentId,
/// The local session ID (may differ from remote ID).
local_session_id: String,
},
/// Remote session restore failed.
SessionRestoreFailed {
agent_id: AgentId,
error: String,
},
/// Incremental progress during remote session restore.
SessionRestoreProgress {
agent_id: AgentId,
message: String,
},
/// Prompt response received (turn ended).
PromptResponse {
agent_id: AgentId,
result: Result<acp::PromptResponse, String>,
/// HTTP status code from the upstream API error, if available.
/// Used by dispatch to show targeted UI (e.g. credit-limit
/// upsell on 403).
http_status: Option<u16>,
/// The `prompt_id` the pager minted when it sent this `session/prompt`
/// RPC. On `Ok` the agent echoes `promptId` back in PR meta, but an
/// `acp::Error` carries no meta — so this is the ONLY way to attribute
/// an *error* response to the prompt it belongs to. The dispatch gate
/// uses it to discard errors from queued/stale prompts instead of
/// painting them onto the running turn. `None` for synthetic/test
/// constructions that don't need gating.
prompt_id: Option<String>,
},
/// A send-now `session/prompt` RPC failed at the transport/RPC layer —
/// the prompt never reached the shell's queue. Carries the payload so
/// dispatch can requeue it locally (the producer already consumed the
/// composer/queue row, so dropping it would silently lose the message —
/// the same contract the removed `InterjectFailed` requeue had).
SendPromptNowFailed {
agent_id: AgentId,
session_id: acp::SessionId,
prompt_id: String,
error: String,
blocks: Vec<acp::ContentBlock>,
},
/// Cancel notification was sent (fire-and-forget).
/// The real turn end comes via PromptResponse.
CancelComplete,
/// Response to `kigi/subagent/cancel`; see [`SubagentKillOutcome`].
KillSubagentComplete {
session_id: acp::SessionId,
subagent_id: String,
outcome: SubagentKillOutcome,
},
PreferredModelPersisted {
result: Result<(), String>,
},
/// Manual `/compact` command completed.
CompactComplete {
agent_id: AgentId,
result: Result<(), String>,
},
/// Background task kill result. `outcome` is `None` when the agent
/// returned an error envelope or an unparseable payload (treated as
/// "clear pending state, keep the row").
BgTaskKilled {
session_id: String,
task_id: String,
outcome: Option<kigi_tools::types::KillOutcome>,
},
/// Background task kill failed.
BgTaskKillFailed {
session_id: String,
task_id: String,
error: String,
},
/// Model switch completed (effort, if any, was applied in the same request).
SwitchModelComplete {
agent_id: AgentId,
model_id: acp::ModelId,
effort: Option<ReasoningEffort>,
result: Result<(), SwitchModelError>,
/// Forwarded from `Effect::SwitchModel.prev_model_id` for
/// rollback on `IncompatibleAgent`.
prev_model_id: Option<acp::ModelId>,
},
/// Cross-session prompt history loaded from ACP.
PromptHistoryLoaded {
agent_id: AgentId,
prompts: Vec<String>,
},
/// Running agent name cached from `session/info` (for agents modal, etc.).
SessionAgentNameResolved {
agent_id: AgentId,
agent_name: Option<String>,
},
/// Authentication completed successfully.
AuthComplete {
request_seq: u64,
meta: Option<serde_json::Value>,
},
/// Authentication failed.
AuthFailed {
request_seq: u64,
error: String,
},
/// Auth URL is ready (from the provider).
AuthUrlReady {
request_seq: u64,
auth_url: Option<String>,
/// Deprecated: superseded by `mode` (authoritative). Kept only as a
/// back-compat fallback for older agents that don't send `mode`.
external: bool,
/// Presentation mode from `kigi/auth/get_url`; `None` on older agents.
mode: Option<String>,
},
/// Auth code was submitted (fire-and-forget).
AuthCodeSubmitted {
request_seq: u64,
},
/// MCP server list fetched from shell.
McpsListLoaded {
agent_id: AgentId,
result: Result<Vec<crate::views::mcps_modal::McpServerInfo>, String>,
},
/// MCP auth trigger completed.
McpAuthTriggerDone {
agent_id: AgentId,
server_name: String,
result: Result<(), String>,
},
/// Hooks list fetched from shell.
HooksListLoaded {
agent_id: AgentId,
result: Result<kigi_hooks_plugins_types::HooksListResponse, String>,
},
/// Plugins list fetched from shell.
PluginsListLoaded {
agent_id: AgentId,
result: Result<kigi_hooks_plugins_types::PluginsListResponse, String>,
},
/// Hooks action completed.
HooksActionResult {
agent_id: AgentId,
result: Result<kigi_hooks_plugins_types::ActionOutcome, String>,
},
/// Plugins action completed.
PluginsActionResult {
agent_id: AgentId,
result: Result<kigi_hooks_plugins_types::ActionOutcome, String>,
},
/// Skills list loaded.
SkillsListLoaded {
agent_id: AgentId,
result: Result<Vec<kigi_tools::implementations::skills::types::SkillInfo>, String>,
},
/// Skill toggle completed (enable/disable).
SkillsToggleDone {
agent_id: AgentId,
result: Result<Vec<kigi_tools::implementations::skills::types::SkillInfo>, String>,
},
/// Live MCP toggle completed.
McpToggleDone {
agent_id: AgentId,
result: Result<(), String>,
},
/// Session info fetched successfully.
SessionInfoComplete {
agent_id: AgentId,
info: Box<kigi_shell::session::SessionInfoResponse>,
text: String,
},
/// Session info fetch failed.
SessionInfoFailed {
agent_id: AgentId,
error: String,
},
/// Session rename completed successfully.
RenameSessionComplete {
agent_id: AgentId,
title: String,
},
/// Session rename failed.
RenameSessionFailed {
agent_id: AgentId,
error: String,
},
/// Session delete completed successfully.
DeleteSessionComplete {
source: String,
session_id: String,
},
/// Session delete failed.
DeleteSessionFailed {
source: String,
session_id: String,
error: String,
},
/// Context info fetched successfully.
ContextInfoComplete {
agent_id: AgentId,
info: Box<kigi_shell::session::SessionInfoResponse>,
},
/// Context info fetch failed.
ContextInfoFailed {
agent_id: AgentId,
error: String,
},
/// Memory note saved to global MEMORY.md.
MemoryNoteSaved {
agent_id: AgentId,
result: Result<(), String>,
},
/// LLM-rewritten memory note ready for inline review.
/// `Ok(text)` = rewritten markdown; `Err(error)` = rewrite failed.
MemoryNoteRewritten {
agent_id: AgentId,
result: Result<String, String>,
/// Nonce from the originating RewriteMemoryNote effect; must match
/// the modal's `rewrite_nonce` before populating enhanced_content.
nonce: u64,
},
/// Bundle status fetched successfully.
BundleStatusReady {
has_cache: bool,
version: Option<String>,
personas: Vec<String>,
roles: Vec<String>,
agents: Vec<String>,
skills: Vec<String>,
persona_details: Vec<super::bundle::PersonaDetail>,
role_details: Vec<super::bundle::RoleDetail>,
},
/// Bundle status fetch failed.
BundleStatusFailed {
error: String,
},
/// Catalog entry content fetched successfully.
CatalogEntryReady {
kind: String,
name: String,
content: String,
},
/// Catalog entry fetch failed.
CatalogEntryFailed {
error: String,
},
/// Side question (/btw) response received.
BtwResponse {
agent_id: AgentId,
result: Result<String, String>,
},
/// `kigi/recap` request acknowledged (fire-and-forget). The recap itself
/// arrives separately as a `SessionRecap` notification; this only carries
/// a transport error, if any, for logging.
RecapRequested {
/// Session the recap was requested for — lets the handler find the
/// agent whose manual loading spinner must be cleared on failure.
session_id: acp::SessionId,
/// Whether this was an automatic recap. Only a manual `/recap` shows a
/// loading spinner, so only a manual failure needs to clear one.
auto: bool,
error: Option<String>,
},
/// Interjection queued acknowledgement.
InterjectQueued {
agent_id: AgentId,
},
/// Interjection send failed. Carries the payload so the dispatcher can
/// requeue it (mirrors the batch path's `failed_local` requeue) — the
/// queue row was already removed optimistically, so dropping the text
/// here would silently lose the user's message.
InterjectFailed {
agent_id: AgentId,
error: String,
text: String,
blocks: Option<Vec<agent_client_protocol::ContentBlock>>,
},
/// Available commands refreshed from the shell.
AvailableCommandsRefreshed {
agent_id: AgentId,
commands: Vec<acp::AvailableCommand>,
},
/// Shell acknowledged logout (auth cleared).
LogoutComplete,
/// The 2-second "copied!" display timer expired.
AuthCopiedTimeout,
DeepSearchResults {
results: Vec<kigi_shell::extensions::session_search::SearchSessionHit>,
seq: u64,
},
/// `kigi/session/fork` completed (no-worktree path). The pager adopts
/// the new session id and emits [`Effect::LoadSession`] to start the
/// replay. Mirrors [`TaskResult::WorktreeForked`] in shape.
ForkSessionReady {
agent_id: AgentId,
new_session_id: acp::SessionId,
cwd: std::path::PathBuf,
},
/// `kigi/session/fork` failed. The placeholder agent stays in
/// `app.agents` with no `session_id` so the user can switch away.
ForkSessionFailed {
agent_id: AgentId,
error: String,
},
RewindPointsLoaded {
agent_id: AgentId,
points: Vec<crate::views::rewind::RewindPointInfo>,
},
RewindPointsFailed {
agent_id: AgentId,
error: String,
},
RewindPreviewComplete {
agent_id: AgentId,
response: crate::views::rewind::RewindResponse,
target_prompt_index: usize,
mode: crate::views::rewind::RewindMode,
},
RewindPreviewFailed {
agent_id: AgentId,
error: String,
},
RewindExecuteComplete {
agent_id: AgentId,
response: crate::views::rewind::RewindResponse,
},
RewindExecuteFailed {
agent_id: AgentId,
error: String,
},
/// Kimi usage/quota rows fetched for the `/usage` view.
UsageFetched {
agent_id: AgentId,
result: Result<Vec<kigi_shell::extensions::billing::UsageRow>, String>,
},
/// Debounce timer for shell suggestions expired. Routed by the arming
/// `agent_id`.
SuggestionDebounceExpired {
agent_id: AgentId,
generation: u64,
},
/// Shell suggestions loaded from ACP `kigi/suggest`. `request_text` /
/// `request_cursor` echo what the request was built from — the anchor
/// the items' `replaceRange` offsets index into and the position Tab
/// targets, paired atomically with them; `agent_id` routes the landing
/// to the agent that fetched.
ShellSuggestionsLoaded {
agent_id: AgentId,
response: crate::views::suggestion_controller::SuggestResponseParsed,
request_text: String,
request_cursor: usize,
},
/// Predicted next prompt loaded from ACP `kigi/suggestPrompt`.
/// `suggestion` is `None` when the shell had nothing to suggest.
PromptSuggestionLoaded {
agent_id: AgentId,
suggestion: Option<String>,
generation: u64,
},
/// Setting persisted successfully. No reconciliation needed today.
SettingPersisted {
key: crate::settings::SettingKey,
value: crate::settings::SettingValue,
},
/// Setting persist failed. Rolls back in-memory cache to
/// `rollback_value` and shows a failure toast.
SettingPersistFailed {
key: crate::settings::SettingKey,
rollback_value: crate::settings::SettingValue,
error: String,
},
/// Best-effort persist failed (cycle_mode path). Logs + toasts but
/// does NOT roll back in-memory state.
SettingPersistFailedBestEffort {
key: crate::settings::SettingKey,
error: String,
},
/// Off-thread clipboard attachment probe finished (see
/// [`Effect::ProbeClipboardAttachment`]); dispatch attaches the chip.
ClipboardAttachmentProbed {
ctx: ClipboardPasteContext,
/// Decoded/persisted image outcome from the off-thread probe.
image: ProbedAttachment,
/// File URL(s) the completion resolves via the existing path handling.
file_urls: Option<String>,
},
/// Shared prompt-image preview state was resolved off-thread.
PromptImagePreviewPrepared,
}
#[cfg(test)]
mod tests {
use super::*;
/// `as_canonical` must return the wire strings that the settings
/// picker and dispatcher pattern-match against.
#[test]
fn plan_mode_kind_as_canonical() {
assert_eq!(PlanModeKind::On.as_canonical(), "on");
assert_eq!(PlanModeKind::Off.as_canonical(), "off");
}
/// `as_display` must return user-visible picker labels.
#[test]
fn plan_mode_kind_as_display() {
assert_eq!(PlanModeKind::On.as_display(), "On");
assert_eq!(PlanModeKind::Off.as_display(), "Off");
}
/// `to_bool` must project `On → true`, `Off → false`.
#[test]
fn plan_mode_kind_to_bool() {
assert!(PlanModeKind::On.to_bool());
assert!(!PlanModeKind::Off.to_bool());
}
/// `from_bool` must be the inverse of `to_bool`.
#[test]
fn plan_mode_kind_from_bool_round_trip() {
assert_eq!(PlanModeKind::from_bool(true), PlanModeKind::On);
assert_eq!(PlanModeKind::from_bool(false), PlanModeKind::Off);
for b in [true, false] {
assert_eq!(
PlanModeKind::from_bool(b).to_bool(),
b,
"from_bool ∘ to_bool round-trip must be identity",
);
}
for k in [PlanModeKind::On, PlanModeKind::Off] {
assert_eq!(
PlanModeKind::from_bool(k.to_bool()),
k,
"to_bool ∘ from_bool round-trip must be identity",
);
}
}
/// Drift-guard: `PLAN_MODE_CHOICES` canonicals must match
/// `PlanModeKind::as_canonical`.
#[test]
fn plan_mode_kind_canonical_strings_match_choices_catalog() {
let catalog_canonicals: std::collections::HashSet<&str> =
crate::settings::defs::default_settings()
.iter()
.find(|m| m.key == "plan_mode")
.map(|m| match &m.kind {
crate::settings::SettingKind::Enum { choices, .. } => {
choices.iter().map(|c| c.canonical).collect()
}
_ => panic!("plan_mode must be Enum"),
})
.expect("plan_mode must be registered");
assert!(
catalog_canonicals.contains(PlanModeKind::On.as_canonical()),
"catalog must contain `{}` (from PlanModeKind::On)",
PlanModeKind::On.as_canonical(),
);
assert!(
catalog_canonicals.contains(PlanModeKind::Off.as_canonical()),
"catalog must contain `{}` (from PlanModeKind::Off)",
PlanModeKind::Off.as_canonical(),
);
assert_eq!(
catalog_canonicals.len(),
2,
"catalog must be exactly {{on, off}} — adding a third \
choice requires adding a PlanModeKind variant AND \
updating the action_for_enum_commit + action_for_reset \
dispatcher arms",
);
}
}