1use super::*;
4
5impl App {
6 pub(super) fn retire_composer_selection(&mut self) {
11 if matches!(self.selection, Some(TextSelection::Composer { .. })) {
12 self.selection = None;
13 }
14 }
15
16 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 (pending.len().min(MAX_VISIBLE_QUEUE_ROWS) as u16).saturating_add(2)
477 }
478
479 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 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 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 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 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 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 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 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 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 pub(super) fn dispatch_session_revert(&mut self, menu: UserMenuState) {
752 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 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 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 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 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}