atomcode-tuix 4.23.1

Open-source terminal AI coding agent
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
// crates/atomcode-tuix/src/state.rs

/// Plan vs Build execution mode. Plan is read-only exploration (no file
/// writes, no shell commands); Build is full execution with all tools.
/// Toggled by the Tab key (when the input buffer is empty) or the
/// `/plan` and `/build` slash commands.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AgentMode {
    #[default]
    Build,
    Plan,
}

impl AgentMode {
    /// Human-readable label for status bar display.
    pub fn label(self) -> &'static str {
        match self {
            Self::Build => "Build",
            Self::Plan => "Plan",
        }
    }

    /// Return the opposite mode.
    pub fn toggle(self) -> Self {
        match self {
            Self::Build => Self::Plan,
            Self::Plan => Self::Build,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UiPhase {
    Idle,
    Streaming,
    Approval,
    Suspended,
}

/// Rotating pool of "thinking" labels — CC-style playful verbs.
/// Advances once per turn so consecutive turns vary.
pub const THINKING_LABELS: &[&str] = &[
    "Pondering",
    "Noodling",
    "Percolating",
    "Brewing",
    "Cogitating",
    "Churning",
    "Hatching",
    "Marinating",
    "Simmering",
    "Tinkering",
    "Mulling",
    "Musing",
    "Ruminating",
    "Puttering",
    "Fermenting",
    "Divining",
    "Concocting",
    "Germinating",
    "Whittling",
    "Scheming",
];

/// Rotating pool of turn-completion phrases — CC-vibe playful verbs.
pub const DONE_LABELS: &[&str] = &[
    "Done",
    "Nailed it",
    "Wrapped",
    "Shipped",
    "Baked",
    "Plated",
    "Served",
    "Bagged",
    "Handled",
    "Dialed in",
    "Locked in",
    "Sealed",
    "Stuck the landing",
    "Buttoned up",
    "Squared away",
    "Cooked",
    "Dusted",
    "Called it",
    "Delivered",
    "Tied off",
];

/// Snapshot of the agent's context budget, cached from `AgentEvent::ContextStats`
/// and surfaced by the `/context` command.
///
/// Merged across two emission paths: the narrow TurnEvent-forwarded one
/// (system/sent/total_messages) and the rich one from `handle_send_message`
/// (tool_defs / cold_zone / ctx_window / ctx_name). Each path leaves the
/// fields it doesn't know at 0 / empty, so we merge by keeping non-zero
/// updates. See `UiState::on_context_stats`.
#[derive(Debug, Clone, Default)]
pub struct ContextSnapshot {
    pub system_tokens: usize,
    pub sent_tokens: usize,
    pub tool_defs_tokens: usize,
    pub cold_zone_tokens: usize,
    pub total_messages: usize,
    pub ctx_window: usize,
    pub ctx_name: String,
    /// Full assembled system prompt from the most recent turn.
    /// Surfaced by `/context prompt`. Empty until the first rich
    /// emission lands.
    pub system_prompt: String,
}

/// One entry in `message_queue`. Replaces the prior `String`-only
/// representation so queued messages can carry their pasted images +
/// markers — otherwise queueing a message during streaming silently
/// drops attachments.
#[derive(Debug, Clone)]
pub struct QueuedMessage {
    pub text: String,
    pub images: Vec<atomcode_core::conversation::message::ImagePart>,
    pub image_markers: Vec<usize>,
}

pub struct UiState {
    pub phase: UiPhase,
    pub agent_mode: AgentMode,
    pub spinner_label: String,
    pub spinner_frame: usize,
    /// Mirrors `TerminalCaps::unicode_symbols` — frozen at construction.
    /// When false, `tick_spinner` and the spinner-label ellipsis fall
    /// back to ASCII so terminals whose font lacks `◐` / `…` (notably
    /// Windows legacy conhost) don't show `□` tofu.
    pub unicode_symbols: bool,
    pub total_tokens: usize,
    pub prompt_tokens: usize,
    pub completion_tokens: usize,
    pub cached_tokens: usize,
    /// When Suspended, holds the phase to restore on resume.
    pub prior_phase: Option<UiPhase>,
    /// While waiting on a tool approval, holds the `"Running {Tool}"`
    /// label that was active before the prompt opened. `on_approval_needed`
    /// stashes it here and swaps `spinner_label` to "Waiting approval";
    /// `on_approval_resolved` restores it. Without this, the spinner kept
    /// saying "Running Bash… · 273s" while the agent was actually blocked
    /// on `permission.decide().await` — looked identical to a real tool
    /// hang.
    pub prior_spinner_label: Option<String>,
    /// Round-robin index into THINKING_LABELS; bumped on each on_submit.
    pub thinking_idx: usize,
    /// When the current turn started. Set by on_submit, cleared on
    /// turn-complete / turn-cancelled / error. Used to surface the
    /// total wall-clock duration in the TurnComplete event payload.
    pub turn_started_at: Option<std::time::Instant>,
    /// When the current phase began. Reset on every phase transition
    /// (on_submit, on_thinking, on_tool_call_streaming,
    /// on_tool_call_started) so the spinner shows time spent on the
    /// CURRENT operation — `Pondering… 12s`, `Running ReadFile… 4s`
    /// — instead of accumulating over the whole turn. Cleared on
    /// turn-complete / turn-cancelled / error so the idle spinner
    /// (rare) doesn't tick a stale duration.
    pub phase_started_at: Option<std::time::Instant>,
    /// Last observed context breakdown. Populated from
    /// `AgentEvent::ContextStats` — `/context` renders this. `None`
    /// before the first turn completes.
    pub last_context: Option<ContextSnapshot>,
    /// Verbatim text of the message that is currently running. Set
    /// on every submit, cleared on turn-complete. When the user hits
    /// Ctrl+C / Esc mid-stream the streaming-key handler takes this
    /// and restores it to the input buffer so the cancelled message
    /// can be edited + resent without re-typing. `None` between
    /// turns and after any successful completion.
    pub last_submitted_message: Option<String>,
    /// `/context` dispatched a `RefreshContextStats` command and is
    /// waiting for the resulting rich ContextStats event to render the
    /// report. `Some(show_prompt)` until the next rich emission lands;
    /// cleared after the render fires. Prevents stale-cache renders
    /// without forcing /context to block synchronously on the agent
    /// loop. The bool is the `prompt` sub-arg (include full system
    /// prompt body).
    pub pending_context_render: Option<bool>,
    /// Images pasted from clipboard (Ctrl+V) waiting to be sent with
    /// the next user message. Drained on submit.
    pub pending_images: Vec<atomcode_core::conversation::message::ImagePart>,
    /// Parallel to `pending_images` — content fingerprint of each pasted
    /// image's raw RGBA bytes. Used by the right-aligned status hint to
    /// suppress `Image in clipboard · ctrl+v to paste` once the clipboard
    /// content matches an already-attached image (avoids dup paste prompts),
    /// while still surfacing the hint when the user copies a new image
    /// after pasting an earlier one. Cleared together with `pending_images`
    /// on submit.
    pub pending_image_hashes: Vec<u64>,
    /// Parallel to `pending_images` — the marker number `N` originally
    /// printed for each image at paste time. Submit-time matching does
    /// `line.contains("[Image #N]")` against this number to decide
    /// whether the image survived editing. Must NOT be `i + 1` from the
    /// vec position — once `session_image_count` became monotonic across
    /// turns, paste-time numbers diverge from positional indices, and
    /// using the index dropped images on every retry that wasn't the
    /// first paste of the session.
    pub pending_image_markers: Vec<usize>,
    /// Image attachments to re-attach when the user submits a recalled
    /// history entry. Populated by the up-arrow handler from the
    /// recalled `HistoryEntry::images`; drained on submit by the
    /// hydrate prelude in `event_loop/mod.rs`. Lazy by design — disk
    /// reads happen at submit time, not on every navigation.
    pub pending_recalled_attachments: Vec<crate::input::history::HistoryImageRef>,
    /// Monotonic counter for the `[Image #N]` marker shown in the input
    /// buffer + scrollback. Incremented on every paste and NEVER reset
    /// across turns — so two images pasted in different turns get
    /// distinct labels (e.g. `[Image #1]` in turn 1, `[Image #2]` in
    /// turn 2). Without this, both turns' first paste would both render
    /// as `[Image #1]`, making it ambiguous which image a later
    /// reference points at when scrolling back.
    pub session_image_count: usize,
    /// Whether to show real-time tool output (e.g., bash stdout/stderr).
    /// Toggled by Ctrl+O. When false (default), tool output is hidden
    /// during execution and only shown in the final result.
    pub show_tool_output: bool,
    /// Whether to show LLM reasoning/thinking content (e.g., DeepSeek-R1,
    /// MiniMax-M2.7). Toggled by Ctrl+O together with `show_tool_output`.
    /// When false (default), reasoning content is hidden during streaming.
    pub show_reasoning: bool,
    /// Number of fork sub-agents currently dispatched. While > 0, the
    /// foreground turn is blocked awaiting `pool.execute_all` — there's
    /// no fresh tool / think event to update the spinner, so without an
    /// override the label stays frozen on the last tool name (e.g.
    /// "Running ReadFile… 82s") for the entire pool duration. Cleared
    /// on `SubAgentDispatchEnd`.
    pub sub_agent_total: usize,
    /// How many sub-agents in the current dispatch have reported a
    /// terminal status (done / failed / timeout). Updated by
    /// `on_sub_agent_settled`. Reset to 0 on each new dispatch.
    pub sub_agent_done: usize,
    /// Per-task descriptors (path + dedup suffix) for the active
    /// dispatch. Indexed identically to the `tasks` field on
    /// `AgentEvent::SubAgentDispatchStart` so the UI can look up a
    /// child's display path from the `index` field on Started/Done/
    /// Failed events. Cleared on `on_sub_agent_dispatch_end`.
    pub sub_agent_tasks: Vec<atomcode_core::agent::SubAgentTaskInfo>,
    /// Number of failed sub-agents in the current dispatch — tracked
    /// separately from `sub_agent_done` so the aggregate summary can
    /// distinguish "6/7 ok · 1 fail" from "7/7 ok". Reset on each new
    /// dispatch.
    pub sub_agent_failed: usize,
    /// Wall-clock start of the current dispatch. Used to render the
    /// elapsed-time figure on the `SubAgentDispatchEnd` aggregate
    /// summary line. Cleared with the rest of the dispatch state.
    pub sub_agent_started_at: Option<std::time::Instant>,

    /// Active tool batches, keyed by `batch_id`. Populated on
    /// `ToolBatchStarted` and cleared on `ToolBatchCompleted`. Used by
    /// per-call event handlers to detect "this ToolCallStarted/Result
    /// belongs to an active batch" and skip the standalone row render
    /// (the batch header already represents it).
    pub active_tool_batches: std::collections::HashMap<String, ActiveToolBatch>,
    /// Reverse map call_id → batch_id for O(1) lookup when a per-call
    /// event arrives. Mirrors `active_tool_batches` membership; cleared
    /// together.
    pub call_id_to_batch: std::collections::HashMap<String, String>,
}

/// Per-batch state for an active `ToolBatchStarted`. Tracks how many
/// children have completed so the UI can emit the final `· N/M ok`
/// summary on `ToolBatchCompleted`.
#[derive(Debug, Clone)]
pub struct ActiveToolBatch {
    pub call_ids: Vec<String>,
}

impl Default for UiState {
    fn default() -> Self {
        Self::new()
    }
}

impl UiState {
    pub fn new() -> Self {
        Self::with_unicode(true)
    }

    /// Construct a `UiState` with an explicit Unicode capability.
    /// Production code calls this from `App::new` with the value the
    /// terminal-capability probe produced; tests stick with `new()`.
    pub fn with_unicode(unicode_symbols: bool) -> Self {
        Self {
            phase: UiPhase::Idle,
            agent_mode: AgentMode::default(),
            spinner_label: String::new(),
            spinner_frame: 0,
            unicode_symbols,
            total_tokens: 0,
            prompt_tokens: 0,
            completion_tokens: 0,
            cached_tokens: 0,
            prior_phase: None,
            prior_spinner_label: None,
            thinking_idx: 0,
            turn_started_at: None,
            phase_started_at: None,
            last_context: None,
            last_submitted_message: None,
            pending_context_render: None,
            pending_images: Vec::new(),
            pending_image_hashes: Vec::new(),
            pending_image_markers: Vec::new(),
            pending_recalled_attachments: Vec::new(),
            session_image_count: 0,
            show_tool_output: false,
            show_reasoning: false,
            sub_agent_total: 0,
            sub_agent_done: 0,
            sub_agent_tasks: Vec::new(),
            sub_agent_failed: 0,
            sub_agent_started_at: None,
            active_tool_batches: std::collections::HashMap::new(),
            call_id_to_batch: std::collections::HashMap::new(),
        }
    }

    /// Single-character horizontal ellipsis (`…`, U+2026) when Unicode
    /// is available, three ASCII dots (`...`) otherwise. Used by the
    /// spinner label and any other "still working…" suffix.
    pub fn ellipsis(&self) -> &'static str {
        if self.unicode_symbols {
            ""
        } else {
            "..."
        }
    }

    /// Merge one `AgentEvent::ContextStats` emission into the cached
    /// snapshot. The agent side fires two emissions per turn: one narrow
    /// (from `TurnRunner`) and one rich (from `handle_send_message`).
    /// Each leaves the fields it doesn't know at 0 / empty — we keep the
    /// most-recent non-zero value per field so either order works.
    pub fn on_context_stats(
        &mut self,
        system_tokens: usize,
        sent_tokens: usize,
        tool_defs_tokens: usize,
        cold_zone_tokens: usize,
        total_messages: usize,
        ctx_window: usize,
        ctx_name: &str,
        system_prompt: &str,
    ) {
        let is_rich = ctx_window > 0;
        let snap = self
            .last_context
            .get_or_insert_with(ContextSnapshot::default);
        if is_rich {
            snap.system_tokens = system_tokens;
            snap.sent_tokens = sent_tokens;
            snap.tool_defs_tokens = tool_defs_tokens;
            snap.cold_zone_tokens = cold_zone_tokens;
            snap.total_messages = total_messages;
            snap.ctx_window = ctx_window;
            if !ctx_name.is_empty() {
                snap.ctx_name = ctx_name.to_string();
            }
            snap.system_prompt = system_prompt.to_string();
            return;
        }
        if system_tokens > 0 {
            snap.system_tokens = system_tokens;
        }
        if sent_tokens > 0 {
            snap.sent_tokens = sent_tokens;
        }
        if tool_defs_tokens > 0 {
            snap.tool_defs_tokens = tool_defs_tokens;
        }
        // cold_zone can be 0 legitimately (no compression yet) — the rich
        // emission always sends an accurate value, so only overwrite when
        // the emission carries the ctx_window signal (rich path).
        if total_messages > 0 {
            snap.total_messages = total_messages;
        }
        if !ctx_name.is_empty() {
            snap.ctx_name = ctx_name.to_string();
        }
        // system_prompt — only the rich path sends non-empty bytes;
        // narrow path passes "" and we keep whatever was cached last.
        if !system_prompt.is_empty() {
            snap.system_prompt = system_prompt.to_string();
        }
    }

    /// Elapsed wall time since the current turn began, if a turn is
    /// active. Returns None when idle.
    pub fn turn_elapsed(&self) -> Option<std::time::Duration> {
        self.turn_started_at.map(|t| t.elapsed())
    }

    /// Elapsed wall time since the current phase began. The spinner
    /// uses this so its `· 12s` suffix shows time on the current
    /// operation (LLM round-trip / tool execution), not cumulative
    /// turn time. Falls back to `turn_elapsed()` when no phase
    /// transition has fired yet — defensive, should not normally
    /// happen since `on_submit` seeds both.
    pub fn phase_elapsed(&self) -> Option<std::time::Duration> {
        self.phase_started_at
            .map(|t| t.elapsed())
            .or_else(|| self.turn_elapsed())
    }

    fn current_thinking(&self) -> &'static str {
        THINKING_LABELS[self.thinking_idx % THINKING_LABELS.len()]
    }

    pub fn on_submit(&mut self) {
        self.phase = UiPhase::Streaming;
        self.spinner_label = self.current_thinking().to_string();
        self.spinner_frame = 0;
        self.thinking_idx = self.thinking_idx.wrapping_add(1);
        let now = std::time::Instant::now();
        self.turn_started_at = Some(now);
        self.phase_started_at = Some(now);
    }

    pub fn on_turn_complete(&mut self) {
        self.phase = UiPhase::Idle;
        self.spinner_label.clear();
        self.turn_started_at = None;
        self.phase_started_at = None;
        // Turn finished normally — no need to offer resubmit of the
        // message any more. (On cancel, the streaming-key handler
        // already took() the Option before the TurnCancelled event
        // reaches here, so the cancelled path naturally leaves this
        // None too.)
        self.last_submitted_message = None;
    }

    pub fn on_turn_cancelled(&mut self) {
        self.phase = UiPhase::Idle;
        self.spinner_label.clear();
        self.turn_started_at = None;
        self.phase_started_at = None;
    }

    pub fn on_error(&mut self) {
        self.phase = UiPhase::Idle;
        self.spinner_label.clear();
        self.turn_started_at = None;
        self.phase_started_at = None;
    }

    /// Set the spinner label to `"Running {name}"` (no trailing ellipsis —
    /// the renderer appends `...` uniformly so it looks right even when
    /// the elapsed-time suffix is appended). Resets the phase clock so
    /// the spinner timer starts fresh on this tool execution.
    pub fn on_tool_call_started(&mut self, name: &str) {
        self.spinner_label = format!("Running {}", name);
        self.phase_started_at = Some(std::time::Instant::now());
    }

    pub fn on_tool_call_streaming(&mut self, name: &str) {
        self.spinner_label = format!("Preparing {}", name);
        self.phase_started_at = Some(std::time::Instant::now());
    }

    pub fn on_thinking(&mut self) {
        // Reuse the current pool label (don't bump the index — that's done
        // on submit, one rotation per turn not per state transition).
        let idx = self.thinking_idx.saturating_sub(1) % THINKING_LABELS.len();
        self.spinner_label = THINKING_LABELS[idx].to_string();
        // New LLM round-trip → new phase clock. Without this reset the
        // displayed time keeps growing across consecutive thinks/tools
        // and ends up showing "Noodling… 1301s" mid-turn.
        self.phase_started_at = Some(std::time::Instant::now());
    }

    /// Begin a fork dispatch. Stores the per-task descriptors so the UI
    /// can look up each child's display path + dedup-suffix by index
    /// when a `SubAgentTaskStarted/Done/Failed` event arrives — the
    /// previous flow flattened identical basenames (`tunnel.rs` × 3)
    /// into indistinguishable rows. Also overrides the foreground
    /// spinner label since `pool.execute_all` blocks the loop.
    pub fn on_sub_agent_dispatch_start(
        &mut self,
        tasks: Vec<atomcode_core::agent::SubAgentTaskInfo>,
    ) {
        self.sub_agent_total = tasks.len();
        self.sub_agent_done = 0;
        self.sub_agent_failed = 0;
        self.spinner_label = format!("Sub-agents 0/{}", tasks.len());
        self.phase_started_at = Some(std::time::Instant::now());
        self.sub_agent_started_at = Some(std::time::Instant::now());
        self.sub_agent_tasks = tasks;
    }

    /// Mark one sub-agent as completed (success). Late events after
    /// `on_sub_agent_dispatch_end` are no-ops — `sub_agent_total == 0`
    /// is the gate.
    pub fn on_sub_agent_task_done(&mut self) {
        if self.sub_agent_total == 0 {
            return;
        }
        self.sub_agent_done = self.sub_agent_done.saturating_add(1);
        self.refresh_sub_agent_label();
    }

    /// Mark one sub-agent as failed (error / timeout / no-edit).
    /// Increments BOTH the done and failed counters — for the spinner
    /// label `done` is "settled" regardless of outcome, but the
    /// aggregate emitted on dispatch_end needs the success/fail split.
    pub fn on_sub_agent_task_failed(&mut self) {
        if self.sub_agent_total == 0 {
            return;
        }
        self.sub_agent_done = self.sub_agent_done.saturating_add(1);
        self.sub_agent_failed = self.sub_agent_failed.saturating_add(1);
        self.refresh_sub_agent_label();
    }

    fn refresh_sub_agent_label(&mut self) {
        self.spinner_label = format!("Sub-agents {}/{}", self.sub_agent_done, self.sub_agent_total);
    }

    /// End the dispatch — clears descriptors so subsequent thinks/tools
    /// resume normal label behaviour. The next `on_thinking` /
    /// `on_tool_call_started` will overwrite `spinner_label`; we leave it
    /// alone here so the final "N/N" stays visible until the next phase.
    /// The success/fail split is preserved long enough for the event
    /// loop to render the aggregate summary line, then cleared.
    pub fn on_sub_agent_dispatch_end(&mut self) {
        self.sub_agent_total = 0;
        self.sub_agent_done = 0;
        self.sub_agent_failed = 0;
        self.sub_agent_tasks.clear();
        self.sub_agent_started_at = None;
    }

    /// `display_tool` is the already-PascalCased name (e.g. `"Bash"`,
    /// `"ReadFile"`) — same form `on_tool_call_started` writes into
    /// `spinner_label`. Stashed so `on_approval_resolved` can put the
    /// label back when execution resumes.
    pub fn on_approval_needed(&mut self, display_tool: &str) {
        self.phase = UiPhase::Approval;
        // Stash the running-tool label so we can restore it after the
        // user answers. Fall back to inferring from `display_tool` if no
        // ToolCallStarted ran first (defensive — should not normally
        // happen, since runner emits ToolCallStarted before approval).
        if !self.spinner_label.is_empty() {
            self.prior_spinner_label = Some(self.spinner_label.clone());
        } else {
            self.prior_spinner_label = Some(format!("Running {}", display_tool));
        }
        self.spinner_label = "Waiting approval".to_string();
        // Reset the phase clock so the elapsed suffix tracks how long
        // we've been waiting on the user, not how long the prior phase
        // (often the just-emitted ToolCallStarted) had been running.
        self.phase_started_at = Some(std::time::Instant::now());
    }

    pub fn on_approval_resolved(&mut self) {
        self.phase = UiPhase::Streaming;
        if let Some(prior) = self.prior_spinner_label.take() {
            self.spinner_label = prior;
            // The tool is about to actually start running now (hook +
            // bash_execute). Restart the clock so the spinner suffix
            // reflects that, not the cumulative wait-then-run time.
            self.phase_started_at = Some(std::time::Instant::now());
        }
    }

    pub fn on_suspend(&mut self) {
        self.prior_phase = Some(self.phase);
        self.phase = UiPhase::Suspended;
    }

    pub fn on_resume(&mut self) {
        if let Some(p) = self.prior_phase.take() {
            self.phase = p;
        } else {
            self.phase = UiPhase::Idle;
        }
    }

    /// Pick (and advance) a playful "done" phrase for the turn separator.
    pub fn next_done_label(&mut self) -> &'static str {
        // Reuse thinking_idx rotation so done/think move together.
        let idx = self.thinking_idx.wrapping_sub(1) % DONE_LABELS.len();
        DONE_LABELS[idx]
    }

    /// Toggle real-time tool output and reasoning visibility.
    /// Both are controlled by Ctrl+O (verbose mode).
    pub fn toggle_tool_output(&mut self) {
        self.show_tool_output = !self.show_tool_output;
        self.show_reasoning = !self.show_reasoning;
    }

    /// Toggle verbose mode (alias for toggle_tool_output).
    /// Shows/hides both tool output and reasoning content.
    pub fn toggle_verbose(&mut self) {
        self.toggle_tool_output();
    }

    pub fn tick_spinner(&mut self) -> &'static str {
        // Two frame sets, picked once at construction:
        //   Unicode → half-moon rotation; Braille was prettier but
        //   Windows fonts often lack that block and fall back to ":".
        //   ASCII   → classic `|/-\` for terminals whose font also
        //   lacks the Geometric Shapes block (notably Windows legacy
        //   conhost with NSimSun / Consolas variants).
        const UNICODE_FRAMES: &[&str] = &["", "", "", ""];
        const ASCII_FRAMES: &[&str] = &["|", "/", "-", "\\"];
        let frames = if self.unicode_symbols {
            UNICODE_FRAMES
        } else {
            ASCII_FRAMES
        };
        self.spinner_frame = (self.spinner_frame + 1) % frames.len();
        frames[self.spinner_frame]
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // Regression: terminals whose font lacks `◐` / `…` (Windows legacy
    // conhost with default Consolas) used to show `□` tofu for both.
    // ASCII fallback gives them readable `|/-\` and `...` instead.
    #[test]
    fn ascii_spinner_uses_pipe_slash_dash_backslash() {
        let mut s = UiState::with_unicode(false);
        let mut seen = Vec::new();
        for _ in 0..4 {
            seen.push(s.tick_spinner());
        }
        // Order is implementation detail; the SET must match.
        let mut sorted = seen.clone();
        sorted.sort();
        assert_eq!(sorted, vec!["-", "/", "\\", "|"]);
    }

    #[test]
    fn unicode_spinner_uses_half_moons() {
        let mut s = UiState::with_unicode(true);
        let mut seen = Vec::new();
        for _ in 0..4 {
            seen.push(s.tick_spinner());
        }
        let mut sorted = seen.clone();
        sorted.sort();
        assert_eq!(sorted, vec!["", "", "", ""]);
    }

    #[test]
    fn ellipsis_falls_back_to_three_ascii_dots() {
        assert_eq!(UiState::with_unicode(false).ellipsis(), "...");
        assert_eq!(UiState::with_unicode(true).ellipsis(), "");
    }

    #[test]
    fn new_state_is_idle() {
        let s = UiState::new();
        assert_eq!(s.phase, UiPhase::Idle);
    }

    /// Regression for the cross-turn `[Image #N]` ambiguity: the marker
    /// counter must NOT reset when `pending_images` drains on submit —
    /// otherwise turn 1's first paste and turn 2's first paste would
    /// both render as `[Image #1]` in scrollback. The counter lives on
    /// `session_image_count`, monotonically increasing for the whole
    /// session.
    #[test]
    fn session_image_count_starts_at_zero_on_new_state() {
        let s = UiState::new();
        assert_eq!(s.session_image_count, 0);
    }

    /// Simulate two-turn paste flow: paste image in turn 1, drain
    /// `pending_images` on submit, paste image in turn 2. The second
    /// paste must get marker `#2`, not `#1`.
    #[test]
    fn session_image_count_survives_pending_images_drain() {
        let mut s = UiState::new();
        // Turn 1: simulate paste sites' increment-then-push pattern.
        s.session_image_count += 1;
        let n1 = s.session_image_count;
        s.pending_images.push(atomcode_core::conversation::message::ImagePart {
            media_type: "image/png".into(),
            data: "AAAA".into(),
        });
        s.pending_image_hashes.push(0xdead_beef);
        // Submit drains pending_images / hashes (mirrors event_loop logic).
        let _ = std::mem::take(&mut s.pending_images);
        let _ = std::mem::take(&mut s.pending_image_hashes);
        // Turn 2: another paste.
        s.session_image_count += 1;
        let n2 = s.session_image_count;
        assert_eq!(n1, 1, "first paste of session is #1");
        assert_eq!(n2, 2, "first paste of next turn must be #2, not #1");
    }

    #[test]
    fn submit_transitions_to_streaming() {
        let mut s = UiState::new();
        s.on_submit();
        assert_eq!(s.phase, UiPhase::Streaming);
        // Label is one of the rotating pool entries.
        assert!(THINKING_LABELS.contains(&s.spinner_label.as_str()));
    }

    #[test]
    fn consecutive_submits_rotate_labels() {
        let mut s = UiState::new();
        s.on_submit();
        let first = s.spinner_label.clone();
        s.on_turn_complete();
        s.on_submit();
        let second = s.spinner_label.clone();
        assert_ne!(first, second);
    }

    #[test]
    fn turn_complete_returns_to_idle() {
        let mut s = UiState::new();
        s.on_submit();
        s.on_turn_complete();
        assert_eq!(s.phase, UiPhase::Idle);
    }

    #[test]
    fn approval_needed_transitions_to_approval() {
        let mut s = UiState::new();
        s.on_submit();
        s.on_approval_needed("Bash");
        assert_eq!(s.phase, UiPhase::Approval);
    }

    #[test]
    fn approval_resolved_back_to_streaming() {
        let mut s = UiState::new();
        s.on_submit();
        s.on_approval_needed("Bash");
        s.on_approval_resolved();
        assert_eq!(s.phase, UiPhase::Streaming);
    }

    // Without this, the spinner shows "Running Bash… · 273s" while the
    // agent is actually blocked on `permission.decide().await` — looks
    // identical to a real hang. Fixed by stashing/restoring the label
    // around the prompt.
    #[test]
    fn approval_needed_swaps_spinner_label_to_waiting() {
        let mut s = UiState::new();
        s.on_submit();
        s.on_tool_call_started("Bash");
        assert_eq!(s.spinner_label, "Running Bash");
        s.on_approval_needed("Bash");
        assert_eq!(s.spinner_label, "Waiting approval");
    }

    #[test]
    fn approval_resolved_restores_running_label() {
        let mut s = UiState::new();
        s.on_submit();
        s.on_tool_call_started("Bash");
        s.on_approval_needed("Bash");
        s.on_approval_resolved();
        assert_eq!(s.spinner_label, "Running Bash");
    }

    // Spinner suffix is `· {phase_elapsed}` — if we don't reset the clock
    // on the Approval transition, the user sees the cumulative
    // ToolCallStarted-to-now time and can't tell wait from work.
    #[test]
    fn approval_needed_resets_phase_clock() {
        let mut s = UiState::new();
        s.on_submit();
        s.on_tool_call_started("Bash");
        let started = s.phase_started_at.unwrap();
        std::thread::sleep(std::time::Duration::from_millis(15));
        s.on_approval_needed("Bash");
        let after = s.phase_started_at.unwrap();
        assert!(after > started, "phase_started_at should advance");
    }

    #[test]
    fn approval_resolved_resets_phase_clock() {
        let mut s = UiState::new();
        s.on_submit();
        s.on_tool_call_started("Bash");
        s.on_approval_needed("Bash");
        let waiting_started = s.phase_started_at.unwrap();
        std::thread::sleep(std::time::Duration::from_millis(15));
        s.on_approval_resolved();
        let resumed = s.phase_started_at.unwrap();
        assert!(resumed > waiting_started, "phase_started_at should advance");
    }

    #[test]
    fn suspend_preserves_prior_phase() {
        let mut s = UiState::new();
        s.on_submit();
        s.on_suspend();
        assert_eq!(s.phase, UiPhase::Suspended);
        s.on_resume();
        assert_eq!(s.phase, UiPhase::Streaming);
    }

    #[test]
    fn tool_call_updates_spinner_label() {
        let mut s = UiState::new();
        s.on_submit();
        s.on_tool_call_started("read_file");
        assert!(s.spinner_label.contains("read_file"));
    }

    #[test]
    fn error_returns_to_idle() {
        let mut s = UiState::new();
        s.on_submit();
        s.on_error();
        assert_eq!(s.phase, UiPhase::Idle);
    }

    #[test]
    fn agent_mode_default_is_build() {
        assert_eq!(AgentMode::default(), AgentMode::Build);
    }

    fn task_info(path: &str, dedup: &str) -> atomcode_core::agent::SubAgentTaskInfo {
        atomcode_core::agent::SubAgentTaskInfo {
            path: path.to_string(),
            dedup_suffix: dedup.to_string(),
        }
    }

    #[test]
    fn sub_agent_dispatch_overrides_stale_tool_label() {
        // Reproduces the user's "Running ReadFile… 82s" stale-spinner
        // problem: the foreground turn was waiting on pool.execute_all and
        // the last tool name (read_file) stayed pinned. After dispatch_start
        // the label must reflect the sub-agent counter, not the dead tool.
        let mut s = UiState::new();
        s.on_submit();
        s.on_tool_call_started("read_file");
        assert!(s.spinner_label.contains("read_file"));
        let tasks = (0..6).map(|i| task_info(&format!("a{}.rs", i), "")).collect();
        s.on_sub_agent_dispatch_start(tasks);
        assert_eq!(s.spinner_label, "Sub-agents 0/6");
    }

    #[test]
    fn sub_agent_task_done_increments_counter() {
        let mut s = UiState::new();
        let tasks = vec![
            task_info("a.rs", ""),
            task_info("b.rs", ""),
            task_info("c.rs", ""),
        ];
        s.on_sub_agent_dispatch_start(tasks);
        s.on_sub_agent_task_done();
        assert_eq!(s.spinner_label, "Sub-agents 1/3");
        s.on_sub_agent_task_done();
        s.on_sub_agent_task_done();
        assert_eq!(s.spinner_label, "Sub-agents 3/3");
        assert_eq!(s.sub_agent_failed, 0);
    }

    #[test]
    fn sub_agent_task_failed_counts_toward_done_and_failed() {
        // `sub_agent_done` is "settled" — done OR failed. The aggregate
        // line emitted on dispatch_end uses `sub_agent_failed` to split
        // them apart for "6 ok · 1 fail" rendering.
        let mut s = UiState::new();
        s.on_sub_agent_dispatch_start(vec![task_info("x.rs", ""), task_info("y.rs", "")]);
        s.on_sub_agent_task_done();
        s.on_sub_agent_task_failed();
        assert_eq!(s.sub_agent_done, 2);
        assert_eq!(s.sub_agent_failed, 1);
    }

    #[test]
    fn sub_agent_task_event_outside_dispatch_is_noop() {
        // A late event after DispatchEnd must not bring the counter
        // label back from the dead.
        let mut s = UiState::new();
        s.on_thinking();
        let pre = s.spinner_label.clone();
        s.on_sub_agent_task_done();
        s.on_sub_agent_task_failed();
        assert_eq!(s.spinner_label, pre);
    }

    #[test]
    fn sub_agent_dispatch_end_clears_descriptors() {
        let mut s = UiState::new();
        s.on_sub_agent_dispatch_start(vec![task_info("a.rs", ""), task_info("b.rs", "")]);
        assert_eq!(s.sub_agent_tasks.len(), 2);
        s.on_sub_agent_task_done();
        s.on_sub_agent_task_done();
        s.on_sub_agent_dispatch_end();
        assert_eq!(s.sub_agent_total, 0);
        assert_eq!(s.sub_agent_done, 0);
        assert_eq!(s.sub_agent_failed, 0);
        assert!(s.sub_agent_tasks.is_empty());
        assert!(s.sub_agent_started_at.is_none());
        s.on_thinking();
        assert!(!s.spinner_label.starts_with("Sub-agents"));
    }

    #[test]
    fn sub_agent_dispatch_preserves_dedup_suffix() {
        // Three tasks against tunnel.rs must come through as #1/#2/#3
        // suffixes from the dispatcher, and `state.sub_agent_tasks`
        // must carry that data so a `SubAgentTaskStarted { index: 2 }`
        // event renders the right disambiguator.
        let mut s = UiState::new();
        s.on_sub_agent_dispatch_start(vec![
            task_info("src/server/tunnel.rs", " (#1)"),
            task_info("src/client/tunnel.rs", ""),
            task_info("src/server/tunnel.rs", " (#2)"),
        ]);
        assert_eq!(s.sub_agent_tasks[0].dedup_suffix, " (#1)");
        assert_eq!(s.sub_agent_tasks[1].dedup_suffix, "");
        assert_eq!(s.sub_agent_tasks[2].dedup_suffix, " (#2)");
    }

    #[test]
    fn agent_mode_build_label() {
        assert_eq!(AgentMode::Build.label(), "Build");
    }

    #[test]
    fn agent_mode_plan_label() {
        assert_eq!(AgentMode::Plan.label(), "Plan");
    }

    #[test]
    fn agent_mode_build_toggles_to_plan() {
        assert_eq!(AgentMode::Build.toggle(), AgentMode::Plan);
    }

    #[test]
    fn agent_mode_plan_toggles_to_build() {
        assert_eq!(AgentMode::Plan.toggle(), AgentMode::Build);
    }

    #[test]
    fn agent_mode_double_toggle_returns_to_original() {
        assert_eq!(AgentMode::Build.toggle().toggle(), AgentMode::Build);
    }

    #[test]
    fn pending_recalled_attachments_starts_empty() {
        let s = UiState::new();
        assert!(s.pending_recalled_attachments.is_empty());
    }

    #[test]
    fn queued_message_carries_images() {
        let q = QueuedMessage {
            text: "hi".into(),
            images: vec![atomcode_core::conversation::message::ImagePart {
                media_type: "image/png".into(),
                data: "AAAA".into(),
            }],
            image_markers: vec![1],
        };
        assert_eq!(q.images.len(), 1);
        assert_eq!(q.image_markers, vec![1]);
    }
}