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, BorderType, 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::provider::ProviderModel;
31use crate::redaction::redact_secret;
32use crate::session::SessionHistoryRecord;
33
34const EVENT_POLL: Duration = Duration::from_millis(50);
35const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
36const MAX_DISPLAY_INPUT_CHARS: usize = 16 * 1024;
37const WORKER_SHUTDOWN_GRACE: Duration = Duration::from_secs(2);
38/// Maximum number of wrapped input rows the input box grows to before it
39/// stops expanding and scrolls its contents internally.
40const MAX_INPUT_ROWS: u16 = 12;
41const WELCOME_MESSAGE: &str = concat!("Coding Agent Harness LUCY - v", env!("CARGO_PKG_VERSION"),);
42const WELCOME_TAGLINE: &str = "An ultra-thin harness for tomorrow's most powerful models";
43const WELCOME_START_COLOR: (u8, u8, u8) = (0, 180, 180);
44const WELCOME_END_COLOR: (u8, u8, u8) = (255, 215, 0);
45const USER_BORDER_COLOR: Color = Color::Rgb(192, 154, 0);
46const PENDING_TOOL_COLOR: Color = Color::Rgb(255, 165, 0);
47// The sequence travels through warm neighbouring hues, then returns along the
48// same path. Each five-second phase blends only its adjacent palette colours.
49const WORKING_GRADIENT_COLORS: [(u8, u8, u8); 4] = [
50    (220, 35, 175), // magenta
51    (235, 45, 65),  // red
52    (255, 130, 25), // orange
53    (235, 45, 65),  // red, returning to magenta
54];
55const WORKING_GRADIENT_CYCLE: Duration = Duration::from_millis(5000);
56const SKILL_PICKER_MAX_ROWS: usize = 5;
57const BUILTIN_COMMANDS: [&str; 2] = ["settings", "exit"];
58const SETTINGS_MIN_WIDTH: u16 = 36;
59const SETTINGS_MAX_WIDTH: u16 = 88;
60const SETTINGS_MIN_HEIGHT: u16 = 8;
61const SETTINGS_MAX_HEIGHT: u16 = 22;
62
63pub(crate) fn run<W: Write>(mut harness: Harness, resumed: bool, stdout: W) -> Result<(), String> {
64    let secret = harness.provider.api_key().to_owned();
65    let context_window = harness
66        .context_window
67        .or_else(|| harness.provider.context_window());
68    harness.context_window = context_window;
69    let context_tokens = estimate_context_tokens(&harness.session.provider_messages());
70    let skill_names = command_names(
71        harness
72            .session
73            .skills
74            .iter()
75            .map(|skill| skill.name.clone())
76            .collect(),
77    );
78    let mut state = UiState::from_history(
79        &harness.session.history,
80        &secret,
81        &harness.session.llm.model,
82        harness.session.llm.effort.as_deref(),
83        resumed,
84    )
85    .with_attached_agents(harness.attached_agents.clone())
86    .with_skill_names(skill_names)
87    .with_context(context_window, context_tokens);
88    let (request_tx, request_rx) = mpsc::channel::<WorkerRequest>();
89    let (message_tx, message_rx) = mpsc::channel::<WorkerMessage>();
90
91    let stdout = stdout;
92    enable_raw_mode().map_err(|error| format!("unable to enable terminal input: {error}"))?;
93    let backend = CrosstermBackend::new(stdout);
94    let terminal = match Terminal::new(backend) {
95        Ok(terminal) => terminal,
96        Err(error) => {
97            let _ = disable_raw_mode();
98            return Err(format!("unable to initialize terminal UI: {error}"));
99        }
100    };
101    let mut terminal_guard = TerminalGuard::new(terminal);
102    let backend = terminal_guard.terminal_mut().backend_mut();
103    if let Err(error) = execute!(backend, EnterAlternateScreen, EnableMouseCapture, Hide) {
104        return Err(format!("unable to enter terminal UI: {error}"));
105    }
106    // Kitty keyboard protocol makes Shift+Enter (and other modified keys)
107    // distinguishable from plain Enter. Only push it on terminals known to
108    // support it; otherwise the enhancement sequence would leak as literal
109    // text on screen.
110    if supports_keyboard_enhancement() {
111        let _ = execute!(
112            backend,
113            PushKeyboardEnhancementFlags(
114                KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
115                    | KeyboardEnhancementFlags::REPORT_EVENT_TYPES,
116            )
117        );
118        terminal_guard.keyboard_enhancement = true;
119    }
120    let worker = thread::spawn(move || worker_loop(&mut harness, request_rx, message_tx, resumed));
121
122    let result = event_loop(
123        terminal_guard.terminal_mut(),
124        &mut state,
125        &request_tx,
126        &message_rx,
127    );
128
129    if let Some(token) = state.active_cancel.take() {
130        let _ = token.cancel();
131    }
132    let _ = request_tx.send(WorkerRequest::Shutdown);
133    wait_for_worker(worker, WORKER_SHUTDOWN_GRACE);
134    drop(terminal_guard);
135    result
136}
137
138fn worker_loop(
139    harness: &mut Harness,
140    requests: Receiver<WorkerRequest>,
141    messages: Sender<WorkerMessage>,
142    resumed: bool,
143) {
144    let mut sink = ChannelSink {
145        sender: messages.clone(),
146    };
147    if sink
148        .emit_event(&ProtocolEvent::Session {
149            session_id: harness.session.id.clone(),
150            resumed,
151        })
152        .is_err()
153    {
154        return;
155    }
156
157    loop {
158        if let Some(notification) = harness.next_subagent_notification() {
159            let cancel = CancellationToken::new();
160            let _ = messages.send(WorkerMessage::Started {
161                cancel: cancel.clone(),
162                user_text: None,
163            });
164            if let Err(error) = harness.handle_message(&notification, &mut sink, Some(&cancel)) {
165                let message = redact_secret(&error, Some(harness.provider.api_key()));
166                let _ = sink.emit_event(&ProtocolEvent::Error { message });
167            }
168            let _ = messages.send(WorkerMessage::Finished);
169            continue;
170        }
171        let request = match requests.recv_timeout(EVENT_POLL) {
172            Ok(request) => request,
173            Err(mpsc::RecvTimeoutError::Timeout) => {
174                if let Some(notification) = harness.next_subagent_notification() {
175                    let cancel = CancellationToken::new();
176                    let _ = messages.send(WorkerMessage::Started {
177                        cancel: cancel.clone(),
178                        user_text: None,
179                    });
180                    if let Err(error) =
181                        harness.handle_message(&notification, &mut sink, Some(&cancel))
182                    {
183                        let message = redact_secret(&error, Some(harness.provider.api_key()));
184                        let _ = sink.emit_event(&ProtocolEvent::Error { message });
185                    }
186                    let _ = messages.send(WorkerMessage::Finished);
187                }
188                continue;
189            }
190            Err(mpsc::RecvTimeoutError::Disconnected) => break,
191        };
192        match request {
193            WorkerRequest::Turn { text } => {
194                let cancel = CancellationToken::new();
195                let _ = messages.send(WorkerMessage::Started {
196                    cancel: cancel.clone(),
197                    user_text: Some(text.clone()),
198                });
199                if let Err(error) = harness.handle_message(&text, &mut sink, Some(&cancel)) {
200                    let message = redact_secret(&error, Some(harness.provider.api_key()));
201                    let _ = sink.emit_event(&ProtocolEvent::Error { message });
202                }
203                let _ = messages.send(WorkerMessage::Finished);
204            }
205            WorkerRequest::Catalog => {
206                let _ = messages.send(WorkerMessage::Catalog(
207                    harness.provider.models().map_err(|error| error.to_string()),
208                ));
209            }
210            WorkerRequest::ApplySettings { model, effort } => {
211                let result = harness.apply_settings(&harness.home.clone(), model, effort);
212                let _ = messages.send(WorkerMessage::SettingsApplied(
213                    result,
214                    harness.session.llm.model.clone(),
215                    harness.session.llm.effort.clone(),
216                    harness.context_window,
217                ));
218            }
219            WorkerRequest::Shutdown => break,
220        }
221    }
222}
223
224fn event_loop<W: Write>(
225    terminal: &mut Terminal<CrosstermBackend<W>>,
226    state: &mut UiState,
227    requests: &Sender<WorkerRequest>,
228    messages: &Receiver<WorkerMessage>,
229) -> Result<(), String> {
230    let mut quitting = false;
231    loop {
232        loop {
233            match messages.try_recv() {
234                Ok(WorkerMessage::Event(event)) => state.apply_event(event),
235                Ok(WorkerMessage::Started { cancel, user_text }) => {
236                    if let Some(text) = user_text {
237                        state.start_queued_user(&text);
238                    }
239                    state.active_cancel = Some(cancel);
240                    state.busy = true;
241                    state.status = "working".to_owned();
242                }
243                Ok(WorkerMessage::Thinking) => state.show_thinking(),
244                Ok(WorkerMessage::ReasoningCompleted) => state.complete_reasoning(),
245                Ok(WorkerMessage::SkillInstructionAttached) => {
246                    state.mark_latest_user_skill_attached()
247                }
248                Ok(WorkerMessage::ContextUsage(tokens)) => state.context_tokens = tokens,
249                Ok(WorkerMessage::CompactionStarted) => state.status = "compacting".to_owned(),
250                Ok(WorkerMessage::CompactionFinished {
251                    tokens_before,
252                    tokens_after,
253                }) => {
254                    state.context_tokens = tokens_after;
255                    state.status = "working".to_owned();
256                    state.transcript.push(TranscriptItem::Info(format!(
257                        "↻ context compacted ({} → {})",
258                        format_context_tokens(tokens_before),
259                        format_context_tokens(tokens_after)
260                    )));
261                }
262                Ok(WorkerMessage::Catalog(result)) => state.open_catalog(result),
263                Ok(WorkerMessage::SettingsApplied(result, model, effort, context_window)) => {
264                    state.settings_applied(result, model, effort, context_window)
265                }
266                Ok(WorkerMessage::Finished) => {
267                    release_finished_turn(terminal.backend_mut(), state);
268                    match state.status.as_str() {
269                        "cancelling" => state.status = "사용자 중단".to_owned(),
270                        "finalizing" => state.status = "ready".to_owned(),
271                        _ => {}
272                    }
273                    if quitting {
274                        return Ok(());
275                    }
276                }
277                Err(TryRecvError::Empty) => break,
278                Err(TryRecvError::Disconnected) => {
279                    if state.busy {
280                        return Err("TUI worker stopped unexpectedly".to_owned());
281                    }
282                    return Ok(());
283                }
284            }
285        }
286
287        terminal
288            .draw(|frame| draw(frame, state))
289            .map_err(|error| format!("unable to render TUI: {error}"))?;
290
291        if quitting {
292            thread::sleep(EVENT_POLL);
293            continue;
294        }
295        if event::poll(EVENT_POLL)
296            .map_err(|error| format!("unable to read terminal input: {error}"))?
297        {
298            let event =
299                event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
300            if let Event::Mouse(mouse) = event {
301                let size = terminal
302                    .size()
303                    .map_err(|error| format!("unable to read terminal size: {error}"))?;
304                let max_scroll = max_scroll_for_area(state, size);
305                handle_mouse_event(state, mouse.kind, max_scroll);
306                continue;
307            }
308            let Event::Key(key) = event else {
309                continue;
310            };
311            if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
312                continue;
313            }
314            if is_ctrl_c(&key) {
315                if let Some(token) = state.active_cancel.as_ref() {
316                    let _ = token.cancel();
317                    quitting = true;
318                } else {
319                    return Ok(());
320                }
321                continue;
322            }
323            if !state.busy && state.settings.is_some() {
324                if let Some((model, effort)) = state.handle_settings_key(&key) {
325                    state.settings = Some(SettingsState::Applying {
326                        model: model.clone(),
327                        effort: effort.clone(),
328                    });
329                    requests
330                        .send(WorkerRequest::ApplySettings { model, effort })
331                        .map_err(|_| "TUI worker is unavailable".to_owned())?;
332                }
333                continue;
334            }
335            if key.code == KeyCode::Esc {
336                if let Some(token) = state.active_cancel.as_ref() {
337                    if token.cancel() {
338                        state.status = "cancelling".to_owned();
339                    }
340                }
341                continue;
342            }
343            match key.code {
344                KeyCode::Enter => {
345                    // Shift+Enter (and Alt+Enter fallback) insert a literal
346                    // newline so the user can write multi-line prompts. Plain
347                    // Enter sends the turn. Many terminals cannot distinguish
348                    // Shift+Enter from Enter, so Alt+Enter is also accepted.
349                    if key.modifiers.contains(KeyModifiers::SHIFT)
350                        || key.modifiers.contains(KeyModifiers::ALT)
351                    {
352                        if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
353                            insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
354                            state.input_changed();
355                        }
356                        continue;
357                    }
358                    // A focused built-in is an action, unlike a skill: Enter
359                    // invokes it immediately. Tab remains completion-only.
360                    let text = if let Some(command) = state.focused_builtin_command() {
361                        state.input.clear();
362                        format!("/{}", command.name())
363                    } else {
364                        if state.select_focused_skill() {
365                            continue;
366                        }
367                        std::mem::take(&mut state.input)
368                    };
369                    state.cursor = 0;
370                    if let Some(command) = builtin_command(&text) {
371                        state.reset_skill_picker();
372                        if state.busy {
373                            state.transcript.push(TranscriptItem::Info(format!(
374                                "/{} is available when the current turn finishes",
375                                command.name()
376                            )));
377                            continue;
378                        }
379                        match command {
380                            BuiltinCommand::Settings => {
381                                state.settings = Some(SettingsState::Loading);
382                                requests
383                                    .send(WorkerRequest::Catalog)
384                                    .map_err(|_| "TUI worker is unavailable".to_owned())?;
385                                continue;
386                            }
387                            BuiltinCommand::Exit => return Ok(()),
388                        }
389                    }
390                    state.reset_skill_picker();
391                    if text.trim().is_empty() {
392                        continue;
393                    }
394                    state.auto_scroll = true;
395                    state.scroll = 0;
396                    state.queue_user(&text);
397                    state.busy = true;
398                    state.status = "working".to_owned();
399                    requests
400                        .send(WorkerRequest::Turn { text })
401                        .map_err(|_| "TUI worker is unavailable".to_owned())?;
402                }
403                KeyCode::Tab => {
404                    // Tab completes the focused skill while the slash picker
405                    // is active, using the same first-selection path as Enter.
406                    state.select_focused_skill();
407                }
408                KeyCode::Char(character) => {
409                    if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
410                        insert_at_cursor(&mut state.input, &mut state.cursor, character);
411                        state.input_changed();
412                    }
413                }
414                KeyCode::Backspace => {
415                    if remove_before_cursor(&mut state.input, &mut state.cursor) {
416                        state.input_changed();
417                    }
418                }
419                KeyCode::Left => {
420                    state.cursor = state.cursor.saturating_sub(1);
421                    state.cursor_epoch = Instant::now();
422                }
423                KeyCode::Right => {
424                    state.cursor = (state.cursor + 1).min(state.input.chars().count());
425                    state.cursor_epoch = Instant::now();
426                }
427                KeyCode::Home => {
428                    state.cursor = 0;
429                    state.cursor_epoch = Instant::now();
430                }
431                KeyCode::End => {
432                    state.cursor = state.input.chars().count();
433                    state.cursor_epoch = Instant::now();
434                }
435                KeyCode::Up => {
436                    if !state.move_skill_picker(false) {
437                        let size = terminal
438                            .size()
439                            .map_err(|error| format!("unable to read terminal size: {error}"))?;
440                        let max_scroll = max_scroll_for_area(state, size);
441                        scroll_up(state, max_scroll);
442                    }
443                }
444                KeyCode::Down => {
445                    if !state.move_skill_picker(true) {
446                        let size = terminal
447                            .size()
448                            .map_err(|error| format!("unable to read terminal size: {error}"))?;
449                        let max_scroll = max_scroll_for_area(state, size);
450                        scroll_down(state, max_scroll);
451                    }
452                }
453                KeyCode::PageUp => {
454                    let size = terminal
455                        .size()
456                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
457                    let max_scroll = max_scroll_for_area(state, size);
458                    scroll_up(state, max_scroll);
459                }
460                KeyCode::PageDown => {
461                    let size = terminal
462                        .size()
463                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
464                    let max_scroll = max_scroll_for_area(state, size);
465                    scroll_down(state, max_scroll);
466                }
467                _ => {}
468            }
469        }
470    }
471}
472
473fn is_ctrl_c(key: &KeyEvent) -> bool {
474    key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
475}
476
477fn handle_mouse_event(state: &mut UiState, kind: MouseEventKind, max_scroll: u16) {
478    match kind {
479        MouseEventKind::ScrollUp => scroll_up(state, max_scroll),
480        MouseEventKind::ScrollDown => scroll_down(state, max_scroll),
481        _ => {}
482    }
483}
484
485fn scroll_up(state: &mut UiState, max_scroll: u16) {
486    if state.auto_scroll {
487        state.scroll = max_scroll;
488        state.auto_scroll = false;
489    } else {
490        state.scroll = state.scroll.min(max_scroll);
491    }
492    state.scroll = state.scroll.saturating_sub(3);
493}
494
495fn scroll_down(state: &mut UiState, max_scroll: u16) {
496    if state.auto_scroll {
497        return;
498    }
499    state.scroll = state.scroll.saturating_add(3).min(max_scroll);
500    if state.scroll == max_scroll {
501        // Reaching the real bottom is an explicit request to resume following
502        // the transcript, so subsequent streamed output stays visible.
503        state.auto_scroll = true;
504        state.scroll = 0;
505    }
506}
507
508fn wait_for_worker(worker: JoinHandle<()>, grace: Duration) {
509    let deadline = std::time::Instant::now() + grace;
510    while !worker.is_finished() && std::time::Instant::now() < deadline {
511        thread::sleep(Duration::from_millis(5));
512    }
513    if worker.is_finished() {
514        let _ = worker.join();
515    }
516}
517
518struct TerminalGuard<W: Write> {
519    terminal: Option<Terminal<CrosstermBackend<W>>>,
520    keyboard_enhancement: bool,
521}
522
523impl<W: Write> TerminalGuard<W> {
524    fn new(terminal: Terminal<CrosstermBackend<W>>) -> Self {
525        Self {
526            terminal: Some(terminal),
527            keyboard_enhancement: false,
528        }
529    }
530
531    fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {
532        self.terminal
533            .as_mut()
534            .expect("terminal guard is initialized")
535    }
536}
537
538impl<W: Write> Drop for TerminalGuard<W> {
539    fn drop(&mut self) {
540        let Some(mut terminal) = self.terminal.take() else {
541            return;
542        };
543        if self.keyboard_enhancement {
544            let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
545        }
546        let _ = terminal.show_cursor();
547        let _ = disable_raw_mode();
548        let _ = execute!(
549            terminal.backend_mut(),
550            DisableMouseCapture,
551            LeaveAlternateScreen,
552            Show
553        );
554        let _ = terminal.backend_mut().flush();
555    }
556}
557
558/// Heuristic for terminals that implement the kitty keyboard protocol.
559/// `PushKeyboardEnhancementFlags` is a no-op on supported terminals, but on
560/// unsupported ones the CSI sequence can render as literal text, so it is only
561/// enabled when the terminal advertises support via `TERM`/`TERM_PROGRAM`.
562fn supports_keyboard_enhancement() -> bool {
563    fn env(name: &str) -> Option<String> {
564        std::env::var(name).ok().map(|value| value.to_lowercase())
565    }
566    let term = env("TERM").unwrap_or_default();
567    let program = env("TERM_PROGRAM").unwrap_or_default();
568    if term.starts_with("xterm-kitty")
569        || term.starts_with("ghostty")
570        || term.starts_with("xterm-ghostty")
571    {
572        return true;
573    }
574    matches!(
575        program.as_str(),
576        "ghostty" | "kitty" | "wezterm" | "alacritty" | "foot" | "footclient" | "iterm.app"
577    )
578}
579
580#[derive(Debug, Clone, Copy, PartialEq, Eq)]
581enum TurnNotification {
582    Completed,
583    Interrupted,
584    Failed,
585}
586
587impl TurnNotification {
588    fn body(self) -> &'static str {
589        match self {
590            Self::Completed => "Turn complete",
591            Self::Interrupted => "Turn interrupted",
592            Self::Failed => "Turn failed",
593        }
594    }
595}
596
597fn turn_notification_for_status(status: &str) -> TurnNotification {
598    match status {
599        "cancelling" | "사용자 중단" => TurnNotification::Interrupted,
600        "error" => TurnNotification::Failed,
601        _ => TurnNotification::Completed,
602    }
603}
604
605/// Ask terminal emulators that support OSC 777 to show a desktop notification.
606///
607/// The title and body are fixed Lucy-owned strings rather than model/provider
608/// text, so completion notifications cannot inject terminal control data or
609/// expose a secret. Terminals without OSC 777 support safely ignore the OSC.
610fn send_turn_notification<W: Write>(
611    writer: &mut W,
612    notification: TurnNotification,
613) -> io::Result<()> {
614    writer.write_all(b"\x1b]777;notify;Lucy;")?;
615    writer.write_all(notification.body().as_bytes())?;
616    writer.write_all(b"\x07")?;
617    writer.flush()
618}
619
620fn release_finished_turn<W: Write>(writer: &mut W, state: &mut UiState) {
621    let was_busy = state.busy;
622    let notification = turn_notification_for_status(&state.status);
623    state.busy = false;
624    state.active_cancel = None;
625    if was_busy {
626        // Notification failure must never change the completed turn result or
627        // make the TUI unusable.
628        let _ = send_turn_notification(writer, notification);
629    }
630}
631
632enum WorkerRequest {
633    Turn {
634        text: String,
635    },
636    Catalog,
637    ApplySettings {
638        model: String,
639        effort: Option<String>,
640    },
641    Shutdown,
642}
643
644enum WorkerMessage {
645    Event(ProtocolEvent),
646    Started {
647        cancel: CancellationToken,
648        user_text: Option<String>,
649    },
650    Thinking,
651    ReasoningCompleted,
652    SkillInstructionAttached,
653    ContextUsage(usize),
654    CompactionStarted,
655    CompactionFinished {
656        tokens_before: usize,
657        tokens_after: usize,
658    },
659    Catalog(Result<Vec<ProviderModel>, String>),
660    SettingsApplied(Result<(), String>, String, Option<String>, Option<usize>),
661    Finished,
662}
663
664struct ChannelSink {
665    sender: Sender<WorkerMessage>,
666}
667
668impl EventSink for ChannelSink {
669    fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
670        self.sender
671            .send(WorkerMessage::Event(event.clone()))
672            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
673    }
674
675    fn reasoning_started(&mut self) -> io::Result<()> {
676        self.sender
677            .send(WorkerMessage::Thinking)
678            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
679    }
680
681    fn reasoning_completed(&mut self) -> io::Result<()> {
682        self.sender
683            .send(WorkerMessage::ReasoningCompleted)
684            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
685    }
686
687    fn skill_instruction_attached(&mut self, _name: &str) -> io::Result<()> {
688        self.sender
689            .send(WorkerMessage::SkillInstructionAttached)
690            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
691    }
692
693    fn context_usage(&mut self, tokens: usize) -> io::Result<()> {
694        self.sender
695            .send(WorkerMessage::ContextUsage(tokens))
696            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
697    }
698
699    fn compaction_started(&mut self) -> io::Result<()> {
700        self.sender
701            .send(WorkerMessage::CompactionStarted)
702            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
703    }
704
705    fn compaction_finished(&mut self, tokens_before: usize, tokens_after: usize) -> io::Result<()> {
706        self.sender
707            .send(WorkerMessage::CompactionFinished {
708                tokens_before,
709                tokens_after,
710            })
711            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
712    }
713}
714
715struct UiState {
716    model: String,
717    effort: Option<String>,
718    context_window: Option<usize>,
719    context_tokens: usize,
720    secret: String,
721    transcript: Vec<TranscriptItem>,
722    queued_messages: Vec<String>,
723    input: String,
724    cursor: usize,
725    status: String,
726    busy: bool,
727    active_cancel: Option<CancellationToken>,
728    scroll: u16,
729    auto_scroll: bool,
730    cursor_epoch: Instant,
731    welcome_visible: bool,
732    attached_agents: Vec<String>,
733    skill_names: Vec<String>,
734    skill_picker_focus: usize,
735    skill_picker_suppressed: bool,
736    settings: Option<SettingsState>,
737}
738
739impl UiState {
740    fn from_history(
741        history: &[SessionHistoryRecord],
742        secret: &str,
743        model: &str,
744        effort: Option<&str>,
745        resumed: bool,
746    ) -> Self {
747        let mut state = Self {
748            model: model.to_owned(),
749            effort: effort.map(str::to_owned),
750            context_window: None,
751            context_tokens: 1,
752            secret: secret.to_owned(),
753            transcript: Vec::new(),
754            queued_messages: Vec::new(),
755            input: String::new(),
756            cursor: 0,
757            status: "ready".to_owned(),
758            busy: false,
759            active_cancel: None,
760            scroll: 0,
761            auto_scroll: true,
762            cursor_epoch: Instant::now(),
763            welcome_visible: !resumed && history.is_empty(),
764            attached_agents: Vec::new(),
765            skill_names: Vec::new(),
766            skill_picker_focus: 0,
767            skill_picker_suppressed: false,
768            settings: None,
769        };
770        for record in history {
771            state.add_history_record(record);
772        }
773        state
774    }
775
776    fn with_attached_agents(mut self, attached_agents: Vec<String>) -> Self {
777        self.attached_agents = attached_agents;
778        self
779    }
780
781    fn with_skill_names(mut self, skill_names: Vec<String>) -> Self {
782        self.skill_names = skill_names;
783        self
784    }
785
786    fn with_context(mut self, context_window: Option<usize>, context_tokens: usize) -> Self {
787        self.context_window = context_window;
788        self.context_tokens = context_tokens.max(1);
789        self
790    }
791
792    /// Return matching skills only while the first input character is `/` and
793    /// the user is still writing the command name (rather than its arguments).
794    fn matching_skill_names(&self) -> Vec<&str> {
795        matching_skill_names(&self.input, &self.skill_names)
796    }
797
798    fn reset_skill_picker(&mut self) {
799        self.skill_picker_focus = 0;
800        self.skill_picker_suppressed = false;
801    }
802
803    fn skill_picker_visible(&self) -> bool {
804        !self.skill_picker_suppressed && !self.matching_skill_names().is_empty()
805    }
806
807    fn input_changed(&mut self) {
808        self.reset_skill_picker();
809        self.cursor_epoch = Instant::now();
810    }
811
812    /// Move through the current filter result without wrapping at its ends.
813    /// Returning false lets the caller retain normal transcript scrolling when
814    /// no slash picker is active.
815    fn move_skill_picker(&mut self, down: bool) -> bool {
816        let match_count = self.matching_skill_names().len();
817        if self.skill_picker_suppressed || match_count == 0 {
818            return false;
819        }
820        if down {
821            self.skill_picker_focus = (self.skill_picker_focus + 1).min(match_count - 1);
822        } else {
823            self.skill_picker_focus = self.skill_picker_focus.saturating_sub(1);
824        }
825        true
826    }
827
828    /// Replace the slash query with the focused explicit skill command. The
829    /// normal Enter path then sends that command and the existing turn engine
830    /// attaches the immutable session skill snapshot.
831    /// Return the built-in represented by the focused slash-picker row, if
832    /// any. Built-ins execute on Enter while skills merely complete there.
833    fn focused_builtin_command(&self) -> Option<BuiltinCommand> {
834        let name = *self.matching_skill_names().get(self.skill_picker_focus)?;
835        builtin_command(&format!("/{name}"))
836    }
837
838    fn select_focused_skill(&mut self) -> bool {
839        if self.skill_picker_suppressed {
840            return false;
841        }
842        let Some(name) = self
843            .matching_skill_names()
844            .get(self.skill_picker_focus)
845            .map(|name| (*name).to_owned())
846        else {
847            return false;
848        };
849        self.input = format!("/{name}");
850        self.cursor = self.input.chars().count();
851        // The first Enter chooses a skill; a second Enter sends the completed
852        // command to the normal attachment path.
853        self.skill_picker_suppressed = true;
854        self.cursor_epoch = Instant::now();
855        true
856    }
857
858    fn open_catalog(&mut self, result: Result<Vec<ProviderModel>, String>) {
859        self.settings = Some(match result {
860            Ok(models) => {
861                let focus = models
862                    .iter()
863                    .position(|model| model.id == self.model)
864                    .unwrap_or(0);
865                SettingsState::Models {
866                    models,
867                    query: String::new(),
868                    focus,
869                }
870            }
871            Err(error) => SettingsState::Error(error),
872        });
873    }
874    fn settings_applied(
875        &mut self,
876        result: Result<(), String>,
877        model: String,
878        effort: Option<String>,
879        context_window: Option<usize>,
880    ) {
881        match result {
882            Ok(()) => {
883                self.model = model;
884                self.effort = effort;
885                self.context_window = context_window;
886                self.settings = None;
887                self.transcript
888                    .push(TranscriptItem::Info("⚙ settings applied".to_owned()));
889            }
890            Err(error) => self.settings = Some(SettingsState::Error(error)),
891        }
892    }
893    fn handle_settings_key(&mut self, key: &KeyEvent) -> Option<(String, Option<String>)> {
894        let current_effort = self.effort.clone();
895        match self.settings.as_mut()? {
896            SettingsState::Loading => {
897                if key.code == KeyCode::Esc {
898                    self.settings = None;
899                }
900            }
901            SettingsState::Applying { .. } => {}
902            SettingsState::Error(_) => {
903                if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
904                    self.settings = None;
905                }
906            }
907            SettingsState::Models {
908                models,
909                query,
910                focus,
911            } => match key.code {
912                KeyCode::Esc => self.settings = None,
913                KeyCode::Char(c) => {
914                    query.push(c);
915                    *focus = 0;
916                }
917                KeyCode::Backspace => {
918                    query.pop();
919                    *focus = 0;
920                }
921                KeyCode::Up => *focus = focus.saturating_sub(1),
922                KeyCode::Down => {
923                    let n = models
924                        .iter()
925                        .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
926                        .count();
927                    *focus = (*focus + 1).min(n.saturating_sub(1));
928                }
929                KeyCode::Enter => {
930                    let selected = models
931                        .iter()
932                        .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
933                        .nth(*focus)
934                        .cloned();
935                    if let Some(model) = selected {
936                        let focus = model
937                            .efforts
938                            .as_ref()
939                            .and_then(|efforts| {
940                                current_effort.as_ref().and_then(|current| {
941                                    efforts.iter().position(|effort| effort == current)
942                                })
943                            })
944                            .map_or(0, |index| index + 1);
945                        self.settings = Some(SettingsState::Effort {
946                            model,
947                            input: current_effort.unwrap_or_default(),
948                            focus,
949                        });
950                    }
951                }
952                _ => {}
953            },
954            SettingsState::Effort {
955                model,
956                input,
957                focus,
958            } => match key.code {
959                KeyCode::Esc => self.settings = None,
960                KeyCode::Char(c) if model.efforts.is_none() => input.push(c),
961                KeyCode::Backspace if model.efforts.is_none() => {
962                    input.pop();
963                }
964                KeyCode::Up => *focus = focus.saturating_sub(1),
965                KeyCode::Down => {
966                    if let Some(efforts) = &model.efforts {
967                        *focus = (*focus + 1).min(efforts.len());
968                    }
969                }
970                KeyCode::Enter => {
971                    let effort = match &model.efforts {
972                        Some(efforts) => {
973                            if *focus == 0 {
974                                None
975                            } else {
976                                efforts.get(focus.saturating_sub(1)).cloned()
977                            }
978                        }
979                        None => (!input.trim().is_empty()).then(|| input.trim().to_owned()),
980                    };
981                    return Some((model.id.clone(), effort));
982                }
983                _ => {}
984            },
985        };
986        None
987    }
988
989    fn add_history_record(&mut self, record: &SessionHistoryRecord) {
990        match record {
991            SessionHistoryRecord::ProviderSettings { model, effort, .. } => {
992                self.transcript.push(TranscriptItem::Info(format!(
993                    "⚙ model={model} · effort={}",
994                    effort.as_deref().unwrap_or("default")
995                )))
996            }
997            SessionHistoryRecord::Message { message, .. } => self.add_message(message),
998            SessionHistoryRecord::Interruption {
999                assistant_text,
1000                tool_calls,
1001                tool_results,
1002                reason,
1003                phase,
1004                ..
1005            } => {
1006                if !assistant_text.is_empty() {
1007                    self.add_assistant_message(assistant_text);
1008                }
1009                for call in tool_calls {
1010                    self.add_tool_call(call);
1011                }
1012                for observation in tool_results {
1013                    self.add_tool_result(
1014                        &observation.id,
1015                        &observation.name,
1016                        observation.result.clone(),
1017                    );
1018                }
1019                self.transcript
1020                    .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1021            }
1022            SessionHistoryRecord::Compaction(compaction) => {
1023                self.transcript.push(TranscriptItem::Info(format!(
1024                    "↻ context compacted ({} before)",
1025                    format_context_tokens(compaction.tokens_before)
1026                )));
1027            }
1028        }
1029    }
1030
1031    fn add_message(&mut self, message: &ChatMessage) {
1032        match message.role.as_str() {
1033            "user" => {
1034                let secret = self.secret.clone();
1035                self.add_user(message.content.as_deref().unwrap_or(""), &secret);
1036            }
1037            "assistant" => {
1038                if let Some(content) = message.content.as_deref() {
1039                    self.add_assistant_message(content);
1040                }
1041                for call in &message.tool_calls {
1042                    self.add_tool_call(call);
1043                }
1044            }
1045            "tool" => {
1046                let result = message
1047                    .content
1048                    .as_deref()
1049                    .and_then(|content| serde_json::from_str::<Value>(content).ok())
1050                    .unwrap_or_else(|| Value::String(message.content.clone().unwrap_or_default()));
1051                self.add_tool_result(
1052                    message.tool_call_id.as_deref().unwrap_or(""),
1053                    message.name.as_deref().unwrap_or("cmd"),
1054                    result,
1055                );
1056            }
1057            _ => {}
1058        }
1059    }
1060
1061    fn queue_user(&mut self, text: &str) {
1062        self.queued_messages
1063            .push(redact_secret(text, Some(&self.secret)));
1064    }
1065
1066    fn start_queued_user(&mut self, text: &str) {
1067        let safe = redact_secret(text, Some(&self.secret));
1068        if self.queued_messages.first() == Some(&safe) {
1069            self.queued_messages.remove(0);
1070        } else if let Some(index) = self
1071            .queued_messages
1072            .iter()
1073            .position(|queued| queued == &safe)
1074        {
1075            self.queued_messages.remove(index);
1076        }
1077        self.add_user(text, &self.secret.clone());
1078    }
1079
1080    fn add_user(&mut self, text: &str, secret: &str) {
1081        self.welcome_visible = false;
1082        self.transcript.push(TranscriptItem::User {
1083            text: redact_secret(text, Some(secret)),
1084            skill_instruction_attached: false,
1085        });
1086    }
1087
1088    fn mark_latest_user_skill_attached(&mut self) {
1089        if let Some(TranscriptItem::User {
1090            skill_instruction_attached,
1091            ..
1092        }) = self.transcript.last_mut()
1093        {
1094            *skill_instruction_attached = true;
1095        }
1096    }
1097
1098    fn clear_thinking(&mut self) {
1099        if matches!(
1100            self.transcript.last(),
1101            Some(TranscriptItem::Reasoning { complete: false })
1102        ) {
1103            self.transcript.pop();
1104        }
1105    }
1106
1107    fn show_thinking(&mut self) {
1108        self.status = "working".to_owned();
1109        if !matches!(
1110            self.transcript.last(),
1111            Some(TranscriptItem::Reasoning { complete: false })
1112        ) {
1113            self.transcript
1114                .push(TranscriptItem::Reasoning { complete: false });
1115        }
1116    }
1117
1118    fn complete_reasoning(&mut self) {
1119        if let Some(TranscriptItem::Reasoning { complete }) = self.transcript.last_mut() {
1120            *complete = true;
1121        }
1122    }
1123
1124    fn add_assistant(&mut self, text: &str) {
1125        self.clear_thinking();
1126        if let Some(TranscriptItem::Assistant(current)) = self.transcript.last_mut() {
1127            current.push_str(text);
1128        } else {
1129            self.add_assistant_message(text);
1130        }
1131    }
1132
1133    fn add_assistant_message(&mut self, text: &str) {
1134        self.transcript
1135            .push(TranscriptItem::Assistant(text.to_owned()));
1136    }
1137
1138    fn add_tool_call(&mut self, call: &crate::model::ChatToolCall) {
1139        self.clear_thinking();
1140        self.transcript.push(TranscriptItem::ToolCall {
1141            id: call.id.clone(),
1142            name: call.name.clone(),
1143            arguments: call.arguments.clone(),
1144        });
1145    }
1146
1147    fn add_tool_result(&mut self, id: &str, name: &str, result: Value) {
1148        self.transcript.push(TranscriptItem::ToolResult {
1149            id: id.to_owned(),
1150            name: name.to_owned(),
1151            result,
1152        });
1153    }
1154
1155    fn cursor_visible(&self) -> bool {
1156        (self.cursor_epoch.elapsed().as_millis() / CURSOR_BLINK_INTERVAL.as_millis())
1157            .is_multiple_of(2)
1158    }
1159
1160    fn apply_event(&mut self, event: ProtocolEvent) {
1161        match event {
1162            ProtocolEvent::Session { .. } => {}
1163            ProtocolEvent::AssistantDelta { text } => self.add_assistant(&text),
1164            ProtocolEvent::ToolCall {
1165                id,
1166                name,
1167                arguments,
1168            } => self.add_tool_call(&crate::model::ChatToolCall {
1169                id,
1170                name,
1171                arguments,
1172            }),
1173            ProtocolEvent::ToolResult { id, name, result } => {
1174                self.add_tool_result(&id, &name, result)
1175            }
1176            ProtocolEvent::TurnEnd => {
1177                self.complete_reasoning();
1178                self.status = "finalizing".to_owned();
1179                self.transcript
1180                    .push(TranscriptItem::Info("✓ turn complete".to_owned()));
1181            }
1182            ProtocolEvent::TurnInterrupted { reason, phase } => {
1183                self.complete_reasoning();
1184                self.status = "cancelling".to_owned();
1185                self.transcript
1186                    .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1187            }
1188            ProtocolEvent::Error { message } => {
1189                self.complete_reasoning();
1190                self.status = "error".to_owned();
1191                self.transcript.push(TranscriptItem::Error(message));
1192            }
1193        }
1194    }
1195}
1196
1197#[derive(Debug, Clone, PartialEq)]
1198enum TranscriptItem {
1199    User {
1200        text: String,
1201        skill_instruction_attached: bool,
1202    },
1203    Assistant(String),
1204    ToolCall {
1205        id: String,
1206        name: String,
1207        arguments: String,
1208    },
1209    ToolResult {
1210        id: String,
1211        name: String,
1212        result: Value,
1213    },
1214    Error(String),
1215    Info(String),
1216    Reasoning {
1217        complete: bool,
1218    },
1219}
1220
1221fn ui_layout(state: &UiState, area: Rect) -> (Rect, Option<Rect>, Rect, Rect) {
1222    let input_rows = input_visible_rows(state, area.width.saturating_sub(2));
1223    let input_height = input_rows.clamp(1, MAX_INPUT_ROWS) + 2;
1224    let chunks = Layout::default()
1225        .direction(Direction::Vertical)
1226        .constraints([
1227            Constraint::Min(1),
1228            Constraint::Length(input_height),
1229            Constraint::Length(1),
1230        ])
1231        .split(area);
1232    let picker_height = skill_picker_height(state);
1233    let picker_area = (picker_height > 0).then(|| {
1234        Rect::new(
1235            chunks[1].x,
1236            chunks[1].y.saturating_sub(picker_height),
1237            chunks[1].width,
1238            picker_height,
1239        )
1240    });
1241    (chunks[0], picker_area, chunks[1], chunks[2])
1242}
1243
1244fn max_scroll_for_area(state: &UiState, size: Size) -> u16 {
1245    let area = Rect::new(0, 0, size.width, size.height);
1246    let (chat_chunk, _, _, _) = ui_layout(state, area);
1247    let chat_height = chat_chunk.height.saturating_sub(1);
1248    let lines = transcript_lines(state, chat_chunk.width);
1249    lines
1250        .len()
1251        .saturating_sub(chat_height as usize)
1252        .min(u16::MAX as usize) as u16
1253}
1254
1255/// Number of wrapped rows the current input occupies at `width`.
1256fn input_visible_rows(state: &UiState, width: u16) -> u16 {
1257    let width = width as usize;
1258    if width == 0 {
1259        return 1;
1260    }
1261    let prompt = input_display_text(state);
1262    let wrapped = wrap_text(&prompt, width);
1263    wrapped.len().max(1) as u16
1264}
1265
1266fn input_prompt(input: &str) -> String {
1267    input.to_owned()
1268}
1269
1270fn input_display_text(state: &UiState) -> String {
1271    redact_secret(&input_prompt(&state.input), Some(&state.secret))
1272}
1273
1274fn command_names(mut skill_names: Vec<String>) -> Vec<String> {
1275    skill_names.extend(BUILTIN_COMMANDS.into_iter().map(str::to_owned));
1276    skill_names.sort();
1277    skill_names.dedup();
1278    skill_names
1279}
1280
1281#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1282enum BuiltinCommand {
1283    Settings,
1284    Exit,
1285}
1286
1287impl BuiltinCommand {
1288    fn name(self) -> &'static str {
1289        match self {
1290            Self::Settings => "settings",
1291            Self::Exit => "exit",
1292        }
1293    }
1294}
1295
1296fn builtin_command(input: &str) -> Option<BuiltinCommand> {
1297    match input.split_whitespace().next()? {
1298        "/settings" => Some(BuiltinCommand::Settings),
1299        "/exit" => Some(BuiltinCommand::Exit),
1300        _ => None,
1301    }
1302}
1303
1304/// The slash picker only accepts a command at the beginning of the message.
1305/// Once whitespace starts arguments, normal message entry resumes.
1306fn matching_skill_names<'a>(input: &str, skill_names: &'a [String]) -> Vec<&'a str> {
1307    let Some(query) = input.strip_prefix('/') else {
1308        return Vec::new();
1309    };
1310    if query.chars().any(char::is_whitespace) {
1311        return Vec::new();
1312    }
1313    skill_names
1314        .iter()
1315        .map(String::as_str)
1316        .filter(|name| name.starts_with(query))
1317        .collect()
1318}
1319
1320fn skill_picker_height(state: &UiState) -> u16 {
1321    if state.skill_picker_visible() {
1322        // Header, visible commands, and the rounded top/bottom border.
1323        (state
1324            .matching_skill_names()
1325            .len()
1326            .min(SKILL_PICKER_MAX_ROWS)
1327            + 3) as u16
1328    } else {
1329        0
1330    }
1331}
1332
1333fn skill_picker_range(total: usize, focus: usize) -> std::ops::Range<usize> {
1334    let start = focus
1335        .saturating_add(1)
1336        .saturating_sub(SKILL_PICKER_MAX_ROWS);
1337    start.min(total)..(start + SKILL_PICKER_MAX_ROWS).min(total)
1338}
1339
1340/// Return the command portion of a currently valid explicit skill invocation.
1341/// This mirrors the command grammar used by the turn engine, while keeping the
1342/// styling concern local to the TUI.
1343fn active_skill_trigger<'a>(input: &'a str, skill_names: &[String]) -> Option<&'a str> {
1344    let invocation = input.strip_prefix('/')?;
1345    let name = invocation
1346        .split_once(char::is_whitespace)
1347        .map_or(invocation, |(name, _)| name);
1348    if name.is_empty() || !skill_names.iter().any(|skill_name| skill_name == name) {
1349        return None;
1350    }
1351    Some(&input[..1 + name.len()])
1352}
1353
1354/// Preserve input wrapping while styling a recognized `/<name>` prefix
1355/// independently from any arguments the user is still entering.
1356fn styled_text_lines(
1357    input: &str,
1358    active_skill_trigger: Option<&str>,
1359    width: usize,
1360    text_style: Style,
1361) -> Vec<Line<'static>> {
1362    let trigger_len = active_skill_trigger.map_or(0, |trigger| trigger.chars().count());
1363    let mut char_offset = 0usize;
1364    let mut lines = Vec::new();
1365
1366    for source_line in input.split('\n') {
1367        for row in wrap_line(source_line, width) {
1368            let mut spans = Vec::new();
1369            let mut text = String::new();
1370            let mut highlighted = None;
1371            for character in row.chars() {
1372                let should_highlight = char_offset < trigger_len;
1373                if highlighted != Some(should_highlight) && !text.is_empty() {
1374                    spans.push(styled_text_span(
1375                        std::mem::take(&mut text),
1376                        highlighted.unwrap_or(false),
1377                        text_style,
1378                    ));
1379                }
1380                highlighted = Some(should_highlight);
1381                text.push(character);
1382                char_offset += 1;
1383            }
1384            if !text.is_empty() {
1385                spans.push(styled_text_span(
1386                    text,
1387                    highlighted.unwrap_or(false),
1388                    text_style,
1389                ));
1390            }
1391            if spans.is_empty() {
1392                spans.push(Span::styled(String::new(), text_style));
1393            }
1394            lines.push(Line::from(spans));
1395        }
1396        // `split` retains empty trailing lines; account for the newline that
1397        // separated this source line from the next one in the character index.
1398        char_offset += 1;
1399    }
1400
1401    lines
1402}
1403
1404fn styled_text_span(text: String, highlighted: bool, text_style: Style) -> Span<'static> {
1405    if highlighted {
1406        Span::styled(text, Style::default().fg(Color::Cyan))
1407    } else {
1408        Span::styled(text, text_style)
1409    }
1410}
1411
1412fn cursor_row(input: &str, cursor: usize, width: usize) -> u16 {
1413    let prefix: String = input.chars().take(cursor).collect();
1414    wrap_text(&prefix, width)
1415        .len()
1416        .saturating_sub(1)
1417        .min(u16::MAX as usize) as u16
1418}
1419
1420fn insert_at_cursor(input: &mut String, cursor: &mut usize, character: char) {
1421    let byte_index = input
1422        .char_indices()
1423        .nth(*cursor)
1424        .map_or(input.len(), |(index, _)| index);
1425    input.insert(byte_index, character);
1426    *cursor += 1;
1427}
1428
1429fn remove_before_cursor(input: &mut String, cursor: &mut usize) -> bool {
1430    if *cursor == 0 {
1431        return false;
1432    }
1433    let end = input
1434        .char_indices()
1435        .nth(*cursor)
1436        .map_or(input.len(), |(index, _)| index);
1437    let start = input
1438        .char_indices()
1439        .nth(*cursor - 1)
1440        .map(|(index, _)| index)
1441        .unwrap_or(0);
1442    input.replace_range(start..end, "");
1443    *cursor -= 1;
1444    true
1445}
1446
1447fn draw(frame: &mut Frame<'_>, state: &UiState) {
1448    let (chat_chunk, picker_area, input_chunk, status_area) = ui_layout(state, frame.area());
1449
1450    // Activity is conversation state, not terminal help text. Keep it in the
1451    // chat viewport so the picker can intentionally overlay its ready state.
1452    let chat_height = chat_chunk.height.saturating_sub(1);
1453    let chat_area = Rect::new(chat_chunk.x, chat_chunk.y, chat_chunk.width, chat_height);
1454    // The skill picker is a true overlay: it must not resize or scroll the transcript.
1455    let visible_chat_area = chat_area;
1456    let activity_area = Rect::new(
1457        chat_chunk.x,
1458        chat_chunk.y + chat_height,
1459        chat_chunk.width,
1460        chat_chunk.height - chat_height,
1461    );
1462
1463    let width = chat_chunk.width;
1464    if state.welcome_visible {
1465        let welcome_lines = welcome_lines(&state.attached_agents);
1466        let welcome_height = (welcome_lines.len() as u16).min(visible_chat_area.height);
1467        let welcome_area = Rect::new(
1468            visible_chat_area.x,
1469            visible_chat_area.y + visible_chat_area.height.saturating_sub(welcome_height) / 2,
1470            visible_chat_area.width,
1471            welcome_height,
1472        );
1473        let welcome = Paragraph::new(welcome_lines).alignment(Alignment::Center);
1474        frame.render_widget(welcome, welcome_area);
1475    } else {
1476        let lines = transcript_lines(state, width);
1477        let available = visible_chat_area.height as usize;
1478        let max_scroll = lines.len().saturating_sub(available).min(u16::MAX as usize) as u16;
1479        let scroll = if state.auto_scroll {
1480            max_scroll
1481        } else {
1482            state.scroll.min(max_scroll)
1483        };
1484        let transcript = Paragraph::new(lines).scroll((scroll, 0));
1485        frame.render_widget(transcript, visible_chat_area);
1486    }
1487
1488    let queued_suffix = (!state.queued_messages.is_empty()).then(|| {
1489        format!(
1490            " · queued {}: {}",
1491            state.queued_messages.len(),
1492            state.queued_messages.join(" | ")
1493        )
1494    });
1495    let activity_text = if state.status == "working" {
1496        format!("{} working", spinner_frame(state))
1497    } else if state.status == "compacting" {
1498        format!("{} compacting", spinner_frame(state))
1499    } else {
1500        format!("● {}", state.status)
1501    };
1502    let activity = activity_line(state, &activity_text, queued_suffix.as_deref());
1503    frame.render_widget(Paragraph::new(activity), activity_area);
1504
1505    if let Some(picker_area) = picker_area {
1506        draw_skill_picker(frame, state, picker_area);
1507    }
1508
1509    let input_style = user_message_style();
1510    let input_text_style = Style::default().fg(Color::White);
1511    let input_block = Block::default()
1512        .borders(Borders::ALL)
1513        .border_type(ratatui::widgets::BorderType::Rounded)
1514        .border_style(input_style);
1515    let input_area = input_block.inner(input_chunk);
1516    let prompt = input_display_text(state);
1517    let input_rows =
1518        input_visible_rows(state, frame.area().width.saturating_sub(2)).clamp(1, MAX_INPUT_ROWS);
1519    let wrapped = wrap_text(&prompt, input_area.width.max(1) as usize);
1520    let visible = (wrapped.len() as u16).clamp(1, input_rows);
1521    let cursor_row = cursor_row(&prompt, state.cursor, input_area.width.max(1) as usize);
1522    let bottom_scroll = (wrapped.len() as u16).saturating_sub(visible);
1523    let cursor_scroll = (cursor_row + 1).saturating_sub(visible);
1524    let input_scroll = bottom_scroll.min(cursor_scroll);
1525    let active_skill_trigger = (!state.busy)
1526        .then(|| active_skill_trigger(&prompt, &state.skill_names))
1527        .flatten();
1528    let input_lines = styled_text_lines(
1529        &prompt,
1530        active_skill_trigger,
1531        input_area.width.max(1) as usize,
1532        input_text_style,
1533    );
1534    let input = Paragraph::new(input_lines)
1535        .style(input_text_style)
1536        .scroll((input_scroll, 0))
1537        .block(input_block);
1538    frame.render_widget(input, input_chunk);
1539
1540    let effort = state.effort.as_deref().unwrap_or("default");
1541    let status_text = format!("model={} · effort={effort}", state.model);
1542    let context_text = context_status_text(state);
1543    let context_width = UnicodeWidthStr::width(context_text.as_str()) as u16;
1544    let context_area_width = context_width.min(status_area.width.saturating_sub(1));
1545    let status_chunks = Layout::default()
1546        .direction(Direction::Horizontal)
1547        .constraints([Constraint::Min(1), Constraint::Length(context_area_width)])
1548        .split(status_area);
1549    let status = Paragraph::new(redact_secret(&status_text, Some(&state.secret)))
1550        .style(Style::default().fg(Color::DarkGray));
1551    frame.render_widget(status, status_chunks[0]);
1552    if context_area_width > 0 {
1553        let context = Paragraph::new(context_text)
1554            .alignment(Alignment::Right)
1555            .style(context_status_style(state));
1556        frame.render_widget(context, status_chunks[1]);
1557    }
1558    if let Some(settings) = &state.settings {
1559        draw_settings(frame, settings, frame.area());
1560    }
1561
1562    // Ratatui shows the cursor when a frame requests a position and hides it
1563    // when this branch is skipped, which provides the blink phase.
1564    if state.settings.is_none() && state.cursor_visible() && !input_area.is_empty() && visible > 0 {
1565        let cursor_prefix: String = prompt.chars().take(state.cursor).collect();
1566        let cursor_rows = wrap_text(&cursor_prefix, input_area.width.max(1) as usize);
1567        let cursor_line = cursor_rows.last().map(String::as_str).unwrap_or("");
1568        let cursor_offset = UnicodeWidthStr::width(cursor_line) as u16;
1569        let cursor_x = input_area.x + cursor_offset.min(input_area.width.saturating_sub(1));
1570        let cursor_y = input_area.y + cursor_row.saturating_sub(input_scroll);
1571        frame.set_cursor_position((cursor_x, cursor_y));
1572    }
1573}
1574
1575enum SettingsState {
1576    Loading,
1577    Applying {
1578        model: String,
1579        effort: Option<String>,
1580    },
1581    Error(String),
1582    Models {
1583        models: Vec<ProviderModel>,
1584        query: String,
1585        focus: usize,
1586    },
1587    Effort {
1588        model: ProviderModel,
1589        input: String,
1590        focus: usize,
1591    },
1592}
1593fn draw_settings(frame: &mut Frame<'_>, settings: &SettingsState, area: Rect) {
1594    let width = area
1595        .width
1596        .saturating_sub(2)
1597        .min(SETTINGS_MAX_WIDTH)
1598        .max(SETTINGS_MIN_WIDTH.min(area.width));
1599    let height = area
1600        .height
1601        .saturating_sub(2)
1602        .min(SETTINGS_MAX_HEIGHT)
1603        .max(SETTINGS_MIN_HEIGHT.min(area.height));
1604    let popup = Rect::new(
1605        area.x + area.width.saturating_sub(width) / 2,
1606        area.y + area.height.saturating_sub(height) / 2,
1607        width,
1608        height,
1609    );
1610    frame.render_widget(Clear, popup);
1611    let block = Block::default()
1612        .title(" /settings ")
1613        .borders(Borders::ALL)
1614        .border_style(Style::default().fg(Color::Cyan));
1615    let inner = block.inner(popup);
1616    frame.render_widget(block, popup);
1617
1618    let lines = match settings {
1619        SettingsState::Loading => vec![
1620            Line::styled("Loading provider models…", Style::default().fg(Color::Cyan)),
1621            Line::raw(""),
1622            Line::styled("Esc  cancel", Style::default().fg(Color::DarkGray)),
1623        ],
1624        SettingsState::Applying { model, effort } => vec![
1625            Line::styled("Applying selection…", Style::default().fg(Color::Cyan)),
1626            Line::raw(model.clone()),
1627            Line::raw(format!(
1628                "effort: {}",
1629                effort.as_deref().unwrap_or("default")
1630            )),
1631        ],
1632        SettingsState::Error(error) => vec![
1633            Line::styled("Unable to update settings", Style::default().fg(Color::Red)),
1634            Line::raw(""),
1635            Line::raw(error.clone()),
1636            Line::raw(""),
1637            Line::styled("Enter/Esc  close", Style::default().fg(Color::DarkGray)),
1638        ],
1639        SettingsState::Models {
1640            models,
1641            query,
1642            focus,
1643        } => {
1644            let query_lower = query.to_lowercase();
1645            let filtered = models
1646                .iter()
1647                .filter(|model| model.id.to_lowercase().contains(&query_lower))
1648                .collect::<Vec<_>>();
1649            let focus = (*focus).min(filtered.len().saturating_sub(1));
1650            let list_rows = inner.height.saturating_sub(4) as usize;
1651            let range = selection_range(filtered.len(), focus, list_rows);
1652            let mut lines = vec![
1653                Line::from(vec![
1654                    Span::styled("Model  ", Style::default().fg(Color::DarkGray)),
1655                    Span::styled(
1656                        if query.is_empty() {
1657                            "type to filter…"
1658                        } else {
1659                            query
1660                        },
1661                        Style::default().fg(if query.is_empty() {
1662                            Color::DarkGray
1663                        } else {
1664                            Color::White
1665                        }),
1666                    ),
1667                ]),
1668                Line::styled(
1669                    format!(
1670                        "{} models{}",
1671                        filtered.len(),
1672                        if filtered.is_empty() {
1673                            ""
1674                        } else {
1675                            " · ↑/↓ move · Enter choose"
1676                        }
1677                    ),
1678                    Style::default().fg(Color::DarkGray),
1679                ),
1680            ];
1681            if filtered.is_empty() {
1682                lines.push(Line::styled(
1683                    "No matching models",
1684                    Style::default().fg(Color::Yellow),
1685                ));
1686            } else {
1687                for index in range {
1688                    let selected = index == focus;
1689                    lines.push(Line::styled(
1690                        format!(
1691                            "{} {}",
1692                            if selected { "›" } else { " " },
1693                            filtered[index].id
1694                        ),
1695                        if selected {
1696                            Style::default().fg(Color::Black).bg(Color::Cyan)
1697                        } else {
1698                            Style::default().fg(Color::White)
1699                        },
1700                    ));
1701                }
1702            }
1703            lines.push(Line::styled(
1704                "Esc  cancel",
1705                Style::default().fg(Color::DarkGray),
1706            ));
1707            lines
1708        }
1709        SettingsState::Effort {
1710            model,
1711            input,
1712            focus,
1713        } => {
1714            let mut lines = vec![
1715                Line::styled(model.id.clone(), Style::default().fg(Color::Cyan)),
1716                Line::styled("Reasoning effort", Style::default().fg(Color::DarkGray)),
1717            ];
1718            match &model.efforts {
1719                Some(efforts) => {
1720                    let total = efforts.len() + 1;
1721                    let focus = (*focus).min(total.saturating_sub(1));
1722                    let list_rows = inner.height.saturating_sub(4) as usize;
1723                    for index in selection_range(total, focus, list_rows) {
1724                        let value = if index == 0 {
1725                            "default"
1726                        } else {
1727                            efforts[index - 1].as_str()
1728                        };
1729                        let selected = index == focus;
1730                        lines.push(Line::styled(
1731                            format!("{} {value}", if selected { "›" } else { " " }),
1732                            if selected {
1733                                Style::default().fg(Color::Black).bg(Color::Cyan)
1734                            } else {
1735                                Style::default().fg(Color::White)
1736                            },
1737                        ));
1738                    }
1739                    lines.push(Line::styled(
1740                        "↑/↓ move · Enter save · Esc cancel",
1741                        Style::default().fg(Color::DarkGray),
1742                    ));
1743                }
1744                None => {
1745                    lines.push(Line::raw("Provider did not advertise allowed efforts."));
1746                    lines.push(Line::from(vec![
1747                        Span::styled("Value  ", Style::default().fg(Color::DarkGray)),
1748                        Span::styled(
1749                            if input.is_empty() { "default" } else { input },
1750                            Style::default().fg(Color::White),
1751                        ),
1752                    ]));
1753                    lines.push(Line::styled(
1754                        "Type a value · Enter save · Esc cancel",
1755                        Style::default().fg(Color::DarkGray),
1756                    ));
1757                }
1758            }
1759            lines
1760        }
1761    };
1762    frame.render_widget(Paragraph::new(lines), inner);
1763}
1764
1765fn selection_range(total: usize, focus: usize, max_rows: usize) -> std::ops::Range<usize> {
1766    if total == 0 || max_rows == 0 {
1767        return 0..0;
1768    }
1769    let focus = focus.min(total - 1);
1770    let visible = total.min(max_rows);
1771    let start = focus
1772        .saturating_add(1)
1773        .saturating_sub(visible)
1774        .min(total - visible);
1775    start..start + visible
1776}
1777
1778fn draw_skill_picker(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
1779    let matches = state.matching_skill_names();
1780    let total = matches.len();
1781    if total == 0 || area.is_empty() {
1782        return;
1783    }
1784
1785    // The picker is painted last, over the existing transcript and activity;
1786    // its geometry never participates in the underlying layout.
1787    frame.render_widget(Clear, area);
1788    let block = Block::default()
1789        .borders(Borders::ALL)
1790        .border_type(BorderType::Rounded)
1791        .border_style(Style::default().fg(Color::Cyan));
1792    let inner = block.inner(area);
1793    frame.render_widget(block, area);
1794
1795    let focus = state.skill_picker_focus.min(total - 1);
1796    let header = Line::styled(
1797        format!("[{}/{}]", focus + 1, total),
1798        Style::default().fg(Color::DarkGray),
1799    );
1800    frame.render_widget(
1801        Paragraph::new(header),
1802        Rect::new(inner.x, inner.y, inner.width, 1),
1803    );
1804
1805    for (row, index) in skill_picker_range(total, focus).enumerate() {
1806        let style = if index == focus {
1807            Style::default().fg(Color::Cyan)
1808        } else {
1809            Style::default().fg(Color::DarkGray)
1810        };
1811        let skill = Line::styled(format!("/{}", matches[index]), style);
1812        frame.render_widget(
1813            Paragraph::new(skill),
1814            Rect::new(inner.x, inner.y + 1 + row as u16, inner.width, 1),
1815        );
1816    }
1817}
1818
1819fn welcome_line() -> Line<'static> {
1820    let character_count = WELCOME_MESSAGE.chars().count();
1821    let spans = WELCOME_MESSAGE
1822        .chars()
1823        .enumerate()
1824        .map(|(index, character)| {
1825            let progress = if character_count <= 1 {
1826                0.0
1827            } else {
1828                index as f32 / (character_count - 1) as f32
1829            };
1830            let color = Color::Rgb(
1831                interpolate_color(WELCOME_START_COLOR.0, WELCOME_END_COLOR.0, progress),
1832                interpolate_color(WELCOME_START_COLOR.1, WELCOME_END_COLOR.1, progress),
1833                interpolate_color(WELCOME_START_COLOR.2, WELCOME_END_COLOR.2, progress),
1834            );
1835            Span::styled(character.to_string(), Style::default().fg(color))
1836        })
1837        .collect::<Vec<_>>();
1838    Line::from(spans)
1839}
1840
1841fn interpolate_color(start: u8, end: u8, progress: f32) -> u8 {
1842    (start as f32 + (end as f32 - start as f32) * progress).round() as u8
1843}
1844
1845fn welcome_lines(attached_agents: &[String]) -> Vec<Line<'static>> {
1846    let mut lines = vec![
1847        welcome_line(),
1848        Line::styled(WELCOME_TAGLINE, Style::default().fg(Color::DarkGray)),
1849        Line::raw(""),
1850    ];
1851
1852    if attached_agents.is_empty() {
1853        lines.push(Line::styled(
1854            "Attached AGENTS.md: none",
1855            Style::default().fg(Color::DarkGray),
1856        ));
1857    } else {
1858        lines.push(Line::styled(
1859            "Attached AGENTS.md:",
1860            Style::default().fg(Color::DarkGray),
1861        ));
1862        lines.extend(
1863            attached_agents.iter().map(|path| {
1864                Line::styled(format!("• {path}"), Style::default().fg(Color::DarkGray))
1865            }),
1866        );
1867    }
1868
1869    lines
1870}
1871
1872fn transcript_lines(state: &UiState, width: u16) -> Vec<Line<'static>> {
1873    let width = width.max(1) as usize;
1874    let mut lines = Vec::new();
1875    let mut rendered_item = false;
1876
1877    for (index, item) in state.transcript.iter().enumerate() {
1878        // Results are positioned on their matching call, even when the model
1879        // emitted several calls before execution produced any result.
1880        if is_result_attached_to_call(&state.transcript, index) {
1881            continue;
1882        }
1883        if rendered_item {
1884            lines.push(Line::raw(String::new()));
1885        }
1886        match item {
1887            TranscriptItem::User {
1888                text,
1889                skill_instruction_attached,
1890            } => {
1891                let text = redact_secret(text, Some(&state.secret));
1892                let trigger = skill_instruction_attached
1893                    .then(|| active_skill_trigger(&text, &state.skill_names))
1894                    .flatten();
1895                push_user_message_block(&mut lines, &text, trigger, width);
1896            }
1897            TranscriptItem::Assistant(text) => {
1898                let text = redact_secret(text, Some(&state.secret));
1899                push_wrapped(&mut lines, &text, width, Style::default());
1900            }
1901            TranscriptItem::ToolCall {
1902                id,
1903                name,
1904                arguments,
1905            } => {
1906                let call_text = format!("[tool:{name} {}]", call_arguments(arguments));
1907                let call_text = redact_secret(&call_text, Some(&state.secret));
1908                let result = matching_tool_result(&state.transcript, index, id);
1909                let mut segments = vec![(
1910                    call_text,
1911                    if result.is_some() {
1912                        tool_call_style()
1913                    } else {
1914                        pending_tool_call_style()
1915                    },
1916                )];
1917                if let Some(result) = result {
1918                    let result_text =
1919                        redact_secret(&format_tool_result(result), Some(&state.secret));
1920                    segments.push((" > ".to_owned(), Style::default()));
1921                    segments.push((result_text, tool_result_style()));
1922                } else {
1923                    segments.push((
1924                        format!(" {}", spinner_frame(state)),
1925                        pending_tool_call_style(),
1926                    ));
1927                }
1928                push_spans_wrapped(&mut lines, &segments, width);
1929            }
1930            TranscriptItem::ToolResult {
1931                id: _,
1932                name: _,
1933                result,
1934            } => {
1935                let result_text = format_tool_result(result);
1936                let result_text = redact_secret(&result_text, Some(&state.secret));
1937                push_spans_wrapped(&mut lines, &[(result_text, tool_result_style())], width);
1938            }
1939            TranscriptItem::Error(text) => {
1940                let text = redact_secret(text, Some(&state.secret));
1941                push_wrapped(&mut lines, &text, width, error_style());
1942            }
1943            TranscriptItem::Info(text) => {
1944                let text = redact_secret(text, Some(&state.secret));
1945                push_wrapped(&mut lines, &text, width, info_style());
1946            }
1947            TranscriptItem::Reasoning { complete } => {
1948                let text = if *complete {
1949                    "Reasoning Complete".to_owned()
1950                } else {
1951                    format!("Reasoning... {}", spinner_frame(state))
1952                };
1953                push_wrapped(&mut lines, &text, width, thinking_style());
1954            }
1955        }
1956        rendered_item = true;
1957    }
1958    if lines.is_empty() {
1959        lines.push(Line::raw(""));
1960    }
1961    lines
1962}
1963
1964fn matching_tool_result<'a>(
1965    transcript: &'a [TranscriptItem],
1966    call_index: usize,
1967    call_id: &str,
1968) -> Option<&'a Value> {
1969    transcript
1970        .iter()
1971        .skip(call_index + 1)
1972        .find_map(|item| match item {
1973            TranscriptItem::ToolResult { id, result, .. } if id == call_id => Some(result),
1974            _ => None,
1975        })
1976}
1977
1978fn is_result_attached_to_call(transcript: &[TranscriptItem], result_index: usize) -> bool {
1979    let TranscriptItem::ToolResult { id, .. } = &transcript[result_index] else {
1980        return false;
1981    };
1982    let Some(call_index) = transcript[..result_index].iter().rposition(
1983        |item| matches!(item, TranscriptItem::ToolCall { id: call_id, .. } if call_id == id),
1984    ) else {
1985        return false;
1986    };
1987    !transcript[call_index + 1..result_index].iter().any(
1988        |item| matches!(item, TranscriptItem::ToolResult { id: result_id, .. } if result_id == id),
1989    )
1990}
1991
1992/// Render tool call arguments as the command string inside double quotes, for
1993/// example `"cat README.md"`. Previews are truncated to the same limit as tool
1994/// results; malformed arguments fall back to truncated raw text.
1995fn call_arguments(arguments: &str) -> String {
1996    let parsed: Value = match serde_json::from_str(arguments) {
1997        Ok(value) => value,
1998        Err(_) => return truncate_output(arguments),
1999    };
2000    if let Some(command) = parsed.get("command").and_then(Value::as_str) {
2001        return format!("\"{}\"", truncate_output(command));
2002    }
2003    let serialized = serde_json::to_string(&parsed).unwrap_or_else(|_| arguments.to_owned());
2004    truncate_output(&serialized)
2005}
2006
2007/// Render a tool result as a single-line JSON-string-array literal containing
2008/// stdout (or stderr when stdout is empty). Newlines are escaped so the whole
2009/// result stays on one line. Output is truncated to `RESULT_PREVIEW_CHARS`.
2010fn format_tool_result(result: &Value) -> String {
2011    let stdout = result.get("stdout").and_then(Value::as_str).unwrap_or("");
2012    let stderr = result.get("stderr").and_then(Value::as_str).unwrap_or("");
2013    let output = if !stdout.is_empty() { stdout } else { stderr };
2014    let truncated = truncate_output(output);
2015    // Build a JSON string literal so newlines and quotes are escaped and the
2016    // result renders on a single line as `["..."]`.
2017    let json_string = serde_json::to_string(&truncated).unwrap_or_else(|_| "\"\"".to_owned());
2018    format!("[{json_string}]")
2019}
2020
2021const RESULT_PREVIEW_CHARS: usize = 50;
2022
2023fn truncate_output(output: &str) -> String {
2024    let mut result: String = output.chars().take(RESULT_PREVIEW_CHARS).collect();
2025    if output.chars().count() > RESULT_PREVIEW_CHARS {
2026        result.push('…');
2027    }
2028    result
2029}
2030
2031fn user_message_style() -> Style {
2032    Style::default().fg(USER_BORDER_COLOR)
2033}
2034
2035/// Render a full-width rounded outline so user messages visually match the
2036/// rounded prompt border while assistant and tool output stays borderless.
2037fn push_user_message_block(
2038    lines: &mut Vec<Line<'static>>,
2039    text: &str,
2040    active_skill_trigger: Option<&str>,
2041    width: usize,
2042) {
2043    if width < 2 {
2044        lines.extend(styled_text_lines(
2045            text,
2046            active_skill_trigger,
2047            width.max(1),
2048            Style::default().fg(Color::White),
2049        ));
2050        return;
2051    }
2052
2053    let content_width = width - 2;
2054    let border_style = user_message_style();
2055    let rows = styled_text_lines(
2056        text,
2057        active_skill_trigger,
2058        content_width,
2059        Style::default().fg(Color::White),
2060    );
2061    lines.push(Line::styled(
2062        format!("╭{}╮", "─".repeat(content_width)),
2063        border_style,
2064    ));
2065    for row in rows {
2066        let row_width = UnicodeWidthStr::width(row.to_string().as_str());
2067        let padding = content_width.saturating_sub(row_width);
2068        let mut spans = Vec::with_capacity(row.spans.len() + 3);
2069        spans.push(Span::styled("│", border_style));
2070        spans.extend(row.spans);
2071        spans.push(Span::styled(
2072            " ".repeat(padding),
2073            Style::default().fg(Color::White),
2074        ));
2075        spans.push(Span::styled("│", border_style));
2076        lines.push(Line::from(spans));
2077    }
2078    lines.push(Line::styled(
2079        format!("╰{}╯", "─".repeat(content_width)),
2080        border_style,
2081    ));
2082}
2083
2084fn tool_call_style() -> Style {
2085    Style::default().fg(Color::Magenta)
2086}
2087
2088fn pending_tool_call_style() -> Style {
2089    Style::default().fg(PENDING_TOOL_COLOR)
2090}
2091
2092fn tool_result_style() -> Style {
2093    Style::default().fg(Color::DarkGray)
2094}
2095
2096fn error_style() -> Style {
2097    Style::default().fg(Color::Red)
2098}
2099
2100fn info_style() -> Style {
2101    Style::default().fg(Color::DarkGray)
2102}
2103
2104fn context_status_text(state: &UiState) -> String {
2105    let used = format_context_tokens(state.context_tokens);
2106    let Some(window) = state.context_window else {
2107        return format!("ctx={used}/? (?%)");
2108    };
2109    let percentage = context_percentage(state.context_tokens, window);
2110    format!(
2111        "ctx={used}/{} ({percentage}%)",
2112        format_context_tokens(window)
2113    )
2114}
2115
2116fn context_status_style(state: &UiState) -> Style {
2117    if state
2118        .context_window
2119        .is_some_and(|window| context_over_threshold(state.context_tokens, window))
2120    {
2121        Style::default().fg(PENDING_TOOL_COLOR)
2122    } else {
2123        Style::default().fg(Color::DarkGray)
2124    }
2125}
2126
2127fn context_percentage(used: usize, window: usize) -> usize {
2128    if window == 0 {
2129        return 0;
2130    }
2131    ((used as u128 * 100).div_ceil(window as u128)) as usize
2132}
2133
2134fn context_over_threshold(used: usize, window: usize) -> bool {
2135    window > 0 && (used as u128 * 100) > (window as u128 * 80)
2136}
2137
2138fn format_context_tokens(tokens: usize) -> String {
2139    if tokens >= 1_000_000 {
2140        format!("{:.2}M", tokens as f64 / 1_000_000.0)
2141    } else if tokens >= 1_000 {
2142        format!("{:.1}K", tokens as f64 / 1_000.0)
2143    } else {
2144        tokens.to_string()
2145    }
2146}
2147
2148fn activity_line<'a>(state: &UiState, text: &'a str, queued_suffix: Option<&'a str>) -> Line<'a> {
2149    if state.status != "working" {
2150        return Line::styled(text, activity_style_for(state));
2151    }
2152    let elapsed = state.cursor_epoch.elapsed();
2153    let character_count = text.chars().count().max(1);
2154    let mut spans = text
2155        .chars()
2156        .enumerate()
2157        .map(|(index, character)| {
2158            Span::styled(
2159                character.to_string(),
2160                Style::default().fg(working_gradient_color_at(elapsed, index, character_count)),
2161            )
2162        })
2163        .collect::<Vec<_>>();
2164    if let Some(suffix) = queued_suffix {
2165        // Queue state is information, not activity: retain a stable style so
2166        // the broad working sweep never makes it harder to scan.
2167        spans.push(Span::styled(suffix, Style::default().fg(Color::DarkGray)));
2168    }
2169    Line::from(spans)
2170}
2171
2172fn activity_style_for(state: &UiState) -> Style {
2173    if state.status == "compacting" {
2174        Style::default().fg(PENDING_TOOL_COLOR)
2175    } else {
2176        Style::default().fg(Color::Cyan)
2177    }
2178}
2179
2180fn working_gradient_colors_at(position: f64) -> (Color, Color, f64) {
2181    let position = position.rem_euclid(WORKING_GRADIENT_COLORS.len() as f64);
2182    let phase = position.floor() as usize;
2183    let progress = position.fract();
2184    let start = WORKING_GRADIENT_COLORS[phase];
2185    let end = WORKING_GRADIENT_COLORS[(phase + 1) % WORKING_GRADIENT_COLORS.len()];
2186    (
2187        Color::Rgb(start.0, start.1, start.2),
2188        Color::Rgb(end.0, end.1, end.2),
2189        progress,
2190    )
2191}
2192
2193/// Move a continuous warm gradient across the activity text. Offset each
2194/// character slightly along the same time-based palette, rather than swapping
2195/// whole character bands at a boundary.
2196fn working_gradient_color_at(
2197    elapsed: Duration,
2198    character_index: usize,
2199    character_count: usize,
2200) -> Color {
2201    const SWEEP_WIDTH: f64 = 0.35;
2202
2203    let position = elapsed.as_secs_f64() / WORKING_GRADIENT_CYCLE.as_secs_f64()
2204        + character_index as f64 / character_count.max(1) as f64 * SWEEP_WIDTH;
2205    let (
2206        Color::Rgb(red_start, green_start, blue_start),
2207        Color::Rgb(red_end, green_end, blue_end),
2208        progress,
2209    ) = working_gradient_colors_at(position)
2210    else {
2211        unreachable!("working gradient palette always uses RGB colours")
2212    };
2213
2214    Color::Rgb(
2215        interpolate_color(red_start, red_end, progress as f32),
2216        interpolate_color(green_start, green_end, progress as f32),
2217        interpolate_color(blue_start, blue_end, progress as f32),
2218    )
2219}
2220
2221fn thinking_style() -> Style {
2222    Style::default().fg(Color::DarkGray)
2223}
2224
2225fn spinner_frame(state: &UiState) -> char {
2226    const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
2227    let tick = state.cursor_epoch.elapsed().as_millis() / 100;
2228    FRAMES[(tick as usize) % FRAMES.len()]
2229}
2230
2231fn push_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, style: Style) {
2232    let mut added = false;
2233    for piece in wrap_text(text, width) {
2234        lines.push(Line::styled(piece, style));
2235        added = true;
2236    }
2237    if !added {
2238        lines.push(Line::styled(String::new(), style));
2239    }
2240}
2241
2242/// Push a logical line built from styled segments. When the rendered width
2243/// exceeds `width`, the whole line is character-wrapped; wrapped continuations
2244/// keep the style of the segment they fall on.
2245fn push_spans_wrapped(lines: &mut Vec<Line<'static>>, segments: &[(String, Style)], width: usize) {
2246    let mut current_spans: Vec<Span<'static>> = Vec::new();
2247    let mut current_width = 0usize;
2248    for (text, style) in segments {
2249        for character in text.chars() {
2250            let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
2251            if current_width + char_width > width && !current_spans.is_empty() {
2252                lines.push(Line::from(std::mem::take(&mut current_spans)));
2253                current_width = 0;
2254            }
2255            let mut buffer = [0u8; 4];
2256            let s = character.encode_utf8(&mut buffer);
2257            current_spans.push(Span::styled(s.to_owned(), *style));
2258            current_width += char_width;
2259        }
2260    }
2261    if current_spans.is_empty() {
2262        current_spans.push(Span::raw(String::new()));
2263    }
2264    lines.push(Line::from(current_spans));
2265}
2266
2267/// Wrap `text` into rows no wider than `width` display columns. Wrapping is
2268/// character-based so the row count matches exactly what a non-wrapping
2269/// `Paragraph` renderer draws, which keeps auto-scroll pinned to the true
2270/// bottom of the transcript regardless of terminal width. Empty lines are
2271/// preserved as empty rows.
2272fn wrap_text(text: &str, width: usize) -> Vec<String> {
2273    if width == 0 {
2274        return text.lines().map(str::to_owned).collect();
2275    }
2276    let mut rows = Vec::new();
2277    // `split` preserves a trailing empty row, so Shift+Enter renders an
2278    // immediate new line even before another character is typed.
2279    for line in text.split('\n') {
2280        rows.extend(wrap_line(line, width));
2281    }
2282    if rows.is_empty() {
2283        rows.push(String::new());
2284    }
2285    rows
2286}
2287
2288fn wrap_line(line: &str, width: usize) -> Vec<String> {
2289    let mut rows = Vec::new();
2290    let mut current = String::new();
2291    let mut current_width = 0usize;
2292    for character in line.chars() {
2293        let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
2294        if current_width + char_width > width && !current.is_empty() {
2295            rows.push(std::mem::take(&mut current));
2296            current_width = 0;
2297        }
2298        current.push(character);
2299        current_width += char_width;
2300    }
2301    rows.push(current);
2302    rows
2303}
2304
2305#[cfg(test)]
2306mod tests {
2307    use super::*;
2308
2309    #[test]
2310    fn turn_notifications_use_osc_777_with_fixed_secret_safe_messages() {
2311        let cases = [
2312            (TurnNotification::Completed, "Turn complete"),
2313            (TurnNotification::Interrupted, "Turn interrupted"),
2314            (TurnNotification::Failed, "Turn failed"),
2315        ];
2316
2317        for (notification, body) in cases {
2318            let mut output = Vec::new();
2319            send_turn_notification(&mut output, notification).expect("notification");
2320            assert_eq!(
2321                output,
2322                format!("\x1b]777;notify;Lucy;{body}\x07").into_bytes()
2323            );
2324        }
2325    }
2326
2327    #[test]
2328    fn turn_notifications_follow_the_terminal_turn_status() {
2329        assert_eq!(
2330            turn_notification_for_status("finalizing"),
2331            TurnNotification::Completed
2332        );
2333        assert_eq!(
2334            turn_notification_for_status("cancelling"),
2335            TurnNotification::Interrupted
2336        );
2337        assert_eq!(
2338            turn_notification_for_status("error"),
2339            TurnNotification::Failed
2340        );
2341    }
2342
2343    struct FailingWriter;
2344
2345    impl Write for FailingWriter {
2346        fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
2347            Err(io::Error::other("notification sink unavailable"))
2348        }
2349
2350        fn flush(&mut self) -> io::Result<()> {
2351            Err(io::Error::other("notification sink unavailable"))
2352        }
2353    }
2354
2355    #[test]
2356    fn notification_write_failure_does_not_keep_the_tui_busy() {
2357        let mut state = UiState::from_history(&[], "secret", "model", None, false);
2358        state.busy = true;
2359        state.active_cancel = Some(CancellationToken::new());
2360        let mut writer = FailingWriter;
2361
2362        release_finished_turn(&mut writer, &mut state);
2363
2364        assert!(!state.busy);
2365        assert!(state.active_cancel.is_none());
2366    }
2367
2368    #[test]
2369    fn an_idle_finish_does_not_emit_a_duplicate_notification() {
2370        let mut state = UiState::from_history(&[], "secret", "model", None, false);
2371        let mut output = Vec::new();
2372
2373        release_finished_turn(&mut output, &mut state);
2374
2375        assert!(output.is_empty());
2376    }
2377
2378    #[test]
2379    fn context_status_shows_used_window_and_percentage() {
2380        let mut state = UiState::from_history(&[], "secret", "model", None, false)
2381            .with_context(Some(100_000), 80_000);
2382
2383        assert_eq!(context_status_text(&state), "ctx=80.0K/100.0K (80%)");
2384        assert_eq!(context_status_style(&state).fg, Some(Color::DarkGray));
2385
2386        state.context_tokens = 80_001;
2387        assert_eq!(context_status_text(&state), "ctx=80.0K/100.0K (81%)");
2388        assert_eq!(context_status_style(&state).fg, Some(PENDING_TOOL_COLOR));
2389    }
2390
2391    #[test]
2392    fn context_status_handles_unknown_window_without_highlighting() {
2393        let state = UiState::from_history(&[], "secret", "model", None, false);
2394
2395        assert_eq!(context_status_text(&state), "ctx=1/? (?%)");
2396        assert_eq!(context_status_style(&state).fg, Some(Color::DarkGray));
2397    }
2398
2399    #[test]
2400    fn context_status_is_right_aligned_and_turns_orange_above_eighty_percent() {
2401        let state =
2402            UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
2403        let mut terminal =
2404            Terminal::new(ratatui::backend::TestBackend::new(60, 10)).expect("test terminal");
2405
2406        terminal
2407            .draw(|frame| draw(frame, &state))
2408            .expect("draw statusline");
2409
2410        let buffer = terminal.backend().buffer();
2411        let status_row = 9;
2412        let context = context_status_text(&state);
2413        let context_start = 60 - context.chars().count();
2414        assert_eq!(buffer[(context_start as u16, status_row)].symbol(), "c");
2415        assert_eq!(buffer[(59, status_row)].symbol(), ")");
2416        assert_eq!(buffer[(59, status_row)].fg, PENDING_TOOL_COLOR);
2417    }
2418
2419    #[test]
2420    fn working_activity_uses_a_continuous_warm_gradient() {
2421        let text = "⠋ working";
2422        let colors = text
2423            .chars()
2424            .enumerate()
2425            .map(|(index, _)| {
2426                working_gradient_color_at(Duration::from_millis(2_500), index, text.chars().count())
2427            })
2428            .collect::<Vec<_>>();
2429
2430        assert_eq!(colors.first(), Some(&Color::Rgb(228, 40, 120)));
2431        assert_eq!(colors.last(), Some(&Color::Rgb(232, 43, 86)));
2432        assert!(colors.windows(2).all(|pair| pair[0] != pair[1]));
2433
2434        let (start, end, _) = working_gradient_colors_at(1.0);
2435        assert_eq!(start, Color::Rgb(235, 45, 65));
2436        assert_eq!(end, Color::Rgb(255, 130, 25));
2437
2438        let (start, end, _) = working_gradient_colors_at(3.0);
2439        assert_eq!(start, Color::Rgb(235, 45, 65));
2440        assert_eq!(end, Color::Rgb(220, 35, 175));
2441    }
2442
2443    #[test]
2444    fn queued_activity_suffix_is_not_in_the_working_gradient() {
2445        let mut state = UiState::from_history(&[], "secret", "model", None, false);
2446        state.status = "working".to_owned();
2447        let line = activity_line(&state, "⠋ working", Some(" · queued 1: next task"));
2448        assert!(line.spans[.."⠋ working".chars().count()]
2449            .iter()
2450            .all(|span| span.style.fg != Some(Color::DarkGray)));
2451        assert_eq!(
2452            line.spans.last().expect("queue suffix").style.fg,
2453            Some(Color::DarkGray)
2454        );
2455    }
2456
2457    #[test]
2458    fn fresh_sessions_show_the_versioned_gradient_welcome_message() {
2459        let state = UiState::from_history(&[], "secret", "model", None, false);
2460        assert!(state.welcome_visible);
2461
2462        let line = welcome_line();
2463        assert_eq!(line.to_string(), WELCOME_MESSAGE);
2464        assert_eq!(
2465            line.spans.first().and_then(|span| span.style.fg),
2466            Some(Color::Rgb(
2467                WELCOME_START_COLOR.0,
2468                WELCOME_START_COLOR.1,
2469                WELCOME_START_COLOR.2,
2470            ))
2471        );
2472        assert_eq!(
2473            line.spans.last().and_then(|span| span.style.fg),
2474            Some(Color::Rgb(
2475                WELCOME_END_COLOR.0,
2476                WELCOME_END_COLOR.1,
2477                WELCOME_END_COLOR.2,
2478            ))
2479        );
2480    }
2481
2482    #[test]
2483    fn welcome_shows_the_tagline_and_attached_agents_paths() {
2484        let state = UiState::from_history(&[], "secret", "model", None, false)
2485            .with_attached_agents(vec![
2486                "/workspace/AGENTS.md".to_owned(),
2487                "/workspace/app/AGENTS.md".to_owned(),
2488            ]);
2489        let lines = welcome_lines(&state.attached_agents);
2490
2491        assert_eq!(lines[1].to_string(), WELCOME_TAGLINE);
2492        assert_eq!(lines[1].style.fg, Some(Color::DarkGray));
2493        assert_eq!(lines[3].to_string(), "Attached AGENTS.md:");
2494        assert_eq!(lines[4].to_string(), "• /workspace/AGENTS.md");
2495        assert_eq!(lines[5].to_string(), "• /workspace/app/AGENTS.md");
2496        assert!(lines[3..]
2497            .iter()
2498            .all(|line| line.style.fg == Some(Color::DarkGray)));
2499    }
2500
2501    #[test]
2502    fn welcome_reports_when_no_agents_file_is_attached() {
2503        let lines = welcome_lines(&[]);
2504        assert_eq!(
2505            lines.last().expect("empty context line").to_string(),
2506            "Attached AGENTS.md: none"
2507        );
2508    }
2509
2510    #[test]
2511    fn resumed_sessions_do_not_show_the_welcome_message() {
2512        let state = UiState::from_history(&[], "secret", "model", None, true);
2513        assert!(!state.welcome_visible);
2514    }
2515
2516    #[test]
2517    fn history_replay_keeps_interruption_after_messages() {
2518        let history = vec![
2519            SessionHistoryRecord::Message {
2520                timestamp: 1,
2521                message: ChatMessage::user("hello".to_owned()),
2522            },
2523            SessionHistoryRecord::Interruption {
2524                timestamp: 2,
2525                reason: "user_cancelled".to_owned(),
2526                phase: "provider_stream".to_owned(),
2527                assistant_text: "partial".to_owned(),
2528                tool_calls: Vec::new(),
2529                tool_results: Vec::new(),
2530            },
2531        ];
2532        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
2533        assert!(matches!(state.transcript[0], TranscriptItem::User { .. }));
2534        assert!(matches!(state.transcript[1], TranscriptItem::Assistant(_)));
2535        assert!(matches!(state.transcript[2], TranscriptItem::Info(_)));
2536        let text = transcript_lines(&state, 80)
2537            .iter()
2538            .map(ToString::to_string)
2539            .collect::<Vec<_>>()
2540            .join("\n");
2541        assert!(!text.contains("choices"));
2542    }
2543
2544    #[test]
2545    fn history_replay_does_not_render_assistant_reasoning_details() {
2546        let mut message = ChatMessage::assistant("visible answer".to_owned(), Vec::new());
2547        message.reasoning_details = Some(vec![serde_json::json!({
2548            "type": "reasoning.text",
2549            "text": "private reasoning"
2550        })]);
2551        let history = [SessionHistoryRecord::Message {
2552            timestamp: 1,
2553            message,
2554        }];
2555        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
2556        let text = transcript_lines(&state, 80)
2557            .iter()
2558            .map(ToString::to_string)
2559            .collect::<Vec<_>>()
2560            .join("\n");
2561        assert!(text.contains("visible answer"));
2562        assert!(!text.contains("private reasoning"));
2563        assert!(!text.contains("reasoning_details"));
2564    }
2565
2566    #[test]
2567    fn history_replay_preserves_repeated_records() {
2568        let history = vec![
2569            SessionHistoryRecord::Message {
2570                timestamp: 1,
2571                message: ChatMessage::assistant("same".to_owned(), Vec::new()),
2572            },
2573            SessionHistoryRecord::Interruption {
2574                timestamp: 2,
2575                reason: "user_cancelled".to_owned(),
2576                phase: "provider_stream".to_owned(),
2577                assistant_text: "same".to_owned(),
2578                tool_calls: Vec::new(),
2579                tool_results: Vec::new(),
2580            },
2581        ];
2582        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
2583        assert_eq!(
2584            state
2585                .transcript
2586                .iter()
2587                .filter(|item| matches!(item, TranscriptItem::Assistant(text) if text == "same"))
2588                .count(),
2589            2
2590        );
2591    }
2592
2593    #[test]
2594    fn user_message_borders_remain_muted_yellow_while_its_text_is_white() {
2595        let history = [SessionHistoryRecord::Message {
2596            timestamp: 1,
2597            message: ChatMessage::user("hello".to_owned()),
2598        }];
2599        let state = UiState::from_history(&history, "provider-secret", "model", None, false);
2600        let lines = transcript_lines(&state, 12);
2601
2602        assert_eq!(lines.len(), 3);
2603        assert_eq!(lines[0].to_string(), "╭──────────╮");
2604        assert_eq!(lines[1].to_string(), "│hello     │");
2605        assert_eq!(lines[2].to_string(), "╰──────────╯");
2606        assert_eq!(lines[0].style.fg, Some(USER_BORDER_COLOR));
2607        assert_eq!(lines[2].style.fg, Some(USER_BORDER_COLOR));
2608        assert_eq!(lines[1].spans[0].style.fg, Some(USER_BORDER_COLOR));
2609        assert_eq!(lines[1].spans[1].style.fg, Some(Color::White));
2610        assert_eq!(
2611            lines[1].spans.last().map(|span| span.style.fg),
2612            Some(Some(USER_BORDER_COLOR))
2613        );
2614    }
2615
2616    #[test]
2617    fn attached_skill_highlights_its_trigger_in_the_user_message_without_a_notice_line() {
2618        let mut state = UiState::from_history(&[], "secret", "model", None, false)
2619            .with_skill_names(vec!["release-notes".to_owned()]);
2620        state.add_user("/release-notes v1.2.0", "secret");
2621        state.mark_latest_user_skill_attached();
2622
2623        let lines = transcript_lines(&state, 40);
2624        assert_eq!(lines.len(), 3);
2625        let cyan_text = lines[1]
2626            .spans
2627            .iter()
2628            .filter(|span| span.style.fg == Some(Color::Cyan))
2629            .map(|span| span.content.as_ref())
2630            .collect::<String>();
2631        assert_eq!(cyan_text, "/release-notes");
2632        assert!(!lines
2633            .iter()
2634            .any(|line| line.to_string().contains("instruction attached")));
2635    }
2636
2637    #[test]
2638    fn transcript_rendering_redacts_history_content() {
2639        let history = [SessionHistoryRecord::Message {
2640            timestamp: 1,
2641            message: ChatMessage::assistant("provider-secret".to_owned(), Vec::new()),
2642        }];
2643        let state = UiState::from_history(&history, "provider-secret", "model", None, false);
2644        let text = transcript_lines(&state, 80)
2645            .iter()
2646            .map(ToString::to_string)
2647            .collect::<Vec<_>>()
2648            .join("\n");
2649        assert!(!text.contains("provider-secret"));
2650    }
2651
2652    #[test]
2653    fn mouse_wheel_disables_following_and_changes_scroll_offset() {
2654        let history = [SessionHistoryRecord::Message {
2655            timestamp: 1,
2656            message: ChatMessage::user("hello".to_owned()),
2657        }];
2658        let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
2659        handle_mouse_event(&mut state, MouseEventKind::ScrollUp, 10);
2660        assert!(!state.auto_scroll);
2661        assert_eq!(state.scroll, 7);
2662        handle_mouse_event(&mut state, MouseEventKind::ScrollDown, 10);
2663        assert!(
2664            state.auto_scroll,
2665            "reaching the bottom resumes transcript following"
2666        );
2667        assert_eq!(state.scroll, 0);
2668        scroll_up(&mut state, 10);
2669        assert!(!state.auto_scroll);
2670        assert_eq!(state.scroll, 7);
2671    }
2672
2673    #[test]
2674    fn wrap_text_breaks_long_lines_and_preserves_empty_lines() {
2675        let rows = wrap_text("12345\n\nabc", 3);
2676        assert_eq!(rows, vec!["123", "45", "", "abc"]);
2677    }
2678
2679    #[test]
2680    fn wrap_line_never_returns_an_empty_vec() {
2681        assert_eq!(wrap_line("", 5), vec![""]);
2682        assert_eq!(wrap_line("abc", 5), vec!["abc"]);
2683    }
2684
2685    #[test]
2686    fn completion_event_does_not_release_input_before_worker_finishes() {
2687        let history = [SessionHistoryRecord::Message {
2688            timestamp: 1,
2689            message: ChatMessage::user("hello".to_owned()),
2690        }];
2691        let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
2692        state.busy = true;
2693        state.active_cancel = Some(CancellationToken::new());
2694        state.apply_event(ProtocolEvent::TurnEnd);
2695        assert!(state.busy);
2696        assert!(state.active_cancel.is_some());
2697        assert_eq!(state.status, "finalizing");
2698    }
2699
2700    #[test]
2701    fn transcript_inserts_a_blank_line_between_items() {
2702        let history = [
2703            SessionHistoryRecord::Message {
2704                timestamp: 1,
2705                message: ChatMessage::user("hi".to_owned()),
2706            },
2707            SessionHistoryRecord::Message {
2708                timestamp: 2,
2709                message: ChatMessage::assistant("hello".to_owned(), Vec::new()),
2710            },
2711        ];
2712        let state = UiState::from_history(&history, "secret", "model", None, false);
2713        let lines = transcript_lines(&state, 80);
2714        assert_eq!(lines.len(), 5);
2715        assert_eq!(lines[0].to_string(), format!("╭{}╮", "─".repeat(78)));
2716        assert_eq!(lines[1].to_string(), format!("│hi{}│", " ".repeat(76)));
2717        assert_eq!(lines[2].to_string(), format!("╰{}╯", "─".repeat(78)));
2718        assert_eq!(lines[3].to_string(), "");
2719        assert_eq!(lines[4].to_string(), "hello");
2720    }
2721
2722    #[test]
2723    fn tool_call_renders_as_compact_single_line_with_command() {
2724        let history = [SessionHistoryRecord::Message {
2725            timestamp: 1,
2726            message: ChatMessage::assistant(
2727                String::new(),
2728                vec![crate::model::ChatToolCall {
2729                    id: "call-1".to_owned(),
2730                    name: "cmd".to_owned(),
2731                    arguments: r#"{"command":"pwd"}"#.to_owned(),
2732                }],
2733            ),
2734        }];
2735        let state = UiState::from_history(&history, "secret", "model", None, false);
2736        let text = transcript_lines(&state, 80)
2737            .iter()
2738            .map(ToString::to_string)
2739            .collect::<Vec<_>>()
2740            .join("\n");
2741        assert!(text.contains("[tool:cmd \"pwd\"]"));
2742        // The raw JSON arguments must not appear verbatim.
2743        assert!(!text.contains("{\"command\":\"pwd\"}"));
2744    }
2745
2746    #[test]
2747    fn pending_tool_calls_are_orange_and_show_a_spinner() {
2748        let history = [SessionHistoryRecord::Message {
2749            timestamp: 1,
2750            message: ChatMessage::assistant(
2751                String::new(),
2752                vec![crate::model::ChatToolCall {
2753                    id: "call-1".to_owned(),
2754                    name: "cmd".to_owned(),
2755                    arguments: r#"{"command":"pwd"}"#.to_owned(),
2756                }],
2757            ),
2758        }];
2759        let state = UiState::from_history(&history, "secret", "model", None, false);
2760        let line = &transcript_lines(&state, 80)[0];
2761
2762        assert!(line.to_string().contains("[tool:cmd \"pwd\"] "));
2763        assert!(line
2764            .spans
2765            .iter()
2766            .all(|span| span.style.fg == Some(PENDING_TOOL_COLOR)));
2767        assert!(line.spans.iter().any(|span| {
2768            span.content
2769                .chars()
2770                .any(|character| "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏".contains(character))
2771        }));
2772    }
2773
2774    #[test]
2775    fn tool_call_truncates_long_arguments() {
2776        let command = "a".repeat(80);
2777        let arguments = serde_json::json!({"command": command}).to_string();
2778        let history = [SessionHistoryRecord::Message {
2779            timestamp: 1,
2780            message: ChatMessage::assistant(
2781                String::new(),
2782                vec![crate::model::ChatToolCall {
2783                    id: "call-1".to_owned(),
2784                    name: "cmd".to_owned(),
2785                    arguments,
2786                }],
2787            ),
2788        }];
2789        let state = UiState::from_history(&history, "secret", "model", None, false);
2790        let text = transcript_lines(&state, 200)[0].to_string();
2791        assert!(text.contains(&format!("[tool:cmd \"{}…\"]", "a".repeat(50))));
2792        assert!(!text.contains(&"a".repeat(51)));
2793    }
2794
2795    #[test]
2796    fn tool_call_and_result_render_on_one_line_with_truncated_stdout() {
2797        let long_stdout = "a".repeat(80);
2798        let history = vec![
2799            SessionHistoryRecord::Message {
2800                timestamp: 1,
2801                message: ChatMessage::assistant(
2802                    String::new(),
2803                    vec![crate::model::ChatToolCall {
2804                        id: "call-1".to_owned(),
2805                        name: "cmd".to_owned(),
2806                        arguments: r#"{"command":"cat README.md"}"#.to_owned(),
2807                    }],
2808                ),
2809            },
2810            SessionHistoryRecord::Message {
2811                timestamp: 2,
2812                message: ChatMessage::tool(
2813                    "call-1".to_owned(),
2814                    "cmd".to_owned(),
2815                    serde_json::json!({
2816                        "command": "cat README.md",
2817                        "exit_code": 0,
2818                        "stdout": long_stdout,
2819                        "stderr": "",
2820                    })
2821                    .to_string(),
2822                ),
2823            },
2824        ];
2825        let state = UiState::from_history(&history, "secret", "model", None, false);
2826        let lines = transcript_lines(&state, 200);
2827        // Call and result share one logical line (no blank line between them).
2828        assert_eq!(lines.len(), 1);
2829        let text = lines[0].to_string();
2830        assert!(text.starts_with("[tool:cmd \"cat README.md\"] > ["));
2831        // stdout is truncated to 50 chars plus the ellipsis.
2832        assert!(text.contains(&"a".repeat(50)));
2833        assert!(text.contains('…'));
2834        assert!(!text.contains(&"a".repeat(51)));
2835    }
2836
2837    #[test]
2838    fn tool_result_falls_back_to_stderr_when_stdout_is_empty() {
2839        let history = vec![
2840            SessionHistoryRecord::Message {
2841                timestamp: 1,
2842                message: ChatMessage::assistant(
2843                    String::new(),
2844                    vec![crate::model::ChatToolCall {
2845                        id: "call-1".to_owned(),
2846                        name: "cmd".to_owned(),
2847                        arguments: r#"{"command":"bad"}"#.to_owned(),
2848                    }],
2849                ),
2850            },
2851            SessionHistoryRecord::Message {
2852                timestamp: 2,
2853                message: ChatMessage::tool(
2854                    "call-1".to_owned(),
2855                    "cmd".to_owned(),
2856                    serde_json::json!({
2857                        "command": "bad",
2858                        "exit_code": 127,
2859                        "stdout": "",
2860                        "stderr": "not found",
2861                    })
2862                    .to_string(),
2863                ),
2864            },
2865        ];
2866        let state = UiState::from_history(&history, "secret", "model", None, false);
2867        let text = transcript_lines(&state, 200)[0].to_string();
2868        assert!(text.contains("not found"));
2869        assert!(text.contains(" > "));
2870    }
2871
2872    #[test]
2873    fn tool_call_and_result_styles_use_foreground_colors() {
2874        let history = vec![
2875            SessionHistoryRecord::Message {
2876                timestamp: 1,
2877                message: ChatMessage::assistant(
2878                    String::new(),
2879                    vec![crate::model::ChatToolCall {
2880                        id: "call-1".to_owned(),
2881                        name: "cmd".to_owned(),
2882                        arguments: r#"{"command":"pwd"}"#.to_owned(),
2883                    }],
2884                ),
2885            },
2886            SessionHistoryRecord::Message {
2887                timestamp: 2,
2888                message: ChatMessage::tool(
2889                    "call-1".to_owned(),
2890                    "cmd".to_owned(),
2891                    serde_json::json!({"stdout":"x","stderr":""}).to_string(),
2892                ),
2893            },
2894        ];
2895        let state = UiState::from_history(&history, "secret", "model", None, false);
2896        let lines = transcript_lines(&state, 200);
2897        let spans = &lines[0].spans;
2898        // Calls and results retain their distinct foreground styles, with an
2899        // unstyled separator between them.
2900        assert_eq!(spans[0].style.fg, Some(Color::Magenta));
2901        assert!(spans.iter().any(|span| span.style.fg.is_none()));
2902        let result_text = spans
2903            .iter()
2904            .filter(|span| span.style.fg == Some(Color::DarkGray))
2905            .map(|span| span.content.as_ref())
2906            .collect::<String>();
2907        assert_eq!(result_text, "[\"x\"]");
2908        assert!(!lines[0]
2909            .to_string()
2910            .chars()
2911            .any(|character| "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏".contains(character)));
2912    }
2913
2914    #[test]
2915    fn recognized_skill_trigger_is_highlighted_but_arguments_remain_default_colored() {
2916        let trigger = active_skill_trigger("/release-notes v1.2.0", &["release-notes".to_owned()]);
2917        assert_eq!(trigger, Some("/release-notes"));
2918
2919        let lines = styled_text_lines(
2920            "/release-notes v1.2.0",
2921            trigger,
2922            80,
2923            Style::default().fg(Color::White),
2924        );
2925        assert_eq!(lines.len(), 1);
2926        assert_eq!(lines[0].to_string(), "/release-notes v1.2.0");
2927        assert_eq!(lines[0].spans[0].content, "/release-notes");
2928        assert_eq!(lines[0].spans[0].style.fg, Some(Color::Cyan));
2929        assert_eq!(lines[0].spans[1].content, " v1.2.0");
2930        assert_eq!(lines[0].spans[1].style.fg, Some(Color::White));
2931    }
2932
2933    #[test]
2934    fn draw_renders_an_active_skill_trigger_in_cyan() {
2935        let mut state = UiState::from_history(&[], "secret", "model", None, false)
2936            .with_skill_names(vec!["release-notes".to_owned()]);
2937        state.input = "/release-notes v1.2.0".to_owned();
2938        state.cursor = state.input.chars().count();
2939
2940        let mut terminal =
2941            Terminal::new(ratatui::backend::TestBackend::new(40, 10)).expect("test terminal");
2942        terminal
2943            .draw(|frame| draw(frame, &state))
2944            .expect("draw input");
2945
2946        // The one-row input area starts at (1, 7): trigger characters are cyan,
2947        // while the argument that follows keeps the normal white input color.
2948        let buffer = terminal.backend().buffer();
2949        assert_eq!(buffer[(1, 7)].fg, Color::Cyan);
2950        assert_eq!(buffer[(21, 7)].fg, Color::White);
2951    }
2952
2953    #[test]
2954    fn busy_input_keeps_normal_style_and_a_blinking_input_cursor() {
2955        let mut state = UiState::from_history(&[], "secret", "model", None, false);
2956        state.busy = true;
2957        state.cursor_epoch = Instant::now();
2958
2959        let mut terminal =
2960            Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
2961        terminal
2962            .draw(|frame| draw(frame, &state))
2963            .expect("draw waiting input");
2964
2965        let buffer = terminal.backend().buffer();
2966        assert_eq!(buffer[(0, 6)].fg, USER_BORDER_COLOR);
2967        assert_eq!(buffer[(1, 7)].symbol(), " ");
2968        assert_eq!(input_display_text(&state), "");
2969        terminal.backend_mut().assert_cursor_position((1, 7));
2970        state.input = "queued message".to_owned();
2971        assert_eq!(input_display_text(&state), "queued message");
2972    }
2973
2974    #[test]
2975    fn only_known_leading_skill_commands_activate_input_highlighting() {
2976        let skills = ["release-notes".to_owned()];
2977        assert_eq!(
2978            active_skill_trigger("/missing", &skills),
2979            None,
2980            "unknown commands are rejected by the turn engine and must not look active"
2981        );
2982        assert_eq!(
2983            active_skill_trigger("/skill:release-notes", &skills),
2984            None,
2985            "the removed /skill: wrapper must not look active"
2986        );
2987        assert_eq!(
2988            active_skill_trigger("write /release-notes", &skills),
2989            None,
2990            "only the command prefix accepted by the turn engine is active"
2991        );
2992        assert_eq!(active_skill_trigger("/", &skills), None);
2993    }
2994
2995    #[test]
2996    fn highlighted_skill_trigger_remains_styled_when_wrapped() {
2997        let input = "/release-notes argument";
2998        let trigger = active_skill_trigger(input, &["release-notes".to_owned()]);
2999        let lines = styled_text_lines(input, trigger, 8, Style::default().fg(Color::White));
3000        let highlighted = lines
3001            .iter()
3002            .flat_map(|line| line.spans.iter())
3003            .filter(|span| span.style.fg == Some(Color::Cyan))
3004            .map(|span| span.content.as_ref())
3005            .collect::<String>();
3006        assert_eq!(highlighted, "/release-notes");
3007    }
3008
3009    #[test]
3010    fn input_has_no_prompt_marker_and_trailing_newline_is_visible() {
3011        assert_eq!(input_prompt("hello"), "hello");
3012        assert_eq!(wrap_text("hello\n", 80), vec!["hello", ""]);
3013    }
3014
3015    #[test]
3016    fn input_prompt_wraps_to_multiple_rows_when_long() {
3017        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3018        state.input = "abcdefghij".to_owned();
3019        // width 5: the input wraps across multiple rows without a prompt marker.
3020        let rows = input_visible_rows(&state, 5);
3021        assert!(rows >= 2);
3022    }
3023
3024    #[test]
3025    fn cursor_editing_moves_by_characters_and_preserves_unicode() {
3026        let mut input = "가나".to_owned();
3027        let mut cursor = input.chars().count();
3028        cursor -= 1;
3029        insert_at_cursor(&mut input, &mut cursor, 'x');
3030        assert_eq!(input, "가x나");
3031        assert_eq!(cursor, 2);
3032        assert!(remove_before_cursor(&mut input, &mut cursor));
3033        assert_eq!(input, "가나");
3034        assert_eq!(cursor, 1);
3035    }
3036
3037    #[test]
3038    fn cursor_row_tracks_newlines_and_wrapping() {
3039        assert_eq!(cursor_row("hello\nworld", 6, 80), 1);
3040        assert_eq!(cursor_row("abcdef", 4, 3), 1);
3041    }
3042
3043    #[test]
3044    fn shift_enter_inserts_at_the_cursor_and_moves_it_to_the_new_row() {
3045        let mut input = "beforeafter".to_owned();
3046        let mut cursor = 6;
3047        insert_at_cursor(&mut input, &mut cursor, '\n');
3048
3049        assert_eq!(input, "before\nafter");
3050        assert_eq!(cursor, 7);
3051        assert_eq!(cursor_row(&input, cursor, 80), 1);
3052    }
3053
3054    #[test]
3055    fn shift_enter_renders_the_cursor_on_the_new_input_row() {
3056        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3057        state.input = "beforeafter".to_owned();
3058        state.cursor = 6;
3059        insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
3060        state.cursor_epoch = Instant::now();
3061
3062        let mut terminal =
3063            Terminal::new(ratatui::backend::TestBackend::new(20, 10)).expect("test terminal");
3064        terminal
3065            .draw(|frame| draw(frame, &state))
3066            .expect("draw input cursor");
3067
3068        // The input box starts at row 5; its inner area begins at (1, 6).
3069        // After inserting a newline, the cursor is at the start of row 7.
3070        terminal.backend_mut().assert_cursor_position((1, 7));
3071    }
3072
3073    #[test]
3074    fn tool_results_attach_to_their_matching_call_after_consecutive_calls() {
3075        let history = vec![
3076            SessionHistoryRecord::Message {
3077                timestamp: 1,
3078                message: ChatMessage::assistant(
3079                    String::new(),
3080                    vec![
3081                        crate::model::ChatToolCall {
3082                            id: "call-first".to_owned(),
3083                            name: "cmd".to_owned(),
3084                            arguments: r#"{"command":"first"}"#.to_owned(),
3085                        },
3086                        crate::model::ChatToolCall {
3087                            id: "call-second".to_owned(),
3088                            name: "cmd".to_owned(),
3089                            arguments: r#"{"command":"second"}"#.to_owned(),
3090                        },
3091                    ],
3092                ),
3093            },
3094            SessionHistoryRecord::Message {
3095                timestamp: 2,
3096                message: ChatMessage::tool(
3097                    "call-first".to_owned(),
3098                    "cmd".to_owned(),
3099                    serde_json::json!({"stdout":"first result","stderr":""}).to_string(),
3100                ),
3101            },
3102            SessionHistoryRecord::Message {
3103                timestamp: 3,
3104                message: ChatMessage::tool(
3105                    "call-second".to_owned(),
3106                    "cmd".to_owned(),
3107                    serde_json::json!({"stdout":"second result","stderr":""}).to_string(),
3108                ),
3109            },
3110        ];
3111
3112        let state = UiState::from_history(&history, "secret", "model", None, false);
3113        let lines = transcript_lines(&state, 200);
3114        assert_eq!(
3115            lines.len(),
3116            3,
3117            "only the two call lines and their separator remain"
3118        );
3119        assert_eq!(
3120            lines[0].to_string(),
3121            "[tool:cmd \"first\"] > [\"first result\"]"
3122        );
3123        assert_eq!(
3124            lines[2].to_string(),
3125            "[tool:cmd \"second\"] > [\"second result\"]"
3126        );
3127    }
3128}
3129
3130#[cfg(test)]
3131mod skill_picker_tests {
3132    use super::*;
3133
3134    fn skill_names() -> Vec<String> {
3135        ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
3136            .into_iter()
3137            .map(str::to_owned)
3138            .collect()
3139    }
3140
3141    #[test]
3142    fn built_in_commands_share_the_slash_catalog_without_becoming_skills() {
3143        assert_eq!(
3144            command_names(vec!["release-notes".to_owned(), "settings".to_owned()]),
3145            vec!["exit", "release-notes", "settings"]
3146        );
3147        assert_eq!(
3148            builtin_command("/settings ignored arguments"),
3149            Some(BuiltinCommand::Settings)
3150        );
3151        assert_eq!(builtin_command("  /exit  "), Some(BuiltinCommand::Exit));
3152        assert_eq!(builtin_command("/settings-extra"), None);
3153    }
3154
3155    #[test]
3156    fn settings_viewport_follows_focus_instead_of_truncating_the_catalog_head() {
3157        assert_eq!(selection_range(30, 0, 12), 0..12);
3158        assert_eq!(selection_range(30, 11, 12), 0..12);
3159        assert_eq!(selection_range(30, 12, 12), 1..13);
3160        assert_eq!(selection_range(30, 29, 12), 18..30);
3161    }
3162
3163    #[test]
3164    fn model_selection_uses_advertised_efforts_and_preserves_the_current_choice() {
3165        let mut state = UiState::from_history(&[], "secret", "old", Some("medium"), false);
3166        state.open_catalog(Ok(vec![ProviderModel {
3167            id: "openai/gpt-5.6-sol".to_owned(),
3168            efforts: Some(vec![
3169                "max".to_owned(),
3170                "high".to_owned(),
3171                "medium".to_owned(),
3172                "low".to_owned(),
3173            ]),
3174        }]));
3175        state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
3176
3177        let SettingsState::Effort { model, focus, .. } =
3178            state.settings.as_ref().expect("effort picker")
3179        else {
3180            panic!("model selection should open the effort picker");
3181        };
3182        assert_eq!(model.id, "openai/gpt-5.6-sol");
3183        assert_eq!(*focus, 3, "default occupies index zero before medium");
3184
3185        let selected = state
3186            .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3187            .expect("effort selection");
3188        assert_eq!(
3189            selected,
3190            ("openai/gpt-5.6-sol".to_owned(), Some("medium".to_owned()))
3191        );
3192    }
3193
3194    #[test]
3195    fn effort_default_selection_does_not_shift_to_the_first_advertised_effort() {
3196        let mut state = UiState::from_history(&[], "secret", "old", None, false);
3197        state.open_catalog(Ok(vec![ProviderModel {
3198            id: "model".to_owned(),
3199            efforts: Some(vec!["high".to_owned(), "low".to_owned()]),
3200        }]));
3201        state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
3202
3203        let selected = state
3204            .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3205            .expect("default effort selection");
3206        assert_eq!(selected, ("model".to_owned(), None));
3207    }
3208
3209    #[test]
3210    fn reasoning_indicator_changes_to_complete_and_stays_dark_gray() {
3211        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3212        state.show_thinking();
3213
3214        let active_lines = transcript_lines(&state, 80);
3215        let active = active_lines.last().expect("reasoning line");
3216        assert!(active.to_string().starts_with("Reasoning... "));
3217        assert_eq!(active.style.fg, Some(Color::DarkGray));
3218
3219        state.complete_reasoning();
3220        let complete_lines = transcript_lines(&state, 80);
3221        let complete = complete_lines.last().expect("complete line");
3222        assert_eq!(complete.to_string(), "Reasoning Complete");
3223        assert_eq!(complete.style.fg, Some(Color::DarkGray));
3224    }
3225
3226    #[test]
3227    fn slash_picker_filters_only_leading_command_text_and_hides_without_matches() {
3228        let names = skill_names();
3229        assert_eq!(
3230            matching_skill_names("/", &names),
3231            vec!["alpha", "beta", "build", "charlie", "deploy", "doctor"]
3232        );
3233        assert_eq!(matching_skill_names("/b", &names), vec!["beta", "build"]);
3234        assert!(matching_skill_names("/missing", &names).is_empty());
3235        assert!(matching_skill_names("message /b", &names).is_empty());
3236        assert!(matching_skill_names("/beta arguments", &names).is_empty());
3237    }
3238
3239    #[test]
3240    fn slash_picker_focuses_the_top_match_and_moves_within_filtered_results() {
3241        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3242            .with_skill_names(skill_names());
3243        state.input = "/b".to_owned();
3244        state.input_changed();
3245
3246        assert!(state.skill_picker_visible());
3247        assert_eq!(state.skill_picker_focus, 0);
3248        assert!(state.move_skill_picker(true));
3249        assert_eq!(state.skill_picker_focus, 1);
3250        assert!(state.move_skill_picker(true));
3251        assert_eq!(state.skill_picker_focus, 1, "focus does not leave the list");
3252        assert!(state.move_skill_picker(false));
3253        assert_eq!(state.skill_picker_focus, 0);
3254
3255        state.input = "/missing".to_owned();
3256        state.input_changed();
3257        assert!(!state.skill_picker_visible());
3258        assert!(!state.move_skill_picker(true));
3259    }
3260
3261    #[test]
3262    fn focused_builtins_are_distinguished_from_skills() {
3263        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3264            .with_skill_names(command_names(skill_names()));
3265        state.input = "/se".to_owned();
3266        state.input_changed();
3267        assert_eq!(
3268            state.focused_builtin_command(),
3269            Some(BuiltinCommand::Settings)
3270        );
3271
3272        state.input = "/be".to_owned();
3273        state.input_changed();
3274        assert_eq!(state.focused_builtin_command(), None);
3275    }
3276
3277    #[test]
3278    fn selecting_the_focused_skill_leaves_the_completed_command_ready_to_send() {
3279        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3280            .with_skill_names(skill_names());
3281        state.input = "/b".to_owned();
3282        state.input_changed();
3283        state.move_skill_picker(true);
3284
3285        assert!(state.select_focused_skill());
3286        assert_eq!(state.input, "/build");
3287        assert_eq!(state.cursor, "/build".chars().count());
3288        assert!(
3289            !state.skill_picker_visible(),
3290            "the first Enter completes the input rather than sending it"
3291        );
3292        assert!(
3293            !state.select_focused_skill(),
3294            "a second Enter follows the normal send/attachment path"
3295        );
3296    }
3297
3298    #[test]
3299    fn slash_picker_overlays_without_reflowing_the_transcript_when_match_count_changes() {
3300        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3301            .with_skill_names(skill_names());
3302        let area = Rect::new(0, 0, 40, 16);
3303        state.transcript = (0..20)
3304            .map(|index| TranscriptItem::Assistant(format!("message {index}")))
3305            .collect();
3306
3307        state.input = "/a".to_owned();
3308        state.input_changed();
3309        let (narrow_chat, narrow_picker, narrow_input, _) = ui_layout(&state, area);
3310        let narrow_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
3311
3312        state.input = "/".to_owned();
3313        state.input_changed();
3314        let (broad_chat, broad_picker, broad_input, _) = ui_layout(&state, area);
3315        let broad_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
3316
3317        assert_ne!(
3318            narrow_picker, broad_picker,
3319            "the overlay may fit its contents"
3320        );
3321        assert_eq!(narrow_chat, broad_chat);
3322        assert_eq!(narrow_input, broad_input);
3323        assert_eq!(
3324            narrow_scroll, broad_scroll,
3325            "the overlay does not reduce the transcript viewport"
3326        );
3327    }
3328
3329    #[test]
3330    fn slash_picker_keeps_the_focused_item_in_its_five_row_viewport() {
3331        assert_eq!(skill_picker_range(20, 0), 0..5);
3332        assert_eq!(skill_picker_range(20, 4), 0..5);
3333        assert_eq!(skill_picker_range(20, 5), 1..6);
3334        assert_eq!(skill_picker_range(20, 19), 15..20);
3335    }
3336
3337    #[test]
3338    fn slash_picker_overlays_the_ready_activity_indicator() {
3339        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3340            .with_skill_names(vec!["a".to_owned(), "b".to_owned()]);
3341        state.input = "/".to_owned();
3342        state.input_changed();
3343        let mut terminal =
3344            Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
3345        terminal
3346            .draw(|frame| draw(frame, &state))
3347            .expect("draw TUI");
3348
3349        let screen = terminal
3350            .backend()
3351            .buffer()
3352            .content()
3353            .iter()
3354            .map(|cell| cell.symbol())
3355            .collect::<String>();
3356        assert!(screen.contains("/a"));
3357        assert!(
3358            !screen.contains("ready"),
3359            "the skill picker should cover the ready activity indicator"
3360        );
3361    }
3362
3363    #[test]
3364    fn slash_picker_is_rendered_immediately_above_the_input() {
3365        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3366            .with_skill_names(skill_names());
3367        state.input = "/".to_owned();
3368        state.input_changed();
3369        let mut terminal =
3370            Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
3371        terminal
3372            .draw(|frame| draw(frame, &state))
3373            .expect("draw TUI");
3374
3375        let buffer = terminal.backend().buffer();
3376        // The rounded picker is an overlay that ends directly before the input.
3377        assert_eq!(buffer[(0, 0)].symbol(), "╭");
3378        assert_eq!(buffer[(1, 1)].symbol(), "[");
3379        assert_eq!(buffer[(1, 2)].symbol(), "/");
3380        assert_eq!(buffer[(0, 8)].symbol(), "╭");
3381        assert_eq!(buffer[(0, 0)].fg, Color::Cyan);
3382    }
3383
3384    #[test]
3385    fn slash_picker_renders_count_and_distinct_focus_colors() {
3386        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3387            .with_skill_names(skill_names());
3388        state.input = "/".to_owned();
3389        state.input_changed();
3390        let mut terminal =
3391            Terminal::new(ratatui::backend::TestBackend::new(30, 8)).expect("test terminal");
3392        terminal
3393            .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 8)))
3394            .expect("draw skill picker");
3395
3396        let buffer = terminal.backend().buffer();
3397        assert_eq!(buffer[(0, 0)].symbol(), "╭");
3398        assert_eq!(buffer[(0, 0)].fg, Color::Cyan);
3399        assert_eq!(buffer[(1, 1)].symbol(), "[");
3400        assert_eq!(buffer[(1, 1)].fg, Color::DarkGray);
3401        assert_eq!(buffer[(1, 2)].symbol(), "/");
3402        assert_eq!(buffer[(1, 2)].fg, Color::Cyan);
3403        assert_eq!(buffer[(1, 3)].symbol(), "/");
3404        assert_eq!(buffer[(1, 3)].fg, Color::DarkGray);
3405    }
3406}