#![allow(
clippy::field_reassign_with_default,
clippy::let_and_return,
clippy::borrow_interior_mutable_const,
clippy::derivable_impls
)]
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, KeyEventKind,
KeyModifiers, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
},
execute,
terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate, disable_raw_mode, enable_raw_mode},
};
use oxicode_agent::AgentEvent;
use oxicode_vtui::theme::{ThemeStyles, active_styles};
use oxicode_vtui::tui::core::{
InlineCommand, InlineEvent, InlineHandle, InlineHeaderContext, InlineHeaderStatusBadge,
InlineHeaderStatusTone, InlineListItem, InlineListSelection, InlineMessageKind, InlineSegment,
InlineTextStyle, OverlayRequest, OverlaySubmission,
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap},
};
use crate::App;
use crate::app::agent_hub_registry::HubEntry;
use crate::app::agent_session::SessionEvent;
use crate::tui_vt::slash::registry::{SlashCtx, SlashOutcome, SlashRegistry};
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 backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
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)]
pub struct RenderState {
pub input_buffer: String,
pub input_cursor: usize,
pub transcript: Vec<TranscriptLine>,
pub scroll_offset: usize,
pub header_context: InlineHeaderContext,
pub input_enabled: bool,
pub footer_left: Option<String>,
pub footer_right: Option<String>,
pub prompt_prefix: String,
pub placeholder: Option<String>,
pub shutdown_requested: bool,
pub message_buffer: String,
pub agent_hub_open: bool,
pub hub_entries: Vec<(String, HubEntry)>,
pub pending_quit: bool,
pub slash_popup: SlashPopup,
pub reasoning_stage: Option<String>,
pub overlay: Option<OverlayState>,
pub overlay_model_ids: Vec<String>,
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_items: Vec<(String, bool)>,
pub vim_state: oxicode_vtui::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 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 seen_tips: std::collections::HashMap<&'static str, u32>,
}
#[derive(Debug, Clone)]
pub struct TranscriptLine {
pub kind: InlineMessageKind,
pub segments: Vec<InlineSegment>,
pub block_id: usize,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum BlockDisplayMode {
Collapsed,
#[default]
Truncated,
Expanded,
}
#[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)]
pub struct OverlayState {
pub title: String,
pub lines: Vec<String>,
pub items: Vec<OverlayListItem>,
pub selected: usize,
pub search: Option<OverlaySearchState>,
}
#[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,
}
#[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;
s
}
fn append_line(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
let block_id = self.block_id_for_kind(kind);
self.transcript.push(TranscriptLine {
kind,
segments,
block_id,
});
}
fn inline_segment(&mut self, kind: InlineMessageKind, segment: InlineSegment) {
if let Some(last) = self.transcript.last_mut()
&& last.kind == kind
{
last.segments.push(segment);
return;
}
let block_id = self.block_id_for_kind(kind);
self.transcript.push(TranscriptLine {
kind,
segments: vec![segment],
block_id,
});
}
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
}
pub fn start_search(&mut self, query: &str) {
let needle = query.to_lowercase();
let matches: Vec<usize> = self
.transcript
.iter()
.enumerate()
.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_default()
}
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::Truncated {
self.block_display.remove(&bid);
} else {
self.block_display.insert(bid, next);
}
}
}
pub fn expand_all(&mut self) {
for bid in self.all_block_ids() {
self.block_display.insert(bid, BlockDisplayMode::Expanded);
}
}
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) {
self.block_display.clear();
}
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_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 what you want to build\u{2026}".to_string()));
let state = Arc::new(parking_lot::Mutex::new(RenderState::new_with_header(
header,
)));
state.lock().cwd = cwd.clone();
state.lock().tip = Some(EphemeralTip {
text: "Press ? for shortcuts \u{00b7} /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,
);
}
spawn_input_thread(state.clone(), evt_tx.clone());
let prompt_tx = spawn_agent_worker(session_handle.clone());
let result = run_event_loop(
&mut tui.terminal,
&mut cmd_rx,
&mut evt_rx,
&mut session_rx,
&handle,
&state,
&session_handle,
prompt_tx.clone(),
)
.await;
drop(prompt_tx);
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>,
handle: &InlineHandle,
state: &Arc<parking_lot::Mutex<RenderState>>,
session: &crate::app::agent_session::AgentSessionHandle,
prompt_tx: tokio::sync::mpsc::UnboundedSender<String>,
) -> Result<()> {
while let Ok(cmd) = cmd_rx.try_recv() {
apply_command(&mut state.lock(), cmd);
}
{
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);
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() => {
handle_session_event(&mut state.lock(), handle, &event);
}
Some(evt) = evt_rx.recv() => {
let outcome = handle_inline_event(
&mut state.lock(),
handle,
session,
&prompt_tx,
evt,
);
if outcome == LoopOutcome::Exit {
break;
}
}
_ = tokio::signal::ctrl_c() => {
let outcome = {
let mut s = state.lock();
handle_interrupt(&mut s, session, handle)
};
if outcome == LoopOutcome::Exit {
break;
}
}
_ = render_tick.tick() => {}
}
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 snapshot = state.lock();
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(())
}
#[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::ReplaceLast {
count, kind, lines, ..
} => {
let drop = count.min(state.transcript.len());
for _ in 0..drop {
state.transcript.pop();
}
for line in lines {
state.append_line(kind, line);
}
}
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 { left, right } => {
state.footer_left = left;
state.footer_right = right;
}
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 } => {
state.overlay = Some(materialize_overlay(*request));
}
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) => OverlayState {
title: req.title,
lines: req.lines,
items: Vec::new(),
selected: 0,
search: None,
},
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,
}
}
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,
}
}
}
}
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,
}
}
fn handle_session_event(state: &mut RenderState, handle: &InlineHandle, event: &SessionEvent) {
match event {
SessionEvent::Agent(boxed) => {
map_agent_event(handle, *boxed.clone(), 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 { .. } => {
}
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 => {
}
}
}
fn map_agent_event(handle: &InlineHandle, event: AgentEvent, state: &mut RenderState) {
match event {
AgentEvent::TextChunk { text } => {
state.message_buffer.push_str(&text);
handle.inline(InlineMessageKind::Agent, plain_segment(text));
}
AgentEvent::MessageStart { .. } => {
state.message_buffer.clear();
}
AgentEvent::MessageUpdate { delta, .. } => match &delta {
oxicode_sdk::StreamDelta::Text(text) => {
state.message_buffer.push_str(text);
handle.inline(InlineMessageKind::Agent, plain_segment(text.clone()));
}
oxicode_sdk::StreamDelta::Thinking(text) => {
let mut style = InlineTextStyle::default();
style.effects |= anstyle::Effects::DIMMED;
let seg = InlineSegment {
text: format!("\u{2733} {text}"),
style: Arc::new(style),
};
handle.inline(InlineMessageKind::Info, seg);
}
oxicode_sdk::StreamDelta::Sync => {
if !state.message_buffer.is_empty() {
let lines =
oxicode_vtui::tui::ui::markdown::render_markdown(&state.message_buffer);
let count = lines.len();
if count > 0 {
handle.replace_last(count, InlineMessageKind::Agent, lines);
}
state.message_buffer.clear();
}
}
},
AgentEvent::MessageEnd { .. } => {
if !state.message_buffer.is_empty() {
let lines = oxicode_vtui::tui::ui::markdown::render_markdown(&state.message_buffer);
let count = lines.len();
if count > 0 {
handle.replace_last(count, InlineMessageKind::Agent, lines);
}
state.message_buffer.clear();
}
}
AgentEvent::ToolStart { tool_name, .. } => {
handle.append_line(
InlineMessageKind::Tool,
vec![plain_segment(format!("\u{2699} {tool_name}"))],
);
handle.set_reasoning_stage(Some(format!("tool: {tool_name}")));
}
AgentEvent::ToolComplete { result } => {
if !try_render_diff(&result.content, handle) {
let preview = preview_tool_result(&result.content);
let mut style = InlineTextStyle::default();
style.effects |= anstyle::Effects::DIMMED;
handle.append_line(
InlineMessageKind::Tool,
vec![InlineSegment {
text: format!("\u{2713} {preview}"),
style: Arc::new(style),
}],
);
}
handle.set_reasoning_stage(None);
handle.set_input_enabled(true);
}
AgentEvent::ToolError { error, .. } => {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("\u{2717} {error}"))],
);
handle.set_reasoning_stage(None);
handle.set_input_enabled(true);
}
AgentEvent::Error { message, .. } => {
handle.append_line(InlineMessageKind::Error, vec![plain_segment(message)]);
handle.set_input_enabled(true);
handle.set_input_status(None, None);
}
AgentEvent::Compaction { .. } => {
}
AgentEvent::Cancelled => {
handle.set_input_enabled(true);
handle.set_input_status(None, Some("cancelled".to_string()));
}
AgentEvent::AutoRetryStart {
attempt,
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();
handle.set_reasoning_stage(None);
}
_ => {
tracing::debug!(?event, "ignored AgentEvent variant");
}
}
}
fn handle_inline_event(
state: &mut RenderState,
handle: &InlineHandle,
session: &crate::app::agent_session::AgentSessionHandle,
prompt_tx: &tokio::sync::mpsc::UnboundedSender<String>,
evt: InlineEvent,
) -> LoopOutcome {
match evt {
InlineEvent::Submit(text) => {
let prompt = text.to_string();
state.input_buffer.clear();
state.input_cursor = 0;
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 => {
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 \u{00b7} Ctrl+; manages queue",
240,
true,
);
}
let _ = prompt_tx.send(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) => {
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(()) => 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.input_buffer = format!("/{name} ");
state.input_cursor = state.input_buffer.len();
}
if let OverlaySubmission::Selection(InlineListSelection::ConfigAction(key)) =
&sub
{
match key.as_str() {
"thinking_level" => {
if let Some(level) = session.cycle_thinking_level() {
handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!("Thinking: {level:?}"))],
);
}
}
"auto_compaction" => {
let enabled = !session.auto_compaction_enabled();
session.set_auto_compaction(enabled);
handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!(
"Auto-compaction: {}",
if enabled { "on" } else { "off" }
))],
);
}
"auto_retry" => {
let enabled = !session.auto_retry_enabled();
session.set_auto_retry(enabled);
handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!(
"Auto-retry: {}",
if enabled { "on" } else { "off" }
))],
);
}
"advisor" => match session.toggle_advisor() {
Ok(enabled) => handle.append_line(
InlineMessageKind::Info,
vec![plain_segment(format!(
"Advisor: {}",
if enabled { "on" } else { "off" }
))],
),
Err(e) => handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("Failed to toggle advisor: {e}"))],
),
},
_ => {}
}
}
if let OverlaySubmission::Selection(InlineListSelection::Session(id)) = &sub {
state.input_buffer = format!("/resume {id}");
state.input_cursor = state.input_buffer.len();
}
state.overlay_model_ids.clear();
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.footer_left = Some("Stopping\u{2026} press Ctrl+C again to confirm quit".to_string());
state.pending_quit = true;
} else {
state.footer_left = None;
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 spawn_input_thread(
state: Arc<parking_lot::Mutex<RenderState>>,
evt_tx: tokio::sync::mpsc::UnboundedSender<InlineEvent>,
) -> 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() {
let mut s = state.lock();
let cursor = s.input_cursor;
s.input_buffer.insert_str(cursor, &pasted);
s.input_cursor = cursor + pasted.len();
continue;
}
let Some(key) = key_event else { continue };
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
let _ = evt_tx.send(InlineEvent::Interrupt);
continue;
}
if key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL) {
let mut s = state.lock();
s.multiline_mode = !s.multiline_mode;
continue;
}
if key.code == KeyCode::Char('p') && key.modifiers.contains(KeyModifiers::CONTROL) {
let mut s = state.lock();
s.overlay = Some(build_command_palette());
continue;
}
if key.code == KeyCode::Char(';') && key.modifiers.contains(KeyModifiers::CONTROL) {
let mut s = state.lock();
s.queue_panel_open = !s.queue_panel_open;
if s.queue_panel_open {
s.queue_selected = 0;
}
continue;
}
if key.code == KeyCode::Char('e') && key.modifiers.contains(KeyModifiers::CONTROL) {
let mut s = state.lock();
s.fold_all();
continue;
}
if key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::CONTROL) {
let submitted = {
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 {
std::mem::take(&mut s.input_buffer)
};
s.input_cursor = 0;
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
};
if !submitted.is_empty() {
let _ = evt_tx.send(InlineEvent::Interrupt);
let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
}
continue;
}
{
let s = state.lock();
if s.confirmation.is_some() {
drop(s);
handle_confirmation_key(&state, &evt_tx, key.code);
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;
}
}
}
match key.code {
KeyCode::Enter => {
let send = !state.lock().multiline_mode
|| key
.modifiers
.contains(crossterm::event::KeyModifiers::SHIFT);
if !send {
let mut s = state.lock();
let cursor = s.input_cursor;
s.input_buffer.insert(cursor, '\n');
s.input_cursor = cursor + 1;
continue;
}
let shell_cmd = state.lock().shell_mode;
if shell_cmd {
let submitted = {
let mut s = state.lock();
let buf = std::mem::take(&mut s.input_buffer);
s.input_cursor = 0;
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 = {
let mut s = state.lock();
let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
let item = &s.slash_popup.items[s.slash_popup.selected];
format!("/{}", item.name)
} else {
std::mem::take(&mut s.input_buffer)
};
s.input_cursor = 0;
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
};
let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
}
KeyCode::Esc => {
let mut s = state.lock();
if s.shell_mode {
s.shell_mode = false;
s.input_buffer.clear();
s.input_cursor = 0;
} else if s.slash_popup.open {
s.slash_popup = SlashPopup::default();
} else if !s.input_buffer.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.input_buffer.clear();
s.input_cursor = 0;
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.input_buffer = format!("/{} ", name);
s.input_cursor = s.input_buffer.len();
refresh_input_popups(&mut s);
}
}
KeyCode::Backspace => {
let mut s = state.lock();
if s.input_cursor > 0 {
let cursor = s.input_cursor;
let prev = s
.input_buffer
.char_indices()
.take_while(|(i, _)| *i < cursor)
.last()
.map(|(i, _)| i)
.unwrap_or(0);
s.input_buffer.replace_range(prev..cursor, "");
s.input_cursor = prev;
}
refresh_input_popups(&mut s);
}
KeyCode::Delete => {
let mut s = state.lock();
if s.input_cursor < s.input_buffer.len() {
let cursor = s.input_cursor;
let next = s.input_buffer[cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| cursor + i)
.unwrap_or(s.input_buffer.len());
s.input_buffer.replace_range(cursor..next, "");
}
refresh_input_popups(&mut s);
}
KeyCode::Left => {
let mut s = state.lock();
s.input_cursor = s.input_cursor.saturating_sub(1);
}
KeyCode::Right => {
let mut s = state.lock();
let len = s.input_buffer.len();
s.input_cursor = (s.input_cursor + 1).min(len);
}
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.input_buffer.is_empty()
{
s.queue_selected = if s.queue_selected == 0 {
s.queued_inputs.len() - 1
} else {
s.queue_selected - 1
};
} else if s.input_buffer.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);
s.input_buffer = s.prompt_history[next].clone();
s.input_cursor = s.input_buffer.len();
} else {
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.input_buffer.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::PageUp => {
let _ = evt_tx.send(InlineEvent::ScrollPageUp);
}
KeyCode::PageDown => {
let _ = evt_tx.send(InlineEvent::ScrollPageDown);
}
KeyCode::Char(ch) => {
let mut s = state.lock();
if s.file_search.is_some()
&& ch == '!'
&& s.input_buffer[..s.input_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 {
buffer: &mut s.input_buffer,
cursor: &mut s.input_cursor,
};
let outcome = oxicode_vtui::vim::handle_key(
&mut s.vim_state,
&mut editor,
&mut s.vim_clipboard,
&vkey,
);
if outcome.handled {
refresh_input_popups(s);
} else {
let cursor = s.input_cursor;
s.input_buffer.insert(cursor, ch);
s.input_cursor = cursor + ch.len_utf8();
refresh_input_popups(s);
}
} else if s.input_buffer.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' => {
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' => {
let entry = s.queued_inputs.remove(idx);
s.input_buffer = entry;
s.input_cursor = s.input_buffer.len();
s.queue_panel_open = false;
continue;
}
'J' => {
if idx + 1 < s.queued_inputs.len() {
s.queued_inputs.swap(idx, idx + 1);
s.queue_selected = idx + 1;
}
continue;
}
'K' => {
if idx > 0 {
s.queued_inputs.swap(idx, idx - 1);
s.queue_selected = idx - 1;
}
continue;
}
_ => {} }
}
match ch {
'?' => {
s.overlay = Some(OverlayState {
title: "Keyboard Shortcuts".into(),
lines: cheatsheet_lines(),
items: vec![],
selected: 0,
search: None,
});
}
'e' => s.cycle_block_at_view(),
'E' => s.expand_all(),
'J' => s.jump_next_turn(),
'K' => s.jump_prev_turn(),
'n' if s.search.is_some() => s.search_next(),
'N' if s.search.is_some() => s.search_prev(),
_ => {
let cursor = s.input_cursor;
s.input_buffer.insert(cursor, ch);
s.input_cursor = cursor + ch.len_utf8();
refresh_input_popups(&mut s);
}
}
} else {
let cursor = s.input_cursor;
s.input_buffer.insert(cursor, ch);
s.input_cursor = cursor + ch.len_utf8();
refresh_input_popups(&mut s);
}
if s.tip.is_none() && s.input_buffer.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>,
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()));
}
}
}
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::{InlineListSelection, OverlayEvent, OverlaySubmission};
let mut s = state.lock();
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) {
item.selection.clone().unwrap_or_else(|| {
InlineListSelection::SlashCommand(format!("overlay:{}", overlay.selected))
})
} 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;
}
}
_ => {
}
}
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 spawn_agent_worker(
session: crate::app::agent_session::AgentSessionHandle,
) -> tokio::sync::mpsc::UnboundedSender<String> {
let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
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 {
while let Some(prompt) = prompt_rx.recv().await {
run_one_prompt(&session, prompt).await;
}
})
.await;
});
});
prompt_tx
}
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),
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(),
" 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(),
}),
}
}
static FRAME_TICK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static TITLE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
const TITLE_SPINNER: &[&str] = &[
"\u{2807}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}",
];
fn wave_brightness(tick: u64, row: u16, wave_rows: u16, speed: f64) -> f64 {
let phase =
(tick as f64 * speed) + (row as f64 / wave_rows.max(1) as f64) * std::f64::consts::TAU;
let s = phase.sin();
s * s
}
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.primary.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::render_chrome(frame, area, state);
let tick = FRAME_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
{
let running = 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[(tick 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();
}
}
render_transcript(frame, layout.scrollback, state, tick);
if !state.queued_inputs.is_empty() {
render_queue_pane(frame, layout.scrollback, state);
}
if !state.todo_items.is_empty() {
render_todo_pane(frame, layout.scrollback, &state.todo_items);
}
if !state.follow_ups.is_empty() {
render_follow_ups(frame, layout.prompt, &state.follow_ups);
}
if let Some(stage) = &state.reasoning_stage {
render_reasoning_indicator(frame, layout.prompt, stage);
}
render_composer(frame, layout.prompt, state);
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);
}
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();
let visible_max = (area.height as usize).saturating_sub(6).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 lines_count = overlay.lines.len();
let items_count = filtered.len().min(visible_max);
let height_inner = (lines_count + items_count + if has_search { 1 } else { 0 }) as u16;
let desired_h = height_inner.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::Rounded)
.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 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);
}
if filtered.is_empty() {
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 {
for (display_idx, &item_idx) in filtered.iter().take(visible_max).enumerate() {
let item = &overlay.items[item_idx];
let is_selected = display_idx == selected_filtered_pos;
let marker = if is_selected { "\u{25b8} " } else { " " };
let indent = " ".repeat(item.indent as usize);
let item_style = if is_selected {
Style::default().fg(primary).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(fg)
};
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: inner.left(),
y: row,
width: inner.width,
height: 1,
};
frame.render_widget(Paragraph::new(line), row_area);
row = row.saturating_add(1);
}
}
}
fn render_transcript(frame: &mut Frame<'_>, area: Rect, state: &RenderState, tick: u64) {
if state.transcript.is_empty() {
render_welcome(frame, area);
return;
}
let styles = active_styles();
let bg_color = color_from_anstyle(Some(styles.background));
let accent_w: u16 = 1;
let scrollbar_w: u16 = 1;
let content_area = Rect {
x: area.x + accent_w,
y: area.y,
width: area.width.saturating_sub(accent_w + scrollbar_w),
height: area.height,
};
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<(usize, InlineMessageKind, Line<'_>)> =
Vec::with_capacity(state.transcript.len());
const TRUNC_TAIL: usize = 3;
let dim_style = Style::default()
.fg(color_from_anstyle(styles.secondary.get_fg_color()))
.add_modifier(Modifier::DIM);
let mut blocks: Vec<(usize, Vec<(usize, &TranscriptLine)>)> = Vec::new();
for (idx, tl) in state.transcript.iter().enumerate() {
if blocks.last().is_some_and(|(id, _)| *id == tl.block_id) {
blocks.last_mut().unwrap().1.push((idx, tl));
} else {
blocks.push((tl.block_id, vec![(idx, tl)]));
}
}
for (block_id, lines) in &blocks {
let mode = state.block_mode(*block_id);
let len = lines.len();
match mode {
BlockDisplayMode::Collapsed => {
let &(idx, tl) = &lines[0];
let is_match = search_set.contains(&idx);
let line =
transcript_line_marked(tl, &styles, true, is_match, current_match == Some(idx));
display.push((idx, tl.kind, line));
}
BlockDisplayMode::Expanded => {
for &(idx, tl) in lines {
let is_match = search_set.contains(&idx);
let line = transcript_line_marked(
tl,
&styles,
false,
is_match,
current_match == Some(idx),
);
display.push((idx, tl.kind, line));
}
}
BlockDisplayMode::Truncated => {
if len <= TRUNC_TAIL + 1 {
for &(idx, tl) in lines {
let is_match = search_set.contains(&idx);
let line = transcript_line_marked(
tl,
&styles,
false,
is_match,
current_match == Some(idx),
);
display.push((idx, tl.kind, line));
}
} else {
let &(hidx, htl) = &lines[0];
let is_match = search_set.contains(&hidx);
let line = transcript_line_marked(
htl,
&styles,
false,
is_match,
current_match == Some(hidx),
);
display.push((hidx, htl.kind, line));
let hidden = len - 1 - TRUNC_TAIL;
let gap = Line::styled(format!(" \u{2026} +{hidden} lines"), dim_style);
display.push((hidx, htl.kind, gap));
for &(idx, tl) in lines.iter().rev().take(TRUNC_TAIL).rev() {
let is_match = search_set.contains(&idx);
let line = transcript_line_marked(
tl,
&styles,
false,
is_match,
current_match == Some(idx),
);
display.push((idx, tl.kind, line));
}
}
}
}
}
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(|(orig_idx, _, _)| *orig_idx >= 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(|(orig_idx, _, _)| {
let bid = state.transcript.get(*orig_idx)?.block_id;
let first_idx = state.transcript.iter().position(|l| l.block_id == bid)?;
(first_idx != *orig_idx).then_some(first_idx)
});
let sticky_h: u16 = if sticky_first.is_some() { 1 } else { 0 };
let body_top = content_area.top() + sticky_h;
let running = state.reasoning_stage.is_some();
const WAVE_ROWS: u16 = 32;
const WAVE_SPEED: f64 = 0.15;
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(|(orig_idx, _, _)| {
state
.transcript
.get(*orig_idx)
.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 rail_blend = 0.7 * sticky_opacity;
let bg_blend = 0.1 * sticky_opacity;
if sticky_opacity > 0.05
&& let Some(cell) = frame.buffer_mut().cell_mut((area.x, content_area.top()))
{
cell.set_char('\u{2503}');
cell.set_style(Style::default().fg(blend_rgb(bg_color, accent_base, rail_blend)));
}
let line = transcript_line_marked(tl, &styles, false, false, false);
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 mut y = body_top;
let width = content_area.width.max(1) as usize;
let mut visual_row: u16 = 0;
for (_, kind, line) in display.into_iter().skip(start) {
if y >= content_area.bottom() {
break;
}
let text_w = line.width();
let wrapped_h = if text_w == 0 {
1
} else {
text_w.div_ceil(width).max(1) as u16
};
let accent_base = accent_color_for_kind(kind, &styles);
for row_offset in 0..wrapped_h {
let paint_y = y + row_offset;
if paint_y >= content_area.bottom() {
break;
}
let brightness = if running {
0.4 + 0.6 * wave_brightness(tick, visual_row + row_offset, WAVE_ROWS, WAVE_SPEED)
} else {
0.7
};
let rail_color = blend_rgb(bg_color, accent_base, brightness);
if let Some(cell) = frame.buffer_mut().cell_mut((area.x, paint_y)) {
cell.set_char('\u{2503}'); cell.set_style(Style::default().fg(rail_color));
}
}
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;
visual_row += wrapped_h;
}
let body_viewport = (content_area.height as usize).saturating_sub(sticky_h as usize);
if total > body_viewport {
let follow = state.scroll_offset == usize::MAX;
render_scrollbar(
frame,
area.right().saturating_sub(1),
area.top(),
area.height,
total,
body_viewport,
start,
follow,
&styles,
bg_color,
);
}
}
#[allow(clippy::too_many_arguments)]
fn render_scrollbar(
frame: &mut Frame,
x: u16,
top: u16,
height: u16,
total: usize,
viewport: usize,
start: usize,
follow: bool,
styles: &ThemeStyles,
bg: Color,
) {
if height == 0 {
return;
}
let ratio = (start as f64 / total.max(1) as f64).clamp(0.0, 1.0);
let thumb_h = (((viewport as f64 / total.max(1) as f64) * height as f64).ceil() as u16)
.max(1)
.min(height);
let track_h = height.saturating_sub(thumb_h);
let thumb_y = (ratio * track_h as f64).round() as u16;
let accent = color_from_anstyle(styles.primary.get_fg_color());
let thumb_color = if follow {
blend_rgb(bg, accent, 0.35)
} else {
accent
};
let rail_color = blend_rgb(bg, accent, 0.1);
for row in 0..height {
let y = top + row;
let is_thumb = row >= thumb_y && row < thumb_y + thumb_h;
let (ch, color) = if is_thumb {
('\u{2588}', thumb_color) } else {
('\u{2502}', rail_color) };
if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) {
cell.set_char(ch);
cell.set_style(Style::default().fg(color));
}
}
}
fn transcript_line_marked<'a>(
line: &'a TranscriptLine,
styles: &'a ThemeStyles,
folded: bool,
is_match: bool,
is_current: bool,
) -> Line<'a> {
let (kind_style, marker) = match line.kind {
InlineMessageKind::Agent => (
Style::default().fg(color_from_anstyle(styles.response.get_fg_color())),
"\u{25cf}", ),
InlineMessageKind::User => (
Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
"\u{276f}", ),
InlineMessageKind::Tool => (
Style::default().fg(color_from_anstyle(styles.tool.get_fg_color())),
"\u{2699}", ),
InlineMessageKind::Error => (
Style::default().fg(color_from_anstyle(styles.error.get_fg_color())),
"\u{2717}", ),
InlineMessageKind::Warning => (
Style::default().fg(color_from_anstyle(styles.status.get_fg_color())),
"\u{26a0}", ),
InlineMessageKind::Info => (
Style::default().fg(color_from_anstyle(styles.info.get_fg_color())),
"\u{2139}", ),
InlineMessageKind::Policy => (
Style::default().fg(color_from_anstyle(styles.mcp.get_fg_color())),
"\u{25c6}", ),
InlineMessageKind::Pty => (
Style::default().fg(color_from_anstyle(styles.pty_output.get_fg_color())),
"\u{258c}", ),
};
let prefix = if folded {
format!("\u{25b8} {} ", marker) } else {
format!("{} ", marker)
};
let highlight = if is_current {
Some(Style::default().reversed())
} else if is_match {
Some(Style::default().add_modifier(Modifier::UNDERLINED))
} else {
None
};
let mut spans = Vec::with_capacity(line.segments.len() + 1);
spans.push(Span::styled(prefix, kind_style));
for segment in &line.segments {
let mut style = segment_style(segment, kind_style, styles);
if let Some(h) = highlight {
style = style.patch(h);
}
spans.push(Span::styled(segment.text.clone(), style));
}
Line::from(spans)
}
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)));
} else {
style = style.fg(color_from_anstyle(styles.response.get_fg_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 text_style = Style::default().fg(color_from_anstyle(Some(styles.foreground)));
let prefix = state.prompt_prefix.clone();
let body = state.input_buffer.clone();
let placeholder = state.placeholder.clone();
let mut line_spans = Vec::new();
if let Some(label) = state.vim_state.status_label() {
line_spans.push(Span::styled(
format!("[{label}] "),
Style::default()
.fg(color_from_anstyle(styles.tool.get_fg_color()))
.add_modifier(Modifier::BOLD),
));
}
line_spans.push(Span::styled(prefix, prefix_style));
if state.shell_mode {
line_spans.push(Span::styled(
"! ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
}
if body.is_empty()
&& let Some(ph) = placeholder
{
line_spans.push(Span::styled(
ph,
Style::default()
.fg(color_from_anstyle(styles.secondary.get_fg_color()))
.dim(),
));
} else {
line_spans.push(Span::styled(body, text_style));
}
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())));
let paragraph = Paragraph::new(Line::from(line_spans))
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
if state.input_enabled {
let vim_off = state
.vim_state
.status_label()
.map(|l| format!("[{l}] ").chars().count() as u16)
.unwrap_or(0);
let shell_off = if state.shell_mode { 2 } else { 0 };
let cursor_x = area.left()
+ 1
+ vim_off
+ shell_off
+ state.prompt_prefix.chars().count() as u16
+ state.input_cursor as u16;
let cursor_y = area.top() + 1;
frame.set_cursor_position(ratatui::layout::Position::new(cursor_x, cursor_y));
}
}
fn render_welcome(frame: &mut Frame<'_>, area: Rect) {
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());
if area.width >= 90 {
use oxicode_vtui::design::layout::WelcomeLayout;
let layout = WelcomeLayout::compute(area, 3, 0, 0, 1, 0, false);
let logo_area = if layout.has_hero_box() {
layout.hero_logo
} else {
layout.logo
};
if logo_area.height > 0 {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"\u{25cf} oxicode",
Style::default().fg(primary).add_modifier(Modifier::BOLD),
)))
.alignment(Alignment::Center),
logo_area,
);
}
if layout.tip.height > 0 {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"Type a message to begin, or press / for commands.",
Style::default().fg(fg),
)))
.alignment(Alignment::Center),
layout.tip,
);
}
if layout.version.height > 0 {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
format!("v{}", env!("CARGO_PKG_VERSION")),
Style::default().fg(secondary).add_modifier(Modifier::DIM),
)))
.alignment(Alignment::Center),
layout.version,
);
}
return;
}
let version = env!("CARGO_PKG_VERSION");
let text = vec![
Line::from(""),
Line::from(""),
Line::from(Span::styled(
"\u{25cf} oxicode",
Style::default().fg(primary).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
"Type a message to begin, or press / for commands.",
Style::default().fg(fg),
)),
Line::from(Span::styled(
format!("v{version} \u{2014} /help for commands"),
Style::default().fg(secondary),
)),
];
frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), area);
}
fn render_reasoning_indicator(frame: &mut Frame<'_>, composer_area: Rect, stage: &str) {
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 spinner = "\u{25cc}"; let line = Line::from(vec![
Span::styled(
format!("{spinner} "),
Style::default().fg(color_from_anstyle(styles.tool.get_fg_color())),
),
Span::styled(
stage.to_string(),
Style::default()
.fg(color_from_anstyle(styles.secondary.get_fg_color()))
.add_modifier(Modifier::DIM),
),
]);
frame.render_widget(Paragraph::new(line), indicator_area);
}
fn render_queue_pane(frame: &mut Frame<'_>, scrollback: Rect, state: &RenderState) {
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 = entries.len() as u16 + 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());
let items: Vec<Line<'_>> = entries
.iter()
.enumerate()
.map(|(i, e)| {
let prefix = if interactive {
format!("#{} ", i + 1)
} else {
"\u{2261} ".to_string()
};
let prefix_style = if interactive && i == selected {
Style::default().fg(primary).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(info)
};
let text_style = if interactive && i == selected {
Style::default().fg(primary).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(secondary)
};
let marker = if interactive && i == selected {
"\u{25b8} " } 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,
);
}
fn render_todo_pane(frame: &mut Frame<'_>, scrollback: Rect, items: &[(String, bool)]) {
let styles = active_styles();
let height = items.len() as u16 + 1;
let area = Rect {
x: scrollback.x,
y: scrollback.y,
width: scrollback.width,
height,
};
let lines: Vec<Line<'_>> = items
.iter()
.map(|(text, done)| {
let (marker, color) = if *done {
("\u{2611}", styles.tool.get_fg_color()) } else {
("\u{2610}", styles.secondary.get_fg_color()) };
Line::from(vec![
Span::styled(
format!("{marker} "),
Style::default().fg(color_from_anstyle(color)),
),
Span::styled(
text.clone(),
Style::default().fg(color_from_anstyle(Some(styles.foreground))),
),
])
})
.collect();
frame.render_widget(Paragraph::new(lines), area);
}
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!("\u{25b8} {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!(" \u{2139} {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 = 8usize;
let visible = items.len().min(max_visible);
let popup_h = visible as u16 + 2; 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::Rounded)
.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 { "\u{25b8} " } 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);
}
}
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 + 2; 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::Rounded)
.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 { "\u{25b8} " } 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();
let hint_area = Rect {
x: inner.left(),
y: hint_y,
width: inner.width,
height: 1,
};
let count = items.len();
let hint = format!("{count} files \u{00b7} 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> {
buffer: &'a mut String,
cursor: &'a mut usize,
}
impl<'a> oxicode_vtui::vim::Editor for InputEditor<'a> {
fn content(&self) -> &str {
self.buffer
}
fn cursor(&self) -> usize {
*self.cursor
}
fn set_cursor(&mut self, pos: usize) {
*self.cursor = pos.min(self.buffer.len());
}
fn move_left(&mut self) {
*self.cursor = self.cursor.saturating_sub(1);
}
fn move_right(&mut self) {
let len = self.buffer.len();
*self.cursor = (*self.cursor + 1).min(len);
}
fn delete_char_forward(&mut self) {
let cursor = *self.cursor;
if cursor < self.buffer.len() {
let next = self.buffer[cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| cursor + i)
.unwrap_or(self.buffer.len());
self.buffer.replace_range(cursor..next, "");
}
}
fn insert_text(&mut self, text: &str) {
let cursor = *self.cursor;
self.buffer.insert_str(cursor, text);
*self.cursor = cursor + text.len();
}
fn replace(&mut self, content: String, cursor: usize) {
*self.buffer = content;
*self.cursor = cursor.min(self.buffer.len());
}
}
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) -> Vec<SlashPopupItem> {
SlashRegistry::builtin_commands()
.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()
}
fn refresh_slash_popup(state: &mut RenderState) {
let buf = state.input_buffer.clone();
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.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.input_buffer, state.input_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.input_cursor;
state
.input_buffer
.replace_range(at_offset..cursor_end.min(state.input_buffer.len()), &text);
state.input_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 try_render_diff(content: &str, handle: &InlineHandle) -> bool {
let lines: Vec<&str> = content.lines().collect();
if !lines.iter().any(|l| l.starts_with("@@")) {
return false;
}
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 false;
}
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 hdr_style = InlineTextStyle::default();
hdr_style.effects |= anstyle::Effects::DIMMED;
handle.append_line(
InlineMessageKind::Tool,
vec![InlineSegment {
text: format!("\u{2713} diff (+{additions} \u{2212}{deletions})"),
style: Arc::new(hdr_style),
}],
);
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;
}
handle.append_line(
InlineMessageKind::Tool,
vec![InlineSegment {
text: format!(" {line}"),
style: Arc::new(style),
}],
);
}
if lines.len() > MAX_DIFF_LINES {
let mut more_style = InlineTextStyle::default();
more_style.effects |= anstyle::Effects::DIMMED;
handle.append_line(
InlineMessageKind::Tool,
vec![InlineSegment {
text: format!(" \u{2026} {} more lines", lines.len() - MAX_DIFF_LINES),
style: Arc::new(more_style),
}],
);
}
true
}
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 popup_opens_on_slash() {
let mut state = RenderState::default();
state.input_buffer = "/".to_string();
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.input_buffer = "/quit ".to_string();
refresh_input_popups(&mut state);
assert!(!state.slash_popup.open);
}
#[test]
fn popup_closes_on_non_slash() {
let mut state = RenderState::default();
state.input_buffer = "hello".to_string();
refresh_input_popups(&mut state);
assert!(!state.slash_popup.open);
}
#[test]
fn popup_filters_as_user_types() {
let mut state = RenderState::default();
state.input_buffer = "/m".to_string();
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.input_buffer = "/".to_string();
refresh_input_popups(&mut state);
let full_count = state.slash_popup.items.len();
state.slash_popup.selected = full_count - 1;
state.input_buffer = "/qu".to_string();
refresh_input_popups(&mut state);
assert!(state.slash_popup.selected < state.slash_popup.items.len());
}
}
#[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
}
#[test]
fn welcome_screen_shown_when_transcript_empty() {
let state = RenderState::default();
let rendered = render_frame_to_string(&state);
assert!(
rendered.contains("oxicode"),
"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.input_buffer = "/qu".to_string();
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 must wrap, not clip — text should be visible past col 40"
);
}
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,
});
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(),
}),
});
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,
});
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,
});
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_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(),
}),
});
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");
}
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 default_block_mode_is_truncated() {
let state = RenderState::default();
assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
assert!(state.block_display.is_empty(), "default needs no map entry");
}
#[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_eq!(state.block_display.len(), 3);
assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
assert_eq!(state.block_mode(2), BlockDisplayMode::Expanded);
}
#[test]
fn truncate_all_resets_to_default() {
let mut state = RenderState::default();
state.transcript = three_block_transcript();
state.fold_all();
state.truncate_all();
assert!(state.block_display.is_empty());
assert_eq!(state.block_mode(1), BlockDisplayMode::Truncated);
}
#[test]
fn fold_all_on_empty_transcript_is_noop() {
let mut state = RenderState::default();
state.fold_all();
assert!(state.block_display.is_empty());
}
#[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::Expanded);
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);
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 scrollbar_paints_thumb_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}'),
"scrollbar thumb (█) must render when transcript overflows the viewport"
);
}
#[test]
fn scrollbar_absent_when_content_fits_viewport() {
let mut state = RenderState::default();
state.transcript.push(TranscriptLine {
kind: InlineMessageKind::Agent,
segments: vec![plain_segment("hi")],
block_id: 0,
});
let rendered = render_frame_to_string(&state);
assert!(
!rendered.contains('\u{2588}'),
"no scrollbar thumb when content fits the viewport"
);
}
#[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();
handle_confirmation_key(&state_arc, &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();
handle_confirmation_key(&state_arc, &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 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.input_buffer = "@main".into();
state.input_cursor = 5;
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"
);
}
}