1581 lines
65 KiB
Rust
1581 lines
65 KiB
Rust
|
|
//! Queued-prompt editing (`PromptMode::EditingQueued`) state machine.
|
||
|
|
//!
|
||
|
|
//! Extracted from `agent_view.rs` as a sibling `impl AgentView` block (same
|
||
|
|
//! pattern as xai-grok-shell's `compaction.rs`): entry from the queue pane,
|
||
|
|
//! editing-mode key intercepts, the dirty-edit focus lock, and the
|
||
|
|
//! exit/cleanup paths.
|
||
|
|
//!
|
||
|
|
//! Stash invariant: `stashed_prompt` is set exactly once on entry
|
||
|
|
//! (`enter_queue_edit`) and `take()`n exactly once on exit —
|
||
|
|
//! `exit_editing_mode` is the sole restore owner: every exit (including
|
||
|
|
//! lost-row cancel) restores the draft exactly once.
|
||
|
|
//!
|
||
|
|
//! A dirty pane switch is blocked and never arms the undrawn `EditConfirm`
|
||
|
|
//! modal, which would otherwise capture all input invisibly.
|
||
|
|
|
||
|
|
use crossterm::event::{KeyCode, KeyEvent};
|
||
|
|
|
||
|
|
use crate::key;
|
||
|
|
use crate::views::modal::{ActiveModal, EditConfirmResult, ModalConfirmation};
|
||
|
|
use crate::views::queue_pane::QueueRowRef;
|
||
|
|
|
||
|
|
use super::actions::Action;
|
||
|
|
use super::agent_view::{AgentPane, AgentView, PromptInputMode};
|
||
|
|
use super::app_view::InputOutcome;
|
||
|
|
|
||
|
|
/// State of the prompt widget's editing context.
|
||
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
|
|
pub enum PromptMode {
|
||
|
|
/// Normal mode: typing a new prompt to send.
|
||
|
|
Normal,
|
||
|
|
/// Editing a queued prompt.
|
||
|
|
EditingQueued {
|
||
|
|
/// Stable selection ID of the prompt being edited. For local rows
|
||
|
|
/// this is the `QueuedPrompt.id` monotonic counter; for server rows
|
||
|
|
/// this is the synthesized `QueuedPromptEntry.id` (a hash of
|
||
|
|
/// `server_id`) so [`AgentView::queue`] selection works uniformly.
|
||
|
|
id: u64,
|
||
|
|
/// Snapshot of the original text (for dirty detection).
|
||
|
|
original: String,
|
||
|
|
/// When `Some`, this is a server-authoritative shared-queue row and
|
||
|
|
/// `server_id` is the agent's stable `prompt_id`. On save we route
|
||
|
|
/// the change through `Action::QueueEditShared` (and rely on the
|
||
|
|
/// `x.ai/queue/changed` rebroadcast for the visual result) instead
|
||
|
|
/// of mutating the local `pending_prompts` mirror. `None` is the
|
||
|
|
/// pre-existing local-origin path.
|
||
|
|
server_id: Option<String>,
|
||
|
|
/// Kind snapshot for the interject guard's vanished-row fallback.
|
||
|
|
kind: crate::app::agent::QueueEntryKind,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
impl AgentView {
|
||
|
|
/// Editing-mode key intercepts for the prompt pane.
|
||
|
|
///
|
||
|
|
/// Bare Enter saves, Esc cancels.
|
||
|
|
/// Interject-key handling for edit mode lives in
|
||
|
|
/// `interject_editing_queued_intercept`, reached via the
|
||
|
|
/// `ActionId::InterjectPrompt` registry arm in `handle_prompt_key` (the
|
||
|
|
/// binding is remappable, so it cannot be matched on a raw key here).
|
||
|
|
/// Shift-Enter / Alt-Enter fall through to widget (newline insertion).
|
||
|
|
/// Tab removed as cancel trigger — too easy to hit accidentally.
|
||
|
|
/// Ctrl-C on empty prompt also cancels (matches cancel-turn pattern).
|
||
|
|
///
|
||
|
|
/// Returns `None` when not editing or for any unhandled key — the key
|
||
|
|
/// MUST fall through to the widget (typing, newline insertion).
|
||
|
|
pub(super) fn handle_editing_queued_key(&mut self, key: &KeyEvent) -> Option<InputOutcome> {
|
||
|
|
if let PromptMode::EditingQueued { id, server_id, .. } = &self.prompt_mode {
|
||
|
|
let (id, server_id) = (*id, server_id.clone());
|
||
|
|
let ctrl_c_empty = key!('c', CONTROL).matches(key) && self.prompt.text().is_empty();
|
||
|
|
|
||
|
|
if key!(Enter).matches(key) && !self.prompt.text().trim().is_empty() {
|
||
|
|
return Some(self.save_edited_queued_row(id, server_id, true));
|
||
|
|
}
|
||
|
|
if key.code == KeyCode::Esc || ctrl_c_empty {
|
||
|
|
// Discard changes.
|
||
|
|
self.exit_editing_mode();
|
||
|
|
return Some(InputOutcome::Action(Action::DrainQueue));
|
||
|
|
}
|
||
|
|
// Everything else (including Shift-Enter, Alt-Enter, typing) falls through.
|
||
|
|
}
|
||
|
|
None
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Dirty-edit focus lock checked by `set_active_pane`.
|
||
|
|
///
|
||
|
|
/// `None` = not editing / no lock — the caller proceeds with the normal
|
||
|
|
/// pane switch. `Some(switched)` = the lock handled the switch: `false`
|
||
|
|
/// when blocked behind the confirm modal, `true` after a clean exit.
|
||
|
|
pub(super) fn editing_lock_on_pane_switch(&mut self, target: AgentPane) -> Option<bool> {
|
||
|
|
if let PromptMode::EditingQueued { ref original, .. } = self.prompt_mode
|
||
|
|
&& target != AgentPane::Prompt
|
||
|
|
{
|
||
|
|
let dirty = self.prompt.text() != original;
|
||
|
|
if dirty {
|
||
|
|
// Block the switch; never arm `EditConfirm` here (it has no draw
|
||
|
|
// arm, so it would capture all input invisibly). Resolve with Enter/Esc.
|
||
|
|
// Clear the overlay-focus flip toggle callers set before switching.
|
||
|
|
match target {
|
||
|
|
AgentPane::Queue => self.queue.overlay.focused = false,
|
||
|
|
AgentPane::Todo => self.todo.overlay.focused = false,
|
||
|
|
AgentPane::Tasks => self.tasks.overlay.focused = false,
|
||
|
|
AgentPane::Catalog => self.catalog.overlay.focused = false,
|
||
|
|
_ => {}
|
||
|
|
}
|
||
|
|
self.show_toast("Editing a queued prompt: press Enter to save, Esc to discard");
|
||
|
|
return Some(false); // blocked, no modal armed
|
||
|
|
}
|
||
|
|
// Clean edit — silently exit editing mode.
|
||
|
|
self.exit_editing_mode();
|
||
|
|
self.active_pane = target;
|
||
|
|
return Some(true);
|
||
|
|
}
|
||
|
|
None
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Resolve an `EditConfirm` modal keypress (the modal has already been
|
||
|
|
/// `take()`n out of `active_modal` by `handle_modal_key`).
|
||
|
|
pub(super) fn handle_edit_confirm_choice(
|
||
|
|
&mut self,
|
||
|
|
confirm: ModalConfirmation<EditConfirmResult>,
|
||
|
|
pending_target: AgentPane,
|
||
|
|
ch: char,
|
||
|
|
) -> InputOutcome {
|
||
|
|
if let Some(result) = confirm.resolve(ch) {
|
||
|
|
let was_drain_blocked = self.drain_blocked();
|
||
|
|
match result {
|
||
|
|
EditConfirmResult::Cancel => {
|
||
|
|
// Dismiss dialog, stay in editing mode.
|
||
|
|
// (active_modal already taken)
|
||
|
|
}
|
||
|
|
EditConfirmResult::Save => {
|
||
|
|
// Empty edit: keep the original row text — a
|
||
|
|
// queued prompt must never be blanked by Save.
|
||
|
|
if self.prompt.text().trim().is_empty() {
|
||
|
|
self.exit_editing_mode();
|
||
|
|
self.set_active_pane(pending_target, true);
|
||
|
|
if was_drain_blocked {
|
||
|
|
return InputOutcome::Action(Action::DrainQueue);
|
||
|
|
}
|
||
|
|
return InputOutcome::Changed;
|
||
|
|
}
|
||
|
|
let outcome = match self.prompt_mode.clone() {
|
||
|
|
PromptMode::EditingQueued { id, server_id, .. } => {
|
||
|
|
// Drain only when "save & send" was the
|
||
|
|
// advertised label (see helper doc).
|
||
|
|
self.save_edited_queued_row(id, server_id, was_drain_blocked)
|
||
|
|
}
|
||
|
|
// Unreachable in practice: the modal only
|
||
|
|
// opens from `EditingQueued`.
|
||
|
|
_ => {
|
||
|
|
self.exit_editing_mode();
|
||
|
|
InputOutcome::Changed
|
||
|
|
}
|
||
|
|
};
|
||
|
|
self.set_active_pane(pending_target, true);
|
||
|
|
return outcome;
|
||
|
|
}
|
||
|
|
EditConfirmResult::Discard => {
|
||
|
|
// Discard changes (revert to original), exit editing.
|
||
|
|
self.exit_editing_mode();
|
||
|
|
self.set_active_pane(pending_target, true);
|
||
|
|
if was_drain_blocked {
|
||
|
|
return InputOutcome::Action(Action::DrainQueue);
|
||
|
|
}
|
||
|
|
return InputOutcome::Changed;
|
||
|
|
}
|
||
|
|
EditConfirmResult::Delete => {
|
||
|
|
// Delete the prompt entirely from the queue.
|
||
|
|
// Server-origin rows route through
|
||
|
|
// `Action::QueueRemoveShared`; local rows mutate
|
||
|
|
// the mirror.
|
||
|
|
if let PromptMode::EditingQueued {
|
||
|
|
id: _,
|
||
|
|
server_id: Some(server_id),
|
||
|
|
..
|
||
|
|
} = self.prompt_mode.clone()
|
||
|
|
{
|
||
|
|
let expected_version = self
|
||
|
|
.shared_queue
|
||
|
|
.iter()
|
||
|
|
.find(|e| e.id == server_id)
|
||
|
|
.map(|e| e.version)
|
||
|
|
.unwrap_or(0);
|
||
|
|
self.exit_editing_mode();
|
||
|
|
self.set_active_pane(pending_target, true);
|
||
|
|
return InputOutcome::Action(Action::QueueRemoveShared {
|
||
|
|
id: server_id,
|
||
|
|
expected_version,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if let PromptMode::EditingQueued { id, .. } = self.prompt_mode {
|
||
|
|
self.session.pending_prompts.retain(|p| p.id != id);
|
||
|
|
}
|
||
|
|
self.exit_editing_mode();
|
||
|
|
self.set_active_pane(pending_target, true);
|
||
|
|
// If drain was blocked and we deleted the front,
|
||
|
|
// the next prompt (if any) should now drain.
|
||
|
|
if was_drain_blocked {
|
||
|
|
return InputOutcome::Action(Action::DrainQueue);
|
||
|
|
}
|
||
|
|
return InputOutcome::Changed;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Key didn't match any option — restore modal, keep blocking.
|
||
|
|
self.active_modal = Some(ActiveModal::EditConfirm {
|
||
|
|
modal: confirm,
|
||
|
|
pending_target,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
InputOutcome::Changed
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Enter editing mode for the queue row selected via
|
||
|
|
/// `QueueEvent::EditSelected` (called from `handle_queue_key`).
|
||
|
|
pub(super) fn enter_queue_edit(&mut self, id: u64, is_server: bool, row: Option<QueueRowRef>) {
|
||
|
|
use crate::app::agent::QueueEntryKind;
|
||
|
|
type QueueEditEntryData = (
|
||
|
|
String,
|
||
|
|
QueueEntryKind,
|
||
|
|
Option<String>,
|
||
|
|
Vec<crate::prompt_images::PastedImage>,
|
||
|
|
Vec<crate::app::agent::ChipElement>,
|
||
|
|
);
|
||
|
|
|
||
|
|
// Resolve text + display kind from whichever mirror owns
|
||
|
|
// the row, plus the server `prompt_id` for server-origin
|
||
|
|
// rows. The save path in `save_edited_queued_row` / the
|
||
|
|
// modal-confirm `Save` arm branches on `server_id`.
|
||
|
|
let entry_data: Option<QueueEditEntryData> = if is_server {
|
||
|
|
row.as_ref()
|
||
|
|
.and_then(|r| r.server_id.clone())
|
||
|
|
.and_then(|server_id| {
|
||
|
|
self.shared_queue
|
||
|
|
.iter()
|
||
|
|
.find(|e| e.id == server_id)
|
||
|
|
.map(|w| {
|
||
|
|
(
|
||
|
|
w.text.clone(),
|
||
|
|
crate::views::queue_pane::kind_from_wire(&w.kind),
|
||
|
|
Some(server_id),
|
||
|
|
Vec::new(),
|
||
|
|
Vec::new(),
|
||
|
|
)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
} else {
|
||
|
|
// Only local rows own image and chip state.
|
||
|
|
self.session
|
||
|
|
.pending_prompts
|
||
|
|
.iter()
|
||
|
|
.find(|p| p.id == id)
|
||
|
|
.map(|p| {
|
||
|
|
(
|
||
|
|
p.text.clone(),
|
||
|
|
p.kind,
|
||
|
|
None,
|
||
|
|
p.images.clone(),
|
||
|
|
p.chip_elements.clone(),
|
||
|
|
)
|
||
|
|
})
|
||
|
|
};
|
||
|
|
if let Some((text, kind, server_id, images, chip_elements)) = entry_data {
|
||
|
|
self.stashed_prompt = if self.prompt.text().is_empty() {
|
||
|
|
None
|
||
|
|
} else {
|
||
|
|
Some(self.prompt.stash())
|
||
|
|
};
|
||
|
|
// Load queued text and enter editing mode.
|
||
|
|
// Set prompt_input_mode based on entry kind so the prompt
|
||
|
|
// renders with the correct visual (yellow `!` prefix
|
||
|
|
// for bash entries, normal for prompts/commands).
|
||
|
|
self.prompt
|
||
|
|
.restore(crate::views::prompt_widget::StashedPrompt::from_submission(
|
||
|
|
text.clone(),
|
||
|
|
images,
|
||
|
|
chip_elements,
|
||
|
|
));
|
||
|
|
// `server_id: Some(_)` routes the save through
|
||
|
|
// `Action::QueueEditShared` (server LWW); `None` is
|
||
|
|
// the existing local-mirror mutation path.
|
||
|
|
self.prompt_mode = PromptMode::EditingQueued {
|
||
|
|
id,
|
||
|
|
original: text,
|
||
|
|
server_id,
|
||
|
|
kind,
|
||
|
|
};
|
||
|
|
self.prompt_input_mode = if kind == QueueEntryKind::BashCommand {
|
||
|
|
PromptInputMode::Bash
|
||
|
|
} else {
|
||
|
|
PromptInputMode::Normal
|
||
|
|
};
|
||
|
|
self.set_active_pane(AgentPane::Prompt, false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Save the edited composer text back to the queued row and exit edit
|
||
|
|
/// mode. Single owner of the save invariants for the bare-Enter
|
||
|
|
/// intercept, the idle edit-interject, and the modal Save arm.
|
||
|
|
///
|
||
|
|
/// `drain`: whether a local-row save requests a queue drain. Enter-save
|
||
|
|
/// and idle edit-interject always drain (the user just released the
|
||
|
|
/// front edit lock); modal Save drains only when the drain was blocked
|
||
|
|
/// on this edit — a plain save of a non-front row must not start the
|
||
|
|
/// head prompt's turn.
|
||
|
|
fn save_edited_queued_row(
|
||
|
|
&mut self,
|
||
|
|
id: u64,
|
||
|
|
server_id: Option<String>,
|
||
|
|
drain: bool,
|
||
|
|
) -> InputOutcome {
|
||
|
|
match server_id {
|
||
|
|
Some(server_id) => {
|
||
|
|
let new_text = self.prompt.text().to_string();
|
||
|
|
// Server-origin row: route the edit through the agent (LWW).
|
||
|
|
// The rebroadcast updates every client's shared queue mirror
|
||
|
|
// — do NOT mutate locally.
|
||
|
|
self.exit_editing_mode();
|
||
|
|
InputOutcome::Action(Action::QueueEditShared {
|
||
|
|
id: server_id,
|
||
|
|
new_text,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
None => {
|
||
|
|
let edited = self.prompt.stash();
|
||
|
|
let (new_text, mut images, chip_elements) = edited.into_submission();
|
||
|
|
// Local row: in-place mutation (existing behavior).
|
||
|
|
// Recompute token ranges for the edited text — the stale
|
||
|
|
// ranges would point at the pre-edit byte offsets.
|
||
|
|
let skill_token_ranges = self
|
||
|
|
.prompt
|
||
|
|
.slash_controller
|
||
|
|
.recognized_token_ranges(&new_text, &self.session.models);
|
||
|
|
if let Some(entry) = self.session.pending_prompts.iter_mut().find(|p| p.id == id) {
|
||
|
|
let retained: std::collections::HashSet<u64> = images
|
||
|
|
.iter()
|
||
|
|
.map(|image| image.preview.identity())
|
||
|
|
.collect();
|
||
|
|
for old in entry.images.drain(..) {
|
||
|
|
if !retained.contains(&old.preview.identity()) {
|
||
|
|
crate::prompt_images::cleanup_temp_file(&old);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
entry.text = new_text;
|
||
|
|
entry.images = std::mem::take(&mut images);
|
||
|
|
entry.chip_elements = chip_elements;
|
||
|
|
entry.skill_token_ranges = skill_token_ranges;
|
||
|
|
// Clear stale wire_blocks — edited text may no longer match
|
||
|
|
// the original skill invocation. The prompt will be sent as
|
||
|
|
// plain text via the normal path. If it still starts with `/`,
|
||
|
|
// the shell's resolve() handles it.
|
||
|
|
entry.wire_blocks = None;
|
||
|
|
// display_as_skill rides wire_blocks (see its field doc) — clear both
|
||
|
|
// together, or the drain keeps stale skill styling over the ranges.
|
||
|
|
entry.display_as_skill = false;
|
||
|
|
}
|
||
|
|
crate::prompt_images::drain_and_cleanup(&mut images);
|
||
|
|
self.exit_editing_mode();
|
||
|
|
if drain {
|
||
|
|
InputOutcome::Action(Action::DrainQueue)
|
||
|
|
} else {
|
||
|
|
InputOutcome::Changed
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Interject-key intercept while editing a queued row, delegated from
|
||
|
|
/// the `ActionId::InterjectPrompt` registry arm in `handle_prompt_key`.
|
||
|
|
/// Falling through would strand `EditingQueued` with the row still
|
||
|
|
/// queued (dirty-modal loop + blocked drain). `None` = not editing —
|
||
|
|
/// the arm proceeds with its normal interject handling.
|
||
|
|
pub(super) fn interject_editing_queued_intercept(&mut self) -> Option<InputOutcome> {
|
||
|
|
if let PromptMode::EditingQueued {
|
||
|
|
id,
|
||
|
|
server_id,
|
||
|
|
kind,
|
||
|
|
..
|
||
|
|
} = &self.prompt_mode
|
||
|
|
{
|
||
|
|
let (id, server_id, kind) = (*id, server_id.clone(), *kind);
|
||
|
|
return Some(self.interject_edited_queued(id, server_id, kind));
|
||
|
|
}
|
||
|
|
None
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Interject key pressed while editing a queued row: turn running →
|
||
|
|
/// interject the EDITED text and remove the row; idle → bare-Enter
|
||
|
|
/// save; empty composer → no-op, stay in edit mode.
|
||
|
|
fn interject_edited_queued(
|
||
|
|
&mut self,
|
||
|
|
id: u64,
|
||
|
|
server_id: Option<String>,
|
||
|
|
kind: crate::app::agent::QueueEntryKind,
|
||
|
|
) -> InputOutcome {
|
||
|
|
let text = self.prompt.text().trim().to_string();
|
||
|
|
if text.is_empty() {
|
||
|
|
return InputOutcome::Changed;
|
||
|
|
}
|
||
|
|
if !self.session.state.is_turn_running() {
|
||
|
|
return self.save_edited_queued_row(id, server_id, true);
|
||
|
|
}
|
||
|
|
// Non-prompt rows stay queued (see `queue_row_prompt_like`): save the edit.
|
||
|
|
let row_prompt_like = self.queue_row_prompt_like(id);
|
||
|
|
if row_prompt_like == Some(false) {
|
||
|
|
self.show_toast("Can't send this mid-turn — it runs when the current turn ends");
|
||
|
|
return self.save_edited_queued_row(id, server_id, true);
|
||
|
|
}
|
||
|
|
if row_prompt_like.is_none() && kind != crate::app::agent::QueueEntryKind::Prompt {
|
||
|
|
self.show_toast("Queued prompt is no longer in the queue");
|
||
|
|
return self.save_edited_queued_row(id, server_id, true);
|
||
|
|
}
|
||
|
|
match server_id {
|
||
|
|
Some(server_id) => {
|
||
|
|
// Server rows: the queue wire (`x.ai/queue/interject` newText)
|
||
|
|
// is text-only, so composer images can't ride along — known
|
||
|
|
// limitation, dropped with an accurate toast.
|
||
|
|
if !self.prompt.images.is_empty() {
|
||
|
|
self.prompt.images.clear();
|
||
|
|
self.show_toast("Images can't be attached when editing a shared queued prompt");
|
||
|
|
}
|
||
|
|
// new_text carries the edit — without it the agent would
|
||
|
|
// interject the original server-side text.
|
||
|
|
let expected_version = self.queue.row_ref(id).map(|r| r.version);
|
||
|
|
self.exit_editing_mode();
|
||
|
|
match expected_version {
|
||
|
|
Some(expected_version) => InputOutcome::Action(Action::QueueInterjectShared {
|
||
|
|
id: server_id,
|
||
|
|
expected_version,
|
||
|
|
new_text: Some(text),
|
||
|
|
}),
|
||
|
|
// Row vanished from the mirror — just interject the text.
|
||
|
|
None => InputOutcome::Action(Action::Interject {
|
||
|
|
text,
|
||
|
|
images: vec![],
|
||
|
|
}),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
None => {
|
||
|
|
// Exit before row removal so auto-hide cannot re-enter or strand edit mode.
|
||
|
|
let edited = self.prompt.stash();
|
||
|
|
let (_, images, _) = edited.into_submission();
|
||
|
|
self.exit_editing_mode();
|
||
|
|
if let Some(mut row) = self.remove_local_queue_row(id) {
|
||
|
|
let retained: std::collections::HashSet<u64> = images
|
||
|
|
.iter()
|
||
|
|
.map(|image| image.preview.identity())
|
||
|
|
.collect();
|
||
|
|
for old in row.images.drain(..) {
|
||
|
|
if !retained.contains(&old.preview.identity()) {
|
||
|
|
crate::prompt_images::cleanup_temp_file(&old);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
InputOutcome::Action(Action::Interject { text, images })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Whether the drain is blocked because the user is editing the front prompt.
|
||
|
|
pub(crate) fn drain_blocked(&self) -> bool {
|
||
|
|
if let PromptMode::EditingQueued { id, .. } = &self.prompt_mode {
|
||
|
|
self.session.state.is_idle()
|
||
|
|
&& self
|
||
|
|
.session
|
||
|
|
.pending_prompts
|
||
|
|
.front()
|
||
|
|
.is_some_and(|p| p.id == *id)
|
||
|
|
} else {
|
||
|
|
false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Exit a server-origin queue edit whose row vanished, restoring the pre-edit draft.
|
||
|
|
pub(crate) fn cancel_editing_queued_for_lost_row(&mut self) {
|
||
|
|
if !matches!(
|
||
|
|
self.prompt_mode,
|
||
|
|
PromptMode::EditingQueued {
|
||
|
|
server_id: Some(_),
|
||
|
|
..
|
||
|
|
}
|
||
|
|
) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// Restore the pre-edit draft; keeping the orphaned edit text would look
|
||
|
|
// "duplicated" (the row is now the running turn). A concurrent-removal edit is lost.
|
||
|
|
self.exit_editing_mode();
|
||
|
|
self.show_toast("Queued prompt is no longer in the queue");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Exit editing mode: restore stashed text, clear mode, focus queue pane.
|
||
|
|
/// No-op unless `EditingQueued`.
|
||
|
|
///
|
||
|
|
/// Always resets `prompt_input_mode` to `Normal` so it doesn't leak
|
||
|
|
/// into subsequent normal prompt entry.
|
||
|
|
pub(super) fn exit_editing_mode(&mut self) {
|
||
|
|
// Idempotent: remove_local_queue_row's guard may have exited already;
|
||
|
|
// a second take() of the spent stash would wipe the composer.
|
||
|
|
if !matches!(self.prompt_mode, PromptMode::EditingQueued { .. }) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
let stash = self.stashed_prompt.take().unwrap_or_default();
|
||
|
|
self.prompt.restore(stash);
|
||
|
|
self.finish_editing_exit();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Shared tail of every edit exit — stash policy stays with the callers.
|
||
|
|
fn finish_editing_exit(&mut self) {
|
||
|
|
self.prompt_mode = PromptMode::Normal;
|
||
|
|
self.prompt_input_mode = PromptInputMode::Normal;
|
||
|
|
// Editing is over, so a pending EditConfirm is meaningless — left
|
||
|
|
// behind it would eat all input without ever rendering. Other
|
||
|
|
// modal variants are untouched.
|
||
|
|
if matches!(self.active_modal, Some(ActiveModal::EditConfirm { .. })) {
|
||
|
|
self.active_modal = None;
|
||
|
|
}
|
||
|
|
// Return focus to queue pane (if still visible).
|
||
|
|
// Force=true: we just cleared editing mode, no lock to check.
|
||
|
|
if self.queue.is_visible() {
|
||
|
|
self.set_active_pane(AgentPane::Queue, true);
|
||
|
|
} else {
|
||
|
|
self.set_active_pane(AgentPane::Scrollback, true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use agent_client_protocol as acp;
|
||
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||
|
|
|
||
|
|
use crate::app::actions::{Action, Effect};
|
||
|
|
use crate::app::agent::AgentState;
|
||
|
|
use crate::app::agent_view::test_fixtures::{
|
||
|
|
force_interject_key, make_running_agent, non_vscode_registry, running_agent_local_only,
|
||
|
|
test_pasted_image,
|
||
|
|
};
|
||
|
|
use crate::app::agent_view::{AgentPane, AgentView, PromptMode};
|
||
|
|
use crate::app::app_view::InputOutcome;
|
||
|
|
use crate::app::dispatch::maybe_drain_queue;
|
||
|
|
use crate::scrollback::block::RenderBlock;
|
||
|
|
use crate::views::modal::{ActiveModal, ModalConfirmation};
|
||
|
|
|
||
|
|
fn edit_key() -> KeyEvent {
|
||
|
|
// Default key binding for QueueEvent::EditSelected.
|
||
|
|
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn delete_key() -> KeyEvent {
|
||
|
|
// Default key binding for QueueEvent::DeleteSelected.
|
||
|
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn enter_key() -> KeyEvent {
|
||
|
|
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn attach_image_to_local_row(agent: &mut AgentView) {
|
||
|
|
let mut image = test_pasted_image();
|
||
|
|
image.display_number = 1;
|
||
|
|
let row = agent.session.pending_prompts.front_mut().unwrap();
|
||
|
|
row.text = "local one [Image #1] ".into();
|
||
|
|
row.images = vec![image];
|
||
|
|
row.chip_elements = vec![crate::app::agent::ChipElement {
|
||
|
|
range: 10..20,
|
||
|
|
kind: crate::views::prompt_widget::KIND_IMAGE,
|
||
|
|
display: None,
|
||
|
|
}];
|
||
|
|
agent.queue.sync_from_merged(
|
||
|
|
&agent.session.pending_prompts,
|
||
|
|
&agent.shared_queue,
|
||
|
|
None,
|
||
|
|
None,
|
||
|
|
&agent.send_now_painted_blocks,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Editing a Server-origin row enters `EditingQueued` with `server_id`
|
||
|
|
/// populated (the new behavior replacing the "isn't supported yet" toast).
|
||
|
|
#[test]
|
||
|
|
fn edit_server_row_enters_editing_queued_with_server_id() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
// Server row first.
|
||
|
|
agent.queue.list_state.select_by_id(ids[0]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
|
||
|
|
match &agent.prompt_mode {
|
||
|
|
PromptMode::EditingQueued {
|
||
|
|
server_id: Some(sid),
|
||
|
|
original,
|
||
|
|
..
|
||
|
|
} => {
|
||
|
|
assert_eq!(sid, "p1");
|
||
|
|
assert_eq!(original, "server one");
|
||
|
|
}
|
||
|
|
other => panic!("expected EditingQueued with server_id Some, got {other:?}"),
|
||
|
|
}
|
||
|
|
assert_eq!(agent.prompt.text(), "server one");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Submitting an edit on a server-origin row dispatches
|
||
|
|
/// `Action::QueueEditShared` and does NOT mutate the local mirror.
|
||
|
|
#[test]
|
||
|
|
fn submit_server_edit_routes_to_action_no_local_mutation() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[0]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
|
||
|
|
// Type a replacement.
|
||
|
|
agent.prompt.set_text("server one EDITED");
|
||
|
|
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&enter_key());
|
||
|
|
match outcome {
|
||
|
|
InputOutcome::Action(Action::QueueEditShared { id, new_text }) => {
|
||
|
|
assert_eq!(id, "p1");
|
||
|
|
assert_eq!(new_text, "server one EDITED");
|
||
|
|
}
|
||
|
|
other => panic!("expected QueueEditShared, got {other:?}"),
|
||
|
|
}
|
||
|
|
// Local mirror untouched.
|
||
|
|
assert_eq!(agent.shared_queue.len(), 1);
|
||
|
|
assert_eq!(agent.shared_queue[0].text, "server one");
|
||
|
|
// EditingQueued cleared.
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn shared_queue_edit_rejects_image_before_normal_save() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[0]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
let ctx = 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(None),
|
||
|
|
tip_showing: false,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
let completion = agent.complete_clipboard_attachment_paste(
|
||
|
|
ctx,
|
||
|
|
crate::app::actions::ProbedAttachment::Image(test_pasted_image()),
|
||
|
|
None,
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
completion,
|
||
|
|
crate::app::actions::ClipboardPasteCompletion::Failed(
|
||
|
|
crate::app::actions::ClipboardPasteFailure::AlreadyReported,
|
||
|
|
)
|
||
|
|
);
|
||
|
|
assert!(agent.prompt.images.is_empty());
|
||
|
|
assert!(!agent.prompt.text().contains("[Image #"));
|
||
|
|
assert!(agent.toast.is_some());
|
||
|
|
|
||
|
|
agent.prompt.set_text("server one EDITED");
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&enter_key());
|
||
|
|
match outcome {
|
||
|
|
InputOutcome::Action(Action::QueueEditShared { new_text, .. }) => {
|
||
|
|
assert_eq!(new_text, "server one EDITED");
|
||
|
|
assert!(!new_text.contains("[Image #"));
|
||
|
|
}
|
||
|
|
other => panic!("expected QueueEditShared, got {other:?}"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Submitting an edit on a local-origin row continues to mutate the local
|
||
|
|
/// `pending_prompts` in place (existing behavior preserved).
|
||
|
|
#[test]
|
||
|
|
fn submit_local_edit_mutates_local_pending_prompts() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
// Local row is second (server rendered first).
|
||
|
|
agent.queue.list_state.select_by_id(ids[1]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
|
||
|
|
agent.prompt.set_text("local one EDITED");
|
||
|
|
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&enter_key());
|
||
|
|
match outcome {
|
||
|
|
InputOutcome::Action(Action::DrainQueue) => {}
|
||
|
|
other => panic!("expected DrainQueue, got {other:?}"),
|
||
|
|
}
|
||
|
|
assert_eq!(agent.session.pending_prompts.len(), 1);
|
||
|
|
assert_eq!(agent.session.pending_prompts[0].text, "local one EDITED");
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Editing a skill-injection row clears `display_as_skill` alongside
|
||
|
|
/// `wire_blocks`, so the drained echo styles the recomputed mid-text
|
||
|
|
/// ranges (not stale leading-token skill styling) and the wire send is
|
||
|
|
/// plain text.
|
||
|
|
#[test]
|
||
|
|
fn edit_skill_row_drains_with_recomputed_ranges_not_skill_styling() {
|
||
|
|
let mut agent = running_agent_local_only();
|
||
|
|
agent.session.state = AgentState::Idle;
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
// Advertise the skill so the edited text's mid-text token is recognized.
|
||
|
|
let models = agent.session.models.clone();
|
||
|
|
agent.prompt.sync_acp_commands(
|
||
|
|
&[
|
||
|
|
acp::AvailableCommand::new("pr-workflow", "PR workflow skill").meta(
|
||
|
|
serde_json::json!({
|
||
|
|
"path": "/tmp/skills/pr-workflow/SKILL.md",
|
||
|
|
"scope": "local",
|
||
|
|
})
|
||
|
|
.as_object()
|
||
|
|
.cloned(),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
None,
|
||
|
|
&models,
|
||
|
|
);
|
||
|
|
// Turn the fixture row into an InjectSkill-shaped row.
|
||
|
|
{
|
||
|
|
let row = agent.session.pending_prompts.front_mut().unwrap();
|
||
|
|
row.text = "/pr-workflow ship it".into();
|
||
|
|
row.wire_blocks = Some(vec![acp::ContentBlock::Text(acp::TextContent::new(
|
||
|
|
"<skill>pr-workflow instructions</skill>",
|
||
|
|
))]);
|
||
|
|
row.display_as_skill = true;
|
||
|
|
}
|
||
|
|
agent.queue.sync_from_merged(
|
||
|
|
&agent.session.pending_prompts,
|
||
|
|
&agent.shared_queue,
|
||
|
|
None,
|
||
|
|
None,
|
||
|
|
&agent.send_now_painted_blocks,
|
||
|
|
);
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[0]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
agent.prompt.set_text("great /pr-workflow go");
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&enter_key());
|
||
|
|
assert!(matches!(outcome, InputOutcome::Action(Action::DrainQueue)));
|
||
|
|
|
||
|
|
let effects = maybe_drain_queue(&mut agent);
|
||
|
|
match &agent.scrollback.get(0).unwrap().block {
|
||
|
|
RenderBlock::UserPrompt(b) => {
|
||
|
|
assert_eq!(b.text, "great /pr-workflow go");
|
||
|
|
assert_eq!(
|
||
|
|
b.skill_token_ranges,
|
||
|
|
vec![6..18],
|
||
|
|
"echo must style the recomputed mid-text token"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
other => panic!("expected UserPrompt, got {other:?}"),
|
||
|
|
}
|
||
|
|
match &effects[0] {
|
||
|
|
Effect::SendPrompt {
|
||
|
|
text,
|
||
|
|
skill_token_ranges,
|
||
|
|
..
|
||
|
|
} => {
|
||
|
|
assert_eq!(text, "great /pr-workflow go");
|
||
|
|
assert_eq!(skill_token_ranges, &vec![6..18]);
|
||
|
|
}
|
||
|
|
other => panic!("expected plain SendPrompt, got {other:?}"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn cancel_editing_queued_for_lost_row_exits_when_server_origin() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
agent.stashed_prompt = Some(crate::views::prompt_widget::StashedPrompt {
|
||
|
|
text: "pre-edit draft".into(),
|
||
|
|
cursor: 0,
|
||
|
|
images: Vec::new(),
|
||
|
|
chip_elements: Vec::new(),
|
||
|
|
image_counter: 0,
|
||
|
|
image_undo_stash: Vec::new(),
|
||
|
|
});
|
||
|
|
agent.prompt.set_text("replacement typed over queued row");
|
||
|
|
agent.prompt_mode = PromptMode::EditingQueued {
|
||
|
|
id: 999,
|
||
|
|
original: "server one".into(),
|
||
|
|
server_id: Some("p1".into()),
|
||
|
|
kind: crate::app::agent::QueueEntryKind::Prompt,
|
||
|
|
};
|
||
|
|
agent.cancel_editing_queued_for_lost_row();
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
assert_eq!(agent.prompt.text(), "pre-edit draft");
|
||
|
|
assert!(agent.stashed_prompt.is_none());
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Idempotent: the cleanup hook is a no-op when not in `EditingQueued`
|
||
|
|
/// with a server origin (e.g. local-origin edit, normal mode).
|
||
|
|
#[test]
|
||
|
|
fn cancel_editing_queued_for_lost_row_noop_for_local_or_normal() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
// Local-origin: should NOT exit (the local row hasn't disappeared).
|
||
|
|
agent.prompt_mode = PromptMode::EditingQueued {
|
||
|
|
id: 0,
|
||
|
|
original: "local one".into(),
|
||
|
|
server_id: None,
|
||
|
|
kind: crate::app::agent::QueueEntryKind::Prompt,
|
||
|
|
};
|
||
|
|
agent.cancel_editing_queued_for_lost_row();
|
||
|
|
assert!(matches!(
|
||
|
|
agent.prompt_mode,
|
||
|
|
PromptMode::EditingQueued { .. }
|
||
|
|
));
|
||
|
|
|
||
|
|
// Normal mode: no-op.
|
||
|
|
agent.prompt_mode = PromptMode::Normal;
|
||
|
|
agent.cancel_editing_queued_for_lost_row();
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A dirty pane switch must not arm the undrawn EditConfirm; lost-row cancel
|
||
|
|
/// restores the pre-edit draft.
|
||
|
|
#[test]
|
||
|
|
fn dirty_pane_switch_blocks_without_modal_then_lost_row_restores_draft() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
agent.prompt.set_text("draft");
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[0]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
agent.prompt.set_text("server one EDITED");
|
||
|
|
|
||
|
|
assert!(!agent.set_active_pane(AgentPane::Scrollback, false));
|
||
|
|
assert!(
|
||
|
|
agent.active_modal.is_none(),
|
||
|
|
"dirty pane switch must never arm an invisible input-eating EditConfirm"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
matches!(agent.prompt_mode, PromptMode::EditingQueued { .. }),
|
||
|
|
"blocked switch keeps the user in the edit"
|
||
|
|
);
|
||
|
|
|
||
|
|
agent.cancel_editing_queued_for_lost_row();
|
||
|
|
assert!(agent.active_modal.is_none());
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
assert_eq!(agent.prompt.text(), "draft");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Ctrl+; (toggle_queue_pane) while dirty-editing must not brick: the switch
|
||
|
|
/// is blocked, no modal armed, and the queue overlay is not left focused.
|
||
|
|
#[test]
|
||
|
|
fn toggle_queue_pane_while_dirty_editing_does_not_brick() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[0]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
agent.prompt.set_text("server one EDITED");
|
||
|
|
assert!(matches!(
|
||
|
|
agent.prompt_mode,
|
||
|
|
PromptMode::EditingQueued { .. }
|
||
|
|
));
|
||
|
|
|
||
|
|
agent.toggle_queue_pane();
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
agent.active_modal.is_none(),
|
||
|
|
"toggling the queue mid-edit must never arm an invisible EditConfirm"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
matches!(agent.prompt_mode, PromptMode::EditingQueued { .. }),
|
||
|
|
"the blocked switch keeps the user in the edit"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
!agent.queue.overlay.focused,
|
||
|
|
"a blocked switch must not leave the queue overlay focused while input is in the prompt"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Interject key while editing a LOCAL queued row mid-turn: the row
|
||
|
|
/// leaves the queue, the EDITED text interjects, edit mode exits, and
|
||
|
|
/// the stashed composer draft is restored (the pre-fix bug stranded
|
||
|
|
/// `EditingQueued` with the row still queued).
|
||
|
|
#[test]
|
||
|
|
fn edit_interject_local_row_interjects_edited_text() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
agent.prompt.set_text("draft");
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[1]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
agent.prompt.set_text("local one EDITED");
|
||
|
|
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
|
||
|
|
match outcome {
|
||
|
|
InputOutcome::Action(Action::Interject { text, .. }) => {
|
||
|
|
assert_eq!(text, "local one EDITED");
|
||
|
|
}
|
||
|
|
other => panic!("expected Interject, got {other:?}"),
|
||
|
|
}
|
||
|
|
assert!(agent.session.pending_prompts.is_empty());
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
assert_eq!(agent.prompt.text(), "draft");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn queue_edit_cancel_restores_draft_image_state() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
attach_image_to_local_row(&mut agent);
|
||
|
|
agent.prompt.set_text("draft ");
|
||
|
|
let draft_end = agent.prompt.text().len();
|
||
|
|
agent.prompt.set_cursor(draft_end);
|
||
|
|
agent.prompt.insert_image(test_pasted_image()).unwrap();
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[1]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
assert_eq!(agent.prompt.text(), "local one [Image #1] ");
|
||
|
|
assert_eq!(agent.prompt.images.len(), 1);
|
||
|
|
agent.prompt.set_text("discard this edit");
|
||
|
|
|
||
|
|
let outcome =
|
||
|
|
agent.handle_prompt_key_for_test(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||
|
|
assert!(matches!(outcome, InputOutcome::Action(Action::DrainQueue)));
|
||
|
|
assert_eq!(agent.prompt.text(), "draft [Image #1] ");
|
||
|
|
assert_eq!(
|
||
|
|
agent
|
||
|
|
.prompt
|
||
|
|
.textarea()
|
||
|
|
.elements()
|
||
|
|
.iter()
|
||
|
|
.filter(|element| element.kind == crate::views::prompt_widget::KIND_IMAGE)
|
||
|
|
.count(),
|
||
|
|
1
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
agent.prompt.drain_images().len(),
|
||
|
|
1,
|
||
|
|
"restored draft image must remain sendable"
|
||
|
|
);
|
||
|
|
let row = agent.session.pending_prompts.front().unwrap();
|
||
|
|
assert_eq!(row.text, "local one [Image #1] ");
|
||
|
|
assert_eq!(row.images.len(), 1);
|
||
|
|
assert_eq!(row.chip_elements.len(), 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn queue_edit_save_moves_complete_state_into_send() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
attach_image_to_local_row(&mut agent);
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[1]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
agent
|
||
|
|
.prompt
|
||
|
|
.insert_image(crate::prompt_images::from_clipboard_data(
|
||
|
|
&crate::clipboard::ImageData {
|
||
|
|
data: vec![1, 2, 3],
|
||
|
|
mime_type: "image/png".into(),
|
||
|
|
},
|
||
|
|
))
|
||
|
|
.unwrap();
|
||
|
|
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&enter_key());
|
||
|
|
assert!(matches!(outcome, InputOutcome::Action(Action::DrainQueue)));
|
||
|
|
let row = agent.session.pending_prompts.front().unwrap();
|
||
|
|
assert_eq!(row.images.len(), 2);
|
||
|
|
assert_eq!(
|
||
|
|
row.chip_elements
|
||
|
|
.iter()
|
||
|
|
.filter(|chip| chip.kind == crate::views::prompt_widget::KIND_IMAGE)
|
||
|
|
.count(),
|
||
|
|
2
|
||
|
|
);
|
||
|
|
|
||
|
|
agent.session.state = AgentState::Idle;
|
||
|
|
// Running server turn completed: its shared-queue row is gone, so the
|
||
|
|
// local edited row is the next turn (server-owns-next-turn gate clears).
|
||
|
|
agent.shared_queue.clear();
|
||
|
|
let effects = maybe_drain_queue(&mut agent);
|
||
|
|
assert!(matches!(
|
||
|
|
effects.as_slice(),
|
||
|
|
[Effect::SendPromptBlocks { .. }]
|
||
|
|
));
|
||
|
|
let in_flight = agent.session.in_flight_prompt.as_ref().unwrap();
|
||
|
|
assert_eq!(in_flight.images.len(), 2);
|
||
|
|
assert_eq!(in_flight.chip_elements.len(), 2);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Lone-local-row agent with "draft" stashed, edit mode entered on the
|
||
|
|
/// row. Interjecting empties the queue → the pane auto-hide switch runs
|
||
|
|
/// mid-flow (the setup `make_running_agent` can't reach: its server row
|
||
|
|
/// keeps the pane open).
|
||
|
|
fn edit_lone_local_row() -> AgentView {
|
||
|
|
let mut agent = running_agent_local_only();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
agent.prompt.set_text("draft");
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[0]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
// Fixture breakage must fail here, not in the flows under test.
|
||
|
|
assert!(matches!(
|
||
|
|
agent.prompt_mode,
|
||
|
|
PromptMode::EditingQueued { .. }
|
||
|
|
));
|
||
|
|
agent
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Edit-interject of a local bash row saves the edit and keeps it queued.
|
||
|
|
#[test]
|
||
|
|
fn edit_interject_local_bash_row_saves_edit_and_keeps_kind() {
|
||
|
|
use crate::app::agent::QueueEntryKind;
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
agent.session.pending_prompts.clear();
|
||
|
|
agent.session.enqueue_bash_command("ls".into());
|
||
|
|
agent.queue.sync_from_merged(
|
||
|
|
&agent.session.pending_prompts,
|
||
|
|
&agent.shared_queue,
|
||
|
|
agent.session.current_prompt_id.as_deref(),
|
||
|
|
agent.expect_send_now_cancel.as_deref(),
|
||
|
|
&agent.send_now_painted_blocks,
|
||
|
|
);
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
// The local bash row renders after the fixture's server row.
|
||
|
|
agent.queue.list_state.select_by_id(*ids.last().unwrap());
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
assert!(matches!(
|
||
|
|
agent.prompt_mode,
|
||
|
|
PromptMode::EditingQueued { .. }
|
||
|
|
));
|
||
|
|
agent.prompt.set_text("ls -la");
|
||
|
|
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
|
||
|
|
assert!(
|
||
|
|
!matches!(outcome, InputOutcome::Action(Action::Interject { .. })),
|
||
|
|
"bash edit-interject must not interject, got {outcome:?}"
|
||
|
|
);
|
||
|
|
let row = &agent.session.pending_prompts[0];
|
||
|
|
assert_eq!(row.text, "ls -la", "edit must be saved");
|
||
|
|
assert_eq!(row.kind, QueueEntryKind::BashCommand, "kind must survive");
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
assert!(agent.toast.is_some(), "guard must explain itself");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Interject a DIRTY edit of the last visible queue row: the auto-hide
|
||
|
|
/// pane switch must not strand an invisible `EditConfirm` modal that
|
||
|
|
/// silently consumes all subsequent input.
|
||
|
|
#[test]
|
||
|
|
fn edit_interject_lone_local_row_dirty_leaves_no_orphaned_modal() {
|
||
|
|
let mut agent = edit_lone_local_row();
|
||
|
|
agent.prompt.set_text("local one EDITED");
|
||
|
|
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
|
||
|
|
match outcome {
|
||
|
|
InputOutcome::Action(Action::Interject { text, .. }) => {
|
||
|
|
assert_eq!(text, "local one EDITED");
|
||
|
|
}
|
||
|
|
other => panic!("expected Interject, got {other:?}"),
|
||
|
|
}
|
||
|
|
assert!(agent.session.pending_prompts.is_empty());
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
assert!(
|
||
|
|
agent.active_modal.is_none(),
|
||
|
|
"orphaned EditConfirm would brick input"
|
||
|
|
);
|
||
|
|
assert_eq!(agent.prompt.text(), "draft");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Interject a CLEAN (unmodified) edit of the last visible queue row:
|
||
|
|
/// the auto-hide pane switch must not exit editing mode a second time
|
||
|
|
/// and wipe the just-restored composer draft.
|
||
|
|
#[test]
|
||
|
|
fn edit_interject_lone_local_row_clean_restores_draft_once() {
|
||
|
|
let mut agent = edit_lone_local_row();
|
||
|
|
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
|
||
|
|
match outcome {
|
||
|
|
InputOutcome::Action(Action::Interject { text, .. }) => {
|
||
|
|
assert_eq!(text, "local one");
|
||
|
|
}
|
||
|
|
other => panic!("expected Interject, got {other:?}"),
|
||
|
|
}
|
||
|
|
assert!(agent.session.pending_prompts.is_empty());
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
assert!(agent.active_modal.is_none());
|
||
|
|
assert_eq!(
|
||
|
|
agent.prompt.text(),
|
||
|
|
"draft",
|
||
|
|
"stash must be restored exactly once, not wiped by a double exit"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Deleting the lone LOCAL row while it is being edited (dirty): the
|
||
|
|
/// delete discards the edit — it must not leave `EditingQueued` pointing
|
||
|
|
/// at a removed row or let the auto-hide arm the invisible modal.
|
||
|
|
#[test]
|
||
|
|
fn delete_edited_lone_local_row_discards_edit_without_orphaned_modal() {
|
||
|
|
let mut agent = edit_lone_local_row();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
agent.prompt.set_text("local one EDITED");
|
||
|
|
|
||
|
|
let _ = agent.handle_queue_key(&delete_key(), ®istry);
|
||
|
|
|
||
|
|
assert!(agent.session.pending_prompts.is_empty());
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
assert!(
|
||
|
|
agent.active_modal.is_none(),
|
||
|
|
"orphaned EditConfirm would brick input"
|
||
|
|
);
|
||
|
|
assert_eq!(agent.prompt.text(), "draft");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// `PromptMode` for an in-progress edit of the fixture's local row.
|
||
|
|
fn editing_lone_local() -> PromptMode {
|
||
|
|
PromptMode::EditingQueued {
|
||
|
|
id: 0,
|
||
|
|
original: "local one".into(),
|
||
|
|
server_id: None,
|
||
|
|
kind: crate::app::agent::QueueEntryKind::Prompt,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// `exit_editing_mode` clears a stray `EditConfirm` (unreachable in-tree
|
||
|
|
/// after the reorder, so pin the backstop directly) and leaves every
|
||
|
|
/// other modal variant alone.
|
||
|
|
#[test]
|
||
|
|
fn exit_editing_mode_clears_only_stray_edit_confirm() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
agent.prompt_mode = editing_lone_local();
|
||
|
|
agent.active_modal = Some(ActiveModal::EditConfirm {
|
||
|
|
modal: ModalConfirmation::edit_confirm(),
|
||
|
|
pending_target: AgentPane::Scrollback,
|
||
|
|
});
|
||
|
|
agent.exit_editing_mode();
|
||
|
|
assert!(agent.active_modal.is_none());
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
|
||
|
|
// Scoping: a non-EditConfirm modal survives the exit untouched.
|
||
|
|
// Re-enter edit mode so the guarded body (not the idempotency
|
||
|
|
// early-return) is what leaves the palette alone.
|
||
|
|
agent.prompt_mode = editing_lone_local();
|
||
|
|
agent.active_modal = Some(ActiveModal::CommandPalette {
|
||
|
|
entries: crate::views::modal::default_palette_entries(agent.sharing_enabled),
|
||
|
|
state: crate::views::picker::PickerState::input_active(),
|
||
|
|
window: crate::views::modal_window::ModalWindowState::new(),
|
||
|
|
});
|
||
|
|
agent.exit_editing_mode();
|
||
|
|
assert!(matches!(
|
||
|
|
agent.active_modal,
|
||
|
|
Some(ActiveModal::CommandPalette { .. })
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// `exit_editing_mode` outside `EditingQueued` is a no-op — the
|
||
|
|
/// remove-then-exit caller shape must not wipe the composer by
|
||
|
|
/// double-taking the spent stash.
|
||
|
|
#[test]
|
||
|
|
fn exit_editing_mode_is_noop_when_not_editing() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
agent.prompt.set_text("draft");
|
||
|
|
agent.exit_editing_mode();
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
assert_eq!(agent.prompt.text(), "draft");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Interject key while editing a SERVER queued row mid-turn routes to
|
||
|
|
/// `Action::QueueInterjectShared` with the edited text as `new_text`
|
||
|
|
/// and exits edit mode without touching the local mirror.
|
||
|
|
#[test]
|
||
|
|
fn edit_interject_server_row_routes_to_queue_interject_shared_with_new_text() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[0]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
agent.prompt.set_text("server one EDITED");
|
||
|
|
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
|
||
|
|
match outcome {
|
||
|
|
InputOutcome::Action(Action::QueueInterjectShared {
|
||
|
|
id,
|
||
|
|
expected_version,
|
||
|
|
new_text,
|
||
|
|
}) => {
|
||
|
|
assert_eq!(id, "p1");
|
||
|
|
assert_eq!(expected_version, 2);
|
||
|
|
assert_eq!(new_text.as_deref(), Some("server one EDITED"));
|
||
|
|
}
|
||
|
|
other => panic!("expected QueueInterjectShared with new_text, got {other:?}"),
|
||
|
|
}
|
||
|
|
// Shared mirror untouched — the rebroadcast is the source of truth.
|
||
|
|
assert_eq!(agent.shared_queue.len(), 1);
|
||
|
|
assert_eq!(agent.shared_queue[0].text, "server one");
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Interject key while editing and IDLE behaves like the bare-Enter save
|
||
|
|
/// (row text updated + drain) instead of silently dropping the key.
|
||
|
|
#[test]
|
||
|
|
fn edit_interject_while_idle_saves_local_edit() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[1]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
agent.session.state = AgentState::Idle;
|
||
|
|
agent.prompt.set_text("local one EDITED");
|
||
|
|
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
|
||
|
|
match outcome {
|
||
|
|
InputOutcome::Action(Action::DrainQueue) => {}
|
||
|
|
other => panic!("expected DrainQueue, got {other:?}"),
|
||
|
|
}
|
||
|
|
assert_eq!(agent.session.pending_prompts.len(), 1);
|
||
|
|
assert_eq!(agent.session.pending_prompts[0].text, "local one EDITED");
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Interject key with an empty composer while editing is a no-op — no
|
||
|
|
/// empty interjection, and edit mode stays active.
|
||
|
|
#[test]
|
||
|
|
fn edit_interject_empty_composer_stays_in_edit_mode() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[1]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
agent.prompt.set_text(" ");
|
||
|
|
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
|
||
|
|
assert!(
|
||
|
|
matches!(outcome, InputOutcome::Changed),
|
||
|
|
"empty edit-interject must be a no-op, got {outcome:?}"
|
||
|
|
);
|
||
|
|
assert!(matches!(
|
||
|
|
agent.prompt_mode,
|
||
|
|
PromptMode::EditingQueued { .. }
|
||
|
|
));
|
||
|
|
assert_eq!(agent.session.pending_prompts.len(), 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Modal Save with an empty composer keeps the original row text — a
|
||
|
|
/// queued prompt must never be blanked by Save.
|
||
|
|
#[test]
|
||
|
|
fn edit_confirm_save_empty_preserves_original_text() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[1]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
agent.prompt.set_text("");
|
||
|
|
|
||
|
|
// Arm the modal directly (the pane switch no longer arms it) to test empty-Save.
|
||
|
|
agent.active_modal = Some(ActiveModal::EditConfirm {
|
||
|
|
modal: ModalConfirmation::edit_confirm(),
|
||
|
|
pending_target: AgentPane::Scrollback,
|
||
|
|
});
|
||
|
|
let _ = agent.handle_modal_key(&KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||
|
|
|
||
|
|
assert_eq!(agent.session.pending_prompts[0].text, "local one");
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Modal Save on a NON-front local row while idle saves the text but must
|
||
|
|
/// NOT dispatch `DrainQueue` — the modal advertised plain "save" (not
|
||
|
|
/// "save & send"), and draining would start the head prompt's turn.
|
||
|
|
#[test]
|
||
|
|
fn edit_confirm_save_non_front_row_does_not_drain() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
agent.shared_queue.clear();
|
||
|
|
agent.session.enqueue_prompt("local two".to_string());
|
||
|
|
agent.queue.sync_from_merged(
|
||
|
|
&agent.session.pending_prompts,
|
||
|
|
&agent.shared_queue,
|
||
|
|
None,
|
||
|
|
None,
|
||
|
|
&agent.send_now_painted_blocks,
|
||
|
|
);
|
||
|
|
agent.session.state = AgentState::Idle;
|
||
|
|
|
||
|
|
// Edit the non-front row; arm the modal directly (the pane switch no longer arms it).
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[1]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
agent.prompt.set_text("local two EDITED");
|
||
|
|
agent.active_modal = Some(ActiveModal::EditConfirm {
|
||
|
|
modal: ModalConfirmation::edit_confirm(),
|
||
|
|
pending_target: AgentPane::Scrollback,
|
||
|
|
});
|
||
|
|
|
||
|
|
let outcome =
|
||
|
|
agent.handle_modal_key(&KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||
|
|
assert!(
|
||
|
|
!matches!(outcome, InputOutcome::Action(Action::DrainQueue)),
|
||
|
|
"plain save must not drain the head prompt, got {outcome:?}"
|
||
|
|
);
|
||
|
|
assert_eq!(agent.session.pending_prompts.len(), 2);
|
||
|
|
assert_eq!(agent.session.pending_prompts[1].text, "local two EDITED");
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A bash row that vanished mid-edit saves via the kind snapshot, never plain-interjects.
|
||
|
|
#[test]
|
||
|
|
fn edit_interject_vanished_bash_row_saves_instead_of_plain_interject() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
agent.shared_queue[0].kind = "bash".into();
|
||
|
|
agent.queue.sync_from_merged(
|
||
|
|
&agent.session.pending_prompts,
|
||
|
|
&agent.shared_queue,
|
||
|
|
agent.session.current_prompt_id.as_deref(),
|
||
|
|
agent.expect_send_now_cancel.as_deref(),
|
||
|
|
&agent.send_now_painted_blocks,
|
||
|
|
);
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[0]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
assert!(matches!(
|
||
|
|
agent.prompt_input_mode,
|
||
|
|
crate::app::agent_view::PromptInputMode::Bash
|
||
|
|
));
|
||
|
|
|
||
|
|
agent.shared_queue.clear();
|
||
|
|
agent.queue.sync_from_merged(
|
||
|
|
&agent.session.pending_prompts,
|
||
|
|
&agent.shared_queue,
|
||
|
|
agent.session.current_prompt_id.as_deref(),
|
||
|
|
agent.expect_send_now_cancel.as_deref(),
|
||
|
|
&agent.send_now_painted_blocks,
|
||
|
|
);
|
||
|
|
agent.prompt_input_mode = crate::app::agent_view::PromptInputMode::Normal;
|
||
|
|
agent.prompt.set_text("ls -la EDITED");
|
||
|
|
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
|
||
|
|
match outcome {
|
||
|
|
InputOutcome::Action(Action::QueueEditShared { id, new_text }) => {
|
||
|
|
assert_eq!(id, "p1");
|
||
|
|
assert_eq!(new_text, "ls -la EDITED");
|
||
|
|
}
|
||
|
|
other => panic!("expected QueueEditShared save, got {other:?}"),
|
||
|
|
}
|
||
|
|
assert!(agent.toast.is_some(), "guard must explain itself");
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Server row vanished mid-edit (drained / removed by another client):
|
||
|
|
/// edit-interject falls back to a plain interjection of the edited text.
|
||
|
|
#[test]
|
||
|
|
fn edit_interject_server_row_vanished_falls_back_to_plain_interject() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[0]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
|
||
|
|
// Row disappears from the broadcast while the user edits.
|
||
|
|
agent.shared_queue.clear();
|
||
|
|
agent.queue.sync_from_merged(
|
||
|
|
&agent.session.pending_prompts,
|
||
|
|
&agent.shared_queue,
|
||
|
|
None,
|
||
|
|
None,
|
||
|
|
&agent.send_now_painted_blocks,
|
||
|
|
);
|
||
|
|
agent.prompt.set_text("server one EDITED");
|
||
|
|
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
|
||
|
|
match outcome {
|
||
|
|
InputOutcome::Action(Action::Interject { text, .. }) => {
|
||
|
|
assert_eq!(text, "server one EDITED");
|
||
|
|
}
|
||
|
|
other => panic!("expected Interject fallback, got {other:?}"),
|
||
|
|
}
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Interject key while editing a SERVER row and IDLE saves via
|
||
|
|
/// `Action::QueueEditShared` (same route as bare-Enter save).
|
||
|
|
#[test]
|
||
|
|
fn edit_interject_while_idle_saves_server_edit() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[0]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
agent.session.state = AgentState::Idle;
|
||
|
|
agent.prompt.set_text("server one EDITED");
|
||
|
|
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
|
||
|
|
match outcome {
|
||
|
|
InputOutcome::Action(Action::QueueEditShared { id, new_text }) => {
|
||
|
|
assert_eq!(id, "p1");
|
||
|
|
assert_eq!(new_text, "server one EDITED");
|
||
|
|
}
|
||
|
|
other => panic!("expected QueueEditShared, got {other:?}"),
|
||
|
|
}
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Composer images pasted while edit-interjecting a LOCAL row ride
|
||
|
|
/// along on the interjection instead of being dropped with a toast.
|
||
|
|
#[test]
|
||
|
|
fn edit_interject_local_row_carries_composer_images() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[1]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
agent.prompt.set_text("local one EDITED");
|
||
|
|
// Insert via the widget so the chip element exists (drain_images
|
||
|
|
// reconciles against live elements).
|
||
|
|
let len = agent.prompt.textarea().text().len();
|
||
|
|
agent.prompt.textarea.set_cursor(len);
|
||
|
|
agent
|
||
|
|
.prompt
|
||
|
|
.insert_image(crate::prompt_images::from_clipboard_data(
|
||
|
|
&crate::clipboard::ImageData {
|
||
|
|
data: vec![1, 2, 3],
|
||
|
|
mime_type: "image/png".into(),
|
||
|
|
},
|
||
|
|
))
|
||
|
|
.unwrap();
|
||
|
|
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
|
||
|
|
match outcome {
|
||
|
|
InputOutcome::Action(Action::Interject { images, .. }) => {
|
||
|
|
assert_eq!(images.len(), 1);
|
||
|
|
}
|
||
|
|
other => panic!("expected Interject with images, got {other:?}"),
|
||
|
|
}
|
||
|
|
assert!(agent.prompt.images.is_empty());
|
||
|
|
assert!(agent.toast.is_none(), "no drop toast expected");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Deciding test for the display_number-collision question: edit a LOCAL
|
||
|
|
/// row whose stored image is `[Image #1]`,
|
||
|
|
/// paste a NEW image mid-edit, interject — BOTH images must survive with
|
||
|
|
/// distinct display_numbers (no silent eviction via the dedup merge).
|
||
|
|
#[test]
|
||
|
|
fn edit_interject_pasted_image_does_not_collide_with_row_image() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
|
||
|
|
// The local row carries a stored image numbered 1 and its placeholder.
|
||
|
|
let mut row_img = crate::prompt_images::from_clipboard_data(&crate::clipboard::ImageData {
|
||
|
|
data: vec![9, 9, 9],
|
||
|
|
mime_type: "image/png".into(),
|
||
|
|
});
|
||
|
|
row_img.display_number = 1;
|
||
|
|
agent.session.pending_prompts[0].text = "local one [Image #1]".into();
|
||
|
|
agent.session.pending_prompts[0].images.push(row_img);
|
||
|
|
agent.session.pending_prompts[0]
|
||
|
|
.chip_elements
|
||
|
|
.push(crate::app::agent::ChipElement {
|
||
|
|
range: 10..20,
|
||
|
|
kind: crate::views::prompt_widget::KIND_IMAGE,
|
||
|
|
display: None,
|
||
|
|
});
|
||
|
|
agent.queue.sync_from_merged(
|
||
|
|
&agent.session.pending_prompts,
|
||
|
|
&agent.shared_queue,
|
||
|
|
None,
|
||
|
|
None,
|
||
|
|
&agent.send_now_painted_blocks,
|
||
|
|
);
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[1]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
|
||
|
|
// Paste a fresh image while editing.
|
||
|
|
let len = agent.prompt.textarea().text().len();
|
||
|
|
agent.prompt.textarea.set_cursor(len);
|
||
|
|
agent
|
||
|
|
.prompt
|
||
|
|
.insert_image(crate::prompt_images::from_clipboard_data(
|
||
|
|
&crate::clipboard::ImageData {
|
||
|
|
data: vec![1, 2, 3],
|
||
|
|
mime_type: "image/png".into(),
|
||
|
|
},
|
||
|
|
))
|
||
|
|
.unwrap();
|
||
|
|
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
|
||
|
|
match outcome {
|
||
|
|
InputOutcome::Action(Action::Interject { images, .. }) => {
|
||
|
|
assert_eq!(
|
||
|
|
images.len(),
|
||
|
|
2,
|
||
|
|
"row image + new paste must BOTH survive, got numbers {:?}",
|
||
|
|
images.iter().map(|i| i.display_number).collect::<Vec<_>>()
|
||
|
|
);
|
||
|
|
let mut numbers: Vec<usize> = images.iter().map(|i| i.display_number).collect();
|
||
|
|
numbers.sort_unstable();
|
||
|
|
numbers.dedup();
|
||
|
|
assert_eq!(numbers.len(), 2, "display_numbers must be distinct");
|
||
|
|
}
|
||
|
|
other => panic!("expected Interject with images, got {other:?}"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Composer images on a SERVER-row edit-interject are still dropped with
|
||
|
|
/// an accurate toast — the queue wire's `newText` is text-only.
|
||
|
|
#[test]
|
||
|
|
fn edit_interject_server_row_clears_images_with_toast() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[0]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
agent.prompt.set_text("server one EDITED");
|
||
|
|
agent
|
||
|
|
.prompt
|
||
|
|
.images
|
||
|
|
.push(crate::prompt_images::from_clipboard_data(
|
||
|
|
&crate::clipboard::ImageData {
|
||
|
|
data: vec![1, 2, 3],
|
||
|
|
mime_type: "image/png".into(),
|
||
|
|
},
|
||
|
|
));
|
||
|
|
|
||
|
|
let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
|
||
|
|
assert!(matches!(
|
||
|
|
outcome,
|
||
|
|
InputOutcome::Action(Action::QueueInterjectShared { .. })
|
||
|
|
));
|
||
|
|
assert!(agent.prompt.images.is_empty());
|
||
|
|
let (msg, _) = agent.toast.as_ref().expect("toast shown");
|
||
|
|
assert!(msg.contains("can't be attached"), "got toast {msg:?}");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Modal Save with an empty composer on a SERVER row must not emit
|
||
|
|
/// `QueueEditShared` (the row text would be blanked agent-side).
|
||
|
|
#[test]
|
||
|
|
fn edit_confirm_save_empty_server_row_skips_queue_edit() {
|
||
|
|
let mut agent = make_running_agent();
|
||
|
|
let registry = non_vscode_registry();
|
||
|
|
|
||
|
|
let ids = agent.queue.entry_ids();
|
||
|
|
agent.queue.list_state.select_by_id(ids[0]);
|
||
|
|
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||
|
|
agent.prompt.set_text("");
|
||
|
|
|
||
|
|
// Arm the modal directly (the pane switch no longer arms it).
|
||
|
|
agent.active_modal = Some(ActiveModal::EditConfirm {
|
||
|
|
modal: ModalConfirmation::edit_confirm(),
|
||
|
|
pending_target: AgentPane::Scrollback,
|
||
|
|
});
|
||
|
|
let outcome =
|
||
|
|
agent.handle_modal_key(&KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
!matches!(
|
||
|
|
outcome,
|
||
|
|
InputOutcome::Action(Action::QueueEditShared { .. })
|
||
|
|
),
|
||
|
|
"empty Save must not blank the server row, got {outcome:?}"
|
||
|
|
);
|
||
|
|
assert_eq!(agent.shared_queue[0].text, "server one");
|
||
|
|
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||
|
|
}
|
||
|
|
}
|