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;