Skip to main content

lucy/
tui.rs

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