Skip to main content

atomcode_tuix/
state.rs

1// crates/atomcode-tuix/src/state.rs
2
3/// Plan vs Build execution mode. Plan is read-only exploration (no file
4/// writes, no shell commands); Build is full execution with all tools.
5/// Toggled by the Tab key (when the input buffer is empty) or the
6/// `/plan` and `/build` slash commands.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum AgentMode {
9    #[default]
10    Build,
11    Plan,
12}
13
14impl AgentMode {
15    /// Human-readable label for status bar display.
16    pub fn label(self) -> &'static str {
17        match self {
18            Self::Build => "Build",
19            Self::Plan => "Plan",
20        }
21    }
22
23    /// Return the opposite mode.
24    pub fn toggle(self) -> Self {
25        match self {
26            Self::Build => Self::Plan,
27            Self::Plan => Self::Build,
28        }
29    }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum UiPhase {
34    Idle,
35    Streaming,
36    Approval,
37    Suspended,
38}
39
40/// Rotating pool of "thinking" labels — CC-style playful verbs.
41/// Advances once per turn so consecutive turns vary.
42pub const THINKING_LABELS: &[&str] = &[
43    "Pondering",
44    "Noodling",
45    "Percolating",
46    "Brewing",
47    "Cogitating",
48    "Churning",
49    "Hatching",
50    "Marinating",
51    "Simmering",
52    "Tinkering",
53    "Mulling",
54    "Musing",
55    "Ruminating",
56    "Puttering",
57    "Fermenting",
58    "Divining",
59    "Concocting",
60    "Germinating",
61    "Whittling",
62    "Scheming",
63];
64
65/// Rotating pool of turn-completion phrases — CC-vibe playful verbs.
66pub const DONE_LABELS: &[&str] = &[
67    "Done",
68    "Nailed it",
69    "Wrapped",
70    "Shipped",
71    "Baked",
72    "Plated",
73    "Served",
74    "Bagged",
75    "Handled",
76    "Dialed in",
77    "Locked in",
78    "Sealed",
79    "Stuck the landing",
80    "Buttoned up",
81    "Squared away",
82    "Cooked",
83    "Dusted",
84    "Called it",
85    "Delivered",
86    "Tied off",
87];
88
89/// Snapshot of the agent's context budget, cached from `AgentEvent::ContextStats`
90/// and surfaced by the `/context` command.
91///
92/// Merged across two emission paths: the narrow TurnEvent-forwarded one
93/// (system/sent/total_messages) and the rich one from `handle_send_message`
94/// (tool_defs / cold_zone / ctx_window / ctx_name). Each path leaves the
95/// fields it doesn't know at 0 / empty, so we merge by keeping non-zero
96/// updates. See `UiState::on_context_stats`.
97#[derive(Debug, Clone, Default)]
98pub struct ContextSnapshot {
99    pub system_tokens: usize,
100    pub sent_tokens: usize,
101    pub tool_defs_tokens: usize,
102    pub cold_zone_tokens: usize,
103    pub total_messages: usize,
104    pub ctx_window: usize,
105    pub ctx_name: String,
106    /// Full assembled system prompt from the most recent turn.
107    /// Surfaced by `/context prompt`. Empty until the first rich
108    /// emission lands.
109    pub system_prompt: String,
110}
111
112/// One entry in `message_queue`. Replaces the prior `String`-only
113/// representation so queued messages can carry their pasted images +
114/// markers — otherwise queueing a message during streaming silently
115/// drops attachments.
116#[derive(Debug, Clone)]
117pub struct QueuedMessage {
118    pub text: String,
119    pub images: Vec<atomcode_core::conversation::message::ImagePart>,
120    pub image_markers: Vec<usize>,
121}
122
123pub struct UiState {
124    pub phase: UiPhase,
125    pub agent_mode: AgentMode,
126    pub spinner_label: String,
127    pub spinner_frame: usize,
128    /// Mirrors `TerminalCaps::unicode_symbols` — frozen at construction.
129    /// When false, `tick_spinner` and the spinner-label ellipsis fall
130    /// back to ASCII so terminals whose font lacks `◐` / `…` (notably
131    /// Windows legacy conhost) don't show `□` tofu.
132    pub unicode_symbols: bool,
133    pub total_tokens: usize,
134    pub prompt_tokens: usize,
135    pub completion_tokens: usize,
136    pub cached_tokens: usize,
137    /// When Suspended, holds the phase to restore on resume.
138    pub prior_phase: Option<UiPhase>,
139    /// While waiting on a tool approval, holds the `"Running {Tool}"`
140    /// label that was active before the prompt opened. `on_approval_needed`
141    /// stashes it here and swaps `spinner_label` to "Waiting approval";
142    /// `on_approval_resolved` restores it. Without this, the spinner kept
143    /// saying "Running Bash… · 273s" while the agent was actually blocked
144    /// on `permission.decide().await` — looked identical to a real tool
145    /// hang.
146    pub prior_spinner_label: Option<String>,
147    /// Round-robin index into THINKING_LABELS; bumped on each on_submit.
148    pub thinking_idx: usize,
149    /// When the current turn started. Set by on_submit, cleared on
150    /// turn-complete / turn-cancelled / error. Used to surface the
151    /// total wall-clock duration in the TurnComplete event payload.
152    pub turn_started_at: Option<std::time::Instant>,
153    /// When the current phase began. Reset on every phase transition
154    /// (on_submit, on_thinking, on_tool_call_streaming,
155    /// on_tool_call_started) so the spinner shows time spent on the
156    /// CURRENT operation — `Pondering… 12s`, `Running ReadFile… 4s`
157    /// — instead of accumulating over the whole turn. Cleared on
158    /// turn-complete / turn-cancelled / error so the idle spinner
159    /// (rare) doesn't tick a stale duration.
160    pub phase_started_at: Option<std::time::Instant>,
161    /// Last observed context breakdown. Populated from
162    /// `AgentEvent::ContextStats` — `/context` renders this. `None`
163    /// before the first turn completes.
164    pub last_context: Option<ContextSnapshot>,
165    /// Verbatim text of the message that is currently running. Set
166    /// on every submit, cleared on turn-complete. When the user hits
167    /// Ctrl+C / Esc mid-stream the streaming-key handler takes this
168    /// and restores it to the input buffer so the cancelled message
169    /// can be edited + resent without re-typing. `None` between
170    /// turns and after any successful completion.
171    pub last_submitted_message: Option<String>,
172    /// `/context` dispatched a `RefreshContextStats` command and is
173    /// waiting for the resulting rich ContextStats event to render the
174    /// report. `Some(show_prompt)` until the next rich emission lands;
175    /// cleared after the render fires. Prevents stale-cache renders
176    /// without forcing /context to block synchronously on the agent
177    /// loop. The bool is the `prompt` sub-arg (include full system
178    /// prompt body).
179    pub pending_context_render: Option<bool>,
180    /// Images pasted from clipboard (Ctrl+V) waiting to be sent with
181    /// the next user message. Drained on submit.
182    pub pending_images: Vec<atomcode_core::conversation::message::ImagePart>,
183    /// Parallel to `pending_images` — content fingerprint of each pasted
184    /// image's raw RGBA bytes. Used by the right-aligned status hint to
185    /// suppress `Image in clipboard · ctrl+v to paste` once the clipboard
186    /// content matches an already-attached image (avoids dup paste prompts),
187    /// while still surfacing the hint when the user copies a new image
188    /// after pasting an earlier one. Cleared together with `pending_images`
189    /// on submit.
190    pub pending_image_hashes: Vec<u64>,
191    /// Parallel to `pending_images` — the marker number `N` originally
192    /// printed for each image at paste time. Submit-time matching does
193    /// `line.contains("[Image #N]")` against this number to decide
194    /// whether the image survived editing. Must NOT be `i + 1` from the
195    /// vec position — once `session_image_count` became monotonic across
196    /// turns, paste-time numbers diverge from positional indices, and
197    /// using the index dropped images on every retry that wasn't the
198    /// first paste of the session.
199    pub pending_image_markers: Vec<usize>,
200    /// Image attachments to re-attach when the user submits a recalled
201    /// history entry. Populated by the up-arrow handler from the
202    /// recalled `HistoryEntry::images`; drained on submit by the
203    /// hydrate prelude in `event_loop/mod.rs`. Lazy by design — disk
204    /// reads happen at submit time, not on every navigation.
205    pub pending_recalled_attachments: Vec<crate::input::history::HistoryImageRef>,
206    /// Monotonic counter for the `[Image #N]` marker shown in the input
207    /// buffer + scrollback. Incremented on every paste and NEVER reset
208    /// across turns — so two images pasted in different turns get
209    /// distinct labels (e.g. `[Image #1]` in turn 1, `[Image #2]` in
210    /// turn 2). Without this, both turns' first paste would both render
211    /// as `[Image #1]`, making it ambiguous which image a later
212    /// reference points at when scrolling back.
213    pub session_image_count: usize,
214    /// Whether to show real-time tool output (e.g., bash stdout/stderr).
215    /// Toggled by Ctrl+O. When false (default), tool output is hidden
216    /// during execution and only shown in the final result.
217    pub show_tool_output: bool,
218    /// Whether to show LLM reasoning/thinking content (e.g., DeepSeek-R1,
219    /// MiniMax-M2.7). Toggled by Ctrl+O together with `show_tool_output`.
220    /// When false (default), reasoning content is hidden during streaming.
221    pub show_reasoning: bool,
222    /// Number of fork sub-agents currently dispatched. While > 0, the
223    /// foreground turn is blocked awaiting `pool.execute_all` — there's
224    /// no fresh tool / think event to update the spinner, so without an
225    /// override the label stays frozen on the last tool name (e.g.
226    /// "Running ReadFile… 82s") for the entire pool duration. Cleared
227    /// on `SubAgentDispatchEnd`.
228    pub sub_agent_total: usize,
229    /// How many sub-agents in the current dispatch have reported a
230    /// terminal status (done / failed / timeout). Updated by
231    /// `on_sub_agent_settled`. Reset to 0 on each new dispatch.
232    pub sub_agent_done: usize,
233    /// Per-task descriptors (path + dedup suffix) for the active
234    /// dispatch. Indexed identically to the `tasks` field on
235    /// `AgentEvent::SubAgentDispatchStart` so the UI can look up a
236    /// child's display path from the `index` field on Started/Done/
237    /// Failed events. Cleared on `on_sub_agent_dispatch_end`.
238    pub sub_agent_tasks: Vec<atomcode_core::agent::SubAgentTaskInfo>,
239    /// Number of failed sub-agents in the current dispatch — tracked
240    /// separately from `sub_agent_done` so the aggregate summary can
241    /// distinguish "6/7 ok · 1 fail" from "7/7 ok". Reset on each new
242    /// dispatch.
243    pub sub_agent_failed: usize,
244    /// Wall-clock start of the current dispatch. Used to render the
245    /// elapsed-time figure on the `SubAgentDispatchEnd` aggregate
246    /// summary line. Cleared with the rest of the dispatch state.
247    pub sub_agent_started_at: Option<std::time::Instant>,
248
249    /// Active tool batches, keyed by `batch_id`. Populated on
250    /// `ToolBatchStarted` and cleared on `ToolBatchCompleted`. Used by
251    /// per-call event handlers to detect "this ToolCallStarted/Result
252    /// belongs to an active batch" and skip the standalone row render
253    /// (the batch header already represents it).
254    pub active_tool_batches: std::collections::HashMap<String, ActiveToolBatch>,
255    /// Reverse map call_id → batch_id for O(1) lookup when a per-call
256    /// event arrives. Mirrors `active_tool_batches` membership; cleared
257    /// together.
258    pub call_id_to_batch: std::collections::HashMap<String, String>,
259}
260
261/// Per-batch state for an active `ToolBatchStarted`. Tracks how many
262/// children have completed so the UI can emit the final `· N/M ok`
263/// summary on `ToolBatchCompleted`.
264#[derive(Debug, Clone)]
265pub struct ActiveToolBatch {
266    pub call_ids: Vec<String>,
267}
268
269impl Default for UiState {
270    fn default() -> Self {
271        Self::new()
272    }
273}
274
275impl UiState {
276    pub fn new() -> Self {
277        Self::with_unicode(true)
278    }
279
280    /// Construct a `UiState` with an explicit Unicode capability.
281    /// Production code calls this from `App::new` with the value the
282    /// terminal-capability probe produced; tests stick with `new()`.
283    pub fn with_unicode(unicode_symbols: bool) -> Self {
284        Self {
285            phase: UiPhase::Idle,
286            agent_mode: AgentMode::default(),
287            spinner_label: String::new(),
288            spinner_frame: 0,
289            unicode_symbols,
290            total_tokens: 0,
291            prompt_tokens: 0,
292            completion_tokens: 0,
293            cached_tokens: 0,
294            prior_phase: None,
295            prior_spinner_label: None,
296            thinking_idx: 0,
297            turn_started_at: None,
298            phase_started_at: None,
299            last_context: None,
300            last_submitted_message: None,
301            pending_context_render: None,
302            pending_images: Vec::new(),
303            pending_image_hashes: Vec::new(),
304            pending_image_markers: Vec::new(),
305            pending_recalled_attachments: Vec::new(),
306            session_image_count: 0,
307            show_tool_output: false,
308            show_reasoning: false,
309            sub_agent_total: 0,
310            sub_agent_done: 0,
311            sub_agent_tasks: Vec::new(),
312            sub_agent_failed: 0,
313            sub_agent_started_at: None,
314            active_tool_batches: std::collections::HashMap::new(),
315            call_id_to_batch: std::collections::HashMap::new(),
316        }
317    }
318
319    /// Single-character horizontal ellipsis (`…`, U+2026) when Unicode
320    /// is available, three ASCII dots (`...`) otherwise. Used by the
321    /// spinner label and any other "still working…" suffix.
322    pub fn ellipsis(&self) -> &'static str {
323        if self.unicode_symbols {
324            "…"
325        } else {
326            "..."
327        }
328    }
329
330    /// Merge one `AgentEvent::ContextStats` emission into the cached
331    /// snapshot. The agent side fires two emissions per turn: one narrow
332    /// (from `TurnRunner`) and one rich (from `handle_send_message`).
333    /// Each leaves the fields it doesn't know at 0 / empty — we keep the
334    /// most-recent non-zero value per field so either order works.
335    pub fn on_context_stats(
336        &mut self,
337        system_tokens: usize,
338        sent_tokens: usize,
339        tool_defs_tokens: usize,
340        cold_zone_tokens: usize,
341        total_messages: usize,
342        ctx_window: usize,
343        ctx_name: &str,
344        system_prompt: &str,
345    ) {
346        let is_rich = ctx_window > 0;
347        let snap = self
348            .last_context
349            .get_or_insert_with(ContextSnapshot::default);
350        if is_rich {
351            snap.system_tokens = system_tokens;
352            snap.sent_tokens = sent_tokens;
353            snap.tool_defs_tokens = tool_defs_tokens;
354            snap.cold_zone_tokens = cold_zone_tokens;
355            snap.total_messages = total_messages;
356            snap.ctx_window = ctx_window;
357            if !ctx_name.is_empty() {
358                snap.ctx_name = ctx_name.to_string();
359            }
360            snap.system_prompt = system_prompt.to_string();
361            return;
362        }
363        if system_tokens > 0 {
364            snap.system_tokens = system_tokens;
365        }
366        if sent_tokens > 0 {
367            snap.sent_tokens = sent_tokens;
368        }
369        if tool_defs_tokens > 0 {
370            snap.tool_defs_tokens = tool_defs_tokens;
371        }
372        // cold_zone can be 0 legitimately (no compression yet) — the rich
373        // emission always sends an accurate value, so only overwrite when
374        // the emission carries the ctx_window signal (rich path).
375        if total_messages > 0 {
376            snap.total_messages = total_messages;
377        }
378        if !ctx_name.is_empty() {
379            snap.ctx_name = ctx_name.to_string();
380        }
381        // system_prompt — only the rich path sends non-empty bytes;
382        // narrow path passes "" and we keep whatever was cached last.
383        if !system_prompt.is_empty() {
384            snap.system_prompt = system_prompt.to_string();
385        }
386    }
387
388    /// Elapsed wall time since the current turn began, if a turn is
389    /// active. Returns None when idle.
390    pub fn turn_elapsed(&self) -> Option<std::time::Duration> {
391        self.turn_started_at.map(|t| t.elapsed())
392    }
393
394    /// Elapsed wall time since the current phase began. The spinner
395    /// uses this so its `· 12s` suffix shows time on the current
396    /// operation (LLM round-trip / tool execution), not cumulative
397    /// turn time. Falls back to `turn_elapsed()` when no phase
398    /// transition has fired yet — defensive, should not normally
399    /// happen since `on_submit` seeds both.
400    pub fn phase_elapsed(&self) -> Option<std::time::Duration> {
401        self.phase_started_at
402            .map(|t| t.elapsed())
403            .or_else(|| self.turn_elapsed())
404    }
405
406    fn current_thinking(&self) -> &'static str {
407        THINKING_LABELS[self.thinking_idx % THINKING_LABELS.len()]
408    }
409
410    pub fn on_submit(&mut self) {
411        self.phase = UiPhase::Streaming;
412        self.spinner_label = self.current_thinking().to_string();
413        self.spinner_frame = 0;
414        self.thinking_idx = self.thinking_idx.wrapping_add(1);
415        let now = std::time::Instant::now();
416        self.turn_started_at = Some(now);
417        self.phase_started_at = Some(now);
418    }
419
420    pub fn on_turn_complete(&mut self) {
421        self.phase = UiPhase::Idle;
422        self.spinner_label.clear();
423        self.turn_started_at = None;
424        self.phase_started_at = None;
425        // Turn finished normally — no need to offer resubmit of the
426        // message any more. (On cancel, the streaming-key handler
427        // already took() the Option before the TurnCancelled event
428        // reaches here, so the cancelled path naturally leaves this
429        // None too.)
430        self.last_submitted_message = None;
431    }
432
433    pub fn on_turn_cancelled(&mut self) {
434        self.phase = UiPhase::Idle;
435        self.spinner_label.clear();
436        self.turn_started_at = None;
437        self.phase_started_at = None;
438    }
439
440    pub fn on_error(&mut self) {
441        self.phase = UiPhase::Idle;
442        self.spinner_label.clear();
443        self.turn_started_at = None;
444        self.phase_started_at = None;
445    }
446
447    /// Set the spinner label to `"Running {name}"` (no trailing ellipsis —
448    /// the renderer appends `...` uniformly so it looks right even when
449    /// the elapsed-time suffix is appended). Resets the phase clock so
450    /// the spinner timer starts fresh on this tool execution.
451    pub fn on_tool_call_started(&mut self, name: &str) {
452        self.spinner_label = format!("Running {}", name);
453        self.phase_started_at = Some(std::time::Instant::now());
454    }
455
456    pub fn on_tool_call_streaming(&mut self, name: &str) {
457        self.spinner_label = format!("Preparing {}", name);
458        self.phase_started_at = Some(std::time::Instant::now());
459    }
460
461    pub fn on_thinking(&mut self) {
462        // Reuse the current pool label (don't bump the index — that's done
463        // on submit, one rotation per turn not per state transition).
464        let idx = self.thinking_idx.saturating_sub(1) % THINKING_LABELS.len();
465        self.spinner_label = THINKING_LABELS[idx].to_string();
466        // New LLM round-trip → new phase clock. Without this reset the
467        // displayed time keeps growing across consecutive thinks/tools
468        // and ends up showing "Noodling… 1301s" mid-turn.
469        self.phase_started_at = Some(std::time::Instant::now());
470    }
471
472    /// Begin a fork dispatch. Stores the per-task descriptors so the UI
473    /// can look up each child's display path + dedup-suffix by index
474    /// when a `SubAgentTaskStarted/Done/Failed` event arrives — the
475    /// previous flow flattened identical basenames (`tunnel.rs` × 3)
476    /// into indistinguishable rows. Also overrides the foreground
477    /// spinner label since `pool.execute_all` blocks the loop.
478    pub fn on_sub_agent_dispatch_start(
479        &mut self,
480        tasks: Vec<atomcode_core::agent::SubAgentTaskInfo>,
481    ) {
482        self.sub_agent_total = tasks.len();
483        self.sub_agent_done = 0;
484        self.sub_agent_failed = 0;
485        self.spinner_label = format!("Sub-agents 0/{}", tasks.len());
486        self.phase_started_at = Some(std::time::Instant::now());
487        self.sub_agent_started_at = Some(std::time::Instant::now());
488        self.sub_agent_tasks = tasks;
489    }
490
491    /// Mark one sub-agent as completed (success). Late events after
492    /// `on_sub_agent_dispatch_end` are no-ops — `sub_agent_total == 0`
493    /// is the gate.
494    pub fn on_sub_agent_task_done(&mut self) {
495        if self.sub_agent_total == 0 {
496            return;
497        }
498        self.sub_agent_done = self.sub_agent_done.saturating_add(1);
499        self.refresh_sub_agent_label();
500    }
501
502    /// Mark one sub-agent as failed (error / timeout / no-edit).
503    /// Increments BOTH the done and failed counters — for the spinner
504    /// label `done` is "settled" regardless of outcome, but the
505    /// aggregate emitted on dispatch_end needs the success/fail split.
506    pub fn on_sub_agent_task_failed(&mut self) {
507        if self.sub_agent_total == 0 {
508            return;
509        }
510        self.sub_agent_done = self.sub_agent_done.saturating_add(1);
511        self.sub_agent_failed = self.sub_agent_failed.saturating_add(1);
512        self.refresh_sub_agent_label();
513    }
514
515    fn refresh_sub_agent_label(&mut self) {
516        self.spinner_label = format!("Sub-agents {}/{}", self.sub_agent_done, self.sub_agent_total);
517    }
518
519    /// End the dispatch — clears descriptors so subsequent thinks/tools
520    /// resume normal label behaviour. The next `on_thinking` /
521    /// `on_tool_call_started` will overwrite `spinner_label`; we leave it
522    /// alone here so the final "N/N" stays visible until the next phase.
523    /// The success/fail split is preserved long enough for the event
524    /// loop to render the aggregate summary line, then cleared.
525    pub fn on_sub_agent_dispatch_end(&mut self) {
526        self.sub_agent_total = 0;
527        self.sub_agent_done = 0;
528        self.sub_agent_failed = 0;
529        self.sub_agent_tasks.clear();
530        self.sub_agent_started_at = None;
531    }
532
533    /// `display_tool` is the already-PascalCased name (e.g. `"Bash"`,
534    /// `"ReadFile"`) — same form `on_tool_call_started` writes into
535    /// `spinner_label`. Stashed so `on_approval_resolved` can put the
536    /// label back when execution resumes.
537    pub fn on_approval_needed(&mut self, display_tool: &str) {
538        self.phase = UiPhase::Approval;
539        // Stash the running-tool label so we can restore it after the
540        // user answers. Fall back to inferring from `display_tool` if no
541        // ToolCallStarted ran first (defensive — should not normally
542        // happen, since runner emits ToolCallStarted before approval).
543        if !self.spinner_label.is_empty() {
544            self.prior_spinner_label = Some(self.spinner_label.clone());
545        } else {
546            self.prior_spinner_label = Some(format!("Running {}", display_tool));
547        }
548        self.spinner_label = "Waiting approval".to_string();
549        // Reset the phase clock so the elapsed suffix tracks how long
550        // we've been waiting on the user, not how long the prior phase
551        // (often the just-emitted ToolCallStarted) had been running.
552        self.phase_started_at = Some(std::time::Instant::now());
553    }
554
555    pub fn on_approval_resolved(&mut self) {
556        self.phase = UiPhase::Streaming;
557        if let Some(prior) = self.prior_spinner_label.take() {
558            self.spinner_label = prior;
559            // The tool is about to actually start running now (hook +
560            // bash_execute). Restart the clock so the spinner suffix
561            // reflects that, not the cumulative wait-then-run time.
562            self.phase_started_at = Some(std::time::Instant::now());
563        }
564    }
565
566    pub fn on_suspend(&mut self) {
567        self.prior_phase = Some(self.phase);
568        self.phase = UiPhase::Suspended;
569    }
570
571    pub fn on_resume(&mut self) {
572        if let Some(p) = self.prior_phase.take() {
573            self.phase = p;
574        } else {
575            self.phase = UiPhase::Idle;
576        }
577    }
578
579    /// Pick (and advance) a playful "done" phrase for the turn separator.
580    pub fn next_done_label(&mut self) -> &'static str {
581        // Reuse thinking_idx rotation so done/think move together.
582        let idx = self.thinking_idx.wrapping_sub(1) % DONE_LABELS.len();
583        DONE_LABELS[idx]
584    }
585
586    /// Toggle real-time tool output and reasoning visibility.
587    /// Both are controlled by Ctrl+O (verbose mode).
588    pub fn toggle_tool_output(&mut self) {
589        self.show_tool_output = !self.show_tool_output;
590        self.show_reasoning = !self.show_reasoning;
591    }
592
593    /// Toggle verbose mode (alias for toggle_tool_output).
594    /// Shows/hides both tool output and reasoning content.
595    pub fn toggle_verbose(&mut self) {
596        self.toggle_tool_output();
597    }
598
599    pub fn tick_spinner(&mut self) -> &'static str {
600        // Two frame sets, picked once at construction:
601        //   Unicode → half-moon rotation; Braille was prettier but
602        //   Windows fonts often lack that block and fall back to ":".
603        //   ASCII   → classic `|/-\` for terminals whose font also
604        //   lacks the Geometric Shapes block (notably Windows legacy
605        //   conhost with NSimSun / Consolas variants).
606        const UNICODE_FRAMES: &[&str] = &["◐", "◓", "◑", "◒"];
607        const ASCII_FRAMES: &[&str] = &["|", "/", "-", "\\"];
608        let frames = if self.unicode_symbols {
609            UNICODE_FRAMES
610        } else {
611            ASCII_FRAMES
612        };
613        self.spinner_frame = (self.spinner_frame + 1) % frames.len();
614        frames[self.spinner_frame]
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    // Regression: terminals whose font lacks `◐` / `…` (Windows legacy
623    // conhost with default Consolas) used to show `□` tofu for both.
624    // ASCII fallback gives them readable `|/-\` and `...` instead.
625    #[test]
626    fn ascii_spinner_uses_pipe_slash_dash_backslash() {
627        let mut s = UiState::with_unicode(false);
628        let mut seen = Vec::new();
629        for _ in 0..4 {
630            seen.push(s.tick_spinner());
631        }
632        // Order is implementation detail; the SET must match.
633        let mut sorted = seen.clone();
634        sorted.sort();
635        assert_eq!(sorted, vec!["-", "/", "\\", "|"]);
636    }
637
638    #[test]
639    fn unicode_spinner_uses_half_moons() {
640        let mut s = UiState::with_unicode(true);
641        let mut seen = Vec::new();
642        for _ in 0..4 {
643            seen.push(s.tick_spinner());
644        }
645        let mut sorted = seen.clone();
646        sorted.sort();
647        assert_eq!(sorted, vec!["◐", "◑", "◒", "◓"]);
648    }
649
650    #[test]
651    fn ellipsis_falls_back_to_three_ascii_dots() {
652        assert_eq!(UiState::with_unicode(false).ellipsis(), "...");
653        assert_eq!(UiState::with_unicode(true).ellipsis(), "…");
654    }
655
656    #[test]
657    fn new_state_is_idle() {
658        let s = UiState::new();
659        assert_eq!(s.phase, UiPhase::Idle);
660    }
661
662    /// Regression for the cross-turn `[Image #N]` ambiguity: the marker
663    /// counter must NOT reset when `pending_images` drains on submit —
664    /// otherwise turn 1's first paste and turn 2's first paste would
665    /// both render as `[Image #1]` in scrollback. The counter lives on
666    /// `session_image_count`, monotonically increasing for the whole
667    /// session.
668    #[test]
669    fn session_image_count_starts_at_zero_on_new_state() {
670        let s = UiState::new();
671        assert_eq!(s.session_image_count, 0);
672    }
673
674    /// Simulate two-turn paste flow: paste image in turn 1, drain
675    /// `pending_images` on submit, paste image in turn 2. The second
676    /// paste must get marker `#2`, not `#1`.
677    #[test]
678    fn session_image_count_survives_pending_images_drain() {
679        let mut s = UiState::new();
680        // Turn 1: simulate paste sites' increment-then-push pattern.
681        s.session_image_count += 1;
682        let n1 = s.session_image_count;
683        s.pending_images.push(atomcode_core::conversation::message::ImagePart {
684            media_type: "image/png".into(),
685            data: "AAAA".into(),
686        });
687        s.pending_image_hashes.push(0xdead_beef);
688        // Submit drains pending_images / hashes (mirrors event_loop logic).
689        let _ = std::mem::take(&mut s.pending_images);
690        let _ = std::mem::take(&mut s.pending_image_hashes);
691        // Turn 2: another paste.
692        s.session_image_count += 1;
693        let n2 = s.session_image_count;
694        assert_eq!(n1, 1, "first paste of session is #1");
695        assert_eq!(n2, 2, "first paste of next turn must be #2, not #1");
696    }
697
698    #[test]
699    fn submit_transitions_to_streaming() {
700        let mut s = UiState::new();
701        s.on_submit();
702        assert_eq!(s.phase, UiPhase::Streaming);
703        // Label is one of the rotating pool entries.
704        assert!(THINKING_LABELS.contains(&s.spinner_label.as_str()));
705    }
706
707    #[test]
708    fn consecutive_submits_rotate_labels() {
709        let mut s = UiState::new();
710        s.on_submit();
711        let first = s.spinner_label.clone();
712        s.on_turn_complete();
713        s.on_submit();
714        let second = s.spinner_label.clone();
715        assert_ne!(first, second);
716    }
717
718    #[test]
719    fn turn_complete_returns_to_idle() {
720        let mut s = UiState::new();
721        s.on_submit();
722        s.on_turn_complete();
723        assert_eq!(s.phase, UiPhase::Idle);
724    }
725
726    #[test]
727    fn approval_needed_transitions_to_approval() {
728        let mut s = UiState::new();
729        s.on_submit();
730        s.on_approval_needed("Bash");
731        assert_eq!(s.phase, UiPhase::Approval);
732    }
733
734    #[test]
735    fn approval_resolved_back_to_streaming() {
736        let mut s = UiState::new();
737        s.on_submit();
738        s.on_approval_needed("Bash");
739        s.on_approval_resolved();
740        assert_eq!(s.phase, UiPhase::Streaming);
741    }
742
743    // Without this, the spinner shows "Running Bash… · 273s" while the
744    // agent is actually blocked on `permission.decide().await` — looks
745    // identical to a real hang. Fixed by stashing/restoring the label
746    // around the prompt.
747    #[test]
748    fn approval_needed_swaps_spinner_label_to_waiting() {
749        let mut s = UiState::new();
750        s.on_submit();
751        s.on_tool_call_started("Bash");
752        assert_eq!(s.spinner_label, "Running Bash");
753        s.on_approval_needed("Bash");
754        assert_eq!(s.spinner_label, "Waiting approval");
755    }
756
757    #[test]
758    fn approval_resolved_restores_running_label() {
759        let mut s = UiState::new();
760        s.on_submit();
761        s.on_tool_call_started("Bash");
762        s.on_approval_needed("Bash");
763        s.on_approval_resolved();
764        assert_eq!(s.spinner_label, "Running Bash");
765    }
766
767    // Spinner suffix is `· {phase_elapsed}` — if we don't reset the clock
768    // on the Approval transition, the user sees the cumulative
769    // ToolCallStarted-to-now time and can't tell wait from work.
770    #[test]
771    fn approval_needed_resets_phase_clock() {
772        let mut s = UiState::new();
773        s.on_submit();
774        s.on_tool_call_started("Bash");
775        let started = s.phase_started_at.unwrap();
776        std::thread::sleep(std::time::Duration::from_millis(15));
777        s.on_approval_needed("Bash");
778        let after = s.phase_started_at.unwrap();
779        assert!(after > started, "phase_started_at should advance");
780    }
781
782    #[test]
783    fn approval_resolved_resets_phase_clock() {
784        let mut s = UiState::new();
785        s.on_submit();
786        s.on_tool_call_started("Bash");
787        s.on_approval_needed("Bash");
788        let waiting_started = s.phase_started_at.unwrap();
789        std::thread::sleep(std::time::Duration::from_millis(15));
790        s.on_approval_resolved();
791        let resumed = s.phase_started_at.unwrap();
792        assert!(resumed > waiting_started, "phase_started_at should advance");
793    }
794
795    #[test]
796    fn suspend_preserves_prior_phase() {
797        let mut s = UiState::new();
798        s.on_submit();
799        s.on_suspend();
800        assert_eq!(s.phase, UiPhase::Suspended);
801        s.on_resume();
802        assert_eq!(s.phase, UiPhase::Streaming);
803    }
804
805    #[test]
806    fn tool_call_updates_spinner_label() {
807        let mut s = UiState::new();
808        s.on_submit();
809        s.on_tool_call_started("read_file");
810        assert!(s.spinner_label.contains("read_file"));
811    }
812
813    #[test]
814    fn error_returns_to_idle() {
815        let mut s = UiState::new();
816        s.on_submit();
817        s.on_error();
818        assert_eq!(s.phase, UiPhase::Idle);
819    }
820
821    #[test]
822    fn agent_mode_default_is_build() {
823        assert_eq!(AgentMode::default(), AgentMode::Build);
824    }
825
826    fn task_info(path: &str, dedup: &str) -> atomcode_core::agent::SubAgentTaskInfo {
827        atomcode_core::agent::SubAgentTaskInfo {
828            path: path.to_string(),
829            dedup_suffix: dedup.to_string(),
830        }
831    }
832
833    #[test]
834    fn sub_agent_dispatch_overrides_stale_tool_label() {
835        // Reproduces the user's "Running ReadFile… 82s" stale-spinner
836        // problem: the foreground turn was waiting on pool.execute_all and
837        // the last tool name (read_file) stayed pinned. After dispatch_start
838        // the label must reflect the sub-agent counter, not the dead tool.
839        let mut s = UiState::new();
840        s.on_submit();
841        s.on_tool_call_started("read_file");
842        assert!(s.spinner_label.contains("read_file"));
843        let tasks = (0..6).map(|i| task_info(&format!("a{}.rs", i), "")).collect();
844        s.on_sub_agent_dispatch_start(tasks);
845        assert_eq!(s.spinner_label, "Sub-agents 0/6");
846    }
847
848    #[test]
849    fn sub_agent_task_done_increments_counter() {
850        let mut s = UiState::new();
851        let tasks = vec![
852            task_info("a.rs", ""),
853            task_info("b.rs", ""),
854            task_info("c.rs", ""),
855        ];
856        s.on_sub_agent_dispatch_start(tasks);
857        s.on_sub_agent_task_done();
858        assert_eq!(s.spinner_label, "Sub-agents 1/3");
859        s.on_sub_agent_task_done();
860        s.on_sub_agent_task_done();
861        assert_eq!(s.spinner_label, "Sub-agents 3/3");
862        assert_eq!(s.sub_agent_failed, 0);
863    }
864
865    #[test]
866    fn sub_agent_task_failed_counts_toward_done_and_failed() {
867        // `sub_agent_done` is "settled" — done OR failed. The aggregate
868        // line emitted on dispatch_end uses `sub_agent_failed` to split
869        // them apart for "6 ok · 1 fail" rendering.
870        let mut s = UiState::new();
871        s.on_sub_agent_dispatch_start(vec![task_info("x.rs", ""), task_info("y.rs", "")]);
872        s.on_sub_agent_task_done();
873        s.on_sub_agent_task_failed();
874        assert_eq!(s.sub_agent_done, 2);
875        assert_eq!(s.sub_agent_failed, 1);
876    }
877
878    #[test]
879    fn sub_agent_task_event_outside_dispatch_is_noop() {
880        // A late event after DispatchEnd must not bring the counter
881        // label back from the dead.
882        let mut s = UiState::new();
883        s.on_thinking();
884        let pre = s.spinner_label.clone();
885        s.on_sub_agent_task_done();
886        s.on_sub_agent_task_failed();
887        assert_eq!(s.spinner_label, pre);
888    }
889
890    #[test]
891    fn sub_agent_dispatch_end_clears_descriptors() {
892        let mut s = UiState::new();
893        s.on_sub_agent_dispatch_start(vec![task_info("a.rs", ""), task_info("b.rs", "")]);
894        assert_eq!(s.sub_agent_tasks.len(), 2);
895        s.on_sub_agent_task_done();
896        s.on_sub_agent_task_done();
897        s.on_sub_agent_dispatch_end();
898        assert_eq!(s.sub_agent_total, 0);
899        assert_eq!(s.sub_agent_done, 0);
900        assert_eq!(s.sub_agent_failed, 0);
901        assert!(s.sub_agent_tasks.is_empty());
902        assert!(s.sub_agent_started_at.is_none());
903        s.on_thinking();
904        assert!(!s.spinner_label.starts_with("Sub-agents"));
905    }
906
907    #[test]
908    fn sub_agent_dispatch_preserves_dedup_suffix() {
909        // Three tasks against tunnel.rs must come through as #1/#2/#3
910        // suffixes from the dispatcher, and `state.sub_agent_tasks`
911        // must carry that data so a `SubAgentTaskStarted { index: 2 }`
912        // event renders the right disambiguator.
913        let mut s = UiState::new();
914        s.on_sub_agent_dispatch_start(vec![
915            task_info("src/server/tunnel.rs", " (#1)"),
916            task_info("src/client/tunnel.rs", ""),
917            task_info("src/server/tunnel.rs", " (#2)"),
918        ]);
919        assert_eq!(s.sub_agent_tasks[0].dedup_suffix, " (#1)");
920        assert_eq!(s.sub_agent_tasks[1].dedup_suffix, "");
921        assert_eq!(s.sub_agent_tasks[2].dedup_suffix, " (#2)");
922    }
923
924    #[test]
925    fn agent_mode_build_label() {
926        assert_eq!(AgentMode::Build.label(), "Build");
927    }
928
929    #[test]
930    fn agent_mode_plan_label() {
931        assert_eq!(AgentMode::Plan.label(), "Plan");
932    }
933
934    #[test]
935    fn agent_mode_build_toggles_to_plan() {
936        assert_eq!(AgentMode::Build.toggle(), AgentMode::Plan);
937    }
938
939    #[test]
940    fn agent_mode_plan_toggles_to_build() {
941        assert_eq!(AgentMode::Plan.toggle(), AgentMode::Build);
942    }
943
944    #[test]
945    fn agent_mode_double_toggle_returns_to_original() {
946        assert_eq!(AgentMode::Build.toggle().toggle(), AgentMode::Build);
947    }
948
949    #[test]
950    fn pending_recalled_attachments_starts_empty() {
951        let s = UiState::new();
952        assert!(s.pending_recalled_attachments.is_empty());
953    }
954
955    #[test]
956    fn queued_message_carries_images() {
957        let q = QueuedMessage {
958            text: "hi".into(),
959            images: vec![atomcode_core::conversation::message::ImagePart {
960                media_type: "image/png".into(),
961                data: "AAAA".into(),
962            }],
963            image_markers: vec![1],
964        };
965        assert_eq!(q.images.len(), 1);
966        assert_eq!(q.image_markers, vec![1]);
967    }
968}