Skip to main content

lucy/
tui.rs

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