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