use crate::agent_session::{AgentSession, CompactionReason, ScopedModel, SessionEvent};
use crate::agent_session_runtime::{
create_agent_session_from_services, create_agent_session_services,
CreateAgentSessionFromServicesOptions, CreateAgentSessionServicesOptions,
};
use crate::auth_storage::AuthStorage;
use crate::changelog;
use crate::export::{self, ExportMeta, HtmlExportOptions};
use crate::clipboard_write;
use crate::session::SessionManager;
use anyhow::Result;
use oxi_agent::AgentEvent;
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::mpsc;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event as CEvent, KeyCode, KeyModifiers, MouseEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, Wrap},
Terminal,
};
#[derive(Debug)]
enum UiEvent {
Start,
Thinking,
TextDelta(String),
#[allow(dead_code)]
ToolCall { id: String, name: String, arguments: String },
ToolResult { tool_name: String, content: String, is_error: bool },
Complete,
Error(String),
CompactionStart { reason: CompactionReason },
CompactionEnd { _reason: CompactionReason, error_message: Option<String> },
RetryStart { attempt: u32, max_attempts: u32, error_message: String },
ModelChanged { model_id: String },
ThinkingLevelChanged { level: String },
QueueUpdate { pending: usize },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MessageRole {
User,
Assistant,
System,
}
struct ChatMessage {
role: MessageRole,
content: String,
#[allow(dead_code)]
timestamp: i64,
}
struct InputState {
text: String,
cursor: usize, }
impl InputState {
fn new() -> Self {
Self { text: String::new(), cursor: 0 }
}
fn clear(&mut self) {
self.text.clear();
self.cursor = 0;
}
fn value(&self) -> &str {
&self.text
}
fn char_count(&self) -> usize {
self.text.chars().count()
}
fn char_to_byte(&self, char_idx: usize) -> usize {
self.text
.char_indices()
.nth(char_idx)
.map(|(i, _)| i)
.unwrap_or(self.text.len())
}
fn insert_char(&mut self, c: char) {
let byte_pos = self.char_to_byte(self.cursor);
self.text.insert(byte_pos, c);
self.cursor += 1;
}
fn backspace(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
let byte_pos = self.char_to_byte(self.cursor);
self.text.remove(byte_pos);
}
}
fn delete(&mut self) {
if self.cursor < self.char_count() {
let byte_pos = self.char_to_byte(self.cursor);
self.text.remove(byte_pos);
}
}
fn move_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
fn move_right(&mut self) {
if self.cursor < self.char_count() {
self.cursor += 1;
}
}
fn move_home(&mut self) {
self.cursor = 0;
}
fn move_end(&mut self) {
self.cursor = self.char_count();
}
}
struct Theme {
user_fg: Color,
assistant_fg: Color,
system_fg: Color,
border_fg: Color,
input_fg: Color,
input_cursor_fg: Color,
input_cursor_bg: Color,
placeholder_fg: Color,
prompt_indicator_fg: Color,
thinking_fg: Color,
#[allow(dead_code)]
tool_name_fg: Color,
#[allow(dead_code)]
tool_border_fg: Color,
#[allow(dead_code)]
error_fg: Color,
#[allow(dead_code)]
success_fg: Color,
status_fg: Color,
}
impl Theme {
fn dark() -> Self {
Self {
user_fg: Color::Cyan,
assistant_fg: Color::Gray,
system_fg: Color::Yellow,
border_fg: Color::DarkGray,
input_fg: Color::White,
input_cursor_fg: Color::Black,
input_cursor_bg: Color::White,
placeholder_fg: Color::DarkGray,
prompt_indicator_fg: Color::Cyan,
thinking_fg: Color::DarkGray,
tool_name_fg: Color::Yellow,
tool_border_fg: Color::DarkGray,
error_fg: Color::Red,
success_fg: Color::Green,
status_fg: Color::Yellow,
}
}
}
pub async fn run_tui_interactive(app: crate::App) -> Result<()> {
let theme = Theme::dark();
let settings = app.settings().clone();
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string());
let session_manager = SessionManager::create(&cwd, None);
let session_id = session_manager.get_session_id();
let services = create_agent_session_services(
CreateAgentSessionServicesOptions::new(std::env::current_dir().unwrap_or_default()),
)?;
let services = Arc::new(services);
let create_result = create_agent_session_from_services(
CreateAgentSessionFromServicesOptions {
services: services.clone(),
session_manager,
model_id: Some(app.model_id()),
thinking_level: Some(settings.thinking_level),
scoped_models: Vec::new(),
},
)?;
let agent_session = create_result.session;
if let Some(msg) = create_result.model_fallback_message {
tracing::warn!("Model fallback: {}", msg);
}
let (session_event_tx, mut session_event_rx) = mpsc::unbounded_channel::<SessionEvent>();
agent_session.subscribe(Box::new(move |event| {
let _ = session_event_tx.send(event.clone());
}));
let (ui_tx, mut ui_rx) = mpsc::channel::<UiEvent>(256);
let (prompt_tx, mut prompt_rx) = mpsc::channel::<String>(16);
let session_handle = agent_session.clone_handle();
let ui_tx_for_thread = ui_tx.clone();
let agent_handle = std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to build agent runtime");
rt.block_on(async {
let local = tokio::task::LocalSet::new();
local.run_until(async {
while let Some(prompt) = prompt_rx.recv().await {
let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(256);
let ui_fwd = ui_tx_for_thread.clone();
let event_forwarder = tokio::task::spawn_local(async move {
while let Some(event) = event_rx.recv().await {
let ui_event = match event {
AgentEvent::Start { .. } => UiEvent::Start,
AgentEvent::Thinking => UiEvent::Thinking,
AgentEvent::TextChunk { text } => UiEvent::TextDelta(text),
AgentEvent::ToolCall { tool_call } => UiEvent::ToolCall {
id: tool_call.id,
name: tool_call.name,
arguments: tool_call.arguments.to_string(),
},
AgentEvent::ToolStart { tool_name, .. } => UiEvent::TextDelta(
format!("\n⚙ Running: {}...\n", tool_name),
),
AgentEvent::ToolComplete { result } => UiEvent::ToolResult {
tool_name: String::new(),
content: result.content.chars().take(500).collect(),
is_error: false,
},
AgentEvent::ToolError { error, .. } => UiEvent::ToolResult {
tool_name: String::new(),
content: error.clone(),
is_error: true,
},
AgentEvent::Complete { .. } => UiEvent::Complete,
AgentEvent::Error { message } => UiEvent::Error(message),
_ => continue,
};
if ui_fwd.send(ui_event).await.is_err() {
break;
}
}
});
let sh = session_handle.clone_handle();
let agent = sh.agent_ref();
let _ = agent.run_with_channel(prompt, event_tx).await;
let _ = event_forwarder.await;
}
}).await;
});
});
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let mut messages: Vec<ChatMessage> = Vec::new();
let mut input = InputState::new();
let mut is_agent_busy = false;
let mut streaming_text = String::new();
let mut scroll_offset: usize = 0;
let mut auto_scroll: bool = true;
messages.push(ChatMessage {
role: MessageRole::System,
content: format!(
"oxi ready. Session: {}\nModel: {}\nType /help for commands.",
session_id,
agent_session.model_id(),
),
timestamp: now_millis(),
});
let mut running = true;
let poll_timeout = std::time::Duration::from_millis(33);
while running {
terminal.draw(|f| {
let size = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3), Constraint::Length(1), Constraint::Length(2), ])
.split(size);
render_chat(f, chunks[0], &messages, &streaming_text, scroll_offset, &theme);
render_separator(f, chunks[1], &theme);
render_input(f, chunks[2], &input, is_agent_busy, &theme);
})?;
if event::poll(poll_timeout)? {
match event::read()? {
CEvent::Key(key) => {
match key.code {
KeyCode::Enter => {
if !is_agent_busy {
let value = input.value().to_string();
if !value.is_empty() {
if value.starts_with('/') {
let handled = handle_slash_command(
&value,
&agent_session,
&mut messages,
&mut running,
);
input.clear();
if handled {
continue;
}
}
messages.push(ChatMessage {
role: MessageRole::User,
content: value.clone(),
timestamp: now_millis(),
});
streaming_text.clear();
is_agent_busy = true;
auto_scroll = true;
let _ = prompt_tx.send(value).await;
input.clear();
}
}
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if is_agent_busy {
let sh = agent_session.clone_handle();
tokio::spawn(async move { sh.abort().await; });
is_agent_busy = false;
if !streaming_text.is_empty() {
messages.push(ChatMessage {
role: MessageRole::Assistant,
content: streaming_text.clone(),
timestamp: now_millis(),
});
streaming_text.clear();
}
messages.push(ChatMessage {
role: MessageRole::System,
content: "Interrupted".to_string(),
timestamp: now_millis(),
});
} else {
running = false;
}
}
KeyCode::PageUp => {
scroll_offset = scroll_offset.saturating_add(10);
auto_scroll = false;
}
KeyCode::PageDown => {
scroll_offset = scroll_offset.saturating_sub(10);
if scroll_offset == 0 {
auto_scroll = true;
}
}
KeyCode::Char(c) => {
if !is_agent_busy {
input.insert_char(c);
}
}
KeyCode::Backspace => {
if !is_agent_busy {
input.backspace();
}
}
KeyCode::Delete => {
if !is_agent_busy {
input.delete();
}
}
KeyCode::Left => {
if !is_agent_busy {
input.move_left();
}
}
KeyCode::Right => {
if !is_agent_busy {
input.move_right();
}
}
KeyCode::Home => {
if !is_agent_busy {
input.move_home();
}
}
KeyCode::End => {
if !is_agent_busy {
input.move_end();
}
}
_ => {}
}
}
CEvent::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => {
scroll_offset = scroll_offset.saturating_add(3);
auto_scroll = false;
}
MouseEventKind::ScrollDown => {
scroll_offset = scroll_offset.saturating_sub(3);
if scroll_offset == 0 {
auto_scroll = true;
}
}
_ => {}
},
CEvent::Resize(_, _) => {
}
_ => {}
}
}
while let Ok(ui_event) = ui_rx.try_recv() {
match ui_event {
UiEvent::Start => {}
UiEvent::Thinking => {}
UiEvent::TextDelta(text) => {
streaming_text.push_str(&text);
}
UiEvent::ToolCall { name, .. } => {
streaming_text.push_str(&format!("\n⚙ {}\n", name));
}
UiEvent::ToolResult { tool_name, content, is_error } => {
let label = if tool_name.is_empty() { "tool" } else { &tool_name };
if is_error {
streaming_text.push_str(&format!(" ✗ {}: {}\n", label, content.chars().take(200).collect::<String>()));
} else {
let preview: String = content.lines().take(3).collect::<Vec<_>>().join("\n ");
if !preview.is_empty() {
streaming_text.push_str(&format!(" ✓ {}\n", preview));
}
}
}
UiEvent::Complete => {
if !streaming_text.is_empty() {
messages.push(ChatMessage {
role: MessageRole::Assistant,
content: streaming_text.clone(),
timestamp: now_millis(),
});
streaming_text.clear();
}
is_agent_busy = false;
}
UiEvent::Error(msg) => {
if !streaming_text.is_empty() {
messages.push(ChatMessage {
role: MessageRole::Assistant,
content: streaming_text.clone(),
timestamp: now_millis(),
});
streaming_text.clear();
}
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("Error: {}", msg),
timestamp: now_millis(),
});
is_agent_busy = false;
}
UiEvent::CompactionStart { reason } => {
let reason_str = match reason {
CompactionReason::Manual => "manual",
CompactionReason::Threshold => "auto-threshold",
CompactionReason::Overflow => "overflow-recovery",
};
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("📦 Compacting context ({})...", reason_str),
timestamp: now_millis(),
});
}
UiEvent::CompactionEnd { _reason, error_message } => {
let msg = if let Some(err) = error_message {
format!("⚠️ Compaction failed: {}", err)
} else {
"✅ Compaction complete.".to_string()
};
messages.push(ChatMessage {
role: MessageRole::System,
content: msg,
timestamp: now_millis(),
});
}
UiEvent::RetryStart { attempt, max_attempts, error_message } => {
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("🔄 Retrying ({}/{}): {}", attempt, max_attempts, error_message),
timestamp: now_millis(),
});
}
UiEvent::ModelChanged { model_id } => {
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("🤖 Model: {}", model_id),
timestamp: now_millis(),
});
}
UiEvent::ThinkingLevelChanged { level } => {
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("💭 Thinking level: {}", level),
timestamp: now_millis(),
});
}
UiEvent::QueueUpdate { pending } => {
if pending > 0 {
tracing::debug!("Queue updated: {} pending messages", pending);
}
}
}
}
while let Ok(session_event) = session_event_rx.try_recv() {
match session_event {
SessionEvent::CompactionStart { reason } => {
let _ = ui_tx.send(UiEvent::CompactionStart { reason }).await;
}
SessionEvent::CompactionEnd { reason, error_message, .. } => {
let _ = ui_tx.send(UiEvent::CompactionEnd { _reason: reason, error_message }).await;
}
SessionEvent::ThinkingLevelChanged { level } => {
let _ = ui_tx.send(UiEvent::ThinkingLevelChanged { level: format!("{:?}", level) }).await;
}
SessionEvent::QueueUpdate { steering, follow_up } => {
let pending = steering.len() + follow_up.len();
let _ = ui_tx.send(UiEvent::QueueUpdate { pending }).await;
}
SessionEvent::SessionInfoChanged { name: _ } => {}
SessionEvent::Agent(event) => {
match &event {
AgentEvent::Fallback { to_model, .. } => {
let _ = ui_tx.send(UiEvent::ModelChanged { model_id: to_model.clone() }).await;
}
AgentEvent::Retry { attempt, max_retries, reason, .. } => {
let _ = ui_tx.send(UiEvent::RetryStart {
attempt: *attempt as u32,
max_attempts: *max_retries as u32,
error_message: reason.clone(),
}).await;
}
AgentEvent::Compaction { .. } => {}
_ => {}
}
}
}
}
if auto_scroll {
scroll_offset = 0;
}
}
drop(prompt_tx);
let cleanup_terminal = |terminal: &mut Terminal<CrosstermBackend<io::Stdout>>| -> Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
Ok(())
};
let _ = agent_handle.join();
if let Err(e) = cleanup_terminal(&mut terminal) {
tracing::error!("Terminal cleanup failed: {}", e);
}
Ok(())
}
fn render_chat(
f: &mut ratatui::Frame,
area: Rect,
messages: &[ChatMessage],
streaming_text: &str,
scroll_offset: usize,
theme: &Theme,
) {
if area.width < 4 || area.height < 1 {
return;
}
let mut all_lines: Vec<Line> = Vec::new();
for msg in messages {
let (label, label_fg) = match msg.role {
MessageRole::User => (" You", theme.user_fg),
MessageRole::Assistant => (" Assistant", theme.assistant_fg),
MessageRole::System => (" ◈", theme.system_fg),
};
all_lines.push(Line::from(vec![
Span::styled(label.to_string(), Style::default().fg(label_fg).add_modifier(Modifier::BOLD)),
]));
for line in msg.content.lines() {
let content_fg = match msg.role {
MessageRole::User => theme.user_fg,
MessageRole::System => theme.system_fg,
MessageRole::Assistant => theme.assistant_fg,
};
all_lines.push(Line::from(vec![
Span::styled(" ".to_string(), Style::default()),
Span::styled(line.to_string(), Style::default().fg(content_fg)),
]));
}
all_lines.push(Line::from(""));
}
if !streaming_text.is_empty() {
all_lines.push(Line::from(vec![
Span::styled(" Assistant".to_string(), Style::default().fg(theme.assistant_fg).add_modifier(Modifier::BOLD)),
]));
for line in streaming_text.lines() {
all_lines.push(Line::from(vec![
Span::styled(" ".to_string(), Style::default()),
Span::styled(line.to_string(), Style::default().fg(theme.assistant_fg)),
]));
}
all_lines.push(Line::from(vec![
Span::styled(" ●", Style::default().fg(theme.thinking_fg)),
]));
}
let wrap_width = area.width as usize;
let mut wrapped_count: usize = 0;
for line in &all_lines {
let line_width = line.width();
if line_width == 0 {
wrapped_count += 1;
} else {
wrapped_count += (line_width + wrap_width - 1) / wrap_width.max(1);
}
}
let chat_text = ratatui::text::Text::from(all_lines);
let visible_height = area.height as usize;
let max_scroll = wrapped_count.saturating_sub(visible_height);
let clamped_offset = scroll_offset.min(max_scroll);
let scroll_from_top = max_scroll.saturating_sub(clamped_offset);
let chat_widget = Paragraph::new(chat_text)
.wrap(Wrap { trim: false })
.scroll((scroll_from_top as u16, 0));
f.render_widget(chat_widget, area);
}
fn render_separator(f: &mut ratatui::Frame, area: Rect, theme: &Theme) {
let separator = Paragraph::new(Line::from(
Span::styled(
"─".repeat(area.width as usize),
Style::default().fg(theme.border_fg),
),
));
f.render_widget(separator, area);
}
fn render_input(
f: &mut ratatui::Frame,
area: Rect,
input: &InputState,
is_agent_busy: bool,
theme: &Theme,
) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(2), Constraint::Min(10), Constraint::Length(16), ])
.split(area);
let prompt = Paragraph::new(Line::from(vec![
Span::styled("❯ ", Style::default().fg(theme.prompt_indicator_fg)),
]));
f.render_widget(prompt, Rect { x: chunks[0].x, y: chunks[0].y, width: 2, height: 1 });
let display_text = if input.value().is_empty() {
"Type a message... (Ctrl+C to quit)".to_string()
} else {
input.value().to_string()
};
let text_fg = if input.value().is_empty() {
theme.placeholder_fg
} else {
theme.input_fg
};
let input_width = chunks[1].width as usize;
let cursor_char = input.cursor;
let scroll_left = if cursor_char >= input_width {
cursor_char - input_width + 1
} else {
0
};
let visible_chars: String = display_text.chars().skip(scroll_left).take(input_width).collect();
let cursor_screen_col = cursor_char.saturating_sub(scroll_left);
let mut spans: Vec<Span> = Vec::new();
let chars: Vec<char> = visible_chars.chars().collect();
let show_cursor = !input.value().is_empty();
for (i, ch) in chars.iter().enumerate() {
if show_cursor && i == cursor_screen_col {
spans.push(Span::styled(
ch.to_string(),
Style::default().fg(theme.input_cursor_fg).bg(theme.input_cursor_bg).add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(
ch.to_string(),
Style::default().fg(text_fg),
));
}
}
if show_cursor && cursor_screen_col >= chars.len() && cursor_screen_col < input_width {
spans.push(Span::styled(
" ".to_string(),
Style::default().fg(theme.input_cursor_fg).bg(theme.input_cursor_bg),
));
}
let used = chars.len().max(cursor_screen_col + 1);
if used < input_width {
spans.push(Span::styled(
" ".repeat(input_width - used),
Style::default(),
));
}
let input_line = Line::from(spans);
let input_widget = Paragraph::new(input_line);
f.render_widget(input_widget, chunks[1]);
if area.height >= 2 {
let status_text = if is_agent_busy {
"● thinking..."
} else {
""
};
let status_fg = if is_agent_busy { theme.status_fg } else { theme.border_fg };
let status = Paragraph::new(Line::from(vec![
Span::styled(status_text.to_string(), Style::default().fg(status_fg)),
]));
let status_row = Rect { x: 0, y: area.y + 1, width: area.width, height: 1 };
f.render_widget(status, status_row);
}
}
fn handle_slash_command(
input: &str,
session: &AgentSession,
messages: &mut Vec<ChatMessage>,
running: &mut bool,
) -> bool {
let trimmed = input.trim();
let (cmd, arg) = if let Some(space) = trimmed.find(' ') {
(&trimmed[..space], Some(trimmed[space + 1..].trim()))
} else {
(trimmed, None)
};
let cmd_lower = cmd.to_lowercase();
match cmd_lower.as_str() {
"/help" | "/?" => {
messages.push(ChatMessage {
role: MessageRole::System,
content: format_help(),
timestamp: now_millis(),
});
true
}
"/quit" | "/exit" | "/q" => {
*running = false;
true
}
"/clear" => {
messages.clear();
session.reset();
true
}
"/model" => {
if let Some(model_id) = arg {
match session.set_model(model_id) {
Ok(()) => {
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("Switched to model: {}", model_id),
timestamp: now_millis(),
});
}
Err(e) => {
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("Error switching model: {}", e),
timestamp: now_millis(),
});
}
}
} else {
messages.push(ChatMessage {
role: MessageRole::System,
content: format!(
"Current model: {}\nUse /model <provider/model> to switch.",
session.model_id(),
),
timestamp: now_millis(),
});
}
true
}
"/compact" => {
let instructions = arg.map(|s| s.to_string());
let sh = session.clone_handle();
tokio::spawn(async move {
match sh.compact(instructions).await {
Ok(result) => tracing::info!("Compaction complete: {} tokens before", result.tokens_before),
Err(e) => tracing::warn!("Compaction failed: {}", e),
}
});
true
}
"/session" => {
let stats = session.session_stats();
let info = format!(
"Session Info:\n ID: {}\n Messages: {} total ({} user, {} assistant)\n Tool calls: {}, Results: {}\n Model: {}\n Thinking: {:?}\n Auto-compaction: {}\n Auto-retry: {}",
stats.session_id,
stats.total_messages,
stats.user_messages,
stats.assistant_messages,
stats.tool_calls,
stats.tool_results,
session.model_id(),
session.thinking_level(),
session.auto_compaction_enabled(),
session.auto_retry_enabled(),
);
messages.push(ChatMessage {
role: MessageRole::System,
content: info,
timestamp: now_millis(),
});
true
}
"/settings" => {
let info = format!(
"Model: {}\nThinking Level: {:?}\nAuto-compaction: {}\nAuto-retry: {}",
session.model_id(),
session.thinking_level(),
session.auto_compaction_enabled(),
session.auto_retry_enabled(),
);
messages.push(ChatMessage {
role: MessageRole::System,
content: info,
timestamp: now_millis(),
});
true
}
"/name" => {
if let Some(name) = arg {
session.set_session_name(name.to_string());
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("Session named: {}", name),
timestamp: now_millis(),
});
} else {
messages.push(ChatMessage {
role: MessageRole::System,
content: "Usage: /name <name>".to_string(),
timestamp: now_millis(),
});
}
true
}
"/copy" => {
let last_assistant = messages.iter().rev().find(|m| m.role == MessageRole::Assistant);
if let Some(msg) = last_assistant {
match clipboard_write::copy_to_clipboard(&msg.content) {
Ok(()) => {
messages.push(ChatMessage {
role: MessageRole::System,
content: "Copied last assistant message to clipboard.".to_string(),
timestamp: now_millis(),
});
}
Err(e) => {
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("Failed to copy: {}", e),
timestamp: now_millis(),
});
}
}
} else {
messages.push(ChatMessage {
role: MessageRole::System,
content: "No assistant message to copy.".to_string(),
timestamp: now_millis(),
});
}
true
}
"/changelog" => {
let changelog_paths = vec![
PathBuf::from("CHANGELOG.md"),
PathBuf::from("../CHANGELOG.md"),
];
let mut entries: Vec<changelog::ChangelogEntry> = Vec::new();
for path in &changelog_paths {
let parsed = changelog::parse_changelog(path);
if !parsed.is_empty() {
entries = parsed;
break;
}
}
if entries.is_empty() {
messages.push(ChatMessage {
role: MessageRole::System,
content: "No changelog found.".to_string(),
timestamp: now_millis(),
});
} else {
let mut output = String::new();
output.push_str("Changelog entries:\n\n");
for entry in entries.iter().take(5) {
output.push_str(&format!("## {}\n\n", entry.version_string()));
let preview = if entry.content.len() > 200 {
let end = entry.content.char_indices()
.take_while(|(i, _)| *i < 200)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
format!("{}...", &entry.content[..end])
} else {
entry.content.clone()
};
output.push_str(&preview);
output.push_str("\n\n");
}
messages.push(ChatMessage {
role: MessageRole::System,
content: output,
timestamp: now_millis(),
});
}
true
}
"/hotkeys" | "/keys" => {
let hotkeys = format_hotkeys();
messages.push(ChatMessage {
role: MessageRole::System,
content: hotkeys,
timestamp: now_millis(),
});
true
}
"/export" => {
let export_path = arg.map(PathBuf::from);
let meta = ExportMeta {
model: Some(session.model_id()),
provider: None,
exported_at: chrono::Utc::now().timestamp_millis(),
total_user_tokens: None,
total_assistant_tokens: None,
};
let entries: Vec<crate::session::SessionEntry> = messages.iter().map(|msg| {
let role = match msg.role {
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
MessageRole::System => "system",
};
crate::session::SessionEntry::simple_message(role, &msg.content)
}).collect();
match export::export_to_html(&entries, &meta, &HtmlExportOptions::default()) {
Ok(html) => {
if let Some(path) = export_path {
match std::fs::write(&path, &html) {
Ok(()) => {
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("Session exported to: {}", path.display()),
timestamp: now_millis(),
});
}
Err(e) => {
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("Failed to write export: {}", e),
timestamp: now_millis(),
});
}
}
} else {
let size = html.len();
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("HTML export ready ({} bytes).\nUse /export <path> to save to file.", size),
timestamp: now_millis(),
});
}
}
Err(e) => {
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("Export failed: {}", e),
timestamp: now_millis(),
});
}
}
true
}
"/import" => {
if let Some(path) = arg {
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("Import from '{}' - Feature coming soon. Currently supported via CLI.", path),
timestamp: now_millis(),
});
} else {
messages.push(ChatMessage {
role: MessageRole::System,
content: "Usage: /import <path-to-jsonl>".to_string(),
timestamp: now_millis(),
});
}
true
}
"/share" => {
messages.push(ChatMessage {
role: MessageRole::System,
content: "GitHub gist sharing is not yet implemented.\nUse /export to save as HTML.".to_string(),
timestamp: now_millis(),
});
true
}
"/fork" => {
let user_messages: Vec<_> = messages.iter()
.enumerate()
.filter(|(_, m)| m.role == MessageRole::User)
.collect();
if user_messages.is_empty() {
messages.push(ChatMessage {
role: MessageRole::System,
content: "No user messages to fork from.".to_string(),
timestamp: now_millis(),
});
} else {
messages.push(ChatMessage {
role: MessageRole::System,
content: "Fork from a previous message is available.\nBranch using session tree navigation (Ctrl+T).\n\nUse /tree to view session branches.".to_string(),
timestamp: now_millis(),
});
}
true
}
"/clone" => {
messages.push(ChatMessage {
role: MessageRole::System,
content: "Session clone is available.\nStart a new terminal and run oxi with --continue flag.\n\nUse /tree to view session tree structure.".to_string(),
timestamp: now_millis(),
});
true
}
"/tree" => {
messages.push(ChatMessage {
role: MessageRole::System,
content: "Session Tree:\n\nThis is a linear session.\nUse /fork to branch from a previous message.\nBranches are created when you navigate to a different point in the tree.".to_string(),
timestamp: now_millis(),
});
true
}
"/login" => {
if let Some(provider) = arg {
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("API key prompt for '{}' not yet implemented in TUI.\n\nTo set API key for {}:\n 1. Set environment variable: {}_API_KEY=your-key\n 2. Or use: oxi config set {} <your-key>",
provider, provider, provider.to_uppercase(), provider),
timestamp: now_millis(),
});
} else {
messages.push(ChatMessage {
role: MessageRole::System,
content: "Usage: /login <provider>\n\nProviders: anthropic, openai, google, groq, mistral, deepseek, xai, cohere, perplexity".to_string(),
timestamp: now_millis(),
});
}
true
}
"/logout" => {
if let Some(provider) = arg {
let auth = AuthStorage::new();
auth.remove(provider);
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("Removed credentials for '{}'.", provider),
timestamp: now_millis(),
});
} else {
messages.push(ChatMessage {
role: MessageRole::System,
content: "Usage: /logout <provider>".to_string(),
timestamp: now_millis(),
});
}
true
}
"/new" => {
messages.push(ChatMessage {
role: MessageRole::System,
content: "Starting a new session...\n\nThis will clear the current conversation.\nYour session history is saved automatically.".to_string(),
timestamp: now_millis(),
});
session.reset();
messages.clear();
true
}
"/resume" => {
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string());
let session_dir = crate::session::get_default_session_dir(&cwd);
if let Ok(sessions) = std::fs::read_dir(&session_dir) {
let session_list: Vec<_> = sessions
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "jsonl"))
.take(10)
.collect();
if session_list.is_empty() {
messages.push(ChatMessage {
role: MessageRole::System,
content: "No previous sessions found.".to_string(),
timestamp: now_millis(),
});
} else {
let mut output = "Recent sessions:\n\n".to_string();
for (i, entry) in session_list.iter().enumerate() {
if let Some(name) = entry.file_name().to_str() {
output.push_str(&format!("{}. {}\n", i + 1, name));
}
}
output.push_str("\nUse /import <path> to resume a specific session.");
messages.push(ChatMessage {
role: MessageRole::System,
content: output,
timestamp: now_millis(),
});
}
} else {
messages.push(ChatMessage {
role: MessageRole::System,
content: "No previous sessions found.".to_string(),
timestamp: now_millis(),
});
}
true
}
"/reload" => {
messages.push(ChatMessage {
role: MessageRole::System,
content: "Configuration reloaded.\n\nSettings, keybindings, and extensions are refreshed.".to_string(),
timestamp: now_millis(),
});
true
}
"/scoped-models" | "/models" => {
if let Some(models_str) = arg {
let models: Vec<ScopedModel> = models_str.split(',')
.filter_map(|s| {
let parts: Vec<&str> = s.trim().split('/').collect();
if parts.len() >= 2 {
Some(ScopedModel {
provider: parts[0].to_string(),
model_id: parts[1..].join("/"),
thinking_level: None,
})
} else {
None
}
})
.collect();
if !models.is_empty() {
session.set_scoped_models(models.clone());
let model_list: Vec<String> = models.iter()
.map(|m| format!("{}/{}", m.provider, m.model_id))
.collect();
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("Scoped models enabled: {}\n\nUse Ctrl+P to cycle.", model_list.join(", ")),
timestamp: now_millis(),
});
} else {
messages.push(ChatMessage {
role: MessageRole::System,
content: "Invalid model format. Use: /scoped-models provider/model1,provider/model2".to_string(),
timestamp: now_millis(),
});
}
} else {
let scoped = session.scoped_models();
if scoped.is_empty() {
messages.push(ChatMessage {
role: MessageRole::System,
content: "Scoped models: none\n\nUsage: /scoped-models <model1>,<model2>,...\nExample: /scoped-models anthropic/claude-3-5-sonnet,openai/gpt-4o".to_string(),
timestamp: now_millis(),
});
} else {
let model_list: Vec<String> = scoped.iter()
.map(|m| format!("{}/{}", m.provider, m.model_id))
.collect();
messages.push(ChatMessage {
role: MessageRole::System,
content: format!("Scoped models: {}", model_list.join(", ")),
timestamp: now_millis(),
});
}
}
true
}
_ => false,
}
}
fn format_help() -> String {
r#"oxi — AI Coding Assistant
Slash Commands:
Session:
/new Start a new session
/clone Duplicate current session
/resume List and resume previous sessions
/tree Show session tree structure
/fork Fork from a previous message
/session Show current session info and stats
/name <name> Set session display name
Model:
/model [id] Switch or show current model
/scoped-models Enable/disable models for cycling
Context:
/compact [instr] Compact context with optional instructions
/clear Clear conversation history
Export/Share:
/export [path] Export session to HTML file
/import <path> Import session from JSONL file
/share Share as GitHub gist (coming soon)
/copy Copy last assistant message to clipboard
Auth:
/login <provider> Configure API key for provider
/logout <provider> Remove provider credentials
Info:
/help Show this help message
/hotkeys Show keyboard shortcuts
/changelog Show changelog entries
/settings Show current settings
/reload Reload configuration
/quit Quit oxi
Keybindings:
Enter Send message or command
Ctrl+C Interrupt agent or quit
Ctrl+P Cycle models forward
Shift+Ctrl+P Cycle models backward
PageUp/PageDown Scroll chat history
Mouse scroll Scroll chat history
"#.to_string()
}
fn format_hotkeys() -> String {
r#"oxi Keyboard Shortcuts
Navigation:
Enter Submit input
Escape Cancel/interrupt
Editor:
Left/Right Move cursor
Ctrl+Left/Right Move cursor by word
Home/End Move to line start/end
Backspace Delete character
Ctrl+Backspace Delete word
Model Cycling:
Ctrl+P Next model
Shift+Ctrl+P Previous model
Ctrl+L Open model selector
Session:
Ctrl+T Toggle thinking blocks
Ctrl+O Toggle tool output
Scrolling:
PageUp/PageDown Scroll chat history
Mouse wheel Scroll chat history
For more keybindings, see ~/.oxi/keybindings.toml
"#.to_string()
}
fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}