Skip to main content

lucy/
tui.rs

1use std::collections::HashMap;
2use std::io::{self, Write};
3use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
4use std::sync::{Arc, Mutex, OnceLock};
5use std::thread::{self, JoinHandle};
6use std::time::{Duration, Instant};
7
8use crossterm::cursor::{Hide, Show};
9use crossterm::event::{
10    self, DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture, Event,
11    KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags, MouseEventKind,
12    PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
13};
14use crossterm::execute;
15use crossterm::terminal::{
16    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
17};
18use ratatui::backend::CrosstermBackend;
19use ratatui::layout::{Alignment, Rect, Size};
20use ratatui::prelude::Frame;
21use ratatui::style::{Color, Modifier, Style};
22use ratatui::text::{Line, Span};
23use ratatui::widgets::{Block, Borders, Clear, Paragraph};
24use ratatui::Terminal;
25use ratatui_image::picker::Picker;
26use ratatui_image::protocol::Protocol;
27use ratatui_image::{Image as TuiImage, Resize};
28use serde_json::Value;
29use unicode_width::UnicodeWidthStr;
30
31use crate::app::Harness;
32use crate::cancellation::CancellationToken;
33use crate::model::{estimate_context_tokens, ChatMessage};
34use crate::protocol::{EventSink, ProtocolEvent};
35use crate::provider::ProviderModel;
36use crate::redaction::redact_secret;
37use crate::session::SessionHistoryRecord;
38
39const EVENT_POLL: Duration = Duration::from_millis(50);
40const MAX_DISPLAY_INPUT_CHARS: usize = 16 * 1024;
41/// Maximum number of wrapped input rows the input box grows to before it
42/// stops expanding and scrolls its contents internally.
43const MAX_INPUT_ROWS: u16 = 12;
44const TUI_MAX_WIDTH: u16 = 100;
45const WELCOME_MESSAGE: &str = "Coding Agent Harness LUCY";
46const WELCOME_VERSION: &str = concat!("v", env!("CARGO_PKG_VERSION"));
47const WELCOME_TAGLINE: &str = "An ultra-thin harness for tomorrow's most powerful models";
48const GREETING_IMAGE_BYTES: &[u8] = include_bytes!("../assets/greeting.png");
49const GREETING_IMAGE_SIZE: Size = Size::new(80, 20);
50const GREETING_IMAGE_MIN_SIZE: Size = Size::new(40, 10);
51const LOGO_TEXT: &str = include_str!("../logo.txt");
52/// Gradient endpoints sampled from the logo.png that logo.txt replaces.
53const LOGO_START_COLOR: (u8, u8, u8) = (165, 200, 250);
54const LOGO_END_COLOR: (u8, u8, u8) = (221, 144, 234);
55const WELCOME_IMAGE_GAP: u16 = 1;
56const WELCOME_IMAGE_BRIGHTNESS_PERCENT: u16 = 85;
57const WELCOME_START_COLOR: (u8, u8, u8) = (180, 130, 245);
58const WELCOME_END_COLOR: (u8, u8, u8) = (0, 180, 180);
59const USER_BORDER_COLOR: Color = Color::Rgb(192, 154, 0);
60const USER_BORDER_GLYPH: &str = "▌";
61const PROMPT_BACKGROUND: Color = Color::Rgb(24, 24, 27);
62const BUSY_INDICATOR_FADE_BASE_RGB: (u8, u8, u8) = (42, 42, 46);
63const CONSOLE_STATUS_COLOR: Color = Color::Rgb(144, 144, 148);
64const CONSOLE_ACCENT_LAVENDER: (u8, u8, u8) = (145, 70, 220);
65const CONSOLE_ACCENT_TEAL: (u8, u8, u8) = (0, 180, 180);
66const CONSOLE_ACCENT_CYCLE_DURATION: Duration = Duration::from_secs(15);
67const CONSOLE_ACCENT_DESATURATION: f32 = 0.15;
68const SKILL_TRIGGER_COLOR: Color = Color::Rgb(80, 255, 245);
69const PENDING_TOOL_COLOR_RGB: (u8, u8, u8) = (255, 165, 0);
70const PENDING_TOOL_COLOR: Color = Color::Rgb(
71    PENDING_TOOL_COLOR_RGB.0,
72    PENDING_TOOL_COLOR_RGB.1,
73    PENDING_TOOL_COLOR_RGB.2,
74);
75/// A completed `cmd` call first retains its pending orange, then sweeps to the
76/// final result colour from the left edge of the compact tool line.
77const TOOL_RESULT_SWEEP_DURATION: Duration = Duration::from_millis(600);
78/// Each character spends this portion of the sweep cross-fading. The remaining
79/// time staggers those fades from the first character to the last.
80const TOOL_RESULT_CHARACTER_FADE_PORTION: f32 = 0.4;
81const TOOL_SUCCESS_COLOR_RGB: (u8, u8, u8) = (0, 210, 175);
82const TOOL_SUCCESS_COLOR: Color = Color::Rgb(
83    TOOL_SUCCESS_COLOR_RGB.0,
84    TOOL_SUCCESS_COLOR_RGB.1,
85    TOOL_SUCCESS_COLOR_RGB.2,
86);
87const TOOL_FAILURE_COLOR: Color = Color::Rgb(255, 0, 0);
88const TOOL_WARNING_COLOR: Color = Color::Rgb(255, 255, 0);
89const QUEUED_MESSAGE_COLOR: Color = Color::Rgb(150, 255, 245);
90/// Floating panels are deliberately darker than the console while remaining neutral gray.
91const FLOATING_PANEL_BACKGROUND: Color = Color::Rgb(28, 28, 30);
92const SKILL_PICKER_BACKGROUND: Color = FLOATING_PANEL_BACKGROUND;
93const SECTION_CHROME_COLOR: Color = Color::Rgb(0, 180, 180);
94const SKILL_PICKER_MAX_ROWS: usize = 5;
95const BUILTIN_COMMANDS: [&str; 2] = ["settings", "exit"];
96const SETTINGS_MIN_WIDTH: u16 = 36;
97const SETTINGS_MAX_WIDTH: u16 = 88;
98const SETTINGS_MIN_HEIGHT: u16 = 8;
99const SETTINGS_MAX_HEIGHT: u16 = 22;
100
101pub(crate) fn run<W: Write>(mut harness: Harness, resumed: bool, stdout: W) -> Result<(), String> {
102    let secret = harness.provider.api_key();
103    let context_window = harness
104        .context_window
105        .or_else(|| harness.provider.context_window());
106    harness.context_window = context_window;
107    let context_tokens = estimate_context_tokens(&harness.session.provider_messages());
108    let skill_names = command_names(
109        harness
110            .session
111            .skills
112            .iter()
113            .map(|skill| skill.name.clone())
114            .collect(),
115    );
116    let mut state = UiState::from_history(
117        &harness.session.history,
118        &secret,
119        &harness.session.llm.model,
120        harness.session.llm.effort.as_deref(),
121        resumed,
122    )
123    .with_attached_agents(harness.attached_agents.clone())
124    .with_skill_names(skill_names)
125    .with_context(context_window, context_tokens);
126    let (request_tx, request_rx) = mpsc::channel::<WorkerRequest>();
127    let (message_tx, message_rx) = mpsc::channel::<WorkerMessage>();
128
129    let stdout = stdout;
130    enable_raw_mode().map_err(|error| format!("unable to enable terminal input: {error}"))?;
131    let backend = CrosstermBackend::new(stdout);
132    let terminal = match Terminal::new(backend) {
133        Ok(terminal) => terminal,
134        Err(error) => {
135            let _ = disable_raw_mode();
136            return Err(format!("unable to initialize terminal UI: {error}"));
137        }
138    };
139    let mut terminal_guard = TerminalGuard::new(terminal);
140    let backend = terminal_guard.terminal_mut().backend_mut();
141    if let Err(error) = execute!(
142        backend,
143        EnterAlternateScreen,
144        EnableFocusChange,
145        EnableMouseCapture,
146        Hide
147    ) {
148        return Err(format!("unable to enter terminal UI: {error}"));
149    }
150    // Kitty keyboard protocol makes Shift+Enter (and other modified keys)
151    // distinguishable from plain Enter. Only push it on terminals known to
152    // support it; otherwise the enhancement sequence would leak as literal
153    // text on screen.
154    let keyboard_enhanced = supports_keyboard_enhancement();
155    if keyboard_enhanced {
156        let _ = execute!(
157            backend,
158            PushKeyboardEnhancementFlags(
159                KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
160                    | KeyboardEnhancementFlags::REPORT_EVENT_TYPES,
161            )
162        );
163    }
164    // tmux does not proxy the kitty keyboard protocol, but it does
165    // recognize modifyOtherKeys (CSI > 4;1m). Enable it so tmux sends
166    // extended key sequences in CSI u format, which crossterm parses
167    // when PushKeyboardEnhancementFlags has been sent.
168    let in_tmux = is_inside_tmux();
169    if in_tmux {
170        let _ = backend
171            .write_all(b"\x1b[>4;1m")
172            .and_then(|_| backend.flush());
173    }
174    // `backend` borrows from `terminal_guard`; all writes are done so
175    // the borrow has ended and we can now set the guard flags.
176    if keyboard_enhanced {
177        terminal_guard.keyboard_enhancement = true;
178    }
179    if in_tmux {
180        terminal_guard.modify_other_keys = true;
181    }
182    let worker = thread::spawn(move || worker_loop(&mut harness, request_rx, message_tx, resumed));
183
184    let result = event_loop(
185        terminal_guard.terminal_mut(),
186        &mut state,
187        &request_tx,
188        &message_rx,
189    );
190
191    if let Some(token) = state.active_cancel.take() {
192        let _ = token.cancel();
193    }
194    let _ = request_tx.send(WorkerRequest::Shutdown);
195    wait_for_worker(worker, Duration::from_secs(2));
196    drop(terminal_guard);
197    result
198}
199
200fn worker_loop(
201    harness: &mut Harness,
202    requests: Receiver<WorkerRequest>,
203    messages: Sender<WorkerMessage>,
204    resumed: bool,
205) {
206    let mut sink = ChannelSink {
207        sender: messages.clone(),
208    };
209    if sink
210        .emit_event(&ProtocolEvent::Session {
211            session_id: harness.session.id.clone(),
212            resumed,
213        })
214        .is_err()
215    {
216        return;
217    }
218
219    loop {
220        let request = match requests.recv_timeout(EVENT_POLL) {
221            Ok(request) => request,
222            Err(mpsc::RecvTimeoutError::Timeout) => {
223                if harness.has_completed_background_commands() {
224                    let cancel = CancellationToken::new();
225                    let _ = messages.send(WorkerMessage::Started {
226                        cancel: cancel.clone(),
227                        user_text: None,
228                    });
229                    if let Err(error) =
230                        harness.handle_background_completions(&mut sink, Some(&cancel))
231                    {
232                        let message =
233                            redact_secret(&error, Some(harness.provider.api_key().as_str()));
234                        let _ = sink.emit_event(&ProtocolEvent::Error { message });
235                    }
236                    let _ = messages.send(WorkerMessage::Finished);
237                }
238                continue;
239            }
240            Err(mpsc::RecvTimeoutError::Disconnected) => break,
241        };
242        match request {
243            WorkerRequest::Turn { text } => {
244                let cancel = CancellationToken::new();
245                let _ = messages.send(WorkerMessage::Started {
246                    cancel: cancel.clone(),
247                    user_text: Some(text.clone()),
248                });
249                if let Err(error) = harness.handle_message(&text, &mut sink, Some(&cancel)) {
250                    let message = redact_secret(&error, Some(harness.provider.api_key().as_str()));
251                    let _ = sink.emit_event(&ProtocolEvent::Error { message });
252                }
253                let _ = messages.send(WorkerMessage::Finished);
254            }
255            WorkerRequest::Catalog => {
256                let _ = messages.send(WorkerMessage::Catalog(
257                    harness.provider.models().map_err(|error| error.to_string()),
258                ));
259            }
260            WorkerRequest::ApplySettings { model, effort } => {
261                let result = harness.apply_settings(&harness.home.clone(), model, effort);
262                let _ = messages.send(WorkerMessage::SettingsApplied(
263                    result,
264                    harness.session.llm.model.clone(),
265                    harness.session.llm.effort.clone(),
266                    harness.context_window,
267                ));
268            }
269            WorkerRequest::Shutdown => break,
270        }
271    }
272}
273
274fn event_loop<W: Write>(
275    terminal: &mut Terminal<CrosstermBackend<W>>,
276    state: &mut UiState,
277    requests: &Sender<WorkerRequest>,
278    messages: &Receiver<WorkerMessage>,
279) -> Result<(), String> {
280    let mut quitting = false;
281    loop {
282        loop {
283            match messages.try_recv() {
284                Ok(WorkerMessage::Event(event)) => state.apply_event(event),
285                Ok(WorkerMessage::Started { cancel, user_text }) => {
286                    if let Some(text) = user_text {
287                        state.start_queued_user(&text);
288                    }
289                    state.active_cancel = Some(cancel);
290                    state.set_busy(true);
291                    state.set_status("working");
292                }
293                Ok(WorkerMessage::Thinking) => state.show_thinking(),
294                Ok(WorkerMessage::ReasoningCompleted) => state.complete_reasoning(),
295                Ok(WorkerMessage::SkillInstructionAttached) => {
296                    state.mark_latest_user_skill_attached()
297                }
298                Ok(WorkerMessage::ContextUsage(tokens)) => state.context_tokens = tokens,
299                Ok(WorkerMessage::CompactionStarted) => state.set_status("compacting"),
300                Ok(WorkerMessage::CompactionFinished {
301                    tokens_before,
302                    tokens_after,
303                }) => {
304                    state.context_tokens = tokens_after;
305                    state.set_status("working");
306                    state.transcript.push(TranscriptItem::Info(format!(
307                        "↻ context compacted ({} → {})",
308                        format_context_tokens(tokens_before),
309                        format_context_tokens(tokens_after)
310                    )));
311                }
312                Ok(WorkerMessage::Catalog(result)) => state.open_catalog(result),
313                Ok(WorkerMessage::SettingsApplied(result, model, effort, context_window)) => {
314                    state.settings_applied(result, model, effort, context_window)
315                }
316                Ok(WorkerMessage::Finished) => {
317                    release_finished_turn(terminal.backend_mut(), state);
318                    match state.status.as_str() {
319                        "cancelling" => state.set_status("사용자 중단"),
320                        "finalizing" => state.set_status("ready"),
321                        _ => {}
322                    }
323                    if quitting {
324                        return Ok(());
325                    }
326                }
327                Err(TryRecvError::Empty) => break,
328                Err(TryRecvError::Disconnected) => {
329                    if state.busy {
330                        return Err("TUI worker stopped unexpectedly".to_owned());
331                    }
332                    return Ok(());
333                }
334            }
335        }
336
337        // Ratatui flushes the buffer diff (which issues MoveTo for every
338        // changed cell) before it hides or shows the cursor. If the hardware
339        // cursor is visible during that flush it briefly appears at each
340        // changed cell. Hide it first so the flush phase never shows it; Ratatui
341        // will re-show it at the prompt position after flush when needed.
342        let _ = execute!(terminal.backend_mut(), Hide);
343
344        terminal
345            .draw(|frame| draw(frame, state))
346            .map_err(|error| format!("unable to render TUI: {error}"))?;
347
348        if quitting {
349            thread::sleep(EVENT_POLL);
350            continue;
351        }
352        if event::poll(EVENT_POLL)
353            .map_err(|error| format!("unable to read terminal input: {error}"))?
354        {
355            let event =
356                event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
357            if handle_terminal_focus_event(state, &event) {
358                continue;
359            }
360            let key = match event {
361                Event::Mouse(mouse) => {
362                    let size = terminal
363                        .size()
364                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
365                    let max_scroll = max_scroll_for_area(state, size);
366                    handle_mouse_event(state, mouse.kind, max_scroll);
367                    continue;
368                }
369                Event::Key(key) => key,
370                _ => continue,
371            };
372            if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
373                continue;
374            }
375            if is_ctrl_c(&key) {
376                if let Some(token) = state.active_cancel.as_ref() {
377                    let _ = token.cancel();
378                    quitting = true;
379                } else {
380                    return Ok(());
381                }
382                continue;
383            }
384            if !state.busy && state.settings.is_some() {
385                if let Some((model, effort)) = state.handle_settings_key(&key) {
386                    state.settings = Some(SettingsState::Applying {
387                        model: model.clone(),
388                        effort: effort.clone(),
389                    });
390                    requests
391                        .send(WorkerRequest::ApplySettings { model, effort })
392                        .map_err(|_| "TUI worker is unavailable".to_owned())?;
393                }
394                continue;
395            }
396            if key.code == KeyCode::Esc {
397                if let Some(token) = state.active_cancel.as_ref() {
398                    if token.cancel() {
399                        state.set_status("cancelling");
400                    }
401                }
402                continue;
403            }
404            match key.code {
405                KeyCode::Enter => {
406                    // Shift+Enter (and Alt+Enter fallback) insert a literal
407                    // newline so the user can write multi-line prompts. Plain
408                    // Enter sends the turn. Many terminals cannot distinguish
409                    // Shift+Enter from Enter, so Alt+Enter is also accepted.
410                    if key.modifiers.contains(KeyModifiers::SHIFT)
411                        || key.modifiers.contains(KeyModifiers::ALT)
412                    {
413                        if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
414                            insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
415                            state.input_changed();
416                        }
417                        continue;
418                    }
419                    // A focused built-in is an action, unlike a skill: Enter
420                    // invokes it immediately. Tab remains completion-only.
421                    let text = if let Some(command) = state.focused_builtin_command() {
422                        state.input.clear();
423                        format!("/{}", command.name())
424                    } else {
425                        if state.select_focused_skill() {
426                            continue;
427                        }
428                        std::mem::take(&mut state.input)
429                    };
430                    state.cursor = 0;
431                    if let Some(command) = builtin_command(&text) {
432                        state.reset_skill_picker();
433                        if state.busy {
434                            state.transcript.push(TranscriptItem::Info(format!(
435                                "/{} is available when the current turn finishes",
436                                command.name()
437                            )));
438                            continue;
439                        }
440                        match command {
441                            BuiltinCommand::Settings => {
442                                state.settings = Some(SettingsState::Loading);
443                                requests
444                                    .send(WorkerRequest::Catalog)
445                                    .map_err(|_| "TUI worker is unavailable".to_owned())?;
446                                continue;
447                            }
448                            BuiltinCommand::Exit => return Ok(()),
449                        }
450                    }
451                    state.reset_skill_picker();
452                    if text.trim().is_empty() {
453                        continue;
454                    }
455                    state.auto_scroll = true;
456                    state.scroll = 0;
457                    state.submit_user(&text);
458                    state.set_busy(true);
459                    state.set_status("working");
460                    requests
461                        .send(WorkerRequest::Turn { text })
462                        .map_err(|_| "TUI worker is unavailable".to_owned())?;
463                }
464                KeyCode::Tab => {
465                    // Tab completes the focused skill while the slash picker
466                    // is active, using the same first-selection path as Enter.
467                    state.select_focused_skill();
468                }
469                KeyCode::Char(character) => {
470                    if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
471                        insert_at_cursor(&mut state.input, &mut state.cursor, character);
472                        state.input_changed();
473                    }
474                }
475                KeyCode::Backspace => {
476                    if remove_before_cursor(&mut state.input, &mut state.cursor) {
477                        state.input_changed();
478                    }
479                }
480                KeyCode::Left => {
481                    state.cursor = state.cursor.saturating_sub(1);
482                }
483                KeyCode::Right => {
484                    state.cursor = (state.cursor + 1).min(state.input.chars().count());
485                }
486                KeyCode::Home => {
487                    state.cursor = 0;
488                }
489                KeyCode::End => {
490                    state.cursor = state.input.chars().count();
491                }
492                KeyCode::Up => {
493                    let size = terminal
494                        .size()
495                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
496                    let area = tui_viewport(Rect::new(0, 0, size.width, size.height));
497                    let input_width = ui_prompt_content_width(area).max(1) as usize;
498                    if !move_up_from_input(state, input_width) {
499                        let max_scroll = max_scroll_for_area(state, size);
500                        scroll_up(state, max_scroll);
501                    }
502                }
503                KeyCode::Down => {
504                    let size = terminal
505                        .size()
506                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
507                    let area = tui_viewport(Rect::new(0, 0, size.width, size.height));
508                    let input_width = ui_prompt_content_width(area).max(1) as usize;
509                    if !move_down_from_input(state, input_width) {
510                        let max_scroll = max_scroll_for_area(state, size);
511                        scroll_down(state, max_scroll);
512                    }
513                }
514                KeyCode::PageUp => {
515                    let size = terminal
516                        .size()
517                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
518                    let max_scroll = max_scroll_for_area(state, size);
519                    scroll_up(state, max_scroll);
520                }
521                KeyCode::PageDown => {
522                    let size = terminal
523                        .size()
524                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
525                    let max_scroll = max_scroll_for_area(state, size);
526                    scroll_down(state, max_scroll);
527                }
528                _ => {}
529            }
530        }
531    }
532}
533
534fn handle_terminal_focus_event(state: &mut UiState, event: &Event) -> bool {
535    match event {
536        Event::FocusGained => state.terminal_focused = true,
537        Event::FocusLost => state.terminal_focused = false,
538        _ => return false,
539    }
540    true
541}
542
543fn is_ctrl_c(key: &KeyEvent) -> bool {
544    key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
545}
546
547fn handle_mouse_event(state: &mut UiState, kind: MouseEventKind, max_scroll: u16) {
548    match kind {
549        MouseEventKind::ScrollUp => scroll_up(state, max_scroll),
550        MouseEventKind::ScrollDown => scroll_down(state, max_scroll),
551        _ => {}
552    }
553}
554
555fn scroll_up(state: &mut UiState, max_scroll: u16) {
556    if state.auto_scroll {
557        state.scroll = max_scroll;
558        state.auto_scroll = false;
559    } else {
560        state.scroll = state.scroll.min(max_scroll);
561    }
562    state.scroll = state.scroll.saturating_sub(3);
563}
564
565fn scroll_down(state: &mut UiState, max_scroll: u16) {
566    if state.auto_scroll {
567        return;
568    }
569    state.scroll = state.scroll.saturating_add(3).min(max_scroll);
570    if state.scroll == max_scroll {
571        // Reaching the real bottom is an explicit request to resume following
572        // the transcript, so subsequent streamed output stays visible.
573        state.auto_scroll = true;
574        state.scroll = 0;
575    }
576}
577
578fn wait_for_worker(worker: JoinHandle<()>, grace: Duration) {
579    let deadline = std::time::Instant::now() + grace;
580    while !worker.is_finished() && std::time::Instant::now() < deadline {
581        thread::sleep(Duration::from_millis(5));
582    }
583    if worker.is_finished() {
584        let _ = worker.join();
585    }
586}
587
588struct TerminalGuard<W: Write> {
589    terminal: Option<Terminal<CrosstermBackend<W>>>,
590    keyboard_enhancement: bool,
591    modify_other_keys: bool,
592}
593
594impl<W: Write> TerminalGuard<W> {
595    fn new(terminal: Terminal<CrosstermBackend<W>>) -> Self {
596        Self {
597            terminal: Some(terminal),
598            keyboard_enhancement: false,
599            modify_other_keys: false,
600        }
601    }
602
603    fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {
604        self.terminal
605            .as_mut()
606            .expect("terminal guard is initialized")
607    }
608}
609
610impl<W: Write> Drop for TerminalGuard<W> {
611    fn drop(&mut self) {
612        let Some(mut terminal) = self.terminal.take() else {
613            return;
614        };
615        if self.modify_other_keys {
616            let _ = terminal
617                .backend_mut()
618                .write_all(b"\x1b[>4;0m")
619                .and_then(|_| terminal.backend_mut().flush());
620        }
621        if self.keyboard_enhancement {
622            let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
623        }
624        let _ = terminal.show_cursor();
625        let _ = disable_raw_mode();
626        let _ = execute!(
627            terminal.backend_mut(),
628            DisableFocusChange,
629            DisableMouseCapture,
630            LeaveAlternateScreen,
631            Show
632        );
633        let _ = terminal.backend_mut().flush();
634    }
635}
636
637/// Heuristic for terminals that implement the kitty keyboard protocol.
638/// `PushKeyboardEnhancementFlags` is a no-op on supported terminals, but on
639/// unsupported ones the CSI sequence can render as literal text, so it is only
640/// enabled when the terminal advertises support via `TERM`/`TERM_PROGRAM`.
641fn supports_keyboard_enhancement() -> bool {
642    fn env(name: &str) -> Option<String> {
643        std::env::var(name).ok().map(|value| value.to_lowercase())
644    }
645    let term = env("TERM").unwrap_or_default();
646    let program = env("TERM_PROGRAM").unwrap_or_default();
647    if term.starts_with("xterm-kitty")
648        || term.starts_with("ghostty")
649        || term.starts_with("xterm-ghostty")
650    {
651        return true;
652    }
653    if matches!(
654        program.as_str(),
655        "ghostty" | "kitty" | "wezterm" | "alacritty" | "foot" | "footclient" | "iterm.app"
656    ) {
657        return true;
658    }
659    // tmux does not support the kitty keyboard protocol (CSI > flags u)
660    // passthrough, but it does support modifyOtherKeys (CSI > 4;1m). Push
661    // kitty flags anyway so crossterm parses CSI u format sequences, and
662    // separately enable modifyOtherKeys so tmux sends extended keys.
663    if program == "tmux" {
664        return true;
665    }
666    false
667}
668
669/// Whether the process is running inside a tmux session.
670fn is_inside_tmux() -> bool {
671    std::env::var("TERM_PROGRAM")
672        .map(|value| value.eq_ignore_ascii_case("tmux"))
673        .unwrap_or(false)
674}
675
676#[derive(Debug, Clone, Copy, PartialEq, Eq)]
677enum TurnNotification {
678    Completed,
679    Interrupted,
680    Failed,
681}
682
683impl TurnNotification {
684    fn body(self) -> &'static str {
685        match self {
686            Self::Completed => "Turn complete",
687            Self::Interrupted => "Turn interrupted",
688            Self::Failed => "Turn failed",
689        }
690    }
691}
692
693fn turn_notification_for_status(status: &str) -> TurnNotification {
694    match status {
695        "cancelling" | "사용자 중단" => TurnNotification::Interrupted,
696        "error" => TurnNotification::Failed,
697        _ => TurnNotification::Completed,
698    }
699}
700
701/// Ask terminal emulators that support OSC 777 to show a desktop notification.
702///
703/// The title and body are fixed Lucy-owned strings rather than model/provider
704/// text, so completion notifications cannot inject terminal control data or
705/// expose a secret. Terminals without OSC 777 support safely ignore the OSC.
706fn send_turn_notification<W: Write>(
707    writer: &mut W,
708    notification: TurnNotification,
709) -> io::Result<()> {
710    writer.write_all(b"\x1b]777;notify;Lucy;")?;
711    writer.write_all(notification.body().as_bytes())?;
712    writer.write_all(b"\x07")?;
713    writer.flush()
714}
715
716fn release_finished_turn<W: Write>(writer: &mut W, state: &mut UiState) {
717    let was_busy = state.busy;
718    let notification = turn_notification_for_status(&state.status);
719    state.set_busy(false);
720    state.active_cancel = None;
721    if was_busy {
722        // Notification failure must never change the completed turn result or
723        // make the TUI unusable.
724        let _ = send_turn_notification(writer, notification);
725    }
726}
727
728enum WorkerRequest {
729    Turn {
730        text: String,
731    },
732    Catalog,
733    ApplySettings {
734        model: String,
735        effort: Option<String>,
736    },
737    Shutdown,
738}
739
740enum WorkerMessage {
741    Event(ProtocolEvent),
742    Started {
743        cancel: CancellationToken,
744        user_text: Option<String>,
745    },
746    Thinking,
747    ReasoningCompleted,
748    SkillInstructionAttached,
749    ContextUsage(usize),
750    CompactionStarted,
751    CompactionFinished {
752        tokens_before: usize,
753        tokens_after: usize,
754    },
755    Catalog(Result<Vec<ProviderModel>, String>),
756    SettingsApplied(Result<(), String>, String, Option<String>, Option<usize>),
757    Finished,
758}
759
760struct ChannelSink {
761    sender: Sender<WorkerMessage>,
762}
763
764impl EventSink for ChannelSink {
765    fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
766        self.sender
767            .send(WorkerMessage::Event(event.clone()))
768            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
769    }
770
771    fn reasoning_started(&mut self) -> io::Result<()> {
772        self.sender
773            .send(WorkerMessage::Thinking)
774            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
775    }
776
777    fn reasoning_completed(&mut self) -> io::Result<()> {
778        self.sender
779            .send(WorkerMessage::ReasoningCompleted)
780            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
781    }
782
783    fn skill_instruction_attached(&mut self, _name: &str) -> io::Result<()> {
784        self.sender
785            .send(WorkerMessage::SkillInstructionAttached)
786            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
787    }
788
789    fn context_usage(&mut self, tokens: usize) -> io::Result<()> {
790        self.sender
791            .send(WorkerMessage::ContextUsage(tokens))
792            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
793    }
794
795    fn compaction_started(&mut self) -> io::Result<()> {
796        self.sender
797            .send(WorkerMessage::CompactionStarted)
798            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
799    }
800
801    fn compaction_finished(&mut self, tokens_before: usize, tokens_after: usize) -> io::Result<()> {
802        self.sender
803            .send(WorkerMessage::CompactionFinished {
804                tokens_before,
805                tokens_after,
806            })
807            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
808    }
809}
810
811#[derive(Debug, Clone)]
812struct ActivityTransition {
813    started_at: Instant,
814    from_levels: [usize; PULSE_BAR_PERIODS.len()],
815    to_levels: [usize; PULSE_BAR_PERIODS.len()],
816}
817
818struct UiState {
819    model: String,
820    effort: Option<String>,
821    context_window: Option<usize>,
822    context_tokens: usize,
823    secret: String,
824    transcript: Vec<TranscriptItem>,
825    queued_messages: Vec<String>,
826    input: String,
827    cursor: usize,
828    status: String,
829    busy: bool,
830    terminal_focused: bool,
831    active_cancel: Option<CancellationToken>,
832    scroll: u16,
833    auto_scroll: bool,
834    tool_animation_epoch: Instant,
835    console_animation_epoch: Instant,
836    activity_started_at: Instant,
837    activity_transition: Option<ActivityTransition>,
838    last_active_levels: [usize; PULSE_BAR_PERIODS.len()],
839    last_active_elapsed: Duration,
840    welcome_visible: bool,
841    attached_agents: Vec<String>,
842    cmd_result_started_at: HashMap<String, Instant>,
843    skill_names: Vec<String>,
844    skill_picker_focus: usize,
845    skill_picker_suppressed: bool,
846    settings: Option<SettingsState>,
847}
848
849impl UiState {
850    fn from_history(
851        history: &[SessionHistoryRecord],
852        secret: &str,
853        model: &str,
854        effort: Option<&str>,
855        resumed: bool,
856    ) -> Self {
857        let mut state = Self {
858            model: model.to_owned(),
859            effort: effort.map(str::to_owned),
860            context_window: None,
861            context_tokens: 1,
862            secret: secret.to_owned(),
863            transcript: Vec::new(),
864            queued_messages: Vec::new(),
865            input: String::new(),
866            cursor: 0,
867            status: "ready".to_owned(),
868            busy: false,
869            terminal_focused: true,
870            active_cancel: None,
871            scroll: 0,
872            auto_scroll: true,
873            tool_animation_epoch: Instant::now(),
874            console_animation_epoch: Instant::now(),
875            activity_started_at: Instant::now(),
876            activity_transition: None,
877            last_active_levels: [0; PULSE_BAR_PERIODS.len()],
878            last_active_elapsed: Duration::ZERO,
879            welcome_visible: !resumed && history.is_empty(),
880            attached_agents: Vec::new(),
881            cmd_result_started_at: HashMap::new(),
882            skill_names: Vec::new(),
883            skill_picker_focus: 0,
884            skill_picker_suppressed: false,
885            settings: None,
886        };
887        for record in history {
888            state.add_history_record(record);
889        }
890        state
891    }
892
893    fn with_attached_agents(mut self, attached_agents: Vec<String>) -> Self {
894        self.attached_agents = attached_agents;
895        self
896    }
897
898    fn with_skill_names(mut self, skill_names: Vec<String>) -> Self {
899        self.skill_names = skill_names;
900        self
901    }
902
903    fn with_context(mut self, context_window: Option<usize>, context_tokens: usize) -> Self {
904        self.context_window = context_window;
905        self.context_tokens = context_tokens.max(1);
906        self
907    }
908
909    /// Return matching skills only while the first input character is `/` and
910    /// the user is still writing the command name (rather than its arguments).
911    fn matching_skill_names(&self) -> Vec<&str> {
912        matching_skill_names(&self.input, &self.skill_names)
913    }
914
915    fn reset_skill_picker(&mut self) {
916        self.skill_picker_focus = 0;
917        self.skill_picker_suppressed = false;
918    }
919
920    fn skill_picker_visible(&self) -> bool {
921        !self.skill_picker_suppressed && !self.matching_skill_names().is_empty()
922    }
923
924    fn set_busy(&mut self, busy: bool) {
925        self.set_busy_at(busy, Instant::now());
926    }
927
928    fn set_busy_at(&mut self, busy: bool, now: Instant) {
929        if self.busy == busy {
930            return;
931        }
932        if busy {
933            self.console_animation_epoch = now;
934        }
935        self.busy = busy;
936    }
937
938    fn set_status(&mut self, status: impl Into<String>) {
939        let status = status.into();
940        if self.status == status {
941            return;
942        }
943
944        let now = Instant::now();
945        let current_levels = self.activity_levels_at(now);
946        let current_elapsed = self.working_elapsed_at(now);
947        if matches!(self.status.as_str(), "working" | "compacting") {
948            self.last_active_levels = current_levels;
949            self.last_active_elapsed = current_elapsed;
950        }
951
952        match status.as_str() {
953            "working" if !matches!(self.status.as_str(), "working" | "compacting") => {
954                // Join a frame whose next pulses continue one level at a time
955                // after the ramp. Sampling the current bars also makes a new
956                // turn during the ready settle-down phase continuous.
957                self.activity_started_at = now;
958                self.activity_transition = Some(ActivityTransition {
959                    started_at: now,
960                    from_levels: current_levels,
961                    to_levels: pulse_levels_at(PULSE_ENTRY_FRAME),
962                });
963            }
964            "ready" if self.status != "ready" => {
965                // TurnEnd is commonly followed by Finished before the next
966                // draw, so retain the most recent working frame even if the
967                // transient status was already changed to "finalizing".
968                let from_levels = if matches!(self.status.as_str(), "working" | "compacting") {
969                    current_levels
970                } else {
971                    self.last_active_levels
972                };
973                self.activity_transition = Some(ActivityTransition {
974                    started_at: now,
975                    from_levels,
976                    to_levels: [0; PULSE_BAR_PERIODS.len()],
977                });
978            }
979            _ => {}
980        }
981        self.status = status;
982    }
983
984    fn activity_levels_at(&self, now: Instant) -> [usize; PULSE_BAR_PERIODS.len()] {
985        if let Some(transition) = &self.activity_transition {
986            let elapsed = now.saturating_duration_since(transition.started_at);
987            if elapsed < ACTIVITY_TRANSITION_DURATION {
988                return interpolate_pulse_levels(
989                    transition.from_levels,
990                    transition.to_levels,
991                    elapsed,
992                );
993            }
994        }
995
996        match self.status.as_str() {
997            "working" | "compacting" => pulse_levels_at(self.working_elapsed_at(now)),
998            _ => [0; PULSE_BAR_PERIODS.len()],
999        }
1000    }
1001
1002    fn console_animation_elapsed_at(&self, now: Instant) -> Duration {
1003        now.saturating_duration_since(self.console_animation_epoch)
1004    }
1005
1006    fn working_elapsed_at(&self, now: Instant) -> Duration {
1007        let elapsed = now.saturating_duration_since(self.activity_started_at);
1008        if self.status == "working" && self.activity_transition.is_some() {
1009            PULSE_ENTRY_FRAME
1010                .checked_add(elapsed.saturating_sub(ACTIVITY_TRANSITION_DURATION))
1011                .unwrap_or(PULSE_ENTRY_FRAME)
1012        } else {
1013            elapsed
1014        }
1015    }
1016
1017    fn input_changed(&mut self) {
1018        self.reset_skill_picker();
1019    }
1020
1021    /// Move through the current filter result without wrapping at its ends.
1022    /// Returning false lets the caller retain normal transcript scrolling when
1023    /// no slash picker is active.
1024    fn move_skill_picker(&mut self, down: bool) -> bool {
1025        let match_count = self.matching_skill_names().len();
1026        if self.skill_picker_suppressed || match_count == 0 {
1027            return false;
1028        }
1029        if down {
1030            self.skill_picker_focus = (self.skill_picker_focus + 1).min(match_count - 1);
1031        } else {
1032            self.skill_picker_focus = self.skill_picker_focus.saturating_sub(1);
1033        }
1034        true
1035    }
1036
1037    /// Replace the slash query with the focused explicit skill command. The
1038    /// normal Enter path then sends that command and the existing turn engine
1039    /// attaches the immutable session skill snapshot.
1040    /// Return the built-in represented by the focused slash-picker row, if
1041    /// any. Built-ins execute on Enter while skills merely complete there.
1042    fn focused_builtin_command(&self) -> Option<BuiltinCommand> {
1043        let name = *self.matching_skill_names().get(self.skill_picker_focus)?;
1044        builtin_command(&format!("/{name}"))
1045    }
1046
1047    fn select_focused_skill(&mut self) -> bool {
1048        if self.skill_picker_suppressed {
1049            return false;
1050        }
1051        let Some(name) = self
1052            .matching_skill_names()
1053            .get(self.skill_picker_focus)
1054            .map(|name| (*name).to_owned())
1055        else {
1056            return false;
1057        };
1058        self.input = format!("/{name}");
1059        self.cursor = self.input.chars().count();
1060        // The first Enter chooses a skill; a second Enter sends the completed
1061        // command to the normal attachment path.
1062        self.skill_picker_suppressed = true;
1063        true
1064    }
1065
1066    fn open_catalog(&mut self, result: Result<Vec<ProviderModel>, String>) {
1067        self.settings = Some(match result {
1068            Ok(models) => {
1069                let focus = models
1070                    .iter()
1071                    .position(|model| model.id == self.model)
1072                    .unwrap_or(0);
1073                SettingsState::Models {
1074                    models,
1075                    query: String::new(),
1076                    focus,
1077                }
1078            }
1079            Err(error) => SettingsState::Error(error),
1080        });
1081    }
1082    fn settings_applied(
1083        &mut self,
1084        result: Result<(), String>,
1085        model: String,
1086        effort: Option<String>,
1087        context_window: Option<usize>,
1088    ) {
1089        match result {
1090            Ok(()) => {
1091                self.model = model;
1092                self.effort = effort;
1093                self.context_window = context_window;
1094                self.settings = None;
1095                self.transcript
1096                    .push(TranscriptItem::Info("⚙ settings applied".to_owned()));
1097            }
1098            Err(error) => self.settings = Some(SettingsState::Error(error)),
1099        }
1100    }
1101    fn handle_settings_key(&mut self, key: &KeyEvent) -> Option<(String, Option<String>)> {
1102        let current_effort = self.effort.clone();
1103        match self.settings.as_mut()? {
1104            SettingsState::Loading => {
1105                if key.code == KeyCode::Esc {
1106                    self.settings = None;
1107                }
1108            }
1109            SettingsState::Applying { .. } => {}
1110            SettingsState::Error(_) => {
1111                if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
1112                    self.settings = None;
1113                }
1114            }
1115            SettingsState::Models {
1116                models,
1117                query,
1118                focus,
1119            } => match key.code {
1120                KeyCode::Esc => self.settings = None,
1121                KeyCode::Char(c) => {
1122                    query.push(c);
1123                    *focus = 0;
1124                }
1125                KeyCode::Backspace => {
1126                    query.pop();
1127                    *focus = 0;
1128                }
1129                KeyCode::Up => *focus = focus.saturating_sub(1),
1130                KeyCode::Down => {
1131                    let n = models
1132                        .iter()
1133                        .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
1134                        .count();
1135                    *focus = (*focus + 1).min(n.saturating_sub(1));
1136                }
1137                KeyCode::Enter => {
1138                    let selected = models
1139                        .iter()
1140                        .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
1141                        .nth(*focus)
1142                        .cloned();
1143                    if let Some(model) = selected {
1144                        let focus = model
1145                            .efforts
1146                            .as_ref()
1147                            .and_then(|efforts| {
1148                                current_effort.as_ref().and_then(|current| {
1149                                    efforts.iter().position(|effort| effort == current)
1150                                })
1151                            })
1152                            .map_or(0, |index| index + 1);
1153                        self.settings = Some(SettingsState::Effort {
1154                            model,
1155                            input: current_effort.unwrap_or_default(),
1156                            focus,
1157                        });
1158                    }
1159                }
1160                _ => {}
1161            },
1162            SettingsState::Effort {
1163                model,
1164                input,
1165                focus,
1166            } => match key.code {
1167                KeyCode::Esc => self.settings = None,
1168                KeyCode::Char(c) if model.efforts.is_none() => input.push(c),
1169                KeyCode::Backspace if model.efforts.is_none() => {
1170                    input.pop();
1171                }
1172                KeyCode::Up => *focus = focus.saturating_sub(1),
1173                KeyCode::Down => {
1174                    if let Some(efforts) = &model.efforts {
1175                        *focus = (*focus + 1).min(efforts.len());
1176                    }
1177                }
1178                KeyCode::Enter => {
1179                    let effort = match &model.efforts {
1180                        Some(efforts) => {
1181                            if *focus == 0 {
1182                                None
1183                            } else {
1184                                efforts.get(focus.saturating_sub(1)).cloned()
1185                            }
1186                        }
1187                        None => (!input.trim().is_empty()).then(|| input.trim().to_owned()),
1188                    };
1189                    return Some((model.id.clone(), effort));
1190                }
1191                _ => {}
1192            },
1193        };
1194        None
1195    }
1196
1197    fn add_history_record(&mut self, record: &SessionHistoryRecord) {
1198        match record {
1199            SessionHistoryRecord::ProviderSettings { model, effort, .. } => {
1200                self.transcript.push(TranscriptItem::Info(format!(
1201                    "⚙ {model} ({})",
1202                    effort.as_deref().unwrap_or("default")
1203                )))
1204            }
1205            SessionHistoryRecord::Message { message, .. } => self.add_message(message),
1206            SessionHistoryRecord::Interruption {
1207                assistant_text,
1208                tool_calls,
1209                tool_results,
1210                reason,
1211                phase,
1212                ..
1213            } => {
1214                if !assistant_text.is_empty() {
1215                    self.add_assistant_message(assistant_text);
1216                }
1217                for call in tool_calls {
1218                    self.add_tool_call(call);
1219                }
1220                for observation in tool_results {
1221                    self.add_tool_result(
1222                        &observation.id,
1223                        &observation.name,
1224                        observation.result.clone(),
1225                    );
1226                }
1227                self.transcript
1228                    .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1229            }
1230            SessionHistoryRecord::Compaction(compaction) => {
1231                self.transcript.push(TranscriptItem::Info(format!(
1232                    "↻ context compacted ({} before)",
1233                    format_context_tokens(compaction.tokens_before)
1234                )));
1235            }
1236        }
1237    }
1238
1239    fn add_message(&mut self, message: &ChatMessage) {
1240        match message.role.as_str() {
1241            "user" => {
1242                let text = message.content.as_deref().unwrap_or("");
1243                let secret = self.secret.clone();
1244                self.add_user(text, &secret);
1245            }
1246            "assistant" => {
1247                if let Some(content) = message.content.as_deref() {
1248                    self.add_assistant_message(content);
1249                }
1250                for call in &message.tool_calls {
1251                    self.add_tool_call(call);
1252                }
1253            }
1254            "tool" => {
1255                let result = message
1256                    .content
1257                    .as_deref()
1258                    .and_then(|content| serde_json::from_str::<Value>(content).ok())
1259                    .unwrap_or_else(|| Value::String(message.content.clone().unwrap_or_default()));
1260                self.add_tool_result(
1261                    message.tool_call_id.as_deref().unwrap_or(""),
1262                    message.name.as_deref().unwrap_or("cmd"),
1263                    result,
1264                );
1265            }
1266            _ => {}
1267        }
1268    }
1269
1270    /// Show an idle submission in the transcript immediately. Only a turn
1271    /// submitted while another turn is active needs the visible queue.
1272    fn submit_user(&mut self, text: &str) {
1273        if self.busy {
1274            self.queue_user(text);
1275        } else {
1276            self.add_user(text, &self.secret.clone());
1277        }
1278    }
1279
1280    fn queue_user(&mut self, text: &str) {
1281        self.queued_messages
1282            .push(redact_secret(text, Some(&self.secret)));
1283    }
1284
1285    fn start_queued_user(&mut self, text: &str) {
1286        let safe = redact_secret(text, Some(&self.secret));
1287        let queued = if self.queued_messages.first() == Some(&safe) {
1288            self.queued_messages.remove(0);
1289            true
1290        } else if let Some(index) = self
1291            .queued_messages
1292            .iter()
1293            .position(|queued| queued == &safe)
1294        {
1295            self.queued_messages.remove(index);
1296            true
1297        } else {
1298            false
1299        };
1300        if queued {
1301            self.add_user(text, &self.secret.clone());
1302        }
1303    }
1304
1305    fn add_user(&mut self, text: &str, secret: &str) {
1306        self.welcome_visible = false;
1307        self.transcript.push(TranscriptItem::User {
1308            text: redact_secret(text, Some(secret)),
1309            skill_instruction_attached: false,
1310        });
1311    }
1312
1313    fn mark_latest_user_skill_attached(&mut self) {
1314        if let Some(TranscriptItem::User {
1315            skill_instruction_attached,
1316            ..
1317        }) = self.transcript.last_mut()
1318        {
1319            *skill_instruction_attached = true;
1320        }
1321    }
1322
1323    fn clear_thinking(&mut self) {
1324        if matches!(
1325            self.transcript.last(),
1326            Some(TranscriptItem::Reasoning { complete: false })
1327        ) {
1328            self.transcript.pop();
1329        }
1330    }
1331
1332    fn show_thinking(&mut self) {
1333        self.set_status("working");
1334        if !matches!(
1335            self.transcript.last(),
1336            Some(TranscriptItem::Reasoning { complete: false })
1337        ) {
1338            self.transcript
1339                .push(TranscriptItem::Reasoning { complete: false });
1340        }
1341    }
1342
1343    fn complete_reasoning(&mut self) {
1344        if let Some(TranscriptItem::Reasoning { complete }) = self.transcript.last_mut() {
1345            *complete = true;
1346        }
1347    }
1348
1349    fn add_assistant(&mut self, text: &str) {
1350        self.clear_thinking();
1351        if let Some(TranscriptItem::Assistant(current)) = self.transcript.last_mut() {
1352            current.push_str(text);
1353        } else {
1354            self.add_assistant_message(text);
1355        }
1356    }
1357
1358    fn add_assistant_message(&mut self, text: &str) {
1359        self.transcript
1360            .push(TranscriptItem::Assistant(text.to_owned()));
1361    }
1362
1363    fn add_tool_call(&mut self, call: &crate::model::ChatToolCall) {
1364        self.record_tool_call(call, false);
1365    }
1366
1367    fn add_live_tool_call(&mut self, call: &crate::model::ChatToolCall) {
1368        self.record_tool_call(call, true);
1369    }
1370
1371    fn record_tool_call(&mut self, call: &crate::model::ChatToolCall, _live: bool) {
1372        self.clear_thinking();
1373        self.transcript.push(TranscriptItem::ToolCall {
1374            id: call.id.clone(),
1375            name: call.name.clone(),
1376            arguments: call.arguments.clone(),
1377        });
1378    }
1379
1380    fn add_tool_result(&mut self, id: &str, name: &str, result: Value) {
1381        self.record_tool_result(id, name, result, false);
1382    }
1383
1384    fn add_live_tool_result(&mut self, id: &str, name: &str, result: Value) {
1385        self.record_tool_result(id, name, result, true);
1386    }
1387
1388    fn record_tool_result(&mut self, id: &str, name: &str, result: Value, animate: bool) {
1389        if animate && name == "cmd" {
1390            self.cmd_result_started_at
1391                .insert(id.to_owned(), Instant::now());
1392        }
1393        self.transcript.push(TranscriptItem::ToolResult {
1394            id: id.to_owned(),
1395            name: name.to_owned(),
1396            result,
1397        });
1398    }
1399
1400    fn apply_event(&mut self, event: ProtocolEvent) {
1401        match event {
1402            ProtocolEvent::Session { .. } => {}
1403            ProtocolEvent::AssistantDelta { text } => self.add_assistant(&text),
1404            ProtocolEvent::ToolCall {
1405                id,
1406                name,
1407                arguments,
1408            } => self.add_live_tool_call(&crate::model::ChatToolCall {
1409                id,
1410                name,
1411                arguments,
1412            }),
1413            ProtocolEvent::ToolResult { id, name, result } => {
1414                self.add_live_tool_result(&id, &name, result)
1415            }
1416            ProtocolEvent::TurnEnd => {
1417                self.complete_reasoning();
1418                self.set_status("finalizing");
1419                self.transcript
1420                    .push(TranscriptItem::Info("✓ turn complete".to_owned()));
1421            }
1422            ProtocolEvent::TurnInterrupted { reason, phase } => {
1423                self.complete_reasoning();
1424                self.set_status("cancelling");
1425                self.transcript
1426                    .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1427            }
1428            ProtocolEvent::Error { message } => {
1429                self.complete_reasoning();
1430                self.set_status("error");
1431                self.transcript.push(TranscriptItem::Error(message));
1432            }
1433        }
1434    }
1435}
1436
1437#[derive(Debug, Clone, PartialEq)]
1438enum TranscriptItem {
1439    User {
1440        text: String,
1441        skill_instruction_attached: bool,
1442    },
1443    Assistant(String),
1444    ToolCall {
1445        id: String,
1446        name: String,
1447        arguments: String,
1448    },
1449    ToolResult {
1450        id: String,
1451        name: String,
1452        result: Value,
1453    },
1454    Error(String),
1455    Info(String),
1456    Reasoning {
1457        complete: bool,
1458    },
1459}
1460
1461/// Center the TUI while reserving one terminal cell on each side when possible.
1462/// Extremely narrow terminals retain their full width because two margins would
1463/// leave no usable content area.
1464fn tui_viewport(area: Rect) -> Rect {
1465    if area.width <= 2 {
1466        return area;
1467    }
1468
1469    let width = area.width.saturating_sub(2).min(TUI_MAX_WIDTH);
1470    let x = area.x + area.width.saturating_sub(width) / 2;
1471    Rect::new(x, area.y, width, area.height)
1472}
1473
1474fn ui_layout(
1475    state: &UiState,
1476    area: Rect,
1477) -> (Rect, Option<Rect>, Option<Rect>, Option<Rect>, Rect, Rect) {
1478    let prompt_rows = input_visible_rows(state, ui_prompt_content_width(area));
1479    let list_height = 0;
1480    let queue_height = message_queue_height(state);
1481    let queue_separator_height = u16::from(queue_height > 0);
1482    let list_separator_height = u16::from(list_height > 0);
1483    let requested_input_height = prompt_rows.clamp(1, MAX_INPUT_ROWS)
1484        + queue_height
1485        + queue_separator_height
1486        + list_height
1487        + list_separator_height
1488        + 1 // prompt/status separator
1489        + 1 // status line
1490        + 2; // blank outer border space
1491             // Preserve a one-row footer around the console when there is room for a
1492             // console at all. On a one-row terminal the console takes that row rather
1493             // than collapsing to an unusable rectangle.
1494    let bottom_margin = u16::from(area.height > 1);
1495    let usable_height = area.height.saturating_sub(bottom_margin);
1496    let input_height = requested_input_height.min(usable_height);
1497    let transcript_gap_height = u16::from(usable_height >= input_height.saturating_add(2));
1498    let chat_height = usable_height.saturating_sub(input_height + transcript_gap_height);
1499    let chat_chunk = bottom_console_area(area, area.y, chat_height);
1500    let input_area = bottom_console_area(
1501        area,
1502        area.y + chat_height + transcript_gap_height,
1503        input_height,
1504    );
1505    let inner = console_content_area(input_area);
1506    let content = bottom_content_heights(state, input_area);
1507    let available_above = input_area.y.saturating_sub(area.y);
1508    let picker_height = skill_picker_height(state).min(available_above);
1509    let picker_area = (picker_height > 0).then(|| {
1510        Rect::new(
1511            input_area.x,
1512            input_area.y - picker_height,
1513            input_area.width,
1514            picker_height,
1515        )
1516    });
1517    let stream_area = None;
1518    let queue_area =
1519        (content.queue > 0).then(|| Rect::new(inner.x, inner.y, inner.width, content.queue));
1520    let status_area = Rect::new(
1521        inner.x,
1522        inner.y + inner.height.saturating_sub(content.status),
1523        inner.width,
1524        content.status,
1525    );
1526    (
1527        chat_chunk,
1528        picker_area,
1529        stream_area,
1530        queue_area,
1531        input_area,
1532        status_area,
1533    )
1534}
1535
1536/// Keep the content area inset without allowing margins to consume all
1537/// available width. A narrow terminal sheds margin cells before it sheds the
1538/// console.
1539const CONTENT_HORIZONTAL_MARGIN: u16 = 7;
1540const MIN_CONSOLE_WIDTH: u16 = 14;
1541
1542fn bottom_console_area(area: Rect, y: u16, height: u16) -> Rect {
1543    let horizontal_margin = area.width.saturating_sub(1) / 2;
1544    let margin_cap = if area.width < MIN_CONSOLE_WIDTH {
1545        2
1546    } else {
1547        CONTENT_HORIZONTAL_MARGIN.min(area.width.saturating_sub(MIN_CONSOLE_WIDTH) / 2)
1548    };
1549    let horizontal_margin = horizontal_margin.min(margin_cap);
1550    Rect::new(
1551        area.x.saturating_add(horizontal_margin),
1552        y,
1553        area.width
1554            .saturating_sub(horizontal_margin.saturating_mul(2)),
1555        height,
1556    )
1557}
1558
1559fn ui_prompt_content_width(area: Rect) -> u16 {
1560    prompt_content_width(bottom_console_area(area, area.y, 0).width)
1561}
1562
1563fn console_content_area(input_area: Rect) -> Rect {
1564    let top_padding = input_area.height.min(1);
1565    let bottom_padding = input_area.height.saturating_sub(top_padding).min(1);
1566    Rect::new(
1567        input_area.x.saturating_add(2),
1568        input_area.y.saturating_add(top_padding),
1569        input_area.width.saturating_sub(4),
1570        input_area
1571            .height
1572            .saturating_sub(top_padding + bottom_padding),
1573    )
1574}
1575
1576fn prompt_content_width(input_width: u16) -> u16 {
1577    input_width.saturating_sub(4)
1578}
1579
1580#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1581struct BottomContentHeights {
1582    queue: u16,
1583    queue_separator: u16,
1584    list: u16,
1585    list_separator: u16,
1586    prompt: u16,
1587    status_separator: u16,
1588    status: u16,
1589}
1590
1591// Constrained layouts keep the status and prompt first. Queue and worker
1592// sections each require a header, one entry, and their following spacer so a
1593// clipped console never renders an orphaned section header.
1594fn bottom_content_heights(state: &UiState, input_area: Rect) -> BottomContentHeights {
1595    let mut available = console_content_area(input_area).height;
1596    let status = available.min(1);
1597    available -= status;
1598
1599    let prompt = input_visible_rows(state, prompt_content_width(input_area.width))
1600        .clamp(1, MAX_INPUT_ROWS)
1601        .min(available);
1602    available -= prompt;
1603
1604    let status_separator = u16::from(status > 0 && prompt > 0 && available > 0);
1605    available -= status_separator;
1606
1607    let requested_queue = message_queue_height(state);
1608    let (queue, queue_separator) = if requested_queue > 0 && available >= 3 {
1609        (requested_queue.min(available - 1), 1)
1610    } else {
1611        (0, 0)
1612    };
1613    available -= queue + queue_separator;
1614
1615    let requested_list = 0;
1616    let (list, list_separator) = if requested_list > 0 && available >= 3 {
1617        (requested_list.min(available - 1), 1)
1618    } else {
1619        (0, 0)
1620    };
1621
1622    BottomContentHeights {
1623        queue,
1624        queue_separator,
1625        list,
1626        list_separator,
1627        prompt,
1628        status_separator,
1629        status,
1630    }
1631}
1632
1633fn prompt_area(input_area: Rect, state: &UiState) -> Rect {
1634    let inner = console_content_area(input_area);
1635    let content = bottom_content_heights(state, input_area);
1636    Rect::new(
1637        inner.x,
1638        inner.y + content.queue + content.queue_separator,
1639        inner.width,
1640        content.prompt,
1641    )
1642}
1643
1644fn message_queue_height(state: &UiState) -> u16 {
1645    let messages = state.queued_messages.len().min(u16::MAX as usize - 1) as u16;
1646    u16::from(messages > 0) + messages
1647}
1648
1649fn max_scroll_for_area(state: &UiState, size: Size) -> u16 {
1650    let area = tui_viewport(Rect::new(0, 0, size.width, size.height));
1651    let (chat_chunk, _, _, _, _, _) = ui_layout(state, area);
1652    let chat_height = chat_chunk.height;
1653    let lines = transcript_lines(state, chat_chunk.width);
1654    lines
1655        .len()
1656        .saturating_sub(chat_height as usize)
1657        .min(u16::MAX as usize) as u16
1658}
1659
1660const TRANSCRIPT_SCROLLBAR_TRACK: &str = "┆";
1661const TRANSCRIPT_SCROLLBAR_THUMB: &str = "█";
1662const TRANSCRIPT_SCROLLBAR_TRACK_COLOR: Color = Color::Rgb(72, 72, 76);
1663
1664fn draw_transcript_scrollbar(
1665    frame: &mut Frame<'_>,
1666    area: Rect,
1667    total_lines: usize,
1668    max_scroll: u16,
1669    scroll: u16,
1670) {
1671    if area.width == 0 || area.height == 0 || total_lines == 0 || max_scroll == 0 {
1672        return;
1673    }
1674
1675    let track_height = area.height as usize;
1676    let thumb_height = ((track_height * track_height) / total_lines)
1677        .max(1)
1678        .min(track_height);
1679    let thumb_range = track_height.saturating_sub(thumb_height);
1680    let thumb_start = (usize::from(scroll.min(max_scroll)) * thumb_range / usize::from(max_scroll))
1681        .min(thumb_range);
1682    // Keep the transcript's final column visible. Cramped layouts without a
1683    // right gutter omit the scrollbar rather than covering message content.
1684    let x = area.x.saturating_add(area.width);
1685    let frame_right = frame.area().x.saturating_add(frame.area().width);
1686    if x >= frame_right {
1687        return;
1688    }
1689    let buffer = frame.buffer_mut();
1690
1691    for offset in 0..track_height {
1692        let y = area.y + offset as u16;
1693        buffer[(x, y)].set_symbol(TRANSCRIPT_SCROLLBAR_TRACK);
1694        buffer[(x, y)].set_fg(TRANSCRIPT_SCROLLBAR_TRACK_COLOR);
1695    }
1696    for offset in thumb_start..thumb_start + thumb_height {
1697        let y = area.y + offset as u16;
1698        buffer[(x, y)].set_symbol(TRANSCRIPT_SCROLLBAR_THUMB);
1699        buffer[(x, y)].set_fg(CONSOLE_STATUS_COLOR);
1700    }
1701}
1702
1703/// Number of wrapped rows the current input occupies at `width`.
1704fn input_visible_rows(state: &UiState, width: u16) -> u16 {
1705    let width = width as usize;
1706    if width == 0 {
1707        return 1;
1708    }
1709    let prompt = input_display_text(state);
1710    let wrapped = wrap_text(&prompt, width);
1711    wrapped.len().max(1) as u16
1712}
1713
1714fn input_prompt(input: &str) -> String {
1715    input.to_owned()
1716}
1717
1718fn input_display_text(state: &UiState) -> String {
1719    redact_secret(&input_prompt(&state.input), Some(&state.secret))
1720}
1721
1722fn command_names(mut skill_names: Vec<String>) -> Vec<String> {
1723    skill_names.extend(BUILTIN_COMMANDS.into_iter().map(str::to_owned));
1724    skill_names.sort();
1725    skill_names.dedup();
1726    skill_names
1727}
1728
1729#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1730enum BuiltinCommand {
1731    Settings,
1732    Exit,
1733}
1734
1735impl BuiltinCommand {
1736    fn name(self) -> &'static str {
1737        match self {
1738            Self::Settings => "settings",
1739            Self::Exit => "exit",
1740        }
1741    }
1742}
1743
1744fn builtin_command(input: &str) -> Option<BuiltinCommand> {
1745    match input.split_whitespace().next()? {
1746        "/settings" => Some(BuiltinCommand::Settings),
1747        "/exit" => Some(BuiltinCommand::Exit),
1748        _ => None,
1749    }
1750}
1751
1752/// The slash picker only accepts a command at the beginning of the message.
1753/// Once whitespace starts arguments, normal message entry resumes.
1754fn matching_skill_names<'a>(input: &str, skill_names: &'a [String]) -> Vec<&'a str> {
1755    let Some(query) = input.strip_prefix('/') else {
1756        return Vec::new();
1757    };
1758    if query.chars().any(char::is_whitespace) {
1759        return Vec::new();
1760    }
1761    skill_names
1762        .iter()
1763        .map(String::as_str)
1764        .filter(|name| name.starts_with(query))
1765        .collect()
1766}
1767
1768fn skill_picker_height(state: &UiState) -> u16 {
1769    if state.skill_picker_visible() {
1770        // Header, visible commands, and the vertical inset.
1771        (state
1772            .matching_skill_names()
1773            .len()
1774            .min(SKILL_PICKER_MAX_ROWS)
1775            + 3) as u16
1776    } else {
1777        0
1778    }
1779}
1780
1781/// Return the command portion of a currently valid explicit skill invocation.
1782/// This mirrors the command grammar used by the turn engine, while keeping the
1783/// styling concern local to the TUI.
1784fn active_skill_trigger<'a>(input: &'a str, skill_names: &[String]) -> Option<&'a str> {
1785    let invocation = input.strip_prefix('/')?;
1786    let name = invocation
1787        .split_once(char::is_whitespace)
1788        .map_or(invocation, |(name, _)| name);
1789    if name.is_empty() || !skill_names.iter().any(|skill_name| skill_name == name) {
1790        return None;
1791    }
1792    Some(&input[..1 + name.len()])
1793}
1794
1795/// Preserve input wrapping while styling a recognized `/<name>` prefix
1796/// independently from any arguments the user is still entering.
1797fn styled_text_lines(
1798    input: &str,
1799    active_skill_trigger: Option<&str>,
1800    width: usize,
1801    text_style: Style,
1802) -> Vec<Line<'static>> {
1803    let trigger_len = active_skill_trigger.map_or(0, |trigger| trigger.chars().count());
1804    let mut char_offset = 0usize;
1805    let mut lines = Vec::new();
1806
1807    for source_line in input.split('\n') {
1808        for row in wrap_line(source_line, width) {
1809            let mut spans = Vec::new();
1810            let mut text = String::new();
1811            let mut highlighted = None;
1812            for character in row.chars() {
1813                let should_highlight = char_offset < trigger_len;
1814                if highlighted != Some(should_highlight) && !text.is_empty() {
1815                    spans.push(styled_text_span(
1816                        std::mem::take(&mut text),
1817                        highlighted.unwrap_or(false),
1818                        text_style,
1819                    ));
1820                }
1821                highlighted = Some(should_highlight);
1822                text.push(character);
1823                char_offset += 1;
1824            }
1825            if !text.is_empty() {
1826                spans.push(styled_text_span(
1827                    text,
1828                    highlighted.unwrap_or(false),
1829                    text_style,
1830                ));
1831            }
1832            if spans.is_empty() {
1833                spans.push(Span::styled(String::new(), text_style));
1834            }
1835            lines.push(Line::from(spans));
1836        }
1837        // `split` retains empty trailing lines; account for the newline that
1838        // separated this source line from the next one in the character index.
1839        char_offset += 1;
1840    }
1841
1842    lines
1843}
1844
1845fn styled_text_span(text: String, highlighted: bool, text_style: Style) -> Span<'static> {
1846    if highlighted {
1847        Span::styled(text, Style::default().fg(SKILL_TRIGGER_COLOR))
1848    } else {
1849        Span::styled(text, text_style)
1850    }
1851}
1852
1853#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1854struct InputVisualRow {
1855    start: usize,
1856    end: usize,
1857}
1858
1859fn input_visual_rows(input: &str, width: usize) -> Vec<InputVisualRow> {
1860    let width = width.max(1);
1861    let characters = input.chars().collect::<Vec<_>>();
1862    let mut rows = Vec::new();
1863    let mut start = 0;
1864    let mut row_width = 0;
1865
1866    for (index, character) in characters.iter().enumerate() {
1867        if *character == '\n' {
1868            rows.push(InputVisualRow { start, end: index });
1869            start = index + 1;
1870            row_width = 0;
1871            continue;
1872        }
1873
1874        let character_width = unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0);
1875        if row_width + character_width > width && index > start {
1876            rows.push(InputVisualRow { start, end: index });
1877            start = index;
1878            row_width = 0;
1879        }
1880        row_width += character_width;
1881    }
1882
1883    rows.push(InputVisualRow {
1884        start,
1885        end: characters.len(),
1886    });
1887    rows
1888}
1889
1890fn input_cursor_row(input: &str, cursor: usize, width: usize) -> usize {
1891    let rows = input_visual_rows(input, width);
1892    let cursor = cursor.min(input.chars().count());
1893    for (index, row) in rows.iter().enumerate() {
1894        if cursor < row.end {
1895            return index;
1896        }
1897        if cursor == row.end && rows.get(index + 1).is_none_or(|next| next.start != cursor) {
1898            return index;
1899        }
1900    }
1901    rows.len().saturating_sub(1)
1902}
1903
1904fn cursor_row(input: &str, cursor: usize, width: usize) -> u16 {
1905    input_cursor_row(input, cursor, width).min(u16::MAX as usize) as u16
1906}
1907
1908fn move_up_from_input(state: &mut UiState, width: usize) -> bool {
1909    state.move_skill_picker(false) || move_input_cursor_vertical(state, width, false)
1910}
1911
1912fn move_down_from_input(state: &mut UiState, width: usize) -> bool {
1913    let width = width.max(1);
1914    state.move_skill_picker(true) || move_input_cursor_vertical(state, width, true)
1915}
1916
1917fn move_input_cursor_vertical(state: &mut UiState, width: usize, down: bool) -> bool {
1918    let width = width.max(1);
1919    let rows = input_visual_rows(&state.input, width);
1920    let current_row = input_cursor_row(&state.input, state.cursor, width);
1921    let target_row = if down {
1922        current_row + 1
1923    } else {
1924        current_row.saturating_sub(1)
1925    };
1926    if target_row == current_row || target_row >= rows.len() {
1927        return false;
1928    }
1929
1930    let characters = state.input.chars().collect::<Vec<_>>();
1931    let current = rows[current_row];
1932    let cursor = state.cursor.min(current.end);
1933    let desired_column = characters[current.start..cursor]
1934        .iter()
1935        .map(|character| unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0))
1936        .sum::<usize>();
1937    let target = rows[target_row];
1938    let mut column = 0;
1939    let mut target_cursor = target.end;
1940    for (index, character) in characters
1941        .iter()
1942        .enumerate()
1943        .take(target.end)
1944        .skip(target.start)
1945    {
1946        let character_width = unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0);
1947        if column + character_width > desired_column {
1948            target_cursor = index;
1949            break;
1950        }
1951        column += character_width;
1952        if column >= desired_column {
1953            target_cursor = index + 1;
1954            break;
1955        }
1956    }
1957    state.cursor = target_cursor;
1958    true
1959}
1960
1961fn insert_at_cursor(input: &mut String, cursor: &mut usize, character: char) {
1962    let byte_index = input
1963        .char_indices()
1964        .nth(*cursor)
1965        .map_or(input.len(), |(index, _)| index);
1966    input.insert(byte_index, character);
1967    *cursor += 1;
1968}
1969
1970fn remove_before_cursor(input: &mut String, cursor: &mut usize) -> bool {
1971    if *cursor == 0 {
1972        return false;
1973    }
1974    let end = input
1975        .char_indices()
1976        .nth(*cursor)
1977        .map_or(input.len(), |(index, _)| index);
1978    let start = input
1979        .char_indices()
1980        .nth(*cursor - 1)
1981        .map(|(index, _)| index)
1982        .unwrap_or(0);
1983    input.replace_range(start..end, "");
1984    *cursor -= 1;
1985    true
1986}
1987
1988fn draw(frame: &mut Frame<'_>, state: &UiState) {
1989    let full_area = frame.area();
1990    // Clear the outer gutters too, so a resize or overlay cannot leave stale
1991    // cells in the one-column margins.
1992    frame.render_widget(Clear, full_area);
1993    let area = tui_viewport(full_area);
1994    let (chat_chunk, picker_area, _, queue_area, input_chunk, status_area) = ui_layout(state, area);
1995
1996    // The queue, prompt, and status line share one background surface.
1997    // The transient picker remains above it.
1998    let visible_chat_area = chat_chunk;
1999
2000    let width = chat_chunk.width;
2001    let welcome_image_layout = if state.welcome_visible && greeting_image_enabled() {
2002        let welcome_lines = welcome_lines(&state.attached_agents);
2003        welcome_image_layout(visible_chat_area, welcome_lines.len() as u16)
2004    } else {
2005        None
2006    };
2007    if state.welcome_visible {
2008        let welcome_lines = welcome_lines(&state.attached_agents);
2009        if let Some(layout) = welcome_image_layout {
2010            let welcome = Paragraph::new(welcome_lines).alignment(Alignment::Center);
2011            frame.render_widget(welcome, layout.intro_area);
2012        } else {
2013            let logo = logo_lines();
2014            let logo_gap = 2u16;
2015            let total_height = logo.len() as u16 + logo_gap + welcome_lines.len() as u16;
2016            // Show the logo only when the chat area can fit the logo, gap,
2017            // and welcome text; otherwise fall back to text-only.
2018            let lines = if total_height <= visible_chat_area.height {
2019                let mut all = logo;
2020                all.push(Line::raw(""));
2021                all.push(Line::raw(""));
2022                all.extend(welcome_lines);
2023                all
2024            } else {
2025                welcome_lines
2026            };
2027            let welcome_height = (lines.len() as u16).min(visible_chat_area.height);
2028            let welcome_area = Rect::new(
2029                visible_chat_area.x,
2030                visible_chat_area.y + visible_chat_area.height.saturating_sub(welcome_height) / 2,
2031                visible_chat_area.width,
2032                welcome_height,
2033            );
2034            let welcome = Paragraph::new(lines).alignment(Alignment::Center);
2035            frame.render_widget(welcome, welcome_area);
2036        }
2037    } else {
2038        let lines = transcript_lines(state, width);
2039        let available = visible_chat_area.height as usize;
2040        let max_scroll = lines.len().saturating_sub(available).min(u16::MAX as usize) as u16;
2041        let scroll = if state.auto_scroll {
2042            max_scroll
2043        } else {
2044            state.scroll.min(max_scroll)
2045        };
2046        let total_lines = lines.len();
2047        let transcript = Paragraph::new(lines).scroll((scroll, 0));
2048        frame.render_widget(transcript, visible_chat_area);
2049        if !state.auto_scroll {
2050            draw_transcript_scrollbar(frame, visible_chat_area, total_lines, max_scroll, scroll);
2051        }
2052    }
2053
2054    frame.render_widget(
2055        Block::default().style(Style::default().bg(PROMPT_BACKGROUND)),
2056        input_chunk,
2057    );
2058
2059    if let Some(layout) = welcome_image_layout {
2060        let image = welcome_image(layout.image_size);
2061        frame.render_widget(TuiImage::new(image.as_ref()), layout.image_area);
2062    }
2063    if let Some(picker_area) = picker_area {
2064        draw_skill_picker(frame, state, picker_area);
2065    }
2066
2067    if let Some(queue_area) = queue_area {
2068        draw_message_queue(frame, state, queue_area);
2069    }
2070
2071    let input_text_style = Style::default().fg(Color::White);
2072    let prompt_area = prompt_area(input_chunk, state);
2073    let prompt = input_display_text(state);
2074    let input_rows = input_visible_rows(state, prompt_area.width).clamp(1, MAX_INPUT_ROWS);
2075    let wrapped = wrap_text(&prompt, prompt_area.width.max(1) as usize);
2076    let visible = (wrapped.len() as u16)
2077        .clamp(1, input_rows)
2078        .min(prompt_area.height);
2079    let cursor_row = cursor_row(&prompt, state.cursor, prompt_area.width.max(1) as usize);
2080    let bottom_scroll = (wrapped.len() as u16).saturating_sub(visible);
2081    let cursor_scroll = (cursor_row + 1).saturating_sub(visible);
2082    let input_scroll = cursor_scroll.min(bottom_scroll);
2083    let active_skill_trigger = (!state.busy)
2084        .then(|| active_skill_trigger(&prompt, &state.skill_names))
2085        .flatten();
2086    let input_lines = styled_text_lines(
2087        &prompt,
2088        active_skill_trigger,
2089        prompt_area.width.max(1) as usize,
2090        input_text_style,
2091    );
2092    let input = Paragraph::new(input_lines)
2093        .style(input_text_style)
2094        .scroll((input_scroll, 0));
2095    frame.render_widget(input, prompt_area);
2096
2097    let effort = state.effort.as_deref().unwrap_or("default");
2098    frame.render_widget(
2099        Paragraph::new(model_status_line(state, effort, status_area.width)),
2100        status_area,
2101    );
2102
2103    if let Some(settings) = &state.settings {
2104        draw_settings(frame, settings, area);
2105    }
2106
2107    // A frame cursor makes Ratatui issue `Show` after every redraw. Only set
2108    // one while focused.
2109    if state.terminal_focused && state.settings.is_none() && !prompt_area.is_empty() && visible > 0
2110    {
2111        let cursor_prefix: String = prompt.chars().take(state.cursor).collect();
2112        let cursor_rows = wrap_text(&cursor_prefix, prompt_area.width.max(1) as usize);
2113        let cursor_line = cursor_rows.last().map(String::as_str).unwrap_or("");
2114        let cursor_offset = UnicodeWidthStr::width(cursor_line) as u16;
2115        let cursor_x = prompt_area.x + cursor_offset.min(prompt_area.width.saturating_sub(1));
2116        let cursor_y = prompt_area.y
2117            + cursor_row
2118                .saturating_sub(input_scroll)
2119                .min(prompt_area.height.saturating_sub(1));
2120        frame.set_cursor_position((cursor_x, cursor_y));
2121    }
2122}
2123
2124fn draw_message_queue(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
2125    if area.is_empty() || state.queued_messages.is_empty() {
2126        return;
2127    }
2128
2129    let chrome = Style::default().fg(SECTION_CHROME_COLOR);
2130    let message = Style::default().fg(QUEUED_MESSAGE_COLOR);
2131    let mut lines = vec![Line::styled("Queued", chrome)];
2132    lines.extend(
2133        state
2134            .queued_messages
2135            .iter()
2136            .take(area.height.saturating_sub(1) as usize)
2137            .enumerate()
2138            .map(|(index, queued)| {
2139                Line::from(vec![
2140                    Span::styled("│ ", chrome),
2141                    Span::styled(
2142                        format!("{}) {}", index + 1, single_line_preview(queued)),
2143                        message,
2144                    ),
2145                ])
2146            }),
2147    );
2148    frame.render_widget(Paragraph::new(lines), area);
2149}
2150
2151fn single_line_preview(text: &str) -> String {
2152    truncate_output(&text.replace(['\n', '\r'], " ↵ "))
2153}
2154
2155enum SettingsState {
2156    Loading,
2157    Applying {
2158        model: String,
2159        effort: Option<String>,
2160    },
2161    Error(String),
2162    Models {
2163        models: Vec<ProviderModel>,
2164        query: String,
2165        focus: usize,
2166    },
2167    Effort {
2168        model: ProviderModel,
2169        input: String,
2170        focus: usize,
2171    },
2172}
2173fn draw_settings(frame: &mut Frame<'_>, settings: &SettingsState, area: Rect) {
2174    let width = area
2175        .width
2176        .saturating_sub(2)
2177        .min(SETTINGS_MAX_WIDTH)
2178        .max(SETTINGS_MIN_WIDTH.min(area.width));
2179    let height = area
2180        .height
2181        .saturating_sub(2)
2182        .min(SETTINGS_MAX_HEIGHT)
2183        .max(SETTINGS_MIN_HEIGHT.min(area.height));
2184    let popup = Rect::new(
2185        area.x + area.width.saturating_sub(width) / 2,
2186        area.y + area.height.saturating_sub(height) / 2,
2187        width,
2188        height,
2189    );
2190    frame.render_widget(Clear, popup);
2191    let block = Block::default()
2192        .title(" /settings ")
2193        .borders(Borders::ALL)
2194        .border_style(Style::default().fg(Color::Cyan));
2195    let inner = block.inner(popup);
2196    frame.render_widget(block, popup);
2197
2198    let lines = match settings {
2199        SettingsState::Loading => vec![
2200            Line::styled("Loading provider models…", Style::default().fg(Color::Cyan)),
2201            Line::raw(""),
2202            Line::styled("Esc  cancel", Style::default().fg(Color::DarkGray)),
2203        ],
2204        SettingsState::Applying { model, effort } => vec![
2205            Line::styled("Applying selection…", Style::default().fg(Color::Cyan)),
2206            Line::raw(model.clone()),
2207            Line::raw(format!(
2208                "effort: {}",
2209                effort.as_deref().unwrap_or("default")
2210            )),
2211        ],
2212        SettingsState::Error(error) => vec![
2213            Line::styled("Unable to update settings", Style::default().fg(Color::Red)),
2214            Line::raw(""),
2215            Line::raw(error.clone()),
2216            Line::raw(""),
2217            Line::styled("Enter/Esc  close", Style::default().fg(Color::DarkGray)),
2218        ],
2219        SettingsState::Models {
2220            models,
2221            query,
2222            focus,
2223        } => {
2224            let query_lower = query.to_lowercase();
2225            let filtered = models
2226                .iter()
2227                .filter(|model| model.id.to_lowercase().contains(&query_lower))
2228                .collect::<Vec<_>>();
2229            let focus = (*focus).min(filtered.len().saturating_sub(1));
2230            let list_rows = inner.height.saturating_sub(4) as usize;
2231            let range = selection_range(filtered.len(), focus, list_rows);
2232            let mut lines = vec![
2233                Line::from(vec![
2234                    Span::styled("Model  ", Style::default().fg(Color::DarkGray)),
2235                    Span::styled(
2236                        if query.is_empty() {
2237                            "type to filter…"
2238                        } else {
2239                            query
2240                        },
2241                        Style::default().fg(if query.is_empty() {
2242                            Color::DarkGray
2243                        } else {
2244                            Color::White
2245                        }),
2246                    ),
2247                ]),
2248                Line::styled(
2249                    format!(
2250                        "{} models{}",
2251                        filtered.len(),
2252                        if filtered.is_empty() {
2253                            ""
2254                        } else {
2255                            " · ↑/↓ move · Enter choose"
2256                        }
2257                    ),
2258                    Style::default().fg(Color::DarkGray),
2259                ),
2260            ];
2261            if filtered.is_empty() {
2262                lines.push(Line::styled(
2263                    "No matching models",
2264                    Style::default().fg(Color::Yellow),
2265                ));
2266            } else {
2267                for index in range {
2268                    let selected = index == focus;
2269                    lines.push(Line::styled(
2270                        format!(
2271                            "{} {}",
2272                            if selected { "›" } else { " " },
2273                            filtered[index].id
2274                        ),
2275                        if selected {
2276                            Style::default().fg(Color::Black).bg(Color::Cyan)
2277                        } else {
2278                            Style::default().fg(Color::White)
2279                        },
2280                    ));
2281                }
2282            }
2283            lines.push(Line::styled(
2284                "Esc  cancel",
2285                Style::default().fg(Color::DarkGray),
2286            ));
2287            lines
2288        }
2289        SettingsState::Effort {
2290            model,
2291            input,
2292            focus,
2293        } => {
2294            let mut lines = vec![
2295                Line::styled(model.id.clone(), Style::default().fg(Color::Cyan)),
2296                Line::styled("Reasoning effort", Style::default().fg(Color::DarkGray)),
2297            ];
2298            match &model.efforts {
2299                Some(efforts) => {
2300                    let total = efforts.len() + 1;
2301                    let focus = (*focus).min(total.saturating_sub(1));
2302                    let list_rows = inner.height.saturating_sub(4) as usize;
2303                    for index in selection_range(total, focus, list_rows) {
2304                        let value = if index == 0 {
2305                            "default"
2306                        } else {
2307                            efforts[index - 1].as_str()
2308                        };
2309                        let selected = index == focus;
2310                        lines.push(Line::styled(
2311                            format!("{} {value}", if selected { "›" } else { " " }),
2312                            if selected {
2313                                Style::default().fg(Color::Black).bg(Color::Cyan)
2314                            } else {
2315                                Style::default().fg(Color::White)
2316                            },
2317                        ));
2318                    }
2319                    lines.push(Line::styled(
2320                        "↑/↓ move · Enter save · Esc cancel",
2321                        Style::default().fg(Color::DarkGray),
2322                    ));
2323                }
2324                None => {
2325                    lines.push(Line::raw("Provider did not advertise allowed efforts."));
2326                    lines.push(Line::from(vec![
2327                        Span::styled("Value  ", Style::default().fg(Color::DarkGray)),
2328                        Span::styled(
2329                            if input.is_empty() { "default" } else { input },
2330                            Style::default().fg(Color::White),
2331                        ),
2332                    ]));
2333                    lines.push(Line::styled(
2334                        "Type a value · Enter save · Esc cancel",
2335                        Style::default().fg(Color::DarkGray),
2336                    ));
2337                }
2338            }
2339            lines
2340        }
2341    };
2342    frame.render_widget(Paragraph::new(lines), inner);
2343}
2344
2345fn selection_range(total: usize, focus: usize, max_rows: usize) -> std::ops::Range<usize> {
2346    if total == 0 || max_rows == 0 {
2347        return 0..0;
2348    }
2349    let focus = focus.min(total - 1);
2350    let visible = total.min(max_rows);
2351    let start = focus
2352        .saturating_add(1)
2353        .saturating_sub(visible)
2354        .min(total - visible);
2355    start..start + visible
2356}
2357
2358fn draw_skill_picker(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
2359    let matches = state.matching_skill_names();
2360    let total = matches.len();
2361    if total == 0 || area.is_empty() {
2362        return;
2363    }
2364
2365    // The picker is painted last, over the existing transcript and activity;
2366    // its geometry never participates in the underlying layout.
2367    frame.render_widget(Clear, area);
2368    let inner = Rect::new(
2369        area.x.saturating_add(2),
2370        area.y.saturating_add(1),
2371        area.width.saturating_sub(4),
2372        area.height.saturating_sub(2),
2373    );
2374    let buffer = frame.buffer_mut();
2375    for y in area.y..area.y.saturating_add(area.height) {
2376        for x in area.x..area.x.saturating_add(area.width) {
2377            buffer[(x, y)].set_bg(SKILL_PICKER_BACKGROUND);
2378        }
2379    }
2380    if inner.is_empty() {
2381        return;
2382    }
2383
2384    let focus = state.skill_picker_focus.min(total - 1);
2385    let header = Line::styled(
2386        format!("[{}/{}]", focus + 1, total),
2387        Style::default().fg(QUEUED_MESSAGE_COLOR),
2388    );
2389    frame.render_widget(
2390        Paragraph::new(header),
2391        Rect::new(inner.x, inner.y, inner.width, 1),
2392    );
2393
2394    let item_rows = inner.height.saturating_sub(1) as usize;
2395    for (row, index) in selection_range(total, focus, item_rows).enumerate() {
2396        let mut style = Style::default().fg(QUEUED_MESSAGE_COLOR);
2397        if index == focus {
2398            style = style.add_modifier(Modifier::BOLD);
2399        }
2400        let skill = Line::styled(format!("/{}", matches[index]), style);
2401        frame.render_widget(
2402            Paragraph::new(skill),
2403            Rect::new(inner.x, inner.y + 1 + row as u16, inner.width, 1),
2404        );
2405    }
2406}
2407
2408fn greeting_image_enabled() -> bool {
2409    std::env::var("LUCY_GREETING_IMAGE").as_deref() == Ok("true")
2410}
2411
2412#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2413struct WelcomeImageLayout {
2414    image_area: Rect,
2415    intro_area: Rect,
2416    image_size: Size,
2417}
2418
2419fn welcome_image_layout(area: Rect, intro_height: u16) -> Option<WelcomeImageLayout> {
2420    let available_height = area
2421        .height
2422        .saturating_sub(intro_height.saturating_add(WELCOME_IMAGE_GAP));
2423    let max_width = area.width.min(GREETING_IMAGE_SIZE.width);
2424    let max_height = available_height.min(GREETING_IMAGE_SIZE.height);
2425    let aspect_width = GREETING_IMAGE_SIZE.width / GREETING_IMAGE_SIZE.height;
2426    let image_height = max_height.min(max_width / aspect_width);
2427    let image_size = Size::new(image_height * aspect_width, image_height);
2428    if image_size.width < GREETING_IMAGE_MIN_SIZE.width
2429        || image_size.height < GREETING_IMAGE_MIN_SIZE.height
2430    {
2431        return None;
2432    }
2433
2434    let group_height = image_size.height + WELCOME_IMAGE_GAP + intro_height;
2435    let group_y = area.y + area.height.saturating_sub(group_height) / 2;
2436    Some(WelcomeImageLayout {
2437        image_area: Rect::new(
2438            area.x + (area.width - image_size.width) / 2,
2439            group_y,
2440            image_size.width,
2441            image_size.height,
2442        ),
2443        intro_area: Rect::new(
2444            area.x,
2445            group_y + image_size.height + WELCOME_IMAGE_GAP,
2446            area.width,
2447            intro_height,
2448        ),
2449        image_size,
2450    })
2451}
2452
2453type WelcomeImageCache = Mutex<HashMap<(u16, u16), Arc<Protocol>>>;
2454
2455fn welcome_image(size: Size) -> Arc<Protocol> {
2456    static IMAGES: OnceLock<WelcomeImageCache> = OnceLock::new();
2457    let images = IMAGES.get_or_init(|| Mutex::new(HashMap::new()));
2458    let mut images = images
2459        .lock()
2460        .expect("welcome image cache should not be poisoned");
2461    images
2462        .entry((size.width, size.height))
2463        .or_insert_with(|| {
2464            let image = image::load_from_memory(GREETING_IMAGE_BYTES)
2465                .expect("embedded greeting PNG should decode");
2466            let image = dim_welcome_image(image);
2467            Arc::new(
2468                Picker::halfblocks()
2469                    .new_protocol(image, size, Resize::Fit(None))
2470                    .expect("embedded greeting PNG should convert to halfblocks"),
2471            )
2472        })
2473        .clone()
2474}
2475
2476fn dim_welcome_image(image: image::DynamicImage) -> image::DynamicImage {
2477    let mut image = image.to_rgba8();
2478    for pixel in image.pixels_mut() {
2479        for channel in pixel.0.iter_mut().take(3) {
2480            *channel = (u16::from(*channel) * WELCOME_IMAGE_BRIGHTNESS_PERCENT / 100) as u8;
2481        }
2482    }
2483    image::DynamicImage::ImageRgba8(image)
2484}
2485
2486fn logo_lines() -> Vec<Line<'static>> {
2487    let max_width = LOGO_TEXT
2488        .lines()
2489        .map(|line| line.chars().count())
2490        .max()
2491        .unwrap_or(0);
2492    LOGO_TEXT
2493        .lines()
2494        .map(|line| {
2495            let spans: Vec<Span> = line
2496                .chars()
2497                .enumerate()
2498                .map(|(index, character)| {
2499                    let progress = if max_width <= 1 {
2500                        0.0
2501                    } else {
2502                        index as f32 / (max_width - 1) as f32
2503                    };
2504                    let color = Color::Rgb(
2505                        interpolate_color(LOGO_START_COLOR.0, LOGO_END_COLOR.0, progress),
2506                        interpolate_color(LOGO_START_COLOR.1, LOGO_END_COLOR.1, progress),
2507                        interpolate_color(LOGO_START_COLOR.2, LOGO_END_COLOR.2, progress),
2508                    );
2509                    Span::styled(character.to_string(), Style::default().fg(color))
2510                })
2511                .collect();
2512            Line::from(spans)
2513        })
2514        .collect()
2515}
2516
2517fn welcome_line() -> Line<'static> {
2518    let character_count = WELCOME_MESSAGE.chars().count();
2519    let spans = WELCOME_MESSAGE
2520        .chars()
2521        .enumerate()
2522        .map(|(index, character)| {
2523            let progress = if character_count <= 1 {
2524                0.0
2525            } else {
2526                index as f32 / (character_count - 1) as f32
2527            };
2528            let color = Color::Rgb(
2529                interpolate_color(WELCOME_START_COLOR.0, WELCOME_END_COLOR.0, progress),
2530                interpolate_color(WELCOME_START_COLOR.1, WELCOME_END_COLOR.1, progress),
2531                interpolate_color(WELCOME_START_COLOR.2, WELCOME_END_COLOR.2, progress),
2532            );
2533            Span::styled(character.to_string(), Style::default().fg(color))
2534        })
2535        .collect::<Vec<_>>();
2536    Line::from(spans)
2537}
2538
2539fn interpolate_color(start: u8, end: u8, progress: f32) -> u8 {
2540    (start as f32 + (end as f32 - start as f32) * progress).round() as u8
2541}
2542
2543fn welcome_lines(attached_agents: &[String]) -> Vec<Line<'static>> {
2544    let mut lines = vec![
2545        welcome_line(),
2546        Line::styled(WELCOME_VERSION, Style::default().fg(Color::DarkGray)),
2547        Line::raw(""),
2548        Line::styled(WELCOME_TAGLINE, Style::default().fg(Color::DarkGray)),
2549        Line::raw(""),
2550    ];
2551
2552    if attached_agents.is_empty() {
2553        lines.push(Line::styled(
2554            "Attached AGENTS.md: none",
2555            Style::default().fg(Color::DarkGray),
2556        ));
2557    } else {
2558        lines.push(Line::styled(
2559            "Attached AGENTS.md:",
2560            Style::default().fg(Color::DarkGray),
2561        ));
2562        lines.extend(
2563            attached_agents.iter().map(|path| {
2564                Line::styled(format!("• {path}"), Style::default().fg(Color::DarkGray))
2565            }),
2566        );
2567    }
2568
2569    lines
2570}
2571
2572fn transcript_lines(state: &UiState, width: u16) -> Vec<Line<'static>> {
2573    render_transcript_items(&state.transcript, width.max(1) as usize, state)
2574}
2575
2576fn render_transcript_items(
2577    transcript: &[TranscriptItem],
2578    width: usize,
2579    state: &UiState,
2580) -> Vec<Line<'static>> {
2581    let mut lines = Vec::new();
2582    let mut rendered_item = false;
2583
2584    for (index, item) in transcript.iter().enumerate() {
2585        // Results are positioned on their matching call, even when the model
2586        // emitted several calls before execution produced any result.
2587        if is_result_attached_to_call(transcript, index) {
2588            continue;
2589        }
2590        if rendered_item {
2591            lines.push(Line::raw(String::new()));
2592        }
2593        match item {
2594            TranscriptItem::User {
2595                text,
2596                skill_instruction_attached,
2597            } => {
2598                let text = redact_secret(text, Some(&state.secret));
2599                let trigger = skill_instruction_attached
2600                    .then(|| active_skill_trigger(&text, &state.skill_names))
2601                    .flatten();
2602                push_user_message_block(&mut lines, &text, trigger, width);
2603            }
2604            TranscriptItem::Assistant(text) => {
2605                let text = redact_secret(text, Some(&state.secret));
2606                push_wrapped(&mut lines, &text, width, Style::default());
2607            }
2608            TranscriptItem::ToolCall {
2609                id,
2610                name,
2611                arguments,
2612            } => {
2613                let result = matching_tool_result(transcript, index, id);
2614                let segments = if name == "cmd" {
2615                    cmd_tool_segments(id, arguments, result, state)
2616                } else {
2617                    generic_tool_segments(name, arguments, result, state)
2618                };
2619                push_spans_wrapped(&mut lines, &segments, width);
2620            }
2621            TranscriptItem::ToolResult {
2622                id: _,
2623                name: _,
2624                result,
2625            } => {
2626                let result_text = format_tool_result(result);
2627                let result_text = redact_secret(&result_text, Some(&state.secret));
2628                push_spans_wrapped(&mut lines, &[(result_text, tool_result_style())], width);
2629            }
2630            TranscriptItem::Error(text) => {
2631                let text = redact_secret(text, Some(&state.secret));
2632                push_wrapped(&mut lines, &text, width, error_style());
2633            }
2634            TranscriptItem::Info(text) => {
2635                let text = redact_secret(text, Some(&state.secret));
2636                push_wrapped(&mut lines, &text, width, info_style());
2637            }
2638            TranscriptItem::Reasoning { complete } => {
2639                let text = if *complete {
2640                    "Reasoning Complete".to_owned()
2641                } else {
2642                    format!("Reasoning... {}", spinner_frame(state))
2643                };
2644                push_wrapped(&mut lines, &text, width, thinking_style());
2645            }
2646        }
2647        rendered_item = true;
2648    }
2649    if lines.is_empty() {
2650        lines.push(Line::raw(""));
2651    }
2652    lines
2653}
2654
2655/// Tool work uses its own clock instead of the main status animation.
2656fn running_tool_status(state: &UiState) -> String {
2657    tool_spinner_frame(state)
2658}
2659
2660fn cmd_tool_segments(
2661    call_id: &str,
2662    arguments: &str,
2663    result: Option<&Value>,
2664    state: &UiState,
2665) -> Vec<(String, Style)> {
2666    let command = redact_secret(&command_display(arguments), Some(&state.secret));
2667    if let Some(result) = result {
2668        let (icon, status, status_style) = cmd_result_status(result);
2669        if status == "done" || state.cmd_result_started_at.contains_key(call_id) {
2670            let text = if status == "done" {
2671                format!("{icon} cmd  $ {command}")
2672            } else {
2673                format!("{icon} cmd  $ {command}  → {status}")
2674            };
2675            return cmd_result_segments(call_id, &text, cmd_result_target_color(result), state);
2676        }
2677        vec![
2678            (format!("{icon} cmd  $ {command}  → "), status_style),
2679            (status, status_style),
2680        ]
2681    } else {
2682        vec![
2683            (format!("· cmd  $ {command}  "), pending_tool_call_style()),
2684            (running_tool_status(state), pending_tool_call_style()),
2685        ]
2686    }
2687}
2688
2689/// During the brief post-result window, turn the compact `cmd` line from the
2690/// pending orange into its final result colour one character at a time. A few
2691/// adjacent characters blend at the leading edge so the visual is a true
2692/// gradient, rather than a hard colour boundary.
2693fn cmd_result_segments(
2694    call_id: &str,
2695    text: &str,
2696    target: Color,
2697    state: &UiState,
2698) -> Vec<(String, Style)> {
2699    let now = Instant::now();
2700    let Some(started_at) = state.cmd_result_started_at.get(call_id).copied() else {
2701        return vec![(text.to_owned(), Style::default().fg(target))];
2702    };
2703    if now.saturating_duration_since(started_at) >= TOOL_RESULT_SWEEP_DURATION {
2704        return vec![(text.to_owned(), Style::default().fg(target))];
2705    }
2706
2707    let character_count = text.chars().count();
2708    text.chars()
2709        .enumerate()
2710        .map(|(index, character)| {
2711            (
2712                character.to_string(),
2713                Style::default().fg(cmd_result_color_at(
2714                    started_at,
2715                    now,
2716                    index,
2717                    character_count,
2718                    target,
2719                )),
2720            )
2721        })
2722        .collect()
2723}
2724
2725fn cmd_result_color_at(
2726    started_at: Instant,
2727    now: Instant,
2728    character_index: usize,
2729    character_count: usize,
2730    target: Color,
2731) -> Color {
2732    let elapsed = now.saturating_duration_since(started_at);
2733    if elapsed >= TOOL_RESULT_SWEEP_DURATION {
2734        return target;
2735    }
2736
2737    let progress = elapsed.as_secs_f32() / TOOL_RESULT_SWEEP_DURATION.as_secs_f32();
2738    let character_position = if character_count <= 1 {
2739        0.0
2740    } else {
2741        character_index as f32 / (character_count - 1) as f32
2742    };
2743    let fade_start = character_position * (1.0 - TOOL_RESULT_CHARACTER_FADE_PORTION);
2744    let character_progress =
2745        ((progress - fade_start) / TOOL_RESULT_CHARACTER_FADE_PORTION).clamp(0.0, 1.0);
2746    let character_progress =
2747        character_progress * character_progress * (3.0 - 2.0 * character_progress);
2748    let (target_red, target_green, target_blue) = tool_result_color_rgb(target);
2749    Color::Rgb(
2750        interpolate_color(PENDING_TOOL_COLOR_RGB.0, target_red, character_progress),
2751        interpolate_color(PENDING_TOOL_COLOR_RGB.1, target_green, character_progress),
2752        interpolate_color(PENDING_TOOL_COLOR_RGB.2, target_blue, character_progress),
2753    )
2754}
2755
2756fn command_display(arguments: &str) -> String {
2757    serde_json::from_str::<Value>(arguments)
2758        .ok()
2759        .and_then(|value| {
2760            value
2761                .get("command")
2762                .and_then(Value::as_str)
2763                .map(str::to_owned)
2764        })
2765        .map(|command| truncate_tool_call(&command))
2766        .unwrap_or_else(|| truncate_tool_call(arguments))
2767}
2768
2769fn cmd_result_target_color(result: &Value) -> Color {
2770    if result
2771        .get("canceled")
2772        .and_then(Value::as_bool)
2773        .unwrap_or(false)
2774        || result
2775            .get("timed_out")
2776            .and_then(Value::as_bool)
2777            .unwrap_or(false)
2778    {
2779        return TOOL_WARNING_COLOR;
2780    }
2781    if result.get("error").is_some()
2782        || matches!(result.get("exit_code").and_then(Value::as_i64), Some(code) if code != 0)
2783    {
2784        return TOOL_FAILURE_COLOR;
2785    }
2786    TOOL_SUCCESS_COLOR
2787}
2788
2789fn tool_result_color_rgb(color: Color) -> (u8, u8, u8) {
2790    let Color::Rgb(red, green, blue) = color else {
2791        unreachable!("cmd result transition colours are RGB")
2792    };
2793    (red, green, blue)
2794}
2795
2796fn cmd_result_status(result: &Value) -> (char, String, Style) {
2797    let target = cmd_result_target_color(result);
2798    if result.get("status").and_then(Value::as_str) == Some("running") {
2799        let id = result
2800            .get("background_id")
2801            .and_then(Value::as_str)
2802            .unwrap_or("background");
2803        return ('↗', id.to_owned(), Style::default().fg(target));
2804    }
2805    if result
2806        .get("canceled")
2807        .and_then(Value::as_bool)
2808        .unwrap_or(false)
2809    {
2810        return ('!', "canceled".to_owned(), Style::default().fg(target));
2811    }
2812    if result
2813        .get("timed_out")
2814        .and_then(Value::as_bool)
2815        .unwrap_or(false)
2816    {
2817        return ('!', "timeout".to_owned(), Style::default().fg(target));
2818    }
2819    if result.get("error").is_some() {
2820        return ('×', "error".to_owned(), Style::default().fg(target));
2821    }
2822    match result.get("exit_code").and_then(Value::as_i64) {
2823        Some(0) => ('✓', "done".to_owned(), Style::default().fg(target)),
2824        Some(code) => ('×', format!("exit {code}"), Style::default().fg(target)),
2825        None => ('✓', "done".to_owned(), Style::default().fg(target)),
2826    }
2827}
2828
2829fn generic_tool_segments(
2830    name: &str,
2831    arguments: &str,
2832    result: Option<&Value>,
2833    state: &UiState,
2834) -> Vec<(String, Style)> {
2835    let call_text = redact_secret(
2836        &format!("[tool:{name} {}]", call_arguments(arguments)),
2837        Some(&state.secret),
2838    );
2839    let mut segments = vec![(
2840        call_text,
2841        if result.is_some() {
2842            tool_call_style()
2843        } else {
2844            pending_tool_call_style()
2845        },
2846    )];
2847    if let Some(result) = result {
2848        let result_text = redact_secret(&format_tool_result(result), Some(&state.secret));
2849        segments.push((" > ".to_owned(), Style::default()));
2850        segments.push((result_text, tool_result_style()));
2851    } else {
2852        segments.push((
2853            format!(" {}", tool_spinner_frame(state)),
2854            pending_tool_call_style(),
2855        ));
2856    }
2857    segments
2858}
2859
2860fn matching_tool_result<'a>(
2861    transcript: &'a [TranscriptItem],
2862    call_index: usize,
2863    call_id: &str,
2864) -> Option<&'a Value> {
2865    transcript
2866        .iter()
2867        .skip(call_index + 1)
2868        .find_map(|item| match item {
2869            TranscriptItem::ToolResult { id, result, .. } if id == call_id => Some(result),
2870            _ => None,
2871        })
2872}
2873
2874fn is_result_attached_to_call(transcript: &[TranscriptItem], result_index: usize) -> bool {
2875    let TranscriptItem::ToolResult { id, .. } = &transcript[result_index] else {
2876        return false;
2877    };
2878    let Some(call_index) = transcript[..result_index].iter().rposition(
2879        |item| matches!(item, TranscriptItem::ToolCall { id: call_id, .. } if call_id == id),
2880    ) else {
2881        return false;
2882    };
2883    !transcript[call_index + 1..result_index].iter().any(
2884        |item| matches!(item, TranscriptItem::ToolResult { id: result_id, .. } if result_id == id),
2885    )
2886}
2887
2888const TOOL_CALL_PREVIEW_CHARS: usize = 100;
2889
2890fn truncate_tool_call(output: &str) -> String {
2891    let mut result: String = output.chars().take(TOOL_CALL_PREVIEW_CHARS).collect();
2892    if output.chars().count() > TOOL_CALL_PREVIEW_CHARS {
2893        result.push('…');
2894    }
2895    result
2896}
2897
2898/// Render tool call arguments as the command string inside double quotes, for
2899/// example `"cat README.md"`. Tool-call previews are limited to 100 characters;
2900/// malformed arguments fall back to the same bounded raw-text preview.
2901fn call_arguments(arguments: &str) -> String {
2902    let parsed: Value = match serde_json::from_str(arguments) {
2903        Ok(value) => value,
2904        Err(_) => return truncate_tool_call(arguments),
2905    };
2906    if let Some(command) = parsed.get("command").and_then(Value::as_str) {
2907        return format!("\"{}\"", truncate_tool_call(command));
2908    }
2909    let serialized = serde_json::to_string(&parsed).unwrap_or_else(|_| arguments.to_owned());
2910    truncate_tool_call(&serialized)
2911}
2912
2913/// Render a tool result as a single-line JSON-string-array literal containing
2914/// stdout (or stderr when stdout is empty). Newlines are escaped so the whole
2915/// result stays on one line. Output is truncated to `RESULT_PREVIEW_CHARS`.
2916fn format_tool_result(result: &Value) -> String {
2917    let stdout = result.get("stdout").and_then(Value::as_str).unwrap_or("");
2918    let stderr = result.get("stderr").and_then(Value::as_str).unwrap_or("");
2919    let output = if !stdout.is_empty() { stdout } else { stderr };
2920    let truncated = truncate_output(output);
2921    // Build a JSON string literal so newlines and quotes are escaped and the
2922    // result renders on a single line as `["..."]`.
2923    let json_string = serde_json::to_string(&truncated).unwrap_or_else(|_| "\"\"".to_owned());
2924    format!("[{json_string}]")
2925}
2926
2927const RESULT_PREVIEW_CHARS: usize = 50;
2928
2929fn truncate_output(output: &str) -> String {
2930    let mut result: String = output.chars().take(RESULT_PREVIEW_CHARS).collect();
2931    if output.chars().count() > RESULT_PREVIEW_CHARS {
2932        result.push('…');
2933    }
2934    result
2935}
2936
2937fn user_message_style() -> Style {
2938    Style::default().fg(USER_BORDER_COLOR)
2939}
2940
2941/// Render user messages with a one-cell yellow block rule, one inner left
2942/// padding cell, and blank rows above and below; assistant and tool output remains borderless.
2943fn push_user_message_block(
2944    lines: &mut Vec<Line<'static>>,
2945    text: &str,
2946    active_skill_trigger: Option<&str>,
2947    width: usize,
2948) {
2949    if width < 3 {
2950        lines.extend(styled_text_lines(
2951            text,
2952            active_skill_trigger,
2953            width.max(1),
2954            Style::default().fg(Color::White),
2955        ));
2956        return;
2957    }
2958
2959    let border_style = user_message_style();
2960    let rows = styled_text_lines(
2961        text,
2962        active_skill_trigger,
2963        width - 2,
2964        Style::default().fg(Color::White),
2965    );
2966    lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
2967    for row in rows {
2968        let mut spans = Vec::with_capacity(row.spans.len() + 2);
2969        spans.push(Span::styled(USER_BORDER_GLYPH, border_style));
2970        spans.push(Span::styled(" ", Style::default().fg(Color::White)));
2971        spans.extend(row.spans);
2972        lines.push(Line::from(spans));
2973    }
2974    lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
2975}
2976
2977fn tool_call_style() -> Style {
2978    Style::default().fg(Color::Magenta)
2979}
2980
2981fn pending_tool_call_style() -> Style {
2982    Style::default().fg(PENDING_TOOL_COLOR)
2983}
2984
2985fn tool_result_style() -> Style {
2986    Style::default().fg(Color::DarkGray)
2987}
2988
2989fn error_style() -> Style {
2990    Style::default().fg(Color::Red)
2991}
2992
2993fn info_style() -> Style {
2994    Style::default().fg(Color::DarkGray)
2995}
2996
2997fn context_status_text(state: &UiState) -> String {
2998    let used = format_context_tokens(state.context_tokens);
2999    let Some(window) = state.context_window else {
3000        return format!("Context: {used}/? (?%) ??????????");
3001    };
3002    let percentage = context_percentage(state.context_tokens, window);
3003    format!(
3004        "Context: {used}/{} ({percentage}%) {}",
3005        format_context_tokens(window),
3006        context_progress_bar(state.context_tokens, window)
3007    )
3008}
3009
3010fn context_progress_bar(used: usize, window: usize) -> String {
3011    const WIDTH: usize = 10;
3012    let filled = if window == 0 {
3013        0
3014    } else {
3015        (used as u128 * WIDTH as u128)
3016            .div_ceil(window as u128)
3017            .min(WIDTH as u128) as usize
3018    };
3019    format!("{}{}", "█".repeat(filled), "░".repeat(WIDTH - filled))
3020}
3021
3022fn context_status_style(_state: &UiState) -> Style {
3023    Style::default().fg(CONSOLE_STATUS_COLOR)
3024}
3025
3026fn context_percentage(used: usize, window: usize) -> usize {
3027    if window == 0 {
3028        return 0;
3029    }
3030    ((used as u128 * 100).div_ceil(window as u128)) as usize
3031}
3032
3033fn format_context_tokens(tokens: usize) -> String {
3034    if tokens >= 1_000_000 {
3035        format!("{:.2}M", tokens as f64 / 1_000_000.0)
3036    } else if tokens >= 1_000 {
3037        format!("{:.1}K", tokens as f64 / 1_000.0)
3038    } else {
3039        tokens.to_string()
3040    }
3041}
3042
3043fn model_status_line(state: &UiState, effort: &str, width: u16) -> Line<'static> {
3044    model_status_line_at(
3045        state,
3046        effort,
3047        state.console_animation_elapsed_at(Instant::now()),
3048        width,
3049    )
3050}
3051
3052fn model_status_line_at(
3053    state: &UiState,
3054    effort: &str,
3055    elapsed: Duration,
3056    width: u16,
3057) -> Line<'static> {
3058    let model = redact_secret(&state.model, Some(&state.secret));
3059    let effort = redact_secret(effort, Some(&state.secret));
3060    let context = context_status_text(state);
3061    let context_width = UnicodeWidthStr::width(context.as_str());
3062    let model_style = if state.busy {
3063        Style::default().fg(console_accent_at(elapsed))
3064    } else {
3065        context_status_style(state)
3066    };
3067    let status_style = context_status_style(state);
3068    let mut spans = vec![
3069        Span::styled(model, model_style),
3070        Span::styled(format!(" · {effort}"), status_style),
3071    ];
3072    if state.busy {
3073        let accent = console_accent_at(elapsed);
3074        let (head, _) = busy_indicator_position_at(elapsed);
3075        spans.push(Span::raw(" "));
3076        for (index, character) in busy_indicator_frame_at(elapsed).chars().enumerate() {
3077            let distance = if character == BUSY_INDICATOR_BLOCK && index != head {
3078                Some(index.abs_diff(head))
3079            } else {
3080                None
3081            };
3082            let color = busy_indicator_color(accent, distance);
3083            spans.push(Span::styled(
3084                character.to_string(),
3085                Style::default().fg(color),
3086            ));
3087        }
3088    }
3089    let left_width = spans
3090        .iter()
3091        .map(|span| UnicodeWidthStr::width(span.content.as_ref()))
3092        .sum::<usize>();
3093    let gap = usize::from(width).saturating_sub(left_width + context_width);
3094    if gap > 0 {
3095        spans.push(Span::raw(" ".repeat(gap)));
3096    }
3097    spans.push(Span::styled(context, status_style));
3098    Line::from(spans)
3099}
3100
3101fn console_accent_cycle() -> Duration {
3102    CONSOLE_ACCENT_CYCLE_DURATION
3103}
3104
3105fn console_accent_at(elapsed: Duration) -> Color {
3106    let cycle_progress =
3107        (elapsed.as_secs_f32() / console_accent_cycle().as_secs_f32()).rem_euclid(1.0);
3108    let progress = if cycle_progress <= 0.5 {
3109        cycle_progress * 2.0
3110    } else {
3111        (1.0 - cycle_progress) * 2.0
3112    };
3113    desaturate_console_accent(
3114        interpolate_color(CONSOLE_ACCENT_LAVENDER.0, CONSOLE_ACCENT_TEAL.0, progress),
3115        interpolate_color(CONSOLE_ACCENT_LAVENDER.1, CONSOLE_ACCENT_TEAL.1, progress),
3116        interpolate_color(CONSOLE_ACCENT_LAVENDER.2, CONSOLE_ACCENT_TEAL.2, progress),
3117    )
3118}
3119
3120fn desaturate_console_accent(red: u8, green: u8, blue: u8) -> Color {
3121    let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
3122    Color::Rgb(
3123        interpolate_color(red, neutral, CONSOLE_ACCENT_DESATURATION),
3124        interpolate_color(green, neutral, CONSOLE_ACCENT_DESATURATION),
3125        interpolate_color(blue, neutral, CONSOLE_ACCENT_DESATURATION),
3126    )
3127}
3128
3129fn thinking_style() -> Style {
3130    Style::default().fg(Color::DarkGray)
3131}
3132
3133// Unicode block elements occupy the full cell width. Rendering them without
3134// separators keeps the five bars visually continuous in terminal fonts.
3135const PULSE_LEVELS: [char; 7] = ['▁', '▂', '▃', '▅', '▆', '▇', '█'];
3136const PULSE_BAR_PERIODS: [u128; 5] = [12, 16, 20, 24, 15];
3137const PULSE_BAR_PHASES: [u128; 5] = [0, 5, 13, 9, 3];
3138const BUSY_INDICATOR_TRACK_LENGTH: usize = 5;
3139const BUSY_INDICATOR_TAIL_LENGTH: usize = 2;
3140const BUSY_INDICATOR_WIDTH: usize = BUSY_INDICATOR_TRACK_LENGTH + BUSY_INDICATOR_TAIL_LENGTH;
3141const BUSY_INDICATOR_BLOCK: char = '■';
3142const BUSY_INDICATOR_TAIL_OPACITY: [f32; BUSY_INDICATOR_TAIL_LENGTH] = [0.55, 0.25];
3143const BUSY_INDICATOR_PERIOD_TICKS: u128 = (BUSY_INDICATOR_TRACK_LENGTH as u128 - 1) * 2;
3144// 62.5ms per cell makes the busy indicator move at 80% of its former speed.
3145const BUSY_INDICATOR_TICK: Duration = Duration::from_micros(62_500);
3146const PULSE_TICK: Duration = Duration::from_millis(50);
3147const TOOL_SPINNER_FRAMES: [char; 4] = ['|', '/', '-', '\\'];
3148const TOOL_SPINNER_FRAME_DURATION: Duration = Duration::from_millis(100);
3149
3150/// Five independently phased triangle waves make the bars feel irregular
3151/// without random jumps: every rendered tick changes a bar by at most one
3152/// level, and the combined pattern repeats every 12 seconds.
3153const ACTIVITY_TRANSITION_DURATION: Duration = Duration::from_millis(400);
3154// This frame gives all five bars room to rise from the resting level while
3155// preserving the pulse waveform's one-level-per-tick continuity afterwards.
3156const PULSE_ENTRY_FRAME: Duration = Duration::from_millis(950);
3157
3158fn spinner_frame(state: &UiState) -> String {
3159    pulse_frame(state.activity_levels_at(Instant::now()))
3160}
3161
3162#[cfg(test)]
3163fn spinner_frame_at(elapsed: Duration) -> String {
3164    pulse_frame(pulse_levels_at(elapsed))
3165}
3166
3167/// A compact, traditional spinner for tool calls that are awaiting a result.
3168/// It deliberately has a separate epoch because background work can outlive a
3169/// main-agent turn.
3170fn tool_spinner_frame(state: &UiState) -> String {
3171    tool_spinner_frame_at(state.tool_animation_epoch.elapsed()).to_string()
3172}
3173
3174fn tool_spinner_frame_at(elapsed: Duration) -> char {
3175    let frame = (elapsed.as_millis() / TOOL_SPINNER_FRAME_DURATION.as_millis()) as usize;
3176    TOOL_SPINNER_FRAMES[frame % TOOL_SPINNER_FRAMES.len()]
3177}
3178
3179fn pulse_frame(levels: [usize; PULSE_BAR_PERIODS.len()]) -> String {
3180    levels
3181        .into_iter()
3182        .map(|level| PULSE_LEVELS[level])
3183        .collect()
3184}
3185
3186fn busy_indicator_position_at(elapsed: Duration) -> (usize, bool) {
3187    let tick = elapsed.as_micros() / BUSY_INDICATOR_TICK.as_micros();
3188    let phase = tick % BUSY_INDICATOR_PERIOD_TICKS;
3189    if phase < BUSY_INDICATOR_TRACK_LENGTH as u128 {
3190        (
3191            phase as usize,
3192            phase < BUSY_INDICATOR_TRACK_LENGTH as u128 - 1,
3193        )
3194    } else {
3195        ((BUSY_INDICATOR_PERIOD_TICKS - phase) as usize, false)
3196    }
3197}
3198
3199fn busy_indicator_frame_at(elapsed: Duration) -> String {
3200    let (head, moving_right) = busy_indicator_position_at(elapsed);
3201    let mut frame = vec![' '; BUSY_INDICATOR_WIDTH];
3202    frame[head] = BUSY_INDICATOR_BLOCK;
3203    for distance in 1..=BUSY_INDICATOR_TAIL_LENGTH {
3204        let tail = if moving_right {
3205            head.checked_sub(distance)
3206        } else {
3207            head.checked_add(distance)
3208        };
3209        if let Some(tail) = tail.filter(|&index| index < BUSY_INDICATOR_WIDTH) {
3210            frame[tail] = BUSY_INDICATOR_BLOCK;
3211        }
3212    }
3213    frame.into_iter().collect()
3214}
3215
3216/// Terminals do not support alpha in a cell foreground, so fade the tail by
3217/// blending the accent toward the console background color.
3218fn busy_indicator_color(accent: Color, distance: Option<usize>) -> Color {
3219    let Some(distance) = distance else {
3220        return accent;
3221    };
3222    let Color::Rgb(red, green, blue) = accent else {
3223        return accent;
3224    };
3225    let opacity = BUSY_INDICATOR_TAIL_OPACITY
3226        .get(distance.saturating_sub(1))
3227        .copied()
3228        .unwrap_or(0.0);
3229    Color::Rgb(
3230        interpolate_color(BUSY_INDICATOR_FADE_BASE_RGB.0, red, opacity),
3231        interpolate_color(BUSY_INDICATOR_FADE_BASE_RGB.1, green, opacity),
3232        interpolate_color(BUSY_INDICATOR_FADE_BASE_RGB.2, blue, opacity),
3233    )
3234}
3235fn pulse_levels_at(elapsed: Duration) -> [usize; PULSE_BAR_PERIODS.len()] {
3236    let tick = elapsed.as_millis() / PULSE_TICK.as_millis();
3237    std::array::from_fn(|index| {
3238        pulse_level_at(tick, PULSE_BAR_PERIODS[index], PULSE_BAR_PHASES[index])
3239    })
3240}
3241
3242fn interpolate_pulse_levels(
3243    from: [usize; PULSE_BAR_PERIODS.len()],
3244    to: [usize; PULSE_BAR_PERIODS.len()],
3245    elapsed: Duration,
3246) -> [usize; PULSE_BAR_PERIODS.len()] {
3247    let elapsed = elapsed.min(ACTIVITY_TRANSITION_DURATION).as_millis();
3248    let duration = ACTIVITY_TRANSITION_DURATION.as_millis();
3249    std::array::from_fn(|index| {
3250        let start = from[index] as i128;
3251        let distance = to[index] as i128 - start;
3252        (start + distance * elapsed as i128 / duration as i128) as usize
3253    })
3254}
3255
3256fn pulse_level_at(tick: u128, period: u128, phase: u128) -> usize {
3257    let position = (tick + phase) % period;
3258    let half_period = period / 2;
3259    let distance_from_floor = if position <= half_period {
3260        position
3261    } else {
3262        period - position
3263    };
3264    (distance_from_floor * (PULSE_LEVELS.len() - 1) as u128 / half_period) as usize
3265}
3266
3267fn push_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, style: Style) {
3268    let mut added = false;
3269    for piece in wrap_text(text, width) {
3270        lines.push(Line::styled(piece, style));
3271        added = true;
3272    }
3273    if !added {
3274        lines.push(Line::styled(String::new(), style));
3275    }
3276}
3277
3278/// Push a logical line built from styled segments. When the rendered width
3279/// exceeds `width`, the whole line is character-wrapped; wrapped continuations
3280/// keep the style of the segment they fall on.
3281fn push_spans_wrapped(lines: &mut Vec<Line<'static>>, segments: &[(String, Style)], width: usize) {
3282    let mut current_spans: Vec<Span<'static>> = Vec::new();
3283    let mut current_width = 0usize;
3284    for (text, style) in segments {
3285        for character in text.chars() {
3286            let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
3287            if current_width + char_width > width && !current_spans.is_empty() {
3288                lines.push(Line::from(std::mem::take(&mut current_spans)));
3289                current_width = 0;
3290            }
3291            let mut buffer = [0u8; 4];
3292            let s = character.encode_utf8(&mut buffer);
3293            current_spans.push(Span::styled(s.to_owned(), *style));
3294            current_width += char_width;
3295        }
3296    }
3297    if current_spans.is_empty() {
3298        current_spans.push(Span::raw(String::new()));
3299    }
3300    lines.push(Line::from(current_spans));
3301}
3302
3303/// Wrap `text` into rows no wider than `width` display columns. Wrapping is
3304/// character-based so the row count matches exactly what a non-wrapping
3305/// `Paragraph` renderer draws, which keeps auto-scroll pinned to the true
3306/// bottom of the transcript regardless of terminal width. Empty lines are
3307/// preserved as empty rows.
3308fn wrap_text(text: &str, width: usize) -> Vec<String> {
3309    if width == 0 {
3310        return text.lines().map(str::to_owned).collect();
3311    }
3312    let mut rows = Vec::new();
3313    // `split` preserves a trailing empty row, so Shift+Enter renders an
3314    // immediate new line even before another character is typed.
3315    for line in text.split('\n') {
3316        rows.extend(wrap_line(line, width));
3317    }
3318    if rows.is_empty() {
3319        rows.push(String::new());
3320    }
3321    rows
3322}
3323
3324fn wrap_line(line: &str, width: usize) -> Vec<String> {
3325    let mut rows = Vec::new();
3326    let mut current = String::new();
3327    let mut current_width = 0usize;
3328    for character in line.chars() {
3329        let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
3330        if current_width + char_width > width && !current.is_empty() {
3331            rows.push(std::mem::take(&mut current));
3332            current_width = 0;
3333        }
3334        current.push(character);
3335        current_width += char_width;
3336    }
3337    rows.push(current);
3338    rows
3339}
3340
3341#[cfg(test)]
3342mod tests {
3343    use super::*;
3344
3345    #[test]
3346    fn turn_notifications_use_osc_777_with_fixed_secret_safe_messages() {
3347        let cases = [
3348            (TurnNotification::Completed, "Turn complete"),
3349            (TurnNotification::Interrupted, "Turn interrupted"),
3350            (TurnNotification::Failed, "Turn failed"),
3351        ];
3352
3353        for (notification, body) in cases {
3354            let mut output = Vec::new();
3355            send_turn_notification(&mut output, notification).expect("notification");
3356            assert_eq!(
3357                output,
3358                format!("\x1b]777;notify;Lucy;{body}\x07").into_bytes()
3359            );
3360        }
3361    }
3362
3363    #[test]
3364    fn turn_notifications_follow_the_terminal_turn_status() {
3365        assert_eq!(
3366            turn_notification_for_status("finalizing"),
3367            TurnNotification::Completed
3368        );
3369        assert_eq!(
3370            turn_notification_for_status("cancelling"),
3371            TurnNotification::Interrupted
3372        );
3373        assert_eq!(
3374            turn_notification_for_status("error"),
3375            TurnNotification::Failed
3376        );
3377    }
3378
3379    struct FailingWriter;
3380
3381    impl Write for FailingWriter {
3382        fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
3383            Err(io::Error::other("notification sink unavailable"))
3384        }
3385
3386        fn flush(&mut self) -> io::Result<()> {
3387            Err(io::Error::other("notification sink unavailable"))
3388        }
3389    }
3390
3391    #[test]
3392    fn notification_write_failure_does_not_keep_the_tui_busy() {
3393        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3394        state.busy = true;
3395        state.active_cancel = Some(CancellationToken::new());
3396        let mut writer = FailingWriter;
3397
3398        release_finished_turn(&mut writer, &mut state);
3399
3400        assert!(!state.busy);
3401        assert!(state.active_cancel.is_none());
3402    }
3403
3404    #[test]
3405    fn an_idle_finish_does_not_emit_a_duplicate_notification() {
3406        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3407        let mut output = Vec::new();
3408
3409        release_finished_turn(&mut output, &mut state);
3410
3411        assert!(output.is_empty());
3412    }
3413
3414    #[test]
3415    fn context_status_shows_used_window_and_percentage_in_uniform_gray() {
3416        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3417            .with_context(Some(100_000), 80_000);
3418
3419        assert_eq!(
3420            context_status_text(&state),
3421            "Context: 80.0K/100.0K (80%) ████████░░"
3422        );
3423        assert_eq!(
3424            context_status_style(&state).fg,
3425            Some(Color::Rgb(144, 144, 148))
3426        );
3427
3428        state.context_tokens = 80_001;
3429        assert_eq!(
3430            context_status_text(&state),
3431            "Context: 80.0K/100.0K (81%) █████████░"
3432        );
3433        assert_eq!(
3434            context_status_style(&state).fg,
3435            Some(Color::Rgb(144, 144, 148)),
3436            "crossing the compaction threshold does not recolor the status line"
3437        );
3438    }
3439
3440    #[test]
3441    fn context_status_keeps_percentage_consistent_at_capacity() {
3442        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3443            .with_context(Some(100_000), 99_001);
3444
3445        assert_eq!(
3446            context_status_text(&state),
3447            "Context: 99.0K/100.0K (100%) ██████████"
3448        );
3449
3450        state.context_tokens = 100_000;
3451        assert_eq!(
3452            context_status_text(&state),
3453            "Context: 100.0K/100.0K (100%) ██████████"
3454        );
3455
3456        state.context_tokens = 100_001;
3457        assert_eq!(
3458            context_status_text(&state),
3459            "Context: 100.0K/100.0K (101%) ██████████"
3460        );
3461    }
3462
3463    #[test]
3464    fn context_status_handles_unknown_window_without_highlighting() {
3465        let state = UiState::from_history(&[], "secret", "model", None, false);
3466
3467        assert_eq!(context_status_text(&state), "Context: 1/? (?%) ??????????");
3468        assert_eq!(
3469            context_status_style(&state).fg,
3470            Some(Color::Rgb(144, 144, 148))
3471        );
3472    }
3473
3474    #[test]
3475    fn tui_viewport_reserves_one_column_on_each_side_when_possible() {
3476        assert_eq!(
3477            tui_viewport(Rect::new(0, 0, 80, 10)),
3478            Rect::new(1, 0, 78, 10)
3479        );
3480        assert_eq!(
3481            tui_viewport(Rect::new(0, 0, 2, 10)),
3482            Rect::new(0, 0, 2, 10),
3483            "a two-column terminal cannot reserve two gutters"
3484        );
3485    }
3486
3487    #[test]
3488    fn tui_viewport_caps_at_one_hundred_columns_and_centers_it() {
3489        assert_eq!(
3490            tui_viewport(Rect::new(0, 0, 140, 10)),
3491            Rect::new(20, 0, TUI_MAX_WIDTH, 10)
3492        );
3493        assert_eq!(
3494            tui_viewport(Rect::new(0, 0, 103, 10)),
3495            Rect::new(1, 0, TUI_MAX_WIDTH, 10),
3496            "an odd remaining column stays on the right"
3497        );
3498    }
3499
3500    #[test]
3501    fn bottom_console_has_external_margins_without_losing_internal_padding() {
3502        let state = UiState::from_history(&[], "secret", "model", None, false);
3503        let viewport = tui_viewport(Rect::new(0, 0, 80, 14));
3504        let (chat, _, _, _, console, _) = ui_layout(&state, viewport);
3505        let content = console_content_area(console);
3506
3507        assert_eq!(chat.x, console.x);
3508        assert_eq!(chat.width, console.width);
3509        assert_eq!(
3510            console,
3511            Rect::new(viewport.x + 7, 8, viewport.width - 14, 5)
3512        );
3513        assert_eq!(console.y + console.height, viewport.y + viewport.height - 1);
3514        assert_eq!(content.x, console.x + 2);
3515        assert_eq!(content.width, console.width - 4);
3516        assert_eq!(content.y, console.y + 1);
3517        assert_eq!(content.y + content.height, console.y + console.height - 1);
3518
3519        for (width, margin, console_width) in [
3520            (1, 0, 1),
3521            (2, 0, 2),
3522            (3, 1, 1),
3523            (4, 1, 2),
3524            (5, 2, 1),
3525            (15, 0, 15),
3526        ] {
3527            let console = bottom_console_area(Rect::new(0, 0, width, 4), 0, 4);
3528            assert_eq!(console.x, margin, "width {width}");
3529            assert_eq!(console.width, console_width, "width {width}");
3530        }
3531    }
3532
3533    #[test]
3534    fn inset_console_width_drives_prompt_rows_and_vertical_navigation() {
3535        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3536        state.input = "x".repeat(71);
3537        let viewport = tui_viewport(Rect::new(0, 0, 80, 14));
3538        let console = ui_layout(&state, viewport).4;
3539        let prompt = prompt_area(console, &state);
3540
3541        assert_eq!(ui_prompt_content_width(viewport), prompt.width);
3542        assert_eq!(prompt.width, 60);
3543        assert_eq!(input_visible_rows(&state, prompt.width), 2);
3544        assert!(move_input_cursor_vertical(
3545            &mut state,
3546            ui_prompt_content_width(viewport) as usize,
3547            true,
3548        ));
3549    }
3550
3551    #[test]
3552    fn context_status_is_right_aligned_in_uniform_gray() {
3553        let state =
3554            UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
3555        let mut terminal =
3556            Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
3557
3558        terminal
3559            .draw(|frame| draw(frame, &state))
3560            .expect("draw statusline");
3561
3562        let buffer = terminal.backend().buffer();
3563        let status_area = ui_layout(&state, tui_viewport(Rect::new(0, 0, 80, 10))).5;
3564        let expected_context = "Context: 81/100 (81%) █████████░";
3565        let rendered = (status_area.x..status_area.x + status_area.width)
3566            .map(|x| buffer[(x, status_area.y)].symbol())
3567            .collect::<String>();
3568        assert!(rendered.ends_with(expected_context));
3569        assert_eq!(
3570            buffer[(status_area.x + status_area.width - 1, status_area.y)].symbol(),
3571            "░",
3572            "context is not pushed to the right edge"
3573        );
3574        assert!(rendered.starts_with("model · default"));
3575        for x in status_area.x..status_area.x + status_area.width {
3576            if buffer[(x, status_area.y)].symbol() != " " {
3577                assert_eq!(buffer[(x, status_area.y)].fg, CONSOLE_STATUS_COLOR);
3578            }
3579        }
3580    }
3581
3582    #[test]
3583    fn busy_model_name_and_indicator_share_the_animated_accent_gradient() {
3584        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3585        state.busy = true;
3586        let start = model_status_line_at(&state, "default", Duration::ZERO, 80);
3587        let middle = model_status_line_at(&state, "default", console_accent_cycle() / 2, 80);
3588        let start_accent = console_accent_at(Duration::ZERO);
3589        let middle_accent = console_accent_at(console_accent_cycle() / 2);
3590
3591        assert_eq!(start.spans[0].style.fg, Some(start_accent));
3592        assert_eq!(middle.spans[0].style.fg, Some(middle_accent));
3593        assert_eq!(start.spans[0].content, "model");
3594        assert_eq!(start.spans[1].content, " · default");
3595        assert_eq!(start.spans[2].content, " ");
3596        assert_eq!(start.spans[3].content, BUSY_INDICATOR_BLOCK.to_string());
3597        assert_eq!(start.spans[3].style.fg, Some(start_accent));
3598        assert_eq!(
3599            start.spans.last().unwrap().style.fg,
3600            Some(CONSOLE_STATUS_COLOR)
3601        );
3602    }
3603
3604    #[test]
3605    fn idle_model_status_has_no_busy_indicator() {
3606        let state = UiState::from_history(&[], "secret", "model", None, false);
3607        let start = model_status_line_at(&state, "default", Duration::ZERO, 80);
3608        let middle = model_status_line_at(&state, "default", console_accent_cycle() / 2, 80);
3609
3610        assert_eq!(start.spans[0].content, "model");
3611        assert_eq!(middle.spans[0].content, "model");
3612        assert_eq!(start.spans[0].style.fg, Some(CONSOLE_STATUS_COLOR));
3613        assert_eq!(middle.spans[0].style.fg, Some(CONSOLE_STATUS_COLOR));
3614    }
3615
3616    #[test]
3617    fn busy_indicator_is_a_five_cell_bounce_with_a_two_cell_tail() {
3618        let frames = (0..=BUSY_INDICATOR_PERIOD_TICKS)
3619            .map(|tick| busy_indicator_frame_at(BUSY_INDICATOR_TICK * tick as u32))
3620            .collect::<Vec<_>>();
3621
3622        assert_eq!(frames[0], "■      ");
3623        assert_eq!(frames[1], "■■     ");
3624        assert_eq!(frames[2], "■■■    ");
3625        assert_eq!(frames[4], "    ■■■");
3626        assert_eq!(frames[5], "   ■■■ ");
3627        assert_eq!(frames[7], " ■■■   ");
3628        assert_eq!(frames[8], frames[0]);
3629        assert!(frames
3630            .iter()
3631            .all(|frame| frame.chars().count() == BUSY_INDICATOR_WIDTH));
3632        assert_eq!(BUSY_INDICATOR_TRACK_LENGTH, 5);
3633        assert_eq!(BUSY_INDICATOR_TAIL_LENGTH, 2);
3634        assert_eq!(BUSY_INDICATOR_TICK, Duration::from_micros(62_500));
3635    }
3636
3637    fn color_distance_from_indicator_base(color: Color) -> u32 {
3638        let Color::Rgb(red, green, blue) = color else {
3639            return 0;
3640        };
3641        u32::from(red.abs_diff(BUSY_INDICATOR_FADE_BASE_RGB.0))
3642            + u32::from(green.abs_diff(BUSY_INDICATOR_FADE_BASE_RGB.1))
3643            + u32::from(blue.abs_diff(BUSY_INDICATOR_FADE_BASE_RGB.2))
3644    }
3645
3646    #[test]
3647    fn busy_indicator_tail_uses_same_block_with_progressively_fainter_colors() {
3648        let accent = Color::Rgb(180, 120, 240);
3649        let near = busy_indicator_color(accent, Some(1));
3650        let far = busy_indicator_color(accent, Some(2));
3651
3652        assert_eq!(BUSY_INDICATOR_BLOCK, '■');
3653        assert_ne!(near, accent);
3654        assert_ne!(far, near);
3655        assert!(color_distance_from_indicator_base(near) > color_distance_from_indicator_base(far));
3656    }
3657
3658    #[test]
3659    fn pulse_spinner_moves_each_bar_one_level_at_a_time() {
3660        let frames = (0..=240)
3661            .map(|tick| spinner_frame_at(PULSE_TICK * tick))
3662            .collect::<Vec<_>>();
3663        assert!(frames.iter().any(|frame| frame != &frames[0]));
3664        assert_eq!(PULSE_TICK, Duration::from_millis(50));
3665
3666        for pair in frames.windows(2) {
3667            let levels = pair
3668                .iter()
3669                .map(|frame| {
3670                    frame
3671                        .chars()
3672                        .map(|bar| {
3673                            PULSE_LEVELS
3674                                .iter()
3675                                .position(|level| *level == bar)
3676                                .expect("known pulse level")
3677                        })
3678                        .collect::<Vec<_>>()
3679                })
3680                .collect::<Vec<_>>();
3681            assert_eq!(levels[0].len(), 5);
3682            assert!(
3683                levels[0]
3684                    .iter()
3685                    .zip(&levels[1])
3686                    .all(|(before, after)| before.abs_diff(*after) <= 1),
3687                "pulse bars must not jump between adjacent ticks: {:?} -> {:?}",
3688                pair[0],
3689                pair[1]
3690            );
3691        }
3692    }
3693
3694    #[test]
3695    fn console_animation_clock_runs_during_entry_and_survives_active_status_changes() {
3696        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3697        state.set_status("working");
3698        let epoch = state.console_animation_epoch;
3699        assert_eq!(
3700            state.console_animation_elapsed_at(epoch + Duration::from_millis(200)),
3701            Duration::from_millis(200),
3702            "the console animation does not freeze during the activity ramp"
3703        );
3704
3705        state.set_status("compacting");
3706        assert_eq!(state.console_animation_epoch, epoch);
3707        state.set_status("working");
3708        assert_eq!(state.console_animation_epoch, epoch);
3709    }
3710
3711    #[test]
3712    fn console_accent_uses_a_fifteen_second_lavender_to_teal_round_trip() {
3713        assert_eq!(console_accent_cycle(), Duration::from_secs(15));
3714        assert_eq!(
3715            console_accent_at(Duration::ZERO),
3716            desaturate_console_accent(
3717                CONSOLE_ACCENT_LAVENDER.0,
3718                CONSOLE_ACCENT_LAVENDER.1,
3719                CONSOLE_ACCENT_LAVENDER.2,
3720            )
3721        );
3722        assert_eq!(
3723            console_accent_at(console_accent_cycle() / 2),
3724            desaturate_console_accent(
3725                CONSOLE_ACCENT_TEAL.0,
3726                CONSOLE_ACCENT_TEAL.1,
3727                CONSOLE_ACCENT_TEAL.2,
3728            )
3729        );
3730        assert_eq!(
3731            console_accent_at(console_accent_cycle()),
3732            console_accent_at(Duration::ZERO)
3733        );
3734        let midpoint = console_accent_at(console_accent_cycle() / 4);
3735        assert_ne!(
3736            midpoint,
3737            console_accent_at(Duration::ZERO),
3738            "the accent transitions continuously instead of holding at lavender"
3739        );
3740        assert_ne!(
3741            midpoint,
3742            console_accent_at(console_accent_cycle() / 2),
3743            "the accent transitions continuously instead of holding at teal"
3744        );
3745    }
3746
3747    #[test]
3748    fn model_status_accent_starts_lavender_with_fifteen_percent_desaturation() {
3749        assert_eq!(
3750            console_accent_at(Duration::ZERO),
3751            desaturate_console_accent(
3752                CONSOLE_ACCENT_LAVENDER.0,
3753                CONSOLE_ACCENT_LAVENDER.1,
3754                CONSOLE_ACCENT_LAVENDER.2,
3755            )
3756        );
3757    }
3758
3759    #[test]
3760    fn prompt_uses_two_cells_of_horizontal_console_padding() {
3761        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3762        state.input = "1234567890123456".to_owned();
3763        let area = Rect::new(0, 0, 20, 6);
3764        let mut terminal =
3765            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
3766                .expect("test terminal");
3767
3768        terminal
3769            .draw(|frame| draw(frame, &state))
3770            .expect("draw padded prompt");
3771
3772        let input_area = ui_layout(&state, tui_viewport(area)).4;
3773        let prompt = prompt_area(input_area, &state);
3774        assert_eq!(prompt.x, input_area.x + 2);
3775        assert_eq!(prompt.width, input_area.width.saturating_sub(4));
3776        assert_eq!(
3777            terminal.backend().buffer()[(input_area.x + 1, prompt.y)].symbol(),
3778            " ",
3779            "the two left padding cells remain blank"
3780        );
3781        assert_eq!(
3782            terminal.backend().buffer()[(input_area.x + input_area.width - 2, prompt.y)].symbol(),
3783            " ",
3784            "the two right padding cells remain blank"
3785        );
3786        terminal
3787            .backend_mut()
3788            .assert_cursor_position((input_area.x + 2, prompt.y));
3789    }
3790
3791    #[test]
3792    fn prompt_width_reduction_wraps_and_saturates_at_narrow_widths() {
3793        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3794        state.input = "12345".to_owned();
3795        state.cursor = state.input.chars().count();
3796        let input_area = Rect::new(3, 2, 6, 6);
3797        let prompt = prompt_area(input_area, &state);
3798
3799        assert_eq!(prompt.width, 2);
3800        assert_eq!(input_visible_rows(&state, prompt.width), 3);
3801        assert_eq!(bottom_content_heights(&state, input_area).prompt, 3);
3802        assert_eq!(
3803            cursor_row(&state.input, state.cursor, prompt.width as usize),
3804            2
3805        );
3806        state.cursor = 1;
3807        assert!(move_input_cursor_vertical(
3808            &mut state,
3809            prompt_content_width(input_area.width) as usize,
3810            true,
3811        ));
3812        assert_eq!(state.cursor, 3);
3813        assert_eq!(prompt_content_width(0), 0);
3814        assert_eq!(prompt_content_width(1), 0);
3815        assert_eq!(prompt_content_width(2), 0);
3816        assert_eq!(prompt_content_width(3), 0);
3817        assert_eq!(prompt_content_width(4), 0);
3818        assert_eq!(prompt_content_width(5), 1);
3819    }
3820
3821    #[test]
3822    fn ready_submission_bypasses_queue_and_is_not_added_twice_when_started() {
3823        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3824
3825        state.submit_user("send now");
3826
3827        assert!(state.queued_messages.is_empty());
3828        assert_eq!(state.transcript.len(), 1);
3829        assert!(matches!(
3830            &state.transcript[0],
3831            TranscriptItem::User { text, .. } if text == "send now"
3832        ));
3833
3834        // The worker's Started notification still arrives asynchronously, but
3835        // must not promote an already visible direct submission a second time.
3836        state.start_queued_user("send now");
3837        assert_eq!(state.transcript.len(), 1);
3838    }
3839
3840    #[test]
3841    fn busy_submission_remains_queued_until_its_turn_starts() {
3842        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3843        state.busy = true;
3844
3845        state.submit_user("send later");
3846
3847        assert_eq!(state.queued_messages, ["send later"]);
3848        assert!(state.transcript.is_empty());
3849
3850        state.start_queued_user("send later");
3851        assert!(state.queued_messages.is_empty());
3852        assert!(matches!(
3853            &state.transcript[..],
3854            [TranscriptItem::User { text, .. }] if text == "send later"
3855        ));
3856    }
3857
3858    #[test]
3859    fn skill_picker_stays_above_a_visible_message_queue() {
3860        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3861            .with_skill_names(vec!["release-notes".to_owned()]);
3862        state.queue_user("next task");
3863        state.input = "/".to_owned();
3864        state.input_changed();
3865
3866        let area = Rect::new(0, 0, 80, 12);
3867        let (_, picker_area, _, queue_area, input_area, _) = ui_layout(&state, tui_viewport(area));
3868        let picker_area = picker_area.expect("skill picker area");
3869        let queue_area = queue_area.expect("message queue area");
3870        assert_eq!(picker_area.y + picker_area.height, input_area.y);
3871        assert_eq!(queue_area.y, input_area.y + 1);
3872        assert_eq!(queue_area.x, input_area.x + 2);
3873    }
3874
3875    #[test]
3876    fn fresh_sessions_show_the_versioned_gradient_welcome_message() {
3877        let state = UiState::from_history(&[], "secret", "model", None, false);
3878        assert!(state.welcome_visible);
3879
3880        let line = welcome_line();
3881        assert_eq!(line.to_string(), WELCOME_MESSAGE);
3882        assert_eq!(WELCOME_VERSION, concat!("v", env!("CARGO_PKG_VERSION")));
3883        assert_eq!(
3884            line.spans.first().and_then(|span| span.style.fg),
3885            Some(Color::Rgb(
3886                WELCOME_START_COLOR.0,
3887                WELCOME_START_COLOR.1,
3888                WELCOME_START_COLOR.2,
3889            ))
3890        );
3891        assert_eq!(
3892            line.spans.last().and_then(|span| span.style.fg),
3893            Some(Color::Rgb(
3894                WELCOME_END_COLOR.0,
3895                WELCOME_END_COLOR.1,
3896                WELCOME_END_COLOR.2,
3897            ))
3898        );
3899    }
3900
3901    #[test]
3902    fn welcome_image_brightness_is_reduced_without_changing_alpha() {
3903        let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
3904            1,
3905            1,
3906            image::Rgba([200, 100, 0, 37]),
3907        ));
3908        let dimmed = dim_welcome_image(image).to_rgba8();
3909        assert_eq!(dimmed.get_pixel(0, 0).0, [170, 85, 0, 37]);
3910    }
3911
3912    #[test]
3913    fn spacious_welcome_uses_the_embedded_png() {
3914        let image = welcome_image(GREETING_IMAGE_SIZE);
3915        assert_eq!(image.size(), GREETING_IMAGE_SIZE);
3916        let layout = welcome_image_layout(Rect::new(0, 0, 100, 40), 6).expect("image fits");
3917        assert_eq!(layout.image_size, GREETING_IMAGE_SIZE);
3918        assert_eq!(layout.image_area, Rect::new(10, 6, 80, 20));
3919        assert_eq!(layout.intro_area.y, layout.image_area.y + 21);
3920    }
3921
3922    #[test]
3923    fn cramped_welcome_falls_back_to_the_text_greeting() {
3924        assert_eq!(welcome_image_layout(Rect::new(0, 0, 80, 16), 6), None);
3925        assert_eq!(welcome_image_layout(Rect::new(0, 0, 39, 40), 6), None);
3926        let scaled = welcome_image_layout(Rect::new(0, 0, 60, 25), 6).expect("scaled image fits");
3927        assert_eq!(scaled.image_size, Size::new(60, 15));
3928
3929        let state = UiState::from_history(&[], "secret", "model", None, false);
3930        let area = Rect::new(0, 0, 80, 12);
3931        let mut terminal =
3932            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
3933                .expect("test terminal");
3934        terminal
3935            .draw(|frame| draw(frame, &state))
3936            .expect("draw text fallback");
3937        let chat_area = ui_layout(&state, tui_viewport(area)).0;
3938        let rows = (chat_area.y..chat_area.y + chat_area.height)
3939            .map(|y| {
3940                (chat_area.x..chat_area.x + chat_area.width)
3941                    .map(|x| terminal.backend().buffer()[(x, y)].symbol())
3942                    .collect::<String>()
3943            })
3944            .collect::<Vec<_>>();
3945        assert!(rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
3946        assert!(!rows
3947            .iter()
3948            .any(|row| row.contains('▀') || row.contains('▄')));
3949    }
3950
3951    #[test]
3952    fn logo_text_renders_by_default_and_greeting_image_replaces_it_when_enabled() {
3953        let logo = logo_lines();
3954        let logo_row_count = LOGO_TEXT.lines().count();
3955        assert_eq!(logo.len(), logo_row_count);
3956        // Every non-space character should carry a gradient color.
3957        assert!(logo.iter().flat_map(|line| &line.spans).any(|span| {
3958            span.content.chars().any(|ch| ch != ' ')
3959                && matches!(span.style.fg, Some(Color::Rgb(..)))
3960        }));
3961
3962        let state = UiState::from_history(&[], "secret", "model", None, false);
3963        let area = Rect::new(0, 0, 100, 50);
3964        let mut terminal =
3965            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
3966                .expect("test terminal");
3967        let chat_area = ui_layout(&state, tui_viewport(area)).0;
3968        let intro_lines = welcome_lines(&state.attached_agents);
3969        let greeting_layout =
3970            welcome_image_layout(chat_area, intro_lines.len() as u16).expect("greeting fits");
3971
3972        // Without the flag the logo text renders (no halfblock image cells).
3973        std::env::remove_var("LUCY_GREETING_IMAGE");
3974        terminal
3975            .draw(|frame| draw(frame, &state))
3976            .expect("draw logo text");
3977        let buffer = terminal.backend().buffer();
3978        let rows = (chat_area.y..chat_area.y + chat_area.height)
3979            .map(|y| {
3980                (chat_area.x..chat_area.x + chat_area.width)
3981                    .map(|x| buffer[(x, y)].symbol())
3982                    .collect::<String>()
3983            })
3984            .collect::<Vec<_>>();
3985        assert!(rows
3986            .iter()
3987            .any(|row| row.contains(':') || row.contains('-') || row.contains('=')));
3988        assert!(!rows
3989            .iter()
3990            .any(|row| row.contains('▀') || row.contains('▄')));
3991        assert!(rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
3992
3993        // With the flag set the greeting image renders instead of the logo.
3994        std::env::set_var("LUCY_GREETING_IMAGE", "true");
3995        terminal
3996            .draw(|frame| draw(frame, &state))
3997            .expect("draw greeting");
3998        let buffer = terminal.backend().buffer();
3999        assert_eq!(greeting_layout.image_size, GREETING_IMAGE_SIZE);
4000        assert!(matches!(
4001            buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].symbol(),
4002            "▀" | "▄"
4003        ));
4004        assert!(matches!(
4005            buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].fg,
4006            Color::Rgb(..)
4007        ));
4008        assert!(matches!(
4009            buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].bg,
4010            Color::Rgb(..)
4011        ));
4012        let intro_rows = (greeting_layout.intro_area.y
4013            ..greeting_layout.intro_area.y + greeting_layout.intro_area.height)
4014            .map(|y| {
4015                (greeting_layout.intro_area.x
4016                    ..greeting_layout.intro_area.x + greeting_layout.intro_area.width)
4017                    .map(|x| buffer[(x, y)].symbol())
4018                    .collect::<String>()
4019            })
4020            .collect::<Vec<_>>();
4021        assert!(intro_rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
4022
4023        std::env::remove_var("LUCY_GREETING_IMAGE");
4024    }
4025
4026    #[test]
4027    fn welcome_renders_version_below_title_with_a_blank_line_before_tagline() {
4028        let state = UiState::from_history(&[], "secret", "model", None, false);
4029        let area = Rect::new(0, 0, 80, 12);
4030        let mut terminal =
4031            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4032                .expect("test terminal");
4033        terminal
4034            .draw(|frame| draw(frame, &state))
4035            .expect("draw welcome screen");
4036
4037        let chat_area = ui_layout(&state, tui_viewport(area)).0;
4038        let buffer = terminal.backend().buffer();
4039        let rows = (chat_area.y..chat_area.y + chat_area.height)
4040            .map(|y| {
4041                (chat_area.x..chat_area.x + chat_area.width)
4042                    .map(|x| buffer[(x, y)].symbol())
4043                    .collect::<String>()
4044            })
4045            .collect::<Vec<_>>();
4046        let title_row = rows
4047            .iter()
4048            .position(|row| row.contains(WELCOME_MESSAGE))
4049            .expect("rendered welcome title");
4050        let version_rows = rows
4051            .iter()
4052            .enumerate()
4053            .filter_map(|(row, rendered)| rendered.contains(WELCOME_VERSION).then_some(row))
4054            .collect::<Vec<_>>();
4055
4056        assert_eq!(version_rows, vec![title_row + 1]);
4057        assert!(rows[title_row + 2].trim().is_empty());
4058        assert!(rows[title_row + 3].contains(WELCOME_TAGLINE));
4059
4060        let version_width = WELCOME_VERSION.chars().count() as u16;
4061        let version_x = chat_area.x
4062            + rows[version_rows[0]]
4063                .find(WELCOME_VERSION)
4064                .expect("rendered welcome version") as u16;
4065        let version_y = chat_area.y + title_row as u16 + 1;
4066        assert!((version_x..version_x + version_width)
4067            .all(|x| buffer[(x, version_y)].fg == Color::DarkGray));
4068    }
4069
4070    #[test]
4071    fn welcome_shows_the_tagline_and_attached_agents_paths() {
4072        let state = UiState::from_history(&[], "secret", "model", None, false)
4073            .with_attached_agents(vec![
4074                "/workspace/AGENTS.md".to_owned(),
4075                "/workspace/app/AGENTS.md".to_owned(),
4076            ]);
4077        let lines = welcome_lines(&state.attached_agents);
4078
4079        assert_eq!(lines[1].to_string(), WELCOME_VERSION);
4080        assert_eq!(lines[1].style.fg, Some(Color::DarkGray));
4081        assert!(lines[2].to_string().is_empty());
4082        assert_eq!(lines[3].to_string(), WELCOME_TAGLINE);
4083        assert_eq!(lines[3].style.fg, Some(Color::DarkGray));
4084        assert!(lines[4].to_string().is_empty());
4085        assert_eq!(lines[5].to_string(), "Attached AGENTS.md:");
4086        assert_eq!(lines[6].to_string(), "• /workspace/AGENTS.md");
4087        assert_eq!(lines[7].to_string(), "• /workspace/app/AGENTS.md");
4088        assert!(lines[5..]
4089            .iter()
4090            .all(|line| line.style.fg == Some(Color::DarkGray)));
4091    }
4092
4093    #[test]
4094    fn welcome_reports_when_no_agents_file_is_attached() {
4095        let lines = welcome_lines(&[]);
4096        assert_eq!(
4097            lines.last().expect("empty context line").to_string(),
4098            "Attached AGENTS.md: none"
4099        );
4100    }
4101
4102    #[test]
4103    fn resumed_sessions_do_not_show_the_welcome_message() {
4104        let state = UiState::from_history(&[], "secret", "model", None, true);
4105        assert!(!state.welcome_visible);
4106    }
4107
4108    #[test]
4109    fn history_replay_keeps_interruption_after_messages() {
4110        let history = vec![
4111            SessionHistoryRecord::Message {
4112                timestamp: 1,
4113                message: ChatMessage::user("hello".to_owned()),
4114            },
4115            SessionHistoryRecord::Interruption {
4116                timestamp: 2,
4117                reason: "user_cancelled".to_owned(),
4118                phase: "provider_stream".to_owned(),
4119                assistant_text: "partial".to_owned(),
4120                tool_calls: Vec::new(),
4121                tool_results: Vec::new(),
4122            },
4123        ];
4124        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
4125        assert!(matches!(state.transcript[0], TranscriptItem::User { .. }));
4126        assert!(matches!(state.transcript[1], TranscriptItem::Assistant(_)));
4127        assert!(matches!(state.transcript[2], TranscriptItem::Info(_)));
4128        let text = transcript_lines(&state, 80)
4129            .iter()
4130            .map(ToString::to_string)
4131            .collect::<Vec<_>>()
4132            .join("\n");
4133        assert!(!text.contains("choices"));
4134    }
4135
4136    #[test]
4137    fn history_replay_does_not_render_assistant_reasoning_details() {
4138        let mut message = ChatMessage::assistant("visible answer".to_owned(), Vec::new());
4139        message.reasoning_details = Some(vec![serde_json::json!({
4140            "type": "reasoning.text",
4141            "text": "private reasoning"
4142        })]);
4143        let history = [SessionHistoryRecord::Message {
4144            timestamp: 1,
4145            message,
4146        }];
4147        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
4148        let text = transcript_lines(&state, 80)
4149            .iter()
4150            .map(ToString::to_string)
4151            .collect::<Vec<_>>()
4152            .join("\n");
4153        assert!(text.contains("visible answer"));
4154        assert!(!text.contains("private reasoning"));
4155        assert!(!text.contains("reasoning_details"));
4156    }
4157
4158    #[test]
4159    fn history_replay_preserves_repeated_records() {
4160        let history = vec![
4161            SessionHistoryRecord::Message {
4162                timestamp: 1,
4163                message: ChatMessage::assistant("same".to_owned(), Vec::new()),
4164            },
4165            SessionHistoryRecord::Interruption {
4166                timestamp: 2,
4167                reason: "user_cancelled".to_owned(),
4168                phase: "provider_stream".to_owned(),
4169                assistant_text: "same".to_owned(),
4170                tool_calls: Vec::new(),
4171                tool_results: Vec::new(),
4172            },
4173        ];
4174        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
4175        assert_eq!(
4176            state
4177                .transcript
4178                .iter()
4179                .filter(|item| matches!(item, TranscriptItem::Assistant(text) if text == "same"))
4180                .count(),
4181            2
4182        );
4183    }
4184
4185    #[test]
4186    fn user_messages_have_a_single_block_rule_with_inner_and_vertical_padding() {
4187        let history = [SessionHistoryRecord::Message {
4188            timestamp: 1,
4189            message: ChatMessage::user("hello\nworld".to_owned()),
4190        }];
4191        let state = UiState::from_history(&history, "provider-secret", "model", None, false);
4192        let lines = transcript_lines(&state, 12);
4193
4194        assert_eq!(UnicodeWidthStr::width(USER_BORDER_GLYPH), 1);
4195        assert_eq!(lines.len(), 4);
4196        assert_eq!(lines[0].to_string(), "▌");
4197        assert_eq!(lines[1].to_string(), "▌ hello");
4198        assert_eq!(lines[2].to_string(), "▌ world");
4199        assert_eq!(lines[3].to_string(), "▌");
4200        for line in &lines {
4201            assert_eq!(line.spans[0].content, USER_BORDER_GLYPH);
4202            assert_eq!(line.spans[0].style.fg, Some(USER_BORDER_COLOR));
4203            assert!(!line.to_string().contains(['┌', '┐', '└', '┘', '│']));
4204        }
4205        for line in &lines[1..3] {
4206            assert_eq!(line.spans[1].content, " ");
4207            assert_eq!(line.spans[1].style.fg, Some(Color::White));
4208            assert_eq!(line.spans[2].style.fg, Some(Color::White));
4209        }
4210    }
4211
4212    #[test]
4213    fn attached_skill_highlights_its_trigger_in_the_user_message_without_a_notice_line() {
4214        let mut state = UiState::from_history(&[], "secret", "model", None, false)
4215            .with_skill_names(vec!["release-notes".to_owned()]);
4216        state.add_user("/release-notes v1.2.0", "secret");
4217        state.mark_latest_user_skill_attached();
4218
4219        let lines = transcript_lines(&state, 40);
4220        assert_eq!(lines.len(), 3);
4221        assert_eq!(lines[1].spans[1].content, " ");
4222        let cyan_text = lines[1]
4223            .spans
4224            .iter()
4225            .filter(|span| span.style.fg == Some(SKILL_TRIGGER_COLOR))
4226            .map(|span| span.content.as_ref())
4227            .collect::<String>();
4228        assert_eq!(cyan_text, "/release-notes");
4229        assert!(!lines
4230            .iter()
4231            .any(|line| line.to_string().contains("instruction attached")));
4232    }
4233
4234    #[test]
4235    fn transcript_rendering_redacts_history_content() {
4236        let history = [SessionHistoryRecord::Message {
4237            timestamp: 1,
4238            message: ChatMessage::assistant("provider-secret".to_owned(), Vec::new()),
4239        }];
4240        let state = UiState::from_history(&history, "provider-secret", "model", None, false);
4241        let text = transcript_lines(&state, 80)
4242            .iter()
4243            .map(ToString::to_string)
4244            .collect::<Vec<_>>()
4245            .join("\n");
4246        assert!(!text.contains("provider-secret"));
4247    }
4248
4249    #[test]
4250    fn mouse_wheel_disables_following_and_changes_scroll_offset() {
4251        let history = [SessionHistoryRecord::Message {
4252            timestamp: 1,
4253            message: ChatMessage::user("hello".to_owned()),
4254        }];
4255        let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
4256        handle_mouse_event(&mut state, MouseEventKind::ScrollUp, 10);
4257        assert!(!state.auto_scroll);
4258        assert_eq!(state.scroll, 7);
4259        handle_mouse_event(&mut state, MouseEventKind::ScrollDown, 10);
4260        assert!(
4261            state.auto_scroll,
4262            "reaching the bottom resumes transcript following"
4263        );
4264        assert_eq!(state.scroll, 0);
4265        scroll_up(&mut state, 10);
4266        assert!(!state.auto_scroll);
4267        assert_eq!(state.scroll, 7);
4268    }
4269
4270    #[test]
4271    fn transcript_scrollbar_appears_only_when_the_stream_is_scrolled() {
4272        let mut state = UiState::from_history(&[], "provider-secret", "model", None, false);
4273        state.welcome_visible = false;
4274        let area = Rect::new(0, 0, 80, 14);
4275        let chat_area = ui_layout(&state, tui_viewport(area)).0;
4276        state.transcript = (0..40)
4277            .map(|_| TranscriptItem::Info(format!("{}#", "x".repeat(chat_area.width as usize - 1))))
4278            .collect();
4279        state.auto_scroll = false;
4280        state.scroll = 3;
4281
4282        let mut terminal =
4283            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4284                .expect("test terminal");
4285        terminal
4286            .draw(|frame| draw(frame, &state))
4287            .expect("draw scrolled transcript");
4288
4289        let message_edge_x = chat_area.x + chat_area.width - 1;
4290        let scrollbar_x = chat_area.x + chat_area.width;
4291        let buffer = terminal.backend().buffer();
4292        assert!(
4293            (chat_area.y..chat_area.y + chat_area.height)
4294                .any(|y| buffer[(message_edge_x, y)].symbol() == "#"),
4295            "the scrollbar must not overwrite transcript content at the right edge"
4296        );
4297        assert!(
4298            (chat_area.y..chat_area.y + chat_area.height).any(|y| {
4299                buffer[(scrollbar_x, y)].symbol() == TRANSCRIPT_SCROLLBAR_THUMB
4300                    && buffer[(scrollbar_x, y)].fg == CONSOLE_STATUS_COLOR
4301            }),
4302            "a scrolled transcript should show a scrollbar thumb"
4303        );
4304        assert!(
4305            (chat_area.y..chat_area.y + chat_area.height)
4306                .any(|y| { buffer[(scrollbar_x, y)].symbol() == TRANSCRIPT_SCROLLBAR_TRACK }),
4307            "a scrolled transcript should show a scrollbar track"
4308        );
4309
4310        state.auto_scroll = true;
4311        state.scroll = 0;
4312        let mut terminal =
4313            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4314                .expect("test terminal");
4315        terminal
4316            .draw(|frame| draw(frame, &state))
4317            .expect("draw following transcript");
4318        let buffer = terminal.backend().buffer();
4319        assert!((chat_area.y..chat_area.y + chat_area.height)
4320            .all(|y| buffer[(scrollbar_x, y)].symbol() != TRANSCRIPT_SCROLLBAR_THUMB));
4321    }
4322
4323    #[test]
4324    fn tool_result_sweep_is_now_twice_as_fast() {
4325        assert_eq!(TOOL_RESULT_SWEEP_DURATION, Duration::from_millis(600));
4326    }
4327
4328    #[test]
4329    fn wrap_text_breaks_long_lines_and_preserves_empty_lines() {
4330        let rows = wrap_text("12345\n\nabc", 3);
4331        assert_eq!(rows, vec!["123", "45", "", "abc"]);
4332    }
4333
4334    #[test]
4335    fn wrap_line_never_returns_an_empty_vec() {
4336        assert_eq!(wrap_line("", 5), vec![""]);
4337        assert_eq!(wrap_line("abc", 5), vec!["abc"]);
4338    }
4339
4340    #[test]
4341    fn multiline_input_arrows_move_cursor_between_explicit_and_wrapped_rows() {
4342        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4343        state.input = "ab\ncd\nef".to_owned();
4344        state.cursor = 1;
4345
4346        assert!(move_input_cursor_vertical(&mut state, 10, true));
4347        assert_eq!(
4348            state.cursor, 4,
4349            "preserve the column on the next explicit row"
4350        );
4351        assert!(move_input_cursor_vertical(&mut state, 10, true));
4352        assert_eq!(state.cursor, 7);
4353        assert!(!move_input_cursor_vertical(&mut state, 10, true));
4354        assert!(move_input_cursor_vertical(&mut state, 10, false));
4355        assert_eq!(state.cursor, 4);
4356
4357        state.input = "abcdef".to_owned();
4358        state.cursor = 1;
4359        assert!(move_input_cursor_vertical(&mut state, 3, true));
4360        assert_eq!(state.cursor, 4, "wrapped rows use the same visual column");
4361        assert!(move_input_cursor_vertical(&mut state, 3, false));
4362        assert_eq!(state.cursor, 1);
4363    }
4364
4365    #[test]
4366    fn completion_event_does_not_release_input_before_worker_finishes() {
4367        let history = [SessionHistoryRecord::Message {
4368            timestamp: 1,
4369            message: ChatMessage::user("hello".to_owned()),
4370        }];
4371        let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
4372        state.busy = true;
4373        state.active_cancel = Some(CancellationToken::new());
4374        state.apply_event(ProtocolEvent::TurnEnd);
4375        assert!(state.busy);
4376        assert!(state.active_cancel.is_some());
4377        assert_eq!(state.status, "finalizing");
4378    }
4379
4380    #[test]
4381    fn transcript_inserts_a_blank_line_between_items() {
4382        let history = [
4383            SessionHistoryRecord::Message {
4384                timestamp: 1,
4385                message: ChatMessage::user("hi".to_owned()),
4386            },
4387            SessionHistoryRecord::Message {
4388                timestamp: 2,
4389                message: ChatMessage::assistant("hello".to_owned(), Vec::new()),
4390            },
4391        ];
4392        let state = UiState::from_history(&history, "secret", "model", None, false);
4393        let lines = transcript_lines(&state, 80);
4394        assert_eq!(lines.len(), 5);
4395        assert_eq!(lines[0].to_string(), "▌");
4396        assert_eq!(lines[1].to_string(), "▌ hi");
4397        assert_eq!(lines[2].to_string(), "▌");
4398        assert_eq!(lines[3].to_string(), "");
4399        assert_eq!(lines[4].to_string(), "hello");
4400    }
4401
4402    #[test]
4403    fn cmd_call_renders_as_a_compact_status_line_without_raw_json() {
4404        let history = vec![
4405            SessionHistoryRecord::Message {
4406                timestamp: 1,
4407                message: ChatMessage::assistant(
4408                    String::new(),
4409                    vec![crate::model::ChatToolCall {
4410                        id: "call-1".to_owned(),
4411                        name: "cmd".to_owned(),
4412                        arguments: r#"{"command":"pwd"}"#.to_owned(),
4413                    }],
4414                ),
4415            },
4416            SessionHistoryRecord::Message {
4417                timestamp: 2,
4418                message: ChatMessage::tool(
4419                    "call-1".to_owned(),
4420                    "cmd".to_owned(),
4421                    serde_json::json!({"exit_code": 0, "stdout": "secret output"}).to_string(),
4422                ),
4423            },
4424        ];
4425        let state = UiState::from_history(&history, "secret", "model", None, false);
4426        let text = transcript_lines(&state, 80)[0].to_string();
4427
4428        assert_eq!(text, "✓ cmd  $ pwd");
4429        assert!(!text.contains("secret output"));
4430        assert!(!text.contains("{\"command\":\"pwd\"}"));
4431    }
4432
4433    #[test]
4434    fn pending_cmd_calls_use_a_compact_running_status() {
4435        let history = [SessionHistoryRecord::Message {
4436            timestamp: 1,
4437            message: ChatMessage::assistant(
4438                String::new(),
4439                vec![crate::model::ChatToolCall {
4440                    id: "call-1".to_owned(),
4441                    name: "cmd".to_owned(),
4442                    arguments: r#"{"command":"pwd"}"#.to_owned(),
4443                }],
4444            ),
4445        }];
4446        let state = UiState::from_history(&history, "secret", "model", None, false);
4447        let line = &transcript_lines(&state, 80)[0];
4448
4449        let text = line.to_string();
4450        let prefix = "· cmd  $ pwd  ";
4451        assert!(text.starts_with(prefix));
4452        assert!(!text.contains("→ running"));
4453        let frame = &text[prefix.len()..];
4454        assert_eq!(frame.chars().count(), 1);
4455        assert!(frame
4456            .chars()
4457            .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
4458        assert!(line
4459            .spans
4460            .iter()
4461            .all(|span| span.style.fg == Some(PENDING_TOOL_COLOR)));
4462    }
4463
4464    #[test]
4465    fn running_tool_indicators_use_a_traditional_spinner_with_their_own_clock() {
4466        assert_eq!(tool_spinner_frame_at(Duration::ZERO), '|');
4467        assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION), '/');
4468        assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 2), '-');
4469        assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 3), '\\');
4470
4471        let state = UiState::from_history(&[], "secret", "model", None, false);
4472        let spinner = running_tool_status(&state);
4473        assert_eq!(spinner.chars().count(), 1);
4474        assert!(spinner
4475            .chars()
4476            .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
4477    }
4478
4479    #[test]
4480    fn successful_cmd_cross_fades_to_teal_from_first_character_to_last() {
4481        let started_at = Instant::now();
4482        let character_count = 12;
4483        let early = started_at + TOOL_RESULT_SWEEP_DURATION / 4;
4484        let halfway = started_at + TOOL_RESULT_SWEEP_DURATION / 2;
4485        let late = started_at + TOOL_RESULT_SWEEP_DURATION * 3 / 4;
4486
4487        assert_eq!(
4488            cmd_result_color_at(
4489                started_at,
4490                started_at,
4491                0,
4492                character_count,
4493                TOOL_SUCCESS_COLOR,
4494            ),
4495            PENDING_TOOL_COLOR,
4496        );
4497        assert_eq!(TOOL_SUCCESS_COLOR, Color::Rgb(0, 210, 175));
4498
4499        let early_first =
4500            cmd_result_color_at(started_at, early, 0, character_count, TOOL_SUCCESS_COLOR);
4501        assert_ne!(early_first, PENDING_TOOL_COLOR);
4502        assert_ne!(early_first, TOOL_SUCCESS_COLOR);
4503        assert_eq!(
4504            cmd_result_color_at(started_at, early, 5, character_count, TOOL_SUCCESS_COLOR),
4505            PENDING_TOOL_COLOR,
4506            "later characters wait while the first character cross-fades"
4507        );
4508
4509        assert_eq!(
4510            cmd_result_color_at(started_at, halfway, 0, character_count, TOOL_SUCCESS_COLOR),
4511            TOOL_SUCCESS_COLOR,
4512        );
4513        let halfway_middle =
4514            cmd_result_color_at(started_at, halfway, 5, character_count, TOOL_SUCCESS_COLOR);
4515        assert_ne!(halfway_middle, PENDING_TOOL_COLOR);
4516        assert_ne!(halfway_middle, TOOL_SUCCESS_COLOR);
4517        assert_eq!(
4518            cmd_result_color_at(
4519                started_at,
4520                halfway,
4521                character_count - 1,
4522                character_count,
4523                TOOL_SUCCESS_COLOR,
4524            ),
4525            PENDING_TOOL_COLOR,
4526        );
4527
4528        let late_last = cmd_result_color_at(
4529            started_at,
4530            late,
4531            character_count - 1,
4532            character_count,
4533            TOOL_SUCCESS_COLOR,
4534        );
4535        assert_ne!(late_last, PENDING_TOOL_COLOR);
4536        assert_ne!(late_last, TOOL_SUCCESS_COLOR);
4537        assert_eq!(
4538            cmd_result_color_at(
4539                started_at,
4540                started_at + TOOL_RESULT_SWEEP_DURATION,
4541                character_count - 1,
4542                character_count,
4543                TOOL_SUCCESS_COLOR,
4544            ),
4545            TOOL_SUCCESS_COLOR,
4546            "the completed sweep keeps the exact teal used during the fade"
4547        );
4548    }
4549
4550    #[test]
4551    fn cmd_result_cross_fade_has_no_abrupt_color_change_between_render_ticks() {
4552        let started_at = Instant::now();
4553        let character_count = 12;
4554        let render_ticks = TOOL_RESULT_SWEEP_DURATION.as_millis() / EVENT_POLL.as_millis();
4555
4556        for target in [TOOL_SUCCESS_COLOR, TOOL_FAILURE_COLOR, TOOL_WARNING_COLOR] {
4557            for character_index in 0..character_count {
4558                let frames = (0..=render_ticks)
4559                    .map(|tick| {
4560                        cmd_result_color_at(
4561                            started_at,
4562                            started_at + EVENT_POLL * tick as u32,
4563                            character_index,
4564                            character_count,
4565                            target,
4566                        )
4567                    })
4568                    .collect::<Vec<_>>();
4569
4570                assert!(frames
4571                    .iter()
4572                    .any(|color| { *color != PENDING_TOOL_COLOR && *color != target }));
4573                assert!(frames.windows(2).all(|pair| {
4574                    let (before_red, before_green, before_blue) = tool_result_color_rgb(pair[0]);
4575                    let (after_red, after_green, after_blue) = tool_result_color_rgb(pair[1]);
4576                    before_red.abs_diff(after_red) <= 90
4577                        && before_green.abs_diff(after_green) <= 90
4578                        && before_blue.abs_diff(after_blue) <= 90
4579                }));
4580                assert_eq!(frames.last(), Some(&target));
4581            }
4582        }
4583    }
4584
4585    #[test]
4586    fn only_live_cmd_results_start_a_result_sweep() {
4587        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4588        let succeeded = serde_json::json!({"exit_code": 0});
4589
4590        state.add_tool_result("historic", "cmd", succeeded.clone());
4591        state.add_live_tool_result("success", "cmd", succeeded);
4592        state.add_live_tool_result("failed", "cmd", serde_json::json!({"exit_code": 1}));
4593
4594        assert!(!state.cmd_result_started_at.contains_key("historic"));
4595        assert!(state.cmd_result_started_at.contains_key("success"));
4596        assert!(state.cmd_result_started_at.contains_key("failed"));
4597    }
4598
4599    #[test]
4600    fn failed_cmd_cross_fades_to_the_same_rgb_red_without_a_final_jump() {
4601        let started_at = Instant::now();
4602        let character_count = 12;
4603        let halfway = started_at + TOOL_RESULT_SWEEP_DURATION / 2;
4604
4605        assert_eq!(
4606            cmd_result_color_at(
4607                started_at,
4608                started_at,
4609                0,
4610                character_count,
4611                TOOL_FAILURE_COLOR,
4612            ),
4613            PENDING_TOOL_COLOR,
4614        );
4615        assert_eq!(
4616            cmd_result_color_at(started_at, halfway, 0, character_count, TOOL_FAILURE_COLOR),
4617            TOOL_FAILURE_COLOR,
4618        );
4619        let intermediate =
4620            cmd_result_color_at(started_at, halfway, 5, character_count, TOOL_FAILURE_COLOR);
4621        assert_ne!(intermediate, PENDING_TOOL_COLOR);
4622        assert_ne!(intermediate, TOOL_FAILURE_COLOR);
4623        assert_eq!(
4624            cmd_result_color_at(
4625                started_at,
4626                halfway,
4627                character_count - 1,
4628                character_count,
4629                TOOL_FAILURE_COLOR,
4630            ),
4631            PENDING_TOOL_COLOR,
4632        );
4633        assert_eq!(
4634            cmd_result_color_at(
4635                started_at,
4636                started_at + TOOL_RESULT_SWEEP_DURATION,
4637                character_count - 1,
4638                character_count,
4639                TOOL_FAILURE_COLOR,
4640            ),
4641            TOOL_FAILURE_COLOR,
4642            "the completed failure sweep keeps the exact RGB red used during the fade"
4643        );
4644    }
4645
4646    #[test]
4647    fn live_failed_cmd_sweep_keeps_the_final_status_text() {
4648        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4649        let result = serde_json::json!({"exit_code": 1});
4650        state.add_live_tool_result("failed", "cmd", result.clone());
4651
4652        let segments = cmd_tool_segments("failed", r#"{"command":"bad"}"#, Some(&result), &state);
4653        let text = segments
4654            .iter()
4655            .map(|(text, _)| text.as_str())
4656            .collect::<String>();
4657
4658        assert_eq!(text, "× cmd  $ bad  → exit 1");
4659    }
4660
4661    #[test]
4662    fn cmd_result_target_colors_follow_the_final_status() {
4663        assert_eq!(
4664            cmd_result_target_color(&serde_json::json!({"exit_code": 0})),
4665            TOOL_SUCCESS_COLOR
4666        );
4667        assert_eq!(
4668            cmd_result_target_color(&serde_json::json!({"exit_code": 1})),
4669            TOOL_FAILURE_COLOR
4670        );
4671        assert_eq!(
4672            cmd_result_target_color(&serde_json::json!({"timed_out": true})),
4673            TOOL_WARNING_COLOR
4674        );
4675    }
4676
4677    #[test]
4678    fn background_cmd_registration_shows_its_running_id() {
4679        let (icon, status, _) = cmd_result_status(&serde_json::json!({
4680            "background_id": "background-1",
4681            "status": "running"
4682        }));
4683        assert_eq!(icon, '↗');
4684        assert_eq!(status, "background-1");
4685    }
4686
4687    #[test]
4688    fn cmd_status_distinguishes_nonzero_exit_timeout_and_cancellation() {
4689        let cases = [
4690            (
4691                serde_json::json!({"exit_code": 127}),
4692                "× cmd  $ bad  → exit 127",
4693            ),
4694            (
4695                serde_json::json!({"timed_out": true, "exit_code": null}),
4696                "! cmd  $ slow  → timeout",
4697            ),
4698            (
4699                serde_json::json!({"canceled": true}),
4700                "! cmd  $ stop  → canceled",
4701            ),
4702        ];
4703        for (result, expected) in cases {
4704            let history = vec![
4705                SessionHistoryRecord::Message {
4706                    timestamp: 1,
4707                    message: ChatMessage::assistant(
4708                        String::new(),
4709                        vec![crate::model::ChatToolCall {
4710                            id: "call-1".to_owned(),
4711                            name: "cmd".to_owned(),
4712                            arguments: serde_json::json!({"command": expected.split("$ ").nth(1).unwrap().split("  ").next().unwrap()}).to_string(),
4713                        }],
4714                    ),
4715                },
4716                SessionHistoryRecord::Message {
4717                    timestamp: 2,
4718                    message: ChatMessage::tool(
4719                        "call-1".to_owned(),
4720                        "cmd".to_owned(),
4721                        result.to_string(),
4722                    ),
4723                },
4724            ];
4725            let state = UiState::from_history(&history, "secret", "model", None, false);
4726            assert_eq!(transcript_lines(&state, 80)[0].to_string(), expected);
4727        }
4728    }
4729
4730    #[test]
4731    fn cmd_line_truncates_long_commands_but_never_renders_output() {
4732        let command = "a".repeat(120);
4733        let arguments = serde_json::json!({"command": command}).to_string();
4734        let history = vec![
4735            SessionHistoryRecord::Message {
4736                timestamp: 1,
4737                message: ChatMessage::assistant(
4738                    String::new(),
4739                    vec![crate::model::ChatToolCall {
4740                        id: "call-1".to_owned(),
4741                        name: "cmd".to_owned(),
4742                        arguments,
4743                    }],
4744                ),
4745            },
4746            SessionHistoryRecord::Message {
4747                timestamp: 2,
4748                message: ChatMessage::tool(
4749                    "call-1".to_owned(),
4750                    "cmd".to_owned(),
4751                    serde_json::json!({"exit_code": 0, "stdout": "output"}).to_string(),
4752                ),
4753            },
4754        ];
4755        let state = UiState::from_history(&history, "secret", "model", None, false);
4756        let text = transcript_lines(&state, 200)[0].to_string();
4757        assert!(text.contains(&format!("$ {}…", "a".repeat(100))));
4758        assert!(!text.contains(&"a".repeat(101)));
4759        assert!(!text.contains("output"));
4760    }
4761
4762    #[test]
4763    fn cmd_lines_remain_compact_for_consecutive_calls() {
4764        let history = vec![
4765            SessionHistoryRecord::Message {
4766                timestamp: 1,
4767                message: ChatMessage::assistant(
4768                    String::new(),
4769                    vec![
4770                        crate::model::ChatToolCall {
4771                            id: "call-first".to_owned(),
4772                            name: "cmd".to_owned(),
4773                            arguments: r#"{"command":"first"}"#.to_owned(),
4774                        },
4775                        crate::model::ChatToolCall {
4776                            id: "call-second".to_owned(),
4777                            name: "cmd".to_owned(),
4778                            arguments: r#"{"command":"second"}"#.to_owned(),
4779                        },
4780                    ],
4781                ),
4782            },
4783            SessionHistoryRecord::Message {
4784                timestamp: 2,
4785                message: ChatMessage::tool(
4786                    "call-first".to_owned(),
4787                    "cmd".to_owned(),
4788                    serde_json::json!({"exit_code": 0}).to_string(),
4789                ),
4790            },
4791            SessionHistoryRecord::Message {
4792                timestamp: 3,
4793                message: ChatMessage::tool(
4794                    "call-second".to_owned(),
4795                    "cmd".to_owned(),
4796                    serde_json::json!({"exit_code": 0}).to_string(),
4797                ),
4798            },
4799        ];
4800        let state = UiState::from_history(&history, "secret", "model", None, false);
4801        let lines = transcript_lines(&state, 200);
4802        assert_eq!(lines[0].to_string(), "✓ cmd  $ first");
4803        assert_eq!(lines[2].to_string(), "✓ cmd  $ second");
4804    }
4805
4806    #[test]
4807    fn cmd_status_styles_use_success_failure_and_pending_colors() {
4808        assert_eq!(
4809            cmd_result_status(&serde_json::json!({"exit_code": 0})).2.fg,
4810            Some(TOOL_SUCCESS_COLOR)
4811        );
4812        assert_eq!(
4813            cmd_result_status(&serde_json::json!({"exit_code": 1})).2.fg,
4814            Some(TOOL_FAILURE_COLOR)
4815        );
4816        assert_eq!(
4817            cmd_tool_segments(
4818                "call-1",
4819                "{\"command\":\"pwd\"}",
4820                None,
4821                &UiState::from_history(&[], "secret", "model", None, false)
4822            )[0]
4823            .1
4824            .fg,
4825            Some(PENDING_TOOL_COLOR)
4826        );
4827    }
4828
4829    #[test]
4830    fn recognized_skill_trigger_is_highlighted_but_arguments_remain_default_colored() {
4831        let trigger = active_skill_trigger("/release-notes v1.2.0", &["release-notes".to_owned()]);
4832        assert_eq!(trigger, Some("/release-notes"));
4833        assert_eq!(SKILL_TRIGGER_COLOR, Color::Rgb(80, 255, 245));
4834
4835        let lines = styled_text_lines(
4836            "/release-notes v1.2.0",
4837            trigger,
4838            80,
4839            Style::default().fg(Color::White),
4840        );
4841        assert_eq!(lines.len(), 1);
4842        assert_eq!(lines[0].to_string(), "/release-notes v1.2.0");
4843        assert_eq!(lines[0].spans[0].content, "/release-notes");
4844        assert_eq!(lines[0].spans[0].style.fg, Some(SKILL_TRIGGER_COLOR));
4845        assert_eq!(lines[0].spans[1].content, " v1.2.0");
4846        assert_eq!(lines[0].spans[1].style.fg, Some(Color::White));
4847    }
4848
4849    #[test]
4850    fn draw_renders_an_active_skill_trigger_in_cyan() {
4851        let mut state = UiState::from_history(&[], "secret", "model", None, false)
4852            .with_skill_names(vec!["release-notes".to_owned()]);
4853        state.input = "/release-notes v1.2.0".to_owned();
4854        state.cursor = state.input.chars().count();
4855
4856        let mut terminal =
4857            Terminal::new(ratatui::backend::TestBackend::new(40, 10)).expect("test terminal");
4858        terminal
4859            .draw(|frame| draw(frame, &state))
4860            .expect("draw input");
4861
4862        // The full-width input block keeps trigger characters bright cyan while the
4863        // argument that follows stays white.
4864        let buffer = terminal.backend().buffer();
4865        let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 40, 10)));
4866        let prompt_area = prompt_area(input_area, &state);
4867        let input_x = prompt_area.x;
4868        let input_y = prompt_area.y;
4869        assert_eq!(buffer[(input_x, input_y)].fg, SKILL_TRIGGER_COLOR);
4870        assert_eq!(
4871            buffer[(input_x + "/release-notes".chars().count() as u16, input_y)].fg,
4872            Color::White
4873        );
4874    }
4875
4876    #[test]
4877    fn main_agent_status_omits_activity_animation_on_idle_and_busy_states() {
4878        let mut state =
4879            UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
4880        let area = Rect::new(0, 0, 80, 10);
4881        let mut terminal =
4882            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4883                .expect("test terminal");
4884
4885        terminal
4886            .draw(|frame| draw(frame, &state))
4887            .expect("draw ready status");
4888        let viewport = tui_viewport(area);
4889        let status_area = ui_layout(&state, viewport).5;
4890        let expected_context = "Context: 81/100 (81%) █████████░";
4891        let buffer = terminal.backend().buffer();
4892        let idle_row = (status_area.x..status_area.x + status_area.width)
4893            .map(|x| buffer[(x, status_area.y)].symbol())
4894            .collect::<String>();
4895        assert!(idle_row.starts_with("model · default"));
4896        assert!(idle_row.ends_with(expected_context));
4897        for x in status_area.x..status_area.x + status_area.width {
4898            if buffer[(x, status_area.y)].symbol() != " " {
4899                assert_eq!(buffer[(x, status_area.y)].fg, Color::Rgb(144, 144, 148));
4900            }
4901        }
4902
4903        state.set_status("working");
4904        state.busy = true;
4905        state.activity_transition = None;
4906        state.console_animation_epoch = Instant::now() - console_accent_cycle() / 4;
4907        terminal
4908            .draw(|frame| draw(frame, &state))
4909            .expect("draw working status");
4910        let status_area = ui_layout(&state, viewport).5;
4911        let buffer = terminal.backend().buffer();
4912        let rendered = (status_area.x..status_area.x + status_area.width)
4913            .map(|x| buffer[(x, status_area.y)].symbol())
4914            .collect::<String>();
4915        assert!(rendered.starts_with("model · default "));
4916        assert!(rendered.contains(BUSY_INDICATOR_BLOCK));
4917        assert!(rendered.ends_with(expected_context));
4918    }
4919
4920    #[test]
4921    fn terminal_focus_events_control_cursor_visibility() {
4922        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4923
4924        assert!(handle_terminal_focus_event(&mut state, &Event::FocusLost));
4925        assert!(!state.terminal_focused);
4926        assert!(handle_terminal_focus_event(&mut state, &Event::FocusGained));
4927        assert!(state.terminal_focused);
4928        assert!(!handle_terminal_focus_event(
4929            &mut state,
4930            &Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
4931        ));
4932        assert!(state.terminal_focused);
4933    }
4934
4935    #[test]
4936    fn unfocused_busy_redraw_keeps_the_hardware_cursor_hidden() {
4937        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4938        state.set_status("working");
4939        state.set_busy(true);
4940        state.terminal_focused = false;
4941
4942        let mut terminal =
4943            Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
4944        terminal
4945            .draw(|frame| draw(frame, &state))
4946            .expect("draw busy state");
4947
4948        assert!(
4949            !terminal.backend().cursor_visible(),
4950            "a busy redraw must not re-show the terminal cursor"
4951        );
4952    }
4953
4954    #[test]
4955    fn cjk_input_keeps_the_terminal_cursor_in_the_prompt_without_resetting_activity() {
4956        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4957        state.set_status("working");
4958        state.busy = true;
4959        state.input = "한글".to_owned();
4960        state.cursor = state.input.chars().count();
4961        let activity_started_at = state.activity_started_at;
4962        let tool_animation_epoch = state.tool_animation_epoch;
4963        let sample_at = Instant::now();
4964        let activity_before = state.activity_levels_at(sample_at);
4965
4966        // A committed CJK character must move the hardware cursor by its
4967        // display width, and input edits must not restart either animation.
4968        state.input_changed();
4969        assert_eq!(state.activity_started_at, activity_started_at);
4970        assert_eq!(state.tool_animation_epoch, tool_animation_epoch);
4971        assert_eq!(state.activity_levels_at(sample_at), activity_before);
4972
4973        let area = Rect::new(0, 0, 80, 10);
4974        let mut terminal =
4975            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4976                .expect("test terminal");
4977        terminal
4978            .draw(|frame| draw(frame, &state))
4979            .expect("draw CJK input while working");
4980        let (_, _, _, _, input_area, status_area) = ui_layout(&state, tui_viewport(area));
4981        assert_ne!(input_area.y, status_area.y);
4982        let prompt_area = prompt_area(input_area, &state);
4983        assert!(terminal.backend().cursor_visible());
4984        terminal.backend_mut().assert_cursor_position((
4985            prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
4986            prompt_area.y,
4987        ));
4988    }
4989
4990    #[test]
4991    fn transcript_and_console_are_separated_by_one_blank_row() {
4992        let state = UiState::from_history(&[], "secret", "model", None, false);
4993        let area = Rect::new(0, 0, 80, 10);
4994        let mut terminal =
4995            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4996                .expect("test terminal");
4997
4998        terminal
4999            .draw(|frame| draw(frame, &state))
5000            .expect("draw separated transcript and console");
5001
5002        let (transcript, _, _, _, console, _) = ui_layout(&state, tui_viewport(area));
5003        assert_eq!(transcript.y + transcript.height + 1, console.y);
5004        let gap_y = console.y - 1;
5005        for x in transcript.x..transcript.x + transcript.width {
5006            assert_eq!(terminal.backend().buffer()[(x, gap_y)].symbol(), " ");
5007            assert_eq!(terminal.backend().buffer()[(x, gap_y)].bg, Color::Reset);
5008        }
5009    }
5010
5011    #[test]
5012    fn prompt_surface_has_a_subtle_dark_background_when_idle_or_busy() {
5013        for busy in [false, true] {
5014            let mut state = UiState::from_history(&[], "secret", "model", None, false);
5015            state.input = "prompt".to_owned();
5016            state.cursor = state.input.chars().count();
5017            state.busy = busy;
5018            let area = Rect::new(0, 0, 80, 10);
5019            let mut terminal =
5020                Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5021                    .expect("test terminal");
5022
5023            terminal
5024                .draw(|frame| draw(frame, &state))
5025                .expect("draw prompt surface");
5026
5027            let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(area));
5028            let buffer = terminal.backend().buffer();
5029            for x in 0..area.width {
5030                assert_eq!(buffer[(x, input_area.y - 1)].bg, Color::Reset);
5031            }
5032            for y in input_area.y..input_area.y + input_area.height {
5033                for x in 0..area.width {
5034                    let expected = if input_area.contains((x, y).into()) {
5035                        PROMPT_BACKGROUND
5036                    } else {
5037                        Color::Reset
5038                    };
5039                    assert_eq!(
5040                        buffer[(x, y)].bg,
5041                        expected,
5042                        "busy={busy}: unexpected background at ({x}, {y})"
5043                    );
5044                }
5045            }
5046        }
5047    }
5048
5049    #[test]
5050    fn only_known_leading_skill_commands_activate_input_highlighting() {
5051        let skills = ["release-notes".to_owned()];
5052        assert_eq!(
5053            active_skill_trigger("/missing", &skills),
5054            None,
5055            "unknown commands are rejected by the turn engine and must not look active"
5056        );
5057        assert_eq!(
5058            active_skill_trigger("/skill:release-notes", &skills),
5059            None,
5060            "the removed /skill: wrapper must not look active"
5061        );
5062        assert_eq!(
5063            active_skill_trigger("write /release-notes", &skills),
5064            None,
5065            "only the command prefix accepted by the turn engine is active"
5066        );
5067        assert_eq!(active_skill_trigger("/", &skills), None);
5068    }
5069
5070    #[test]
5071    fn highlighted_skill_trigger_remains_styled_when_wrapped() {
5072        let input = "/release-notes argument";
5073        let trigger = active_skill_trigger(input, &["release-notes".to_owned()]);
5074        let lines = styled_text_lines(input, trigger, 8, Style::default().fg(Color::White));
5075        let highlighted = lines
5076            .iter()
5077            .flat_map(|line| line.spans.iter())
5078            .filter(|span| span.style.fg == Some(SKILL_TRIGGER_COLOR))
5079            .map(|span| span.content.as_ref())
5080            .collect::<String>();
5081        assert_eq!(highlighted, "/release-notes");
5082    }
5083
5084    #[test]
5085    fn input_has_no_prompt_marker_and_trailing_newline_is_visible() {
5086        assert_eq!(input_prompt("hello"), "hello");
5087        assert_eq!(wrap_text("hello\n", 80), vec!["hello", ""]);
5088    }
5089
5090    #[test]
5091    fn input_prompt_wraps_to_multiple_rows_when_long() {
5092        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5093        state.input = "abcdefghij".to_owned();
5094        // width 5: the input wraps across multiple rows without a prompt marker.
5095        let rows = input_visible_rows(&state, 5);
5096        assert!(rows >= 2);
5097    }
5098
5099    #[test]
5100    fn cursor_editing_moves_by_characters_and_preserves_unicode() {
5101        let mut input = "가나".to_owned();
5102        let mut cursor = input.chars().count();
5103        cursor -= 1;
5104        insert_at_cursor(&mut input, &mut cursor, 'x');
5105        assert_eq!(input, "가x나");
5106        assert_eq!(cursor, 2);
5107        assert!(remove_before_cursor(&mut input, &mut cursor));
5108        assert_eq!(input, "가나");
5109        assert_eq!(cursor, 1);
5110    }
5111
5112    #[test]
5113    fn cursor_row_tracks_newlines_and_wrapping() {
5114        assert_eq!(cursor_row("hello\nworld", 6, 80), 1);
5115        assert_eq!(cursor_row("abcdef", 4, 3), 1);
5116    }
5117
5118    #[test]
5119    fn shift_enter_inserts_at_the_cursor_and_moves_it_to_the_new_row() {
5120        let mut input = "beforeafter".to_owned();
5121        let mut cursor = 6;
5122        insert_at_cursor(&mut input, &mut cursor, '\n');
5123
5124        assert_eq!(input, "before\nafter");
5125        assert_eq!(cursor, 7);
5126        assert_eq!(cursor_row(&input, cursor, 80), 1);
5127    }
5128
5129    #[test]
5130    fn shift_enter_renders_the_cursor_on_the_new_input_row() {
5131        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5132        state.input = "beforeafter".to_owned();
5133        state.cursor = 6;
5134        insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
5135
5136        let mut terminal =
5137            Terminal::new(ratatui::backend::TestBackend::new(20, 10)).expect("test terminal");
5138        terminal
5139            .draw(|frame| draw(frame, &state))
5140            .expect("draw input cursor");
5141
5142        // After inserting a newline, the cursor is at the start of the second
5143        // input row.
5144        let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 20, 10)));
5145        let prompt_area = prompt_area(input_area, &state);
5146        terminal
5147            .backend_mut()
5148            .assert_cursor_position((prompt_area.x, prompt_area.y + 1));
5149    }
5150
5151    #[test]
5152    fn tool_results_attach_to_their_matching_call_after_consecutive_calls() {
5153        let history = vec![
5154            SessionHistoryRecord::Message {
5155                timestamp: 1,
5156                message: ChatMessage::assistant(
5157                    String::new(),
5158                    vec![
5159                        crate::model::ChatToolCall {
5160                            id: "call-first".to_owned(),
5161                            name: "cmd".to_owned(),
5162                            arguments: r#"{"command":"first"}"#.to_owned(),
5163                        },
5164                        crate::model::ChatToolCall {
5165                            id: "call-second".to_owned(),
5166                            name: "cmd".to_owned(),
5167                            arguments: r#"{"command":"second"}"#.to_owned(),
5168                        },
5169                    ],
5170                ),
5171            },
5172            SessionHistoryRecord::Message {
5173                timestamp: 2,
5174                message: ChatMessage::tool(
5175                    "call-first".to_owned(),
5176                    "cmd".to_owned(),
5177                    serde_json::json!({"stdout":"first result","stderr":""}).to_string(),
5178                ),
5179            },
5180            SessionHistoryRecord::Message {
5181                timestamp: 3,
5182                message: ChatMessage::tool(
5183                    "call-second".to_owned(),
5184                    "cmd".to_owned(),
5185                    serde_json::json!({"stdout":"second result","stderr":""}).to_string(),
5186                ),
5187            },
5188        ];
5189
5190        let state = UiState::from_history(&history, "secret", "model", None, false);
5191        let lines = transcript_lines(&state, 200);
5192        assert_eq!(
5193            lines.len(),
5194            3,
5195            "only the two call lines and their separator remain"
5196        );
5197        assert_eq!(lines[0].to_string(), "✓ cmd  $ first");
5198        assert_eq!(lines[2].to_string(), "✓ cmd  $ second");
5199    }
5200    #[test]
5201    fn clipped_slash_picker_uses_its_actual_item_rows_for_the_focused_item() {
5202        let mut state = UiState::from_history(&[], "secret", "model", None, false)
5203            .with_skill_names(
5204                ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
5205                    .into_iter()
5206                    .map(str::to_owned)
5207                    .collect(),
5208            );
5209        state.input = "/".to_owned();
5210        state.input_changed();
5211        state.skill_picker_focus = 5;
5212        let mut terminal =
5213            Terminal::new(ratatui::backend::TestBackend::new(30, 5)).expect("test terminal");
5214        terminal
5215            .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 5)))
5216            .expect("draw clipped skill picker");
5217
5218        let buffer = terminal.backend().buffer();
5219        let item_rows = (2..4)
5220            .map(|y| (2..28).map(|x| buffer[(x, y)].symbol()).collect::<String>())
5221            .collect::<Vec<_>>();
5222        assert!(item_rows[0].starts_with("/deploy"));
5223        assert!(item_rows[1].starts_with("/doctor"));
5224        assert_eq!(buffer[(2, 3)].fg, QUEUED_MESSAGE_COLOR);
5225        assert!(buffer[(2, 3)].modifier.contains(Modifier::BOLD));
5226    }
5227}
5228
5229#[cfg(test)]
5230mod skill_picker_tests {
5231    use super::*;
5232
5233    fn skill_names() -> Vec<String> {
5234        ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
5235            .into_iter()
5236            .map(str::to_owned)
5237            .collect()
5238    }
5239
5240    #[test]
5241    fn built_in_commands_share_the_slash_catalog_without_becoming_skills() {
5242        assert_eq!(
5243            command_names(vec!["release-notes".to_owned(), "settings".to_owned()]),
5244            vec!["exit", "release-notes", "settings"]
5245        );
5246        assert_eq!(
5247            builtin_command("/settings ignored arguments"),
5248            Some(BuiltinCommand::Settings)
5249        );
5250        assert_eq!(builtin_command("  /exit  "), Some(BuiltinCommand::Exit));
5251        assert_eq!(builtin_command("/settings-extra"), None);
5252    }
5253
5254    #[test]
5255    fn settings_viewport_follows_focus_instead_of_truncating_the_catalog_head() {
5256        assert_eq!(selection_range(30, 0, 12), 0..12);
5257        assert_eq!(selection_range(30, 11, 12), 0..12);
5258        assert_eq!(selection_range(30, 12, 12), 1..13);
5259        assert_eq!(selection_range(30, 29, 12), 18..30);
5260    }
5261
5262    #[test]
5263    fn model_selection_uses_advertised_efforts_and_preserves_the_current_choice() {
5264        let mut state = UiState::from_history(&[], "secret", "old", Some("medium"), false);
5265        state.open_catalog(Ok(vec![ProviderModel {
5266            id: "openai/gpt-5.6-sol".to_owned(),
5267            efforts: Some(vec![
5268                "max".to_owned(),
5269                "high".to_owned(),
5270                "medium".to_owned(),
5271                "low".to_owned(),
5272            ]),
5273        }]));
5274        state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
5275
5276        let SettingsState::Effort { model, focus, .. } =
5277            state.settings.as_ref().expect("effort picker")
5278        else {
5279            panic!("model selection should open the effort picker");
5280        };
5281        assert_eq!(model.id, "openai/gpt-5.6-sol");
5282        assert_eq!(*focus, 3, "default occupies index zero before medium");
5283
5284        let selected = state
5285            .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
5286            .expect("effort selection");
5287        assert_eq!(
5288            selected,
5289            ("openai/gpt-5.6-sol".to_owned(), Some("medium".to_owned()))
5290        );
5291    }
5292
5293    #[test]
5294    fn effort_default_selection_does_not_shift_to_the_first_advertised_effort() {
5295        let mut state = UiState::from_history(&[], "secret", "old", None, false);
5296        state.open_catalog(Ok(vec![ProviderModel {
5297            id: "model".to_owned(),
5298            efforts: Some(vec!["high".to_owned(), "low".to_owned()]),
5299        }]));
5300        state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
5301
5302        let selected = state
5303            .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
5304            .expect("default effort selection");
5305        assert_eq!(selected, ("model".to_owned(), None));
5306    }
5307
5308    #[test]
5309    fn reasoning_indicator_changes_to_complete_and_stays_dark_gray() {
5310        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5311        state.show_thinking();
5312
5313        let active_lines = transcript_lines(&state, 80);
5314        let active = active_lines.last().expect("reasoning line");
5315        assert!(active.to_string().starts_with("Reasoning... "));
5316        assert_eq!(active.style.fg, Some(Color::DarkGray));
5317
5318        state.complete_reasoning();
5319        let complete_lines = transcript_lines(&state, 80);
5320        let complete = complete_lines.last().expect("complete line");
5321        assert_eq!(complete.to_string(), "Reasoning Complete");
5322        assert_eq!(complete.style.fg, Some(Color::DarkGray));
5323    }
5324
5325    #[test]
5326    fn slash_picker_filters_only_leading_command_text_and_hides_without_matches() {
5327        let names = skill_names();
5328        assert_eq!(
5329            matching_skill_names("/", &names),
5330            vec!["alpha", "beta", "build", "charlie", "deploy", "doctor"]
5331        );
5332        assert_eq!(matching_skill_names("/b", &names), vec!["beta", "build"]);
5333        assert!(matching_skill_names("/missing", &names).is_empty());
5334        assert!(matching_skill_names("message /b", &names).is_empty());
5335        assert!(matching_skill_names("/beta arguments", &names).is_empty());
5336    }
5337
5338    #[test]
5339    fn slash_picker_focuses_the_top_match_and_moves_within_filtered_results() {
5340        let mut state = UiState::from_history(&[], "secret", "model", None, false)
5341            .with_skill_names(skill_names());
5342        state.input = "/b".to_owned();
5343        state.input_changed();
5344
5345        assert!(state.skill_picker_visible());
5346        assert_eq!(state.skill_picker_focus, 0);
5347        assert!(state.move_skill_picker(true));
5348        assert_eq!(state.skill_picker_focus, 1);
5349        assert!(state.move_skill_picker(true));
5350        assert_eq!(state.skill_picker_focus, 1, "focus does not leave the list");
5351        assert!(state.move_skill_picker(false));
5352        assert_eq!(state.skill_picker_focus, 0);
5353
5354        state.input = "/missing".to_owned();
5355        state.input_changed();
5356        assert!(!state.skill_picker_visible());
5357        assert!(!state.move_skill_picker(true));
5358    }
5359
5360    #[test]
5361    fn focused_builtins_are_distinguished_from_skills() {
5362        let mut state = UiState::from_history(&[], "secret", "model", None, false)
5363            .with_skill_names(command_names(skill_names()));
5364        state.input = "/se".to_owned();
5365        state.input_changed();
5366        assert_eq!(
5367            state.focused_builtin_command(),
5368            Some(BuiltinCommand::Settings)
5369        );
5370
5371        state.input = "/be".to_owned();
5372        state.input_changed();
5373        assert_eq!(state.focused_builtin_command(), None);
5374    }
5375
5376    #[test]
5377    fn selecting_the_focused_skill_leaves_the_completed_command_ready_to_send() {
5378        let mut state = UiState::from_history(&[], "secret", "model", None, false)
5379            .with_skill_names(skill_names());
5380        state.input = "/b".to_owned();
5381        state.input_changed();
5382        state.move_skill_picker(true);
5383
5384        assert!(state.select_focused_skill());
5385        assert_eq!(state.input, "/build");
5386        assert_eq!(state.cursor, "/build".chars().count());
5387        assert!(
5388            !state.skill_picker_visible(),
5389            "the first Enter completes the input rather than sending it"
5390        );
5391        assert!(
5392            !state.select_focused_skill(),
5393            "a second Enter follows the normal send/attachment path"
5394        );
5395    }
5396
5397    #[test]
5398    fn slash_picker_overlays_without_reflowing_the_transcript_when_match_count_changes() {
5399        let mut state = UiState::from_history(&[], "secret", "model", None, false)
5400            .with_skill_names(skill_names());
5401        let area = Rect::new(0, 0, 40, 16);
5402        state.transcript = (0..20)
5403            .map(|index| TranscriptItem::Assistant(format!("message {index}")))
5404            .collect();
5405
5406        state.input = "/a".to_owned();
5407        state.input_changed();
5408        let (narrow_chat, narrow_picker, _, _, narrow_input, _) = ui_layout(&state, area);
5409        let narrow_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
5410
5411        state.input = "/".to_owned();
5412        state.input_changed();
5413        let (broad_chat, broad_picker, _, _, broad_input, _) = ui_layout(&state, area);
5414        let broad_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
5415
5416        assert_ne!(
5417            narrow_picker, broad_picker,
5418            "the overlay may fit its contents"
5419        );
5420        assert_eq!(narrow_chat, broad_chat);
5421        assert_eq!(narrow_input, broad_input);
5422        assert_eq!(
5423            narrow_scroll, broad_scroll,
5424            "the overlay does not reduce the transcript viewport"
5425        );
5426    }
5427
5428    #[test]
5429    fn slash_picker_keeps_the_focused_item_in_its_five_row_viewport() {
5430        assert_eq!(selection_range(20, 0, 5), 0..5);
5431        assert_eq!(selection_range(20, 4, 5), 0..5);
5432        assert_eq!(selection_range(20, 5, 5), 1..6);
5433        assert_eq!(selection_range(20, 19, 5), 15..20);
5434    }
5435
5436    #[test]
5437    fn is_inside_tmux_detection() {
5438        std::env::set_var("TERM_PROGRAM", "tmux");
5439        assert!(is_inside_tmux());
5440        std::env::set_var("TERM_PROGRAM", "TMUX");
5441        assert!(is_inside_tmux());
5442        std::env::set_var("TERM_PROGRAM", "ghostty");
5443        assert!(!is_inside_tmux());
5444        std::env::remove_var("TERM_PROGRAM");
5445        assert!(!is_inside_tmux());
5446    }
5447
5448    #[test]
5449    fn slash_picker_is_rendered_immediately_above_the_input() {
5450        let mut state = UiState::from_history(&[], "secret", "model", None, false)
5451            .with_skill_names(skill_names());
5452        state.input = "/".to_owned();
5453        state.input_changed();
5454        let mut terminal =
5455            Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
5456        terminal
5457            .draw(|frame| draw(frame, &state))
5458            .expect("draw TUI");
5459
5460        let buffer = terminal.backend().buffer();
5461        let area = tui_viewport(Rect::new(0, 0, 40, 12));
5462        let (_, picker_area, _, _, input_area, _) = ui_layout(&state, area);
5463        let picker_area = picker_area.expect("picker area");
5464        // The picker shares a boundary with the prompt; no blank row separates them.
5465        assert_eq!(picker_area.y + picker_area.height, input_area.y);
5466        for (x, y) in [
5467            (picker_area.x, picker_area.y),
5468            (picker_area.x + picker_area.width - 1, picker_area.y),
5469            (picker_area.x, picker_area.y + picker_area.height - 1),
5470            (
5471                picker_area.x + picker_area.width - 1,
5472                picker_area.y + picker_area.height - 1,
5473            ),
5474        ] {
5475            assert_eq!(buffer[(x, y)].symbol(), " ");
5476            assert_eq!(buffer[(x, y)].bg, SKILL_PICKER_BACKGROUND);
5477        }
5478        assert_eq!(
5479            buffer[(picker_area.x + 1, picker_area.y + 1)].bg,
5480            SKILL_PICKER_BACKGROUND
5481        );
5482        assert_eq!(buffer[(picker_area.x + 2, picker_area.y + 1)].symbol(), "[");
5483        assert_eq!(
5484            buffer[(picker_area.x + 2, picker_area.y + 1)].fg,
5485            QUEUED_MESSAGE_COLOR
5486        );
5487        assert_eq!(buffer[(picker_area.x + 2, picker_area.y + 2)].symbol(), "/");
5488        assert_eq!(
5489            buffer[(picker_area.x + 2, picker_area.y + 2)].fg,
5490            QUEUED_MESSAGE_COLOR
5491        );
5492        assert_eq!(
5493            buffer[(picker_area.x + 1, picker_area.y + picker_area.height - 2)].bg,
5494            SKILL_PICKER_BACKGROUND
5495        );
5496        assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), " ");
5497        assert_eq!(buffer[(input_area.x, input_area.y)].bg, PROMPT_BACKGROUND);
5498    }
5499
5500    #[test]
5501    fn slash_picker_renders_count_with_bold_focus_on_the_picker_surface() {
5502        let mut state = UiState::from_history(&[], "secret", "model", None, false)
5503            .with_skill_names(skill_names());
5504        state.input = "/".to_owned();
5505        state.input_changed();
5506        let mut terminal =
5507            Terminal::new(ratatui::backend::TestBackend::new(30, 8)).expect("test terminal");
5508        terminal
5509            .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 8)))
5510            .expect("draw skill picker");
5511
5512        let buffer = terminal.backend().buffer();
5513        assert_eq!(buffer[(0, 0)].symbol(), " ");
5514        assert_eq!(buffer[(0, 0)].bg, SKILL_PICKER_BACKGROUND);
5515        assert_eq!(buffer[(2, 1)].symbol(), "[");
5516        assert_eq!(buffer[(2, 1)].fg, QUEUED_MESSAGE_COLOR);
5517        assert_eq!(buffer[(2, 2)].symbol(), "/");
5518        assert_eq!(buffer[(2, 2)].fg, QUEUED_MESSAGE_COLOR);
5519        assert!(buffer[(2, 2)].modifier.contains(Modifier::BOLD));
5520        assert_eq!(buffer[(2, 3)].symbol(), "/");
5521        assert_eq!(buffer[(2, 3)].fg, QUEUED_MESSAGE_COLOR);
5522        assert!(!buffer[(2, 3)].modifier.contains(Modifier::BOLD));
5523    }
5524}
5525
5526#[cfg(test)]
5527mod tmux_keyboard_tests {
5528    use super::*;
5529
5530    #[test]
5531    fn is_inside_tmux_detection() {
5532        std::env::set_var("TERM_PROGRAM", "tmux");
5533        assert!(is_inside_tmux());
5534        std::env::set_var("TERM_PROGRAM", "TMUX");
5535        assert!(is_inside_tmux());
5536        std::env::set_var("TERM_PROGRAM", "ghostty");
5537        assert!(!is_inside_tmux());
5538        std::env::remove_var("TERM_PROGRAM");
5539        assert!(!is_inside_tmux());
5540    }
5541}