Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
3149 lines
147 KiB
Rust
3149 lines
147 KiB
Rust
//! Per-agent view component.
|
||
//!
|
||
//! [`AgentView`] is a view-model that owns both business state (session,
|
||
//! entries) and UI state (scroll, selection, focus, mode). It handles
|
||
//! input routing to the active pane and renders the agent layout.
|
||
//!
|
||
//! ## Input flow (three-level bubbling)
|
||
//!
|
||
//! ```text
|
||
//! key press
|
||
//! → overlays / modals / dropdowns / search / selection steal Esc first
|
||
//! → 1. pane level (exact context match):
|
||
//! prompt focused:
|
||
//! prompt.handle_key(key) → Submit / Edited / Ignored
|
||
//! if Ignored → registry.lookup(key, PromptFocused) → pane-specific actions
|
||
//! if still unmatched → Tab structural FocusScrollback
|
||
//! scrollback focused:
|
||
//! Space/i → FocusPrompt
|
||
//! registry.lookup(key, ScrollbackFocused) → navigation, view, etc.
|
||
//! → 2. agent level (if pane returned Unchanged):
|
||
//! registry.lookup(key, AgentScreen) → CancelTurn (Ctrl+C), ToggleYolo, NextModel
|
||
//! CancelTurn has runtime guards (Running→cancel, Cancelling→quit on Ctrl+C).
|
||
//! From the prompt pane, Ctrl+C CancelTurn is a two-step gesture:
|
||
//! a non-empty prompt skips the AgentScreen promotion so the key
|
||
//! falls through to the widget's clear path; the next Ctrl+C (now
|
||
//! on an empty prompt) re-enters this level and runs CancelTurn.
|
||
//! → 3. Esc policy (try_handle_esc_policy) on Prompt or Scrollback only,
|
||
//! after overlays/dropdowns/selection returned Changed / stole Esc:
|
||
//! turn running → Changed (swallow; Esc does not cancel)
|
||
//! turn cancelling → CancelTurn (retry lost ack; Ctrl+C escalates to Quit)
|
||
//! idle + non-empty prompt, prompt pane only → ArmPending ClearPrompt (2× within 800ms, hint)
|
||
//! idle + empty + messages, either pane (Normal composer mode, no
|
||
//! needs-input overlay pending, no open history search) → ArmPending
|
||
//! RewindShowPicker (2×, silent)
|
||
//! idle otherwise (scrollback-pane draft / latent mode / pending overlay /
|
||
//! open history search, or empty + no messages) → Changed (swallow Esc;
|
||
//! not FocusScrollback)
|
||
//! → 4. return Unchanged → bubbles to app_view for global actions (quit)
|
||
//! ```
|
||
//!
|
||
//! Esc policy is independent of `[ui].simple_mode` (prompt editor) and
|
||
//! `[ui].vim_mode` (scrollback nav). Tab remains leave-prompt in both modes.
|
||
//!
|
||
//! ## Future: data/view split
|
||
//!
|
||
//! When multi-agent views arrive (swarm overview, aggregate stats), we'll
|
||
//! need views that read from MULTIPLE agents. At that point, split into:
|
||
//! - `AgentData` — entries, session, tracker (shared, view-agnostic)
|
||
//! - `AgentViewState` — scroll, selection, folds (per-view instance)
|
||
//!
|
||
//! See `plan/app-reorg.md` for details.
|
||
//!
|
||
//! ## Module organization
|
||
//!
|
||
//! The `AgentView` struct, its supporting types, and shared free functions
|
||
//! live here; the `impl AgentView` method clusters live in the per-domain
|
||
//! child modules declared below (input routing, rendering, selection, panes,
|
||
//! modals, ...). Child modules import from `super::` with at most one hop;
|
||
//! their `#[cfg(test)]` mods share `test_fixtures` below.
|
||
use crate::actions::ActionId;
|
||
use crate::key;
|
||
use crate::render::SafeBuf;
|
||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||
/// Hit areas for inline media buttons, rebuilt each frame.
|
||
///
|
||
/// All hit areas are cleared at the start of inline media rendering and
|
||
/// repopulated only when the media is visible. This ensures scroll
|
||
/// resilience — stale hit areas are never left around.
|
||
#[derive(Debug, Clone, Default)]
|
||
pub(crate) struct InlineMediaHitAreas {
|
||
/// Inline image areas (image overlay): clicking opens the file natively.
|
||
pub media_areas: Vec<(ratatui::layout::Rect, std::path::PathBuf)>,
|
||
/// Video poster areas: clicking starts/restarts inline playback.
|
||
pub video_play_areas: Vec<(ratatui::layout::Rect, std::path::PathBuf)>,
|
||
/// `[Play]` button rects: clicking starts/replays inline video.
|
||
pub play_buttons: Vec<(ratatui::layout::Rect, std::path::PathBuf)>,
|
||
/// `[Open]` button rects (overlay and text fallback): open the file natively.
|
||
pub open_buttons: Vec<(ratatui::layout::Rect, std::path::PathBuf)>,
|
||
/// `[Copy]` button rects (images only): clicking copies the image.
|
||
pub copy_image_buttons: Vec<(ratatui::layout::Rect, std::path::PathBuf)>,
|
||
/// Filepath line rects: clicking copies the path to the clipboard.
|
||
pub filepath_areas: Vec<(ratatui::layout::Rect, std::path::PathBuf)>,
|
||
/// Mermaid affordance-row button rects: `(rect, kind, source_idx)` where
|
||
/// `source_idx` indexes [`mermaid_sources`](Self::mermaid_sources). `[Open]`/
|
||
/// `[Copy path]` render the source lazily on click; `[Copy source]` copies
|
||
/// it. Indexing keeps each diagram's source cloned once per frame, not per
|
||
/// button.
|
||
pub mermaid_buttons: Vec<(
|
||
ratatui::layout::Rect,
|
||
crate::scrollback::blocks::mermaid_content::AffordanceKind,
|
||
usize,
|
||
)>,
|
||
/// Diagram sources for the visible affordance rows; [`mermaid_buttons`] index
|
||
/// into this (one entry per visible diagram).
|
||
///
|
||
/// [`mermaid_buttons`]: Self::mermaid_buttons
|
||
pub mermaid_sources: Vec<String>,
|
||
}
|
||
/// Inline video playback state for scrollback media entries.
|
||
///
|
||
/// Created when the user clicks or presses Enter on a video poster frame.
|
||
/// Frames are extracted via ffmpeg in a background thread. Playback
|
||
/// advances one frame per tick, stops on the last frame (no loop).
|
||
#[derive(Debug)]
|
||
pub(crate) struct InlineVideoState {
|
||
/// Video file path (used to match against visible placements).
|
||
pub path: std::path::PathBuf,
|
||
/// Pre-extracted frames, protocol-prepared (PNG for Kitty, JPEG for iTerm2).
|
||
pub frames: Vec<Vec<u8>>,
|
||
/// Current frame index (0-based).
|
||
pub current_frame: usize,
|
||
/// Timestamp of last frame advance (for fps pacing).
|
||
pub last_frame_time: std::time::Instant,
|
||
/// Target playback frame rate.
|
||
pub fps: f64,
|
||
/// True after the last frame has been displayed.
|
||
pub finished: bool,
|
||
}
|
||
use super::actions::Action;
|
||
use super::agent::AgentSession;
|
||
use super::app_view::InputOutcome;
|
||
use crate::scrollback::EntryId;
|
||
use crate::scrollback::ScrollbackSearchState;
|
||
use crate::scrollback::state::ScrollbackState;
|
||
use crate::scrollback::text_selection::{
|
||
ActiveBlockDrag, ActiveTextDrag, DragAutoScrollState, PendingBlockDrag, PendingTextDrag,
|
||
PersistentTextSelection, ResolvedSelectionBoundaries, ResolvedSelectionModel,
|
||
TableSelectionGeometry,
|
||
};
|
||
use crate::theme::Theme;
|
||
pub use crate::views::agent::{ActivePane, AgentViewLayout, InputMode, PaneAreas};
|
||
use crate::views::block_viewer::BlockViewerPane;
|
||
use crate::views::extensions_modal::ExtensionsModalState;
|
||
use crate::views::file_search::line_viewer::LineViewerState;
|
||
use crate::views::modal::{self, ActiveModal, ModalButtonHit};
|
||
use crate::views::permission_view::{PermissionViewState, SubagentInfo};
|
||
use crate::views::plan_approval_view::{PlanApprovalViewState, PlanComment};
|
||
use crate::views::prompt_widget::{PromptWidget, StashedPrompt};
|
||
use crate::views::question_view::QuestionViewState;
|
||
use crate::views::queue_pane::QueuePane;
|
||
use crate::views::subagent_catalog_pane::SubagentCatalogPane;
|
||
use crate::views::tasks_pane::TasksPane;
|
||
use crate::views::todo_pane::TodoPane;
|
||
use ratatui::buffer::Buffer;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::style::Style;
|
||
use ratatui::text::Line;
|
||
use ratatui::widgets::Widget;
|
||
use std::collections::{HashMap, HashSet, VecDeque};
|
||
use std::time::Instant;
|
||
mod cta;
|
||
mod input;
|
||
mod interactions;
|
||
mod jump;
|
||
mod links;
|
||
mod media;
|
||
mod modals;
|
||
mod notices;
|
||
mod panes;
|
||
mod paste;
|
||
mod plan;
|
||
mod prompt;
|
||
mod queue;
|
||
mod render;
|
||
mod rewind;
|
||
mod selection;
|
||
mod session;
|
||
mod shell_completion;
|
||
mod viewer;
|
||
use super::actions;
|
||
use super::dispatch;
|
||
pub(super) fn active_contexts_for_pane(pane: ActivePane) -> Vec<crate::actions::When> {
|
||
use crate::actions::When;
|
||
match pane {
|
||
ActivePane::Prompt => vec![When::PromptFocused, When::AgentScreen, When::Always],
|
||
ActivePane::Scrollback => {
|
||
vec![When::ScrollbackFocused, When::AgentScreen, When::Always]
|
||
}
|
||
_ => vec![When::AgentScreen, When::Always],
|
||
}
|
||
}
|
||
/// Pane focus within the agent view.
|
||
///
|
||
/// This will grow as we add more panes (tasks, review files, etc.).
|
||
pub type AgentPane = ActivePane;
|
||
/// MCP server initialization progress, received from the shell.
|
||
#[derive(Debug, Clone)]
|
||
pub struct McpInitProgress {
|
||
pub total: u32,
|
||
pub connected: u32,
|
||
pub started_at: Instant,
|
||
}
|
||
impl McpInitProgress {
|
||
/// Max age for a `total == 0` seed before it auto-expires.
|
||
pub const SEED_EXPIRE: std::time::Duration = std::time::Duration::from_secs(30);
|
||
/// Whether the progress indicator should be visible in the UI.
|
||
///
|
||
/// - `total > 0` (real servers): always visible until
|
||
/// `kigi/mcp_initialized` clears the progress.
|
||
/// - `total == 0` (seed / 0-server): visible for at most
|
||
/// [`SEED_EXPIRE`] seconds, then auto-expires as
|
||
/// defense-in-depth against the shell failing to send
|
||
/// `mcp_initialized`.
|
||
pub fn is_visible(&self) -> bool {
|
||
self.total > 0 || self.started_at.elapsed() < Self::SEED_EXPIRE
|
||
}
|
||
}
|
||
#[cfg(test)]
|
||
mod mcp_init_progress_tests {
|
||
use super::McpInitProgress;
|
||
#[test]
|
||
fn is_visible_requires_servers_or_fresh_seed() {
|
||
let real = McpInitProgress {
|
||
total: 3,
|
||
connected: 1,
|
||
started_at: std::time::Instant::now(),
|
||
};
|
||
assert!(real.is_visible(), "real progress must be visible");
|
||
let fresh = McpInitProgress {
|
||
total: 0,
|
||
connected: 0,
|
||
started_at: std::time::Instant::now(),
|
||
};
|
||
assert!(fresh.is_visible(), "fresh seed must be visible");
|
||
let expired = McpInitProgress {
|
||
total: 0,
|
||
connected: 0,
|
||
started_at: std::time::Instant::now()
|
||
- McpInitProgress::SEED_EXPIRE
|
||
- std::time::Duration::from_secs(1),
|
||
};
|
||
assert!(!expired.is_visible(), "expired seed must not be visible");
|
||
}
|
||
}
|
||
/// A clickable/hoverable screen region.
|
||
///
|
||
/// Tracks an optional screen rect (set during render) and whether the
|
||
/// mouse is currently hovering over it. Consolidates the repeated
|
||
/// `area: Option<Rect>` + `hovered: bool` pattern.
|
||
#[derive(Debug, Clone, Copy, Default)]
|
||
pub struct HitArea {
|
||
pub rect: Option<Rect>,
|
||
pub hovered: bool,
|
||
}
|
||
impl HitArea {
|
||
/// Update hover state for a mouse position. Returns `true` if changed.
|
||
pub fn update_hover(&mut self, col: u16, row: u16) -> bool {
|
||
let new = self.rect.is_some_and(|r| r.contains((col, row).into()));
|
||
let changed = new != self.hovered;
|
||
self.hovered = new;
|
||
changed
|
||
}
|
||
pub fn contains(&self, col: u16, row: u16) -> bool {
|
||
self.rect.is_some_and(|r| r.contains((col, row).into()))
|
||
}
|
||
pub fn set(&mut self, rect: Option<Rect>) {
|
||
self.rect = rect;
|
||
}
|
||
/// Like [`Self::set`], but drops the rect while a dropdown is open: dropdowns paint over these rows post-arm.
|
||
pub fn set_unless_dropdown(&mut self, rect: Option<Rect>, dropdown_open: bool) {
|
||
self.set(if dropdown_open { None } else { rect });
|
||
}
|
||
pub fn clear(&mut self) {
|
||
self.rect = None;
|
||
self.hovered = false;
|
||
}
|
||
}
|
||
pub use super::queue_edit::PromptMode;
|
||
/// Which special input mode the prompt is currently in.
|
||
///
|
||
/// These modes are **mutually exclusive**: only one can be active at a time.
|
||
/// `Normal` is the default. Each variant changes the prompt's visual appearance
|
||
/// (accent color, prefix, placeholder) and the action dispatched on Enter.
|
||
///
|
||
/// Orthogonal to `multiline_mode` ... and `PromptMode` ... and `InputMode`...
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum PromptInputMode {
|
||
/// Standard prompt: Enter sends `Action::SendPrompt`.
|
||
#[default]
|
||
Normal,
|
||
/// Bash mode (`!` prefix): Enter sends `Action::SendBashCommand`.
|
||
Bash,
|
||
/// Remember mode (`#` prefix, green accent): Enter sends `Action::SendRememberNote`.
|
||
Remember,
|
||
}
|
||
impl PromptInputMode {
|
||
pub fn accent_color(self, theme: &Theme) -> Option<ratatui::style::Color> {
|
||
match self {
|
||
PromptInputMode::Normal => None,
|
||
PromptInputMode::Bash => Some(theme.command),
|
||
PromptInputMode::Remember => Some(theme.accent_remember),
|
||
}
|
||
}
|
||
pub fn prefix_override(self, theme: &Theme) -> Option<(&'static str, ratatui::style::Color)> {
|
||
match self {
|
||
PromptInputMode::Normal => None,
|
||
PromptInputMode::Bash => Some(("! ", theme.command)),
|
||
PromptInputMode::Remember => Some(("# ", theme.accent_remember)),
|
||
}
|
||
}
|
||
pub fn placeholder_override(self, multiline: bool) -> Option<&'static str> {
|
||
match self {
|
||
PromptInputMode::Normal | PromptInputMode::Bash => None,
|
||
PromptInputMode::Remember => {
|
||
if multiline {
|
||
Some("Save a memory note... (Enter for newline, Shift+Enter to save)")
|
||
} else {
|
||
Some("Save a memory note... (Shift+Enter for multiline)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
pub fn prompt_info_override(self) -> Option<&'static str> {
|
||
match self {
|
||
PromptInputMode::Normal => None,
|
||
PromptInputMode::Bash => Some("Run shell command"),
|
||
PromptInputMode::Remember => Some("Save memory note"),
|
||
}
|
||
}
|
||
pub fn send_action(self, text: String) -> Action {
|
||
match self {
|
||
PromptInputMode::Normal => Action::SendPrompt(text),
|
||
PromptInputMode::Bash => Action::SendBashCommand(text),
|
||
PromptInputMode::Remember => Action::SendRememberNote(text),
|
||
}
|
||
}
|
||
pub fn is_exit_key(self, key: &KeyEvent) -> bool {
|
||
match self {
|
||
PromptInputMode::Normal => false,
|
||
PromptInputMode::Bash | PromptInputMode::Remember => {
|
||
let ctrl_w = key!('w', CONTROL).matches(key);
|
||
let ctrl_u = key!('u', CONTROL).matches(key);
|
||
let ctrl_c = key!('c', CONTROL).matches(key);
|
||
key.code == KeyCode::Backspace
|
||
|| key.code == KeyCode::Esc
|
||
|| ctrl_w
|
||
|| ctrl_u
|
||
|| ctrl_c
|
||
}
|
||
}
|
||
}
|
||
}
|
||
/// Multi-click state for text-level selection (word/line).
|
||
///
|
||
/// Unlike block-level `last_click` (which tracks by entry_idx),
|
||
/// this tracks by (entry_idx, range_id, block_line_idx) to ensure
|
||
/// double-click word selection only triggers when clicking the same text region.
|
||
#[derive(Debug, Clone)]
|
||
pub struct TextClickState {
|
||
pub time: Instant,
|
||
pub entry_idx: usize,
|
||
pub range_id: u16,
|
||
pub block_line_idx: usize,
|
||
pub col_within_range: u16,
|
||
pub click_count: u8,
|
||
}
|
||
/// Maximum time (ms) between consecutive clicks to count as a multi-click.
|
||
pub(super) const MULTI_CLICK_TIMEOUT_MS: u128 = 300;
|
||
/// Minimum interval (ms) between clipboard toasts for rapid word/line
|
||
/// selections. Drag completions always show the toast regardless.
|
||
const CLIPBOARD_TOAST_DEBOUNCE_MS: u128 = 500;
|
||
/// Minimum interval (ms) between consecutive context-bar clicks. Each click
|
||
/// fires an async ACP `session/info` request, so without this debounce a
|
||
/// double/triple-click would spawn redundant backend round-trips and reopen
|
||
/// the modal multiple times.
|
||
pub(super) const CONTEXT_CLICK_DEBOUNCE_MS: u128 = 300;
|
||
/// Default highlight TTL when `keep_text_selection` is `flash`.
|
||
const DEFAULT_SELECTION_HIGHLIGHT_DURATION_MS: u64 = 150;
|
||
/// Duration of the transient mode-switch banner (shown above prompt on Shift+Tab).
|
||
/// 2 s full visibility + 0.3 s fade out @ 30 fps.
|
||
const MODE_BANNER_TOTAL_TICKS: u8 = 69;
|
||
/// Final portion of the banner lifetime spent fading out (full → invisible).
|
||
const MODE_BANNER_FADE_TICKS: u8 = 9;
|
||
/// Whether `Event::Paste(text)` should probe the clipboard for image
|
||
/// bytes / a file reference. See [`crate::clipboard::paste_payload_needs_clipboard_attachment_probe`].
|
||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||
pub(super) fn bracketed_paste_should_probe(text: &str) -> bool {
|
||
crate::clipboard::paste_payload_needs_clipboard_attachment_probe(text)
|
||
}
|
||
/// Check if the platform link-action modifier is held.
|
||
/// macOS: Cmd (via CoreGraphics). Linux/Windows: Ctrl (from mouse modifiers).
|
||
#[cfg(target_os = "macos")]
|
||
pub(super) fn is_link_modifier_held(_mouse_modifiers: KeyModifiers) -> bool {
|
||
crate::input::macos_modifiers::snapshot().command
|
||
}
|
||
#[cfg(not(target_os = "macos"))]
|
||
pub(super) fn is_link_modifier_held(mouse_modifiers: KeyModifiers) -> bool {
|
||
mouse_modifiers.contains(KeyModifiers::CONTROL)
|
||
}
|
||
/// Determine whether the link modifier is held during a key event.
|
||
///
|
||
/// On macOS, polls CoreGraphics directly (independent of the event's modifier bits).
|
||
/// On Linux/Windows, derives from the key event's modifier flags, with a
|
||
/// special case for Ctrl-key release events (where the modifier bit is still
|
||
/// set in the event but the physical key is no longer held).
|
||
fn is_link_modifier_for_key(key: &KeyEvent) -> bool {
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
let _ = key;
|
||
crate::input::macos_modifiers::snapshot().command
|
||
}
|
||
#[cfg(not(target_os = "macos"))]
|
||
{
|
||
if key.kind == crossterm::event::KeyEventKind::Release
|
||
&& matches!(
|
||
key.code,
|
||
KeyCode::Modifier(
|
||
crossterm::event::ModifierKeyCode::LeftControl
|
||
| crossterm::event::ModifierKeyCode::RightControl
|
||
)
|
||
)
|
||
{
|
||
false
|
||
} else {
|
||
key.modifiers.contains(KeyModifiers::CONTROL)
|
||
}
|
||
}
|
||
}
|
||
fn supports_osc22() -> bool {
|
||
crate::terminal::terminal_context()
|
||
.hyperlink_capabilities()
|
||
.osc22_cursor
|
||
}
|
||
pub(super) fn has_native_link_hover() -> bool {
|
||
crate::terminal::terminal_context()
|
||
.hyperlink_capabilities()
|
||
.native_link_hover
|
||
}
|
||
/// Whether app Cmd/Ctrl+click should open this link (vs terminal bare-URL open).
|
||
pub(super) fn app_should_open_link_on_click(link: &crate::scrollback::VisibleLink) -> bool {
|
||
app_should_open_link_on_click_with(
|
||
crate::terminal::terminal_context()
|
||
.hyperlink_capabilities()
|
||
.native_plain_url_open,
|
||
link,
|
||
)
|
||
}
|
||
/// Test form of [`app_should_open_link_on_click`]. Yields only Standard-scheme
|
||
/// bare-URL-text hits when `native_plain_url_open` (labels/citations/file stay).
|
||
pub(super) fn app_should_open_link_on_click_with(
|
||
native_plain_url_open: bool,
|
||
link: &crate::scrollback::VisibleLink,
|
||
) -> bool {
|
||
if !native_plain_url_open {
|
||
return true;
|
||
}
|
||
if !crate::app::link_opener::is_safe_to_open(
|
||
&link.url,
|
||
crate::terminal::hyperlinks::SchemeFilter::Standard,
|
||
) {
|
||
return true;
|
||
}
|
||
!link.looks_like_bare_url_text()
|
||
}
|
||
/// Whether double/triple-click performs terminal-like word/line text selection
|
||
/// (and copy) instead of toggling a fold.
|
||
///
|
||
/// Unified into the `keep_text_selection` setting (the `word_select` mode):
|
||
/// reads the live appearance cache, so a Settings-panel change applies without
|
||
/// a restart and can never drift from the highlight-persistence behavior. The
|
||
/// default (`flash`) is fold-toggle, preserving backwards compatibility.
|
||
pub(super) fn is_text_selection_on_double_click() -> bool {
|
||
crate::appearance::cache::load_keep_text_selection().selects_word()
|
||
}
|
||
/// A driver-side turn-end broadcast awaiting its `session/prompt` RPC
|
||
/// response. See [`AgentView::pending_turn_end_reconcile`].
|
||
#[derive(Debug, Clone)]
|
||
pub(crate) struct PendingTurnEnd {
|
||
/// The prompt whose turn the broadcast declared ended.
|
||
pub prompt_id: String,
|
||
/// `stopReason` from the broadcast (`"cancelled"`, `"end_turn"`, …).
|
||
pub stop_reason: Option<String>,
|
||
/// `agentResult` detail from the broadcast (error text, when present).
|
||
pub agent_result: Option<String>,
|
||
/// `_meta.cancelTrigger` from the broadcast (`"send_now"` marks a
|
||
/// cancel-and-send whose "Turn cancelled" marker is suppressed). `None`
|
||
/// on older shells / non-cancel ends.
|
||
pub cancel_trigger: Option<String>,
|
||
/// When the broadcast arrived; the reconcile fires after
|
||
/// [`super::dispatch::TURN_END_RECONCILE_GRACE`].
|
||
pub received_at: std::time::Instant,
|
||
}
|
||
/// Stop/stop_failure hook runs held for the live turn's terminal marker.
|
||
/// See [`AgentView::pending_stop_hooks`].
|
||
#[derive(Debug, Clone, Default)]
|
||
pub(crate) struct PendingStopHooks {
|
||
/// The turn the stash belongs to; a stash that can't be matched to the
|
||
/// ending turn flushes standalone instead of attaching to its marker.
|
||
pub prompt_id: Option<String>,
|
||
/// `(event_name, runs)` per hook batch, in arrival order
|
||
/// (`stop_failure` before `stop` on error turns).
|
||
pub groups: Vec<(String, Vec<crate::scrollback::blocks::tool::HookRunEntry>)>,
|
||
}
|
||
/// Components for the deferred fork banner. Stored by
|
||
/// `dispatch_fork_resolved` and formatted into the final banner text
|
||
/// in `TaskResult::SessionLoaded` once the child's session id is known.
|
||
#[derive(Debug, Clone)]
|
||
pub(crate) struct PendingForkBanner {
|
||
/// Full session id of the parent session.
|
||
pub parent_sid: String,
|
||
/// Whether the fork created a new worktree.
|
||
pub worktree: bool,
|
||
}
|
||
/// In-flight reconnect session reload.
|
||
///
|
||
/// Opened by [`AgentView::begin_session_reload`]: the pre-outage scrollback
|
||
/// and tracker are stashed here while the live fields point at fresh state
|
||
/// the `session/load` replay streams into. [`AgentView::finish_session_reload`]
|
||
/// then keeps the replayed state, merges a cursor-resolved live tail onto the
|
||
/// stash, or restores the stash wholesale on failure — so a failed or
|
||
/// superseded reload can never leave the transcript blank.
|
||
///
|
||
/// Restore scope: the stash covers the transcript-critical state below (plus
|
||
/// the todo list). Satellite state the replay also mutates —
|
||
/// `subagent_sessions`/`subagent_views`, bg/scheduled tasks,
|
||
/// `available_commands`, context usage — is NOT restored on failure: live
|
||
/// updates keep routing through those maps during the window, so stashing
|
||
/// them would break mid-window routing, and they re-converge on the next
|
||
/// successful reload. A `SubagentInfo::scrollback_entry_id` replayed during a
|
||
/// failed window dangles into the discarded staging state (harmless no-op
|
||
/// lookups; never aliased, thanks to the shared `EntryId` space).
|
||
pub(crate) struct SessionReload {
|
||
/// Reconnect generation (from `ConnectionStatus::Connected`) this reload
|
||
/// was opened for; finalization is rejected for any other generation.
|
||
generation: u64,
|
||
/// Pre-outage transcript.
|
||
scrollback: ScrollbackState,
|
||
/// Pre-outage streaming tracker (paired with `scrollback`).
|
||
tracker: crate::acp::tracker::AcpUpdateTracker,
|
||
/// Pre-outage todo list (replayed Plan updates overwrite the live pane).
|
||
todo: TodoPane,
|
||
/// Reconnect cursor as of window open, restored with the stash so a
|
||
/// later reload doesn't skip events the restored transcript never got.
|
||
last_seen_event_id: Option<String>,
|
||
/// Live dedup highwaters (ACP + xAI) as of window open (same restore
|
||
/// rationale).
|
||
last_applied_event_seq: Option<u64>,
|
||
last_applied_xai_event_seq: Option<u64>,
|
||
/// Whether any `isReplay` update applied during this window. False means
|
||
/// the agent resolved the cursor and sent only a live post-cursor tail.
|
||
saw_replay: bool,
|
||
/// Whether a Plan update applied during this window: the cursor-merge
|
||
/// outcome then keeps the staging todo list (newer) instead of the stash.
|
||
saw_todo_update: bool,
|
||
}
|
||
/// Follow-up suggestion chips for the latest assistant response
|
||
/// (`kigi/follow_ups`). Streaming-only: never persisted, does not survive a
|
||
/// session reload. Keyed by the assistant `response_id` (the newest-wins key).
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub(crate) struct FollowUps {
|
||
/// `params.response_id` these chips belong to (the newest-wins key).
|
||
pub(crate) response_id: String,
|
||
/// Suggestion labels, already sanitized (control + bidi/format chars
|
||
/// stripped) and length-bounded at ingestion. Non-empty whenever
|
||
/// `AgentView::follow_ups` is `Some`.
|
||
pub(crate) suggestions: Vec<String>,
|
||
}
|
||
/// A prompt submit stashed while a clipboard attachment probe is off-thread.
|
||
///
|
||
/// Kind-only: the payload is re-derived from the live widget when the send is
|
||
/// re-issued (see [`AgentView::build_deferred_send_action`]), so the freshly
|
||
/// attached image chip (and its aligned chip range) travels with it.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub(crate) enum AgentDeferredSend {
|
||
/// Enter — a normal prompt send.
|
||
SendPrompt,
|
||
/// Ctrl+Enter — a mid-turn interjection.
|
||
Interject,
|
||
}
|
||
/// How the parked-marker slot was consumed. Both variants carry the turn's
|
||
/// prompt id and both keep the parked (idle) chrome. `Rendered` markers keep
|
||
/// flowing on tail staleness (see `maybe_push_parked_marker`); `Forgone` (an
|
||
/// interjection continued the parked turn) is final — a later "Worked for"
|
||
/// line would land below the interjected message, flipping the transcript.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub(crate) enum ParkedMarkerSlot {
|
||
/// A "Worked for X. … still running." marker block was pushed.
|
||
Rendered {
|
||
prompt_id: String,
|
||
entry_id: EntryId,
|
||
agent_output_epoch: u64,
|
||
},
|
||
/// The marker was forgone: an interjection continued the parked turn.
|
||
Forgone(String),
|
||
}
|
||
impl ParkedMarkerSlot {
|
||
/// The prompt id the slot was consumed for, regardless of variant.
|
||
pub(crate) fn prompt_id(&self) -> &str {
|
||
match self {
|
||
ParkedMarkerSlot::Rendered { prompt_id, .. } | ParkedMarkerSlot::Forgone(prompt_id) => {
|
||
prompt_id
|
||
}
|
||
}
|
||
}
|
||
/// The mutable parked marker entry and its parent-output boundary.
|
||
pub(crate) fn rendered_marker(&self) -> Option<(EntryId, u64)> {
|
||
match self {
|
||
ParkedMarkerSlot::Rendered {
|
||
entry_id,
|
||
agent_output_epoch,
|
||
..
|
||
} => Some((*entry_id, *agent_output_epoch)),
|
||
ParkedMarkerSlot::Forgone(_) => None,
|
||
}
|
||
}
|
||
}
|
||
pub struct AgentView {
|
||
pub session: AgentSession,
|
||
pub scrollback: ScrollbackState,
|
||
pub prompt: PromptWidget,
|
||
/// Sticky: once the user types in the prompt, hide the tip for the session.
|
||
pub tip_typing_dismissed: bool,
|
||
pub todo: TodoPane,
|
||
pub tasks: TasksPane,
|
||
pub catalog: SubagentCatalogPane,
|
||
pub queue: QueuePane,
|
||
/// Per-agent mirror of the server-authoritative shared prompt queue
|
||
/// (`AppView::shared_prompt_queues[sid]`), kept in sync by
|
||
/// `handle_queue_changed` and the immediate-send path. The queue
|
||
/// pane renders the union of this and the local `pending_prompts`; the
|
||
/// edit handlers read it to route remove/reorder by origin. Empty unless a
|
||
/// plain prompt was queued server-side while a turn was running.
|
||
pub shared_queue: Vec<crate::app::prompt_queue::QueueEntryWire>,
|
||
/// True when this session was opened via `session/load` (session picker
|
||
/// resume, `/resume`, or a leader dashboard roster attach) rather than
|
||
/// created locally — i.e. this client is *viewing* a session it did not
|
||
/// start. While set, the ACP gate adopts the prompt id of incoming live
|
||
/// `session/update` deltas (the driver's turn) instead of dropping them,
|
||
/// so the viewer renders the in-flight (and subsequent) turns live. This
|
||
/// must NOT be applied to a locally-created driver, whose post-rewind
|
||
/// stale-chunk drop semantics rely on the strict prompt-id match. Cleared
|
||
/// in `maybe_drain_queue` the moment this client sends its own prompt
|
||
/// ("takes the wheel"), and re-derived per turn from
|
||
/// [`Self::self_originated_prompt_ids`] in the ACP gate / turn-start shim:
|
||
/// a client that has driven a turn can still go on to VIEW a turn another
|
||
/// client drives (e.g. a `/loop` cron, or a plain prompt typed in another
|
||
/// pane), so this flag is not a one-way latch.
|
||
pub attached_as_viewer: bool,
|
||
/// Prompt ids of turns THIS client originated (sent to the agent as the
|
||
/// turn driver). The ACP gate consults this to keep `attached_as_viewer`
|
||
/// per-turn accurate: a prompt id present here is this client's own turn
|
||
/// (drive it — and drop a stale post-rewind chunk on a mismatch), while one
|
||
/// that is absent is another client's (or a server-initiated) turn (adopt +
|
||
/// render it as a viewer). Without this, the flag latched false after the
|
||
/// first local prompt and the gate dropped every later turn a different
|
||
/// pane drove. Bounded FIFO — only recent ids matter (a stale chunk arrives
|
||
/// right after its turn ends).
|
||
pub self_originated_prompt_ids: VecDeque<String>,
|
||
/// Highwater of the largest `eventId` counter applied to this session's
|
||
/// scrollback (see `acp::meta::NotificationMeta::event_seq`). Incoming
|
||
/// `session/update`s with a counter `<=` this are duplicates (replay/live
|
||
/// overlap, a re-emit after the reconnect gate, or duplicate routing) and
|
||
/// are dropped so each event renders exactly once. `None` until the first
|
||
/// `eventId`-bearing update.
|
||
///
|
||
/// ACP stream only — the xAI stream keeps its own highwater
|
||
/// ([`Self::last_applied_xai_event_seq`]) because the two streams are not
|
||
/// delivered in one id order: ACP lines ride the agent's FIFO event
|
||
/// pipeline while xAI lines are emitted direct-to-gateway, so a fresh xAI
|
||
/// id arriving ahead of queued lower-id ACP chunks must not make the
|
||
/// chunks look stale (silent live-text loss).
|
||
pub last_applied_event_seq: Option<u64>,
|
||
/// xAI-stream sibling of [`Self::last_applied_event_seq`] (see there for
|
||
/// why the highwaters are split). Same drop rule, replay-exempt.
|
||
pub last_applied_xai_event_seq: Option<u64>,
|
||
/// Raw `eventId` of the most recent update APPLIED to this root session —
|
||
/// replay or live, on both the ACP and xAI paths; dropped updates (dedup,
|
||
/// promptId gate, unexpected replay) don't move it. Sent as `_meta.cursor`
|
||
/// on a reconnect `session/load` so the agent replays only the post-cursor
|
||
/// tail. Why the full string: see
|
||
/// [`crate::acp::meta::NotificationMeta::event_id`].
|
||
pub last_seen_event_id: Option<String>,
|
||
/// Open reconnect reload window, if any. See [`SessionReload`].
|
||
pub(crate) session_reload: Option<SessionReload>,
|
||
/// Unexpected-replay drops since the last reload window opened. Gates the
|
||
/// drop log to one `warn!` per incident (a late replay is one line per
|
||
/// event — thousands for a large transcript).
|
||
pub(crate) unexpected_replay_drops: u32,
|
||
/// Prompt ids whose durable `TurnCompleted` terminal arrived during THIS
|
||
/// load's replay window (`loading_replay`). The running turn is not adopted
|
||
/// until replay finishes, so a terminal seen mid-replay can't be finalized
|
||
/// yet — it is recorded here and consulted by
|
||
/// [`Self::should_adopt_running_prompt`] so the post-replay adoption skips a
|
||
/// turn that already ended (otherwise the viewer re-strands on "Waiting…").
|
||
/// Reset at the start of every load so it never leaks across loads.
|
||
pub(crate) replayed_terminal_prompts: HashSet<String>,
|
||
pub active_pane: AgentPane,
|
||
/// Current mode of the prompt widget (normal vs editing a queued prompt).
|
||
pub prompt_mode: PromptMode,
|
||
pub prompt_input_mode: PromptInputMode,
|
||
/// Multiline input mode: swap Enter (insert newline) and Shift+Enter (send).
|
||
/// Toggled by `Ctrl+M` or `/multiline`. Not persisted across sessions.
|
||
pub multiline_mode: bool,
|
||
/// Vim-mode scrollback keybindings. When `false` (default), bare-letter
|
||
/// and Shift+letter scrollback bindings (j/k, h/l, g/G, y/Y, o/O, r,
|
||
/// x, e/E, L/H, plus the `i` FocusPrompt alt) are suppressed and the
|
||
/// pressed letter forwards to the prompt textarea via
|
||
/// `Action::FocusPrompt` + `ActionThenForward`. Arrows / Tab / Esc /
|
||
/// Space / PgUp / PgDn and all `Ctrl+letter` bindings remain active
|
||
/// regardless of this flag. Pager-owned ephemeral field — reset each session.
|
||
pub vim_mode: bool,
|
||
/// Runtime InputMode synced from persisted `simple_mode` bool (false=Vim). Pane reconcile only on Vim+empty prompt. Subagent children are forced to Vim after new() (no prompt UI).
|
||
pub input_mode: InputMode,
|
||
/// Whether the current/last turn was a bash-mode command.
|
||
/// Set when a bash command starts, cleared on next non-bash turn.
|
||
/// Used to auto-focus scrollback after bash turn completion.
|
||
pub bash_turn: bool,
|
||
/// The task ID of the currently running cron turn, if any.
|
||
/// Set when a cron prompt is drained, cleared on turn completion.
|
||
pub cron_task_id: Option<String>,
|
||
/// Stashed normal prompt state while editing a queued prompt.
|
||
/// Restored when editing ends.
|
||
pub stashed_prompt: Option<StashedPrompt>,
|
||
/// Complete prompt stashed from a turn that failed because the login
|
||
/// expired (401 / re-auth). Used by the `AuthComplete` handler to
|
||
/// auto-resubmit the prompt after a successful mid-session re-auth so
|
||
/// the user doesn't have to retype it.
|
||
pub reauth_stashed_prompt: Option<crate::app::agent::InFlightPrompt>,
|
||
/// Currently active modal dialog (blocks all other input).
|
||
pub active_modal: Option<ActiveModal>,
|
||
/// Hit areas for modal buttons (from last render).
|
||
pub(crate) modal_buttons: Vec<ModalButtonHit>,
|
||
/// Currently hovered modal button key (for highlight).
|
||
pub(crate) modal_hovered_key: Option<char>,
|
||
/// Cached server-reported context state.
|
||
pub context_state: Option<kigi_shell::session::ContextInfo>,
|
||
/// Gateway light-frontend session (`kind: "chat"` / `--chat` / conversation
|
||
/// resume). Suppresses Build credits / local sampler context telemetry so the
|
||
/// status bar and prompt never imply remote usage from wrong metrics.
|
||
pub chat_kind: bool,
|
||
/// Process-wide `--chat` (mirrors `AppView::chat_mode`; set via
|
||
/// [`Self::apply_app_scoped_gates`]). UI policy only: hides picker
|
||
/// source filter / delete / deep search on a conversations-only list.
|
||
/// Unlike `chat_kind`, stays `false` for a `/chat` one-shot session in
|
||
/// a Build process, whose picker still lists local sessions.
|
||
pub app_chat_mode: bool,
|
||
/// Current goal orchestration state. Set by `GoalUpdated` session
|
||
/// notifications, cleared when a new session starts.
|
||
pub goal_state: Option<super::agent::GoalDisplayState>,
|
||
/// Current graph orchestration state. Set by `GraphUpdated` session
|
||
/// notifications, cleared on the `"cleared"` sentinel / new session.
|
||
pub graph_state: Option<super::agent::GraphDisplayState>,
|
||
/// The consumed parked-wait marker slot for the current turn, if any.
|
||
/// Keyed by prompt id: a new turn naturally invalidates the slot with no
|
||
/// explicit clear site. See [`ParkedMarkerSlot`].
|
||
pub(crate) parked_wait_marker_for: Option<ParkedMarkerSlot>,
|
||
/// A turn-end marker announced background work ("N still running"), so
|
||
/// no-wake completions landing between turns re-emit a fresh work-only
|
||
/// status line after their chip (wake-bound ones get the wake turn's end
|
||
/// marker instead). Written by exactly one assignment — the shared
|
||
/// marker tail `push_end_marker_block` (real-turn AND wake markers:
|
||
/// counted opens, workless closes) — and cleared when a real turn starts
|
||
/// and on every replay-window entry, closing the between-turns window.
|
||
pub(crate) end_work_announced: bool,
|
||
/// Live `stop`/`stop_failure` hook runs held for the turn's terminal
|
||
/// marker (driver order: the hooks arrive before the `PromptResponse`
|
||
/// that pushes it). Consumed or flushed by `push_turn_terminal_marker`;
|
||
/// dropped on every replay-window entry.
|
||
pub(crate) pending_stop_hooks: Option<PendingStopHooks>,
|
||
/// Goal id of the most recently cleared goal, captured from the dropped
|
||
/// state (the `cleared` event itself carries an empty id). Drops a late
|
||
/// in-flight `GoalUpdated` that would otherwise resurrect the cleared
|
||
/// chip/modal. Single slot: goal ids are unique, so only the latest clear
|
||
/// can race a stale update.
|
||
pub last_cleared_goal_id: Option<String>,
|
||
/// Whether the expanded goal detail overlay is visible.
|
||
/// Toggled by `Action::ToggleGoalDetail`. Only shown when
|
||
/// `goal_state` is `Some`.
|
||
pub show_goal_detail: bool,
|
||
/// UTC ms when the current turn started (`turnStartMs` from notification meta).
|
||
/// Used for turn elapsed display.
|
||
pub turn_start_ms: Option<i64>,
|
||
/// `(prompt_id, turnStartMs)` of the streaming wake turn, recorded off its
|
||
/// live deltas. Non-adopted synthetic turns never set `turn_started_at`,
|
||
/// and the durable `TurnCompleted` envelope carries no timing — this is
|
||
/// the wake-end marker's only elapsed source. Consumed (pid-matched) at
|
||
/// that marker's push; `None` there renders the marker without a duration.
|
||
pub wake_turn_start: Option<(String, i64)>,
|
||
/// Local wall-clock time when the current turn started.
|
||
/// Set by `maybe_drain_queue` when a prompt is sent. Used to compute
|
||
/// elapsed time for "Worked for Xm Ys." system messages.
|
||
pub turn_started_at: Option<Instant>,
|
||
/// Turn-start anchor a `turn.first_activity` log was already emitted for (fire-once-per-turn guard).
|
||
pub first_activity_logged_for: Option<Instant>,
|
||
/// Accumulated duration the turn timer was paused (while the user was
|
||
/// answering questions via `AskUserQuestion`). Reset when the turn ends.
|
||
pub turn_paused_duration: std::time::Duration,
|
||
/// IDs of interjections this client sent and already rendered locally
|
||
/// (optimistic echo). The shell broadcasts `kigi/session/interjection` to
|
||
/// every attached pane; when our own broadcast echoes back carrying an id
|
||
/// in this set, `handle_interjection` drops it (we already showed it) and
|
||
/// removes the id. Other panes (which lack the id) render it. This is the
|
||
/// queue's optimistic-echo + reconcile-by-id pattern, applied so the
|
||
/// originator gets instant feedback AND viewers stay in sync.
|
||
pub self_interjection_ids: std::collections::HashSet<String>,
|
||
/// Local wall-clock time when the most recent turn finished
|
||
/// (success, failure, or cancellation). Used by the dashboard
|
||
/// modal to display "Nm ago" idle markers. Initialised to the
|
||
/// agent-creation time in [`AgentView::new`] so newly-created
|
||
/// agents that have never run a turn still show a sensible
|
||
/// relative time.
|
||
pub last_active_at: Option<Instant>,
|
||
pub current_branch: Option<String>,
|
||
pub is_worktree: bool,
|
||
pub main_repo: Option<String>,
|
||
/// Human-readable worktree label from the worktree metadata DB, when
|
||
/// this agent's cwd is a managed worktree. Refreshed off the git
|
||
/// caches when the dashboard opens; drives the dashboard row's
|
||
/// worktree-name subtitle. `None` for non-worktree agents.
|
||
pub worktree_label: Option<String>,
|
||
/// Local wall-clock time when the current activity phase started.
|
||
/// Reset on each activity transition (thinking → responding → tool, etc.).
|
||
/// Used for the `(5s)` phase timer in the turn status line.
|
||
pub activity_started_at: Option<Instant>,
|
||
/// Last observed [`TurnActivity`] — used to detect phase transitions
|
||
/// and reset `activity_started_at`.
|
||
pub(crate) last_activity: Option<crate::acp::tracker::TurnActivity>,
|
||
/// Cached pane areas from last render, for mouse hit-testing.
|
||
pub pane_areas: PaneAreas,
|
||
/// Entry index currently hovered by the mouse (for dimmed selection box).
|
||
pub hovered_entry: Option<usize>,
|
||
/// Pending markdown text drag before the pointer crosses the drag threshold.
|
||
pub pending_text_drag: Option<PendingTextDrag>,
|
||
/// Active markdown text drag selection.
|
||
pub drag_selection: Option<ActiveTextDrag>,
|
||
/// Pending whole-block drag before the pointer crosses the drag threshold.
|
||
pub pending_block_drag: Option<PendingBlockDrag>,
|
||
/// Active whole-block drag selection.
|
||
pub block_drag_selection: Option<ActiveBlockDrag>,
|
||
/// Deferred text-drag anchor, armed (with the press position, for
|
||
/// tracing) by a scrollback press that hit no selectable text — chrome,
|
||
/// vpad, and gap rows all count, so any scrollback point can start a
|
||
/// selection gesture — or by a press on the passive strips between the
|
||
/// scrollback pane and the prompt box (turn status, banner, gap rows;
|
||
/// interactive controls there consume their presses first). While set,
|
||
/// the first drag motion that lands on selectable text anchors an
|
||
/// [`ActiveTextDrag`] THERE (the entry point, not the press point) and
|
||
/// cancels any block-drag state; the conversion is one-way. A gesture
|
||
/// that never enters text keeps whatever the press
|
||
/// armed alongside (the in-pane whole-block drag; strips arm nothing
|
||
/// else), and a release while still deferred falls back to the press's
|
||
/// native semantics (in-pane: the plain click cascade; strips: a no-op —
|
||
/// no click latch is set there). Btw presses never arm it, and no source
|
||
/// arms while the block viewer is open.
|
||
pub deferred_text_press: Option<(u16, u16)>,
|
||
/// Persistent text selection (survives mouse-up). Set after drag
|
||
/// completion, double-click, or triple-click. Cleared on next click
|
||
/// elsewhere, Escape, or navigation.
|
||
pub persistent_text_selection: Option<PersistentTextSelection>,
|
||
/// Table geometry backing a table-shaped drag / persistent selection,
|
||
/// keyed to that selection; ignored when the key doesn't match.
|
||
pub table_selection_geometry: Option<TableSelectionGeometry>,
|
||
/// Timestamp when the current persistent selection was created.
|
||
/// Used for auto-dismissal after a configurable timeout.
|
||
pub selection_created_at: Option<Instant>,
|
||
/// Last mouse position during an active drag (for autoscroll re-hit-testing).
|
||
pub last_drag_mouse: Option<(u16, u16)>,
|
||
/// Active drag autoscroll state (scrolls on each tick while set).
|
||
pub drag_autoscroll: Option<DragAutoScrollState>,
|
||
/// Whether the primary mouse button is currently held down.
|
||
pub(crate) left_mouse_down: bool,
|
||
/// Whether a left-button drag that started in the feedback prompt is in
|
||
/// progress while the plan preview (line viewer) is open. Used to keep
|
||
/// forwarding `Drag`/`Up` events to the prompt so text selection in the
|
||
/// input works even though the line viewer otherwise intercepts them.
|
||
pub(crate) plan_prompt_mouse_drag: bool,
|
||
/// Last resolved scrollback selection model from the most recent draw.
|
||
pub last_scrollback_selection_model: ResolvedSelectionModel,
|
||
/// Edit-only hidden source boundaries paired with the last scrollback model.
|
||
pub(crate) last_scrollback_selection_boundaries: ResolvedSelectionBoundaries,
|
||
/// Link overlay from the last frame (scrollback + optional `/btw` merge;
|
||
/// for OSC 8 emission).
|
||
pub last_link_overlay: crate::render::osc8::LinkOverlay,
|
||
/// Rects of overlays drawn over the scrollback content this frame (dropdowns,
|
||
/// goal-detail). Used to exclude covered links from both OSC 8 emission and
|
||
/// mouse hover/click hit-testing. Rebuilt each `draw()`.
|
||
pub frame_occluder_rects: Vec<Rect>,
|
||
/// Per-frame map of clickable link regions (populated after scrollback render).
|
||
pub visible_link_map: crate::scrollback::link_map::VisibleLinkMap,
|
||
/// Number of links in `visible_link_map` that came from the scrollback
|
||
/// rebuild (citations included). Overlay-only sources (e.g. `/btw`) are
|
||
/// appended after this prefix each frame and truncated back on the next.
|
||
scrollback_visible_link_count: usize,
|
||
/// Index of the currently highlighted link in `visible_link_map` for
|
||
/// keyboard link navigation (o/O cycling). `None` when not in link-nav mode.
|
||
pub highlighted_link_idx: Option<usize>,
|
||
/// Link index under the mouse cursor (for hover highlight).
|
||
pub hovered_link_idx: Option<usize>,
|
||
/// Last emitted OSC 22 pointer state (avoids re-emitting every frame).
|
||
pub last_pointer_on_link: bool,
|
||
/// Selection model for the /btw overlay panel (populated each frame).
|
||
pub last_btw_selection_model: ResolvedSelectionModel,
|
||
/// Cached screen rect of the /btw overlay panel from the last render.
|
||
pub last_btw_area: Rect,
|
||
/// Pending plain scrollback click that should dispatch on mouse-up if no drag starts.
|
||
pub pending_scrollback_click: Option<(u16, u16)>,
|
||
/// Pending link click: (col, row, url). Set on Down(Left) when a link is hit,
|
||
/// consumed on Up(Left) at the same position, cleared on drag.
|
||
pub pending_link_click: Option<(u16, u16, String)>,
|
||
/// Absolute paths of media generated in this transcript, used to resolve the
|
||
/// short relative paths the model prints (`images/1.jpg`) to clickable
|
||
/// links. Rebuilt from scrollback only when its generation changes.
|
||
pub media_link_paths: Vec<std::path::PathBuf>,
|
||
/// Scrollback generation [`Self::media_link_paths`] was built for.
|
||
pub media_link_paths_gen: Option<u64>,
|
||
/// Last mouse position (column, row) for hover hit-testing.
|
||
pub last_mouse_pos: (u16, u16),
|
||
/// When the pointer last moved (any mouse event). Bounds the macOS
|
||
/// Cmd-key link-hover poll: a pointer merely *resting* over content must
|
||
/// not keep the ~30fps animation tick (and its per-tick CoreGraphics
|
||
/// query) alive indefinitely — see [`Self::needs_link_modifier_poll`].
|
||
pub last_mouse_moved_at: Option<Instant>,
|
||
/// Last click info for multi-click detection: (timestamp, entry_index, click_count).
|
||
pub last_click: Option<(Instant, usize, u8)>,
|
||
/// Text-level multi-click state (finer-grained than block-level `last_click`).
|
||
/// Mutually exclusive with `last_click`: one is cleared when the other is set.
|
||
pub last_text_click: Option<TextClickState>,
|
||
/// Timestamp of the last clipboard toast shown for word/line selection.
|
||
/// Used to debounce rapid toasts; drag completions bypass this.
|
||
pub last_clipboard_toast_at: Option<Instant>,
|
||
/// Timestamp of the last accepted context-bar click. Used to debounce
|
||
/// rapid clicks so we don't spawn a redundant `session/info` request
|
||
/// per double-click.
|
||
pub last_context_click_at: Option<Instant>,
|
||
/// Whether the mouse is hovering over the prompt widget.
|
||
pub hovered_prompt: bool,
|
||
pub hit_badge: HitArea,
|
||
pub hit_context: HitArea,
|
||
pub hit_todo_close: HitArea,
|
||
pub hit_bg_close: HitArea,
|
||
pub hit_subagent_close: HitArea,
|
||
pub hit_catalog_close: HitArea,
|
||
pub hit_bg_status: HitArea,
|
||
pub hit_goal_status: HitArea,
|
||
pub hit_goal_close: HitArea,
|
||
pub hit_bg_button: HitArea,
|
||
#[allow(dead_code)]
|
||
pub(crate) last_bg_click: Option<Instant>,
|
||
pub hit_queue_close: HitArea,
|
||
pub hit_queue_badge: HitArea,
|
||
pub hit_plan_button: HitArea,
|
||
pub hit_plan_approval_status: HitArea,
|
||
pub hit_follow_indicator: HitArea,
|
||
/// CWD / worktree path in the status bar (click to copy).
|
||
pub hit_cwd: HitArea,
|
||
/// Cancel button in turn status line (`[stop]`).
|
||
pub hit_cancel_button: HitArea,
|
||
/// Scrollbar track for the scrollback pane (for click-to-jump / drag).
|
||
pub hit_scrollbar: HitArea,
|
||
/// Whether a scrollbar drag is in progress on the scrollback scrollbar.
|
||
pub scrollbar_dragging: bool,
|
||
/// Cached screen area of the file search dropdown items (for mouse hit-testing).
|
||
/// Excludes border rows — only the clickable item rows.
|
||
pub(crate) dropdown_items_area: Option<Rect>,
|
||
/// Cached screen area of the slash dropdown items (for mouse hit-testing).
|
||
pub(crate) slash_dropdown_items_area: Option<Rect>,
|
||
/// Slash dropdown hit-test state from the renderer (see
|
||
/// [`crate::views::slash_dropdown::RenderedDropdown`]).
|
||
pub(crate) slash_dropdown_hit: crate::views::slash_dropdown::RenderedDropdown,
|
||
/// Cached screen area of the completion dropdown items (for mouse hit-testing).
|
||
pub(crate) completion_dropdown_items_area: Option<Rect>,
|
||
/// Cached screen area of the history search dropdown items (for mouse hit-testing).
|
||
pub(crate) history_dropdown_area: Option<Rect>,
|
||
/// Last prompt click timestamp (for double-click detection).
|
||
pub(crate) last_prompt_click_ms: Option<Instant>,
|
||
/// Active line viewer popup (Phase 3). When `Some`, the viewer intercepts
|
||
/// all input and renders as a centered overlay.
|
||
pub(crate) line_viewer: Option<LineViewerState>,
|
||
/// Active image viewer popup. When `Some`, shows an image preview that
|
||
/// intercepts input (Esc to close).
|
||
pub(crate) image_viewer: Option<crate::prompt_images::ImageViewerState>,
|
||
/// Receiver for background image loading. Set when a deferred image
|
||
/// viewer spawns a load thread; polled each tick via `try_recv()`.
|
||
pub(crate) image_load_rx:
|
||
Option<std::sync::mpsc::Receiver<crate::prompt_images::ImageLoadResult>>,
|
||
/// Active video viewer popup (Esc to close, Space to pause).
|
||
pub(crate) video_viewer: Option<crate::prompt_images::VideoViewerState>,
|
||
/// Active `/gboom` easter-egg game modal.
|
||
pub(crate) gboom: Option<crate::gboom::GboomState>,
|
||
/// Protocol-prepared image bytes keyed by file path. Used for dimension
|
||
/// decoding and iTerm2 re-sends. Kitty transmits once and re-places.
|
||
pub(crate) inline_media_cache: std::collections::HashMap<std::path::PathBuf, Vec<u8>>,
|
||
/// Kitty GPU image IDs per media path. Each path gets a unique ID (2+)
|
||
/// so switching between images is a cheap re-place (~80 bytes) instead
|
||
/// of a full re-transmit. ID 1 is reserved for modal overlays.
|
||
pub(crate) inline_media_ids: std::collections::HashMap<std::path::PathBuf, u32>,
|
||
/// Last iTerm2 placement rect per path. Avoids re-sending full base64 image
|
||
/// data every TUI frame; re-emitted only when a path's `screen_rect` changes.
|
||
pub(crate) inline_media_iterm_emitted:
|
||
std::collections::HashMap<std::path::PathBuf, ratatui::layout::Rect>,
|
||
/// Counter for allocating the next Kitty image ID.
|
||
pub(crate) next_inline_media_id: u32,
|
||
/// Active inline video playback (user-initiated via click/Enter).
|
||
pub(crate) inline_video: Option<InlineVideoState>,
|
||
/// Receiver for background video frame extraction.
|
||
pub(crate) video_load_rx: Option<std::sync::mpsc::Receiver<Option<InlineVideoState>>>,
|
||
/// Off-thread Mermaid render runtime (worker channels + the on-click renders
|
||
/// awaiting their result). Lazily created on the first *cache-missing*
|
||
/// `[Open]`/`[Copy path]` click; `None` until then.
|
||
pub(crate) mermaid: Option<crate::app::mermaid_worker::MermaidRuntime>,
|
||
/// Off-thread edit-diff full-file syntax highlight upgrade. Lazily created
|
||
/// on the first successful Edit tool completion that has hunks.
|
||
pub(crate) edit_hl: Option<crate::app::edit_highlight_worker::EditHlRuntime>,
|
||
/// Whether any inline media is currently placed on screen.
|
||
pub(crate) inline_media_active: bool,
|
||
/// Image IDs that were placed on screen last frame. Used to detect
|
||
/// images that scrolled off and need their Kitty placements cleared.
|
||
pub(crate) last_placed_ids: HashSet<u32>,
|
||
/// Previous terminal dimensions — used to detect resize and invalidate
|
||
/// Kitty IDs (terminals clear GPU data on resize).
|
||
///
|
||
/// This is the size of the rect this view last painted into, which can
|
||
/// be smaller than the terminal (dashboard overlay header band/popup,
|
||
/// dev tracing split). Only `note_terminal_size` (draw) writes it.
|
||
pub(crate) last_terminal_size: (u16, u16),
|
||
/// Set on every `Event::Resize` (see `AppView::handle_input`), cleared
|
||
/// by the next draw's re-measure. While set, `last_terminal_size` is
|
||
/// known-invalidated and the ephemeral-tip show gate refuses, so a
|
||
/// trigger racing the (debounced) resize draw can never burn a seen
|
||
/// count against a height the new layout may not be able to paint.
|
||
pub(crate) terminal_size_stale: bool,
|
||
/// Hit areas for inline media buttons (cleared and rebuilt each frame).
|
||
pub(crate) inline_media_hits: InlineMediaHitAreas,
|
||
/// Active hooks/plugins modal popup. When `Some`, blocks all input and
|
||
/// renders as a centered overlay. Opened by `/hooks`, `/plugins`, or `/mcps`.
|
||
pub(crate) extensions_modal: Option<ExtensionsModalState>,
|
||
/// Active agents modal popup. When `Some`, blocks all input and
|
||
/// renders as a centered overlay. Opened by `/config-agents` or `/agents`.
|
||
pub(crate) agents_modal: Option<crate::views::agents_modal::AgentsModalState>,
|
||
pub(crate) persona_detail: Option<crate::views::persona_detail::PersonaDetailState>,
|
||
/// Active /btw side question overlay. When `Some`, renders as a dismissible
|
||
/// overlay and captures keyboard input (Esc/Enter/Space to dismiss).
|
||
pub btw_state: Option<crate::views::btw_overlay::BtwOverlayState>,
|
||
/// Whether the /btw panel holds keyboard focus. The panel is non-blocking,
|
||
/// so Up/Down/PgUp/PgDn scroll it when focused and otherwise reach the
|
||
/// prompt. Set on a `Done` answer; cleared when the user types in or clicks
|
||
/// the prompt.
|
||
pub(crate) btw_focused: bool,
|
||
/// Hit area for the [Esc] close button in the /btw panel title.
|
||
pub(crate) hit_btw_close: HitArea,
|
||
/// Toast message to display briefly (e.g., "Copied!" after y).
|
||
/// Tuple of (message, remaining_ticks). Decremented each tick, removed at 0.
|
||
/// Does **not** carry sticky status banners — see [`Self::sticky_toast`].
|
||
pub(crate) toast: Option<(String, u8)>,
|
||
/// Single-slot ephemeral tip shown in the banner rect above the prompt.
|
||
/// Unlike `toast`, survives typing; cleared by TTL, any prompt-box
|
||
/// submit (prompt/interject/bash/feedback/remember), or explicit clear.
|
||
/// Show via `show_ephemeral_tip` (renderability-gated), never `.show()`.
|
||
pub(crate) ephemeral_tip: crate::tips::EphemeralTipState,
|
||
/// Prompt text snapshot taken when the word-select tip was shown. Any
|
||
/// divergence (typed, pasted, dropped — every edit path, no per-helper
|
||
/// hooks) means the user moved past the double-click moment: the Ctrl+Y
|
||
/// intercept refuses and the tick path retires the tip, so the long TTL
|
||
/// can never shadow yank mid-edit. `None` while the tip is not showing.
|
||
pub(crate) word_select_tip_prompt_snapshot: Option<String>,
|
||
/// When the last fold/nav double-click landed on assistant text (a
|
||
/// word-select probe). A second probe within the repeat window is the
|
||
/// repeated-selection-attempt signal that fires the word-select tip —
|
||
/// lone double-clicks (habitual folders) never tip.
|
||
pub(crate) last_word_select_probe: Option<Instant>,
|
||
/// Persistent status line (e.g. mouse reporting off). Survives transient
|
||
/// toasts, keypress dismissal, and subagent open/close when propagated
|
||
/// via [`Self::set_sticky_toast_recursive`].
|
||
pub(crate) sticky_toast: Option<String>,
|
||
/// Transient "Switched to mode: X" banner shown above the prompt after
|
||
/// Shift+Tab. (message, remaining_ticks). Full brightness for 2 s, then
|
||
/// fades out over the final 0.3 s.
|
||
pub(crate) mode_switch_banner: Option<(String, u8)>,
|
||
/// Fullscreen block viewer. When `Some`, replaces the scrollback area.
|
||
pub(crate) block_viewer: Option<BlockViewerPane>,
|
||
/// Active scrollback search session. When `Some`, vim `/` (or `/find`) is
|
||
/// searching the scrollback. Inert until input wiring opens it.
|
||
pub(crate) scrollback_search: Option<ScrollbackSearchState>,
|
||
/// Hit area for scrollback selection box copy button.
|
||
pub(crate) hit_sb_copy: HitArea,
|
||
/// Hit area for scrollback selection box view button.
|
||
pub(crate) hit_sb_view: HitArea,
|
||
/// Active question view (from `AskUserQuestion` tool). When `Some`, the
|
||
/// prompt area shows a structured question UI and input is modal.
|
||
pub(crate) question_view: Option<QuestionViewState>,
|
||
/// Scrollbar hit area for the question view (set during render).
|
||
pub(crate) hit_question_scrollbar: HitArea,
|
||
/// Hovered question item index (visual highlight only).
|
||
pub(crate) hovered_question_item: Option<usize>,
|
||
/// Whether a scrollbar drag is in progress on the question scrollbar.
|
||
pub(crate) question_scrollbar_dragging: bool,
|
||
/// Last question-view option click: (timestamp, item_index) for double-click detection.
|
||
pub(crate) last_question_click: Option<(Instant, usize)>,
|
||
/// Screen area of the inline prompt (label + textarea) in InputMode.
|
||
/// Used for mouse scroll forwarding — scrolls over this region go to
|
||
/// the textarea instead of the options list.
|
||
pub(crate) inline_prompt_area: Option<Rect>,
|
||
/// Clickable button regions on the question nav bar (key → rect).
|
||
pub(crate) question_nav_buttons: Vec<(char, Rect)>,
|
||
/// Currently hovered question nav button key (for highlight).
|
||
pub(crate) hovered_question_button: Option<char>,
|
||
/// Y-range of the scrollable options area (set during render).
|
||
/// Scroll events outside this range are ignored.
|
||
pub(crate) question_scroll_region: Option<(u16, u16)>,
|
||
/// Whether plan mode is currently active. Set when `enter_plan_mode`
|
||
/// tool completes, cleared when `exit_plan_mode` tool completes.
|
||
/// Controls prompt accent color and shortcut bar hints.
|
||
pub(crate) plan_mode_active: bool,
|
||
/// Optimistic plan-mode state set immediately on Shift+Tab.
|
||
/// Cleared to `None` when `detect_plan_mode_change()` confirms real state.
|
||
/// The cycle logic uses `plan_mode_pending.unwrap_or(plan_mode_active)`
|
||
/// so rapid Shift+Tab presses advance correctly without waiting for ACP.
|
||
pub(crate) plan_mode_pending: Option<bool>,
|
||
/// Session mode to apply once this agent's ACP session exists. Set when
|
||
/// the agent is spawned from the dashboard with `/plan` active (the
|
||
/// session does not exist yet, so the mode can't be sent immediately).
|
||
/// Consumed in the `SessionCreated` / `WorktreeSessionCreated` handlers,
|
||
/// mirroring `AgentSession.deferred_model_switch`.
|
||
pub(crate) deferred_session_mode: Option<kigi_tools::types::SessionMode>,
|
||
pub(crate) pending_extensions_fetch: bool,
|
||
/// Whether this view was last rendered inside the dashboard's session
|
||
/// overlay. Updated every frame by `draw`; read when building the
|
||
/// shortcuts cheatsheet so the overlay-scoped shortcuts
|
||
/// (`When::DashboardOverlay`) are lit in the overlay and dimmed elsewhere.
|
||
pub(crate) in_dashboard_overlay: bool,
|
||
/// MCP server init progress. Set when the shell starts connecting
|
||
/// MCP servers, cleared when `kigi/mcp_initialized` arrives.
|
||
/// Shown in the turn status line while the agent is idle.
|
||
pub(crate) mcp_init_progress: Option<McpInitProgress>,
|
||
/// Last synced ACP command generation. When this differs from
|
||
/// `session.available_commands_generation`, `sync_acp_commands()`
|
||
/// is called on the prompt. Starts at 0 so bootstrap (generation 1)
|
||
/// triggers an initial sync.
|
||
pub(crate) acp_synced_generation: u64,
|
||
/// Hovered permission option index (visual highlight only, like question view).
|
||
pub(crate) hovered_permission_item: Option<usize>,
|
||
pub(crate) last_permission_click: Option<(Instant, usize)>,
|
||
/// Queue of pending permission requests. Only the front request is rendered
|
||
/// and interactive. Subsequent requests wait until the front is resolved.
|
||
/// Matches the TUI's `VecDeque<PermissionRequest>` semantics.
|
||
pub permission_queue: VecDeque<PermissionViewState>,
|
||
/// Monotonic counter for permission request IDs.
|
||
pub next_perm_req_id: usize,
|
||
/// Original prompt text stashed when the permission queue became non-empty.
|
||
/// Restored when the queue drains to empty. This is queue-level state, NOT
|
||
/// per-request — stashing happens on the `empty -> non-empty` transition
|
||
/// and restoring on the `non-empty -> empty` transition.
|
||
pub permission_stashed_prompt: Option<StashedPrompt>,
|
||
/// Active plan approval view (from `exit_plan_mode` ext_method). When `Some`,
|
||
/// the prompt area shows the plan approval overlay and input is modal.
|
||
pub(crate) plan_approval_view: Option<PlanApprovalViewState>,
|
||
pub(crate) latest_inline_plan_content: Option<String>,
|
||
pub(crate) plan_comments: Vec<PlanComment>,
|
||
/// Monotonic counter for casual plan comment IDs.
|
||
pub(crate) plan_next_comment_id: u64,
|
||
/// Line range for the casual comment being composed (1-based).
|
||
pub(crate) casual_commenting_range: Option<std::ops::Range<usize>>,
|
||
/// Comment being edited in casual mode (if any).
|
||
pub(crate) casual_editing_comment_id: Option<u64>,
|
||
/// Prompt text stashed when entering casual commenting — restored
|
||
/// on save/cancel. Mirrors `plan_approval_view.stashed_prompt` so
|
||
/// the casual plan modal can share the same prompt-input UX.
|
||
pub(crate) casual_stashed_prompt: Option<StashedPrompt>,
|
||
/// Non-blocking cancel-turn panel (QA-style, shown when cancelling with running subagents).
|
||
pub(crate) cancel_turn_view: Option<modal::CancelTurnViewState>,
|
||
/// Clickable rects for cancel-turn option rows, populated by
|
||
/// `render_cancel_turn_panel`.
|
||
pub(crate) cancel_turn_buttons: Vec<Rect>,
|
||
/// Per-agent mirror of cancel-subagents preference (`Some(true)` = always
|
||
/// stop, `Some(false)` = always continue). Always choices set this on every
|
||
/// agent and persist to `[ui].cancel_subagents_on_turn_cancel`; when unset,
|
||
/// cancel falls back to that UI/config field, then the prompt panel.
|
||
pub(crate) cancel_subagents_preference: Option<bool>,
|
||
/// What gesture triggered the pending turn-cancel (Ctrl+C / mouse; Esc
|
||
/// only via the cancel-retry path while TurnCancelling — a bare Esc does
|
||
/// not start a cancel).
|
||
/// Set by the key/mouse handler, consumed by `do_cancel_turn` / the
|
||
/// cancel-retry path so `session/cancel` carries `_meta.cancelTrigger`.
|
||
pub(crate) cancel_trigger_hint: Option<crate::app::actions::CancelTrigger>,
|
||
pub(crate) rewind_state: Option<crate::views::rewind::RewindState>,
|
||
pub(crate) rewind_points: Option<Vec<crate::views::rewind::RewindPointInfo>>,
|
||
/// In-place edit of a previous user prompt. See `inline_edit.rs`.
|
||
pub(crate) inline_edit: Option<crate::app::inline_edit::InlineEditState>,
|
||
/// Edited text awaiting its rewind; `dispatch_rewind_success` resubmits it.
|
||
/// Set only when the rewind flow emits `Effect::RewindExecute` while the
|
||
/// inline editor is open (see `stash_inline_resubmit_if_editing`).
|
||
pub(crate) pending_inline_resubmit: Option<String>,
|
||
/// `/jump` picker overlay (pure client-side turn navigation).
|
||
pub(crate) jump_state: Option<crate::views::jump::JumpState>,
|
||
/// Timeline sidebar rail geometry for the current frame (`None` =
|
||
/// hidden). Set by the renderer, consumed by mouse hit-testing.
|
||
pub(crate) timeline_rail: Option<crate::views::timeline::TimelineRail>,
|
||
/// Rail part under the mouse — drives hover styling + the tick
|
||
/// preview popup.
|
||
pub(crate) timeline_hover: Option<crate::views::timeline::TimelineHit>,
|
||
/// Cached tick-hover preview `(turn_idx, text)`. Filled when hover
|
||
/// lands on a tick; borrowed during render so streaming redraws don't
|
||
/// rescan/allocate the prompt every frame.
|
||
pub(crate) timeline_hover_preview: Option<(usize, String)>,
|
||
/// Running agent definition for this session (`kigi/session/info` `agentName`).
|
||
pub session_agent_name: Option<String>,
|
||
/// Map of child session IDs to subagent metadata. Populated on
|
||
/// `SubagentSpawned` notifications, used for permission routing
|
||
/// (which agent owns a session) and provenance display.
|
||
pub subagent_sessions: HashMap<String, SubagentInfo>,
|
||
/// Child subagent views. Keyed by child_session_id.
|
||
/// Created eagerly on SubagentSpawned so updates are tracked from the start.
|
||
pub subagent_views: HashMap<String, Box<AgentView>>,
|
||
/// Currently open subagent view (child_session_id). When Some, the
|
||
/// scrollback area is replaced by the subagent's framed view.
|
||
pub active_subagent: Option<String>,
|
||
/// When true, this AgentView is rendering as a subagent (read-only):
|
||
/// - Prompt is hidden
|
||
/// - Cancel turn / demote to bg shortcuts are disabled
|
||
/// - Shortcuts bar shows subagent-specific hints
|
||
pub is_subagent_view: bool,
|
||
/// Hit area for the [✗] close button in the subagent frame title bar.
|
||
pub hit_subagent_frame_close: HitArea,
|
||
/// Whether the `/share` slash command is available (mirrors
|
||
/// Input flight recorder — rolling buffer of recent key events.
|
||
/// Dumped to file via Esc→d combo for debugging.
|
||
pub(crate) input_log: crate::input_log::InputRingBuffer,
|
||
/// Timestamp of the last Esc press, used for Esc→d combo (dump input log).
|
||
/// Cleared on any non-`d` key press, after 500ms expiry, or once
|
||
/// `try_handle_esc_policy` consumes the Esc. `pub(crate)` for policy tests.
|
||
pub(crate) esc_pressed_at: Option<std::time::Instant>,
|
||
/// First prompt to enqueue once the session finishes loading replay.
|
||
/// Set by `/fork` when a directive is provided; drained in the
|
||
/// `TaskResult::SessionLoaded` arm via `enqueue_prompt_front` so the
|
||
/// directive runs ahead of any prompts the user typed during the
|
||
/// placeholder window.
|
||
pub(crate) pending_first_prompt: Option<String>,
|
||
/// Deferred fork banner to push at the bottom of the scrollback once
|
||
/// the fork session finishes loading (in `TaskResult::SessionLoaded`).
|
||
/// Set by `dispatch_fork_resolved`; stores the parent session id and
|
||
/// worktree flag so the banner can be formatted with the child's
|
||
/// session id (not known until `SessionLoaded`). `None` for non-fork
|
||
/// sessions.
|
||
///
|
||
/// Cleared on all failure paths: `SessionLoadFailed`,
|
||
/// `WorktreeSessionFailed` (non-orphan branch), and
|
||
/// `ForkSessionFailed`.
|
||
pub(crate) pending_fork_banner: Option<PendingForkBanner>,
|
||
/// Entry ID of the "Loading session ..." placeholder block pushed
|
||
/// by `dispatch_load_session_inner`. Cleared by the `SessionLoaded`
|
||
/// handler so the placeholder doesn't linger on screen when the
|
||
/// loaded session has no replay content.
|
||
pub(crate) loading_placeholder_id: Option<EntryId>,
|
||
/// Entry ID of the in-flight manual `/recap` loading block (rendered with
|
||
/// the animated "running" sidebar). Set when `/recap` is dispatched and
|
||
/// taken by the `SessionRecap` handler, which fills the block with the
|
||
/// summary and stops the animation. `None` when no manual recap is
|
||
/// pending (auto recaps never show a loading block).
|
||
pub(crate) pending_recap_entry: Option<EntryId>,
|
||
/// The manually-chosen session title (`/rename` or the dashboard
|
||
/// rename flow), as distinct from the auto-generated
|
||
/// `generated_session_title` below. Set optimistically at dispatch,
|
||
/// persisted by the shell as `Summary.title_is_manual`, and restored
|
||
/// from disk on resume (`TaskResult::SessionTitleFromDisk`). Drives the
|
||
/// prompt-border inline title and wins precedence for the dashboard
|
||
/// modal label and the OSC terminal title. The on-disk write is
|
||
/// best-effort (failure surfaces a toast through the existing
|
||
/// `RenameSessionFailed` arm).
|
||
pub display_name: Option<String>,
|
||
/// Short title from shell `SessionSummaryGenerated` or `summary.json` on load/resume.
|
||
/// Precedence in the dashboard title is below `display_name`, above first-prompt text.
|
||
pub generated_session_title: Option<String>,
|
||
/// Effects queued by input handlers that cannot return `InputOutcome::Action`.
|
||
/// Drained by `AppView.handle_input` after each event.
|
||
pub(crate) pending_effects: Vec<super::actions::Effect>,
|
||
/// In-flight deferred clipboard attachment probes for this prompt. A send
|
||
/// while `> 0` is stashed (see `deferred_send`) so a
|
||
/// paste-then-immediate-send never builds content blocks before the image
|
||
/// attaches.
|
||
pub(crate) paste_probe_in_flight: usize,
|
||
/// A prompt send / interject deferred until the in-flight paste probe(s)
|
||
/// complete. Kind-only: the payload is re-derived from the widget on
|
||
/// reissue so the freshly attached image chip travels with it.
|
||
pub(crate) deferred_send: Option<AgentDeferredSend>,
|
||
/// Armed when an `kigi/session/prompt_complete` broadcast arrives for the
|
||
/// turn THIS client drives while it is still awaiting that turn's
|
||
/// `session/prompt` RPC response. The RPC normally lands milliseconds
|
||
/// later and disarms this; if it never does (lost in leader response
|
||
/// routing / reconnect races), the event loop reconciles turn state from
|
||
/// the broadcast after [`super::dispatch::TURN_END_RECONCILE_GRACE`] so
|
||
/// the pane cannot stay latched in `TurnRunning`/`TurnCancelling` forever
|
||
/// (a lost response would otherwise leave the TUI on "Cancelling…" with
|
||
/// Esc and the input dead until a restart).
|
||
pub(crate) pending_turn_end_reconcile: Option<PendingTurnEnd>,
|
||
/// Send-now cancel expectation: the client-minted id of a cancel-and-send
|
||
/// prompt this client dispatched into a running turn (send-now chord /
|
||
/// queue-row "Send now" / a plain prompt sent into a held blocking wait,
|
||
/// which the shell auto-routes onto send-now). The running turn's imminent
|
||
/// cancel is the silent half of cancel-and-send, so the turn-end rails
|
||
/// suppress the "Turn cancelled by user …" marker.
|
||
///
|
||
/// Compat fallback only: a wire `_meta.cancelTrigger` on the turn end is
|
||
/// trusted over this flag (`"send_now"` suppresses, anything else
|
||
/// renders). Consumed at every driver turn end. Kept across the matching
|
||
/// send-now prompt's turn start (so the outgoing turn's cancel
|
||
/// PromptResponse can still suppress the marker when it races behind the
|
||
/// adopt), but cleared on a non-matching turn start / interactive cancel /
|
||
/// replay-window entry, so a stale expectation can never eat a later real
|
||
/// Ctrl+C marker.
|
||
pub(crate) expect_send_now_cancel: Option<String>,
|
||
/// Send-now promote: skip `scroll_to_entry_top` on next matching adoption.
|
||
/// Survives cancel-rail `take()` of [`Self::expect_send_now_cancel`].
|
||
pub(crate) follow_without_jump_prompt_id: Option<String>,
|
||
/// Ids of THIS client's server-queue rows that are still optimistic
|
||
/// echoes — the `session/prompt` RPC is in flight and no
|
||
/// `kigi/queue/changed` broadcast has confirmed the row yet. Inserted by
|
||
/// the echo push, drained when a broadcast lists the id (queued or
|
||
/// running) or the RPC resolves without the row landing.
|
||
pub(crate) optimistic_queue_ids: std::collections::HashSet<String>,
|
||
/// A queue-row send-now the user fired while the row was still an
|
||
/// optimistic echo. Firing `kigi/queue/interject` then would race the
|
||
/// row's own in-flight `session/prompt` and silently no-op shell-side
|
||
/// (a rapid double-Enter on a queued bash command could "disappear" — the
|
||
/// interject overtook the row, the no-op dropped the send-now, and the
|
||
/// armed cancel expectation hid the still-queued row).
|
||
/// Parked here and fired from the confirming `kigi/queue/changed`
|
||
/// broadcast with the row's authoritative version.
|
||
pub(crate) send_now_awaiting_confirm: Option<String>,
|
||
/// User blocks painted at send-now dispatch, keyed by prompt id; the
|
||
/// turn-start adoption consumes an entry to reuse its block. The flag
|
||
/// marks an edit-interject override (fresher than the mirror text the
|
||
/// adoption captures). Cleared on session reload.
|
||
pub(crate) send_now_painted_blocks:
|
||
std::collections::HashMap<String, (crate::scrollback::EntryId, bool)>,
|
||
/// Follow-up suggestion chips for the latest assistant response
|
||
/// (`kigi/follow_ups`). `None` when no chips are shown. Set by
|
||
/// [`AgentView::apply_follow_ups`]; cleared at each turn start.
|
||
pub(crate) follow_ups: Option<FollowUps>,
|
||
/// `promptId` (turn identity) of the currently-shown `follow_ups`, when the
|
||
/// delivery that displayed them carried one. Tracked separately because
|
||
/// [`FollowUps`] is keyed by `response_id` and does not carry the turn id.
|
||
/// Used by [`AgentView::reset_follow_ups_for_reload_preserving`] to tell
|
||
/// whether the on-screen chips belong to the running turn a reload is about
|
||
/// to adopt — so a reload preserves chips that RENDERED during replay, not
|
||
/// only those still sitting in the pending buffer. `None` when no chips are
|
||
/// shown or the delivery had no stamped `promptId` (legacy/newest-wins).
|
||
pub(crate) follow_up_shown_prompt_id: Option<String>,
|
||
/// Clickable screen rect of each rendered follow-up chip, index-aligned
|
||
/// with the rendered prefix of `follow_ups.suggestions` (chips that do
|
||
/// not fit the row are omitted). Rebuilt every frame by the renderer;
|
||
/// hit-tested by [`AgentView::follow_up_chip_at`].
|
||
pub(crate) follow_up_chips: Vec<Rect>,
|
||
/// Chip under the mouse (hover highlight). Cleared when chips clear or
|
||
/// the pointer leaves the follow-up row.
|
||
pub(crate) hovered_follow_up_chip: Option<usize>,
|
||
/// Assistant `response_id`s the pager has accepted follow-up chips for, in
|
||
/// strictly-increasing acceptance order (`follow_up_next_gen`). Makes
|
||
/// newest-wins correct on EVERY turn-boundary path without depending on a
|
||
/// clear being wired: an id already accepted is, by construction, older than
|
||
/// the current one, so a re-delivered (buffer-replay/duplicate) chunk for it
|
||
/// is rejected, while a never-seen id is strictly newer and supersedes.
|
||
/// Never evicted — eviction is what would let a stale id masquerade as new.
|
||
/// Bounded in practice by the number of follow-up-bearing turns in a
|
||
/// session. See [`AgentView::apply_follow_ups`].
|
||
pub(crate) follow_up_seen: HashMap<String, u64>,
|
||
/// Monotonic generation assigned to the next newly-accepted `response_id`.
|
||
/// The ordering key for newest-wins: a fresh id takes the next value (the
|
||
/// new high-water), so every already-seen id is strictly lower.
|
||
pub(crate) follow_up_next_gen: u64,
|
||
/// Stamped `kigi/follow_ups` that arrived for a turn that is NOT yet the
|
||
/// currently-adopted one, keyed by `promptId`. Ext notifications and
|
||
/// `session/update` travel on separate channels, so a turn's follow_ups can
|
||
/// land BEFORE the `session/update` that adopts it. Rather than drop such a
|
||
/// delivery (chips would never appear if it was the only one), it is buffered
|
||
/// here and flushed by [`AgentView::flush_pending_follow_ups`] when that
|
||
/// `promptId` becomes current. A `promptId` that is already a prior turn and
|
||
/// never becomes current again is never flushed (so stale chips are not
|
||
/// revived); the buffer is FIFO-bounded by [`MAX_PENDING_FOLLOW_UPS`] via
|
||
/// `follow_up_pending_order`.
|
||
pub(crate) follow_up_pending: HashMap<String, FollowUps>,
|
||
/// Insertion order of `follow_up_pending` keys, so an overflow evicts ONLY
|
||
/// the OLDEST buffered entry (never the whole map).
|
||
pub(crate) follow_up_pending_order: VecDeque<String>,
|
||
/// Live `session/update`s buffered for the stashed pending running
|
||
/// adoption: in the FIFO handoff window an instant turn emits its whole
|
||
/// stream before the previous turn's PromptResponse applies the adoption.
|
||
pub(crate) pending_adoption_updates: Vec<(
|
||
String,
|
||
agent_client_protocol::SessionUpdate,
|
||
crate::acp::meta::NotificationMeta,
|
||
)>,
|
||
}
|
||
/// Cap on [`AgentView::self_originated_prompt_ids`]. Only recent ids matter (a
|
||
/// stale post-rewind chunk arrives right after its turn ends), so a small
|
||
/// bounded ring is plenty and keeps a long-lived session from growing the set
|
||
/// without bound.
|
||
const SELF_ORIGINATED_PROMPT_CAP: usize = 64;
|
||
/// Cap on [`AgentView::follow_up_pending`]. Only a handful of turns can ever be
|
||
/// "buffered but not-yet-adopted" at once (the ext/`session/update` race window
|
||
/// is tiny), so a small bounded map is plenty; an overflow evicts the oldest
|
||
/// buffered entry (FIFO) rather than the whole map.
|
||
const MAX_PENDING_FOLLOW_UPS: usize = 16;
|
||
/// Cap on [`AgentView::pending_adoption_updates`]. Overflow drops the NEWEST
|
||
/// entry (unlike the follow-up buffer's oldest-first eviction): a coherent
|
||
/// prefix (user echo + tool-call start) renders sanely, a headless tail would not.
|
||
pub(crate) const MAX_PENDING_ADOPTION_UPDATES: usize = 128;
|
||
/// Outcome of [`AgentView::dashboard_answer_question`] — tells the
|
||
/// dashboard dispatcher whether the whole ask form was submitted (close
|
||
/// the peek), the form advanced to the next question (keep the peek open
|
||
/// but reset its per-question draft), or nothing happened.
|
||
pub(crate) enum PeekAnswerOutcome {
|
||
Submitted,
|
||
Advanced,
|
||
NoOp,
|
||
}
|
||
/// Test-only re-export of [`translate_local_submit`] so dispatch tests
|
||
/// can verify the local-question -> Action mapping without spinning up
|
||
/// a full agent view.
|
||
#[cfg(test)]
|
||
pub(crate) fn translate_local_submit_for_test(
|
||
qv: &crate::views::question_view::QuestionViewState,
|
||
kind: crate::views::question_view::LocalQuestionKind,
|
||
skipped: bool,
|
||
) -> InputOutcome {
|
||
translate_local_submit(qv, kind, skipped)
|
||
}
|
||
/// Translate a local-question submission into an [`InputOutcome`].
|
||
///
|
||
/// Returns `InputOutcome::Action(...)` so the event loop dispatches the
|
||
/// action through the normal channel, mirroring the way ACP-driven
|
||
/// questions complete via `response_tx.send(..)`. Cancel / skip /
|
||
/// invalid-selection paths return `InputOutcome::Changed` and the
|
||
/// directive (if one was supplied) is silently dropped -- matching the
|
||
/// "no UI for cancellation" stance.
|
||
/// Map a worktree-question option index to `(use_worktree, persist_mode)`.
|
||
///
|
||
/// Indices 0-3 correspond to the four options presented in
|
||
/// `open_fork_question` / `open_new_session_question`. Returns `None`
|
||
/// for out-of-range indices.
|
||
fn worktree_choice_from_index(
|
||
idx: usize,
|
||
) -> Option<(bool, Option<crate::app::app_view::WorktreeMode>)> {
|
||
use crate::app::app_view::WorktreeMode;
|
||
match idx {
|
||
0 => Some((true, None)),
|
||
1 => Some((false, None)),
|
||
2 => Some((true, Some(WorktreeMode::Always))),
|
||
3 => Some((false, Some(WorktreeMode::Never))),
|
||
_ => None,
|
||
}
|
||
}
|
||
fn translate_local_submit(
|
||
qv: &crate::views::question_view::QuestionViewState,
|
||
kind: crate::views::question_view::LocalQuestionKind,
|
||
skipped: bool,
|
||
) -> InputOutcome {
|
||
use crate::views::question_view::{LocalQuestionKind, QuestionSelection};
|
||
if let LocalQuestionKind::ProjectSelect {
|
||
resolved_paths,
|
||
original_cwd,
|
||
stashed_prompt,
|
||
dont_ask_index,
|
||
} = kind
|
||
{
|
||
return translate_project_select(
|
||
qv,
|
||
resolved_paths,
|
||
original_cwd,
|
||
stashed_prompt,
|
||
dont_ask_index,
|
||
skipped,
|
||
);
|
||
}
|
||
if skipped {
|
||
return InputOutcome::Changed;
|
||
}
|
||
let Some(QuestionSelection::Single(Some(idx))) = qv.selections.first() else {
|
||
return InputOutcome::Changed;
|
||
};
|
||
match kind {
|
||
LocalQuestionKind::Fork { directive } => {
|
||
let Some((worktree, persist_mode)) = worktree_choice_from_index(*idx) else {
|
||
return InputOutcome::Changed;
|
||
};
|
||
InputOutcome::Action(Action::ForkAnswered {
|
||
worktree,
|
||
directive,
|
||
persist_mode,
|
||
})
|
||
}
|
||
LocalQuestionKind::NewSession => {
|
||
let Some((worktree, persist_mode)) = worktree_choice_from_index(*idx) else {
|
||
return InputOutcome::Changed;
|
||
};
|
||
InputOutcome::Action(Action::NewSessionAnswered {
|
||
worktree,
|
||
persist_mode,
|
||
})
|
||
}
|
||
LocalQuestionKind::AgentTypeMismatch { model_id, effort } => {
|
||
let start_new = *idx == 0;
|
||
InputOutcome::Action(Action::AgentTypeMismatchAnswered {
|
||
start_new,
|
||
model_id: model_id.clone(),
|
||
effort,
|
||
})
|
||
}
|
||
LocalQuestionKind::ProjectSelect { .. } => unreachable!(),
|
||
}
|
||
}
|
||
fn translate_project_select(
|
||
qv: &crate::views::question_view::QuestionViewState,
|
||
resolved_paths: Vec<std::path::PathBuf>,
|
||
original_cwd: std::path::PathBuf,
|
||
stashed_prompt: String,
|
||
dont_ask_index: usize,
|
||
skipped: bool,
|
||
) -> InputOutcome {
|
||
use crate::views::question_view::QuestionSelection;
|
||
|
||
if skipped {
|
||
return InputOutcome::Action(Action::ProjectSelected {
|
||
path: original_cwd,
|
||
stashed_prompt,
|
||
disable_picker: false,
|
||
});
|
||
}
|
||
let freeform_path = qv
|
||
.per_question_freeform_selected
|
||
.first()
|
||
.copied()
|
||
.unwrap_or(false)
|
||
.then(|| qv.per_question_freeform.first())
|
||
.flatten()
|
||
.filter(|s| !s.trim().is_empty())
|
||
.map(|s| {
|
||
let expanded = shellexpand::tilde(s.trim());
|
||
std::path::PathBuf::from(expanded.as_ref())
|
||
});
|
||
if let Some(path) = freeform_path {
|
||
return InputOutcome::Action(Action::ProjectSelected {
|
||
path,
|
||
stashed_prompt,
|
||
disable_picker: false,
|
||
});
|
||
}
|
||
let selected_idx = qv.selections.first().and_then(|s| match s {
|
||
QuestionSelection::Single(Some(idx)) => Some(*idx),
|
||
_ => None,
|
||
});
|
||
if selected_idx == Some(dont_ask_index) {
|
||
return InputOutcome::Action(Action::ProjectSelected {
|
||
path: original_cwd,
|
||
stashed_prompt,
|
||
disable_picker: true,
|
||
});
|
||
}
|
||
let path = selected_idx
|
||
.and_then(|idx| resolved_paths.get(idx).cloned())
|
||
.unwrap_or(original_cwd);
|
||
InputOutcome::Action(Action::ProjectSelected {
|
||
path,
|
||
stashed_prompt,
|
||
disable_picker: false,
|
||
})
|
||
}
|
||
/// Convert an [`OverlayAction`] to an [`InputOutcome`].
|
||
fn overlay_action_to_outcome(action: crate::views::overlay::OverlayAction) -> InputOutcome {
|
||
use crate::views::overlay::OverlayAction;
|
||
match action {
|
||
OverlayAction::Ignored => InputOutcome::Unchanged,
|
||
OverlayAction::Changed => InputOutcome::Changed,
|
||
OverlayAction::FocusScrollback => InputOutcome::Action(Action::FocusScrollback),
|
||
OverlayAction::FocusPrompt => InputOutcome::Action(Action::FocusPrompt),
|
||
}
|
||
}
|
||
/// Render dropdown chrome (borders, count hint) anchored to the prompt and
|
||
/// return the inner items area. Returns `None` when geometry doesn't fit.
|
||
///
|
||
/// `below = false` anchors the panel *above* the prompt (full-TUI default);
|
||
/// `below = true` anchors it *below* the prompt (minimal mode, common CLI
|
||
/// style, to reduce layout shift).
|
||
///
|
||
/// Shared by slash dropdown and completion dropdown to avoid duplicated chrome code.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn render_dropdown_chrome(
|
||
buf: &mut Buffer,
|
||
item_count: usize,
|
||
item_rows: u16,
|
||
inline_prompt_area: Option<Rect>,
|
||
layout_prompt: Rect,
|
||
area: Rect,
|
||
layout_cfg: &crate::appearance::LayoutConfig,
|
||
compact: bool,
|
||
below: bool,
|
||
theme: &Theme,
|
||
) -> Option<DropdownChrome> {
|
||
let mut panel_height = item_rows + 2;
|
||
let (top_border_y, bottom_border_y) = if below {
|
||
let anchor = inline_prompt_area.unwrap_or(layout_prompt);
|
||
let top = anchor.y + anchor.height;
|
||
(top, top + panel_height - 1)
|
||
} else {
|
||
let bottom = if let Some(ipa) = inline_prompt_area {
|
||
ipa.y.saturating_sub(1)
|
||
} else {
|
||
layout_prompt.y.saturating_sub(1)
|
||
};
|
||
let avail = bottom.saturating_sub(area.y).saturating_add(1);
|
||
panel_height = panel_height.min(avail);
|
||
if panel_height < 3 {
|
||
return None;
|
||
}
|
||
(bottom.saturating_sub(panel_height - 1), bottom)
|
||
};
|
||
let embedded = crate::views::modal_window::embedded();
|
||
let (hpad_left, hpad_right) = if embedded {
|
||
(0, 0)
|
||
} else {
|
||
(
|
||
layout_cfg.eff_hpad_left(compact),
|
||
layout_cfg.eff_hpad_right(compact),
|
||
)
|
||
};
|
||
let panel_x = area.x + hpad_left;
|
||
let panel_width = area.width.saturating_sub(hpad_left + hpad_right);
|
||
if top_border_y >= bottom_border_y || panel_width <= 4 {
|
||
return None;
|
||
}
|
||
if below && bottom_border_y > area.y + area.height.saturating_sub(1) {
|
||
return None;
|
||
}
|
||
let panel_area = Rect {
|
||
x: panel_x,
|
||
y: top_border_y,
|
||
width: panel_width,
|
||
height: panel_height,
|
||
};
|
||
if panel_area.bottom() > buf.area.bottom()
|
||
|| panel_area.right() > buf.area.right()
|
||
|| panel_area.y < buf.area.y
|
||
{
|
||
return None;
|
||
}
|
||
ratatui::widgets::Clear.render(panel_area, buf);
|
||
if embedded {
|
||
let reset = ratatui::style::Color::Reset;
|
||
let divider_style = Style::default().fg(theme.gray_dim).bg(reset);
|
||
let divider = Line::styled("\u{2500}".repeat(panel_width as usize), divider_style);
|
||
buf.set_line_safe(panel_x, top_border_y, ÷r, panel_width);
|
||
let footer = "\u{2191}/\u{2193} navigate \u{00b7} enter confirm \u{00b7} esc cancel";
|
||
let footer_line = Line::styled(
|
||
footer.to_string(),
|
||
Style::default().fg(theme.gray_dim).bg(reset),
|
||
);
|
||
buf.set_line_safe(
|
||
panel_x + 1,
|
||
bottom_border_y,
|
||
&footer_line,
|
||
panel_width.saturating_sub(1),
|
||
);
|
||
} else {
|
||
buf.set_style(
|
||
panel_area,
|
||
Style::default().fg(theme.text_primary).bg(theme.bg_light),
|
||
);
|
||
let border_style = Style::default().fg(theme.bg_highlight).bg(theme.bg_base);
|
||
let border_line = Line::styled("\u{2500}".repeat(panel_width as usize), border_style);
|
||
buf.set_line_safe(panel_x, top_border_y, &border_line, panel_width);
|
||
buf.set_line_safe(panel_x, bottom_border_y, &border_line, panel_width);
|
||
let hint = format!("{}", item_count);
|
||
let hint_w = hint.len() as u16;
|
||
if hint_w + 2 <= panel_width {
|
||
let hint_x = panel_x + panel_width - hint_w - 1;
|
||
let hint_line = Line::styled(hint, Style::default().fg(theme.gray).bg(theme.bg_base));
|
||
buf.set_line_safe(hint_x, top_border_y, &hint_line, hint_w);
|
||
}
|
||
}
|
||
let content_inset = dropdown_content_inset(layout_cfg, compact);
|
||
let items_x = layout_prompt.x + content_inset;
|
||
let items_width = layout_prompt.width.saturating_sub(content_inset);
|
||
Some(DropdownChrome {
|
||
items: Rect {
|
||
x: items_x,
|
||
y: top_border_y + 1,
|
||
height: panel_height - 2,
|
||
width: items_width,
|
||
},
|
||
panel: panel_area,
|
||
})
|
||
}
|
||
/// Left inset of dropdown item rows inside the panel (see the comment in
|
||
/// [`render_dropdown_chrome`]).
|
||
fn dropdown_content_inset(layout_cfg: &crate::appearance::LayoutConfig, compact: bool) -> u16 {
|
||
if crate::views::modal_window::embedded() {
|
||
0
|
||
} else {
|
||
1 + layout_cfg.eff_hpad_left(compact)
|
||
}
|
||
}
|
||
/// Width of the dropdown item rows [`render_dropdown_chrome`] will produce for
|
||
/// `layout_prompt` — for sizing the row count *before* drawing the chrome.
|
||
pub(crate) fn dropdown_items_width(
|
||
layout_prompt: Rect,
|
||
layout_cfg: &crate::appearance::LayoutConfig,
|
||
compact: bool,
|
||
) -> u16 {
|
||
layout_prompt
|
||
.width
|
||
.saturating_sub(dropdown_content_inset(layout_cfg, compact))
|
||
}
|
||
/// Geometry returned by [`render_dropdown_chrome`]: the inset `items` area for
|
||
/// rendering rows and the full `panel` rect (borders + padding) used as an
|
||
/// occluder for hyperlink hit-testing.
|
||
pub(crate) struct DropdownChrome {
|
||
pub(crate) items: Rect,
|
||
pub(crate) panel: Rect,
|
||
}
|
||
/// Render a row of 1-char buttons right-aligned, returning their hit-test rects.
|
||
///
|
||
/// Buttons are rendered right-to-left starting from `right_x`. Each button
|
||
/// is 1 cell wide, separated by `gap` cells. The `base_style` is used for
|
||
/// non-hovered buttons; `hover_style` for hovered ones.
|
||
///
|
||
/// Returns one `Rect` per button, in the same order as the input iterator.
|
||
fn render_char_buttons<const N: usize>(
|
||
buf: &mut Buffer,
|
||
right_x: u16,
|
||
y: u16,
|
||
buttons: [(&str, bool); N],
|
||
base_style: Style,
|
||
hover_style: Style,
|
||
gap: u16,
|
||
) -> [Rect; N] {
|
||
let mut areas = [Rect::default(); N];
|
||
let mut x = right_x;
|
||
for i in (0..N).rev() {
|
||
let (sym, hovered) = buttons[i];
|
||
let style = if hovered { hover_style } else { base_style };
|
||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||
cell.set_symbol(sym);
|
||
cell.set_style(style);
|
||
}
|
||
areas[i] = Rect::new(x, y, 1, 1);
|
||
x = x.saturating_sub(1 + gap);
|
||
}
|
||
areas
|
||
}
|
||
/// Whether this key event represents `!` (bang).
|
||
///
|
||
/// Most terminals report `KeyCode::Char('!')` directly. Under the Kitty
|
||
/// keyboard protocol the terminal sends the base key `1` with SHIFT
|
||
/// modifier instead of the produced character (crossterm#968).
|
||
fn is_bang_key(key: &KeyEvent) -> bool {
|
||
key.code == KeyCode::Char('!')
|
||
|| (key.code == KeyCode::Char('1') && key.modifiers.contains(KeyModifiers::SHIFT))
|
||
}
|
||
/// Translate a `SettingsKeyOutcome` into an `InputOutcome`.
|
||
pub(super) fn apply_settings_outcome(
|
||
agent: &mut AgentView,
|
||
outcome: crate::views::settings_modal::SettingsKeyOutcome,
|
||
) -> InputOutcome {
|
||
use crate::views::settings_modal::SettingsKeyOutcome;
|
||
match outcome {
|
||
SettingsKeyOutcome::Close => {
|
||
agent.active_modal = None;
|
||
InputOutcome::Changed
|
||
}
|
||
SettingsKeyOutcome::Action(a) => InputOutcome::Action(a),
|
||
SettingsKeyOutcome::ActionPair(a, b) => InputOutcome::ActionPair(a, b),
|
||
SettingsKeyOutcome::Changed => InputOutcome::Changed,
|
||
SettingsKeyOutcome::Unchanged => InputOutcome::Unchanged,
|
||
}
|
||
}
|
||
/// Whether this key event represents `#` (hash).
|
||
///
|
||
/// Most terminals report `KeyCode::Char('#')` directly. Under the Kitty
|
||
/// keyboard protocol the terminal sends the base key `3` with SHIFT
|
||
/// modifier instead of the produced character (crossterm#968).
|
||
fn is_hash_key(key: &KeyEvent) -> bool {
|
||
key.code == KeyCode::Char('#')
|
||
|| (key.code == KeyCode::Char('3') && key.modifiers.contains(KeyModifiers::SHIFT))
|
||
}
|
||
/// Check `[features] remember_mode` in config.toml. Defaults to `false`.
|
||
fn remember_mode_enabled() -> bool {
|
||
let path = kigi_tools::util::kigi_home::kigi_home().join("config.toml");
|
||
let Some(doc) = crate::config_toml_edit::read_config_document_for_edit(&path) else {
|
||
return false;
|
||
};
|
||
doc.get("features")
|
||
.and_then(|f| f.get("remember_mode"))
|
||
.and_then(|v| v.as_bool())
|
||
.unwrap_or(false)
|
||
}
|
||
/// Mouse reporting toggle chord (Ctrl+R on scrollback), for unified-log diagnostics.
|
||
fn is_mouse_reporting_toggle_chord(key: &KeyEvent) -> bool {
|
||
crate::key!('r', CONTROL).matches(key)
|
||
}
|
||
fn format_key_for_log(key: &KeyEvent) -> serde_json::Value {
|
||
serde_json::json!(
|
||
{ "code" : format!("{:?}", key.code), "modifiers" : format!("{:?}", key
|
||
.modifiers), "kind" : format!("{:?}", key.kind), }
|
||
)
|
||
}
|
||
fn resolve_action(action_id: Option<ActionId>) -> Option<InputOutcome> {
|
||
let action = match action_id? {
|
||
ActionId::SendPrompt => return None,
|
||
ActionId::SelectNext => Action::SelectNext,
|
||
ActionId::SelectPrev => Action::SelectPrev,
|
||
ActionId::NextTurn => Action::NextTurn,
|
||
ActionId::PrevTurn => Action::PrevTurn,
|
||
ActionId::NextResponse => Action::NextResponse,
|
||
ActionId::PrevResponse => Action::PrevResponse,
|
||
ActionId::GotoTop => Action::GotoTop,
|
||
ActionId::GotoBottom => Action::GotoBottom,
|
||
ActionId::ScrollUp => Action::ScrollUp(1),
|
||
ActionId::ScrollDown => Action::ScrollDown(1),
|
||
ActionId::HalfPageUp => Action::HalfPageUp,
|
||
ActionId::HalfPageDown => Action::HalfPageDown,
|
||
ActionId::PageUp => Action::PageUp,
|
||
ActionId::PageDown => Action::PageDown,
|
||
ActionId::Collapse => Action::Collapse,
|
||
ActionId::Expand => Action::Expand,
|
||
ActionId::ToggleFold => Action::ToggleFold,
|
||
ActionId::ToggleExpandAll => Action::ToggleExpandAll,
|
||
ActionId::ExpandAllThinking => Action::ExpandAllThinking,
|
||
ActionId::ToggleRaw => Action::ToggleRaw,
|
||
ActionId::ToggleMouseCapture => Action::ToggleMouseCapture,
|
||
ActionId::CopyBlockContent => Action::CopyBlockContent,
|
||
ActionId::CopyBlockMeta => Action::CopyBlockMeta,
|
||
ActionId::OpenBlockViewer => Action::OpenBlockViewer,
|
||
ActionId::OpenNextLink => Action::OpenNextLink,
|
||
ActionId::OpenPrevLink => Action::OpenPrevLink,
|
||
ActionId::FocusPrompt => Action::FocusPrompt,
|
||
ActionId::FocusScrollback => Action::FocusScrollback,
|
||
ActionId::NextModel => Action::NextModel,
|
||
ActionId::CycleMode => Action::CycleMode,
|
||
ActionId::CancelTurn
|
||
| ActionId::Quit
|
||
| ActionId::ExitSession
|
||
| ActionId::NewSession
|
||
| ActionId::NewSessionInWorktree
|
||
| ActionId::CommandPalette
|
||
| ActionId::ModelPicker => return None,
|
||
ActionId::DumpInputLog => return None,
|
||
ActionId::ToggleYolo => return None,
|
||
ActionId::ToggleMultiline => return None,
|
||
ActionId::InterjectPrompt => return None,
|
||
ActionId::ShortcutsHelp => return None,
|
||
ActionId::OpenSettings => return None,
|
||
ActionId::ToggleTodos
|
||
| ActionId::ToggleTasks
|
||
| ActionId::ToggleQueue
|
||
| ActionId::OpenSessions
|
||
| ActionId::OpenExtensions
|
||
| ActionId::SendToBackground
|
||
| ActionId::BashMode
|
||
| ActionId::Rewind
|
||
| ActionId::KillBgTask
|
||
| ActionId::OpenDashboard
|
||
| ActionId::DashboardSelectNext
|
||
| ActionId::DashboardSelectPrev
|
||
| ActionId::DashboardTogglePin
|
||
| ActionId::DashboardBeginRename
|
||
| ActionId::DashboardStop
|
||
| ActionId::DashboardCycleMode
|
||
| ActionId::DashboardToggleGrouping
|
||
| ActionId::DashboardReorderUp
|
||
| ActionId::DashboardReorderDown
|
||
| ActionId::DashboardShortcutsHelp
|
||
| ActionId::DashboardExit
|
||
| ActionId::DashboardOverlayExit
|
||
| ActionId::DashboardOverlayPrev
|
||
| ActionId::DashboardOverlayNext
|
||
| ActionId::DashboardOverlayStop
|
||
| ActionId::DashboardToggleAutoApprove
|
||
| ActionId::DashboardOpenLocationPicker
|
||
| ActionId::DashboardToggleWorktree => return None,
|
||
};
|
||
Some(InputOutcome::Action(action))
|
||
}
|
||
/// Visible height of the scrollable options area, from the render-computed
|
||
/// scroll region or a fallback estimate (footer=3, sticky freeform=1).
|
||
/// `sticky_freeform_h` is 0 for `no_freeform` questions (no sticky row is
|
||
/// rendered) and 1 otherwise.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn question_visible_h(
|
||
scroll_region: Option<(u16, u16)>,
|
||
prompt_height: u16,
|
||
question: &kigi_tools::implementations::kigi::ask_user_question::Question,
|
||
content_w: usize,
|
||
preview: Option<&str>,
|
||
fullscreen: bool,
|
||
desc_cap: u16,
|
||
preview_cap: u16,
|
||
sticky_freeform_h: u16,
|
||
) -> u16 {
|
||
if let Some((top, bottom)) = scroll_region {
|
||
bottom.saturating_sub(top)
|
||
} else {
|
||
let question_area_h = prompt_height.saturating_sub(3);
|
||
crate::views::question_view::visible_options_height(
|
||
question,
|
||
question_area_h,
|
||
content_w,
|
||
preview,
|
||
fullscreen,
|
||
desc_cap,
|
||
preview_cap,
|
||
)
|
||
.saturating_sub(sticky_freeform_h)
|
||
}
|
||
}
|
||
/// Collect citation URLs from visible WebSearch and WebFetch tool blocks.
|
||
///
|
||
/// Returns `VisibleLink` entries with the entry's rendered content area.
|
||
/// Only visible blocks (from the selection model) are scanned.
|
||
fn collect_citation_links(
|
||
scrollback: &ScrollbackState,
|
||
selection_model: &ResolvedSelectionModel,
|
||
) -> Vec<crate::scrollback::link_map::VisibleLink> {
|
||
use crate::scrollback::block::RenderBlock;
|
||
use crate::scrollback::blocks::tool::ToolCallBlock;
|
||
use crate::scrollback::link_map::VisibleLink;
|
||
use std::sync::Arc;
|
||
let mut links = Vec::new();
|
||
for block_geom in &selection_model.visible_blocks {
|
||
let Some(entry) = scrollback.entry(block_geom.entry_idx) else {
|
||
continue;
|
||
};
|
||
match &entry.block {
|
||
RenderBlock::ToolCall(ToolCallBlock::WebSearch(ws)) => {
|
||
for url in &ws.citations {
|
||
links.push(VisibleLink {
|
||
rects: vec![block_geom.content_area],
|
||
url: Arc::from(url.as_str()),
|
||
id: None,
|
||
});
|
||
}
|
||
}
|
||
RenderBlock::ToolCall(ToolCallBlock::WebFetch(wf)) if !wf.url.is_empty() => {
|
||
links.push(VisibleLink {
|
||
rects: vec![block_geom.content_area],
|
||
url: Arc::from(wf.url.as_str()),
|
||
id: None,
|
||
});
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
links
|
||
}
|
||
/// Shared fixtures for the queue-routing tests here, the queued-prompt
|
||
/// editing tests in `queue_edit.rs`, and the parked-wait tests in
|
||
/// `dispatch/queue.rs` / `acp_handler.rs`.
|
||
#[cfg(test)]
|
||
pub(super) mod test_fixtures {
|
||
use super::{AgentPane, AgentView};
|
||
use crate::acp::model_state::ModelState;
|
||
use crate::actions::ActionRegistry;
|
||
use crate::app::agent::{AgentId, AgentSession, AgentState};
|
||
use crate::app::prompt_queue::QueueEntryWire;
|
||
use crate::scrollback::state::ScrollbackState;
|
||
use agent_client_protocol as acp;
|
||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||
/// Drive the agent's tracker into a task-output wait via the real update
|
||
/// path. `timeout_ms > 0` advertises a blocking (sendable/parked) wait;
|
||
/// `0` is an instant poll that must NOT advertise one.
|
||
pub fn simulate_task_output_wait_ms(agent: &mut AgentView, task_id: &str, timeout_ms: u64) {
|
||
simulate_task_output_wait_call(agent, "wait-1", task_id, timeout_ms);
|
||
}
|
||
/// [`simulate_task_output_wait_ms`] with an explicit tool-call id.
|
||
pub fn simulate_task_output_wait_call(
|
||
agent: &mut AgentView,
|
||
tool_call_id: &str,
|
||
task_id: &str,
|
||
timeout_ms: u64,
|
||
) {
|
||
use crate::acp::meta::NotificationMeta;
|
||
use crate::acp::tracker::{TurnActivity, WaitingReason};
|
||
use std::sync::Arc;
|
||
let meta = NotificationMeta::default();
|
||
agent.session.handle_update(
|
||
acp::SessionUpdate::ToolCall(
|
||
acp::ToolCall::new(
|
||
acp::ToolCallId::new(Arc::from(tool_call_id)),
|
||
"get_command_or_subagent_output",
|
||
)
|
||
.kind(acp::ToolKind::Other)
|
||
.status(acp::ToolCallStatus::Pending)
|
||
.content(vec![])
|
||
.locations(vec![]),
|
||
),
|
||
&meta,
|
||
&mut agent.scrollback,
|
||
);
|
||
agent.session.handle_update(
|
||
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
|
||
acp::ToolCallId::new(Arc::from(tool_call_id)),
|
||
acp::ToolCallUpdateFields::new().raw_input(Some(serde_json::json!(
|
||
{ "task_ids" : [task_id], "timeout_ms" : timeout_ms, }
|
||
))),
|
||
)),
|
||
&meta,
|
||
&mut agent.scrollback,
|
||
);
|
||
let activity = agent.resolve_turn_activity();
|
||
if timeout_ms > 0 {
|
||
assert!(
|
||
matches!(
|
||
activity,
|
||
Some(TurnActivity::Waiting(WaitingReason::TaskOutput {
|
||
waits: true,
|
||
..
|
||
}))
|
||
),
|
||
"expected TaskOutput wait, got {activity:?}"
|
||
);
|
||
} else {
|
||
assert!(
|
||
!matches!(
|
||
activity,
|
||
Some(TurnActivity::Waiting(WaitingReason::TaskOutput { .. }))
|
||
),
|
||
"poll must not advertise a task-output wait, got {activity:?}"
|
||
);
|
||
}
|
||
}
|
||
/// Complete a wait tool call registered by
|
||
/// [`simulate_task_output_wait_call`], releasing its blocking-wait entry.
|
||
pub fn complete_task_output_wait_call(agent: &mut AgentView, tool_call_id: &str) {
|
||
use crate::acp::meta::NotificationMeta;
|
||
use std::sync::Arc;
|
||
let meta = NotificationMeta::default();
|
||
agent.session.handle_update(
|
||
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
|
||
acp::ToolCallId::new(Arc::from(tool_call_id)),
|
||
acp::ToolCallUpdateFields::new().status(Some(acp::ToolCallStatus::Completed)),
|
||
)),
|
||
&meta,
|
||
&mut agent.scrollback,
|
||
);
|
||
}
|
||
/// Blocking-wait shorthand for [`simulate_task_output_wait_ms`].
|
||
pub fn simulate_task_output_wait(agent: &mut AgentView, task_id: &str) {
|
||
simulate_task_output_wait_ms(agent, task_id, 30_000);
|
||
}
|
||
/// Drive the agent's tracker into a wait-all
|
||
/// (`WaitingReason::TasksComplete`) blocking wait via the real update
|
||
/// path; the tracker classifies on the title alone.
|
||
pub fn simulate_wait_all(agent: &mut AgentView) {
|
||
use crate::acp::meta::NotificationMeta;
|
||
use crate::acp::tracker::{TurnActivity, WaitingReason};
|
||
use std::sync::Arc;
|
||
let meta = NotificationMeta::default();
|
||
agent.session.handle_update(
|
||
acp::SessionUpdate::ToolCall(
|
||
acp::ToolCall::new(
|
||
acp::ToolCallId::new(Arc::from("waitall-1")),
|
||
"wait_commands_or_subagents",
|
||
)
|
||
.kind(acp::ToolKind::Other)
|
||
.status(acp::ToolCallStatus::Pending)
|
||
.content(vec![])
|
||
.locations(vec![]),
|
||
),
|
||
&meta,
|
||
&mut agent.scrollback,
|
||
);
|
||
let activity = agent.resolve_turn_activity();
|
||
assert!(
|
||
matches!(
|
||
activity,
|
||
Some(TurnActivity::Waiting(WaitingReason::TasksComplete))
|
||
),
|
||
"expected TasksComplete wait, got {activity:?}"
|
||
);
|
||
}
|
||
/// Drive the agent's tracker into a foreground-subagent wait via the real
|
||
/// update path: a pending `task` tool call (no `run_in_background`)
|
||
/// registers a blocking [`WaitingReason::Subagent`]
|
||
/// (`crate::acp::tracker`). The shell aborts that await the moment the
|
||
/// user sends (send-now), so it must read as a sendable/parked wait.
|
||
pub fn simulate_subagent_wait(agent: &mut AgentView) {
|
||
use crate::acp::meta::NotificationMeta;
|
||
use crate::acp::tracker::{TurnActivity, WaitingReason};
|
||
use std::sync::Arc;
|
||
let meta = NotificationMeta::default();
|
||
agent.session.handle_update(
|
||
acp::SessionUpdate::ToolCall(
|
||
acp::ToolCall::new(acp::ToolCallId::new(Arc::from("task-tc-1")), "task")
|
||
.kind(acp::ToolKind::Other)
|
||
.status(acp::ToolCallStatus::Pending)
|
||
.content(vec![])
|
||
.locations(vec![]),
|
||
),
|
||
&meta,
|
||
&mut agent.scrollback,
|
||
);
|
||
let activity = agent.resolve_turn_activity();
|
||
assert!(
|
||
matches!(
|
||
activity,
|
||
Some(TurnActivity::Waiting(WaitingReason::Subagent))
|
||
),
|
||
"expected Subagent wait, got {activity:?}"
|
||
);
|
||
}
|
||
/// A minimal running (foreground) subagent registry row, so tests can
|
||
/// count it in `current_end_work` / park-marker snapshots.
|
||
pub fn running_subagent_info(child_sid: &str) -> crate::app::subagent::SubagentInfo {
|
||
use std::sync::Arc;
|
||
use std::time::Instant;
|
||
crate::app::subagent::SubagentInfo {
|
||
subagent_id: Arc::from(format!("sa-{child_sid}")),
|
||
child_session_id: Arc::from(child_sid),
|
||
description: Arc::from("test"),
|
||
subagent_type: Arc::from("general-purpose"),
|
||
persona: None,
|
||
role: None,
|
||
model: None,
|
||
context_source: None,
|
||
resumed_from: None,
|
||
capability_mode: None,
|
||
context_normalized: false,
|
||
parent_prompt_id: None,
|
||
started_at: Instant::now(),
|
||
last_progress_at: Instant::now(),
|
||
finished: false,
|
||
status: None,
|
||
error: None,
|
||
duration_ms: None,
|
||
tool_calls: None,
|
||
turns: None,
|
||
turn_count: None,
|
||
tool_call_count: None,
|
||
tokens_used: None,
|
||
context_window_tokens: None,
|
||
context_usage_pct: None,
|
||
tools_used: Vec::new(),
|
||
error_count: None,
|
||
activity_label: None,
|
||
is_background: false,
|
||
pending_kill: false,
|
||
kill_requested_at: None,
|
||
scrollback_entry_id: None,
|
||
prompt: None,
|
||
child_cwd: None,
|
||
worktree_path: None,
|
||
child_updates_replayed: false,
|
||
}
|
||
}
|
||
/// Count of parked ("Worked for … still running") marker blocks in
|
||
/// the agent's scrollback.
|
||
pub fn count_parked(agent: &AgentView) -> usize {
|
||
use crate::scrollback::block::RenderBlock;
|
||
(0..agent.scrollback.len())
|
||
.filter(|i| {
|
||
matches!(
|
||
agent.scrollback.get(* i).map(| e | & e.block),
|
||
Some(RenderBlock::SessionEvent(b)) if b.parked
|
||
)
|
||
})
|
||
.count()
|
||
}
|
||
pub fn make_running_agent() -> AgentView {
|
||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||
let mut session = AgentSession {
|
||
id: AgentId(0),
|
||
acp_tx: tx,
|
||
session_id: Some(acp::SessionId::new("test-session")),
|
||
models: ModelState::default(),
|
||
state: AgentState::TurnRunning,
|
||
tracker: crate::acp::tracker::AcpUpdateTracker::new(),
|
||
cwd: std::path::PathBuf::from("/tmp"),
|
||
is_worktree: false,
|
||
forked_from: None,
|
||
pending_prompts: std::collections::VecDeque::new(),
|
||
next_queue_id: 0,
|
||
yolo_mode: false,
|
||
auto_mode: false,
|
||
prompt_history: Vec::new(),
|
||
prompt_history_loading: false,
|
||
loading_replay: false,
|
||
restore_degree: None,
|
||
rate_limited: false,
|
||
model_incompatible: false,
|
||
available_commands: Vec::new(),
|
||
available_commands_generation: 0,
|
||
available_tools: None,
|
||
model_switch_pending: false,
|
||
user_model_preference: None,
|
||
deferred_model_switch: None,
|
||
bg_tasks: std::collections::BTreeMap::new(),
|
||
bg_tool_call_to_task: std::collections::HashMap::new(),
|
||
scheduled_tasks: std::collections::HashMap::new(),
|
||
in_flight_prompt: None,
|
||
current_prompt_id: None,
|
||
created_via_new: false,
|
||
};
|
||
session.enqueue_prompt("local one".to_string());
|
||
let mut agent = AgentView::new(session, ScrollbackState::new());
|
||
agent.shared_queue = vec![QueueEntryWire {
|
||
id: "p1".into(),
|
||
version: 2,
|
||
owner: None,
|
||
last_editor: None,
|
||
kind: "prompt".into(),
|
||
text: "server one".into(),
|
||
position: 0,
|
||
}];
|
||
agent.queue.sync_from_merged(
|
||
&agent.session.pending_prompts,
|
||
&agent.shared_queue,
|
||
agent.session.current_prompt_id.as_deref(),
|
||
agent.expect_send_now_cancel.as_deref(),
|
||
&agent.send_now_painted_blocks,
|
||
);
|
||
agent.queue.overlay.visible = true;
|
||
agent.queue.overlay.focused = true;
|
||
agent
|
||
}
|
||
/// Minimal idle agent (no queue, no session id) shared by input tests.
|
||
pub fn make_agent() -> AgentView {
|
||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||
AgentView::new(
|
||
AgentSession {
|
||
id: AgentId(0),
|
||
acp_tx: tx,
|
||
session_id: None,
|
||
models: ModelState::default(),
|
||
state: AgentState::Idle,
|
||
tracker: crate::acp::tracker::AcpUpdateTracker::new(),
|
||
cwd: std::path::PathBuf::from("/tmp"),
|
||
is_worktree: false,
|
||
forked_from: None,
|
||
pending_prompts: std::collections::VecDeque::new(),
|
||
next_queue_id: 0,
|
||
yolo_mode: false,
|
||
auto_mode: false,
|
||
prompt_history: Vec::new(),
|
||
prompt_history_loading: false,
|
||
loading_replay: false,
|
||
restore_degree: None,
|
||
rate_limited: false,
|
||
model_incompatible: false,
|
||
available_commands: Vec::new(),
|
||
available_commands_generation: 0,
|
||
available_tools: None,
|
||
model_switch_pending: false,
|
||
user_model_preference: None,
|
||
deferred_model_switch: None,
|
||
bg_tasks: std::collections::BTreeMap::new(),
|
||
bg_tool_call_to_task: std::collections::HashMap::new(),
|
||
scheduled_tasks: std::collections::HashMap::new(),
|
||
in_flight_prompt: None,
|
||
current_prompt_id: None,
|
||
created_via_new: false,
|
||
},
|
||
ScrollbackState::new(),
|
||
)
|
||
}
|
||
/// Interject chord for non–VS Code family tests (`Ctrl+Enter`).
|
||
pub fn force_interject_key() -> KeyEvent {
|
||
KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL)
|
||
}
|
||
/// Interject chord for VS Code family tests (`Ctrl+L`).
|
||
pub fn vscode_interject_key() -> KeyEvent {
|
||
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)
|
||
}
|
||
/// Host-independent registry for queue/prompt interject tests (Ctrl+Enter).
|
||
pub fn non_vscode_registry() -> ActionRegistry {
|
||
ActionRegistry::non_vscode_for_test()
|
||
}
|
||
/// Host-independent VS family registry (Ctrl+L interject, OpenExtensions Null).
|
||
pub fn vscode_family_registry() -> ActionRegistry {
|
||
ActionRegistry::vscode_family_for_test()
|
||
}
|
||
#[test]
|
||
fn apply_follow_ups_renders_chips_for_a_response() {
|
||
let mut agent = make_agent();
|
||
let changed =
|
||
agent.apply_follow_ups("resp-1".into(), vec!["Tell me more".into(), "Sum".into()]);
|
||
assert!(changed, "first chips for a response warrant a redraw");
|
||
let fu = agent.follow_ups.as_ref().expect("chips must be set");
|
||
assert_eq!(fu.response_id, "resp-1");
|
||
assert_eq!(fu.suggestions, vec!["Tell me more", "Sum"]);
|
||
}
|
||
#[test]
|
||
fn apply_follow_ups_newer_response_supersedes() {
|
||
let mut agent = make_agent();
|
||
agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
|
||
let changed = agent.apply_follow_ups("resp-2".into(), vec!["b".into()]);
|
||
assert!(changed, "a newer response must take over");
|
||
let fu = agent.follow_ups.as_ref().unwrap();
|
||
assert_eq!(fu.response_id, "resp-2");
|
||
assert_eq!(fu.suggestions, vec!["b"]);
|
||
}
|
||
#[test]
|
||
fn apply_follow_ups_ignores_superseded_redelivery() {
|
||
let mut agent = make_agent();
|
||
agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
|
||
agent.apply_follow_ups("resp-2".into(), vec!["b".into()]);
|
||
let changed = agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
|
||
assert!(!changed, "a superseded response's re-delivery is ignored");
|
||
let fu = agent.follow_ups.as_ref().unwrap();
|
||
assert_eq!(fu.response_id, "resp-2");
|
||
assert_eq!(fu.suggestions, vec!["b"]);
|
||
}
|
||
#[test]
|
||
fn apply_follow_ups_same_response_is_idempotent() {
|
||
let mut agent = make_agent();
|
||
agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
|
||
let changed = agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
|
||
assert!(!changed);
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().suggestions, vec!["a"]);
|
||
}
|
||
#[test]
|
||
fn apply_follow_ups_empty_clears_current_response_chips() {
|
||
let mut agent = make_agent();
|
||
agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
|
||
let changed = agent.apply_follow_ups("resp-1".into(), Vec::new());
|
||
assert!(
|
||
changed,
|
||
"retracting the shown response's chips warrants redraw"
|
||
);
|
||
assert!(agent.follow_ups.is_none());
|
||
}
|
||
#[test]
|
||
fn apply_follow_ups_empty_for_different_response_supersedes_and_clears() {
|
||
let mut agent = make_agent();
|
||
agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
|
||
let changed = agent.apply_follow_ups("resp-2".into(), Vec::new());
|
||
assert!(changed);
|
||
assert!(agent.follow_ups.is_none());
|
||
}
|
||
#[test]
|
||
fn apply_follow_ups_empty_for_unseen_id_does_not_poison_ring() {
|
||
let mut agent = make_agent();
|
||
assert!(!agent.apply_follow_ups("resp-1".into(), Vec::new()));
|
||
assert!(agent.follow_ups.is_none());
|
||
assert!(agent.apply_follow_ups("resp-1".into(), vec!["a".into()]));
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().suggestions, vec!["a"]);
|
||
let mut agent = make_agent();
|
||
agent.apply_follow_ups("shown".into(), vec!["x".into()]);
|
||
agent.apply_follow_ups("resp-2".into(), Vec::new());
|
||
let changed = agent.apply_follow_ups("resp-2".into(), vec!["b".into()]);
|
||
assert!(
|
||
changed,
|
||
"non-empty for the previously-empty resp-2 must render"
|
||
);
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().suggestions, vec!["b"]);
|
||
}
|
||
#[test]
|
||
fn apply_follow_ups_empty_retract_allows_same_id_redelivery() {
|
||
let mut agent = make_agent();
|
||
assert!(agent.apply_follow_ups("resp-1".into(), vec!["a".into()]));
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().suggestions, vec!["a"]);
|
||
assert!(agent.apply_follow_ups("resp-1".into(), Vec::new()));
|
||
assert!(agent.follow_ups.is_none());
|
||
assert!(
|
||
!agent.follow_up_seen.contains_key("resp-1"),
|
||
"an empty retraction of the shown chips must drop the id from the seen-ring"
|
||
);
|
||
let changed = agent.apply_follow_ups("resp-1".into(), vec!["b".into()]);
|
||
assert!(
|
||
changed,
|
||
"non-empty re-delivery for a retracted id must render"
|
||
);
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().suggestions, vec!["b"]);
|
||
assert!(agent.apply_follow_ups("resp-2".into(), vec!["c".into()]));
|
||
let changed_old = agent.apply_follow_ups("resp-1".into(), vec!["b".into()]);
|
||
assert!(!changed_old, "a superseded older id stays rejected");
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-2");
|
||
}
|
||
#[test]
|
||
fn clear_then_superseded_redelivery_is_ignored() {
|
||
let mut agent = make_agent();
|
||
agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
|
||
agent.clear_follow_ups();
|
||
let changed = agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
|
||
assert!(!changed, "a cleared response's re-delivery is ignored");
|
||
assert!(agent.follow_ups.is_none());
|
||
}
|
||
#[test]
|
||
fn apply_follow_ups_old_response_rejected_after_many_newer() {
|
||
let mut agent = make_agent();
|
||
agent.apply_follow_ups("resp-0".into(), vec!["first".into()]);
|
||
for i in 1..=200 {
|
||
agent.apply_follow_ups(format!("resp-{i}"), vec![format!("s{i}")]);
|
||
}
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-200");
|
||
let changed = agent.apply_follow_ups("resp-0".into(), vec!["first".into()]);
|
||
assert!(!changed, "a long-superseded response stays rejected");
|
||
let fu = agent.follow_ups.as_ref().unwrap();
|
||
assert_eq!(fu.response_id, "resp-200");
|
||
assert_eq!(fu.suggestions, vec!["s200"]);
|
||
}
|
||
#[test]
|
||
fn apply_follow_ups_generation_is_monotonic() {
|
||
let mut agent = make_agent();
|
||
agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
|
||
assert_eq!(agent.follow_up_seen.get("resp-1"), Some(&0));
|
||
assert_eq!(agent.follow_up_next_gen, 1);
|
||
agent.apply_follow_ups("resp-1".into(), vec!["a2".into()]);
|
||
assert_eq!(agent.follow_up_next_gen, 1);
|
||
agent.apply_follow_ups("resp-2".into(), vec!["b".into()]);
|
||
assert_eq!(agent.follow_up_seen.get("resp-2"), Some(&1));
|
||
assert_eq!(agent.follow_up_next_gen, 2);
|
||
assert!(!agent.apply_follow_ups("resp-1".into(), vec!["a".into()]));
|
||
assert_eq!(agent.follow_up_next_gen, 2);
|
||
}
|
||
#[test]
|
||
fn reconnect_reload_finalize_clears_follow_up_chips() {
|
||
let mut agent = make_agent();
|
||
assert!(agent.apply_follow_ups("resp-1".into(), vec!["a".into(), "b".into()]));
|
||
assert!(agent.follow_ups.is_some(), "chips shown before the reload");
|
||
assert!(agent.follow_up_seen.contains_key("resp-1"));
|
||
assert!(agent.follow_up_next_gen > 0);
|
||
agent.session.prompt_history_loading = true;
|
||
agent.begin_session_reload(1);
|
||
assert!(agent.finish_session_reload(1, true));
|
||
assert!(!agent.session.prompt_history_loading);
|
||
assert!(
|
||
agent.follow_ups.is_none(),
|
||
"reconnect reload must clear the shown chips"
|
||
);
|
||
assert!(agent.follow_up_chips.is_empty());
|
||
assert!(
|
||
agent.follow_up_seen.is_empty(),
|
||
"the seen map must clear so chips streamed after the reload are not suppressed"
|
||
);
|
||
assert_eq!(
|
||
agent.follow_up_next_gen, 0,
|
||
"the acceptance generation resets with the reload"
|
||
);
|
||
}
|
||
/// A model switch stuck across a reconnect must not jam the drain, but a
|
||
/// switch started DURING the reload window must keep its model-switch hold. The
|
||
/// reload START (`begin_session_reload`) releases the hold (the disconnect
|
||
/// dropped the in-flight RPC); finalize (`apply_reload_outcome`) must NOT —
|
||
/// a window switch is live on the reconnected link.
|
||
#[test]
|
||
fn reconnect_reload_clears_stuck_model_switch_pending() {
|
||
for success in [false, true] {
|
||
let mut agent = make_agent();
|
||
agent.session.model_switch_pending = true;
|
||
agent.begin_session_reload(1);
|
||
assert!(
|
||
!agent.session.model_switch_pending,
|
||
"reload start must release the hold for the lost pre-outage switch"
|
||
);
|
||
agent.session.model_switch_pending = true;
|
||
assert!(agent.finish_session_reload(1, success));
|
||
assert!(
|
||
agent.session.model_switch_pending,
|
||
"finalize (success={success}) must NOT clear a switch started \
|
||
during the reload window"
|
||
);
|
||
}
|
||
}
|
||
#[test]
|
||
fn reconnect_reload_failure_also_clears_follow_up_chips() {
|
||
let mut agent = make_agent();
|
||
assert!(agent.apply_follow_ups("resp-1".into(), vec!["a".into()]));
|
||
assert!(agent.follow_ups.is_some());
|
||
agent.session.prompt_history_loading = true;
|
||
agent.begin_session_reload(1);
|
||
assert!(agent.finish_session_reload(1, false));
|
||
assert!(!agent.session.prompt_history_loading);
|
||
assert!(
|
||
agent.follow_ups.is_none(),
|
||
"a failed reconnect reload must still clear stale chips"
|
||
);
|
||
assert!(agent.follow_up_seen.is_empty());
|
||
assert_eq!(agent.follow_up_next_gen, 0);
|
||
}
|
||
/// Drives the production `finalize_reload_and_maybe_adopt` that the
|
||
/// `event_loop.rs` reconnect loop also calls (so a future reorder of the
|
||
/// finalize-before-adopt gate fails here). A synthetic non-scheduler running
|
||
/// id leaves the agent `Idle` (reload still finalized), while a `/loop` or
|
||
/// user id IS adopted.
|
||
#[test]
|
||
fn reconnect_reload_adopts_only_for_prompt_with_completion_exit() {
|
||
let mut synthetic = make_agent();
|
||
synthetic.begin_session_reload(1);
|
||
assert!(
|
||
synthetic.finalize_reload_and_maybe_adopt(
|
||
1,
|
||
true,
|
||
Some("task-completed-abc-123".into())
|
||
),
|
||
"the reload must finalize even when adoption is skipped"
|
||
);
|
||
assert!(synthetic.session_reload.is_none());
|
||
assert!(synthetic.session.current_prompt_id.is_none());
|
||
assert!(
|
||
synthetic.session.state.is_idle(),
|
||
"a synthetic non-scheduler running id must not strand the viewer in TurnRunning"
|
||
);
|
||
let mut cron = make_agent();
|
||
cron.begin_session_reload(1);
|
||
assert!(cron.finalize_reload_and_maybe_adopt(
|
||
1,
|
||
true,
|
||
Some("scheduler-fired-019e51a3-abcd-1234".into()),
|
||
));
|
||
assert_eq!(
|
||
cron.session.current_prompt_id.as_deref(),
|
||
Some("scheduler-fired-019e51a3-abcd-1234"),
|
||
"a /loop fire has a prompt_complete exit, so it is adopted on reconnect"
|
||
);
|
||
assert!(cron.session.state.is_turn_running());
|
||
let mut user = make_agent();
|
||
user.begin_session_reload(1);
|
||
assert!(user.finalize_reload_and_maybe_adopt(1, true, Some("p-user".into())));
|
||
assert_eq!(user.session.current_prompt_id.as_deref(), Some("p-user"));
|
||
assert!(user.session.state.is_turn_running());
|
||
}
|
||
/// Resolving a reload window purges iff a heavy transient dropped: the
|
||
/// stash (success + full replay), or the staged partial replay (failure /
|
||
/// abort / supersede). The common cursor-resolve outcome reuses the stash
|
||
/// — nothing multi-MB drops — and must NOT purge. The counter is
|
||
/// thread-local, so parallel tests cannot interfere with the deltas.
|
||
#[test]
|
||
fn reload_finalize_and_abort_release_retained_memory() {
|
||
use crate::memory_release::test_support;
|
||
test_support::install_counting_hook();
|
||
let mut agent = make_agent();
|
||
agent.begin_session_reload(1);
|
||
agent.mark_reload_replay_seen();
|
||
let before = test_support::calls();
|
||
assert!(agent.finish_session_reload(1, true));
|
||
assert_eq!(
|
||
test_support::calls(),
|
||
before + 1,
|
||
"full-replay finalize must purge after the reload stash drops"
|
||
);
|
||
let mut cursor = make_agent();
|
||
cursor.begin_session_reload(1);
|
||
let before = test_support::calls();
|
||
assert!(cursor.finish_session_reload(1, true));
|
||
assert_eq!(
|
||
test_support::calls(),
|
||
before,
|
||
"cursor-resolve finalize drops nothing heavy and must not purge"
|
||
);
|
||
let mut stale = make_agent();
|
||
stale.begin_session_reload(2);
|
||
let before = test_support::calls();
|
||
assert!(!stale.finish_session_reload(1, true));
|
||
assert_eq!(
|
||
test_support::calls(),
|
||
before,
|
||
"a superseded finalize drops nothing and must not purge"
|
||
);
|
||
let before = test_support::calls();
|
||
stale.begin_session_reload(3);
|
||
assert_eq!(
|
||
test_support::calls(),
|
||
before + 1,
|
||
"supersede must purge the discarded prior staging"
|
||
);
|
||
let before = test_support::calls();
|
||
stale.abort_session_reload();
|
||
assert_eq!(
|
||
test_support::calls(),
|
||
before + 1,
|
||
"abort must purge after the discarded staging drops"
|
||
);
|
||
let before = test_support::calls();
|
||
stale.abort_session_reload();
|
||
assert_eq!(
|
||
test_support::calls(),
|
||
before,
|
||
"abort without an open window must not purge"
|
||
);
|
||
}
|
||
#[test]
|
||
fn apply_follow_ups_empty_clears_hit_areas() {
|
||
let mut agent = make_agent();
|
||
agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
|
||
agent.follow_up_chips = vec![ratatui::layout::Rect::new(0, 0, 5, 1)];
|
||
agent.apply_follow_ups("resp-1".into(), Vec::new());
|
||
assert!(agent.follow_ups.is_none());
|
||
assert!(
|
||
agent.follow_up_chips.is_empty(),
|
||
"hit areas must be cleared"
|
||
);
|
||
}
|
||
#[test]
|
||
fn clear_follow_ups_drops_chips_and_hit_areas() {
|
||
let mut agent = make_agent();
|
||
agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
|
||
agent.follow_up_chips = vec![ratatui::layout::Rect::new(0, 0, 5, 1)];
|
||
agent.clear_follow_ups();
|
||
assert!(agent.follow_ups.is_none());
|
||
assert!(agent.follow_up_chips.is_empty());
|
||
}
|
||
/// FIX 4 (a): a re-delivery of the CURRENTLY-ADOPTED turn's follow_ups
|
||
/// re-renders even after its chips were cleared by turn adoption — the
|
||
/// stamped `promptId` matches the active `current_prompt_id`, so the
|
||
/// (already-seen) response is re-rendered rather than rejected.
|
||
#[test]
|
||
fn apply_follow_ups_current_turn_redelivery_rerenders_after_clear() {
|
||
let mut agent = make_agent();
|
||
agent.session.current_prompt_id = Some("p1".into());
|
||
assert!(agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p1"), vec!["a".into()]));
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-1");
|
||
agent.clear_follow_ups();
|
||
assert!(agent.follow_ups.is_none());
|
||
assert!(
|
||
agent.follow_up_seen.contains_key("resp-1"),
|
||
"adoption keeps the seen ring (no un-record)"
|
||
);
|
||
let changed =
|
||
agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p1"), vec!["a".into()]);
|
||
assert!(
|
||
changed,
|
||
"re-delivery of the adopted turn's follow_ups must re-render"
|
||
);
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().suggestions, vec!["a"]);
|
||
}
|
||
/// FIX 4 (b): after adopting a NEW turn, a buffer-replayed `kigi/follow_ups`
|
||
/// for a PRIOR turn's response_id must NOT revive stale chips — its
|
||
/// `promptId` is not the active turn and it is already in the seen ring.
|
||
#[test]
|
||
fn apply_follow_ups_prior_turn_replay_does_not_revive() {
|
||
let mut agent = make_agent();
|
||
agent.session.current_prompt_id = Some("p1".into());
|
||
assert!(agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p1"), vec!["a".into()]));
|
||
agent.session.current_prompt_id = Some("p2".into());
|
||
agent.clear_follow_ups();
|
||
assert!(agent.follow_ups.is_none());
|
||
let changed =
|
||
agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p1"), vec!["a".into()]);
|
||
assert!(
|
||
!changed,
|
||
"a prior turn's replay must not revive stale chips"
|
||
);
|
||
assert!(agent.follow_ups.is_none(), "no stale chips were revived");
|
||
assert!(agent.apply_follow_ups_with_prompt("resp-2".into(), Some("p2"), vec!["b".into()]));
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-2");
|
||
}
|
||
/// FINDING B (stamped path): a LATE FIRST-TIME (never-seen) `kigi/follow_ups`
|
||
/// for a PRIOR turn — arriving while a newer turn is active — must NOT
|
||
/// render. Before the fix it slipped through the "strictly newer" branch
|
||
/// (never recorded in `follow_up_seen`, so the seen-reject didn't catch it).
|
||
#[test]
|
||
fn apply_follow_ups_late_prior_turn_first_time_rejected() {
|
||
let mut agent = make_agent();
|
||
agent.session.current_prompt_id = Some("p2".into());
|
||
assert!(agent.apply_follow_ups_with_prompt("resp-2".into(), Some("p2"), vec!["b".into()]));
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-2");
|
||
let changed =
|
||
agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p1"), vec!["a".into()]);
|
||
assert!(
|
||
!changed,
|
||
"a never-seen prior-turn follow_ups must not render over the active turn"
|
||
);
|
||
assert_eq!(
|
||
agent.follow_ups.as_ref().unwrap().response_id,
|
||
"resp-2",
|
||
"the active turn's chips must remain untouched"
|
||
);
|
||
assert!(
|
||
!agent.follow_up_seen.contains_key("resp-1"),
|
||
"a rejected non-current first-time arrival must not poison the seen ring"
|
||
);
|
||
}
|
||
/// Regression guard for the FINDING B fix: a first-time follow_ups for the
|
||
/// CURRENTLY-ADOPTED turn (promptId == current) still renders.
|
||
#[test]
|
||
fn apply_follow_ups_current_turn_first_time_renders() {
|
||
let mut agent = make_agent();
|
||
agent.session.current_prompt_id = Some("p1".into());
|
||
assert!(
|
||
agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p1"), vec!["a".into()]),
|
||
"the active turn's first follow_ups must render"
|
||
);
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-1");
|
||
}
|
||
/// Regression guard: trailing follow_ups that arrive AFTER the turn finished
|
||
/// (`current_prompt_id` cleared to None) are NOT treated as a mismatch and
|
||
/// still render — `None` current is not "another active turn".
|
||
#[test]
|
||
fn apply_follow_ups_trailing_after_turn_complete_still_renders() {
|
||
let mut agent = make_agent();
|
||
agent.session.current_prompt_id = None;
|
||
assert!(
|
||
agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p1"), vec!["a".into()]),
|
||
"a stamped follow_ups with no active turn must still render (turn just completed)"
|
||
);
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-1");
|
||
}
|
||
/// None-fallback (older shells / no promptId): with no turn identity on the
|
||
/// notification AND a newer turn active, a late first-time arrival cannot be
|
||
/// distinguished from the new turn's first follow_ups, so it follows the
|
||
/// legacy newest-wins (renders). This path is not reachable for current
|
||
/// shells (which always stamp `promptId`) or for buffer-replays (suppressed
|
||
/// upstream by the `_meta["kigi/replayed"]` gate); it is pinned here so the
|
||
/// stamped-path fix above is understood to be the deterministic guard.
|
||
#[test]
|
||
fn apply_follow_ups_none_prompt_first_time_follows_legacy_newest_wins() {
|
||
let mut agent = make_agent();
|
||
agent.session.current_prompt_id = Some("p2".into());
|
||
assert!(
|
||
agent.apply_follow_ups_with_prompt("resp-x".into(), None, vec!["a".into()]),
|
||
"a None-promptId first-time arrival follows legacy newest-wins"
|
||
);
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-x");
|
||
}
|
||
/// FIX (buffer-before-adoption): a stamped `kigi/follow_ups` for a turn that
|
||
/// is NOT yet current (its `session/update` adoption raced behind the ext
|
||
/// channel) must be BUFFERED, not dropped — and then RENDER when that turn
|
||
/// becomes current and is flushed.
|
||
#[test]
|
||
fn apply_follow_ups_buffered_before_adoption_flushes_on_adoption() {
|
||
let mut agent = make_agent();
|
||
agent.session.current_prompt_id = Some("p1".into());
|
||
let changed =
|
||
agent.apply_follow_ups_with_prompt("resp-2".into(), Some("p2"), vec!["b".into()]);
|
||
assert!(
|
||
!changed,
|
||
"a not-yet-current turn's follow_ups must not render immediately"
|
||
);
|
||
assert!(
|
||
agent.follow_ups.is_none(),
|
||
"nothing rendered while buffered"
|
||
);
|
||
assert!(
|
||
agent.follow_up_pending.contains_key("p2"),
|
||
"the delivery is buffered keyed by its promptId"
|
||
);
|
||
agent.session.current_prompt_id = Some("p2".into());
|
||
let flushed = agent.flush_pending_follow_ups("p2");
|
||
assert!(flushed, "adoption flushes the buffered follow_ups");
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-2");
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().suggestions, vec!["b"]);
|
||
assert!(
|
||
!agent.follow_up_pending.contains_key("p2"),
|
||
"the buffered entry is consumed on flush"
|
||
);
|
||
assert!(agent.follow_up_pending_order.is_empty());
|
||
}
|
||
/// FIX (no stale revival): a buffered entry for a `promptId` that is
|
||
/// SUPERSEDED by a newer turn (and never becomes current) must NOT revive.
|
||
#[test]
|
||
fn apply_follow_ups_buffered_superseded_turn_does_not_revive() {
|
||
let mut agent = make_agent();
|
||
agent.session.current_prompt_id = Some("p1".into());
|
||
assert!(!agent.apply_follow_ups_with_prompt("resp-2".into(), Some("p2"), vec!["b".into()]));
|
||
assert!(agent.follow_up_pending.contains_key("p2"));
|
||
agent.session.current_prompt_id = Some("p3".into());
|
||
assert!(
|
||
!agent.flush_pending_follow_ups("p3"),
|
||
"no buffered entry for the adopted turn p3"
|
||
);
|
||
assert!(
|
||
agent.follow_ups.is_none(),
|
||
"the never-adopted p2 buffer must not revive on a different adoption"
|
||
);
|
||
assert!(agent.apply_follow_ups_with_prompt("resp-3".into(), Some("p3"), vec!["c".into()]));
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-3");
|
||
assert!(
|
||
agent.follow_up_pending.contains_key("p2"),
|
||
"p2 remains buffered-but-inert (bounded by the cap), never revived"
|
||
);
|
||
}
|
||
/// The pending buffer is FIFO-bounded: an overflow evicts ONLY the oldest
|
||
/// entry, never the whole map (so other not-yet-adopted turns survive).
|
||
#[test]
|
||
fn pending_follow_ups_buffer_evicts_oldest_on_overflow() {
|
||
let mut agent = make_agent();
|
||
agent.session.current_prompt_id = Some("cur".into());
|
||
let total = super::MAX_PENDING_FOLLOW_UPS + 1;
|
||
for i in 0..total {
|
||
assert!(!agent.apply_follow_ups_with_prompt(
|
||
format!("resp-{i}"),
|
||
Some(&format!("p{i}")),
|
||
vec!["x".into()],
|
||
));
|
||
}
|
||
assert_eq!(agent.follow_up_pending.len(), super::MAX_PENDING_FOLLOW_UPS);
|
||
assert!(
|
||
!agent.follow_up_pending.contains_key("p0"),
|
||
"the OLDEST buffered entry is evicted on overflow"
|
||
);
|
||
assert!(
|
||
agent.follow_up_pending.contains_key("p1"),
|
||
"a still-buffered (non-oldest) entry survives the overflow"
|
||
);
|
||
agent.session.current_prompt_id = Some("p1".into());
|
||
assert!(agent.flush_pending_follow_ups("p1"));
|
||
assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-1");
|
||
}
|
||
/// FIX (reload must not wipe adopted chips): follow_ups that arrive during
|
||
/// `loading_replay` for the running turn are BUFFERED (the turn is not
|
||
/// current yet). On `SessionLoaded` the reset must PRESERVE that buffer (drop
|
||
/// only stale pre-reload state) so adoption flushes + renders them.
|
||
#[test]
|
||
fn reload_preserves_running_turn_follow_ups_and_renders_on_adoption() {
|
||
let mut agent = make_agent();
|
||
agent.session.current_prompt_id = Some("p-stale".into());
|
||
agent.session.loading_replay = true;
|
||
assert!(!agent.apply_follow_ups_with_prompt(
|
||
"resp-run".into(),
|
||
Some("p-run"),
|
||
vec!["go".into()],
|
||
));
|
||
assert!(!agent.apply_follow_ups_with_prompt(
|
||
"resp-old".into(),
|
||
Some("p-old"),
|
||
vec!["stale".into()],
|
||
));
|
||
assert!(agent.follow_up_pending.contains_key("p-run"));
|
||
assert!(agent.follow_up_pending.contains_key("p-old"));
|
||
agent.reset_follow_ups_for_reload_preserving(Some("p-run"));
|
||
assert!(
|
||
agent.follow_up_pending.contains_key("p-run"),
|
||
"the running turn's buffered follow_ups survive the reload reset"
|
||
);
|
||
assert!(
|
||
!agent.follow_up_pending.contains_key("p-old"),
|
||
"stale pre-reload buffers are still dropped"
|
||
);
|
||
agent.adopt_running_prompt("p-run".into());
|
||
assert_eq!(
|
||
agent.follow_ups.as_ref().unwrap().response_id,
|
||
"resp-run",
|
||
"the running turn's follow_ups render after adoption"
|
||
);
|
||
}
|
||
/// FIX (reload must not wipe DISPLAYED chips): when the running turn's
|
||
/// follow_ups already RENDERED during `loading_replay` (because
|
||
/// `current_prompt_id` was unset or already equalled the running turn, so the
|
||
/// delivery took the render path, not the buffer), the reload reset must also
|
||
/// preserve those on-screen chips — re-buffering them so adoption re-renders
|
||
/// them WITHOUT the server resending. A stale OTHER turn is still dropped.
|
||
#[test]
|
||
fn reload_preserves_running_turn_displayed_chips_and_rerenders_on_adoption() {
|
||
let mut agent = make_agent();
|
||
agent.session.current_prompt_id = Some("p-run".into());
|
||
agent.session.loading_replay = true;
|
||
assert!(agent.apply_follow_ups_with_prompt(
|
||
"resp-run".into(),
|
||
Some("p-run"),
|
||
vec!["go".into()],
|
||
));
|
||
assert_eq!(
|
||
agent.follow_ups.as_ref().unwrap().response_id,
|
||
"resp-run",
|
||
"the running turn's chips are on screen (displayed, not buffered)"
|
||
);
|
||
assert_eq!(agent.follow_up_shown_prompt_id.as_deref(), Some("p-run"));
|
||
assert!(
|
||
agent.follow_up_pending.is_empty(),
|
||
"displayed chips are NOT in the pending buffer"
|
||
);
|
||
assert!(!agent.apply_follow_ups_with_prompt(
|
||
"resp-old".into(),
|
||
Some("p-old"),
|
||
vec!["stale".into()],
|
||
));
|
||
assert!(agent.follow_up_pending.contains_key("p-old"));
|
||
agent.reset_follow_ups_for_reload_preserving(Some("p-run"));
|
||
assert!(
|
||
agent.follow_ups.is_none(),
|
||
"the reset clears the on-screen chips"
|
||
);
|
||
assert!(
|
||
agent.follow_up_pending.contains_key("p-run"),
|
||
"the running turn's DISPLAYED chips are re-buffered so adoption can restore them"
|
||
);
|
||
assert!(
|
||
!agent.follow_up_pending.contains_key("p-old"),
|
||
"the stale OTHER turn is still dropped"
|
||
);
|
||
agent.adopt_running_prompt("p-run".into());
|
||
assert_eq!(
|
||
agent.follow_ups.as_ref().unwrap().response_id,
|
||
"resp-run",
|
||
"the running turn's chips re-render after adoption without a server resend"
|
||
);
|
||
}
|
||
/// A full reload reset (no running turn to preserve) clears the pending
|
||
/// buffer too — the reconnect-reload finalize path.
|
||
#[test]
|
||
fn reset_for_reload_clears_pending_buffer() {
|
||
let mut agent = make_agent();
|
||
agent.session.current_prompt_id = Some("cur".into());
|
||
assert!(!agent.apply_follow_ups_with_prompt("r".into(), Some("future"), vec!["a".into()],));
|
||
assert!(agent.follow_up_pending.contains_key("future"));
|
||
agent.reset_follow_ups_for_reload();
|
||
assert!(
|
||
agent.follow_up_pending.is_empty(),
|
||
"a full reload reset clears the pending buffer"
|
||
);
|
||
assert!(agent.follow_up_pending_order.is_empty());
|
||
}
|
||
#[test]
|
||
fn follow_up_chip_click_maps_to_suggestion_text() {
|
||
let mut agent = make_agent();
|
||
agent.apply_follow_ups("resp-1".into(), vec!["First".into(), "Second".into()]);
|
||
let area = ratatui::layout::Rect::new(0, 0, 60, 1);
|
||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||
let theme = crate::theme::Theme::current();
|
||
let suggestions = agent.follow_ups.as_ref().unwrap().suggestions.clone();
|
||
agent.follow_up_chips =
|
||
crate::views::agent::render_follow_ups(area, &mut buf, &theme, &suggestions, None);
|
||
assert_eq!(agent.follow_up_chips.len(), 2, "both chips fit");
|
||
let r = agent.follow_up_chips[1];
|
||
let idx = agent
|
||
.follow_up_chip_at(r.x + 1, r.y)
|
||
.expect("click inside a chip hits it");
|
||
assert_eq!(idx, 1);
|
||
assert_eq!(
|
||
agent.follow_ups.as_ref().unwrap().suggestions[idx],
|
||
"Second"
|
||
);
|
||
assert_eq!(agent.follow_up_chip_at(area.width - 1, 0), None);
|
||
}
|
||
/// `make_running_agent` reduced to a single focused local row: empty server
|
||
/// mirror, no in-flight prompt. The shared setup for the pane-hide paths.
|
||
pub fn running_agent_local_only() -> AgentView {
|
||
let mut agent = make_running_agent();
|
||
agent.active_pane = AgentPane::Queue;
|
||
agent.shared_queue.clear();
|
||
agent.session.current_prompt_id = None;
|
||
agent.queue.sync_from_merged(
|
||
&agent.session.pending_prompts,
|
||
&agent.shared_queue,
|
||
None,
|
||
None,
|
||
&agent.send_now_painted_blocks,
|
||
);
|
||
agent.queue.overlay.visible = true;
|
||
agent.queue.overlay.focused = true;
|
||
agent
|
||
}
|
||
/// Test image record attached to queued-prompt rows in the carry tests.
|
||
pub fn test_pasted_image() -> crate::prompt_images::PastedImage {
|
||
crate::prompt_images::from_clipboard_data(&crate::clipboard::ImageData {
|
||
data: vec![1, 2, 3],
|
||
mime_type: "image/png".into(),
|
||
})
|
||
}
|
||
}
|
||
/// Build a minimal [`AgentView`] for tests with an explicit session identity, so
|
||
/// the lazy Mermaid glue (which needs a session dir) can be exercised from the
|
||
/// `mermaid_worker` test module without duplicating the large `AgentSession`
|
||
/// literal.
|
||
#[cfg(any(test, feature = "test-support"))]
|
||
pub(crate) fn test_agent_view(session_id: Option<&str>, cwd: std::path::PathBuf) -> AgentView {
|
||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||
AgentView::new(
|
||
crate::app::agent::AgentSession {
|
||
id: crate::app::agent::AgentId(0),
|
||
acp_tx: tx,
|
||
session_id: session_id.map(agent_client_protocol::SessionId::new),
|
||
models: crate::acp::model_state::ModelState::default(),
|
||
state: crate::app::agent::AgentState::Idle,
|
||
tracker: crate::acp::tracker::AcpUpdateTracker::new(),
|
||
cwd,
|
||
is_worktree: false,
|
||
forked_from: None,
|
||
pending_prompts: std::collections::VecDeque::new(),
|
||
next_queue_id: 0,
|
||
yolo_mode: false,
|
||
auto_mode: false,
|
||
prompt_history: Vec::new(),
|
||
prompt_history_loading: false,
|
||
loading_replay: false,
|
||
restore_degree: None,
|
||
rate_limited: false,
|
||
model_incompatible: false,
|
||
available_commands: Vec::new(),
|
||
available_commands_generation: 0,
|
||
available_tools: None,
|
||
model_switch_pending: false,
|
||
user_model_preference: None,
|
||
deferred_model_switch: None,
|
||
bg_tasks: std::collections::BTreeMap::new(),
|
||
bg_tool_call_to_task: std::collections::HashMap::new(),
|
||
scheduled_tasks: std::collections::HashMap::new(),
|
||
in_flight_prompt: None,
|
||
current_prompt_id: None,
|
||
created_via_new: false,
|
||
},
|
||
crate::scrollback::state::ScrollbackState::new(),
|
||
)
|
||
}
|
||
#[cfg(test)]
|
||
mod dropdown_chrome_tests {
|
||
use super::*;
|
||
use ratatui::buffer::Buffer;
|
||
/// A panel taller than the space above the prompt (wrapped rows on a
|
||
/// short screen) must clamp, not hang past the buffer: an unclamped
|
||
/// `top` saturates at 0 and ratatui's `Clear` panics on the overhang.
|
||
#[test]
|
||
fn above_anchor_clamps_to_short_screen() {
|
||
let theme = crate::theme::Theme::current();
|
||
let layout_cfg = crate::appearance::LayoutConfig::default();
|
||
let area = Rect::new(0, 0, 100, 6);
|
||
let prompt = Rect::new(0, 4, 100, 2);
|
||
let mut buf = Buffer::empty(area);
|
||
let chrome = render_dropdown_chrome(
|
||
&mut buf,
|
||
2,
|
||
6,
|
||
None,
|
||
prompt,
|
||
area,
|
||
&layout_cfg,
|
||
false,
|
||
false,
|
||
&theme,
|
||
);
|
||
if let Some(chrome) = chrome {
|
||
assert!(chrome.panel.bottom() <= area.bottom());
|
||
assert!(chrome.items.height >= 1);
|
||
assert_eq!(chrome.items.height, chrome.panel.height - 2);
|
||
}
|
||
let prompt_top = Rect::new(0, 0, 100, 2);
|
||
let chrome = render_dropdown_chrome(
|
||
&mut buf,
|
||
2,
|
||
6,
|
||
None,
|
||
prompt_top,
|
||
area,
|
||
&layout_cfg,
|
||
false,
|
||
false,
|
||
&theme,
|
||
);
|
||
assert!(chrome.is_none());
|
||
for h in 1..=8u16 {
|
||
for prompt_y in 0..h {
|
||
let area = Rect::new(0, 0, 40, h);
|
||
let mut buf = Buffer::empty(area);
|
||
let prompt = Rect::new(0, prompt_y, 40, 1);
|
||
let _ = render_dropdown_chrome(
|
||
&mut buf,
|
||
2,
|
||
6,
|
||
None,
|
||
prompt,
|
||
area,
|
||
&layout_cfg,
|
||
false,
|
||
false,
|
||
&theme,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
#[cfg(test)]
|
||
mod prompt_input_mode_tests {
|
||
use super::*;
|
||
use crate::app::actions::Action;
|
||
use crate::theme::Theme;
|
||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||
#[test]
|
||
fn default_is_normal() {
|
||
assert_eq!(PromptInputMode::default(), PromptInputMode::Normal);
|
||
}
|
||
#[test]
|
||
fn accent_color_returns_expected_for_each_variant() {
|
||
let theme = Theme::current();
|
||
assert_eq!(PromptInputMode::Normal.accent_color(&theme), None);
|
||
assert_eq!(
|
||
PromptInputMode::Bash.accent_color(&theme),
|
||
Some(theme.command)
|
||
);
|
||
assert_eq!(
|
||
PromptInputMode::Remember.accent_color(&theme),
|
||
Some(theme.accent_remember)
|
||
);
|
||
}
|
||
#[test]
|
||
fn prefix_override_returns_expected_for_each_variant() {
|
||
let theme = Theme::current();
|
||
assert_eq!(PromptInputMode::Normal.prefix_override(&theme), None);
|
||
assert_eq!(
|
||
PromptInputMode::Bash.prefix_override(&theme),
|
||
Some(("! ", theme.command))
|
||
);
|
||
assert_eq!(
|
||
PromptInputMode::Remember.prefix_override(&theme),
|
||
Some(("# ", theme.accent_remember))
|
||
);
|
||
}
|
||
#[test]
|
||
fn placeholder_override_returns_expected_for_each_variant() {
|
||
assert_eq!(PromptInputMode::Normal.placeholder_override(false), None);
|
||
assert_eq!(PromptInputMode::Normal.placeholder_override(true), None);
|
||
assert_eq!(PromptInputMode::Bash.placeholder_override(false), None);
|
||
assert_eq!(PromptInputMode::Bash.placeholder_override(true), None);
|
||
assert_eq!(
|
||
PromptInputMode::Remember.placeholder_override(false),
|
||
Some("Save a memory note... (Shift+Enter for multiline)")
|
||
);
|
||
assert_eq!(
|
||
PromptInputMode::Remember.placeholder_override(true),
|
||
Some("Save a memory note... (Enter for newline, Shift+Enter to save)")
|
||
);
|
||
}
|
||
#[test]
|
||
fn prompt_info_override_returns_expected_for_each_variant() {
|
||
assert_eq!(PromptInputMode::Normal.prompt_info_override(), None);
|
||
assert_eq!(
|
||
PromptInputMode::Bash.prompt_info_override(),
|
||
Some("Run shell command")
|
||
);
|
||
assert_eq!(
|
||
PromptInputMode::Remember.prompt_info_override(),
|
||
Some("Save memory note")
|
||
);
|
||
}
|
||
#[test]
|
||
fn send_action_maps_to_correct_action_variant() {
|
||
let t1 = "hello world".to_string();
|
||
assert!(matches!(PromptInputMode::Normal.send_action(t1.clone()),
|
||
Action::SendPrompt(t) if t == t1));
|
||
let t2 = "ls -l".to_string();
|
||
assert!(matches!(PromptInputMode::Bash.send_action(t2.clone()),
|
||
Action::SendBashCommand(t) if t == t2));
|
||
let t4 = "remember this".to_string();
|
||
assert!(matches!(PromptInputMode::Remember.send_action(t4.clone()),
|
||
Action::SendRememberNote(t) if t == t4));
|
||
}
|
||
#[test]
|
||
fn is_exit_key_normal_never_exits() {
|
||
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||
let back = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
|
||
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
|
||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
assert!(!PromptInputMode::Normal.is_exit_key(&esc));
|
||
assert!(!PromptInputMode::Normal.is_exit_key(&back));
|
||
assert!(!PromptInputMode::Normal.is_exit_key(&ctrl_c));
|
||
assert!(!PromptInputMode::Normal.is_exit_key(&enter));
|
||
}
|
||
#[test]
|
||
fn is_exit_key_bash_and_remember_share_full_exit_set() {
|
||
for mode in [PromptInputMode::Bash, PromptInputMode::Remember] {
|
||
assert!(mode.is_exit_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)));
|
||
assert!(mode.is_exit_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
|
||
assert!(mode.is_exit_key(&KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)));
|
||
assert!(mode.is_exit_key(&KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)));
|
||
assert!(mode.is_exit_key(&KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)));
|
||
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
|
||
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)));
|
||
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)));
|
||
}
|
||
}
|
||
}
|