use std::collections::HashSet;
use std::io::{self, Stdout, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::{
event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind, PopKeyboardEnhancementFlags,
},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
layout::{Constraint, Direction, Layout, Rect, Size},
prelude::Widget,
style::Style,
text::Span,
widgets::Block,
};
use tracing;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::audit::log_sensitive_event;
use crate::automation_manager::{AutomationManager, AutomationSchedulerConfig, spawn_scheduler};
use crate::client::DeepSeekClient;
use crate::commands;
use crate::compaction::estimate_input_tokens_conservative;
use crate::config::{ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL};
use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEvent};
use crate::core::coherence::CoherenceState;
use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
use crate::core::events::Event as EngineEvent;
use crate::core::ops::Op;
use crate::hooks::HookEvent;
use crate::models::{ContentBlock, Message, SystemPrompt, context_window_for_model};
use crate::palette;
use crate::prompts;
use crate::session_manager::{
OfflineQueueState, QueuedSessionMessage, SavedSession, SessionManager,
create_saved_session_with_mode, update_session,
};
use crate::task_manager::{
NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskStatus,
};
use crate::tools::spec::RuntimeToolServices;
use crate::tools::subagent::SubAgentStatus;
use crate::tui::color_compat::ColorCompatBackend;
use crate::tui::command_palette::{
CommandPaletteView, build_entries as build_command_palette_entries,
};
use crate::tui::context_inspector::build_context_inspector_text;
use crate::tui::context_menu::{ContextMenuEntry, ContextMenuView};
use crate::tui::event_broker::EventBroker;
use crate::tui::live_transcript::LiveTranscriptOverlay;
use crate::tui::mcp_routing::{add_mcp_message, open_mcp_manager_pager};
use crate::tui::onboarding;
use crate::tui::pager::PagerView;
use crate::tui::persistence_actor::{self, PersistRequest};
use crate::tui::plan_prompt::PlanPromptView;
use crate::tui::scrolling::{ScrollDirection, TranscriptScroll};
use crate::tui::selection::TranscriptSelectionPoint;
use crate::tui::session_picker::SessionPickerView;
use crate::tui::shell_job_routing::{
add_shell_job_message, format_shell_job_list, format_shell_poll, open_shell_job_pager,
};
use crate::tui::subagent_routing::{
active_fanout_counts, format_task_list, handle_subagent_mailbox, open_task_pager,
reconcile_subagent_activity_state, running_agent_count, sort_subagents_in_place,
task_mode_label, task_summary_to_panel_entry,
};
#[cfg(test)]
use crate::tui::tool_routing::exploring_label;
use crate::tui::tool_routing::{
handle_tool_call_complete, handle_tool_call_started, maybe_add_patch_preview,
};
use crate::tui::ui_text::{history_cell_to_text, line_to_plain, slice_text, text_display_width};
use crate::tui::user_input::UserInputView;
use crate::tui::views::subagent_view_agents;
use super::active_cell::ActiveCell;
use super::app::{
App, AppAction, AppMode, OnboardingState, QueuedMessage, ReasoningEffort, SidebarFocus,
StatusToastLevel, SubmitDisposition, TaskPanelEntry, ToolDetailRecord, TuiOptions,
};
use super::approval::{
ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision,
};
use super::history::{
HistoryCell, ToolCell, ToolStatus, history_cells_from_message, summarize_tool_output,
};
use super::slash_menu::{
apply_slash_menu_selection, try_autocomplete_slash_command, visible_slash_menu_entries,
};
use super::views::{
ConfigView, ContextMenuAction, HelpView, ModalKind, ShellControlView, ViewEvent,
};
use super::widgets::pending_input_preview::{ContextPreviewItem, PendingInputPreview};
use super::widgets::{
ChatWidget, ComposerWidget, FooterProps, FooterToast, FooterWidget, HeaderData, HeaderWidget,
Renderable,
};
const SLASH_MENU_LIMIT: usize = 128;
const MENTION_MENU_LIMIT: usize = 6;
const MIN_CHAT_HEIGHT: u16 = 3;
const MIN_COMPOSER_HEIGHT: u16 = 2;
const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0;
const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0;
const UI_IDLE_POLL_MS: u64 = 48;
const UI_ACTIVE_POLL_MS: u64 = 24;
const WEB_CONFIG_POLL_MS: u64 = 16;
const UI_STATUS_ANIMATION_MS: u64 = 80;
const WORKSPACE_CONTEXT_REFRESH_SECS: u64 = 15;
const SIDEBAR_VISIBLE_MIN_WIDTH: u16 = 100;
const DEFAULT_TERMINAL_PROBE_TIMEOUT_MS: u64 = 500;
type AppTerminal = Terminal<ColorCompatBackend<Stdout>>;
const TERMINAL_ORIGIN_RESET: &[u8] = b"\x1b[r\x1b[?6l\x1b[H\x1b[2J";
pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
let use_alt_screen = options.use_alt_screen;
let use_mouse_capture = options.use_mouse_capture;
let use_bracketed_paste = options.use_bracketed_paste;
let osc8_default_on = false;
crate::tui::osc8::set_enabled(
config
.tui
.as_ref()
.and_then(|tui| tui.osc8_links)
.unwrap_or(osc8_default_on),
);
let probe_timeout = terminal_probe_timeout(config);
let enable_raw = tokio::task::spawn_blocking(move || {
enable_raw_mode().map_err(|e| anyhow::anyhow!("Failed to enable raw mode: {}", e))
});
match tokio::time::timeout(probe_timeout, enable_raw).await {
Ok(inner_result) => {
inner_result??; }
Err(_) => {
tracing::warn!(
"Terminal probe timed out after {}ms - terminal may be unresponsive",
probe_timeout.as_millis()
);
return Err(anyhow::anyhow!(
"Terminal probe timed out after {}ms",
probe_timeout.as_millis()
));
}
}
let mut stdout = io::stdout();
if use_alt_screen {
execute!(stdout, EnterAlternateScreen)?;
}
if use_mouse_capture {
execute!(stdout, EnableMouseCapture)?;
}
if use_bracketed_paste {
execute!(stdout, EnableBracketedPaste)?;
}
if let Err(err) = execute!(
stdout,
crossterm::event::PushKeyboardEnhancementFlags(
crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
)
) {
tracing::debug!(
target: "kitty_keyboard",
?err,
"PushKeyboardEnhancementFlags ignored (terminal lacks support)"
);
}
let color_depth = palette::ColorDepth::detect();
let palette_mode = palette::PaletteMode::detect();
tracing::debug!(
?color_depth,
?palette_mode,
"terminal color profile detected"
);
let backend = ColorCompatBackend::new(stdout, color_depth, palette_mode);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let event_broker = EventBroker::new();
let mut config = config.clone();
let config = &mut config;
let mut app = App::new(options.clone(), config);
if let Some(ref session_id) = options.resume_session_id
&& let Ok(manager) = SessionManager::default_location()
{
let load_result: std::io::Result<Option<crate::session_manager::SavedSession>> =
if session_id == "latest" {
match manager.get_latest_session_for_workspace(&options.workspace) {
Ok(Some(meta)) => manager.load_session(&meta.id).map(Some),
Ok(None) => Ok(None),
Err(e) => Err(e),
}
} else {
manager.load_session_by_prefix(session_id).map(Some)
};
match load_result {
Ok(Some(saved)) => {
let recovered = apply_loaded_session(&mut app, &saved);
if !recovered {
app.status_message = Some(format!(
"Resumed session: {}",
crate::session_manager::truncate_id(&saved.metadata.id)
));
}
}
Ok(None) => {
app.status_message = Some("No sessions found to resume".to_string());
}
Err(e) => {
app.status_message = Some(format!("Failed to load session: {e}"));
}
}
}
if let Ok(manager) = SessionManager::default_location() {
match manager.load_offline_queue_state() {
Ok(Some(state)) => {
let should_restore = match (&state.session_id, &app.current_session_id) {
(Some(saved_id), Some(current_id)) => saved_id == current_id,
(None, _) => false, (_, None) => false, };
if should_restore {
app.queued_messages = state
.messages
.into_iter()
.map(queued_session_to_ui)
.collect();
let restored_draft = state.draft.map(queued_session_to_ui);
if restored_draft.is_some() || app.queued_draft.is_none() {
app.queued_draft = restored_draft;
}
if app.status_message.is_none() && app.queued_message_count() > 0 {
app.status_message = Some(format!(
"Restored {} queued message(s) from previous session — ↑ to edit, Ctrl+X to discard",
app.queued_message_count()
));
}
} else {
let _ = manager.clear_offline_queue_state();
}
}
Ok(None) => {}
Err(err) => {
if app.status_message.is_none() {
app.status_message = Some(format!("Failed to restore offline queue: {err}"));
}
}
}
}
let task_manager = TaskManager::start(
TaskManagerConfig::from_runtime(
config,
app.workspace.clone(),
Some(app.model.clone()),
Some(app.max_subagents.clamp(1, 4)),
),
config.clone(),
)
.await?;
let automations = std::sync::Arc::new(tokio::sync::Mutex::new(
AutomationManager::default_location()?,
));
let automation_cancel = tokio_util::sync::CancellationToken::new();
let automation_scheduler = spawn_scheduler(
automations.clone(),
task_manager.clone(),
automation_cancel.clone(),
AutomationSchedulerConfig::default(),
);
let shell_manager = app
.runtime_services
.shell_manager
.clone()
.unwrap_or_else(|| crate::tools::shell::new_shared_shell_manager(app.workspace.clone()));
app.runtime_services = RuntimeToolServices {
shell_manager: Some(shell_manager),
task_manager: Some(task_manager.clone()),
automations: Some(automations),
task_data_dir: Some(task_manager.data_dir()),
active_task_id: None,
active_thread_id: None,
hook_executor: Some(std::sync::Arc::new(app.hooks.clone())),
};
refresh_active_task_panel(&mut app, &task_manager).await;
let engine_config = build_engine_config(&app, config);
let engine_handle = spawn_engine(engine_config, config);
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {
messages: app.api_messages.clone(),
system_prompt: app.system_prompt.clone(),
model: app.model.clone(),
workspace: app.workspace.clone(),
})
.await;
}
{
let context = app.base_hook_context();
let _ = app.execute_hooks(HookEvent::SessionStart, &context);
}
if let Ok(persist_manager) = SessionManager::default_location() {
let handle = persistence_actor::spawn_persistence_actor(persist_manager);
persistence_actor::init_actor(handle);
}
let result = run_event_loop(
&mut terminal,
&mut app,
config,
engine_handle,
task_manager,
&event_broker,
)
.await;
automation_cancel.cancel();
automation_scheduler.abort();
{
let context = app.base_hook_context();
let _ = app.execute_hooks(HookEvent::SessionEnd, &context);
}
persistence_actor::persist(PersistRequest::ClearCheckpoint);
persistence_actor::persist(PersistRequest::Shutdown);
let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
disable_raw_mode()?;
if use_alt_screen {
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
}
if use_mouse_capture {
execute!(terminal.backend_mut(), DisableMouseCapture)?;
}
if use_bracketed_paste {
execute!(terminal.backend_mut(), DisableBracketedPaste)?;
}
terminal.show_cursor()?;
drop(terminal);
if result.is_ok()
&& let Some(hint) = format_resume_hint(app.current_session_id.as_deref())
{
println!("{hint}");
}
result
}
fn format_resume_hint(session_id: Option<&str>) -> Option<String> {
let session_id = session_id?.trim();
if session_id.is_empty() {
return None;
}
Some(format!(
"To continue this session, run deepseek resume {session_id}"
))
}
fn terminal_probe_timeout(config: &Config) -> Duration {
let timeout_ms = config
.tui
.as_ref()
.and_then(|tui| tui.terminal_probe_timeout_ms)
.unwrap_or(DEFAULT_TERMINAL_PROBE_TIMEOUT_MS)
.clamp(100, 5_000);
Duration::from_millis(timeout_ms)
}
#[must_use]
fn is_memory_quick_add(input: &str) -> bool {
let trimmed = input.trim_start();
if !trimmed.starts_with('#') {
return false;
}
if trimmed.starts_with("##") || trimmed.starts_with("#!") {
return false;
}
if input.contains('\n') {
return false;
}
!trimmed.trim_start_matches('#').trim().is_empty()
}
fn handle_memory_quick_add(app: &mut App, input: &str, config: &Config) {
let path = config.memory_path();
match crate::memory::append_entry(&path, input) {
Ok(()) => {
app.status_message = Some(format!("memory: appended to {}", path.display()));
}
Err(err) => {
app.status_message = Some(format!(
"memory: failed to write {}: {}",
path.display(),
err
));
}
}
}
fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
EngineConfig {
model: app.model.clone(),
workspace: app.workspace.clone(),
allow_shell: app.allow_shell,
trust_mode: app.trust_mode,
notes_path: config.notes_path(),
mcp_config_path: config.mcp_config_path(),
skills_dir: app.skills_dir.clone(),
instructions: config.instructions_paths(),
max_steps: u32::MAX,
max_subagents: app.max_subagents,
features: config.features(),
compaction: app.compaction_config(),
cycle: app.cycle_config(),
capacity: crate::core::capacity::CapacityControllerConfig::from_app_config(config),
todos: app.todos.clone(),
plan_state: app.plan_state.clone(),
max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
network_policy: config.network.clone().map(|toml_cfg| {
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
}),
snapshots_enabled: config.snapshots_config().enabled,
lsp_config: config
.lsp
.clone()
.map(crate::config::LspConfigToml::into_runtime),
runtime_services: app.runtime_services.clone(),
subagent_model_overrides: config.subagent_model_overrides(),
memory_enabled: config.memory_enabled(),
memory_path: config.memory_path(),
strict_tool_mode: config.strict_tool_mode.unwrap_or(false),
goal_objective: app.goal.goal_objective.clone(),
locale_tag: app.ui_locale.tag().to_string(),
workshop: config.workshop.clone(),
}
}
async fn refresh_active_task_panel(app: &mut App, task_manager: &SharedTaskManager) {
let tasks = task_manager.list_tasks(None).await;
let mut entries: Vec<TaskPanelEntry> = tasks
.into_iter()
.filter(|task| matches!(task.status, TaskStatus::Queued | TaskStatus::Running))
.map(task_summary_to_panel_entry)
.collect();
entries.extend(active_rlm_task_entries(app));
if let Some(shell_mgr) = app.runtime_services.shell_manager.as_ref()
&& let Ok(mut mgr) = shell_mgr.lock()
{
for job in mgr.list_jobs() {
if !matches!(job.status, crate::tools::shell::ShellStatus::Running) {
continue;
}
entries.push(TaskPanelEntry {
id: job.id,
status: "running".to_string(),
prompt_summary: format!("shell: {}", job.command),
duration_ms: Some(job.elapsed_ms),
});
}
}
app.task_panel = entries;
}
fn active_rlm_task_entries(app: &App) -> Vec<TaskPanelEntry> {
let Some(active) = app.active_cell.as_ref() else {
return Vec::new();
};
let duration_ms = app
.turn_started_at
.map(|started| u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX));
active
.entries()
.iter()
.enumerate()
.filter_map(|(idx, entry)| {
let HistoryCell::Tool(ToolCell::Generic(generic)) = entry else {
return None;
};
if generic.name != "rlm" || generic.status != ToolStatus::Running {
return None;
}
let summary = generic
.input_summary
.as_deref()
.filter(|summary| !summary.trim().is_empty())
.unwrap_or("running chunked analysis");
Some(TaskPanelEntry {
id: format!("rlm-{}", idx + 1),
status: "running".to_string(),
prompt_summary: format!("RLM: {summary}"),
duration_ms,
})
})
.collect()
}
#[allow(clippy::too_many_lines)]
async fn run_event_loop(
terminal: &mut AppTerminal,
app: &mut App,
config: &mut Config,
mut engine_handle: EngineHandle,
task_manager: SharedTaskManager,
event_broker: &EventBroker,
) -> Result<()> {
let mut current_streaming_text = String::new();
let mut last_queue_state = (app.queued_messages.clone(), app.queued_draft.clone());
let mut last_task_refresh = Instant::now()
.checked_sub(Duration::from_secs(2))
.unwrap_or_else(Instant::now);
let mut last_status_frame = Instant::now()
.checked_sub(Duration::from_millis(UI_STATUS_ANIMATION_MS))
.unwrap_or_else(Instant::now);
let mut frame_rate_limiter = crate::tui::frame_rate_limiter::FrameRateLimiter::default();
let mut web_config_session: Option<WebConfigSession> = None;
let mut shift_bypass_active = false;
let mut terminal_paused_at: Option<Instant> = None;
let mut force_terminal_repaint = false;
loop {
if !drain_web_config_events(&mut web_config_session, app, config, &engine_handle).await {
web_config_session = None;
}
if last_task_refresh.elapsed() >= Duration::from_millis(2500) {
refresh_active_task_panel(app, &task_manager).await;
last_task_refresh = Instant::now();
app.needs_redraw = true;
}
let mut received_engine_event = false;
let mut transcript_batch_updated = false;
let mut queued_to_send: Option<QueuedMessage> = None;
{
let mut rx = engine_handle.rx_event.write().await;
while let Ok(event) = rx.try_recv() {
received_engine_event = true;
match event {
EngineEvent::MessageStarted { .. } => {
app.flush_active_cell();
current_streaming_text.clear();
app.streaming_state.reset();
app.streaming_state.start_text(0, None);
app.streaming_message_index = None;
}
EngineEvent::MessageDelta { content, .. } => {
let sanitized = sanitize_stream_chunk(&content);
if sanitized.is_empty() {
continue;
}
if app.streaming_message_index.is_none() {
app.flush_active_cell();
}
current_streaming_text.push_str(&sanitized);
let index = ensure_streaming_assistant_history_cell(app);
app.streaming_state.push_content(0, &sanitized);
let committed = app.streaming_state.commit_text(0);
if !committed.is_empty() {
append_streaming_text(app, index, &committed);
transcript_batch_updated = true;
}
}
EngineEvent::MessageComplete { .. } => {
if let Some(index) = app.streaming_message_index.take() {
let remaining = app.streaming_state.finalize_block_text(0);
if !remaining.is_empty() {
append_streaming_text(app, index, &remaining);
}
if let Some(HistoryCell::Assistant { streaming, .. }) =
app.history.get_mut(index)
{
*streaming = false;
}
app.bump_history_cell(index);
transcript_batch_updated = true;
}
let mut blocks = Vec::new();
let thinking = app.last_reasoning.take();
if let Some(thinking) = thinking {
blocks.push(ContentBlock::Thinking { thinking });
}
if !current_streaming_text.is_empty() {
blocks.push(ContentBlock::Text {
text: current_streaming_text.clone(),
cache_control: None,
});
}
for (id, name, input) in app.pending_tool_uses.drain(..) {
blocks.push(ContentBlock::ToolUse {
id,
name,
input,
caller: None,
});
}
let has_sendable_content = blocks.iter().any(|block| {
matches!(
block,
ContentBlock::Text { .. } | ContentBlock::ToolUse { .. }
)
});
if has_sendable_content {
app.api_messages.push(Message {
role: "assistant".to_string(),
content: blocks,
});
}
}
EngineEvent::ThinkingStarted { .. } => {
if start_streaming_thinking_block(app) {
transcript_batch_updated = true;
}
}
EngineEvent::ThinkingDelta { content, .. } => {
let sanitized = sanitize_stream_chunk(&content);
if sanitized.is_empty() {
continue;
}
app.reasoning_buffer.push_str(&sanitized);
if app.reasoning_header.is_none() {
app.reasoning_header = extract_reasoning_header(&app.reasoning_buffer);
}
let entry_idx = ensure_streaming_thinking_active_entry(app);
app.streaming_state.push_content(0, &sanitized);
let committed = app.streaming_state.commit_text(0);
if !committed.is_empty() {
append_streaming_thinking(app, entry_idx, &committed);
transcript_batch_updated = true;
}
}
EngineEvent::ThinkingComplete { .. } => {
if finalize_current_streaming_thinking(app) {
transcript_batch_updated = true;
}
stash_reasoning_buffer_into_last_reasoning(app);
}
EngineEvent::ToolCallStarted { id, name, input } => {
app.pending_tool_uses
.push((id.clone(), name.clone(), input.clone()));
if matches!(name.as_str(), "agent_spawn" | "rlm" | "delegate") {
app.pending_subagent_dispatch = Some(name.clone());
if name == "rlm" {
app.last_fanout_card_index = None;
}
}
handle_tool_call_started(app, &id, &name, &input);
}
EngineEvent::ToolCallComplete { id, name, result } => {
if name == "update_plan" {
app.plan_tool_used_in_turn = true;
}
let tool_content = match &result {
Ok(output) => sanitize_stream_chunk(
&crate::core::engine::compact_tool_result_for_context(
&app.model, &name, output,
),
),
Err(err) => sanitize_stream_chunk(&format!("Error: {err}")),
};
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: id.clone(),
content: tool_content,
is_error: None,
content_blocks: None,
}],
});
handle_tool_call_complete(app, &id, &name, &result);
if matches!(
name.as_str(),
"agent_spawn"
| "agent_cancel"
| "todo_write"
| "task_shell_start"
| "exec_shell"
) {
refresh_active_task_panel(app, &task_manager).await;
last_task_refresh = Instant::now();
}
if matches!(
name.as_str(),
"agent_spawn"
| "agent_cancel"
| "agent_wait"
| "agent_result"
| "agent_status"
) {
let _ = engine_handle.send(Op::ListSubAgents).await;
}
}
EngineEvent::TurnStarted { turn_id } => {
app.is_loading = true;
app.offline_mode = false;
current_streaming_text.clear();
app.streaming_state.reset();
app.streaming_message_index = None;
app.streaming_thinking_active_entry = None;
app.turn_started_at = Some(Instant::now());
app.runtime_turn_id = Some(turn_id);
app.runtime_turn_status = Some("in_progress".to_string());
app.reasoning_buffer.clear();
app.reasoning_header = None;
app.last_reasoning = None;
app.pending_tool_uses.clear();
app.plan_tool_used_in_turn = false;
last_status_frame = Instant::now();
}
EngineEvent::TurnComplete {
usage,
status,
error,
} => {
force_terminal_repaint = true;
if matches!(
status,
crate::core::events::TurnOutcomeStatus::Interrupted
| crate::core::events::TurnOutcomeStatus::Failed
) {
app.finalize_active_cell_as_interrupted();
app.finalize_streaming_assistant_as_interrupted();
} else {
app.flush_active_cell();
}
app.is_loading = false;
app.offline_mode = false;
app.streaming_state.reset();
let turn_elapsed =
app.turn_started_at.map(|t| t.elapsed()).unwrap_or_default();
app.turn_started_at = None;
app.cumulative_turn_duration =
app.cumulative_turn_duration.saturating_add(turn_elapsed);
app.user_scrolled_during_stream = false;
app.runtime_turn_status = Some(match status {
crate::core::events::TurnOutcomeStatus::Completed => {
"completed".to_string()
}
crate::core::events::TurnOutcomeStatus::Interrupted => {
"interrupted".to_string()
}
crate::core::events::TurnOutcomeStatus::Failed => "failed".to_string(),
});
if matches!(
status,
crate::core::events::TurnOutcomeStatus::Interrupted
| crate::core::events::TurnOutcomeStatus::Failed
) {
let _ = engine_handle.send(Op::ListSubAgents).await;
}
let turn_tokens = usage.input_tokens + usage.output_tokens;
app.session.total_tokens =
app.session.total_tokens.saturating_add(turn_tokens);
app.session.total_conversation_tokens = app
.session
.total_conversation_tokens
.saturating_add(turn_tokens);
app.session.last_prompt_tokens = Some(usage.input_tokens);
app.session.last_completion_tokens = Some(usage.output_tokens);
app.session.last_prompt_cache_hit_tokens = usage.prompt_cache_hit_tokens;
app.session.last_prompt_cache_miss_tokens = usage.prompt_cache_miss_tokens;
app.session.last_reasoning_replay_tokens = usage.reasoning_replay_tokens;
app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cache_hit_tokens: usage.prompt_cache_hit_tokens,
cache_miss_tokens: usage.prompt_cache_miss_tokens,
reasoning_replay_tokens: usage.reasoning_replay_tokens,
recorded_at: Instant::now(),
});
if let Some(error) = error {
app.status_message = Some(format!("Turn failed: {error}"));
}
let pricing_model = if app.auto_model {
app.last_effective_model.as_deref().unwrap_or(&app.model)
} else {
&app.model
};
let turn_cost = crate::pricing::calculate_turn_cost_estimate_from_usage(
pricing_model,
&usage,
);
if let Some(cost) = turn_cost {
app.accrue_session_cost_estimate(cost);
}
if status == crate::core::events::TurnOutcomeStatus::Completed
&& let Some((method, threshold, include_summary)) =
notification_settings(config)
{
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
let msg = completed_turn_notification_message(
app,
¤t_streaming_text,
include_summary,
turn_elapsed,
turn_cost,
);
crate::tui::notifications::notify_done(
method,
in_tmux,
&msg,
threshold,
turn_elapsed,
);
}
if let Ok(manager) = SessionManager::default_location() {
let session = build_session_snapshot(app, &manager);
app.current_session_id = Some(session.metadata.id.clone());
persistence_actor::persist(PersistRequest::SessionSnapshot(session));
}
persistence_actor::persist(PersistRequest::ClearCheckpoint);
if app.mode == AppMode::Plan
&& app.plan_tool_used_in_turn
&& !app.plan_prompt_pending
&& app.queued_message_count() == 0
&& app.queued_draft.is_none()
{
app.plan_prompt_pending = true;
app.add_message(HistoryCell::System {
content: plan_next_step_prompt(),
});
if app.view_stack.top_kind() != Some(ModalKind::PlanPrompt) {
app.view_stack.push(PlanPromptView::new());
}
}
app.plan_tool_used_in_turn = false;
if status == crate::core::events::TurnOutcomeStatus::Interrupted
&& app.submit_pending_steers_after_interrupt
{
if let Some(merged) = merge_pending_steers(&mut *app) {
queued_to_send = Some(merged);
}
} else if status == crate::core::events::TurnOutcomeStatus::Failed
&& !app.pending_steers.is_empty()
{
for msg in app.drain_pending_steers() {
app.queue_message(msg);
}
}
if queued_to_send.is_none() {
queued_to_send = app.pop_queued_message();
}
}
EngineEvent::Error {
envelope,
recoverable: _,
} => {
apply_engine_error_to_app(app, envelope);
}
EngineEvent::Status { message } => {
app.status_message = Some(message);
}
EngineEvent::SessionUpdated {
messages,
system_prompt,
model,
workspace,
} => {
app.api_messages = messages;
app.system_prompt = system_prompt;
if app.auto_model {
app.last_effective_model = Some(model);
} else {
app.model = model;
app.last_effective_model = None;
}
app.update_model_compaction_budget();
app.workspace = workspace;
if (app.is_loading || app.is_compacting)
&& let Ok(manager) = SessionManager::default_location()
{
let session = build_session_snapshot(app, &manager);
persistence_actor::persist(PersistRequest::Checkpoint(session));
}
}
EngineEvent::CompactionStarted { message, .. } => {
app.is_compacting = true;
app.status_message = Some(message);
}
EngineEvent::CompactionCompleted { message, .. } => {
app.is_compacting = false;
app.status_message = Some(message);
}
EngineEvent::CompactionFailed { message, .. } => {
app.is_compacting = false;
app.status_message = Some(message);
}
EngineEvent::CycleAdvanced { from, to, briefing } => {
app.cycle_count = to;
let briefing_tokens = briefing.token_estimate;
app.cycle_briefings.push(briefing);
let separator = format!(
"─── cycle {from} → {to} (briefing: {briefing_tokens} tokens) ───"
);
app.add_message(HistoryCell::System { content: separator });
app.status_message = Some(format!(
"↻ context refreshed (cycle {from} → {to}, briefing: {briefing_tokens} tokens carried)"
));
}
EngineEvent::CoherenceState { state, .. } => {
app.coherence_state = state;
}
EngineEvent::CapacityDecision { .. } => {
}
EngineEvent::CapacityIntervention {
action,
before_prompt_tokens,
after_prompt_tokens,
..
} => {
app.status_message = Some(format!(
"Capacity intervention: {action} (~{before_prompt_tokens} -> ~{after_prompt_tokens} tokens)"
));
}
EngineEvent::CapacityMemoryPersistFailed { action, error, .. } => {
app.status_message = Some(format!(
"Capacity memory persist failed ({action}): {error}"
));
}
EngineEvent::PauseEvents { ack } => {
if !event_broker.is_paused() {
pause_terminal(
terminal,
app.use_alt_screen,
app.use_mouse_capture,
app.use_bracketed_paste,
)?;
event_broker.pause_events();
terminal_paused_at = Some(Instant::now());
}
if let Some(ack) = ack {
ack.notify_one();
}
}
EngineEvent::ResumeEvents => {
if event_broker.is_paused() {
resume_terminal(
terminal,
app.use_alt_screen,
app.use_mouse_capture,
app.use_bracketed_paste,
)?;
event_broker.resume_events();
terminal_paused_at = None;
}
}
EngineEvent::AgentSpawned { id, prompt } => {
let prompt_summary = summarize_tool_output(&prompt);
app.agent_progress
.insert(id.clone(), format!("starting: {prompt_summary}"));
if app.agent_activity_started_at.is_none() {
app.agent_activity_started_at = Some(Instant::now());
}
app.status_message =
Some(format!("Sub-agent {id} starting: {prompt_summary}"));
let _ = engine_handle.send(Op::ListSubAgents).await;
}
EngineEvent::AgentProgress { id, status } => {
let display = friendly_subagent_progress(app, &id, &status);
if is_noisy_subagent_progress(&status) {
app.agent_progress
.entry(id.clone())
.or_insert_with(|| display.clone());
} else {
app.agent_progress.insert(id.clone(), display.clone());
}
if app.agent_activity_started_at.is_none() {
app.agent_activity_started_at = Some(Instant::now());
}
app.status_message = Some(format!("Sub-agent {id}: {display}"));
}
EngineEvent::AgentComplete { id, result } => {
let subagent_elapsed = app
.agent_activity_started_at
.or(app.turn_started_at)
.map(|started| started.elapsed())
.unwrap_or_default();
let has_other_running_subagents =
app.agent_progress.keys().any(|agent_id| agent_id != &id)
|| app.subagent_cache.iter().any(|agent| {
agent.agent_id != id
&& matches!(agent.status, SubAgentStatus::Running)
});
app.agent_progress.remove(&id);
app.status_message = Some(format!(
"Sub-agent {id} completed: {}",
summarize_tool_output(&result)
));
let should_recapture_terminal =
!has_other_running_subagents && app.use_alt_screen;
if !has_other_running_subagents
&& let Some((method, threshold, include_summary)) =
notification_settings(config)
{
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
let msg = subagent_completion_notification_message(
&id,
&result,
include_summary,
subagent_elapsed,
);
crate::tui::notifications::notify_done(
method,
in_tmux,
&msg,
threshold,
subagent_elapsed,
);
}
if should_recapture_terminal {
resume_terminal(
terminal,
app.use_alt_screen,
app.use_mouse_capture,
app.use_bracketed_paste,
)?;
event_broker.resume_events();
terminal_paused_at = None;
app.needs_redraw = true;
}
let _ = engine_handle.send(Op::ListSubAgents).await;
}
EngineEvent::AgentList { agents } => {
let mut sorted = agents.clone();
sort_subagents_in_place(&mut sorted);
sorted.retain(|a| !a.from_prior_session);
app.subagent_cache = sorted.clone();
reconcile_subagent_activity_state(app);
let view_agents = subagent_view_agents(app, &sorted);
if app.view_stack.update_subagents(&view_agents) {
app.status_message =
Some(format!("Sub-agents: {} total", view_agents.len()));
}
}
EngineEvent::SubAgentMailbox { seq, message } => {
handle_subagent_mailbox(app, seq, &message);
transcript_batch_updated = true;
}
EngineEvent::ApprovalRequired {
id,
tool_name,
description,
approval_key,
} => {
let session_approved =
app.approval_session_approved.contains(&approval_key)
|| app.approval_session_approved.contains(&tool_name);
let session_denied = app.approval_session_denied.contains(&approval_key)
|| app.approval_session_denied.contains(&tool_name);
if session_denied {
log_sensitive_event(
"tool.approval.auto_deny_session",
serde_json::json!({
"tool_name": tool_name,
"approval_key": approval_key,
"session_id": app.current_session_id,
}),
);
let _ = engine_handle.deny_tool_call(id.clone()).await;
} else if session_approved || app.approval_mode == ApprovalMode::Auto {
log_sensitive_event(
"tool.approval.auto_approve",
serde_json::json!({
"tool_name": tool_name,
"approval_key": approval_key,
"session_id": app.current_session_id,
"mode": app.mode.label(),
}),
);
let _ = engine_handle.approve_tool_call(id.clone()).await;
} else if app.approval_mode == ApprovalMode::Never {
log_sensitive_event(
"tool.approval.auto_deny",
serde_json::json!({
"tool_name": tool_name,
"session_id": app.current_session_id,
"mode": app.mode.label(),
}),
);
let _ = engine_handle.deny_tool_call(id.clone()).await;
app.status_message =
Some(format!("Blocked tool '{tool_name}' (approval_mode=never)"));
} else {
let tool_input = app
.pending_tool_uses
.iter()
.find(|(tool_id, _, _)| tool_id == &id)
.map(|(_, _, input)| input.clone())
.unwrap_or_else(|| serde_json::json!({}));
if tool_name == "apply_patch" {
maybe_add_patch_preview(app, &tool_input);
}
let request = ApprovalRequest::new(
&id,
&tool_name,
&description,
&tool_input,
&approval_key,
);
log_sensitive_event(
"tool.approval.prompted",
serde_json::json!({
"tool_name": tool_name,
"description": description,
"session_id": app.current_session_id,
"mode": app.mode.label(),
}),
);
app.view_stack
.push(ApprovalView::new_for_locale(request, app.ui_locale));
app.status_message = Some(format!(
"Approval required for '{tool_name}': {description}"
));
}
}
EngineEvent::UserInputRequired { id, request } => {
app.view_stack.push(UserInputView::new(id.clone(), request));
app.status_message = Some(
"Action required: answer the popup with 1-4, arrows, or Enter"
.to_string(),
);
}
EngineEvent::ToolCallProgress { id, output } => {
app.status_message =
Some(format!("Tool {id}: {}", summarize_tool_output(&output)));
}
EngineEvent::ElevationRequired {
tool_id,
tool_name,
command,
denial_reason,
blocked_network,
blocked_write,
} => {
if app.approval_mode == ApprovalMode::Auto {
log_sensitive_event(
"tool.sandbox.auto_elevate",
serde_json::json!({
"tool_name": tool_name,
"tool_id": tool_id,
"reason": denial_reason,
"session_id": app.current_session_id,
}),
);
app.add_message(HistoryCell::System {
content: format!(
"Sandbox denied {tool_name}: {denial_reason} - auto-elevating to full access"
),
});
let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
} else {
log_sensitive_event(
"tool.sandbox.prompt_elevation",
serde_json::json!({
"tool_name": tool_name,
"tool_id": tool_id,
"reason": denial_reason,
"session_id": app.current_session_id,
}),
);
let request = ElevationRequest::for_shell(
&tool_id,
command.as_deref().unwrap_or(&tool_name),
&denial_reason,
blocked_network,
blocked_write,
);
app.view_stack.push(ElevationView::new(request));
app.status_message =
Some(format!("Sandbox blocked {tool_name}: {denial_reason}"));
}
}
}
}
}
if let Some(index) = app.streaming_message_index {
let committed = app.streaming_state.commit_text(0);
if !committed.is_empty() {
append_streaming_text(app, index, &committed);
transcript_batch_updated = true;
}
} else if let Some(entry_idx) = app.streaming_thinking_active_entry {
let committed = app.streaming_state.commit_text(0);
if !committed.is_empty() {
append_streaming_thinking(app, entry_idx, &committed);
transcript_batch_updated = true;
}
}
if transcript_batch_updated {
app.mark_history_updated();
}
if received_engine_event {
app.needs_redraw = true;
}
if let Some(next) = queued_to_send {
if let Err(err) = dispatch_user_message(app, config, &engine_handle, next.clone()).await
{
app.queue_message(next);
app.status_message = Some(format!(
"Dispatch failed ({err}); kept {} queued message(s)",
app.queued_message_count()
));
}
app.needs_redraw = true;
}
let queue_state = (app.queued_messages.clone(), app.queued_draft.clone());
if queue_state != last_queue_state {
persist_offline_queue_state(app);
last_queue_state = queue_state;
app.needs_redraw = true;
}
if !app.view_stack.is_empty() {
let events = app.view_stack.tick();
if !events.is_empty() {
app.needs_redraw = true;
}
if handle_view_events(
terminal,
app,
config,
&task_manager,
&mut engine_handle,
&mut web_config_session,
events,
)
.await?
{
return Ok(());
}
}
let has_running_agents = running_agent_count(app) > 0;
if (app.is_loading || has_running_agents || app.is_compacting)
&& last_status_frame.elapsed()
>= Duration::from_millis(status_animation_interval_ms(app))
{
if !app.low_motion && history_has_live_motion(&app.history) {
app.mark_history_updated();
}
app.needs_redraw = true;
last_status_frame = Instant::now();
}
if event_broker.is_paused() {
let grace_active = terminal_paused_at
.map(|paused_at| paused_at.elapsed() < Duration::from_millis(500))
.unwrap_or(false);
if terminal_pause_has_live_owner(app) || grace_active {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
continue;
}
resume_terminal(
terminal,
app.use_alt_screen,
app.use_mouse_capture,
app.use_bracketed_paste,
)?;
event_broker.resume_events();
terminal_paused_at = None;
app.status_message = Some("Terminal controls restored".to_string());
app.needs_redraw = true;
force_terminal_repaint = true;
}
let now = Instant::now();
app.flush_paste_burst_if_enabled(now);
app.sync_status_message_to_toasts();
let pending_bg_cost = crate::cost_status::drain();
if pending_bg_cost.is_positive() {
app.accrue_subagent_cost_estimate(pending_bg_cost);
app.needs_redraw = true;
}
app.tick_quit_armed();
let allow_workspace_context_refresh =
!app.is_loading && !has_running_agents && !app.is_compacting;
refresh_workspace_context_if_needed(app, now, allow_workspace_context_refresh);
frame_rate_limiter.set_low_motion(app.low_motion);
app.streaming_state.set_low_motion(app.low_motion);
let draw_wait = if app.needs_redraw {
frame_rate_limiter.time_until_next_draw(now)
} else {
None
};
if app.needs_redraw && draw_wait.is_none() {
if force_terminal_repaint {
reset_terminal_viewport(terminal)?;
force_terminal_repaint = false;
}
draw_app_frame(terminal, app)?;
frame_rate_limiter.mark_emitted(Instant::now());
app.needs_redraw = false;
}
let mut poll_timeout = if app.is_loading || has_running_agents || app.is_compacting {
Duration::from_millis(active_poll_ms(app))
} else {
Duration::from_millis(idle_poll_ms(app))
};
if let Some(until_flush) = app.paste_burst_next_flush_delay_if_enabled(now) {
poll_timeout = poll_timeout.min(until_flush);
}
if let Some(until_draw) = draw_wait {
poll_timeout = poll_timeout.min(until_draw);
}
if web_config_session.is_some() {
poll_timeout = poll_timeout.min(Duration::from_millis(WEB_CONFIG_POLL_MS));
}
if let Some(deadline) = app.quit_armed_until {
let remaining = deadline.saturating_duration_since(now);
poll_timeout = poll_timeout.min(remaining.max(Duration::from_millis(50)));
}
poll_timeout = clamp_event_poll_timeout(poll_timeout);
tokio::task::yield_now().await;
if event::poll(poll_timeout)? {
let evt = event::read()?;
app.needs_redraw = true;
if let Event::Paste(text) = &evt {
tracing::debug!(
paste_len = text.len(),
preview = %text.chars().take(80).collect::<String>(),
"Received bracketed paste event"
);
if app.onboarding == OnboardingState::ApiKey {
app.insert_api_key_str(text);
sync_api_key_validation_status(app, false);
} else if app.is_history_search_active() {
app.history_search_insert_str(text);
} else if app.view_stack.handle_paste(text) {
} else if !app.view_stack.is_empty() {
} else {
app.insert_paste_text(text);
}
continue;
}
if let Event::Resize(width, height) = evt {
tracing::debug!(
width,
height,
coherence = ?app.coherence_state,
use_alt_screen = app.use_alt_screen,
"Event::Resize received; clearing terminal"
);
let mut final_w = width;
let mut final_h = height;
while event::poll(Duration::from_millis(0)).unwrap_or(false) {
match event::read() {
Ok(Event::Resize(w, h)) => {
final_w = w;
final_h = h;
}
Ok(other) => {
tracing::debug!(
?other,
"non-resize event during resize coalesce; dropping"
);
break;
}
Err(_) => break,
}
}
if let Err(err) = terminal.resize(Rect::new(0, 0, final_w, final_h)) {
tracing::warn!(
?err,
final_w,
final_h,
"terminal.resize during Resize event failed; falling back to clear+draw"
);
}
reset_terminal_viewport(terminal)?;
app.handle_resize(final_w, final_h);
{
let backend = terminal.backend_mut();
backend.force_size(Size::new(final_w, final_h));
}
draw_app_frame(terminal, app)?;
{
let backend = terminal.backend_mut();
backend.clear_forced_size();
}
app.needs_redraw = false;
continue;
}
if app.use_mouse_capture
&& let Event::Mouse(mouse) = evt
{
if mouse.modifiers.contains(KeyModifiers::SHIFT) {
if !shift_bypass_active {
let _ = execute!(terminal.backend_mut(), DisableMouseCapture);
shift_bypass_active = true;
app.push_status_toast(
"Native selection \u{2014} release Shift to return",
StatusToastLevel::Info,
Some(3_000),
);
}
continue;
}
if shift_bypass_active {
let _ = execute!(terminal.backend_mut(), EnableMouseCapture);
shift_bypass_active = false;
app.push_status_toast(
"Mouse capture restored",
StatusToastLevel::Info,
Some(2_000),
);
}
let events = handle_mouse_event(app, mouse);
if handle_view_events(
terminal,
app,
config,
&task_manager,
&mut engine_handle,
&mut web_config_session,
events,
)
.await?
{
return Ok(());
}
continue;
}
let Event::Key(key) = evt else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if app.onboarding != OnboardingState::None {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let _ = engine_handle.send(Op::Shutdown).await;
return Ok(());
}
KeyCode::Esc if app.onboarding == OnboardingState::ApiKey => {
app.onboarding = OnboardingState::Welcome;
app.api_key_input.clear();
app.api_key_cursor = 0;
app.status_message = None;
}
KeyCode::Esc if app.onboarding == OnboardingState::Language => {
app.onboarding = OnboardingState::Welcome;
app.status_message = None;
}
KeyCode::Char(c)
if app.onboarding == OnboardingState::Language && c.is_ascii_digit() =>
{
if let Some((_, tag, _, _)) = onboarding::language::LANGUAGE_OPTIONS
.iter()
.find(|(hotkey, _, _, _)| *hotkey == c)
{
match app.set_locale_from_onboarding(tag) {
Ok(()) => {
app.push_status_toast(
format!("Language set to {tag}"),
StatusToastLevel::Info,
Some(2_500),
);
advance_onboarding_after_language(app);
}
Err(err) => {
app.status_message =
Some(format!("Failed to save locale: {err}"));
}
}
}
}
KeyCode::Enter => match app.onboarding {
OnboardingState::Welcome => {
advance_onboarding_from_welcome(app);
}
OnboardingState::Language => {
advance_onboarding_after_language(app);
}
OnboardingState::ApiKey => {
let key = app.api_key_input.trim().to_string();
if let ApiKeyValidation::Reject(message) =
validate_api_key_for_onboarding(&key)
{
app.status_message = Some(message);
continue;
}
match app.submit_api_key() {
Ok(saved) => {
app.push_status_toast(
format!("API key saved to {}", saved.describe()),
StatusToastLevel::Info,
Some(4_000),
);
app.status_message = None;
let _ = engine_handle.send(Op::Shutdown).await;
config.api_key = Some(key.clone());
let mut refreshed_config = config.clone();
refreshed_config.api_key = Some(key);
let engine_config = build_engine_config(app, &refreshed_config);
engine_handle = spawn_engine(engine_config, &refreshed_config);
app.offline_mode = false;
app.api_key_env_only = false;
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {
messages: app.api_messages.clone(),
system_prompt: app.system_prompt.clone(),
model: app.model.clone(),
workspace: app.workspace.clone(),
})
.await;
}
advance_onboarding_after_language(app);
}
Err(e) => {
app.status_message = Some(e.to_string());
}
}
}
OnboardingState::TrustDirectory => {}
OnboardingState::Tips => {
app.finish_onboarding();
}
OnboardingState::None => {}
},
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('1')
if app.onboarding == OnboardingState::TrustDirectory =>
{
match onboarding::mark_trusted(&app.workspace) {
Ok(_) => {
app.trust_mode = true;
app.status_message = None;
if app.onboarding_workspace_trust_gate {
app.onboarding_workspace_trust_gate = false;
app.onboarding = OnboardingState::None;
} else {
app.onboarding = OnboardingState::Tips;
}
}
Err(err) => {
app.status_message =
Some(format!("Failed to trust workspace: {err}"));
}
}
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Char('2')
if app.onboarding == OnboardingState::TrustDirectory =>
{
let _ = engine_handle.send(Op::Shutdown).await;
return Ok(());
}
KeyCode::Backspace if app.onboarding == OnboardingState::ApiKey => {
app.delete_api_key_char();
sync_api_key_validation_status(app, false);
}
KeyCode::Char('h')
if is_ctrl_h_backspace(&key)
&& app.onboarding == OnboardingState::ApiKey =>
{
app.delete_api_key_char();
sync_api_key_validation_status(app, false);
}
_ if is_paste_shortcut(&key) && app.onboarding == OnboardingState::ApiKey => {
app.paste_api_key_from_clipboard();
sync_api_key_validation_status(app, false);
}
KeyCode::Char(c)
if app.onboarding == OnboardingState::ApiKey && is_text_input_key(&key) =>
{
app.insert_api_key_char(c);
sync_api_key_validation_status(app, false);
}
_ => {}
}
continue;
}
if key.code == KeyCode::F(1) {
if app.view_stack.top_kind() == Some(ModalKind::Help) {
app.view_stack.pop();
} else {
app.view_stack.push(HelpView::new_for_locale(app.ui_locale));
}
continue;
}
if key.code == KeyCode::Char('/') && key.modifiers.contains(KeyModifiers::CONTROL) {
if app.view_stack.top_kind() == Some(ModalKind::Help) {
app.view_stack.pop();
} else {
app.view_stack.push(HelpView::new_for_locale(app.ui_locale));
}
continue;
}
if key.code == KeyCode::Char('k') && key.modifiers.contains(KeyModifiers::CONTROL) {
if app.view_stack.is_empty() && app.kill_to_end_of_line() {
continue;
}
app.view_stack
.push(CommandPaletteView::new(build_command_palette_entries(
app.ui_locale,
&app.skills_dir,
&app.workspace,
&app.mcp_config_path,
app.mcp_snapshot.as_ref(),
)));
continue;
}
if is_file_tree_toggle_shortcut(&key) {
if let Some(_state) = app.file_tree.as_mut() {
app.file_tree = None;
app.status_message = Some("File tree closed".to_string());
} else {
let state = crate::tui::file_tree::FileTreeState::new(&app.workspace);
app.file_tree = Some(state);
app.status_message = Some(
"File tree: \u{2191}/\u{2193} navigate Enter select Esc close"
.to_string(),
);
}
app.needs_redraw = true;
continue;
}
if key.code == KeyCode::Char('p')
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& app.view_stack.is_empty()
&& !app.is_loading
{
open_file_picker(app);
continue;
}
if matches!(key.code, KeyCode::Char('b') | KeyCode::Char('B'))
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& app.view_stack.is_empty()
{
open_shell_control(app);
continue;
}
if matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
&& key.modifiers.contains(KeyModifiers::ALT)
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::SUPER)
&& app.view_stack.is_empty()
{
open_context_inspector(app);
continue;
}
if !app.view_stack.is_empty() {
let events = app.view_stack.handle_key(key);
if handle_view_events(
terminal,
app,
config,
&task_manager,
&mut engine_handle,
&mut web_config_session,
events,
)
.await?
{
return Ok(());
}
continue;
}
if app.file_tree.is_some() {
match key.code {
KeyCode::Up => {
if let Some(state) = app.file_tree.as_mut() {
state.cursor_up();
}
app.needs_redraw = true;
continue;
}
KeyCode::Down => {
if let Some(state) = app.file_tree.as_mut() {
state.cursor_down();
}
app.needs_redraw = true;
continue;
}
KeyCode::Enter => {
if let Some(state) = app.file_tree.as_mut() {
if let Some(rel_path) = state.activate() {
let path_str = rel_path.to_string_lossy().to_string();
app.status_message = Some(format!("Attached @{path_str}"));
app.insert_str(&format!("@{} ", path_str));
} else {
app.needs_redraw = true;
}
}
continue;
}
KeyCode::Esc => {
app.file_tree = None;
app.status_message = Some("File tree closed".to_string());
app.needs_redraw = true;
continue;
}
_ => {}
}
}
if app.is_history_search_active() {
handle_history_search_key(app, key);
continue;
}
if matches!(key.code, KeyCode::Char('r') | KeyCode::Char('R'))
&& key.modifiers.contains(KeyModifiers::ALT)
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::SUPER)
{
app.start_history_search();
continue;
}
let now = Instant::now();
app.flush_paste_burst_if_enabled(now);
let has_ctrl_alt_or_super = super::widgets::key_hint::has_ctrl_or_alt(key.modifiers)
|| key.modifiers.contains(KeyModifiers::SUPER);
let is_plain_char = matches!(key.code, KeyCode::Char(_)) && !has_ctrl_alt_or_super;
let is_enter = matches!(key.code, KeyCode::Enter);
if !is_plain_char
&& !is_enter
&& let Some(pending) = app.flush_paste_burst_before_modified_input_if_enabled()
{
app.insert_str(&pending);
}
if (is_plain_char || is_enter) && super::paste::handle_paste_burst_key(app, &key, now) {
continue;
}
let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT);
let slash_menu_open = !slash_menu_entries.is_empty();
if slash_menu_open && app.slash_menu_selected >= slash_menu_entries.len() {
app.slash_menu_selected = slash_menu_entries.len().saturating_sub(1);
}
let mention_menu_entries =
crate::tui::file_mention::visible_mention_menu_entries(app, MENTION_MENU_LIMIT);
let mention_menu_open = !mention_menu_entries.is_empty();
if mention_menu_open && app.mention_menu_selected >= mention_menu_entries.len() {
app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
}
if !matches!(key.code, KeyCode::Esc)
&& matches!(
app.backtrack.phase,
crate::tui::backtrack::BacktrackPhase::Primed
)
{
app.backtrack.reset();
}
match key.code {
KeyCode::Enter
if app.input.is_empty()
&& app.viewport.transcript_selection.is_active()
&& open_pager_for_selection(app) =>
{
continue;
}
KeyCode::Char('l')
if key.modifiers.is_empty()
&& app.input.is_empty()
&& open_pager_for_last_message(app) =>
{
continue;
}
KeyCode::Char('v') | KeyCode::Char('V')
if details_shortcut_modifiers(key.modifiers)
&& app.input.is_empty()
&& open_tool_details_pager(app) =>
{
continue;
}
KeyCode::Char('o')
if key.modifiers == KeyModifiers::CONTROL
&& app.input.is_empty()
&& open_thinking_pager(app) =>
{
continue;
}
KeyCode::Char('t') | KeyCode::Char('T')
if key.modifiers == KeyModifiers::CONTROL =>
{
toggle_live_transcript_overlay(app);
continue;
}
KeyCode::Char('1') if key.modifiers.contains(KeyModifiers::ALT) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
app.set_sidebar_focus(SidebarFocus::Plan);
app.status_message = Some("Sidebar focus: plan".to_string());
} else {
app.set_mode(AppMode::Plan);
}
continue;
}
KeyCode::Char('2') if key.modifiers.contains(KeyModifiers::ALT) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
app.set_sidebar_focus(SidebarFocus::Todos);
app.status_message = Some("Sidebar focus: todos".to_string());
} else {
app.set_mode(AppMode::Agent);
}
continue;
}
KeyCode::Char('3') if key.modifiers.contains(KeyModifiers::ALT) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
app.set_sidebar_focus(SidebarFocus::Tasks);
app.status_message = Some("Sidebar focus: tasks".to_string());
} else {
app.set_mode(AppMode::Yolo);
}
continue;
}
KeyCode::Char('4') if key.modifiers.contains(KeyModifiers::ALT) => {
apply_alt_4_shortcut(app, key.modifiers);
continue;
}
KeyCode::Char('!') if key.modifiers.contains(KeyModifiers::ALT) => {
app.set_sidebar_focus(SidebarFocus::Plan);
app.status_message = Some("Sidebar focus: plan".to_string());
continue;
}
KeyCode::Char('@') if key.modifiers.contains(KeyModifiers::ALT) => {
app.set_sidebar_focus(SidebarFocus::Todos);
app.status_message = Some("Sidebar focus: todos".to_string());
continue;
}
KeyCode::Char('#') if key.modifiers.contains(KeyModifiers::ALT) => {
app.set_sidebar_focus(SidebarFocus::Tasks);
app.status_message = Some("Sidebar focus: tasks".to_string());
continue;
}
KeyCode::Char('$') if key.modifiers.contains(KeyModifiers::ALT) => {
app.set_sidebar_focus(SidebarFocus::Agents);
app.status_message = Some("Sidebar focus: agents".to_string());
continue;
}
KeyCode::Char('%') if key.modifiers.contains(KeyModifiers::ALT) => {
app.set_sidebar_focus(SidebarFocus::Context);
app.status_message = Some("Sidebar focus: context".to_string());
continue;
}
KeyCode::Char(')') if key.modifiers.contains(KeyModifiers::ALT) => {
app.set_sidebar_focus(SidebarFocus::Auto);
app.status_message = Some("Sidebar focus: auto".to_string());
continue;
}
KeyCode::Char('0') if key.modifiers.contains(KeyModifiers::ALT) => {
app.set_sidebar_focus(SidebarFocus::Auto);
app.status_message = Some("Sidebar focus: auto".to_string());
continue;
}
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.view_stack.push(SessionPickerView::new());
continue;
}
KeyCode::Char('c') | KeyCode::Char('C') if is_copy_shortcut(&key) => {
copy_active_selection(app);
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if app.is_loading {
engine_handle.cancel();
app.is_loading = false;
app.streaming_state.reset();
app.runtime_turn_status = None;
app.status_message = Some("Request cancelled".to_string());
app.disarm_quit();
} else if app.quit_is_armed() {
let _ = engine_handle.send(Op::Shutdown).await;
return Ok(());
} else {
app.arm_quit();
}
}
KeyCode::Char('d')
if key.modifiers.contains(KeyModifiers::CONTROL) && app.input.is_empty() =>
{
let _ = engine_handle.send(Op::Shutdown).await;
return Ok(());
}
KeyCode::Esc
if app.composer.vim_enabled
&& app.composer.vim_mode != crate::tui::app::VimMode::Normal =>
{
app.vim_enter_normal();
continue;
}
KeyCode::Esc if app.clear_composer_attachment_selection() => {
continue;
}
KeyCode::Esc if mention_menu_open => {
app.mention_menu_hidden = true;
app.mention_menu_selected = 0;
}
KeyCode::Esc => match next_escape_action(app, slash_menu_open) {
EscapeAction::CloseSlashMenu => {
app.backtrack.reset();
app.close_slash_menu();
}
EscapeAction::CancelRequest => {
app.backtrack.reset();
engine_handle.cancel();
app.is_loading = false;
app.streaming_state.reset();
app.runtime_turn_status = None;
app.finalize_active_cell_as_interrupted();
app.finalize_streaming_assistant_as_interrupted();
app.status_message = Some("Request cancelled".to_string());
}
EscapeAction::DiscardQueuedDraft => {
app.backtrack.reset();
app.queued_draft = None;
app.status_message = Some("Stopped editing queued message".to_string());
}
EscapeAction::ClearInput => {
app.backtrack.reset();
app.edit_in_progress = false;
app.clear_input_recoverable();
}
EscapeAction::Noop => {
if app.is_loading
|| app.view_stack.top_kind() == Some(ModalKind::LiveTranscript)
{
continue;
}
let total = count_user_history_cells(app);
match app.backtrack.handle_esc(total) {
crate::tui::backtrack::EscEffect::None => {}
crate::tui::backtrack::EscEffect::Prime => {
app.status_message =
Some("Press Esc again to backtrack".to_string());
app.needs_redraw = true;
}
crate::tui::backtrack::EscEffect::Cancel => {
app.status_message = Some("Backtrack canceled".to_string());
app.needs_redraw = true;
}
crate::tui::backtrack::EscEffect::OpenOverlay => {
open_backtrack_overlay(app);
}
}
}
},
KeyCode::Up if key.modifiers.contains(KeyModifiers::SUPER) => {
app.scroll_up(app.viewport.last_transcript_visible.max(3));
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
app.scroll_up(3);
}
KeyCode::Up
if key.modifiers.is_empty()
&& mention_menu_open
&& app.mention_menu_selected > 0 =>
{
app.mention_menu_selected = app.mention_menu_selected.saturating_sub(1);
}
KeyCode::Up
if key.modifiers.is_empty()
&& slash_menu_open
&& app.slash_menu_selected > 0 =>
{
app.slash_menu_selected = app.slash_menu_selected.saturating_sub(1);
}
KeyCode::Up
if key.modifiers.is_empty()
&& app.selected_composer_attachment_index().is_some() =>
{
let _ = app.select_previous_composer_attachment();
}
KeyCode::Up
if key.modifiers.is_empty()
&& app.cursor_position == 0
&& !mention_menu_open
&& !slash_menu_open
&& app.composer_attachment_count() > 0 =>
{
let _ = app.select_previous_composer_attachment();
continue;
}
KeyCode::Up
if key.modifiers.is_empty()
&& app.input.is_empty()
&& app.cursor_position == 0
&& app.queued_draft.is_none()
&& !app.queued_messages.is_empty()
&& !mention_menu_open
&& !slash_menu_open
&& app.selected_composer_attachment_index().is_none() =>
{
let _ = app.pop_last_queued_into_draft();
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::SUPER) => {
app.scroll_down(app.viewport.last_transcript_visible.max(3));
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) => {
app.scroll_down(3);
}
KeyCode::Down if key.modifiers.is_empty() && mention_menu_open => {
app.mention_menu_selected = (app.mention_menu_selected + 1)
.min(mention_menu_entries.len().saturating_sub(1));
}
KeyCode::Down if key.modifiers.is_empty() && slash_menu_open => {
app.slash_menu_selected = (app.slash_menu_selected + 1)
.min(slash_menu_entries.len().saturating_sub(1));
}
KeyCode::Down
if key.modifiers.is_empty()
&& app.selected_composer_attachment_index().is_some() =>
{
let _ = app.select_next_composer_attachment();
}
KeyCode::PageUp => {
let page = app.viewport.last_transcript_visible.max(1);
app.scroll_up(page);
}
KeyCode::PageDown => {
let page = app.viewport.last_transcript_visible.max(1);
app.scroll_down(page);
}
KeyCode::Tab => {
if mention_menu_open
&& crate::tui::file_mention::apply_mention_menu_selection(
app,
&mention_menu_entries,
)
{
continue;
}
if slash_menu_open && apply_slash_menu_selection(app, &slash_menu_entries, true)
{
continue;
}
if try_autocomplete_slash_command(app) {
continue;
}
if crate::tui::file_mention::try_autocomplete_file_mention(app) {
continue;
}
if app.is_loading && queue_current_draft_for_next_turn(app) {
continue;
}
let prior_model = app.model.clone();
app.cycle_mode();
if app.model != prior_model {
let _ = engine_handle
.send(Op::SetModel {
model: app.model.clone(),
})
.await;
}
}
KeyCode::BackTab => {
app.cycle_effort();
}
KeyCode::Char('g')
if key.modifiers.is_empty() && app.input.is_empty() && !slash_menu_open =>
{
if let Some(anchor) =
TranscriptScroll::anchor_for(app.viewport.transcript_cache.line_meta(), 0)
{
app.viewport.transcript_scroll = anchor;
}
}
KeyCode::Char('G')
if (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT)
&& app.input.is_empty()
&& !slash_menu_open =>
{
app.scroll_to_bottom();
}
KeyCode::Char('[')
if key.modifiers.is_empty()
&& app.input.is_empty()
&& !slash_menu_open
&& !jump_to_adjacent_tool_cell(app, SearchDirection::Backward) =>
{
app.status_message = Some("No previous tool output".to_string());
}
KeyCode::Char(']')
if key.modifiers.is_empty()
&& app.input.is_empty()
&& !slash_menu_open
&& !jump_to_adjacent_tool_cell(app, SearchDirection::Forward) =>
{
app.status_message = Some("No next tool output".to_string());
}
KeyCode::Char('?')
if (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT)
&& app.input.is_empty()
&& !slash_menu_open =>
{
if app.view_stack.top_kind() != Some(ModalKind::Help) {
app.view_stack.push(HelpView::new_for_locale(app.ui_locale));
}
continue;
}
_ if is_composer_newline_key(key) => {
app.insert_char('\n');
}
KeyCode::Enter
if mention_menu_open
&& crate::tui::file_mention::apply_mention_menu_selection(
app,
&mention_menu_entries,
) =>
{
continue;
}
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(input) = app.submit_input() {
if input.starts_with('/') {
if execute_command_input(
terminal,
app,
&mut engine_handle,
&task_manager,
config,
&mut web_config_session,
&input,
)
.await?
{
return Ok(());
}
} else {
let queued = if let Some(mut draft) = app.queued_draft.take() {
draft.display = input;
draft
} else {
build_queued_message(app, input)
};
if let Err(err) =
steer_user_message(app, &engine_handle, queued.clone()).await
{
app.queue_message(queued);
app.status_message = Some(format!(
"Steer failed ({err}); queued {} message(s)",
app.queued_message_count()
));
}
}
}
}
KeyCode::Enter => {
if slash_menu_open
&& !slash_menu_entries.is_empty()
&& app.input.starts_with('/')
&& apply_slash_menu_selection(app, &slash_menu_entries, false)
{
app.close_slash_menu();
}
if let Some(input) = app.handle_composer_enter() {
if handle_plan_choice(app, config, &engine_handle, &input).await? {
continue;
}
if config.memory_enabled() && is_memory_quick_add(&input) {
handle_memory_quick_add(app, &input, config);
continue;
}
if input.starts_with('/') {
if execute_command_input(
terminal,
app,
&mut engine_handle,
&task_manager,
config,
&mut web_config_session,
&input,
)
.await?
{
return Ok(());
}
} else {
let queued = if let Some(mut draft) = app.queued_draft.take() {
draft.display = input;
draft
} else {
build_queued_message(app, input)
};
if app.edit_in_progress {
crate::commands::execute("/undo", app);
app.edit_in_progress = false;
let _ = engine_handle
.send(Op::SyncSession {
messages: app.api_messages.clone(),
system_prompt: app.system_prompt.clone(),
model: app.model.clone(),
workspace: app.workspace.clone(),
})
.await;
}
submit_or_steer_message(app, config, &engine_handle, queued).await?;
}
}
}
KeyCode::Backspace
if key.modifiers.contains(KeyModifiers::SUPER)
&& !app.remove_selected_composer_attachment() =>
{
app.delete_to_start_of_line();
}
KeyCode::Backspace if key.modifiers.contains(KeyModifiers::SUPER) => {}
KeyCode::Backspace
if key.modifiers.contains(KeyModifiers::ALT)
&& !app.remove_selected_composer_attachment() =>
{
app.delete_word_backward();
}
KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => {}
KeyCode::Backspace
if key.modifiers.contains(KeyModifiers::CONTROL)
&& !app.remove_selected_composer_attachment() =>
{
app.delete_word_backward();
}
KeyCode::Backspace if key.modifiers.contains(KeyModifiers::CONTROL) => {}
KeyCode::Delete
if key.modifiers.contains(KeyModifiers::ALT)
&& !app.remove_selected_composer_attachment() =>
{
app.delete_word_forward();
}
KeyCode::Delete if key.modifiers.contains(KeyModifiers::ALT) => {}
KeyCode::Delete
if key.modifiers.contains(KeyModifiers::CONTROL)
&& !app.remove_selected_composer_attachment() =>
{
app.delete_word_forward();
}
KeyCode::Delete if key.modifiers.contains(KeyModifiers::CONTROL) => {}
KeyCode::Backspace if !app.remove_selected_composer_attachment() => {
app.delete_char();
}
KeyCode::Backspace => {}
KeyCode::Char('h')
if is_ctrl_h_backspace(&key) && !app.remove_selected_composer_attachment() =>
{
app.delete_char();
}
KeyCode::Char('h') if is_ctrl_h_backspace(&key) => {}
KeyCode::Delete if !app.remove_selected_composer_attachment() => {
app.delete_char_forward();
}
KeyCode::Delete => {}
KeyCode::Left => {
app.move_cursor_left();
}
KeyCode::Right => {
app.move_cursor_right();
}
KeyCode::Home if key.modifiers.is_empty() => {
if let Some(anchor) =
TranscriptScroll::anchor_for(app.viewport.transcript_cache.line_meta(), 0)
{
app.viewport.transcript_scroll = anchor;
}
}
KeyCode::End if key.modifiers.is_empty() => {
app.scroll_to_bottom();
}
KeyCode::Home | KeyCode::Char('a')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
app.move_cursor_start();
}
KeyCode::End => {
app.move_cursor_end();
}
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.move_cursor_end();
}
KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let seed = app.input.clone();
match super::external_editor::spawn_editor_for_input(
terminal,
app.use_alt_screen,
app.use_mouse_capture,
app.use_bracketed_paste,
&seed,
) {
Ok(super::external_editor::EditorOutcome::Edited(new)) => {
app.input = new;
app.move_cursor_end();
let editor = std::env::var("VISUAL")
.ok()
.filter(|s| !s.trim().is_empty())
.or_else(|| {
std::env::var("EDITOR")
.ok()
.filter(|s| !s.trim().is_empty())
})
.unwrap_or_else(|| "vi".to_string());
app.status_message = Some(format!("Edited in {editor}"));
}
Ok(super::external_editor::EditorOutcome::Unchanged) => {
app.status_message = Some("Editor closed (no changes)".to_string());
}
Ok(super::external_editor::EditorOutcome::Cancelled) => {
app.status_message = Some("Editor cancelled".to_string());
}
Err(err) => {
app.status_message = Some(format!("Editor error: {err}"));
}
}
app.needs_redraw = true;
}
KeyCode::Up => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
app.history_up();
} else if should_scroll_with_arrows(app) {
app.scroll_up(1);
} else {
app.history_up();
}
}
KeyCode::Down => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
app.history_down();
} else if should_scroll_with_arrows(app) {
app.scroll_down(1);
} else {
app.history_down();
}
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.clear_input_recoverable();
}
KeyCode::Char('w') | KeyCode::Char('W')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
app.delete_word_backward();
}
KeyCode::Char('s') | KeyCode::Char('S')
if key.modifiers == KeyModifiers::CONTROL && !app.input.is_empty() =>
{
crate::composer_stash::push_stash(&app.input);
app.clear_input_recoverable();
app.push_status_toast(
"Draft stashed — `/stash pop` to restore",
StatusToastLevel::Info,
Some(3_000),
);
}
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if app.input.is_empty() && app.view_stack.is_empty() {
if copy_focused_cell(app) {
app.push_status_toast(
"Copied to clipboard",
StatusToastLevel::Info,
Some(2_000),
);
} else {
app.status_message = Some("No transcript cell to copy".to_string());
}
} else {
app.yank();
}
}
KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let new_mode = match app.mode {
AppMode::Plan => AppMode::Agent,
_ => AppMode::Plan,
};
app.set_mode(new_mode);
}
_ if is_paste_shortcut(&key) => {
app.paste_from_clipboard();
}
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::ALT) => {
app.set_mode(AppMode::Agent);
continue;
}
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::ALT) => {
app.set_mode(AppMode::Yolo);
continue;
}
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::ALT) => {
app.set_mode(AppMode::Plan);
continue;
}
KeyCode::Char('A') if key.modifiers.contains(KeyModifiers::ALT) => {
app.set_mode(AppMode::Agent);
continue;
}
KeyCode::Char('Y') if key.modifiers.contains(KeyModifiers::ALT) => {
app.set_mode(AppMode::Yolo);
continue;
}
KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::ALT) => {
app.set_mode(AppMode::Plan);
continue;
}
KeyCode::Char('v') | KeyCode::Char('V')
if key.modifiers.contains(KeyModifiers::ALT) =>
{
open_tool_details_pager(app);
continue;
}
KeyCode::Char(c)
if app.vim_is_normal_mode()
&& key.modifiers.is_empty()
&& !slash_menu_open
&& !mention_menu_open
&& app.view_stack.is_empty() =>
{
handle_vim_normal_key(app, c);
continue;
}
KeyCode::Char(_)
if app.vim_is_visual_mode()
&& key.modifiers.is_empty()
&& app.view_stack.is_empty() =>
{
}
KeyCode::Char(c) => {
app.insert_char(c);
}
_ => {}
}
if !is_plain_char && !is_enter {
app.paste_burst.clear_window_after_non_char();
}
}
}
}
fn handle_vim_normal_key(app: &mut App, c: char) {
use crate::tui::app::VimMode;
if app.composer.vim_pending_d {
app.composer.vim_pending_d = false;
if c == 'd' {
app.vim_delete_line();
}
return;
}
match c {
'h' => {
app.move_cursor_left();
}
'l' => {
app.move_cursor_right();
}
'j' => {
app.vim_move_down();
}
'k' => {
app.vim_move_up();
}
'w' => {
app.vim_move_word_forward();
}
'b' => {
app.vim_move_word_backward();
}
'0' => {
app.vim_move_line_start();
}
'$' => {
app.vim_move_line_end();
}
'x' => {
app.vim_delete_char_under_cursor();
}
'd' => {
app.composer.vim_pending_d = true;
}
'i' => {
app.vim_enter_insert();
}
'a' => {
app.vim_enter_append();
}
'o' => {
app.vim_open_line_below();
}
'v' => {
app.composer.vim_mode = VimMode::Visual;
app.needs_redraw = true;
}
'G' => {
app.move_cursor_end();
}
_ => {
}
}
}
fn apply_alt_4_shortcut(app: &mut App, _modifiers: KeyModifiers) {
app.set_sidebar_focus(SidebarFocus::Agents);
app.status_message = Some("Sidebar focus: agents".to_string());
}
async fn fetch_available_models(config: &Config) -> Result<Vec<String>> {
use crate::client::DeepSeekClient;
let client = DeepSeekClient::new(config)?;
let models = tokio::time::timeout(Duration::from_secs(20), client.list_models()).await??;
let mut ids = models.into_iter().map(|model| model.id).collect::<Vec<_>>();
ids.sort();
ids.dedup();
Ok(ids)
}
fn format_available_models_message(current_model: &str, models: &[String]) -> String {
let mut lines = vec![format!("Available models ({})", models.len())];
for model in models {
if model == current_model {
lines.push(format!("* {model} (current)"));
} else {
lines.push(format!(" {model}"));
}
}
lines.join("\n")
}
fn build_session_snapshot(app: &App, manager: &SessionManager) -> SavedSession {
if let Some(ref existing_id) = app.current_session_id
&& let Ok(existing) = manager.load_session(existing_id)
{
let mut updated = update_session(
existing,
&app.api_messages,
u64::from(app.session.total_tokens),
app.system_prompt.as_ref(),
);
updated.metadata.mode = Some(app.mode.as_setting().to_string());
updated.context_references = app.session_context_references.clone();
updated
} else {
let mut session = create_saved_session_with_mode(
&app.api_messages,
&app.model,
&app.workspace,
u64::from(app.session.total_tokens),
app.system_prompt.as_ref(),
Some(app.mode.as_setting()),
);
session.context_references = app.session_context_references.clone();
session
}
}
fn queued_ui_to_session(msg: &QueuedMessage) -> QueuedSessionMessage {
QueuedSessionMessage {
display: msg.display.clone(),
skill_instruction: msg.skill_instruction.clone(),
}
}
fn queued_session_to_ui(msg: QueuedSessionMessage) -> QueuedMessage {
QueuedMessage {
display: msg.display,
skill_instruction: msg.skill_instruction,
}
}
pub(crate) fn apply_engine_error_to_app(
app: &mut App,
envelope: crate::error_taxonomy::ErrorEnvelope,
) {
let recoverable = envelope.recoverable;
let message = envelope.message.clone();
let severity = envelope.severity;
finalize_current_streaming_thinking(app);
app.streaming_state.reset();
app.streaming_message_index = None;
app.streaming_thinking_active_entry = None;
if app
.hooks
.has_hooks_for_event(crate::hooks::HookEvent::OnError)
{
let context = app.base_hook_context().with_error(&message);
let _ = app.execute_hooks(crate::hooks::HookEvent::OnError, &context);
}
app.add_message(HistoryCell::Error {
message: message.clone(),
severity,
});
app.is_loading = false;
if matches!(
envelope.category,
crate::error_taxonomy::ErrorCategory::Authentication
) && app.api_key_env_only
{
app.offline_mode = true;
app.onboarding_needs_api_key = true;
app.onboarding = OnboardingState::ApiKey;
app.status_message = Some(
"The API key from DEEPSEEK_API_KEY was rejected. Paste a valid key to save it to ~/.deepseek/config.toml, or update the environment variable.".to_string(),
);
return;
}
if recoverable {
app.status_message = Some(format!("Connection interrupted: {message}"));
} else {
app.offline_mode = true;
app.status_message = Some(format!(
"Engine error; queued messages stay pending: {message}"
));
}
}
fn persist_offline_queue_state(app: &App) {
if let Ok(manager) = SessionManager::default_location() {
if app.queued_messages.is_empty() && app.queued_draft.is_none() {
let _ = manager.clear_offline_queue_state();
return;
}
let state = OfflineQueueState {
messages: app
.queued_messages
.iter()
.map(queued_ui_to_session)
.collect(),
draft: app.queued_draft.as_ref().map(queued_ui_to_session),
..OfflineQueueState::default()
};
let _ = manager.save_offline_queue_state(&state, app.current_session_id.as_deref());
}
}
fn sanitize_stream_chunk(chunk: &str) -> String {
chunk
.chars()
.filter(|c| *c == '\n' || *c == '\t' || !c.is_control())
.collect()
}
fn notification_settings(
config: &Config,
) -> Option<(crate::tui::notifications::Method, Duration, bool)> {
let notif = config.notifications_config();
let method = match notif.method {
crate::config::NotificationMethod::Auto => crate::tui::notifications::Method::Auto,
crate::config::NotificationMethod::Osc9 => crate::tui::notifications::Method::Osc9,
crate::config::NotificationMethod::Bel => crate::tui::notifications::Method::Bel,
crate::config::NotificationMethod::Off => crate::tui::notifications::Method::Off,
};
if let Some(condition) = config
.tui
.as_ref()
.and_then(|tui| tui.notification_condition)
{
match condition {
crate::config::NotificationCondition::Always => {
return Some((method, Duration::ZERO, notif.include_summary));
}
crate::config::NotificationCondition::Never => return None,
}
}
Some((
method,
Duration::from_secs(notif.threshold_secs),
notif.include_summary,
))
}
fn completed_turn_notification_message(
app: &App,
current_streaming_text: &str,
include_summary: bool,
turn_elapsed: Duration,
turn_cost: Option<crate::pricing::CostEstimate>,
) -> String {
let mut msg = notification_text_summary(current_streaming_text)
.or_else(|| latest_assistant_notification_text(&app.api_messages))
.unwrap_or_else(|| "deepseek: turn complete".to_string());
if include_summary {
let human = crate::tui::notifications::humanize_duration(turn_elapsed);
let summary = match turn_cost {
Some(c) => {
let cost = crate::pricing::format_cost_estimate(c, app.cost_currency);
format!("deepseek: turn complete ({human}, {cost})")
}
None => format!("deepseek: turn complete ({human})"),
};
if msg == "deepseek: turn complete" {
msg = summary;
} else {
msg.push('\n');
msg.push_str(&summary);
}
}
msg
}
fn subagent_completion_notification_message(
id: &str,
result: &str,
include_summary: bool,
elapsed: Duration,
) -> String {
let result_line = result
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with("<deepseek:subagent.done>"));
let mut msg = result_line
.and_then(notification_text_summary)
.map(|summary| format!("sub-agent {id}: {summary}"))
.unwrap_or_else(|| format!("deepseek: sub-agent {id} complete"));
if include_summary {
let human = crate::tui::notifications::humanize_duration(elapsed);
msg.push('\n');
msg.push_str(&format!("deepseek: sub-agent complete ({human})"));
}
msg
}
fn latest_assistant_notification_text(messages: &[Message]) -> Option<String> {
messages
.iter()
.rev()
.find(|message| message.role == "assistant")
.and_then(|message| {
let text = message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text, .. } => Some(text.as_str()),
ContentBlock::Thinking { .. }
| ContentBlock::ToolUse { .. }
| ContentBlock::ToolResult { .. }
| ContentBlock::ServerToolUse { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. } => None,
})
.collect::<Vec<_>>()
.join("\n");
notification_text_summary(&text)
})
}
fn notification_text_summary(text: &str) -> Option<String> {
const MAX_CHARS: usize = 360;
let sanitized = sanitize_stream_chunk(text);
let collapsed = sanitized
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n");
let trimmed = collapsed.trim();
if trimmed.is_empty() {
return None;
}
if let Some((idx, _)) = trimmed.char_indices().nth(MAX_CHARS) {
let mut s = String::with_capacity(idx + 3);
s.push_str(&trimmed[..idx]);
s.push_str("...");
Some(s)
} else {
Some(trimmed.to_string())
}
}
fn ensure_streaming_assistant_history_cell(app: &mut App) -> usize {
if let Some(index) = app.streaming_message_index {
return index;
}
app.add_message(HistoryCell::Assistant {
content: String::new(),
streaming: true,
});
let index = app.history.len().saturating_sub(1);
app.streaming_message_index = Some(index);
index
}
fn append_streaming_text(app: &mut App, index: usize, text: &str) {
if text.is_empty() {
return;
}
if let Some(HistoryCell::Assistant { content, .. }) = app.history.get_mut(index) {
content.push_str(text);
app.bump_history_cell(index);
}
}
fn ensure_streaming_thinking_active_entry(app: &mut App) -> usize {
if let Some(idx) = app.streaming_thinking_active_entry {
return idx;
}
if app.active_cell.is_none() {
app.active_cell = Some(ActiveCell::new());
}
let active = app.active_cell.as_mut().expect("active_cell just ensured");
let entry_idx = active.push_thinking(HistoryCell::Thinking {
content: String::new(),
streaming: true,
duration_secs: None,
});
app.streaming_thinking_active_entry = Some(entry_idx);
app.bump_active_cell_revision();
entry_idx
}
fn append_streaming_thinking(app: &mut App, entry_idx: usize, text: &str) {
if text.is_empty() {
return;
}
let mutated = if let Some(active) = app.active_cell.as_mut()
&& let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(entry_idx)
{
content.push_str(text);
true
} else {
false
};
if mutated {
app.bump_active_cell_revision();
}
}
fn start_streaming_thinking_block(app: &mut App) -> bool {
let finalized_previous = if app.streaming_thinking_active_entry.is_some() {
let finalized = finalize_current_streaming_thinking(app);
stash_reasoning_buffer_into_last_reasoning(app);
finalized
} else {
false
};
app.reasoning_buffer.clear();
app.reasoning_header = None;
app.thinking_started_at = Some(Instant::now());
app.streaming_state.reset();
app.streaming_state.start_thinking(0, None);
let _ = ensure_streaming_thinking_active_entry(app);
finalized_previous
}
fn finalize_current_streaming_thinking(app: &mut App) -> bool {
let duration = app
.thinking_started_at
.take()
.map(|t| t.elapsed().as_secs_f32());
let remaining = app.streaming_state.finalize_block_text(0);
finalize_streaming_thinking_active_entry(app, duration, &remaining)
}
fn stash_reasoning_buffer_into_last_reasoning(app: &mut App) {
if app.reasoning_buffer.is_empty() {
return;
}
if let Some(existing) = app.last_reasoning.as_mut()
&& !existing.is_empty()
{
if !existing.ends_with('\n') {
existing.push('\n');
}
existing.push_str(&app.reasoning_buffer);
} else {
app.last_reasoning = Some(app.reasoning_buffer.clone());
}
app.reasoning_buffer.clear();
}
fn finalize_streaming_thinking_active_entry(
app: &mut App,
duration: Option<f32>,
remaining: &str,
) -> bool {
let Some(entry_idx) = app.streaming_thinking_active_entry.take() else {
return false;
};
if !remaining.is_empty() {
append_streaming_thinking(app, entry_idx, remaining);
}
if let Some(active) = app.active_cell.as_mut()
&& let Some(HistoryCell::Thinking {
streaming,
duration_secs,
..
}) = active.entry_mut(entry_idx)
{
*streaming = false;
*duration_secs = duration;
}
app.bump_active_cell_revision();
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EscapeAction {
CloseSlashMenu,
CancelRequest,
DiscardQueuedDraft,
ClearInput,
Noop,
}
fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeAction {
if slash_menu_open {
EscapeAction::CloseSlashMenu
} else if app.is_loading {
EscapeAction::CancelRequest
} else if app.queued_draft.is_some() && app.input.is_empty() {
EscapeAction::DiscardQueuedDraft
} else if !app.input.is_empty() {
EscapeAction::ClearInput
} else {
EscapeAction::Noop
}
}
fn is_composer_newline_key(key: KeyEvent) -> bool {
match key.code {
KeyCode::Char('j') => key.modifiers.contains(KeyModifiers::CONTROL),
KeyCode::Enter => {
key.modifiers.contains(KeyModifiers::ALT)
|| (key.modifiers.contains(KeyModifiers::SHIFT)
&& !key.modifiers.contains(KeyModifiers::CONTROL))
}
_ => false,
}
}
fn handle_history_search_key(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Enter => {
let _ = app.accept_history_search();
}
KeyCode::Esc => {
app.cancel_history_search();
}
KeyCode::Char('c') | KeyCode::Char('C')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
app.cancel_history_search();
}
KeyCode::Backspace => {
app.history_search_backspace();
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
while app
.history_search_query()
.is_some_and(|query| !query.is_empty())
{
app.history_search_backspace();
}
}
KeyCode::Up => {
app.history_search_select_previous();
}
KeyCode::Down => {
app.history_search_select_next();
}
KeyCode::Char(ch)
if key.modifiers.is_empty()
|| key.modifiers == KeyModifiers::SHIFT
|| key.modifiers == KeyModifiers::NONE =>
{
app.history_search_insert_char(ch);
}
_ => {}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ApiKeyValidation {
Accept { warning: Option<String> },
Reject(String),
}
fn validate_api_key_for_onboarding(api_key: &str) -> ApiKeyValidation {
let trimmed = api_key.trim();
if trimmed.is_empty() {
return ApiKeyValidation::Reject("API key cannot be empty.".to_string());
}
if trimmed.contains(char::is_whitespace) {
return ApiKeyValidation::Reject(
"API key appears malformed (contains whitespace).".to_string(),
);
}
if trimmed.len() < 16 {
return ApiKeyValidation::Accept {
warning: Some(
"API key looks short. Double-check it, but unusual formats are allowed."
.to_string(),
),
};
}
if !trimmed.contains('-') {
return ApiKeyValidation::Accept {
warning: Some(
"API key format looks unusual. Check that the full key was copied.".to_string(),
),
};
}
ApiKeyValidation::Accept { warning: None }
}
fn advance_onboarding_from_welcome(app: &mut App) {
app.status_message = None;
app.onboarding = OnboardingState::Language;
}
fn advance_onboarding_after_language(app: &mut App) {
app.status_message = None;
if app.onboarding_needs_api_key {
app.onboarding = OnboardingState::ApiKey;
} else if !app.trust_mode && onboarding::needs_trust(&app.workspace) {
app.onboarding = OnboardingState::TrustDirectory;
} else {
app.onboarding = OnboardingState::Tips;
}
}
fn sync_api_key_validation_status(app: &mut App, show_empty_error: bool) {
if app.api_key_input.trim().is_empty() && !show_empty_error {
app.status_message = None;
return;
}
match validate_api_key_for_onboarding(&app.api_key_input) {
ApiKeyValidation::Accept { warning } => {
app.status_message = warning;
}
ApiKeyValidation::Reject(message) => {
app.status_message = Some(message);
}
}
}
fn build_queued_message(app: &mut App, input: String) -> QueuedMessage {
let skill_instruction = app.active_skill.take();
QueuedMessage::new(input, skill_instruction)
}
fn queue_current_draft_for_next_turn(app: &mut App) -> bool {
let Some(input) = app.submit_input() else {
return false;
};
let queued = if let Some(mut draft) = app.queued_draft.take() {
draft.display = input;
draft
} else {
build_queued_message(app, input)
};
app.queue_message(queued);
app.status_message = Some(format!(
"{} queued — ↑ to edit, /queue list",
app.queued_message_count()
));
true
}
fn queued_message_content_for_app(
app: &App,
message: &QueuedMessage,
cwd: Option<PathBuf>,
) -> String {
let user_request = crate::tui::file_mention::user_request_with_file_mentions(
&message.display,
&app.workspace,
cwd,
);
if let Some(skill_instruction) = message.skill_instruction.as_ref() {
format!("{skill_instruction}\n\n---\n\nUser request: {user_request}")
} else {
user_request
}
}
async fn dispatch_user_message(
app: &mut App,
config: &Config,
engine_handle: &EngineHandle,
message: QueuedMessage,
) -> Result<()> {
if app
.hooks
.has_hooks_for_event(crate::hooks::HookEvent::MessageSubmit)
{
let context = app.base_hook_context().with_message(&message.display);
let _ = app.execute_hooks(crate::hooks::HookEvent::MessageSubmit, &context);
}
app.is_loading = true;
app.last_send_at = Some(Instant::now());
let cwd = std::env::current_dir().ok();
let references = crate::tui::file_mention::context_references_from_input(
&message.display,
&app.workspace,
cwd.clone(),
);
let content = queued_message_content_for_app(app, &message, cwd);
let message_index = app.api_messages.len();
app.system_prompt = Some(
prompts::system_prompt_for_mode_with_context_skills_and_session(
app.mode,
&app.workspace,
None,
None,
None,
prompts::PromptSessionContext {
user_memory_block: None,
goal_objective: app.goal.goal_objective.as_deref(),
locale_tag: app.ui_locale.tag(),
},
),
);
app.add_message(HistoryCell::User {
content: message.display.clone(),
});
let history_cell = app.history.len().saturating_sub(1);
app.record_context_references(history_cell, message_index, references);
app.scroll_to_bottom();
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: content.clone(),
cache_control: None,
}],
});
maybe_warn_context_pressure(app);
if should_auto_compact_before_send(app) {
app.status_message = Some("Context critical; compacting before send...".to_string());
let _ = engine_handle.send(Op::CompactContext).await;
}
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_prompt_cache_hit_tokens = None;
app.session.last_prompt_cache_miss_tokens = None;
app.session.last_reasoning_replay_tokens = None;
if let Ok(manager) = SessionManager::default_location() {
let session = build_session_snapshot(app, &manager);
persistence_actor::persist(PersistRequest::Checkpoint(session));
}
let auto_selection = if should_resolve_auto_model_selection(app) {
Some(resolve_auto_model_selection(app, config, &message, &content).await)
} else {
None
};
let effective_model = if app.auto_model {
auto_selection
.as_ref()
.map(|selection| selection.model.clone())
.unwrap_or_else(|| commands::auto_model_heuristic(&message.display, &app.model))
} else {
app.model.clone()
};
let auto_controls_reasoning = app.auto_model || app.reasoning_effort == ReasoningEffort::Auto;
let effective_reasoning_effort = if auto_controls_reasoning {
let effort = auto_selection
.as_ref()
.and_then(|selection| selection.reasoning_effort)
.unwrap_or_else(|| {
normalize_auto_routed_effort(crate::auto_reasoning::select(false, &message.display))
});
app.last_effective_reasoning_effort = Some(effort);
Some(effort.as_setting().to_string())
} else {
app.last_effective_reasoning_effort = None;
app.reasoning_effort.api_value().map(str::to_string)
};
if let Some(selection) = auto_selection.as_ref() {
if app.auto_model {
app.last_effective_model = Some(effective_model.clone());
let mut status = format!(
"Auto model selected: {effective_model} via {}",
selection.source.label()
);
if let Some(effort) = app.last_effective_reasoning_effort {
status.push_str(&format!("; thinking auto: {}", effort.as_setting()));
}
app.status_message = Some(status);
}
} else {
app.last_effective_model = None;
}
if let Err(err) = engine_handle
.send(Op::SendMessage {
content,
mode: app.mode,
model: effective_model,
goal_objective: app.goal.goal_objective.clone(),
reasoning_effort: effective_reasoning_effort,
reasoning_effort_auto: auto_controls_reasoning,
auto_model: app.auto_model,
allow_shell: app.allow_shell,
trust_mode: app.trust_mode,
auto_approve: app.mode == AppMode::Yolo,
approval_mode: app.approval_mode,
})
.await
{
app.is_loading = false;
app.last_send_at = None;
return Err(err);
}
Ok(())
}
fn should_resolve_auto_model_selection(app: &App) -> bool {
app.auto_model
}
async fn resolve_auto_model_selection(
app: &App,
config: &Config,
message: &QueuedMessage,
latest_content: &str,
) -> commands::AutoRouteSelection {
let latest_request = if latest_content.trim().is_empty() {
message.display.as_str()
} else {
latest_content
};
commands::resolve_auto_route_with_flash(
config,
latest_request,
&recent_auto_router_context(&app.api_messages),
if app.auto_model { "auto" } else { "fixed" },
app.reasoning_effort.as_setting(),
)
.await
}
fn normalize_auto_routed_effort(effort: ReasoningEffort) -> ReasoningEffort {
commands::normalize_auto_route_effort(effort)
}
fn recent_auto_router_context(messages: &[Message]) -> String {
let mut rows = Vec::new();
for message in messages.iter().rev().skip(1) {
if rows.len() >= 6 {
break;
}
let text = content_blocks_text(&message.content);
let text = text.trim();
if text.is_empty() {
continue;
}
rows.push(format!(
"{}: {}",
message.role,
truncate_for_auto_router(text, 900)
));
}
rows.reverse();
if rows.is_empty() {
"No prior context.".to_string()
} else {
rows.join("\n")
}
}
fn content_blocks_text(blocks: &[ContentBlock]) -> String {
let mut out = String::new();
for block in blocks {
match block {
ContentBlock::Text { text, .. } => {
append_router_text(&mut out, text);
}
ContentBlock::Thinking { thinking } => {
append_router_text(&mut out, thinking);
}
ContentBlock::ToolUse { name, .. } => {
append_router_text(&mut out, &format!("[tool call: {name}]"));
}
ContentBlock::ToolResult { content, .. } => {
append_router_text(&mut out, &format!("[tool result] {content}"));
}
_ => {}
}
}
out
}
fn append_router_text(out: &mut String, text: &str) {
if !out.is_empty() {
out.push('\n');
}
out.push_str(text);
}
fn truncate_for_auto_router(text: &str, max_chars: usize) -> String {
let mut chars = text.chars();
let truncated: String = chars.by_ref().take(max_chars).collect();
if chars.next().is_some() {
format!("{truncated}...")
} else {
truncated
}
}
async fn apply_model_and_compaction_update(
engine_handle: &EngineHandle,
compaction: crate::compaction::CompactionConfig,
) {
let _ = engine_handle
.send(Op::SetModel {
model: compaction.model.clone(),
})
.await;
let _ = engine_handle
.send(Op::SetCompaction { config: compaction })
.await;
}
async fn drain_web_config_events(
web_config_session: &mut Option<WebConfigSession>,
app: &mut App,
config: &mut Config,
engine_handle: &EngineHandle,
) -> bool {
let Some(session) = web_config_session.as_mut() else {
return true;
};
let mut keep_session = true;
while let Ok(event) = session.receiver.try_recv() {
match event {
WebConfigSessionEvent::Draft(doc) => {
match config_ui::apply_document(doc, app, config, false) {
Ok(outcome) if outcome.changed => {
if outcome.requires_engine_sync {
apply_model_and_compaction_update(
engine_handle,
app.compaction_config(),
)
.await;
}
app.status_message = Some(format!(
"Web config draft applied: {}",
outcome.final_message
));
}
Ok(_) => {}
Err(err) => {
app.add_message(HistoryCell::System {
content: format!("Web config draft apply failed: {err}"),
});
}
}
}
WebConfigSessionEvent::Committed(doc) => {
keep_session = false;
match config_ui::apply_document(doc, app, config, true) {
Ok(outcome) => {
if outcome.requires_engine_sync {
apply_model_and_compaction_update(
engine_handle,
app.compaction_config(),
)
.await;
}
app.add_message(HistoryCell::System {
content: outcome.final_message.clone(),
});
app.status_message = Some(outcome.final_message);
}
Err(err) => {
app.add_message(HistoryCell::System {
content: format!("Web config commit failed: {err}"),
});
}
}
}
WebConfigSessionEvent::Failed(err) => {
keep_session = false;
app.add_message(HistoryCell::System {
content: format!("Web config session failed: {err}"),
});
}
}
}
keep_session
}
async fn apply_model_picker_choice(
app: &mut App,
engine_handle: &EngineHandle,
model: String,
mut effort: crate::tui::app::ReasoningEffort,
previous_model: String,
previous_effort: crate::tui::app::ReasoningEffort,
) {
let model_is_auto = model.trim().eq_ignore_ascii_case("auto");
if model_is_auto {
effort = ReasoningEffort::Auto;
}
let model_changed = model != previous_model || app.auto_model != model_is_auto;
let effort_changed = effort != previous_effort;
if !model_changed && !effort_changed {
app.status_message = Some(format!(
"Model unchanged: {model} · thinking {}",
effort.short_label()
));
return;
}
if model_changed {
app.auto_model = model_is_auto;
app.last_effective_model = None;
app.model = model.clone();
app.update_model_compaction_budget();
app.clear_model_scoped_telemetry();
}
if effort_changed {
app.reasoning_effort = effort;
app.last_effective_reasoning_effort = None;
}
let mut persist_warning: Option<String> = None;
match crate::settings::Settings::load() {
Ok(mut settings) => {
if model_changed {
let _ = settings.set("default_model", &model);
}
if effort_changed {
let _ = settings.set("reasoning_effort", effort.as_setting());
}
if let Err(err) = settings.save() {
persist_warning = Some(format!("(not persisted: {err})"));
}
}
Err(err) => {
persist_warning = Some(format!("(not persisted: {err})"));
}
}
if model_changed {
apply_model_and_compaction_update(engine_handle, app.compaction_config()).await;
}
let model_summary = if model_is_auto {
"auto (per-turn model)".to_string()
} else {
model.clone()
};
let previous_effort_summary = previous_effort.short_label();
let effort_summary = if effort == ReasoningEffort::Auto {
"auto (per-turn thinking)".to_string()
} else {
effort.short_label().to_string()
};
let mut summary = match (model_changed, effort_changed) {
(true, true) => format!(
"Model: {previous_model} → {model_summary} · thinking: {previous_effort_summary} → {effort_summary}"
),
(true, false) => {
format!("Model: {previous_model} → {model_summary} · thinking {effort_summary}")
}
(false, true) => format!(
"Thinking: {previous_effort_summary} → {effort_summary} · model {model_summary}"
),
(false, false) => unreachable!(),
};
if let Some(warning) = persist_warning {
summary.push(' ');
summary.push_str(&warning);
}
app.status_message = Some(summary);
}
async fn switch_provider(
app: &mut App,
engine_handle: &mut EngineHandle,
config: &mut Config,
target: ApiProvider,
model_override: Option<String>,
) {
let previous_provider = app.api_provider;
let previous_model = app.model.clone();
let previous_provider_str = config.provider.clone();
let previous_base_url = config.base_url.clone();
let previous_default_text_model = config.default_text_model.clone();
config.provider = Some(target.as_str().to_string());
if matches!(target, ApiProvider::NvidiaNim)
&& config
.base_url
.as_deref()
.map(|base| !base.contains("integrate.api.nvidia.com"))
.unwrap_or(true)
{
config.base_url = Some(DEFAULT_NVIDIA_NIM_BASE_URL.to_string());
}
if matches!(target, ApiProvider::Deepseek)
&& config
.base_url
.as_deref()
.map(|base| base.contains("integrate.api.nvidia.com"))
.unwrap_or(false)
{
config.base_url = None;
}
if let Some(ref model) = model_override {
config.default_text_model = Some(model.clone());
}
if let Err(err) = DeepSeekClient::new(config) {
config.provider = previous_provider_str;
config.base_url = previous_base_url;
config.default_text_model = previous_default_text_model;
app.add_message(HistoryCell::System {
content: format!(
"Failed to switch provider to {}: {err}\nProvider unchanged ({}).",
target.as_str(),
previous_provider.as_str()
),
});
return;
}
let new_model = config.default_model();
let cache_scope_changed = previous_provider != target || previous_model != new_model;
app.api_provider = target;
app.model = new_model.clone();
app.update_model_compaction_budget();
if cache_scope_changed {
app.clear_model_scoped_telemetry();
} else {
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
}
let _ = engine_handle.send(Op::Shutdown).await;
let engine_config = build_engine_config(app, config);
*engine_handle = spawn_engine(engine_config, config);
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {
messages: app.api_messages.clone(),
system_prompt: app.system_prompt.clone(),
model: app.model.clone(),
workspace: app.workspace.clone(),
})
.await;
}
let _ = engine_handle
.send(Op::SetCompaction {
config: app.compaction_config(),
})
.await;
app.add_message(HistoryCell::System {
content: format!(
"Provider switched: {} → {}\nModel: {} → {}",
previous_provider.as_str(),
target.as_str(),
previous_model,
new_model
),
});
app.status_message = Some(format!("Provider: {}", target.as_str()));
}
fn open_text_pager(app: &mut App, title: String, content: String) {
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
app.view_stack.push(PagerView::from_text(
title,
&content,
width.saturating_sub(2),
));
}
fn open_context_inspector(app: &mut App) {
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
let content = build_context_inspector_text(app);
app.view_stack.push(PagerView::from_text(
"Context inspector",
&content,
width.saturating_sub(2),
));
}
fn open_file_picker(app: &mut App) {
let relevance = build_file_picker_relevance(app);
app.view_stack
.push(crate::tui::file_picker::FilePickerView::new_with_relevance(
&app.workspace,
relevance,
));
}
fn build_file_picker_relevance(app: &App) -> crate::tui::file_picker::FilePickerRelevance {
let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();
for path in modified_workspace_paths(&app.workspace) {
relevance.mark_modified(path);
}
for record in app.session_context_references.iter().rev().take(64) {
let reference = &record.reference;
if reference.source != crate::tui::file_mention::ContextReferenceSource::AtMention {
continue;
}
if !matches!(
reference.kind,
crate::tui::file_mention::ContextReferenceKind::File
) {
continue;
}
for raw in [&reference.target, &reference.label] {
if let Some(path) = workspace_file_candidate(raw, &app.workspace) {
relevance.mark_mentioned(path);
}
}
}
let mut seen_tool_paths = HashSet::new();
for detail in app.active_tool_details.values() {
mark_tool_detail_paths(detail, &app.workspace, &mut seen_tool_paths, &mut relevance);
}
let mut rows: Vec<_> = app.tool_details_by_cell.iter().collect();
rows.sort_by_key(|(idx, _)| std::cmp::Reverse(**idx));
for (_, detail) in rows.into_iter().take(48) {
mark_tool_detail_paths(detail, &app.workspace, &mut seen_tool_paths, &mut relevance);
}
relevance
}
fn modified_workspace_paths(workspace: &Path) -> Vec<String> {
let Ok(output) = Command::new("git")
.arg("-C")
.arg(workspace)
.args(["status", "--short", "--untracked-files=normal"])
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(parse_git_status_path)
.filter_map(|path| workspace_file_candidate(&path, workspace))
.collect()
}
fn parse_git_status_path(line: &str) -> Option<String> {
if line.len() < 4 {
return None;
}
let raw = line.get(3..)?.trim();
let raw = raw.rsplit(" -> ").next().unwrap_or(raw).trim();
let raw = raw.trim_matches('"');
if raw.is_empty() {
None
} else {
Some(raw.to_string())
}
}
fn mark_tool_detail_paths(
detail: &ToolDetailRecord,
workspace: &Path,
seen: &mut HashSet<String>,
relevance: &mut crate::tui::file_picker::FilePickerRelevance,
) {
let mut budget = 256usize;
mark_tool_paths_from_value(&detail.input, workspace, seen, relevance, &mut budget);
if let Some(output) = detail
.output
.as_deref()
.filter(|output| output.len() <= 8_192)
{
mark_tool_paths_from_text(output, workspace, seen, relevance, &mut budget);
}
}
fn mark_tool_paths_from_value(
value: &serde_json::Value,
workspace: &Path,
seen: &mut HashSet<String>,
relevance: &mut crate::tui::file_picker::FilePickerRelevance,
budget: &mut usize,
) {
if *budget == 0 {
return;
}
match value {
serde_json::Value::String(text) => {
mark_tool_paths_from_text(text, workspace, seen, relevance, budget);
}
serde_json::Value::Array(items) => {
for item in items {
mark_tool_paths_from_value(item, workspace, seen, relevance, budget);
if *budget == 0 {
break;
}
}
}
serde_json::Value::Object(map) => {
for item in map.values() {
mark_tool_paths_from_value(item, workspace, seen, relevance, budget);
if *budget == 0 {
break;
}
}
}
_ => {}
}
}
fn mark_tool_paths_from_text(
text: &str,
workspace: &Path,
seen: &mut HashSet<String>,
relevance: &mut crate::tui::file_picker::FilePickerRelevance,
budget: &mut usize,
) {
if *budget == 0 || text.len() > 8_192 {
return;
}
if let Some(path) = workspace_file_candidate(text, workspace)
&& seen.insert(path.clone())
{
relevance.mark_tool(path);
*budget = (*budget).saturating_sub(1);
}
for token in text.split_whitespace().take(128) {
if *budget == 0 {
break;
}
if let Some(path) = workspace_file_candidate(token, workspace)
&& seen.insert(path.clone())
{
relevance.mark_tool(path);
*budget = (*budget).saturating_sub(1);
}
}
}
fn workspace_file_candidate(raw: &str, workspace: &Path) -> Option<String> {
let cleaned = clean_path_token(raw)?;
let path = Path::new(&cleaned);
let absolute = if path.is_absolute() {
PathBuf::from(path)
} else {
workspace.join(path)
};
if !absolute.is_file() {
return None;
}
let rel = absolute.strip_prefix(workspace).ok()?;
workspace_path_to_picker_string(rel)
}
fn clean_path_token(raw: &str) -> Option<String> {
let mut trimmed = raw.trim().trim_matches(|ch: char| {
ch.is_ascii_whitespace()
|| matches!(
ch,
'"' | '\'' | '`' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';'
)
});
if let Some(stripped) = trimmed.strip_prefix("./") {
trimmed = stripped;
}
if let Some((before, after)) = trimmed.rsplit_once(':')
&& !before.is_empty()
&& after.chars().all(|ch| ch.is_ascii_digit())
{
trimmed = before;
}
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn workspace_path_to_picker_string(path: &Path) -> Option<String> {
let mut out = String::new();
for (idx, component) in path.components().enumerate() {
if matches!(
component,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
) {
return None;
}
if idx > 0 {
out.push('/');
}
out.push_str(&component.as_os_str().to_string_lossy());
}
if out.is_empty() { None } else { Some(out) }
}
async fn apply_command_result(
terminal: &mut AppTerminal,
app: &mut App,
engine_handle: &mut EngineHandle,
task_manager: &SharedTaskManager,
config: &mut Config,
#[cfg_attr(not(feature = "web"), allow(unused_variables))] web_config_session: &mut Option<
WebConfigSession,
>,
result: commands::CommandResult,
) -> Result<bool> {
if let Some(msg) = result.message {
app.add_message(HistoryCell::System { content: msg });
}
if let Some(action) = result.action {
match action {
AppAction::Quit => {
let _ = engine_handle.send(Op::Shutdown).await;
return Ok(true);
}
AppAction::SaveSession(path) => {
app.status_message = Some(format!("Session saved to {}", path.display()));
}
AppAction::LoadSession(path) => {
app.status_message = Some(format!("Session loaded from {}", path.display()));
}
AppAction::SyncSession {
messages,
system_prompt,
model,
workspace,
} => {
let is_full_reset = messages.is_empty() && system_prompt.is_none();
let _ = engine_handle
.send(Op::SyncSession {
messages,
system_prompt,
model,
workspace,
})
.await;
let _ = engine_handle
.send(Op::SetCompaction {
config: app.compaction_config(),
})
.await;
if is_full_reset {
if let Ok(manager) = SessionManager::default_location() {
let session = build_session_snapshot(app, &manager);
app.current_session_id = Some(session.metadata.id.clone());
persistence_actor::persist(PersistRequest::SessionSnapshot(session));
}
persistence_actor::persist(PersistRequest::ClearCheckpoint);
}
}
AppAction::SendMessage(content) => {
let queued = build_queued_message(app, content);
submit_or_steer_message(app, config, engine_handle, queued).await?;
}
AppAction::Rlm {
prompt,
model,
child_model,
max_depth,
} => {
app.status_message = Some("RLM turn starting...".to_string());
let _ = engine_handle
.send(Op::Rlm {
content: prompt,
model,
child_model,
max_depth,
})
.await;
}
AppAction::ListSubAgents => {
let _ = engine_handle.send(Op::ListSubAgents).await;
}
AppAction::FetchModels => {
app.status_message = Some("Fetching models...".to_string());
match fetch_available_models(config).await {
Ok(models) => {
app.add_message(HistoryCell::System {
content: format_available_models_message(&app.model, &models),
});
app.status_message = Some(format!("Found {} model(s)", models.len()));
}
Err(error) => {
app.add_message(HistoryCell::System {
content: format!("Failed to fetch models: {error}"),
});
}
}
}
AppAction::SwitchProvider { provider, model } => {
switch_provider(app, engine_handle, config, provider, model).await;
}
AppAction::UpdateCompaction(compaction) => {
apply_model_and_compaction_update(engine_handle, compaction).await;
}
AppAction::OpenConfigEditor(mode) => match mode {
ConfigUiMode::Native => {
if app.view_stack.top_kind() != Some(ModalKind::Config) {
app.view_stack.push(ConfigView::new_for_app(app));
}
}
ConfigUiMode::Tui => {
pause_terminal(
terminal,
app.use_alt_screen,
app.use_mouse_capture,
app.use_bracketed_paste,
)?;
let editor_result = config_ui::run_tui_editor(app, config)
.and_then(|doc| config_ui::apply_document(doc, app, config, true));
resume_terminal(
terminal,
app.use_alt_screen,
app.use_mouse_capture,
app.use_bracketed_paste,
)?;
match editor_result {
Ok(outcome) => {
if outcome.requires_engine_sync {
apply_model_and_compaction_update(
engine_handle,
app.compaction_config(),
)
.await;
}
app.add_message(HistoryCell::System {
content: outcome.final_message.clone(),
});
app.status_message = Some(outcome.final_message);
}
Err(err) => {
app.add_message(HistoryCell::System {
content: format!("Config UI failed: {err}"),
});
}
}
}
ConfigUiMode::Web => {
#[cfg(feature = "web")]
{
let session = config_ui::start_web_editor(app, config).await?;
let url = format!("http://{}", session.addr);
let open_err = config_ui::open_browser(&url).err();
if let Some(err) = open_err {
app.add_message(HistoryCell::System {
content: format!("Failed to open browser automatically: {err}"),
});
}
app.status_message = Some(format!("web ui listen on: {url}"));
*web_config_session = Some(session);
}
#[cfg(not(feature = "web"))]
{
app.add_message(HistoryCell::System {
content: "This build does not include the web config UI.".to_string(),
});
}
}
},
AppAction::OpenConfigView => {
if app.view_stack.top_kind() != Some(ModalKind::Config) {
app.view_stack.push(ConfigView::new_for_app(app));
}
}
AppAction::OpenModelPicker => {
if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) {
app.view_stack
.push(crate::tui::model_picker::ModelPickerView::new(app));
}
}
AppAction::OpenProviderPicker => {
if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) {
app.view_stack
.push(crate::tui::provider_picker::ProviderPickerView::new(
app.api_provider,
config,
));
}
}
AppAction::OpenStatusPicker => {
if app.view_stack.top_kind() != Some(ModalKind::StatusPicker) {
app.view_stack
.push(crate::tui::views::status_picker::StatusPickerView::new(
&app.status_items,
));
}
}
AppAction::OpenContextInspector => {
open_context_inspector(app);
}
AppAction::CompactContext => {
app.status_message = Some("Compacting context...".to_string());
let _ = engine_handle.send(Op::CompactContext).await;
}
AppAction::TaskAdd { prompt } => {
let request = NewTaskRequest {
prompt: prompt.clone(),
model: Some(app.model.clone()),
workspace: Some(app.workspace.clone()),
mode: Some(task_mode_label(app.mode).to_string()),
allow_shell: Some(app.allow_shell),
trust_mode: Some(app.trust_mode),
auto_approve: Some(app.approval_mode == ApprovalMode::Auto),
};
match task_manager.add_task(request).await {
Ok(task) => {
app.add_message(HistoryCell::System {
content: format!(
"Task queued: {} ({})",
task.id,
summarize_tool_output(&task.prompt)
),
});
app.status_message = Some(format!("Queued {}", task.id));
}
Err(err) => {
app.add_message(HistoryCell::System {
content: format!("Failed to queue task: {err}"),
});
}
}
refresh_active_task_panel(app, task_manager).await;
}
AppAction::TaskList => {
let tasks = task_manager.list_tasks(Some(30)).await;
refresh_active_task_panel(app, task_manager).await;
app.add_message(HistoryCell::System {
content: format_task_list(&tasks),
});
}
AppAction::TaskShow { id } => match task_manager.get_task(&id).await {
Ok(task) => open_task_pager(app, &task),
Err(err) => {
app.add_message(HistoryCell::System {
content: format!("Task lookup failed: {err}"),
});
}
},
AppAction::TaskCancel { id } => {
match task_manager.cancel_task(&id).await {
Ok(task) => {
app.add_message(HistoryCell::System {
content: format!("Task {} status: {:?}", task.id, task.status),
});
}
Err(err) => {
app.add_message(HistoryCell::System {
content: format!("Task cancel failed: {err}"),
});
}
}
refresh_active_task_panel(app, task_manager).await;
}
AppAction::ShellJob(action) => {
handle_shell_job_action(app, action);
}
AppAction::Mcp(action) => {
handle_mcp_ui_action(app, config, action).await;
}
AppAction::SwitchProfile { profile } => {
app.config_profile = Some(profile.clone());
match Config::load(app.config_path.clone(), Some(&profile)) {
Ok(new_config) => {
*config = new_config.clone();
app.api_provider = config.api_provider();
let new_model = config.default_model();
app.model = new_model.clone();
app.update_model_compaction_budget();
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
let _ = engine_handle.send(Op::Shutdown).await;
let engine_config = build_engine_config(app, config);
*engine_handle = spawn_engine(engine_config, config);
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {
messages: app.api_messages.clone(),
system_prompt: app.system_prompt.clone(),
model: app.model.clone(),
workspace: app.workspace.clone(),
})
.await;
}
app.add_message(HistoryCell::System {
content: format!(
"Switched to profile '{profile}'. Model: {new_model}, Provider: {}",
config.api_provider().as_str()
),
});
app.status_message = Some(format!("Profile: {profile}"));
}
Err(err) => {
app.config_profile = None;
app.status_message =
Some(format!("Failed to switch to profile '{profile}': {err}"));
}
}
}
AppAction::ShareSession {
history_len: _,
model,
mode,
} => {
let status = if app.api_messages.is_empty() {
"No session content to share.".to_string()
} else {
let history_json = serde_json::to_string_pretty(&app.api_messages)
.unwrap_or_else(|_| "[]".to_string());
match crate::commands::share::perform_share(&history_json, &model, &mode).await
{
Ok(url) => format!("Session shared! URL: {url}"),
Err(err) => format!("Share failed: {err}"),
}
};
app.add_message(HistoryCell::System {
content: status.clone(),
});
app.status_message = Some(status);
}
}
}
Ok(false)
}
async fn handle_mcp_ui_action(
app: &mut App,
config: &Config,
action: crate::tui::app::McpUiAction,
) {
use crate::mcp::{self, McpWriteStatus};
let path = app.mcp_config_path.clone();
let mut changed = false;
let mut message = None;
let discover = matches!(
action,
crate::tui::app::McpUiAction::Validate | crate::tui::app::McpUiAction::Reload
);
let action_result = match action {
crate::tui::app::McpUiAction::Show => Ok(()),
crate::tui::app::McpUiAction::Init { force } => {
changed = true;
match mcp::init_config(&path, force) {
Ok(McpWriteStatus::Created) => {
message = Some(format!("Created MCP config at {}", path.display()));
Ok(())
}
Ok(McpWriteStatus::Overwritten) => {
message = Some(format!("Overwrote MCP config at {}", path.display()));
Ok(())
}
Ok(McpWriteStatus::SkippedExists) => {
changed = false;
message = Some(format!(
"MCP config already exists at {} (use /mcp init --force to overwrite)",
path.display()
));
Ok(())
}
Err(err) => Err(err),
}
}
crate::tui::app::McpUiAction::AddStdio {
name,
command,
args,
} => {
changed = true;
mcp::add_server_config(&path, name.clone(), Some(command), None, args)
.map(|()| message = Some(format!("Added MCP stdio server '{name}'")))
}
crate::tui::app::McpUiAction::AddHttp { name, url } => {
changed = true;
mcp::add_server_config(&path, name.clone(), None, Some(url), Vec::new())
.map(|()| message = Some(format!("Added MCP HTTP/SSE server '{name}'")))
}
crate::tui::app::McpUiAction::Enable { name } => {
changed = true;
mcp::set_server_enabled(&path, &name, true)
.map(|()| message = Some(format!("Enabled MCP server '{name}'")))
}
crate::tui::app::McpUiAction::Disable { name } => {
changed = true;
mcp::set_server_enabled(&path, &name, false)
.map(|()| message = Some(format!("Disabled MCP server '{name}'")))
}
crate::tui::app::McpUiAction::Remove { name } => {
changed = true;
mcp::remove_server_config(&path, &name)
.map(|()| message = Some(format!("Removed MCP server '{name}'")))
}
crate::tui::app::McpUiAction::Validate | crate::tui::app::McpUiAction::Reload => Ok(()),
};
if let Err(err) = action_result {
add_mcp_message(app, format!("MCP action failed: {err}"));
return;
}
if changed {
app.mcp_restart_required = true;
}
if let Some(message) = message {
add_mcp_message(app, message);
}
let snapshot_result = if discover {
let network_policy = config.network.clone().map(|toml_cfg| {
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
});
mcp::discover_manager_snapshot(&path, network_policy, app.mcp_restart_required).await
} else {
mcp::manager_snapshot_from_config(&path, app.mcp_restart_required)
};
match snapshot_result {
Ok(snapshot) => {
if discover {
add_mcp_message(
app,
"MCP discovery refreshed for the UI. Restart the TUI after config edits to rebuild the model-visible MCP tool pool.".to_string(),
);
}
app.mcp_configured_count = snapshot.servers.len();
app.mcp_snapshot = Some(snapshot.clone());
open_mcp_manager_pager(app, &snapshot);
}
Err(err) => add_mcp_message(app, format!("MCP snapshot failed: {err}")),
}
}
fn handle_shell_job_action(app: &mut App, action: crate::tui::app::ShellJobAction) {
let Some(shell_manager) = app.runtime_services.shell_manager.clone() else {
add_shell_job_message(app, "Shell job center is not attached.".to_string());
return;
};
let mut manager = match shell_manager.lock() {
Ok(manager) => manager,
Err(_) => {
add_shell_job_message(app, "Shell job center lock is poisoned.".to_string());
return;
}
};
match action {
crate::tui::app::ShellJobAction::List => {
let jobs = manager.list_jobs();
add_shell_job_message(app, format_shell_job_list(&jobs));
}
crate::tui::app::ShellJobAction::Show { id } => match manager.inspect_job(&id) {
Ok(detail) => open_shell_job_pager(app, &detail),
Err(err) => add_shell_job_message(app, format!("Shell job lookup failed: {err}")),
},
crate::tui::app::ShellJobAction::Poll { id, wait } => {
match manager.poll_delta(&id, wait, if wait { 5_000 } else { 1_000 }) {
Ok(delta) => add_shell_job_message(app, format_shell_poll(&delta.result)),
Err(err) => add_shell_job_message(app, format!("Shell job poll failed: {err}")),
}
}
crate::tui::app::ShellJobAction::SendStdin { id, input, close } => {
match manager.write_stdin(&id, &input, close) {
Ok(()) => match manager.poll_delta(&id, false, 1_000) {
Ok(delta) => add_shell_job_message(app, format_shell_poll(&delta.result)),
Err(err) => {
add_shell_job_message(app, format!("Shell stdin sent; poll failed: {err}"));
}
},
Err(err) => add_shell_job_message(app, format!("Shell stdin failed: {err}")),
}
}
crate::tui::app::ShellJobAction::Cancel { id } => match manager.kill(&id) {
Ok(result) => add_shell_job_message(app, format_shell_poll(&result)),
Err(err) => add_shell_job_message(app, format!("Shell job cancel failed: {err}")),
},
}
}
async fn execute_command_input(
terminal: &mut AppTerminal,
app: &mut App,
engine_handle: &mut EngineHandle,
task_manager: &SharedTaskManager,
config: &mut Config,
web_config_session: &mut Option<WebConfigSession>,
input: &str,
) -> Result<bool> {
let result = commands::execute(input, app);
if input.trim().eq_ignore_ascii_case("/logout") {
config.api_key = None;
if let Some(providers) = config.providers.as_mut() {
providers.deepseek.api_key = None;
providers.deepseek_cn.api_key = None;
providers.nvidia_nim.api_key = None;
providers.openrouter.api_key = None;
providers.novita.api_key = None;
providers.fireworks.api_key = None;
providers.sglang.api_key = None;
providers.vllm.api_key = None;
providers.ollama.api_key = None;
}
app.api_key_env_only = crate::config::active_provider_uses_env_only_api_key(config);
}
apply_command_result(
terminal,
app,
engine_handle,
task_manager,
config,
web_config_session,
result,
)
.await
}
async fn steer_user_message(
app: &mut App,
engine_handle: &EngineHandle,
message: QueuedMessage,
) -> Result<()> {
let cwd = std::env::current_dir().ok();
let references = crate::tui::file_mention::context_references_from_input(
&message.display,
&app.workspace,
cwd.clone(),
);
let content = queued_message_content_for_app(app, &message, cwd);
let message_index = app.api_messages.len();
engine_handle.steer(content.clone()).await?;
app.add_message(HistoryCell::User {
content: format!("+ {}", message.display),
});
let history_cell = app.history.len().saturating_sub(1);
app.record_context_references(history_cell, message_index, references);
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: content.clone(),
cache_control: None,
}],
});
app.status_message = Some("Steering current turn...".to_string());
Ok(())
}
async fn queue_follow_up(app: &mut App, message: QueuedMessage) -> Result<()> {
let display = message.display.clone();
app.queue_message(message);
app.status_message = Some(format!(
"Queued: {} ({} total) — ↑ to edit",
display,
app.queued_message_count()
));
Ok(())
}
async fn submit_or_steer_message(
app: &mut App,
config: &Config,
engine_handle: &EngineHandle,
message: QueuedMessage,
) -> Result<()> {
match app.decide_submit_disposition() {
SubmitDisposition::Immediate => {
dispatch_user_message(app, config, engine_handle, message).await
}
SubmitDisposition::Queue => {
let count = app.queued_message_count().saturating_add(1);
app.queue_message(message);
if app.offline_mode {
app.status_message =
Some(format!("Offline: {count} queued — ↑ to edit, /queue list"));
} else {
app.status_message = Some(format!("{count} queued — ↑ to edit, /queue list"));
}
Ok(())
}
SubmitDisposition::Steer => {
if let Err(err) = steer_user_message(app, engine_handle, message.clone()).await {
app.queue_message(message);
app.status_message = Some(format!(
"Steer failed ({err}); {} queued — ↑ to edit, /queue list",
app.queued_message_count()
));
} else {
app.push_status_toast(
"Steering into current turn",
StatusToastLevel::Info,
Some(1_500),
);
}
Ok(())
}
SubmitDisposition::QueueFollowUp => queue_follow_up(app, message).await,
}
}
fn merge_pending_steers(app: &mut App) -> Option<QueuedMessage> {
let drained = app.drain_pending_steers();
if drained.is_empty() {
return None;
}
if drained.len() == 1 {
return drained.into_iter().next();
}
let mut skill_instruction: Option<String> = None;
let mut bodies: Vec<String> = Vec::with_capacity(drained.len());
for msg in drained {
if skill_instruction.is_none() {
skill_instruction = msg.skill_instruction;
}
bodies.push(msg.display);
}
Some(QueuedMessage::new(bodies.join("\n\n"), skill_instruction))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PlanChoice {
AcceptAgent,
AcceptYolo,
RevisePlan,
ExitPlan,
}
fn plan_next_step_prompt() -> String {
[
"Action required: choose the next step for this plan.",
" 1) Accept + implement in Agent mode",
" 2) Accept + implement in YOLO mode",
" 3) Revise the plan / ask follow-ups",
" 4) Return to Agent mode without implementing",
"",
"Use the plan confirmation popup, or type 1-4 and press Enter.",
]
.join("\n")
}
fn plan_choice_from_option(option: usize) -> Option<PlanChoice> {
match option {
1 => Some(PlanChoice::AcceptAgent),
2 => Some(PlanChoice::AcceptYolo),
3 => Some(PlanChoice::RevisePlan),
4 => Some(PlanChoice::ExitPlan),
_ => None,
}
}
fn parse_plan_choice(input: &str) -> Option<PlanChoice> {
match input.trim() {
"1" => Some(PlanChoice::AcceptAgent),
"2" => Some(PlanChoice::AcceptYolo),
"3" => Some(PlanChoice::RevisePlan),
"4" => Some(PlanChoice::ExitPlan),
_ => None,
}
}
async fn apply_plan_choice(
app: &mut App,
config: &Config,
engine_handle: &EngineHandle,
choice: PlanChoice,
) -> Result<()> {
match choice {
PlanChoice::AcceptAgent => {
app.set_mode(AppMode::Agent);
app.add_message(HistoryCell::System {
content: "Plan accepted. Switching to Agent mode and starting implementation."
.to_string(),
});
let followup = QueuedMessage::new("Proceed with the accepted plan.".to_string(), None);
if app.is_loading {
app.queue_message(followup);
app.status_message =
Some("Queued accepted plan execution (agent mode).".to_string());
} else {
dispatch_user_message(app, config, engine_handle, followup).await?;
}
}
PlanChoice::AcceptYolo => {
app.set_mode(AppMode::Yolo);
app.add_message(HistoryCell::System {
content: "Plan accepted. Switching to YOLO mode and starting implementation."
.to_string(),
});
let followup = QueuedMessage::new("Proceed with the accepted plan.".to_string(), None);
if app.is_loading {
app.queue_message(followup);
app.status_message =
Some("Queued accepted plan execution (YOLO mode).".to_string());
} else {
dispatch_user_message(app, config, engine_handle, followup).await?;
}
}
PlanChoice::RevisePlan => {
let prompt = "Revise the plan: ";
app.input = prompt.to_string();
app.cursor_position = prompt.chars().count();
app.status_message = Some("Revise the plan and press Enter.".to_string());
}
PlanChoice::ExitPlan => {
app.set_mode(AppMode::Agent);
app.add_message(HistoryCell::System {
content: "Exited Plan mode. Switched to Agent mode.".to_string(),
});
}
}
Ok(())
}
async fn handle_plan_choice(
app: &mut App,
config: &Config,
engine_handle: &EngineHandle,
input: &str,
) -> Result<bool> {
if !app.plan_prompt_pending {
return Ok(false);
}
let choice = parse_plan_choice(input);
app.plan_prompt_pending = false;
let Some(choice) = choice else {
return Ok(false);
};
apply_plan_choice(app, config, engine_handle, choice).await?;
Ok(true)
}
fn build_pending_input_preview(app: &App) -> PendingInputPreview {
let mut preview = PendingInputPreview::new();
let selected_attachment = app.selected_composer_attachment_index();
let mut attachment_index = 0usize;
preview.context_items = crate::tui::file_mention::pending_context_previews(
&app.input,
&app.workspace,
std::env::current_dir().ok(),
)
.into_iter()
.map(|item| {
let selected = if item.removable {
let selected = selected_attachment == Some(attachment_index);
attachment_index += 1;
selected
} else {
false
};
ContextPreviewItem {
kind: item.kind,
label: item.label,
detail: item.detail,
included: item.included,
removable: item.removable,
selected,
}
})
.collect();
preview.pending_steers = app
.pending_steers
.iter()
.map(|m| m.display.clone())
.collect();
preview.rejected_steers = app.rejected_steers.iter().cloned().collect();
preview.queued_messages = app
.queued_messages
.iter()
.map(|m| m.display.clone())
.collect();
preview
}
fn render(f: &mut Frame, app: &mut App) {
let size = f.area();
let background = Block::default().style(Style::default().bg(app.ui_theme.surface_bg));
f.render_widget(background, size);
if app.onboarding != OnboardingState::None {
onboarding::render(f, size, app);
return;
}
let header_height = 1;
let footer_height = 1;
let body_height = size.height.saturating_sub(header_height + footer_height);
let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT);
let mention_menu_entries =
crate::tui::file_mention::visible_mention_menu_entries(app, MENTION_MENU_LIMIT);
if !mention_menu_entries.is_empty() && app.mention_menu_selected >= mention_menu_entries.len() {
app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
}
let context_usage = context_usage_snapshot(app);
let composer_max_height = body_height
.saturating_sub(MIN_CHAT_HEIGHT)
.max(MIN_COMPOSER_HEIGHT);
let composer_height = {
let composer_widget = ComposerWidget::new(
app,
composer_max_height,
&slash_menu_entries,
&mention_menu_entries,
);
composer_widget.desired_height(size.width)
};
let pending_preview = build_pending_input_preview(app);
let preview_height = pending_preview.desired_height(size.width);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(header_height), Constraint::Min(1), Constraint::Length(preview_height), Constraint::Length(composer_height), Constraint::Length(footer_height), ])
.split(size);
{
let sanitized_context_window = context_usage
.as_ref()
.map(|(_, max, _)| *max)
.or_else(|| crate::models::context_window_for_model(&app.model));
let sanitized_prompt_tokens = context_usage
.as_ref()
.and_then(|(used, _, _)| u32::try_from(*used).ok());
let workspace_name = app
.workspace
.file_name()
.and_then(|value| value.to_str())
.filter(|value| !value.is_empty())
.unwrap_or("workspace");
let model_label = app.model_display_label();
let effort_label = app.reasoning_effort_display_label();
let provider_label = match app.api_provider {
crate::config::ApiProvider::Deepseek => None,
crate::config::ApiProvider::DeepseekCN => None,
crate::config::ApiProvider::NvidiaNim => Some("NIM"),
crate::config::ApiProvider::Openai => Some("OpenAI"),
crate::config::ApiProvider::Openrouter => Some("OR"),
crate::config::ApiProvider::Novita => Some("Novita"),
crate::config::ApiProvider::Fireworks => Some("Fireworks"),
crate::config::ApiProvider::Sglang => Some("SGLang"),
crate::config::ApiProvider::Vllm => Some("vLLM"),
crate::config::ApiProvider::Ollama => Some("Ollama"),
};
let header_data = HeaderData::new(
app.mode,
&model_label,
workspace_name,
app.is_loading,
app.ui_theme.header_bg,
)
.with_usage(
app.session.total_conversation_tokens,
sanitized_context_window,
app.session.session_cost,
sanitized_prompt_tokens,
)
.with_reasoning_effort(Some(&effort_label))
.with_provider(provider_label);
let header_widget = HeaderWidget::new(header_data);
let buf = f.buffer_mut();
header_widget.render(chunks[0], buf);
}
{
Block::default()
.style(Style::default().bg(app.ui_theme.surface_bg))
.render(chunks[1], f.buffer_mut());
let mut sidebar_area = None;
let mut chat_area =
if app.file_tree.is_some() && chunks[1].width >= SIDEBAR_VISIBLE_MIN_WIDTH {
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
.split(chunks[1]);
let tree_area = split[0];
let remaining = split[1];
if let Some(ref mut state) = app.file_tree {
super::file_tree::render_file_tree(f, tree_area, state, app.ui_theme.mode);
}
remaining
} else {
chunks[1]
};
if chat_area.width >= SIDEBAR_VISIBLE_MIN_WIDTH {
let preferred_sidebar = (u32::from(chat_area.width)
* u32::from(app.sidebar_width_percent.clamp(10, 50))
/ 100) as u16;
let sidebar_width = preferred_sidebar
.max(24)
.min(chat_area.width.saturating_sub(40));
if sidebar_width >= 20 {
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(1), Constraint::Length(sidebar_width)])
.split(chat_area);
chat_area = split[0];
sidebar_area = Some(split[1]);
}
}
let chat_widget = ChatWidget::new(app, chat_area);
let buf = f.buffer_mut();
chat_widget.render(chat_area, buf);
if let Some(sidebar_area) = sidebar_area {
super::sidebar::render_sidebar(f, sidebar_area, app);
}
}
if preview_height > 0 {
let buf = f.buffer_mut();
pending_preview.render(chunks[2], buf);
}
let cursor_pos = {
let composer_widget = ComposerWidget::new(
app,
composer_max_height,
&slash_menu_entries,
&mention_menu_entries,
);
let buf = f.buffer_mut();
composer_widget.render(chunks[3], buf);
composer_widget.cursor_pos(chunks[3])
};
if let Some(cursor_pos) = cursor_pos {
f.set_cursor_position(cursor_pos);
}
render_footer(f, chunks[4], app);
render_toast_stack_overlay(f, size, chunks[4], app);
if !app.view_stack.is_empty() {
if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
refresh_live_transcript_overlay(app);
}
let buf = f.buffer_mut();
app.view_stack.render(size, buf);
}
}
fn draw_app_frame(terminal: &mut AppTerminal, app: &mut App) -> Result<()> {
terminal.backend_mut().set_palette_mode(app.ui_theme.mode);
terminal.draw(|f| render(f, app))?;
Ok(())
}
fn refresh_live_transcript_overlay(app: &mut App) {
let Some(mut overlay) = app.view_stack.pop() else {
return;
};
if let Some(typed) = overlay.as_any_mut().downcast_mut::<LiveTranscriptOverlay>() {
typed.refresh_from_app(app);
}
app.view_stack.push_boxed(overlay);
}
fn open_backtrack_overlay(app: &mut App) {
let mut overlay = LiveTranscriptOverlay::new();
overlay.refresh_from_app(app);
overlay.set_backtrack_preview(0);
app.view_stack.push(overlay);
app.status_message =
Some("Backtrack: \u{2190}/\u{2192} step Enter rewind Esc cancel".to_string());
app.needs_redraw = true;
}
fn toggle_live_transcript_overlay(app: &mut App) {
if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
app.view_stack.pop();
app.needs_redraw = true;
return;
}
let mut overlay = LiveTranscriptOverlay::new();
overlay.refresh_from_app(app);
app.view_stack.push(overlay);
app.status_message = Some("Live transcript: tailing (Esc to close)".to_string());
app.needs_redraw = true;
}
async fn handle_view_events(
terminal: &mut AppTerminal,
app: &mut App,
config: &mut Config,
task_manager: &SharedTaskManager,
engine_handle: &mut EngineHandle,
web_config_session: &mut Option<WebConfigSession>,
events: Vec<ViewEvent>,
) -> Result<bool> {
for event in events {
match event {
ViewEvent::CommandPaletteSelected { action } => match action {
crate::tui::views::CommandPaletteAction::ExecuteCommand { command } => {
if execute_command_input(
terminal,
app,
engine_handle,
task_manager,
config,
&mut *web_config_session,
&command,
)
.await?
{
return Ok(true);
}
}
crate::tui::views::CommandPaletteAction::InsertText { text } => {
app.input = text;
app.cursor_position = app.input.chars().count();
app.status_message = Some(
"Inserted into composer. Finish the input or press Enter.".to_string(),
);
}
crate::tui::views::CommandPaletteAction::OpenTextPager { title, content } => {
open_text_pager(app, title, content);
}
},
ViewEvent::OpenTextPager { title, content } => {
open_text_pager(app, title, content);
}
ViewEvent::ApprovalDecision {
tool_id,
tool_name,
decision,
timed_out,
approval_key,
} => {
if decision == ReviewDecision::ApprovedForSession {
app.approval_session_approved.insert(tool_name.clone());
app.approval_session_approved.insert(approval_key.clone());
}
match decision {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
let _ = engine_handle.approve_tool_call(tool_id).await;
}
ReviewDecision::Denied | ReviewDecision::Abort => {
if !timed_out {
app.approval_session_denied.insert(tool_name.clone());
app.approval_session_denied.insert(approval_key);
}
let _ = engine_handle.deny_tool_call(tool_id).await;
}
}
if timed_out {
app.add_message(HistoryCell::System {
content: "Approval request timed out - denied".to_string(),
});
}
}
ViewEvent::ElevationDecision {
tool_id,
tool_name,
option,
} => {
use crate::tui::approval::ElevationOption;
match option {
ElevationOption::Abort => {
let _ = engine_handle.deny_tool_call(tool_id).await;
app.add_message(HistoryCell::System {
content: format!("Sandbox elevation aborted for {tool_name}"),
});
}
ElevationOption::WithNetwork => {
app.add_message(HistoryCell::System {
content: format!("Retrying {tool_name} with network access enabled"),
});
let policy = option.to_policy(&app.workspace);
let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
}
ElevationOption::WithWriteAccess(_) => {
app.add_message(HistoryCell::System {
content: format!("Retrying {tool_name} with write access enabled"),
});
let policy = option.to_policy(&app.workspace);
let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
}
ElevationOption::FullAccess => {
app.add_message(HistoryCell::System {
content: format!("Retrying {tool_name} with full access (no sandbox)"),
});
let policy = option.to_policy(&app.workspace);
let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
}
}
}
ViewEvent::UserInputSubmitted { tool_id, response } => {
let _ = engine_handle.submit_user_input(tool_id, response).await;
}
ViewEvent::UserInputCancelled { tool_id } => {
let _ = engine_handle.cancel_user_input(tool_id).await;
app.add_message(HistoryCell::System {
content: "User input cancelled".to_string(),
});
}
ViewEvent::PlanPromptSelected { option } => {
if app.plan_prompt_pending {
app.plan_prompt_pending = false;
if let Some(choice) = plan_choice_from_option(option)
&& let Err(err) =
apply_plan_choice(app, config, engine_handle, choice).await
{
app.status_message = Some(format!("Failed to apply plan selection: {err}"));
}
}
}
ViewEvent::PlanPromptDismissed => {
app.plan_prompt_pending = true;
app.status_message =
Some("Plan prompt closed. Type 1-4 and press Enter to choose.".to_string());
}
ViewEvent::SessionSelected { session_id } => {
let manager = match SessionManager::default_location() {
Ok(manager) => manager,
Err(err) => {
app.status_message =
Some(format!("Failed to open sessions directory: {err}"));
continue;
}
};
match manager.load_session(&session_id) {
Ok(session) => {
let recovered = apply_loaded_session(app, &session);
let _ = engine_handle
.send(Op::SyncSession {
messages: app.api_messages.clone(),
system_prompt: app.system_prompt.clone(),
model: app.model.clone(),
workspace: app.workspace.clone(),
})
.await;
let _ = engine_handle
.send(Op::SetCompaction {
config: app.compaction_config(),
})
.await;
if !recovered {
app.status_message = Some(format!(
"Session loaded (ID: {})",
&session_id[..8.min(session_id.len())]
));
}
}
Err(err) => {
app.status_message =
Some(format!("Failed to load session {session_id}: {err}"));
}
}
}
ViewEvent::SessionDeleted { session_id, title } => {
app.status_message = Some(format!(
"Deleted session {} ({})",
&session_id[..8.min(session_id.len())],
title
));
}
ViewEvent::ConfigUpdated {
key,
value,
persist,
} => {
let result = commands::set_config_value(app, &key, &value, persist);
if let Some(msg) = result.message {
app.add_message(HistoryCell::System { content: msg });
}
if let Some(action) = result.action {
match action {
AppAction::UpdateCompaction(compaction) => {
apply_model_and_compaction_update(engine_handle, compaction).await;
}
AppAction::OpenConfigView => {}
_ => {}
}
}
if app.view_stack.top_kind() == Some(ModalKind::Config) {
app.view_stack.pop();
app.view_stack.push(ConfigView::new_for_app(app));
}
}
ViewEvent::StatusItemsUpdated { items, final_save } => {
app.status_items = items.clone();
app.needs_redraw = true;
if final_save {
match commands::persist_status_items(&items) {
Ok(path) => {
app.status_message =
Some(format!("Status line saved to {}", path.display()));
}
Err(err) => {
app.add_message(HistoryCell::System {
content: format!("Failed to save status line: {err}"),
});
}
}
}
}
ViewEvent::SubAgentsRefresh => {
app.status_message = Some("Refreshing sub-agents...".to_string());
let _ = engine_handle.send(Op::ListSubAgents).await;
}
ViewEvent::FilePickerSelected { path } => {
let cursor = app.cursor_position;
let needs_leading_space = cursor > 0
&& !app
.input
.chars()
.nth(cursor.saturating_sub(1))
.is_some_and(|c| c.is_whitespace());
let mut insertion = String::new();
if needs_leading_space {
insertion.push(' ');
}
insertion.push('@');
insertion.push_str(&path);
insertion.push(' ');
app.insert_str(&insertion);
app.status_message = Some(format!("Attached @{path}"));
}
ViewEvent::ModelPickerApplied {
model,
effort,
previous_model,
previous_effort,
} => {
apply_model_picker_choice(
app,
engine_handle,
model,
effort,
previous_model,
previous_effort,
)
.await;
}
ViewEvent::ProviderPickerApplied { provider } => {
switch_provider(app, engine_handle, config, provider, None).await;
}
ViewEvent::ProviderPickerApiKeySubmitted { provider, api_key } => {
apply_provider_picker_api_key(app, engine_handle, config, provider, api_key).await;
}
ViewEvent::BacktrackStep { direction } => {
app.backtrack.step(direction);
if let Some(idx) = app.backtrack.selected_idx() {
update_backtrack_overlay_selection(app, idx);
}
}
ViewEvent::BacktrackConfirm => {
if let Some(depth) = app.backtrack.confirm() {
apply_backtrack(app, depth);
}
}
ViewEvent::BacktrackCancel => {
app.backtrack.reset();
app.status_message = Some("Backtrack canceled".to_string());
app.needs_redraw = true;
}
ViewEvent::ContextMenuSelected { action } => {
handle_context_menu_action(app, action);
}
ViewEvent::ShellControlBackground => {
request_foreground_shell_background(app);
}
ViewEvent::ShellControlCancel => {
app.backtrack.reset();
engine_handle.cancel();
app.is_loading = false;
app.streaming_state.reset();
app.runtime_turn_status = None;
app.finalize_active_cell_as_interrupted();
app.finalize_streaming_assistant_as_interrupted();
app.status_message = Some("Request cancelled".to_string());
}
}
}
Ok(false)
}
fn update_backtrack_overlay_selection(app: &mut App, selected_idx: usize) {
if app.view_stack.top_kind() != Some(ModalKind::LiveTranscript) {
return;
}
let Some(mut overlay) = app.view_stack.pop() else {
return;
};
if let Some(typed) = overlay.as_any_mut().downcast_mut::<LiveTranscriptOverlay>() {
typed.set_backtrack_preview(selected_idx);
}
app.view_stack.push_boxed(overlay);
app.needs_redraw = true;
}
fn count_user_history_cells(app: &App) -> usize {
app.history
.iter()
.filter(|cell| matches!(cell, HistoryCell::User { .. }))
.count()
}
fn find_user_cell_index_from_tail(app: &App, depth: usize) -> Option<usize> {
let mut count = 0usize;
for (idx, cell) in app.history.iter().enumerate().rev() {
if matches!(cell, HistoryCell::User { .. }) {
if count == depth {
return Some(idx);
}
count += 1;
}
}
None
}
fn apply_backtrack(app: &mut App, depth: usize) {
let Some(history_idx) = find_user_cell_index_from_tail(app, depth) else {
app.status_message = Some("Backtrack target no longer present".to_string());
return;
};
let user_text = match app.history.get(history_idx) {
Some(HistoryCell::User { content }) => content.clone(),
_ => String::new(),
};
app.truncate_history_to(history_idx);
let mut user_seen = 0usize;
let mut cut = None;
for (idx, msg) in app.api_messages.iter().enumerate().rev() {
if msg.role == "user" {
if user_seen == depth {
cut = Some(idx);
break;
}
user_seen += 1;
}
}
if let Some(idx) = cut {
app.api_messages.truncate(idx);
}
app.input = user_text;
app.cursor_position = app.input.chars().count();
if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
app.view_stack.pop();
}
app.status_message =
Some("Rewound to previous user message — edit and Enter to resend".to_string());
app.scroll_to_bottom();
app.mark_history_updated();
app.needs_redraw = true;
}
async fn apply_provider_picker_api_key(
app: &mut App,
engine_handle: &mut EngineHandle,
config: &mut Config,
provider: ApiProvider,
api_key: String,
) {
use crate::config::{ProviderConfig, ProvidersConfig, save_api_key_for};
match save_api_key_for(provider, &api_key) {
Ok(path) => {
app.status_message = Some(format!(
"Saved {} API key to {}",
provider.as_str(),
path.display()
));
app.api_key_env_only = false;
}
Err(err) => {
app.add_message(HistoryCell::System {
content: format!(
"Failed to save {} API key: {err}\nProvider unchanged.",
provider.as_str()
),
});
return;
}
}
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
config.api_key = Some(api_key);
} else {
let providers = config
.providers
.get_or_insert_with(ProvidersConfig::default);
let entry: &mut ProviderConfig = match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
return;
}
ApiProvider::NvidiaNim => &mut providers.nvidia_nim,
ApiProvider::Openai => &mut providers.openai,
ApiProvider::Openrouter => &mut providers.openrouter,
ApiProvider::Novita => &mut providers.novita,
ApiProvider::Fireworks => &mut providers.fireworks,
ApiProvider::Sglang => &mut providers.sglang,
ApiProvider::Vllm => &mut providers.vllm,
ApiProvider::Ollama => &mut providers.ollama,
};
entry.api_key = Some(api_key);
}
switch_provider(app, engine_handle, config, provider, None).await;
}
fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool {
let (messages, recovered_draft) = recover_interrupted_user_tail(&session.messages);
app.api_messages = messages;
app.clear_history();
app.tool_cells.clear();
app.tool_details_by_cell.clear();
app.active_cell = None;
app.active_tool_details.clear();
app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
app.exploring_cell = None;
app.exploring_entries.clear();
app.ignored_tool_calls.clear();
app.pending_tool_uses.clear();
app.last_exec_wait_command = None;
let messages = app.api_messages.clone();
let mut message_to_cell = std::collections::HashMap::new();
for (message_index, msg) in messages.iter().enumerate() {
let mut cells = history_cells_from_message(msg);
if msg.role == "user"
&& session
.context_references
.iter()
.any(|record| record.message_index == message_index)
{
for cell in &mut cells {
if let HistoryCell::User { content } = cell {
*content = compact_user_context_display(content);
}
}
}
let base = app.history.len();
if msg.role == "user"
&& let Some(offset) = cells
.iter()
.position(|cell| matches!(cell, HistoryCell::User { .. }))
{
message_to_cell.insert(message_index, base + offset);
}
app.extend_history(cells);
}
app.sync_context_references_from_session(&session.context_references, &message_to_cell);
app.mark_history_updated();
app.viewport.transcript_selection.clear();
app.model.clone_from(&session.metadata.model);
app.update_model_compaction_budget();
app.workspace.clone_from(&session.metadata.workspace);
app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX);
app.session.total_conversation_tokens = app.session.total_tokens;
app.session.session_cost = 0.0;
app.session.session_cost_cny = 0.0;
app.session.subagent_cost = 0.0;
app.session.subagent_cost_cny = 0.0;
app.session.subagent_cost_event_seqs.clear();
app.session.displayed_cost_high_water = 0.0;
app.session.displayed_cost_high_water_cny = 0.0;
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_prompt_cache_hit_tokens = None;
app.session.last_prompt_cache_miss_tokens = None;
app.session.last_reasoning_replay_tokens = None;
app.session.turn_cache_history.clear();
app.current_session_id = Some(session.metadata.id.clone());
app.workspace_context = None;
app.workspace_context_refreshed_at = None;
if let Some(sp) = session.system_prompt.as_ref() {
app.system_prompt = Some(SystemPrompt::Text(sp.clone()));
} else {
app.system_prompt = None;
}
let recovered = if let Some(draft) = recovered_draft {
restore_recovered_retry_draft(app, draft);
true
} else {
false
};
app.scroll_to_bottom();
recovered
}
fn recover_interrupted_user_tail(messages: &[Message]) -> (Vec<Message>, Option<QueuedMessage>) {
let mut recovered = messages.to_vec();
let Some(last) = recovered.last() else {
return (recovered, None);
};
if last.role != "user" {
return (recovered, None);
}
let Some(display) = retry_display_from_user_message(last) else {
return (recovered, None);
};
recovered.pop();
(recovered, Some(QueuedMessage::new(display, None)))
}
fn retry_display_from_user_message(message: &Message) -> Option<String> {
let text = message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
let display = compact_user_context_display(&text).trim().to_string();
if display.is_empty() {
None
} else {
Some(display)
}
}
fn restore_recovered_retry_draft(app: &mut App, draft: QueuedMessage) {
app.input.clone_from(&draft.display);
app.cursor_position = app.input.chars().count();
app.queued_draft = Some(draft);
app.status_message = Some(
"Recovered interrupted prompt as an editable draft; press Enter to retry.".to_string(),
);
app.needs_redraw = true;
}
fn compact_user_context_display(content: &str) -> String {
content
.split("\n\n---\n\nLocal context from @mentions:")
.next()
.unwrap_or(content)
.to_string()
}
fn refresh_workspace_context_if_needed(app: &mut App, now: Instant, allow_refresh: bool) {
if let Ok(mut cell) = app.workspace_context_cell.lock()
&& let Some(ctx) = cell.take()
{
app.workspace_context = Some(ctx);
}
if app
.workspace_context_refreshed_at
.is_some_and(|refreshed_at| {
now.duration_since(refreshed_at) < Duration::from_secs(WORKSPACE_CONTEXT_REFRESH_SECS)
})
{
return;
}
if !allow_refresh {
return;
}
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let ctx = app.workspace_context_cell.clone();
let workspace = app.workspace.clone();
handle.spawn_blocking(move || {
let result = collect_workspace_context(&workspace);
if let Ok(mut guard) = ctx.lock() {
*guard = result;
}
});
} else {
app.workspace_context = collect_workspace_context(&app.workspace);
}
app.workspace_context_refreshed_at = Some(now);
}
#[derive(Debug, Default, Clone, Copy)]
struct WorkspaceChangeSummary {
staged: usize,
modified: usize,
untracked: usize,
conflicts: usize,
}
impl WorkspaceChangeSummary {
fn is_clean(&self) -> bool {
self.staged == 0 && self.modified == 0 && self.untracked == 0 && self.conflicts == 0
}
}
fn collect_workspace_context(workspace: &Path) -> Option<String> {
let branch = workspace_git_branch(workspace)?;
let summary = workspace_git_change_summary(workspace)?;
let mut parts = Vec::new();
if summary.staged > 0 {
parts.push(format!("{} staged", summary.staged));
}
if summary.modified > 0 {
parts.push(format!("{} modified", summary.modified));
}
if summary.untracked > 0 {
parts.push(format!("{} untracked", summary.untracked));
}
if summary.conflicts > 0 {
parts.push(format!("{} conflicts", summary.conflicts));
}
let status = if summary.is_clean() {
"clean".to_string()
} else {
parts.join(", ")
};
Some(format!("{branch} | {status}"))
}
fn workspace_git_branch(workspace: &Path) -> Option<String> {
let branch = run_git_query(workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
let branch = branch.trim().to_string();
if branch == "HEAD" || branch.is_empty() {
let short_hash = run_git_query(workspace, &["rev-parse", "--short", "HEAD"]).ok()?;
let short_hash = short_hash.trim();
if short_hash.is_empty() {
return None;
}
return Some(format!("detached:{short_hash}"));
}
Some(branch)
}
fn workspace_git_change_summary(workspace: &Path) -> Option<WorkspaceChangeSummary> {
let status = run_git_query(
workspace,
&["status", "--short", "--untracked-files=normal"],
)
.ok()?;
if status.trim().is_empty() {
return Some(WorkspaceChangeSummary::default());
}
let mut summary = WorkspaceChangeSummary::default();
for line in status.lines() {
if line.trim().is_empty() {
continue;
}
let mut chars = line.chars();
let staged = chars.next()?;
let modified = chars.next().unwrap_or(' ');
if staged == ' ' && modified == ' ' {
continue;
}
if staged == '?' && modified == '?' {
summary.untracked = summary.untracked.saturating_add(1);
continue;
}
if staged == 'U' || modified == 'U' {
summary.conflicts = summary.conflicts.saturating_add(1);
}
if staged != ' ' && staged != '?' {
summary.staged = summary.staged.saturating_add(1);
}
if modified != ' ' && modified != '?' {
summary.modified = summary.modified.saturating_add(1);
}
}
Some(summary)
}
fn run_git_query(workspace: &Path, args: &[&str]) -> std::io::Result<String> {
let output = Command::new("git")
.args(args)
.current_dir(workspace)
.output()?;
if !output.status.success() {
return Err(std::io::Error::other("git command failed"));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn pause_terminal(
terminal: &mut AppTerminal,
use_alt_screen: bool,
use_mouse_capture: bool,
use_bracketed_paste: bool,
) -> Result<()> {
let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
disable_raw_mode()?;
if use_alt_screen {
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
}
if use_mouse_capture {
execute!(terminal.backend_mut(), DisableMouseCapture)?;
}
if use_bracketed_paste {
execute!(terminal.backend_mut(), DisableBracketedPaste)?;
}
Ok(())
}
fn resume_terminal(
terminal: &mut AppTerminal,
use_alt_screen: bool,
use_mouse_capture: bool,
use_bracketed_paste: bool,
) -> Result<()> {
enable_raw_mode()?;
if use_alt_screen {
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
}
if use_mouse_capture {
execute!(terminal.backend_mut(), EnableMouseCapture)?;
}
if use_bracketed_paste {
execute!(terminal.backend_mut(), EnableBracketedPaste)?;
}
reset_terminal_viewport(terminal)?;
Ok(())
}
fn reset_terminal_viewport(terminal: &mut AppTerminal) -> Result<()> {
terminal.backend_mut().write_all(TERMINAL_ORIGIN_RESET)?;
terminal.backend_mut().flush()?;
terminal.clear()?;
Ok(())
}
fn status_color(level: StatusToastLevel) -> ratatui::style::Color {
match level {
StatusToastLevel::Info => palette::DEEPSEEK_SKY,
StatusToastLevel::Success => palette::STATUS_SUCCESS,
StatusToastLevel::Warning => palette::STATUS_WARNING,
StatusToastLevel::Error => palette::STATUS_ERROR,
}
}
const TOAST_STACK_MAX_VISIBLE: usize = 3;
fn render_toast_stack_overlay(f: &mut Frame, full_area: Rect, footer_area: Rect, app: &mut App) {
let toasts = app.active_status_toasts(TOAST_STACK_MAX_VISIBLE);
if toasts.len() < 2 || footer_area.y == 0 {
return;
}
let extra = toasts.len() - 1;
let stack_height = extra.min(TOAST_STACK_MAX_VISIBLE - 1) as u16;
let max_above = footer_area.y.min(full_area.height);
if stack_height == 0 || max_above == 0 {
return;
}
let height = stack_height.min(max_above);
let stack_area = Rect {
x: full_area.x,
y: footer_area.y.saturating_sub(height),
width: full_area.width,
height,
};
let visible = &toasts[..extra];
for (i, toast) in visible.iter().take(height as usize).enumerate() {
let row_y = stack_area.y + i as u16;
let row = Rect {
x: stack_area.x,
y: row_y,
width: stack_area.width,
height: 1,
};
let style = ratatui::style::Style::default()
.fg(status_color(toast.level))
.add_modifier(ratatui::style::Modifier::DIM);
let line = ratatui::text::Line::styled(format!(" {} ", toast.text), style);
f.render_widget(ratatui::widgets::Paragraph::new(line), row);
}
}
fn render_footer(f: &mut Frame, area: Rect, app: &mut App) {
if area.width == 0 || area.height == 0 {
return;
}
let quit_prompt = if app.quit_is_armed() {
Some(FooterToast {
text: crate::localization::tr(
app.ui_locale,
crate::localization::MessageId::FooterPressCtrlCAgain,
)
.to_string(),
color: palette::STATUS_WARNING,
})
} else {
None
};
let toast = quit_prompt.or_else(|| {
app.active_status_toast().map(|toast| FooterToast {
text: toast.text,
color: status_color(toast.level),
})
});
let mut props = render_footer_from(app, &app.status_items, toast);
if footer_working_strip_active(app) {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let dot_frame = now_ms / 400;
props.state_label = active_subagent_status_label(app)
.or_else(|| active_tool_status_label(app))
.unwrap_or_else(|| crate::tui::widgets::footer_working_label(dot_frame, app.ui_locale));
props.state_color = palette::DEEPSEEK_SKY;
if !app.low_motion {
let strip_frame = now_ms;
props.working_strip_frame = Some(strip_frame);
}
} else if props.state_label == "ready"
&& let Some(label) = selected_detail_footer_label(app)
{
props.state_label = label;
props.state_color = palette::TEXT_MUTED;
}
let widget = FooterWidget::new(props);
let buf = f.buffer_mut();
widget.render(area, buf);
}
fn footer_working_strip_active(app: &App) -> bool {
let turn_in_progress = app.runtime_turn_status.as_deref() == Some("in_progress");
app.is_loading || app.is_compacting || running_agent_count(app) > 0 || turn_in_progress
}
fn is_noisy_subagent_progress(status: &str) -> bool {
let status = status.trim().to_ascii_lowercase();
status.contains("requesting model response")
}
fn subagent_objective_summary(app: &App, id: &str) -> Option<String> {
app.subagent_cache
.iter()
.find(|agent| agent.agent_id == id)
.map(|agent| summarize_tool_output(&agent.assignment.objective))
.filter(|summary| !summary.is_empty())
}
fn friendly_subagent_progress(app: &App, id: &str, status: &str) -> String {
if !is_noisy_subagent_progress(status) {
return summarize_tool_output(status);
}
if let Some(summary) = subagent_objective_summary(app, id) {
return format!("working on {summary}");
}
if let Some(existing) = app.agent_progress.get(id)
&& !is_noisy_subagent_progress(existing)
&& existing != "working"
{
return existing.clone();
}
"working".to_string()
}
fn active_subagent_status_label(app: &App) -> Option<String> {
let running = running_agent_count(app);
let fanout = active_fanout_counts(app);
let (display_running, total) = if let Some((fanout_running, fanout_total)) = fanout {
if fanout_running == 0 {
return None;
}
(fanout_running, fanout_total)
} else {
if running == 0 {
return None;
}
(running, running)
};
let detail = app
.subagent_cache
.iter()
.find(|agent| matches!(agent.status, SubAgentStatus::Running))
.map(|agent| summarize_tool_output(&agent.assignment.objective))
.filter(|summary| !summary.is_empty())
.or_else(|| {
app.agent_progress
.values()
.find(|value| !is_noisy_subagent_progress(value) && value.as_str() != "working")
.cloned()
})
.unwrap_or_else(|| "working".to_string());
let detail = truncate_line_to_width(&detail, 34);
let elapsed = app
.agent_activity_started_at
.or(app.turn_started_at)
.map(|started| format!("{}s", started.elapsed().as_secs()));
let mut parts = vec![format!("agents {display_running}/{total}"), detail];
if let Some(elapsed) = elapsed {
parts.push(elapsed);
}
parts.push("Alt+4".to_string());
Some(parts.join(" \u{00B7} "))
}
#[derive(Default)]
struct ActiveToolStatusSnapshot {
primary_running: Option<String>,
primary_any: Option<String>,
running: usize,
completed: usize,
started_at: Option<Instant>,
}
impl ActiveToolStatusSnapshot {
fn record(&mut self, label: String, status: ToolStatus, started_at: Option<Instant>) {
if self.primary_any.is_none() {
self.primary_any = Some(label.clone());
}
if status == ToolStatus::Running {
self.running += 1;
if self.primary_running.is_none() {
self.primary_running = Some(label);
}
} else {
self.completed += 1;
}
if let Some(started) = started_at {
self.started_at = Some(match self.started_at {
Some(current) => current.min(started),
None => started,
});
}
}
fn total(&self) -> usize {
self.running + self.completed
}
}
fn active_tool_status_label(app: &App) -> Option<String> {
let active = app.active_cell.as_ref()?;
if active.is_empty() {
return None;
}
let mut snapshot = ActiveToolStatusSnapshot::default();
for cell in active.entries() {
collect_active_tool_status(cell, &mut snapshot);
}
if snapshot.total() == 0 {
return None;
}
let primary = snapshot
.primary_running
.or(snapshot.primary_any)
.unwrap_or_else(|| "tools".to_string());
let primary = truncate_line_to_width(&primary, 30);
let elapsed = snapshot
.started_at
.or(app.turn_started_at)
.map(|started| format!("{}s", started.elapsed().as_secs()));
let mut parts = vec![
primary,
format!("{} active", snapshot.running),
format!("{} done", snapshot.completed),
];
if let Some(elapsed) = elapsed {
parts.push(elapsed);
}
if active_foreground_shell_running(app) {
parts.push("Ctrl+B shell".to_string());
}
parts.push("Alt+V".to_string());
Some(parts.join(" \u{00B7} "))
}
fn open_shell_control(app: &mut App) {
if !app.is_loading || !active_foreground_shell_running(app) {
app.status_message = Some("No foreground shell command to control".to_string());
return;
}
app.view_stack.push(ShellControlView::new());
app.status_message = Some("Shell control opened".to_string());
}
fn request_foreground_shell_background(app: &mut App) {
if !app.is_loading || !active_foreground_shell_running(app) {
app.status_message = Some("No foreground shell command to background".to_string());
return;
}
let Some(shell_manager) = app.runtime_services.shell_manager.clone() else {
app.status_message = Some("Shell manager is not attached".to_string());
return;
};
match shell_manager.lock() {
Ok(mut manager) => {
manager.request_foreground_background();
app.status_message = Some("Backgrounding current shell command...".to_string());
}
Err(_) => {
app.status_message = Some("Shell manager lock is poisoned".to_string());
}
}
}
fn active_foreground_shell_running(app: &App) -> bool {
app.active_cell.as_ref().is_some_and(|active| {
active.entries().iter().any(|cell| {
matches!(
cell,
HistoryCell::Tool(ToolCell::Exec(exec))
if exec.status == ToolStatus::Running && exec.interaction.is_none()
)
})
})
}
fn terminal_pause_has_live_owner(app: &App) -> bool {
app.active_cell.as_ref().is_some_and(|active| {
active.entries().iter().any(|cell| {
matches!(
cell,
HistoryCell::Tool(ToolCell::Exec(exec)) if exec.status == ToolStatus::Running
)
})
})
}
fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatusSnapshot) {
let HistoryCell::Tool(tool) = cell else {
return;
};
match tool {
ToolCell::Exec(exec) => snapshot.record(
format!("run {}", one_line_summary(&exec.command, 80)),
exec.status,
exec.started_at,
),
ToolCell::Exploring(explore) => {
for entry in &explore.entries {
snapshot.record(
format!("read {}", one_line_summary(&entry.label, 80)),
entry.status,
None,
);
}
}
ToolCell::PlanUpdate(plan) => {
snapshot.record("update plan".to_string(), plan.status, None);
}
ToolCell::PatchSummary(patch) => {
snapshot.record(format!("patch {}", patch.path), patch.status, None);
}
ToolCell::Review(review) => {
let target = one_line_summary(&review.target, 80);
let label = if target.is_empty() {
"review".to_string()
} else {
format!("review {target}")
};
snapshot.record(label, review.status, None);
}
ToolCell::DiffPreview(diff) => {
snapshot.record(format!("diff {}", diff.title), ToolStatus::Success, None);
}
ToolCell::Mcp(mcp) => snapshot.record(format!("tool {}", mcp.tool), mcp.status, None),
ToolCell::ViewImage(image) => snapshot.record(
format!("image {}", image.path.display()),
ToolStatus::Success,
None,
),
ToolCell::WebSearch(search) => {
snapshot.record(format!("search {}", search.query), search.status, None);
}
ToolCell::Generic(generic) => {
if generic.name == "agent_spawn" {
return;
}
snapshot.record(format!("tool {}", generic.name), generic.status, None);
}
}
}
fn one_line_summary(text: &str, max_width: usize) -> String {
truncate_line_to_width(
&text.split_whitespace().collect::<Vec<_>>().join(" "),
max_width,
)
}
fn render_footer_from(
app: &App,
items: &[crate::config::StatusItem],
toast: Option<FooterToast>,
) -> FooterProps {
use crate::config::StatusItem as S;
let has = |item: S| items.contains(&item);
let (state_label, state_color) = if has(S::Status) {
footer_state_label(app)
} else {
("ready", app.ui_theme.text_muted)
};
let coherence = if has(S::Coherence) {
footer_coherence_spans(app)
} else {
Vec::new()
};
let agents = if has(S::Agents) {
crate::tui::widgets::footer_agents_chip(running_agent_count(app), app.ui_locale)
} else {
Vec::new()
};
let reasoning_replay = if has(S::ReasoningReplay) {
footer_reasoning_replay_spans(app)
} else {
Vec::new()
};
let cache = if has(S::Cache) {
footer_cache_spans(app)
} else {
Vec::new()
};
let cost = if has(S::Cost) {
footer_cost_spans(app)
} else {
Vec::new()
};
let mut props = FooterProps::from_app(
app,
toast,
state_label,
state_color,
coherence,
agents,
reasoning_replay,
cache,
cost,
);
if !has(S::Mode) {
props.mode_label = "";
}
if !has(S::Model) {
props.model.clear();
}
let mut extra: Vec<Span<'static>> = Vec::new();
for item in items {
let chip = match *item {
S::ContextPercent => footer_context_percent_spans(app),
S::GitBranch | S::LastToolElapsed | S::RateLimit => Vec::new(),
_ => continue,
};
if chip.is_empty() {
continue;
}
if !extra.is_empty() {
extra.push(Span::raw(" "));
}
extra.extend(chip);
}
if !extra.is_empty() {
if !props.cache.is_empty() {
props.cache.push(Span::raw(" "));
}
props.cache.extend(extra);
}
props
}
fn footer_context_percent_spans(app: &App) -> Vec<Span<'static>> {
let Some((_, _, percent)) = context_usage_snapshot(app) else {
return Vec::new();
};
let color = if percent >= 95.0 {
palette::STATUS_ERROR
} else if percent >= 85.0 {
palette::STATUS_WARNING
} else {
palette::TEXT_MUTED
};
vec![Span::styled(
format!("active ctx {percent:.0}%"),
Style::default().fg(color),
)]
}
fn footer_cost_spans(app: &App) -> Vec<Span<'static>> {
let displayed_cost = app.displayed_session_cost_for_currency(app.cost_currency);
if !should_show_footer_cost(displayed_cost) {
return Vec::new();
}
vec![Span::styled(
app.format_cost_amount(displayed_cost),
Style::default().fg(palette::TEXT_MUTED),
)]
}
fn should_show_footer_cost(displayed_cost: f64) -> bool {
displayed_cost.is_finite() && displayed_cost > 0.0
}
#[allow(dead_code)]
fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec<Span<'static>> {
let coherence_spans = footer_coherence_spans(app);
let agents_spans =
crate::tui::widgets::footer_agents_chip(running_agent_count(app), app.ui_locale);
let replay_spans = footer_reasoning_replay_spans(app);
let cache_spans = footer_cache_spans(app);
let cost_spans = footer_cost_spans(app);
let parts: Vec<&Vec<Span<'static>>> = [
&coherence_spans,
&agents_spans,
&replay_spans,
&cache_spans,
&cost_spans,
]
.iter()
.filter(|spans| !spans.is_empty())
.copied()
.collect();
for end in (0..=parts.len()).rev() {
let mut combined = Vec::new();
for (i, part) in parts[..end].iter().enumerate() {
if i > 0 {
combined.push(Span::raw(" "));
}
combined.extend(part.iter().cloned());
}
if spans_width(&combined) <= max_width {
return combined;
}
}
Vec::new()
}
fn footer_coherence_spans(app: &App) -> Vec<Span<'static>> {
let (label, color) = match app.coherence_state {
CoherenceState::Healthy | CoherenceState::GettingCrowded => return Vec::new(),
CoherenceState::RefreshingContext => ("refreshing context", palette::STATUS_WARNING),
CoherenceState::VerifyingRecentWork => ("verifying", palette::DEEPSEEK_SKY),
CoherenceState::ResettingPlan => ("resetting plan", palette::STATUS_ERROR),
};
vec![Span::styled(label.to_string(), Style::default().fg(color))]
}
fn footer_cache_spans(app: &App) -> Vec<Span<'static>> {
let Some(hit_tokens) = app.session.last_prompt_cache_hit_tokens else {
return Vec::new();
};
let miss_tokens = app
.session
.last_prompt_cache_miss_tokens
.unwrap_or_else(|| {
app.session
.last_prompt_tokens
.unwrap_or(0)
.saturating_sub(hit_tokens)
});
let total = hit_tokens.saturating_add(miss_tokens);
if total == 0 {
return Vec::new();
}
let percent = (f64::from(hit_tokens) / f64::from(total) * 100.0).clamp(0.0, 100.0);
let color = if percent > 80.0 {
palette::STATUS_SUCCESS
} else if percent >= 40.0 {
palette::STATUS_WARNING
} else {
palette::STATUS_ERROR
};
vec![Span::styled(
format!("cache hit {:.0}%", percent),
Style::default().fg(color),
)]
}
fn footer_reasoning_replay_spans(app: &App) -> Vec<Span<'static>> {
let Some(replay) = app.session.last_reasoning_replay_tokens else {
return Vec::new();
};
if replay == 0 {
return Vec::new();
}
let label = format!("rsn {}", format_token_count_compact(u64::from(replay)));
let color = match app.session.last_prompt_tokens {
Some(input) if input > 0 && f64::from(replay) / f64::from(input) > 0.5 => {
palette::STATUS_WARNING
}
_ => palette::TEXT_MUTED,
};
vec![Span::styled(label, Style::default().fg(color))]
}
#[allow(dead_code)]
fn footer_toast_spans(
toast: &crate::tui::app::StatusToast,
max_width: usize,
) -> Vec<Span<'static>> {
let truncated = truncate_line_to_width(&toast.text, max_width.max(1));
vec![Span::styled(
truncated,
Style::default().fg(status_color(toast.level)),
)]
}
#[allow(dead_code)]
fn footer_status_line_spans(app: &App, max_width: usize) -> Vec<Span<'static>> {
if max_width == 0 {
return Vec::new();
}
let (mode_label, mode_color) = footer_mode_style(app);
let (status_label, status_color) = footer_state_label(app);
let sep = " \u{00B7} ";
let show_status = status_label != "ready";
let fixed_width = mode_label.width()
+ sep.width()
+ if show_status {
sep.width() + status_label.width()
} else {
0
};
if max_width <= mode_label.width() {
return vec![Span::styled(
truncate_line_to_width(mode_label, max_width),
Style::default().fg(mode_color),
)];
}
let model_budget = max_width.saturating_sub(fixed_width).max(1);
let model_label = truncate_line_to_width(&app.model, model_budget);
let mut spans = vec![
Span::styled(mode_label.to_string(), Style::default().fg(mode_color)),
Span::styled(sep.to_string(), Style::default().fg(app.ui_theme.text_dim)),
Span::styled(model_label, Style::default().fg(app.ui_theme.text_hint)),
];
if show_status {
spans.push(Span::styled(
sep.to_string(),
Style::default().fg(app.ui_theme.text_dim),
));
spans.push(Span::styled(
status_label.to_string(),
Style::default().fg(status_color),
));
}
spans
}
fn footer_state_label(app: &App) -> (&'static str, ratatui::style::Color) {
if app.is_compacting {
return ("compacting \u{238B}", app.ui_theme.status_warning);
}
if running_agent_count(app) > 0 {
return ("working", app.ui_theme.status_working);
}
if app.queued_draft.is_some() {
return ("draft", app.ui_theme.text_muted);
}
if !app.view_stack.is_empty() {
return ("overlay", app.ui_theme.text_muted);
}
if !app.input.is_empty() {
return ("draft", app.ui_theme.text_muted);
}
("ready", app.ui_theme.status_ready)
}
#[allow(dead_code)]
fn footer_mode_style(app: &App) -> (&'static str, ratatui::style::Color) {
let label = app.mode.as_setting();
let color = match app.mode {
crate::tui::app::AppMode::Agent => app.ui_theme.mode_agent,
crate::tui::app::AppMode::Yolo => app.ui_theme.mode_yolo,
crate::tui::app::AppMode::Plan => app.ui_theme.mode_plan,
};
(label, color)
}
fn format_token_count_compact(tokens: u64) -> String {
if tokens >= 1_000_000 {
format!("{:.1}M", tokens as f64 / 1_000_000.0)
} else if tokens >= 1_000 {
format!("{:.1}k", tokens as f64 / 1_000.0)
} else {
tokens.to_string()
}
}
#[allow(dead_code)]
fn format_context_budget(used: i64, max: u32) -> String {
let max_u64 = u64::from(max);
let max_i64 = i64::from(max);
if used > max_i64 {
return format!(
">{}/{}",
format_token_count_compact(max_u64),
format_token_count_compact(max_u64)
);
}
let used_u64 = u64::try_from(used.max(0)).unwrap_or(0);
format!(
"{}/{}",
format_token_count_compact(used_u64),
format_token_count_compact(max_u64)
)
}
#[allow(dead_code)]
fn spans_width(spans: &[Span<'_>]) -> usize {
spans.iter().map(|span| span.content.width()).sum()
}
#[allow(dead_code)]
fn transcript_scroll_percent(top: usize, visible: usize, total: usize) -> Option<u16> {
if total <= visible {
return None;
}
let max_top = total.saturating_sub(visible);
if max_top == 0 {
return None;
}
let clamped_top = top.min(max_top);
let percent = ((clamped_top as f64 / max_top as f64) * 100.0).round() as u16;
Some(percent.min(100))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SearchDirection {
Forward,
Backward,
}
fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool {
let line_meta = app.viewport.transcript_cache.line_meta();
if line_meta.is_empty() {
return false;
}
let top = app
.viewport
.last_transcript_top
.min(line_meta.len().saturating_sub(1));
let current_cell = line_meta
.get(top)
.and_then(crate::tui::scrolling::TranscriptLineMeta::cell_line)
.map(|(cell_index, _)| cell_index);
let mut scan_indices = Vec::new();
match direction {
SearchDirection::Forward => {
scan_indices.extend((top.saturating_add(1))..line_meta.len());
}
SearchDirection::Backward => {
scan_indices.extend((0..top).rev());
}
}
for idx in scan_indices {
let Some((cell_index, _)) = line_meta[idx].cell_line() else {
continue;
};
if current_cell.is_some_and(|current| current == cell_index) {
continue;
}
if !matches!(app.history.get(cell_index), Some(HistoryCell::Tool(_))) {
continue;
}
if let Some(anchor) = TranscriptScroll::anchor_for(line_meta, idx) {
app.viewport.transcript_scroll = anchor;
app.viewport.pending_scroll_delta = 0;
app.needs_redraw = true;
return true;
}
}
false
}
fn estimated_context_tokens(app: &App) -> Option<i64> {
i64::try_from(estimate_input_tokens_conservative(
&app.api_messages,
app.system_prompt.as_ref(),
))
.ok()
}
fn context_usage_snapshot(app: &App) -> Option<(i64, u32, f64)> {
let max = context_window_for_model(app.effective_model_for_budget())?;
let max_i64 = i64::from(max);
let reported = app
.session
.last_prompt_tokens
.map(i64::from)
.map(|tokens| tokens.max(0));
let estimated = estimated_context_tokens(app).map(|tokens| tokens.max(0));
let used = match (estimated, reported) {
(Some(estimated), _) => estimated.min(max_i64),
(None, Some(reported)) => reported.min(max_i64),
(None, None) => return None,
};
let max_f64 = f64::from(max);
let used_f64 = used as f64;
let percent = ((used_f64 / max_f64) * 100.0).clamp(0.0, 100.0);
Some((used, max, percent))
}
#[allow(dead_code)]
fn is_reported_context_inflated(reported: i64, estimated: i64) -> bool {
const MIN_ABSOLUTE_GAP: i64 = 4_096;
if estimated <= 0 || reported <= estimated {
return false;
}
reported.saturating_sub(estimated) >= MIN_ABSOLUTE_GAP
&& reported >= estimated.saturating_mul(4)
}
fn maybe_warn_context_pressure(app: &mut App) {
let Some((used, max, percent)) = context_usage_snapshot(app) else {
return;
};
if percent < CONTEXT_WARNING_THRESHOLD_PERCENT {
return;
}
let recommendation = if app.auto_compact {
"Auto-compaction is enabled."
} else {
"Consider /compact or /clear."
};
if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT {
app.status_message = Some(format!(
"Context critical: {:.0}% ({used}/{max} tokens). {recommendation}",
percent
));
return;
}
if app.status_message.is_none() {
app.status_message = Some(format!(
"Context high: {:.0}% ({used}/{max} tokens). {recommendation}",
percent
));
}
}
fn should_auto_compact_before_send(app: &App) -> bool {
if !app.auto_compact {
return false;
}
context_usage_snapshot(app)
.map(|(_, _, pct)| pct >= CONTEXT_CRITICAL_THRESHOLD_PERCENT)
.unwrap_or(false)
}
fn status_animation_interval_ms(app: &App) -> u64 {
if app.low_motion {
2_400
} else {
UI_STATUS_ANIMATION_MS
}
}
fn active_poll_ms(app: &App) -> u64 {
if app.low_motion {
96
} else {
UI_ACTIVE_POLL_MS
}
}
fn idle_poll_ms(app: &App) -> u64 {
if app.low_motion { 120 } else { UI_IDLE_POLL_MS }
}
fn clamp_event_poll_timeout(timeout: Duration) -> Duration {
const MIN_EVENT_POLL_TIMEOUT: Duration = Duration::from_millis(1);
timeout.max(MIN_EVENT_POLL_TIMEOUT)
}
fn history_has_live_motion(history: &[HistoryCell]) -> bool {
use crate::tui::history::SubAgentCell;
use crate::tui::widgets::agent_card::AgentLifecycle;
history.iter().any(|cell| match cell {
HistoryCell::Thinking { streaming, .. } => *streaming,
HistoryCell::Tool(tool) => match tool {
ToolCell::Exec(cell) => cell.status == ToolStatus::Running,
ToolCell::Exploring(cell) => cell
.entries
.iter()
.any(|entry| entry.status == ToolStatus::Running),
ToolCell::PlanUpdate(cell) => cell.status == ToolStatus::Running,
ToolCell::PatchSummary(cell) => cell.status == ToolStatus::Running,
ToolCell::Review(cell) => cell.status == ToolStatus::Running,
ToolCell::DiffPreview(_) => false,
ToolCell::Mcp(cell) => cell.status == ToolStatus::Running,
ToolCell::ViewImage(_) => false,
ToolCell::WebSearch(cell) => cell.status == ToolStatus::Running,
ToolCell::Generic(cell) => cell.status == ToolStatus::Running,
},
HistoryCell::SubAgent(SubAgentCell::Delegate(card)) => matches!(
card.status,
AgentLifecycle::Pending | AgentLifecycle::Running
),
HistoryCell::SubAgent(SubAgentCell::Fanout(card)) => card
.workers
.iter()
.any(|w| matches!(w.status, AgentLifecycle::Pending | AgentLifecycle::Running)),
_ => false,
})
}
pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
if UnicodeWidthStr::width(text) <= max_width {
return text.to_string();
}
if max_width <= 3 {
let mut out = String::new();
let mut width = 0usize;
for ch in text.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if width + ch_width > max_width {
break;
}
out.push(ch);
width += ch_width;
}
return out;
}
let mut out = String::new();
let mut width = 0usize;
let limit = max_width.saturating_sub(3);
for ch in text.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if width + ch_width > limit {
break;
}
out.push(ch);
width += ch_width;
}
out.push_str("...");
out
}
fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEvent> {
if app.view_stack.top_kind() == Some(ModalKind::ContextMenu) {
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
app.view_stack.pop();
open_context_menu(app, mouse);
return Vec::new();
}
return app.view_stack.handle_mouse(mouse);
}
if !app.view_stack.is_empty() {
app.needs_redraw = true;
return app.view_stack.handle_mouse(mouse);
}
match mouse.kind {
MouseEventKind::ScrollUp => {
let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Up);
app.viewport.pending_scroll_delta += update.delta_lines;
if update.delta_lines != 0 {
app.user_scrolled_during_stream = true;
app.needs_redraw = true;
}
}
MouseEventKind::ScrollDown => {
let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Down);
app.viewport.pending_scroll_delta += update.delta_lines;
if update.delta_lines != 0 {
app.user_scrolled_during_stream = true;
app.needs_redraw = true;
}
}
MouseEventKind::Down(MouseButton::Left) => {
app.viewport.transcript_scrollbar_dragging = false;
if mouse_hits_rect(mouse, app.viewport.jump_to_latest_button_area) {
app.scroll_to_bottom();
return Vec::new();
}
if mouse_hits_transcript_scrollbar(app, mouse) {
app.viewport.transcript_scrollbar_dragging = true;
app.viewport.transcript_selection.clear();
scroll_transcript_to_mouse_row(app, mouse.row);
return Vec::new();
}
if let Some(point) = selection_point_from_mouse(app, mouse) {
app.viewport.transcript_selection.anchor = Some(point);
app.viewport.transcript_selection.head = Some(point);
app.viewport.transcript_selection.dragging = true;
if app.is_loading
&& app.viewport.transcript_scroll.is_at_tail()
&& let Some(anchor) = TranscriptScroll::anchor_for(
app.viewport.transcript_cache.line_meta(),
app.viewport.last_transcript_top,
)
{
app.viewport.transcript_scroll = anchor;
}
} else if app.viewport.transcript_selection.is_active() {
app.viewport.transcript_selection.clear();
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if app.viewport.transcript_scrollbar_dragging {
scroll_transcript_to_mouse_row(app, mouse.row);
return Vec::new();
}
if app.viewport.transcript_selection.dragging
&& let Some(point) = selection_point_from_mouse(app, mouse)
{
app.viewport.transcript_selection.head = Some(point);
}
}
MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_scrollbar_dragging => {
app.viewport.transcript_scrollbar_dragging = false;
app.needs_redraw = true;
}
MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_selection.dragging => {
app.viewport.transcript_selection.dragging = false;
if selection_has_content(app) {
copy_active_selection(app);
}
}
MouseEventKind::Down(MouseButton::Right) => {
open_context_menu(app, mouse);
}
_ => {}
}
Vec::new()
}
fn mouse_hits_transcript_scrollbar(app: &App, mouse: MouseEvent) -> bool {
let Some(area) = app.viewport.last_transcript_area else {
return false;
};
if area.width <= 1 || app.viewport.last_transcript_total <= app.viewport.last_transcript_visible
{
return false;
}
let scrollbar_col = area.x.saturating_add(area.width.saturating_sub(1));
mouse.column == scrollbar_col
&& mouse.row >= area.y
&& mouse.row < area.y.saturating_add(area.height)
}
fn scroll_transcript_to_mouse_row(app: &mut App, row: u16) -> bool {
let Some(area) = app.viewport.last_transcript_area else {
return false;
};
let total = app.viewport.last_transcript_total;
let visible = app.viewport.last_transcript_visible;
if area.height == 0 || total <= visible {
return false;
}
let max_start = total.saturating_sub(visible);
if max_start == 0 {
app.scroll_to_bottom();
return true;
}
let max_row = usize::from(area.height.saturating_sub(1));
let relative_row = usize::from(row.saturating_sub(area.y)).min(max_row);
let numerator = relative_row
.saturating_mul(max_start)
.saturating_add(max_row / 2);
let top = numerator.checked_div(max_row).unwrap_or(0);
app.viewport.transcript_scroll = if top >= max_start {
TranscriptScroll::to_bottom()
} else {
TranscriptScroll::at_line(top)
};
app.viewport.pending_scroll_delta = 0;
app.user_scrolled_during_stream = !app.viewport.transcript_scroll.is_at_tail();
app.needs_redraw = true;
true
}
fn mouse_hits_rect(mouse: MouseEvent, area: Option<Rect>) -> bool {
let Some(area) = area else {
return false;
};
mouse.column >= area.x
&& mouse.column < area.x.saturating_add(area.width)
&& mouse.row >= area.y
&& mouse.row < area.y.saturating_add(area.height)
}
fn open_context_menu(app: &mut App, mouse: MouseEvent) {
let entries = build_context_menu_entries(app, mouse);
if entries.is_empty() {
return;
}
app.view_stack
.push(ContextMenuView::new(entries, mouse.column, mouse.row));
app.needs_redraw = true;
}
fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec<ContextMenuEntry> {
let mut entries = Vec::new();
if selection_has_content(app) {
entries.push(ContextMenuEntry {
label: "Copy selection".to_string(),
description: "write selected transcript text".to_string(),
action: ContextMenuAction::CopySelection,
});
entries.push(ContextMenuEntry {
label: "Open selection".to_string(),
description: "show selected text in pager".to_string(),
action: ContextMenuAction::OpenSelection,
});
entries.push(ContextMenuEntry {
label: "Clear selection".to_string(),
description: String::new(),
action: ContextMenuAction::ClearSelection,
});
}
if let Some(filtered_cell_index) = transcript_cell_index_from_mouse(app, mouse) {
let cell_index = app
.collapsed_cell_map
.get(filtered_cell_index)
.copied()
.unwrap_or(filtered_cell_index);
let target = detail_target_label(app, cell_index)
.map(|label| truncate_line_to_width(&label, 28))
.unwrap_or_else(|| "message".to_string());
entries.push(ContextMenuEntry {
label: "Open details".to_string(),
description: target,
action: ContextMenuAction::OpenDetails { cell_index },
});
entries.push(ContextMenuEntry {
label: "Copy message".to_string(),
description: "write clicked transcript cell".to_string(),
action: ContextMenuAction::CopyCell { cell_index },
});
entries.push(ContextMenuEntry {
label: "Open in editor".to_string(),
description: "open file:line in $EDITOR".to_string(),
action: ContextMenuAction::OpenFileAtLine { cell_index },
});
if app.collapsed_cells.contains(&cell_index) {
entries.push(ContextMenuEntry {
label: "Show cell".to_string(),
description: "unhide this transcript cell".to_string(),
action: ContextMenuAction::ShowCell { cell_index },
});
} else {
entries.push(ContextMenuEntry {
label: "Hide cell".to_string(),
description: "collapse this transcript cell".to_string(),
action: ContextMenuAction::HideCell { cell_index },
});
}
}
if !app.collapsed_cells.is_empty() {
let count = app.collapsed_cells.len();
entries.push(ContextMenuEntry {
label: format!("Show hidden ({count})"),
description: "unhide all collapsed cells".to_string(),
action: ContextMenuAction::ShowAllHidden,
});
}
entries.push(ContextMenuEntry {
label: "Paste".to_string(),
description: "insert clipboard into composer".to_string(),
action: ContextMenuAction::Paste,
});
entries.push(ContextMenuEntry {
label: "Command palette".to_string(),
description: "commands, skills, and tools".to_string(),
action: ContextMenuAction::OpenCommandPalette,
});
entries.push(ContextMenuEntry {
label: "Context inspector".to_string(),
description: "active context and cache hints".to_string(),
action: ContextMenuAction::OpenContextInspector,
});
entries.push(ContextMenuEntry {
label: "Help".to_string(),
description: "keybindings and commands".to_string(),
action: ContextMenuAction::OpenHelp,
});
entries
}
fn transcript_cell_index_from_mouse(app: &App, mouse: MouseEvent) -> Option<usize> {
let point = selection_point_from_mouse(app, mouse)?;
app.viewport
.transcript_cache
.line_meta()
.get(point.line_index)
.and_then(|meta| meta.cell_line())
.map(|(cell_index, _)| cell_index)
}
fn handle_context_menu_action(app: &mut App, action: ContextMenuAction) {
match action {
ContextMenuAction::CopySelection => {
copy_active_selection(app);
}
ContextMenuAction::OpenSelection => {
if !open_pager_for_selection(app) {
app.status_message = Some("No selection to open".to_string());
}
}
ContextMenuAction::ClearSelection => {
app.viewport.transcript_selection.clear();
app.status_message = Some("Selection cleared".to_string());
}
ContextMenuAction::CopyCell { cell_index } => {
copy_cell_to_clipboard(app, cell_index);
}
ContextMenuAction::OpenDetails { cell_index } => {
if !open_details_pager_for_cell(app, cell_index) {
app.status_message = Some("No details available for that line".to_string());
}
}
ContextMenuAction::Paste => {
app.paste_from_clipboard();
}
ContextMenuAction::OpenCommandPalette => {
app.view_stack
.push(CommandPaletteView::new(build_command_palette_entries(
app.ui_locale,
&app.skills_dir,
&app.workspace,
&app.mcp_config_path,
app.mcp_snapshot.as_ref(),
)));
}
ContextMenuAction::OpenContextInspector => {
open_context_inspector(app);
}
ContextMenuAction::OpenHelp => {
app.view_stack.push(HelpView::new_for_locale(app.ui_locale));
}
ContextMenuAction::OpenFileAtLine { cell_index } => {
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
let text = history_cell_to_text(
app.cell_at_virtual_index(cell_index)
.unwrap_or(&HistoryCell::System {
content: String::new(),
}),
width,
);
if crate::tui::history::try_open_file_at_line(&text, &app.workspace) {
app.status_message = Some("Opened file in editor".to_string());
} else {
app.status_message = Some("No file:line pattern found in selection".to_string());
}
}
ContextMenuAction::HideCell { cell_index } => {
app.collapsed_cells.insert(cell_index);
app.status_message = Some("Cell hidden".to_string());
}
ContextMenuAction::ShowCell { cell_index } => {
app.collapsed_cells.remove(&cell_index);
app.status_message = Some("Cell shown".to_string());
}
ContextMenuAction::ShowAllHidden => {
let count = app.collapsed_cells.len();
app.collapsed_cells.clear();
app.status_message = Some(format!("{count} hidden cell(s) restored"));
}
}
app.needs_redraw = true;
}
fn selection_point_from_mouse(app: &App, mouse: MouseEvent) -> Option<TranscriptSelectionPoint> {
selection_point_from_position(
app.viewport.last_transcript_area?,
mouse.column,
mouse.row,
app.viewport.last_transcript_top,
app.viewport.last_transcript_total,
app.viewport.last_transcript_padding_top,
)
}
fn selection_point_from_position(
area: Rect,
column: u16,
row: u16,
transcript_top: usize,
transcript_total: usize,
padding_top: usize,
) -> Option<TranscriptSelectionPoint> {
if column < area.x
|| column >= area.x + area.width
|| row < area.y
|| row >= area.y + area.height
{
return None;
}
if transcript_total == 0 {
return None;
}
let row = row.saturating_sub(area.y) as usize;
if row < padding_top {
return None;
}
let row = row.saturating_sub(padding_top);
let col = column.saturating_sub(area.x) as usize;
let line_index = transcript_top
.saturating_add(row)
.min(transcript_total.saturating_sub(1));
Some(TranscriptSelectionPoint {
line_index,
column: col,
})
}
fn selection_has_content(app: &App) -> bool {
selection_to_text(app).is_some_and(|text| !text.is_empty())
}
fn copy_active_selection(app: &mut App) {
if !app.viewport.transcript_selection.is_active() {
return;
}
if let Some(text) = selection_to_text(app).filter(|text| !text.is_empty()) {
if app.clipboard.write_text(&text).is_ok() {
app.status_message = Some("Selection copied".to_string());
} else {
app.status_message = Some("Copy failed".to_string());
}
} else {
app.viewport.transcript_selection.clear();
app.status_message = Some("No selection to copy".to_string());
}
}
fn selection_to_text(app: &App) -> Option<String> {
let (start, end) = app.viewport.transcript_selection.ordered_endpoints()?;
let lines = app.viewport.transcript_cache.lines();
if lines.is_empty() {
return None;
}
let end_index = end.line_index.min(lines.len().saturating_sub(1));
let start_index = start.line_index.min(end_index);
let mut selected_lines = Vec::new();
#[allow(clippy::needless_range_loop)]
for line_index in start_index..=end_index {
let line_text = line_to_plain(&lines[line_index]);
let line_width = text_display_width(&line_text);
let (col_start, col_end) = if start_index == end_index {
(start.column, end.column)
} else if line_index == start_index {
(start.column, line_width)
} else if line_index == end_index {
(0, end.column)
} else {
(0, line_width)
};
let slice = slice_text(&line_text, col_start, col_end);
selected_lines.push(slice);
}
Some(selected_lines.join("\n"))
}
fn open_pager_for_selection(app: &mut App) -> bool {
let Some(text) = selection_to_text(app) else {
return false;
};
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
let pager = PagerView::from_text("Selection", &text, width.saturating_sub(2));
app.view_stack.push(pager);
true
}
fn open_pager_for_last_message(app: &mut App) -> bool {
let Some(cell) = app.history.last() else {
return false;
};
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
let text = history_cell_to_text(cell, width);
let pager = PagerView::from_text("Message", &text, width.saturating_sub(2));
app.view_stack.push(pager);
true
}
fn open_thinking_pager(app: &mut App) -> bool {
let selected_cell = app
.viewport
.transcript_selection
.ordered_endpoints()
.and_then(|(start, _)| {
app.viewport
.transcript_cache
.line_meta()
.get(start.line_index)
.and_then(|meta| meta.cell_line())
.map(|(cell_index, _)| cell_index)
})
.filter(|&idx| {
matches!(
app.history.get(idx),
Some(crate::tui::history::HistoryCell::Thinking { .. })
)
});
let target_idx = selected_cell.or_else(|| {
app.history
.iter()
.enumerate()
.rev()
.find_map(|(idx, cell)| {
if matches!(cell, crate::tui::history::HistoryCell::Thinking { .. }) {
Some(idx)
} else {
None
}
})
});
let Some(idx) = target_idx else {
app.status_message = Some("No thinking blocks to expand".to_string());
return true;
};
let cell = &app.history[idx];
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
let text = history_cell_to_text(cell, width);
app.view_stack.push(PagerView::from_text(
"Thinking",
&text,
width.saturating_sub(2),
));
true
}
fn open_tool_details_pager(app: &mut App) -> bool {
let target_cell = detail_target_cell_index(app);
let Some(cell_index) = target_cell else {
return false;
};
open_details_pager_for_cell(app, cell_index)
}
fn spillover_pager_section(app: &App, cell_index: usize) -> Option<String> {
use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell};
let cell = app.cell_at_virtual_index(cell_index)?;
let HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
spillover_path: Some(path),
..
})) = cell
else {
return None;
};
let path_str = path.display().to_string();
let body = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(err) => format!("(could not read spillover file: {err})"),
};
Some(format!(
"── Full output (spillover) ──\nFile: {path_str}\n\n{body}"
))
}
fn open_details_pager_for_cell(app: &mut App, cell_index: usize) -> bool {
if let Some(detail) = app.tool_detail_record_for_cell(cell_index) {
let input = serde_json::to_string_pretty(&detail.input)
.unwrap_or_else(|_| detail.input.to_string());
let output = detail.output.as_deref().map_or(
"(not available)".to_string(),
std::string::ToString::to_string,
);
let spillover_section = spillover_pager_section(app, cell_index);
let content = if let Some(section) = spillover_section {
format!(
"Tool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}\n\n{}",
detail.tool_id, detail.tool_name, input, output, section
)
} else {
format!(
"Tool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}",
detail.tool_id, detail.tool_name, input, output
)
};
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
app.view_stack.push(PagerView::from_text(
format!("Tool: {}", detail.tool_name),
&content,
width.saturating_sub(2),
));
return true;
}
let Some(cell) = app.cell_at_virtual_index(cell_index) else {
app.status_message = Some("No details available for the selected line".to_string());
return false;
};
let title = match cell {
HistoryCell::User { .. } => "You".to_string(),
HistoryCell::Assistant { .. } => "Assistant".to_string(),
HistoryCell::System { .. } => "Note".to_string(),
HistoryCell::Error { .. } => "Error".to_string(),
HistoryCell::Thinking { .. } => "Reasoning".to_string(),
HistoryCell::Tool(_) => "Message".to_string(),
HistoryCell::SubAgent(_) => "Sub-agent".to_string(),
HistoryCell::ArchivedContext { .. } => "Archived Context".to_string(),
};
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
let content = history_cell_to_text(cell, width);
app.view_stack.push(PagerView::from_text(
title,
&content,
width.saturating_sub(2),
));
true
}
fn copy_focused_cell(app: &mut App) -> bool {
let cell_index = detail_target_cell_index(app);
let Some(index) = cell_index else {
return false;
};
copy_cell_to_clipboard(app, index)
}
fn copy_cell_to_clipboard(app: &mut App, cell_index: usize) -> bool {
let Some(cell) = app.cell_at_virtual_index(cell_index) else {
app.status_message = Some("No message at that line".to_string());
return false;
};
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
let text = history_cell_to_text(cell, width);
if text.trim().is_empty() {
app.status_message = Some("Message is empty".to_string());
return false;
}
if app.clipboard.write_text(&text).is_ok() {
app.status_message = Some("Message copied".to_string());
true
} else {
app.status_message = Some("Copy failed".to_string());
false
}
}
fn detail_target_cell_index(app: &App) -> Option<usize> {
if let Some((start, _)) = app.viewport.transcript_selection.ordered_endpoints() {
return app
.viewport
.transcript_cache
.line_meta()
.get(start.line_index)
.and_then(|meta| meta.cell_line())
.map(|(cell_index, _)| cell_index);
}
app.detail_cell_index_for_viewport(
app.viewport.last_transcript_top,
app.viewport.last_transcript_visible.max(1),
app.viewport.transcript_cache.line_meta(),
)
.or_else(|| app.history.len().checked_sub(1))
}
fn selected_detail_footer_label(app: &App) -> Option<String> {
if app.viewport.transcript_selection.is_active() {
return None;
}
let cell_index = app.detail_cell_index_for_viewport(
app.viewport.last_transcript_top,
app.viewport.last_transcript_visible.max(1),
app.viewport.transcript_cache.line_meta(),
)?;
let label = detail_target_label(app, cell_index)?;
Some(format!(
"Alt+V details: {}",
truncate_line_to_width(&label, 34)
))
}
fn detail_target_label(app: &App, cell_index: usize) -> Option<String> {
if let Some(detail) = app.tool_detail_record_for_cell(cell_index) {
return Some(detail.tool_name.clone());
}
let cell = app.cell_at_virtual_index(cell_index)?;
match cell {
HistoryCell::Tool(ToolCell::Exec(exec)) => {
Some(format!("run {}", one_line_summary(&exec.command, 80)))
}
HistoryCell::Tool(ToolCell::Exploring(explore)) => Some(format!(
"workspace {} item{}",
explore.entries.len(),
if explore.entries.len() == 1 { "" } else { "s" }
)),
HistoryCell::Tool(ToolCell::PlanUpdate(_)) => Some("update plan".to_string()),
HistoryCell::Tool(ToolCell::PatchSummary(patch)) => Some(format!("patch {}", patch.path)),
HistoryCell::Tool(ToolCell::Review(review)) => {
let target = one_line_summary(&review.target, 80);
Some(if target.is_empty() {
"review".to_string()
} else {
format!("review {target}")
})
}
HistoryCell::Tool(ToolCell::DiffPreview(diff)) => Some(format!("diff {}", diff.title)),
HistoryCell::Tool(ToolCell::Mcp(mcp)) => Some(format!("tool {}", mcp.tool)),
HistoryCell::Tool(ToolCell::ViewImage(image)) => {
Some(format!("image {}", image.path.display()))
}
HistoryCell::Tool(ToolCell::WebSearch(search)) => Some(format!("search {}", search.query)),
HistoryCell::Tool(ToolCell::Generic(generic)) => Some(format!("tool {}", generic.name)),
HistoryCell::SubAgent(_) => Some("sub-agent".to_string()),
_ => None,
}
}
fn is_copy_shortcut(key: &KeyEvent) -> bool {
let is_c = matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'));
if !is_c {
return false;
}
if key.modifiers.contains(KeyModifiers::SUPER) {
return true;
}
key.modifiers.contains(KeyModifiers::CONTROL) && key.modifiers.contains(KeyModifiers::SHIFT)
}
fn is_file_tree_toggle_shortcut(key: &KeyEvent) -> bool {
let is_shifted_e = matches!(key.code, KeyCode::Char('E'))
|| (matches!(key.code, KeyCode::Char('e')) && key.modifiers.contains(KeyModifiers::SHIFT));
if !is_shifted_e {
return false;
}
let has_forbidden_modifier =
key.modifiers.contains(KeyModifiers::ALT) || key.modifiers.contains(KeyModifiers::SUPER);
let ctrl_shift_e = key.modifiers.contains(KeyModifiers::CONTROL) && !has_forbidden_modifier;
let cmd_shift_e = key.modifiers.contains(KeyModifiers::SUPER)
&& key.modifiers.contains(KeyModifiers::SHIFT)
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT);
ctrl_shift_e || cmd_shift_e
}
fn details_shortcut_modifiers(modifiers: KeyModifiers) -> bool {
modifiers.is_empty()
|| modifiers == KeyModifiers::SHIFT
|| (modifiers.contains(KeyModifiers::ALT)
&& !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::SUPER))
}
fn is_paste_shortcut(key: &KeyEvent) -> bool {
let is_v = matches!(key.code, KeyCode::Char('v') | KeyCode::Char('V'));
let is_legacy_ctrl_v = matches!(key.code, KeyCode::Char('\u{16}'));
if !is_v && !is_legacy_ctrl_v {
return false;
}
if is_legacy_ctrl_v {
return true;
}
if key.modifiers.contains(KeyModifiers::SUPER) {
return true;
}
key.modifiers.contains(KeyModifiers::CONTROL)
}
fn is_text_input_key(key: &KeyEvent) -> bool {
if matches!(key.code, KeyCode::Char(c) if c.is_control()) {
return false;
}
!key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT)
&& !key.modifiers.contains(KeyModifiers::SUPER)
}
fn is_ctrl_h_backspace(key: &KeyEvent) -> bool {
matches!(key.code, KeyCode::Char('h'))
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT)
&& !key.modifiers.contains(KeyModifiers::SUPER)
}
fn should_scroll_with_arrows(app: &App) -> bool {
app.input.trim().is_empty()
}
fn extract_reasoning_header(text: &str) -> Option<String> {
let start = text.find("**")?;
let rest = &text[start + 2..];
let end = rest.find("**")?;
let header = rest[..end].trim().trim_end_matches(':');
if header.is_empty() {
None
} else {
Some(header.to_string())
}
}
#[cfg(test)]
mod tests;