pub mod agent_actor;
pub mod app;
pub mod editor;
pub mod ext_settings;
pub mod giff;
pub mod handlers;
pub mod line_input;
pub mod oauth_flow;
pub mod overlay;
pub mod panel;
pub mod program_status;
pub mod render;
pub mod session_selector;
pub mod settings_selector;
pub mod skills_panel;
pub mod system_theme;
pub mod theme;
pub mod worker;
use self::{
agent_actor::WorkerHandle,
app::{App, MsgLevel, NextAction, ReloadCtx, SysSpan, WorkingKind},
handlers::{
KeyAction, handle_event, take_staged_session_repair, take_staged_zombie_operations,
},
render::{
messages::{CachedMsg, ImageLayout},
render_frame, startup,
},
theme::{DEFAULT_THEME_NAME, Theme},
};
use crate::{
APP_NAME,
core::{
self,
agent_session::Agent,
auth, crash_log, model_refresh, project_trust,
prompt_templates::{self, PromptTemplate},
provider::AgentMessage,
settings_manager, tools_manager,
},
error::{Error, Result},
modes::interactive::{
self,
panel::{PanelItem, PanelKind},
},
utils::{
terminal_caps::{in_tmux, tmux_show_global_value},
terminal_colors::prime_terminal_colors,
terminal_image,
time::now_ms,
tokens,
wheel::WheelScrollAccelerator,
},
};
use crossterm::{
event::EventStream,
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
use futures_util::StreamExt;
use program_status::ProgramStatusReporter;
use ratatui::{Terminal, backend::CrosstermBackend};
use std::{
cell::RefCell,
collections::HashMap,
io::{self, Write as _},
path::{Path, PathBuf},
rc::Rc,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
pub type UiTask = Box<dyn FnOnce(&mut App) + Send + 'static>;
static UI_TASK_TX: Mutex<Option<UnboundedSender<UiTask>>> = Mutex::new(None);
static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false);
#[cfg(unix)]
extern "C" fn request_shutdown(_signal: nix::libc::c_int) {
SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst);
}
#[cfg(unix)]
fn install_shutdown_signal_handlers() {
use nix::sys::signal::{SigHandler, Signal, signal};
unsafe {
_ = signal(Signal::SIGTERM, SigHandler::Handler(request_shutdown));
_ = signal(Signal::SIGHUP, SigHandler::Handler(request_shutdown));
}
}
#[cfg(not(unix))]
fn install_shutdown_signal_handlers() {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventLoopError {
Closed,
}
impl std::fmt::Display for EventLoopError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EventLoopError::Closed => write!(f, "TUI event loop closed"),
}
}
}
impl std::error::Error for EventLoopError {}
pub fn set_event_loop_tx(tx: UnboundedSender<UiTask>) {
*UI_TASK_TX.lock().unwrap() = Some(tx);
}
pub fn clear_event_loop_tx() {
*UI_TASK_TX.lock().unwrap() = None;
}
pub fn run_in_event_loop(
func: impl FnOnce(&mut App) + Send + 'static,
) -> std::result::Result<(), EventLoopError> {
let Some(tx) = UI_TASK_TX.lock().unwrap().clone() else {
return Err(EventLoopError::Closed);
};
tx.send(Box::new(func)).map_err(|_| EventLoopError::Closed)
}
struct HighlightDone {
snap_id: u64,
msg_len: usize,
width: usize,
expand_all: bool,
show_thinking: bool,
images: ImageLayout,
results: HashMap<(u64, usize), CachedMsg>,
}
#[allow(clippy::too_many_arguments)]
pub async fn run_interactive(
agent: Agent,
initial_messages: Vec<AgentMessage>,
prompt_templates: Vec<PromptTemplate>,
theme_override: Option<&str>,
no_themes: bool,
reload_ctx: ReloadCtx,
verbose: bool,
) -> Result<()> {
prime_terminal_colors();
terminal_image::prime_image_picker(settings_manager::read_settings_show_images());
let program_status_supported = program_status::detect_program_status_support();
let mut terminal = match setup_terminal() {
Ok(t) => t,
Err(e) if is_dead_terminal_error(&e) => return Ok(()),
Err(e) => return Err(e),
};
install_shutdown_signal_handlers();
let (ui_task_tx, mut ui_task_rx) = unbounded_channel::<UiTask>();
set_event_loop_tx(ui_task_tx);
let model_id = agent.model.model_id.clone();
std::thread::spawn(move || tokens::warmup(Some(&model_id)));
let shared = init_app(
&agent,
initial_messages,
prompt_templates,
theme_override,
no_themes,
reload_ctx,
verbose,
);
{
let mut st = shared.borrow_mut();
st.program_status = ProgramStatusReporter::new(program_status_supported);
st.program_status
.set_session_name(agent.session.as_ref().and_then(|s| s.name.clone()));
}
let (worker_cmd_tx, worker_cmd_rx) = agent_actor::channels();
let worker = WorkerHandle::new(worker_cmd_tx);
shared.borrow_mut().worker = worker.clone();
let worker_task = tokio::spawn(worker::run_worker(
Arc::new(tokio::sync::Mutex::new(agent)),
worker_cmd_rx,
));
let mut event_stream = EventStream::new();
spawn_tmux_keyboard_check();
loop {
update_terminal_title(&shared);
report_program_status(&shared);
match render_tick(&shared, &mut terminal) {
Ok(()) => {}
Err(e) if is_dead_terminal_error(&e) => break,
Err(e) => return Err(e),
}
maybe_start_backlog_highlight(&shared);
tokio::select! {
task = ui_task_rx.recv() => {
if let Some(task) = task {
let mut st = shared.borrow_mut();
task(&mut st);
st.dirty = true;
}
}
ev = event_stream.next() => {
match ev {
Some(Ok(ev)) => {
if process_key_action(&shared, handle_event(&shared, ev)) {
break;
}
}
Some(Err(e)) if is_dead_terminal_io(&e) => break,
_ => continue,
}
}
_ = tokio::time::sleep(Duration::from_millis(80)) => {
if SHUTDOWN_REQUESTED.swap(false, Ordering::SeqCst) {
break;
}
let mut st = shared.borrow_mut();
if st.expire_status() || st.auto_scroll_tick() || overlay::auto_scroll_tick(&mut st)
{
st.dirty = true;
}
if core::extensions::wants_redraw() {
st.dirty = true;
}
if st.session_selector.is_loading()
&& st
.session_selector
.drain_pending(session_selector::DRAIN_BATCH)
{
st.dirty = true;
}
st.retry_ui_tick();
}
}
poll_oauth(&shared).await;
maybe_handle_extension_ui(&shared);
maybe_handle_session_repair(&shared);
maybe_handle_zombie_operations(&shared);
maybe_handle_settings_overwrite(&shared);
let action = shared.borrow_mut().take_next_action();
start_next_action(&shared, action);
}
clear_event_loop_tx();
core::extensions::cancel_background_work();
shared.borrow_mut().delete_current_session_if_empty();
worker.send(agent_actor::AgentCommand::Shutdown);
_ = tokio::time::timeout(Duration::from_secs(3), worker_task).await;
if let Some(sequence) = shared.borrow().program_status.exit_sequence() {
_ = io::stdout().write_all(sequence.as_bytes());
_ = io::stdout().flush();
}
teardown_terminal(&mut terminal);
Ok(())
}
fn report_program_status(shared: &Rc<RefCell<App>>) {
let sequence = {
let mut st = shared.borrow_mut();
let blocked = st.program_status_blocked();
st.program_status.set_blocked(blocked);
st.program_status.report();
st.program_status.take_pending()
};
let Some(sequence) = sequence else {
return;
};
let mut stdout = io::stdout();
_ = stdout.write_all(sequence.as_bytes());
_ = stdout.flush();
}
fn process_key_action(shared: &Rc<RefCell<App>>, action: KeyAction) -> bool {
match action {
KeyAction::Quit => true,
KeyAction::CancelPrompt => {
shared.borrow_mut().worker.abort();
core::extensions::cancel_background_work();
handle_cancel_prompt(shared);
false
}
KeyAction::Continue => false,
}
}
fn drain_next_batch(st: &mut App) -> Option<Vec<AgentMessage>> {
let steer_mode = settings_manager::read_settings_steering_mode();
let follow_up_mode = settings_manager::read_settings_follow_up_mode();
let steer_batch: Vec<AgentMessage> = {
let mut inbox = st.runtime_steer_inbox.lock().unwrap();
if inbox.is_empty() {
Vec::new()
} else if steer_mode == "all" {
inbox.drain(..).collect()
} else {
inbox.pop_front().into_iter().collect()
}
};
if !steer_batch.is_empty() {
return Some(steer_batch);
}
let mut fups = Vec::new();
while let Some(fup) = st.runtime_followup_inbox.pop_front() {
fups.push(fup);
if follow_up_mode != "all" {
break;
}
}
if !fups.is_empty() {
return Some(
fups.into_iter()
.map(|t| AgentMessage::user_text(&t))
.collect(),
);
}
None
}
fn update_terminal_title(shared: &Rc<RefCell<App>>) {
let basename = {
let st = shared.borrow();
Path::new(&st.cwd)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default()
};
_ = write!(
std::io::stdout(),
"\x1b]0;{APP_NAME} - {basename}\x07\x1b]2;{APP_NAME} - {basename}\x07"
);
_ = std::io::stdout().flush();
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
let mut stdout = io::stdout();
terminal::enable_raw_mode().map_err(|e| Error::Io {
context: "failed to enable raw mode".to_string(),
source: e,
})?;
crossterm::execute!(stdout, EnterAlternateScreen).map_err(|e| Error::Io {
context: "failed to initialize terminal".to_string(),
source: e,
})?;
_ = crossterm::execute!(stdout, crossterm::event::EnableBracketedPaste);
if in_tmux() {
write!(
stdout,
"\x1b[>4;2m\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h"
)
.and_then(|_| stdout.flush())
.map_err(|e| Error::Io {
context: "failed to setup tmux terminal".to_string(),
source: e,
})?;
} else {
crossterm::execute!(stdout, crossterm::event::EnableMouseCapture).map_err(|e| {
Error::Io {
context: "failed to initialize terminal".to_string(),
source: e,
}
})?;
_ = crossterm::execute!(
stdout,
crossterm::event::PushKeyboardEnhancementFlags(
crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| crossterm::event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
)
);
}
let backend = CrosstermBackend::new(stdout);
Terminal::new(backend).map_err(|e| Error::Io {
context: "failed to initialize terminal backend".to_string(),
source: e,
})
}
fn is_dead_terminal_error(err: &Error) -> bool {
let mut cur: Option<&(dyn std::error::Error + 'static)> = Some(err);
while let Some(e) = cur {
if let Some(io_err) = e.downcast_ref::<io::Error>()
&& is_dead_terminal_io(io_err)
{
return true;
}
cur = e.source();
}
false
}
fn is_dead_terminal_io(io_err: &io::Error) -> bool {
if matches!(
io_err.kind(),
io::ErrorKind::BrokenPipe | io::ErrorKind::NotConnected | io::ErrorKind::UnexpectedEof
) {
return true;
}
#[cfg(unix)]
if let Some(code) = io_err.raw_os_error()
&& matches!(
code,
nix::libc::EIO | nix::libc::EPIPE | nix::libc::ENOTCONN | nix::libc::ENOTTY
)
{
return true;
}
false
}
fn init_app(
agent: &Agent,
initial_messages: Vec<AgentMessage>,
prompt_templates: Vec<PromptTemplate>,
theme_override: Option<&str>,
no_themes: bool,
reload_ctx: ReloadCtx,
verbose: bool,
) -> Rc<RefCell<App>> {
#[allow(clippy::arc_with_non_send_sync)]
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
let agent_cwd = agent.cwd.clone();
st.theme = Theme::load(theme_override.unwrap_or(DEFAULT_THEME_NAME));
st.messages = agent.messages.clone();
st.runtime_steer_inbox = agent.runtime_steer_inbox.clone();
st.current_model = Some(agent.model.model_id.clone());
st.current_provider = Some(agent.model.provider.clone());
let settings = settings_manager::read_settings();
st.default_provider = settings.default_provider;
st.default_model = settings.default_model;
st.default_thinking_level = settings.default_thinking_level;
let quiet_startup = settings_manager::read_settings_quiet_startup();
st.banner_hidden = !(verbose || quiet_startup.shows_banner());
if verbose || quiet_startup.shows_details() {
let facts = startup::collect(agent, &prompt_templates);
st.push_msg_rich(startup::render(&facts), MsgLevel::Info);
}
if agent.model_is_fallback {
let fallback_available =
auth::list_configured_providers().contains(&agent.model.provider);
st.no_model_available = !fallback_available;
if fallback_available {
st.push_msg(
format!(
"No model configured — using {} ({}); open /model to choose",
agent.model.model_id, agent.model.provider
),
MsgLevel::Warning,
);
} else {
st.push_msg(
"No model configured; open /model to choose".to_string(),
MsgLevel::Warning,
);
}
}
st.model_reasoning = agent.model.reasoning;
st.thinking_level = agent.thinking_level.clone();
st.thinking_levels = agent
.get_available_thinking_levels()
.iter()
.map(|s| s.to_string())
.collect();
st.context_window = agent.model.context_window;
st.current_session_path = agent
.session
.as_ref()
.and_then(|s| s.get_session_file())
.map(|p| p.to_string_lossy().to_string());
st.theme.latex = settings_manager::read_settings_latex();
st.theme.mermaid = settings_manager::read_settings_mermaid();
st.theme.syntax_highlight = settings_manager::read_settings_syntax_highlight();
if !st.messages.is_empty() && st.theme.syntax_highlight {
st.last_has_scrollbar = true;
st.highlight_backlog = true;
st.theme.syntax_highlight = false;
}
st.prompt_templates = prompt_templates;
for w in prompt_templates::take_load_warnings() {
st.push_msg(w, MsgLevel::Warning);
}
if let Some(crash) = crash_log::take_unnotified_crash() {
st.push_msg(
format!(
"{} crashed on {} ({}). Run /bug to report it; the crash details are attached automatically.",
APP_NAME, crash.timestamp, crash.message
),
MsgLevel::Warning,
);
}
st.skills = agent.skills.clone();
st.model_cycle = agent.model_cycle.clone();
st.reload_ctx = reload_ctx;
st.initial_queue = initial_messages.into_iter().collect();
st.no_themes = no_themes;
st.cwd = agent_cwd.clone();
st.refresh_context_percent();
st.set_show_thinking(!settings_manager::read_settings_hide_thinking());
st.show_images = settings_manager::read_settings_show_images();
st.cache_images_fp = (st.show_images, terminal_image::image_font_size());
st.autocomplete_max_visible = settings_manager::read_settings_autocomplete_max_visible();
st.wheel_accel = WheelScrollAccelerator::for_terminal(
settings_manager::read_settings_fullscreen_wheel_scroll_lines(),
);
st.history_max_entries = settings_manager::read_settings_history_max_entries();
st.editor.history = core::prompt_history::load(&st.cwd, st.history_max_entries)
.into_iter()
.map(|e| e.text)
.collect();
if auth::list_configured_providers().is_empty() {
let note = auth::federation_note("anthropic")
.map(|n| format!(" {n}"))
.unwrap_or_default();
st.push_msg(
format!("No API keys configured. Run /login to store a provider API key.{note}"),
MsgLevel::Warning,
);
} else if let Some(w) = startup_model_warning(
&agent.model.provider,
&agent.model.model_id,
agent.api_key_override.is_some(),
) {
st.push_msg(w, MsgLevel::Warning);
}
if let Some(e) = settings_manager::settings_load_error() {
st.push_msg(e, MsgLevel::Warning);
}
for hint in tools_manager::missing_tool_hints(&agent.get_active_tools()) {
st.push_msg(hint, MsgLevel::Warning);
}
for warning in core::extensions::registration_issues() {
st.push_msg(warning, MsgLevel::Warning);
}
model_refresh::cleanup_stale_entries();
maybe_ask_project_trust(&mut st, &agent_cwd);
}
shared
}
fn startup_model_warning(
provider: &str,
model_id: &str,
has_api_key_override: bool,
) -> Option<String> {
if auth::list_configured_providers().is_empty() || has_api_key_override {
return None;
}
if model_in_panel_list(provider, model_id) {
return None;
}
Some(format!(
"Model {}/{} is not available in the /model panel; run /login to add credentials or use /model to switch",
provider, model_id
))
}
fn model_in_panel_list(provider: &str, model_id: &str) -> bool {
if !auth::list_configured_providers()
.iter()
.any(|p| p == provider)
{
return false;
}
core::model_resolver::list_models(provider)
.iter()
.any(|(id, _)| id == model_id)
}
fn maybe_ask_project_trust(st: &mut App, agent_cwd: &str) {
let cwd_path = PathBuf::from(&agent_cwd);
let agent_dir = settings_manager::agent_dir();
let requires_trust = project_trust::has_trust_requiring_project_resources(&cwd_path);
let prompt = st.reload_ctx.trust_override.is_none()
&& requires_trust
&& project_trust::trust_decision(&cwd_path, &agent_dir).is_none()
&& project_trust::default_project_trust(&agent_dir) == project_trust::ProjectTrust::Ask;
if prompt {
st.open_project_trust_panel();
return;
}
st.warn_if_project_untrusted();
}
fn spawn_tmux_keyboard_check() {
if !in_tmux() {
return;
}
std::thread::spawn(move || {
let Some(extended_keys) = tmux_show_global_value("extended-keys") else {
return;
};
let warning = if extended_keys != "on" && extended_keys != "always" {
Some(format!(
"tmux extended-keys is {}; modified keys (Ctrl+Shift+..., Shift+Enter) may \
not be distinguishable. Add `set -g extended-keys on` to ~/.tmux.conf and \
restart tmux.",
extended_keys
))
} else if let Some(format) = tmux_show_global_value("extended-keys-format") {
if format == "xterm" {
Some(format!(
"tmux extended-keys-format is xterm; {APP_NAME} works best with csi-u. \
Add `set -g extended-keys-format csi-u` to ~/.tmux.conf and restart tmux."
))
} else {
None
}
} else {
None
};
if let Some(w) = warning {
_ = run_in_event_loop(move |ui| ui.push_msg(w, MsgLevel::Warning));
}
});
}
fn maybe_start_backlog_highlight(shared: &Rc<RefCell<App>>) {
let hl_snapshot = {
let mut st = shared.borrow_mut();
if st.highlight_backlog && !st.highlight_started {
st.highlight_started = true;
st.highlight_snap_id += 1;
Some((st.highlight_snap_id, st.build_highlight_snapshot()))
} else {
None
}
};
if let Some((snap_id, snap)) = hl_snapshot {
let done = HighlightDone {
msg_len: snap.msgs.len(),
width: snap.width,
expand_all: snap.expand_all,
show_thinking: snap.show_thinking,
images: snap.images,
snap_id,
results: HashMap::new(),
};
std::thread::spawn(move || {
let results = interactive::render::messages::render_full_history(&snap);
_ = run_in_event_loop(move |ui| {
on_highlight_done(
ui,
HighlightDone {
msg_len: done.msg_len,
width: done.width,
expand_all: done.expand_all,
show_thinking: done.show_thinking,
images: done.images,
snap_id,
results,
},
);
});
});
}
}
fn render_tick(
shared: &Rc<RefCell<App>>,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
let mut st = shared.borrow_mut();
st.theme.publish_view();
if st.full_redraw {
terminal.clear().map_err(|e| Error::Io {
context: "failed to clear terminal".to_string(),
source: e,
})?;
st.full_redraw = false;
st.dirty = true;
}
if st.should_repaint() {
terminal
.draw(|f| render_frame(f, &mut st))
.map_err(|e| Error::Io {
context: "failed to render".to_string(),
source: e,
})?;
st.dirty = false;
}
Ok(())
}
fn on_highlight_done(st: &mut App, done: HighlightDone) {
if done.snap_id != st.highlight_snap_id {
return;
}
let ok = st.messages.len() == done.msg_len
&& st.cache_width == done.width
&& st.cache_expand_all == done.expand_all
&& st.cache_show_thinking == done.show_thinking
&& st.image_layout().unwrap_or_else(ImageLayout::disabled) == done.images;
if ok {
st.theme.syntax_highlight = true;
st.msg_cache.extend(done.results);
st.highlight_backlog = false;
st.dirty = true;
} else {
st.highlight_started = false;
}
}
fn handle_cancel_prompt(shared: &Rc<RefCell<App>>) {
let mut st = shared.borrow_mut();
apply_hard_abort_state(&mut st);
let steers: Vec<String> = st
.runtime_steer_inbox
.lock()
.unwrap()
.drain(..)
.map(|m| m.text())
.collect();
let follow_ups: Vec<String> = st.runtime_followup_inbox.drain(..).collect();
if !steers.is_empty() || !follow_ups.is_empty() {
let mut texts = steers;
texts.extend(follow_ups);
let queued = texts.join("\n\n");
let current = st.editor.text();
let combined = if current.trim().is_empty() {
queued
} else {
format!("{}\n\n{}", queued, current)
};
st.editor.clear();
st.editor.insert_text(&combined);
}
}
fn apply_hard_abort_state(st: &mut App) {
core::extensions::dispatch_agent_event(&serde_json::json!({
"type": "agent_end",
"reason": "aborted",
}));
st.clear_streaming();
st.tool_started_at.clear();
st.last_elapsed_rebuild = None;
let mut aborted = AgentMessage::user_text("");
aborted.role = "assistant".to_string();
aborted.stop_reason = Some("aborted".to_string());
aborted.error_message = Some("Operation aborted".to_string());
aborted.timestamp = now_ms();
st.messages.push(aborted);
st.stream_anchor_index = None;
st.busy = false;
st.cancel_retry_ui();
st.clear_status();
st.dirty = true;
}
#[allow(clippy::await_holding_refcell_ref)]
async fn poll_oauth(shared: &Rc<RefCell<App>>) {
let oauth_done = oauth_flow::poll(&mut shared.borrow_mut()).await;
if oauth_done {
shared.borrow_mut().dirty = true;
}
}
fn maybe_handle_extension_ui(shared: &Rc<RefCell<App>>) {
let Some(req) = core::extensions::take_pending_ui() else {
return;
};
let mut st = shared.borrow_mut();
match req {
core::extensions::ExtensionUiRequest::Select { id, title, options } => {
let items: Vec<PanelItem> = options
.into_iter()
.map(|o| match o.fg {
Some(fg) => PanelItem::styled(o.text.clone(), o.text, fg),
None => PanelItem::new(o.text.clone(), o.text),
})
.collect();
st.panel.close();
st.panel.open(PanelKind::Custom(id), title, items);
}
core::extensions::ExtensionUiRequest::Notify { text, level } => {
let level = match level {
core::extensions::UiNotifyLevel::Info => MsgLevel::Info,
core::extensions::UiNotifyLevel::Success => MsgLevel::Success,
core::extensions::UiNotifyLevel::Warning => MsgLevel::Warning,
core::extensions::UiNotifyLevel::Error => MsgLevel::Error,
};
st.push_msg(text, level);
}
core::extensions::ExtensionUiRequest::NotifyRich { spans, level } => {
let level = match level {
core::extensions::UiNotifyLevel::Info => MsgLevel::Info,
core::extensions::UiNotifyLevel::Success => MsgLevel::Success,
core::extensions::UiNotifyLevel::Warning => MsgLevel::Warning,
core::extensions::UiNotifyLevel::Error => MsgLevel::Error,
};
let sys_spans = spans
.into_iter()
.map(|s| {
let fg = s.fg;
SysSpan {
fg,
bold: false,
text: s.text,
}
})
.collect();
st.push_msg_rich(sys_spans, level);
}
core::extensions::ExtensionUiRequest::ShowDock => {
st.dock_visible = true;
st.dock_offset = 0;
st.dirty = true;
}
core::extensions::ExtensionUiRequest::CustomMessage {
label,
custom_type,
data,
} => {
let mut spans = vec![SysSpan {
fg: Some("accent".to_string()),
bold: true,
text: format!("[{label}]"),
}];
match core::extensions::render_custom_message(&custom_type, &data) {
Some(rendered) => spans.extend(rendered.into_iter().map(|s| SysSpan {
fg: s.fg,
bold: false,
text: s.text,
})),
None => spans.push(SysSpan::plain(format!(" {data}").as_str())),
}
st.push_msg_rich(spans, MsgLevel::Info);
}
core::extensions::ExtensionUiRequest::ShowOverlay { id } => {
st.panel.close();
if let Some(view) = core::extensions::overlay_view(id, 80) {
overlay::open(&mut st, id, view);
}
}
core::extensions::ExtensionUiRequest::HideOverlay { id } => {
if st.overlay.as_ref().is_some_and(|o| o.id == id) {
overlay::close(&mut st);
}
}
core::extensions::ExtensionUiRequest::ShowSettings { ext } => {
st.ext_settings.open(ext);
st.dirty = true;
}
core::extensions::ExtensionUiRequest::Continuation { message } => {
if !st.busy {
st.initial_queue.push_back(*message);
}
}
core::extensions::ExtensionUiRequest::AbortRun => {
st.worker.abort();
apply_hard_abort_state(&mut st);
}
core::extensions::ExtensionUiRequest::RewindLastUserInput { restore_to_editor } => {
st.pending_rewind_restore = restore_to_editor;
st.worker.send(agent_actor::AgentCommand::Rewind);
}
core::extensions::ExtensionUiRequest::RestartRun { message } => {
st.worker.abort();
apply_hard_abort_state(&mut st);
let mut batch: Vec<AgentMessage> = vec![*message];
{
let mut inbox = st
.runtime_steer_inbox
.lock()
.unwrap_or_else(|e| e.into_inner());
batch.extend(inbox.drain(..));
}
st.busy = true;
st.clear_streaming();
st.status = "Working...".to_string();
st.working_kind = WorkingKind::Working;
st.worker.prompt_batch(batch);
}
core::extensions::ExtensionUiRequest::RebuildTools => {
st.worker.send(agent_actor::AgentCommand::RebuildTools);
}
core::extensions::ExtensionUiRequest::PersistSessionEntry { custom_type, data } => {
st.worker.append_custom_entry(&custom_type, Some(data));
}
}
st.dirty = true;
}
fn maybe_handle_session_repair(shared: &Rc<RefCell<App>>) {
let Some(req) = take_staged_session_repair() else {
return;
};
shared.borrow_mut().stage_session_repair(req);
}
fn maybe_handle_zombie_operations(shared: &Rc<RefCell<App>>) {
let Some(req) = take_staged_zombie_operations() else {
return;
};
shared.borrow_mut().stage_zombie_operations(req);
}
fn maybe_handle_settings_overwrite(shared: &Rc<RefCell<App>>) {
let mut st = shared.borrow_mut();
if st.pending_settings_overwrite.is_some()
|| st.panel.active
|| st.settings_selector.active
|| st.session_selector.active
{
return;
}
let Some((path, detail)) = settings_manager::pending_settings_overwrite() else {
return;
};
st.stage_settings_overwrite(path, detail);
}
fn start_next_action(shared: &Rc<RefCell<App>>, action: NextAction) {
match action {
NextAction::Compact(instructions) => {
let mut st = shared.borrow_mut();
st.busy = true;
st.status = "Compacting context... (Esc to cancel)".to_string();
st.working_kind = WorkingKind::Compaction;
st.dirty = true;
drop(st);
shared
.borrow_mut()
.worker
.send(agent_actor::AgentCommand::Compact { instructions });
}
NextAction::Navigate(target_id, summarize) => {
let mut st = shared.borrow_mut();
st.busy = true;
st.status = if summarize {
"generating branch summary and navigating...".to_string()
} else {
"navigating...".to_string()
};
st.working_kind = if summarize {
WorkingKind::BranchSummary
} else {
WorkingKind::Working
};
st.dirty = true;
let w = st.worker.clone();
drop(st);
w.send(agent_actor::AgentCommand::Navigate {
target_id,
summarize,
});
}
NextAction::Prompt(text) => {
let mut st = shared.borrow_mut();
st.busy = true;
st.clear_streaming();
st.status = "Working...".to_string();
st.working_kind = WorkingKind::Working;
st.dirty = true;
let w = st.worker.clone();
drop(st);
w.prompt(text);
}
NextAction::PromptMessage(msg) => {
let mut st = shared.borrow_mut();
st.messages.push((*msg).clone());
st.busy = true;
st.clear_streaming();
st.status = "Working...".to_string();
st.working_kind = WorkingKind::Working;
st.dirty = true;
let w = st.worker.clone();
drop(st);
w.prompt_message(*msg);
}
NextAction::RefreshModels(provider) => {
tokio::spawn(async move {
let result = model_refresh::refresh_provider_models(&provider, true).await;
_ = run_in_event_loop(move |ui| {
ui.on_catalog_refresh(provider, result.map_err(|e| e.to_string()))
});
});
}
NextAction::None => {}
}
}
fn teardown_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) {
crossterm::terminal::disable_raw_mode().ok();
crossterm::execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
crossterm::event::DisableBracketedPaste,
)
.ok();
_ = crossterm::execute!(
terminal.backend_mut(),
crossterm::event::PopKeyboardEnhancementFlags
);
if in_tmux() {
_ = write!(
terminal.backend_mut(),
"\x1b[>4;0m\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l"
);
_ = terminal.backend_mut().flush();
} else {
crossterm::execute!(
terminal.backend_mut(),
crossterm::event::DisableMouseCapture
)
.ok();
}
terminal.show_cursor().ok();
}
#[cfg(test)]
mod tests {
#![allow(clippy::arc_with_non_send_sync)]
use super::*;
use crate::modes::interactive::app::App;
fn test_agent_dir() -> crate::test_support::AgentDirGuard {
crate::test_support::AgentDirGuard::temp()
}
#[test]
fn dead_terminal_errors_are_recognized() {
assert!(!is_dead_terminal_error(&Error::msg("EIO")));
let wrapped = Error::Io {
context: "failed to render".to_string(),
source: std::io::Error::new(std::io::ErrorKind::BrokenPipe, "broken"),
};
assert!(is_dead_terminal_error(&wrapped));
let flattened = Error::msg(format!("failed to render: {wrapped}"));
assert!(!is_dead_terminal_error(&flattened));
#[cfg(unix)]
{
for code in [
nix::libc::EIO,
nix::libc::EPIPE,
nix::libc::ENOTCONN,
nix::libc::ENOTTY,
] {
let err = Error::Io {
context: "failed to render".to_string(),
source: std::io::Error::from_raw_os_error(code),
};
assert!(
is_dead_terminal_error(&err),
"os error {code} 应判为终端已消失"
);
}
let eacces = Error::Io {
context: "failed to render".to_string(),
source: std::io::Error::from_raw_os_error(nix::libc::EACCES),
};
assert!(!is_dead_terminal_error(&eacces));
}
}
#[test]
fn startup_model_warning_matches_model_panel_list() {
let _g = test_agent_dir();
let _ad = crate::test_support::AgentDirGuard::temp();
let _ = crate::core::auth::remove_auth("anthropic").ok();
let _ = crate::core::auth::remove_auth("deepseek").ok();
assert!(startup_model_warning("deepseek", "deepseek-flash", false).is_none());
crate::core::auth::write_auth_key("anthropic", "sk-ant").unwrap();
let w = startup_model_warning("deepseek", "deepseek-flash", false);
assert!(w.is_some());
let txt = w.unwrap();
assert!(txt.contains("/login") && txt.contains("/model"));
assert!(startup_model_warning("deepseek", "deepseek-flash", true).is_none());
assert!(startup_model_warning("anthropic", "claude-opus-4-8", false).is_none());
assert!(startup_model_warning("anthropic", "no-such-model", false).is_some());
let _ = crate::core::auth::remove_auth("anthropic").ok();
let _ = crate::core::auth::remove_auth("deepseek").ok();
}
#[test]
fn project_workflows_only_cwd_opens_startup_trust_panel() {
let _ad = test_agent_dir();
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().join("proj");
std::fs::create_dir_all(cwd.join(".prux").join("workflows")).unwrap();
std::fs::write(
cwd.join(".prux").join("workflows").join("test-simple.js"),
"export const meta = { name: 'x', description: 'y' };",
)
.unwrap();
let mut st = App::new();
let cwd_s = cwd.to_string_lossy().to_string();
st.cwd = cwd_s.clone();
maybe_ask_project_trust(&mut st, &cwd_s);
assert!(st.ask_trust, "信任门控应置位(首个消息在决定前不派发)");
assert_eq!(
st.panel.top().map(|l| l.kind),
Some(panel::PanelKind::ProjectTrust),
"应打开启动信任面板"
);
}
#[test]
fn command_reply_model_changed_updates_app_mirror() {
let mut st = App::new();
st.on_model_changed(Ok(crate::modes::interactive::app::ModelChanged {
provider: "deepseek".into(),
model: "v4-pro".into(),
name: "DeepSeek V4 Pro".into(),
reasoning: true,
thinking_level: Some("high".into()),
thinking_levels: vec!["off".into(), "high".into()],
persisted: false,
}));
assert_eq!(st.current_model.as_deref(), Some("v4-pro"));
assert_eq!(st.current_provider.as_deref(), Some("deepseek"));
assert!(st.model_reasoning);
assert_eq!(st.thinking_level.as_deref(), Some("high"));
assert_eq!(
st.thinking_levels,
vec!["off".to_string(), "high".to_string()]
);
let txt = crate::modes::interactive::app::sys_spans_text(&st.system_messages[0].1.spans);
assert_eq!(txt, "Switched to DeepSeek V4 Pro (deepseek)");
}
#[test]
fn show_dock_request_opens_dock_panel() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
while crate::core::extensions::take_pending_ui().is_some() {}
let st = std::rc::Rc::new(std::cell::RefCell::new(App::new()));
st.borrow_mut().dirty = false;
assert!(!st.borrow().dock_visible);
crate::core::extensions::request_ui(crate::core::extensions::ExtensionUiRequest::ShowDock);
maybe_handle_extension_ui(&st);
assert!(st.borrow().dock_visible, "ShowDock 应打开面板");
assert_eq!(st.borrow().dock_offset, 0, "无锚点从顶部开始");
assert!(
st.borrow().dirty,
"ShowDock 应置 dirty:空闲时(无流式事件)也要立即重绘面板"
);
}
#[test]
fn restart_run_drains_steer_but_keeps_follow_up_queued() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
while crate::core::extensions::take_pending_ui().is_some() {}
let st = std::rc::Rc::new(std::cell::RefCell::new(App::new()));
{
let mut s = st.borrow_mut();
s.busy = true;
s.runtime_steer_inbox
.lock()
.unwrap()
.push_back(AgentMessage::user_text("steer me"));
s.runtime_followup_inbox.push_back("follow me".to_string());
}
crate::core::extensions::request_ui(
crate::core::extensions::ExtensionUiRequest::RestartRun {
message: Box::new(AgentMessage::user_text("nudge")),
},
);
maybe_handle_extension_ui(&st);
let s = st.borrow();
assert!(s.busy, "重开后应重新进入忙碌态");
assert!(
s.runtime_steer_inbox.lock().unwrap().is_empty(),
"steer 应随新一轮一并发送"
);
assert_eq!(
s.runtime_followup_inbox,
std::collections::VecDeque::from(vec!["follow me".to_string()]),
"follow-up 应留在队列里,等新一轮 settle 后自然发送"
);
assert!(
s.messages
.iter()
.any(|m| m.stop_reason.as_deref() == Some("aborted")),
"被丢弃的回合应补写中断提示"
);
}
#[test]
fn command_reply_session_switched_updates_app_mirror() {
let _sub = crate::extensions::subagent::test_lock();
let mut st = App::new();
st.dock_visible = true;
st.dock_offset = 3;
st.system_messages.push((
1,
crate::modes::interactive::app::SysMsg::new(
MsgLevel::Info,
vec![crate::modes::interactive::app::SysSpan::plain("stale")],
),
));
st.on_session_switched(Ok(crate::modes::interactive::app::SessionSwitched {
session_id: "abc".into(),
name: None,
file: Some("/tmp/s.json".into()),
messages: vec![AgentMessage::user_text("hi")],
}));
assert_eq!(st.current_session_path.as_deref(), Some("/tmp/s.json"));
assert!(st.system_messages.is_empty(), "切换后旧提示应清除");
assert_eq!(st.messages.len(), 1);
assert_eq!(st.messages[0].text(), "hi");
assert!(!st.dock_visible, "会话切换应隐藏停靠面板");
assert_eq!(st.dock_offset, 0, "隐藏时重置偏移");
}
#[test]
fn session_switched_refreshes_context_percent() {
let _sub = crate::extensions::subagent::test_lock();
let mut st = App::new();
st.context_window = 100_000;
st.context_percent = 97.0; st.on_session_switched(Ok(crate::modes::interactive::app::SessionSwitched {
session_id: "abc".into(),
name: None,
file: Some("/tmp/s.json".into()),
messages: vec![AgentMessage::user_text("hello, new session body")],
}));
assert!(
st.context_percent < 97.0 && st.context_percent > 0.0,
"切换后应按新消息集刷新使用率: {}",
st.context_percent
);
}
#[test]
fn cancel_prompt_pushes_aborted_hint_message() {
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.busy = true;
st.streaming_text.push_str("partial output");
}
assert!(
!process_key_action(&shared, KeyAction::CancelPrompt),
"取消不退出"
);
let st = shared.borrow();
assert!(!st.busy, "取消后 busy 复位");
assert!(st.streaming_text.is_empty(), "流式缓冲清空");
let last = st.messages.last().expect("应有 aborted 消息");
assert_eq!(last.role, "assistant");
assert_eq!(last.stop_reason.as_deref(), Some("aborted"));
assert_eq!(last.error_message.as_deref(), Some("Operation aborted"));
assert!(st.stream_anchor_index.is_none(), "流式锚点应失效");
let texts = crate::modes::interactive::render::render_messages_text(&st, 80);
assert!(
texts.iter().any(|l| l.contains("Operation aborted")),
"中断提示应出现在消息区: {texts:?}"
);
}
#[test]
fn cancel_prompt_stops_elapsed_timer_on_tool_block() {
let shared = Rc::new(RefCell::new(App::new()));
let before_started: u64 = {
let mut st = shared.borrow_mut();
st.busy = true;
let mut m = AgentMessage::user_text("");
m.role = "assistant".to_string();
m.content = vec![crate::ContentBlock::ToolCall {
id: "call_1".to_string(),
name: "bash".to_string(),
arguments: serde_json::json!({ "command": "sleep 5" }),
thought_signature: None,
namespace: None,
}];
st.messages.push(m);
st.tool_started_at
.insert("call_1".to_string(), crate::utils::time::now_ms() - 3000);
let r = st.render_history_lines(80);
r.segments
.iter()
.find_map(|(_, _, p)| p.iter().find_map(|p| p.started_at_ms))
.expect("进行中工具块应携带 started_at")
};
assert!(before_started > 0);
process_key_action(&shared, KeyAction::CancelPrompt);
{
let st = shared.borrow();
assert!(
!st.tool_started_at.contains_key("call_1"),
"取消后进行中工具计时必须清空,否则 Elapsed 持续刷新"
);
assert!(
st.last_elapsed_rebuild.is_none(),
"取消后应允许下一帧强制重渲染以去除 Elapsed"
);
}
{
let mut st = shared.borrow_mut();
let r2 = st.render_history_lines(80);
let any_started = r2
.segments
.iter()
.any(|(_, _, p)| p.iter().any(|p| p.started_at_ms.is_some()));
assert!(!any_started, "取消后工具块不应再驱动 Elapsed 渲染");
}
}
#[test]
fn drain_next_batch_steer_before_follow_up() {
let _g = test_agent_dir();
crate::core::settings_manager::write_settings_steering_mode("one-at-a-time").unwrap();
crate::core::settings_manager::write_settings_follow_up_mode("one-at-a-time").unwrap();
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.runtime_steer_inbox
.lock()
.unwrap()
.push_back(crate::core::provider::AgentMessage::user_text("steer one"));
st.runtime_steer_inbox
.lock()
.unwrap()
.push_back(crate::core::provider::AgentMessage::user_text("steer two"));
st.runtime_followup_inbox.push_back("fup one".to_string());
}
let mut st = shared.borrow_mut();
assert_eq!(drain_next_batch(&mut st).unwrap()[0].text(), "steer one");
assert_eq!(drain_next_batch(&mut st).unwrap()[0].text(), "steer two");
assert_eq!(drain_next_batch(&mut st).unwrap()[0].text(), "fup one");
assert!(drain_next_batch(&mut st).is_none());
}
#[test]
fn drain_next_batch_all_empties_whole_batch() {
let _g = test_agent_dir();
let shared = Rc::new(RefCell::new(App::new()));
crate::core::settings_manager::write_settings_steering_mode("all").unwrap();
crate::core::settings_manager::write_settings_follow_up_mode("all").unwrap();
{
let mut st = shared.borrow_mut();
st.runtime_steer_inbox
.lock()
.unwrap()
.push_back(crate::core::provider::AgentMessage::user_text("s1"));
st.runtime_steer_inbox
.lock()
.unwrap()
.push_back(crate::core::provider::AgentMessage::user_text("s2"));
st.runtime_followup_inbox.push_back("f1".to_string());
st.runtime_followup_inbox.push_back("f2".to_string());
}
let mut st = shared.borrow_mut();
let s = drain_next_batch(&mut st).unwrap();
assert_eq!(s.len(), 2);
assert_eq!(s[0].text(), "s1");
assert_eq!(s[1].text(), "s2");
let f = drain_next_batch(&mut st).unwrap();
assert_eq!(f.len(), 2);
assert_eq!(f[0].text(), "f1");
assert_eq!(f[1].text(), "f2");
assert!(drain_next_batch(&mut st).is_none());
crate::core::settings_manager::write_settings_steering_mode("one-at-a-time").unwrap();
crate::core::settings_manager::write_settings_follow_up_mode("one-at-a-time").unwrap();
}
}