M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,361 @@
//! Prompt-suggestion gate and follow-up chips: the tab-autocomplete ghost
//! gate plus the follow-up chip lifecycle.
use super::{AgentView, FollowUps, MAX_PENDING_FOLLOW_UPS};
impl AgentView {
/// Refresh the gate for the predicted-next-prompt ghost (tab
/// autocomplete): it only shows on an idle session's normal prompt.
/// Called before key dispatch and before each draw so a turn starting
/// or an input-mode switch hides the ghost immediately. Also re-reads
/// the enabled state so a `/settings` toggle applies live.
pub(crate) fn refresh_prompt_suggestion_gate(&mut self) {
self.prompt.prompt_suggestion.enabled = crate::views::prompt_suggestion::resolve_enabled();
self.prompt.prompt_suggestion_active = self.prompt_input_mode
== super::PromptInputMode::Normal
&& matches!(self.prompt_mode, super::PromptMode::Normal)
&& !self.session.state.is_busy();
}
/// Notify the suggestion controller that the prompt text changed.
/// Returns an Effect to dispatch if the controller wants a debounce.
///
/// Shell suggestions are a bash-mode (`!`) feature: outside it the
/// pipeline never fires (no shell-history ghosts over natural-language
/// chat text) and any leftover ghost/dropdown is torn down.
pub(crate) fn notify_suggestion_text_changed(&mut self) -> Option<super::actions::Effect> {
use crate::views::suggestion_controller::SuggestionAction;
if self.prompt_input_mode != super::PromptInputMode::Bash {
self.prompt.suggestions.clear_ghost();
return None;
}
let snap = self.prompt.slash_state.snapshot();
let slash_active = snap.active;
let has_inline_ghost = snap.inline_ghost.is_some();
// Copy text before passing to text_changed to satisfy the borrow checker.
let text = self.prompt.text().to_owned();
let action = self
.prompt
.suggestions
.text_changed(&text, slash_active, has_inline_ghost)?;
match action {
SuggestionAction::Matched => None,
SuggestionAction::Debounce { generation } => {
Some(super::actions::Effect::DebounceSuggestions {
agent_id: self.session.id,
generation,
})
}
}
}
/// Apply an `x.ai/follow_ups` notification, keyed by `response_id`
/// (newest-response-wins).
///
/// Monotonic accept-the-newer: a never-seen `response_id` is strictly newer
/// than any previously accepted one, so it supersedes the shown chips; a
/// re-delivery of an already-accepted (hence older) response is ignored, so
/// a buffer-replay or duplicate cannot clobber the newest chips on any
/// turn-boundary path, with no reliance on a clear being wired there and no
/// eviction window that could let a stale id pass as new. A re-delivery of
/// the currently-shown response refreshes it in place (no-op when
/// identical); empty `suggestions` retracts that response's chips. Returns
/// `true` when the displayed chips changed (a redraw is warranted).
/// Backward-compatible shim used by tests that don't exercise the turn
/// identity: equivalent to a follow_ups notification with no stamped
/// `promptId` (the older-shell / replay path). Production always routes
/// through [`apply_follow_ups_with_prompt`] from `handle_follow_ups`.
#[cfg(test)]
pub(crate) fn apply_follow_ups(
&mut self,
response_id: String,
suggestions: Vec<String>,
) -> bool {
self.apply_follow_ups_with_prompt(response_id, None, suggestions)
}
/// `apply_follow_ups` with the turn identity (`prompt_id`) the shell stamps
/// on each `x.ai/follow_ups` notification (the same `promptId` it stamps on
/// every `session/update`). The identity makes viewer-adoption dedup
/// DETERMINISTIC:
///
/// - A re-delivery of the CURRENTLY-ADOPTED turn's follow-ups (its
/// `prompt_id` equals `session.current_prompt_id`) re-renders even when its
/// chips were cleared by turn adoption — so chips that were applied then
/// cleared reappear instead of being lost until reload.
/// - A buffer-replayed `x.ai/follow_ups` for a PRIOR turn's `response_id`
/// stays rejected by the seen-ring (its `prompt_id` is not the active one),
/// so stale chips are never revived on the new turn.
///
/// `prompt_id == None` (older shells, or a replay path that lacks it) is
/// treated as "not provably the current turn" → it falls back to the
/// monotonic newest-wins seen-ring and NEVER revives a cleared prior turn.
pub(crate) fn apply_follow_ups_with_prompt(
&mut self,
response_id: String,
prompt_id: Option<&str>,
suggestions: Vec<String>,
) -> bool {
// Re-delivery of the currently-shown response: refresh in place.
if self
.follow_ups
.as_ref()
.is_some_and(|c| c.response_id == response_id)
{
if self
.follow_ups
.as_ref()
.is_some_and(|c| c.suggestions == suggestions)
{
return false;
}
self.follow_up_chips.clear();
self.hovered_follow_up_chip = None;
if suggestions.is_empty() {
// Empty retraction of the currently-shown chips: drop this id
// from the seen-ring so a later NON-empty delivery for the SAME
// response can be re-accepted and re-rendered. Otherwise the id
// (recorded when first accepted) would make the re-delivery hit
// the `follow_up_seen` reject below and never display. This only
// ever affects the currently-shown (newest) id — a genuinely
// older/superseded id is never the shown one, so it never
// reaches this branch and stays rejected (newest-wins intact).
self.follow_up_seen.remove(&response_id);
self.follow_ups = None;
self.follow_up_shown_prompt_id = None;
} else {
self.follow_ups = Some(FollowUps {
response_id,
suggestions,
});
self.follow_up_shown_prompt_id = prompt_id.map(str::to_owned);
}
return true;
}
// Does this notification belong to the turn the client has currently
// adopted? Deterministic when the shell stamped the `promptId`; `false`
// for older shells / replay paths without one (those rely on the
// newest-wins seen-ring below and never revive a prior turn).
let current_prompt_id = self.session.current_prompt_id.as_deref();
let is_current_turn =
matches!((prompt_id, current_prompt_id), (Some(pid), Some(cur)) if pid == cur);
// A stamped `promptId` that names a DIFFERENT turn than the one
// currently adopted: this is a non-current turn's follow_ups (a PRIOR
// turn's late first-time arrival, or a not-yet-adopted turn). It must
// never render — as a re-delivery OR as "newest" — while another turn is
// active, or its chips would appear over the running turn.
//
// Guarded on `current == Some`: a `None` `promptId` (older shells) has
// no turn identity → newest-wins fallback; and `current == None` (e.g. a
// just-finished turn whose trailing follow_ups arrive after
// `current_prompt_id` was cleared) is NOT a mismatch, so those chips
// still render.
let names_other_active_turn =
matches!((prompt_id, current_prompt_id), (Some(pid), Some(cur)) if pid != cur);
if self.follow_up_seen.contains_key(&response_id) {
// Already accepted. Normally this is an older, superseded response →
// reject (newest-wins; a stale prior-turn buffer-replay must NOT
// revive chips). EXCEPTION: if this IS the currently-adopted turn
// (its `prompt_id` matches the active turn) and it carries chips, a
// re-delivery whose chips were cleared by turn adoption must
// re-render — scoped deterministically to the active turn so a prior
// turn is never revived.
if is_current_turn && !suggestions.is_empty() {
self.follow_up_chips.clear();
self.hovered_follow_up_chip = None;
self.follow_ups = Some(FollowUps {
response_id,
suggestions,
});
self.follow_up_shown_prompt_id = prompt_id.map(str::to_owned);
return true;
}
return false;
}
// First-time (never-seen) arrival for a turn that is NOT the active one.
// It must not render NOW (it would draw over the running turn), but it
// may be a not-yet-adopted FUTURE turn whose follow_ups raced ahead of
// the `session/update` that adopts it. Dropping it would lose the chips
// forever if it is the only delivery. Instead BUFFER it keyed by its
// `promptId`; [`flush_pending_follow_ups`] renders it if/when that turn
// becomes current. A genuinely prior turn's `promptId` never becomes
// current again, so its buffered entry is never flushed (no stale
// revival) and is eventually FIFO-evicted by the cap.
if names_other_active_turn {
if let Some(pid) = prompt_id
&& !suggestions.is_empty()
{
self.buffer_pending_follow_ups(pid.to_owned(), response_id, suggestions);
}
return false;
}
// Strictly newer response: supersede the prior chips (already recorded
// in `follow_up_seen` at its own acceptance, so no re-record needed).
let had_chips = self.follow_ups.take().is_some();
self.follow_up_shown_prompt_id = None;
self.follow_up_chips.clear();
self.hovered_follow_up_chip = None;
if suggestions.is_empty() {
// An empty payload for a never-seen response is a no-op retraction
// and is deliberately NOT recorded, so a later non-empty delivery
// for the same response still renders.
return had_chips;
}
self.follow_up_seen
.insert(response_id.clone(), self.follow_up_next_gen);
self.follow_up_next_gen += 1;
self.follow_ups = Some(FollowUps {
response_id,
suggestions,
});
self.follow_up_shown_prompt_id = prompt_id.map(str::to_owned);
true
}
/// Buffer a stamped `x.ai/follow_ups` for a turn that is not yet current,
/// keyed by its `promptId`. A newer delivery for the same `promptId`
/// overwrites the earlier one (keep the latest); the FIFO order list bounds
/// the map to [`MAX_PENDING_FOLLOW_UPS`], evicting only the oldest entry.
fn buffer_pending_follow_ups(
&mut self,
prompt_id: String,
response_id: String,
suggestions: Vec<String>,
) {
let is_new_key = self
.follow_up_pending
.insert(
prompt_id.clone(),
FollowUps {
response_id,
suggestions,
},
)
.is_none();
if is_new_key {
self.follow_up_pending_order.push_back(prompt_id);
if self.follow_up_pending_order.len() > MAX_PENDING_FOLLOW_UPS
&& let Some(evicted) = self.follow_up_pending_order.pop_front()
{
self.follow_up_pending.remove(&evicted);
}
}
}
/// Flush a buffered `x.ai/follow_ups` for `prompt_id` (a turn that has just
/// become current). Renders the chips through [`apply_follow_ups_with_prompt`]
/// — now that `current_prompt_id == prompt_id`, the stamped delivery is
/// accepted as the active turn's. Returns whether chips were rendered. A
/// no-op when nothing is buffered for `prompt_id`. Callers invoke this AFTER
/// setting `current_prompt_id` to `prompt_id` at every turn-adoption site.
pub(crate) fn flush_pending_follow_ups(&mut self, prompt_id: &str) -> bool {
let Some(pending) = self.follow_up_pending.remove(prompt_id) else {
return false;
};
if let Some(pos) = self
.follow_up_pending_order
.iter()
.position(|p| p == prompt_id)
{
self.follow_up_pending_order.remove(pos);
}
self.apply_follow_ups_with_prompt(pending.response_id, Some(prompt_id), pending.suggestions)
}
/// Drop the shown follow-up chips at a turn start (UX: they belong to the
/// previous response). The response stays recorded in `follow_up_seen`, so a
/// stale re-delivery stays rejected; the active turn's own re-delivery still
/// re-renders via the `prompt_id` match in [`apply_follow_ups_with_prompt`],
/// so this is used for BOTH viewer-adoption and self-driven turn starts.
pub(crate) fn clear_follow_ups(&mut self) {
self.follow_ups = None;
self.follow_up_shown_prompt_id = None;
self.follow_up_chips.clear();
self.hovered_follow_up_chip = None;
}
/// Full follow-up reset for a session reload. Unlike [`clear_follow_ups`]
/// (turn boundary — keeps `follow_up_seen` so a stale re-delivery stays
/// rejected), a reload starts a fresh streaming session: follow-ups never
/// persist, so the prior session's seen ids must also be dropped or they
/// would suppress chips streamed after the reload.
pub(crate) fn reset_follow_ups_for_reload(&mut self) {
self.reset_follow_ups_for_reload_preserving(None);
}
/// Reload reset that PRESERVES the running turn's follow-ups for
/// `keep_prompt_id` (the turn the load is about to adopt). On `SessionLoaded`
/// the running turn's `x.ai/follow_ups` arrive on the ext channel DURING
/// `loading_replay`; an unconditional reset would drop them before adoption
/// could re-render them, so the chips would never appear unless the server
/// resent them. The running turn's chips live in ONE of two places at reset
/// time:
///
/// * [`follow_up_pending`](Self::follow_up_pending) — buffered, never
/// displayed (the turn was not current when the chips arrived); OR
/// * [`follow_ups`](Self::follow_ups) — already ON SCREEN, because
/// `current_prompt_id` was unset or already equalled the running turn, so
/// the delivery took the newest-wins / current-turn render path instead
/// of the buffer.
///
/// Both are preserved (the on-screen copy is the live, latest state, so it
/// wins) by re-buffering the survivor into `follow_up_pending` keyed by
/// `keep_prompt_id`; [`adopt_running_prompt`](Self::adopt_running_prompt)
/// then flushes it. All other state — every OTHER turn's buffer, the seen
/// ring, on-screen chips of any other turn — is still cleared, so a reload
/// never leaves stale chips behind. `None` is a full reset (the
/// reconnect-reload finalize path, which has no running turn to adopt).
pub(crate) fn reset_follow_ups_for_reload_preserving(&mut self, keep_prompt_id: Option<&str>) {
// Capture the running turn's follow_ups BEFORE wiping state. Prefer the
// on-screen copy (it rendered, so it is the latest accepted delivery);
// fall back to the pending buffer.
let kept = keep_prompt_id.and_then(|keep| {
let displayed = self
.follow_up_shown_prompt_id
.as_deref()
.filter(|shown| *shown == keep)
.and_then(|_| self.follow_ups.clone());
displayed
.or_else(|| self.follow_up_pending.get(keep).cloned())
.map(|entry| (keep.to_owned(), entry))
});
self.follow_ups = None;
self.follow_up_shown_prompt_id = None;
self.follow_up_chips.clear();
self.hovered_follow_up_chip = None;
self.follow_up_seen.clear();
self.follow_up_next_gen = 0;
self.follow_up_pending.clear();
self.follow_up_pending_order.clear();
if let Some((pid, entry)) = kept {
self.follow_up_pending.insert(pid.clone(), entry);
self.follow_up_pending_order.push_back(pid);
}
}
/// Index of the follow-up chip under a screen position, if any. Used by
/// the mouse handler to submit the clicked suggestion as a literal prompt.
pub(crate) fn follow_up_chip_at(&self, col: u16, row: u16) -> Option<usize> {
self.follow_up_chips
.iter()
.position(|r| r.contains((col, row).into()))
}
/// Update hover highlight for follow-up chips. Returns true if the hover
/// index changed (caller should re-render).
pub(crate) fn set_hovered_follow_up_chip(&mut self, idx: Option<usize>) -> bool {
if self.hovered_follow_up_chip == idx {
return false;
}
self.hovered_follow_up_chip = idx;
true
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
//! `/jump` picker: transcript preview syncing and key/mouse handling.
use super::AgentView;
use crate::app::actions::Action;
use crate::app::app_view::InputOutcome;
use crate::views::jump::{
JumpInput, JumpRestore, handle_jump_key, jump_activate, jump_row_at, move_cursor,
set_jump_cursor,
};
use crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
impl AgentView {
/// Close the `/jump` picker (if open) and restore the viewport it opened
/// from. Shared by the `Esc` dismiss path and the rewind / inline-edit entry
/// points, so a shadowed picker can't reappear stale.
pub(crate) fn dismiss_jump_picker(&mut self) {
if let Some(js) = self.jump_state.take() {
self.restore_jump_viewport(js.restore);
}
}
/// Re-pin the viewport the picker captured (width-stable bookmark), restore
/// the prior selection, and re-arm follow mode. Shared by `Esc` dismiss and
/// the failed-jump restore, so both stay consistent under a resize.
pub(crate) fn restore_jump_viewport(&mut self, restore: JumpRestore) {
self.scrollback.set_selected(restore.selected);
if let Some(bookmark) = restore.bookmark {
self.scrollback.restore_scroll_bookmark(bookmark);
}
if restore.follow_mode {
self.scrollback.enable_follow();
}
}
/// True when another prompt overlay owns the input slot, so the `/jump`
/// picker must not open and an open one must be dismissed: rewind, inline
/// edit, the `/btw` panel, or a pending permission / question / cancel-turn /
/// plan-approval overlay. One predicate keeps dispatch, key, mouse, and
/// scroll routing from disagreeing on the owner.
pub(crate) fn jump_slot_taken(&self) -> bool {
self.rewind_state.is_some()
|| self.inline_edit.is_some()
|| self.btw_state.is_some()
|| !self.no_input_overlay_pending()
}
/// Drop the picker when another overlay owns the input slot
/// ([`Self::jump_slot_taken`]), so it can't eat wheel/keys while hidden.
/// Returns whether it dropped one, so an `Esc` caller can spend that key
/// here rather than let it also dismiss the overlay shadowing the picker
/// (e.g. the `/btw` panel). Called at the input and scroll entry points.
pub(super) fn dismiss_jump_picker_if_suppressed(&mut self) -> bool {
if self.jump_state.is_some() && self.jump_slot_taken() {
self.dismiss_jump_picker();
return true;
}
false
}
/// Live-scroll the transcript to the turn under the picker cursor,
/// anchored at the viewport TOP — where `jump_to_turn` lands — so the
/// preview shows exactly what Enter commits to. (Rewind centers
/// instead: it previews a cut point and needs both sides visible.)
pub(super) fn sync_jump_preview(&mut self) {
let Some(prompt_id) = self
.jump_state
.as_ref()
.and_then(|js| js.entries.get(js.selected))
.map(|entry| entry.prompt_entry_id)
else {
return;
};
// Resolve the stable id at the boundary; a removal since capture just
// means no preview scroll rather than landing on the wrong block.
if let Some(idx) = self.scrollback.index_of_id(prompt_id) {
self.scrollback.scroll_to_entry_top(idx);
}
}
pub(super) fn handle_jump_key(&mut self, key: &KeyEvent) -> InputOutcome {
let Some(ref state) = self.jump_state else {
return InputOutcome::Unchanged;
};
match handle_jump_key(state, key) {
JumpInput::MoveUp => {
if let Some(ref mut js) = self.jump_state {
move_cursor(js, -1);
self.sync_jump_preview();
}
InputOutcome::Changed
}
JumpInput::MoveDown => {
if let Some(ref mut js) = self.jump_state {
move_cursor(js, 1);
self.sync_jump_preview();
}
InputOutcome::Changed
}
other => Self::jump_input_to_outcome(other),
}
}
/// Map a terminal `JumpInput` to its `InputOutcome`. Shared by the key,
/// mouse, and wheel paths so they can't drift.
fn jump_input_to_outcome(input: JumpInput) -> InputOutcome {
match input {
JumpInput::Select(id) => InputOutcome::Action(Action::JumpPickerSelect(id)),
JumpInput::Dismissed => InputOutcome::Action(Action::JumpDismiss),
JumpInput::MoveUp | JumpInput::MoveDown | JumpInput::Consumed => InputOutcome::Changed,
}
}
/// `Moved` moves the cursor (and previews); `Down(Left)` activates the
/// row (Enter-equivalent). Row geometry comes from `jump_row_at`.
pub(super) fn handle_jump_mouse(&mut self, mouse: &MouseEvent) -> InputOutcome {
let Some(js) = self.jump_state.as_mut() else {
return InputOutcome::Unchanged;
};
let area = self.pane_areas.prompt;
let Some(idx) = jump_row_at(js, area, mouse.column, mouse.row) else {
return InputOutcome::Unchanged;
};
match mouse.kind {
MouseEventKind::Moved => {
if set_jump_cursor(js, idx) {
self.sync_jump_preview();
InputOutcome::Changed
} else {
InputOutcome::Unchanged
}
}
MouseEventKind::Down(MouseButton::Left) => {
set_jump_cursor(js, idx);
let activated = jump_activate(js);
Self::jump_input_to_outcome(activated)
}
_ => InputOutcome::Unchanged,
}
}
}
#[cfg(test)]
mod tests {
use crate::scrollback::block::RenderBlock;
use crate::views::jump::{JumpRestore, JumpState};
#[test]
fn preview_scrolls_to_cursor_turn() {
let mut agent = crate::test_util::make_agent_view(None, "/tmp");
agent.scrollback.push_block(RenderBlock::user_prompt("Q1"));
for i in 0..20 {
agent
.scrollback
.push_block(RenderBlock::agent_message(format!("para {i}")));
}
agent.scrollback.push_block(RenderBlock::user_prompt("Q2"));
agent.scrollback.push_block(RenderBlock::agent_message("a"));
agent.scrollback.prepare_layout(80, 6);
agent.scrollback.goto_bottom();
let at_bottom = agent.scrollback.scroll_offset();
agent.jump_state = Some(JumpState {
entries: agent.scrollback.timeline_entries(),
selected: 0,
restore: JumpRestore {
bookmark: agent.scrollback.capture_scroll_bookmark(),
selected: None,
follow_mode: true,
},
});
agent.sync_jump_preview();
assert!(
agent.scrollback.scroll_offset() < at_bottom,
"previewing turn 1 scrolls the transcript up"
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,800 @@
//! Inline media: image/video viewer keys, playback state, media click
//! handling, and mermaid diagram affordances.
use super::{AgentView, InlineVideoState};
use crate::app::app_view::InputOutcome;
use crate::render::SafeBuf;
use crate::terminal::overlay::{self, PostFlush};
use crate::theme::Theme;
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
impl AgentView {
// -- Image viewer input --------------------------------------------------
/// Handle a key event in the image viewer modal.
pub(super) fn handle_image_viewer_key(&mut self, key: &KeyEvent) -> InputOutcome {
use crossterm::event::KeyCode;
if self.image_viewer.is_none() {
return InputOutcome::Unchanged;
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
// Clear the Kitty image before closing.
// Old code bypassed STDERR_OUTPUT_LOCK which could interleave
// mid-frame. Safe to revert: content is valid escapes, not raw text.
kigi_shell::util::with_locked_stderr(|stderr| {
let clear = PostFlush::from(overlay::clear_kitty());
let _ = clear.write_to(stderr);
});
self.image_viewer = None;
self.image_load_rx = None;
// The viewer's decoded/re-encoded overlay image (tens of MB
// for screenshots/renders) just dropped; input path, so a
// synchronous purge lands between interactions.
crate::memory_release::release_retained_memory_with("image-viewer-close");
}
_ => {}
}
InputOutcome::Changed
}
// -- Inline media rendering -----------------------------------------------
/// Build Kitty/iTerm2 escape sequences for an inline media placement.
pub(super) fn build_inline_media_escapes(
&mut self,
placement: &crate::scrollback::render::InlineMediaPlacement,
) -> Option<String> {
use crate::prompt_images::decode_image_dimensions;
let path = &placement.info.path;
// During inline video playback, transmit the current frame.
let is_video_playing = self.inline_video.as_ref().is_some_and(|v| v.path == *path);
if is_video_playing {
let vid_id = self.get_or_alloc_media_id(path);
let video = self.inline_video.as_ref()?;
let frame_data = &video.frames[video.current_frame];
let (w, h) = decode_image_dimensions(frame_data)
.unwrap_or((placement.info.width, placement.info.height));
let transmit = crate::terminal::image::transmit_inline_image(frame_data, vid_id)?;
let place = crate::terminal::image::place_inline_image(
frame_data,
w,
h,
placement.screen_rect,
placement.full_rows,
placement.top_crop_rows,
vid_id,
true,
)?;
return Some(format!("{transmit}{place}"));
}
// Static image or video poster frame.
// Allocate the Kitty id only *after* bytes are in hand: a not-yet-written
// path (or a failed read) must return `None` without recording an id, or
// the next time the path is seen `needs_transmit` would be false and only
// `place` (no `transmit`) would emit — leaving a blank image.
let needs_transmit = !self.inline_media_ids.contains_key(path);
let mut transmit_esc = String::new();
if needs_transmit {
// Load bytes from disk (or use cached bytes if available).
if !self.inline_media_cache.contains_key(path) {
let bytes = if placement.info.is_video {
let (frame_bytes, _, _) = crate::prompt_images::extract_poster_frame(path)?;
crate::terminal::image::prepare_overlay_image_bytes(&frame_bytes)?
} else {
let raw = std::fs::read(path).ok()?;
crate::terminal::image::prepare_overlay_image_bytes(&raw)?
};
// Bound the cache: a long image-heavy session must not pin
// every encoded image for its lifetime. Evicting drops only
// CPU-side bytes — Kitty placements already transmitted stay
// valid on the GPU (`inline_media_ids` is kept); an evicted
// path re-reads from disk if it needs a re-transmit.
const INLINE_MEDIA_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
let incoming = bytes.len();
if incoming < INLINE_MEDIA_CACHE_MAX_BYTES {
let mut total: usize = self
.inline_media_cache
.values()
.map(Vec::len)
.sum::<usize>()
+ incoming;
while total > INLINE_MEDIA_CACHE_MAX_BYTES {
// HashMap iteration order is arbitrary — treat as random eviction.
let Some(victim) = self.inline_media_cache.keys().next().cloned() else {
break;
};
if let Some(evicted) = self.inline_media_cache.remove(&victim) {
total -= evicted.len();
}
}
}
self.inline_media_cache.insert(path.clone(), bytes);
}
let image_id = self.get_or_alloc_media_id(path);
let bytes = self.inline_media_cache.get(path)?;
transmit_esc = crate::terminal::image::transmit_inline_image(bytes, image_id)?;
}
let image_id = self.get_or_alloc_media_id(path);
let image_data = self.inline_media_cache.get(path)?;
let (w, h) = decode_image_dimensions(image_data)
.unwrap_or((placement.info.width, placement.info.height));
// iTerm2 has no place-only escape — re-emit when placement moves.
let emit_iterm = self
.inline_media_iterm_emitted
.get(path)
.is_none_or(|last| *last != placement.screen_rect);
let place_esc = crate::terminal::image::place_inline_image(
image_data,
w,
h,
placement.screen_rect,
placement.full_rows,
placement.top_crop_rows,
image_id,
emit_iterm,
)?;
if emit_iterm
&& crate::terminal::image::detect_graphics_protocol()
== crate::terminal::image::GraphicsProtocol::ITerm2
{
self.inline_media_iterm_emitted
.insert(path.clone(), placement.screen_rect);
}
Some(format!("{transmit_esc}{place_esc}"))
}
/// Paint each visible Mermaid affordance row (`◇ mermaid [Open Image]
/// [Copy Image Path] [Copy Source]`) and register its click hit-rects.
///
/// The leading `◇ mermaid` label is a dim, non-clickable marker. Every button
/// is always clickable (`[Open]`/`[Copy path]` render lazily on click); a
/// button whose hit-rect is under the mouse is highlighted, the rest are dim.
/// A trailing dim `rendering…` hint follows the buttons while an on-click
/// render for that diagram is in flight. The whole layout (label + button +
/// hint columns) comes from
/// [`affordance_row`](crate::scrollback::blocks::mermaid_content::affordance_row)
/// so the painted labels and the hit-rects can't drift, and each segment is
/// clipped to `screen_rect.width` (which excludes the timestamp reserve).
pub(super) fn paint_diagram_affordances(
&mut self,
buf: &mut Buffer,
placements: Vec<crate::scrollback::render::DiagramAffordancePlacement>,
theme: &Theme,
) {
use crate::scrollback::blocks::mermaid_content::affordance_row;
use ratatui::style::Modifier;
use unicode_width::UnicodeWidthStr;
let (hover_col, hover_row) = self.last_mouse_pos;
for aff in placements {
let crate::scrollback::render::DiagramAffordancePlacement {
screen_rect: rect,
source,
} = aff;
// The transient `rendering…` hint shows only while an on-click render
// for this diagram is in flight.
let rendering = self.diagram_is_rendering(&source);
let row = affordance_row(rendering);
// A segment is drawn only if it fits wholly within the row width
// (which already excludes the timestamp reserve), so labels never
// spill past the content area and hit-rects stay inside the row.
let fits =
|col: u16, label: &str| col + UnicodeWidthStr::width(label) as u16 <= rect.width;
// Leading dim, non-clickable `◇ mermaid` label.
let (label_col, label_text) = row.label;
if fits(label_col, label_text) {
buf.set_string_safe(
rect.x.saturating_add(label_col),
rect.y,
label_text,
Style::default().fg(theme.gray_dim),
);
}
// Register the diagram's source once — moved, not cloned (the
// placement is owned and used only here) — when at least one button
// fits; every fitting button below indexes into it for click routing.
let source_idx = if row.buttons.iter().any(|b| fits(b.col, b.label)) {
let idx = self.inline_media_hits.mermaid_sources.len();
self.inline_media_hits.mermaid_sources.push(source);
Some(idx)
} else {
None
};
for btn in row.buttons {
if !fits(btn.col, btn.label) {
continue;
}
let bx = rect.x.saturating_add(btn.col);
let width = UnicodeWidthStr::width(btn.label) as u16;
let hit = Rect {
x: bx,
y: rect.y,
width,
height: 1,
};
// Hovered button is highlighted; idle buttons stay at the normal
// `gray` (brighter than the dim `◇ mermaid` label) so they remain
// discoverable at rest.
let style = if hit.contains((hover_col, hover_row).into()) {
Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default().fg(theme.gray)
};
buf.set_string_safe(bx, rect.y, btn.label, style);
if let Some(idx) = source_idx {
self.inline_media_hits
.mermaid_buttons
.push((hit, btn.kind, idx));
}
}
// Trailing dim `rendering…` hint after the buttons (not clickable).
if let Some((col, status)) = row.status
&& fits(col, status)
{
buf.set_string_safe(
rect.x.saturating_add(col),
rect.y,
status,
Style::default().fg(theme.gray_dim),
);
}
}
}
/// Whether the diagram with `source` has an on-click render in flight (drives
/// the affordance row's transient `rendering…` hint).
fn diagram_is_rendering(&self, source: &str) -> bool {
self.mermaid_is_rendering(source)
}
/// Get or allocate a Kitty image ID for the given media path.
fn get_or_alloc_media_id(&mut self, path: &std::path::Path) -> u32 {
if let Some(&id) = self.inline_media_ids.get(path) {
return id;
}
let id = self.next_inline_media_id;
self.next_inline_media_id += 1;
self.inline_media_ids.insert(path.to_path_buf(), id);
id
}
/// Drain this agent's inline-media placement tracking and return the
/// Kitty delete escapes for every image it has placed on the GPU.
///
/// Kitty graphics are independent of the cell grid: they survive
/// redraws until explicitly deleted, and every regular clear path
/// lives inside [`AgentView::draw`]. When another view takes over the
/// frame (e.g. the agent dashboard), those per-frame clears stop
/// running, so the caller uses this to delete whatever this agent
/// left on screen. Resetting `inline_media_ids` forces a fresh
/// transmit when this agent next draws; any active inline playback
/// is stopped, mirroring the scrolled-off-screen clear path.
///
/// Returns `None` when this agent (and its subagent views) has no
/// placements.
pub(crate) fn take_inline_media_clear_escapes(&mut self) -> Option<String> {
let mut clear_esc = self
.take_own_inline_media_clear_escapes()
.unwrap_or_default();
if let Some(esc) = self.take_subagent_inline_media_clear_escapes() {
clear_esc.push_str(&esc);
}
(!clear_esc.is_empty()).then_some(clear_esc)
}
/// This view's own placements only, leaving `subagent_views` untouched.
/// Used by the fullscreen-subagent takeover in [`AgentView::draw`]: the
/// parent's images must be deleted, but the child is about to draw and
/// manages its own placements — draining it too would just force a
/// re-transmit.
pub(super) fn take_own_inline_media_clear_escapes(&mut self) -> Option<String> {
// Also proceed when only playback state remains (`inline_video` Some
// with no active placements — e.g. frames finished loading after the
// media scrolled off): the drain must still stop the ticking video,
// or it keeps holding the animation gate open invisibly and its
// eventual drop is never purged.
if !self.inline_media_active
&& self.inline_media_ids.is_empty()
&& self.inline_video.is_none()
{
return None;
}
self.inline_media_active = false;
self.stop_inline_playback();
let mut clear_esc = String::new();
for &id in self.inline_media_ids.values() {
clear_esc.push_str(&crate::terminal::image::clear_kitty_image(id));
}
self.inline_media_ids.clear();
self.inline_media_iterm_emitted.clear();
self.last_placed_ids.clear();
(!clear_esc.is_empty()).then_some(clear_esc)
}
/// Stop inline video playback, dropping the pre-extracted frame set
/// (~50300 MB), and request a post-draw purge for it. Returns whether a
/// video was actually playing — callers on the draw path rely on the
/// deferred request (never a synchronous purge mid-frame), and image-only
/// paths (`None` here) must not purge at all.
pub(super) fn stop_inline_playback(&mut self) -> bool {
let had_video = self.inline_video.take().is_some();
if had_video {
crate::memory_release::request_release_after_draw_with("inline-video-stop");
}
had_video
}
/// Install freshly-extracted inline video frames, dropping (and
/// requesting a post-draw purge for) any previous playback's frame set.
/// Called from the tick path when the background extraction completes.
pub(crate) fn replace_inline_video(&mut self, video: crate::app::agent_view::InlineVideoState) {
if self.inline_video.replace(video).is_some() {
// Switching videos: the previous frame set just dropped.
crate::memory_release::request_release_after_draw_with("inline-video-replace");
}
}
/// Subagent fullscreen views render inline media with their own ids —
/// drain those (recursively), leaving this view's placements alone.
pub(super) fn take_subagent_inline_media_clear_escapes(&mut self) -> Option<String> {
let mut clear_esc = String::new();
for child in self.subagent_views.values_mut() {
if let Some(esc) = child.take_inline_media_clear_escapes() {
clear_esc.push_str(&esc);
}
}
(!clear_esc.is_empty()).then_some(clear_esc)
}
/// Refresh [`Self::media_link_paths`] — the absolute paths of media
/// generated in this transcript — from scrollback, but only when its
/// generation has changed. The model prints short session-relative paths
/// (`images/1.jpg`); resolving them against the actual generated files ties
/// each link to the file its message produced (correct across forks) and
/// never opens an out-of-session or arbitrary file.
pub(crate) fn ensure_media_link_paths(&mut self) {
let generation = self.scrollback.generation();
if self.media_link_paths_gen == Some(generation) {
return;
}
self.media_link_paths_gen = Some(generation);
self.media_link_paths.clear();
self.media_link_paths.extend(
self.scrollback
.iter_entries()
.filter_map(|(_, entry)| entry.block.media_ref_path()),
);
}
/// Open a media file in the OS-native default application (Preview,
/// default video player, etc.). Shared by the `[Open]` button, the
/// inline-image click target, and the Enter-key handler.
pub(crate) fn open_media_natively(&mut self, path: &std::path::Path) -> bool {
if crate::app::link_opener::open_path(path) {
self.show_toast("Opening in default app\u{2026}");
true
} else {
self.show_toast("Could not open file");
false
}
}
/// Start or restart inline video playback. If already playing for this
/// path, restarts from the beginning. Frames are extracted via ffmpeg in
/// a background thread so the UI never blocks.
pub(crate) fn start_inline_video_playback(&mut self, path: &std::path::Path) {
// If already loaded for this path, just restart.
if let Some(ref mut video) = self.inline_video
&& video.path == path
{
video.current_frame = 0;
video.finished = false;
video.last_frame_time = std::time::Instant::now();
return;
}
// Extract frames in a background thread to avoid blocking the UI.
let path_owned = path.to_path_buf();
let (tx, rx) = std::sync::mpsc::channel();
self.video_load_rx = Some(rx);
self.show_toast("Loading video\u{2026}");
std::thread::spawn(move || {
let result =
crate::prompt_images::VideoViewerState::open_from_path(&path_owned).map(|viewer| {
InlineVideoState {
path: path_owned,
frames: viewer.frames,
current_frame: 0,
last_frame_time: std::time::Instant::now(),
fps: viewer.fps,
finished: false,
}
});
let _ = tx.send(result);
});
}
// -- Inline media click handling -----------------------------------------
/// Handle a click on inline media buttons. Returns `Some(InputOutcome)` if
/// the click was consumed, `None` to fall through to normal handling.
pub(in crate::app) fn handle_inline_media_click(
&mut self,
col: u16,
row: u16,
) -> Option<InputOutcome> {
let pos = ratatui::layout::Position::new(col, row);
// [Open] button or inline image → open natively. Checked before the
// play targets so a video's [Open] button opens rather than plays.
let open_target = self
.inline_media_hits
.open_buttons
.iter()
.chain(self.inline_media_hits.media_areas.iter())
.find(|(rect, _)| rect.contains(pos))
.map(|(_, path)| path.clone());
if let Some(path) = open_target {
self.open_media_natively(&path);
return Some(InputOutcome::Changed);
}
// [Play] button or video poster → start/restart inline playback.
let play_target = self
.inline_media_hits
.play_buttons
.iter()
.chain(self.inline_media_hits.video_play_areas.iter())
.find(|(rect, _)| rect.contains(pos))
.map(|(_, path)| path.clone());
if let Some(path) = play_target {
self.start_inline_video_playback(&path);
return Some(InputOutcome::Changed);
}
// [Copy] button → copy image to clipboard (async).
if let Some((_, path)) = self
.inline_media_hits
.copy_image_buttons
.iter()
.find(|(rect, _)| rect.contains(pos))
{
let path = path.clone();
std::thread::spawn(move || {
if let Err(e) = kigi_shell::util::clipboard::set_image_file(&path) {
tracing::debug!("copy image failed: {e}");
}
});
self.show_toast("Copied image");
return Some(InputOutcome::Changed);
}
// Click on filepath line → copy path to clipboard.
if let Some((_, path)) = self
.inline_media_hits
.filepath_areas
.iter()
.find(|(rect, _)| rect.contains(pos))
{
let path_str = path.display().to_string();
self.copy_to_clipboard(&path_str);
return Some(InputOutcome::Changed);
}
// Mermaid affordance row → render-on-click (Open/Copy path) or copy
// source. Resolve the kind + source index first so the `mermaid_buttons`
// borrow ends before the `&mut self` dispatch below.
let mermaid_hit = self
.inline_media_hits
.mermaid_buttons
.iter()
.find(|(rect, _, _)| rect.contains(pos))
.map(|&(_, kind, idx)| (kind, idx));
if let Some((kind, idx)) = mermaid_hit {
let source = self
.inline_media_hits
.mermaid_sources
.get(idx)
.cloned()
.unwrap_or_default();
self.on_mermaid_affordance_click(kind, source);
return Some(InputOutcome::Changed);
}
None
}
/// Route a Mermaid affordance-row click. `[Copy source]` copies the diagram
/// source (no render); `[Open]`/`[Copy path]` render it lazily at the live
/// theme/width and then open the PNG / copy its path. `source` is moved into
/// the renderer, never cloned. `copy_to_clipboard` owns the copy toast.
fn on_mermaid_affordance_click(
&mut self,
kind: crate::scrollback::blocks::mermaid_content::AffordanceKind,
source: String,
) {
use crate::scrollback::blocks::mermaid_content::AffordanceKind;
match kind {
AffordanceKind::CopySource => {
if !self.copy_to_clipboard(&source) {
crate::unified_log::error(
"mermaid.copy_source.failed",
self.session.session_id.as_ref().map(|s| s.0.as_ref()),
Some(serde_json::json!({ "source_len": source.len() })),
);
}
}
AffordanceKind::Open | AffordanceKind::CopyPath => {
let action = if matches!(kind, AffordanceKind::Open) {
crate::app::mermaid_worker::MermaidClickAction::Open
} else {
crate::app::mermaid_worker::MermaidClickAction::CopyPath
};
self.request_mermaid_render(source, action);
}
}
}
// -- Video viewer input --------------------------------------------------
/// Handle a key event in the video viewer modal.
pub(super) fn handle_video_viewer_key(&mut self, key: &KeyEvent) -> InputOutcome {
use crossterm::event::KeyCode;
let Some(ref mut viewer) = self.video_viewer else {
return InputOutcome::Unchanged;
};
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
// Clear the Kitty image before closing.
kigi_shell::util::with_locked_stderr(|stderr| {
let clear = PostFlush::from(overlay::clear_kitty());
let _ = clear.write_to(stderr);
});
self.video_viewer = None;
// The viewer's pre-extracted frame set (~50300 MB for a
// typical clip) just dropped; return the pages to the OS.
crate::memory_release::release_retained_memory_with("video-viewer-close");
}
KeyCode::Char(' ') => {
viewer.toggle_play_pause();
}
KeyCode::Right | KeyCode::Char('l') => {
viewer.seek_forward();
}
KeyCode::Left | KeyCode::Char('h') => {
viewer.seek_backward();
}
_ => {}
}
InputOutcome::Changed
}
// -- /gboom easter egg input ------------------------------------------------
/// Handle a key event in the `/gboom` game modal.
pub(super) fn handle_gboom_key(&mut self, key: &KeyEvent) -> InputOutcome {
let Some(ref mut gboom) = self.gboom else {
return InputOutcome::Unchanged;
};
match gboom.handle_key(key) {
crate::gboom::GboomKeyOutcome::Close => {
// Clear the kitty image before closing (same as the video
// viewer) so no stale frame lingers in the cell grid.
kigi_shell::util::with_locked_stderr(|stderr| {
let clear = PostFlush::from(overlay::clear_kitty());
let _ = clear.write_to(stderr);
});
self.gboom = None;
}
crate::gboom::GboomKeyOutcome::Changed => {}
}
InputOutcome::Changed
}
/// Handle a key-release in the `/gboom` modal (un-latch movement).
pub(super) fn handle_gboom_release(&mut self, key: &KeyEvent) -> InputOutcome {
if let Some(ref mut gboom) = self.gboom {
gboom.handle_release(key);
}
InputOutcome::Changed
}
pub(super) fn handle_gboom_mouse(&mut self, mouse: &MouseEvent) -> InputOutcome {
if let Some(ref mut gboom) = self.gboom {
gboom.handle_mouse(mouse);
}
InputOutcome::Changed
}
}
#[cfg(test)]
mod tests {
use crate::memory_release::test_support;
fn make_agent() -> crate::app::agent_view::AgentView {
crate::test_util::make_agent_view(None, "/tmp")
}
fn stub_inline_video() -> crate::app::agent_view::InlineVideoState {
crate::app::agent_view::InlineVideoState {
path: std::path::PathBuf::from("/tmp/clip.mp4"),
frames: vec![Vec::new()],
current_frame: 0,
last_frame_time: std::time::Instant::now(),
fps: 1.0,
finished: false,
}
}
/// Closing the video viewer modal drops the pre-extracted frame set —
/// the purge must fire on close and never on other viewer keys.
#[test]
fn video_viewer_close_releases_retained_memory() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
test_support::install_counting_hook();
let mut agent = make_agent();
agent.video_viewer = Some(crate::prompt_images::VideoViewerState::test_stub());
// A non-close key keeps the viewer (and its frames) → no purge.
let before = test_support::calls();
agent.handle_video_viewer_key(&KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert!(agent.video_viewer.is_some());
assert_eq!(
test_support::calls(),
before,
"play/pause drops nothing and must not purge"
);
// Esc closes → frames drop → one purge.
let before = test_support::calls();
agent.handle_video_viewer_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(agent.video_viewer.is_none());
assert_eq!(
test_support::calls(),
before + 1,
"closing the viewer must purge after the frame set drops"
);
}
/// Draining inline-media placements requests a POST-DRAW purge only when
/// live playback (a frame set) was actually dropped — image-only clears
/// must not, and the purge must never run synchronously (these paths sit
/// inside `draw`). Serialized: the deferred-request flag is process-wide.
#[test]
#[serial_test::serial(MEMORY_RELEASE_DEFER)]
fn inline_media_clear_defers_release_only_for_video() {
test_support::install_counting_hook();
// Drain any stale request left by an earlier test in this group.
crate::memory_release::run_deferred_release();
let mut agent = make_agent();
// Image-only placements active: clear drops no frames → no request.
agent.inline_media_active = true;
let before = test_support::calls();
let _ = agent.take_inline_media_clear_escapes();
crate::memory_release::run_deferred_release();
assert_eq!(
test_support::calls(),
before,
"an image-only media clear must not purge"
);
// Active inline playback: sync no purge; the drain runs it → one.
agent.inline_media_active = true;
agent.inline_video = Some(stub_inline_video());
let before = test_support::calls();
let _ = agent.take_inline_media_clear_escapes();
assert!(agent.inline_video.is_none());
assert_eq!(
test_support::calls(),
before,
"draw-path video stop must never purge synchronously"
);
crate::memory_release::run_deferred_release();
assert_eq!(
test_support::calls(),
before + 1,
"the post-draw drain must purge the dropped frame set"
);
// Orphaned playback (frames finished loading after the media
// scrolled off: no active flag, no placements): the drain must still
// stop the video and request its purge.
agent.inline_media_active = false;
agent.inline_video = Some(stub_inline_video());
let before = test_support::calls();
assert!(agent.take_inline_media_clear_escapes().is_none());
assert!(
agent.inline_video.is_none(),
"orphaned playback must be stopped by the drain"
);
crate::memory_release::run_deferred_release();
assert_eq!(test_support::calls(), before + 1);
// Nothing at all: the early no-placement return → no request.
let before = test_support::calls();
let _ = agent.take_inline_media_clear_escapes();
crate::memory_release::run_deferred_release();
assert_eq!(
test_support::calls(),
before,
"a no-op clear must not purge"
);
}
/// Installing freshly-extracted frames purges the PREVIOUS playback's
/// frame set (deferred), and never purges on first install.
#[test]
#[serial_test::serial(MEMORY_RELEASE_DEFER)]
fn replace_inline_video_defers_release_only_when_replacing() {
test_support::install_counting_hook();
crate::memory_release::run_deferred_release();
let mut agent = make_agent();
// First install: nothing drops → no request.
let before = test_support::calls();
agent.replace_inline_video(stub_inline_video());
crate::memory_release::run_deferred_release();
assert_eq!(
test_support::calls(),
before,
"first frame-set install drops nothing and must not purge"
);
// Replacement: the old frame set drops → deferred purge.
let before = test_support::calls();
agent.replace_inline_video(stub_inline_video());
assert_eq!(
test_support::calls(),
before,
"tick-path replacement must never purge synchronously"
);
crate::memory_release::run_deferred_release();
assert_eq!(test_support::calls(), before + 1);
}
/// Closing the image viewer drops the decoded overlay image — purge
/// synchronously (input path), exactly once.
#[test]
fn image_viewer_close_releases_retained_memory() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
test_support::install_counting_hook();
let mut agent = make_agent();
agent.image_viewer = Some(
crate::prompt_images::ImageViewerState::open_from_path_deferred(std::path::Path::new(
"x.png",
)),
);
let before = test_support::calls();
agent.handle_image_viewer_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(agent.image_viewer.is_none());
assert_eq!(
test_support::calls(),
before + 1,
"closing the image viewer must purge after the image drops"
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,362 @@
//! Transient user feedback: toasts, ephemeral tips, mode-switch banners,
//! terminal-size notes, clipboard-copy feedback, and their tick timers.
use super::{
ActivePane, AgentView, CLIPBOARD_TOAST_DEBOUNCE_MS, MODE_BANNER_TOTAL_TICKS, PromptInputMode,
};
#[cfg(test)]
use super::{AgentPane, test_fixtures};
use crate::app::actions::Action;
use std::time::Instant;
impl AgentView {
/// Show a brief toast message (e.g., "Copied!").
///
/// Displayed for ~3 seconds (90 ticks at 30fps). Previous transient toast
/// is replaced; [`Self::sticky_toast`] is preserved and returns after this
/// expires or is dismissed.
pub fn show_toast(&mut self, msg: &str) {
let msg = crate::glyphs::legacy_glyph_fallback(msg).into_owned();
self.toast = Some((msg, 90));
}
/// Show an ephemeral tip in the banner row above the prompt, gated by the
/// app-level per-session `seen_counts` map (`AppView::tip_seen_counts`).
/// Returns true when the tip was newly shown (and the per-session count
/// incremented in place — never persisted to disk).
///
/// No-op while the row cannot paint (an occluding view — permission,
/// question, modal, subagent takeover, fullscreen viewer, `/gboom`, the
/// extensions/agents modals, the goal-detail overlay, or an open prompt
/// dropdown — a short terminal, the unknown size before the first draw, or a
/// pending re-measure right after a resize event) so counts, TTL, and
/// telemetry never burn on an invisible tip.
pub fn show_ephemeral_tip(
&mut self,
tip: crate::tips::EphemeralTip,
seen_counts: &mut std::collections::HashMap<&'static str, u32>,
) -> bool {
if !self.ephemeral_tip_renderable(self.last_terminal_size.1) {
return false;
}
self.ephemeral_tip.show(tip, seen_counts)
}
/// Whether the tip row could paint right now (last drawn size). Lets
/// app-level triggers skip work that the show gate would refuse anyway.
pub(crate) fn ephemeral_tip_can_render(&self) -> bool {
self.ephemeral_tip_renderable(self.last_terminal_size.1)
}
/// Single definition of the agent-level eligibility for the clipboard-image
/// tip: the tip row can paint, no image chips are already attached, and the
/// current model accepts image input.
pub(crate) fn clipboard_image_tip_eligible(&self) -> bool {
self.ephemeral_tip_can_render()
&& self.prompt.images.is_empty()
&& self.session.models.current_model_accepts_images()
}
/// One-shot undo-tip show signal from the last `PromptWidget::handle_key`,
/// routed as an action so dispatch can reach `app.tip_seen_counts`. Fires
/// only on a qualifying wipe (set exclusively on `PromptEvent::Edited`).
pub(super) fn take_prompt_tip_signal(&mut self) -> Option<Action> {
// Undo (wipe-to-empty) takes precedence; the wipe and the typed-keyword
// nudge are mutually exclusive on a single keypress, so one
// `Option<Action>` suffices. The plan nudge is suppressed when already
// in plan mode (the optimistic read), while the turn is busy, or in a
// special prompt input mode (bash/feedback/remember).
if self.prompt.take_undo_tip_fire() {
return Some(Action::ShowUndoTip);
}
let in_plan = self.plan_mode_pending.unwrap_or(self.plan_mode_active);
if self.prompt.take_plan_nudge_fire()
&& !in_plan
&& self.session.state.is_idle()
&& self.prompt_input_mode == PromptInputMode::Normal
{
return Some(Action::ShowPlanNudge);
}
None
}
/// Whether the ephemeral tip needs tick / animation this frame.
/// Ambient tips freeze under EVERY occluder (permission ask, modal,
/// dropdown): their TTL burns only while the row can paint, so an
/// occluder pauses rather than expires them off-screen.
pub(crate) fn ephemeral_tip_needs_tick(&self) -> bool {
self.ephemeral_tip.is_active()
&& (!self.ephemeral_tip.active_is_ambient() || self.ephemeral_tip_can_render())
}
/// Advance tip TTL only when the tip is allowed to tick (see
/// [`Self::ephemeral_tip_needs_tick`]).
pub(crate) fn tick_ephemeral_tip(&mut self) -> bool {
// Word-select tip lifecycle: any prompt divergence since the tip was
// shown (typed, pasted, dropped — every edit path funnels into the
// prompt text) retires it, and the snapshot drops once the tip is
// gone for any reason. A visible tip is always ticking (it arms the
// metronome), so the sweep runs within a frame of the edit.
if self.ephemeral_tip.current_key() == Some(crate::tips::word_select::WORD_SELECT_TIP_KEY) {
if self.word_select_tip_prompt_snapshot.as_deref() != Some(self.prompt.text()) {
self.ephemeral_tip
.clear(crate::tips::word_select::WORD_SELECT_TIP_KEY);
self.word_select_tip_prompt_snapshot = None;
return true;
}
} else if self.word_select_tip_prompt_snapshot.is_some() {
self.word_select_tip_prompt_snapshot = None;
}
if !self.ephemeral_tip_needs_tick() {
return false;
}
self.ephemeral_tip.tick()
}
/// Unified visibility for the ephemeral tip row: no occluding view, a
/// tall-enough screen, and no resize since the height was measured. Shared
/// by the show gate and the draw path (reserve + paint), so a view opening
/// over an already-shown tip also stops the row's reservation until it
/// closes.
///
/// Most occluders leave an edit-contextual tip active with TTL still
/// burning (tip may repaint on close). AMBIENT tips freeze under any
/// occluder: paint yields **and** [`Self::tick_ephemeral_tip`] freezes TTL
/// so a long-lived occluder cannot burn the tip off-screen or keep
/// `needs_animation` hot.
///
/// An occluder is anything that, later in the same frame, keeps the banner
/// row from reaching the user. The transient mode-switch banner and the
/// inline `/btw` panel are deliberately NOT occluders: the banner owns the
/// slot ~2 s while the tip's TTL ticks, and `/btw` has its own layout slot
/// above the banner.
///
/// Drift warning: banner-covering views are also enumerated in two sibling
/// hand-maintained lists — the pre-overlay inline-media clear in `draw` and
/// the per-frame `frame_occluder_rects` (dropdowns + goal detail). A new
/// banner-covering view must be added here too.
pub(super) fn ephemeral_tip_renderable(&self, screen_height: u16) -> bool {
let occluded = !self.permission_queue.is_empty()
|| self.question_view.is_some()
|| self.active_modal.is_some()
// Subagent fullscreen takeover: draw early-returns into
// draw_subagent_fullscreen and never paints the parent banner.
|| self.active_subagent.is_some()
// Fullscreen viewers render after the banner paints: image/video/
// block dim the whole region down to the shortcuts row (banner
// included). line_viewer's overlay stops at turn_status.y when a
// turn status shows, so it does NOT always cover the banner — kept
// anyway as a safe over-refusal (the gate cannot know layout
// heights, and a tip during viewer reading is unwanted regardless).
|| self.line_viewer.is_some()
|| self.image_viewer.is_some()
|| self.video_viewer.is_some()
|| self.block_viewer.is_some()
// /gboom dims the same down-to-shortcuts region as the video viewer.
|| self.gboom.is_some()
// Extensions/agents modals are centered popups (render_modal_window)
// that capture all input and early-return out of draw; distinct
// from active_modal. persona_detail only renders atop the agents
// modal. A tip could at most peek beside the modal, so refuse.
|| self.extensions_modal.is_some()
|| self.agents_modal.is_some()
// Goal-detail is a vertically-centered overlay painted after the
// tip; its box only reaches the banner row for tall/content-rich
// goals, but kept unconditional as a safe over-refusal (like the
// modals and line_viewer) since a tip during goal reading is
// unwanted regardless.
|| (self.show_goal_detail && self.goal_state.is_some())
// Prompt dropdowns (@/slash/completion/history) render in the
// row directly above the prompt — the banner row — clearing it.
|| self.prompt.any_dropdown_open();
!self.terminal_size_stale && crate::tips::tip_row_renderable(occluded, screen_height)
}
/// Draw-path re-measure: record the size of the rect this view painted
/// into, invalidating Kitty image IDs when it changed (terminals clear
/// GPU data on resize), and mark the measurement fresh again.
///
/// Only draw calls this — the rect can be smaller than the terminal
/// (dashboard overlay header band/popup, dev tracing split), so a
/// resize event must NOT write an extrapolated size here; it flags
/// staleness via `note_terminal_resize` instead and the next draw
/// re-measures.
pub(crate) fn note_terminal_size(&mut self, size: (u16, u16)) {
if self.last_terminal_size != (0, 0) && self.last_terminal_size != size {
self.inline_media_ids.clear();
self.inline_media_iterm_emitted.clear();
crate::terminal::overlay::reset_owner();
}
self.last_terminal_size = size;
self.terminal_size_stale = false;
}
/// Event-path resize note: the terminal changed size, so the height in
/// `last_terminal_size` no longer describes what this view can paint —
/// chrome (dashboard overlay header/popup, dev tracing split) means the
/// view's rect is not derivable from the event's full-terminal size.
/// The ephemeral-tip show gate refuses until the next draw re-measures;
/// resize draws are debounced (`RESIZE_DEBOUNCE`), so that window is a
/// frame's worth of events, and a refusal burns nothing.
pub(crate) fn note_terminal_resize(&mut self) {
self.terminal_size_stale = true;
}
/// Set or clear the sticky status banner (process-wide indicators should
/// use [`Self::set_sticky_toast_recursive`] on every agent view).
pub fn set_sticky_toast(&mut self, msg: Option<&str>) {
self.sticky_toast = msg.map(|m| crate::glyphs::legacy_glyph_fallback(m).into_owned());
}
/// Propagate sticky status to this view and every nested subagent view.
pub fn set_sticky_toast_recursive(&mut self, msg: Option<&str>) {
self.set_sticky_toast(msg);
for child in self.subagent_views.values_mut() {
child.set_sticky_toast_recursive(msg);
}
}
/// Show a toast with an explicit tick duration.
pub fn show_toast_ticks(&mut self, msg: &str, ticks: u8) {
let msg = crate::glyphs::legacy_glyph_fallback(msg).into_owned();
self.toast = Some((msg, ticks));
}
/// Message currently drawn in the toast slot: transient wins while active,
/// otherwise sticky status (if any).
pub(super) fn active_toast_message(&self) -> Option<&str> {
if let Some((ref msg, _)) = self.toast {
return Some(msg.as_str());
}
let sticky = self.sticky_toast.as_deref()?;
// The mouse-off banner advertises how to re-enable. `Ctrl+R` only works
// from scrollback, so when the prompt is focused show the
// `/toggle-mouse-reporting` command instead (it toggles from any pane).
// Storage keeps the scrollback form; swap the displayed text here.
if sticky == crate::app::MOUSE_OFF_HINT_SCROLLBACK && self.active_pane == ActivePane::Prompt
{
return Some(crate::app::MOUSE_OFF_HINT_PROMPT);
}
Some(sticky)
}
/// Show a transient "Switched to mode: ..." banner above the prompt.
///
/// Triggered on Shift+Tab mode cycles.
/// Renders at full visibility for 2 s, then fades out over the final 0.3 s.
pub fn show_mode_switch_banner(&mut self, mode_name: &str) {
let msg = format!("Switched to mode: {}", mode_name);
self.mode_switch_banner = Some((msg, MODE_BANNER_TOTAL_TICKS));
}
/// Tick the mode-switch banner timer. Returns true if redraw needed
/// (active or just expired).
pub fn tick_mode_banner(&mut self) -> bool {
if let Some((_, ref mut remaining)) = self.mode_switch_banner {
if *remaining == 0 {
self.mode_switch_banner = None;
return true;
}
*remaining = remaining.saturating_sub(1);
return true; // redraw to advance fade
}
false
}
/// Copy text to clipboard and show the result toast.
pub fn copy_to_clipboard(&mut self, text: &str) -> bool {
let r = crate::clipboard::copy_text(text);
self.show_toast_ticks(r.message, r.ticks);
r.success
}
/// Like [`copy_to_clipboard`] but debounces the toast to prevent
/// rapid flickering during quick word/line selections.
pub(super) fn copy_to_clipboard_debounced(&mut self, text: &str) {
let now = Instant::now();
let too_soon = self
.last_clipboard_toast_at
.is_some_and(|t| now.duration_since(t).as_millis() < CLIPBOARD_TOAST_DEBOUNCE_MS);
if too_soon {
// Still copy, just skip the toast.
let _ = crate::clipboard::copy_text(text);
return;
}
self.last_clipboard_toast_at = Some(now);
self.copy_to_clipboard(text);
}
/// Returns `true` if the terminal can render pixel images. Shows a
/// toast and returns `false` when no graphics protocol is available.
pub(crate) fn guard_image_support(&mut self) -> bool {
if crate::terminal::image::detect_graphics_protocol().supports_images() {
return true;
}
let msg = match crate::terminal::terminal_context().graphics_protocol_skip_reason() {
Some("tmux") => "Inline images disabled within tmux.",
_ => "Image rendering not supported in this terminal",
};
self.show_toast_ticks(msg, 60);
false
}
/// Tick the transient toast timer. Call once per animation tick.
/// Returns true if the transient toast was removed (needs redraw so a
/// sticky banner can reappear).
pub fn tick_toast(&mut self) -> bool {
if let Some((_, ref mut remaining)) = self.toast {
if *remaining == 0 {
self.toast = None;
return true;
}
*remaining = remaining.saturating_sub(1);
}
false
}
/// Tick the extensions modal's transient result notice. Returns true if it
/// just expired (needs a redraw to erase the badge / status line).
pub fn tick_extensions_result_notice(&mut self) -> bool {
self.extensions_modal
.as_mut()
.is_some_and(|m| m.tick_result_notice())
}
}
#[cfg(test)]
mod mouse_off_banner_tests {
use super::test_fixtures::make_running_agent;
use super::*;
#[test]
fn mouse_off_banner_key_swaps_with_focused_pane() {
let mut view = make_running_agent();
view.set_sticky_toast(Some(crate::app::MOUSE_OFF_HINT_SCROLLBACK));
// Scrollback focus: Ctrl+R works there, so advertise it.
view.active_pane = AgentPane::Scrollback;
assert_eq!(
view.active_toast_message(),
Some(crate::app::MOUSE_OFF_HINT_SCROLLBACK)
);
// Prompt focus: the toggle chord is scrollback-only, so advertise the command.
view.active_pane = AgentPane::Prompt;
assert_eq!(
view.active_toast_message(),
Some(crate::app::MOUSE_OFF_HINT_PROMPT)
);
// A transient toast still wins over the sticky banner, regardless of pane.
view.show_toast("Copied!");
assert_eq!(view.active_toast_message(), Some("Copied!"));
}
#[test]
fn non_mouse_sticky_banner_is_not_swapped() {
let mut view = make_running_agent();
view.set_sticky_toast(Some("Reconnecting"));
view.active_pane = AgentPane::Prompt;
assert_eq!(view.active_toast_message(), Some("Reconnecting"));
}
}
@@ -0,0 +1,728 @@
//! Secondary pane input: scrollback keys and search, todo/tool-usage panes,
//! background tasks, subagent catalog, and the pane-aware scroll router.
use super::{ActivePane, AgentPane, AgentView, overlay_action_to_outcome, resolve_action};
use crate::actions::{ActionId, ActionRegistry, When};
use crate::app::actions::Action;
use crate::app::app_view::InputOutcome;
use crate::key;
use crate::scrollback::ScrollbackSearchState;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
impl AgentView {
/// Scrollback-focused key handling.
///
/// When the block viewer is open, routes keys to the viewer.
/// Otherwise, uses ActionRegistry for keybinding lookup.
pub(super) fn handle_scrollback_key(
&mut self,
key: &KeyEvent,
registry: &ActionRegistry,
) -> InputOutcome {
if let Some(outcome) = self.handle_scrollback_search_key(key) {
return outcome;
}
let viewer_has_input = self
.block_viewer
.as_ref()
.is_some_and(|v| v.list_state.input_mode().is_some());
let allow_i_alt = self.vim_mode;
if !viewer_has_input
&& (matches!(key.code, KeyCode::Tab | KeyCode::Char(' '))
|| (allow_i_alt && matches!(key.code, KeyCode::Char('i'))))
{
if self.question_view.is_some() {
self.set_active_pane(AgentPane::Prompt, false);
return InputOutcome::Changed;
}
if key.code == KeyCode::Tab
&& self.tasks.overlay.visible
&& self.set_active_pane(AgentPane::Tasks, false)
{
self.tasks.overlay.focused = true;
return InputOutcome::Changed;
}
return InputOutcome::Action(Action::FocusPrompt);
}
if key!(Enter).matches(key)
&& let Some(url) = self.highlighted_link_url().map(String::from)
{
self.highlighted_link_idx = None;
return InputOutcome::Action(Action::OpenUrl(url));
}
if key!(Enter).matches(key)
&& !self.scrollback.is_selected_group_header()
&& let Some(idx) = self.scrollback.selected()
&& self
.scrollback
.entry(idx)
.is_some_and(|e| e.block.is_user_prompt())
&& self.enter_inline_edit(idx)
{
return InputOutcome::Changed;
}
if key!(Enter).matches(key)
&& !self.scrollback.is_selected_group_header()
&& let Some(idx) = self.scrollback.selected()
&& let Some(entry) = self.scrollback.entry(idx)
&& let crate::scrollback::block::RenderBlock::Subagent(ref sb) = entry.block
{
let child_sid = sb.child_session_id.clone();
if self.subagent_views.contains_key(&child_sid) {
self.open_subagent_fullscreen(child_sid);
return InputOutcome::Changed;
}
}
if self.vim_mode
&& key!('x').matches(key)
&& !self.scrollback.is_selected_group_header()
&& let Some(idx) = self.scrollback.selected()
&& let Some(entry) = self.scrollback.entry(idx)
&& let crate::scrollback::block::RenderBlock::BgTask(ref bt) = entry.block
&& self
.session
.bg_tasks
.get(&bt.task_id)
.is_some_and(|t| t.status == crate::app::agent::BgTaskStatus::Running)
{
return InputOutcome::Action(Action::KillBgTask(bt.task_id.clone()));
}
if key.code == KeyCode::Esc
&& key.modifiers.is_empty()
&& self.persistent_text_selection.take().is_some()
{
self.table_selection_geometry = None;
self.selection_created_at = None;
return InputOutcome::Changed;
}
if key.code == KeyCode::Esc
&& key.modifiers.is_empty()
&& self.highlighted_link_idx.take().is_some()
{
return InputOutcome::Changed;
}
if self.vim_mode
&& key!('/').matches(key)
&& self.no_input_overlay_pending()
&& self.btw_state.is_none()
{
if self.scrollback.is_empty() {
return InputOutcome::ActionThenForward(Action::FocusPrompt);
}
self.open_scrollback_search(None);
return InputOutcome::Changed;
}
if key!('r', CONTROL).matches(key)
&& (registry.find(ActionId::ToggleMouseCapture).is_some()
|| crate::app::mouse_reporting_toggle_enabled())
{
return InputOutcome::Action(Action::ToggleMouseCapture);
}
if let Some(outcome) =
resolve_action(registry.lookup_with_mode(key, When::ScrollbackFocused, self.vim_mode))
{
return outcome;
}
if !self.vim_mode
&& let KeyCode::Char(c) = key.code
&& (c.is_ascii_alphabetic() || c == '/')
&& (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT)
{
return InputOutcome::ActionThenForward(Action::FocusPrompt);
}
InputOutcome::Unchanged
}
/// Focus the scrollback pane and open an incremental search over it.
///
/// Shared by the vim `/` key and the `/find` slash command so both entry
/// points land in the same state, refocusing scrollback when `/find` is run
/// from the prompt in simple mode.
///
/// Only opens the search if the pane switch succeeds: a dirty queued-prompt
/// edit blocks the switch (showing the confirm modal) and returns false, so
/// opening search then would strand an invisible session on the prompt — the
/// search bar and key handling are gated on scrollback being focused.
///
/// `initial_query` (the `/find <word>` argument) is fed through the same
/// keystroke path so a pre-filled search behaves identically to typing the
/// word into the bar: a composing regex query with immediate highlights.
pub(crate) fn open_scrollback_search(&mut self, initial_query: Option<&str>) {
if self.set_active_pane(AgentPane::Scrollback, false) {
self.scrollback_search = Some(ScrollbackSearchState::open());
if let Some(query) = initial_query {
self.set_scrollback_search_query(query);
}
}
}
/// Step to the next (`forward`) or previous match and scroll it into view.
/// Shared by the `n`/`N` keys and the `↓`/`↑` arrows.
fn navigate_search(&mut self, forward: bool) -> Option<InputOutcome> {
if let Some(search) = self.scrollback_search.as_mut() {
if forward {
search.next();
} else {
search.prev();
}
}
self.reveal_current_search_match();
Some(InputOutcome::Changed)
}
/// Bottom scrollback rows to reserve for the search UI (divider + bar):
/// two when search is active, clamped to the rows that actually exist so a
/// very short region never pushes the bar below the scrollback rect.
pub(super) fn search_reserved_rows(scrollback_height: u16, search_active: bool) -> u16 {
if search_active {
scrollback_height.min(2)
} else {
0
}
}
/// Handle a key while the scrollback search overlay is open.
///
/// Returns `None` when search isn't open (or, while browsing, for keys that
/// should fall through to normal scrollback handling). While composing the
/// query the bar is modal and swallows other keys.
fn handle_scrollback_search_key(&mut self, key: &KeyEvent) -> Option<InputOutcome> {
let composing = self.scrollback_search.as_ref()?.is_composing();
let non_text = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER;
if key.code == KeyCode::Esc {
self.scrollback_search = None;
return Some(InputOutcome::Changed);
}
if key.modifiers.is_empty() {
match key.code {
KeyCode::Down => return self.navigate_search(true),
KeyCode::Up => return self.navigate_search(false),
_ => {}
}
}
if composing {
match key.code {
KeyCode::Enter => {
if self.scrollback_search.as_ref()?.query().is_empty() {
self.scrollback_search = None;
} else {
if let Some(search) = self.scrollback_search.as_mut() {
search.accept();
}
self.reveal_current_search_match();
}
Some(InputOutcome::Changed)
}
KeyCode::Backspace => {
let mut q = self.scrollback_search.as_ref()?.query().to_string();
q.pop();
self.set_scrollback_search_query(&q);
Some(InputOutcome::Changed)
}
KeyCode::Char(c) if !key.modifiers.intersects(non_text) => {
let mut q = self.scrollback_search.as_ref()?.query().to_string();
q.push(c);
self.set_scrollback_search_query(&q);
Some(InputOutcome::Changed)
}
_ => Some(InputOutcome::Unchanged),
}
} else {
match key.code {
KeyCode::Char('n') if key.modifiers.is_empty() => self.navigate_search(true),
KeyCode::Char('N') if !key.modifiers.intersects(non_text) => {
self.navigate_search(false)
}
_ => None,
}
}
}
/// Enqueue `query` for the background scan. Results (and the reveal) arrive
/// later via [`poll_scrollback_search`](Self::poll_scrollback_search); the
/// highlight updates immediately because it reads the UI-side matcher.
fn set_scrollback_search_query(&mut self, query: &str) {
if let Some(search) = self.scrollback_search.as_mut() {
search.update_query(query, &self.scrollback);
}
}
/// Poll the background search daemon for new results, revealing the freshly
/// parked match when they change. Returns `true` if the UI should redraw.
pub(crate) fn poll_scrollback_search(&mut self) -> bool {
let changed = self.scrollback_search.as_mut().is_some_and(|s| s.poll());
if changed {
self.reveal_current_search_match();
}
changed
}
/// Scroll the current search match into view via `reveal_entry_line`.
fn reveal_current_search_match(&mut self) {
let target = self
.scrollback_search
.as_ref()
.and_then(|s| s.current())
.map(|m| (m.entry_id, m.line_in_entry));
if let Some((id, line)) = target
&& let Some(idx) = self.scrollback.index_of_id(id)
{
self.scrollback.reveal_entry_line(idx, line);
}
}
/// Todo-pane-focused key handling.
///
/// Routes structural keys through the shared overlay handler, then
/// content keys through `TodoPane::handle_key`.
pub(super) fn handle_todo_key(
&mut self,
key: &KeyEvent,
_registry: &ActionRegistry,
) -> InputOutcome {
use crate::views::overlay::{handle_overlay_key, handle_overlay_nav_key};
if key!('t', CONTROL).matches(key) {
self.todo.overlay.toggle();
self.todo.on_state_change();
if !self.todo.overlay.focused {
return InputOutcome::Action(Action::FocusScrollback);
}
return InputOutcome::Changed;
}
let has_input = self.todo.list_state.input_mode().is_some();
let action = handle_overlay_key(&mut self.todo.overlay, key).or_else(|| {
if !has_input {
handle_overlay_nav_key(&mut self.todo.overlay, key)
} else {
None
}
});
if let Some(action) = action {
self.todo.on_state_change();
if !self.todo.overlay.visible || !self.todo.overlay.focused {
self.set_active_pane(AgentPane::Scrollback, false);
}
return overlay_action_to_outcome(action);
}
if self.todo.handle_key(key) {
InputOutcome::Changed
} else {
InputOutcome::Unchanged
}
}
/// Bg-task-pane-focused key handling.
pub(super) fn handle_bg_tasks_key(
&mut self,
key: &KeyEvent,
_registry: &ActionRegistry,
) -> InputOutcome {
use crate::views::overlay::{handle_overlay_key, handle_overlay_nav_key};
use crate::views::tasks_pane::TaskEntry;
if key!('b', CONTROL).matches(key) {
self.tasks.overlay.toggle();
self.tasks.on_state_change();
if !self.tasks.overlay.focused {
return InputOutcome::Action(Action::FocusScrollback);
}
return InputOutcome::Changed;
}
if self.tasks.list_state.input_mode().is_none()
&& let Some(group) = self.tasks.selected_header_group()
{
if key!(Right).matches(key) {
self.tasks.set_group_collapsed(group, false);
return InputOutcome::Changed;
}
if key!(Left).matches(key) {
self.tasks.set_group_collapsed(group, true);
return InputOutcome::Changed;
}
}
let is_open_key = self.tasks.list_state.input_mode().is_none()
&& (key!(Enter).matches(key) || key!('f', CONTROL).matches(key));
if is_open_key {
if let Some(group) = self.tasks.selected_header_group() {
self.tasks.toggle_group(group);
return InputOutcome::Changed;
}
match self.tasks.selected_entry() {
Some(TaskEntry::BgTask { task_id, .. }) => {
let task_id = task_id.clone();
if let Some(task) = self.session.bg_tasks.get(&task_id) {
let entry_id = task
.scrollback_entry_id
.unwrap_or_else(|| crate::scrollback::entry::EntryId::new(0));
let is_running = task.status == crate::app::agent::BgTaskStatus::Running;
self.block_viewer =
Some(crate::views::block_viewer::BlockViewerPane::for_bg_task(
entry_id,
&task_id,
&task.stdout,
is_running,
));
self.set_active_pane(AgentPane::Scrollback, true);
return InputOutcome::Changed;
}
}
Some(TaskEntry::Agent {
child_session_id, ..
}) => {
let child_sid = child_session_id.clone();
if self.subagent_views.contains_key(&child_sid) {
self.open_subagent_fullscreen(child_sid);
return InputOutcome::Changed;
}
}
Some(TaskEntry::Scheduled { .. }) => {}
Some(TaskEntry::Header { .. }) => {}
None => {}
}
}
if key!('x').matches(key) && self.tasks.list_state.input_mode().is_none() {
match self.tasks.selected_entry() {
Some(TaskEntry::BgTask { task_id, .. }) => {
let task_id = task_id.clone();
if self
.session
.bg_tasks
.get(&task_id)
.is_some_and(|t| t.status == crate::app::agent::BgTaskStatus::Running)
{
return InputOutcome::Action(Action::KillBgTask(task_id));
}
}
Some(TaskEntry::Agent { subagent_id, .. }) => {
let subagent_id = subagent_id.clone();
if self.subagent_sessions.values().any(|s| {
s.subagent_id.as_ref() == subagent_id && s.is_running() && !s.pending_kill
}) {
return InputOutcome::Action(Action::KillSubagent(subagent_id));
}
}
Some(TaskEntry::Scheduled { task_id, .. }) => {
return InputOutcome::Action(Action::CancelScheduledTask(task_id.clone()));
}
Some(TaskEntry::Header { .. }) => {}
None => {}
}
}
if key!('y').matches(key)
&& self.tasks.list_state.input_mode().is_none()
&& let Some(task_id) = self.tasks.selected_task_id().map(|s| s.to_string())
&& let Some(task) = self.session.bg_tasks.get(&task_id)
&& !task.stdout.is_empty()
{
let text = task.stdout.clone();
self.copy_to_clipboard(&text);
return InputOutcome::Changed;
}
if key!(Tab).matches(key) && self.tasks.list_state.input_mode().is_none() {
self.tasks.overlay.focused = false;
return InputOutcome::Action(Action::FocusPrompt);
}
let has_input = self.tasks.list_state.input_mode().is_some();
let action = handle_overlay_key(&mut self.tasks.overlay, key).or_else(|| {
if !has_input {
handle_overlay_nav_key(&mut self.tasks.overlay, key)
} else {
None
}
});
if let Some(action) = action {
self.tasks.on_state_change();
if !self.tasks.overlay.visible || !self.tasks.overlay.focused {
self.set_active_pane(AgentPane::Scrollback, false);
}
return overlay_action_to_outcome(action);
}
if self.tasks.handle_key(key) {
InputOutcome::Changed
} else {
InputOutcome::Unchanged
}
}
/// Subagent-pane-focused key handling.
pub(super) fn handle_catalog_key(
&mut self,
key: &KeyEvent,
_registry: &ActionRegistry,
) -> InputOutcome {
use crate::views::overlay::{handle_overlay_key, handle_overlay_nav_key};
let has_input = self.catalog.list_state.input_mode().is_some();
let action = handle_overlay_key(&mut self.catalog.overlay, key).or_else(|| {
if !has_input {
handle_overlay_nav_key(&mut self.catalog.overlay, key)
} else {
None
}
});
if let Some(action) = action {
self.catalog.on_state_change();
if !self.catalog.overlay.visible || !self.catalog.overlay.focused {
self.set_active_pane(AgentPane::Scrollback, false);
}
return overlay_action_to_outcome(action);
}
if key.code == crossterm::event::KeyCode::Enter
&& key.modifiers == crossterm::event::KeyModifiers::NONE
{
if let Some((kind, name)) = self.catalog.selected_entry() {
return InputOutcome::Action(Action::ViewCatalogEntry {
kind: kind.to_owned(),
name: name.to_owned(),
});
}
return InputOutcome::Unchanged;
}
if self.catalog.handle_key(key) {
InputOutcome::Changed
} else {
InputOutcome::Unchanged
}
}
/// Handle a normalized scroll event at a screen position.
///
/// Hit-tests against pane areas to decide what to scroll:
/// - Scrollback area → scroll the scrollback (uses accelerated line count)
/// - Prompt area → forward to textarea (which has its own scroll logic)
///
/// Positive `lines` = scroll down, negative = scroll up.
pub fn handle_scroll(&mut self, lines: i32, col: u16, row: u16) {
if let Some(ref mut modal) = self.active_modal {
use crate::views::modal::ActiveModal;
match modal {
ActiveModal::CommandPalette { state, .. }
| ActiveModal::ArgPicker { state, .. }
| ActiveModal::SessionPicker { state, .. }
| ActiveModal::DocPicker { state, .. } => {
let delta = lines.unsigned_abs() as usize;
let current = state.scroll_offset.unwrap_or(0);
let new_offset = if lines > 0 {
current + delta
} else {
current.saturating_sub(delta)
};
state.scroll_offset = Some(new_offset);
state.hovered = None;
return;
}
ActiveModal::DocViewer { scroll, .. }
| ActiveModal::RememberNoteReview { scroll, .. } => {
crate::views::modal::apply_doc_scroll_delta(scroll, lines);
return;
}
_ => {}
}
}
if let Some(ref mut viewer) = self.block_viewer {
viewer.handle_scroll(lines);
return;
}
if self.rewind_state.is_some() {
if let Some(ref mut rw) = self.rewind_state {
crate::views::rewind::move_cursor(&mut rw.phase, lines.signum());
self.sync_rewind_anchor_to_picker();
}
return;
}
self.dismiss_jump_picker_if_suppressed();
if let Some(ref mut js) = self.jump_state {
crate::views::jump::move_cursor(js, lines.signum());
self.sync_jump_preview();
return;
}
if let Some(ref mut viewer) = self.line_viewer {
if let Some(area) = viewer.last_popup_area
&& area.contains((col, row).into())
{
viewer
.list_state
.handle_scroll_event(lines, col, row, &viewer.lines);
}
return;
}
if let Some(ref mut btw) = self.btw_state
&& matches!(btw, crate::views::btw_overlay::BtwOverlayState::Done { .. })
&& self.last_btw_area.area() > 0
&& self.last_btw_area.contains((col, row).into())
{
use crate::views::btw_overlay::DONE_MAX_BODY_LINES;
let max_body = DONE_MAX_BODY_LINES as usize;
let content_width = self.last_btw_area.width.saturating_sub(4) as usize;
let max_off = btw.max_scroll_offset(content_width, max_body);
if lines > 0 {
btw.scroll_down(lines as usize, max_off);
} else {
btw.scroll_up((-lines) as usize);
}
return;
}
if let Some(hd_area) = self.history_dropdown_area
&& hd_area.contains((col, row).into())
&& self.prompt.history_search.is_active()
{
let moved = if lines > 0 {
self.prompt.history_search.move_down()
} else if lines < 0 {
self.prompt.history_search.move_up()
} else {
false
};
if moved && self.prompt.history_search.is_browse() {
self.populate_prompt_from_history_selection();
}
return;
}
if let Some(dd_area) = self.dropdown_items_area
&& dd_area.contains((col, row).into())
{
self.prompt
.file_search
.move_selection(lines.signum() as isize);
return;
}
if let Some(dd_area) = self.slash_dropdown_items_area
&& dd_area.contains((col, row).into())
{
self.prompt.slash_scroll_selection(lines.signum() as isize);
self.prompt.slash_preview_current_selection();
return;
}
if let Some(dd_area) = self.completion_dropdown_items_area
&& dd_area.contains((col, row).into())
{
self.prompt
.completion_dropdown_scroll(lines.signum() as isize);
return;
}
if self.question_view.is_some() && self.pane_areas.prompt.contains((col, row).into()) {
if self
.inline_prompt_area
.is_some_and(|r| r.contains((col, row).into()))
{
let kind = if lines > 0 {
MouseEventKind::ScrollDown
} else {
MouseEventKind::ScrollUp
};
let event = MouseEvent {
kind,
column: col,
row,
modifiers: crossterm::event::KeyModifiers::NONE,
};
let _ = self.prompt.handle_mouse(&event);
} else if let Some((scroll_top, scroll_bottom)) = self.question_scroll_region
&& row >= scroll_top
&& row < scroll_bottom
{
self.apply_question_scroll(lines);
}
return;
}
let target = self
.pane_areas
.hit_test(col, row)
.unwrap_or(ActivePane::Scrollback);
match target {
ActivePane::Scrollback => {
if lines > 0 {
self.scrollback.scroll_down(lines as u16);
} else {
self.scrollback.scroll_up((-lines) as u16);
}
}
ActivePane::Todo => {
self.todo.handle_scroll(lines, col, row);
}
ActivePane::Queue => {
self.queue.handle_scroll(lines, col, row);
}
ActivePane::Tasks => {
self.tasks.handle_scroll(lines, col, row);
}
ActivePane::Catalog => {
self.catalog.handle_scroll(lines, col, row);
}
ActivePane::Prompt => {
if self.question_view.is_some() {
return;
}
let kind = if lines > 0 {
MouseEventKind::ScrollDown
} else {
MouseEventKind::ScrollUp
};
let event = MouseEvent {
kind,
column: col,
row,
modifiers: KeyModifiers::NONE,
};
self.prompt.handle_mouse(&event);
}
}
}
}
#[cfg(test)]
mod scroll_granularity_tests {
use super::super::test_fixtures::make_agent;
use crate::views::suggestion_controller::{
CompletionDropdownState, CompletionItemParsed, SuggestionSource,
};
use ratatui::layout::Rect;
/// Selection dropdowns step exactly one item per wheel dispatch: a
/// 3-line notch (or accelerated trackpad flush) must not skip items.
#[test]
fn wheel_notch_over_slash_dropdown_moves_selection_one_step() {
let mut agent = make_agent();
agent.prompt.set_text("/");
agent.prompt.refresh_slash(&agent.session.models);
assert!(agent.prompt.slash_open(), "precondition: dropdown open");
assert!(
agent.prompt.slash_snapshot().matches.len() >= 3,
"precondition: enough builtin commands to skip over"
);
assert_eq!(agent.prompt.slash_snapshot().selected, 0);
agent.slash_dropdown_items_area = Some(Rect::new(0, 0, 40, 8));
agent.handle_scroll(3, 5, 4);
assert_eq!(
agent.prompt.slash_snapshot().selected,
1,
"3-line wheel notch must move the slash selection by exactly 1"
);
agent.handle_scroll(-3, 5, 4);
assert_eq!(
agent.prompt.slash_snapshot().selected,
0,
"-3-line wheel notch must move the slash selection by exactly -1"
);
}
fn completion_item(label: &str) -> CompletionItemParsed {
CompletionItemParsed {
display: label.into(),
description: String::new(),
insert_text: label.into(),
source: SuggestionSource::History,
priority: 0,
replace_range: None,
token_text: None,
truncated: false,
}
}
#[test]
fn wheel_notch_over_completion_dropdown_moves_selection_one_step() {
let mut agent = make_agent();
agent.prompt.suggestions.dropdown = CompletionDropdownState {
open: true,
items: vec![
completion_item("a"),
completion_item("b"),
completion_item("c"),
],
selected: 0,
..Default::default()
};
agent.completion_dropdown_items_area = Some(Rect::new(0, 0, 40, 8));
agent.handle_scroll(3, 5, 4);
assert_eq!(
agent.prompt.suggestions.dropdown.selected, 1,
"3-line wheel notch must move the completion selection by exactly 1"
);
agent.handle_scroll(-3, 5, 4);
assert_eq!(
agent.prompt.suggestions.dropdown.selected, 0,
"-3-line wheel notch must move the completion selection by exactly -1"
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,855 @@
//! Plan surfaces: plan chip/preview, plan approval + feedback, and casual
//! plan commenting (incl. the casual-commenting test fixture).
use super::AgentView;
#[cfg(test)]
use super::{ActivePane, InputMode, test_fixtures};
#[cfg(test)]
use crate::actions::ActionRegistry;
use crate::app::actions::Action;
use crate::app::app_view::InputOutcome;
use crate::views::file_search::line_viewer::LineViewerState;
use crate::views::list_pane::ListItem;
use crate::views::plan_approval_view::{PlanApprovalFocus, PlanComment, PlanReviewSource};
use crate::views::prompt_widget::{EnterOutcome, PromptEvent};
#[cfg(test)]
use crossterm::event::KeyModifiers;
use crossterm::event::{KeyCode, KeyEvent};
impl AgentView {
/// Resolve the absolute path to the plan file for this session.
fn plan_file_path(&self) -> Option<std::path::PathBuf> {
let session_id = self.session.session_id.as_ref()?;
let cwd_str = self.session.cwd.to_string_lossy().into_owned();
let encoded_cwd = urlencoding::encode(&cwd_str);
Some(
kigi_shell::util::kigi_home::kigi_home()
.join("sessions")
.join(encoded_cwd.as_ref())
.join(session_id.0.as_ref())
.join("plan.md"),
)
}
/// Whether the current line viewer is showing a plan preview.
pub(super) fn is_plan_viewer(&self) -> bool {
self.line_viewer.as_ref().is_some_and(|v| {
v.kind == crate::views::file_search::line_viewer::LineViewerKind::PlanPreview
})
}
/// Whether the user is currently composing a comment via the prompt
/// input inside the *casual* plan preview (the modal opened with no
/// `plan_approval_view`). Mirrors the `pav.focus == Commenting`
/// check used by the plan-approval path so the prompt/footer
/// behaves identically across both modes.
pub(super) fn is_casual_commenting(&self) -> bool {
self.plan_approval_view.is_none()
&& self.is_plan_viewer()
&& self.casual_commenting_range.is_some()
}
/// Whether the prompt "auto" (LLM classifier mode) flag should render.
/// Extracted for unit testing the precedence: auto shows only when the
/// session is in auto mode and neither yolo (always-approve wins) nor plan
/// is active.
pub(super) fn auto_flag_visible(&self, effective_plan: bool) -> bool {
self.session.is_auto() && !self.session.is_yolo() && !effective_plan
}
/// Whether plan content is available for preview.
fn plan_preview_available(&self) -> bool {
self.plan_body_for_preview().is_some()
}
/// Whether the "plan" status-bar chip should be rendered.
///
/// Visible while plan mode is active, or always when the user has set
/// `show_plan_chip = true` in `pager.toml`. Hidden by default once the
/// user exits plan mode.
pub(super) fn should_show_plan_chip(
&self,
appearance: &crate::appearance::AppearanceConfig,
) -> bool {
(self.plan_mode_active || appearance.show_plan_chip) && self.plan_preview_available()
}
fn inline_plan_content(&self) -> Option<&str> {
self.plan_approval_view
.as_ref()
.filter(|p| p.source == PlanReviewSource::Inline)
.and_then(|p| p.plan_content.as_deref())
.filter(|s| !s.trim().is_empty())
}
/// Resolve the plan body for the line-viewer preview.
///
/// Prefers content carried on the approval request (inline plan-creation or
/// the shell-read file body), then falls back to the on-disk plan file.
/// Request body first keeps file-backed previews working when the path
/// resolution fails or the file disappears between intercept and open.
fn plan_body_for_preview(&self) -> Option<String> {
if let Some(content) = self
.plan_approval_view
.as_ref()
.and_then(|p| p.plan_content.as_deref())
.filter(|s| !s.trim().is_empty())
{
return Some(content.to_owned());
}
if let Some(content) = self
.latest_inline_plan_content
.as_deref()
.filter(|s| !s.trim().is_empty())
{
return Some(content.to_owned());
}
self.plan_file_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.filter(|s| !s.trim().is_empty())
}
/// Open the plan preview when content exists, or when plan approval is
/// parked with an empty body (so the decision surface always pops).
pub(crate) fn show_plan_preview_if_available(&mut self) {
if self.plan_preview_available() || self.plan_approval_view.is_some() {
self.show_plan_preview();
}
}
/// Show the plan in the line viewer overlay or a "no plan" toast.
///
/// When plan approval is parked without a body, opens a placeholder
/// preview so the user always sees a decision surface (a/s/q) instead of
/// a dead "Waiting on plan approval" line with a no-op Tab:plan.
pub fn show_plan_preview(&mut self) {
let body = self.plan_body_for_preview();
let approval_empty = self
.plan_approval_view
.as_ref()
.is_some_and(|p| !p.has_plan);
let Some(mut viewer) = (if let Some(content) = body {
LineViewerState::open_markdown_content("plan.md", content, None)
} else if approval_empty {
LineViewerState::open_markdown_content(
"plan.md",
crate::views::plan_approval_view::EMPTY_PLAN_PLACEHOLDER.to_owned(),
None,
)
} else if let Some(plan_path) = self.plan_file_path() {
LineViewerState::open_markdown(&plan_path, None)
} else {
None
}) else {
self.show_toast("No plan written yet.");
return;
};
viewer.kind = crate::views::file_search::line_viewer::LineViewerKind::PlanPreview;
viewer.title_override = Some(if approval_empty {
"plan.md (empty)".to_string()
} else {
"plan.md".to_string()
});
viewer.fullscreen = true;
{
let plan = viewer.plan_mut();
plan.show_action_buttons = self.plan_approval_view.is_none();
plan.feedback_active = self.plan_approval_view.is_some();
}
if let Some(ref pav) = self.plan_approval_view
&& !pav.comments.is_empty()
{
viewer.rebuild_with_comments(&pav.comments);
} else if !self.plan_comments.is_empty() {
viewer.rebuild_with_comments(&self.plan_comments);
}
self.line_viewer = Some(viewer);
}
/// Test fixture: drive the agent into casual-commenting state
/// (line viewer open in plan-preview mode + `casual_commenting_range`
/// armed) so the `Event::Paste` plan-feedback arm at ~1539 is
/// reachable from a unit test without spawning the real
/// keystroke pipeline. Consolidates three field mutations into
/// one helper so a future refactor of casual-commenting state
/// only has to update this fixture rather than every test that
/// reaches into the fields by name.
#[cfg(test)]
pub(crate) fn enter_casual_commenting_for_test(&mut self) {
let mut viewer =
crate::views::file_search::line_viewer::LineViewerState::open_markdown_content(
"test.md",
"hello\n".to_owned(),
None,
)
.expect("fixture must open the line viewer");
viewer.kind = crate::views::file_search::line_viewer::LineViewerKind::PlanPreview;
self.line_viewer = Some(viewer);
self.casual_commenting_range = Some(0..1);
}
pub(crate) fn approve_plan(&mut self) -> InputOutcome {
let Some(mut pav) = self.plan_approval_view.take() else {
return InputOutcome::Changed;
};
let review_comments = if !pav.comments.is_empty() {
let formatted = pav.format_feedback(None);
if formatted.trim().is_empty() {
None
} else {
Some(format!(
"The user approved the plan with the following review comments:\n\n{}",
formatted
))
}
} else {
None
};
pav.send_approved();
self.latest_inline_plan_content = None;
self.plan_next_comment_id = pav.next_comment_id;
self.prompt.restore(pav.stashed_prompt);
self.line_viewer = None;
self.casual_commenting_range = None;
self.casual_editing_comment_id = None;
{}
if let Some(text) = review_comments {
return InputOutcome::Action(Action::Interject {
text,
images: vec![],
});
}
InputOutcome::Changed
}
pub(crate) fn abandon_plan(&mut self) -> InputOutcome {
let Some(mut pav) = self.plan_approval_view.take() else {
return InputOutcome::Changed;
};
pav.send_abandoned();
self.plan_mode_pending = Some(false);
self.latest_inline_plan_content = None;
self.plan_next_comment_id = pav.next_comment_id;
self.prompt.restore(pav.stashed_prompt);
self.line_viewer = None;
self.casual_commenting_range = None;
self.casual_editing_comment_id = None;
{}
InputOutcome::Changed
}
fn send_plan_feedback(&mut self, feedback: Option<String>) -> InputOutcome {
let Some(mut pav) = self.plan_approval_view.take() else {
return InputOutcome::Changed;
};
let formatted = pav.format_feedback(feedback.as_deref());
let to_send = if formatted.trim().is_empty() {
feedback
} else {
Some(formatted)
};
if crate::app::minimal_mode_active()
&& let Some(msg) = to_send.as_deref().map(str::trim).filter(|s| !s.is_empty())
{
self.scrollback
.push_block(crate::scrollback::RenderBlock::user_prompt(msg.to_string()));
}
pav.send_cancelled(to_send);
if pav.source == PlanReviewSource::Inline {
self.latest_inline_plan_content = None;
}
self.plan_next_comment_id = pav.next_comment_id;
self.prompt.restore(pav.stashed_prompt);
self.line_viewer = None;
self.prompt.textarea.cancel_undo_group();
self.show_toast("Plan revision sent.");
{}
InputOutcome::Changed
}
pub(crate) fn reopen_plan_approval(&mut self) {
if let Some(ref mut pav) = self.plan_approval_view {
pav.stashed_prompt = self.prompt.stash();
pav.focus = PlanApprovalFocus::Preview;
}
self.prompt.set_text("");
self.show_plan_preview_if_available();
if self.line_viewer.is_none() {
if let Some(ref mut pav) = self.plan_approval_view {
pav.focus = PlanApprovalFocus::Prompt;
}
} else if let Some(ref mut viewer) = self.line_viewer {
viewer.plan_mut().feedback_active = true;
}
}
/// Discard an in-progress comment draft: clear the prompt text and
/// drop the selected line range + pending edit + stashed feedback.
/// Used whenever focus leaves the prompt without an explicit save
/// or cancel (e.g. Tab back to Preview, click into the modal).
fn discard_in_progress_comment(&mut self) {
if let Some(ref mut pav) = self.plan_approval_view {
pav.commenting_range = None;
pav.editing_comment_id = None;
pav.stashed_feedback_prompt = None;
}
self.prompt.set_text("");
}
pub(super) fn handle_plan_feedback_key(&mut self, key: &KeyEvent) -> InputOutcome {
let is_commenting = self
.plan_approval_view
.as_ref()
.is_some_and(|pav| pav.focus == PlanApprovalFocus::Commenting);
if key.code == KeyCode::Tab && key.modifiers.is_empty() {
let focus = self.plan_approval_view.as_ref().map(|p| p.focus);
match focus {
Some(PlanApprovalFocus::Prompt) | Some(PlanApprovalFocus::Commenting) => {
if self.line_viewer.is_none() {
self.show_plan_preview_if_available();
}
if let Some(ref mut pav) = self.plan_approval_view {
pav.focus = PlanApprovalFocus::Preview;
}
if let Some(ref mut viewer) = self.line_viewer {
viewer.plan_mut().feedback_active = true;
}
}
Some(PlanApprovalFocus::Preview) => {
if let Some(ref mut pav) = self.plan_approval_view {
pav.focus = PlanApprovalFocus::Prompt;
}
}
None => {}
}
if is_commenting {
self.discard_in_progress_comment();
}
return InputOutcome::Changed;
}
if key.code == KeyCode::Esc {
if self.prompt.file_search_visible() {
self.prompt.file_search.clear_context();
return InputOutcome::Changed;
}
if is_commenting {
let stashed = if let Some(ref mut pav) = self.plan_approval_view {
pav.focus = PlanApprovalFocus::Preview;
pav.editing_comment_id = None;
pav.commenting_range = None;
pav.stashed_feedback_prompt.take()
} else {
None
};
if let Some(stashed) = stashed {
self.prompt.restore(stashed);
} else {
self.prompt.set_text("");
}
return InputOutcome::Changed;
}
if let Some(ref mut pav) = self.plan_approval_view {
pav.focus = PlanApprovalFocus::Preview;
}
return InputOutcome::Changed;
}
match self.prompt.route_enter(key) {
EnterOutcome::NewlineInserted => return InputOutcome::Changed,
EnterOutcome::Submit => {
if is_commenting {
return self.save_plan_comment();
}
let text = self.prompt.text().to_string();
let has_comments = self
.plan_approval_view
.as_ref()
.is_some_and(|pav| !pav.comments.is_empty());
let prompt_focused = self
.plan_approval_view
.as_ref()
.is_some_and(|pav| pav.focus == PlanApprovalFocus::Prompt);
if prompt_focused {
if text.trim().is_empty() && !has_comments {
return self.approve_plan();
}
let freeform = if text.trim().is_empty() {
None
} else {
Some(text)
};
return self.send_plan_feedback(freeform);
}
return InputOutcome::Changed;
}
EnterOutcome::PassThrough => {}
}
match self.prompt.handle_key(key) {
PromptEvent::Edited => {
if let Some(req) = self.prompt.pending_viewer_request.take() {
self.open_line_viewer(&req.path, req.initial_range);
}
InputOutcome::Changed
}
PromptEvent::Ignored => InputOutcome::Changed,
}
}
pub(super) fn enter_plan_commenting(&mut self) -> InputOutcome {
let viewer = match self.line_viewer.as_mut() {
Some(v) => v,
None => return InputOutcome::Changed,
};
if let Some(vi) = viewer.list_state.selected_index() {
let pi = viewer.list_state.to_physical(vi);
if let Some(comment_id) = viewer.lines.get(pi).and_then(|item| item.comment_id())
&& let Some(pav) = self.plan_approval_view.as_mut()
&& let Some(comment) = pav.comments.iter().find(|c| c.id == comment_id)
{
let comment_text = comment.text.clone();
let comment_range = comment.line_range.clone();
pav.stashed_feedback_prompt = Some(self.prompt.stash());
pav.editing_comment_id = Some(comment_id);
pav.commenting_range = Some(comment_range);
pav.focus = PlanApprovalFocus::Commenting;
self.prompt.set_text(&comment_text);
return InputOutcome::Changed;
}
}
let range = viewer.selected_line_range();
let Some(range) = range else {
return InputOutcome::Changed;
};
if viewer.list_state.visual_mode {
let start_vi = viewer.list_state.multi_range().map(|r| r.start);
if let Some(start_vi) = start_vi {
let start_pi = viewer.list_state.to_physical(start_vi);
let start_id = viewer.lines.get(start_pi).map(|l| l.stable_id());
viewer.list_state.exit_visual_mode();
if let Some(id) = start_id {
viewer.list_state.select_by_id(id);
}
} else {
viewer.list_state.exit_visual_mode();
}
}
if let Some(ref mut pav) = self.plan_approval_view {
pav.stashed_feedback_prompt = Some(self.prompt.stash());
pav.commenting_range = Some(range);
pav.editing_comment_id = None;
pav.focus = PlanApprovalFocus::Commenting;
}
self.prompt.set_text("");
InputOutcome::Changed
}
fn save_plan_comment(&mut self) -> InputOutcome {
let text = self.prompt.text().to_string();
if text.trim().is_empty() {
return InputOutcome::Changed;
}
let pav = match self.plan_approval_view.as_mut() {
Some(pav) => pav,
None => return InputOutcome::Changed,
};
let range = match pav.commenting_range.take() {
Some(r) => r,
None => return InputOutcome::Changed,
};
if let Some(edit_id) = pav.editing_comment_id.take() {
if let Some(comment) = pav.comments.iter_mut().find(|c| c.id == edit_id) {
comment.text = text;
comment.line_range = range;
}
} else {
let id = pav.next_comment_id;
pav.next_comment_id += 1;
pav.comments.push(PlanComment {
id,
line_range: range,
text,
});
}
pav.focus = PlanApprovalFocus::Preview;
let comments = pav.comments.clone();
if let Some(ref mut viewer) = self.line_viewer {
viewer.rebuild_with_comments(&comments);
}
if let Some(stashed) = pav.stashed_feedback_prompt.take() {
self.prompt.restore(stashed);
} else {
self.prompt.set_text("");
}
InputOutcome::Changed
}
pub(super) fn delete_plan_comment_at_cursor(&mut self) -> InputOutcome {
let viewer = match self.line_viewer.as_ref() {
Some(v) => v,
None => return InputOutcome::Changed,
};
let vi = match viewer.list_state.selected_index() {
Some(vi) => vi,
None => return InputOutcome::Changed,
};
let pi = viewer.list_state.to_physical(vi);
let comment_id = match viewer.lines.get(pi).and_then(|item| item.comment_id()) {
Some(id) => id,
None => return InputOutcome::Changed,
};
if let Some(ref mut pav) = self.plan_approval_view {
pav.comments.retain(|c| c.id != comment_id);
let comments = pav.comments.clone();
if let Some(ref mut viewer) = self.line_viewer {
viewer.rebuild_with_comments(&comments);
}
}
InputOutcome::Changed
}
/// Enter casual commenting mode from the plan preview.
///
/// If the cursor is on a comment line, enter edit mode for that comment.
/// If the cursor is on a source line, capture the line range and enter
/// new-comment mode.
pub(super) fn enter_casual_plan_commenting(&mut self) -> InputOutcome {
let viewer = match self.line_viewer.as_mut() {
Some(v) => v,
None => return InputOutcome::Changed,
};
if let Some(vi) = viewer.list_state.selected_index() {
let pi = viewer.list_state.to_physical(vi);
if let Some(comment_id) = viewer.lines.get(pi).and_then(|item| item.comment_id())
&& let Some(comment) = self.plan_comments.iter().find(|c| c.id == comment_id)
{
let comment_text = comment.text.clone();
let comment_range = comment.line_range.clone();
if self.casual_stashed_prompt.is_none() {
self.casual_stashed_prompt = Some(self.prompt.stash());
}
self.casual_editing_comment_id = Some(comment_id);
self.casual_commenting_range = Some(comment_range);
self.prompt.set_text(&comment_text);
return InputOutcome::Changed;
}
}
let range = viewer.selected_line_range();
let Some(range) = range else {
return InputOutcome::Changed;
};
if viewer.list_state.visual_mode {
let start_vi = viewer.list_state.multi_range().map(|r| r.start);
if let Some(start_vi) = start_vi {
let start_pi = viewer.list_state.to_physical(start_vi);
let start_id = viewer.lines.get(start_pi).map(|l| l.stable_id());
viewer.list_state.exit_visual_mode();
if let Some(id) = start_id {
viewer.list_state.select_by_id(id);
}
} else {
viewer.list_state.exit_visual_mode();
}
}
if self.casual_stashed_prompt.is_none() {
self.casual_stashed_prompt = Some(self.prompt.stash());
}
self.casual_commenting_range = Some(range);
self.casual_editing_comment_id = None;
self.prompt.set_text("");
InputOutcome::Changed
}
/// Save the current casual comment (new or edited) and rebuild the viewer.
pub(super) fn save_casual_plan_comment(&mut self) -> InputOutcome {
let text = self.prompt.text().to_owned();
if text.trim().is_empty() {
return self.cancel_casual_plan_commenting();
}
let range = match self.casual_commenting_range.take() {
Some(r) => r,
None => return self.cancel_casual_plan_commenting(),
};
if let Some(edit_id) = self.casual_editing_comment_id.take() {
if let Some(comment) = self.plan_comments.iter_mut().find(|c| c.id == edit_id) {
comment.text = text;
comment.line_range = range;
}
} else {
let id = self.plan_next_comment_id;
self.plan_next_comment_id += 1;
self.plan_comments.push(PlanComment {
id,
line_range: range,
text,
});
}
if let Some(stashed) = self.casual_stashed_prompt.take() {
self.prompt.restore(stashed);
} else {
self.prompt.set_text("");
}
let comments = self.plan_comments.clone();
if let Some(ref mut viewer) = self.line_viewer {
viewer.rebuild_with_comments(&comments);
}
InputOutcome::Changed
}
/// Cancel casual plan commenting without saving.
pub(super) fn cancel_casual_plan_commenting(&mut self) -> InputOutcome {
self.casual_commenting_range = None;
self.casual_editing_comment_id = None;
if let Some(stashed) = self.casual_stashed_prompt.take() {
self.prompt.restore(stashed);
} else {
self.prompt.set_text("");
}
InputOutcome::Changed
}
/// Key handler used while the user is composing a casual plan
/// comment via the prompt input. Mirrors `handle_plan_feedback_key`
/// (which serves the plan-approval Commenting focus) so the UX is
/// identical: Enter saves, Esc cancels, Tab cancels back to the
/// modal, and everything else routes to the prompt textarea.
pub(super) fn handle_casual_plan_feedback_key(&mut self, key: &KeyEvent) -> InputOutcome {
if key.code == KeyCode::Esc {
if self.prompt.file_search_visible() {
self.prompt.file_search.clear_context();
return InputOutcome::Changed;
}
return self.cancel_casual_plan_commenting();
}
match self.prompt.route_enter(key) {
EnterOutcome::NewlineInserted => return InputOutcome::Changed,
EnterOutcome::Submit => return self.save_casual_plan_comment(),
EnterOutcome::PassThrough => {}
}
if key.code == KeyCode::Tab && key.modifiers.is_empty() {
return self.cancel_casual_plan_commenting();
}
match self.prompt.handle_key(key) {
PromptEvent::Edited => {
if let Some(req) = self.prompt.pending_viewer_request.take() {
self.open_line_viewer(&req.path, req.initial_range);
}
InputOutcome::Changed
}
PromptEvent::Ignored => InputOutcome::Changed,
}
}
/// Delete the casual comment under the cursor in the plan preview.
pub(super) fn delete_casual_plan_comment_at_cursor(&mut self) -> InputOutcome {
let viewer = match self.line_viewer.as_ref() {
Some(v) => v,
None => return InputOutcome::Unchanged,
};
let vi = match viewer.list_state.selected_index() {
Some(vi) => vi,
None => return InputOutcome::Unchanged,
};
let pi = viewer.list_state.to_physical(vi);
let comment_id = match viewer.lines.get(pi).and_then(|item| item.comment_id()) {
Some(id) => id,
None => return InputOutcome::Unchanged,
};
self.plan_comments.retain(|c| c.id != comment_id);
let comments = self.plan_comments.clone();
if let Some(ref mut viewer) = self.line_viewer {
viewer.rebuild_with_comments(&comments);
}
InputOutcome::Changed
}
pub(super) fn send_casual_plan_comments(&mut self) -> InputOutcome {
if self.plan_comments.is_empty() {
self.show_toast("No comments to send.");
return InputOutcome::Changed;
}
let plan_content = self.inline_plan_content().map(str::to_owned).or_else(|| {
let path = self.plan_file_path()?;
std::fs::read_to_string(path).ok()
});
let body = crate::views::plan_approval_view::format_plan_comments(
&self.plan_comments,
plan_content.as_deref(),
);
let text = format!("Plan feedback:\n\n{body}");
self.plan_comments.clear();
self.plan_next_comment_id = 0;
self.cancel_line_viewer();
self.show_toast("Plan feedback sent.");
InputOutcome::Action(Action::SendPrompt(text))
}
}
#[cfg(test)]
mod prompt_flag_tests {
use super::test_fixtures::make_agent;
/// The prompt "auto" (classifier) mode flag shows only when the session is
/// in Auto and neither yolo (always-approve wins) nor plan is active.
#[test]
fn auto_flag_visible_precedence() {
let mut agent = make_agent();
assert!(!agent.auto_flag_visible(false));
agent.session.auto_mode = true;
assert!(agent.auto_flag_visible(false));
assert!(!agent.auto_flag_visible(true));
agent.session.yolo_mode = true;
assert!(!agent.auto_flag_visible(false));
agent.session.yolo_mode = false;
assert!(agent.auto_flag_visible(false));
}
}
#[cfg(test)]
mod plan_chip_tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::agent::{AgentId, AgentSession, AgentState};
use crate::appearance::AppearanceConfig;
use crate::scrollback::state::ScrollbackState;
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,
credit_limit_blocked: false,
free_usage_blocked: 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(),
)
}
#[test]
fn plan_chip_hidden_after_exit_by_default() {
let mut agent = make_agent();
agent.plan_mode_active = false;
let appearance = AppearanceConfig::default();
assert!(!appearance.show_plan_chip);
assert!(!agent.should_show_plan_chip(&appearance));
}
#[test]
fn plan_chip_visible_while_plan_mode_active() {
let mut agent = make_agent();
agent.plan_mode_active = true;
let appearance = AppearanceConfig::default();
assert!(!agent.should_show_plan_chip(&appearance));
}
#[test]
fn plan_chip_visible_when_config_overrides() {
let mut agent = make_agent();
agent.plan_mode_active = false;
let appearance = AppearanceConfig {
show_plan_chip: true,
..Default::default()
};
assert!(!agent.should_show_plan_chip(&appearance));
}
#[test]
fn set_input_mode_vim_empty_prompt_switches_to_scrollback_and_j_selects_next() {
crate::appearance::cache::set_simple_mode(true);
let mut agent = make_agent();
agent.vim_mode = true;
agent.set_active_pane(ActivePane::Prompt, true);
agent.set_input_mode(InputMode::Vim);
assert_eq!(agent.active_pane, ActivePane::Scrollback);
assert!(!agent.is_simple_mode());
let registry = ActionRegistry::defaults();
let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
let outcome = agent.handle_scrollback_key(&j, &registry);
assert!(matches!(outcome, InputOutcome::Action(Action::SelectNext)));
}
#[test]
fn set_input_mode_vim_nonempty_prompt_keeps_pane() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
agent.prompt.set_text("draft");
agent.set_input_mode(InputMode::Vim);
assert_eq!(agent.active_pane, ActivePane::Prompt);
}
#[test]
fn set_input_mode_simple_from_scrollback_leaves_pane_unchanged() {
let mut agent = make_agent();
agent.vim_mode = true;
agent.set_active_pane(ActivePane::Scrollback, true);
agent.set_input_mode(InputMode::Simple);
assert_eq!(agent.active_pane, ActivePane::Scrollback);
assert!(agent.is_simple_mode());
let registry = ActionRegistry::defaults();
let x = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
let outcome = agent.handle_scrollback_key(&x, &registry);
assert_eq!(agent.active_pane, ActivePane::Scrollback);
assert!(matches!(outcome, InputOutcome::Unchanged));
}
#[test]
fn new_agent_respects_persisted_simple_mode_for_mode_and_pane() {
crate::appearance::cache::set_simple_mode(true);
let a1 = make_agent();
assert!(a1.is_simple_mode());
assert_eq!(a1.active_pane, ActivePane::Prompt);
crate::appearance::cache::set_simple_mode(false);
let a2 = make_agent();
assert!(!a2.is_simple_mode());
assert_eq!(a2.active_pane, ActivePane::Scrollback);
}
#[test]
fn set_input_mode_reconciles_pane_orthogonal_to_active_modal_field() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
agent.active_modal = None;
agent.set_input_mode(InputMode::Vim);
assert_eq!(agent.active_pane, ActivePane::Scrollback);
assert!(agent.active_modal.is_none());
}
#[test]
fn scrollback_j_with_vim_mode_off_forwards_to_prompt() {
crate::appearance::cache::set_vim_mode(false);
let mut agent = make_agent();
agent.vim_mode = false;
agent.set_active_pane(ActivePane::Scrollback, true);
let registry = ActionRegistry::defaults();
let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
let outcome = agent.handle_scrollback_key(&j, &registry);
assert!(
matches!(
outcome,
InputOutcome::ActionThenForward(Action::FocusPrompt)
),
"vim-off: bare 'j' in scrollback must forward to prompt; got {outcome:?}"
);
}
#[test]
fn scrollback_j_with_vim_mode_on_selects_next() {
crate::appearance::cache::set_vim_mode(true);
let mut agent = make_agent();
agent.vim_mode = true;
agent.set_active_pane(ActivePane::Scrollback, true);
let registry = ActionRegistry::defaults();
let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
let outcome = agent.handle_scrollback_key(&j, &registry);
assert!(
matches!(outcome, InputOutcome::Action(Action::SelectNext)),
"vim-on: bare 'j' in scrollback must dispatch SelectNext; got {outcome:?}"
);
}
#[test]
fn scrollback_arrow_down_works_in_both_modes() {
let registry = ActionRegistry::defaults();
let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
let mut a_off = make_agent();
a_off.vim_mode = false;
a_off.set_active_pane(ActivePane::Scrollback, true);
assert!(matches!(
a_off.handle_scrollback_key(&down, &registry),
InputOutcome::Action(Action::SelectNext)
));
let mut a_on = make_agent();
a_on.vim_mode = true;
a_on.set_active_pane(ActivePane::Scrollback, true);
assert!(matches!(
a_on.handle_scrollback_key(&down, &registry),
InputOutcome::Action(Action::SelectNext)
));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,330 @@
//! Rewind picker: anchor syncing, dim ranges, and key/mouse handling.
use super::AgentView;
use crate::app::actions::Action;
use crate::app::app_view::InputOutcome;
use crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
impl AgentView {
pub(super) fn sync_rewind_anchor_to_picker(&mut self) {
let prompt_index = {
let Some(ref rw) = self.rewind_state else {
return;
};
let crate::views::rewind::RewindPhase::Picker {
ref points,
selected,
} = rw.phase
else {
return;
};
let Some(point) = points.get(selected) else {
return;
};
point.prompt_index
};
let entry_idx = crate::app::dispatch::find_user_prompt_entry_for_shell_index(
&self.scrollback,
prompt_index,
);
if let Some(ref mut rw) = self.rewind_state {
rw.anchor_entry_idx = entry_idx.unwrap_or(0);
}
if let Some(idx) = entry_idx {
self.scrollback.scroll_to_entry_center(idx);
}
}
pub(super) fn rewind_dim_from_entry(&self) -> Option<usize> {
let rw = self.rewind_state.as_ref()?;
match &rw.phase {
crate::views::rewind::RewindPhase::Picker { .. }
| crate::views::rewind::RewindPhase::ModeSelect { .. }
| crate::views::rewind::RewindPhase::Previewing { .. }
| crate::views::rewind::RewindPhase::Confirm { .. }
| crate::views::rewind::RewindPhase::ConversationOnlyConfirm { .. }
| crate::views::rewind::RewindPhase::Executing { .. } => Some(rw.anchor_entry_idx),
crate::views::rewind::RewindPhase::Loading
| crate::views::rewind::RewindPhase::CancelOffer { .. }
| crate::views::rewind::RewindPhase::Error { .. } => None,
}
}
/// Refresh the scrollback's "awaiting user input" marks so the renderer
/// can swap the running-spinner bullet for a pulsing-circle bullet on
/// tool entries that are blocked on a permission prompt or
/// `ask_user_question`.
///
/// Recomputed every frame because the queue/question state is fully
/// owned by `AgentView` and changes asynchronously; doing a fresh
/// clear+rebuild keeps the mark and the view of record from drifting
/// out of sync (e.g. on Cancelled requests we never observe a
/// matching "pop" event).
///
/// Cheap: O(entries) for the clear plus O(permission_queue +
/// question_view) lookups via the tracker, both tiny in practice.
///
/// Called once per frame from `AgentView::draw` in the full TUI; minimal
/// mode bypasses that draw path, so its commit pass
/// ([`crate::minimal::commit::commit_active`]) calls this itself to keep a
/// tool blocked on a permission/question out of the committed frontier.
pub(crate) fn sync_pending_user_input_marks(&mut self) {
self.scrollback.clear_all_pending_user_input();
for perm in &self.permission_queue {
let tc_id = perm.request.request.tool_call.tool_call_id.0.as_ref();
if let Some(entry_id) = self.session.tracker.pending_tool_entry_id(tc_id) {
self.scrollback.set_pending_user_input(entry_id, true);
}
}
if let Some(qv) = self.question_view.as_ref()
&& let Some(entry_id) = self.session.tracker.pending_tool_entry_id(&qv.tool_call_id)
{
self.scrollback.set_pending_user_input(entry_id, true);
}
}
pub(super) fn handle_rewind_key(&mut self, key: &KeyEvent) -> InputOutcome {
let Some(ref state) = self.rewind_state else {
return InputOutcome::Unchanged;
};
let input = crate::views::rewind::handle_rewind_key(state, key);
match input {
crate::views::rewind::RewindInput::MoveUp => {
if let Some(ref mut rw) = self.rewind_state {
crate::views::rewind::move_cursor(&mut rw.phase, -1);
self.sync_rewind_anchor_to_picker();
}
InputOutcome::Changed
}
crate::views::rewind::RewindInput::MoveDown => {
if let Some(ref mut rw) = self.rewind_state {
crate::views::rewind::move_cursor(&mut rw.phase, 1);
self.sync_rewind_anchor_to_picker();
}
InputOutcome::Changed
}
crate::views::rewind::RewindInput::ConfirmCursor => {
let Some(ref state) = self.rewind_state else {
return InputOutcome::Unchanged;
};
let resolved = crate::views::rewind::confirm_cursor(&state.phase);
Self::rewind_input_to_outcome(resolved)
}
other => Self::rewind_input_to_outcome(other),
}
}
/// Map a terminal `RewindInput` (one that doesn't itself move the cursor)
/// to the corresponding `InputOutcome`. Shared by the key and mouse paths
/// so the two can't drift.
fn rewind_input_to_outcome(input: crate::views::rewind::RewindInput) -> InputOutcome {
use crate::views::rewind::RewindInput;
match input {
RewindInput::Dismissed => InputOutcome::Action(Action::RewindDismiss),
RewindInput::CancelTurnThenProceed => InputOutcome::Action(Action::RewindCancelOffer),
RewindInput::SelectMode(mode, target) => {
InputOutcome::Action(Action::RewindSelectMode(mode, target))
}
RewindInput::Confirm(target, mode) => {
InputOutcome::Action(Action::RewindConfirm(target, mode))
}
RewindInput::BackToModeSelect => InputOutcome::Action(Action::RewindBackToModeSelect),
RewindInput::DismissError => InputOutcome::Action(Action::RewindDismissError),
RewindInput::ConversationOnlyConfirm(target) => {
InputOutcome::Action(Action::RewindConversationOnlyConfirm(target))
}
RewindInput::PickerSelect(prompt_index) => {
InputOutcome::Action(Action::RewindPickerSelect(prompt_index))
}
RewindInput::MoveUp
| RewindInput::MoveDown
| RewindInput::ConfirmCursor
| RewindInput::Consumed => InputOutcome::Changed,
}
}
/// Mouse handler for the rewind overlay. `Moved` moves the cursor
/// (`selected` for picker, `active_idx` for radio phases) and syncs
/// the scrollback preview on the picker. `Down(Left)` either
/// dispatches a synthesized key (radio) or `PickerSelect` (picker).
/// Mouse handler for the rewind overlay. `Moved` moves the cursor
/// to the row under the pointer; `Down(Left)` moves the cursor then
/// activates that row (Enter-equivalent). Geometry comes from
/// `rewind_row_at`, which mirrors `render_rewind_overlay`'s layout.
pub(super) fn handle_rewind_mouse(&mut self, mouse: &MouseEvent) -> InputOutcome {
use crate::views::rewind::{rewind_activate, rewind_row_at, set_rewind_cursor};
let Some(rw) = self.rewind_state.as_mut() else {
return InputOutcome::Unchanged;
};
let area = self.pane_areas.prompt;
let Some(idx) = rewind_row_at(&rw.phase, area, mouse.column, mouse.row) else {
return InputOutcome::Unchanged;
};
match mouse.kind {
MouseEventKind::Moved => {
if set_rewind_cursor(&mut rw.phase, idx) {
InputOutcome::Changed
} else {
InputOutcome::Unchanged
}
}
MouseEventKind::Down(MouseButton::Left) => {
set_rewind_cursor(&mut rw.phase, idx);
let is_picker =
matches!(rw.phase, crate::views::rewind::RewindPhase::Picker { .. });
let activated = rewind_activate(&rw.phase);
if is_picker {
self.sync_rewind_anchor_to_picker();
}
Self::rewind_input_to_outcome(activated)
}
_ => InputOutcome::Unchanged,
}
}
}
#[cfg(test)]
mod sync_rewind_anchor_to_picker_tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::agent::{AgentId, AgentSession, AgentState};
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::UserPromptBlock;
use crate::scrollback::state::ScrollbackState;
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,
credit_limit_blocked: false,
free_usage_blocked: 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(),
)
}
fn user_block(text: &str, pi: Option<usize>) -> RenderBlock {
let mut b = UserPromptBlock::new(text);
b.prompt_index = pi;
RenderBlock::UserPrompt(b)
}
fn run_with_indices(prompt_indices: [Option<usize>; 3]) -> (AgentView, usize, usize, usize) {
let mut agent = make_agent();
let alpha = agent
.scrollback
.push_block(user_block("alpha", prompt_indices[0]));
agent.scrollback.push_block(RenderBlock::agent_message("a"));
let bravo = agent
.scrollback
.push_block(user_block("bravo", prompt_indices[1]));
agent.scrollback.push_block(RenderBlock::agent_message("b"));
let charlie = agent
.scrollback
.push_block(user_block("charlie", prompt_indices[2]));
agent.scrollback.push_block(RenderBlock::agent_message("c"));
let alpha_idx = agent.scrollback.index_of_id(alpha).unwrap();
let bravo_idx = agent.scrollback.index_of_id(bravo).unwrap();
let charlie_idx = agent.scrollback.index_of_id(charlie).unwrap();
(agent, alpha_idx, bravo_idx, charlie_idx)
}
fn set_selected(agent: &mut AgentView, sel: usize) {
use crate::views::rewind::RewindPhase;
if let Some(rw) = agent.rewind_state.as_mut()
&& let RewindPhase::Picker { selected, .. } = &mut rw.phase
{
*selected = sel;
}
}
fn install_picker(agent: &mut AgentView) {
use crate::views::rewind::{RewindPhase, RewindPointInfo, RewindState};
let pt = |pi: usize, preview: &str| RewindPointInfo {
prompt_index: pi,
created_at: String::new(),
num_file_snapshots: 0,
has_file_changes: false,
prompt_preview: Some(preview.into()),
};
let points = vec![pt(2, "charlie"), pt(1, "bravo"), pt(0, "alpha")];
agent.rewind_state = Some(RewindState {
phase: RewindPhase::Picker {
points,
selected: 0,
},
anchor_entry_idx: 0,
stashed_draft: None,
selected_prompt_index: None,
});
}
#[test]
fn anchor_tracks_each_picker_row_when_prompt_index_is_set() {
let (mut agent, alpha_idx, bravo_idx, charlie_idx) =
run_with_indices([Some(0), Some(1), Some(2)]);
install_picker(&mut agent);
agent.sync_rewind_anchor_to_picker();
assert_eq!(
agent.rewind_state.as_ref().unwrap().anchor_entry_idx,
charlie_idx,
"selected=0 → charlie"
);
set_selected(&mut agent, 1);
agent.sync_rewind_anchor_to_picker();
assert_eq!(
agent.rewind_state.as_ref().unwrap().anchor_entry_idx,
bravo_idx,
"selected=1 → bravo"
);
set_selected(&mut agent, 2);
agent.sync_rewind_anchor_to_picker();
assert_eq!(
agent.rewind_state.as_ref().unwrap().anchor_entry_idx,
alpha_idx,
"selected=2 → alpha"
);
}
#[test]
fn anchor_tracks_each_picker_row_when_prompt_index_is_missing() {
let (mut agent, alpha_idx, bravo_idx, charlie_idx) = run_with_indices([None, None, None]);
install_picker(&mut agent);
agent.sync_rewind_anchor_to_picker();
assert_eq!(
agent.rewind_state.as_ref().unwrap().anchor_entry_idx,
charlie_idx,
"fallback: selected=0 → charlie"
);
set_selected(&mut agent, 1);
agent.sync_rewind_anchor_to_picker();
assert_eq!(
agent.rewind_state.as_ref().unwrap().anchor_entry_idx,
bravo_idx,
"fallback: selected=1 → bravo (regression: was alpha before fix)"
);
set_selected(&mut agent, 2);
agent.sync_rewind_anchor_to_picker();
assert_eq!(
agent.rewind_state.as_ref().unwrap().anchor_entry_idx,
alpha_idx,
"fallback: selected=2 → alpha"
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,923 @@
//! Bash-mode shell completion: the always-on Tab surface (deterministic
//! fetch arming, terminal Tab semantics execution) and the dropdown accept
//! path shared by Tab/Enter/mouse.
#[cfg(test)]
use super::test_fixtures;
use super::{AgentView, PromptInputMode};
use crate::views::suggestion_controller::TabAction;
impl AgentView {
/// Accept the selected completion-dropdown item into the prompt (the
/// what-to-write policy lives in `CompletionSplice`). Returns whether
/// the key was consumed; `false` only for the empty-items race (callers
/// keep their close-and-fall-through arm).
pub(in crate::app) fn accept_completion_dropdown_item(&mut self) -> bool {
let had_items = !self.prompt.suggestions.dropdown.items.is_empty();
// The SELECTED splice would clip an atomic element (paste chip):
// committing would consume the candidates and then be declined by
// the write path — honest no-op instead (nothing safe to write;
// the dropdown stays up so another selection can still accept).
if self.prompt.completion_accept_would_clip_element() {
return true;
}
let Some(splice) = self.prompt.completion_dropdown_accept() else {
// Stale-generation refusal closed the dropdown; swallow the key
// (the refreshed fetch is in flight) instead of falling through
// to focus-cycling or send.
return had_items;
};
if self.prompt.apply_completion_splice(splice) {
self.prompt_input_mode = PromptInputMode::Bash;
// Re-fetch for the accepted text so accepting a directory
// (trailing `/`) lets the NEXT Tab complete inside it.
self.kick_shell_suggest_refetch();
}
true
}
/// Terminal-like Tab over a closed dropdown's completion items: decide
/// via `SuggestionController::tab_decision`, then execute. Used by the
/// pending-Tab landing (where `Nothing` — stale/empty items — must do
/// nothing rather than fetch again).
pub(in crate::app) fn shell_completion_tab(&mut self) {
let action = self
.prompt
.suggestions
.tab_decision(self.prompt.text(), self.prompt.cursor());
self.execute_tab_action(action);
}
/// View-side executor for a [`TabAction`] (the policy lives in the
/// controller's `tab_decision`).
pub(super) fn execute_tab_action(&mut self, action: TabAction) {
match action {
TabAction::InstaAccept => {
// A splice clipping an atomic element (paste chip) would be
// declined AFTER the accept consumed the sole candidate —
// every Tab would then refetch the same set. Show it instead.
if self.prompt.completion_accept_would_clip_element() {
self.prompt.completion_dropdown_open_if_available();
} else {
self.accept_completion_dropdown_item();
}
}
TabAction::Fill(range, fill) => {
if self.prompt.apply_completion_fill(range, &fill) {
// A fill is typing: refresh the candidate set for the longer
// token (the next Tab opens the dropdown on the refreshed set).
self.kick_shell_suggest_refetch();
} else {
// Declined (range clips an atomic element): show the
// candidates instead of respinning fill+refetch every Tab.
self.prompt.completion_dropdown_open_if_available();
}
}
TabAction::Open => {
self.prompt.completion_dropdown_open_if_available();
}
TabAction::Nothing => {}
}
}
/// Fire a deterministic (`includeAi: false`) completion fetch for the
/// current draft, bypassing the env-gated as-you-type debounce — the
/// always-on Tab path. `run_tab_on_load` makes the landing response run
/// the terminal Tab semantics once (a Tab that found no usable items
/// still completes when its candidates arrive).
pub(super) fn request_shell_tab_completion(&mut self, run_tab_on_load: bool) {
// Repeat Tab while the armed fetch is still in flight: keep the
// marker (its landing runs the Tab semantics) — no second RPC.
if run_tab_on_load && self.prompt.suggestions.tab_fetch_pending() {
return;
}
let generation = self
.prompt
.suggestions
.begin_tab_completion(run_tab_on_load);
self.pending_effects
.push(super::actions::Effect::FetchShellSuggestions {
agent_id: self.session.id,
text: self.prompt.text().to_owned(),
cursor: self.prompt.cursor(),
cwd: self.session.cwd.to_string_lossy().into_owned(),
generation,
limit: crate::views::suggestion_controller::SHELL_SUGGEST_WIRE_LIMIT,
include_ai: false,
ai_model: None,
session_id: self.session.session_id.as_ref().map(|s| s.0.to_string()),
// Deterministic Tab surface: token providers only (a
// history row would make the set mixed and kill
// insta-accept/LCP).
token_only: true,
});
}
/// Refresh the candidate set after an accept or a prefix fill changed
/// the draft: through the debounced as-you-type pipeline when enabled,
/// else a direct deterministic fetch. Either way the refreshed items
/// land silently and the NEXT Tab consumes them.
fn kick_shell_suggest_refetch(&mut self) {
if self.prompt.suggestions.enabled {
if let Some(eff) = self.notify_suggestion_text_changed() {
self.pending_effects.push(eff);
}
} else {
self.request_shell_tab_completion(false);
}
}
}
#[cfg(test)]
mod shell_suggestion_key_tests {
use super::*;
use crate::app::actions::{Action, Effect};
use crate::app::app_view::InputOutcome;
use crate::views::suggestion_controller::{CompletionItemParsed, SuggestionSource};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
/// Wire-shaped token item: `insert_text` is the compat whole line,
/// `token_text` the span replacement (what a new shell sends).
fn token_item(line: &str, token: &str, range: std::ops::Range<usize>) -> CompletionItemParsed {
CompletionItemParsed {
display: token.to_owned(),
description: String::new(),
insert_text: line.to_owned(),
source: SuggestionSource::PathExecutable,
priority: 0,
replace_range: Some(range),
token_text: Some(token.to_owned()),
truncated: false,
}
}
fn item(insert: &str, range: Option<std::ops::Range<usize>>) -> CompletionItemParsed {
CompletionItemParsed {
display: insert.to_owned(),
description: String::new(),
insert_text: insert.to_owned(),
source: SuggestionSource::PathExecutable,
priority: 0,
replace_range: range,
token_text: None,
truncated: false,
}
}
/// Wire-shaped FILE token item (what the file provider sends).
fn file_item(line: &str, token: &str, range: std::ops::Range<usize>) -> CompletionItemParsed {
CompletionItemParsed {
display: token.to_owned(),
description: String::new(),
insert_text: line.to_owned(),
source: SuggestionSource::FilePath,
priority: 0,
replace_range: Some(range),
token_text: Some(token.to_owned()),
truncated: false,
}
}
/// Whole-line history item (insert_text doubles as the span replacement).
fn history_item(line: &str, range: std::ops::Range<usize>) -> CompletionItemParsed {
CompletionItemParsed {
display: line.to_owned(),
description: String::new(),
insert_text: line.to_owned(),
source: SuggestionSource::History,
priority: 10,
replace_range: Some(range),
token_text: None,
truncated: false,
}
}
/// Bash-mode agent with the env-gated as-you-type pipeline ON and
/// `text` typed (the dropdown's request-text anchor pinned to it — the
/// state right after a suggest response landed for the draft).
fn bash_agent(text: &str) -> AgentView {
let mut agent = bash_agent_always_on(text);
agent.prompt.suggestions.enabled = true;
agent
}
/// Same, with the pipeline OFF (`KIGI_SUGGESTIONS` unset) — the
/// always-on Tab surface under test.
fn bash_agent_always_on(text: &str) -> AgentView {
let mut agent = super::test_fixtures::make_agent();
agent.prompt_input_mode = PromptInputMode::Bash;
agent.prompt.suggestions.enabled = false;
agent.prompt.textarea.insert_str(text);
agent.prompt.suggestions.dropdown.request_text = text.to_owned();
agent.prompt.suggestions.dropdown.request_cursor = text.len();
agent
}
/// THE acceptance regression: accepting a $PATH item after `ls | gr`
/// edits the token in place — never replaces the whole line with `grep`.
#[test]
fn dropdown_tab_accept_replaces_token_in_place() {
let mut agent = bash_agent("ls | gr");
agent.prompt.suggestions.dropdown.open = true;
agent.prompt.suggestions.dropdown.items = vec![token_item("ls | grep", "grep", 5..7)];
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.text(), "ls | grep");
assert_eq!(agent.prompt.cursor(), "ls | grep".len());
assert_eq!(agent.prompt_input_mode, PromptInputMode::Bash);
assert!(!agent.prompt.completion_dropdown_open());
}
/// Enter accepts the same way (both arms share the accept helper).
#[test]
fn dropdown_enter_accept_replaces_token_in_place() {
let mut agent = bash_agent("ls | gr");
agent.prompt.suggestions.dropdown.open = true;
agent.prompt.suggestions.dropdown.items = vec![token_item("ls | grep", "grep", 5..7)];
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Enter));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.text(), "ls | grep");
assert_eq!(agent.prompt_input_mode, PromptInputMode::Bash);
}
/// The accept works identically with the as-you-type pipeline OFF —
/// in-place acceptance is not env-gated.
#[test]
fn dropdown_accept_works_without_env_flag() {
let mut agent = bash_agent_always_on("ls | gr");
agent.prompt.suggestions.dropdown.open = true;
agent.prompt.suggestions.dropdown.items = vec![token_item("ls | grep", "grep", 5..7)];
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.text(), "ls | grep");
}
/// A ranged item whose range no longer fits the draft is a NO-OP accept
/// — the draft survives untouched, the dropdown closes, the key is
/// consumed (never a whole-line clobber, never a send).
#[test]
fn dropdown_accept_stale_range_is_a_draft_preserving_noop() {
let mut agent = bash_agent("ls | gr");
agent.prompt.set_text("totally different");
agent.prompt.suggestions.dropdown.open = true;
agent.prompt.suggestions.dropdown.items = vec![token_item("ls | grep", "grep", 5..7)];
// Pass the generation gate (`set_text` bumped it) so this pins the
// range-validation no-op, not the staleness gate. The "ls | gr"
// anchor from `bash_agent` survives the swap (close() keeps it).
agent.prompt.suggestions.dropdown.generation = agent.prompt.suggestions.generation();
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.text(), "totally different");
assert!(!agent.prompt.completion_dropdown_open());
}
/// Items populated for a superseded generation refuse the accept
/// wholesale: dropdown closes, draft untouched, Enter does not fall
/// through to send.
#[test]
fn dropdown_accept_stale_generation_is_a_noop() {
let mut agent = bash_agent("ls | gr");
agent.prompt.suggestions.dropdown.open = true;
agent.prompt.suggestions.dropdown.items = vec![token_item("ls | grep", "grep", 5..7)];
// A newer edit bumped the controller past the items' generation.
agent.prompt.suggestions.dropdown.generation = 3;
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Enter));
assert!(
matches!(outcome, InputOutcome::Changed),
"stale accept must consume the key, got {outcome:?}"
);
assert_eq!(agent.prompt.text(), "ls | gr");
assert!(!agent.prompt.completion_dropdown_open());
}
/// Rangeless items (older shells) keep the whole-line behavior.
#[test]
fn dropdown_accept_without_range_sets_whole_line() {
let mut agent = bash_agent("git st");
agent.prompt.suggestions.dropdown.open = true;
agent.prompt.suggestions.dropdown.items = vec![item("git status --porcelain", None)];
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert_eq!(agent.prompt.text(), "git status --porcelain");
assert_eq!(agent.prompt.cursor(), agent.prompt.text().len());
}
/// Tab opens the dropdown whenever items exist — a ghost is NOT required
/// (pure path/file completions never carry one). Two candidates with no
/// shared prefix beyond the typed token = the plain-open path (a single
/// candidate insta-accepts instead — see the terminal-Tab tests below).
#[test]
fn tab_opens_dropdown_without_ghost() {
let mut agent = bash_agent("ls | gr");
agent.prompt.suggestions.dropdown.items =
vec![item("grep", Some(5..7)), item("grip", Some(5..7))];
assert!(!agent.prompt.has_ghost_text());
assert!(!agent.prompt.completion_dropdown_open());
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert!(agent.prompt.completion_dropdown_open());
assert_eq!(
agent.prompt.text(),
"ls | gr",
"no fill without a longer LCP"
);
}
// -- always-on Tab fetch (no KIGI_SUGGESTIONS) --------------------------
/// Tab in bash mode with no fetched candidates fires a deterministic
/// fetch — no env flag, no AI, dropdown-scale limit.
#[test]
fn tab_without_items_fires_deterministic_fetch() {
let mut agent = bash_agent_always_on("cat no");
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
let fetch = agent.pending_effects.iter().find_map(|e| match e {
Effect::FetchShellSuggestions {
include_ai,
generation,
limit,
text,
token_only,
..
} => Some((*include_ai, *generation, *limit, text.clone(), *token_only)),
_ => None,
});
let (include_ai, generation, limit, text, token_only) =
fetch.expect("Tab must fire a fetch");
assert!(!include_ai, "Tab completion is deterministic (no AI)");
assert!(token_only, "Tab fetches run only the token providers");
assert_eq!(limit, 50);
assert_eq!(text, "cat no");
assert_eq!(generation, agent.prompt.suggestions.generation());
}
/// Repeat Tab while the armed fetch is still in flight is a no-op: one
/// RPC, one landing that runs the Tab semantics once.
#[test]
fn repeat_tab_fires_single_fetch_while_pending() {
let mut agent = bash_agent_always_on("cat no");
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
let fetches = agent
.pending_effects
.iter()
.filter(|e| matches!(e, Effect::FetchShellSuggestions { .. }))
.count();
assert_eq!(fetches, 1, "the second Tab must not fire a second RPC");
assert!(
agent.prompt.suggestions.tab_fetch_pending(),
"the pending-Tab marker survives the repeat press"
);
}
/// Items outdated by an edit (stale generation) refetch instead of
/// completing over the old candidate set.
#[test]
fn tab_with_stale_items_refetches() {
let mut agent = bash_agent_always_on("cat no");
agent.prompt.suggestions.dropdown.items = vec![file_item("cat notes.md", "notes.md", 4..6)];
agent.prompt.suggestions.dropdown.generation = 7;
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert_eq!(agent.prompt.text(), "cat no", "no accept from stale items");
assert!(
agent
.pending_effects
.iter()
.any(|e| matches!(e, Effect::FetchShellSuggestions { .. })),
"stale items must refetch"
);
}
/// An empty bash draft has no token to complete: Tab keeps its
/// focus-cycling fallthrough.
#[test]
fn tab_on_empty_bash_draft_falls_through_to_focus_scrollback() {
let mut agent = bash_agent_always_on("");
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(
outcome,
InputOutcome::Action(Action::FocusScrollback)
));
assert!(agent.pending_effects.is_empty());
}
/// The normal (chat) prompt keeps its Tab behavior: no fetch, no
/// completion — the surface is bash-mode-only.
#[test]
fn tab_in_normal_mode_does_not_fetch() {
let mut agent = super::test_fixtures::make_agent();
agent.prompt.textarea.insert_str("cat no");
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(
!agent
.pending_effects
.iter()
.any(|e| matches!(e, Effect::FetchShellSuggestions { .. })),
"normal-mode Tab must not fetch completions"
);
}
// -- terminal-like Tab (single-candidate accept / common-prefix fill) --
/// Exactly one token candidate: Tab accepts it immediately — no
/// dropdown flash — and the accept re-fetch keeps the pipeline alive.
#[test]
fn tab_single_token_candidate_accepts_without_dropdown_flash() {
let mut agent = bash_agent("cat no");
agent.prompt.suggestions.dropdown.items = vec![file_item("cat notes.md", "notes.md", 4..6)];
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.text(), "cat notes.md");
assert_eq!(agent.prompt.cursor(), "cat notes.md".len());
assert!(!agent.prompt.completion_dropdown_open());
}
/// The same insta-accept with the pipeline OFF: the refetch kick is a
/// direct deterministic fetch instead of a debounce.
#[test]
fn tab_single_candidate_accepts_and_kicks_fetch_always_on() {
let mut agent = bash_agent_always_on("cat no");
agent.prompt.suggestions.dropdown.items = vec![file_item("cat notes.md", "notes.md", 4..6)];
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.text(), "cat notes.md");
assert!(
agent.pending_effects.iter().any(|e| matches!(
e,
Effect::FetchShellSuggestions {
include_ai: false,
..
}
)),
"accept must kick a deterministic refetch"
);
}
/// A single HISTORY item keeps the plain dropdown-open behavior:
/// terminal Tab semantics apply to token completions only.
#[test]
fn tab_single_history_item_opens_dropdown() {
let mut agent = bash_agent("git st");
agent.prompt.suggestions.dropdown.items =
vec![history_item("git status --porcelain", 0..6)];
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert!(agent.prompt.completion_dropdown_open());
assert_eq!(agent.prompt.text(), "git st");
}
/// THE legacy-shell compatibility case: a rangeless `path` row (old
/// shells send `insertText: "grep"`, no range) must never insta-accept
/// — its whole-line fallback would replace `ls | gr` with `grep`. Tab
/// plain-opens instead, sole match or not.
#[test]
fn tab_sole_rangeless_path_row_opens_dropdown_never_accepts() {
let mut agent = bash_agent("ls | gr");
agent.prompt.suggestions.dropdown.items = vec![item("grep", None)];
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.text(), "ls | gr", "draft must survive");
assert!(agent.prompt.completion_dropdown_open());
}
/// Any rangeless row in a MIXED set (legacy PATH row next to a ranged
/// file row) forces plain-open too — no insta-accept, no fill.
#[test]
fn tab_mixed_rangeless_and_ranged_rows_open_dropdown() {
let mut agent = bash_agent("ls | gr");
agent.prompt.suggestions.dropdown.items = vec![
item("grep", None),
file_item("ls | grokfile", "grokfile", 5..7),
];
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert!(agent.prompt.completion_dropdown_open());
assert_eq!(agent.prompt.text(), "ls | gr", "no accept, no fill");
}
/// A MIXED set (any non-token item alongside file/path rows) disables
/// terminal-Tab semantics wholesale: no insta-accept, no fill — Tab
/// plain-opens so the user sees every candidate, history included.
#[test]
fn tab_mixed_file_and_history_items_opens_dropdown() {
let mut agent = bash_agent("cat no");
agent.prompt.suggestions.dropdown.items = vec![
history_item("cat notes.md --verbose", 0..6),
file_item("cat notes.md", "notes.md", 4..6),
];
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert!(agent.prompt.completion_dropdown_open());
assert_eq!(agent.prompt.text(), "cat no", "no accept, no fill");
}
/// Whole-line history sets never prefix-fill (half a history line is
/// not a command) — Tab plain-opens.
#[test]
fn tab_whole_line_history_items_open_dropdown_not_fill() {
let mut agent = bash_agent("git st");
agent.prompt.suggestions.dropdown.items = vec![
history_item("git status --porcelain-A", 0..6),
history_item("git status --porcelain-B", 0..6),
];
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert!(agent.prompt.completion_dropdown_open());
assert_eq!(agent.prompt.text(), "git st");
}
/// Multiple candidates sharing a prefix longer than the typed token:
/// the first Tab fills the common prefix in place (no dropdown) and
/// re-fetches; when the refreshed items land, the second Tab opens the
/// dropdown.
#[test]
fn tab_fills_common_prefix_then_opens_dropdown_on_refresh() {
let mut agent = bash_agent("cat al");
agent.prompt.suggestions.dropdown.items = vec![
file_item("cat alpha_one.txt", "alpha_one.txt", 4..6),
file_item("cat alpha_two.txt", "alpha_two.txt", 4..6),
];
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.text(), "cat alpha_");
assert_eq!(agent.prompt.cursor(), "cat alpha_".len());
assert!(
!agent.prompt.completion_dropdown_open(),
"first Tab fills; the dropdown waits for the second"
);
assert!(
agent
.pending_effects
.iter()
.any(|e| matches!(e, Effect::DebounceSuggestions { .. })),
"the fill re-fetches candidates for the longer prefix"
);
// The refreshed response lands for the filled text…
let generation = agent.prompt.suggestions.generation();
agent.prompt.suggestions.on_suggestions_loaded(
crate::views::suggestion_controller::SuggestResponseParsed {
ghost: None,
completions: vec![
file_item("cat alpha_one.txt", "alpha_one.txt", 4..10),
file_item("cat alpha_two.txt", "alpha_two.txt", 4..10),
],
generation,
},
"cat alpha_",
"cat alpha_".len(),
);
// …and the second Tab opens the dropdown (LCP no longer extends).
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert!(agent.prompt.completion_dropdown_open());
assert_eq!(agent.prompt.text(), "cat alpha_");
}
/// The fill's refetch with the pipeline OFF is a direct deterministic
/// fetch (no debounce to ride on).
#[test]
fn tab_fill_kicks_deterministic_fetch_always_on() {
let mut agent = bash_agent_always_on("cat al");
agent.prompt.suggestions.dropdown.items = vec![
file_item("cat alpha_one.txt", "alpha_one.txt", 4..6),
file_item("cat alpha_two.txt", "alpha_two.txt", 4..6),
];
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert_eq!(agent.prompt.text(), "cat alpha_");
assert!(
agent.pending_effects.iter().any(|e| matches!(
e,
Effect::FetchShellSuggestions {
include_ai: false,
..
}
)),
"fill must kick a deterministic refetch"
);
}
/// Bash-mode agent whose draft is a paste CHIP (atomic element), with
/// the dropdown anchor pinned to it — the state a landing would leave
/// when the shell's token range points into the chip's raw text.
fn chip_agent(items: Vec<CompletionItemParsed>) -> (AgentView, String) {
let mut agent = super::test_fixtures::make_agent();
agent.prompt_input_mode = PromptInputMode::Bash;
agent.prompt.suggestions.enabled = false;
agent
.prompt
.handle_paste("line one\nline two\nline three\nline four");
let text = agent.prompt.text().to_owned();
agent.prompt.suggestions.dropdown.request_text = text.clone();
agent.prompt.suggestions.dropdown.request_cursor = agent.prompt.cursor();
agent.prompt.suggestions.dropdown.items = items;
(agent, text)
}
fn suggest_fetch_count(agent: &AgentView) -> usize {
agent
.pending_effects
.iter()
.filter(|e| {
matches!(
e,
Effect::FetchShellSuggestions { .. } | Effect::DebounceSuggestions { .. }
)
})
.count()
}
/// BugBot: a Fill whose range clips a paste chip used to no-op the
/// write and STILL kick a refetch — every Tab spun fill+refetch with no
/// draft change. The declined fill now degrades to opening the
/// dropdown: candidates visible, nothing fetched, chip intact, and the
/// second Tab rides the normal open-dropdown handling.
#[test]
fn tab_fill_clipping_paste_chip_opens_dropdown_without_refetch() {
// Two candidates whose shared range (chip bytes 0..2, "li") fills
// to "lima_" — a valid Fill decision over an unwritable span.
let (mut agent, text) = chip_agent(vec![
file_item("lima_one.txt", "lima_one.txt", 0..2),
file_item("lima_two.txt", "lima_two.txt", 0..2),
]);
let gen_before = agent.prompt.suggestions.generation();
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.text(), text, "chip must survive the fill");
assert!(agent.prompt.completion_dropdown_open());
assert_eq!(
agent.prompt.suggestions.generation(),
gen_before,
"a declined fill must not invalidate anything"
);
assert_eq!(suggest_fetch_count(&agent), 0, "no refetch kick");
// Second Tab goes through the open dropdown (accept path), never
// the fetch arm — no spin.
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert_eq!(agent.prompt.text(), text);
assert_eq!(suggest_fetch_count(&agent), 0);
}
/// Same hole on the insta-accept arm: committing would consume the
/// sole candidate and THEN decline the splice, leaving every Tab to
/// refetch the same set. The probe degrades to showing the candidate.
#[test]
fn tab_insta_accept_clipping_paste_chip_opens_dropdown_without_refetch() {
let (mut agent, text) = chip_agent(vec![file_item("lima_one.txt", "lima_one.txt", 0..2)]);
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.text(), text, "chip must survive");
assert!(agent.prompt.completion_dropdown_open());
assert_eq!(
agent.prompt.suggestions.dropdown.items.len(),
1,
"the candidate must not be consumed"
);
assert_eq!(suggest_fetch_count(&agent), 0, "no refetch kick");
}
/// BugBot sibling hole: the OPEN-dropdown accept (Tab/Enter/mouse all
/// share the helper) used to consume the candidates and close before
/// the write path declined the chip-clipping splice — leaving nothing.
/// The probe now makes it an honest no-op: nothing consumed, dropdown
/// up, chip/draft/generation untouched, no kick — and Enter must not
/// fall through to send.
#[test]
fn dropdown_accept_clipping_paste_chip_keeps_candidates() {
let (mut agent, text) = chip_agent(vec![
file_item("lima_one.txt", "lima_one.txt", 0..2),
file_item("lima_two.txt", "lima_two.txt", 0..2),
]);
agent.prompt.suggestions.dropdown.open = true;
let gen_before = agent.prompt.suggestions.generation();
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Enter));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.text(), text, "chip must survive");
assert!(
agent.prompt.completion_dropdown_open(),
"candidates stay up"
);
assert_eq!(
agent.prompt.suggestions.dropdown.items.len(),
2,
"nothing consumed"
);
assert_eq!(agent.prompt.suggestions.generation(), gen_before);
assert_eq!(suggest_fetch_count(&agent), 0, "no refetch kick");
// Tab rides the same helper.
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert_eq!(agent.prompt.suggestions.dropdown.items.len(), 2);
assert_eq!(agent.prompt.text(), text);
assert_eq!(suggest_fetch_count(&agent), 0);
}
/// The probe peeks the SELECTED item: with a chip-clipping row next to
/// a plain-text row, acceptance follows the selection — no-op on the
/// clipping one, normal accept after Down moves to the safe one.
#[test]
fn dropdown_accept_respects_selection_over_mixed_clip_ranges() {
let (mut agent, _) = chip_agent(vec![]);
agent.prompt.textarea.insert_str(" li");
let text = agent.prompt.text().to_owned();
agent.prompt.suggestions.dropdown.request_text = text.clone();
agent.prompt.suggestions.dropdown.request_cursor = agent.prompt.cursor();
let tok = text.len() - 2;
agent.prompt.suggestions.dropdown.items = vec![
file_item("lima_one.txt", "lima_one.txt", 0..2),
file_item("lima_two.txt", "lima_two.txt", tok..text.len()),
];
agent.prompt.suggestions.dropdown.open = true;
// Selected = the chip-clipping row: honest no-op.
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Enter));
assert_eq!(agent.prompt.suggestions.dropdown.items.len(), 2);
assert_eq!(agent.prompt.text(), text);
// Down selects the plain-text row: accepts normally.
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Down));
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert!(
agent.prompt.text().ends_with(" lima_two.txt"),
"safe selection must splice: {}",
agent.prompt.text()
);
assert!(!agent.prompt.completion_dropdown_open());
}
/// Accepting a directory completion (trailing `/`) must re-fetch so the
/// NEXT Tab completes inside it — drill-down chaining.
#[test]
fn dir_accept_kicks_refetch_for_drill_down() {
let mut agent = bash_agent("cat no");
agent.prompt.suggestions.dropdown.open = true;
agent.prompt.suggestions.dropdown.items =
vec![file_item("cat Notes\\ Archive/", "Notes\\ Archive/", 4..6)];
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.text(), "cat Notes\\ Archive/");
assert!(
agent
.pending_effects
.iter()
.any(|e| matches!(e, Effect::DebounceSuggestions { .. })),
"dir accept must kick a fresh fetch for the drill-down"
);
}
// -- Bash-mode gating of the as-you-type pipeline ------------------------
/// Typing in the normal (chat) prompt never fires the suggest pipeline;
/// the same keystroke in bash mode debounces a request.
#[test]
fn pipeline_fires_only_in_bash_mode() {
let mut agent = super::test_fixtures::make_agent();
agent.prompt.suggestions.enabled = true;
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Char('g')));
assert!(
!agent
.pending_effects
.iter()
.any(|e| matches!(e, Effect::DebounceSuggestions { .. })),
"normal-mode typing must not reach the suggest pipeline"
);
let mut agent = super::test_fixtures::make_agent();
agent.prompt.suggestions.enabled = true;
agent.prompt_input_mode = PromptInputMode::Bash;
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Char('g')));
assert!(
agent
.pending_effects
.iter()
.any(|e| matches!(e, Effect::DebounceSuggestions { .. })),
"bash-mode typing debounces a suggest request"
);
}
/// Esc closes a dropdown the Tab-armed landing opened (the always-on
/// dismissal path), and the draft survives.
#[test]
fn esc_closes_tab_fetched_dropdown() {
let mut agent = bash_agent_always_on("git st");
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
let generation = agent.prompt.suggestions.generation();
agent.prompt.suggestions.on_suggestions_loaded(
crate::views::suggestion_controller::SuggestResponseParsed {
ghost: None,
completions: vec![
history_item("git status --porcelain-A", 0..6),
history_item("git status --porcelain-B", 0..6),
],
generation,
},
"git st",
"git st".len(),
);
assert!(agent.prompt.suggestions.take_pending_tab(generation));
agent.shell_completion_tab();
assert!(agent.prompt.completion_dropdown_open());
let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Esc));
assert!(matches!(outcome, InputOutcome::Changed));
assert!(!agent.prompt.completion_dropdown_open());
assert_eq!(agent.prompt.text(), "git st");
assert_eq!(agent.prompt_input_mode, PromptInputMode::Bash);
}
/// With the pipeline OFF, typing invalidates Tab-fetched state instead:
/// the landing response for the pre-edit text is stale.
#[test]
fn typing_invalidates_tab_state_always_on() {
let mut agent = bash_agent_always_on("cat no");
agent.prompt.suggestions.dropdown.items = vec![file_item("cat notes.md", "notes.md", 4..6)];
let gen_before = agent.prompt.suggestions.generation();
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Char('x')));
assert!(
agent.prompt.suggestions.generation() > gen_before,
"the edit must invalidate Tab-fetched state"
);
assert!(agent.prompt.suggestions.dropdown.items.is_empty());
assert!(
!agent
.pending_effects
.iter()
.any(|e| matches!(e, Effect::DebounceSuggestions { .. })),
"no as-you-type fetch without the env flag"
);
}
/// THE stale-anchor regression: a mouse click repositions the cursor
/// with no text change, so it must invalidate cached completion items
/// exactly like a typed edit — the next Tab fetches for the token under
/// the clicked cursor instead of completing the old one.
#[test]
fn prompt_click_invalidates_cached_items_before_tab() {
use crate::app::agent_view::AgentPane;
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut agent = bash_agent_always_on("cat no");
agent.prompt.suggestions.dropdown.items = vec![file_item("cat notes.md", "notes.md", 4..6)];
agent.pane_areas.prompt = ratatui::layout::Rect::new(0, 40, 80, 5);
// Already focused: an unfocused-collapse click only refocuses and
// never reaches the textarea (the exact bug needs a focused click).
agent.active_pane = AgentPane::Prompt;
let gen_before = agent.prompt.suggestions.generation();
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 2,
row: 41,
modifiers: KeyModifiers::NONE,
};
let _ = agent.handle_mouse(&click);
assert!(
agent.prompt.suggestions.generation() > gen_before,
"a prompt click must invalidate cached completion state"
);
assert!(agent.prompt.suggestions.dropdown.items.is_empty());
let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
assert_eq!(agent.prompt.text(), "cat no", "old token must not complete");
assert!(
agent
.pending_effects
.iter()
.any(|e| matches!(e, Effect::FetchShellSuggestions { .. })),
"Tab must refetch for the clicked position"
);
}
}
@@ -0,0 +1,982 @@
//! Line and block viewer popups plus the /btw panel: open/confirm/dismiss
//! and their key/mouse handlers.
use super::{AgentView, render_char_buttons};
use crate::app::app_view::InputOutcome;
use crate::key;
use crate::scrollback::selection::SelectionBox;
use crate::scrollback::types::DisplayMode;
use crate::theme::Theme;
use crate::views::btw_overlay::BTW_OVERLAY_ENTRY_IDX;
use crate::views::file_search::line_viewer::LineViewerState;
use crate::views::list_pane::ListItem;
use crate::views::plan_approval_view::PlanApprovalFocus;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
impl AgentView {
// ── Line viewer methods ────────────────────────────────────────────
/// Open the line viewer for a file path with optional initial line range.
pub(in crate::app) fn open_line_viewer(
&mut self,
path: &std::path::Path,
initial_range: Option<std::ops::Range<usize>>,
) {
// Resolve path relative to cwd.
let full_path = if path.is_relative() {
self.session.cwd.join(path)
} else {
path.to_path_buf()
};
// Get the element ID of the last file ref element (just created).
let element_id = self
.prompt
.textarea
.elements()
.iter()
.rev()
.find(|e| e.kind == crate::views::prompt_widget::KIND_FILE_REF)
.map(|e| e.id);
if let Some(mut viewer) = LineViewerState::open(&full_path, element_id) {
// If we have an initial line range, scroll to it and select.
if let Some(range) = initial_range {
viewer.set_initial_selection(range);
}
self.line_viewer = Some(viewer);
} else {
// File couldn't be read — cancel the undo group.
self.prompt.textarea.cancel_undo_group();
}
}
/// Handle a key event while the line viewer is open.
pub(super) fn handle_line_viewer_key(&mut self, key: &KeyEvent) -> InputOutcome {
let in_plan_approval = self.plan_approval_view.is_some();
let input_bar_active = self
.line_viewer
.as_ref()
.is_some_and(|v| v.list_state.input_mode().is_some());
// When the search/filter/goto input bar is active, let ListPane
// handle everything. Comment mode is special: Enter/Esc are not
// consumed by the list state (it returns false), so we handle
// save/cancel here.
if input_bar_active {
let is_comment_mode = self.line_viewer.as_ref().is_some_and(|v| {
v.list_state.input_mode() == Some(crate::views::list_pane::InputBarMode::Comment)
});
if is_comment_mode {
if key!(Enter).matches(key) {
return self.save_casual_plan_comment();
}
if key!(Esc).matches(key) {
return self.cancel_casual_plan_commenting();
}
}
if let Some(ref mut viewer) = self.line_viewer {
viewer.list_state.handle_key_event(key, &viewer.lines);
}
return InputOutcome::Changed;
}
if in_plan_approval && key.code == KeyCode::Tab && key.modifiers.is_empty() {
if let Some(ref mut pav) = self.plan_approval_view {
pav.focus = PlanApprovalFocus::Prompt;
}
return InputOutcome::Changed;
}
// Plan-approval `Esc` doesn't close the viewer (use `q` / `Ctrl+\`),
// but it still clears a transient visual selection or accepted search
// matcher first, so the graduated dashboard-overlay back-out (which
// declines to fire while a matcher is active) isn't left dead-ended.
if in_plan_approval && key!(Esc).matches(key) {
if let Some(ref mut viewer) = self.line_viewer {
if viewer.list_state.visual_mode {
viewer.list_state.exit_visual_mode();
return InputOutcome::Changed;
}
if viewer.list_state.matcher().is_some() {
viewer.list_state.handle_key_event(key, &viewer.lines);
return InputOutcome::Changed;
}
}
return InputOutcome::Changed;
}
// Ctrl+F: toggle fullscreen.
if key.code == KeyCode::Char('f') && key.modifiers.contains(KeyModifiers::CONTROL) {
if let Some(ref mut viewer) = self.line_viewer {
viewer.fullscreen = !viewer.fullscreen;
}
return InputOutcome::Changed;
}
if in_plan_approval && key!('c').matches(key) {
return self.enter_plan_commenting();
}
// Casual mode: same `c` / `s` shortcuts as plan approval so the
// footer hints actually work.
if !in_plan_approval && self.is_plan_viewer() && key!('c').matches(key) {
return self.enter_casual_plan_commenting();
}
if !in_plan_approval
&& self.is_plan_viewer()
&& key!('s').matches(key)
&& !self.plan_comments.is_empty()
{
return self.send_casual_plan_comments();
}
if in_plan_approval && key!('a').matches(key) {
return self.approve_plan();
}
// s: switch to prompt so the user can type an overall revision
// message before submitting. Enter from Prompt does the actual send.
if in_plan_approval && key!('s').matches(key) {
if let Some(ref mut pav) = self.plan_approval_view {
pav.focus = PlanApprovalFocus::Prompt;
}
return InputOutcome::Changed;
}
if in_plan_approval && key!('q').matches(key) {
return self.abandon_plan();
}
if !in_plan_approval
&& self.is_plan_viewer()
&& !self.plan_comments.is_empty()
&& key.code == KeyCode::Enter
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
return self.send_casual_plan_comments();
}
if key!(Enter).matches(key) {
if in_plan_approval {
return self.enter_plan_commenting();
}
if self.is_plan_viewer() {
return self.enter_casual_plan_commenting();
}
let has_visual = self
.line_viewer
.as_ref()
.is_some_and(|v| v.list_state.visual_mode);
self.confirm_line_viewer(has_visual);
return InputOutcome::Changed;
}
if key!('x').matches(key) {
if in_plan_approval {
return self.delete_plan_comment_at_cursor();
}
if self.is_plan_viewer() {
return self.delete_casual_plan_comment_at_cursor();
}
self.confirm_line_viewer(false);
return InputOutcome::Changed;
}
// y: copy selected line(s) to system clipboard.
if key!('y').matches(key) {
if let Some(ref viewer) = self.line_viewer {
let text = if viewer.list_state.visual_mode {
if let Some(ref range) = viewer.list_state.multi_range() {
let lines: Vec<String> = (range.start..range.end)
.filter_map(|vi| {
let pi = viewer.list_state.to_physical(vi);
viewer.lines.get(pi)
})
.map(|item| item.copy_text())
.collect();
Some(lines.join("\n"))
} else {
None
}
} else {
viewer
.list_state
.selected_index()
.and_then(|vi| {
let pi = viewer.list_state.to_physical(vi);
viewer.lines.get(pi)
})
.map(|item| item.copy_text())
};
if let Some(text) = text
&& !text.is_empty()
{
self.copy_to_clipboard(&text);
}
}
return InputOutcome::Changed;
}
// Y: copy filename to clipboard.
if key!('Y').matches(key) {
if let Some(ref viewer) = self.line_viewer {
let name = viewer
.title_override
.as_deref()
.unwrap_or_else(|| {
viewer
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
})
.to_owned();
self.copy_to_clipboard(&name);
}
return InputOutcome::Changed;
}
if key!(Esc).matches(key) || key!('q').matches(key) || key!('c', CONTROL).matches(key) {
if in_plan_approval {
return InputOutcome::Changed;
}
// In the plan viewer, Esc first clears visual selection / search
// before closing. q and Ctrl-C always close immediately.
if key!(Esc).matches(key)
&& let Some(ref mut viewer) = self.line_viewer
{
if viewer.list_state.visual_mode {
viewer.list_state.exit_visual_mode();
return InputOutcome::Changed;
}
if viewer.list_state.matcher().is_some() {
viewer.list_state.handle_key_event(key, &viewer.lines);
return InputOutcome::Changed;
}
}
self.cancel_line_viewer();
return InputOutcome::Changed;
}
// All other keys (including Ctrl-D/U for page nav): forward to ListPaneState.
if let Some(ref mut viewer) = self.line_viewer {
viewer.list_state.handle_key_event(key, &viewer.lines);
}
InputOutcome::Changed
}
/// Confirm line viewer: update the element, optionally with a line range.
///
/// `include_range`: if true and visual mode is active, appends `:N-M`.
/// If false, confirms with just the file path (strips any existing range).
fn confirm_line_viewer(&mut self, include_range: bool) {
if let Some(viewer) = self.line_viewer.take() {
if let Some(elem_id) = viewer.element_id {
let rel_path = viewer
.path
.strip_prefix(&self.session.cwd)
.unwrap_or(&viewer.path);
let suffix = if include_range {
viewer.line_range_suffix().unwrap_or_default()
} else {
String::new()
};
let path_display = format!("{}{suffix}", rel_path.display());
let new_text = format!("@{path_display}");
let display = crate::views::prompt_widget::file_ref_display(&path_display);
if let Some(elem) = self
.prompt
.textarea
.elements()
.iter()
.find(|e| e.id == elem_id)
{
let range = elem.range.clone();
self.prompt.textarea.replace_range_with_element(
range,
&new_text,
crate::views::prompt_widget::KIND_FILE_REF,
Some(display),
);
}
}
// Close the undo group.
self.prompt.textarea.insert_str(" ");
self.prompt.textarea.end_undo_group();
}
}
/// Cancel line viewer: revert all changes.
pub(crate) fn cancel_line_viewer(&mut self) {
self.line_viewer = None;
self.prompt.textarea.cancel_undo_group();
if let Some(ref mut pav) = self.plan_approval_view {
pav.focus = PlanApprovalFocus::Preview;
}
// If a casual plan comment was in progress when the modal
// closed (via [✗], click-outside, or any other path that
// doesn't route through `cancel_casual_plan_commenting`),
// restore the pre-comment prompt text so the user's original
// text isn't lost behind the in-progress comment draft.
// Mirrors `cancel_casual_plan_commenting`.
if let Some(stashed) = self.casual_stashed_prompt.take() {
self.prompt.restore(stashed);
}
self.casual_commenting_range = None;
self.casual_editing_comment_id = None;
}
/// Dismiss the /btw panel. If Done, flush response to scrollback first.
pub(super) fn dismiss_btw_panel(&mut self) -> InputOutcome {
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::BtwBlock;
use crate::views::btw_overlay::BtwOverlayState;
if let Some(BtwOverlayState::Done {
question, content, ..
}) = self.btw_state.take()
{
self.scrollback
.push_block(RenderBlock::Btw(BtwBlock::new(question, content.text())));
} else {
self.btw_state = None;
}
self.btw_focused = false;
self.clear_btw_drag_state();
InputOutcome::Changed
}
pub(super) fn clear_btw_drag_state(&mut self) {
let is_btw = self
.pending_text_drag
.is_some_and(|p| p.anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX)
|| self
.drag_selection
.as_ref()
.is_some_and(|d| d.anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX);
if is_btw {
self.pending_text_drag = None;
self.drag_selection = None;
self.drag_autoscroll = None;
self.last_drag_mouse = None;
}
}
/// Handle mouse events while the line viewer is open.
pub(super) fn handle_line_viewer_mouse(
&mut self,
mouse: &crossterm::event::MouseEvent,
) -> InputOutcome {
use crossterm::event::{MouseButton, MouseEventKind};
let Some(ref mut viewer) = self.line_viewer else {
return InputOutcome::Changed;
};
// `popup_area` is the list-rendered area (excludes the divider
// + footer rows in plan modes); used for dispatching mouse
// events into `ListPaneState`. `modal_area` is the full inner
// rect of the modal frame (includes the footer); used by the
// click-outside-modal check so that clicks on the divider or
// the empty space between footer buttons don't accidentally
// close the modal.
let popup_area = viewer.last_popup_area;
let modal_area = viewer.last_modal_area;
let close_area = viewer.close_button_area;
let fs_area = viewer.fullscreen_button_area;
let send_area = viewer.plan_ref().and_then(|p| p.send_button_area);
let abandon_area = viewer.plan_ref().and_then(|p| p.abandon_button_area);
let approve_area = viewer.plan_ref().and_then(|p| p.approve_button_area);
let comment_btn_area = viewer.plan_ref().and_then(|p| p.comment_button_area);
// Cached `is_plan_viewer()` so we don't need to call self while
// the line_viewer is mutably borrowed below.
let is_plan_preview =
viewer.kind == crate::views::file_search::line_viewer::LineViewerKind::PlanPreview;
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
// Click on close button -> cancel.
if close_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into())) {
if self.plan_approval_view.is_none() {
self.cancel_line_viewer();
}
return InputOutcome::Changed;
}
// Click on fullscreen button -> toggle fullscreen.
if fs_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into())) {
if let Some(ref mut v) = self.line_viewer {
v.fullscreen = !v.fullscreen;
}
return InputOutcome::Changed;
}
if abandon_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into())) {
return self.abandon_plan();
}
if approve_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into())) {
if self.plan_approval_view.is_some() {
return self.approve_plan();
} else if is_plan_preview && !self.plan_comments.is_empty() {
// Casual mode: the only action button shown is
// `s send` (when there are comments to send).
return self.send_casual_plan_comments();
}
return InputOutcome::Changed;
}
if comment_btn_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into())) {
if self.plan_approval_view.is_some() {
return self.enter_plan_commenting();
}
if is_plan_preview {
return self.enter_casual_plan_commenting();
}
// The comment button is only set on plan viewers,
// so the two arms above are exhaustive in practice.
// Return here to make the dead fall-through
// explicit and to match the abandon/approve hit
// patterns just above.
return InputOutcome::Changed;
}
if send_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into())) {
if self.plan_approval_view.is_some() {
if let Some(ref mut pav) = self.plan_approval_view {
pav.focus = PlanApprovalFocus::Prompt;
}
return InputOutcome::Changed;
}
return self.send_casual_plan_comments();
}
if modal_area.is_none_or(|a| !a.contains((mouse.column, mouse.row).into())) {
if self.plan_approval_view.is_some()
&& self
.pane_areas
.prompt
.contains((mouse.column, mouse.row).into())
{
if let Some(ref mut pav) = self.plan_approval_view {
pav.focus = PlanApprovalFocus::Prompt;
}
return InputOutcome::Changed;
}
if self.plan_approval_view.is_some() {
return InputOutcome::Changed;
}
self.cancel_line_viewer();
return InputOutcome::Changed;
}
let was_commenting = self
.plan_approval_view
.as_ref()
.is_some_and(|pav| pav.focus == PlanApprovalFocus::Commenting);
if let Some(ref mut pav) = self.plan_approval_view {
pav.focus = PlanApprovalFocus::Preview;
if was_commenting {
// Same rule as Tab: clicking back into the modal
// discards the in-progress comment draft.
pav.commenting_range = None;
pav.editing_comment_id = None;
pav.stashed_feedback_prompt = None;
}
}
if was_commenting {
self.prompt.set_text("");
}
// Forward below.
}
MouseEventKind::Moved => {
let mut changed = false;
let close_hover =
close_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into()));
if close_hover != viewer.close_hovered {
viewer.close_hovered = close_hover;
changed = true;
}
let fs_hover =
fs_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into()));
if fs_hover != viewer.fullscreen_hovered {
viewer.fullscreen_hovered = fs_hover;
changed = true;
}
let send_hover =
send_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into()));
let prev_send = viewer.plan_ref().is_some_and(|p| p.send_hovered);
if send_hover != prev_send {
viewer.plan_mut().send_hovered = send_hover;
changed = true;
}
let abandon_hover =
abandon_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into()));
let prev_abandon = viewer.plan_ref().is_some_and(|p| p.abandon_hovered);
if abandon_hover != prev_abandon {
viewer.plan_mut().abandon_hovered = abandon_hover;
changed = true;
}
let approve_hover =
approve_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into()));
let prev_approve = viewer.plan_ref().is_some_and(|p| p.approve_hovered);
if approve_hover != prev_approve {
viewer.plan_mut().approve_hovered = approve_hover;
changed = true;
}
let comment_btn_hover =
comment_btn_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into()));
let prev_comment_btn = viewer.plan_ref().is_some_and(|p| p.comment_hovered);
if comment_btn_hover != prev_comment_btn {
viewer.plan_mut().comment_hovered = comment_btn_hover;
changed = true;
}
if self.plan_approval_view.is_some()
&& let Some(area) = popup_area
&& area.contains((mouse.column, mouse.row).into())
&& mouse.row >= area.y
{
let ry = (mouse.row - area.y) as usize;
let vy = viewer.list_state.scroll_offset() + ry;
if viewer.list_state.layout().item_at_y(vy).is_some()
&& viewer.list_state.select_at_y(vy, &viewer.lines)
{
changed = true;
}
}
return if changed {
InputOutcome::Changed
} else {
InputOutcome::Unchanged
};
}
MouseEventKind::Drag(MouseButton::Left) => {
// Drag-to-extend works in both plan-approval and casual
// plan-preview modes (anywhere the PlanPreview viewer is
// showing).
if is_plan_preview
&& let Some(area) = popup_area
&& let Some(ln) = viewer.source_line_at_screen_row(mouse.row, area)
{
let has_start = viewer
.plan_ref()
.is_some_and(|p| p.gutter_drag_start.is_some());
if has_start {
viewer.plan_mut().gutter_drag_end = Some(ln);
return InputOutcome::Changed;
}
}
if let Some(area) = popup_area
&& area.contains((mouse.column, mouse.row).into())
{
viewer.list_state.handle_mouse_event(
mouse.kind,
mouse.column,
mouse.row,
area,
&viewer.lines,
);
}
return InputOutcome::Changed;
}
MouseEventKind::Up(MouseButton::Left) => {
if is_plan_preview {
let drag_start = viewer.plan_ref().and_then(|p| p.gutter_drag_start);
let drag_end = viewer.plan_ref().and_then(|p| p.gutter_drag_end);
viewer.plan_mut().gutter_drag_start = None;
viewer.plan_mut().gutter_drag_end = None;
if let (Some(start), Some(end)) = (drag_start, drag_end)
&& start != end
{
let lo = start.min(end);
let hi = start.max(end);
let range = lo..hi + 1;
if let Some(ref mut pav) = self.plan_approval_view {
pav.stashed_feedback_prompt = Some(self.prompt.stash());
pav.commenting_range = Some(range);
pav.editing_comment_id = None;
pav.focus = PlanApprovalFocus::Commenting;
self.prompt.set_text("");
} else {
// First-entry-only stash; see
// `enter_casual_plan_commenting` for the
// same guard rationale.
if self.casual_stashed_prompt.is_none() {
self.casual_stashed_prompt = Some(self.prompt.stash());
}
self.casual_commenting_range = Some(range);
self.casual_editing_comment_id = None;
self.prompt.set_text("");
}
return InputOutcome::Changed;
}
}
if let Some(area) = popup_area
&& area.contains((mouse.column, mouse.row).into())
{
viewer.list_state.handle_mouse_event(
mouse.kind,
mouse.column,
mouse.row,
area,
&viewer.lines,
);
}
return InputOutcome::Changed;
}
MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => {}
_ => return InputOutcome::Changed,
}
// Forward to ListPaneState if inside the popup area.
let mut should_enter_commenting = false;
let mut should_enter_plan_commenting = false;
if let Some(area) = popup_area
&& area.contains((mouse.column, mouse.row).into())
{
viewer.list_state.handle_mouse_event(
mouse.kind,
mouse.column,
mouse.row,
area,
&viewer.lines,
);
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
let clicked_line = viewer.source_line_at_screen_row(mouse.row, area);
// Drag selection works in both modes whenever the
// plan preview is showing — but only on source rows
// (we need a 1-based line number as the drag anchor).
if is_plan_preview && let Some(ln) = clicked_line {
viewer.plan_mut().gutter_drag_start = Some(ln);
viewer.plan_mut().gutter_drag_end = Some(ln);
}
viewer.plan_mut().last_click_at = Some(std::time::Instant::now());
// A single click on any list row — source line OR
// existing comment annotation — enters commenting (or
// edit-comment) for that row. Same shortcut as
// selecting + pressing `c` / Enter. Works for both
// plan-approval and casual plan-preview modes.
let on_list_row = mouse.row >= area.y && {
let ry = (mouse.row - area.y) as usize;
let vy = viewer.list_state.scroll_offset() + ry;
viewer.list_state.layout().item_at_y(vy).is_some()
};
// Skip the click-to-comment trigger if the user is
// already composing a comment. Without this guard, any
// click on a list row would re-enter commenting and
// re-stash the (now-comment) prompt, clobbering the
// user's pre-comment text and preventing the mouse from
// being used to reposition the cursor without
// committing to a fresh comment.
let in_pav_commenting = self
.plan_approval_view
.as_ref()
.is_some_and(|pav| pav.focus == PlanApprovalFocus::Commenting);
let in_casual_commenting =
self.plan_approval_view.is_none() && self.casual_commenting_range.is_some();
if on_list_row
&& is_plan_preview
&& viewer.list_state.input_mode().is_none()
&& !in_pav_commenting
&& !in_casual_commenting
{
if self.plan_approval_view.is_some() {
should_enter_plan_commenting = true;
} else {
should_enter_commenting = true;
}
}
}
}
if should_enter_commenting {
return self.enter_casual_plan_commenting();
}
if should_enter_plan_commenting {
return self.enter_plan_commenting();
}
InputOutcome::Changed
}
// -- Scrollback selection box buttons -------------------------------------
/// Render ⧉ (copy) and ↗ (view) buttons on the scrollback selection box.
///
/// Two modes:
/// - **Corner row** (expanded or ungrouped): buttons on the `╭...╮` row.
/// - **Inline** (collapsed + grouped): buttons on the selected entry's row,
/// overlaying content at the right edge.
pub(super) fn render_selection_buttons(
&mut self,
buf: &mut Buffer,
selection_box: &SelectionBox,
selected_entry_area: Option<Rect>,
theme: &Theme,
) {
// Gated by appearance config (opt-in while testing).
if !self
.scrollback
.appearance()
.scrollback
.display
.selection_buttons
{
self.hit_sb_copy.clear();
self.hit_sb_view.clear();
return;
}
let Some(selected_idx) = self.scrollback.selected() else {
self.hit_sb_copy.clear();
self.hit_sb_view.clear();
return;
};
let Some(entry) = self.scrollback.entry(selected_idx) else {
self.hit_sb_copy.clear();
self.hit_sb_view.clear();
return;
};
let header_selected = self.scrollback.entry_content_hidden_by_group(selected_idx);
let has_copy = entry.block.supports_copy() && !header_selected;
let has_view = entry.block.supports_fullscreen() && !header_selected;
if !has_copy && !has_view {
self.hit_sb_copy.clear();
self.hit_sb_view.clear();
return;
}
// Determine inline vs corner mode.
// Inline: entry is collapsed AND part of a group (group_range > 1).
let split_mode = self
.scrollback
.appearance()
.scrollback
.display
.group_selection_split;
let group_range = self.scrollback.group_range_of(selected_idx, split_mode);
let is_grouped = group_range.len() > 1;
let is_collapsed = entry.display_mode == DisplayMode::Collapsed;
let inline = is_collapsed && is_grouped;
let sel = &selection_box.inner_area;
let right_x = sel.x + sel.width.saturating_sub(1);
let btn_base = Style::default().fg(theme.selection_border);
let btn_hover = Style::default().fg(theme.text_primary);
// Build button array based on capabilities.
if has_copy && has_view {
let (btn_right_x, y) = if inline {
// Inline: buttons on the selected entry's content row.
let entry_y = selected_entry_area.map(|r| r.y).unwrap_or(sel.y);
// Place inside the right border (right_x has │).
(right_x.saturating_sub(2), entry_y)
} else {
// Corner row: buttons to the left of ╮.
let corner_y = sel.y.saturating_sub(1);
(right_x.saturating_sub(2), corner_y)
};
if !selection_box.top_clipped || inline {
let areas = render_char_buttons(
buf,
btn_right_x,
y,
[
(crate::glyphs::copy_icon(), self.hit_sb_copy.hovered),
(crate::glyphs::enlarge(), self.hit_sb_view.hovered),
],
btn_base,
btn_hover,
1,
);
self.hit_sb_copy.set(Some(areas[0]));
self.hit_sb_view.set(Some(areas[1]));
} else {
self.hit_sb_copy.clear();
self.hit_sb_view.clear();
}
} else if has_copy {
let (btn_right_x, y) = if inline {
let entry_y = selected_entry_area.map(|r| r.y).unwrap_or(sel.y);
(right_x.saturating_sub(2), entry_y)
} else {
let corner_y = sel.y.saturating_sub(1);
(right_x.saturating_sub(2), corner_y)
};
if !selection_box.top_clipped || inline {
let areas = render_char_buttons(
buf,
btn_right_x,
y,
[(crate::glyphs::copy_icon(), self.hit_sb_copy.hovered)],
btn_base,
btn_hover,
0,
);
self.hit_sb_copy.set(Some(areas[0]));
} else {
self.hit_sb_copy.clear();
}
self.hit_sb_view.clear();
} else {
// has_view only
let (btn_right_x, y) = if inline {
let entry_y = selected_entry_area.map(|r| r.y).unwrap_or(sel.y);
(right_x.saturating_sub(2), entry_y)
} else {
let corner_y = sel.y.saturating_sub(1);
(right_x.saturating_sub(2), corner_y)
};
if !selection_box.top_clipped || inline {
let areas = render_char_buttons(
buf,
btn_right_x,
y,
[(crate::glyphs::enlarge(), self.hit_sb_view.hovered)],
btn_base,
btn_hover,
0,
);
self.hit_sb_view.set(Some(areas[0]));
} else {
self.hit_sb_view.clear();
}
self.hit_sb_copy.clear();
}
}
// -- Block viewer input handling ------------------------------------------
/// Handle a key event when the block viewer is open.
///
/// Returns `Changed` if consumed, `Unchanged` if the key should bubble up.
pub(super) fn handle_block_viewer_key(&mut self, key: &KeyEvent) -> InputOutcome {
let Some(ref mut viewer) = self.block_viewer else {
return InputOutcome::Unchanged;
};
// Check for close signals first (Esc/q/Ctrl-F)
if viewer.is_close_key(key) {
self.block_viewer = None;
return InputOutcome::Changed;
}
// Route to viewer — returns whether the key was consumed
if !viewer.handle_key(key) {
return InputOutcome::Unchanged;
}
// Handle raw toggle: capture old source map, toggle, rebuild with stability
if viewer.raw_toggle_pending {
viewer.raw_toggle_pending = false;
// Record scroll anchor BEFORE toggle so the selected line stays
// at the same screen position after the rebuild.
viewer.list_state.set_scroll_anchor();
// Capture source map BEFORE toggle for cursor mapping
let old_source_line = self
.scrollback
.get_by_id(viewer.entry_id)
.and_then(|entry| {
viewer.list_state.selected_id().and_then(|id| {
crate::views::block_viewer::BlockViewerPane::source_line_for_id(
&entry.block,
id,
)
})
});
// Toggle raw mode on the entry
if let Some(entry) = self.scrollback.get_by_id_mut(viewer.entry_id) {
entry.toggle_raw();
}
// Re-borrow immutably to rebuild items (avoids clone)
if let Some(entry) = self.scrollback.get_by_id(viewer.entry_id) {
viewer.rebuild_items(entry);
viewer.jump_to_source_line(entry, old_source_line);
}
}
// Process pending copy actions (logic lives in BlockViewerPane)
let entry_id = viewer.entry_id;
if let Some(entry) = self.scrollback.get_by_id(entry_id)
&& let Some(text) = viewer.process_pending_copy(entry)
{
self.copy_to_clipboard(&text);
}
InputOutcome::Changed
}
/// Handle a mouse event when the block viewer modal is open.
pub(in crate::app) fn handle_block_viewer_mouse(
&mut self,
mouse: &crossterm::event::MouseEvent,
) -> InputOutcome {
use crate::views::modal_window::{ModalWindowOutcome, handle_modal_mouse};
use crossterm::event::{MouseButton, MouseEventKind};
let Some(ref mut viewer) = self.block_viewer else {
return InputOutcome::Changed;
};
// Route to modal chrome first (close button, click-outside).
let modal_outcome =
handle_modal_mouse(&mut viewer.modal, mouse.kind, mouse.column, mouse.row);
match modal_outcome {
ModalWindowOutcome::CloseRequested => {
self.block_viewer = None;
return InputOutcome::Changed;
}
ModalWindowOutcome::Handled => return InputOutcome::Changed,
_ => {}
}
// Content interaction (scroll, click, drag).
match mouse.kind {
MouseEventKind::ScrollDown => viewer.handle_scroll(3),
MouseEventKind::ScrollUp => viewer.handle_scroll(-3),
MouseEventKind::Down(MouseButton::Left)
| MouseEventKind::Drag(MouseButton::Left)
| MouseEventKind::Up(MouseButton::Left) => {
viewer.handle_mouse(mouse.kind, mouse.column, mouse.row);
}
MouseEventKind::Moved => {
// Update hover state for content area.
viewer.handle_mouse(mouse.kind, mouse.column, mouse.row);
}
_ => {}
}
// Collect any pending copy text: drag-release auto-copy (like
// scrollback finish_text_drag) or Y/y key handler copy.
let drag_text = viewer.drag_copy_text.take();
let entry_id = viewer.entry_id;
let key_text = if drag_text.is_none() {
self.scrollback
.get_by_id(entry_id)
.and_then(|entry| viewer.process_pending_copy(entry))
} else {
None
};
// viewer borrow ends here — clipboard + toast can use &mut self.
if let Some(text) = drag_text.or(key_text) {
self.copy_to_clipboard(&text);
}
InputOutcome::Changed
}
/// Dynamic fold label for the shortcuts bar hint.
///
/// Returns "expand" if the selected entry is collapsed/truncated,
/// "collapse" if expanded, or `None` if the selected entry isn't foldable.
pub(super) fn selected_fold_label(&self) -> Option<&'static str> {
let idx = self.scrollback.selected()?;
let entry = self.scrollback.get(idx)?;
if !entry.is_foldable() {
return None;
}
Some(match entry.display_mode() {
DisplayMode::Expanded => "collapse",
_ => "expand",
})
}
}