Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/agent_view/paste.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

2603 lines
110 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Paste routing: bracketed paste, clipboard attachment probe, dropped
//! paths, image paste, and the deferred send-after-paste flow.
#[cfg(test)]
use super::{ActivePane, AgentViewLayout, PromptInputMode, render_dropdown_chrome};
use super::{AgentDeferredSend, AgentView};
use crate::actions::ActionRegistry;
use crate::app::actions::Action;
use crate::app::app_view::InputOutcome;
#[cfg(test)]
use crate::key;
#[cfg(test)]
use crate::theme::Theme;
use crate::views::prompt_widget::{PromptEvent, PromptWidget};
#[cfg(test)]
use crossterm::event::{Event, KeyEvent};
impl AgentView {
/// Insert a plain-text (caption) clipboard paste into the prompt, matching
/// the bracketed arm's whitespace policy + slash/suggestion refresh. The
/// image/file-url portion of a paste is handled by the deferred probe.
fn insert_prompt_plain_text(
&mut self,
clipboard_text: Option<&str>,
) -> (InputOutcome, crate::app::actions::ClipboardTextInsertion) {
self.insert_prompt_text(clipboard_text, false)
}
pub(super) fn insert_bracketed_prompt_text(
&mut self,
text: &str,
) -> (InputOutcome, crate::app::actions::ClipboardTextInsertion) {
self.insert_prompt_text(Some(text), true)
}
fn insert_prompt_text(
&mut self,
clipboard_text: Option<&str>,
activate_bash: bool,
) -> (InputOutcome, crate::app::actions::ClipboardTextInsertion) {
use crate::app::actions::ClipboardTextInsertion;
let Some(text) = clipboard_text else {
return (InputOutcome::Changed, ClipboardTextInsertion::Empty);
};
if text.trim().is_empty() {
return (InputOutcome::Unchanged, ClipboardTextInsertion::Empty);
}
let paste_text = if activate_bash
&& self.prompt_input_mode == super::PromptInputMode::Normal
&& self.prompt.text().is_empty()
{
if let Some(command) = text.strip_prefix("! ") {
self.prompt_input_mode = super::PromptInputMode::Bash;
command
} else {
text
}
} else {
text
};
match self.prompt.handle_paste(paste_text) {
PromptEvent::Edited => {
self.prompt.refresh_slash(&self.session.models);
if let Some(eff) = self.notify_suggestion_text_changed() {
self.pending_effects.push(eff);
}
(InputOutcome::Changed, ClipboardTextInsertion::Inserted)
}
PromptEvent::Ignored => (InputOutcome::Changed, ClipboardTextInsertion::Failed),
}
}
fn reject_shared_queue_image_edit(
&mut self,
pasted: &crate::prompt_images::PastedImage,
) -> bool {
if !matches!(
self.prompt_mode,
crate::app::queue_edit::PromptMode::EditingQueued {
server_id: Some(_),
..
}
) {
return false;
}
crate::prompt_images::cleanup_temp_file(pasted);
self.show_toast("Images can't be attached when editing a shared queued prompt");
true
}
/// Enqueue attachment probing off-thread so paste-then-send remains ordered.
pub(super) fn enqueue_clipboard_attachment_probe(
&mut self,
source: crate::app::actions::ClipboardPasteSource,
change_count: Option<u64>,
) {
let images_dir = crate::prompt_images::session_images_dir(
self.session.session_id.as_ref(),
&self.session.cwd,
);
self.paste_probe_in_flight += 1;
self.pending_effects
.push(crate::app::actions::Effect::ProbeClipboardAttachment {
ctx: crate::app::actions::ClipboardPasteContext {
target: crate::app::actions::ClipboardPasteTarget::AgentPrompt {
agent_id: self.session.id,
images_dir,
},
source,
},
change_count,
});
}
/// Ctrl/Cmd+V paste: a file path in the text resolves synchronously and
/// wins; else the clipboard raster/file-url probe defers off the event loop
/// (image wins over the caption, inserted on completion only if no image);
/// else plain text with no raster inserts synchronously.
pub(super) fn handle_paste_key_deferred(
&mut self,
clipboard_text: crate::app::actions::ClipboardTextRead,
) -> InputOutcome {
let tip_showing = self.ephemeral_tip.current_key()
== Some(crate::tips::clipboard_focus::CLIPBOARD_IMAGE_TIP_KEY);
self.ephemeral_tip
.clear(crate::tips::clipboard_focus::CLIPBOARD_IMAGE_TIP_KEY);
if let Some(text) = clipboard_text.as_deref()
&& let Some((outcome, _)) = self.try_handle_dropped_paths_paste(text)
{
return outcome;
}
if let Some(change_count) =
crate::clipboard::attachment_probe_gate(clipboard_text.as_deref())
{
self.enqueue_clipboard_attachment_probe(
crate::app::actions::ClipboardPasteSource::ClipboardKey {
text: clipboard_text,
tip_showing,
},
change_count,
);
return InputOutcome::Changed;
}
self.insert_prompt_plain_text(clipboard_text.as_deref()).0
}
/// Attach the result of a deferred clipboard attachment probe
/// ([`Effect::ProbeClipboardAttachment`]). The heavy read/decode/persist
/// already ran off-thread; this only mutates prompt state on the event loop.
pub(crate) fn complete_clipboard_attachment_paste(
&mut self,
ctx: crate::app::actions::ClipboardPasteContext,
image: crate::app::actions::ProbedAttachment,
file_urls: Option<String>,
) -> crate::app::actions::ClipboardPasteCompletion {
use crate::app::actions::{
ClipboardPasteCompletion, ClipboardPasteFailure, ProbedAttachment,
};
self.paste_probe_in_flight = self.paste_probe_in_flight.saturating_sub(1);
let insert_deferred_text = matches!(
&image,
ProbedAttachment::NoRaster
| ProbedAttachment::ProbeDropped
| ProbedAttachment::ProbeFailed
);
let attachment = match image {
ProbedAttachment::Image(pasted) => {
if self.reject_shared_queue_image_edit(&pasted) {
return ClipboardPasteCompletion::Failed(
ClipboardPasteFailure::AlreadyReported,
);
}
let preparation = pasted.preview_preparation();
if let Err(msg) = self.prompt.insert_image(pasted) {
self.show_toast_ticks(&msg, 150);
ClipboardPasteCompletion::Failed(ClipboardPasteFailure::AlreadyReported)
} else {
if let Some(preparation) = preparation {
self.pending_effects.push(
crate::app::actions::Effect::PreparePromptImagePreview { preparation },
);
}
ctx.source.tip_showing();
self.prompt.refresh_slash(&self.session.models);
ClipboardPasteCompletion::Handled
}
}
ProbedAttachment::PersistFailed(_) => {
self.show_toast("Couldn't save pasted image");
ClipboardPasteCompletion::Failed(ClipboardPasteFailure::AlreadyReported)
}
ProbedAttachment::NoRaster => ClipboardPasteCompletion::FullMiss,
ProbedAttachment::ProbeDropped => ClipboardPasteCompletion::Dropped,
ProbedAttachment::ProbeFailed => {
ClipboardPasteCompletion::Failed(ClipboardPasteFailure::AttachmentRead)
}
};
let file = if attachment == ClipboardPasteCompletion::FullMiss {
file_urls.as_deref().and_then(|urls| {
self.try_handle_dropped_paths_paste(urls)
.map(|(_, completion)| completion)
})
} else {
None
};
let text = if insert_deferred_text {
ctx.source
.text_to_insert_on_miss()
.filter(|text| !text.trim().is_empty())
.map(|text| self.insert_prompt_plain_text(Some(text)).1)
} else {
None
};
let completion = crate::app::actions::reduce_clipboard_paste_completion(
&ctx.source,
attachment,
file,
text,
);
if completion == ClipboardPasteCompletion::FullMiss && ctx.source.is_clipboard_key() {
crate::clipboard::log_paste_key_empty_host_clipboard(ctx.target.surface_str());
}
completion
}
/// After a deferred paste probe completes, take the kind of any send
/// stashed while the probe(s) were in flight. Returns `None` while probes
/// remain in flight or nothing is stashed. The stash is always cleared; the
/// caller builds the action (via [`Self::build_deferred_send_action`]) only
/// when it actually reissues, so a dropped reissue keeps the draft intact.
pub(crate) fn take_deferred_send_after_paste(&mut self) -> Option<AgentDeferredSend> {
if self.paste_probe_in_flight != 0 {
return None;
}
self.deferred_send.take()
}
/// Build the reissue action for a drained stash, re-deriving the payload
/// from the now-updated prompt so the freshly attached image chip (and its
/// aligned range) travels with it. Call only when actually reissuing — the
/// interject variant consumes the draft (drain images + clear) exactly like
/// the `InterjectPrompt` arm it was stashed from.
pub(crate) fn build_deferred_send_action(&mut self, kind: AgentDeferredSend) -> Option<Action> {
match kind {
AgentDeferredSend::SendPrompt => {
let text = self.prompt.text().to_string();
(!text.trim().is_empty()).then_some(Action::SendPrompt(text))
}
AgentDeferredSend::Interject => {
let text = self.prompt.text().trim().to_string();
if !ActionRegistry::interjection_possible(
self.session.state.is_turn_running(),
!text.is_empty(),
) {
return None;
}
let images = self.prompt.drain_images();
self.prompt.set_text("");
Some(Action::SendPromptNow { text, images })
}
}
}
/// Consume wrap host-image magic paste (`Some` = handled, never as text).
pub(super) fn try_handle_wrap_host_image_paste(&mut self, text: &str) -> Option<InputOutcome> {
let wrap = crate::wrap_clipboard_image::try_decode_wrap_host_image_paste(text)?;
Some(match wrap {
crate::wrap_clipboard_image::WrapImagePaste::Image(data) => {
let pasted = crate::prompt_images::from_clipboard_data(&data);
let _ = self.handle_image_paste_from_data(pasted);
self.prompt.refresh_slash(&self.session.models);
InputOutcome::Changed
}
crate::wrap_clipboard_image::WrapImagePaste::NoImage => InputOutcome::Unchanged,
})
}
/// Parse a paste payload as one or more drop-style file paths and
/// route each entry: image paths become `[Image #N]` chips, non-image
/// paths get inserted as decoded absolute path text.
///
/// Route a popup pane's `Event::Paste(text)` through the drop
/// classifier and fall back to a plain text paste into the shared
/// prompt buffer. Used by the plan-feedback, permission-followup,
/// plan-approval, and question-view paste arms — all of which share
/// the same prompt widget as the main Prompt pane and need identical
/// classifier semantics.
pub(super) fn route_popup_paste(&mut self, text: &str) -> InputOutcome {
if let Some((outcome, _)) = self.try_handle_dropped_paths_paste(text) {
return outcome;
}
let _ = self.prompt.handle_paste(text);
InputOutcome::Changed
}
/// Returns redraw and completion outcomes only when at least one path resolves.
///
/// This is the canonical drag-and-drop / Finder-paste classifier on the
/// main Prompt pane. It must run BEFORE any clipboard image probe so the
/// Finder icon attached to a non-image file path doesn't get rendered
/// as a chip. Other `Event::Paste` arms (permission followup, question
/// view, plan feedback, plan approval) route through here too.
///
/// `refresh_slash` fires exactly once at the end of the loop when any
/// entry was inserted. `notify_suggestion_text_changed` fires only when
/// at least one non-image path was inserted — `[Image #N]` placeholder
/// text doesn't influence @-mention / file-search completions.
///
/// Entries are processed in **source-token order**:
/// `"file://{png} file://{txt}"` → `[Image #N] {canon_txt} `;
/// `"file://{txt} file://{png}"` → `{canon_txt} [Image #N] `.
///
/// **Size guard**: payloads ≥ `DROP_CLASSIFIER_MAX_BYTES`
/// short-circuit to `None`. The early-return lives inside this
/// function (not at each call site) so every paste arm — the
/// main Prompt bracketed-paste arm, the four popup `Event::Paste`
/// arms (plan-feedback, permission-followup, plan-approval,
/// question-view), and the Cmd+V `handle_paste_key_deferred` path
/// (clipboard-text, plus deferred file-urls on completion) — gets the
/// guard uniformly. Real drag-and-drop payloads (one or more
/// `file://` URLs) are at most a few KB; anything ≥ 10 MB is a
/// log/code paste and not worth iterating line-by-line.
pub(super) fn try_handle_dropped_paths_paste(
&mut self,
text: &str,
) -> Option<(InputOutcome, crate::app::actions::ClipboardPasteCompletion)> {
if crate::terminal::terminal_context().is_ssh {
return None;
}
/// Upper bound on the size of a paste payload the drop
/// classifier will scan. 10 MB matches `MAX_SEND_BYTES` for
/// individual image attachments — well above any realistic
/// drop, well below any log/code paste worth iterating.
const DROP_CLASSIFIER_MAX_BYTES: usize = 10 * 1024 * 1024;
if text.len() >= DROP_CLASSIFIER_MAX_BYTES {
return None;
}
let dropped = crate::prompt_images::try_read_dropped_paths(text);
if dropped.is_empty() {
return None;
}
let mut group_open = false;
let mut inserted_image = false;
let mut inserted_non_image = false;
let mut image_cap_reached = false;
for entry in dropped {
match entry {
crate::prompt_images::DroppedPath::Image(img) => {
if image_cap_reached {
continue;
}
if self.prompt.images.len() >= PromptWidget::IMAGE_CAP {
image_cap_reached = true;
self.show_toast(&PromptWidget::cap_reached_toast());
continue;
}
if !group_open {
self.prompt.textarea.begin_undo_group();
group_open = true;
}
if self.handle_image_paste_from_data(img) {
inserted_image = true;
}
}
crate::prompt_images::DroppedPath::NonImage(path) => {
let to_insert = format!("{} ", path.display());
if !group_open {
self.prompt.textarea.begin_undo_group();
group_open = true;
}
if matches!(self.prompt.handle_paste(&to_insert), PromptEvent::Edited) {
inserted_non_image = true;
}
}
}
}
if group_open {
self.prompt.textarea.end_undo_group();
}
if inserted_image || inserted_non_image {
self.prompt.refresh_slash(&self.session.models);
}
if inserted_non_image && let Some(eff) = self.notify_suggestion_text_changed() {
self.pending_effects.push(eff);
}
let completion = if inserted_image || inserted_non_image {
crate::app::actions::ClipboardPasteCompletion::Handled
} else {
crate::app::actions::ClipboardPasteCompletion::Failed(
crate::app::actions::ClipboardPasteFailure::AlreadyReported,
)
};
Some((InputOutcome::Changed, completion))
}
/// Persist a `PastedImage` to the session directory and insert it as an
/// `[Image #N]` chip in the prompt.
///
/// **Does NOT call `refresh_slash`.** Callers are expected to do
/// that once per logical paste event (at the loop boundary for
/// the drag-and-drop classifier, immediately after-return for
/// single-image callers). Centralising the refresh at the caller
/// boundary avoids the (N+1)-call multiplicity that would otherwise
/// arise on a mixed N-image-plus-1-non-image drop.
///
/// Returns `true` when the buffer was actually mutated (chip
/// inserted), `false` when persistence or the cap rejected the
/// insert. Callers that wrap an undo group around a batch of
/// inserts use this to defer opening the group until at least one
/// mutation lands (avoids an empty undo step on Ctrl-Z).
fn handle_image_paste_from_data(
&mut self,
mut pasted: crate::prompt_images::PastedImage,
) -> bool {
if self.reject_shared_queue_image_edit(&pasted) {
return false;
}
let preparation = pasted.preview_preparation();
if let Some(images_dir) = crate::prompt_images::session_images_dir(
self.session.session_id.as_ref(),
&self.session.cwd,
) && let Err(e) = crate::prompt_images::persist_to_session(&mut pasted, &images_dir)
{
tracing::warn!("failed to persist pasted image: {e}");
self.show_toast("Couldn't save pasted image");
return false;
}
if let Err(msg) = self.prompt.insert_image(pasted) {
self.show_toast_ticks(&msg, 150);
return false;
}
if let Some(preparation) = preparation {
self.pending_effects
.push(crate::app::actions::Effect::PreparePromptImagePreview { preparation });
}
true
}
}
#[cfg(test)]
pub(super) mod paste_key_tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::agent::{AgentId, AgentSession, AgentState};
use crate::app::app_view::InputOutcome;
use crate::clipboard::ImageData;
use crate::scrollback::state::ScrollbackState;
use crate::views::prompt_widget::KIND_PASTE;
fn make_agent() -> AgentView {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
AgentView::new(
AgentSession {
id: AgentId(0),
acp_tx: tx,
session_id: None,
models: ModelState::default(),
state: AgentState::Idle,
tracker: crate::acp::tracker::AcpUpdateTracker::new(),
cwd: std::path::PathBuf::from("/tmp"),
is_worktree: false,
forked_from: None,
pending_prompts: std::collections::VecDeque::new(),
next_queue_id: 0,
yolo_mode: false,
auto_mode: false,
prompt_history: Vec::new(),
prompt_history_loading: false,
loading_replay: false,
restore_degree: None,
rate_limited: false,
model_incompatible: false,
available_commands: Vec::new(),
available_commands_generation: 0,
available_tools: None,
model_switch_pending: false,
user_model_preference: None,
deferred_model_switch: None,
bg_tasks: std::collections::BTreeMap::new(),
bg_tool_call_to_task: std::collections::HashMap::new(),
scheduled_tasks: std::collections::HashMap::new(),
in_flight_prompt: None,
current_prompt_id: None,
created_via_new: false,
},
ScrollbackState::new(),
)
}
/// Minimal PNG header (not a valid image, but enough for `from_clipboard_data`
/// which only copies bytes and MIME type without decoding).
fn test_image_data() -> ImageData {
ImageData {
data: vec![
0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, 0, 0, 0, 0,
],
mime_type: "image/png".to_string(),
}
}
#[test]
fn wrap_host_image_none_paste_not_inserted_as_text() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let registry = ActionRegistry::defaults();
let outcome = agent.handle_input(
&Event::Paste(crate::wrap_clipboard_image::MAGIC_NONE.to_string()),
&registry,
);
assert!(matches!(outcome, InputOutcome::Unchanged));
assert!(agent.prompt.text().is_empty());
}
#[test]
fn wrap_host_image_malformed_paste_not_inserted_as_text() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let registry = ActionRegistry::defaults();
let outcome = agent.handle_input(
&Event::Paste("KIGI_WRAP_IMG\nimage/png\n!!!".to_string()),
&registry,
);
assert!(matches!(outcome, InputOutcome::Unchanged));
assert!(agent.prompt.text().is_empty());
}
#[test]
fn paste_key_text_inserts_into_prompt() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let outcome = paste_cmd_v(&mut agent, Some("hello world"));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.text(), "hello world");
}
#[test]
fn paste_key_multiline_text_creates_element() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let text = "line1\nline2\nline3\nline4";
let outcome = paste_cmd_v(&mut agent, Some(text));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.text(), text);
assert_eq!(agent.prompt.textarea().elements().len(), 1);
assert_eq!(agent.prompt.textarea().elements()[0].kind, KIND_PASTE);
}
#[test]
fn paste_key_image_preferred_over_text() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
paste_cmd_v_image(&mut agent, Some("text content"));
assert_eq!(agent.prompt.images.len(), 1);
assert!(agent.prompt.text().contains("[Image #1]"));
assert!(!agent.prompt.text().contains("text content"));
}
#[test]
fn paste_key_image_when_no_text() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
paste_cmd_v_image(&mut agent, None);
assert_eq!(agent.prompt.images.len(), 1);
assert!(agent.prompt.text().contains("[Image #1]"));
}
#[test]
fn paste_key_image_when_text_is_empty_string() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
paste_cmd_v_image(&mut agent, Some(""));
assert_eq!(agent.prompt.images.len(), 1);
}
#[test]
fn paste_key_image_when_text_is_whitespace() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
paste_cmd_v_image(&mut agent, Some(" \n "));
assert_eq!(agent.prompt.images.len(), 1);
}
#[test]
fn paste_key_empty_clipboard_consumes_key() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let outcome = paste_cmd_v(&mut agent, None);
assert!(matches!(outcome, InputOutcome::Changed));
assert!(agent.prompt.text().is_empty());
assert!(agent.prompt.images.is_empty());
}
/// Whitespace-only Cmd+V inserts no text. Trimmed-empty routes to the
/// FileUrlsThenImage probe (to catch an image-only pasteboard), so it defers
/// off the event loop rather than inserting spaces; the completion drops the
/// whitespace caption (a no-image miss with blank text inserts nothing).
#[test]
fn paste_key_whitespace_only_text_with_no_image_or_urls_is_noop() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let _ = paste_cmd_v(&mut agent, Some(" "));
assert!(agent.prompt.text().is_empty());
let _ = paste_cmd_v(&mut agent, Some("\t\n \t"));
assert!(agent.prompt.text().is_empty());
}
#[test]
fn paste_key_appends_to_existing_text() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
agent.prompt.textarea.insert_str("existing ");
let outcome = paste_cmd_v(&mut agent, Some("pasted"));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.text(), "existing pasted");
}
#[test]
fn paste_key_image_with_existing_text_in_prompt() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
agent.prompt.textarea.insert_str("describe: ");
paste_cmd_v_image(&mut agent, None);
assert_eq!(agent.prompt.images.len(), 1);
assert!(agent.prompt.text().contains("describe: "));
assert!(agent.prompt.text().contains("[Image #1]"));
}
/// A single-newline paste is whitespace-only and inserts no text (trimmed
/// empty → deferred probe, whitespace caption dropped on the miss).
#[test]
fn paste_key_single_newline_text_is_noop() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let _ = paste_cmd_v(&mut agent, Some("\n"));
assert!(agent.prompt.text().is_empty());
}
#[test]
fn paste_key_cr_normalized_to_lf() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let outcome = paste_cmd_v(&mut agent, Some("a\rb\rc"));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.text(), "a\nb\nc");
}
#[test]
fn paste_key_tabs_expanded_to_spaces() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let outcome = paste_cmd_v(&mut agent, Some("if true:\n\tpass"));
assert!(matches!(outcome, InputOutcome::Changed));
assert!(!agent.prompt.text().contains('\t'));
assert_eq!(agent.prompt.text(), "if true:\n pass");
}
/// Empty-string clipboard text is whitespace-only: no text is inserted
/// (trimmed-empty → deferred probe, blank caption dropped on the miss).
#[test]
fn paste_key_empty_string_text_no_image_is_noop() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let _ = paste_cmd_v(&mut agent, Some(""));
assert!(agent.prompt.text().is_empty());
}
#[test]
fn paste_key_image_path_detected_as_image() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let dir = tempfile::tempdir().unwrap();
let png_path = dir.path().join("test.png");
let png = make_test_png(10, 10);
std::fs::write(&png_path, &png).unwrap();
let path_str = png_path.to_string_lossy().to_string();
let outcome = paste_cmd_v(&mut agent, Some(&path_str));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.images.len(), 1);
assert!(agent.prompt.text().contains("[Image #"));
assert!(agent.prompt.images[0].preview.is_pending());
assert!(agent.pending_effects.iter().any(|effect| matches!(
effect,
crate::app::actions::Effect::PreparePromptImagePreview { .. }
)));
}
#[test]
fn paste_key_tiny_image_path_cannot_insert_or_send_immediately() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tiny.png");
std::fs::write(&path, make_test_png(1, 1)).unwrap();
let outcome = paste_cmd_v(&mut agent, Some(&path.display().to_string()));
assert!(matches!(outcome, InputOutcome::Changed));
assert!(agent.prompt.images.is_empty());
assert!(!agent.prompt.text().contains("[Image #"));
assert!(!agent.pending_effects.iter().any(|effect| matches!(
effect,
crate::app::actions::Effect::PreparePromptImagePreview { .. }
)));
let enter = agent.handle_prompt_key_for_test(&KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
assert!(!matches!(
enter,
InputOutcome::Action(crate::app::actions::Action::SendPrompt(_))
));
}
#[test]
fn paste_key_non_image_path_pasted_as_text() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let outcome = paste_cmd_v(&mut agent, Some("/tmp/not-an-image.txt"));
assert!(matches!(outcome, InputOutcome::Changed));
assert!(agent.prompt.images.is_empty());
assert_eq!(agent.prompt.text(), "/tmp/not-an-image.txt");
}
#[test]
fn paste_key_non_image_file_url_with_clipboard_icon_uses_path_not_icon() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let dir = tempfile::tempdir().unwrap();
let txt = dir.path().join("notes.txt");
std::fs::write(&txt, b"hello").unwrap();
let url = format!("file://{}", txt.display());
let outcome = paste_cmd_v(&mut agent, Some(&url));
assert!(matches!(outcome, InputOutcome::Changed));
assert!(
agent.prompt.images.is_empty(),
"must NOT insert image chip for non-image file; got images: {:?}",
agent.prompt.images.len()
);
assert!(
!agent.prompt.text().contains("[Image #"),
"no chip placeholder expected for non-image drop; got {:?}",
agent.prompt.text()
);
let canon_txt = dunce::canonicalize(&txt).unwrap();
let want_with_trailing_space = format!("{} ", canon_txt.display());
assert!(
agent.prompt.text().contains(&want_with_trailing_space),
"decoded path + trailing space must be inserted; got {:?}, want substring {:?}",
agent.prompt.text(),
want_with_trailing_space
);
}
/// Bug A regression: when pbpaste returns `None` (the
/// `public.utf8-plain-text` representation is absent on the macOS clipboard)
/// but the deferred probe recovers `public.file-url`, the completion routes
/// the non-image file to decoded path text (not an `[Image #N]` chip). The
/// probe's `FileUrlsThenImage` route suppresses the Finder file-icon raster
/// off-thread, so the completion sees no image.
#[test]
fn paste_key_file_urls_probe_recovers_when_text_is_none() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let dir = tempfile::tempdir().unwrap();
let txt = dir.path().join("only_furl.txt");
std::fs::write(&txt, b"hi").unwrap();
let url = format!("file://{}", txt.display());
let ctx = agent_completion_ctx(&agent, None);
let completion = agent.complete_clipboard_attachment_paste(
ctx,
crate::app::actions::ProbedAttachment::NoRaster,
Some(url),
);
assert_eq!(
completion,
crate::app::actions::ClipboardPasteCompletion::Handled
);
assert!(
agent.prompt.images.is_empty(),
"file-url path must beat the icon image; got images: {:?}",
agent.prompt.images.len()
);
assert!(
!agent.prompt.text().contains("[Image #"),
"no chip placeholder expected; got {:?}",
agent.prompt.text()
);
let canon_txt = dunce::canonicalize(&txt).unwrap();
let want_with_trailing_space = format!("{} ", canon_txt.display());
assert!(
agent.prompt.text().contains(&want_with_trailing_space),
"decoded path + trailing space must be inserted; got {:?}, want substring {:?}",
agent.prompt.text(),
want_with_trailing_space
);
}
/// Bug A regression (sibling of the `None`-text case): macOS `pbpaste`
/// returns `Some("")` rather than `None` in some configurations. The
/// deferred file-url recovery must still route the path on completion.
#[test]
fn paste_key_file_urls_probe_recovers_when_text_is_empty_string() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let dir = tempfile::tempdir().unwrap();
let txt = dir.path().join("empty_text_furl.txt");
std::fs::write(&txt, b"hi").unwrap();
let url = format!("file://{}", txt.display());
let ctx = agent_completion_ctx(&agent, Some(""));
agent.complete_clipboard_attachment_paste(
ctx,
crate::app::actions::ProbedAttachment::NoRaster,
Some(url),
);
assert!(
agent.prompt.images.is_empty(),
"file-url path must beat the icon image even when text is \"\"",
);
let canon_txt = dunce::canonicalize(&txt).unwrap();
let want_with_trailing_space = format!("{} ", canon_txt.display());
assert!(
agent.prompt.text().contains(&want_with_trailing_space),
"decoded path + trailing space must be inserted; got {:?}",
agent.prompt.text(),
);
}
/// Bug A multi-file integration: the macOS pasteboard's `public.file-url`
/// type carries N newline-joined POSIX paths. The completion must (a) route
/// the PNG entry to a chip and (b) route the non-image entry to decoded path
/// text (the Finder file-icon raster is suppressed by the off-thread probe).
#[test]
fn paste_key_file_urls_probe_handles_multi_file_payload() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("snap.png");
let txt = dir.path().join("notes.txt");
std::fs::write(&png, make_test_png(8, 8)).unwrap();
std::fs::write(&txt, b"x").unwrap();
let furl_payload = format!("{}\n{}", png.display(), txt.display());
let ctx = agent_completion_ctx(&agent, None);
agent.complete_clipboard_attachment_paste(
ctx,
crate::app::actions::ProbedAttachment::NoRaster,
Some(furl_payload),
);
assert_eq!(
agent.prompt.images.len(),
1,
"exactly one chip for the real PNG — not the Finder icon",
);
assert!(
agent.prompt.text().contains("[Image #1]"),
"PNG chip placeholder must be present; prompt = {:?}",
agent.prompt.text(),
);
let canon_txt = dunce::canonicalize(&txt).unwrap();
let want_with_trailing_space = format!("{} ", canon_txt.display());
assert!(
agent.prompt.text().contains(&want_with_trailing_space),
"non-image path + trailing space must be inserted; prompt = {:?}",
agent.prompt.text(),
);
}
/// Bug A regression: when the pbpaste text already classifies as drop paths,
/// the text-path resolver wins synchronously and NO probe is deferred (so the
/// off-thread file-url recovery never runs and can't insert a rival path).
#[test]
fn paste_key_file_urls_probe_not_double_inserted_when_text_classifies() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let dir = tempfile::tempdir().unwrap();
let primary = dir.path().join("notes.txt");
std::fs::write(&primary, b"primary").unwrap();
let primary_url = format!("file://{}", primary.display());
let outcome = paste_cmd_v(&mut agent, Some(&primary_url));
assert!(matches!(outcome, InputOutcome::Changed));
assert!(
agent.prompt.images.is_empty(),
"image must lose to classifying text; got images: {}",
agent.prompt.images.len(),
);
assert!(
deferred_probe_target(&agent).is_none(),
"a classifying text paste must not also defer a probe"
);
let canon_primary = dunce::canonicalize(&primary).unwrap();
let want_primary = format!("{} ", canon_primary.display());
assert!(
agent.prompt.text().contains(&want_primary),
"primary text path must be inserted; prompt = {:?}",
agent.prompt.text(),
);
}
#[test]
fn paste_key_non_image_file_url_percent_encoded_space_round_trips() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("My Documents");
std::fs::create_dir_all(&sub).unwrap();
let txt = sub.join("readme report.md");
std::fs::write(&txt, b"x").unwrap();
let encoded = txt.display().to_string().replace(' ', "%20");
let url = format!("file://{}", encoded);
let outcome = paste_cmd_v(&mut agent, Some(&url));
assert!(matches!(outcome, InputOutcome::Changed));
assert!(agent.prompt.images.is_empty());
assert!(
!agent.prompt.text().contains("[Image #"),
"no chip placeholder expected for non-image drop; got {:?}",
agent.prompt.text()
);
let canon_txt = dunce::canonicalize(&txt).unwrap();
let want_with_trailing_space = format!("{} ", canon_txt.display());
assert!(
agent.prompt.text().contains(&want_with_trailing_space),
"percent-encoded path + trailing space must round-trip; got {:?}, want substring {:?}",
agent.prompt.text(),
want_with_trailing_space
);
assert!(
!agent.prompt.text().contains("%20"),
"%20 must be decoded; got {:?}",
agent.prompt.text()
);
}
#[test]
fn paste_key_multi_file_drop_image_plus_non_image_handles_both() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("snap.png");
let txt = dir.path().join("notes.txt");
let png_bytes = make_test_png(8, 8);
std::fs::write(&png, &png_bytes).unwrap();
std::fs::write(&txt, b"hi").unwrap();
let pasted = format!("file://{}\nfile://{}", png.display(), txt.display());
let outcome = paste_cmd_v(&mut agent, Some(&pasted));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.images.len(), 1);
assert!(agent.prompt.text().contains("[Image #1]"));
assert!(
!agent.prompt.text().contains("[Image #2]"),
"non-image must not also produce a chip; prompt = {:?}",
agent.prompt.text()
);
let canon_txt = dunce::canonicalize(&txt).unwrap();
let want_with_trailing_space = format!("{} ", canon_txt.display());
assert!(
agent.prompt.text().contains(&want_with_trailing_space),
"non-image path + trailing space must be inserted; prompt = {:?}, want substring = {:?}",
agent.prompt.text(),
want_with_trailing_space
);
let prompt_text = agent.prompt.text();
let chip_idx = prompt_text.find("[Image #1]").unwrap();
let path_idx = prompt_text.find(&want_with_trailing_space).unwrap();
assert!(
chip_idx < path_idx,
"chip must precede path text in source order; prompt = {prompt_text:?}"
);
}
#[test]
fn paste_key_image_path_with_trailing_newline_still_attaches() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("trail.png");
let png_bytes = make_test_png(8, 8);
std::fs::write(&png, &png_bytes).unwrap();
let pasted = format!("file://{}\n", png.display());
let outcome = paste_cmd_v(&mut agent, Some(&pasted));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.images.len(), 1);
assert!(
agent.prompt.text().contains("[Image #1]"),
"expected `[Image #1]` chip text; got {:?}",
agent.prompt.text()
);
assert!(
!agent.prompt.text().contains("file://"),
"no `file://` fragment should leak into the prompt; got {:?}",
agent.prompt.text()
);
}
/// Mirrors the strongest Cmd+V drop-path test, but exercises the
/// `Event::Paste` (bracketed-paste) branch — the actual path a
/// drag-from-Finder takes through the dispatcher. A future refactor
/// that re-orders the clipboard-image probe ahead of the path
/// classifier on this branch would silently regress the headline
/// bug; this test fails fast in that scenario.
#[test]
fn event_paste_non_image_file_url_inserts_decoded_path_not_chip() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let dir = tempfile::tempdir().unwrap();
let txt = dir.path().join("event_paste.txt");
std::fs::write(&txt, b"hello").unwrap();
let url = format!("file://{}", txt.display());
let registry = ActionRegistry::defaults();
let outcome = agent.handle_input(&Event::Paste(url), &registry);
assert!(
matches!(outcome, InputOutcome::Changed),
"Event::Paste path must claim the drop with `Changed`; got {outcome:?}"
);
assert!(
agent.prompt.images.is_empty(),
"non-image file:// drop must not create an image chip"
);
assert!(
!agent.prompt.text().contains("[Image #"),
"no chip placeholder expected; got {:?}",
agent.prompt.text()
);
let canon_txt = dunce::canonicalize(&txt).unwrap();
let want_with_trailing_space = format!("{} ", canon_txt.display());
assert!(
agent.prompt.text().contains(&want_with_trailing_space),
"decoded path + trailing space must be inserted; got {:?}, want substring {:?}",
agent.prompt.text(),
want_with_trailing_space
);
}
/// A multi-image drop that pushes us past the cap must NOT block
/// subsequent non-image entries — the trailing path text must still
/// be inserted. We assert the cap toast is the live (last-written)
/// toast at the end of the drop; the dedup latch itself is enforced
/// statically by the `image_cap_reached` branch in
/// `try_handle_dropped_paths_paste`.
#[test]
fn paste_key_cap_reached_does_not_block_non_image_insert() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let cap = crate::views::prompt_widget::PromptWidget::IMAGE_CAP;
for _ in 0..(cap - 1) {
agent.prompt.insert_image(test_image_paste()).unwrap();
}
assert_eq!(agent.prompt.images.len(), cap - 1);
let dir = tempfile::tempdir().unwrap();
let mk_png = |name: &str| {
let p = dir.path().join(name);
std::fs::write(&p, make_test_png(8, 8)).unwrap();
p
};
let a = mk_png("a.png");
let b = mk_png("b.png");
let c = mk_png("c.png");
let txt = dir.path().join("note.txt");
std::fs::write(&txt, b"x").unwrap();
let pasted = format!(
"file://{}\nfile://{}\nfile://{}\nfile://{}",
a.display(),
b.display(),
c.display(),
txt.display(),
);
let outcome = paste_cmd_v(&mut agent, Some(&pasted));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.prompt.images.len(), cap);
let canon_txt = dunce::canonicalize(&txt).unwrap();
let want = format!("{} ", canon_txt.display());
assert!(
agent.prompt.text().contains(&want),
"non-image path must still be inserted past the image cap; prompt = {:?}",
agent.prompt.text()
);
let toast_msg = agent
.toast
.as_ref()
.map(|(msg, _ticks)| msg.clone())
.unwrap_or_default();
assert!(
toast_msg.contains("Image limit reached"),
"expected cap toast to be the last toast shown; got {toast_msg:?}"
);
}
/// Drive `agent` through the canonical drop-classifier assertions
/// for one `Event::Paste` arm. The `setup` closure puts the agent
/// into whatever state the dispatcher needs to route paste through
/// the target arm (focus, queue, viewer, etc.). All four arms
/// share these assertions; using a single helper prevents the
/// four bodies from drifting against each other.
fn assert_event_paste_arm_decodes_non_image(
arm_name: &str,
setup: impl FnOnce(&mut AgentView),
) {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
setup(&mut agent);
let dir = tempfile::tempdir().unwrap();
let txt = dir.path().join(format!("{arm_name}_paste.txt"));
std::fs::write(&txt, b"hello").unwrap();
let url = format!("file://{}", txt.display());
let registry = ActionRegistry::defaults();
let outcome = agent.handle_input(&Event::Paste(url), &registry);
assert!(
matches!(outcome, InputOutcome::Changed),
"[{arm_name}] outcome must be Changed; got {outcome:?}"
);
assert!(
agent.prompt.images.is_empty(),
"[{arm_name}] non-image drop must not create an image chip"
);
assert!(
!agent.prompt.text().contains("[Image #"),
"[{arm_name}] no chip placeholder expected; got {:?}",
agent.prompt.text()
);
assert!(
!agent.prompt.text().contains("file://"),
"[{arm_name}] no `file://` fragment may leak into the prompt; got {:?}",
agent.prompt.text()
);
let canon_txt = dunce::canonicalize(&txt).unwrap();
let want = format!("{} ", canon_txt.display());
assert!(
agent.prompt.text().contains(&want),
"[{arm_name}] decoded path + trailing space must appear; got {:?}",
agent.prompt.text()
);
}
/// The plan-feedback / casual-commenting `Event::Paste` arm routes
/// through the canonical drop classifier. Re-introducing a raw
/// `self.prompt.handle_paste(text)` here would skip path decoding
/// and fail this test.
#[test]
fn event_paste_plan_feedback_non_image_file_url_decoded_into_prompt() {
assert_event_paste_arm_decodes_non_image("plan_feedback", |agent| {
agent.enter_casual_commenting_for_test();
assert!(
agent.is_casual_commenting(),
"fixture must satisfy is_casual_commenting() to reach the plan-feedback paste arm"
);
});
}
/// Permission-followup `Event::Paste` arm routes through the classifier.
#[test]
fn event_paste_permission_followup_non_image_file_url_decoded_into_prompt() {
assert_event_paste_arm_decodes_non_image("permission_followup", |agent| {
agent
.permission_queue
.push_back(make_followup_permission_state());
});
}
/// Plan-approval-view `Event::Paste` arm routes through the classifier.
#[test]
fn event_paste_plan_approval_non_image_file_url_decoded_into_prompt() {
assert_event_paste_arm_decodes_non_image("plan_approval", |agent| {
agent.plan_approval_view = Some(make_plan_approval_view_state());
agent.line_viewer = None;
});
}
/// Question-view `Event::Paste` arm routes through the classifier when
/// the question view is in `InputMode` focus.
#[test]
fn event_paste_question_view_input_mode_non_image_file_url_decoded_into_prompt() {
assert_event_paste_arm_decodes_non_image("question_view", |agent| {
agent.question_view = Some(make_question_view_state_in_input_mode());
});
}
/// Build a `PermissionViewState` already in FollowupInput focus —
/// enough for the dispatcher's permission-followup paste arm.
pub(in crate::app::agent_view) fn make_followup_permission_state()
-> crate::views::permission_view::PermissionViewState {
let (response_tx, _rx) = tokio::sync::oneshot::channel();
let request = agent_client_protocol::RequestPermissionRequest::new(
agent_client_protocol::SessionId::new(std::sync::Arc::from("test")),
agent_client_protocol::ToolCallUpdate::new(
agent_client_protocol::ToolCallId::new(std::sync::Arc::from("call-1")),
agent_client_protocol::ToolCallUpdateFields::default(),
),
vec![],
);
let perm = kigi_acp_lib::AcpArgs {
request,
response_tx,
};
crate::views::permission_view::PermissionViewState {
request: perm,
id: 0,
focus: crate::views::permission_view::PermissionFocus::FollowupInput,
options: vec![],
active_idx: 0,
bash_highlights: None,
bash_selection_count: 0,
bash_command_raw: None,
mcp_scope: None,
title: String::new(),
description: vec![],
args_expanded: false,
desc_scroll: 0,
subagent_label: None,
options_area_height: 0,
options_scroll_offset: 0,
}
}
/// Build a minimal `PlanApprovalViewState` — enough for the
/// dispatcher's plan-approval-view paste arm.
pub(in crate::app::agent_view) fn make_plan_approval_view_state()
-> crate::views::plan_approval_view::PlanApprovalViewState {
let (tx, _rx) = tokio::sync::oneshot::channel();
let request = crate::views::plan_approval_view::ExitPlanModeExtRequest {
session_id: "test-session".into(),
tool_call_id: "call-1".into(),
plan_content: Some("# Plan\n\n## Step 1\nDo something".into()),
};
crate::views::plan_approval_view::PlanApprovalViewState::new(
request,
crate::views::prompt_widget::StashedPrompt {
text: String::new(),
cursor: 0,
images: Vec::new(),
chip_elements: Vec::new(),
image_counter: 0,
image_undo_stash: Vec::new(),
},
tx,
)
}
/// Build a `QuestionViewState` already in `InputMode` focus.
pub(in crate::app::agent_view) fn make_question_view_state_in_input_mode()
-> crate::views::question_view::QuestionViewState {
let question = kigi_tools::implementations::kigi::ask_user_question::Question {
question: "Pick one?".to_string(),
options: vec![
kigi_tools::implementations::kigi::ask_user_question::QuestionOption {
label: "A".to_string(),
description: "Option A".to_string(),
preview: None,
id: None,
},
],
multi_select: Some(false),
id: None,
};
let mut state = crate::views::question_view::QuestionViewState::new(
"tc-1".into(),
vec![question],
crate::views::prompt_widget::StashedPrompt {
text: String::new(),
cursor: 0,
images: Vec::new(),
chip_elements: Vec::new(),
image_counter: 0,
image_undo_stash: Vec::new(),
},
);
state.focus = crate::views::question_view::QuestionFocus::InputMode;
state
}
/// Helper: build a minimal `PastedImage` for in-memory cap testing.
/// Uses a real PNG byte payload so `insert_image` accepts it.
fn test_image_paste() -> crate::prompt_images::PastedImage {
let bytes = make_test_png(8, 8);
crate::prompt_images::from_clipboard_data(&crate::clipboard::ImageData {
data: bytes,
mime_type: "image/png".to_string(),
})
}
/// Generate a valid minimal PNG of the given dimensions.
fn make_test_png(width: u32, height: u32) -> Vec<u8> {
use image::{ImageBuffer, Rgba};
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_pixel(width, height, Rgba([128, 64, 32, 255]));
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
buf
}
#[test]
fn regression_question_modal_fullscreen_overcommit() {
use crate::appearance::{LayoutConfig, ScrollbarConfig};
use ratatui::layout::Rect;
let area = Rect::new(0, 0, 80, 25);
let reserved: u16 = 1 + 5 + 1 + 3;
let unclamped: u16 = area.height + 3 + 5;
assert!(unclamped > area.height);
let clamped = unclamped.min(area.height.saturating_sub(reserved));
assert!(clamped + reserved <= area.height);
let layout_cfg = LayoutConfig::default();
let scrollbar_cfg = ScrollbarConfig::default();
let layout = AgentViewLayout::compute(
area,
&layout_cfg,
&scrollbar_cfg,
0,
clamped,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
false,
);
assert!(layout.prompt.y + layout.prompt.height <= area.height);
}
/// Wiping a substantial main-prompt draft routes `Action::ShowUndoTip`:
/// the end-to-end happy path the whole feature exists for.
#[test]
fn main_prompt_substantial_wipe_routes_show_undo_tip() {
let mut agent = make_agent();
agent.prompt.set_contextual_hints(true, true);
for _ in 0..25 {
let _ = agent.handle_prompt_key_for_test(&key!('x').to_key_event());
}
let outcome = agent.handle_prompt_key_for_test(&key!('c', CONTROL).to_key_event());
assert!(
matches!(outcome, InputOutcome::Action(Action::ShowUndoTip)),
"substantial main-prompt wipe must route show, got {outcome:?}"
);
}
/// Ctrl+Z while the undo tip is on screen is an acceptance: it restores the
/// wiped draft and retires the hint (the guarded branch that also emits the
/// `accepted` telemetry; the emit itself has no in-process capture sink).
#[test]
fn ctrl_z_accepts_and_retires_undo_tip() {
let mut agent = make_agent();
agent.prompt.set_contextual_hints(true, true);
for _ in 0..25 {
let _ = agent.handle_prompt_key_for_test(&key!('x').to_key_event());
}
let _ = agent.handle_prompt_key_for_test(&key!('c', CONTROL).to_key_event());
assert!(agent.prompt.text().is_empty(), "ctrl+c wiped the draft");
let _ = agent.ephemeral_tip.show(
crate::tips::clear_detector::undo_tip(),
&mut std::collections::HashMap::new(),
);
let _ = agent.handle_prompt_key_for_test(&key!('z', CONTROL).to_key_event());
assert!(
!agent.prompt.text().is_empty(),
"ctrl+z restored the wiped draft"
);
assert!(
!agent.ephemeral_tip.is_active(),
"accepting ctrl+z retires the undo tip"
);
}
/// Ctrl+Z attributes ONLY the undo tip: with a different tip on screen the
/// undo-accept guard is false, so that tip is left untouched (no acceptance
/// is misattributed to it).
#[test]
fn ctrl_z_leaves_a_non_undo_tip_untouched() {
let mut agent = make_agent();
agent.prompt.set_contextual_hints(true, true);
for _ in 0..25 {
let _ = agent.handle_prompt_key_for_test(&key!('x').to_key_event());
}
let _ = agent.handle_prompt_key_for_test(&key!('c', CONTROL).to_key_event());
let _ = agent.ephemeral_tip.show(
crate::tips::clipboard_focus::clipboard_image_tip(),
&mut std::collections::HashMap::new(),
);
let _ = agent.handle_prompt_key_for_test(&key!('z', CONTROL).to_key_event());
assert_eq!(
agent.ephemeral_tip.current_key(),
Some(crate::tips::clipboard_focus::CLIPBOARD_IMAGE_TIP_KEY),
"ctrl+z must not retire a tip that is not the undo tip"
);
}
/// Type a draft across into a planning keyword so the prompt's one-shot
/// plan-nudge fire is armed (mirrors a real keypress without the
/// auto-managed input-mode reset a full route would apply).
fn arm_plan_nudge(agent: &mut AgentView) {
agent.prompt.set_contextual_hints(true, true);
for ch in "plan".chars() {
let _ = agent.prompt.handle_key(&crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(ch),
crossterm::event::KeyModifiers::NONE,
));
}
}
/// Typing a planning keyword into an idle, normal-mode prompt that is not
/// already in plan mode routes `Action::ShowPlanNudge`.
#[test]
fn typed_planning_keyword_routes_show_plan_nudge() {
let mut agent = make_agent();
arm_plan_nudge(&mut agent);
assert!(
matches!(agent.take_prompt_tip_signal(), Some(Action::ShowPlanNudge)),
"idle normal-mode planning keyword must route the plan nudge"
);
}
/// Plan-nudge gates: already in plan mode (optimistic read), a busy turn,
/// or a special (bash/feedback/remember) input mode each suppress it.
#[test]
fn plan_nudge_suppressed_by_state_gates() {
let mut agent = make_agent();
agent.plan_mode_pending = Some(true);
arm_plan_nudge(&mut agent);
assert!(
agent.take_prompt_tip_signal().is_none(),
"plan mode must suppress the nudge"
);
let mut agent = make_agent();
agent.session.state = AgentState::TurnRunning;
arm_plan_nudge(&mut agent);
assert!(
agent.take_prompt_tip_signal().is_none(),
"a busy turn must suppress the nudge"
);
let mut agent = make_agent();
agent.prompt_input_mode = PromptInputMode::Bash;
arm_plan_nudge(&mut agent);
assert!(
agent.take_prompt_tip_signal().is_none(),
"a non-Normal input mode must suppress the nudge"
);
}
/// A paste retires the clipboard-image hint that advertised it.
#[test]
fn paste_clears_clipboard_image_tip() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let _ = agent.ephemeral_tip.show(
crate::tips::clipboard_focus::clipboard_image_tip(),
&mut std::collections::HashMap::new(),
);
assert!(agent.ephemeral_tip.is_active());
let _ = paste_cmd_v(&mut agent, Some("hello"));
assert!(
!agent.ephemeral_tip.is_active(),
"paste must clear the clipboard-image tip"
);
}
/// A clipboard-IMAGE paste while the hint is on screen runs the acceptance
/// branch (the guarded `contextual_tip` emit): the image attaches and the
/// hint retires. The emission has no in-process sink, so this pins the
/// guarded branch's observable behavior; `current_key()` is unit-tested in
/// `tips::ephemeral` and the mapping in the telemetry crate.
#[test]
fn image_paste_accepts_clipboard_tip_and_attaches() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let _ = agent.ephemeral_tip.show(
crate::tips::clipboard_focus::clipboard_image_tip(),
&mut std::collections::HashMap::new(),
);
assert_eq!(
agent.ephemeral_tip.current_key(),
Some(crate::tips::clipboard_focus::CLIPBOARD_IMAGE_TIP_KEY)
);
paste_cmd_v_image(&mut agent, None);
assert_eq!(agent.prompt.images.len(), 1, "image attached");
assert!(
!agent.ephemeral_tip.is_active(),
"the image paste retired the clipboard-image hint"
);
}
/// A bracketed `Event::Paste` (not the Cmd+V chord) also retires the
/// clipboard-image hint (regression guard). A 5-line, no-`://` payload
/// skips the macOS attachment probe, so the test never reads the real
/// pasteboard; the clear runs at the top of the prompt paste arm regardless.
#[test]
fn bracketed_paste_clears_clipboard_image_tip() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let _ = agent.ephemeral_tip.show(
crate::tips::clipboard_focus::clipboard_image_tip(),
&mut std::collections::HashMap::new(),
);
assert!(agent.ephemeral_tip.is_active());
let registry = ActionRegistry::defaults();
let _ = agent.handle_input(&Event::Paste("a\nb\nc\nd\ne".to_string()), &registry);
assert!(
!agent.ephemeral_tip.is_active(),
"bracketed paste must clear the clipboard-image tip"
);
}
/// `show_ephemeral_tip` refuses shows that cannot paint, so no seen count,
/// TTL, or telemetry burns invisibly: an unknown/short terminal, or any
/// occluding view. One case per occluder predicate term — a per-term typo
/// (wrong field, duplicate, omission) fails exactly one assertion — closed
/// by a non-vacuous success that shows and counts.
#[test]
fn ephemeral_tip_show_refused_while_unrenderable() {
use std::collections::HashMap;
let mut agent = make_agent();
let mut counts: HashMap<&'static str, u32> = HashMap::new();
let gated = || {
crate::tips::EphemeralTip::new("t", ratatui::text::Line::from("hint"))
.with_session_seen_cap("t_seen", 1)
};
let assert_refused =
|agent: &mut AgentView, counts: &mut HashMap<&'static str, u32>, why: &str| {
assert!(!agent.show_ephemeral_tip(gated(), counts), "{why}");
assert!(!agent.ephemeral_tip.is_active(), "{why}: tip must not show");
assert!(counts.is_empty(), "{why}: must not burn a count");
};
assert_refused(&mut agent, &mut counts, "unknown size");
agent.last_terminal_size = (80, 16);
assert_refused(&mut agent, &mut counts, "short terminal");
agent.last_terminal_size = (80, 30);
agent.active_subagent = Some("child".into());
assert_refused(&mut agent, &mut counts, "subagent takeover");
agent.active_subagent = None;
agent.line_viewer =
crate::views::file_search::line_viewer::LineViewerState::open_markdown_content(
"x.md",
"body".to_string(),
None,
);
assert!(agent.line_viewer.is_some(), "line viewer fixture must open");
assert_refused(&mut agent, &mut counts, "line viewer");
agent.line_viewer = None;
agent.image_viewer = Some(
crate::prompt_images::ImageViewerState::open_from_path_deferred(std::path::Path::new(
"x.png",
)),
);
assert_refused(&mut agent, &mut counts, "image viewer");
agent.image_viewer = None;
agent.video_viewer = Some(crate::prompt_images::VideoViewerState::test_stub());
assert_refused(&mut agent, &mut counts, "video viewer");
agent.video_viewer = None;
agent.block_viewer = Some(crate::views::block_viewer::BlockViewerPane::for_plain_text(
"t", "content",
));
assert_refused(&mut agent, &mut counts, "block viewer");
agent.block_viewer = None;
agent.gboom = Some(crate::gboom::GboomState::new());
assert_refused(&mut agent, &mut counts, "gboom");
agent.gboom = None;
agent.extensions_modal = Some(crate::views::extensions_modal::ExtensionsModalState::new(
crate::views::extensions_modal::ExtensionsTab::Hooks,
));
assert_refused(&mut agent, &mut counts, "extensions modal");
agent.extensions_modal = None;
agent.agents_modal = Some(crate::views::agents_modal::AgentsModalState::new(
std::path::Path::new("/nonexistent"),
&HashMap::new(),
&crate::app::bundle::BundleState::default(),
None,
None,
));
assert_refused(&mut agent, &mut counts, "agents modal");
agent.agents_modal = None;
agent.show_goal_detail = true;
agent.goal_state = Some(crate::app::agent::GoalDisplayState::test_stub());
assert_refused(&mut agent, &mut counts, "goal detail overlay");
agent.show_goal_detail = false;
agent.goal_state = None;
agent.prompt.suggestions.dropdown.open = true;
assert_refused(&mut agent, &mut counts, "prompt dropdown");
agent.prompt.suggestions.dropdown.open = false;
assert!(
agent.show_ephemeral_tip(gated(), &mut counts),
"renderable show must take the slot"
);
assert!(agent.ephemeral_tip.is_active());
assert_eq!(counts.get("t_seen"), Some(&1));
}
/// A resize event must close the show gate until the next draw
/// re-measures — in EITHER direction. The recorded height describes a
/// possibly chrome-shrunk paint rect (dashboard overlay header/popup,
/// dev tracing split), so even a grown terminal does not prove the
/// banner row can paint; acting on any extrapolated height could burn
/// a seen count on a tip that never shows.
#[test]
fn ephemeral_tip_show_gate_refuses_between_resize_and_redraw() {
let mut agent = make_agent();
let mut counts = std::collections::HashMap::new();
let gated = || {
crate::tips::EphemeralTip::new("t", ratatui::text::Line::from("hint"))
.with_session_seen_cap("t_seen", 1)
};
agent.note_terminal_size((80, 30));
agent.note_terminal_resize();
assert!(!agent.show_ephemeral_tip(gated(), &mut counts));
assert!(!agent.ephemeral_tip.is_active());
assert!(counts.is_empty(), "stale-size show must not burn a count");
assert_eq!(
agent.last_terminal_size,
(80, 30),
"the event must not overwrite the draw-measured rect size"
);
agent.note_terminal_size((80, 30));
assert!(agent.show_ephemeral_tip(gated(), &mut counts));
assert!(agent.ephemeral_tip.is_active());
assert_eq!(counts.get("t_seen"), Some(&1));
}
/// `note_terminal_size` keeps the draw-path semantics it replaced:
/// Kitty IDs are invalidated only on an actual size change, the
/// `(0, 0)` pre-first-draw state never counts as a resize, and every
/// re-measure clears the resize-event staleness flag.
#[test]
fn note_terminal_size_invalidates_kitty_ids_only_on_change() {
let mut agent = make_agent();
let path = std::path::PathBuf::from("/img.png");
agent.inline_media_ids.insert(path, 2);
agent.note_terminal_size((80, 30));
assert_eq!(agent.last_terminal_size, (80, 30));
assert!(!agent.inline_media_ids.is_empty());
agent.note_terminal_size((80, 30));
assert!(!agent.inline_media_ids.is_empty());
agent.note_terminal_resize();
assert!(agent.terminal_size_stale);
agent.note_terminal_size((80, 10));
assert_eq!(agent.last_terminal_size, (80, 10));
assert!(agent.inline_media_ids.is_empty());
assert!(!agent.terminal_size_stale, "draw re-measure ends staleness");
}
/// `[Copy source]` copies the diagram source with NO render dispatched
/// (needs no PNG), in every build; a click outside every hit-rect falls
/// through. Lazy `[Open]`/`[Copy path]` routing is covered (engine-gated) by
/// `mermaid_open_click_routes_to_lazy_render`.
#[test]
fn mermaid_copy_source_click_copies_without_render() {
use crate::scrollback::blocks::mermaid_content::AffordanceKind;
use ratatui::layout::Rect;
let mut agent = make_agent();
agent.inline_media_hits.mermaid_sources = vec!["flowchart TD\nA-->B\n".to_string()];
agent.inline_media_hits.mermaid_buttons =
vec![(Rect::new(35, 0, 13, 1), AffordanceKind::CopySource, 0)];
let out = agent.handle_inline_media_click(36, 0);
assert!(matches!(out, Some(InputOutcome::Changed)));
let toast = agent
.toast
.as_ref()
.map(|(m, _)| m.clone())
.unwrap_or_default();
assert!(
toast.starts_with("Copied") || toast.starts_with("Copy failed"),
"copy-source emits a clipboard toast, got {toast:?}",
);
assert!(
!agent.mermaid_needs_tick(),
"copy source dispatches no render"
);
agent.toast = None;
assert!(agent.handle_inline_media_click(60, 5).is_none());
}
/// `[Open]`/`[Copy path]` route to the lazy render path: with no session dir
/// (so nowhere to cache a PNG) the request reports "not ready" — proving the
/// click reached `request_mermaid_render` rather than an eager/open path.
#[test]
fn mermaid_open_click_routes_to_lazy_render() {
use crate::scrollback::blocks::mermaid_content::AffordanceKind;
use ratatui::layout::Rect;
for kind in [AffordanceKind::Open, AffordanceKind::CopyPath] {
let mut agent = make_agent();
agent.inline_media_hits.mermaid_sources = vec!["flowchart TD\nA-->B\n".to_string()];
agent.inline_media_hits.mermaid_buttons = vec![(Rect::new(12, 0, 6, 1), kind, 0)];
let out = agent.handle_inline_media_click(13, 0);
assert!(matches!(out, Some(InputOutcome::Changed)));
let toast = agent
.toast
.as_ref()
.map(|(m, _)| m.clone())
.unwrap_or_default();
assert!(
toast.contains("not ready"),
"{kind:?} routes to the lazy render path (no session dir): {toast:?}",
);
}
}
/// The real painter (`paint_diagram_affordances`) lays the row from the
/// single layout source of truth: a leading dim `◇ mermaid` label then the
/// three always-clickable buttons (each registering a hit-rect carrying the
/// source), with the buttons shifted right past the label.
#[test]
fn paints_affordance_row_with_label_and_registers_all_buttons() {
use crate::scrollback::blocks::mermaid_content::{AffordanceKind, affordance_row};
use crate::scrollback::render::DiagramAffordancePlacement;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let theme = Theme::current();
let source = "flowchart TD\nA-->B\n".to_string();
let layout = affordance_row(false);
let cols = layout.buttons.map(|b| b.col);
let mut agent = make_agent();
let rect = Rect::new(0, 0, 80, 1);
let mut buf = Buffer::empty(rect);
agent.paint_diagram_affordances(
&mut buf,
vec![DiagramAffordancePlacement {
screen_rect: rect,
source: source.clone(),
}],
&theme,
);
let span = |col: u16, w: u16| -> String {
(col..col + w)
.map(|x| buf.cell((x, 0)).unwrap().symbol().to_string())
.collect()
};
assert_eq!(span(0, 9), "\u{25c7} mermaid");
assert_eq!(
buf.cell((0, 0)).unwrap().fg,
theme.gray_dim,
"the ◇ mermaid label is dim",
);
assert_eq!(span(cols[0], 12), "[Open Image]");
assert_eq!(span(cols[1], 17), "[Copy Image Path]");
assert_eq!(span(cols[2], 13), "[Copy Source]");
let buttons = &agent.inline_media_hits.mermaid_buttons;
assert_eq!(buttons.len(), 3);
assert_eq!(
buttons.iter().map(|&(_, k, _)| k).collect::<Vec<_>>(),
vec![
AffordanceKind::Open,
AffordanceKind::CopyPath,
AffordanceKind::CopySource
],
);
for (i, &(r, _, idx)) in buttons.iter().enumerate() {
assert_eq!(r.x, cols[i], "hit-rect aligns with painted column");
assert_eq!(idx, 0, "all buttons index the one source");
}
assert_eq!(agent.inline_media_hits.mermaid_sources, vec![source]);
}
/// The hovered button is highlighted (BOLD|UNDERLINED, `text_primary`); every
/// other button stays at the idle `gray` (brighter than the dim `gray_dim`
/// label so it stays discoverable), with no bold/underline. With the cursor
/// off the row, all buttons are idle.
#[test]
fn paints_affordance_row_highlights_only_the_hovered_button() {
use crate::scrollback::blocks::mermaid_content::affordance_row;
use crate::scrollback::render::DiagramAffordancePlacement;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
let theme = Theme::current();
let cols = affordance_row(false).buttons.map(|b| b.col);
let placement = |rect: Rect| DiagramAffordancePlacement {
screen_rect: rect,
source: "A-->B\n".to_string(),
};
let rect = Rect::new(0, 0, 80, 1);
let modifier = |buf: &Buffer, x: u16| buf.cell((x, 0)).unwrap().modifier;
let highlighted = |buf: &Buffer, x: u16| {
modifier(buf, x).contains(Modifier::BOLD | Modifier::UNDERLINED)
&& buf.cell((x, 0)).unwrap().fg == theme.text_primary
};
let idle = |buf: &Buffer, x: u16| {
!modifier(buf, x).intersects(Modifier::BOLD | Modifier::UNDERLINED)
&& buf.cell((x, 0)).unwrap().fg == theme.gray
};
let mut agent = make_agent();
agent.last_mouse_pos = (cols[1] + 1, 0);
let mut buf = Buffer::empty(rect);
agent.paint_diagram_affordances(&mut buf, vec![placement(rect)], &theme);
assert!(
highlighted(&buf, cols[1]),
"hovered [Copy Image Path] is BOLD|UNDERLINED + text_primary",
);
assert!(
idle(&buf, cols[0]),
"non-hovered [Open Image] stays idle gray"
);
assert!(
idle(&buf, cols[2]),
"non-hovered [Copy Source] stays idle gray",
);
assert_eq!(buf.cell((0, 0)).unwrap().fg, theme.gray_dim);
assert!(
!modifier(&buf, 0).contains(Modifier::BOLD),
"label not bold"
);
let mut agent = make_agent();
agent.last_mouse_pos = (79, 0);
let mut buf = Buffer::empty(rect);
agent.paint_diagram_affordances(&mut buf, vec![placement(rect)], &theme);
for col in cols {
assert!(idle(&buf, col), "no hover ⇒ all buttons idle gray");
}
}
/// Narrow rows clip whole segments rather than spilling past the row width:
/// a row wide enough for the label + `[Open]` paints just those and registers
/// only `[Open]`'s hit-rect; the clipped buttons register none.
#[test]
fn paints_affordance_row_clips_segments_to_row_width() {
use crate::scrollback::render::DiagramAffordancePlacement;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let theme = Theme::current();
let mut agent = make_agent();
let rect = Rect::new(0, 0, 24, 1);
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 1));
agent.paint_diagram_affordances(
&mut buf,
vec![DiagramAffordancePlacement {
screen_rect: rect,
source: "A-->B\n".to_string(),
}],
&theme,
);
let row: String = (0..80)
.filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol()))
.collect();
assert!(
row.contains("[Open Image]"),
"[Open Image] fits and is painted: {row:?}"
);
assert!(
!row.contains("[Copy"),
"clipped segments are not painted: {row:?}"
);
assert_eq!(
agent.inline_media_hits.mermaid_buttons.len(),
1,
"only the [Open Image] hit-rect is registered",
);
}
/// `render_dropdown_chrome` anchors the items band above the prompt by
/// default (full TUI) and below it when `below = true` (minimal mode).
#[test]
fn dropdown_chrome_anchors_above_or_below_the_prompt() {
use crate::appearance::LayoutConfig;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let theme = Theme::current();
let layout_cfg = LayoutConfig::default();
let area = Rect::new(0, 0, 80, 25);
let prompt = Rect::new(0, 10, 80, 2);
let item_rows = 3u16;
let mut buf = Buffer::empty(area);
let above = render_dropdown_chrome(
&mut buf,
3,
item_rows,
None,
prompt,
area,
&layout_cfg,
false,
false,
&theme,
)
.expect("above chrome fits");
assert!(
above.items.y < prompt.y,
"above: items.y {} should be above prompt.y {}",
above.items.y,
prompt.y
);
let mut buf = Buffer::empty(area);
let below = render_dropdown_chrome(
&mut buf,
3,
item_rows,
None,
prompt,
area,
&layout_cfg,
false,
true,
&theme,
)
.expect("below chrome fits");
assert!(
below.items.y >= prompt.y + prompt.height,
"below: items.y {} should be at/after prompt bottom {}",
below.items.y,
prompt.y + prompt.height
);
assert_eq!(below.items.height, item_rows);
}
/// Minimal ("embedded") dropdown chrome is flush-left (W-38): no outer
/// horizontal padding around the panel and no content inset for the item
/// rows, so the dropdown's `` marker sits at column 0 under the prompt's.
/// The full TUI keeps the layout hpad + 1-col item inset in its boxed panel.
#[test]
#[serial_test::serial]
fn dropdown_chrome_embedded_is_flush_left() {
use crate::appearance::LayoutConfig;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
struct EmbedReset;
impl Drop for EmbedReset {
fn drop(&mut self) {
crate::views::modal_window::set_embedded(false);
}
}
let theme = Theme::current();
let layout_cfg = LayoutConfig::default();
let area = Rect::new(0, 0, 80, 25);
let prompt = Rect::new(0, 10, 80, 2);
let mut buf = Buffer::empty(area);
let full = render_dropdown_chrome(
&mut buf,
3,
3,
None,
prompt,
area,
&layout_cfg,
false,
false,
&theme,
)
.expect("full chrome fits");
assert_eq!(full.panel.x, area.x + layout_cfg.eff_hpad_left(false));
assert_eq!(full.items.x, prompt.x + 1 + layout_cfg.eff_hpad_left(false));
let _reset = EmbedReset;
crate::views::modal_window::set_embedded(true);
let mut buf = Buffer::empty(area);
let embedded = render_dropdown_chrome(
&mut buf,
3,
3,
None,
prompt,
area,
&layout_cfg,
false,
true,
&theme,
)
.expect("embedded chrome fits");
assert_eq!(embedded.panel.x, area.x, "no outer hpad in minimal");
assert_eq!(embedded.panel.width, area.width, "panel spans full width");
assert_eq!(
embedded.items.x, prompt.x,
"items flush with the prompt's left edge"
);
assert_eq!(embedded.items.width, prompt.width);
}
/// The tool-media inline-image path (`build_inline_media_escapes`, still live
/// for tool calls) transmits the bytes (`a=t`) before placing them (`a=p`) on
/// the first paint, then places only (no re-transmit) on a later frame for
/// the same path — a place-without-transmit would render blank.
#[test]
fn tool_media_first_frame_transmits_then_places_only() {
use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test};
let _g = set_protocol_for_test(GraphicsProtocol::Kitty);
let mut agent = make_agent();
let path = std::path::PathBuf::from("/tmp/tool-media.png");
agent
.inline_media_cache
.insert(path.clone(), make_test_png(40, 20));
let placement = crate::scrollback::render::InlineMediaPlacement {
info: crate::prompt_images::InlineMediaInfo {
path: path.clone(),
width: 40,
height: 20,
is_video: false,
alt_text: String::new(),
},
screen_rect: ratatui::layout::Rect::new(0, 0, 20, 6),
full_rows: 6,
top_crop_rows: 0,
filepath_screen_rect: None,
open_button_screen_rect: None,
has_button_row: true,
};
let first = agent
.build_inline_media_escapes(&placement)
.expect("first frame emits escapes");
assert!(
first.contains("\x1b_G"),
"first frame is a Kitty graphics escape"
);
assert!(
first.contains("a=t"),
"first frame transmits the bytes (a=t), not place-only: {first:?}",
);
assert!(
first.contains("a=p"),
"first frame also places the image (a=p)"
);
let second = agent
.build_inline_media_escapes(&placement)
.expect("second frame emits escapes");
assert!(second.contains("a=p"), "second frame places the image");
assert!(
!second.contains("a=t"),
"second frame must not re-transmit the bytes: {second:?}",
);
}
/// Draining an agent with live inline-media placements returns delete
/// escapes for every placed id — including ids placed by subagent
/// fullscreen views — and resets the tracking state so the next draw
/// re-transmits (used when another view takes over the frame and the
/// agent's per-frame clears stop running).
#[test]
fn take_inline_media_clear_escapes_drains_placements() {
let mut agent = make_agent();
agent
.inline_media_ids
.insert(std::path::PathBuf::from("/tmp/a.png"), 2);
agent
.inline_media_ids
.insert(std::path::PathBuf::from("/tmp/b.png"), 3);
agent.last_placed_ids = [2, 3].into_iter().collect();
agent.inline_media_active = true;
let mut child = make_agent();
child
.inline_media_ids
.insert(std::path::PathBuf::from("/tmp/c.png"), 4);
child.inline_media_active = true;
agent
.subagent_views
.insert("child-sid".into(), Box::new(child));
let esc = agent
.take_inline_media_clear_escapes()
.expect("drains placed media");
assert!(
esc.contains(&crate::terminal::image::clear_kitty_image(2)),
"deletes id 2: {esc:?}"
);
assert!(
esc.contains(&crate::terminal::image::clear_kitty_image(3)),
"deletes id 3: {esc:?}"
);
assert!(
esc.contains(&crate::terminal::image::clear_kitty_image(4)),
"deletes the subagent view's id 4: {esc:?}"
);
assert!(!agent.inline_media_active);
assert!(agent.inline_media_ids.is_empty());
assert!(agent.last_placed_ids.is_empty());
assert!(agent.inline_video.is_none());
assert!(agent.take_inline_media_clear_escapes().is_none());
}
/// An agent with no placements has nothing to clear.
#[test]
fn take_inline_media_clear_escapes_none_when_no_placements() {
let mut agent = make_agent();
assert!(agent.take_inline_media_clear_escapes().is_none());
}
/// The own-only drain deletes this view's placements but leaves
/// `subagent_views` untouched, so the fullscreen takeover doesn't force
/// the active child into a pointless re-transmit.
#[test]
fn take_own_inline_media_clear_escapes_leaves_children() {
let mut agent = make_agent();
agent
.inline_media_ids
.insert(std::path::PathBuf::from("/tmp/a.png"), 2);
agent.last_placed_ids = [2].into_iter().collect();
agent.inline_media_active = true;
let mut child = make_agent();
child
.inline_media_ids
.insert(std::path::PathBuf::from("/tmp/c.png"), 4);
child.inline_media_active = true;
agent
.subagent_views
.insert("child-sid".into(), Box::new(child));
let esc = agent
.take_own_inline_media_clear_escapes()
.expect("drains own placed media");
assert!(
esc.contains(&crate::terminal::image::clear_kitty_image(2)),
"deletes own id 2: {esc:?}"
);
assert!(
!esc.contains(&crate::terminal::image::clear_kitty_image(4)),
"must not delete the child's id 4: {esc:?}"
);
assert!(!agent.inline_media_active);
assert!(agent.inline_media_ids.is_empty());
assert!(agent.last_placed_ids.is_empty());
let child = agent.subagent_views.get("child-sid").unwrap();
assert!(child.inline_media_active);
assert_eq!(child.inline_media_ids.len(), 1);
}
/// Draw one 80x30 frame — shared fixture for the subagent-takeover
/// inline-media regression tests below.
fn draw_media_frame(agent: &mut AgentView) {
let registry = ActionRegistry::defaults();
let area = ratatui::layout::Rect::new(0, 0, 80, 30);
let mut buf = ratatui::buffer::Buffer::empty(area);
let mut scratch = crate::scrollback::render::ScratchBuffer::new();
let bundle = crate::app::bundle::BundleState::default();
agent.draw(
area,
&mut buf,
&registry,
&mut scratch,
None,
false,
0,
None,
&bundle,
false,
&mut Vec::new(),
);
}
/// The scrolled-off/overlay branch of `AgentView::draw` (render.rs) must
/// stop live inline playback through `stop_inline_playback` — dropping
/// the frame set and REQUESTING a post-draw purge, never purging
/// synchronously mid-frame. Pins the render.rs wiring itself (the
/// helper alone is covered in media.rs). Serialized: the deferred flag
/// is process-wide.
#[test]
#[serial_test::serial(MEMORY_RELEASE_DEFER)]
fn scrolled_off_video_stop_requests_post_draw_release() {
use crate::memory_release::test_support;
test_support::install_counting_hook();
crate::memory_release::run_deferred_release();
let mut agent = make_agent();
agent.inline_media_active = true;
agent.inline_video = Some(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,
});
let before = test_support::calls();
draw_media_frame(&mut agent);
assert!(
agent.inline_video.is_none(),
"scroll-off branch must stop playback"
);
assert_eq!(
test_support::calls(),
before,
"no synchronous purge inside AgentView::draw"
);
crate::memory_release::run_deferred_release();
assert_eq!(
test_support::calls(),
before + 1,
"the post-draw drain must purge the dropped frame set"
);
}
/// Entering the fullscreen subagent view must delete the parent's Kitty
/// placements: the takeover early-returns before every normal per-frame
/// clear path, and Kitty images survive cell overdraw, so without the
/// takeover-time drain the parent's image bleeds through the child view.
#[test]
fn subagent_fullscreen_draw_clears_parent_inline_media() {
let mut agent = make_agent();
agent
.inline_media_ids
.insert(std::path::PathBuf::from("/tmp/a.png"), 2);
agent.last_placed_ids = [2].into_iter().collect();
agent.inline_media_active = true;
agent
.subagent_views
.insert("child-sid".into(), Box::new(make_agent()));
agent.active_subagent = Some("child-sid".into());
draw_media_frame(&mut agent);
assert!(
!agent.inline_media_active,
"takeover frame must drain the parent's inline media"
);
assert!(agent.inline_media_ids.is_empty());
assert!(agent.last_placed_ids.is_empty());
}
/// Symmetric regression: after the fullscreen subagent view closes, the
/// child's per-frame clears stop running, so the parent's next normal draw
/// must delete whatever the child placed while fullscreen.
#[test]
fn draw_after_subagent_close_clears_child_inline_media() {
let mut agent = make_agent();
let mut child = make_agent();
child
.inline_media_ids
.insert(std::path::PathBuf::from("/tmp/c.png"), 4);
child.inline_media_active = true;
agent
.subagent_views
.insert("child-sid".into(), Box::new(child));
assert!(agent.active_subagent.is_none(), "subagent view is closed");
draw_media_frame(&mut agent);
let child = agent.subagent_views.get("child-sid").unwrap();
assert!(
!child.inline_media_active,
"normal draw must drain a closed subagent view's inline media"
);
assert!(child.inline_media_ids.is_empty());
}
fn ctrl_v_key() -> KeyEvent {
key!('v', CONTROL).to_key_event()
}
/// Target of the enqueued deferred-probe effect, if any.
fn deferred_probe_target(
agent: &AgentView,
) -> Option<crate::app::actions::ClipboardPasteTarget> {
deferred_probe_ctx(agent).map(|ctx| ctx.target)
}
/// The `ClipboardPasteContext` of the enqueued deferred-probe effect, if any.
fn deferred_probe_ctx(agent: &AgentView) -> Option<crate::app::actions::ClipboardPasteContext> {
agent.pending_effects.iter().find_map(|e| match e {
crate::app::actions::Effect::ProbeClipboardAttachment { ctx, .. } => Some(ctx.clone()),
_ => None,
})
}
/// Drive a real Cmd+V through the shipped entry point with the given pbpaste
/// text and an available, raster-free snapshot (the native snapshot gate skips
/// the deferred image probe), so a text or file-path paste resolves synchronously.
fn paste_cmd_v(agent: &mut AgentView, clipboard_text: Option<&str>) -> InputOutcome {
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
text: clipboard_text.map(str::to_owned),
snapshot_supported: Some(true),
snapshot: Some((Some(1), false)),
..Default::default()
});
let outcome = agent.handle_prompt_key_for_test(&ctrl_v_key());
crate::clipboard::clear_clipboard_probe_hook();
outcome
}
/// A `ClipboardPasteContext` matching what a real agent Cmd+V enqueues, for
/// driving `complete_clipboard_attachment_paste` directly in completion tests.
fn agent_completion_ctx(
agent: &AgentView,
clipboard_text: Option<&str>,
) -> crate::app::actions::ClipboardPasteContext {
crate::app::actions::ClipboardPasteContext {
target: crate::app::actions::ClipboardPasteTarget::AgentPrompt {
agent_id: agent.session.id,
images_dir: None,
},
source: crate::app::actions::ClipboardPasteSource::ClipboardKey {
text: crate::app::actions::ClipboardTextRead::Success(
clipboard_text.map(str::to_owned),
),
tip_showing: false,
},
}
}
/// Drive a real Cmd+V that finds a raster (defers), then complete the probe
/// with a decoded image — the full shipped image-paste path through the
/// deferred entry point.
fn paste_cmd_v_image(agent: &mut AgentView, clipboard_text: Option<&str>) {
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
text: clipboard_text.map(str::to_owned),
..crate::clipboard::ClipboardProbeHook::with_raster(None)
});
let _ = agent.handle_prompt_key_for_test(&ctrl_v_key());
let ctx = deferred_probe_ctx(agent).expect("an image paste must defer a probe");
crate::clipboard::clear_clipboard_probe_hook();
let pasted = crate::prompt_images::from_clipboard_data(&test_image_data());
agent.complete_clipboard_attachment_paste(
ctx,
crate::app::actions::ProbedAttachment::Image(pasted),
None,
);
}
#[test]
fn agent_paste_snapshot_no_raster_stays_synchronous() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
text: Some("hello world".to_string()),
snapshot_supported: Some(true),
snapshot: Some((Some(1), false)),
..Default::default()
});
let outcome = agent.handle_prompt_key_for_test(&ctrl_v_key());
let calls = crate::clipboard::clipboard_probe_call_count();
let deferred = deferred_probe_target(&agent).is_some();
crate::clipboard::clear_clipboard_probe_hook();
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(calls, 0, "snapshot with no raster must skip the probe");
assert!(
!deferred,
"plain text with no raster must not defer a probe"
);
assert!(agent.prompt.images.is_empty());
assert_eq!(agent.prompt.text(), "hello world");
}
#[test]
fn agent_paste_snapshot_has_image_defers_probe() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
text: Some("caption".to_string()),
snapshot_supported: Some(true),
snapshot: Some((Some(1), true)),
..Default::default()
});
let outcome = agent.handle_prompt_key_for_test(&ctrl_v_key());
let calls = crate::clipboard::clipboard_probe_call_count();
let ctx = deferred_probe_ctx(&agent);
crate::clipboard::clear_clipboard_probe_hook();
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(calls, 0, "probe must NOT run inline on the event loop");
let ctx = ctx.expect("an agent-targeted probe effect must be enqueued");
assert!(matches!(
ctx.target,
crate::app::actions::ClipboardPasteTarget::AgentPrompt { .. }
));
assert_eq!(ctx.source.text(), Some("caption"));
assert!(ctx.source.is_clipboard_key());
assert_eq!(
agent.prompt.text(),
"",
"caption must not be inserted synchronously"
);
assert!(agent.prompt.images.is_empty());
}
#[test]
fn agent_cmd_v_probe_ctx_not_bracketed() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
text: Some("caption".to_string()),
snapshot_supported: Some(true),
snapshot: Some((Some(1), true)),
..Default::default()
});
let _ = agent.handle_prompt_key_for_test(&ctrl_v_key());
let ctx = deferred_probe_ctx(&agent);
crate::clipboard::clear_clipboard_probe_hook();
let ctx = ctx.expect("a Cmd+V with a raster must defer a probe");
assert!(
!ctx.source.is_bracketed(),
"Cmd+V source must remain a CLIPBOARD-key read"
);
}
/// Regression: an IME commit delivered as bracketed paste (Otty)
/// must not attach the unrelated clipboard image.
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[test]
fn agent_bracketed_paste_stamps_ctx_bracketed() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let registry = ActionRegistry::defaults();
crate::clipboard::set_clipboard_probe_hook(
crate::clipboard::ClipboardProbeHook::with_raster(None),
);
let _ = agent.handle_input(&Event::Paste("中".to_owned()), &registry);
let ctx = deferred_probe_ctx(&agent);
crate::clipboard::clear_clipboard_probe_hook();
let ctx = ctx.expect("a short bracketed paste with a raster must defer a probe");
assert!(
ctx.source.is_bracketed(),
"bracketed source must let the probe verify payload origin"
);
assert_eq!(ctx.source.text(), Some("中"));
assert_eq!(
ctx.source.synchronous_insertion(),
Some(crate::app::actions::ClipboardTextInsertion::Inserted)
);
assert_eq!(agent.prompt.text(), "中");
}
#[test]
fn agent_empty_paste_key_defers_probe() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
snapshot_supported: Some(true),
snapshot: Some((Some(1), false)),
..Default::default()
});
let _ = agent.handle_prompt_key_for_test(&ctrl_v_key());
let calls = crate::clipboard::clipboard_probe_call_count();
let target = deferred_probe_target(&agent);
crate::clipboard::clear_clipboard_probe_hook();
assert_eq!(calls, 0, "probe must NOT run inline on the event loop");
assert!(matches!(
target,
Some(crate::app::actions::ClipboardPasteTarget::AgentPrompt { .. })
));
assert!(agent.prompt.images.is_empty());
}
#[test]
fn failed_clipboard_text_read_is_carried_into_deferred_context() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
text_read_failed: true,
..crate::clipboard::ClipboardProbeHook::snapshot_unavailable()
});
let _ = agent.handle_prompt_key_for_test(&ctrl_v_key());
let ctx =
deferred_probe_ctx(&agent).expect("failed text read must still probe attachments");
crate::clipboard::clear_clipboard_probe_hook();
assert!(ctx.source.text_read_failed());
}
#[test]
fn agent_completion_attaches_image_to_prompt() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let pasted = crate::prompt_images::from_clipboard_data(&test_image_data());
let ctx = agent_completion_ctx(&agent, Some("caption"));
let completion = agent.complete_clipboard_attachment_paste(
ctx,
crate::app::actions::ProbedAttachment::Image(pasted),
None,
);
assert_eq!(
completion,
crate::app::actions::ClipboardPasteCompletion::Handled
);
assert_eq!(
agent.prompt.images.len(),
1,
"deferred image attaches on completion"
);
assert!(agent.prompt.text().contains("[Image #"));
assert!(
!agent.prompt.text().contains("caption"),
"caption must not be inserted when the probe returns an image: {:?}",
agent.prompt.text()
);
}
/// Image-wins across the deferral boundary: a Cmd+V with both a caption and a
/// raster attaches ONLY the image (caption suppressed) — never image + caption.
#[test]
fn agent_cmd_v_image_wins_no_double_insert() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
text: Some("caption text".to_string()),
..crate::clipboard::ClipboardProbeHook::with_raster(None)
});
let _ = agent.handle_prompt_key_for_test(&ctrl_v_key());
let ctx = deferred_probe_ctx(&agent).expect("probe deferred");
crate::clipboard::clear_clipboard_probe_hook();
let pasted = crate::prompt_images::from_clipboard_data(&test_image_data());
agent.complete_clipboard_attachment_paste(
ctx,
crate::app::actions::ProbedAttachment::Image(pasted),
None,
);
assert_eq!(agent.prompt.images.len(), 1);
assert!(agent.prompt.text().contains("[Image #1]"));
assert!(
!agent.prompt.text().contains("caption text"),
"caption must not double-insert alongside the image: {:?}",
agent.prompt.text()
);
}
/// No-image miss on an image-wins Cmd+V inserts the carried caption instead.
#[test]
fn agent_cmd_v_caption_inserted_on_no_image_miss() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let ctx = agent_completion_ctx(&agent, Some("caption text"));
let completion = agent.complete_clipboard_attachment_paste(
ctx,
crate::app::actions::ProbedAttachment::NoRaster,
None,
);
assert_eq!(
completion,
crate::app::actions::ClipboardPasteCompletion::Handled
);
assert!(agent.prompt.images.is_empty());
assert_eq!(
agent.prompt.text(),
"caption text",
"the carried caption inserts when the probe finds no image"
);
}
#[test]
fn agent_deferred_caption_survives_failed_or_dropped_probe() {
use crate::app::actions::{
ClipboardPasteCompletion, ClipboardPasteFailure, ProbedAttachment,
};
for (probe, expected) in [
(
ProbedAttachment::ProbeFailed,
ClipboardPasteCompletion::Failed(ClipboardPasteFailure::AttachmentRead),
),
(
ProbedAttachment::ProbeDropped,
ClipboardPasteCompletion::Dropped,
),
] {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let ctx = agent_completion_ctx(&agent, Some("caption text"));
let completion = agent.complete_clipboard_attachment_paste(ctx, probe, None);
assert_eq!(completion, expected);
assert_eq!(agent.prompt.text(), "caption text");
}
}
#[test]
fn canonical_text_insertion_handles_same_text_replacement() {
let mut agent = make_agent();
agent.prompt.set_text("same text");
agent.prompt.textarea.set_selection(0, "same text".len());
let (_, completion) = agent.insert_prompt_plain_text(Some("same text"));
assert_eq!(
completion,
crate::app::actions::ClipboardTextInsertion::Inserted
);
assert_eq!(agent.prompt.text(), "same text");
}
#[test]
fn empty_and_whitespace_bracketed_completion_are_not_handled() {
use crate::app::actions::{
ClipboardPasteCompletion, ClipboardTextInsertion, ProbedAttachment,
};
for text in ["", " \n\t"] {
let mut agent = make_agent();
let (_, synchronous) = agent.insert_bracketed_prompt_text(text);
assert_eq!(synchronous, ClipboardTextInsertion::Empty);
let mut ctx = agent_completion_ctx(&agent, Some(text));
ctx.source = crate::app::actions::ClipboardPasteSource::BracketedInserted {
text: text.to_owned(),
insertion: synchronous,
};
let completion =
agent.complete_clipboard_attachment_paste(ctx, ProbedAttachment::NoRaster, None);
assert_eq!(
completion,
ClipboardPasteCompletion::FullMiss,
"bracketed payload {text:?}"
);
}
}
#[test]
fn bracketed_probe_failure_or_drop_wins_over_synchronous_text() {
use crate::app::actions::{
ClipboardPasteCompletion, ClipboardPasteFailure, ClipboardTextInsertion,
ProbedAttachment,
};
for (probe, expected) in [
(
ProbedAttachment::ProbeFailed,
ClipboardPasteCompletion::Failed(ClipboardPasteFailure::AttachmentRead),
),
(
ProbedAttachment::ProbeDropped,
ClipboardPasteCompletion::Dropped,
),
] {
let mut agent = make_agent();
let (_, synchronous) = agent.insert_bracketed_prompt_text("inserted");
assert_eq!(synchronous, ClipboardTextInsertion::Inserted);
let mut ctx = agent_completion_ctx(&agent, Some("inserted"));
ctx.source = crate::app::actions::ClipboardPasteSource::BracketedInserted {
text: "inserted".to_owned(),
insertion: synchronous,
};
let completion = agent.complete_clipboard_attachment_paste(ctx, probe, None);
assert_eq!(completion, expected);
}
}
#[test]
fn failed_clipboard_text_read_cannot_become_full_miss() {
let mut agent = make_agent();
let mut ctx = agent_completion_ctx(&agent, None);
ctx.source = crate::app::actions::ClipboardPasteSource::ClipboardKey {
text: crate::app::actions::ClipboardTextRead::Failed,
tip_showing: false,
};
let completion = agent.complete_clipboard_attachment_paste(
ctx,
crate::app::actions::ProbedAttachment::NoRaster,
None,
);
assert_eq!(
completion,
crate::app::actions::ClipboardPasteCompletion::Failed(
crate::app::actions::ClipboardPasteFailure::TextRead,
)
);
}
#[test]
fn agent_completion_inserts_unreadable_file_url_as_path_text() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let ctx = agent_completion_ctx(&agent, None);
let completion = agent.complete_clipboard_attachment_paste(
ctx,
crate::app::actions::ProbedAttachment::NoRaster,
Some("file:///definitely/missing/xai-primary-paste.png".to_owned()),
);
assert_eq!(
completion,
crate::app::actions::ClipboardPasteCompletion::Handled
);
assert_eq!(
agent.prompt.text(),
"/definitely/missing/xai-primary-paste.png "
);
assert!(agent.prompt.images.is_empty());
}
#[test]
fn agent_completion_distinguishes_dropped_and_failed_probes() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let ctx = agent_completion_ctx(&agent, None);
let dropped = agent.complete_clipboard_attachment_paste(
ctx,
crate::app::actions::ProbedAttachment::ProbeDropped,
None,
);
let ctx = agent_completion_ctx(&agent, None);
let failed = agent.complete_clipboard_attachment_paste(
ctx,
crate::app::actions::ProbedAttachment::ProbeFailed,
None,
);
let ctx = agent_completion_ctx(&agent, None);
let persist_failed = agent.complete_clipboard_attachment_paste(
ctx,
crate::app::actions::ProbedAttachment::PersistFailed("disk full".to_owned()),
None,
);
assert_eq!(
dropped,
crate::app::actions::ClipboardPasteCompletion::Dropped
);
assert_eq!(
failed,
crate::app::actions::ClipboardPasteCompletion::Failed(
crate::app::actions::ClipboardPasteFailure::AttachmentRead,
)
);
assert_eq!(
persist_failed,
crate::app::actions::ClipboardPasteCompletion::Failed(
crate::app::actions::ClipboardPasteFailure::AlreadyReported,
)
);
}
/// The `ContextualTip { ImageInput, Accepted }` funnel survives the deferral:
/// a Cmd+V while the clipboard-image tip is showing emits the accept event
/// when the deferred probe attaches an image.
#[test]
fn agent_cmd_v_tip_accept_emitted_on_deferred_image() {
let mut agent = make_agent();
agent.set_active_pane(ActivePane::Prompt, true);
let _ = agent.ephemeral_tip.show(
crate::tips::clipboard_focus::clipboard_image_tip(),
&mut std::collections::HashMap::new(),
);
crate::clipboard::set_clipboard_probe_hook(
crate::clipboard::ClipboardProbeHook::with_raster(None),
);
let _ = agent.handle_prompt_key_for_test(&ctrl_v_key());
let ctx = deferred_probe_ctx(&agent).expect("probe deferred");
crate::clipboard::clear_clipboard_probe_hook();
assert!(
ctx.source.tip_showing(),
"tip-showing must thread into the deferred ctx"
);
let pasted = crate::prompt_images::from_clipboard_data(&test_image_data());
agent.complete_clipboard_attachment_paste(
ctx,
crate::app::actions::ProbedAttachment::Image(pasted),
None,
);
assert_eq!(agent.prompt.images.len(), 1);
}
}