#![deny(unsafe_code)]
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::Result;
use crossterm::cursor::SetCursorStyle;
use crossterm::event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind,
};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::prelude::*;
use ratatui::widgets::*;
use pulldown_cmark::{
BlockQuoteKind, CodeBlockKind, Event as MdEvent, Options, Parser as MdParser, Tag as MdTag,
TagEnd as MdTagEnd,
};
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
use unicode_width::UnicodeWidthStr;
use ascend_tools::client::AscendClient;
use ascend_tools::models::{
Conversation, OttoChatRequest, OttoModel, OttoStreamStatus, StreamEvent,
};
use std::ops::ControlFlow;
const SPINNER: &[&str] = &[
"\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}",
"\u{2807}", "\u{280f}",
];
const POLL_DURATION: Duration = Duration::from_millis(16);
const SPINNER_INTERVAL: Duration = Duration::from_millis(80);
#[rustfmt::skip]
const COMMANDS: &[&str] = &[
"/clear", "/copy", "/emacs", "/exit", "/help",
"/q", "/quit", "/timestamps", "/vi", "/vim",
];
#[rustfmt::skip]
const SPLASH: &[&str] = &[
" \u{2588}\u{2588} \u{2588}\u{2588}",
" \u{2588}\u{2588}\u{2588} \u{2588}\u{2588}\u{2588}",
" \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}",
" \u{2588}\u{2588} . . \u{2588}\u{2588}",
" \u{2588}\u{2588} v \u{2588}\u{2588}",
" \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}",
"",
" \u{2588}\u{2588}\u{2588}\u{2588} \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588} \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588} \u{2588}\u{2588}\u{2588}\u{2588}",
" \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588}",
" \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588}",
" \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588}",
" \u{2588}\u{2588}\u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588}\u{2588}\u{2588}",
"",
" type /help for commands",
];
#[rustfmt::skip]
const EXPERIMENTAL_BANNER: &[&str] = &[
"\u{26a0} EXPERIMENTAL \u{26a0}",
"",
"This feature is under active development.",
"Expect rough edges, bugs, and breaking changes.",
"Mascot below not finalized.",
];
const USER_COLOR: Color = Color::Rgb(80, 120, 200); const OTTO_COLOR: Color = Color::Rgb(232, 67, 67); const SYSTEM_COLOR: Color = Color::Rgb(160, 120, 200); const VI_NORMAL_COLOR: Color = Color::Rgb(255, 140, 80); const CODE_COLOR: Color = Color::Rgb(255, 140, 80); const DIM_COLOR: Color = Color::Rgb(100, 100, 100);
const WARNING_COLOR: Color = Color::Rgb(255, 200, 50); const DIM_OTTO_COLOR: Color = Color::Rgb(120, 45, 45); const POPUP_BG: Color = Color::Rgb(50, 50, 50);
const TEXT_COLOR: Color = Color::White;
const HEADING_COLOR: Color = Color::Rgb(130, 170, 255); const CHECK_COLOR: Color = Color::Rgb(80, 200, 120); const LINK_COLOR: Color = Color::Rgb(100, 160, 240); const DIFF_ADD_COLOR: Color = Color::Rgb(80, 200, 120); const DIFF_DEL_COLOR: Color = Color::Rgb(232, 80, 80); const DIFF_HUNK_COLOR: Color = Color::Rgb(130, 170, 255); const NOTE_COLOR: Color = Color::Rgb(100, 160, 240); const TIP_COLOR: Color = Color::Rgb(80, 200, 120); const IMPORTANT_COLOR: Color = Color::Rgb(180, 130, 240); const CAUTION_COLOR: Color = Color::Rgb(232, 80, 80); const TIMESTAMP_COLOR: Color = Color::Rgb(80, 80, 80);
const STREAM_CPS: f64 = 200.0;
const STREAM_BULK_THRESHOLD: usize = 200;
const STREAM_FAST_THRESHOLD: usize = 50;
const MAX_HISTORY: usize = 1000;
static SYNTAX_SET: std::sync::LazyLock<SyntaxSet> =
std::sync::LazyLock::new(SyntaxSet::load_defaults_nonewlines);
static THEME: std::sync::LazyLock<syntect::highlighting::Theme> = std::sync::LazyLock::new(|| {
let ts = ThemeSet::load_defaults();
ts.themes["base16-eighties.dark"].clone()
});
const MAX_INPUT_LINES: u16 = 8;
enum StreamMsg {
ProviderInfo {
provider_label: Option<String>,
model_label: String,
},
ConversationHistory {
generation: u64,
messages: Vec<Message>,
},
StopFinished {
error: Option<String>,
},
Stream {
generation: u64,
kind: StreamMsgKind,
},
}
enum StreamMsgKind {
ThreadId(String),
Delta(String),
ToolCallStart {
name: String,
arguments: String,
},
ToolCallOutput {
name: String,
output: String,
},
Finished {
status: OttoStreamStatus,
error: Option<String>,
},
Error(String),
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum InputMode {
Emacs,
ViInsert,
ViNormal,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Role {
User,
Otto,
System,
}
struct ToolCallData {
name: String,
arguments: String,
output: String,
}
struct Message {
role: Role,
content: String,
timestamp: SystemTime,
tool_call: Option<ToolCallData>,
}
struct History {
entries: Vec<String>,
position: Option<usize>,
saved_input: Vec<char>,
}
impl History {
fn load() -> Self {
let entries = Self::history_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|s| {
s.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect()
})
.unwrap_or_default();
Self {
entries,
position: None,
saved_input: Vec::new(),
}
}
fn push(&mut self, entry: &str) {
let entry = entry.trim().replace('\n', "\\n");
if entry.is_empty() {
return;
}
if self.entries.last().is_some_and(|last| *last == entry) {
return;
}
self.entries.push(entry.clone());
if self.entries.len() > MAX_HISTORY {
self.entries.remove(0);
}
self.position = None;
if let Some(path) = Self::history_path() {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
let _ = writeln!(f, "{entry}");
}
}
}
fn decode(entry: &str) -> Vec<char> {
entry.replace("\\n", "\n").chars().collect()
}
fn prev(&mut self, current_input: &[char]) -> Option<Vec<char>> {
if self.entries.is_empty() {
return None;
}
let new_pos = match self.position {
None => {
self.saved_input = current_input.to_vec();
self.entries.len() - 1
}
Some(0) => return None,
Some(p) => p - 1,
};
self.position = Some(new_pos);
Some(Self::decode(&self.entries[new_pos]))
}
fn next(&mut self) -> Option<Vec<char>> {
let pos = self.position?;
if pos + 1 >= self.entries.len() {
self.position = None;
Some(self.saved_input.clone())
} else {
self.position = Some(pos + 1);
Some(Self::decode(&self.entries[pos + 1]))
}
}
fn history_path() -> Option<std::path::PathBuf> {
std::env::var("HOME").ok().map(|h| {
std::path::PathBuf::from(h)
.join(".ascend-tools")
.join("history")
})
}
}
struct App {
messages: Vec<Message>,
input: Vec<char>,
cursor: usize,
input_mode: InputMode,
scroll: usize,
auto_scroll: bool,
streaming: bool,
stream_buffer: String,
stream_pending: VecDeque<char>,
last_stream_tick: Instant,
stream_start: Option<Instant>,
thread_id: Option<String>,
runtime_uuid: Option<String>,
otto_model: Option<OttoModel>,
provider_label: Option<String>,
model_label: String,
context_label: Option<String>,
pending_request: Option<OttoChatRequest>,
should_quit: bool,
spinner_frame: usize,
last_spinner: Instant,
vi_pending: Option<char>,
yank_register: String,
completion_index: Option<usize>,
history: History,
show_timestamps: bool,
active_tool_call: Option<(String, String)>,
expand_tool_calls: bool,
stream_generation: u64,
stop_pending: Option<u64>,
interrupting: bool,
force_quit: bool,
show_raw_markdown: bool,
}
impl App {
fn new(
runtime_uuid: Option<String>,
otto_model: Option<OttoModel>,
provider_label: Option<String>,
model_label: String,
context_label: Option<String>,
thread_id: Option<String>,
) -> Self {
Self {
messages: Vec::new(),
input: Vec::new(),
cursor: 0,
input_mode: InputMode::ViInsert,
scroll: 0,
auto_scroll: true,
streaming: false,
stream_buffer: String::new(),
stream_pending: VecDeque::new(),
last_stream_tick: Instant::now(),
stream_start: None,
thread_id,
runtime_uuid,
otto_model,
provider_label,
model_label,
context_label,
pending_request: None,
should_quit: false,
spinner_frame: 0,
last_spinner: Instant::now(),
vi_pending: None,
yank_register: String::new(),
completion_index: None,
history: History::load(),
show_timestamps: false,
active_tool_call: None,
expand_tool_calls: false,
stream_generation: 0,
stop_pending: None,
interrupting: false,
force_quit: false,
show_raw_markdown: false,
}
}
fn input_line_count(&self, width: u16) -> u16 {
let avail = (width as usize).saturating_sub(3); if avail == 0 {
return 1;
}
let mut rows = 1usize;
let mut col = 0usize;
for &ch in &self.input {
if ch == '\n' {
rows += 1;
col = 0;
} else {
if col >= avail {
rows += 1;
col = 0;
}
col += 1;
}
}
if self.cursor == self.input.len() && col >= avail {
rows += 1;
}
(rows as u16).min(MAX_INPUT_LINES)
}
fn handle_paste(&mut self, text: &str) {
if self.input_mode == InputMode::ViNormal {
self.input_mode = InputMode::ViInsert;
}
let chars: Vec<char> = text.chars().collect();
let count = chars.len();
self.input.splice(self.cursor..self.cursor, chars);
self.cursor += count;
self.completion_index = None;
}
fn handle_key(&mut self, key: KeyEvent, cancelled_gen: &AtomicU64) {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
if self.interrupting {
self.force_quit = true;
self.should_quit = true;
return;
}
if self.streaming {
self.cancel_stream(cancelled_gen);
} else {
self.should_quit = true;
}
return;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('r') {
self.show_raw_markdown = !self.show_raw_markdown;
return;
}
if key.code == KeyCode::Esc && key.modifiers == KeyModifiers::NONE && self.streaming {
if self.interrupting {
return;
}
self.cancel_stream(cancelled_gen);
return;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('o') {
self.expand_tool_calls = !self.expand_tool_calls;
return;
}
match self.input_mode {
InputMode::Emacs => self.handle_key_emacs(key),
InputMode::ViInsert => self.handle_key_vi_insert(key),
InputMode::ViNormal => self.handle_key_vi_normal(key),
}
}
fn handle_key_emacs(&mut self, key: KeyEvent) {
if key.code == KeyCode::Tab && key.modifiers == KeyModifiers::NONE {
self.complete_tab();
return;
}
self.reset_completion();
match (key.modifiers, key.code) {
(KeyModifiers::NONE, KeyCode::Enter) => self.submit(),
(KeyModifiers::ALT, KeyCode::Enter) | (KeyModifiers::SHIFT, KeyCode::Enter) => {
self.input.insert(self.cursor, '\n');
self.cursor += 1;
}
(KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(c)) => {
self.input.insert(self.cursor, c);
self.cursor += 1;
self.history.position = None;
}
(KeyModifiers::NONE, KeyCode::Backspace) => {
if self.cursor > 0 {
self.cursor -= 1;
self.input.remove(self.cursor);
self.history.position = None;
}
}
(KeyModifiers::NONE, KeyCode::Delete) => {
if self.cursor < self.input.len() {
self.input.remove(self.cursor);
self.history.position = None;
}
}
(KeyModifiers::NONE, KeyCode::Left) => {
self.cursor = self.cursor.saturating_sub(1);
}
(KeyModifiers::NONE, KeyCode::Right) => {
self.cursor = (self.cursor + 1).min(self.input.len());
}
(KeyModifiers::ALT, KeyCode::Left) => self.cursor = self.word_back(),
(KeyModifiers::ALT, KeyCode::Right) => self.cursor = self.word_fwd(),
(KeyModifiers::NONE, KeyCode::Home) | (KeyModifiers::CONTROL, KeyCode::Char('a')) => {
self.cursor = 0;
}
(KeyModifiers::NONE, KeyCode::End) | (KeyModifiers::CONTROL, KeyCode::Char('e')) => {
self.cursor = self.input.len();
}
(KeyModifiers::CONTROL, KeyCode::Char('u')) => {
self.input.drain(..self.cursor);
self.cursor = 0;
}
(KeyModifiers::CONTROL, KeyCode::Char('k')) => {
self.input.truncate(self.cursor);
}
(KeyModifiers::CONTROL, KeyCode::Char('w')) => {
let new_cursor = self.word_back();
self.input.drain(new_cursor..self.cursor);
self.cursor = new_cursor;
}
(KeyModifiers::NONE, KeyCode::Up) => {
if let Some(chars) = self.history.prev(&self.input) {
self.input = chars;
self.cursor = self.input.len();
self.completion_index = None;
}
}
(KeyModifiers::NONE, KeyCode::Down) => {
if let Some(chars) = self.history.next() {
self.input = chars;
self.cursor = self.input.len();
self.completion_index = None;
}
}
(KeyModifiers::NONE, KeyCode::PageUp) => self.scroll_up(10),
(KeyModifiers::NONE, KeyCode::PageDown) => self.scroll_down(10),
_ => {}
}
}
fn handle_key_vi_insert(&mut self, key: KeyEvent) {
if key.code == KeyCode::Esc && key.modifiers == KeyModifiers::NONE {
self.input_mode = InputMode::ViNormal;
if self.cursor > 0 {
self.cursor -= 1;
}
return;
}
self.handle_key_emacs(key);
}
fn handle_key_vi_normal(&mut self, key: KeyEvent) {
if let Some(pending) = self.vi_pending.take() {
match (pending, key.code) {
('d', KeyCode::Char('d')) => {
self.yank_register = self.input.iter().collect();
self.input.clear();
self.cursor = 0;
}
('y', KeyCode::Char('y')) => {
self.yank_register = self.input.iter().collect();
}
_ => {}
}
return;
}
match (key.modifiers, key.code) {
(KeyModifiers::NONE, KeyCode::Char('i')) => {
self.input_mode = InputMode::ViInsert;
}
(KeyModifiers::NONE, KeyCode::Char('a')) => {
self.input_mode = InputMode::ViInsert;
self.cursor = (self.cursor + 1).min(self.input.len());
}
(KeyModifiers::SHIFT, KeyCode::Char('I')) => {
self.input_mode = InputMode::ViInsert;
self.cursor = 0;
}
(KeyModifiers::SHIFT, KeyCode::Char('A')) => {
self.input_mode = InputMode::ViInsert;
self.cursor = self.input.len();
}
(KeyModifiers::NONE, KeyCode::Char('h') | KeyCode::Left) => {
self.cursor = self.cursor.saturating_sub(1);
}
(KeyModifiers::NONE, KeyCode::Char('l') | KeyCode::Right) => {
let max = self.input.len().saturating_sub(1);
self.cursor = (self.cursor + 1).min(max);
}
(KeyModifiers::NONE, KeyCode::Char('0')) => self.cursor = 0,
(KeyModifiers::SHIFT, KeyCode::Char('$')) => {
self.cursor = self.input.len().saturating_sub(1);
}
(KeyModifiers::NONE, KeyCode::Char('w')) => self.cursor = self.word_fwd(),
(KeyModifiers::NONE, KeyCode::Char('b')) => self.cursor = self.word_back(),
(KeyModifiers::NONE, KeyCode::Char('e')) => self.cursor = self.word_end(),
(KeyModifiers::NONE, KeyCode::Char('x')) => {
if self.cursor < self.input.len() {
let ch = self.input.remove(self.cursor);
self.yank_register = ch.to_string();
if self.cursor > 0 && self.cursor >= self.input.len() {
self.cursor = self.input.len().saturating_sub(1);
}
}
}
(KeyModifiers::NONE, KeyCode::Char('d')) => {
self.vi_pending = Some('d');
}
(KeyModifiers::NONE, KeyCode::Char('y')) => {
self.vi_pending = Some('y');
}
(KeyModifiers::NONE, KeyCode::Char('p')) => {
if !self.yank_register.is_empty() {
let pos = (self.cursor + 1).min(self.input.len());
let chars: Vec<char> = self.yank_register.chars().collect();
let count = chars.len();
self.input.splice(pos..pos, chars);
self.cursor = pos + count - 1;
}
}
(KeyModifiers::SHIFT, KeyCode::Char('P')) => {
if !self.yank_register.is_empty() {
let chars: Vec<char> = self.yank_register.chars().collect();
let count = chars.len();
self.input.splice(self.cursor..self.cursor, chars);
self.cursor += count.saturating_sub(1);
}
}
(KeyModifiers::NONE, KeyCode::Enter) => self.submit(),
(KeyModifiers::NONE, KeyCode::Char('k') | KeyCode::Up) if self.input.is_empty() => {
if let Some(chars) = self.history.prev(&self.input) {
self.input = chars;
self.cursor = self.input.len().saturating_sub(1);
self.completion_index = None;
}
}
(KeyModifiers::NONE, KeyCode::Char('j') | KeyCode::Down) if self.input.is_empty() => {
if let Some(chars) = self.history.next() {
self.input = chars;
self.cursor = self.input.len().saturating_sub(1);
self.completion_index = None;
}
}
(KeyModifiers::NONE, KeyCode::PageUp) => self.scroll_up(10),
(KeyModifiers::NONE, KeyCode::PageDown) => self.scroll_down(10),
(KeyModifiers::CONTROL, KeyCode::Char('u')) => self.scroll_up(15),
(KeyModifiers::CONTROL, KeyCode::Char('d')) => self.scroll_down(15),
_ => {}
}
}
fn input_str(&self) -> String {
self.input.iter().collect()
}
fn completions(&self) -> Vec<&'static str> {
let text = self.input_str();
if !text.starts_with('/') {
return Vec::new();
}
COMMANDS
.iter()
.filter(|cmd| cmd.starts_with(&text) && **cmd != text)
.copied()
.collect()
}
fn complete_tab(&mut self) {
let matches = self.completions();
if matches.is_empty() {
self.completion_index = None;
return;
}
let idx = match self.completion_index {
Some(i) => (i + 1) % matches.len(),
None => 0,
};
self.completion_index = Some(idx);
let cmd = matches[idx];
self.input = cmd.chars().collect();
self.cursor = self.input.len();
}
fn reset_completion(&mut self) {
self.completion_index = None;
}
fn word_fwd(&self) -> usize {
let mut i = self.cursor;
while i < self.input.len() && !self.input[i].is_whitespace() {
i += 1;
}
while i < self.input.len() && self.input[i].is_whitespace() {
i += 1;
}
i
}
fn word_back(&self) -> usize {
if self.cursor == 0 {
return 0;
}
let mut i = self.cursor - 1;
while i > 0 && self.input[i].is_whitespace() {
i -= 1;
}
while i > 0 && !self.input[i - 1].is_whitespace() {
i -= 1;
}
i
}
fn word_end(&self) -> usize {
if self.input.is_empty() {
return 0;
}
let last = self.input.len() - 1;
let mut i = self.cursor;
if i < last {
i += 1;
}
while i < last && self.input[i].is_whitespace() {
i += 1;
}
while i < last && !self.input[i + 1].is_whitespace() {
i += 1;
}
i
}
fn scroll_up(&mut self, n: usize) {
self.scroll = self.scroll.saturating_add(n);
self.auto_scroll = false;
}
fn scroll_down(&mut self, n: usize) {
self.scroll = self.scroll.saturating_sub(n);
if self.scroll == 0 {
self.auto_scroll = true;
}
}
fn submit(&mut self) {
if self.streaming {
self.push_system("Waiting for response...");
return;
}
let text: String = self.input.drain(..).collect();
self.cursor = 0;
let text = text.trim().to_string();
if text.is_empty() {
return;
}
if text.starts_with('/') {
self.handle_command(&text);
return;
}
self.history.push(&text);
self.messages.push(Message {
role: Role::User,
content: text.clone(),
timestamp: SystemTime::now(),
tool_call: None,
});
self.pending_request = Some(OttoChatRequest {
prompt: text,
runtime_uuid: self.runtime_uuid.clone(),
thread_id: self.thread_id.clone(),
model: self.otto_model.clone(),
});
self.streaming = true;
self.stream_buffer.clear();
self.stream_pending.clear();
self.last_stream_tick = Instant::now();
self.stream_start = Some(Instant::now());
self.auto_scroll = true;
self.scroll = 0;
if self.input_mode == InputMode::ViNormal {
self.input_mode = InputMode::ViInsert;
}
}
fn push_system(&mut self, content: impl Into<String>) {
self.messages.push(Message {
role: Role::System,
content: content.into(),
timestamp: SystemTime::now(),
tool_call: None,
});
}
fn handle_command(&mut self, cmd: &str) {
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
match parts[0] {
"/vim" | "/vi" => {
self.input_mode = InputMode::ViNormal;
self.push_system("Vi mode");
}
"/emacs" => {
self.input_mode = InputMode::Emacs;
self.push_system("Emacs mode");
}
"/clear" => {
self.messages.clear();
self.scroll = 0;
self.thread_id = None;
self.push_system("Thread cleared");
}
"/copy" => {
let last_otto = self
.messages
.iter()
.rev()
.find(|m| m.role == Role::Otto)
.map(|m| m.content.clone());
match last_otto {
Some(text) => {
match arboard::Clipboard::new().and_then(|mut cb| cb.set_text(text)) {
Ok(()) => self.push_system("Copied to clipboard"),
Err(e) => self.push_system(format!("Clipboard error: {e}")),
}
}
None => self.push_system("No Otto message to copy"),
}
}
"/timestamps" => {
self.show_timestamps = !self.show_timestamps;
let state = if self.show_timestamps { "on" } else { "off" };
self.push_system(format!("Timestamps {state}"));
}
"/quit" | "/exit" | "/q" => {
self.should_quit = true;
}
"/help" => {
self.push_system(concat!(
"Commands:\n",
" /emacs Switch to Emacs keybindings\n",
" /vim Switch to Vi keybindings (default)\n",
" /copy Copy last Otto response to clipboard\n",
" /timestamps Toggle message timestamps\n",
" /clear Clear chat and start new thread\n",
" /quit, /exit Exit\n",
" /help Show this help\n",
"\n",
"Keys:\n",
" Enter Send message\n",
" Alt+Enter Insert newline\n",
" Esc Vi normal mode\n",
" Up/Down Input history\n",
" PageUp/Down Scroll chat\n",
" Tab Complete /command\n",
" Ctrl+o Toggle tool call details\n",
" Ctrl+C Cancel stream / Exit",
));
}
other => {
self.push_system(format!("Unknown command: {other}"));
}
}
}
fn handle_stream_msg(&mut self, msg: StreamMsg) {
match msg {
StreamMsg::ProviderInfo {
provider_label: provider,
model_label: model,
} => {
self.provider_label = provider;
self.model_label = model;
}
StreamMsg::ConversationHistory {
generation,
messages,
} => {
if generation == self.stream_generation && self.messages.is_empty() {
self.messages = messages;
}
}
StreamMsg::StopFinished { error } => {
if self.interrupting {
self.finish_stream();
if let Some(err) = error {
self.push_system(format!("Interrupt failed: {err}"));
} else {
self.push_system("Cancelled");
}
}
}
StreamMsg::Stream { generation, kind } => {
if generation != self.stream_generation {
return;
}
self.handle_stream_kind(kind);
}
}
}
fn handle_stream_kind(&mut self, kind: StreamMsgKind) {
match kind {
StreamMsgKind::ThreadId(tid) => {
self.thread_id = Some(tid);
}
StreamMsgKind::Delta(text) => {
self.stream_pending.extend(text.chars());
}
StreamMsgKind::ToolCallStart { name, arguments } => {
self.flush_stream_text();
self.active_tool_call = Some((name, arguments));
}
StreamMsgKind::ToolCallOutput { name, output } => {
let arguments = self
.active_tool_call
.take()
.map(|(_, args)| args)
.unwrap_or_default();
let output_summary = truncate(&output, 80);
self.messages.push(Message {
role: Role::System,
content: format!("\u{2699} {name} \u{2192} {output_summary}"),
timestamp: SystemTime::now(),
tool_call: Some(ToolCallData {
name,
arguments,
output,
}),
});
}
StreamMsgKind::Finished { status, error } => match status {
OttoStreamStatus::Completed => {
let should_bell = self
.stream_start
.is_some_and(|s| s.elapsed() > Duration::from_secs(3));
self.finish_stream();
if should_bell {
let _ =
crossterm::execute!(std::io::stderr(), crossterm::style::Print("\x07"));
}
}
OttoStreamStatus::Cancelled => {
}
OttoStreamStatus::Interrupted => {
self.finish_stream();
let detail = error.unwrap_or_else(|| "stream interrupted".to_string());
self.push_system(format!("Connection lost: {detail}"));
}
},
StreamMsgKind::Error(err) => {
self.finish_stream();
let message = if err.contains("Otto stream ended unexpectedly") {
format!("Connection lost: {err}")
} else {
format!("Error: {err}")
};
self.push_system(message);
}
}
}
fn flush_stream_text(&mut self) {
let remaining: String = self.stream_pending.drain(..).collect();
self.stream_buffer.push_str(&remaining);
let content = std::mem::take(&mut self.stream_buffer);
if !content.is_empty() {
self.messages.push(Message {
role: Role::Otto,
content,
timestamp: SystemTime::now(),
tool_call: None,
});
}
}
fn cancel_stream(&mut self, cancelled_gen: &AtomicU64) {
if self.interrupting {
return;
}
let cancelled_generation = self.stream_generation;
cancelled_gen.store(cancelled_generation, Ordering::Release);
self.stream_generation = cancelled_generation.wrapping_add(1);
self.flush_stream_text();
self.active_tool_call = None;
self.interrupting = true;
self.stop_pending = Some(cancelled_generation);
}
fn finish_stream(&mut self) {
self.flush_stream_text();
self.streaming = false;
self.interrupting = false;
self.active_tool_call = None;
self.stream_start = None;
}
fn tick_stream(&mut self) {
if self.stream_pending.is_empty() {
return;
}
let elapsed = self.last_stream_tick.elapsed();
let chars_due = (elapsed.as_secs_f64() * STREAM_CPS) as usize;
if chars_due == 0 {
return;
}
self.last_stream_tick = Instant::now();
let pending = self.stream_pending.len();
let n = if pending > STREAM_BULK_THRESHOLD {
pending.min(chars_due + 100)
} else if pending > STREAM_FAST_THRESHOLD {
chars_due * 3
} else {
chars_due
};
let n = n.min(pending);
let chunk: String = self.stream_pending.drain(..n).collect();
self.stream_buffer.push_str(&chunk);
}
fn take_pending_request(&mut self) -> Option<OttoChatRequest> {
self.pending_request.take()
}
fn tick_spinner(&mut self) {
if self.streaming && self.last_spinner.elapsed() >= SPINNER_INTERVAL {
self.spinner_frame = (self.spinner_frame + 1) % SPINNER.len();
self.last_spinner = Instant::now();
}
}
fn render(&self, frame: &mut Frame) {
let area = frame.area();
if area.height < 5 {
return;
}
let input_height = self.input_line_count(area.width);
let chunks = Layout::vertical([
Constraint::Min(1), Constraint::Length(1), Constraint::Length(input_height), Constraint::Length(1), Constraint::Length(1), ])
.split(area);
self.render_chat(frame, chunks[0]);
self.render_rule(frame, chunks[1]);
self.render_input(frame, chunks[2]);
self.render_rule(frame, chunks[3]);
self.render_completions(frame, chunks[0], chunks[1]);
self.render_status(frame, chunks[4]);
let cursor_style = match self.input_mode {
InputMode::ViNormal => SetCursorStyle::SteadyBlock,
_ => SetCursorStyle::BlinkingBar,
};
let _ = crossterm::execute!(std::io::stderr(), cursor_style);
}
fn render_chat(&self, frame: &mut Frame, area: Rect) {
let has_content = self.streaming || !self.messages.is_empty();
if !has_content {
self.render_splash(frame, area);
return;
}
let mut lines: Vec<Line<'_>> = Vec::new();
for msg in &self.messages {
if !lines.is_empty() {
lines.push(Line::raw(""));
}
let (label, color) = match msg.role {
Role::User => (" you", USER_COLOR),
Role::Otto => (" otto", OTTO_COLOR),
Role::System => ("", SYSTEM_COLOR),
};
if !label.is_empty() {
let mut label_spans = vec![Span::styled(label, Style::default().fg(color).bold())];
if self.show_timestamps {
label_spans.push(Span::styled(
format!(" {}", format_time(msg.timestamp)),
Style::default().fg(TIMESTAMP_COLOR),
));
}
lines.push(Line::from(label_spans));
} else if self.show_timestamps {
lines.push(Line::from(Span::styled(
format!(" {}", format_time(msg.timestamp)),
Style::default().fg(TIMESTAMP_COLOR),
)));
}
if let Some(tc) = &msg.tool_call {
lines.extend(render_tool_call(tc, self.expand_tool_calls));
} else {
lines.extend(render_markdown(
&msg.content,
msg.role,
self.show_raw_markdown,
));
}
}
if self.streaming {
if !lines.is_empty() {
lines.push(Line::raw(""));
}
lines.push(Line::from(Span::styled(
" otto",
Style::default().fg(OTTO_COLOR).bold(),
)));
if self.stream_buffer.is_empty() && self.stream_pending.is_empty() {
let label = if self.interrupting {
format!(" {} Stopping...", SPINNER[self.spinner_frame])
} else if let Some((tool, _)) = &self.active_tool_call {
format!(" {} \u{2699} {tool}...", SPINNER[self.spinner_frame])
} else {
format!(" {} Ascending...", SPINNER[self.spinner_frame])
};
lines.push(Line::from(Span::styled(
label,
Style::default().fg(DIM_OTTO_COLOR),
)));
} else if self.interrupting {
lines.extend(render_markdown(
&self.stream_buffer,
Role::Otto,
self.show_raw_markdown,
));
lines.push(Line::from(Span::styled(
format!(" {} Stopping...", SPINNER[self.spinner_frame]),
Style::default().fg(DIM_OTTO_COLOR),
)));
} else if let Some((tool, _)) = &self.active_tool_call {
lines.extend(render_markdown(
&self.stream_buffer,
Role::Otto,
self.show_raw_markdown,
));
lines.push(Line::from(Span::styled(
format!(" {} \u{2699} {tool}...", SPINNER[self.spinner_frame]),
Style::default().fg(DIM_OTTO_COLOR),
)));
} else {
lines.extend(render_markdown(
&self.stream_buffer,
Role::Otto,
self.show_raw_markdown,
));
}
}
lines.push(Line::raw(""));
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
let total_rendered = paragraph.line_count(area.width);
let visible = area.height as usize;
let max_scroll = total_rendered.saturating_sub(visible);
let clamped_scroll = self.scroll.min(max_scroll);
let scroll_y = max_scroll.saturating_sub(clamped_scroll);
let paragraph = paragraph.scroll((scroll_y.min(u16::MAX as usize) as u16, 0));
frame.render_widget(Clear, area);
frame.render_widget(paragraph, area);
if total_rendered > visible {
let scrollbar_position = max_scroll.saturating_sub(clamped_scroll);
let mut scrollbar_state = ScrollbarState::new(max_scroll).position(scrollbar_position);
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.style(Style::default().fg(DIM_COLOR)),
area,
&mut scrollbar_state,
);
}
}
fn render_splash(&self, frame: &mut Frame, area: Rect) {
let banner_height = EXPERIMENTAL_BANNER.len() as u16 + 2; let splash_height = SPLASH.len() as u16;
let total_height = splash_height + banner_height;
let y_offset = area.height.saturating_sub(total_height) / 2;
let warning_style = Style::default().fg(WARNING_COLOR).bold();
let mut lines: Vec<Line<'_>> = Vec::new();
lines.push(Line::raw(""));
for &line in EXPERIMENTAL_BANNER {
let display_width = line.chars().count();
let pad = (area.width as usize).saturating_sub(display_width) / 2;
let padded = format!("{:>width$}{}", "", line, width = pad);
lines.push(Line::from(Span::styled(padded, warning_style)));
}
lines.push(Line::raw(""));
for &line in SPLASH {
let display_width = line.chars().count();
let pad = (area.width as usize).saturating_sub(display_width) / 2;
let padded = format!("{:>width$}{}", "", line, width = pad);
if line.contains("/help") {
lines.push(Line::from(Span::styled(
padded,
Style::default().fg(DIM_COLOR),
)));
} else {
lines.push(Line::from(Span::styled(
padded,
Style::default().fg(OTTO_COLOR),
)));
}
}
let clamped_height = total_height.min(area.height);
let splash_area = Rect::new(area.x, area.y + y_offset, area.width, clamped_height);
frame.render_widget(Paragraph::new(lines), splash_area);
}
fn render_rule(&self, frame: &mut Frame, area: Rect) {
let rule_color = match self.input_mode {
InputMode::ViNormal => VI_NORMAL_COLOR,
_ if self.streaming => DIM_COLOR,
_ => OTTO_COLOR,
};
let rule = "\u{2500}".repeat(area.width as usize);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
rule,
Style::default().fg(rule_color),
))),
area,
);
}
fn render_input(&self, frame: &mut Frame, area: Rect) {
let prompt = match self.input_mode {
InputMode::ViNormal => " \u{2502} ",
_ => " \u{276f} ",
};
let prompt_len = 3usize;
let avail = (area.width as usize).saturating_sub(prompt_len);
if avail == 0 {
return;
}
let prompt_color = match self.input_mode {
InputMode::ViNormal => VI_NORMAL_COLOR,
_ if self.streaming => DIM_COLOR,
_ => OTTO_COLOR,
};
let mut rows: Vec<String> = vec![String::new()];
let mut col = 0usize;
let mut cursor_row = 0usize;
let mut cursor_col = 0usize;
for (i, &ch) in self.input.iter().enumerate() {
if i == self.cursor {
cursor_row = rows.len() - 1;
cursor_col = col;
}
if ch == '\n' {
rows.push(String::new());
col = 0;
} else {
if col >= avail {
rows.push(String::new());
col = 0;
}
rows.last_mut().unwrap().push(ch);
col += 1;
}
}
if self.cursor == self.input.len() {
if col >= avail {
rows.push(String::new());
cursor_row = rows.len() - 1;
cursor_col = 0;
} else {
cursor_row = rows.len() - 1;
cursor_col = col;
}
}
let max_visible = area.height as usize;
let scroll_offset = if cursor_row >= max_visible {
cursor_row - max_visible + 1
} else {
0
};
let visible_end = (scroll_offset + max_visible).min(rows.len());
let mut render_lines: Vec<Line<'_>> = Vec::new();
for (i, row) in rows
.iter()
.enumerate()
.take(visible_end)
.skip(scroll_offset)
{
let p = if i == 0 { prompt } else { " " };
let p_style = if i == 0 {
Style::default().fg(prompt_color)
} else {
Style::default().fg(DIM_COLOR)
};
render_lines.push(Line::from(vec![
Span::styled(p, p_style),
Span::raw(row.clone()),
]));
}
frame.render_widget(Paragraph::new(render_lines), area);
if !self.streaming {
let cx = area.x + prompt_len as u16 + cursor_col as u16;
let cy = area.y + (cursor_row - scroll_offset) as u16;
frame.set_cursor_position((cx, cy));
}
}
fn render_completions(&self, frame: &mut Frame, chat_area: Rect, rule_area: Rect) {
let matches = self.completions();
if matches.is_empty() {
return;
}
let height = matches.len().min(8) as u16;
let width = matches.iter().map(|s| s.len()).max().unwrap_or(0) as u16 + 4;
let x = rule_area.x + 1;
let y = chat_area.bottom().saturating_sub(height);
let popup = Rect::new(x, y, width.min(rule_area.width), height);
frame.render_widget(Clear, popup);
let items: Vec<Line<'_>> = matches
.iter()
.enumerate()
.map(|(i, cmd)| {
let style = if self.completion_index == Some(i) {
Style::default().fg(TEXT_COLOR).bg(OTTO_COLOR).bold()
} else {
Style::default().fg(TEXT_COLOR).bg(POPUP_BG)
};
Line::from(Span::styled(format!(" {cmd} "), style))
})
.collect();
let block = Block::default()
.borders(Borders::NONE)
.style(Style::default().bg(POPUP_BG));
let paragraph = Paragraph::new(items).block(block);
frame.render_widget(paragraph, popup);
}
fn render_status(&self, frame: &mut Frame, area: Rect) {
let (mode, mode_color) = match self.input_mode {
InputMode::Emacs => ("emacs", SYSTEM_COLOR),
InputMode::ViInsert => ("INSERT", VI_NORMAL_COLOR),
InputMode::ViNormal => ("NORMAL", VI_NORMAL_COLOR),
};
let (mode, mode_color) = if self.interrupting {
("STOPPING", WARNING_COLOR)
} else {
(mode, mode_color)
};
let mut parts = vec![Span::styled(
format!(" {mode}"),
Style::default().fg(mode_color),
)];
let pill_style = Style::default().fg(DIM_OTTO_COLOR);
if let Some(label) = &self.context_label {
parts.push(Span::raw(" "));
parts.push(Span::styled(format!(" {label} "), pill_style));
}
if let Some(provider) = &self.provider_label {
parts.push(Span::raw(" "));
parts.push(Span::styled(format!(" provider:{provider} "), pill_style));
}
if !self.model_label.is_empty() {
parts.push(Span::raw(" "));
parts.push(Span::styled(
format!(" model:{} ", self.model_label),
pill_style,
));
}
if let Some(tid) = &self.thread_id {
let short: String = tid.chars().take(12).collect();
parts.push(Span::raw(" "));
parts.push(Span::styled(format!(" thread:{short} "), pill_style));
}
let msg_count = self
.messages
.iter()
.filter(|m| m.role != Role::System)
.count();
if msg_count > 0 {
parts.push(Span::raw(" "));
parts.push(Span::styled(format!(" {msg_count} messages "), pill_style));
}
let total_width: usize = parts.iter().map(|s| s.width()).sum();
if total_width > area.width as usize {
let mut width = 0;
let mut truncated = Vec::new();
for span in parts {
width += span.width();
if width > area.width as usize {
break;
}
truncated.push(span);
}
frame.render_widget(Paragraph::new(Line::from(truncated)), area);
} else {
frame.render_widget(Paragraph::new(Line::from(parts)), area);
}
}
}
fn truncate(s: &str, max_len: usize) -> String {
if s.chars().count() <= max_len {
s.to_string()
} else {
let truncated: String = s.chars().take(max_len).collect();
format!("{truncated}...")
}
}
fn format_time(time: SystemTime) -> String {
let elapsed = time.elapsed().unwrap_or_default();
let secs = elapsed.as_secs();
if secs < 60 {
"just now".to_string()
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else {
format!("{}h ago", secs / 3600)
}
}
fn render_markdown(text: &str, role: Role, raw: bool) -> Vec<Line<'static>> {
if raw {
return render_raw(text, role);
}
render_markdown_parsed(text, role)
}
fn render_tool_call(tc: &ToolCallData, expanded: bool) -> Vec<Line<'static>> {
let indent = " ";
let sys_style = Style::default().fg(SYSTEM_COLOR).italic();
let dim_style = Style::default().fg(DIM_COLOR);
let text_style = Style::default().fg(TEXT_COLOR);
let mut lines = Vec::new();
lines.push(Line::from(Span::styled(
format!("{indent}\u{2699} {}", tc.name),
sys_style,
)));
if !expanded {
let summary = truncate(&tc.output, 80);
lines.push(Line::from(vec![
Span::styled(format!("{indent}\u{2192} {summary}"), text_style),
Span::styled(" Ctrl+o to expand", dim_style),
]));
return lines;
}
let pretty = |raw: &str| -> String {
serde_json::from_str::<serde_json::Value>(raw)
.ok()
.and_then(|v| serde_json::to_string_pretty(&v).ok())
.unwrap_or_else(|| raw.to_string())
};
for (label, raw) in [("arguments", &tc.arguments), ("output", &tc.output)] {
if raw.is_empty() {
continue;
}
let content = pretty(raw);
lines.push(Line::from(Span::styled(
format!("{indent}\u{256d}\u{2500} {label} \u{2500}"),
dim_style,
)));
for line in content.lines() {
lines.push(Line::from(Span::styled(
format!("{indent}\u{2502} {line}"),
text_style,
)));
}
lines.push(Line::from(Span::styled(
format!("{indent}\u{2570}\u{2500}\u{2500}"),
dim_style,
)));
}
lines.push(Line::from(Span::styled(
format!("{indent}Ctrl+o to collapse"),
dim_style,
)));
lines
}
fn render_raw(text: &str, role: Role) -> Vec<Line<'static>> {
let base_style = match role {
Role::System => Style::default().fg(SYSTEM_COLOR).italic(),
_ => Style::default(),
};
text.lines()
.map(|line| {
Line::from(vec![
Span::raw(" "),
Span::styled(line.to_string(), base_style),
])
})
.collect()
}
fn render_markdown_parsed(text: &str, role: Role) -> Vec<Line<'static>> {
let base_style = match role {
Role::System => Style::default().fg(SYSTEM_COLOR).italic(),
_ => Style::default(),
};
let mut md = MdRenderer {
lines: Vec::new(),
spans: Vec::new(),
style_stack: vec![base_style],
base_indent: " ".to_string(),
list_indent: String::new(),
list_stack: Vec::new(),
in_code_block: false,
code_block_lang: String::new(),
highlighter: None,
blockquote_depth: 0,
in_heading: false,
in_table: false,
in_table_header: false,
table_cell_spans: Vec::new(),
table_cell_texts: Vec::new(),
table_row_spans: Vec::new(),
table_header_spans: Vec::new(),
table_body_spans: Vec::new(),
table_col_widths: Vec::new(),
table_alignments: Vec::new(),
link_url: None,
link_text: String::new(),
};
let opts = Options::ENABLE_TABLES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS
| Options::ENABLE_GFM;
let parser = MdParser::new_ext(text, opts);
for event in parser {
md.process(event);
}
md.flush_line();
md.lines
}
#[derive(Clone)]
enum ListKind {
Unordered,
Ordered { next: u64, max_digits: usize },
}
#[derive(Clone)]
struct ListEntry {
kind: ListKind,
parent_indent: String,
}
struct MdRenderer {
lines: Vec<Line<'static>>,
spans: Vec<Span<'static>>,
style_stack: Vec<Style>,
base_indent: String,
list_indent: String,
list_stack: Vec<ListEntry>,
in_code_block: bool,
code_block_lang: String,
highlighter: Option<HighlightLines<'static>>,
blockquote_depth: usize,
in_heading: bool,
in_table: bool,
in_table_header: bool,
table_cell_spans: Vec<Span<'static>>,
table_cell_texts: Vec<String>,
table_row_spans: Vec<Vec<Span<'static>>>,
table_header_spans: Vec<Vec<Span<'static>>>,
table_body_spans: Vec<Vec<Vec<Span<'static>>>>,
table_col_widths: Vec<usize>,
table_alignments: Vec<pulldown_cmark::Alignment>,
link_url: Option<String>,
link_text: String,
}
impl MdRenderer {
fn current_style(&self) -> Style {
self.style_stack.last().copied().unwrap_or_default()
}
fn push_style(&mut self, modifier: impl FnOnce(Style) -> Style) {
let new = modifier(self.current_style());
self.style_stack.push(new);
}
fn pop_style(&mut self) {
if self.style_stack.len() > 1 {
self.style_stack.pop();
}
}
fn indent_prefix(&self) -> String {
let mut prefix = self.base_indent.clone();
for _ in 0..self.blockquote_depth {
prefix.push_str("\u{2502} ");
}
prefix.push_str(&self.list_indent);
prefix
}
fn indent_spans(&self) -> Vec<Span<'static>> {
let mut spans = Vec::new();
if self.blockquote_depth == 0 {
let mut prefix = self.base_indent.clone();
prefix.push_str(&self.list_indent);
spans.push(Span::raw(prefix));
} else {
spans.push(Span::raw(self.base_indent.clone()));
for _ in 0..self.blockquote_depth {
spans.push(Span::styled(
"\u{2502} ".to_string(),
Style::default().fg(DIM_COLOR),
));
}
if !self.list_indent.is_empty() {
spans.push(Span::raw(self.list_indent.clone()));
}
}
spans
}
fn flush_line(&mut self) {
if !self.spans.is_empty() {
self.lines.push(Line::from(std::mem::take(&mut self.spans)));
}
}
fn blank_line_if_needed(&mut self) {
self.flush_line();
if let Some(last) = self.lines.last()
&& !(last.spans.is_empty()
|| (last.spans.len() == 1 && last.spans[0].content.trim().is_empty()))
{
self.lines.push(Line::raw(""));
}
}
fn process(&mut self, event: MdEvent<'_>) {
match event {
MdEvent::Start(MdTag::Heading { level, .. }) => {
self.blank_line_if_needed();
self.in_heading = true;
match level {
pulldown_cmark::HeadingLevel::H1 => {
self.push_style(|s| s.fg(HEADING_COLOR).bold().underlined());
}
pulldown_cmark::HeadingLevel::H2 => {
self.push_style(|s| s.fg(HEADING_COLOR).bold());
}
pulldown_cmark::HeadingLevel::H3 => {
self.push_style(|s| s.bold());
}
_ => {
self.push_style(|s| s.bold().italic());
}
}
self.spans.extend(self.indent_spans());
}
MdEvent::End(MdTagEnd::Heading(_)) => {
self.in_heading = false;
self.pop_style();
self.flush_line();
}
MdEvent::Start(MdTag::Paragraph) => {
if !self.in_code_block && self.list_stack.is_empty() {
self.blank_line_if_needed();
}
}
MdEvent::End(MdTagEnd::Paragraph) => {
self.flush_line();
}
MdEvent::Start(MdTag::BlockQuote(kind)) => {
self.blank_line_if_needed();
self.blockquote_depth += 1;
self.push_style(|s| s.italic());
if let Some(bqk) = kind {
let (label, color) = match bqk {
BlockQuoteKind::Note => ("NOTE", NOTE_COLOR),
BlockQuoteKind::Tip => ("TIP", TIP_COLOR),
BlockQuoteKind::Important => ("IMPORTANT", IMPORTANT_COLOR),
BlockQuoteKind::Warning => ("WARNING", WARNING_COLOR),
BlockQuoteKind::Caution => ("CAUTION", CAUTION_COLOR),
};
let mut label_spans = self.indent_spans();
label_spans.push(Span::styled(
label.to_string(),
Style::default().fg(color).bold(),
));
self.lines.push(Line::from(label_spans));
}
}
MdEvent::End(MdTagEnd::BlockQuote(_)) => {
self.flush_line();
self.blockquote_depth = self.blockquote_depth.saturating_sub(1);
self.pop_style();
}
MdEvent::Start(MdTag::CodeBlock(kind)) => {
self.blank_line_if_needed();
self.in_code_block = true;
self.code_block_lang = match kind {
CodeBlockKind::Fenced(info) => {
info.split_whitespace().next().unwrap_or("").to_string()
}
CodeBlockKind::Indented => String::new(),
};
self.highlighter =
find_syntax(&self.code_block_lang).map(|syn| HighlightLines::new(syn, &THEME));
let prefix = self.indent_prefix();
let header = if self.code_block_lang.is_empty() {
format!("{prefix}\u{256d}\u{2500}\u{2500}")
} else {
format!("{prefix}\u{256d}\u{2500} {} \u{2500}", self.code_block_lang)
};
self.lines.push(Line::from(Span::styled(
header,
Style::default().fg(DIM_COLOR),
)));
}
MdEvent::End(MdTagEnd::CodeBlock) => {
let prefix = self.indent_prefix();
self.lines.push(Line::from(Span::styled(
format!("{prefix}\u{2570}\u{2500}\u{2500}"),
Style::default().fg(DIM_COLOR),
)));
self.in_code_block = false;
self.code_block_lang.clear();
self.highlighter = None;
}
MdEvent::Start(MdTag::List(first)) => {
if self.list_stack.is_empty() {
self.blank_line_if_needed();
}
let kind = match first {
Some(start) => ListKind::Ordered {
next: start,
max_digits: start.to_string().len(),
},
None => ListKind::Unordered,
};
self.list_stack.push(ListEntry {
kind,
parent_indent: self.list_indent.clone(),
});
}
MdEvent::End(MdTagEnd::List(_)) => {
self.list_stack.pop();
if self.list_stack.is_empty() {
self.flush_line();
}
}
MdEvent::Start(MdTag::Item) => {
self.flush_line();
let depth = self.list_stack.len().saturating_sub(1);
let nested_indent = " ".repeat(depth);
let bullet = match self.list_stack.last().map(|e| &e.kind) {
Some(ListKind::Unordered) => format!("{nested_indent}\u{2022} "),
Some(ListKind::Ordered { next, max_digits }) => {
let num = *next;
let d = (*max_digits).max(num.to_string().len());
format!("{nested_indent}{num:>d$}. ")
}
None => String::new(),
};
self.list_indent = bullet.clone();
self.spans.extend(self.indent_spans());
self.list_indent = " ".repeat(bullet.len());
}
MdEvent::End(MdTagEnd::Item) => {
self.flush_line();
if let Some(ListEntry {
kind: ListKind::Ordered { next, max_digits },
..
}) = self.list_stack.last_mut()
{
*next += 1;
*max_digits = (*max_digits).max(next.to_string().len());
}
self.list_indent = self
.list_stack
.last()
.map(|entry| entry.parent_indent.clone())
.unwrap_or_default();
}
MdEvent::Start(MdTag::Strong) => {
self.push_style(|s| s.bold());
}
MdEvent::End(MdTagEnd::Strong) => {
self.pop_style();
}
MdEvent::Start(MdTag::Emphasis) => {
self.push_style(|s| s.italic());
}
MdEvent::End(MdTagEnd::Emphasis) => {
self.pop_style();
}
MdEvent::Start(MdTag::Strikethrough) => {
self.push_style(|s| s.crossed_out());
}
MdEvent::End(MdTagEnd::Strikethrough) => {
self.pop_style();
}
MdEvent::Start(MdTag::Link { dest_url, .. }) => {
self.push_style(|s| s.fg(LINK_COLOR).underlined());
self.link_url = Some(dest_url.to_string());
self.link_text.clear();
}
MdEvent::End(MdTagEnd::Link) => {
self.pop_style();
if let Some(url) = self.link_url.take() {
let text = std::mem::take(&mut self.link_text);
if text != url {
self.spans.push(Span::styled(
format!(" ({url})"),
Style::default().fg(DIM_COLOR),
));
}
}
}
MdEvent::Start(MdTag::Image { dest_url, .. }) => {
if self.spans.is_empty() {
self.spans.extend(self.indent_spans());
}
self.spans.push(Span::styled(
format!("[image: {dest_url}]"),
Style::default().fg(DIM_COLOR),
));
}
MdEvent::End(MdTagEnd::Image) => {}
MdEvent::Start(MdTag::Table(alignments)) => {
self.blank_line_if_needed();
self.in_table = true;
self.table_alignments = alignments;
self.table_col_widths.clear();
self.table_header_spans.clear();
self.table_body_spans.clear();
}
MdEvent::End(MdTagEnd::Table) => {
self.render_buffered_table();
self.in_table = false;
self.table_alignments.clear();
self.table_col_widths.clear();
}
MdEvent::Start(MdTag::TableHead) => {
self.in_table_header = true;
self.table_cell_texts.clear();
self.table_row_spans.clear();
}
MdEvent::End(MdTagEnd::TableHead) => {
self.in_table_header = false;
for (i, text) in self.table_cell_texts.iter().enumerate() {
let w = text.width();
if i < self.table_col_widths.len() {
self.table_col_widths[i] = self.table_col_widths[i].max(w);
} else {
self.table_col_widths.push(w);
}
}
self.table_header_spans = std::mem::take(&mut self.table_row_spans);
self.table_cell_texts.clear();
}
MdEvent::Start(MdTag::TableRow) => {
self.table_cell_texts.clear();
self.table_row_spans.clear();
}
MdEvent::End(MdTagEnd::TableRow) => {
if !self.in_table_header {
for (i, text) in self.table_cell_texts.iter().enumerate() {
let w = text.width();
if i < self.table_col_widths.len() {
self.table_col_widths[i] = self.table_col_widths[i].max(w);
} else {
self.table_col_widths.push(w);
}
}
self.table_body_spans
.push(std::mem::take(&mut self.table_row_spans));
}
self.table_cell_texts.clear();
}
MdEvent::Start(MdTag::TableCell) => {
self.table_cell_spans.clear();
}
MdEvent::End(MdTagEnd::TableCell) => {
let plain: String = self
.table_cell_spans
.iter()
.map(|s| s.content.as_ref())
.collect();
self.table_cell_texts.push(plain);
self.table_row_spans
.push(std::mem::take(&mut self.table_cell_spans));
}
MdEvent::Text(text) => {
if self.in_code_block {
let prefix = self.indent_prefix();
let is_diff = self.code_block_lang == "diff";
for line in text.lines() {
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled(
format!("{prefix}\u{2502} "),
Style::default().fg(DIM_COLOR),
));
if is_diff {
spans.push(Span::styled(
line.to_string(),
Style::default().fg(diff_line_color(line)),
));
} else if let Some(ref mut hl) = self.highlighter {
if let Ok(highlighted) = hl.highlight_line(line, &SYNTAX_SET) {
for (style, fragment) in highlighted {
spans.push(Span::styled(
fragment.to_string(),
syntect_to_ratatui_style(style),
));
}
} else {
spans.push(Span::styled(
line.to_string(),
Style::default().fg(TEXT_COLOR),
));
}
} else {
spans.push(Span::styled(
line.to_string(),
Style::default().fg(TEXT_COLOR),
));
}
self.lines.push(Line::from(spans));
}
} else if self.in_table {
self.table_cell_spans
.push(Span::styled(text.to_string(), self.current_style()));
} else {
if self.link_url.is_some() {
self.link_text.push_str(&text);
}
if self.spans.is_empty() && !self.in_heading {
self.spans.extend(self.indent_spans());
}
self.spans
.push(Span::styled(text.to_string(), self.current_style()));
}
}
MdEvent::Code(code) => {
let backtick_style = Style::default().fg(DIM_COLOR);
let code_style = Style::default().fg(CODE_COLOR);
let target = if self.in_table {
&mut self.table_cell_spans
} else {
if self.spans.is_empty() {
self.spans.extend(self.indent_spans());
}
&mut self.spans
};
target.push(Span::styled("`".to_string(), backtick_style));
target.push(Span::styled(code.to_string(), code_style));
target.push(Span::styled("`".to_string(), backtick_style));
}
MdEvent::SoftBreak => {
if self.in_code_block {
return;
}
if self.in_table {
self.table_cell_spans.push(Span::raw(" "));
} else {
self.spans.push(Span::raw(" "));
}
}
MdEvent::HardBreak => {
if self.in_table {
self.table_cell_spans.push(Span::raw(" "));
} else {
self.flush_line();
self.spans.extend(self.indent_spans());
}
}
MdEvent::Rule => {
self.blank_line_if_needed();
let prefix = self.indent_prefix();
self.lines.push(Line::from(Span::styled(
format!("{prefix}{}", "\u{2500}".repeat(40)),
Style::default().fg(DIM_COLOR),
)));
}
MdEvent::TaskListMarker(checked) => {
if checked {
self.spans.push(Span::styled(
"[\u{2713}] ".to_string(),
Style::default().fg(CHECK_COLOR),
));
} else {
self.spans.push(Span::styled(
"[ ] ".to_string(),
Style::default().fg(DIM_COLOR),
));
}
}
_ => {}
}
}
fn render_buffered_table(&mut self) {
let prefix = self.indent_prefix();
let header = std::mem::take(&mut self.table_header_spans);
self.render_table_line(&prefix, &header, true);
let sep = self
.table_col_widths
.iter()
.map(|&w| "\u{2500}".repeat(w))
.collect::<Vec<_>>()
.join("\u{2500}\u{253c}\u{2500}");
self.lines.push(Line::from(Span::styled(
format!("{prefix}{sep}"),
Style::default().fg(DIM_COLOR),
)));
let body = std::mem::take(&mut self.table_body_spans);
for row in &body {
self.render_table_line(&prefix, row, false);
}
}
fn render_table_line(&mut self, prefix: &str, cells: &[Vec<Span<'static>>], bold: bool) {
let mut line_spans: Vec<Span<'static>> = vec![Span::raw(prefix.to_string())];
for (i, cell_spans) in cells.iter().enumerate() {
if i > 0 {
line_spans.push(Span::styled(
" \u{2502} ".to_string(),
Style::default().fg(DIM_COLOR),
));
}
let cell_text_len: usize = cell_spans.iter().map(|s| s.content.width()).sum();
let col_width = self
.table_col_widths
.get(i)
.copied()
.unwrap_or(cell_text_len);
let pad = col_width.saturating_sub(cell_text_len);
let align = self.table_alignments.get(i).copied();
let (left_pad, right_pad) = match align {
Some(pulldown_cmark::Alignment::Center) => (pad / 2, pad - pad / 2),
Some(pulldown_cmark::Alignment::Right) => (pad, 0),
_ => (0, pad),
};
if left_pad > 0 {
line_spans.push(Span::raw(" ".repeat(left_pad)));
}
for span in cell_spans {
if bold {
line_spans.push(Span::styled(span.content.clone(), span.style.bold()));
} else {
line_spans.push(span.clone());
}
}
if right_pad > 0 {
line_spans.push(Span::raw(" ".repeat(right_pad)));
}
}
self.lines.push(Line::from(line_spans));
}
}
fn diff_line_color(line: &str) -> Color {
if line.starts_with("@@") {
DIFF_HUNK_COLOR
} else if line.starts_with('+') {
DIFF_ADD_COLOR
} else if line.starts_with('-') {
DIFF_DEL_COLOR
} else {
TEXT_COLOR
}
}
fn find_syntax(lang: &str) -> Option<&'static syntect::parsing::SyntaxReference> {
if lang.is_empty() || lang == "diff" {
return None;
}
SYNTAX_SET
.find_syntax_by_token(lang)
.filter(|s| s.name != "Plain Text")
}
fn syntect_to_ratatui_style(style: syntect::highlighting::Style) -> Style {
let fg = style.foreground;
let mut s = Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b));
if style
.font_style
.contains(syntect::highlighting::FontStyle::BOLD)
{
s = s.bold();
}
if style
.font_style
.contains(syntect::highlighting::FontStyle::ITALIC)
{
s = s.italic();
}
if style
.font_style
.contains(syntect::highlighting::FontStyle::UNDERLINE)
{
s = s.underlined();
}
s
}
fn resolve_provider_labels(
client: &AscendClient,
otto_model: &Option<OttoModel>,
) -> (Option<String>, String) {
let providers = client.list_otto_providers().ok().unwrap_or_default();
match otto_model {
Some(model) => {
let model_id = model.id();
let lower = model_id.to_lowercase();
for p in &providers {
if let Some(m) = p.models.iter().find(|m| {
m.id == model_id
|| m.id.to_lowercase() == lower
|| m.name.to_lowercase() == lower
}) {
return (Some(p.name.clone()), m.name.clone());
}
}
let short = model_id
.rsplit_once('/')
.map(|(_, s)| s)
.or_else(|| model_id.rsplit_once('.').map(|(_, s)| s))
.unwrap_or(model_id);
(None, short.to_string())
}
None => providers
.first()
.map(|p| {
let model_name = p
.models
.iter()
.find(|m| m.id == p.default_model)
.map(|m| m.name.clone())
.unwrap_or_else(|| p.default_model.clone());
(Some(p.name.clone()), model_name)
})
.unwrap_or_default(),
}
}
fn conversation_to_messages(conv: &Conversation) -> Vec<Message> {
let Some(messages) = &conv.messages else {
return Vec::new();
};
let mut out = Vec::new();
for msg in messages {
let role_str = msg.get("role").and_then(|v| v.as_str()).unwrap_or("");
let role = match role_str {
"user" => Role::User,
"assistant" => Role::Otto,
_ => continue,
};
let text = Conversation::extract_message_text(msg);
if text.is_empty() {
continue;
}
let timestamp = msg
.get("created_at")
.and_then(|v| v.as_f64())
.map(|epoch| UNIX_EPOCH + Duration::from_secs_f64(epoch))
.or_else(|| {
msg.get("created_at")
.and_then(|v| v.as_i64())
.map(|epoch| UNIX_EPOCH + Duration::from_secs(epoch.try_into().unwrap_or(0)))
})
.unwrap_or(UNIX_EPOCH);
out.push(Message {
role,
content: text,
timestamp,
tool_call: None,
});
}
out
}
pub fn run_tui(
client: &AscendClient,
runtime_uuid: Option<String>,
otto_model: Option<OttoModel>,
context_label: Option<String>,
thread_id: Option<String>,
) -> Result<()> {
terminal::enable_raw_mode()?;
let mut stderr = std::io::stderr();
crossterm::execute!(
stderr,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste
)?;
let backend = CrosstermBackend::new(stderr);
let mut terminal = Terminal::new(backend)?;
let original_hook: Arc<dyn Fn(&std::panic::PanicHookInfo<'_>) + Sync + Send + 'static> =
std::panic::take_hook().into();
let panic_hook = original_hook.clone();
std::panic::set_hook(Box::new(move |info| {
let _ = terminal::disable_raw_mode();
let _ = crossterm::execute!(
std::io::stderr(),
LeaveAlternateScreen,
DisableMouseCapture,
DisableBracketedPaste,
SetCursorStyle::DefaultUserShape
);
(panic_hook)(info);
}));
let (stream_tx, stream_rx) = mpsc::channel::<StreamMsg>();
let cancelled_gen = AtomicU64::new(0);
let gen_counter = AtomicU64::new(0);
let active_thread_id: Mutex<Option<(u64, String)>> = Mutex::new(None);
let result = std::thread::scope(|scope| {
let bg_tx = stream_tx.clone();
let bg_model = otto_model.clone();
scope.spawn(move || {
let (provider_label, model_label) = resolve_provider_labels(client, &bg_model);
let _ = bg_tx.send(StreamMsg::ProviderInfo {
provider_label,
model_label,
});
});
let mut app = App::new(
runtime_uuid,
otto_model,
None,
String::new(),
context_label,
thread_id.clone(),
);
if let Some(tid) = thread_id {
let history_tx = stream_tx.clone();
let history_gen = app.stream_generation;
scope.spawn(move || {
if let Ok(conv) = client.get_conversation(&tid) {
let messages = conversation_to_messages(&conv);
let _ = history_tx.send(StreamMsg::ConversationHistory {
generation: history_gen,
messages,
});
}
});
}
loop {
app.tick_spinner();
app.tick_stream();
terminal.draw(|frame| app.render(frame))?;
if event::poll(POLL_DURATION)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
app.handle_key(key, &cancelled_gen);
}
Event::Paste(text) => {
app.handle_paste(&text);
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => app.scroll_up(3),
MouseEventKind::ScrollDown => app.scroll_down(3),
_ => {}
},
Event::Resize(_, _) => {
if app.auto_scroll {
app.scroll = 0;
}
}
_ => {}
}
}
while let Ok(msg) = stream_rx.try_recv() {
app.handle_stream_msg(msg);
}
if let Some(cancelled_generation) = app.stop_pending.take() {
let tid = active_thread_id
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.filter(|(g, _)| *g == cancelled_generation)
.map(|(_, tid)| tid.clone());
if let Some(tid) = tid {
let stop_tx = stream_tx.clone();
scope.spawn(move || {
let error = client
.stop_thread_and_wait(&tid)
.err()
.map(|e| e.to_string());
let _ = stop_tx.send(StreamMsg::StopFinished { error });
});
} else {
app.finish_stream();
app.push_system("Cancelled");
}
}
if !app.interrupting
&& let Some(request) = app.take_pending_request()
{
let generation = gen_counter.fetch_add(1, Ordering::AcqRel) + 1;
app.stream_generation = generation;
*active_thread_id.lock().unwrap_or_else(|e| e.into_inner()) = None;
let tx = stream_tx.clone();
let cg_ref = &cancelled_gen;
let active_tid = &active_thread_id;
scope.spawn(move || {
let tx2 = tx.clone();
let mut tool_names: HashMap<String, String> = HashMap::new();
let send = |kind: StreamMsgKind| {
let _ = tx.send(StreamMsg::Stream { generation, kind });
};
let result = client.otto_streaming(
&request,
|event| {
if cg_ref.load(Ordering::Acquire) >= generation {
return ControlFlow::Break(());
}
match event {
StreamEvent::TextDelta(delta) => {
send(StreamMsgKind::Delta(delta));
}
StreamEvent::ToolCallStart {
call_id,
name,
arguments,
} => {
tool_names.insert(call_id, name.clone());
send(StreamMsgKind::ToolCallStart { name, arguments });
}
StreamEvent::ToolCallOutput { call_id, output } => {
let name =
tool_names.get(&call_id).cloned().unwrap_or_default();
send(StreamMsgKind::ToolCallOutput { name, output });
}
}
ControlFlow::Continue(())
},
|tid: &str| {
*active_tid.lock().unwrap_or_else(|e| e.into_inner()) =
Some((generation, tid.to_string()));
let _ = tx2.send(StreamMsg::Stream {
generation,
kind: StreamMsgKind::ThreadId(tid.to_string()),
});
},
);
if cg_ref.load(Ordering::Acquire) >= generation
&& let Some((g, tid)) = active_tid
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
&& *g == generation
{
let _ = client.stop_thread(tid);
}
match result {
Ok(response) => send(StreamMsgKind::Finished {
status: response.stream_status,
error: response.stream_error,
}),
Err(e) => send(StreamMsgKind::Error(format!("{e}"))),
}
});
}
if app.should_quit {
terminal::disable_raw_mode()?;
crossterm::execute!(
std::io::stderr(),
LeaveAlternateScreen,
DisableMouseCapture,
DisableBracketedPaste,
SetCursorStyle::DefaultUserShape
)?;
terminal.show_cursor()?;
let restore_hook = original_hook.clone();
std::panic::set_hook(Box::new(move |info| (restore_hook)(info)));
if app.force_quit || cancelled_gen.load(Ordering::Acquire) > 0 {
std::process::exit(0);
}
break;
}
}
Ok::<(), anyhow::Error>(())
});
let _ = terminal::disable_raw_mode();
let _ = crossterm::execute!(
std::io::stderr(),
LeaveAlternateScreen,
DisableMouseCapture,
DisableBracketedPaste,
SetCursorStyle::DefaultUserShape
);
let _ = terminal.show_cursor();
let restore_hook = original_hook.clone();
std::panic::set_hook(Box::new(move |info| (restore_hook)(info)));
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicU64;
fn test_app() -> App {
App::new(None, None, None, String::new(), None, None)
}
#[test]
fn submit_starts_streaming_and_creates_pending_request() {
let mut app = test_app();
app.input = "hello".chars().collect();
app.submit();
assert!(app.streaming);
assert!(app.pending_request.is_some());
assert_eq!(app.pending_request.as_ref().unwrap().prompt, "hello");
assert!(app.stream_buffer.is_empty());
assert!(app.stream_pending.is_empty());
assert!(app.auto_scroll);
assert_eq!(app.messages.len(), 1);
assert_eq!(app.messages[0].role, Role::User);
}
#[test]
fn submit_blocked_while_streaming() {
let mut app = test_app();
app.streaming = true;
app.input = "blocked".chars().collect();
app.submit();
assert!(app.pending_request.is_none());
assert!(app.messages.iter().any(|m| m.role == Role::System));
}
#[test]
fn submit_on_empty_input_is_noop() {
let mut app = test_app();
app.input.clear();
app.submit();
assert!(!app.streaming);
assert!(app.pending_request.is_none());
assert!(app.messages.is_empty());
}
#[test]
fn stream_delta_accumulates_in_pending() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::Delta("hello ".into()),
});
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::Delta("world".into()),
});
let pending: String = app.stream_pending.iter().collect();
assert_eq!(pending, "hello world");
}
#[test]
fn stale_generation_messages_are_discarded() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 2;
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::Delta("stale".into()),
});
assert!(app.stream_pending.is_empty());
}
#[test]
fn thread_id_is_stored_on_stream_msg() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::ThreadId("t-123".into()),
});
assert_eq!(app.thread_id.as_deref(), Some("t-123"));
}
#[test]
fn completed_stream_flushes_buffer_and_stops_streaming() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
app.stream_start = Some(Instant::now());
app.stream_buffer = "response text".into();
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::Finished {
status: OttoStreamStatus::Completed,
error: None,
},
});
assert!(!app.streaming);
assert!(!app.interrupting);
assert!(
app.messages
.iter()
.any(|m| m.role == Role::Otto && m.content == "response text")
);
}
#[test]
fn stream_error_finishes_stream_and_shows_error_message() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::Error("connection reset".into()),
});
assert!(!app.streaming);
assert!(
app.messages
.iter()
.any(|m| m.role == Role::System && m.content.contains("connection reset"))
);
}
#[test]
fn interrupted_stream_shows_connection_lost() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::Finished {
status: OttoStreamStatus::Interrupted,
error: Some("SSE stream closed".into()),
},
});
assert!(!app.streaming);
assert!(
app.messages
.iter()
.any(|m| m.role == Role::System && m.content.contains("Connection lost"))
);
}
#[test]
fn otto_stream_ended_unexpectedly_error_shows_connection_lost() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::Error(
"Otto stream ended unexpectedly: stream did not complete".into(),
),
});
assert!(!app.streaming);
let sys_msg = app
.messages
.iter()
.find(|m| m.role == Role::System)
.expect("should have system message");
assert!(
sys_msg.content.contains("Connection lost"),
"expected 'Connection lost', got: {}",
sys_msg.content
);
}
#[test]
fn cancel_sets_interrupting_and_stop_pending() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
app.stream_buffer = "partial output".into();
let cancelled_gen = AtomicU64::new(0);
app.cancel_stream(&cancelled_gen);
assert!(app.interrupting);
assert_eq!(app.stop_pending, Some(1)); assert_eq!(cancelled_gen.load(Ordering::Acquire), 1);
assert_eq!(app.stream_generation, 2);
assert!(
app.messages
.iter()
.any(|m| m.role == Role::Otto && m.content == "partial output")
);
assert!(app.active_tool_call.is_none());
}
#[test]
fn cancel_with_no_text_yet() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
let cancelled_gen = AtomicU64::new(0);
app.cancel_stream(&cancelled_gen);
assert!(app.interrupting);
assert_eq!(app.stop_pending, Some(1)); assert!(!app.messages.iter().any(|m| m.role == Role::Otto));
}
#[test]
fn cancel_with_pending_chars_flushes_both_buffer_and_pending() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
app.stream_buffer = "buffered ".into();
app.stream_pending = "pending".chars().collect();
let cancelled_gen = AtomicU64::new(0);
app.cancel_stream(&cancelled_gen);
let otto_msg = app
.messages
.iter()
.find(|m| m.role == Role::Otto)
.expect("should have Otto message");
assert_eq!(otto_msg.content, "buffered pending");
}
#[test]
fn cancel_clears_active_tool_call() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
app.active_tool_call = Some(("read_file".into(), "{}".into()));
let cancelled_gen = AtomicU64::new(0);
app.cancel_stream(&cancelled_gen);
assert!(app.active_tool_call.is_none());
}
#[test]
fn cancel_is_idempotent_while_interrupting() {
let mut app = test_app();
app.streaming = true;
app.interrupting = true;
app.stream_generation = 5;
app.stop_pending = None;
let cancelled_gen = AtomicU64::new(1);
app.cancel_stream(&cancelled_gen);
assert_eq!(app.stream_generation, 5);
assert_eq!(app.stop_pending, None); }
#[test]
fn stop_finished_success_ends_interrupt_and_shows_cancelled() {
let mut app = test_app();
app.streaming = true;
app.interrupting = true;
app.handle_stream_msg(StreamMsg::StopFinished { error: None });
assert!(!app.streaming);
assert!(!app.interrupting);
assert!(app.stream_start.is_none());
assert!(
app.messages
.iter()
.any(|m| m.role == Role::System && m.content == "Cancelled")
);
}
#[test]
fn stop_finished_timeout_shows_interrupt_failed_and_recovers() {
let mut app = test_app();
app.streaming = true;
app.interrupting = true;
app.thread_id = Some("t-123".into());
app.handle_stream_msg(StreamMsg::StopFinished {
error: Some(
"API error (HTTP 408): thread 019d0b9d... did not stop within 30 seconds".into(),
),
});
assert!(!app.streaming);
assert!(!app.interrupting);
assert!(app.stream_start.is_none());
assert!(app.active_tool_call.is_none());
assert!(
app.messages
.iter()
.any(|m| m.role == Role::System && m.content.contains("Interrupt failed"))
);
assert_eq!(app.thread_id.as_deref(), Some("t-123"));
}
#[test]
fn after_stop_timeout_user_can_submit_new_message() {
let mut app = test_app();
app.streaming = true;
app.interrupting = true;
app.thread_id = Some("t-123".into());
app.handle_stream_msg(StreamMsg::StopFinished {
error: Some("thread did not stop within 30 seconds".into()),
});
app.input = "follow up question".chars().collect();
app.submit();
assert!(app.streaming);
let req = app
.pending_request
.as_ref()
.expect("should have pending request");
assert_eq!(req.prompt, "follow up question");
assert_eq!(req.thread_id.as_deref(), Some("t-123"));
}
#[test]
fn stop_finished_network_error_recovers() {
let mut app = test_app();
app.streaming = true;
app.interrupting = true;
app.thread_id = Some("t-456".into());
app.handle_stream_msg(StreamMsg::StopFinished {
error: Some("connection refused".into()),
});
assert!(!app.streaming);
assert!(!app.interrupting);
assert!(
app.messages
.iter()
.any(|m| m.role == Role::System && m.content.contains("Interrupt failed"))
);
assert_eq!(app.thread_id.as_deref(), Some("t-456"));
app.input = "retry".chars().collect();
app.submit();
assert!(app.streaming);
}
#[test]
fn cancel_before_thread_id_finishes_immediately() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
app.thread_id = None;
let cancelled_gen = AtomicU64::new(0);
app.cancel_stream(&cancelled_gen);
assert!(app.interrupting);
assert_eq!(app.stop_pending, Some(1));
app.stop_pending = None;
app.finish_stream();
app.push_system("Cancelled");
assert!(!app.streaming);
assert!(!app.interrupting);
assert!(
app.messages
.iter()
.any(|m| m.role == Role::System && m.content == "Cancelled")
);
}
#[test]
fn cancelled_stream_status_defers_to_stop_finished() {
let mut app = test_app();
app.streaming = true;
app.interrupting = true;
app.stream_generation = 1;
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::Finished {
status: OttoStreamStatus::Cancelled,
error: None,
},
});
assert!(app.streaming);
assert!(app.interrupting);
}
#[test]
fn cancelled_then_stop_finished_success() {
let mut app = test_app();
app.streaming = true;
app.interrupting = true;
app.stream_generation = 1;
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::Finished {
status: OttoStreamStatus::Cancelled,
error: None,
},
});
assert!(app.streaming);
app.handle_stream_msg(StreamMsg::StopFinished { error: None });
assert!(!app.streaming);
assert!(!app.interrupting);
assert!(
app.messages
.iter()
.any(|m| m.role == Role::System && m.content == "Cancelled")
);
}
#[test]
fn cancelled_then_stop_finished_error() {
let mut app = test_app();
app.streaming = true;
app.interrupting = true;
app.stream_generation = 1;
app.thread_id = Some("t-789".into());
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::Finished {
status: OttoStreamStatus::Cancelled,
error: None,
},
});
app.handle_stream_msg(StreamMsg::StopFinished {
error: Some("timeout".into()),
});
assert!(!app.streaming);
assert!(!app.interrupting);
assert!(
app.messages
.iter()
.any(|m| m.role == Role::System && m.content.contains("Interrupt failed"))
);
assert_eq!(app.thread_id.as_deref(), Some("t-789"));
}
#[test]
fn stale_deltas_after_cancel_are_discarded() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
let cancelled_gen = AtomicU64::new(0);
app.cancel_stream(&cancelled_gen);
assert_eq!(app.stream_generation, 2);
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::Delta("stale text".into()),
});
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::ToolCallStart {
name: "stale_tool".into(),
arguments: "{}".into(),
},
});
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::Finished {
status: OttoStreamStatus::Completed,
error: None,
},
});
assert!(app.stream_pending.is_empty());
assert!(app.active_tool_call.is_none());
assert!(app.interrupting);
assert!(app.streaming);
}
#[test]
fn full_cancel_recover_new_message_cycle() {
let mut app = test_app();
app.input = "first question".chars().collect();
app.submit();
assert!(app.streaming);
let req1 = app.take_pending_request().unwrap();
assert_eq!(req1.prompt, "first question");
app.stream_generation = 1;
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::ThreadId("t-cycle".into()),
});
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::Delta("partial answer".into()),
});
let cancelled_gen = AtomicU64::new(0);
app.cancel_stream(&cancelled_gen);
assert!(app.interrupting);
assert_eq!(app.stop_pending, Some(1));
app.stop_pending = None;
app.handle_stream_msg(StreamMsg::StopFinished { error: None });
assert!(!app.streaming);
assert!(!app.interrupting);
assert!(
app.messages
.iter()
.any(|m| m.role == Role::Otto && m.content.contains("partial answer"))
);
app.input = "second question".chars().collect();
app.submit();
assert!(app.streaming);
let req2 = app.pending_request.as_ref().unwrap();
assert_eq!(req2.prompt, "second question");
assert_eq!(req2.thread_id.as_deref(), Some("t-cycle"));
}
#[test]
fn full_cancel_timeout_recover_new_message_cycle() {
let mut app = test_app();
app.input = "question".chars().collect();
app.submit();
app.stream_generation = 1;
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::ThreadId("t-timeout".into()),
});
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::Delta("response".into()),
});
let cancelled_gen = AtomicU64::new(0);
app.cancel_stream(&cancelled_gen);
app.stop_pending = None;
app.handle_stream_msg(StreamMsg::StopFinished {
error: Some(
"API error (HTTP 408): thread t-timeout did not stop within 30 seconds".into(),
),
});
assert!(!app.streaming);
assert!(!app.interrupting);
app.input = "follow up".chars().collect();
app.submit();
assert!(app.streaming);
assert!(!app.interrupting);
let req = app.pending_request.as_ref().unwrap();
assert_eq!(req.thread_id.as_deref(), Some("t-timeout"));
}
#[test]
fn rapid_ctrl_c_during_interrupting_force_quits() {
let mut app = test_app();
app.streaming = true;
app.interrupting = true;
app.stream_generation = 5;
let cancelled_gen = AtomicU64::new(1);
app.handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&cancelled_gen,
);
assert!(app.should_quit);
assert!(app.force_quit);
assert!(app.interrupting);
assert!(app.streaming);
assert_eq!(app.stream_generation, 5);
}
#[test]
fn rapid_esc_during_interrupting_is_safe() {
let mut app = test_app();
app.streaming = true;
app.interrupting = true;
app.stream_generation = 5;
let cancelled_gen = AtomicU64::new(1);
for _ in 0..5 {
app.handle_key(
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
&cancelled_gen,
);
}
assert!(app.interrupting);
assert!(app.streaming);
assert_eq!(app.stream_generation, 5);
}
#[test]
fn stream_error_during_interrupting_is_discarded() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
let cancelled_gen = AtomicU64::new(0);
app.cancel_stream(&cancelled_gen);
assert_eq!(app.stream_generation, 2);
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::Error("old error".into()),
});
assert!(app.streaming);
assert!(app.interrupting);
assert!(
!app.messages
.iter()
.any(|m| { m.role == Role::System && m.content.contains("old error") })
);
}
#[test]
fn stop_finished_when_not_interrupting_is_noop() {
let mut app = test_app();
app.streaming = true;
app.interrupting = false;
app.handle_stream_msg(StreamMsg::StopFinished { error: None });
assert!(app.streaming);
assert!(!app.interrupting);
}
#[test]
fn stop_finished_when_idle_is_harmless() {
let mut app = test_app();
assert!(!app.streaming);
assert!(!app.interrupting);
app.handle_stream_msg(StreamMsg::StopFinished {
error: Some("some error".into()),
});
assert!(!app.streaming);
assert!(!app.interrupting);
}
#[test]
fn ctrl_c_while_streaming_cancels_not_quits() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
let cancelled_gen = AtomicU64::new(0);
app.handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&cancelled_gen,
);
assert!(app.interrupting);
assert!(!app.should_quit);
assert_eq!(cancelled_gen.load(Ordering::Acquire), 1);
}
#[test]
fn ctrl_c_while_not_streaming_quits() {
let mut app = test_app();
let cancelled_gen = AtomicU64::new(0);
app.handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&cancelled_gen,
);
assert!(app.should_quit);
}
#[test]
fn esc_while_streaming_cancels() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
let cancelled_gen = AtomicU64::new(0);
app.handle_key(
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
&cancelled_gen,
);
assert!(app.interrupting);
assert_eq!(cancelled_gen.load(Ordering::Acquire), 1);
}
#[test]
fn esc_while_not_streaming_enters_vi_normal() {
let mut app = test_app();
app.input_mode = InputMode::ViInsert;
let cancelled_gen = AtomicU64::new(0);
app.handle_key(
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
&cancelled_gen,
);
assert_eq!(app.input_mode, InputMode::ViNormal);
assert!(!app.interrupting);
}
#[test]
fn submit_blocked_during_interrupting() {
let mut app = test_app();
app.streaming = true;
app.interrupting = true;
app.input = "please work".chars().collect();
app.submit();
assert!(app.pending_request.is_none());
assert!(
app.messages
.iter()
.any(|m| m.role == Role::System && m.content.contains("Waiting"))
);
}
#[test]
fn pending_request_guard_during_interrupting() {
let mut app = test_app();
app.streaming = true;
app.interrupting = true;
app.pending_request = Some(OttoChatRequest {
prompt: "should not launch".into(),
runtime_uuid: None,
thread_id: None,
model: None,
});
assert!(app.interrupting);
assert!(app.pending_request.is_some());
}
#[test]
fn cancel_during_tool_call_clears_tool_and_preserves_text() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
app.stream_buffer = "Let me check that for you.".into();
app.active_tool_call = Some(("list_workspaces".into(), "{}".into()));
let cancelled_gen = AtomicU64::new(0);
app.cancel_stream(&cancelled_gen);
assert!(app.active_tool_call.is_none());
assert!(app.interrupting);
assert!(
app.messages
.iter()
.any(|m| m.role == Role::Otto && m.content.contains("Let me check"))
);
}
#[test]
fn tool_call_start_sets_active_tool() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::ToolCallStart {
name: "list_flows".into(),
arguments: "{}".into(),
},
});
assert_eq!(
app.active_tool_call.as_ref().map(|(n, _)| n.as_str()),
Some("list_flows")
);
}
#[test]
fn tool_call_output_clears_active_tool_and_adds_system_msg() {
let mut app = test_app();
app.streaming = true;
app.stream_generation = 1;
app.active_tool_call = Some(("list_flows".into(), "{}".into()));
app.handle_stream_msg(StreamMsg::Stream {
generation: 1,
kind: StreamMsgKind::ToolCallOutput {
name: "list_flows".into(),
output: "sales, marketing".into(),
},
});
assert!(app.active_tool_call.is_none());
assert!(
app.messages
.iter()
.any(|m| m.role == Role::System && m.content.contains("list_flows"))
);
}
#[test]
fn provider_info_updates_labels() {
let mut app = test_app();
app.handle_stream_msg(StreamMsg::ProviderInfo {
provider_label: Some("AWS Bedrock".into()),
model_label: "Claude Sonnet".into(),
});
assert_eq!(app.provider_label.as_deref(), Some("AWS Bedrock"));
assert_eq!(app.model_label, "Claude Sonnet");
}
#[test]
fn input_line_count_wraps_correctly() {
let mut app = test_app();
app.input = "abcdefghij".chars().collect();
app.cursor = app.input.len();
assert_eq!(app.input_line_count(8), 3);
app.input = "abcdefg".chars().collect();
app.cursor = app.input.len();
assert_eq!(app.input_line_count(8), 2);
}
#[test]
fn input_line_count_newlines() {
let mut app = test_app();
app.input = "line1\nline2\nline3".chars().collect();
app.cursor = app.input.len();
assert_eq!(app.input_line_count(80), 3);
}
#[test]
fn input_line_count_capped_at_max() {
let mut app = test_app();
app.input = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk".chars().collect();
app.cursor = app.input.len();
assert_eq!(app.input_line_count(80), MAX_INPUT_LINES);
}
#[test]
fn clear_command_resets_thread() {
let mut app = test_app();
app.thread_id = Some("t-old".into());
app.messages.push(Message {
role: Role::Otto,
content: "old message".into(),
timestamp: SystemTime::now(),
tool_call: None,
});
app.handle_command("/clear");
assert!(app.thread_id.is_none());
assert_eq!(app.messages.len(), 1);
assert!(app.messages[0].content.contains("cleared"));
}
#[test]
fn unknown_command_shows_error() {
let mut app = test_app();
app.handle_command("/foobar");
assert!(
app.messages
.iter()
.any(|m| m.role == Role::System && m.content.contains("Unknown command"))
);
}
#[test]
fn quit_command_sets_should_quit() {
let mut app = test_app();
app.handle_command("/quit");
assert!(app.should_quit);
let mut app2 = test_app();
app2.handle_command("/exit");
assert!(app2.should_quit);
let mut app3 = test_app();
app3.handle_command("/q");
assert!(app3.should_quit);
}
#[test]
fn history_records_submitted_input() {
let mut app = test_app();
app.input = "first query".chars().collect();
app.submit();
app.finish_stream();
app.input = "second query".chars().collect();
app.submit();
app.finish_stream();
if let Some(prev) = app.history.prev(&app.input) {
let s: String = prev.iter().collect();
assert_eq!(s, "second query");
} else {
panic!("expected history entry");
}
}
#[test]
fn tick_stream_flushes_when_bulk_threshold_exceeded() {
let mut app = test_app();
app.streaming = true;
let text: String = (0..250).map(|_| 'x').collect();
app.stream_pending = text.chars().collect();
app.last_stream_tick = Instant::now() - Duration::from_millis(100);
app.tick_stream();
assert!(!app.stream_buffer.is_empty());
assert_eq!(app.stream_buffer.len() + app.stream_pending.len(), 250);
}
#[test]
fn tab_completion_cycles_through_commands() {
let mut app = test_app();
app.input = "/cl".chars().collect();
app.cursor = app.input.len();
app.complete_tab();
let first: String = app.input.iter().collect();
assert_eq!(first, "/clear");
app.complete_tab();
let second: String = app.input.iter().collect();
assert_eq!(second, "/clear");
}
#[test]
fn paste_inserts_at_cursor_and_switches_to_insert_mode() {
let mut app = test_app();
app.input_mode = InputMode::ViNormal;
app.input = "hello".chars().collect();
app.cursor = 5;
app.handle_paste(" world");
let text: String = app.input.iter().collect();
assert_eq!(text, "hello world");
assert_eq!(app.input_mode, InputMode::ViInsert);
assert_eq!(app.cursor, 11);
}
#[test]
fn render_markdown_handles_code_blocks() {
let text = "text\n```rust\nfn main() {}\n```\nmore text";
let lines = render_markdown(text, Role::Otto, false);
assert!(lines.len() >= 5);
}
#[test]
fn render_markdown_handles_inline_code() {
let text = "use `foo()` here";
let lines = render_markdown(text, Role::Otto, false);
let content_lines: Vec<_> = lines.iter().filter(|l| !l.spans.is_empty()).collect();
assert!(!content_lines.is_empty());
assert!(content_lines[0].spans.len() >= 3);
}
#[test]
fn render_markdown_raw_mode_shows_source() {
let text = "**bold** and `code`";
let lines = render_markdown(text, Role::Otto, true);
assert_eq!(lines.len(), 1);
let full_text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(full_text.contains("**bold**"));
assert!(full_text.contains("`code`"));
}
#[test]
fn render_markdown_headings_are_styled() {
let text = "# Title\n\n## Subtitle";
let lines = render_markdown(text, Role::Otto, false);
let content: Vec<_> = lines
.iter()
.filter(|l| {
!l.spans.is_empty() && !(l.spans.len() == 1 && l.spans[0].content.trim().is_empty())
})
.collect();
assert!(content.len() >= 2);
assert!(
content[0]
.spans
.iter()
.any(|s| s.style.add_modifier.contains(Modifier::BOLD))
);
}
#[test]
fn render_markdown_unordered_list() {
let text = "- one\n- two\n- three";
let lines = render_markdown(text, Role::Otto, false);
let text_lines: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert!(text_lines.iter().any(|l| l.contains('\u{2022}')));
}
#[test]
fn render_markdown_link_dedup() {
let text = "[https://example.com](https://example.com)";
let lines = render_markdown(text, Role::Otto, false);
let full: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert_eq!(full.matches("example.com").count(), 1);
}
#[test]
fn render_markdown_link_shows_url() {
let text = "[click here](https://example.com)";
let lines = render_markdown(text, Role::Otto, false);
let full: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(full.contains("click here"));
assert!(full.contains("(https://example.com)"));
}
#[test]
fn render_markdown_blockquote() {
let text = "> quoted text";
let lines = render_markdown(text, Role::Otto, false);
let full: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(full.contains('\u{2502}')); assert!(full.contains("quoted text"));
}
#[test]
fn render_markdown_task_list() {
let text = "- [x] done\n- [ ] todo";
let lines = render_markdown(text, Role::Otto, false);
let full: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(full.contains('\u{2713}')); assert!(full.contains("[ ]"));
}
#[test]
fn render_markdown_inline_code_has_backtick_delimiters() {
let text = "use `foo()` here";
let lines = render_markdown(text, Role::Otto, false);
let content_lines: Vec<_> = lines.iter().filter(|l| !l.spans.is_empty()).collect();
assert!(!content_lines.is_empty());
let spans = &content_lines[0].spans;
let backtick_count = spans.iter().filter(|s| s.content.as_ref() == "`").count();
assert_eq!(backtick_count, 2);
}
#[test]
fn render_markdown_table() {
let text = "| A | B |\n|---|---|\n| 1 | 2 |";
let lines = render_markdown(text, Role::Otto, false);
let full: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(full.contains('\u{2502}'));
assert!(full.contains('\u{2500}'));
}
#[test]
fn render_markdown_table_alignment_consistent_widths() {
let text = "| A | B |\n|---|---|\n| longer | x |";
let lines = render_markdown(text, Role::Otto, false);
let sep_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains('\u{253c}')))
.expect("should have separator");
let sep_text: String = sep_line.spans.iter().map(|s| s.content.as_ref()).collect();
let first_col = sep_text.split('\u{253c}').next().unwrap();
let dash_count = first_col.chars().filter(|&c| c == '\u{2500}').count();
assert!(
dash_count >= 6,
"separator should match widest cell, got {dash_count}"
);
}
#[test]
fn render_markdown_table_inline_code_preserved() {
let text = "| Col |\n|---|\n| `code` |";
let lines = render_markdown(text, Role::Otto, false);
let has_code_span = lines.iter().any(|l| {
l.spans
.iter()
.any(|s| s.style.fg == Some(CODE_COLOR) && s.content.as_ref() == "code")
});
assert!(has_code_span, "inline code in table should be styled");
}
#[test]
fn render_markdown_nested_list() {
let text = "- outer\n - inner\n- back to outer";
let lines = render_markdown(text, Role::Otto, false);
let texts: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert!(texts.iter().any(|t| t.contains("inner")));
assert!(texts.iter().any(|t| t.contains("back to outer")));
}
#[test]
fn render_markdown_empty_input() {
let lines = render_markdown("", Role::Otto, false);
assert!(lines.is_empty());
}
#[test]
fn render_markdown_horizontal_rule() {
let text = "above\n\n---\n\nbelow";
let lines = render_markdown(text, Role::Otto, false);
let full: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(full.contains('\u{2500}'));
}
#[test]
fn render_markdown_strikethrough() {
let text = "~~deleted~~";
let lines = render_markdown(text, Role::Otto, false);
let has_strikethrough = lines.iter().any(|l| {
l.spans
.iter()
.any(|s| s.style.add_modifier.contains(Modifier::CROSSED_OUT))
});
assert!(has_strikethrough);
}
#[test]
fn render_markdown_diff_coloring() {
let text = "```diff\n- removed\n+ added\n context\n@@ -1,3 +1,3 @@\n```";
let lines = render_markdown(text, Role::Otto, false);
let del_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains("- removed")))
.expect("should have a deletion line");
assert_eq!(
del_line.spans.last().unwrap().style.fg,
Some(DIFF_DEL_COLOR)
);
let add_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains("+ added")))
.expect("should have an addition line");
assert_eq!(
add_line.spans.last().unwrap().style.fg,
Some(DIFF_ADD_COLOR)
);
let hunk_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains("@@")))
.expect("should have a hunk header line");
assert_eq!(
hunk_line.spans.last().unwrap().style.fg,
Some(DIFF_HUNK_COLOR)
);
}
#[test]
fn render_markdown_syntax_highlighting() {
let text = "```rust\nfn main() {\n println!(\"hello\");\n}\n```";
let lines = render_markdown(text, Role::Otto, false);
let code_lines: Vec<_> = lines
.iter()
.filter(|l| l.spans.iter().any(|s| s.content.contains('\u{2502}')))
.collect();
assert!(!code_lines.is_empty());
let multi_span_lines = code_lines.iter().filter(|l| l.spans.len() > 2).count();
assert!(multi_span_lines > 0, "expected syntax-highlighted spans");
}
#[test]
fn render_markdown_code_block_extracts_language_from_info_string() {
let text = "```sql title=\"file.sql\" lines=\"1-15\"\nSELECT 1;\n```";
let lines = render_markdown(text, Role::Otto, false);
let header = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains('\u{256d}')))
.expect("should have code block header");
let header_text: String = header.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
header_text.contains("sql"),
"header should contain language"
);
assert!(
!header_text.contains("title"),
"header should NOT contain metadata"
);
let code_lines: Vec<_> = lines
.iter()
.filter(|l| l.spans.iter().any(|s| s.content.contains('\u{2502}')))
.collect();
assert!(!code_lines.is_empty());
let multi_span = code_lines.iter().filter(|l| l.spans.len() > 2).count();
assert!(multi_span > 0, "SQL should be syntax-highlighted");
}
#[test]
fn render_markdown_python_syntax_highlighting() {
let text = "```python\ndef hello():\n print(\"hi\")\n```";
let lines = render_markdown(text, Role::Otto, false);
let code_lines: Vec<_> = lines
.iter()
.filter(|l| l.spans.iter().any(|s| s.content.contains('\u{2502}')))
.collect();
assert!(!code_lines.is_empty());
let multi_span = code_lines.iter().filter(|l| l.spans.len() > 2).count();
assert!(multi_span > 0, "Python should be syntax-highlighted");
}
#[test]
fn render_markdown_unicode_table_widths() {
let text = "| A | B |\n|---|---|\n| \u{4f60}\u{597d} | x |";
let lines = render_markdown(text, Role::Otto, false);
let sep_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains('\u{253c}')))
.expect("should have separator");
let sep_text: String = sep_line.spans.iter().map(|s| s.content.as_ref()).collect();
let first_col = sep_text.split('\u{253c}').next().unwrap();
let dash_count = first_col.chars().filter(|&c| c == '\u{2500}').count();
assert!(
dash_count >= 4,
"separator should match CJK display width, got {dash_count}"
);
}
#[test]
fn render_markdown_ordered_list_high_start() {
let text = "99. first\n100. second";
let lines = render_markdown(text, Role::Otto, false);
let texts: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert!(texts.iter().any(|t| t.contains("99.")));
assert!(texts.iter().any(|t| t.contains("100.")));
}
#[test]
fn render_markdown_nested_blockquote() {
let text = "> > doubly quoted";
let lines = render_markdown(text, Role::Otto, false);
let full: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
full.matches('\u{2502}').count() >= 2,
"nested blockquote should have 2+ vertical bars"
);
}
#[test]
fn render_markdown_mixed_nested_lists() {
let text = "1. ordered\n - unordered inside\n2. back";
let lines = render_markdown(text, Role::Otto, false);
let texts: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert!(texts.iter().any(|t| t.contains("1.")));
assert!(texts.iter().any(|t| t.contains('\u{2022}')));
}
#[test]
fn render_markdown_multi_paragraph_list_item() {
let text = "- first paragraph\n\n second paragraph\n- next item";
let lines = render_markdown(text, Role::Otto, false);
let full: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(full.contains("first paragraph"));
assert!(full.contains("second paragraph"));
assert!(full.contains("next item"));
}
#[test]
fn render_markdown_emoji_in_table() {
let text = "| Col |\n|---|\n| \u{1f600} |";
let lines = render_markdown(text, Role::Otto, false);
assert!(!lines.is_empty());
let full: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(full.contains('\u{1f600}'));
}
#[test]
fn render_markdown_gfm_note_blockquote() {
let text = "> [!NOTE]\n> This is a note.";
let lines = render_markdown(text, Role::Otto, false);
let full: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(full.contains("NOTE"), "should render NOTE label");
assert!(full.contains("This is a note."));
}
#[test]
fn render_markdown_gfm_warning_blockquote() {
let text = "> [!WARNING]\n> Be careful.";
let lines = render_markdown(text, Role::Otto, false);
let has_warning = lines.iter().any(|l| {
l.spans
.iter()
.any(|s| s.content.as_ref() == "WARNING" && s.style.fg == Some(WARNING_COLOR))
});
assert!(
has_warning,
"should render WARNING label with correct color"
);
}
#[test]
fn render_markdown_code_in_blockquote() {
let text = "> ```rust\n> fn x() {}\n> ```";
let lines = render_markdown(text, Role::Otto, false);
let full: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(full.contains("fn x()"));
assert!(full.contains('\u{2502}'));
assert!(full.contains('\u{256d}'));
}
#[test]
fn second_ctrl_c_during_interrupting_sets_force_quit() {
let mut app = test_app();
app.streaming = true;
app.interrupting = true;
let cancelled_gen = AtomicU64::new(1);
app.handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&cancelled_gen,
);
assert!(app.force_quit);
assert!(app.should_quit);
}
#[test]
fn stop_finished_ignored_when_not_interrupting() {
let mut app = test_app();
app.streaming = true;
app.interrupting = false;
let msg_count_before = app.messages.len();
app.handle_stream_msg(StreamMsg::StopFinished {
error: Some("should be ignored".into()),
});
assert!(app.streaming);
assert_eq!(app.messages.len(), msg_count_before);
}
}