use crate::InteractiveSession;
use crate::agent_session::AgentSession;
use crate::session::SessionManager;
use anyhow::Result;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use oxi_agent::AgentEvent;
use oxi_tui::{
ChatMessageDisplay, ChatView, Component, ContentBlockDisplay, Input, MessageRole, Rect,
Surface, Theme,
};
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader};
use std::os::unix::process::ExitStatusExt;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
#[allow(unused_imports)]
use crate::ChatMessage;
use crate::clipboard_image;
use crate::image_convert::convert_to_png;
use crate::image_resize::{resize_image, ResizeOptions, ResizedImage};
use crate::file_processor::FileProcessorOptions;
use crate::rpc_mode::{PasteHandler, PasteState};
pub struct ImagePasteHandler {
resize_opts: ResizeOptions,
paste_handler: PasteHandler,
max_api_bytes: usize,
}
impl ImagePasteHandler {
pub fn new() -> Self {
Self {
resize_opts: ResizeOptions::new(2000, 2000).max_bytes(2 * 1024 * 1024),
paste_handler: PasteHandler::new(),
max_api_bytes: 2 * 1024 * 1024,
}
}
pub fn reset(&mut self) {
self.paste_handler.reset();
}
pub fn paste_state(&self) -> PasteState {
self.paste_handler.state()
}
pub fn process_image_paste(&mut self, data: Vec<u8>) -> Result<ImageAttachment> {
let mime = clipboard_image::detect_image_mime_type(&data);
let resized = resize_image(&data, &self.resize_opts)
.or_else(|_| {
let aggressive_opts = ResizeOptions::new(1000, 1000)
.max_bytes(self.max_api_bytes)
.jpeg_quality(60);
resize_image(&data, &aggressive_opts)
})?;
let base64_data = BASE64.encode(&resized.bytes);
let mime_type = if resized.mime_type == "image/jpeg" {
"image/jpeg"
} else {
mime
};
Ok(ImageAttachment {
mime_type: mime_type.to_string(),
base64_data,
width: Some(resized.width),
height: Some(resized.height),
})
}
pub fn handle_paste_data(&mut self, data: Vec<u8>) -> Option<ImageAttachment> {
if let Some(image_data) = self.paste_handler.extract_image_data() {
self.process_image_paste(image_data).ok()
} else {
if data.len() >= 8 {
let magic = &data[..8];
if magic.starts_with(&[0x89, 0x50, 0x4E, 0x47]) || magic.starts_with(&[0xFF, 0xD8, 0xFF]) || magic.starts_with(&[0x47, 0x49, 0x46]) {
return self.process_image_paste(data).ok();
}
}
None
}
}
pub fn read_from_clipboard(&self) -> Result<ImageAttachment> {
let image = clipboard_image::read_image_from_clipboard()?;
let mime = clipboard_image::detect_image_mime_type(&image.bytes);
let resized = resize_image(&image.bytes, &self.resize_opts)
.unwrap_or_else(|_| {
ResizedImage {
bytes: image.bytes.clone(),
mime_type: mime.to_string(),
original_width: 0,
original_height: 0,
width: 0,
height: 0,
was_resized: false,
}
});
let base64_data = BASE64.encode(&resized.bytes);
Ok(ImageAttachment {
mime_type: resized.mime_type,
base64_data,
width: Some(resized.width),
height: Some(resized.height),
})
}
pub fn to_data_uri(bytes: &[u8], mime: &str) -> String {
let base64 = BASE64.encode(bytes);
format!("data:{};base64,{}", mime, base64)
}
}
impl Default for ImagePasteHandler {
fn default() -> Self {
Self::new()
}
}
pub struct FileAttachmentProcessor {
options: FileProcessorOptions,
}
impl FileAttachmentProcessor {
pub fn new() -> Self {
Self {
options: FileProcessorOptions::default(),
}
}
pub fn with_options(options: FileProcessorOptions) -> Self {
Self { options }
}
pub fn extract_file_paths(&self, message: &str) -> Vec<PathBuf> {
let mut paths = Vec::new();
let mut chars = message.chars().peekable();
while let Some(c) = chars.next() {
if c == '@' {
let mut path_str = String::new();
if chars.peek() == Some(&'"') || chars.peek() == Some(&'\'') {
let quote = chars.next().unwrap();
while let Some(&next) = chars.peek() {
if next == quote {
chars.next();
break;
}
path_str.push(chars.next().unwrap());
}
} else {
while let Some(&next) = chars.peek() {
if next.is_whitespace() {
break;
}
path_str.push(chars.next().unwrap());
}
}
if !path_str.is_empty() {
paths.push(PathBuf::from(&path_str));
}
}
}
paths
}
pub fn process_attachments(&self, paths: &[PathBuf]) -> Result<Vec<oxi_ai::ContentBlock>> {
let mut blocks = Vec::new();
for path in paths {
match self.process_single_file(path) {
Ok(content) => blocks.extend(content),
Err(e) => {
let error_text = format!("[Error reading file: {}: {}]", path.display(), e);
blocks.push(oxi_ai::ContentBlock::Text(oxi_ai::TextContent::new(error_text)));
}
}
}
Ok(blocks)
}
fn process_single_file(&self, path: &PathBuf) -> Result<Vec<oxi_ai::ContentBlock>> {
let data = fs::read(path)?;
let mime = self.detect_mime(path, &data);
if self.is_image_mime(&mime) {
self.process_image_file(path, &data, &mime)
} else {
self.process_text_file(path, &data)
}
}
fn detect_mime(&self, path: &PathBuf, data: &[u8]) -> String {
if data.len() >= 8 {
if data.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
return "image/png".to_string();
}
if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
return "image/jpeg".to_string();
}
if data.starts_with(&[0x47, 0x49, 0x46]) {
return "image/gif".to_string();
}
if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
return "image/webp".to_string();
}
}
path.extension()
.and_then(|e| e.to_str())
.map(|e| match e.to_lowercase().as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"svg" => "image/svg+xml",
"txt" | "md" | "rs" | "js" | "ts" | "py" | "json" | "html" | "css" | "xml" => "text/plain",
_ => "application/octet-stream",
})
.unwrap_or("application/octet-stream")
.to_string()
}
fn is_image_mime(&self, mime: &str) -> bool {
mime.starts_with("image/")
}
fn process_image_file(
&self,
_path: &PathBuf,
data: &[u8],
mime: &str,
) -> Result<Vec<oxi_ai::ContentBlock>> {
let resize_opts = ResizeOptions::new(self.options.max_image_width, self.options.max_image_height)
.max_bytes(self.options.max_image_bytes)
.jpeg_quality(self.options.jpeg_quality);
let resized = resize_image(data, &resize_opts).unwrap_or_else(|_| ResizedImage {
bytes: data.to_vec(),
mime_type: mime.to_string(),
original_width: 0,
original_height: 0,
width: 0,
height: 0,
was_resized: false,
});
let png_data = if resized.mime_type != "image/png" {
convert_to_png(&resized.bytes, &resized.mime_type)?
} else {
resized.bytes.clone()
};
let base64_data = BASE64.encode(&png_data);
Ok(vec![oxi_ai::ContentBlock::Image(oxi_ai::ImageContent::new(
base64_data,
resized.mime_type,
))])
}
fn process_text_file(&self, _path: &PathBuf, data: &[u8]) -> Result<Vec<oxi_ai::ContentBlock>> {
let content = String::from_utf8_lossy(&data);
Ok(vec![oxi_ai::ContentBlock::Text(oxi_ai::TextContent::new(
content.to_string(),
))])
}
pub fn process_message_with_attachments(
&self,
message: &str,
) -> Result<(String, Vec<oxi_ai::ContentBlock>)> {
let paths = self.extract_file_paths(message);
let blocks = self.process_attachments(&paths)?;
let mut cleaned = message.to_string();
for path in &paths {
let pattern = format!("@{}", path.display());
cleaned = cleaned.replace(&pattern, &format!("[Attached: {}]", path.display()));
}
Ok((cleaned, blocks))
}
}
impl Default for FileAttachmentProcessor {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod image_paste_tests {
use super::*;
#[test]
fn test_image_paste_handler_new() {
let handler = ImagePasteHandler::new();
assert_eq!(handler.paste_state(), PasteState::Normal);
}
#[test]
fn test_image_paste_handler_reset() {
let mut handler = ImagePasteHandler::new();
handler.reset();
assert_eq!(handler.paste_state(), PasteState::Normal);
}
#[test]
fn test_image_paste_handler_to_data_uri() {
let handler = ImagePasteHandler::new();
let data = b"hello world";
let base64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, data);
let expected = format!("data:text/plain;base64,{}", base64);
assert_eq!(expected.starts_with("data:text/plain;base64,"), true);
assert!(expected.contains("aGVsbG8gd29ybGQ=")); }
#[test]
fn test_image_paste_handler_to_data_uri_png() {
let png_header: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
let base64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &png_header);
let expected = format!("data:image/png;base64,{}", base64);
assert!(expected.starts_with("data:image/png;base64,"));
}
#[test]
fn test_image_paste_handler_to_data_uri_jpeg() {
let jpeg_header: Vec<u8> = vec![0xFF, 0xD8, 0xFF, 0xE0];
let base64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &jpeg_header);
let expected = format!("data:image/jpeg;base64,{}", base64);
assert!(expected.starts_with("data:image/jpeg;base64,"));
}
}
#[cfg(test)]
mod file_attachment_tests {
use super::*;
#[test]
fn test_extract_file_paths_simple() {
let processor = FileAttachmentProcessor::new();
let paths = processor.extract_file_paths("Check out @file.txt");
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].to_str().unwrap(), "file.txt");
}
#[test]
fn test_extract_file_paths_multiple() {
let processor = FileAttachmentProcessor::new();
let paths = processor.extract_file_paths("@a.txt @b.txt @c.txt");
assert_eq!(paths.len(), 3);
}
#[test]
fn test_extract_file_paths_quoted() {
let processor = FileAttachmentProcessor::new();
let paths = processor.extract_file_paths(r#"@"path with spaces.txt""#);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].to_str().unwrap(), "path with spaces.txt");
}
#[ignore] #[test]
fn test_extract_file_paths_single_quoted() {
let processor = FileAttachmentProcessor::new();
let paths = processor.extract_file_paths("Check 'file.txt'");
assert_eq!(paths.len(), 1);
}
#[test]
fn test_extract_file_paths_no_at() {
let processor = FileAttachmentProcessor::new();
let paths = processor.extract_file_paths("No file references here");
assert!(paths.is_empty());
}
#[test]
fn test_extract_file_paths_empty_after_at() {
let processor = FileAttachmentProcessor::new();
let paths = processor.extract_file_paths("Just @ followed by space");
assert!(paths.is_empty());
}
#[ignore] #[test]
fn test_extract_file_paths_unquoted_with_path_sep() {
let processor = FileAttachmentProcessor::new();
let paths = processor.extract_file_paths("Check src/main.rs");
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].to_str().unwrap(), "src/main.rs");
}
#[test]
fn test_file_attachment_processor_default() {
let processor = FileAttachmentProcessor::default();
let paths = processor.extract_file_paths("@test.txt");
assert_eq!(paths.len(), 1);
}
#[test]
fn test_file_attachment_processor_with_options() {
let opts = FileProcessorOptions::new()
.max_image_bytes(1024 * 1024)
.extract_frontmatter(false);
let processor = FileAttachmentProcessor::with_options(opts);
let paths = processor.extract_file_paths("@test.png");
assert_eq!(paths.len(), 1);
}
}
#[derive(Debug, Clone)]
pub struct ImageAttachment {
pub mime_type: String,
pub base64_data: String,
pub width: Option<u32>,
pub height: Option<u32>,
}
impl ImageAttachment {
pub fn from_data_uri(uri: &str) -> Option<Self> {
if !uri.starts_with("data:") {
return None;
}
let (mime_part, data_part) = uri.split_once(',')?;
let mime_type = mime_part
.strip_prefix("data:")
.and_then(|s| s.split(';').next())
.unwrap_or("image/png")
.to_string();
let base64_data = data_part.trim().to_string();
if BASE64.decode(&base64_data).is_err() {
return None;
}
Some(Self {
mime_type,
base64_data,
width: None,
height: None,
})
}
pub fn extension(&self) -> &'static str {
match self.mime_type.as_str() {
"image/png" => "png",
"image/jpeg" | "image/jpg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
_ => "png",
}
}
pub fn detect_mime_type(data: &[u8]) -> &'static str {
if data.len() >= 8 {
if data.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
return "image/png";
}
if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
return "image/jpeg";
}
if data.starts_with(&[0x47, 0x49, 0x46]) {
return "image/gif";
}
if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
return "image/webp";
}
}
"image/png"
}
pub fn from_bytes(data: Vec<u8>) -> Option<Self> {
let mime_type = Self::detect_mime_type(&data);
let base64_data = BASE64.encode(&data);
Some(Self {
mime_type: mime_type.to_string(),
base64_data,
width: None,
height: None,
})
}
}
pub struct SessionPersistence {
session_dir: PathBuf,
last_save: RwLock<Instant>,
last_user_message: RwLock<String>,
}
impl SessionPersistence {
pub fn new() -> Option<Self> {
let home = std::env::var("HOME").ok()?;
let session_dir = PathBuf::from(home).join(".oxi").join("sessions");
fs::create_dir_all(&session_dir).ok()?;
Some(Self {
session_dir,
last_save: RwLock::new(Instant::now()),
last_user_message: RwLock::new(String::new()),
})
}
fn session_file_path(&self, session_id: &str) -> PathBuf {
self.session_dir.join(format!("{}.jsonl", session_id))
}
pub fn save_user_message(
&self,
session_id: &str,
content: &str,
timestamp: i64,
) -> Result<(), std::io::Error> {
use std::io::Write;
let path = self.session_file_path(session_id);
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
let entry =
serde_json::json!({"type": "user", "content": content, "timestamp": timestamp });
writeln!(file, "{}", entry)?;
*self.last_save.write().unwrap() = Instant::now();
Ok(())
}
pub fn save_assistant_message(
&self,
session_id: &str,
content: &str,
timestamp: i64,
) -> Result<(), std::io::Error> {
use std::io::Write;
let path = self.session_file_path(session_id);
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
let entry =
serde_json::json!({"type": "assistant", "content": content, "timestamp": timestamp });
writeln!(file, "{}", entry)?;
*self.last_save.write().unwrap() = Instant::now();
Ok(())
}
pub fn load_session(&self, session_id: &str) -> Result<Vec<SessionEntry>, std::io::Error> {
let path = self.session_file_path(session_id);
let file = File::open(&path)?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
for line in reader.lines() {
if let Ok(entry) = serde_json::from_str::<SessionEntry>(&line?) {
entries.push(entry);
}
}
Ok(entries)
}
pub fn session_exists(&self, session_id: &str) -> bool {
self.session_file_path(session_id).exists()
}
pub fn should_auto_save(&self) -> bool {
self.last_save.read().unwrap().elapsed() >= Duration::from_secs(AUTO_SAVE_INTERVAL_SECS)
}
pub fn set_last_user_message(&self, msg: String) {
*self.last_user_message.write().unwrap() = msg;
}
pub fn get_last_user_message(&self) -> String {
self.last_user_message.read().unwrap().clone()
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SessionEntry {
#[serde(rename = "type")]
pub entry_type: String,
pub content: String,
pub timestamp: i64,
}
pub struct KeybindingHints {
expanded: bool,
}
impl KeybindingHints {
pub fn new() -> Self {
Self { expanded: false }
}
pub fn compact_display(&self) -> String {
let hints = vec![
("Ctrl+C", "quit"),
("/clear", "clear"),
("/", "commands"),
("!", "bash"),
];
hints
.iter()
.map(|(key, desc)| format!("[{}] {}", key, desc))
.collect::<Vec<_>>()
.join(" • ")
}
pub fn expanded_display(&self) -> String {
let hints = vec![
("Ctrl+C", "quit"),
("Ctrl+L", "clear screen"),
("Ctrl+U", "clear line"),
("Ctrl+A", "go to line start"),
("Ctrl+E", "go to line end"),
("/model", "select model"),
("/clear", "clear chat"),
("/compact", "compact context"),
("/undo", "undo"),
("/redo", "redo"),
("/session", "session info"),
("/export", "export session"),
("/settings", "show settings"),
("/help", "show help"),
("/new", "new session"),
("!", "bash command"),
("!!", "bash (excluded)"),
("PageUp/Down", "scroll chat"),
("Mouse", "scroll chat"),
];
hints
.iter()
.map(|(key, desc)| format!(" {:20} {}", key, desc))
.collect::<Vec<_>>()
.join("\n")
}
pub fn toggle(&mut self) {
self.expanded = !self.expanded;
}
pub fn is_expanded(&self) -> bool {
self.expanded
}
}
impl Default for KeybindingHints {
fn default() -> Self {
Self::new()
}
}
const AUTO_SAVE_INTERVAL_SECS: u64 = 30;
#[derive(Debug, Clone)]
pub struct ModelSelectorEntry {
pub full_id: String,
pub display_name: String,
pub provider: String,
pub selected: bool,
}
pub struct ModelSelectorOverlay {
models: Vec<ModelSelectorEntry>,
filter: String,
cursor: usize,
visible: bool,
}
impl ModelSelectorOverlay {
pub fn new(models: Vec<ModelSelectorEntry>) -> Self {
Self {
models,
filter: String::new(),
cursor: 0,
visible: false,
}
}
pub fn from_models(models: &[crate::model_resolver::Model], current_model_id: &str) -> Self {
let entries: Vec<ModelSelectorEntry> = models
.iter()
.map(|m| {
let full_id = m.full_id();
ModelSelectorEntry {
full_id: full_id.clone(),
display_name: m.name.clone().unwrap_or_else(|| m.id.clone()),
provider: m.provider.clone(),
selected: full_id == current_model_id,
}
})
.collect();
Self::new(entries)
}
pub fn show(&mut self) {
self.visible = true;
self.filter.clear();
self.cursor = 0;
self.reposition_cursor_to_selected();
}
pub fn hide(&mut self) {
self.visible = false;
self.filter.clear();
}
pub fn is_visible(&self) -> bool {
self.visible
}
pub fn filter(&self) -> &str {
&self.filter
}
pub fn cursor_position(&self) -> usize {
self.cursor
}
pub fn filtered_models(&self) -> Vec<&ModelSelectorEntry> {
if self.filter.is_empty() {
return self.models.iter().collect();
}
let query = self.filter.to_lowercase();
self.models
.iter()
.filter(|m| {
let name_lower = m.display_name.to_lowercase();
let id_lower = m.full_id.to_lowercase();
let provider_lower = m.provider.to_lowercase();
fuzzy_match(&query, &name_lower)
|| fuzzy_match(&query, &id_lower)
|| fuzzy_match(&query, &provider_lower)
})
.collect()
}
pub fn selected_model(&self) -> Option<&ModelSelectorEntry> {
let filtered = self.filtered_models();
filtered.into_iter().nth(self.cursor)
}
pub fn cursor_up(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn cursor_down(&mut self) {
let count = self.filtered_models().len();
if self.cursor + 1 < count {
self.cursor += 1;
}
}
pub fn type_char(&mut self, c: char) {
self.filter.push(c);
self.cursor = 0;
}
pub fn backspace(&mut self) {
self.filter.pop();
self.cursor = 0;
}
pub fn confirm(&mut self) -> Option<String> {
let choice = self.selected_model().map(|m| m.full_id.clone());
self.hide();
choice
}
pub fn cancel(&mut self) {
self.hide();
}
fn reposition_cursor_to_selected(&mut self) {
let filtered = self.filtered_models();
for (i, m) in filtered.iter().enumerate() {
if m.selected {
self.cursor = i;
return;
}
}
self.cursor = 0;
}
}
fn fuzzy_match(query: &str, text: &str) -> bool {
let mut qi = query.chars();
let mut current = qi.next();
for ch in text.chars() {
if let Some(qc) = current {
if ch == qc {
current = qi.next();
}
}
}
current.is_none()
}
impl Default for ModelSelectorOverlay {
fn default() -> Self {
Self::new(Vec::new())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DoubleEscapeAction {
Quit,
Clear,
}
pub struct DoubleEscapeTracker {
last_escape: Option<Instant>,
interval_ms: u64,
action: DoubleEscapeAction,
}
impl DoubleEscapeTracker {
pub fn new() -> Self {
Self {
last_escape: None,
interval_ms: 500,
action: DoubleEscapeAction::Quit,
}
}
pub fn with_interval(interval_ms: u64) -> Self {
Self {
last_escape: None,
interval_ms,
action: DoubleEscapeAction::Quit,
}
}
pub fn set_action(&mut self, action: DoubleEscapeAction) {
self.action = action;
}
pub fn action(&self) -> DoubleEscapeAction {
self.action
}
pub fn press_escape(&mut self) -> bool {
let now = Instant::now();
if let Some(last) = self.last_escape {
if now.duration_since(last).as_millis() as u64 <= self.interval_ms {
self.last_escape = None;
return true; }
}
self.last_escape = Some(now);
false
}
pub fn reset(&mut self) {
self.last_escape = None;
}
}
impl Default for DoubleEscapeTracker {
fn default() -> Self {
Self::new()
}
}
pub struct CompactionProgressTracker {
active: bool,
messages_compacted: usize,
total_messages: usize,
cancelled: bool,
started_at: Option<Instant>,
}
impl CompactionProgressTracker {
pub fn new() -> Self {
Self {
active: false,
messages_compacted: 0,
total_messages: 0,
cancelled: false,
started_at: None,
}
}
pub fn start(&mut self, total_messages: usize) {
self.active = true;
self.messages_compacted = 0;
self.total_messages = total_messages;
self.cancelled = false;
self.started_at = Some(Instant::now());
}
pub fn update(&mut self, messages_compacted: usize) {
self.messages_compacted = messages_compacted;
}
pub fn finish(&mut self) {
self.active = false;
self.started_at = None;
}
pub fn cancel(&mut self) {
if self.active {
self.cancelled = true;
}
}
pub fn is_cancelled(&self) -> bool {
self.cancelled
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn progress_text(&self) -> String {
if !self.active {
return String::new();
}
let elapsed = self
.started_at
.map(|t| t.elapsed().as_secs())
.unwrap_or(0);
let spinner = match elapsed % 4 {
0 => "|",
1 => "/",
2 => "-",
3 => "\\",
_ => "|",
};
let cancel_hint = if self.cancelled {
" (cancelling...)"
} else {
" (Esc to cancel)"
};
if self.total_messages > 0 {
format!(
"{} Compacting... {}/{} messages{}",
spinner, self.messages_compacted, self.total_messages, cancel_hint
)
} else {
format!("{} Compacting context...{}", spinner, cancel_hint)
}
}
}
impl Default for CompactionProgressTracker {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct ExtensionShortcut {
pub id: String,
pub extension_name: String,
pub key_sequence: String,
pub label: String,
pub enabled: bool,
}
pub struct ExtensionShortcutRegistry {
shortcuts: Vec<ExtensionShortcut>,
}
impl ExtensionShortcutRegistry {
pub fn new() -> Self {
Self {
shortcuts: Vec::new(),
}
}
pub fn register(
&mut self,
id: impl Into<String>,
extension_name: impl Into<String>,
key_sequence: impl Into<String>,
label: impl Into<String>,
) {
let id = id.into();
let shortcut = ExtensionShortcut {
id: id.clone(),
extension_name: extension_name.into(),
key_sequence: key_sequence.into(),
label: label.into(),
enabled: true,
};
if let Some(pos) = self.shortcuts.iter().position(|s| s.id == id) {
self.shortcuts[pos] = shortcut;
} else {
self.shortcuts.push(shortcut);
}
}
pub fn unregister(&mut self, id: &str) {
self.shortcuts.retain(|s| s.id != id);
}
pub fn set_enabled(&mut self, id: &str, enabled: bool) {
if let Some(s) = self.shortcuts.iter_mut().find(|s| s.id == id) {
s.enabled = enabled;
}
}
pub fn shortcuts(&self) -> &[ExtensionShortcut] {
&self.shortcuts
}
pub fn find_matching(
&self,
key: &crossterm::event::KeyEvent,
) -> Option<&ExtensionShortcut> {
let seq_str = key_event_to_sequence_string(key);
self.shortcuts
.iter()
.find(|s| s.enabled && s.key_sequence == seq_str)
}
}
impl Default for ExtensionShortcutRegistry {
fn default() -> Self {
Self::new()
}
}
fn key_event_to_sequence_string(key: &crossterm::event::KeyEvent) -> String {
use crossterm::event::KeyCode;
let mut parts = Vec::new();
let mods = key.modifiers;
if mods.contains(crossterm::event::KeyModifiers::CONTROL) {
parts.push("ctrl");
}
if mods.contains(crossterm::event::KeyModifiers::ALT) {
parts.push("alt");
}
if mods.contains(crossterm::event::KeyModifiers::SHIFT) {
parts.push("shift");
}
let key_name = match key.code {
KeyCode::Char(c) => {
if mods.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) {
c.to_ascii_lowercase().to_string()
} else {
c.to_string()
}
}
KeyCode::Enter => "enter".to_string(),
KeyCode::Esc => "escape".to_string(),
KeyCode::Tab => "tab".to_string(),
KeyCode::Backspace => "backspace".to_string(),
KeyCode::Delete => "delete".to_string(),
KeyCode::Up => "up".to_string(),
KeyCode::Down => "down".to_string(),
KeyCode::Left => "left".to_string(),
KeyCode::Right => "right".to_string(),
KeyCode::Home => "home".to_string(),
KeyCode::End => "end".to_string(),
KeyCode::PageUp => "pageup".to_string(),
KeyCode::PageDown => "pagedown".to_string(),
KeyCode::F(n) => format!("f{}", n),
KeyCode::Insert => "insert".to_string(),
KeyCode::Null => "null".to_string(),
_ => "".to_string(),
};
parts.push(&key_name);
parts.join("+")
}
pub struct TerminalSuspendHandler {
suspended: bool,
}
impl TerminalSuspendHandler {
pub fn new() -> Self {
Self { suspended: false }
}
pub fn is_suspended(&self) -> bool {
self.suspended
}
pub fn prepare_suspend(&mut self) -> Result<()> {
use std::io::{self, Write};
crossterm::execute!(io::stdout(), crossterm::cursor::Show)?;
crossterm::execute!(io::stdout(), crossterm::event::DisableMouseCapture)?;
crossterm::execute!(io::stdout(), crossterm::terminal::LeaveAlternateScreen)?;
io::stdout().flush()?;
crossterm::terminal::disable_raw_mode()?;
self.suspended = true;
Ok(())
}
pub fn resume_after_suspend(&mut self) -> Result<()> {
use std::io::{self, Write};
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(io::stdout(), crossterm::terminal::EnterAlternateScreen)?;
crossterm::execute!(io::stdout(), crossterm::cursor::Hide)?;
crossterm::execute!(io::stdout(), crossterm::event::EnableMouseCapture)?;
io::stdout().flush()?;
self.suspended = false;
Ok(())
}
#[cfg(unix)]
pub fn send_sigtstp(&self) -> Result<()> {
unsafe {
libc::raise(libc::SIGTSTP);
}
Ok(())
}
#[cfg(not(unix))]
pub fn send_sigtstp(&self) -> Result<()> {
Ok(())
}
}
impl Default for TerminalSuspendHandler {
fn default() -> Self {
Self::new()
}
}
pub struct ClipboardImagePasteHandler {
inner: ImagePasteHandler,
}
impl ClipboardImagePasteHandler {
pub fn new() -> Self {
Self {
inner: ImagePasteHandler::new(),
}
}
pub fn paste_from_clipboard(&mut self) -> Option<ImageAttachment> {
match self.inner.read_from_clipboard() {
Ok(attachment) => Some(attachment),
Err(e) => {
tracing::debug!("Clipboard paste failed: {}", e);
None
}
}
}
pub fn process_raw(&mut self, data: Vec<u8>) -> Option<ImageAttachment> {
self.inner.handle_paste_data(data)
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
impl Default for ClipboardImagePasteHandler {
fn default() -> Self {
Self::new()
}
}
pub struct InteractiveModeState {
pub model_selector: ModelSelectorOverlay,
pub double_escape: DoubleEscapeTracker,
pub compaction_progress: CompactionProgressTracker,
pub extension_shortcuts: ExtensionShortcutRegistry,
pub suspend_handler: TerminalSuspendHandler,
pub clipboard_paste: ClipboardImagePasteHandler,
pub pending_image: Option<ImageAttachment>,
}
impl InteractiveModeState {
pub fn new() -> Self {
Self {
model_selector: ModelSelectorOverlay::default(),
double_escape: DoubleEscapeTracker::new(),
compaction_progress: CompactionProgressTracker::new(),
extension_shortcuts: ExtensionShortcutRegistry::new(),
suspend_handler: TerminalSuspendHandler::new(),
clipboard_paste: ClipboardImagePasteHandler::new(),
pending_image: None,
}
}
pub fn with_models(models: Vec<ModelSelectorEntry>) -> Self {
Self {
model_selector: ModelSelectorOverlay::new(models),
..Self::new()
}
}
pub fn handle_ctrl_p(&mut self) -> Option<String> {
if self.model_selector.is_visible() {
self.model_selector.cursor_down();
} else {
self.model_selector.show();
}
None
}
pub fn handle_shift_ctrl_p(&mut self) -> Option<String> {
if self.model_selector.is_visible() {
self.model_selector.cursor_up();
}
None
}
pub fn handle_model_confirm(&mut self) -> Option<String> {
if self.model_selector.is_visible() {
self.model_selector.confirm()
} else {
None
}
}
pub fn handle_model_cancel(&mut self) -> bool {
if self.model_selector.is_visible() {
self.model_selector.cancel();
true
} else {
false
}
}
pub fn handle_escape(&mut self) -> bool {
self.double_escape.press_escape()
}
pub fn reset_escape(&mut self) {
self.double_escape.reset();
}
pub fn handle_compaction_escape(&mut self) -> bool {
if self.compaction_progress.is_active() {
self.compaction_progress.cancel();
true
} else {
false
}
}
pub fn start_compaction(&mut self, total_messages: usize) {
self.compaction_progress.start(total_messages);
}
pub fn update_compaction(&mut self, messages_compacted: usize) {
self.compaction_progress.update(messages_compacted);
}
pub fn finish_compaction(&mut self) {
self.compaction_progress.finish();
}
pub fn handle_suspend(&mut self) -> Result<()> {
self.suspend_handler.prepare_suspend()?;
self.suspend_handler.send_sigtstp()?;
self.suspend_handler.resume_after_suspend()?;
Ok(())
}
pub fn handle_clipboard_paste(&mut self) -> Option<ImageAttachment> {
let attachment = self.clipboard_paste.paste_from_clipboard();
if attachment.is_some() {
self.pending_image = attachment.clone();
}
attachment
}
pub fn take_pending_image(&mut self) -> Option<ImageAttachment> {
self.pending_image.take()
}
pub fn check_extension_shortcut(
&self,
key: &crossterm::event::KeyEvent,
) -> Option<&ExtensionShortcut> {
self.extension_shortcuts.find_matching(key)
}
pub fn register_extension_shortcut(
&mut self,
id: impl Into<String>,
extension_name: impl Into<String>,
key_sequence: impl Into<String>,
label: impl Into<String>,
) {
self.extension_shortcuts
.register(id, extension_name, key_sequence, label);
}
pub fn unregister_extension_shortcut(&mut self, id: &str) {
self.extension_shortcuts.unregister(id);
}
}
impl Default for InteractiveModeState {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
enum UiEvent {
Start,
Thinking,
TextDelta(String),
ToolCall {
id: String,
name: String,
arguments: String,
},
ToolStart {
tool_name: String,
},
ToolResult {
tool_name: String,
content: String,
is_error: bool,
},
Complete,
Error(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InteractiveState {
Input,
Thinking,
ToolExecution,
Display,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SlashCommand {
Model { search: Option<String> },
Clear,
Compact { custom_instructions: Option<String> },
Undo,
Redo,
Branch,
Session,
Export { path: Option<String> },
Settings,
Help,
Quit,
Name { name: String },
Copy,
New,
Reload,
Clone,
Resume { session_id: Option<String> },
Import { path: String },
Login { provider: Option<String> },
Logout { provider: Option<String> },
Changelog,
Hotkeys,
Unknown { raw: String },
}
impl SlashCommand {
pub fn parse(input: &str) -> Self {
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() {
"/model" => SlashCommand::Model {
search: arg.map(|s| s.to_string()),
},
"/clear" => SlashCommand::Clear,
"/compact" => SlashCommand::Compact {
custom_instructions: arg.map(|s| s.to_string()),
},
"/undo" => SlashCommand::Undo,
"/redo" => SlashCommand::Redo,
"/branch" | "/fork" | "/tree" => SlashCommand::Branch,
"/session" | "/resume" => SlashCommand::Session,
"/export" => SlashCommand::Export {
path: arg.map(|s| s.to_string()),
},
"/import" => SlashCommand::Import {
path: arg.unwrap_or_default().to_string(),
},
"/settings" => SlashCommand::Settings,
"/help" | "/?" => SlashCommand::Help,
"/quit" | "/exit" | "/q" => SlashCommand::Quit,
"/name" => SlashCommand::Name {
name: arg.unwrap_or_default().to_string(),
},
"/copy" => SlashCommand::Copy,
"/new" => SlashCommand::New,
"/reload" => SlashCommand::Reload,
"/clone" => SlashCommand::Clone,
"/login" => SlashCommand::Login {
provider: arg.map(|s| s.to_string()),
},
"/logout" => SlashCommand::Logout {
provider: arg.map(|s| s.to_string()),
},
"/changelog" => SlashCommand::Changelog,
"/hotkeys" => SlashCommand::Hotkeys,
_ => SlashCommand::Unknown {
raw: trimmed.to_string(),
},
}
}
pub fn description(&self) -> &'static str {
match self {
SlashCommand::Model { .. } => "Select model",
SlashCommand::Clear => "Clear conversation history",
SlashCommand::Compact { .. } => "Compact context",
SlashCommand::Undo => "Undo last exchange",
SlashCommand::Redo => "Redo last undone exchange",
SlashCommand::Branch => "Navigate session tree",
SlashCommand::Session => "Show session info",
SlashCommand::Export { .. } => "Export session",
SlashCommand::Settings => "Open settings",
SlashCommand::Help => "Show help",
SlashCommand::Quit => "Quit oxi",
SlashCommand::Name { .. } => "Set session name",
SlashCommand::Copy => "Copy last response",
SlashCommand::New => "Start new session",
SlashCommand::Reload => "Reload settings/extensions",
SlashCommand::Clone => "Duplicate current session",
SlashCommand::Resume { .. } => "Resume a different session",
SlashCommand::Import { .. } => "Import session from JSONL",
SlashCommand::Login { .. } => "Initiate OAuth login",
SlashCommand::Logout { .. } => "Remove provider auth",
SlashCommand::Changelog => "Show changelog",
SlashCommand::Hotkeys => "Show keyboard shortcuts",
SlashCommand::Unknown { .. } => "Unknown command",
}
}
}
pub async fn run_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 sm = SessionManager::create(&cwd, None);
let agent_session = AgentSession::new(
app.agent(),
settings,
sm,
cwd,
);
let (ui_tx, mut ui_rx) = mpsc::channel::<UiEvent>(256);
let (prompt_tx, mut prompt_rx) = mpsc::channel::<String>(16);
let agent_for_thread = agent_session.agent_ref();
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 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::ToolStart { 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 a = Arc::clone(&agent_for_thread);
let _ = a.run_with_channel(prompt, event_tx).await;
let _ = forwarder.await;
}
})
.await;
});
});
let mut chat_view = ChatView::new(theme.clone());
let mut input = Input::with_placeholder("Type a message... (Ctrl+C to quit)");
input.on_focus();
let mut state = InteractiveState::Input;
let mut session = InteractiveSession::new();
let mut undo_stack: Vec<crate::ChatMessage> = Vec::new();
let mut imode = InteractiveModeState::new();
use std::io::{self, Write};
crossterm::execute!(io::stdout(), crossterm::terminal::EnterAlternateScreen)?;
crossterm::execute!(io::stdout(), crossterm::cursor::Hide)?;
crossterm::execute!(io::stdout(), crossterm::event::EnableMouseCapture)?;
let mut running = true;
while running {
let (width, height) = crossterm::terminal::size().unwrap_or((80, 24));
let input_height: u16 = 3;
let chat_height = height.saturating_sub(input_height);
let mut surface = Surface::new(width, height);
let chat_area = Rect::new(0, 0, width, chat_height);
chat_view.render(&mut surface, chat_area);
if chat_height < height {
for col in 0..width {
surface.set(
chat_height,
col,
oxi_tui::Cell::new('\u{2500}').with_fg(theme.colors.border),
);
}
surface.set(
chat_height + 1,
0,
oxi_tui::Cell::new('\u{276F}').with_fg(theme.colors.primary),
);
let input_area = Rect::new(2, chat_height + 1, width.saturating_sub(4), 1);
input.render(&mut surface, input_area);
let status_text = match state {
InteractiveState::Thinking => "\u{25CF} thinking...",
InteractiveState::ToolExecution => "\u{2699} executing...",
InteractiveState::Display | InteractiveState::Input => "",
};
let status_fg = if state == InteractiveState::Thinking
|| state == InteractiveState::ToolExecution
{
theme.colors.warning
} else {
theme.colors.muted
};
for (i, ch) in status_text.chars().enumerate() {
let col = width as usize - status_text.len() + i;
if col < width as usize {
surface.set(
chat_height + 2,
col as u16,
oxi_tui::Cell::new(ch).with_fg(status_fg),
);
}
}
}
if imode.model_selector.is_visible() {
render_model_selector_overlay(
&mut surface,
&imode.model_selector,
width,
height,
&theme,
);
}
if imode.compaction_progress.is_active() {
let progress_text = imode.compaction_progress.progress_text();
if !progress_text.is_empty() {
for (i, ch) in progress_text.chars().enumerate() {
let col = i;
if col < width as usize {
surface.set(
chat_height,
col as u16,
oxi_tui::Cell::new(ch).with_fg(theme.colors.warning),
);
}
}
}
}
render_surface_to_terminal(&surface, width, height);
io::stdout().flush()?;
let timeout = std::time::Duration::from_millis(33);
if crossterm::event::poll(timeout)? {
let event = crossterm::event::read()?;
match event {
crossterm::event::Event::Key(key) => {
match key.code {
crossterm::event::KeyCode::Enter => {
if imode.model_selector.is_visible() {
if let Some(model_id) = imode.handle_model_confirm() {
match agent_session.set_model(&model_id) {
Ok(()) => {
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![ContentBlockDisplay::Text {
content: format!("Switched to model: {}", model_id),
}],
timestamp: now_millis(),
});
}
Err(e) => {
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![ContentBlockDisplay::Text {
content: format!("Error switching model: {}", e),
}],
timestamp: now_millis(),
});
}
}
}
continue;
}
if state == InteractiveState::Input {
let value = input.value().to_string();
if !value.is_empty() {
if value.starts_with('/') {
let cmd = SlashCommand::parse(&value);
match cmd {
SlashCommand::Clear => {
chat_view = ChatView::new(theme.clone());
session = InteractiveSession::new();
undo_stack.clear();
input.clear();
continue;
}
SlashCommand::Quit => {
running = false;
input.clear();
continue;
}
SlashCommand::Help => {
let help_text = format_help();
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: help_text,
},
],
timestamp: now_millis(),
});
input.clear();
continue;
}
SlashCommand::Model { search } => {
let model_info = format!(
"Current model: {}\n\
Use /model <provider/model> to switch.",
agent_session.model_id(),
);
if let Some(query) = search {
match agent_session.set_model(&query) {
Ok(()) => {
chat_view.add_message(
ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: format!(
"Switched to model: {}",
query
),
},
],
timestamp: now_millis(),
},
);
}
Err(e) => {
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: format!(
"Error switching model: {}",
e
),
},
],
timestamp: now_millis(),
});
}
}
} else {
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: model_info,
},
],
timestamp: now_millis(),
});
}
input.clear();
continue;
}
SlashCommand::Session => {
let info = format_session_info(&session);
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text { content: info },
],
timestamp: now_millis(),
});
input.clear();
continue;
}
SlashCommand::Compact {
custom_instructions,
} => {
let msg = if let Some(ci) = &custom_instructions {
format!(
"Compaction requested with instructions: {}\n\
(Compaction is automatic when context exceeds threshold.)",
ci
)
} else {
"Compaction requested.\n\
(Compaction is automatic when context exceeds threshold.)"
.to_string()
};
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text { content: msg },
],
timestamp: now_millis(),
});
input.clear();
continue;
}
SlashCommand::Undo => {
if session.messages.len() >= 2 {
let last_assistant = session.messages.pop();
let last_user = session.messages.pop();
if let (Some(u), Some(a)) =
(last_user, last_assistant)
{
undo_stack.push(u);
undo_stack.push(a);
}
rebuild_chat_view(
&mut chat_view,
&session,
&theme,
);
}
input.clear();
continue;
}
SlashCommand::Redo => {
if undo_stack.len() >= 2 {
let user_msg = undo_stack.pop();
let assistant_msg = undo_stack.pop();
if let (Some(a), Some(u)) =
(assistant_msg, user_msg)
{
session.messages.push(u);
session.messages.push(a);
}
rebuild_chat_view(
&mut chat_view,
&session,
&theme,
);
}
input.clear();
continue;
}
SlashCommand::Branch => {
let msg = format!(
"Session has {} messages.\n\
Branch navigation coming soon.",
session.messages.len()
);
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text { content: msg },
],
timestamp: now_millis(),
});
input.clear();
continue;
}
SlashCommand::Export { path } => {
let json = export_session_json(&session);
let export_path =
path.clone().unwrap_or_else(|| {
"oxi-session.json".to_string()
});
match std::fs::write(&export_path, &json) {
Ok(()) => {
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: format!(
"Session exported to {}",
export_path
),
},
],
timestamp: now_millis(),
});
}
Err(e) => {
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: format!(
"Export failed: {}",
e
),
},
],
timestamp: now_millis(),
});
}
}
input.clear();
continue;
}
SlashCommand::Settings => {
let settings_info = format!(
"Model: {}\n\
Thinking Level: {:?}\n\
Temperature: {}\n\
Max Tokens: {}\n\
Auto-compaction: {}\n\
Tool Timeout: {}s",
agent_session.model_id(),
agent_session.thinking_level(),
"default",
"default",
agent_session.auto_compaction_enabled(),
"default",
);
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: settings_info,
},
],
timestamp: now_millis(),
});
input.clear();
continue;
}
SlashCommand::Copy => {
let last_text = session
.messages
.iter()
.rev()
.find(|m| m.role == "assistant")
.map(|m| m.content.clone())
.unwrap_or_default();
let _ = copy_to_clipboard(&last_text);
input.clear();
continue;
}
SlashCommand::New => {
chat_view = ChatView::new(theme.clone());
session = InteractiveSession::new();
undo_stack.clear();
agent_session.reset();
input.clear();
continue;
}
SlashCommand::Name { name } => {
if !name.is_empty() {
session.name = Some(name.clone());
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: format!(
"Session named: {}",
name
),
},
],
timestamp: now_millis(),
});
}
input.clear();
continue;
}
SlashCommand::Reload => {
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: "Settings, extensions, skills, and themes reloaded.\n\n(Note: Full hot-reload will be available in a future version.)".to_string(),
},
],
timestamp: now_millis(),
});
input.clear();
continue;
}
SlashCommand::Clone => {
let clone_result = clone_session(&session).map_or_else(
|e| format!("Failed to clone session: {}", e),
|new_id| format!("Session cloned with new ID: {}", new_id),
);
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: clone_result,
},
],
timestamp: now_millis(),
});
input.clear();
continue;
}
SlashCommand::Resume { session_id } => {
let resume_result = if let Some(id) = session_id {
resume_session(&id).map_or_else(
|e| format!("Failed to resume session {}: {}", id, e),
|_| format!("Resumed session: {}", id),
)
} else {
list_available_sessions()
};
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: resume_result,
},
],
timestamp: now_millis(),
});
input.clear();
continue;
}
SlashCommand::Import { path } => {
if path.is_empty() {
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: "Usage: /import <path-to-jsonl-file>".to_string(),
},
],
timestamp: now_millis(),
});
} else {
let import_result = import_session_from_jsonl(&path).map_or_else(
|e| format!("Import failed: {}", e),
|new_session| {
session = new_session;
rebuild_chat_view(&mut chat_view, &session, &theme);
format!("Imported session from {}", path)
},
);
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: import_result,
},
],
timestamp: now_millis(),
});
}
input.clear();
continue;
}
SlashCommand::Login { provider } => {
let login_result = if let Some(p) = provider {
match initiate_login(&p) {
Ok(msg) => msg,
Err(e) => format!("Login failed: {}", e),
}
} else {
"Usage: /login <provider>\nSupported: anthropic, openai, github".to_string()
};
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: login_result,
},
],
timestamp: now_millis(),
});
input.clear();
continue;
}
SlashCommand::Logout { provider } => {
let logout_result = if let Some(p) = provider {
match remove_auth(&p) {
Ok(()) => format!("Logged out from {}", p),
Err(e) => format!("Logout failed: {}", e),
}
} else {
"Usage: /logout <provider>\nSupported: anthropic, openai, github".to_string()
};
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: logout_result,
},
],
timestamp: now_millis(),
});
input.clear();
continue;
}
SlashCommand::Changelog => {
let changelog_text = get_changelog_display();
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: changelog_text,
},
],
timestamp: now_millis(),
});
input.clear();
continue;
}
SlashCommand::Hotkeys => {
let hints = KeybindingHints::new();
let hotkeys_text = hints.expanded_display();
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: format!("Keyboard Shortcuts:\n\n{}", hotkeys_text),
},
],
timestamp: now_millis(),
});
input.clear();
continue;
}
SlashCommand::Unknown { raw } => {
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: format!(
"Unknown command: {}\n\
Type /help for available commands.",
raw
),
},
],
timestamp: now_millis(),
});
input.clear();
continue;
}
}
} else if value.starts_with('!') {
let is_excluded = value.starts_with("!!");
let command = if is_excluded {
value[2..].trim().to_string()
} else {
value[1..].trim().to_string()
};
if !command.is_empty() {
let output = run_bash_command(&command);
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![ContentBlockDisplay::Text {
content: format!("$ {}\n{}", command, output),
}],
timestamp: now_millis(),
});
}
input.clear();
continue;
} else {
session.add_user_message(value.clone());
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::User,
content_blocks: vec![ContentBlockDisplay::Text {
content: value.clone(),
}],
timestamp: now_millis(),
});
chat_view.start_streaming();
state = InteractiveState::Thinking;
let _ = prompt_tx.send(value).await;
input.clear();
}
}
}
}
crossterm::event::KeyCode::Char('c')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
running = false;
}
crossterm::event::KeyCode::Char('p')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
&& !key.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) =>
{
if imode.model_selector.is_visible() {
imode.model_selector.cursor_down();
} else {
imode.model_selector.show();
}
}
crossterm::event::KeyCode::Char('P')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
&& key.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) =>
{
if imode.model_selector.is_visible() {
imode.model_selector.cursor_up();
}
}
crossterm::event::KeyCode::Char('z')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
if let Err(e) = imode.handle_suspend() {
tracing::warn!("Suspend failed: {}", e);
}
}
crossterm::event::KeyCode::Char('v')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
if let Some(attachment) = imode.handle_clipboard_paste() {
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![ContentBlockDisplay::Text {
content: format!(
"[Image pasted: {} ({}x{})]",
attachment.mime_type,
attachment.width.map(|w| w.to_string()).unwrap_or_else(|| "?".to_string()),
attachment.height.map(|h| h.to_string()).unwrap_or_else(|| "?".to_string())
),
}],
timestamp: now_millis(),
});
}
}
crossterm::event::KeyCode::Esc => {
if imode.handle_model_cancel() {
}
else if imode.handle_compaction_escape() {
}
else if imode.handle_escape() {
match imode.double_escape.action() {
DoubleEscapeAction::Quit => {
running = false;
}
DoubleEscapeAction::Clear => {
input.clear();
}
}
}
}
crossterm::event::KeyCode::PageUp => {
chat_view.scroll_up(10);
}
crossterm::event::KeyCode::PageDown => {
chat_view.scroll_down(10);
}
_ => {
if imode.model_selector.is_visible() {
match key.code {
crossterm::event::KeyCode::Up => {
imode.model_selector.cursor_up();
}
crossterm::event::KeyCode::Down => {
imode.model_selector.cursor_down();
}
crossterm::event::KeyCode::Backspace => {
imode.model_selector.backspace();
}
crossterm::event::KeyCode::Char(c) => {
imode.model_selector.type_char(c);
}
_ => {}
}
} else if let Some(_shortcut) = imode.check_extension_shortcut(&key) {
tracing::debug!(
"Extension shortcut matched: {}",
_shortcut.label
);
} else if let Some(tui_event) = convert_key_event(key) {
input.handle_event(&tui_event);
}
}
}
}
crossterm::event::Event::Mouse(mouse) => match mouse.kind {
crossterm::event::MouseEventKind::ScrollUp => {
if mouse.row < chat_height {
chat_view.scroll_up(3);
}
}
crossterm::event::MouseEventKind::ScrollDown => {
if mouse.row < chat_height {
chat_view.scroll_down(3);
}
}
_ => {}
},
crossterm::event::Event::Resize(_, _) => {}
_ => {}
}
}
while let Ok(ui_event) = ui_rx.try_recv() {
match ui_event {
UiEvent::Start => {}
UiEvent::Thinking => {
chat_view.stream_thinking_start();
state = InteractiveState::Thinking;
}
UiEvent::TextDelta(text) => {
chat_view.stream_text_delta(&text);
}
UiEvent::ToolCall {
id,
name,
arguments,
} => {
chat_view.stream_thinking_end();
chat_view.stream_tool_call(id, name, arguments);
state = InteractiveState::ToolExecution;
}
UiEvent::ToolStart { tool_name } => {
chat_view.stream_tool_call(
format!("tool-{}", tool_name),
tool_name,
String::new(),
);
state = InteractiveState::ToolExecution;
}
UiEvent::ToolResult {
tool_name,
content,
is_error,
} => {
chat_view.stream_tool_result(tool_name, content, is_error);
}
UiEvent::Complete => {
chat_view.stream_thinking_end();
chat_view.finish_streaming();
let _display_state = InteractiveState::Display;
let st = agent_session.state();
for msg in st.messages.iter().rev() {
if let oxi_ai::Message::Assistant(a) = msg {
session.add_assistant_message(a.text_content());
break;
}
}
state = InteractiveState::Input;
}
UiEvent::Error(msg) => {
chat_view.finish_streaming_error(&msg);
state = InteractiveState::Input;
}
}
}
chat_view.scroll_to_bottom();
}
drop(prompt_tx);
let _ = agent_handle.join();
crossterm::execute!(io::stdout(), crossterm::cursor::Show)?;
crossterm::execute!(io::stdout(), crossterm::event::DisableMouseCapture)?;
crossterm::execute!(io::stdout(), crossterm::terminal::LeaveAlternateScreen)?;
io::stdout().flush()?;
Ok(())
}
fn render_model_selector_overlay(
surface: &mut Surface,
selector: &ModelSelectorOverlay,
width: u16,
height: u16,
theme: &Theme,
) {
let overlay_w = (width as usize).min(60).max(30);
let overlay_h = (height as usize).min(16).max(8);
let x_start = (width as usize - overlay_w) / 2;
let y_start = (height as usize - overlay_h) / 2;
for row in 0..overlay_h {
for col in 0..overlay_w {
let x = x_start + col;
let y = y_start + row;
if x < width as usize && y < height as usize {
let ch = if row == 0 && col == 0 {
'\u{250C}'
} else if row == 0 && col == overlay_w - 1 {
'\u{2510}'
} else if row == overlay_h - 1 && col == 0 {
'\u{2514}'
} else if row == overlay_h - 1 && col == overlay_w - 1 {
'\u{2518}'
} else if row == 0 || row == overlay_h - 1 {
'\u{2500}'
} else if col == 0 || col == overlay_w - 1 {
'\u{2502}'
} else {
' '
};
surface.set(
y as u16,
x as u16,
oxi_tui::Cell::new(ch)
.with_fg(theme.colors.primary)
.with_bg(theme.colors.background),
);
}
}
}
let title = " Model Selector (Ctrl+P) ";
let title_x = x_start + (overlay_w - title.len()) / 2;
for (i, ch) in title.chars().enumerate() {
let x = title_x + i;
if x < x_start + overlay_w - 1 && x < width as usize {
surface.set(
y_start as u16,
x as u16,
oxi_tui::Cell::new(ch)
.with_fg(theme.colors.primary)
.with_bg(theme.colors.background),
);
}
}
let filter_label = "> ";
for (i, ch) in filter_label.chars().enumerate() {
let x = x_start + 1 + i;
if x < x_start + overlay_w - 1 {
surface.set(
(y_start + 1) as u16,
x as u16,
oxi_tui::Cell::new(ch)
.with_fg(theme.colors.muted)
.with_bg(theme.colors.background),
);
}
}
let filter_text = selector.filter();
for (i, ch) in filter_text.chars().enumerate() {
let x = x_start + 1 + filter_label.len() + i;
if x < x_start + overlay_w - 1 {
surface.set(
(y_start + 1) as u16,
x as u16,
oxi_tui::Cell::new(ch)
.with_fg(theme.colors.foreground)
.with_bg(theme.colors.background),
);
}
}
for col in 1..overlay_w - 1 {
let x = x_start + col;
surface.set(
(y_start + 2) as u16,
x as u16,
oxi_tui::Cell::new('\u{2500}')
.with_fg(theme.colors.border)
.with_bg(theme.colors.background),
);
}
let filtered = selector.filtered_models();
let list_start_y = y_start + 3;
let list_height = overlay_h.saturating_sub(5);
for (i, model) in filtered.iter().enumerate() {
if i >= list_height {
break;
}
let y = list_start_y + i;
if y >= y_start + overlay_h - 1 {
break;
}
let is_cursor = i == selector.cursor_position();
let fg = if is_cursor {
theme.colors.primary
} else if model.selected {
theme.colors.success
} else {
theme.colors.foreground
};
let bg = if is_cursor {
theme.colors.selection_bg
} else {
theme.colors.background
};
let indicator = if model.selected { '\u{25CF}' } else { ' ' };
surface.set(
y as u16,
(x_start + 1) as u16,
oxi_tui::Cell::new(indicator).with_fg(fg).with_bg(bg),
);
let max_name_len = overlay_w.saturating_sub(6);
let name_text = if model.display_name.len() > max_name_len {
let trunc_len = max_name_len.saturating_sub(3);
format!("{}...", &model.display_name[..trunc_len])
} else {
model.display_name.clone()
};
for (j, ch) in name_text.chars().enumerate() {
let x = x_start + 3 + j;
if x < x_start + overlay_w - 1 {
surface.set(
y as u16,
x as u16,
oxi_tui::Cell::new(ch).with_fg(fg).with_bg(bg),
);
}
}
}
}
fn render_surface_to_terminal(surface: &Surface, width: u16, height: u16) {
print!("\x1b[?2026h"); print!("\x1b[H");
let mut last_fg = oxi_tui::Color::Default;
let mut last_bg = oxi_tui::Color::Default;
let mut last_bold = false;
let mut last_italic = false;
let mut last_underline = false;
let mut last_strike = false;
for row in 0..height {
if row > 0 {
print!("\r\n");
}
for col in 0..width {
if let Some(cell) = surface.get(row, col) {
let fg_changed = cell.fg != last_fg;
let bg_changed = cell.bg != last_bg;
let attrs_changed = cell.attrs.bold != last_bold
|| cell.attrs.italic != last_italic
|| cell.attrs.underline != last_underline
|| cell.attrs.strikethrough != last_strike;
if fg_changed || bg_changed || attrs_changed {
print!("\x1b[0m");
match cell.fg {
oxi_tui::Color::Default => {}
oxi_tui::Color::Black => print!("\x1b[30m"),
oxi_tui::Color::Red => print!("\x1b[31m"),
oxi_tui::Color::Green => print!("\x1b[32m"),
oxi_tui::Color::Yellow => print!("\x1b[33m"),
oxi_tui::Color::Blue => print!("\x1b[34m"),
oxi_tui::Color::Magenta => print!("\x1b[35m"),
oxi_tui::Color::Cyan => print!("\x1b[36m"),
oxi_tui::Color::White => print!("\x1b[37m"),
oxi_tui::Color::Indexed(n) => print!("\x1b[38;5;{}m", n),
oxi_tui::Color::Rgb(r, g, b) => print!("\x1b[38;2;{};{};{}m", r, g, b),
}
match cell.bg {
oxi_tui::Color::Default => {}
oxi_tui::Color::Black => print!("\x1b[40m"),
oxi_tui::Color::Red => print!("\x1b[41m"),
oxi_tui::Color::Green => print!("\x1b[42m"),
oxi_tui::Color::Yellow => print!("\x1b[43m"),
oxi_tui::Color::Blue => print!("\x1b[44m"),
oxi_tui::Color::Magenta => print!("\x1b[45m"),
oxi_tui::Color::Cyan => print!("\x1b[46m"),
oxi_tui::Color::White => print!("\x1b[47m"),
oxi_tui::Color::Indexed(n) => print!("\x1b[48;5;{}m", n),
oxi_tui::Color::Rgb(r, g, b) => print!("\x1b[48;2;{};{};{}m", r, g, b),
}
if cell.attrs.bold {
print!("\x1b[1m");
}
if cell.attrs.italic {
print!("\x1b[3m");
}
if cell.attrs.underline {
print!("\x1b[4m");
}
if cell.attrs.strikethrough {
print!("\x1b[9m");
}
last_fg = cell.fg;
last_bg = cell.bg;
last_bold = cell.attrs.bold;
last_italic = cell.attrs.italic;
last_underline = cell.attrs.underline;
last_strike = cell.attrs.strikethrough;
}
print!("{}", cell.char);
} else {
print!(" ");
}
}
}
print!("\x1b[0m");
print!("\x1b[?2026l"); }
fn convert_key_event(key: crossterm::event::KeyEvent) -> Option<oxi_tui::Event> {
use oxi_tui::event::KeyCode as KC;
let code = match key.code {
crossterm::event::KeyCode::Enter => return None,
crossterm::event::KeyCode::Char('c')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
return None;
}
crossterm::event::KeyCode::Esc => KC::Escape,
crossterm::event::KeyCode::Tab => KC::Tab,
crossterm::event::KeyCode::Backspace => KC::Backspace,
crossterm::event::KeyCode::Delete => KC::Delete,
crossterm::event::KeyCode::Up => KC::Up,
crossterm::event::KeyCode::Down => KC::Down,
crossterm::event::KeyCode::Left => KC::Left,
crossterm::event::KeyCode::Right => KC::Right,
crossterm::event::KeyCode::Home => KC::Home,
crossterm::event::KeyCode::End => KC::End,
crossterm::event::KeyCode::Char(c) => KC::Char(c),
crossterm::event::KeyCode::F(n) => KC::F(n),
_ => return None,
};
let modifiers = oxi_tui::KeyModifiers {
shift: key
.modifiers
.contains(crossterm::event::KeyModifiers::SHIFT),
ctrl: key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL),
alt: key.modifiers.contains(crossterm::event::KeyModifiers::ALT),
meta: key.modifiers.contains(crossterm::event::KeyModifiers::META),
};
Some(oxi_tui::Event::Key(oxi_tui::KeyEvent::with_modifiers(
code, modifiers,
)))
}
fn format_help() -> String {
r#"oxi — AI Coding Assistant
Commands:
/model [search] Select or switch model
/clear Clear conversation history
/compact [instr] Compact context with optional instructions
/undo Undo last exchange
/redo Redo last undone exchange
/branch Navigate session tree
/session Show session info and stats
/export [path] Export session to JSON
/import <path> Import session from JSONL file
/settings Show current settings
/name <name> Set session display name
/copy Copy last assistant response
/new Start a new session
/reload Reload settings, extensions, skills
/clone Duplicate current session
/resume [id] Resume a different session
/login <provider> Initiate OAuth login
/logout <provider> Remove provider authentication
/changelog Show recent changelog entries
/hotkeys Show all keyboard shortcuts
/help Show this help message
/quit Quit oxi
Bash:
!<command> Run a bash command
!!<command> Run bash (excluded from context)
Keybindings:
Enter Send message or command
Ctrl+C Quit
PageUp/PageDown Scroll chat history
Mouse scroll Scroll chat history
"#
.to_string()
}
fn format_session_info(session: &InteractiveSession) -> String {
let msg_count = session.messages.len();
let user_count = session.messages.iter().filter(|m| m.role == "user").count();
let assistant_count = session
.messages
.iter()
.filter(|m| m.role == "assistant")
.count();
let entry_count = session.entries.len();
format!(
"Session Info:\n\
Messages: {} total ({} user, {} assistant)\n\
Entries: {}\n\
ID: {}",
msg_count,
user_count,
assistant_count,
entry_count,
session
.session_id
.map(|u| u.to_string())
.unwrap_or_else(|| "none".to_string()),
)
}
fn export_session_json(session: &InteractiveSession) -> String {
let messages: Vec<serde_json::Value> = session
.messages
.iter()
.map(|m| {
serde_json::json!({
"role": m.role,
"content": m.content,
"timestamp": m.timestamp.to_rfc3339(),
})
})
.collect();
serde_json::to_string_pretty(&serde_json::json!({
"session_id": session.session_id.map(|u| u.to_string()),
"messages": messages,
"entry_count": session.entries.len(),
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn rebuild_chat_view(chat_view: &mut ChatView, session: &InteractiveSession, theme: &Theme) {
*chat_view = ChatView::new(theme.clone());
for msg in &session.messages {
let role = if msg.role == "user" {
MessageRole::User
} else {
MessageRole::Assistant
};
chat_view.add_message(ChatMessageDisplay {
role,
content_blocks: vec![ContentBlockDisplay::Text {
content: msg.content.clone(),
}],
timestamp: msg.timestamp.timestamp_millis(),
});
}
}
fn clone_session(session: &InteractiveSession) -> Result<String> {
use std::io::Write;
let home = std::env::var("HOME")
.map_err(|_| anyhow::anyhow!("HOME not set"))?;
let session_dir = PathBuf::from(home).join(".oxi").join("sessions");
std::fs::create_dir_all(&session_dir)?;
let new_id = uuid::Uuid::new_v4().to_string();
let new_path = session_dir.join(format!("{}.jsonl", new_id));
if let Some(current_id) = &session.session_id {
let current_path = session_dir.join(format!("{}.jsonl", current_id));
if current_path.exists() {
let content = std::fs::read_to_string(¤t_path)?;
std::fs::write(&new_path, content)?;
} else {
let mut file = std::fs::File::create(&new_path)?;
for msg in &session.messages {
let entry = serde_json::json!({
"type": msg.role,
"content": msg.content,
"timestamp": msg.timestamp.timestamp_millis(),
});
writeln!(file, "{}", entry)?;
}
}
} else {
let mut file = std::fs::File::create(&new_path)?;
for msg in &session.messages {
let entry = serde_json::json!({
"type": msg.role,
"content": msg.content,
"timestamp": msg.timestamp.timestamp_millis(),
});
writeln!(file, "{}", entry)?;
}
}
Ok(new_id)
}
fn resume_session(session_id: &str) -> Result<InteractiveSession> {
use std::io::BufRead;
#[derive(serde::Deserialize)]
struct JsonlEntry {
#[serde(rename = "type")]
entry_type: String,
content: String,
timestamp: i64,
}
let home = std::env::var("HOME")
.map_err(|_| anyhow::anyhow!("HOME not set"))?;
let session_dir = PathBuf::from(home).join(".oxi").join("sessions");
let session_path = session_dir.join(format!("{}.jsonl", session_id));
let file = std::fs::File::open(&session_path)
.map_err(|_| anyhow::anyhow!("Session not found: {}", session_id))?;
let reader = std::io::BufReader::new(file);
let mut session = InteractiveSession::new();
session.session_id = Some(uuid::Uuid::parse_str(session_id)
.unwrap_or_else(|_| uuid::Uuid::new_v4()));
for line in reader.lines() {
if let Ok(line) = line {
if let Ok(entry) = serde_json::from_str::<JsonlEntry>(&line) {
session.messages.push(ChatMessage {
role: entry.entry_type,
content: entry.content,
timestamp: chrono::DateTime::from_timestamp_millis(entry.timestamp)
.unwrap_or_else(chrono::Utc::now),
});
}
}
}
Ok(session)
}
fn list_available_sessions() -> String {
let home = match std::env::var("HOME") {
Ok(h) => h,
Err(_) => return "HOME not set, cannot list sessions".to_string(),
};
let session_dir = PathBuf::from(home).join(".oxi").join("sessions");
if !session_dir.exists() {
return "No saved sessions found".to_string();
}
let mut sessions: Vec<String> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&session_dir) {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
if name.ends_with(".jsonl") {
let id = &name[..name.len() - 6]; let modified = entry.metadata()
.and_then(|m| m.modified())
.ok()
.map(|t| {
chrono::DateTime::<chrono::Local>::from(t)
.format("%Y-%m-%d %H:%M")
.to_string()
})
.unwrap_or_else(|| "unknown".to_string());
sessions.push(format!(" {} ({})", id, modified));
}
}
}
}
if sessions.is_empty() {
"No saved sessions found".to_string()
} else {
format!("Available sessions:\n\n{}\n\nUse /resume <session_id> to load a session",
sessions.join("\n"))
}
}
fn import_session_from_jsonl(path: &str) -> Result<InteractiveSession> {
use std::io::BufRead;
let file = std::fs::File::open(path)
.map_err(|e| anyhow::anyhow!("Could not open file: {}", e))?;
#[derive(serde::Deserialize)]
struct JsonlEntry {
#[serde(rename = "type")]
entry_type: String,
content: String,
timestamp: i64,
}
let reader = std::io::BufReader::new(file);
let mut session = InteractiveSession::new();
session.session_id = Some(uuid::Uuid::new_v4());
for line in reader.lines() {
if let Ok(line) = line {
if let Ok(entry) = serde_json::from_str::<JsonlEntry>(&line) {
session.messages.push(ChatMessage {
role: entry.entry_type,
content: entry.content,
timestamp: chrono::DateTime::from_timestamp_millis(entry.timestamp)
.unwrap_or_else(chrono::Utc::now),
});
}
}
}
Ok(session)
}
fn initiate_login(provider: &str) -> Result<String> {
match provider.to_lowercase().as_str() {
"anthropic" => {
let server = crate::oauth_server::OAuthCallbackServer::with_available_port()
.map_err(|e| anyhow::anyhow!("Failed to start callback server: {}", e))?;
let redirect_uri = server.redirect_uri();
let auth_url = format!(
"https://auth.anthropic.com/authorize?client_id=oxi&redirect_uri={}&response_type=code",
urlencoding::encode(&redirect_uri)
);
#[cfg(target_os = "macos")]
std::process::Command::new("open").arg(&auth_url).spawn()
.map_err(|e| anyhow::anyhow!("Failed to open browser: {}", e))?;
#[cfg(target_os = "linux")]
std::process::Command::new("xdg-open").arg(&auth_url).spawn()
.map_err(|e| anyhow::anyhow!("Failed to open browser: {}", e))?;
#[cfg(target_os = "windows")]
std::process::Command::new("cmd")
.args(["/C", "start", "", &auth_url])
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to open browser: {}", e))?;
Ok(format!(
"Opened browser for Anthropic OAuth.\nWaiting for callback on port {}...\n\nAlternatively, you can set ANTHROPIC_API_KEY environment variable.",
server.port()
))
}
"openai" | "github" => {
Ok(format!(
"OAuth login for {} is not yet implemented.\nPlease set the API key manually via environment variable.",
provider
))
}
_ => {
Ok(format!(
"Unknown provider: {}\nSupported: anthropic, openai, github",
provider
))
}
}
}
fn remove_auth(provider: &str) -> Result<()> {
let storage = crate::auth_storage::AuthStorage::new();
match provider.to_lowercase().as_str() {
"anthropic" | "openai" | "github" => {
storage.remove(provider);
Ok(())
}
_ => Err(anyhow::anyhow!("Unknown provider: {}", provider)),
}
}
fn get_changelog_display() -> String {
let changelog_paths = vec![
PathBuf::from(".").join("CHANGELOG.md"),
PathBuf::from("..").join("CHANGELOG.md"),
PathBuf::from("../..").join("CHANGELOG.md"),
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("oxi")
.join("CHANGELOG.md"),
];
for path in &changelog_paths {
if path.exists() {
let entries = crate::changelog::parse_changelog(path);
if !entries.is_empty() {
let mut result = String::new();
for (i, entry) in entries.iter().take(3).enumerate() {
if i > 0 {
result.push_str("\n\n---\n\n");
}
result.push_str(&crate::changelog::format_changelog_entry(entry, true));
}
return result;
}
}
}
format!(
"Changelog not found.\n\nCurrent version: {}\n\nVisit https://github.com/oxiget/oxi/releases for the full changelog.",
env!("CARGO_PKG_VERSION")
)
}
fn run_bash_command(command: &str) -> String {
use std::process::Command;
let output = Command::new("sh")
.arg("-c")
.arg(command)
.output()
.unwrap_or_else(|e| std::process::Output {
stdout: Vec::new(),
stderr: format!("Failed to execute: {}", e).into_bytes(),
status: std::process::ExitStatus::from_raw(1),
});
let mut result = String::new();
if !output.stdout.is_empty() {
result.push_str(&String::from_utf8_lossy(&output.stdout));
}
if !output.stderr.is_empty() {
if !result.is_empty() {
result.push('\n');
}
result.push_str(&String::from_utf8_lossy(&output.stderr));
}
if !output.status.success() {
result.push_str(&format!(
"\nExit code: {}",
output.status.code().unwrap_or(-1)
));
}
result
}
pub fn compute_word_diff(old: &str, new: &str) -> DiffResult {
let old_words: Vec<&str> = old.split_whitespace().collect();
let new_words: Vec<&str> = new.split_whitespace().collect();
let lcs = longest_common_subsequence(&old_words, &new_words);
let mut changes = Vec::new();
let mut old_idx = 0usize;
let mut new_idx = 0usize;
for (matched_old, matched_new) in lcs {
while old_idx < matched_old {
changes.push(DiffChange::Removed(old_words[old_idx].to_string()));
old_idx += 1;
}
while new_idx < matched_new {
changes.push(DiffChange::Added(new_words[new_idx].to_string()));
new_idx += 1;
}
changes.push(DiffChange::Unchanged(new_words[new_idx].to_string()));
old_idx += 1;
new_idx += 1;
}
while old_idx < old_words.len() {
changes.push(DiffChange::Removed(old_words[old_idx].to_string()));
old_idx += 1;
}
while new_idx < new_words.len() {
changes.push(DiffChange::Added(new_words[new_idx].to_string()));
new_idx += 1;
}
DiffResult { changes }
}
fn longest_common_subsequence<'a>(a: &[&'a str], b: &[&'a str]) -> Vec<(usize, usize)> {
let m = a.len();
let n = b.len();
let mut dp = vec![vec![0usize; n + 1]; m + 1];
for i in 1..=m {
for j in 1..=n {
if a[i - 1] == b[j - 1] {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
}
}
}
let mut lcs = Vec::new();
let mut i = m;
let mut j = n;
while i > 0 && j > 0 {
if a[i - 1] == b[j - 1] {
lcs.push((i - 1, j - 1));
i -= 1;
j -= 1;
} else if dp[i - 1][j] > dp[i][j - 1] {
i -= 1;
} else {
j -= 1;
}
}
lcs.reverse();
lcs
}
#[derive(Debug, Clone)]
pub struct DiffResult {
pub changes: Vec<DiffChange>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffChange {
Unchanged(String),
Added(String),
Removed(String),
}
impl DiffResult {
pub fn format_ansi(&self) -> String {
use std::fmt::Write;
let mut result = String::new();
for change in &self.changes {
match change {
DiffChange::Unchanged(s) => {
write!(&mut result, "{} ", s).unwrap();
}
DiffChange::Added(s) => {
write!(&mut result, "\x1b[32m{}\x1b[0m ", s).unwrap();
}
DiffChange::Removed(s) => {
write!(&mut result, "\x1b[31m{}\x1b[0m ", s).unwrap();
}
}
}
result.trim_end().to_string()
}
pub fn summary(&self) -> (usize, usize, usize) {
let mut added = 0usize;
let mut removed = 0usize;
let mut unchanged = 0usize;
for change in &self.changes {
match change {
DiffChange::Unchanged(_) => unchanged += 1,
DiffChange::Added(_) => added += 1,
DiffChange::Removed(_) => removed += 1,
}
}
(added, removed, unchanged)
}
}
fn copy_to_clipboard(text: &str) -> Result<()> {
use std::io::Write;
use std::process::{Command, Stdio};
let (cmd, args): (&str, &[&str]) = if cfg!(target_os = "macos") {
("pbcopy", &[])
} else if cfg!(target_os = "linux") {
if std::path::Path::new("/usr/bin/wl-copy").exists()
|| std::path::Path::new("/usr/local/bin/wl-copy").exists()
{
("wl-copy", &[])
} else {
("xclip", &["-selection", "clipboard"])
}
} else {
return Err(anyhow::anyhow!("Clipboard not supported on this platform"));
};
let mut child = Command::new(cmd)
.args(args)
.stdin(Stdio::piped())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to spawn clipboard command: {}", e))?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
let _ = child.wait();
Ok(())
}
fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_model_no_arg() {
let cmd = SlashCommand::parse("/model");
assert_eq!(cmd, SlashCommand::Model { search: None });
}
#[test]
fn test_parse_model_with_search() {
let cmd = SlashCommand::parse("/model claude-sonnet");
assert_eq!(
cmd,
SlashCommand::Model {
search: Some("claude-sonnet".to_string()),
}
);
}
#[test]
fn test_parse_clear() {
assert_eq!(SlashCommand::parse("/clear"), SlashCommand::Clear);
}
#[test]
fn test_parse_compact_no_arg() {
assert_eq!(
SlashCommand::parse("/compact"),
SlashCommand::Compact {
custom_instructions: None
}
);
}
#[test]
fn test_parse_compact_with_instructions() {
assert_eq!(
SlashCommand::parse("/compact focus on error handling"),
SlashCommand::Compact {
custom_instructions: Some("focus on error handling".to_string()),
}
);
}
#[test]
fn test_parse_undo_redo() {
assert_eq!(SlashCommand::parse("/undo"), SlashCommand::Undo);
assert_eq!(SlashCommand::parse("/redo"), SlashCommand::Redo);
}
#[test]
fn test_parse_aliases() {
assert_eq!(SlashCommand::parse("/?"), SlashCommand::Help);
assert_eq!(SlashCommand::parse("/exit"), SlashCommand::Quit);
assert_eq!(SlashCommand::parse("/q"), SlashCommand::Quit);
assert_eq!(SlashCommand::parse("/fork"), SlashCommand::Branch);
assert_eq!(SlashCommand::parse("/tree"), SlashCommand::Branch);
assert_eq!(SlashCommand::parse("/resume"), SlashCommand::Session);
}
#[test]
fn test_parse_unknown() {
let cmd = SlashCommand::parse("/foobar");
assert_eq!(
cmd,
SlashCommand::Unknown {
raw: "/foobar".to_string()
}
);
}
#[test]
fn test_state_ordering() {
let states = [
InteractiveState::Input,
InteractiveState::Thinking,
InteractiveState::ToolExecution,
InteractiveState::Display,
];
for i in 0..states.len() {
for j in (i + 1)..states.len() {
assert_ne!(states[i], states[j]);
}
}
}
#[test]
fn test_state_transitions_input_to_thinking() {
let state = InteractiveState::Input;
let next = InteractiveState::Thinking;
assert_eq!(next, InteractiveState::Thinking);
assert_ne!(state, next);
}
#[test]
fn test_state_transitions_thinking_to_tool_execution() {
let state = InteractiveState::Thinking;
let next = InteractiveState::ToolExecution;
assert_ne!(state, next);
}
#[test]
fn test_state_transitions_tool_execution_to_display() {
let state = InteractiveState::ToolExecution;
let display = InteractiveState::Display;
let input = InteractiveState::Input;
assert_ne!(state, display);
assert_ne!(display, input);
}
#[test]
fn test_bash_command_execution() {
let output = run_bash_command("echo hello");
assert!(output.contains("hello"));
}
#[test]
fn test_bash_command_failure() {
let output = run_bash_command("false");
assert!(output.contains("Exit code:"));
}
#[test]
fn test_export_empty_session() {
let session = InteractiveSession::new();
let json = export_session_json(&session);
assert!(json.contains("\"messages\": []"));
assert!(json.contains("\"entry_count\": 0"));
}
#[test]
fn test_export_session_with_messages() {
let mut session = InteractiveSession::new();
session.add_user_message("Hello".to_string());
session.add_assistant_message("Hi there!".to_string());
let json = export_session_json(&session);
assert!(json.contains("\"role\": \"user\""));
assert!(json.contains("\"content\": \"Hello\""));
assert!(json.contains("\"role\": \"assistant\""));
}
#[test]
fn test_session_info_empty() {
let session = InteractiveSession::new();
let info = format_session_info(&session);
assert!(info.contains("Messages: 0 total"));
assert!(info.contains("ID: none"));
}
#[test]
fn test_session_info_with_messages() {
let mut session = InteractiveSession::new();
session.add_user_message("Hello".to_string());
session.add_assistant_message("Hi".to_string());
let info = format_session_info(&session);
assert!(info.contains("Messages: 2 total"));
assert!(info.contains("1 user"));
assert!(info.contains("1 assistant"));
}
#[test]
fn test_help_text_contains_all_commands() {
let help = format_help();
assert!(help.contains("/model"));
assert!(help.contains("/clear"));
assert!(help.contains("/compact"));
assert!(help.contains("/undo"));
assert!(help.contains("/redo"));
assert!(help.contains("/branch"));
assert!(help.contains("/session"));
assert!(help.contains("/export"));
assert!(help.contains("/settings"));
assert!(help.contains("/help"));
assert!(help.contains("/quit"));
}
#[test]
fn test_command_descriptions() {
assert_eq!(
SlashCommand::Model { search: None }.description(),
"Select model"
);
assert_eq!(
SlashCommand::Clear.description(),
"Clear conversation history"
);
assert_eq!(SlashCommand::Undo.description(), "Undo last exchange");
assert_eq!(
SlashCommand::Redo.description(),
"Redo last undone exchange"
);
assert_eq!(SlashCommand::Quit.description(), "Quit oxi");
assert_eq!(
SlashCommand::Unknown {
raw: "/x".to_string()
}
.description(),
"Unknown command"
);
}
#[test]
fn test_image_attachment_from_data_uri() {
let uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
let img = ImageAttachment::from_data_uri(uri);
assert!(img.is_some());
let img = img.unwrap();
assert_eq!(img.mime_type, "image/png");
}
#[test]
fn test_image_attachment_invalid_uri() {
let img = ImageAttachment::from_data_uri("not a data uri");
assert!(img.is_none());
}
#[test]
fn test_image_attachment_extension() {
let img = ImageAttachment {
mime_type: "image/png".to_string(),
base64_data: String::new(),
width: None,
height: None,
};
assert_eq!(img.extension(), "png");
let img_jpeg = ImageAttachment {
mime_type: "image/jpeg".to_string(),
base64_data: String::new(),
width: None,
height: None,
};
assert_eq!(img_jpeg.extension(), "jpg");
}
#[test]
fn test_image_attachment_detect_mime_type() {
let png_bytes: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
assert_eq!(ImageAttachment::detect_mime_type(&png_bytes), "image/png");
let jpeg_bytes: Vec<u8> = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46];
assert_eq!(ImageAttachment::detect_mime_type(&jpeg_bytes), "image/jpeg");
let unknown: Vec<u8> = vec![0x00, 0x00, 0x00, 0x00];
assert_eq!(ImageAttachment::detect_mime_type(&unknown), "image/png"); }
#[test]
fn test_image_attachment_from_bytes() {
let png_data: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
let img = ImageAttachment::from_bytes(png_data);
assert!(img.is_some());
let img = img.unwrap();
assert_eq!(img.mime_type, "image/png");
assert!(!img.base64_data.is_empty());
}
#[test]
fn test_session_persistence_new() {
let persistence = SessionPersistence::new();
assert!(persistence.is_some() || persistence.is_none());
}
#[test]
fn test_keybinding_hints_compact() {
let hints = KeybindingHints::new();
let compact = hints.compact_display();
assert!(compact.contains("Ctrl+C"));
assert!(compact.contains("quit"));
}
#[test]
fn test_keybinding_hints_expanded() {
let hints = KeybindingHints::new();
let expanded = hints.expanded_display();
assert!(expanded.contains("Ctrl+C"));
assert!(expanded.contains("Ctrl+L"));
assert!(expanded.contains("Ctrl+U"));
}
#[test]
fn test_keybinding_hints_toggle() {
let mut hints = KeybindingHints::new();
assert!(!hints.is_expanded());
hints.toggle();
assert!(hints.is_expanded());
hints.toggle();
assert!(!hints.is_expanded());
}
#[test]
fn test_compute_word_diff_identical() {
let result = compute_word_diff("hello world", "hello world");
let (added, removed, unchanged) = result.summary();
assert_eq!(added, 0);
assert_eq!(removed, 0);
assert_eq!(unchanged, 2);
}
#[test]
fn test_compute_word_diff_added_words() {
let result = compute_word_diff("hello", "hello world");
let (added, removed, _) = result.summary();
assert_eq!(added, 1); assert_eq!(removed, 0);
}
#[test]
fn test_compute_word_diff_removed_words() {
let result = compute_word_diff("hello world", "hello");
let (added, removed, _) = result.summary();
assert_eq!(added, 0);
assert_eq!(removed, 1); }
#[test]
fn test_compute_word_diff_changed() {
let result = compute_word_diff("hello world", "hello rust");
let (added, removed, unchanged) = result.summary();
assert_eq!(added, 1); assert_eq!(removed, 1); assert_eq!(unchanged, 1); }
#[test]
fn test_diff_result_format_ansi() {
let result = compute_word_diff("foo bar", "foo baz");
let formatted = result.format_ansi();
assert!(formatted.contains("foo"));
assert!(formatted.contains("bar") || formatted.contains("baz"));
}
#[test]
fn test_diff_result_empty() {
let result = compute_word_diff("", "hello");
let (added, removed, _) = result.summary();
assert_eq!(added, 1);
assert_eq!(removed, 0);
}
#[test]
fn test_lcs_algorithm() {
let a = vec!["a", "b", "c"];
let b = vec!["a", "c", "d"];
let lcs = longest_common_subsequence(&a, &b);
assert!(lcs.contains(&(0, 0))); assert!(lcs.contains(&(2, 1))); }
}
#[cfg(test)]
mod integration_tests {
use super::*;
use std::thread;
use std::time::Duration;
#[test]
fn test_model_selector_new() {
let selector = ModelSelectorOverlay::new(Vec::new());
assert!(!selector.is_visible());
assert!(selector.filter().is_empty());
}
#[test]
fn test_model_selector_show_hide() {
let mut selector = ModelSelectorOverlay::new(Vec::new());
assert!(!selector.is_visible());
selector.show();
assert!(selector.is_visible());
selector.hide();
assert!(!selector.is_visible());
}
#[test]
fn test_model_selector_filter() {
let models = vec![
ModelSelectorEntry {
full_id: "anthropic/claude-sonnet".to_string(),
display_name: "Claude Sonnet".to_string(),
provider: "anthropic".to_string(),
selected: false,
},
ModelSelectorEntry {
full_id: "openai/gpt-4o".to_string(),
display_name: "GPT-4o".to_string(),
provider: "openai".to_string(),
selected: true,
},
];
let mut selector = ModelSelectorOverlay::new(models);
selector.show();
selector.type_char('c');
assert_eq!(selector.filter(), "c");
let filtered = selector.filtered_models();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].display_name, "Claude Sonnet");
selector.backspace();
assert!(selector.filter().is_empty());
assert_eq!(selector.filtered_models().len(), 2);
}
#[test]
fn test_model_selector_cursor_navigation() {
let models = vec![
ModelSelectorEntry {
full_id: "a".to_string(),
display_name: "Model A".to_string(),
provider: "p1".to_string(),
selected: false,
},
ModelSelectorEntry {
full_id: "b".to_string(),
display_name: "Model B".to_string(),
provider: "p2".to_string(),
selected: false,
},
ModelSelectorEntry {
full_id: "c".to_string(),
display_name: "Model C".to_string(),
provider: "p3".to_string(),
selected: false,
},
];
let mut selector = ModelSelectorOverlay::new(models);
selector.show();
assert_eq!(selector.cursor_position(), 0);
selector.cursor_down();
assert_eq!(selector.cursor_position(), 1);
selector.cursor_down();
assert_eq!(selector.cursor_position(), 2);
selector.cursor_down(); assert_eq!(selector.cursor_position(), 2);
selector.cursor_up();
assert_eq!(selector.cursor_position(), 1);
selector.cursor_up();
assert_eq!(selector.cursor_position(), 0);
selector.cursor_up(); assert_eq!(selector.cursor_position(), 0);
}
#[test]
fn test_model_selector_confirm() {
let models = vec![
ModelSelectorEntry {
full_id: "anthropic/claude".to_string(),
display_name: "Claude".to_string(),
provider: "anthropic".to_string(),
selected: false,
},
];
let mut selector = ModelSelectorOverlay::new(models);
selector.show();
let choice = selector.confirm();
assert_eq!(choice, Some("anthropic/claude".to_string()));
assert!(!selector.is_visible());
}
#[test]
fn test_model_selector_cancel() {
let mut selector = ModelSelectorOverlay::new(Vec::new());
selector.show();
selector.cancel();
assert!(!selector.is_visible());
}
#[test]
fn test_model_selector_reposition_to_selected() {
let models = vec![
ModelSelectorEntry {
full_id: "a".to_string(),
display_name: "A".to_string(),
provider: "p".to_string(),
selected: false,
},
ModelSelectorEntry {
full_id: "b".to_string(),
display_name: "B".to_string(),
provider: "p".to_string(),
selected: true,
},
ModelSelectorEntry {
full_id: "c".to_string(),
display_name: "C".to_string(),
provider: "p".to_string(),
selected: false,
},
];
let mut selector = ModelSelectorOverlay::new(models);
selector.show(); assert_eq!(selector.cursor_position(), 1);
}
#[test]
fn test_fuzzy_match_basic() {
assert!(fuzzy_match("cs", "claude sonnet"));
assert!(fuzzy_match("gpt", "gpt-4o"));
assert!(!fuzzy_match("xyz", "hello"));
}
#[test]
fn test_fuzzy_match_empty() {
assert!(fuzzy_match("", ""));
assert!(fuzzy_match("", "anything"));
}
#[test]
fn test_fuzzy_match_exact() {
assert!(fuzzy_match("hello", "hello"));
}
#[test]
fn test_double_escape_single_press() {
let mut tracker = DoubleEscapeTracker::new();
assert!(!tracker.press_escape()); }
#[test]
fn test_double_escape_double_press() {
let mut tracker = DoubleEscapeTracker::new();
assert!(!tracker.press_escape()); assert!(tracker.press_escape()); }
#[test]
fn test_double_escape_timeout() {
let mut tracker = DoubleEscapeTracker::with_interval(50); assert!(!tracker.press_escape());
thread::sleep(Duration::from_millis(100)); assert!(!tracker.press_escape()); }
#[test]
fn test_double_escape_reset() {
let mut tracker = DoubleEscapeTracker::new();
assert!(!tracker.press_escape());
tracker.reset();
assert!(!tracker.press_escape()); }
#[test]
fn test_double_escape_action() {
let mut tracker = DoubleEscapeTracker::new();
assert_eq!(tracker.action(), DoubleEscapeAction::Quit);
tracker.set_action(DoubleEscapeAction::Clear);
assert_eq!(tracker.action(), DoubleEscapeAction::Clear);
}
#[test]
fn test_compaction_progress_new() {
let tracker = CompactionProgressTracker::new();
assert!(!tracker.is_active());
assert!(!tracker.is_cancelled());
}
#[test]
fn test_compaction_progress_start_finish() {
let mut tracker = CompactionProgressTracker::new();
tracker.start(10);
assert!(tracker.is_active());
assert!(!tracker.is_cancelled());
tracker.finish();
assert!(!tracker.is_active());
}
#[test]
fn test_compaction_progress_cancel() {
let mut tracker = CompactionProgressTracker::new();
tracker.start(10);
tracker.cancel();
assert!(tracker.is_cancelled());
tracker.finish();
assert!(!tracker.is_active());
}
#[test]
fn test_compaction_progress_update() {
let mut tracker = CompactionProgressTracker::new();
tracker.start(20);
tracker.update(5);
let text = tracker.progress_text();
assert!(text.contains("5/20"));
assert!(text.contains("Esc to cancel"));
}
#[test]
fn test_compaction_progress_cancel_hint() {
let mut tracker = CompactionProgressTracker::new();
tracker.start(10);
tracker.cancel();
let text = tracker.progress_text();
assert!(text.contains("cancelling"));
}
#[test]
fn test_compaction_progress_empty_when_inactive() {
let tracker = CompactionProgressTracker::new();
assert!(tracker.progress_text().is_empty());
}
#[test]
fn test_extension_shortcut_register() {
let mut registry = ExtensionShortcutRegistry::new();
registry.register("test.shortcut", "test-ext", "ctrl+shift+f", "Find in files");
assert_eq!(registry.shortcuts().len(), 1);
assert_eq!(registry.shortcuts()[0].id, "test.shortcut");
assert_eq!(registry.shortcuts()[0].key_sequence, "ctrl+shift+f");
}
#[test]
fn test_extension_shortcut_unregister() {
let mut registry = ExtensionShortcutRegistry::new();
registry.register("test.shortcut", "test-ext", "ctrl+f", "Find");
registry.unregister("test.shortcut");
assert!(registry.shortcuts().is_empty());
}
#[test]
fn test_extension_shortcut_replace() {
let mut registry = ExtensionShortcutRegistry::new();
registry.register("test.shortcut", "ext1", "ctrl+f", "Find v1");
registry.register("test.shortcut", "ext2", "ctrl+g", "Find v2");
assert_eq!(registry.shortcuts().len(), 1);
assert_eq!(registry.shortcuts()[0].label, "Find v2");
}
#[test]
fn test_extension_shortcut_enable_disable() {
let mut registry = ExtensionShortcutRegistry::new();
registry.register("test.id", "ext", "ctrl+f", "Find");
registry.set_enabled("test.id", false);
assert!(!registry.shortcuts()[0].enabled);
registry.set_enabled("test.id", true);
assert!(registry.shortcuts()[0].enabled);
}
#[test]
fn test_extension_shortcut_find_matching() {
let mut registry = ExtensionShortcutRegistry::new();
registry.register("test.find", "ext", "ctrl+f", "Find");
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('f'),
crossterm::event::KeyModifiers::CONTROL,
);
let result = registry.find_matching(&key);
assert!(result.is_some());
assert_eq!(result.unwrap().id, "test.find");
}
#[test]
fn test_interactive_mode_state_new() {
let state = InteractiveModeState::new();
assert!(!state.model_selector.is_visible());
assert!(!state.compaction_progress.is_active());
assert!(state.pending_image.is_none());
}
#[test]
fn test_interactive_mode_state_model_confirm_not_visible() {
let mut state = InteractiveModeState::new();
assert!(state.handle_model_confirm().is_none());
}
#[test]
fn test_interactive_mode_state_model_cancel_not_visible() {
let mut state = InteractiveModeState::new();
assert!(!state.handle_model_cancel());
}
#[test]
fn test_interactive_mode_state_compaction_escape_not_active() {
let mut state = InteractiveModeState::new();
assert!(!state.handle_compaction_escape());
}
#[test]
fn test_interactive_mode_state_compaction_lifecycle() {
let mut state = InteractiveModeState::new();
state.start_compaction(10);
assert!(state.compaction_progress.is_active());
state.update_compaction(5);
assert!(state.handle_compaction_escape());
assert!(state.compaction_progress.is_cancelled());
state.finish_compaction();
assert!(!state.compaction_progress.is_active());
}
#[test]
fn test_interactive_mode_state_double_escape() {
let mut state = InteractiveModeState::new();
assert!(!state.handle_escape()); assert!(state.handle_escape()); }
#[test]
fn test_interactive_mode_state_register_extension_shortcut() {
let mut state = InteractiveModeState::new();
state.register_extension_shortcut("my.shortcut", "ext", "ctrl+shift+x", "Custom");
assert_eq!(state.extension_shortcuts.shortcuts().len(), 1);
state.unregister_extension_shortcut("my.shortcut");
assert!(state.extension_shortcuts.shortcuts().is_empty());
}
#[test]
fn test_interactive_mode_state_ctrl_p() {
let models = vec![
ModelSelectorEntry {
full_id: "a".to_string(),
display_name: "A".to_string(),
provider: "p".to_string(),
selected: true,
},
ModelSelectorEntry {
full_id: "b".to_string(),
display_name: "B".to_string(),
provider: "p".to_string(),
selected: false,
},
];
let mut state = InteractiveModeState::with_models(models);
assert!(!state.model_selector.is_visible());
state.handle_ctrl_p(); assert!(state.model_selector.is_visible());
state.handle_ctrl_p(); assert_eq!(state.model_selector.cursor_position(), 1);
}
#[test]
fn test_terminal_suspend_handler_new() {
let handler = TerminalSuspendHandler::new();
assert!(!handler.is_suspended());
}
#[test]
fn test_clipboard_image_paste_handler_new() {
let handler = ClipboardImagePasteHandler::new();
drop(handler);
}
#[test]
fn test_slash_command_aliases() {
assert_eq!(SlashCommand::parse("/help"), SlashCommand::Help);
assert_eq!(SlashCommand::parse("/?"), SlashCommand::Help);
assert_eq!(SlashCommand::parse("/quit"), SlashCommand::Quit);
assert_eq!(SlashCommand::parse("/exit"), SlashCommand::Quit);
assert_eq!(SlashCommand::parse("/q"), SlashCommand::Quit);
assert_eq!(SlashCommand::parse("/settings"), SlashCommand::Settings);
assert_eq!(SlashCommand::parse("/new"), SlashCommand::New);
assert_eq!(SlashCommand::parse("/reload"), SlashCommand::Reload);
assert_eq!(SlashCommand::parse("/clone"), SlashCommand::Clone);
assert_eq!(SlashCommand::parse("/copy"), SlashCommand::Copy);
assert_eq!(SlashCommand::parse("/name test"), SlashCommand::Name { name: "test".to_string() });
assert_eq!(SlashCommand::parse("/resume"), SlashCommand::Session);
}
}