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