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