Skip to main content

ascend_tools_tui/
lib.rs

1#![deny(unsafe_code)]
2
3//! Interactive TUI for Otto chat.
4//!
5//! Full-screen terminal interface using ratatui with:
6//! - Scrollable chat history with scrollbar
7//! - Streaming responses with spinner and smooth output
8//! - Vi input mode (default) with `/emacs` toggle
9//! - Multi-line input (Alt+Enter for newline)
10//! - Input history (Up/Down, persisted to ~/.ascend-tools/history)
11//! - Slash commands with tab completion
12//! - Markdown rendering (headings, lists, code blocks, tables, and more)
13//! - Message timestamps (`/timestamps` to toggle)
14//! - Clipboard copy (`/copy`)
15//! - Vi yank/paste registers
16
17use 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
47// ---------------------------------------------------------------------------
48// Constants
49// ---------------------------------------------------------------------------
50
51const 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); // dark blue
92const OTTO_COLOR: Color = Color::Rgb(232, 67, 67); // ascend red
93const SYSTEM_COLOR: Color = Color::Rgb(160, 120, 200); // purple
94const VI_NORMAL_COLOR: Color = Color::Rgb(255, 140, 80); // orange
95const CODE_COLOR: Color = Color::Rgb(255, 140, 80); // orange (matches vi normal)
96const DIM_COLOR: Color = Color::Rgb(100, 100, 100);
97const WARNING_COLOR: Color = Color::Rgb(255, 200, 50); // yellow
98const DIM_OTTO_COLOR: Color = Color::Rgb(120, 45, 45); // muted ascend red
99const POPUP_BG: Color = Color::Rgb(50, 50, 50);
100const TEXT_COLOR: Color = Color::White;
101const HEADING_COLOR: Color = Color::Rgb(130, 170, 255); // light blue for headings
102const CHECK_COLOR: Color = Color::Rgb(80, 200, 120); // green for task checkmarks
103const LINK_COLOR: Color = Color::Rgb(100, 160, 240); // blue for link text
104const DIFF_ADD_COLOR: Color = Color::Rgb(80, 200, 120); // green for diff additions
105const DIFF_DEL_COLOR: Color = Color::Rgb(232, 80, 80); // red for diff deletions
106const DIFF_HUNK_COLOR: Color = Color::Rgb(130, 170, 255); // blue for diff hunk headers
107const NOTE_COLOR: Color = Color::Rgb(100, 160, 240); // blue for [!NOTE]
108const TIP_COLOR: Color = Color::Rgb(80, 200, 120); // green for [!TIP]
109const IMPORTANT_COLOR: Color = Color::Rgb(180, 130, 240); // purple for [!IMPORTANT]
110const CAUTION_COLOR: Color = Color::Rgb(232, 80, 80); // red for [!CAUTION]
111const TIMESTAMP_COLOR: Color = Color::Rgb(80, 80, 80);
112
113/// Characters per second for smoothed streaming output.
114const STREAM_CPS: f64 = 200.0;
115/// Above this pending count, flush in bulk to catch up.
116const STREAM_BULK_THRESHOLD: usize = 200;
117/// Above this pending count, skip smoothing entirely.
118const STREAM_FAST_THRESHOLD: usize = 50;
119
120const MAX_HISTORY: usize = 1000;
121
122/// Syntax highlighting for code blocks.
123static 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
131// ---------------------------------------------------------------------------
132// Types
133// ---------------------------------------------------------------------------
134
135enum 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 messages tagged with a generation to discard stale messages
148    /// from cancelled requests.
149    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
200// ---------------------------------------------------------------------------
201// History
202// ---------------------------------------------------------------------------
203
204struct 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        // Deduplicate consecutive
234        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        // Append to file
243        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
298// ---------------------------------------------------------------------------
299// App
300// ---------------------------------------------------------------------------
301
302struct App {
303    messages: Vec<Message>,
304    input: Vec<char>,
305    cursor: usize,
306    input_mode: InputMode,
307    /// Lines scrolled up from the bottom (0 = pinned to newest).
308    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    /// Set when cancel fires; the main loop spawns a thread to stop the backend.
334    /// The generation value is the *cancelled* generation (before advancement).
335    stop_pending: Option<u64>,
336    interrupting: bool,
337    /// Set when user presses Ctrl+C during interrupting state — force exit.
338    force_quit: bool,
339    /// Show raw markdown source instead of rendered output (Ctrl+R toggle).
340    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    // -- Input helpers ------------------------------------------------------
390
391    fn input_line_count(&self, width: u16) -> u16 {
392        let avail = (width as usize).saturating_sub(3); // prompt_len
393        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        // Cursor at end needs an extra row if current row is full
411        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    // -- Key handling -------------------------------------------------------
429
430    fn handle_key(&mut self, key: KeyEvent, cancelled_gen: &AtomicU64) {
431        // Ctrl+C: cancel stream, force quit if already interrupting, or quit
432        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
433            if self.interrupting {
434                // Second Ctrl+C while stopping — force quit
435                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        // Ctrl+R: toggle raw markdown view
448        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        // Escape: cancel stream (if streaming), otherwise normal key handling
454        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        // Ctrl+o: toggle tool call expand/collapse
463        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        // Tab: cycle completions
477        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            // Alt+Enter or Shift+Enter: insert newline
486            (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            // Word-wise movement
519            (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            // Kill to start
530            (KeyModifiers::CONTROL, KeyCode::Char('u')) => {
531                self.input.drain(..self.cursor);
532                self.cursor = 0;
533            }
534            // Kill to end
535            (KeyModifiers::CONTROL, KeyCode::Char('k')) => {
536                self.input.truncate(self.cursor);
537            }
538            // Kill word backward
539            (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            // History
546            (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            // Scroll
562            (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        // Multi-char commands (dd, yy)
582        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            // Enter insert mode
599            (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            // Motion
616            (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            // Editing
632            (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            // Paste after cursor
648            (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            // Paste before cursor
658            (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            // Submit
668            (KeyModifiers::NONE, KeyCode::Enter) => self.submit(),
669
670            // History
671            (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            // Scroll
687            (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    // -- Completions --------------------------------------------------------
697
698    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    // -- Word boundaries ----------------------------------------------------
735
736    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    // -- Scroll helpers -----------------------------------------------------
780
781    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    // -- Submit & commands --------------------------------------------------
794
795    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    // -- Streaming ----------------------------------------------------------
920
921    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                // Only act if we're actually in interrupting state.
940                // A late StopFinished from a previous cancel is harmless.
941                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                // Discard stale messages from cancelled requests
952                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                    // No-op: cleanup is deferred to StopFinished message
1003                    // from the background stop thread.
1004                }
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        // Store the generation being cancelled BEFORE advancing, so workers
1042        // for this generation see the cancellation even if a new request
1043        // resets nothing (cancelled_gen is never cleared).
1044        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    // -- Rendering ----------------------------------------------------------
1101
1102    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),               // chat
1111            Constraint::Length(1),            // top rule
1112            Constraint::Length(input_height), // input
1113            Constraint::Length(1),            // bottom rule
1114            Constraint::Length(1),            // status
1115        ])
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        // Cursor shape
1126        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                // System messages: show timestamp inline
1164                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        // Streaming: show current buffer or spinner
1182        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        // Trailing padding
1233        lines.push(Line::raw(""));
1234
1235        // Exact rendered line count via Paragraph::line_count
1236        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        // Clear stale buffer cells — Paragraph doesn't overwrite unused positions.
1245        frame.render_widget(Clear, area);
1246        frame.render_widget(paragraph, area);
1247
1248        // Scrollbar
1249        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; // +2 for blank lines around it
1263        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        // Experimental banner
1272        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        // Otto splash
1282        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        // Build visual rows with wrapping, tracking cursor position
1338        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        // Cursor at end of input
1361        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        // Scroll viewport so cursor is always visible
1373        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        // Truncate pills to fit terminal width
1498        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
1516// ---------------------------------------------------------------------------
1517// Helpers
1518// ---------------------------------------------------------------------------
1519
1520/// Truncate a string for display, adding "..." if it exceeds `max_len`.
1521fn 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    // Elapsed since message was created — avoids UTC vs local time issues
1532    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
1543// ---------------------------------------------------------------------------
1544// Markdown rendering
1545// ---------------------------------------------------------------------------
1546
1547fn 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    // Pretty-print a JSON string, falling back to raw text
1576    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
1612/// Raw mode: show literal markdown source with minimal styling.
1613fn 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
1628/// Rendered mode: parse markdown with pulldown-cmark and produce styled lines.
1629fn 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    // Flush any remaining spans.
1671    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    /// The `list_indent` that was active when this list was opened.
1686    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    // Table state — two-pass: buffer all rows, render at End(Table).
1702    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    /// Plain string indent — used for code blocks and other concatenation contexts.
1732    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    /// Styled indent spans — blockquote bars get DIM_COLOR, rest is unstyled.
1742    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        // Add blank line if previous line was non-empty content.
1772        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            // -- Block-level start tags --
1783            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                // Render GFM admonition labels ([!NOTE], [!TIP], etc.).
1822                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                // Extract just the language token (first word) from the info string.
1848                // Fenced code blocks can have metadata after the language, e.g.:
1849                //   ```sql title="file.sql" lines="1-15"
1850                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                // Try to find a syntax for highlighting.
1857                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                        // Pad to max_digits for consistent indentation.
1914                        let d = (*max_digits).max(num.to_string().len());
1915                        format!("{nested_indent}{num:>d$}. ")
1916                    }
1917                    None => String::new(),
1918                };
1919
1920                // Set bullet prefix for first line, render the indent+bullet.
1921                self.list_indent = bullet.clone();
1922                self.spans.extend(self.indent_spans());
1923                // Set continuation indent matching the bullet width.
1924                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                // Restore the indent that was active when this list was opened.
1937                self.list_indent = self
1938                    .list_stack
1939                    .last()
1940                    .map(|entry| entry.parent_indent.clone())
1941                    .unwrap_or_default();
1942            }
1943
1944            // -- Inline start/end tags --
1945            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                    // Only show URL if it differs from the link text.
1976                    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            // -- Table handling (two-pass: buffer all rows, render at End(Table)) --
1997            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            // -- Leaf events --
2066            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                    // Track link text for dedup.
2108                    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                    // Tables are single-line cells; treat as space.
2149                    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            // Ignore HTML and other events.
2180            _ => {}
2181        }
2182    }
2183
2184    /// Render the fully-buffered table: header, separator, body rows.
2185    /// Two-pass ensures column widths are correct across all rows.
2186    fn render_buffered_table(&mut self) {
2187        let prefix = self.indent_prefix();
2188
2189        // Header row (bold).
2190        let header = std::mem::take(&mut self.table_header_spans);
2191        self.render_table_line(&prefix, &header, true);
2192
2193        // Separator.
2194        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        // Body rows.
2206        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    /// Render one table row as a Line with per-cell styled Spans and padding.
2213    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
2258/// Pick a color for a line inside a `diff` code block.
2259fn 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
2271/// Find a syntect syntax definition for a code block language tag.
2272fn find_syntax(lang: &str) -> Option<&'static syntect::parsing::SyntaxReference> {
2273    // Diff blocks use custom line-by-line coloring (diff_line_color), not syntect.
2274    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
2282/// Convert a syntect highlighting style to a ratatui style.
2283fn 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
2307// ---------------------------------------------------------------------------
2308// Provider resolution
2309// ---------------------------------------------------------------------------
2310
2311/// Resolve provider/model labels from the API, mapping IDs to friendly names.
2312fn 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            // Find which provider has this model (match by ID or name)
2322            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            // Fallback: extract a short name from the ID
2332            // (e.g. "bedrock/global.anthropic.claude-sonnet-4-5-v1" → last segment)
2333            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
2355/// Convert a fetched `Conversation` into TUI `Message`s.
2356fn 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
2392// ---------------------------------------------------------------------------
2393// Entry point
2394// ---------------------------------------------------------------------------
2395
2396pub 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    // Setup terminal
2404    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    // Panic hook to restore terminal on crash
2416    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        // Resolve provider/model labels in the background so the TUI loads instantly
2438        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 resuming a conversation, load its history in the background
2458        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                        // ratatui handles re-layout; just ensure scroll is valid
2492                        if app.auto_scroll {
2493                            app.scroll = 0;
2494                        }
2495                    }
2496                    _ => {}
2497                }
2498            }
2499
2500            // Process stream messages
2501            while let Ok(msg) = stream_rx.try_recv() {
2502                app.handle_stream_msg(msg);
2503            }
2504
2505            // If the user cancelled, tell the backend to stop the thread.
2506            // Spawns a background thread so the TUI stays responsive.
2507            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            // Launch streaming request if pending
2530            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                // No cancelled_gen reset — each worker checks its own generation.
2536                *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 this generation was cancelled (possibly before thread_id
2582                    // arrived), fire a stop to clean up the backend run.
2583                    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                // Restore terminal before exiting the scope, since scope.join
2604                // may block if a background SSE read is stuck.
2605                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 force-quitting (second Ctrl+C) or a cancelled worker
2618                // may be stuck on a blocking SSE read, exit the process
2619                // cleanly rather than waiting for scoped thread join.
2620                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    // Restore terminal (reached when scope completes normally)
2631    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// ---------------------------------------------------------------------------
2647// Tests — App state machine
2648// ---------------------------------------------------------------------------
2649
2650#[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    // -- Stream lifecycle --------------------------------------------------
2660
2661    #[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        // User message should be added
2674        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        // Should push a system message but NOT create a pending request
2686        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    // -- Stream message handling -------------------------------------------
2702
2703    #[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        // Message from generation 1 should be ignored
2729        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    // -- Completed stream --------------------------------------------------
2752
2753    #[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        // Buffer should be flushed to messages
2772        assert!(
2773            app.messages
2774                .iter()
2775                .any(|m| m.role == Role::Otto && m.content == "response text")
2776        );
2777    }
2778
2779    // -- Error handling ----------------------------------------------------
2780
2781    #[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    // =====================================================================
2849    // Cancellation & interrupt state machine (exhaustive)
2850    // =====================================================================
2851    //
2852    // State diagram:
2853    //   idle → [Ctrl+C] → cancel_stream() → interrupting=true, stop_pending=Some(gen)
2854    //        → [main loop] spawns stop thread → stop_pending=None
2855    //        → [stop thread] sends StopFinished{error} → finish_stream()
2856    //        → idle (ready for next message)
2857    //
2858    // The SSE stream may also send Finished{Cancelled} before StopFinished
2859    // arrives — this is a no-op (deferred to StopFinished).
2860
2861    // -- 1. Basic cancel initiation ----------------------------------------
2862
2863    #[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)); // cancelled generation
2875        assert_eq!(cancelled_gen.load(Ordering::Acquire), 1);
2876        // Generation should advance to reject future messages from old stream
2877        assert_eq!(app.stream_generation, 2);
2878        // Partial output should be flushed to messages
2879        assert!(
2880            app.messages
2881                .iter()
2882                .any(|m| m.role == Role::Otto && m.content == "partial output")
2883        );
2884        // Active tool call should be cleared
2885        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        // No text received yet — buffer and pending are empty
2894
2895        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)); // cancelled generation
2900        // No Otto message should be flushed (nothing to flush)
2901        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        // Both buffer and pending should be flushed together
2916        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    // -- 2. Idempotent cancel (multiple Ctrl+C) ----------------------------
2938
2939    #[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; // already dispatched
2946
2947        let cancelled_gen = AtomicU64::new(1);
2948        app.cancel_stream(&cancelled_gen);
2949
2950        // Should not change anything — generation stays, no double stop
2951        assert_eq!(app.stream_generation, 5);
2952        assert_eq!(app.stop_pending, None); // should NOT re-set stop_pending
2953    }
2954
2955    // -- 3. StopFinished: success ------------------------------------------
2956
2957    #[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    // -- 4. StopFinished: timeout (your screenshot) ------------------------
2976
2977    #[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        // Should fully recover to idle state
2991        assert!(!app.streaming);
2992        assert!(!app.interrupting);
2993        assert!(app.stream_start.is_none());
2994        assert!(app.active_tool_call.is_none());
2995        // Error message shown
2996        assert!(
2997            app.messages
2998                .iter()
2999                .any(|m| m.role == Role::System && m.content.contains("Interrupt failed"))
3000        );
3001        // Thread ID should be preserved so follow-up works
3002        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        // Stop times out
3013        app.handle_stream_msg(StreamMsg::StopFinished {
3014            error: Some("thread did not stop within 30 seconds".into()),
3015        });
3016
3017        // User types a follow-up
3018        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        // Thread ID preserved for follow-up
3028        assert_eq!(req.thread_id.as_deref(), Some("t-123"));
3029    }
3030
3031    // -- 5. StopFinished: network error ------------------------------------
3032
3033    #[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        // Thread ID preserved
3052        assert_eq!(app.thread_id.as_deref(), Some("t-456"));
3053
3054        // Can still submit
3055        app.input = "retry".chars().collect();
3056        app.submit();
3057        assert!(app.streaming);
3058    }
3059
3060    // -- 6. Cancel before thread_id is known -------------------------------
3061
3062    #[test]
3063    fn cancel_before_thread_id_finishes_immediately() {
3064        // This simulates the main loop path where active_thread_id is None
3065        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)); // cancelled generation
3075
3076        // In the main loop, stop_pending=Some but active_thread_id=None
3077        // triggers immediate finish_stream + "Cancelled"
3078        // Simulate that path:
3079        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    // -- 7. SSE Finished(Cancelled) then StopFinished ----------------------
3093
3094    #[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        // SSE callback breaks → otto_streaming returns Cancelled
3102        // This arrives as a Stream message (NOT StopFinished)
3103        app.handle_stream_msg(StreamMsg::Stream {
3104            generation: 1,
3105            kind: StreamMsgKind::Finished {
3106                status: OttoStreamStatus::Cancelled,
3107                error: None,
3108            },
3109        });
3110
3111        // Should still be in interrupting state — waiting for StopFinished
3112        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        // Step 1: SSE returns Cancelled (no-op)
3124        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); // still waiting
3132
3133        // Step 2: Background stop thread completes
3134        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        // Step 1: SSE returns Cancelled
3154        app.handle_stream_msg(StreamMsg::Stream {
3155            generation: 1,
3156            kind: StreamMsgKind::Finished {
3157                status: OttoStreamStatus::Cancelled,
3158                error: None,
3159            },
3160        });
3161
3162        // Step 2: Stop thread fails
3163        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        // Thread preserved for retry
3175        assert_eq!(app.thread_id.as_deref(), Some("t-789"));
3176    }
3177
3178    // -- 8. Stale messages after cancel ------------------------------------
3179
3180    #[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        // Cancel advances generation to 2
3187        let cancelled_gen = AtomicU64::new(0);
3188        app.cancel_stream(&cancelled_gen);
3189        assert_eq!(app.stream_generation, 2);
3190
3191        // Old-generation messages should be silently dropped
3192        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        // None of these should have affected state
3212        assert!(app.stream_pending.is_empty());
3213        assert!(app.active_tool_call.is_none());
3214        // Still interrupting (waiting for StopFinished)
3215        assert!(app.interrupting);
3216        assert!(app.streaming);
3217    }
3218
3219    // -- 9. Full cancel → recover → new message cycle ----------------------
3220
3221    #[test]
3222    fn full_cancel_recover_new_message_cycle() {
3223        let mut app = test_app();
3224
3225        // 1. User sends first message
3226        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        // Simulate thread ID arriving
3233        app.stream_generation = 1;
3234        app.handle_stream_msg(StreamMsg::Stream {
3235            generation: 1,
3236            kind: StreamMsgKind::ThreadId("t-cycle".into()),
3237        });
3238        // Some text arrives
3239        app.handle_stream_msg(StreamMsg::Stream {
3240            generation: 1,
3241            kind: StreamMsgKind::Delta("partial answer".into()),
3242        });
3243
3244        // 2. User cancels
3245        let cancelled_gen = AtomicU64::new(0);
3246        app.cancel_stream(&cancelled_gen);
3247        assert!(app.interrupting);
3248        assert_eq!(app.stop_pending, Some(1)); // cancelled generation
3249
3250        // 3. Stop thread succeeds
3251        app.stop_pending = None;
3252        app.handle_stream_msg(StreamMsg::StopFinished { error: None });
3253        assert!(!app.streaming);
3254        assert!(!app.interrupting);
3255
3256        // Partial text should be in messages
3257        assert!(
3258            app.messages
3259                .iter()
3260                .any(|m| m.role == Role::Otto && m.content.contains("partial answer"))
3261        );
3262
3263        // 4. User sends follow-up
3264        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        // 1. Streaming
3277        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        // 2. Cancel
3290        let cancelled_gen = AtomicU64::new(0);
3291        app.cancel_stream(&cancelled_gen);
3292
3293        // 3. Stop times out (your screenshot scenario)
3294        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        // 4. User sends follow-up — should work despite timeout
3305        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    // -- 10. Multiple rapid Ctrl+C -----------------------------------------
3314
3315    #[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        // First Ctrl+C during interrupting → force quit
3325        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        // Still interrupting/streaming — force quit bypasses normal cleanup
3333        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    // -- 11. Stream error during interrupting state ------------------------
3360
3361    #[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        // Cancel advances generation
3368        let cancelled_gen = AtomicU64::new(0);
3369        app.cancel_stream(&cancelled_gen);
3370        assert_eq!(app.stream_generation, 2);
3371
3372        // Error from old generation arrives — should be discarded
3373        app.handle_stream_msg(StreamMsg::Stream {
3374            generation: 1,
3375            kind: StreamMsgKind::Error("old error".into()),
3376        });
3377
3378        // Still interrupting, waiting for StopFinished
3379        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    // -- 12. StopFinished when not interrupting (race) ---------------------
3389
3390    #[test]
3391    fn stop_finished_when_not_interrupting_is_noop() {
3392        let mut app = test_app();
3393        app.streaming = true;
3394        app.interrupting = false; // race: already recovered somehow
3395
3396        app.handle_stream_msg(StreamMsg::StopFinished { error: None });
3397
3398        // Should not affect state — only acts when interrupting
3399        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        // Not streaming at all
3407        assert!(!app.streaming);
3408        assert!(!app.interrupting);
3409
3410        app.handle_stream_msg(StreamMsg::StopFinished {
3411            error: Some("some error".into()),
3412        });
3413
3414        // Should not crash or leave bad state
3415        assert!(!app.streaming);
3416        assert!(!app.interrupting);
3417    }
3418
3419    // -- 13. Ctrl+C/Esc routing --------------------------------------------
3420
3421    #[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    // -- 14. Submit blocked during interrupting ----------------------------
3483
3484    #[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        // Should be blocked (streaming is still true)
3493        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    // -- 15. Pending request not launched during interrupting ---------------
3502
3503    #[test]
3504    fn pending_request_guard_during_interrupting() {
3505        // The main loop has: if !app.interrupting && let Some(request) = ...
3506        // This test verifies the app-level invariant that pending_request
3507        // should not exist during interrupting (submit blocks it).
3508        let mut app = test_app();
3509        app.streaming = true;
3510        app.interrupting = true;
3511
3512        // Force a pending request (shouldn't happen in practice)
3513        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        // The main loop guard is: if !app.interrupting && let Some(req) = app.take_pending_request()
3521        // Verify: with interrupting=true, take_pending_request should return Some
3522        // but the guard prevents launching. We verify the take works.
3523        assert!(app.interrupting);
3524        assert!(app.pending_request.is_some());
3525        // (The main loop guard is tested by integration, not here)
3526    }
3527
3528    // -- 16. Cancel during tool call execution -----------------------------
3529
3530    #[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        // Partial text preserved
3544        assert!(
3545            app.messages
3546                .iter()
3547                .any(|m| m.role == Role::Otto && m.content.contains("Let me check"))
3548        );
3549    }
3550
3551    // -- Tool call display -------------------------------------------------
3552
3553    #[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    // -- Provider info update ----------------------------------------------
3597
3598    #[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    // -- Input helpers -----------------------------------------------------
3612
3613    #[test]
3614    fn input_line_count_wraps_correctly() {
3615        let mut app = test_app();
3616        // 10 chars, width 8 (avail = 5 after 3-char prompt) → 2 rows of content
3617        // + cursor at end of full row triggers extra row = 3
3618        app.input = "abcdefghij".chars().collect();
3619        app.cursor = app.input.len();
3620        assert_eq!(app.input_line_count(8), 3);
3621
3622        // 7 chars, avail 5 → row1: 5 chars, row2: 2 chars + cursor not full → 2 rows
3623        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        // Create input with many newlines to exceed MAX_INPUT_LINES
3640        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    // -- Slash commands ----------------------------------------------------
3646
3647    #[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        // Should only have the "Thread cleared" system message
3662        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    // -- History -----------------------------------------------------------
3694
3695    #[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(); // clear streaming state
3701
3702        app.input = "second query".chars().collect();
3703        app.submit();
3704        app.finish_stream();
3705
3706        // Navigate back through history
3707        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    // -- Streaming text smoothing ------------------------------------------
3716
3717    #[test]
3718    fn tick_stream_flushes_when_bulk_threshold_exceeded() {
3719        let mut app = test_app();
3720        app.streaming = true;
3721        // Add more chars than STREAM_BULK_THRESHOLD
3722        let text: String = (0..250).map(|_| 'x').collect();
3723        app.stream_pending = text.chars().collect();
3724        // Set last_stream_tick to the past so chars_due > 0
3725        app.last_stream_tick = Instant::now() - Duration::from_millis(100);
3726
3727        app.tick_stream();
3728
3729        // Should have flushed a large chunk into stream_buffer
3730        assert!(!app.stream_buffer.is_empty());
3731        // Total should still equal 250
3732        assert_eq!(app.stream_buffer.len() + app.stream_pending.len(), 250);
3733    }
3734
3735    // -- Completion --------------------------------------------------------
3736
3737    #[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        // Tab again should still show /clear (only match)
3748        app.complete_tab();
3749        let second: String = app.input.iter().collect();
3750        assert_eq!(second, "/clear");
3751    }
3752
3753    // -- Paste handling ----------------------------------------------------
3754
3755    #[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    // -- Markdown rendering ------------------------------------------------
3771
3772    #[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        // Should have: text, blank, code block header, code line, code block footer, blank, more text
3777        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        // Line should contain multiple spans (indent, text, code, text)
3785        // pulldown-cmark wraps in paragraph, so we may have blank lines
3786        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        // H1 should be bold
3813        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        // Should contain bullet character
3830        assert!(text_lines.iter().any(|l| l.contains('\u{2022}')));
3831    }
3832
3833    #[test]
3834    fn render_markdown_link_dedup() {
3835        // When link text matches URL, should not show URL twice.
3836        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        // URL should appear exactly once, not duplicated in parens.
3843        assert_eq!(full.matches("example.com").count(), 1);
3844    }
3845
3846    #[test]
3847    fn render_markdown_link_shows_url() {
3848        // When link text differs from URL, should show URL in parens.
3849        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}')); // vertical bar
3868        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}')); // checkmark
3880        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        // Should have dim backtick spans around the code span.
3890        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        // Body cell "longer" is wider than header "A" — all rows should use the wider width.
3910        let text = "| A | B |\n|---|---|\n| longer | x |";
3911        let lines = render_markdown(text, Role::Otto, false);
3912        // Find the separator line — its column widths should match the widest cell.
3913        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        // The first column separator should be at least 6 chars wide (len of "longer").
3919        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        // Should have a span with CODE_COLOR for inline code in the table.
3932        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        // Should have bullet characters at different indent levels.
3949        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        // Find the line with "- removed" — should be DIFF_DEL_COLOR (red).
3987        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        // Find the line with "+ added" — should be DIFF_ADD_COLOR (green).
3996        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        // Find the hunk header — should be DIFF_HUNK_COLOR (blue).
4005        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        // Find a code content line (has │ border).
4020        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        // With syntax highlighting, code lines should have >1 span
4026        // (border span + multiple colored spans, not just border + single white span).
4027        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        // Code fence with metadata after language: ```sql title="file.sql" lines="1-15"
4034        let text = "```sql title=\"file.sql\" lines=\"1-15\"\nSELECT 1;\n```";
4035        let lines = render_markdown(text, Role::Otto, false);
4036        // Header should only show "sql", not the full info string.
4037        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        // Code should have syntax highlighting (SQL matched).
4051        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        // CJK characters are display-width 2 each; column width should reflect that.
4076        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        // "你好" is 4 display columns — separator first column should be at least 4 dashes.
4084        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        // First item should show "99." with proper padding for the eventual 3-digit number.
4101        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        // Should have two vertical bar characters for nested blockquote.
4114        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        // Should have both ordered number and bullet character.
4129        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        // Should not panic and should produce output.
4151        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        // Find the WARNING label span with the correct color.
4176        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        // Should have both blockquote bar and code border.
4197        assert!(full.contains('\u{2502}'));
4198        assert!(full.contains('\u{256d}'));
4199    }
4200
4201    // -- Force quit (second Ctrl+C during interrupting) --------------------
4202
4203    #[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    // -- StopFinished guarded by interrupting state ------------------------
4220
4221    #[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        // Should not change state or add messages
4233        assert!(app.streaming);
4234        assert_eq!(app.messages.len(), msg_count_before);
4235    }
4236}