Skip to main content

cookie_agent_tui/ui/app/
mod.rs

1//! Application state, event handling, and terminal loop.
2
3mod 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    /// The variant step of `/model`, for the model in
121    /// `App::variant_step_model`.
122    Variants,
123    ConnectProviders,
124    ConnectDetails,
125    ConnectSetup,
126    ConnectError,
127    DisconnectConfirm,
128    /// The copy/revert/fork menu for one clicked user message row.
129    UserMessage,
130    /// Confirm guard behind the menu's revert action.
131    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
152/// Whether the daemon reported retryable store contention (`lock_contention`),
153/// covering both the bounded-lock budget and a lost commit-time CAS.
154pub(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
167/// Provider connect/disconnect resubmit policy: the first contention-class
168/// failure gets exactly one automatic resubmission of the identical request
169/// (same client request id, so the daemon's replay path answers a commit whose
170/// response was lost); a second consecutive contention surfaces to the user and
171/// the pending payload stays held.
172pub(super) fn retry_store_contention_once(attempt: usize, error: &ClientError) -> bool {
173    attempt == 0 && is_store_contention(error)
174}
175
176/// Renders the spec's store-contention copy when the daemon reports
177/// `lock_contention`; `store` is the human noun for the error site.
178pub(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/// Where copied text goes: the terminal's OSC 52 clipboard escape in
187/// production, a shared capture buffer in tests.
188#[derive(Default)]
189pub(super) enum ClipboardSink {
190    #[default]
191    Osc52,
192    #[cfg(test)]
193    Capture(Arc<std::sync::Mutex<Vec<String>>>),
194}
195
196/// The user-message action the menu/confirm guard acts on. The message's
197/// text rides along so copy and the revert's composer restoration never
198/// re-derive it from a transcript that may have rebuilt underneath.
199#[derive(Clone, Debug)]
200pub(super) struct UserMenuState {
201    pub(super) session_id: SessionId,
202    /// Physical sequence of the message's `UserInputSubmitted` event.
203    pub(super) seq: u64,
204    pub(super) text: String,
205}
206
207/// Mouse text selection, stored in content coordinates so it survives
208/// scrolling: the conversation leg addresses `(logical line, display
209/// column)` inside the rendered lines, the composer leg addresses draft
210/// buffer bytes. Extraction maps these back to real text — wrapped rows,
211/// raw code without band chrome, user rows, draft text.
212#[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    /// Normalized endpoints with the start before the end in reading order.
226    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    /// Normalized composer byte range `(start, end)`.
240    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/// A left-button press inside a selectable pane, held until motion decides
251/// between a click (dispatched on release) and a selection drag.
252#[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/// Which pane a pending press can start a selection in.
260#[derive(Clone, Copy, Debug, Eq, PartialEq)]
261pub(super) enum PressTarget {
262    Conversation,
263    Composer,
264}
265
266/// Cell movement beyond this turns a pending press into a selection drag;
267/// at or below it the release still counts as a plain click.
268const 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/// A clickable agent/model/variant segment inside the Message title.
310#[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/// Only user rows recall the newest user input; producer rows are read-only.
331#[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/// The visible rows of one past user message: a click opens the
339/// copy/revert/fork menu targeting the message's physical event sequence.
340#[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/// A captured scrollbar thumb drag. The row offset where the press grabbed
354/// the thumb is kept so dragging stays anchored even outside the track.
355#[derive(Clone, Copy, Debug)]
356pub(super) struct ScrollbarDrag {
357    pub(super) grab_row: u16,
358    pub(super) target: ScrollbarTarget,
359}
360
361/// Which pane's scrollbar a captured drag drives.
362#[derive(Clone, Copy, Debug, Eq, PartialEq)]
363pub(super) enum ScrollbarTarget {
364    Conversation,
365    Input,
366}
367
368/// The interactive element currently under the pointer. Hover is resolved
369/// from the same per-frame hit map that click handling consults, in the same
370/// priority order, and only ever changes styling — never selection state.
371/// Only elements with a real click action are hover targets at all: passive
372/// surfaces (the composer and scrollbar) stay quiet even
373/// though clicks on them still work.
374#[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/// Per-frame hit targets built from the same geometry and transcript layout
400/// that were rendered. Mouse events consult this cached map for hit-testing
401/// within a surface; overlay *ownership* is read from current state (modal,
402/// palette, approval), so a panel that opened since the last frame still
403/// claims its pointer events instead of leaking them to the content beneath.
404#[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    /// One clickable row per rendered queue-strip line (entries and the
421    /// overflow fold alike): any click recalls the newest pending input.
422    pub(super) queue_entries: Vec<QueueEntryHit>,
423    /// Visible user-message rows that open the copy/revert/fork menu.
424    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/// UI state separated from the client and durable protocol projection.
480#[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    /// Client-local preset used only when creating a new root session.
514    pub(super) selected_preset: Option<String>,
515    /// Draft owned by the `/new` flow, independent of the viewed session draft.
516    pub(super) new_session_draft: Option<RunSelection>,
517    /// Revision of the current agent descriptor snapshot; refreshed
518    /// coherently with the model revision.
519    pub(super) agent_revision: Option<cookie_agent_protocol::AgentRevision>,
520    pub(super) models: Vec<AvailableModelDescriptor>,
521    /// Revision of the current model descriptor snapshot.
522    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    /// Client-local draft selection; never alters an active run.
529    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    /// Session the conversation currently shows. Independent of the tree root.
540    pub(super) selected: Option<SessionId>,
541    /// Stable delegation-tree root; every tree refresh queries this session.
542    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    /// Runtime permission modes keyed by delegation-tree root.
562    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    /// The approval request that most recently became the topmost panel,
578    /// and when. Hotkeys stay inert for [`APPROVAL_HOTKEY_GRACE`] after it
579    /// appears so keystrokes aimed at the composer cannot answer it.
580    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    /// Interactive element under the pointer as of the last mouse move;
590    /// resolved against the hit map at render time and purely visual.
591    pub(super) hover: Option<HoverTarget>,
592    /// Monotonic frame counter driving the streaming "thinking…" ellipsis;
593    /// advanced by the frame tick only while animation is active.
594    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    /// The open command palette; `None` while it is closed. It never
605    /// shares text with the composer.
606    pub(super) palette: Option<CommandPalette>,
607    /// Set while the model picker was opened by `/model`: choosing a model
608    /// with named variants continues to the variant step.
609    pub(super) model_then_variant: bool,
610    /// The model chosen in `/model`, applied only once its variant is picked.
611    pub(super) variant_step_model: Option<ModelKey>,
612    pub(super) last_escape: Option<Instant>,
613    /// When Ctrl-C last found nothing to interrupt; a second press inside
614    /// the quit window exits.
615    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    /// Active mouse text selection (conversation or composer); cleared by
623    /// Esc, a plain click anywhere, or a copy.
624    pub(super) selection: Option<TextSelection>,
625    /// A left-button press awaiting the click-or-drag decision.
626    pub(super) pending_press: Option<PendingPress>,
627    /// State of the user-message copy/revert/fork menu and its confirm.
628    pub(super) user_menu: Option<UserMenuState>,
629    /// Clipboard destination for copy/cut (OSC 52 in production).
630    pub(super) clipboard_sink: ClipboardSink,
631    /// Latest authoritative title sequence per session: patches apply only a
632    /// strictly newer sequence, so stale tree/list responses cannot
633    /// overwrite a newer title event.
634    pub(super) title_sequences: HashMap<SessionId, u64>,
635}
636
637/// Maximum queue entries shown by the strip between the conversation pane
638/// and the composer; a "+N more" row folds the remainder into that budget.
639const 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
674/// The user-message action menu rows, in display/keyboard order:
675/// copy, revert (confirm-guarded), fork.
676const 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    /// A steer RPC failed at the transport level (admission itself never
711    /// rejects anymore): the submitted text is owed back to the composer.
712    SteerFailed {
713        session_id: SessionId,
714        input: String,
715        error: String,
716    },
717    /// A recall RPC returned the withdrawn pending input's text.
718    SteerRecalled {
719        session_id: SessionId,
720        text: String,
721    },
722    /// A revert RPC committed; the `SessionReverted` event rebuilds the
723    /// transcript, and the message text is owed back to the composer.
724    Reverted {
725        session_id: SessionId,
726        text: String,
727    },
728    /// A fork RPC committed; the new session becomes the viewed one.
729    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
824/// An approval response captured at click time and currently in flight.
825/// How long approval hotkeys ignore keys after a request appears on top.
826pub(super) const APPROVAL_HOTKEY_GRACE: Duration = Duration::from_millis(400);
827
828/// One approval request revision and the moment it became the topmost
829/// panel.
830#[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/// The modal was dismissed optimistically; this marker blocks duplicate
838/// actions until the RPC resolves.
839#[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    /// Row labels of the palette's current list step (commands or skills).
945    #[cfg(test)]
946    pub(crate) fn palette_labels_for_test(&self) -> Vec<String> {
947        self.palette_list_labels()
948    }
949
950    /// Backdate the current approval's appearance past the hotkey grace, as
951    /// if it had been on screen for a while.
952    #[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        // Subscribe before issuing events.subscribe so its replay and a live
1006        // tail racing App construction share the same retained receiver.
1007        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            // Keep a fresh root entirely client-side until the first prompt.
1119            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    /// The session reopened on startup: the root session with the most recent
1135    /// activity. The listing is root-only, so the delegation tree — never a
1136    /// delegated child — is what the user resumes; the first entry and the
1137    /// create-new path remain as fallbacks.
1138    ///
1139    /// `session_id` breaks `last_activity` ties deterministically (the listing
1140    /// arrives in HashMap order), choosing the highest session ID among roots
1141    /// that share the newest activity timestamp.
1142    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                // Preserve the draft so the user can retry after a transient
1352                // create failure.
1353                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
1474/// Run the terminal UI against a connected client.
1475pub async fn run_with_client(client: Client) -> anyhow::Result<()> {
1476    run_terminal(client, false).await
1477}
1478
1479/// Run the terminal UI with a newly created root session.
1480pub 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                // The frame cadence drives only the streaming "thinking…"
1621                // ellipsis; everything else redraws on events.
1622                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
1663/// One compact connect-form action button: a border-colored frame sized to
1664/// its label so it reads as a button, not a panel-wide strip. The label sits
1665/// on the middle row (or the only row when the panel is vertically cramped).
1666fn 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
1687/// Paint an overlay panel: reset every cell like `Clear` (a styled `Block`
1688/// only re-styles cells, so underlying glyphs would ghost through), then
1689/// fill with the theme surface instead of punching a terminal-default hole
1690/// in the light theme.
1691pub(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
1730/// The OSC 52 clipboard escape for `text`: `ESC ] 52 ; c ; <base64> BEL`.
1731/// The `c` target is the system clipboard selection in every terminal that
1732/// implements the sequence.
1733pub(super) fn osc52_sequence(text: &str) -> String {
1734    format!("\x1b]52;c;{}\x07", STANDARD.encode(text.as_bytes()))
1735}
1736
1737/// Coarse age label for the strip title: precise enough to show a stuck
1738/// queue, coarse enough to avoid false precision (and flicker).
1739pub(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
1749/// Collapse a queued message to one display line of at most `width` cells:
1750/// newlines flatten to spaces and overlong text ends in an ellipsis.
1751pub(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        // The ellipsis itself needs one cell.
1761        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}