Skip to main content

lucy/
tui.rs

1use std::io::{self, Write};
2use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
3use std::thread::{self, JoinHandle};
4use std::time::{Duration, Instant};
5
6use crossterm::cursor::{Hide, Show};
7use crossterm::event::{
8    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
9    KeyModifiers, KeyboardEnhancementFlags, MouseEventKind, PopKeyboardEnhancementFlags,
10    PushKeyboardEnhancementFlags,
11};
12use crossterm::execute;
13use crossterm::terminal::{
14    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
15};
16use ratatui::backend::CrosstermBackend;
17use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect, Size};
18use ratatui::prelude::Frame;
19use ratatui::style::{Color, Style};
20use ratatui::text::{Line, Span};
21use ratatui::widgets::{Block, Borders, Clear, Paragraph};
22use ratatui::Terminal;
23use serde_json::Value;
24use unicode_width::UnicodeWidthStr;
25
26use crate::app::Harness;
27use crate::cancellation::CancellationToken;
28use crate::model::{estimate_context_tokens, ChatMessage};
29use crate::protocol::{EventSink, ProtocolEvent};
30use crate::redaction::redact_secret;
31use crate::session::SessionHistoryRecord;
32
33const EVENT_POLL: Duration = Duration::from_millis(50);
34const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
35const MAX_DISPLAY_INPUT_CHARS: usize = 16 * 1024;
36const WORKER_SHUTDOWN_GRACE: Duration = Duration::from_secs(2);
37/// Maximum number of wrapped input rows the input box grows to before it
38/// stops expanding and scrolls its contents internally.
39const MAX_INPUT_ROWS: u16 = 12;
40const WELCOME_MESSAGE: &str = concat!("Coding Agent Harness LUCY - v", env!("CARGO_PKG_VERSION"),);
41const WELCOME_TAGLINE: &str = "An ultra-thin harness for tomorrow's most powerful models";
42const WELCOME_START_COLOR: (u8, u8, u8) = (0, 180, 180);
43const WELCOME_END_COLOR: (u8, u8, u8) = (255, 215, 0);
44const USER_BORDER_COLOR: Color = Color::Rgb(192, 154, 0);
45const PENDING_TOOL_COLOR: Color = Color::Rgb(255, 165, 0);
46const WAITING_INPUT_MESSAGE: &str = "The agent is working. Please wait until it finishes.";
47const BUSY_INPUT_COLOR: Color = Color::DarkGray;
48const SKILL_PICKER_MAX_ROWS: usize = 5;
49
50pub(crate) fn run<W: Write>(mut harness: Harness, resumed: bool, stdout: W) -> Result<(), String> {
51    let secret = harness.provider.api_key().to_owned();
52    let context_window = harness
53        .context_window
54        .or_else(|| harness.provider.context_window());
55    harness.context_window = context_window;
56    let context_tokens = estimate_context_tokens(&harness.session.provider_messages());
57    let skill_names = harness
58        .session
59        .skills
60        .iter()
61        .map(|skill| skill.name.clone())
62        .collect();
63    let mut state = UiState::from_history(
64        &harness.session.history,
65        &secret,
66        &harness.session.llm.model,
67        harness.session.llm.effort.as_deref(),
68        resumed,
69    )
70    .with_attached_agents(harness.attached_agents.clone())
71    .with_skill_names(skill_names)
72    .with_context(context_window, context_tokens);
73    let (request_tx, request_rx) = mpsc::channel::<WorkerRequest>();
74    let (message_tx, message_rx) = mpsc::channel::<WorkerMessage>();
75
76    let stdout = stdout;
77    enable_raw_mode().map_err(|error| format!("unable to enable terminal input: {error}"))?;
78    let backend = CrosstermBackend::new(stdout);
79    let terminal = match Terminal::new(backend) {
80        Ok(terminal) => terminal,
81        Err(error) => {
82            let _ = disable_raw_mode();
83            return Err(format!("unable to initialize terminal UI: {error}"));
84        }
85    };
86    let mut terminal_guard = TerminalGuard::new(terminal);
87    let backend = terminal_guard.terminal_mut().backend_mut();
88    if let Err(error) = execute!(backend, EnterAlternateScreen, EnableMouseCapture, Hide) {
89        return Err(format!("unable to enter terminal UI: {error}"));
90    }
91    // Kitty keyboard protocol makes Shift+Enter (and other modified keys)
92    // distinguishable from plain Enter. Only push it on terminals known to
93    // support it; otherwise the enhancement sequence would leak as literal
94    // text on screen.
95    if supports_keyboard_enhancement() {
96        let _ = execute!(
97            backend,
98            PushKeyboardEnhancementFlags(
99                KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
100                    | KeyboardEnhancementFlags::REPORT_EVENT_TYPES,
101            )
102        );
103        terminal_guard.keyboard_enhancement = true;
104    }
105    let worker = thread::spawn(move || worker_loop(&mut harness, request_rx, message_tx, resumed));
106
107    let result = event_loop(
108        terminal_guard.terminal_mut(),
109        &mut state,
110        &request_tx,
111        &message_rx,
112    );
113
114    if let Some(token) = state.active_cancel.take() {
115        let _ = token.cancel();
116    }
117    let _ = request_tx.send(WorkerRequest::Shutdown);
118    wait_for_worker(worker, WORKER_SHUTDOWN_GRACE);
119    drop(terminal_guard);
120    result
121}
122
123fn worker_loop(
124    harness: &mut Harness,
125    requests: Receiver<WorkerRequest>,
126    messages: Sender<WorkerMessage>,
127    resumed: bool,
128) {
129    let mut sink = ChannelSink {
130        sender: messages.clone(),
131    };
132    if sink
133        .emit_event(&ProtocolEvent::Session {
134            session_id: harness.session.id.clone(),
135            resumed,
136        })
137        .is_err()
138    {
139        return;
140    }
141
142    while let Ok(request) = requests.recv() {
143        match request {
144            WorkerRequest::Turn { text, cancel } => {
145                if let Err(error) = harness.handle_message(&text, &mut sink, Some(&cancel)) {
146                    let message = redact_secret(&error, Some(harness.provider.api_key()));
147                    let _ = sink.emit_event(&ProtocolEvent::Error { message });
148                }
149                let _ = messages.send(WorkerMessage::Finished);
150            }
151            WorkerRequest::Shutdown => break,
152        }
153    }
154}
155
156fn event_loop<W: Write>(
157    terminal: &mut Terminal<CrosstermBackend<W>>,
158    state: &mut UiState,
159    requests: &Sender<WorkerRequest>,
160    messages: &Receiver<WorkerMessage>,
161) -> Result<(), String> {
162    let mut quitting = false;
163    loop {
164        loop {
165            match messages.try_recv() {
166                Ok(WorkerMessage::Event(event)) => state.apply_event(event),
167                Ok(WorkerMessage::Thinking) => state.show_thinking(),
168                Ok(WorkerMessage::SkillInstructionAttached) => {
169                    state.mark_latest_user_skill_attached()
170                }
171                Ok(WorkerMessage::ContextUsage(tokens)) => state.context_tokens = tokens,
172                Ok(WorkerMessage::CompactionStarted) => state.status = "compacting".to_owned(),
173                Ok(WorkerMessage::CompactionFinished {
174                    tokens_before,
175                    tokens_after,
176                }) => {
177                    state.context_tokens = tokens_after;
178                    state.status = "working".to_owned();
179                    state.transcript.push(TranscriptItem::Info(format!(
180                        "↻ context compacted ({} → {})",
181                        format_context_tokens(tokens_before),
182                        format_context_tokens(tokens_after)
183                    )));
184                }
185                Ok(WorkerMessage::Finished) => {
186                    release_finished_turn(terminal.backend_mut(), state);
187                    discard_pending_input()?;
188                    match state.status.as_str() {
189                        "cancelling" => state.status = "사용자 중단".to_owned(),
190                        "finalizing" => state.status = "ready".to_owned(),
191                        _ => {}
192                    }
193                    if quitting {
194                        return Ok(());
195                    }
196                }
197                Err(TryRecvError::Empty) => break,
198                Err(TryRecvError::Disconnected) => {
199                    if state.busy {
200                        return Err("TUI worker stopped unexpectedly".to_owned());
201                    }
202                    return Ok(());
203                }
204            }
205        }
206
207        terminal
208            .draw(|frame| draw(frame, state))
209            .map_err(|error| format!("unable to render TUI: {error}"))?;
210
211        if quitting {
212            thread::sleep(EVENT_POLL);
213            continue;
214        }
215        if event::poll(EVENT_POLL)
216            .map_err(|error| format!("unable to read terminal input: {error}"))?
217        {
218            let event =
219                event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
220            if let Event::Mouse(mouse) = event {
221                let size = terminal
222                    .size()
223                    .map_err(|error| format!("unable to read terminal size: {error}"))?;
224                let max_scroll = max_scroll_for_area(state, size);
225                handle_mouse_event(state, mouse.kind, max_scroll);
226                continue;
227            }
228            let Event::Key(key) = event else {
229                continue;
230            };
231            if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
232                continue;
233            }
234            if is_ctrl_c(&key) {
235                if let Some(token) = state.active_cancel.as_ref() {
236                    let _ = token.cancel();
237                    quitting = true;
238                } else {
239                    return Ok(());
240                }
241                continue;
242            }
243            if key.code == KeyCode::Esc {
244                if let Some(token) = state.active_cancel.as_ref() {
245                    if token.cancel() {
246                        state.status = "cancelling".to_owned();
247                    }
248                }
249                continue;
250            }
251            if state.busy {
252                // A turn owns the input line. ESC and Ctrl-C are the only
253                // controls accepted while provider/tool work is active.
254                continue;
255            }
256            match key.code {
257                KeyCode::Enter => {
258                    // Shift+Enter (and Alt+Enter fallback) insert a literal
259                    // newline so the user can write multi-line prompts. Plain
260                    // Enter sends the turn. Many terminals cannot distinguish
261                    // Shift+Enter from Enter, so Alt+Enter is also accepted.
262                    if key.modifiers.contains(KeyModifiers::SHIFT)
263                        || key.modifiers.contains(KeyModifiers::ALT)
264                    {
265                        if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
266                            insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
267                            state.input_changed();
268                        }
269                        continue;
270                    }
271                    if state.select_focused_skill() {
272                        continue;
273                    }
274                    let text = std::mem::take(&mut state.input);
275                    state.cursor = 0;
276                    state.reset_skill_picker();
277                    if text.trim().is_empty() {
278                        continue;
279                    }
280                    let secret = state.secret.clone();
281                    state.auto_scroll = true;
282                    state.scroll = 0;
283                    state.add_user(&text, &secret);
284                    let cancel = CancellationToken::new();
285                    state.active_cancel = Some(cancel.clone());
286                    state.busy = true;
287                    state.status = "working".to_owned();
288                    requests
289                        .send(WorkerRequest::Turn { text, cancel })
290                        .map_err(|_| "TUI worker is unavailable".to_owned())?;
291                }
292                KeyCode::Char(character) => {
293                    if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
294                        insert_at_cursor(&mut state.input, &mut state.cursor, character);
295                        state.input_changed();
296                    }
297                }
298                KeyCode::Backspace => {
299                    if remove_before_cursor(&mut state.input, &mut state.cursor) {
300                        state.input_changed();
301                    }
302                }
303                KeyCode::Left => {
304                    state.cursor = state.cursor.saturating_sub(1);
305                    state.cursor_epoch = Instant::now();
306                }
307                KeyCode::Right => {
308                    state.cursor = (state.cursor + 1).min(state.input.chars().count());
309                    state.cursor_epoch = Instant::now();
310                }
311                KeyCode::Home => {
312                    state.cursor = 0;
313                    state.cursor_epoch = Instant::now();
314                }
315                KeyCode::End => {
316                    state.cursor = state.input.chars().count();
317                    state.cursor_epoch = Instant::now();
318                }
319                KeyCode::Up => {
320                    if !state.move_skill_picker(false) {
321                        let size = terminal
322                            .size()
323                            .map_err(|error| format!("unable to read terminal size: {error}"))?;
324                        let max_scroll = max_scroll_for_area(state, size);
325                        scroll_up(state, max_scroll);
326                    }
327                }
328                KeyCode::Down => {
329                    if !state.move_skill_picker(true) {
330                        let size = terminal
331                            .size()
332                            .map_err(|error| format!("unable to read terminal size: {error}"))?;
333                        let max_scroll = max_scroll_for_area(state, size);
334                        scroll_down(state, max_scroll);
335                    }
336                }
337                KeyCode::PageUp => {
338                    let size = terminal
339                        .size()
340                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
341                    let max_scroll = max_scroll_for_area(state, size);
342                    scroll_up(state, max_scroll);
343                }
344                KeyCode::PageDown => {
345                    let size = terminal
346                        .size()
347                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
348                    let max_scroll = max_scroll_for_area(state, size);
349                    scroll_down(state, max_scroll);
350                }
351                _ => {}
352            }
353        }
354    }
355}
356
357fn discard_pending_input() -> Result<(), String> {
358    while event::poll(Duration::ZERO)
359        .map_err(|error| format!("unable to read terminal input: {error}"))?
360    {
361        let _ = event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
362    }
363    Ok(())
364}
365
366fn is_ctrl_c(key: &KeyEvent) -> bool {
367    key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
368}
369
370fn handle_mouse_event(state: &mut UiState, kind: MouseEventKind, max_scroll: u16) {
371    match kind {
372        MouseEventKind::ScrollUp => scroll_up(state, max_scroll),
373        MouseEventKind::ScrollDown => scroll_down(state, max_scroll),
374        _ => {}
375    }
376}
377
378fn scroll_up(state: &mut UiState, max_scroll: u16) {
379    if state.auto_scroll {
380        state.scroll = max_scroll;
381        state.auto_scroll = false;
382    } else {
383        state.scroll = state.scroll.min(max_scroll);
384    }
385    state.scroll = state.scroll.saturating_sub(3);
386}
387
388fn scroll_down(state: &mut UiState, max_scroll: u16) {
389    if state.auto_scroll {
390        return;
391    }
392    state.scroll = state.scroll.saturating_add(3).min(max_scroll);
393    if state.scroll == max_scroll {
394        // Reaching the real bottom is an explicit request to resume following
395        // the transcript, so subsequent streamed output stays visible.
396        state.auto_scroll = true;
397        state.scroll = 0;
398    }
399}
400
401fn wait_for_worker(worker: JoinHandle<()>, grace: Duration) {
402    let deadline = std::time::Instant::now() + grace;
403    while !worker.is_finished() && std::time::Instant::now() < deadline {
404        thread::sleep(Duration::from_millis(5));
405    }
406    if worker.is_finished() {
407        let _ = worker.join();
408    }
409}
410
411struct TerminalGuard<W: Write> {
412    terminal: Option<Terminal<CrosstermBackend<W>>>,
413    keyboard_enhancement: bool,
414}
415
416impl<W: Write> TerminalGuard<W> {
417    fn new(terminal: Terminal<CrosstermBackend<W>>) -> Self {
418        Self {
419            terminal: Some(terminal),
420            keyboard_enhancement: false,
421        }
422    }
423
424    fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {
425        self.terminal
426            .as_mut()
427            .expect("terminal guard is initialized")
428    }
429}
430
431impl<W: Write> Drop for TerminalGuard<W> {
432    fn drop(&mut self) {
433        let Some(mut terminal) = self.terminal.take() else {
434            return;
435        };
436        if self.keyboard_enhancement {
437            let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
438        }
439        let _ = terminal.show_cursor();
440        let _ = disable_raw_mode();
441        let _ = execute!(
442            terminal.backend_mut(),
443            DisableMouseCapture,
444            LeaveAlternateScreen,
445            Show
446        );
447        let _ = terminal.backend_mut().flush();
448    }
449}
450
451/// Heuristic for terminals that implement the kitty keyboard protocol.
452/// `PushKeyboardEnhancementFlags` is a no-op on supported terminals, but on
453/// unsupported ones the CSI sequence can render as literal text, so it is only
454/// enabled when the terminal advertises support via `TERM`/`TERM_PROGRAM`.
455fn supports_keyboard_enhancement() -> bool {
456    fn env(name: &str) -> Option<String> {
457        std::env::var(name).ok().map(|value| value.to_lowercase())
458    }
459    let term = env("TERM").unwrap_or_default();
460    let program = env("TERM_PROGRAM").unwrap_or_default();
461    if term.starts_with("xterm-kitty")
462        || term.starts_with("ghostty")
463        || term.starts_with("xterm-ghostty")
464    {
465        return true;
466    }
467    matches!(
468        program.as_str(),
469        "ghostty" | "kitty" | "wezterm" | "alacritty" | "foot" | "footclient" | "iterm.app"
470    )
471}
472
473#[derive(Debug, Clone, Copy, PartialEq, Eq)]
474enum TurnNotification {
475    Completed,
476    Interrupted,
477    Failed,
478}
479
480impl TurnNotification {
481    fn body(self) -> &'static str {
482        match self {
483            Self::Completed => "Turn complete",
484            Self::Interrupted => "Turn interrupted",
485            Self::Failed => "Turn failed",
486        }
487    }
488}
489
490fn turn_notification_for_status(status: &str) -> TurnNotification {
491    match status {
492        "cancelling" | "사용자 중단" => TurnNotification::Interrupted,
493        "error" => TurnNotification::Failed,
494        _ => TurnNotification::Completed,
495    }
496}
497
498/// Ask terminal emulators that support OSC 777 to show a desktop notification.
499///
500/// The title and body are fixed Lucy-owned strings rather than model/provider
501/// text, so completion notifications cannot inject terminal control data or
502/// expose a secret. Terminals without OSC 777 support safely ignore the OSC.
503fn send_turn_notification<W: Write>(
504    writer: &mut W,
505    notification: TurnNotification,
506) -> io::Result<()> {
507    writer.write_all(b"\x1b]777;notify;Lucy;")?;
508    writer.write_all(notification.body().as_bytes())?;
509    writer.write_all(b"\x07")?;
510    writer.flush()
511}
512
513fn release_finished_turn<W: Write>(writer: &mut W, state: &mut UiState) {
514    let was_busy = state.busy;
515    let notification = turn_notification_for_status(&state.status);
516    state.busy = false;
517    state.active_cancel = None;
518    if was_busy {
519        // Notification failure must never change the completed turn result or
520        // make the TUI unusable.
521        let _ = send_turn_notification(writer, notification);
522    }
523}
524
525enum WorkerRequest {
526    Turn {
527        text: String,
528        cancel: CancellationToken,
529    },
530    Shutdown,
531}
532
533enum WorkerMessage {
534    Event(ProtocolEvent),
535    Thinking,
536    SkillInstructionAttached,
537    ContextUsage(usize),
538    CompactionStarted,
539    CompactionFinished {
540        tokens_before: usize,
541        tokens_after: usize,
542    },
543    Finished,
544}
545
546struct ChannelSink {
547    sender: Sender<WorkerMessage>,
548}
549
550impl EventSink for ChannelSink {
551    fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
552        self.sender
553            .send(WorkerMessage::Event(event.clone()))
554            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
555    }
556
557    fn reasoning_started(&mut self) -> io::Result<()> {
558        self.sender
559            .send(WorkerMessage::Thinking)
560            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
561    }
562
563    fn skill_instruction_attached(&mut self, _name: &str) -> io::Result<()> {
564        self.sender
565            .send(WorkerMessage::SkillInstructionAttached)
566            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
567    }
568
569    fn context_usage(&mut self, tokens: usize) -> io::Result<()> {
570        self.sender
571            .send(WorkerMessage::ContextUsage(tokens))
572            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
573    }
574
575    fn compaction_started(&mut self) -> io::Result<()> {
576        self.sender
577            .send(WorkerMessage::CompactionStarted)
578            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
579    }
580
581    fn compaction_finished(&mut self, tokens_before: usize, tokens_after: usize) -> io::Result<()> {
582        self.sender
583            .send(WorkerMessage::CompactionFinished {
584                tokens_before,
585                tokens_after,
586            })
587            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
588    }
589}
590
591struct UiState {
592    model: String,
593    effort: Option<String>,
594    context_window: Option<usize>,
595    context_tokens: usize,
596    secret: String,
597    transcript: Vec<TranscriptItem>,
598    input: String,
599    cursor: usize,
600    status: String,
601    busy: bool,
602    active_cancel: Option<CancellationToken>,
603    scroll: u16,
604    auto_scroll: bool,
605    cursor_epoch: Instant,
606    welcome_visible: bool,
607    attached_agents: Vec<String>,
608    skill_names: Vec<String>,
609    skill_picker_focus: usize,
610    skill_picker_suppressed: bool,
611}
612
613impl UiState {
614    fn from_history(
615        history: &[SessionHistoryRecord],
616        secret: &str,
617        model: &str,
618        effort: Option<&str>,
619        resumed: bool,
620    ) -> Self {
621        let mut state = Self {
622            model: model.to_owned(),
623            effort: effort.map(str::to_owned),
624            context_window: None,
625            context_tokens: 1,
626            secret: secret.to_owned(),
627            transcript: Vec::new(),
628            input: String::new(),
629            cursor: 0,
630            status: "ready".to_owned(),
631            busy: false,
632            active_cancel: None,
633            scroll: 0,
634            auto_scroll: true,
635            cursor_epoch: Instant::now(),
636            welcome_visible: !resumed && history.is_empty(),
637            attached_agents: Vec::new(),
638            skill_names: Vec::new(),
639            skill_picker_focus: 0,
640            skill_picker_suppressed: false,
641        };
642        for record in history {
643            state.add_history_record(record);
644        }
645        state
646    }
647
648    fn with_attached_agents(mut self, attached_agents: Vec<String>) -> Self {
649        self.attached_agents = attached_agents;
650        self
651    }
652
653    fn with_skill_names(mut self, skill_names: Vec<String>) -> Self {
654        self.skill_names = skill_names;
655        self
656    }
657
658    fn with_context(mut self, context_window: Option<usize>, context_tokens: usize) -> Self {
659        self.context_window = context_window;
660        self.context_tokens = context_tokens.max(1);
661        self
662    }
663
664    /// Return matching skills only while the first input character is `/` and
665    /// the user is still writing the command name (rather than its arguments).
666    fn matching_skill_names(&self) -> Vec<&str> {
667        matching_skill_names(&self.input, &self.skill_names)
668    }
669
670    fn reset_skill_picker(&mut self) {
671        self.skill_picker_focus = 0;
672        self.skill_picker_suppressed = false;
673    }
674
675    fn skill_picker_visible(&self) -> bool {
676        !self.skill_picker_suppressed && !self.matching_skill_names().is_empty()
677    }
678
679    fn input_changed(&mut self) {
680        self.reset_skill_picker();
681        self.cursor_epoch = Instant::now();
682    }
683
684    /// Move through the current filter result without wrapping at its ends.
685    /// Returning false lets the caller retain normal transcript scrolling when
686    /// no slash picker is active.
687    fn move_skill_picker(&mut self, down: bool) -> bool {
688        let match_count = self.matching_skill_names().len();
689        if self.skill_picker_suppressed || match_count == 0 {
690            return false;
691        }
692        if down {
693            self.skill_picker_focus = (self.skill_picker_focus + 1).min(match_count - 1);
694        } else {
695            self.skill_picker_focus = self.skill_picker_focus.saturating_sub(1);
696        }
697        true
698    }
699
700    /// Replace the slash query with the focused explicit skill command. The
701    /// normal Enter path then sends that command and the existing turn engine
702    /// attaches the immutable session skill snapshot.
703    fn select_focused_skill(&mut self) -> bool {
704        if self.skill_picker_suppressed {
705            return false;
706        }
707        let Some(name) = self
708            .matching_skill_names()
709            .get(self.skill_picker_focus)
710            .map(|name| (*name).to_owned())
711        else {
712            return false;
713        };
714        self.input = format!("/{name}");
715        self.cursor = self.input.chars().count();
716        // The first Enter chooses a skill; a second Enter sends the completed
717        // command to the normal attachment path.
718        self.skill_picker_suppressed = true;
719        self.cursor_epoch = Instant::now();
720        true
721    }
722
723    fn add_history_record(&mut self, record: &SessionHistoryRecord) {
724        match record {
725            SessionHistoryRecord::Message { message, .. } => self.add_message(message),
726            SessionHistoryRecord::Interruption {
727                assistant_text,
728                tool_calls,
729                tool_results,
730                reason,
731                phase,
732                ..
733            } => {
734                if !assistant_text.is_empty() {
735                    self.add_assistant_message(assistant_text);
736                }
737                for call in tool_calls {
738                    self.add_tool_call(call);
739                }
740                for observation in tool_results {
741                    self.add_tool_result(
742                        &observation.id,
743                        &observation.name,
744                        observation.result.clone(),
745                    );
746                }
747                self.transcript
748                    .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
749            }
750            SessionHistoryRecord::Compaction(compaction) => {
751                self.transcript.push(TranscriptItem::Info(format!(
752                    "↻ context compacted ({} before)",
753                    format_context_tokens(compaction.tokens_before)
754                )));
755            }
756        }
757    }
758
759    fn add_message(&mut self, message: &ChatMessage) {
760        match message.role.as_str() {
761            "user" => {
762                let secret = self.secret.clone();
763                self.add_user(message.content.as_deref().unwrap_or(""), &secret);
764            }
765            "assistant" => {
766                if let Some(content) = message.content.as_deref() {
767                    self.add_assistant_message(content);
768                }
769                for call in &message.tool_calls {
770                    self.add_tool_call(call);
771                }
772            }
773            "tool" => {
774                let result = message
775                    .content
776                    .as_deref()
777                    .and_then(|content| serde_json::from_str::<Value>(content).ok())
778                    .unwrap_or_else(|| Value::String(message.content.clone().unwrap_or_default()));
779                self.add_tool_result(
780                    message.tool_call_id.as_deref().unwrap_or(""),
781                    message.name.as_deref().unwrap_or("cmd"),
782                    result,
783                );
784            }
785            _ => {}
786        }
787    }
788
789    fn add_user(&mut self, text: &str, secret: &str) {
790        self.welcome_visible = false;
791        self.transcript.push(TranscriptItem::User {
792            text: redact_secret(text, Some(secret)),
793            skill_instruction_attached: false,
794        });
795    }
796
797    fn mark_latest_user_skill_attached(&mut self) {
798        if let Some(TranscriptItem::User {
799            skill_instruction_attached,
800            ..
801        }) = self.transcript.last_mut()
802        {
803            *skill_instruction_attached = true;
804        }
805    }
806
807    fn clear_thinking(&mut self) {
808        if matches!(self.transcript.last(), Some(TranscriptItem::Thinking)) {
809            self.transcript.pop();
810        }
811    }
812
813    fn show_thinking(&mut self) {
814        self.status = "working".to_owned();
815        if !matches!(self.transcript.last(), Some(TranscriptItem::Thinking)) {
816            self.transcript.push(TranscriptItem::Thinking);
817        }
818    }
819
820    fn add_assistant(&mut self, text: &str) {
821        self.clear_thinking();
822        if let Some(TranscriptItem::Assistant(current)) = self.transcript.last_mut() {
823            current.push_str(text);
824        } else {
825            self.add_assistant_message(text);
826        }
827    }
828
829    fn add_assistant_message(&mut self, text: &str) {
830        self.transcript
831            .push(TranscriptItem::Assistant(text.to_owned()));
832    }
833
834    fn add_tool_call(&mut self, call: &crate::model::ChatToolCall) {
835        self.clear_thinking();
836        self.transcript.push(TranscriptItem::ToolCall {
837            id: call.id.clone(),
838            name: call.name.clone(),
839            arguments: call.arguments.clone(),
840        });
841    }
842
843    fn add_tool_result(&mut self, id: &str, name: &str, result: Value) {
844        self.transcript.push(TranscriptItem::ToolResult {
845            id: id.to_owned(),
846            name: name.to_owned(),
847            result,
848        });
849    }
850
851    fn cursor_visible(&self) -> bool {
852        !self.busy
853            && (self.cursor_epoch.elapsed().as_millis() / CURSOR_BLINK_INTERVAL.as_millis())
854                .is_multiple_of(2)
855    }
856
857    fn apply_event(&mut self, event: ProtocolEvent) {
858        match event {
859            ProtocolEvent::Session { .. } => {}
860            ProtocolEvent::AssistantDelta { text } => self.add_assistant(&text),
861            ProtocolEvent::ToolCall {
862                id,
863                name,
864                arguments,
865            } => self.add_tool_call(&crate::model::ChatToolCall {
866                id,
867                name,
868                arguments,
869            }),
870            ProtocolEvent::ToolResult { id, name, result } => {
871                self.add_tool_result(&id, &name, result)
872            }
873            ProtocolEvent::TurnEnd => {
874                self.clear_thinking();
875                self.status = "finalizing".to_owned();
876                self.transcript
877                    .push(TranscriptItem::Info("✓ turn complete".to_owned()));
878            }
879            ProtocolEvent::TurnInterrupted { reason, phase } => {
880                self.status = "cancelling".to_owned();
881                self.transcript
882                    .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
883            }
884            ProtocolEvent::Error { message } => {
885                self.status = "error".to_owned();
886                self.transcript.push(TranscriptItem::Error(message));
887            }
888        }
889    }
890}
891
892#[derive(Debug, Clone, PartialEq)]
893enum TranscriptItem {
894    User {
895        text: String,
896        skill_instruction_attached: bool,
897    },
898    Assistant(String),
899    ToolCall {
900        id: String,
901        name: String,
902        arguments: String,
903    },
904    ToolResult {
905        id: String,
906        name: String,
907        result: Value,
908    },
909    Error(String),
910    Info(String),
911    Thinking,
912}
913
914fn ui_layout(state: &UiState, area: Rect) -> (Rect, Option<Rect>, Rect, Rect) {
915    let input_rows = input_visible_rows(state, area.width.saturating_sub(2));
916    let input_height = input_rows.clamp(1, MAX_INPUT_ROWS) + 2;
917    let chunks = Layout::default()
918        .direction(Direction::Vertical)
919        .constraints([
920            Constraint::Min(1),
921            Constraint::Length(input_height),
922            Constraint::Length(1),
923        ])
924        .split(area);
925    let picker_height = skill_picker_height(state);
926    let picker_area = (picker_height > 0).then(|| {
927        Rect::new(
928            chunks[1].x,
929            chunks[1].y.saturating_sub(picker_height),
930            chunks[1].width,
931            picker_height,
932        )
933    });
934    (chunks[0], picker_area, chunks[1], chunks[2])
935}
936
937fn max_scroll_for_area(state: &UiState, size: Size) -> u16 {
938    let area = Rect::new(0, 0, size.width, size.height);
939    let (chat_chunk, picker_area, _, _) = ui_layout(state, area);
940    let chat_height = chat_chunk.height.saturating_sub(1);
941    let chat_area = Rect::new(chat_chunk.x, chat_chunk.y, chat_chunk.width, chat_height);
942    let visible_chat_height = visible_chat_height(chat_area, picker_area);
943    let lines = transcript_lines(state, chat_chunk.width);
944    lines
945        .len()
946        .saturating_sub(visible_chat_height as usize)
947        .min(u16::MAX as usize) as u16
948}
949
950/// Return the transcript rows that remain visible after the picker overlays the
951/// bottom of the chat viewport. The activity row is excluded by the caller;
952/// the picker intentionally covers that row as well.
953fn visible_chat_height(chat_area: Rect, picker_area: Option<Rect>) -> u16 {
954    let Some(picker_area) = picker_area else {
955        return chat_area.height;
956    };
957    let overlap_start = chat_area.y.max(picker_area.y);
958    let overlap_end = chat_area
959        .y
960        .saturating_add(chat_area.height)
961        .min(picker_area.y.saturating_add(picker_area.height));
962    chat_area
963        .height
964        .saturating_sub(overlap_end.saturating_sub(overlap_start))
965}
966
967/// Number of wrapped rows the current input occupies at `width`.
968fn input_visible_rows(state: &UiState, width: u16) -> u16 {
969    let width = width as usize;
970    if width == 0 {
971        return 1;
972    }
973    let prompt = input_display_text(state);
974    let wrapped = wrap_text(&prompt, width);
975    wrapped.len().max(1) as u16
976}
977
978fn input_prompt(input: &str) -> String {
979    input.to_owned()
980}
981
982fn input_display_text(state: &UiState) -> String {
983    if state.busy {
984        WAITING_INPUT_MESSAGE.to_owned()
985    } else {
986        redact_secret(&input_prompt(&state.input), Some(&state.secret))
987    }
988}
989
990/// The slash picker only accepts a command at the beginning of the message.
991/// Once whitespace starts arguments, normal message entry resumes.
992fn matching_skill_names<'a>(input: &str, skill_names: &'a [String]) -> Vec<&'a str> {
993    let Some(query) = input.strip_prefix('/') else {
994        return Vec::new();
995    };
996    if query.chars().any(char::is_whitespace) {
997        return Vec::new();
998    }
999    skill_names
1000        .iter()
1001        .map(String::as_str)
1002        .filter(|name| name.starts_with(query))
1003        .collect()
1004}
1005
1006fn skill_picker_height(state: &UiState) -> u16 {
1007    if state.skill_picker_visible() {
1008        (state
1009            .matching_skill_names()
1010            .len()
1011            .min(SKILL_PICKER_MAX_ROWS)
1012            + 1) as u16
1013    } else {
1014        0
1015    }
1016}
1017
1018fn skill_picker_range(total: usize, focus: usize) -> std::ops::Range<usize> {
1019    let start = focus
1020        .saturating_add(1)
1021        .saturating_sub(SKILL_PICKER_MAX_ROWS);
1022    start.min(total)..(start + SKILL_PICKER_MAX_ROWS).min(total)
1023}
1024
1025/// Return the command portion of a currently valid explicit skill invocation.
1026/// This mirrors the command grammar used by the turn engine, while keeping the
1027/// styling concern local to the TUI.
1028fn active_skill_trigger<'a>(input: &'a str, skill_names: &[String]) -> Option<&'a str> {
1029    let invocation = input.strip_prefix('/')?;
1030    let name = invocation
1031        .split_once(char::is_whitespace)
1032        .map_or(invocation, |(name, _)| name);
1033    if name.is_empty() || !skill_names.iter().any(|skill_name| skill_name == name) {
1034        return None;
1035    }
1036    Some(&input[..1 + name.len()])
1037}
1038
1039/// Preserve input wrapping while styling a recognized `/<name>` prefix
1040/// independently from any arguments the user is still entering.
1041fn styled_text_lines(
1042    input: &str,
1043    active_skill_trigger: Option<&str>,
1044    width: usize,
1045    text_style: Style,
1046) -> Vec<Line<'static>> {
1047    let trigger_len = active_skill_trigger.map_or(0, |trigger| trigger.chars().count());
1048    let mut char_offset = 0usize;
1049    let mut lines = Vec::new();
1050
1051    for source_line in input.split('\n') {
1052        for row in wrap_line(source_line, width) {
1053            let mut spans = Vec::new();
1054            let mut text = String::new();
1055            let mut highlighted = None;
1056            for character in row.chars() {
1057                let should_highlight = char_offset < trigger_len;
1058                if highlighted != Some(should_highlight) && !text.is_empty() {
1059                    spans.push(styled_text_span(
1060                        std::mem::take(&mut text),
1061                        highlighted.unwrap_or(false),
1062                        text_style,
1063                    ));
1064                }
1065                highlighted = Some(should_highlight);
1066                text.push(character);
1067                char_offset += 1;
1068            }
1069            if !text.is_empty() {
1070                spans.push(styled_text_span(
1071                    text,
1072                    highlighted.unwrap_or(false),
1073                    text_style,
1074                ));
1075            }
1076            if spans.is_empty() {
1077                spans.push(Span::styled(String::new(), text_style));
1078            }
1079            lines.push(Line::from(spans));
1080        }
1081        // `split` retains empty trailing lines; account for the newline that
1082        // separated this source line from the next one in the character index.
1083        char_offset += 1;
1084    }
1085
1086    lines
1087}
1088
1089fn styled_text_span(text: String, highlighted: bool, text_style: Style) -> Span<'static> {
1090    if highlighted {
1091        Span::styled(text, Style::default().fg(Color::Cyan))
1092    } else {
1093        Span::styled(text, text_style)
1094    }
1095}
1096
1097fn cursor_row(input: &str, cursor: usize, width: usize) -> u16 {
1098    let prefix: String = input.chars().take(cursor).collect();
1099    wrap_text(&prefix, width)
1100        .len()
1101        .saturating_sub(1)
1102        .min(u16::MAX as usize) as u16
1103}
1104
1105fn insert_at_cursor(input: &mut String, cursor: &mut usize, character: char) {
1106    let byte_index = input
1107        .char_indices()
1108        .nth(*cursor)
1109        .map_or(input.len(), |(index, _)| index);
1110    input.insert(byte_index, character);
1111    *cursor += 1;
1112}
1113
1114fn remove_before_cursor(input: &mut String, cursor: &mut usize) -> bool {
1115    if *cursor == 0 {
1116        return false;
1117    }
1118    let end = input
1119        .char_indices()
1120        .nth(*cursor)
1121        .map_or(input.len(), |(index, _)| index);
1122    let start = input
1123        .char_indices()
1124        .nth(*cursor - 1)
1125        .map(|(index, _)| index)
1126        .unwrap_or(0);
1127    input.replace_range(start..end, "");
1128    *cursor -= 1;
1129    true
1130}
1131
1132fn draw(frame: &mut Frame<'_>, state: &UiState) {
1133    let (chat_chunk, picker_area, input_chunk, status_area) = ui_layout(state, frame.area());
1134
1135    // Activity is conversation state, not terminal help text. Keep it in the
1136    // chat viewport so the picker can intentionally overlay its ready state.
1137    let chat_height = chat_chunk.height.saturating_sub(1);
1138    let chat_area = Rect::new(chat_chunk.x, chat_chunk.y, chat_chunk.width, chat_height);
1139    let visible_chat_height = visible_chat_height(chat_area, picker_area);
1140    let visible_chat_area = Rect::new(
1141        chat_area.x,
1142        chat_area.y,
1143        chat_area.width,
1144        visible_chat_height,
1145    );
1146    let activity_area = Rect::new(
1147        chat_chunk.x,
1148        chat_chunk.y + chat_height,
1149        chat_chunk.width,
1150        chat_chunk.height - chat_height,
1151    );
1152
1153    let width = chat_chunk.width;
1154    if state.welcome_visible {
1155        let welcome_lines = welcome_lines(&state.attached_agents);
1156        let welcome_height = (welcome_lines.len() as u16).min(visible_chat_area.height);
1157        let welcome_area = Rect::new(
1158            visible_chat_area.x,
1159            visible_chat_area.y + visible_chat_area.height.saturating_sub(welcome_height) / 2,
1160            visible_chat_area.width,
1161            welcome_height,
1162        );
1163        let welcome = Paragraph::new(welcome_lines).alignment(Alignment::Center);
1164        frame.render_widget(welcome, welcome_area);
1165    } else {
1166        let lines = transcript_lines(state, width);
1167        let available = visible_chat_area.height as usize;
1168        let max_scroll = lines.len().saturating_sub(available).min(u16::MAX as usize) as u16;
1169        let scroll = if state.auto_scroll {
1170            max_scroll
1171        } else {
1172            state.scroll.min(max_scroll)
1173        };
1174        let transcript = Paragraph::new(lines).scroll((scroll, 0));
1175        frame.render_widget(transcript, visible_chat_area);
1176    }
1177
1178    let activity_text = if state.status == "working" {
1179        format!("{} working", spinner_frame(state))
1180    } else if state.status == "compacting" {
1181        format!("{} compacting", spinner_frame(state))
1182    } else {
1183        format!("● {}", state.status)
1184    };
1185    let activity = Line::styled(activity_text, activity_style_for(state));
1186    frame.render_widget(Paragraph::new(activity), activity_area);
1187
1188    if let Some(picker_area) = picker_area {
1189        draw_skill_picker(frame, state, picker_area);
1190    }
1191
1192    let input_style = if state.busy {
1193        waiting_input_style()
1194    } else {
1195        user_message_style()
1196    };
1197    let input_text_style = if state.busy {
1198        waiting_input_style()
1199    } else {
1200        Style::default().fg(Color::White)
1201    };
1202    let input_block = Block::default()
1203        .borders(Borders::ALL)
1204        .border_type(ratatui::widgets::BorderType::Rounded)
1205        .border_style(input_style);
1206    let input_area = input_block.inner(input_chunk);
1207    let prompt = input_display_text(state);
1208    let input_rows =
1209        input_visible_rows(state, frame.area().width.saturating_sub(2)).clamp(1, MAX_INPUT_ROWS);
1210    let wrapped = wrap_text(&prompt, input_area.width.max(1) as usize);
1211    let visible = (wrapped.len() as u16).clamp(1, input_rows);
1212    let cursor_row = cursor_row(&prompt, state.cursor, input_area.width.max(1) as usize);
1213    let bottom_scroll = (wrapped.len() as u16).saturating_sub(visible);
1214    let cursor_scroll = (cursor_row + 1).saturating_sub(visible);
1215    let input_scroll = bottom_scroll.min(cursor_scroll);
1216    let active_skill_trigger = (!state.busy)
1217        .then(|| active_skill_trigger(&prompt, &state.skill_names))
1218        .flatten();
1219    let input_lines = styled_text_lines(
1220        &prompt,
1221        active_skill_trigger,
1222        input_area.width.max(1) as usize,
1223        input_text_style,
1224    );
1225    let input = Paragraph::new(input_lines)
1226        .style(input_text_style)
1227        .scroll((input_scroll, 0))
1228        .block(input_block);
1229    frame.render_widget(input, input_chunk);
1230
1231    let effort = state.effort.as_deref().unwrap_or("default");
1232    let status_text = format!("model={} · effort={effort}", state.model);
1233    let context_text = context_status_text(state);
1234    let context_width = UnicodeWidthStr::width(context_text.as_str()) as u16;
1235    let context_area_width = context_width.min(status_area.width.saturating_sub(1));
1236    let status_chunks = Layout::default()
1237        .direction(Direction::Horizontal)
1238        .constraints([Constraint::Min(1), Constraint::Length(context_area_width)])
1239        .split(status_area);
1240    let status = Paragraph::new(redact_secret(&status_text, Some(&state.secret)))
1241        .style(Style::default().fg(Color::DarkGray));
1242    frame.render_widget(status, status_chunks[0]);
1243    if context_area_width > 0 {
1244        let context = Paragraph::new(context_text)
1245            .alignment(Alignment::Right)
1246            .style(context_status_style(state));
1247        frame.render_widget(context, status_chunks[1]);
1248    }
1249    // Ratatui shows the cursor when a frame requests a position and hides it
1250    // when this branch is skipped, which provides the blink phase.
1251    if state.cursor_visible() && !input_area.is_empty() && visible > 0 {
1252        let cursor_prefix: String = prompt.chars().take(state.cursor).collect();
1253        let cursor_rows = wrap_text(&cursor_prefix, input_area.width.max(1) as usize);
1254        let cursor_line = cursor_rows.last().map(String::as_str).unwrap_or("");
1255        let cursor_offset = UnicodeWidthStr::width(cursor_line) as u16;
1256        let cursor_x = input_area.x + cursor_offset.min(input_area.width.saturating_sub(1));
1257        let cursor_y = input_area.y + cursor_row.saturating_sub(input_scroll);
1258        frame.set_cursor_position((cursor_x, cursor_y));
1259    }
1260}
1261
1262fn draw_skill_picker(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
1263    let matches = state.matching_skill_names();
1264    let total = matches.len();
1265    if total == 0 || area.is_empty() {
1266        return;
1267    }
1268    // The picker intentionally overlays the activity row, including `ready`.
1269    // Clear the full row first so a short skill name cannot leave stale status
1270    // characters visible after the picker has been rendered.
1271    frame.render_widget(Clear, area);
1272    let focus = state.skill_picker_focus.min(total - 1);
1273    let header = Line::styled(
1274        format!("[{}/{}]", focus + 1, total),
1275        Style::default().fg(Color::DarkGray),
1276    );
1277    frame.render_widget(
1278        Paragraph::new(header),
1279        Rect::new(area.x, area.y, area.width, 1),
1280    );
1281
1282    for (row, index) in skill_picker_range(total, focus).enumerate() {
1283        let style = if index == focus {
1284            Style::default().fg(Color::Cyan)
1285        } else {
1286            Style::default().fg(Color::DarkGray)
1287        };
1288        let skill = Line::styled(format!("/{}", matches[index]), style);
1289        frame.render_widget(
1290            Paragraph::new(skill),
1291            Rect::new(area.x, area.y + 1 + row as u16, area.width, 1),
1292        );
1293    }
1294}
1295
1296fn welcome_line() -> Line<'static> {
1297    let character_count = WELCOME_MESSAGE.chars().count();
1298    let spans = WELCOME_MESSAGE
1299        .chars()
1300        .enumerate()
1301        .map(|(index, character)| {
1302            let progress = if character_count <= 1 {
1303                0.0
1304            } else {
1305                index as f32 / (character_count - 1) as f32
1306            };
1307            let color = Color::Rgb(
1308                interpolate_color(WELCOME_START_COLOR.0, WELCOME_END_COLOR.0, progress),
1309                interpolate_color(WELCOME_START_COLOR.1, WELCOME_END_COLOR.1, progress),
1310                interpolate_color(WELCOME_START_COLOR.2, WELCOME_END_COLOR.2, progress),
1311            );
1312            Span::styled(character.to_string(), Style::default().fg(color))
1313        })
1314        .collect::<Vec<_>>();
1315    Line::from(spans)
1316}
1317
1318fn interpolate_color(start: u8, end: u8, progress: f32) -> u8 {
1319    (start as f32 + (end as f32 - start as f32) * progress).round() as u8
1320}
1321
1322fn welcome_lines(attached_agents: &[String]) -> Vec<Line<'static>> {
1323    let mut lines = vec![
1324        welcome_line(),
1325        Line::styled(WELCOME_TAGLINE, Style::default().fg(Color::DarkGray)),
1326        Line::raw(""),
1327    ];
1328
1329    if attached_agents.is_empty() {
1330        lines.push(Line::styled(
1331            "Attached AGENTS.md: none",
1332            Style::default().fg(Color::DarkGray),
1333        ));
1334    } else {
1335        lines.push(Line::styled(
1336            "Attached AGENTS.md:",
1337            Style::default().fg(Color::DarkGray),
1338        ));
1339        lines.extend(
1340            attached_agents.iter().map(|path| {
1341                Line::styled(format!("• {path}"), Style::default().fg(Color::DarkGray))
1342            }),
1343        );
1344    }
1345
1346    lines
1347}
1348
1349fn transcript_lines(state: &UiState, width: u16) -> Vec<Line<'static>> {
1350    let width = width.max(1) as usize;
1351    let mut lines = Vec::new();
1352    let mut rendered_item = false;
1353
1354    for (index, item) in state.transcript.iter().enumerate() {
1355        // Results are positioned on their matching call, even when the model
1356        // emitted several calls before execution produced any result.
1357        if is_result_attached_to_call(&state.transcript, index) {
1358            continue;
1359        }
1360        if rendered_item {
1361            lines.push(Line::raw(String::new()));
1362        }
1363        match item {
1364            TranscriptItem::User {
1365                text,
1366                skill_instruction_attached,
1367            } => {
1368                let text = redact_secret(text, Some(&state.secret));
1369                let trigger = skill_instruction_attached
1370                    .then(|| active_skill_trigger(&text, &state.skill_names))
1371                    .flatten();
1372                push_user_message_block(&mut lines, &text, trigger, width);
1373            }
1374            TranscriptItem::Assistant(text) => {
1375                let text = redact_secret(text, Some(&state.secret));
1376                push_wrapped(&mut lines, &text, width, Style::default());
1377            }
1378            TranscriptItem::ToolCall {
1379                id,
1380                name,
1381                arguments,
1382            } => {
1383                let call_text = format!("[tool:{name} {}]", call_arguments(arguments));
1384                let call_text = redact_secret(&call_text, Some(&state.secret));
1385                let result = matching_tool_result(&state.transcript, index, id);
1386                let mut segments = vec![(
1387                    call_text,
1388                    if result.is_some() {
1389                        tool_call_style()
1390                    } else {
1391                        pending_tool_call_style()
1392                    },
1393                )];
1394                if let Some(result) = result {
1395                    let result_text =
1396                        redact_secret(&format_tool_result(result), Some(&state.secret));
1397                    segments.push((" > ".to_owned(), Style::default()));
1398                    segments.push((result_text, tool_result_style()));
1399                } else {
1400                    segments.push((
1401                        format!(" {}", spinner_frame(state)),
1402                        pending_tool_call_style(),
1403                    ));
1404                }
1405                push_spans_wrapped(&mut lines, &segments, width);
1406            }
1407            TranscriptItem::ToolResult {
1408                id: _,
1409                name: _,
1410                result,
1411            } => {
1412                let result_text = format_tool_result(result);
1413                let result_text = redact_secret(&result_text, Some(&state.secret));
1414                push_spans_wrapped(&mut lines, &[(result_text, tool_result_style())], width);
1415            }
1416            TranscriptItem::Error(text) => {
1417                let text = redact_secret(text, Some(&state.secret));
1418                push_wrapped(&mut lines, &text, width, error_style());
1419            }
1420            TranscriptItem::Info(text) => {
1421                let text = redact_secret(text, Some(&state.secret));
1422                push_wrapped(&mut lines, &text, width, info_style());
1423            }
1424            TranscriptItem::Thinking => {
1425                let text = format!("{} Thinking...", spinner_frame(state));
1426                push_wrapped(&mut lines, &text, width, thinking_style());
1427            }
1428        }
1429        rendered_item = true;
1430    }
1431    if lines.is_empty() {
1432        lines.push(Line::raw(""));
1433    }
1434    lines
1435}
1436
1437fn matching_tool_result<'a>(
1438    transcript: &'a [TranscriptItem],
1439    call_index: usize,
1440    call_id: &str,
1441) -> Option<&'a Value> {
1442    transcript
1443        .iter()
1444        .skip(call_index + 1)
1445        .find_map(|item| match item {
1446            TranscriptItem::ToolResult { id, result, .. } if id == call_id => Some(result),
1447            _ => None,
1448        })
1449}
1450
1451fn is_result_attached_to_call(transcript: &[TranscriptItem], result_index: usize) -> bool {
1452    let TranscriptItem::ToolResult { id, .. } = &transcript[result_index] else {
1453        return false;
1454    };
1455    let Some(call_index) = transcript[..result_index].iter().rposition(
1456        |item| matches!(item, TranscriptItem::ToolCall { id: call_id, .. } if call_id == id),
1457    ) else {
1458        return false;
1459    };
1460    !transcript[call_index + 1..result_index].iter().any(
1461        |item| matches!(item, TranscriptItem::ToolResult { id: result_id, .. } if result_id == id),
1462    )
1463}
1464
1465/// Render tool call arguments as the command string inside double quotes, for
1466/// example `"cat README.md"`. Previews are truncated to the same limit as tool
1467/// results; malformed arguments fall back to truncated raw text.
1468fn call_arguments(arguments: &str) -> String {
1469    let parsed: Value = match serde_json::from_str(arguments) {
1470        Ok(value) => value,
1471        Err(_) => return truncate_output(arguments),
1472    };
1473    if let Some(command) = parsed.get("command").and_then(Value::as_str) {
1474        return format!("\"{}\"", truncate_output(command));
1475    }
1476    let serialized = serde_json::to_string(&parsed).unwrap_or_else(|_| arguments.to_owned());
1477    truncate_output(&serialized)
1478}
1479
1480/// Render a tool result as a single-line JSON-string-array literal containing
1481/// stdout (or stderr when stdout is empty). Newlines are escaped so the whole
1482/// result stays on one line. Output is truncated to `RESULT_PREVIEW_CHARS`.
1483fn format_tool_result(result: &Value) -> String {
1484    let stdout = result.get("stdout").and_then(Value::as_str).unwrap_or("");
1485    let stderr = result.get("stderr").and_then(Value::as_str).unwrap_or("");
1486    let output = if !stdout.is_empty() { stdout } else { stderr };
1487    let truncated = truncate_output(output);
1488    // Build a JSON string literal so newlines and quotes are escaped and the
1489    // result renders on a single line as `["..."]`.
1490    let json_string = serde_json::to_string(&truncated).unwrap_or_else(|_| "\"\"".to_owned());
1491    format!("[{json_string}]")
1492}
1493
1494const RESULT_PREVIEW_CHARS: usize = 50;
1495
1496fn truncate_output(output: &str) -> String {
1497    let mut result: String = output.chars().take(RESULT_PREVIEW_CHARS).collect();
1498    if output.chars().count() > RESULT_PREVIEW_CHARS {
1499        result.push('…');
1500    }
1501    result
1502}
1503
1504fn user_message_style() -> Style {
1505    Style::default().fg(USER_BORDER_COLOR)
1506}
1507
1508/// Render a full-width rounded outline so user messages visually match the
1509/// rounded prompt border while assistant and tool output stays borderless.
1510fn push_user_message_block(
1511    lines: &mut Vec<Line<'static>>,
1512    text: &str,
1513    active_skill_trigger: Option<&str>,
1514    width: usize,
1515) {
1516    if width < 2 {
1517        lines.extend(styled_text_lines(
1518            text,
1519            active_skill_trigger,
1520            width.max(1),
1521            Style::default().fg(Color::White),
1522        ));
1523        return;
1524    }
1525
1526    let content_width = width - 2;
1527    let border_style = user_message_style();
1528    let rows = styled_text_lines(
1529        text,
1530        active_skill_trigger,
1531        content_width,
1532        Style::default().fg(Color::White),
1533    );
1534    lines.push(Line::styled(
1535        format!("╭{}╮", "─".repeat(content_width)),
1536        border_style,
1537    ));
1538    for row in rows {
1539        let row_width = UnicodeWidthStr::width(row.to_string().as_str());
1540        let padding = content_width.saturating_sub(row_width);
1541        let mut spans = Vec::with_capacity(row.spans.len() + 3);
1542        spans.push(Span::styled("│", border_style));
1543        spans.extend(row.spans);
1544        spans.push(Span::styled(
1545            " ".repeat(padding),
1546            Style::default().fg(Color::White),
1547        ));
1548        spans.push(Span::styled("│", border_style));
1549        lines.push(Line::from(spans));
1550    }
1551    lines.push(Line::styled(
1552        format!("╰{}╯", "─".repeat(content_width)),
1553        border_style,
1554    ));
1555}
1556
1557fn tool_call_style() -> Style {
1558    Style::default().fg(Color::Magenta)
1559}
1560
1561fn pending_tool_call_style() -> Style {
1562    Style::default().fg(PENDING_TOOL_COLOR)
1563}
1564
1565fn tool_result_style() -> Style {
1566    Style::default().fg(Color::DarkGray)
1567}
1568
1569fn error_style() -> Style {
1570    Style::default().fg(Color::Red)
1571}
1572
1573fn info_style() -> Style {
1574    Style::default().fg(Color::DarkGray)
1575}
1576
1577fn context_status_text(state: &UiState) -> String {
1578    let used = format_context_tokens(state.context_tokens);
1579    let Some(window) = state.context_window else {
1580        return format!("ctx={used}/? (?%)");
1581    };
1582    let percentage = context_percentage(state.context_tokens, window);
1583    format!(
1584        "ctx={used}/{} ({percentage}%)",
1585        format_context_tokens(window)
1586    )
1587}
1588
1589fn context_status_style(state: &UiState) -> Style {
1590    if state
1591        .context_window
1592        .is_some_and(|window| context_over_threshold(state.context_tokens, window))
1593    {
1594        Style::default().fg(PENDING_TOOL_COLOR)
1595    } else {
1596        Style::default().fg(Color::DarkGray)
1597    }
1598}
1599
1600fn context_percentage(used: usize, window: usize) -> usize {
1601    if window == 0 {
1602        return 0;
1603    }
1604    ((used as u128 * 100).div_ceil(window as u128)) as usize
1605}
1606
1607fn context_over_threshold(used: usize, window: usize) -> bool {
1608    window > 0 && (used as u128 * 100) > (window as u128 * 80)
1609}
1610
1611fn format_context_tokens(tokens: usize) -> String {
1612    if tokens >= 1_000_000 {
1613        format!("{:.2}M", tokens as f64 / 1_000_000.0)
1614    } else if tokens >= 1_000 {
1615        format!("{:.1}K", tokens as f64 / 1_000.0)
1616    } else {
1617        tokens.to_string()
1618    }
1619}
1620
1621fn activity_style_for(state: &UiState) -> Style {
1622    if state.status == "working" {
1623        Style::default().fg(Color::LightGreen)
1624    } else if state.status == "compacting" {
1625        Style::default().fg(PENDING_TOOL_COLOR)
1626    } else {
1627        Style::default().fg(Color::Cyan)
1628    }
1629}
1630
1631fn thinking_style() -> Style {
1632    Style::default().fg(Color::DarkGray)
1633}
1634
1635fn waiting_input_style() -> Style {
1636    Style::default().fg(BUSY_INPUT_COLOR)
1637}
1638
1639fn spinner_frame(state: &UiState) -> char {
1640    const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1641    let tick = state.cursor_epoch.elapsed().as_millis() / 100;
1642    FRAMES[(tick as usize) % FRAMES.len()]
1643}
1644
1645fn push_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, style: Style) {
1646    let mut added = false;
1647    for piece in wrap_text(text, width) {
1648        lines.push(Line::styled(piece, style));
1649        added = true;
1650    }
1651    if !added {
1652        lines.push(Line::styled(String::new(), style));
1653    }
1654}
1655
1656/// Push a logical line built from styled segments. When the rendered width
1657/// exceeds `width`, the whole line is character-wrapped; wrapped continuations
1658/// keep the style of the segment they fall on.
1659fn push_spans_wrapped(lines: &mut Vec<Line<'static>>, segments: &[(String, Style)], width: usize) {
1660    let mut current_spans: Vec<Span<'static>> = Vec::new();
1661    let mut current_width = 0usize;
1662    for (text, style) in segments {
1663        for character in text.chars() {
1664            let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
1665            if current_width + char_width > width && !current_spans.is_empty() {
1666                lines.push(Line::from(std::mem::take(&mut current_spans)));
1667                current_width = 0;
1668            }
1669            let mut buffer = [0u8; 4];
1670            let s = character.encode_utf8(&mut buffer);
1671            current_spans.push(Span::styled(s.to_owned(), *style));
1672            current_width += char_width;
1673        }
1674    }
1675    if current_spans.is_empty() {
1676        current_spans.push(Span::raw(String::new()));
1677    }
1678    lines.push(Line::from(current_spans));
1679}
1680
1681/// Wrap `text` into rows no wider than `width` display columns. Wrapping is
1682/// character-based so the row count matches exactly what a non-wrapping
1683/// `Paragraph` renderer draws, which keeps auto-scroll pinned to the true
1684/// bottom of the transcript regardless of terminal width. Empty lines are
1685/// preserved as empty rows.
1686fn wrap_text(text: &str, width: usize) -> Vec<String> {
1687    if width == 0 {
1688        return text.lines().map(str::to_owned).collect();
1689    }
1690    let mut rows = Vec::new();
1691    // `split` preserves a trailing empty row, so Shift+Enter renders an
1692    // immediate new line even before another character is typed.
1693    for line in text.split('\n') {
1694        rows.extend(wrap_line(line, width));
1695    }
1696    if rows.is_empty() {
1697        rows.push(String::new());
1698    }
1699    rows
1700}
1701
1702fn wrap_line(line: &str, width: usize) -> Vec<String> {
1703    let mut rows = Vec::new();
1704    let mut current = String::new();
1705    let mut current_width = 0usize;
1706    for character in line.chars() {
1707        let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
1708        if current_width + char_width > width && !current.is_empty() {
1709            rows.push(std::mem::take(&mut current));
1710            current_width = 0;
1711        }
1712        current.push(character);
1713        current_width += char_width;
1714    }
1715    rows.push(current);
1716    rows
1717}
1718
1719#[cfg(test)]
1720mod tests {
1721    use super::*;
1722
1723    #[test]
1724    fn turn_notifications_use_osc_777_with_fixed_secret_safe_messages() {
1725        let cases = [
1726            (TurnNotification::Completed, "Turn complete"),
1727            (TurnNotification::Interrupted, "Turn interrupted"),
1728            (TurnNotification::Failed, "Turn failed"),
1729        ];
1730
1731        for (notification, body) in cases {
1732            let mut output = Vec::new();
1733            send_turn_notification(&mut output, notification).expect("notification");
1734            assert_eq!(
1735                output,
1736                format!("\x1b]777;notify;Lucy;{body}\x07").into_bytes()
1737            );
1738        }
1739    }
1740
1741    #[test]
1742    fn turn_notifications_follow_the_terminal_turn_status() {
1743        assert_eq!(
1744            turn_notification_for_status("finalizing"),
1745            TurnNotification::Completed
1746        );
1747        assert_eq!(
1748            turn_notification_for_status("cancelling"),
1749            TurnNotification::Interrupted
1750        );
1751        assert_eq!(
1752            turn_notification_for_status("error"),
1753            TurnNotification::Failed
1754        );
1755    }
1756
1757    struct FailingWriter;
1758
1759    impl Write for FailingWriter {
1760        fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
1761            Err(io::Error::other("notification sink unavailable"))
1762        }
1763
1764        fn flush(&mut self) -> io::Result<()> {
1765            Err(io::Error::other("notification sink unavailable"))
1766        }
1767    }
1768
1769    #[test]
1770    fn notification_write_failure_does_not_keep_the_tui_busy() {
1771        let mut state = UiState::from_history(&[], "secret", "model", None, false);
1772        state.busy = true;
1773        state.active_cancel = Some(CancellationToken::new());
1774        let mut writer = FailingWriter;
1775
1776        release_finished_turn(&mut writer, &mut state);
1777
1778        assert!(!state.busy);
1779        assert!(state.active_cancel.is_none());
1780    }
1781
1782    #[test]
1783    fn an_idle_finish_does_not_emit_a_duplicate_notification() {
1784        let mut state = UiState::from_history(&[], "secret", "model", None, false);
1785        let mut output = Vec::new();
1786
1787        release_finished_turn(&mut output, &mut state);
1788
1789        assert!(output.is_empty());
1790    }
1791
1792    #[test]
1793    fn context_status_shows_used_window_and_percentage() {
1794        let mut state = UiState::from_history(&[], "secret", "model", None, false)
1795            .with_context(Some(100_000), 80_000);
1796
1797        assert_eq!(context_status_text(&state), "ctx=80.0K/100.0K (80%)");
1798        assert_eq!(context_status_style(&state).fg, Some(Color::DarkGray));
1799
1800        state.context_tokens = 80_001;
1801        assert_eq!(context_status_text(&state), "ctx=80.0K/100.0K (81%)");
1802        assert_eq!(context_status_style(&state).fg, Some(PENDING_TOOL_COLOR));
1803    }
1804
1805    #[test]
1806    fn context_status_handles_unknown_window_without_highlighting() {
1807        let state = UiState::from_history(&[], "secret", "model", None, false);
1808
1809        assert_eq!(context_status_text(&state), "ctx=1/? (?%)");
1810        assert_eq!(context_status_style(&state).fg, Some(Color::DarkGray));
1811    }
1812
1813    #[test]
1814    fn context_status_is_right_aligned_and_turns_orange_above_eighty_percent() {
1815        let state =
1816            UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
1817        let mut terminal =
1818            Terminal::new(ratatui::backend::TestBackend::new(60, 10)).expect("test terminal");
1819
1820        terminal
1821            .draw(|frame| draw(frame, &state))
1822            .expect("draw statusline");
1823
1824        let buffer = terminal.backend().buffer();
1825        let status_row = 9;
1826        let context = context_status_text(&state);
1827        let context_start = 60 - context.chars().count();
1828        assert_eq!(buffer[(context_start as u16, status_row)].symbol(), "c");
1829        assert_eq!(buffer[(59, status_row)].symbol(), ")");
1830        assert_eq!(buffer[(59, status_row)].fg, PENDING_TOOL_COLOR);
1831    }
1832
1833    #[test]
1834    fn fresh_sessions_show_the_versioned_gradient_welcome_message() {
1835        let state = UiState::from_history(&[], "secret", "model", None, false);
1836        assert!(state.welcome_visible);
1837
1838        let line = welcome_line();
1839        assert_eq!(line.to_string(), WELCOME_MESSAGE);
1840        assert_eq!(
1841            line.spans.first().and_then(|span| span.style.fg),
1842            Some(Color::Rgb(
1843                WELCOME_START_COLOR.0,
1844                WELCOME_START_COLOR.1,
1845                WELCOME_START_COLOR.2,
1846            ))
1847        );
1848        assert_eq!(
1849            line.spans.last().and_then(|span| span.style.fg),
1850            Some(Color::Rgb(
1851                WELCOME_END_COLOR.0,
1852                WELCOME_END_COLOR.1,
1853                WELCOME_END_COLOR.2,
1854            ))
1855        );
1856    }
1857
1858    #[test]
1859    fn welcome_shows_the_tagline_and_attached_agents_paths() {
1860        let state = UiState::from_history(&[], "secret", "model", None, false)
1861            .with_attached_agents(vec![
1862                "/workspace/AGENTS.md".to_owned(),
1863                "/workspace/app/AGENTS.md".to_owned(),
1864            ]);
1865        let lines = welcome_lines(&state.attached_agents);
1866
1867        assert_eq!(lines[1].to_string(), WELCOME_TAGLINE);
1868        assert_eq!(lines[1].style.fg, Some(Color::DarkGray));
1869        assert_eq!(lines[3].to_string(), "Attached AGENTS.md:");
1870        assert_eq!(lines[4].to_string(), "• /workspace/AGENTS.md");
1871        assert_eq!(lines[5].to_string(), "• /workspace/app/AGENTS.md");
1872        assert!(lines[3..]
1873            .iter()
1874            .all(|line| line.style.fg == Some(Color::DarkGray)));
1875    }
1876
1877    #[test]
1878    fn welcome_reports_when_no_agents_file_is_attached() {
1879        let lines = welcome_lines(&[]);
1880        assert_eq!(
1881            lines.last().expect("empty context line").to_string(),
1882            "Attached AGENTS.md: none"
1883        );
1884    }
1885
1886    #[test]
1887    fn resumed_sessions_do_not_show_the_welcome_message() {
1888        let state = UiState::from_history(&[], "secret", "model", None, true);
1889        assert!(!state.welcome_visible);
1890    }
1891
1892    #[test]
1893    fn history_replay_keeps_interruption_after_messages() {
1894        let history = vec![
1895            SessionHistoryRecord::Message {
1896                timestamp: 1,
1897                message: ChatMessage::user("hello".to_owned()),
1898            },
1899            SessionHistoryRecord::Interruption {
1900                timestamp: 2,
1901                reason: "user_cancelled".to_owned(),
1902                phase: "provider_stream".to_owned(),
1903                assistant_text: "partial".to_owned(),
1904                tool_calls: Vec::new(),
1905                tool_results: Vec::new(),
1906            },
1907        ];
1908        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
1909        assert!(matches!(state.transcript[0], TranscriptItem::User { .. }));
1910        assert!(matches!(state.transcript[1], TranscriptItem::Assistant(_)));
1911        assert!(matches!(state.transcript[2], TranscriptItem::Info(_)));
1912        let text = transcript_lines(&state, 80)
1913            .iter()
1914            .map(ToString::to_string)
1915            .collect::<Vec<_>>()
1916            .join("\n");
1917        assert!(!text.contains("choices"));
1918    }
1919
1920    #[test]
1921    fn history_replay_does_not_render_assistant_reasoning_details() {
1922        let mut message = ChatMessage::assistant("visible answer".to_owned(), Vec::new());
1923        message.reasoning_details = Some(vec![serde_json::json!({
1924            "type": "reasoning.text",
1925            "text": "private reasoning"
1926        })]);
1927        let history = [SessionHistoryRecord::Message {
1928            timestamp: 1,
1929            message,
1930        }];
1931        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
1932        let text = transcript_lines(&state, 80)
1933            .iter()
1934            .map(ToString::to_string)
1935            .collect::<Vec<_>>()
1936            .join("\n");
1937        assert!(text.contains("visible answer"));
1938        assert!(!text.contains("private reasoning"));
1939        assert!(!text.contains("reasoning_details"));
1940    }
1941
1942    #[test]
1943    fn history_replay_preserves_repeated_records() {
1944        let history = vec![
1945            SessionHistoryRecord::Message {
1946                timestamp: 1,
1947                message: ChatMessage::assistant("same".to_owned(), Vec::new()),
1948            },
1949            SessionHistoryRecord::Interruption {
1950                timestamp: 2,
1951                reason: "user_cancelled".to_owned(),
1952                phase: "provider_stream".to_owned(),
1953                assistant_text: "same".to_owned(),
1954                tool_calls: Vec::new(),
1955                tool_results: Vec::new(),
1956            },
1957        ];
1958        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
1959        assert_eq!(
1960            state
1961                .transcript
1962                .iter()
1963                .filter(|item| matches!(item, TranscriptItem::Assistant(text) if text == "same"))
1964                .count(),
1965            2
1966        );
1967    }
1968
1969    #[test]
1970    fn user_message_borders_remain_muted_yellow_while_its_text_is_white() {
1971        let history = [SessionHistoryRecord::Message {
1972            timestamp: 1,
1973            message: ChatMessage::user("hello".to_owned()),
1974        }];
1975        let state = UiState::from_history(&history, "provider-secret", "model", None, false);
1976        let lines = transcript_lines(&state, 12);
1977
1978        assert_eq!(lines.len(), 3);
1979        assert_eq!(lines[0].to_string(), "╭──────────╮");
1980        assert_eq!(lines[1].to_string(), "│hello     │");
1981        assert_eq!(lines[2].to_string(), "╰──────────╯");
1982        assert_eq!(lines[0].style.fg, Some(USER_BORDER_COLOR));
1983        assert_eq!(lines[2].style.fg, Some(USER_BORDER_COLOR));
1984        assert_eq!(lines[1].spans[0].style.fg, Some(USER_BORDER_COLOR));
1985        assert_eq!(lines[1].spans[1].style.fg, Some(Color::White));
1986        assert_eq!(
1987            lines[1].spans.last().map(|span| span.style.fg),
1988            Some(Some(USER_BORDER_COLOR))
1989        );
1990    }
1991
1992    #[test]
1993    fn attached_skill_highlights_its_trigger_in_the_user_message_without_a_notice_line() {
1994        let mut state = UiState::from_history(&[], "secret", "model", None, false)
1995            .with_skill_names(vec!["release-notes".to_owned()]);
1996        state.add_user("/release-notes v1.2.0", "secret");
1997        state.mark_latest_user_skill_attached();
1998
1999        let lines = transcript_lines(&state, 40);
2000        assert_eq!(lines.len(), 3);
2001        let cyan_text = lines[1]
2002            .spans
2003            .iter()
2004            .filter(|span| span.style.fg == Some(Color::Cyan))
2005            .map(|span| span.content.as_ref())
2006            .collect::<String>();
2007        assert_eq!(cyan_text, "/release-notes");
2008        assert!(!lines
2009            .iter()
2010            .any(|line| line.to_string().contains("instruction attached")));
2011    }
2012
2013    #[test]
2014    fn transcript_rendering_redacts_history_content() {
2015        let history = [SessionHistoryRecord::Message {
2016            timestamp: 1,
2017            message: ChatMessage::assistant("provider-secret".to_owned(), Vec::new()),
2018        }];
2019        let state = UiState::from_history(&history, "provider-secret", "model", None, false);
2020        let text = transcript_lines(&state, 80)
2021            .iter()
2022            .map(ToString::to_string)
2023            .collect::<Vec<_>>()
2024            .join("\n");
2025        assert!(!text.contains("provider-secret"));
2026    }
2027
2028    #[test]
2029    fn mouse_wheel_disables_following_and_changes_scroll_offset() {
2030        let history = [SessionHistoryRecord::Message {
2031            timestamp: 1,
2032            message: ChatMessage::user("hello".to_owned()),
2033        }];
2034        let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
2035        handle_mouse_event(&mut state, MouseEventKind::ScrollUp, 10);
2036        assert!(!state.auto_scroll);
2037        assert_eq!(state.scroll, 7);
2038        handle_mouse_event(&mut state, MouseEventKind::ScrollDown, 10);
2039        assert!(
2040            state.auto_scroll,
2041            "reaching the bottom resumes transcript following"
2042        );
2043        assert_eq!(state.scroll, 0);
2044        scroll_up(&mut state, 10);
2045        assert!(!state.auto_scroll);
2046        assert_eq!(state.scroll, 7);
2047    }
2048
2049    #[test]
2050    fn wrap_text_breaks_long_lines_and_preserves_empty_lines() {
2051        let rows = wrap_text("12345\n\nabc", 3);
2052        assert_eq!(rows, vec!["123", "45", "", "abc"]);
2053    }
2054
2055    #[test]
2056    fn wrap_line_never_returns_an_empty_vec() {
2057        assert_eq!(wrap_line("", 5), vec![""]);
2058        assert_eq!(wrap_line("abc", 5), vec!["abc"]);
2059    }
2060
2061    #[test]
2062    fn completion_event_does_not_release_input_before_worker_finishes() {
2063        let history = [SessionHistoryRecord::Message {
2064            timestamp: 1,
2065            message: ChatMessage::user("hello".to_owned()),
2066        }];
2067        let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
2068        state.busy = true;
2069        state.active_cancel = Some(CancellationToken::new());
2070        state.apply_event(ProtocolEvent::TurnEnd);
2071        assert!(state.busy);
2072        assert!(state.active_cancel.is_some());
2073        assert_eq!(state.status, "finalizing");
2074    }
2075
2076    #[test]
2077    fn transcript_inserts_a_blank_line_between_items() {
2078        let history = [
2079            SessionHistoryRecord::Message {
2080                timestamp: 1,
2081                message: ChatMessage::user("hi".to_owned()),
2082            },
2083            SessionHistoryRecord::Message {
2084                timestamp: 2,
2085                message: ChatMessage::assistant("hello".to_owned(), Vec::new()),
2086            },
2087        ];
2088        let state = UiState::from_history(&history, "secret", "model", None, false);
2089        let lines = transcript_lines(&state, 80);
2090        assert_eq!(lines.len(), 5);
2091        assert_eq!(lines[0].to_string(), format!("╭{}╮", "─".repeat(78)));
2092        assert_eq!(lines[1].to_string(), format!("│hi{}│", " ".repeat(76)));
2093        assert_eq!(lines[2].to_string(), format!("╰{}╯", "─".repeat(78)));
2094        assert_eq!(lines[3].to_string(), "");
2095        assert_eq!(lines[4].to_string(), "hello");
2096    }
2097
2098    #[test]
2099    fn tool_call_renders_as_compact_single_line_with_command() {
2100        let history = [SessionHistoryRecord::Message {
2101            timestamp: 1,
2102            message: ChatMessage::assistant(
2103                String::new(),
2104                vec![crate::model::ChatToolCall {
2105                    id: "call-1".to_owned(),
2106                    name: "cmd".to_owned(),
2107                    arguments: r#"{"command":"pwd"}"#.to_owned(),
2108                }],
2109            ),
2110        }];
2111        let state = UiState::from_history(&history, "secret", "model", None, false);
2112        let text = transcript_lines(&state, 80)
2113            .iter()
2114            .map(ToString::to_string)
2115            .collect::<Vec<_>>()
2116            .join("\n");
2117        assert!(text.contains("[tool:cmd \"pwd\"]"));
2118        // The raw JSON arguments must not appear verbatim.
2119        assert!(!text.contains("{\"command\":\"pwd\"}"));
2120    }
2121
2122    #[test]
2123    fn pending_tool_calls_are_orange_and_show_a_spinner() {
2124        let history = [SessionHistoryRecord::Message {
2125            timestamp: 1,
2126            message: ChatMessage::assistant(
2127                String::new(),
2128                vec![crate::model::ChatToolCall {
2129                    id: "call-1".to_owned(),
2130                    name: "cmd".to_owned(),
2131                    arguments: r#"{"command":"pwd"}"#.to_owned(),
2132                }],
2133            ),
2134        }];
2135        let state = UiState::from_history(&history, "secret", "model", None, false);
2136        let line = &transcript_lines(&state, 80)[0];
2137
2138        assert!(line.to_string().contains("[tool:cmd \"pwd\"] "));
2139        assert!(line
2140            .spans
2141            .iter()
2142            .all(|span| span.style.fg == Some(PENDING_TOOL_COLOR)));
2143        assert!(line.spans.iter().any(|span| {
2144            span.content
2145                .chars()
2146                .any(|character| "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏".contains(character))
2147        }));
2148    }
2149
2150    #[test]
2151    fn tool_call_truncates_long_arguments() {
2152        let command = "a".repeat(80);
2153        let arguments = serde_json::json!({"command": command}).to_string();
2154        let history = [SessionHistoryRecord::Message {
2155            timestamp: 1,
2156            message: ChatMessage::assistant(
2157                String::new(),
2158                vec![crate::model::ChatToolCall {
2159                    id: "call-1".to_owned(),
2160                    name: "cmd".to_owned(),
2161                    arguments,
2162                }],
2163            ),
2164        }];
2165        let state = UiState::from_history(&history, "secret", "model", None, false);
2166        let text = transcript_lines(&state, 200)[0].to_string();
2167        assert!(text.contains(&format!("[tool:cmd \"{}…\"]", "a".repeat(50))));
2168        assert!(!text.contains(&"a".repeat(51)));
2169    }
2170
2171    #[test]
2172    fn tool_call_and_result_render_on_one_line_with_truncated_stdout() {
2173        let long_stdout = "a".repeat(80);
2174        let history = vec![
2175            SessionHistoryRecord::Message {
2176                timestamp: 1,
2177                message: ChatMessage::assistant(
2178                    String::new(),
2179                    vec![crate::model::ChatToolCall {
2180                        id: "call-1".to_owned(),
2181                        name: "cmd".to_owned(),
2182                        arguments: r#"{"command":"cat README.md"}"#.to_owned(),
2183                    }],
2184                ),
2185            },
2186            SessionHistoryRecord::Message {
2187                timestamp: 2,
2188                message: ChatMessage::tool(
2189                    "call-1".to_owned(),
2190                    "cmd".to_owned(),
2191                    serde_json::json!({
2192                        "command": "cat README.md",
2193                        "exit_code": 0,
2194                        "stdout": long_stdout,
2195                        "stderr": "",
2196                    })
2197                    .to_string(),
2198                ),
2199            },
2200        ];
2201        let state = UiState::from_history(&history, "secret", "model", None, false);
2202        let lines = transcript_lines(&state, 200);
2203        // Call and result share one logical line (no blank line between them).
2204        assert_eq!(lines.len(), 1);
2205        let text = lines[0].to_string();
2206        assert!(text.starts_with("[tool:cmd \"cat README.md\"] > ["));
2207        // stdout is truncated to 50 chars plus the ellipsis.
2208        assert!(text.contains(&"a".repeat(50)));
2209        assert!(text.contains('…'));
2210        assert!(!text.contains(&"a".repeat(51)));
2211    }
2212
2213    #[test]
2214    fn tool_result_falls_back_to_stderr_when_stdout_is_empty() {
2215        let history = vec![
2216            SessionHistoryRecord::Message {
2217                timestamp: 1,
2218                message: ChatMessage::assistant(
2219                    String::new(),
2220                    vec![crate::model::ChatToolCall {
2221                        id: "call-1".to_owned(),
2222                        name: "cmd".to_owned(),
2223                        arguments: r#"{"command":"bad"}"#.to_owned(),
2224                    }],
2225                ),
2226            },
2227            SessionHistoryRecord::Message {
2228                timestamp: 2,
2229                message: ChatMessage::tool(
2230                    "call-1".to_owned(),
2231                    "cmd".to_owned(),
2232                    serde_json::json!({
2233                        "command": "bad",
2234                        "exit_code": 127,
2235                        "stdout": "",
2236                        "stderr": "not found",
2237                    })
2238                    .to_string(),
2239                ),
2240            },
2241        ];
2242        let state = UiState::from_history(&history, "secret", "model", None, false);
2243        let text = transcript_lines(&state, 200)[0].to_string();
2244        assert!(text.contains("not found"));
2245        assert!(text.contains(" > "));
2246    }
2247
2248    #[test]
2249    fn tool_call_and_result_styles_use_foreground_colors() {
2250        let history = vec![
2251            SessionHistoryRecord::Message {
2252                timestamp: 1,
2253                message: ChatMessage::assistant(
2254                    String::new(),
2255                    vec![crate::model::ChatToolCall {
2256                        id: "call-1".to_owned(),
2257                        name: "cmd".to_owned(),
2258                        arguments: r#"{"command":"pwd"}"#.to_owned(),
2259                    }],
2260                ),
2261            },
2262            SessionHistoryRecord::Message {
2263                timestamp: 2,
2264                message: ChatMessage::tool(
2265                    "call-1".to_owned(),
2266                    "cmd".to_owned(),
2267                    serde_json::json!({"stdout":"x","stderr":""}).to_string(),
2268                ),
2269            },
2270        ];
2271        let state = UiState::from_history(&history, "secret", "model", None, false);
2272        let lines = transcript_lines(&state, 200);
2273        let spans = &lines[0].spans;
2274        // Calls and results retain their distinct foreground styles, with an
2275        // unstyled separator between them.
2276        assert_eq!(spans[0].style.fg, Some(Color::Magenta));
2277        assert!(spans.iter().any(|span| span.style.fg.is_none()));
2278        let result_text = spans
2279            .iter()
2280            .filter(|span| span.style.fg == Some(Color::DarkGray))
2281            .map(|span| span.content.as_ref())
2282            .collect::<String>();
2283        assert_eq!(result_text, "[\"x\"]");
2284        assert!(!lines[0]
2285            .to_string()
2286            .chars()
2287            .any(|character| "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏".contains(character)));
2288    }
2289
2290    #[test]
2291    fn recognized_skill_trigger_is_highlighted_but_arguments_remain_default_colored() {
2292        let trigger = active_skill_trigger("/release-notes v1.2.0", &["release-notes".to_owned()]);
2293        assert_eq!(trigger, Some("/release-notes"));
2294
2295        let lines = styled_text_lines(
2296            "/release-notes v1.2.0",
2297            trigger,
2298            80,
2299            Style::default().fg(Color::White),
2300        );
2301        assert_eq!(lines.len(), 1);
2302        assert_eq!(lines[0].to_string(), "/release-notes v1.2.0");
2303        assert_eq!(lines[0].spans[0].content, "/release-notes");
2304        assert_eq!(lines[0].spans[0].style.fg, Some(Color::Cyan));
2305        assert_eq!(lines[0].spans[1].content, " v1.2.0");
2306        assert_eq!(lines[0].spans[1].style.fg, Some(Color::White));
2307    }
2308
2309    #[test]
2310    fn draw_renders_an_active_skill_trigger_in_cyan() {
2311        let mut state = UiState::from_history(&[], "secret", "model", None, false)
2312            .with_skill_names(vec!["release-notes".to_owned()]);
2313        state.input = "/release-notes v1.2.0".to_owned();
2314        state.cursor = state.input.chars().count();
2315
2316        let mut terminal =
2317            Terminal::new(ratatui::backend::TestBackend::new(40, 10)).expect("test terminal");
2318        terminal
2319            .draw(|frame| draw(frame, &state))
2320            .expect("draw input");
2321
2322        // The one-row input area starts at (1, 7): trigger characters are cyan,
2323        // while the argument that follows keeps the normal white input color.
2324        let buffer = terminal.backend().buffer();
2325        assert_eq!(buffer[(1, 7)].fg, Color::Cyan);
2326        assert_eq!(buffer[(21, 7)].fg, Color::White);
2327    }
2328
2329    #[test]
2330    fn busy_input_uses_a_dark_gray_border_and_waiting_message() {
2331        let mut state = UiState::from_history(&[], "secret", "model", None, false);
2332        state.busy = true;
2333
2334        let mut terminal =
2335            Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
2336        terminal
2337            .draw(|frame| draw(frame, &state))
2338            .expect("draw waiting input");
2339
2340        let buffer = terminal.backend().buffer();
2341        assert_eq!(buffer[(0, 6)].fg, BUSY_INPUT_COLOR);
2342        assert_eq!(buffer[(1, 7)].symbol(), "T");
2343        assert_eq!(buffer[(1, 7)].fg, BUSY_INPUT_COLOR);
2344        assert_eq!(input_display_text(&state), WAITING_INPUT_MESSAGE);
2345    }
2346
2347    #[test]
2348    fn only_known_leading_skill_commands_activate_input_highlighting() {
2349        let skills = ["release-notes".to_owned()];
2350        assert_eq!(
2351            active_skill_trigger("/missing", &skills),
2352            None,
2353            "unknown commands are rejected by the turn engine and must not look active"
2354        );
2355        assert_eq!(
2356            active_skill_trigger("/skill:release-notes", &skills),
2357            None,
2358            "the removed /skill: wrapper must not look active"
2359        );
2360        assert_eq!(
2361            active_skill_trigger("write /release-notes", &skills),
2362            None,
2363            "only the command prefix accepted by the turn engine is active"
2364        );
2365        assert_eq!(active_skill_trigger("/", &skills), None);
2366    }
2367
2368    #[test]
2369    fn highlighted_skill_trigger_remains_styled_when_wrapped() {
2370        let input = "/release-notes argument";
2371        let trigger = active_skill_trigger(input, &["release-notes".to_owned()]);
2372        let lines = styled_text_lines(input, trigger, 8, Style::default().fg(Color::White));
2373        let highlighted = lines
2374            .iter()
2375            .flat_map(|line| line.spans.iter())
2376            .filter(|span| span.style.fg == Some(Color::Cyan))
2377            .map(|span| span.content.as_ref())
2378            .collect::<String>();
2379        assert_eq!(highlighted, "/release-notes");
2380    }
2381
2382    #[test]
2383    fn input_has_no_prompt_marker_and_trailing_newline_is_visible() {
2384        assert_eq!(input_prompt("hello"), "hello");
2385        assert_eq!(wrap_text("hello\n", 80), vec!["hello", ""]);
2386    }
2387
2388    #[test]
2389    fn input_prompt_wraps_to_multiple_rows_when_long() {
2390        let mut state = UiState::from_history(&[], "secret", "model", None, false);
2391        state.input = "abcdefghij".to_owned();
2392        // width 5: the input wraps across multiple rows without a prompt marker.
2393        let rows = input_visible_rows(&state, 5);
2394        assert!(rows >= 2);
2395    }
2396
2397    #[test]
2398    fn cursor_editing_moves_by_characters_and_preserves_unicode() {
2399        let mut input = "가나".to_owned();
2400        let mut cursor = input.chars().count();
2401        cursor -= 1;
2402        insert_at_cursor(&mut input, &mut cursor, 'x');
2403        assert_eq!(input, "가x나");
2404        assert_eq!(cursor, 2);
2405        assert!(remove_before_cursor(&mut input, &mut cursor));
2406        assert_eq!(input, "가나");
2407        assert_eq!(cursor, 1);
2408    }
2409
2410    #[test]
2411    fn cursor_row_tracks_newlines_and_wrapping() {
2412        assert_eq!(cursor_row("hello\nworld", 6, 80), 1);
2413        assert_eq!(cursor_row("abcdef", 4, 3), 1);
2414    }
2415
2416    #[test]
2417    fn shift_enter_inserts_at_the_cursor_and_moves_it_to_the_new_row() {
2418        let mut input = "beforeafter".to_owned();
2419        let mut cursor = 6;
2420        insert_at_cursor(&mut input, &mut cursor, '\n');
2421
2422        assert_eq!(input, "before\nafter");
2423        assert_eq!(cursor, 7);
2424        assert_eq!(cursor_row(&input, cursor, 80), 1);
2425    }
2426
2427    #[test]
2428    fn shift_enter_renders_the_cursor_on_the_new_input_row() {
2429        let mut state = UiState::from_history(&[], "secret", "model", None, false);
2430        state.input = "beforeafter".to_owned();
2431        state.cursor = 6;
2432        insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
2433        state.cursor_epoch = Instant::now();
2434
2435        let mut terminal =
2436            Terminal::new(ratatui::backend::TestBackend::new(20, 10)).expect("test terminal");
2437        terminal
2438            .draw(|frame| draw(frame, &state))
2439            .expect("draw input cursor");
2440
2441        // The input box starts at row 5; its inner area begins at (1, 6).
2442        // After inserting a newline, the cursor is at the start of row 7.
2443        terminal.backend_mut().assert_cursor_position((1, 7));
2444    }
2445
2446    #[test]
2447    fn tool_results_attach_to_their_matching_call_after_consecutive_calls() {
2448        let history = vec![
2449            SessionHistoryRecord::Message {
2450                timestamp: 1,
2451                message: ChatMessage::assistant(
2452                    String::new(),
2453                    vec![
2454                        crate::model::ChatToolCall {
2455                            id: "call-first".to_owned(),
2456                            name: "cmd".to_owned(),
2457                            arguments: r#"{"command":"first"}"#.to_owned(),
2458                        },
2459                        crate::model::ChatToolCall {
2460                            id: "call-second".to_owned(),
2461                            name: "cmd".to_owned(),
2462                            arguments: r#"{"command":"second"}"#.to_owned(),
2463                        },
2464                    ],
2465                ),
2466            },
2467            SessionHistoryRecord::Message {
2468                timestamp: 2,
2469                message: ChatMessage::tool(
2470                    "call-first".to_owned(),
2471                    "cmd".to_owned(),
2472                    serde_json::json!({"stdout":"first result","stderr":""}).to_string(),
2473                ),
2474            },
2475            SessionHistoryRecord::Message {
2476                timestamp: 3,
2477                message: ChatMessage::tool(
2478                    "call-second".to_owned(),
2479                    "cmd".to_owned(),
2480                    serde_json::json!({"stdout":"second result","stderr":""}).to_string(),
2481                ),
2482            },
2483        ];
2484
2485        let state = UiState::from_history(&history, "secret", "model", None, false);
2486        let lines = transcript_lines(&state, 200);
2487        assert_eq!(
2488            lines.len(),
2489            3,
2490            "only the two call lines and their separator remain"
2491        );
2492        assert_eq!(
2493            lines[0].to_string(),
2494            "[tool:cmd \"first\"] > [\"first result\"]"
2495        );
2496        assert_eq!(
2497            lines[2].to_string(),
2498            "[tool:cmd \"second\"] > [\"second result\"]"
2499        );
2500    }
2501}
2502
2503#[cfg(test)]
2504mod skill_picker_tests {
2505    use super::*;
2506
2507    fn skill_names() -> Vec<String> {
2508        ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
2509            .into_iter()
2510            .map(str::to_owned)
2511            .collect()
2512    }
2513
2514    #[test]
2515    fn slash_picker_filters_only_leading_command_text_and_hides_without_matches() {
2516        let names = skill_names();
2517        assert_eq!(
2518            matching_skill_names("/", &names),
2519            vec!["alpha", "beta", "build", "charlie", "deploy", "doctor"]
2520        );
2521        assert_eq!(matching_skill_names("/b", &names), vec!["beta", "build"]);
2522        assert!(matching_skill_names("/missing", &names).is_empty());
2523        assert!(matching_skill_names("message /b", &names).is_empty());
2524        assert!(matching_skill_names("/beta arguments", &names).is_empty());
2525    }
2526
2527    #[test]
2528    fn slash_picker_focuses_the_top_match_and_moves_within_filtered_results() {
2529        let mut state = UiState::from_history(&[], "secret", "model", None, false)
2530            .with_skill_names(skill_names());
2531        state.input = "/b".to_owned();
2532        state.input_changed();
2533
2534        assert!(state.skill_picker_visible());
2535        assert_eq!(state.skill_picker_focus, 0);
2536        assert!(state.move_skill_picker(true));
2537        assert_eq!(state.skill_picker_focus, 1);
2538        assert!(state.move_skill_picker(true));
2539        assert_eq!(state.skill_picker_focus, 1, "focus does not leave the list");
2540        assert!(state.move_skill_picker(false));
2541        assert_eq!(state.skill_picker_focus, 0);
2542
2543        state.input = "/missing".to_owned();
2544        state.input_changed();
2545        assert!(!state.skill_picker_visible());
2546        assert!(!state.move_skill_picker(true));
2547    }
2548
2549    #[test]
2550    fn enter_selects_the_focused_skill_then_leaves_the_completed_command_ready_to_send() {
2551        let mut state = UiState::from_history(&[], "secret", "model", None, false)
2552            .with_skill_names(skill_names());
2553        state.input = "/b".to_owned();
2554        state.input_changed();
2555        state.move_skill_picker(true);
2556
2557        assert!(state.select_focused_skill());
2558        assert_eq!(state.input, "/build");
2559        assert_eq!(state.cursor, "/build".chars().count());
2560        assert!(
2561            !state.skill_picker_visible(),
2562            "the first Enter completes the input rather than sending it"
2563        );
2564        assert!(
2565            !state.select_focused_skill(),
2566            "a second Enter follows the normal send/attachment path"
2567        );
2568    }
2569
2570    #[test]
2571    fn slash_picker_keeps_the_focused_item_in_its_five_row_viewport() {
2572        assert_eq!(skill_picker_range(20, 0), 0..5);
2573        assert_eq!(skill_picker_range(20, 4), 0..5);
2574        assert_eq!(skill_picker_range(20, 5), 1..6);
2575        assert_eq!(skill_picker_range(20, 19), 15..20);
2576    }
2577
2578    #[test]
2579    fn slash_picker_overlays_the_ready_activity_indicator() {
2580        let mut state = UiState::from_history(&[], "secret", "model", None, false)
2581            .with_skill_names(vec!["a".to_owned(), "b".to_owned()]);
2582        state.input = "/".to_owned();
2583        state.input_changed();
2584        let mut terminal =
2585            Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
2586        terminal
2587            .draw(|frame| draw(frame, &state))
2588            .expect("draw TUI");
2589
2590        let screen = terminal
2591            .backend()
2592            .buffer()
2593            .content()
2594            .iter()
2595            .map(|cell| cell.symbol())
2596            .collect::<String>();
2597        assert!(screen.contains("/a"));
2598        assert!(
2599            !screen.contains("ready"),
2600            "the skill picker should cover the ready activity indicator"
2601        );
2602    }
2603
2604    #[test]
2605    fn slash_picker_is_rendered_immediately_above_the_input() {
2606        let mut state = UiState::from_history(&[], "secret", "model", None, false)
2607            .with_skill_names(skill_names());
2608        state.input = "/".to_owned();
2609        state.input_changed();
2610        let mut terminal =
2611            Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
2612        terminal
2613            .draw(|frame| draw(frame, &state))
2614            .expect("draw TUI");
2615
2616        let buffer = terminal.backend().buffer();
2617        // With a one-row prompt, the six-row picker ends directly before the
2618        // rounded input box: header, five skills, then the input border.
2619        assert_eq!(buffer[(0, 2)].symbol(), "[");
2620        assert_eq!(buffer[(0, 7)].symbol(), "/");
2621        assert_eq!(buffer[(0, 8)].symbol(), "╭");
2622    }
2623
2624    #[test]
2625    fn slash_picker_renders_count_and_distinct_focus_colors() {
2626        let mut state = UiState::from_history(&[], "secret", "model", None, false)
2627            .with_skill_names(skill_names());
2628        state.input = "/".to_owned();
2629        state.input_changed();
2630        let mut terminal =
2631            Terminal::new(ratatui::backend::TestBackend::new(30, 8)).expect("test terminal");
2632        terminal
2633            .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 6)))
2634            .expect("draw skill picker");
2635
2636        let buffer = terminal.backend().buffer();
2637        assert_eq!(buffer[(0, 0)].symbol(), "[");
2638        assert_eq!(buffer[(0, 0)].fg, Color::DarkGray);
2639        assert_eq!(buffer[(0, 1)].symbol(), "/");
2640        assert_eq!(buffer[(0, 1)].fg, Color::Cyan);
2641        assert_eq!(buffer[(0, 2)].symbol(), "/");
2642        assert_eq!(buffer[(0, 2)].fg, Color::DarkGray);
2643    }
2644}