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