mod activity;
mod events;
mod input;
mod layout;
mod perf;
mod prompt;
pub(crate) mod render;
mod screens;
mod sessions;
pub(crate) mod state;
pub(crate) mod terminal;
mod theme;
mod toast;
mod transcript;
mod transcript_cards;
pub(crate) mod usage;
mod viewports;
mod worker;
#[cfg(test)]
use self::input::submit_command::{
is_new_session_command, is_quit_command, slash_command_token, unsupported_slash_command,
};
#[cfg(test)]
use self::sessions::commands::execute_switch_session_command;
use self::{
events::delivery::send_tui_event,
input::{
autocomplete_sources::{
refresh_skill_autocomplete_candidates, tui_autocomplete_candidates,
tui_file_autocomplete_candidates, tui_skill_autocomplete_candidates,
},
mouse::{
apply_copy_request, handle_mouse_event, handle_mouse_event_with_scroll_rows,
mouse_scroll_batch,
},
submit_command::{SubmitDecision, TuiSubmitCommand, submit_decision, tui_submit_command},
},
screens::primary_agent_startup::{
record_primary_agent_startup_diagnostics, resolve_persisted_primary_agent_selection,
},
sessions::commands::{
SessionSwitchLoad, apply_footer_context, apply_session_hydration_snapshot,
execute_new_session_command, execute_prune_sessions_command, load_session_preview,
preserve_critical_tui_events, session_picker_rows, short_session_id,
start_session_switch_load,
},
worker::{
CompactionActivityFinal, WorkerFinalEvent, WorkerOutcomeState, WorkerState,
apply_worker_final_event, send_completion,
},
};
#[cfg(test)]
use crate::commands::BuiltInCommand;
use crate::{
agent::runner::{
ProviderRunOptions, append_primary_agent_to_main_prompt, run_bash_mode_once,
run_provider_once_streaming_with_steering,
},
commands::CommandRegistry,
config::EffectiveConfig,
instructions::InstructionFile,
output::{ActivityEvent, ActivitySender, OutputEvent},
sessions::{Session, SessionEventKind, SessionManager, record_session_event},
shell::ShellState,
skills::{SkillDiscovery, filter_enabled_skills},
};
use anyhow::Result;
use crossbeam_channel::{Receiver, Sender, bounded};
#[cfg(test)]
use std::fs;
use std::{
collections::VecDeque,
panic,
path::PathBuf,
process::Command,
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc,
},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
#[cfg(test)]
pub(crate) trait TranscriptDequeExt {
fn join(&self, separator: &str) -> String;
}
#[cfg(test)]
impl TranscriptDequeExt for VecDeque<String> {
fn join(&self, separator: &str) -> String {
self.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(separator)
}
}
pub(crate) struct TuiSessionConfig {
pub(crate) config: EffectiveConfig,
pub(crate) instructions: Vec<InstructionFile>,
pub(crate) discovered_skills: SkillDiscovery,
pub(crate) skills: SkillDiscovery,
pub(crate) commands: CommandRegistry,
pub(crate) manager: SessionManager,
pub(crate) active_session: Option<Session>,
pub(crate) cwd: PathBuf,
}
#[derive(Debug, Clone)]
pub(crate) enum TuiEvent {
Output(OutputEvent),
Activity(ActivityEvent),
Error(String),
ModelCatalog {
request_id: u64,
result: Result<crate::model_catalog::CatalogForUi, String>,
visible_rows: usize,
},
UsageLoaded {
request_id: u64,
result: crate::tui::usage::UsageLoadResult,
},
LoginInstructions(crate::login::LoginInstructions),
CompactionFinishedAuthoritative {
result: Result<Option<crate::compaction::CompactionResult>, String>,
activity: worker::CompactionActivityFinal,
},
LoginFinished {
provider_id: String,
result: Result<String, String>,
},
Done,
RunCanceled {
prompt: String,
},
RunFinished {
final_event: Option<Box<TuiEvent>>,
},
SessionTitleUpdated {
session_id: String,
title: String,
},
SessionPreviewLoaded {
request_id: u64,
session_id: String,
preview: state::SessionPreviewState,
},
SessionSwitchLoaded {
request_id: u64,
session_id: String,
result: Box<Result<SessionSwitchLoad, String>>,
},
FooterGitBranchLoaded {
request_id: u64,
branch: Option<String>,
},
StartupLoaded {
request_id: u64,
result: Box<Result<TuiStartupLoaded, String>>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TuiStartupLoaded {
pub(crate) settings: Option<crate::config::Settings>,
pub(crate) subagent_profile_discovery: crate::subagents::profiles::SubagentProfileDiscovery,
pub(crate) primary_agent_discovery: crate::primary_agents::PrimaryAgentProfileDiscovery,
pub(crate) persisted_primary_agent: Option<String>,
pub(crate) persisted_primary_agent_error: Option<String>,
pub(crate) active_latest_title: Option<String>,
pub(crate) file_autocomplete_candidates: Vec<state::AutocompleteCandidate>,
pub(crate) skill_autocomplete_candidates: Vec<state::AutocompleteCandidate>,
pub(crate) thinking_levels: Vec<crate::thinking::ThinkingLevel>,
pub(crate) footer_git_branch: Option<String>,
}
const CRITICAL_EVENT_TIMEOUT: Duration = Duration::from_millis(250);
const PROMPT_CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
const FOOTER_GIT_BRANCH_REFRESH_INTERVAL: Duration = Duration::from_secs(2);
pub(crate) const INLINE_SEPARATOR_CHAR: char = '•';
pub(crate) const PADDED_INLINE_SEPARATOR: &str = " • ";
const LEGACY_PADDED_INLINE_SEPARATOR: &str = " · ";
pub(crate) fn split_once_inline_separator(text: &str) -> Option<(&str, &str)> {
text.split_once(PADDED_INLINE_SEPARATOR)
.or_else(|| text.split_once(LEGACY_PADDED_INLINE_SEPARATOR))
}
pub(crate) fn rsplit_once_inline_separator(text: &str) -> Option<(&str, &str)> {
text.rsplit_once(PADDED_INLINE_SEPARATOR)
.or_else(|| text.rsplit_once(LEGACY_PADDED_INLINE_SEPARATOR))
}
pub(crate) fn normalize_inline_separators(text: &str) -> String {
text.replace(LEGACY_PADDED_INLINE_SEPARATOR, PADDED_INLINE_SEPARATOR)
}
#[cfg(test)]
const CANCEL_JOIN_GRACE_PERIOD: Duration = Duration::from_millis(50);
#[cfg(test)]
const WORKER_EXIT_JOIN_TIMEOUT: Duration = CANCEL_JOIN_GRACE_PERIOD;
#[cfg(not(test))]
const WORKER_EXIT_JOIN_TIMEOUT: Duration = Duration::from_secs(2);
static NEXT_TUI_RUN_ID: AtomicU64 = AtomicU64::new(1);
pub(crate) fn send_critical(sender: &Sender<TuiEvent>, event: TuiEvent) -> anyhow::Result<()> {
sender
.send_timeout(event, CRITICAL_EVENT_TIMEOUT)
.map_err(|error| anyhow::anyhow!("tui event channel blocked or closed: {error}"))
}
pub(crate) fn send_best_effort(sender: &Sender<TuiEvent>, event: TuiEvent) {
const BEST_EFFORT_QUEUE_RESERVE: usize = 64;
if sender.capacity().is_some_and(|capacity| {
capacity > BEST_EFFORT_QUEUE_RESERVE
&& sender.len() >= capacity.saturating_sub(BEST_EFFORT_QUEUE_RESERVE)
}) {
return;
}
match sender.try_send(event) {
Ok(()) | Err(crossbeam_channel::TrySendError::Full(_)) => {}
Err(crossbeam_channel::TrySendError::Disconnected(_)) => {}
}
}
pub(crate) fn run_tui(config: TuiSessionConfig) -> Result<()> {
crate::rendering::highlight::prewarm();
install_terminal_restore_panic_hook();
let (sender, receiver) = bounded::<TuiEvent>(1024);
let mut app = MissionControlApp::new(config, sender);
app.run(receiver)
}
fn install_terminal_restore_panic_hook() {
let previous_hook = panic::take_hook();
panic::set_hook(Box::new(move |panic_info| {
terminal::restore_terminal_best_effort();
previous_hook(panic_info);
}));
}
mod controller;
pub(crate) use controller::TerminalInputBridge;
use controller::*;
#[cfg(test)]
#[path = "tests.rs"]
mod tests;