Skip to main content

zeph_tui/app/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4// TODO(arch-revised-2026-04-26): TUI Reducer/Action decomposition deferred to
5// epic/m49+/tui-reducer. Blocked until /specs/<area>/tui-reducer.md is authored
6// via /sdd. See arch-assessment-revised-2026-04-26T02-04-23.md PR 9.
7
8use std::sync::Arc;
9
10use tokio::sync::{Notify, mpsc, oneshot, watch};
11use tracing::debug;
12use zeph_common::task_supervisor::TaskSupervisor;
13
14use crate::command::TuiCommand;
15use crate::event::AgentEvent;
16use crate::file_picker::{FileIndex, FilePickerState};
17use crate::hyperlink::HyperlinkSpan;
18use crate::metrics::MetricsSnapshot;
19use crate::session::SessionRegistry;
20use crate::widgets::command_palette::CommandPaletteState;
21use crate::widgets::slash_autocomplete::SlashAutocompleteState;
22use crate::widgets::tool_view::ToolDensity;
23
24pub use crate::render_cache::{RenderCache, RenderCacheEntry, RenderCacheKey, content_hash};
25pub use crate::types::{ChatMessage, InputMode, MessageRole};
26
27use crate::types::PasteState;
28
29/// Maximum number of input history entries retained in the TUI (#2737).
30const MAX_INPUT_HISTORY: usize = 500;
31const MAX_VISIBLE_INPUT_LINES: u16 = 3;
32
33/// The currently focused side panel in the TUI layout.
34///
35/// Controls which panel receives keyboard focus for scrolling and navigation.
36///
37/// # Examples
38///
39/// ```rust
40/// use zeph_tui::app::Panel;
41///
42/// let panel = Panel::Chat;
43/// assert_eq!(panel, Panel::Chat);
44/// ```
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[non_exhaustive]
47pub enum Panel {
48    /// The main chat / transcript area.
49    Chat,
50    /// The skills mini-panel (side column).
51    Skills,
52    /// The semantic memory mini-panel (side column).
53    Memory,
54    /// The MCP resources mini-panel (side column).
55    Resources,
56    /// The sub-agents mini-panel (side column).
57    SubAgents,
58    /// The supervised task registry panel (side column).
59    Tasks,
60    /// The fleet session overview panel (side column).
61    Fleet,
62}
63
64/// Discriminates what the main chat area is currently displaying.
65///
66/// In `Main` mode the user sees their own conversation with the primary agent.
67/// In `SubAgent` mode the area shows the transcript of a spawned sub-agent.
68///
69/// # Examples
70///
71/// ```rust
72/// use zeph_tui::app::AgentViewTarget;
73///
74/// let target = AgentViewTarget::Main;
75/// assert!(target.is_main());
76///
77/// let sub = AgentViewTarget::SubAgent { id: "sa-1".into(), name: "Planner".into() };
78/// assert_eq!(sub.subagent_id(), Some("sa-1"));
79/// ```
80#[derive(Debug, Clone, PartialEq, Eq)]
81#[non_exhaustive]
82pub enum AgentViewTarget {
83    /// Displaying the main agent conversation.
84    Main,
85    /// Displaying the transcript of the named sub-agent.
86    SubAgent {
87        /// Stable sub-agent identifier (matches [`SubAgentMetrics::id`](crate::metrics::SubAgentMetrics)).
88        id: String,
89        /// Display name shown in the header bar.
90        name: String,
91    },
92}
93
94impl AgentViewTarget {
95    /// Returns `true` when the target is the primary agent conversation.
96    ///
97    /// # Examples
98    ///
99    /// ```rust
100    /// use zeph_tui::app::AgentViewTarget;
101    ///
102    /// assert!(AgentViewTarget::Main.is_main());
103    /// let sub = AgentViewTarget::SubAgent { id: "x".into(), name: "y".into() };
104    /// assert!(!sub.is_main());
105    /// ```
106    #[must_use]
107    pub fn is_main(&self) -> bool {
108        matches!(self, Self::Main)
109    }
110
111    /// Returns the sub-agent ID if this target points to a sub-agent, otherwise `None`.
112    ///
113    /// # Examples
114    ///
115    /// ```rust
116    /// use zeph_tui::app::AgentViewTarget;
117    ///
118    /// assert_eq!(AgentViewTarget::Main.subagent_id(), None);
119    /// let sub = AgentViewTarget::SubAgent { id: "sa-42".into(), name: "n".into() };
120    /// assert_eq!(sub.subagent_id(), Some("sa-42"));
121    /// ```
122    #[must_use]
123    pub fn subagent_id(&self) -> Option<&str> {
124        if let Self::SubAgent { id, .. } = self {
125            Some(id)
126        } else {
127            None
128        }
129    }
130
131    /// Returns the sub-agent display name if this target points to a sub-agent, otherwise `None`.
132    ///
133    /// # Examples
134    ///
135    /// ```rust
136    /// use zeph_tui::app::AgentViewTarget;
137    ///
138    /// assert_eq!(AgentViewTarget::Main.subagent_name(), None);
139    /// let sub = AgentViewTarget::SubAgent { id: "x".into(), name: "Planner".into() };
140    /// assert_eq!(sub.subagent_name(), Some("Planner"));
141    /// ```
142    #[must_use]
143    pub fn subagent_name(&self) -> Option<&str> {
144        if let Self::SubAgent { name, .. } = self {
145            Some(name)
146        } else {
147            None
148        }
149    }
150}
151
152/// A single entry from a sub-agent's JSONL transcript, ready for TUI display.
153///
154/// Loaded by the background transcript reader and converted to
155/// [`ChatMessage`] for rendering in the chat widget via
156/// [`to_chat_message`](Self::to_chat_message).
157///
158/// # Examples
159///
160/// ```rust
161/// use zeph_tui::app::TuiTranscriptEntry;
162///
163/// let entry = TuiTranscriptEntry {
164///     role: "assistant".to_string(),
165///     content: "I found 3 results.".to_string(),
166///     tool_name: None,
167///     timestamp: None,
168/// };
169/// let msg = entry.to_chat_message();
170/// ```
171#[derive(Debug, Clone)]
172pub struct TuiTranscriptEntry {
173    pub role: String,
174    pub content: String,
175    pub tool_name: Option<zeph_common::ToolName>,
176    pub timestamp: Option<String>,
177}
178
179impl TuiTranscriptEntry {
180    /// Convert this transcript entry to a [`ChatMessage`] for chat widget rendering.
181    ///
182    /// The `role` string is mapped to a [`MessageRole`]: `"user"`, `"assistant"`,
183    /// `"tool"`, or `"system"` for all other values. The optional `tool_name`
184    /// and `timestamp` fields are forwarded verbatim.
185    ///
186    /// # Examples
187    ///
188    /// ```rust
189    /// use zeph_tui::app::TuiTranscriptEntry;
190    /// use zeph_tui::MessageRole;
191    ///
192    /// let entry = TuiTranscriptEntry {
193    ///     role: "user".to_string(),
194    ///     content: "hello".to_string(),
195    ///     tool_name: None,
196    ///     timestamp: Some("14:30".to_string()),
197    /// };
198    /// let msg = entry.to_chat_message();
199    /// assert_eq!(msg.role, MessageRole::User);
200    /// assert_eq!(msg.timestamp, "14:30");
201    /// ```
202    #[must_use]
203    pub fn to_chat_message(&self) -> ChatMessage {
204        let role = match self.role.as_str() {
205            "user" => MessageRole::User,
206            "assistant" => MessageRole::Assistant,
207            "tool" => MessageRole::Tool,
208            _ => MessageRole::System,
209        };
210        let mut msg = ChatMessage::new(role, self.content.clone());
211        if let Some(ref name) = self.tool_name {
212            msg.tool_name = Some(name.clone());
213        }
214        if let Some(ref ts) = self.timestamp {
215            msg.timestamp.clone_from(ts);
216        }
217        msg
218    }
219}
220
221/// Cached transcript data for a single sub-agent session.
222///
223/// Populated by the background transcript loader and invalidated when
224/// `turns_used` in the metrics snapshot advances beyond `turns_at_load`.
225pub struct TranscriptCache {
226    /// The sub-agent ID this cache entry belongs to.
227    pub agent_id: String,
228    /// Parsed transcript entries (last `TRANSCRIPT_MAX_ENTRIES` entries).
229    pub entries: Vec<TuiTranscriptEntry>,
230    /// `turns_used` value at the time of last load, for staleness detection (W2).
231    pub turns_at_load: u32,
232    /// Total entries in file (before truncation to last N).
233    pub total_in_file: usize,
234}
235
236/// Selection and scroll state for the interactive sub-agent sidebar.
237///
238/// Wraps a ratatui [`ListState`](ratatui::widgets::ListState) with convenience
239/// helpers that clamp the selection to valid indices.
240///
241/// # Examples
242///
243/// ```rust
244/// use zeph_tui::app::SubAgentSidebarState;
245///
246/// let mut state = SubAgentSidebarState::new();
247/// state.select_next(3);
248/// assert_eq!(state.selected(), Some(0));
249/// ```
250pub struct SubAgentSidebarState {
251    /// Underlying ratatui list selection state.
252    pub list_state: ratatui::widgets::ListState,
253}
254
255impl SubAgentSidebarState {
256    /// Create a new sidebar state with no selection.
257    ///
258    /// # Examples
259    ///
260    /// ```rust
261    /// use zeph_tui::app::SubAgentSidebarState;
262    ///
263    /// let state = SubAgentSidebarState::new();
264    /// assert_eq!(state.selected(), None);
265    /// ```
266    #[must_use]
267    pub fn new() -> Self {
268        Self {
269            list_state: ratatui::widgets::ListState::default(),
270        }
271    }
272
273    /// Advance the selection to the next item, clamped to `count - 1`.
274    ///
275    /// A no-op when `count` is zero.
276    pub fn select_next(&mut self, count: usize) {
277        if count == 0 {
278            return;
279        }
280        let next = match self.list_state.selected() {
281            Some(i) => (i + 1).min(count - 1),
282            None => 0,
283        };
284        self.list_state.select(Some(next));
285    }
286
287    /// Move the selection to the previous item, clamped to `0`.
288    ///
289    /// A no-op when `count` is zero.
290    pub fn select_prev(&mut self, count: usize) {
291        if count == 0 {
292            return;
293        }
294        let prev = match self.list_state.selected() {
295            Some(0) | None => 0,
296            Some(i) => i - 1,
297        };
298        self.list_state.select(Some(prev));
299    }
300
301    /// Ensure the selection is valid given the current agent count.
302    pub fn clamp(&mut self, count: usize) {
303        if count == 0 {
304            self.list_state.select(None);
305        } else if self.list_state.selected().is_some_and(|i| i >= count) {
306            self.list_state.select(Some(count - 1));
307        }
308    }
309
310    /// Returns the currently selected index, or `None` if nothing is selected.
311    ///
312    /// # Examples
313    ///
314    /// ```rust
315    /// use zeph_tui::app::SubAgentSidebarState;
316    ///
317    /// let mut state = SubAgentSidebarState::new();
318    /// assert_eq!(state.selected(), None);
319    /// state.select_next(5);
320    /// assert_eq!(state.selected(), Some(0));
321    /// ```
322    #[must_use]
323    pub fn selected(&self) -> Option<usize> {
324        self.list_state.selected()
325    }
326}
327
328impl Default for SubAgentSidebarState {
329    fn default() -> Self {
330        Self::new()
331    }
332}
333
334pub struct ConfirmState {
335    pub prompt: String,
336    pub response_tx: Option<oneshot::Sender<bool>>,
337}
338
339pub struct ElicitationState {
340    pub dialog: crate::widgets::elicitation::ElicitationDialogState,
341    pub response_tx: Option<oneshot::Sender<zeph_core::channel::ElicitationResponse>>,
342}
343
344/// Central state machine for the TUI dashboard.
345///
346/// `App` owns all widget state, the render cache, the message history, and
347/// the event channel endpoints. The main loop in [`crate::run_tui`] calls
348/// [`draw`](Self::draw) once per frame and routes events through
349/// [`handle_event`](Self::handle_event) and
350/// [`handle_agent_event`](Self::handle_agent_event).
351///
352/// # Construction
353///
354/// ```rust
355/// use tokio::sync::mpsc;
356/// use zeph_tui::App;
357///
358/// let (user_tx, _user_rx) = mpsc::channel(64);
359/// let (_agent_tx, agent_rx) = mpsc::channel(64);
360/// let app = App::new(user_tx, agent_rx);
361/// ```
362///
363/// Use the builder methods to wire optional components:
364/// - [`with_metrics_rx`](Self::with_metrics_rx) — live metrics watch channel.
365/// - [`with_cancel_signal`](Self::with_cancel_signal) — Ctrl-C cancel notify.
366/// - [`with_command_tx`](Self::with_command_tx) — slash-command dispatch channel.
367#[allow(clippy::struct_excessive_bools)] // independent boolean flags; bitflags or enum would obscure semantics without reducing complexity
368pub struct App {
369    // SESSION-LOCAL state (10 fields relocated into SessionSlot)
370    pub(crate) sessions: SessionRegistry,
371
372    // GLOBAL state — unchanged from before relocation
373    show_side_panels: bool,
374    show_help: bool,
375    pub metrics: MetricsSnapshot,
376    metrics_rx: Option<watch::Receiver<MetricsSnapshot>>,
377    active_panel: Panel,
378    tool_expanded: bool,
379    tool_density: ToolDensity,
380    show_source_labels: bool,
381    show_balance: bool,
382    throbber_state: throbber_widgets_tui::ThrobberState,
383    confirm_state: Option<ConfirmState>,
384    elicitation_state: Option<ElicitationState>,
385    command_palette: Option<CommandPaletteState>,
386    command_tx: Option<mpsc::Sender<TuiCommand>>,
387    file_picker_state: Option<FilePickerState>,
388    file_index: Option<FileIndex>,
389    slash_autocomplete: Option<SlashAutocompleteState>,
390    reverse_search: Option<crate::widgets::reverse_search::ReverseSearchState>,
391    pub should_quit: bool,
392    user_input_tx: mpsc::Sender<String>,
393    agent_event_rx: mpsc::Receiver<AgentEvent>,
394    // GLOBAL — single shared agent queue counters (stays global per arch v2 §7)
395    queued_count: usize,
396    pending_count: usize,
397    /// Projected context token count from the last context assembly, or 0 if not yet known.
398    context_token_estimate: usize,
399    editing_queued: bool,
400    hyperlinks: Vec<HyperlinkSpan>,
401    cancel_signal: Option<Arc<Notify>>,
402    pending_file_index: Option<oneshot::Receiver<FileIndex>>,
403    /// Interactive selection state for the subagent sidebar (stays global per arch v2 E5).
404    pub subagent_sidebar: SubAgentSidebarState,
405    /// Optional handle to the `TaskSupervisor` for the task registry panel.
406    task_supervisor: Option<TaskSupervisor>,
407    /// Whether the task registry panel is currently visible (toggled by `/tasks`).
408    show_task_panel: bool,
409    /// Snapshot of supervisor tasks cached once per render tick before `terminal.draw()`.
410    ///
411    /// Avoids acquiring `TaskSupervisor`'s inner mutex inside the draw closure, which
412    /// can block the render loop when the reap driver holds the lock concurrently.
413    cached_task_snapshots: Vec<zeph_common::task_supervisor::TaskSnapshot>,
414    /// Clipboard handle for `/copy` and `Ctrl+O` (#3685).
415    pub(crate) clipboard: crate::clipboard::ClipboardHandle,
416    /// Cached fleet session data for the fleet panel (#3884).
417    pub(crate) fleet_snapshot: crate::widgets::fleet::FleetSnapshot,
418    /// List scroll state for the fleet panel.
419    pub(crate) fleet_list_state: ratatui::widgets::ListState,
420}
421
422impl App {
423    /// Create a new `App` with the given I/O channels.
424    ///
425    /// The app starts in insert mode with the splash screen visible and no
426    /// messages in the buffer.
427    ///
428    /// # Arguments
429    ///
430    /// * `user_input_tx` — sender used to forward the user's typed text to the
431    ///   agent loop via [`TuiChannel`](crate::TuiChannel).
432    /// * `agent_event_rx` — receiver for [`AgentEvent`] produced by the agent.
433    ///
434    /// # Examples
435    ///
436    /// ```rust
437    /// use tokio::sync::mpsc;
438    /// use zeph_tui::App;
439    ///
440    /// let (user_tx, _user_rx) = mpsc::channel(64);
441    /// let (_agent_tx, agent_rx) = mpsc::channel(64);
442    /// let app = App::new(user_tx, agent_rx);
443    /// assert!(app.show_splash());
444    /// ```
445    #[must_use]
446    pub fn new(
447        user_input_tx: mpsc::Sender<String>,
448        agent_event_rx: mpsc::Receiver<AgentEvent>,
449    ) -> Self {
450        Self {
451            sessions: SessionRegistry::bootstrap(),
452            show_side_panels: true,
453            show_help: false,
454            metrics: MetricsSnapshot::default(),
455            metrics_rx: None,
456            active_panel: Panel::Chat,
457            tool_expanded: false,
458            tool_density: ToolDensity::default(),
459            show_source_labels: false,
460            show_balance: true,
461            throbber_state: throbber_widgets_tui::ThrobberState::default(),
462            confirm_state: None,
463            elicitation_state: None,
464            command_palette: None,
465            command_tx: None,
466            file_picker_state: None,
467            file_index: None,
468            slash_autocomplete: None,
469            reverse_search: None,
470            should_quit: false,
471            user_input_tx,
472            agent_event_rx,
473            queued_count: 0,
474            pending_count: 0,
475            context_token_estimate: 0,
476            editing_queued: false,
477            hyperlinks: Vec::new(),
478            cancel_signal: None,
479            pending_file_index: None,
480            subagent_sidebar: SubAgentSidebarState::new(),
481            task_supervisor: None,
482            show_task_panel: false,
483            cached_task_snapshots: Vec::new(),
484            clipboard: crate::clipboard::ClipboardHandle::new(),
485            fleet_snapshot: crate::widgets::fleet::FleetSnapshot::default(),
486            fleet_list_state: ratatui::widgets::ListState::default(),
487        }
488    }
489
490    /// Return `true` while the splash screen should be displayed.
491    ///
492    /// The splash screen is hidden as soon as the first chat message arrives.
493    #[must_use]
494    pub fn show_splash(&self) -> bool {
495        self.sessions.current().show_splash
496    }
497
498    /// Return `true` when the side panels column is visible.
499    ///
500    /// Controlled by the `s` keybinding and automatically disabled on narrow
501    /// terminals (< 80 columns).
502    #[must_use]
503    pub fn show_side_panels(&self) -> bool {
504        self.show_side_panels
505    }
506
507    /// Returns `true` when the user has toggled back to subagents view (plan view overridden).
508    #[must_use]
509    pub fn plan_view_active(&self) -> bool {
510        self.sessions.current().plan_view_active
511    }
512
513    // ---- Accessors for fields relocated into SessionSlot (preserves pub API surface) ----
514
515    /// Returns the active session's render cache.
516    #[must_use]
517    pub fn render_cache(&self) -> &RenderCache {
518        &self.sessions.current().render_cache
519    }
520
521    /// Returns a mutable reference to the active session's render cache.
522    pub fn render_cache_mut(&mut self) -> &mut RenderCache {
523        &mut self.sessions.current_mut().render_cache
524    }
525
526    /// Returns the current chat area view target (main conversation or sub-agent transcript).
527    #[must_use]
528    pub fn view_target(&self) -> &AgentViewTarget {
529        &self.sessions.current().view_target
530    }
531
532    /// Returns the cached transcript for the currently-focused sub-agent, if any.
533    #[must_use]
534    pub fn transcript_cache(&self) -> Option<&TranscriptCache> {
535        self.sessions.current().transcript_cache.as_ref()
536    }
537
538    /// Populate the message buffer from a persisted session history.
539    ///
540    /// Each element is a `(role, content)` pair where `role` is one of
541    /// `"user"`, `"assistant"`, or `"tool"`. Tool outputs are detected by a
542    /// sentinel suffix and rendered as [`MessageRole::Tool`] messages.
543    /// The splash screen is hidden after loading if any messages are present.
544    pub fn load_history(&mut self, messages: &[(&str, &str)]) {
545        const TOOL_SUFFIX: &str = "\n```";
546
547        for &(role_str, content) in messages {
548            if role_str == "user"
549                && let Some((tool_name, body)) = parse_tool_output(content, TOOL_SUFFIX)
550            {
551                self.sessions
552                    .current_mut()
553                    .messages
554                    .push(ChatMessage::new(MessageRole::Tool, body).with_tool(tool_name.into()));
555                continue;
556            }
557
558            let role = match role_str {
559                "user" => MessageRole::User,
560                "assistant" => {
561                    if is_tool_use_only(content) {
562                        continue;
563                    }
564                    MessageRole::Assistant
565                }
566                _ => continue,
567            };
568            if role == MessageRole::User {
569                self.sessions
570                    .current_mut()
571                    .input_history
572                    .push(content.to_owned());
573            }
574            self.sessions
575                .current_mut()
576                .messages
577                .push(ChatMessage::new(role, content));
578        }
579        // Enforce the message buffer cap on initial history load as well.
580        self.trim_messages();
581        if !self.sessions.current().messages.is_empty() {
582            self.sessions.current_mut().show_splash = false;
583        }
584    }
585
586    /// Attach a cancel signal that Ctrl-C in the TUI will trigger.
587    ///
588    /// # Examples
589    ///
590    /// ```rust
591    /// use std::sync::Arc;
592    /// use tokio::sync::{Notify, mpsc};
593    /// use zeph_tui::App;
594    ///
595    /// let (tx, _rx) = mpsc::channel(1);
596    /// let (_atx, arx) = mpsc::channel(1);
597    /// let notify = Arc::new(Notify::new());
598    /// let _app = App::new(tx, arx).with_cancel_signal(notify);
599    /// ```
600    #[must_use]
601    pub fn with_cancel_signal(mut self, signal: Arc<Notify>) -> Self {
602        self.cancel_signal = Some(signal);
603        self
604    }
605
606    /// Attach a metrics watch channel for live dashboard updates.
607    ///
608    /// The current snapshot is read immediately; subsequent updates are polled
609    /// by [`poll_metrics`](Self::poll_metrics) each frame.
610    ///
611    /// # Examples
612    ///
613    /// ```rust
614    /// use tokio::sync::{mpsc, watch};
615    /// use zeph_tui::{App, MetricsSnapshot};
616    ///
617    /// let (tx, _rx) = mpsc::channel(1);
618    /// let (_atx, arx) = mpsc::channel(1);
619    /// let (_metrics_tx, metrics_rx) = watch::channel(MetricsSnapshot::default());
620    /// let _app = App::new(tx, arx).with_metrics_rx(metrics_rx);
621    /// ```
622    #[must_use]
623    pub fn with_metrics_rx(mut self, rx: watch::Receiver<MetricsSnapshot>) -> Self {
624        self.metrics = rx.borrow().clone();
625        self.metrics_rx = Some(rx);
626        self
627    }
628
629    /// Attach the command dispatch sender used for slash-command routing.
630    ///
631    /// # Examples
632    ///
633    /// ```rust
634    /// use tokio::sync::mpsc;
635    /// use zeph_tui::{App, TuiCommand};
636    ///
637    /// let (tx, _rx) = mpsc::channel(1);
638    /// let (_atx, arx) = mpsc::channel(1);
639    /// let (cmd_tx, _cmd_rx) = mpsc::channel(8);
640    /// let _app = App::new(tx, arx).with_command_tx(cmd_tx);
641    /// ```
642    #[must_use]
643    pub fn with_command_tx(mut self, tx: mpsc::Sender<TuiCommand>) -> Self {
644        self.command_tx = Some(tx);
645        self
646    }
647
648    /// Set the initial tool-output density from a loaded `TuiConfig`.
649    ///
650    /// Applied once at startup; runtime changes via the `c` key override this
651    /// but are not persisted back to config.
652    ///
653    /// # Examples
654    ///
655    /// ```rust
656    /// use tokio::sync::mpsc;
657    /// use zeph_tui::App;
658    /// use zeph_config::ToolDensity;
659    ///
660    /// let (tx, _rx) = mpsc::channel(1);
661    /// let (_atx, arx) = mpsc::channel(1);
662    /// let _app = App::new(tx, arx).with_tool_density(ToolDensity::Compact);
663    /// ```
664    #[must_use]
665    pub fn with_tool_density(mut self, density: ToolDensity) -> Self {
666        self.tool_density = density;
667        self
668    }
669
670    /// Wire a [`TaskSupervisor`] into the `App` for the task registry panel.
671    ///
672    /// The supervisor's task list is snapshotted once per render tick before
673    /// `terminal.draw()`, keeping the draw closure free of mutex contention.
674    /// Toggle the panel visibility with `/tasks`.
675    ///
676    /// # Examples
677    ///
678    /// ```rust,ignore
679    /// use tokio::sync::mpsc;
680    /// use tokio_util::sync::CancellationToken;
681    /// use zeph_common::task_supervisor::TaskSupervisor;
682    /// use zeph_tui::App;
683    ///
684    /// let (user_tx, _) = mpsc::channel(64);
685    /// let (_, agent_rx) = mpsc::channel(64);
686    /// let cancel = CancellationToken::new();
687    /// let supervisor = TaskSupervisor::new(cancel);
688    /// let _app = App::new(user_tx, agent_rx).with_task_supervisor(supervisor);
689    /// ```
690    #[must_use]
691    pub fn with_task_supervisor(mut self, supervisor: TaskSupervisor) -> Self {
692        self.task_supervisor = Some(supervisor);
693        self
694    }
695
696    /// Refresh the cached task snapshot from the supervisor.
697    ///
698    /// Must be called once per render tick **before** `terminal.draw()` to avoid
699    /// acquiring the supervisor's inner mutex inside the draw closure.
700    pub(crate) fn refresh_task_snapshots(&mut self) {
701        self.cached_task_snapshots = self
702            .task_supervisor
703            .as_ref()
704            .map(TaskSupervisor::snapshot)
705            .unwrap_or_default();
706    }
707
708    /// Return a truncated label for active `TaskSupervisor` tasks, or `None` when idle.
709    ///
710    /// Used by the input widget to show a braille spinner with the name of the first
711    /// active (Running/Restarting) task when no other status is being displayed.
712    #[must_use]
713    pub fn supervisor_activity_label(&self) -> Option<String> {
714        self.task_supervisor.as_ref()?;
715        let mut active = self
716            .cached_task_snapshots
717            .iter()
718            .filter(|t| {
719                matches!(
720                    t.status,
721                    zeph_common::task_supervisor::TaskStatus::Running
722                        | zeph_common::task_supervisor::TaskStatus::Restarting { .. }
723                )
724            })
725            .filter(|t| !t.name.starts_with("mem-"))
726            .peekable();
727        let first = active.next()?;
728        let label = if active.peek().is_none() {
729            first.name.to_string()
730        } else {
731            let extra = active.count() + 1; // +1 because we already consumed first
732            format!("{} +{} more", first.name, extra)
733        };
734        // Char-based truncation to avoid panicking on multi-byte UTF-8 boundaries.
735        let truncated: String = label.chars().take(38).collect();
736        Some(truncated)
737    }
738
739    /// Wire a cancel signal into a running App instance.
740    ///
741    /// Used by the two-phase TUI startup path to connect the agent's cancel signal
742    /// after the agent has been constructed (Phase 2).
743    pub fn set_cancel_signal(&mut self, signal: Arc<Notify>) {
744        self.cancel_signal = Some(signal);
745    }
746
747    /// Wire a metrics receiver into a running App instance.
748    ///
749    /// Used by the two-phase TUI startup path to connect the metrics channel
750    /// after the metrics watch channel has been created (Phase 2).
751    pub fn set_metrics_rx(&mut self, rx: watch::Receiver<MetricsSnapshot>) {
752        self.metrics = rx.borrow().clone();
753        self.metrics_rx = Some(rx);
754    }
755
756    /// Check the metrics watch channel for an updated snapshot and apply it.
757    ///
758    /// Also clamps the sidebar selection and triggers a transcript reload if
759    /// the sub-agent's turn count has advanced. Called once per render frame.
760    pub fn poll_metrics(&mut self) {
761        if let Some(ref mut rx) = self.metrics_rx
762            && rx.has_changed().unwrap_or(false)
763        {
764            let new_metrics = rx.borrow_and_update().clone();
765            // IC2: reset plan_view_active (subagents-override) when a new plan appears.
766            // Detect new plan by comparing graph_id; new plan should be shown immediately.
767            let new_graph_id = new_metrics
768                .orchestration_graph
769                .as_ref()
770                .map(|s| &s.graph_id);
771            let old_graph_id = self
772                .metrics
773                .orchestration_graph
774                .as_ref()
775                .map(|s| &s.graph_id);
776            if new_graph_id != old_graph_id && new_graph_id.is_some() {
777                self.sessions.current_mut().plan_view_active = false;
778            }
779            self.metrics = new_metrics;
780        }
781        // Clamp sidebar selection in case subagents count changed.
782        let count = self.metrics.sub_agents.len();
783        self.subagent_sidebar.clamp(count);
784        // Trigger transcript reload when turns count increased.
785        self.maybe_reload_transcript();
786    }
787
788    /// Switch the chat view target. Clears render cache and scroll offset.
789    /// All view changes MUST go through this method (W5).
790    pub fn set_view_target(&mut self, target: AgentViewTarget) {
791        if self.sessions.current().view_target == target {
792            return;
793        }
794        self.sessions.current_mut().view_target = target;
795        self.sessions.current_mut().render_cache.clear();
796        self.sessions.current_mut().scroll_offset = 0;
797        self.sessions.current_mut().transcript_cache = None;
798        self.sessions.current_mut().pending_transcript = None;
799        // Kick off transcript load if switching to a subagent.
800        if let AgentViewTarget::SubAgent { ref id, .. } = self.sessions.current().view_target {
801            let id = id.clone();
802            self.start_transcript_load(&id);
803        }
804    }
805
806    /// Initiates a background transcript load for the given agent ID.
807    fn start_transcript_load(&mut self, agent_id: &str) {
808        // Find transcript_dir from current metrics.
809        let transcript_path = self
810            .metrics
811            .sub_agents
812            .iter()
813            .find(|sa| sa.id == agent_id)
814            .and_then(|sa| sa.transcript_dir.as_deref())
815            .map(|dir| std::path::PathBuf::from(dir).join(format!("{agent_id}.jsonl")));
816
817        let Some(path) = transcript_path else {
818            return;
819        };
820
821        let (tx, rx) = oneshot::channel();
822        self.sessions.current_mut().pending_transcript = Some(rx);
823        // Determine if the agent is still active (for C2: skip warning on partial last line).
824        let is_active = self
825            .metrics
826            .sub_agents
827            .iter()
828            .find(|sa| sa.id == agent_id)
829            .is_some_and(|sa| matches!(sa.state.as_str(), "working" | "submitted"));
830
831        tokio::task::spawn_blocking(move || {
832            let result = load_transcript_file(&path, is_active);
833            let _ = tx.send(result);
834        });
835    }
836
837    /// Poll the pending transcript load and install result if ready.
838    pub fn poll_pending_transcript(&mut self) {
839        let Some(rx) = self.sessions.current_mut().pending_transcript.as_mut() else {
840            return;
841        };
842        match rx.try_recv() {
843            Ok((entries, total)) => {
844                self.sessions.current_mut().pending_transcript = None;
845                let turns_at_load = self
846                    .sessions
847                    .current()
848                    .view_target
849                    .subagent_id()
850                    .and_then(|id| self.metrics.sub_agents.iter().find(|sa| sa.id == id))
851                    .map_or(0, |sa| sa.turns_used);
852                if let AgentViewTarget::SubAgent { ref id, .. } =
853                    self.sessions.current().view_target.clone()
854                {
855                    self.sessions.current_mut().transcript_cache = Some(TranscriptCache {
856                        agent_id: id.clone(),
857                        entries,
858                        turns_at_load,
859                        total_in_file: total,
860                    });
861                }
862                self.sessions.current_mut().render_cache.clear();
863            }
864            Err(oneshot::error::TryRecvError::Empty) => {}
865            Err(oneshot::error::TryRecvError::Closed) => {
866                self.sessions.current_mut().pending_transcript = None;
867            }
868        }
869    }
870
871    /// Check if the transcript needs reloading (turns count increased).
872    fn maybe_reload_transcript(&mut self) {
873        let AgentViewTarget::SubAgent { ref id, .. } = self.sessions.current().view_target.clone()
874        else {
875            return;
876        };
877        // Don't start a new load while one is already in flight.
878        if self.sessions.current().pending_transcript.is_some() {
879            return;
880        }
881        let current_turns = self
882            .metrics
883            .sub_agents
884            .iter()
885            .find(|sa| sa.id == *id)
886            .map_or(0, |sa| sa.turns_used);
887        let cached_turns = self
888            .sessions
889            .current()
890            .transcript_cache
891            .as_ref()
892            .map_or(0, |c| c.turns_at_load);
893        if current_turns > cached_turns {
894            let agent_id = id.to_owned();
895            self.start_transcript_load(&agent_id);
896        }
897    }
898
899    /// Returns the messages to display in the chat area.
900    ///
901    /// Always returns an owned `Vec` — the cost is one clone of at most
902    /// `MAX_TUI_MESSAGES` (2000) ref-counted strings inside `ChatMessage`.
903    /// When viewing a subagent, returns transcript entries converted to [`ChatMessage`].
904    /// When no transcript is loaded yet, returns a loading placeholder.
905    #[must_use]
906    pub fn visible_messages(&self) -> Vec<ChatMessage> {
907        let slot = self.sessions.current();
908        if slot.view_target.is_main() {
909            return slot.messages.clone();
910        }
911        if let Some(ref cache) = slot.transcript_cache {
912            return cache
913                .entries
914                .iter()
915                .map(TuiTranscriptEntry::to_chat_message)
916                .collect();
917        }
918        if slot.pending_transcript.is_some() {
919            return vec![ChatMessage::new(
920                MessageRole::System,
921                "Loading transcript...".to_owned(),
922            )];
923        }
924        let name = slot.view_target.subagent_name().unwrap_or("unknown");
925        vec![ChatMessage::new(
926            MessageRole::System,
927            format!("Transcript not available for {name}."),
928        )]
929    }
930
931    /// Returns the truncation info string if the transcript was truncated.
932    #[must_use]
933    pub fn transcript_truncation_info(&self) -> Option<String> {
934        let cache = self.sessions.current().transcript_cache.as_ref()?;
935        if cache.total_in_file > TRANSCRIPT_MAX_ENTRIES {
936            Some(format!(
937                "[showing last {TRANSCRIPT_MAX_ENTRIES} of {} messages]",
938                cache.total_in_file
939            ))
940        } else {
941            None
942        }
943    }
944
945    /// Evict oldest messages when the buffer exceeds `MAX_TUI_MESSAGES` (#2737).
946    ///
947    /// Shifts the render cache to match the drained messages, preserving cached renders
948    /// for the remaining entries and avoiding a full re-render stall (#2775).
949    fn trim_messages(&mut self) {
950        self.sessions.current_mut().trim_messages();
951    }
952
953    /// Return a slice of all chat messages currently in the buffer.
954    ///
955    /// For the currently-displayed messages (which may be a sub-agent
956    /// transcript) use [`visible_messages`](Self::visible_messages) instead.
957    #[must_use]
958    pub fn messages(&self) -> &[ChatMessage] {
959        &self.sessions.current().messages
960    }
961
962    /// Return the current content of the text input field.
963    #[must_use]
964    pub fn input(&self) -> &str {
965        &self.sessions.current().input
966    }
967
968    /// Return the current input mode (normal vs. insert).
969    #[must_use]
970    pub fn input_mode(&self) -> InputMode {
971        self.sessions.current().input_mode
972    }
973
974    /// Return the cursor byte position within the input string.
975    #[must_use]
976    pub fn cursor_position(&self) -> usize {
977        self.sessions.current().cursor_position
978    }
979
980    /// Returns the composer height requested by the current draft, capped at three visible rows.
981    #[must_use]
982    pub(crate) fn desired_input_height(&self) -> u16 {
983        let content_lines = self.input_line_count().min(MAX_VISIBLE_INPUT_LINES);
984        content_lines.saturating_add(2)
985    }
986
987    /// Returns the number of logical lines in the current draft or indicator.
988    #[must_use]
989    pub(crate) fn input_line_count(&self) -> u16 {
990        if self.sessions.current().paste_state.is_some()
991            || (self.sessions.current().input.is_empty()
992                && matches!(self.sessions.current().input_mode, InputMode::Insert))
993        {
994            1
995        } else {
996            u16::try_from(self.sessions.current().input.matches('\n').count() + 1)
997                .unwrap_or(u16::MAX)
998        }
999    }
1000
1001    /// Return the number of lines the chat view is scrolled up from the bottom.
1002    ///
1003    /// `0` means the view is at the bottom (latest messages visible).
1004    #[must_use]
1005    pub fn scroll_offset(&self) -> usize {
1006        self.sessions.current().scroll_offset
1007    }
1008
1009    /// Scroll to bottom only if already at (or near) the bottom.
1010    fn auto_scroll(&mut self) {
1011        if self.sessions.current().scroll_offset <= 1 {
1012            self.sessions.current_mut().scroll_offset = 0;
1013        }
1014    }
1015
1016    /// Return `true` when tool-output blocks are expanded to full height.
1017    #[must_use]
1018    pub fn tool_expanded(&self) -> bool {
1019        self.tool_expanded
1020    }
1021
1022    /// Return the active paste indicator state, if any.
1023    ///
1024    /// `Some` when a multiline paste is in the input buffer and no edit
1025    /// keypress has occurred since the paste. `None` otherwise.
1026    #[must_use]
1027    pub fn paste_state(&self) -> Option<&PasteState> {
1028        self.sessions.current().paste_state.as_ref()
1029    }
1030
1031    /// Return the current tool-output density level.
1032    #[must_use]
1033    pub fn tool_density(&self) -> ToolDensity {
1034        self.tool_density
1035    }
1036
1037    /// Return `true` when source-label badges are shown on assistant messages.
1038    #[must_use]
1039    pub fn show_source_labels(&self) -> bool {
1040        self.show_source_labels
1041    }
1042
1043    /// Toggle source-label visibility.
1044    ///
1045    /// Clears the render cache so all messages are re-rendered with the new
1046    /// setting on the next frame.
1047    pub fn set_show_source_labels(&mut self, v: bool) {
1048        if self.show_source_labels != v {
1049            self.show_source_labels = v;
1050            self.sessions.current_mut().render_cache.clear();
1051        }
1052    }
1053
1054    /// Return `true` when the Cocoon TON balance should be shown in the status bar.
1055    ///
1056    /// Controlled by `[cocoon] show_balance` in config (default `true`). When `false`,
1057    /// the balance is redacted to `*** TON` per spec §15.2.
1058    #[must_use]
1059    pub fn show_balance(&self) -> bool {
1060        self.show_balance
1061    }
1062
1063    /// Set whether the Cocoon TON balance is shown in the status bar.
1064    pub fn set_show_balance(&mut self, v: bool) {
1065        self.show_balance = v;
1066    }
1067
1068    /// Replace the current hyperlink span list with `links`.
1069    ///
1070    /// Called by the render loop after each frame to store spans detected in
1071    /// the terminal buffer so they can be emitted as OSC 8 sequences.
1072    pub fn set_hyperlinks(&mut self, links: Vec<HyperlinkSpan>) {
1073        self.hyperlinks = links;
1074    }
1075
1076    /// Take ownership of the accumulated hyperlink spans, clearing the list.
1077    ///
1078    /// Called once per frame; the caller writes OSC 8 sequences to the terminal.
1079    pub fn take_hyperlinks(&mut self) -> Vec<HyperlinkSpan> {
1080        std::mem::take(&mut self.hyperlinks)
1081    }
1082
1083    /// Return the current activity status label, if any.
1084    ///
1085    /// Displayed in the activity bar with a spinner when non-`None`
1086    /// (e.g. `"Searching memory…"`, `"Executing tool: bash"`).
1087    #[must_use]
1088    pub fn status_label(&self) -> Option<&str> {
1089        self.sessions.current().status_label.as_deref()
1090    }
1091
1092    /// Return the number of messages queued or pending for the agent.
1093    ///
1094    /// Displayed in the input bar to indicate backpressure.
1095    #[must_use]
1096    pub fn queued_count(&self) -> usize {
1097        self.queued_count.max(self.pending_count)
1098    }
1099
1100    /// Return the projected context token count from the last assembly, or 0 if not yet known.
1101    ///
1102    /// The value is approximate (character-level heuristic) and is updated once per agent turn.
1103    ///
1104    /// # Examples
1105    ///
1106    /// ```rust
1107    /// use tokio::sync::mpsc;
1108    /// use zeph_tui::App;
1109    ///
1110    /// let (tx, _) = mpsc::channel(1);
1111    /// let (_, rx) = mpsc::channel(1);
1112    /// let app = App::new(tx, rx);
1113    /// assert_eq!(app.context_token_estimate(), 0);
1114    /// ```
1115    #[must_use]
1116    pub fn context_token_estimate(&self) -> usize {
1117        self.context_token_estimate
1118    }
1119
1120    /// Return `true` when the user is currently editing a queued message.
1121    #[must_use]
1122    pub fn editing_queued(&self) -> bool {
1123        self.editing_queued
1124    }
1125
1126    /// Return `true` when the agent is actively processing (streaming or running a tool).
1127    ///
1128    /// Used by the render loop to decide whether to show the activity spinner.
1129    #[must_use]
1130    pub fn is_agent_busy(&self) -> bool {
1131        self.sessions.current().status_label.is_some()
1132            || self
1133                .sessions
1134                .current()
1135                .messages
1136                .last()
1137                .is_some_and(|m| m.streaming)
1138    }
1139
1140    /// Return `true` when the last message is a streaming tool output.
1141    #[must_use]
1142    pub fn has_running_tool(&self) -> bool {
1143        self.sessions
1144            .current()
1145            .messages
1146            .last()
1147            .is_some_and(|m| m.role == MessageRole::Tool && m.streaming)
1148    }
1149
1150    /// Return a reference to the throbber animation state.
1151    ///
1152    /// Used by the status widget to render the spinner frame.
1153    #[must_use]
1154    pub fn throbber_state(&self) -> &throbber_widgets_tui::ThrobberState {
1155        &self.throbber_state
1156    }
1157
1158    /// Return a mutable reference to the throbber animation state.
1159    ///
1160    /// Called by the tick handler to advance the spinner frame each tick.
1161    pub fn throbber_state_mut(&mut self) -> &mut throbber_widgets_tui::ThrobberState {
1162        &mut self.throbber_state
1163    }
1164}
1165
1166mod draw;
1167mod events;
1168mod keys;
1169
1170/// Maximum number of transcript entries loaded into the TUI (W4).
1171pub const TRANSCRIPT_MAX_ENTRIES: usize = 200;
1172
1173/// Load transcript entries from a JSONL file in a blocking context.
1174/// Returns `(entries, total_line_count)` where `total_line_count` is the number
1175/// of lines in the file (before truncation), used for the truncation indicator.
1176///
1177/// When `is_active` is true, silently discards the last line if it fails to parse
1178/// (C2: partial-write race condition mitigation).
1179fn load_transcript_file(
1180    path: &std::path::Path,
1181    is_active: bool,
1182) -> (Vec<TuiTranscriptEntry>, usize) {
1183    let Ok(content) = std::fs::read_to_string(path) else {
1184        return (Vec::new(), 0);
1185    };
1186
1187    let lines: Vec<&str> = content.lines().collect();
1188    let total = lines.len();
1189    if total == 0 {
1190        return (Vec::new(), 0);
1191    }
1192
1193    // C2: when agent is active, check if last line looks like partial write.
1194    let parse_end = if is_active && total > 0 {
1195        let last = lines[total - 1].trim();
1196        // A complete JSON object ends with '}'. Discard last line if partial write.
1197        if last.ends_with('}') {
1198            total
1199        } else {
1200            total - 1
1201        }
1202    } else {
1203        total
1204    };
1205
1206    let entries: Vec<TuiTranscriptEntry> = lines[..parse_end]
1207        .iter()
1208        .filter_map(|line| {
1209            let line = line.trim();
1210            if line.is_empty() {
1211                return None;
1212            }
1213            // Parse minimal fields needed for display.
1214            // Using serde_json::Value to avoid coupling to zeph-subagent types.
1215            let v: serde_json::Value = serde_json::from_str(line).ok()?;
1216            // TranscriptEntry wraps a Message in a `message` field.
1217            // Schema: { seq, timestamp, message: { role, parts: [{content}], tool_name? } }
1218            // Also support flat format: { role, content, tool_name?, timestamp? }
1219            let (role, content, tool_name, timestamp) = if let Some(msg) = v.get("message") {
1220                let role = msg
1221                    .get("role")
1222                    .and_then(|r| r.as_str())
1223                    .unwrap_or("system")
1224                    .to_owned();
1225                // Extract content from first text part or direct content field.
1226                let content = msg
1227                    .get("parts")
1228                    .and_then(|p| p.as_array())
1229                    .and_then(|arr| arr.first())
1230                    .and_then(|part| part.get("content"))
1231                    .and_then(|c| c.as_str())
1232                    .or_else(|| msg.get("content").and_then(|c| c.as_str()))
1233                    .unwrap_or("")
1234                    .to_owned();
1235                let tool_name = msg
1236                    .get("tool_name")
1237                    .and_then(|t| t.as_str())
1238                    .map(zeph_common::ToolName::new);
1239                let timestamp = v
1240                    .get("timestamp")
1241                    .and_then(|t| t.as_str())
1242                    .map(ToOwned::to_owned);
1243                (role, content, tool_name, timestamp)
1244            } else {
1245                // Flat format fallback.
1246                let role = v
1247                    .get("role")
1248                    .and_then(|r| r.as_str())
1249                    .unwrap_or("system")
1250                    .to_owned();
1251                let content = v
1252                    .get("content")
1253                    .and_then(|c| c.as_str())
1254                    .unwrap_or("")
1255                    .to_owned();
1256                let tool_name = v
1257                    .get("tool_name")
1258                    .and_then(|t| t.as_str())
1259                    .map(zeph_common::ToolName::new);
1260                let timestamp = v
1261                    .get("timestamp")
1262                    .and_then(|t| t.as_str())
1263                    .map(ToOwned::to_owned);
1264                (role, content, tool_name, timestamp)
1265            };
1266
1267            if content.is_empty() && tool_name.is_none() {
1268                return None;
1269            }
1270
1271            Some(TuiTranscriptEntry {
1272                role,
1273                content,
1274                tool_name,
1275                timestamp,
1276            })
1277        })
1278        .collect();
1279
1280    // Take only the last N entries (W4).
1281    let truncated: Vec<TuiTranscriptEntry> = if entries.len() > TRANSCRIPT_MAX_ENTRIES {
1282        entries
1283            .into_iter()
1284            .rev()
1285            .take(TRANSCRIPT_MAX_ENTRIES)
1286            .rev()
1287            .collect()
1288    } else {
1289        entries
1290    };
1291
1292    (truncated, total)
1293}
1294
1295fn format_security_report(metrics: &MetricsSnapshot) -> String {
1296    use crate::metrics::SecurityEventCategory;
1297
1298    let n = metrics.security_events.len();
1299    if n == 0 {
1300        return "Security event history (0 events)\n\nNo events recorded.".to_owned();
1301    }
1302
1303    let mut lines = vec![format!("Security event history ({n} events):")];
1304    for ev in &metrics.security_events {
1305        #[allow(clippy::cast_possible_wrap)]
1306        let ts = chrono::DateTime::from_timestamp(ev.timestamp as i64, 0).map_or_else(
1307            || "??:??:??".to_owned(),
1308            |dt| {
1309                dt.with_timezone(&chrono::Local)
1310                    .format("%H:%M:%S")
1311                    .to_string()
1312            },
1313        );
1314        let cat = match ev.category {
1315            SecurityEventCategory::InjectionFlag => "INJECTION_FLAG ",
1316            SecurityEventCategory::InjectionBlocked => "INJECT_BLOCKED ",
1317            SecurityEventCategory::ExfiltrationBlock => "EXFIL_BLOCK    ",
1318            SecurityEventCategory::Quarantine => "QUARANTINE     ",
1319            SecurityEventCategory::Truncation => "TRUNCATION     ",
1320            SecurityEventCategory::RateLimit => "RATE_LIMIT     ",
1321            SecurityEventCategory::MemoryValidation => "MEM_VALIDATION ",
1322            SecurityEventCategory::PreExecutionBlock => "PRE_EXEC_BLOCK ",
1323            SecurityEventCategory::PreExecutionWarn => "PRE_EXEC_WARN  ",
1324            SecurityEventCategory::ResponseVerification => "RESP_VERIFY    ",
1325            SecurityEventCategory::CausalIpiFlag => "CAUSAL_IPI     ",
1326            SecurityEventCategory::CrossBoundaryMcpToAcp => "CROSS_BOUNDARY ",
1327            SecurityEventCategory::VigilFlag => "VIGIL_FLAG     ",
1328            SecurityEventCategory::GoalDrift => "GOAL_DRIFT     ",
1329            _ => "UNKNOWN        ",
1330        };
1331        lines.push(format!("  [{ts}] {cat}  {:<20}  {}", ev.source, ev.detail));
1332    }
1333    lines.push(String::new());
1334    lines.push("Totals:".to_owned());
1335    lines.push(format!(
1336        "  Sanitizer runs: {}  |  Flags: {}  |  Truncations: {}",
1337        metrics.sanitizer_runs, metrics.sanitizer_injection_flags, metrics.sanitizer_truncations,
1338    ));
1339    lines.push(format!(
1340        "  Quarantine: {} ({} failures)",
1341        metrics.quarantine_invocations, metrics.quarantine_failures,
1342    ));
1343    lines.push(format!(
1344        "  Exfiltration: {} images  |  {} URLs  |  {} memory",
1345        metrics.exfiltration_images_blocked,
1346        metrics.exfiltration_tool_urls_flagged,
1347        metrics.exfiltration_memory_guards,
1348    ));
1349    lines.join("\n")
1350}
1351
1352fn is_tool_use_only(content: &str) -> bool {
1353    let trimmed = content.trim();
1354    if trimmed.is_empty() {
1355        return false;
1356    }
1357    let mut rest = trimmed;
1358    while let Some(start) = rest.find("[tool_use: ") {
1359        if !rest[..start].trim().is_empty() {
1360            return false;
1361        }
1362        let after = &rest[start + "[tool_use: ".len()..];
1363        let Some(end) = after.find(']') else {
1364            return false;
1365        };
1366        rest = after[end + 1..].trim_start();
1367    }
1368    rest.is_empty()
1369}
1370
1371fn parse_tool_output(content: &str, suffix: &str) -> Option<(String, String)> {
1372    // New format: [tool output: name]
1373    if let Some(rest) = content.strip_prefix("[tool output: ")
1374        && let Some(header_end) = rest.find("]\n```\n")
1375    {
1376        let name = rest[..header_end].to_owned();
1377        let body_start = header_end + "]\n```\n".len();
1378        let body_part = &rest[body_start..];
1379        let body = body_part.strip_suffix(suffix).unwrap_or(body_part);
1380        return Some((name, body.to_owned()));
1381    }
1382    // Legacy format: [tool output] — infer tool name from body
1383    if let Some(rest) = content.strip_prefix("[tool output]\n```\n") {
1384        let body = rest.strip_suffix(suffix).unwrap_or(rest);
1385        let name = if body.starts_with("$ ") {
1386            "bash"
1387        } else {
1388            "tool"
1389        };
1390        return Some((name.to_owned(), body.to_owned()));
1391    }
1392    // Native tool_use format: [tool_result: id]\ncontent
1393    if let Some(rest) = content.strip_prefix("[tool_result: ") {
1394        let body = rest.find("]\n").map_or("", |i| &rest[i + 2..]);
1395        let name = if body.contains("$ ") { "bash" } else { "tool" };
1396        return Some((name.to_owned(), body.to_owned()));
1397    }
1398    None
1399}
1400
1401#[cfg(test)]
1402mod tests {
1403    use super::*;
1404    use crate::event::{AgentEvent, AppEvent};
1405    use crate::session::MAX_TUI_MESSAGES;
1406    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1407
1408    fn make_app() -> (App, mpsc::Receiver<String>, mpsc::Sender<AgentEvent>) {
1409        let (user_tx, user_rx) = mpsc::channel(16);
1410        let (agent_tx, agent_rx) = mpsc::channel(16);
1411        let mut app = App::new(user_tx, agent_rx);
1412        app.sessions.current_mut().messages.clear();
1413        (app, user_rx, agent_tx)
1414    }
1415
1416    #[test]
1417    fn initial_state() {
1418        let (app, _rx, _tx) = make_app();
1419        assert!(app.input().is_empty());
1420        assert_eq!(app.input_mode(), InputMode::Insert);
1421        assert!(app.messages().is_empty());
1422        assert!(app.show_splash());
1423        assert!(!app.should_quit);
1424    }
1425
1426    #[test]
1427    fn ctrl_c_quits() {
1428        let (mut app, _rx, _tx) = make_app();
1429        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
1430        app.handle_event(AppEvent::Key(key));
1431        assert!(app.should_quit);
1432    }
1433
1434    #[test]
1435    fn insert_mode_typing() {
1436        let (mut app, _rx, _tx) = make_app();
1437        app.sessions.current_mut().input_mode = InputMode::Insert;
1438        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
1439        app.handle_event(AppEvent::Key(key));
1440        assert_eq!(app.input(), "a");
1441        assert_eq!(app.cursor_position(), 1);
1442    }
1443
1444    #[test]
1445    fn escape_switches_to_normal() {
1446        let (mut app, _rx, _tx) = make_app();
1447        app.sessions.current_mut().input_mode = InputMode::Insert;
1448        let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
1449        app.handle_event(AppEvent::Key(key));
1450        assert_eq!(app.input_mode(), InputMode::Normal);
1451    }
1452
1453    #[test]
1454    fn i_enters_insert_mode() {
1455        let (mut app, _rx, _tx) = make_app();
1456        app.sessions.current_mut().input_mode = InputMode::Normal;
1457        let key = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
1458        app.handle_event(AppEvent::Key(key));
1459        assert_eq!(app.input_mode(), InputMode::Insert);
1460    }
1461
1462    #[test]
1463    fn q_quits_in_normal_mode() {
1464        let (mut app, _rx, _tx) = make_app();
1465        app.sessions.current_mut().input_mode = InputMode::Normal;
1466        let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
1467        app.handle_event(AppEvent::Key(key));
1468        assert!(app.should_quit);
1469    }
1470
1471    #[test]
1472    fn backspace_deletes_char() {
1473        let (mut app, _rx, _tx) = make_app();
1474        app.sessions.current_mut().input_mode = InputMode::Insert;
1475        app.sessions.current_mut().input = "ab".into();
1476        app.sessions.current_mut().cursor_position = 2;
1477        let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
1478        app.handle_event(AppEvent::Key(key));
1479        assert_eq!(app.input(), "a");
1480        assert_eq!(app.cursor_position(), 1);
1481    }
1482
1483    #[test]
1484    fn enter_submits_input() {
1485        let (mut app, mut rx, _tx) = make_app();
1486        app.sessions.current_mut().input_mode = InputMode::Insert;
1487        app.sessions.current_mut().input = "hello".into();
1488        app.sessions.current_mut().cursor_position = 5;
1489        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
1490        app.handle_event(AppEvent::Key(key));
1491        assert!(app.input().is_empty());
1492        assert_eq!(app.messages().len(), 1);
1493        assert_eq!(app.messages()[0].content, "hello");
1494
1495        let sent = rx.try_recv().unwrap();
1496        assert_eq!(sent, "hello");
1497    }
1498
1499    #[test]
1500    fn empty_enter_does_not_submit() {
1501        let (mut app, mut rx, _tx) = make_app();
1502        app.sessions.current_mut().input_mode = InputMode::Insert;
1503        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
1504        app.handle_event(AppEvent::Key(key));
1505        assert!(app.messages().is_empty());
1506        assert!(rx.try_recv().is_err());
1507    }
1508
1509    #[test]
1510    fn agent_chunk_creates_streaming_message() {
1511        let (mut app, _rx, _tx) = make_app();
1512        app.handle_agent_event(AgentEvent::Chunk("hel".into()));
1513        assert_eq!(app.messages().len(), 1);
1514        assert!(app.messages()[0].streaming);
1515        assert_eq!(app.messages()[0].content, "hel");
1516
1517        app.handle_agent_event(AgentEvent::Chunk("lo".into()));
1518        assert_eq!(app.messages().len(), 1);
1519        assert_eq!(app.messages()[0].content, "hello");
1520    }
1521
1522    #[test]
1523    fn agent_flush_stops_streaming() {
1524        let (mut app, _rx, _tx) = make_app();
1525        app.handle_agent_event(AgentEvent::Chunk("test".into()));
1526        assert!(app.messages()[0].streaming);
1527        app.handle_agent_event(AgentEvent::Flush);
1528        assert!(!app.messages()[0].streaming);
1529    }
1530
1531    #[test]
1532    fn agent_full_message() {
1533        let (mut app, _rx, _tx) = make_app();
1534        app.handle_agent_event(AgentEvent::FullMessage("done".into()));
1535        assert_eq!(app.messages().len(), 1);
1536        assert!(!app.messages()[0].streaming);
1537        assert_eq!(app.messages()[0].content, "done");
1538    }
1539
1540    #[test]
1541    fn full_message_skips_tool_output_new_format() {
1542        let (mut app, _rx, _tx) = make_app();
1543        app.handle_agent_event(AgentEvent::FullMessage(
1544            "[tool output: bash]\n```\n$ echo hi\nhi\n```".into(),
1545        ));
1546        assert!(app.messages().is_empty());
1547    }
1548
1549    #[test]
1550    fn scroll_in_normal_mode() {
1551        let (mut app, _rx, _tx) = make_app();
1552        app.sessions.current_mut().input_mode = InputMode::Normal;
1553        let up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
1554        app.handle_event(AppEvent::Key(up));
1555        assert_eq!(app.scroll_offset(), 1);
1556
1557        let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
1558        app.handle_event(AppEvent::Key(down));
1559        assert_eq!(app.scroll_offset(), 0);
1560    }
1561
1562    #[test]
1563    fn tab_cycles_panels() {
1564        let (mut app, _rx, _tx) = make_app();
1565        app.sessions.current_mut().input_mode = InputMode::Normal;
1566        assert_eq!(app.active_panel, Panel::Chat);
1567
1568        let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
1569        app.handle_event(AppEvent::Key(tab));
1570        assert_eq!(app.active_panel, Panel::Skills);
1571
1572        app.handle_event(AppEvent::Key(tab));
1573        assert_eq!(app.active_panel, Panel::Memory);
1574
1575        app.handle_event(AppEvent::Key(tab));
1576        assert_eq!(app.active_panel, Panel::Resources);
1577
1578        app.handle_event(AppEvent::Key(tab));
1579        assert_eq!(app.active_panel, Panel::SubAgents);
1580
1581        app.handle_event(AppEvent::Key(tab));
1582        assert_eq!(app.active_panel, Panel::Fleet);
1583
1584        app.handle_event(AppEvent::Key(tab));
1585        assert_eq!(app.active_panel, Panel::Chat);
1586    }
1587
1588    #[test]
1589    fn ctrl_u_clears_input() {
1590        let (mut app, _rx, _tx) = make_app();
1591        app.sessions.current_mut().input_mode = InputMode::Insert;
1592        app.sessions.current_mut().input = "some text".into();
1593        app.sessions.current_mut().cursor_position = 9;
1594        let key = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
1595        app.handle_event(AppEvent::Key(key));
1596        assert!(app.input().is_empty());
1597        assert_eq!(app.cursor_position(), 0);
1598    }
1599
1600    #[test]
1601    fn cursor_movement() {
1602        let (mut app, _rx, _tx) = make_app();
1603        app.sessions.current_mut().input_mode = InputMode::Insert;
1604        app.sessions.current_mut().input = "abc".into();
1605        app.sessions.current_mut().cursor_position = 1;
1606
1607        let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
1608        app.handle_event(AppEvent::Key(left));
1609        assert_eq!(app.cursor_position(), 0);
1610
1611        // left at 0 stays at 0
1612        app.handle_event(AppEvent::Key(left));
1613        assert_eq!(app.cursor_position(), 0);
1614
1615        let right = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
1616        app.handle_event(AppEvent::Key(right));
1617        assert_eq!(app.cursor_position(), 1);
1618
1619        let home = KeyEvent::new(KeyCode::Home, KeyModifiers::NONE);
1620        app.handle_event(AppEvent::Key(home));
1621        assert_eq!(app.cursor_position(), 0);
1622
1623        let end = KeyEvent::new(KeyCode::End, KeyModifiers::NONE);
1624        app.handle_event(AppEvent::Key(end));
1625        assert_eq!(app.cursor_position(), 3);
1626    }
1627
1628    #[test]
1629    fn delete_key_removes_char_at_cursor() {
1630        let (mut app, _rx, _tx) = make_app();
1631        app.sessions.current_mut().input_mode = InputMode::Insert;
1632        app.sessions.current_mut().input = "abc".into();
1633        app.sessions.current_mut().cursor_position = 1;
1634        let key = KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE);
1635        app.handle_event(AppEvent::Key(key));
1636        assert_eq!(app.input(), "ac");
1637        assert_eq!(app.cursor_position(), 1);
1638    }
1639
1640    #[test]
1641    fn unicode_input_insert_and_delete() {
1642        let (mut app, _rx, _tx) = make_app();
1643        app.sessions.current_mut().input_mode = InputMode::Insert;
1644
1645        // Type multi-byte chars
1646        for c in "\u{00e9}a\u{1f600}".chars() {
1647            let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
1648            app.handle_event(AppEvent::Key(key));
1649        }
1650        assert_eq!(app.input(), "\u{00e9}a\u{1f600}");
1651        assert_eq!(app.cursor_position(), 3);
1652
1653        // Backspace removes the emoji (last char)
1654        let bs = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
1655        app.handle_event(AppEvent::Key(bs));
1656        assert_eq!(app.input(), "\u{00e9}a");
1657        assert_eq!(app.cursor_position(), 2);
1658
1659        // Move cursor left and delete 'a'
1660        let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
1661        app.handle_event(AppEvent::Key(left));
1662        assert_eq!(app.cursor_position(), 1);
1663
1664        let del = KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE);
1665        app.handle_event(AppEvent::Key(del));
1666        assert_eq!(app.input(), "\u{00e9}");
1667        assert_eq!(app.cursor_position(), 1);
1668
1669        // End key uses char count, not byte count
1670        let end = KeyEvent::new(KeyCode::End, KeyModifiers::NONE);
1671        app.handle_event(AppEvent::Key(end));
1672        assert_eq!(app.cursor_position(), 1);
1673    }
1674
1675    #[test]
1676    fn confirm_request_sets_state() {
1677        let (mut app, _rx, _tx) = make_app();
1678        let (tx, _rx) = tokio::sync::oneshot::channel();
1679        app.handle_agent_event(AgentEvent::ConfirmRequest {
1680            prompt: "delete?".into(),
1681            response_tx: tx,
1682        });
1683        assert!(app.confirm_state.is_some());
1684        assert_eq!(app.confirm_state.as_ref().unwrap().prompt, "delete?");
1685    }
1686
1687    #[test]
1688    fn confirm_modal_y_sends_true() {
1689        let (mut app, _rx, _tx) = make_app();
1690        let (tx, mut rx) = tokio::sync::oneshot::channel();
1691        app.confirm_state = Some(ConfirmState {
1692            prompt: "proceed?".into(),
1693            response_tx: Some(tx),
1694        });
1695        let key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
1696        app.handle_event(AppEvent::Key(key));
1697        assert!(app.confirm_state.is_none());
1698        assert!(rx.try_recv().unwrap());
1699    }
1700
1701    #[test]
1702    fn confirm_modal_enter_sends_true() {
1703        let (mut app, _rx, _tx) = make_app();
1704        let (tx, mut rx) = tokio::sync::oneshot::channel();
1705        app.confirm_state = Some(ConfirmState {
1706            prompt: "proceed?".into(),
1707            response_tx: Some(tx),
1708        });
1709        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
1710        app.handle_event(AppEvent::Key(key));
1711        assert!(app.confirm_state.is_none());
1712        assert!(rx.try_recv().unwrap());
1713    }
1714
1715    #[test]
1716    fn confirm_modal_n_sends_false() {
1717        let (mut app, _rx, _tx) = make_app();
1718        let (tx, mut rx) = tokio::sync::oneshot::channel();
1719        app.confirm_state = Some(ConfirmState {
1720            prompt: "delete?".into(),
1721            response_tx: Some(tx),
1722        });
1723        let key = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE);
1724        app.handle_event(AppEvent::Key(key));
1725        assert!(app.confirm_state.is_none());
1726        assert!(!rx.try_recv().unwrap());
1727    }
1728
1729    #[test]
1730    fn confirm_modal_escape_sends_false() {
1731        let (mut app, _rx, _tx) = make_app();
1732        let (tx, mut rx) = tokio::sync::oneshot::channel();
1733        app.confirm_state = Some(ConfirmState {
1734            prompt: "delete?".into(),
1735            response_tx: Some(tx),
1736        });
1737        let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
1738        app.handle_event(AppEvent::Key(key));
1739        assert!(app.confirm_state.is_none());
1740        assert!(!rx.try_recv().unwrap());
1741    }
1742
1743    #[test]
1744    fn try_switch_blocked_by_confirm_modal() {
1745        let (mut app, _rx, _tx) = make_app();
1746        let (tx, _oneshot_rx) = tokio::sync::oneshot::channel();
1747        app.confirm_state = Some(ConfirmState {
1748            prompt: "ok?".into(),
1749            response_tx: Some(tx),
1750        });
1751        let prev_active = app.sessions.active();
1752        app.execute_command(TuiCommand::SessionSwitchNext);
1753        assert_eq!(app.sessions.active(), prev_active);
1754        assert!(
1755            app.sessions
1756                .current()
1757                .messages
1758                .iter()
1759                .any(|m| m.content.contains("Resolve"))
1760        );
1761    }
1762
1763    #[test]
1764    fn try_switch_blocked_by_elicitation_modal() {
1765        let (mut app, _rx, _tx) = make_app();
1766        let (tx, _oneshot_rx) = tokio::sync::oneshot::channel();
1767        let req = zeph_core::channel::ElicitationRequest {
1768            server_name: "test".into(),
1769            message: "test".into(),
1770            fields: vec![],
1771        };
1772        app.elicitation_state = Some(ElicitationState {
1773            dialog: crate::widgets::elicitation::ElicitationDialogState::new(req),
1774            response_tx: Some(tx),
1775        });
1776        let prev_active = app.sessions.active();
1777        app.execute_command(TuiCommand::SessionSwitchPrev);
1778        assert_eq!(app.sessions.active(), prev_active);
1779        assert!(
1780            app.sessions
1781                .current()
1782                .messages
1783                .iter()
1784                .any(|m| m.content.contains("Resolve"))
1785        );
1786    }
1787
1788    #[test]
1789    fn try_switch_close_refused_on_last_slot() {
1790        let (mut app, _rx, _tx) = make_app();
1791        app.execute_command(TuiCommand::SessionClose);
1792        assert!(
1793            app.sessions
1794                .current()
1795                .messages
1796                .iter()
1797                .any(|m| m.content.contains("Cannot close"))
1798        );
1799    }
1800
1801    #[test]
1802    fn confirm_modal_blocks_other_keys() {
1803        let (mut app, _rx, _tx) = make_app();
1804        let (tx, _oneshot_rx) = tokio::sync::oneshot::channel();
1805        app.sessions.current_mut().input_mode = InputMode::Insert;
1806        app.confirm_state = Some(ConfirmState {
1807            prompt: "test?".into(),
1808            response_tx: Some(tx),
1809        });
1810        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
1811        app.handle_event(AppEvent::Key(key));
1812        assert!(app.input().is_empty());
1813        assert!(app.confirm_state.is_some());
1814    }
1815
1816    #[test]
1817    fn shift_enter_inserts_newline() {
1818        let (mut app, mut rx, _tx) = make_app();
1819        app.sessions.current_mut().input_mode = InputMode::Insert;
1820        app.sessions.current_mut().input = "hello".into();
1821        app.sessions.current_mut().cursor_position = 5;
1822        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
1823        app.handle_event(AppEvent::Key(key));
1824        assert_eq!(app.input(), "hello\n");
1825        assert_eq!(app.cursor_position(), 6);
1826        assert!(app.messages().is_empty());
1827        assert!(rx.try_recv().is_err());
1828    }
1829
1830    #[test]
1831    fn ctrl_j_inserts_newline() {
1832        let (mut app, mut rx, _tx) = make_app();
1833        app.sessions.current_mut().input_mode = InputMode::Insert;
1834        app.sessions.current_mut().input = "hello".into();
1835        app.sessions.current_mut().cursor_position = 5;
1836        let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL);
1837        app.handle_event(AppEvent::Key(key));
1838        assert_eq!(app.input(), "hello\n");
1839        assert_eq!(app.cursor_position(), 6);
1840        assert!(app.messages().is_empty());
1841        assert!(rx.try_recv().is_err());
1842    }
1843
1844    #[test]
1845    fn shift_enter_mid_input() {
1846        let (mut app, _rx, _tx) = make_app();
1847        app.sessions.current_mut().input_mode = InputMode::Insert;
1848        app.sessions.current_mut().input = "ab".into();
1849        app.sessions.current_mut().cursor_position = 1;
1850        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
1851        app.handle_event(AppEvent::Key(key));
1852        assert_eq!(app.input(), "a\nb");
1853        assert_eq!(app.cursor_position(), 2);
1854    }
1855
1856    #[test]
1857    fn d_toggles_side_panels() {
1858        let (mut app, _rx, _tx) = make_app();
1859        app.sessions.current_mut().input_mode = InputMode::Normal;
1860        assert!(app.show_side_panels());
1861
1862        let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
1863        app.handle_event(AppEvent::Key(key));
1864        assert!(!app.show_side_panels());
1865
1866        app.handle_event(AppEvent::Key(key));
1867        assert!(app.show_side_panels());
1868    }
1869
1870    #[test]
1871    fn scroll_up_via_key() {
1872        let (mut app, _rx, _tx) = make_app();
1873        app.sessions.current_mut().input_mode = InputMode::Normal;
1874        assert_eq!(app.scroll_offset(), 0);
1875        app.handle_event(AppEvent::Key(KeyEvent::new(
1876            KeyCode::Up,
1877            KeyModifiers::NONE,
1878        )));
1879        assert_eq!(app.scroll_offset(), 1);
1880        app.handle_event(AppEvent::Key(KeyEvent::new(
1881            KeyCode::Up,
1882            KeyModifiers::NONE,
1883        )));
1884        assert_eq!(app.scroll_offset(), 2);
1885    }
1886
1887    #[test]
1888    fn scroll_down_via_key() {
1889        let (mut app, _rx, _tx) = make_app();
1890        app.sessions.current_mut().input_mode = InputMode::Normal;
1891        app.sessions.current_mut().scroll_offset = 5;
1892        app.handle_event(AppEvent::Key(KeyEvent::new(
1893            KeyCode::Down,
1894            KeyModifiers::NONE,
1895        )));
1896        assert_eq!(app.scroll_offset(), 4);
1897        app.handle_event(AppEvent::Key(KeyEvent::new(
1898            KeyCode::Down,
1899            KeyModifiers::NONE,
1900        )));
1901        assert_eq!(app.scroll_offset(), 3);
1902    }
1903
1904    #[test]
1905    fn scroll_down_saturates_at_zero() {
1906        let (mut app, _rx, _tx) = make_app();
1907        app.sessions.current_mut().input_mode = InputMode::Normal;
1908        app.sessions.current_mut().scroll_offset = 1;
1909        app.handle_event(AppEvent::Key(KeyEvent::new(
1910            KeyCode::Down,
1911            KeyModifiers::NONE,
1912        )));
1913        assert_eq!(app.scroll_offset(), 0);
1914        app.handle_event(AppEvent::Key(KeyEvent::new(
1915            KeyCode::Down,
1916            KeyModifiers::NONE,
1917        )));
1918        assert_eq!(app.scroll_offset(), 0);
1919    }
1920
1921    #[test]
1922    fn scroll_during_confirm_blocked() {
1923        let (mut app, _rx, _tx) = make_app();
1924        app.sessions.current_mut().input_mode = InputMode::Normal;
1925        let (tx, _oneshot_rx) = tokio::sync::oneshot::channel();
1926        app.confirm_state = Some(ConfirmState {
1927            prompt: "test?".into(),
1928            response_tx: Some(tx),
1929        });
1930        app.sessions.current_mut().scroll_offset = 5;
1931        // The confirm dialog intercepts all key events before the normal key handler.
1932        app.handle_event(AppEvent::Key(KeyEvent::new(
1933            KeyCode::Up,
1934            KeyModifiers::NONE,
1935        )));
1936        assert_eq!(app.scroll_offset(), 5);
1937        app.handle_event(AppEvent::Key(KeyEvent::new(
1938            KeyCode::Down,
1939            KeyModifiers::NONE,
1940        )));
1941        assert_eq!(app.scroll_offset(), 5);
1942    }
1943
1944    #[test]
1945    fn load_history_recognizes_tool_output_new_format() {
1946        let (mut app, _rx, _tx) = make_app();
1947        app.load_history(&[
1948            ("user", "hello"),
1949            ("assistant", "hi there"),
1950            ("user", "[tool output: bash]\n```\n$ echo hello\nhello\n```"),
1951            ("assistant", "done"),
1952        ]);
1953        assert_eq!(app.messages().len(), 4);
1954        assert_eq!(app.messages()[0].role, MessageRole::User);
1955        assert_eq!(app.messages()[1].role, MessageRole::Assistant);
1956        assert_eq!(app.messages()[2].role, MessageRole::Tool);
1957        assert_eq!(
1958            app.messages()[2]
1959                .tool_name
1960                .as_ref()
1961                .map(zeph_common::ToolName::as_str),
1962            Some("bash")
1963        );
1964        assert_eq!(app.messages()[2].content, "$ echo hello\nhello");
1965        assert_eq!(app.messages()[3].role, MessageRole::Assistant);
1966    }
1967
1968    #[test]
1969    fn load_history_recognizes_legacy_tool_output() {
1970        let (mut app, _rx, _tx) = make_app();
1971        app.load_history(&[("user", "[tool output]\n```\n$ ls\nfile.txt\n```")]);
1972        assert_eq!(app.messages().len(), 1);
1973        assert_eq!(app.messages()[0].role, MessageRole::Tool);
1974        assert_eq!(
1975            app.messages()[0]
1976                .tool_name
1977                .as_ref()
1978                .map(zeph_common::ToolName::as_str),
1979            Some("bash")
1980        );
1981        assert_eq!(app.messages()[0].content, "$ ls\nfile.txt");
1982    }
1983
1984    #[test]
1985    fn load_history_legacy_non_bash_tool() {
1986        let (mut app, _rx, _tx) = make_app();
1987        app.load_history(&[(
1988            "user",
1989            "[tool output]\n```\n[mcp:github:list]\nresults\n```",
1990        )]);
1991        assert_eq!(app.messages().len(), 1);
1992        assert_eq!(app.messages()[0].role, MessageRole::Tool);
1993        assert_eq!(
1994            app.messages()[0]
1995                .tool_name
1996                .as_ref()
1997                .map(zeph_common::ToolName::as_str),
1998            Some("tool")
1999        );
2000    }
2001
2002    #[test]
2003    fn load_history_recognizes_tool_result_format() {
2004        let (mut app, _rx, _tx) = make_app();
2005        app.load_history(&[("user", "[tool_result: toolu_abc]\n$ echo hello\nhello")]);
2006        assert_eq!(app.messages().len(), 1);
2007        assert_eq!(app.messages()[0].role, MessageRole::Tool);
2008        assert_eq!(
2009            app.messages()[0]
2010                .tool_name
2011                .as_ref()
2012                .map(zeph_common::ToolName::as_str),
2013            Some("bash")
2014        );
2015        assert_eq!(app.messages()[0].content, "$ echo hello\nhello");
2016    }
2017
2018    #[test]
2019    fn load_history_hides_tool_use_only_messages() {
2020        let (mut app, _rx, _tx) = make_app();
2021        app.load_history(&[
2022            ("user", "hello"),
2023            (
2024                "assistant",
2025                "[tool_use: bash(toolu_01AfnYMrx3Ub13LLQ1Py3nfg)]",
2026            ),
2027            ("assistant", "here is the result"),
2028        ]);
2029        assert_eq!(app.messages().len(), 2);
2030        assert_eq!(app.messages()[0].role, MessageRole::User);
2031        assert_eq!(app.messages()[1].role, MessageRole::Assistant);
2032        assert_eq!(app.messages()[1].content, "here is the result");
2033    }
2034
2035    #[test]
2036    fn load_history_keeps_assistant_with_text_and_tool_use() {
2037        let (mut app, _rx, _tx) = make_app();
2038        app.load_history(&[("assistant", "Let me check. [tool_use: bash(toolu_abc)]")]);
2039        assert_eq!(app.messages().len(), 1);
2040        assert_eq!(app.messages()[0].role, MessageRole::Assistant);
2041    }
2042
2043    #[test]
2044    fn is_tool_use_only_multiple_tags() {
2045        assert!(is_tool_use_only(
2046            "[tool_use: bash(id1)] [tool_use: read(id2)]"
2047        ));
2048        assert!(!is_tool_use_only("text [tool_use: bash(id1)]"));
2049        assert!(!is_tool_use_only(""));
2050    }
2051
2052    #[test]
2053    fn tool_output_without_prior_tool_start_creates_tool_message_with_diff() {
2054        let (mut app, _rx, _tx) = make_app();
2055        let diff = zeph_core::DiffData {
2056            file_path: "src/lib.rs".into(),
2057            old_content: "fn old() {}".into(),
2058            new_content: "fn new() {}".into(),
2059        };
2060        app.handle_agent_event(AgentEvent::ToolOutput {
2061            tool_name: "edit".into(),
2062            command: "[tool output: edit]\n```\nok\n```".into(),
2063            output: "[tool output: edit]\n```\nok\n```".into(),
2064            success: true,
2065            diff: Some(diff),
2066            filter_stats: None,
2067            kept_lines: None,
2068            tool_call_id: "call-1".into(),
2069        });
2070
2071        assert_eq!(app.messages().len(), 1);
2072        let msg = &app.messages()[0];
2073        assert_eq!(msg.role, MessageRole::Tool);
2074        assert!(!msg.streaming);
2075        assert!(msg.diff_data.is_some());
2076    }
2077
2078    #[test]
2079    fn tool_output_without_diff_does_not_create_spurious_message() {
2080        let (mut app, _rx, _tx) = make_app();
2081        app.handle_agent_event(AgentEvent::ToolOutput {
2082            tool_name: "read".into(),
2083            command: "[tool output: read]\n```\ncontent\n```".into(),
2084            output: "[tool output: read]\n```\ncontent\n```".into(),
2085            success: true,
2086            diff: None,
2087            filter_stats: None,
2088            kept_lines: None,
2089            tool_call_id: "call-2".into(),
2090        });
2091
2092        // No prior ToolStart and no diff/filter_stats: nothing to display.
2093        assert!(app.messages().is_empty());
2094    }
2095
2096    #[test]
2097    fn show_help_defaults_to_false() {
2098        let (app, _rx, _tx) = make_app();
2099        assert!(!app.show_help);
2100    }
2101
2102    #[test]
2103    fn question_mark_in_normal_mode_opens_help() {
2104        let (mut app, _rx, _tx) = make_app();
2105        app.sessions.current_mut().input_mode = InputMode::Normal;
2106        let key = KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE);
2107        app.handle_event(AppEvent::Key(key));
2108        assert!(app.show_help);
2109    }
2110
2111    #[test]
2112    fn question_mark_toggles_help_closed() {
2113        let (mut app, _rx, _tx) = make_app();
2114        app.show_help = true;
2115        let key = KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE);
2116        app.handle_event(AppEvent::Key(key));
2117        assert!(!app.show_help);
2118    }
2119
2120    #[test]
2121    fn esc_closes_help_popup() {
2122        let (mut app, _rx, _tx) = make_app();
2123        app.show_help = true;
2124        let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
2125        app.handle_event(AppEvent::Key(key));
2126        assert!(!app.show_help);
2127    }
2128
2129    #[test]
2130    fn other_keys_ignored_when_help_open() {
2131        let (mut app, _rx, _tx) = make_app();
2132        app.sessions.current_mut().input_mode = InputMode::Insert;
2133        app.show_help = true;
2134
2135        // Typing a character should not modify input
2136        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
2137        app.handle_event(AppEvent::Key(key));
2138        assert!(app.input().is_empty());
2139        assert!(app.show_help);
2140
2141        // Enter should not submit
2142        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
2143        app.handle_event(AppEvent::Key(key));
2144        assert!(app.messages().is_empty());
2145        assert!(app.show_help);
2146    }
2147
2148    #[test]
2149    fn help_popup_does_not_block_ctrl_c() {
2150        let (mut app, _rx, _tx) = make_app();
2151        app.show_help = true;
2152        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
2153        app.handle_event(AppEvent::Key(key));
2154        assert!(app.should_quit);
2155    }
2156
2157    #[test]
2158    fn question_mark_in_insert_mode_does_not_open_help() {
2159        let (mut app, _rx, _tx) = make_app();
2160        app.sessions.current_mut().input_mode = InputMode::Insert;
2161        let key = KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE);
2162        app.handle_event(AppEvent::Key(key));
2163        assert!(!app.show_help);
2164        assert_eq!(app.input(), "?");
2165    }
2166
2167    #[tokio::test]
2168    async fn esc_in_normal_mode_cancels_when_busy() {
2169        let (mut app, _rx, _tx) = make_app();
2170        let notify = Arc::new(Notify::new());
2171        let notify_waiter = Arc::clone(&notify);
2172        let handle = tokio::spawn(async move {
2173            notify_waiter.notified().await;
2174            true
2175        });
2176        tokio::task::yield_now().await;
2177
2178        app = app.with_cancel_signal(Arc::clone(&notify));
2179        app.sessions.current_mut().input_mode = InputMode::Normal;
2180        app.sessions.current_mut().status_label = Some("Thinking...".into());
2181        assert!(app.is_agent_busy());
2182
2183        let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
2184        app.handle_event(AppEvent::Key(key));
2185        let result = tokio::time::timeout(std::time::Duration::from_millis(100), handle).await;
2186        assert!(result.is_ok(), "notify should have been triggered");
2187    }
2188
2189    #[test]
2190    fn esc_in_normal_mode_does_not_cancel_when_idle() {
2191        let (mut app, _rx, _tx) = make_app();
2192        let notify = Arc::new(Notify::new());
2193        app = app.with_cancel_signal(notify);
2194        app.sessions.current_mut().input_mode = InputMode::Normal;
2195        assert!(!app.is_agent_busy());
2196
2197        let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
2198        app.handle_event(AppEvent::Key(key));
2199        // No way to assert "not notified" directly, but we verify no panic
2200    }
2201
2202    #[test]
2203    fn up_with_empty_input_and_queued_recalls_from_history() {
2204        let (mut app, mut rx, _tx) = make_app();
2205        app.sessions.current_mut().input_mode = InputMode::Insert;
2206        app.pending_count = 2;
2207        app.sessions
2208            .current_mut()
2209            .input_history
2210            .push("queued msg".into());
2211        app.sessions
2212            .current_mut()
2213            .messages
2214            .push(ChatMessage::new(MessageRole::User, "queued msg"));
2215
2216        let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
2217        app.handle_event(AppEvent::Key(key));
2218
2219        assert_eq!(app.input(), "queued msg");
2220        assert_eq!(app.cursor_position(), 10);
2221        assert!(app.editing_queued());
2222        assert_eq!(app.queued_count(), 1);
2223        assert!(app.sessions.current_mut().input_history.is_empty());
2224        assert!(app.messages().is_empty());
2225        let sent = rx.try_recv().unwrap();
2226        assert_eq!(sent, "/drop-last-queued");
2227    }
2228
2229    #[test]
2230    fn up_with_non_empty_input_navigates_history() {
2231        let (mut app, mut rx, _tx) = make_app();
2232        app.sessions.current_mut().input_mode = InputMode::Insert;
2233        app.pending_count = 2;
2234        app.sessions.current_mut().input = "hello".into();
2235        app.sessions.current_mut().cursor_position = 5;
2236        app.sessions
2237            .current_mut()
2238            .input_history
2239            .push("hello world".into());
2240
2241        let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
2242        app.handle_event(AppEvent::Key(key));
2243
2244        assert!(rx.try_recv().is_err());
2245        assert_eq!(app.input(), "hello world");
2246    }
2247
2248    #[test]
2249    fn submit_input_resets_editing_queued() {
2250        let (mut app, _rx, _tx) = make_app();
2251        app.editing_queued = true;
2252        app.sessions.current_mut().input = "some text".into();
2253        app.sessions.current_mut().cursor_position = 9;
2254        app.submit_input();
2255        assert!(!app.editing_queued());
2256    }
2257
2258    #[test]
2259    fn desired_input_height_caps_at_three_visible_lines() {
2260        let (mut app, _rx, _tx) = make_app();
2261        app.sessions.current_mut().input_mode = InputMode::Insert;
2262        app.sessions.current_mut().input = "one\ntwo\nthree\nfour".into();
2263        app.sessions.current_mut().cursor_position = app.char_count();
2264
2265        assert_eq!(app.input_line_count(), 4);
2266        assert_eq!(app.desired_input_height(), 5);
2267    }
2268
2269    mod integration {
2270        use super::*;
2271        use crate::test_utils::test_terminal;
2272
2273        fn draw_app(app: &mut App, width: u16, height: u16) -> String {
2274            let mut terminal = test_terminal(width, height);
2275            terminal.draw(|frame| app.draw(frame)).unwrap();
2276            let buf = terminal.backend().buffer().clone();
2277            let mut output = String::new();
2278            for y in 0..buf.area.height {
2279                for x in 0..buf.area.width {
2280                    output.push_str(buf[(x, y)].symbol());
2281                }
2282                output.push('\n');
2283            }
2284            output
2285        }
2286
2287        #[test]
2288        fn submit_message_appears_in_chat() {
2289            let (mut app, _rx, _tx) = make_app();
2290            app.sessions.current_mut().input_mode = InputMode::Insert;
2291            app.sessions.current_mut().input = "hello world".into();
2292            app.sessions.current_mut().cursor_position = 11;
2293            let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
2294            app.handle_event(AppEvent::Key(enter));
2295
2296            let output = draw_app(&mut app, 80, 24);
2297            assert!(output.contains("hello world"));
2298        }
2299
2300        #[test]
2301        fn help_overlay_renders() {
2302            let (mut app, _rx, _tx) = make_app();
2303            app.sessions.current_mut().input_mode = InputMode::Normal;
2304            let key = KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE);
2305            app.handle_event(AppEvent::Key(key));
2306
2307            let output = draw_app(&mut app, 80, 30);
2308            assert!(output.contains("Help"));
2309            assert!(output.contains("quit"));
2310        }
2311
2312        #[test]
2313        fn help_overlay_closes() {
2314            let (mut app, _rx, _tx) = make_app();
2315            app.sessions.current_mut().input_mode = InputMode::Normal;
2316            let open = KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE);
2317            app.handle_event(AppEvent::Key(open));
2318            let close = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
2319            app.handle_event(AppEvent::Key(close));
2320
2321            let output = draw_app(&mut app, 80, 30);
2322            assert!(!output.contains("Help — press"));
2323        }
2324
2325        #[test]
2326        fn confirm_dialog_renders() {
2327            let (mut app, _rx, _tx) = make_app();
2328            let (tx, _oneshot_rx) = tokio::sync::oneshot::channel();
2329            app.confirm_state = Some(ConfirmState {
2330                prompt: "Execute rm -rf?".into(),
2331                response_tx: Some(tx),
2332            });
2333
2334            let output = draw_app(&mut app, 60, 20);
2335            assert!(output.contains("Confirm"));
2336            assert!(output.contains("Execute rm -rf?"));
2337            assert!(output.contains("[Y]es / [N]o"));
2338        }
2339
2340        #[test]
2341        fn confirm_dialog_disappears_after_response() {
2342            let (mut app, _rx, _tx) = make_app();
2343            let (tx, _oneshot_rx) = tokio::sync::oneshot::channel();
2344            app.confirm_state = Some(ConfirmState {
2345                prompt: "Delete?".into(),
2346                response_tx: Some(tx),
2347            });
2348            let key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
2349            app.handle_event(AppEvent::Key(key));
2350
2351            let output = draw_app(&mut app, 60, 20);
2352            assert!(!output.contains("[Y]es / [N]o"));
2353        }
2354
2355        #[test]
2356        fn side_panels_toggle_off() {
2357            let (mut app, _rx, _tx) = make_app();
2358            app.sessions.current_mut().input_mode = InputMode::Normal;
2359
2360            let before = draw_app(&mut app, 120, 40);
2361            assert!(before.contains("Skills"));
2362            assert!(before.contains("Memory"));
2363
2364            let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
2365            app.handle_event(AppEvent::Key(key));
2366
2367            let after = draw_app(&mut app, 120, 40);
2368            assert!(!after.contains("Skills ("));
2369        }
2370
2371        #[test]
2372        fn splash_shown_initially() {
2373            let (mut app, _rx, _tx) = make_app();
2374            let output = draw_app(&mut app, 80, 24);
2375            assert!(output.contains("Type a message to start."));
2376        }
2377
2378        #[test]
2379        fn splash_disappears_after_submit() {
2380            let (mut app, _rx, _tx) = make_app();
2381            app.sessions.current_mut().input_mode = InputMode::Insert;
2382            app.sessions.current_mut().input = "hi".into();
2383            app.sessions.current_mut().cursor_position = 2;
2384            let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
2385            app.handle_event(AppEvent::Key(enter));
2386
2387            assert!(
2388                !app.sessions.current_mut().show_splash,
2389                "splash should be hidden after submit"
2390            );
2391        }
2392
2393        #[test]
2394        fn markdown_link_produces_hyperlink_span() {
2395            let (mut app, _rx, _tx) = make_app();
2396            app.sessions.current_mut().show_splash = false;
2397            app.sessions.current_mut().messages.push(ChatMessage::new(
2398                MessageRole::Assistant,
2399                "See [docs](https://docs.rs) for details",
2400            ));
2401
2402            let _ = draw_app(&mut app, 80, 24);
2403            let links = app.take_hyperlinks();
2404            let doc_link = links.iter().find(|s| s.url == "https://docs.rs");
2405            assert!(
2406                doc_link.is_some(),
2407                "expected hyperlink span for markdown link, got: {links:?}"
2408            );
2409        }
2410
2411        #[test]
2412        fn bare_url_still_produces_hyperlink_span() {
2413            let (mut app, _rx, _tx) = make_app();
2414            app.sessions.current_mut().show_splash = false;
2415            app.sessions.current_mut().messages.push(ChatMessage::new(
2416                MessageRole::Assistant,
2417                "Visit https://example.com today",
2418            ));
2419
2420            let _ = draw_app(&mut app, 80, 24);
2421            let links = app.take_hyperlinks();
2422            let bare = links.iter().find(|s| s.url == "https://example.com");
2423            assert!(
2424                bare.is_some(),
2425                "expected hyperlink span for bare URL, got: {links:?}"
2426            );
2427        }
2428    }
2429
2430    #[test]
2431    fn prev_word_boundary_from_middle_of_word() {
2432        let (mut app, _rx, _tx) = make_app();
2433        app.sessions.current_mut().input = "hello world".into();
2434        app.sessions.current_mut().cursor_position = 8;
2435        assert_eq!(app.prev_word_boundary(), 6);
2436    }
2437
2438    #[test]
2439    fn prev_word_boundary_from_start_of_second_word() {
2440        let (mut app, _rx, _tx) = make_app();
2441        app.sessions.current_mut().input = "hello world".into();
2442        app.sessions.current_mut().cursor_position = 6;
2443        assert_eq!(app.prev_word_boundary(), 0);
2444    }
2445
2446    #[test]
2447    fn prev_word_boundary_at_zero_stays_zero() {
2448        let (mut app, _rx, _tx) = make_app();
2449        app.sessions.current_mut().input = "hello world".into();
2450        app.sessions.current_mut().cursor_position = 0;
2451        assert_eq!(app.prev_word_boundary(), 0);
2452    }
2453
2454    #[test]
2455    fn next_word_boundary_from_middle_of_first_word() {
2456        let (mut app, _rx, _tx) = make_app();
2457        app.sessions.current_mut().input = "hello world".into();
2458        app.sessions.current_mut().cursor_position = 2;
2459        assert_eq!(app.next_word_boundary(), 6);
2460    }
2461
2462    #[test]
2463    fn next_word_boundary_from_start_of_second_word() {
2464        let (mut app, _rx, _tx) = make_app();
2465        app.sessions.current_mut().input = "hello world".into();
2466        app.sessions.current_mut().cursor_position = 6;
2467        assert_eq!(app.next_word_boundary(), 11);
2468    }
2469
2470    #[test]
2471    fn next_word_boundary_at_end_stays_at_end() {
2472        let (mut app, _rx, _tx) = make_app();
2473        app.sessions.current_mut().input = "hello world".into();
2474        app.sessions.current_mut().cursor_position = 11;
2475        assert_eq!(app.next_word_boundary(), 11);
2476    }
2477
2478    #[test]
2479    fn prev_word_boundary_unicode() {
2480        let (mut app, _rx, _tx) = make_app();
2481        // "привет мир" — 6 chars + space + 3 chars = 10 chars total
2482        app.sessions.current_mut().input = "привет мир".into();
2483        app.sessions.current_mut().cursor_position = 9;
2484        assert_eq!(app.prev_word_boundary(), 7);
2485    }
2486
2487    #[test]
2488    fn next_word_boundary_unicode() {
2489        let (mut app, _rx, _tx) = make_app();
2490        // "привет мир" — 6 chars + space + 3 chars
2491        app.sessions.current_mut().input = "привет мир".into();
2492        app.sessions.current_mut().cursor_position = 2;
2493        assert_eq!(app.next_word_boundary(), 7);
2494    }
2495
2496    #[test]
2497    fn alt_left_moves_to_prev_word_boundary() {
2498        let (mut app, _rx, _tx) = make_app();
2499        app.sessions.current_mut().input_mode = InputMode::Insert;
2500        app.sessions.current_mut().input = "hello world".into();
2501        app.sessions.current_mut().cursor_position = 8;
2502        let key = KeyEvent::new(KeyCode::Left, KeyModifiers::ALT);
2503        app.handle_event(AppEvent::Key(key));
2504        assert_eq!(app.cursor_position(), 6);
2505    }
2506
2507    #[test]
2508    fn alt_right_moves_to_next_word_boundary() {
2509        let (mut app, _rx, _tx) = make_app();
2510        app.sessions.current_mut().input_mode = InputMode::Insert;
2511        app.sessions.current_mut().input = "hello world".into();
2512        app.sessions.current_mut().cursor_position = 2;
2513        let key = KeyEvent::new(KeyCode::Right, KeyModifiers::ALT);
2514        app.handle_event(AppEvent::Key(key));
2515        assert_eq!(app.cursor_position(), 6);
2516    }
2517
2518    #[test]
2519    fn ctrl_a_moves_cursor_to_start() {
2520        let (mut app, _rx, _tx) = make_app();
2521        app.sessions.current_mut().input_mode = InputMode::Insert;
2522        app.sessions.current_mut().input = "hello world".into();
2523        app.sessions.current_mut().cursor_position = 7;
2524        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
2525        app.handle_event(AppEvent::Key(key));
2526        assert_eq!(app.cursor_position(), 0);
2527    }
2528
2529    #[test]
2530    fn ctrl_e_moves_cursor_to_end() {
2531        let (mut app, _rx, _tx) = make_app();
2532        app.sessions.current_mut().input_mode = InputMode::Insert;
2533        app.sessions.current_mut().input = "hello world".into();
2534        app.sessions.current_mut().cursor_position = 3;
2535        let key = KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL);
2536        app.handle_event(AppEvent::Key(key));
2537        assert_eq!(app.cursor_position(), 11);
2538    }
2539
2540    #[test]
2541    fn alt_backspace_deletes_to_prev_word_boundary() {
2542        let (mut app, _rx, _tx) = make_app();
2543        app.sessions.current_mut().input_mode = InputMode::Insert;
2544        app.sessions.current_mut().input = "hello world".into();
2545        app.sessions.current_mut().cursor_position = 11;
2546        let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT);
2547        app.handle_event(AppEvent::Key(key));
2548        assert_eq!(app.input(), "hello ");
2549        assert_eq!(app.cursor_position(), 6);
2550    }
2551
2552    #[test]
2553    fn alt_backspace_at_boundary_deletes_word_and_space() {
2554        let (mut app, _rx, _tx) = make_app();
2555        app.sessions.current_mut().input_mode = InputMode::Insert;
2556        app.sessions.current_mut().input = "hello world".into();
2557        app.sessions.current_mut().cursor_position = 6;
2558        let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT);
2559        app.handle_event(AppEvent::Key(key));
2560        assert_eq!(app.input(), "world");
2561        assert_eq!(app.cursor_position(), 0);
2562    }
2563
2564    #[test]
2565    fn alt_backspace_at_zero_is_noop() {
2566        let (mut app, _rx, _tx) = make_app();
2567        app.sessions.current_mut().input_mode = InputMode::Insert;
2568        app.sessions.current_mut().input = "hello".into();
2569        app.sessions.current_mut().cursor_position = 0;
2570        let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT);
2571        app.handle_event(AppEvent::Key(key));
2572        assert_eq!(app.input(), "hello");
2573        assert_eq!(app.cursor_position(), 0);
2574    }
2575
2576    mod proptest_cursor {
2577        use super::*;
2578        use proptest::prelude::*;
2579
2580        proptest! {
2581            #![proptest_config(ProptestConfig::with_cases(500))]
2582
2583            #[test]
2584            fn word_boundaries_stay_in_bounds(
2585                input in "\\PC{0,100}",
2586                cursor in 0usize..=100,
2587            ) {
2588                let (mut app, _rx, _tx) = make_app();
2589                app.sessions.current_mut().input = input;
2590                let len = app.char_count();
2591                app.sessions.current_mut().cursor_position = cursor.min(len);
2592
2593                let prev = app.prev_word_boundary();
2594                prop_assert!(prev <= app.sessions.current_mut().cursor_position, "prev {prev} > cursor {}", app.sessions.current_mut().cursor_position);
2595
2596                let next = app.next_word_boundary();
2597                prop_assert!(next >= app.sessions.current_mut().cursor_position, "next {next} < cursor {}", app.sessions.current_mut().cursor_position);
2598                prop_assert!(next <= len, "next {next} > len {len}");
2599            }
2600
2601            #[test]
2602            fn alt_backspace_keeps_valid_state(
2603                input in "\\PC{0,50}",
2604                cursor in 0usize..=50,
2605            ) {
2606                let (mut app, _rx, _tx) = make_app();
2607                app.sessions.current_mut().input_mode = InputMode::Insert;
2608                app.sessions.current_mut().input = input;
2609                let len = app.char_count();
2610                app.sessions.current_mut().cursor_position = cursor.min(len);
2611
2612                let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT);
2613                app.handle_event(AppEvent::Key(key));
2614
2615                prop_assert!(app.cursor_position() <= app.char_count());
2616            }
2617        }
2618    }
2619
2620    mod render_cache_tests {
2621        use super::*;
2622        use ratatui::text::{Line, Span};
2623
2624        fn make_key(content_hash: u64, width: u16) -> RenderCacheKey {
2625            RenderCacheKey {
2626                content_hash,
2627                terminal_width: width,
2628                tool_expanded: false,
2629                tool_density: zeph_config::ToolDensity::Inline,
2630                show_labels: false,
2631            }
2632        }
2633
2634        #[test]
2635        fn get_returns_none_when_empty() {
2636            let cache = RenderCache::default();
2637            let key = make_key(1, 80);
2638            assert!(cache.get(0, &key).is_none());
2639        }
2640
2641        #[test]
2642        fn put_and_get_returns_cached_lines() {
2643            let mut cache = RenderCache::default();
2644            let key = make_key(42, 80);
2645            let lines = vec![Line::from(Span::raw("hello"))];
2646            cache.put(0, key, lines.clone(), vec![]);
2647            let (result, _) = cache.get(0, &key).unwrap();
2648            assert_eq!(result.len(), 1);
2649            assert_eq!(result[0].spans[0].content, "hello");
2650        }
2651
2652        #[test]
2653        fn get_returns_none_on_key_mismatch() {
2654            let mut cache = RenderCache::default();
2655            let key1 = make_key(1, 80);
2656            let key2 = make_key(2, 80);
2657            let lines = vec![Line::from(Span::raw("a"))];
2658            cache.put(0, key1, lines, vec![]);
2659            assert!(cache.get(0, &key2).is_none());
2660        }
2661
2662        #[test]
2663        fn get_returns_none_on_width_mismatch() {
2664            let mut cache = RenderCache::default();
2665            let key80 = make_key(1, 80);
2666            let key100 = make_key(1, 100);
2667            let lines = vec![Line::from(Span::raw("b"))];
2668            cache.put(0, key80, lines, vec![]);
2669            assert!(cache.get(0, &key100).is_none());
2670        }
2671
2672        #[test]
2673        fn invalidate_clears_single_entry() {
2674            let mut cache = RenderCache::default();
2675            let key = make_key(1, 80);
2676            let lines = vec![Line::from(Span::raw("x"))];
2677            cache.put(0, key, lines, vec![]);
2678            assert!(cache.get(0, &key).is_some());
2679            cache.invalidate(0);
2680            assert!(cache.get(0, &key).is_none());
2681        }
2682
2683        #[test]
2684        fn invalidate_out_of_bounds_is_noop() {
2685            let mut cache = RenderCache::default();
2686            cache.invalidate(99);
2687        }
2688
2689        #[test]
2690        fn clear_removes_all_entries() {
2691            let mut cache = RenderCache::default();
2692            let key0 = make_key(1, 80);
2693            let key1 = make_key(2, 80);
2694            cache.put(0, key0, vec![Line::from(Span::raw("a"))], vec![]);
2695            cache.put(1, key1, vec![Line::from(Span::raw("b"))], vec![]);
2696            cache.clear();
2697            assert!(cache.get(0, &key0).is_none());
2698            assert!(cache.get(1, &key1).is_none());
2699        }
2700
2701        #[test]
2702        fn put_grows_entries_for_non_contiguous_index() {
2703            let mut cache = RenderCache::default();
2704            let key = make_key(5, 80);
2705            let lines = vec![Line::from(Span::raw("z"))];
2706            cache.put(5, key, lines, vec![]);
2707            let (result, _) = cache.get(5, &key).unwrap();
2708            assert_eq!(result[0].spans[0].content, "z");
2709        }
2710    }
2711
2712    mod try_recv_tests {
2713        use super::*;
2714
2715        #[test]
2716        fn try_recv_returns_empty_when_no_events() {
2717            let (mut app, _rx, _tx) = make_app();
2718            let result = app.try_recv_agent_event();
2719            assert!(matches!(result, Err(mpsc::error::TryRecvError::Empty)));
2720        }
2721
2722        #[test]
2723        fn try_recv_returns_event_when_available() {
2724            let (mut app, _rx, tx) = make_app();
2725            tx.try_send(AgentEvent::Typing).unwrap();
2726            let result = app.try_recv_agent_event();
2727            assert!(result.is_ok());
2728            assert!(matches!(result.unwrap(), AgentEvent::Typing));
2729        }
2730
2731        #[test]
2732        fn try_recv_returns_disconnected_when_sender_dropped() {
2733            let (mut app, _rx, tx) = make_app();
2734            drop(tx);
2735            let result = app.try_recv_agent_event();
2736            assert!(matches!(
2737                result,
2738                Err(mpsc::error::TryRecvError::Disconnected)
2739            ));
2740        }
2741    }
2742
2743    mod command_palette_tests {
2744        use super::*;
2745
2746        #[test]
2747        fn colon_in_normal_mode_opens_palette() {
2748            let (mut app, _rx, _tx) = make_app();
2749            app.sessions.current_mut().input_mode = InputMode::Normal;
2750            assert!(app.command_palette.is_none());
2751
2752            let key = KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE);
2753            app.handle_event(AppEvent::Key(key));
2754            assert!(app.command_palette.is_some());
2755        }
2756
2757        #[test]
2758        fn esc_closes_palette() {
2759            let (mut app, _rx, _tx) = make_app();
2760            app.sessions.current_mut().input_mode = InputMode::Normal;
2761            app.command_palette = Some(crate::widgets::command_palette::CommandPaletteState::new());
2762
2763            let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
2764            app.handle_event(AppEvent::Key(key));
2765            assert!(app.command_palette.is_none());
2766        }
2767
2768        #[test]
2769        fn palette_intercepts_all_keys_except_ctrl_c() {
2770            let (mut app, _rx, _tx) = make_app();
2771            app.sessions.current_mut().input_mode = InputMode::Insert;
2772            app.command_palette = Some(crate::widgets::command_palette::CommandPaletteState::new());
2773
2774            // Typing a char goes to palette, not to input field
2775            let key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE);
2776            app.handle_event(AppEvent::Key(key));
2777            assert!(app.input().is_empty());
2778            let palette = app.command_palette.as_ref().unwrap();
2779            assert_eq!(palette.query, "s");
2780        }
2781
2782        #[test]
2783        fn enter_on_selected_dispatches_command_locally() {
2784            let (mut app, _rx, _tx) = make_app();
2785            app.sessions.current_mut().input_mode = InputMode::Normal;
2786            // Open palette
2787            let colon = KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE);
2788            app.handle_event(AppEvent::Key(colon));
2789            assert!(app.command_palette.is_some());
2790
2791            // Enter on first command (skill:list)
2792            let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
2793            app.handle_event(AppEvent::Key(enter));
2794            assert!(app.command_palette.is_none());
2795            // Should have added a system message
2796            assert!(!app.messages().is_empty());
2797            assert_eq!(app.messages().last().unwrap().role, MessageRole::System);
2798        }
2799
2800        #[test]
2801        fn typing_in_palette_filters_commands() {
2802            let (mut app, _rx, _tx) = make_app();
2803            app.command_palette = Some(crate::widgets::command_palette::CommandPaletteState::new());
2804
2805            let m = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
2806            let c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
2807            let p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE);
2808            app.handle_event(AppEvent::Key(m));
2809            app.handle_event(AppEvent::Key(c));
2810            app.handle_event(AppEvent::Key(p));
2811
2812            let palette = app.command_palette.as_ref().unwrap();
2813            assert_eq!(palette.query, "mcp");
2814            // mcp:list is the top result; plan:confirm also fuzzy-matches "mcp" (m→c→p in label).
2815            assert!(
2816                palette.filtered.iter().any(|e| e.id == "mcp:list"),
2817                "mcp:list must be in filtered results"
2818            );
2819            assert_eq!(
2820                palette.filtered[0].id, "mcp:list",
2821                "mcp:list must rank first"
2822            );
2823        }
2824
2825        #[test]
2826        fn backspace_in_palette_removes_char() {
2827            let (mut app, _rx, _tx) = make_app();
2828            app.command_palette = Some(crate::widgets::command_palette::CommandPaletteState::new());
2829
2830            let s = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE);
2831            app.handle_event(AppEvent::Key(s));
2832            assert_eq!(app.command_palette.as_ref().unwrap().query, "s");
2833
2834            let bs = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
2835            app.handle_event(AppEvent::Key(bs));
2836            assert!(app.command_palette.as_ref().unwrap().query.is_empty());
2837        }
2838
2839        #[test]
2840        fn command_result_event_adds_system_message() {
2841            let (mut app, _rx, _tx) = make_app();
2842            app.handle_agent_event(AgentEvent::CommandResult {
2843                command_id: "skill:list".to_owned(),
2844                output: "No skills loaded.".to_owned(),
2845            });
2846            assert_eq!(app.messages().len(), 1);
2847            assert_eq!(app.messages()[0].role, MessageRole::System);
2848            assert_eq!(app.messages()[0].content, "No skills loaded.");
2849            assert!(app.command_palette.is_none());
2850        }
2851
2852        #[test]
2853        fn command_result_closes_palette_if_open() {
2854            let (mut app, _rx, _tx) = make_app();
2855            app.command_palette = Some(crate::widgets::command_palette::CommandPaletteState::new());
2856            app.handle_agent_event(AgentEvent::CommandResult {
2857                command_id: "view:config".to_owned(),
2858                output: "config output".to_owned(),
2859            });
2860            assert!(app.command_palette.is_none());
2861        }
2862
2863        #[test]
2864        fn colon_in_insert_mode_types_colon() {
2865            let (mut app, _rx, _tx) = make_app();
2866            app.sessions.current_mut().input_mode = InputMode::Insert;
2867            let key = KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE);
2868            app.handle_event(AppEvent::Key(key));
2869            assert!(app.command_palette.is_none());
2870            assert_eq!(app.input(), ":");
2871        }
2872
2873        #[test]
2874        fn enter_with_empty_filter_does_not_panic() {
2875            let (mut app, _rx, _tx) = make_app();
2876            let mut palette = crate::widgets::command_palette::CommandPaletteState::new();
2877            // type something that matches nothing
2878            for c in "xxxxxxxxxx".chars() {
2879                palette.push_char(c);
2880            }
2881            assert!(palette.filtered.is_empty());
2882            app.command_palette = Some(palette);
2883
2884            let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
2885            app.handle_event(AppEvent::Key(enter));
2886            // palette should close without crashing, no message added
2887            assert!(app.command_palette.is_none());
2888        }
2889
2890        #[test]
2891        fn execute_view_config_with_command_tx_sends_command() {
2892            let (mut app, _rx, _tx) = make_app();
2893            let (cmd_tx, mut cmd_rx) = mpsc::channel::<TuiCommand>(16);
2894            app.command_tx = Some(cmd_tx);
2895
2896            app.execute_command(TuiCommand::ViewConfig);
2897
2898            let received = cmd_rx.try_recv().expect("command should be sent");
2899            assert_eq!(received, TuiCommand::ViewConfig);
2900            assert!(
2901                app.messages().is_empty(),
2902                "no system message when channel present"
2903            );
2904        }
2905
2906        #[test]
2907        fn execute_view_autonomy_with_command_tx_sends_command() {
2908            let (mut app, _rx, _tx) = make_app();
2909            let (cmd_tx, mut cmd_rx) = mpsc::channel::<TuiCommand>(16);
2910            app.command_tx = Some(cmd_tx);
2911
2912            app.execute_command(TuiCommand::ViewAutonomy);
2913
2914            let received = cmd_rx.try_recv().expect("command should be sent");
2915            assert_eq!(received, TuiCommand::ViewAutonomy);
2916            assert!(
2917                app.messages().is_empty(),
2918                "no system message when channel present"
2919            );
2920        }
2921
2922        #[test]
2923        fn execute_view_config_without_command_tx_adds_fallback_message() {
2924            let (mut app, _rx, _tx) = make_app();
2925            assert!(app.command_tx.is_none());
2926
2927            app.execute_command(TuiCommand::ViewConfig);
2928
2929            assert_eq!(app.messages().len(), 1);
2930            assert!(app.messages()[0].content.contains("no command channel"));
2931        }
2932
2933        #[test]
2934        fn execute_security_events_no_events_shows_history_header() {
2935            let (mut app, _rx, _tx) = make_app();
2936            app.execute_command(TuiCommand::SecurityEvents);
2937            assert_eq!(app.messages().len(), 1);
2938            assert!(app.messages()[0].content.contains("Security event history"));
2939        }
2940
2941        #[test]
2942        fn execute_security_events_with_events_shows_all() {
2943            use zeph_common::SecurityEventCategory;
2944            use zeph_core::metrics::SecurityEvent;
2945
2946            let (mut app, _rx, _tx) = make_app();
2947            app.metrics.security_events.push_back(SecurityEvent::new(
2948                SecurityEventCategory::InjectionFlag,
2949                "web_scrape",
2950                "Detected pattern: ignore previous",
2951            ));
2952            app.execute_command(TuiCommand::SecurityEvents);
2953            let content = &app.messages()[0].content;
2954            assert!(content.contains("web_scrape"));
2955            assert!(content.contains("INJECTION_FLAG"));
2956        }
2957
2958        #[test]
2959        fn has_recent_security_events_false_when_no_events() {
2960            let (app, _rx, _tx) = make_app();
2961            assert!(!app.has_recent_security_events());
2962        }
2963
2964        #[test]
2965        fn has_recent_security_events_true_when_recent() {
2966            use zeph_common::SecurityEventCategory;
2967            use zeph_core::metrics::SecurityEvent;
2968
2969            let (mut app, _rx, _tx) = make_app();
2970            // Event with current timestamp is recent
2971            app.metrics.security_events.push_back(SecurityEvent::new(
2972                SecurityEventCategory::Truncation,
2973                "tool",
2974                "truncated",
2975            ));
2976            assert!(app.has_recent_security_events());
2977        }
2978
2979        #[test]
2980        fn has_recent_security_events_false_when_event_older_than_60s() {
2981            use zeph_common::SecurityEventCategory;
2982            use zeph_core::metrics::SecurityEvent;
2983
2984            let (mut app, _rx, _tx) = make_app();
2985            let now = std::time::SystemTime::now()
2986                .duration_since(std::time::UNIX_EPOCH)
2987                .unwrap_or_default()
2988                .as_secs();
2989            let mut ev = SecurityEvent::new(SecurityEventCategory::Truncation, "tool", "old");
2990            // Backdate the event by 120 seconds.
2991            ev.timestamp = now.saturating_sub(120);
2992            app.metrics.security_events.push_back(ev);
2993            assert!(!app.has_recent_security_events());
2994        }
2995    }
2996
2997    mod file_picker_tests {
2998        use std::fs;
2999
3000        use super::*;
3001        use crate::file_picker::FileIndex;
3002
3003        fn make_app_with_index() -> (App, mpsc::Receiver<String>, mpsc::Sender<AgentEvent>) {
3004            let (app, rx, tx) = make_app();
3005            (app, rx, tx)
3006        }
3007
3008        fn build_temp_index(files: &[&str]) -> (FileIndex, tempfile::TempDir) {
3009            let dir = tempfile::tempdir().unwrap();
3010            for &f in files {
3011                let path = dir.path().join(f);
3012                if let Some(parent) = path.parent() {
3013                    fs::create_dir_all(parent).unwrap();
3014                }
3015                fs::write(&path, "").unwrap();
3016            }
3017            let idx = FileIndex::build(dir.path());
3018            (idx, dir)
3019        }
3020
3021        fn open_picker_with_index(app: &mut App, idx: &FileIndex) {
3022            let dir = tempfile::tempdir().unwrap();
3023            let path = dir.path().to_owned();
3024            drop(dir.keep());
3025            app.file_index = Some(FileIndex::build(&path));
3026            // Replace with our controlled index
3027            app.file_picker_state = Some(crate::file_picker::FilePickerState::new(idx));
3028        }
3029
3030        #[test]
3031        fn at_sign_opens_picker_and_does_not_insert_into_input() {
3032            let (mut app, _rx, _tx) = make_app_with_index();
3033            // Pre-populate a fresh index so open_file_picker can open the picker immediately
3034            // without spawning a background build (which requires a Tokio runtime).
3035            let (idx, _dir) = build_temp_index(&["a.rs"]);
3036            app.file_index = Some(idx);
3037            app.sessions.current_mut().input_mode = InputMode::Insert;
3038            let key = KeyEvent::new(KeyCode::Char('@'), KeyModifiers::NONE);
3039            app.handle_event(AppEvent::Key(key));
3040            assert!(
3041                !app.sessions.current_mut().input.contains('@'),
3042                "@ should not be in input after opening picker"
3043            );
3044            assert!(
3045                app.file_picker_state.is_some(),
3046                "file_picker_state should be Some after @"
3047            );
3048        }
3049
3050        #[test]
3051        fn esc_dismisses_picker() {
3052            let (mut app, _rx, _tx) = make_app_with_index();
3053            let (idx, _dir) = build_temp_index(&["a.rs", "b.rs"]);
3054            open_picker_with_index(&mut app, &idx);
3055            assert!(app.file_picker_state.is_some());
3056
3057            let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
3058            app.handle_event(AppEvent::Key(key));
3059            assert!(app.file_picker_state.is_none());
3060            assert!(app.sessions.current_mut().input.is_empty());
3061        }
3062
3063        #[test]
3064        fn enter_inserts_selected_path_and_closes_picker() {
3065            let (mut app, _rx, _tx) = make_app_with_index();
3066            let (idx, _dir) = build_temp_index(&["src/main.rs"]);
3067            open_picker_with_index(&mut app, &idx);
3068
3069            let selected = app
3070                .file_picker_state
3071                .as_ref()
3072                .unwrap()
3073                .selected_path()
3074                .map(ToOwned::to_owned)
3075                .unwrap();
3076
3077            let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
3078            app.handle_event(AppEvent::Key(key));
3079
3080            assert!(app.file_picker_state.is_none());
3081            assert!(
3082                app.sessions.current_mut().input.contains(&selected),
3083                "input should contain selected path"
3084            );
3085            assert_eq!(
3086                app.sessions.current_mut().cursor_position,
3087                selected.chars().count()
3088            );
3089        }
3090
3091        #[test]
3092        fn tab_inserts_selected_path_and_closes_picker() {
3093            let (mut app, _rx, _tx) = make_app_with_index();
3094            let (idx, _dir) = build_temp_index(&["README.md"]);
3095            open_picker_with_index(&mut app, &idx);
3096
3097            let selected = app
3098                .file_picker_state
3099                .as_ref()
3100                .unwrap()
3101                .selected_path()
3102                .map(ToOwned::to_owned)
3103                .unwrap();
3104
3105            let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
3106            app.handle_event(AppEvent::Key(key));
3107
3108            assert!(app.file_picker_state.is_none());
3109            assert!(app.sessions.current_mut().input.contains(&selected));
3110        }
3111
3112        #[test]
3113        fn enter_with_no_matches_closes_picker_without_modifying_input() {
3114            let (mut app, _rx, _tx) = make_app_with_index();
3115            let (idx, _dir) = build_temp_index(&["a.rs"]);
3116            open_picker_with_index(&mut app, &idx);
3117
3118            let state = app.file_picker_state.as_mut().unwrap();
3119            state.update_query("xyznotfound");
3120
3121            assert!(app.file_picker_state.as_ref().unwrap().matches().is_empty());
3122
3123            let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
3124            app.handle_event(AppEvent::Key(key));
3125
3126            assert!(app.file_picker_state.is_none());
3127            assert!(
3128                app.sessions.current_mut().input.is_empty(),
3129                "input must be unchanged"
3130            );
3131        }
3132
3133        #[test]
3134        fn down_key_advances_selection() {
3135            let (mut app, _rx, _tx) = make_app_with_index();
3136            let (idx, _dir) = build_temp_index(&["a.rs", "b.rs", "c.rs"]);
3137            open_picker_with_index(&mut app, &idx);
3138
3139            assert_eq!(app.file_picker_state.as_ref().unwrap().selected, 0);
3140
3141            let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
3142            app.handle_event(AppEvent::Key(key));
3143            assert_eq!(app.file_picker_state.as_ref().unwrap().selected, 1);
3144        }
3145
3146        #[test]
3147        fn up_key_wraps_selection_to_last() {
3148            let (mut app, _rx, _tx) = make_app_with_index();
3149            let (idx, _dir) = build_temp_index(&["a.rs", "b.rs", "c.rs"]);
3150            open_picker_with_index(&mut app, &idx);
3151
3152            let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
3153            app.handle_event(AppEvent::Key(key));
3154            let state = app.file_picker_state.as_ref().unwrap();
3155            assert_eq!(state.selected, state.matches().len() - 1);
3156        }
3157
3158        #[test]
3159        fn typing_filters_matches() {
3160            let (mut app, _rx, _tx) = make_app_with_index();
3161            let (idx, _dir) = build_temp_index(&["src/main.rs", "src/lib.rs"]);
3162            open_picker_with_index(&mut app, &idx);
3163
3164            let initial_count = app.file_picker_state.as_ref().unwrap().matches().len();
3165
3166            let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
3167            app.handle_event(AppEvent::Key(key));
3168
3169            let filtered_count = app.file_picker_state.as_ref().unwrap().matches().len();
3170            assert!(filtered_count <= initial_count);
3171            assert_eq!(app.file_picker_state.as_ref().unwrap().query, "m");
3172        }
3173
3174        #[test]
3175        fn backspace_with_nonempty_query_removes_char() {
3176            let (mut app, _rx, _tx) = make_app_with_index();
3177            let (idx, _dir) = build_temp_index(&["a.rs"]);
3178            open_picker_with_index(&mut app, &idx);
3179
3180            app.file_picker_state.as_mut().unwrap().update_query("ma");
3181
3182            let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
3183            app.handle_event(AppEvent::Key(key));
3184
3185            assert!(app.file_picker_state.is_some());
3186            assert_eq!(app.file_picker_state.as_ref().unwrap().query, "m");
3187        }
3188
3189        #[test]
3190        fn backspace_on_empty_query_dismisses_picker() {
3191            let (mut app, _rx, _tx) = make_app_with_index();
3192            let (idx, _dir) = build_temp_index(&["a.rs"]);
3193            open_picker_with_index(&mut app, &idx);
3194
3195            assert!(app.file_picker_state.as_ref().unwrap().query.is_empty());
3196
3197            let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
3198            app.handle_event(AppEvent::Key(key));
3199
3200            assert!(app.file_picker_state.is_none());
3201        }
3202
3203        #[test]
3204        fn picker_blocks_other_keys() {
3205            let (mut app, _rx, _tx) = make_app_with_index();
3206            let (idx, _dir) = build_temp_index(&["a.rs"]);
3207            open_picker_with_index(&mut app, &idx);
3208
3209            app.sessions.current_mut().input = "hello".into();
3210            app.sessions.current_mut().cursor_position = 5;
3211            let key = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
3212            app.handle_event(AppEvent::Key(key));
3213            assert_eq!(
3214                app.sessions.current_mut().input,
3215                "hello",
3216                "input should be unchanged while picker is open"
3217            );
3218        }
3219
3220        #[test]
3221        fn enter_inserts_at_cursor_mid_input() {
3222            let (mut app, _rx, _tx) = make_app_with_index();
3223            let (idx, _dir) = build_temp_index(&["src/lib.rs"]);
3224            open_picker_with_index(&mut app, &idx);
3225
3226            app.sessions.current_mut().input = "ab".into();
3227            app.sessions.current_mut().cursor_position = 1;
3228
3229            let selected = app
3230                .file_picker_state
3231                .as_ref()
3232                .unwrap()
3233                .selected_path()
3234                .map(ToOwned::to_owned)
3235                .unwrap();
3236
3237            let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
3238            app.handle_event(AppEvent::Key(key));
3239
3240            assert!(app.sessions.current_mut().input.contains(&selected));
3241            assert!(app.sessions.current_mut().input.starts_with('a'));
3242            assert!(app.sessions.current_mut().input.ends_with('b'));
3243        }
3244
3245        #[tokio::test]
3246        async fn poll_pending_file_index_installs_index_and_opens_picker() {
3247            let (user_tx, _user_rx) = tokio::sync::mpsc::channel(1);
3248            let (_agent_tx, agent_rx) = tokio::sync::mpsc::channel(1);
3249            let mut app = App::new(user_tx, agent_rx);
3250
3251            // Simulate: status is set, pending_file_index is Some (already resolved)
3252            let (tx, rx) = tokio::sync::oneshot::channel();
3253            let (idx, _dir) = build_temp_index(&["foo.rs"]);
3254            let _ = tx.send(idx);
3255            app.pending_file_index = Some(rx);
3256            app.sessions.current_mut().status_label = Some("indexing files...".to_owned());
3257
3258            // Give the oneshot a moment to be ready (it already is since we sent before assigning)
3259            tokio::task::yield_now().await;
3260
3261            app.poll_pending_file_index();
3262
3263            assert!(app.file_index.is_some(), "file_index should be installed");
3264            assert!(
3265                app.file_picker_state.is_some(),
3266                "picker should open after index ready"
3267            );
3268            assert!(
3269                app.sessions.current_mut().status_label.is_none(),
3270                "status should be cleared after index ready"
3271            );
3272            assert!(
3273                app.pending_file_index.is_none(),
3274                "pending handle should be consumed"
3275            );
3276        }
3277
3278        #[tokio::test]
3279        async fn poll_pending_file_index_noop_when_none() {
3280            let (user_tx, _user_rx) = tokio::sync::mpsc::channel(1);
3281            let (_agent_tx, agent_rx) = tokio::sync::mpsc::channel(1);
3282            let mut app = App::new(user_tx, agent_rx);
3283
3284            // No pending handle — should be a no-op
3285            app.poll_pending_file_index();
3286
3287            assert!(app.file_index.is_none());
3288            assert!(app.file_picker_state.is_none());
3289        }
3290
3291        #[tokio::test]
3292        async fn poll_pending_file_index_clears_on_closed_sender() {
3293            let (user_tx, _user_rx) = tokio::sync::mpsc::channel(1);
3294            let (_agent_tx, agent_rx) = tokio::sync::mpsc::channel(1);
3295            let mut app = App::new(user_tx, agent_rx);
3296
3297            let (tx, rx) = tokio::sync::oneshot::channel::<crate::file_picker::FileIndex>();
3298            // Drop sender without sending — simulates spawn_blocking panic
3299            drop(tx);
3300            app.pending_file_index = Some(rx);
3301            app.sessions.current_mut().status_label = Some("indexing files...".to_owned());
3302
3303            app.poll_pending_file_index();
3304
3305            assert!(
3306                app.pending_file_index.is_none(),
3307                "closed handle should be consumed"
3308            );
3309            assert!(
3310                app.sessions.current_mut().status_label.is_none(),
3311                "status should be cleared on closed sender"
3312            );
3313        }
3314    }
3315
3316    #[test]
3317    fn draw_header_shows_1m_ctx_badge_when_extended_context() {
3318        use crate::test_utils::render_to_string;
3319
3320        let (mut app, _rx, _tx) = make_app();
3321        app.metrics.provider_name = "claude".into();
3322        app.metrics.model_name = "claude-sonnet-4-6".into();
3323        app.metrics.extended_context = true;
3324
3325        let output = render_to_string(80, 1, |frame, area| {
3326            app.draw_header(frame, area);
3327        });
3328        assert!(
3329            output.contains("[1M CTX]"),
3330            "header must contain [1M CTX] badge when extended_context is true; got: {output:?}"
3331        );
3332    }
3333
3334    #[test]
3335    fn draw_header_no_badge_without_extended_context() {
3336        use crate::test_utils::render_to_string;
3337
3338        let (mut app, _rx, _tx) = make_app();
3339        app.metrics.provider_name = "claude".into();
3340        app.metrics.model_name = "claude-sonnet-4-6".into();
3341        app.metrics.extended_context = false;
3342
3343        let output = render_to_string(80, 1, |frame, area| {
3344            app.draw_header(frame, area);
3345        });
3346        assert!(
3347            !output.contains("[1M CTX]"),
3348            "header must not contain [1M CTX] badge when extended_context is false; got: {output:?}"
3349        );
3350    }
3351
3352    // R-FIX-1938: with_metrics_rx must eagerly read the initial snapshot so graph counts are
3353    // visible immediately without waiting for the first watch::Receiver::has_changed() event.
3354    #[test]
3355    fn with_metrics_rx_reads_initial_value() {
3356        use tokio::sync::watch;
3357        use zeph_core::metrics::MetricsSnapshot;
3358
3359        let (user_tx, agent_rx) = {
3360            let (u, _ur) = mpsc::channel(4);
3361            let (_at, ar) = mpsc::channel(4);
3362            (u, ar)
3363        };
3364        let initial = MetricsSnapshot {
3365            graph_entities_total: 42,
3366            graph_edges_total: 7,
3367            graph_communities_total: 3,
3368            ..MetricsSnapshot::default()
3369        };
3370
3371        let (tx, rx) = watch::channel(initial);
3372        let app = App::new(user_tx, agent_rx).with_metrics_rx(rx);
3373
3374        assert_eq!(app.metrics.graph_entities_total, 42);
3375        assert_eq!(app.metrics.graph_edges_total, 7);
3376        assert_eq!(app.metrics.graph_communities_total, 3);
3377
3378        drop(tx);
3379    }
3380
3381    // Regression tests for #2126: tool output must not be duplicated when streaming chunks
3382    // arrive before the final ToolOutput event.
3383
3384    #[test]
3385    fn tool_output_with_prior_tool_start_no_chunks_appends_output() {
3386        let (mut app, _rx, _tx) = make_app();
3387        // Path A: ToolStart creates message with header only.
3388        app.handle_agent_event(AgentEvent::ToolStart {
3389            tool_name: "bash".into(),
3390            command: "ls -la".into(),
3391            tool_call_id: "call-a".into(),
3392        });
3393        // Path C: ToolOutput arrives with no prior chunks.
3394        app.handle_agent_event(AgentEvent::ToolOutput {
3395            tool_name: "bash".into(),
3396            command: "ls -la".into(),
3397            output: "file1\nfile2\n".into(),
3398            success: true,
3399            diff: None,
3400            filter_stats: None,
3401            kept_lines: None,
3402            tool_call_id: "call-a".into(),
3403        });
3404
3405        assert_eq!(app.messages().len(), 1);
3406        let msg = &app.messages()[0];
3407        assert_eq!(msg.content, "$ ls -la\nfile1\nfile2\n");
3408        assert!(!msg.streaming);
3409    }
3410
3411    #[test]
3412    fn tool_output_with_prior_tool_start_and_chunks_does_not_duplicate() {
3413        let (mut app, _rx, _tx) = make_app();
3414        // Path A: ToolStart.
3415        app.handle_agent_event(AgentEvent::ToolStart {
3416            tool_name: "bash".into(),
3417            command: "echo hello".into(),
3418            tool_call_id: "call-b".into(),
3419        });
3420        // Path B: streaming chunks arrive.
3421        app.handle_agent_event(AgentEvent::ToolOutputChunk {
3422            tool_name: "bash".into(),
3423            command: "echo hello".into(),
3424            chunk: "hello\n".into(),
3425            tool_call_id: "call-b".into(),
3426        });
3427        // Path C: ToolOutput with canonical body_display (same content as chunks).
3428        app.handle_agent_event(AgentEvent::ToolOutput {
3429            tool_name: "bash".into(),
3430            command: "echo hello".into(),
3431            output: "hello\n".into(),
3432            success: true,
3433            diff: None,
3434            filter_stats: None,
3435            kept_lines: None,
3436            tool_call_id: "call-b".into(),
3437        });
3438
3439        assert_eq!(app.messages().len(), 1);
3440        let msg = &app.messages()[0];
3441        // Must contain exactly one copy of "hello\n", not two.
3442        assert_eq!(msg.content, "$ echo hello\nhello\n");
3443        assert!(!msg.streaming);
3444    }
3445
3446    // ── AgentViewTarget ──────────────────────────────────────────────────────
3447
3448    #[test]
3449    fn agent_view_target_main_is_main() {
3450        assert!(AgentViewTarget::Main.is_main());
3451        assert!(AgentViewTarget::Main.subagent_id().is_none());
3452        assert!(AgentViewTarget::Main.subagent_name().is_none());
3453    }
3454
3455    #[test]
3456    fn agent_view_target_subagent_accessors() {
3457        let t = AgentViewTarget::SubAgent {
3458            id: "abc".into(),
3459            name: "Worker".into(),
3460        };
3461        assert!(!t.is_main());
3462        assert_eq!(t.subagent_id(), Some("abc"));
3463        assert_eq!(t.subagent_name(), Some("Worker"));
3464    }
3465
3466    // ── SubAgentSidebarState ─────────────────────────────────────────────────
3467
3468    #[test]
3469    fn sidebar_select_next_advances() {
3470        let mut s = SubAgentSidebarState::new();
3471        // start with nothing selected
3472        assert!(s.selected().is_none());
3473        s.select_next(3);
3474        assert_eq!(s.selected(), Some(0));
3475        s.select_next(3);
3476        assert_eq!(s.selected(), Some(1));
3477        s.select_next(3);
3478        assert_eq!(s.selected(), Some(2));
3479        // at last item — stays clamped
3480        s.select_next(3);
3481        assert_eq!(s.selected(), Some(2));
3482    }
3483
3484    #[test]
3485    fn sidebar_select_next_noop_when_empty() {
3486        let mut s = SubAgentSidebarState::new();
3487        s.select_next(0);
3488        assert!(s.selected().is_none());
3489    }
3490
3491    #[test]
3492    fn sidebar_select_prev_decrements() {
3493        let mut s = SubAgentSidebarState::new();
3494        s.list_state.select(Some(2));
3495        s.select_prev(3);
3496        assert_eq!(s.selected(), Some(1));
3497        s.select_prev(3);
3498        assert_eq!(s.selected(), Some(0));
3499        // at 0 — stays at 0
3500        s.select_prev(3);
3501        assert_eq!(s.selected(), Some(0));
3502    }
3503
3504    #[test]
3505    fn sidebar_select_prev_from_none_goes_to_zero() {
3506        let mut s = SubAgentSidebarState::new();
3507        s.select_prev(3);
3508        assert_eq!(s.selected(), Some(0));
3509    }
3510
3511    #[test]
3512    fn sidebar_select_prev_noop_when_empty() {
3513        let mut s = SubAgentSidebarState::new();
3514        s.select_prev(0);
3515        assert!(s.selected().is_none());
3516    }
3517
3518    #[test]
3519    fn sidebar_clamp_removes_selection_when_empty() {
3520        let mut s = SubAgentSidebarState::new();
3521        s.list_state.select(Some(2));
3522        s.clamp(0);
3523        assert!(s.selected().is_none());
3524    }
3525
3526    #[test]
3527    fn sidebar_clamp_reduces_out_of_bounds_selection() {
3528        let mut s = SubAgentSidebarState::new();
3529        s.list_state.select(Some(5));
3530        s.clamp(3); // valid range: 0..2
3531        assert_eq!(s.selected(), Some(2));
3532    }
3533
3534    #[test]
3535    fn sidebar_clamp_leaves_valid_selection_unchanged() {
3536        let mut s = SubAgentSidebarState::new();
3537        s.list_state.select(Some(1));
3538        s.clamp(3);
3539        assert_eq!(s.selected(), Some(1));
3540    }
3541
3542    // ── TuiTranscriptEntry::to_chat_message ──────────────────────────────────
3543
3544    #[test]
3545    fn transcript_entry_to_chat_message_role_mapping() {
3546        let cases = [
3547            ("user", MessageRole::User),
3548            ("assistant", MessageRole::Assistant),
3549            ("tool", MessageRole::Tool),
3550            ("system", MessageRole::System),
3551            ("unknown_role", MessageRole::System),
3552        ];
3553        for (role_str, expected) in cases {
3554            let entry = TuiTranscriptEntry {
3555                role: role_str.into(),
3556                content: "hello".into(),
3557                tool_name: None,
3558                timestamp: None,
3559            };
3560            let msg = entry.to_chat_message();
3561            assert_eq!(msg.role, expected, "role_str={role_str}");
3562        }
3563    }
3564
3565    #[test]
3566    fn transcript_entry_to_chat_message_copies_tool_name_and_timestamp() {
3567        let entry = TuiTranscriptEntry {
3568            role: "tool".into(),
3569            content: "result".into(),
3570            tool_name: Some("bash".into()),
3571            timestamp: Some("12:34".into()),
3572        };
3573        let msg = entry.to_chat_message();
3574        assert_eq!(
3575            msg.tool_name.as_ref().map(zeph_common::ToolName::as_str),
3576            Some("bash")
3577        );
3578        assert_eq!(msg.timestamp, "12:34");
3579        assert_eq!(msg.content, "result");
3580    }
3581
3582    // ── load_transcript_file ─────────────────────────────────────────────────
3583
3584    #[test]
3585    fn load_transcript_file_returns_empty_for_nonexistent_path() {
3586        let (entries, total) =
3587            load_transcript_file(std::path::Path::new("/nonexistent/path/x.jsonl"), false);
3588        assert!(entries.is_empty());
3589        assert_eq!(total, 0);
3590    }
3591
3592    #[test]
3593    fn load_transcript_file_parses_flat_format() {
3594        let tmp = tempfile::NamedTempFile::new().unwrap();
3595        std::fs::write(
3596            tmp.path(),
3597            r#"{"role":"user","content":"hello"}
3598{"role":"assistant","content":"world"}
3599"#,
3600        )
3601        .unwrap();
3602        let (entries, total) = load_transcript_file(tmp.path(), false);
3603        assert_eq!(total, 2);
3604        assert_eq!(entries.len(), 2);
3605        assert_eq!(entries[0].role, "user");
3606        assert_eq!(entries[0].content, "hello");
3607        assert_eq!(entries[1].role, "assistant");
3608        assert_eq!(entries[1].content, "world");
3609    }
3610
3611    #[test]
3612    fn load_transcript_file_parses_nested_format() {
3613        let tmp = tempfile::NamedTempFile::new().unwrap();
3614        std::fs::write(
3615            tmp.path(),
3616            r#"{"seq":1,"timestamp":"12:00","message":{"role":"user","parts":[{"content":"hi"}]}}
3617"#,
3618        )
3619        .unwrap();
3620        let (entries, total) = load_transcript_file(tmp.path(), false);
3621        assert_eq!(total, 1);
3622        assert_eq!(entries.len(), 1);
3623        assert_eq!(entries[0].role, "user");
3624        assert_eq!(entries[0].content, "hi");
3625        assert_eq!(entries[0].timestamp.as_deref(), Some("12:00"));
3626    }
3627
3628    #[test]
3629    fn load_transcript_file_skips_partial_last_line_when_active() {
3630        let tmp = tempfile::NamedTempFile::new().unwrap();
3631        // Last line is missing closing brace — partial write.
3632        std::fs::write(
3633            tmp.path(),
3634            r#"{"role":"user","content":"complete"}
3635{"role":"assistant","content":"incomplet"#,
3636        )
3637        .unwrap();
3638        let (entries, total) = load_transcript_file(tmp.path(), true);
3639        // is_active=true: last partial line discarded
3640        assert_eq!(total, 2); // total = raw line count
3641        assert_eq!(entries.len(), 1);
3642        assert_eq!(entries[0].content, "complete");
3643    }
3644
3645    #[test]
3646    fn load_transcript_file_keeps_partial_last_line_when_inactive() {
3647        let tmp = tempfile::NamedTempFile::new().unwrap();
3648        // Valid JSON that ends with '}' but missing "content" — will be skipped by filter.
3649        std::fs::write(
3650            tmp.path(),
3651            r#"{"role":"user","content":"complete"}
3652{"role":"assistant","content":"also complete"}
3653"#,
3654        )
3655        .unwrap();
3656        // is_active=false: no line skipping, both lines parsed
3657        let (entries, total) = load_transcript_file(tmp.path(), false);
3658        assert_eq!(total, 2);
3659        assert_eq!(entries.len(), 2);
3660    }
3661
3662    #[test]
3663    fn load_transcript_file_skips_empty_content_without_tool_name() {
3664        let tmp = tempfile::NamedTempFile::new().unwrap();
3665        std::fs::write(
3666            tmp.path(),
3667            r#"{"role":"user","content":""}
3668{"role":"assistant","content":"real"}
3669"#,
3670        )
3671        .unwrap();
3672        let (entries, _total) = load_transcript_file(tmp.path(), false);
3673        // Entry with empty content and no tool_name is filtered out.
3674        assert_eq!(entries.len(), 1);
3675        assert_eq!(entries[0].content, "real");
3676    }
3677
3678    #[test]
3679    fn load_transcript_file_keeps_empty_content_with_tool_name() {
3680        let tmp = tempfile::NamedTempFile::new().unwrap();
3681        std::fs::write(
3682            tmp.path(),
3683            r#"{"role":"tool","content":"","tool_name":"bash"}
3684"#,
3685        )
3686        .unwrap();
3687        let (entries, _total) = load_transcript_file(tmp.path(), false);
3688        assert_eq!(entries.len(), 1);
3689        assert_eq!(
3690            entries[0]
3691                .tool_name
3692                .as_ref()
3693                .map(zeph_common::ToolName::as_str),
3694            Some("bash")
3695        );
3696    }
3697
3698    #[test]
3699    fn load_transcript_file_truncates_to_max_entries() {
3700        let tmp = tempfile::NamedTempFile::new().unwrap();
3701        // Write TRANSCRIPT_MAX_ENTRIES + 5 lines.
3702        let extra = 5;
3703        let count = TRANSCRIPT_MAX_ENTRIES + extra;
3704        let content: String = (0..count).fold(String::new(), |mut acc, i| {
3705            use std::fmt::Write;
3706            let _ = writeln!(acc, "{{\"role\":\"user\",\"content\":\"msg{i}\"}}");
3707            acc
3708        });
3709        std::fs::write(tmp.path(), &content).unwrap();
3710        let (entries, total) = load_transcript_file(tmp.path(), false);
3711        assert_eq!(total, count);
3712        assert_eq!(entries.len(), TRANSCRIPT_MAX_ENTRIES);
3713        // Must keep the LAST N entries, not first N.
3714        assert_eq!(entries[0].content, format!("msg{extra}"));
3715        assert_eq!(
3716            entries[TRANSCRIPT_MAX_ENTRIES - 1].content,
3717            format!("msg{}", count - 1)
3718        );
3719    }
3720
3721    // ── transcript_truncation_info ────────────────────────────────────────────
3722
3723    #[test]
3724    fn transcript_truncation_info_returns_none_when_no_cache() {
3725        let (app, _rx, _tx) = make_app();
3726        assert!(app.transcript_truncation_info().is_none());
3727    }
3728
3729    #[test]
3730    fn transcript_truncation_info_returns_none_when_not_truncated() {
3731        let (mut app, _rx, _tx) = make_app();
3732        app.sessions.current_mut().transcript_cache = Some(TranscriptCache {
3733            agent_id: "a".into(),
3734            entries: vec![],
3735            turns_at_load: 1,
3736            total_in_file: TRANSCRIPT_MAX_ENTRIES,
3737        });
3738        assert!(app.transcript_truncation_info().is_none());
3739    }
3740
3741    #[test]
3742    fn transcript_truncation_info_returns_message_when_truncated() {
3743        let (mut app, _rx, _tx) = make_app();
3744        let total = TRANSCRIPT_MAX_ENTRIES + 50;
3745        app.sessions.current_mut().transcript_cache = Some(TranscriptCache {
3746            agent_id: "a".into(),
3747            entries: vec![],
3748            turns_at_load: 1,
3749            total_in_file: total,
3750        });
3751        let info = app.transcript_truncation_info().unwrap();
3752        assert!(info.contains(&total.to_string()), "info={info}");
3753        assert!(
3754            info.contains(&TRANSCRIPT_MAX_ENTRIES.to_string()),
3755            "info={info}"
3756        );
3757    }
3758
3759    // ── visible_messages ─────────────────────────────────────────────────────
3760
3761    #[test]
3762    fn visible_messages_returns_main_messages_when_in_main_view() {
3763        let (mut app, _rx, _tx) = make_app();
3764        app.sessions
3765            .current_mut()
3766            .messages
3767            .push(ChatMessage::new(MessageRole::User, String::from("hello")));
3768        let msgs = app.visible_messages();
3769        assert_eq!(msgs.len(), 1);
3770        assert_eq!(msgs[0].content, "hello");
3771    }
3772
3773    #[test]
3774    fn visible_messages_returns_transcript_when_cache_present() {
3775        let (mut app, _rx, _tx) = make_app();
3776        app.sessions.current_mut().view_target = AgentViewTarget::SubAgent {
3777            id: "x".into(),
3778            name: "X".into(),
3779        };
3780        app.sessions.current_mut().transcript_cache = Some(TranscriptCache {
3781            agent_id: "x".into(),
3782            entries: vec![TuiTranscriptEntry {
3783                role: "user".into(),
3784                content: "from transcript".into(),
3785                tool_name: None,
3786                timestamp: None,
3787            }],
3788            turns_at_load: 1,
3789            total_in_file: 1,
3790        });
3791        let msgs = app.visible_messages();
3792        assert_eq!(msgs.len(), 1);
3793        assert_eq!(msgs[0].content, "from transcript");
3794    }
3795
3796    #[test]
3797    fn visible_messages_returns_loading_placeholder_when_pending() {
3798        let (mut app, _rx, _tx) = make_app();
3799        app.sessions.current_mut().view_target = AgentViewTarget::SubAgent {
3800            id: "x".into(),
3801            name: "X".into(),
3802        };
3803        // Simulate pending by installing a oneshot receiver that is not yet resolved.
3804        let (_tx2, rx2) = tokio::sync::oneshot::channel::<(Vec<TuiTranscriptEntry>, usize)>();
3805        app.sessions.current_mut().pending_transcript = Some(rx2);
3806        let msgs = app.visible_messages();
3807        assert_eq!(msgs.len(), 1);
3808        assert!(
3809            msgs[0].content.contains("Loading"),
3810            "content={}",
3811            msgs[0].content
3812        );
3813    }
3814
3815    #[test]
3816    fn visible_messages_returns_unavailable_when_no_cache_and_no_pending() {
3817        let (mut app, _rx, _tx) = make_app();
3818        app.sessions.current_mut().view_target = AgentViewTarget::SubAgent {
3819            id: "x".into(),
3820            name: "MyAgent".into(),
3821        };
3822        let msgs = app.visible_messages();
3823        assert_eq!(msgs.len(), 1);
3824        assert!(
3825            msgs[0].content.contains("MyAgent"),
3826            "content={}",
3827            msgs[0].content
3828        );
3829    }
3830
3831    // ── set_view_target ───────────────────────────────────────────────────────
3832
3833    #[test]
3834    fn set_view_target_same_target_is_noop() {
3835        let (mut app, _rx, _tx) = make_app();
3836        app.sessions.current_mut().scroll_offset = 5;
3837        // Already in Main — set to Main again.
3838        app.set_view_target(AgentViewTarget::Main);
3839        // scroll_offset must not be reset because nothing changed.
3840        assert_eq!(app.sessions.current_mut().scroll_offset, 5);
3841    }
3842
3843    #[test]
3844    fn set_view_target_clears_cache_and_scroll_on_switch() {
3845        let (mut app, _rx, _tx) = make_app();
3846        app.sessions.current_mut().scroll_offset = 10;
3847        app.sessions.current_mut().transcript_cache = Some(TranscriptCache {
3848            agent_id: "a".into(),
3849            entries: vec![],
3850            turns_at_load: 1,
3851            total_in_file: 1,
3852        });
3853        // Switch to Main (was implicitly Main — set a SubAgent first).
3854        app.sessions.current_mut().view_target = AgentViewTarget::SubAgent {
3855            id: "a".into(),
3856            name: "A".into(),
3857        };
3858        app.set_view_target(AgentViewTarget::Main);
3859        assert_eq!(app.sessions.current_mut().scroll_offset, 0);
3860        assert!(app.sessions.current_mut().transcript_cache.is_none());
3861    }
3862
3863    mod slash_autocomplete_tests {
3864        use super::*;
3865
3866        #[test]
3867        fn slash_on_empty_input_opens_autocomplete() {
3868            let (mut app, _rx, _tx) = make_app();
3869            app.sessions.current_mut().input_mode = InputMode::Insert;
3870            assert!(app.slash_autocomplete.is_none());
3871
3872            let key = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
3873            app.handle_event(AppEvent::Key(key));
3874            assert!(app.slash_autocomplete.is_some());
3875            assert_eq!(app.input(), "/");
3876        }
3877
3878        #[test]
3879        fn no_open_mid_input() {
3880            let (mut app, _rx, _tx) = make_app();
3881            app.sessions.current_mut().input_mode = InputMode::Insert;
3882            app.sessions.current_mut().input = "hello ".to_owned();
3883            app.sessions.current_mut().cursor_position = 6;
3884
3885            let key = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
3886            app.handle_event(AppEvent::Key(key));
3887            assert!(app.slash_autocomplete.is_none());
3888        }
3889
3890        #[test]
3891        fn esc_dismisses_autocomplete() {
3892            let (mut app, _rx, _tx) = make_app();
3893            app.sessions.current_mut().input_mode = InputMode::Insert;
3894            app.slash_autocomplete =
3895                Some(crate::widgets::slash_autocomplete::SlashAutocompleteState::new());
3896            app.sessions.current_mut().input = "/sk".to_owned();
3897            app.sessions.current_mut().cursor_position = 3;
3898
3899            let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
3900            app.handle_event(AppEvent::Key(key));
3901            assert!(app.slash_autocomplete.is_none());
3902            // Input retained
3903            assert_eq!(app.input(), "/sk");
3904        }
3905
3906        #[test]
3907        fn at_char_while_autocomplete_open_does_not_open_file_picker() {
3908            let (mut app, _rx, _tx) = make_app();
3909            app.sessions.current_mut().input_mode = InputMode::Insert;
3910            app.slash_autocomplete =
3911                Some(crate::widgets::slash_autocomplete::SlashAutocompleteState::new());
3912            app.sessions.current_mut().input = "/".to_owned();
3913            app.sessions.current_mut().cursor_position = 1;
3914
3915            let key = KeyEvent::new(KeyCode::Char('@'), KeyModifiers::NONE);
3916            app.handle_event(AppEvent::Key(key));
3917            assert!(app.file_picker_state.is_none());
3918        }
3919
3920        #[test]
3921        fn backspace_removes_slash_and_dismisses() {
3922            let (mut app, _rx, _tx) = make_app();
3923            app.sessions.current_mut().input_mode = InputMode::Insert;
3924            app.slash_autocomplete =
3925                Some(crate::widgets::slash_autocomplete::SlashAutocompleteState::new());
3926            app.sessions.current_mut().input = "/".to_owned();
3927            app.sessions.current_mut().cursor_position = 1;
3928
3929            let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
3930            app.handle_event(AppEvent::Key(key));
3931            assert!(app.slash_autocomplete.is_none());
3932            assert!(app.input().is_empty());
3933        }
3934    }
3935
3936    // ── trim_messages scroll adjustment (#2775) ──────────────────────────────
3937
3938    #[test]
3939    fn trim_messages_no_trim_when_within_limit() {
3940        let (mut app, _rx, _tx) = make_app();
3941        for i in 0..10 {
3942            app.sessions
3943                .current_mut()
3944                .messages
3945                .push(ChatMessage::new(MessageRole::User, format!("msg {i}")));
3946        }
3947        app.sessions.current_mut().scroll_offset = 5;
3948        app.trim_messages();
3949        assert_eq!(app.sessions.current_mut().messages.len(), 10);
3950        assert_eq!(app.sessions.current_mut().scroll_offset, 5);
3951    }
3952
3953    #[test]
3954    fn trim_messages_evicts_excess_and_adjusts_scroll() {
3955        let (mut app, _rx, _tx) = make_app();
3956        let over = MAX_TUI_MESSAGES + 10;
3957        for i in 0..over {
3958            app.sessions
3959                .current_mut()
3960                .messages
3961                .push(ChatMessage::new(MessageRole::User, format!("msg {i}")));
3962        }
3963        app.sessions.current_mut().scroll_offset = 20;
3964        app.trim_messages();
3965        assert_eq!(app.sessions.current_mut().messages.len(), MAX_TUI_MESSAGES);
3966        assert_eq!(app.sessions.current_mut().scroll_offset, 10); // 20 - 10 excess = 10
3967    }
3968
3969    #[test]
3970    fn trim_messages_scroll_saturates_at_zero() {
3971        let (mut app, _rx, _tx) = make_app();
3972        let over = MAX_TUI_MESSAGES + 50;
3973        for i in 0..over {
3974            app.sessions
3975                .current_mut()
3976                .messages
3977                .push(ChatMessage::new(MessageRole::User, format!("msg {i}")));
3978        }
3979        app.sessions.current_mut().scroll_offset = 10; // less than excess (50)
3980        app.trim_messages();
3981        assert_eq!(app.sessions.current_mut().messages.len(), MAX_TUI_MESSAGES);
3982        assert_eq!(app.sessions.current_mut().scroll_offset, 0); // saturates at 0
3983    }
3984
3985    #[test]
3986    fn supervisor_activity_label_no_supervisor_returns_none() {
3987        let (app, _rx, _tx) = make_app();
3988        assert!(app.supervisor_activity_label().is_none());
3989    }
3990
3991    #[tokio::test]
3992    async fn supervisor_activity_label_single_active_task() {
3993        use zeph_common::task_supervisor::{RestartPolicy, TaskDescriptor, TaskSupervisor};
3994
3995        // CancellationToken is a re-export from tokio-util inside zeph-core.
3996        let cancel = tokio_util::sync::CancellationToken::new();
3997        let sup = TaskSupervisor::new(cancel.clone());
3998        sup.spawn(TaskDescriptor {
3999            name: "config-watcher",
4000            restart: RestartPolicy::RunOnce,
4001            factory: || async { std::future::pending::<()>().await },
4002        });
4003
4004        // Give the task time to start and register as Running.
4005        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4006
4007        let (mut app, _rx, _tx) = make_app();
4008        app = app.with_task_supervisor(sup);
4009        app.refresh_task_snapshots();
4010
4011        let label = app.supervisor_activity_label();
4012        assert!(label.is_some(), "expected Some label for active task");
4013        assert!(
4014            label.as_deref().unwrap().contains("config-watcher"),
4015            "label should contain task name: {label:?}"
4016        );
4017
4018        cancel.cancel();
4019    }
4020
4021    #[tokio::test]
4022    async fn supervisor_activity_label_multiple_tasks_shows_more() {
4023        use zeph_common::task_supervisor::{RestartPolicy, TaskDescriptor, TaskSupervisor};
4024
4025        let cancel = tokio_util::sync::CancellationToken::new();
4026        let sup = TaskSupervisor::new(cancel.clone());
4027        for name in &["task-a", "task-b", "task-c"] {
4028            sup.spawn(TaskDescriptor {
4029                name,
4030                restart: RestartPolicy::RunOnce,
4031                factory: || async { std::future::pending::<()>().await },
4032            });
4033        }
4034
4035        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4036
4037        let (mut app, _rx, _tx) = make_app();
4038        app = app.with_task_supervisor(sup);
4039        app.refresh_task_snapshots();
4040
4041        let label = app
4042            .supervisor_activity_label()
4043            .expect("expected Some label");
4044        assert!(
4045            label.contains('+') || label.contains("more"),
4046            "expected '+N more' for multiple tasks, got: {label:?}"
4047        );
4048
4049        cancel.cancel();
4050    }
4051
4052    #[test]
4053    fn paste_inserts_text_in_insert_mode() {
4054        let (mut app, _rx, _tx) = make_app();
4055        app.handle_event(AppEvent::Paste("hello".to_owned()));
4056        assert_eq!(app.input(), "hello");
4057        assert_eq!(app.cursor_position(), 5);
4058    }
4059
4060    #[test]
4061    fn paste_at_mid_cursor_inserts_at_position() {
4062        let (mut app, _rx, _tx) = make_app();
4063        app.handle_event(AppEvent::Paste("ac".to_owned()));
4064        // Move cursor to position 1 (between 'a' and 'c') via Left key
4065        let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
4066        app.handle_event(AppEvent::Key(left));
4067        app.handle_event(AppEvent::Paste("b".to_owned()));
4068        assert_eq!(app.input(), "abc");
4069        assert_eq!(app.cursor_position(), 2);
4070    }
4071
4072    #[test]
4073    fn paste_multiline_inserts_newlines() {
4074        let (mut app, _rx, _tx) = make_app();
4075        app.handle_event(AppEvent::Paste("line1\nline2".to_owned()));
4076        assert_eq!(app.input(), "line1\nline2");
4077        assert_eq!(app.cursor_position(), 11);
4078    }
4079
4080    #[test]
4081    fn paste_in_normal_mode_ignored() {
4082        let (mut app, _rx, _tx) = make_app();
4083        app.sessions.current_mut().input_mode = InputMode::Normal;
4084        app.handle_event(AppEvent::Paste("should not appear".to_owned()));
4085        assert!(app.input().is_empty());
4086    }
4087
4088    #[test]
4089    fn paste_clears_slash_autocomplete() {
4090        let (mut app, _rx, _tx) = make_app();
4091        app.slash_autocomplete =
4092            Some(crate::widgets::slash_autocomplete::SlashAutocompleteState::new());
4093        app.handle_event(AppEvent::Paste("text".to_owned()));
4094        assert!(app.slash_autocomplete.is_none());
4095        assert_eq!(app.input(), "text");
4096    }
4097
4098    #[test]
4099    fn supervisor_activity_label_truncates_at_utf8_boundary() {
4100        // Construct a label that is exactly 38 Unicode chars (each 3 bytes in UTF-8).
4101        // This verifies char-based truncation does not panic on multi-byte boundaries.
4102
4103        // Build a fake supervisor by manually checking the truncation logic directly.
4104        // We can't easily inject a custom snapshot, so we test the logic inline.
4105        let long_name: String = "あ".repeat(50); // 50 × 3-byte chars
4106        let truncated: String = long_name.chars().take(38).collect();
4107        assert_eq!(truncated.chars().count(), 38, "should truncate to 38 chars");
4108        assert!(
4109            truncated.is_char_boundary(truncated.len()),
4110            "must be valid UTF-8"
4111        );
4112        // Confirm byte-slicing the full string at char-boundary position doesn't panic.
4113        let _ = &long_name[..truncated.len()];
4114    }
4115
4116    #[test]
4117    fn paste_state_set_for_multiline() {
4118        let (mut app, _rx, _tx) = make_app();
4119        app.sessions.current_mut().input_mode = InputMode::Insert;
4120        app.handle_event(AppEvent::Paste("line1\nline2\nline3".to_owned()));
4121        let ps = app.paste_state().expect("paste_state should be Some");
4122        assert_eq!(ps.line_count, 3);
4123        assert_eq!(ps.byte_len, "line1\nline2\nline3".len());
4124    }
4125
4126    #[test]
4127    fn paste_state_none_for_single_line() {
4128        let (mut app, _rx, _tx) = make_app();
4129        app.sessions.current_mut().input_mode = InputMode::Insert;
4130        app.handle_event(AppEvent::Paste("single line".to_owned()));
4131        assert!(app.paste_state().is_none());
4132    }
4133
4134    #[test]
4135    fn paste_state_cleared_on_char() {
4136        let (mut app, _rx, _tx) = make_app();
4137        app.sessions.current_mut().input_mode = InputMode::Insert;
4138        app.handle_event(AppEvent::Paste("a\nb".to_owned()));
4139        assert!(app.paste_state().is_some());
4140        app.handle_event(AppEvent::Key(KeyEvent::new(
4141            KeyCode::Char('x'),
4142            KeyModifiers::NONE,
4143        )));
4144        assert!(app.paste_state().is_none());
4145    }
4146
4147    #[test]
4148    fn paste_state_cleared_on_backspace() {
4149        let (mut app, _rx, _tx) = make_app();
4150        app.sessions.current_mut().input_mode = InputMode::Insert;
4151        app.handle_event(AppEvent::Paste("a\nb".to_owned()));
4152        assert!(app.paste_state().is_some());
4153        app.handle_event(AppEvent::Key(KeyEvent::new(
4154            KeyCode::Backspace,
4155            KeyModifiers::NONE,
4156        )));
4157        assert!(app.paste_state().is_none());
4158    }
4159
4160    #[test]
4161    fn paste_state_cleared_on_ctrl_u() {
4162        let (mut app, _rx, _tx) = make_app();
4163        app.sessions.current_mut().input_mode = InputMode::Insert;
4164        app.handle_event(AppEvent::Paste("a\nb".to_owned()));
4165        assert!(app.paste_state().is_some());
4166        app.handle_event(AppEvent::Key(KeyEvent::new(
4167            KeyCode::Char('u'),
4168            KeyModifiers::CONTROL,
4169        )));
4170        assert!(app.paste_state().is_none());
4171        assert!(
4172            app.input().is_empty(),
4173            "Ctrl+U must also clear input buffer"
4174        );
4175    }
4176
4177    #[test]
4178    fn paste_state_cleared_on_shift_enter() {
4179        let (mut app, _rx, _tx) = make_app();
4180        app.sessions.current_mut().input_mode = InputMode::Insert;
4181        app.handle_event(AppEvent::Paste("a\nb".to_owned()));
4182        assert!(app.paste_state().is_some());
4183        app.handle_event(AppEvent::Key(KeyEvent::new(
4184            KeyCode::Enter,
4185            KeyModifiers::SHIFT,
4186        )));
4187        assert!(app.paste_state().is_none());
4188    }
4189
4190    #[test]
4191    fn paste_state_cleared_on_navigation() {
4192        let (mut app, _rx, _tx) = make_app();
4193        app.sessions.current_mut().input_mode = InputMode::Insert;
4194
4195        // Left arrow
4196        app.handle_event(AppEvent::Paste("a\nb".to_owned()));
4197        assert!(app.paste_state().is_some());
4198        app.handle_event(AppEvent::Key(KeyEvent::new(
4199            KeyCode::Left,
4200            KeyModifiers::NONE,
4201        )));
4202        assert!(app.paste_state().is_none(), "Left must clear paste_state");
4203
4204        // Home key
4205        app.handle_event(AppEvent::Paste("c\nd".to_owned()));
4206        assert!(app.paste_state().is_some());
4207        app.handle_event(AppEvent::Key(KeyEvent::new(
4208            KeyCode::Home,
4209            KeyModifiers::NONE,
4210        )));
4211        assert!(app.paste_state().is_none(), "Home must clear paste_state");
4212    }
4213
4214    #[test]
4215    fn paste_state_consumed_on_submit() {
4216        let (mut app, _rx, _tx) = make_app();
4217        app.sessions.current_mut().input_mode = InputMode::Insert;
4218        app.handle_event(AppEvent::Paste("line1\nline2\nline3\nline4".to_owned()));
4219        assert!(app.paste_state().is_some());
4220        app.handle_event(AppEvent::Key(KeyEvent::new(
4221            KeyCode::Enter,
4222            KeyModifiers::NONE,
4223        )));
4224        assert!(
4225            app.paste_state().is_none(),
4226            "paste_state cleared after submit"
4227        );
4228        assert_eq!(app.messages().len(), 1);
4229        assert_eq!(
4230            app.messages()[0].paste_line_count,
4231            Some(4),
4232            "paste_line_count must be set on submitted message"
4233        );
4234    }
4235}