Skip to main content

cookie_agent_tui/ui/app/
composer.rs

1//! Composer input, submission, steering queue, and session commands.
2
3use super::*;
4
5impl App {
6    /// Retire any composer-leg selection: its byte offsets address the
7    /// draft as it was when the drag happened, so a later buffer mutation
8    /// or cursor move would leave it painting and cutting the wrong slice.
9    /// Conversation selections address rendered lines and are unaffected.
10    pub(super) fn retire_composer_selection(&mut self) {
11        if matches!(self.selection, Some(TextSelection::Composer { .. })) {
12            self.selection = None;
13        }
14    }
15
16    /// Mutate the composer draft, retiring any composer-leg selection.
17    pub(super) fn mutate_input(&mut self, mutation: impl FnOnce(&mut InputState)) {
18        mutation(&mut self.input);
19        self.retire_composer_selection();
20    }
21
22    /// Move the composer cursor, retiring any composer-leg selection the
23    /// same way a mutation does.
24    pub(super) fn navigate_input(&mut self, navigation: impl FnOnce(&mut InputState)) {
25        navigation(&mut self.input);
26        self.retire_composer_selection();
27    }
28
29    /// Delete a non-empty composer selection, as Backspace and Delete do in
30    /// any editor. Returns whether there was one to delete.
31    fn delete_composer_selection(&mut self) -> bool {
32        let Some(selection @ TextSelection::Composer { .. }) = self.selection else {
33            return false;
34        };
35        let (start, end) = selection.byte_range();
36        if start == end {
37            return false;
38        }
39        self.mutate_input(|input| input.delete_byte_range(start, end));
40        true
41    }
42
43    /// The composer holds keyboard focus whenever nothing sits on top of it:
44    /// no modal, palette, or approval panel, and no goal-bar focus (F6). A
45    /// read-only snapshot never takes input, so it is never focused. Clicks
46    /// elsewhere in the conversation do not move focus.
47    pub(in crate::ui) fn composer_focused(&self) -> bool {
48        self.modal == Modal::None
49            && self.palette.is_none()
50            && self.current_approval().is_none()
51            && self.goal_focus.is_none()
52            && self.session_writable()
53    }
54
55    /// Whether the composer may send to the current view: a read-only
56    /// snapshot accepts a message only as the first one of a pending new
57    /// root session.
58    pub(super) fn session_writable(&self) -> bool {
59        self.new_session_draft.is_some()
60            || self
61                .selected
62                .is_none_or(|session| !self.read_only_sessions.contains(&session))
63    }
64
65    pub(in crate::ui) async fn handle_input_key(&mut self, key: KeyEvent) {
66        // The palette opens from anywhere, read-only views included, and
67        // leaves the draft untouched. `/` opens it only on an empty draft so
68        // paths and other mid-text slashes still type.
69        if (key.code == KeyCode::Char('p') && key.modifiers == KeyModifiers::CONTROL)
70            || (key.code == KeyCode::Char('/')
71                && is_printable_key(key)
72                && self.input.as_str().is_empty())
73        {
74            self.open_command_palette();
75            return;
76        }
77        if !self.session_writable() {
78            self.status = "Session is owned by another cookie process; input is disabled.".into();
79            return;
80        }
81        if self.runtime.phase() == RuntimePhase::ErrorRetry
82            && key.code == KeyCode::Enter
83            && self.input.as_str().is_empty()
84        {
85            self.status = "Retrying runtime snapshot…".into();
86            self.refresh_coherent_lists().await;
87            return;
88        }
89        if self.runtime.phase() == RuntimePhase::Loading {
90            self.status = "loading runtime snapshot".into();
91            return;
92        }
93        if is_newline_key(key) {
94            self.mutate_input(|input| input.insert_newline());
95            return;
96        }
97        // A selection is deleted whole, with or without Ctrl, rather than
98        // retired in favor of a one-character or one-word delete.
99        if matches!(key.code, KeyCode::Backspace | KeyCode::Delete)
100            && self.delete_composer_selection()
101        {
102            return;
103        }
104        match key.code {
105            KeyCode::Enter => self.submit_input().await,
106            KeyCode::Backspace if key.modifiers.contains(KeyModifiers::CONTROL) => {
107                self.mutate_input(|input| input.delete_word_left());
108            }
109            KeyCode::Backspace => {
110                self.mutate_input(|input| input.backspace());
111            }
112            KeyCode::Delete if key.modifiers.contains(KeyModifiers::CONTROL) => {
113                self.mutate_input(|input| input.delete_word_right());
114            }
115            KeyCode::Delete => {
116                self.mutate_input(|input| input.delete());
117            }
118            KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => {
119                self.navigate_input(|input| input.move_word_left());
120            }
121            KeyCode::Left => self.navigate_input(|input| input.move_left()),
122            KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => {
123                self.navigate_input(|input| input.move_word_right());
124            }
125            KeyCode::Right => self.navigate_input(|input| input.move_right()),
126            KeyCode::Up => {
127                // Recall gesture: with an empty composer and a non-empty
128                // pending lane, Up withdraws the newest pending message back
129                // for editing instead of moving a cursor that has nothing
130                // to move through.
131                if self.input.as_str().is_empty() && self.selected_pending_inputs().is_some() {
132                    self.recall_newest_pending();
133                } else {
134                    self.navigate_input(|input| input.move_up());
135                }
136            }
137            KeyCode::Down => self.navigate_input(|input| input.move_down()),
138            KeyCode::PageUp => {
139                let page = self
140                    .hit_map
141                    .conversation
142                    .map_or(1, |rect| rect.height.max(1));
143                self.conversation_scroll.up(usize::from(page));
144            }
145            KeyCode::PageDown => {
146                let page = self
147                    .hit_map
148                    .conversation
149                    .map_or(1, |rect| rect.height.max(1));
150                self.conversation_scroll.down(usize::from(page));
151            }
152            KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL) => {
153                self.navigate_input(|input| input.move_buffer_home());
154            }
155            KeyCode::Home => self.navigate_input(|input| input.move_home()),
156            KeyCode::End if key.modifiers.contains(KeyModifiers::CONTROL) => {
157                self.navigate_input(|input| input.move_buffer_end());
158            }
159            KeyCode::End => self.navigate_input(|input| input.move_end()),
160            // Select the whole draft, cursor at its end, ready for Ctrl-C,
161            // Ctrl-X, Backspace, a paste, or typing over it.
162            KeyCode::Char('a') if key.modifiers == KeyModifiers::CONTROL => {
163                let len = self.input.as_str().len();
164                self.navigate_input(|input| input.move_buffer_end());
165                if len > 0 {
166                    self.selection = Some(TextSelection::Composer {
167                        anchor: 0,
168                        head: len,
169                    });
170                }
171            }
172            KeyCode::Char('e') if key.modifiers == KeyModifiers::CONTROL => {
173                self.navigate_input(|input| input.move_end());
174            }
175            KeyCode::Char(character) if is_printable_key(key) => {
176                // Typing over a selection replaces it, as a paste does.
177                self.delete_composer_selection();
178                self.mutate_input(|input| input.insert(character));
179            }
180            _ => {}
181        }
182    }
183
184    pub(in crate::ui) fn handle_paste(&mut self, text: &str) {
185        if self.command_palette_visible() {
186            self.paste_into_palette(text);
187            return;
188        }
189        if self.modal == Modal::Sessions {
190            let sanitized = text.replace(['\r', '\n'], "");
191            self.session_search.focus_input();
192            self.session_search.input_mut().insert_text(&sanitized);
193            self.session_search_changed();
194            return;
195        }
196        if self.modal == Modal::ConnectProviders {
197            let sanitized = text.replace(['\r', '\n'], "");
198            self.provider_search.focus_input();
199            self.provider_search.input_mut().insert_text(&sanitized);
200            self.provider_search_changed();
201            return;
202        }
203        if self.modal == Modal::Models {
204            let sanitized = text.replace(['\r', '\n'], "");
205            self.model_search.focus_input();
206            self.model_search.input_mut().insert_text(&sanitized);
207            self.model_search_changed();
208            return;
209        }
210        if self.modal == Modal::Agents {
211            let sanitized = text.replace(['\r', '\n'], "");
212            self.agent_search.focus_input();
213            self.agent_search.input_mut().insert_text(&sanitized);
214            self.agent_search_changed();
215            return;
216        }
217        if self.modal == Modal::ConnectSetup {
218            let mut sanitized = Zeroizing::new(text.replace(['\r', '\n'], ""));
219            if let Some(form) = &mut self.provider_form {
220                match form.focus() {
221                    ProviderFormFocus::Credential(index) => {
222                        form.error = None;
223                        form.secrets[index]
224                            .input
225                            .insert_owned(std::mem::take(&mut *sanitized));
226                    }
227                    ProviderFormFocus::Setup(index) => {
228                        form.error = None;
229                        form.setup[index]
230                            .input
231                            .insert_owned(std::mem::take(&mut *sanitized));
232                    }
233                    ProviderFormFocus::AuthMethod
234                    | ProviderFormFocus::Submit
235                    | ProviderFormFocus::Cancel => {}
236                }
237            }
238            return;
239        }
240        if self.modal == Modal::Mcp {
241            if let Some(input) = self
242                .mcp_panel
243                .form
244                .as_mut()
245                .and_then(McpForm::focused_input)
246            {
247                input.insert_text(&text.replace(['\r', '\n'], ""));
248            }
249            return;
250        }
251        if self.modal == Modal::Permissions {
252            if let Some(form) = &mut self.permission_panel.form
253                && form.focus_pattern
254            {
255                form.pattern.insert_text(&text.replace(['\r', '\n'], ""));
256            }
257            return;
258        }
259        // An open approval owns input; a paste must not land in the
260        // composer hidden behind it.
261        if self.modal != Modal::None || self.current_approval().is_some() {
262            return;
263        }
264        if !self.session_writable() {
265            self.status = "Session is owned by another cookie process; input is disabled.".into();
266            return;
267        }
268        // A paste over a composer selection replaces it, as in any editor;
269        // the deletion leaves the cursor in the gap the text fills.
270        self.delete_composer_selection();
271        let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
272        self.mutate_input(|input| input.insert_text(&normalized));
273    }
274
275    /// Send the composer draft as a prompt. Commands never come from the
276    /// composer: they run from the palette only, so every draft, including
277    /// one that begins with `/`, is sent verbatim.
278    pub(in crate::ui) async fn submit_input(&mut self) {
279        if !self.session_writable() {
280            self.status = "Session is owned by another cookie process; input is disabled.".into();
281            return;
282        }
283        if self.input.as_str().trim().is_empty() {
284            return;
285        }
286        let prompt = self.input.as_str().to_owned();
287        self.submit_prompt(prompt).await;
288    }
289
290    pub(in crate::ui) async fn submit_prompt(&mut self, input: String) {
291        if self.runtime.phase() == RuntimePhase::Loading {
292            self.status = "loading runtime snapshot".into();
293            return;
294        }
295        if self.runtime.phase() == RuntimePhase::ErrorRetry && self.runtime.snapshot().is_none() {
296            self.status = self
297                .runtime
298                .durable_explanation()
299                .unwrap_or("runtime snapshot unavailable; retry")
300                .into();
301            return;
302        }
303        if self.runtime.is_empty() {
304            self.status = EMPTY_RUNTIME_GUIDANCE.into();
305            return;
306        }
307        if let Some(selection) = self.new_session_draft.clone() {
308            // `/new` may be opened while an existing session remains
309            // selected. The draft is the authoritative signal that this
310            // first message belongs to a new root.
311            self.create_root_session(selection).await;
312            if self.new_session_draft.is_some() {
313                return;
314            }
315        }
316        let Some(session_id) = self.selected else {
317            self.status = "create or select a session first".into();
318            return;
319        };
320        let client = self.client.clone();
321        let updates = self.rpc_updates_tx.clone();
322        let active_run = self
323            .store
324            .sessions
325            .get(&session_id)
326            .and_then(|state| state.active_run);
327        let selection = if active_run.is_none() {
328            self.validated_draft_selection()
329        } else {
330            None
331        };
332        let reset_fallback = self.draft_reset_fallback;
333        if active_run.is_none() && selection.is_none() {
334            self.status = "select a draft agent/model before submitting".into();
335            return;
336        }
337        self.mutate_input(|input| {
338            input.take();
339        });
340        let submitted_id = client_run_id();
341        let draft_generation = self.draft_generation;
342        if active_run.is_none() {
343            self.track_fallback_reset(session_id, &submitted_id);
344        }
345        self.spawn_rpc(async move {
346            if let Some(run_id) = active_run {
347                // The engine admits steered inputs even across compaction
348                // reservations now and reports the pending lane through
349                // events; only a transport failure can strand the text, and
350                // that is owed back to the composer.
351                match client
352                    .steer_run(RunSteerParams {
353                        run_id,
354                        input: input.clone(),
355                    })
356                    .await
357                {
358                    Ok(result) if !result.accepted => {
359                        let error = result
360                            .handled_reason
361                            .unwrap_or_else(|| "steer request was rejected".into());
362                        let _ = updates.send(RpcUpdate::SteerFailed {
363                            session_id,
364                            input,
365                            error,
366                        });
367                    }
368                    Ok(result) if result.handled_reason.is_some() => {
369                        let _ = updates.send(RpcUpdate::Notice(
370                            result.handled_reason.expect("reason is present"),
371                        ));
372                    }
373                    Ok(_) => {}
374                    Err(error) => {
375                        let _ = updates.send(RpcUpdate::SteerFailed {
376                            session_id,
377                            input,
378                            error: error.to_string(),
379                        });
380                    }
381                }
382            } else {
383                let result = client
384                    .start_run(RunStartParams {
385                        reset_fallback,
386                        session_id,
387                        client_run_id: submitted_id.clone(),
388                        selection: selection.expect("draft selection checked"),
389                        input: input.clone(),
390                    })
391                    .await
392                    .map(|_| ())
393                    .map_err(|error| error.to_string());
394                let _ = updates.send(RpcUpdate::RunStartFinished {
395                    session_id,
396                    client_run_id: submitted_id,
397                    draft_generation,
398                    reset_fallback,
399                    input,
400                    result,
401                });
402            }
403        });
404    }
405
406    /// The viewed session's pending steered inputs, when the lane is
407    /// non-empty. The lane itself is a pure event reduction inside
408    /// `SessionState`; the strip is only a projection of it.
409    pub(super) fn selected_pending_inputs(&self) -> Option<&VecDeque<PendingInput>> {
410        self.selected
411            .and_then(|session_id| self.store.sessions.get(&session_id))
412            .map(|state| &state.pending_inputs)
413            .filter(|pending| !pending.is_empty())
414    }
415
416    pub(in crate::ui) fn selected_queue_entries(&self) -> Vec<PendingQueueEntry> {
417        use crate::state::ProducerMessageStatus;
418
419        let Some(state) = self.selected.and_then(|id| self.store.sessions.get(&id)) else {
420            return Vec::new();
421        };
422        let mut entries = state
423            .pending_inputs
424            .iter()
425            .map(|input| PendingQueueEntry {
426                kind: QueueEntryKind::User,
427                seq: input.admission_seq,
428                accepted_at: input.admitted_at,
429                preview: input.text.clone(),
430            })
431            .collect::<Vec<_>>();
432        for item in &state.transcript {
433            let TranscriptItem::ProducerMessage {
434                seq,
435                accepted_at,
436                message_id,
437                producer_owner,
438                mode,
439                summary,
440                status,
441                ..
442            } = item
443            else {
444                continue;
445            };
446            match status {
447                ProducerMessageStatus::Pending | ProducerMessageStatus::Admitted => {}
448                ProducerMessageStatus::Claimed
449                | ProducerMessageStatus::Consumed
450                | ProducerMessageStatus::Discarded => continue,
451            };
452            entries.push(PendingQueueEntry {
453                kind: QueueEntryKind::Producer(*message_id),
454                seq: *seq,
455                accepted_at: *accepted_at,
456                preview: crate::ui::transcript::producer_summary(
457                    producer_owner,
458                    *mode,
459                    summary.as_deref(),
460                ),
461            });
462        }
463        entries.sort_by_key(|entry| entry.seq);
464        entries
465    }
466
467    /// Strip height in rows for the selected session's pending lane: zero
468    /// while empty so the layout never leaves a stray border behind.
469    pub(in crate::ui) fn queue_strip_height(&self) -> u16 {
470        let pending = self.selected_queue_entries();
471        if pending.is_empty() {
472            return 0;
473        }
474        // Visible entries plus block borders; the "+N more" folding row
475        // shares the entry budget, so the cap never grows past it.
476        (pending.len().min(MAX_VISIBLE_QUEUE_ROWS) as u16).saturating_add(2)
477    }
478
479    /// Render the pending-input strip between the conversation pane and the
480    /// status line. Only user rows can recall input for editing.
481    pub(in crate::ui) fn render_queue_strip(&mut self, frame: &mut ratatui::Frame, area: Rect) {
482        self.hit_map.queue_entries.clear();
483        if area.height == 0 || area.width < 3 {
484            return;
485        }
486        let pending = self.selected_queue_entries();
487        if pending.is_empty() {
488            return;
489        }
490        let oldest_age = jiff::Timestamp::now()
491            .duration_since(pending[0].accepted_at)
492            .as_secs()
493            .max(0);
494        let title = truncate_with_ellipsis(
495            &format!("Pending · oldest {}", queue_age_label(oldest_age)),
496            usize::from(
497                area.width
498                    .saturating_sub(2 + 2 * crate::ui::PANEL_TITLE_PAD),
499            ),
500        );
501        let block = crate::ui::panel_block()
502            .title(crate::ui::panel_title(Span::styled(
503                title,
504                self.theme.muted(),
505            )))
506            .border_style(self.theme.panel_border())
507            .style(self.theme.panel());
508        let inner = block.inner(area);
509        frame.render_widget(block, area);
510        let entry_rows = inner.height as usize;
511        if entry_rows == 0 {
512            return;
513        }
514        // Entries fill the budget; one row folds the remainder into
515        // "+N more" so the strip never grows past its cap.
516        let shown = if pending.len() > entry_rows {
517            entry_rows.saturating_sub(1)
518        } else {
519            pending.len()
520        };
521        let overflow = pending.len() - shown;
522        let mut lines = Vec::new();
523        for (index, entry) in pending.iter().enumerate().take(shown) {
524            let prefix =
525                truncate_with_ellipsis(&format!("⏳ {} ", index + 1), usize::from(inner.width));
526            let available =
527                usize::from(inner.width).saturating_sub(UnicodeWidthStr::width(prefix.as_str()));
528            lines.push(Line::from(vec![
529                Span::styled(prefix, self.theme.muted()),
530                Span::styled(
531                    if available == 0 {
532                        String::new()
533                    } else {
534                        ellipsize_single_line(&entry.preview, available)
535                    },
536                    self.theme.muted(),
537                ),
538            ]));
539        }
540        if overflow > 0 {
541            lines.push(Line::from(Span::styled(
542                truncate_with_ellipsis(&format!("+{overflow} more"), usize::from(inner.width)),
543                self.theme.muted(),
544            )));
545        }
546        let line_count = lines.len();
547        frame.render_widget(Paragraph::new(lines), inner);
548        self.hit_map.queue_entries = (0..line_count)
549            .map(|index| QueueEntryHit {
550                rect: Rect::new(
551                    inner.x,
552                    inner.y.saturating_add(index as u16),
553                    inner.width,
554                    1,
555                ),
556                index,
557                kind: if index < shown {
558                    pending[index].kind
559                } else if pending
560                    .iter()
561                    .all(|entry| entry.kind == QueueEntryKind::User)
562                {
563                    QueueEntryKind::User
564                } else {
565                    QueueEntryKind::Overflow
566                },
567            })
568            .collect();
569    }
570
571    /// Copy text to the system clipboard via an OSC 52 escape written to
572    /// the terminal: no platform dependency, and it works over SSH (the
573    /// terminal emulator owns the clipboard, not the host). Terminals that
574    /// ignore OSC 52 simply leave the clipboard untouched.
575    pub(super) fn copy_to_clipboard(&mut self, text: String) {
576        let characters = text.chars().count();
577        let result = match &self.clipboard_sink {
578            ClipboardSink::Osc52 => io::stdout()
579                .lock()
580                .write_all(osc52_sequence(&text).as_bytes())
581                .and_then(|()| io::stdout().flush()),
582            #[cfg(test)]
583            ClipboardSink::Capture(copied) => {
584                copied.lock().expect("clipboard capture").push(text);
585                Ok(())
586            }
587        };
588        self.status = match result {
589            Ok(()) => format!("copied {characters} characters to the clipboard"),
590            Err(error) => format!("clipboard write failed: {error}"),
591        };
592    }
593
594    /// Prepend restored text to the composer, preserving FIFO order for
595    /// multiple entries. Never called with an empty batch.
596    pub(super) fn restore_composer_text(&mut self, texts: Vec<String>) {
597        debug_assert!(!texts.is_empty());
598        let mut restored = texts.join("\n");
599        let existing = self.input.as_str();
600        if !existing.is_empty() {
601            restored.push('\n');
602            restored.push_str(existing);
603        }
604        self.mutate_input(|input| input.set_buffer(restored));
605    }
606
607    /// Restore any voided pending inputs of the viewed session into the
608    /// composer: run-end casualties and recalls that resolved while another
609    /// session was being viewed. The store holds them until this drain.
610    pub(super) fn restore_voided_inputs(&mut self) {
611        let Some(session_id) = self.selected else {
612            return;
613        };
614        let texts = self.store.take_voided_inputs(session_id);
615        if texts.is_empty() {
616            return;
617        }
618        self.restore_composer_text(texts);
619        self.status = "unsent message restored to the composer".into();
620    }
621
622    /// Recall the engine's newest pending steered input so its text returns
623    /// to the composer for editing (`run.recall_steer`). Triggered by
624    /// clicking a strip entry or pressing Up in an empty composer; the
625    /// `UserInputRecalled` event removes the entry from the strip itself.
626    pub(in crate::ui) fn recall_newest_pending(&mut self) {
627        let Some(session_id) = self.selected else {
628            return;
629        };
630        let Some(state) = self.store.sessions.get(&session_id) else {
631            return;
632        };
633        let Some(run_id) = state.active_run else {
634            self.status = "no active run to recall from".into();
635            return;
636        };
637        if state.pending_inputs.is_empty() {
638            self.status = "no pending message to recall".into();
639            return;
640        }
641        let client = self.client.clone();
642        let updates = self.rpc_updates_tx.clone();
643        self.spawn_rpc(async move {
644            let update = match client.recall_steer(RunRecallSteerParams { run_id }).await {
645                Ok(result) => match result.recalled {
646                    Some(text) => {
647                        let _ = updates.send(RpcUpdate::SteerRecalled { session_id, text });
648                        return;
649                    }
650                    // The lane raced ahead (a promotion landed first):
651                    // nothing is owed; the strip is already catching up.
652                    None => RpcUpdate::Notice("nothing pending to recall".to_owned()),
653                },
654                Err(error) => RpcUpdate::Status(error.to_string()),
655            };
656            let _ = updates.send(update);
657        });
658    }
659
660    /// Open the copy/revert/fork menu for a clicked user-message row. The
661    /// message text is captured now: a rebuild (e.g. a concurrent revert)
662    /// can change the transcript before the action runs.
663    pub(super) fn open_user_menu(&mut self, hit: UserMessageHit) {
664        let Some(session_id) = self.selected else {
665            return;
666        };
667        let text = self.store.sessions.get(&session_id).and_then(|state| {
668            state.transcript.iter().find_map(|item| match item {
669                TranscriptItem::User { seq, text, .. } if *seq == hit.seq => Some(text.clone()),
670                _ => None,
671            })
672        });
673        let Some(text) = text else {
674            self.status = "message is no longer in the visible branch".into();
675            return;
676        };
677        self.user_menu = Some(UserMenuState {
678            session_id,
679            seq: hit.seq,
680            text,
681        });
682        self.picker_state.select(Some(0));
683        self.modal = Modal::UserMessage;
684    }
685
686    pub(super) async fn handle_user_menu_key(&mut self, key: KeyEvent) {
687        match key.code {
688            KeyCode::Esc => {
689                self.modal = Modal::None;
690                self.user_menu = None;
691            }
692            KeyCode::Up => {
693                move_picker_selection(&mut self.picker_state, USER_MENU_ITEMS.len(), true);
694            }
695            KeyCode::Down => {
696                move_picker_selection(&mut self.picker_state, USER_MENU_ITEMS.len(), false);
697            }
698            KeyCode::Enter => {
699                self.choose_picker_entry(self.picker_state.selected().unwrap_or(0))
700                    .await;
701            }
702            KeyCode::Char('c') => self.activate_user_menu_entry(0),
703            KeyCode::Char('r') => self.activate_user_menu_entry(1),
704            KeyCode::Char('f') => self.activate_user_menu_entry(2),
705            _ => {}
706        }
707    }
708
709    /// Run one menu row: copy, revert (behind its confirm guard), or fork.
710    pub(super) fn activate_user_menu_entry(&mut self, index: usize) {
711        let Some(menu) = self.user_menu.clone() else {
712            self.modal = Modal::None;
713            return;
714        };
715        match index {
716            0 => {
717                self.modal = Modal::None;
718                self.user_menu = None;
719                self.copy_to_clipboard(menu.text);
720            }
721            1 => self.modal = Modal::RevertConfirm,
722            2 => {
723                self.modal = Modal::None;
724                self.user_menu = None;
725                self.dispatch_session_fork(menu);
726            }
727            _ => {}
728        }
729    }
730
731    pub(super) async fn handle_revert_confirm_key(&mut self, key: KeyEvent) {
732        match key.code {
733            KeyCode::Esc | KeyCode::Char('n' | 'N') => self.modal = Modal::UserMessage,
734            KeyCode::Enter | KeyCode::Char('y' | 'Y') => {
735                let Some(menu) = self.user_menu.take() else {
736                    self.modal = Modal::None;
737                    return;
738                };
739                self.modal = Modal::None;
740                self.dispatch_session_revert(menu);
741            }
742            _ => {}
743        }
744    }
745
746    /// Revert the session to just before the menu's message (`through_seq =
747    /// seq - 1`), voiding it and every later turn from the visible branch.
748    /// The physical log is append-only; the `SessionReverted` marker drives
749    /// the transcript rebuild through the normal event flow. On success the
750    /// message text restores into the composer for editing and resending.
751    pub(super) fn dispatch_session_revert(&mut self, menu: UserMenuState) {
752        // User messages always follow `SessionCreated` (sequence 1), so
753        // `seq - 1` is a positive existing physical sequence.
754        let through_seq = menu.seq.saturating_sub(1).max(1);
755        let session_id = menu.session_id;
756        let text = menu.text;
757        self.status = "reverting to before the message…".into();
758        let client = self.client.clone();
759        let updates = self.rpc_updates_tx.clone();
760        self.spawn_rpc(async move {
761            let message = match client
762                .revert_session(SessionRevertParams {
763                    session_id,
764                    through_seq,
765                })
766                .await
767            {
768                Ok(result) => {
769                    let text = result.instructions_override.unwrap_or(text);
770                    let _ = updates.send(RpcUpdate::Reverted { session_id, text });
771                    return;
772                }
773                Err(error) => format!("revert failed: {error}"),
774            };
775            let _ = updates.send(RpcUpdate::Status(message));
776        });
777    }
778
779    /// Fork the session at the menu's message (`through_seq = seq`, keeping
780    /// the message in the copied prefix), then switch to the new session.
781    pub(super) fn dispatch_session_fork(&mut self, menu: UserMenuState) {
782        let session_id = menu.session_id;
783        self.status = "forking the session from the message…".into();
784        let client = self.client.clone();
785        let updates = self.rpc_updates_tx.clone();
786        self.spawn_rpc(async move {
787            let message = match client
788                .fork_session(SessionForkParams {
789                    session_id,
790                    through_seq: menu.seq,
791                })
792                .await
793            {
794                Ok(result) => {
795                    let _ = updates.send(RpcUpdate::Forked {
796                        forked: result.session_id,
797                    });
798                    return;
799                }
800                Err(error) => format!("fork failed: {error}"),
801            };
802            let _ = updates.send(RpcUpdate::Status(message));
803        });
804    }
805
806    pub(super) fn render_user_menu(&mut self, frame: &mut ratatui::Frame, area: Rect) {
807        paint_panel(frame, area, &self.theme);
808        let entries = USER_MENU_ITEMS
809            .iter()
810            .map(|(action, description)| format!("{action} — {description}"))
811            .collect();
812        self.render_picker(
813            frame,
814            "Message actions",
815            entries,
816            None,
817            area,
818            Some("↑↓ move · enter/c/r/f: run · esc: close"),
819        );
820    }
821
822    pub(super) fn render_revert_confirm(&mut self, frame: &mut ratatui::Frame, area: Rect) {
823        paint_panel(frame, area, &self.theme);
824        let Some(menu) = self.user_menu.as_ref() else {
825            return;
826        };
827        let preview = ellipsize_single_line(&menu.text, 48);
828        let content = format!(
829            "Revert to before \"{preview}\"?\n\nThe message and every later turn leave the visible branch; the append-only log is kept. The message text returns to the composer for editing and resending.\n\nPress Enter/Y to revert or Esc/N to go back."
830        );
831        frame.render_widget(
832            Paragraph::new(content).wrap(Wrap { trim: false }).block(
833                crate::ui::panel_block()
834                    .border_style(self.theme.panel_border())
835                    .title(crate::ui::panel_title("Confirm revert")),
836            ),
837            area,
838        );
839    }
840
841    pub async fn send_stdin(&mut self, input: String, eof: bool) {
842        let Some((run_id, call_id)) = self.selected_running_tool() else {
843            self.status = "no running interactive tool".into();
844            return;
845        };
846        let data = (!input.is_empty()).then(|| STANDARD.encode(input.as_bytes()));
847        let client = self.client.clone();
848        let updates = self.rpc_updates_tx.clone();
849        let lane = {
850            let mut lanes = self.stdin_lanes.lock().await;
851            lanes
852                .entry(call_id)
853                .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
854                .clone()
855        };
856        self.spawn_rpc(async move {
857            let _guard = lane.lock().await;
858            let update = match tokio::time::timeout(
859                STDIN_RPC_TIMEOUT,
860                client.tool_stdin(RunToolStdinParams {
861                    run_id,
862                    call_id,
863                    data,
864                    eof,
865                }),
866            )
867            .await
868            {
869                Err(_) => RpcUpdate::Status("stdin request timed out".into()),
870                Ok(Ok(result)) if !result.accepted => {
871                    RpcUpdate::Status("stdin was rejected by the tool".into())
872                }
873                Ok(Ok(_)) => {
874                    if eof {
875                        RpcUpdate::Notice("tool stdin closed".into())
876                    } else {
877                        RpcUpdate::Notice("stdin sent".into())
878                    }
879                }
880                Ok(Err(error)) => RpcUpdate::Status(error.to_string()),
881            };
882            let _ = updates.send(update);
883        });
884    }
885
886    pub(in crate::ui) async fn run_command(&mut self, command: SlashCommand) {
887        match command {
888            SlashCommand::Quit => self.should_quit = true,
889            SlashCommand::ShowAgentPanel => {
890                self.agent_panel_mode = AgentPanelMode::Shown;
891                self.status = "agent panel shown; manual visibility override active".into();
892            }
893            SlashCommand::HideAgentPanel => {
894                self.agent_panel_mode = AgentPanelMode::Hidden;
895                self.status = "agent panel hidden; manual visibility override active".into();
896            }
897            SlashCommand::New => {
898                if self.runtime.is_empty() {
899                    self.status = EMPTY_RUNTIME_GUIDANCE.into();
900                    return;
901                }
902                self.new_session_draft =
903                    self.draft_selection_for_preset(self.selected_preset.as_deref(), None);
904                self.open_selection_modal(Modal::Agents);
905                if self.modal == Modal::Agents {
906                    self.status = "Select the agent for the new root session.".into();
907                }
908            }
909            SlashCommand::Agent => self.open_selection_modal(Modal::Agents),
910            SlashCommand::Model => {
911                self.open_selection_modal(Modal::Models);
912                // Only the palette's `/model` continues on to the variant
913                // step; the title's model segment keeps its one-step picker.
914                self.model_then_variant = self.modal == Modal::Models;
915            }
916            SlashCommand::Preset => {
917                self.modal = Modal::Presets;
918                self.picker_state.select(Some(0));
919                self.status =
920                    "Select the preset for the next root run and future new sessions.".into();
921            }
922            SlashCommand::Connect => {
923                self.clear_connect_secrets();
924                self.modal = Modal::ConnectProviders;
925                self.provider_search.reset();
926                self.picker_state.select(Some(0));
927                if self.providers.is_empty() {
928                    self.status = "No providers are available in the runtime snapshot.".into();
929                } else {
930                    self.status = "Search providers, then press Down or Tab to choose one.".into();
931                }
932            }
933            SlashCommand::Mcp => {
934                self.modal = Modal::Mcp;
935                self.mcp_panel.form = None;
936                self.poll_mcp();
937            }
938            SlashCommand::Permissions => {
939                if self.selected.is_none() {
940                    self.status = "select a session before editing permissions".into();
941                } else {
942                    self.modal = Modal::Permissions;
943                    self.permission_panel.begin_load();
944                    self.load_permissions();
945                }
946            }
947            SlashCommand::Usage => {
948                self.modal = Modal::Usage;
949                self.load_usage();
950            }
951            SlashCommand::Sessions => {
952                self.modal = Modal::Sessions;
953                self.session_search.reset();
954                self.picker_state.select(Some(0));
955            }
956            SlashCommand::Cancel => self.cancel_active_run(),
957        }
958    }
959
960    /// View-only threshold change: the TOML is not rewritten and hidden rows
961    /// stay in the session projection.
962    pub(in crate::ui) fn set_event_level(&mut self, level: crate::state::EventLevel) {
963        self.tui_config.minimum_event_level = level;
964        self.status = format!("diagnostic event filter: {}", level.name());
965    }
966
967    /// Run a user-invocable skill with free-form arguments. A pending
968    /// new-session draft is honored by `submit_prompt`, which creates the
969    /// root session before sending.
970    pub(in crate::ui) async fn invoke_skill(&mut self, name: String, args: String) {
971        let Some(session_id) = self.selected else {
972            self.status = "select a session before invoking a skill".into();
973            return;
974        };
975        match self
976            .client
977            .get_skill(cookie_agent_protocol::SkillsGetParams {
978                session_id,
979                name: name.clone(),
980                args: args.clone(),
981            })
982            .await
983        {
984            Ok(result) if result.skill.user_invocable => {
985                self.submit_prompt(cookie_agent_protocol::encode_skill_submission(&name, &args))
986                    .await;
987            }
988            Ok(_) => self.status = "skill is not user-invocable".into(),
989            Err(error) => self.status = format!("load skill failed: {error}"),
990        }
991    }
992
993    pub(super) async fn compact_selected_session(&mut self, focus: Option<String>) {
994        let Some(session_id) = self.selected else {
995            self.status = "select a session before compacting context".into();
996            return;
997        };
998        let focus = match focus.map(SafeDisplayText::new).transpose() {
999            Ok(focus) => focus,
1000            Err(_) => {
1001                self.status = "compaction focus must be control-free and at most 1024 bytes".into();
1002                return;
1003            }
1004        };
1005        self.status = "compacting context…".into();
1006        self.status = match self
1007            .client
1008            .compact_session(SessionCompactParams { session_id, focus })
1009            .await
1010        {
1011            Ok(result) if result.compacted => "context compacted".into(),
1012            Ok(result) if result.cancellation_reason.is_some() => {
1013                result.cancellation_reason.expect("reason is present")
1014            }
1015            Ok(_) => "context did not require or could not produce a smaller checkpoint".into(),
1016            Err(error) => format!("context compaction failed: {error}"),
1017        };
1018    }
1019}