use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{Notify, mpsc, oneshot, watch};
use tracing::debug;
use zeph_common::task_supervisor::{BlockingHandle, TaskSupervisor};
use crate::command::TuiCommand;
use crate::event::AgentEvent;
use crate::file_picker::{FileIndex, FilePickerState};
use crate::hyperlink::HyperlinkSpan;
use crate::metrics::MetricsSnapshot;
use crate::session::SessionRegistry;
use crate::widgets::command_palette::CommandPaletteState;
use crate::widgets::slash_autocomplete::SlashAutocompleteState;
use crate::widgets::tool_view::ToolDensity;
pub use crate::render_cache::{RenderCache, RenderCacheEntry, RenderCacheKey, content_hash};
pub use crate::types::{ChatMessage, InputMode, MessageRole};
use crate::types::PasteState;
const MAX_VISIBLE_INPUT_LINES: u16 = 3;
enum PendingFileIndex {
Supervised(BlockingHandle<crate::file_picker::FileIndex>),
Bare(oneshot::Receiver<crate::file_picker::FileIndex>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Panel {
Chat,
Skills,
Memory,
Resources,
SubAgents,
Tasks,
Fleet,
Durable,
Settings,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum AgentViewTarget {
Main,
SubAgent {
id: String,
name: String,
},
}
impl AgentViewTarget {
#[must_use]
pub fn is_main(&self) -> bool {
matches!(self, Self::Main)
}
#[must_use]
pub fn subagent_id(&self) -> Option<&str> {
if let Self::SubAgent { id, .. } = self {
Some(id)
} else {
None
}
}
#[must_use]
pub fn subagent_name(&self) -> Option<&str> {
if let Self::SubAgent { name, .. } = self {
Some(name)
} else {
None
}
}
}
#[derive(Debug, Clone)]
pub struct TuiTranscriptEntry {
pub role: String,
pub content: String,
pub tool_name: Option<zeph_common::ToolName>,
pub timestamp: Option<String>,
}
impl TuiTranscriptEntry {
#[must_use]
pub fn to_chat_message(&self) -> ChatMessage {
let role = match self.role.as_str() {
"user" => MessageRole::User,
"assistant" => MessageRole::Assistant,
"tool" => MessageRole::Tool,
_ => MessageRole::System,
};
let mut msg = ChatMessage::new(role, self.content.clone());
if let Some(ref name) = self.tool_name {
msg.tool_name = Some(name.clone());
}
if let Some(ref ts) = self.timestamp {
msg.timestamp.clone_from(ts);
}
msg
}
}
pub struct TranscriptCache {
pub agent_id: String,
pub entries: Vec<TuiTranscriptEntry>,
pub turns_at_load: u32,
pub total_in_file: usize,
}
pub struct SubAgentSidebarState {
pub list_state: ratatui::widgets::ListState,
}
impl SubAgentSidebarState {
#[must_use]
pub fn new() -> Self {
Self {
list_state: ratatui::widgets::ListState::default(),
}
}
pub fn select_next(&mut self, count: usize) {
if count == 0 {
return;
}
let next = match self.list_state.selected() {
Some(i) => (i + 1).min(count - 1),
None => 0,
};
self.list_state.select(Some(next));
}
pub fn select_prev(&mut self, count: usize) {
if count == 0 {
return;
}
let prev = match self.list_state.selected() {
Some(0) | None => 0,
Some(i) => i - 1,
};
self.list_state.select(Some(prev));
}
pub fn clamp(&mut self, count: usize) {
if count == 0 {
self.list_state.select(None);
} else if self.list_state.selected().is_some_and(|i| i >= count) {
self.list_state.select(Some(count - 1));
}
}
#[must_use]
pub fn selected(&self) -> Option<usize> {
self.list_state.selected()
}
}
impl Default for SubAgentSidebarState {
fn default() -> Self {
Self::new()
}
}
pub struct ConfirmState {
pub prompt: String,
pub response_tx: Option<oneshot::Sender<bool>>,
}
pub struct ElicitationState {
pub dialog: crate::widgets::elicitation::ElicitationDialogState,
pub response_tx: Option<oneshot::Sender<zeph_core::channel::ElicitationResponse>>,
}
#[allow(clippy::struct_excessive_bools)] pub struct App {
pub(crate) sessions: SessionRegistry,
show_side_panels: bool,
show_help: bool,
pub metrics: MetricsSnapshot,
metrics_rx: Option<watch::Receiver<MetricsSnapshot>>,
active_panel: Panel,
tool_expanded: bool,
tool_density: ToolDensity,
show_source_labels: bool,
show_balance: bool,
throbber_state: throbber_widgets_tui::ThrobberState,
confirm_state: Option<ConfirmState>,
elicitation_state: Option<ElicitationState>,
command_palette: Option<CommandPaletteState>,
command_tx: Option<mpsc::Sender<TuiCommand>>,
file_picker_state: Option<FilePickerState>,
file_index: Option<FileIndex>,
slash_autocomplete: Option<SlashAutocompleteState>,
reverse_search: Option<crate::widgets::reverse_search::ReverseSearchState>,
pub(crate) transcript_search: Option<crate::widgets::transcript_search::TranscriptSearchState>,
pub(crate) settings: crate::widgets::settings::SettingsViewState,
pub should_quit: bool,
user_input_tx: mpsc::Sender<String>,
agent_event_rx: mpsc::Receiver<AgentEvent>,
queued_count: usize,
pending_count: usize,
context_token_estimate: usize,
editing_queued: bool,
hyperlinks: Vec<HyperlinkSpan>,
cancel_signal: Option<Arc<Notify>>,
pending_file_index: Option<PendingFileIndex>,
pending_theme: Option<
oneshot::Receiver<Result<super::theme::SemanticPalette, super::theme::ThemeLoadError>>,
>,
pending_theme_name: Option<String>,
pub subagent_sidebar: SubAgentSidebarState,
pub(crate) resume_banner: Option<String>,
task_supervisor: Option<TaskSupervisor>,
show_task_panel: bool,
cached_task_snapshots: Vec<zeph_common::task_supervisor::TaskSnapshot>,
pub(crate) clipboard: crate::clipboard::ClipboardHandle,
pub(crate) fleet_snapshot: crate::widgets::fleet::FleetSnapshot,
pub(crate) fleet_list_state: ratatui::widgets::ListState,
pub(crate) durable_snapshot: crate::widgets::durable::DurableSnapshot,
pub(crate) durable_list_state: ratatui::widgets::ListState,
pub(crate) theme: crate::theme::Theme,
pub(crate) theme_generation: u64,
pub(crate) theme_name: String,
pub(crate) effective_color_mode: crate::theme::EffectiveColorMode,
pub(crate) unicode_capable: bool,
pub(crate) collapsed_panels: [bool; 4],
pub(crate) motion: zeph_config::Motion,
pub(crate) wave_tick: u64,
pub(crate) last_progress_at: Instant,
pub(crate) show_equalizer: bool,
pub(crate) delights: zeph_config::DelightsConfig,
pub(crate) stream_rate: crate::delights::StreamRate,
pub(crate) toasts: crate::delights::ToastQueue,
pub(crate) splash_shimmer: crate::delights::SplashShimmer,
pub(crate) mouse_enabled: bool,
pub(crate) last_layout: Option<crate::layout::AppLayout>,
pub(crate) pending_mouse_capture: Option<bool>,
remote_daemon_url: Option<String>,
}
pub(crate) mod action;
mod draw;
mod events;
mod keys;
pub(crate) mod mouse;
pub(crate) mod reducer;
mod state;
mod transcript;
pub const TRANSCRIPT_MAX_ENTRIES: usize = 200;
fn load_transcript_file(
path: &std::path::Path,
is_active: bool,
) -> (Vec<TuiTranscriptEntry>, usize) {
let Ok(content) = std::fs::read_to_string(path) else {
return (Vec::new(), 0);
};
let lines: Vec<&str> = content.lines().collect();
let total = lines.len();
if total == 0 {
return (Vec::new(), 0);
}
let parse_end = if is_active && total > 0 {
let last = lines[total - 1].trim();
if last.ends_with('}') {
total
} else {
total - 1
}
} else {
total
};
let entries: Vec<TuiTranscriptEntry> = lines[..parse_end]
.iter()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() {
return None;
}
let v: serde_json::Value = serde_json::from_str(line).ok()?;
let (role, content, tool_name, timestamp) = if let Some(msg) = v.get("message") {
let role = msg
.get("role")
.and_then(|r| r.as_str())
.unwrap_or("system")
.to_owned();
let content = msg
.get("parts")
.and_then(|p| p.as_array())
.and_then(|arr| arr.first())
.and_then(|part| part.get("content"))
.and_then(|c| c.as_str())
.or_else(|| msg.get("content").and_then(|c| c.as_str()))
.unwrap_or("")
.to_owned();
let tool_name = msg
.get("tool_name")
.and_then(|t| t.as_str())
.map(zeph_common::ToolName::new);
let timestamp = v
.get("timestamp")
.and_then(|t| t.as_str())
.map(ToOwned::to_owned);
(role, content, tool_name, timestamp)
} else {
let role = v
.get("role")
.and_then(|r| r.as_str())
.unwrap_or("system")
.to_owned();
let content = v
.get("content")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_owned();
let tool_name = v
.get("tool_name")
.and_then(|t| t.as_str())
.map(zeph_common::ToolName::new);
let timestamp = v
.get("timestamp")
.and_then(|t| t.as_str())
.map(ToOwned::to_owned);
(role, content, tool_name, timestamp)
};
if content.is_empty() && tool_name.is_none() {
return None;
}
Some(TuiTranscriptEntry {
role,
content,
tool_name,
timestamp,
})
})
.collect();
let truncated: Vec<TuiTranscriptEntry> = if entries.len() > TRANSCRIPT_MAX_ENTRIES {
entries
.into_iter()
.rev()
.take(TRANSCRIPT_MAX_ENTRIES)
.rev()
.collect()
} else {
entries
};
(truncated, total)
}
pub(crate) fn format_security_report(metrics: &MetricsSnapshot) -> String {
use crate::metrics::SecurityEventCategory;
let n = metrics.security_events.len();
if n == 0 {
return "Security event history (0 events)\n\nNo events recorded.".to_owned();
}
let mut lines = vec![format!("Security event history ({n} events):")];
for ev in &metrics.security_events {
#[allow(clippy::cast_possible_wrap)]
let ts = chrono::DateTime::from_timestamp(ev.timestamp as i64, 0).map_or_else(
|| "??:??:??".to_owned(),
|dt| {
dt.with_timezone(&chrono::Local)
.format("%H:%M:%S")
.to_string()
},
);
let cat = match ev.category {
SecurityEventCategory::InjectionFlag => "INJECTION_FLAG ",
SecurityEventCategory::InjectionBlocked => "INJECT_BLOCKED ",
SecurityEventCategory::ExfiltrationBlock => "EXFIL_BLOCK ",
SecurityEventCategory::Quarantine => "QUARANTINE ",
SecurityEventCategory::Truncation => "TRUNCATION ",
SecurityEventCategory::RateLimit => "RATE_LIMIT ",
SecurityEventCategory::MemoryValidation => "MEM_VALIDATION ",
SecurityEventCategory::PreExecutionBlock => "PRE_EXEC_BLOCK ",
SecurityEventCategory::PreExecutionWarn => "PRE_EXEC_WARN ",
SecurityEventCategory::ResponseVerification => "RESP_VERIFY ",
SecurityEventCategory::CausalIpiFlag => "CAUSAL_IPI ",
SecurityEventCategory::CrossBoundaryMcpToAcp => "CROSS_BOUNDARY ",
SecurityEventCategory::VigilFlag => "VIGIL_FLAG ",
SecurityEventCategory::GoalDrift => "GOAL_DRIFT ",
_ => "UNKNOWN ",
};
lines.push(format!(" [{ts}] {cat} {:<20} {}", ev.source, ev.detail));
}
lines.push(String::new());
lines.push("Totals:".to_owned());
lines.push(format!(
" Sanitizer runs: {} | Flags: {} | Truncations: {}",
metrics.sanitizer_runs, metrics.sanitizer_injection_flags, metrics.sanitizer_truncations,
));
lines.push(format!(
" Quarantine: {} ({} failures)",
metrics.quarantine_invocations, metrics.quarantine_failures,
));
lines.push(format!(
" Exfiltration: {} images | {} URLs | {} memory",
metrics.exfiltration_images_blocked,
metrics.exfiltration_tool_urls_flagged,
metrics.exfiltration_memory_guards,
));
lines.join("\n")
}
fn is_tool_use_only(content: &str) -> bool {
let trimmed = content.trim();
if trimmed.is_empty() {
return false;
}
let mut rest = trimmed;
while let Some(start) = rest.find("[tool_use: ") {
if !rest[..start].trim().is_empty() {
return false;
}
let after = &rest[start + "[tool_use: ".len()..];
let Some(end) = after.find(']') else {
return false;
};
rest = after[end + 1..].trim_start();
}
rest.is_empty()
}
fn parse_tool_output(content: &str, suffix: &str) -> Option<(String, String)> {
if let Some(rest) = content.strip_prefix("[tool output: ")
&& let Some(header_end) = rest.find("]\n```\n")
{
let name = rest[..header_end].to_owned();
let body_start = header_end + "]\n```\n".len();
let body_part = &rest[body_start..];
let body = body_part.strip_suffix(suffix).unwrap_or(body_part);
return Some((name, body.to_owned()));
}
if let Some(rest) = content.strip_prefix("[tool output]\n```\n") {
let body = rest.strip_suffix(suffix).unwrap_or(rest);
let name = if body.starts_with("$ ") {
"bash"
} else {
"tool"
};
return Some((name.to_owned(), body.to_owned()));
}
if let Some(rest) = content.strip_prefix("[tool_result: ") {
let body = rest.find("]\n").map_or("", |i| &rest[i + 2..]);
let name = if body.contains("$ ") { "bash" } else { "tool" };
return Some((name.to_owned(), body.to_owned()));
}
None
}
#[cfg(test)]
mod tests;