1mod agents;
4mod approvals;
5mod composer;
6mod draw;
7mod keys;
8mod pickers;
9mod providers;
10mod refresh;
11mod sessions;
12#[cfg_attr(not(test), allow(unused_imports))]
13pub(super) use approvals::approval_content;
14use keys::{edit_credential_input, is_newline_key, is_printable_key};
15use pickers::{agent_picker_row, draft_title, model_picker_row};
16pub(super) use sessions::status_change_from_event;
17use sessions::{
18 collect_known_statuses, collect_known_titles, collect_subtree_sessions,
19 collect_tree_session_ids, find_node, find_node_mut, find_session, patch_tree_node_statuses,
20 patch_tree_node_titles, title_change_from_event,
21};
22
23mod goal;
24
25#[cfg(test)]
26mod fallback_tests;
27
28use std::{
29 collections::{HashMap, HashSet, VecDeque},
30 fmt::Write as _,
31 io::{self, Write as _},
32 sync::Arc,
33 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
34};
35
36use anyhow::Context;
37use base64::{Engine as _, engine::general_purpose::STANDARD};
38use cookie_agent_protocol::{
39 AgentDescriptor, AgentId, ApprovalListParams, ApprovalListResult, ApprovalRespondError,
40 ApprovalRespondErrorCode, ApprovalRespondParams, ApprovalStatus, ApprovalUserDecision,
41 AvailableModelDescriptor, ClientConnectId, ClientRequestId, ClientResponseId, ClientRunId,
42 EventPayload, McpAuthBeginParams, McpAuthBeginResult, McpAuthCancelParams, McpServerAddParams,
43 McpServerEditParams, McpServerInfo, McpServerNameParams, McpServerPersistParams,
44 McpServerSetEnabledParams, McpServerState, ModelKey, ModelSelection, PermissionAction,
45 PermissionEffect, PermissionMode, PermissionRuleSource, ProviderConnectParams,
46 ProviderDescriptor, ProviderDisconnectParams, RunCancelParams, RunRecallSteerParams,
47 RunSelection, RunStartParams, RunSteerParams, RunToolStdinParams,
48 SESSION_TREE_USAGE_CORRUPT_DELEGATION_CODE, SafeDisplayText, SessionCompactParams,
49 SessionCreateParams, SessionForkParams, SessionId, SessionListParams, SessionMeta,
50 SessionPermissionClearParams, SessionPermissionGetParams, SessionPermissionGetResult,
51 SessionPermissionSetParams, SessionResumeParams, SessionRevertParams,
52 SessionSetPermissionModeParams, SessionStatus, SessionTitle, SessionTitleChange, SessionTree,
53 SessionTreeParams, SessionTreeUsageResult, SessionUsageParams, SessionUsageResult, StoredEvent,
54 VariantId,
55};
56use crossterm::{
57 event::{
58 EnableBracketedPaste, EnableMouseCapture, Event as CrosstermEvent, EventStream, KeyCode,
59 KeyEvent, KeyModifiers, KeyboardEnhancementFlags, MouseButton, MouseEvent, MouseEventKind,
60 PushKeyboardEnhancementFlags,
61 },
62 execute,
63 terminal::{EnterAlternateScreen, enable_raw_mode},
64};
65use futures_util::StreamExt;
66use ratatui::{
67 Terminal,
68 backend::{Backend, CrosstermBackend},
69 layout::{Constraint, Direction, Layout, Position, Rect},
70 style::{Modifier, Style},
71 text::{Line, Span},
72 widgets::{Block, List, ListState, Paragraph, Wrap},
73};
74use unicode_segmentation::UnicodeSegmentation;
75use unicode_width::UnicodeWidthStr;
76use uuid::Uuid;
77use zeroize::Zeroizing;
78
79use crate::{
80 client::{Client, ClientDelivery, ClientError},
81 config::TuiConfig,
82 markdown::{Highlighter, SyntectHighlighter},
83 state::{
84 ApprovalState, DeliveryOutcome, EMPTY_RUNTIME_GUIDANCE, PendingInput, RuntimePhase,
85 RuntimeState, StateStore, ToolStatus, TranscriptItem, approval_state_from_record,
86 },
87 theme::Theme,
88};
89
90use super::events::{RenderScheduler, TerminalRestore, install_terminal_panic_hook};
91use super::input::{self, InputState};
92use super::management::{
93 McpAuthView, McpForm, McpFormFocus, McpPanel, PermissionForm, PermissionPanel, UsagePanel,
94 cycle_effect,
95};
96use super::pickers::{
97 SearchPickerFocus, SearchPickerState, SessionSearchRow, agent_matches, cycle_selection,
98 flatten_tree, model_matches, move_selection as move_picker_selection, provider_matches,
99 session_search_rows, short_id,
100};
101use super::provider::{
102 DURABLE_PROVIDER_COPY, ProviderAction, ProviderForm, ProviderFormFocus, ProviderOperation,
103 ProviderRowState, action_name, row_label, row_state,
104};
105use super::slash::{
106 CommandPalette, CommandSpec, EVENT_LEVELS, GoalCommand, PaletteAction, PaletteStep,
107 SlashCommand, TextTarget, move_selection,
108};
109use super::transcript::{
110 BlockHit, BlockId, ConversationScroll, LayoutCache, ScrollbarGeometry, wrapped_line,
111};
112
113#[derive(Clone, Copy, Debug, Eq, PartialEq)]
114pub(super) enum Modal {
115 None,
116 Sessions,
117 Presets,
118 Agents,
119 Models,
120 Variants,
123 ConnectProviders,
124 ConnectDetails,
125 ConnectSetup,
126 ConnectError,
127 DisconnectConfirm,
128 UserMessage,
130 RevertConfirm,
132 Mcp,
133 Permissions,
134 Usage,
135 GoalDetail,
136}
137
138#[derive(Clone, Copy, Debug, Eq, PartialEq)]
139pub(super) enum GoalBarAction {
140 Details,
141 Pause,
142 Resume,
143 Cancel,
144}
145
146pub(super) const SESSION_OWNED_BY_ANOTHER_PROCESS_CODE: i32 = -32022;
147
148pub(super) fn session_owned_by_another_process(error: &ClientError) -> bool {
149 matches!(error, ClientError::Rpc(error) if error.code == SESSION_OWNED_BY_ANOTHER_PROCESS_CODE)
150}
151
152pub(super) fn is_store_contention(error: &ClientError) -> bool {
155 match error {
156 ClientError::Rpc(rpc) => {
157 rpc.data
158 .as_ref()
159 .and_then(|data| data.get("code"))
160 .and_then(serde_json::Value::as_str)
161 == Some("lock_contention")
162 }
163 _ => false,
164 }
165}
166
167pub(super) fn retry_store_contention_once(attempt: usize, error: &ClientError) -> bool {
173 attempt == 0 && is_store_contention(error)
174}
175
176pub(super) fn store_contention_message(error: &ClientError, store: &str) -> String {
179 if is_store_contention(error) {
180 format!("Another cookie-agent process is writing the {store} — try again.")
181 } else {
182 error.to_string()
183 }
184}
185
186#[derive(Default)]
189pub(super) enum ClipboardSink {
190 #[default]
191 Osc52,
192 #[cfg(test)]
193 Capture(Arc<std::sync::Mutex<Vec<String>>>),
194}
195
196#[derive(Clone, Debug)]
200pub(super) struct UserMenuState {
201 pub(super) session_id: SessionId,
202 pub(super) seq: u64,
204 pub(super) text: String,
205}
206
207#[derive(Clone, Copy, Debug, Eq, PartialEq)]
213pub(super) enum TextSelection {
214 Conversation {
215 anchor: (usize, u16),
216 head: (usize, u16),
217 },
218 Composer {
219 anchor: usize,
220 head: usize,
221 },
222}
223
224impl TextSelection {
225 pub(super) fn ordered(&self) -> ((usize, u16), (usize, u16)) {
227 match *self {
228 Self::Conversation { anchor, head } => {
229 if anchor <= head {
230 (anchor, head)
231 } else {
232 (head, anchor)
233 }
234 }
235 Self::Composer { .. } => unreachable!("composer endpoints are byte offsets"),
236 }
237 }
238
239 pub(super) fn byte_range(&self) -> (usize, usize) {
241 match *self {
242 Self::Composer { anchor, head } => (anchor.min(head), anchor.max(head)),
243 Self::Conversation { .. } => {
244 unreachable!("conversation endpoints are line/column pairs")
245 }
246 }
247 }
248}
249
250#[derive(Clone, Copy, Debug, Eq, PartialEq)]
253pub(super) struct PendingPress {
254 pub(super) column: u16,
255 pub(super) row: u16,
256 pub(super) target: PressTarget,
257}
258
259#[derive(Clone, Copy, Debug, Eq, PartialEq)]
261pub(super) enum PressTarget {
262 Conversation,
263 Composer,
264}
265
266const DRAG_THRESHOLD_CELLS: u16 = 1;
269
270#[derive(Clone, Copy, Debug)]
271pub(super) struct InputHit {
272 pub(super) rect: Rect,
273 pub(super) text_rect: Rect,
274 pub(super) scrollbar: Option<ScrollbarGeometry>,
275}
276
277#[derive(Clone, Copy, Debug)]
278pub(super) struct ProviderFieldHit {
279 pub(super) rect: Rect,
280 pub(super) text_rect: Rect,
281 pub(super) focus: ProviderFormFocus,
282}
283
284#[derive(Clone, Copy, Debug)]
285pub(super) struct TreeRowHit {
286 pub(super) rect: Rect,
287 pub(super) session_id: SessionId,
288 pub(super) expand_rect: Option<Rect>,
289}
290
291#[derive(Clone, Copy, Debug)]
292pub(super) struct PickerRowHit {
293 pub(super) rect: Rect,
294 pub(super) index: usize,
295}
296
297#[derive(Clone, Copy, Debug)]
298pub(super) struct ApprovalHit {
299 pub(super) rect: Rect,
300 pub(super) decision: ApprovalUserDecision,
301}
302
303#[derive(Clone, Copy, Debug)]
304pub(super) struct PaletteRowHit {
305 pub(super) rect: Rect,
306 pub(super) index: usize,
307}
308
309#[derive(Clone, Copy, Debug)]
311pub(super) struct TitleSegmentHit {
312 pub(super) rect: Rect,
313 pub(super) segment: TitleSegment,
314}
315
316#[derive(Clone, Copy, Debug, Eq, PartialEq)]
317pub(super) enum QueueEntryKind {
318 User,
319 Producer(cookie_agent_protocol::ProducerMessageId),
320 Overflow,
321}
322
323pub(super) struct PendingQueueEntry {
324 pub(super) kind: QueueEntryKind,
325 pub(super) seq: u64,
326 pub(super) accepted_at: jiff::Timestamp,
327 pub(super) preview: String,
328}
329
330#[derive(Clone, Copy, Debug)]
332pub(super) struct QueueEntryHit {
333 pub(super) rect: Rect,
334 pub(super) index: usize,
335 pub(super) kind: QueueEntryKind,
336}
337
338#[derive(Clone, Copy, Debug)]
341pub(super) struct UserMessageHit {
342 pub(super) rect: Rect,
343 pub(super) seq: u64,
344}
345
346#[derive(Clone, Copy, Debug, Eq, PartialEq)]
347pub(super) enum TitleSegment {
348 Agent,
349 Model,
350 Variant,
351}
352
353#[derive(Clone, Copy, Debug)]
356pub(super) struct ScrollbarDrag {
357 pub(super) grab_row: u16,
358 pub(super) target: ScrollbarTarget,
359}
360
361#[derive(Clone, Copy, Debug, Eq, PartialEq)]
363pub(super) enum ScrollbarTarget {
364 Conversation,
365 Input,
366}
367
368#[derive(Clone, Copy, Debug, Eq, PartialEq)]
375pub(super) enum HoverTarget {
376 PaletteRow(usize),
377 PickerRow(usize),
378 ApprovalAction(ApprovalUserDecision),
379 TitleSegment(TitleSegment),
380 PermissionMode,
381 SessionCost,
382 EventLevelFilter,
383 TreeRow(SessionId),
384 QueueEntry(usize),
385 ProviderField(ProviderFormFocus),
386 ProviderSubmit,
387 ProviderCancel,
388 GoalAction(GoalBarAction),
389 GoalClose,
390 TranscriptBlock(BlockId),
391}
392
393#[derive(Clone, Copy, Debug, Eq, PartialEq)]
394enum WorkingState {
395 Working,
396 Queued(usize),
397}
398
399#[derive(Default)]
405pub(super) struct UiHitMap {
406 pub(super) input: Option<InputHit>,
407 pub(super) conversation: Option<Rect>,
408 pub(super) scrollbar: Option<Rect>,
409 pub(super) tree: Option<Rect>,
410 pub(super) picker: Option<Rect>,
411 pub(super) picker_input: Option<InputHit>,
412 pub(super) palette: Option<Rect>,
413 pub(super) blocks: Vec<BlockHit>,
414 pub(super) tree_rows: Vec<TreeRowHit>,
415 pub(super) picker_rows: Vec<PickerRowHit>,
416 pub(super) palette_rows: Vec<PaletteRowHit>,
417 pub(super) approval_actions: Vec<ApprovalHit>,
418 pub(super) approval: Option<Rect>,
419 pub(super) title_segments: Vec<TitleSegmentHit>,
420 pub(super) queue_entries: Vec<QueueEntryHit>,
423 pub(super) user_messages: Vec<UserMessageHit>,
425 pub(super) permission_mode: Option<Rect>,
426 pub(super) session_cost: Option<Rect>,
427 pub(super) event_level_filter: Option<Rect>,
428 pub(super) provider_fields: Vec<ProviderFieldHit>,
429 pub(super) provider_submit: Option<Rect>,
430 pub(super) provider_cancel: Option<Rect>,
431 pub(super) goal_actions: Vec<(Rect, GoalBarAction)>,
432 pub(super) goal_close: Option<Rect>,
433}
434
435impl UiHitMap {
436 fn clear(&mut self) {
437 self.input = None;
438 self.conversation = None;
439 self.scrollbar = None;
440 self.tree = None;
441 self.picker = None;
442 self.picker_input = None;
443 self.palette = None;
444 self.blocks.clear();
445 self.tree_rows.clear();
446 self.picker_rows.clear();
447 self.palette_rows.clear();
448 self.approval_actions.clear();
449 self.approval = None;
450 self.title_segments.clear();
451 self.queue_entries.clear();
452 self.user_messages.clear();
453 self.permission_mode = None;
454 self.session_cost = None;
455 self.event_level_filter = None;
456 self.provider_fields.clear();
457 self.provider_submit = None;
458 self.provider_cancel = None;
459 self.goal_actions.clear();
460 self.goal_close = None;
461 }
462}
463
464struct BottomBarRender {
465 line: Line<'static>,
466 mode_span: Option<usize>,
467 cost_span: Option<usize>,
468}
469
470#[derive(Default)]
471pub(super) struct SessionCostRefresh {
472 debounce_generation: u64,
473 request_id: u64,
474 scheduled: bool,
475 in_flight: bool,
476 dirty: bool,
477}
478
479#[derive(Default)]
481struct SessionSearchRowsCache {
482 query: String,
483 sessions_revision: u64,
484 sessions_len: usize,
485 local_day: Option<jiff::civil::Date>,
486 rows: Vec<SessionSearchRow>,
487}
488
489#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
490enum AgentPanelMode {
491 #[default]
492 Auto,
493 Shown,
494 Hidden,
495}
496
497pub struct App {
498 pub(super) client: Client,
499 pub(super) deliveries: Option<tokio::sync::mpsc::UnboundedReceiver<ClientDelivery>>,
500 pub(super) rpc_updates_tx: tokio::sync::mpsc::UnboundedSender<RpcUpdate>,
501 pub(super) rpc_updates_rx: tokio::sync::mpsc::UnboundedReceiver<RpcUpdate>,
502 pub(super) subscription_lanes:
503 Arc<tokio::sync::Mutex<HashMap<SessionId, Arc<tokio::sync::Mutex<()>>>>>,
504 pub(super) stdin_lanes: Arc<
505 tokio::sync::Mutex<HashMap<cookie_agent_protocol::ToolCallId, Arc<tokio::sync::Mutex<()>>>>,
506 >,
507 pub store: StateStore,
508 pub(super) sessions: Vec<SessionMeta>,
509 sessions_revision: u64,
510 session_search_rows_cache: SessionSearchRowsCache,
511 pub(super) runtime: RuntimeState,
512 pub(super) agents: Vec<AgentDescriptor>,
513 pub(super) selected_preset: Option<String>,
515 pub(super) new_session_draft: Option<RunSelection>,
517 pub(super) agent_revision: Option<cookie_agent_protocol::AgentRevision>,
520 pub(super) models: Vec<AvailableModelDescriptor>,
521 pub(super) model_revision: Option<cookie_agent_protocol::ModelRevision>,
523 pub(super) providers: Vec<ProviderDescriptor>,
524 pub(super) skills: Vec<cookie_agent_protocol::SkillDescriptor>,
525 #[cfg(test)]
526 pub(super) skill_refresh_requests: Vec<SessionId>,
527 pub(super) catalog_revision: Option<cookie_agent_protocol::CatalogRevision>,
528 pub(super) draft: Option<RunSelection>,
530 pub(super) draft_reset_fallback: bool,
531 draft_generation: u64,
532 pending_fallback_resets: HashMap<ClientRunId, PendingFallbackReset>,
533 pub(super) connect_provider: Option<ProviderDescriptor>,
534 pub(super) provider_form: Option<ProviderForm>,
535 pub(super) provider_operations: HashMap<cookie_agent_protocol::ProviderId, ProviderOperation>,
536 pub(super) connect_task: Option<tokio::task::JoinHandle<()>>,
537 pub(super) tree: Option<SessionTree>,
538 agent_panel_mode: AgentPanelMode,
539 pub(super) selected: Option<SessionId>,
541 pub(super) tree_root: Option<SessionId>,
543 pub(super) selection_generation: u64,
544 pub(super) tree_subscription_sessions: HashSet<SessionId>,
545 pub(super) read_only_sessions: HashSet<SessionId>,
546 pub(super) owned_sessions: HashSet<SessionId>,
547 pub(super) ownership_classifications: HashMap<SessionId, u64>,
548 pub(super) next_ownership_classification: u64,
549 pub(super) pending_live_subscriptions: HashSet<SessionId>,
550 pub(super) live_subscription_attempts: HashMap<SessionId, u64>,
551 pub(super) next_live_subscription_attempt: u64,
552 pub(super) replay_ended_for_live_subscription: HashSet<SessionId>,
553 pub(super) tree_refresh_in_flight: Option<(u64, u64)>,
554 pub(super) tree_refresh_pending: bool,
555 pub(super) next_tree_refresh_id: u64,
556 pub(super) tree_cursor: Option<SessionId>,
557 pub(super) tree_offset: usize,
558 pub(super) tree_viewport_height: usize,
559 pub(super) collapsed_sessions: HashSet<SessionId>,
560 pub(super) expanded_blocks: HashMap<SessionId, HashSet<BlockId>>,
561 pub(super) permission_modes: HashMap<SessionId, PermissionMode>,
563 permission_mode_generations: HashMap<SessionId, u64>,
564 pub(super) mcp_panel: McpPanel,
565 pub(super) permission_panel: PermissionPanel,
566 pub(super) usage_panel: UsagePanel,
567 pub(super) usage_load_generation: u64,
568 pub(super) cost_refreshes: HashMap<SessionId, SessionCostRefresh>,
569 pub(super) next_cost_refresh_request_id: u64,
570 pub(super) conversation_scroll: ConversationScroll,
571 pub(super) scrollbar_geometry: Option<ScrollbarGeometry>,
572 pub(super) scrollbar_drag: Option<ScrollbarDrag>,
573 pub(super) approval_scroll: u16,
574 pub(super) approval_max_scroll: u16,
575 pub(super) approval_scroll_request: Option<(cookie_agent_protocol::ApprovalId, u64)>,
576 pub(super) pending_approval: Option<PendingApprovalSubmission>,
577 pub(super) approval_shown: Option<ApprovalShown>,
581 pub(super) next_approval_request_id: u64,
582 pub(super) approval_refresh_in_flight: Option<(SessionId, u64, u64)>,
583 pub(super) next_approval_refresh_id: u64,
584 pub(super) layout_cache: LayoutCache,
585 pub(super) tui_config: TuiConfig,
586 pub(super) theme: Theme,
587 pub(super) highlighter: Box<dyn Highlighter>,
588 pub(super) hit_map: UiHitMap,
589 pub(super) hover: Option<HoverTarget>,
592 pub(super) animation_ticks: u64,
595 pub(super) transient_notices: Vec<String>,
596 pub(super) goal_notices: HashMap<SessionId, Vec<String>>,
597 goal_detail: goal::GoalDetailState,
598 pub(super) goal_focus: Option<GoalBarAction>,
599 pub(super) picker_state: ListState,
600 pub(super) session_search: SearchPickerState,
601 pub(super) agent_search: SearchPickerState,
602 pub(super) model_search: SearchPickerState,
603 pub(super) provider_search: SearchPickerState,
604 pub(super) palette: Option<CommandPalette>,
607 pub(super) model_then_variant: bool,
610 pub(super) variant_step_model: Option<ModelKey>,
612 pub(super) last_escape: Option<Instant>,
613 pub(super) last_quit_press: Option<Instant>,
616 pub(super) input: InputState,
617 pub(super) modal: Modal,
618 pub(super) stdin_target: Option<cookie_agent_protocol::ToolCallId>,
619 pub(super) status: String,
620 session_errors: SessionErrorSummary,
621 pub(super) should_quit: bool,
622 pub(super) selection: Option<TextSelection>,
625 pub(super) pending_press: Option<PendingPress>,
627 pub(super) user_menu: Option<UserMenuState>,
629 pub(super) clipboard_sink: ClipboardSink,
631 pub(super) title_sequences: HashMap<SessionId, u64>,
635}
636
637const MAX_VISIBLE_QUEUE_ROWS: usize = 3;
640const MAX_REPORTED_SESSION_ERROR_LINES: usize = 20;
641
642#[derive(Clone, Debug, Default, Eq, PartialEq)]
643struct SessionErrorSummary {
644 error_count: usize,
645 lines: VecDeque<String>,
646}
647
648impl SessionErrorSummary {
649 fn record(&mut self, error: &str) {
650 self.error_count = self.error_count.saturating_add(1);
651 for line in error.lines().filter(|line| !line.trim().is_empty()) {
652 if self.lines.len() == MAX_REPORTED_SESSION_ERROR_LINES {
653 self.lines.pop_front();
654 }
655 self.lines.push_back(line.to_owned());
656 }
657 }
658
659 fn format(&self) -> Option<String> {
660 if self.error_count == 0 {
661 return None;
662 }
663 let mut output = format!(
664 "cookie-agent: session ended with {} error(s):",
665 self.error_count
666 );
667 for line in &self.lines {
668 let _ = write!(output, "\n - {line}");
669 }
670 Some(output)
671 }
672}
673
674const USER_MENU_ITEMS: &[(&str, &str)] = &[
677 ("copy", "message text to the clipboard"),
678 (
679 "revert",
680 "roll back to before this message; its text returns to the composer",
681 ),
682 ("fork", "branch a new session from this message"),
683];
684
685pub(super) enum RpcUpdate {
686 Status(String),
687 Notice(String),
688 RunStartFinished {
689 session_id: SessionId,
690 client_run_id: ClientRunId,
691 draft_generation: u64,
692 reset_fallback: bool,
693 input: String,
694 result: Result<(), String>,
695 },
696 GoalFinished {
697 session_id: SessionId,
698 result: Box<Result<Option<cookie_agent_protocol::GoalState>, String>>,
699 },
700 SessionOwnershipClassified {
701 session_id: SessionId,
702 generation: u64,
703 outcome: SessionOwnershipOutcome,
704 },
705 SessionLiveSubscriptionFinished {
706 session_id: SessionId,
707 live_attempt: Option<u64>,
708 outcome: SessionLiveSubscriptionOutcome,
709 },
710 SteerFailed {
713 session_id: SessionId,
714 input: String,
715 error: String,
716 },
717 SteerRecalled {
719 session_id: SessionId,
720 text: String,
721 },
722 Reverted {
725 session_id: SessionId,
726 text: String,
727 },
728 Forked {
730 forked: SessionId,
731 },
732 Tree {
733 session_id: SessionId,
734 generation: u64,
735 request_id: u64,
736 tree: Box<SessionTree>,
737 },
738 TreeFailed {
739 session_id: SessionId,
740 generation: u64,
741 request_id: u64,
742 error: String,
743 },
744 ProviderMutationFinished {
745 outcome: ProviderMutationOutcome,
746 },
747 ApprovalResponse {
748 request_id: u64,
749 approval_id: cookie_agent_protocol::ApprovalId,
750 result: Result<(), ApprovalSubmissionError>,
751 },
752 ApprovalList {
753 root_session_id: SessionId,
754 generation: u64,
755 request_id: u64,
756 result: Result<ApprovalListResult, String>,
757 },
758 PermissionModeMutationFinished {
759 session_id: SessionId,
760 generation: u64,
761 result: Result<(), String>,
762 },
763 PermissionModeLoaded {
764 session_id: SessionId,
765 generation: u64,
766 result: Result<Option<PermissionMode>, String>,
767 },
768 McpRefreshed {
769 result: Result<cookie_agent_protocol::McpServerListResult, String>,
770 },
771 McpMutation {
772 result: Box<Result<Option<McpServerInfo>, String>>,
773 },
774 McpAuthBegan {
775 result: Result<McpAuthBeginResult, String>,
776 },
777 McpAuthCancelled {
778 result: Result<String, String>,
779 },
780 PermissionsLoaded {
781 session_id: SessionId,
782 result: Result<SessionPermissionGetResult, String>,
783 },
784 SkillsLoaded {
785 session_id: SessionId,
786 result: Result<cookie_agent_protocol::SkillsListResult, String>,
787 },
788 UsageLoaded {
789 generation: u64,
790 session_id: Option<SessionId>,
791 session: Result<Option<SessionUsageResult>, String>,
792 tree: Result<Option<SessionTreeUsageResult>, ClientError>,
793 },
794 SessionCostLoaded {
795 session_id: SessionId,
796 request_id: u64,
797 result: Result<SessionUsageResult, String>,
798 },
799 SessionCostDebounceElapsed {
800 session_id: SessionId,
801 generation: u64,
802 },
803}
804
805struct PendingFallbackReset {
806 session_id: SessionId,
807 draft_generation: u64,
808 rpc_admitted: bool,
809 replay_generation: Option<u64>,
810}
811
812pub(super) enum SessionOwnershipOutcome {
813 Owned(Box<SessionMeta>),
814 Foreign,
815 Failed(String),
816}
817
818pub(super) enum SessionLiveSubscriptionOutcome {
819 Established,
820 ReplayInProgress,
821 Failed(String),
822}
823
824pub(super) const APPROVAL_HOTKEY_GRACE: Duration = Duration::from_millis(400);
827
828#[derive(Clone, Copy, Debug)]
831pub(super) struct ApprovalShown {
832 pub(super) approval_id: cookie_agent_protocol::ApprovalId,
833 pub(super) request_revision: u64,
834 pub(super) at: Instant,
835}
836
837#[derive(Clone, Debug)]
840pub(super) struct PendingApprovalSubmission {
841 pub(super) request_id: u64,
842 pub(super) approval: ApprovalState,
843 pub(super) decision: ApprovalUserDecision,
844}
845
846#[derive(Debug)]
847pub(super) struct ApprovalSubmissionError {
848 pub(super) message: String,
849 pub(super) code: Option<ApprovalRespondErrorCode>,
850}
851
852impl ApprovalSubmissionError {
853 fn from_client(error: ClientError) -> Self {
854 let code = match &error {
855 ClientError::Rpc(error) => error
856 .data
857 .clone()
858 .and_then(|data| serde_json::from_value::<ApprovalRespondError>(data).ok())
859 .map(|error| error.code),
860 _ => None,
861 };
862 Self {
863 message: error.to_string(),
864 code,
865 }
866 }
867
868 fn stale_projection(&self) -> bool {
869 matches!(
870 self.code,
871 Some(
872 ApprovalRespondErrorCode::ApprovalNotFound
873 | ApprovalRespondErrorCode::ApprovalNotPending
874 | ApprovalRespondErrorCode::ApprovalRevisionConflict
875 | ApprovalRespondErrorCode::OperationFingerprintMismatch
876 | ApprovalRespondErrorCode::OperationChanged
877 | ApprovalRespondErrorCode::IdempotencyConflict
878 )
879 )
880 }
881}
882
883pub(super) enum ProviderMutationOutcome {
884 Failed {
885 provider_id: cookie_agent_protocol::ProviderId,
886 action: ProviderAction,
887 error: String,
888 },
889 Connected {
890 provider_id: cookie_agent_protocol::ProviderId,
891 baseline: Option<cookie_agent_protocol::RuntimeRevision>,
892 runtime: Box<cookie_agent_protocol::RuntimeSnapshotV1>,
893 },
894 Disconnected {
895 provider_id: cookie_agent_protocol::ProviderId,
896 baseline: Option<cookie_agent_protocol::RuntimeRevision>,
897 runtime: Box<cookie_agent_protocol::RuntimeSnapshotV1>,
898 },
899}
900
901const MAX_TRANSIENT_NOTICES: usize = 4;
902const TREE_REFRESH_TIMEOUT: Duration = Duration::from_secs(2);
903const TREE_SUBSCRIPTION_TIMEOUT: Duration = Duration::from_secs(6);
904const STDIN_RPC_TIMEOUT: Duration = Duration::from_secs(1);
905const SESSION_COST_DEBOUNCE: Duration = Duration::from_millis(250);
906impl App {
907 #[cfg(test)]
908 pub(crate) async fn wait_for_skill_refresh_for_test(&mut self) {
909 while let Some(update) = self.rpc_updates_rx.recv().await {
910 let skills = matches!(update, RpcUpdate::SkillsLoaded { .. });
911 self.handle_rpc_update(update);
912 if skills {
913 break;
914 }
915 }
916 }
917
918 #[cfg(test)]
919 pub(crate) fn skill_names_for_test(&self) -> Vec<&str> {
920 self.skills
921 .iter()
922 .map(|skill| skill.name.as_str())
923 .collect()
924 }
925
926 #[cfg(test)]
927 pub(crate) fn skill_visible_for_test(&self, name: &str) -> Option<bool> {
928 self.skills
929 .iter()
930 .find(|skill| skill.name == name)
931 .map(|skill| skill.visible)
932 }
933
934 #[cfg(test)]
935 pub(crate) async fn handle_key_for_test(&mut self, key: KeyEvent) {
936 self.handle_key(key).await;
937 }
938
939 #[cfg(test)]
940 pub(crate) fn palette_open_for_test(&self) -> bool {
941 self.command_palette_visible()
942 }
943
944 #[cfg(test)]
946 pub(crate) fn palette_labels_for_test(&self) -> Vec<String> {
947 self.palette_list_labels()
948 }
949
950 #[cfg(test)]
953 pub(crate) fn arm_approval_hotkeys_for_test(&mut self) {
954 let approval = self.current_approval().expect("pending approval");
955 self.approval_shown = Some(ApprovalShown {
956 approval_id: approval.approval_id,
957 request_revision: approval.request_revision,
958 at: Instant::now()
959 .checked_sub(APPROVAL_HOTKEY_GRACE * 2)
960 .expect("backdated instant"),
961 });
962 }
963
964 #[cfg(test)]
965 pub(crate) async fn submit_text_for_test(&mut self, text: &str) {
966 self.input.set_buffer(text.to_owned());
967 self.submit_input().await;
968 }
969
970 #[cfg(test)]
971 pub(crate) fn refresh_skills_for_event_for_test(&mut self, event: &StoredEvent) {
972 self.refresh_skills_for_event(event);
973 }
974
975 #[cfg(test)]
976 pub(crate) fn skill_refresh_count_for_test(&self) -> usize {
977 self.skill_refresh_requests.len()
978 }
979
980 pub async fn new(client: Client) -> Result<Self, crate::config::TuiConfigError> {
981 Self::new_with_startup_mode(client, false).await
982 }
983
984 pub async fn new_with_new_session(
985 client: Client,
986 ) -> Result<Self, crate::config::TuiConfigError> {
987 Self::new_with_startup_mode(client, true).await
988 }
989
990 async fn new_with_startup_mode(
991 client: Client,
992 create_new_session: bool,
993 ) -> Result<Self, crate::config::TuiConfigError> {
994 let tui_config = crate::config::load(None)?;
995 let theme = crate::terminal_detect::theme_without_terminal_detection(tui_config.theme);
996 Self::new_with_config(client, create_new_session, tui_config, theme).await
997 }
998
999 async fn new_with_config(
1000 client: Client,
1001 create_new_session: bool,
1002 tui_config: TuiConfig,
1003 theme: Theme,
1004 ) -> Result<Self, crate::config::TuiConfigError> {
1005 let deliveries = client
1008 .subscribe_deliveries()
1009 .expect("app delivery receiver already attached");
1010 let (rpc_updates_tx, rpc_updates_rx) = tokio::sync::mpsc::unbounded_channel();
1011 let mut app = Self {
1012 client,
1013 deliveries: Some(deliveries),
1014 rpc_updates_tx,
1015 rpc_updates_rx,
1016 subscription_lanes: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
1017 stdin_lanes: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
1018 store: StateStore::default(),
1019 sessions: Vec::new(),
1020 sessions_revision: 0,
1021 session_search_rows_cache: SessionSearchRowsCache::default(),
1022 runtime: RuntimeState::default(),
1023 agents: Vec::new(),
1024 selected_preset: None,
1025 new_session_draft: None,
1026 agent_revision: None,
1027 models: Vec::new(),
1028 model_revision: None,
1029 providers: Vec::new(),
1030 skills: Vec::new(),
1031 #[cfg(test)]
1032 skill_refresh_requests: Vec::new(),
1033 catalog_revision: None,
1034 draft: None,
1035 draft_reset_fallback: false,
1036 draft_generation: 0,
1037 pending_fallback_resets: HashMap::new(),
1038 connect_provider: None,
1039 provider_form: None,
1040 provider_operations: HashMap::new(),
1041 connect_task: None,
1042 tree: None,
1043 agent_panel_mode: AgentPanelMode::Auto,
1044 selected: None,
1045 tree_root: None,
1046 selection_generation: 0,
1047 tree_subscription_sessions: HashSet::new(),
1048 read_only_sessions: HashSet::new(),
1049 owned_sessions: HashSet::new(),
1050 ownership_classifications: HashMap::new(),
1051 next_ownership_classification: 0,
1052 pending_live_subscriptions: HashSet::new(),
1053 live_subscription_attempts: HashMap::new(),
1054 next_live_subscription_attempt: 0,
1055 replay_ended_for_live_subscription: HashSet::new(),
1056 tree_refresh_in_flight: None,
1057 tree_refresh_pending: false,
1058 next_tree_refresh_id: 0,
1059 tree_cursor: None,
1060 tree_offset: 0,
1061 tree_viewport_height: 0,
1062 collapsed_sessions: HashSet::new(),
1063 expanded_blocks: HashMap::new(),
1064 permission_modes: HashMap::new(),
1065 permission_mode_generations: HashMap::new(),
1066 mcp_panel: McpPanel::default(),
1067 permission_panel: PermissionPanel::default(),
1068 usage_panel: UsagePanel::default(),
1069 usage_load_generation: 0,
1070 cost_refreshes: HashMap::new(),
1071 next_cost_refresh_request_id: 0,
1072 conversation_scroll: ConversationScroll::default(),
1073 scrollbar_geometry: None,
1074 scrollbar_drag: None,
1075 approval_scroll: 0,
1076 approval_max_scroll: 0,
1077 approval_scroll_request: None,
1078 pending_approval: None,
1079 approval_shown: None,
1080 next_approval_request_id: 0,
1081 approval_refresh_in_flight: None,
1082 next_approval_refresh_id: 0,
1083 layout_cache: LayoutCache::default(),
1084 tui_config,
1085 theme,
1086 highlighter: Box::<SyntectHighlighter>::default(),
1087 hit_map: UiHitMap::default(),
1088 hover: None,
1089 animation_ticks: 0,
1090 transient_notices: Vec::new(),
1091 goal_notices: HashMap::new(),
1092 goal_detail: goal::GoalDetailState::default(),
1093 goal_focus: None,
1094 picker_state: ListState::default().with_selected(Some(0)),
1095 session_search: SearchPickerState::default(),
1096 agent_search: SearchPickerState::default(),
1097 model_search: SearchPickerState::default(),
1098 provider_search: SearchPickerState::default(),
1099 palette: None,
1100 model_then_variant: false,
1101 variant_step_model: None,
1102 last_escape: None,
1103 last_quit_press: None,
1104 input: InputState::default(),
1105 modal: Modal::None,
1106 stdin_target: None,
1107 status: "Connected. Press / or Ctrl-P for commands.".into(),
1108 session_errors: SessionErrorSummary::default(),
1109 should_quit: false,
1110 selection: None,
1111 pending_press: None,
1112 user_menu: None,
1113 clipboard_sink: ClipboardSink::default(),
1114 title_sequences: HashMap::new(),
1115 };
1116 app.refresh_lists().await;
1117 if create_new_session {
1118 app.new_session_draft = app.default_draft_selection();
1120 app.selected = None;
1121 } else if let Some(session_id) = Self::preferred_startup_session(&app.sessions) {
1122 app.open_session(session_id).await;
1123 }
1124 if app.draft.is_none() {
1125 app.draft = app.default_draft_selection();
1126 }
1127 if app.selectable_agents().is_empty() {
1128 app.draft = None;
1129 app.status = app.setup_status();
1130 }
1131 Ok(app)
1132 }
1133
1134 pub(super) fn preferred_startup_session(sessions: &[SessionMeta]) -> Option<SessionId> {
1143 sessions
1144 .iter()
1145 .filter(|session| matches!(session.origin, cookie_agent_protocol::SessionOrigin::Root))
1146 .max_by_key(|session| (session.last_activity, session.session_id))
1147 .or_else(|| sessions.first())
1148 .map(|session| session.session_id)
1149 }
1150
1151 async fn open_session(&mut self, session_id: SessionId) {
1152 let generation = self.begin_ownership_classification(session_id);
1153 let outcome = match self
1154 .client
1155 .resume_session(SessionResumeParams { session_id })
1156 .await
1157 {
1158 Ok(result) => SessionOwnershipOutcome::Owned(Box::new(result.session)),
1159 Err(error) if session_owned_by_another_process(&error) => {
1160 SessionOwnershipOutcome::Foreign
1161 }
1162 Err(error) => SessionOwnershipOutcome::Failed(error.to_string()),
1163 };
1164 self.apply_ownership_classification(session_id, generation, outcome);
1165 self.select_session(session_id).await;
1166 if self.deliveries.is_some() {
1167 self.drain_replay(session_id).await;
1168 }
1169 self.refresh_tree().await;
1170 }
1171
1172 fn begin_ownership_classification(&mut self, session_id: SessionId) -> u64 {
1173 self.next_ownership_classification = self.next_ownership_classification.wrapping_add(1);
1174 let generation = self.next_ownership_classification;
1175 self.ownership_classifications
1176 .insert(session_id, generation);
1177 if !self.owned_sessions.contains(&session_id) {
1178 self.read_only_sessions.insert(session_id);
1179 if self.selected == Some(session_id) {}
1180 }
1181 generation
1182 }
1183
1184 fn classify_session_background(&mut self, session_id: SessionId) {
1185 if self.owned_sessions.contains(&session_id) {
1186 self.start_pending_live_subscription(session_id);
1187 return;
1188 }
1189 if self.ownership_classifications.contains_key(&session_id) {
1190 return;
1191 }
1192 let generation = self.begin_ownership_classification(session_id);
1193 let client = self.client.clone();
1194 let updates = self.rpc_updates_tx.clone();
1195 self.spawn_rpc(async move {
1196 let outcome = match client
1197 .resume_session(SessionResumeParams { session_id })
1198 .await
1199 {
1200 Ok(result) => SessionOwnershipOutcome::Owned(Box::new(result.session)),
1201 Err(error) if session_owned_by_another_process(&error) => {
1202 SessionOwnershipOutcome::Foreign
1203 }
1204 Err(error) => SessionOwnershipOutcome::Failed(error.to_string()),
1205 };
1206 let _ = updates.send(RpcUpdate::SessionOwnershipClassified {
1207 session_id,
1208 generation,
1209 outcome,
1210 });
1211 });
1212 }
1213
1214 fn apply_ownership_classification(
1215 &mut self,
1216 session_id: SessionId,
1217 generation: u64,
1218 outcome: SessionOwnershipOutcome,
1219 ) {
1220 if self.ownership_classifications.get(&session_id) != Some(&generation) {
1221 return;
1222 }
1223 self.ownership_classifications.remove(&session_id);
1224 match outcome {
1225 SessionOwnershipOutcome::Owned(session) => {
1226 self.owned_sessions.insert(session_id);
1227 self.read_only_sessions.remove(&session_id);
1228 let session = self.merge_session_meta(*session);
1229 if let Some(existing) = self
1230 .sessions
1231 .iter_mut()
1232 .find(|existing| existing.session_id == session_id)
1233 {
1234 *existing = session;
1235 } else {
1236 self.sessions.push(session);
1237 }
1238 self.note_sessions_changed();
1239 self.pending_live_subscriptions.insert(session_id);
1240 self.replay_ended_for_live_subscription.remove(&session_id);
1241 if self.selected == Some(session_id) {
1242 self.status = "Session is writable.".into();
1243 }
1244 self.start_pending_live_subscription(session_id);
1245 }
1246 SessionOwnershipOutcome::Foreign => {
1247 self.owned_sessions.remove(&session_id);
1248 self.read_only_sessions.insert(session_id);
1249 self.pending_live_subscriptions.remove(&session_id);
1250 self.live_subscription_attempts.remove(&session_id);
1251 self.replay_ended_for_live_subscription.remove(&session_id);
1252 if self.selected == Some(session_id) {
1253 self.status =
1254 "Session is owned by another cookie process; read-only snapshot.".into();
1255 }
1256 }
1257 SessionOwnershipOutcome::Failed(error) => {
1258 self.owned_sessions.remove(&session_id);
1259 self.read_only_sessions.insert(session_id);
1260 self.pending_live_subscriptions.remove(&session_id);
1261 self.live_subscription_attempts.remove(&session_id);
1262 self.replay_ended_for_live_subscription.remove(&session_id);
1263 self.session_errors.record(&error);
1264 if self.selected == Some(session_id) {
1265 self.status = error;
1266 }
1267 }
1268 }
1269 }
1270
1271 fn start_pending_live_subscription(&mut self, session_id: SessionId) {
1272 if !self.pending_live_subscriptions.contains(&session_id)
1273 || self.live_subscription_attempts.contains_key(&session_id)
1274 {
1275 return;
1276 }
1277 self.replay_ended_for_live_subscription.remove(&session_id);
1278 self.next_live_subscription_attempt = self.next_live_subscription_attempt.wrapping_add(1);
1279 let live_attempt = self.next_live_subscription_attempt;
1280 self.live_subscription_attempts
1281 .insert(session_id, live_attempt);
1282 let cursor = self
1283 .store
1284 .sessions
1285 .get(&session_id)
1286 .map(|state| state.last_seq);
1287 self.subscribe_session_background(session_id, cursor, Some(live_attempt));
1288 }
1289
1290 fn finish_live_subscription(
1291 &mut self,
1292 session_id: SessionId,
1293 live_attempt: Option<u64>,
1294 outcome: SessionLiveSubscriptionOutcome,
1295 ) {
1296 let Some(live_attempt) = live_attempt else {
1297 if let SessionLiveSubscriptionOutcome::Failed(error) = outcome {
1298 self.session_errors.record(&error);
1299 if self.selected == Some(session_id) {
1300 self.status = error;
1301 }
1302 }
1303 return;
1304 };
1305 if self.live_subscription_attempts.get(&session_id) != Some(&live_attempt) {
1306 return;
1307 }
1308 self.live_subscription_attempts.remove(&session_id);
1309 if !self.pending_live_subscriptions.contains(&session_id) {
1310 return;
1311 }
1312 match outcome {
1313 SessionLiveSubscriptionOutcome::Established => {
1314 self.pending_live_subscriptions.remove(&session_id);
1315 self.replay_ended_for_live_subscription.remove(&session_id);
1316 }
1317 SessionLiveSubscriptionOutcome::ReplayInProgress => {
1318 if self.replay_ended_for_live_subscription.remove(&session_id) {
1319 self.start_pending_live_subscription(session_id);
1320 }
1321 }
1322 SessionLiveSubscriptionOutcome::Failed(error) => {
1323 self.session_errors.record(&error);
1324 if self.selected == Some(session_id) {
1325 self.status = error;
1326 }
1327 }
1328 }
1329 }
1330
1331 async fn create_root_session(&mut self, selection: RunSelection) -> bool {
1332 let agent = selection.agent.clone();
1333 match self
1334 .client
1335 .create_session(SessionCreateParams { selection })
1336 .await
1337 {
1338 Ok(result) => {
1339 let session_id = result.session.session_id;
1340 self.note_title_sequence(&result.session);
1341 self.sessions.push(result.session);
1342 self.note_sessions_changed();
1343 self.open_session(session_id).await;
1344 self.new_session_draft = None;
1345 self.status = format!(
1346 "New root session opened with agent {agent}. Press / or Ctrl-P for commands."
1347 );
1348 true
1349 }
1350 Err(error) => {
1351 self.status = error.to_string();
1354 false
1355 }
1356 }
1357 }
1358
1359 pub(super) fn take_deliveries(
1360 &mut self,
1361 ) -> tokio::sync::mpsc::UnboundedReceiver<ClientDelivery> {
1362 self.deliveries
1363 .take()
1364 .expect("app delivery receiver already attached")
1365 }
1366
1367 pub(super) fn spawn_rpc<F>(&self, task: F)
1368 where
1369 F: std::future::Future<Output = ()> + Send + 'static,
1370 {
1371 tokio::spawn(task);
1372 }
1373}
1374
1375fn latest_resolved_model_key(state: &crate::state::SessionState) -> Option<&ModelKey> {
1376 state.transcript.iter().rev().find_map(|item| {
1377 let TranscriptItem::Assistant {
1378 attribution,
1379 children,
1380 ..
1381 } = item
1382 else {
1383 return None;
1384 };
1385 children
1386 .iter()
1387 .rev()
1388 .find_map(|child| match child {
1389 crate::state::AssistantChild::Attribution { resolved_model } => {
1390 Some(&resolved_model.selection.model)
1391 }
1392 _ => None,
1393 })
1394 .or(Some(&attribution.resolved_model.selection.model))
1395 })
1396}
1397
1398fn shorten_home(cwd: &str) -> String {
1399 let Ok(home) = cookie_agent_protocol::paths::home_dir() else {
1400 return cwd.to_owned();
1401 };
1402 let home = home.to_string_lossy();
1403 if cwd == home {
1404 "~".into()
1405 } else if let Some(suffix) = cwd
1406 .strip_prefix(home.as_ref())
1407 .filter(|suffix| suffix.starts_with('/'))
1408 {
1409 format!("~{suffix}")
1410 } else {
1411 cwd.to_owned()
1412 }
1413}
1414
1415pub(super) fn format_token_count(tokens: u64) -> String {
1416 if tokens < 1_000 {
1417 tokens.to_string()
1418 } else {
1419 format!("{:.1}K", tokens as f64 / 1_000.0)
1420 }
1421}
1422
1423pub(super) fn format_cost_usd(cost: f64) -> String {
1424 if cost >= 0.01 {
1425 format!("${cost:.2}")
1426 } else {
1427 format!("${cost:.4}")
1428 }
1429}
1430
1431const fn permission_mode_label(mode: PermissionMode) -> &'static str {
1432 match mode {
1433 PermissionMode::AutoApprove => "auto-approve",
1434 PermissionMode::AutoApproveN => "auto-n",
1435 PermissionMode::AutoApproveY => "auto-y",
1436 PermissionMode::Ask => "ask",
1437 PermissionMode::Yolo => "yolo",
1438 }
1439}
1440
1441pub(super) fn truncate_with_ellipsis(value: &str, width: usize) -> String {
1442 if UnicodeWidthStr::width(value) <= width {
1443 return value.to_owned();
1444 }
1445 if width == 0 {
1446 return String::new();
1447 }
1448 let ellipsis = "…";
1449 let ellipsis_width = UnicodeWidthStr::width(ellipsis);
1450 if width <= ellipsis_width {
1451 return ellipsis.into();
1452 }
1453 let mut truncated = String::new();
1454 let content_width = width - ellipsis_width;
1455 for grapheme in value.graphemes(true) {
1456 if UnicodeWidthStr::width(truncated.as_str()) + UnicodeWidthStr::width(grapheme)
1457 > content_width
1458 {
1459 break;
1460 }
1461 truncated.push_str(grapheme);
1462 }
1463 truncated.push_str(ellipsis);
1464 truncated
1465}
1466
1467impl Drop for App {
1468 fn drop(&mut self) {
1469 self.clear_connect_secrets();
1470 self.abort_connect_work();
1471 }
1472}
1473
1474pub async fn run_with_client(client: Client) -> anyhow::Result<()> {
1476 run_terminal(client, false).await
1477}
1478
1479pub async fn run_with_new_session(client: Client) -> anyhow::Result<()> {
1481 run_terminal(client, true).await
1482}
1483
1484async fn run_terminal(client: Client, create_new_session: bool) -> anyhow::Result<()> {
1485 install_terminal_panic_hook();
1486 let tui_config = crate::config::load(None).context("load TUI configuration")?;
1487 let detection = crate::terminal_detect::detect_startup_theme(tui_config.theme);
1488 let theme = Theme::with_kind_from_env(detection.kind);
1489 tracing::info!(
1490 theme = ?theme.key().kind,
1491 color_level = ?theme.key().colors,
1492 detection_source = %detection.source,
1493 "TUI theme selected"
1494 );
1495 let mut app = App::new_with_config(client, create_new_session, tui_config, theme)
1496 .await
1497 .context("initialize TUI")?;
1498 let mut restore = TerminalRestore;
1499 enable_raw_mode().context("enable terminal raw mode")?;
1500 restore.raw_mode_enabled();
1501 let mut stdout = io::stdout();
1502 execute!(stdout, EnterAlternateScreen).context("enter alternate screen")?;
1503 restore.alternate_screen_entered();
1504 execute!(stdout, EnableMouseCapture).context("enable mouse capture")?;
1505 restore.mouse_capture_enabled();
1506 execute!(stdout, EnableBracketedPaste).context("enable bracketed paste")?;
1507 restore.bracketed_paste_enabled();
1508 execute!(
1509 stdout,
1510 PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
1511 )
1512 .context("enable keyboard enhancement")?;
1513 restore.keyboard_enhancement_enabled();
1514 let backend = CrosstermBackend::new(stdout);
1515 let mut terminal = Terminal::new(backend).context("create terminal")?;
1516 let deliveries = app.take_deliveries();
1517 let (result, session_errors) = event_loop(&mut terminal, app, deliveries).await;
1518 drop(terminal);
1519 drop(restore);
1520 for message in post_teardown_messages(&result, &session_errors) {
1521 eprintln!("{message}");
1522 }
1523 result
1524}
1525
1526fn post_teardown_messages(
1527 result: &anyhow::Result<()>,
1528 session_errors: &SessionErrorSummary,
1529) -> Vec<String> {
1530 let mut messages = Vec::new();
1531 if let Err(error) = result {
1532 messages.push(format!("cookie-agent: {error:#}"));
1533 }
1534 if let Some(summary) = session_errors.format() {
1535 messages.push(summary);
1536 }
1537 messages
1538}
1539
1540async fn event_loop(
1541 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1542 mut app: App,
1543 mut deliveries: tokio::sync::mpsc::UnboundedReceiver<ClientDelivery>,
1544) -> (anyhow::Result<()>, SessionErrorSummary) {
1545 let mut events = EventStream::new();
1546 let mut replay_watchdog = tokio::time::interval(std::time::Duration::from_millis(250));
1547 let mut mcp_poll = tokio::time::interval(std::time::Duration::from_secs(1));
1548 let mut frame_tick = tokio::time::interval(RenderScheduler::FRAME_INTERVAL);
1549 let mut render = RenderScheduler::default();
1550 loop {
1551 if render.should_draw(Instant::now()) {
1552 if let Err(error) = terminal
1553 .draw(|frame| app.draw(frame))
1554 .context("draw terminal")
1555 {
1556 return (Err(error), app.session_errors.clone());
1557 }
1558 render.drew(Instant::now());
1559 }
1560 if app.should_quit {
1561 return (Ok(()), app.session_errors.clone());
1562 }
1563 tokio::select! {
1564 Some(event) = events.next() => match event {
1565 Ok(CrosstermEvent::Key(key)) => {
1566 app.handle_key(key).await;
1567 render.mark_immediate();
1568 }
1569 Ok(CrosstermEvent::Mouse(mouse)) => {
1570 if app.handle_mouse(mouse).await {
1571 render.mark_immediate();
1572 }
1573 }
1574 Ok(CrosstermEvent::Paste(text)) => {
1575 let text = Zeroizing::new(text);
1576 app.handle_paste(&text);
1577 render.mark_immediate();
1578 }
1579 Ok(CrosstermEvent::Resize(_, _)) => {
1580 if let Err(error) = handle_terminal_resize(terminal, &mut render)
1581 .context("resize terminal")
1582 {
1583 return (Err(error), app.session_errors.clone());
1584 }
1585 }
1586 Ok(_) => {},
1587 Err(error) => {
1588 app.status = error.to_string();
1589 render.mark_immediate();
1590 }
1591 },
1592 delivery = deliveries.recv() => match delivery {
1593 Some(delivery) => {
1594 app.handle_delivery(delivery).await;
1595 render.mark_stream();
1596 }
1597 None => {
1598 for session_id in app.store.abandon_replays() {
1599 app.client.recover_session(session_id, true);
1600 }
1601 app.clear_connect_secrets();
1602 app.abort_connect_work();
1603 app.status = "daemon disconnected".into();
1604 app.session_errors.record(&app.status);
1605 return (Ok(()), app.session_errors.clone());
1606 }
1607 },
1608 Some(update) = app.rpc_updates_rx.recv() => {
1609 app.handle_rpc_update(update);
1610 render.mark_immediate();
1611 },
1612 _ = replay_watchdog.tick() => {
1613 app.recover_timed_out_replays();
1614 render.mark_stream();
1615 },
1616 _ = mcp_poll.tick() => {
1617 app.poll_mcp();
1618 },
1619 _ = frame_tick.tick() => {
1620 if app.animation_active() {
1623 app.animation_tick();
1624 render.mark_stream();
1625 }
1626 },
1627 }
1628 }
1629}
1630
1631pub(super) fn handle_terminal_resize<B: Backend>(
1632 terminal: &mut Terminal<B>,
1633 render: &mut RenderScheduler,
1634) -> Result<(), B::Error> {
1635 terminal.autoresize()?;
1636 render.mark_immediate();
1637 Ok(())
1638}
1639
1640fn contains(rect: Rect, column: u16, row: u16) -> bool {
1641 rect.contains(Position::new(column, row))
1642}
1643
1644fn agent_cycle_backward(key: KeyEvent) -> Option<bool> {
1645 match (key.code, key.modifiers) {
1646 (KeyCode::Tab, KeyModifiers::NONE) => Some(false),
1647 (KeyCode::Tab | KeyCode::BackTab, KeyModifiers::SHIFT) | (KeyCode::BackTab, _) => {
1648 Some(true)
1649 }
1650 _ => None,
1651 }
1652}
1653
1654fn inner_rect(area: Rect) -> Rect {
1655 Rect::new(
1656 area.x.saturating_add(1),
1657 area.y.saturating_add(1),
1658 area.width.saturating_sub(2),
1659 area.height.saturating_sub(2),
1660 )
1661}
1662
1663fn render_connect_button(frame: &mut ratatui::Frame, area: Rect, label: &str, style: Style) {
1667 if area.width == 0 || area.height == 0 {
1668 return;
1669 }
1670 if area.height > 1 {
1671 frame.render_widget(crate::ui::panel_block().border_style(style), area);
1672 let label_area = Rect::new(
1673 area.x.saturating_add(1),
1674 area.y.saturating_add(1),
1675 area.width.saturating_sub(2),
1676 1,
1677 );
1678 frame.render_widget(
1679 Paragraph::new(Span::styled(label.to_owned(), style)),
1680 label_area,
1681 );
1682 } else {
1683 frame.render_widget(Paragraph::new(Span::styled(label.to_owned(), style)), area);
1684 }
1685}
1686
1687pub(super) fn paint_panel(frame: &mut ratatui::Frame, area: Rect, theme: &Theme) {
1692 let clip = area.intersection(frame.area());
1693 let buffer = frame.buffer_mut();
1694 for y in clip.top()..clip.bottom() {
1695 for x in clip.left()..clip.right() {
1696 let cell = &mut buffer[(x, y)];
1697 cell.reset();
1698 cell.set_style(theme.panel());
1699 }
1700 }
1701}
1702
1703fn centered(area: Rect, width: u16, height: u16) -> Rect {
1704 let vertical = Layout::default()
1705 .direction(Direction::Vertical)
1706 .constraints([
1707 Constraint::Percentage((100 - height) / 2),
1708 Constraint::Percentage(height),
1709 Constraint::Percentage((100 - height) / 2),
1710 ])
1711 .split(area);
1712 Layout::default()
1713 .direction(Direction::Horizontal)
1714 .constraints([
1715 Constraint::Percentage((100 - width) / 2),
1716 Constraint::Percentage(width),
1717 Constraint::Percentage((100 - width) / 2),
1718 ])
1719 .split(vertical[1])[1]
1720}
1721
1722fn client_run_id() -> ClientRunId {
1723 let ticks = SystemTime::now()
1724 .duration_since(UNIX_EPOCH)
1725 .unwrap_or_default()
1726 .as_nanos();
1727 ClientRunId::new(format!("tui-{ticks}")).expect("bounded client run id")
1728}
1729
1730pub(super) fn osc52_sequence(text: &str) -> String {
1734 format!("\x1b]52;c;{}\x07", STANDARD.encode(text.as_bytes()))
1735}
1736
1737pub(super) fn queue_age_label(age_secs: i64) -> String {
1740 if age_secs < 60 {
1741 "<1m".to_owned()
1742 } else if age_secs < 3600 {
1743 format!("{}m", age_secs / 60)
1744 } else {
1745 format!("{}h", age_secs / 3600)
1746 }
1747}
1748
1749pub(super) fn ellipsize_single_line(text: &str, width: usize) -> String {
1752 let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
1753 if UnicodeWidthStr::width(flat.as_str()) <= width {
1754 return flat;
1755 }
1756 let mut out = String::new();
1757 let mut used = 0;
1758 for grapheme in flat.graphemes(true) {
1759 let cell = UnicodeWidthStr::width(grapheme);
1760 if used + cell + 1 > width {
1762 break;
1763 }
1764 used += cell;
1765 out.push_str(grapheme);
1766 }
1767 out.push('…');
1768 out
1769}
1770
1771fn client_response_id() -> ClientResponseId {
1772 ClientResponseId::new(Uuid::now_v7().to_string()).expect("uuid-derived client response id")
1773}
1774
1775#[cfg(test)]
1776mod post_teardown_tests {
1777 use super::*;
1778
1779 #[test]
1780 fn clean_exit_without_session_errors_prints_nothing() {
1781 let messages = post_teardown_messages(&Ok(()), &SessionErrorSummary::default());
1782 assert!(messages.is_empty());
1783 }
1784
1785 #[test]
1786 fn session_error_summary_keeps_only_the_last_twenty_lines() {
1787 let mut summary = SessionErrorSummary::default();
1788 for index in 0..25 {
1789 summary.record(&format!("error {index}"));
1790 }
1791
1792 let output = summary.format().expect("summary");
1793 assert!(output.starts_with("cookie-agent: session ended with 25 error(s):"));
1794 assert!(!output.contains("error 4\n"));
1795 assert!(output.contains("error 5\n"));
1796 assert!(output.ends_with("error 24"));
1797 }
1798
1799 #[test]
1800 fn terminal_error_and_session_summary_are_both_reported() {
1801 let mut summary = SessionErrorSummary::default();
1802 summary.record("daemon disconnected");
1803 let result = Err(anyhow::anyhow!("server task failed").context("event loop failed"));
1804
1805 let messages = post_teardown_messages(&result, &summary);
1806
1807 assert_eq!(messages.len(), 2);
1808 assert_eq!(
1809 messages[0],
1810 "cookie-agent: event loop failed: server task failed"
1811 );
1812 assert!(messages[1].contains("daemon disconnected"));
1813 }
1814}