Skip to main content

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}