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:
@@ -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
|
||||
/// (~50–300 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 (~50–300 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, ®istry);
|
||||
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, ®istry);
|
||||
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, ®istry);
|
||||
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, ®istry);
|
||||
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, ®istry),
|
||||
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, ®istry),
|
||||
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",
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user