use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use ratatui::layout::Rect;
use serde_json::Value;
use thiserror::Error;
use crate::compaction::CompactionConfig;
use crate::config::{ApiProvider, Config, has_api_key, save_api_key};
use crate::core::coherence::CoherenceState;
use crate::cycle_manager::{CycleBriefing, CycleConfig};
use crate::hooks::{HookContext, HookEvent, HookExecutor, HookResult};
use crate::models::{
Message, SystemPrompt, compaction_message_threshold_for_model,
compaction_threshold_for_model_and_effort,
};
use crate::palette::{self, UiTheme};
use crate::settings::Settings;
use crate::tools::plan::{SharedPlanState, new_shared_plan_state};
use crate::tools::subagent::SubAgentResult;
use crate::tools::todo::{SharedTodoList, new_shared_todo_list};
use crate::tui::active_cell::ActiveCell;
use crate::tui::approval::ApprovalMode;
use crate::tui::clipboard::{ClipboardContent, ClipboardHandler};
use crate::tui::history::{HistoryCell, TranscriptRenderOptions};
use crate::tui::paste_burst::{FlushResult, PasteBurst};
use crate::tui::scrolling::{MouseScrollState, TranscriptScroll};
use crate::tui::selection::TranscriptSelection;
use crate::tui::streaming::StreamingState;
use crate::tui::transcript::TranscriptViewCache;
use crate::tui::views::ViewStack;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnboardingState {
Welcome,
ApiKey,
TrustDirectory,
Tips,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode {
Agent,
Yolo,
Plan,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum ReasoningEffort {
Off,
Low,
Medium,
High,
#[default]
Max,
}
impl ReasoningEffort {
#[must_use]
pub fn from_setting(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"off" | "disabled" | "none" | "false" => Self::Off,
"low" | "minimal" => Self::Low,
"medium" | "mid" => Self::Medium,
"high" => Self::High,
"max" | "maximum" | "xhigh" => Self::Max,
_ => Self::default(),
}
}
#[must_use]
pub fn as_setting(self) -> &'static str {
match self {
Self::Off => "off",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Max => "max",
}
}
#[must_use]
pub fn short_label(self) -> &'static str {
match self {
Self::Off => "off",
Self::Low => "low",
Self::Medium => "med",
Self::High => "high",
Self::Max => "max",
}
}
#[must_use]
pub fn api_value(self) -> Option<&'static str> {
Some(self.as_setting())
}
#[must_use]
pub fn cycle_next(self) -> Self {
match self {
Self::Off => Self::High,
Self::Low | Self::Medium | Self::High => Self::Max,
Self::Max => Self::Off,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SidebarFocus {
Auto,
Plan,
Todos,
Tasks,
Agents,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComposerDensity {
Compact,
Comfortable,
Spacious,
}
impl ComposerDensity {
#[must_use]
pub fn from_setting(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"compact" | "tight" => Self::Compact,
"spacious" | "loose" => Self::Spacious,
_ => Self::Comfortable,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TranscriptSpacing {
Compact,
Comfortable,
Spacious,
}
impl TranscriptSpacing {
#[must_use]
pub fn from_setting(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"compact" | "tight" => Self::Compact,
"spacious" | "loose" => Self::Spacious,
_ => Self::Comfortable,
}
}
}
impl SidebarFocus {
#[must_use]
pub fn from_setting(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"plan" => Self::Plan,
"todos" => Self::Todos,
"tasks" => Self::Tasks,
"agents" | "subagents" | "sub-agents" => Self::Agents,
_ => Self::Auto,
}
}
#[must_use]
#[allow(dead_code)]
pub fn as_setting(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Plan => "plan",
Self::Todos => "todos",
Self::Tasks => "tasks",
Self::Agents => "agents",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusToastLevel {
Info,
Success,
Warning,
Error,
}
#[derive(Debug, Clone)]
pub struct StatusToast {
pub text: String,
pub level: StatusToastLevel,
pub created_at: Instant,
pub ttl_ms: Option<u64>,
}
impl StatusToast {
#[must_use]
pub fn new(text: impl Into<String>, level: StatusToastLevel, ttl_ms: Option<u64>) -> Self {
Self {
text: text.into(),
level,
created_at: Instant::now(),
ttl_ms,
}
}
#[must_use]
pub fn is_expired(&self, now: Instant) -> bool {
self.ttl_ms
.is_some_and(|ttl| now.duration_since(self.created_at).as_millis() >= u128::from(ttl))
}
}
fn char_count(text: &str) -> usize {
text.chars().count()
}
fn byte_index_at_char(text: &str, char_index: usize) -> usize {
if char_index == 0 {
return 0;
}
text.char_indices()
.nth(char_index)
.map(|(idx, _)| idx)
.unwrap_or_else(|| text.len())
}
fn remove_char_at(text: &mut String, char_index: usize) -> bool {
let start = byte_index_at_char(text, char_index);
if start >= text.len() {
return false;
}
let ch = text[start..].chars().next().unwrap();
let end = start + ch.len_utf8();
text.replace_range(start..end, "");
true
}
fn normalize_paste_text(text: &str) -> String {
if text.contains('\r') {
text.replace("\r\n", "\n").replace('\r', "")
} else {
text.to_string()
}
}
fn sanitize_api_key_text(text: &str) -> String {
text.chars().filter(|c| !c.is_control()).collect()
}
const MAX_SUBMITTED_INPUT_CHARS: usize = 16_000;
impl AppMode {
#[must_use]
pub fn from_setting(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"plan" => Self::Plan,
"yolo" => Self::Yolo,
_ => Self::Agent,
}
}
#[must_use]
pub fn as_setting(self) -> &'static str {
match self {
Self::Agent => "agent",
Self::Yolo => "yolo",
Self::Plan => "plan",
}
}
pub fn label(self) -> &'static str {
match self {
AppMode::Agent => "AGENT",
AppMode::Yolo => "YOLO",
AppMode::Plan => "PLAN",
}
}
#[allow(dead_code)]
pub fn description(self) -> &'static str {
match self {
AppMode::Agent => "Agent mode - autonomous task execution with tools",
AppMode::Yolo => "YOLO mode - full tool access without approvals",
AppMode::Plan => "Plan mode - design before implementing",
}
}
}
#[derive(Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct TuiOptions {
pub model: String,
pub workspace: PathBuf,
pub allow_shell: bool,
pub use_alt_screen: bool,
pub use_mouse_capture: bool,
pub use_bracketed_paste: bool,
pub max_subagents: usize,
#[allow(dead_code)]
pub skills_dir: PathBuf,
#[allow(dead_code)]
pub memory_path: PathBuf,
#[allow(dead_code)]
pub notes_path: PathBuf,
#[allow(dead_code)]
pub mcp_config_path: PathBuf,
#[allow(dead_code)]
pub use_memory: bool,
pub start_in_agent_mode: bool,
pub skip_onboarding: bool,
pub yolo: bool,
pub resume_session_id: Option<String>,
}
#[derive(Debug, Clone, Copy)]
struct YoloRestoreState {
allow_shell: bool,
trust_mode: bool,
approval_mode: ApprovalMode,
}
#[allow(clippy::struct_excessive_bools)]
pub struct App {
pub mode: AppMode,
pub input: String,
pub cursor_position: usize,
pub kill_buffer: String,
pub paste_burst: PasteBurst,
pub history: Vec<HistoryCell>,
pub history_version: u64,
pub history_revisions: Vec<u64>,
pub next_history_revision: u64,
pub api_messages: Vec<Message>,
pub transcript_scroll: TranscriptScroll,
pub pending_scroll_delta: i32,
pub mouse_scroll: MouseScrollState,
pub transcript_cache: TranscriptViewCache,
pub transcript_selection: TranscriptSelection,
pub last_transcript_area: Option<Rect>,
pub last_transcript_top: usize,
pub last_transcript_visible: usize,
pub last_transcript_total: usize,
pub last_transcript_padding_top: usize,
pub is_loading: bool,
pub offline_mode: bool,
pub status_message: Option<String>,
pub status_toasts: VecDeque<StatusToast>,
pub sticky_status: Option<StatusToast>,
pub last_status_message_seen: Option<String>,
pub model: String,
pub api_provider: ApiProvider,
pub reasoning_effort: ReasoningEffort,
pub workspace: PathBuf,
pub skills_dir: PathBuf,
pub use_alt_screen: bool,
pub use_mouse_capture: bool,
pub use_bracketed_paste: bool,
#[allow(dead_code)]
pub system_prompt: Option<SystemPrompt>,
pub input_history: Vec<String>,
pub history_index: Option<usize>,
pub auto_compact: bool,
pub calm_mode: bool,
pub low_motion: bool,
#[allow(dead_code)]
pub fancy_animations: bool,
pub show_thinking: bool,
pub show_tool_details: bool,
pub composer_density: ComposerDensity,
pub composer_border: bool,
pub transcript_spacing: TranscriptSpacing,
pub sidebar_width_percent: u16,
pub sidebar_focus: SidebarFocus,
pub slash_menu_selected: usize,
pub slash_menu_hidden: bool,
pub mention_menu_selected: usize,
pub mention_menu_hidden: bool,
#[allow(dead_code)]
pub compact_threshold: usize,
pub max_input_history: usize,
pub total_tokens: u32,
pub total_conversation_tokens: u32,
pub allow_shell: bool,
pub max_subagents: usize,
pub subagent_cache: Vec<SubAgentResult>,
pub agent_progress: HashMap<String, String>,
pub subagent_card_index: HashMap<String, usize>,
pub last_fanout_card_index: Option<usize>,
pub pending_subagent_dispatch: Option<String>,
pub agent_activity_started_at: Option<Instant>,
pub ui_theme: UiTheme,
pub onboarding: OnboardingState,
pub onboarding_needs_api_key: bool,
pub api_key_input: String,
pub api_key_cursor: usize,
pub hooks: HookExecutor,
#[allow(dead_code)]
pub yolo: bool,
yolo_restore: Option<YoloRestoreState>,
pub clipboard: ClipboardHandler,
pub approval_session_approved: HashSet<String>,
pub approval_mode: ApprovalMode,
pub view_stack: ViewStack,
pub backtrack: crate::tui::backtrack::BacktrackState,
pub current_session_id: Option<String>,
pub trust_mode: bool,
pub status_items: Vec<crate::config::StatusItem>,
#[allow(dead_code)]
pub project_doc: Option<String>,
pub plan_state: SharedPlanState,
pub plan_prompt_pending: bool,
pub plan_tool_used_in_turn: bool,
#[allow(dead_code)] pub todos: SharedTodoList,
pub tool_log: Vec<String>,
pub session_cost: f64,
pub active_skill: Option<String>,
pub tool_cells: HashMap<String, usize>,
pub tool_details_by_cell: HashMap<usize, ToolDetailRecord>,
pub active_cell: Option<ActiveCell>,
pub active_cell_revision: u64,
pub active_tool_details: HashMap<String, ToolDetailRecord>,
pub exploring_cell: Option<usize>,
pub exploring_entries: HashMap<String, (usize, usize)>,
pub ignored_tool_calls: HashSet<String>,
pub last_exec_wait_command: Option<String>,
pub streaming_message_index: Option<usize>,
pub streaming_thinking_active_entry: Option<usize>,
pub streaming_state: StreamingState,
pub reasoning_buffer: String,
pub reasoning_header: Option<String>,
pub last_reasoning: Option<String>,
pub pending_tool_uses: Vec<(String, String, Value)>,
pub queued_messages: VecDeque<QueuedMessage>,
pub queued_draft: Option<QueuedMessage>,
pub pending_steers: VecDeque<QueuedMessage>,
pub rejected_steers: VecDeque<String>,
pub submit_pending_steers_after_interrupt: bool,
pub turn_started_at: Option<Instant>,
pub runtime_turn_id: Option<String>,
pub runtime_turn_status: Option<String>,
pub last_prompt_tokens: Option<u32>,
pub last_completion_tokens: Option<u32>,
pub last_prompt_cache_hit_tokens: Option<u32>,
pub last_prompt_cache_miss_tokens: Option<u32>,
pub last_reasoning_replay_tokens: Option<u32>,
pub workspace_context: Option<String>,
pub workspace_context_refreshed_at: Option<Instant>,
pub task_panel: Vec<TaskPanelEntry>,
pub needs_redraw: bool,
pub thinking_started_at: Option<Instant>,
pub is_compacting: bool,
pub user_scrolled_during_stream: bool,
pub coherence_state: CoherenceState,
pub last_send_at: Option<Instant>,
pub quit_armed_until: Option<Instant>,
pub cycle_count: u32,
pub cycle_briefings: Vec<CycleBriefing>,
pub cycle: CycleConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueuedMessage {
pub display: String,
pub skill_instruction: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubmitDisposition {
Immediate,
Queue,
Steer,
}
#[derive(Debug, Clone)]
pub struct ToolDetailRecord {
pub tool_id: String,
pub tool_name: String,
pub input: Value,
pub output: Option<String>,
}
#[derive(Debug, Clone)]
pub struct TaskPanelEntry {
pub id: String,
pub status: String,
pub prompt_summary: String,
pub duration_ms: Option<u64>,
}
impl QueuedMessage {
pub fn new(display: String, skill_instruction: Option<String>) -> Self {
Self {
display,
skill_instruction,
}
}
#[allow(dead_code)] pub fn content(&self) -> String {
if let Some(skill_instruction) = self.skill_instruction.as_ref() {
format!(
"{skill_instruction}\n\n---\n\nUser request: {}",
self.display
)
} else {
self.display.clone()
}
}
}
#[derive(Debug, Error)]
pub enum ApiKeyError {
#[error("Failed to save API key: API key cannot be empty")]
Empty,
#[error("Failed to save API key: {source}")]
SaveFailed { source: anyhow::Error },
}
impl App {
#[allow(clippy::too_many_lines)]
pub fn new(options: TuiOptions, config: &Config) -> Self {
let TuiOptions {
model,
workspace,
allow_shell,
use_alt_screen,
use_mouse_capture,
use_bracketed_paste,
max_subagents,
skills_dir: global_skills_dir,
memory_path: _,
notes_path: _,
mcp_config_path: _,
use_memory: _,
start_in_agent_mode,
skip_onboarding,
yolo,
resume_session_id: _,
} = options;
let needs_api_key = !has_api_key(config);
let was_onboarded = crate::tui::onboarding::is_onboarded();
let needs_onboarding = !skip_onboarding && (!was_onboarded || needs_api_key);
let settings = Settings::load().unwrap_or_else(|_| Settings::default());
let auto_compact = settings.auto_compact;
let calm_mode = settings.calm_mode;
let low_motion = settings.low_motion;
let fancy_animations = settings.fancy_animations;
let show_thinking = settings.show_thinking;
let show_tool_details = settings.show_tool_details;
let composer_density = ComposerDensity::from_setting(&settings.composer_density);
let composer_border = settings.composer_border;
let transcript_spacing = TranscriptSpacing::from_setting(&settings.transcript_spacing);
let sidebar_width_percent = settings.sidebar_width_percent;
let sidebar_focus = SidebarFocus::from_setting(&settings.sidebar_focus);
let max_input_history = settings.max_input_history;
let ui_theme = palette::UI_THEME;
let model = settings.default_model.clone().unwrap_or(model);
let compact_threshold =
compaction_threshold_for_model_and_effort(&model, config.reasoning_effort());
let preferred_mode = AppMode::from_setting(&settings.default_mode);
let initial_mode = if yolo {
AppMode::Yolo
} else if start_in_agent_mode {
AppMode::Agent
} else {
preferred_mode
};
let yolo_restore = if initial_mode == AppMode::Yolo {
Some(YoloRestoreState {
allow_shell: config.allow_shell(),
trust_mode: false,
approval_mode: ApprovalMode::Suggest,
})
} else {
None
};
let allow_shell = allow_shell || initial_mode == AppMode::Yolo;
let hooks_config = config.hooks_config();
let hooks = HookExecutor::new(hooks_config, workspace.clone());
let plan_state = new_shared_plan_state();
let agents_skills_dir = workspace.join(".agents").join("skills");
let local_skills_dir = workspace.join("skills");
let skills_dir = if agents_skills_dir.exists() {
agents_skills_dir
} else if local_skills_dir.exists() {
local_skills_dir
} else {
global_skills_dir
};
Self {
mode: initial_mode,
input: String::new(),
cursor_position: 0,
kill_buffer: String::new(),
paste_burst: PasteBurst::default(),
history: Vec::new(),
history_version: 0,
history_revisions: Vec::new(),
next_history_revision: 1,
api_messages: Vec::new(),
transcript_scroll: TranscriptScroll::to_bottom(),
pending_scroll_delta: 0,
mouse_scroll: MouseScrollState::new(),
transcript_cache: TranscriptViewCache::new(),
transcript_selection: TranscriptSelection::default(),
last_transcript_area: None,
last_transcript_top: 0,
last_transcript_visible: 0,
last_transcript_total: 0,
last_transcript_padding_top: 0,
is_loading: false,
offline_mode: false,
status_message: None,
status_toasts: VecDeque::new(),
sticky_status: None,
last_status_message_seen: None,
model,
api_provider: config.api_provider(),
reasoning_effort: config
.reasoning_effort()
.map_or_else(ReasoningEffort::default, |s| {
ReasoningEffort::from_setting(s)
}),
workspace,
skills_dir,
use_alt_screen,
use_mouse_capture,
use_bracketed_paste,
system_prompt: None,
input_history: Vec::new(),
history_index: None,
auto_compact,
calm_mode,
low_motion,
fancy_animations,
show_thinking,
show_tool_details,
composer_density,
composer_border,
transcript_spacing,
sidebar_width_percent,
sidebar_focus,
slash_menu_selected: 0,
mention_menu_selected: 0,
mention_menu_hidden: false,
slash_menu_hidden: false,
compact_threshold,
max_input_history,
total_tokens: 0,
total_conversation_tokens: 0,
allow_shell,
max_subagents,
subagent_cache: Vec::new(),
agent_progress: HashMap::new(),
subagent_card_index: HashMap::new(),
last_fanout_card_index: None,
pending_subagent_dispatch: None,
agent_activity_started_at: None,
ui_theme,
onboarding: if needs_onboarding {
if was_onboarded && needs_api_key {
OnboardingState::ApiKey
} else {
OnboardingState::Welcome
}
} else {
OnboardingState::None
},
onboarding_needs_api_key: needs_api_key,
api_key_input: String::new(),
api_key_cursor: 0,
hooks,
yolo: initial_mode == AppMode::Yolo,
yolo_restore,
clipboard: ClipboardHandler::new(),
approval_session_approved: HashSet::new(),
approval_mode: if matches!(initial_mode, AppMode::Yolo) {
ApprovalMode::Auto
} else {
ApprovalMode::Suggest
},
view_stack: ViewStack::new(),
backtrack: crate::tui::backtrack::BacktrackState::new(),
current_session_id: None,
trust_mode: initial_mode == AppMode::Yolo,
status_items: config
.tui
.as_ref()
.and_then(|tui| tui.status_items.clone())
.unwrap_or_else(crate::config::StatusItem::default_footer),
project_doc: None,
plan_state,
plan_prompt_pending: false,
plan_tool_used_in_turn: false,
todos: new_shared_todo_list(),
tool_log: Vec::new(),
session_cost: 0.0,
active_skill: None,
tool_cells: HashMap::new(),
tool_details_by_cell: HashMap::new(),
active_cell: None,
active_cell_revision: 0,
active_tool_details: HashMap::new(),
exploring_cell: None,
exploring_entries: HashMap::new(),
ignored_tool_calls: HashSet::new(),
last_exec_wait_command: None,
streaming_message_index: None,
streaming_thinking_active_entry: None,
streaming_state: StreamingState::new(),
reasoning_buffer: String::new(),
reasoning_header: None,
last_reasoning: None,
pending_tool_uses: Vec::new(),
queued_messages: VecDeque::new(),
queued_draft: None,
pending_steers: VecDeque::new(),
rejected_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
turn_started_at: None,
runtime_turn_id: None,
runtime_turn_status: None,
last_prompt_tokens: None,
last_completion_tokens: None,
last_prompt_cache_hit_tokens: None,
last_prompt_cache_miss_tokens: None,
last_reasoning_replay_tokens: None,
workspace_context: None,
workspace_context_refreshed_at: None,
task_panel: Vec::new(),
needs_redraw: true,
thinking_started_at: None,
is_compacting: false,
user_scrolled_during_stream: false,
coherence_state: CoherenceState::default(),
last_send_at: None,
quit_armed_until: None,
cycle_count: 0,
cycle_briefings: Vec::new(),
cycle: CycleConfig::default(),
}
}
pub fn submit_api_key(&mut self) -> Result<PathBuf, ApiKeyError> {
let key = self.api_key_input.trim().to_string();
if key.is_empty() {
return Err(ApiKeyError::Empty);
}
match save_api_key(&key) {
Ok(path) => {
self.api_key_input.clear();
self.api_key_cursor = 0;
self.onboarding_needs_api_key = false;
Ok(path)
}
Err(source) => Err(ApiKeyError::SaveFailed { source }),
}
}
pub fn finish_onboarding(&mut self) {
self.onboarding = OnboardingState::None;
if let Err(err) = crate::tui::onboarding::mark_onboarded() {
self.status_message = Some(format!("Failed to mark onboarding: {err}"));
}
self.needs_redraw = true;
}
pub fn set_mode(&mut self, mode: AppMode) -> bool {
let previous_mode = self.mode;
if previous_mode == mode {
return false;
}
let entering_yolo = mode == AppMode::Yolo && previous_mode != AppMode::Yolo;
let leaving_yolo = previous_mode == AppMode::Yolo && mode != AppMode::Yolo;
self.mode = mode;
self.status_message = Some(format!("Switched to {} mode", mode.label()));
if entering_yolo {
self.yolo_restore = Some(YoloRestoreState {
allow_shell: self.allow_shell,
trust_mode: self.trust_mode,
approval_mode: self.approval_mode,
});
self.allow_shell = true;
self.trust_mode = true;
self.approval_mode = ApprovalMode::Auto;
} else if leaving_yolo && let Some(restore) = self.yolo_restore.take() {
self.allow_shell = restore.allow_shell;
self.trust_mode = restore.trust_mode;
self.approval_mode = restore.approval_mode;
}
self.yolo = mode == AppMode::Yolo;
if mode != AppMode::Plan {
self.plan_prompt_pending = false;
self.plan_tool_used_in_turn = false;
}
let context = HookContext::new()
.with_mode(mode.label())
.with_previous_mode(previous_mode.label())
.with_workspace(self.workspace.clone())
.with_model(&self.model);
let _ = self.hooks.execute(HookEvent::ModeChange, &context);
self.needs_redraw = true;
true
}
pub fn cycle_mode(&mut self) {
let next = match self.mode {
AppMode::Plan => AppMode::Agent,
AppMode::Agent => AppMode::Yolo,
AppMode::Yolo => AppMode::Plan,
};
let _ = self.set_mode(next);
}
#[allow(dead_code)]
pub fn cycle_mode_reverse(&mut self) {
let next = match self.mode {
AppMode::Agent => AppMode::Plan,
AppMode::Yolo => AppMode::Agent,
AppMode::Plan => AppMode::Yolo,
};
let _ = self.set_mode(next);
}
pub fn cycle_effort(&mut self) {
self.reasoning_effort = self.reasoning_effort.cycle_next();
self.needs_redraw = true;
self.push_status_toast(
format!("Thinking: {}", self.reasoning_effort.short_label()),
StatusToastLevel::Info,
Some(1_500),
);
}
pub fn execute_hooks(&self, event: HookEvent, context: &HookContext) -> Vec<HookResult> {
self.hooks.execute(event, context)
}
pub fn base_hook_context(&self) -> HookContext {
HookContext::new()
.with_mode(self.mode.label())
.with_workspace(self.workspace.clone())
.with_model(&self.model)
.with_session_id(self.hooks.session_id())
.with_tokens(self.total_tokens)
}
pub fn add_message(&mut self, msg: HistoryCell) {
let rev = self.fresh_history_revision();
self.history.push(msg);
self.history_revisions.push(rev);
self.history_version = self.history_version.wrapping_add(1);
let selection_has_range = self
.transcript_selection
.ordered_endpoints()
.is_some_and(|(start, end)| start != end);
if self.transcript_scroll.is_at_tail()
&& !self.transcript_selection.dragging
&& !selection_has_range
&& !self.user_scrolled_during_stream
{
self.scroll_to_bottom();
}
}
pub fn mark_history_updated(&mut self) {
self.history_version = self.history_version.wrapping_add(1);
self.resync_history_revisions();
self.needs_redraw = true;
}
fn fresh_history_revision(&mut self) -> u64 {
let rev = self.next_history_revision;
self.next_history_revision = self.next_history_revision.wrapping_add(1);
rev
}
pub fn resync_history_revisions(&mut self) {
if self.history_revisions.len() < self.history.len() {
let needed = self.history.len() - self.history_revisions.len();
for _ in 0..needed {
let rev = self.fresh_history_revision();
self.history_revisions.push(rev);
}
} else if self.history_revisions.len() > self.history.len() {
self.history_revisions.truncate(self.history.len());
}
}
pub fn bump_history_cell(&mut self, idx: usize) {
self.resync_history_revisions();
if let Some(rev) = self.history_revisions.get_mut(idx) {
let new_rev = self.next_history_revision;
self.next_history_revision = self.next_history_revision.wrapping_add(1);
*rev = new_rev;
}
self.history_version = self.history_version.wrapping_add(1);
self.needs_redraw = true;
}
pub fn push_history_cell(&mut self, cell: HistoryCell) {
let rev = self.fresh_history_revision();
self.history.push(cell);
self.history_revisions.push(rev);
self.history_version = self.history_version.wrapping_add(1);
self.needs_redraw = true;
}
pub fn extend_history<I>(&mut self, cells: I)
where
I: IntoIterator<Item = HistoryCell>,
{
for cell in cells {
let rev = self.fresh_history_revision();
self.history.push(cell);
self.history_revisions.push(rev);
}
self.history_version = self.history_version.wrapping_add(1);
self.needs_redraw = true;
}
pub fn clear_history(&mut self) {
self.history.clear();
self.history_revisions.clear();
self.history_version = self.history_version.wrapping_add(1);
self.needs_redraw = true;
}
pub fn pop_history(&mut self) -> Option<HistoryCell> {
let cell = self.history.pop();
if cell.is_some() {
self.history_revisions.pop();
self.history_version = self.history_version.wrapping_add(1);
self.needs_redraw = true;
}
cell
}
pub fn truncate_history_to(&mut self, new_len: usize) {
if new_len >= self.history.len() {
return;
}
self.history.truncate(new_len);
if self.history_revisions.len() > new_len {
self.history_revisions.truncate(new_len);
}
self.tool_cells.retain(|_, idx| *idx < new_len);
self.tool_details_by_cell.retain(|idx, _| *idx < new_len);
self.subagent_card_index.retain(|_, idx| *idx < new_len);
if self
.last_fanout_card_index
.is_some_and(|idx| idx >= new_len)
{
self.last_fanout_card_index = None;
}
self.history_version = self.history_version.wrapping_add(1);
self.needs_redraw = true;
}
pub fn bump_active_cell_revision(&mut self) {
self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
if let Some(active) = self.active_cell.as_mut() {
active.bump_revision();
}
self.history_version = self.history_version.wrapping_add(1);
self.needs_redraw = true;
}
#[must_use]
#[allow(dead_code)] pub fn virtual_cell_count(&self) -> usize {
self.history.len() + self.active_cell.as_ref().map_or(0, ActiveCell::entry_count)
}
#[must_use]
#[allow(dead_code)] pub fn next_virtual_cell_index(&self) -> usize {
self.virtual_cell_count()
}
#[must_use]
#[allow(dead_code)] pub fn cell_at_virtual_index(&self, index: usize) -> Option<&HistoryCell> {
if index < self.history.len() {
self.history.get(index)
} else {
let entry_idx = index - self.history.len();
self.active_cell
.as_ref()
.and_then(|active| active.entries().get(entry_idx))
}
}
pub fn cell_at_virtual_index_mut(&mut self, index: usize) -> Option<&mut HistoryCell> {
if index < self.history.len() {
self.resync_history_revisions();
if let Some(rev) = self.history_revisions.get_mut(index) {
let new_rev = self.next_history_revision;
self.next_history_revision = self.next_history_revision.wrapping_add(1);
*rev = new_rev;
}
self.history_version = self.history_version.wrapping_add(1);
self.history.get_mut(index)
} else {
let entry_idx = index - self.history.len();
self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
self.history_version = self.history_version.wrapping_add(1);
self.active_cell
.as_mut()
.and_then(|active| active.entry_mut(entry_idx))
}
}
pub fn flush_active_cell(&mut self) {
let Some(mut active) = self.active_cell.take() else {
self.streaming_thinking_active_entry = None;
return;
};
if active.is_empty() {
self.exploring_cell = None;
self.exploring_entries.clear();
self.active_tool_details.clear();
self.streaming_thinking_active_entry = None;
self.bump_active_cell_revision();
return;
}
if let Some(entry_idx) = self.streaming_thinking_active_entry.take()
&& let Some(HistoryCell::Thinking { streaming, .. }) = active.entry_mut(entry_idx)
{
*streaming = false;
}
let drained = active.drain();
let base_index = self.history.len();
let mut details = std::mem::take(&mut self.active_tool_details);
for (tool_id, detail) in details.drain() {
self.tool_details_by_cell
.entry(self.tool_cells.get(&tool_id).copied().unwrap_or(base_index))
.or_insert(detail);
}
self.exploring_cell = None;
self.exploring_entries.clear();
for cell in drained {
let rev = self.fresh_history_revision();
self.history.push(cell);
self.history_revisions.push(rev);
}
self.history_version = self.history_version.wrapping_add(1);
self.needs_redraw = true;
let selection_has_range = self
.transcript_selection
.ordered_endpoints()
.is_some_and(|(start, end)| start != end);
if self.transcript_scroll.is_at_tail()
&& !self.transcript_selection.dragging
&& !selection_has_range
&& !self.user_scrolled_during_stream
{
self.scroll_to_bottom();
}
}
pub fn finalize_active_cell_as_interrupted(&mut self) {
if let Some(active) = self.active_cell.as_mut() {
active.mark_in_progress_as_interrupted();
}
self.flush_active_cell();
}
pub fn push_status_toast(
&mut self,
text: impl Into<String>,
level: StatusToastLevel,
ttl_ms: Option<u64>,
) {
let toast = StatusToast::new(text, level, ttl_ms);
self.status_toasts.push_back(toast);
while self.status_toasts.len() > 24 {
self.status_toasts.pop_front();
}
self.needs_redraw = true;
}
pub const QUIT_CONFIRMATION_WINDOW: Duration = Duration::from_secs(2);
pub fn arm_quit(&mut self) {
self.quit_armed_until = Some(Instant::now() + Self::QUIT_CONFIRMATION_WINDOW);
self.needs_redraw = true;
}
pub fn quit_is_armed(&self) -> bool {
self.quit_armed_until
.map(|deadline| Instant::now() < deadline)
.unwrap_or(false)
}
pub fn disarm_quit(&mut self) {
if self.quit_armed_until.is_some() {
self.quit_armed_until = None;
self.needs_redraw = true;
}
}
pub fn tick_quit_armed(&mut self) {
if let Some(deadline) = self.quit_armed_until
&& Instant::now() >= deadline
{
self.quit_armed_until = None;
self.needs_redraw = true;
}
}
pub fn set_sticky_status(
&mut self,
text: impl Into<String>,
level: StatusToastLevel,
ttl_ms: Option<u64>,
) {
self.sticky_status = Some(StatusToast::new(text, level, ttl_ms));
self.needs_redraw = true;
}
pub fn clear_sticky_status(&mut self) {
self.sticky_status = None;
}
pub fn set_sidebar_focus(&mut self, focus: SidebarFocus) {
self.sidebar_focus = focus;
self.needs_redraw = true;
}
pub fn close_slash_menu(&mut self) {
self.slash_menu_hidden = true;
self.needs_redraw = true;
}
fn classify_status_text(text: &str) -> (StatusToastLevel, Option<u64>, bool) {
let lower = text.to_ascii_lowercase();
let has = |needle: &str| lower.contains(needle);
if has("offline mode") || has("context critical") {
return (StatusToastLevel::Warning, None, true);
}
if has("error")
|| has("failed")
|| has("denied")
|| has("timeout")
|| has("aborted")
|| has("critical")
{
return (StatusToastLevel::Error, Some(15_000), true);
}
if has("saved")
|| has("loaded")
|| has("queued")
|| has("found")
|| has("enabled")
|| has("completed")
{
return (StatusToastLevel::Success, Some(5_000), false);
}
if has("cancelled") || has("warning") {
return (StatusToastLevel::Warning, Some(5_000), false);
}
(StatusToastLevel::Info, Some(4_000), false)
}
pub fn sync_status_message_to_toasts(&mut self) {
let current = self.status_message.clone();
if self.last_status_message_seen == current {
return;
}
self.last_status_message_seen = current.clone();
let Some(message) = current else {
return;
};
if message.trim().is_empty() {
return;
}
let (level, ttl_ms, sticky) = Self::classify_status_text(&message);
if sticky {
self.set_sticky_status(message, level, ttl_ms);
} else {
if matches!(level, StatusToastLevel::Success)
&& self
.sticky_status
.as_ref()
.is_some_and(|toast| matches!(toast.level, StatusToastLevel::Error))
{
self.clear_sticky_status();
}
self.push_status_toast(message, level, ttl_ms);
}
}
pub fn active_status_toast(&mut self) -> Option<StatusToast> {
self.sync_status_message_to_toasts();
let now = Instant::now();
let mut removed = false;
while self
.status_toasts
.front()
.is_some_and(|toast| toast.is_expired(now))
{
self.status_toasts.pop_front();
removed = true;
}
if self
.sticky_status
.as_ref()
.is_some_and(|toast| toast.is_expired(now))
{
self.sticky_status = None;
removed = true;
}
if removed {
self.needs_redraw = true;
}
self.sticky_status
.clone()
.or_else(|| self.status_toasts.back().cloned())
}
pub fn transcript_render_options(&self) -> TranscriptRenderOptions {
TranscriptRenderOptions {
show_thinking: self.show_thinking,
show_tool_details: self.show_tool_details,
calm_mode: self.calm_mode,
low_motion: self.low_motion,
spacing: self.transcript_spacing,
}
}
pub fn handle_resize(&mut self, _width: u16, _height: u16) {
self.transcript_cache = TranscriptViewCache::new();
if !self.transcript_scroll.is_at_tail() {
self.transcript_scroll = TranscriptScroll::to_bottom();
}
self.pending_scroll_delta = 0;
self.transcript_selection.clear();
self.last_transcript_area = None;
self.last_transcript_top = 0;
self.last_transcript_visible = 0;
self.last_transcript_total = 0;
self.last_transcript_padding_top = 0;
self.mark_history_updated();
}
pub fn cursor_byte_index(&self) -> usize {
byte_index_at_char(&self.input, self.cursor_position)
}
pub fn insert_str(&mut self, text: &str) {
if text.is_empty() {
return;
}
let cursor = self.cursor_position.min(char_count(&self.input));
let byte_index = byte_index_at_char(&self.input, cursor);
self.input.insert_str(byte_index, text);
self.cursor_position = cursor + char_count(text);
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
}
pub fn insert_paste_text(&mut self, text: &str) {
let normalized = normalize_paste_text(text);
if !normalized.is_empty() {
self.insert_str(&normalized);
}
self.paste_burst.clear_after_explicit_paste();
}
pub fn insert_media_attachment(&mut self, kind: &str, path: &Path, description: Option<&str>) {
let reference = media_attachment_reference(kind, path, description);
let cursor = self.cursor_position.min(char_count(&self.input));
let byte_index = byte_index_at_char(&self.input, cursor);
let needs_prefix_newline = self.input[..byte_index]
.chars()
.last()
.is_some_and(|ch| !ch.is_whitespace());
let needs_suffix_newline = self.input[byte_index..]
.chars()
.next()
.is_some_and(|ch| !ch.is_whitespace());
let mut inserted = String::new();
if needs_prefix_newline {
inserted.push('\n');
}
inserted.push_str(&reference);
if needs_suffix_newline || self.input[byte_index..].is_empty() {
inserted.push('\n');
}
self.insert_str(&inserted);
self.paste_burst.clear_after_explicit_paste();
}
pub fn flush_paste_burst_if_due(&mut self, now: Instant) -> bool {
match self.paste_burst.flush_if_due(now) {
FlushResult::Paste(text) => {
self.insert_str(&text);
true
}
FlushResult::Typed(ch) => {
self.insert_char(ch);
true
}
FlushResult::None => false,
}
}
pub fn insert_api_key_char(&mut self, c: char) {
let cursor = self.api_key_cursor.min(char_count(&self.api_key_input));
let byte_index = byte_index_at_char(&self.api_key_input, cursor);
self.api_key_input.insert(byte_index, c);
self.api_key_cursor = cursor + 1;
}
pub fn insert_api_key_str(&mut self, text: &str) {
let sanitized = sanitize_api_key_text(text);
if sanitized.is_empty() {
return;
}
let cursor = self.api_key_cursor.min(char_count(&self.api_key_input));
let byte_index = byte_index_at_char(&self.api_key_input, cursor);
self.api_key_input.insert_str(byte_index, &sanitized);
self.api_key_cursor = cursor + char_count(&sanitized);
}
pub fn delete_api_key_char(&mut self) {
if self.api_key_cursor == 0 {
return;
}
let target = self.api_key_cursor.saturating_sub(1);
if remove_char_at(&mut self.api_key_input, target) {
self.api_key_cursor = target;
}
}
pub fn paste_from_clipboard(&mut self) {
if let Some(content) = self.clipboard.read(self.workspace.as_path()) {
if let Some(pending) = self.paste_burst.flush_before_modified_input() {
self.insert_str(&pending);
}
match content {
ClipboardContent::Text(text) => {
self.insert_paste_text(&text);
}
ClipboardContent::Image(pasted) => {
let description = format!("{} ({})", pasted.short_label(), pasted.size_label());
self.insert_media_attachment("image", &pasted.path, Some(&description));
self.status_message = Some(format!(
"Pasted {} image ({}) -> {}",
pasted.short_label(),
pasted.size_label(),
pasted.path.display()
));
}
}
}
}
pub fn paste_api_key_from_clipboard(&mut self) {
if let Some(ClipboardContent::Text(text)) = self.clipboard.read(self.workspace.as_path()) {
self.insert_api_key_str(&text);
}
}
pub fn scroll_up(&mut self, amount: usize) {
let delta = i32::try_from(amount).unwrap_or(i32::MAX);
self.pending_scroll_delta = self.pending_scroll_delta.saturating_sub(delta);
self.user_scrolled_during_stream = true;
self.needs_redraw = true;
}
pub fn scroll_down(&mut self, amount: usize) {
let delta = i32::try_from(amount).unwrap_or(i32::MAX);
self.pending_scroll_delta = self.pending_scroll_delta.saturating_add(delta);
self.user_scrolled_during_stream = true;
self.needs_redraw = true;
}
pub fn scroll_to_bottom(&mut self) {
self.transcript_scroll = TranscriptScroll::to_bottom();
self.pending_scroll_delta = 0;
self.user_scrolled_during_stream = false;
self.needs_redraw = true;
}
pub fn insert_char(&mut self, c: char) {
let cursor = self.cursor_position.min(char_count(&self.input));
let byte_index = byte_index_at_char(&self.input, cursor);
self.input.insert(byte_index, c);
self.cursor_position = cursor + 1;
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
}
pub fn delete_char(&mut self) {
if self.cursor_position == 0 {
return;
}
let target = self.cursor_position.saturating_sub(1);
let removed = remove_char_at(&mut self.input, target);
if removed {
self.cursor_position = target;
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
}
}
pub fn delete_char_forward(&mut self) {
if self.input.is_empty() {
return;
}
let target = self.cursor_position;
let removed = remove_char_at(&mut self.input, target);
if !removed {
self.cursor_position = char_count(&self.input);
}
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
}
pub fn kill_to_end_of_line(&mut self) -> bool {
let total_chars = char_count(&self.input);
let cursor = self.cursor_position.min(total_chars);
let start_byte = byte_index_at_char(&self.input, cursor);
let eol_byte = self.input[start_byte..]
.find('\n')
.map(|rel| start_byte + rel)
.unwrap_or_else(|| self.input.len());
let end_byte = if start_byte == eol_byte {
if eol_byte < self.input.len() {
eol_byte + 1
} else {
return false;
}
} else {
eol_byte
};
let removed: String = self.input[start_byte..end_byte].to_string();
if removed.is_empty() {
return false;
}
self.kill_buffer = removed;
self.input.replace_range(start_byte..end_byte, "");
self.cursor_position = cursor;
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
true
}
pub fn yank(&mut self) -> bool {
if self.kill_buffer.is_empty() {
return false;
}
let text = self.kill_buffer.clone();
let cursor = self.cursor_position.min(char_count(&self.input));
let byte_index = byte_index_at_char(&self.input, cursor);
self.input.insert_str(byte_index, &text);
self.cursor_position = cursor + char_count(&text);
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
true
}
pub fn move_cursor_left(&mut self) {
self.cursor_position = self.cursor_position.saturating_sub(1);
self.needs_redraw = true;
}
pub fn move_cursor_right(&mut self) {
if self.cursor_position < char_count(&self.input) {
self.cursor_position += 1;
self.needs_redraw = true;
}
}
pub fn move_cursor_start(&mut self) {
self.cursor_position = 0;
self.needs_redraw = true;
}
pub fn move_cursor_end(&mut self) {
self.cursor_position = char_count(&self.input);
self.needs_redraw = true;
}
pub fn clear_input(&mut self) {
self.input.clear();
self.cursor_position = 0;
self.slash_menu_selected = 0;
self.slash_menu_hidden = false;
self.paste_burst.clear_after_explicit_paste();
self.needs_redraw = true;
}
pub fn submit_input(&mut self) -> Option<String> {
if self.input.trim().is_empty() {
self.paste_burst.clear_after_explicit_paste();
return None;
}
let mut input = self.input.clone();
if char_count(&input) > MAX_SUBMITTED_INPUT_CHARS {
input = input.chars().take(MAX_SUBMITTED_INPUT_CHARS).collect();
self.status_message = Some(format!(
"Input truncated to {} characters for safety",
MAX_SUBMITTED_INPUT_CHARS
));
}
if !input.starts_with('/') {
self.input_history.push(input.clone());
if self.max_input_history == 0 {
self.input_history.clear();
} else if self.input_history.len() > self.max_input_history {
let excess = self.input_history.len() - self.max_input_history;
self.input_history.drain(0..excess);
}
}
self.history_index = None;
self.clear_input();
Some(input)
}
pub fn queue_message(&mut self, message: QueuedMessage) {
self.queued_messages.push_back(message);
}
pub fn pop_queued_message(&mut self) -> Option<QueuedMessage> {
self.queued_messages.pop_front()
}
pub fn remove_queued_message(&mut self, index: usize) -> Option<QueuedMessage> {
self.queued_messages.remove(index)
}
pub fn queued_message_count(&self) -> usize {
self.queued_messages.len()
}
pub fn pop_last_queued_into_draft(&mut self) -> bool {
if !self.input.is_empty() || self.queued_draft.is_some() {
return false;
}
let Some(msg) = self.queued_messages.pop_back() else {
return false;
};
self.input = msg.display.clone();
self.cursor_position = char_count(&self.input);
self.queued_draft = Some(msg);
self.needs_redraw = true;
true
}
pub fn push_pending_steer(&mut self, message: QueuedMessage) {
self.pending_steers.push_back(message);
self.submit_pending_steers_after_interrupt = true;
self.needs_redraw = true;
}
pub fn drain_pending_steers(&mut self) -> Vec<QueuedMessage> {
self.submit_pending_steers_after_interrupt = false;
if self.pending_steers.is_empty() {
return Vec::new();
}
self.needs_redraw = true;
self.pending_steers.drain(..).collect()
}
#[must_use]
pub fn decide_submit_disposition(&self) -> SubmitDisposition {
if self.is_loading {
SubmitDisposition::Steer
} else if self.offline_mode {
SubmitDisposition::Queue
} else {
SubmitDisposition::Immediate
}
}
pub fn finalize_streaming_assistant_as_interrupted(&mut self) {
let Some(index) = self.streaming_message_index.take() else {
return;
};
if let Some(HistoryCell::Assistant { content, streaming }) = self.history.get_mut(index) {
*streaming = false;
if content.is_empty() {
*content = "[interrupted]".to_string();
} else if !content.starts_with("[interrupted]") {
content.insert_str(0, "[interrupted] ");
}
}
self.bump_history_cell(index);
}
pub fn history_up(&mut self) {
if self.input_history.is_empty() {
return;
}
let new_index = match self.history_index {
None => self.input_history.len().saturating_sub(1),
Some(i) => i.saturating_sub(1),
};
self.history_index = Some(new_index);
self.input = self.input_history[new_index].clone();
self.cursor_position = char_count(&self.input);
self.slash_menu_hidden = false;
self.paste_burst.clear_after_explicit_paste();
}
pub fn history_down(&mut self) {
if self.input_history.is_empty() {
return;
}
match self.history_index {
None => {}
Some(i) => {
if i + 1 < self.input_history.len() {
self.history_index = Some(i + 1);
self.input = self.input_history[i + 1].clone();
self.cursor_position = char_count(&self.input);
self.slash_menu_hidden = false;
self.paste_burst.clear_after_explicit_paste();
} else {
self.history_index = None;
self.clear_input();
}
}
}
}
pub fn clear_todos(&mut self) -> bool {
if let Ok(mut plan) = self.plan_state.try_lock() {
*plan = crate::tools::plan::PlanState::default();
return true;
}
false
}
pub fn update_model_compaction_budget(&mut self) {
self.compact_threshold = compaction_threshold_for_model_and_effort(
&self.model,
self.reasoning_effort.api_value(),
);
}
pub fn compaction_config(&self) -> CompactionConfig {
CompactionConfig {
enabled: self.auto_compact,
token_threshold: self.compact_threshold,
message_threshold: compaction_message_threshold_for_model(&self.model),
model: self.model.clone(),
..Default::default()
}
}
pub fn cycle_config(&self) -> CycleConfig {
self.cycle.clone()
}
}
pub fn media_attachment_reference(kind: &str, path: &Path, description: Option<&str>) -> String {
match description {
Some(description) if !description.trim().is_empty() => {
format!(
"[Attached {kind}: {} at {}]",
description.trim(),
path.display()
)
}
_ => format!("[Attached {kind}: {}]", path.display()),
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum AppAction {
Quit,
#[allow(dead_code)] SaveSession(PathBuf),
#[allow(dead_code)] LoadSession(PathBuf),
SyncSession {
messages: Vec<Message>,
system_prompt: Option<SystemPrompt>,
model: String,
workspace: PathBuf,
},
OpenConfigView,
OpenModelPicker,
OpenProviderPicker,
OpenStatusPicker,
SendMessage(String),
Rlm {
prompt: String,
model: String,
child_model: String,
max_depth: u32,
},
ListSubAgents,
FetchModels,
SwitchProvider {
provider: ApiProvider,
model: Option<String>,
},
UpdateCompaction(CompactionConfig),
CompactContext,
TaskAdd {
prompt: String,
},
TaskList,
TaskShow {
id: String,
},
TaskCancel {
id: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs};
fn test_options(yolo: bool) -> TuiOptions {
TuiOptions {
model: "test-model".to_string(),
workspace: PathBuf::from("."),
allow_shell: yolo,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: yolo,
skip_onboarding: false,
yolo,
resume_session_id: None,
}
}
#[test]
fn test_trust_mode_follows_yolo_on_startup() {
let app = App::new(test_options(true), &Config::default());
assert!(app.trust_mode);
}
#[test]
fn submit_input_truncates_oversized_payloads() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "x".repeat(MAX_SUBMITTED_INPUT_CHARS + 128);
app.cursor_position = app.input.chars().count();
let submitted = app.submit_input().expect("expected submitted input");
assert_eq!(submitted.chars().count(), MAX_SUBMITTED_INPUT_CHARS);
assert!(
app.status_message
.as_ref()
.is_some_and(|msg| msg.contains("Input truncated"))
);
}
#[test]
fn app_starts_without_seeded_transcript_messages() {
let app = App::new(test_options(false), &Config::default());
assert!(app.history.is_empty());
assert_eq!(app.history_version, 0);
}
#[test]
fn clear_todos_resets_plan_state() {
let mut app = App::new(test_options(false), &Config::default());
{
let mut plan = app
.plan_state
.try_lock()
.expect("plan lock should be available");
plan.update(UpdatePlanArgs {
explanation: Some("test plan".to_string()),
plan: vec![PlanItemArg {
step: "step 1".to_string(),
status: StepStatus::InProgress,
}],
});
assert!(!plan.is_empty());
}
assert!(app.clear_todos());
let plan = app
.plan_state
.try_lock()
.expect("plan lock should be available");
assert!(plan.is_empty());
}
#[test]
fn test_cycle_mode_transitions() {
let mut app = App::new(test_options(false), &Config::default());
let initial_mode = app.mode;
app.cycle_mode();
assert_ne!(app.mode, initial_mode);
}
#[test]
fn test_cycle_mode_reverse_transitions() {
let mut app = App::new(test_options(false), &Config::default());
app.mode = AppMode::Plan;
app.cycle_mode_reverse();
assert_eq!(app.mode, AppMode::Yolo);
app.mode = AppMode::Agent;
app.cycle_mode_reverse();
assert_eq!(app.mode, AppMode::Plan);
}
#[test]
fn test_clear_input() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "test input".to_string();
app.cursor_position = app.input.len();
app.clear_input();
assert!(app.input.is_empty());
assert_eq!(app.cursor_position, 0);
}
#[test]
fn test_queue_message() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new("test message".to_string(), None));
assert_eq!(app.queued_message_count(), 1);
assert!(app.queued_messages.front().is_some());
}
#[test]
fn test_remove_queued_message() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new("first".to_string(), None));
app.queue_message(QueuedMessage::new("second".to_string(), None));
let removed = app.remove_queued_message(0);
assert!(removed.is_some());
assert_eq!(app.queued_message_count(), 1);
let removed = app.remove_queued_message(0);
assert!(removed.is_some());
assert_eq!(app.queued_message_count(), 0);
}
#[test]
fn test_remove_queued_message_invalid_index() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new("test".to_string(), None));
let removed = app.remove_queued_message(100);
assert!(removed.is_none());
}
#[test]
fn test_set_mode_updates_state() {
let mut app = App::new(test_options(false), &Config::default());
let initial_mode = app.mode;
app.set_mode(AppMode::Yolo);
assert_eq!(app.mode, AppMode::Yolo);
assert_ne!(app.mode, initial_mode);
assert!(app.trust_mode);
assert!(app.allow_shell);
}
#[test]
fn app_new_respects_allow_shell_option_when_not_yolo() {
let mut options = test_options(false);
options.allow_shell = false;
options.start_in_agent_mode = true; let app = App::new(options, &Config::default());
assert!(!app.allow_shell);
}
#[test]
fn set_mode_yolo_restores_previous_policies_on_exit() {
let mut options = test_options(false);
options.allow_shell = false;
options.start_in_agent_mode = true; let mut app = App::new(options, &Config::default());
app.allow_shell = false;
app.trust_mode = false;
app.approval_mode = ApprovalMode::Never;
app.set_mode(AppMode::Yolo);
assert!(app.allow_shell);
assert!(app.trust_mode);
assert_eq!(app.approval_mode, ApprovalMode::Auto);
app.set_mode(AppMode::Agent);
assert!(!app.allow_shell);
assert!(!app.trust_mode);
assert_eq!(app.approval_mode, ApprovalMode::Never);
}
#[test]
fn leaving_yolo_after_startup_restores_baseline_policies() {
let config = Config {
allow_shell: Some(false),
..Default::default()
};
let mut app = App::new(test_options(true), &config);
assert_eq!(app.mode, AppMode::Yolo);
assert!(app.allow_shell);
assert!(app.trust_mode);
assert_eq!(app.approval_mode, ApprovalMode::Auto);
app.set_mode(AppMode::Agent);
assert!(!app.allow_shell);
assert!(!app.trust_mode);
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
}
#[test]
fn test_mark_history_updated() {
let mut app = App::new(test_options(false), &Config::default());
let initial_version = app.history_version;
app.mark_history_updated();
assert!(app.history_version > initial_version);
}
#[test]
fn test_scroll_operations() {
let mut app = App::new(test_options(false), &Config::default());
app.scroll_up(5);
app.scroll_down(3);
}
#[test]
fn test_add_message() {
let mut app = App::new(test_options(false), &Config::default());
let initial_len = app.history.len();
app.add_message(HistoryCell::User {
content: "test".to_string(),
});
assert_eq!(app.history.len(), initial_len + 1);
}
#[test]
fn test_compaction_config() {
let app = App::new(test_options(false), &Config::default());
let config = app.compaction_config();
let _ = config.enabled;
}
#[test]
fn test_update_model_compaction_budget() {
let mut app = App::new(test_options(false), &Config::default());
app.model = "unknown-test-model".to_string();
app.update_model_compaction_budget();
let initial_threshold = app.compact_threshold;
app.model = "deepseek-v3.2-128k".to_string();
app.update_model_compaction_budget();
assert!(app.compact_threshold >= initial_threshold);
}
#[test]
fn test_input_history_navigation() {
let mut app = App::new(test_options(false), &Config::default());
app.input_history.push("first".to_string());
app.input_history.push("second".to_string());
app.history_up();
assert!(app.history_index.is_some());
app.history_down();
}
#[test]
fn kill_to_end_of_line_cuts_from_middle_of_word() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello world".to_string();
app.cursor_position = 6; assert!(app.kill_to_end_of_line());
assert_eq!(app.input, "hello ");
assert_eq!(app.cursor_position, 6);
assert_eq!(app.kill_buffer, "world");
}
#[test]
fn kill_at_eol_consumes_following_newline() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "line one\nline two".to_string();
app.cursor_position = 8; assert!(app.kill_to_end_of_line());
assert_eq!(app.input, "line oneline two");
assert_eq!(app.cursor_position, 8);
assert_eq!(app.kill_buffer, "\n");
let mut empty = App::new(test_options(false), &Config::default());
assert!(!empty.kill_to_end_of_line());
assert!(empty.input.is_empty());
assert!(empty.kill_buffer.is_empty());
}
#[test]
fn yank_inserts_kill_buffer_and_preserves_it() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "abc def".to_string();
app.cursor_position = 4; assert!(app.kill_to_end_of_line());
assert_eq!(app.input, "abc ");
assert_eq!(app.kill_buffer, "def");
app.cursor_position = 0;
assert!(app.yank());
assert!(app.yank());
assert_eq!(app.input, "defdefabc ");
assert_eq!(app.cursor_position, 6);
assert_eq!(app.kill_buffer, "def");
let mut empty = App::new(test_options(false), &Config::default());
assert!(!empty.yank());
assert!(empty.input.is_empty());
}
#[test]
fn quit_is_not_armed_by_default() {
let app = App::new(test_options(false), &Config::default());
assert!(!app.quit_is_armed());
assert!(app.quit_armed_until.is_none());
}
#[test]
fn arm_quit_sets_two_second_window() {
let mut app = App::new(test_options(false), &Config::default());
app.arm_quit();
assert!(app.quit_is_armed());
let deadline = app.quit_armed_until.expect("deadline set");
let remaining = deadline.saturating_duration_since(Instant::now());
assert!(
remaining >= Duration::from_millis(1500) && remaining <= Duration::from_secs(2),
"expected ~2s window, got {remaining:?}",
);
assert!(app.needs_redraw, "armed prompt should request a redraw");
}
#[test]
fn disarm_quit_clears_the_timer() {
let mut app = App::new(test_options(false), &Config::default());
app.arm_quit();
app.needs_redraw = false;
app.disarm_quit();
assert!(!app.quit_is_armed());
assert!(app.quit_armed_until.is_none());
assert!(app.needs_redraw, "disarming should request a redraw");
}
#[test]
fn disarm_quit_when_not_armed_is_a_noop() {
let mut app = App::new(test_options(false), &Config::default());
app.needs_redraw = false;
app.disarm_quit();
assert!(!app.needs_redraw, "no redraw when nothing changed");
}
#[test]
fn quit_armed_expires_after_window() {
let mut app = App::new(test_options(false), &Config::default());
app.quit_armed_until = Some(Instant::now() - Duration::from_millis(10));
assert!(
!app.quit_is_armed(),
"expired timer must not count as armed"
);
app.needs_redraw = false;
app.tick_quit_armed();
assert!(app.quit_armed_until.is_none(), "tick clears expired timer");
assert!(
app.needs_redraw,
"expiry triggers a redraw to repaint footer"
);
}
#[test]
fn quit_armed_tick_is_noop_within_window() {
let mut app = App::new(test_options(false), &Config::default());
app.arm_quit();
app.needs_redraw = false;
app.tick_quit_armed();
assert!(
app.quit_is_armed(),
"tick within window keeps the timer armed"
);
assert!(!app.needs_redraw, "no redraw when nothing changed");
}
#[test]
fn re_arming_after_expiry_starts_a_fresh_window() {
let mut app = App::new(test_options(false), &Config::default());
app.quit_armed_until = Some(Instant::now() - Duration::from_secs(5));
app.tick_quit_armed();
assert!(app.quit_armed_until.is_none());
app.arm_quit();
let deadline = app.quit_armed_until.expect("re-armed");
assert!(deadline > Instant::now(), "fresh deadline in the future");
}
#[test]
fn submit_disposition_immediate_when_idle_and_online() {
let app = App::new(test_options(false), &Config::default());
assert!(!app.is_loading);
assert!(!app.offline_mode);
assert_eq!(
app.decide_submit_disposition(),
SubmitDisposition::Immediate
);
}
#[test]
fn submit_disposition_steer_when_busy_and_online() {
let mut app = App::new(test_options(false), &Config::default());
app.is_loading = true;
app.offline_mode = false;
assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Steer);
}
#[test]
fn submit_disposition_queue_when_offline_and_idle() {
let mut app = App::new(test_options(false), &Config::default());
app.is_loading = false;
app.offline_mode = true;
assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue);
}
#[test]
fn submit_disposition_offline_busy_still_steers() {
let mut app = App::new(test_options(false), &Config::default());
app.is_loading = true;
app.offline_mode = true;
assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Steer);
}
#[test]
fn push_pending_steer_arms_resend_flag() {
let mut app = App::new(test_options(false), &Config::default());
assert!(!app.submit_pending_steers_after_interrupt);
app.push_pending_steer(QueuedMessage::new("steer me".to_string(), None));
assert_eq!(app.pending_steers.len(), 1);
assert!(app.submit_pending_steers_after_interrupt);
}
#[test]
fn drain_pending_steers_clears_flag_and_returns_in_order() {
let mut app = App::new(test_options(false), &Config::default());
app.push_pending_steer(QueuedMessage::new("first".to_string(), None));
app.push_pending_steer(QueuedMessage::new("second".to_string(), None));
app.push_pending_steer(QueuedMessage::new("third".to_string(), None));
let drained = app.drain_pending_steers();
assert_eq!(drained.len(), 3);
assert_eq!(drained[0].display, "first");
assert_eq!(drained[2].display, "third");
assert!(app.pending_steers.is_empty());
assert!(!app.submit_pending_steers_after_interrupt);
}
#[test]
fn drain_pending_steers_when_empty_is_safe() {
let mut app = App::new(test_options(false), &Config::default());
app.submit_pending_steers_after_interrupt = true;
let drained = app.drain_pending_steers();
assert!(drained.is_empty());
assert!(!app.submit_pending_steers_after_interrupt);
}
#[test]
fn double_push_pending_steer_is_idempotent_on_flag() {
let mut app = App::new(test_options(false), &Config::default());
app.push_pending_steer(QueuedMessage::new("a".to_string(), None));
app.push_pending_steer(QueuedMessage::new("b".to_string(), None));
assert!(app.submit_pending_steers_after_interrupt);
assert_eq!(app.pending_steers.len(), 2);
}
#[test]
fn pop_last_queued_into_draft_pops_back_and_arms_draft() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new(
"first".to_string(),
Some("skill-A".to_string()),
));
app.queue_message(QueuedMessage::new(
"last".to_string(),
Some("skill-B".to_string()),
));
assert!(app.pop_last_queued_into_draft());
assert_eq!(app.input, "last");
assert_eq!(app.cursor_position, "last".chars().count());
assert_eq!(app.queued_messages.len(), 1);
let draft = app.queued_draft.clone().expect("draft is set");
assert_eq!(draft.display, "last");
assert_eq!(draft.skill_instruction.as_deref(), Some("skill-B"));
}
#[test]
fn pop_last_queued_into_draft_noop_when_composer_dirty() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new("queued".to_string(), None));
app.input = "typing".to_string();
app.cursor_position = char_count(&app.input);
assert!(!app.pop_last_queued_into_draft());
assert_eq!(app.input, "typing");
assert_eq!(app.queued_messages.len(), 1);
assert!(app.queued_draft.is_none());
}
#[test]
fn pop_last_queued_into_draft_noop_when_draft_already_armed() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new("queued".to_string(), None));
app.queued_draft = Some(QueuedMessage::new("editing".to_string(), None));
assert!(!app.pop_last_queued_into_draft());
assert_eq!(app.queued_messages.len(), 1);
assert_eq!(
app.queued_draft.as_ref().map(|d| d.display.as_str()),
Some("editing")
);
}
#[test]
fn pop_last_queued_into_draft_noop_when_queue_empty() {
let mut app = App::new(test_options(false), &Config::default());
assert!(!app.pop_last_queued_into_draft());
assert!(app.input.is_empty());
assert!(app.queued_draft.is_none());
}
#[test]
fn finalize_streaming_assistant_marks_existing_cell_interrupted() {
let mut app = App::new(test_options(false), &Config::default());
app.add_message(HistoryCell::Assistant {
content: "partial reply so far".to_string(),
streaming: true,
});
let idx = app.history.len() - 1;
app.streaming_message_index = Some(idx);
app.finalize_streaming_assistant_as_interrupted();
assert!(app.streaming_message_index.is_none());
match &app.history[idx] {
HistoryCell::Assistant { content, streaming } => {
assert!(content.starts_with("[interrupted]"), "got: {content}");
assert!(content.contains("partial reply so far"));
assert!(!*streaming);
}
other => panic!("expected Assistant cell, got {other:?}"),
}
}
#[test]
fn finalize_streaming_assistant_handles_empty_content() {
let mut app = App::new(test_options(false), &Config::default());
app.add_message(HistoryCell::Assistant {
content: String::new(),
streaming: true,
});
let idx = app.history.len() - 1;
app.streaming_message_index = Some(idx);
app.finalize_streaming_assistant_as_interrupted();
match &app.history[idx] {
HistoryCell::Assistant { content, streaming } => {
assert_eq!(content, "[interrupted]");
assert!(!*streaming);
}
other => panic!("expected Assistant cell, got {other:?}"),
}
}
#[test]
fn finalize_streaming_assistant_no_op_without_index() {
let mut app = App::new(test_options(false), &Config::default());
let prev_len = app.history.len();
app.finalize_streaming_assistant_as_interrupted();
assert_eq!(app.history.len(), prev_len);
assert!(app.streaming_message_index.is_none());
}
#[test]
fn finalize_streaming_assistant_is_idempotent_on_double_call() {
let mut app = App::new(test_options(false), &Config::default());
app.add_message(HistoryCell::Assistant {
content: "something".to_string(),
streaming: true,
});
let idx = app.history.len() - 1;
app.streaming_message_index = Some(idx);
app.finalize_streaming_assistant_as_interrupted();
app.finalize_streaming_assistant_as_interrupted();
match &app.history[idx] {
HistoryCell::Assistant { content, .. } => {
assert!(content.starts_with("[interrupted] "));
assert_eq!(content.matches("[interrupted]").count(), 1);
}
other => panic!("expected Assistant cell, got {other:?}"),
}
}
#[test]
fn kill_and_yank_handle_multibyte_utf8() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "café 你好".to_string();
app.cursor_position = 5; assert!(app.kill_to_end_of_line());
assert_eq!(app.input, "café ");
assert_eq!(app.cursor_position, 5);
assert_eq!(app.kill_buffer, "你好");
assert!(app.yank());
assert_eq!(app.input, "café 你好");
assert_eq!(app.cursor_position, 7);
}
}