use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::time::Instant;
use serde_json::Value;
use crate::config::NikaConfig;
use crate::event::{ContextSource, EventKind, ExcludedItem};
use super::theme::{MissionPhase, TaskStatus, ThemeMode};
use super::views::{DagTab, MissionTab, NovanetTab, ReasoningTab};
use super::widgets::{task_box::TokenVelocity, StatusQueue, TimelineEntry};
#[allow(dead_code)]
pub const TARGET_FPS: u8 = 60;
pub const FRAME_CYCLE: u8 = 60;
#[allow(dead_code)]
pub const FRAME_DIV_FAST: u8 = 3;
#[allow(dead_code)]
pub const FRAME_DIV_STANDARD: u8 = 4;
pub const FRAME_DIV_NORMAL: u8 = 6;
#[allow(dead_code)]
pub const FRAME_DIV_BLINK: u8 = 8;
#[allow(dead_code)]
pub const FRAME_DIV_SLOW: u8 = 10;
pub const FRAME_DIV_GLACIAL: u8 = 15;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PanelId {
Progress,
Dag,
NovaNet,
Agent,
}
impl PanelId {
pub fn all() -> &'static [PanelId] {
&[
PanelId::Progress,
PanelId::Dag,
PanelId::NovaNet,
PanelId::Agent,
]
}
pub fn next(&self) -> PanelId {
match self {
PanelId::Progress => PanelId::Dag,
PanelId::Dag => PanelId::NovaNet,
PanelId::NovaNet => PanelId::Agent,
PanelId::Agent => PanelId::Progress,
}
}
pub fn prev(&self) -> PanelId {
match self {
PanelId::Progress => PanelId::Agent,
PanelId::Dag => PanelId::Progress,
PanelId::NovaNet => PanelId::Dag,
PanelId::Agent => PanelId::NovaNet,
}
}
pub fn number(&self) -> u8 {
match self {
PanelId::Progress => 1,
PanelId::Dag => 2,
PanelId::NovaNet => 3,
PanelId::Agent => 4,
}
}
pub fn title(&self) -> &'static str {
match self {
PanelId::Progress => "MISSION CONTROL",
PanelId::Dag => "DAG EXECUTION",
PanelId::NovaNet => "NOVANET STATION",
PanelId::Agent => "AGENT REASONING",
}
}
pub fn icon(&self) -> &'static str {
match self {
PanelId::Progress => "◉",
PanelId::Dag => "⎔",
PanelId::NovaNet => "⊛",
PanelId::Agent => "⊕",
}
}
}
const SCROLL_MARGIN: usize = 3;
#[derive(Debug, Clone, Default)]
pub struct PanelScrollState {
pub offset: usize,
pub cursor: usize,
pub total: usize,
pub visible: usize,
}
impl PanelScrollState {
pub fn new() -> Self {
Self::default()
}
pub fn with_total(total: usize) -> Self {
Self {
total,
..Default::default()
}
}
pub fn ensure_cursor_visible(&mut self) {
if self.visible == 0 || self.total == 0 {
return;
}
let margin = SCROLL_MARGIN.min(self.visible / 2);
if self.cursor < self.offset.saturating_add(margin) {
self.offset = self.cursor.saturating_sub(margin);
}
let bottom_threshold = self.offset + self.visible.saturating_sub(margin);
if self.cursor >= bottom_threshold && self.total > self.visible {
self.offset = (self.cursor + margin + 1).saturating_sub(self.visible);
let max_offset = self.total.saturating_sub(self.visible);
self.offset = self.offset.min(max_offset);
}
}
pub fn cursor_down(&mut self) {
if self.cursor + 1 < self.total {
self.cursor += 1;
self.ensure_cursor_visible();
}
}
pub fn cursor_up(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.ensure_cursor_visible();
}
}
pub fn cursor_first(&mut self) {
self.cursor = 0;
self.ensure_cursor_visible();
}
pub fn cursor_last(&mut self) {
if self.total > 0 {
self.cursor = self.total - 1;
self.ensure_cursor_visible();
}
}
pub fn page_down(&mut self) {
if self.total > 0 {
self.cursor = (self.cursor + self.visible).min(self.total - 1);
self.ensure_cursor_visible();
}
}
pub fn page_up(&mut self) {
self.cursor = self.cursor.saturating_sub(self.visible);
self.ensure_cursor_visible();
}
pub fn scroll_down(&mut self) {
let max_offset = if self.visible > 0 {
self.total.saturating_sub(self.visible)
} else {
self.total.saturating_sub(1)
};
if self.offset < max_offset {
self.offset += 1;
}
}
pub fn scroll_up(&mut self) {
if self.offset > 0 {
self.offset -= 1;
}
}
pub fn scroll_to_top(&mut self) {
self.offset = 0;
self.cursor = 0;
}
pub fn scroll_to_bottom(&mut self) {
if self.total > self.visible {
self.offset = self.total - self.visible;
}
if self.total > 0 {
self.cursor = self.total - 1;
}
}
pub fn set_total(&mut self, total: usize) {
self.total = total;
if self.total > 0 && self.cursor >= self.total {
self.cursor = self.total - 1;
}
if self.total > 0 && self.offset + self.visible > self.total {
self.offset = self.total.saturating_sub(self.visible);
}
}
pub fn set_visible(&mut self, visible: usize) {
self.visible = visible;
self.ensure_cursor_visible();
}
pub fn selected(&self) -> Option<usize> {
if self.total > 0 {
Some(self.cursor)
} else {
None
}
}
pub fn is_selected(&self, index: usize) -> bool {
self.cursor == index
}
pub fn percentage(&self) -> f64 {
if self.total <= self.visible {
0.0
} else {
self.offset as f64 / (self.total - self.visible) as f64
}
}
pub fn visible_range(&self) -> std::ops::Range<usize> {
let start = self.offset;
let end = (self.offset + self.visible).min(self.total);
start..end
}
pub fn at_top(&self) -> bool {
self.offset == 0
}
pub fn at_bottom(&self) -> bool {
self.total <= self.visible || self.offset >= self.total - self.visible
}
pub fn indicator(&self) -> Option<String> {
if self.total <= self.visible {
return None;
}
let arrow = if self.at_top() {
"↓"
} else if self.at_bottom() {
"↑"
} else {
"↕"
};
let position = if self.at_top() {
"Top".to_string()
} else if self.at_bottom() {
"Bot".to_string()
} else {
let pct = (self.percentage() * 100.0).round() as u8;
format!("{}%", pct)
};
Some(format!(" {} {} ", arrow, position))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum ChatPanel {
#[default]
Conversation,
Activity,
Input,
}
#[allow(dead_code)] impl ChatPanel {
pub fn all() -> &'static [ChatPanel] {
&[
ChatPanel::Conversation,
ChatPanel::Activity,
ChatPanel::Input,
]
}
pub fn next(self) -> ChatPanel {
match self {
ChatPanel::Conversation => ChatPanel::Activity,
ChatPanel::Activity => ChatPanel::Input,
ChatPanel::Input => ChatPanel::Conversation,
}
}
pub fn prev(self) -> ChatPanel {
match self {
ChatPanel::Conversation => ChatPanel::Input,
ChatPanel::Activity => ChatPanel::Conversation,
ChatPanel::Input => ChatPanel::Activity,
}
}
pub fn title(&self) -> &'static str {
match self {
ChatPanel::Conversation => "CONVERSATION",
ChatPanel::Activity => "ACTIVITY STACK",
ChatPanel::Input => "INPUT",
}
}
pub fn icon(&self) -> &'static str {
match self {
ChatPanel::Conversation => "💬",
ChatPanel::Activity => "🎯",
ChatPanel::Input => "✏️",
}
}
pub fn is_scrollable(&self) -> bool {
matches!(self, ChatPanel::Conversation | ChatPanel::Activity)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum TuiMode {
#[default]
Normal,
Streaming,
Inspect(String),
Edit(String),
Search,
Help,
Metrics,
Settings,
ChatOverlay,
}
#[derive(Debug, Clone)]
pub struct WorkflowState {
pub path: String,
pub phase: MissionPhase,
pub task_count: usize,
pub tasks_completed: usize,
pub started_at: Option<Instant>,
pub elapsed_ms: u64,
pub generation_id: Option<String>,
pub final_output: Option<Arc<Value>>,
pub total_duration_ms: Option<u64>,
pub error_message: Option<String>,
pub paused: bool,
pub phase_before_pause: Option<MissionPhase>,
}
impl WorkflowState {
pub fn new(path: String) -> Self {
Self {
path,
phase: MissionPhase::Preflight,
task_count: 0,
tasks_completed: 0,
started_at: None,
elapsed_ms: 0,
generation_id: None,
final_output: None,
total_duration_ms: None,
error_message: None,
paused: false,
phase_before_pause: None,
}
}
pub fn progress_pct(&self) -> f32 {
if self.task_count == 0 {
0.0
} else {
(self.tasks_completed as f32 / self.task_count as f32) * 100.0
}
}
}
#[derive(Debug, Clone)]
pub struct TaskState {
pub id: String,
pub status: TaskStatus,
pub task_type: Option<String>,
pub dependencies: Vec<String>,
pub started_at: Option<Instant>,
pub duration_ms: Option<u64>,
pub input: Option<Arc<Value>>,
pub output: Option<Arc<Value>>,
pub error: Option<String>,
pub tokens: Option<u32>,
pub provider: Option<String>,
pub model: Option<String>,
pub prompt_len: Option<usize>,
}
impl TaskState {
pub fn new(id: String, dependencies: Vec<String>) -> Self {
Self {
id,
status: TaskStatus::Pending,
task_type: None,
dependencies,
started_at: None,
duration_ms: None,
input: None,
output: None,
error: None,
tokens: None,
provider: None,
model: None,
prompt_len: None,
}
}
}
#[derive(Debug, Clone)]
pub struct McpCall {
pub call_id: String,
pub seq: usize,
pub server: String,
pub tool: Option<String>,
pub resource: Option<String>,
pub task_id: String,
pub completed: bool,
pub output_len: Option<usize>,
pub timestamp_ms: u64,
pub params: Option<serde_json::Value>,
pub response: Option<serde_json::Value>,
pub is_error: bool,
pub duration_ms: Option<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct ContextAssembly {
pub sources: Vec<ContextSource>,
pub excluded: Vec<ExcludedItem>,
pub total_tokens: u32,
pub budget_used_pct: f32,
pub truncated: bool,
}
#[derive(Debug, Clone)]
pub struct AgentTurnState {
pub index: u32,
pub status: String,
pub tokens: Option<u32>,
pub tool_calls: Vec<String>,
pub thinking: Option<String>,
pub response_text: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SpawnedAgent {
pub parent_task_id: String,
pub child_task_id: String,
pub depth: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Breakpoint {
BeforeTask(String),
AfterTask(String),
OnError(String),
OnMcp(String),
OnAgentTurn(String, u32),
}
#[derive(Debug, Clone, Default)]
pub struct Metrics {
pub total_tokens: u32,
pub input_tokens: u32,
pub output_tokens: u32,
pub cache_read_tokens: u32,
pub cost_usd: f64,
pub mcp_calls: HashMap<String, usize>,
pub token_history: Vec<u32>,
pub latency_history: Vec<u64>,
pub provider_calls: usize,
pub last_model: Option<String>,
pub token_velocity: TokenVelocity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotificationLevel {
Info,
Warning,
Alert,
Success,
Error,
}
impl NotificationLevel {
pub fn icon(&self) -> &'static str {
match self {
NotificationLevel::Info => "ℹ",
NotificationLevel::Warning => "⚠",
NotificationLevel::Alert => "🔔",
NotificationLevel::Success => "✓",
NotificationLevel::Error => "✗",
}
}
}
#[derive(Debug, Clone)]
pub struct Notification {
pub level: NotificationLevel,
pub message: String,
pub timestamp_ms: u64,
pub dismissed: bool,
}
impl Notification {
pub fn new(level: NotificationLevel, message: impl Into<String>, timestamp_ms: u64) -> Self {
Self {
level,
message: message.into(),
timestamp_ms,
dismissed: false,
}
}
pub fn info(message: impl Into<String>, timestamp_ms: u64) -> Self {
Self::new(NotificationLevel::Info, message, timestamp_ms)
}
pub fn warning(message: impl Into<String>, timestamp_ms: u64) -> Self {
Self::new(NotificationLevel::Warning, message, timestamp_ms)
}
pub fn alert(message: impl Into<String>, timestamp_ms: u64) -> Self {
Self::new(NotificationLevel::Alert, message, timestamp_ms)
}
pub fn success(message: impl Into<String>, timestamp_ms: u64) -> Self {
Self::new(NotificationLevel::Success, message, timestamp_ms)
}
pub fn error(message: impl Into<String>, timestamp_ms: u64) -> Self {
Self::new(NotificationLevel::Error, message, timestamp_ms)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SettingsField {
#[default]
AnthropicKey,
OpenAiKey,
Provider,
Model,
}
impl SettingsField {
pub fn all() -> &'static [SettingsField] {
&[
SettingsField::AnthropicKey,
SettingsField::OpenAiKey,
SettingsField::Provider,
SettingsField::Model,
]
}
pub fn next(&self) -> SettingsField {
match self {
SettingsField::AnthropicKey => SettingsField::OpenAiKey,
SettingsField::OpenAiKey => SettingsField::Provider,
SettingsField::Provider => SettingsField::Model,
SettingsField::Model => SettingsField::AnthropicKey,
}
}
pub fn prev(&self) -> SettingsField {
match self {
SettingsField::AnthropicKey => SettingsField::Model,
SettingsField::OpenAiKey => SettingsField::AnthropicKey,
SettingsField::Provider => SettingsField::OpenAiKey,
SettingsField::Model => SettingsField::Provider,
}
}
pub fn label(&self) -> &'static str {
match self {
SettingsField::AnthropicKey => "Anthropic API Key",
SettingsField::OpenAiKey => "OpenAI API Key",
SettingsField::Provider => "Default Provider",
SettingsField::Model => "Default Model",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SettingsState {
pub focus: SettingsField,
pub editing: bool,
pub input_buffer: String,
pub cursor: usize,
pub config: NikaConfig,
pub dirty: bool,
pub status_message: Option<String>,
}
impl SettingsState {
pub fn new(config: NikaConfig) -> Self {
Self {
config,
..Default::default()
}
}
pub fn focus_next(&mut self) {
self.focus = self.focus.next();
self.editing = false;
self.input_buffer.clear();
self.cursor = 0;
}
pub fn focus_prev(&mut self) {
self.focus = self.focus.prev();
self.editing = false;
self.input_buffer.clear();
self.cursor = 0;
}
pub fn start_edit(&mut self) {
self.editing = true;
self.input_buffer = match self.focus {
SettingsField::AnthropicKey => {
self.config.api_keys.anthropic.clone().unwrap_or_default()
}
SettingsField::OpenAiKey => self.config.api_keys.openai.clone().unwrap_or_default(),
SettingsField::Provider => self.config.defaults.provider.clone().unwrap_or_default(),
SettingsField::Model => self.config.defaults.model.clone().unwrap_or_default(),
};
self.cursor = self.input_buffer.len();
}
pub fn cancel_edit(&mut self) {
self.editing = false;
self.input_buffer.clear();
self.cursor = 0;
}
pub fn confirm_edit(&mut self) {
if !self.editing {
return;
}
let value = if self.input_buffer.is_empty() {
None
} else {
Some(self.input_buffer.clone())
};
match self.focus {
SettingsField::AnthropicKey => {
self.config.api_keys.anthropic = value;
}
SettingsField::OpenAiKey => {
self.config.api_keys.openai = value;
}
SettingsField::Provider => {
self.config.defaults.provider = value;
}
SettingsField::Model => {
self.config.defaults.model = value;
}
}
self.dirty = true;
self.editing = false;
self.input_buffer.clear();
self.cursor = 0;
}
pub fn insert_char(&mut self, c: char) {
if !self.editing {
return;
}
self.input_buffer.insert(self.cursor, c);
self.cursor += 1;
}
pub fn backspace(&mut self) {
if !self.editing || self.cursor == 0 {
return;
}
self.cursor -= 1;
self.input_buffer.remove(self.cursor);
}
pub fn delete(&mut self) {
if !self.editing || self.cursor >= self.input_buffer.len() {
return;
}
self.input_buffer.remove(self.cursor);
}
pub fn cursor_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn cursor_right(&mut self) {
if self.cursor < self.input_buffer.len() {
self.cursor += 1;
}
}
pub fn cursor_home(&mut self) {
self.cursor = 0;
}
pub fn cursor_end(&mut self) {
self.cursor = self.input_buffer.len();
}
pub fn save(&mut self) -> Result<(), String> {
self.config.save().map_err(|e| e.to_string())?;
self.dirty = false;
self.status_message = Some("Settings saved".to_string());
Ok(())
}
pub fn key_status(&self, field: SettingsField) -> (bool, String) {
match field {
SettingsField::AnthropicKey => {
if let Some(ref key) = self.config.api_keys.anthropic {
(true, crate::config::mask_api_key(key, 12))
} else {
(false, "Not set".to_string())
}
}
SettingsField::OpenAiKey => {
if let Some(ref key) = self.config.api_keys.openai {
(true, crate::config::mask_api_key(key, 10))
} else {
(false, "Not set".to_string())
}
}
SettingsField::Provider => {
if let Some(ref provider) = self.config.defaults.provider {
(true, provider.clone())
} else {
let auto = self.config.default_provider().unwrap_or("none");
(false, format!("{} (auto)", auto))
}
}
SettingsField::Model => {
if let Some(ref model) = self.config.defaults.model {
(true, model.clone())
} else {
(false, "Default".to_string())
}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ChatOverlayMessageRole {
User,
Nika,
System,
Tool,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ChatOverlayMessage {
pub role: ChatOverlayMessageRole,
pub content: String,
}
impl ChatOverlayMessage {
pub fn new(role: ChatOverlayMessageRole, content: impl Into<String>) -> Self {
Self {
role,
content: content.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct ChatOverlayState {
pub messages: Vec<ChatOverlayMessage>,
pub input: String,
pub cursor: usize,
pub scroll: usize,
pub history: Vec<String>,
pub history_index: Option<usize>,
pub is_streaming: bool,
pub partial_response: String,
pub current_model: String,
pub edit_history: super::edit_history::EditHistory,
}
impl Default for ChatOverlayState {
fn default() -> Self {
Self::new()
}
}
impl ChatOverlayState {
pub fn new() -> Self {
let initial_model = if std::env::var("ANTHROPIC_API_KEY").is_ok() {
"claude-sonnet-4".to_string()
} else if std::env::var("OPENAI_API_KEY").is_ok() {
"gpt-4o".to_string()
} else {
"No API Key".to_string()
};
let mut edit_history = super::edit_history::EditHistory::default();
edit_history.init("", 0);
Self {
messages: vec![ChatOverlayMessage::new(
ChatOverlayMessageRole::System,
"Chat overlay active. Ask for help with the current view.",
)],
input: String::new(),
cursor: 0,
scroll: 0,
history: Vec::new(),
history_index: None,
is_streaming: false,
partial_response: String::new(),
current_model: initial_model,
edit_history,
}
}
pub fn start_streaming(&mut self) {
self.is_streaming = true;
self.partial_response.clear();
}
pub fn append_streaming(&mut self, chunk: &str) {
self.partial_response.push_str(chunk);
}
pub fn finish_streaming(&mut self) -> String {
self.is_streaming = false;
std::mem::take(&mut self.partial_response)
}
pub fn set_model(&mut self, model: impl Into<String>) {
self.current_model = model.into();
}
pub fn add_tool_message(&mut self, content: impl Into<String>) {
self.messages.push(ChatOverlayMessage::new(
ChatOverlayMessageRole::Tool,
content,
));
}
pub fn insert_char(&mut self, c: char) {
self.input.insert(self.cursor, c);
self.cursor += 1;
self.edit_history.push(&self.input, self.cursor);
}
pub fn backspace(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.input.remove(self.cursor);
self.edit_history.push(&self.input, self.cursor);
}
}
pub fn delete(&mut self) {
if self.cursor < self.input.len() {
self.input.remove(self.cursor);
self.edit_history.push(&self.input, self.cursor);
}
}
pub fn undo(&mut self) -> bool {
if let Some((text, cursor)) = self.edit_history.undo() {
self.input = text;
self.cursor = cursor;
true
} else {
false
}
}
pub fn redo(&mut self) -> bool {
if let Some((text, cursor)) = self.edit_history.redo() {
self.input = text;
self.cursor = cursor;
true
} else {
false
}
}
pub fn can_undo(&self) -> bool {
self.edit_history.can_undo()
}
pub fn can_redo(&self) -> bool {
self.edit_history.can_redo()
}
pub fn cursor_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn cursor_right(&mut self) {
if self.cursor < self.input.len() {
self.cursor += 1;
}
}
pub fn cursor_home(&mut self) {
self.cursor = 0;
}
pub fn cursor_end(&mut self) {
self.cursor = self.input.len();
}
pub fn add_user_message(&mut self) -> Option<String> {
let trimmed = self.input.trim();
if trimmed.is_empty() {
return None;
}
let message = std::mem::take(&mut self.input);
self.history.push(message.clone());
self.history_index = None;
self.messages.push(ChatOverlayMessage::new(
ChatOverlayMessageRole::User,
&message,
));
self.cursor = 0;
self.edit_history.init("", 0);
Some(message)
}
pub fn add_nika_message(&mut self, content: impl Into<String>) {
self.messages.push(ChatOverlayMessage::new(
ChatOverlayMessageRole::Nika,
content,
));
}
pub fn history_up(&mut self) {
if self.history.is_empty() {
return;
}
match self.history_index {
None => {
self.history_index = Some(self.history.len() - 1);
}
Some(i) if i > 0 => {
self.history_index = Some(i - 1);
}
_ => {}
}
if let Some(i) = self.history_index {
if let Some(entry) = self.history.get(i) {
self.input = entry.clone();
self.cursor = self.input.len();
}
}
}
pub fn history_down(&mut self) {
let history_len = self.history.len();
match self.history_index {
Some(i) if history_len > 0 && i + 1 < history_len => {
self.history_index = Some(i + 1);
if let Some(entry) = self.history.get(i + 1) {
self.input = entry.clone();
self.cursor = self.input.len();
}
}
Some(_) => {
self.history_index = None;
self.input.clear();
self.cursor = 0;
}
None => {}
}
}
pub fn clear(&mut self) {
self.messages = vec![ChatOverlayMessage::new(
ChatOverlayMessageRole::System,
"Chat cleared.",
)];
self.scroll = 0;
}
pub fn scroll_up(&mut self) {
self.scroll = self.scroll.saturating_add(1);
}
pub fn scroll_down(&mut self) {
self.scroll = self.scroll.saturating_sub(1);
}
}
#[derive(Debug, Clone)]
pub struct TemplateResolution {
pub task_id: String,
pub template: String,
pub result: String,
pub timestamp_ms: u64,
}
#[derive(Debug, Clone)]
pub struct TuiState {
pub frame: u8,
pub workflow: WorkflowState,
pub tasks: HashMap<String, TaskState>,
pub current_task: Option<String>,
pub task_order: Vec<String>,
pub mcp_calls: Vec<McpCall>,
pub mcp_seq: usize,
pub selected_mcp_idx: Option<usize>,
pub context_assembly: ContextAssembly,
pub agent_turns: Vec<AgentTurnState>,
pub streaming_buffer: String,
pub agent_max_turns: Option<u32>,
pub spawned_agents: Vec<SpawnedAgent>,
pub recent_templates: VecDeque<TemplateResolution>,
pub focus: PanelId,
pub mode: TuiMode,
pub scroll: HashMap<PanelId, usize>,
pub settings: SettingsState,
pub chat_overlay: ChatOverlayState,
pub theme_mode: ThemeMode,
pub mission_tab: MissionTab,
pub dag_tab: DagTab,
pub novanet_tab: NovanetTab,
pub reasoning_tab: ReasoningTab,
pub breakpoints: HashSet<Breakpoint>,
pub step_mode: bool,
pub metrics: Metrics,
pub filter_query: String,
pub filter_cursor: usize,
pub notifications: Vec<Notification>,
pub max_notifications: usize,
pub status_messages: StatusQueue,
pub dirty: DirtyFlags,
pub json_cache: JsonFormatCache,
pub cached_timeline_entries: Vec<TimelineEntry>,
timeline_version: u32,
timeline_cache_version: u32,
}
#[derive(Debug, Clone, Default)]
pub struct DirtyFlags {
pub all: bool,
pub progress: bool,
pub dag: bool,
pub novanet: bool,
pub reasoning: bool,
pub status: bool,
pub notifications: bool,
}
impl DirtyFlags {
pub fn mark_all(&mut self) {
self.all = true;
}
pub fn clear(&mut self) {
self.all = false;
self.progress = false;
self.dag = false;
self.novanet = false;
self.reasoning = false;
self.status = false;
self.notifications = false;
}
pub fn any(&self) -> bool {
self.all
|| self.progress
|| self.dag
|| self.novanet
|| self.reasoning
|| self.status
|| self.notifications
}
pub fn is_panel_dirty(&self, panel: PanelId) -> bool {
if self.all {
return true;
}
match panel {
PanelId::Progress => self.progress,
PanelId::Dag => self.dag,
PanelId::NovaNet => self.novanet,
PanelId::Agent => self.reasoning,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct JsonFormatCache {
cache: HashMap<String, String>,
max_entries: usize,
}
impl JsonFormatCache {
pub fn new() -> Self {
Self {
cache: HashMap::new(),
max_entries: 50,
}
}
pub fn get_or_format<T: serde::Serialize>(&mut self, key: &str, value: &T) -> &str {
if !self.cache.contains_key(key) {
let formatted = serde_json::to_string_pretty(value).unwrap_or_default();
if self.cache.len() >= self.max_entries {
let to_remove = (self.max_entries / 10).max(1);
let keys: Vec<String> = self.cache.keys().take(to_remove).cloned().collect();
for k in keys {
self.cache.remove(&k);
}
}
self.cache.insert(key.to_string(), formatted);
}
self.cache.get(key).map(|s| s.as_str()).unwrap_or("")
}
pub fn invalidate(&mut self, key: &str) {
self.cache.remove(key);
}
pub fn invalidate_prefix(&mut self, prefix: &str) {
self.cache.retain(|k, _| !k.starts_with(prefix));
}
pub fn clear(&mut self) {
self.cache.clear();
}
#[allow(dead_code)]
pub fn stats(&self) -> (usize, usize) {
(self.cache.len(), self.max_entries)
}
}
impl TuiState {
pub fn new(workflow_path: &str) -> Self {
let config = NikaConfig::load().unwrap_or_default().with_env();
Self {
frame: 0,
workflow: WorkflowState::new(workflow_path.to_string()),
tasks: HashMap::new(),
current_task: None,
task_order: Vec::new(),
mcp_calls: Vec::new(),
mcp_seq: 0,
selected_mcp_idx: None,
context_assembly: ContextAssembly::default(),
agent_turns: Vec::new(),
streaming_buffer: String::new(),
agent_max_turns: None,
spawned_agents: Vec::new(),
recent_templates: VecDeque::new(),
focus: PanelId::Progress,
mode: TuiMode::Normal,
scroll: HashMap::new(),
settings: SettingsState::new(config),
chat_overlay: ChatOverlayState::new(),
theme_mode: ThemeMode::default(),
mission_tab: MissionTab::default(),
dag_tab: DagTab::default(),
novanet_tab: NovanetTab::default(),
reasoning_tab: ReasoningTab::default(),
breakpoints: HashSet::new(),
step_mode: false,
metrics: Metrics::default(),
filter_query: String::new(),
filter_cursor: 0,
notifications: Vec::new(),
max_notifications: 10,
status_messages: StatusQueue::new(),
dirty: DirtyFlags::default(),
json_cache: JsonFormatCache::new(),
cached_timeline_entries: Vec::new(),
timeline_version: 0,
timeline_cache_version: 0,
}
}
pub fn handle_event(&mut self, kind: &EventKind, timestamp_ms: u64) {
match kind {
EventKind::WorkflowStarted {
task_count,
generation_id,
..
} => {
self.workflow.task_count = *task_count;
self.workflow.phase = MissionPhase::Countdown;
self.workflow.started_at = Some(Instant::now());
self.workflow.generation_id = Some(generation_id.clone());
self.dirty.mark_all();
self.json_cache.clear();
}
EventKind::WorkflowCompleted {
final_output,
total_duration_ms,
} => {
self.workflow.phase = MissionPhase::MissionSuccess;
self.workflow.final_output = Some(Arc::clone(final_output));
self.workflow.total_duration_ms = Some(*total_duration_ms);
self.current_task = None;
let duration_secs = *total_duration_ms as f64 / 1000.0;
self.add_notification(Notification::success(
format!(
"🦚 Magnificent! Warped through in {:.1}s ({}/{} tasks)",
duration_secs, self.workflow.tasks_completed, self.workflow.task_count
),
timestamp_ms,
));
self.dirty.progress = true;
self.dirty.status = true;
}
EventKind::WorkflowFailed { error, .. } => {
self.workflow.phase = MissionPhase::Abort;
self.workflow.error_message = Some(error.clone());
self.add_notification(Notification::error(
format!("🦖 RAWR! Mission failed: {}", error),
timestamp_ms,
));
self.dirty.progress = true;
self.dirty.status = true;
self.dirty.notifications = true;
}
EventKind::WorkflowAborted {
reason,
duration_ms,
running_tasks,
} => {
self.workflow.phase = MissionPhase::Abort;
self.workflow.error_message = Some(format!("Aborted: {}", reason));
self.workflow.total_duration_ms = Some(*duration_ms);
self.current_task = None;
let task_info = if running_tasks.is_empty() {
String::new()
} else {
format!(" ({} tasks interrupted)", running_tasks.len())
};
self.add_notification(Notification::warning(
format!("⚠️ Mission aborted: {}{}", reason, task_info),
timestamp_ms,
));
self.dirty.progress = true;
self.dirty.status = true;
self.dirty.notifications = true;
}
EventKind::TaskScheduled {
task_id,
dependencies,
} => {
let deps: Vec<String> = dependencies
.iter()
.map(|s: &std::sync::Arc<str>| s.to_string())
.collect();
let task = TaskState::new(task_id.to_string(), deps);
self.tasks.insert(task_id.to_string(), task);
self.task_order.push(task_id.to_string());
self.dirty.progress = true;
self.dirty.dag = true;
self.invalidate_timeline_cache();
}
EventKind::TaskStarted {
task_id,
verb,
inputs,
} => {
if let Some(task) = self.tasks.get_mut(task_id.as_ref()) {
task.status = TaskStatus::Running;
task.started_at = Some(Instant::now());
task.input = Some(Arc::new(inputs.clone()));
task.task_type = Some(verb.to_string());
}
self.current_task = Some(task_id.to_string());
if self.workflow.phase == MissionPhase::Countdown {
self.workflow.phase = MissionPhase::Launch;
} else {
self.workflow.phase = MissionPhase::Orbital;
}
self.dirty.progress = true;
self.dirty.dag = true;
self.invalidate_timeline_cache();
self.json_cache.invalidate(&format!("task:{}", task_id));
}
EventKind::TaskCompleted {
task_id,
output,
duration_ms,
} => {
if let Some(task) = self.tasks.get_mut(task_id.as_ref()) {
task.status = TaskStatus::Success;
task.duration_ms = Some(*duration_ms);
task.output = Some(output.clone());
}
self.workflow.tasks_completed += 1;
let duration_secs = *duration_ms as f64 / 1000.0;
if *duration_ms > 30_000 {
self.add_notification(Notification::alert(
format!(
"🦥 Sloth mode! '{}' crawled in at {:.1}s",
task_id, duration_secs
),
timestamp_ms,
));
} else if *duration_ms > 10_000 {
self.add_notification(Notification::warning(
format!(
"🦩 Taking its time... '{}' at {:.1}s",
task_id, duration_secs
),
timestamp_ms,
));
}
if let Some(task) = self.tasks.get(task_id.as_ref()) {
if task.task_type.as_deref() == Some("agent") {
self.agent_turns.clear();
self.streaming_buffer.clear();
self.agent_max_turns = None;
}
}
self.dirty.progress = true;
self.dirty.dag = true;
self.invalidate_timeline_cache();
self.json_cache.invalidate(&format!("task:{}", task_id));
}
EventKind::TaskFailed {
task_id,
error,
duration_ms,
} => {
if let Some(task) = self.tasks.get_mut(task_id.as_ref()) {
task.status = TaskStatus::Failed;
task.duration_ms = Some(*duration_ms);
task.error = Some(error.clone());
}
self.dirty.progress = true;
self.dirty.dag = true;
self.dirty.status = true;
self.invalidate_timeline_cache();
}
EventKind::McpInvoke {
task_id,
mcp_server,
tool,
resource,
call_id,
params,
} => {
let call = McpCall {
call_id: call_id.clone(),
seq: self.mcp_seq,
server: mcp_server.clone(),
tool: tool.clone(),
resource: resource.clone(),
task_id: task_id.to_string(),
completed: false,
output_len: None,
timestamp_ms,
params: params.clone(),
response: None,
is_error: false,
duration_ms: None,
};
self.mcp_calls.push(call);
self.mcp_seq += 1;
self.workflow.phase = MissionPhase::Rendezvous;
if let Some(ref tool_name) = tool {
let entry = self.metrics.mcp_calls.entry(tool_name.clone()).or_insert(0);
*entry += 1;
}
self.dirty.novanet = true;
}
EventKind::McpResponse {
task_id: _,
output_len,
call_id,
duration_ms,
cached: _,
is_error,
response,
} => {
let tool_name = self
.mcp_calls
.iter()
.find(|c| c.call_id == *call_id)
.and_then(|c| c.tool.clone());
if let Some(call) = self.mcp_calls.iter_mut().find(|c| c.call_id == *call_id) {
call.completed = true;
call.output_len = Some(*output_len);
call.response = response.clone();
call.is_error = *is_error;
call.duration_ms = Some(*duration_ms);
}
if self.metrics.latency_history.len() >= 20 {
self.metrics.latency_history.remove(0);
}
self.metrics.latency_history.push(*duration_ms);
if *duration_ms > 5_000 {
let duration_secs = *duration_ms as f64 / 1000.0;
let tool_display = tool_name.as_deref().unwrap_or("resource");
self.add_notification(Notification::warning(
format!(
"🐙 Tentacles reaching... '{}' at {:.1}s",
tool_display, duration_secs
),
timestamp_ms,
));
}
self.workflow.phase = MissionPhase::Orbital;
self.dirty.novanet = true;
self.json_cache.invalidate(&format!("mcp:{}", call_id));
}
EventKind::ContextAssembled {
sources,
excluded,
total_tokens,
budget_used_pct,
truncated,
..
} => {
self.context_assembly = ContextAssembly {
sources: sources.clone(),
excluded: excluded.clone(),
total_tokens: *total_tokens,
budget_used_pct: *budget_used_pct,
truncated: *truncated,
};
self.dirty.novanet = true;
}
EventKind::TemplateResolved {
task_id,
template,
result,
} => {
if self.recent_templates.len() >= 10 {
self.recent_templates.pop_front();
}
self.recent_templates.push_back(TemplateResolution {
task_id: task_id.to_string(),
template: template.clone(),
result: result.clone(),
timestamp_ms,
});
self.dirty.novanet = true;
}
EventKind::AgentStart { max_turns, .. } => {
self.agent_turns.clear();
self.streaming_buffer.clear();
self.agent_max_turns = Some(*max_turns);
self.dirty.reasoning = true;
}
EventKind::AgentTurn {
turn_index,
kind,
metadata,
..
} => {
let tokens = metadata.as_ref().map(|m| m.total_tokens());
let thinking = metadata.as_ref().and_then(|m| m.thinking.clone());
let response_text = metadata.as_ref().map(|m| m.response_text.clone());
let turn = AgentTurnState {
index: *turn_index,
status: kind.clone(),
tokens,
tool_calls: Vec::new(),
thinking,
response_text,
};
if let Some(existing) = self.agent_turns.iter_mut().find(|t| t.index == *turn_index)
{
existing.status = kind.clone();
existing.tokens = tokens;
existing.thinking = turn.thinking;
existing.response_text = turn.response_text;
} else {
self.agent_turns.push(turn);
}
self.dirty.reasoning = true;
}
EventKind::AgentComplete { turns, .. } => {
if let Some(last_turn) = self.agent_turns.last() {
if let Some(tokens) = last_turn.tokens {
self.metrics.token_history.push(tokens);
}
}
let _ = turns; self.dirty.reasoning = true;
}
EventKind::AgentSpawned {
parent_task_id,
child_task_id,
depth,
} => {
self.spawned_agents.push(SpawnedAgent {
parent_task_id: parent_task_id.to_string(),
child_task_id: child_task_id.to_string(),
depth: *depth,
});
self.add_notification(Notification::info(
format!(
"🐤 Hatching '{}' at depth {} — fly little one!",
child_task_id, depth
),
timestamp_ms,
));
self.dirty.reasoning = true;
self.dirty.notifications = true;
}
EventKind::ProviderCalled {
task_id,
provider,
model,
prompt_len,
} => {
if let Some(task) = self.tasks.get_mut(task_id.as_ref()) {
task.provider = Some(provider.clone());
task.model = Some(model.clone());
task.prompt_len = Some(*prompt_len);
}
self.metrics.provider_calls += 1;
self.metrics.last_model = Some(model.clone());
self.dirty.progress = true;
}
EventKind::ProviderResponded {
input_tokens,
output_tokens,
cache_read_tokens,
cost_usd,
ttft_ms,
..
} => {
self.metrics.input_tokens += input_tokens;
self.metrics.output_tokens += output_tokens;
self.metrics.cache_read_tokens += cache_read_tokens;
self.metrics.total_tokens += input_tokens + output_tokens;
self.metrics.cost_usd += cost_usd;
self.metrics
.token_history
.push(input_tokens + output_tokens);
if let Some(ttft) = ttft_ms {
self.metrics.latency_history.push(*ttft);
let ttft_secs = (*ttft as f32).max(1.0) / 1000.0;
let velocity = *output_tokens as f32 / ttft_secs;
self.metrics.token_velocity.push(velocity);
} else if *output_tokens > 0 {
self.metrics.token_velocity.push(*output_tokens as f32);
}
const CONTEXT_WINDOW: u32 = 100_000;
let pct = (self.metrics.total_tokens as f64 / CONTEXT_WINDOW as f64) * 100.0;
if pct > 95.0 {
self.add_notification(Notification::alert(
format!(
"💀 ABANDON SHIP! {:.0}% fuel ({}/{}k)",
pct,
self.metrics.total_tokens,
CONTEXT_WINDOW / 1000
),
timestamp_ms,
));
} else if pct > 85.0 {
self.add_notification(Notification::alert(
format!(
"☠️ Danger zone! {:.0}% fuel ({}/{}k)",
pct,
self.metrics.total_tokens,
CONTEXT_WINDOW / 1000
),
timestamp_ms,
));
} else if pct > 70.0 {
self.add_notification(Notification::warning(
format!(
"🧨 Getting spicy! {:.0}% fuel ({}/{}k)",
pct,
self.metrics.total_tokens,
CONTEXT_WINDOW / 1000
),
timestamp_ms,
));
} else if pct > 50.0 {
self.add_notification(Notification::info(
format!(
"🔥 Heating up... {:.0}% fuel ({}/{}k)",
pct,
self.metrics.total_tokens,
CONTEXT_WINDOW / 1000
),
timestamp_ms,
));
}
self.dirty.progress = true;
}
EventKind::WorkflowPaused => {
self.workflow.phase_before_pause = Some(self.workflow.phase);
self.workflow.paused = true;
self.workflow.phase = MissionPhase::Pause;
self.add_notification(Notification::warning(
"⏸️ Mission paused — press SPACE to resume",
timestamp_ms,
));
self.dirty.progress = true;
self.dirty.status = true;
}
EventKind::WorkflowResumed => {
self.workflow.paused = false;
if let Some(phase) = self.workflow.phase_before_pause.take() {
self.workflow.phase = phase;
} else if self.current_task.is_some() {
self.workflow.phase = MissionPhase::Orbital;
} else {
self.workflow.phase = MissionPhase::Countdown;
}
self.add_notification(Notification::info(
"▶️ Mission resumed — engines back online!",
timestamp_ms,
));
self.dirty.progress = true;
self.dirty.status = true;
}
EventKind::McpConnected { server_name, .. } => {
self.add_notification(Notification::success(
format!("🔌 MCP server '{}' connected", server_name),
timestamp_ms,
));
self.dirty.status = true;
}
EventKind::McpError {
server_name, error, ..
} => {
self.add_notification(Notification::error(
format!("❌ MCP '{}' error: {}", server_name, error),
timestamp_ms,
));
self.dirty.status = true;
}
EventKind::McpRetry {
server_name,
operation,
attempt,
max_attempts,
error,
..
} => {
self.add_notification(Notification::warning(
format!(
"🔄 MCP '{}' retry {}/{} for '{}': {}",
server_name, attempt, max_attempts, operation, error
),
timestamp_ms,
));
self.dirty.status = true;
}
EventKind::Log {
level,
message,
task_id,
} => {
let prefix = match level.as_str() {
"error" => "🔴",
"warn" => "🟡",
"info" => "🔵",
"debug" => "⚪",
"trace" => "⚫",
_ => "📝",
};
let task_suffix = task_id
.as_ref()
.map(|t| format!(" [{}]", t))
.unwrap_or_default();
self.add_notification(Notification::info(
format!("{} {}{}", prefix, message, task_suffix),
timestamp_ms,
));
self.dirty.notifications = true;
}
EventKind::Custom {
name,
payload,
task_id,
} => {
let task_suffix = task_id
.as_ref()
.map(|t| format!(" [{}]", t))
.unwrap_or_default();
let payload_preview = if payload.is_null() {
String::new()
} else {
let s = payload.to_string();
if s.len() > 50 {
format!(": {}...", &s[..47])
} else {
format!(": {}", s)
}
};
self.add_notification(Notification::info(
format!("📦 {}{}{}", name, payload_preview, task_suffix),
timestamp_ms,
));
self.dirty.notifications = true;
}
EventKind::ArtifactWritten {
task_id,
path,
size,
..
} => {
let size_str = if *size < 1024 {
format!("{} B", size)
} else if *size < 1024 * 1024 {
format!("{:.1} KB", *size as f64 / 1024.0)
} else {
format!("{:.1} MB", *size as f64 / (1024.0 * 1024.0))
};
self.add_notification(Notification::success(
format!("💾 [{}] Artifact written: {} ({})", task_id, path, size_str),
timestamp_ms,
));
self.dirty.notifications = true;
}
EventKind::ArtifactFailed {
task_id,
path,
reason,
} => {
self.add_notification(Notification::error(
format!("💾 [{}] Artifact failed: {} - {}", task_id, path, reason),
timestamp_ms,
));
self.dirty.notifications = true;
}
}
}
pub fn tick(&mut self) {
if let Some(started) = self.workflow.started_at {
self.workflow.elapsed_ms = started.elapsed().as_millis() as u64;
}
self.frame = self.frame.wrapping_add(1) % FRAME_CYCLE;
self.status_messages.tick();
}
pub fn spinner_char(&self) -> char {
const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let idx = (self.frame / FRAME_DIV_NORMAL) as usize % SPINNER.len();
SPINNER[idx]
}
pub fn rocket_char(&self) -> char {
const ROCKET: &[char] = &['🚀', '🔥', '💨', '✨'];
let idx = (self.frame / FRAME_DIV_GLACIAL) as usize % ROCKET.len();
ROCKET[idx]
}
pub fn status_info(&mut self, message: impl Into<String>) {
self.status_messages.info(message);
}
pub fn status_success(&mut self, message: impl Into<String>) {
self.status_messages.success(message);
}
pub fn status_warning(&mut self, message: impl Into<String>) {
self.status_messages.warning(message);
}
pub fn status_error(&mut self, message: impl Into<String>) {
self.status_messages.error(message);
}
pub fn is_subagent(&self, task_id: &str) -> bool {
self.spawned_agents
.iter()
.any(|s| s.child_task_id == task_id)
}
pub fn agent_icon(&self, task_id: &str) -> &'static str {
if self.is_subagent(task_id) {
"🐤" } else {
"🐔" }
}
pub fn focus_next(&mut self) {
self.focus = self.focus.next();
}
pub fn focus_prev(&mut self) {
self.focus = self.focus.prev();
}
pub fn focus_panel(&mut self, num: u8) {
self.focus = match num {
1 => PanelId::Progress,
2 => PanelId::Dag,
3 => PanelId::NovaNet,
4 => PanelId::Agent,
_ => self.focus,
};
}
pub fn cycle_tab(&mut self) {
match self.focus {
PanelId::Progress => self.mission_tab = self.mission_tab.next(),
PanelId::Dag => self.dag_tab = self.dag_tab.next(),
PanelId::NovaNet => self.novanet_tab = self.novanet_tab.next(),
PanelId::Agent => self.reasoning_tab = self.reasoning_tab.next(),
}
}
pub fn select_prev_mcp(&mut self) {
if self.mcp_calls.is_empty() {
return;
}
self.selected_mcp_idx = match self.selected_mcp_idx {
None => Some(self.mcp_calls.len().saturating_sub(1)), Some(0) => Some(0), Some(idx) => Some(idx - 1),
};
}
pub fn select_next_mcp(&mut self) {
if self.mcp_calls.is_empty() {
return;
}
let max_idx = self.mcp_calls.len().saturating_sub(1);
self.selected_mcp_idx = match self.selected_mcp_idx {
None => Some(0), Some(idx) if idx >= max_idx => Some(max_idx), Some(idx) => Some(idx + 1),
};
}
pub fn select_mcp(&mut self, idx: usize) {
if idx < self.mcp_calls.len() {
self.selected_mcp_idx = Some(idx);
}
}
pub fn get_selected_mcp(&self) -> Option<&McpCall> {
self.selected_mcp_idx
.and_then(|idx| self.mcp_calls.get(idx))
}
pub fn select_first_mcp(&mut self) {
if !self.mcp_calls.is_empty() {
self.selected_mcp_idx = Some(0);
}
}
pub fn select_last_mcp(&mut self) {
if !self.mcp_calls.is_empty() {
self.selected_mcp_idx = Some(self.mcp_calls.len().saturating_sub(1));
}
}
pub fn filter_push(&mut self, c: char) {
self.filter_query.insert(self.filter_cursor, c);
self.filter_cursor += 1;
}
pub fn filter_backspace(&mut self) {
if self.filter_cursor > 0 {
self.filter_cursor -= 1;
self.filter_query.remove(self.filter_cursor);
}
}
pub fn filter_delete(&mut self) {
if self.filter_cursor < self.filter_query.len() {
self.filter_query.remove(self.filter_cursor);
}
}
pub fn filter_cursor_left(&mut self) {
if self.filter_cursor > 0 {
self.filter_cursor -= 1;
}
}
pub fn filter_cursor_right(&mut self) {
if self.filter_cursor < self.filter_query.len() {
self.filter_cursor += 1;
}
}
pub fn filter_clear(&mut self) {
self.filter_query.clear();
self.filter_cursor = 0;
}
pub fn has_filter(&self) -> bool {
!self.filter_query.is_empty()
}
pub fn filtered_task_ids(&self) -> Vec<&String> {
if self.filter_query.is_empty() {
return self.task_order.iter().collect();
}
let query = self.filter_query.to_lowercase();
self.task_order
.iter()
.filter(|id| {
if id.to_lowercase().contains(&query) {
return true;
}
if let Some(task) = self.tasks.get(*id) {
if let Some(task_type) = &task.task_type {
if task_type.to_lowercase().contains(&query) {
return true;
}
}
}
false
})
.collect()
}
pub fn filtered_mcp_calls(&self) -> Vec<&McpCall> {
if self.filter_query.is_empty() {
return self.mcp_calls.iter().collect();
}
let query = self.filter_query.to_lowercase();
self.mcp_calls
.iter()
.filter(|call| {
if call.server.to_lowercase().contains(&query) {
return true;
}
if let Some(tool) = &call.tool {
if tool.to_lowercase().contains(&query) {
return true;
}
}
if let Some(resource) = &call.resource {
if resource.to_lowercase().contains(&query) {
return true;
}
}
false
})
.collect()
}
pub fn toggle_pause(&mut self) {
self.workflow.paused = !self.workflow.paused;
if self.workflow.paused {
self.workflow.phase = MissionPhase::Pause;
} else if self.current_task.is_some() {
self.workflow.phase = MissionPhase::Orbital;
} else {
self.workflow.phase = MissionPhase::Countdown;
}
}
pub fn is_paused(&self) -> bool {
self.workflow.paused
}
pub fn should_break(&self, kind: &EventKind) -> bool {
if self.breakpoints.is_empty() {
return false;
}
match kind {
EventKind::TaskStarted { task_id, .. } => self
.breakpoints
.contains(&Breakpoint::BeforeTask(task_id.to_string())),
EventKind::TaskCompleted { task_id, .. } => self
.breakpoints
.contains(&Breakpoint::AfterTask(task_id.to_string())),
EventKind::TaskFailed { task_id, .. } => self
.breakpoints
.contains(&Breakpoint::OnError(task_id.to_string())),
EventKind::McpInvoke { task_id, .. } => self
.breakpoints
.contains(&Breakpoint::OnMcp(task_id.to_string())),
EventKind::AgentTurn {
task_id,
turn_index,
..
} => self
.breakpoints
.contains(&Breakpoint::OnAgentTurn(task_id.to_string(), *turn_index)),
_ => false,
}
}
pub fn has_breakpoint(&self, task_id: &str) -> bool {
self.breakpoints
.contains(&Breakpoint::BeforeTask(task_id.to_string()))
|| self
.breakpoints
.contains(&Breakpoint::AfterTask(task_id.to_string()))
|| self
.breakpoints
.contains(&Breakpoint::OnError(task_id.to_string()))
|| self
.breakpoints
.contains(&Breakpoint::OnMcp(task_id.to_string()))
}
#[inline]
pub fn invalidate_timeline_cache(&mut self) {
self.timeline_version = self.timeline_version.wrapping_add(1);
}
pub fn ensure_timeline_cache(&mut self) {
if self.timeline_cache_version != self.timeline_version {
self.rebuild_timeline_cache();
}
}
fn rebuild_timeline_cache(&mut self) {
self.cached_timeline_entries.clear();
for id in &self.task_order {
if let Some(task) = self.tasks.get(id) {
let mut entry = TimelineEntry::new(&task.id, task.status);
if let Some(ms) = task.duration_ms {
entry = entry.with_duration(ms);
}
if self.current_task.as_ref() == Some(&task.id) {
entry = entry.current();
}
entry = entry.with_breakpoint(self.has_breakpoint(&task.id));
self.cached_timeline_entries.push(entry);
}
}
self.timeline_cache_version = self.timeline_version;
}
pub fn get_copyable_content(&self) -> Option<String> {
match self.focus {
PanelId::Progress => {
if let Some(ref output) = self.workflow.final_output {
Some(serde_json::to_string_pretty(output.as_ref()).unwrap_or_default())
} else if let Some(ref task_id) = self.current_task {
self.tasks.get(task_id).and_then(|task| {
task.output
.as_ref()
.map(|o| serde_json::to_string_pretty(o.as_ref()).unwrap_or_default())
})
} else {
Some(format!(
"Workflow: {}\nTasks: {}/{}\nTokens: {}\nMCP calls: {}",
self.workflow.path,
self.workflow.tasks_completed,
self.workflow.task_count,
self.metrics.total_tokens,
self.mcp_calls.len()
))
}
}
PanelId::Dag => {
let mut lines = vec!["# DAG Tasks".to_string()];
for task_id in &self.task_order {
if let Some(task) = self.tasks.get(task_id) {
let status = match task.status {
crate::tui::theme::TaskStatus::Pending => "○",
crate::tui::theme::TaskStatus::Running => "◐",
crate::tui::theme::TaskStatus::Success => "✓",
crate::tui::theme::TaskStatus::Failed => "✗",
crate::tui::theme::TaskStatus::Paused => "⏸",
};
let deps = if task.dependencies.is_empty() {
String::new()
} else {
format!(" → {}", task.dependencies.join(", "))
};
lines.push(format!("{} {}{}", status, task_id, deps));
}
}
Some(lines.join("\n"))
}
PanelId::NovaNet => {
if let Some(idx) = self.selected_mcp_idx {
self.mcp_calls.get(idx).map(|call| {
let mut content = format!(
"# MCP Call #{}: {}\n\n",
call.seq + 1,
call.tool.as_deref().unwrap_or("resource")
);
content.push_str("## Request\n");
if let Some(ref params) = call.params {
content.push_str(
&serde_json::to_string_pretty(params).unwrap_or_default(),
);
}
content.push_str("\n\n## Response\n");
if let Some(ref response) = call.response {
content.push_str(
&serde_json::to_string_pretty(response).unwrap_or_default(),
);
} else if !call.completed {
content.push_str("(pending...)");
}
content
})
} else if !self.mcp_calls.is_empty() {
let mut lines = vec!["# MCP Calls".to_string()];
for call in &self.mcp_calls {
let status = if call.completed { "✓" } else { "◐" };
let tool = call.tool.as_deref().unwrap_or("resource");
let duration = call
.duration_ms
.map(|d| format!(" {}ms", d))
.unwrap_or_default();
lines.push(format!(
"{} #{} {}:{}{}",
status,
call.seq + 1,
call.server,
tool,
duration
));
}
Some(lines.join("\n"))
} else {
None
}
}
PanelId::Agent => {
if self.agent_turns.is_empty() {
return None;
}
let mut content = String::from("# Agent Turns\n\n");
for turn in &self.agent_turns {
content.push_str(&format!("## Turn {}\n", turn.index + 1));
if let Some(ref thinking) = turn.thinking {
content.push_str("### Thinking\n");
content.push_str(thinking);
content.push_str("\n\n");
}
if let Some(ref response) = turn.response_text {
content.push_str("### Response\n");
content.push_str(response);
content.push_str("\n\n");
}
if !turn.tool_calls.is_empty() {
content.push_str("### Tool Calls\n");
for tool in &turn.tool_calls {
content.push_str(&format!("- {}\n", tool));
}
content.push('\n');
}
}
Some(content)
}
}
}
pub fn is_failed(&self) -> bool {
self.workflow.phase == MissionPhase::Abort || self.workflow.error_message.is_some()
}
pub fn is_success(&self) -> bool {
self.workflow.phase == MissionPhase::MissionSuccess
}
pub fn is_running(&self) -> bool {
matches!(
self.workflow.phase,
MissionPhase::Countdown
| MissionPhase::Launch
| MissionPhase::Orbital
| MissionPhase::Rendezvous
)
}
pub fn reset_for_retry(&mut self) -> Vec<String> {
let mut reset_tasks = Vec::new();
self.workflow.phase = MissionPhase::Preflight;
self.workflow.error_message = None;
self.workflow.final_output = None;
self.workflow.total_duration_ms = None;
self.workflow.tasks_completed = 0;
self.workflow.started_at = None;
for (task_id, task) in &mut self.tasks {
if task.status == TaskStatus::Failed {
task.status = TaskStatus::Pending;
task.duration_ms = None;
task.error = None;
task.output = None;
reset_tasks.push(task_id.clone());
}
}
self.current_task = None;
self.agent_turns.clear();
self.metrics = Metrics::default();
self.mcp_seq = 0;
reset_tasks
}
pub fn add_notification(&mut self, notification: Notification) {
self.notifications.push(notification);
while self.notifications.len() > self.max_notifications {
self.notifications.remove(0);
}
self.dirty.notifications = true;
}
pub fn active_notifications(&self) -> impl Iterator<Item = &Notification> {
self.notifications.iter().filter(|n| !n.dismissed)
}
pub fn active_notification_count(&self) -> usize {
self.notifications.iter().filter(|n| !n.dismissed).count()
}
pub fn dismiss_notification(&mut self) {
for n in self.notifications.iter_mut().rev() {
if !n.dismissed {
n.dismissed = true;
self.dirty.notifications = true;
break;
}
}
}
pub fn dismiss_all_notifications(&mut self) {
for n in &mut self.notifications {
n.dismissed = true;
}
self.dirty.notifications = true;
}
pub fn clear_notifications(&mut self) {
self.notifications.clear();
self.dirty.notifications = true;
}
pub fn dismiss_error(&mut self) -> bool {
if self.workflow.error_message.is_some() {
self.workflow.error_message = None;
self.dirty.progress = true;
self.dirty.status = true;
true
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_VERSION: &str = env!("CARGO_PKG_VERSION");
#[test]
fn test_panel_id_next_cycles() {
assert_eq!(PanelId::Progress.next(), PanelId::Dag);
assert_eq!(PanelId::Agent.next(), PanelId::Progress);
}
#[test]
fn test_panel_id_prev_cycles() {
assert_eq!(PanelId::Progress.prev(), PanelId::Agent);
assert_eq!(PanelId::Dag.prev(), PanelId::Progress);
}
#[test]
fn test_panel_id_all_returns_all_panels() {
let all = PanelId::all();
assert_eq!(all.len(), 4);
assert_eq!(all[0], PanelId::Progress);
assert_eq!(all[1], PanelId::Dag);
assert_eq!(all[2], PanelId::NovaNet);
assert_eq!(all[3], PanelId::Agent);
}
#[test]
fn test_panel_id_number() {
assert_eq!(PanelId::Progress.number(), 1);
assert_eq!(PanelId::Dag.number(), 2);
assert_eq!(PanelId::NovaNet.number(), 3);
assert_eq!(PanelId::Agent.number(), 4);
}
#[test]
fn test_panel_id_title() {
assert_eq!(PanelId::Progress.title(), "MISSION CONTROL");
assert_eq!(PanelId::Dag.title(), "DAG EXECUTION");
assert_eq!(PanelId::NovaNet.title(), "NOVANET STATION");
assert_eq!(PanelId::Agent.title(), "AGENT REASONING");
}
#[test]
fn test_panel_id_icon() {
assert_eq!(PanelId::Progress.icon(), "◉");
assert_eq!(PanelId::Dag.icon(), "⎔");
assert_eq!(PanelId::NovaNet.icon(), "⊛");
assert_eq!(PanelId::Agent.icon(), "⊕");
}
#[test]
fn test_panel_id_complete_cycle() {
let mut current = PanelId::Progress;
let mut count = 0;
for _ in 0..4 {
current = current.next();
count += 1;
}
assert_eq!(current, PanelId::Progress);
assert_eq!(count, 4);
}
#[test]
fn test_panel_id_reverse_cycle() {
let mut current = PanelId::Progress;
let mut count = 0;
for _ in 0..4 {
current = current.prev();
count += 1;
}
assert_eq!(current, PanelId::Progress);
assert_eq!(count, 4);
}
#[test]
fn test_workflow_state_progress() {
let mut ws = WorkflowState::new("test.yaml".to_string());
ws.task_count = 10;
ws.tasks_completed = 5;
assert!((ws.progress_pct() - 50.0).abs() < f32::EPSILON);
}
#[test]
fn test_tui_state_focus_navigation() {
let mut state = TuiState::new("test.yaml");
assert_eq!(state.focus, PanelId::Progress);
state.focus_next();
assert_eq!(state.focus, PanelId::Dag);
state.focus_panel(4);
assert_eq!(state.focus, PanelId::Agent);
state.focus_prev();
assert_eq!(state.focus, PanelId::NovaNet);
}
#[test]
fn test_tui_state_cycle_tab() {
use crate::tui::views::{DagTab, MissionTab, NovanetTab, ReasoningTab};
let mut state = TuiState::new("test.yaml");
state.focus = PanelId::Progress;
assert_eq!(state.mission_tab, MissionTab::Progress);
state.cycle_tab();
assert_eq!(state.mission_tab, MissionTab::TaskIO);
state.cycle_tab();
assert_eq!(state.mission_tab, MissionTab::Output);
state.cycle_tab();
assert_eq!(state.mission_tab, MissionTab::Progress);
state.focus = PanelId::Dag;
assert_eq!(state.dag_tab, DagTab::Graph);
state.cycle_tab();
assert_eq!(state.dag_tab, DagTab::Yaml);
state.cycle_tab();
assert_eq!(state.dag_tab, DagTab::Graph);
state.focus = PanelId::NovaNet;
assert_eq!(state.novanet_tab, NovanetTab::Summary);
state.cycle_tab();
assert_eq!(state.novanet_tab, NovanetTab::FullJson);
state.cycle_tab();
assert_eq!(state.novanet_tab, NovanetTab::Summary);
state.focus = PanelId::Agent;
assert_eq!(state.reasoning_tab, ReasoningTab::Turns);
state.cycle_tab();
assert_eq!(state.reasoning_tab, ReasoningTab::Thinking);
state.cycle_tab();
assert_eq!(state.reasoning_tab, ReasoningTab::Steps);
state.cycle_tab();
assert_eq!(state.reasoning_tab, ReasoningTab::Turns);
}
#[test]
fn test_tui_state_handle_workflow_started() {
let mut state = TuiState::new("test.yaml");
state.handle_event(
&EventKind::WorkflowStarted {
task_count: 5,
generation_id: "gen-123".to_string(),
workflow_hash: "abc".to_string(),
nika_version: TEST_VERSION.to_string(),
},
0,
);
assert_eq!(state.workflow.task_count, 5);
assert_eq!(state.workflow.phase, MissionPhase::Countdown);
assert!(state.workflow.started_at.is_some());
}
#[test]
fn test_tui_state_handle_task_lifecycle() {
let mut state = TuiState::new("test.yaml");
state.handle_event(
&EventKind::TaskScheduled {
task_id: Arc::from("task1"),
dependencies: vec![],
},
0,
);
assert!(state.tasks.contains_key("task1"));
assert_eq!(state.tasks["task1"].status, TaskStatus::Pending);
state.handle_event(
&EventKind::TaskStarted {
verb: "infer".into(),
task_id: Arc::from("task1"),
inputs: serde_json::json!({}),
},
100,
);
assert_eq!(state.tasks["task1"].status, TaskStatus::Running);
assert_eq!(state.current_task, Some("task1".to_string()));
state.handle_event(
&EventKind::TaskCompleted {
task_id: Arc::from("task1"),
output: Arc::new(serde_json::json!({"result": "ok"})),
duration_ms: 500,
},
600,
);
assert_eq!(state.tasks["task1"].status, TaskStatus::Success);
assert_eq!(state.workflow.tasks_completed, 1);
}
#[test]
fn test_tui_state_handle_mcp_events() {
let mut state = TuiState::new("test.yaml");
let test_params = serde_json::json!({"entity": "qr-code"});
state.handle_event(
&EventKind::McpInvoke {
task_id: Arc::from("task1"),
call_id: "test-call-1".to_string(),
mcp_server: "novanet".to_string(),
tool: Some("novanet_describe".to_string()),
resource: None,
params: Some(test_params.clone()),
},
100,
);
assert_eq!(state.mcp_calls.len(), 1);
assert_eq!(state.mcp_calls[0].call_id, "test-call-1");
assert_eq!(
state.mcp_calls[0].tool,
Some("novanet_describe".to_string())
);
assert!(!state.mcp_calls[0].completed);
assert_eq!(state.mcp_calls[0].params, Some(test_params));
let test_response = serde_json::json!({"name": "QR Code", "locale": "en-US"});
state.handle_event(
&EventKind::McpResponse {
task_id: Arc::from("task1"),
call_id: "test-call-1".to_string(),
output_len: 1024,
duration_ms: 100,
cached: false,
is_error: false,
response: Some(test_response.clone()),
},
200,
);
assert!(state.mcp_calls[0].completed);
assert_eq!(state.mcp_calls[0].output_len, Some(1024));
assert_eq!(state.mcp_calls[0].response, Some(test_response));
assert_eq!(state.mcp_calls[0].duration_ms, Some(100));
assert!(!state.mcp_calls[0].is_error);
}
#[test]
fn test_tui_state_handle_mcp_error_response() {
let mut state = TuiState::new("test.yaml");
state.handle_event(
&EventKind::McpInvoke {
task_id: Arc::from("task1"),
call_id: "error-call-1".to_string(),
mcp_server: "novanet".to_string(),
tool: Some("novanet_traverse".to_string()),
resource: None,
params: Some(serde_json::json!({"invalid": "params"})),
},
100,
);
state.handle_event(
&EventKind::McpResponse {
task_id: Arc::from("task1"),
call_id: "error-call-1".to_string(),
output_len: 50,
duration_ms: 25,
cached: false,
is_error: true,
response: Some(serde_json::json!({"error": "Invalid params"})),
},
125,
);
assert!(state.mcp_calls[0].is_error);
assert_eq!(state.mcp_calls[0].duration_ms, Some(25));
assert_eq!(
state.mcp_calls[0].response,
Some(serde_json::json!({"error": "Invalid params"}))
);
}
#[test]
fn test_tui_state_handle_mcp_parallel_calls() {
let mut state = TuiState::new("test.yaml");
state.handle_event(
&EventKind::McpInvoke {
task_id: Arc::from("task1"),
call_id: "call-fr".to_string(),
mcp_server: "novanet".to_string(),
tool: Some("novanet_generate".to_string()),
resource: None,
params: Some(serde_json::json!({"locale": "fr-FR"})),
},
100,
);
state.handle_event(
&EventKind::McpInvoke {
task_id: Arc::from("task1"),
call_id: "call-en".to_string(),
mcp_server: "novanet".to_string(),
tool: Some("novanet_generate".to_string()),
resource: None,
params: Some(serde_json::json!({"locale": "en-US"})),
},
110,
);
assert_eq!(state.mcp_calls.len(), 2);
assert!(!state.mcp_calls[0].completed);
assert!(!state.mcp_calls[1].completed);
state.handle_event(
&EventKind::McpResponse {
task_id: Arc::from("task1"),
call_id: "call-en".to_string(),
output_len: 500,
duration_ms: 50,
cached: false,
is_error: false,
response: Some(serde_json::json!({"content": "English content"})),
},
160,
);
assert!(!state.mcp_calls[0].completed);
assert!(state.mcp_calls[1].completed);
assert_eq!(state.mcp_calls[1].call_id, "call-en");
state.handle_event(
&EventKind::McpResponse {
task_id: Arc::from("task1"),
call_id: "call-fr".to_string(),
output_len: 600,
duration_ms: 120,
cached: false,
is_error: false,
response: Some(serde_json::json!({"content": "French content"})),
},
220,
);
assert!(state.mcp_calls[0].completed);
assert_eq!(state.mcp_calls[0].call_id, "call-fr");
assert_eq!(state.mcp_calls[0].duration_ms, Some(120));
assert!(state.mcp_calls[1].completed);
assert_eq!(state.mcp_calls[1].call_id, "call-en");
assert_eq!(state.mcp_calls[1].duration_ms, Some(50));
}
#[test]
fn test_breakpoint_detection() {
let mut state = TuiState::new("test.yaml");
state
.breakpoints
.insert(Breakpoint::BeforeTask("task1".to_string()));
let event = EventKind::TaskStarted {
verb: "infer".into(),
task_id: Arc::from("task1"),
inputs: serde_json::json!({}),
};
assert!(state.should_break(&event));
let event2 = EventKind::TaskStarted {
verb: "infer".into(),
task_id: Arc::from("task2"),
inputs: serde_json::json!({}),
};
assert!(!state.should_break(&event2));
}
#[test]
fn test_timeline_cache_initialization() {
let state = TuiState::new("test.yaml");
assert!(state.cached_timeline_entries.is_empty());
assert_eq!(state.timeline_version, 0);
assert_eq!(state.timeline_cache_version, 0);
}
#[test]
fn test_timeline_cache_invalidation_on_task_scheduled() {
let mut state = TuiState::new("test.yaml");
let v1 = state.timeline_version;
state.handle_event(
&EventKind::TaskScheduled {
task_id: Arc::from("task1"),
dependencies: vec![],
},
0,
);
assert_ne!(
state.timeline_version, v1,
"Version should change after TaskScheduled"
);
}
#[test]
fn test_timeline_cache_invalidation_on_task_started() {
let mut state = TuiState::new("test.yaml");
state.handle_event(
&EventKind::TaskScheduled {
task_id: Arc::from("task1"),
dependencies: vec![],
},
0,
);
let v1 = state.timeline_version;
state.handle_event(
&EventKind::TaskStarted {
verb: "infer".into(),
task_id: Arc::from("task1"),
inputs: serde_json::json!({}),
},
10,
);
assert_ne!(
state.timeline_version, v1,
"Version should change after TaskStarted"
);
}
#[test]
fn test_timeline_cache_invalidation_on_task_completed() {
let mut state = TuiState::new("test.yaml");
state.handle_event(
&EventKind::TaskScheduled {
task_id: Arc::from("task1"),
dependencies: vec![],
},
0,
);
state.handle_event(
&EventKind::TaskStarted {
verb: "infer".into(),
task_id: Arc::from("task1"),
inputs: serde_json::json!({}),
},
10,
);
let v1 = state.timeline_version;
state.handle_event(
&EventKind::TaskCompleted {
task_id: Arc::from("task1"),
output: serde_json::json!({"result": "done"}).into(),
duration_ms: 100,
},
110,
);
assert_ne!(
state.timeline_version, v1,
"Version should change after TaskCompleted"
);
}
#[test]
fn test_timeline_cache_ensure_builds_entries() {
let mut state = TuiState::new("test.yaml");
state.handle_event(
&EventKind::TaskScheduled {
task_id: Arc::from("task1"),
dependencies: vec![],
},
0,
);
state.handle_event(
&EventKind::TaskScheduled {
task_id: Arc::from("task2"),
dependencies: vec![Arc::from("task1")],
},
0,
);
assert!(state.cached_timeline_entries.is_empty());
state.ensure_timeline_cache();
assert_eq!(state.cached_timeline_entries.len(), 2);
assert_eq!(state.timeline_cache_version, state.timeline_version);
}
#[test]
fn test_timeline_cache_reuse_when_not_stale() {
let mut state = TuiState::new("test.yaml");
state.handle_event(
&EventKind::TaskScheduled {
task_id: Arc::from("task1"),
dependencies: vec![],
},
0,
);
state.ensure_timeline_cache();
let v1 = state.timeline_cache_version;
let entries_ptr = state.cached_timeline_entries.as_ptr();
state.ensure_timeline_cache();
let v2 = state.timeline_cache_version;
let entries_ptr2 = state.cached_timeline_entries.as_ptr();
assert_eq!(v1, v2);
assert_eq!(entries_ptr, entries_ptr2);
}
#[test]
fn test_settings_field_next_cycles() {
assert_eq!(SettingsField::AnthropicKey.next(), SettingsField::OpenAiKey);
assert_eq!(SettingsField::OpenAiKey.next(), SettingsField::Provider);
assert_eq!(SettingsField::Provider.next(), SettingsField::Model);
assert_eq!(SettingsField::Model.next(), SettingsField::AnthropicKey);
}
#[test]
fn test_settings_field_prev_cycles() {
assert_eq!(SettingsField::AnthropicKey.prev(), SettingsField::Model);
assert_eq!(SettingsField::OpenAiKey.prev(), SettingsField::AnthropicKey);
assert_eq!(SettingsField::Provider.prev(), SettingsField::OpenAiKey);
assert_eq!(SettingsField::Model.prev(), SettingsField::Provider);
}
#[test]
fn test_settings_field_all() {
let all = SettingsField::all();
assert_eq!(all.len(), 4);
assert_eq!(all[0], SettingsField::AnthropicKey);
assert_eq!(all[3], SettingsField::Model);
}
#[test]
fn test_settings_field_labels() {
assert_eq!(SettingsField::AnthropicKey.label(), "Anthropic API Key");
assert_eq!(SettingsField::OpenAiKey.label(), "OpenAI API Key");
assert_eq!(SettingsField::Provider.label(), "Default Provider");
assert_eq!(SettingsField::Model.label(), "Default Model");
}
#[test]
fn test_settings_state_default() {
let state = SettingsState::default();
assert_eq!(state.focus, SettingsField::AnthropicKey);
assert!(!state.editing);
assert!(state.input_buffer.is_empty());
assert_eq!(state.cursor, 0);
assert!(!state.dirty);
}
#[test]
fn test_settings_state_focus_navigation() {
let mut state = SettingsState::default();
assert_eq!(state.focus, SettingsField::AnthropicKey);
state.focus_next();
assert_eq!(state.focus, SettingsField::OpenAiKey);
state.focus_next();
assert_eq!(state.focus, SettingsField::Provider);
state.focus_prev();
assert_eq!(state.focus, SettingsField::OpenAiKey);
}
#[test]
fn test_settings_state_edit_lifecycle() {
use crate::config::ApiKeys;
let config = NikaConfig {
api_keys: ApiKeys {
anthropic: Some("sk-ant-test".to_string()),
openai: None,
},
..Default::default()
};
let mut state = SettingsState::new(config);
state.start_edit();
assert!(state.editing);
assert_eq!(state.input_buffer, "sk-ant-test");
assert_eq!(state.cursor, 11);
state.backspace();
assert_eq!(state.input_buffer, "sk-ant-tes");
state.insert_char('X');
assert_eq!(state.input_buffer, "sk-ant-tesX");
state.cancel_edit();
assert!(!state.editing);
assert!(state.input_buffer.is_empty());
assert!(!state.dirty);
}
#[test]
fn test_settings_state_confirm_edit() {
let mut state = SettingsState {
focus: SettingsField::OpenAiKey,
..Default::default()
};
state.start_edit();
state.input_buffer = "sk-new-key".to_string();
state.confirm_edit();
assert!(!state.editing);
assert!(state.dirty);
assert_eq!(state.config.api_keys.openai, Some("sk-new-key".to_string()));
}
#[test]
fn test_settings_state_confirm_edit_empty_clears_value() {
use crate::config::ApiKeys;
let config = NikaConfig {
api_keys: ApiKeys {
anthropic: Some("sk-ant-test".to_string()),
openai: None,
},
..Default::default()
};
let mut state = SettingsState::new(config);
state.start_edit();
state.input_buffer.clear(); state.confirm_edit();
assert!(state.config.api_keys.anthropic.is_none());
assert!(state.dirty);
}
#[test]
fn test_settings_state_cursor_movement() {
let mut state = SettingsState {
editing: true,
input_buffer: "hello".to_string(),
cursor: 3, ..Default::default()
};
state.cursor_left();
assert_eq!(state.cursor, 2);
state.cursor_right();
assert_eq!(state.cursor, 3);
state.cursor_home();
assert_eq!(state.cursor, 0);
state.cursor_end();
assert_eq!(state.cursor, 5);
state.cursor_home();
state.cursor_left(); assert_eq!(state.cursor, 0);
state.cursor_end();
state.cursor_right(); assert_eq!(state.cursor, 5);
}
#[test]
fn test_settings_state_key_status_displays_masked() {
use crate::config::ApiKeys;
let config = NikaConfig {
api_keys: ApiKeys {
anthropic: Some("sk-ant-api03-xyz123abc456".to_string()),
openai: None,
},
..Default::default()
};
let state = SettingsState::new(config);
let (is_set, display) = state.key_status(SettingsField::AnthropicKey);
assert!(is_set);
assert!(display.contains("***"));
assert!(display.starts_with("sk-ant-api03"));
let (is_set, display) = state.key_status(SettingsField::OpenAiKey);
assert!(!is_set);
assert_eq!(display, "Not set");
}
#[test]
fn test_settings_state_provider_auto_detection() {
use crate::config::ApiKeys;
let config = NikaConfig {
api_keys: ApiKeys {
anthropic: Some("sk-ant-test".to_string()),
openai: None,
},
..Default::default()
};
let state = SettingsState::new(config);
let (is_set, display) = state.key_status(SettingsField::Provider);
assert!(!is_set); assert!(display.contains("claude"));
assert!(display.contains("auto"));
}
#[test]
fn test_tui_mode_settings_variant() {
let mode = TuiMode::Settings;
assert_eq!(mode, TuiMode::Settings);
assert_ne!(mode, TuiMode::Normal);
assert_ne!(mode, TuiMode::Help);
}
#[test]
fn test_tui_mode_all_variants() {
let normal = TuiMode::Normal;
let streaming = TuiMode::Streaming;
let _inspect = TuiMode::Inspect("task-1".to_string());
let _edit = TuiMode::Edit("task-1".to_string());
let search = TuiMode::Search;
let help = TuiMode::Help;
let metrics = TuiMode::Metrics;
let settings = TuiMode::Settings;
let chat_overlay = TuiMode::ChatOverlay;
assert_eq!(normal, TuiMode::Normal);
assert_eq!(streaming, TuiMode::Streaming);
assert_eq!(search, TuiMode::Search);
assert_eq!(help, TuiMode::Help);
assert_eq!(metrics, TuiMode::Metrics);
assert_eq!(settings, TuiMode::Settings);
assert_eq!(chat_overlay, TuiMode::ChatOverlay);
assert_ne!(normal, streaming);
assert_ne!(streaming, help);
assert_ne!(search, metrics);
}
#[test]
fn test_tui_mode_with_data_variants() {
let inspect1 = TuiMode::Inspect("task-1".to_string());
let inspect2 = TuiMode::Inspect("task-1".to_string());
let inspect3 = TuiMode::Inspect("task-2".to_string());
let edit1 = TuiMode::Edit("task-1".to_string());
let edit2 = TuiMode::Edit("task-1".to_string());
let edit3 = TuiMode::Edit("task-2".to_string());
assert_eq!(inspect1, inspect2);
assert_eq!(edit1, edit2);
assert_ne!(inspect1, inspect3);
assert_ne!(edit1, edit3);
assert_ne!(inspect1, edit1);
}
#[test]
fn test_tui_mode_default_is_normal() {
let mode: TuiMode = Default::default();
assert_eq!(mode, TuiMode::Normal);
}
#[test]
fn test_tui_state_has_settings() {
let state = TuiState::new("test.yaml");
assert_eq!(state.settings.focus, SettingsField::AnthropicKey);
assert!(!state.settings.editing);
}
#[test]
fn test_is_failed_returns_true_on_abort() {
let mut state = TuiState::new("test.yaml");
state.workflow.phase = MissionPhase::Abort;
assert!(state.is_failed());
}
#[test]
fn test_is_failed_returns_true_on_error_message() {
let mut state = TuiState::new("test.yaml");
state.workflow.error_message = Some("Something went wrong".to_string());
assert!(state.is_failed());
}
#[test]
fn test_is_failed_returns_false_on_success() {
let mut state = TuiState::new("test.yaml");
state.workflow.phase = MissionPhase::MissionSuccess;
assert!(!state.is_failed());
assert!(state.is_success());
}
#[test]
fn test_is_running_returns_true_during_execution() {
let mut state = TuiState::new("test.yaml");
state.workflow.phase = MissionPhase::Countdown;
assert!(state.is_running());
state.workflow.phase = MissionPhase::Launch;
assert!(state.is_running());
state.workflow.phase = MissionPhase::Orbital;
assert!(state.is_running());
state.workflow.phase = MissionPhase::Rendezvous;
assert!(state.is_running());
}
#[test]
fn test_is_running_returns_false_when_not_executing() {
let mut state = TuiState::new("test.yaml");
state.workflow.phase = MissionPhase::Preflight;
assert!(!state.is_running());
state.workflow.phase = MissionPhase::MissionSuccess;
assert!(!state.is_running());
state.workflow.phase = MissionPhase::Abort;
assert!(!state.is_running());
}
#[test]
fn test_reset_for_retry_resets_workflow_state() {
let mut state = TuiState::new("test.yaml");
state.workflow.phase = MissionPhase::Abort;
state.workflow.error_message = Some("Test error".to_string());
state.workflow.task_count = 3;
state.workflow.tasks_completed = 2;
let reset_tasks = state.reset_for_retry();
assert_eq!(state.workflow.phase, MissionPhase::Preflight);
assert!(state.workflow.error_message.is_none());
assert!(state.workflow.final_output.is_none());
assert_eq!(state.workflow.tasks_completed, 0);
assert!(reset_tasks.is_empty()); }
#[test]
fn test_reset_for_retry_resets_failed_tasks() {
let mut state = TuiState::new("test.yaml");
state.tasks.insert(
"task1".to_string(),
TaskState {
id: "task1".to_string(),
task_type: Some("infer".to_string()),
status: TaskStatus::Success,
dependencies: vec![],
started_at: None,
duration_ms: Some(100),
input: None,
output: None,
error: None,
tokens: None,
provider: None,
model: None,
prompt_len: None,
},
);
state.tasks.insert(
"task2".to_string(),
TaskState {
id: "task2".to_string(),
task_type: Some("exec".to_string()),
status: TaskStatus::Failed,
dependencies: vec!["task1".to_string()],
started_at: None,
duration_ms: Some(50),
input: None,
output: None,
error: Some("Command failed".to_string()),
tokens: None,
provider: None,
model: None,
prompt_len: None,
},
);
state.workflow.phase = MissionPhase::Abort;
let reset_tasks = state.reset_for_retry();
assert_eq!(state.tasks["task1"].status, TaskStatus::Success);
assert_eq!(state.tasks["task2"].status, TaskStatus::Pending);
assert!(state.tasks["task2"].error.is_none());
assert!(state.tasks["task2"].duration_ms.is_none());
assert_eq!(reset_tasks.len(), 1);
assert!(reset_tasks.contains(&"task2".to_string()));
}
#[test]
fn test_mcp_navigation_empty_list() {
let mut state = TuiState::new("test.yaml");
assert!(state.mcp_calls.is_empty());
assert!(state.selected_mcp_idx.is_none());
state.select_prev_mcp();
state.select_next_mcp();
assert!(state.selected_mcp_idx.is_none());
}
#[test]
fn test_mcp_navigation_select_prev() {
let mut state = TuiState::new("test.yaml");
for i in 0..3 {
state.mcp_calls.push(McpCall {
call_id: format!("call-{}", i),
seq: i,
server: "novanet".to_string(),
tool: Some(format!("tool{}", i)),
resource: None,
task_id: "task1".to_string(),
completed: true,
output_len: Some(100),
timestamp_ms: 1000 + (i as u64) * 100,
params: None,
response: None,
is_error: false,
duration_ms: Some(10),
});
}
state.select_prev_mcp();
assert_eq!(state.selected_mcp_idx, Some(2));
state.select_prev_mcp();
assert_eq!(state.selected_mcp_idx, Some(1));
state.select_prev_mcp();
assert_eq!(state.selected_mcp_idx, Some(0));
state.select_prev_mcp();
assert_eq!(state.selected_mcp_idx, Some(0));
}
#[test]
fn test_mcp_navigation_select_next() {
let mut state = TuiState::new("test.yaml");
for i in 0..3 {
state.mcp_calls.push(McpCall {
call_id: format!("call-{}", i),
seq: i,
server: "novanet".to_string(),
tool: Some(format!("tool{}", i)),
resource: None,
task_id: "task1".to_string(),
completed: true,
output_len: Some(100),
timestamp_ms: 1000 + (i as u64) * 100,
params: None,
response: None,
is_error: false,
duration_ms: Some(10),
});
}
state.select_next_mcp();
assert_eq!(state.selected_mcp_idx, Some(0));
state.select_next_mcp();
assert_eq!(state.selected_mcp_idx, Some(1));
state.select_next_mcp();
assert_eq!(state.selected_mcp_idx, Some(2));
state.select_next_mcp();
assert_eq!(state.selected_mcp_idx, Some(2));
}
#[test]
fn test_mcp_navigation_get_selected() {
let mut state = TuiState::new("test.yaml");
state.mcp_calls.push(McpCall {
call_id: "call-0".to_string(),
seq: 0,
server: "novanet".to_string(),
tool: Some("novanet_describe".to_string()),
resource: None,
task_id: "task1".to_string(),
completed: true,
output_len: Some(100),
timestamp_ms: 1000,
params: None,
response: None,
is_error: false,
duration_ms: Some(10),
});
assert!(state.get_selected_mcp().is_none());
state.select_mcp(0);
let selected = state.get_selected_mcp().unwrap();
assert_eq!(selected.tool.as_deref(), Some("novanet_describe"));
}
#[test]
fn test_filter_push_adds_characters() {
let mut state = TuiState::new("test.yaml");
assert!(state.filter_query.is_empty());
assert_eq!(state.filter_cursor, 0);
state.filter_push('h');
state.filter_push('e');
state.filter_push('l');
state.filter_push('l');
state.filter_push('o');
assert_eq!(state.filter_query, "hello");
assert_eq!(state.filter_cursor, 5);
}
#[test]
fn test_filter_backspace_removes_before_cursor() {
let mut state = TuiState::new("test.yaml");
state.filter_query = "hello".to_string();
state.filter_cursor = 5;
state.filter_backspace();
assert_eq!(state.filter_query, "hell");
assert_eq!(state.filter_cursor, 4);
state.filter_backspace();
state.filter_backspace();
assert_eq!(state.filter_query, "he");
assert_eq!(state.filter_cursor, 2);
state.filter_cursor = 0;
state.filter_backspace();
assert_eq!(state.filter_query, "he");
assert_eq!(state.filter_cursor, 0);
}
#[test]
fn test_filter_delete_removes_at_cursor() {
let mut state = TuiState::new("test.yaml");
state.filter_query = "hello".to_string();
state.filter_cursor = 0;
state.filter_delete();
assert_eq!(state.filter_query, "ello");
assert_eq!(state.filter_cursor, 0);
state.filter_cursor = state.filter_query.len();
state.filter_delete();
assert_eq!(state.filter_query, "ello");
}
#[test]
fn test_filter_cursor_movement() {
let mut state = TuiState::new("test.yaml");
state.filter_query = "hello".to_string();
state.filter_cursor = 2;
state.filter_cursor_left();
assert_eq!(state.filter_cursor, 1);
state.filter_cursor_right();
assert_eq!(state.filter_cursor, 2);
state.filter_cursor = 0;
state.filter_cursor_left();
assert_eq!(state.filter_cursor, 0);
state.filter_cursor = 5;
state.filter_cursor_right();
assert_eq!(state.filter_cursor, 5);
}
#[test]
fn test_filter_clear_resets_all() {
let mut state = TuiState::new("test.yaml");
state.filter_query = "hello".to_string();
state.filter_cursor = 3;
state.filter_clear();
assert!(state.filter_query.is_empty());
assert_eq!(state.filter_cursor, 0);
}
#[test]
fn test_has_filter() {
let mut state = TuiState::new("test.yaml");
assert!(!state.has_filter());
state.filter_query = "test".to_string();
assert!(state.has_filter());
state.filter_clear();
assert!(!state.has_filter());
}
#[test]
fn test_filtered_task_ids_no_filter() {
let mut state = TuiState::new("test.yaml");
state.task_order = vec![
"task1".to_string(),
"task2".to_string(),
"task3".to_string(),
];
let filtered = state.filtered_task_ids();
assert_eq!(filtered.len(), 3);
}
#[test]
fn test_filtered_task_ids_matches_id() {
let mut state = TuiState::new("test.yaml");
state.task_order = vec![
"generate".to_string(),
"fetch_data".to_string(),
"transform".to_string(),
];
state.filter_query = "gen".to_string();
let filtered = state.filtered_task_ids();
assert_eq!(filtered.len(), 1);
assert_eq!(*filtered[0], "generate");
}
#[test]
fn test_filtered_task_ids_matches_type() {
let mut state = TuiState::new("test.yaml");
state.task_order = vec!["task1".to_string(), "task2".to_string()];
state.tasks.insert(
"task1".to_string(),
TaskState {
id: "task1".to_string(),
task_type: Some("infer".to_string()),
status: TaskStatus::Pending,
dependencies: vec![],
started_at: None,
duration_ms: None,
input: None,
output: None,
error: None,
tokens: None,
provider: None,
model: None,
prompt_len: None,
},
);
state.tasks.insert(
"task2".to_string(),
TaskState {
id: "task2".to_string(),
task_type: Some("exec".to_string()),
status: TaskStatus::Pending,
dependencies: vec![],
started_at: None,
duration_ms: None,
input: None,
output: None,
error: None,
tokens: None,
provider: None,
model: None,
prompt_len: None,
},
);
state.filter_query = "infer".to_string();
let filtered = state.filtered_task_ids();
assert_eq!(filtered.len(), 1);
assert_eq!(*filtered[0], "task1");
}
#[test]
fn test_filtered_task_ids_case_insensitive() {
let mut state = TuiState::new("test.yaml");
state.task_order = vec!["GeneratePage".to_string()];
state.filter_query = "page".to_string();
let filtered = state.filtered_task_ids();
assert_eq!(filtered.len(), 1);
state.filter_query = "PAGE".to_string();
let filtered = state.filtered_task_ids();
assert_eq!(filtered.len(), 1);
}
#[test]
fn test_filtered_mcp_calls_no_filter() {
let mut state = TuiState::new("test.yaml");
state.mcp_calls.push(McpCall {
call_id: "call-0".to_string(),
seq: 0,
server: "novanet".to_string(),
tool: Some("novanet_describe".to_string()),
resource: None,
task_id: "task1".to_string(),
completed: true,
output_len: Some(100),
timestamp_ms: 1000,
params: None,
response: None,
is_error: false,
duration_ms: Some(10),
});
let filtered = state.filtered_mcp_calls();
assert_eq!(filtered.len(), 1);
}
#[test]
fn test_filtered_mcp_calls_matches_server() {
let mut state = TuiState::new("test.yaml");
state.mcp_calls.push(McpCall {
call_id: "call-0".to_string(),
seq: 0,
server: "novanet".to_string(),
tool: Some("novanet_describe".to_string()),
resource: None,
task_id: "task1".to_string(),
completed: true,
output_len: Some(100),
timestamp_ms: 1000,
params: None,
response: None,
is_error: false,
duration_ms: Some(10),
});
state.mcp_calls.push(McpCall {
call_id: "call-1".to_string(),
seq: 1,
server: "other_server".to_string(),
tool: Some("other_tool".to_string()),
resource: None,
task_id: "task1".to_string(),
completed: true,
output_len: Some(100),
timestamp_ms: 1100,
params: None,
response: None,
is_error: false,
duration_ms: Some(10),
});
state.filter_query = "nova".to_string();
let filtered = state.filtered_mcp_calls();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].server, "novanet");
}
#[test]
fn test_filtered_mcp_calls_matches_tool() {
let mut state = TuiState::new("test.yaml");
state.mcp_calls.push(McpCall {
call_id: "call-0".to_string(),
seq: 0,
server: "novanet".to_string(),
tool: Some("novanet_describe".to_string()),
resource: None,
task_id: "task1".to_string(),
completed: true,
output_len: Some(100),
timestamp_ms: 1000,
params: None,
response: None,
is_error: false,
duration_ms: Some(10),
});
state.mcp_calls.push(McpCall {
call_id: "call-1".to_string(),
seq: 1,
server: "novanet".to_string(),
tool: Some("novanet_traverse".to_string()),
resource: None,
task_id: "task1".to_string(),
completed: true,
output_len: Some(100),
timestamp_ms: 1100,
params: None,
response: None,
is_error: false,
duration_ms: Some(10),
});
state.filter_query = "describe".to_string();
let filtered = state.filtered_mcp_calls();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].tool.as_deref(), Some("novanet_describe"));
}
#[test]
fn test_filtered_mcp_calls_matches_resource() {
let mut state = TuiState::new("test.yaml");
state.mcp_calls.push(McpCall {
call_id: "call-0".to_string(),
seq: 0,
server: "novanet".to_string(),
tool: None,
resource: Some("neo4j://entity/qr-code".to_string()),
task_id: "task1".to_string(),
completed: true,
output_len: Some(100),
timestamp_ms: 1000,
params: None,
response: None,
is_error: false,
duration_ms: Some(10),
});
state.filter_query = "qr-code".to_string();
let filtered = state.filtered_mcp_calls();
assert_eq!(filtered.len(), 1);
assert!(filtered[0].resource.as_ref().unwrap().contains("qr-code"));
}
#[test]
fn test_notification_level_icons() {
assert_eq!(NotificationLevel::Info.icon(), "ℹ");
assert_eq!(NotificationLevel::Warning.icon(), "⚠");
assert_eq!(NotificationLevel::Alert.icon(), "🔔");
assert_eq!(NotificationLevel::Success.icon(), "✓");
assert_eq!(NotificationLevel::Error.icon(), "✗");
}
#[test]
fn test_notification_constructors() {
let n = Notification::info("Test info", 1000);
assert_eq!(n.level, NotificationLevel::Info);
assert_eq!(n.message, "Test info");
assert_eq!(n.timestamp_ms, 1000);
assert!(!n.dismissed);
let n = Notification::warning("Test warning", 2000);
assert_eq!(n.level, NotificationLevel::Warning);
let n = Notification::alert("Test alert", 3000);
assert_eq!(n.level, NotificationLevel::Alert);
let n = Notification::success("Test success", 4000);
assert_eq!(n.level, NotificationLevel::Success);
let n = Notification::error("Test error", 5000);
assert_eq!(n.level, NotificationLevel::Error);
}
#[test]
fn test_add_notification() {
let mut state = TuiState::new("test.yaml");
assert_eq!(state.notifications.len(), 0);
state.add_notification(Notification::info("Test 1", 1000));
assert_eq!(state.notifications.len(), 1);
assert_eq!(state.notifications[0].message, "Test 1");
state.add_notification(Notification::warning("Test 2", 2000));
assert_eq!(state.notifications.len(), 2);
}
#[test]
fn test_notification_max_limit() {
let mut state = TuiState::new("test.yaml");
state.max_notifications = 3;
for i in 0..5 {
state.add_notification(Notification::info(format!("Test {}", i), i * 1000));
}
assert_eq!(state.notifications.len(), 3);
assert_eq!(state.notifications[0].message, "Test 2");
assert_eq!(state.notifications[1].message, "Test 3");
assert_eq!(state.notifications[2].message, "Test 4");
}
#[test]
fn test_active_notifications() {
let mut state = TuiState::new("test.yaml");
state.add_notification(Notification::info("Active 1", 1000));
state.add_notification(Notification::info("Active 2", 2000));
state.notifications[0].dismissed = true;
let active: Vec<_> = state.active_notifications().collect();
assert_eq!(active.len(), 1);
assert_eq!(active[0].message, "Active 2");
}
#[test]
fn test_active_notification_count() {
let mut state = TuiState::new("test.yaml");
state.add_notification(Notification::info("1", 1000));
state.add_notification(Notification::info("2", 2000));
state.add_notification(Notification::info("3", 3000));
assert_eq!(state.active_notification_count(), 3);
state.notifications[1].dismissed = true;
assert_eq!(state.active_notification_count(), 2);
}
#[test]
fn test_dismiss_notification() {
let mut state = TuiState::new("test.yaml");
state.add_notification(Notification::info("1", 1000));
state.add_notification(Notification::info("2", 2000));
state.add_notification(Notification::info("3", 3000));
state.dismiss_notification();
assert!(state.notifications[2].dismissed);
assert!(!state.notifications[1].dismissed);
assert!(!state.notifications[0].dismissed);
state.dismiss_notification();
assert!(state.notifications[1].dismissed);
assert!(!state.notifications[0].dismissed);
}
#[test]
fn test_dismiss_all_notifications() {
let mut state = TuiState::new("test.yaml");
state.add_notification(Notification::info("1", 1000));
state.add_notification(Notification::info("2", 2000));
state.add_notification(Notification::info("3", 3000));
state.dismiss_all_notifications();
assert!(state.notifications.iter().all(|n| n.dismissed));
assert_eq!(state.active_notification_count(), 0);
}
#[test]
fn test_clear_notifications() {
let mut state = TuiState::new("test.yaml");
state.add_notification(Notification::info("1", 1000));
state.add_notification(Notification::info("2", 2000));
assert_eq!(state.notifications.len(), 2);
state.clear_notifications();
assert_eq!(state.notifications.len(), 0);
}
#[test]
fn test_workflow_completed_adds_notification() {
let mut state = TuiState::new("test.yaml");
state.workflow.task_count = 4;
state.workflow.tasks_completed = 4;
state.handle_event(
&EventKind::WorkflowCompleted {
final_output: std::sync::Arc::new(serde_json::Value::Null),
total_duration_ms: 5000,
},
5000,
);
assert_eq!(state.notifications.len(), 1);
assert_eq!(state.notifications[0].level, NotificationLevel::Success);
assert!(state.notifications[0].message.contains("Magnificent"));
}
#[test]
fn test_workflow_failed_adds_notification() {
let mut state = TuiState::new("test.yaml");
state.handle_event(
&EventKind::WorkflowFailed {
error: "Something went wrong".to_string(),
failed_task: None,
},
5000,
);
assert_eq!(state.notifications.len(), 1);
assert_eq!(state.notifications[0].level, NotificationLevel::Error);
assert!(state.notifications[0].message.contains("failed"));
}
#[test]
fn test_slow_task_adds_warning() {
let mut state = TuiState::new("test.yaml");
state.tasks.insert(
"slow-task".to_string(),
TaskState::new("slow-task".to_string(), vec![]),
);
state.handle_event(
&EventKind::TaskCompleted {
task_id: "slow-task".into(),
output: std::sync::Arc::new(serde_json::Value::Null),
duration_ms: 15000,
},
15000,
);
assert_eq!(state.notifications.len(), 1);
assert_eq!(state.notifications[0].level, NotificationLevel::Warning);
assert!(state.notifications[0].message.contains("15.0s"));
}
#[test]
fn test_very_slow_task_adds_alert() {
let mut state = TuiState::new("test.yaml");
state.tasks.insert(
"very-slow-task".to_string(),
TaskState::new("very-slow-task".to_string(), vec![]),
);
state.handle_event(
&EventKind::TaskCompleted {
task_id: "very-slow-task".into(),
output: std::sync::Arc::new(serde_json::Value::Null),
duration_ms: 35000,
},
35000,
);
assert_eq!(state.notifications.len(), 1);
assert_eq!(state.notifications[0].level, NotificationLevel::Alert);
assert!(state.notifications[0].message.contains("35.0s"));
}
#[test]
fn test_dirty_flags_default() {
let flags = DirtyFlags::default();
assert!(!flags.all);
assert!(!flags.progress);
assert!(!flags.dag);
assert!(!flags.novanet);
assert!(!flags.reasoning);
assert!(!flags.status);
assert!(!flags.notifications);
assert!(!flags.any());
}
#[test]
fn test_dirty_flags_mark_all() {
let mut flags = DirtyFlags::default();
flags.mark_all();
assert!(flags.all);
assert!(flags.any());
}
#[test]
fn test_dirty_flags_clear() {
let mut flags = DirtyFlags {
all: true,
progress: true,
dag: true,
novanet: true,
reasoning: true,
status: true,
notifications: true,
};
flags.clear();
assert!(!flags.all);
assert!(!flags.progress);
assert!(!flags.dag);
assert!(!flags.novanet);
assert!(!flags.reasoning);
assert!(!flags.status);
assert!(!flags.notifications);
assert!(!flags.any());
}
#[test]
fn test_dirty_flags_any() {
let mut flags = DirtyFlags::default();
assert!(!flags.any());
flags.progress = true;
assert!(flags.any());
flags.progress = false;
flags.dag = true;
assert!(flags.any());
}
#[test]
fn test_dirty_flags_is_panel_dirty() {
let mut flags = DirtyFlags {
all: true,
..Default::default()
};
assert!(flags.is_panel_dirty(PanelId::Progress));
assert!(flags.is_panel_dirty(PanelId::Dag));
assert!(flags.is_panel_dirty(PanelId::NovaNet));
assert!(flags.is_panel_dirty(PanelId::Agent));
flags.all = false;
assert!(!flags.is_panel_dirty(PanelId::Progress));
flags.progress = true;
assert!(flags.is_panel_dirty(PanelId::Progress));
assert!(!flags.is_panel_dirty(PanelId::Dag));
flags.dag = true;
assert!(flags.is_panel_dirty(PanelId::Dag));
flags.novanet = true;
assert!(flags.is_panel_dirty(PanelId::NovaNet));
flags.reasoning = true;
assert!(flags.is_panel_dirty(PanelId::Agent));
}
#[test]
fn test_workflow_started_marks_all_dirty() {
let mut state = TuiState::new("test.yaml");
state.handle_event(
&EventKind::WorkflowStarted {
task_count: 5,
generation_id: "gen-123".to_string(),
workflow_hash: "abc".to_string(),
nika_version: TEST_VERSION.to_string(),
},
0,
);
assert!(state.dirty.all);
}
#[test]
fn test_workflow_completed_marks_progress_status_dirty() {
let mut state = TuiState::new("test.yaml");
state.dirty.clear();
state.handle_event(
&EventKind::WorkflowCompleted {
final_output: std::sync::Arc::new(serde_json::Value::Null),
total_duration_ms: 1000,
},
1000,
);
assert!(state.dirty.progress);
assert!(state.dirty.status);
assert!(state.dirty.notifications); }
#[test]
fn test_task_events_mark_progress_dag_dirty() {
let mut state = TuiState::new("test.yaml");
state.dirty.clear();
state.handle_event(
&EventKind::TaskScheduled {
task_id: "task1".into(),
dependencies: vec![],
},
100,
);
assert!(state.dirty.progress);
assert!(state.dirty.dag);
state.dirty.clear();
state.handle_event(
&EventKind::TaskStarted {
verb: "infer".into(),
task_id: "task1".into(),
inputs: serde_json::json!({}),
},
200,
);
assert!(state.dirty.progress);
assert!(state.dirty.dag);
state.dirty.clear();
state.handle_event(
&EventKind::TaskCompleted {
task_id: "task1".into(),
output: std::sync::Arc::new(serde_json::Value::Null),
duration_ms: 500,
},
300,
);
assert!(state.dirty.progress);
assert!(state.dirty.dag);
}
#[test]
fn test_task_failed_marks_status_dirty() {
let mut state = TuiState::new("test.yaml");
state.tasks.insert(
"task1".to_string(),
TaskState::new("task1".to_string(), vec![]),
);
state.dirty.clear();
state.handle_event(
&EventKind::TaskFailed {
task_id: "task1".into(),
error: "error".into(),
duration_ms: 100,
},
100,
);
assert!(state.dirty.progress);
assert!(state.dirty.dag);
assert!(state.dirty.status);
}
#[test]
fn test_mcp_events_mark_novanet_dirty() {
let mut state = TuiState::new("test.yaml");
state.dirty.clear();
state.handle_event(
&EventKind::McpInvoke {
task_id: "task1".into(),
mcp_server: "novanet".to_string(),
tool: Some("describe".to_string()),
resource: None,
call_id: "call1".to_string(),
params: None,
},
100,
);
assert!(state.dirty.novanet);
state.dirty.clear();
state.handle_event(
&EventKind::McpResponse {
task_id: "task1".into(),
output_len: 100,
call_id: "call1".to_string(),
duration_ms: 50,
cached: false,
is_error: false,
response: None,
},
150,
);
assert!(state.dirty.novanet);
}
#[test]
fn test_agent_events_mark_reasoning_dirty() {
let mut state = TuiState::new("test.yaml");
state.dirty.clear();
state.handle_event(
&EventKind::AgentStart {
task_id: "task1".into(),
max_turns: 5,
mcp_servers: vec![],
},
100,
);
assert!(state.dirty.reasoning);
state.dirty.clear();
state.handle_event(
&EventKind::AgentTurn {
task_id: "task1".into(),
turn_index: 0,
kind: "started".to_string(),
metadata: None,
},
200,
);
assert!(state.dirty.reasoning);
state.dirty.clear();
state.handle_event(
&EventKind::AgentComplete {
task_id: "task1".into(),
turns: 1,
stop_reason: "natural".to_string(),
},
300,
);
assert!(state.dirty.reasoning);
}
#[test]
fn test_add_notification_marks_notifications_dirty() {
let mut state = TuiState::new("test.yaml");
state.dirty.clear();
state.add_notification(Notification::info("test", 100));
assert!(state.dirty.notifications);
}
#[test]
fn test_dismiss_notification_marks_notifications_dirty() {
let mut state = TuiState::new("test.yaml");
state.add_notification(Notification::info("test", 100));
state.dirty.clear();
state.dismiss_notification();
assert!(state.dirty.notifications);
}
#[test]
fn test_dismiss_all_marks_notifications_dirty() {
let mut state = TuiState::new("test.yaml");
state.add_notification(Notification::info("test", 100));
state.dirty.clear();
state.dismiss_all_notifications();
assert!(state.dirty.notifications);
}
#[test]
fn test_clear_notifications_marks_dirty() {
let mut state = TuiState::new("test.yaml");
state.add_notification(Notification::info("test", 100));
state.dirty.clear();
state.clear_notifications();
assert!(state.dirty.notifications);
}
#[test]
fn test_json_cache_new() {
let cache = JsonFormatCache::new();
assert_eq!(cache.stats(), (0, 50)); }
#[test]
fn test_json_cache_get_or_format_caches() {
let mut cache = JsonFormatCache::new();
let value = serde_json::json!({"name": "test"});
let result1 = cache.get_or_format("key1", &value).to_string();
assert!(result1.contains("name"));
let result2 = cache.get_or_format("key1", &value).to_string();
assert_eq!(result1, result2);
assert_eq!(cache.stats().0, 1); }
#[test]
fn test_json_cache_different_keys() {
let mut cache = JsonFormatCache::new();
let value1 = serde_json::json!({"a": 1});
let value2 = serde_json::json!({"b": 2});
cache.get_or_format("key1", &value1);
cache.get_or_format("key2", &value2);
assert_eq!(cache.stats().0, 2); }
#[test]
fn test_json_cache_invalidate() {
let mut cache = JsonFormatCache::new();
let value = serde_json::json!({"test": true});
cache.get_or_format("key1", &value);
cache.get_or_format("key2", &value);
assert_eq!(cache.stats().0, 2);
cache.invalidate("key1");
assert_eq!(cache.stats().0, 1);
}
#[test]
fn test_json_cache_invalidate_prefix() {
let mut cache = JsonFormatCache::new();
let value = serde_json::json!({"test": true});
cache.get_or_format("task:abc", &value);
cache.get_or_format("task:def", &value);
cache.get_or_format("mcp:xyz", &value);
assert_eq!(cache.stats().0, 3);
cache.invalidate_prefix("task:");
assert_eq!(cache.stats().0, 1); }
#[test]
fn test_json_cache_clear() {
let mut cache = JsonFormatCache::new();
let value = serde_json::json!({"test": true});
cache.get_or_format("key1", &value);
cache.get_or_format("key2", &value);
assert_eq!(cache.stats().0, 2);
cache.clear();
assert_eq!(cache.stats().0, 0);
}
#[test]
fn test_json_cache_eviction_on_limit() {
let mut cache = JsonFormatCache {
cache: HashMap::new(),
max_entries: 5, };
let value = serde_json::json!({"test": true});
for i in 0..5 {
cache.get_or_format(&format!("key{}", i), &value);
}
assert_eq!(cache.stats().0, 5);
cache.get_or_format("key_new", &value);
assert!(cache.stats().0 < 6);
}
#[test]
fn test_workflow_start_clears_json_cache() {
let mut state = TuiState::new("test.yaml");
let value = serde_json::json!({"test": true});
state.json_cache.get_or_format("key1", &value);
assert_eq!(state.json_cache.stats().0, 1);
state.handle_event(
&EventKind::WorkflowStarted {
task_count: 1,
workflow_hash: "hash-123".into(),
generation_id: "gen-123".into(),
nika_version: "0.5.1".into(),
},
100,
);
assert_eq!(state.json_cache.stats().0, 0);
}
#[test]
fn test_task_started_invalidates_task_cache() {
let mut state = TuiState::new("test.yaml");
let value = serde_json::json!({"test": true});
state.json_cache.get_or_format("task:my_task", &value);
state.json_cache.get_or_format("task:other_task", &value);
assert_eq!(state.json_cache.stats().0, 2);
state.handle_event(
&EventKind::TaskStarted {
verb: "infer".into(),
task_id: "my_task".into(),
inputs: serde_json::json!({}),
},
100,
);
assert_eq!(state.json_cache.stats().0, 1); }
#[test]
fn test_mcp_response_invalidates_mcp_cache() {
let mut state = TuiState::new("test.yaml");
let value = serde_json::json!({"test": true});
state.json_cache.get_or_format("mcp:call-123", &value);
state.json_cache.get_or_format("mcp:call-456", &value);
assert_eq!(state.json_cache.stats().0, 2);
state.handle_event(
&EventKind::McpResponse {
task_id: "task1".into(),
output_len: 100,
call_id: "call-123".into(),
duration_ms: 50,
cached: false,
is_error: false,
response: None,
},
100,
);
assert_eq!(state.json_cache.stats().0, 1); }
#[test]
fn test_chat_overlay_state_new() {
let state = ChatOverlayState::new();
assert_eq!(state.messages.len(), 1);
assert_eq!(state.messages[0].role, ChatOverlayMessageRole::System);
assert!(state.input.is_empty());
assert_eq!(state.cursor, 0);
assert_eq!(state.scroll, 0);
assert!(state.history.is_empty());
assert!(state.history_index.is_none());
assert!(!state.is_streaming);
assert!(state.partial_response.is_empty());
assert!(!state.current_model.is_empty());
}
#[test]
fn test_chat_overlay_streaming() {
let mut state = ChatOverlayState::new();
assert!(!state.is_streaming);
state.start_streaming();
assert!(state.is_streaming);
assert!(state.partial_response.is_empty());
state.append_streaming("Hello ");
state.append_streaming("world!");
assert_eq!(state.partial_response, "Hello world!");
let result = state.finish_streaming();
assert_eq!(result, "Hello world!");
assert!(!state.is_streaming);
assert!(state.partial_response.is_empty());
}
#[test]
fn test_chat_overlay_set_model() {
let mut state = ChatOverlayState::new();
state.set_model("gpt-4o-mini");
assert_eq!(state.current_model, "gpt-4o-mini");
}
#[test]
fn test_chat_overlay_tool_message() {
let mut state = ChatOverlayState::new();
state.add_tool_message("Tool output: OK");
assert_eq!(state.messages.len(), 2);
assert_eq!(state.messages[1].role, ChatOverlayMessageRole::Tool);
assert_eq!(state.messages[1].content, "Tool output: OK");
}
#[test]
fn test_chat_overlay_insert_char() {
let mut state = ChatOverlayState::new();
state.insert_char('h');
state.insert_char('i');
assert_eq!(state.input, "hi");
assert_eq!(state.cursor, 2);
}
#[test]
fn test_chat_overlay_backspace() {
let mut state = ChatOverlayState::new();
state.input = "hello".to_string();
state.cursor = 5;
state.backspace();
assert_eq!(state.input, "hell");
assert_eq!(state.cursor, 4);
state.cursor = 0;
state.backspace();
assert_eq!(state.input, "hell");
assert_eq!(state.cursor, 0);
}
#[test]
fn test_chat_overlay_delete() {
let mut state = ChatOverlayState::new();
state.input = "hello".to_string();
state.cursor = 0;
state.delete();
assert_eq!(state.input, "ello");
assert_eq!(state.cursor, 0);
state.cursor = 4;
state.delete();
assert_eq!(state.input, "ello");
}
#[test]
fn test_chat_overlay_cursor_movement() {
let mut state = ChatOverlayState::new();
state.input = "hello".to_string();
state.cursor = 3;
state.cursor_left();
assert_eq!(state.cursor, 2);
state.cursor_right();
assert_eq!(state.cursor, 3);
state.cursor_home();
assert_eq!(state.cursor, 0);
state.cursor_end();
assert_eq!(state.cursor, 5);
state.cursor_home();
state.cursor_left();
assert_eq!(state.cursor, 0);
state.cursor_end();
state.cursor_right();
assert_eq!(state.cursor, 5);
}
#[test]
fn test_chat_overlay_undo_after_typing() {
let mut state = ChatOverlayState::new();
state.edit_history.checkpoint("a", 1);
state.input = "a".to_string();
state.cursor = 1;
state.edit_history.checkpoint("ab", 2);
state.input = "ab".to_string();
state.cursor = 2;
assert!(state.can_undo());
assert!(state.undo());
assert_eq!(state.input, "a");
assert_eq!(state.cursor, 1);
}
#[test]
fn test_chat_overlay_redo_after_undo() {
let mut state = ChatOverlayState::new();
state.edit_history.checkpoint("hello", 5);
state.input = "hello".to_string();
state.cursor = 5;
state.edit_history.checkpoint("hello world", 11);
state.input = "hello world".to_string();
state.cursor = 11;
state.undo();
assert_eq!(state.input, "hello");
assert!(state.can_redo());
assert!(state.redo());
assert_eq!(state.input, "hello world");
}
#[test]
fn test_chat_overlay_undo_empty_returns_false() {
let state = ChatOverlayState::new();
assert!(!state.can_undo());
}
#[test]
fn test_chat_overlay_redo_empty_returns_false() {
let state = ChatOverlayState::new();
assert!(!state.can_redo());
}
#[test]
fn test_chat_overlay_add_user_message() {
let mut state = ChatOverlayState::new();
state.input = "hello Nika".to_string();
state.cursor = 10;
let result = state.add_user_message();
assert!(result.is_some());
assert_eq!(result.unwrap(), "hello Nika");
assert!(state.input.is_empty());
assert_eq!(state.cursor, 0);
assert_eq!(state.messages.len(), 2);
assert_eq!(state.messages[1].role, ChatOverlayMessageRole::User);
assert_eq!(state.messages[1].content, "hello Nika");
assert_eq!(state.history.len(), 1);
assert_eq!(state.history[0], "hello Nika");
}
#[test]
fn test_chat_overlay_add_user_message_empty_returns_none() {
let mut state = ChatOverlayState::new();
state.input = " ".to_string();
let result = state.add_user_message();
assert!(result.is_none());
assert_eq!(state.messages.len(), 1); }
#[test]
fn test_chat_overlay_add_nika_message() {
let mut state = ChatOverlayState::new();
state.add_nika_message("Hello there!");
assert_eq!(state.messages.len(), 2);
assert_eq!(state.messages[1].role, ChatOverlayMessageRole::Nika);
assert_eq!(state.messages[1].content, "Hello there!");
}
#[test]
fn test_chat_overlay_history_navigation() {
let mut state = ChatOverlayState::new();
state.history = vec![
"first message".to_string(),
"second message".to_string(),
"third message".to_string(),
];
state.history_up();
assert_eq!(state.history_index, Some(2));
assert_eq!(state.input, "third message");
state.history_up();
assert_eq!(state.history_index, Some(1));
assert_eq!(state.input, "second message");
state.history_up();
assert_eq!(state.history_index, Some(0));
assert_eq!(state.input, "first message");
state.history_up();
assert_eq!(state.history_index, Some(0));
state.history_down();
assert_eq!(state.history_index, Some(1));
assert_eq!(state.input, "second message");
state.history_down();
assert_eq!(state.history_index, Some(2));
assert_eq!(state.input, "third message");
state.history_down();
assert!(state.history_index.is_none());
assert!(state.input.is_empty());
}
#[test]
fn test_chat_overlay_history_up_empty() {
let mut state = ChatOverlayState::new();
state.history_up();
assert!(state.history_index.is_none());
assert!(state.input.is_empty());
}
#[test]
fn test_chat_overlay_clear() {
let mut state = ChatOverlayState::new();
state.add_nika_message("Message 1");
state.add_nika_message("Message 2");
state.scroll = 5;
state.clear();
assert_eq!(state.messages.len(), 1);
assert_eq!(state.messages[0].role, ChatOverlayMessageRole::System);
assert!(state.messages[0].content.contains("cleared"));
assert_eq!(state.scroll, 0);
}
#[test]
fn test_chat_overlay_scroll() {
let mut state = ChatOverlayState::new();
assert_eq!(state.scroll, 0);
state.scroll_up();
assert_eq!(state.scroll, 1);
state.scroll_up();
assert_eq!(state.scroll, 2);
state.scroll_down();
assert_eq!(state.scroll, 1);
state.scroll_down();
assert_eq!(state.scroll, 0);
state.scroll_down();
assert_eq!(state.scroll, 0);
}
#[test]
fn test_tui_mode_chat_overlay_variant() {
let mode = TuiMode::ChatOverlay;
assert_eq!(mode, TuiMode::ChatOverlay);
assert_ne!(mode, TuiMode::Normal);
assert_ne!(mode, TuiMode::Settings);
}
#[test]
fn test_tui_state_has_chat_overlay() {
let state = TuiState::new("test.yaml");
assert_eq!(state.chat_overlay.messages.len(), 1);
assert!(state.chat_overlay.input.is_empty());
}
#[test]
fn test_chat_overlay_message_new() {
let msg = ChatOverlayMessage::new(ChatOverlayMessageRole::User, "test message");
assert_eq!(msg.role, ChatOverlayMessageRole::User);
assert_eq!(msg.content, "test message");
}
#[test]
fn test_panel_scroll_state_new() {
let state = PanelScrollState::new();
assert_eq!(state.offset, 0);
assert_eq!(state.cursor, 0);
assert_eq!(state.total, 0);
assert_eq!(state.visible, 0);
}
#[test]
fn test_panel_scroll_state_with_total() {
let state = PanelScrollState::with_total(100);
assert_eq!(state.total, 100);
assert_eq!(state.cursor, 0);
assert_eq!(state.offset, 0);
}
#[test]
fn test_panel_scroll_state_cursor_down() {
let mut state = PanelScrollState::with_total(10);
state.visible = 5;
state.cursor_down();
assert_eq!(state.cursor, 1);
for _ in 0..10 {
state.cursor_down();
}
assert_eq!(state.cursor, 9); }
#[test]
fn test_panel_scroll_state_cursor_up() {
let mut state = PanelScrollState::with_total(10);
state.visible = 5;
state.cursor = 5;
state.cursor_up();
assert_eq!(state.cursor, 4);
for _ in 0..10 {
state.cursor_up();
}
assert_eq!(state.cursor, 0); }
#[test]
fn test_panel_scroll_state_ensure_cursor_visible() {
let mut state = PanelScrollState::with_total(100);
state.visible = 10;
state.cursor = 50;
state.ensure_cursor_visible();
let margin = SCROLL_MARGIN.min(state.visible / 2);
assert!(state.cursor >= state.offset + margin || state.cursor < margin);
assert!(state.cursor < state.offset + state.visible);
}
#[test]
fn test_panel_scroll_state_cursor_first_last() {
let mut state = PanelScrollState::with_total(100);
state.visible = 10;
state.cursor = 50;
state.cursor_first();
assert_eq!(state.cursor, 0);
assert_eq!(state.offset, 0);
state.cursor_last();
assert_eq!(state.cursor, 99);
}
#[test]
fn test_panel_scroll_state_page_up_down() {
let mut state = PanelScrollState::with_total(100);
state.visible = 10;
state.page_down();
assert_eq!(state.cursor, 10);
state.page_down();
assert_eq!(state.cursor, 20);
state.page_up();
assert_eq!(state.cursor, 10);
}
#[test]
fn test_panel_scroll_state_selected() {
let state = PanelScrollState::with_total(10);
assert_eq!(state.selected(), Some(0));
let empty_state = PanelScrollState::new();
assert_eq!(empty_state.selected(), None);
}
#[test]
fn test_panel_scroll_state_is_selected() {
let mut state = PanelScrollState::with_total(10);
state.cursor = 5;
assert!(state.is_selected(5));
assert!(!state.is_selected(3));
}
#[test]
fn test_panel_scroll_state_visible_range() {
let mut state = PanelScrollState::with_total(100);
state.visible = 10;
state.offset = 20;
let range = state.visible_range();
assert_eq!(range, 20..30);
}
#[test]
fn test_panel_scroll_state_at_boundaries() {
let mut state = PanelScrollState::with_total(100);
state.visible = 10;
assert!(state.at_top());
assert!(!state.at_bottom());
state.offset = 90;
assert!(!state.at_top());
assert!(state.at_bottom());
}
#[test]
fn test_panel_scroll_state_set_total_clamps_cursor() {
let mut state = PanelScrollState::with_total(100);
state.cursor = 90;
state.visible = 10;
state.set_total(50);
assert_eq!(state.cursor, 49);
}
#[test]
fn test_panel_scroll_state_percentage() {
let mut state = PanelScrollState::with_total(100);
state.visible = 10;
assert!((state.percentage() - 0.0).abs() < f64::EPSILON);
state.offset = 45; assert!((state.percentage() - 0.5).abs() < f64::EPSILON);
state.offset = 90; assert!((state.percentage() - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_panel_scroll_state_scroll_down() {
let mut state = PanelScrollState::with_total(100);
state.visible = 10;
state.offset = 0;
state.scroll_down();
assert_eq!(state.offset, 1);
for _ in 0..90 {
state.scroll_down();
}
assert_eq!(state.offset, 90);
state.scroll_down();
assert_eq!(state.offset, 90);
}
#[test]
fn test_panel_scroll_state_scroll_up() {
let mut state = PanelScrollState::with_total(100);
state.visible = 10;
state.offset = 50;
state.scroll_up();
assert_eq!(state.offset, 49);
for _ in 0..49 {
state.scroll_up();
}
assert_eq!(state.offset, 0);
state.scroll_up();
assert_eq!(state.offset, 0);
}
#[test]
fn test_panel_scroll_state_scroll_to_top() {
let mut state = PanelScrollState::with_total(100);
state.visible = 10;
state.offset = 50;
state.cursor = 50;
state.scroll_to_top();
assert_eq!(state.offset, 0);
assert_eq!(state.cursor, 0);
}
#[test]
fn test_panel_scroll_state_scroll_to_bottom() {
let mut state = PanelScrollState::with_total(100);
state.visible = 10;
state.offset = 0;
state.cursor = 0;
state.scroll_to_bottom();
assert_eq!(state.offset, 90); assert_eq!(state.cursor, 99); }
#[test]
fn test_panel_scroll_state_scroll_to_bottom_less_than_viewport() {
let mut state = PanelScrollState::with_total(5); state.visible = 10;
state.offset = 0;
state.cursor = 0;
state.scroll_to_bottom();
assert_eq!(state.offset, 0); assert_eq!(state.cursor, 4); }
#[test]
fn test_panel_scroll_state_set_visible() {
let mut state = PanelScrollState::with_total(100);
state.visible = 10;
state.offset = 50;
state.cursor = 50;
state.set_visible(20);
assert_eq!(state.visible, 20);
assert!(state.cursor >= state.offset);
assert!(state.cursor < state.offset + state.visible);
}
#[test]
fn test_panel_scroll_state_set_visible_with_cursor_adjustment() {
let mut state = PanelScrollState::with_total(100);
state.visible = 5;
state.offset = 0;
state.cursor = 0;
state.set_visible(50);
assert_eq!(state.visible, 50);
assert_eq!(state.cursor, 0);
}
#[test]
fn test_panel_scroll_state_scroll_behavior_with_zero_total() {
let mut state = PanelScrollState::new();
state.visible = 10;
state.scroll_up();
assert_eq!(state.offset, 0);
state.scroll_down();
assert_eq!(state.offset, 0);
state.scroll_to_top();
assert_eq!(state.offset, 0);
assert_eq!(state.cursor, 0);
state.scroll_to_bottom();
assert_eq!(state.offset, 0);
assert_eq!(state.cursor, 0);
}
#[test]
fn test_panel_scroll_state_scroll_behavior_with_zero_visible() {
let mut state = PanelScrollState::with_total(100);
state.visible = 0;
state.offset = 50;
state.scroll_up();
assert_eq!(state.offset, 49);
state.scroll_down();
assert_eq!(state.offset, 50);
state.scroll_to_bottom();
assert!(state.cursor == 99);
}
#[test]
fn test_panel_scroll_state_percentage_with_small_content() {
let mut state = PanelScrollState::with_total(5);
state.visible = 10;
assert!((state.percentage() - 0.0).abs() < f64::EPSILON);
state.offset = 1;
assert!((state.percentage() - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_panel_scroll_state_all_methods_preserve_invariants() {
let mut state = PanelScrollState::with_total(100);
state.visible = 20;
for cursor_val in 0..100 {
state.cursor = cursor_val;
state.ensure_cursor_visible();
assert!(state.cursor < state.total || state.total == 0);
let max_offset = state.total.saturating_sub(state.visible);
assert!(state.offset <= max_offset);
}
}
#[test]
fn test_dismiss_error_clears_message() {
let mut state = TuiState::new("test.yaml");
state.workflow.error_message = Some("Test error".to_string());
let dismissed = state.dismiss_error();
assert!(dismissed);
assert!(state.workflow.error_message.is_none());
assert!(state.dirty.progress);
assert!(state.dirty.status);
}
#[test]
fn test_dismiss_error_returns_false_when_no_error() {
let mut state = TuiState::new("test.yaml");
assert!(state.workflow.error_message.is_none());
let dismissed = state.dismiss_error();
assert!(!dismissed);
}
#[test]
fn test_dismiss_error_preserves_other_workflow_state() {
let mut state = TuiState::new("test.yaml");
state.workflow.error_message = Some("Test error".to_string());
state.workflow.phase = MissionPhase::Abort;
state.workflow.task_count = 5;
state.workflow.tasks_completed = 3;
state.dismiss_error();
assert!(state.workflow.error_message.is_none());
assert_eq!(state.workflow.phase, MissionPhase::Abort); assert_eq!(state.workflow.task_count, 5);
assert_eq!(state.workflow.tasks_completed, 3);
}
}