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