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// TUI Reducer/Action decomposition implemented in this PR (#5076/#5103).
5// See specs/tui-reducer/spec.md for the full design.
6
7use std::sync::Arc;
8use std::time::Instant;
9
10use tokio::sync::{Notify, mpsc, oneshot, watch};
11use tracing::debug;
12use zeph_common::task_supervisor::{BlockingHandle, TaskSupervisor};
13
14use crate::command::TuiCommand;
15use crate::event::AgentEvent;
16use crate::file_picker::{FileIndex, FilePickerState};
17use crate::hyperlink::HyperlinkSpan;
18use crate::metrics::MetricsSnapshot;
19use crate::session::SessionRegistry;
20use crate::widgets::command_palette::CommandPaletteState;
21use crate::widgets::slash_autocomplete::SlashAutocompleteState;
22use crate::widgets::tool_view::ToolDensity;
23
24pub use crate::render_cache::{RenderCache, RenderCacheEntry, RenderCacheKey, content_hash};
25pub use crate::types::{ChatMessage, InputMode, MessageRole};
26
27use crate::types::PasteState;
28
29const MAX_VISIBLE_INPUT_LINES: u16 = 3;
30
31/// Tracks an in-flight background file-index build.
32///
33/// When a [`TaskSupervisor`] is wired into the `App`, the build is routed through it
34/// so it appears in the task registry panel and is bounded by the blocking semaphore.
35/// In environments without a supervisor (e.g., tests) the bare oneshot receiver is used.
36enum PendingFileIndex {
37    /// Supervised via [`TaskSupervisor::spawn_blocking`].
38    Supervised(BlockingHandle<crate::file_picker::FileIndex>),
39    /// Bare `tokio::task::spawn_blocking` — supervisor not available.
40    Bare(oneshot::Receiver<crate::file_picker::FileIndex>),
41}
42
43/// The currently focused side panel in the TUI layout.
44///
45/// Controls which panel receives keyboard focus for scrolling and navigation.
46///
47/// # Examples
48///
49/// ```rust
50/// use zeph_tui::app::Panel;
51///
52/// let panel = Panel::Chat;
53/// assert_eq!(panel, Panel::Chat);
54/// ```
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56#[non_exhaustive]
57pub enum Panel {
58    /// The main chat / transcript area.
59    Chat,
60    /// The skills mini-panel (side column).
61    Skills,
62    /// The semantic memory mini-panel (side column).
63    Memory,
64    /// The MCP resources mini-panel (side column).
65    Resources,
66    /// The sub-agents mini-panel (side column).
67    SubAgents,
68    /// The supervised task registry panel (side column).
69    Tasks,
70    /// The fleet session overview panel (side column).
71    Fleet,
72    /// The durable execution journal panel (side column).
73    Durable,
74    /// The read-only settings view: LLM providers, MCP servers, and agent definitions.
75    Settings,
76}
77
78/// Discriminates what the main chat area is currently displaying.
79///
80/// In `Main` mode the user sees their own conversation with the primary agent.
81/// In `SubAgent` mode the area shows the transcript of a spawned sub-agent.
82///
83/// # Examples
84///
85/// ```rust
86/// use zeph_tui::app::AgentViewTarget;
87///
88/// let target = AgentViewTarget::Main;
89/// assert!(target.is_main());
90///
91/// let sub = AgentViewTarget::SubAgent { id: "sa-1".into(), name: "Planner".into() };
92/// assert_eq!(sub.subagent_id(), Some("sa-1"));
93/// ```
94#[derive(Debug, Clone, PartialEq, Eq)]
95#[non_exhaustive]
96pub enum AgentViewTarget {
97    /// Displaying the main agent conversation.
98    Main,
99    /// Displaying the transcript of the named sub-agent.
100    SubAgent {
101        /// Stable sub-agent identifier (matches [`SubAgentMetrics::id`](crate::metrics::SubAgentMetrics)).
102        id: String,
103        /// Display name shown in the header bar.
104        name: String,
105    },
106}
107
108impl AgentViewTarget {
109    /// Returns `true` when the target is the primary agent conversation.
110    ///
111    /// # Examples
112    ///
113    /// ```rust
114    /// use zeph_tui::app::AgentViewTarget;
115    ///
116    /// assert!(AgentViewTarget::Main.is_main());
117    /// let sub = AgentViewTarget::SubAgent { id: "x".into(), name: "y".into() };
118    /// assert!(!sub.is_main());
119    /// ```
120    #[must_use]
121    pub fn is_main(&self) -> bool {
122        matches!(self, Self::Main)
123    }
124
125    /// Returns the sub-agent ID if this target points to a sub-agent, otherwise `None`.
126    ///
127    /// # Examples
128    ///
129    /// ```rust
130    /// use zeph_tui::app::AgentViewTarget;
131    ///
132    /// assert_eq!(AgentViewTarget::Main.subagent_id(), None);
133    /// let sub = AgentViewTarget::SubAgent { id: "sa-42".into(), name: "n".into() };
134    /// assert_eq!(sub.subagent_id(), Some("sa-42"));
135    /// ```
136    #[must_use]
137    pub fn subagent_id(&self) -> Option<&str> {
138        if let Self::SubAgent { id, .. } = self {
139            Some(id)
140        } else {
141            None
142        }
143    }
144
145    /// Returns the sub-agent display name if this target points to a sub-agent, otherwise `None`.
146    ///
147    /// # Examples
148    ///
149    /// ```rust
150    /// use zeph_tui::app::AgentViewTarget;
151    ///
152    /// assert_eq!(AgentViewTarget::Main.subagent_name(), None);
153    /// let sub = AgentViewTarget::SubAgent { id: "x".into(), name: "Planner".into() };
154    /// assert_eq!(sub.subagent_name(), Some("Planner"));
155    /// ```
156    #[must_use]
157    pub fn subagent_name(&self) -> Option<&str> {
158        if let Self::SubAgent { name, .. } = self {
159            Some(name)
160        } else {
161            None
162        }
163    }
164}
165
166/// A single entry from a sub-agent's JSONL transcript, ready for TUI display.
167///
168/// Loaded by the background transcript reader and converted to
169/// [`ChatMessage`] for rendering in the chat widget via
170/// [`to_chat_message`](Self::to_chat_message).
171///
172/// # Examples
173///
174/// ```rust
175/// use zeph_tui::app::TuiTranscriptEntry;
176///
177/// let entry = TuiTranscriptEntry {
178///     role: "assistant".to_string(),
179///     content: "I found 3 results.".to_string(),
180///     tool_name: None,
181///     timestamp: None,
182/// };
183/// let msg = entry.to_chat_message();
184/// ```
185#[derive(Debug, Clone)]
186pub struct TuiTranscriptEntry {
187    pub role: String,
188    pub content: String,
189    pub tool_name: Option<zeph_common::ToolName>,
190    pub timestamp: Option<String>,
191}
192
193impl TuiTranscriptEntry {
194    /// Convert this transcript entry to a [`ChatMessage`] for chat widget rendering.
195    ///
196    /// The `role` string is mapped to a [`MessageRole`]: `"user"`, `"assistant"`,
197    /// `"tool"`, or `"system"` for all other values. The optional `tool_name`
198    /// and `timestamp` fields are forwarded verbatim.
199    ///
200    /// # Examples
201    ///
202    /// ```rust
203    /// use zeph_tui::app::TuiTranscriptEntry;
204    /// use zeph_tui::MessageRole;
205    ///
206    /// let entry = TuiTranscriptEntry {
207    ///     role: "user".to_string(),
208    ///     content: "hello".to_string(),
209    ///     tool_name: None,
210    ///     timestamp: Some("14:30".to_string()),
211    /// };
212    /// let msg = entry.to_chat_message();
213    /// assert_eq!(msg.role, MessageRole::User);
214    /// assert_eq!(msg.timestamp, "14:30");
215    /// ```
216    #[must_use]
217    pub fn to_chat_message(&self) -> ChatMessage {
218        let role = match self.role.as_str() {
219            "user" => MessageRole::User,
220            "assistant" => MessageRole::Assistant,
221            "tool" => MessageRole::Tool,
222            _ => MessageRole::System,
223        };
224        let mut msg = ChatMessage::new(role, self.content.clone());
225        if let Some(ref name) = self.tool_name {
226            msg.tool_name = Some(name.clone());
227        }
228        if let Some(ref ts) = self.timestamp {
229            msg.timestamp.clone_from(ts);
230        }
231        msg
232    }
233}
234
235/// Cached transcript data for a single sub-agent session.
236///
237/// Populated by the background transcript loader and invalidated when
238/// `turns_used` in the metrics snapshot advances beyond `turns_at_load`.
239pub struct TranscriptCache {
240    /// The sub-agent ID this cache entry belongs to.
241    pub agent_id: String,
242    /// Parsed transcript entries (last `TRANSCRIPT_MAX_ENTRIES` entries).
243    pub entries: Vec<TuiTranscriptEntry>,
244    /// `turns_used` value at the time of last load, for staleness detection (W2).
245    pub turns_at_load: u32,
246    /// Total entries in file (before truncation to last N).
247    pub total_in_file: usize,
248}
249
250/// Selection and scroll state for the interactive sub-agent sidebar.
251///
252/// Wraps a ratatui [`ListState`](ratatui::widgets::ListState) with convenience
253/// helpers that clamp the selection to valid indices.
254///
255/// # Examples
256///
257/// ```rust
258/// use zeph_tui::app::SubAgentSidebarState;
259///
260/// let mut state = SubAgentSidebarState::new();
261/// state.select_next(3);
262/// assert_eq!(state.selected(), Some(0));
263/// ```
264pub struct SubAgentSidebarState {
265    /// Underlying ratatui list selection state.
266    pub list_state: ratatui::widgets::ListState,
267}
268
269impl SubAgentSidebarState {
270    /// Create a new sidebar state with no selection.
271    ///
272    /// # Examples
273    ///
274    /// ```rust
275    /// use zeph_tui::app::SubAgentSidebarState;
276    ///
277    /// let state = SubAgentSidebarState::new();
278    /// assert_eq!(state.selected(), None);
279    /// ```
280    #[must_use]
281    pub fn new() -> Self {
282        Self {
283            list_state: ratatui::widgets::ListState::default(),
284        }
285    }
286
287    /// Advance the selection to the next item, clamped to `count - 1`.
288    ///
289    /// A no-op when `count` is zero.
290    pub fn select_next(&mut self, count: usize) {
291        if count == 0 {
292            return;
293        }
294        let next = match self.list_state.selected() {
295            Some(i) => (i + 1).min(count - 1),
296            None => 0,
297        };
298        self.list_state.select(Some(next));
299    }
300
301    /// Move the selection to the previous item, clamped to `0`.
302    ///
303    /// A no-op when `count` is zero.
304    pub fn select_prev(&mut self, count: usize) {
305        if count == 0 {
306            return;
307        }
308        let prev = match self.list_state.selected() {
309            Some(0) | None => 0,
310            Some(i) => i - 1,
311        };
312        self.list_state.select(Some(prev));
313    }
314
315    /// Ensure the selection is valid given the current agent count.
316    pub fn clamp(&mut self, count: usize) {
317        if count == 0 {
318            self.list_state.select(None);
319        } else if self.list_state.selected().is_some_and(|i| i >= count) {
320            self.list_state.select(Some(count - 1));
321        }
322    }
323
324    /// Returns the currently selected index, or `None` if nothing is selected.
325    ///
326    /// # Examples
327    ///
328    /// ```rust
329    /// use zeph_tui::app::SubAgentSidebarState;
330    ///
331    /// let mut state = SubAgentSidebarState::new();
332    /// assert_eq!(state.selected(), None);
333    /// state.select_next(5);
334    /// assert_eq!(state.selected(), Some(0));
335    /// ```
336    #[must_use]
337    pub fn selected(&self) -> Option<usize> {
338        self.list_state.selected()
339    }
340}
341
342impl Default for SubAgentSidebarState {
343    fn default() -> Self {
344        Self::new()
345    }
346}
347
348pub struct ConfirmState {
349    pub prompt: String,
350    pub response_tx: Option<oneshot::Sender<bool>>,
351}
352
353pub struct ElicitationState {
354    pub dialog: crate::widgets::elicitation::ElicitationDialogState,
355    pub response_tx: Option<oneshot::Sender<zeph_core::channel::ElicitationResponse>>,
356}
357
358/// Central state machine for the TUI dashboard.
359///
360/// `App` owns all widget state, the render cache, the message history, and
361/// the event channel endpoints. The main loop in [`crate::run_tui`] calls
362/// [`draw`](Self::draw) once per frame and routes events through
363/// [`handle_event`](Self::handle_event) and
364/// [`handle_agent_event`](Self::handle_agent_event).
365///
366/// # Construction
367///
368/// ```rust
369/// use tokio::sync::mpsc;
370/// use zeph_tui::App;
371///
372/// let (user_tx, _user_rx) = mpsc::channel(64);
373/// let (_agent_tx, agent_rx) = mpsc::channel(64);
374/// let app = App::new(user_tx, agent_rx);
375/// ```
376///
377/// Use the builder methods to wire optional components:
378/// - [`with_metrics_rx`](Self::with_metrics_rx) — live metrics watch channel.
379/// - [`with_cancel_signal`](Self::with_cancel_signal) — Ctrl-C cancel notify.
380/// - [`with_command_tx`](Self::with_command_tx) — slash-command dispatch channel.
381#[allow(clippy::struct_excessive_bools)] // independent boolean flags; bitflags or enum would obscure semantics without reducing complexity
382pub struct App {
383    // SESSION-LOCAL state (10 fields relocated into SessionSlot)
384    pub(crate) sessions: SessionRegistry,
385
386    // GLOBAL state — unchanged from before relocation
387    show_side_panels: bool,
388    show_help: bool,
389    pub metrics: MetricsSnapshot,
390    metrics_rx: Option<watch::Receiver<MetricsSnapshot>>,
391    active_panel: Panel,
392    tool_expanded: bool,
393    tool_density: ToolDensity,
394    show_source_labels: bool,
395    show_balance: bool,
396    throbber_state: throbber_widgets_tui::ThrobberState,
397    confirm_state: Option<ConfirmState>,
398    elicitation_state: Option<ElicitationState>,
399    command_palette: Option<CommandPaletteState>,
400    command_tx: Option<mpsc::Sender<TuiCommand>>,
401    file_picker_state: Option<FilePickerState>,
402    file_index: Option<FileIndex>,
403    slash_autocomplete: Option<SlashAutocompleteState>,
404    reverse_search: Option<crate::widgets::reverse_search::ReverseSearchState>,
405    /// `Ctrl+F` transcript-search overlay state (issue #6023). `None` when closed.
406    ///
407    /// Fully independent of `reverse_search` — no shared mutable state — but the two
408    /// overlays are mutually exclusive at the key-routing level (`decode_key`).
409    pub(crate) transcript_search: Option<crate::widgets::transcript_search::TranscriptSearchState>,
410    /// Read-only settings view state: active tab and per-tab selection (issue #6024).
411    pub(crate) settings: crate::widgets::settings::SettingsViewState,
412    pub should_quit: bool,
413    user_input_tx: mpsc::Sender<String>,
414    agent_event_rx: mpsc::Receiver<AgentEvent>,
415    // GLOBAL — single shared agent queue counters (stays global per arch v2 §7)
416    queued_count: usize,
417    pending_count: usize,
418    /// Projected context token count from the last context assembly, or 0 if not yet known.
419    context_token_estimate: usize,
420    editing_queued: bool,
421    hyperlinks: Vec<HyperlinkSpan>,
422    cancel_signal: Option<Arc<Notify>>,
423    pending_file_index: Option<PendingFileIndex>,
424    /// Pending user-theme load: fired by `apply_theme` when the name resolves to a
425    /// user file on disk rather than a built-in preset.  The background thread reads
426    /// and parses `~/.config/zeph/themes/<name>.toml`; the result is installed by
427    /// `poll_pending_theme` on the next tick.
428    pending_theme: Option<
429        oneshot::Receiver<Result<super::theme::SemanticPalette, super::theme::ThemeLoadError>>,
430    >,
431    /// Theme name paired with `pending_theme` so the poll handler can update `theme_name`.
432    pending_theme_name: Option<String>,
433    /// Interactive selection state for the subagent sidebar (stays global per arch v2 E5).
434    pub subagent_sidebar: SubAgentSidebarState,
435    /// Persistent "Resuming session" banner text, set once at startup by
436    /// `AgentEvent::ResumeBanner` (spec-068 §13.5). `None` for a fresh conversation — never
437    /// rendered in that case (AC-16). Unlike a transient status line, this stays visible
438    /// after the first prompt.
439    pub(crate) resume_banner: Option<String>,
440    /// Optional handle to the `TaskSupervisor` for the task registry panel.
441    task_supervisor: Option<TaskSupervisor>,
442    /// Whether the task registry panel is currently visible (toggled by `/tasks`).
443    show_task_panel: bool,
444    /// Snapshot of supervisor tasks cached once per render tick before `terminal.draw()`.
445    ///
446    /// Avoids acquiring `TaskSupervisor`'s inner mutex inside the draw closure, which
447    /// can block the render loop when the reap driver holds the lock concurrently.
448    cached_task_snapshots: Vec<zeph_common::task_supervisor::TaskSnapshot>,
449    /// Clipboard handle for `/copy` and `Ctrl+O` (#3685).
450    pub(crate) clipboard: crate::clipboard::ClipboardHandle,
451    /// Cached fleet session data for the fleet panel (#3884).
452    pub(crate) fleet_snapshot: crate::widgets::fleet::FleetSnapshot,
453    /// List scroll state for the fleet panel.
454    pub(crate) fleet_list_state: ratatui::widgets::ListState,
455    /// Cached durable execution data for the durable panel (spec-064, #4949).
456    pub(crate) durable_snapshot: crate::widgets::durable::DurableSnapshot,
457    /// List scroll state for the durable panel.
458    pub(crate) durable_list_state: ratatui::widgets::ListState,
459    /// Active visual theme. Derived from config at startup via [`crate::theme::Theme::from_palette_with_mode`].
460    pub(crate) theme: crate::theme::Theme,
461    /// Monotonic counter bumped on every theme swap; threads into [`RenderCacheKey`] to
462    /// force cache misses when the user switches themes mid-session.
463    pub(crate) theme_generation: u64,
464    /// Name of the currently-active theme preset or user file.
465    pub(crate) theme_name: String,
466    /// Resolved terminal colour capability, stored once at startup for consistent re-derivation.
467    pub(crate) effective_color_mode: crate::theme::EffectiveColorMode,
468    /// Whether the terminal can render Unicode glyphs. Independent of colour support.
469    ///
470    /// `false` when `TERM=dumb`; `true` otherwise (default). Used by [`App::is_ascii_only`].
471    pub(crate) unicode_capable: bool,
472    /// Per-section collapse mask: `[skills, memory, resources, subagents]`.
473    ///
474    /// Use [`toggle_panel_collapse`](crate::App::toggle_panel_collapse) to toggle and
475    /// [`effective_collapsed`](crate::App::effective_collapsed) for the layout-safe mask.
476    pub(crate) collapsed_panels: [bool; 4],
477
478    // --- Wave animation (#5096) ---
479    /// Animation budget for the input separator row.
480    ///
481    /// Sourced from `[tui] motion` in config; runtime-switchable via `/motion`.
482    pub(crate) motion: zeph_config::Motion,
483
484    /// Monotonic tick counter for the wave animation phase.
485    ///
486    /// Incremented once per `AppEvent::Tick` (100 ms). `u64` never wraps within
487    /// a session lifetime. Used as the explicit `t` argument to [`crate::widgets::wave::sample`]
488    /// so that the wave renderer stays purely deterministic.
489    pub(crate) wave_tick: u64,
490
491    /// Timestamp of the last observed progress event (token chunk or status change).
492    ///
493    /// Initialized at the moment the agent transitions to busy, NOT at `App` construction
494    /// — otherwise the first frame after a long idle gap would falsely read as `Stalled`.
495    pub(crate) last_progress_at: Instant,
496
497    /// Whether the compact equalizer widget is visible in the busy separator row.
498    ///
499    /// Toggled via [`crate::command::TuiCommand::ToggleEqualizer`].
500    /// Defaults to `true`. Ignored when `Motion` is not `Full`.
501    pub(crate) show_equalizer: bool,
502
503    // --- Micro-delights (#5104) ---
504    /// Individual feature toggles sourced from `[tui.delights]` in config.
505    pub(crate) delights: zeph_config::DelightsConfig,
506    /// Approximate streaming rate and TTFT for the status bar.
507    pub(crate) stream_rate: crate::delights::StreamRate,
508    /// Ephemeral toast queue rendered as an overlay above the chat area.
509    pub(crate) toasts: crate::delights::ToastQueue,
510    /// One-shot shimmer state for the splash wordmark.
511    pub(crate) splash_shimmer: crate::delights::SplashShimmer,
512
513    // --- TUI Reducer / Mouse Mode (#5076, #5103) ---
514    /// Whether opt-in mouse capture is currently enabled.
515    ///
516    /// When `true`, the terminal emits `MouseEvent`s instead of converting
517    /// wheel events to arrow keys. Toggled by `/mouse on|off` or the palette.
518    pub(crate) mouse_enabled: bool,
519
520    /// Last computed layout rects, stored at the end of each `draw()` frame.
521    ///
522    /// Used by `decode_mouse` for hit-testing. `None` until the first frame
523    /// is rendered — `decode_mouse` must guard against this (INV-M1, C3).
524    pub(crate) last_layout: Option<crate::layout::AppLayout>,
525
526    /// Pending mouse capture state change requested by `Effect::SetMouseCapture`.
527    ///
528    /// Drained by `tui_loop` in the shared post-select block (C2 — never
529    /// inside an event arm to avoid ordering hazards).
530    pub(crate) pending_mouse_capture: Option<bool>,
531
532    /// URL of the remote daemon this session was attached to via `--connect <URL>`, if any.
533    ///
534    /// Set once at startup by [`with_remote_daemon_url`](Self::with_remote_daemon_url) —
535    /// there is no runtime mechanism to attach/detach mid-session (#5509).
536    remote_daemon_url: Option<String>,
537}
538
539pub(crate) mod action;
540mod draw;
541mod events;
542mod keys;
543pub(crate) mod mouse;
544pub(crate) mod reducer;
545mod state;
546mod transcript;
547
548/// Maximum number of transcript entries loaded into the TUI (W4).
549pub const TRANSCRIPT_MAX_ENTRIES: usize = 200;
550
551/// Load transcript entries from a JSONL file in a blocking context.
552/// Returns `(entries, total_line_count)` where `total_line_count` is the number
553/// of lines in the file (before truncation), used for the truncation indicator.
554///
555/// When `is_active` is true, silently discards the last line if it fails to parse
556/// (C2: partial-write race condition mitigation).
557fn load_transcript_file(
558    path: &std::path::Path,
559    is_active: bool,
560) -> (Vec<TuiTranscriptEntry>, usize) {
561    let Ok(content) = std::fs::read_to_string(path) else {
562        return (Vec::new(), 0);
563    };
564
565    let lines: Vec<&str> = content.lines().collect();
566    let total = lines.len();
567    if total == 0 {
568        return (Vec::new(), 0);
569    }
570
571    // C2: when agent is active, check if last line looks like partial write.
572    let parse_end = if is_active && total > 0 {
573        let last = lines[total - 1].trim();
574        // A complete JSON object ends with '}'. Discard last line if partial write.
575        if last.ends_with('}') {
576            total
577        } else {
578            total - 1
579        }
580    } else {
581        total
582    };
583
584    let entries: Vec<TuiTranscriptEntry> = lines[..parse_end]
585        .iter()
586        .filter_map(|line| {
587            let line = line.trim();
588            if line.is_empty() {
589                return None;
590            }
591            // Parse minimal fields needed for display.
592            // Using serde_json::Value to avoid coupling to zeph-subagent types.
593            let v: serde_json::Value = serde_json::from_str(line).ok()?;
594            // TranscriptEntry wraps a Message in a `message` field.
595            // Schema: { seq, timestamp, message: { role, parts: [{content}], tool_name? } }
596            // Also support flat format: { role, content, tool_name?, timestamp? }
597            let (role, content, tool_name, timestamp) = if let Some(msg) = v.get("message") {
598                let role = msg
599                    .get("role")
600                    .and_then(|r| r.as_str())
601                    .unwrap_or("system")
602                    .to_owned();
603                // Extract content from first text part or direct content field.
604                let content = msg
605                    .get("parts")
606                    .and_then(|p| p.as_array())
607                    .and_then(|arr| arr.first())
608                    .and_then(|part| part.get("content"))
609                    .and_then(|c| c.as_str())
610                    .or_else(|| msg.get("content").and_then(|c| c.as_str()))
611                    .unwrap_or("")
612                    .to_owned();
613                let tool_name = msg
614                    .get("tool_name")
615                    .and_then(|t| t.as_str())
616                    .map(zeph_common::ToolName::new);
617                let timestamp = v
618                    .get("timestamp")
619                    .and_then(|t| t.as_str())
620                    .map(ToOwned::to_owned);
621                (role, content, tool_name, timestamp)
622            } else {
623                // Flat format fallback.
624                let role = v
625                    .get("role")
626                    .and_then(|r| r.as_str())
627                    .unwrap_or("system")
628                    .to_owned();
629                let content = v
630                    .get("content")
631                    .and_then(|c| c.as_str())
632                    .unwrap_or("")
633                    .to_owned();
634                let tool_name = v
635                    .get("tool_name")
636                    .and_then(|t| t.as_str())
637                    .map(zeph_common::ToolName::new);
638                let timestamp = v
639                    .get("timestamp")
640                    .and_then(|t| t.as_str())
641                    .map(ToOwned::to_owned);
642                (role, content, tool_name, timestamp)
643            };
644
645            if content.is_empty() && tool_name.is_none() {
646                return None;
647            }
648
649            Some(TuiTranscriptEntry {
650                role,
651                content,
652                tool_name,
653                timestamp,
654            })
655        })
656        .collect();
657
658    // Take only the last N entries (W4).
659    let truncated: Vec<TuiTranscriptEntry> = if entries.len() > TRANSCRIPT_MAX_ENTRIES {
660        entries
661            .into_iter()
662            .rev()
663            .take(TRANSCRIPT_MAX_ENTRIES)
664            .rev()
665            .collect()
666    } else {
667        entries
668    };
669
670    (truncated, total)
671}
672
673pub(crate) fn format_security_report(metrics: &MetricsSnapshot) -> String {
674    use crate::metrics::SecurityEventCategory;
675
676    let n = metrics.security_events.len();
677    if n == 0 {
678        return "Security event history (0 events)\n\nNo events recorded.".to_owned();
679    }
680
681    let mut lines = vec![format!("Security event history ({n} events):")];
682    for ev in &metrics.security_events {
683        #[allow(clippy::cast_possible_wrap)]
684        let ts = chrono::DateTime::from_timestamp(ev.timestamp as i64, 0).map_or_else(
685            || "??:??:??".to_owned(),
686            |dt| {
687                dt.with_timezone(&chrono::Local)
688                    .format("%H:%M:%S")
689                    .to_string()
690            },
691        );
692        let cat = match ev.category {
693            SecurityEventCategory::InjectionFlag => "INJECTION_FLAG ",
694            SecurityEventCategory::InjectionBlocked => "INJECT_BLOCKED ",
695            SecurityEventCategory::ExfiltrationBlock => "EXFIL_BLOCK    ",
696            SecurityEventCategory::Quarantine => "QUARANTINE     ",
697            SecurityEventCategory::Truncation => "TRUNCATION     ",
698            SecurityEventCategory::RateLimit => "RATE_LIMIT     ",
699            SecurityEventCategory::MemoryValidation => "MEM_VALIDATION ",
700            SecurityEventCategory::PreExecutionBlock => "PRE_EXEC_BLOCK ",
701            SecurityEventCategory::PreExecutionWarn => "PRE_EXEC_WARN  ",
702            SecurityEventCategory::ResponseVerification => "RESP_VERIFY    ",
703            SecurityEventCategory::CausalIpiFlag => "CAUSAL_IPI     ",
704            SecurityEventCategory::CrossBoundaryMcpToAcp => "CROSS_BOUNDARY ",
705            SecurityEventCategory::VigilFlag => "VIGIL_FLAG     ",
706            SecurityEventCategory::GoalDrift => "GOAL_DRIFT     ",
707            _ => "UNKNOWN        ",
708        };
709        lines.push(format!("  [{ts}] {cat}  {:<20}  {}", ev.source, ev.detail));
710    }
711    lines.push(String::new());
712    lines.push("Totals:".to_owned());
713    lines.push(format!(
714        "  Sanitizer runs: {}  |  Flags: {}  |  Truncations: {}",
715        metrics.sanitizer_runs, metrics.sanitizer_injection_flags, metrics.sanitizer_truncations,
716    ));
717    lines.push(format!(
718        "  Quarantine: {} ({} failures)",
719        metrics.quarantine_invocations, metrics.quarantine_failures,
720    ));
721    lines.push(format!(
722        "  Exfiltration: {} images  |  {} URLs  |  {} memory",
723        metrics.exfiltration_images_blocked,
724        metrics.exfiltration_tool_urls_flagged,
725        metrics.exfiltration_memory_guards,
726    ));
727    lines.join("\n")
728}
729
730fn is_tool_use_only(content: &str) -> bool {
731    let trimmed = content.trim();
732    if trimmed.is_empty() {
733        return false;
734    }
735    let mut rest = trimmed;
736    while let Some(start) = rest.find("[tool_use: ") {
737        if !rest[..start].trim().is_empty() {
738            return false;
739        }
740        let after = &rest[start + "[tool_use: ".len()..];
741        let Some(end) = after.find(']') else {
742            return false;
743        };
744        rest = after[end + 1..].trim_start();
745    }
746    rest.is_empty()
747}
748
749fn parse_tool_output(content: &str, suffix: &str) -> Option<(String, String)> {
750    // New format: [tool output: name]
751    if let Some(rest) = content.strip_prefix("[tool output: ")
752        && let Some(header_end) = rest.find("]\n```\n")
753    {
754        let name = rest[..header_end].to_owned();
755        let body_start = header_end + "]\n```\n".len();
756        let body_part = &rest[body_start..];
757        let body = body_part.strip_suffix(suffix).unwrap_or(body_part);
758        return Some((name, body.to_owned()));
759    }
760    // Legacy format: [tool output] — infer tool name from body
761    if let Some(rest) = content.strip_prefix("[tool output]\n```\n") {
762        let body = rest.strip_suffix(suffix).unwrap_or(rest);
763        let name = if body.starts_with("$ ") {
764            "bash"
765        } else {
766            "tool"
767        };
768        return Some((name.to_owned(), body.to_owned()));
769    }
770    // Native tool_use format: [tool_result: id]\ncontent
771    if let Some(rest) = content.strip_prefix("[tool_result: ") {
772        let body = rest.find("]\n").map_or("", |i| &rest[i + 2..]);
773        let name = if body.contains("$ ") { "bash" } else { "tool" };
774        return Some((name.to_owned(), body.to_owned()));
775    }
776    None
777}
778
779#[cfg(test)]
780mod tests;