zeph_tui/app/state.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! App construction, builder configuration, and accessors over the active session
5//! state (input, messages, scroll, panels, metrics, and display toggles).
6
7use std::sync::Arc;
8use std::time::Instant;
9
10use tokio::sync::{Notify, mpsc, watch};
11use zeph_common::task_supervisor::TaskSupervisor;
12
13use crate::command::TuiCommand;
14use crate::event::AgentEvent;
15use crate::hyperlink::HyperlinkSpan;
16use crate::metrics::MetricsSnapshot;
17use crate::session::SessionRegistry;
18use crate::types::PasteState;
19use crate::widgets::tool_view::ToolDensity;
20
21use super::{
22 AgentViewTarget, App, ChatMessage, InputMode, MAX_VISIBLE_INPUT_LINES, MessageRole, Panel,
23 RenderCache, SubAgentSidebarState, TranscriptCache, is_tool_use_only, parse_tool_output,
24};
25
26/// No-progress duration after which the wave transitions to `Stalled`.
27/// TODO: wire to `config.tui.stall_threshold_secs` (deferred per #5096 v1 scope)
28const STALL_THRESHOLD: std::time::Duration = std::time::Duration::from_secs(10);
29
30impl App {
31 /// Create a new `App` with the given I/O channels.
32 ///
33 /// The app starts in insert mode with the splash screen visible and no
34 /// messages in the buffer.
35 ///
36 /// # Arguments
37 ///
38 /// * `user_input_tx` — sender used to forward the user's typed text to the
39 /// agent loop via [`TuiChannel`](crate::TuiChannel).
40 /// * `agent_event_rx` — receiver for [`AgentEvent`] produced by the agent.
41 ///
42 /// # Examples
43 ///
44 /// ```rust
45 /// use tokio::sync::mpsc;
46 /// use zeph_tui::App;
47 ///
48 /// let (user_tx, _user_rx) = mpsc::channel(64);
49 /// let (_agent_tx, agent_rx) = mpsc::channel(64);
50 /// let app = App::new(user_tx, agent_rx);
51 /// assert!(app.show_splash());
52 /// ```
53 #[must_use]
54 pub fn new(
55 user_input_tx: mpsc::Sender<String>,
56 agent_event_rx: mpsc::Receiver<AgentEvent>,
57 ) -> Self {
58 Self {
59 sessions: SessionRegistry::bootstrap(),
60 show_side_panels: true,
61 show_help: false,
62 metrics: MetricsSnapshot::default(),
63 metrics_rx: None,
64 active_panel: Panel::Chat,
65 tool_expanded: false,
66 tool_density: ToolDensity::default(),
67 show_source_labels: false,
68 show_balance: true,
69 throbber_state: throbber_widgets_tui::ThrobberState::default(),
70 confirm_state: None,
71 elicitation_state: None,
72 command_palette: None,
73 command_tx: None,
74 file_picker_state: None,
75 file_index: None,
76 slash_autocomplete: None,
77 reverse_search: None,
78 transcript_search: None,
79 settings: crate::widgets::settings::SettingsViewState::default(),
80 should_quit: false,
81 user_input_tx,
82 agent_event_rx,
83 queued_count: 0,
84 pending_count: 0,
85 context_token_estimate: 0,
86 editing_queued: false,
87 hyperlinks: Vec::new(),
88 cancel_signal: None,
89 pending_file_index: None,
90 pending_theme: None,
91 pending_theme_name: None,
92 subagent_sidebar: SubAgentSidebarState::new(),
93 resume_banner: None,
94 task_supervisor: None,
95 show_task_panel: false,
96 cached_task_snapshots: Vec::new(),
97 clipboard: crate::clipboard::ClipboardHandle::new(),
98 fleet_snapshot: crate::widgets::fleet::FleetSnapshot::default(),
99 fleet_list_state: ratatui::widgets::ListState::default(),
100 durable_snapshot: crate::widgets::durable::DurableSnapshot::default(),
101 durable_list_state: ratatui::widgets::ListState::default(),
102 theme: crate::theme::Theme::default(),
103 theme_generation: 0,
104 theme_name: "zephyr".to_owned(),
105 effective_color_mode: crate::theme::EffectiveColorMode::Truecolor,
106 unicode_capable: crate::theme::detect_unicode_capable(),
107 collapsed_panels: [false; 4],
108 motion: zeph_config::Motion::Full,
109 wave_tick: 0,
110 last_progress_at: Instant::now(),
111 show_equalizer: true,
112 delights: zeph_config::DelightsConfig::default(),
113 stream_rate: crate::delights::StreamRate::new(),
114 toasts: crate::delights::ToastQueue::new(),
115 splash_shimmer: crate::delights::SplashShimmer::new(),
116 mouse_enabled: false,
117 last_layout: None,
118 pending_mouse_capture: None,
119 remote_daemon_url: None,
120 }
121 }
122
123 /// Override the visual theme with a palette-derived [`crate::theme::Theme`].
124 ///
125 /// Called once at startup after [`crate::theme::Theme::from_palette_with_mode`] has been
126 /// built from the user's config and detected terminal colour capability.
127 ///
128 /// # Examples
129 ///
130 /// ```rust
131 /// use tokio::sync::mpsc;
132 /// use zeph_tui::{App, theme::{Theme, SemanticPalette}};
133 ///
134 /// let (user_tx, _) = mpsc::channel(64);
135 /// let (_, agent_rx) = mpsc::channel(64);
136 /// let app = App::new(user_tx, agent_rx)
137 /// .with_theme(Theme::from_palette(&SemanticPalette::zephyr()));
138 /// ```
139 #[must_use]
140 pub fn with_theme(mut self, theme: crate::theme::Theme) -> Self {
141 self.theme = theme;
142 self
143 }
144
145 /// Set the active theme name for cycle tracking and status echoes.
146 ///
147 /// Must be called at every construction site that supplies a non-default theme so that
148 /// `cycle_theme` starts cycling from the correct position.
149 ///
150 /// # Examples
151 ///
152 /// ```rust
153 /// use tokio::sync::mpsc;
154 /// use zeph_tui::App;
155 ///
156 /// let (user_tx, _) = mpsc::channel(64);
157 /// let (_, agent_rx) = mpsc::channel(64);
158 /// let app = App::new(user_tx, agent_rx).with_theme_name("gruvbox-dark");
159 /// ```
160 #[must_use]
161 pub fn with_theme_name(mut self, name: impl Into<String>) -> Self {
162 self.theme_name = name.into();
163 self
164 }
165
166 /// Set the resolved colour mode used to re-derive themes on runtime swap.
167 ///
168 /// Store the `EffectiveColorMode` resolved once at startup so that `apply_theme`
169 /// produces consistent downgrade behaviour without re-running OS detection per swap.
170 ///
171 /// # Examples
172 ///
173 /// ```rust
174 /// use tokio::sync::mpsc;
175 /// use zeph_tui::{App, theme::EffectiveColorMode};
176 ///
177 /// let (user_tx, _) = mpsc::channel(64);
178 /// let (_, agent_rx) = mpsc::channel(64);
179 /// let app = App::new(user_tx, agent_rx)
180 /// .with_effective_color_mode(EffectiveColorMode::Truecolor);
181 /// ```
182 #[must_use]
183 pub fn with_effective_color_mode(mut self, mode: crate::theme::EffectiveColorMode) -> Self {
184 self.effective_color_mode = mode;
185 self
186 }
187
188 /// Return the current theme generation counter.
189 ///
190 /// Passed into `RenderCacheKey::theme_generation` so the render cache is
191 /// invalidated after every theme swap.
192 ///
193 /// # Examples
194 ///
195 /// ```rust
196 /// use tokio::sync::mpsc;
197 /// use zeph_tui::App;
198 ///
199 /// let (user_tx, _) = mpsc::channel(64);
200 /// let (_, agent_rx) = mpsc::channel(64);
201 /// let app = App::new(user_tx, agent_rx);
202 /// assert_eq!(app.theme_generation(), 0);
203 /// ```
204 #[must_use]
205 pub fn theme_generation(&self) -> u64 {
206 self.theme_generation
207 }
208
209 /// Apply a named theme preset or user file.
210 ///
211 /// Returns `Ok(true)` when the theme was applied immediately (built-in preset).
212 /// Returns `Ok(false)` when the user file load was dispatched asynchronously; the
213 /// result will be installed by `poll_pending_theme` on the next tick.
214 ///
215 /// Cancels any in-flight user-file load when switching to a preset, so the earlier
216 /// async result cannot silently revert the newer choice.
217 ///
218 /// # Errors
219 ///
220 /// Returns [`crate::theme::ThemeLoadError`] for empty or path-unsafe names.
221 ///
222 /// # Examples
223 ///
224 /// ```rust
225 /// use tokio::sync::mpsc;
226 /// use zeph_tui::App;
227 ///
228 /// let (user_tx, _) = mpsc::channel(64);
229 /// let (_, agent_rx) = mpsc::channel(64);
230 /// let mut app = App::new(user_tx, agent_rx);
231 /// let gen_before = app.theme_generation();
232 /// let _ = app.apply_theme("zephyr-light");
233 /// assert!(app.theme_generation() > gen_before);
234 /// ```
235 pub fn apply_theme(&mut self, name: &str) -> Result<bool, crate::theme::ThemeLoadError> {
236 use crate::theme::{Theme, ThemeLoadError, presets};
237 // Reject empty names — always routes to listing, never implicit preset resolution.
238 if name.is_empty() {
239 return Err(ThemeLoadError::UnsafeName(String::new()));
240 }
241 // Validate name before any I/O so callers get immediate feedback on bad input.
242 presets::validate_theme_name_pub(name)?;
243
244 // Built-in presets are compile-time constants — no I/O, apply synchronously.
245 if let Some(preset) = presets::Preset::from_name(name) {
246 // Cancel any in-flight user-file load so it cannot revert this newer choice.
247 if self.pending_theme.take().is_some() {
248 // Only clear if we actually had a load in flight — otherwise this could
249 // wipe an unrelated status_label (e.g. "indexing files..."). Its
250 // "loading theme..." label would otherwise never be cleared, since
251 // poll_pending_theme (the only other clearer) never runs once
252 // pending_theme is gone.
253 self.sessions.current_mut().status_label = None;
254 }
255 self.pending_theme_name = None;
256 let palette = preset.palette();
257 let new_theme = Theme::from_palette_with_mode(&palette, self.effective_color_mode);
258 self.theme = new_theme;
259 name.clone_into(&mut self.theme_name);
260 self.theme_generation += 1;
261 self.clear_all_render_caches();
262 return Ok(true);
263 }
264
265 // User file: offload blocking I/O to a spawn_blocking thread.
266 // The result is installed by `poll_pending_theme` on the next tick.
267 self.sessions.current_mut().status_label = Some("loading theme...".to_owned());
268 let name_owned = name.to_owned();
269 let (tx, rx) = tokio::sync::oneshot::channel();
270 tokio::task::spawn_blocking(move || {
271 let _ = tx.send(presets::load_user_theme(&name_owned));
272 });
273 self.pending_theme = Some(rx);
274 self.pending_theme_name = Some(name.to_owned());
275 Ok(false)
276 }
277
278 /// Install a pending user-theme load result if the background task has completed.
279 ///
280 /// Must be called once per tick from `tui_loop` (alongside `poll_pending_file_index`).
281 pub fn poll_pending_theme(&mut self) {
282 use crate::theme::Theme;
283
284 let Some(rx) = self.pending_theme.as_mut() else {
285 return;
286 };
287 match rx.try_recv() {
288 Ok(result) => {
289 self.pending_theme = None;
290 self.sessions.current_mut().status_label = None;
291 let name = self.pending_theme_name.take().unwrap_or_default();
292 match result {
293 Ok(palette) => {
294 let new_theme =
295 Theme::from_palette_with_mode(&palette, self.effective_color_mode);
296 self.theme = new_theme;
297 name.clone_into(&mut self.theme_name);
298 self.theme_generation += 1;
299 self.clear_all_render_caches();
300 self.push_system_message_pub(format!("Theme switched to: {name}"));
301 }
302 Err(e) => {
303 self.push_system_message_pub(format!("Theme error: {e}"));
304 }
305 }
306 }
307 Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
308 // Not ready yet — keep waiting.
309 }
310 Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
311 // Sender dropped without sending (spawn_blocking panicked).
312 self.pending_theme = None;
313 self.pending_theme_name = None;
314 self.sessions.current_mut().status_label = None;
315 tracing::warn!("pending theme load task dropped without result");
316 }
317 }
318 }
319
320 /// Cycle to the next preset in the fixed cycle list `["zephyr", "zephyr-light", "high-contrast"]`.
321 ///
322 /// Finds the current theme name in the cycle list and advances to the next entry,
323 /// wrapping around. If the current name is not in the list, starts from `"zephyr"`.
324 ///
325 /// # Examples
326 ///
327 /// ```rust
328 /// use tokio::sync::mpsc;
329 /// use zeph_tui::App;
330 ///
331 /// let (user_tx, _) = mpsc::channel(64);
332 /// let (_, agent_rx) = mpsc::channel(64);
333 /// let mut app = App::new(user_tx, agent_rx).with_theme_name("zephyr");
334 /// app.cycle_theme();
335 /// assert_eq!(app.active_theme_name(), "zephyr-light");
336 /// ```
337 pub fn cycle_theme(&mut self) {
338 const CYCLE: &[&str] = &["zephyr", "zephyr-light", "high-contrast"];
339 let pos = CYCLE
340 .iter()
341 .position(|&n| n == self.theme_name.as_str())
342 .unwrap_or(0);
343 let next = CYCLE[(pos + 1) % CYCLE.len()];
344 if let Err(e) = self.apply_theme(next) {
345 tracing::warn!("cycle_theme: failed to apply '{}': {e}", next);
346 }
347 }
348
349 /// Return the name of the currently-active theme.
350 ///
351 /// # Examples
352 ///
353 /// ```rust
354 /// use tokio::sync::mpsc;
355 /// use zeph_tui::App;
356 ///
357 /// let (user_tx, _) = mpsc::channel(64);
358 /// let (_, agent_rx) = mpsc::channel(64);
359 /// let app = App::new(user_tx, agent_rx).with_theme_name("gruvbox-dark");
360 /// assert_eq!(app.active_theme_name(), "gruvbox-dark");
361 /// ```
362 #[must_use]
363 pub fn active_theme_name(&self) -> &str {
364 &self.theme_name
365 }
366
367 /// Return the resolved terminal colour mode stored at startup.
368 ///
369 /// Used by widgets to choose between Unicode and ASCII fallback rendering.
370 ///
371 /// # Examples
372 ///
373 /// ```rust
374 /// use tokio::sync::mpsc;
375 /// use zeph_tui::{App, theme::EffectiveColorMode};
376 ///
377 /// let (user_tx, _) = mpsc::channel(64);
378 /// let (_, agent_rx) = mpsc::channel(64);
379 /// let app = App::new(user_tx, agent_rx);
380 /// assert_eq!(app.effective_color_mode(), EffectiveColorMode::Truecolor);
381 /// ```
382 #[must_use]
383 pub fn effective_color_mode(&self) -> crate::theme::EffectiveColorMode {
384 self.effective_color_mode
385 }
386
387 /// Return `true` when the terminal cannot render Unicode glyphs and ASCII-only output
388 /// should be used in place of box-drawing characters and spinners.
389 ///
390 /// Unicode capability is detected independently from colour support. A terminal with
391 /// `NO_COLOR` set (which produces `EffectiveColorMode::Never`) may still render `▹▸`
392 /// perfectly. Only `TERM=dumb` or a non-UTF-8 locale forces ASCII mode.
393 ///
394 /// # Examples
395 ///
396 /// ```rust
397 /// use tokio::sync::mpsc;
398 /// use zeph_tui::App;
399 ///
400 /// let (user_tx, _) = mpsc::channel(64);
401 /// let (_, agent_rx) = mpsc::channel(64);
402 /// // Default app created in a normal environment reports Unicode capable.
403 /// let app = App::new(user_tx, agent_rx);
404 /// // is_ascii_only() depends on TERM/LANG env vars, not color mode.
405 /// let _ = app.is_ascii_only();
406 /// ```
407 #[must_use]
408 pub fn is_ascii_only(&self) -> bool {
409 !self.unicode_capable
410 }
411
412 /// Invalidate render caches in every session slot.
413 ///
414 /// Called on theme swap because cached `Line`s bake in theme `Style` values — stale
415 /// styles from the old theme would otherwise persist until a content change triggers a
416 /// miss. Must clear ALL sessions, not only the currently-active one.
417 fn clear_all_render_caches(&mut self) {
418 for slot in self.sessions.iter_mut() {
419 slot.render_cache.clear();
420 }
421 }
422
423 /// Return `true` while the splash screen should be displayed.
424 ///
425 /// The splash screen is hidden as soon as the first chat message arrives.
426 #[must_use]
427 pub fn show_splash(&self) -> bool {
428 self.sessions.current().show_splash
429 }
430
431 /// Return `true` when the side panels column is visible.
432 ///
433 /// Controlled by the `s` keybinding and automatically disabled on narrow
434 /// terminals (< 80 columns).
435 #[must_use]
436 pub fn show_side_panels(&self) -> bool {
437 self.show_side_panels
438 }
439
440 /// Returns `true` when the user has toggled back to subagents view (plan view overridden).
441 #[must_use]
442 pub fn plan_view_active(&self) -> bool {
443 self.sessions.current().plan_view_active
444 }
445
446 // ---- Accessors for fields relocated into SessionSlot (preserves pub API surface) ----
447
448 /// Returns the active session's render cache.
449 #[must_use]
450 pub fn render_cache(&self) -> &RenderCache {
451 &self.sessions.current().render_cache
452 }
453
454 /// Returns a mutable reference to the active session's render cache.
455 pub fn render_cache_mut(&mut self) -> &mut RenderCache {
456 &mut self.sessions.current_mut().render_cache
457 }
458
459 /// Returns the current chat area view target (main conversation or sub-agent transcript).
460 #[must_use]
461 pub fn view_target(&self) -> &AgentViewTarget {
462 &self.sessions.current().view_target
463 }
464
465 /// Returns the cached transcript for the currently-focused sub-agent, if any.
466 #[must_use]
467 pub fn transcript_cache(&self) -> Option<&TranscriptCache> {
468 self.sessions.current().transcript_cache.as_ref()
469 }
470
471 /// Populate the message buffer from a persisted session history.
472 ///
473 /// Each element is a `(role, content)` pair where `role` is one of
474 /// `"user"`, `"assistant"`, or `"tool"`. Tool outputs are detected by a
475 /// sentinel suffix and rendered as [`MessageRole::Tool`] messages.
476 /// The splash screen is hidden after loading if any messages are present.
477 pub fn load_history(&mut self, messages: &[(&str, &str)]) {
478 const TOOL_SUFFIX: &str = "\n```";
479
480 for &(role_str, content) in messages {
481 if role_str == "user"
482 && let Some((tool_name, body)) = parse_tool_output(content, TOOL_SUFFIX)
483 {
484 self.sessions
485 .current_mut()
486 .messages
487 .push(ChatMessage::new(MessageRole::Tool, body).with_tool(tool_name.into()));
488 continue;
489 }
490
491 let role = match role_str {
492 "user" => MessageRole::User,
493 "assistant" => {
494 if is_tool_use_only(content) {
495 continue;
496 }
497 MessageRole::Assistant
498 }
499 _ => continue,
500 };
501 if role == MessageRole::User {
502 self.sessions
503 .current_mut()
504 .input_history
505 .push(content.to_owned());
506 }
507 self.sessions
508 .current_mut()
509 .messages
510 .push(ChatMessage::new(role, content));
511 }
512 // Enforce the message buffer cap on initial history load as well.
513 self.trim_messages();
514 if !self.sessions.current().messages.is_empty() {
515 self.sessions.current_mut().show_splash = false;
516 }
517 }
518
519 /// Backfill the message buffer from a bounded `/history` transcript slice
520 /// (spec-068 §13.6-§13.7).
521 ///
522 /// Unlike [`App::load_history`], this never pushes into `input_history` — display
523 /// backfill and readline/up-arrow recall are deliberately separate code paths (INV-SP-6,
524 /// AC-20). Entries arrive already role-classified by
525 /// `zeph_commands::transcript::TranscriptFormatter`'s upstream producer
526 /// (`MessageAccess::transcript_page`), so no sentinel/tool-output re-parsing is needed
527 /// here (contrast with `load_history`, which still receives raw `(role_str, content)`
528 /// pairs from the legacy `SQLite` projection).
529 pub fn backfill_history_display_only(&mut self, entries: &[zeph_commands::TranscriptEntry]) {
530 use zeph_commands::transcript::TranscriptRole;
531
532 for entry in entries {
533 let role = match entry.role {
534 TranscriptRole::User => MessageRole::User,
535 TranscriptRole::Tool => MessageRole::Tool,
536 // `TranscriptRole` is `#[non_exhaustive]`; fall back to Assistant for
537 // `Assistant` itself and any future variant, rather than failing to compile
538 // against a semver-compatible zeph-commands upgrade.
539 TranscriptRole::Assistant | _ => MessageRole::Assistant,
540 };
541 let mut msg = ChatMessage::new(role, entry.content.clone());
542 if let Some(tool_name) = &entry.tool_name {
543 msg = msg.with_tool(tool_name.clone().into());
544 }
545 self.sessions.current_mut().messages.push(msg);
546 }
547 self.trim_messages();
548 if !self.sessions.current().messages.is_empty() {
549 self.sessions.current_mut().show_splash = false;
550 }
551 }
552
553 /// Attach a cancel signal that Ctrl-C in the TUI will trigger.
554 ///
555 /// # Examples
556 ///
557 /// ```rust
558 /// use std::sync::Arc;
559 /// use tokio::sync::{Notify, mpsc};
560 /// use zeph_tui::App;
561 ///
562 /// let (tx, _rx) = mpsc::channel(1);
563 /// let (_atx, arx) = mpsc::channel(1);
564 /// let notify = Arc::new(Notify::new());
565 /// let _app = App::new(tx, arx).with_cancel_signal(notify);
566 /// ```
567 #[must_use]
568 pub fn with_cancel_signal(mut self, signal: Arc<Notify>) -> Self {
569 self.cancel_signal = Some(signal);
570 self
571 }
572
573 /// Attach a metrics watch channel for live dashboard updates.
574 ///
575 /// The current snapshot is read immediately; subsequent updates are polled
576 /// by [`poll_metrics`](Self::poll_metrics) each frame.
577 ///
578 /// # Examples
579 ///
580 /// ```rust
581 /// use tokio::sync::{mpsc, watch};
582 /// use zeph_tui::{App, MetricsSnapshot};
583 ///
584 /// let (tx, _rx) = mpsc::channel(1);
585 /// let (_atx, arx) = mpsc::channel(1);
586 /// let (_metrics_tx, metrics_rx) = watch::channel(MetricsSnapshot::default());
587 /// let _app = App::new(tx, arx).with_metrics_rx(metrics_rx);
588 /// ```
589 #[must_use]
590 pub fn with_metrics_rx(mut self, rx: watch::Receiver<MetricsSnapshot>) -> Self {
591 self.metrics = rx.borrow().clone();
592 self.metrics_rx = Some(rx);
593 self
594 }
595
596 /// Attach the command dispatch sender used for slash-command routing.
597 ///
598 /// # Examples
599 ///
600 /// ```rust
601 /// use tokio::sync::mpsc;
602 /// use zeph_tui::{App, TuiCommand};
603 ///
604 /// let (tx, _rx) = mpsc::channel(1);
605 /// let (_atx, arx) = mpsc::channel(1);
606 /// let (cmd_tx, _cmd_rx) = mpsc::channel(8);
607 /// let _app = App::new(tx, arx).with_command_tx(cmd_tx);
608 /// ```
609 #[must_use]
610 pub fn with_command_tx(mut self, tx: mpsc::Sender<TuiCommand>) -> Self {
611 self.command_tx = Some(tx);
612 self
613 }
614
615 /// Set the initial tool-output density from a loaded `TuiConfig`.
616 ///
617 /// Applied once at startup; runtime changes via the `c` key override this
618 /// but are not persisted back to config.
619 ///
620 /// # Examples
621 ///
622 /// ```rust
623 /// use tokio::sync::mpsc;
624 /// use zeph_tui::App;
625 /// use zeph_config::ToolDensity;
626 ///
627 /// let (tx, _rx) = mpsc::channel(1);
628 /// let (_atx, arx) = mpsc::channel(1);
629 /// let _app = App::new(tx, arx).with_tool_density(ToolDensity::Compact);
630 /// ```
631 #[must_use]
632 pub fn with_tool_density(mut self, density: ToolDensity) -> Self {
633 self.tool_density = density;
634 self
635 }
636
637 /// Record the remote daemon URL this session was attached to via `--connect <URL>`.
638 ///
639 /// Set once at startup in `run_tui_remote`; there is no runtime mechanism to attach
640 /// to or detach from a daemon mid-session (#5509). Used by `daemon:status` to report
641 /// real connection state instead of a stub message.
642 ///
643 /// # Examples
644 ///
645 /// ```rust
646 /// use tokio::sync::mpsc;
647 /// use zeph_tui::App;
648 ///
649 /// let (user_tx, _) = mpsc::channel(64);
650 /// let (_, agent_rx) = mpsc::channel(64);
651 /// let app = App::new(user_tx, agent_rx).with_remote_daemon_url("http://localhost:8765");
652 /// ```
653 #[must_use]
654 pub fn with_remote_daemon_url(mut self, url: impl Into<String>) -> Self {
655 self.remote_daemon_url = Some(url.into());
656 self
657 }
658
659 /// Return the remote daemon URL this session was attached to at startup, if any.
660 ///
661 /// `None` means this is a local session (no `--connect <URL>` flag was used).
662 pub(crate) fn remote_daemon_url(&self) -> Option<&str> {
663 self.remote_daemon_url.as_deref()
664 }
665
666 /// Wire a [`TaskSupervisor`] into the `App` for the task registry panel.
667 ///
668 /// The supervisor's task list is snapshotted once per render tick before
669 /// `terminal.draw()`, keeping the draw closure free of mutex contention.
670 /// Toggle the panel visibility with `/tasks`.
671 ///
672 /// # Examples
673 ///
674 /// ```rust,ignore
675 /// use tokio::sync::mpsc;
676 /// use tokio_util::sync::CancellationToken;
677 /// use zeph_common::task_supervisor::TaskSupervisor;
678 /// use zeph_tui::App;
679 ///
680 /// let (user_tx, _) = mpsc::channel(64);
681 /// let (_, agent_rx) = mpsc::channel(64);
682 /// let cancel = CancellationToken::new();
683 /// let supervisor = TaskSupervisor::new(cancel);
684 /// let _app = App::new(user_tx, agent_rx).with_task_supervisor(supervisor);
685 /// ```
686 #[must_use]
687 pub fn with_task_supervisor(mut self, supervisor: TaskSupervisor) -> Self {
688 self.task_supervisor = Some(supervisor);
689 self
690 }
691
692 /// Wire a [`TaskSupervisor`] into a running App instance.
693 ///
694 /// Used by the two-phase TUI startup path to connect the supervisor after
695 /// early startup (Phase 2), mirroring [`App::set_cancel_signal`] and
696 /// [`App::set_metrics_rx`] so the task registry panel works on that path too.
697 pub fn set_task_supervisor(&mut self, supervisor: TaskSupervisor) {
698 self.task_supervisor = Some(supervisor);
699 }
700
701 /// Refresh the cached task snapshot from the supervisor.
702 ///
703 /// Must be called once per render tick **before** `terminal.draw()` to avoid
704 /// acquiring the supervisor's inner mutex inside the draw closure.
705 pub(crate) fn refresh_task_snapshots(&mut self) {
706 self.cached_task_snapshots = self
707 .task_supervisor
708 .as_ref()
709 .map(TaskSupervisor::snapshot)
710 .unwrap_or_default();
711 }
712
713 /// Return a truncated label for active `TaskSupervisor` tasks, or `None` when idle.
714 ///
715 /// Used by the input widget to show a braille spinner with the name of the first
716 /// active (Running/Restarting) task when no other status is being displayed.
717 #[must_use]
718 pub fn supervisor_activity_label(&self) -> Option<String> {
719 self.task_supervisor.as_ref()?;
720 let mut active = self
721 .cached_task_snapshots
722 .iter()
723 .filter(|t| {
724 matches!(
725 t.status,
726 zeph_common::task_supervisor::TaskStatus::Running
727 | zeph_common::task_supervisor::TaskStatus::Restarting { .. }
728 )
729 })
730 .filter(|t| !t.name.starts_with("mem-"))
731 .peekable();
732 let first = active.next()?;
733 let label = if active.peek().is_none() {
734 first.name.to_string()
735 } else {
736 let extra = active.count() + 1; // +1 because we already consumed first
737 format!("{} +{} more", first.name, extra)
738 };
739 // Char-based truncation to avoid panicking on multi-byte UTF-8 boundaries.
740 let truncated: String = label.chars().take(38).collect();
741 Some(truncated)
742 }
743
744 /// Wire a cancel signal into a running App instance.
745 ///
746 /// Used by the two-phase TUI startup path to connect the agent's cancel signal
747 /// after the agent has been constructed (Phase 2).
748 pub fn set_cancel_signal(&mut self, signal: Arc<Notify>) {
749 self.cancel_signal = Some(signal);
750 }
751
752 /// Wire a metrics receiver into a running App instance.
753 ///
754 /// Used by the two-phase TUI startup path to connect the metrics channel
755 /// after the metrics watch channel has been created (Phase 2).
756 pub fn set_metrics_rx(&mut self, rx: watch::Receiver<MetricsSnapshot>) {
757 self.metrics = rx.borrow().clone();
758 self.metrics_rx = Some(rx);
759 }
760
761 /// Check the metrics watch channel for an updated snapshot and apply it.
762 ///
763 /// Also clamps the sidebar selection and triggers a transcript reload if
764 /// the sub-agent's turn count has advanced. Called once per render frame.
765 pub fn poll_metrics(&mut self) {
766 if let Some(ref mut rx) = self.metrics_rx
767 && rx.has_changed().unwrap_or(false)
768 {
769 let new_metrics = rx.borrow_and_update().clone();
770 // IC2: reset plan_view_active (subagents-override) when a new plan appears.
771 // Detect new plan by comparing graph_id; new plan should be shown immediately.
772 let new_graph_id = new_metrics
773 .orchestration_graph
774 .as_ref()
775 .map(|s| &s.graph_id);
776 let old_graph_id = self
777 .metrics
778 .orchestration_graph
779 .as_ref()
780 .map(|s| &s.graph_id);
781 if new_graph_id != old_graph_id && new_graph_id.is_some() {
782 self.sessions.current_mut().plan_view_active = false;
783 }
784 self.metrics = new_metrics;
785 }
786 // Clamp sidebar selection in case subagents count changed.
787 let count = self.metrics.sub_agents.len();
788 self.subagent_sidebar.clamp(count);
789 // Trigger transcript reload when turns count increased.
790 self.maybe_reload_transcript();
791 }
792
793 /// Evict oldest messages when the buffer exceeds `MAX_TUI_MESSAGES` (#2737).
794 ///
795 /// Shifts the render cache to match the drained messages, preserving cached renders
796 /// for the remaining entries and avoiding a full re-render stall (#2775).
797 pub(super) fn trim_messages(&mut self) {
798 self.sessions.current_mut().trim_messages();
799 }
800
801 /// Return a slice of all chat messages currently in the buffer.
802 ///
803 /// For the currently-displayed messages (which may be a sub-agent
804 /// transcript) use [`visible_messages`](Self::visible_messages) instead.
805 #[must_use]
806 pub fn messages(&self) -> &[ChatMessage] {
807 &self.sessions.current().messages
808 }
809
810 /// Return the current content of the text input field.
811 #[must_use]
812 pub fn input(&self) -> &str {
813 &self.sessions.current().input
814 }
815
816 /// Return the current input mode (normal vs. insert).
817 #[must_use]
818 pub fn input_mode(&self) -> InputMode {
819 self.sessions.current().input_mode
820 }
821
822 /// Return the cursor byte position within the input string.
823 #[must_use]
824 pub fn cursor_position(&self) -> usize {
825 self.sessions.current().cursor_position
826 }
827
828 /// Returns the composer height requested by the current draft, capped at three visible rows.
829 #[must_use]
830 pub(crate) fn desired_input_height(&self) -> u16 {
831 let content_lines = self.input_line_count().min(MAX_VISIBLE_INPUT_LINES);
832 content_lines.saturating_add(2)
833 }
834
835 /// Returns the number of logical lines in the current draft or indicator.
836 #[must_use]
837 pub(crate) fn input_line_count(&self) -> u16 {
838 if self.sessions.current().paste_state.is_some()
839 || (self.sessions.current().input.is_empty()
840 && matches!(self.sessions.current().input_mode, InputMode::Insert))
841 {
842 1
843 } else {
844 u16::try_from(self.sessions.current().input.matches('\n').count() + 1)
845 .unwrap_or(u16::MAX)
846 }
847 }
848
849 /// Return the number of lines the chat view is scrolled up from the bottom.
850 ///
851 /// `0` means the view is at the bottom (latest messages visible).
852 #[must_use]
853 pub fn scroll_offset(&self) -> usize {
854 self.sessions.current().scroll_offset
855 }
856
857 /// Scroll to bottom only if already at (or near) the bottom.
858 pub(super) fn auto_scroll(&mut self) {
859 if self.sessions.current().scroll_offset <= 1 {
860 self.sessions.current_mut().scroll_offset = 0;
861 }
862 }
863
864 /// Return `true` when tool-output blocks are expanded to full height.
865 #[must_use]
866 pub fn tool_expanded(&self) -> bool {
867 self.tool_expanded
868 }
869
870 /// Return the active paste indicator state, if any.
871 ///
872 /// `Some` when a multiline paste is in the input buffer and no edit
873 /// keypress has occurred since the paste. `None` otherwise.
874 #[must_use]
875 pub fn paste_state(&self) -> Option<&PasteState> {
876 self.sessions.current().paste_state.as_ref()
877 }
878
879 /// Return the current tool-output density level.
880 #[must_use]
881 pub fn tool_density(&self) -> ToolDensity {
882 self.tool_density
883 }
884
885 /// Return `true` when source-label badges are shown on assistant messages.
886 #[must_use]
887 pub fn show_source_labels(&self) -> bool {
888 self.show_source_labels
889 }
890
891 /// Toggle source-label visibility.
892 ///
893 /// Clears the render cache so all messages are re-rendered with the new
894 /// setting on the next frame.
895 pub fn set_show_source_labels(&mut self, v: bool) {
896 if self.show_source_labels != v {
897 self.show_source_labels = v;
898 self.sessions.current_mut().render_cache.clear();
899 }
900 }
901
902 /// Return `true` when the Cocoon TON balance should be shown in the status bar.
903 ///
904 /// Controlled by `[cocoon] show_balance` in config (default `true`). When `false`,
905 /// the balance is redacted to `*** TON` per spec §15.2.
906 #[must_use]
907 pub fn show_balance(&self) -> bool {
908 self.show_balance
909 }
910
911 /// Set whether the Cocoon TON balance is shown in the status bar.
912 pub fn set_show_balance(&mut self, v: bool) {
913 self.show_balance = v;
914 }
915
916 /// Replace the current hyperlink span list with `links`.
917 ///
918 /// Called by the render loop after each frame to store spans detected in
919 /// the terminal buffer so they can be emitted as OSC 8 sequences.
920 pub fn set_hyperlinks(&mut self, links: Vec<HyperlinkSpan>) {
921 self.hyperlinks = links;
922 }
923
924 /// Take ownership of the accumulated hyperlink spans, clearing the list.
925 ///
926 /// Called once per frame; the caller writes OSC 8 sequences to the terminal.
927 pub fn take_hyperlinks(&mut self) -> Vec<HyperlinkSpan> {
928 std::mem::take(&mut self.hyperlinks)
929 }
930
931 /// Return the current raw activity status label, if any.
932 ///
933 /// This is the internal label as set by the agent loop (e.g.
934 /// `"Searching memory…"`, `"Executing tool: bash"`), not yet transformed
935 /// for display. The status bar passes it through
936 /// [`crate::widgets::status_verbs::humanize`] before rendering it next to
937 /// the spinner; other consumers (logs, debug output) use the raw form.
938 #[must_use]
939 pub fn status_label(&self) -> Option<&str> {
940 self.sessions.current().status_label.as_deref()
941 }
942
943 /// Return the persistent "Resuming session" banner text, if a non-empty prior
944 /// conversation was resumed at startup (spec-068 §13.5). `None` for a fresh
945 /// conversation — render nothing in that case (AC-16).
946 #[must_use]
947 pub fn resume_banner(&self) -> Option<&str> {
948 self.resume_banner.as_deref()
949 }
950
951 /// Return the number of messages queued or pending for the agent.
952 ///
953 /// Displayed in the input bar to indicate backpressure.
954 #[must_use]
955 pub fn queued_count(&self) -> usize {
956 self.queued_count.max(self.pending_count)
957 }
958
959 /// Return the projected context token count from the last assembly, or 0 if not yet known.
960 ///
961 /// The value is approximate (character-level heuristic) and is updated once per agent turn.
962 ///
963 /// # Examples
964 ///
965 /// ```rust
966 /// use tokio::sync::mpsc;
967 /// use zeph_tui::App;
968 ///
969 /// let (tx, _) = mpsc::channel(1);
970 /// let (_, rx) = mpsc::channel(1);
971 /// let app = App::new(tx, rx);
972 /// assert_eq!(app.context_token_estimate(), 0);
973 /// ```
974 #[must_use]
975 pub fn context_token_estimate(&self) -> usize {
976 self.context_token_estimate
977 }
978
979 /// Return `true` when the user is currently editing a queued message.
980 #[must_use]
981 pub fn editing_queued(&self) -> bool {
982 self.editing_queued
983 }
984
985 /// Return `true` when the agent is actively processing (streaming or running a tool).
986 ///
987 /// Used by the render loop to decide whether to show the activity spinner.
988 #[must_use]
989 pub fn is_agent_busy(&self) -> bool {
990 self.sessions.current().status_label.is_some()
991 || self
992 .sessions
993 .current()
994 .messages
995 .last()
996 .is_some_and(|m| m.streaming)
997 }
998
999 /// Return `true` when the last message is a streaming tool output.
1000 #[must_use]
1001 pub fn has_running_tool(&self) -> bool {
1002 self.sessions
1003 .current()
1004 .messages
1005 .last()
1006 .is_some_and(|m| m.role == MessageRole::Tool && m.streaming)
1007 }
1008
1009 /// Return a reference to the throbber animation state.
1010 ///
1011 /// Used by the status widget to render the spinner frame.
1012 #[must_use]
1013 pub fn throbber_state(&self) -> &throbber_widgets_tui::ThrobberState {
1014 &self.throbber_state
1015 }
1016
1017 /// Return a mutable reference to the throbber animation state.
1018 ///
1019 /// Called by the tick handler to advance the spinner frame each tick.
1020 pub fn throbber_state_mut(&mut self) -> &mut throbber_widgets_tui::ThrobberState {
1021 &mut self.throbber_state
1022 }
1023
1024 /// Toggle the collapsed state of a side-panel section by index.
1025 ///
1026 /// Index mapping: `0` = Skills, `1` = Memory, `2` = Resources, `3` = `SubAgents`.
1027 /// Out-of-range indices are silently ignored.
1028 ///
1029 /// # Examples
1030 ///
1031 /// ```rust
1032 /// use tokio::sync::mpsc;
1033 /// use zeph_tui::App;
1034 ///
1035 /// let (tx, _) = mpsc::channel(1);
1036 /// let (_, rx) = mpsc::channel(1);
1037 /// let mut app = App::new(tx, rx);
1038 /// app.toggle_panel_collapse(0);
1039 /// assert!(app.collapsed_panels()[0]);
1040 /// app.toggle_panel_collapse(0);
1041 /// assert!(!app.collapsed_panels()[0]);
1042 /// ```
1043 pub fn toggle_panel_collapse(&mut self, idx: usize) {
1044 if let Some(slot) = self.collapsed_panels.get_mut(idx) {
1045 *slot = !*slot;
1046 }
1047 }
1048
1049 /// Return the current per-section collapse mask.
1050 ///
1051 /// Index mapping: `0` = Skills, `1` = Memory, `2` = Resources, `3` = `SubAgents`.
1052 ///
1053 /// # Examples
1054 ///
1055 /// ```rust
1056 /// use tokio::sync::mpsc;
1057 /// use zeph_tui::App;
1058 ///
1059 /// let (tx, _) = mpsc::channel(1);
1060 /// let (_, rx) = mpsc::channel(1);
1061 /// let app = App::new(tx, rx);
1062 /// assert_eq!(app.collapsed_panels(), [false; 4]);
1063 /// ```
1064 #[must_use]
1065 pub fn collapsed_panels(&self) -> [bool; 4] {
1066 self.collapsed_panels
1067 }
1068
1069 /// Compute the effective collapse mask used for layout and rendering.
1070 ///
1071 /// Index 3 (`SubAgents` slot) is forced expanded when any overlay currently
1072 /// owns that slot — Fleet, Durable, Tasks, plan view, or security events.
1073 /// Indices 0–2 pass through the raw `collapsed_panels` value unchanged.
1074 ///
1075 /// # Examples
1076 ///
1077 /// ```rust
1078 /// use tokio::sync::mpsc;
1079 /// use zeph_tui::App;
1080 ///
1081 /// let (tx, _) = mpsc::channel(1);
1082 /// let (_, rx) = mpsc::channel(1);
1083 /// let mut app = App::new(tx, rx);
1084 /// // Collapsing slot 3 is honoured when no overlay is active.
1085 /// app.toggle_panel_collapse(3);
1086 /// assert!(app.effective_collapsed()[3]);
1087 /// ```
1088 #[must_use]
1089 pub fn effective_collapsed(&self) -> [bool; 4] {
1090 let mut eff = self.collapsed_panels;
1091 // Force-expand slot 3 whenever an overlay is rendering into the subagents rect.
1092 let slot3_has_overlay = matches!(
1093 self.active_panel,
1094 Panel::SubAgents | Panel::Fleet | Panel::Durable | Panel::Settings
1095 ) || self.show_task_panel
1096 || self
1097 .metrics
1098 .orchestration_graph
1099 .as_ref()
1100 .is_some_and(|s| !s.is_stale() && !self.sessions.current().plan_view_active)
1101 || self.has_recent_security_events();
1102 if slot3_has_overlay {
1103 eff[3] = false;
1104 }
1105 eff
1106 }
1107
1108 /// Returns the number of rows in the settings view's currently active tab
1109 /// (issue #6024), used to clamp `Action::SettingsSelectMove` navigation.
1110 pub(crate) fn settings_active_tab_len(&self) -> usize {
1111 match self.settings.tab {
1112 crate::widgets::settings::SettingsTab::Providers => self.metrics.providers.len(),
1113 crate::widgets::settings::SettingsTab::Mcp => self.metrics.mcp_servers.len(),
1114 crate::widgets::settings::SettingsTab::Agents => self.metrics.agent_definitions.len(),
1115 }
1116 }
1117
1118 /// Sets `active_panel`, keeping `show_task_panel` in sync so at most one
1119 /// panel/overlay ever claims `render_subagents_slot`'s shared `Rect` per frame (#6061).
1120 ///
1121 /// `SubAgents`, `Fleet`, and `Durable` all render into that Rect (`SubAgents` as the
1122 /// interactive base layer with live key routing, `Fleet`/`Durable` as overlays on top of
1123 /// it) — activating any of them clears `show_task_panel` so the task-panel overlay can't
1124 /// silently cover live content or a hidden-but-still-key-routed sub-agent sidebar.
1125 /// `Tasks` is the task panel's own marker value and sets `show_task_panel` back on.
1126 /// `Chat`/`Skills`/`Memory`/`Resources` render in unrelated areas and are left alone —
1127 /// the task panel may keep overlaying the (non-interactive) default baseline there.
1128 ///
1129 /// All call sites that change `active_panel` (`Action::SetActivePanel`,
1130 /// `Action::CyclePanelFocus`, `TuiCommand::FleetPanel`/`DurablePanel`) must go through
1131 /// this method rather than assigning the field directly, so the invariant holds
1132 /// regardless of which input path triggered the change.
1133 pub(crate) fn set_active_panel(&mut self, p: Panel) {
1134 self.active_panel = p;
1135 match p {
1136 Panel::Tasks => self.show_task_panel = true,
1137 Panel::SubAgents | Panel::Fleet | Panel::Durable | Panel::Settings => {
1138 self.show_task_panel = false;
1139 }
1140 Panel::Chat | Panel::Skills | Panel::Memory | Panel::Resources => {}
1141 }
1142 }
1143
1144 /// Configure the animation budget from config.
1145 ///
1146 /// # Examples
1147 ///
1148 /// ```rust
1149 /// use tokio::sync::mpsc;
1150 /// use zeph_config::Motion;
1151 /// use zeph_tui::App;
1152 ///
1153 /// let (user_tx, _) = mpsc::channel(1);
1154 /// let (_, agent_rx) = mpsc::channel(1);
1155 /// let app = App::new(user_tx, agent_rx).with_motion(Motion::Minimal);
1156 /// assert_eq!(app.motion(), Motion::Minimal);
1157 /// ```
1158 #[must_use]
1159 pub fn with_motion(mut self, motion: zeph_config::Motion) -> Self {
1160 self.motion = motion;
1161 self
1162 }
1163
1164 /// Return the current animation budget.
1165 #[must_use]
1166 pub fn motion(&self) -> zeph_config::Motion {
1167 self.motion
1168 }
1169
1170 /// Return the monotonic wave-tick counter.
1171 ///
1172 /// Passed as `t` into [`crate::widgets::wave::sample`] / [`crate::widgets::wave::glyphs`].
1173 #[must_use]
1174 pub fn wave_tick(&self) -> u64 {
1175 self.wave_tick
1176 }
1177
1178 /// Advance the wave animation clock by one tick.
1179 ///
1180 /// Called from the render loop's internal interval as an animation heartbeat
1181 /// that is independent of the `EventReader`'s `AppEvent::Tick`s, so the
1182 /// equalizer keeps moving even when the event channel is briefly starved by a
1183 /// streaming burst. Only the wave counter is advanced here — the throbber and
1184 /// micro-delights stay driven by `AppEvent::Tick`.
1185 pub fn advance_wave_tick(&mut self) {
1186 self.wave_tick = self.wave_tick.saturating_add(1);
1187 }
1188
1189 /// Apply micro-delight configuration (#5104).
1190 ///
1191 /// Called at construction time from `tui_bridge` to propagate `[tui.delights]` config.
1192 ///
1193 /// # Examples
1194 ///
1195 /// ```rust
1196 /// use tokio::sync::mpsc;
1197 /// use zeph_tui::App;
1198 /// use zeph_config::DelightsConfig;
1199 ///
1200 /// let (tx, _) = mpsc::channel(1);
1201 /// let (_, rx) = mpsc::channel(1);
1202 /// let app = App::new(tx, rx).with_delights(DelightsConfig::default());
1203 /// ```
1204 #[must_use]
1205 pub fn with_delights(mut self, delights: zeph_config::DelightsConfig) -> Self {
1206 self.delights = delights;
1207 self
1208 }
1209
1210 /// Return the current animation tick counter.
1211 ///
1212 /// Aliased from `wave_tick` so animation code can read it by an intent-revealing name.
1213 /// Free-running at ~10fps (100ms/tick via `EventReader`). Never pauses.
1214 ///
1215 /// # Examples
1216 ///
1217 /// ```rust
1218 /// use tokio::sync::mpsc;
1219 /// use zeph_tui::App;
1220 ///
1221 /// let (tx, _) = mpsc::channel(1);
1222 /// let (_, rx) = mpsc::channel(1);
1223 /// let app = App::new(tx, rx);
1224 /// assert_eq!(app.anim_tick(), 0);
1225 /// ```
1226 #[must_use]
1227 pub fn anim_tick(&self) -> u64 {
1228 self.wave_tick
1229 }
1230
1231 /// Begin an animated scroll to `target_offset` for the current session.
1232 ///
1233 /// When smooth-scroll is disabled (`motion = Off` or `delights.smooth_scroll = false`),
1234 /// the offset is set directly. Single-line scrolls (j/k) bypass this and write
1235 /// `scroll_offset` directly — animation is reserved for page-sized jumps.
1236 pub(crate) fn begin_scroll(&mut self, target_offset: usize) {
1237 let smooth = self.motion != zeph_config::Motion::Off && self.delights.smooth_scroll;
1238 if smooth {
1239 // Use the in-flight animation's destination as the starting point so that
1240 // two rapid PageDown presses chain correctly instead of producing identical
1241 // animations from the same stale scroll_offset.
1242 let cur = self.sessions.current();
1243 let from = cur.scroll_anim.as_ref().map_or(cur.scroll_offset, |a| a.to);
1244 let now = self.anim_tick();
1245 self.sessions.current_mut().scroll_anim = Some(crate::session::ScrollAnim {
1246 from,
1247 to: target_offset,
1248 start_tick: now,
1249 });
1250 } else {
1251 self.sessions.current_mut().scroll_offset = target_offset;
1252 }
1253 }
1254
1255 /// Enqueue an ephemeral toast notification.
1256 ///
1257 /// **MUST** be called only from the render thread (inside `handle_event` /
1258 /// `handle_agent_event`). Off-thread origins must be routed as `AgentEvent` or
1259 /// `AppEvent` variants — never mutate the queue cross-thread.
1260 pub(crate) fn push_toast(&mut self, text: impl Into<String>, kind: crate::delights::ToastKind) {
1261 let tick = self.anim_tick();
1262 self.toasts.push(text, kind, tick);
1263 }
1264
1265 /// Whether any animation-driven feature is currently active.
1266 ///
1267 /// Provided as an optional future hook for a deferred CPU-optimization issue
1268 /// (suppress idle redraws when nothing animates). NOT wired to the redraw gate
1269 /// in this PR — the `EventReader` already drives 10fps unconditionally.
1270 #[must_use]
1271 pub fn wants_animation_frame(&self) -> bool {
1272 if self.motion == zeph_config::Motion::Off {
1273 return false;
1274 }
1275 let t = self.anim_tick();
1276 let flash_active = self
1277 .sessions
1278 .current()
1279 .flash
1280 .pending
1281 .values()
1282 .any(|&born| t.saturating_sub(born) < crate::session::FLASH_TICKS);
1283 let scroll_active = self.sessions.current().scroll_anim.is_some();
1284 self.toasts.has_active(t)
1285 || flash_active
1286 || scroll_active
1287 || self.splash_shimmer.is_active(t)
1288 }
1289
1290 /// Derive the current wave animation state from live agent state.
1291 ///
1292 /// Stalled is checked first so a hung turn never reads as Streaming or Swell.
1293 ///
1294 /// # Stall behaviour
1295 ///
1296 /// A slow time-to-first-token > `stall_threshold` shows `Stalled` before any token
1297 /// arrives, because `last_progress_at` is set when the turn goes busy (Typing/Status)
1298 /// and the threshold starts counting from that moment. Accepted for v1 simplicity.
1299 #[must_use]
1300 pub fn wave_state(&self) -> crate::widgets::wave::WaveState {
1301 use crate::widgets::wave::WaveState;
1302
1303 let foreground = self.is_agent_busy();
1304 let bg = self.background_inflight();
1305
1306 // Nothing running at all → flat baseline.
1307 if !foreground && bg == 0 {
1308 return WaveState::Idle;
1309 }
1310
1311 // Stalled: a foreground turn with no progress past the threshold. Checked
1312 // before background so a genuinely hung turn still surfaces the warning.
1313 if foreground && self.last_progress_at.elapsed() > STALL_THRESHOLD {
1314 return WaveState::Stalled;
1315 }
1316
1317 // Foreground tool execution takes priority over background requests.
1318 if foreground && self.has_running_tool() {
1319 return WaveState::Tool;
1320 }
1321
1322 // External/background requests (task-supervisor work: enrichment, telemetry,
1323 // MCP, egress, background shell). Rendered in violet so concurrent background
1324 // activity is visually distinct from the agent's own foreground turn.
1325 if bg >= 1 {
1326 #[allow(clippy::cast_possible_truncation)]
1327 return WaveState::Network {
1328 sines: (bg as u8).clamp(1, 3),
1329 };
1330 }
1331
1332 // Streaming: last message is a streaming assistant message.
1333 if self
1334 .sessions
1335 .current()
1336 .messages
1337 .last()
1338 .is_some_and(|m| m.streaming && m.role == crate::types::MessageRole::Assistant)
1339 {
1340 return WaveState::Streaming;
1341 }
1342
1343 // Swell: busy but awaiting first token.
1344 WaveState::Swell
1345 }
1346
1347 /// Count in-flight background/external requests for the wave equalizer.
1348 ///
1349 /// Combines the task-supervisor inflight gauge (`bg_inflight` — all classes,
1350 /// already includes enrichment + telemetry) with in-flight background shell
1351 /// runs. Used by [`Self::wave_state`] to drive the violet `Network` wave and
1352 /// by the draw loop to keep the equalizer visible while background work runs
1353 /// even when the agent itself is idle.
1354 #[must_use]
1355 pub fn background_inflight(&self) -> u64 {
1356 self.metrics.bg_inflight + self.metrics.shell_background_runs.len() as u64
1357 }
1358
1359 /// Advance all micro-delight animations by one tick.
1360 ///
1361 /// Called from [`crate::app::events`] on every `AppEvent::Tick` so that
1362 /// animation state advances unconditionally, regardless of whether a draw
1363 /// frame is suppressed by `DirtyState::AnimationOnly`.
1364 pub(crate) fn tick_delights(&mut self) {
1365 let now = self.anim_tick();
1366
1367 // Prune expired toasts.
1368 self.toasts.prune(now);
1369
1370 // Advance current session's scroll animation.
1371 if let Some(ref anim) = self.sessions.current().scroll_anim {
1372 let (offset, done) = anim.current_offset(now);
1373 self.sessions.current_mut().scroll_offset = offset;
1374 if done {
1375 self.sessions.current_mut().scroll_anim = None;
1376 }
1377 }
1378
1379 // Prune expired flash entries for the current session.
1380 self.sessions.current_mut().flash.prune(now);
1381
1382 // Detect show_splash rising edge (false → true) → reset shimmer for fresh sweep.
1383 let cur_show_splash = self.sessions.current().show_splash;
1384 if cur_show_splash && !self.sessions.current().prev_show_splash {
1385 self.splash_shimmer.reset();
1386 }
1387 self.sessions.current_mut().prev_show_splash = cur_show_splash;
1388
1389 // Activate shimmer on first splash frame.
1390 let shimmer_enabled =
1391 self.motion != zeph_config::Motion::Off && self.delights.splash_shimmer;
1392 if shimmer_enabled && cur_show_splash {
1393 self.splash_shimmer.activate(now);
1394 }
1395 }
1396
1397 // ── Mouse mode (#5103) ────────────────────────────────────────────────────
1398
1399 /// Enable or disable opt-in mouse capture at startup.
1400 ///
1401 /// Called from the builder chain in `tui_bridge` when `config.tui.mouse` is `true`.
1402 /// Actual terminal-level capture is enabled **after** the first frame is drawn
1403 /// (C3 — avoid delivering mouse events before `last_layout` is populated).
1404 ///
1405 /// # Examples
1406 ///
1407 /// ```rust
1408 /// use tokio::sync::mpsc;
1409 /// use zeph_tui::App;
1410 ///
1411 /// let (tx, _) = mpsc::channel(1);
1412 /// let (_, rx) = mpsc::channel(1);
1413 /// let app = App::new(tx, rx).with_mouse(true);
1414 /// assert!(app.mouse_enabled());
1415 /// ```
1416 #[must_use]
1417 pub fn with_mouse(mut self, enabled: bool) -> Self {
1418 self.mouse_enabled = enabled;
1419 self
1420 }
1421
1422 /// Return `true` when opt-in mouse capture is currently active.
1423 ///
1424 /// # Examples
1425 ///
1426 /// ```rust
1427 /// use tokio::sync::mpsc;
1428 /// use zeph_tui::App;
1429 ///
1430 /// let (tx, _) = mpsc::channel(1);
1431 /// let (_, rx) = mpsc::channel(1);
1432 /// let app = App::new(tx, rx);
1433 /// assert!(!app.mouse_enabled());
1434 /// ```
1435 #[must_use]
1436 pub fn mouse_enabled(&self) -> bool {
1437 self.mouse_enabled
1438 }
1439
1440 /// Drain any pending mouse-capture toggle and return it.
1441 ///
1442 /// Returns `Some(true)` to enable capture, `Some(false)` to disable, or `None`
1443 /// if no toggle is pending.
1444 ///
1445 /// Called by `tui_loop` in the shared post-select block after every event arm
1446 /// (C2 — not inside an individual arm to avoid ordering hazards).
1447 pub(crate) fn take_mouse_capture_request(&mut self) -> Option<bool> {
1448 self.pending_mouse_capture.take()
1449 }
1450
1451 // ── Pub(crate) helpers for the reducer ──────────────────────────────────
1452
1453 /// Push a system message visible in the chat area (public(crate) forwarding wrapper).
1454 pub(crate) fn push_system_message_pub(&mut self, content: String) {
1455 self.sessions.current_mut().show_splash = false;
1456 self.sessions
1457 .current_mut()
1458 .messages
1459 .push(crate::ChatMessage::new(crate::MessageRole::System, content));
1460 self.sessions.current_mut().scroll_offset = 0;
1461 }
1462
1463 /// Return the content of the last assistant message (pub(crate) for reducer).
1464 pub(crate) fn last_assistant_content_pub(&self) -> Option<String> {
1465 self.sessions
1466 .current()
1467 .messages
1468 .iter()
1469 .rev()
1470 .find(|m| m.role == crate::MessageRole::Assistant)
1471 .map(|m| m.content.clone())
1472 }
1473
1474 /// Extract all fenced code blocks from the last assistant message (pub(crate) for reducer).
1475 pub(crate) fn last_assistant_code_blocks_pub(&self) -> Vec<String> {
1476 use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
1477 let Some(content) = self.last_assistant_content_pub() else {
1478 return Vec::new();
1479 };
1480 let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES;
1481 let parser = Parser::new_ext(&content, options);
1482 let mut blocks: Vec<String> = Vec::new();
1483 let mut current: Option<String> = None;
1484 for event in parser {
1485 match event {
1486 Event::Start(Tag::CodeBlock(
1487 CodeBlockKind::Fenced(_) | CodeBlockKind::Indented,
1488 )) => {
1489 current = Some(String::new());
1490 }
1491 Event::Text(text) => {
1492 if let Some(ref mut buf) = current {
1493 buf.push_str(&text);
1494 }
1495 }
1496 Event::End(TagEnd::CodeBlock) => {
1497 if let Some(buf) = current.take() {
1498 blocks.push(buf);
1499 }
1500 }
1501 _ => {}
1502 }
1503 }
1504 if let Some(buf) = current
1505 && !buf.is_empty()
1506 {
1507 blocks.push(buf);
1508 }
1509 blocks
1510 }
1511}
1512
1513#[cfg(test)]
1514mod tests {
1515 use tokio::sync::mpsc;
1516
1517 use super::{App, Panel};
1518
1519 fn make_app() -> App {
1520 let (user_tx, _) = mpsc::channel(1);
1521 let (_, agent_rx) = mpsc::channel(1);
1522 App::new(user_tx, agent_rx)
1523 }
1524
1525 #[test]
1526 fn apply_theme_path_traversal_rejected() {
1527 let mut app = make_app();
1528 assert!(
1529 app.apply_theme("../../etc/passwd").is_err(),
1530 "path traversal must be rejected"
1531 );
1532 assert!(
1533 app.apply_theme("bad..name").is_err(),
1534 "dotdot in name must be rejected"
1535 );
1536 assert!(app.apply_theme("").is_err(), "empty name must be rejected");
1537 // Theme must remain unchanged after all failed attempts.
1538 assert_eq!(app.active_theme_name(), "zephyr");
1539 }
1540
1541 #[test]
1542 fn apply_theme_valid_bumps_generation() {
1543 let mut app = make_app();
1544 let gen_before = app.theme_generation();
1545 app.apply_theme("zephyr-light").expect("valid theme");
1546 assert!(
1547 app.theme_generation() > gen_before,
1548 "generation must increment"
1549 );
1550 assert_eq!(app.active_theme_name(), "zephyr-light");
1551 }
1552
1553 #[test]
1554 fn apply_theme_invalidates_all_session_caches() {
1555 use crate::app::RenderCacheKey;
1556 use crate::widgets::tool_view::ToolDensity;
1557
1558 let mut app = make_app();
1559
1560 // Add a second session (pub(crate) — accessible within the same crate).
1561 let _slot2_key = app.sessions.create("session 2");
1562
1563 // Populate the render cache of the current (first) session.
1564 let dummy_key = RenderCacheKey {
1565 content_hash: 1,
1566 terminal_width: 80,
1567 tool_expanded: false,
1568 tool_density: ToolDensity::Inline,
1569 show_labels: false,
1570 theme_generation: 0,
1571 };
1572 app.sessions
1573 .current_mut()
1574 .render_cache
1575 .put(0, dummy_key, vec![], vec![]);
1576
1577 // Verify the entry is present before the theme swap.
1578 let hit_before = app.sessions.current().render_cache.get(0, &dummy_key);
1579 assert!(hit_before.is_some(), "cache must contain the seeded entry");
1580
1581 // Swap theme → must clear caches in ALL sessions.
1582 app.apply_theme("zephyr-light").expect("valid theme");
1583
1584 // After the swap the key has a stale theme_generation, so get() returns None.
1585 let hit_after = app.sessions.current().render_cache.get(0, &dummy_key);
1586 assert!(
1587 hit_after.is_none(),
1588 "cache must be cleared (or invalidated) on theme swap"
1589 );
1590 }
1591
1592 #[test]
1593 fn with_theme_name_builder_sets_name() {
1594 let (user_tx, _) = mpsc::channel(1);
1595 let (_, agent_rx) = mpsc::channel(1);
1596 let app = App::new(user_tx, agent_rx).with_theme_name("gruvbox-dark");
1597 assert_eq!(app.active_theme_name(), "gruvbox-dark");
1598 }
1599
1600 #[test]
1601 fn with_remote_daemon_url_builder_sets_url() {
1602 let (user_tx, _) = mpsc::channel(1);
1603 let (_, agent_rx) = mpsc::channel(1);
1604 let app = App::new(user_tx, agent_rx).with_remote_daemon_url("http://localhost:8765");
1605 assert_eq!(app.remote_daemon_url(), Some("http://localhost:8765"));
1606 }
1607
1608 #[test]
1609 fn remote_daemon_url_defaults_to_none() {
1610 let app = make_app();
1611 assert_eq!(app.remote_daemon_url(), None);
1612 }
1613
1614 // ── #5984 status_label lifecycle around theme load ─────────────────────────
1615
1616 #[test]
1617 fn apply_theme_preset_does_not_touch_status_label() {
1618 // Built-in presets apply synchronously — there is no background load, so no
1619 // "loading theme..." indicator should ever appear for this path.
1620 let mut app = make_app();
1621 app.apply_theme("zephyr-light").expect("valid preset");
1622 assert_eq!(app.status_label(), None);
1623 }
1624
1625 #[tokio::test]
1626 async fn apply_theme_user_file_sets_status_label_before_dispatch() {
1627 // A name that is not a built-in preset takes the user-file branch, which offloads
1628 // to spawn_blocking (requires a Tokio runtime). status_label must be set
1629 // synchronously, before the background task can possibly complete, so the
1630 // spinner is visible immediately (#5984).
1631 let mut app = make_app();
1632 let result = app.apply_theme("my-custom-theme");
1633 assert!(
1634 matches!(result, Ok(false)),
1635 "user-file branch defers via Ok(false)"
1636 );
1637 assert_eq!(
1638 app.status_label(),
1639 Some("loading theme..."),
1640 "status_label must be set before the async dispatch, not after"
1641 );
1642 }
1643
1644 #[test]
1645 fn poll_pending_theme_clears_status_label_on_success() {
1646 let mut app = make_app();
1647 app.sessions.current_mut().status_label = Some("loading theme...".to_owned());
1648 let (tx, rx) = tokio::sync::oneshot::channel();
1649 app.pending_theme = Some(rx);
1650 app.pending_theme_name = Some("custom".to_owned());
1651 tx.send(Ok(crate::theme::presets::Preset::Zephyr.palette()))
1652 .expect("receiver still open");
1653
1654 app.poll_pending_theme();
1655
1656 assert_eq!(app.status_label(), None);
1657 assert!(app.pending_theme.is_none());
1658 }
1659
1660 #[test]
1661 fn poll_pending_theme_clears_status_label_on_load_error() {
1662 // The Ok(result) branch covers both success and a load error inside the Result —
1663 // status_label must be cleared in both sub-cases, not only on success.
1664 let mut app = make_app();
1665 app.sessions.current_mut().status_label = Some("loading theme...".to_owned());
1666 let (tx, rx) = tokio::sync::oneshot::channel();
1667 app.pending_theme = Some(rx);
1668 app.pending_theme_name = Some("custom".to_owned());
1669 tx.send(Err(crate::theme::ThemeLoadError::UnsafeName(
1670 "custom".to_owned(),
1671 )))
1672 .expect("receiver still open");
1673
1674 app.poll_pending_theme();
1675
1676 assert_eq!(app.status_label(), None);
1677 assert!(app.pending_theme.is_none());
1678 }
1679
1680 #[test]
1681 fn poll_pending_theme_clears_status_label_when_task_panics() {
1682 // Closed branch: the spawn_blocking task dropped its sender without sending
1683 // (e.g. panicked) — status_label must not be left stuck on "loading theme...".
1684 let mut app = make_app();
1685 app.sessions.current_mut().status_label = Some("loading theme...".to_owned());
1686 let (tx, rx) = tokio::sync::oneshot::channel::<
1687 Result<crate::theme::SemanticPalette, crate::theme::ThemeLoadError>,
1688 >();
1689 app.pending_theme = Some(rx);
1690 app.pending_theme_name = Some("custom".to_owned());
1691 drop(tx);
1692
1693 app.poll_pending_theme();
1694
1695 assert_eq!(app.status_label(), None);
1696 assert!(app.pending_theme.is_none());
1697 assert!(app.pending_theme_name.is_none());
1698 }
1699
1700 #[test]
1701 fn poll_pending_theme_is_noop_while_still_pending() {
1702 let mut app = make_app();
1703 app.sessions.current_mut().status_label = Some("loading theme...".to_owned());
1704 let (_tx, rx) = tokio::sync::oneshot::channel();
1705 app.pending_theme = Some(rx);
1706
1707 app.poll_pending_theme();
1708
1709 // Not ready yet (TryRecvError::Empty) — status_label must remain set.
1710 assert_eq!(app.status_label(), Some("loading theme..."));
1711 assert!(app.pending_theme.is_some());
1712 }
1713
1714 // ── #5984 apply_theme(preset) cancellation must not strand status_label ────
1715
1716 #[test]
1717 fn apply_theme_preset_clears_status_label_when_cancelling_pending_user_load() {
1718 // Reproduces the stuck-spinner bug: a user-file load is in flight (status_label =
1719 // "loading theme..."), then the user picks a built-in preset before it resolves.
1720 // The preset path cancels pending_theme, but poll_pending_theme (the only other
1721 // clearer) will now never run again — apply_theme itself must clear the label.
1722 let mut app = make_app();
1723 let (_tx, rx) = tokio::sync::oneshot::channel();
1724 app.pending_theme = Some(rx);
1725 app.pending_theme_name = Some("my-custom-theme".to_owned());
1726 app.sessions.current_mut().status_label = Some("loading theme...".to_owned());
1727
1728 app.apply_theme("zephyr-light").expect("valid preset");
1729
1730 assert!(
1731 app.pending_theme.is_none(),
1732 "pending load must be cancelled"
1733 );
1734 assert_eq!(
1735 app.status_label(),
1736 None,
1737 "cancelling the in-flight theme load must clear its status_label"
1738 );
1739 }
1740
1741 #[test]
1742 fn apply_theme_preset_preserves_unrelated_status_label_when_nothing_pending() {
1743 // Negative case for the same fix: if there is no pending_theme in flight, an
1744 // unrelated status_label (e.g. from a concurrent file index) must survive a
1745 // preset switch — the clear is conditioned on an actual cancellation happening.
1746 let mut app = make_app();
1747 assert!(app.pending_theme.is_none());
1748 app.sessions.current_mut().status_label = Some("indexing files...".to_owned());
1749
1750 app.apply_theme("zephyr-light").expect("valid preset");
1751
1752 assert_eq!(
1753 app.status_label(),
1754 Some("indexing files..."),
1755 "unrelated status_label must not be wiped by a preset switch with no \
1756 in-flight theme load to cancel"
1757 );
1758 }
1759
1760 // ── #6061 set_active_panel is the single source of truth for the invariant ─
1761
1762 #[test]
1763 fn set_active_panel_tasks_shows_task_panel() {
1764 let mut app = make_app();
1765 app.set_active_panel(Panel::Tasks);
1766 assert_eq!(app.active_panel, Panel::Tasks);
1767 assert!(app.show_task_panel);
1768 }
1769
1770 #[test]
1771 fn set_active_panel_subagents_hides_task_panel() {
1772 // SubAgents renders as the interactive base layer of the shared Rect (not just an
1773 // overlay like Fleet/Durable), so it must also displace the task panel.
1774 let mut app = make_app();
1775 app.show_task_panel = true;
1776 app.set_active_panel(Panel::SubAgents);
1777 assert_eq!(app.active_panel, Panel::SubAgents);
1778 assert!(!app.show_task_panel);
1779 }
1780
1781 #[test]
1782 fn set_active_panel_fleet_hides_task_panel() {
1783 let mut app = make_app();
1784 app.show_task_panel = true;
1785 app.set_active_panel(Panel::Fleet);
1786 assert!(!app.show_task_panel);
1787 }
1788
1789 #[test]
1790 fn set_active_panel_durable_hides_task_panel() {
1791 let mut app = make_app();
1792 app.show_task_panel = true;
1793 app.set_active_panel(Panel::Durable);
1794 assert!(!app.show_task_panel);
1795 }
1796
1797 #[test]
1798 fn set_active_panel_unrelated_panels_leave_task_panel_untouched() {
1799 // Chat/Skills/Memory/Resources render in unrelated areas — switching to them
1800 // must not incidentally toggle show_task_panel in either direction.
1801 let mut app = make_app();
1802 for panel in [Panel::Chat, Panel::Skills, Panel::Memory, Panel::Resources] {
1803 app.show_task_panel = true;
1804 app.set_active_panel(panel);
1805 assert!(
1806 app.show_task_panel,
1807 "{panel:?} must not clear show_task_panel"
1808 );
1809
1810 app.show_task_panel = false;
1811 app.set_active_panel(panel);
1812 assert!(
1813 !app.show_task_panel,
1814 "{panel:?} must not set show_task_panel"
1815 );
1816 }
1817 }
1818
1819 // ── #6420 backfill_history_display_only never pollutes input_history (AC-20) ─
1820
1821 #[test]
1822 fn backfill_history_display_only_populates_messages_not_input_history() {
1823 use zeph_commands::transcript::{TranscriptEntry, TranscriptRole};
1824
1825 let mut app = make_app();
1826 let entries = vec![
1827 TranscriptEntry {
1828 role: TranscriptRole::User,
1829 content: "hello".to_owned(),
1830 tool_name: None,
1831 },
1832 TranscriptEntry {
1833 role: TranscriptRole::Assistant,
1834 content: "hi there".to_owned(),
1835 tool_name: None,
1836 },
1837 TranscriptEntry {
1838 role: TranscriptRole::Tool,
1839 content: "file.txt".to_owned(),
1840 tool_name: Some("bash".to_owned()),
1841 },
1842 ];
1843
1844 app.backfill_history_display_only(&entries);
1845
1846 assert_eq!(
1847 app.sessions.current().messages.len(),
1848 3,
1849 "every backfilled entry must appear as its own chat message"
1850 );
1851 assert!(
1852 app.sessions.current().input_history.is_empty(),
1853 "backfill must never push into input_history — that would pollute up-arrow \
1854 recall with transcript text instead of genuinely-typed prior input (AC-20)"
1855 );
1856 }
1857}