1#![deny(unsafe_code)]
2
3use std::collections::{HashMap, VecDeque};
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::sync::{Arc, Mutex, mpsc};
20use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
21
22use anyhow::Result;
23use crossterm::cursor::SetCursorStyle;
24use crossterm::event::{
25 self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
26 Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind,
27};
28use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
29use ratatui::prelude::*;
30use ratatui::widgets::*;
31
32use pulldown_cmark::{
33 BlockQuoteKind, CodeBlockKind, Event as MdEvent, Options, Parser as MdParser, Tag as MdTag,
34 TagEnd as MdTagEnd,
35};
36use syntect::easy::HighlightLines;
37use syntect::highlighting::ThemeSet;
38use syntect::parsing::SyntaxSet;
39use unicode_width::UnicodeWidthStr;
40
41use ascend_tools::client::AscendClient;
42use ascend_tools::models::{
43 Conversation, OttoChatRequest, OttoModel, OttoStreamStatus, StreamEvent,
44};
45use std::ops::ControlFlow;
46
47const SPINNER: &[&str] = &[
52 "\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}",
53 "\u{2807}", "\u{280f}",
54];
55const POLL_DURATION: Duration = Duration::from_millis(16);
56const SPINNER_INTERVAL: Duration = Duration::from_millis(80);
57
58#[rustfmt::skip]
59const COMMANDS: &[&str] = &[
60 "/clear", "/copy", "/emacs", "/exit", "/help",
61 "/q", "/quit", "/timestamps", "/vi", "/vim",
62];
63
64#[rustfmt::skip]
65const SPLASH: &[&str] = &[
66 " \u{2588}\u{2588} \u{2588}\u{2588}",
67 " \u{2588}\u{2588}\u{2588} \u{2588}\u{2588}\u{2588}",
68 " \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}",
69 " \u{2588}\u{2588} . . \u{2588}\u{2588}",
70 " \u{2588}\u{2588} v \u{2588}\u{2588}",
71 " \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}",
72 "",
73 " \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}",
74 " \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588}",
75 " \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588}",
76 " \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588}",
77 " \u{2588}\u{2588}\u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588} \u{2588}\u{2588}\u{2588}\u{2588}",
78 "",
79 " type /help for commands",
80];
81
82#[rustfmt::skip]
83const EXPERIMENTAL_BANNER: &[&str] = &[
84 "\u{26a0} EXPERIMENTAL \u{26a0}",
85 "",
86 "This feature is under active development.",
87 "Expect rough edges, bugs, and breaking changes.",
88 "Mascot below not finalized.",
89];
90
91const 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);
97const 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);
100const TEXT_COLOR: Color = Color::White;
101const 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);
112
113const STREAM_CPS: f64 = 200.0;
115const STREAM_BULK_THRESHOLD: usize = 200;
117const STREAM_FAST_THRESHOLD: usize = 50;
119
120const MAX_HISTORY: usize = 1000;
121
122static SYNTAX_SET: std::sync::LazyLock<SyntaxSet> =
124 std::sync::LazyLock::new(SyntaxSet::load_defaults_nonewlines);
125static THEME: std::sync::LazyLock<syntect::highlighting::Theme> = std::sync::LazyLock::new(|| {
126 let ts = ThemeSet::load_defaults();
127 ts.themes["base16-eighties.dark"].clone()
128});
129const MAX_INPUT_LINES: u16 = 8;
130
131enum StreamMsg {
136 ProviderInfo {
137 provider_label: Option<String>,
138 model_label: String,
139 },
140 ConversationHistory {
141 generation: u64,
142 messages: Vec<Message>,
143 },
144 StopFinished {
145 error: Option<String>,
146 },
147 Stream {
150 generation: u64,
151 kind: StreamMsgKind,
152 },
153}
154
155enum StreamMsgKind {
156 ThreadId(String),
157 Delta(String),
158 ToolCallStart {
159 name: String,
160 arguments: String,
161 },
162 ToolCallOutput {
163 name: String,
164 output: String,
165 },
166 Finished {
167 status: OttoStreamStatus,
168 error: Option<String>,
169 },
170 Error(String),
171}
172
173#[derive(Clone, Copy, Debug, PartialEq)]
174enum InputMode {
175 Emacs,
176 ViInsert,
177 ViNormal,
178}
179
180#[derive(Clone, Copy, Debug, PartialEq)]
181enum Role {
182 User,
183 Otto,
184 System,
185}
186
187struct ToolCallData {
188 name: String,
189 arguments: String,
190 output: String,
191}
192
193struct Message {
194 role: Role,
195 content: String,
196 timestamp: SystemTime,
197 tool_call: Option<ToolCallData>,
198}
199
200struct History {
205 entries: Vec<String>,
206 position: Option<usize>,
207 saved_input: Vec<char>,
208}
209
210impl History {
211 fn load() -> Self {
212 let entries = Self::history_path()
213 .and_then(|p| std::fs::read_to_string(p).ok())
214 .map(|s| {
215 s.lines()
216 .filter(|l| !l.is_empty())
217 .map(String::from)
218 .collect()
219 })
220 .unwrap_or_default();
221 Self {
222 entries,
223 position: None,
224 saved_input: Vec::new(),
225 }
226 }
227
228 fn push(&mut self, entry: &str) {
229 let entry = entry.trim().replace('\n', "\\n");
230 if entry.is_empty() {
231 return;
232 }
233 if self.entries.last().is_some_and(|last| *last == entry) {
235 return;
236 }
237 self.entries.push(entry.clone());
238 if self.entries.len() > MAX_HISTORY {
239 self.entries.remove(0);
240 }
241 self.position = None;
242 if let Some(path) = Self::history_path() {
244 if let Some(parent) = path.parent() {
245 let _ = std::fs::create_dir_all(parent);
246 }
247 use std::io::Write;
248 if let Ok(mut f) = std::fs::OpenOptions::new()
249 .create(true)
250 .append(true)
251 .open(path)
252 {
253 let _ = writeln!(f, "{entry}");
254 }
255 }
256 }
257
258 fn decode(entry: &str) -> Vec<char> {
259 entry.replace("\\n", "\n").chars().collect()
260 }
261
262 fn prev(&mut self, current_input: &[char]) -> Option<Vec<char>> {
263 if self.entries.is_empty() {
264 return None;
265 }
266 let new_pos = match self.position {
267 None => {
268 self.saved_input = current_input.to_vec();
269 self.entries.len() - 1
270 }
271 Some(0) => return None,
272 Some(p) => p - 1,
273 };
274 self.position = Some(new_pos);
275 Some(Self::decode(&self.entries[new_pos]))
276 }
277
278 fn next(&mut self) -> Option<Vec<char>> {
279 let pos = self.position?;
280 if pos + 1 >= self.entries.len() {
281 self.position = None;
282 Some(self.saved_input.clone())
283 } else {
284 self.position = Some(pos + 1);
285 Some(Self::decode(&self.entries[pos + 1]))
286 }
287 }
288
289 fn history_path() -> Option<std::path::PathBuf> {
290 std::env::var("HOME").ok().map(|h| {
291 std::path::PathBuf::from(h)
292 .join(".ascend-tools")
293 .join("history")
294 })
295 }
296}
297
298struct App {
303 messages: Vec<Message>,
304 input: Vec<char>,
305 cursor: usize,
306 input_mode: InputMode,
307 scroll: usize,
309 auto_scroll: bool,
310 streaming: bool,
311 stream_buffer: String,
312 stream_pending: VecDeque<char>,
313 last_stream_tick: Instant,
314 stream_start: Option<Instant>,
315 thread_id: Option<String>,
316 runtime_uuid: Option<String>,
317 otto_model: Option<OttoModel>,
318 provider_label: Option<String>,
319 model_label: String,
320 context_label: Option<String>,
321 pending_request: Option<OttoChatRequest>,
322 should_quit: bool,
323 spinner_frame: usize,
324 last_spinner: Instant,
325 vi_pending: Option<char>,
326 yank_register: String,
327 completion_index: Option<usize>,
328 history: History,
329 show_timestamps: bool,
330 active_tool_call: Option<(String, String)>,
331 expand_tool_calls: bool,
332 stream_generation: u64,
333 stop_pending: Option<u64>,
336 interrupting: bool,
337 force_quit: bool,
339 show_raw_markdown: bool,
341}
342
343impl App {
344 fn new(
345 runtime_uuid: Option<String>,
346 otto_model: Option<OttoModel>,
347 provider_label: Option<String>,
348 model_label: String,
349 context_label: Option<String>,
350 thread_id: Option<String>,
351 ) -> Self {
352 Self {
353 messages: Vec::new(),
354 input: Vec::new(),
355 cursor: 0,
356 input_mode: InputMode::ViInsert,
357 scroll: 0,
358 auto_scroll: true,
359 streaming: false,
360 stream_buffer: String::new(),
361 stream_pending: VecDeque::new(),
362 last_stream_tick: Instant::now(),
363 stream_start: None,
364 thread_id,
365 runtime_uuid,
366 otto_model,
367 provider_label,
368 model_label,
369 context_label,
370 pending_request: None,
371 should_quit: false,
372 spinner_frame: 0,
373 last_spinner: Instant::now(),
374 vi_pending: None,
375 yank_register: String::new(),
376 completion_index: None,
377 history: History::load(),
378 show_timestamps: false,
379 active_tool_call: None,
380 expand_tool_calls: false,
381 stream_generation: 0,
382 stop_pending: None,
383 interrupting: false,
384 force_quit: false,
385 show_raw_markdown: false,
386 }
387 }
388
389 fn input_line_count(&self, width: u16) -> u16 {
392 let avail = (width as usize).saturating_sub(3); if avail == 0 {
394 return 1;
395 }
396 let mut rows = 1usize;
397 let mut col = 0usize;
398 for &ch in &self.input {
399 if ch == '\n' {
400 rows += 1;
401 col = 0;
402 } else {
403 if col >= avail {
404 rows += 1;
405 col = 0;
406 }
407 col += 1;
408 }
409 }
410 if self.cursor == self.input.len() && col >= avail {
412 rows += 1;
413 }
414 (rows as u16).min(MAX_INPUT_LINES)
415 }
416
417 fn handle_paste(&mut self, text: &str) {
418 if self.input_mode == InputMode::ViNormal {
419 self.input_mode = InputMode::ViInsert;
420 }
421 let chars: Vec<char> = text.chars().collect();
422 let count = chars.len();
423 self.input.splice(self.cursor..self.cursor, chars);
424 self.cursor += count;
425 self.completion_index = None;
426 }
427
428 fn handle_key(&mut self, key: KeyEvent, cancelled_gen: &AtomicU64) {
431 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
433 if self.interrupting {
434 self.force_quit = true;
436 self.should_quit = true;
437 return;
438 }
439 if self.streaming {
440 self.cancel_stream(cancelled_gen);
441 } else {
442 self.should_quit = true;
443 }
444 return;
445 }
446
447 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('r') {
449 self.show_raw_markdown = !self.show_raw_markdown;
450 return;
451 }
452
453 if key.code == KeyCode::Esc && key.modifiers == KeyModifiers::NONE && self.streaming {
455 if self.interrupting {
456 return;
457 }
458 self.cancel_stream(cancelled_gen);
459 return;
460 }
461
462 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('o') {
464 self.expand_tool_calls = !self.expand_tool_calls;
465 return;
466 }
467
468 match self.input_mode {
469 InputMode::Emacs => self.handle_key_emacs(key),
470 InputMode::ViInsert => self.handle_key_vi_insert(key),
471 InputMode::ViNormal => self.handle_key_vi_normal(key),
472 }
473 }
474
475 fn handle_key_emacs(&mut self, key: KeyEvent) {
476 if key.code == KeyCode::Tab && key.modifiers == KeyModifiers::NONE {
478 self.complete_tab();
479 return;
480 }
481 self.reset_completion();
482
483 match (key.modifiers, key.code) {
484 (KeyModifiers::NONE, KeyCode::Enter) => self.submit(),
485 (KeyModifiers::ALT, KeyCode::Enter) | (KeyModifiers::SHIFT, KeyCode::Enter) => {
487 self.input.insert(self.cursor, '\n');
488 self.cursor += 1;
489 }
490
491 (KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(c)) => {
492 self.input.insert(self.cursor, c);
493 self.cursor += 1;
494 self.history.position = None;
495 }
496
497 (KeyModifiers::NONE, KeyCode::Backspace) => {
498 if self.cursor > 0 {
499 self.cursor -= 1;
500 self.input.remove(self.cursor);
501 self.history.position = None;
502 }
503 }
504 (KeyModifiers::NONE, KeyCode::Delete) => {
505 if self.cursor < self.input.len() {
506 self.input.remove(self.cursor);
507 self.history.position = None;
508 }
509 }
510
511 (KeyModifiers::NONE, KeyCode::Left) => {
512 self.cursor = self.cursor.saturating_sub(1);
513 }
514 (KeyModifiers::NONE, KeyCode::Right) => {
515 self.cursor = (self.cursor + 1).min(self.input.len());
516 }
517
518 (KeyModifiers::ALT, KeyCode::Left) => self.cursor = self.word_back(),
520 (KeyModifiers::ALT, KeyCode::Right) => self.cursor = self.word_fwd(),
521
522 (KeyModifiers::NONE, KeyCode::Home) | (KeyModifiers::CONTROL, KeyCode::Char('a')) => {
523 self.cursor = 0;
524 }
525 (KeyModifiers::NONE, KeyCode::End) | (KeyModifiers::CONTROL, KeyCode::Char('e')) => {
526 self.cursor = self.input.len();
527 }
528
529 (KeyModifiers::CONTROL, KeyCode::Char('u')) => {
531 self.input.drain(..self.cursor);
532 self.cursor = 0;
533 }
534 (KeyModifiers::CONTROL, KeyCode::Char('k')) => {
536 self.input.truncate(self.cursor);
537 }
538 (KeyModifiers::CONTROL, KeyCode::Char('w')) => {
540 let new_cursor = self.word_back();
541 self.input.drain(new_cursor..self.cursor);
542 self.cursor = new_cursor;
543 }
544
545 (KeyModifiers::NONE, KeyCode::Up) => {
547 if let Some(chars) = self.history.prev(&self.input) {
548 self.input = chars;
549 self.cursor = self.input.len();
550 self.completion_index = None;
551 }
552 }
553 (KeyModifiers::NONE, KeyCode::Down) => {
554 if let Some(chars) = self.history.next() {
555 self.input = chars;
556 self.cursor = self.input.len();
557 self.completion_index = None;
558 }
559 }
560
561 (KeyModifiers::NONE, KeyCode::PageUp) => self.scroll_up(10),
563 (KeyModifiers::NONE, KeyCode::PageDown) => self.scroll_down(10),
564
565 _ => {}
566 }
567 }
568
569 fn handle_key_vi_insert(&mut self, key: KeyEvent) {
570 if key.code == KeyCode::Esc && key.modifiers == KeyModifiers::NONE {
571 self.input_mode = InputMode::ViNormal;
572 if self.cursor > 0 {
573 self.cursor -= 1;
574 }
575 return;
576 }
577 self.handle_key_emacs(key);
578 }
579
580 fn handle_key_vi_normal(&mut self, key: KeyEvent) {
581 if let Some(pending) = self.vi_pending.take() {
583 match (pending, key.code) {
584 ('d', KeyCode::Char('d')) => {
585 self.yank_register = self.input.iter().collect();
586 self.input.clear();
587 self.cursor = 0;
588 }
589 ('y', KeyCode::Char('y')) => {
590 self.yank_register = self.input.iter().collect();
591 }
592 _ => {}
593 }
594 return;
595 }
596
597 match (key.modifiers, key.code) {
598 (KeyModifiers::NONE, KeyCode::Char('i')) => {
600 self.input_mode = InputMode::ViInsert;
601 }
602 (KeyModifiers::NONE, KeyCode::Char('a')) => {
603 self.input_mode = InputMode::ViInsert;
604 self.cursor = (self.cursor + 1).min(self.input.len());
605 }
606 (KeyModifiers::SHIFT, KeyCode::Char('I')) => {
607 self.input_mode = InputMode::ViInsert;
608 self.cursor = 0;
609 }
610 (KeyModifiers::SHIFT, KeyCode::Char('A')) => {
611 self.input_mode = InputMode::ViInsert;
612 self.cursor = self.input.len();
613 }
614
615 (KeyModifiers::NONE, KeyCode::Char('h') | KeyCode::Left) => {
617 self.cursor = self.cursor.saturating_sub(1);
618 }
619 (KeyModifiers::NONE, KeyCode::Char('l') | KeyCode::Right) => {
620 let max = self.input.len().saturating_sub(1);
621 self.cursor = (self.cursor + 1).min(max);
622 }
623 (KeyModifiers::NONE, KeyCode::Char('0')) => self.cursor = 0,
624 (KeyModifiers::SHIFT, KeyCode::Char('$')) => {
625 self.cursor = self.input.len().saturating_sub(1);
626 }
627 (KeyModifiers::NONE, KeyCode::Char('w')) => self.cursor = self.word_fwd(),
628 (KeyModifiers::NONE, KeyCode::Char('b')) => self.cursor = self.word_back(),
629 (KeyModifiers::NONE, KeyCode::Char('e')) => self.cursor = self.word_end(),
630
631 (KeyModifiers::NONE, KeyCode::Char('x')) => {
633 if self.cursor < self.input.len() {
634 let ch = self.input.remove(self.cursor);
635 self.yank_register = ch.to_string();
636 if self.cursor > 0 && self.cursor >= self.input.len() {
637 self.cursor = self.input.len().saturating_sub(1);
638 }
639 }
640 }
641 (KeyModifiers::NONE, KeyCode::Char('d')) => {
642 self.vi_pending = Some('d');
643 }
644 (KeyModifiers::NONE, KeyCode::Char('y')) => {
645 self.vi_pending = Some('y');
646 }
647 (KeyModifiers::NONE, KeyCode::Char('p')) => {
649 if !self.yank_register.is_empty() {
650 let pos = (self.cursor + 1).min(self.input.len());
651 let chars: Vec<char> = self.yank_register.chars().collect();
652 let count = chars.len();
653 self.input.splice(pos..pos, chars);
654 self.cursor = pos + count - 1;
655 }
656 }
657 (KeyModifiers::SHIFT, KeyCode::Char('P')) => {
659 if !self.yank_register.is_empty() {
660 let chars: Vec<char> = self.yank_register.chars().collect();
661 let count = chars.len();
662 self.input.splice(self.cursor..self.cursor, chars);
663 self.cursor += count.saturating_sub(1);
664 }
665 }
666
667 (KeyModifiers::NONE, KeyCode::Enter) => self.submit(),
669
670 (KeyModifiers::NONE, KeyCode::Char('k') | KeyCode::Up) if self.input.is_empty() => {
672 if let Some(chars) = self.history.prev(&self.input) {
673 self.input = chars;
674 self.cursor = self.input.len().saturating_sub(1);
675 self.completion_index = None;
676 }
677 }
678 (KeyModifiers::NONE, KeyCode::Char('j') | KeyCode::Down) if self.input.is_empty() => {
679 if let Some(chars) = self.history.next() {
680 self.input = chars;
681 self.cursor = self.input.len().saturating_sub(1);
682 self.completion_index = None;
683 }
684 }
685
686 (KeyModifiers::NONE, KeyCode::PageUp) => self.scroll_up(10),
688 (KeyModifiers::NONE, KeyCode::PageDown) => self.scroll_down(10),
689 (KeyModifiers::CONTROL, KeyCode::Char('u')) => self.scroll_up(15),
690 (KeyModifiers::CONTROL, KeyCode::Char('d')) => self.scroll_down(15),
691
692 _ => {}
693 }
694 }
695
696 fn input_str(&self) -> String {
699 self.input.iter().collect()
700 }
701
702 fn completions(&self) -> Vec<&'static str> {
703 let text = self.input_str();
704 if !text.starts_with('/') {
705 return Vec::new();
706 }
707 COMMANDS
708 .iter()
709 .filter(|cmd| cmd.starts_with(&text) && **cmd != text)
710 .copied()
711 .collect()
712 }
713
714 fn complete_tab(&mut self) {
715 let matches = self.completions();
716 if matches.is_empty() {
717 self.completion_index = None;
718 return;
719 }
720 let idx = match self.completion_index {
721 Some(i) => (i + 1) % matches.len(),
722 None => 0,
723 };
724 self.completion_index = Some(idx);
725 let cmd = matches[idx];
726 self.input = cmd.chars().collect();
727 self.cursor = self.input.len();
728 }
729
730 fn reset_completion(&mut self) {
731 self.completion_index = None;
732 }
733
734 fn word_fwd(&self) -> usize {
737 let mut i = self.cursor;
738 while i < self.input.len() && !self.input[i].is_whitespace() {
739 i += 1;
740 }
741 while i < self.input.len() && self.input[i].is_whitespace() {
742 i += 1;
743 }
744 i
745 }
746
747 fn word_back(&self) -> usize {
748 if self.cursor == 0 {
749 return 0;
750 }
751 let mut i = self.cursor - 1;
752 while i > 0 && self.input[i].is_whitespace() {
753 i -= 1;
754 }
755 while i > 0 && !self.input[i - 1].is_whitespace() {
756 i -= 1;
757 }
758 i
759 }
760
761 fn word_end(&self) -> usize {
762 if self.input.is_empty() {
763 return 0;
764 }
765 let last = self.input.len() - 1;
766 let mut i = self.cursor;
767 if i < last {
768 i += 1;
769 }
770 while i < last && self.input[i].is_whitespace() {
771 i += 1;
772 }
773 while i < last && !self.input[i + 1].is_whitespace() {
774 i += 1;
775 }
776 i
777 }
778
779 fn scroll_up(&mut self, n: usize) {
782 self.scroll = self.scroll.saturating_add(n);
783 self.auto_scroll = false;
784 }
785
786 fn scroll_down(&mut self, n: usize) {
787 self.scroll = self.scroll.saturating_sub(n);
788 if self.scroll == 0 {
789 self.auto_scroll = true;
790 }
791 }
792
793 fn submit(&mut self) {
796 if self.streaming {
797 self.push_system("Waiting for response...");
798 return;
799 }
800 let text: String = self.input.drain(..).collect();
801 self.cursor = 0;
802 let text = text.trim().to_string();
803 if text.is_empty() {
804 return;
805 }
806
807 if text.starts_with('/') {
808 self.handle_command(&text);
809 return;
810 }
811
812 self.history.push(&text);
813
814 self.messages.push(Message {
815 role: Role::User,
816 content: text.clone(),
817 timestamp: SystemTime::now(),
818 tool_call: None,
819 });
820
821 self.pending_request = Some(OttoChatRequest {
822 prompt: text,
823 runtime_uuid: self.runtime_uuid.clone(),
824 thread_id: self.thread_id.clone(),
825 model: self.otto_model.clone(),
826 });
827 self.streaming = true;
828 self.stream_buffer.clear();
829 self.stream_pending.clear();
830 self.last_stream_tick = Instant::now();
831 self.stream_start = Some(Instant::now());
832 self.auto_scroll = true;
833 self.scroll = 0;
834
835 if self.input_mode == InputMode::ViNormal {
836 self.input_mode = InputMode::ViInsert;
837 }
838 }
839
840 fn push_system(&mut self, content: impl Into<String>) {
841 self.messages.push(Message {
842 role: Role::System,
843 content: content.into(),
844 timestamp: SystemTime::now(),
845 tool_call: None,
846 });
847 }
848
849 fn handle_command(&mut self, cmd: &str) {
850 let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
851 match parts[0] {
852 "/vim" | "/vi" => {
853 self.input_mode = InputMode::ViNormal;
854 self.push_system("Vi mode");
855 }
856 "/emacs" => {
857 self.input_mode = InputMode::Emacs;
858 self.push_system("Emacs mode");
859 }
860 "/clear" => {
861 self.messages.clear();
862 self.scroll = 0;
863 self.thread_id = None;
864 self.push_system("Thread cleared");
865 }
866 "/copy" => {
867 let last_otto = self
868 .messages
869 .iter()
870 .rev()
871 .find(|m| m.role == Role::Otto)
872 .map(|m| m.content.clone());
873 match last_otto {
874 Some(text) => {
875 match arboard::Clipboard::new().and_then(|mut cb| cb.set_text(text)) {
876 Ok(()) => self.push_system("Copied to clipboard"),
877 Err(e) => self.push_system(format!("Clipboard error: {e}")),
878 }
879 }
880 None => self.push_system("No Otto message to copy"),
881 }
882 }
883 "/timestamps" => {
884 self.show_timestamps = !self.show_timestamps;
885 let state = if self.show_timestamps { "on" } else { "off" };
886 self.push_system(format!("Timestamps {state}"));
887 }
888 "/quit" | "/exit" | "/q" => {
889 self.should_quit = true;
890 }
891 "/help" => {
892 self.push_system(concat!(
893 "Commands:\n",
894 " /emacs Switch to Emacs keybindings\n",
895 " /vim Switch to Vi keybindings (default)\n",
896 " /copy Copy last Otto response to clipboard\n",
897 " /timestamps Toggle message timestamps\n",
898 " /clear Clear chat and start new thread\n",
899 " /quit, /exit Exit\n",
900 " /help Show this help\n",
901 "\n",
902 "Keys:\n",
903 " Enter Send message\n",
904 " Alt+Enter Insert newline\n",
905 " Esc Vi normal mode\n",
906 " Up/Down Input history\n",
907 " PageUp/Down Scroll chat\n",
908 " Tab Complete /command\n",
909 " Ctrl+o Toggle tool call details\n",
910 " Ctrl+C Cancel stream / Exit",
911 ));
912 }
913 other => {
914 self.push_system(format!("Unknown command: {other}"));
915 }
916 }
917 }
918
919 fn handle_stream_msg(&mut self, msg: StreamMsg) {
922 match msg {
923 StreamMsg::ProviderInfo {
924 provider_label: provider,
925 model_label: model,
926 } => {
927 self.provider_label = provider;
928 self.model_label = model;
929 }
930 StreamMsg::ConversationHistory {
931 generation,
932 messages,
933 } => {
934 if generation == self.stream_generation && self.messages.is_empty() {
935 self.messages = messages;
936 }
937 }
938 StreamMsg::StopFinished { error } => {
939 if self.interrupting {
942 self.finish_stream();
943 if let Some(err) = error {
944 self.push_system(format!("Interrupt failed: {err}"));
945 } else {
946 self.push_system("Cancelled");
947 }
948 }
949 }
950 StreamMsg::Stream { generation, kind } => {
951 if generation != self.stream_generation {
953 return;
954 }
955 self.handle_stream_kind(kind);
956 }
957 }
958 }
959
960 fn handle_stream_kind(&mut self, kind: StreamMsgKind) {
961 match kind {
962 StreamMsgKind::ThreadId(tid) => {
963 self.thread_id = Some(tid);
964 }
965 StreamMsgKind::Delta(text) => {
966 self.stream_pending.extend(text.chars());
967 }
968 StreamMsgKind::ToolCallStart { name, arguments } => {
969 self.flush_stream_text();
970 self.active_tool_call = Some((name, arguments));
971 }
972 StreamMsgKind::ToolCallOutput { name, output } => {
973 let arguments = self
974 .active_tool_call
975 .take()
976 .map(|(_, args)| args)
977 .unwrap_or_default();
978 let output_summary = truncate(&output, 80);
979 self.messages.push(Message {
980 role: Role::System,
981 content: format!("\u{2699} {name} \u{2192} {output_summary}"),
982 timestamp: SystemTime::now(),
983 tool_call: Some(ToolCallData {
984 name,
985 arguments,
986 output,
987 }),
988 });
989 }
990 StreamMsgKind::Finished { status, error } => match status {
991 OttoStreamStatus::Completed => {
992 let should_bell = self
993 .stream_start
994 .is_some_and(|s| s.elapsed() > Duration::from_secs(3));
995 self.finish_stream();
996 if should_bell {
997 let _ =
998 crossterm::execute!(std::io::stderr(), crossterm::style::Print("\x07"));
999 }
1000 }
1001 OttoStreamStatus::Cancelled => {
1002 }
1005 OttoStreamStatus::Interrupted => {
1006 self.finish_stream();
1007 let detail = error.unwrap_or_else(|| "stream interrupted".to_string());
1008 self.push_system(format!("Connection lost: {detail}"));
1009 }
1010 },
1011 StreamMsgKind::Error(err) => {
1012 self.finish_stream();
1013 let message = if err.contains("Otto stream ended unexpectedly") {
1014 format!("Connection lost: {err}")
1015 } else {
1016 format!("Error: {err}")
1017 };
1018 self.push_system(message);
1019 }
1020 }
1021 }
1022
1023 fn flush_stream_text(&mut self) {
1024 let remaining: String = self.stream_pending.drain(..).collect();
1025 self.stream_buffer.push_str(&remaining);
1026 let content = std::mem::take(&mut self.stream_buffer);
1027 if !content.is_empty() {
1028 self.messages.push(Message {
1029 role: Role::Otto,
1030 content,
1031 timestamp: SystemTime::now(),
1032 tool_call: None,
1033 });
1034 }
1035 }
1036
1037 fn cancel_stream(&mut self, cancelled_gen: &AtomicU64) {
1038 if self.interrupting {
1039 return;
1040 }
1041 let cancelled_generation = self.stream_generation;
1045 cancelled_gen.store(cancelled_generation, Ordering::Release);
1046 self.stream_generation = cancelled_generation.wrapping_add(1);
1047 self.flush_stream_text();
1048 self.active_tool_call = None;
1049 self.interrupting = true;
1050 self.stop_pending = Some(cancelled_generation);
1051 }
1052
1053 fn finish_stream(&mut self) {
1054 self.flush_stream_text();
1055 self.streaming = false;
1056 self.interrupting = false;
1057 self.active_tool_call = None;
1058 self.stream_start = None;
1059 }
1060
1061 fn tick_stream(&mut self) {
1062 if self.stream_pending.is_empty() {
1063 return;
1064 }
1065
1066 let elapsed = self.last_stream_tick.elapsed();
1067 let chars_due = (elapsed.as_secs_f64() * STREAM_CPS) as usize;
1068
1069 if chars_due == 0 {
1070 return;
1071 }
1072
1073 self.last_stream_tick = Instant::now();
1074 let pending = self.stream_pending.len();
1075
1076 let n = if pending > STREAM_BULK_THRESHOLD {
1077 pending.min(chars_due + 100)
1078 } else if pending > STREAM_FAST_THRESHOLD {
1079 chars_due * 3
1080 } else {
1081 chars_due
1082 };
1083
1084 let n = n.min(pending);
1085 let chunk: String = self.stream_pending.drain(..n).collect();
1086 self.stream_buffer.push_str(&chunk);
1087 }
1088
1089 fn take_pending_request(&mut self) -> Option<OttoChatRequest> {
1090 self.pending_request.take()
1091 }
1092
1093 fn tick_spinner(&mut self) {
1094 if self.streaming && self.last_spinner.elapsed() >= SPINNER_INTERVAL {
1095 self.spinner_frame = (self.spinner_frame + 1) % SPINNER.len();
1096 self.last_spinner = Instant::now();
1097 }
1098 }
1099
1100 fn render(&self, frame: &mut Frame) {
1103 let area = frame.area();
1104 if area.height < 5 {
1105 return;
1106 }
1107
1108 let input_height = self.input_line_count(area.width);
1109 let chunks = Layout::vertical([
1110 Constraint::Min(1), Constraint::Length(1), Constraint::Length(input_height), Constraint::Length(1), Constraint::Length(1), ])
1116 .split(area);
1117
1118 self.render_chat(frame, chunks[0]);
1119 self.render_rule(frame, chunks[1]);
1120 self.render_input(frame, chunks[2]);
1121 self.render_rule(frame, chunks[3]);
1122 self.render_completions(frame, chunks[0], chunks[1]);
1123 self.render_status(frame, chunks[4]);
1124
1125 let cursor_style = match self.input_mode {
1127 InputMode::ViNormal => SetCursorStyle::SteadyBlock,
1128 _ => SetCursorStyle::BlinkingBar,
1129 };
1130 let _ = crossterm::execute!(std::io::stderr(), cursor_style);
1131 }
1132
1133 fn render_chat(&self, frame: &mut Frame, area: Rect) {
1134 let has_content = self.streaming || !self.messages.is_empty();
1135 if !has_content {
1136 self.render_splash(frame, area);
1137 return;
1138 }
1139
1140 let mut lines: Vec<Line<'_>> = Vec::new();
1141
1142 for msg in &self.messages {
1143 if !lines.is_empty() {
1144 lines.push(Line::raw(""));
1145 }
1146
1147 let (label, color) = match msg.role {
1148 Role::User => (" you", USER_COLOR),
1149 Role::Otto => (" otto", OTTO_COLOR),
1150 Role::System => ("", SYSTEM_COLOR),
1151 };
1152
1153 if !label.is_empty() {
1154 let mut label_spans = vec![Span::styled(label, Style::default().fg(color).bold())];
1155 if self.show_timestamps {
1156 label_spans.push(Span::styled(
1157 format!(" {}", format_time(msg.timestamp)),
1158 Style::default().fg(TIMESTAMP_COLOR),
1159 ));
1160 }
1161 lines.push(Line::from(label_spans));
1162 } else if self.show_timestamps {
1163 lines.push(Line::from(Span::styled(
1165 format!(" {}", format_time(msg.timestamp)),
1166 Style::default().fg(TIMESTAMP_COLOR),
1167 )));
1168 }
1169
1170 if let Some(tc) = &msg.tool_call {
1171 lines.extend(render_tool_call(tc, self.expand_tool_calls));
1172 } else {
1173 lines.extend(render_markdown(
1174 &msg.content,
1175 msg.role,
1176 self.show_raw_markdown,
1177 ));
1178 }
1179 }
1180
1181 if self.streaming {
1183 if !lines.is_empty() {
1184 lines.push(Line::raw(""));
1185 }
1186 lines.push(Line::from(Span::styled(
1187 " otto",
1188 Style::default().fg(OTTO_COLOR).bold(),
1189 )));
1190
1191 if self.stream_buffer.is_empty() && self.stream_pending.is_empty() {
1192 let label = if self.interrupting {
1193 format!(" {} Stopping...", SPINNER[self.spinner_frame])
1194 } else if let Some((tool, _)) = &self.active_tool_call {
1195 format!(" {} \u{2699} {tool}...", SPINNER[self.spinner_frame])
1196 } else {
1197 format!(" {} Ascending...", SPINNER[self.spinner_frame])
1198 };
1199 lines.push(Line::from(Span::styled(
1200 label,
1201 Style::default().fg(DIM_OTTO_COLOR),
1202 )));
1203 } else if self.interrupting {
1204 lines.extend(render_markdown(
1205 &self.stream_buffer,
1206 Role::Otto,
1207 self.show_raw_markdown,
1208 ));
1209 lines.push(Line::from(Span::styled(
1210 format!(" {} Stopping...", SPINNER[self.spinner_frame]),
1211 Style::default().fg(DIM_OTTO_COLOR),
1212 )));
1213 } else if let Some((tool, _)) = &self.active_tool_call {
1214 lines.extend(render_markdown(
1215 &self.stream_buffer,
1216 Role::Otto,
1217 self.show_raw_markdown,
1218 ));
1219 lines.push(Line::from(Span::styled(
1220 format!(" {} \u{2699} {tool}...", SPINNER[self.spinner_frame]),
1221 Style::default().fg(DIM_OTTO_COLOR),
1222 )));
1223 } else {
1224 lines.extend(render_markdown(
1225 &self.stream_buffer,
1226 Role::Otto,
1227 self.show_raw_markdown,
1228 ));
1229 }
1230 }
1231
1232 lines.push(Line::raw(""));
1234
1235 let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
1237 let total_rendered = paragraph.line_count(area.width);
1238 let visible = area.height as usize;
1239 let max_scroll = total_rendered.saturating_sub(visible);
1240 let clamped_scroll = self.scroll.min(max_scroll);
1241 let scroll_y = max_scroll.saturating_sub(clamped_scroll);
1242
1243 let paragraph = paragraph.scroll((scroll_y.min(u16::MAX as usize) as u16, 0));
1244 frame.render_widget(Clear, area);
1246 frame.render_widget(paragraph, area);
1247
1248 if total_rendered > visible {
1250 let scrollbar_position = max_scroll.saturating_sub(clamped_scroll);
1251 let mut scrollbar_state = ScrollbarState::new(max_scroll).position(scrollbar_position);
1252 frame.render_stateful_widget(
1253 Scrollbar::new(ScrollbarOrientation::VerticalRight)
1254 .style(Style::default().fg(DIM_COLOR)),
1255 area,
1256 &mut scrollbar_state,
1257 );
1258 }
1259 }
1260
1261 fn render_splash(&self, frame: &mut Frame, area: Rect) {
1262 let banner_height = EXPERIMENTAL_BANNER.len() as u16 + 2; let splash_height = SPLASH.len() as u16;
1264 let total_height = splash_height + banner_height;
1265 let y_offset = area.height.saturating_sub(total_height) / 2;
1266
1267 let warning_style = Style::default().fg(WARNING_COLOR).bold();
1268
1269 let mut lines: Vec<Line<'_>> = Vec::new();
1270
1271 lines.push(Line::raw(""));
1273 for &line in EXPERIMENTAL_BANNER {
1274 let display_width = line.chars().count();
1275 let pad = (area.width as usize).saturating_sub(display_width) / 2;
1276 let padded = format!("{:>width$}{}", "", line, width = pad);
1277 lines.push(Line::from(Span::styled(padded, warning_style)));
1278 }
1279 lines.push(Line::raw(""));
1280
1281 for &line in SPLASH {
1283 let display_width = line.chars().count();
1284 let pad = (area.width as usize).saturating_sub(display_width) / 2;
1285 let padded = format!("{:>width$}{}", "", line, width = pad);
1286 if line.contains("/help") {
1287 lines.push(Line::from(Span::styled(
1288 padded,
1289 Style::default().fg(DIM_COLOR),
1290 )));
1291 } else {
1292 lines.push(Line::from(Span::styled(
1293 padded,
1294 Style::default().fg(OTTO_COLOR),
1295 )));
1296 }
1297 }
1298
1299 let clamped_height = total_height.min(area.height);
1300 let splash_area = Rect::new(area.x, area.y + y_offset, area.width, clamped_height);
1301 frame.render_widget(Paragraph::new(lines), splash_area);
1302 }
1303
1304 fn render_rule(&self, frame: &mut Frame, area: Rect) {
1305 let rule_color = match self.input_mode {
1306 InputMode::ViNormal => VI_NORMAL_COLOR,
1307 _ if self.streaming => DIM_COLOR,
1308 _ => OTTO_COLOR,
1309 };
1310 let rule = "\u{2500}".repeat(area.width as usize);
1311 frame.render_widget(
1312 Paragraph::new(Line::from(Span::styled(
1313 rule,
1314 Style::default().fg(rule_color),
1315 ))),
1316 area,
1317 );
1318 }
1319
1320 fn render_input(&self, frame: &mut Frame, area: Rect) {
1321 let prompt = match self.input_mode {
1322 InputMode::ViNormal => " \u{2502} ",
1323 _ => " \u{276f} ",
1324 };
1325 let prompt_len = 3usize;
1326 let avail = (area.width as usize).saturating_sub(prompt_len);
1327 if avail == 0 {
1328 return;
1329 }
1330
1331 let prompt_color = match self.input_mode {
1332 InputMode::ViNormal => VI_NORMAL_COLOR,
1333 _ if self.streaming => DIM_COLOR,
1334 _ => OTTO_COLOR,
1335 };
1336
1337 let mut rows: Vec<String> = vec![String::new()];
1339 let mut col = 0usize;
1340 let mut cursor_row = 0usize;
1341 let mut cursor_col = 0usize;
1342
1343 for (i, &ch) in self.input.iter().enumerate() {
1344 if i == self.cursor {
1345 cursor_row = rows.len() - 1;
1346 cursor_col = col;
1347 }
1348 if ch == '\n' {
1349 rows.push(String::new());
1350 col = 0;
1351 } else {
1352 if col >= avail {
1353 rows.push(String::new());
1354 col = 0;
1355 }
1356 rows.last_mut().unwrap().push(ch);
1357 col += 1;
1358 }
1359 }
1360 if self.cursor == self.input.len() {
1362 if col >= avail {
1363 rows.push(String::new());
1364 cursor_row = rows.len() - 1;
1365 cursor_col = 0;
1366 } else {
1367 cursor_row = rows.len() - 1;
1368 cursor_col = col;
1369 }
1370 }
1371
1372 let max_visible = area.height as usize;
1374 let scroll_offset = if cursor_row >= max_visible {
1375 cursor_row - max_visible + 1
1376 } else {
1377 0
1378 };
1379 let visible_end = (scroll_offset + max_visible).min(rows.len());
1380
1381 let mut render_lines: Vec<Line<'_>> = Vec::new();
1382 for (i, row) in rows
1383 .iter()
1384 .enumerate()
1385 .take(visible_end)
1386 .skip(scroll_offset)
1387 {
1388 let p = if i == 0 { prompt } else { " " };
1389 let p_style = if i == 0 {
1390 Style::default().fg(prompt_color)
1391 } else {
1392 Style::default().fg(DIM_COLOR)
1393 };
1394 render_lines.push(Line::from(vec![
1395 Span::styled(p, p_style),
1396 Span::raw(row.clone()),
1397 ]));
1398 }
1399
1400 frame.render_widget(Paragraph::new(render_lines), area);
1401
1402 if !self.streaming {
1403 let cx = area.x + prompt_len as u16 + cursor_col as u16;
1404 let cy = area.y + (cursor_row - scroll_offset) as u16;
1405 frame.set_cursor_position((cx, cy));
1406 }
1407 }
1408
1409 fn render_completions(&self, frame: &mut Frame, chat_area: Rect, rule_area: Rect) {
1410 let matches = self.completions();
1411 if matches.is_empty() {
1412 return;
1413 }
1414
1415 let height = matches.len().min(8) as u16;
1416 let width = matches.iter().map(|s| s.len()).max().unwrap_or(0) as u16 + 4;
1417
1418 let x = rule_area.x + 1;
1419 let y = chat_area.bottom().saturating_sub(height);
1420 let popup = Rect::new(x, y, width.min(rule_area.width), height);
1421
1422 frame.render_widget(Clear, popup);
1423
1424 let items: Vec<Line<'_>> = matches
1425 .iter()
1426 .enumerate()
1427 .map(|(i, cmd)| {
1428 let style = if self.completion_index == Some(i) {
1429 Style::default().fg(TEXT_COLOR).bg(OTTO_COLOR).bold()
1430 } else {
1431 Style::default().fg(TEXT_COLOR).bg(POPUP_BG)
1432 };
1433 Line::from(Span::styled(format!(" {cmd} "), style))
1434 })
1435 .collect();
1436
1437 let block = Block::default()
1438 .borders(Borders::NONE)
1439 .style(Style::default().bg(POPUP_BG));
1440 let paragraph = Paragraph::new(items).block(block);
1441 frame.render_widget(paragraph, popup);
1442 }
1443
1444 fn render_status(&self, frame: &mut Frame, area: Rect) {
1445 let (mode, mode_color) = match self.input_mode {
1446 InputMode::Emacs => ("emacs", SYSTEM_COLOR),
1447 InputMode::ViInsert => ("INSERT", VI_NORMAL_COLOR),
1448 InputMode::ViNormal => ("NORMAL", VI_NORMAL_COLOR),
1449 };
1450 let (mode, mode_color) = if self.interrupting {
1451 ("STOPPING", WARNING_COLOR)
1452 } else {
1453 (mode, mode_color)
1454 };
1455
1456 let mut parts = vec![Span::styled(
1457 format!(" {mode}"),
1458 Style::default().fg(mode_color),
1459 )];
1460
1461 let pill_style = Style::default().fg(DIM_OTTO_COLOR);
1462
1463 if let Some(label) = &self.context_label {
1464 parts.push(Span::raw(" "));
1465 parts.push(Span::styled(format!(" {label} "), pill_style));
1466 }
1467
1468 if let Some(provider) = &self.provider_label {
1469 parts.push(Span::raw(" "));
1470 parts.push(Span::styled(format!(" provider:{provider} "), pill_style));
1471 }
1472
1473 if !self.model_label.is_empty() {
1474 parts.push(Span::raw(" "));
1475 parts.push(Span::styled(
1476 format!(" model:{} ", self.model_label),
1477 pill_style,
1478 ));
1479 }
1480
1481 if let Some(tid) = &self.thread_id {
1482 let short: String = tid.chars().take(12).collect();
1483 parts.push(Span::raw(" "));
1484 parts.push(Span::styled(format!(" thread:{short} "), pill_style));
1485 }
1486
1487 let msg_count = self
1488 .messages
1489 .iter()
1490 .filter(|m| m.role != Role::System)
1491 .count();
1492 if msg_count > 0 {
1493 parts.push(Span::raw(" "));
1494 parts.push(Span::styled(format!(" {msg_count} messages "), pill_style));
1495 }
1496
1497 let total_width: usize = parts.iter().map(|s| s.width()).sum();
1499 if total_width > area.width as usize {
1500 let mut width = 0;
1501 let mut truncated = Vec::new();
1502 for span in parts {
1503 width += span.width();
1504 if width > area.width as usize {
1505 break;
1506 }
1507 truncated.push(span);
1508 }
1509 frame.render_widget(Paragraph::new(Line::from(truncated)), area);
1510 } else {
1511 frame.render_widget(Paragraph::new(Line::from(parts)), area);
1512 }
1513 }
1514}
1515
1516fn truncate(s: &str, max_len: usize) -> String {
1522 if s.chars().count() <= max_len {
1523 s.to_string()
1524 } else {
1525 let truncated: String = s.chars().take(max_len).collect();
1526 format!("{truncated}...")
1527 }
1528}
1529
1530fn format_time(time: SystemTime) -> String {
1531 let elapsed = time.elapsed().unwrap_or_default();
1533 let secs = elapsed.as_secs();
1534 if secs < 60 {
1535 "just now".to_string()
1536 } else if secs < 3600 {
1537 format!("{}m ago", secs / 60)
1538 } else {
1539 format!("{}h ago", secs / 3600)
1540 }
1541}
1542
1543fn render_markdown(text: &str, role: Role, raw: bool) -> Vec<Line<'static>> {
1548 if raw {
1549 return render_raw(text, role);
1550 }
1551 render_markdown_parsed(text, role)
1552}
1553
1554fn render_tool_call(tc: &ToolCallData, expanded: bool) -> Vec<Line<'static>> {
1555 let indent = " ";
1556 let sys_style = Style::default().fg(SYSTEM_COLOR).italic();
1557 let dim_style = Style::default().fg(DIM_COLOR);
1558 let text_style = Style::default().fg(TEXT_COLOR);
1559
1560 let mut lines = Vec::new();
1561 lines.push(Line::from(Span::styled(
1562 format!("{indent}\u{2699} {}", tc.name),
1563 sys_style,
1564 )));
1565
1566 if !expanded {
1567 let summary = truncate(&tc.output, 80);
1568 lines.push(Line::from(vec![
1569 Span::styled(format!("{indent}\u{2192} {summary}"), text_style),
1570 Span::styled(" Ctrl+o to expand", dim_style),
1571 ]));
1572 return lines;
1573 }
1574
1575 let pretty = |raw: &str| -> String {
1577 serde_json::from_str::<serde_json::Value>(raw)
1578 .ok()
1579 .and_then(|v| serde_json::to_string_pretty(&v).ok())
1580 .unwrap_or_else(|| raw.to_string())
1581 };
1582
1583 for (label, raw) in [("arguments", &tc.arguments), ("output", &tc.output)] {
1584 if raw.is_empty() {
1585 continue;
1586 }
1587 let content = pretty(raw);
1588 lines.push(Line::from(Span::styled(
1589 format!("{indent}\u{256d}\u{2500} {label} \u{2500}"),
1590 dim_style,
1591 )));
1592 for line in content.lines() {
1593 lines.push(Line::from(Span::styled(
1594 format!("{indent}\u{2502} {line}"),
1595 text_style,
1596 )));
1597 }
1598 lines.push(Line::from(Span::styled(
1599 format!("{indent}\u{2570}\u{2500}\u{2500}"),
1600 dim_style,
1601 )));
1602 }
1603
1604 lines.push(Line::from(Span::styled(
1605 format!("{indent}Ctrl+o to collapse"),
1606 dim_style,
1607 )));
1608
1609 lines
1610}
1611
1612fn render_raw(text: &str, role: Role) -> Vec<Line<'static>> {
1614 let base_style = match role {
1615 Role::System => Style::default().fg(SYSTEM_COLOR).italic(),
1616 _ => Style::default(),
1617 };
1618 text.lines()
1619 .map(|line| {
1620 Line::from(vec![
1621 Span::raw(" "),
1622 Span::styled(line.to_string(), base_style),
1623 ])
1624 })
1625 .collect()
1626}
1627
1628fn render_markdown_parsed(text: &str, role: Role) -> Vec<Line<'static>> {
1630 let base_style = match role {
1631 Role::System => Style::default().fg(SYSTEM_COLOR).italic(),
1632 _ => Style::default(),
1633 };
1634
1635 let mut md = MdRenderer {
1636 lines: Vec::new(),
1637 spans: Vec::new(),
1638 style_stack: vec![base_style],
1639 base_indent: " ".to_string(),
1640 list_indent: String::new(),
1641 list_stack: Vec::new(),
1642 in_code_block: false,
1643 code_block_lang: String::new(),
1644 highlighter: None,
1645 blockquote_depth: 0,
1646 in_heading: false,
1647 in_table: false,
1648 in_table_header: false,
1649 table_cell_spans: Vec::new(),
1650 table_cell_texts: Vec::new(),
1651 table_row_spans: Vec::new(),
1652 table_header_spans: Vec::new(),
1653 table_body_spans: Vec::new(),
1654 table_col_widths: Vec::new(),
1655 table_alignments: Vec::new(),
1656 link_url: None,
1657 link_text: String::new(),
1658 };
1659
1660 let opts = Options::ENABLE_TABLES
1661 | Options::ENABLE_STRIKETHROUGH
1662 | Options::ENABLE_TASKLISTS
1663 | Options::ENABLE_GFM;
1664 let parser = MdParser::new_ext(text, opts);
1665
1666 for event in parser {
1667 md.process(event);
1668 }
1669
1670 md.flush_line();
1672
1673 md.lines
1674}
1675
1676#[derive(Clone)]
1677enum ListKind {
1678 Unordered,
1679 Ordered { next: u64, max_digits: usize },
1680}
1681
1682#[derive(Clone)]
1683struct ListEntry {
1684 kind: ListKind,
1685 parent_indent: String,
1687}
1688
1689struct MdRenderer {
1690 lines: Vec<Line<'static>>,
1691 spans: Vec<Span<'static>>,
1692 style_stack: Vec<Style>,
1693 base_indent: String,
1694 list_indent: String,
1695 list_stack: Vec<ListEntry>,
1696 in_code_block: bool,
1697 code_block_lang: String,
1698 highlighter: Option<HighlightLines<'static>>,
1699 blockquote_depth: usize,
1700 in_heading: bool,
1701 in_table: bool,
1703 in_table_header: bool,
1704 table_cell_spans: Vec<Span<'static>>,
1705 table_cell_texts: Vec<String>,
1706 table_row_spans: Vec<Vec<Span<'static>>>,
1707 table_header_spans: Vec<Vec<Span<'static>>>,
1708 table_body_spans: Vec<Vec<Vec<Span<'static>>>>,
1709 table_col_widths: Vec<usize>,
1710 table_alignments: Vec<pulldown_cmark::Alignment>,
1711 link_url: Option<String>,
1712 link_text: String,
1713}
1714
1715impl MdRenderer {
1716 fn current_style(&self) -> Style {
1717 self.style_stack.last().copied().unwrap_or_default()
1718 }
1719
1720 fn push_style(&mut self, modifier: impl FnOnce(Style) -> Style) {
1721 let new = modifier(self.current_style());
1722 self.style_stack.push(new);
1723 }
1724
1725 fn pop_style(&mut self) {
1726 if self.style_stack.len() > 1 {
1727 self.style_stack.pop();
1728 }
1729 }
1730
1731 fn indent_prefix(&self) -> String {
1733 let mut prefix = self.base_indent.clone();
1734 for _ in 0..self.blockquote_depth {
1735 prefix.push_str("\u{2502} ");
1736 }
1737 prefix.push_str(&self.list_indent);
1738 prefix
1739 }
1740
1741 fn indent_spans(&self) -> Vec<Span<'static>> {
1743 let mut spans = Vec::new();
1744 if self.blockquote_depth == 0 {
1745 let mut prefix = self.base_indent.clone();
1746 prefix.push_str(&self.list_indent);
1747 spans.push(Span::raw(prefix));
1748 } else {
1749 spans.push(Span::raw(self.base_indent.clone()));
1750 for _ in 0..self.blockquote_depth {
1751 spans.push(Span::styled(
1752 "\u{2502} ".to_string(),
1753 Style::default().fg(DIM_COLOR),
1754 ));
1755 }
1756 if !self.list_indent.is_empty() {
1757 spans.push(Span::raw(self.list_indent.clone()));
1758 }
1759 }
1760 spans
1761 }
1762
1763 fn flush_line(&mut self) {
1764 if !self.spans.is_empty() {
1765 self.lines.push(Line::from(std::mem::take(&mut self.spans)));
1766 }
1767 }
1768
1769 fn blank_line_if_needed(&mut self) {
1770 self.flush_line();
1771 if let Some(last) = self.lines.last()
1773 && !(last.spans.is_empty()
1774 || (last.spans.len() == 1 && last.spans[0].content.trim().is_empty()))
1775 {
1776 self.lines.push(Line::raw(""));
1777 }
1778 }
1779
1780 fn process(&mut self, event: MdEvent<'_>) {
1781 match event {
1782 MdEvent::Start(MdTag::Heading { level, .. }) => {
1784 self.blank_line_if_needed();
1785 self.in_heading = true;
1786 match level {
1787 pulldown_cmark::HeadingLevel::H1 => {
1788 self.push_style(|s| s.fg(HEADING_COLOR).bold().underlined());
1789 }
1790 pulldown_cmark::HeadingLevel::H2 => {
1791 self.push_style(|s| s.fg(HEADING_COLOR).bold());
1792 }
1793 pulldown_cmark::HeadingLevel::H3 => {
1794 self.push_style(|s| s.bold());
1795 }
1796 _ => {
1797 self.push_style(|s| s.bold().italic());
1798 }
1799 }
1800 self.spans.extend(self.indent_spans());
1801 }
1802 MdEvent::End(MdTagEnd::Heading(_)) => {
1803 self.in_heading = false;
1804 self.pop_style();
1805 self.flush_line();
1806 }
1807
1808 MdEvent::Start(MdTag::Paragraph) => {
1809 if !self.in_code_block && self.list_stack.is_empty() {
1810 self.blank_line_if_needed();
1811 }
1812 }
1813 MdEvent::End(MdTagEnd::Paragraph) => {
1814 self.flush_line();
1815 }
1816
1817 MdEvent::Start(MdTag::BlockQuote(kind)) => {
1818 self.blank_line_if_needed();
1819 self.blockquote_depth += 1;
1820 self.push_style(|s| s.italic());
1821 if let Some(bqk) = kind {
1823 let (label, color) = match bqk {
1824 BlockQuoteKind::Note => ("NOTE", NOTE_COLOR),
1825 BlockQuoteKind::Tip => ("TIP", TIP_COLOR),
1826 BlockQuoteKind::Important => ("IMPORTANT", IMPORTANT_COLOR),
1827 BlockQuoteKind::Warning => ("WARNING", WARNING_COLOR),
1828 BlockQuoteKind::Caution => ("CAUTION", CAUTION_COLOR),
1829 };
1830 let mut label_spans = self.indent_spans();
1831 label_spans.push(Span::styled(
1832 label.to_string(),
1833 Style::default().fg(color).bold(),
1834 ));
1835 self.lines.push(Line::from(label_spans));
1836 }
1837 }
1838 MdEvent::End(MdTagEnd::BlockQuote(_)) => {
1839 self.flush_line();
1840 self.blockquote_depth = self.blockquote_depth.saturating_sub(1);
1841 self.pop_style();
1842 }
1843
1844 MdEvent::Start(MdTag::CodeBlock(kind)) => {
1845 self.blank_line_if_needed();
1846 self.in_code_block = true;
1847 self.code_block_lang = match kind {
1851 CodeBlockKind::Fenced(info) => {
1852 info.split_whitespace().next().unwrap_or("").to_string()
1853 }
1854 CodeBlockKind::Indented => String::new(),
1855 };
1856 self.highlighter =
1858 find_syntax(&self.code_block_lang).map(|syn| HighlightLines::new(syn, &THEME));
1859 let prefix = self.indent_prefix();
1860 let header = if self.code_block_lang.is_empty() {
1861 format!("{prefix}\u{256d}\u{2500}\u{2500}")
1862 } else {
1863 format!("{prefix}\u{256d}\u{2500} {} \u{2500}", self.code_block_lang)
1864 };
1865 self.lines.push(Line::from(Span::styled(
1866 header,
1867 Style::default().fg(DIM_COLOR),
1868 )));
1869 }
1870 MdEvent::End(MdTagEnd::CodeBlock) => {
1871 let prefix = self.indent_prefix();
1872 self.lines.push(Line::from(Span::styled(
1873 format!("{prefix}\u{2570}\u{2500}\u{2500}"),
1874 Style::default().fg(DIM_COLOR),
1875 )));
1876 self.in_code_block = false;
1877 self.code_block_lang.clear();
1878 self.highlighter = None;
1879 }
1880
1881 MdEvent::Start(MdTag::List(first)) => {
1882 if self.list_stack.is_empty() {
1883 self.blank_line_if_needed();
1884 }
1885 let kind = match first {
1886 Some(start) => ListKind::Ordered {
1887 next: start,
1888 max_digits: start.to_string().len(),
1889 },
1890 None => ListKind::Unordered,
1891 };
1892 self.list_stack.push(ListEntry {
1893 kind,
1894 parent_indent: self.list_indent.clone(),
1895 });
1896 }
1897 MdEvent::End(MdTagEnd::List(_)) => {
1898 self.list_stack.pop();
1899 if self.list_stack.is_empty() {
1900 self.flush_line();
1901 }
1902 }
1903
1904 MdEvent::Start(MdTag::Item) => {
1905 self.flush_line();
1906 let depth = self.list_stack.len().saturating_sub(1);
1907 let nested_indent = " ".repeat(depth);
1908
1909 let bullet = match self.list_stack.last().map(|e| &e.kind) {
1910 Some(ListKind::Unordered) => format!("{nested_indent}\u{2022} "),
1911 Some(ListKind::Ordered { next, max_digits }) => {
1912 let num = *next;
1913 let d = (*max_digits).max(num.to_string().len());
1915 format!("{nested_indent}{num:>d$}. ")
1916 }
1917 None => String::new(),
1918 };
1919
1920 self.list_indent = bullet.clone();
1922 self.spans.extend(self.indent_spans());
1923 self.list_indent = " ".repeat(bullet.len());
1925 }
1926 MdEvent::End(MdTagEnd::Item) => {
1927 self.flush_line();
1928 if let Some(ListEntry {
1929 kind: ListKind::Ordered { next, max_digits },
1930 ..
1931 }) = self.list_stack.last_mut()
1932 {
1933 *next += 1;
1934 *max_digits = (*max_digits).max(next.to_string().len());
1935 }
1936 self.list_indent = self
1938 .list_stack
1939 .last()
1940 .map(|entry| entry.parent_indent.clone())
1941 .unwrap_or_default();
1942 }
1943
1944 MdEvent::Start(MdTag::Strong) => {
1946 self.push_style(|s| s.bold());
1947 }
1948 MdEvent::End(MdTagEnd::Strong) => {
1949 self.pop_style();
1950 }
1951
1952 MdEvent::Start(MdTag::Emphasis) => {
1953 self.push_style(|s| s.italic());
1954 }
1955 MdEvent::End(MdTagEnd::Emphasis) => {
1956 self.pop_style();
1957 }
1958
1959 MdEvent::Start(MdTag::Strikethrough) => {
1960 self.push_style(|s| s.crossed_out());
1961 }
1962 MdEvent::End(MdTagEnd::Strikethrough) => {
1963 self.pop_style();
1964 }
1965
1966 MdEvent::Start(MdTag::Link { dest_url, .. }) => {
1967 self.push_style(|s| s.fg(LINK_COLOR).underlined());
1968 self.link_url = Some(dest_url.to_string());
1969 self.link_text.clear();
1970 }
1971 MdEvent::End(MdTagEnd::Link) => {
1972 self.pop_style();
1973 if let Some(url) = self.link_url.take() {
1974 let text = std::mem::take(&mut self.link_text);
1975 if text != url {
1977 self.spans.push(Span::styled(
1978 format!(" ({url})"),
1979 Style::default().fg(DIM_COLOR),
1980 ));
1981 }
1982 }
1983 }
1984
1985 MdEvent::Start(MdTag::Image { dest_url, .. }) => {
1986 if self.spans.is_empty() {
1987 self.spans.extend(self.indent_spans());
1988 }
1989 self.spans.push(Span::styled(
1990 format!("[image: {dest_url}]"),
1991 Style::default().fg(DIM_COLOR),
1992 ));
1993 }
1994 MdEvent::End(MdTagEnd::Image) => {}
1995
1996 MdEvent::Start(MdTag::Table(alignments)) => {
1998 self.blank_line_if_needed();
1999 self.in_table = true;
2000 self.table_alignments = alignments;
2001 self.table_col_widths.clear();
2002 self.table_header_spans.clear();
2003 self.table_body_spans.clear();
2004 }
2005 MdEvent::End(MdTagEnd::Table) => {
2006 self.render_buffered_table();
2007 self.in_table = false;
2008 self.table_alignments.clear();
2009 self.table_col_widths.clear();
2010 }
2011
2012 MdEvent::Start(MdTag::TableHead) => {
2013 self.in_table_header = true;
2014 self.table_cell_texts.clear();
2015 self.table_row_spans.clear();
2016 }
2017 MdEvent::End(MdTagEnd::TableHead) => {
2018 self.in_table_header = false;
2019 for (i, text) in self.table_cell_texts.iter().enumerate() {
2020 let w = text.width();
2021 if i < self.table_col_widths.len() {
2022 self.table_col_widths[i] = self.table_col_widths[i].max(w);
2023 } else {
2024 self.table_col_widths.push(w);
2025 }
2026 }
2027 self.table_header_spans = std::mem::take(&mut self.table_row_spans);
2028 self.table_cell_texts.clear();
2029 }
2030
2031 MdEvent::Start(MdTag::TableRow) => {
2032 self.table_cell_texts.clear();
2033 self.table_row_spans.clear();
2034 }
2035 MdEvent::End(MdTagEnd::TableRow) => {
2036 if !self.in_table_header {
2037 for (i, text) in self.table_cell_texts.iter().enumerate() {
2038 let w = text.width();
2039 if i < self.table_col_widths.len() {
2040 self.table_col_widths[i] = self.table_col_widths[i].max(w);
2041 } else {
2042 self.table_col_widths.push(w);
2043 }
2044 }
2045 self.table_body_spans
2046 .push(std::mem::take(&mut self.table_row_spans));
2047 }
2048 self.table_cell_texts.clear();
2049 }
2050
2051 MdEvent::Start(MdTag::TableCell) => {
2052 self.table_cell_spans.clear();
2053 }
2054 MdEvent::End(MdTagEnd::TableCell) => {
2055 let plain: String = self
2056 .table_cell_spans
2057 .iter()
2058 .map(|s| s.content.as_ref())
2059 .collect();
2060 self.table_cell_texts.push(plain);
2061 self.table_row_spans
2062 .push(std::mem::take(&mut self.table_cell_spans));
2063 }
2064
2065 MdEvent::Text(text) => {
2067 if self.in_code_block {
2068 let prefix = self.indent_prefix();
2069 let is_diff = self.code_block_lang == "diff";
2070 for line in text.lines() {
2071 let mut spans: Vec<Span<'static>> = Vec::new();
2072 spans.push(Span::styled(
2073 format!("{prefix}\u{2502} "),
2074 Style::default().fg(DIM_COLOR),
2075 ));
2076 if is_diff {
2077 spans.push(Span::styled(
2078 line.to_string(),
2079 Style::default().fg(diff_line_color(line)),
2080 ));
2081 } else if let Some(ref mut hl) = self.highlighter {
2082 if let Ok(highlighted) = hl.highlight_line(line, &SYNTAX_SET) {
2083 for (style, fragment) in highlighted {
2084 spans.push(Span::styled(
2085 fragment.to_string(),
2086 syntect_to_ratatui_style(style),
2087 ));
2088 }
2089 } else {
2090 spans.push(Span::styled(
2091 line.to_string(),
2092 Style::default().fg(TEXT_COLOR),
2093 ));
2094 }
2095 } else {
2096 spans.push(Span::styled(
2097 line.to_string(),
2098 Style::default().fg(TEXT_COLOR),
2099 ));
2100 }
2101 self.lines.push(Line::from(spans));
2102 }
2103 } else if self.in_table {
2104 self.table_cell_spans
2105 .push(Span::styled(text.to_string(), self.current_style()));
2106 } else {
2107 if self.link_url.is_some() {
2109 self.link_text.push_str(&text);
2110 }
2111 if self.spans.is_empty() && !self.in_heading {
2112 self.spans.extend(self.indent_spans());
2113 }
2114 self.spans
2115 .push(Span::styled(text.to_string(), self.current_style()));
2116 }
2117 }
2118
2119 MdEvent::Code(code) => {
2120 let backtick_style = Style::default().fg(DIM_COLOR);
2121 let code_style = Style::default().fg(CODE_COLOR);
2122 let target = if self.in_table {
2123 &mut self.table_cell_spans
2124 } else {
2125 if self.spans.is_empty() {
2126 self.spans.extend(self.indent_spans());
2127 }
2128 &mut self.spans
2129 };
2130 target.push(Span::styled("`".to_string(), backtick_style));
2131 target.push(Span::styled(code.to_string(), code_style));
2132 target.push(Span::styled("`".to_string(), backtick_style));
2133 }
2134
2135 MdEvent::SoftBreak => {
2136 if self.in_code_block {
2137 return;
2138 }
2139 if self.in_table {
2140 self.table_cell_spans.push(Span::raw(" "));
2141 } else {
2142 self.spans.push(Span::raw(" "));
2143 }
2144 }
2145
2146 MdEvent::HardBreak => {
2147 if self.in_table {
2148 self.table_cell_spans.push(Span::raw(" "));
2150 } else {
2151 self.flush_line();
2152 self.spans.extend(self.indent_spans());
2153 }
2154 }
2155
2156 MdEvent::Rule => {
2157 self.blank_line_if_needed();
2158 let prefix = self.indent_prefix();
2159 self.lines.push(Line::from(Span::styled(
2160 format!("{prefix}{}", "\u{2500}".repeat(40)),
2161 Style::default().fg(DIM_COLOR),
2162 )));
2163 }
2164
2165 MdEvent::TaskListMarker(checked) => {
2166 if checked {
2167 self.spans.push(Span::styled(
2168 "[\u{2713}] ".to_string(),
2169 Style::default().fg(CHECK_COLOR),
2170 ));
2171 } else {
2172 self.spans.push(Span::styled(
2173 "[ ] ".to_string(),
2174 Style::default().fg(DIM_COLOR),
2175 ));
2176 }
2177 }
2178
2179 _ => {}
2181 }
2182 }
2183
2184 fn render_buffered_table(&mut self) {
2187 let prefix = self.indent_prefix();
2188
2189 let header = std::mem::take(&mut self.table_header_spans);
2191 self.render_table_line(&prefix, &header, true);
2192
2193 let sep = self
2195 .table_col_widths
2196 .iter()
2197 .map(|&w| "\u{2500}".repeat(w))
2198 .collect::<Vec<_>>()
2199 .join("\u{2500}\u{253c}\u{2500}");
2200 self.lines.push(Line::from(Span::styled(
2201 format!("{prefix}{sep}"),
2202 Style::default().fg(DIM_COLOR),
2203 )));
2204
2205 let body = std::mem::take(&mut self.table_body_spans);
2207 for row in &body {
2208 self.render_table_line(&prefix, row, false);
2209 }
2210 }
2211
2212 fn render_table_line(&mut self, prefix: &str, cells: &[Vec<Span<'static>>], bold: bool) {
2214 let mut line_spans: Vec<Span<'static>> = vec![Span::raw(prefix.to_string())];
2215
2216 for (i, cell_spans) in cells.iter().enumerate() {
2217 if i > 0 {
2218 line_spans.push(Span::styled(
2219 " \u{2502} ".to_string(),
2220 Style::default().fg(DIM_COLOR),
2221 ));
2222 }
2223
2224 let cell_text_len: usize = cell_spans.iter().map(|s| s.content.width()).sum();
2225 let col_width = self
2226 .table_col_widths
2227 .get(i)
2228 .copied()
2229 .unwrap_or(cell_text_len);
2230 let pad = col_width.saturating_sub(cell_text_len);
2231 let align = self.table_alignments.get(i).copied();
2232
2233 let (left_pad, right_pad) = match align {
2234 Some(pulldown_cmark::Alignment::Center) => (pad / 2, pad - pad / 2),
2235 Some(pulldown_cmark::Alignment::Right) => (pad, 0),
2236 _ => (0, pad),
2237 };
2238
2239 if left_pad > 0 {
2240 line_spans.push(Span::raw(" ".repeat(left_pad)));
2241 }
2242 for span in cell_spans {
2243 if bold {
2244 line_spans.push(Span::styled(span.content.clone(), span.style.bold()));
2245 } else {
2246 line_spans.push(span.clone());
2247 }
2248 }
2249 if right_pad > 0 {
2250 line_spans.push(Span::raw(" ".repeat(right_pad)));
2251 }
2252 }
2253
2254 self.lines.push(Line::from(line_spans));
2255 }
2256}
2257
2258fn diff_line_color(line: &str) -> Color {
2260 if line.starts_with("@@") {
2261 DIFF_HUNK_COLOR
2262 } else if line.starts_with('+') {
2263 DIFF_ADD_COLOR
2264 } else if line.starts_with('-') {
2265 DIFF_DEL_COLOR
2266 } else {
2267 TEXT_COLOR
2268 }
2269}
2270
2271fn find_syntax(lang: &str) -> Option<&'static syntect::parsing::SyntaxReference> {
2273 if lang.is_empty() || lang == "diff" {
2275 return None;
2276 }
2277 SYNTAX_SET
2278 .find_syntax_by_token(lang)
2279 .filter(|s| s.name != "Plain Text")
2280}
2281
2282fn syntect_to_ratatui_style(style: syntect::highlighting::Style) -> Style {
2284 let fg = style.foreground;
2285 let mut s = Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b));
2286 if style
2287 .font_style
2288 .contains(syntect::highlighting::FontStyle::BOLD)
2289 {
2290 s = s.bold();
2291 }
2292 if style
2293 .font_style
2294 .contains(syntect::highlighting::FontStyle::ITALIC)
2295 {
2296 s = s.italic();
2297 }
2298 if style
2299 .font_style
2300 .contains(syntect::highlighting::FontStyle::UNDERLINE)
2301 {
2302 s = s.underlined();
2303 }
2304 s
2305}
2306
2307fn resolve_provider_labels(
2313 client: &AscendClient,
2314 otto_model: &Option<OttoModel>,
2315) -> (Option<String>, String) {
2316 let providers = client.list_otto_providers().ok().unwrap_or_default();
2317 match otto_model {
2318 Some(model) => {
2319 let model_id = model.id();
2320 let lower = model_id.to_lowercase();
2321 for p in &providers {
2323 if let Some(m) = p.models.iter().find(|m| {
2324 m.id == model_id
2325 || m.id.to_lowercase() == lower
2326 || m.name.to_lowercase() == lower
2327 }) {
2328 return (Some(p.name.clone()), m.name.clone());
2329 }
2330 }
2331 let short = model_id
2334 .rsplit_once('/')
2335 .map(|(_, s)| s)
2336 .or_else(|| model_id.rsplit_once('.').map(|(_, s)| s))
2337 .unwrap_or(model_id);
2338 (None, short.to_string())
2339 }
2340 None => providers
2341 .first()
2342 .map(|p| {
2343 let model_name = p
2344 .models
2345 .iter()
2346 .find(|m| m.id == p.default_model)
2347 .map(|m| m.name.clone())
2348 .unwrap_or_else(|| p.default_model.clone());
2349 (Some(p.name.clone()), model_name)
2350 })
2351 .unwrap_or_default(),
2352 }
2353}
2354
2355fn conversation_to_messages(conv: &Conversation) -> Vec<Message> {
2357 let Some(messages) = &conv.messages else {
2358 return Vec::new();
2359 };
2360 let mut out = Vec::new();
2361 for msg in messages {
2362 let role_str = msg.get("role").and_then(|v| v.as_str()).unwrap_or("");
2363 let role = match role_str {
2364 "user" => Role::User,
2365 "assistant" => Role::Otto,
2366 _ => continue,
2367 };
2368 let text = Conversation::extract_message_text(msg);
2369 if text.is_empty() {
2370 continue;
2371 }
2372 let timestamp = msg
2373 .get("created_at")
2374 .and_then(|v| v.as_f64())
2375 .map(|epoch| UNIX_EPOCH + Duration::from_secs_f64(epoch))
2376 .or_else(|| {
2377 msg.get("created_at")
2378 .and_then(|v| v.as_i64())
2379 .map(|epoch| UNIX_EPOCH + Duration::from_secs(epoch.try_into().unwrap_or(0)))
2380 })
2381 .unwrap_or(UNIX_EPOCH);
2382 out.push(Message {
2383 role,
2384 content: text,
2385 timestamp,
2386 tool_call: None,
2387 });
2388 }
2389 out
2390}
2391
2392pub fn run_tui(
2397 client: &AscendClient,
2398 runtime_uuid: Option<String>,
2399 otto_model: Option<OttoModel>,
2400 context_label: Option<String>,
2401 thread_id: Option<String>,
2402) -> Result<()> {
2403 terminal::enable_raw_mode()?;
2405 let mut stderr = std::io::stderr();
2406 crossterm::execute!(
2407 stderr,
2408 EnterAlternateScreen,
2409 EnableMouseCapture,
2410 EnableBracketedPaste
2411 )?;
2412 let backend = CrosstermBackend::new(stderr);
2413 let mut terminal = Terminal::new(backend)?;
2414
2415 let original_hook: Arc<dyn Fn(&std::panic::PanicHookInfo<'_>) + Sync + Send + 'static> =
2417 std::panic::take_hook().into();
2418 let panic_hook = original_hook.clone();
2419 std::panic::set_hook(Box::new(move |info| {
2420 let _ = terminal::disable_raw_mode();
2421 let _ = crossterm::execute!(
2422 std::io::stderr(),
2423 LeaveAlternateScreen,
2424 DisableMouseCapture,
2425 DisableBracketedPaste,
2426 SetCursorStyle::DefaultUserShape
2427 );
2428 (panic_hook)(info);
2429 }));
2430
2431 let (stream_tx, stream_rx) = mpsc::channel::<StreamMsg>();
2432 let cancelled_gen = AtomicU64::new(0);
2433 let gen_counter = AtomicU64::new(0);
2434 let active_thread_id: Mutex<Option<(u64, String)>> = Mutex::new(None);
2435
2436 let result = std::thread::scope(|scope| {
2437 let bg_tx = stream_tx.clone();
2439 let bg_model = otto_model.clone();
2440 scope.spawn(move || {
2441 let (provider_label, model_label) = resolve_provider_labels(client, &bg_model);
2442 let _ = bg_tx.send(StreamMsg::ProviderInfo {
2443 provider_label,
2444 model_label,
2445 });
2446 });
2447
2448 let mut app = App::new(
2449 runtime_uuid,
2450 otto_model,
2451 None,
2452 String::new(),
2453 context_label,
2454 thread_id.clone(),
2455 );
2456
2457 if let Some(tid) = thread_id {
2459 let history_tx = stream_tx.clone();
2460 let history_gen = app.stream_generation;
2461 scope.spawn(move || {
2462 if let Ok(conv) = client.get_conversation(&tid) {
2463 let messages = conversation_to_messages(&conv);
2464 let _ = history_tx.send(StreamMsg::ConversationHistory {
2465 generation: history_gen,
2466 messages,
2467 });
2468 }
2469 });
2470 }
2471
2472 loop {
2473 app.tick_spinner();
2474 app.tick_stream();
2475 terminal.draw(|frame| app.render(frame))?;
2476
2477 if event::poll(POLL_DURATION)? {
2478 match event::read()? {
2479 Event::Key(key) if key.kind == KeyEventKind::Press => {
2480 app.handle_key(key, &cancelled_gen);
2481 }
2482 Event::Paste(text) => {
2483 app.handle_paste(&text);
2484 }
2485 Event::Mouse(mouse) => match mouse.kind {
2486 MouseEventKind::ScrollUp => app.scroll_up(3),
2487 MouseEventKind::ScrollDown => app.scroll_down(3),
2488 _ => {}
2489 },
2490 Event::Resize(_, _) => {
2491 if app.auto_scroll {
2493 app.scroll = 0;
2494 }
2495 }
2496 _ => {}
2497 }
2498 }
2499
2500 while let Ok(msg) = stream_rx.try_recv() {
2502 app.handle_stream_msg(msg);
2503 }
2504
2505 if let Some(cancelled_generation) = app.stop_pending.take() {
2508 let tid = active_thread_id
2509 .lock()
2510 .unwrap_or_else(|e| e.into_inner())
2511 .as_ref()
2512 .filter(|(g, _)| *g == cancelled_generation)
2513 .map(|(_, tid)| tid.clone());
2514 if let Some(tid) = tid {
2515 let stop_tx = stream_tx.clone();
2516 scope.spawn(move || {
2517 let error = client
2518 .stop_thread_and_wait(&tid)
2519 .err()
2520 .map(|e| e.to_string());
2521 let _ = stop_tx.send(StreamMsg::StopFinished { error });
2522 });
2523 } else {
2524 app.finish_stream();
2525 app.push_system("Cancelled");
2526 }
2527 }
2528
2529 if !app.interrupting
2531 && let Some(request) = app.take_pending_request()
2532 {
2533 let generation = gen_counter.fetch_add(1, Ordering::AcqRel) + 1;
2534 app.stream_generation = generation;
2535 *active_thread_id.lock().unwrap_or_else(|e| e.into_inner()) = None;
2537 let tx = stream_tx.clone();
2538 let cg_ref = &cancelled_gen;
2539 let active_tid = &active_thread_id;
2540 scope.spawn(move || {
2541 let tx2 = tx.clone();
2542 let mut tool_names: HashMap<String, String> = HashMap::new();
2543 let send = |kind: StreamMsgKind| {
2544 let _ = tx.send(StreamMsg::Stream { generation, kind });
2545 };
2546 let result = client.otto_streaming(
2547 &request,
2548 |event| {
2549 if cg_ref.load(Ordering::Acquire) >= generation {
2550 return ControlFlow::Break(());
2551 }
2552 match event {
2553 StreamEvent::TextDelta(delta) => {
2554 send(StreamMsgKind::Delta(delta));
2555 }
2556 StreamEvent::ToolCallStart {
2557 call_id,
2558 name,
2559 arguments,
2560 } => {
2561 tool_names.insert(call_id, name.clone());
2562 send(StreamMsgKind::ToolCallStart { name, arguments });
2563 }
2564 StreamEvent::ToolCallOutput { call_id, output } => {
2565 let name =
2566 tool_names.get(&call_id).cloned().unwrap_or_default();
2567 send(StreamMsgKind::ToolCallOutput { name, output });
2568 }
2569 }
2570 ControlFlow::Continue(())
2571 },
2572 |tid: &str| {
2573 *active_tid.lock().unwrap_or_else(|e| e.into_inner()) =
2574 Some((generation, tid.to_string()));
2575 let _ = tx2.send(StreamMsg::Stream {
2576 generation,
2577 kind: StreamMsgKind::ThreadId(tid.to_string()),
2578 });
2579 },
2580 );
2581 if cg_ref.load(Ordering::Acquire) >= generation
2584 && let Some((g, tid)) = active_tid
2585 .lock()
2586 .unwrap_or_else(|e| e.into_inner())
2587 .as_ref()
2588 && *g == generation
2589 {
2590 let _ = client.stop_thread(tid);
2591 }
2592 match result {
2593 Ok(response) => send(StreamMsgKind::Finished {
2594 status: response.stream_status,
2595 error: response.stream_error,
2596 }),
2597 Err(e) => send(StreamMsgKind::Error(format!("{e}"))),
2598 }
2599 });
2600 }
2601
2602 if app.should_quit {
2603 terminal::disable_raw_mode()?;
2606 crossterm::execute!(
2607 std::io::stderr(),
2608 LeaveAlternateScreen,
2609 DisableMouseCapture,
2610 DisableBracketedPaste,
2611 SetCursorStyle::DefaultUserShape
2612 )?;
2613 terminal.show_cursor()?;
2614 let restore_hook = original_hook.clone();
2615 std::panic::set_hook(Box::new(move |info| (restore_hook)(info)));
2616
2617 if app.force_quit || cancelled_gen.load(Ordering::Acquire) > 0 {
2621 std::process::exit(0);
2622 }
2623 break;
2624 }
2625 }
2626
2627 Ok::<(), anyhow::Error>(())
2628 });
2629
2630 let _ = terminal::disable_raw_mode();
2632 let _ = crossterm::execute!(
2633 std::io::stderr(),
2634 LeaveAlternateScreen,
2635 DisableMouseCapture,
2636 DisableBracketedPaste,
2637 SetCursorStyle::DefaultUserShape
2638 );
2639 let _ = terminal.show_cursor();
2640 let restore_hook = original_hook.clone();
2641 std::panic::set_hook(Box::new(move |info| (restore_hook)(info)));
2642
2643 result
2644}
2645
2646#[cfg(test)]
2651mod tests {
2652 use super::*;
2653 use std::sync::atomic::AtomicU64;
2654
2655 fn test_app() -> App {
2656 App::new(None, None, None, String::new(), None, None)
2657 }
2658
2659 #[test]
2662 fn submit_starts_streaming_and_creates_pending_request() {
2663 let mut app = test_app();
2664 app.input = "hello".chars().collect();
2665 app.submit();
2666
2667 assert!(app.streaming);
2668 assert!(app.pending_request.is_some());
2669 assert_eq!(app.pending_request.as_ref().unwrap().prompt, "hello");
2670 assert!(app.stream_buffer.is_empty());
2671 assert!(app.stream_pending.is_empty());
2672 assert!(app.auto_scroll);
2673 assert_eq!(app.messages.len(), 1);
2675 assert_eq!(app.messages[0].role, Role::User);
2676 }
2677
2678 #[test]
2679 fn submit_blocked_while_streaming() {
2680 let mut app = test_app();
2681 app.streaming = true;
2682 app.input = "blocked".chars().collect();
2683 app.submit();
2684
2685 assert!(app.pending_request.is_none());
2687 assert!(app.messages.iter().any(|m| m.role == Role::System));
2688 }
2689
2690 #[test]
2691 fn submit_on_empty_input_is_noop() {
2692 let mut app = test_app();
2693 app.input.clear();
2694 app.submit();
2695
2696 assert!(!app.streaming);
2697 assert!(app.pending_request.is_none());
2698 assert!(app.messages.is_empty());
2699 }
2700
2701 #[test]
2704 fn stream_delta_accumulates_in_pending() {
2705 let mut app = test_app();
2706 app.streaming = true;
2707 app.stream_generation = 1;
2708
2709 app.handle_stream_msg(StreamMsg::Stream {
2710 generation: 1,
2711 kind: StreamMsgKind::Delta("hello ".into()),
2712 });
2713 app.handle_stream_msg(StreamMsg::Stream {
2714 generation: 1,
2715 kind: StreamMsgKind::Delta("world".into()),
2716 });
2717
2718 let pending: String = app.stream_pending.iter().collect();
2719 assert_eq!(pending, "hello world");
2720 }
2721
2722 #[test]
2723 fn stale_generation_messages_are_discarded() {
2724 let mut app = test_app();
2725 app.streaming = true;
2726 app.stream_generation = 2;
2727
2728 app.handle_stream_msg(StreamMsg::Stream {
2730 generation: 1,
2731 kind: StreamMsgKind::Delta("stale".into()),
2732 });
2733
2734 assert!(app.stream_pending.is_empty());
2735 }
2736
2737 #[test]
2738 fn thread_id_is_stored_on_stream_msg() {
2739 let mut app = test_app();
2740 app.streaming = true;
2741 app.stream_generation = 1;
2742
2743 app.handle_stream_msg(StreamMsg::Stream {
2744 generation: 1,
2745 kind: StreamMsgKind::ThreadId("t-123".into()),
2746 });
2747
2748 assert_eq!(app.thread_id.as_deref(), Some("t-123"));
2749 }
2750
2751 #[test]
2754 fn completed_stream_flushes_buffer_and_stops_streaming() {
2755 let mut app = test_app();
2756 app.streaming = true;
2757 app.stream_generation = 1;
2758 app.stream_start = Some(Instant::now());
2759 app.stream_buffer = "response text".into();
2760
2761 app.handle_stream_msg(StreamMsg::Stream {
2762 generation: 1,
2763 kind: StreamMsgKind::Finished {
2764 status: OttoStreamStatus::Completed,
2765 error: None,
2766 },
2767 });
2768
2769 assert!(!app.streaming);
2770 assert!(!app.interrupting);
2771 assert!(
2773 app.messages
2774 .iter()
2775 .any(|m| m.role == Role::Otto && m.content == "response text")
2776 );
2777 }
2778
2779 #[test]
2782 fn stream_error_finishes_stream_and_shows_error_message() {
2783 let mut app = test_app();
2784 app.streaming = true;
2785 app.stream_generation = 1;
2786
2787 app.handle_stream_msg(StreamMsg::Stream {
2788 generation: 1,
2789 kind: StreamMsgKind::Error("connection reset".into()),
2790 });
2791
2792 assert!(!app.streaming);
2793 assert!(
2794 app.messages
2795 .iter()
2796 .any(|m| m.role == Role::System && m.content.contains("connection reset"))
2797 );
2798 }
2799
2800 #[test]
2801 fn interrupted_stream_shows_connection_lost() {
2802 let mut app = test_app();
2803 app.streaming = true;
2804 app.stream_generation = 1;
2805
2806 app.handle_stream_msg(StreamMsg::Stream {
2807 generation: 1,
2808 kind: StreamMsgKind::Finished {
2809 status: OttoStreamStatus::Interrupted,
2810 error: Some("SSE stream closed".into()),
2811 },
2812 });
2813
2814 assert!(!app.streaming);
2815 assert!(
2816 app.messages
2817 .iter()
2818 .any(|m| m.role == Role::System && m.content.contains("Connection lost"))
2819 );
2820 }
2821
2822 #[test]
2823 fn otto_stream_ended_unexpectedly_error_shows_connection_lost() {
2824 let mut app = test_app();
2825 app.streaming = true;
2826 app.stream_generation = 1;
2827
2828 app.handle_stream_msg(StreamMsg::Stream {
2829 generation: 1,
2830 kind: StreamMsgKind::Error(
2831 "Otto stream ended unexpectedly: stream did not complete".into(),
2832 ),
2833 });
2834
2835 assert!(!app.streaming);
2836 let sys_msg = app
2837 .messages
2838 .iter()
2839 .find(|m| m.role == Role::System)
2840 .expect("should have system message");
2841 assert!(
2842 sys_msg.content.contains("Connection lost"),
2843 "expected 'Connection lost', got: {}",
2844 sys_msg.content
2845 );
2846 }
2847
2848 #[test]
2864 fn cancel_sets_interrupting_and_stop_pending() {
2865 let mut app = test_app();
2866 app.streaming = true;
2867 app.stream_generation = 1;
2868 app.stream_buffer = "partial output".into();
2869
2870 let cancelled_gen = AtomicU64::new(0);
2871 app.cancel_stream(&cancelled_gen);
2872
2873 assert!(app.interrupting);
2874 assert_eq!(app.stop_pending, Some(1)); assert_eq!(cancelled_gen.load(Ordering::Acquire), 1);
2876 assert_eq!(app.stream_generation, 2);
2878 assert!(
2880 app.messages
2881 .iter()
2882 .any(|m| m.role == Role::Otto && m.content == "partial output")
2883 );
2884 assert!(app.active_tool_call.is_none());
2886 }
2887
2888 #[test]
2889 fn cancel_with_no_text_yet() {
2890 let mut app = test_app();
2891 app.streaming = true;
2892 app.stream_generation = 1;
2893 let cancelled_gen = AtomicU64::new(0);
2896 app.cancel_stream(&cancelled_gen);
2897
2898 assert!(app.interrupting);
2899 assert_eq!(app.stop_pending, Some(1)); assert!(!app.messages.iter().any(|m| m.role == Role::Otto));
2902 }
2903
2904 #[test]
2905 fn cancel_with_pending_chars_flushes_both_buffer_and_pending() {
2906 let mut app = test_app();
2907 app.streaming = true;
2908 app.stream_generation = 1;
2909 app.stream_buffer = "buffered ".into();
2910 app.stream_pending = "pending".chars().collect();
2911
2912 let cancelled_gen = AtomicU64::new(0);
2913 app.cancel_stream(&cancelled_gen);
2914
2915 let otto_msg = app
2917 .messages
2918 .iter()
2919 .find(|m| m.role == Role::Otto)
2920 .expect("should have Otto message");
2921 assert_eq!(otto_msg.content, "buffered pending");
2922 }
2923
2924 #[test]
2925 fn cancel_clears_active_tool_call() {
2926 let mut app = test_app();
2927 app.streaming = true;
2928 app.stream_generation = 1;
2929 app.active_tool_call = Some(("read_file".into(), "{}".into()));
2930
2931 let cancelled_gen = AtomicU64::new(0);
2932 app.cancel_stream(&cancelled_gen);
2933
2934 assert!(app.active_tool_call.is_none());
2935 }
2936
2937 #[test]
2940 fn cancel_is_idempotent_while_interrupting() {
2941 let mut app = test_app();
2942 app.streaming = true;
2943 app.interrupting = true;
2944 app.stream_generation = 5;
2945 app.stop_pending = None; let cancelled_gen = AtomicU64::new(1);
2948 app.cancel_stream(&cancelled_gen);
2949
2950 assert_eq!(app.stream_generation, 5);
2952 assert_eq!(app.stop_pending, None); }
2954
2955 #[test]
2958 fn stop_finished_success_ends_interrupt_and_shows_cancelled() {
2959 let mut app = test_app();
2960 app.streaming = true;
2961 app.interrupting = true;
2962
2963 app.handle_stream_msg(StreamMsg::StopFinished { error: None });
2964
2965 assert!(!app.streaming);
2966 assert!(!app.interrupting);
2967 assert!(app.stream_start.is_none());
2968 assert!(
2969 app.messages
2970 .iter()
2971 .any(|m| m.role == Role::System && m.content == "Cancelled")
2972 );
2973 }
2974
2975 #[test]
2978 fn stop_finished_timeout_shows_interrupt_failed_and_recovers() {
2979 let mut app = test_app();
2980 app.streaming = true;
2981 app.interrupting = true;
2982 app.thread_id = Some("t-123".into());
2983
2984 app.handle_stream_msg(StreamMsg::StopFinished {
2985 error: Some(
2986 "API error (HTTP 408): thread 019d0b9d... did not stop within 30 seconds".into(),
2987 ),
2988 });
2989
2990 assert!(!app.streaming);
2992 assert!(!app.interrupting);
2993 assert!(app.stream_start.is_none());
2994 assert!(app.active_tool_call.is_none());
2995 assert!(
2997 app.messages
2998 .iter()
2999 .any(|m| m.role == Role::System && m.content.contains("Interrupt failed"))
3000 );
3001 assert_eq!(app.thread_id.as_deref(), Some("t-123"));
3003 }
3004
3005 #[test]
3006 fn after_stop_timeout_user_can_submit_new_message() {
3007 let mut app = test_app();
3008 app.streaming = true;
3009 app.interrupting = true;
3010 app.thread_id = Some("t-123".into());
3011
3012 app.handle_stream_msg(StreamMsg::StopFinished {
3014 error: Some("thread did not stop within 30 seconds".into()),
3015 });
3016
3017 app.input = "follow up question".chars().collect();
3019 app.submit();
3020
3021 assert!(app.streaming);
3022 let req = app
3023 .pending_request
3024 .as_ref()
3025 .expect("should have pending request");
3026 assert_eq!(req.prompt, "follow up question");
3027 assert_eq!(req.thread_id.as_deref(), Some("t-123"));
3029 }
3030
3031 #[test]
3034 fn stop_finished_network_error_recovers() {
3035 let mut app = test_app();
3036 app.streaming = true;
3037 app.interrupting = true;
3038 app.thread_id = Some("t-456".into());
3039
3040 app.handle_stream_msg(StreamMsg::StopFinished {
3041 error: Some("connection refused".into()),
3042 });
3043
3044 assert!(!app.streaming);
3045 assert!(!app.interrupting);
3046 assert!(
3047 app.messages
3048 .iter()
3049 .any(|m| m.role == Role::System && m.content.contains("Interrupt failed"))
3050 );
3051 assert_eq!(app.thread_id.as_deref(), Some("t-456"));
3053
3054 app.input = "retry".chars().collect();
3056 app.submit();
3057 assert!(app.streaming);
3058 }
3059
3060 #[test]
3063 fn cancel_before_thread_id_finishes_immediately() {
3064 let mut app = test_app();
3066 app.streaming = true;
3067 app.stream_generation = 1;
3068 app.thread_id = None;
3069
3070 let cancelled_gen = AtomicU64::new(0);
3071 app.cancel_stream(&cancelled_gen);
3072
3073 assert!(app.interrupting);
3074 assert_eq!(app.stop_pending, Some(1)); app.stop_pending = None;
3080 app.finish_stream();
3081 app.push_system("Cancelled");
3082
3083 assert!(!app.streaming);
3084 assert!(!app.interrupting);
3085 assert!(
3086 app.messages
3087 .iter()
3088 .any(|m| m.role == Role::System && m.content == "Cancelled")
3089 );
3090 }
3091
3092 #[test]
3095 fn cancelled_stream_status_defers_to_stop_finished() {
3096 let mut app = test_app();
3097 app.streaming = true;
3098 app.interrupting = true;
3099 app.stream_generation = 1;
3100
3101 app.handle_stream_msg(StreamMsg::Stream {
3104 generation: 1,
3105 kind: StreamMsgKind::Finished {
3106 status: OttoStreamStatus::Cancelled,
3107 error: None,
3108 },
3109 });
3110
3111 assert!(app.streaming);
3113 assert!(app.interrupting);
3114 }
3115
3116 #[test]
3117 fn cancelled_then_stop_finished_success() {
3118 let mut app = test_app();
3119 app.streaming = true;
3120 app.interrupting = true;
3121 app.stream_generation = 1;
3122
3123 app.handle_stream_msg(StreamMsg::Stream {
3125 generation: 1,
3126 kind: StreamMsgKind::Finished {
3127 status: OttoStreamStatus::Cancelled,
3128 error: None,
3129 },
3130 });
3131 assert!(app.streaming); app.handle_stream_msg(StreamMsg::StopFinished { error: None });
3135
3136 assert!(!app.streaming);
3137 assert!(!app.interrupting);
3138 assert!(
3139 app.messages
3140 .iter()
3141 .any(|m| m.role == Role::System && m.content == "Cancelled")
3142 );
3143 }
3144
3145 #[test]
3146 fn cancelled_then_stop_finished_error() {
3147 let mut app = test_app();
3148 app.streaming = true;
3149 app.interrupting = true;
3150 app.stream_generation = 1;
3151 app.thread_id = Some("t-789".into());
3152
3153 app.handle_stream_msg(StreamMsg::Stream {
3155 generation: 1,
3156 kind: StreamMsgKind::Finished {
3157 status: OttoStreamStatus::Cancelled,
3158 error: None,
3159 },
3160 });
3161
3162 app.handle_stream_msg(StreamMsg::StopFinished {
3164 error: Some("timeout".into()),
3165 });
3166
3167 assert!(!app.streaming);
3168 assert!(!app.interrupting);
3169 assert!(
3170 app.messages
3171 .iter()
3172 .any(|m| m.role == Role::System && m.content.contains("Interrupt failed"))
3173 );
3174 assert_eq!(app.thread_id.as_deref(), Some("t-789"));
3176 }
3177
3178 #[test]
3181 fn stale_deltas_after_cancel_are_discarded() {
3182 let mut app = test_app();
3183 app.streaming = true;
3184 app.stream_generation = 1;
3185
3186 let cancelled_gen = AtomicU64::new(0);
3188 app.cancel_stream(&cancelled_gen);
3189 assert_eq!(app.stream_generation, 2);
3190
3191 app.handle_stream_msg(StreamMsg::Stream {
3193 generation: 1,
3194 kind: StreamMsgKind::Delta("stale text".into()),
3195 });
3196 app.handle_stream_msg(StreamMsg::Stream {
3197 generation: 1,
3198 kind: StreamMsgKind::ToolCallStart {
3199 name: "stale_tool".into(),
3200 arguments: "{}".into(),
3201 },
3202 });
3203 app.handle_stream_msg(StreamMsg::Stream {
3204 generation: 1,
3205 kind: StreamMsgKind::Finished {
3206 status: OttoStreamStatus::Completed,
3207 error: None,
3208 },
3209 });
3210
3211 assert!(app.stream_pending.is_empty());
3213 assert!(app.active_tool_call.is_none());
3214 assert!(app.interrupting);
3216 assert!(app.streaming);
3217 }
3218
3219 #[test]
3222 fn full_cancel_recover_new_message_cycle() {
3223 let mut app = test_app();
3224
3225 app.input = "first question".chars().collect();
3227 app.submit();
3228 assert!(app.streaming);
3229 let req1 = app.take_pending_request().unwrap();
3230 assert_eq!(req1.prompt, "first question");
3231
3232 app.stream_generation = 1;
3234 app.handle_stream_msg(StreamMsg::Stream {
3235 generation: 1,
3236 kind: StreamMsgKind::ThreadId("t-cycle".into()),
3237 });
3238 app.handle_stream_msg(StreamMsg::Stream {
3240 generation: 1,
3241 kind: StreamMsgKind::Delta("partial answer".into()),
3242 });
3243
3244 let cancelled_gen = AtomicU64::new(0);
3246 app.cancel_stream(&cancelled_gen);
3247 assert!(app.interrupting);
3248 assert_eq!(app.stop_pending, Some(1)); app.stop_pending = None;
3252 app.handle_stream_msg(StreamMsg::StopFinished { error: None });
3253 assert!(!app.streaming);
3254 assert!(!app.interrupting);
3255
3256 assert!(
3258 app.messages
3259 .iter()
3260 .any(|m| m.role == Role::Otto && m.content.contains("partial answer"))
3261 );
3262
3263 app.input = "second question".chars().collect();
3265 app.submit();
3266 assert!(app.streaming);
3267 let req2 = app.pending_request.as_ref().unwrap();
3268 assert_eq!(req2.prompt, "second question");
3269 assert_eq!(req2.thread_id.as_deref(), Some("t-cycle"));
3270 }
3271
3272 #[test]
3273 fn full_cancel_timeout_recover_new_message_cycle() {
3274 let mut app = test_app();
3275
3276 app.input = "question".chars().collect();
3278 app.submit();
3279 app.stream_generation = 1;
3280 app.handle_stream_msg(StreamMsg::Stream {
3281 generation: 1,
3282 kind: StreamMsgKind::ThreadId("t-timeout".into()),
3283 });
3284 app.handle_stream_msg(StreamMsg::Stream {
3285 generation: 1,
3286 kind: StreamMsgKind::Delta("response".into()),
3287 });
3288
3289 let cancelled_gen = AtomicU64::new(0);
3291 app.cancel_stream(&cancelled_gen);
3292
3293 app.stop_pending = None;
3295 app.handle_stream_msg(StreamMsg::StopFinished {
3296 error: Some(
3297 "API error (HTTP 408): thread t-timeout did not stop within 30 seconds".into(),
3298 ),
3299 });
3300
3301 assert!(!app.streaming);
3302 assert!(!app.interrupting);
3303
3304 app.input = "follow up".chars().collect();
3306 app.submit();
3307 assert!(app.streaming);
3308 assert!(!app.interrupting);
3309 let req = app.pending_request.as_ref().unwrap();
3310 assert_eq!(req.thread_id.as_deref(), Some("t-timeout"));
3311 }
3312
3313 #[test]
3316 fn rapid_ctrl_c_during_interrupting_force_quits() {
3317 let mut app = test_app();
3318 app.streaming = true;
3319 app.interrupting = true;
3320 app.stream_generation = 5;
3321
3322 let cancelled_gen = AtomicU64::new(1);
3323
3324 app.handle_key(
3326 KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
3327 &cancelled_gen,
3328 );
3329
3330 assert!(app.should_quit);
3331 assert!(app.force_quit);
3332 assert!(app.interrupting);
3334 assert!(app.streaming);
3335 assert_eq!(app.stream_generation, 5);
3336 }
3337
3338 #[test]
3339 fn rapid_esc_during_interrupting_is_safe() {
3340 let mut app = test_app();
3341 app.streaming = true;
3342 app.interrupting = true;
3343 app.stream_generation = 5;
3344
3345 let cancelled_gen = AtomicU64::new(1);
3346
3347 for _ in 0..5 {
3348 app.handle_key(
3349 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
3350 &cancelled_gen,
3351 );
3352 }
3353
3354 assert!(app.interrupting);
3355 assert!(app.streaming);
3356 assert_eq!(app.stream_generation, 5);
3357 }
3358
3359 #[test]
3362 fn stream_error_during_interrupting_is_discarded() {
3363 let mut app = test_app();
3364 app.streaming = true;
3365 app.stream_generation = 1;
3366
3367 let cancelled_gen = AtomicU64::new(0);
3369 app.cancel_stream(&cancelled_gen);
3370 assert_eq!(app.stream_generation, 2);
3371
3372 app.handle_stream_msg(StreamMsg::Stream {
3374 generation: 1,
3375 kind: StreamMsgKind::Error("old error".into()),
3376 });
3377
3378 assert!(app.streaming);
3380 assert!(app.interrupting);
3381 assert!(
3382 !app.messages
3383 .iter()
3384 .any(|m| { m.role == Role::System && m.content.contains("old error") })
3385 );
3386 }
3387
3388 #[test]
3391 fn stop_finished_when_not_interrupting_is_noop() {
3392 let mut app = test_app();
3393 app.streaming = true;
3394 app.interrupting = false; app.handle_stream_msg(StreamMsg::StopFinished { error: None });
3397
3398 assert!(app.streaming);
3400 assert!(!app.interrupting);
3401 }
3402
3403 #[test]
3404 fn stop_finished_when_idle_is_harmless() {
3405 let mut app = test_app();
3406 assert!(!app.streaming);
3408 assert!(!app.interrupting);
3409
3410 app.handle_stream_msg(StreamMsg::StopFinished {
3411 error: Some("some error".into()),
3412 });
3413
3414 assert!(!app.streaming);
3416 assert!(!app.interrupting);
3417 }
3418
3419 #[test]
3422 fn ctrl_c_while_streaming_cancels_not_quits() {
3423 let mut app = test_app();
3424 app.streaming = true;
3425 app.stream_generation = 1;
3426
3427 let cancelled_gen = AtomicU64::new(0);
3428 app.handle_key(
3429 KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
3430 &cancelled_gen,
3431 );
3432
3433 assert!(app.interrupting);
3434 assert!(!app.should_quit);
3435 assert_eq!(cancelled_gen.load(Ordering::Acquire), 1);
3436 }
3437
3438 #[test]
3439 fn ctrl_c_while_not_streaming_quits() {
3440 let mut app = test_app();
3441
3442 let cancelled_gen = AtomicU64::new(0);
3443 app.handle_key(
3444 KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
3445 &cancelled_gen,
3446 );
3447
3448 assert!(app.should_quit);
3449 }
3450
3451 #[test]
3452 fn esc_while_streaming_cancels() {
3453 let mut app = test_app();
3454 app.streaming = true;
3455 app.stream_generation = 1;
3456
3457 let cancelled_gen = AtomicU64::new(0);
3458 app.handle_key(
3459 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
3460 &cancelled_gen,
3461 );
3462
3463 assert!(app.interrupting);
3464 assert_eq!(cancelled_gen.load(Ordering::Acquire), 1);
3465 }
3466
3467 #[test]
3468 fn esc_while_not_streaming_enters_vi_normal() {
3469 let mut app = test_app();
3470 app.input_mode = InputMode::ViInsert;
3471
3472 let cancelled_gen = AtomicU64::new(0);
3473 app.handle_key(
3474 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
3475 &cancelled_gen,
3476 );
3477
3478 assert_eq!(app.input_mode, InputMode::ViNormal);
3479 assert!(!app.interrupting);
3480 }
3481
3482 #[test]
3485 fn submit_blocked_during_interrupting() {
3486 let mut app = test_app();
3487 app.streaming = true;
3488 app.interrupting = true;
3489 app.input = "please work".chars().collect();
3490 app.submit();
3491
3492 assert!(app.pending_request.is_none());
3494 assert!(
3495 app.messages
3496 .iter()
3497 .any(|m| m.role == Role::System && m.content.contains("Waiting"))
3498 );
3499 }
3500
3501 #[test]
3504 fn pending_request_guard_during_interrupting() {
3505 let mut app = test_app();
3509 app.streaming = true;
3510 app.interrupting = true;
3511
3512 app.pending_request = Some(OttoChatRequest {
3514 prompt: "should not launch".into(),
3515 runtime_uuid: None,
3516 thread_id: None,
3517 model: None,
3518 });
3519
3520 assert!(app.interrupting);
3524 assert!(app.pending_request.is_some());
3525 }
3527
3528 #[test]
3531 fn cancel_during_tool_call_clears_tool_and_preserves_text() {
3532 let mut app = test_app();
3533 app.streaming = true;
3534 app.stream_generation = 1;
3535 app.stream_buffer = "Let me check that for you.".into();
3536 app.active_tool_call = Some(("list_workspaces".into(), "{}".into()));
3537
3538 let cancelled_gen = AtomicU64::new(0);
3539 app.cancel_stream(&cancelled_gen);
3540
3541 assert!(app.active_tool_call.is_none());
3542 assert!(app.interrupting);
3543 assert!(
3545 app.messages
3546 .iter()
3547 .any(|m| m.role == Role::Otto && m.content.contains("Let me check"))
3548 );
3549 }
3550
3551 #[test]
3554 fn tool_call_start_sets_active_tool() {
3555 let mut app = test_app();
3556 app.streaming = true;
3557 app.stream_generation = 1;
3558
3559 app.handle_stream_msg(StreamMsg::Stream {
3560 generation: 1,
3561 kind: StreamMsgKind::ToolCallStart {
3562 name: "list_flows".into(),
3563 arguments: "{}".into(),
3564 },
3565 });
3566
3567 assert_eq!(
3568 app.active_tool_call.as_ref().map(|(n, _)| n.as_str()),
3569 Some("list_flows")
3570 );
3571 }
3572
3573 #[test]
3574 fn tool_call_output_clears_active_tool_and_adds_system_msg() {
3575 let mut app = test_app();
3576 app.streaming = true;
3577 app.stream_generation = 1;
3578 app.active_tool_call = Some(("list_flows".into(), "{}".into()));
3579
3580 app.handle_stream_msg(StreamMsg::Stream {
3581 generation: 1,
3582 kind: StreamMsgKind::ToolCallOutput {
3583 name: "list_flows".into(),
3584 output: "sales, marketing".into(),
3585 },
3586 });
3587
3588 assert!(app.active_tool_call.is_none());
3589 assert!(
3590 app.messages
3591 .iter()
3592 .any(|m| m.role == Role::System && m.content.contains("list_flows"))
3593 );
3594 }
3595
3596 #[test]
3599 fn provider_info_updates_labels() {
3600 let mut app = test_app();
3601
3602 app.handle_stream_msg(StreamMsg::ProviderInfo {
3603 provider_label: Some("AWS Bedrock".into()),
3604 model_label: "Claude Sonnet".into(),
3605 });
3606
3607 assert_eq!(app.provider_label.as_deref(), Some("AWS Bedrock"));
3608 assert_eq!(app.model_label, "Claude Sonnet");
3609 }
3610
3611 #[test]
3614 fn input_line_count_wraps_correctly() {
3615 let mut app = test_app();
3616 app.input = "abcdefghij".chars().collect();
3619 app.cursor = app.input.len();
3620 assert_eq!(app.input_line_count(8), 3);
3621
3622 app.input = "abcdefg".chars().collect();
3624 app.cursor = app.input.len();
3625 assert_eq!(app.input_line_count(8), 2);
3626 }
3627
3628 #[test]
3629 fn input_line_count_newlines() {
3630 let mut app = test_app();
3631 app.input = "line1\nline2\nline3".chars().collect();
3632 app.cursor = app.input.len();
3633 assert_eq!(app.input_line_count(80), 3);
3634 }
3635
3636 #[test]
3637 fn input_line_count_capped_at_max() {
3638 let mut app = test_app();
3639 app.input = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk".chars().collect();
3641 app.cursor = app.input.len();
3642 assert_eq!(app.input_line_count(80), MAX_INPUT_LINES);
3643 }
3644
3645 #[test]
3648 fn clear_command_resets_thread() {
3649 let mut app = test_app();
3650 app.thread_id = Some("t-old".into());
3651 app.messages.push(Message {
3652 role: Role::Otto,
3653 content: "old message".into(),
3654 timestamp: SystemTime::now(),
3655 tool_call: None,
3656 });
3657
3658 app.handle_command("/clear");
3659
3660 assert!(app.thread_id.is_none());
3661 assert_eq!(app.messages.len(), 1);
3663 assert!(app.messages[0].content.contains("cleared"));
3664 }
3665
3666 #[test]
3667 fn unknown_command_shows_error() {
3668 let mut app = test_app();
3669 app.handle_command("/foobar");
3670
3671 assert!(
3672 app.messages
3673 .iter()
3674 .any(|m| m.role == Role::System && m.content.contains("Unknown command"))
3675 );
3676 }
3677
3678 #[test]
3679 fn quit_command_sets_should_quit() {
3680 let mut app = test_app();
3681 app.handle_command("/quit");
3682 assert!(app.should_quit);
3683
3684 let mut app2 = test_app();
3685 app2.handle_command("/exit");
3686 assert!(app2.should_quit);
3687
3688 let mut app3 = test_app();
3689 app3.handle_command("/q");
3690 assert!(app3.should_quit);
3691 }
3692
3693 #[test]
3696 fn history_records_submitted_input() {
3697 let mut app = test_app();
3698 app.input = "first query".chars().collect();
3699 app.submit();
3700 app.finish_stream(); app.input = "second query".chars().collect();
3703 app.submit();
3704 app.finish_stream();
3705
3706 if let Some(prev) = app.history.prev(&app.input) {
3708 let s: String = prev.iter().collect();
3709 assert_eq!(s, "second query");
3710 } else {
3711 panic!("expected history entry");
3712 }
3713 }
3714
3715 #[test]
3718 fn tick_stream_flushes_when_bulk_threshold_exceeded() {
3719 let mut app = test_app();
3720 app.streaming = true;
3721 let text: String = (0..250).map(|_| 'x').collect();
3723 app.stream_pending = text.chars().collect();
3724 app.last_stream_tick = Instant::now() - Duration::from_millis(100);
3726
3727 app.tick_stream();
3728
3729 assert!(!app.stream_buffer.is_empty());
3731 assert_eq!(app.stream_buffer.len() + app.stream_pending.len(), 250);
3733 }
3734
3735 #[test]
3738 fn tab_completion_cycles_through_commands() {
3739 let mut app = test_app();
3740 app.input = "/cl".chars().collect();
3741 app.cursor = app.input.len();
3742
3743 app.complete_tab();
3744 let first: String = app.input.iter().collect();
3745 assert_eq!(first, "/clear");
3746
3747 app.complete_tab();
3749 let second: String = app.input.iter().collect();
3750 assert_eq!(second, "/clear");
3751 }
3752
3753 #[test]
3756 fn paste_inserts_at_cursor_and_switches_to_insert_mode() {
3757 let mut app = test_app();
3758 app.input_mode = InputMode::ViNormal;
3759 app.input = "hello".chars().collect();
3760 app.cursor = 5;
3761
3762 app.handle_paste(" world");
3763
3764 let text: String = app.input.iter().collect();
3765 assert_eq!(text, "hello world");
3766 assert_eq!(app.input_mode, InputMode::ViInsert);
3767 assert_eq!(app.cursor, 11);
3768 }
3769
3770 #[test]
3773 fn render_markdown_handles_code_blocks() {
3774 let text = "text\n```rust\nfn main() {}\n```\nmore text";
3775 let lines = render_markdown(text, Role::Otto, false);
3776 assert!(lines.len() >= 5);
3778 }
3779
3780 #[test]
3781 fn render_markdown_handles_inline_code() {
3782 let text = "use `foo()` here";
3783 let lines = render_markdown(text, Role::Otto, false);
3784 let content_lines: Vec<_> = lines.iter().filter(|l| !l.spans.is_empty()).collect();
3787 assert!(!content_lines.is_empty());
3788 assert!(content_lines[0].spans.len() >= 3);
3789 }
3790
3791 #[test]
3792 fn render_markdown_raw_mode_shows_source() {
3793 let text = "**bold** and `code`";
3794 let lines = render_markdown(text, Role::Otto, true);
3795 assert_eq!(lines.len(), 1);
3796 let full_text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
3797 assert!(full_text.contains("**bold**"));
3798 assert!(full_text.contains("`code`"));
3799 }
3800
3801 #[test]
3802 fn render_markdown_headings_are_styled() {
3803 let text = "# Title\n\n## Subtitle";
3804 let lines = render_markdown(text, Role::Otto, false);
3805 let content: Vec<_> = lines
3806 .iter()
3807 .filter(|l| {
3808 !l.spans.is_empty() && !(l.spans.len() == 1 && l.spans[0].content.trim().is_empty())
3809 })
3810 .collect();
3811 assert!(content.len() >= 2);
3812 assert!(
3814 content[0]
3815 .spans
3816 .iter()
3817 .any(|s| s.style.add_modifier.contains(Modifier::BOLD))
3818 );
3819 }
3820
3821 #[test]
3822 fn render_markdown_unordered_list() {
3823 let text = "- one\n- two\n- three";
3824 let lines = render_markdown(text, Role::Otto, false);
3825 let text_lines: Vec<String> = lines
3826 .iter()
3827 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
3828 .collect();
3829 assert!(text_lines.iter().any(|l| l.contains('\u{2022}')));
3831 }
3832
3833 #[test]
3834 fn render_markdown_link_dedup() {
3835 let text = "[https://example.com](https://example.com)";
3837 let lines = render_markdown(text, Role::Otto, false);
3838 let full: String = lines
3839 .iter()
3840 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3841 .collect();
3842 assert_eq!(full.matches("example.com").count(), 1);
3844 }
3845
3846 #[test]
3847 fn render_markdown_link_shows_url() {
3848 let text = "[click here](https://example.com)";
3850 let lines = render_markdown(text, Role::Otto, false);
3851 let full: String = lines
3852 .iter()
3853 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3854 .collect();
3855 assert!(full.contains("click here"));
3856 assert!(full.contains("(https://example.com)"));
3857 }
3858
3859 #[test]
3860 fn render_markdown_blockquote() {
3861 let text = "> quoted text";
3862 let lines = render_markdown(text, Role::Otto, false);
3863 let full: String = lines
3864 .iter()
3865 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3866 .collect();
3867 assert!(full.contains('\u{2502}')); assert!(full.contains("quoted text"));
3869 }
3870
3871 #[test]
3872 fn render_markdown_task_list() {
3873 let text = "- [x] done\n- [ ] todo";
3874 let lines = render_markdown(text, Role::Otto, false);
3875 let full: String = lines
3876 .iter()
3877 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3878 .collect();
3879 assert!(full.contains('\u{2713}')); assert!(full.contains("[ ]"));
3881 }
3882
3883 #[test]
3884 fn render_markdown_inline_code_has_backtick_delimiters() {
3885 let text = "use `foo()` here";
3886 let lines = render_markdown(text, Role::Otto, false);
3887 let content_lines: Vec<_> = lines.iter().filter(|l| !l.spans.is_empty()).collect();
3888 assert!(!content_lines.is_empty());
3889 let spans = &content_lines[0].spans;
3891 let backtick_count = spans.iter().filter(|s| s.content.as_ref() == "`").count();
3892 assert_eq!(backtick_count, 2);
3893 }
3894
3895 #[test]
3896 fn render_markdown_table() {
3897 let text = "| A | B |\n|---|---|\n| 1 | 2 |";
3898 let lines = render_markdown(text, Role::Otto, false);
3899 let full: String = lines
3900 .iter()
3901 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3902 .collect();
3903 assert!(full.contains('\u{2502}'));
3904 assert!(full.contains('\u{2500}'));
3905 }
3906
3907 #[test]
3908 fn render_markdown_table_alignment_consistent_widths() {
3909 let text = "| A | B |\n|---|---|\n| longer | x |";
3911 let lines = render_markdown(text, Role::Otto, false);
3912 let sep_line = lines
3914 .iter()
3915 .find(|l| l.spans.iter().any(|s| s.content.contains('\u{253c}')))
3916 .expect("should have separator");
3917 let sep_text: String = sep_line.spans.iter().map(|s| s.content.as_ref()).collect();
3918 let first_col = sep_text.split('\u{253c}').next().unwrap();
3920 let dash_count = first_col.chars().filter(|&c| c == '\u{2500}').count();
3921 assert!(
3922 dash_count >= 6,
3923 "separator should match widest cell, got {dash_count}"
3924 );
3925 }
3926
3927 #[test]
3928 fn render_markdown_table_inline_code_preserved() {
3929 let text = "| Col |\n|---|\n| `code` |";
3930 let lines = render_markdown(text, Role::Otto, false);
3931 let has_code_span = lines.iter().any(|l| {
3933 l.spans
3934 .iter()
3935 .any(|s| s.style.fg == Some(CODE_COLOR) && s.content.as_ref() == "code")
3936 });
3937 assert!(has_code_span, "inline code in table should be styled");
3938 }
3939
3940 #[test]
3941 fn render_markdown_nested_list() {
3942 let text = "- outer\n - inner\n- back to outer";
3943 let lines = render_markdown(text, Role::Otto, false);
3944 let texts: Vec<String> = lines
3945 .iter()
3946 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
3947 .collect();
3948 assert!(texts.iter().any(|t| t.contains("inner")));
3950 assert!(texts.iter().any(|t| t.contains("back to outer")));
3951 }
3952
3953 #[test]
3954 fn render_markdown_empty_input() {
3955 let lines = render_markdown("", Role::Otto, false);
3956 assert!(lines.is_empty());
3957 }
3958
3959 #[test]
3960 fn render_markdown_horizontal_rule() {
3961 let text = "above\n\n---\n\nbelow";
3962 let lines = render_markdown(text, Role::Otto, false);
3963 let full: String = lines
3964 .iter()
3965 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3966 .collect();
3967 assert!(full.contains('\u{2500}'));
3968 }
3969
3970 #[test]
3971 fn render_markdown_strikethrough() {
3972 let text = "~~deleted~~";
3973 let lines = render_markdown(text, Role::Otto, false);
3974 let has_strikethrough = lines.iter().any(|l| {
3975 l.spans
3976 .iter()
3977 .any(|s| s.style.add_modifier.contains(Modifier::CROSSED_OUT))
3978 });
3979 assert!(has_strikethrough);
3980 }
3981
3982 #[test]
3983 fn render_markdown_diff_coloring() {
3984 let text = "```diff\n- removed\n+ added\n context\n@@ -1,3 +1,3 @@\n```";
3985 let lines = render_markdown(text, Role::Otto, false);
3986 let del_line = lines
3988 .iter()
3989 .find(|l| l.spans.iter().any(|s| s.content.contains("- removed")))
3990 .expect("should have a deletion line");
3991 assert_eq!(
3992 del_line.spans.last().unwrap().style.fg,
3993 Some(DIFF_DEL_COLOR)
3994 );
3995 let add_line = lines
3997 .iter()
3998 .find(|l| l.spans.iter().any(|s| s.content.contains("+ added")))
3999 .expect("should have an addition line");
4000 assert_eq!(
4001 add_line.spans.last().unwrap().style.fg,
4002 Some(DIFF_ADD_COLOR)
4003 );
4004 let hunk_line = lines
4006 .iter()
4007 .find(|l| l.spans.iter().any(|s| s.content.contains("@@")))
4008 .expect("should have a hunk header line");
4009 assert_eq!(
4010 hunk_line.spans.last().unwrap().style.fg,
4011 Some(DIFF_HUNK_COLOR)
4012 );
4013 }
4014
4015 #[test]
4016 fn render_markdown_syntax_highlighting() {
4017 let text = "```rust\nfn main() {\n println!(\"hello\");\n}\n```";
4018 let lines = render_markdown(text, Role::Otto, false);
4019 let code_lines: Vec<_> = lines
4021 .iter()
4022 .filter(|l| l.spans.iter().any(|s| s.content.contains('\u{2502}')))
4023 .collect();
4024 assert!(!code_lines.is_empty());
4025 let multi_span_lines = code_lines.iter().filter(|l| l.spans.len() > 2).count();
4028 assert!(multi_span_lines > 0, "expected syntax-highlighted spans");
4029 }
4030
4031 #[test]
4032 fn render_markdown_code_block_extracts_language_from_info_string() {
4033 let text = "```sql title=\"file.sql\" lines=\"1-15\"\nSELECT 1;\n```";
4035 let lines = render_markdown(text, Role::Otto, false);
4036 let header = lines
4038 .iter()
4039 .find(|l| l.spans.iter().any(|s| s.content.contains('\u{256d}')))
4040 .expect("should have code block header");
4041 let header_text: String = header.spans.iter().map(|s| s.content.as_ref()).collect();
4042 assert!(
4043 header_text.contains("sql"),
4044 "header should contain language"
4045 );
4046 assert!(
4047 !header_text.contains("title"),
4048 "header should NOT contain metadata"
4049 );
4050 let code_lines: Vec<_> = lines
4052 .iter()
4053 .filter(|l| l.spans.iter().any(|s| s.content.contains('\u{2502}')))
4054 .collect();
4055 assert!(!code_lines.is_empty());
4056 let multi_span = code_lines.iter().filter(|l| l.spans.len() > 2).count();
4057 assert!(multi_span > 0, "SQL should be syntax-highlighted");
4058 }
4059
4060 #[test]
4061 fn render_markdown_python_syntax_highlighting() {
4062 let text = "```python\ndef hello():\n print(\"hi\")\n```";
4063 let lines = render_markdown(text, Role::Otto, false);
4064 let code_lines: Vec<_> = lines
4065 .iter()
4066 .filter(|l| l.spans.iter().any(|s| s.content.contains('\u{2502}')))
4067 .collect();
4068 assert!(!code_lines.is_empty());
4069 let multi_span = code_lines.iter().filter(|l| l.spans.len() > 2).count();
4070 assert!(multi_span > 0, "Python should be syntax-highlighted");
4071 }
4072
4073 #[test]
4074 fn render_markdown_unicode_table_widths() {
4075 let text = "| A | B |\n|---|---|\n| \u{4f60}\u{597d} | x |";
4077 let lines = render_markdown(text, Role::Otto, false);
4078 let sep_line = lines
4079 .iter()
4080 .find(|l| l.spans.iter().any(|s| s.content.contains('\u{253c}')))
4081 .expect("should have separator");
4082 let sep_text: String = sep_line.spans.iter().map(|s| s.content.as_ref()).collect();
4083 let first_col = sep_text.split('\u{253c}').next().unwrap();
4085 let dash_count = first_col.chars().filter(|&c| c == '\u{2500}').count();
4086 assert!(
4087 dash_count >= 4,
4088 "separator should match CJK display width, got {dash_count}"
4089 );
4090 }
4091
4092 #[test]
4093 fn render_markdown_ordered_list_high_start() {
4094 let text = "99. first\n100. second";
4095 let lines = render_markdown(text, Role::Otto, false);
4096 let texts: Vec<String> = lines
4097 .iter()
4098 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
4099 .collect();
4100 assert!(texts.iter().any(|t| t.contains("99.")));
4102 assert!(texts.iter().any(|t| t.contains("100.")));
4103 }
4104
4105 #[test]
4106 fn render_markdown_nested_blockquote() {
4107 let text = "> > doubly quoted";
4108 let lines = render_markdown(text, Role::Otto, false);
4109 let full: String = lines
4110 .iter()
4111 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
4112 .collect();
4113 assert!(
4115 full.matches('\u{2502}').count() >= 2,
4116 "nested blockquote should have 2+ vertical bars"
4117 );
4118 }
4119
4120 #[test]
4121 fn render_markdown_mixed_nested_lists() {
4122 let text = "1. ordered\n - unordered inside\n2. back";
4123 let lines = render_markdown(text, Role::Otto, false);
4124 let texts: Vec<String> = lines
4125 .iter()
4126 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
4127 .collect();
4128 assert!(texts.iter().any(|t| t.contains("1.")));
4130 assert!(texts.iter().any(|t| t.contains('\u{2022}')));
4131 }
4132
4133 #[test]
4134 fn render_markdown_multi_paragraph_list_item() {
4135 let text = "- first paragraph\n\n second paragraph\n- next item";
4136 let lines = render_markdown(text, Role::Otto, false);
4137 let full: String = lines
4138 .iter()
4139 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
4140 .collect();
4141 assert!(full.contains("first paragraph"));
4142 assert!(full.contains("second paragraph"));
4143 assert!(full.contains("next item"));
4144 }
4145
4146 #[test]
4147 fn render_markdown_emoji_in_table() {
4148 let text = "| Col |\n|---|\n| \u{1f600} |";
4149 let lines = render_markdown(text, Role::Otto, false);
4150 assert!(!lines.is_empty());
4152 let full: String = lines
4153 .iter()
4154 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
4155 .collect();
4156 assert!(full.contains('\u{1f600}'));
4157 }
4158
4159 #[test]
4160 fn render_markdown_gfm_note_blockquote() {
4161 let text = "> [!NOTE]\n> This is a note.";
4162 let lines = render_markdown(text, Role::Otto, false);
4163 let full: String = lines
4164 .iter()
4165 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
4166 .collect();
4167 assert!(full.contains("NOTE"), "should render NOTE label");
4168 assert!(full.contains("This is a note."));
4169 }
4170
4171 #[test]
4172 fn render_markdown_gfm_warning_blockquote() {
4173 let text = "> [!WARNING]\n> Be careful.";
4174 let lines = render_markdown(text, Role::Otto, false);
4175 let has_warning = lines.iter().any(|l| {
4177 l.spans
4178 .iter()
4179 .any(|s| s.content.as_ref() == "WARNING" && s.style.fg == Some(WARNING_COLOR))
4180 });
4181 assert!(
4182 has_warning,
4183 "should render WARNING label with correct color"
4184 );
4185 }
4186
4187 #[test]
4188 fn render_markdown_code_in_blockquote() {
4189 let text = "> ```rust\n> fn x() {}\n> ```";
4190 let lines = render_markdown(text, Role::Otto, false);
4191 let full: String = lines
4192 .iter()
4193 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
4194 .collect();
4195 assert!(full.contains("fn x()"));
4196 assert!(full.contains('\u{2502}'));
4198 assert!(full.contains('\u{256d}'));
4199 }
4200
4201 #[test]
4204 fn second_ctrl_c_during_interrupting_sets_force_quit() {
4205 let mut app = test_app();
4206 app.streaming = true;
4207 app.interrupting = true;
4208
4209 let cancelled_gen = AtomicU64::new(1);
4210 app.handle_key(
4211 KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
4212 &cancelled_gen,
4213 );
4214
4215 assert!(app.force_quit);
4216 assert!(app.should_quit);
4217 }
4218
4219 #[test]
4222 fn stop_finished_ignored_when_not_interrupting() {
4223 let mut app = test_app();
4224 app.streaming = true;
4225 app.interrupting = false;
4226
4227 let msg_count_before = app.messages.len();
4228 app.handle_stream_msg(StreamMsg::StopFinished {
4229 error: Some("should be ignored".into()),
4230 });
4231
4232 assert!(app.streaming);
4234 assert_eq!(app.messages.len(), msg_count_before);
4235 }
4236}