mod commands;
mod events;
mod lifecycle;
mod render;
mod routing;
mod routing_models;
mod types;
pub use types::Action;
use std::io::{self, Stdout};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use parking_lot::Mutex;
use crossterm::{
event::{self, EnableMouseCapture, Event},
execute,
terminal::{enable_raw_mode, EnterAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, layout::Rect, Terminal};
use tokio::sync::{broadcast, mpsc};
use tokio::task::AbortHandle;
use crate::error::{NikaError, Result};
use crate::event::Event as NikaEvent;
use crate::mcp::McpClientPool;
use crate::provider::rig::StreamChunk;
use crate::tui::chat_agent::ChatAgent;
use super::config::{ThemeName, TuiConfig};
use super::cosmic_theme::CosmicTheme;
use super::mode::InputMode;
use super::standalone::StandaloneState;
use super::startup;
use super::state::TuiState;
use super::theme::Theme;
use super::verification::VerificationCache;
use super::views::{CommandView, ControlView, HomeView, StudioView, TuiView, View};
use super::widgets::NikaIntroState;
pub struct App {
pub(crate) workflow_path: std::path::PathBuf,
pub(crate) terminal: Option<Terminal<CrosstermBackend<Stdout>>>,
pub(crate) state: TuiState,
pub(crate) standalone_state: Option<StandaloneState>,
pub(crate) cosmic_theme: CosmicTheme,
pub(crate) theme: Theme,
pub(crate) event_rx: Option<mpsc::Receiver<NikaEvent>>,
pub(crate) broadcast_rx: Option<broadcast::Receiver<NikaEvent>>,
pub(crate) should_quit: bool,
pub(crate) last_ctrl_c: Option<std::time::Instant>,
pub(crate) workflow_done: bool,
pub(crate) status_message: Option<(String, std::time::Instant)>,
pub(crate) retry_requested: bool,
pub(crate) should_launch_wizard: bool,
pub(crate) current_view: TuiView,
pub(crate) input_mode: InputMode,
pub(crate) command_view: CommandView,
pub(crate) home_view: Option<HomeView>,
pub(crate) studio_view: StudioView,
pub(crate) control_view: ControlView,
pub(crate) llm_response_rx: mpsc::Receiver<String>,
pub(crate) stream_chunk_rx: mpsc::Receiver<StreamChunk>,
pub(crate) stream_chunk_tx: mpsc::Sender<StreamChunk>,
pub(crate) chat_agent: Option<ChatAgent>,
pub(crate) mcp_pool: McpClientPool,
pub(crate) background_handles: Arc<Mutex<Vec<AbortHandle>>>,
pub(crate) session_id: Option<String>,
pub(crate) event_buffer: Vec<NikaEvent>,
pub(crate) verification_cache: Arc<Mutex<VerificationCache>>,
pub(crate) intro_state: Option<NikaIntroState>,
}
impl App {
pub fn new(workflow_path: &Path) -> Result<Self> {
if !workflow_path.exists() {
return Err(NikaError::WorkflowNotFound {
path: workflow_path.display().to_string(),
});
}
let state = TuiState::new(&workflow_path.display().to_string());
let command_view = CommandView::new();
let mut studio_view = StudioView::new();
let _ = studio_view.load_file(workflow_path.to_path_buf());
let control_view = ControlView::new();
let (_llm_response_tx, llm_response_rx) = mpsc::channel(32);
let (stream_chunk_tx, stream_chunk_rx) = mpsc::channel(512);
let chat_agent = ChatAgent::new().ok();
let config = TuiConfig::load_or_default();
let theme_variant = match config.tui.theme {
ThemeName::Dark => crate::tui::tokens::CosmicVariant::CosmicDark,
ThemeName::Light => crate::tui::tokens::CosmicVariant::CosmicLight,
ThemeName::Solarized => crate::tui::tokens::CosmicVariant::CosmicViolet,
};
let cosmic_theme = CosmicTheme::new(theme_variant);
let theme = cosmic_theme.as_theme();
Ok(Self {
workflow_path: workflow_path.to_path_buf(),
terminal: None,
state,
standalone_state: None,
cosmic_theme,
theme,
event_rx: None,
broadcast_rx: None,
should_quit: false,
last_ctrl_c: None,
workflow_done: false,
status_message: None,
retry_requested: false,
should_launch_wizard: false,
current_view: TuiView::Command,
input_mode: InputMode::Normal,
command_view,
home_view: None, studio_view,
control_view,
llm_response_rx,
stream_chunk_rx,
stream_chunk_tx,
chat_agent,
mcp_pool: McpClientPool::new(crate::event::EventLog::new()),
background_handles: Arc::new(Mutex::new(Vec::new())),
session_id: None,
event_buffer: Vec::with_capacity(64), verification_cache: Arc::new(Mutex::new(VerificationCache::default())),
intro_state: None,
})
}
pub fn new_standalone(standalone_state: StandaloneState) -> Result<Self> {
let workflow_path = standalone_state.root.clone();
let state = TuiState::new("Standalone Mode");
let command_view = CommandView::new();
let home_view = HomeView::new(standalone_state.root.clone());
let studio_view = StudioView::new();
let control_view = ControlView::new();
let (_llm_response_tx, llm_response_rx) = mpsc::channel(32);
let (stream_chunk_tx, stream_chunk_rx) = mpsc::channel(512);
let chat_agent = ChatAgent::new().ok();
let config = TuiConfig::load_or_default();
let theme_variant = match config.tui.theme {
ThemeName::Dark => crate::tui::tokens::CosmicVariant::CosmicDark,
ThemeName::Light => crate::tui::tokens::CosmicVariant::CosmicLight,
ThemeName::Solarized => crate::tui::tokens::CosmicVariant::CosmicViolet,
};
let cosmic_theme = CosmicTheme::new(theme_variant);
let theme = cosmic_theme.as_theme();
Ok(Self {
workflow_path,
terminal: None,
state,
standalone_state: Some(standalone_state),
cosmic_theme,
theme,
event_rx: None,
broadcast_rx: None,
should_quit: false,
last_ctrl_c: None,
workflow_done: false,
status_message: None,
retry_requested: false,
should_launch_wizard: false,
current_view: TuiView::Studio,
input_mode: InputMode::Normal,
command_view,
home_view: Some(home_view),
studio_view,
control_view,
llm_response_rx,
stream_chunk_rx,
stream_chunk_tx,
chat_agent,
mcp_pool: McpClientPool::new(crate::event::EventLog::new()),
background_handles: Arc::new(Mutex::new(Vec::new())),
session_id: None,
event_buffer: Vec::with_capacity(64), verification_cache: Arc::new(Mutex::new(VerificationCache::default())),
intro_state: Some(NikaIntroState::new()),
})
}
fn init_terminal(&mut self) -> Result<()> {
if self.terminal.is_some() {
return Ok(());
}
enable_raw_mode().map_err(|e| NikaError::TuiError {
reason: format!("Failed to enable raw mode: {}", e),
})?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture).map_err(|e| {
NikaError::TuiError {
reason: format!("Failed to enter alternate screen: {}", e),
}
})?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend).map_err(|e| NikaError::TuiError {
reason: format!("Failed to create terminal: {}", e),
})?;
self.terminal = Some(terminal);
Ok(())
}
pub fn with_event_receiver(mut self, rx: mpsc::Receiver<NikaEvent>) -> Self {
self.event_rx = Some(rx);
self
}
pub fn with_broadcast_receiver(mut self, rx: broadcast::Receiver<NikaEvent>) -> Self {
self.broadcast_rx = Some(rx);
self
}
pub fn with_initial_view(mut self, view: TuiView) -> Self {
self.current_view = view;
if view == TuiView::Command {
self.input_mode = InputMode::Insert;
}
self
}
pub fn with_studio_file(mut self, path: std::path::PathBuf) -> Self {
let _ = self.studio_view.load_file(path);
self
}
pub fn with_chat_overrides(mut self, provider: Option<String>, model: Option<String>) -> Self {
if provider.is_some() || model.is_some() {
match ChatAgent::with_overrides(provider.as_deref(), model.as_deref()) {
Ok(agent) => {
self.chat_agent = Some(agent);
}
Err(e) => {
tracing::warn!("Failed to create ChatAgent with overrides: {}", e);
}
}
}
self
}
pub async fn run_unified(mut self) -> Result<bool> {
tracing::info!("TUI (unified) started");
let startup_report = startup::verify_startup()?;
if !startup_report.is_ok() {
for warning in startup_report.warnings() {
tracing::error!("Startup issue: {}", warning);
}
return Err(NikaError::StartupError {
phase: "verification".into(),
reason: "Startup verification failed - see logs for details".into(),
});
}
tracing::info!("{}", startup_report.summary());
for warning in startup_report.warnings() {
tracing::warn!("Startup warning: {}", warning);
}
self.init_mcp_clients();
self.spawn_provider_verification();
self.spawn_provider_verification_timeout();
self.spawn_mcp_verification();
self.init_terminal()?;
if self.intro_state.is_none() {
self.call_view_on_enter(self.current_view);
}
const FAST_TICK_MS: u64 = 16; const SLOW_TICK_MS: u64 = 100;
let mut had_recent_input = true;
loop {
self.poll_runtime_events();
let is_streaming = self.command_view.chat.is_streaming;
let has_inline_content = !self.command_view.chat.inline_content.is_empty();
let intro_active = self
.intro_state
.as_ref()
.map(|i| !i.is_done())
.unwrap_or(false);
let needs_fast_render =
is_streaming || has_inline_content || had_recent_input || intro_active;
had_recent_input = false;
self.state.tick();
match self.current_view {
TuiView::Studio => self.studio_view.tick(&mut self.state),
TuiView::Command => self.command_view.tick(&mut self.state),
TuiView::Control => self.control_view.tick(&mut self.state),
}
if let Some(ref mut home) = self.home_view {
if home.rain_fading {
home.tick();
}
}
if self.current_view != TuiView::Command && self.command_view.chat.is_streaming {
self.command_view.chat.tick();
}
if let Some(ref mut intro) = self.intro_state {
if !intro.is_done() {
let intro_area = self
.terminal
.as_ref()
.and_then(|t| t.size().ok())
.map(|s| Rect::new(0, 0, s.width, s.height))
.unwrap_or_else(|| Rect::new(0, 0, 80, 24));
intro.tick(intro_area);
}
}
if self.intro_state.as_ref().is_some_and(|i| i.is_done()) {
self.intro_state = None;
self.state.dirty.mark_all();
self.call_view_on_enter(self.current_view);
}
self.render_unified_frame()?;
let _terminal_size = if let Some(ref terminal) = self.terminal {
terminal
.size()
.ok()
.map(|size| Rect::new(0, 0, size.width, size.height))
} else {
None
};
let tick_rate = if needs_fast_render {
Duration::from_millis(FAST_TICK_MS)
} else {
Duration::from_millis(SLOW_TICK_MS)
};
if event::poll(tick_rate).map_err(|e| NikaError::TuiError {
reason: format!("Failed to poll events: {}", e),
})? {
let event = event::read().map_err(|e| NikaError::TuiError {
reason: format!("Failed to read event: {}", e),
})?;
let action = match event {
Event::Key(key) => self.handle_unified_key(key.code, key.modifiers),
Event::Mouse(_) => Action::Continue,
_ => Action::Continue,
};
self.apply_action(action);
had_recent_input = true;
}
if self.should_quit {
self.save_current_session();
break;
}
}
self.cancel_background_tasks();
let launch_wizard = self.should_launch_wizard;
self.cleanup()?;
Ok(launch_wizard)
}
pub fn current_view(&self) -> TuiView {
self.current_view
}
pub fn switch_view(&mut self, view: TuiView) {
self.current_view = view;
}
pub fn wants_retry(&self) -> bool {
self.retry_requested
}
pub fn clear_retry_request(&mut self) {
self.retry_requested = false;
}
}
impl Drop for App {
fn drop(&mut self) {
if self.terminal.is_some() {
let _ = self.cleanup();
}
}
}