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}