#![allow(
clippy::field_reassign_with_default,
clippy::let_and_return,
clippy::borrow_interior_mutable_const,
clippy::derivable_impls
)]
use std::collections::VecDeque;
use std::io::{self, Stdout, Write};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::Result;
use crossterm::{
cursor::{Hide, Show},
event::{
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
},
execute,
terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate, disable_raw_mode, enable_raw_mode},
};
use oxicode_agent::AgentEvent;
use oxicode_agent::config::Mode;
use oxicode_agent::tools::TodoStateProvider;
use oxicode_agent::tools::todo::{TodoItem, TodoPhase, TodoStatus};
use oxicode_vtui::theme::{ThemeStyles, active_styles};
use oxicode_vtui::tui::core::{
AuthAction, InlineCommand, InlineEvent, InlineHandle, InlineHeaderContext,
InlineHeaderStatusBadge, InlineHeaderStatusTone, InlineListItem, InlineListSelection,
InlineMessageKind, InlineSegment, InlineTextStyle, OverlayRequest, OverlaySubmission,
SecurePromptConfig,
};
use ratatui::{
Frame, Terminal, TerminalOptions, Viewport,
backend::CrosstermBackend,
buffer::Buffer,
layout::{Alignment, Margin, Position, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap},
};
use unicode_width::UnicodeWidthStr;
use crate::App;
use crate::app::agent_hub_registry::HubEntry;
use crate::app::agent_session::SessionEvent;
use crate::tui_vt::keymap::{GlobalAction, KeyCombo, Keymap};
use crate::tui_vt::settings_defs::{
SETTING_DEFS, SettingKey, SettingWidget, SettingsMapRow, SettingsTab, defs_for_tab,
get_display_value,
};
use crate::tui_vt::slash::file_commands::FileCommand;
use crate::tui_vt::slash::registry::{
SlashCtx, SlashOutcome, SlashRegistry, settings_overlay_items,
};
use oxicode_vtui::presentation::{
BlockAlloc, BlockDisplayMode, TranscriptLine, VisibleItem, allocate_rows, visible_items,
};
use oxicode_vtui::tui::ui::clamp_segments_to_width;
use oxicode_textarea::{EditBuffer, ElementKind, TextArea, TextAreaState};
use ratatui::widgets::FrameExt;
const MASKED_ELEMENT_KIND: ElementKind = ElementKind(1);
pub struct Tui {
terminal: Terminal<CrosstermBackend<Stdout>>,
tty_ok: bool,
}
impl Tui {
pub fn enter() -> Result<Self> {
Self::set_panic_hook();
let tty_ok = enable_raw_mode().is_ok();
let mut stdout = io::stdout();
if tty_ok {
let flags = if std::env::var("OXICODE_KITTY_KEYBOARD").as_deref() == Ok("1") {
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
} else {
KeyboardEnhancementFlags::REPORT_EVENT_TYPES
};
let _ = execute!(
stdout,
Hide,
EnableBracketedPaste,
PushKeyboardEnhancementFlags(flags)
);
let _ = stdout.flush();
}
let mut terminal = None;
if tty_ok {
let height = crossterm::terminal::size().map(|s| s.1).unwrap_or(24);
let backend = CrosstermBackend::new(stdout);
terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(height),
},
)
.ok();
}
let mut terminal = match terminal {
Some(t) => t,
None => Terminal::new(CrosstermBackend::new(io::stdout()))?,
};
if tty_ok {
let _ = terminal.clear();
}
Ok(Self { terminal, tty_ok })
}
pub fn exit(&mut self) -> Result<()> {
if self.tty_ok {
let _ = execute!(
self.terminal.backend_mut(),
PopKeyboardEnhancementFlags,
DisableBracketedPaste
);
let _ = self.terminal.show_cursor();
disable_raw_mode()?;
self.tty_ok = false;
}
Ok(())
}
fn set_panic_hook() {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = execute!(io::stdout(), Show);
let _ = disable_raw_mode();
original_hook(panic_info);
}));
}
}
impl Drop for Tui {
fn drop(&mut self) {
let _ = self.exit();
}
}
#[derive(Default)]
struct PromptQueue {
pending: parking_lot::Mutex<VecDeque<String>>,
wake: tokio::sync::Notify,
}
impl PromptQueue {
fn enqueue(&self, prompt: String) {
self.pending.lock().push_back(prompt);
self.wake.notify_one();
}
fn remove(&self, index: usize) -> Option<String> {
self.pending.lock().remove(index)
}
fn move_by(&self, index: usize, delta: isize) -> bool {
let mut pending = self.pending.lock();
let Some(target) = index.checked_add_signed(delta) else {
return false;
};
if index >= pending.len() || target >= pending.len() {
return false;
}
pending.swap(index, target);
true
}
async fn next(&self) -> String {
loop {
let notified = self.wake.notified();
if let Some(prompt) = self.pending.lock().pop_front() {
return prompt;
}
notified.await;
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum BrainChip {
#[default]
Off,
Down,
Degraded,
Ok,
}
impl BrainChip {
pub(crate) fn chip_label(self) -> Option<(&'static str, bool)> {
match self {
BrainChip::Off => None,
BrainChip::Ok => Some(("brain·ok", true)),
BrainChip::Degraded | BrainChip::Down => Some(("brain·down", false)),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct RunState {
pub started_at: std::time::Instant,
pub turn: u32,
pub tool_calls: u32,
}
impl Default for RunState {
fn default() -> Self {
Self {
started_at: std::time::Instant::now(),
turn: 0,
tool_calls: 0,
}
}
}
pub struct RenderState {
pub composer: oxicode_textarea::TextArea,
pub transcript: Vec<TranscriptLine>,
pub scroll_offset: usize,
pub committed_entries: usize,
pub viewport_width: u16,
pub last_viewport_height: u16,
pub glyph_set: crate::symbols::GlyphSet,
pub image_previews: super::image_preview::ImagePreviews,
pub header_context: InlineHeaderContext,
pub input_enabled: bool,
pub prompt_prefix: String,
pub placeholder: Option<String>,
pub shutdown_requested: bool,
pub message_buffer: String,
pub thinking_buffer: String,
pub md_cache: oxicode_vtui::tui::ui::markdown::MdRenderCache,
pub stream_anchor: Option<usize>,
pub agent_hub_open: bool,
pub hub_entries: Vec<(String, HubEntry)>,
pub hub: Option<crate::app::agent_hub_registry::SharedHubRegistry>,
pub pending_quit: bool,
pub slash_popup: SlashPopup,
pub reasoning_stage: Option<String>,
pub(crate) active_run: Option<RunState>,
pub stream_reveal: usize,
pub(crate) brain: BrainChip,
pub thinking_level: String,
pub context_tokens: Option<usize>,
pub context_window: usize,
pub overlay: Option<OverlayState>,
pub overlay_model_ids: Vec<String>,
pub overlay_catalog_models: Vec<(String, String)>,
pub overlay_providers: Vec<String>,
pub catalog: Option<std::sync::Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog>>,
pub queued_inputs: Vec<String>,
pub queue_panel_open: bool,
pub queue_selected: usize,
pub shell_mode: bool,
pub follow_ups: Vec<String>,
pub todo_phases: Vec<TodoPhase>,
pub todo_expanded: bool,
pub todo_clear_deadline: Option<std::time::Instant>,
pub todo_clear_delay_secs: i64,
pub todo_provider: Option<Arc<dyn TodoStateProvider>>,
pub vim_state: crate::tui_vt::vim::VimState,
pub vim_clipboard: String,
pub search: Option<SearchState>,
pub block_display: std::collections::HashMap<usize, BlockDisplayMode>,
pub last_esc_at: Option<std::time::Instant>,
pub multiline_mode: bool,
pub autonomy_mode: Mode,
pub prompt_history: Vec<String>,
pub history_pos: Option<usize>,
pub next_block_id: usize,
pub cancel_grace_until: Option<std::time::Instant>,
pub confirmation: Option<ModalConfirmation>,
pub tip: Option<EphemeralTip>,
pub cwd: PathBuf,
pub file_search: Option<crate::tui_vt::file_search::FileSearchState>,
pub(crate) issues_panel: Option<crate::tui_vt::issues_panel::IssuesPanelState>,
pub issue_store: Option<std::sync::Arc<oxicode_sdk::FileIssueStore>>,
pub seen_tips: std::collections::HashMap<&'static str, u32>,
pub file_commands: Vec<FileCommand>,
pub secure_input_origin: Option<SecureInputOrigin>,
pub session_swapper: Option<Arc<crate::app::agent_session_handle::SessionSwapper>>,
pub pending_resume: Option<PathBuf>,
pub session_state: Option<crate::SessionState>,
pub settings_active_tab: crate::tui_vt::settings_defs::SettingsTab,
pub settings_map_rows: Vec<Option<SettingsMapRow>>,
pub keymap: Arc<parking_lot::RwLock<crate::tui_vt::keymap::Keymap>>,
#[cfg(test)]
pub settings_override_path: Option<std::path::PathBuf>,
pub git_tui: Option<crate::tui_vt::git_tui::GitTuiState>,
pub git_tui_viewport: (u16, u16),
}
impl Default for RenderState {
fn default() -> Self {
Self {
composer: oxicode_textarea::TextArea::new(),
transcript: Vec::new(),
scroll_offset: usize::MAX,
committed_entries: 0,
header_context: InlineHeaderContext::default(),
input_enabled: false,
prompt_prefix: String::new(),
placeholder: None,
thinking_buffer: String::new(),
shutdown_requested: false,
message_buffer: String::new(),
stream_anchor: None,
md_cache: oxicode_vtui::tui::ui::markdown::MdRenderCache::default(),
agent_hub_open: false,
hub_entries: Vec::new(),
hub: None,
pending_quit: false,
slash_popup: SlashPopup::default(),
reasoning_stage: None,
active_run: None,
stream_reveal: usize::MAX,
thinking_level: "medium".to_string(),
viewport_width: 80,
last_viewport_height: 24,
glyph_set: crate::symbols::GlyphSet::default(),
image_previews: super::image_preview::ImagePreviews::default(),
overlay: None,
overlay_model_ids: Vec::new(),
overlay_catalog_models: Vec::new(),
overlay_providers: Vec::new(),
catalog: None,
queued_inputs: Vec::new(),
queue_panel_open: false,
queue_selected: 0,
shell_mode: false,
follow_ups: Vec::new(),
todo_phases: Vec::new(),
todo_expanded: false,
todo_clear_deadline: None,
todo_clear_delay_secs: -1,
todo_provider: None,
vim_state: crate::tui_vt::vim::VimState::default(),
vim_clipboard: String::new(),
search: None,
block_display: std::collections::HashMap::new(),
last_esc_at: None,
multiline_mode: false,
autonomy_mode: Mode::default(),
prompt_history: Vec::new(),
history_pos: None,
next_block_id: 0,
cancel_grace_until: None,
confirmation: None,
tip: None,
cwd: PathBuf::new(),
file_search: None,
issues_panel: None,
issue_store: None,
seen_tips: std::collections::HashMap::new(),
file_commands: Vec::new(),
secure_input_origin: None,
session_swapper: None,
context_tokens: None,
context_window: 128_000,
settings_active_tab: crate::tui_vt::settings_defs::SettingsTab::General,
settings_map_rows: Vec::new(),
keymap: Arc::new(parking_lot::RwLock::new(Keymap::from_settings(
&std::collections::HashMap::new(),
))),
#[cfg(test)]
settings_override_path: None,
brain: BrainChip::default(),
pending_resume: None,
session_state: None,
git_tui: None,
git_tui_viewport: (80, 24),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SecureInputOrigin {
SetKey { provider: String },
NewlyAdded { provider: String },
ModelRoleKey,
ModelRoleValue { role: String },
TextEdit(crate::tui_vt::settings_defs::SettingKey),
}
#[derive(Clone, Debug)]
pub struct SearchState {
pub query: String,
pub matches: Vec<usize>,
pub current: usize,
}
#[derive(Clone)]
pub struct SlashPopupItem {
pub label: String,
pub description: String,
pub name: String,
}
#[derive(Default, Clone)]
pub struct SlashPopup {
pub open: bool,
pub items: Vec<SlashPopupItem>,
pub selected: usize,
}
#[derive(Clone, Debug)]
pub struct OverlayListItem {
pub title: String,
pub subtitle: Option<String>,
pub badge: Option<String>,
pub indent: u8,
pub search_value: Option<String>,
pub selection: Option<oxicode_vtui::tui::core::InlineListSelection>,
}
#[derive(Clone, Debug, Default)]
pub struct OverlayState {
pub title: String,
pub lines: Vec<String>,
pub items: Vec<OverlayListItem>,
pub selected: usize,
pub search: Option<OverlaySearchState>,
pub secure_input: Option<OverlaySecureInput>,
pub tabs: Vec<String>,
pub active_tab: usize,
pub sections: Vec<String>,
pub active_section: usize,
pub key_capture: Option<String>,
}
#[derive(Clone, Debug)]
pub struct OverlaySecureInput {
pub config: SecurePromptConfig,
pub editor: EditBuffer,
}
#[derive(Clone, Debug)]
pub struct ModalConfirmation {
pub title: String,
pub message: String,
pub action: ConfirmationAction,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ConfirmationAction {
Quit,
ClearConversation,
RemoveProviderKey(String),
CloseIssue(u32),
}
#[derive(Clone, Debug)]
pub struct EphemeralTip {
pub text: String,
pub born_tick: u64,
pub ttl_ticks: u64,
pub key: &'static str,
pub ambient: bool,
}
#[derive(Clone, Debug)]
pub struct OverlaySearchState {
pub label: String,
pub placeholder: Option<String>,
pub value: String,
}
impl RenderState {
fn new_with_header(header: InlineHeaderContext) -> Self {
let mut s = Self::default();
s.header_context = header;
s.prompt_prefix = "> ".to_string();
s.input_enabled = true;
let bindings = crate::store::settings::Settings::load()
.unwrap_or_default()
.keybindings;
*s.keymap.write() = Keymap::from_settings(&bindings);
s
}
pub fn swapper(&self) -> Arc<crate::app::agent_session_handle::SessionSwapper> {
self.session_swapper
.clone()
.expect("RenderState::session_swapper must be initialized at TUI startup")
}
fn append_line(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
let block_id = self.block_id_for_kind(kind);
self.transcript
.extend(
Self::segments_by_explicit_line(segments)
.into_iter()
.map(|segments| TranscriptLine {
kind,
segments,
block_id,
}),
);
}
fn append_line_new_block(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
let block_id = self.fresh_block_id();
self.transcript
.extend(
Self::segments_by_explicit_line(segments)
.into_iter()
.map(|segments| TranscriptLine {
kind,
segments,
block_id,
}),
);
}
fn inline_segment(&mut self, kind: InlineMessageKind, segment: InlineSegment) {
let mut lines = Self::segments_by_explicit_line(vec![segment]).into_iter();
let streaming = self.stream_anchor.is_some();
if let Some(first) = lines.next() {
let merge_ok =
streaming && self.transcript.last().is_some_and(|last| last.kind == kind);
if merge_ok && let Some(last) = self.transcript.last_mut() {
last.segments.extend(first);
} else {
let block_id = self.fresh_block_id();
if self.stream_anchor.is_none() {
self.stream_anchor = Some(self.transcript.len());
}
self.transcript.push(TranscriptLine {
kind,
segments: first,
block_id,
});
}
}
let block_id = self
.stream_anchor
.and_then(|a| self.transcript.get(a))
.map(|l| l.block_id)
.unwrap_or_else(|| self.fresh_block_id());
self.transcript.extend(lines.map(|segments| TranscriptLine {
kind,
segments,
block_id,
}));
}
fn segments_by_explicit_line(segments: Vec<InlineSegment>) -> Vec<Vec<InlineSegment>> {
if !segments.iter().any(|segment| segment.text.contains('\n')) {
return vec![segments];
}
let mut lines = vec![Vec::new()];
for segment in segments {
let InlineSegment { text, style } = segment;
for (index, part) in text.split('\n').enumerate() {
if index > 0 {
lines.push(Vec::new());
}
if !part.is_empty()
&& let Some(line) = lines.last_mut()
{
line.push(InlineSegment {
text: part.to_string(),
style: Arc::clone(&style),
});
}
}
}
lines
}
fn block_id_for_kind(&mut self, kind: InlineMessageKind) -> usize {
if let Some(last) = self.transcript.last()
&& last.kind == kind
{
return last.block_id;
}
let id = self.next_block_id;
self.next_block_id += 1;
id
}
fn fresh_block_id(&mut self) -> usize {
let id = self.next_block_id;
self.next_block_id += 1;
id
}
pub fn start_search(&mut self, query: &str) {
let needle = query.to_lowercase();
let matches: Vec<usize> = self
.transcript
.iter()
.enumerate()
.filter(|(i, _)| *i >= self.committed_entries)
.filter(|(_, line)| {
line.segments
.iter()
.any(|s| s.text.to_lowercase().contains(&needle))
})
.map(|(i, _)| i)
.collect();
self.search = Some(SearchState {
query: query.to_string(),
matches,
current: 0,
});
if let Some(s) = &self.search
&& let Some(&first) = s.matches.first()
{
self.scroll_offset = first;
}
}
pub fn search_next(&mut self) {
if let Some(s) = &mut self.search
&& !s.matches.is_empty()
{
s.current = (s.current + 1) % s.matches.len();
let line = s.matches[s.current];
self.scroll_offset = line;
}
}
pub fn search_prev(&mut self) {
if let Some(s) = &mut self.search
&& !s.matches.is_empty()
{
if s.current == 0 {
s.current = s.matches.len() - 1;
} else {
s.current -= 1;
}
let line = s.matches[s.current];
self.scroll_offset = line;
}
}
pub fn block_mode(&self, block_id: usize) -> BlockDisplayMode {
self.block_display
.get(&block_id)
.copied()
.unwrap_or(BlockDisplayMode::Expanded)
}
pub fn cycle_block_at_view(&mut self) {
let offset = self.effective_offset();
if let Some(line) = self.transcript.get(offset) {
let bid = line.block_id;
let next = match self.block_mode(bid) {
BlockDisplayMode::Collapsed => BlockDisplayMode::Truncated,
BlockDisplayMode::Truncated => BlockDisplayMode::Expanded,
BlockDisplayMode::Expanded => BlockDisplayMode::Collapsed,
};
if next == BlockDisplayMode::Expanded {
self.block_display.remove(&bid);
} else {
self.block_display.insert(bid, next);
}
}
}
pub fn expand_all(&mut self) {
self.block_display.clear();
}
pub fn fold_all(&mut self) {
for bid in self.all_block_ids() {
self.block_display.insert(bid, BlockDisplayMode::Collapsed);
}
}
pub fn truncate_all(&mut self) {
for bid in self.all_block_ids() {
self.block_display.insert(bid, BlockDisplayMode::Truncated);
}
}
fn all_block_ids(&self) -> Vec<usize> {
let mut ids = Vec::new();
let mut prev: Option<usize> = None;
for l in &self.transcript {
if prev != Some(l.block_id) {
ids.push(l.block_id);
prev = Some(l.block_id);
}
}
ids
}
pub fn jump_next_turn(&mut self) {
let offset = self.effective_offset();
let search_after = self
.transcript
.iter()
.enumerate()
.skip(offset + 1)
.find(|(_, l)| l.kind == InlineMessageKind::Agent || l.kind == InlineMessageKind::User);
if let Some((idx, _)) = search_after {
self.scroll_offset = idx;
}
}
pub fn jump_prev_turn(&mut self) {
let offset = self.effective_offset();
let search_before = self
.transcript
.iter()
.enumerate()
.take(offset)
.rev()
.find(|(_, l)| l.kind == InlineMessageKind::User);
if let Some((idx, _)) = search_before {
self.scroll_offset = idx;
}
}
fn effective_offset(&self) -> usize {
if self.scroll_offset == usize::MAX {
self.transcript.len().saturating_sub(1)
} else {
self.scroll_offset
}
}
pub fn drain_queue_head(&mut self) {
if !self.queued_inputs.is_empty() {
self.queued_inputs.remove(0);
}
}
pub fn show_tip(&mut self, key: &'static str, text: &str, ttl: u64, ambient: bool) {
let count = self.seen_tips.entry(key).or_insert(0);
if *count >= SEEN_CAP {
return;
}
*count += 1;
self.tip = Some(EphemeralTip {
text: text.to_string(),
born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
ttl_ticks: ttl,
key,
ambient,
});
}
}
const SEEN_CAP: u32 = 3;
pub async fn run_tui(app: App) -> Result<()> {
let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
let git_branch = crate::util::git_utils::get_current_branch(&cwd);
super::host::activate_theme(app.settings());
let theme_id = oxicode_vtui::theme::active_theme_id();
let validation = oxicode_vtui::theme::validate_theme_contrast(&theme_id);
if validation.warnings.is_empty() {
tracing::debug!("theme '{theme_id}' passed contrast validation");
} else {
for w in &validation.warnings {
tracing::warn!("theme contrast: {w}");
}
}
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::<InlineEvent>();
let handle = InlineHandle::new_for_tests(cmd_tx);
let session = build_agent_session(&app).await?;
let session_handle = session.clone_handle();
let session_swapper = Arc::new(crate::app::agent_session_handle::SessionSwapper::new(
session_handle.clone(),
));
let (session_tx, mut session_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
let _sub_guard = session.subscribe(Box::new(move |event| {
let _ = session_tx.send(event.clone());
}));
let header = build_header_context(&app, &cwd, git_branch.as_deref());
handle.set_header_context(header.clone());
let mut tui = Tui::enter()?;
handle.set_prompt("> ".to_string(), InlineTextStyle::default());
handle.set_placeholder(Some(
"Describe the task, or type / for commands".to_string(),
));
let state = Arc::new(parking_lot::Mutex::new(RenderState::new_with_header(
header,
)));
state.lock().cwd = cwd.clone();
state.lock().catalog = Some(app.catalog());
state.lock().file_commands = crate::tui_vt::slash::file_commands::load_file_commands(&cwd);
state.lock().todo_provider = session_handle.todo_provider();
state.lock().todo_clear_delay_secs = app.settings().todo_clear_delay_secs;
state.lock().hub = Some(session_handle.hub_arc());
state.lock().session_swapper = Some(session_swapper.clone());
state.lock().session_state = Some(app.session_state().clone());
state.lock().thinking_level = format!("{:?}", session.thinking_level()).to_ascii_lowercase();
{
let mut s = state.lock();
sync_model_chips(&mut s, &session_handle);
}
state.lock().tip = Some(EphemeralTip {
text: "Press ? for shortcuts | /help for commands".to_string(),
born_tick: 0,
ttl_ticks: 900,
key: "onboarding",
ambient: true,
});
if std::env::var("SSH_CONNECTION").is_ok() {
state.lock().show_tip(
"ssh_wrap",
"Over SSH? Consider tmux to keep sessions alive",
600,
true,
);
}
let mode_handle = app.ask_bridge().map(|b| {
let handle = b.mode_handle();
state.lock().autonomy_mode = Mode::load(&handle);
handle
});
state.lock().glyph_set = app.settings().glyph_set;
state
.lock()
.image_previews
.set_enabled(app.settings().inline_images);
let prompt_queue = Arc::new(PromptQueue::default());
let (issue_action_tx, mut issue_action_rx) =
tokio::sync::mpsc::unbounded_channel::<crate::tui_vt::issues_panel::IssueActionRequest>();
spawn_input_thread(
state.clone(),
evt_tx.clone(),
mode_handle,
prompt_queue.clone(),
issue_action_tx.clone(),
);
spawn_agent_worker(session_swapper.clone(), prompt_queue.clone());
let (brain_tx, mut brain_rx) =
tokio::sync::watch::channel(crate::services::initial_brain_chip(app.settings()));
{
let memory_enabled = app.settings().memory_enabled;
let announce = handle.clone();
tokio::spawn(async move {
let backend = crate::foundation::brain::BrainMemoryBackend::new(
crate::foundation::brain::default_socket_path(),
);
let mut tick = tokio::time::interval(std::time::Duration::from_secs(20));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut auto_revive_attempted = false;
loop {
tick.tick().await;
let mut chip = if !memory_enabled {
BrainChip::Off
} else if !crate::services::brain_socket_present(
&crate::foundation::brain::default_socket_path(),
) {
BrainChip::Down
} else {
match tokio::time::timeout(
std::time::Duration::from_millis(1500),
backend.ping(),
)
.await
{
Ok(Ok(())) => BrainChip::Ok,
Ok(Err(_)) | Err(_) => BrainChip::Degraded,
}
};
let down = matches!(chip, BrainChip::Down | BrainChip::Degraded);
if crate::foundation::brain_control::should_auto_revive(
memory_enabled,
down,
auto_revive_attempted,
) {
auto_revive_attempted = true;
let installed = crate::foundation::brain_control::probe_control()
.binary
.is_some();
if installed {
match crate::foundation::brain_control::revive().await {
Ok(msg) => {
announce.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!("brain: daemon was down — {msg}"))],
);
chip = match tokio::time::timeout(
std::time::Duration::from_millis(1500),
backend.ping(),
)
.await
{
Ok(Ok(())) => BrainChip::Ok,
_ => chip,
};
}
Err(e) => {
announce.append_line(
InlineMessageKind::Warning,
vec![plain_segment(format!(
"brain: auto-restart failed — {e} (run /brain for details)"
))],
);
}
}
}
}
let _ = brain_tx.send(chip);
}
});
}
let result = run_event_loop(
&mut tui.terminal,
&mut cmd_rx,
&mut evt_rx,
&mut session_rx,
&mut brain_rx,
&handle,
&state,
&session_swapper,
&prompt_queue,
&mut issue_action_rx,
)
.await;
handle.shutdown();
drop(tui);
result
}
#[allow(clippy::too_many_arguments)]
async fn run_event_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
cmd_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineCommand>,
evt_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineEvent>,
session_rx: &mut tokio::sync::mpsc::UnboundedReceiver<SessionEvent>,
brain_rx: &mut tokio::sync::watch::Receiver<BrainChip>,
handle: &InlineHandle,
state: &Arc<parking_lot::Mutex<RenderState>>,
session_swapper: &Arc<crate::app::agent_session_handle::SessionSwapper>,
prompt_queue: &Arc<PromptQueue>,
issue_action_rx: &mut tokio::sync::mpsc::UnboundedReceiver<
crate::tui_vt::issues_panel::IssueActionRequest,
>,
) -> Result<()> {
while let Ok(cmd) = cmd_rx.try_recv() {
apply_command(&mut state.lock(), cmd);
}
{
let mut s = state.lock();
match terminal.size() {
Ok(size) => {
s.viewport_width = size.width;
s.last_viewport_height = size.height;
}
Err(_) => {
s.viewport_width = 0;
s.last_viewport_height = 0;
}
}
}
{
let snapshot = state.lock();
let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
if let Err(err) = terminal.draw(|frame| render_frame(frame, &snapshot, handle)) {
tracing::warn!(?err, "initial tui draw failed");
}
let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
}
let mut render_tick = tokio::time::interval(std::time::Duration::from_millis(50));
render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut last_draw = std::time::Instant::now();
let mut priority = false;
loop {
tokio::select! {
biased;
Some(cmd) = cmd_rx.recv() => {
let shutdown = {
let mut s = state.lock();
apply_command(&mut s, cmd)
};
if shutdown {
break;
}
}
Some(event) = session_rx.recv() => {
if let SessionEvent::HandoffComplete { doc_path, auto_continue } = &event {
let mut s = state.lock();
s.transcript.clear();
s.message_buffer.clear();
s.scroll_offset = usize::MAX;
s.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!(
"Handoff written to {}. New session started.",
doc_path
))],
);
let user_typed = !s.composer.text().trim().is_empty();
s.composer.set_text("");
if *auto_continue && !user_typed {
drop(s);
prompt_queue.enqueue(format!(
"Read the handoff document at {} and continue \
from where the previous session left off.",
doc_path
));
} else if user_typed {
drop(s);
handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(
"Handoff complete. Auto-continue skipped \
because input was non-empty \u{2014} press \
Enter to submit your message in the new \
session."
.to_string(),
)],
);
}
let session = session_swapper.current();
handle_session_event(&mut state.lock(), handle, &event, Some(&session));
} else {
let session = session_swapper.current();
handle_session_event(&mut state.lock(), handle, &event, Some(&session));
}
}
Some(evt) = evt_rx.recv() => {
let outcome = handle_inline_event(
&mut state.lock(),
handle,
&session_swapper.current(),
prompt_queue,
evt,
);
if outcome == LoopOutcome::Exit {
break;
}
priority = true;
}
Some(req) = issue_action_rx.recv() => {
crate::tui_vt::issues_panel::dispatch_action(req, state.clone());
}
_ = tokio::signal::ctrl_c() => {
let outcome = {
let mut s = state.lock();
handle_interrupt(&mut s, &session_swapper.current(), handle)
};
if outcome == LoopOutcome::Exit {
break;
}
priority = true;
}
changed = brain_rx.changed() => {
if changed.is_ok() {
state.lock().brain = *brain_rx.borrow_and_update();
priority = true;
}
}
_ = render_tick.tick() => {}
}
if coalesce_draw(last_draw, priority, DRAW_MIN_INTERVAL) == DrawDecision::DrawNow {
if let Ok(size) = terminal.size()
&& size.width < 40
{
let mut s = state.lock();
if s.tip.is_none() {
s.show_tip(
"small_screen",
"Terminal too narrow \u{2014} resize for full UI",
300,
true,
);
}
}
let mut snapshot = state.lock();
let mut prev_size: Option<(u16, u16)> = None;
if let Ok(size) = terminal.size() {
prev_size = Some((snapshot.viewport_width, snapshot.last_viewport_height));
snapshot.viewport_width = size.width;
snapshot.last_viewport_height = size.height;
}
if let Some((prev_w, prev_h)) = prev_size
&& let Ok(size) = terminal.size()
&& should_rebuild_scrollback(prev_w, size.width, prev_h, size.height)
{
let _ = execute!(terminal.backend_mut(), crossterm::style::Print("\x1b[3J"));
let _ = terminal.clear();
snapshot.committed_entries = 0;
}
if let Some(provider) = snapshot.todo_provider.as_ref() {
snapshot.todo_phases = refresh_todo_phases(provider, snapshot.hub.as_ref());
}
let clear_delay = snapshot.todo_clear_delay_secs;
sync_todo_clear_timer(&mut snapshot, clear_delay);
advance_stream_reveal(&mut snapshot);
commit_scrollback(terminal, &mut snapshot, false);
let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
let draw_err = terminal
.draw(|frame| render_frame(frame, &snapshot, handle))
.err();
let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
let committed = snapshot.committed_entries;
let image_escapes = snapshot.image_previews.emit_live(committed);
if !image_escapes.is_empty() {
let _ = execute!(
terminal.backend_mut(),
crossterm::style::Print(image_escapes)
);
}
if let Some(err) = draw_err {
tracing::warn!(?err, "tui draw failed");
break;
}
last_draw = std::time::Instant::now();
priority = false;
}
}
const MAX_EXIT_FLUSH_ITERATIONS: usize = 50;
for _ in 0..MAX_EXIT_FLUSH_ITERATIONS {
let mut snapshot = state.lock();
let before = snapshot.committed_entries;
if snapshot.transcript.is_empty() || before >= snapshot.transcript.len() {
break;
}
if let Some(provider) = snapshot.todo_provider.as_ref() {
snapshot.todo_phases = refresh_todo_phases(provider, snapshot.hub.as_ref());
}
let clear_delay = snapshot.todo_clear_delay_secs;
sync_todo_clear_timer(&mut snapshot, clear_delay);
advance_stream_reveal(&mut snapshot);
commit_scrollback(terminal, &mut snapshot, true);
let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
let draw_err = terminal
.draw(|frame| render_frame(frame, &snapshot, handle))
.err();
let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
if let Some(err) = draw_err {
tracing::warn!(?err, "tui draw failed");
break;
}
}
Ok(())
}
const DRAW_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50);
#[derive(Debug, PartialEq, Eq)]
enum DrawDecision {
DrawNow,
Defer,
}
fn coalesce_draw(
last_draw_at: std::time::Instant,
priority: bool,
min_interval: std::time::Duration,
) -> DrawDecision {
if priority || last_draw_at.elapsed() >= min_interval {
DrawDecision::DrawNow
} else {
DrawDecision::Defer
}
}
#[derive(PartialEq, Eq)]
enum LoopOutcome {
Continue,
Exit,
}
#[derive(PartialEq, Eq, Debug)]
enum CancelRoute {
Interrupt,
Exit,
}
fn route_cancel(is_streaming: bool) -> CancelRoute {
if is_streaming {
CancelRoute::Interrupt
} else {
CancelRoute::Exit
}
}
fn apply_command(state: &mut RenderState, cmd: InlineCommand) -> bool {
match cmd {
InlineCommand::AppendLine { kind, segments } => {
state.append_line(kind, segments);
}
InlineCommand::Inline { kind, segment } => {
state.inline_segment(kind, segment);
}
InlineCommand::BeginStream { .. } => {
state.stream_anchor = None;
}
InlineCommand::EndStream => {
state.stream_anchor = None;
}
InlineCommand::AppendLineBlockStart { kind, segments } => {
state.append_line_new_block(kind, segments);
}
InlineCommand::ReplaceLast { kind, lines, .. } => {
let from = state.stream_anchor.unwrap_or(state.transcript.len());
state.transcript.truncate(from);
for line in lines {
state.append_line(kind, line);
}
state.stream_anchor = Some(from);
}
InlineCommand::AppendPastedMessage { kind, text, .. } => {
state.append_line(kind, vec![plain_segment(text)]);
}
InlineCommand::SetPrompt { prefix, .. } => {
state.prompt_prefix = prefix;
}
InlineCommand::SetPlaceholder { hint, .. } => {
state.placeholder = hint;
}
InlineCommand::SetHeaderContext { context } => {
state.header_context = *context;
}
InlineCommand::SetInputStatus { .. } => {
}
InlineCommand::SetInputEnabled(enabled) => {
state.input_enabled = enabled;
}
InlineCommand::SetCursorVisible(_) | InlineCommand::ForceRedraw => {}
InlineCommand::SetReasoningStage(stage) => {
state.reasoning_stage = stage;
}
InlineCommand::SetVimModeEnabled(enabled) => {
state.vim_state.set_enabled(enabled);
}
InlineCommand::SetQueuedInputs { entries } => {
state.queued_inputs = entries;
}
InlineCommand::ShowOverlay { request } => {
let mut overlay = materialize_overlay(*request);
let mut map_rows = Vec::new();
if overlay.items.iter().any(|it| {
matches!(
it.selection,
Some(InlineListSelection::ConfigAction(_))
| Some(InlineListSelection::SettingsTab(_))
| Some(InlineListSelection::SettingsSection(_))
| Some(InlineListSelection::SettingKeyCapture(_))
| Some(InlineListSelection::SettingTextEdit(_))
| Some(InlineListSelection::SettingSubmenuOpen(_))
| Some(InlineListSelection::SettingMultiselect(_))
)
}) {
let (hydrated, rows) = build_settings_overlay(state.settings_active_tab, None);
overlay = hydrated;
map_rows = rows;
}
state.overlay = Some(overlay);
state.settings_map_rows = map_rows;
}
InlineCommand::CloseOverlay => {
state.overlay = None;
}
InlineCommand::Shutdown => {
state.shutdown_requested = true;
return true;
}
_ => {
tracing::trace!("unhandled InlineCommand (not rendered)");
}
}
false
}
fn materialize_overlay(request: OverlayRequest) -> OverlayState {
match request {
OverlayRequest::Modal(req) => {
let secure_input = req.secure_prompt.map(|cfg| OverlaySecureInput {
config: cfg,
editor: EditBuffer::new(),
});
OverlayState {
title: req.title,
lines: req.lines,
items: Vec::new(),
selected: 0,
search: None,
secure_input,
..Default::default()
}
}
OverlayRequest::List(req) => {
let search = req.search.map(|cfg| OverlaySearchState {
label: cfg.label,
placeholder: cfg.placeholder,
value: String::new(),
});
OverlayState {
title: req.title,
lines: req.lines,
items: req.items.into_iter().map(overlay_item_from).collect(),
selected: 0,
search,
secure_input: None,
..Default::default()
}
}
OverlayRequest::Wizard(req) => {
let step_items = req
.steps
.first()
.map(|s| {
s.items
.iter()
.map(|it| overlay_item_from(it.clone()))
.collect()
})
.unwrap_or_default();
let search = req.search.map(|cfg| OverlaySearchState {
label: cfg.label,
placeholder: cfg.placeholder,
value: String::new(),
});
OverlayState {
title: req.title,
lines: Vec::new(),
items: step_items,
selected: 0,
search,
secure_input: None,
..Default::default()
}
}
}
}
fn overlay_item_from(item: InlineListItem) -> OverlayListItem {
OverlayListItem {
title: item.title,
subtitle: item.subtitle,
badge: item.badge,
indent: item.indent,
search_value: item.search_value,
selection: item.selection,
}
}
const SETTINGS_TABS: &[(SettingsTab, &str)] = &[
(SettingsTab::General, "General"),
(SettingsTab::Model, "Model"),
(SettingsTab::Interaction, "Interaction"),
(SettingsTab::Tools, "Tools"),
(SettingsTab::Ui, "UI"),
(SettingsTab::AdvisorMemory, "Advisor & Memory"),
(SettingsTab::Keybindings, "Keybindings"),
(SettingsTab::Advanced, "Advanced"),
];
fn build_settings_overlay(
tab: SettingsTab,
keep_search: Option<OverlaySearchState>,
) -> (OverlayState, Vec<Option<SettingsMapRow>>) {
let settings = crate::store::settings::Settings::load().unwrap_or_default();
let (items, map_rows) = settings_overlay_items(tab, &settings);
let items: Vec<OverlayListItem> = items.into_iter().map(overlay_item_from).collect();
let mut sections: Vec<String> = Vec::new();
for def in defs_for_tab(tab, &settings) {
if sections.last().map(String::as_str) != Some(def.group) {
sections.push(def.group.to_string());
}
}
let active_tab = SETTINGS_TABS
.iter()
.position(|(t, _)| *t == tab)
.unwrap_or(0);
(
OverlayState {
title: "Settings".into(),
lines: vec!["Browse settings by group; filter with the search bar.".into()],
items,
selected: 0,
search: keep_search.or(Some(OverlaySearchState {
label: "Filter settings".into(),
placeholder: Some("Type to filter".into()),
value: String::new(),
})),
secure_input: None,
tabs: SETTINGS_TABS
.iter()
.map(|(_, name)| name.to_string())
.collect(),
active_tab,
sections,
active_section: 0,
key_capture: None,
},
map_rows,
)
}
fn reopen_settings_panel(state: &mut RenderState, tab: SettingsTab, status: Option<String>) {
let keep_search = state.overlay.as_ref().and_then(|o| o.search.clone());
state.settings_active_tab = tab;
let (mut overlay, map_rows) = build_settings_overlay(tab, keep_search);
if let Some(status) = status {
if overlay.lines.is_empty() {
overlay.lines.push(status);
} else {
overlay.lines[0] = status;
}
}
state.overlay = Some(overlay);
state.settings_map_rows = map_rows;
}
fn switch_settings_tab(state: &mut RenderState, tab_idx: usize) {
let Some(&(tab, _)) = SETTINGS_TABS.get(tab_idx) else {
return;
};
let search = state.overlay.as_ref().and_then(|o| o.search.clone());
state.settings_active_tab = tab;
let (overlay, map_rows) = build_settings_overlay(tab, search);
state.overlay = Some(overlay);
state.settings_map_rows = map_rows;
}
fn jump_settings_section(state: &mut RenderState, section_idx: usize) {
let tab = state.settings_active_tab;
let search = state.overlay.as_ref().and_then(|o| o.search.clone());
let (mut overlay, map_rows) = build_settings_overlay(tab, search);
if let Some(target) = overlay.sections.get(section_idx).cloned() {
let mut in_target = false;
let mut anchor: Option<usize> = None;
for (idx, item) in overlay.items.iter().enumerate() {
let is_heading =
item.selection.is_none() && item.badge.is_none() && item.subtitle.is_none();
if is_heading {
in_target = item.title == target;
} else if in_target && anchor.is_none() {
anchor = Some(idx);
}
}
if let Some(idx) = anchor {
overlay.selected = idx;
overlay.active_section = section_idx;
}
}
state.overlay = Some(overlay);
state.settings_map_rows = map_rows;
}
fn build_key_capture_overlay(action_name: &str) -> OverlayState {
OverlayState {
title: format!("Keybinding: {action_name}"),
lines: vec![format!(
"Press a key combo for {action_name} (Esc to cancel)\u{2026}"
)],
key_capture: Some(action_name.to_string()),
..Default::default()
}
}
fn key_event_to_combo_text(key: KeyEvent) -> Option<(String, KeyCombo)> {
use crossterm::event::KeyCode as Kc;
let mods = key.modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT);
let code = match key.code {
Kc::Char(c) if c.is_ascii_lowercase() && mods.contains(KeyModifiers::SHIFT) => {
Kc::Char(c.to_ascii_uppercase())
}
other => other,
};
let combo = KeyCombo {
code,
modifiers: mods,
};
let text = combo.to_string();
(KeyCombo::parse(&text) == Some(combo.clone())).then_some((text, combo))
}
fn handle_key_capture(state: &mut RenderState, key: KeyEvent) {
let Some(action_name) = state.overlay.as_ref().and_then(|o| o.key_capture.clone()) else {
return;
};
let Some(action) = GlobalAction::from_name(&action_name) else {
state.overlay = None;
state.settings_map_rows.clear();
return;
};
if key.code == KeyCode::Esc
&& !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
reopen_settings_panel(state, SettingsTab::Keybindings, None);
return;
}
let Some((text, _combo)) = key_event_to_combo_text(key) else {
set_capture_prompt_line(
state,
"That key can't be captured (F-keys and arrows don't round-trip). \
Try another combo, Esc to cancel\u{2026}",
);
return;
};
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
set_capture_prompt_line(
state,
&format!(
"'{text}' has no Ctrl or Alt — it would hijack typing. \
Try another combo, Esc to cancel\u{2026}"
),
);
return;
}
let mut combos: Vec<String> = state
.keymap
.read()
.action_combos(action)
.iter()
.map(|c| c.to_string())
.collect();
if !combos.iter().any(|c| c == &text) {
combos.push(text.clone());
}
match commit_keybindings(state, action, combos) {
Ok(()) => reopen_settings_panel(
state,
SettingsTab::Keybindings,
Some(format!("Captured {text} for {action_name}")),
),
Err(e) => set_capture_prompt_line(
state,
&format!("Failed to save keybindings: {e} — Esc to cancel\u{2026}"),
),
}
}
fn set_capture_prompt_line(state: &mut RenderState, line: &str) {
if let Some(overlay) = state.overlay.as_mut() {
overlay.lines = vec![line.to_string()];
}
}
fn commit_keybindings(
state: &mut RenderState,
action: GlobalAction,
combos: Vec<String>,
) -> anyhow::Result<()> {
let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
crate::tui_vt::settings_defs::set_action_combos(&mut settings, action, combos);
save_settings_sandboxed(state, &settings)?;
*state.keymap.write() = Keymap::from_settings(&settings.keybindings);
Ok(())
}
fn save_settings_sandboxed(
state: &RenderState,
settings: &crate::store::settings::Settings,
) -> anyhow::Result<()> {
#[cfg(test)]
{
if let Some(path) = state.settings_override_path.as_ref() {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
return settings.save_to(path);
}
}
let _ = state; settings.save()
}
fn remove_keybinding_combo(state: &mut RenderState, action: GlobalAction, combo: &str) {
let current: Vec<String> = state
.keymap
.read()
.action_combos(action)
.iter()
.map(|c| c.to_string())
.collect();
if current.len() <= 1 {
reopen_settings_panel(
state,
SettingsTab::Keybindings,
Some(format!(
"Refusing to remove the last combo for {} — add another first (Enter on the \
action row)",
action.name()
)),
);
return;
}
let next: Vec<String> = current
.iter()
.filter(|c| c.as_str() != combo)
.cloned()
.collect();
if next.len() == current.len() {
return;
}
match commit_keybindings(state, action, next) {
Ok(()) => reopen_settings_panel(
state,
SettingsTab::Keybindings,
Some(format!("Removed {combo} from {}", action.name())),
),
Err(e) => reopen_settings_panel(
state,
SettingsTab::Keybindings,
Some(format!("Failed to save keybindings: {e}")),
),
}
}
fn open_model_role_value_prompt(state: &mut RenderState, role: &str) {
let current = crate::store::settings::Settings::load()
.map(|s| s.model_roles.get(role).cloned())
.ok()
.flatten();
state.secure_input_origin = Some(SecureInputOrigin::ModelRoleValue {
role: role.to_string(),
});
state.overlay = Some(text_prompt_overlay(
format!("Model for role '{role}'"),
"Enter the model pattern (provider/model). Enter saves, Esc cancels.".into(),
"model",
Some("provider/model".into()),
current.as_deref(),
));
state.settings_map_rows.clear();
}
fn open_model_role_key_prompt(state: &mut RenderState) {
state.secure_input_origin = Some(SecureInputOrigin::ModelRoleKey);
state.overlay = Some(text_prompt_overlay(
"New model role".to_string(),
"Name the role (e.g. fast, reviewer). Enter continues, Esc cancels.".into(),
"role",
Some("role name".into()),
None,
));
state.settings_map_rows.clear();
}
fn text_prompt_overlay(
title: String,
line: String,
label: &str,
placeholder: Option<String>,
prefill: Option<&str>,
) -> OverlayState {
let mut editor = EditBuffer::new();
if let Some(text) = prefill {
let _ = editor.insert_str(text);
}
OverlayState {
title,
lines: vec![line],
secure_input: Some(OverlaySecureInput {
config: SecurePromptConfig {
label: label.to_string(),
placeholder,
mask_input: false,
},
editor,
}),
..Default::default()
}
}
fn settings_map_editor_active(state: &RenderState) -> bool {
state.overlay.as_ref().is_some_and(|o| {
o.tabs.len() > 1
&& o.key_capture.is_none()
&& state.settings_map_rows.len() == o.items.len()
})
}
fn selected_settings_map_row(state: &RenderState) -> Option<SettingsMapRow> {
if !settings_map_editor_active(state) {
return None;
}
let selected = state.overlay.as_ref().map(|o| o.selected)?;
state.settings_map_rows.get(selected).cloned().flatten()
}
fn try_edit_model_role(state: &mut RenderState) -> bool {
match selected_settings_map_row(state) {
Some(SettingsMapRow::ModelRole(role)) => {
open_model_role_value_prompt(state, &role);
true
}
_ => false,
}
}
fn try_remove_settings_map_row(state: &mut RenderState) -> bool {
match selected_settings_map_row(state) {
Some(SettingsMapRow::KeybindingCombo(action, combo)) => {
remove_keybinding_combo(state, action, &combo);
true
}
Some(SettingsMapRow::ModelRole(role)) => {
let status = match crate::store::settings::Settings::load() {
Ok(mut settings) => {
let existed =
crate::tui_vt::settings_defs::remove_model_role(&mut settings, &role);
match settings.save() {
Ok(()) if existed => format!("Removed model role '{role}'"),
Ok(()) => format!("Role '{role}' was already gone"),
Err(e) => format!("Failed to save model roles: {e}"),
}
}
Err(e) => format!("Failed to load settings: {e}"),
};
reopen_settings_panel(state, SettingsTab::Model, Some(status));
true
}
_ => false,
}
}
fn try_start_new_model_role(state: &mut RenderState) -> bool {
if settings_map_editor_active(state) && state.settings_active_tab == SettingsTab::Model {
open_model_role_key_prompt(state);
true
} else {
false
}
}
fn open_text_edit_prompt(state: &mut RenderState, key: SettingKey) {
let current = crate::store::settings::Settings::load()
.map(|s| get_display_value(key, &s))
.unwrap_or_default();
state.secure_input_origin = Some(SecureInputOrigin::TextEdit(key));
state.overlay = Some(text_prompt_overlay(
format!("Edit {}", label_for_setting(key)),
format!(
"Enter the new value for {} (Esc to cancel). Empty clears the override when the field supports it.",
label_for_setting(key)
),
"value",
None,
if current == "default" {
None
} else {
Some(current.as_str())
},
));
}
fn open_submenu_select_prompt(state: &mut RenderState, key: SettingKey) {
let Some(options) = submenu_options_for(key) else {
reopen_settings_panel(
state,
state.settings_active_tab,
Some(format!("'{:?}' is not a submenu-select setting", key)),
);
return;
};
let current = crate::store::settings::Settings::load()
.map(|s| get_display_value(key, &s))
.unwrap_or_default();
let items: Vec<OverlayListItem> = options
.iter()
.map(|opt| InlineListItem {
title: (*opt).to_string(),
subtitle: None,
badge: if *opt == current {
Some("current".to_string())
} else {
None
},
indent: 0,
selection: Some(InlineListSelection::ConfigAction(format!(
"SubmenuCommit:{:?}:{}",
key, opt
))),
search_value: None,
})
.map(overlay_item_from)
.collect();
let label = label_for_setting(key);
state.overlay = Some(OverlayState {
title: format!("Pick value for {label}"),
lines: vec![format!("Esc cancels — current: {current}")],
items,
selected: options.iter().position(|o| *o == current).unwrap_or(0),
..Default::default()
});
state.settings_map_rows.clear();
}
fn open_disabled_tools_multiselect(
state: &mut RenderState,
session: &crate::app::agent_session::AgentSessionHandle,
) {
let mut tools = session.agent_ref().tools().get_tools();
tools.sort_by(|a, b| a.name().cmp(b.name()));
let settings = crate::store::settings::Settings::load().unwrap_or_default();
let disabled: std::collections::HashSet<String> =
settings.disabled_tools.iter().cloned().collect();
let items: Vec<OverlayListItem> = tools
.iter()
.map(|t| {
let name = t.name();
let is_disabled = disabled.contains(name);
InlineListItem {
title: name.to_string(),
subtitle: Some(t.description().to_string()),
badge: Some(if t.essential() {
if is_disabled {
"essential — locked".to_string()
} else {
"essential".to_string()
}
} else if is_disabled {
"disabled".to_string()
} else {
"enabled".to_string()
}),
indent: 0,
selection: Some(InlineListSelection::ConfigAction(format!(
"DisabledToolToggle:{}",
name
))),
search_value: None,
}
})
.map(overlay_item_from)
.collect();
let disabled_count = items
.iter()
.filter(|i| {
i.badge
.as_deref()
.map(|b| b == "disabled" || b == "essential — locked")
.unwrap_or(false)
})
.count();
state.overlay = Some(OverlayState {
title: "Disabled tools".into(),
lines: vec![format!(
"{disabled_count} disabled — Enter/Space toggles, Esc closes"
)],
items,
selected: 0,
..Default::default()
});
state.settings_map_rows.clear();
}
fn commit_text_edit(
state: &mut RenderState,
handle: &InlineHandle,
session: Option<&crate::app::agent_session::AgentSessionHandle>,
key: SettingKey,
text: String,
) -> (anyhow::Result<()>, String) {
let label = label_for_setting(key);
let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
let outcome =
crate::tui_vt::settings_defs::apply_change(key, &mut settings, text.trim().to_string())
.and_then(|_| save_settings_sandboxed(state, &settings));
let new_display = get_display_value(key, &settings);
match &outcome {
Ok(()) => {
if let Some(session) = session {
if let Err(e) = sync_settings_live(state, session, key, &settings) {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("{label}: {e}"))],
);
}
}
let status = format!("{label}: {new_display}");
handle.append_line(InlineMessageKind::Info, vec![plain_segment(status.clone())]);
reopen_settings_panel(state, state.settings_active_tab, Some(status.clone()));
(Ok(()), status)
}
Err(e) => {
let msg = format!("{label}: {e}");
handle.append_line(InlineMessageKind::Error, vec![plain_segment(msg.clone())]);
(Err(anyhow::anyhow!("{e}")), msg)
}
}
}
fn commit_submenu_choice(
state: &mut RenderState,
key: SettingKey,
value: String,
) -> anyhow::Result<String> {
let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
crate::tui_vt::settings_defs::apply_change(key, &mut settings, value.clone())?;
save_settings_sandboxed(state, &settings)?;
let new_display = get_display_value(key, &settings);
let label = label_for_setting(key);
let status = format!("{label}: {new_display}");
reopen_settings_panel(state, state.settings_active_tab, Some(status.clone()));
Ok(status)
}
fn commit_disabled_tool_toggle(
state: &mut RenderState,
handle: &InlineHandle,
session: &crate::app::agent_session::AgentSessionHandle,
tool: String,
essential: bool,
currently_disabled: bool,
) {
let label = label_for_setting(SettingKey::DisabledTools);
if essential {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"'{tool}' is essential and cannot be disabled"
))],
);
open_disabled_tools_multiselect(state, session);
return;
}
let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
let new_enabled = currently_disabled; crate::tui_vt::settings_defs::toggle_disabled_tool(&mut settings, &tool, new_enabled);
match save_settings_sandboxed(state, &settings) {
Ok(()) => {
let new_state = if new_enabled { "enabled" } else { "disabled" };
handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!("{label}: '{tool}' {new_state}"))],
);
open_disabled_tools_multiselect(state, session);
}
Err(e) => {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("{label}: failed to save: {e}"))],
);
}
}
}
fn label_for_setting(key: SettingKey) -> &'static str {
SETTING_DEFS
.iter()
.find(|d| d.key == key)
.map(|d| d.label)
.unwrap_or("setting")
}
fn parse_setting_key(name: &str) -> Option<SettingKey> {
SETTING_DEFS
.iter()
.map(|d| d.key)
.find(|k| format!("{k:?}") == name)
}
fn submenu_options_for(key: SettingKey) -> Option<&'static [&'static str]> {
SETTING_DEFS
.iter()
.find(|d| d.key == key)
.and_then(|d| match d.widget {
SettingWidget::SubmenuSelect(opts) => Some(opts),
_ => None,
})
}
fn item_section_idx(overlay: &OverlayState, idx: usize) -> Option<usize> {
let mut current: Option<String> = None;
for (i, item) in overlay.items.iter().enumerate() {
let is_heading =
item.selection.is_none() && item.badge.is_none() && item.subtitle.is_none();
if is_heading {
current = Some(item.title.clone());
}
if i == idx {
return current
.as_deref()
.and_then(|g| overlay.sections.iter().position(|s| s == g));
}
}
None
}
fn next_cycle_value(key: SettingKey, s: &crate::store::settings::Settings) -> Option<String> {
match key {
SettingKey::ThinkingLevel => {
const LEVELS: [&str; 6] = ["off", "minimal", "low", "medium", "high", "xhigh"];
let cur = get_display_value(key, s);
let idx = LEVELS.iter().position(|l| *l == cur).unwrap_or(0);
Some(LEVELS[(idx + 1) % LEVELS.len()].to_string())
}
SettingKey::GlyphSet => Some(s.glyph_set.next().to_string()),
SettingKey::EditFormat => Some(
if get_display_value(key, s) == "hashline" {
"str_replace"
} else {
"hashline"
}
.to_string(),
),
_ => None,
}
}
fn sync_settings_live(
state: &mut RenderState,
session: &crate::app::agent_session::AgentSessionHandle,
key: SettingKey,
settings: &crate::store::settings::Settings,
) -> anyhow::Result<()> {
match key {
SettingKey::ThinkingLevel => {
session.set_thinking_level(settings.thinking_level);
state.thinking_level = get_display_value(key, settings);
}
SettingKey::GlyphSet => state.glyph_set = settings.glyph_set,
SettingKey::AutoCompaction => session.set_auto_compaction(settings.auto_compaction),
SettingKey::AdvisorEnabled if session.is_advisor_enabled() != settings.advisor.enabled => {
session
.set_advisor_enabled(settings.advisor.enabled)
.map_err(|e| {
anyhow::anyhow!(
"failed to {} the advisor live: {e} (saved; restart to apply)",
if settings.advisor.enabled {
"enable"
} else {
"disable"
}
)
})?;
}
_ => {}
}
Ok(())
}
fn handle_session_event(
state: &mut RenderState,
handle: &InlineHandle,
event: &SessionEvent,
session: Option<&crate::app::agent_session::AgentSessionHandle>,
) {
match event {
SessionEvent::Agent(boxed) => {
let event = *boxed.clone();
if let (AgentEvent::Error { message, .. }, Some(session)) = (&event, session)
&& is_missing_api_key_error(message)
{
let provider = provider_from_model_id(&session.model_id());
handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!(
"Authentication is required for '{provider}'. Enter an API key to continue."
))],
);
open_secure_prompt(state, handle, SecureInputOrigin::SetKey { provider });
}
map_agent_event(handle, event, state);
}
SessionEvent::CompactionStart { .. } => {
handle.set_reasoning_stage(Some("Compacting\u{2026}".to_string()));
}
SessionEvent::CompactionEnd { error_message, .. } => {
handle.set_reasoning_stage(None);
if let Some(msg) = error_message {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("Compaction failed: {msg}"))],
);
}
}
SessionEvent::ThinkingLevelChanged { level } => {
state.thinking_level = format!("{level:?}").to_ascii_lowercase();
}
SessionEvent::QueueUpdate { .. } => {
let pending = state.transcript.len();
handle.set_input_status(
None,
Some(if pending == 0 {
"ready".to_string()
} else {
"queued".to_string()
}),
);
}
SessionEvent::Advisor { body, .. } => {
handle.append_line(InlineMessageKind::Info, vec![plain_segment(body.clone())]);
}
SessionEvent::SessionInfoChanged => {
}
SessionEvent::HandoffComplete { .. } => {
}
SessionEvent::HandoffFailed { error } => {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("Handoff failed: {}", error))],
);
}
}
}
fn is_missing_api_key_error(message: &str) -> bool {
let message = message.to_ascii_lowercase();
message.contains("missing api key") || message.contains("api key is required")
}
fn provider_from_model_id(model_id: &str) -> String {
model_id
.split_once('/')
.map(|(provider, _)| provider)
.filter(|provider| !provider.is_empty())
.unwrap_or("provider")
.to_string()
}
pub(crate) fn apply_model_to_chips(state: &mut RenderState, model_id: &str, ctx_window: usize) {
if model_id.is_empty() {
return;
}
state.header_context.provider = provider_from_model_id(model_id);
state.header_context.model = model_id.to_string();
state.header_context.editor_context = Some(model_id.to_string());
if ctx_window > 0 {
state.context_window = ctx_window;
}
}
pub(crate) fn sync_model_chips(
state: &mut RenderState,
session: &crate::app::agent_session::AgentSessionHandle,
) {
apply_model_to_chips(state, &session.model_id(), session.context_window());
}
fn render_streamed_message(state: &mut RenderState) -> Vec<Vec<InlineSegment>> {
let mut lines = Vec::new();
if !state.thinking_buffer.is_empty() {
let styles = active_styles();
let mut style = InlineTextStyle::default();
style.color = styles.reasoning.get_fg_color();
style.effects |= anstyle::Effects::DIMMED | anstyle::Effects::ITALIC;
for chunk in state.thinking_buffer.split('\n') {
lines.push(vec![InlineSegment {
text: chunk.to_string(),
style: Arc::new(style.clone()),
}]);
}
if !state.message_buffer.is_empty() {
lines.push(vec![plain_segment("")]);
}
}
let body = revealed_stream_body(state).to_string();
if !body.is_empty() {
let (_, content_w) = super::frame_layout::scrollback_geometry(Rect {
x: 0,
y: 0,
width: state.viewport_width,
height: 24,
});
lines.extend(oxicode_vtui::tui::ui::markdown::render_markdown_cached(
&body,
content_w as usize,
&mut state.md_cache,
));
}
lines
}
fn revealed_stream_body(state: &RenderState) -> &str {
if state.stream_reveal == usize::MAX {
&state.message_buffer
} else {
let idx = floor_char_boundary(&state.message_buffer, state.stream_reveal);
&state.message_buffer[..idx]
}
}
fn floor_char_boundary(s: &str, mut i: usize) -> usize {
if i >= s.len() {
return s.len();
}
while !s.is_char_boundary(i) {
i -= 1;
}
i
}
fn advance_stream_reveal(state: &mut RenderState) -> bool {
if state.stream_anchor.is_none() || state.stream_reveal == usize::MAX {
return false;
}
let len = state.message_buffer.len();
if state.stream_reveal >= len {
state.stream_reveal = len;
return false;
}
let remaining = len - state.stream_reveal;
let step = (remaining / 6).max(8);
let target = (state.stream_reveal + step).min(len);
state.stream_reveal = floor_char_boundary(&state.message_buffer, target);
let lines = render_streamed_message(state);
let from = state.stream_anchor.unwrap_or(state.transcript.len());
state.transcript.truncate(from);
for line in lines {
state.append_line(InlineMessageKind::Agent, line);
}
state.stream_anchor = Some(from);
true
}
fn tool_args_preview(args: &serde_json::Value) -> String {
use serde_json::Value;
let raw = match args {
Value::Null => return String::new(),
Value::String(s) => s.clone(),
Value::Object(map) => {
if let Some(Value::String(cmd)) = map.get("command") {
cmd.clone()
} else {
map.iter()
.filter_map(|(k, v)| match v {
Value::String(s) => Some(format!("{k}={s}")),
_ => None,
})
.take(3)
.collect::<Vec<_>>()
.join(" ")
}
}
other => other.to_string(),
};
if raw.chars().count() > 72 {
let head: String = raw.chars().take(71).collect();
format!("{head}\u{2026}")
} else {
raw
}
}
fn tool_box_width(state: &RenderState) -> usize {
let area = Rect {
x: 0,
y: 0,
width: state.viewport_width,
height: 24,
};
let (_x, w) = super::frame_layout::scrollback_geometry(area);
w.max(24) as usize
}
fn border_segment(text: impl Into<String>, color: anstyle::Color) -> InlineSegment {
let mut style = InlineTextStyle::default();
style.color = Some(color);
InlineSegment {
text: text.into(),
style: Arc::new(style),
}
}
fn tool_box_top(w: usize, color: anstyle::Color) -> Vec<InlineSegment> {
vec![border_segment(
format!("\u{256D}{}\u{256E}", "\u{2500}".repeat(w.saturating_sub(2))),
color,
)]
}
fn tool_box_bottom(w: usize, color: anstyle::Color) -> Vec<InlineSegment> {
vec![border_segment(
format!("\u{2570}{}\u{256F}", "\u{2500}".repeat(w.saturating_sub(2))),
color,
)]
}
fn tool_box_divider(label: &str, w: usize, color: anstyle::Color) -> Vec<InlineSegment> {
let text = format!(" {label} ");
let dashes = w
.saturating_sub(2)
.saturating_sub(text.chars().count())
.saturating_sub(2);
vec![border_segment(
format!(
"\u{251C}\u{2500}{}{}\u{2500}\u{2524}",
text,
"\u{2500}".repeat(dashes)
),
color,
)]
}
fn tool_box_rows(
text: &str,
w: usize,
style: InlineTextStyle,
color: anstyle::Color,
) -> Vec<Vec<InlineSegment>> {
let inner = w.saturating_sub(4).max(1);
text.split('\n')
.map(|line| expand_tabs(line, TAB_WIDTH))
.flat_map(|line| wrap_by_display_width(&line, inner))
.map(|chunk| {
let pad = inner.saturating_sub(chunk.width());
vec![
border_segment("\u{2502} ", color),
InlineSegment {
text: chunk,
style: Arc::new(style.clone()),
},
border_segment(format!("{} \u{2502}", " ".repeat(pad)), color),
]
})
.collect()
}
fn wrap_by_display_width(line: &str, inner: usize) -> Vec<String> {
use unicode_width::UnicodeWidthChar as _;
if line.width() <= inner {
return vec![line.to_string()];
}
let mut out: Vec<String> = Vec::new();
let mut cur = String::new();
let mut cur_w = 0usize;
for ch in line.chars() {
let ch_w = ch.width().unwrap_or(0);
if cur_w + ch_w > inner && !cur.is_empty() {
out.push(std::mem::take(&mut cur));
cur_w = 0;
}
cur.push(ch);
cur_w += ch_w;
}
if !cur.is_empty() {
out.push(cur);
}
out
}
const TAB_WIDTH: usize = 4;
fn expand_tabs(line: &str, tab_width: usize) -> String {
use unicode_width::UnicodeWidthChar as _;
if !line.contains('\t') {
return line.to_string();
}
let mut out = String::with_capacity(line.len() + tab_width);
let mut col = 0usize;
for ch in line.chars() {
if ch == '\t' {
let spaces = tab_width - (col % tab_width);
for _ in 0..spaces {
out.push(' ');
col += 1;
}
} else {
out.push(ch);
col += ch.width().unwrap_or(1).max(1);
}
}
out
}
fn diff_rows(content: &str) -> Option<Vec<(String, InlineTextStyle)>> {
let lines: Vec<&str> = content.lines().collect();
if !lines.iter().any(|l| l.starts_with("@@")) {
return None;
}
let additions = lines
.iter()
.filter(|l| l.starts_with('+') && !l.starts_with("+++"))
.count();
let deletions = lines
.iter()
.filter(|l| l.starts_with('-') && !l.starts_with("---"))
.count();
if additions + deletions < 2 {
return None;
}
let styles = active_styles();
let green = styles.secondary.get_fg_color();
let red = styles.error.get_fg_color();
const MAX_DIFF_LINES: usize = 30;
let mut rows: Vec<(String, InlineTextStyle)> = Vec::new();
let mut hdr = InlineTextStyle::default();
hdr.effects |= anstyle::Effects::DIMMED;
rows.push((format!("diff +{additions} -{deletions}"), hdr));
for line in lines.iter().take(MAX_DIFF_LINES) {
let mut style = InlineTextStyle::default();
if line.starts_with('+') && !line.starts_with("+++") {
style.color = green;
} else if line.starts_with('-') && !line.starts_with("---") {
style.color = red;
} else {
style.effects |= anstyle::Effects::DIMMED;
}
rows.push(((*line).to_string(), style));
}
if lines.len() > MAX_DIFF_LINES {
let mut more = InlineTextStyle::default();
more.effects |= anstyle::Effects::DIMMED;
rows.push((
format!("\u{2026} +{} lines", lines.len() - MAX_DIFF_LINES),
more,
));
}
Some(rows)
}
fn map_agent_event(handle: &InlineHandle, event: AgentEvent, state: &mut RenderState) {
match event {
AgentEvent::TextChunk { text } => {
state.reasoning_stage = Some("generating response".to_string());
state.message_buffer.push_str(&text);
handle.inline(InlineMessageKind::Agent, plain_segment(text));
}
AgentEvent::AgentStart { .. } => {
state.active_run = Some(RunState::default());
}
AgentEvent::MessageStart { .. } => {
if let Some(run) = &mut state.active_run {
run.turn += 1;
}
state.reasoning_stage = Some("generating response".to_string());
state.message_buffer.clear();
state.thinking_buffer.clear();
state.stream_reveal = 0;
handle.begin_stream(InlineMessageKind::Agent);
}
AgentEvent::MessageUpdate { delta, .. } => match &delta {
oxicode_sdk::StreamDelta::Text(text) => {
state.reasoning_stage = Some("generating response".to_string());
state.message_buffer.push_str(text);
handle.replace_last(0, InlineMessageKind::Agent, render_streamed_message(state));
}
oxicode_sdk::StreamDelta::Thinking(text) => {
state.reasoning_stage = Some("thinking\u{2026}".to_string());
state.thinking_buffer.push_str(text);
handle.replace_last(0, InlineMessageKind::Agent, render_streamed_message(state));
}
oxicode_sdk::StreamDelta::Sync => {
if !state.message_buffer.is_empty() || !state.thinking_buffer.is_empty() {
handle.replace_last(
0,
InlineMessageKind::Agent,
render_streamed_message(state),
);
state.message_buffer.clear();
state.thinking_buffer.clear();
state.stream_reveal = 0;
}
}
},
AgentEvent::MessageEnd { message } => {
if state.active_run.is_none() {
state.reasoning_stage = None;
}
if let oxicode_ai::Message::Assistant(a) = &message {
state.thinking_buffer = a
.content
.iter()
.filter_map(|b| b.as_thinking().map(|t| t.thinking.clone()))
.collect();
state.message_buffer = a.text_content();
state.stream_reveal = usize::MAX;
}
if !state.message_buffer.is_empty() || !state.thinking_buffer.is_empty() {
handle.replace_last(0, InlineMessageKind::Agent, render_streamed_message(state));
state.message_buffer.clear();
state.thinking_buffer.clear();
}
handle.end_stream();
}
AgentEvent::ToolExecutionStart {
tool_name, args, ..
} => {
let styles = active_styles();
let border = styles
.tool
.get_fg_color()
.unwrap_or(anstyle::Color::Ansi(anstyle::AnsiColor::White));
let w = tool_box_width(state);
let header = match args.get("command").and_then(|v| v.as_str()) {
Some(cmd) => format!("$ {cmd}"),
None => {
let preview = tool_args_preview(&args);
if preview.is_empty() {
tool_name.clone()
} else {
format!("{tool_name} {preview}")
}
}
};
handle.append_line_block_start(InlineMessageKind::Tool, tool_box_top(w, border));
for row in tool_box_rows(&header, w, InlineTextStyle::default(), border) {
handle.append_line(InlineMessageKind::Tool, row);
}
let stage = format!("tool: {tool_name}");
if let Some(run) = &mut state.active_run {
run.tool_calls += 1;
}
state.reasoning_stage = Some(stage.clone());
handle.set_reasoning_stage(Some(stage));
}
AgentEvent::ToolExecutionEnd {
tool_name,
result,
is_error,
..
} => {
let styles = active_styles();
let (border, label) = if is_error {
(
styles
.error
.get_fg_color()
.unwrap_or(anstyle::Color::Ansi(anstyle::AnsiColor::White)),
"Error",
)
} else {
(
styles
.tool
.get_fg_color()
.unwrap_or(anstyle::Color::Ansi(anstyle::AnsiColor::White)),
"Output",
)
};
let w = tool_box_width(state);
handle.append_line(InlineMessageKind::Tool, tool_box_divider(label, w, border));
let embedded_png = if tool_name == "generate_image" && !is_error {
extract_generated_png(&result.content)
} else {
None
};
if let Some(png) = embedded_png {
let id = super::image_preview::content_hash_id(&png);
let label = format!("generate_image:{id:08x}");
let mut dim = InlineTextStyle::default();
dim.effects |= anstyle::Effects::DIMMED;
let fallback = super::image_preview::text_fallback(&label);
for row in tool_box_rows(&fallback, w, dim, border) {
handle.append_line(InlineMessageKind::Tool, row);
}
state
.image_previews
.enqueue(id, std::sync::Arc::new(png), label);
} else if let Some(rows) = diff_rows(&result.content) {
for (text, style) in rows {
for row in tool_box_rows(&text, w, style, border) {
handle.append_line(InlineMessageKind::Tool, row);
}
}
} else {
const MAX_BOX_LINES: usize = 12;
let preview = preview_tool_result(&result.content);
let lines: Vec<&str> = preview.split('\n').collect();
let mut dim = InlineTextStyle::default();
dim.effects |= anstyle::Effects::DIMMED;
for line in lines.iter().take(MAX_BOX_LINES) {
for row in tool_box_rows(line, w, dim.clone(), border) {
handle.append_line(InlineMessageKind::Tool, row);
}
}
if lines.len() > MAX_BOX_LINES {
let more = format!("\u{2026} +{} lines", lines.len() - MAX_BOX_LINES);
for row in tool_box_rows(&more, w, dim, border) {
handle.append_line(InlineMessageKind::Tool, row);
}
}
}
handle.append_line(InlineMessageKind::Tool, tool_box_bottom(w, border));
state.reasoning_stage = Some("generating response".to_string());
handle.set_reasoning_stage(Some("generating response".to_string()));
handle.set_input_enabled(true);
}
AgentEvent::Error { message, .. } => {
handle.append_line(InlineMessageKind::Error, vec![plain_segment(message)]);
state.active_run = None;
state.reasoning_stage = None;
handle.set_input_enabled(true);
handle.set_input_status(None, None);
}
AgentEvent::Compaction { .. } => {
}
AgentEvent::Cancelled => {
state.active_run = None;
state.reasoning_stage = None;
handle.set_input_enabled(true);
handle.set_input_status(None, Some("cancelled".to_string()));
}
AgentEvent::AutoRetryStart {
attempt,
max_attempts,
..
} => {
state.reasoning_stage = Some(format!("retrying {attempt} of {max_attempts}"));
handle.set_input_status(None, Some(format!("retry {attempt}/{max_attempts}")));
}
AgentEvent::TurnEnd { .. } => {
crate::tui_vt::notifications::emit_notification("oxicode", "Response complete");
state.drain_queue_head();
if state.active_run.is_none() {
handle.set_reasoning_stage(None);
}
}
AgentEvent::Usage { input_tokens, .. } => {
state.context_tokens = Some(input_tokens);
}
AgentEvent::AgentEnd { .. } => {
state.active_run = None;
state.reasoning_stage = None;
handle.set_reasoning_stage(None);
}
AgentEvent::TodoReminder { open, attempt, max } => {
let header = format!(
"⚠ {} incomplete todo{} — reminder {attempt}/{max}",
open.len(),
if open.len() == 1 { "" } else { "s" }
);
handle.append_line(InlineMessageKind::Warning, vec![plain_segment(header)]);
for t in &open {
handle.append_line(
InlineMessageKind::Warning,
vec![plain_segment(format!(" ☐ {}", t.content))],
);
}
}
_ => {
tracing::debug!(?event, "ignored AgentEvent variant");
}
}
}
pub(crate) fn next_provider_actions(has_key: bool, oauth_capable: bool) -> Vec<AuthAction> {
match (has_key, oauth_capable) {
(true, true) => vec![
AuthAction::SetApiKey,
AuthAction::StartOAuth,
AuthAction::RemoveKey,
],
(true, false) => vec![AuthAction::SetApiKey, AuthAction::RemoveKey],
(false, true) => vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
(false, false) => vec![AuthAction::SetApiKey],
}
}
pub(crate) fn open_secure_prompt(
state: &mut RenderState,
handle: &InlineHandle,
origin: SecureInputOrigin,
) {
let provider = match &origin {
SecureInputOrigin::SetKey { provider } | SecureInputOrigin::NewlyAdded { provider } => {
provider.clone()
}
SecureInputOrigin::ModelRoleKey | SecureInputOrigin::ModelRoleValue { .. } => return,
SecureInputOrigin::TextEdit(_) => return,
};
state.secure_input_origin = Some(origin);
handle.show_modal(
format!("Set API key for {provider}"),
vec![
"Paste the API key. Press Enter to save, Esc to cancel.".into(),
"The key is masked on screen; nothing is logged.".into(),
],
Some(SecurePromptConfig {
label: format!("{provider} key"),
placeholder: Some("sk-...".into()),
mask_input: true,
}),
);
}
pub(crate) fn handle_auth_action(
provider: &str,
action: &AuthAction,
auth: &Arc<crate::store::auth_storage::AuthStorage>,
handle: &InlineHandle,
state: &mut RenderState,
) -> bool {
match action {
AuthAction::SetApiKey => {
open_secure_prompt(
state,
handle,
SecureInputOrigin::SetKey {
provider: provider.to_string(),
},
);
true
}
AuthAction::StartOAuth => {
let spec = match crate::provider_oauth::spec_for(provider) {
Some(s) => s,
None => {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"OAuth: no OAuth config for '{provider}'."
))],
);
return false;
}
};
let provider_owned = provider.to_string();
let tx = handle.clone();
let auth_clone = Arc::clone(auth);
tokio::spawn(async move {
run_oauth_flow(provider_owned, spec, tx, auth_clone).await;
});
false
}
AuthAction::RemoveKey => {
state.confirmation = Some(ModalConfirmation {
title: format!("Remove key for {provider}?"),
message: " y \u{2014} remove key n / x \u{2014} cancel".into(),
action: ConfirmationAction::RemoveProviderKey(provider.to_string()),
});
false
}
}
}
pub(crate) async fn run_oauth_flow(
provider: String,
spec: crate::provider_oauth::ProviderOAuthSpec,
handle: InlineHandle,
auth: Arc<crate::store::auth_storage::AuthStorage>,
) {
use std::time::Duration;
let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await {
Ok(l) => l,
Err(e) => {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"OAuth: could not bind loopback listener for '{provider}': {e}"
))],
);
return;
}
};
let port = match listener.local_addr() {
Ok(addr) => addr.port(),
Err(e) => {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"OAuth: could not read loopback port for '{provider}': {e}"
))],
);
return;
}
};
let (verifier, challenge) = crate::provider_oauth::pkce_pair();
let state_token = crate::provider_oauth::pkce_pair().0;
let auth_url = crate::provider_oauth::build_auth_url(&spec, port, &state_token, &challenge);
handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!(
"OAuth: opening browser for '{provider}' on http://127.0.0.1:{port}{}",
spec.redirect_path
))],
);
let callback_timeout = match crate::provider_oauth::open_browser(&auth_url) {
Ok(()) => Duration::from_secs(120),
Err(e) => {
handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!(
"OAuth: could not open a browser ({e}).\nOpen this URL manually within 5 minutes:\n {auth_url}"
))],
);
Duration::from_secs(300)
}
};
let callback = match crate::oauth_listener::await_callback(
listener,
state_token.clone(),
spec.redirect_path.clone(),
callback_timeout,
)
.await
{
Ok(c) => c,
Err(crate::oauth_listener::CallbackError::Timeout) => {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"OAuth: timed out waiting for '{provider}' callback (after {}s)",
callback_timeout.as_secs()
))],
);
return;
}
Err(e) => {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"OAuth: callback failed for '{provider}': {e}"
))],
);
return;
}
};
let tokens =
match crate::provider_oauth::exchange_code(&spec, port, &callback.code, &verifier).await {
Ok(t) => t,
Err(e) => {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"OAuth: token exchange failed for '{provider}': {e}"
))],
);
return;
}
};
let new_expires_at: u64 = tokens.expires_at.max(0) as u64;
let access_token_len = tokens.access_token.chars().count();
auth.set_oauth_full(
&provider,
tokens.access_token,
tokens.refresh_token,
new_expires_at,
if tokens.scopes.is_empty() {
None
} else {
Some(tokens.scopes.join(" "))
},
None,
);
handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!(
"OAuth: '{provider}' logged in. Token stored ({} chars).",
access_token_len
))],
);
}
fn handle_inline_event(
state: &mut RenderState,
handle: &InlineHandle,
session: &crate::app::agent_session::AgentSessionHandle,
prompt_queue: &Arc<PromptQueue>,
evt: InlineEvent,
) -> LoopOutcome {
match evt {
InlineEvent::Submit(text) => {
if let Some(path) = state.pending_resume.take() {
let swapper = state.swapper();
let agent_arc = Arc::clone(&session.agent_arc());
let settings = session.settings_clone();
let session_state = state
.session_state
.clone()
.expect("RenderState::session_state must be initialized at TUI startup");
let path_for_log = path.clone();
let handle = handle.clone();
let swapper_for_swap = swapper.clone();
tokio::spawn(async move {
match crate::app::agent_session::resume_from_file(
agent_arc,
settings,
session_state,
&path,
None,
)
.await
{
Ok(new_session) => {
swapper_for_swap.swap(new_session.clone_handle());
let n = new_session.messages().len();
let id = new_session.session_id();
handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!(
"Resumed session {id} ({n} messages)"
))],
);
}
Err(crate::app::agent_session::ResumeError::FileNotFound(p)) => {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("No session file: {}", p.display()))],
);
}
Err(crate::app::agent_session::ResumeError::CwdInvalid(cwd)) => {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"Cannot resume {}: the session was recorded in `{cwd}`, which no longer exists. \
Use /export to save its content, then /clear.",
path_for_log.display()
))],
);
}
}
});
return LoopOutcome::Continue;
}
let prompt = text.to_string();
state.composer.set_text("");
if prompt.is_empty() {
return LoopOutcome::Continue;
}
state.pending_quit = false;
if prompt.trim_start().starts_with('/') {
state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
let mut ctx = SlashCtx {
session,
handle,
state,
};
return match SlashRegistry::builtins().dispatch(&prompt, &mut ctx) {
SlashOutcome::Quit => LoopOutcome::Exit,
SlashOutcome::Handled => LoopOutcome::Continue,
SlashOutcome::NotHandled => {
if let Some(expanded) = crate::tui_vt::slash::file_commands::try_expand(
&ctx.state.file_commands,
&prompt,
) {
prompt_queue.enqueue(expanded);
LoopOutcome::Continue
} else {
ctx.reply(
InlineMessageKind::Error,
format!("Unknown command: {}", prompt.trim()),
);
LoopOutcome::Continue
}
}
};
}
state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
if session.is_streaming() {
state.queued_inputs.push(prompt.clone());
state.show_tip(
"send_now",
"Ctrl+Enter sends now | Ctrl+; manages queue",
240,
true,
);
}
prompt_queue.enqueue(prompt);
}
InlineEvent::Cancel => {
return match route_cancel(session.is_streaming()) {
CancelRoute::Interrupt => handle_interrupt(state, session, handle),
CancelRoute::Exit => LoopOutcome::Exit,
};
}
InlineEvent::Exit => {
return LoopOutcome::Exit;
}
InlineEvent::Interrupt => {
return handle_interrupt(state, session, handle);
}
InlineEvent::ScrollLineUp => {
state.scroll_offset = state.scroll_offset.saturating_add(1);
}
InlineEvent::ScrollLineDown => {
state.scroll_offset = state.scroll_offset.saturating_sub(1);
}
InlineEvent::ScrollPageUp => {
state.scroll_offset = state.scroll_offset.saturating_add(10);
}
InlineEvent::ScrollPageDown => {
state.scroll_offset = state.scroll_offset.saturating_sub(10);
}
InlineEvent::CyclePrimaryAgent => {
let _ = session.cycle_model();
}
InlineEvent::CyclePrimaryAgentPrevious => {
let _ = session.cycle_model();
}
InlineEvent::Overlay(overlay_evt) => {
use oxicode_vtui::tui::core::OverlayEvent;
match overlay_evt {
OverlayEvent::Submitted(sub) => {
let mut opened_new_overlay = false;
if let OverlaySubmission::Selection(InlineListSelection::Model(idx)) = &sub
&& idx < &state.overlay_model_ids.len()
{
let model_id = state.overlay_model_ids[*idx].clone();
match session.set_model(&model_id) {
Ok(()) => {
sync_model_chips(state, session);
handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!("Switched to {model_id}"))],
);
}
Err(e) => handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("Failed to set model: {e}"))],
),
}
}
if let OverlaySubmission::Selection(InlineListSelection::Theme(theme_id)) = &sub
{
match oxicode_vtui::theme::set_active_theme(theme_id) {
Ok(()) => {
let label = oxicode_vtui::theme::theme_label(theme_id)
.unwrap_or(theme_id.as_ref())
.to_string();
handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!("Theme: {label}"))],
);
}
Err(e) => handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("Unknown theme: {e}"))],
),
}
}
if let OverlaySubmission::Selection(InlineListSelection::SlashCommand(name)) =
&sub
{
state.composer.set_text(&format!("/{name} "));
}
let synthetic_dispatched = if let OverlaySubmission::Selection(
InlineListSelection::ConfigAction(payload),
) = &sub
{
if let Some(rest) = payload.strip_prefix("SubmenuCommit:") {
if let Some((key_str, value)) = rest.split_once(':') {
if let Some(key) = parse_setting_key(key_str) {
match commit_submenu_choice(state, key, value.to_string()) {
Ok(status) => {
handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(status.clone())],
);
opened_new_overlay = state.overlay.is_some();
}
Err(e) => handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"Failed to save setting: {e}"
))],
),
}
} else {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"Unknown setting key in submenu commit: {key_str}"
))],
);
}
} else {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"Malformed submenu commit payload: {payload}"
))],
);
}
true
} else if let Some(tool) = payload.strip_prefix("DisabledToolToggle:") {
let essential = session
.agent_ref()
.tools()
.get_tools()
.into_iter()
.find(|t| t.name() == tool)
.is_some_and(|t| t.essential());
let currently_disabled = crate::store::settings::Settings::load()
.map(|s| s.disabled_tools.iter().any(|t| t == tool))
.unwrap_or(false);
commit_disabled_tool_toggle(
state,
handle,
session,
tool.to_string(),
essential,
currently_disabled,
);
opened_new_overlay = state.overlay.is_some();
true
} else {
false
}
} else {
false
};
if !synthetic_dispatched
&& let OverlaySubmission::Selection(InlineListSelection::ConfigAction(key)) =
&sub
{
let def = SETTING_DEFS.iter().find(|d| format!("{:?}", d.key) == *key);
match def {
Some(def) => {
let mut settings =
crate::store::settings::Settings::load().unwrap_or_default();
let next_value = match def.widget {
SettingWidget::Toggle => Some(
(get_display_value(def.key, &settings) != "true")
.to_string(),
),
SettingWidget::Cycle => next_cycle_value(def.key, &settings),
_ => None,
};
if let Some(next) = next_value {
match crate::tui_vt::settings_defs::apply_change(
def.key,
&mut settings,
next,
) {
Ok(()) => match settings.save() {
Ok(()) => {
if let Err(e) = sync_settings_live(
state, session, def.key, &settings,
) {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"{}: {e}",
def.label
))],
);
} else {
handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!(
"{}: {}",
def.label,
get_display_value(def.key, &settings)
))],
);
}
}
Err(e) => handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"Failed to save {}: {e}",
def.label
))],
),
},
Err(e) => handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"Failed to apply {}: {e}",
def.label
))],
),
}
}
}
None => handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("Unknown setting: {key}"))],
),
}
}
if let OverlaySubmission::Selection(InlineListSelection::SettingsTab(idx)) =
&sub
{
switch_settings_tab(state, *idx);
opened_new_overlay = state.overlay.is_some();
}
if let OverlaySubmission::Selection(InlineListSelection::SettingsSection(idx)) =
&sub
{
jump_settings_section(state, *idx);
opened_new_overlay = state.overlay.is_some();
}
if let OverlaySubmission::Selection(InlineListSelection::SettingKeyCapture(
name,
)) = &sub
{
if GlobalAction::from_name(name).is_some() {
state.overlay = Some(build_key_capture_overlay(name));
state.settings_map_rows.clear();
opened_new_overlay = true;
} else {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("Unknown keybinding action: {name}"))],
);
}
}
if let OverlaySubmission::Selection(InlineListSelection::SettingTextEdit(
key_name,
)) = &sub
{
if let Some(key) = parse_setting_key(key_name) {
open_text_edit_prompt(state, key);
opened_new_overlay = true;
} else {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("Unknown setting key: {key_name}"))],
);
}
}
if let OverlaySubmission::Selection(InlineListSelection::SettingSubmenuOpen(
key_name,
)) = &sub
{
if let Some(key) = parse_setting_key(key_name) {
open_submenu_select_prompt(state, key);
opened_new_overlay = true;
} else {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("Unknown setting key: {key_name}"))],
);
}
}
if let OverlaySubmission::Selection(InlineListSelection::SettingMultiselect(
key_name,
)) = &sub
{
if let Some(parsed) = parse_setting_key(key_name) {
if parsed == SettingKey::DisabledTools {
open_disabled_tools_multiselect(state, session);
opened_new_overlay = true;
} else {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"'{parsed:?}' has no multiselect editor"
))],
);
}
} else {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("Unknown setting key: {key_name}"))],
);
}
}
if let OverlaySubmission::Selection(InlineListSelection::Session(id)) = &sub {
if session.is_streaming() {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(
"Cannot resume while agent is running. Use /cancel first.",
)],
);
} else {
let path = crate::tui_vt::slash::registry::sessions_dir()
.join(format!("{id}.jsonl"));
if !path.is_file() {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"No session file: {}",
path.display()
))],
);
} else {
state.pending_resume = Some(path);
}
}
}
if let OverlaySubmission::Selection(InlineListSelection::CatalogModel(idx)) =
&sub
&& idx < &state.overlay_catalog_models.len()
{
let (provider, model_id) = &state.overlay_catalog_models[*idx];
let full = format!("{provider}/{model_id}");
match session.set_model(&full) {
Ok(()) => {
sync_model_chips(state, session);
handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!("Switched to {full}"))],
);
}
Err(e) => handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("Failed to set model: {e}"))],
),
}
}
if let OverlaySubmission::Selection(InlineListSelection::ProviderRow(idx)) =
&sub
&& idx < &state.overlay_providers.len()
{
let name = state.overlay_providers[*idx].clone();
let auth = crate::store::auth_storage::shared_auth_storage();
let has_key = auth.has(&name);
let oauth_capable = crate::provider_oauth::spec_for(&name).is_some();
let actions = next_provider_actions(has_key, oauth_capable);
if actions.len() == 1 {
opened_new_overlay |=
handle_auth_action(&name, &actions[0], &auth, handle, state);
} else {
let items: Vec<InlineListItem> = actions
.iter()
.map(|a| InlineListItem {
title: match a {
AuthAction::SetApiKey => "Set API key".into(),
AuthAction::StartOAuth => "Login with OAuth".into(),
AuthAction::RemoveKey => "Remove key".into(),
},
subtitle: None,
badge: None,
indent: 0,
selection: Some(InlineListSelection::ProviderAction {
provider: name.clone(),
action: a.clone(),
}),
search_value: None,
})
.collect();
handle.show_list_modal(
name.clone(),
vec!["Pick an action".into()],
items,
None,
None,
);
opened_new_overlay = true;
}
}
if let OverlaySubmission::Selection(InlineListSelection::ProviderAction {
provider,
action,
}) = &sub
{
let auth = crate::store::auth_storage::shared_auth_storage();
opened_new_overlay |=
handle_auth_action(provider, action, &auth, handle, state);
}
if let OverlaySubmission::SecureInput(text) = &sub
&& let Some(origin) = state.secure_input_origin.take()
{
match origin {
SecureInputOrigin::ModelRoleKey => {
let role = text.trim().to_string();
if role.is_empty() {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(
"Model role name can't be empty".to_string(),
)],
);
} else {
open_model_role_value_prompt(state, &role);
opened_new_overlay = true;
}
}
SecureInputOrigin::TextEdit(key) => {
let (_outcome, _msg) = commit_text_edit(
state,
handle,
Some(session),
key,
text.clone(),
);
opened_new_overlay = state.overlay.is_some();
}
SecureInputOrigin::ModelRoleValue { role } => {
let model = text.trim().to_string();
let outcome = if model.is_empty() {
Err("model pattern can't be empty".to_string())
} else {
crate::store::settings::Settings::load()
.map_err(|e| e.to_string())
.and_then(|mut settings| {
crate::tui_vt::settings_defs::set_model_role(
&mut settings,
&role,
model.clone(),
);
settings.save().map_err(|e| e.to_string())
})
};
match outcome {
Ok(()) => {
handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!(
"Model role '{role}' \u{2192} {model}"
))],
);
reopen_settings_panel(
state,
SettingsTab::Model,
Some(format!("Saved '{role}' \u{2192} {model}")),
);
opened_new_overlay = true;
}
Err(e) => handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!(
"Failed to save model role '{role}': {e}"
))],
),
}
}
origin @ (SecureInputOrigin::SetKey { .. }
| SecureInputOrigin::NewlyAdded { .. }) => {
let provider = match &origin {
SecureInputOrigin::SetKey { provider }
| SecureInputOrigin::NewlyAdded { provider } => {
provider.clone()
}
_ => unreachable!("auth arm only matches auth origins"),
};
let auth = crate::store::auth_storage::shared_auth_storage();
auth.set_api_key(&provider, text.clone());
let refreshed = session.refresh_api_key();
let msg = match origin {
SecureInputOrigin::SetKey { .. } => format!(
"Saved API key for '{provider}'. {}",
match refreshed {
Ok(()) => "Ready to retry your message.",
Err(_) => "Restart this session before retrying.",
}
),
SecureInputOrigin::NewlyAdded { .. } => format!(
"Added and configured '{provider}'. {}",
match refreshed {
Ok(()) =>
"Use /models to choose a model, or send a message.",
Err(_) => "Restart this session before using it.",
}
),
SecureInputOrigin::ModelRoleKey
| SecureInputOrigin::ModelRoleValue { .. } => {
unreachable!("auth branch reached with a model-role origin")
}
SecureInputOrigin::TextEdit(_) => {
unreachable!("auth branch reached with a text-edit origin")
}
};
handle
.append_line(InlineMessageKind::Info, vec![plain_segment(msg)]);
}
}
}
state.overlay_catalog_models.clear();
state.overlay_providers.clear();
state.overlay_model_ids.clear();
if !opened_new_overlay {
handle.close_overlay();
}
}
OverlayEvent::Cancelled => {
handle.close_overlay();
}
OverlayEvent::SelectionChanged(_) => {}
}
}
_ => {
}
}
LoopOutcome::Continue
}
struct StreamingGuard<'a>(&'a std::sync::atomic::AtomicBool);
impl Drop for StreamingGuard<'_> {
fn drop(&mut self) {
use std::sync::atomic::Ordering;
self.0.store(false, Ordering::SeqCst);
}
}
fn handle_interrupt(
state: &mut RenderState,
session: &crate::app::agent_session::AgentSessionHandle,
_handle: &InlineHandle,
) -> LoopOutcome {
if state.confirmation.is_some() {
return LoopOutcome::Exit;
}
if state.pending_quit {
state.confirmation = Some(quit_confirmation());
state.pending_quit = false;
return LoopOutcome::Continue;
}
if session.is_streaming() {
let s = session.clone();
tokio::spawn(async move {
s.abort().await;
});
state.pending_quit = true;
} else {
state.confirmation = Some(quit_confirmation());
}
LoopOutcome::Continue
}
fn quit_confirmation() -> ModalConfirmation {
ModalConfirmation {
title: "Quit oxicode?".into(),
message: " y \u{2014} quit now n / x \u{2014} stay".into(),
action: ConfirmationAction::Quit,
}
}
pub(super) fn clear_confirmation() -> ModalConfirmation {
ModalConfirmation {
title: "Clear conversation?".into(),
message: " y \u{2014} clear all n / x \u{2014} cancel".into(),
action: ConfirmationAction::ClearConversation,
}
}
fn apply_global_action(
action: GlobalAction,
state: &Arc<parking_lot::Mutex<RenderState>>,
evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
) {
match action {
GlobalAction::Interrupt => {
let _ = evt_tx.send(InlineEvent::Interrupt);
}
GlobalAction::ToggleMultiline => {
let mut s = state.lock();
s.multiline_mode = !s.multiline_mode;
}
GlobalAction::OpenCommandPalette => {
let mut s = state.lock();
s.overlay = Some(build_command_palette());
}
GlobalAction::ToggleQueuePanel => {
let mut s = state.lock();
s.queue_panel_open = !s.queue_panel_open;
if s.queue_panel_open {
s.queue_selected = 0;
}
}
GlobalAction::FoldAll => {
let mut s = state.lock();
s.fold_all();
}
GlobalAction::SendNow => {
let submitted = harvest_and_clear_input(state);
if !submitted.is_empty() {
let _ = evt_tx.send(InlineEvent::Interrupt);
let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
}
}
GlobalAction::Submit => {
let submitted = harvest_and_clear_input(state);
let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
}
GlobalAction::ScrollUp => {
let _ = evt_tx.send(InlineEvent::ScrollPageUp);
}
GlobalAction::ScrollDown => {
let _ = evt_tx.send(InlineEvent::ScrollPageDown);
}
GlobalAction::Clear => {
let mut s = state.lock();
s.fold_all();
}
GlobalAction::Help => {
let mut s = state.lock();
s.overlay = Some(cheatsheet_overlay());
}
GlobalAction::ModelPicker => {
let mut s = state.lock();
s.overlay = Some(build_command_palette());
}
GlobalAction::ToggleThinking => {
let mut s = state.lock();
s.multiline_mode = !s.multiline_mode;
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum KeymapDispatch {
Submit,
ScrollPageUp,
ScrollPageDown,
Help,
None,
}
pub(crate) fn keymap_pre_match(
keymap: &Keymap,
key: &crossterm::event::KeyEvent,
multiline: bool,
) -> KeymapDispatch {
let plain_enter_multiline =
key.code == KeyCode::Enter && multiline && !key.modifiers.contains(KeyModifiers::SHIFT);
if keymap.matches(GlobalAction::Submit, key) && !plain_enter_multiline {
return KeymapDispatch::Submit;
}
if keymap.matches(GlobalAction::ScrollUp, key) {
return KeymapDispatch::ScrollPageUp;
}
if keymap.matches(GlobalAction::ScrollDown, key) {
return KeymapDispatch::ScrollPageDown;
}
if keymap.matches(GlobalAction::Help, key) && !matches!(key.code, KeyCode::Char(_)) {
return KeymapDispatch::Help;
}
KeymapDispatch::None
}
fn harvest_and_clear_input(state: &Arc<parking_lot::Mutex<RenderState>>) -> String {
let mut s = state.lock();
let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
format!("/{}", s.slash_popup.items[s.slash_popup.selected].name)
} else {
let buf = s.composer.text().to_string();
s.composer.set_text("");
buf
};
s.slash_popup = SlashPopup::default();
s.history_pos = None;
if !buf.is_empty() && !buf.starts_with('/') {
s.prompt_history.insert(0, buf.clone());
s.prompt_history.truncate(100);
}
buf
}
fn cheatsheet_overlay() -> OverlayState {
OverlayState {
title: "Keyboard Shortcuts".into(),
lines: cheatsheet_lines(),
items: vec![],
selected: 0,
search: None,
secure_input: None,
..Default::default()
}
}
fn spawn_input_thread(
state: Arc<parking_lot::Mutex<RenderState>>,
evt_tx: tokio::sync::mpsc::UnboundedSender<InlineEvent>,
mode_handle: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
prompt_queue: Arc<PromptQueue>,
issue_action_tx: tokio::sync::mpsc::UnboundedSender<
crate::tui_vt::issues_panel::IssueActionRequest,
>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
loop {
match event::poll(std::time::Duration::from_millis(50)) {
Ok(true) => {}
Ok(false) => continue,
Err(_) => break,
}
let event = match event::read() {
Ok(ev) => ev,
Err(_) => continue,
};
let mut pasted = String::new();
let mut key_event = None;
match event {
Event::Key(k) if k.kind == KeyEventKind::Press => key_event = Some(k),
Event::Paste(p) => pasted = p,
_ => {}
}
if !pasted.is_empty() && state.lock().issues_panel.is_some() {
continue;
}
if !pasted.is_empty() {
let routed_to_secure = {
let mut s = state.lock();
if let Some(overlay) = s.overlay.as_mut() {
if let Some(secure) = overlay.secure_input.as_mut() {
let trimmed = pasted.trim_end_matches('\n');
for ch in trimmed.chars() {
if ch.is_ascii_graphic() || ch == ' ' {
let _ = secure
.editor
.apply(oxicode_textarea::EditCommand::Insert(ch));
}
}
true
} else {
false
}
} else {
false
}
};
if routed_to_secure {
continue;
}
let mut s = state.lock();
s.composer.insert_str(&pasted);
refresh_input_popups(&mut s);
continue;
}
let Some(key) = key_event else { continue };
let keymap = state.lock().keymap.read().clone();
{
let capturing = {
let s = state.lock();
s.overlay.as_ref().is_some_and(|o| o.key_capture.is_some())
};
if capturing {
let mut s = state.lock();
handle_key_capture(&mut s, key);
continue;
}
}
{
let s = state.lock();
if s.confirmation.is_some() {
drop(s);
let interrupt = {
let s = state.lock();
matches!(s.keymap.read().resolve(key), Some(GlobalAction::Interrupt))
};
if interrupt {
let _ = evt_tx.send(InlineEvent::Interrupt);
continue;
}
handle_confirmation_key(&state, &evt_tx, &issue_action_tx, key.code);
continue;
}
}
{
let s = state.lock();
if s.issues_panel.is_some() {
drop(s);
if crate::tui_vt::issues_panel::handle_issues_panel_key(
&state,
&issue_action_tx,
key,
) {
continue;
}
}
}
{
let action = {
let s = state.lock();
s.keymap.read().resolve(key)
};
if let Some(action) = action {
apply_global_action(action, &state, &evt_tx);
continue;
}
}
{
let s = state.lock();
if s.overlay.is_some() {
drop(s);
if handle_overlay_key(&state, &evt_tx, key.code) {
continue;
}
}
}
{
let s = state.lock();
if s.file_search.is_some() {
drop(s);
if handle_file_search_key(&state, &evt_tx, key.code) {
continue;
}
}
}
if state.lock().git_tui.is_some() && handle_git_tui_key(&state, key.code, key.modifiers)
{
continue;
}
let multiline_mode = state.lock().multiline_mode;
match keymap_pre_match(&keymap, &key, multiline_mode) {
KeymapDispatch::Submit => {
if state.lock().shell_mode {
let submitted = {
let mut s = state.lock();
let buf = s.composer.text().to_string();
s.composer.set_text("");
s.shell_mode = false;
s.history_pos = None;
if !buf.is_empty() {
s.prompt_history.insert(0, buf.clone());
s.prompt_history.truncate(100);
}
buf
};
if !submitted.is_empty() {
let prompt = format!("Run this shell command: `{submitted}`");
let _ = evt_tx.send(InlineEvent::Submit(prompt.into()));
}
continue;
}
let submitted = harvest_and_clear_input(&state);
let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
continue;
}
KeymapDispatch::ScrollPageUp => {
let _ = evt_tx.send(InlineEvent::ScrollPageUp);
continue;
}
KeymapDispatch::ScrollPageDown => {
let _ = evt_tx.send(InlineEvent::ScrollPageDown);
continue;
}
KeymapDispatch::Help => {
let mut s = state.lock();
s.overlay = Some(cheatsheet_overlay());
continue;
}
KeymapDispatch::None => {}
}
match key.code {
KeyCode::BackTab => {
if let Some(h) = &mode_handle {
let new_mode = Mode::load(h).toggle();
h.store(new_mode.as_u8(), std::sync::atomic::Ordering::SeqCst);
let label = new_mode.label();
let detail = if new_mode.is_auto() {
"autonomous — no questions, runs to completion"
} else {
"interactive — may ask questions"
};
let mut s = state.lock();
s.autonomy_mode = new_mode;
s.tip = Some(EphemeralTip {
text: format!("Mode: {label} — {detail}"),
born_tick: 0,
ttl_ticks: 240,
key: "mode_toggle",
ambient: false,
});
}
continue;
}
KeyCode::Enter => {
let multiline = state.lock().multiline_mode;
let shift = key
.modifiers
.contains(crossterm::event::KeyModifiers::SHIFT);
if multiline && !shift {
let mut s = state.lock();
s.composer.insert_str("\n");
}
}
KeyCode::Esc => {
let mut s = state.lock();
if s.shell_mode {
s.shell_mode = false;
s.composer.set_text("");
} else if s.slash_popup.open {
s.slash_popup = SlashPopup::default();
} else if !s.composer.is_empty() {
let now = std::time::Instant::now();
let is_double = s
.last_esc_at
.map(|t| now.duration_since(t).as_millis() < 800)
.unwrap_or(false);
if is_double {
s.composer.set_text("");
s.last_esc_at = None;
} else {
s.last_esc_at = Some(now);
s.tip = Some(EphemeralTip {
text: "Press Esc again to clear input".to_string(),
born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
ttl_ticks: 120,
key: "esc_clear",
ambient: false,
});
}
} else {
let now = std::time::Instant::now();
let in_grace = s.cancel_grace_until.map(|t| t > now).unwrap_or(false);
if in_grace {
} else {
s.cancel_grace_until = Some(now + std::time::Duration::from_secs(1));
s.last_esc_at = None;
drop(s);
let _ = evt_tx.send(InlineEvent::Cancel);
}
}
}
KeyCode::Tab => {
let mut s = state.lock();
if s.slash_popup.open && !s.slash_popup.items.is_empty() {
let name = s.slash_popup.items[s.slash_popup.selected].name.clone();
s.composer.set_text(&format!("/{} ", name));
refresh_input_popups(&mut s);
}
}
KeyCode::Backspace => {
let mut s = state.lock();
s.composer.input(crossterm::event::KeyEvent::new(
KeyCode::Backspace,
KeyModifiers::NONE,
));
refresh_input_popups(&mut s);
}
KeyCode::Delete => {
let mut s = state.lock();
s.composer.input(crossterm::event::KeyEvent::new(
KeyCode::Delete,
KeyModifiers::NONE,
));
refresh_input_popups(&mut s);
}
KeyCode::Left => {
let mut s = state.lock();
s.composer.input(crossterm::event::KeyEvent::new(
KeyCode::Left,
KeyModifiers::NONE,
));
}
KeyCode::Right => {
let mut s = state.lock();
s.composer.input(crossterm::event::KeyEvent::new(
KeyCode::Right,
KeyModifiers::NONE,
));
}
KeyCode::Up => {
let mut s = state.lock();
if s.slash_popup.open && !s.slash_popup.items.is_empty() {
let len = s.slash_popup.items.len();
s.slash_popup.selected = if s.slash_popup.selected == 0 {
len - 1
} else {
s.slash_popup.selected - 1
};
} else if s.queue_panel_open
&& !s.queued_inputs.is_empty()
&& s.composer.is_empty()
{
s.queue_selected = if s.queue_selected == 0 {
s.queued_inputs.len() - 1
} else {
s.queue_selected - 1
};
} else if s.composer.is_empty() && !s.prompt_history.is_empty() {
let pos = s.history_pos.unwrap_or(0);
let next = (pos + 1).min(s.prompt_history.len() - 1);
s.history_pos = Some(next);
let entry = s.prompt_history[next].clone();
s.composer.set_text(&entry);
drop(s);
let _ = evt_tx.send(InlineEvent::ScrollLineUp);
}
}
KeyCode::Down => {
let mut s = state.lock();
if s.slash_popup.open && !s.slash_popup.items.is_empty() {
let len = s.slash_popup.items.len();
s.slash_popup.selected = if s.slash_popup.selected + 1 >= len {
0
} else {
s.slash_popup.selected + 1
};
} else if s.queue_panel_open
&& !s.queued_inputs.is_empty()
&& s.composer.is_empty()
{
s.queue_selected = if s.queue_selected + 1 >= s.queued_inputs.len() {
0
} else {
s.queue_selected + 1
};
} else {
drop(s);
let _ = evt_tx.send(InlineEvent::ScrollLineDown);
}
}
KeyCode::Char(ch) => {
let mut s = state.lock();
if s.file_search.is_some()
&& ch == '!'
&& s.composer.text()[..s.composer.cursor()].ends_with('@')
{
let cwd = s.cwd.clone();
if let Some(fs) = s.file_search.as_mut() {
fs.toggle_hidden(&cwd);
}
continue;
}
if s.agent_hub_open && ch == 'q' {
s.agent_hub_open = false;
} else if s.vim_state.enabled() && !s.slash_popup.open {
let s = &mut *s;
let vkey =
crossterm::event::KeyEvent::new(KeyCode::Char(ch), key.modifiers);
let mut editor = InputEditor::new(&mut s.composer);
let outcome = crate::tui_vt::vim::handle_key(
&mut s.vim_state,
&mut editor,
&mut s.vim_clipboard,
&vkey,
);
if outcome.handled {
refresh_input_popups(s);
}
} else if s.composer.is_empty() && !s.slash_popup.open {
if ch == '!' && !s.shell_mode {
s.shell_mode = true;
continue;
}
if s.queue_panel_open && !s.queued_inputs.is_empty() {
let idx = s.queue_selected.min(s.queued_inputs.len() - 1);
match ch {
'x' | 'X' => {
let _ = prompt_queue.remove(idx);
s.queued_inputs.remove(idx);
if s.queue_selected >= s.queued_inputs.len()
&& !s.queued_inputs.is_empty()
{
s.queue_selected = s.queued_inputs.len() - 1;
}
continue;
}
'e' => {
if let Some(entry) = prompt_queue.remove(idx) {
s.queued_inputs.remove(idx);
s.composer.set_text(&entry);
s.queue_panel_open = false;
continue;
}
}
'J' => {
if prompt_queue.move_by(idx, 1)
&& idx + 1 < s.queued_inputs.len()
{
s.queued_inputs.swap(idx, idx + 1);
s.queue_selected = idx + 1;
}
continue;
}
'K' => {
if idx > 0 && prompt_queue.move_by(idx, -1) {
s.queued_inputs.swap(idx, idx - 1);
s.queue_selected = idx - 1;
}
continue;
}
_ => {} }
}
if keymap.matches(GlobalAction::Help, &key) {
s.overlay = Some(cheatsheet_overlay());
} else if matches!(ch, 'e') {
s.cycle_block_at_view();
} else if matches!(ch, 'E') {
s.expand_all();
} else if matches!(ch, 'J') {
s.jump_next_turn();
} else if matches!(ch, 'K') {
s.jump_prev_turn();
} else if matches!(ch, 'n') && s.search.is_some() {
s.search_next();
} else if matches!(ch, 'N') && s.search.is_some() {
s.search_prev();
} else {
s.composer.input(crossterm::event::KeyEvent::new(
KeyCode::Char(ch),
key.modifiers,
));
refresh_input_popups(&mut s);
}
} else {
s.composer.input(crossterm::event::KeyEvent::new(
KeyCode::Char(ch),
key.modifiers,
));
refresh_input_popups(&mut s);
}
if s.tip.is_none() && s.composer.text().to_lowercase().contains("plan") {
s.show_tip(
"plan_nudge",
"Try /compact to summarize and plan ahead",
180,
true,
);
}
}
_ => {}
}
}
})
}
fn handle_confirmation_key(
state: &Arc<parking_lot::Mutex<RenderState>>,
evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
issue_action_tx: &tokio::sync::mpsc::UnboundedSender<
crate::tui_vt::issues_panel::IssueActionRequest,
>,
code: KeyCode,
) {
let mut s = state.lock();
let Some(confirm) = s.confirmation.clone() else {
return;
};
match code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
s.confirmation = None;
drop(s);
match confirm.action {
ConfirmationAction::Quit => {
let _ = evt_tx.send(InlineEvent::Exit);
}
ConfirmationAction::ClearConversation => {
let _ = evt_tx.send(InlineEvent::Submit("/clear --yes".into()));
}
ConfirmationAction::CloseIssue(id) => {
let (caller, hash, cwd) = {
let s = state.lock();
let hash = s
.issue_store
.as_ref()
.and_then(|store| store.read(id).ok())
.map(|(_, h)| h);
(
oxicode_sdk::liveness::TUI_OWNERSHIP_ID.to_string(),
hash,
s.cwd.clone(),
)
};
let _ = cwd; let _ = issue_action_tx.send(
crate::tui_vt::issues_panel::IssueActionRequest::Close { id, caller, hash },
);
let mut s = state.lock();
if let Some(panel) = s.issues_panel.as_mut() {
panel.pending = true;
}
}
ConfirmationAction::RemoveProviderKey(name) => {
let _ = evt_tx.send(InlineEvent::Submit(
format!("/providers remove {name} --yes").into(),
));
}
}
}
KeyCode::Char('n')
| KeyCode::Char('N')
| KeyCode::Char('x')
| KeyCode::Char('X')
| KeyCode::Esc => {
s.confirmation = None;
}
_ => {}
}
}
fn handle_overlay_key(
state: &Arc<parking_lot::Mutex<RenderState>>,
evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
code: KeyCode,
) -> bool {
use oxicode_vtui::tui::core::{OverlayEvent, OverlaySubmission};
let mut s = state.lock();
let Some(overlay) = s.overlay.as_mut() else {
return false;
};
if let Some(secure) = overlay.secure_input.as_mut() {
use oxicode_textarea::EditCommand;
match code {
KeyCode::Backspace => {
if secure.editor.cursor_byte() > 0 {
let _ = secure.editor.apply(EditCommand::DeleteGraphemeBackward);
}
}
KeyCode::Left => {
let _ = secure.editor.apply(EditCommand::MoveGraphemeLeft);
}
KeyCode::Right => {
let _ = secure.editor.apply(EditCommand::MoveGraphemeRight);
}
KeyCode::Enter => {
let submission = OverlaySubmission::SecureInput(secure.editor.text().to_string());
drop(s);
state.lock().overlay = None;
let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(submission)));
}
KeyCode::Esc => {
drop(s);
state.lock().overlay = None;
let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
}
KeyCode::Char(ch) if ch.is_ascii_graphic() || ch == ' ' => {
let _ = secure.editor.apply(EditCommand::Insert(ch));
}
_ => {} }
return true;
}
let search_empty = s
.overlay
.as_ref()
.and_then(|o| o.search.as_ref())
.is_none_or(|search| search.value.is_empty());
let map_row_consumed = match code {
KeyCode::Enter if try_edit_model_role(&mut s) => true,
KeyCode::Char('d') if search_empty && try_remove_settings_map_row(&mut s) => true,
KeyCode::Char('n') if search_empty && try_start_new_model_role(&mut s) => true,
_ => false,
};
if map_row_consumed {
return true;
}
let Some(overlay) = s.overlay.as_mut() else {
return false;
};
match code {
KeyCode::Esc => {
drop(s);
state.lock().overlay = None;
let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
}
KeyCode::Enter => {
let submission = if let Some(item) = overlay.items.get(overlay.selected) {
match item.selection.clone() {
Some(sel) => sel,
None => {
return true;
}
}
} else {
drop(s);
state.lock().overlay = None;
let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
return true;
};
let title = overlay.title.clone();
let selected = overlay.selected;
drop(s);
state.lock().overlay = None;
tracing::debug!(
overlay = %title,
selected,
"overlay submitted"
);
let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(
OverlaySubmission::Selection(submission),
)));
}
KeyCode::Up => {
let len = overlay_filtered_indices(overlay).len();
if len == 0 {
return true;
}
let pos = overlay_filtered_indices(overlay)
.iter()
.position(|&i| i == overlay.selected)
.unwrap_or(0);
let new_pos = if pos == 0 { len - 1 } else { pos - 1 };
overlay.selected = overlay_filtered_indices(overlay)[new_pos];
}
KeyCode::Down => {
let filtered = overlay_filtered_indices(overlay);
let len = filtered.len();
if len == 0 {
return true;
}
let pos = filtered
.iter()
.position(|&i| i == overlay.selected)
.unwrap_or(0);
let new_pos = if pos + 1 >= len { 0 } else { pos + 1 };
overlay.selected = filtered[new_pos];
}
KeyCode::Backspace => {
if let Some(search) = overlay.search.as_mut() {
search.value.pop();
overlay.selected = 0;
}
}
KeyCode::Char(ch) => {
if let Some(search) = overlay.search.as_mut() {
search.value.push(ch);
overlay.selected = 0;
}
}
KeyCode::Left | KeyCode::Right => {
let tab_count = overlay.tabs.len();
if tab_count > 1 {
let next = if code == KeyCode::Right {
(overlay.active_tab + 1) % tab_count
} else {
overlay.active_tab.checked_sub(1).unwrap_or(tab_count - 1)
};
switch_settings_tab(&mut s, next);
}
}
_ => {
}
}
true
}
fn overlay_filtered_indices(overlay: &OverlayState) -> Vec<usize> {
let needle = overlay
.search
.as_ref()
.map(|s| s.value.to_lowercase())
.unwrap_or_default();
if needle.is_empty() {
return (0..overlay.items.len()).collect();
}
overlay
.items
.iter()
.enumerate()
.filter_map(|(idx, item)| {
let title_hit = item.title.to_lowercase().contains(&needle);
let sv_hit = item
.search_value
.as_deref()
.map(|v| v.to_lowercase().contains(&needle))
.unwrap_or(false);
if title_hit || sv_hit { Some(idx) } else { None }
})
.collect()
}
fn handle_file_search_key(
state: &Arc<parking_lot::Mutex<RenderState>>,
_evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
code: KeyCode,
) -> bool {
match code {
KeyCode::Up => {
let mut s = state.lock();
if let Some(fs) = s.file_search.as_mut() {
fs.up();
true
} else {
false
}
}
KeyCode::Down => {
let mut s = state.lock();
if let Some(fs) = s.file_search.as_mut() {
fs.down();
true
} else {
false
}
}
KeyCode::Tab | KeyCode::Enter => {
let mut s = state.lock();
if s.file_search
.as_ref()
.and_then(|fs| fs.selected_result())
.is_some()
{
accept_file_search(&mut s, false);
true
} else {
s.file_search = None;
false
}
}
KeyCode::Esc => {
let mut s = state.lock();
s.file_search = None;
true
}
_ => false,
}
}
fn handle_git_tui_key(
state: &Arc<parking_lot::Mutex<RenderState>>,
code: KeyCode,
modifiers: KeyModifiers,
) -> bool {
use crate::tui_vt::git_tui::{GitKeyAction, match_git_key};
use crossterm::event::KeyEvent;
let mut s = state.lock();
if s.git_tui.is_none() {
return false;
}
let cwd = s.cwd.clone();
let Some(git) = s.git_tui.as_mut() else {
unreachable!("checked above");
};
if git.commit_mode {
match code {
KeyCode::Esc => {
git.commit_mode = false;
git.commit_msg.clear();
return true;
}
KeyCode::Enter => {
if let Err(err) = git.commit(&cwd) {
tracing::warn!(?err, "git commit failed");
s.tip = Some(EphemeralTip {
text: format!("git commit failed: {err}"),
born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
ttl_ticks: 240,
key: "git-commit-error",
ambient: false,
});
}
return true;
}
KeyCode::Backspace => {
git.commit_backspace();
return true;
}
KeyCode::Char(c) => {
if !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT)
{
git.commit_input_char(c);
}
return true;
}
_ => return true, }
}
let key = KeyEvent::new(code, modifiers);
let Some(action) = match_git_key(&key) else {
return true;
};
if matches!(action, GitKeyAction::Close) {
s.git_tui = None;
return true;
}
if let Err(err) = git.apply_action(&cwd, action) {
s.tip = Some(EphemeralTip {
text: format!("/git: {err}"),
born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
ttl_ticks: 240,
key: "git-action-error",
ambient: false,
});
}
true
}
fn spawn_agent_worker(
session_swapper: Arc<crate::app::agent_session_handle::SessionSwapper>,
prompt_queue: Arc<PromptQueue>,
) {
std::thread::spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(err) => {
tracing::error!(?err, "failed to build agent worker runtime");
return;
}
};
runtime.block_on(async move {
let local = tokio::task::LocalSet::new();
local
.run_until(async move {
loop {
let prompt = prompt_queue.next().await;
run_one_prompt(&session_swapper.current(), prompt).await;
}
})
.await;
});
});
}
async fn run_one_prompt(session: &crate::app::agent_session::AgentSessionHandle, prompt: String) {
let session_for_forward = session.clone();
let (event_tx, event_rx) = std::sync::mpsc::channel::<AgentEvent>();
let forwarder = std::thread::spawn(move || {
while let Ok(event) = event_rx.recv() {
session_for_forward.forward_event_to_extensions(&event);
}
});
use std::sync::atomic::Ordering;
session.reset_should_stop();
let streaming = session.streaming_flag();
streaming.store(true, Ordering::SeqCst);
let _stream_guard = StreamingGuard(&streaming);
let agent = session.agent_ref();
let local = tokio::task::LocalSet::new();
let result = local
.run_until(agent.run_with_channel(prompt, event_tx))
.await;
let _ = forwarder.join();
if let Err(err) = result {
tracing::warn!(?err, "agent run failed");
}
}
fn build_header_context(
app: &App,
cwd: &std::path::Path,
git_branch: Option<&str>,
) -> InlineHeaderContext {
let workspace_name = cwd
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "oxicode".to_string());
let model_id = app.model_id();
let provider = model_id
.split_once('/')
.map(|(p, _)| p.to_string())
.unwrap_or_else(|| "Provider".to_string());
let branch = git_branch.unwrap_or("\u{2014}").to_string();
let mut ctx = InlineHeaderContext::default();
ctx.app_name = "oxicode".to_string();
ctx.provider = provider;
ctx.model = model_id.clone();
ctx.git = format!("git: {workspace_name}@{branch}");
ctx.tools = "Tools: ready".to_string();
ctx.search_tools = Some(InlineHeaderStatusBadge {
text: workspace_name,
tone: InlineHeaderStatusTone::Ready,
});
ctx.persistent_memory = Some(InlineHeaderStatusBadge {
text: branch,
tone: InlineHeaderStatusTone::Ready,
});
ctx.editor_context = Some(model_id);
ctx
}
async fn build_agent_session(app: &App) -> Result<crate::app::agent_session::AgentSession> {
use crate::app::agent_session_runtime::{
CreateAgentSessionFromServicesOptions, CreateAgentSessionServicesOptions,
create_agent_session_from_services, create_agent_session_services,
};
use crate::store::session::SessionManager;
let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
let hook_runner = Arc::clone(&app.oxicode().ports().hooks);
let services = create_agent_session_services(
CreateAgentSessionServicesOptions::new(cwd.clone()),
Some(hook_runner),
)?;
let services = Arc::new(services);
let model_id = app.model_id();
let tools = app.agent_tools();
let session_manager = SessionManager::create(&cwd.to_string_lossy(), None);
let result = create_agent_session_from_services(CreateAgentSessionFromServicesOptions {
services,
session_manager,
model_id: if model_id.is_empty() {
None
} else {
Some(model_id)
},
thinking_level: None,
scoped_models: Vec::new(),
tool_registry: Some(tools),
oxicode: Some(app.oxicode().clone()),
session_state: Some(app.session_state().clone()),
})
.await?;
if let Some(msg) = result.model_fallback_message {
tracing::warn!(message = %msg, "agent session model fallback");
}
Ok(result.session)
}
fn cheatsheet_lines() -> Vec<String> {
vec![
"".into(),
" Navigation".into(),
" j / ↓ Scroll down".into(),
" k / ↑ Scroll up".into(),
" J (Shift+j) Next turn".into(),
" K (Shift+k) Previous turn".into(),
" PgDn / PgUp Page scroll".into(),
" g / G Top / bottom".into(),
"".into(),
" Blocks".into(),
" e Cycle block (collapse/truncate/expand)".into(),
" E Expand all blocks".into(),
" Ctrl+E Collapse all blocks".into(),
"".into(),
" Search".into(),
" /find <q> Search transcript".into(),
" n / N Next / previous match".into(),
"".into(),
" Commands".into(),
" /theme Cycle color theme".into(),
" /model Pick a model".into(),
" /vim Toggle vim mode".into(),
" /compact Compact context".into(),
" /clear Clear conversation".into(),
" Ctrl+C Cancel run (then y to quit)".into(),
" Ctrl+Enter Send now (abort + submit)".into(),
" Ctrl+M Toggle multiline input".into(),
" Shift+Tab Toggle Auto mode (no questions, runs to end)".into(),
" Ctrl+; Toggle queue panel".into(),
"".into(),
" Special Input".into(),
" @ File picker (fuzzy search)".into(),
" @! Toggle hidden files in picker".into(),
" ! Shell mode (bash command)".into(),
]
}
fn build_command_palette() -> OverlayState {
use oxicode_vtui::tui::core::{InlineListItem, InlineListSelection};
let catalog = SlashRegistry::builtin_commands();
let mut items: Vec<InlineListItem> = catalog
.iter()
.map(|(name, desc, aliases)| {
let title = if aliases.is_empty() {
format!("/{name}")
} else {
format!(
"/{name} ({})",
aliases
.iter()
.map(|a| format!("/{a}"))
.collect::<Vec<_>>()
.join(", ")
)
};
InlineListItem {
title,
subtitle: Some(desc.to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand(name.to_string())),
search_value: Some(format!("{name} {desc}")),
}
})
.collect();
items.sort_by(|a, b| a.title.cmp(&b.title));
OverlayState {
title: "Command Palette".into(),
lines: vec!["Type to filter, Enter to select".into()],
items: items
.into_iter()
.map(|item| OverlayListItem {
title: item.title,
subtitle: item.subtitle,
badge: item.badge,
indent: item.indent,
search_value: item.search_value,
selection: item.selection,
})
.collect(),
selected: 0,
search: Some(OverlaySearchState {
label: "search".into(),
placeholder: Some("filter commands\u{2026}".into()),
value: String::new(),
}),
secure_input: None,
..Default::default()
}
}
static FRAME_TICK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static ANIMATION_T0: std::sync::LazyLock<std::time::Instant> =
std::sync::LazyLock::new(std::time::Instant::now);
fn animation_frame(period_ms: u64) -> u64 {
ANIMATION_T0.elapsed().as_millis() as u64 / period_ms.max(1)
}
static TITLE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
const TITLE_SPINNER: &[&str] = &["-", "\\", "|", "/"];
const RUN_SPINNER: &[&str] = &[
"\u{280B}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283C}", "\u{2834}", "\u{2826}", "\u{2827}",
"\u{2807}", "\u{280F}",
];
fn format_elapsed_secs(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else {
format!("{}m {:02}s", secs / 60, secs % 60)
}
}
fn blend_rgb(base: Color, target: Color, ratio: f64) -> Color {
match (base, target) {
(Color::Rgb(br, bg, bb), Color::Rgb(tr, tg, tb)) => {
let r = (br as f64 + (tr as f64 - br as f64) * ratio).round() as u8;
let g = (bg as f64 + (tg as f64 - bg as f64) * ratio).round() as u8;
let b = (bb as f64 + (tb as f64 - bb as f64) * ratio).round() as u8;
Color::Rgb(r, g, b)
}
_ => base,
}
}
fn accent_color_for_kind(kind: InlineMessageKind, styles: &ThemeStyles) -> Color {
match kind {
InlineMessageKind::User => color_from_anstyle(styles.user.get_fg_color()),
InlineMessageKind::Agent => color_from_anstyle(styles.response.get_fg_color()),
InlineMessageKind::Tool => color_from_anstyle(styles.tool.get_fg_color()),
InlineMessageKind::Error => color_from_anstyle(styles.error.get_fg_color()),
InlineMessageKind::Warning => color_from_anstyle(styles.status.get_fg_color()),
InlineMessageKind::Info => color_from_anstyle(styles.info.get_fg_color()),
InlineMessageKind::Policy => color_from_anstyle(styles.mcp.get_fg_color()),
InlineMessageKind::Pty => color_from_anstyle(styles.pty_output.get_fg_color()),
}
}
fn render_frame(frame: &mut Frame<'_>, state: &RenderState, _handle: &InlineHandle) {
let area = frame.area();
let bg = active_styles().background;
frame
.buffer_mut()
.set_style(area, Style::default().bg(color_from_anstyle(Some(bg))));
let layout = super::frame_layout::compute_chrome(area);
let tick = FRAME_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
{
let running = state.active_run.is_some() || state.reasoning_stage.is_some();
let was_running = TITLE_RUNNING.swap(running, std::sync::atomic::Ordering::Relaxed);
if running || was_running {
let title = if running {
let spin = TITLE_SPINNER[(animation_frame(120) as usize) % TITLE_SPINNER.len()];
let model = state
.header_context
.editor_context
.as_deref()
.unwrap_or("oxicode");
format!("{spin} oxicode \u{2014} {model}")
} else {
"oxicode".to_string()
};
use std::io::Write;
let _ = write!(std::io::stderr(), "\x1b]2;{}\x07", title);
let _ = std::io::stderr().flush();
}
}
if let Some(panel) = &state.issues_panel {
crate::tui_vt::issues_panel::render_issues_panel(frame, area, panel);
if let Some(overlay) = &state.overlay {
render_overlay(frame, area, overlay);
}
if let Some(confirm) = &state.confirmation {
render_confirmation(frame, area, confirm);
}
return;
}
if let Some(git) = &state.git_tui {
crate::tui_vt::git_tui::render::render_overlay_lines(frame, area, git);
} else {
render_transcript(frame, layout.scrollback, state);
let mut pinned_area = layout.scrollback;
if !state.queued_inputs.is_empty() {
let used = render_queue_pane(frame, pinned_area, state);
pinned_area.y = pinned_area.y.saturating_add(used);
pinned_area.height = pinned_area.height.saturating_sub(used);
}
if !state.todo_phases.is_empty() {
if frame.area().height < TODO_COMPACT_ROWS_THRESHOLD {
let line = render_todo_compact_line(&state.todo_phases);
frame.render_widget(
Paragraph::new(vec![line]),
Rect {
height: 1,
..pinned_area
},
);
} else {
let is_matched = build_matched_closure(state.hub.as_ref());
render_todo_pane(
frame,
pinned_area,
&state.todo_phases,
state.todo_expanded,
is_matched,
);
}
}
if state.active_run.is_some() || state.reasoning_stage.is_some() {
render_reasoning_indicator(frame, layout.prompt, state);
} else if state.pending_quit {
render_pending_quit_hint(frame, layout.prompt);
} else if !state.follow_ups.is_empty() {
render_follow_ups(frame, layout.prompt, &state.follow_ups);
} else {
let occluded = state.overlay.is_some() || state.confirmation.is_some();
if let Some(tip) = &state.tip
&& tip_is_visible(tip, tick)
&& !(tip.ambient && occluded)
{
render_tip(frame, layout.prompt, &tip.text);
}
}
render_composer(frame, layout.prompt, state);
if state.slash_popup.open {
render_slash_popup(frame, layout.prompt, state);
}
if state.file_search.is_some() {
render_file_search_dropdown(frame, layout.prompt, state);
}
}
if state.agent_hub_open {
render_agent_hub(frame, area, state);
}
if let Some(overlay) = &state.overlay {
render_overlay(frame, area, overlay);
}
if let Some(confirm) = &state.confirmation {
render_confirmation(frame, area, confirm);
}
}
fn render_confirmation(frame: &mut Frame, area: Rect, confirm: &ModalConfirmation) {
let styles = active_styles();
let accent = color_from_anstyle(styles.error.get_fg_color());
let inner_w = confirm
.title
.chars()
.count()
.max(confirm.message.chars().count())
.max(36) as u16;
let width = inner_w + 4;
let height = 5;
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
let popup_area = Rect {
x,
y,
width,
height,
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
format!(" {} ", confirm.title),
Style::default().fg(accent).bold(),
))
.border_style(Style::default().fg(accent));
let msg = Line::styled(
confirm.message.clone(),
Style::default().fg(color_from_anstyle(Some(styles.foreground))),
);
frame.render_widget(
Paragraph::new(vec![Line::default(), msg]).block(block),
popup_area,
);
}
fn render_agent_hub(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
let rows = state.hub_entries.len() as u16;
let height = rows.saturating_add(4).min(area.height.saturating_sub(1));
let width = area.width.clamp(30, 80);
let rect = Rect {
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
};
frame.render_widget(Clear, rect);
let title = Line::from(Span::styled(
" Agent Hub ",
Style::default().add_modifier(Modifier::BOLD),
));
let block = Block::default().borders(Borders::ALL).title(title);
let items: Vec<ListItem<'_>> = if state.hub_entries.is_empty() {
vec![ListItem::new(Line::from(Span::raw(
"No agents registered.",
)))]
} else {
state
.hub_entries
.iter()
.map(|(id, e)| {
ListItem::new(Line::from(vec![
Span::raw(format!("{:?} ", e.kind)),
Span::raw(e.display_name.clone()),
Span::raw(format!(" — {:?} ({})", e.status, id)),
]))
})
.collect()
};
frame.render_widget(List::new(items).block(block), rect);
}
fn render_overlay(frame: &mut Frame<'_>, area: Rect, overlay: &OverlayState) {
let styles = active_styles();
if let Some(secure) = &overlay.secure_input {
let lines_count = overlay.lines.len();
let desired_h = (lines_count as u16).saturating_add(1).saturating_add(2); let height = desired_h.min(area.height.saturating_sub(2));
let width = area.width.clamp(30, 80);
let rect = Rect {
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
};
frame.render_widget(Clear, rect);
let title = Line::from(Span::styled(
format!(" {} ", overlay.title),
Style::default()
.fg(color_from_anstyle(styles.primary.get_fg_color()))
.add_modifier(Modifier::BOLD),
));
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
.title(title);
let inner = block.inner(rect);
frame.render_widget(&block, rect);
let secondary = color_from_anstyle(styles.secondary.get_fg_color());
let mut row = inner.top();
for line_text in &overlay.lines {
let row_area = Rect {
x: inner.left(),
y: row,
width: inner.width,
height: 1,
};
let line = Line::from(Span::styled(
line_text.clone(),
Style::default().fg(secondary),
));
frame.render_widget(Paragraph::new(line), row_area);
row = row.saturating_add(1);
}
let label = &secure.config.label;
let label_prefix = format!("{label}: ");
let prefix_columns = UnicodeWidthStr::width(label_prefix.as_str()) as u16;
let prefix_area = Rect {
x: inner.left(),
y: row,
width: prefix_columns.min(inner.width),
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
label_prefix.clone(),
Style::default().fg(secondary),
))),
prefix_area,
);
let textarea_area = Rect {
x: inner.left().saturating_add(prefix_columns),
y: row,
width: inner.width.saturating_sub(prefix_columns),
height: 1,
};
let inner_left = textarea_area.left();
let inner_right = textarea_area.right().saturating_sub(1);
let value = secure.editor.text();
if value.is_empty() {
if let Some(placeholder) = secure.config.placeholder.as_deref() {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
placeholder.to_string(),
Style::default().fg(secondary).dim(),
))),
textarea_area,
);
}
if textarea_area.width > 0 {
frame.set_cursor_position(Position::new(inner_left, row));
}
return;
}
let display_line: Line<'static> = if secure.config.mask_input {
Line::from("*".repeat(value.chars().count()))
} else {
Line::from(value.to_string())
};
let mut ta = TextArea::new();
ta.set_text(value);
ta.replace_range_with_element(
0..value.len(),
value,
MASKED_ELEMENT_KIND,
Some(display_line),
);
ta.set_cursor(secure.editor.cursor_byte());
frame.render_widget_ref(&ta, textarea_area);
if let Some((cx, cy)) = ta.cursor_pos_with_state(textarea_area, TextAreaState::default()) {
let caret_x = cx.min(inner_right);
frame.set_cursor_position(Position::new(caret_x, cy));
}
return;
}
let visible_max = (area.height as usize).saturating_sub(7).max(3);
let filtered: Vec<usize> = match &overlay.search {
Some(search) if !search.value.is_empty() => {
let needle = search.value.to_lowercase();
overlay
.items
.iter()
.enumerate()
.filter_map(|(idx, item)| {
let title_match = item.title.to_lowercase().contains(&needle);
let sv_match = item
.search_value
.as_deref()
.map(|v| v.to_lowercase().contains(&needle))
.unwrap_or(false);
if title_match || sv_match {
Some(idx)
} else {
None
}
})
.collect()
}
_ => (0..overlay.items.len()).collect(),
};
let has_search = overlay.search.is_some();
let has_tabs = overlay.tabs.len() > 1;
let lines_count = overlay.lines.len();
let items_count = filtered.len().min(visible_max);
let height_inner =
(lines_count + items_count + usize::from(has_search) + usize::from(has_tabs)) as u16;
let desired_h = height_inner.saturating_add(3); let height = desired_h.min(area.height.saturating_sub(2));
let width = area.width.clamp(30, 80);
let rect = Rect {
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
};
frame.render_widget(Clear, rect);
let title = Line::from(Span::styled(
format!(" {} ", overlay.title),
Style::default()
.fg(color_from_anstyle(styles.primary.get_fg_color()))
.add_modifier(Modifier::BOLD),
));
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
.title(title);
let inner = block.inner(rect);
frame.render_widget(&block, rect);
let primary = color_from_anstyle(styles.primary.get_fg_color());
let fg = color_from_anstyle(Some(styles.foreground));
let secondary = color_from_anstyle(styles.secondary.get_fg_color());
let selected_filtered_pos = filtered
.iter()
.position(|&idx| idx == overlay.selected)
.unwrap_or(0);
let mut row = inner.top();
if has_tabs {
let mut spans: Vec<Span> = Vec::new();
for (i, name) in overlay.tabs.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
let style = if i == overlay.active_tab {
Style::default().fg(primary).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(secondary).add_modifier(Modifier::DIM)
};
spans.push(Span::styled(name.clone(), style));
}
let row_area = Rect {
x: inner.left(),
y: row,
width: inner.width,
height: 1,
};
frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
row = row.saturating_add(1);
}
if let Some(search) = &overlay.search {
let prompt = format!("{}: {}", search.label, search.value);
let line = Line::from(vec![
Span::styled(
format!("{}: ", search.label),
Style::default().fg(secondary),
),
Span::styled(
if search.value.is_empty() {
search
.placeholder
.clone()
.unwrap_or_else(|| "type to filter\u{2026}".to_string())
} else {
search.value.clone()
},
if search.value.is_empty() {
Style::default().fg(secondary).add_modifier(Modifier::DIM)
} else {
Style::default().fg(fg)
},
),
]);
let _ = prompt; let row_area = Rect {
x: inner.left(),
y: row,
width: inner.width,
height: 1,
};
frame.render_widget(Paragraph::new(line), row_area);
row = row.saturating_add(1);
}
for line_text in &overlay.lines {
let row_area = Rect {
x: inner.left(),
y: row,
width: inner.width,
height: 1,
};
let line = Line::from(Span::styled(
line_text.clone(),
Style::default().fg(secondary),
));
frame.render_widget(Paragraph::new(line), row_area);
row = row.saturating_add(1);
}
let searching = overlay.search.as_ref().is_some_and(|s| !s.value.is_empty());
let use_sidebar = overlay.sections.len() >= 2 && inner.width >= 60 && !searching;
let sidebar_w = if use_sidebar {
let longest = overlay
.sections
.iter()
.map(|s| s.chars().count())
.max()
.unwrap_or(0);
(22usize.min(longest) + 4) as u16
} else {
0
};
let active_section = if use_sidebar {
item_section_idx(overlay, overlay.selected).unwrap_or(overlay.active_section)
} else {
overlay.active_section
};
let list_x = inner.left() + sidebar_w;
let list_w = inner.width.saturating_sub(sidebar_w);
if use_sidebar {
let mut srow = row;
for (i, name) in overlay.sections.iter().enumerate() {
let style = if i == active_section {
Style::default().fg(primary).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(secondary)
};
let marker = if i == active_section { "> " } else { " " };
let row_area = Rect {
x: inner.left(),
y: srow,
width: sidebar_w,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(marker, style),
Span::styled(name.clone(), style),
])),
row_area,
);
srow = srow.saturating_add(1);
}
}
if filtered.is_empty() {
if overlay.key_capture.is_some() {
return;
}
let row_area = Rect {
x: inner.left(),
y: row,
width: inner.width,
height: 1,
};
let empty_text = if overlay.search.is_some() {
" (no matches)"
} else {
" (no items)"
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
empty_text,
Style::default().fg(secondary).add_modifier(Modifier::DIM),
))),
row_area,
);
} else {
let first_visible = selected_filtered_pos
.saturating_sub(visible_max / 2)
.min(filtered.len().saturating_sub(visible_max));
for &item_idx in filtered.iter().skip(first_visible).take(visible_max) {
let item = &overlay.items[item_idx];
let is_selected = item_idx == overlay.selected;
let marker = if is_selected { "> " } else { " " };
let indent = " ".repeat(item.indent as usize);
let mut item_style = if is_selected {
Style::default().fg(primary).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(fg)
};
if use_sidebar
&& item_section_idx(overlay, item_idx).is_some_and(|sec| sec != active_section)
{
item_style = item_style.add_modifier(Modifier::DIM);
}
let mut spans = vec![
Span::styled(marker, item_style),
Span::styled(indent, item_style),
Span::styled(item.title.clone(), item_style),
];
if let Some(badge) = &item.badge {
spans.push(Span::raw(" "));
spans.push(Span::styled(
badge.clone(),
Style::default().fg(secondary).add_modifier(Modifier::DIM),
));
}
if let Some(subtitle) = &item.subtitle {
spans.push(Span::raw(" "));
spans.push(Span::styled(
subtitle.clone(),
Style::default().fg(secondary),
));
}
let line = Line::from(spans);
let row_area = Rect {
x: list_x,
y: row,
width: list_w,
height: 1,
};
frame.render_widget(Paragraph::new(line), row_area);
row = row.saturating_add(1);
}
}
if row < inner.bottom() {
let hint = if overlay.items.iter().any(|item| item.selection.is_some()) {
if has_tabs {
"Enter select | Up/Down move | ←/→ tabs | Esc close"
} else {
"Enter select | Up/Down move | Esc close"
}
} else {
"Esc close"
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
hint,
Style::default().fg(secondary).add_modifier(Modifier::DIM),
))),
Rect {
x: inner.left(),
y: inner.bottom().saturating_sub(1),
width: inner.width,
height: 1,
},
);
}
}
fn render_transcript(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
if state.transcript.is_empty() {
render_welcome(frame, area, state);
return;
}
let styles = active_styles();
let bg_color = color_from_anstyle(Some(styles.background));
let content_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height,
};
let display =
build_transcript_display(state, &styles, state.committed_entries, content_area.width);
let total = display.len();
let raw_start = if state.scroll_offset == usize::MAX {
total.saturating_sub(content_area.height as usize)
} else {
display
.iter()
.position(|d| d.source_index >= state.scroll_offset)
.unwrap_or(total.saturating_sub(1))
};
let start = effective_scroll_offset(raw_start, total, content_area.height as usize);
let sticky_first: Option<usize> = display.get(start).and_then(|d| {
let bid = state.transcript.get(d.source_index)?.block_id;
let first_idx = state.transcript.iter().position(|l| l.block_id == bid)?;
(first_idx != d.source_index).then_some(first_idx)
});
let sticky_h: u16 = if sticky_first.is_some() { 1 } else { 0 };
let body_top = content_area.top() + sticky_h;
const FADE_ROWS: usize = 5;
let sticky_opacity: f64 = if let Some(sidx) = sticky_first {
let sticky_bid = state.transcript[sidx].block_id;
let next_offset = display.iter().skip(start).position(|d| {
state
.transcript
.get(d.source_index)
.map(|l| l.block_id != sticky_bid)
.unwrap_or(false)
});
match next_offset {
Some(off) if off <= FADE_ROWS => off as f64 / FADE_ROWS as f64,
_ => 1.0,
}
} else {
1.0
};
if let Some(sidx) = sticky_first {
let tl = &state.transcript[sidx];
let accent_base = accent_color_for_kind(tl.kind, &styles);
let bg_blend = 0.1 * sticky_opacity;
let line =
transcript_line_marked(tl, &styles, false, false, false, true, content_area.width);
let row = Rect {
x: content_area.x,
y: content_area.top(),
width: content_area.width,
height: 1,
};
if bg_blend > 0.01 {
frame.buffer_mut().set_style(
row,
Style::default().bg(blend_rgb(bg_color, accent_base, bg_blend)),
);
}
frame.render_widget(Paragraph::new(line), row);
}
let live_budget = content_area.height.saturating_sub(sticky_h) as usize;
let (alloc_by_block, hidden_count, natural_by_block) =
compute_block_allocations(state, state.committed_entries, live_budget);
let pulse = animation_frame(1000).is_multiple_of(2);
let banner_row_used = hidden_count > 0;
let banner_y = content_area.bottom().saturating_sub(1);
let mut y = body_top;
let width = content_area.width.max(1) as usize;
let image_block_plans: Vec<(usize, u32, u16, usize)> = state
.image_previews
.pending()
.iter()
.filter_map(|p| {
let row_index = state
.transcript
.iter()
.position(|l| l.segments.iter().any(|s| s.text.contains(&p.label)))?;
let bid = state.transcript[row_index].block_id;
let mut block_rows: u16 = 0;
for d in &display {
let Some(l) = state.transcript.get(d.source_index) else {
continue;
};
if l.block_id != bid {
continue;
}
block_rows = block_rows.saturating_add(match &d.line {
None => 1,
Some(line) => {
let lw = line.width();
if lw == 0 {
1
} else {
lw.div_ceil(width).max(1) as u16
}
}
});
}
(block_rows > 0).then_some((bid, p.id, block_rows, row_index))
})
.collect();
let mut current_block: Option<usize> = None;
let mut skipped_blocks: std::collections::HashSet<usize> = std::collections::HashSet::new();
for d in display.into_iter().skip(start) {
if y >= content_area.bottom() {
break;
}
if banner_row_used && y >= banner_y {
break;
}
let d_bid = state.transcript.get(d.source_index).map(|l| l.block_id);
let Some(d_bid) = d_bid else {
continue;
};
if current_block != Some(d_bid) {
if y < content_area.bottom()
&& let Some((_, pid, prows, row_index)) =
image_block_plans.iter().find(|(b, _, _, _)| *b == d_bid)
{
let visible_rows = content_area.bottom().saturating_sub(y).max(1);
state.image_previews.record_anchor(
*pid,
content_area.x,
y,
(*prows).min(visible_rows),
*row_index,
);
}
current_block = Some(d_bid);
let alloc = alloc_by_block
.get(&d_bid)
.copied()
.unwrap_or(BlockAlloc { rows: 0 });
let natural = natural_by_block.get(&d_bid).copied().unwrap_or(0);
if alloc.rows < natural {
if skipped_blocks.contains(&d_bid) {
continue;
}
match alloc.rows {
0 => {
skipped_blocks.insert(d_bid);
continue;
}
1 => {
let activity = block_activity(&state.transcript, d_bid);
render_glyph_row(frame, content_area, y, &activity, &styles, pulse);
y += 1;
skipped_blocks.insert(d_bid);
continue;
}
2 => {
let activity = block_activity(&state.transcript, d_bid);
y += render_folded_card(frame, content_area, y, &activity, &styles, pulse);
skipped_blocks.insert(d_bid);
continue;
}
_ => {}
}
}
}
let Some(line) = d.line else {
y += 1;
continue;
};
let text_w = line.width();
let wrapped_h = if text_w == 0 {
1
} else {
text_w.div_ceil(width).max(1) as u16
};
let row = Rect {
x: content_area.x,
y,
width: content_area.width,
height: wrapped_h.min(content_area.bottom().saturating_sub(y)),
};
frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: false }), row);
y += wrapped_h;
}
if banner_row_used {
render_hidden_banner(frame, content_area, banner_y, hidden_count);
}
let _ = (total, sticky_h);
}
#[derive(Clone)]
struct TranscriptDisplayItem<'a> {
source_index: usize,
line: Option<Line<'a>>,
}
fn block_activity(transcript: &[TranscriptLine], block_id: usize) -> String {
let mut activity = String::new();
for line in transcript.iter().filter(|l| l.block_id == block_id) {
for seg in &line.segments {
let text = seg.text.trim();
if !text.is_empty() {
activity.push_str(text);
break;
}
}
if !activity.is_empty() {
break;
}
}
if !activity.is_empty() {
return activity;
}
transcript
.iter()
.find(|l| l.block_id == block_id)
.map(|l| kind_label(l.kind))
.unwrap_or_else(|| "block".to_string())
}
fn kind_label(kind: InlineMessageKind) -> String {
match kind {
InlineMessageKind::Agent => "agent".to_string(),
InlineMessageKind::User => "user".to_string(),
InlineMessageKind::Tool => "tool".to_string(),
InlineMessageKind::Error => "error".to_string(),
InlineMessageKind::Warning => "warning".to_string(),
InlineMessageKind::Info => "info".to_string(),
InlineMessageKind::Policy => "policy".to_string(),
InlineMessageKind::Pty => "pty".to_string(),
}
}
fn block_natural_heights(
transcript: &[TranscriptLine],
mode_for: impl Fn(usize) -> BlockDisplayMode,
from_entry: usize,
) -> (Vec<usize>, Vec<usize>) {
let mut block_ids: Vec<usize> = Vec::new();
let mut heights: Vec<usize> = Vec::new();
let mut index_of: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
for item in visible_items(transcript, mode_for) {
let bid = match item {
VisibleItem::Line { source_index, .. } => transcript[source_index].block_id,
VisibleItem::Gap { source_index, .. } => transcript[source_index].block_id,
};
let live_source = match item {
VisibleItem::Line { source_index, .. } => source_index,
VisibleItem::Gap { source_index, .. } => source_index,
};
if live_source < from_entry {
continue;
}
let idx = *index_of.entry(bid).or_insert_with(|| {
block_ids.push(bid);
heights.push(0);
block_ids.len() - 1
});
heights[idx] += 1;
}
(block_ids, heights)
}
fn compute_block_allocations(
state: &RenderState,
from_entry: usize,
budget: usize,
) -> (
std::collections::HashMap<usize, BlockAlloc>,
usize,
std::collections::HashMap<usize, usize>,
) {
let (block_ids, heights) =
block_natural_heights(&state.transcript, |bid| state.block_mode(bid), from_entry);
let total_blocks = block_ids.len();
let allocs = allocate_rows(&heights, budget);
let mut by_block: std::collections::HashMap<usize, BlockAlloc> =
std::collections::HashMap::with_capacity(total_blocks);
let mut natural_by_block: std::collections::HashMap<usize, usize> =
std::collections::HashMap::with_capacity(total_blocks);
let mut hidden = 0usize;
for (i, &bid) in block_ids.iter().enumerate() {
let alloc = match state.block_mode(bid) {
BlockDisplayMode::Collapsed => BlockAlloc { rows: heights[i] },
BlockDisplayMode::Truncated | BlockDisplayMode::Expanded => allocs[i],
};
if alloc.rows == 0 {
hidden += 1;
}
natural_by_block.insert(bid, heights[i]);
by_block.insert(bid, alloc);
}
(by_block, hidden, natural_by_block)
}
fn clamp_fold_text(text: &str, prefix_w: usize, width: usize, ellipsis: &str) -> String {
let budget = width.saturating_sub(prefix_w);
if budget == 0 || width == 0 {
return String::new();
}
let text_w = text.width();
if text_w <= budget {
return text.to_string();
}
let ell_w = ellipsis.width();
let cap = budget.saturating_sub(ell_w);
let mut out = String::new();
let mut used = 0usize;
for ch in text.chars() {
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if used + w > cap {
break;
}
out.push(ch);
used += w;
}
out.push_str(ellipsis);
out
}
fn render_folded_card(
frame: &mut Frame<'_>,
area: Rect,
y: u16,
activity: &str,
styles: &ThemeStyles,
pulse: bool,
) -> u16 {
let tool_color = styles
.tool
.get_fg_color()
.or_else(|| styles.secondary.get_fg_color())
.or(styles.response.get_fg_color());
let style = Style::default().fg(color_from_anstyle(tool_color));
let pulse_mark = if pulse { " \u{2022}" } else { "" };
let head_prefix_w = "\u{256D}\u{2500} ".width() + pulse_mark.width();
let head_activity = clamp_fold_text(activity, head_prefix_w, area.width as usize, "\u{2026}");
let head = Line::from(vec![Span::styled(
format!("\u{256D}\u{2500} {head_activity}{pulse_mark}"),
style,
)]);
let tail = Line::from(vec![Span::styled("\u{2570}\u{2500} \u{2026}", style)]);
if y < area.bottom() {
let row = Rect {
x: area.x,
y,
width: area.width,
height: 1,
};
frame.render_widget(Paragraph::new(head), row);
}
let y2 = y.saturating_add(1);
if y2 < area.bottom() {
let row = Rect {
x: area.x,
y: y2,
width: area.width,
height: 1,
};
frame.render_widget(Paragraph::new(tail), row);
}
2
}
fn render_glyph_row(
frame: &mut Frame<'_>,
area: Rect,
y: u16,
activity: &str,
styles: &ThemeStyles,
pulse: bool,
) -> u16 {
let tool_color = styles
.tool
.get_fg_color()
.or_else(|| styles.secondary.get_fg_color())
.or(styles.response.get_fg_color());
let style = Style::default().fg(color_from_anstyle(tool_color));
let pulse_mark = if pulse { " \u{2022}" } else { "" };
let glyph_prefix_w = "\u{25B8} ".width() + pulse_mark.width();
let glyph_activity = clamp_fold_text(activity, glyph_prefix_w, area.width as usize, "\u{2026}");
let line = Line::from(vec![Span::styled(
format!("\u{25B8} {glyph_activity}{pulse_mark}"),
style,
)]);
if y < area.bottom() {
let row = Rect {
x: area.x,
y,
width: area.width,
height: 1,
};
frame.render_widget(Paragraph::new(line), row);
}
1
}
fn render_hidden_banner(frame: &mut Frame<'_>, area: Rect, y: u16, hidden: usize) -> u16 {
let style = Style::default().fg(color_from_anstyle(active_styles().secondary.get_fg_color()));
let text = if hidden == 1 {
"\u{2026} 1 earlier block hidden".to_string()
} else {
format!("\u{2026} {hidden} earlier blocks hidden")
};
let clamped = clamp_fold_text(&text, 0, area.width as usize, "\u{2026}");
let line = Line::from(vec![Span::styled(clamped, style)]);
if y < area.bottom() {
let row = Rect {
x: area.x,
y,
width: area.width,
height: 1,
};
frame.render_widget(Paragraph::new(line), row);
}
1
}
fn build_transcript_display<'a>(
state: &'a RenderState,
styles: &'a ThemeStyles,
from_entry: usize,
width: u16,
) -> Vec<TranscriptDisplayItem<'a>> {
let search_set: std::collections::HashSet<usize> = state
.search
.as_ref()
.map(|s| s.matches.iter().copied().collect())
.unwrap_or_default();
let current_match = state
.search
.as_ref()
.and_then(|s| (!s.matches.is_empty()).then(|| s.matches[s.current]));
let mut display = Vec::with_capacity(state.transcript.len());
let dim_style = Style::default()
.fg(color_from_anstyle(styles.secondary.get_fg_color()))
.add_modifier(Modifier::DIM);
let mut prev_block: Option<usize> = None;
let mut prev_kind: Option<InlineMessageKind> = None;
for item in visible_items(&state.transcript, |block_id| state.block_mode(block_id)) {
match item {
VisibleItem::Line {
source_index,
folded,
} => {
if source_index < from_entry {
continue;
}
let tl = &state.transcript[source_index];
let is_block_start = prev_block != Some(tl.block_id);
let needs_spacer = is_block_start
&& prev_block.is_some()
&& (tl.kind == InlineMessageKind::User
|| prev_kind == Some(InlineMessageKind::User));
if needs_spacer {
display.push(TranscriptDisplayItem {
source_index,
line: None,
});
}
let is_match = search_set.contains(&source_index);
let line = transcript_line_marked(
tl,
styles,
folded,
is_match,
current_match == Some(source_index),
is_block_start,
width,
);
display.push(TranscriptDisplayItem {
source_index,
line: Some(line),
});
prev_block = Some(tl.block_id);
prev_kind = Some(tl.kind);
}
VisibleItem::Gap {
source_index,
hidden_lines,
} => {
if source_index < from_entry {
continue;
}
let gap = Line::styled(format!(" \u{2026} +{hidden_lines} lines"), dim_style);
display.push(TranscriptDisplayItem {
source_index,
line: Some(gap),
});
}
}
}
display
}
pub(crate) fn should_rebuild_scrollback(
prev_w: u16,
new_w: u16,
_prev_h: u16,
_new_h: u16,
) -> bool {
prev_w != 0 && prev_w != new_w
}
pub(crate) fn plan_full_flush(display_len: usize) -> usize {
display_len
}
struct ScrollbackCommit {
rows: u16,
boundary_item: usize,
new_committed_entries: usize,
}
fn scrollback_commit_plan(
display: &[TranscriptDisplayItem<'_>],
transcript: &[TranscriptLine],
width: usize,
keep_rows: usize,
anchor_entry: Option<usize>,
) -> Option<ScrollbackCommit> {
let width = width.max(1);
let mut ends: Vec<usize> = Vec::with_capacity(display.len());
let mut y = 0usize;
for d in display {
let h = match &d.line {
None => 1,
Some(line) => {
let w = line.width();
if w == 0 { 1 } else { w.div_ceil(width).max(1) }
}
};
y += h;
ends.push(y);
}
let total_rows = y;
if total_rows <= keep_rows || display.is_empty() {
return None;
}
let limit = total_rows - keep_rows;
let mut boundary_item = ends.iter().rposition(|&e| e <= limit)? + 1;
if let Some(anchor) = anchor_entry
&& let Some(anchor_item) = display.iter().position(|d| d.source_index >= anchor)
{
boundary_item = boundary_item.min(anchor_item);
}
boundary_item = boundary_item.min(display.len().saturating_sub(1));
let bid_of = |i: usize| transcript.get(display[i].source_index).map(|t| t.block_id);
while boundary_item > 0 {
let last = display[boundary_item - 1].source_index;
let next = display[boundary_item].source_index;
let same_block = matches!(
(transcript.get(last), transcript.get(next)),
(Some(a), Some(b)) if a.block_id == b.block_id
);
if !same_block {
break;
}
let block_bid = bid_of(boundary_item);
let block_start = (0..boundary_item)
.rev()
.find(|&i| bid_of(i) != block_bid)
.map_or(0, |i| i + 1);
let block_end = (boundary_item..display.len())
.find(|&i| bid_of(i) != block_bid)
.unwrap_or(display.len());
let before_rows = if block_start == 0 {
0
} else {
ends[block_start - 1]
};
if ends[block_end - 1] - before_rows > keep_rows {
break; }
boundary_item -= 1;
}
if boundary_item == 0 {
return None;
}
let first_live = display[boundary_item].source_index;
let last_committed = display[boundary_item - 1].source_index;
let new_committed_entries = if matches!(
(transcript.get(last_committed), transcript.get(first_live)),
(Some(a), Some(b)) if a.block_id == b.block_id
) {
first_live
} else {
let live_bid = transcript.get(first_live)?.block_id;
transcript.iter().position(|t| t.block_id == live_bid)?
};
let rows = ends[boundary_item - 1].min(u16::MAX as usize) as u16;
Some(ScrollbackCommit {
rows,
boundary_item,
new_committed_entries,
})
}
fn render_committed_chunk(
buf: &mut Buffer,
items: &[TranscriptDisplayItem<'_>],
x: u16,
width: u16,
) {
use ratatui::widgets::Widget;
let width = width.max(1);
let mut y = 0u16;
for item in items {
let Some(line) = &item.line else {
y += 1;
continue;
};
let text_w = line.width();
let wrapped_h = if text_w == 0 {
1
} else {
text_w.div_ceil(width as usize).max(1) as u16
};
let area = Rect {
x,
y,
width,
height: wrapped_h,
};
Paragraph::new(line.clone())
.wrap(Wrap { trim: false })
.render(area, buf);
y += wrapped_h;
}
}
fn commit_scrollback(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
state: &mut RenderState,
force_all: bool,
) {
if state.scroll_offset != usize::MAX
|| !state.message_buffer.is_empty()
|| !state.thinking_buffer.is_empty()
|| state.overlay.is_some()
|| state.confirmation.is_some()
|| state.agent_hub_open
|| state.slash_popup.open
|| state.file_search.is_some()
|| state.search.is_some()
|| state.transcript.is_empty()
{
return;
}
let Ok(size) = terminal.size() else {
return;
};
let area = Rect {
x: 0,
y: 0,
width: size.width,
height: size.height,
};
let keep_rows = super::frame_layout::scrollback_height(area) as usize;
if keep_rows == 0 && !force_all {
return;
}
let styles = active_styles();
let (gutter_x, scrollback_w) = super::frame_layout::scrollback_geometry(area);
let content_w = scrollback_w as usize;
let display =
build_transcript_display(state, &styles, state.committed_entries, content_w as u16);
if force_all {
let boundary_item = plan_full_flush(display.len()).min(display.len());
if boundary_item == 0 {
return;
}
let mut total_rows = 0usize;
let width = content_w.max(1);
for d in &display[..boundary_item] {
total_rows += match &d.line {
None => 1,
Some(line) => {
let w = line.width();
if w == 0 { 1 } else { w.div_ceil(width).max(1) }
}
};
}
let rows = total_rows.min(u16::MAX as usize) as u16;
let chunk = &display[..boundary_item];
let res = terminal.insert_before(rows, |buf| {
render_committed_chunk(buf, chunk, gutter_x, content_w as u16);
});
if res.is_ok() {
state.committed_entries = state.transcript.len();
}
return;
}
let Some(plan) = scrollback_commit_plan(
&display,
&state.transcript,
content_w,
keep_rows,
state.stream_anchor,
) else {
return;
};
let chunk = &display[..plan.boundary_item];
let res = terminal.insert_before(plan.rows, |buf| {
render_committed_chunk(buf, chunk, gutter_x, content_w as u16);
});
if res.is_ok() {
state.committed_entries = plan.new_committed_entries;
}
}
fn transcript_line_marked<'a>(
line: &'a TranscriptLine,
styles: &'a ThemeStyles,
folded: bool,
is_match: bool,
is_current: bool,
is_block_start: bool,
width: u16,
) -> Line<'a> {
let kind_style = match line.kind {
InlineMessageKind::Agent => {
Style::default().fg(color_from_anstyle(styles.response.get_fg_color()))
}
InlineMessageKind::User => {
Style::default().fg(color_from_anstyle(styles.user.get_fg_color()))
}
InlineMessageKind::Tool => {
Style::default().fg(color_from_anstyle(styles.tool.get_fg_color()))
}
InlineMessageKind::Error => {
Style::default().fg(color_from_anstyle(styles.error.get_fg_color()))
}
InlineMessageKind::Warning => {
Style::default().fg(color_from_anstyle(styles.status.get_fg_color()))
}
InlineMessageKind::Info => {
Style::default().fg(color_from_anstyle(styles.info.get_fg_color()))
}
InlineMessageKind::Policy => {
Style::default().fg(color_from_anstyle(styles.mcp.get_fg_color()))
}
InlineMessageKind::Pty => {
Style::default().fg(color_from_anstyle(styles.pty_output.get_fg_color()))
}
};
let severity_label = match line.kind {
InlineMessageKind::Error => Some("error: "),
InlineMessageKind::Warning => Some("warning: "),
InlineMessageKind::Info => Some("info: "),
InlineMessageKind::Policy => Some("policy: "),
_ => None,
};
let mut prefix = String::new();
if folded {
prefix.push_str("[+] ");
}
if let Some(label) = severity_label
&& (folded || is_block_start)
{
prefix.push_str(label);
}
let highlight = if is_current {
Some(Style::default().reversed())
} else if is_match {
Some(Style::default().add_modifier(Modifier::UNDERLINED))
} else {
None
};
let prefix_w = prefix.len() as u16;
let budget = width.saturating_sub(prefix_w);
let clamped = clamp_segments_to_width(&line.segments, budget);
let mut spans = Vec::with_capacity(clamped.len() + 1);
if !prefix.is_empty() {
spans.push(Span::styled(prefix, kind_style));
}
for segment in &clamped {
let mut style = segment_style(segment, kind_style, styles);
if line.kind == InlineMessageKind::User {
style = style.add_modifier(Modifier::BOLD);
}
if let Some(h) = highlight {
style = style.patch(h);
}
spans.push(Span::styled(segment.text.clone(), style));
}
Line::from(spans)
}
pub(crate) fn segment_style(
segment: &InlineSegment,
fallback: Style,
_styles: &ThemeStyles,
) -> Style {
let mut style = fallback;
let inline = segment.style.as_ref();
if let Some(color) = inline.color {
style = style.fg(color_from_anstyle(Some(color)));
}
if inline.effects.contains(anstyle::Effects::BOLD) {
style = style.add_modifier(Modifier::BOLD);
}
if inline.effects.contains(anstyle::Effects::ITALIC) {
style = style.add_modifier(Modifier::ITALIC);
}
if inline.effects.contains(anstyle::Effects::UNDERLINE) {
style = style.add_modifier(Modifier::UNDERLINED);
}
if inline.effects.contains(anstyle::Effects::DIMMED) {
style = style.add_modifier(Modifier::DIM);
}
style
}
fn render_composer(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
let styles = active_styles();
let prefix_style = Style::default()
.fg(color_from_anstyle(styles.primary.get_fg_color()))
.bold();
let prefix = state.prompt_prefix.clone();
let placeholder = state.placeholder.clone();
let mut prefix_w: u16 = 0;
let mut line_spans = Vec::new();
if let Some(label) = state.vim_state.status_label() {
let seg = format!("[{label}] ");
prefix_w = prefix_w.saturating_add(seg.width() as u16);
line_spans.push(Span::styled(
seg,
Style::default()
.fg(color_from_anstyle(styles.tool.get_fg_color()))
.add_modifier(Modifier::BOLD),
));
}
if state.autonomy_mode.is_auto() {
let seg = "[auto] ";
prefix_w = prefix_w.saturating_add(seg.width() as u16);
line_spans.push(Span::styled(
seg,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
}
prefix_w = prefix_w.saturating_add(UnicodeWidthStr::width(prefix.as_str()) as u16);
line_spans.push(Span::styled(prefix, prefix_style));
if state.shell_mode {
let seg = "! ";
prefix_w = prefix_w.saturating_add(seg.width() as u16);
line_spans.push(Span::styled(
seg,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
}
let context_line = composer_context_line(state, area.width);
let used: usize = context_line.spans.iter().map(|s| s.width()).sum();
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
.title(context_line);
if let Some(chip) = composer_brain_chip(state, area.width, used) {
block = block.title(chip);
}
let inner = area.inner(Margin::new(1, 1));
let textarea_area = Rect {
x: inner.left().saturating_add(prefix_w),
y: inner.top(),
width: inner.width.saturating_sub(prefix_w),
height: inner.height,
};
if state.composer.is_empty()
&& let Some(ph) = placeholder.as_deref()
{
line_spans.push(Span::styled(
ph.to_string(),
Style::default()
.fg(color_from_anstyle(styles.secondary.get_fg_color()))
.dim(),
));
let paragraph = Paragraph::new(Line::from(line_spans))
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
if state.input_enabled {
frame.set_cursor_position(Position::new(
inner.left().saturating_add(prefix_w),
area.top().saturating_add(1),
));
}
return;
}
let prefix_area = Rect {
x: inner.left(),
y: inner.top(),
width: prefix_w,
height: inner.height,
};
let frame_paragraph = Paragraph::new(Line::from(Vec::<Span>::new()))
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(frame_paragraph, area);
frame.render_widget(Paragraph::new(Line::from(line_spans)), prefix_area);
frame.render_widget_ref(&state.composer, textarea_area);
if state.input_enabled
&& let Some((cx, cy)) = state
.composer
.cursor_pos_with_state(textarea_area, TextAreaState::default())
{
frame.set_cursor_position(Position::new(cx, cy));
}
}
fn composer_context_line<'a>(state: &'a RenderState, width: u16) -> Line<'a> {
let styles = active_styles();
let primary = color_from_anstyle(styles.primary.get_fg_color());
let fg = color_from_anstyle(Some(styles.foreground));
let muted = color_from_anstyle(styles.secondary.get_fg_color());
let info = color_from_anstyle(styles.info.get_fg_color());
let model = state
.header_context
.model
.strip_prefix(&format!("{}/", state.header_context.provider))
.unwrap_or(&state.header_context.model);
let workspace = state
.cwd
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "workspace".to_string());
let branch = state
.header_context
.persistent_memory
.as_ref()
.map(|badge| badge.text.as_str())
.filter(|branch| !branch.is_empty())
.unwrap_or("—");
let context = match state.context_tokens {
Some(used) => {
let percent = used.saturating_mul(100) / state.context_window.max(1);
format!(
"{}/{} {percent}%",
compact_token_count(used),
compact_token_count(state.context_window)
)
}
None => format!("0/{}", compact_token_count(state.context_window)),
};
use crate::symbols::nerd as icons;
let nerd = state.glyph_set == crate::symbols::GlyphSet::Nerd;
let label =
|text: &'static str, icon: &'static str| -> &'static str { if nerd { icon } else { text } };
let mut fields: Vec<(&str, String, Style, u16)> = vec![
(
label("MODEL ", icons::MODEL),
model.to_string(),
Style::default().fg(fg).add_modifier(Modifier::BOLD),
0,
),
(
label("THINK ", icons::THINK),
state.thinking_level.clone(),
Style::default().fg(info),
58,
),
(
label("DIR ", icons::DIR),
workspace,
Style::default().fg(fg),
82,
),
(
label("GIT ", icons::GIT),
branch.to_string(),
Style::default().fg(fg),
104,
),
(
label("CTX ", icons::CTX),
context,
Style::default().fg(info),
124,
),
];
if state.active_run.is_some() || state.reasoning_stage.is_some() {
fields.push((
label("RUN ", icons::RUN),
state
.reasoning_stage
.clone()
.unwrap_or_else(|| "working\u{2026}".to_string()),
Style::default().fg(primary).add_modifier(Modifier::BOLD),
148,
));
}
let mut spans = Vec::new();
for (i, (label, value, value_style, min_width)) in fields.into_iter().enumerate() {
if width < min_width {
break;
}
if i > 0 {
spans.push(Span::styled(" | ", Style::default().fg(muted)));
}
spans.push(Span::styled(
(*label).to_string(),
Style::default().fg(muted),
));
spans.push(Span::styled(value, value_style));
}
Line::from(spans)
}
fn composer_brain_chip<'a>(state: &'a RenderState, width: u16, used: usize) -> Option<Line<'a>> {
let styles = active_styles();
let (chip_label, healthy) = state.brain.chip_label()?;
let chip_color = if healthy {
color_from_anstyle(styles.info.get_fg_color())
} else {
color_from_anstyle(styles.error.get_fg_color())
};
let nerd = state.glyph_set == crate::symbols::GlyphSet::Nerd;
let text = if nerd {
let state_word = chip_label.trim_start_matches("brain\u{b7}");
format!("{} {}", crate::symbols::nerd::BRAIN, state_word)
} else {
chip_label.to_string()
};
let chip = format!(" {text} ");
let usable = width.saturating_sub(2) as usize;
(used + chip.width() < usable)
.then(|| Line::from(Span::styled(chip, Style::default().fg(chip_color))).right_aligned())
}
fn compact_token_count(tokens: usize) -> String {
if tokens >= 1_000 {
let whole = tokens / 1_000;
let decimal = (tokens % 1_000) / 100;
if decimal == 0 {
format!("{whole}K")
} else {
format!("{whole}.{decimal}K")
}
} else {
tokens.to_string()
}
}
fn render_welcome(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
let styles = active_styles();
let primary = color_from_anstyle(styles.primary.get_fg_color());
let fg = color_from_anstyle(Some(styles.foreground));
let secondary = color_from_anstyle(styles.secondary.get_fg_color());
let workspace = state
.cwd
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "workspace".to_string());
let lines = vec![
Line::from(Span::styled(
"OXICODE",
Style::default().fg(primary).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"Terminal coding assistant",
Style::default().fg(secondary).add_modifier(Modifier::DIM),
)),
Line::from(""),
Line::from(vec![
Span::styled("WORKSPACE ", Style::default().fg(secondary)),
Span::styled(
workspace,
Style::default().fg(fg).add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled("MODEL ", Style::default().fg(secondary)),
Span::styled(
format!(
"{} / {}",
state.header_context.provider, state.header_context.model
),
Style::default().fg(fg),
),
]),
Line::from(""),
Line::from(Span::styled(
"Enter send / commands @ attach a file",
Style::default().fg(fg),
)),
Line::from(Span::styled(
"? shortcuts /model change model /help all commands",
Style::default().fg(secondary),
)),
];
let height = lines.len().min(area.height as usize) as u16;
let card = Rect {
x: area.x,
y: area.y + area.height.saturating_sub(height) / 2,
width: area.width,
height,
};
frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), card);
}
fn render_reasoning_indicator(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
let styles = active_styles();
let indicator_area = Rect {
x: composer_area.x,
y: composer_area.top().saturating_sub(1),
width: composer_area.width,
height: 1,
};
let primary = color_from_anstyle(styles.primary.get_fg_color());
let muted = color_from_anstyle(styles.secondary.get_fg_color());
let spin = RUN_SPINNER[(animation_frame(80) as usize) % RUN_SPINNER.len()];
let stage = state
.reasoning_stage
.as_deref()
.unwrap_or("working\u{2026}");
let mut spans = vec![
Span::styled(spin, Style::default().fg(primary)),
Span::styled(
" RUNNING",
Style::default().fg(primary).add_modifier(Modifier::BOLD),
),
Span::styled(" | ", Style::default().fg(muted)),
Span::styled(
stage.to_string(),
Style::default().fg(muted).add_modifier(Modifier::DIM),
),
];
if let Some(run) = &state.active_run {
let elapsed = format_elapsed_secs(run.started_at.elapsed().as_secs());
let facts = if run.turn > 0 {
format!(
" \u{b7} turn {} \u{b7} {} tool call{} \u{b7} {elapsed}",
run.turn,
run.tool_calls,
if run.tool_calls == 1 { "" } else { "s" },
)
} else {
format!(" \u{b7} {elapsed}")
};
spans.push(Span::styled(
facts,
Style::default().fg(muted).add_modifier(Modifier::DIM),
));
}
spans.push(Span::styled(
" Esc abort \u{b7} Ctrl+C quit",
Style::default().fg(muted).add_modifier(Modifier::DIM),
));
frame.render_widget(Paragraph::new(Line::from(spans)), indicator_area);
}
fn render_pending_quit_hint(frame: &mut Frame<'_>, composer_area: Rect) {
let styles = active_styles();
let hint_area = Rect {
x: composer_area.x,
y: composer_area.top().saturating_sub(1),
width: composer_area.width,
height: 1,
};
let line = Line::from(Span::styled(
"press Ctrl+C again to quit",
Style::default()
.fg(color_from_anstyle(styles.error.get_fg_color()))
.add_modifier(Modifier::BOLD),
));
frame.render_widget(Paragraph::new(line), hint_area);
}
fn render_queue_pane(frame: &mut Frame<'_>, scrollback: Rect, state: &RenderState) -> u16 {
let styles = active_styles();
let entries = &state.queued_inputs;
let interactive = state.queue_panel_open;
let selected = state.queue_selected.min(entries.len().saturating_sub(1));
let height = if interactive {
entries.len() as u16 + 1
} else {
1
};
let area = Rect {
x: scrollback.x,
y: scrollback.y,
width: scrollback.width,
height,
};
let info = color_from_anstyle(styles.info.get_fg_color());
let secondary = color_from_anstyle(styles.secondary.get_fg_color());
let primary = color_from_anstyle(styles.primary.get_fg_color());
if !interactive {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
format!("QUEUED {}", entries.len()),
Style::default().fg(primary).add_modifier(Modifier::BOLD),
),
Span::styled(
" | Ctrl+; manage",
Style::default().fg(secondary).add_modifier(Modifier::DIM),
),
])),
area,
);
return height;
}
let items: Vec<Line<'_>> = entries
.iter()
.enumerate()
.map(|(i, e)| {
let prefix = format!("#{} ", i + 1);
let prefix_style = if i == selected {
Style::default().fg(primary).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(info)
};
let text_style = if i == selected {
Style::default().fg(primary).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(secondary)
};
let marker = if i == selected { "> " } else { " " };
Line::from(vec![
Span::styled(prefix, prefix_style),
Span::styled(marker, prefix_style),
Span::styled(e.clone(), text_style),
])
})
.collect();
frame.render_widget(
Paragraph::new(items).block(Block::default().borders(Borders::TOP).border_style(
Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
)),
area,
);
height
}
fn format_todo_line(todo: &TodoItem, matched: bool, styles: &ThemeStyles) -> Line<'static> {
let notes_marker = match todo.notes.as_ref().map(|n| n.len()).unwrap_or(0) {
0 => String::new(),
n => format!(" ·{n}"),
};
let (marker, color, strike, suffix) = match todo.status {
TodoStatus::Completed => ("✓", styles.foreground, true, String::new()),
TodoStatus::InProgress => (
"▸",
styles.primary.get_fg_color().unwrap_or(styles.foreground),
false,
String::new(),
),
TodoStatus::Abandoned => (
"☐",
styles.error.get_fg_color().unwrap_or(styles.foreground),
true,
String::new(),
),
TodoStatus::Blocked => {
let reason = todo
.block_reason
.as_deref()
.map(|r| format!(" (blocked: {r})"))
.unwrap_or_else(|| " (blocked)".to_string());
(
"☐",
styles.info.get_fg_color().unwrap_or(styles.foreground),
false,
reason,
)
}
TodoStatus::Pending if matched => (
"☐",
styles.primary.get_fg_color().unwrap_or(styles.foreground),
false,
String::new(),
),
TodoStatus::Pending => (
"☐",
styles.secondary.get_fg_color().unwrap_or(styles.foreground),
false,
String::new(),
),
};
let mut text_style = Style::default().fg(color_from_anstyle(Some(color)));
if strike {
text_style = text_style.add_modifier(Modifier::CROSSED_OUT);
}
Line::from(vec![
Span::styled(
format!("{marker} "),
Style::default().fg(color_from_anstyle(Some(color))),
),
Span::styled(
format!("{}{}{}", todo.content, suffix, notes_marker),
text_style,
),
])
}
const TREE_BRANCH: &str = "├─";
const TREE_VERTICAL: &str = "│ ";
const TREE_HOOK: &str = "└";
const SUBSEQUENT_STAGE_CAP: usize = 4;
const ACTIVE_TASK_CAP: usize = 5;
fn active_phase_index(phases: &[&TodoPhase]) -> usize {
phases
.iter()
.position(|p| {
p.tasks
.iter()
.any(|t| matches!(t.status, TodoStatus::Pending | TodoStatus::InProgress))
})
.unwrap_or_else(|| phases.len().saturating_sub(1))
}
fn closed_count(tasks: &[TodoItem]) -> usize {
tasks
.iter()
.filter(|t| matches!(t.status, TodoStatus::Completed | TodoStatus::Abandoned))
.count()
}
fn phase_display_name(name: &str, one_based: usize) -> String {
format!(
"{}. {name}",
oxicode_agent::tools::todo::roman_numeral(one_based)
)
}
fn render_todo_pane(
frame: &mut Frame<'_>,
area: Rect,
phases: &[TodoPhase],
expanded: bool,
is_matched: impl Fn(&TodoItem) -> bool,
) -> u16 {
let phases: Vec<&TodoPhase> = phases.iter().filter(|p| !p.tasks.is_empty()).collect();
if phases.is_empty() {
return 0;
}
let styles = active_styles();
let multi_phase = phases.len() > 1;
let active_idx = active_phase_index(&phases);
let render_tasks = |phase: &TodoPhase| -> Vec<Line<'static>> {
if expanded {
phase
.tasks
.iter()
.map(|t| format_todo_line(t, is_matched(t), &styles))
.collect()
} else {
let sel = oxicode_agent::tools::todo::select_collapsed_todos(
&phase.tasks,
&is_matched,
ACTIVE_TASK_CAP,
);
let mut lines: Vec<Line<'static>> = sel
.items
.iter()
.map(|t| format_todo_line(t, is_matched(t), &styles))
.collect();
if let Some(summary) = sel.summary {
lines.push(Line::from(Span::styled(
summary,
Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
)));
}
lines
}
};
let base_idx = if expanded { 0 } else { active_idx };
let phase_slice: &[&TodoPhase] = if expanded {
&phases[base_idx..]
} else {
&phases[base_idx..(base_idx + 1 + SUBSEQUENT_STAGE_CAP).min(phases.len())]
};
let hidden_stages = phases.len() - base_idx - phase_slice.len();
let mut content_lines: Vec<Line<'static>> = Vec::new();
for (i, phase) in phase_slice.iter().enumerate() {
let one_based = base_idx + i + 1;
let is_active = base_idx + i == active_idx;
let done = closed_count(&phase.tasks);
let header_text = if multi_phase {
format!(
"{} · {done}/{}",
phase_display_name(&phase.name, one_based),
phase.tasks.len()
)
} else {
phase.name.clone()
};
let header_style = if is_active {
Style::default()
.fg(color_from_anstyle(styles.primary.get_fg_color()))
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color()))
};
content_lines.push(Line::from(Span::styled(header_text, header_style)));
if is_active || expanded {
content_lines.extend(render_tasks(phase));
}
}
if hidden_stages > 0 {
content_lines.push(Line::from(Span::styled(
format!(
"… {hidden_stages} more stage{}",
if hidden_stages == 1 { "" } else { "s" }
),
Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
)));
}
let total: usize = phases.iter().map(|p| p.tasks.len()).sum();
let closed: usize = phases.iter().map(|p| closed_count(&p.tasks)).sum();
let path_len = content_lines.len() + 1;
let mut filled = (closed * path_len).checked_div(total).unwrap_or(0);
if closed > 0 {
filled = filled.max(1);
}
if closed < total {
filled = filled.min(path_len.saturating_sub(1));
}
let mut lines: Vec<Line<'static>> = vec![Line::from(Span::styled(
"TODO",
Style::default()
.fg(color_from_anstyle(styles.primary.get_fg_color()))
.add_modifier(Modifier::BOLD),
))];
for (i, content) in content_lines.into_iter().enumerate() {
let glyph = if i == 0 { TREE_BRANCH } else { TREE_VERTICAL };
let glyph_color = if i < filled {
styles.primary.get_fg_color()
} else {
styles.secondary.get_fg_color()
};
let mut spans = vec![Span::styled(
format!(" {glyph}"),
Style::default().fg(color_from_anstyle(glyph_color)),
)];
spans.extend(content.spans);
lines.push(Line::from(spans));
}
let hook_color = if filled >= path_len {
styles.primary.get_fg_color()
} else {
styles.secondary.get_fg_color()
};
lines.push(Line::from(Span::styled(
format!(" {TREE_HOOK}"),
Style::default().fg(color_from_anstyle(hook_color)),
)));
let height = lines.len() as u16;
frame.render_widget(
Paragraph::new(lines),
Rect {
x: area.x,
y: area.y,
width: area.width,
height,
},
);
height
}
const TODO_COMPACT_ROWS_THRESHOLD: u16 = 18;
fn next_actionable_task(phases: &[TodoPhase]) -> Option<&TodoItem> {
let mut first_pending = None;
for phase in phases {
for task in &phase.tasks {
if task.status == TodoStatus::InProgress {
return Some(task);
}
if first_pending.is_none() && task.status == TodoStatus::Pending {
first_pending = Some(task);
}
}
}
first_pending
}
fn render_todo_compact_line(phases: &[TodoPhase]) -> Line<'static> {
let styles = active_styles();
let total: usize = phases.iter().map(|p| p.tasks.len()).sum();
let closed: usize = phases.iter().map(|p| closed_count(&p.tasks)).sum();
let mut spans = vec![Span::styled(
format!("TODO {closed}/{total} "),
Style::default()
.fg(color_from_anstyle(styles.primary.get_fg_color()))
.add_modifier(Modifier::BOLD),
)];
match next_actionable_task(phases) {
Some(task) => spans.extend(format_todo_line(task, false, &styles).spans),
None => spans.push(Span::styled(
"✓ done",
Style::default().fg(color_from_anstyle(Some(styles.foreground))),
)),
}
Line::from(spans)
}
fn refresh_todo_phases(
provider: &std::sync::Arc<dyn TodoStateProvider>,
hub: Option<&crate::app::agent_hub_registry::SharedHubRegistry>,
) -> Vec<TodoPhase> {
let phases = provider.get_phases();
let Some(hub) = hub else {
return phases;
};
let completed: Vec<String> = hub
.snapshot()
.into_iter()
.filter(|(_, e)| {
e.kind == oxicode_sdk::HubKind::Subagent && e.status == oxicode_sdk::HubStatus::Idle
})
.filter_map(|(_, e)| e.current_task)
.collect();
let (updated, mutated) =
oxicode_agent::tools::todo::reconcile_with_subagents(&phases, &completed);
if mutated {
provider.set_phases_sync(updated.clone());
}
updated
}
fn is_todo_list_settled(phases: &[TodoPhase]) -> bool {
let mut seen_task = false;
for phase in phases {
for task in &phase.tasks {
if !matches!(task.status, TodoStatus::Completed | TodoStatus::Abandoned) {
return false;
}
seen_task = true;
}
}
seen_task
}
fn sync_todo_clear_timer(state: &mut RenderState, delay_secs: i64) {
if delay_secs < 0 || !is_todo_list_settled(&state.todo_phases) {
state.todo_clear_deadline = None;
return;
}
if delay_secs == 0 {
state.todo_phases.clear();
state.todo_clear_deadline = None;
return;
}
let deadline = state.todo_clear_deadline.get_or_insert_with(|| {
std::time::Instant::now() + std::time::Duration::from_secs(delay_secs as u64)
});
if std::time::Instant::now() >= *deadline {
state.todo_phases.clear();
state.todo_clear_deadline = None;
}
}
fn build_matched_closure(
hub: Option<&crate::app::agent_hub_registry::SharedHubRegistry>,
) -> impl Fn(&TodoItem) -> bool + '_ {
let active_descs: Vec<String> = hub
.map(|h| {
h.snapshot()
.into_iter()
.filter(|(_, e)| {
e.kind == oxicode_sdk::HubKind::Subagent
&& e.status == oxicode_sdk::HubStatus::Running
})
.filter_map(|(_, e)| e.current_task)
.collect()
})
.unwrap_or_default();
move |t| {
!active_descs.is_empty()
&& oxicode_agent::tools::todo::todo_matches_any_description(&t.content, &active_descs)
}
}
fn render_follow_ups(frame: &mut Frame<'_>, composer_area: Rect, chips: &[String]) {
let styles = active_styles();
let area = Rect {
x: composer_area.x,
y: composer_area.top().saturating_sub(1),
width: composer_area.width,
height: 1,
};
let mut spans = vec![Span::styled(
"Suggestions: ",
Style::default()
.fg(color_from_anstyle(styles.secondary.get_fg_color()))
.add_modifier(Modifier::DIM),
)];
for (i, chip) in chips.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
format!("[{}]", chip),
Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn tip_is_visible(tip: &EphemeralTip, now_tick: u64) -> bool {
now_tick.saturating_sub(tip.born_tick) < tip.ttl_ticks
}
fn render_tip(frame: &mut Frame, composer_area: Rect, text: &str) {
let styles = active_styles();
let area = Rect {
x: composer_area.x,
y: composer_area.top().saturating_sub(1),
width: composer_area.width,
height: 1,
};
let line = Line::styled(
format!(" note: {text}"),
Style::default()
.fg(color_from_anstyle(styles.info.get_fg_color()))
.add_modifier(Modifier::DIM),
);
frame.render_widget(Paragraph::new(line), area);
}
fn render_slash_popup(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
let styles = active_styles();
let items = &state.slash_popup.items;
if items.is_empty() {
return;
}
let max_visible = 7usize;
let visible = items.len().min(max_visible);
let popup_h = visible as u16 + 3; let width = composer_area.width.min(64);
let popup_area = Rect {
x: composer_area.left(),
y: composer_area.top().saturating_sub(popup_h),
width,
height: popup_h,
};
frame.render_widget(Clear, popup_area);
let border_color = color_from_anstyle(styles.secondary.get_fg_color());
let title = Line::from(Span::styled(
" COMMANDS ",
Style::default()
.fg(color_from_anstyle(styles.primary.get_fg_color()))
.add_modifier(Modifier::BOLD),
));
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(border_color))
.title(title);
let inner = block.inner(popup_area);
frame.render_widget(&block, popup_area);
let max_label = items
.iter()
.take(visible)
.map(|i| i.label.chars().count())
.max()
.unwrap_or(0);
let primary = color_from_anstyle(styles.primary.get_fg_color());
let fg = color_from_anstyle(Some(styles.foreground));
let secondary = color_from_anstyle(styles.secondary.get_fg_color());
for (i, item) in items.iter().take(visible).enumerate() {
let is_selected = i == state.slash_popup.selected;
let y = inner.top() + i as u16;
let row_area = Rect {
x: inner.left(),
y,
width: inner.width,
height: 1,
};
let marker = if is_selected { "> " } else { " " };
let label_style = if is_selected {
Style::default().fg(primary).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(fg)
};
let label_padded = format!("{:<width$}", item.label, width = max_label);
let line = Line::from(vec![
Span::styled(marker, label_style),
Span::styled(label_padded, label_style),
Span::raw(" "),
Span::styled(&item.description, Style::default().fg(secondary)),
]);
frame.render_widget(Paragraph::new(line), row_area);
}
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"Enter insert | Up/Down move | Esc close",
Style::default().fg(secondary).add_modifier(Modifier::DIM),
))),
Rect {
x: inner.left(),
y: inner.bottom().saturating_sub(1),
width: inner.width,
height: 1,
},
);
}
fn render_file_search_dropdown(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
let styles = active_styles();
let Some(fs) = &state.file_search else {
return;
};
let items = &fs.results;
if items.is_empty() {
return;
}
let max_visible = 10usize;
let visible = items.len().min(max_visible);
let popup_h = visible as u16 + 3; let width = composer_area.width.min(72);
let popup_area = Rect {
x: composer_area.left(),
y: composer_area.top().saturating_sub(popup_h),
width,
height: popup_h,
};
frame.render_widget(Clear, popup_area);
let border_color = color_from_anstyle(styles.secondary.get_fg_color());
let title_str = if fs.hidden_mode {
" FILES: HIDDEN "
} else {
" FILES "
};
let title = Line::from(Span::styled(
title_str,
Style::default()
.fg(color_from_anstyle(styles.primary.get_fg_color()))
.add_modifier(Modifier::BOLD),
));
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(border_color))
.title(title);
let inner = block.inner(popup_area);
frame.render_widget(&block, popup_area);
let primary = color_from_anstyle(styles.primary.get_fg_color());
let fg = color_from_anstyle(Some(styles.foreground));
let secondary = color_from_anstyle(styles.secondary.get_fg_color());
for (i, result) in items.iter().take(visible).enumerate() {
let is_selected = i == fs.selected;
let y = inner.top() + i as u16;
let row_area = Rect {
x: inner.left(),
y,
width: inner.width,
height: 1,
};
let marker = if is_selected { "> " } else { " " };
let path_style = if is_selected {
Style::default().fg(primary).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(fg)
};
let line = Line::from(vec![
Span::styled(marker, path_style),
Span::styled(&result.path, path_style),
]);
frame.render_widget(Paragraph::new(line), row_area);
}
if popup_h >= 4 {
let hint_y = inner.bottom().saturating_sub(1);
let hint_area = Rect {
x: inner.left(),
y: hint_y,
width: inner.width,
height: 1,
};
let count = items.len();
let hint = format!("{count} files | Tab accept | Esc cancel");
let _ = secondary; frame.render_widget(
Paragraph::new(Line::from(Span::styled(
hint,
Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
)))
.style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color()))),
hint_area,
);
}
}
struct InputEditor<'a> {
composer: &'a mut oxicode_textarea::TextArea,
}
impl<'a> InputEditor<'a> {
fn new(composer: &'a mut oxicode_textarea::TextArea) -> Self {
Self { composer }
}
}
impl<'a> crate::tui_vt::vim::Editor for InputEditor<'a> {
fn content(&self) -> &str {
self.composer.text()
}
fn cursor(&self) -> usize {
self.composer.cursor()
}
fn set_cursor(&mut self, pos: usize) {
self.composer.set_cursor(pos);
}
fn move_left(&mut self) {
let new_pos = self.composer.cursor().saturating_sub(1);
self.composer.set_cursor(new_pos);
}
fn move_right(&mut self) {
let new_pos = self.composer.cursor().saturating_add(1);
self.composer.set_cursor(new_pos);
}
fn delete_char_forward(&mut self) {
self.composer.input(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Delete,
crossterm::event::KeyModifiers::NONE,
));
}
fn insert_text(&mut self, text: &str) {
self.composer.insert_str(text);
}
fn replace(&mut self, content: String, cursor: usize) {
self.composer.set_text(&content);
self.composer.set_cursor(cursor);
}
fn replace_range(&mut self, start: usize, end: usize, text: &str) {
self.composer.replace_range(start..end, text);
}
}
pub(crate) fn plain_segment(text: impl Into<String>) -> InlineSegment {
InlineSegment {
text: text.into(),
style: Arc::new(InlineTextStyle::default()),
}
}
pub(super) fn effective_scroll_offset(offset: usize, total: usize, viewport: usize) -> usize {
if offset == usize::MAX {
return total.saturating_sub(viewport);
}
let max_start = total.saturating_sub(viewport);
offset.min(max_start)
}
fn slash_filter(token: &str, file_commands: &[FileCommand]) -> Vec<SlashPopupItem> {
let builtins = SlashRegistry::builtin_commands();
let builtin_names: std::collections::HashSet<&str> =
builtins.iter().map(|(n, _, _)| *n).collect();
let mut items: Vec<SlashPopupItem> = builtins
.into_iter()
.filter(|(name, _, aliases)| {
token.is_empty()
|| name.starts_with(token)
|| aliases.iter().any(|a| a.starts_with(token))
})
.map(|(name, desc, aliases)| {
let mut label = format!("/{name}");
for a in &aliases {
label.push_str(&format!(", /{a}"));
}
SlashPopupItem {
label,
description: desc.to_string(),
name: name.to_string(),
}
})
.collect();
for fc in file_commands {
if builtin_names.contains(fc.name.as_str())
|| fc
.aliases
.iter()
.any(|alias| builtin_names.contains(alias.as_str()))
{
continue;
}
if token.is_empty()
|| fc.name.starts_with(token)
|| fc.aliases.iter().any(|a| a.starts_with(token))
{
let mut label = format!("/{}", fc.name);
for a in &fc.aliases {
label.push_str(&format!(", /{a}"));
}
items.push(SlashPopupItem {
label,
description: fc.description.clone(),
name: fc.name.clone(),
});
}
}
items
}
fn refresh_slash_popup(state: &mut RenderState) {
let buf = state.composer.text();
let active = buf.starts_with('/') && !buf[1..].contains(' ');
if !active {
state.slash_popup.open = false;
state.slash_popup.items.clear();
state.slash_popup.selected = 0;
return;
}
let token = &buf[1..];
let items = slash_filter(token, &state.file_commands);
state.slash_popup.open = !items.is_empty();
if items.is_empty() {
state.slash_popup.selected = 0;
} else {
state.slash_popup.selected = state.slash_popup.selected.min(items.len() - 1);
}
state.slash_popup.items = items;
}
fn refresh_input_popups(state: &mut RenderState) {
refresh_slash_popup(state);
refresh_file_search(state);
}
fn refresh_file_search(state: &mut RenderState) {
use crate::tui_vt::file_search;
if state.slash_popup.open {
state.file_search = None;
return;
}
match file_search::parse_at_cursor(state.composer.text(), state.composer.cursor()) {
Some(token) => match &mut state.file_search {
None => {
let cwd = state.cwd.clone();
state.file_search = Some(file_search::open(&cwd, token.at_offset, false));
}
Some(fs) => {
if fs.query != token.path_query {
fs.refresh(&token.path_query);
}
}
},
None => state.file_search = None,
}
}
fn accept_file_search(state: &mut RenderState, line_mode: bool) -> bool {
use crate::tui_vt::file_search;
let Some(fs) = &state.file_search else {
return false;
};
let Some(result) = fs.selected_result().cloned() else {
return false;
};
let at_offset = fs.at_offset;
let text = file_search::insertion_text(&result.path, None, line_mode);
let cursor_end = state.composer.cursor();
state.composer.replace_range(
at_offset..cursor_end.min(state.composer.text().len()),
&text,
);
state.composer.set_cursor(at_offset + text.len());
state.file_search = None;
true
}
fn preview_tool_result(content: &str) -> String {
const MAX: usize = 500;
if content.chars().count() <= MAX {
return content.to_string();
}
let truncated: String = content.chars().take(MAX).collect();
format!("{truncated}\u{2026}")
}
fn extract_generated_png(content: &str) -> Option<Vec<u8>> {
use base64::{Engine, engine::general_purpose};
const MARKER: &str = "base64):";
let rest = &content[content.find(MARKER)? + MARKER.len()..];
let blob = rest.lines().map(str::trim).find(|l| !l.is_empty())?;
if blob.is_empty() {
return None;
}
let bytes = general_purpose::STANDARD.decode(blob).ok()?;
(bytes.len() >= 8).then_some(bytes)
}
fn color_from_anstyle(color: Option<anstyle::Color>) -> Color {
match color {
Some(anstyle::Color::Ansi(a)) => ansi_to_ratatui(a),
Some(anstyle::Color::Ansi256(idx)) => Color::Indexed(idx.0),
Some(anstyle::Color::Rgb(rgb)) => Color::Rgb(rgb.0, rgb.1, rgb.2),
None => Color::Reset,
}
}
fn ansi_to_ratatui(color: anstyle::AnsiColor) -> Color {
use anstyle::AnsiColor as A;
match color {
A::Black => Color::Black,
A::Red => Color::Red,
A::Green => Color::Green,
A::Yellow => Color::Yellow,
A::Blue => Color::Blue,
A::Magenta => Color::Magenta,
A::Cyan => Color::Cyan,
A::White => Color::Gray,
A::BrightBlack => Color::DarkGray,
A::BrightRed => Color::LightRed,
A::BrightGreen => Color::LightGreen,
A::BrightYellow => Color::LightYellow,
A::BrightBlue => Color::LightBlue,
A::BrightMagenta => Color::LightMagenta,
A::BrightCyan => Color::LightCyan,
A::BrightWhite => Color::White,
}
}
#[allow(dead_code, clippy::declare_interior_mutable_const)]
const _ATOMIC_REFS: (AtomicBool, Ordering) = (AtomicBool::new(false), Ordering::SeqCst);
#[cfg(test)]
mod slash_popup_tests {
use super::*;
#[test]
fn empty_token_lists_all_commands() {
let items = slash_filter("", &[]);
assert!(items.len() >= 7);
assert!(items.iter().any(|i| i.name == "quit"));
assert!(items.iter().any(|i| i.name == "clear"));
assert!(items.iter().any(|i| i.name == "model"));
}
#[test]
fn prefix_filter_matches_name() {
let items = slash_filter("qu", &[]);
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "quit");
assert!(items[0].label.contains("/quit"));
}
#[test]
fn prefix_filter_matches_alias() {
let items = slash_filter("cl", &[]);
let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
assert!(names.contains(&"clear"));
}
#[test]
fn file_commands_appear_in_filter() {
let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
"review",
"---\ndescription: proj cmd\naliases: cr\n---\nbody",
);
let items = slash_filter("", &[fc]);
assert!(items.iter().any(|i| i.name == "review"));
assert!(items.iter().any(|i| i.name == "quit")); }
#[test]
fn file_commands_filtered_by_prefix() {
let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
"review",
"---\ndescription: x\n---\nbody",
);
let items = slash_filter("rev", &[fc]);
assert!(items.iter().any(|i| i.name == "review"));
}
#[test]
fn file_commands_shadowed_by_builtins_are_dropped() {
let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
"quit",
"---\ndescription: hijack\n---\nbody",
);
let items = slash_filter("", &[fc]);
let quit_count = items.iter().filter(|i| i.name == "quit").count();
assert_eq!(quit_count, 1, "shadowed file command must not appear");
assert!(
items
.iter()
.any(|i| i.name == "quit" && !i.description.contains("hijack"))
);
}
#[test]
fn file_commands_with_builtin_aliases_are_dropped() {
let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
"review",
"---\ndescription: hijack\naliases: quit\n---\nbody",
);
let items = slash_filter("", &[fc]);
assert!(!items.iter().any(|item| item.name == "review"));
}
#[test]
fn popup_opens_on_slash() {
let mut state = RenderState::default();
state.composer.set_text("/");
refresh_input_popups(&mut state);
assert!(state.slash_popup.open);
assert!(!state.slash_popup.items.is_empty());
}
#[test]
fn popup_closes_on_space() {
let mut state = RenderState::default();
state.composer.set_text("/quit ");
refresh_input_popups(&mut state);
assert!(!state.slash_popup.open);
}
#[test]
fn popup_closes_on_non_slash() {
let mut state = RenderState::default();
state.composer.set_text("hello");
refresh_input_popups(&mut state);
assert!(!state.slash_popup.open);
}
#[test]
fn popup_filters_as_user_types() {
let mut state = RenderState::default();
state.composer.set_text("/m");
refresh_input_popups(&mut state);
assert!(state.slash_popup.open);
assert!(
state
.slash_popup
.items
.iter()
.all(|i| i.name.starts_with('m'))
);
}
#[test]
fn popup_selection_clamps_on_shrink() {
let mut state = RenderState::default();
state.composer.set_text("/");
refresh_input_popups(&mut state);
let full_count = state.slash_popup.items.len();
state.slash_popup.selected = full_count - 1;
state.composer.set_text("/qu");
refresh_input_popups(&mut state);
assert!(state.slash_popup.selected < state.slash_popup.items.len());
}
}
#[cfg(test)]
mod keymap_dispatch_tests {
use super::*;
use crate::tui_vt::keymap::Keymap;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn press(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, mods)
}
fn keymap_with(overrides: &[(&str, &str)]) -> Keymap {
let mut o = std::collections::HashMap::new();
for (action, combo) in overrides {
o.insert(action.to_string(), vec![combo.to_string()]);
}
Keymap::from_settings(&o)
}
fn default_keymap() -> Keymap {
Keymap::from_settings(&std::collections::HashMap::new())
}
#[test]
fn rebound_submit_fires_through_generic_dispatch() {
let km = keymap_with(&[("Submit", "Alt+s")]);
let alt_s = press(KeyCode::Char('s'), KeyModifiers::ALT);
assert_eq!(keymap_pre_match(&km, &alt_s, false), KeymapDispatch::Submit);
assert_eq!(keymap_pre_match(&km, &alt_s, true), KeymapDispatch::Submit);
let enter = press(KeyCode::Enter, KeyModifiers::NONE);
assert_eq!(keymap_pre_match(&km, &enter, false), KeymapDispatch::None);
assert_eq!(keymap_pre_match(&km, &enter, true), KeymapDispatch::None);
}
#[test]
fn default_submit_dispatch_preserves_muscle_memory() {
let km = default_keymap();
let enter = press(KeyCode::Enter, KeyModifiers::NONE);
let shift_enter = press(KeyCode::Enter, KeyModifiers::SHIFT);
assert_eq!(keymap_pre_match(&km, &enter, false), KeymapDispatch::Submit);
assert_eq!(keymap_pre_match(&km, &enter, true), KeymapDispatch::None);
assert_eq!(
keymap_pre_match(&km, &shift_enter, true),
KeymapDispatch::Submit
);
}
#[test]
fn scroll_and_help_dispatch_via_keymap() {
let km = default_keymap();
assert_eq!(
keymap_pre_match(&km, &press(KeyCode::PageUp, KeyModifiers::NONE), false),
KeymapDispatch::ScrollPageUp
);
assert_eq!(
keymap_pre_match(&km, &press(KeyCode::PageDown, KeyModifiers::NONE), false),
KeymapDispatch::ScrollPageDown
);
let km = keymap_with(&[("ScrollUp", "Ctrl+u")]);
assert_eq!(
keymap_pre_match(
&km,
&press(KeyCode::Char('u'), KeyModifiers::CONTROL),
false
),
KeymapDispatch::ScrollPageUp
);
let km = default_keymap();
assert_eq!(
keymap_pre_match(&km, &press(KeyCode::Char('?'), KeyModifiers::NONE), false),
KeymapDispatch::None
);
let km = keymap_with(&[("Help", "Ctrl+PageUp")]);
assert_eq!(
keymap_pre_match(&km, &press(KeyCode::PageUp, KeyModifiers::CONTROL), false),
KeymapDispatch::Help
);
assert_eq!(
keymap_pre_match(&km, &press(KeyCode::Char('x'), KeyModifiers::NONE), false),
KeymapDispatch::None
);
}
}
#[cfg(test)]
mod render_tests {
use super::*;
use oxicode_vtui::tui::core::{InlineHandle, OverlayEvent};
use ratatui::{Terminal, backend::TestBackend};
use tokio::sync::mpsc;
fn render_frame_to_string(state: &RenderState) -> String {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("backend");
let (tx, _rx) = mpsc::unbounded_channel();
let handle = InlineHandle::new_for_tests(tx);
terminal
.draw(|f| render_frame(f, state, &handle))
.expect("draw");
let buf = terminal.backend().buffer();
let area = buf.area();
let mut out = String::new();
for y in 0..area.height {
for x in 0..area.width {
if let Some(cell) = buf.cell((x, y)) {
out.push_str(cell.symbol());
}
}
out.push('\n');
}
out
}
#[allow(dead_code)]
fn render_frame_to_string_at(state: &RenderState, width: u16, height: u16) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("backend");
let (tx, _rx) = mpsc::unbounded_channel();
let handle = InlineHandle::new_for_tests(tx);
terminal
.draw(|f| render_frame(f, state, &handle))
.expect("draw");
let buf = terminal.backend().buffer();
let area = buf.area();
let mut out = String::new();
for y in 0..area.height {
for x in 0..area.width {
if let Some(cell) = buf.cell((x, y)) {
out.push_str(cell.symbol());
}
}
out.push('\n');
}
out
}
fn terminal_caret(state: &RenderState) -> Option<(u16, u16)> {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("backend");
let (tx, _rx) = mpsc::unbounded_channel();
let handle = InlineHandle::new_for_tests(tx);
terminal
.draw(|f| render_frame(f, state, &handle))
.expect("draw");
terminal
.get_cursor_position()
.ok()
.map(|position| (position.x, position.y))
}
#[test]
fn composer_caret_aligns_after_ascii() {
let mut state = RenderState::default();
state.prompt_prefix = "> ".to_string();
state.input_enabled = true;
let mut composer = oxicode_textarea::TextArea::new();
composer.set_text("hello");
composer.set_cursor(5);
state.composer = composer;
let caret = terminal_caret(&state);
assert_eq!(
caret,
Some((9, 22)),
"ASCII caret must sit right after '> hello'"
);
}
#[test]
fn composer_caret_aligns_after_cjk_display_columns() {
let mut state = RenderState::default();
state.prompt_prefix = "> ".to_string();
state.input_enabled = true;
let body = "안녕";
let mut composer = oxicode_textarea::TextArea::new();
composer.set_text(body);
composer.set_cursor(body.len()); state.composer = composer;
let caret = terminal_caret(&state);
assert_eq!(
caret,
Some((8, 22)),
"CJK caret must sit after 4 display columns (not 6 bytes)"
);
}
#[test]
fn composer_caret_aligns_after_mixed_ascii_cjk() {
let body = "hi안녕";
let mut state = RenderState::default();
state.prompt_prefix = "> ".to_string();
state.input_enabled = true;
let mut composer = oxicode_textarea::TextArea::new();
composer.set_text(body);
composer.set_cursor(body.len()); state.composer = composer;
let caret = terminal_caret(&state);
assert_eq!(
caret,
Some((10, 22)),
"Mixed caret must sit after 6 display columns"
);
}
#[test]
fn agent_session_event_reaches_the_transcript_bridge() {
let (tx, mut rx) = mpsc::unbounded_channel();
let handle = InlineHandle::new_for_tests(tx);
let mut state = RenderState::default();
handle_session_event(
&mut state,
&handle,
&SessionEvent::Agent(Box::new(AgentEvent::TextChunk {
text: "streamed reply".to_string(),
})),
None,
);
let command = rx
.try_recv()
.expect("an agent event must produce a render command");
apply_command(&mut state, command);
assert_eq!(state.transcript.len(), 1);
assert_eq!(state.transcript[0].kind, InlineMessageKind::Agent);
assert_eq!(state.transcript[0].segments[0].text, "streamed reply");
}
#[test]
fn missing_key_errors_are_distinguished_from_other_provider_failures() {
assert!(is_missing_api_key_error(
"Provider stream error: Missing API key — configure a credential"
));
assert!(!is_missing_api_key_error("Provider returned HTTP 429"));
assert_eq!(
provider_from_model_id("deepseek/deepseek-v4-flash"),
"deepseek"
);
}
#[test]
fn prompt_queue_mutations_change_the_execution_queue() {
let queue = PromptQueue::default();
queue.enqueue("first".to_string());
queue.enqueue("second".to_string());
queue.enqueue("third".to_string());
assert!(queue.move_by(2, -1));
assert_eq!(queue.remove(0).as_deref(), Some("first"));
let pending: Vec<_> = queue.pending.lock().iter().cloned().collect();
assert_eq!(pending, ["third", "second"]);
}
#[test]
fn welcome_screen_shown_when_transcript_empty() {
let state = RenderState::default();
let rendered = render_frame_to_string(&state);
assert!(
rendered.contains("OXICODE") && rendered.contains("WORKSPACE"),
"welcome banner must appear when transcript is empty"
);
}
#[test]
fn composer_is_painted() {
let mut state = RenderState::default();
state.input_enabled = true;
state.prompt_prefix = "> ".to_string();
let rendered = render_frame_to_string(&state);
assert!(
rendered.contains('>'),
"composer prompt prefix must be painted"
);
}
#[test]
fn slash_popup_renders_command_list() {
let mut state = RenderState::default();
state.slash_popup.open = true;
state.slash_popup.items = slash_filter("", &[]);
let rendered = render_frame_to_string(&state);
assert!(rendered.contains("COMMANDS"), "popup title must render");
assert!(rendered.contains("/quit"), "popup must list /quit");
}
#[test]
fn composer_and_popup_render_together() {
let mut state = RenderState::default();
state.prompt_prefix = "> ".to_string();
state.composer.set_text("/qu");
state.slash_popup.open = true;
state.slash_popup.items = slash_filter("qu", &[]);
let rendered = render_frame_to_string(&state);
assert!(rendered.contains("COMMANDS"), "popup must render");
assert!(rendered.contains("/quit"), "popup must list /quit");
assert!(rendered.contains('>'), "composer must still render");
}
#[test]
fn transcript_wraps_long_lines() {
let mut state = RenderState::default();
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Agent,
segments: vec![plain_segment(
"This is a very long agent response line that should wrap across multiple terminal rows when rendered at a narrow width.".to_string()
)],
block_id: 0,
});
let backend = TestBackend::new(40, 24);
let mut terminal = Terminal::new(backend).expect("backend");
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let handle = InlineHandle::new_for_tests(tx);
terminal
.draw(|f| render_frame(f, &state, &handle))
.expect("draw");
let buf = terminal.backend().buffer();
let mut full = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
if let Some(cell) = buf.cell((x, y)) {
full.push_str(cell.symbol());
}
}
full.push('\n');
}
assert!(
!full.contains("wrap"),
"long line is clamped at the viewport edge — content past col 40 must be dropped, not wrapped"
);
assert!(
full.contains("This"),
"the truncated prefix of the clamped line is visible: {full:?}"
);
}
fn sample_overlay_items() -> Vec<OverlayListItem> {
vec![
OverlayListItem {
title: "model-a".to_string(),
subtitle: Some("first".to_string()),
badge: Some("ready".to_string()),
indent: 0,
search_value: None,
selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(0)),
},
OverlayListItem {
title: "model-b".to_string(),
subtitle: None,
badge: None,
indent: 0,
search_value: None,
selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(1)),
},
OverlayListItem {
title: "model-c".to_string(),
subtitle: None,
badge: None,
indent: 0,
search_value: None,
selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(2)),
},
]
}
#[test]
fn overlay_renders_title_and_items() {
let mut state = RenderState::default();
state.overlay = Some(OverlayState {
title: "Select model".to_string(),
lines: vec!["Pick one".to_string()],
items: sample_overlay_items(),
selected: 0,
search: None,
secure_input: None,
..Default::default()
});
let rendered = render_frame_to_string(&state);
assert!(
rendered.contains("Select model"),
"overlay title must render"
);
assert!(rendered.contains("model-a"), "first item must render");
assert!(rendered.contains("model-b"), "second item must render");
assert!(rendered.contains("model-c"), "third item must render");
assert!(
rendered.contains("Pick one"),
"descriptive line must render"
);
}
#[test]
fn overlay_search_filters_items() {
let mut state = RenderState::default();
state.overlay = Some(OverlayState {
title: "Select".to_string(),
lines: Vec::new(),
items: sample_overlay_items(),
selected: 0,
search: Some(OverlaySearchState {
label: "filter".to_string(),
placeholder: Some("type".to_string()),
value: "model-b".to_string(),
}),
secure_input: None,
..Default::default()
});
let rendered = render_frame_to_string(&state);
assert!(rendered.contains("model-b"), "matching item must render");
assert!(
!rendered.contains("model-a"),
"non-matching item must not render (got: {})",
rendered
);
assert!(
!rendered.contains("model-c"),
"non-matching item must not render"
);
}
#[test]
fn overlay_keyboard_nav_moves_selection() {
let mut state = RenderState::default();
state.overlay = Some(OverlayState {
title: "Select".to_string(),
lines: Vec::new(),
items: sample_overlay_items(),
selected: 0,
search: None,
secure_input: None,
..Default::default()
});
let state_arc = Arc::new(parking_lot::Mutex::new(state));
let (tx, mut _rx) = mpsc::unbounded_channel();
assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
assert!(consumed, "Down must be consumed while overlay is open");
assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 1);
let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
assert!(consumed);
assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
assert!(consumed);
assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Up);
assert!(consumed);
assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
assert!(consumed);
assert!(
state_arc.lock().overlay.is_none(),
"overlay must be cleared after Enter"
);
let evt = _rx.try_recv().expect("submit event must arrive");
match evt {
InlineEvent::Overlay(OverlayEvent::Submitted(_)) => {}
other => panic!("expected Submitted overlay event, got {other:?}"),
}
}
#[test]
fn overlay_esc_closes_and_emits_cancelled() {
let mut state = RenderState::default();
state.overlay = Some(OverlayState {
title: "Select".to_string(),
lines: Vec::new(),
items: sample_overlay_items(),
selected: 0,
search: None,
secure_input: None,
..Default::default()
});
let state_arc = Arc::new(parking_lot::Mutex::new(state));
let (tx, mut rx) = mpsc::unbounded_channel();
let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Esc);
assert!(consumed);
assert!(
state_arc.lock().overlay.is_none(),
"overlay must be cleared after Esc"
);
let evt = rx.try_recv().expect("cancel event must arrive");
assert!(
matches!(evt, InlineEvent::Overlay(OverlayEvent::Cancelled)),
"expected Cancelled overlay event"
);
}
#[test]
fn overlay_enter_on_readonly_item_is_noop() {
let mut state = RenderState::default();
state.overlay = Some(OverlayState {
title: "Tools".to_string(),
lines: Vec::new(),
items: vec![OverlayListItem {
title: "read".to_string(),
subtitle: Some("Read a file".to_string()),
badge: None,
indent: 0,
search_value: None,
selection: None,
}],
selected: 0,
search: None,
secure_input: None,
..Default::default()
});
let state_arc = Arc::new(parking_lot::Mutex::new(state));
let (tx, mut rx) = mpsc::unbounded_channel();
let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
assert!(consumed, "Enter must be consumed even on read-only items");
assert!(
state_arc.lock().overlay.is_some(),
"overlay must stay open when Enter hits a read-only item"
);
assert!(
rx.try_recv().is_err(),
"no overlay event must be emitted for a read-only Enter"
);
}
#[test]
fn overlay_chars_route_to_search_field() {
let mut state = RenderState::default();
state.overlay = Some(OverlayState {
title: "Select".to_string(),
lines: Vec::new(),
items: sample_overlay_items(),
selected: 0,
search: Some(OverlaySearchState {
label: "filter".to_string(),
placeholder: None,
value: String::new(),
}),
secure_input: None,
..Default::default()
});
let state_arc = Arc::new(parking_lot::Mutex::new(state));
let (tx, _rx) = mpsc::unbounded_channel();
handle_overlay_key(&state_arc, &tx, KeyCode::Char('m'));
handle_overlay_key(&state_arc, &tx, KeyCode::Char('o'));
handle_overlay_key(&state_arc, &tx, KeyCode::Backspace);
let value = state_arc
.lock()
.overlay
.as_ref()
.unwrap()
.search
.as_ref()
.unwrap()
.value
.clone();
assert_eq!(value, "m", "Backspace should drop last char");
}
#[test]
fn overlay_key_no_op_when_no_overlay_open() {
let state = RenderState::default();
let state_arc = Arc::new(parking_lot::Mutex::new(state));
let (tx, _rx) = mpsc::unbounded_channel();
let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
assert!(
!consumed,
"handle_overlay_key must return false when no overlay is open"
);
}
#[test]
fn apply_command_show_overlay_populates_state() {
use oxicode_vtui::tui::core::{InlineListItem, ListOverlayRequest};
let mut state = RenderState::default();
let items = vec![
InlineListItem {
title: "alpha".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: None,
search_value: None,
},
InlineListItem {
title: "beta".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: None,
search_value: None,
},
];
let request = OverlayRequest::List(ListOverlayRequest {
title: "Pick".to_string(),
lines: vec!["desc".to_string()],
footer_hint: None,
items,
selected: None,
search: None,
hotkeys: Vec::new(),
});
let shutdown = apply_command(
&mut state,
InlineCommand::ShowOverlay {
request: Box::new(request),
},
);
assert!(!shutdown, "ShowOverlay must not request shutdown");
let overlay = state.overlay.as_ref().expect("overlay must be Some");
assert_eq!(overlay.title, "Pick");
assert_eq!(overlay.items.len(), 2);
assert_eq!(overlay.items[0].title, "alpha");
assert_eq!(overlay.items[1].title, "beta");
assert_eq!(overlay.lines.len(), 1);
apply_command(&mut state, InlineCommand::CloseOverlay);
assert!(state.overlay.is_none(), "CloseOverlay must clear state");
}
#[test]
fn materialize_overlay_modal_with_secure_prompt_populates_secure_input() {
use oxicode_vtui::tui::core::{ModalOverlayRequest, SecurePromptConfig};
let request = OverlayRequest::Modal(ModalOverlayRequest {
title: "API key".into(),
lines: vec!["Paste your key".into()],
secure_prompt: Some(SecurePromptConfig {
label: "Key".into(),
placeholder: Some("sk-...".into()),
mask_input: true,
}),
});
let state = materialize_overlay(request);
let secure = state
.secure_input
.expect("secure_input must be Some when secure_prompt is Some");
assert_eq!(secure.config.label, "Key");
assert!(secure.config.mask_input);
assert_eq!(secure.editor.text(), "");
assert_eq!(secure.editor.cursor_byte(), 0);
}
#[test]
fn materialize_overlay_modal_without_secure_prompt_has_none_secure_input() {
use oxicode_vtui::tui::core::ModalOverlayRequest;
let request = OverlayRequest::Modal(ModalOverlayRequest {
title: "Confirm".into(),
lines: vec!["y/n".into()],
secure_prompt: None,
});
let state = materialize_overlay(request);
assert!(
state.secure_input.is_none(),
"secure_input must be None when secure_prompt is None"
);
}
fn three_block_transcript() -> Vec<TranscriptLine> {
vec![
TranscriptLine {
kind: InlineMessageKind::User,
segments: vec![plain_segment("hi")],
block_id: 0,
},
TranscriptLine {
kind: InlineMessageKind::Agent,
segments: vec![plain_segment("hello")],
block_id: 1,
},
TranscriptLine {
kind: InlineMessageKind::Agent,
segments: vec![plain_segment("world")],
block_id: 1,
},
TranscriptLine {
kind: InlineMessageKind::User,
segments: vec![plain_segment("bye")],
block_id: 2,
},
]
}
#[test]
fn fold_all_collapses_every_block() {
let mut state = RenderState::default();
state.transcript = three_block_transcript();
state.fold_all();
assert_eq!(state.block_display.len(), 3, "3 distinct block ids");
assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
assert_eq!(state.block_mode(1), BlockDisplayMode::Collapsed);
assert_eq!(state.block_mode(2), BlockDisplayMode::Collapsed);
}
#[test]
fn expand_all_after_fold_all_shows_expanded() {
let mut state = RenderState::default();
state.transcript = three_block_transcript();
state.fold_all();
state.expand_all();
assert!(
state.block_display.is_empty(),
"Expanded is the default — no overrides"
);
assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
assert_eq!(state.block_mode(2), BlockDisplayMode::Expanded);
}
#[test]
fn truncate_all_sets_explicit_truncated() {
let mut state = RenderState::default();
state.transcript = three_block_transcript();
state.fold_all();
state.truncate_all();
assert_eq!(state.block_display.len(), 3);
assert_eq!(state.block_mode(1), BlockDisplayMode::Truncated);
}
#[test]
fn default_block_mode_is_expanded() {
let state = RenderState::default();
assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
assert!(state.block_display.is_empty(), "default needs no map entry");
}
#[test]
fn cycle_block_advances_through_three_states() {
let mut state = RenderState::default();
state.transcript = three_block_transcript();
state.scroll_offset = 0; state.cycle_block_at_view();
assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
state.cycle_block_at_view();
assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
state.cycle_block_at_view();
assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
assert!(!state.block_display.contains_key(&0));
}
#[test]
fn cancel_grace_field_defaults_none() {
let state = RenderState::default();
assert!(
state.cancel_grace_until.is_none(),
"cancel_grace_until must default to None"
);
}
#[test]
fn cancel_routes_to_interrupt_when_streaming() {
assert_eq!(
route_cancel(true),
CancelRoute::Interrupt,
"Esc while streaming must route through the interrupt path"
);
}
#[test]
fn cancel_routes_to_exit_when_idle() {
assert_eq!(
route_cancel(false),
CancelRoute::Exit,
"Esc while idle must exit immediately (one-press quit)"
);
}
#[test]
fn no_scrollbar_even_when_content_overflows() {
let mut state = RenderState::default();
for i in 0..40u32 {
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Agent,
segments: vec![plain_segment(format!("line {i}"))],
block_id: i as usize,
});
}
let rendered = render_frame_to_string(&state);
assert!(
!rendered.contains('\u{2588}'),
"no scrollbar thumb (█): native scrollback owns history"
);
}
#[test]
fn confirmation_modal_renders_title() {
let mut state = RenderState::default();
state.confirmation = Some(quit_confirmation());
let rendered = render_frame_to_string(&state);
assert!(
rendered.contains("Quit oxicode?"),
"confirmation title must render"
);
}
#[test]
fn confirmation_yes_sends_exit_and_closes() {
let mut state = RenderState::default();
state.confirmation = Some(quit_confirmation());
let state_arc = Arc::new(parking_lot::Mutex::new(state));
let (tx, mut rx) = mpsc::unbounded_channel();
let (issue_tx, _issue_rx) = mpsc::unbounded_channel();
handle_confirmation_key(&state_arc, &tx, &issue_tx, KeyCode::Char('y'));
assert!(
state_arc.lock().confirmation.is_none(),
"yes must close the modal"
);
let ev = rx.try_recv().expect("yes must send an event");
assert!(matches!(ev, InlineEvent::Exit), "yes must send Exit");
}
#[test]
fn confirmation_no_closes_without_event() {
let mut state = RenderState::default();
state.confirmation = Some(quit_confirmation());
let state_arc = Arc::new(parking_lot::Mutex::new(state));
let (tx, mut rx) = mpsc::unbounded_channel();
let (issue_tx, _issue_rx) = mpsc::unbounded_channel();
handle_confirmation_key(&state_arc, &tx, &issue_tx, KeyCode::Char('n'));
assert!(
state_arc.lock().confirmation.is_none(),
"no must close the modal"
);
assert!(rx.try_recv().is_err(), "no must not send an event");
}
#[test]
fn tip_banner_renders_when_active() {
let mut state = RenderState::default();
let now_tick = FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed);
state.tip = Some(EphemeralTip {
text: "hello-tip-marker".to_string(),
born_tick: now_tick,
ttl_ticks: 100,
key: "test",
ambient: false,
});
let rendered = render_frame_to_string(&state);
assert!(
rendered.contains("hello-tip-marker"),
"active tip must render above the composer"
);
}
#[test]
fn tip_visible_within_ttl_window() {
let tip = EphemeralTip {
text: "x".to_string(),
born_tick: 10,
ttl_ticks: 5,
key: "test",
ambient: false,
};
assert!(tip_is_visible(&tip, 12), "within TTL must be visible");
assert!(
!tip_is_visible(&tip, 15),
"at TTL boundary (born + ttl) must expire"
);
assert!(!tip_is_visible(&tip, 99), "past TTL must expire");
}
#[test]
fn sticky_header_pins_block_head_when_scrolled_into_body() {
let mut state = RenderState::default();
for i in 0..40u32 {
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Agent,
segments: vec![plain_segment(format!("body-line-{i:02}"))],
block_id: 0,
});
}
state.scroll_offset = 10;
let rendered = render_frame_to_string(&state);
assert!(
rendered.contains("body-line-00"),
"sticky header must pin the block head when scrolled into the body"
);
}
#[test]
fn sticky_header_absent_when_viewport_at_block_head() {
let mut state = RenderState::default();
for i in 0..40u32 {
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Agent,
segments: vec![plain_segment(format!("head-line-{i:02}"))],
block_id: 0,
});
}
state.scroll_offset = 0;
let rendered = render_frame_to_string(&state);
assert!(rendered.contains("head-line-00"));
}
#[test]
fn turn_end_drains_queue_head() {
let mut state = RenderState::default();
state.queued_inputs = vec!["queued-1".into(), "queued-2".into()];
state.drain_queue_head();
assert_eq!(
state.queued_inputs.len(),
1,
"drain_queue_head must drop the head (now running)"
);
assert_eq!(state.queued_inputs[0], "queued-2");
}
#[test]
fn render_frame_paints_transcript_content() {
let mut state = RenderState::default();
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Agent,
segments: vec![plain_segment("frame-content-marker-xyz")],
block_id: 0,
});
let rendered = render_frame_to_string(&state);
assert!(
rendered.contains("frame-content-marker-xyz"),
"render_frame must paint transcript content"
);
}
#[test]
fn user_turns_get_one_blank_spacer_row() {
let mut state = RenderState::default();
state.transcript = vec![
TranscriptLine {
kind: InlineMessageKind::Agent,
segments: vec![plain_segment("agent-answer")],
block_id: 0,
},
TranscriptLine {
kind: InlineMessageKind::User,
segments: vec![plain_segment("next-question")],
block_id: 1,
},
];
let rendered = render_frame_to_string(&state);
let rows: Vec<&str> = rendered.split('\n').collect();
let agent_row = rows
.iter()
.position(|r| r.contains("agent-answer"))
.expect("agent row");
assert!(
rows[agent_row + 1].trim().is_empty(),
"blank spacer between turns: {:?}",
&rows[agent_row..agent_row + 3]
);
assert!(
rows[agent_row + 2].contains("next-question"),
"user line follows the spacer"
);
}
#[test]
fn transcript_snapshot_at_120_cols_matches_role_layout() {
let mut state = RenderState::default();
state.brain = BrainChip::Ok;
state.append_line(
InlineMessageKind::User,
vec![plain_segment("intro message\nsecond line")],
);
state.append_line(
InlineMessageKind::Agent,
vec![plain_segment("answer paragraph line one\nline two")],
);
state.append_line(
InlineMessageKind::User,
vec![plain_segment("follow-up question")],
);
let rendered = render_frame_to_string_at(&state, 120, 24);
let rows: Vec<&str> = rendered.split('\n').collect();
let first_user = rows
.iter()
.position(|row| row.contains("intro message"))
.expect("intro user row");
let continuation = rows
.iter()
.position(|row| row.contains("second line"))
.expect("user continuation visible");
assert_eq!(
continuation,
first_user + 1,
"user continuation on next row"
);
assert!(
!rows[first_user].contains("> "),
"plain style has no prompt glyph: {rows:?}"
);
let agent_row = rows
.iter()
.position(|row| row.contains("answer paragraph"))
.expect("agent row");
assert_eq!(
agent_row,
continuation + 2,
"one blank row separates request from response: {:?}",
&rows[continuation..=agent_row]
);
assert!(
!rows[agent_row].trim_start().starts_with('>'),
"agent rows carry no prompt glyph: {rows:?}"
);
let next_user = rows
.iter()
.position(|row| row.contains("follow-up question"))
.expect("second user row");
assert_eq!(
next_user,
agent_row + 3,
"answer (2 rows) + one blank + next user turn: {:?}",
&rows[agent_row..=next_user]
);
let shortcuts_row = rows
.iter()
.position(|row| row.contains("brain·ok"))
.expect("brain chip on shortcuts row");
assert!(shortcuts_row > next_user, "chip below the chat surface");
}
#[test]
fn response_breathes_after_the_user_request() {
let mut state = RenderState::default();
state.append_line(InlineMessageKind::User, vec![plain_segment("the request")]);
state.append_line(InlineMessageKind::Agent, vec![plain_segment("the answer")]);
let rendered = render_frame_to_string(&state);
let rows: Vec<&str> = rendered.split('\n').collect();
let request_row = rows
.iter()
.position(|r| r.contains("the request"))
.expect("request row");
let answer_row = rows
.iter()
.position(|r| r.contains("the answer"))
.expect("answer row");
assert_eq!(
answer_row,
request_row + 2,
"one blank row must separate request from response: {:?}",
&rows[request_row..=answer_row]
);
}
#[test]
fn assistant_tool_flow_stays_contiguous() {
let mut state = RenderState::default();
state.append_line(InlineMessageKind::Tool, vec![plain_segment("[tool] read")]);
state.append_line(InlineMessageKind::Tool, vec![plain_segment("[done] ok")]);
state.append_line(InlineMessageKind::Agent, vec![plain_segment("the answer")]);
let rendered = render_frame_to_string(&state);
let rows: Vec<&str> = rendered.split('\n').collect();
let tool_row = rows
.iter()
.position(|r| r.contains("[tool] read"))
.expect("tool row");
let answer_row = rows
.iter()
.position(|r| r.contains("the answer"))
.expect("answer row");
assert_eq!(
answer_row,
tool_row + 2,
"tool → answer is one assistant turn — no blank inside it: {:?}",
&rows[tool_row..=answer_row]
);
}
#[test]
fn no_spacer_above_the_first_transcript_line() {
let mut state = RenderState::default();
state.transcript = vec![TranscriptLine {
kind: InlineMessageKind::User,
segments: vec![plain_segment("opening-question")],
block_id: 0,
}];
let rendered = render_frame_to_string(&state);
let rows: Vec<&str> = rendered.split('\n').collect();
let user_row = rows
.iter()
.position(|r| r.contains("opening-question"))
.expect("user row");
assert!(
rows[..user_row].iter().all(|r| r.trim().is_empty()),
"transcript starts at the top with no spacer"
);
}
#[test]
fn long_response_renders_every_line_by_default() {
let mut state = RenderState::default();
let mut segments = Vec::new();
for i in 0..8 {
if i > 0 {
segments.push(plain_segment("\n"));
}
segments.push(plain_segment(format!("line-{i}")));
}
state.append_line(InlineMessageKind::Agent, segments);
let rendered = render_frame_to_string(&state);
assert!(
!rendered.contains("lines"),
"no elision gap by default — full text scrolls instead: {rendered}"
);
for i in 0..8 {
assert!(
rendered.contains(&format!("line-{i}")),
"line-{i} must be reachable by scrolling: {rendered}"
);
}
}
#[test]
fn multiline_user_input_renders_every_explicit_line() {
let mut state = RenderState::default();
state.append_line(
InlineMessageKind::User,
vec![plain_segment("first line\nsecond line")],
);
let rendered = render_frame_to_string(&state);
let rows: Vec<&str> = rendered.split('\n').collect();
let first_row = rows
.iter()
.position(|row| row.contains("first line"))
.expect("first user row");
let second_row = rows
.iter()
.position(|row| row.contains("second line"))
.expect("explicit continuation line is visible");
assert_eq!(
second_row,
first_row + 1,
"explicit newline must occupy the following visual row"
);
assert!(
!rows[second_row].contains("> second line"),
"continuation row must not look like a second user turn"
);
}
#[test]
fn streaming_agent_delta_renders_every_explicit_line() {
let mut state = RenderState::default();
state.inline_segment(
InlineMessageKind::Agent,
plain_segment("first answer\nsecond answer"),
);
let rendered = render_frame_to_string(&state);
let rows: Vec<&str> = rendered.split('\n').collect();
let first_row = rows
.iter()
.position(|row| row.contains("first answer"))
.expect("first agent row");
let second_row = rows
.iter()
.position(|row| row.contains("second answer"))
.expect("second agent row");
assert_eq!(
second_row,
first_row + 1,
"streamed newline must occupy the following visual row"
);
}
#[test]
fn file_search_dropdown_renders_results() {
use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
let mut state = RenderState::default();
state.input_enabled = true;
state.file_search = Some(FileSearchState {
query: "main".into(),
at_offset: 0,
hidden_mode: false,
results: vec![
FileSearchResult {
path: "src/main.rs".into(),
score: 100,
},
FileSearchResult {
path: "tests/main.rs".into(),
score: 50,
},
],
selected: 0,
index: vec![],
line_mode: false,
});
let rendered = render_frame_to_string(&state);
assert!(rendered.contains("FILES"), "dropdown title must render");
assert!(
rendered.contains("src/main.rs"),
"dropdown must show file paths"
);
}
#[test]
fn file_search_dropdown_hidden_mode_title() {
use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
let mut state = RenderState::default();
state.input_enabled = true;
state.file_search = Some(FileSearchState {
query: "".into(),
at_offset: 0,
hidden_mode: true,
results: vec![FileSearchResult {
path: ".env".into(),
score: 0,
}],
selected: 0,
index: vec![],
line_mode: false,
});
let rendered = render_frame_to_string(&state);
assert!(
rendered.contains("HIDDEN"),
"hidden mode must be indicated in title"
);
}
#[test]
fn file_search_and_composer_render_together() {
use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
let mut state = RenderState::default();
state.input_enabled = true;
state.prompt_prefix = "> ".into();
state.composer.set_text("@main");
state.file_search = Some(FileSearchState {
query: "main".into(),
at_offset: 0,
hidden_mode: false,
results: vec![FileSearchResult {
path: "src/main.rs".into(),
score: 100,
}],
selected: 0,
index: vec![],
line_mode: false,
});
let rendered = render_frame_to_string(&state);
assert!(rendered.contains('>'), "composer must still render");
assert!(
rendered.contains("src/main.rs"),
"dropdown must render alongside composer"
);
}
#[test]
fn format_todo_line_shows_block_reason_and_notes_marker() {
let styles = active_styles();
let todo = TodoItem {
content: "Wire OAuth".into(),
status: TodoStatus::Blocked,
notes: Some(vec!["waiting on vendor".into()]),
block_reason: Some("vendor sandbox pending".into()),
};
let line = format_todo_line(&todo, false, &styles);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("Wire OAuth"));
assert!(text.contains("blocked: vendor sandbox pending"));
assert!(text.contains("·1"));
}
#[test]
fn format_todo_line_abandoned_is_strikethrough() {
let styles = active_styles();
let todo = TodoItem {
content: "Drop this".into(),
status: TodoStatus::Abandoned,
notes: None,
block_reason: None,
};
let line = format_todo_line(&todo, false, &styles);
assert!(
line.spans
.iter()
.any(|s| s.style.add_modifier.contains(Modifier::CROSSED_OUT))
);
}
#[test]
fn render_todo_pane_multi_phase_shows_roman_header_and_progress() {
use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
let mut state = RenderState::default();
state.todo_phases = vec![
TodoPhase {
name: "Foundation".into(),
tasks: vec![
TodoItem {
content: "a".into(),
status: TodoStatus::Completed,
notes: None,
block_reason: None,
},
TodoItem {
content: "b".into(),
status: TodoStatus::Completed,
notes: None,
block_reason: None,
},
],
},
TodoPhase {
name: "Auth".into(),
tasks: vec![
TodoItem {
content: "c".into(),
status: TodoStatus::Completed,
notes: None,
block_reason: None,
},
TodoItem {
content: "d".into(),
status: TodoStatus::InProgress,
notes: None,
block_reason: None,
},
TodoItem {
content: "e".into(),
status: TodoStatus::Pending,
notes: None,
block_reason: None,
},
],
},
];
let rendered = render_frame_to_string(&state);
assert!(
rendered.contains("II. Auth"),
"multi-phase HUD must show the roman-numeral phase header"
);
assert!(
rendered.contains("1/3"),
"active phase must show its done/total progress"
);
}
#[test]
fn todo_auto_clear_fires_after_delay_when_all_closed() {
use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
let mut state = RenderState::default();
state.todo_phases = vec![TodoPhase {
name: "Auth".into(),
tasks: vec![TodoItem {
content: "a".into(),
status: TodoStatus::Completed,
notes: None,
block_reason: None,
}],
}];
sync_todo_clear_timer(&mut state, 0); assert!(state.todo_phases.is_empty());
}
#[test]
fn todo_auto_clear_does_not_fire_while_open_tasks_remain() {
use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
let mut state = RenderState::default();
let phases = vec![TodoPhase {
name: "Auth".into(),
tasks: vec![TodoItem {
content: "a".into(),
status: TodoStatus::Pending,
notes: None,
block_reason: None,
}],
}];
state.todo_phases = phases.clone();
sync_todo_clear_timer(&mut state, 0);
assert_eq!(state.todo_phases.len(), phases.len());
assert_eq!(state.todo_phases[0].name, "Auth");
}
#[test]
fn todo_auto_clear_negative_delay_disables_clearing() {
use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
let mut state = RenderState::default();
let phases = vec![TodoPhase {
name: "Auth".into(),
tasks: vec![TodoItem {
content: "a".into(),
status: TodoStatus::Completed,
notes: None,
block_reason: None,
}],
}];
state.todo_phases = phases.clone();
sync_todo_clear_timer(&mut state, -1);
assert_eq!(state.todo_phases.len(), phases.len());
}
#[test]
fn render_todo_pane_single_phase_has_no_roman_header() {
use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
let mut state = RenderState::default();
state.todo_phases = vec![TodoPhase {
name: "Todos".into(),
tasks: vec![TodoItem {
content: "a".into(),
status: TodoStatus::Pending,
notes: None,
block_reason: None,
}],
}];
let rendered = render_frame_to_string(&state);
assert!(
!rendered.contains("I. Todos"),
"single phase must skip roman header"
);
assert!(rendered.contains("Todos"), "single-phase name must render");
}
#[test]
fn render_todo_compact_line_shows_counts_and_active_task() {
use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
let phases = vec![TodoPhase {
name: "Auth".into(),
tasks: vec![
TodoItem {
content: "a".into(),
status: TodoStatus::Completed,
notes: None,
block_reason: None,
},
TodoItem {
content: "b".into(),
status: TodoStatus::InProgress,
notes: None,
block_reason: None,
},
],
}];
let line = render_todo_compact_line(&phases);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("TODO 1/2"));
assert!(text.contains("b"));
}
#[test]
fn render_todo_compact_line_all_done_shows_done_marker() {
use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
let phases = vec![TodoPhase {
name: "Auth".into(),
tasks: vec![TodoItem {
content: "a".into(),
status: TodoStatus::Completed,
notes: None,
block_reason: None,
}],
}];
let line = render_todo_compact_line(&phases);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("done"));
}
fn test_todo_state() -> std::sync::Arc<crate::store::todo_state::TodoState> {
use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
std::sync::Arc::new(crate::store::todo_state::TodoState::with_phases(vec![
TodoPhase {
name: "Auth".into(),
tasks: vec![TodoItem {
content: "implement authentication module".into(),
status: TodoStatus::Pending,
notes: None,
block_reason: None,
}],
},
]))
}
fn hub_with_subagent(
status: oxicode_sdk::HubStatus,
current_task: Option<&str>,
) -> std::sync::Arc<crate::app::agent_hub_registry::HubRegistry> {
use crate::app::agent_hub_registry::{HubEntry, HubRegistry};
let hub = HubRegistry::new();
hub.register(
"sub".into(),
HubEntry {
kind: oxicode_sdk::HubKind::Subagent,
status,
display_name: "sub".into(),
current_task: current_task.map(str::to_string),
last_activity_ms: 0,
session_file: None,
},
);
std::sync::Arc::new(hub)
}
#[test]
fn frame_refresh_reconciles_todo_with_completed_subagent() {
let state = test_todo_state();
let provider: std::sync::Arc<dyn TodoStateProvider> =
crate::store::todo_state::provider_from_state(state.clone());
let hub = hub_with_subagent(oxicode_sdk::HubStatus::Idle, Some("authentication module"));
let phases = refresh_todo_phases(&provider, Some(&hub));
assert_eq!(phases[0].tasks[0].status, TodoStatus::Completed);
assert_eq!(state.get_phases()[0].tasks[0].status, TodoStatus::Completed);
}
#[test]
fn matched_closure_lights_pending_todo_for_running_subagent() {
let hub = hub_with_subagent(
oxicode_sdk::HubStatus::Running,
Some("authentication module"),
);
let matched = build_matched_closure(Some(&hub));
let t = oxicode_agent::tools::todo::TodoItem {
content: "implement authentication module".into(),
status: TodoStatus::Pending,
notes: None,
block_reason: None,
};
assert!(matched(&t));
}
#[test]
fn todo_pane_renders_when_items_present() {
let mut state = RenderState::default();
state.todo_phases = vec![oxicode_agent::tools::todo::TodoPhase {
name: "Work".into(),
tasks: vec![
oxicode_agent::tools::todo::TodoItem {
content: "active task".into(),
status: TodoStatus::InProgress,
notes: None,
block_reason: None,
},
oxicode_agent::tools::todo::TodoItem {
content: "open task".into(),
status: TodoStatus::Pending,
notes: None,
block_reason: None,
},
],
}];
let rendered = render_frame_to_string(&state);
assert!(
rendered.contains("active task"),
"in-progress task must render"
);
assert!(rendered.contains("open task"), "pending task must render");
assert!(rendered.contains("▸"), "in-progress status must render");
}
#[test]
fn todo_pane_hidden_when_empty() {
let state = RenderState::default();
let rendered = render_frame_to_string(&state);
assert!(!rendered.contains("done"), "no completed state when empty");
}
#[test]
fn render_overlay_secure_input_shows_label_mask_value_and_placeholder() {
use ratatui::{Terminal, backend::TestBackend};
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let overlay = OverlayState {
title: "OpenAI key".into(),
lines: vec!["Paste your API key".into()],
items: Vec::new(),
selected: 0,
search: None,
secure_input: Some(OverlaySecureInput {
config: SecurePromptConfig {
label: "Key".into(),
placeholder: Some("sk-...".into()),
mask_input: true,
},
editor: oxicode_textarea::EditBuffer::from_parts("sk-abc", 6),
}),
..Default::default()
};
terminal
.draw(|f| render_overlay(f, f.area(), &overlay))
.unwrap();
let buf = terminal.backend().buffer().clone();
let text: String = buf
.content()
.iter()
.map(|c| c.symbol())
.collect::<Vec<_>>()
.join("");
assert!(text.contains("Key:"));
assert!(text.contains("******"));
assert!(!text.contains("sk-abc"));
}
#[test]
fn render_overlay_secure_input_placeholder_when_empty() {
use ratatui::{Terminal, backend::TestBackend};
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let overlay = OverlayState {
title: "OpenAI key".into(),
lines: vec!["Paste your API key".into()],
items: Vec::new(),
selected: 0,
search: None,
secure_input: Some(OverlaySecureInput {
config: SecurePromptConfig {
label: "Key".into(),
placeholder: Some("sk-...".into()),
mask_input: true,
},
editor: oxicode_textarea::EditBuffer::new(),
}),
..Default::default()
};
terminal
.draw(|f| render_overlay(f, f.area(), &overlay))
.unwrap();
let buf = terminal.backend().buffer().clone();
let text: String = buf
.content()
.iter()
.map(|c| c.symbol())
.collect::<Vec<_>>()
.join("");
assert!(text.contains("sk-..."));
}
#[test]
fn ladder_collapses_oldest_blocks_to_glyph_row_and_banner() {
let mut state = RenderState::default();
for i in 0..6 {
let bid = i;
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Tool,
segments: vec![plain_segment(format!("tool-{i}-headline"))],
block_id: bid,
});
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Tool,
segments: vec![plain_segment(format!("tool-{i}-middle"))],
block_id: bid,
});
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Tool,
segments: vec![plain_segment(format!("tool-{i}-trailer"))],
block_id: bid,
});
}
let rendered = render_frame_to_string_at(&state, 80, 10);
assert!(
rendered.contains("tool-5")
|| rendered.contains("tool-5-headline")
|| rendered.contains("\u{25B8}")
|| rendered.contains("earlier blocks hidden"),
"live region must surface a recent block or its folded form"
);
}
#[test]
fn ladder_keeps_newest_block_full_under_pressure() {
let mut state = RenderState::default();
for j in 0..2 {
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Agent,
segments: vec![plain_segment(format!("old-block-line-{j}"))],
block_id: 0,
});
}
for j in 0..2 {
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Agent,
segments: vec![plain_segment(format!("mid-block-line-{j}"))],
block_id: 1,
});
}
for j in 0..5 {
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Agent,
segments: vec![plain_segment(format!("new-block-line-{j}"))],
block_id: 2,
});
}
let rendered = render_frame_to_string_at(&state, 80, 12);
assert!(
rendered.contains("new-block-line-0"),
"newest block's leading line must be visible"
);
let old_visible = (0..2).all(|j| rendered.contains(&format!("old-block-line-{j}")));
assert!(
!old_visible,
"oldest block must be folded or hidden when under pressure"
);
}
#[test]
fn long_activity_folded_card_stays_within_width() {
let mut state = RenderState::default();
for i in 0..8 {
let bid = i;
let long = format!("tool-{i}-{}", "x".repeat(120));
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Tool,
segments: vec![plain_segment(format!("tool-{i}-head"))],
block_id: bid,
});
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Tool,
segments: vec![plain_segment(format!("tool-{i}-body"))],
block_id: bid,
});
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Tool,
segments: vec![plain_segment(long)],
block_id: bid,
});
}
let rendered = render_frame_to_string_at(&state, 80, 10);
for line in rendered.split('\n') {
assert!(
line.width() <= 80,
"rendered row exceeded the viewport width: '{}' ({} cells)",
line,
line.width()
);
}
assert!(
rendered.contains('\u{25B8}'),
"glyph rows must be painted even with long activities"
);
}
#[test]
fn manual_collapsed_block_keeps_the_plus_marker() {
let mut state = RenderState::default();
for j in 0..3 {
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Error,
segments: vec![plain_segment(format!("boom-line-{j}"))],
block_id: 0,
});
}
state.block_display.insert(0, BlockDisplayMode::Collapsed);
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Agent,
segments: vec![plain_segment("after-collapsed")],
block_id: 1,
});
let rendered = render_frame_to_string_at(&state, 80, 12);
assert!(
rendered.contains("[+] error: boom-line-0"),
"manual Collapsed must keep the [+] marker (got: {rendered:?})"
);
assert!(
!rendered.contains('\u{25B8}'),
"manual Collapsed must NOT be replaced by the ladder glyph"
);
}
#[test]
fn clamp_fold_text_truncates_at_unicode_width() {
let out = clamp_fold_text(&"x".repeat(100), 3, 80, "\u{2026}");
assert!(out.ends_with('\u{2026}'), "ellipsis appended on overflow");
assert!(out.width() <= 80, "clamped to budget: got {}", out.width());
let cjk = "\u{4ECA}\u{65E5}\u{306F}\u{667A}\u{6167}".repeat(20);
let out_cjk = clamp_fold_text(&cjk, 2, 20, "\u{2026}");
assert!(out_cjk.width() <= 20, "CJK clamp: got {}", out_cjk.width());
assert!(out_cjk.ends_with('\u{2026}'));
assert_eq!(clamp_fold_text("short", 0, 80, "\u{2026}"), "short");
assert_eq!(clamp_fold_text("text", 0, 0, "\u{2026}"), "");
}
}
#[cfg(test)]
mod secure_input_tests {
use super::*;
use oxicode_vtui::tui::core::OverlaySubmission;
#[test]
fn overlay_submission_secure_input_is_routed_to_host() {
let _ = OverlaySubmission::SecureInput("sk-test".into());
let serialized = format!("{:?}", OverlaySubmission::SecureInput("x".into()));
assert!(serialized.contains("SecureInput"));
}
#[test]
fn providers_action_matrix_branches_correctly() {
assert_eq!(
next_provider_actions(true, true),
vec![
AuthAction::SetApiKey,
AuthAction::StartOAuth,
AuthAction::RemoveKey,
],
"has key + oauth-capable: replace, oauth, remove"
);
assert_eq!(
next_provider_actions(true, false),
vec![AuthAction::SetApiKey, AuthAction::RemoveKey],
"has key, key-only provider: replace, remove"
);
assert_eq!(
next_provider_actions(false, true),
vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
"no key + oauth-capable: set key, oauth"
);
assert_eq!(
next_provider_actions(false, false),
vec![AuthAction::SetApiKey],
"no key + key-only provider: set key only"
);
}
fn render_secure_to_text(secure: &OverlaySecureInput) -> String {
use ratatui::{Terminal, backend::TestBackend};
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let overlay = OverlayState {
title: "OpenAI key".into(),
lines: vec!["Paste your API key".into()],
items: Vec::new(),
selected: 0,
search: None,
secure_input: Some(secure.clone()),
..Default::default()
};
terminal
.draw(|f| render_overlay(f, f.area(), &overlay))
.unwrap();
terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect::<Vec<_>>()
.join("")
}
#[test]
fn masked_render_shows_asterisks_not_value() {
let mut editor = oxicode_textarea::EditBuffer::new();
let _ = editor.insert_str("ABCDE");
let rendered = render_secure_to_text(&OverlaySecureInput {
config: SecurePromptConfig {
label: "Key".into(),
placeholder: Some("sk-...".into()),
mask_input: true,
},
editor,
});
assert!(rendered.contains("*****"), "mask must render asterisks");
assert!(
!rendered.contains("ABCDE"),
"masked render must NEVER carry the real value"
);
assert!(rendered.contains("Key:"), "label prefix must still render");
}
#[test]
fn masked_render_caret_lands_after_mask() {
let mut editor = oxicode_textarea::EditBuffer::new();
let _ = editor.insert_str("ABCD");
let secure = OverlaySecureInput {
config: SecurePromptConfig {
label: "Key".into(),
placeholder: Some("sk-...".into()),
mask_input: true,
},
editor,
};
use ratatui::{Terminal, backend::TestBackend};
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let overlay = OverlayState {
title: "OpenAI key".into(),
lines: vec!["Paste your API key".into()],
items: Vec::new(),
selected: 0,
search: None,
secure_input: Some(secure.clone()),
..Default::default()
};
terminal
.draw(|f| render_overlay(f, f.area(), &overlay))
.unwrap();
let value = secure.editor.text();
let mut ta = oxicode_textarea::TextArea::new();
ta.set_text(value);
ta.replace_range_with_element(
0..value.len(),
value,
MASKED_ELEMENT_KIND,
Some(Line::from("*".repeat(value.chars().count()))),
);
ta.set_cursor(secure.editor.cursor_byte());
let caret = ta
.cursor_pos_with_state(
Rect {
x: 0,
y: 0,
width: 80,
height: 24,
},
oxicode_textarea::TextAreaState::default(),
)
.expect("caret must be visible");
assert_eq!(caret.0, 4);
}
#[test]
fn backspace_removes_previous_grapheme() {
let mut editor = oxicode_textarea::EditBuffer::new();
let _ = editor.insert_str("XYZ");
assert_eq!(editor.text(), "XYZ");
assert_eq!(editor.cursor_byte(), 3);
let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
assert_eq!(editor.text(), "XY");
assert_eq!(editor.cursor_byte(), 2);
let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
assert_eq!(editor.text(), "X");
let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
assert_eq!(editor.text(), "");
assert_eq!(editor.cursor_byte(), 0);
}
#[test]
fn empty_editor_renders_placeholder_not_asterisks() {
let rendered = render_secure_to_text(&OverlaySecureInput {
config: SecurePromptConfig {
label: "Key".into(),
placeholder: Some("sk-...".into()),
mask_input: true,
},
editor: oxicode_textarea::EditBuffer::new(),
});
assert!(rendered.contains("sk-..."));
assert!(!rendered.contains("*"));
}
#[test]
fn paste_filter_drops_newline_and_non_ascii_via_edit_command() {
let mut editor = oxicode_textarea::EditBuffer::new();
let pasted = "sk-xyz\nABC\u{1F600}";
let trimmed = pasted.trim_end_matches('\n');
for ch in trimmed.chars() {
if ch.is_ascii_graphic() || ch == ' ' {
let _ = editor.apply(oxicode_textarea::EditCommand::Insert(ch));
}
}
assert_eq!(editor.text(), "sk-xyzABC");
assert_eq!(editor.cursor_byte(), 9);
}
}
#[cfg(test)]
mod provider_overlay_tests {
use super::*;
use crate::app::agent_session::{AgentSession, AgentSessionHandle};
use crate::store::session::SessionManager;
use crate::store::settings::Settings;
use oxicode_agent::{Agent, AgentConfig};
use oxicode_sdk::{Provider, ProviderError, ProviderEvent};
use oxicode_vtui::tui::core::OverlayEvent;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context as TaskContext, Poll};
struct EmptyStream;
impl futures::Stream for EmptyStream {
type Item = ProviderEvent;
fn poll_next(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
Poll::Ready(None)
}
}
struct StubProvider;
impl Provider for StubProvider {
fn stream<'a>(
&'a self,
_model: &'a oxicode_sdk::Model,
_context: &'a oxicode_sdk::Context,
_options: Option<oxicode_sdk::StreamOptions>,
) -> Pin<
Box<
dyn Future<
Output = Result<
Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>,
ProviderError,
>,
> + Send
+ 'a,
>,
> {
Box::pin(async move {
Ok::<_, ProviderError>(Box::pin(EmptyStream)
as Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>)
})
}
}
struct StubEssentialTool {
name: &'static str,
essential: bool,
}
#[async_trait::async_trait]
impl oxicode_agent::AgentTool for StubEssentialTool {
fn name(&self) -> &str {
self.name
}
fn label(&self) -> &str {
self.name
}
fn description(&self) -> &str {
"stub tool for multiselect editor tests"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({ "type": "object", "properties": {} })
}
fn essential(&self) -> bool {
self.essential
}
async fn execute(
&self,
_id: &str,
_params: serde_json::Value,
_signal: Option<tokio::sync::oneshot::Receiver<()>>,
_ctx: &oxicode_agent::ToolContext,
) -> Result<oxicode_agent::AgentToolResult, String> {
Ok(oxicode_agent::AgentToolResult::success("ok"))
}
}
fn make_session() -> AgentSessionHandle {
let provider = Arc::new(StubProvider);
let config = AgentConfig::new("anthropic/claude-sonnet-4-20250514");
let agent = Arc::new(Agent::new(
provider,
config,
Arc::new(oxicode_agent::ToolRegistry::new()),
));
let settings = Settings::default();
let session_manager = SessionManager::in_memory("/tmp/test_providers");
let session = AgentSession::new(
agent,
settings,
session_manager,
"/tmp/test_providers".to_string(),
crate::SessionState::default(),
);
session.clone_handle()
}
pub(super) fn make_session_with_tools_for_tests() -> AgentSessionHandle {
let provider = Arc::new(StubProvider);
let config = AgentConfig::new("anthropic/claude-sonnet-4-20250514");
let registry = oxicode_agent::ToolRegistry::new();
registry.register_arc(Arc::new(StubEssentialTool {
name: "bash",
essential: true,
}));
registry.register_arc(Arc::new(StubEssentialTool {
name: "commit",
essential: false,
}));
let agent = Arc::new(Agent::new(provider, config, Arc::new(registry)));
let settings = Settings::default();
let session_manager = SessionManager::in_memory("/tmp/test_providers");
let session = AgentSession::new(
agent,
settings,
session_manager,
"/tmp/test_providers".to_string(),
crate::SessionState::default(),
);
session.clone_handle()
}
fn summarise(cmds: &[InlineCommand]) -> String {
let mut show = 0;
let mut close = 0;
let mut other = 0;
for c in cmds {
match c {
InlineCommand::ShowOverlay { .. } => show += 1,
InlineCommand::CloseOverlay => close += 1,
_ => other += 1,
}
}
format!("[ShowOverlay={show}, CloseOverlay={close}, other={other}]")
}
#[test]
fn provider_row_opens_action_menu_without_close() {
let session = make_session();
let mut state = RenderState::default();
state.overlay_providers = vec!["openai".to_string()];
state.overlay = Some(OverlayState {
title: "Providers".to_string(),
lines: Vec::new(),
items: Vec::new(),
selected: 0,
search: None,
secure_input: None,
..Default::default()
});
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
let handle = InlineHandle::new_for_tests(cmd_tx);
let prompt_queue = Arc::new(PromptQueue::default());
let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
InlineListSelection::ProviderRow(0),
)));
let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
let cmds: Vec<InlineCommand> = {
let mut out = Vec::new();
while let Ok(cmd) = cmd_rx.try_recv() {
out.push(cmd);
}
out
};
let show_count = cmds
.iter()
.filter(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
.count();
assert_eq!(
show_count,
1,
"submitting a provider row must ShowOverlay exactly once (commands: {})",
summarise(&cmds)
);
let show_idx = cmds
.iter()
.position(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
.expect("ShowOverlay must be present");
let trailing = &cmds[show_idx + 1..];
assert!(
!trailing
.iter()
.any(|c| matches!(c, InlineCommand::CloseOverlay)),
"no CloseOverlay may follow the action-menu ShowOverlay (commands: {})",
summarise(&cmds)
);
assert!(
state.overlay_providers.is_empty(),
"overlay_providers must be cleared after dispatch (got {:?})",
state.overlay_providers
);
}
#[test]
fn provider_row_set_api_key_opens_secure_prompt_without_close() {
let session = make_session();
let mut state = RenderState::default();
state.overlay_providers = vec!["cerebras".to_string()];
state.overlay = Some(OverlayState {
title: "Providers".to_string(),
lines: Vec::new(),
items: Vec::new(),
selected: 0,
search: None,
secure_input: None,
..Default::default()
});
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
let handle = InlineHandle::new_for_tests(cmd_tx);
let prompt_queue = Arc::new(PromptQueue::default());
let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
InlineListSelection::ProviderRow(0),
)));
let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
let cmds: Vec<InlineCommand> = {
let mut out = Vec::new();
while let Ok(cmd) = cmd_rx.try_recv() {
out.push(cmd);
}
out
};
let show_count = cmds
.iter()
.filter(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
.count();
assert_eq!(
show_count,
1,
"submitting a provider row must ShowOverlay exactly once (commands: {})",
summarise(&cmds)
);
let show_idx = cmds
.iter()
.position(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
.expect("ShowOverlay must be present");
let trailing = &cmds[show_idx + 1..];
assert!(
!trailing
.iter()
.any(|c| matches!(c, InlineCommand::CloseOverlay)),
"no CloseOverlay may follow the secure-prompt ShowOverlay (commands: {})",
summarise(&cmds)
);
assert_eq!(
state.secure_input_origin,
Some(SecureInputOrigin::SetKey {
provider: "cerebras".to_string(),
}),
"secure_input_origin must be stashed by SetApiKey"
);
}
#[test]
fn catalog_model_selection_still_closes_overlay() {
let session = make_session();
let mut state = RenderState::default();
state.overlay_catalog_models = vec![(
"anthropic".to_string(),
"claude-sonnet-4-20250514".to_string(),
)];
state.overlay = Some(OverlayState {
title: "Models".to_string(),
lines: Vec::new(),
items: Vec::new(),
selected: 0,
search: None,
secure_input: None,
..Default::default()
});
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
let handle = InlineHandle::new_for_tests(cmd_tx);
let prompt_queue = Arc::new(PromptQueue::default());
let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
InlineListSelection::CatalogModel(0),
)));
let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
let cmds: Vec<InlineCommand> = {
let mut out = Vec::new();
while let Ok(cmd) = cmd_rx.try_recv() {
out.push(cmd);
}
out
};
assert!(
cmds.iter()
.any(|c| matches!(c, InlineCommand::CloseOverlay)),
"catalog model selection must close the overlay (commands: {})",
summarise(&cmds)
);
}
fn secure_input_origin_carries_provider_independently_of_variant() {
let set = SecureInputOrigin::SetKey {
provider: "openai".to_string(),
};
let added = SecureInputOrigin::NewlyAdded {
provider: "minimax".to_string(),
};
let provider_of = |o: &SecureInputOrigin| match o {
SecureInputOrigin::SetKey { provider } | SecureInputOrigin::NewlyAdded { provider } => {
provider.clone()
}
SecureInputOrigin::ModelRoleKey | SecureInputOrigin::ModelRoleValue { .. } => {
unreachable!("model-role origins have no provider")
}
SecureInputOrigin::TextEdit(_) => {
unreachable!("text-edit origin has no provider")
}
};
assert_eq!(provider_of(&set), "openai");
assert_eq!(provider_of(&added), "minimax");
assert_ne!(set, added);
}
#[test]
fn session_picker_resume_refused_while_streaming() {
let session = make_session();
session
.streaming_flag()
.store(true, std::sync::atomic::Ordering::SeqCst);
let mut state = RenderState::default();
assert!(
state.pending_resume.is_none(),
"precondition: pending_resume must start None"
);
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
let handle = InlineHandle::new_for_tests(cmd_tx);
let prompt_queue = Arc::new(PromptQueue::default());
let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
InlineListSelection::Session("some-id".to_string()),
)));
let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
assert!(
state.pending_resume.is_none(),
"streaming session must not enqueue pending_resume (got {:?})",
state.pending_resume
);
let mut cmds: Vec<InlineCommand> = Vec::new();
while let Ok(cmd) = cmd_rx.try_recv() {
cmds.push(cmd);
}
let mut found_error = false;
let mut error_text = String::new();
for cmd in &cmds {
if let InlineCommand::AppendLine { kind, segments } = cmd
&& matches!(kind, InlineMessageKind::Error)
{
error_text = segments
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.join("");
found_error = true;
}
}
assert!(
found_error,
"expected an error AppendLine (commands: {})",
summarise(&cmds)
);
assert!(
error_text.contains("Cannot resume while agent is running"),
"error text must match the direct-path wording (got {error_text:?})"
);
session
.streaming_flag()
.store(false, std::sync::atomic::Ordering::SeqCst);
}
#[test]
fn settings_tab_selection_rebuilds_item_list() {
let session = make_session();
let mut state = RenderState::default();
state.overlay = None;
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
let handle = InlineHandle::new_for_tests(cmd_tx);
let prompt_queue = Arc::new(PromptQueue::default());
let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
InlineListSelection::SettingsTab(1),
)));
let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
let overlay = state.overlay.as_ref().expect("panel reopened on tab 1");
assert_eq!(overlay.active_tab, 1);
assert_eq!(overlay.tabs.get(1).map(String::as_str), Some("Model"));
assert_eq!(
overlay.sections,
vec!["Defaults".to_string(), "Pointers".to_string()]
);
let settings = Settings::load().unwrap_or_default();
let expected = settings_overlay_items(SettingsTab::Model, &settings).0;
assert_eq!(overlay.items.len(), expected.len());
assert_eq!(
overlay
.items
.iter()
.map(|i| i.title.clone())
.collect::<Vec<_>>(),
expected.iter().map(|i| i.title.clone()).collect::<Vec<_>>(),
);
assert_eq!(state.settings_active_tab, SettingsTab::Model);
while let Ok(cmd) = cmd_rx.try_recv() {
assert!(
!matches!(cmd, InlineCommand::CloseOverlay),
"tab switch must not close the reopened panel"
);
}
}
}
#[cfg(test)]
mod thinking_stream_tests {
use super::*;
use oxicode_ai::{Api, AssistantMessage, ContentBlock, Message, TextContent};
use oxicode_vtui::tui::core::InlineHandle;
use tokio::sync::mpsc;
fn fresh_handle() -> (InlineHandle, mpsc::UnboundedReceiver<InlineCommand>) {
let (tx, rx) = mpsc::unbounded_channel();
(InlineHandle::new_for_tests(tx), rx)
}
fn assistant() -> Message {
Message::Assistant(AssistantMessage::new(
Api::OpenAiCompletions,
"test",
"test",
))
}
fn assistant_with_text(text: &str) -> Message {
let mut a = AssistantMessage::new(Api::OpenAiCompletions, "test", "test");
a.content.push(ContentBlock::Text(TextContent::new(text)));
Message::Assistant(a)
}
#[test]
fn thinking_delta_sets_fixed_stage_label_not_raw_text() {
let mut state = RenderState::default();
let (handle, mut cmd_rx) = fresh_handle();
let event = AgentEvent::MessageUpdate {
message: assistant(),
delta: oxicode_sdk::StreamDelta::Thinking("considering options".into()),
};
map_agent_event(&handle, event, &mut state);
assert_eq!(
state.reasoning_stage.as_deref(),
Some("thinking\u{2026}"),
"reasoning stage must show a fixed label, never the streamed fragment"
);
while let Ok(cmd) = cmd_rx.try_recv() {
assert!(
!matches!(cmd, InlineCommand::Inline { .. }),
"thinking must not emit a transcript Inline command"
);
}
}
#[test]
fn thinking_streams_as_dimmed_block_above_the_answer() {
let mut state = RenderState::default();
let (handle, mut rx) = fresh_handle();
map_agent_event(
&handle,
AgentEvent::MessageStart {
message: assistant(),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageUpdate {
message: assistant(),
delta: oxicode_sdk::StreamDelta::Thinking("weighing alternatives".into()),
},
&mut state,
);
apply_all(&mut state, &mut rx);
let text: String = state
.transcript
.iter()
.flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
.collect::<Vec<_>>()
.join(" ");
assert!(
text.contains("weighing alternatives"),
"thinking must render in the transcript: {text}"
);
let dim_italic = state.transcript.iter().any(|l| {
l.segments.iter().any(|s| {
let st = s.style.as_ref();
st.effects.contains(anstyle::Effects::DIMMED)
&& st.effects.contains(anstyle::Effects::ITALIC)
})
});
assert!(
dim_italic,
"thinking lines render in the dimmed italic reasoning style"
);
map_agent_event(
&handle,
AgentEvent::MessageUpdate {
message: assistant(),
delta: oxicode_sdk::StreamDelta::Text("the answer".into()),
},
&mut state,
);
apply_all(&mut state, &mut rx);
type_out_stream(&mut state);
let texts: Vec<String> = state
.transcript
.iter()
.map(|l| {
l.segments
.iter()
.map(|s| s.text.as_str())
.collect::<String>()
})
.collect();
let think_idx = texts
.iter()
.position(|t| t.contains("weighing alternatives"))
.expect("thinking line");
let answer_idx = texts
.iter()
.position(|t| t.contains("the answer"))
.expect("answer line");
assert!(
answer_idx > think_idx,
"answer renders below thinking: {texts:?}"
);
assert_eq!(
state.reasoning_stage.as_deref(),
Some("generating response"),
"the stage label moves on once the answer streams"
);
}
#[test]
fn tool_lines_survive_the_full_turn_event_sequence() {
let mut state = RenderState::default();
let (handle, mut rx) = fresh_handle();
map_agent_event(
&handle,
AgentEvent::MessageStart {
message: assistant(),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageUpdate {
message: assistant(),
delta: oxicode_sdk::StreamDelta::Text("I will check.".into()),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageEnd {
message: assistant(),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::ToolExecutionStart {
tool_call_id: "tc1".into(),
tool_name: "bash".into(),
args: serde_json::json!({"command": "echo hi"}),
intent: None,
context: None,
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::ToolExecutionEnd {
tool_call_id: "tc1".into(),
tool_name: "bash".into(),
intent: None,
result: oxicode_ai::ToolResult {
tool_call_id: "tc1".into(),
content: "ls output".into(),
status: "success".into(),
},
is_error: false,
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageStart {
message: assistant(),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageUpdate {
message: assistant(),
delta: oxicode_sdk::StreamDelta::Text("All done.".into()),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageEnd {
message: assistant(),
},
&mut state,
);
apply_all(&mut state, &mut rx);
let text: String = state
.transcript
.iter()
.flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
.collect::<Vec<_>>()
.join("|");
assert!(
text.contains("$ echo hi"),
"box header shows the shell command: {text}"
);
assert!(
text.contains("Output") && text.contains("ls output"),
"labeled divider separates the call from its output: {text}"
);
assert!(
text.contains("\u{256D}") && text.contains("\u{2570}"),
"rounded top and bottom borders close the box: {text}"
);
let block_ids: std::collections::HashSet<usize> = state
.transcript
.iter()
.filter(|l| l.kind == InlineMessageKind::Tool)
.map(|l| l.block_id)
.collect();
assert_eq!(block_ids.len(), 1, "one tool call = one block");
}
#[test]
fn first_text_delta_overrides_thinking_stage_with_generating_response() {
let mut state = RenderState::default();
let (handle, _cmd_rx) = fresh_handle();
map_agent_event(
&handle,
AgentEvent::MessageUpdate {
message: assistant(),
delta: oxicode_sdk::StreamDelta::Thinking("considering".into()),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageUpdate {
message: assistant(),
delta: oxicode_sdk::StreamDelta::Text("hi".into()),
},
&mut state,
);
assert_eq!(
state.reasoning_stage.as_deref(),
Some("generating response"),
"first Text delta must move the stage off `thinking\u{2026}`"
);
}
fn apply_all(state: &mut RenderState, rx: &mut mpsc::UnboundedReceiver<InlineCommand>) {
while let Ok(cmd) = rx.try_recv() {
apply_command(state, cmd);
}
}
#[test]
fn message_end_replaces_the_streamed_block_without_duplicates() {
let mut state = RenderState::default();
let (handle, mut rx) = fresh_handle();
map_agent_event(
&handle,
AgentEvent::MessageStart {
message: assistant(),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageUpdate {
message: assistant(),
delta: oxicode_sdk::StreamDelta::Text("para one".into()),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageUpdate {
message: assistant(),
delta: oxicode_sdk::StreamDelta::Text("\n\npara two".into()),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageEnd {
message: assistant_with_text("para one\n\npara two"),
},
&mut state,
);
apply_all(&mut state, &mut rx);
let text: String = state
.transcript
.iter()
.flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
.collect::<Vec<_>>()
.join(" ");
assert_eq!(
text.matches("para one").count(),
1,
"the markdown re-render must fully replace the streamed raw lines: {text}"
);
}
#[test]
fn consecutive_messages_stream_into_separate_blocks() {
let mut state = RenderState::default();
let (handle, mut rx) = fresh_handle();
for body in ["first answer", "second answer"] {
map_agent_event(
&handle,
AgentEvent::MessageStart {
message: assistant(),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageUpdate {
message: assistant(),
delta: oxicode_sdk::StreamDelta::Text(body.into()),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageEnd {
message: assistant_with_text(body),
},
&mut state,
);
}
apply_all(&mut state, &mut rx);
let joined = state
.transcript
.iter()
.map(|l| {
l.segments
.iter()
.map(|s| s.text.as_str())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("|");
assert!(
joined.contains("first answer") && joined.contains("second answer"),
"both messages survive: {joined}"
);
assert!(
!joined.contains("first answersecond answer"),
"a new message must not append into the previous message's line: {joined}"
);
}
#[test]
fn text_deltas_render_markdown_live_not_raw() {
let mut state = RenderState::default();
let (handle, mut rx) = fresh_handle();
map_agent_event(
&handle,
AgentEvent::MessageStart {
message: assistant(),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageUpdate {
message: assistant(),
delta: oxicode_sdk::StreamDelta::Text("a **bold** claim".into()),
},
&mut state,
);
apply_all(&mut state, &mut rx);
type_out_stream(&mut state);
let text = state
.transcript
.iter()
.flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
.collect::<Vec<_>>()
.join(" ");
assert!(
!text.contains("**"),
"the live stream must render markdown, not raw syntax: {text}"
);
assert!(text.contains("bold"), "content survives: {text}");
}
#[test]
fn message_end_does_not_reflow_the_streamed_block() {
let mut state = RenderState::default();
let (handle, mut rx) = fresh_handle();
map_agent_event(
&handle,
AgentEvent::MessageStart {
message: assistant(),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageUpdate {
message: assistant(),
delta: oxicode_sdk::StreamDelta::Text("hello **world**".into()),
},
&mut state,
);
apply_all(&mut state, &mut rx);
type_out_stream(&mut state);
let streamed: Vec<String> = state
.transcript
.iter()
.map(|l| {
l.segments
.iter()
.map(|s| s.text.as_str())
.collect::<String>()
})
.collect();
map_agent_event(
&handle,
AgentEvent::MessageEnd {
message: assistant_with_text("hello **world**"),
},
&mut state,
);
apply_all(&mut state, &mut rx);
let final_: Vec<String> = state
.transcript
.iter()
.map(|l| {
l.segments
.iter()
.map(|s| s.text.as_str())
.collect::<String>()
})
.collect();
assert_eq!(
streamed, final_,
"MessageEnd must not re-render what the live stream already shows"
);
}
#[test]
fn final_message_renders_the_authoritative_tail() {
let mut state = RenderState::default();
let (handle, mut rx) = fresh_handle();
map_agent_event(
&handle,
AgentEvent::MessageStart {
message: assistant(),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageUpdate {
message: assistant(),
delta: oxicode_sdk::StreamDelta::Text("visible prefix ".into()),
},
&mut state,
);
apply_all(&mut state, &mut rx);
type_out_stream(&mut state);
map_agent_event(
&handle,
AgentEvent::MessageEnd {
message: assistant_with_text("visible prefix HIDDEN-TAIL"),
},
&mut state,
);
apply_all(&mut state, &mut rx);
let text: String = state
.transcript
.iter()
.map(|l| {
l.segments
.iter()
.map(|s| s.text.as_str())
.collect::<String>()
})
.collect();
assert!(
text.contains("HIDDEN-TAIL"),
"the final message is authoritative — its tail must render: {text}"
);
assert_eq!(
text.matches("visible prefix").count(),
1,
"no duplicated block: {text}"
);
}
#[test]
fn streamed_body_renders_only_the_revealed_prefix() {
let mut state = RenderState::default();
state.message_buffer = "hello world".to_string();
state.stream_reveal = 5; let lines = render_streamed_message(&mut state);
let text: String = lines
.iter()
.map(|l| l.iter().map(|s| s.text.as_str()).collect::<String>())
.collect();
assert!(text.contains("hello"), "revealed prefix renders: {text}");
assert!(!text.contains("world"), "unrevealed text waits: {text}");
}
#[test]
fn advance_stream_reveal_types_out_in_bounded_steps() {
let mut state = RenderState::default();
state.stream_anchor = Some(0);
state.message_buffer = "x".repeat(6000);
state.stream_reveal = 0;
assert!(advance_stream_reveal(&mut state), "first tick paints");
assert!(
state.stream_reveal > 0 && state.stream_reveal < 6000,
"bounded step, not a lump: {}",
state.stream_reveal
);
let transcript_after_step: usize = state.transcript.len();
assert!(
transcript_after_step > 0,
"the revealed prefix lands in the transcript"
);
while advance_stream_reveal(&mut state) {}
assert_eq!(
state.stream_reveal, 6000,
"repeated ticks drain the backlog completely"
);
}
fn type_out_stream(state: &mut RenderState) {
while advance_stream_reveal(state) {}
}
#[test]
fn message_end_clears_reasoning_stage() {
let mut state = RenderState::default();
let (handle, _cmd_rx) = fresh_handle();
state.reasoning_stage = Some("thinking\u{2026}".into());
map_agent_event(
&handle,
AgentEvent::MessageEnd {
message: assistant(),
},
&mut state,
);
assert!(
state.reasoning_stage.is_none(),
"MessageEnd must clear the reasoning stage so follow-ups / tips can render"
);
}
#[test]
fn run_tracker_spans_the_whole_tool_loop() {
let mut state = RenderState::default();
let (handle, _cmd_rx) = fresh_handle();
map_agent_event(
&handle,
AgentEvent::AgentStart {
prompts: vec![],
session_id: None,
},
&mut state,
);
assert!(
state.active_run.is_some(),
"AgentStart opens the run tracker"
);
map_agent_event(
&handle,
AgentEvent::MessageStart {
message: assistant(),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::ToolExecutionStart {
tool_call_id: "tc-1".into(),
tool_name: "read".into(),
args: serde_json::json!({}),
intent: None,
context: None,
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageEnd {
message: assistant(),
},
&mut state,
);
let run = state.active_run.as_ref().expect("run stays live");
assert_eq!(run.turn, 1, "MessageStart counts a turn");
assert_eq!(run.tool_calls, 1, "ToolExecutionStart counts a call");
map_agent_event(
&handle,
AgentEvent::AgentEnd {
messages: vec![],
stop_reason: None,
session_id: None,
},
&mut state,
);
assert!(state.active_run.is_none(), "AgentEnd closes the tracker");
assert!(
state.reasoning_stage.is_none(),
"AgentEnd releases the indicator row"
);
}
#[test]
fn message_end_releases_the_stream_anchor() {
let mut state = RenderState::default();
let (handle, mut rx) = fresh_handle();
map_agent_event(
&handle,
AgentEvent::MessageStart {
message: assistant(),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageUpdate {
message: assistant(),
delta: oxicode_sdk::StreamDelta::Text("done".into()),
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::MessageEnd {
message: assistant(),
},
&mut state,
);
while let Ok(cmd) = rx.try_recv() {
apply_command(&mut state, cmd);
}
assert!(
state.stream_anchor.is_none(),
"MessageEnd finalizes the message — the anchor must release so the finished block can commit to scrollback"
);
}
}
#[cfg(test)]
mod composer_border_tests {
use super::*;
fn spans_to_string(line: &Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn composer_border_has_no_app_badge() {
let mut state = RenderState::default();
state.header_context.provider = "prov".to_string();
state.header_context.model = "prov/m-1".to_string();
let text = spans_to_string(&composer_context_line(&state, 200));
assert!(
text.starts_with("MODEL "),
"model leads with no leading separator: {text}"
);
assert!(
text.contains("MODEL m-1"),
"provider prefix is stripped from the model: {text}"
);
}
#[test]
fn composer_border_fields_drop_by_width() {
let mut state = RenderState::default();
state.header_context.provider = "prov".to_string();
state.header_context.model = "prov/m-1".to_string();
let narrow = spans_to_string(&composer_context_line(&state, 60));
assert!(
narrow.contains("MODEL ") && !narrow.contains("CTX "),
"narrow keeps the model only: {narrow}"
);
let wide = spans_to_string(&composer_context_line(&state, 140));
assert!(wide.contains("CTX "), "wide carries context usage: {wide}");
}
#[test]
fn model_chips_follow_model_switch() {
let mut state = RenderState::default();
assert_eq!(state.context_window, 128_000, "default before sync");
apply_model_to_chips(&mut state, "google/gemini-2.5-pro", 1_048_576);
assert_eq!(state.header_context.provider, "google");
assert_eq!(state.header_context.model, "google/gemini-2.5-pro");
assert_eq!(
state.header_context.editor_context.as_deref(),
Some("google/gemini-2.5-pro")
);
assert_eq!(state.context_window, 1_048_576);
let wide = spans_to_string(&composer_context_line(&state, 140));
assert!(
wide.contains("CTX 0/1048.5K"),
"CTX chip renders the synced denominator: {wide}"
);
apply_model_to_chips(&mut state, "", 999);
assert_eq!(state.header_context.model, "google/gemini-2.5-pro");
apply_model_to_chips(&mut state, "zai/glm-5.1", 0);
assert_eq!(state.header_context.model, "zai/glm-5.1");
assert_eq!(state.context_window, 1_048_576);
}
#[test]
fn plain_segments_render_in_their_kind_color_not_response() {
let styles = active_styles();
let user_color = color_from_anstyle(styles.user.get_fg_color());
let response = color_from_anstyle(styles.response.get_fg_color());
let line = |kind| TranscriptLine {
kind,
segments: vec![plain_segment("body")],
block_id: 0,
};
let user_line = line(InlineMessageKind::User);
let user = transcript_line_marked(&user_line, &styles, false, false, false, true, 80);
assert_eq!(
user.spans[0].style.fg,
Some(user_color),
"user text must read in the user color — response-ink makes turns indistinguishable"
);
let agent_line = line(InlineMessageKind::Agent);
let agent = transcript_line_marked(&agent_line, &styles, false, false, false, true, 80);
assert_eq!(agent.spans[0].style.fg, Some(response));
}
}
#[cfg(test)]
mod transcript_turn_tests {
use super::*;
fn tl(kind: InlineMessageKind, text: &str, block_id: usize) -> TranscriptLine {
TranscriptLine {
kind,
segments: vec![plain_segment(text)],
block_id,
}
}
fn spans_to_string(line: &Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn user_lines_are_bold_primary_without_prefix() {
let styles = active_styles();
let line = tl(InlineMessageKind::User, "refactor the parser", 0);
let rendered = transcript_line_marked(&line, &styles, false, false, false, true, 80);
assert_eq!(
rendered.spans.len(),
1,
"plain style renders no prefix span"
);
assert_eq!(
spans_to_string(&rendered),
"refactor the parser",
"user text renders as typed, no glyph"
);
assert!(
rendered.spans[0]
.style
.add_modifier
.contains(Modifier::BOLD),
"user body is the only bold transcript text"
);
}
#[test]
fn agent_tool_and_shell_lines_have_no_prefix() {
let styles = active_styles();
for (kind, label) in [
(InlineMessageKind::Agent, "agent"),
(InlineMessageKind::Tool, "tool"),
(InlineMessageKind::Pty, "shell"),
] {
let line = tl(kind, &format!("{label}-content"), 0);
let rendered = transcript_line_marked(&line, &styles, false, false, false, true, 80);
assert_eq!(
rendered.spans.len(),
1,
"{label} lines carry no marker spans"
);
assert_eq!(spans_to_string(&rendered), format!("{label}-content"));
}
}
#[test]
fn system_labels_render_on_block_start_only() {
let styles = active_styles();
let line = tl(InlineMessageKind::Error, "boom", 0);
let head = transcript_line_marked(&line, &styles, false, false, false, true, 80);
assert_eq!(spans_to_string(&head), "error: boom");
let body = transcript_line_marked(&line, &styles, false, false, false, false, 80);
assert_eq!(
spans_to_string(&body),
"boom",
"continuation lines drop the label"
);
}
#[test]
fn folded_head_keeps_the_block_label() {
let styles = active_styles();
let line = tl(InlineMessageKind::Error, "boom", 0);
let rendered = transcript_line_marked(&line, &styles, true, false, false, false, 80);
assert_eq!(
spans_to_string(&rendered),
"[+] error: boom",
"a collapsed block stays identifiable"
);
}
#[test]
fn transcript_line_marked_clamps_to_width() {
let styles = active_styles();
let big: String = "x".repeat(300);
let line = tl(InlineMessageKind::Agent, &big, 0);
let rendered = transcript_line_marked(&line, &styles, false, false, false, true, 40);
assert!(
rendered.width() <= 40,
"transcript row overflowed the terminal width: rendered.width()={}",
rendered.width()
);
}
}
#[cfg(test)]
mod scrollback_commit_tests {
use super::*;
fn tl(kind: InlineMessageKind, text: &str, block_id: usize) -> TranscriptLine {
TranscriptLine {
kind,
segments: vec![plain_segment(text)],
block_id,
}
}
fn long_transcript() -> Vec<TranscriptLine> {
(0..6)
.flat_map(|b| {
[
tl(InlineMessageKind::Agent, &format!("b{b}-line-one"), b),
tl(InlineMessageKind::Agent, &format!("b{b}-line-two"), b),
]
})
.collect()
}
#[test]
fn commit_plan_sheds_oldest_blocks_and_keeps_the_tail() {
let state = RenderState {
transcript: long_transcript(),
..Default::default()
};
let styles = active_styles();
let display = build_transcript_display(&state, &styles, 0, 80);
let plan = scrollback_commit_plan(&display, &state.transcript, 80, 4, None).expect("plan");
assert_eq!(plan.rows, 8, "12 rows total, keep 4 → commit 8");
assert_eq!(plan.new_committed_entries, 8);
}
#[test]
fn commit_plan_never_splits_a_block() {
let transcript: Vec<TranscriptLine> = (0..3)
.flat_map(|b| {
(0..3).map(move |i| tl(InlineMessageKind::Agent, &format!("b{b}-{i}"), b))
})
.collect();
let state = RenderState {
transcript,
..Default::default()
};
let styles = active_styles();
let display = build_transcript_display(&state, &styles, 0, 80);
let plan = scrollback_commit_plan(&display, &state.transcript, 80, 5, None).expect("plan");
assert_eq!(
plan.new_committed_entries, 3,
"boundary snaps to block start"
);
assert_eq!(plan.rows, 3);
}
#[test]
fn commit_plan_excludes_the_streaming_anchor() {
let state = RenderState {
transcript: long_transcript(),
stream_anchor: Some(4),
..Default::default()
};
let styles = active_styles();
let display = build_transcript_display(&state, &styles, 0, 80);
let plan = scrollback_commit_plan(&display, &state.transcript, 80, 4, state.stream_anchor)
.expect("plan");
assert!(
plan.new_committed_entries <= 4,
"nothing at/after the anchored (streaming) block commits"
);
}
#[test]
fn committed_entries_floor_the_live_render() {
let mut state = RenderState::default();
state.transcript = long_transcript();
state.committed_entries = 8;
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("backend");
terminal
.draw(|f| render_frame(f, &state, &unused_handle()))
.expect("draw");
let buf = terminal.backend().buffer();
let area = buf.area();
let mut rendered = String::new();
for y in 0..area.height {
for x in 0..area.width {
if let Some(cell) = buf.cell((x, y)) {
rendered.push_str(cell.symbol());
}
}
rendered.push('\n');
}
assert!(
!rendered.contains("b0-line-one"),
"committed blocks leave the viewport"
);
assert!(rendered.contains("b5-line"), "the live tail stays");
}
#[test]
fn search_skips_committed_entries() {
let mut state = RenderState::default();
state.transcript = long_transcript();
state.committed_entries = 8;
state.start_search("line-one");
let s = state.search.as_ref().expect("search open");
assert!(
s.matches.iter().all(|&i| i >= 8),
"matches confined to the live region: {:?}",
s.matches
);
assert!(!s.matches.is_empty());
}
fn unused_handle() -> InlineHandle {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
InlineHandle::new_for_tests(tx)
}
#[test]
fn commit_plan_noop_when_tail_fits() {
let state = RenderState {
transcript: long_transcript(),
..Default::default()
};
let styles = active_styles();
let display = build_transcript_display(&state, &styles, 0, 80);
assert!(scrollback_commit_plan(&display, &state.transcript, 80, 12, None).is_none());
}
#[test]
fn oversized_block_commits_its_head_at_line_granularity() {
let mut transcript: Vec<TranscriptLine> = (0..30)
.map(|i| tl(InlineMessageKind::Agent, &format!("big-{i:02}"), 0))
.collect();
transcript.push(tl(InlineMessageKind::Agent, "tail-a", 1));
transcript.push(tl(InlineMessageKind::Agent, "tail-b", 1));
let state = RenderState {
transcript,
..Default::default()
};
let styles = active_styles();
let display = build_transcript_display(&state, &styles, 0, 80);
let plan = scrollback_commit_plan(&display, &state.transcript, 80, 10, None).expect("plan");
assert_eq!(
plan.new_committed_entries, 22,
"32 rows, keep 10 → head 22 rows commit"
);
assert_eq!(plan.rows, 22);
}
#[test]
fn rebuild_only_on_width_change() {
assert!(!should_rebuild_scrollback(80, 80, 24, 30));
assert!(should_rebuild_scrollback(80, 100, 24, 24));
assert!(should_rebuild_scrollback(100, 80, 30, 24));
assert!(!should_rebuild_scrollback(0, 100, 24, 24));
assert!(!should_rebuild_scrollback(0, 80, 24, 24));
}
#[test]
fn force_flush_boundary_is_everything() {
assert_eq!(plan_full_flush(0), 0);
assert_eq!(plan_full_flush(8), 8);
assert_eq!(plan_full_flush(40), 40);
}
}
#[cfg(test)]
mod tool_box_tests {
use super::*;
fn row_text(row: &[InlineSegment]) -> String {
row.iter().map(|s| s.text.as_str()).collect()
}
#[test]
fn korean_rows_keep_the_right_border_aligned() {
let rows = tool_box_rows(
"한글테스트 명령어",
20,
InlineTextStyle::default(),
anstyle::Color::Ansi(anstyle::AnsiColor::White),
);
for row in &rows {
let text = row_text(row);
assert_eq!(text.width(), 20, "row must fill exactly 20 cells: {text:?}");
assert!(text.starts_with('\u{2502}'), "left border: {text:?}");
assert!(text.ends_with('\u{2502}'), "right border: {text:?}");
}
assert!(!rows.is_empty());
assert_eq!(rows.len(), 2, "wraps by display width");
}
#[test]
fn divider_carries_the_label() {
let seg = tool_box_divider(
"Output",
30,
anstyle::Color::Ansi(anstyle::AnsiColor::White),
);
let text = row_text(&seg);
assert!(
text.starts_with("\u{251C}\u{2500} Output"),
"label after ├─: {text:?}"
);
assert!(text.ends_with('\u{2524}'), "closes with ┤: {text:?}");
assert_eq!(text.width(), 30, "divider fills the box width");
}
}
#[cfg(test)]
mod tool_box_width_tests {
use super::*;
#[test]
fn tool_box_width_matches_live_content_width() {
let state = RenderState {
viewport_width: 100,
..Default::default()
};
assert_eq!(tool_box_width(&state), 98);
}
}
#[test]
fn tool_box_rows_expand_tabs_so_borders_align() {
let chunk = format!("{:>6}\t{}", 1, "[package]");
let rows = tool_box_rows(
&chunk,
176,
InlineTextStyle::default(),
anstyle::Color::Ansi(anstyle::AnsiColor::White),
);
assert_eq!(rows.len(), 1);
for seg in &rows[0] {
assert!(
!seg.text.contains('\t'),
"tabs must be expanded: {:?}",
seg.text
);
}
let built: usize = rows[0]
.iter()
.map(|s| UnicodeWidthStr::width(s.text.as_str()))
.sum();
assert_eq!(built, 176, "built width must equal the box width exactly");
}
#[cfg(test)]
mod contextual_hint_tests {
use super::*;
#[test]
fn brain_chip_lives_on_the_composer_border() {
let mut state = RenderState::default();
state.header_context.provider = "prov".to_string();
state.header_context.model = "prov/m-1".to_string();
let off = spans_to_string_border(&state);
assert!(!off.contains("brain"), "chip hidden when off: {off}");
state.brain = BrainChip::Ok;
let ok = spans_to_string_border(&state);
assert!(ok.contains("brain·ok"), "healthy chip on border: {ok}");
assert!(
ok.trim_end().ends_with("brain·ok"),
"chip is right-aligned: {ok}"
);
state.brain = BrainChip::Down;
let down = spans_to_string_border(&state);
assert!(down.contains("brain·down"), "degraded chip: {down}");
}
#[test]
fn brain_chip_does_not_erase_the_border_rule() {
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("backend");
let mut state = RenderState::default();
state.header_context.provider = "prov".to_string();
state.header_context.model = "prov/m-1".to_string();
state.brain = BrainChip::Ok;
terminal
.draw(|f| render_frame(f, &state, &unused_test_handle()))
.expect("draw");
let buf = terminal.backend().buffer();
let border_row = (0..buf.area().height)
.map(|y| {
(0..buf.area().width)
.filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
.collect::<String>()
})
.find(|row| row.contains("MODEL") && row.contains(" | "))
.expect("composer border row rendered");
let rule_count = border_row.chars().filter(|c| *c == '\u{2500}').count();
assert!(
rule_count >= 10,
"the ─ rule must survive right of the facts: {border_row}"
);
assert!(
border_row.contains("brain\u{b7}ok"),
"chip still on the border: {border_row}"
);
}
#[test]
fn run_indicator_stays_up_between_turns() {
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("backend");
let state = RenderState {
active_run: Some(RunState {
started_at: std::time::Instant::now(),
turn: 2,
tool_calls: 3,
}),
reasoning_stage: None,
..Default::default()
};
terminal
.draw(|f| render_frame(f, &state, &unused_test_handle()))
.expect("draw");
let buf = terminal.backend().buffer();
let row: String = (0..buf.area().width)
.filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
.collect();
assert!(row.contains("RUNNING"), "row stays up between turns: {row}");
assert!(row.contains("working"), "stage fallback label: {row}");
assert!(row.contains("turn 2"), "turn count: {row}");
assert!(row.contains("3 tool calls"), "tool count: {row}");
assert!(row.contains("Esc abort"), "abort hint stays: {row}");
assert!(
RUN_SPINNER.iter().any(|f| row.contains(f)),
"animated spinner frame: {row}"
);
}
#[test]
fn spinner_frame_is_wall_clock_not_draw_count() {
let state = || RenderState {
active_run: Some(RunState::default()),
reasoning_stage: None,
..Default::default()
};
let spinner_of = |s: &RenderState| -> Option<char> {
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("backend");
terminal
.draw(|f| render_frame(f, s, &unused_test_handle()))
.expect("draw");
let buf = terminal.backend().buffer();
let row: String = (0..buf.area().width)
.filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
.collect();
row.chars()
.find(|c| RUN_SPINNER.iter().any(|f| f.starts_with(*c)))
};
let first = spinner_of(&state());
let second = spinner_of(&state());
assert!(first.is_some(), "spinner renders");
assert_eq!(
first, second,
"back-to-back draws must not advance the spinner"
);
}
#[test]
fn elapsed_formats_minutes_beyond_60s() {
assert_eq!(format_elapsed_secs(59), "59s");
assert_eq!(format_elapsed_secs(60), "1m 00s");
assert_eq!(format_elapsed_secs(125), "2m 05s");
}
#[test]
fn reasoning_row_carries_the_abort_hint() {
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("backend");
let state = RenderState {
reasoning_stage: Some("thinking\u{2026}".into()),
..Default::default()
};
terminal
.draw(|f| render_frame(f, &state, &unused_test_handle()))
.expect("draw");
let buf = terminal.backend().buffer();
let row: String = (0..buf.area().width)
.filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
.collect();
assert!(
row.contains("Esc abort"),
"streaming shows the contextual abort hint: {row}"
);
}
#[test]
fn pending_quit_owns_the_hint_row() {
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("backend");
let state = RenderState {
pending_quit: true,
..Default::default()
};
terminal
.draw(|f| render_frame(f, &state, &unused_test_handle()))
.expect("draw");
let buf = terminal.backend().buffer();
let row: String = (0..buf.area().width)
.filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
.collect();
assert!(
row.contains("press Ctrl+C again to quit"),
"armed quit shows its hint: {row}"
);
}
fn spans_to_string_border(state: &RenderState) -> String {
let line = composer_context_line(state, 200);
let mut text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let used = line.spans.iter().map(|s| s.width()).sum();
if let Some(chip) = composer_brain_chip(state, 200, used) {
assert_eq!(
chip.alignment,
Some(Alignment::Right),
"the chip is its own right-aligned title"
);
text.extend(chip.spans.iter().map(|s| s.content.as_ref()));
}
text
}
fn unused_test_handle() -> InlineHandle {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
InlineHandle::new_for_tests(tx)
}
}
#[cfg(test)]
mod trailing_breath_tests {
use super::*;
#[test]
fn display_ends_at_the_last_line_no_trailing_blank_item() {
let state = RenderState {
transcript: vec![TranscriptLine {
kind: InlineMessageKind::Agent,
segments: vec![plain_segment("answer")],
block_id: 0,
}],
..Default::default()
};
let styles = active_styles();
let display = build_transcript_display(&state, &styles, 0, 80);
assert_eq!(display.len(), 1, "the gap is layout, not a display item");
assert!(display[0].line.is_some());
}
#[test]
fn scrollback_area_reserves_one_breath_row_above_the_composer() {
let area = Rect {
x: 0,
y: 0,
width: 100,
height: 30,
};
let layout = super::super::frame_layout::compute_chrome(area);
assert_eq!(
layout.scrollback.bottom() + 1,
layout.prompt.y,
"exactly one row separates the transcript from the composer"
);
assert_eq!(
super::super::frame_layout::scrollback_height(area),
layout.scrollback.height,
"the commit keep-rows must match the rendered area"
);
}
}
#[cfg(test)]
mod nerd_icon_tests {
use super::*;
fn border_text(state: &RenderState) -> String {
let line = composer_context_line(state, 200);
let mut text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let used = line.spans.iter().map(|s| s.width()).sum();
if let Some(chip) = composer_brain_chip(state, 200, used) {
text.extend(chip.spans.iter().map(|s| s.content.as_ref()));
}
text
}
fn base_state() -> RenderState {
let mut state = RenderState::default();
state.header_context.provider = "prov".to_string();
state.header_context.model = "prov/m-1".to_string();
state.brain = BrainChip::Ok;
state
}
#[test]
fn nerd_mode_replaces_labels_with_private_use_glyphs() {
let mut state = base_state();
state.glyph_set = crate::symbols::GlyphSet::Nerd;
let text = border_text(&state);
assert!(!text.contains("MODEL "), "text label gone: {text}");
assert!(
text.contains(crate::symbols::nerd::MODEL),
"robot glyph for the model: {text}"
);
assert!(
text.contains(crate::symbols::nerd::GIT),
"git glyph present: {text}"
);
assert!(
text.contains(crate::symbols::nerd::BRAIN),
"brain glyph chip: {text}"
);
for ch in text.chars() {
let cp = ch as u32;
let private_use = (0xE000..=0xF8FF).contains(&cp)
|| (0xF0000..=0xFFFFD).contains(&cp)
|| (0x100000..=0x10FFFD).contains(&cp);
assert!(
!('\u{1F300}'..='\u{1FAFF}').contains(&ch) || !private_use,
"sanity"
);
}
}
#[test]
fn unicode_default_keeps_text_labels() {
let state = base_state();
let text = border_text(&state);
assert!(text.contains("MODEL "), "default keeps text: {text}");
assert!(text.contains("brain\u{b7}ok"), "default chip text: {text}");
}
}
#[cfg(test)]
mod glyph_cycle_tests {
use crate::symbols::GlyphSet;
#[test]
fn glyph_set_cycles_unicode_ascii_nerd() {
assert_eq!(GlyphSet::Unicode.next(), GlyphSet::Ascii);
assert_eq!(GlyphSet::Ascii.next(), GlyphSet::Nerd);
assert_eq!(GlyphSet::Nerd.next(), GlyphSet::Unicode);
}
}
#[cfg(test)]
mod coalesce_draw_tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn defer_within_interval() {
let now = Instant::now();
let last = now - Duration::from_millis(10);
assert_eq!(
coalesce_draw(last, false, DRAW_MIN_INTERVAL),
DrawDecision::Defer
);
}
#[test]
fn draw_now_on_priority_even_within_interval() {
let now = Instant::now();
let last = now - Duration::from_millis(10);
assert_eq!(
coalesce_draw(last, true, DRAW_MIN_INTERVAL),
DrawDecision::DrawNow
);
}
#[test]
fn draw_now_when_interval_elapsed() {
let now = Instant::now();
let last = now - Duration::from_millis(60);
assert_eq!(
coalesce_draw(last, false, DRAW_MIN_INTERVAL),
DrawDecision::DrawNow
);
}
}
#[cfg(test)]
mod settings_panel_tests {
use super::*;
use crate::app::agent_session::AgentSessionHandle;
use crate::store::settings::Settings;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn make_session_with_tools() -> AgentSessionHandle {
super::provider_overlay_tests::make_session_with_tools_for_tests()
}
use oxicode_vtui::tui::core::InlineListSelection;
use ratatui::{Terminal, backend::TestBackend};
fn heading(title: &str) -> OverlayListItem {
OverlayListItem {
title: title.into(),
subtitle: None,
badge: None,
indent: 0,
search_value: None,
selection: None,
}
}
fn row(title: &str, badge: &str, selection: Option<InlineListSelection>) -> OverlayListItem {
OverlayListItem {
title: title.into(),
subtitle: None,
badge: Some(badge.into()),
indent: 0,
search_value: None,
selection,
}
}
fn rows_with_positions(terminal: &Terminal<TestBackend>) -> Vec<(u16, String, Vec<u16>)> {
let buf = terminal.backend().buffer();
let area = buf.area();
let mut out = Vec::new();
for y in 0..area.height {
let mut text = String::new();
let mut xs = Vec::new();
for x in 0..area.width {
if let Some(cell) = buf.cell((x, y)) {
text.push_str(cell.symbol());
xs.push(x);
}
}
out.push((y, text, xs));
}
out
}
fn occurrences(rows: &[(u16, String, Vec<u16>)], needle: &str) -> Vec<(u16, usize)> {
let mut hits = Vec::new();
for (y, text, xs) in rows {
let mut from = 0;
while let Some(rel) = text[from..].find(needle) {
let byte_idx = from + rel;
let char_idx = text[..byte_idx].chars().count();
if let Some(&x) = xs.get(char_idx) {
hits.push((*y, x as usize));
}
from = byte_idx + needle.len();
}
}
hits
}
#[test]
fn render_overlay_tabbed_settings_shows_tab_bar_and_sidebar() {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let overlay = OverlayState {
title: "Settings".into(),
lines: Vec::new(),
items: vec![
heading("Defaults"),
row(
"Thinking level",
"medium",
Some(InlineListSelection::ConfigAction("ThinkingLevel".into())),
),
row("Model roles", "0", None),
heading("Pointers"),
row("Theme", "dark", None),
],
selected: 1,
search: None,
secure_input: None,
tabs: vec!["General".into(), "Model".into(), "Interaction".into()],
active_tab: 1,
sections: vec!["Defaults".into(), "Pointers".into()],
active_section: 0,
key_capture: None,
};
terminal
.draw(|f| render_overlay(f, f.area(), &overlay))
.unwrap();
let rows = rows_with_positions(&terminal);
let general = occurrences(&rows, "General");
let interaction = occurrences(&rows, "Interaction");
assert!(
general
.iter()
.any(|(gy, _)| interaction.iter().any(|(iy, _)| gy == iy)),
"tab bar must list tabs on one row"
);
for name in ["Defaults", "Pointers"] {
let hits = occurrences(&rows, name);
assert!(hits.len() >= 2, "{name} must render in sidebar AND list");
assert!(
hits.iter().any(|(_, x)| *x < 13),
"{name} must render in the sidebar column"
);
assert!(
hits.iter().any(|(_, x)| *x >= 13),
"{name} must render in the item column"
);
}
let buf = terminal.backend().buffer();
let pointers_item_col = occurrences(&rows, "Pointers")
.into_iter()
.find(|(_, x)| *x >= 13)
.expect("items-column Pointers heading");
let cell = buf
.cell((pointers_item_col.1 as u16, pointers_item_col.0))
.expect("cell");
assert!(
cell.modifier.contains(Modifier::DIM),
"out-of-section rows must be dimmed"
);
let defaults_item_col = occurrences(&rows, "Defaults")
.into_iter()
.find(|(_, x)| *x >= 13)
.expect("items-column Defaults heading");
let cell = buf
.cell((defaults_item_col.1 as u16, defaults_item_col.0))
.expect("cell");
assert!(
!cell.modifier.contains(Modifier::DIM),
"active-section rows must not be dimmed"
);
}
#[test]
fn rebound_send_now_combo_submits_immediately() {
let state = Arc::new(parking_lot::Mutex::new(RenderState::default()));
let mut overrides = std::collections::HashMap::new();
overrides.insert("SendNow".to_string(), vec!["Alt+s".to_string()]);
*state.lock().keymap.write() = Keymap::from_settings(&overrides);
let alt_s = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::ALT);
let action = state
.lock()
.keymap
.read()
.resolve(alt_s)
.expect("Alt+s must resolve to SendNow after the rebind");
assert!(matches!(action, GlobalAction::SendNow));
let ctrl_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL);
assert_eq!(state.lock().keymap.read().resolve(ctrl_enter), None);
let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
assert!(matches!(
state.lock().keymap.read().resolve(ctrl_p),
Some(GlobalAction::OpenCommandPalette)
));
state.lock().composer.set_text("send me now");
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
apply_global_action(action, &state, &tx);
assert_eq!(state.lock().composer.text(), "");
match rx.try_recv().expect("interrupt fires first") {
InlineEvent::Interrupt => {}
other => panic!("expected Interrupt first, got {other:?}"),
}
match rx.try_recv().expect("submit fires second") {
InlineEvent::Submit(text) => assert_eq!(&*text, "send me now"),
other => panic!("expected Submit, got {other:?}"),
}
assert!(rx.try_recv().is_err(), "no further events");
}
#[test]
fn settings_selection_variants_round_trip_names() {
assert_eq!(
InlineListSelection::SettingsTab(1),
InlineListSelection::SettingsTab(1)
);
assert_eq!(
InlineListSelection::SettingsSection(0),
InlineListSelection::SettingsSection(0)
);
assert_eq!(
InlineListSelection::SettingKeyCapture("OpenCommandPalette".into()),
InlineListSelection::SettingKeyCapture("OpenCommandPalette".into())
);
assert_eq!(
InlineListSelection::SettingTextEdit("ToolTimeoutSecs".into()),
InlineListSelection::SettingTextEdit("ToolTimeoutSecs".into())
);
assert_eq!(
InlineListSelection::SettingSubmenuOpen("AdvisorSyncBacklog".into()),
InlineListSelection::SettingSubmenuOpen("AdvisorSyncBacklog".into())
);
assert_eq!(
InlineListSelection::SettingMultiselect("DisabledTools".into()),
InlineListSelection::SettingMultiselect("DisabledTools".into())
);
}
#[test]
fn key_capture_appends_combo_and_keeps_default_resolving() {
let real_settings = dirs::home_dir()
.map(|h| h.join(".oxicode").join("settings.json"))
.filter(|p| p.exists());
let real_settings_mtime_before = real_settings
.as_ref()
.and_then(|p| std::fs::metadata(p).ok())
.and_then(|m| m.modified().ok());
let real_settings_sha_before = real_settings
.as_ref()
.and_then(|p| std::fs::read(p).ok())
.map(|b| {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
b.hash(&mut h);
h.finish()
});
let tmp = tempfile::tempdir().expect("tempdir");
let sandbox = tmp.path().join("settings.json");
let mut state = RenderState::default();
state.settings_override_path = Some(sandbox.clone());
state.overlay = Some(build_key_capture_overlay(
GlobalAction::OpenCommandPalette.name(),
));
state.settings_map_rows.clear();
let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
let alt_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::ALT);
assert_eq!(
state.keymap.read().resolve(ctrl_p),
Some(GlobalAction::OpenCommandPalette)
);
assert_eq!(state.keymap.read().resolve(alt_p), None);
handle_key_capture(&mut state, alt_p);
let keymap = state.keymap.read();
assert_eq!(
keymap.resolve(ctrl_p),
Some(GlobalAction::OpenCommandPalette)
);
assert_eq!(
keymap.resolve(alt_p),
Some(GlobalAction::OpenCommandPalette)
);
drop(keymap);
let overlay = state
.overlay
.as_ref()
.expect("capture commits reopen the panel on Keybindings");
assert!(
overlay.key_capture.is_none(),
"capture prompt must be closed"
);
assert_eq!(
overlay.lines.first().map(String::as_str),
Some("Captured Alt+p for OpenCommandPalette")
);
assert_eq!(state.settings_active_tab, SettingsTab::Keybindings);
let sandbox_contents = std::fs::read_to_string(&sandbox)
.expect("sandbox settings.json must exist after capture");
assert!(
sandbox_contents.contains("OpenCommandPalette"),
"sandbox file must contain the captured keybinding override; got {sandbox_contents}"
);
assert!(
sandbox_contents.contains("Alt+p"),
"sandbox file must contain the captured Alt+p combo; got {sandbox_contents}"
);
if let Some(before) = real_settings_mtime_before {
let after = real_settings
.as_ref()
.and_then(|p| std::fs::metadata(p).ok())
.and_then(|m| m.modified().ok())
.expect("real settings.json must still exist after capture");
assert_eq!(
before, after,
"real ~/.oxicode/settings.json mtime must not change (sandbox leak)"
);
}
if let (Some(before), Some(after)) = (
real_settings_sha_before,
real_settings
.as_ref()
.and_then(|p| std::fs::read(p).ok())
.map(|b| {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
b.hash(&mut h);
h.finish()
}),
) {
assert_eq!(
before, after,
"real ~/.oxicode/settings.json content must not change (sandbox leak)"
);
}
}
#[test]
fn remove_keybinding_combo_refuses_to_drop_the_last_combo() {
let mut state = RenderState::default();
let mut settings = Settings::default();
crate::tui_vt::settings_defs::set_action_combos(
&mut settings,
GlobalAction::OpenCommandPalette,
vec!["Ctrl+p".to_string()],
);
*state.keymap.write() = Keymap::from_settings(&settings.keybindings);
assert_eq!(
state
.keymap
.read()
.action_combos(GlobalAction::OpenCommandPalette)
.len(),
1,
"test setup: action must start with exactly one combo"
);
state.settings_active_tab = SettingsTab::Keybindings;
remove_keybinding_combo(&mut state, GlobalAction::OpenCommandPalette, "Ctrl+p");
let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
assert_eq!(
state.keymap.read().resolve(ctrl_p),
Some(GlobalAction::OpenCommandPalette),
"the guard must not let OpenCommandPalette go combo-less"
);
assert_eq!(
state
.keymap
.read()
.action_combos(GlobalAction::OpenCommandPalette)
.len(),
1,
"no combo was removed"
);
let overlay = state.overlay.as_ref().expect("reopen leaves the panel up");
assert!(
overlay
.lines
.first()
.map(|l| l.contains("Refusing to remove the last combo"))
.unwrap_or(false),
"panel must explain why the removal was refused; got {:?}",
overlay.lines
);
}
#[test]
fn text_edit_commit_valid_input() {
let tmp = tempfile::tempdir().expect("tempdir");
let sandbox = tmp.path().join("settings.json");
let mut state = RenderState::default();
state.settings_override_path = Some(sandbox.clone());
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let handle = InlineHandle::new_for_tests(tx);
let (outcome, _msg) = commit_text_edit(
&mut state,
&handle,
None,
SettingKey::SessionHistorySize,
"300".to_string(),
);
assert!(outcome.is_ok(), "valid numeric input must commit");
let contents = std::fs::read_to_string(&sandbox).expect("sandbox written");
let saved: Settings = serde_json::from_str(&contents).expect("sandbox parses");
assert_eq!(
saved.session_history_size, 300,
"sandbox must hold session_history_size=300"
);
let overlay = state.overlay.as_ref().expect("panel reopened");
assert!(
overlay
.lines
.first()
.map(|l| l.contains("300"))
.unwrap_or(false),
"status line must show the new value; got {:?}",
overlay.lines
);
let mut saw_info = false;
while let Ok(cmd) = rx.try_recv() {
if let InlineCommand::AppendLine { kind, .. } = cmd
&& matches!(kind, InlineMessageKind::Info)
{
saw_info = true;
}
}
assert!(saw_info, "commit must surface an Info line");
}
#[test]
fn text_edit_commit_invalid_input_is_rejected() {
let tmp = tempfile::tempdir().expect("tempdir");
let sandbox = tmp.path().join("settings.json");
let mut state = RenderState::default();
state.settings_override_path = Some(sandbox.clone());
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let handle = InlineHandle::new_for_tests(tx);
let (outcome, msg) = commit_text_edit(
&mut state,
&handle,
None,
SettingKey::SessionHistorySize,
"not-a-number".to_string(),
);
assert!(outcome.is_err(), "non-numeric input must be rejected");
assert!(
msg.contains("invalid") || msg.contains("ParseError") || !msg.is_empty(),
"rejection must carry a reason; got {msg}"
);
assert!(
!sandbox.exists(),
"rejected input must not persist anything"
);
let mut saw_error = false;
while let Ok(cmd) = rx.try_recv() {
if let InlineCommand::AppendLine { kind, .. } = cmd
&& matches!(kind, InlineMessageKind::Error)
{
saw_error = true;
}
}
assert!(saw_error, "rejection must surface an Error line");
}
#[test]
fn submenu_select_commit_for_sync_backlog() {
let tmp = tempfile::tempdir().expect("tempdir");
let sandbox = tmp.path().join("settings.json");
let mut state = RenderState::default();
state.settings_override_path = Some(sandbox.clone());
open_submenu_select_prompt(&mut state, SettingKey::AdvisorSyncBacklog);
let overlay = state.overlay.as_ref().expect("submenu overlay opens");
assert_eq!(overlay.items.len(), 3, "off/sync/async rows");
let titles: Vec<&str> = overlay.items.iter().map(|i| i.title.as_str()).collect();
assert_eq!(titles, vec!["off", "sync", "async"]);
for item in &overlay.items {
let sel = item.selection.as_ref().expect("row is selectable");
match sel {
InlineListSelection::ConfigAction(p) => {
assert!(
p.starts_with("SubmenuCommit:AdvisorSyncBacklog:"),
"payload must address the key; got {p}"
);
}
other => panic!("expected ConfigAction, got {other:?}"),
}
}
let status = commit_submenu_choice(
&mut state,
SettingKey::AdvisorSyncBacklog,
"async".to_string(),
)
.expect("valid option commits");
assert!(status.contains("async"), "status names the new value");
let contents = std::fs::read_to_string(&sandbox).expect("sandbox written");
assert!(
contents.contains("async"),
"sandbox must hold the async choice: {contents}"
);
let overlay = state.overlay.as_ref().expect("panel reopened");
assert!(
overlay
.lines
.first()
.map(|l| l.contains("async"))
.unwrap_or(false),
"status line must show the new value"
);
}
#[test]
fn multiselect_toggles_tool_and_refuses_essential() {
let session = make_session_with_tools();
let tmp = tempfile::tempdir().expect("tempdir");
let sandbox = tmp.path().join("settings.json");
let mut state = RenderState::default();
state.settings_override_path = Some(sandbox.clone());
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let handle = InlineHandle::new_for_tests(tx);
open_disabled_tools_multiselect(&mut state, &session);
let overlay = state.overlay.as_ref().expect("multiselect opens");
let titles: Vec<&str> = overlay.items.iter().map(|i| i.title.as_str()).collect();
assert_eq!(titles, vec!["bash", "commit"], "registry tools, sorted");
let bash = &overlay.items[0];
assert_eq!(bash.badge.as_deref(), Some("essential"));
let commit = &overlay.items[1];
assert_eq!(commit.badge.as_deref(), Some("enabled"));
commit_disabled_tool_toggle(
&mut state,
&handle,
&session,
"commit".to_string(),
false, false, );
let saved: Settings = serde_json::from_str(&std::fs::read_to_string(&sandbox).unwrap())
.expect("sandbox parses");
assert!(
saved.disabled_tools.iter().any(|t| t == "commit"),
"toggle must add 'commit' to disabled_tools; got {:?}",
saved.disabled_tools
);
commit_disabled_tool_toggle(
&mut state,
&handle,
&session,
"commit".to_string(),
false, true, );
let saved: Settings = serde_json::from_str(&std::fs::read_to_string(&sandbox).unwrap())
.expect("sandbox parses");
assert!(
!saved.disabled_tools.iter().any(|t| t == "commit"),
"toggle must remove 'commit' from disabled_tools; got {:?}",
saved.disabled_tools
);
let before = std::fs::read_to_string(&sandbox).unwrap();
commit_disabled_tool_toggle(
&mut state,
&handle,
&session,
"bash".to_string(),
true, false, );
let after = std::fs::read_to_string(&sandbox).unwrap();
assert_eq!(before, after, "essential refusal must not write");
let mut saw_refusal = false;
while let Ok(cmd) = rx.try_recv() {
if let InlineCommand::AppendLine { kind, segments } = cmd
&& matches!(kind, InlineMessageKind::Error)
&& segments.iter().any(|s| s.text.contains("essential"))
{
saw_refusal = true;
}
}
assert!(
saw_refusal,
"essential refusal must surface an Error line mentioning 'essential'"
);
}
}
#[cfg(test)]
mod image_preview_hook_tests {
use super::*;
use base64::{Engine, engine::general_purpose};
use tokio::sync::mpsc;
fn image_result_content(payload: &[u8]) -> String {
let b64 = general_purpose::STANDARD.encode(payload);
format!(
"Generated 1 image(s).\n\nImage 1 ({} bytes, base64):\n{}\n",
payload.len(),
b64
)
}
fn fresh_handle() -> (InlineHandle, mpsc::UnboundedReceiver<InlineCommand>) {
let (tx, rx) = mpsc::unbounded_channel();
(InlineHandle::new_for_tests(tx), rx)
}
fn apply_all(state: &mut RenderState, rx: &mut mpsc::UnboundedReceiver<InlineCommand>) {
while let Ok(cmd) = rx.try_recv() {
apply_command(state, cmd);
}
}
fn transcript_text(state: &RenderState) -> Vec<String> {
state
.transcript
.iter()
.map(|l| {
l.segments
.iter()
.map(|s| s.text.as_str())
.collect::<String>()
})
.collect()
}
#[test]
fn generate_image_result_renders_fallback_row_and_enqueues_live_preview() {
let mut state = RenderState::default();
let (handle, mut rx) = fresh_handle();
let payload = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
map_agent_event(
&handle,
AgentEvent::ToolExecutionStart {
tool_call_id: "img-1".into(),
tool_name: "generate_image".into(),
args: serde_json::json!({"prompt": "a cat"}),
intent: None,
context: None,
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::ToolExecutionEnd {
tool_call_id: "img-1".into(),
tool_name: "generate_image".into(),
intent: None,
result: oxicode_ai::ToolResult {
tool_call_id: "img-1".into(),
content: image_result_content(&payload),
status: "success".into(),
},
is_error: false,
},
&mut state,
);
apply_all(&mut state, &mut rx);
let texts = transcript_text(&state);
let fallback_idx = texts
.iter()
.position(|t| t.contains("[image: generate_image:"))
.expect("fallback row rendered in the tool box");
assert!(
texts
.iter()
.all(|t| !t.contains(&general_purpose::STANDARD.encode(payload))),
"raw base64 must never render as text"
);
assert_eq!(
state.image_previews.pending_len(),
1,
"decoded PNG enqueued for live placement"
);
let pending = &state.image_previews.pending()[0];
assert_eq!(&*pending.png, &payload, "decoded bytes round-trip");
assert!(
texts[fallback_idx].contains(&pending.label),
"label {label:?} matches the fallback row {row:?}",
label = pending.label,
row = texts[fallback_idx],
);
}
#[test]
fn generate_image_without_payload_keeps_generic_preview() {
let mut state = RenderState::default();
let (handle, mut rx) = fresh_handle();
map_agent_event(
&handle,
AgentEvent::ToolExecutionStart {
tool_call_id: "img-2".into(),
tool_name: "generate_image".into(),
args: serde_json::json!({"prompt": "a cat"}),
intent: None,
context: None,
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::ToolExecutionEnd {
tool_call_id: "img-2".into(),
tool_name: "generate_image".into(),
intent: None,
result: oxicode_ai::ToolResult {
tool_call_id: "img-2".into(),
content: "Image generation completed but returned no images.".into(),
status: "success".into(),
},
is_error: false,
},
&mut state,
);
apply_all(&mut state, &mut rx);
let texts = transcript_text(&state);
assert!(
texts.iter().any(|t| t.contains("returned no images")),
"generic preview path still renders the summary"
);
assert_eq!(state.image_previews.pending_len(), 0);
}
#[test]
fn live_frame_anchors_and_emits_kitty_sequence() {
use crate::tui_vt::image_preview::{ImagePreviews, ImageSupport};
use ratatui::{Terminal, backend::TestBackend};
let mut state = RenderState::default();
state.image_previews = ImagePreviews::new(ImageSupport::Kitty);
let (handle, mut rx) = fresh_handle();
let payload = [0x89u8, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
map_agent_event(
&handle,
AgentEvent::ToolExecutionStart {
tool_call_id: "img-3".into(),
tool_name: "generate_image".into(),
args: serde_json::json!({"prompt": "a dog"}),
intent: None,
context: None,
},
&mut state,
);
map_agent_event(
&handle,
AgentEvent::ToolExecutionEnd {
tool_call_id: "img-3".into(),
tool_name: "generate_image".into(),
intent: None,
result: oxicode_ai::ToolResult {
tool_call_id: "img-3".into(),
content: image_result_content(&payload),
status: "success".into(),
},
is_error: false,
},
&mut state,
);
apply_all(&mut state, &mut rx);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("backend");
let (tx, _drain) = mpsc::unbounded_channel();
terminal
.draw(|frame| render_frame(frame, &state, &InlineHandle::new_for_tests(tx)))
.expect("draw");
let buf = terminal.backend().buffer().clone();
let frame_text: String = (0..buf.area().height)
.map(|y| {
(0..buf.area().width)
.filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
frame_text.contains("[image: generate_image:"),
"live frame paints the fallback row"
);
let seq = state.image_previews.emit_live(state.committed_entries);
assert!(seq.contains("\x1b["));
assert!(seq.contains("\x1b_Ga=t,f=100"), "transmit");
assert!(seq.contains("a=p"), "placement");
assert_eq!(state.image_previews.pending_len(), 0, "placed and consumed");
}
#[test]
fn extract_generated_png_parses_first_image_and_rejects_garbage() {
let payload = vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
assert_eq!(
extract_generated_png(&image_result_content(&payload)),
Some(payload),
"first base64 blob after the marker decodes"
);
let first: Vec<u8> = (1u8..=8).collect();
let second: Vec<u8> = (9u8..=16).collect();
let two = format!(
"Generated 2 image(s).\n\nImage 1 (8 bytes, base64):\n{}\n\nImage 2 (8 bytes, base64):\n{}\n",
general_purpose::STANDARD.encode(&first),
general_purpose::STANDARD.encode(&second),
);
assert_eq!(extract_generated_png(&two), Some(first));
assert_eq!(extract_generated_png("plain text output"), None);
assert_eq!(
extract_generated_png("Image 1 (8 bytes, base64):\n!!!not-base64!!!\n"),
None
);
assert_eq!(
extract_generated_png("Image 1 (2 bytes, base64):\n AQID \n"),
None,
"payloads shorter than a PNG header are rejected"
);
}
}