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;