Files
codewhale/src/tui/app.rs
T
Hunter Bown 5f90e1685e fix: resolve CI failures — fmt + remove unused function
- Run cargo fmt to fix trailing whitespace and formatting
- Remove unused `get_compaction_instruction()` that triggered -D warnings
2026-02-16 12:22:14 -06:00

1153 lines
38 KiB
Rust

//! Application state for the `DeepSeek` TUI.
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use std::time::Instant;
use ratatui::layout::Rect;
use serde_json::Value;
use thiserror::Error;
use crate::compaction::CompactionConfig;
use crate::config::{Config, has_api_key, save_api_key};
use crate::hooks::{HookContext, HookEvent, HookExecutor, HookResult};
use crate::models::{
Message, SystemPrompt, compaction_message_threshold_for_model, compaction_threshold_for_model,
};
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::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::transcript::TranscriptViewCache;
use crate::tui::views::ViewStack;
/// Format a nice welcome banner.
fn format_welcome_banner(model: &str, workspace: &PathBuf, yolo: bool) -> String {
let mode_line = if yolo {
"\nYOLO mode — shell + trust + auto-approve enabled\n"
} else {
""
};
format!(
"Tips: Tab to switch modes, F1 or /help for commands, Esc to cancel\n\
{mode_line}\
Directory: {}\n\
Model: {}",
workspace.display(),
model
)
}
// === Types ===
/// State machine for onboarding new users.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnboardingState {
Welcome,
ApiKey,
TrustDirectory,
Tips,
None,
}
/// Supported application modes for the TUI.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode {
Normal,
Agent,
Yolo,
Plan,
}
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 {
/// Short label used in the UI footer.
pub fn label(self) -> &'static str {
match self {
AppMode::Normal => "NORMAL",
AppMode::Agent => "AGENT",
AppMode::Yolo => "YOLO",
AppMode::Plan => "PLAN",
}
}
#[allow(dead_code)]
/// Description shown in help or onboarding text.
pub fn description(self) -> &'static str {
match self {
AppMode::Normal => "Chat mode - ask questions, get answers",
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",
}
}
}
/// Configuration required to bootstrap the TUI.
#[derive(Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct TuiOptions {
pub model: String,
pub workspace: PathBuf,
pub allow_shell: bool,
/// Use the alternate screen buffer (fullscreen TUI).
pub use_alt_screen: bool,
/// Maximum number of concurrent sub-agents.
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,
/// Start in agent mode (defaults to agent; --yolo starts in YOLO)
pub start_in_agent_mode: bool,
/// Skip onboarding screens
pub skip_onboarding: bool,
/// Auto-approve tool executions (yolo mode)
pub yolo: bool,
/// Resume a previous session by ID
pub resume_session_id: Option<String>,
}
/// Global UI state for the TUI.
#[allow(clippy::struct_excessive_bools)]
pub struct App {
pub mode: AppMode,
pub input: String,
pub cursor_position: usize,
pub paste_burst: PasteBurst,
pub history: Vec<HistoryCell>,
pub history_version: 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,
/// Degraded connectivity mode; new user inputs are queued for later retry.
pub offline_mode: bool,
pub status_message: Option<String>,
pub model: String,
pub workspace: PathBuf,
pub skills_dir: PathBuf,
pub use_alt_screen: bool,
#[allow(dead_code)]
pub system_prompt: Option<SystemPrompt>,
pub input_history: Vec<String>,
pub history_index: Option<usize>,
pub auto_compact: bool,
pub show_thinking: bool,
pub show_tool_details: bool,
pub sidebar_width_percent: u16,
#[allow(dead_code)]
pub compact_threshold: usize,
pub max_input_history: usize,
pub total_tokens: u32,
/// Tokens used in the current conversation (reset on clear/load)
pub total_conversation_tokens: u32,
pub allow_shell: bool,
pub max_subagents: usize,
/// Cached sub-agent snapshots for UI views.
pub subagent_cache: Vec<SubAgentResult>,
pub ui_theme: UiTheme,
// Onboarding
pub onboarding: OnboardingState,
pub onboarding_needs_api_key: bool,
pub api_key_input: String,
pub api_key_cursor: usize,
// Hooks system
pub hooks: HookExecutor,
#[allow(dead_code)]
pub yolo: bool,
// Clipboard handler
pub clipboard: ClipboardHandler,
// Tool approval session allowlist
pub approval_session_approved: HashSet<String>,
pub approval_mode: ApprovalMode,
// Modal view stack (approval/help/etc.)
pub view_stack: ViewStack,
/// Current session ID for auto-save updates
pub current_session_id: Option<String>,
/// Trust mode - allow access outside workspace
pub trust_mode: bool,
/// Project documentation (AGENTS.md or CLAUDE.md)
pub project_doc: Option<String>,
/// Plan state for tracking tasks
pub plan_state: SharedPlanState,
/// Whether a plan follow-up prompt is waiting for user input
pub plan_prompt_pending: bool,
/// Whether update_plan was called during the current turn
pub plan_tool_used_in_turn: bool,
/// Todo list for `TodoWriteTool`
#[allow(dead_code)] // For future engine integration
pub todos: SharedTodoList,
/// Tool execution log
pub tool_log: Vec<String>,
/// Session cost tracking
pub session_cost: f64,
/// Active skill to apply to next user message
pub active_skill: Option<String>,
/// Tool call cells by tool id
pub tool_cells: HashMap<String, usize>,
/// Full tool input/output keyed by history cell index.
pub tool_details_by_cell: HashMap<usize, ToolDetailRecord>,
/// Active exploring cell index
pub exploring_cell: Option<usize>,
/// Mapping of exploring tool ids to (cell index, entry index)
pub exploring_entries: HashMap<String, (usize, usize)>,
/// Tool calls that should be ignored by the UI
pub ignored_tool_calls: HashSet<String>,
/// Last exec wait command shown (for duplicate suppression)
pub last_exec_wait_command: Option<String>,
/// Current streaming assistant cell
pub streaming_message_index: Option<usize>,
/// Accumulated reasoning text
pub reasoning_buffer: String,
/// Live reasoning header extracted from bold text
pub reasoning_header: Option<String>,
/// Last completed reasoning block
pub last_reasoning: Option<String>,
/// Tool calls captured for the pending assistant message
pub pending_tool_uses: Vec<(String, String, Value)>,
/// User messages queued while a turn is running
pub queued_messages: VecDeque<QueuedMessage>,
/// Draft queued message being edited
pub queued_draft: Option<QueuedMessage>,
/// Start time for current turn
pub turn_started_at: Option<Instant>,
/// Current runtime turn id (if known).
pub runtime_turn_id: Option<String>,
/// Current runtime turn status (if known).
pub runtime_turn_status: Option<String>,
/// Last prompt token usage
pub last_prompt_tokens: Option<u32>,
/// Last completion token usage
pub last_completion_tokens: Option<u32>,
/// Cached background tasks for sidebar rendering.
pub task_panel: Vec<TaskPanelEntry>,
}
/// Message queued while the engine is busy.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueuedMessage {
pub display: String,
pub skill_instruction: Option<String>,
}
/// Detailed tool payload attached to a history cell.
#[derive(Debug, Clone)]
pub struct ToolDetailRecord {
pub tool_id: String,
pub tool_name: String,
pub input: Value,
pub output: Option<String>,
}
/// Lightweight task view for sidebar rendering.
#[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,
}
}
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()
}
}
}
// === Errors ===
/// Errors that can occur while submitting API keys during onboarding.
#[derive(Debug, Error)]
pub enum ApiKeyError {
/// The provided API key was empty.
#[error("Failed to save API key: API key cannot be empty")]
Empty,
/// Persisting the API key failed.
#[error("Failed to save API key: {source}")]
SaveFailed { source: anyhow::Error },
}
// === App State ===
impl App {
#[allow(clippy::too_many_lines)]
pub fn new(options: TuiOptions, config: &Config) -> Self {
let TuiOptions {
model,
workspace,
allow_shell: _allow_shell,
use_alt_screen,
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;
// Check if API key exists
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 show_thinking = settings.show_thinking;
let show_tool_details = settings.show_tool_details;
let sidebar_width_percent = settings.sidebar_width_percent;
let max_input_history = settings.max_input_history;
let ui_theme = palette::ui_theme(&settings.theme);
let model = settings.default_model.clone().unwrap_or_else(|| model);
let compact_threshold = compaction_threshold_for_model(&model);
// Start in YOLO mode if --yolo flag was passed
let preferred_mode = match settings.default_mode.as_str() {
"plan" => AppMode::Plan,
"agent" | "normal" => AppMode::Agent,
"yolo" => AppMode::Yolo,
_ => AppMode::Agent,
};
let initial_mode = if yolo {
AppMode::Yolo
} else if start_in_agent_mode {
AppMode::Agent
} else {
preferred_mode
};
let history = if needs_onboarding {
Vec::new() // No welcome message during onboarding
} else {
vec![HistoryCell::System {
content: format_welcome_banner(&model, &workspace, yolo),
}]
};
// Initialize hooks executor from config
let hooks_config = config.hooks_config();
let hooks = HookExecutor::new(hooks_config, workspace.clone());
// Initialize plan state
let plan_state = new_shared_plan_state();
let history_len = history.len() as u64;
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,
paste_burst: PasteBurst::default(),
history,
history_version: history_len,
api_messages: Vec::new(),
transcript_scroll: TranscriptScroll::ToBottom,
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,
model,
workspace,
skills_dir,
use_alt_screen,
system_prompt: None,
input_history: Vec::new(),
history_index: None,
auto_compact,
show_thinking,
show_tool_details,
sidebar_width_percent,
compact_threshold,
max_input_history,
total_tokens: 0,
total_conversation_tokens: 0,
allow_shell: true,
max_subagents,
subagent_cache: Vec::new(),
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,
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(),
current_session_id: None,
trust_mode: initial_mode == AppMode::Yolo,
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(),
exploring_cell: None,
exploring_entries: HashMap::new(),
ignored_tool_calls: HashSet::new(),
last_exec_wait_command: None,
streaming_message_index: None,
reasoning_buffer: String::new(),
reasoning_header: None,
last_reasoning: None,
pending_tool_uses: Vec::new(),
queued_messages: VecDeque::new(),
queued_draft: None,
turn_started_at: None,
runtime_turn_id: None,
runtime_turn_status: None,
last_prompt_tokens: None,
last_completion_tokens: None,
task_panel: Vec::new(),
}
}
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.add_message(HistoryCell::System {
content: format_welcome_banner(&self.model, &self.workspace, self.yolo),
});
}
pub fn set_mode(&mut self, mode: AppMode) {
let previous_mode = self.mode;
self.mode = mode;
self.status_message = Some(format!("Switched to {} mode", mode.label()));
self.allow_shell = true;
self.trust_mode = matches!(mode, AppMode::Yolo);
self.yolo = matches!(mode, AppMode::Yolo);
self.approval_mode = if matches!(mode, AppMode::Yolo) {
ApprovalMode::Auto
} else {
ApprovalMode::Suggest
};
if mode != AppMode::Plan {
self.plan_prompt_pending = false;
self.plan_tool_used_in_turn = false;
}
// Execute mode change hooks
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);
}
/// Cycle through modes: Plan -> Agent -> YOLO -> Plan
pub fn cycle_mode(&mut self) {
let next = match self.mode {
AppMode::Plan => AppMode::Agent,
AppMode::Agent => AppMode::Yolo,
AppMode::Yolo | AppMode::Normal => AppMode::Plan,
};
self.set_mode(next);
}
/// Execute hooks for a specific event with the given context
pub fn execute_hooks(&self, event: HookEvent, context: &HookContext) -> Vec<HookResult> {
self.hooks.execute(event, context)
}
/// Create a hook context with common fields pre-populated
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) {
self.history.push(msg);
self.history_version = self.history_version.wrapping_add(1);
if matches!(self.transcript_scroll, TranscriptScroll::ToBottom)
&& !self.transcript_selection.dragging
{
self.scroll_to_bottom();
}
}
pub fn mark_history_updated(&mut self) {
self.history_version = self.history_version.wrapping_add(1);
}
pub fn transcript_render_options(&self) -> TranscriptRenderOptions {
TranscriptRenderOptions {
show_thinking: self.show_thinking,
show_tool_details: self.show_tool_details,
}
}
/// Handle terminal resize event.
///
/// This method properly invalidates all cached layout state to ensure
/// correct rendering after the terminal dimensions change.
pub fn handle_resize(&mut self, _width: u16, _height: u16) {
// Invalidate transcript cache (will be rebuilt on next render)
self.transcript_cache = TranscriptViewCache::new();
// Reset scroll to bottom to avoid invalid anchors
// (anchored cell indices may be invalid at new width)
self.transcript_scroll = TranscriptScroll::ToBottom;
// Clear pending scroll delta
self.pending_scroll_delta = 0;
// Clear selection (endpoints may be invalid at new width)
self.transcript_selection.clear();
// Clear stale layout info
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;
// Mark history updated to force cache rebuild
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);
}
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 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;
}
}
/// Paste from clipboard into input
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 { path, description } => {
// Insert image path reference
let reference = format!("[Image: {} at {}]", description, path.display());
self.insert_str(&reference);
self.paste_burst.clear_after_explicit_paste();
self.status_message = Some(format!("Pasted image: {}", 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);
}
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);
}
pub fn scroll_to_bottom(&mut self) {
self.transcript_scroll = TranscriptScroll::ToBottom;
self.pending_scroll_delta = 0;
}
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;
}
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;
}
}
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);
}
}
pub fn move_cursor_left(&mut self) {
self.cursor_position = self.cursor_position.saturating_sub(1);
}
pub fn move_cursor_right(&mut self) {
if self.cursor_position < char_count(&self.input) {
self.cursor_position += 1;
}
}
pub fn move_cursor_start(&mut self) {
self.cursor_position = 0;
}
pub fn move_cursor_end(&mut self) {
self.cursor_position = char_count(&self.input);
}
pub fn clear_input(&mut self) {
self.input.clear();
self.cursor_position = 0;
self.paste_burst.clear_after_explicit_paste();
}
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_previews(&self, max: usize) -> Vec<String> {
if max == 0 {
return Vec::new();
}
let mut previews: Vec<String> = self
.queued_messages
.iter()
.take(max)
.map(|msg| msg.display.clone())
.collect();
let extra = self.queued_messages.len().saturating_sub(previews.len());
if extra > 0 {
previews.push(format!("+{extra} more"));
}
previews
}
pub fn queued_message_count(&self) -> usize {
self.queued_messages.len()
}
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.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.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(&self.model);
}
pub fn compaction_config(&self) -> CompactionConfig {
let mut compaction = CompactionConfig::default();
compaction.enabled = self.auto_compact;
compaction.token_threshold = self.compact_threshold;
compaction.message_threshold = compaction_message_threshold_for_model(&self.model);
compaction.model = self.model.clone();
compaction
}
}
// === Actions ===
/// Actions emitted by the UI event loop.
#[derive(Debug, Clone, PartialEq)]
pub enum AppAction {
Quit,
#[allow(dead_code)] // For explicit /save command
SaveSession(PathBuf),
#[allow(dead_code)] // For explicit /load command
LoadSession(PathBuf),
SyncSession {
messages: Vec<Message>,
system_prompt: Option<SystemPrompt>,
model: String,
workspace: PathBuf,
},
SendMessage(String),
ListSubAgents,
FetchModels,
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,
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 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());
// Default mode should be Agent based on settings
let initial_mode = app.mode;
app.cycle_mode();
// Mode should have changed
assert_ne!(app.mode, initial_mode);
}
#[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));
// Remove first (index 0)
let removed = app.remove_queued_message(0);
assert!(removed.is_some());
assert_eq!(app.queued_message_count(), 1);
// Remove second (now at index 0)
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));
// Try to remove non-existent index
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);
// Yolo mode should enable trust and shell
assert!(app.trust_mode);
assert!(app.allow_shell);
}
#[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());
// Just verify scroll methods can be called without panic
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();
// Config should be valid (just checking it returns something)
let _ = config.enabled;
}
#[test]
fn test_update_model_compaction_budget() {
let mut app = App::new(test_options(false), &Config::default());
let initial_threshold = app.compact_threshold;
app.model = "deepseek-reasoner".to_string();
app.update_model_compaction_budget();
// Threshold may have changed based on model
// deepseek-reasoner has 128k context, so threshold should be higher
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());
// Navigate up
app.history_up();
assert!(app.history_index.is_some());
// Navigate down
app.history_down();
}
}