use std::io;
use std::sync::Arc;
use std::time::{Duration, Instant};
use aonyx_agent::approval::AsyncApprover;
use aonyx_agent::{AgentRunner, ApprovalPolicy, TurnEvent};
use aonyx_core::{LlmProvider, MemoryStore, Message, Role, SafetyClass, ToolCall};
use aonyx_memory::{
chunks::{Chunk, ChunksStore},
kg::{Entity, EntityId, KgStore, Relation},
Palace, SessionId, SessionStore, SqliteSessionStore,
};
use aonyx_skills::Skill;
use aonyx_tools::ToolRegistry;
use async_trait::async_trait;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::{Frame, Terminal};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tui_textarea::{CursorMove, TextArea};
use crate::images;
use crate::pricing::{self, Pricing};
use crate::session::SlashCommand;
use crate::theme::{self, Theme};
const HISTORY_MAX: usize = 200;
const VIEWPORT_MAX_LINES: usize = 2000;
const MIN_COMPOSER_HEIGHT: u16 = 3;
const MAX_COMPOSER_HEIGHT: u16 = 10;
const SUGGESTION_LIMIT: usize = 8;
const FILE_CACHE_LIMIT: usize = 5000;
const FILE_CACHE_MAX_DEPTH: usize = 8;
const DIFF_MAX_LINES: usize = 6;
const STREAM_MD_MIN_INCREMENT: usize = 24;
const UNIFIED_DIFF_MAX_LINES: usize = 18;
const UNIFIED_DIFF_CONTEXT: usize = 1;
const INGEST_CHUNK_MAX_CHARS: usize = 2_000;
const COMPACT_KEEP_RECENT: usize = 6;
const SLASH_CANDIDATES: &[&str] = &[
"/quit",
"/clear",
"/new",
"/help",
"/models",
"/sessions",
"/export",
"/export-html",
"/export-bundle",
"/import-bundle",
"/rename",
"/cost",
"/theme-edit",
"/details",
"/thinking",
"/themes",
"/vim",
"/undo",
"/find",
"/load",
"/tree",
"/kg",
"/tools",
"/mcp",
"/skills",
"/inspect",
"/fork",
"/compact",
"/retry",
"/model",
"/provider",
"/mouse",
"/ingest",
"/editor",
"/init",
];
#[derive(Debug, Clone, PartialEq, Eq)]
enum Trigger {
At,
Slash,
SlashArg(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ComposerMode {
Chat,
Slash,
Bash,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VimMode {
Off,
Insert,
Normal,
}
impl VimMode {
fn label(self) -> Option<&'static str> {
match self {
VimMode::Off => None,
VimMode::Insert => Some("INS"),
VimMode::Normal => Some("NRM"),
}
}
}
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const PULSE_FRAMES: &[&str] = &["●", "◉", "○", "◉"];
#[derive(Debug, Clone)]
enum PaletteAction {
Slash(SlashCommand),
SwitchTheme(String),
}
#[derive(Debug, Clone)]
struct PaletteEntry {
label: String,
hint: String,
action: PaletteAction,
}
#[derive(Debug)]
struct PendingApproval {
call: ToolCall,
class: SafetyClass,
respond_to: tokio::sync::oneshot::Sender<bool>,
}
#[derive(Debug)]
struct TuiApprover {
tx: tokio::sync::mpsc::Sender<PendingApproval>,
}
#[async_trait]
impl AsyncApprover for TuiApprover {
async fn approve(&self, call: &ToolCall, class: SafetyClass) -> bool {
if always_allow_set()
.lock()
.map(|s| approval_matches(&s, &call.name, &call.args))
.unwrap_or(false)
{
return true;
}
let (respond_to, rx) = tokio::sync::oneshot::channel();
let req = PendingApproval {
call: call.clone(),
class,
respond_to,
};
if self.tx.send(req).await.is_err() {
return false;
}
rx.await.unwrap_or(false)
}
}
#[derive(Debug, Clone)]
struct McpServerEntry {
server: String,
tool_count: usize,
disabled: bool,
}
#[derive(Debug, Default)]
struct McpPanel {
open: bool,
entries: Vec<McpServerEntry>,
selected: usize,
}
impl McpPanel {
fn close(&mut self) {
self.open = false;
self.selected = 0;
}
fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
fn move_down(&mut self) {
if self.selected + 1 < self.entries.len() {
self.selected += 1;
}
}
}
#[derive(Debug, Clone)]
struct ToolEntry {
name: String,
class: SafetyClass,
disabled: bool,
}
#[derive(Debug, Default)]
struct ToolsPanel {
open: bool,
entries: Vec<ToolEntry>,
selected: usize,
}
impl ToolsPanel {
fn close(&mut self) {
self.open = false;
self.selected = 0;
}
fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
fn move_down(&mut self) {
if self.selected + 1 < self.entries.len() {
self.selected += 1;
}
}
}
#[derive(Debug, Default)]
struct TreePanel {
open: bool,
lines: Vec<Line<'static>>,
scroll: u16,
}
impl TreePanel {
fn close(&mut self) {
self.open = false;
self.scroll = 0;
}
}
#[derive(Debug, Default)]
struct ThemeEditor {
open: bool,
field: usize,
channel: usize,
}
impl ThemeEditor {
fn close(&mut self) {
self.open = false;
}
}
#[derive(Debug, Default)]
struct InspectPanel {
open: bool,
lines: Vec<Line<'static>>,
scroll: u16,
}
impl InspectPanel {
fn close(&mut self) {
self.open = false;
self.scroll = 0;
}
}
#[derive(Debug, Clone)]
struct SkillEntry {
id: String,
name: String,
triggers: String,
disabled: bool,
}
#[derive(Debug, Default)]
struct SkillsPanel {
open: bool,
entries: Vec<SkillEntry>,
selected: usize,
}
impl SkillsPanel {
fn close(&mut self) {
self.open = false;
self.selected = 0;
}
fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
fn move_down(&mut self) {
if self.selected + 1 < self.entries.len() {
self.selected += 1;
}
}
}
#[derive(Debug, Default)]
struct KgPanel {
open: bool,
entities: Vec<Entity>,
relations: Vec<Relation>,
lines: Vec<Line<'static>>,
scroll: u16,
}
impl KgPanel {
fn close(&mut self) {
self.open = false;
self.scroll = 0;
}
}
#[derive(Debug)]
struct Palette {
open: bool,
query: String,
entries: Vec<PaletteEntry>,
filtered: Vec<usize>,
selected: usize,
}
impl Palette {
fn new() -> Self {
let entries = build_palette_entries();
let filtered = (0..entries.len()).collect();
Self {
open: false,
query: String::new(),
entries,
filtered,
selected: 0,
}
}
fn show(&mut self) {
self.open = true;
self.query.clear();
self.filtered = (0..self.entries.len()).collect();
self.selected = 0;
}
fn close(&mut self) {
self.open = false;
self.query.clear();
self.selected = 0;
}
fn refilter(&mut self) {
if self.query.is_empty() {
self.filtered = (0..self.entries.len()).collect();
} else {
let labels: Vec<String> = self
.entries
.iter()
.map(|e| format!("{} {}", e.label, e.hint))
.collect();
self.filtered = fuzzy_top_idx(&self.query, &labels, self.entries.len());
}
if self.selected >= self.filtered.len() {
self.selected = 0;
}
}
fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
fn move_down(&mut self) {
if self.selected + 1 < self.filtered.len() {
self.selected += 1;
}
}
fn current(&self) -> Option<&PaletteEntry> {
self.filtered
.get(self.selected)
.and_then(|i| self.entries.get(*i))
}
}
fn build_palette_entries() -> Vec<PaletteEntry> {
let mut out = vec![
PaletteEntry {
label: "/new".into(),
hint: "Start a fresh conversation".into(),
action: PaletteAction::Slash(SlashCommand::New),
},
PaletteEntry {
label: "/help".into(),
hint: "Show every command".into(),
action: PaletteAction::Slash(SlashCommand::Help),
},
PaletteEntry {
label: "/models".into(),
hint: "Active provider + model".into(),
action: PaletteAction::Slash(SlashCommand::Models),
},
PaletteEntry {
label: "/sessions".into(),
hint: "List sessions for this project".into(),
action: PaletteAction::Slash(SlashCommand::Sessions),
},
PaletteEntry {
label: "/export".into(),
hint: "Export conversation to Markdown".into(),
action: PaletteAction::Slash(SlashCommand::Export(None)),
},
PaletteEntry {
label: "/export-html".into(),
hint: "Export conversation to a styled standalone HTML".into(),
action: PaletteAction::Slash(SlashCommand::ExportHtml(None)),
},
PaletteEntry {
label: "/export-bundle".into(),
hint: "Export a .zip bundle (Markdown + HTML + messages.json + meta.json)".into(),
action: PaletteAction::Slash(SlashCommand::ExportBundle(None)),
},
PaletteEntry {
label: "/import-bundle".into(),
hint: "Import a session from a .zip bundle's messages.json".into(),
action: PaletteAction::Slash(SlashCommand::ImportBundle(None)),
},
PaletteEntry {
label: "/rename".into(),
hint: "Rename the current session".into(),
action: PaletteAction::Slash(SlashCommand::Rename(None)),
},
PaletteEntry {
label: "/cost".into(),
hint: "Detailed token + cost breakdown for the session".into(),
action: PaletteAction::Slash(SlashCommand::Cost),
},
PaletteEntry {
label: "/theme-edit".into(),
hint: "Live-edit theme colours and save them".into(),
action: PaletteAction::Slash(SlashCommand::ThemeEdit),
},
PaletteEntry {
label: "/details".into(),
hint: "Toggle verbose tool output".into(),
action: PaletteAction::Slash(SlashCommand::Details),
},
PaletteEntry {
label: "/thinking".into(),
hint: "Toggle reasoning visibility".into(),
action: PaletteAction::Slash(SlashCommand::Thinking),
},
PaletteEntry {
label: "/editor".into(),
hint: "Open $EDITOR (legacy mode)".into(),
action: PaletteAction::Slash(SlashCommand::Editor),
},
PaletteEntry {
label: "/init".into(),
hint: "Drop agent.yaml in project root".into(),
action: PaletteAction::Slash(SlashCommand::Init),
},
PaletteEntry {
label: "/vim".into(),
hint: "Toggle vim-style modal editing".into(),
action: PaletteAction::Slash(SlashCommand::Vim),
},
PaletteEntry {
label: "/undo".into(),
hint: "Revert the last fs change · `/undo N` · `/undo list`".into(),
action: PaletteAction::Slash(SlashCommand::Undo(None)),
},
PaletteEntry {
label: "/find".into(),
hint: "Search past sessions (needs a query: /find oauth)".into(),
action: PaletteAction::Slash(SlashCommand::Find(None)),
},
PaletteEntry {
label: "/load".into(),
hint: "Switch to a session by id prefix".into(),
action: PaletteAction::Slash(SlashCommand::Load(None)),
},
PaletteEntry {
label: "/kg".into(),
hint: "Open the memory-palace visualization panel".into(),
action: PaletteAction::Slash(SlashCommand::Kg),
},
PaletteEntry {
label: "/tools".into(),
hint: "Open the tools panel (enable / disable handlers)".into(),
action: PaletteAction::Slash(SlashCommand::Tools),
},
PaletteEntry {
label: "/mcp".into(),
hint: "Connected MCP servers — toggle a server's tools".into(),
action: PaletteAction::Slash(SlashCommand::Mcp),
},
PaletteEntry {
label: "/skills".into(),
hint: "Open the skills panel (enable / disable skills)".into(),
action: PaletteAction::Slash(SlashCommand::Skills),
},
PaletteEntry {
label: "/inspect".into(),
hint: "Show the JSON of the last LLM request".into(),
action: PaletteAction::Slash(SlashCommand::Inspect),
},
PaletteEntry {
label: "/fork".into(),
hint: "Fork this session into a child branch".into(),
action: PaletteAction::Slash(SlashCommand::Fork),
},
PaletteEntry {
label: "/compact".into(),
hint: "Summarize old turns to free up context".into(),
action: PaletteAction::Slash(SlashCommand::Compact),
},
PaletteEntry {
label: "/retry".into(),
hint: "Re-run the last user message".into(),
action: PaletteAction::Slash(SlashCommand::Retry),
},
PaletteEntry {
label: "/model".into(),
hint: "Switch the active model live (/model <name>)".into(),
action: PaletteAction::Slash(SlashCommand::Model(None)),
},
PaletteEntry {
label: "/provider".into(),
hint: "Switch the LLM provider live (/provider <id>)".into(),
action: PaletteAction::Slash(SlashCommand::Provider(None)),
},
PaletteEntry {
label: "/tree".into(),
hint: "Show the session genealogy (fork/compact tree)".into(),
action: PaletteAction::Slash(SlashCommand::Tree),
},
PaletteEntry {
label: "/mouse".into(),
hint: "Toggle mouse capture (off = native drag-to-select)".into(),
action: PaletteAction::Slash(SlashCommand::Mouse),
},
PaletteEntry {
label: "/ingest".into(),
hint: "Ingest a local file into the project palace".into(),
action: PaletteAction::Slash(SlashCommand::Ingest(None)),
},
PaletteEntry {
label: "/quit".into(),
hint: "Exit Aonyx".into(),
action: PaletteAction::Slash(SlashCommand::Quit),
},
];
for name in theme::available_names() {
out.push(PaletteEntry {
label: format!("Theme: {name}"),
hint: format!("Switch palette to {name}"),
action: PaletteAction::SwitchTheme(name.to_string()),
});
}
out
}
#[allow(clippy::too_many_arguments)]
pub async fn run(
provider: Arc<dyn LlmProvider>,
palace: Palace,
model: String,
max_iterations: usize,
_system_prompt: Option<String>,
project_slug: String,
skills: Vec<Skill>,
provider_name: String,
session_store: SqliteSessionStore,
session_id: SessionId,
session_messages: Vec<Message>,
session_turns: u32,
theme_name: Option<String>,
custom_theme_fields: Option<[(u8, u8, u8); 10]>,
show_thinking: bool,
desktop_notifications: bool,
auto_compact: bool,
auto_compact_threshold: u64,
tool_registry: ToolRegistry,
) -> anyhow::Result<()> {
let (approval_tx, approval_rx) = tokio::sync::mpsc::channel::<PendingApproval>(4);
let approver: Arc<dyn AsyncApprover> = Arc::new(TuiApprover { tx: approval_tx });
let mut tool_registry = tool_registry;
tool_registry.register(std::sync::Arc::new(aonyx_tools::memory::MemorySearch::new(
palace.clone(),
)));
tool_registry.register(std::sync::Arc::new(
aonyx_tools::memory::MemoryDiaryAppend::new(palace.clone(), project_slug.clone()),
));
tool_registry.register(std::sync::Arc::new(
aonyx_tools::memory::MemoryKgQuery::new(palace.kg.clone()),
));
let skills_catalogue = skills.clone();
let runner = AgentRunner::new(provider, tool_registry.clone(), model.clone())
.with_max_iterations(max_iterations)
.with_approval(ApprovalPolicy::interactive(approver))
.with_skills(skills)
.with_project(&project_slug);
let disabled_skills = runner.skill_toggle_handle();
let last_request = runner.last_request_handle();
let model_handle = runner.model_handle();
let provider_handle = runner.provider_handle();
let messages: Vec<Message> = session_messages;
let mut composer = TextArea::default();
composer.set_block(
Block::default()
.borders(Borders::TOP | Borders::BOTTOM)
.border_style(Style::default().fg(Color::DarkGray)),
);
composer.set_cursor_line_style(Style::default());
composer.set_placeholder_text("type a message — Enter to send, Shift+Enter for newline");
let active_theme = match theme_name.as_deref() {
Some("custom") => custom_theme_fields
.map(|f| theme::from_rgb_fields(&f))
.unwrap_or(theme::DEFAULT),
Some(name) => theme::by_name(name),
None => theme::DEFAULT,
};
let cached_pricing = pricing::lookup(&provider_name, &model);
let mut app = TuiApp {
runner: Arc::new(runner),
palace,
messages,
project_slug,
provider_name,
model_name: model,
turns: session_turns,
session_store,
session_id,
theme: active_theme,
show_thinking,
desktop_notifications,
composer,
viewport: vec![Line::from(Span::styled(
"🦦 Aonyx Agent — Shift+Enter = newline · ↑/↓ history · Esc to quit · /help for commands",
Style::default().fg(Color::DarkGray),
))],
scroll: 0,
auto_scroll: true,
viewport_height: 0,
history: Vec::new(),
history_cursor: None,
scratch: Vec::new(),
runner_event_rx: None,
runner_handle: None,
runner_active: false,
show_tool_details: false,
tick: 0,
thinking_line: None,
first_delta_received: false,
current_assistant_text: String::new(),
assistant_msg_start: None,
last_md_render_chars: 0,
suggestions: Vec::new(),
suggestion_idx: 0,
suggestion_kind: None,
suggestion_trigger_pos: 0,
file_cache: None,
turn_started_at: None,
palette: Palette::new(),
kg_panel: KgPanel::default(),
tool_registry,
tools_panel: ToolsPanel::default(),
mcp_panel: McpPanel::default(),
skills: skills_catalogue,
disabled_skills,
skills_panel: SkillsPanel::default(),
last_request,
inspect_panel: InspectPanel::default(),
theme_editor: ThemeEditor::default(),
tree_panel: TreePanel::default(),
recent_session_ids: Vec::new(),
approval_rx,
pending_approval: None,
vim_mode: VimMode::Off,
mouse_captured: true,
viewport_rect: None,
palette_results_rect: None,
total_input_tokens: 0,
total_output_tokens: 0,
pricing: cached_pricing,
auto_compact,
compact_threshold: auto_compact_threshold,
compact_nudged: false,
model_handle,
provider_handle,
quit: false,
};
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
app.apply_composer_style();
app.refresh_recent_sessions().await;
let res = app.event_loop(&mut terminal).await;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
res
}
struct TuiApp {
runner: Arc<AgentRunner>,
palace: Palace,
messages: Vec<Message>,
project_slug: String,
provider_name: String,
model_name: String,
turns: u32,
session_store: SqliteSessionStore,
session_id: SessionId,
theme: Theme,
show_thinking: bool,
desktop_notifications: bool,
composer: TextArea<'static>,
viewport: Vec<Line<'static>>,
scroll: u16,
auto_scroll: bool,
viewport_height: u16,
history: Vec<String>,
history_cursor: Option<usize>,
scratch: Vec<String>,
runner_event_rx: Option<mpsc::Receiver<TurnEvent>>,
runner_handle: Option<JoinHandle<aonyx_core::Result<aonyx_agent::TurnResult>>>,
runner_active: bool,
show_tool_details: bool,
tick: u64,
thinking_line: Option<usize>,
first_delta_received: bool,
current_assistant_text: String,
assistant_msg_start: Option<usize>,
last_md_render_chars: usize,
suggestions: Vec<String>,
suggestion_idx: usize,
suggestion_kind: Option<Trigger>,
suggestion_trigger_pos: usize,
file_cache: Option<Vec<String>>,
turn_started_at: Option<Instant>,
palette: Palette,
kg_panel: KgPanel,
tool_registry: ToolRegistry,
tools_panel: ToolsPanel,
mcp_panel: McpPanel,
skills: Vec<Skill>,
disabled_skills: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
skills_panel: SkillsPanel,
last_request: Arc<std::sync::Mutex<Option<String>>>,
inspect_panel: InspectPanel,
theme_editor: ThemeEditor,
tree_panel: TreePanel,
recent_session_ids: Vec<(String, String)>,
approval_rx: tokio::sync::mpsc::Receiver<PendingApproval>,
pending_approval: Option<PendingApproval>,
vim_mode: VimMode,
mouse_captured: bool,
viewport_rect: Option<Rect>,
palette_results_rect: Option<Rect>,
total_input_tokens: u64,
total_output_tokens: u64,
pricing: Option<Pricing>,
auto_compact: bool,
compact_threshold: u64,
compact_nudged: bool,
model_handle: Arc<std::sync::Mutex<String>>,
provider_handle: Arc<std::sync::Mutex<Arc<dyn LlmProvider>>>,
quit: bool,
}
impl TuiApp {
async fn event_loop(
mut self,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> anyhow::Result<()> {
while !self.quit {
terminal.draw(|f| self.render(f))?;
self.poll_runner().await;
let timeout = if self.runner_active {
Duration::from_millis(80)
} else {
Duration::from_millis(50)
};
if event::poll(timeout)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
self.handle_key(key).await;
}
Event::Mouse(m) => {
self.handle_mouse(m).await;
}
Event::Resize(_, _) => { }
_ => {}
}
}
self.tick = self.tick.wrapping_add(1);
}
Ok(())
}
async fn poll_runner(&mut self) {
if self.pending_approval.is_none() {
if let Ok(req) = self.approval_rx.try_recv() {
self.pending_approval = Some(req);
}
}
if self.runner_event_rx.is_none() {
return;
}
loop {
let next = self
.runner_event_rx
.as_mut()
.and_then(|rx| rx.try_recv().ok());
match next {
Some(ev) => self.apply_event(ev),
None => break,
}
}
if let Some(handle) = &self.runner_handle {
if handle.is_finished() {
let handle = self.runner_handle.take().expect("checked above");
match handle.await {
Ok(Ok(turn)) => {
self.messages = turn.messages;
self.turns += 1;
let summary = self
.messages
.iter()
.rev()
.find(|m| m.role == Role::User)
.map(|m| m.content.clone())
.unwrap_or_default();
let _ = self.palace.diary_append(&self.project_slug, &summary).await;
let _ = self
.session_store
.update(self.session_id, self.messages.clone(), self.turns)
.await;
self.maybe_notify("Aonyx Agent", "Turn finished", Duration::from_secs(5));
}
Ok(Err(e)) => {
self.maybe_notify("Aonyx Agent (error)", &format!("{e}"), Duration::ZERO);
self.push_line(error_line(format!("{e}")));
}
Err(e) => {
self.maybe_notify(
"Aonyx Agent (error)",
&format!("join: {e}"),
Duration::ZERO,
);
self.push_line(error_line(format!("join: {e}")));
}
}
self.runner_event_rx = None;
self.runner_active = false;
self.turn_started_at = None;
self.retire_thinking_line();
self.maybe_auto_compact().await;
}
}
}
fn retire_thinking_line(&mut self) {
if let Some(idx) = self.thinking_line.take() {
if idx < self.viewport.len() {
self.viewport.remove(idx);
}
}
self.first_delta_received = false;
}
fn rerender_assistant_markdown(&mut self) {
let Some(start) = self.assistant_msg_start else {
return;
};
if self.current_assistant_text.trim().is_empty() {
return;
}
if start > self.viewport.len() {
return;
}
self.viewport.truncate(start);
self.viewport.push(Line::from(Span::styled(
"aonyx>",
Style::default()
.fg(self.theme.assistant_prefix)
.add_modifier(Modifier::BOLD),
)));
let rendered = tui_markdown::from_str(&self.current_assistant_text);
for line in rendered.lines.into_iter() {
self.viewport.push(line_to_static(line));
}
self.last_md_render_chars = self.current_assistant_text.chars().count();
}
fn should_rerender_markdown(&self, delta: &str) -> bool {
if self.last_md_render_chars == 0 && !self.current_assistant_text.is_empty() {
return true;
}
if delta.contains('\n') {
return true;
}
let new_chars = self.current_assistant_text.chars().count();
new_chars.saturating_sub(self.last_md_render_chars) >= STREAM_MD_MIN_INCREMENT
}
fn finalize_assistant_message(&mut self) {
self.rerender_assistant_markdown();
}
fn apply_event(&mut self, event: TurnEvent) {
match event {
TurnEvent::AssistantDelta(text) => {
if !self.first_delta_received {
self.retire_thinking_line();
self.first_delta_received = true;
self.assistant_msg_start = Some(self.viewport.len());
}
self.total_output_tokens = self
.total_output_tokens
.saturating_add(pricing::estimate_tokens(&text));
self.current_assistant_text.push_str(&text);
if self.should_rerender_markdown(&text) {
self.rerender_assistant_markdown();
}
}
TurnEvent::AssistantMessageEnd => {
self.finalize_assistant_message();
if !self.viewport.is_empty() {
let last_empty = self
.viewport
.last()
.map(|l| l.spans.is_empty())
.unwrap_or(false);
if !last_empty {
self.viewport.push(Line::default());
}
}
self.first_delta_received = false;
self.assistant_msg_start = None;
self.current_assistant_text.clear();
self.last_md_render_chars = 0;
}
TurnEvent::ToolStart { name, args, class } => {
self.retire_thinking_line();
self.first_delta_received = true;
let dot_color = match class {
SafetyClass::Safe => Color::Cyan,
SafetyClass::Caution => Color::Yellow,
SafetyClass::Destructive => Color::Red,
};
let is_diff_tool = name == "fs_edit" || name == "fs_write";
let preview = if is_diff_tool {
args.get("path")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string()
} else {
abbreviate_value(&args, 80)
};
self.push_line(Line::from(vec![
Span::styled("● ", Style::default().fg(dot_color)),
Span::styled(name.clone(), Style::default().fg(Color::Cyan)),
Span::styled(format!("({preview})"), Style::default().fg(Color::DarkGray)),
]));
if is_diff_tool {
self.push_diff_preview(&name, &args);
}
}
TurnEvent::ToolEnd { name, ok, summary } => {
let arrow_color = if ok { Color::Green } else { Color::Red };
let trimmed = if self.show_tool_details {
summary
} else {
truncate(&summary, 120)
};
self.push_line(Line::from(vec![
Span::styled(" ↳ ", Style::default().fg(arrow_color)),
Span::styled(
format!("{name}: {trimmed}"),
Style::default().fg(Color::DarkGray),
),
]));
}
TurnEvent::ToolRejected { name, class } => {
self.push_line(Line::from(vec![
Span::styled(" ✗ rejected: ", Style::default().fg(Color::Red)),
Span::styled(
format!("{name} ({class:?})"),
Style::default().fg(Color::DarkGray),
),
]));
}
TurnEvent::IterationStart(n) if n > 1 => {
self.push_line(Line::from(Span::styled(
format!("[iter {n}]"),
Style::default().fg(Color::DarkGray),
)));
}
TurnEvent::Done {
max_iterations_hit: true,
iterations,
} => {
self.push_line(Line::from(Span::styled(
format!("(loop hit max_iterations = {iterations})"),
Style::default().fg(Color::Yellow),
)));
}
_ => {}
}
}
fn push_line(&mut self, line: Line<'static>) {
self.viewport.push(line);
if self.viewport.len() > VIEWPORT_MAX_LINES {
let drop = self.viewport.len() - VIEWPORT_MAX_LINES;
self.viewport.drain(..drop);
if let Some(idx) = self.thinking_line {
self.thinking_line = idx.checked_sub(drop);
}
}
}
fn push_thinking_line(&mut self) {
let span = Span::styled(
" 💭 thinking…",
Style::default()
.fg(self.theme.thinking)
.add_modifier(Modifier::ITALIC),
);
self.viewport.push(Line::from(span));
self.thinking_line = Some(self.viewport.len() - 1);
self.first_delta_received = false;
}
fn clamp_scroll_and_maybe_resume_auto(&mut self) {
let max = self.max_scroll();
if self.scroll >= max {
self.scroll = max;
self.auto_scroll = true;
}
}
fn max_scroll(&self) -> u16 {
let total = self.viewport.len() as u32;
let visible = self.viewport_height as u32;
(total.saturating_sub(visible)) as u16
}
async fn handle_key(&mut self, key: KeyEvent) {
use KeyCode::*;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let suggestions_open = !self.suggestions.is_empty();
if self.pending_approval.is_some() {
self.handle_approval_key(key);
return;
}
if self.palette.open {
self.handle_palette_key(key).await;
return;
}
if self.kg_panel.open {
self.handle_kg_panel_key(key);
return;
}
if self.tools_panel.open {
self.handle_tools_panel_key(key);
return;
}
if self.mcp_panel.open {
self.handle_mcp_panel_key(key);
return;
}
if self.skills_panel.open {
self.handle_skills_panel_key(key);
return;
}
if self.inspect_panel.open {
self.handle_inspect_panel_key(key);
return;
}
if self.theme_editor.open {
self.handle_theme_editor_key(key).await;
return;
}
if self.tree_panel.open {
self.handle_tree_panel_key(key);
return;
}
if self.vim_mode == VimMode::Normal {
self.handle_vim_normal_key(key);
return;
}
match key.code {
Char('p') if ctrl => {
self.palette.show();
}
Esc if suggestions_open => {
self.dismiss_suggestions();
}
Esc if self.vim_mode == VimMode::Insert => {
self.vim_mode = VimMode::Normal;
}
Esc => {
self.quit = true;
}
Char('c') | Char('d') if ctrl => {
self.quit = true;
}
PageUp => {
self.auto_scroll = false;
self.scroll = self.scroll.saturating_sub(8);
}
PageDown => {
self.scroll = self.scroll.saturating_add(8);
self.clamp_scroll_and_maybe_resume_auto();
}
End => {
self.auto_scroll = true;
}
Home => {
self.auto_scroll = false;
self.scroll = 0;
}
Up if suggestions_open => {
if self.suggestion_idx > 0 {
self.suggestion_idx -= 1;
}
}
Down if suggestions_open => {
if self.suggestion_idx + 1 < self.suggestions.len() {
self.suggestion_idx += 1;
}
}
Tab if suggestions_open => {
self.accept_suggestion();
}
Up if self.composer_at_top() && !shift => self.history_prev(),
Down if self.composer_at_bottom() && !shift => self.history_next(),
Enter if shift || alt => {
self.composer.insert_newline();
self.update_suggestions();
}
Enter => {
self.submit_composer().await;
self.dismiss_suggestions();
}
_ => {
let _ = self.composer.input(key);
self.update_suggestions();
}
}
}
fn update_suggestions(&mut self) {
let text = self.composer.lines().join("\n");
let cursor_byte = cursor_byte_offset(&self.composer);
match detect_trigger(&text, cursor_byte) {
Some((trigger, trigger_pos, query)) => {
let pool: Vec<String> = match &trigger {
Trigger::At => self.file_candidates(),
Trigger::Slash => SLASH_CANDIDATES.iter().map(|s| (*s).to_string()).collect(),
Trigger::SlashArg(cmd) => self.slash_arg_pool(cmd),
};
if pool.is_empty() {
self.dismiss_suggestions();
self.apply_composer_style();
return;
}
self.suggestion_kind = Some(trigger);
self.suggestion_trigger_pos = trigger_pos;
let suggestions = if query.is_empty() {
pool.into_iter().take(SUGGESTION_LIMIT).collect()
} else {
fuzzy_top(&query, &pool, SUGGESTION_LIMIT)
};
self.suggestions = suggestions;
if self.suggestion_idx >= self.suggestions.len() {
self.suggestion_idx = 0;
}
}
None => self.dismiss_suggestions(),
}
self.apply_composer_style();
}
fn slash_arg_pool(&mut self, cmd: &str) -> Vec<String> {
match cmd {
"themes" | "theme" | "t" => theme::available_names()
.into_iter()
.map(str::to_string)
.collect(),
"load" | "switch" => self
.recent_session_ids
.iter()
.map(|(id, _)| id.clone())
.collect(),
"ingest" => self.file_candidates(),
"undo" | "u" => vec![
"list".to_string(),
"1".to_string(),
"3".to_string(),
"5".to_string(),
"10".to_string(),
],
"model" => pricing::known_models(&self.provider_name)
.iter()
.map(|s| (*s).to_string())
.collect(),
"provider" => vec![
"anthropic".to_string(),
"openai".to_string(),
"openrouter".to_string(),
"ollama".to_string(),
"lm-studio".to_string(),
"claude-code".to_string(),
],
_ => Vec::new(),
}
}
fn apply_composer_style(&mut self) {
let mode = detect_composer_mode(&self.composer);
let (text_style, border_color) = match mode {
ComposerMode::Chat => (
Style::default().fg(self.theme.header_fg),
self.theme.composer_border,
),
ComposerMode::Slash => (
Style::default()
.fg(self.theme.suggestion_border)
.add_modifier(Modifier::BOLD),
self.theme.suggestion_border,
),
ComposerMode::Bash => (
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
Color::Yellow,
),
};
self.composer.set_style(text_style);
self.composer.set_block(
Block::default()
.borders(Borders::TOP | Borders::BOTTOM)
.border_style(Style::default().fg(border_color)),
);
}
fn dismiss_suggestions(&mut self) {
self.suggestions.clear();
self.suggestion_idx = 0;
self.suggestion_kind = None;
}
fn accept_suggestion(&mut self) {
let Some(selected) = self.suggestions.get(self.suggestion_idx).cloned() else {
return;
};
let Some(trigger) = self.suggestion_kind.clone() else {
return;
};
let trigger_pos = self.suggestion_trigger_pos;
let text = self.composer.lines().join("\n");
let cursor_byte = cursor_byte_offset(&self.composer);
let mut new_text = String::new();
match trigger {
Trigger::At => {
new_text.push_str(&text[..=trigger_pos.min(text.len() - 1)]);
new_text.push_str(&selected);
}
Trigger::Slash => {
new_text.push_str(&text[..trigger_pos]);
new_text.push_str(&selected);
}
Trigger::SlashArg(_) => {
new_text.push_str(&text[..trigger_pos]);
new_text.push_str(&selected);
}
}
new_text.push(' ');
if cursor_byte <= text.len() {
new_text.push_str(&text[cursor_byte..]);
}
self.set_composer_content(&new_text);
self.dismiss_suggestions();
}
fn file_candidates(&mut self) -> Vec<String> {
if self.file_cache.is_none() {
let base = std::env::current_dir().unwrap_or_else(|_| ".".into());
self.file_cache = Some(collect_files(&base, FILE_CACHE_MAX_DEPTH, FILE_CACHE_LIMIT));
}
self.file_cache.clone().unwrap_or_default()
}
fn composer_at_top(&self) -> bool {
self.composer.cursor().0 == 0
}
fn composer_at_bottom(&self) -> bool {
let (row, _) = self.composer.cursor();
row >= self.composer.lines().len().saturating_sub(1)
}
fn set_composer_content(&mut self, content: &str) {
let lines: Vec<String> = if content.is_empty() {
vec![String::new()]
} else {
content.lines().map(String::from).collect()
};
let mut next = TextArea::new(lines);
next.set_cursor_line_style(Style::default());
next.set_placeholder_text("type a message — Enter to send, Shift+Enter for newline");
next.move_cursor(CursorMove::Bottom);
next.move_cursor(CursorMove::End);
self.composer = next;
self.apply_composer_style();
}
fn history_prev(&mut self) {
if self.history.is_empty() {
return;
}
let new_idx = match self.history_cursor {
None => self.history.len() - 1,
Some(0) => return,
Some(n) => n - 1,
};
if self.history_cursor.is_none() {
self.scratch = self.composer.lines().to_vec();
}
self.history_cursor = Some(new_idx);
let value = self.history[new_idx].clone();
self.set_composer_content(&value);
}
fn history_next(&mut self) {
match self.history_cursor {
None => {}
Some(n) if n + 1 >= self.history.len() => {
let scratch = self.scratch.clone().join("\n");
self.history_cursor = None;
self.set_composer_content(&scratch);
}
Some(n) => {
self.history_cursor = Some(n + 1);
let value = self.history[n + 1].clone();
self.set_composer_content(&value);
}
}
}
async fn submit_composer(&mut self) {
if self.runner_active {
return;
}
let content = self.composer.lines().join("\n");
let trimmed = content.trim();
if trimmed.is_empty() {
return;
}
if self.history.last().map(String::as_str) != Some(trimmed) {
self.history.push(trimmed.to_string());
if self.history.len() > HISTORY_MAX {
let drop = self.history.len() - HISTORY_MAX;
self.history.drain(..drop);
}
}
self.history_cursor = None;
self.scratch.clear();
if let Some(cmd) = trimmed.strip_prefix('!') {
self.handle_bash_inline(cmd.trim()).await;
self.set_composer_content("");
return;
}
if let Some(cmd) = SlashCommand::parse(trimmed) {
self.handle_slash(cmd).await;
} else {
let (display_text, refs) = extract_refs(trimmed);
self.push_line(Line::from(vec![
Span::styled(
"you> ",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(self.theme.user_prefix),
),
Span::raw(display_text.clone()),
]));
let mut attachments: Vec<aonyx_core::Attachment> = Vec::new();
if !refs.is_empty() {
let (image_refs, text_refs): (Vec<_>, Vec<_>) = refs
.iter()
.cloned()
.partition(|p| images::looks_like_image(p));
for path in &image_refs {
let from_url = is_http_url(path);
if from_url {
self.push_dim(&format!(" 📷 fetching {path}"));
} else {
self.render_image_ref(path);
}
let encoded = if from_url {
attach_image_from_url(path).await
} else {
base64_image(path)
};
match encoded {
Ok((media_type, data)) => {
attachments.push(aonyx_core::Attachment::Image { media_type, data });
}
Err(e) => {
self.push_line(error_line(format!("📷 attach {path}: {e}")));
}
}
}
if !text_refs.is_empty() {
let resolved = resolve_refs(&text_refs).await;
for (path, result) in &resolved {
match result {
Ok(text) => {
self.push_dim(&format!(
" 📎 loaded: {path} ({} bytes)",
text.len()
));
}
Err(e) => {
self.push_line(error_line(format!("📎 {path}: {e}")));
}
}
}
if let Some(ctx_msg) = build_refs_message(&resolved) {
self.messages.push(ctx_msg);
}
}
}
if attachments.is_empty() {
self.messages.push(Message::new(Role::User, display_text));
} else {
self.messages.push(Message::with_attachments(
Role::User,
display_text,
attachments,
));
}
self.push_thinking_line();
self.auto_scroll = true;
self.start_runner();
}
self.set_composer_content("");
}
async fn handle_bash_inline(&mut self, cmd: &str) {
if cmd.is_empty() {
self.push_dim("(empty bash command — try `!ls` or `!git status`)");
return;
}
self.push_line(Line::from(vec![
Span::styled(
"you> ",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(self.theme.user_prefix),
),
Span::styled(format!("!{cmd}"), Style::default().fg(Color::Yellow)),
]));
match run_bash(cmd).await {
Ok(out) => {
self.push_dim(&format!(" $ {cmd}"));
for line in out.lines() {
self.push_line(Line::from(Span::raw(line.to_string())));
}
self.messages.push(Message::new(
Role::System,
format!("User ran `!{cmd}` in the shell. Output:\n```\n{out}\n```"),
));
}
Err(e) => {
self.push_line(error_line(format!("bash: {e}")));
}
}
self.auto_scroll = true;
}
fn start_runner(&mut self) {
let (tx, rx) = mpsc::channel::<TurnEvent>(256);
let runner = Arc::clone(&self.runner);
let messages = self.messages.clone();
let input_estimate: u64 = messages
.iter()
.map(|m| pricing::estimate_tokens(&m.content))
.sum();
self.total_input_tokens = self.total_input_tokens.saturating_add(input_estimate);
let handle = tokio::spawn(async move { runner.run_streaming(messages, tx).await });
self.runner_event_rx = Some(rx);
self.runner_handle = Some(handle);
self.runner_active = true;
self.turn_started_at = Some(Instant::now());
}
fn maybe_notify(&self, summary: &str, body: &str, min_elapsed: Duration) {
if !self.desktop_notifications {
return;
}
if let Some(started) = self.turn_started_at {
if started.elapsed() < min_elapsed {
return;
}
}
let _ = notify_rust::Notification::new()
.summary(summary)
.body(body)
.timeout(notify_rust::Timeout::Milliseconds(4000))
.show();
}
async fn handle_slash(&mut self, cmd: SlashCommand) {
match cmd {
SlashCommand::Quit => self.quit = true,
SlashCommand::Clear | SlashCommand::New => {
let system = self
.messages
.first()
.filter(|m| m.role == Role::System)
.cloned();
self.messages.clear();
if let Some(s) = system {
self.messages.push(s);
}
self.turns = 0;
self.viewport.clear();
if matches!(cmd, SlashCommand::New) {
if let Ok(created) = self
.session_store
.create(&self.project_slug, self.messages.clone())
.await
{
self.session_id = created.id;
self.push_dim(&format!("(new session #{})", created.id));
}
self.refresh_recent_sessions().await;
} else {
self.push_dim("(history cleared)");
}
}
SlashCommand::Help => {
for line in HELP_LINES {
self.push_dim(line);
}
}
SlashCommand::Models => {
self.push_dim(&format!(
"active: {} · {}",
self.provider_name, self.model_name
));
self.push_dim(
"available: anthropic · openai · openrouter · ollama · lm-studio · claude-code",
);
self.push_dim("switch with: edit ~/.aonyx/config.toml (live switch in V0.3)");
}
SlashCommand::Sessions => {
match self
.session_store
.list_by_project(&self.project_slug, 20)
.await
{
Ok(list) if list.is_empty() => self.push_dim("(no other sessions yet)"),
Ok(list) => {
self.push_dim(&format!(
"{} session(s) for project '{}':",
list.len(),
self.project_slug
));
for (i, s) in list.iter().enumerate() {
let marker = if s.id == self.session_id { "▸" } else { " " };
let line = format!(
"{marker} [{:>2}] {} · {} turn(s) · {}",
i + 1,
s.updated_at.format("%Y-%m-%d %H:%M"),
s.turns,
s.title
);
self.push_dim(&line);
}
self.push_dim("(switch UI lands in Phase D.5)");
}
Err(e) => self.push_line(error_line(format!("list sessions: {e}"))),
}
}
SlashCommand::Export(target) => {
let path = export_path(target);
match self.export_markdown(&path).await {
Ok(()) => self.push_dim(&format!(
"exported: {} ({} messages)",
path.display(),
self.messages.len()
)),
Err(e) => self.push_line(error_line(format!("export failed: {e}"))),
}
}
SlashCommand::ExportHtml(target) => {
let path = export_html_path(target);
match self.export_html(&path).await {
Ok(()) => self.push_dim(&format!(
"exported HTML: {} ({} messages)",
path.display(),
self.messages.len()
)),
Err(e) => self.push_line(error_line(format!("export-html failed: {e}"))),
}
}
SlashCommand::ExportBundle(target) => {
let path = export_bundle_path(target);
match self.export_bundle(&path).await {
Ok(()) => self.push_dim(&format!(
"exported bundle: {} (md + html + messages.json + meta.json, {} messages)",
path.display(),
self.messages.len()
)),
Err(e) => self.push_line(error_line(format!("export-bundle failed: {e}"))),
}
}
SlashCommand::ImportBundle(target) => {
let Some(path) = target.filter(|q| !q.trim().is_empty()) else {
self.push_dim("usage: /import-bundle <path-to.zip>");
return;
};
match self.import_bundle(path.trim()).await {
Ok(n) => {
self.push_dim(&format!(
"imported {n} messages as a new session — /load it from /find, \
or it's now active"
));
}
Err(e) => self.push_line(error_line(format!("import-bundle failed: {e}"))),
}
}
SlashCommand::Rename(target) => {
let Some(title) = target.filter(|q| !q.trim().is_empty()) else {
self.push_dim("usage: /rename <new title>");
return;
};
let title = title.trim().to_string();
match self.session_store.rename(self.session_id, &title).await {
Ok(()) => {
self.push_dim(&format!("session renamed → \"{title}\""));
self.refresh_recent_sessions().await;
}
Err(e) => self.push_line(error_line(format!("rename failed: {e}"))),
}
}
SlashCommand::Cost => {
self.show_cost_breakdown();
}
SlashCommand::Mcp => {
self.open_mcp_panel();
}
SlashCommand::ThemeEdit => {
self.theme_editor.open = true;
self.theme_editor.field = 0;
self.theme_editor.channel = 0;
self.push_dim(
"theme editor: ↑/↓ field · ←/→ R/G/B · +/- adjust · s save · Esc close",
);
}
SlashCommand::Details => {
self.show_tool_details = !self.show_tool_details;
let state = if self.show_tool_details { "on" } else { "off" };
self.push_dim(&format!("tool details: {state}"));
}
SlashCommand::Thinking => {
self.show_thinking = !self.show_thinking;
let state = if self.show_thinking { "on" } else { "off" };
self.push_dim(&format!(
"reasoning visibility: {state} (requires a provider that emits thinking blocks)"
));
}
SlashCommand::Themes(target) => match target {
Some(name) => {
let new_theme = theme::by_name(&name);
let resolved_to_default = !name.eq_ignore_ascii_case(new_theme.name);
self.theme = new_theme;
if resolved_to_default {
self.push_dim(&format!(
"unknown theme '{name}' — staying on {}",
new_theme.name
));
} else {
self.push_dim(&format!("theme: {}", new_theme.name));
}
}
None => {
self.push_dim(&format!(
"active theme: {} · available: {}",
self.theme.name,
theme::available_names().join(" · ")
));
}
},
SlashCommand::Editor => {
self.push_dim("`/editor` runs in legacy mode (`aonyx` without --tui) for now");
}
SlashCommand::Vim => {
self.vim_mode = match self.vim_mode {
VimMode::Off => {
self.push_dim(
"vim mode: on (Esc = Normal · i/a = Insert · j/k scroll · g/G top/bottom · q quit)",
);
VimMode::Insert
}
VimMode::Insert | VimMode::Normal => {
self.push_dim("vim mode: off");
VimMode::Off
}
};
}
SlashCommand::Find(target) => {
let Some(query) = target.filter(|q| !q.trim().is_empty()) else {
self.push_dim("usage: /find <query> — searches all sessions");
return;
};
match self.session_store.search(query.trim(), 10).await {
Ok(hits) if hits.is_empty() => self.push_dim(&format!(
"no hits for '{}' across {} project(s)",
query.trim(),
"all"
)),
Ok(hits) => {
self.push_dim(&format!(
"{} hit(s) for '{}' — `/load <id>` to switch:",
hits.len(),
query.trim()
));
for h in hits {
let short_id: String = h.id.to_string().chars().take(8).collect();
let header = format!(
" [{short_id}] {} · {} · {} turn(s) · \"{}\"",
h.updated_at.format("%Y-%m-%d %H:%M"),
h.project,
h.turns,
h.title
);
self.push_dim(&header);
self.push_dim(&format!(" └ {}", h.snippet));
}
}
Err(e) => self.push_line(error_line(format!("search failed: {e}"))),
}
}
SlashCommand::Load(target) => {
let Some(prefix) = target.filter(|q| !q.trim().is_empty()) else {
self.push_dim("usage: /load <id-prefix> — from a /find result");
return;
};
match self.session_store.find_by_id_prefix(prefix.trim(), 5).await {
Ok(matches) if matches.is_empty() => {
self.push_dim(&format!("no session matches prefix '{}'", prefix.trim()))
}
Ok(matches) if matches.len() > 1 => {
self.push_dim(&format!(
"ambiguous prefix '{}' — {} matches:",
prefix.trim(),
matches.len()
));
for r in matches {
let short: String = r.id.to_string().chars().take(8).collect();
self.push_dim(&format!(
" [{short}] {} · {}",
r.updated_at.format("%Y-%m-%d %H:%M"),
r.title
));
}
}
Ok(mut matches) => {
let target_record = matches.remove(0);
let _ = self
.session_store
.update(self.session_id, self.messages.clone(), self.turns)
.await;
let loaded_id = target_record.id;
let short: String = loaded_id.to_string().chars().take(8).collect();
self.session_id = loaded_id;
self.messages = target_record.messages;
self.turns = target_record.turns;
self.project_slug = target_record.project.clone();
self.viewport.clear();
self.viewport.push(Line::from(Span::styled(
format!(
"🦦 loaded session [{short}] · {} · \"{}\"",
target_record.project, target_record.title
),
Style::default().fg(self.theme.dim),
)));
self.auto_scroll = true;
self.scroll = 0;
self.refresh_recent_sessions().await;
}
Err(e) => self.push_line(error_line(format!("load failed: {e}"))),
}
}
SlashCommand::Kg => {
self.open_kg_panel().await;
}
SlashCommand::Tools => {
self.open_tools_panel();
}
SlashCommand::Skills => {
self.open_skills_panel();
}
SlashCommand::Inspect => {
self.open_inspect_panel();
}
SlashCommand::Fork => {
self.fork_session().await;
}
SlashCommand::Compact => {
self.compact_session(false).await;
}
SlashCommand::Retry => {
self.retry_last_turn();
}
SlashCommand::Model(target) => {
self.switch_model(target);
}
SlashCommand::Provider(target) => {
self.switch_provider(target);
}
SlashCommand::Tree => {
self.open_tree_panel().await;
}
SlashCommand::Mouse => {
self.toggle_mouse_capture();
}
SlashCommand::Ingest(target) => {
let Some(path) = target.filter(|s| !s.trim().is_empty()) else {
self.push_dim("usage: /ingest <path> — adds the file to the project palace");
return;
};
self.ingest_file(path.trim()).await;
}
SlashCommand::Undo(target) => {
self.handle_undo_command(target);
}
SlashCommand::Init => {
let path = std::path::PathBuf::from("agent.yaml");
if path.exists() {
self.push_dim(&format!(
"{} already exists — leaving it alone",
path.display()
));
} else {
let yaml = format!(
"# Aonyx Agent — per-project configuration\n\
persona: \"You are an Aonyx agent helping with {} .\"\n\
system_prompt: |\n Be concise. Cite sources. Confirm destructive actions.\n\
preferred_provider: {}\n\
preferred_model: {}\n",
self.project_slug, self.provider_name, self.model_name
);
match tokio::fs::write(&path, yaml).await {
Ok(()) => self.push_dim(&format!("created: {}", path.display())),
Err(e) => self.push_line(error_line(format!("init failed: {e}"))),
}
}
}
}
}
async fn handle_palette_key(&mut self, key: KeyEvent) {
use KeyCode::*;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
Esc => self.palette.close(),
Char('p') if ctrl => self.palette.close(),
Char('c') | Char('d') if ctrl => {
self.palette.close();
self.quit = true;
}
Up => self.palette.move_up(),
Down => self.palette.move_down(),
Enter => {
let action = self.palette.current().map(|e| e.action.clone());
self.palette.close();
if let Some(action) = action {
self.dispatch_palette_action(action).await;
}
}
Backspace => {
self.palette.query.pop();
self.palette.refilter();
}
Char(c) if !ctrl => {
self.palette.query.push(c);
self.palette.refilter();
}
_ => {}
}
}
fn handle_undo_command(&mut self, target: Option<String>) {
let arg = target.as_deref().map(str::trim);
if matches!(arg, Some("list") | Some("l")) {
match aonyx_tools::undo::list_snapshots(20) {
Ok(snaps) if snaps.is_empty() => self.push_dim("undo journal: empty"),
Ok(snaps) => {
self.push_dim(&format!(
"undo journal ({} entr{}, newest first):",
snaps.len(),
if snaps.len() == 1 { "y" } else { "ies" }
));
for (i, s) in snaps.iter().enumerate() {
let detail = if s.prior.is_none() {
"(new file)"
} else {
"(in-place edit)"
};
self.push_dim(&format!(
" [{i:>2}] ts={} · {} ({}) {}",
s.ts, s.path, s.tool, detail
));
}
}
Err(e) => self.push_line(error_line(format!("undo failed: {e}"))),
}
return;
}
let n = arg
.and_then(|s| s.parse::<usize>().ok())
.map(|n| n.max(1))
.unwrap_or(1);
let mut reverted = 0usize;
let mut last_path: Option<String> = None;
for _ in 0..n {
match aonyx_tools::undo::pop_last_snapshot() {
Ok(Some(snap)) => match aonyx_tools::undo::restore(&snap) {
Ok(()) => {
reverted += 1;
last_path = Some(snap.path.clone());
}
Err(e) => {
self.push_line(error_line(format!("undo failed: {e}")));
break;
}
},
Ok(None) => break,
Err(e) => {
self.push_line(error_line(format!("undo failed: {e}")));
break;
}
}
}
match (reverted, last_path) {
(0, _) => self.push_dim("undo: nothing to revert"),
(1, Some(p)) => self.push_dim(&format!("undo: restored {p}")),
(n, Some(p)) => self.push_dim(&format!(
"undo: restored {n} snapshot(s) — last touched {p}"
)),
_ => self.push_dim("undo: done"),
}
}
async fn ingest_file(&mut self, path: &str) {
let text = match tokio::fs::read_to_string(path).await {
Ok(t) => t,
Err(e) => {
self.push_line(error_line(format!("ingest {path}: {e}")));
return;
}
};
if text.trim().is_empty() {
self.push_dim(&format!("ingest {path}: (empty file — nothing to add)"));
return;
}
let kind = ingest_kind_from_path(path);
let chunks = split_into_chunks(&text, INGEST_CHUNK_MAX_CHARS);
let chunk_count = chunks.len();
let mut appended = 0usize;
for content in chunks {
let chunk = Chunk::new(&self.project_slug, path, content).with_kind(kind);
match self.palace.chunks.append(chunk).await {
Ok(_) => appended += 1,
Err(e) => {
self.push_line(error_line(format!("ingest chunk: {e}")));
}
}
}
let total_chars = text.chars().count();
self.push_dim(&format!(
"📥 ingested {path} → {appended}/{chunk_count} chunk(s) · kind={kind} · {} chars",
total_chars
));
}
fn switch_provider(&mut self, target: Option<String>) {
const AVAILABLE: [&str; 6] = [
"anthropic",
"openai",
"openrouter",
"ollama",
"lm-studio",
"claude-code",
];
let Some(id) = target
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
else {
self.push_dim(&format!("active provider: {}", self.provider_name));
self.push_dim(&format!("available: {}", AVAILABLE.join(" · ")));
return;
};
if id == self.provider_name {
self.push_dim(&format!("already on {id}"));
return;
}
let mut cfg = match crate::config::Config::load_or_init() {
Ok(mut c) => {
c.provider = id.clone();
c
}
Err(e) => {
self.push_line(error_line(format!("provider: load config: {e}")));
return;
}
};
let provider = match crate::build_provider(&cfg) {
Ok(p) => p,
Err(e) => {
self.push_line(error_line(format!("provider '{id}': {e}")));
return;
}
};
let swapped = {
match self.provider_handle.lock() {
Ok(mut slot) => {
*slot = provider;
true
}
Err(_) => false,
}
};
if !swapped {
self.push_line(error_line("provider: could not acquire lock".to_string()));
return;
}
self.provider_name = id.clone();
let remapped = if pricing::model_matches_provider(&id, &self.model_name) {
None
} else if let Some(def) = pricing::default_model(&id) {
let wrote = {
match self.model_handle.lock() {
Ok(mut slot) => {
*slot = def.to_string();
true
}
Err(_) => false,
}
};
if wrote {
self.model_name = def.to_string();
Some(def)
} else {
None
}
} else {
None
};
self.pricing = pricing::lookup(&id, &self.model_name);
cfg.model = self.model_name.clone();
let persisted = match crate::config::Config::config_path() {
Ok(path) => toml::to_string_pretty(&cfg)
.ok()
.and_then(|s| std::fs::write(&path, s).ok())
.is_some(),
Err(_) => false,
};
let note = if persisted { " (saved)" } else { "" };
match remapped {
Some(m) => self.push_dim(&format!(
"provider → {id}{note} · model remapped → {m} (/model to change it)"
)),
None => self.push_dim(&format!(
"provider → {id}{note} (model stays {}; /model to change it)",
self.model_name
)),
}
}
fn switch_model(&mut self, target: Option<String>) {
let Some(name) = target
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
else {
let known = pricing::known_models(&self.provider_name);
self.push_dim(&format!("active model: {}", self.model_name));
if known.is_empty() {
self.push_dim(&format!(
"(no preset list for provider '{}' — pass any id: /model <name>)",
self.provider_name
));
} else {
self.push_dim(&format!("known {} models:", self.provider_name));
for m in known {
let marker = if *m == self.model_name { "▸" } else { " " };
self.push_dim(&format!(" {marker} {m}"));
}
}
return;
};
if name == self.model_name {
self.push_dim(&format!("already on {name}"));
return;
}
let wrote = {
match self.model_handle.lock() {
Ok(mut slot) => {
*slot = name.clone();
true
}
Err(_) => false,
}
};
if !wrote {
self.push_line(error_line("could not acquire model lock".to_string()));
return;
}
self.model_name = name.clone();
self.pricing = pricing::lookup(&self.provider_name, &name);
let cost_note = if self.pricing.is_some() {
""
} else {
" (no pricing table — cost shown as tokens only)"
};
self.push_dim(&format!("model → {name}{cost_note}"));
}
fn toggle_mouse_capture(&mut self) {
if self.mouse_captured {
if execute!(io::stdout(), DisableMouseCapture).is_ok() {
self.mouse_captured = false;
self.push_dim(
"mouse: off — drag to select, Ctrl+C / right-click to copy. `/mouse` to re-enable.",
);
} else {
self.push_line(error_line("could not disable mouse capture".to_string()));
}
} else if execute!(io::stdout(), EnableMouseCapture).is_ok() {
self.mouse_captured = true;
self.push_dim("mouse: on — scroll wheel + palette click restored.");
} else {
self.push_line(error_line("could not enable mouse capture".to_string()));
}
}
fn retry_last_turn(&mut self) {
if self.runner_active {
self.push_dim("retry: a turn is already running");
return;
}
let Some(last_user) = self.messages.iter().rposition(|m| m.role == Role::User) else {
self.push_dim("retry: no user message to re-run yet");
return;
};
let keep = last_user + 1;
let dropped = self.messages.len() - keep;
if dropped == 0 {
self.push_dim("↻ retry — re-running last message");
} else {
self.messages.truncate(keep);
self.push_dim(&format!(
"↻ retry — dropped {dropped} message(s), re-running last turn"
));
}
self.push_thinking_line();
self.auto_scroll = true;
self.start_runner();
}
fn conversation_tokens(&self) -> u64 {
self.messages
.iter()
.map(|m| pricing::estimate_tokens(&m.content))
.sum()
}
async fn compact_session(&mut self, automatic: bool) {
let has_system = self
.messages
.first()
.map(|m| m.role == Role::System)
.unwrap_or(false);
let head = usize::from(has_system);
if self.messages.len() <= head + COMPACT_KEEP_RECENT + 1 {
if !automatic {
self.push_dim("compact: not enough history to compact yet");
}
return;
}
let tail_start = self.messages.len() - COMPACT_KEEP_RECENT;
let middle: Vec<Message> = self.messages[head..tail_start].to_vec();
let label = if automatic { "auto-compact" } else { "compact" };
self.push_dim(&format!(
"⟳ {label}: summarizing {} message(s)…",
middle.len()
));
let summary = match self.runner.summarize(&middle).await {
Ok(s) if !s.trim().is_empty() => s,
Ok(_) => {
self.push_line(error_line(format!(
"{label}: model returned an empty summary"
)));
return;
}
Err(e) => {
self.push_line(error_line(format!("{label} failed: {e}")));
return;
}
};
let archive_id = self
.session_store
.fork(
&self.project_slug,
self.session_id,
self.messages.clone(),
self.turns,
)
.await
.ok()
.map(|r| r.id);
let mut rebuilt: Vec<Message> = Vec::with_capacity(2 + COMPACT_KEEP_RECENT);
if has_system {
rebuilt.push(self.messages[0].clone());
}
rebuilt.push(Message::new(
Role::System,
format!("[Earlier conversation, compacted]\n\n{summary}"),
));
rebuilt.extend_from_slice(&self.messages[tail_start..]);
self.messages = rebuilt;
self.compact_nudged = false;
let _ = self
.session_store
.update(self.session_id, self.messages.clone(), self.turns)
.await;
self.refresh_recent_sessions().await;
match archive_id {
Some(id) => {
let short: String = id.to_string().chars().take(8).collect();
self.push_dim(&format!(
"✓ {label} done — kept last {COMPACT_KEEP_RECENT} message(s) + summary · full history archived as [{short}] (`/load {short}`)"
));
}
None => self.push_dim(&format!(
"✓ {label} done — kept last {COMPACT_KEEP_RECENT} message(s) + summary (archive failed)"
)),
}
}
async fn maybe_auto_compact(&mut self) {
if self.conversation_tokens() < self.compact_threshold {
self.compact_nudged = false;
return;
}
if self.auto_compact {
self.compact_session(true).await;
} else if !self.compact_nudged {
self.compact_nudged = true;
self.push_dim(&format!(
"⚠ conversation ≈ {} tokens (over {}). Run /compact to summarize old turns.",
pricing::format_tokens(self.conversation_tokens()),
pricing::format_tokens(self.compact_threshold)
));
}
}
async fn fork_session(&mut self) {
let _ = self
.session_store
.update(self.session_id, self.messages.clone(), self.turns)
.await;
let parent_id = self.session_id;
match self
.session_store
.fork(
&self.project_slug,
parent_id,
self.messages.clone(),
self.turns,
)
.await
{
Ok(child) => {
let parent_short: String = parent_id.to_string().chars().take(8).collect();
let child_short: String = child.id.to_string().chars().take(8).collect();
self.session_id = child.id;
self.push_dim(&format!(
"🔱 forked [{parent_short}] → [{child_short}] · {} turn(s) carried over · `/load {parent_short}` to return",
self.turns
));
self.refresh_recent_sessions().await;
}
Err(e) => self.push_line(error_line(format!("fork failed: {e}"))),
}
}
async fn open_tree_panel(&mut self) {
let sessions = match self
.session_store
.list_by_project(&self.project_slug, 200)
.await
{
Ok(s) => s,
Err(e) => {
self.push_line(error_line(format!("tree: {e}")));
return;
}
};
self.tree_panel.lines = self.build_tree_lines(&sessions);
self.tree_panel.scroll = 0;
self.tree_panel.open = true;
}
fn build_tree_lines(&self, sessions: &[aonyx_memory::SessionRecord]) -> Vec<Line<'static>> {
use std::collections::HashMap;
let dim = Style::default().fg(self.theme.dim);
if sessions.is_empty() {
return vec![Line::from(Span::styled(" (no sessions yet)", dim))];
}
let ids: std::collections::HashSet<_> = sessions.iter().map(|s| s.id).collect();
let by_id: HashMap<_, _> = sessions.iter().map(|s| (s.id, s)).collect();
let mut children: HashMap<aonyx_memory::SessionId, Vec<aonyx_memory::SessionId>> =
HashMap::new();
let mut roots: Vec<aonyx_memory::SessionId> = Vec::new();
for s in sessions {
match s.parent_id {
Some(p) if ids.contains(&p) => children.entry(p).or_default().push(s.id),
_ => roots.push(s.id),
}
}
let name_style = Style::default().fg(self.theme.header_fg);
let active_style = Style::default()
.fg(self.theme.assistant_prefix)
.add_modifier(Modifier::BOLD);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut stack: Vec<(aonyx_memory::SessionId, usize)> =
roots.iter().rev().map(|id| (*id, 0usize)).collect();
while let Some((id, depth)) = stack.pop() {
if let Some(s) = by_id.get(&id) {
let short: String = s.id.to_string().chars().take(8).collect();
let indent = " ".repeat(depth);
let active = s.id == self.session_id;
let marker = if active { "▸ " } else { "• " };
let label_style = if active { active_style } else { name_style };
lines.push(Line::from(vec![
Span::styled(format!(" {indent}{marker}"), dim),
Span::styled(format!("[{short}] "), dim),
Span::styled(s.title.clone(), label_style),
Span::styled(
format!(
" · {} turn(s) · {}",
s.turns,
s.updated_at.format("%m-%d %H:%M")
),
dim,
),
]));
}
if let Some(kids) = children.get(&id) {
for kid in kids.iter().rev() {
stack.push((*kid, depth + 1));
}
}
}
lines
}
fn handle_tree_panel_key(&mut self, key: KeyEvent) {
use KeyCode::*;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
Esc | Char('q') => self.tree_panel.close(),
Char('c') | Char('d') if ctrl => {
self.tree_panel.close();
self.quit = true;
}
Up | Char('k') => self.tree_panel.scroll = self.tree_panel.scroll.saturating_sub(1),
Down | Char('j') => self.tree_panel.scroll = self.tree_panel.scroll.saturating_add(1),
PageUp => self.tree_panel.scroll = self.tree_panel.scroll.saturating_sub(8),
PageDown => self.tree_panel.scroll = self.tree_panel.scroll.saturating_add(8),
Home | Char('g') => self.tree_panel.scroll = 0,
End | Char('G') => self.tree_panel.scroll = u16::MAX,
_ => {}
}
}
async fn refresh_recent_sessions(&mut self) {
if let Ok(list) = self
.session_store
.list_by_project(&self.project_slug, 20)
.await
{
self.recent_session_ids = list
.into_iter()
.map(|s| {
let short: String = s.id.to_string().chars().take(8).collect();
(short, s.title)
})
.collect();
}
}
fn open_tools_panel(&mut self) {
let mut entries: Vec<ToolEntry> = self
.tool_registry
.names()
.map(|n| n.to_string())
.filter_map(|name| {
let h = self.tool_registry.get_raw(&name)?;
Some(ToolEntry {
class: h.classify(),
disabled: self.tool_registry.is_disabled(&name),
name,
})
})
.collect();
entries.sort_by(|a, b| a.name.cmp(&b.name));
self.tools_panel.entries = entries;
self.tools_panel.selected = 0;
self.tools_panel.open = true;
}
fn open_mcp_panel(&mut self) {
use std::collections::BTreeMap;
let mut servers: BTreeMap<String, (usize, usize)> = BTreeMap::new();
for name in self.tool_registry.names() {
if let Some((server, _tool)) = name.split_once("__") {
let entry = servers.entry(server.to_string()).or_insert((0, 0));
entry.0 += 1;
if self.tool_registry.is_disabled(name) {
entry.1 += 1;
}
}
}
self.mcp_panel.entries = servers
.into_iter()
.map(|(server, (total, off))| McpServerEntry {
server,
tool_count: total,
disabled: total > 0 && off == total,
})
.collect();
self.mcp_panel.selected = 0;
self.mcp_panel.open = true;
}
fn toggle_server(&mut self, server: &str, disable: bool) {
let prefix = format!("{server}__");
let names: Vec<String> = self
.tool_registry
.names()
.filter(|n| n.starts_with(&prefix))
.map(|n| n.to_string())
.collect();
for name in names {
if disable {
self.tool_registry.disable(&name);
} else {
self.tool_registry.enable(&name);
}
}
}
fn open_inspect_panel(&mut self) {
let snapshot = self.last_request.lock().ok().and_then(|s| s.clone());
let lines: Vec<Line<'static>> = match snapshot {
Some(json) => json
.lines()
.map(|l| {
Line::from(Span::styled(
l.to_string(),
Style::default().fg(self.theme.header_fg),
))
})
.collect(),
None => vec![Line::from(Span::styled(
"(no request captured yet — send a message first)",
Style::default().fg(self.theme.dim),
))],
};
self.inspect_panel.lines = lines;
self.inspect_panel.scroll = 0;
self.inspect_panel.open = true;
}
fn handle_inspect_panel_key(&mut self, key: KeyEvent) {
use KeyCode::*;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
Esc | Char('q') => self.inspect_panel.close(),
Char('c') | Char('d') if ctrl => {
self.inspect_panel.close();
self.quit = true;
}
Up | Char('k') => {
self.inspect_panel.scroll = self.inspect_panel.scroll.saturating_sub(1);
}
Down | Char('j') => {
self.inspect_panel.scroll = self.inspect_panel.scroll.saturating_add(1);
}
PageUp => {
self.inspect_panel.scroll = self.inspect_panel.scroll.saturating_sub(8);
}
PageDown => {
self.inspect_panel.scroll = self.inspect_panel.scroll.saturating_add(8);
}
Home | Char('g') => self.inspect_panel.scroll = 0,
End | Char('G') => self.inspect_panel.scroll = u16::MAX,
_ => {}
}
}
async fn handle_theme_editor_key(&mut self, key: KeyEvent) {
use KeyCode::*;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let field_count = theme::EDITABLE_FIELDS.len();
match key.code {
Esc | Char('q') => self.theme_editor.close(),
Char('c') | Char('d') if ctrl => {
self.theme_editor.close();
self.quit = true;
}
Up => {
if self.theme_editor.field > 0 {
self.theme_editor.field -= 1;
}
}
Down => {
if self.theme_editor.field + 1 < field_count {
self.theme_editor.field += 1;
}
}
Left => self.theme_editor.channel = self.theme_editor.channel.saturating_sub(1),
Right => {
if self.theme_editor.channel < 2 {
self.theme_editor.channel += 1;
}
}
Char('+') | Char('=') | Char('l') => self.adjust_theme_channel(8),
Char('-') | Char('_') | Char('h') => self.adjust_theme_channel(-8),
Char('s') => self.save_custom_theme().await,
_ => {}
}
}
fn adjust_theme_channel(&mut self, delta: i16) {
let idx = self.theme_editor.field;
let (mut r, mut g, mut b) = theme::color_to_rgb(self.theme.field_color(idx));
let chan = match self.theme_editor.channel {
0 => &mut r,
1 => &mut g,
_ => &mut b,
};
*chan = (*chan as i16 + delta).clamp(0, 255) as u8;
self.theme.set_field(idx, Color::Rgb(r, g, b));
}
async fn save_custom_theme(&mut self) {
let fields = self.theme.editable_rgb();
let custom = crate::config::CustomTheme::from_rgb_fields(&fields);
match crate::config::Config::load_or_init() {
Ok(mut cfg) => {
cfg.theme = Some("custom".to_string());
cfg.custom_theme = Some(custom);
match crate::config::Config::config_path() {
Ok(path) => match toml::to_string_pretty(&cfg) {
Ok(s) => match tokio::fs::write(&path, s).await {
Ok(()) => self.push_dim(&format!(
"✓ theme saved to {} (theme = \"custom\")",
path.display()
)),
Err(e) => self.push_line(error_line(format!("theme save: {e}"))),
},
Err(e) => self.push_line(error_line(format!("theme encode: {e}"))),
},
Err(e) => self.push_line(error_line(format!("theme path: {e}"))),
}
}
Err(e) => self.push_line(error_line(format!("theme load config: {e}"))),
}
}
fn open_skills_panel(&mut self) {
let disabled = self
.disabled_skills
.lock()
.map(|d| d.clone())
.unwrap_or_default();
let mut entries: Vec<SkillEntry> = self
.skills
.iter()
.map(|s| {
let triggers = if s.trigger.always_on {
"always-on".to_string()
} else if s.trigger.keywords.is_empty() {
"(no keywords)".to_string()
} else {
s.trigger
.keywords
.iter()
.take(4)
.cloned()
.collect::<Vec<_>>()
.join(", ")
};
SkillEntry {
disabled: disabled.contains(&s.id),
id: s.id.clone(),
name: s.name.clone(),
triggers,
}
})
.collect();
entries.sort_by(|a, b| a.name.cmp(&b.name));
self.skills_panel.entries = entries;
self.skills_panel.selected = 0;
self.skills_panel.open = true;
}
fn handle_skills_panel_key(&mut self, key: KeyEvent) {
use KeyCode::*;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
Esc | Char('q') => self.skills_panel.close(),
Char('c') | Char('d') if ctrl => {
self.skills_panel.close();
self.quit = true;
}
Up | Char('k') => self.skills_panel.move_up(),
Down | Char('j') => self.skills_panel.move_down(),
Char(' ') | Enter => {
if let Some(entry) = self.skills_panel.entries.get(self.skills_panel.selected) {
let id = entry.id.clone();
let name = entry.name.clone();
let now_disabled = {
let mut set = match self.disabled_skills.lock() {
Ok(s) => s,
Err(_) => return,
};
if set.contains(&id) {
set.remove(&id);
false
} else {
set.insert(id);
true
}
};
self.skills_panel.entries[self.skills_panel.selected].disabled = now_disabled;
let state = if now_disabled { "disabled" } else { "enabled" };
self.push_dim(&format!(" · skill {name} {state}"));
}
}
_ => {}
}
}
fn handle_tools_panel_key(&mut self, key: KeyEvent) {
use KeyCode::*;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
Esc | Char('q') => self.tools_panel.close(),
Char('c') | Char('d') if ctrl => {
self.tools_panel.close();
self.quit = true;
}
Up | Char('k') => self.tools_panel.move_up(),
Down | Char('j') => self.tools_panel.move_down(),
Char(' ') | Enter => {
if let Some(entry) = self.tools_panel.entries.get(self.tools_panel.selected) {
let new_state = self.tool_registry.toggle(&entry.name);
let name = entry.name.clone();
let updated = ToolEntry {
name: entry.name.clone(),
class: entry.class,
disabled: new_state,
};
self.tools_panel.entries[self.tools_panel.selected] = updated;
let state_str = if new_state { "disabled" } else { "enabled" };
self.push_dim(&format!(" · tool {name} {state_str}"));
}
}
_ => {}
}
}
fn handle_mcp_panel_key(&mut self, key: KeyEvent) {
use KeyCode::*;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
Esc | Char('q') => self.mcp_panel.close(),
Char('c') | Char('d') if ctrl => {
self.mcp_panel.close();
self.quit = true;
}
Up | Char('k') => self.mcp_panel.move_up(),
Down | Char('j') => self.mcp_panel.move_down(),
Char(' ') | Enter => {
if let Some(entry) = self.mcp_panel.entries.get(self.mcp_panel.selected).cloned() {
let now_disabled = !entry.disabled;
self.toggle_server(&entry.server, now_disabled);
self.mcp_panel.entries[self.mcp_panel.selected].disabled = now_disabled;
let state_str = if now_disabled { "disabled" } else { "enabled" };
self.push_dim(&format!(
" · MCP server {} {state_str} ({} tool(s))",
entry.server, entry.tool_count
));
}
}
_ => {}
}
}
fn handle_approval_key(&mut self, key: KeyEvent) {
use KeyCode::*;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let always = matches!(key.code, Char('a') | Char('A'));
let approve = always || matches!(key.code, Char('y') | Char('Y') | Enter);
let deny = matches!(key.code, Char('n') | Char('N') | Esc);
let ctrl_quit = matches!(key.code, Char('c') | Char('d')) && ctrl;
if !approve && !deny && !ctrl_quit {
return;
}
if let Some(req) = self.pending_approval.take() {
let decision = approve;
let name = req.call.name.clone();
let _ = req.respond_to.send(decision);
if always {
self.remember_always_allow(&name);
self.push_dim(&format!(" ✓ always allowing {name} (saved)"));
} else if decision {
self.push_dim(&format!(" ✓ approved {name}"));
} else {
self.push_dim(&format!(" ✗ denied {name}"));
}
}
if ctrl_quit {
self.quit = true;
}
}
fn remember_always_allow(&mut self, name: &str) {
if let Ok(mut set) = always_allow_set().lock() {
set.insert(name.to_string());
}
if let Ok(mut cfg) = crate::config::Config::load_or_init() {
if !cfg.tool_approvals.iter().any(|t| t == name) {
cfg.tool_approvals.push(name.to_string());
if let (Ok(path), Ok(s)) = (
crate::config::Config::config_path(),
toml::to_string_pretty(&cfg),
) {
let _ = std::fs::write(path, s);
}
}
}
}
async fn open_kg_panel(&mut self) {
let entities = match self.palace.kg.list_entities(200).await {
Ok(v) => v,
Err(e) => {
self.push_line(error_line(format!("kg list_entities: {e}")));
return;
}
};
let relations = match self.palace.kg.list_relations(200).await {
Ok(v) => v,
Err(e) => {
self.push_line(error_line(format!("kg list_relations: {e}")));
return;
}
};
self.kg_panel.lines = self.build_kg_lines(&entities, &relations);
self.kg_panel.entities = entities;
self.kg_panel.relations = relations;
self.kg_panel.scroll = 0;
self.kg_panel.open = true;
}
fn build_kg_lines(&self, entities: &[Entity], relations: &[Relation]) -> Vec<Line<'static>> {
use std::collections::BTreeMap;
let mut lines: Vec<Line<'static>> = Vec::new();
let header_style = Style::default()
.fg(self.theme.assistant_prefix)
.add_modifier(Modifier::BOLD);
let dim_style = Style::default().fg(self.theme.dim);
let name_style = Style::default().fg(self.theme.header_fg);
let type_style = Style::default().fg(self.theme.user_prefix);
let arrow_style = Style::default()
.fg(self.theme.suggestion_border)
.add_modifier(Modifier::BOLD);
if entities.is_empty() && relations.is_empty() {
lines.push(Line::from(Span::styled(
" (the memory palace is empty — ask the agent to ingest some facts)",
dim_style,
)));
return lines;
}
let mut by_type: BTreeMap<String, Vec<&Entity>> = BTreeMap::new();
for e in entities {
by_type.entry(e.entity_type.clone()).or_default().push(e);
}
lines.push(Line::from(Span::styled("Entities", header_style)));
lines.push(Line::default());
for (ty, list) in &by_type {
lines.push(Line::from(vec![
Span::styled(format!(" [{ty}] "), type_style),
Span::styled(format!("({} entities)", list.len()), dim_style),
]));
for e in list {
lines.push(Line::from(vec![
Span::styled(" • ", dim_style),
Span::styled(e.name.clone(), name_style),
]));
}
lines.push(Line::default());
}
let mut name_of: std::collections::HashMap<EntityId, &str> =
std::collections::HashMap::with_capacity(entities.len());
for e in entities {
name_of.insert(e.id, e.name.as_str());
}
lines.push(Line::from(Span::styled("Relations", header_style)));
lines.push(Line::default());
if relations.is_empty() {
lines.push(Line::from(Span::styled(" (no edges yet)", dim_style)));
} else {
for r in relations {
let src = name_of
.get(&r.src_id)
.map(|s| (*s).to_string())
.unwrap_or_else(|| format!("{:?}", r.src_id));
let dst = name_of
.get(&r.dst_id)
.map(|s| (*s).to_string())
.unwrap_or_else(|| format!("{:?}", r.dst_id));
lines.push(Line::from(vec![
Span::styled(" • ", dim_style),
Span::styled(src, name_style),
Span::styled(" ──", arrow_style),
Span::styled(r.predicate.clone(), arrow_style),
Span::styled("──▶ ", arrow_style),
Span::styled(dst, name_style),
]));
}
}
lines
}
fn handle_kg_panel_key(&mut self, key: KeyEvent) {
use KeyCode::*;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
Esc | Char('q') => self.kg_panel.close(),
Char('c') | Char('d') if ctrl => {
self.kg_panel.close();
self.quit = true;
}
Up | Char('k') => {
self.kg_panel.scroll = self.kg_panel.scroll.saturating_sub(1);
}
Down | Char('j') => {
self.kg_panel.scroll = self.kg_panel.scroll.saturating_add(1);
}
PageUp => {
self.kg_panel.scroll = self.kg_panel.scroll.saturating_sub(8);
}
PageDown => {
self.kg_panel.scroll = self.kg_panel.scroll.saturating_add(8);
}
Home | Char('g') => {
self.kg_panel.scroll = 0;
}
End | Char('G') => {
self.kg_panel.scroll = u16::MAX;
}
_ => {}
}
}
async fn handle_mouse(&mut self, m: MouseEvent) {
match m.kind {
MouseEventKind::ScrollUp => {
self.auto_scroll = false;
self.scroll = self.scroll.saturating_sub(3);
}
MouseEventKind::ScrollDown => {
self.scroll = self.scroll.saturating_add(3);
self.clamp_scroll_and_maybe_resume_auto();
}
MouseEventKind::Down(MouseButton::Left) if self.palette.open => {
if let Some(rect) = self.palette_results_rect {
if rect_contains(rect, m.column, m.row) {
let row_in_pane = m.row.saturating_sub(rect.y) as usize;
let max_rows = rect.height as usize;
let scroll = self
.palette
.selected
.saturating_sub(max_rows.saturating_sub(1));
let target = scroll + row_in_pane;
if target < self.palette.filtered.len() {
self.palette.selected = target;
let action = self.palette.current().map(|e| e.action.clone());
self.palette.close();
if let Some(action) = action {
self.dispatch_palette_action(action).await;
}
}
} else {
self.palette.close();
}
}
}
_ => {}
}
}
fn handle_vim_normal_key(&mut self, key: KeyEvent) {
use KeyCode::*;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
Char('c') | Char('d') if ctrl => self.quit = true,
Char('q') => self.quit = true,
Char('i') | Char('a') | Char('o') => self.vim_mode = VimMode::Insert,
Char('j') | Down => {
self.scroll = self.scroll.saturating_add(1);
self.clamp_scroll_and_maybe_resume_auto();
}
Char('k') | Up => {
self.auto_scroll = false;
self.scroll = self.scroll.saturating_sub(1);
}
Char('g') | Home => {
self.auto_scroll = false;
self.scroll = 0;
}
Char('G') | End => {
self.auto_scroll = true;
}
PageUp => {
self.auto_scroll = false;
self.scroll = self.scroll.saturating_sub(8);
}
PageDown => {
self.scroll = self.scroll.saturating_add(8);
self.clamp_scroll_and_maybe_resume_auto();
}
_ => {}
}
}
async fn dispatch_palette_action(&mut self, action: PaletteAction) {
match action {
PaletteAction::Slash(cmd) => self.handle_slash(cmd).await,
PaletteAction::SwitchTheme(name) => {
self.handle_slash(SlashCommand::Themes(Some(name))).await;
}
}
}
async fn export_markdown(&self, path: &std::path::Path) -> std::io::Result<()> {
tokio::fs::write(path, self.build_transcript_markdown()).await
}
fn build_transcript_markdown(&self) -> String {
let mut out = String::new();
out.push_str(&format!(
"# Aonyx Agent session — {project}\n\n",
project = self.project_slug
));
out.push_str(&format!(
"_provider: {} · model: {} · turns: {}_\n\n---\n\n",
self.provider_name, self.model_name, self.turns,
));
for m in &self.messages {
let role = match m.role {
Role::System => "system",
Role::User => "user",
Role::Assistant => "assistant",
Role::Tool => "tool",
};
out.push_str(&format!("### {role}\n\n{}\n\n", m.content));
}
out
}
async fn export_html(&self, path: &std::path::Path) -> std::io::Result<()> {
tokio::fs::write(path, self.build_transcript_html()).await
}
fn build_transcript_html(&self) -> String {
let mut body = String::new();
for m in &self.messages {
let (role, css) = match m.role {
Role::System => ("system", "system"),
Role::User => ("you", "user"),
Role::Assistant => ("aonyx", "assistant"),
Role::Tool => ("tool", "tool"),
};
let rendered = render_markdown_to_html(&m.content);
let attach = if m.attachments.is_empty() {
String::new()
} else {
format!(
"<div class=\"attachments\">📎 {} attachment(s)</div>",
m.attachments.len()
)
};
body.push_str(&format!(
"<section class=\"msg {css}\"><div class=\"role\">{role}</div><div class=\"content\">{rendered}{attach}</div></section>\n"
));
}
let title = html_escape(&format!("Aonyx session — {}", self.project_slug));
let meta = html_escape(&format!(
"provider: {} · model: {} · turns: {}",
self.provider_name, self.model_name, self.turns
));
let doc = format!(
"<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
<title>{title}</title>\n<style>{HTML_EXPORT_CSS}</style></head>\n<body>\
<header><h1>🦦 {title}</h1><p class=\"meta\">{meta}</p></header>\n<main>\n{body}</main>\
<footer>Exported by Aonyx Agent</footer></body></html>\n"
);
doc
}
async fn export_bundle(&self, path: &std::path::Path) -> std::io::Result<()> {
let md = self.build_transcript_markdown();
let html = self.build_transcript_html();
let messages =
serde_json::to_string_pretty(&self.messages).unwrap_or_else(|_| "[]".to_string());
let meta = serde_json::json!({
"generator": concat!("aonyx-agent ", env!("CARGO_PKG_VERSION")),
"project": self.project_slug,
"session_id": self.session_id.to_string(),
"provider": self.provider_name,
"model": self.model_name,
"turns": self.turns,
"messages": self.messages.len(),
"files": ["session.md", "session.html", "messages.json", "meta.json"],
})
.to_string();
let bytes = match tokio::task::spawn_blocking(move || {
build_zip_bytes(&[
("session.md", md.as_str()),
("session.html", html.as_str()),
("messages.json", messages.as_str()),
("meta.json", meta.as_str()),
])
})
.await
{
Ok(inner) => inner?,
Err(join) => return Err(std::io::Error::other(join)),
};
tokio::fs::write(path, bytes).await
}
async fn import_bundle(&mut self, path: &str) -> std::io::Result<usize> {
let raw = tokio::fs::read(path).await?;
let json = tokio::task::spawn_blocking(move || unzip_member(&raw, "messages.json"))
.await
.map_err(std::io::Error::other)??;
let messages: Vec<Message> = serde_json::from_slice(&json).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("messages.json: {e}"),
)
})?;
let count = messages.len();
let _ = self
.session_store
.update(self.session_id, self.messages.clone(), self.turns)
.await;
let created = self
.session_store
.create(&self.project_slug, messages.clone())
.await
.map_err(std::io::Error::other)?;
self.session_id = created.id;
self.messages = messages;
self.turns = 0;
let short: String = created.id.to_string().chars().take(8).collect();
self.viewport.clear();
self.viewport.push(Line::from(Span::styled(
format!("📥 imported bundle [{short}] · {count} messages"),
Style::default().fg(self.theme.dim),
)));
self.auto_scroll = true;
self.scroll = 0;
self.refresh_recent_sessions().await;
Ok(count)
}
fn push_dim(&mut self, text: &str) {
self.push_line(Line::from(Span::styled(
text.to_string(),
Style::default().fg(Color::DarkGray),
)));
}
fn render_image_ref(&mut self, path: &str) {
let header = Style::default()
.fg(self.theme.assistant_prefix)
.add_modifier(Modifier::BOLD);
let frame_style = Style::default().fg(self.theme.dim);
match images::render(std::path::Path::new(path)) {
Ok(img) => {
self.push_line(Line::from(vec![
Span::styled(" ┌─ ", frame_style),
Span::styled(format!("📷 {path}"), header),
Span::styled(
format!(" · {}×{}", img.width, img.height),
Style::default().fg(self.theme.dim),
),
]));
for line in img.lines {
let mut spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len() + 1);
spans.push(Span::styled(" │ ", frame_style));
spans.extend(line.spans);
self.push_line(Line::from(spans));
}
self.push_line(Line::from(Span::styled(" └─", frame_style)));
}
Err(e) => {
self.push_line(error_line(format!("📷 {path}: {e}")));
}
}
}
fn show_cost_breakdown(&mut self) {
let inp = self.total_input_tokens;
let out = self.total_output_tokens;
let total = inp.saturating_add(out);
self.push_dim(&format!(
"cost · {} · {} · {} turns",
self.provider_name, self.model_name, self.turns
));
self.push_dim(&format!(
" input ~{:>8} output ~{:>8} total ~{}",
pricing::format_tokens(inp),
pricing::format_tokens(out),
pricing::format_tokens(total),
));
match self.pricing {
Some(p) => {
let cost = pricing::estimate_cost(p, inp, out);
self.push_dim(&format!(
" rates in ${:.2}/Mtok · out ${:.2}/Mtok",
p.input_per_million, p.output_per_million
));
self.push_dim(&format!(
" est. cost ~{} (token counts are heuristic, ~4 chars/tok)",
pricing::format_cost(cost)
));
}
None => self.push_dim(
" no pricing table for this provider — tokens shown, cost not estimated",
),
}
}
fn cost_marker_string(&self) -> String {
let total = self.total_input_tokens + self.total_output_tokens;
if total == 0 {
return String::new();
}
let tokens = pricing::format_tokens(total);
match self.pricing {
Some(p) => {
let cost =
pricing::estimate_cost(p, self.total_input_tokens, self.total_output_tokens);
format!(" · ~{tokens} tok · ~{}", pricing::format_cost(cost))
}
None => format!(" · ~{tokens} tok"),
}
}
fn push_diff_preview(&mut self, name: &str, args: &serde_json::Value) {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("?");
let header_style = Style::default()
.fg(self.theme.assistant_prefix)
.add_modifier(Modifier::BOLD);
let frame_style = Style::default().fg(self.theme.dim);
self.push_line(Line::from(vec![
Span::styled(" ┌─ ", frame_style),
Span::styled(format!("{name} · {path}"), header_style),
]));
match name {
"fs_edit" => {
let old = args
.get("old_string")
.and_then(|v| v.as_str())
.unwrap_or("");
let new = args
.get("new_string")
.and_then(|v| v.as_str())
.unwrap_or("");
self.push_unified_diff(old, new);
}
"fs_write" => {
let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
self.push_diff_lines("+ ", content, Color::Green);
}
_ => {}
}
self.push_line(Line::from(Span::styled(" └─", frame_style)));
}
fn push_diff_lines(&mut self, prefix: &'static str, text: &str, color: Color) {
let frame_style = Style::default().fg(self.theme.dim);
let lines: Vec<&str> = text.lines().collect();
let take = lines.len().min(DIFF_MAX_LINES);
for line in lines.iter().take(take) {
self.push_line(Line::from(vec![
Span::styled(" │ ", frame_style),
Span::styled(
prefix,
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(line.to_string(), Style::default().fg(color)),
]));
}
if lines.len() > DIFF_MAX_LINES {
let omitted = lines.len() - DIFF_MAX_LINES;
self.push_line(Line::from(vec![
Span::styled(" │ ", frame_style),
Span::styled(
format!(
"… (+{omitted} more line{})",
if omitted == 1 { "" } else { "s" }
),
Style::default()
.fg(self.theme.dim)
.add_modifier(Modifier::ITALIC),
),
]));
}
}
fn push_unified_diff(&mut self, old: &str, new: &str) {
use similar::{ChangeTag, TextDiff};
let frame_style = Style::default().fg(self.theme.dim);
let diff = TextDiff::from_lines(old, new);
let groups = diff.grouped_ops(UNIFIED_DIFF_CONTEXT);
if groups.is_empty() {
self.push_line(Line::from(vec![
Span::styled(" │ ", frame_style),
Span::styled(
"(no change)",
Style::default()
.fg(self.theme.dim)
.add_modifier(Modifier::ITALIC),
),
]));
return;
}
let mut emitted = 0usize;
let mut truncated = 0usize;
for (i, group) in groups.iter().enumerate() {
if i > 0 && emitted < UNIFIED_DIFF_MAX_LINES {
self.push_line(Line::from(vec![
Span::styled(" │ ", frame_style),
Span::styled(
" …",
Style::default()
.fg(self.theme.dim)
.add_modifier(Modifier::ITALIC),
),
]));
emitted += 1;
}
for op in group {
for change in diff.iter_changes(op) {
if emitted >= UNIFIED_DIFF_MAX_LINES {
truncated += 1;
continue;
}
let (prefix, color, bold) = match change.tag() {
ChangeTag::Delete => ("- ", Color::Red, true),
ChangeTag::Insert => ("+ ", Color::Green, true),
ChangeTag::Equal => (" ", self.theme.dim, false),
};
let text = change.to_string();
let text = text.trim_end_matches(['\n', '\r']);
let mut style = Style::default().fg(color);
if bold {
style = style.add_modifier(Modifier::BOLD);
}
self.push_line(Line::from(vec![
Span::styled(" │ ", frame_style),
Span::styled(prefix, style),
Span::styled(text.to_string(), Style::default().fg(color)),
]));
emitted += 1;
}
}
}
if truncated > 0 {
self.push_line(Line::from(vec![
Span::styled(" │ ", frame_style),
Span::styled(
format!(
"… (+{truncated} more change{})",
if truncated == 1 { "" } else { "s" }
),
Style::default()
.fg(self.theme.dim)
.add_modifier(Modifier::ITALIC),
),
]));
}
}
fn composer_height(&self) -> u16 {
let lines = self.composer.lines().len() as u16;
lines
.saturating_add(2)
.clamp(MIN_COMPOSER_HEIGHT, MAX_COMPOSER_HEIGHT)
}
fn render(&mut self, f: &mut Frame<'_>) {
let composer_h = self.composer_height();
let suggestions_h = if self.suggestions.is_empty() {
0
} else {
(self.suggestions.len() as u16 + 2).clamp(3, 10)
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(suggestions_h),
Constraint::Length(composer_h),
Constraint::Length(1),
])
.split(f.area());
self.viewport_height = chunks[1].height;
self.viewport_rect = Some(chunks[1]);
if self.auto_scroll {
self.scroll = self.max_scroll();
}
if self.runner_active {
let pulse = PULSE_FRAMES[(self.tick / 3) as usize % PULSE_FRAMES.len()];
if let Some(last) = self.viewport.last_mut() {
if let Some(first) = last.spans.first_mut() {
let stripped = first.content.trim_start();
if stripped.starts_with('●')
|| stripped.starts_with('◉')
|| stripped.starts_with('○')
{
first.content = format!("{pulse} ").into();
}
}
}
}
let header_color = if self.runner_active {
self.theme.accents[(self.tick / 6) as usize % self.theme.accents.len()]
} else {
self.theme.header_fg
};
let header = Paragraph::new(Line::from(vec![
Span::styled(
"🦦 Aonyx Agent",
Style::default()
.fg(header_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" · project:{}", self.project_slug),
Style::default().fg(self.theme.dim),
),
]));
f.render_widget(header, chunks[0]);
let viewport = Paragraph::new(Text::from(self.viewport.clone()))
.wrap(Wrap { trim: false })
.scroll((self.scroll, 0));
f.render_widget(viewport, chunks[1]);
if suggestions_h > 0 {
let kind_label = match &self.suggestion_kind {
Some(Trigger::At) => "files",
Some(Trigger::Slash) => "commands",
Some(Trigger::SlashArg(cmd)) => match cmd.as_str() {
"themes" | "theme" | "t" => "themes",
"load" | "switch" => "sessions",
"ingest" => "files",
"undo" | "u" => "undo",
"model" => "models",
"provider" => "providers",
_ => "args",
},
None => "",
};
let lines: Vec<Line> = self
.suggestions
.iter()
.enumerate()
.map(|(i, s)| {
let selected = i == self.suggestion_idx;
let marker = if selected { "▸ " } else { " " };
let style = if selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
Line::from(vec![
Span::styled(marker, style),
Span::styled(s.clone(), style),
])
})
.collect();
let title = format!(" {} · Tab accept · ↑/↓ navigate · Esc cancel ", kind_label);
let popup = Paragraph::new(Text::from(lines)).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.suggestion_border))
.title(title),
);
f.render_widget(popup, chunks[2]);
}
if self.runner_active {
let spinner_idx = self.tick as usize % SPINNER_FRAMES.len();
let spinner = SPINNER_FRAMES[spinner_idx];
let pulse_color =
self.theme.accents[(self.tick / 3) as usize % self.theme.accents.len()];
let blocker = Paragraph::new(Line::from(vec![
Span::styled(
format!(" {spinner} "),
Style::default()
.fg(pulse_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"runner busy — Esc to quit",
Style::default().fg(self.theme.dim),
),
]))
.block(
Block::default()
.borders(Borders::TOP | Borders::BOTTOM)
.border_style(Style::default().fg(pulse_color)),
);
f.render_widget(blocker, chunks[3]);
} else {
f.render_widget(&self.composer, chunks[3]);
}
let details = if self.show_tool_details {
" · details:on"
} else {
""
};
let scroll_marker = if self.auto_scroll {
""
} else {
" · scroll:manual"
};
let vim_marker = match self.vim_mode.label() {
Some(tag) => format!(" · vim:{tag}"),
None => String::new(),
};
let mouse_marker = if self.mouse_captured {
""
} else {
" · mouse:off"
};
let cost_marker = self.cost_marker_string();
let status_line = if self.runner_active {
let spinner_idx = self.tick as usize % SPINNER_FRAMES.len();
let spinner = SPINNER_FRAMES[spinner_idx];
let spin_color =
self.theme.accents[(self.tick / 3) as usize % self.theme.accents.len()];
Line::from(vec![
Span::styled(
format!(" {spinner} "),
Style::default().fg(spin_color).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(
"{} · {} · turn {} · running{}{}{}{}{} ",
self.provider_name,
self.model_name,
self.turns,
details,
scroll_marker,
vim_marker,
mouse_marker,
cost_marker
),
Style::default().fg(self.theme.header_fg),
),
])
} else {
Line::from(vec![
Span::styled(" ▸ ", Style::default().fg(self.theme.user_prefix)),
Span::styled(
format!(
"{} · {} · turn {} · idle{}{}{}{}{} ",
self.provider_name,
self.model_name,
self.turns,
details,
scroll_marker,
vim_marker,
mouse_marker,
cost_marker
),
Style::default().fg(self.theme.status_fg),
),
])
};
let bg = if self.runner_active {
self.theme.status_busy_bg
} else {
self.theme.status_bg
};
let status = Paragraph::new(status_line).style(Style::default().bg(bg));
f.render_widget(status, chunks[4]);
if self.palette.open {
self.render_palette(f);
}
if self.kg_panel.open {
self.render_kg_panel(f);
}
if self.tools_panel.open {
self.render_tools_panel(f);
}
if self.mcp_panel.open {
self.render_mcp_panel(f);
}
if self.skills_panel.open {
self.render_skills_panel(f);
}
if self.inspect_panel.open {
self.render_inspect_panel(f);
}
if self.theme_editor.open {
self.render_theme_editor(f);
}
if self.tree_panel.open {
self.render_tree_panel(f);
}
if self.pending_approval.is_some() {
self.render_approval_overlay(f);
}
}
fn render_tree_panel(&mut self, f: &mut Frame<'_>) {
let area = f.area();
let width = (area.width as u32 * 75 / 100).clamp(44, 110) as u16;
let height = (area.height as u32 * 70 / 100).clamp(10, 30) as u16;
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let popup = Rect::new(x, y, width, height);
f.render_widget(ratatui::widgets::Clear, popup);
let total = self.tree_panel.lines.len() as u16;
let visible = popup.height.saturating_sub(2);
let max_scroll = total.saturating_sub(visible);
if self.tree_panel.scroll > max_scroll {
self.tree_panel.scroll = max_scroll;
}
let footer = Line::from(Span::styled(
" ↑/↓ scroll · g/G ends · Esc / q close ",
Style::default().fg(self.theme.dim),
));
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.suggestion_border))
.title(format!(
" /tree · {} · {} session(s) ",
self.project_slug,
self.tree_panel.lines.len()
))
.title_alignment(Alignment::Left)
.title_bottom(footer);
let para = Paragraph::new(Text::from(self.tree_panel.lines.clone()))
.wrap(Wrap { trim: false })
.scroll((self.tree_panel.scroll, 0))
.block(block);
f.render_widget(para, popup);
}
fn render_theme_editor(&self, f: &mut Frame<'_>) {
let area = f.area();
let width = (area.width as u32 * 60 / 100).clamp(44, 78) as u16;
let height = (theme::EDITABLE_FIELDS.len() as u16) + 4;
let height = height.min(area.height);
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let popup = Rect::new(x, y, width, height);
f.render_widget(ratatui::widgets::Clear, popup);
let dim = Style::default().fg(self.theme.dim);
let header = Style::default()
.fg(self.theme.assistant_prefix)
.add_modifier(Modifier::BOLD);
let mut lines: Vec<Line> = Vec::new();
for (i, label) in theme::EDITABLE_FIELDS.iter().enumerate() {
let (r, g, b) = theme::color_to_rgb(self.theme.field_color(i));
let selected = i == self.theme_editor.field;
let marker = if selected { "▸ " } else { " " };
let chan_span = |idx: usize, val: u8, tag: &str| {
let mut st = Style::default().fg(self.theme.header_fg);
if selected && self.theme_editor.channel == idx {
st = st.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
}
Span::styled(format!("{tag}{val:>3} "), st)
};
let row = Line::from(vec![
Span::styled(marker, header),
Span::styled(
format!("{label:<18}"),
Style::default().fg(self.theme.header_fg),
),
Span::styled("███ ", Style::default().fg(Color::Rgb(r, g, b))),
chan_span(0, r, "R"),
chan_span(1, g, "G"),
chan_span(2, b, "B"),
]);
lines.push(row);
}
let footer = Line::from(Span::styled(
" ↑/↓ field · ←/→ R·G·B · +/- adjust · s save · Esc close ",
dim,
));
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.suggestion_border))
.title(" /theme-edit · live preview ")
.title_alignment(Alignment::Left)
.title_bottom(footer);
let para = Paragraph::new(Text::from(lines)).block(block);
f.render_widget(para, popup);
}
fn render_inspect_panel(&mut self, f: &mut Frame<'_>) {
let area = f.area();
let width = (area.width as u32 * 80 / 100).clamp(40, 120) as u16;
let height = (area.height as u32 * 75 / 100).clamp(10, 36) as u16;
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let popup = Rect::new(x, y, width, height);
f.render_widget(ratatui::widgets::Clear, popup);
let total = self.inspect_panel.lines.len() as u16;
let visible = popup.height.saturating_sub(2);
let max_scroll = total.saturating_sub(visible);
if self.inspect_panel.scroll > max_scroll {
self.inspect_panel.scroll = max_scroll;
}
let footer = Line::from(Span::styled(
" ↑/↓ scroll · g/G top/bottom · Esc / q close ",
Style::default().fg(self.theme.dim),
));
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.suggestion_border))
.title(format!(" /inspect · last LLM request · {total} lines "))
.title_alignment(Alignment::Left)
.title_bottom(footer);
let para = Paragraph::new(Text::from(self.inspect_panel.lines.clone()))
.wrap(Wrap { trim: false })
.scroll((self.inspect_panel.scroll, 0))
.block(block);
f.render_widget(para, popup);
}
fn render_palette(&mut self, f: &mut Frame<'_>) {
let area = f.area();
let width = (area.width as u32 * 60 / 100).clamp(40, 90) as u16;
let height = (area.height as u32 * 50 / 100).clamp(8, 20) as u16;
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let popup = ratatui::layout::Rect::new(x, y, width, height);
f.render_widget(ratatui::widgets::Clear, popup);
let inner = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(popup);
let query_text = if self.palette.query.is_empty() {
"type to filter…".to_string()
} else {
self.palette.query.clone()
};
let query_style = if self.palette.query.is_empty() {
Style::default().fg(self.theme.dim)
} else {
Style::default()
.fg(self.theme.header_fg)
.add_modifier(Modifier::BOLD)
};
let count_label = format!(
" {} / {} ",
self.palette.filtered.len(),
self.palette.entries.len()
);
let query = Paragraph::new(Line::from(vec![
Span::styled(" › ", Style::default().fg(self.theme.user_prefix)),
Span::styled(query_text, query_style),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.suggestion_border))
.title(" Ctrl+P · Command palette ")
.title_alignment(Alignment::Left)
.title_bottom(Line::from(Span::styled(
count_label,
Style::default().fg(self.theme.dim),
)))
.title_alignment(Alignment::Left),
);
f.render_widget(query, inner[0]);
let max_rows = inner[1].height.saturating_sub(2) as usize;
let total = self.palette.filtered.len();
let scroll = self
.palette
.selected
.saturating_sub(max_rows.saturating_sub(1));
let visible_end = (scroll + max_rows).min(total);
let visible = &self.palette.filtered[scroll..visible_end];
let lines: Vec<Line> = if visible.is_empty() {
vec![Line::from(Span::styled(
" (no match)",
Style::default().fg(self.theme.dim),
))]
} else {
visible
.iter()
.enumerate()
.map(|(i, idx)| {
let entry = &self.palette.entries[*idx];
let selected = scroll + i == self.palette.selected;
let marker = if selected { "▸ " } else { " " };
let label_style = if selected {
Style::default()
.fg(self.theme.assistant_prefix)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.theme.header_fg)
};
let hint_style = Style::default().fg(self.theme.dim);
Line::from(vec![
Span::styled(marker, label_style),
Span::styled(entry.label.clone(), label_style),
Span::raw(" "),
Span::styled(entry.hint.clone(), hint_style),
])
})
.collect()
};
let footer = Line::from(Span::styled(
" ↑/↓ navigate · Enter accept · Esc close ",
Style::default().fg(self.theme.dim),
));
let results = Paragraph::new(Text::from(lines)).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.suggestion_border))
.title_bottom(footer),
);
f.render_widget(results, inner[1]);
self.palette_results_rect = Some(rect_shrink(inner[1], 1));
}
fn render_tools_panel(&self, f: &mut Frame<'_>) {
let area = f.area();
let width = (area.width as u32 * 60 / 100).clamp(40, 80) as u16;
let height = (area.height as u32 * 60 / 100).clamp(8, 22) as u16;
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let popup = Rect::new(x, y, width, height);
f.render_widget(ratatui::widgets::Clear, popup);
let header_style = Style::default()
.fg(self.theme.assistant_prefix)
.add_modifier(Modifier::BOLD);
let dim_style = Style::default().fg(self.theme.dim);
let on_style = Style::default()
.fg(self.theme.user_prefix)
.add_modifier(Modifier::BOLD);
let off_style = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
let name_style = Style::default().fg(self.theme.header_fg);
let total = self.tools_panel.entries.len();
let disabled = self
.tools_panel
.entries
.iter()
.filter(|e| e.disabled)
.count();
let title = format!(
" /tools · {total} registered · {} enabled · {} disabled ",
total - disabled,
disabled
);
let footer = Line::from(Span::styled(
" ↑/↓ navigate · Space/Enter toggle · Esc / q close ",
dim_style,
));
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.suggestion_border))
.title(title)
.title_alignment(Alignment::Left)
.title_bottom(footer);
let max_rows = popup.height.saturating_sub(2) as usize;
let scroll = self
.tools_panel
.selected
.saturating_sub(max_rows.saturating_sub(1));
let visible_end = (scroll + max_rows).min(total);
let visible = if total == 0 {
&[][..]
} else {
&self.tools_panel.entries[scroll..visible_end]
};
let lines: Vec<Line> = if visible.is_empty() {
vec![Line::from(Span::styled(
" (no tools registered)",
dim_style,
))]
} else {
visible
.iter()
.enumerate()
.map(|(i, entry)| {
let selected = scroll + i == self.tools_panel.selected;
let marker = if selected { "▸ " } else { " " };
let dot_color = match entry.class {
SafetyClass::Safe => self.theme.user_prefix,
SafetyClass::Caution => Color::Yellow,
SafetyClass::Destructive => Color::Red,
};
let state_span = if entry.disabled {
Span::styled(" [off] ", off_style)
} else {
Span::styled(" [on] ", on_style)
};
Line::from(vec![
Span::styled(marker, header_style),
Span::styled("● ", Style::default().fg(dot_color)),
Span::styled(format!("{:<12}", entry.name), name_style),
state_span,
Span::styled(format!(" {:?}", entry.class), dim_style),
])
})
.collect()
};
let para = Paragraph::new(Text::from(lines)).block(block);
f.render_widget(para, popup);
}
fn render_mcp_panel(&self, f: &mut Frame<'_>) {
let area = f.area();
let width = (area.width as u32 * 60 / 100).clamp(40, 80) as u16;
let height = (area.height as u32 * 60 / 100).clamp(8, 22) as u16;
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let popup = Rect::new(x, y, width, height);
f.render_widget(ratatui::widgets::Clear, popup);
let header_style = Style::default()
.fg(self.theme.assistant_prefix)
.add_modifier(Modifier::BOLD);
let dim_style = Style::default().fg(self.theme.dim);
let on_style = Style::default()
.fg(self.theme.user_prefix)
.add_modifier(Modifier::BOLD);
let off_style = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
let name_style = Style::default().fg(self.theme.header_fg);
let total = self.mcp_panel.entries.len();
let tools: usize = self.mcp_panel.entries.iter().map(|e| e.tool_count).sum();
let title = format!(" /mcp · {total} server(s) · {tools} tool(s) ");
let footer = Line::from(Span::styled(
" ↑/↓ navigate · Space/Enter toggle server · Esc / q close ",
dim_style,
));
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.suggestion_border))
.title(title)
.title_alignment(Alignment::Left)
.title_bottom(footer);
let max_rows = popup.height.saturating_sub(2) as usize;
let scroll = self
.mcp_panel
.selected
.saturating_sub(max_rows.saturating_sub(1));
let visible_end = (scroll + max_rows).min(total);
let visible = if total == 0 {
&[][..]
} else {
&self.mcp_panel.entries[scroll..visible_end]
};
let lines: Vec<Line> = if visible.is_empty() {
vec![Line::from(Span::styled(
" (no MCP servers connected — add some under [[mcp_servers]] in config)",
dim_style,
))]
} else {
visible
.iter()
.enumerate()
.map(|(i, entry)| {
let selected = scroll + i == self.mcp_panel.selected;
let marker = if selected { "▸ " } else { " " };
let state_span = if entry.disabled {
Span::styled(" [off] ", off_style)
} else {
Span::styled(" [on] ", on_style)
};
Line::from(vec![
Span::styled(marker, header_style),
Span::styled(format!("{:<20}", entry.server), name_style),
state_span,
Span::styled(format!(" {} tool(s)", entry.tool_count), dim_style),
])
})
.collect()
};
let para = Paragraph::new(Text::from(lines)).block(block);
f.render_widget(para, popup);
}
fn render_skills_panel(&self, f: &mut Frame<'_>) {
let area = f.area();
let width = (area.width as u32 * 70 / 100).clamp(40, 96) as u16;
let height = (area.height as u32 * 60 / 100).clamp(8, 22) as u16;
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let popup = Rect::new(x, y, width, height);
f.render_widget(ratatui::widgets::Clear, popup);
let header_style = Style::default()
.fg(self.theme.assistant_prefix)
.add_modifier(Modifier::BOLD);
let dim_style = Style::default().fg(self.theme.dim);
let on_style = Style::default()
.fg(self.theme.user_prefix)
.add_modifier(Modifier::BOLD);
let off_style = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
let name_style = Style::default().fg(self.theme.header_fg);
let total = self.skills_panel.entries.len();
let disabled = self
.skills_panel
.entries
.iter()
.filter(|e| e.disabled)
.count();
let title = format!(
" /skills · {total} loaded · {} active · {} off ",
total - disabled,
disabled
);
let footer = Line::from(Span::styled(
" ↑/↓ navigate · Space/Enter toggle · Esc / q close ",
dim_style,
));
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.suggestion_border))
.title(title)
.title_alignment(Alignment::Left)
.title_bottom(footer);
let max_rows = popup.height.saturating_sub(2) as usize;
let scroll = self
.skills_panel
.selected
.saturating_sub(max_rows.saturating_sub(1));
let visible_end = (scroll + max_rows).min(total);
let visible = if total == 0 {
&[][..]
} else {
&self.skills_panel.entries[scroll..visible_end]
};
let lines: Vec<Line> = if visible.is_empty() {
vec![Line::from(Span::styled(
" (no skills loaded — drop SKILL.md files in ~/.aonyx/skills/)",
dim_style,
))]
} else {
visible
.iter()
.enumerate()
.map(|(i, entry)| {
let selected = scroll + i == self.skills_panel.selected;
let marker = if selected { "▸ " } else { " " };
let state_span = if entry.disabled {
Span::styled(" [off] ", off_style)
} else {
Span::styled(" [on] ", on_style)
};
Line::from(vec![
Span::styled(marker, header_style),
Span::styled(format!("{:<18}", entry.name), name_style),
state_span,
Span::styled(format!(" {}", entry.triggers), dim_style),
])
})
.collect()
};
let para = Paragraph::new(Text::from(lines)).block(block);
f.render_widget(para, popup);
}
fn render_approval_overlay(&self, f: &mut Frame<'_>) {
let Some(req) = self.pending_approval.as_ref() else {
return;
};
let area = f.area();
let width = (area.width as u32 * 65 / 100).clamp(40, 90) as u16;
let height: u16 = 7;
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let popup = Rect::new(x, y, width, height);
f.render_widget(ratatui::widgets::Clear, popup);
let border_color = match req.class {
SafetyClass::Safe => self.theme.user_prefix,
SafetyClass::Caution => Color::Yellow,
SafetyClass::Destructive => Color::Red,
};
let header_style = Style::default()
.fg(border_color)
.add_modifier(Modifier::BOLD);
let dim_style = Style::default().fg(self.theme.dim);
let args_preview = abbreviate_value(&req.call.args, (width.saturating_sub(8)) as usize);
let lines = vec![
Line::from(vec![
Span::styled("⚠ approve ", header_style.add_modifier(Modifier::BOLD)),
Span::styled(req.call.name.clone(), header_style),
Span::styled(format!(" ({:?})", req.class), dim_style),
]),
Line::from(Span::styled(args_preview, dim_style)),
Line::default(),
Line::from(vec![
Span::styled(" [Y] approve ", header_style),
Span::styled("[A] always ", header_style),
Span::styled("[n] deny ", dim_style),
Span::styled("[Esc] also denies", dim_style),
]),
];
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(" Approval required ")
.title_alignment(Alignment::Left);
let para = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(block);
f.render_widget(para, popup);
}
fn render_kg_panel(&mut self, f: &mut Frame<'_>) {
let area = f.area();
let width = (area.width as u32 * 75 / 100).clamp(40, 110) as u16;
let height = (area.height as u32 * 70 / 100).clamp(10, 30) as u16;
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let popup = Rect::new(x, y, width, height);
f.render_widget(ratatui::widgets::Clear, popup);
let total = self.kg_panel.lines.len() as u16;
let visible = popup.height.saturating_sub(2); let max_scroll = total.saturating_sub(visible);
if self.kg_panel.scroll > max_scroll {
self.kg_panel.scroll = max_scroll;
}
let title = format!(
" /kg · Memory palace · {} entit(y/ies) · {} relation(s) ",
self.kg_panel.entities.len(),
self.kg_panel.relations.len()
);
let footer = Line::from(Span::styled(
" ↑/↓ scroll · g/G top/bottom · Esc / q close ",
Style::default().fg(self.theme.dim),
));
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.suggestion_border))
.title(title)
.title_alignment(Alignment::Left)
.title_bottom(footer);
let para = Paragraph::new(Text::from(self.kg_panel.lines.clone()))
.wrap(Wrap { trim: false })
.scroll((self.kg_panel.scroll, 0))
.block(block);
f.render_widget(para, popup);
}
}
fn rect_shrink(r: Rect, n: u16) -> Rect {
let x = r.x.saturating_add(n);
let y = r.y.saturating_add(n);
let width = r.width.saturating_sub(n.saturating_mul(2));
let height = r.height.saturating_sub(n.saturating_mul(2));
Rect::new(x, y, width, height)
}
fn rect_contains(r: Rect, x: u16, y: u16) -> bool {
x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
}
fn detect_composer_mode(textarea: &TextArea<'_>) -> ComposerMode {
let first = textarea
.lines()
.iter()
.find(|l| !l.trim().is_empty())
.cloned()
.unwrap_or_default();
let t = first.trim_start();
if t.starts_with('/') {
ComposerMode::Slash
} else if t.starts_with('!') {
ComposerMode::Bash
} else {
ComposerMode::Chat
}
}
const HELP_LINES: &[&str] = &[
"available commands:",
" /quit /q /exit exit",
" /clear /reset /new reset conversation (keeps system prompt)",
" /help /? this list",
" /models /m active provider + model",
" /sessions /s multi-session UI (Phase D)",
" /export [path] dump the conversation to Markdown",
" /export-html [path] dump the conversation to standalone HTML (Phase FF)",
" /export-bundle [p] .zip: Markdown + HTML + messages.json + meta.json (NN/OO)",
" /import-bundle <z> import a session from a .zip bundle's messages.json (PP)",
" /rename <title> rename the current session (RR)",
" /cost detailed token + cost breakdown (RR)",
" /theme-edit live-edit theme colours, save to config (Phase KK)",
" /details toggle verbose tool output",
" /thinking reasoning visibility (Phase E)",
" /themes /t [name] switch palette (default, catppuccin, dracula, gruvbox)",
" /vim toggle vim modal editing (F3)",
" /undo /u [N|list] revert last N fs changes or list the journal (Phase J + W)",
" /find /f <query> search past sessions across every project (Phase L)",
" /load /switch <id> switch to a session by id prefix (Phase L)",
" /tree show the session genealogy tree (Phase MM)",
" /kg /palace open the memory-palace visualization (Phase O)",
" /tools enable / disable registered tools live (Phase Q)",
" /mcp connected MCP servers · toggle a server's tools (RR)",
" /skills enable / disable loaded skills live (Phase X)",
" /inspect show the JSON of the last LLM request (Phase Y)",
" /fork fork the current session into a child branch (Phase Z)",
" /compact summarize old turns, keep the tail (Phase BB)",
" /retry /r re-run the last user message (Phase CC)",
" /model [name] switch the active model live (Phase EE)",
" /provider [id] switch the LLM provider live (Phase LL)",
" /mouse /select toggle mouse capture (off = native text selection, Phase U)",
" /ingest <path> add a local file to the project palace (Phase V)",
" /editor /e legacy-mode only for now",
" /init drop an agent.yaml in the project root",
"inline:",
" @path/to/file.rs load the file into the next turn's context",
" @src/**/*.rs glob: load every match (RR; capped at 50)",
" !ls / !git status run a shell command locally and feed output back",
"keys: Ctrl+P palette · Shift+Enter newline · ↑/↓ history · PgUp/PgDn scroll · Esc quit",
];
fn extract_refs(input: &str) -> (String, Vec<String>) {
let mut refs = Vec::new();
let mut out = String::new();
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '@' {
let mut path = String::new();
while let Some(&next) = chars.peek() {
if next.is_whitespace() {
break;
}
path.push(next);
chars.next();
}
if path.is_empty() {
out.push('@');
} else {
refs.push(path.clone());
out.push('`');
out.push('@');
out.push_str(&path);
out.push('`');
}
} else {
out.push(c);
}
}
(out, refs)
}
fn approval_matches(
rules: &std::collections::HashSet<String>,
name: &str,
args: &serde_json::Value,
) -> bool {
rules.iter().any(|rule| match rule.split_once(':') {
Some((tool, needle)) if !needle.is_empty() => {
tool == name && args.to_string().contains(needle)
}
_ => rule == name,
})
}
fn always_allow_set() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
static SET: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::OnceLock::new();
SET.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
}
pub fn seed_tool_approvals(names: &[String]) {
if let Ok(mut set) = always_allow_set().lock() {
for n in names {
set.insert(n.clone());
}
}
}
fn base64_image(path: &str) -> std::io::Result<(String, String)> {
use base64::Engine;
let bytes = std::fs::read(path)?;
let (media_type, payload) = match images::downscale_for_vision(&bytes, images::MAX_VISION_DIM) {
Some(png) => ("image/png".to_string(), png),
None => (media_type_from_ext(path).to_string(), bytes),
};
let data = base64::engine::general_purpose::STANDARD.encode(&payload);
Ok((media_type, data))
}
fn media_type_from_ext(path: &str) -> &'static str {
let lower = path.to_ascii_lowercase();
if lower.ends_with(".png") {
"image/png"
} else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
"image/jpeg"
} else if lower.ends_with(".gif") {
"image/gif"
} else if lower.ends_with(".webp") {
"image/webp"
} else if lower.ends_with(".bmp") {
"image/bmp"
} else {
"application/octet-stream"
}
}
fn is_http_url(s: &str) -> bool {
s.starts_with("http://") || s.starts_with("https://")
}
fn media_type_for(url: &str, content_type: &str) -> String {
let ct = content_type.split(';').next().unwrap_or("").trim();
if ct.starts_with("image/") {
ct.to_string()
} else {
media_type_from_ext(url).to_string()
}
}
async fn attach_image_from_url(url: &str) -> std::io::Result<(String, String)> {
use base64::Engine;
let (content_type, bytes) = aonyx_tools::web::fetch_bytes(url)
.await
.map_err(std::io::Error::other)?;
let (media_type, payload) = match images::downscale_for_vision(&bytes, images::MAX_VISION_DIM) {
Some(png) => ("image/png".to_string(), png),
None => (media_type_for(url, &content_type), bytes),
};
let data = base64::engine::general_purpose::STANDARD.encode(&payload);
Ok((media_type, data))
}
async fn resolve_refs(paths: &[String]) -> Vec<(String, Result<String, String>)> {
let expanded = expand_glob_refs(paths);
let mut out = Vec::with_capacity(expanded.len());
for path in &expanded {
let result = tokio::fs::read_to_string(path)
.await
.map_err(|e| e.to_string());
out.push((path.clone(), result));
}
out
}
fn expand_glob_refs(paths: &[String]) -> Vec<String> {
const GLOB_MAX_MATCHES: usize = 50;
let mut out = Vec::new();
for p in paths {
if !p.contains(['*', '?', '[']) {
out.push(p.clone());
continue;
}
match glob::glob(p) {
Ok(paths_iter) => {
let mut matched: Vec<String> = paths_iter
.filter_map(|r| r.ok())
.filter(|pb| pb.is_file())
.map(|pb| pb.to_string_lossy().replace('\\', "/"))
.collect();
matched.sort();
matched.truncate(GLOB_MAX_MATCHES);
if matched.is_empty() {
out.push(p.clone()); } else {
out.extend(matched);
}
}
Err(_) => out.push(p.clone()),
}
}
out
}
fn build_refs_message(refs: &[(String, Result<String, String>)]) -> Option<Message> {
let any_ok = refs.iter().any(|(_, r)| r.is_ok());
if !any_ok {
return None;
}
let mut content = String::new();
content.push_str(
"The user attached the following files (full text follows). Treat them as authoritative context.\n\n",
);
for (path, result) in refs {
match result {
Ok(text) => {
content.push_str(&format!("--- {path} ---\n{text}\n\n"));
}
Err(e) => {
content.push_str(&format!("--- {path} ---\n(could not read: {e})\n\n"));
}
}
}
Some(Message::new(Role::System, content))
}
async fn run_bash(cmd: &str) -> Result<String, String> {
use tokio::process::Command;
let mut command = if cfg!(windows) {
let mut c = Command::new("cmd");
c.args(["/C", cmd]);
c
} else {
let mut c = Command::new("sh");
c.args(["-c", cmd]);
c
};
let output = command.output().await.map_err(|e| format!("spawn: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let mut combined = String::new();
combined.push_str(&stdout);
if !stderr.is_empty() {
if !combined.is_empty() && !combined.ends_with('\n') {
combined.push('\n');
}
combined.push_str(&stderr);
}
if !output.status.success() {
let code = output.status.code().unwrap_or(-1);
combined.push_str(&format!("\n[exit {code}]"));
}
Ok(combined.trim_end_matches(&['\n', '\r'][..]).to_string())
}
fn cursor_byte_offset(textarea: &TextArea<'_>) -> usize {
let (row, col) = textarea.cursor();
let lines = textarea.lines();
let mut offset = 0usize;
for (i, line) in lines.iter().enumerate() {
if i == row {
offset += line.chars().take(col).map(|c| c.len_utf8()).sum::<usize>();
return offset;
}
offset += line.len() + 1; }
offset
}
fn split_into_chunks(text: &str, max_chars: usize) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
for para in text.split("\n\n") {
let para = para.trim();
if para.is_empty() {
continue;
}
if current.is_empty() {
current.push_str(para);
} else if current.chars().count() + 2 + para.chars().count() > max_chars {
out.push(std::mem::take(&mut current));
current.push_str(para);
} else {
current.push_str("\n\n");
current.push_str(para);
}
}
if !current.is_empty() {
out.push(current);
}
out
}
fn ingest_kind_from_path(path: &str) -> &'static str {
let lower = path.to_ascii_lowercase();
if lower.ends_with(".md")
|| lower.ends_with(".markdown")
|| lower.ends_with(".mdx")
|| lower.ends_with(".rst")
{
"doc"
} else if lower.ends_with(".txt") || lower.ends_with(".log") {
"note"
} else if lower.ends_with(".rs")
|| lower.ends_with(".ts")
|| lower.ends_with(".js")
|| lower.ends_with(".tsx")
|| lower.ends_with(".jsx")
|| lower.ends_with(".py")
|| lower.ends_with(".go")
|| lower.ends_with(".java")
|| lower.ends_with(".kt")
|| lower.ends_with(".rb")
|| lower.ends_with(".php")
|| lower.ends_with(".c")
|| lower.ends_with(".h")
|| lower.ends_with(".cpp")
|| lower.ends_with(".hpp")
{
"code"
} else {
"doc"
}
}
fn detect_slash_arg(text: &str, cursor: usize) -> Option<(String, usize, String)> {
let line_start = text[..cursor].rfind('\n').map(|i| i + 1).unwrap_or(0);
let line = &text[line_start..cursor];
let rest = line.strip_prefix('/')?;
let cmd_end = rest.find(|c: char| c.is_whitespace())?;
let cmd = &rest[..cmd_end];
if cmd.is_empty() {
return None;
}
let arg_start_in_line = 1 + cmd_end + 1;
if arg_start_in_line > line.len() {
return None;
}
let query = line[arg_start_in_line..].to_string();
Some((cmd.to_string(), line_start + arg_start_in_line, query))
}
fn detect_trigger(text: &str, cursor: usize) -> Option<(Trigger, usize, String)> {
if let Some((cmd, pos, query)) = detect_slash_arg(text, cursor) {
return Some((Trigger::SlashArg(cmd), pos, query));
}
if cursor == 0 {
return None;
}
let bytes = text.as_bytes();
let mut i = cursor;
while i > 0 {
i -= 1;
let c = bytes[i] as char;
if c == '@' {
let preceded_by_ws_or_start = i == 0 || (bytes[i - 1] as char).is_whitespace();
if preceded_by_ws_or_start {
let query = text[i + 1..cursor].to_string();
return Some((Trigger::At, i, query));
}
return None;
}
if c == '/' {
let at_line_start = i == 0 || bytes[i - 1] == b'\n';
if at_line_start {
let query = text[i + 1..cursor].to_string();
return Some((Trigger::Slash, i, query));
}
return None;
}
if c.is_whitespace() {
return None;
}
}
None
}
fn fuzzy_top(query: &str, pool: &[String], limit: usize) -> Vec<String> {
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher, Utf32Str};
let mut matcher = Matcher::new(Config::DEFAULT);
let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
let mut buf = Vec::new();
let mut scored: Vec<(String, u32)> = pool
.iter()
.filter_map(|s| {
buf.clear();
let utf32 = Utf32Str::new(s, &mut buf);
pattern.score(utf32, &mut matcher).map(|s_| (s.clone(), s_))
})
.collect();
scored.sort_by_key(|b| std::cmp::Reverse(b.1));
scored.truncate(limit);
scored.into_iter().map(|(s, _)| s).collect()
}
fn fuzzy_top_idx(query: &str, pool: &[String], limit: usize) -> Vec<usize> {
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher, Utf32Str};
let mut matcher = Matcher::new(Config::DEFAULT);
let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
let mut buf = Vec::new();
let mut scored: Vec<(usize, u32)> = pool
.iter()
.enumerate()
.filter_map(|(i, s)| {
buf.clear();
let utf32 = Utf32Str::new(s, &mut buf);
pattern.score(utf32, &mut matcher).map(|sc| (i, sc))
})
.collect();
scored.sort_by_key(|b| std::cmp::Reverse(b.1));
scored.truncate(limit);
scored.into_iter().map(|(i, _)| i).collect()
}
fn collect_files(base: &std::path::Path, max_depth: usize, limit: usize) -> Vec<String> {
use walkdir::WalkDir;
let mut out = Vec::new();
for entry in WalkDir::new(base)
.max_depth(max_depth)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
!matches!(
name.as_ref(),
".git" | ".aonyx" | "target" | "node_modules" | "dist"
)
})
.flatten()
{
if !entry.file_type().is_file() {
continue;
}
if let Ok(rel) = entry.path().strip_prefix(base) {
out.push(rel.to_string_lossy().replace('\\', "/"));
if out.len() >= limit {
break;
}
}
}
out.sort();
out
}
fn export_path(target: Option<String>) -> std::path::PathBuf {
if let Some(t) = target.filter(|s| !s.is_empty()) {
return std::path::PathBuf::from(t);
}
let stamp = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
std::path::PathBuf::from(format!("aonyx-session-{stamp}.md"))
}
fn export_html_path(target: Option<String>) -> std::path::PathBuf {
if let Some(t) = target.filter(|s| !s.is_empty()) {
return std::path::PathBuf::from(t);
}
let stamp = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
std::path::PathBuf::from(format!("aonyx-session-{stamp}.html"))
}
fn export_bundle_path(target: Option<String>) -> std::path::PathBuf {
if let Some(t) = target.filter(|s| !s.is_empty()) {
return std::path::PathBuf::from(t);
}
let stamp = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
std::path::PathBuf::from(format!("aonyx-session-{stamp}.zip"))
}
fn build_zip_bytes(members: &[(&str, &str)]) -> std::io::Result<Vec<u8>> {
use std::io::Write;
use zip::write::SimpleFileOptions;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::<u8>::new()));
let opts = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for (name, content) in members {
zip.start_file(*name, opts).map_err(std::io::Error::other)?;
zip.write_all(content.as_bytes())?;
}
let cursor = zip.finish().map_err(std::io::Error::other)?;
Ok(cursor.into_inner())
}
fn unzip_member(zip_bytes: &[u8], name: &str) -> std::io::Result<Vec<u8>> {
use std::io::Read;
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).map_err(std::io::Error::other)?;
let mut file = archive.by_name(name).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("bundle is missing {name}"),
)
})?;
let mut out = Vec::new();
file.read_to_end(&mut out)?;
Ok(out)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn render_markdown_to_html(md: &str) -> String {
use pulldown_cmark::{html, Event, Options, Parser};
let parser = Parser::new_ext(md, Options::all()).map(|ev| match ev {
Event::Html(raw) => Event::Text(raw),
Event::InlineHtml(raw) => Event::Text(raw),
other => other,
});
let mut out = String::new();
html::push_html(&mut out, parser);
out
}
const HTML_EXPORT_CSS: &str = "\
:root{color-scheme:dark}\
*{box-sizing:border-box}\
body{margin:0;background:#14181f;color:#d7dce4;\
font:16px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}\
header{padding:24px 20px;border-bottom:1px solid #2a313c;background:#181d26}\
header h1{margin:0 0 6px;font-size:20px}\
.meta{margin:0;color:#8a93a3;font-size:13px}\
main{max-width:860px;margin:0 auto;padding:20px}\
.msg{margin:0 0 16px;border:1px solid #2a313c;border-radius:8px;overflow:hidden}\
.msg .role{padding:6px 12px;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em}\
.msg .content{padding:12px 16px}\
.msg.user .role{background:#1b3a2a;color:#8fe3a8}\
.msg.assistant .role{background:#2a1f3d;color:#c9a8ff}\
.msg.system .role{background:#1f2733;color:#8a93a3}\
.msg.tool .role{background:#33240f;color:#e0b870}\
.content p{margin:0 0 10px}\
.content pre{background:#0e1219;border:1px solid #2a313c;border-radius:6px;\
padding:12px;overflow-x:auto;font:13px/1.5 'SF Mono',Menlo,Consolas,monospace}\
.content code{background:#0e1219;padding:1px 5px;border-radius:4px;\
font:13px 'SF Mono',Menlo,Consolas,monospace}\
.content pre code{background:none;padding:0}\
.content a{color:#7cc4ff}\
.content blockquote{margin:0 0 10px;padding:0 12px;border-left:3px solid #3a4452;color:#9aa4b3}\
.attachments{margin-top:8px;color:#8a93a3;font-size:13px}\
footer{max-width:860px;margin:0 auto;padding:16px 20px;color:#5a6473;font-size:12px}\
";
fn abbreviate_value(value: &serde_json::Value, max_chars: usize) -> String {
let mut s = match value {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
s = s.replace('\n', " ");
truncate(&s, max_chars)
}
fn truncate(s: &str, max_chars: usize) -> String {
if s.chars().count() > max_chars {
let cut: String = s.chars().take(max_chars).collect();
format!("{cut}…")
} else {
s.to_string()
}
}
fn error_line(text: String) -> Line<'static> {
Line::from(vec![
Span::styled("[error] ", Style::default().fg(Color::Red)),
Span::raw(text),
])
}
fn line_to_static(line: ratatui_core::text::Line<'_>) -> Line<'static> {
let spans: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|span| Span::styled(span.content.into_owned(), convert_style(span.style)))
.collect();
let mut new_line = Line::from(spans);
new_line.style = convert_style(line.style);
if let Some(alignment) = line.alignment {
new_line = new_line.alignment(convert_alignment(alignment));
}
new_line
}
fn convert_style(s: ratatui_core::style::Style) -> Style {
Style {
fg: s.fg.map(convert_color),
bg: s.bg.map(convert_color),
underline_color: None,
add_modifier: convert_modifier(s.add_modifier),
sub_modifier: convert_modifier(s.sub_modifier),
}
}
fn convert_color(c: ratatui_core::style::Color) -> Color {
use ratatui_core::style::Color as Cc;
match c {
Cc::Reset => Color::Reset,
Cc::Black => Color::Black,
Cc::Red => Color::Red,
Cc::Green => Color::Green,
Cc::Yellow => Color::Yellow,
Cc::Blue => Color::Blue,
Cc::Magenta => Color::Magenta,
Cc::Cyan => Color::Cyan,
Cc::Gray => Color::Gray,
Cc::DarkGray => Color::DarkGray,
Cc::LightRed => Color::LightRed,
Cc::LightGreen => Color::LightGreen,
Cc::LightYellow => Color::LightYellow,
Cc::LightBlue => Color::LightBlue,
Cc::LightMagenta => Color::LightMagenta,
Cc::LightCyan => Color::LightCyan,
Cc::White => Color::White,
Cc::Rgb(r, g, b) => Color::Rgb(r, g, b),
Cc::Indexed(i) => Color::Indexed(i),
}
}
fn convert_modifier(m: ratatui_core::style::Modifier) -> Modifier {
Modifier::from_bits_truncate(m.bits())
}
fn convert_alignment(a: ratatui_core::layout::HorizontalAlignment) -> Alignment {
use ratatui_core::layout::HorizontalAlignment as H;
match a {
H::Left => Alignment::Left,
H::Center => Alignment::Center,
H::Right => Alignment::Right,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn abbreviate_value_truncates_long_strings() {
let v = serde_json::Value::String("x".repeat(200));
let s = abbreviate_value(&v, 50);
assert!(s.chars().count() <= 51);
assert!(s.ends_with('…'));
}
#[test]
fn error_line_starts_with_marker() {
let line = error_line("boom".into());
assert!(line.spans[0].content.contains("[error]"));
assert!(line.spans[1].content.contains("boom"));
}
#[test]
fn truncate_keeps_short_strings() {
assert_eq!(truncate("hello", 80), "hello");
assert!(truncate(&"x".repeat(200), 50).ends_with('…'));
}
#[test]
fn export_path_defaults_to_timestamped_file() {
let p = export_path(None);
let name = p.file_name().unwrap().to_string_lossy().to_string();
assert!(name.starts_with("aonyx-session-"));
assert!(name.ends_with(".md"));
}
#[test]
fn export_path_uses_explicit_target_when_provided() {
let p = export_path(Some("transcript.md".into()));
assert_eq!(p, std::path::PathBuf::from("transcript.md"));
}
#[test]
fn export_html_path_defaults_to_html_extension() {
let p = export_html_path(None);
assert!(p.to_string_lossy().ends_with(".html"));
assert!(p.to_string_lossy().starts_with("aonyx-session-"));
}
#[test]
fn export_html_path_uses_explicit_target() {
let p = export_html_path(Some("out.html".into()));
assert_eq!(p, std::path::PathBuf::from("out.html"));
}
#[test]
fn export_bundle_path_defaults_to_zip_extension() {
let p = export_bundle_path(None);
assert!(p.to_string_lossy().ends_with(".zip"));
assert!(p.to_string_lossy().starts_with("aonyx-session-"));
assert_eq!(
export_bundle_path(Some("out.zip".into())),
std::path::PathBuf::from("out.zip")
);
}
#[test]
fn build_zip_bytes_round_trips_members() {
let bytes = build_zip_bytes(&[
("session.md", "# md body"),
("session.html", "<html>body</html>"),
("messages.json", "[]"),
("meta.json", "{\"k\":1}"),
])
.unwrap();
assert_eq!(&bytes[..2], b"PK");
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect();
for want in ["session.md", "session.html", "messages.json", "meta.json"] {
assert!(names.contains(&want.to_string()), "missing {want}");
}
let mut md = archive.by_name("session.md").unwrap();
let mut s = String::new();
std::io::Read::read_to_string(&mut md, &mut s).unwrap();
assert_eq!(s, "# md body");
}
#[test]
fn unzip_member_reads_back_what_build_zip_wrote() {
let msgs = r#"[{"role":"user","content":"hi"}]"#;
let bytes = build_zip_bytes(&[("messages.json", msgs), ("meta.json", "{}")]).unwrap();
let got = unzip_member(&bytes, "messages.json").unwrap();
assert_eq!(String::from_utf8(got).unwrap(), msgs);
let err = unzip_member(&bytes, "nope.json").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
#[test]
fn html_escape_neutralises_markup() {
assert_eq!(html_escape("a<b>&\"c"), "a<b>&"c");
}
#[test]
fn approval_matches_bare_name_and_arg_pattern() {
use std::collections::HashSet;
let mut rules = HashSet::new();
rules.insert("fs_write".to_string()); rules.insert("bash:cargo".to_string()); let args = |s: &str| serde_json::json!({ "command": s });
assert!(approval_matches(&rules, "fs_write", &args("anything")));
assert!(approval_matches(&rules, "bash", &args("cargo test")));
assert!(!approval_matches(&rules, "bash", &args("rm -rf /")));
assert!(!approval_matches(&rules, "git_push", &args("cargo")));
}
#[test]
fn is_http_url_detects_remote_refs() {
assert!(is_http_url("https://x.com/cat.png"));
assert!(is_http_url("http://x.com/cat.png"));
assert!(!is_http_url("cat.png"));
assert!(!is_http_url("/abs/path/cat.png"));
}
#[test]
fn media_type_helpers_pick_sensible_types() {
assert_eq!(media_type_from_ext("dir/IMG.PNG"), "image/png");
assert_eq!(media_type_from_ext("x.jpeg"), "image/jpeg");
assert_eq!(media_type_from_ext("x.bin"), "application/octet-stream");
assert_eq!(
media_type_for("http://x/y", "image/webp; charset=binary"),
"image/webp"
);
assert_eq!(media_type_for("http://x/y.gif", "text/html"), "image/gif");
}
#[tokio::test]
async fn approver_honours_always_allow_set() {
use aonyx_agent::approval::AsyncApprover;
seed_tool_approvals(&["fs_write".to_string()]);
let (tx, rx) = tokio::sync::mpsc::channel::<PendingApproval>(1);
drop(rx);
let approver = TuiApprover { tx };
let allowed = ToolCall {
id: "1".into(),
name: "fs_write".into(),
args: serde_json::Value::Null,
};
assert!(approver.approve(&allowed, SafetyClass::Destructive).await);
let other = ToolCall {
id: "2".into(),
name: "bash_unseeded_oo".into(),
args: serde_json::Value::Null,
};
assert!(!approver.approve(&other, SafetyClass::Destructive).await);
}
#[test]
fn render_markdown_renders_basic_structure() {
let html = render_markdown_to_html("# Title\n\nsome **bold** text");
assert!(html.contains("<h1>"));
assert!(html.contains("<strong>bold</strong>"));
}
#[test]
fn render_markdown_escapes_raw_html_to_prevent_xss() {
let html = render_markdown_to_html("hello <script>alert(1)</script>");
assert!(!html.contains("<script>"));
assert!(html.contains("<script>"));
}
#[test]
fn render_markdown_renders_code_blocks() {
let html = render_markdown_to_html("```rust\nfn main() {}\n```");
assert!(html.contains("<pre>"));
assert!(html.contains("<code"));
}
#[test]
fn extract_refs_pulls_paths_and_quotes_them_back() {
let (cleaned, refs) = extract_refs("look at @src/main.rs and @Cargo.toml together");
assert_eq!(refs, vec!["src/main.rs", "Cargo.toml"]);
assert!(cleaned.contains("`@src/main.rs`"));
assert!(cleaned.contains("`@Cargo.toml`"));
}
#[test]
fn extract_refs_leaves_bare_at_alone() {
let (cleaned, refs) = extract_refs("send mail @ now");
assert!(refs.is_empty());
assert!(cleaned.contains("@ now"));
}
#[test]
fn palette_initially_lists_every_entry() {
let p = Palette::new();
assert!(!p.open);
assert_eq!(p.filtered.len(), p.entries.len());
assert!(p.entries.len() >= 10);
}
#[test]
fn palette_refilter_narrows_by_query() {
let mut p = Palette::new();
let total = p.entries.len();
p.query = "themes".into();
p.refilter();
assert!(p.filtered.len() < total);
assert!(!p.filtered.is_empty());
}
#[test]
fn palette_refilter_no_match_clamps_selected_to_zero() {
let mut p = Palette::new();
p.selected = 5;
p.query = "zzzzz_no_match_xxxx".into();
p.refilter();
assert_eq!(p.filtered.len(), 0);
assert_eq!(p.selected, 0);
}
#[test]
fn split_into_chunks_keeps_paragraph_boundaries() {
let text = "first para\n\nsecond para\n\nthird para";
let chunks = split_into_chunks(text, 1000);
assert_eq!(chunks.len(), 1);
assert!(chunks[0].contains("first para"));
assert!(chunks[0].contains("third para"));
}
#[test]
fn split_into_chunks_flushes_when_next_paragraph_would_overflow() {
let para = "x".repeat(50);
let text = format!("{para}\n\n{para}\n\n{para}");
let chunks = split_into_chunks(&text, 70);
assert_eq!(chunks.len(), 3);
}
#[test]
fn split_into_chunks_drops_empty_paragraphs() {
let text = "alpha\n\n\n\nbeta";
let chunks = split_into_chunks(text, 1000);
assert_eq!(chunks.len(), 1);
assert!(chunks[0].contains("alpha"));
assert!(chunks[0].contains("beta"));
}
#[test]
fn ingest_kind_from_path_picks_doc_for_markdown() {
assert_eq!(ingest_kind_from_path("README.md"), "doc");
assert_eq!(ingest_kind_from_path("path/to/notes.markdown"), "doc");
assert_eq!(ingest_kind_from_path("guide.MDX"), "doc");
}
#[test]
fn ingest_kind_from_path_picks_code_for_source_files() {
assert_eq!(ingest_kind_from_path("main.rs"), "code");
assert_eq!(ingest_kind_from_path("src/lib.py"), "code");
assert_eq!(ingest_kind_from_path("app.TS"), "code");
}
#[test]
fn ingest_kind_from_path_picks_note_for_plain_text() {
assert_eq!(ingest_kind_from_path("scratch.txt"), "note");
assert_eq!(ingest_kind_from_path("/var/log/foo.log"), "note");
}
#[test]
fn detect_slash_arg_returns_none_when_line_is_not_a_slash_command() {
assert!(detect_slash_arg("hello world", 11).is_none());
assert!(detect_slash_arg("@README", 7).is_none());
}
#[test]
fn detect_slash_arg_returns_none_when_cursor_is_still_on_the_command() {
assert!(detect_slash_arg("/themes", 7).is_none());
}
#[test]
fn detect_slash_arg_recognises_command_plus_arg() {
let text = "/themes drac";
let cursor = text.len();
let (cmd, pos, query) = detect_slash_arg(text, cursor).expect("recognised");
assert_eq!(cmd, "themes");
assert_eq!(query, "drac");
assert_eq!(pos, 8);
}
#[test]
fn detect_slash_arg_works_with_empty_query() {
let text = "/themes ";
let cursor = text.len();
let (cmd, _, query) = detect_slash_arg(text, cursor).expect("recognised");
assert_eq!(cmd, "themes");
assert!(query.is_empty());
}
#[test]
fn detect_slash_arg_only_picks_up_command_at_line_start() {
let text = "hello\n/load abc";
let cursor = text.len();
let (cmd, _, query) = detect_slash_arg(text, cursor).expect("recognised");
assert_eq!(cmd, "load");
assert_eq!(query, "abc");
}
#[test]
fn detect_composer_mode_classifies_first_non_empty_line() {
let chat = TextArea::from(["", " ", "hello world"]);
let slash = TextArea::from(["", "/help"]);
let slash_indented = TextArea::from(["", " /themes dracula"]);
let bash = TextArea::from(["!ls -la"]);
let bare_at = TextArea::from(["@README.md what is this"]);
assert_eq!(detect_composer_mode(&chat), ComposerMode::Chat);
assert_eq!(detect_composer_mode(&slash), ComposerMode::Slash);
assert_eq!(detect_composer_mode(&slash_indented), ComposerMode::Slash);
assert_eq!(detect_composer_mode(&bash), ComposerMode::Bash);
assert_eq!(detect_composer_mode(&bare_at), ComposerMode::Chat);
}
#[test]
fn rect_contains_inclusive_on_low_corner_exclusive_on_high() {
let r = Rect::new(10, 5, 4, 3); assert!(rect_contains(r, 10, 5));
assert!(rect_contains(r, 13, 7));
assert!(!rect_contains(r, 14, 5));
assert!(!rect_contains(r, 10, 8));
assert!(!rect_contains(r, 9, 5));
}
#[test]
fn rect_shrink_strips_n_cells_each_side() {
let r = Rect::new(10, 5, 20, 10);
let inner = rect_shrink(r, 1);
assert_eq!(inner, Rect::new(11, 6, 18, 8));
}
#[test]
fn rect_shrink_clamps_to_zero_when_too_small() {
let r = Rect::new(0, 0, 2, 2);
let inner = rect_shrink(r, 4);
assert_eq!(inner.width, 0);
assert_eq!(inner.height, 0);
}
#[test]
fn palette_show_resets_state_to_visible_and_unfiltered() {
let mut p = Palette::new();
p.query = "stale".into();
p.selected = 4;
p.show();
assert!(p.open);
assert!(p.query.is_empty());
assert_eq!(p.selected, 0);
assert_eq!(p.filtered.len(), p.entries.len());
}
#[test]
fn extract_refs_handles_path_with_dots_and_dashes() {
let (_, refs) = extract_refs("compare @./crates/aonyx-cli/Cargo.toml please");
assert_eq!(refs, vec!["./crates/aonyx-cli/Cargo.toml"]);
}
#[test]
fn build_refs_message_skips_when_all_fail() {
let refs = vec![("missing.rs".to_string(), Err("not found".to_string()))];
assert!(build_refs_message(&refs).is_none());
}
#[test]
fn build_refs_message_keeps_failures_alongside_successes() {
let refs = vec![
("a.rs".to_string(), Ok("contents".to_string())),
("b.rs".to_string(), Err("nope".to_string())),
];
let msg = build_refs_message(&refs).expect("non-empty");
assert!(msg.content.contains("contents"));
assert!(msg.content.contains("could not read: nope"));
assert_eq!(msg.role, Role::System);
}
#[test]
fn expand_glob_passes_literals_and_expands_patterns() {
let lit = expand_glob_refs(&["does/not/exist.rs".to_string()]);
assert_eq!(lit, vec!["does/not/exist.rs".to_string()]);
let hits = expand_glob_refs(&["src/*.rs".to_string()]);
assert!(hits.len() > 1, "expected several src/*.rs, got {hits:?}");
assert!(hits.iter().all(|p| p.ends_with(".rs")));
assert!(hits.iter().any(|p| p.ends_with("tui.rs")));
let none = expand_glob_refs(&["src/*.zzz".to_string()]);
assert_eq!(none, vec!["src/*.zzz".to_string()]);
}
}