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}