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