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(600);
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 content area inset without allowing margins to consume all
1580/// available width. A narrow terminal sheds margin cells before it sheds the
1581/// console.
1582const CONTENT_HORIZONTAL_MARGIN: u16 = 7;
1583const MIN_CONSOLE_WIDTH: u16 = 14;
1584
1585fn bottom_console_area(area: Rect, y: u16, height: u16) -> Rect {
1586    let horizontal_margin = area.width.saturating_sub(1) / 2;
1587    let margin_cap = if area.width < MIN_CONSOLE_WIDTH {
1588        2
1589    } else {
1590        CONTENT_HORIZONTAL_MARGIN.min(area.width.saturating_sub(MIN_CONSOLE_WIDTH) / 2)
1591    };
1592    let horizontal_margin = horizontal_margin.min(margin_cap);
1593    Rect::new(
1594        area.x.saturating_add(horizontal_margin),
1595        y,
1596        area.width
1597            .saturating_sub(horizontal_margin.saturating_mul(2)),
1598        height,
1599    )
1600}
1601
1602fn ui_prompt_content_width(area: Rect) -> u16 {
1603    prompt_content_width(bottom_console_area(area, area.y, 0).width)
1604}
1605
1606fn console_content_area(input_area: Rect) -> Rect {
1607    let top_padding = input_area.height.min(1);
1608    let bottom_padding = input_area.height.saturating_sub(top_padding).min(1);
1609    Rect::new(
1610        input_area.x.saturating_add(2),
1611        input_area.y.saturating_add(top_padding),
1612        input_area.width.saturating_sub(4),
1613        input_area
1614            .height
1615            .saturating_sub(top_padding + bottom_padding),
1616    )
1617}
1618
1619fn prompt_content_width(input_width: u16) -> u16 {
1620    input_width.saturating_sub(4)
1621}
1622
1623#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1624struct BottomContentHeights {
1625    queue: u16,
1626    queue_separator: u16,
1627    list: u16,
1628    list_separator: u16,
1629    prompt: u16,
1630    status_separator: u16,
1631    status: u16,
1632}
1633
1634// Constrained layouts keep the status and prompt first. Queue and worker
1635// sections each require a header, one entry, and their following spacer so a
1636// clipped console never renders an orphaned section header.
1637fn bottom_content_heights(state: &UiState, input_area: Rect) -> BottomContentHeights {
1638    let mut available = console_content_area(input_area).height;
1639    let status = available.min(1);
1640    available -= status;
1641
1642    let prompt = input_visible_rows(state, prompt_content_width(input_area.width))
1643        .clamp(1, MAX_INPUT_ROWS)
1644        .min(available);
1645    available -= prompt;
1646
1647    let status_separator = u16::from(status > 0 && prompt > 0 && available > 0);
1648    available -= status_separator;
1649
1650    let requested_queue = message_queue_height(state);
1651    let (queue, queue_separator) = if requested_queue > 0 && available >= 3 {
1652        (requested_queue.min(available - 1), 1)
1653    } else {
1654        (0, 0)
1655    };
1656    available -= queue + queue_separator;
1657
1658    let requested_list = 0;
1659    let (list, list_separator) = if requested_list > 0 && available >= 3 {
1660        (requested_list.min(available - 1), 1)
1661    } else {
1662        (0, 0)
1663    };
1664
1665    BottomContentHeights {
1666        queue,
1667        queue_separator,
1668        list,
1669        list_separator,
1670        prompt,
1671        status_separator,
1672        status,
1673    }
1674}
1675
1676fn prompt_area(input_area: Rect, state: &UiState) -> Rect {
1677    let inner = console_content_area(input_area);
1678    let content = bottom_content_heights(state, input_area);
1679    Rect::new(
1680        inner.x,
1681        inner.y + content.queue + content.queue_separator,
1682        inner.width,
1683        content.prompt,
1684    )
1685}
1686
1687fn message_queue_height(state: &UiState) -> u16 {
1688    let messages = state.queued_messages.len().min(u16::MAX as usize - 1) as u16;
1689    u16::from(messages > 0) + messages
1690}
1691
1692fn max_scroll_for_area(state: &UiState, size: Size) -> u16 {
1693    let area = tui_viewport(Rect::new(0, 0, size.width, size.height));
1694    let (chat_chunk, _, _, _, _, _) = ui_layout(state, area);
1695    let chat_height = chat_chunk.height;
1696    let lines = transcript_lines(state, chat_chunk.width);
1697    lines
1698        .len()
1699        .saturating_sub(chat_height as usize)
1700        .min(u16::MAX as usize) as u16
1701}
1702
1703const TRANSCRIPT_SCROLLBAR_TRACK: &str = "┆";
1704const TRANSCRIPT_SCROLLBAR_THUMB: &str = "█";
1705const TRANSCRIPT_SCROLLBAR_TRACK_COLOR: Color = Color::Rgb(72, 72, 76);
1706
1707fn draw_transcript_scrollbar(
1708    frame: &mut Frame<'_>,
1709    area: Rect,
1710    total_lines: usize,
1711    max_scroll: u16,
1712    scroll: u16,
1713) {
1714    if area.width == 0 || area.height == 0 || total_lines == 0 || max_scroll == 0 {
1715        return;
1716    }
1717
1718    let track_height = area.height as usize;
1719    let thumb_height = ((track_height * track_height) / total_lines)
1720        .max(1)
1721        .min(track_height);
1722    let thumb_range = track_height.saturating_sub(thumb_height);
1723    let thumb_start = (usize::from(scroll.min(max_scroll)) * thumb_range / usize::from(max_scroll))
1724        .min(thumb_range);
1725    let x = area.x + area.width - 1;
1726    let buffer = frame.buffer_mut();
1727
1728    for offset in 0..track_height {
1729        let y = area.y + offset as u16;
1730        buffer[(x, y)].set_symbol(TRANSCRIPT_SCROLLBAR_TRACK);
1731        buffer[(x, y)].set_fg(TRANSCRIPT_SCROLLBAR_TRACK_COLOR);
1732    }
1733    for offset in thumb_start..thumb_start + thumb_height {
1734        let y = area.y + offset as u16;
1735        buffer[(x, y)].set_symbol(TRANSCRIPT_SCROLLBAR_THUMB);
1736        buffer[(x, y)].set_fg(CONSOLE_STATUS_COLOR);
1737    }
1738}
1739
1740/// Number of wrapped rows the current input occupies at `width`.
1741fn input_visible_rows(state: &UiState, width: u16) -> u16 {
1742    let width = width as usize;
1743    if width == 0 {
1744        return 1;
1745    }
1746    let prompt = input_display_text(state);
1747    let wrapped = wrap_text(&prompt, width);
1748    wrapped.len().max(1) as u16
1749}
1750
1751fn input_prompt(input: &str) -> String {
1752    input.to_owned()
1753}
1754
1755fn input_display_text(state: &UiState) -> String {
1756    redact_secret(&input_prompt(&state.input), Some(&state.secret))
1757}
1758
1759fn command_names(mut skill_names: Vec<String>) -> Vec<String> {
1760    skill_names.extend(BUILTIN_COMMANDS.into_iter().map(str::to_owned));
1761    skill_names.sort();
1762    skill_names.dedup();
1763    skill_names
1764}
1765
1766#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1767enum BuiltinCommand {
1768    Settings,
1769    Exit,
1770}
1771
1772impl BuiltinCommand {
1773    fn name(self) -> &'static str {
1774        match self {
1775            Self::Settings => "settings",
1776            Self::Exit => "exit",
1777        }
1778    }
1779}
1780
1781fn builtin_command(input: &str) -> Option<BuiltinCommand> {
1782    match input.split_whitespace().next()? {
1783        "/settings" => Some(BuiltinCommand::Settings),
1784        "/exit" => Some(BuiltinCommand::Exit),
1785        _ => None,
1786    }
1787}
1788
1789/// The slash picker only accepts a command at the beginning of the message.
1790/// Once whitespace starts arguments, normal message entry resumes.
1791fn matching_skill_names<'a>(input: &str, skill_names: &'a [String]) -> Vec<&'a str> {
1792    let Some(query) = input.strip_prefix('/') else {
1793        return Vec::new();
1794    };
1795    if query.chars().any(char::is_whitespace) {
1796        return Vec::new();
1797    }
1798    skill_names
1799        .iter()
1800        .map(String::as_str)
1801        .filter(|name| name.starts_with(query))
1802        .collect()
1803}
1804
1805fn skill_picker_height(state: &UiState) -> u16 {
1806    if state.skill_picker_visible() {
1807        // Header, visible commands, and the vertical inset.
1808        (state
1809            .matching_skill_names()
1810            .len()
1811            .min(SKILL_PICKER_MAX_ROWS)
1812            + 3) as u16
1813    } else {
1814        0
1815    }
1816}
1817
1818/// Return the command portion of a currently valid explicit skill invocation.
1819/// This mirrors the command grammar used by the turn engine, while keeping the
1820/// styling concern local to the TUI.
1821fn active_skill_trigger<'a>(input: &'a str, skill_names: &[String]) -> Option<&'a str> {
1822    let invocation = input.strip_prefix('/')?;
1823    let name = invocation
1824        .split_once(char::is_whitespace)
1825        .map_or(invocation, |(name, _)| name);
1826    if name.is_empty() || !skill_names.iter().any(|skill_name| skill_name == name) {
1827        return None;
1828    }
1829    Some(&input[..1 + name.len()])
1830}
1831
1832/// Preserve input wrapping while styling a recognized `/<name>` prefix
1833/// independently from any arguments the user is still entering.
1834fn styled_text_lines(
1835    input: &str,
1836    active_skill_trigger: Option<&str>,
1837    width: usize,
1838    text_style: Style,
1839) -> Vec<Line<'static>> {
1840    let trigger_len = active_skill_trigger.map_or(0, |trigger| trigger.chars().count());
1841    let mut char_offset = 0usize;
1842    let mut lines = Vec::new();
1843
1844    for source_line in input.split('\n') {
1845        for row in wrap_line(source_line, width) {
1846            let mut spans = Vec::new();
1847            let mut text = String::new();
1848            let mut highlighted = None;
1849            for character in row.chars() {
1850                let should_highlight = char_offset < trigger_len;
1851                if highlighted != Some(should_highlight) && !text.is_empty() {
1852                    spans.push(styled_text_span(
1853                        std::mem::take(&mut text),
1854                        highlighted.unwrap_or(false),
1855                        text_style,
1856                    ));
1857                }
1858                highlighted = Some(should_highlight);
1859                text.push(character);
1860                char_offset += 1;
1861            }
1862            if !text.is_empty() {
1863                spans.push(styled_text_span(
1864                    text,
1865                    highlighted.unwrap_or(false),
1866                    text_style,
1867                ));
1868            }
1869            if spans.is_empty() {
1870                spans.push(Span::styled(String::new(), text_style));
1871            }
1872            lines.push(Line::from(spans));
1873        }
1874        // `split` retains empty trailing lines; account for the newline that
1875        // separated this source line from the next one in the character index.
1876        char_offset += 1;
1877    }
1878
1879    lines
1880}
1881
1882fn styled_text_span(text: String, highlighted: bool, text_style: Style) -> Span<'static> {
1883    if highlighted {
1884        Span::styled(text, Style::default().fg(SKILL_TRIGGER_COLOR))
1885    } else {
1886        Span::styled(text, text_style)
1887    }
1888}
1889
1890#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1891struct InputVisualRow {
1892    start: usize,
1893    end: usize,
1894}
1895
1896fn input_visual_rows(input: &str, width: usize) -> Vec<InputVisualRow> {
1897    let width = width.max(1);
1898    let characters = input.chars().collect::<Vec<_>>();
1899    let mut rows = Vec::new();
1900    let mut start = 0;
1901    let mut row_width = 0;
1902
1903    for (index, character) in characters.iter().enumerate() {
1904        if *character == '\n' {
1905            rows.push(InputVisualRow { start, end: index });
1906            start = index + 1;
1907            row_width = 0;
1908            continue;
1909        }
1910
1911        let character_width = unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0);
1912        if row_width + character_width > width && index > start {
1913            rows.push(InputVisualRow { start, end: index });
1914            start = index;
1915            row_width = 0;
1916        }
1917        row_width += character_width;
1918    }
1919
1920    rows.push(InputVisualRow {
1921        start,
1922        end: characters.len(),
1923    });
1924    rows
1925}
1926
1927fn input_cursor_row(input: &str, cursor: usize, width: usize) -> usize {
1928    let rows = input_visual_rows(input, width);
1929    let cursor = cursor.min(input.chars().count());
1930    for (index, row) in rows.iter().enumerate() {
1931        if cursor < row.end {
1932            return index;
1933        }
1934        if cursor == row.end && rows.get(index + 1).is_none_or(|next| next.start != cursor) {
1935            return index;
1936        }
1937    }
1938    rows.len().saturating_sub(1)
1939}
1940
1941fn cursor_row(input: &str, cursor: usize, width: usize) -> u16 {
1942    input_cursor_row(input, cursor, width).min(u16::MAX as usize) as u16
1943}
1944
1945fn move_up_from_input(state: &mut UiState, width: usize) -> bool {
1946    state.move_skill_picker(false) || move_input_cursor_vertical(state, width, false)
1947}
1948
1949fn move_down_from_input(state: &mut UiState, width: usize) -> bool {
1950    let width = width.max(1);
1951    state.move_skill_picker(true) || move_input_cursor_vertical(state, width, true)
1952}
1953
1954fn move_input_cursor_vertical(state: &mut UiState, width: usize, down: bool) -> bool {
1955    let width = width.max(1);
1956    let rows = input_visual_rows(&state.input, width);
1957    let current_row = input_cursor_row(&state.input, state.cursor, width);
1958    let target_row = if down {
1959        current_row + 1
1960    } else {
1961        current_row.saturating_sub(1)
1962    };
1963    if target_row == current_row || target_row >= rows.len() {
1964        return false;
1965    }
1966
1967    let characters = state.input.chars().collect::<Vec<_>>();
1968    let current = rows[current_row];
1969    let cursor = state.cursor.min(current.end);
1970    let desired_column = characters[current.start..cursor]
1971        .iter()
1972        .map(|character| unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0))
1973        .sum::<usize>();
1974    let target = rows[target_row];
1975    let mut column = 0;
1976    let mut target_cursor = target.end;
1977    for (index, character) in characters
1978        .iter()
1979        .enumerate()
1980        .take(target.end)
1981        .skip(target.start)
1982    {
1983        let character_width = unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0);
1984        if column + character_width > desired_column {
1985            target_cursor = index;
1986            break;
1987        }
1988        column += character_width;
1989        if column >= desired_column {
1990            target_cursor = index + 1;
1991            break;
1992        }
1993    }
1994    state.cursor = target_cursor;
1995    true
1996}
1997
1998fn insert_at_cursor(input: &mut String, cursor: &mut usize, character: char) {
1999    let byte_index = input
2000        .char_indices()
2001        .nth(*cursor)
2002        .map_or(input.len(), |(index, _)| index);
2003    input.insert(byte_index, character);
2004    *cursor += 1;
2005}
2006
2007fn remove_before_cursor(input: &mut String, cursor: &mut usize) -> bool {
2008    if *cursor == 0 {
2009        return false;
2010    }
2011    let end = input
2012        .char_indices()
2013        .nth(*cursor)
2014        .map_or(input.len(), |(index, _)| index);
2015    let start = input
2016        .char_indices()
2017        .nth(*cursor - 1)
2018        .map(|(index, _)| index)
2019        .unwrap_or(0);
2020    input.replace_range(start..end, "");
2021    *cursor -= 1;
2022    true
2023}
2024
2025fn draw(frame: &mut Frame<'_>, state: &UiState) {
2026    let full_area = frame.area();
2027    // Clear the outer gutters too, so a resize or overlay cannot leave stale
2028    // cells in the one-column margins.
2029    frame.render_widget(Clear, full_area);
2030    let area = tui_viewport(full_area);
2031    let (chat_chunk, picker_area, _, queue_area, input_chunk, status_area) = ui_layout(state, area);
2032
2033    // The queue, prompt, and status line share one background surface.
2034    // The transient picker remains above it.
2035    let visible_chat_area = chat_chunk;
2036
2037    let width = chat_chunk.width;
2038    let welcome_image_layout = if state.welcome_visible && greeting_image_enabled() {
2039        let welcome_lines = welcome_lines(&state.attached_agents);
2040        welcome_image_layout(visible_chat_area, welcome_lines.len() as u16)
2041    } else {
2042        None
2043    };
2044    if state.welcome_visible {
2045        let welcome_lines = welcome_lines(&state.attached_agents);
2046        if let Some(layout) = welcome_image_layout {
2047            let welcome = Paragraph::new(welcome_lines).alignment(Alignment::Center);
2048            frame.render_widget(welcome, layout.intro_area);
2049        } else {
2050            let logo = logo_lines();
2051            let logo_gap = 2u16;
2052            let total_height = logo.len() as u16 + logo_gap + welcome_lines.len() as u16;
2053            // Show the logo only when the chat area can fit the logo, gap,
2054            // and welcome text; otherwise fall back to text-only.
2055            let lines = if total_height <= visible_chat_area.height {
2056                let mut all = logo;
2057                all.push(Line::raw(""));
2058                all.push(Line::raw(""));
2059                all.extend(welcome_lines);
2060                all
2061            } else {
2062                welcome_lines
2063            };
2064            let welcome_height = (lines.len() as u16).min(visible_chat_area.height);
2065            let welcome_area = Rect::new(
2066                visible_chat_area.x,
2067                visible_chat_area.y + visible_chat_area.height.saturating_sub(welcome_height) / 2,
2068                visible_chat_area.width,
2069                welcome_height,
2070            );
2071            let welcome = Paragraph::new(lines).alignment(Alignment::Center);
2072            frame.render_widget(welcome, welcome_area);
2073        }
2074    } else {
2075        let lines = transcript_lines(state, width);
2076        let available = visible_chat_area.height as usize;
2077        let max_scroll = lines.len().saturating_sub(available).min(u16::MAX as usize) as u16;
2078        let scroll = if state.auto_scroll {
2079            max_scroll
2080        } else {
2081            state.scroll.min(max_scroll)
2082        };
2083        let total_lines = lines.len();
2084        let transcript = Paragraph::new(lines).scroll((scroll, 0));
2085        frame.render_widget(transcript, visible_chat_area);
2086        if !state.auto_scroll {
2087            draw_transcript_scrollbar(frame, visible_chat_area, total_lines, max_scroll, scroll);
2088        }
2089    }
2090
2091    let activity_now = Instant::now();
2092    let activity_elapsed = state.console_animation_elapsed_at(activity_now);
2093    let console_visibility = state.console_visibility_at(activity_now);
2094    apply_tui_glow(
2095        frame,
2096        full_area,
2097        input_chunk,
2098        activity_elapsed,
2099        console_visibility,
2100    );
2101    // Keep the reflection in the existing transcript gap. On constrained
2102    // layouts the row above the console belongs to the transcript instead.
2103    if chat_chunk.y.saturating_add(chat_chunk.height) < input_chunk.y {
2104        apply_console_top_reflection(frame, input_chunk, activity_elapsed, console_visibility);
2105    }
2106    if let Some(layout) = welcome_image_layout {
2107        let image = welcome_image(layout.image_size);
2108        frame.render_widget(TuiImage::new(image.as_ref()), layout.image_area);
2109    }
2110    if let Some(picker_area) = picker_area {
2111        draw_skill_picker(frame, state, picker_area);
2112    }
2113
2114    if let Some(queue_area) = queue_area {
2115        draw_message_queue(frame, state, queue_area);
2116    }
2117
2118    let input_text_style = Style::default().fg(Color::White);
2119    let prompt_area = prompt_area(input_chunk, state);
2120    let prompt = input_display_text(state);
2121    let input_rows = input_visible_rows(state, prompt_area.width).clamp(1, MAX_INPUT_ROWS);
2122    let wrapped = wrap_text(&prompt, prompt_area.width.max(1) as usize);
2123    let visible = (wrapped.len() as u16)
2124        .clamp(1, input_rows)
2125        .min(prompt_area.height);
2126    let cursor_row = cursor_row(&prompt, state.cursor, prompt_area.width.max(1) as usize);
2127    let bottom_scroll = (wrapped.len() as u16).saturating_sub(visible);
2128    let cursor_scroll = (cursor_row + 1).saturating_sub(visible);
2129    let input_scroll = cursor_scroll.min(bottom_scroll);
2130    let active_skill_trigger = (!state.busy)
2131        .then(|| active_skill_trigger(&prompt, &state.skill_names))
2132        .flatten();
2133    let input_lines = styled_text_lines(
2134        &prompt,
2135        active_skill_trigger,
2136        prompt_area.width.max(1) as usize,
2137        input_text_style,
2138    );
2139    let input = Paragraph::new(input_lines)
2140        .style(input_text_style)
2141        .scroll((input_scroll, 0));
2142    frame.render_widget(input, prompt_area);
2143
2144    let effort = state.effort.as_deref().unwrap_or("default");
2145    frame.render_widget(
2146        Paragraph::new(model_status_line(state, effort, status_area.width)),
2147        status_area,
2148    );
2149
2150    apply_console_background(frame, input_chunk, activity_elapsed, console_visibility);
2151    if let Some(settings) = &state.settings {
2152        draw_settings(frame, settings, area);
2153    }
2154
2155    // A frame cursor makes Ratatui issue `Show` after every redraw. Only set
2156    // one while focused, so background glow redraws cannot re-show it.
2157    if state.terminal_focused && state.settings.is_none() && !prompt_area.is_empty() && visible > 0
2158    {
2159        let cursor_prefix: String = prompt.chars().take(state.cursor).collect();
2160        let cursor_rows = wrap_text(&cursor_prefix, prompt_area.width.max(1) as usize);
2161        let cursor_line = cursor_rows.last().map(String::as_str).unwrap_or("");
2162        let cursor_offset = UnicodeWidthStr::width(cursor_line) as u16;
2163        let cursor_x = prompt_area.x + cursor_offset.min(prompt_area.width.saturating_sub(1));
2164        let cursor_y = prompt_area.y
2165            + cursor_row
2166                .saturating_sub(input_scroll)
2167                .min(prompt_area.height.saturating_sub(1));
2168        frame.set_cursor_position((cursor_x, cursor_y));
2169    }
2170}
2171
2172fn draw_message_queue(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
2173    if area.is_empty() || state.queued_messages.is_empty() {
2174        return;
2175    }
2176
2177    let chrome = Style::default().fg(SECTION_CHROME_COLOR);
2178    let message = Style::default().fg(QUEUED_MESSAGE_COLOR);
2179    let mut lines = vec![Line::styled("Queued", chrome)];
2180    lines.extend(
2181        state
2182            .queued_messages
2183            .iter()
2184            .take(area.height.saturating_sub(1) as usize)
2185            .enumerate()
2186            .map(|(index, queued)| {
2187                Line::from(vec![
2188                    Span::styled("│ ", chrome),
2189                    Span::styled(
2190                        format!("{}) {}", index + 1, single_line_preview(queued)),
2191                        message,
2192                    ),
2193                ])
2194            }),
2195    );
2196    frame.render_widget(Paragraph::new(lines), area);
2197}
2198
2199fn single_line_preview(text: &str) -> String {
2200    truncate_output(&text.replace(['\n', '\r'], " ↵ "))
2201}
2202
2203enum SettingsState {
2204    Loading,
2205    Applying {
2206        model: String,
2207        effort: Option<String>,
2208    },
2209    Error(String),
2210    Models {
2211        models: Vec<ProviderModel>,
2212        query: String,
2213        focus: usize,
2214    },
2215    Effort {
2216        model: ProviderModel,
2217        input: String,
2218        focus: usize,
2219    },
2220}
2221fn draw_settings(frame: &mut Frame<'_>, settings: &SettingsState, area: Rect) {
2222    let width = area
2223        .width
2224        .saturating_sub(2)
2225        .min(SETTINGS_MAX_WIDTH)
2226        .max(SETTINGS_MIN_WIDTH.min(area.width));
2227    let height = area
2228        .height
2229        .saturating_sub(2)
2230        .min(SETTINGS_MAX_HEIGHT)
2231        .max(SETTINGS_MIN_HEIGHT.min(area.height));
2232    let popup = Rect::new(
2233        area.x + area.width.saturating_sub(width) / 2,
2234        area.y + area.height.saturating_sub(height) / 2,
2235        width,
2236        height,
2237    );
2238    frame.render_widget(Clear, popup);
2239    let block = Block::default()
2240        .title(" /settings ")
2241        .borders(Borders::ALL)
2242        .border_style(Style::default().fg(Color::Cyan));
2243    let inner = block.inner(popup);
2244    frame.render_widget(block, popup);
2245
2246    let lines = match settings {
2247        SettingsState::Loading => vec![
2248            Line::styled("Loading provider models…", Style::default().fg(Color::Cyan)),
2249            Line::raw(""),
2250            Line::styled("Esc  cancel", Style::default().fg(Color::DarkGray)),
2251        ],
2252        SettingsState::Applying { model, effort } => vec![
2253            Line::styled("Applying selection…", Style::default().fg(Color::Cyan)),
2254            Line::raw(model.clone()),
2255            Line::raw(format!(
2256                "effort: {}",
2257                effort.as_deref().unwrap_or("default")
2258            )),
2259        ],
2260        SettingsState::Error(error) => vec![
2261            Line::styled("Unable to update settings", Style::default().fg(Color::Red)),
2262            Line::raw(""),
2263            Line::raw(error.clone()),
2264            Line::raw(""),
2265            Line::styled("Enter/Esc  close", Style::default().fg(Color::DarkGray)),
2266        ],
2267        SettingsState::Models {
2268            models,
2269            query,
2270            focus,
2271        } => {
2272            let query_lower = query.to_lowercase();
2273            let filtered = models
2274                .iter()
2275                .filter(|model| model.id.to_lowercase().contains(&query_lower))
2276                .collect::<Vec<_>>();
2277            let focus = (*focus).min(filtered.len().saturating_sub(1));
2278            let list_rows = inner.height.saturating_sub(4) as usize;
2279            let range = selection_range(filtered.len(), focus, list_rows);
2280            let mut lines = vec![
2281                Line::from(vec![
2282                    Span::styled("Model  ", Style::default().fg(Color::DarkGray)),
2283                    Span::styled(
2284                        if query.is_empty() {
2285                            "type to filter…"
2286                        } else {
2287                            query
2288                        },
2289                        Style::default().fg(if query.is_empty() {
2290                            Color::DarkGray
2291                        } else {
2292                            Color::White
2293                        }),
2294                    ),
2295                ]),
2296                Line::styled(
2297                    format!(
2298                        "{} models{}",
2299                        filtered.len(),
2300                        if filtered.is_empty() {
2301                            ""
2302                        } else {
2303                            " · ↑/↓ move · Enter choose"
2304                        }
2305                    ),
2306                    Style::default().fg(Color::DarkGray),
2307                ),
2308            ];
2309            if filtered.is_empty() {
2310                lines.push(Line::styled(
2311                    "No matching models",
2312                    Style::default().fg(Color::Yellow),
2313                ));
2314            } else {
2315                for index in range {
2316                    let selected = index == focus;
2317                    lines.push(Line::styled(
2318                        format!(
2319                            "{} {}",
2320                            if selected { "›" } else { " " },
2321                            filtered[index].id
2322                        ),
2323                        if selected {
2324                            Style::default().fg(Color::Black).bg(Color::Cyan)
2325                        } else {
2326                            Style::default().fg(Color::White)
2327                        },
2328                    ));
2329                }
2330            }
2331            lines.push(Line::styled(
2332                "Esc  cancel",
2333                Style::default().fg(Color::DarkGray),
2334            ));
2335            lines
2336        }
2337        SettingsState::Effort {
2338            model,
2339            input,
2340            focus,
2341        } => {
2342            let mut lines = vec![
2343                Line::styled(model.id.clone(), Style::default().fg(Color::Cyan)),
2344                Line::styled("Reasoning effort", Style::default().fg(Color::DarkGray)),
2345            ];
2346            match &model.efforts {
2347                Some(efforts) => {
2348                    let total = efforts.len() + 1;
2349                    let focus = (*focus).min(total.saturating_sub(1));
2350                    let list_rows = inner.height.saturating_sub(4) as usize;
2351                    for index in selection_range(total, focus, list_rows) {
2352                        let value = if index == 0 {
2353                            "default"
2354                        } else {
2355                            efforts[index - 1].as_str()
2356                        };
2357                        let selected = index == focus;
2358                        lines.push(Line::styled(
2359                            format!("{} {value}", if selected { "›" } else { " " }),
2360                            if selected {
2361                                Style::default().fg(Color::Black).bg(Color::Cyan)
2362                            } else {
2363                                Style::default().fg(Color::White)
2364                            },
2365                        ));
2366                    }
2367                    lines.push(Line::styled(
2368                        "↑/↓ move · Enter save · Esc cancel",
2369                        Style::default().fg(Color::DarkGray),
2370                    ));
2371                }
2372                None => {
2373                    lines.push(Line::raw("Provider did not advertise allowed efforts."));
2374                    lines.push(Line::from(vec![
2375                        Span::styled("Value  ", Style::default().fg(Color::DarkGray)),
2376                        Span::styled(
2377                            if input.is_empty() { "default" } else { input },
2378                            Style::default().fg(Color::White),
2379                        ),
2380                    ]));
2381                    lines.push(Line::styled(
2382                        "Type a value · Enter save · Esc cancel",
2383                        Style::default().fg(Color::DarkGray),
2384                    ));
2385                }
2386            }
2387            lines
2388        }
2389    };
2390    frame.render_widget(Paragraph::new(lines), inner);
2391}
2392
2393fn selection_range(total: usize, focus: usize, max_rows: usize) -> std::ops::Range<usize> {
2394    if total == 0 || max_rows == 0 {
2395        return 0..0;
2396    }
2397    let focus = focus.min(total - 1);
2398    let visible = total.min(max_rows);
2399    let start = focus
2400        .saturating_add(1)
2401        .saturating_sub(visible)
2402        .min(total - visible);
2403    start..start + visible
2404}
2405
2406fn draw_skill_picker(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
2407    let matches = state.matching_skill_names();
2408    let total = matches.len();
2409    if total == 0 || area.is_empty() {
2410        return;
2411    }
2412
2413    // The picker is painted last, over the existing transcript and activity;
2414    // its geometry never participates in the underlying layout.
2415    frame.render_widget(Clear, area);
2416    let inner = Rect::new(
2417        area.x.saturating_add(2),
2418        area.y.saturating_add(1),
2419        area.width.saturating_sub(4),
2420        area.height.saturating_sub(2),
2421    );
2422    let buffer = frame.buffer_mut();
2423    for y in area.y..area.y.saturating_add(area.height) {
2424        for x in area.x..area.x.saturating_add(area.width) {
2425            buffer[(x, y)].set_bg(SKILL_PICKER_BACKGROUND);
2426        }
2427    }
2428    if inner.is_empty() {
2429        return;
2430    }
2431
2432    let focus = state.skill_picker_focus.min(total - 1);
2433    let header = Line::styled(
2434        format!("[{}/{}]", focus + 1, total),
2435        Style::default().fg(QUEUED_MESSAGE_COLOR),
2436    );
2437    frame.render_widget(
2438        Paragraph::new(header),
2439        Rect::new(inner.x, inner.y, inner.width, 1),
2440    );
2441
2442    let item_rows = inner.height.saturating_sub(1) as usize;
2443    for (row, index) in selection_range(total, focus, item_rows).enumerate() {
2444        let mut style = Style::default().fg(QUEUED_MESSAGE_COLOR);
2445        if index == focus {
2446            style = style.add_modifier(Modifier::BOLD);
2447        }
2448        let skill = Line::styled(format!("/{}", matches[index]), style);
2449        frame.render_widget(
2450            Paragraph::new(skill),
2451            Rect::new(inner.x, inner.y + 1 + row as u16, inner.width, 1),
2452        );
2453    }
2454}
2455
2456fn greeting_image_enabled() -> bool {
2457    std::env::var("LUCY_GREETING_IMAGE").as_deref() == Ok("true")
2458}
2459
2460#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2461struct WelcomeImageLayout {
2462    image_area: Rect,
2463    intro_area: Rect,
2464    image_size: Size,
2465}
2466
2467fn welcome_image_layout(area: Rect, intro_height: u16) -> Option<WelcomeImageLayout> {
2468    let available_height = area
2469        .height
2470        .saturating_sub(intro_height.saturating_add(WELCOME_IMAGE_GAP));
2471    let max_width = area.width.min(GREETING_IMAGE_SIZE.width);
2472    let max_height = available_height.min(GREETING_IMAGE_SIZE.height);
2473    let aspect_width = GREETING_IMAGE_SIZE.width / GREETING_IMAGE_SIZE.height;
2474    let image_height = max_height.min(max_width / aspect_width);
2475    let image_size = Size::new(image_height * aspect_width, image_height);
2476    if image_size.width < GREETING_IMAGE_MIN_SIZE.width
2477        || image_size.height < GREETING_IMAGE_MIN_SIZE.height
2478    {
2479        return None;
2480    }
2481
2482    let group_height = image_size.height + WELCOME_IMAGE_GAP + intro_height;
2483    let group_y = area.y + area.height.saturating_sub(group_height) / 2;
2484    Some(WelcomeImageLayout {
2485        image_area: Rect::new(
2486            area.x + (area.width - image_size.width) / 2,
2487            group_y,
2488            image_size.width,
2489            image_size.height,
2490        ),
2491        intro_area: Rect::new(
2492            area.x,
2493            group_y + image_size.height + WELCOME_IMAGE_GAP,
2494            area.width,
2495            intro_height,
2496        ),
2497        image_size,
2498    })
2499}
2500
2501type WelcomeImageCache = Mutex<HashMap<(u16, u16), Arc<Protocol>>>;
2502
2503fn welcome_image(size: Size) -> Arc<Protocol> {
2504    static IMAGES: OnceLock<WelcomeImageCache> = OnceLock::new();
2505    let images = IMAGES.get_or_init(|| Mutex::new(HashMap::new()));
2506    let mut images = images
2507        .lock()
2508        .expect("welcome image cache should not be poisoned");
2509    images
2510        .entry((size.width, size.height))
2511        .or_insert_with(|| {
2512            let image = image::load_from_memory(GREETING_IMAGE_BYTES)
2513                .expect("embedded greeting PNG should decode");
2514            let image = dim_welcome_image(image);
2515            Arc::new(
2516                Picker::halfblocks()
2517                    .new_protocol(image, size, Resize::Fit(None))
2518                    .expect("embedded greeting PNG should convert to halfblocks"),
2519            )
2520        })
2521        .clone()
2522}
2523
2524fn dim_welcome_image(image: image::DynamicImage) -> image::DynamicImage {
2525    let mut image = image.to_rgba8();
2526    for pixel in image.pixels_mut() {
2527        for channel in pixel.0.iter_mut().take(3) {
2528            *channel = (u16::from(*channel) * WELCOME_IMAGE_BRIGHTNESS_PERCENT / 100) as u8;
2529        }
2530    }
2531    image::DynamicImage::ImageRgba8(image)
2532}
2533
2534fn logo_lines() -> Vec<Line<'static>> {
2535    let max_width = LOGO_TEXT
2536        .lines()
2537        .map(|line| line.chars().count())
2538        .max()
2539        .unwrap_or(0);
2540    LOGO_TEXT
2541        .lines()
2542        .map(|line| {
2543            let spans: Vec<Span> = line
2544                .chars()
2545                .enumerate()
2546                .map(|(index, character)| {
2547                    let progress = if max_width <= 1 {
2548                        0.0
2549                    } else {
2550                        index as f32 / (max_width - 1) as f32
2551                    };
2552                    let color = Color::Rgb(
2553                        interpolate_color(LOGO_START_COLOR.0, LOGO_END_COLOR.0, progress),
2554                        interpolate_color(LOGO_START_COLOR.1, LOGO_END_COLOR.1, progress),
2555                        interpolate_color(LOGO_START_COLOR.2, LOGO_END_COLOR.2, progress),
2556                    );
2557                    Span::styled(character.to_string(), Style::default().fg(color))
2558                })
2559                .collect();
2560            Line::from(spans)
2561        })
2562        .collect()
2563}
2564
2565fn welcome_line() -> Line<'static> {
2566    let character_count = WELCOME_MESSAGE.chars().count();
2567    let spans = WELCOME_MESSAGE
2568        .chars()
2569        .enumerate()
2570        .map(|(index, character)| {
2571            let progress = if character_count <= 1 {
2572                0.0
2573            } else {
2574                index as f32 / (character_count - 1) as f32
2575            };
2576            let color = Color::Rgb(
2577                interpolate_color(WELCOME_START_COLOR.0, WELCOME_END_COLOR.0, progress),
2578                interpolate_color(WELCOME_START_COLOR.1, WELCOME_END_COLOR.1, progress),
2579                interpolate_color(WELCOME_START_COLOR.2, WELCOME_END_COLOR.2, progress),
2580            );
2581            Span::styled(character.to_string(), Style::default().fg(color))
2582        })
2583        .collect::<Vec<_>>();
2584    Line::from(spans)
2585}
2586
2587fn interpolate_color(start: u8, end: u8, progress: f32) -> u8 {
2588    (start as f32 + (end as f32 - start as f32) * progress).round() as u8
2589}
2590
2591fn welcome_lines(attached_agents: &[String]) -> Vec<Line<'static>> {
2592    let mut lines = vec![
2593        welcome_line(),
2594        Line::styled(WELCOME_VERSION, Style::default().fg(Color::DarkGray)),
2595        Line::raw(""),
2596        Line::styled(WELCOME_TAGLINE, Style::default().fg(Color::DarkGray)),
2597        Line::raw(""),
2598    ];
2599
2600    if attached_agents.is_empty() {
2601        lines.push(Line::styled(
2602            "Attached AGENTS.md: none",
2603            Style::default().fg(Color::DarkGray),
2604        ));
2605    } else {
2606        lines.push(Line::styled(
2607            "Attached AGENTS.md:",
2608            Style::default().fg(Color::DarkGray),
2609        ));
2610        lines.extend(
2611            attached_agents.iter().map(|path| {
2612                Line::styled(format!("• {path}"), Style::default().fg(Color::DarkGray))
2613            }),
2614        );
2615    }
2616
2617    lines
2618}
2619
2620fn transcript_lines(state: &UiState, width: u16) -> Vec<Line<'static>> {
2621    render_transcript_items(&state.transcript, width.max(1) as usize, state)
2622}
2623
2624fn render_transcript_items(
2625    transcript: &[TranscriptItem],
2626    width: usize,
2627    state: &UiState,
2628) -> Vec<Line<'static>> {
2629    let mut lines = Vec::new();
2630    let mut rendered_item = false;
2631
2632    for (index, item) in transcript.iter().enumerate() {
2633        // Results are positioned on their matching call, even when the model
2634        // emitted several calls before execution produced any result.
2635        if is_result_attached_to_call(transcript, index) {
2636            continue;
2637        }
2638        if rendered_item {
2639            lines.push(Line::raw(String::new()));
2640        }
2641        match item {
2642            TranscriptItem::User {
2643                text,
2644                skill_instruction_attached,
2645            } => {
2646                let text = redact_secret(text, Some(&state.secret));
2647                let trigger = skill_instruction_attached
2648                    .then(|| active_skill_trigger(&text, &state.skill_names))
2649                    .flatten();
2650                push_user_message_block(&mut lines, &text, trigger, width);
2651            }
2652            TranscriptItem::Assistant(text) => {
2653                let text = redact_secret(text, Some(&state.secret));
2654                push_wrapped(&mut lines, &text, width, Style::default());
2655            }
2656            TranscriptItem::ToolCall {
2657                id,
2658                name,
2659                arguments,
2660            } => {
2661                let result = matching_tool_result(transcript, index, id);
2662                let segments = if name == "cmd" {
2663                    cmd_tool_segments(id, arguments, result, state)
2664                } else {
2665                    generic_tool_segments(name, arguments, result, state)
2666                };
2667                push_spans_wrapped(&mut lines, &segments, width);
2668            }
2669            TranscriptItem::ToolResult {
2670                id: _,
2671                name: _,
2672                result,
2673            } => {
2674                let result_text = format_tool_result(result);
2675                let result_text = redact_secret(&result_text, Some(&state.secret));
2676                push_spans_wrapped(&mut lines, &[(result_text, tool_result_style())], width);
2677            }
2678            TranscriptItem::Error(text) => {
2679                let text = redact_secret(text, Some(&state.secret));
2680                push_wrapped(&mut lines, &text, width, error_style());
2681            }
2682            TranscriptItem::Info(text) => {
2683                let text = redact_secret(text, Some(&state.secret));
2684                push_wrapped(&mut lines, &text, width, info_style());
2685            }
2686            TranscriptItem::Reasoning { complete } => {
2687                let text = if *complete {
2688                    "Reasoning Complete".to_owned()
2689                } else {
2690                    format!("Reasoning... {}", spinner_frame(state))
2691                };
2692                push_wrapped(&mut lines, &text, width, thinking_style());
2693            }
2694        }
2695        rendered_item = true;
2696    }
2697    if lines.is_empty() {
2698        lines.push(Line::raw(""));
2699    }
2700    lines
2701}
2702
2703/// Tool work uses its own clock instead of the main status animation.
2704fn running_tool_status(state: &UiState) -> String {
2705    tool_spinner_frame(state)
2706}
2707
2708fn cmd_tool_segments(
2709    call_id: &str,
2710    arguments: &str,
2711    result: Option<&Value>,
2712    state: &UiState,
2713) -> Vec<(String, Style)> {
2714    let command = redact_secret(&command_display(arguments), Some(&state.secret));
2715    if let Some(result) = result {
2716        let (icon, status, status_style) = cmd_result_status(result);
2717        if status == "done" || state.cmd_result_started_at.contains_key(call_id) {
2718            let text = if status == "done" {
2719                format!("{icon} cmd  $ {command}")
2720            } else {
2721                format!("{icon} cmd  $ {command}  → {status}")
2722            };
2723            return cmd_result_segments(call_id, &text, cmd_result_target_color(result), state);
2724        }
2725        vec![
2726            (format!("{icon} cmd  $ {command}  → "), status_style),
2727            (status, status_style),
2728        ]
2729    } else {
2730        vec![
2731            (format!("· cmd  $ {command}  "), pending_tool_call_style()),
2732            (running_tool_status(state), pending_tool_call_style()),
2733        ]
2734    }
2735}
2736
2737/// During the brief post-result window, turn the compact `cmd` line from the
2738/// pending orange into its final result colour one character at a time. A few
2739/// adjacent characters blend at the leading edge so the visual is a true
2740/// gradient, rather than a hard colour boundary.
2741fn cmd_result_segments(
2742    call_id: &str,
2743    text: &str,
2744    target: Color,
2745    state: &UiState,
2746) -> Vec<(String, Style)> {
2747    let now = Instant::now();
2748    let Some(started_at) = state.cmd_result_started_at.get(call_id).copied() else {
2749        return vec![(text.to_owned(), Style::default().fg(target))];
2750    };
2751    if now.saturating_duration_since(started_at) >= TOOL_RESULT_SWEEP_DURATION {
2752        return vec![(text.to_owned(), Style::default().fg(target))];
2753    }
2754
2755    let character_count = text.chars().count();
2756    text.chars()
2757        .enumerate()
2758        .map(|(index, character)| {
2759            (
2760                character.to_string(),
2761                Style::default().fg(cmd_result_color_at(
2762                    started_at,
2763                    now,
2764                    index,
2765                    character_count,
2766                    target,
2767                )),
2768            )
2769        })
2770        .collect()
2771}
2772
2773fn cmd_result_color_at(
2774    started_at: Instant,
2775    now: Instant,
2776    character_index: usize,
2777    character_count: usize,
2778    target: Color,
2779) -> Color {
2780    let elapsed = now.saturating_duration_since(started_at);
2781    if elapsed >= TOOL_RESULT_SWEEP_DURATION {
2782        return target;
2783    }
2784
2785    let progress = elapsed.as_secs_f32() / TOOL_RESULT_SWEEP_DURATION.as_secs_f32();
2786    let character_position = if character_count <= 1 {
2787        0.0
2788    } else {
2789        character_index as f32 / (character_count - 1) as f32
2790    };
2791    let fade_start = character_position * (1.0 - TOOL_RESULT_CHARACTER_FADE_PORTION);
2792    let character_progress =
2793        ((progress - fade_start) / TOOL_RESULT_CHARACTER_FADE_PORTION).clamp(0.0, 1.0);
2794    let character_progress =
2795        character_progress * character_progress * (3.0 - 2.0 * character_progress);
2796    let (target_red, target_green, target_blue) = tool_result_color_rgb(target);
2797    Color::Rgb(
2798        interpolate_color(PENDING_TOOL_COLOR_RGB.0, target_red, character_progress),
2799        interpolate_color(PENDING_TOOL_COLOR_RGB.1, target_green, character_progress),
2800        interpolate_color(PENDING_TOOL_COLOR_RGB.2, target_blue, character_progress),
2801    )
2802}
2803
2804fn command_display(arguments: &str) -> String {
2805    serde_json::from_str::<Value>(arguments)
2806        .ok()
2807        .and_then(|value| {
2808            value
2809                .get("command")
2810                .and_then(Value::as_str)
2811                .map(str::to_owned)
2812        })
2813        .map(|command| truncate_tool_call(&command))
2814        .unwrap_or_else(|| truncate_tool_call(arguments))
2815}
2816
2817fn cmd_result_target_color(result: &Value) -> Color {
2818    if result
2819        .get("canceled")
2820        .and_then(Value::as_bool)
2821        .unwrap_or(false)
2822        || result
2823            .get("timed_out")
2824            .and_then(Value::as_bool)
2825            .unwrap_or(false)
2826    {
2827        return TOOL_WARNING_COLOR;
2828    }
2829    if result.get("error").is_some()
2830        || matches!(result.get("exit_code").and_then(Value::as_i64), Some(code) if code != 0)
2831    {
2832        return TOOL_FAILURE_COLOR;
2833    }
2834    TOOL_SUCCESS_COLOR
2835}
2836
2837fn tool_result_color_rgb(color: Color) -> (u8, u8, u8) {
2838    let Color::Rgb(red, green, blue) = color else {
2839        unreachable!("cmd result transition colours are RGB")
2840    };
2841    (red, green, blue)
2842}
2843
2844fn cmd_result_status(result: &Value) -> (char, String, Style) {
2845    let target = cmd_result_target_color(result);
2846    if result
2847        .get("canceled")
2848        .and_then(Value::as_bool)
2849        .unwrap_or(false)
2850    {
2851        return ('!', "canceled".to_owned(), Style::default().fg(target));
2852    }
2853    if result
2854        .get("timed_out")
2855        .and_then(Value::as_bool)
2856        .unwrap_or(false)
2857    {
2858        return ('!', "timeout".to_owned(), Style::default().fg(target));
2859    }
2860    if result.get("error").is_some() {
2861        return ('×', "error".to_owned(), Style::default().fg(target));
2862    }
2863    match result.get("exit_code").and_then(Value::as_i64) {
2864        Some(0) => ('✓', "done".to_owned(), Style::default().fg(target)),
2865        Some(code) => ('×', format!("exit {code}"), Style::default().fg(target)),
2866        None => ('✓', "done".to_owned(), Style::default().fg(target)),
2867    }
2868}
2869
2870fn generic_tool_segments(
2871    name: &str,
2872    arguments: &str,
2873    result: Option<&Value>,
2874    state: &UiState,
2875) -> Vec<(String, Style)> {
2876    let call_text = redact_secret(
2877        &format!("[tool:{name} {}]", call_arguments(arguments)),
2878        Some(&state.secret),
2879    );
2880    let mut segments = vec![(
2881        call_text,
2882        if result.is_some() {
2883            tool_call_style()
2884        } else {
2885            pending_tool_call_style()
2886        },
2887    )];
2888    if let Some(result) = result {
2889        let result_text = redact_secret(&format_tool_result(result), Some(&state.secret));
2890        segments.push((" > ".to_owned(), Style::default()));
2891        segments.push((result_text, tool_result_style()));
2892    } else {
2893        segments.push((
2894            format!(" {}", tool_spinner_frame(state)),
2895            pending_tool_call_style(),
2896        ));
2897    }
2898    segments
2899}
2900
2901fn matching_tool_result<'a>(
2902    transcript: &'a [TranscriptItem],
2903    call_index: usize,
2904    call_id: &str,
2905) -> Option<&'a Value> {
2906    transcript
2907        .iter()
2908        .skip(call_index + 1)
2909        .find_map(|item| match item {
2910            TranscriptItem::ToolResult { id, result, .. } if id == call_id => Some(result),
2911            _ => None,
2912        })
2913}
2914
2915fn is_result_attached_to_call(transcript: &[TranscriptItem], result_index: usize) -> bool {
2916    let TranscriptItem::ToolResult { id, .. } = &transcript[result_index] else {
2917        return false;
2918    };
2919    let Some(call_index) = transcript[..result_index].iter().rposition(
2920        |item| matches!(item, TranscriptItem::ToolCall { id: call_id, .. } if call_id == id),
2921    ) else {
2922        return false;
2923    };
2924    !transcript[call_index + 1..result_index].iter().any(
2925        |item| matches!(item, TranscriptItem::ToolResult { id: result_id, .. } if result_id == id),
2926    )
2927}
2928
2929const TOOL_CALL_PREVIEW_CHARS: usize = 100;
2930
2931fn truncate_tool_call(output: &str) -> String {
2932    let mut result: String = output.chars().take(TOOL_CALL_PREVIEW_CHARS).collect();
2933    if output.chars().count() > TOOL_CALL_PREVIEW_CHARS {
2934        result.push('…');
2935    }
2936    result
2937}
2938
2939/// Render tool call arguments as the command string inside double quotes, for
2940/// example `"cat README.md"`. Tool-call previews are limited to 100 characters;
2941/// malformed arguments fall back to the same bounded raw-text preview.
2942fn call_arguments(arguments: &str) -> String {
2943    let parsed: Value = match serde_json::from_str(arguments) {
2944        Ok(value) => value,
2945        Err(_) => return truncate_tool_call(arguments),
2946    };
2947    if let Some(command) = parsed.get("command").and_then(Value::as_str) {
2948        return format!("\"{}\"", truncate_tool_call(command));
2949    }
2950    let serialized = serde_json::to_string(&parsed).unwrap_or_else(|_| arguments.to_owned());
2951    truncate_tool_call(&serialized)
2952}
2953
2954/// Render a tool result as a single-line JSON-string-array literal containing
2955/// stdout (or stderr when stdout is empty). Newlines are escaped so the whole
2956/// result stays on one line. Output is truncated to `RESULT_PREVIEW_CHARS`.
2957fn format_tool_result(result: &Value) -> String {
2958    let stdout = result.get("stdout").and_then(Value::as_str).unwrap_or("");
2959    let stderr = result.get("stderr").and_then(Value::as_str).unwrap_or("");
2960    let output = if !stdout.is_empty() { stdout } else { stderr };
2961    let truncated = truncate_output(output);
2962    // Build a JSON string literal so newlines and quotes are escaped and the
2963    // result renders on a single line as `["..."]`.
2964    let json_string = serde_json::to_string(&truncated).unwrap_or_else(|_| "\"\"".to_owned());
2965    format!("[{json_string}]")
2966}
2967
2968const RESULT_PREVIEW_CHARS: usize = 50;
2969
2970fn truncate_output(output: &str) -> String {
2971    let mut result: String = output.chars().take(RESULT_PREVIEW_CHARS).collect();
2972    if output.chars().count() > RESULT_PREVIEW_CHARS {
2973        result.push('…');
2974    }
2975    result
2976}
2977
2978fn user_message_style() -> Style {
2979    Style::default().fg(USER_BORDER_COLOR)
2980}
2981
2982/// Render user messages with a one-cell yellow block rule, one inner left
2983/// padding cell, and blank rows above and below; assistant and tool output remains borderless.
2984fn push_user_message_block(
2985    lines: &mut Vec<Line<'static>>,
2986    text: &str,
2987    active_skill_trigger: Option<&str>,
2988    width: usize,
2989) {
2990    if width < 3 {
2991        lines.extend(styled_text_lines(
2992            text,
2993            active_skill_trigger,
2994            width.max(1),
2995            Style::default().fg(Color::White),
2996        ));
2997        return;
2998    }
2999
3000    let border_style = user_message_style();
3001    let rows = styled_text_lines(
3002        text,
3003        active_skill_trigger,
3004        width - 2,
3005        Style::default().fg(Color::White),
3006    );
3007    lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
3008    for row in rows {
3009        let mut spans = Vec::with_capacity(row.spans.len() + 2);
3010        spans.push(Span::styled(USER_BORDER_GLYPH, border_style));
3011        spans.push(Span::styled(" ", Style::default().fg(Color::White)));
3012        spans.extend(row.spans);
3013        lines.push(Line::from(spans));
3014    }
3015    lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
3016}
3017
3018fn tool_call_style() -> Style {
3019    Style::default().fg(Color::Magenta)
3020}
3021
3022fn pending_tool_call_style() -> Style {
3023    Style::default().fg(PENDING_TOOL_COLOR)
3024}
3025
3026fn tool_result_style() -> Style {
3027    Style::default().fg(Color::DarkGray)
3028}
3029
3030fn error_style() -> Style {
3031    Style::default().fg(Color::Red)
3032}
3033
3034fn info_style() -> Style {
3035    Style::default().fg(Color::DarkGray)
3036}
3037
3038fn context_status_text(state: &UiState) -> String {
3039    let used = format_context_tokens(state.context_tokens);
3040    let Some(window) = state.context_window else {
3041        return format!("Context: {used}/? (?%) ??????????");
3042    };
3043    let percentage = context_percentage(state.context_tokens, window);
3044    format!(
3045        "Context: {used}/{} ({percentage}%) {}",
3046        format_context_tokens(window),
3047        context_progress_bar(state.context_tokens, window)
3048    )
3049}
3050
3051fn context_progress_bar(used: usize, window: usize) -> String {
3052    const WIDTH: usize = 10;
3053    let filled = if window == 0 {
3054        0
3055    } else {
3056        (used as u128 * WIDTH as u128)
3057            .div_ceil(window as u128)
3058            .min(WIDTH as u128) as usize
3059    };
3060    format!("{}{}", "█".repeat(filled), "░".repeat(WIDTH - filled))
3061}
3062
3063fn context_status_style(_state: &UiState) -> Style {
3064    Style::default().fg(CONSOLE_STATUS_COLOR)
3065}
3066
3067fn context_percentage(used: usize, window: usize) -> usize {
3068    if window == 0 {
3069        return 0;
3070    }
3071    ((used as u128 * 100).div_ceil(window as u128)) as usize
3072}
3073
3074fn format_context_tokens(tokens: usize) -> String {
3075    if tokens >= 1_000_000 {
3076        format!("{:.2}M", tokens as f64 / 1_000_000.0)
3077    } else if tokens >= 1_000 {
3078        format!("{:.1}K", tokens as f64 / 1_000.0)
3079    } else {
3080        tokens.to_string()
3081    }
3082}
3083
3084fn model_status_line(state: &UiState, effort: &str, width: u16) -> Line<'static> {
3085    model_status_line_at(
3086        state,
3087        effort,
3088        state.console_animation_elapsed_at(Instant::now()),
3089        width,
3090    )
3091}
3092
3093fn model_status_line_at(
3094    state: &UiState,
3095    effort: &str,
3096    elapsed: Duration,
3097    width: u16,
3098) -> Line<'static> {
3099    let model = redact_secret(&state.model, Some(&state.secret));
3100    let effort = redact_secret(effort, Some(&state.secret));
3101    let context = context_status_text(state);
3102    let context_width = UnicodeWidthStr::width(context.as_str());
3103    let model_style = if state.busy {
3104        Style::default().fg(console_accent_at(elapsed))
3105    } else {
3106        context_status_style(state)
3107    };
3108    let status_style = context_status_style(state);
3109    let mut spans = vec![
3110        Span::styled(model, model_style),
3111        Span::styled(format!(" · {effort}"), status_style),
3112    ];
3113    if state.busy {
3114        let accent = console_accent_at(elapsed);
3115        let (head, _) = busy_indicator_position_at(elapsed);
3116        spans.push(Span::raw(" "));
3117        for (index, character) in busy_indicator_frame_at(elapsed).chars().enumerate() {
3118            let distance = if character == BUSY_INDICATOR_BLOCK && index != head {
3119                Some(index.abs_diff(head))
3120            } else {
3121                None
3122            };
3123            let color = busy_indicator_color(accent, distance);
3124            spans.push(Span::styled(
3125                character.to_string(),
3126                Style::default().fg(color),
3127            ));
3128        }
3129    }
3130    let left_width = spans
3131        .iter()
3132        .map(|span| UnicodeWidthStr::width(span.content.as_ref()))
3133        .sum::<usize>();
3134    let gap = usize::from(width).saturating_sub(left_width + context_width);
3135    if gap > 0 {
3136        spans.push(Span::raw(" ".repeat(gap)));
3137    }
3138    spans.push(Span::styled(context, status_style));
3139    Line::from(spans)
3140}
3141
3142fn blend_rgb(from: Color, to: Color, progress: f32) -> Color {
3143    let (from_red, from_green, from_blue) = activity_rgb(from);
3144    let (to_red, to_green, to_blue) = activity_rgb(to);
3145    Color::Rgb(
3146        interpolate_color(from_red, to_red, progress),
3147        interpolate_color(from_green, to_green, progress),
3148        interpolate_color(from_blue, to_blue, progress),
3149    )
3150}
3151
3152fn activity_rgb(color: Color) -> (u8, u8, u8) {
3153    match color {
3154        Color::Rgb(red, green, blue) => (red, green, blue),
3155        // The resting indicator uses ratatui's named cyan. Convert it to its
3156        // ANSI RGB equivalent while blending into and out of the accent cycle.
3157        Color::Cyan => (0, 255, 255),
3158        _ => unreachable!("activity transition colours are cyan or RGB"),
3159    }
3160}
3161
3162fn console_reach_at(elapsed: Duration) -> f32 {
3163    let phase = elapsed.as_secs_f32() / CONSOLE_BOUNDARY_CYCLE.as_secs_f32();
3164    let midpoint = (CONSOLE_REACH_MIN + CONSOLE_REACH_MAX) / 2.0;
3165    let amplitude = (CONSOLE_REACH_MAX - CONSOLE_REACH_MIN) / 2.0;
3166    midpoint + amplitude * (phase * std::f32::consts::TAU).sin()
3167}
3168
3169fn console_accent_cycle() -> Duration {
3170    CONSOLE_ACCENT_CYCLE_DURATION
3171}
3172
3173fn console_accent_at(elapsed: Duration) -> Color {
3174    let cycle_progress =
3175        (elapsed.as_secs_f32() / console_accent_cycle().as_secs_f32()).rem_euclid(1.0);
3176    let progress = if cycle_progress <= 0.5 {
3177        cycle_progress * 2.0
3178    } else {
3179        (1.0 - cycle_progress) * 2.0
3180    };
3181    desaturate_console_accent(
3182        interpolate_color(CONSOLE_ACCENT_LAVENDER.0, CONSOLE_ACCENT_TEAL.0, progress),
3183        interpolate_color(CONSOLE_ACCENT_LAVENDER.1, CONSOLE_ACCENT_TEAL.1, progress),
3184        interpolate_color(CONSOLE_ACCENT_LAVENDER.2, CONSOLE_ACCENT_TEAL.2, progress),
3185    )
3186}
3187
3188fn desaturate_console_accent(red: u8, green: u8, blue: u8) -> Color {
3189    let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
3190    Color::Rgb(
3191        interpolate_color(red, neutral, CONSOLE_ACCENT_DESATURATION),
3192        interpolate_color(green, neutral, CONSOLE_ACCENT_DESATURATION),
3193        interpolate_color(blue, neutral, CONSOLE_ACCENT_DESATURATION),
3194    )
3195}
3196
3197/// Return a smooth half-elliptical falloff beneath the console.
3198///
3199/// The TUI's bottom edge cuts the ellipse on its horizontal centreline, so the
3200/// visible upper half grows wider toward the bottom. The glow is never taller
3201/// than `GLOW_HEIGHT` terminal rows and reaches farther horizontally than it
3202/// does vertically.
3203fn glow_coverage_at(column: u16, row: u16, canvas: Rect, console_area: Rect) -> f32 {
3204    let canvas_bottom = canvas.y.saturating_add(canvas.height);
3205    if canvas.is_empty() || row < canvas.y || row >= canvas_bottom {
3206        return 0.0;
3207    }
3208
3209    let left = console_area.x.max(canvas.x);
3210    let right = console_area
3211        .x
3212        .saturating_add(console_area.width)
3213        .min(canvas.x.saturating_add(canvas.width));
3214    if left >= right {
3215        return 0.0;
3216    }
3217
3218    let x = canvas.x.saturating_add(column);
3219    let center_x = (left as f32 + right.saturating_sub(1) as f32) / 2.0;
3220    let horizontal_radius = (right - left) as f32 / 2.0 + GLOW_HORIZONTAL_SPREAD as f32;
3221    let horizontal_distance = (x as f32 - center_x).abs() / horizontal_radius;
3222    // Sample the lower edge of each cell: the bottom edge of the TUI is the
3223    // ellipse's centreline while its visible half remains within the canvas.
3224    let vertical_distance =
3225        (row.saturating_add(1) as f32 - canvas_bottom as f32).abs() / GLOW_HEIGHT as f32;
3226    let distance = horizontal_distance.hypot(vertical_distance);
3227    let falloff = (1.0 - distance).clamp(0.0, 1.0);
3228    falloff * falloff * (3.0 - 2.0 * falloff)
3229}
3230
3231fn glow_accent_at(elapsed: Duration) -> Color {
3232    glow_accent_with_desaturation_at(elapsed, GLOW_DESATURATION)
3233}
3234
3235fn glow_accent_with_desaturation_at(elapsed: Duration, desaturation: f32) -> Color {
3236    let (red, green, blue) = activity_rgb(console_accent_at(elapsed));
3237    let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
3238    Color::Rgb(
3239        interpolate_color(red, neutral, desaturation),
3240        interpolate_color(green, neutral, desaturation),
3241        interpolate_color(blue, neutral, desaturation),
3242    )
3243}
3244
3245fn glow_color_at(
3246    elapsed: Duration,
3247    column: u16,
3248    _width: u16,
3249    row: u16,
3250    canvas: Rect,
3251    console_area: Rect,
3252    visibility: f32,
3253) -> Option<Color> {
3254    let visibility = visibility.clamp(0.0, 1.0);
3255    let bottom = canvas.y.saturating_add(canvas.height);
3256    if visibility <= 0.0 || row < canvas.y || row >= bottom {
3257        return None;
3258    }
3259
3260    let coverage = glow_coverage_at(column, row, canvas, console_area);
3261    if coverage <= 0.0 {
3262        return None;
3263    }
3264
3265    let intensity =
3266        (console_reach_at(elapsed) / CONSOLE_REACH_MAX) * GLOW_INTENSITY * coverage * visibility;
3267    Some(blend_rgb(
3268        TUI_GLOW_BACKGROUND,
3269        glow_accent_at(elapsed),
3270        intensity,
3271    ))
3272}
3273
3274fn apply_tui_glow(
3275    frame: &mut Frame<'_>,
3276    canvas: Rect,
3277    console_area: Rect,
3278    elapsed: Duration,
3279    visibility: f32,
3280) {
3281    let left = console_area
3282        .x
3283        .saturating_sub(GLOW_HORIZONTAL_SPREAD)
3284        .max(canvas.x);
3285    let right = console_area
3286        .x
3287        .saturating_add(console_area.width)
3288        .saturating_add(GLOW_HORIZONTAL_SPREAD)
3289        .min(canvas.x.saturating_add(canvas.width));
3290    let bottom = canvas.y.saturating_add(canvas.height);
3291    let top = bottom.saturating_sub(GLOW_HEIGHT).max(canvas.y);
3292    let buffer = frame.buffer_mut();
3293    for y in top..bottom {
3294        for x in left..right {
3295            if let Some(color) = glow_color_at(
3296                elapsed,
3297                x.saturating_sub(canvas.x),
3298                canvas.width,
3299                y,
3300                canvas,
3301                console_area,
3302                visibility,
3303            ) {
3304                buffer[(x, y)].set_bg(color);
3305            }
3306        }
3307    }
3308}
3309
3310fn console_glass_color_at(elapsed: Duration, glow: Color, visibility: f32) -> Color {
3311    let (red, green, blue) = activity_rgb(console_accent_at(elapsed));
3312    let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
3313    let glass_accent = Color::Rgb(
3314        interpolate_color(red, neutral, CONSOLE_GLASS_DESATURATION),
3315        interpolate_color(green, neutral, CONSOLE_GLASS_DESATURATION),
3316        interpolate_color(blue, neutral, CONSOLE_GLASS_DESATURATION),
3317    );
3318    let visibility = visibility.clamp(0.0, 1.0);
3319    let tint = blend_rgb(
3320        CONSOLE_BACKGROUND,
3321        glass_accent,
3322        CONSOLE_GLASS_TINT * visibility,
3323    );
3324    let white_tinted = blend_rgb(
3325        tint,
3326        Color::Rgb(255, 255, 255),
3327        CONSOLE_GLASS_WHITE_TINT * visibility,
3328    );
3329    blend_rgb(white_tinted, glow, CONSOLE_GLASS_GLOW_THROUGH * visibility)
3330}
3331
3332/// Return the faint accent colour used by the external glass reflection.
3333fn console_top_reflection_color_at(elapsed: Duration, background: Color, visibility: f32) -> Color {
3334    let visibility = visibility.clamp(0.0, 1.0);
3335    let reflected = blend_rgb(
3336        background,
3337        glow_accent_at(elapsed),
3338        CONSOLE_REFLECTION_TINT * visibility,
3339    );
3340    blend_rgb(
3341        reflected,
3342        Color::Rgb(255, 255, 255),
3343        CONSOLE_REFLECTION_WHITE_TINT * visibility,
3344    )
3345}
3346
3347/// Draw a one-eighth-cell reflection on the bottom edge of the row immediately
3348/// above the console. This leaves the console's own background uniform while
3349/// keeping the effect thin in terminals that render block glyphs.
3350fn apply_console_top_reflection(
3351    frame: &mut Frame<'_>,
3352    area: Rect,
3353    elapsed: Duration,
3354    visibility: f32,
3355) {
3356    if visibility <= 0.0 || area.y == 0 {
3357        return;
3358    }
3359
3360    let y = area.y - 1;
3361    let buffer = frame.buffer_mut();
3362    for x in area.x..area.x.saturating_add(area.width) {
3363        let background = match buffer[(x, y)].bg {
3364            Color::Rgb(_, _, _) => buffer[(x, y)].bg,
3365            _ => TUI_GLOW_BACKGROUND,
3366        };
3367        buffer[(x, y)].set_symbol(CONSOLE_REFLECTION_GLYPH).set_fg(
3368            console_top_reflection_color_at(elapsed, background, visibility),
3369        );
3370    }
3371}
3372
3373/// Composite the dark, low-saturation glass only over the console rectangle.
3374fn apply_console_background(frame: &mut Frame<'_>, area: Rect, elapsed: Duration, visibility: f32) {
3375    let buffer = frame.buffer_mut();
3376    for y in area.y..area.y.saturating_add(area.height) {
3377        for x in area.x..area.x.saturating_add(area.width) {
3378            let glow = match buffer[(x, y)].bg {
3379                Color::Rgb(_, _, _) => buffer[(x, y)].bg,
3380                _ => TUI_GLOW_BACKGROUND,
3381            };
3382            buffer[(x, y)].set_bg(console_glass_color_at(elapsed, glow, visibility));
3383        }
3384    }
3385}
3386
3387fn thinking_style() -> Style {
3388    Style::default().fg(Color::DarkGray)
3389}
3390
3391// Unicode block elements occupy the full cell width. Rendering them without
3392// separators keeps the five bars visually continuous in terminal fonts.
3393const PULSE_LEVELS: [char; 7] = ['▁', '▂', '▃', '▅', '▆', '▇', '█'];
3394const PULSE_BAR_PERIODS: [u128; 5] = [12, 16, 20, 24, 15];
3395const PULSE_BAR_PHASES: [u128; 5] = [0, 5, 13, 9, 3];
3396const BUSY_INDICATOR_TRACK_LENGTH: usize = 5;
3397const BUSY_INDICATOR_TAIL_LENGTH: usize = 2;
3398const BUSY_INDICATOR_WIDTH: usize = BUSY_INDICATOR_TRACK_LENGTH + BUSY_INDICATOR_TAIL_LENGTH;
3399const BUSY_INDICATOR_BLOCK: char = '■';
3400const BUSY_INDICATOR_TAIL_OPACITY: [f32; BUSY_INDICATOR_TAIL_LENGTH] = [0.55, 0.25];
3401const BUSY_INDICATOR_PERIOD_TICKS: u128 = (BUSY_INDICATOR_TRACK_LENGTH as u128 - 1) * 2;
3402// 62.5ms per cell makes the busy indicator move at 80% of its former speed.
3403const BUSY_INDICATOR_TICK: Duration = Duration::from_micros(62_500);
3404const PULSE_TICK: Duration = Duration::from_millis(50);
3405const TOOL_SPINNER_FRAMES: [char; 4] = ['|', '/', '-', '\\'];
3406const TOOL_SPINNER_FRAME_DURATION: Duration = Duration::from_millis(100);
3407
3408/// Five independently phased triangle waves make the bars feel irregular
3409/// without random jumps: every rendered tick changes a bar by at most one
3410/// level, and the combined pattern repeats every 12 seconds.
3411const ACTIVITY_TRANSITION_DURATION: Duration = Duration::from_millis(400);
3412// This frame gives all five bars room to rise from the resting level while
3413// preserving the pulse waveform's one-level-per-tick continuity afterwards.
3414const PULSE_ENTRY_FRAME: Duration = Duration::from_millis(950);
3415
3416fn spinner_frame(state: &UiState) -> String {
3417    pulse_frame(state.activity_levels_at(Instant::now()))
3418}
3419
3420#[cfg(test)]
3421fn spinner_frame_at(elapsed: Duration) -> String {
3422    pulse_frame(pulse_levels_at(elapsed))
3423}
3424
3425/// A compact, traditional spinner for tool calls that are awaiting a result.
3426/// It deliberately has a separate epoch because background work can outlive a
3427/// main-agent turn.
3428fn tool_spinner_frame(state: &UiState) -> String {
3429    tool_spinner_frame_at(state.tool_animation_epoch.elapsed()).to_string()
3430}
3431
3432fn tool_spinner_frame_at(elapsed: Duration) -> char {
3433    let frame = (elapsed.as_millis() / TOOL_SPINNER_FRAME_DURATION.as_millis()) as usize;
3434    TOOL_SPINNER_FRAMES[frame % TOOL_SPINNER_FRAMES.len()]
3435}
3436
3437fn pulse_frame(levels: [usize; PULSE_BAR_PERIODS.len()]) -> String {
3438    levels
3439        .into_iter()
3440        .map(|level| PULSE_LEVELS[level])
3441        .collect()
3442}
3443
3444fn busy_indicator_position_at(elapsed: Duration) -> (usize, bool) {
3445    let tick = elapsed.as_micros() / BUSY_INDICATOR_TICK.as_micros();
3446    let phase = tick % BUSY_INDICATOR_PERIOD_TICKS;
3447    if phase < BUSY_INDICATOR_TRACK_LENGTH as u128 {
3448        (
3449            phase as usize,
3450            phase < BUSY_INDICATOR_TRACK_LENGTH as u128 - 1,
3451        )
3452    } else {
3453        ((BUSY_INDICATOR_PERIOD_TICKS - phase) as usize, false)
3454    }
3455}
3456
3457fn busy_indicator_frame_at(elapsed: Duration) -> String {
3458    let (head, moving_right) = busy_indicator_position_at(elapsed);
3459    let mut frame = vec![' '; BUSY_INDICATOR_WIDTH];
3460    frame[head] = BUSY_INDICATOR_BLOCK;
3461    for distance in 1..=BUSY_INDICATOR_TAIL_LENGTH {
3462        let tail = if moving_right {
3463            head.checked_sub(distance)
3464        } else {
3465            head.checked_add(distance)
3466        };
3467        if let Some(tail) = tail.filter(|&index| index < BUSY_INDICATOR_WIDTH) {
3468            frame[tail] = BUSY_INDICATOR_BLOCK;
3469        }
3470    }
3471    frame.into_iter().collect()
3472}
3473
3474/// Terminals do not support alpha in a cell foreground, so fade the tail by
3475/// blending the accent toward the console background color.
3476fn busy_indicator_color(accent: Color, distance: Option<usize>) -> Color {
3477    let Some(distance) = distance else {
3478        return accent;
3479    };
3480    let Color::Rgb(red, green, blue) = accent else {
3481        return accent;
3482    };
3483    let opacity = BUSY_INDICATOR_TAIL_OPACITY
3484        .get(distance.saturating_sub(1))
3485        .copied()
3486        .unwrap_or(0.0);
3487    Color::Rgb(
3488        interpolate_color(CONSOLE_BACKGROUND_RGB.0, red, opacity),
3489        interpolate_color(CONSOLE_BACKGROUND_RGB.1, green, opacity),
3490        interpolate_color(CONSOLE_BACKGROUND_RGB.2, blue, opacity),
3491    )
3492}
3493fn pulse_levels_at(elapsed: Duration) -> [usize; PULSE_BAR_PERIODS.len()] {
3494    let tick = elapsed.as_millis() / PULSE_TICK.as_millis();
3495    std::array::from_fn(|index| {
3496        pulse_level_at(tick, PULSE_BAR_PERIODS[index], PULSE_BAR_PHASES[index])
3497    })
3498}
3499
3500fn interpolate_pulse_levels(
3501    from: [usize; PULSE_BAR_PERIODS.len()],
3502    to: [usize; PULSE_BAR_PERIODS.len()],
3503    elapsed: Duration,
3504) -> [usize; PULSE_BAR_PERIODS.len()] {
3505    let elapsed = elapsed.min(ACTIVITY_TRANSITION_DURATION).as_millis();
3506    let duration = ACTIVITY_TRANSITION_DURATION.as_millis();
3507    std::array::from_fn(|index| {
3508        let start = from[index] as i128;
3509        let distance = to[index] as i128 - start;
3510        (start + distance * elapsed as i128 / duration as i128) as usize
3511    })
3512}
3513
3514fn pulse_level_at(tick: u128, period: u128, phase: u128) -> usize {
3515    let position = (tick + phase) % period;
3516    let half_period = period / 2;
3517    let distance_from_floor = if position <= half_period {
3518        position
3519    } else {
3520        period - position
3521    };
3522    (distance_from_floor * (PULSE_LEVELS.len() - 1) as u128 / half_period) as usize
3523}
3524
3525fn push_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, style: Style) {
3526    let mut added = false;
3527    for piece in wrap_text(text, width) {
3528        lines.push(Line::styled(piece, style));
3529        added = true;
3530    }
3531    if !added {
3532        lines.push(Line::styled(String::new(), style));
3533    }
3534}
3535
3536/// Push a logical line built from styled segments. When the rendered width
3537/// exceeds `width`, the whole line is character-wrapped; wrapped continuations
3538/// keep the style of the segment they fall on.
3539fn push_spans_wrapped(lines: &mut Vec<Line<'static>>, segments: &[(String, Style)], width: usize) {
3540    let mut current_spans: Vec<Span<'static>> = Vec::new();
3541    let mut current_width = 0usize;
3542    for (text, style) in segments {
3543        for character in text.chars() {
3544            let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
3545            if current_width + char_width > width && !current_spans.is_empty() {
3546                lines.push(Line::from(std::mem::take(&mut current_spans)));
3547                current_width = 0;
3548            }
3549            let mut buffer = [0u8; 4];
3550            let s = character.encode_utf8(&mut buffer);
3551            current_spans.push(Span::styled(s.to_owned(), *style));
3552            current_width += char_width;
3553        }
3554    }
3555    if current_spans.is_empty() {
3556        current_spans.push(Span::raw(String::new()));
3557    }
3558    lines.push(Line::from(current_spans));
3559}
3560
3561/// Wrap `text` into rows no wider than `width` display columns. Wrapping is
3562/// character-based so the row count matches exactly what a non-wrapping
3563/// `Paragraph` renderer draws, which keeps auto-scroll pinned to the true
3564/// bottom of the transcript regardless of terminal width. Empty lines are
3565/// preserved as empty rows.
3566fn wrap_text(text: &str, width: usize) -> Vec<String> {
3567    if width == 0 {
3568        return text.lines().map(str::to_owned).collect();
3569    }
3570    let mut rows = Vec::new();
3571    // `split` preserves a trailing empty row, so Shift+Enter renders an
3572    // immediate new line even before another character is typed.
3573    for line in text.split('\n') {
3574        rows.extend(wrap_line(line, width));
3575    }
3576    if rows.is_empty() {
3577        rows.push(String::new());
3578    }
3579    rows
3580}
3581
3582fn wrap_line(line: &str, width: usize) -> Vec<String> {
3583    let mut rows = Vec::new();
3584    let mut current = String::new();
3585    let mut current_width = 0usize;
3586    for character in line.chars() {
3587        let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
3588        if current_width + char_width > width && !current.is_empty() {
3589            rows.push(std::mem::take(&mut current));
3590            current_width = 0;
3591        }
3592        current.push(character);
3593        current_width += char_width;
3594    }
3595    rows.push(current);
3596    rows
3597}
3598
3599#[cfg(test)]
3600mod tests {
3601    use super::*;
3602
3603    #[test]
3604    fn turn_notifications_use_osc_777_with_fixed_secret_safe_messages() {
3605        let cases = [
3606            (TurnNotification::Completed, "Turn complete"),
3607            (TurnNotification::Interrupted, "Turn interrupted"),
3608            (TurnNotification::Failed, "Turn failed"),
3609        ];
3610
3611        for (notification, body) in cases {
3612            let mut output = Vec::new();
3613            send_turn_notification(&mut output, notification).expect("notification");
3614            assert_eq!(
3615                output,
3616                format!("\x1b]777;notify;Lucy;{body}\x07").into_bytes()
3617            );
3618        }
3619    }
3620
3621    #[test]
3622    fn turn_notifications_follow_the_terminal_turn_status() {
3623        assert_eq!(
3624            turn_notification_for_status("finalizing"),
3625            TurnNotification::Completed
3626        );
3627        assert_eq!(
3628            turn_notification_for_status("cancelling"),
3629            TurnNotification::Interrupted
3630        );
3631        assert_eq!(
3632            turn_notification_for_status("error"),
3633            TurnNotification::Failed
3634        );
3635    }
3636
3637    struct FailingWriter;
3638
3639    impl Write for FailingWriter {
3640        fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
3641            Err(io::Error::other("notification sink unavailable"))
3642        }
3643
3644        fn flush(&mut self) -> io::Result<()> {
3645            Err(io::Error::other("notification sink unavailable"))
3646        }
3647    }
3648
3649    #[test]
3650    fn notification_write_failure_does_not_keep_the_tui_busy() {
3651        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3652        state.busy = true;
3653        state.active_cancel = Some(CancellationToken::new());
3654        let mut writer = FailingWriter;
3655
3656        release_finished_turn(&mut writer, &mut state);
3657
3658        assert!(!state.busy);
3659        assert!(state.active_cancel.is_none());
3660    }
3661
3662    #[test]
3663    fn an_idle_finish_does_not_emit_a_duplicate_notification() {
3664        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3665        let mut output = Vec::new();
3666
3667        release_finished_turn(&mut output, &mut state);
3668
3669        assert!(output.is_empty());
3670    }
3671
3672    #[test]
3673    fn context_status_shows_used_window_and_percentage_in_uniform_gray() {
3674        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3675            .with_context(Some(100_000), 80_000);
3676
3677        assert_eq!(
3678            context_status_text(&state),
3679            "Context: 80.0K/100.0K (80%) ████████░░"
3680        );
3681        assert_eq!(
3682            context_status_style(&state).fg,
3683            Some(Color::Rgb(144, 144, 148))
3684        );
3685
3686        state.context_tokens = 80_001;
3687        assert_eq!(
3688            context_status_text(&state),
3689            "Context: 80.0K/100.0K (81%) █████████░"
3690        );
3691        assert_eq!(
3692            context_status_style(&state).fg,
3693            Some(Color::Rgb(144, 144, 148)),
3694            "crossing the compaction threshold does not recolor the status line"
3695        );
3696    }
3697
3698    #[test]
3699    fn context_status_keeps_percentage_consistent_at_capacity() {
3700        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3701            .with_context(Some(100_000), 99_001);
3702
3703        assert_eq!(
3704            context_status_text(&state),
3705            "Context: 99.0K/100.0K (100%) ██████████"
3706        );
3707
3708        state.context_tokens = 100_000;
3709        assert_eq!(
3710            context_status_text(&state),
3711            "Context: 100.0K/100.0K (100%) ██████████"
3712        );
3713
3714        state.context_tokens = 100_001;
3715        assert_eq!(
3716            context_status_text(&state),
3717            "Context: 100.0K/100.0K (101%) ██████████"
3718        );
3719    }
3720
3721    #[test]
3722    fn context_status_handles_unknown_window_without_highlighting() {
3723        let state = UiState::from_history(&[], "secret", "model", None, false);
3724
3725        assert_eq!(context_status_text(&state), "Context: 1/? (?%) ??????????");
3726        assert_eq!(
3727            context_status_style(&state).fg,
3728            Some(Color::Rgb(144, 144, 148))
3729        );
3730    }
3731
3732    #[test]
3733    fn tui_viewport_reserves_one_column_on_each_side_when_possible() {
3734        assert_eq!(
3735            tui_viewport(Rect::new(0, 0, 80, 10)),
3736            Rect::new(1, 0, 78, 10)
3737        );
3738        assert_eq!(
3739            tui_viewport(Rect::new(0, 0, 2, 10)),
3740            Rect::new(0, 0, 2, 10),
3741            "a two-column terminal cannot reserve two gutters"
3742        );
3743    }
3744
3745    #[test]
3746    fn tui_viewport_caps_at_one_hundred_columns_and_centers_it() {
3747        assert_eq!(
3748            tui_viewport(Rect::new(0, 0, 140, 10)),
3749            Rect::new(20, 0, TUI_MAX_WIDTH, 10)
3750        );
3751        assert_eq!(
3752            tui_viewport(Rect::new(0, 0, 103, 10)),
3753            Rect::new(1, 0, TUI_MAX_WIDTH, 10),
3754            "an odd remaining column stays on the right"
3755        );
3756    }
3757
3758    #[test]
3759    fn bottom_console_has_external_margins_without_losing_internal_padding() {
3760        let state = UiState::from_history(&[], "secret", "model", None, false);
3761        let viewport = tui_viewport(Rect::new(0, 0, 80, 14));
3762        let (chat, _, _, _, console, _) = ui_layout(&state, viewport);
3763        let content = console_content_area(console);
3764
3765        assert_eq!(chat.x, console.x);
3766        assert_eq!(chat.width, console.width);
3767        assert_eq!(
3768            console,
3769            Rect::new(viewport.x + 7, 8, viewport.width - 14, 5)
3770        );
3771        assert_eq!(console.y + console.height, viewport.y + viewport.height - 1);
3772        assert_eq!(content.x, console.x + 2);
3773        assert_eq!(content.width, console.width - 4);
3774        assert_eq!(content.y, console.y + 1);
3775        assert_eq!(content.y + content.height, console.y + console.height - 1);
3776
3777        for (width, margin, console_width) in [
3778            (1, 0, 1),
3779            (2, 0, 2),
3780            (3, 1, 1),
3781            (4, 1, 2),
3782            (5, 2, 1),
3783            (15, 0, 15),
3784        ] {
3785            let console = bottom_console_area(Rect::new(0, 0, width, 4), 0, 4);
3786            assert_eq!(console.x, margin, "width {width}");
3787            assert_eq!(console.width, console_width, "width {width}");
3788        }
3789    }
3790
3791    #[test]
3792    fn inset_console_width_drives_prompt_rows_and_vertical_navigation() {
3793        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3794        state.input = "x".repeat(71);
3795        let viewport = tui_viewport(Rect::new(0, 0, 80, 14));
3796        let console = ui_layout(&state, viewport).4;
3797        let prompt = prompt_area(console, &state);
3798
3799        assert_eq!(ui_prompt_content_width(viewport), prompt.width);
3800        assert_eq!(prompt.width, 60);
3801        assert_eq!(input_visible_rows(&state, prompt.width), 2);
3802        assert!(move_input_cursor_vertical(
3803            &mut state,
3804            ui_prompt_content_width(viewport) as usize,
3805            true,
3806        ));
3807    }
3808
3809    #[test]
3810    fn context_status_is_right_aligned_in_uniform_gray() {
3811        let state =
3812            UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
3813        let mut terminal =
3814            Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
3815
3816        terminal
3817            .draw(|frame| draw(frame, &state))
3818            .expect("draw statusline");
3819
3820        let buffer = terminal.backend().buffer();
3821        let status_area = ui_layout(&state, tui_viewport(Rect::new(0, 0, 80, 10))).5;
3822        let expected_context = "Context: 81/100 (81%) █████████░";
3823        let rendered = (status_area.x..status_area.x + status_area.width)
3824            .map(|x| buffer[(x, status_area.y)].symbol())
3825            .collect::<String>();
3826        assert!(rendered.ends_with(expected_context));
3827        assert_eq!(
3828            buffer[(status_area.x + status_area.width - 1, status_area.y)].symbol(),
3829            "░",
3830            "context is not pushed to the right edge"
3831        );
3832        assert!(rendered.starts_with("model · default"));
3833        for x in status_area.x..status_area.x + status_area.width {
3834            if buffer[(x, status_area.y)].symbol() != " " {
3835                assert_eq!(buffer[(x, status_area.y)].fg, CONSOLE_STATUS_COLOR);
3836            }
3837        }
3838    }
3839
3840    #[test]
3841    fn busy_model_name_and_indicator_share_the_animated_accent_gradient() {
3842        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3843        state.busy = true;
3844        let start = model_status_line_at(&state, "default", Duration::ZERO, 80);
3845        let middle = model_status_line_at(&state, "default", console_accent_cycle() / 2, 80);
3846        let start_accent = console_accent_at(Duration::ZERO);
3847        let middle_accent = console_accent_at(console_accent_cycle() / 2);
3848
3849        assert_eq!(start.spans[0].style.fg, Some(start_accent));
3850        assert_eq!(middle.spans[0].style.fg, Some(middle_accent));
3851        assert_eq!(start.spans[0].content, "model");
3852        assert_eq!(start.spans[1].content, " · default");
3853        assert_eq!(start.spans[2].content, " ");
3854        assert_eq!(start.spans[3].content, BUSY_INDICATOR_BLOCK.to_string());
3855        assert_eq!(start.spans[3].style.fg, Some(start_accent));
3856        assert_eq!(
3857            start.spans.last().unwrap().style.fg,
3858            Some(CONSOLE_STATUS_COLOR)
3859        );
3860    }
3861
3862    #[test]
3863    fn idle_model_status_has_no_busy_indicator() {
3864        let state = UiState::from_history(&[], "secret", "model", None, false);
3865        let start = model_status_line_at(&state, "default", Duration::ZERO, 80);
3866        let middle = model_status_line_at(&state, "default", console_accent_cycle() / 2, 80);
3867
3868        assert_eq!(start.spans[0].content, "model");
3869        assert_eq!(middle.spans[0].content, "model");
3870        assert_eq!(start.spans[0].style.fg, Some(CONSOLE_STATUS_COLOR));
3871        assert_eq!(middle.spans[0].style.fg, Some(CONSOLE_STATUS_COLOR));
3872    }
3873
3874    #[test]
3875    fn busy_indicator_is_a_five_cell_bounce_with_a_two_cell_tail() {
3876        let frames = (0..=BUSY_INDICATOR_PERIOD_TICKS)
3877            .map(|tick| busy_indicator_frame_at(BUSY_INDICATOR_TICK * tick as u32))
3878            .collect::<Vec<_>>();
3879
3880        assert_eq!(frames[0], "■      ");
3881        assert_eq!(frames[1], "■■     ");
3882        assert_eq!(frames[2], "■■■    ");
3883        assert_eq!(frames[4], "    ■■■");
3884        assert_eq!(frames[5], "   ■■■ ");
3885        assert_eq!(frames[7], " ■■■   ");
3886        assert_eq!(frames[8], frames[0]);
3887        assert!(frames
3888            .iter()
3889            .all(|frame| frame.chars().count() == BUSY_INDICATOR_WIDTH));
3890        assert_eq!(BUSY_INDICATOR_TRACK_LENGTH, 5);
3891        assert_eq!(BUSY_INDICATOR_TAIL_LENGTH, 2);
3892        assert_eq!(BUSY_INDICATOR_TICK, Duration::from_micros(62_500));
3893    }
3894
3895    fn color_distance_from_console(color: Color) -> u32 {
3896        let Color::Rgb(red, green, blue) = color else {
3897            return 0;
3898        };
3899        u32::from(red.abs_diff(CONSOLE_BACKGROUND_RGB.0))
3900            + u32::from(green.abs_diff(CONSOLE_BACKGROUND_RGB.1))
3901            + u32::from(blue.abs_diff(CONSOLE_BACKGROUND_RGB.2))
3902    }
3903
3904    #[test]
3905    fn busy_indicator_tail_uses_same_block_with_progressively_fainter_colors() {
3906        let accent = Color::Rgb(180, 120, 240);
3907        let near = busy_indicator_color(accent, Some(1));
3908        let far = busy_indicator_color(accent, Some(2));
3909
3910        assert_eq!(BUSY_INDICATOR_BLOCK, '■');
3911        assert_ne!(near, accent);
3912        assert_ne!(far, near);
3913        assert!(color_distance_from_console(near) > color_distance_from_console(far));
3914    }
3915
3916    #[test]
3917    fn pulse_spinner_moves_each_bar_one_level_at_a_time() {
3918        let frames = (0..=240)
3919            .map(|tick| spinner_frame_at(PULSE_TICK * tick))
3920            .collect::<Vec<_>>();
3921        assert!(frames.iter().any(|frame| frame != &frames[0]));
3922        assert_eq!(PULSE_TICK, Duration::from_millis(50));
3923
3924        for pair in frames.windows(2) {
3925            let levels = pair
3926                .iter()
3927                .map(|frame| {
3928                    frame
3929                        .chars()
3930                        .map(|bar| {
3931                            PULSE_LEVELS
3932                                .iter()
3933                                .position(|level| *level == bar)
3934                                .expect("known pulse level")
3935                        })
3936                        .collect::<Vec<_>>()
3937                })
3938                .collect::<Vec<_>>();
3939            assert_eq!(levels[0].len(), 5);
3940            assert!(
3941                levels[0]
3942                    .iter()
3943                    .zip(&levels[1])
3944                    .all(|(before, after)| before.abs_diff(*after) <= 1),
3945                "pulse bars must not jump between adjacent ticks: {:?} -> {:?}",
3946                pair[0],
3947                pair[1]
3948            );
3949        }
3950    }
3951
3952    #[test]
3953    fn console_animation_clock_runs_during_entry_and_survives_active_status_changes() {
3954        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3955        state.set_status("working");
3956        let epoch = state.console_animation_epoch;
3957        assert_eq!(
3958            state.console_animation_elapsed_at(epoch + Duration::from_millis(200)),
3959            Duration::from_millis(200),
3960            "the console animation does not freeze during the activity ramp"
3961        );
3962
3963        state.set_status("compacting");
3964        assert_eq!(state.console_animation_epoch, epoch);
3965        state.set_status("working");
3966        assert_eq!(state.console_animation_epoch, epoch);
3967    }
3968
3969    fn color_distance(from: Color, to: Color) -> u16 {
3970        let (from_red, from_green, from_blue) = activity_rgb(from);
3971        let (to_red, to_green, to_blue) = activity_rgb(to);
3972        u16::from(from_red.abs_diff(to_red))
3973            + u16::from(from_green.abs_diff(to_green))
3974            + u16::from(from_blue.abs_diff(to_blue))
3975    }
3976
3977    fn color_saturation(color: Color) -> u8 {
3978        let (red, green, blue) = activity_rgb(color);
3979        red.max(green).max(blue) - red.min(green).min(blue)
3980    }
3981
3982    fn color_luminance(color: Color) -> u32 {
3983        let (red, green, blue) = activity_rgb(color);
3984        299 * u32::from(red) + 587 * u32::from(green) + 114 * u32::from(blue)
3985    }
3986
3987    #[test]
3988    fn glow_coverage_is_a_bottom_anchored_half_ellipse_with_a_twelve_row_cap() {
3989        let canvas = Rect::new(0, 0, 160, 40);
3990        let console = Rect::new(40, 28, 80, 7);
3991        let bottom = canvas.y + canvas.height;
3992        let center_x = console.x + console.width / 2 - 1;
3993        let outer_x = center_x + 35;
3994        let active_rows = (canvas.y..bottom)
3995            .filter(|&row| glow_coverage_at(center_x, row, canvas, console) > 0.0)
3996            .collect::<Vec<_>>();
3997
3998        assert_eq!(GLOW_HEIGHT, 12);
3999        assert_eq!(GLOW_HORIZONTAL_SPREAD, 24);
4000        assert_eq!(
4001            active_rows,
4002            (bottom - GLOW_HEIGHT..bottom).collect::<Vec<_>>()
4003        );
4004        assert_eq!(
4005            glow_coverage_at(center_x, bottom - GLOW_HEIGHT - 1, canvas, console),
4006            0.0,
4007            "the glow never exceeds its twelve-row cap"
4008        );
4009        assert_eq!(
4010            glow_coverage_at(center_x, bottom - 1, canvas, console),
4011            glow_coverage_at(center_x + 1, bottom - 1, canvas, console),
4012            "the bottom edge intersects the ellipse at its horizontal centreline"
4013        );
4014        assert_eq!(
4015            glow_coverage_at(outer_x, bottom - GLOW_HEIGHT, canvas, console),
4016            0.0,
4017            "the top of the half ellipse stays narrow"
4018        );
4019        assert!(
4020            glow_coverage_at(outer_x, bottom - 1, canvas, console) > 0.0,
4021            "the exposed glow grows wider toward the TUI bottom"
4022        );
4023        assert!(
4024            glow_coverage_at(console.x + console.width + 23, bottom - 1, canvas, console) > 0.0,
4025            "the glow extends twenty-four cells beyond each console edge"
4026        );
4027    }
4028
4029    #[test]
4030    fn wider_console_has_a_wider_elliptical_side_falloff() {
4031        let canvas = Rect::new(0, 0, 200, 20);
4032        let wide_console = Rect::new(50, 12, 100, 7);
4033        let narrow_console = Rect::new(50, 12, 4, 7);
4034        let bottom_row = canvas.y + canvas.height - 1;
4035        let offset_from_center = 60;
4036        let wide_sample = wide_console.x + wide_console.width / 2 + offset_from_center;
4037        let narrow_sample = narrow_console.x + narrow_console.width / 2 + offset_from_center;
4038
4039        let wide = glow_coverage_at(wide_sample, bottom_row, canvas, wide_console);
4040        let narrow = glow_coverage_at(narrow_sample, bottom_row, canvas, narrow_console);
4041
4042        assert!(wide > 0.0);
4043        assert_eq!(narrow, 0.0);
4044        assert!(
4045            wide > narrow,
4046            "the horizontal radius follows the console width to form an ellipse rather than fixed endpoint circles"
4047        );
4048    }
4049
4050    #[test]
4051    fn exposed_bloom_is_not_tinted_as_console_glass() {
4052        let canvas = Rect::new(0, 0, 80, 20);
4053        let console = Rect::new(20, 12, 40, 7);
4054        let elapsed = CONSOLE_BOUNDARY_CYCLE / 4;
4055        let source_row = canvas.y + canvas.height - 1;
4056        let exposed = (console.x - 1, source_row);
4057        let inside = (console.x, console.y + console.height - 1);
4058        let exposed_glow = glow_color_at(
4059            elapsed,
4060            exposed.0,
4061            canvas.width,
4062            exposed.1,
4063            canvas,
4064            console,
4065            1.0,
4066        )
4067        .expect("endpoint bloom");
4068        let inside_glow = glow_color_at(
4069            elapsed,
4070            inside.0,
4071            canvas.width,
4072            inside.1,
4073            canvas,
4074            console,
4075            1.0,
4076        )
4077        .expect("source segment glow");
4078        let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
4079            canvas.width,
4080            canvas.height,
4081        ))
4082        .expect("test terminal");
4083
4084        terminal
4085            .draw(|frame| {
4086                apply_tui_glow(frame, canvas, console, elapsed, 1.0);
4087                apply_console_background(frame, console, elapsed, 1.0);
4088                let buffer = frame.buffer_mut();
4089                assert_eq!(buffer[exposed].bg, exposed_glow);
4090                assert_eq!(
4091                    buffer[inside].bg,
4092                    console_glass_color_at(elapsed, inside_glow, 1.0)
4093                );
4094            })
4095            .expect("render glow and glass");
4096    }
4097
4098    #[test]
4099    fn idle_canvas_has_no_glow() {
4100        let canvas = Rect::new(0, 0, 80, 12);
4101        let console = Rect::new(2, 6, 76, 5);
4102        for row in canvas.y..canvas.y + canvas.height {
4103            for column in 0..canvas.width {
4104                assert_eq!(
4105                    glow_color_at(
4106                        CONSOLE_BOUNDARY_CYCLE / 4,
4107                        column,
4108                        canvas.width,
4109                        row,
4110                        canvas,
4111                        console,
4112                        0.0,
4113                    ),
4114                    None,
4115                    "idle glow never paints the canvas"
4116                );
4117            }
4118        }
4119    }
4120
4121    #[test]
4122    fn console_visibility_transition_is_smooth_and_reversible() {
4123        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4124        state.set_busy(true);
4125        let entering = state
4126            .console_visibility_transition
4127            .expect("entry transition");
4128        assert_eq!(state.console_visibility_at(entering.started_at), 0.0);
4129        let entry_middle =
4130            state.console_visibility_at(entering.started_at + CONSOLE_VISIBILITY_TRANSITION / 2);
4131        assert!((entry_middle - 0.5).abs() < 0.000_1);
4132        assert_eq!(
4133            state.console_visibility_at(entering.started_at + CONSOLE_VISIBILITY_TRANSITION),
4134            1.0
4135        );
4136
4137        state.console_visibility_transition = None;
4138        state.set_busy(false);
4139        let exiting = state
4140            .console_visibility_transition
4141            .expect("exit transition");
4142        assert_eq!(state.console_visibility_at(exiting.started_at), 1.0);
4143        let exit_middle =
4144            state.console_visibility_at(exiting.started_at + CONSOLE_VISIBILITY_TRANSITION / 2);
4145        assert!((exit_middle - 0.5).abs() < 0.000_1);
4146        assert_eq!(
4147            state.console_visibility_at(exiting.started_at + CONSOLE_VISIBILITY_TRANSITION),
4148            0.0
4149        );
4150    }
4151
4152    #[test]
4153    fn rapid_console_reentry_preserves_glow_phase() {
4154        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4155        let canvas = Rect::new(0, 0, 80, 12);
4156        let console = Rect::new(2, 6, 76, 5);
4157        let start = Instant::now();
4158        state.set_busy_at(true, start);
4159        let epoch = state.console_animation_epoch;
4160        state.set_busy_at(false, start + CONSOLE_VISIBILITY_TRANSITION);
4161        let reversal_at = start + CONSOLE_VISIBILITY_TRANSITION * 3 / 2;
4162        let visibility_before = state.console_visibility_at(reversal_at);
4163        let elapsed_before = state.console_animation_elapsed_at(reversal_at);
4164        let color_before = glow_color_at(
4165            elapsed_before,
4166            40,
4167            canvas.width,
4168            canvas.y + canvas.height - 1,
4169            canvas,
4170            console,
4171            visibility_before,
4172        );
4173
4174        state.set_busy_at(true, reversal_at);
4175
4176        assert_eq!(state.console_animation_epoch, epoch);
4177        assert!((state.console_visibility_at(reversal_at) - visibility_before).abs() < 0.000_1);
4178        assert_eq!(
4179            glow_color_at(
4180                state.console_animation_elapsed_at(reversal_at),
4181                40,
4182                canvas.width,
4183                canvas.y + canvas.height - 1,
4184                canvas,
4185                console,
4186                state.console_visibility_at(reversal_at),
4187            ),
4188            color_before,
4189            "reversing an exit keeps the current glow phase"
4190        );
4191    }
4192
4193    #[test]
4194    fn glow_reach_still_controls_light_intensity() {
4195        let long = CONSOLE_BOUNDARY_CYCLE / 4;
4196        let short = CONSOLE_BOUNDARY_CYCLE * 3 / 4;
4197        let canvas = Rect::new(0, 0, 80, 12);
4198        let console = Rect::new(2, 6, 76, 5);
4199        let row = canvas.y + canvas.height - 1;
4200        let long_glow =
4201            glow_color_at(long, 40, canvas.width, row, canvas, console, 1.0).expect("visible glow");
4202        let short_glow = glow_color_at(short, 40, canvas.width, row, canvas, console, 1.0)
4203            .expect("visible glow");
4204
4205        assert!((console_reach_at(long) - CONSOLE_REACH_MAX).abs() < 0.000_1);
4206        assert!((console_reach_at(short) - CONSOLE_REACH_MIN).abs() < 0.000_1);
4207        assert!(
4208            color_distance(TUI_GLOW_BACKGROUND, long_glow)
4209                > color_distance(TUI_GLOW_BACKGROUND, short_glow)
4210        );
4211    }
4212
4213    #[test]
4214    fn maximum_glow_is_brighter_and_more_saturated_than_the_previous_tuning() {
4215        let canvas = Rect::new(0, 0, 160, 20);
4216        let console = Rect::new(40, 12, 80, 7);
4217        let column = console.x + console.width / 2;
4218        let row = canvas.y + canvas.height - 1;
4219        let elapsed = Duration::ZERO;
4220        let coverage = glow_coverage_at(column, row, canvas, console);
4221        assert_eq!(GLOW_INTENSITY, 0.70);
4222        let current = glow_color_at(elapsed, column, canvas.width, row, canvas, console, 1.0)
4223            .expect("bottom-centre glow");
4224        let previous = blend_rgb(
4225            TUI_GLOW_BACKGROUND,
4226            glow_accent_with_desaturation_at(elapsed, 0.16),
4227            (console_reach_at(elapsed) / CONSOLE_REACH_MAX) * 0.62 * coverage,
4228        );
4229
4230        assert!(
4231            color_luminance(current) > color_luminance(previous),
4232            "the adjusted maximum glow is brighter than the previous maximum"
4233        );
4234        assert!(
4235            color_saturation(current) > color_saturation(previous),
4236            "the adjusted maximum glow is more saturated than the previous maximum"
4237        );
4238    }
4239
4240    #[test]
4241    fn glow_tuning_is_brighter_and_more_saturated_than_before() {
4242        let canvas = Rect::new(0, 0, 160, 20);
4243        let console = Rect::new(40, 12, 80, 7);
4244        let column = console.x + console.width / 2;
4245        let row = canvas.y + canvas.height - 1;
4246        let coverage = glow_coverage_at(column, row, canvas, console);
4247
4248        assert_eq!(GLOW_INTENSITY, 0.70);
4249        assert_eq!(GLOW_DESATURATION, 0.10);
4250        for (phase, elapsed) in [
4251            Duration::ZERO,
4252            console_accent_cycle() / 4,
4253            console_accent_cycle() / 2,
4254            console_accent_cycle() * 3 / 4,
4255        ]
4256        .into_iter()
4257        .enumerate()
4258        {
4259            let current_accent = glow_accent_at(elapsed);
4260            let previous_accent = glow_accent_with_desaturation_at(elapsed, 0.16);
4261            assert!(
4262                color_saturation(current_accent) > color_saturation(previous_accent),
4263                "phase {phase}: reducing glow desaturation raises saturation"
4264            );
4265            let current = glow_color_at(elapsed, column, canvas.width, row, canvas, console, 1.0)
4266                .expect("bottom-centre glow");
4267            let previous = blend_rgb(
4268                TUI_GLOW_BACKGROUND,
4269                glow_accent_with_desaturation_at(elapsed, 0.16),
4270                (console_reach_at(elapsed) / CONSOLE_REACH_MAX) * 0.62 * coverage,
4271            );
4272
4273            assert!(
4274                color_luminance(current) > color_luminance(previous),
4275                "phase {phase}: the rendered glow is brighter than the prior tuning"
4276            );
4277            assert!(
4278                color_saturation(current) > color_saturation(previous),
4279                "phase {phase}: the rendered glow is more saturated than the prior tuning"
4280            );
4281        }
4282    }
4283
4284    #[test]
4285    fn console_is_dark_lower_saturation_glass_over_the_glow() {
4286        let elapsed = CONSOLE_BOUNDARY_CYCLE / 4;
4287        let canvas = Rect::new(0, 0, 80, 12);
4288        let console = Rect::new(2, 6, 76, 5);
4289        let glow = glow_color_at(
4290            elapsed,
4291            40,
4292            canvas.width,
4293            canvas.y + canvas.height - 1,
4294            canvas,
4295            console,
4296            1.0,
4297        )
4298        .expect("visible glow");
4299        let glass = console_glass_color_at(elapsed, glow, 1.0);
4300        let solid_glass = console_glass_color_at(elapsed, CONSOLE_BACKGROUND, 1.0);
4301
4302        assert_eq!(
4303            console_glass_color_at(elapsed, glow, 0.0),
4304            CONSOLE_BACKGROUND
4305        );
4306        assert_ne!(glass, CONSOLE_BACKGROUND);
4307        let (glass_red, glass_green, glass_blue) = activity_rgb(glass);
4308        let (glow_red, glow_green, glow_blue) = activity_rgb(glow);
4309        assert!(
4310            u16::from(glass_red) + u16::from(glass_green) + u16::from(glass_blue)
4311                < u16::from(glow_red) + u16::from(glow_green) + u16::from(glow_blue),
4312            "console glass is darker than the exposed glow"
4313        );
4314        assert!(
4315            color_distance(CONSOLE_BACKGROUND, glass) < color_distance(TUI_GLOW_BACKGROUND, glow),
4316            "glass tint is subtler than the exposed glow"
4317        );
4318        assert!(
4319            color_distance(CONSOLE_BACKGROUND, glass)
4320                > color_distance(CONSOLE_BACKGROUND, solid_glass),
4321            "the glow visibly carries through the stronger glass tint"
4322        );
4323        assert!(
4324            color_saturation(glass) < color_saturation(glow),
4325            "glass tint is less saturated than the exposed glow"
4326        );
4327    }
4328
4329    #[test]
4330    fn console_top_reflection_is_a_thin_active_line_in_the_external_gap() {
4331        assert_eq!(CONSOLE_REFLECTION_WHITE_TINT, 0.20);
4332        let elapsed = Duration::ZERO;
4333        let canvas = Rect::new(0, 0, 20, 8);
4334        let console = Rect::new(2, 3, 16, 4);
4335        let reflection_y = console.y - 1;
4336        let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
4337            canvas.width,
4338            canvas.height,
4339        ))
4340        .expect("test terminal");
4341        terminal
4342            .draw(|frame| {
4343                apply_tui_glow(frame, canvas, console, elapsed, 1.0);
4344                apply_console_top_reflection(frame, console, elapsed, 1.0);
4345                apply_console_background(frame, console, elapsed, 1.0);
4346            })
4347            .expect("render external console reflection");
4348
4349        let reflection_glow = glow_color_at(
4350            elapsed,
4351            console.x,
4352            canvas.width,
4353            reflection_y,
4354            canvas,
4355            console,
4356            1.0,
4357        )
4358        .expect("reflection row glow");
4359        let accent_only_reflection = blend_rgb(
4360            reflection_glow,
4361            glow_accent_at(elapsed),
4362            CONSOLE_REFLECTION_TINT,
4363        );
4364        let previous_reflection =
4365            blend_rgb(accent_only_reflection, Color::Rgb(255, 255, 255), 0.10);
4366        let reflection = console_top_reflection_color_at(elapsed, reflection_glow, 1.0);
4367        let previous_luminance_lift =
4368            color_luminance(previous_reflection) - color_luminance(accent_only_reflection);
4369        let luminance_lift = color_luminance(reflection) - color_luminance(accent_only_reflection);
4370        assert!(
4371            luminance_lift * 100 >= previous_luminance_lift * 195
4372                && luminance_lift * 100 <= previous_luminance_lift * 210,
4373            "doubling the white tint doubles its reflection luminance contribution"
4374        );
4375        let console_glow = glow_color_at(
4376            elapsed,
4377            console.x,
4378            canvas.width,
4379            console.y,
4380            canvas,
4381            console,
4382            1.0,
4383        )
4384        .expect("console glow");
4385        let buffer = terminal.backend().buffer();
4386        assert_eq!(
4387            buffer[(console.x, reflection_y)].symbol(),
4388            CONSOLE_REFLECTION_GLYPH,
4389            "the reflection is drawn in the row above the console"
4390        );
4391        assert_eq!(
4392            buffer[(console.x, reflection_y)].fg,
4393            console_top_reflection_color_at(elapsed, reflection_glow, 1.0),
4394            "the thin reflection follows the glow accent"
4395        );
4396        assert_eq!(
4397            buffer[(console.x, reflection_y - 1)].symbol(),
4398            " ",
4399            "only the closest external row is changed"
4400        );
4401        assert_eq!(
4402            buffer[(console.x, console.y)].bg,
4403            console_glass_color_at(elapsed, console_glow, 1.0),
4404            "the console top row remains normal glass"
4405        );
4406
4407        let mut idle_terminal = Terminal::new(ratatui::backend::TestBackend::new(
4408            canvas.width,
4409            canvas.height,
4410        ))
4411        .expect("idle test terminal");
4412        idle_terminal
4413            .draw(|frame| {
4414                apply_tui_glow(frame, canvas, console, elapsed, 0.0);
4415                apply_console_top_reflection(frame, console, elapsed, 0.0);
4416                apply_console_background(frame, console, elapsed, 0.0);
4417            })
4418            .expect("render idle console");
4419        assert_eq!(
4420            idle_terminal.backend().buffer()[(console.x, reflection_y)].symbol(),
4421            " ",
4422            "idle consoles have no external reflection"
4423        );
4424
4425        let state = UiState::from_history(&[], "secret", "model", None, false);
4426        let viewport = tui_viewport(Rect::new(0, 0, 80, 10));
4427        let (chat, _, _, _, input_area, _) = ui_layout(&state, viewport);
4428        assert_eq!(
4429            chat.y + chat.height + 1,
4430            input_area.y,
4431            "the reflection uses the existing transcript gap rather than adding a row"
4432        );
4433    }
4434
4435    #[test]
4436    fn console_reflection_skips_transcript_when_no_gap_fits() {
4437        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4438        state.welcome_visible = false;
4439        state
4440            .transcript
4441            .push(TranscriptItem::Info("protected transcript".to_owned()));
4442        state.busy = true;
4443        state.activity_transition = None;
4444        state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
4445        let area = Rect::new(0, 0, 60, 7);
4446        let mut terminal =
4447            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4448                .expect("test terminal");
4449        terminal
4450            .draw(|frame| draw(frame, &state))
4451            .expect("draw constrained active console");
4452
4453        let (chat, _, _, _, console, _) = ui_layout(&state, tui_viewport(area));
4454        assert_eq!(chat.y + chat.height, console.y, "there is no separator row");
4455        assert_eq!(
4456            terminal.backend().buffer()[(chat.x, chat.y)].symbol(),
4457            "p",
4458            "the active reflection does not overwrite the adjacent transcript"
4459        );
4460    }
4461
4462    #[test]
4463    fn console_reflection_stays_behind_an_active_skill_picker() {
4464        let mut state = UiState::from_history(&[], "secret", "model", None, false)
4465            .with_skill_names(vec!["agent-browser".to_owned()]);
4466        state.input = "/".to_owned();
4467        state.input_changed();
4468        state.busy = true;
4469        state.activity_transition = None;
4470        state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
4471        let area = Rect::new(0, 0, 40, 12);
4472        let mut terminal =
4473            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4474                .expect("test terminal");
4475        terminal
4476            .draw(|frame| draw(frame, &state))
4477            .expect("draw active picker");
4478
4479        let (_, picker, _, _, input, _) = ui_layout(&state, tui_viewport(area));
4480        let picker = picker.expect("visible picker");
4481        assert_eq!(picker.y + picker.height, input.y);
4482        let buffer = terminal.backend().buffer();
4483        assert_eq!(
4484            buffer[(picker.x, picker.y + picker.height - 1)].bg,
4485            SKILL_PICKER_BACKGROUND,
4486            "the picker covers the reflection row"
4487        );
4488        assert_ne!(
4489            buffer[(picker.x, picker.y + picker.height - 1)].symbol(),
4490            CONSOLE_REFLECTION_GLYPH,
4491            "the reflection glyph does not show through the picker"
4492        );
4493    }
4494
4495    #[test]
4496    fn console_glass_white_film_brightens_only_visible_glass() {
4497        let elapsed = Duration::ZERO;
4498        let glow = Color::Rgb(80, 82, 86);
4499        let (red, green, blue) = activity_rgb(console_accent_at(elapsed));
4500        let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
4501        let glass_accent = Color::Rgb(
4502            interpolate_color(red, neutral, CONSOLE_GLASS_DESATURATION),
4503            interpolate_color(green, neutral, CONSOLE_GLASS_DESATURATION),
4504            interpolate_color(blue, neutral, CONSOLE_GLASS_DESATURATION),
4505        );
4506        let accent_tinted = blend_rgb(CONSOLE_BACKGROUND, glass_accent, CONSOLE_GLASS_TINT);
4507        let without_white_film = blend_rgb(accent_tinted, glow, CONSOLE_GLASS_GLOW_THROUGH);
4508        let with_white_film = console_glass_color_at(elapsed, glow, 1.0);
4509        let expected = blend_rgb(
4510            blend_rgb(
4511                accent_tinted,
4512                Color::Rgb(255, 255, 255),
4513                CONSOLE_GLASS_WHITE_TINT,
4514            ),
4515            glow,
4516            CONSOLE_GLASS_GLOW_THROUGH,
4517        );
4518
4519        assert_eq!(CONSOLE_GLASS_WHITE_TINT, 0.03);
4520        assert_eq!(
4521            console_glass_color_at(elapsed, glow, 0.0),
4522            CONSOLE_BACKGROUND,
4523            "idle glass uses the configured console background"
4524        );
4525        assert_eq!(
4526            with_white_film, expected,
4527            "the white film is composited before glow-through"
4528        );
4529        let (with_white_red, with_white_green, with_white_blue) = activity_rgb(with_white_film);
4530        let (without_white_red, without_white_green, without_white_blue) =
4531            activity_rgb(without_white_film);
4532        assert!(
4533            u16::from(with_white_red) + u16::from(with_white_green) + u16::from(with_white_blue)
4534                > u16::from(without_white_red)
4535                    + u16::from(without_white_green)
4536                    + u16::from(without_white_blue),
4537            "the active glass has a visible white film"
4538        );
4539    }
4540
4541    #[test]
4542    fn console_accent_uses_a_fifteen_second_lavender_to_teal_round_trip() {
4543        assert_eq!(console_accent_cycle(), Duration::from_secs(15));
4544        assert_eq!(
4545            console_accent_at(Duration::ZERO),
4546            desaturate_console_accent(
4547                CONSOLE_ACCENT_LAVENDER.0,
4548                CONSOLE_ACCENT_LAVENDER.1,
4549                CONSOLE_ACCENT_LAVENDER.2,
4550            )
4551        );
4552        assert_eq!(
4553            console_accent_at(console_accent_cycle() / 2),
4554            desaturate_console_accent(
4555                CONSOLE_ACCENT_TEAL.0,
4556                CONSOLE_ACCENT_TEAL.1,
4557                CONSOLE_ACCENT_TEAL.2,
4558            )
4559        );
4560        assert_eq!(
4561            console_accent_at(console_accent_cycle()),
4562            console_accent_at(Duration::ZERO)
4563        );
4564        let midpoint = console_accent_at(console_accent_cycle() / 4);
4565        assert_ne!(
4566            midpoint,
4567            console_accent_at(Duration::ZERO),
4568            "the glow transitions continuously instead of holding at lavender"
4569        );
4570        assert_ne!(
4571            midpoint,
4572            console_accent_at(console_accent_cycle() / 2),
4573            "the glow transitions continuously instead of holding at teal"
4574        );
4575    }
4576
4577    #[test]
4578    fn tui_glow_background_is_ghostty_base_101216() {
4579        assert_eq!(TUI_GLOW_BACKGROUND_RGB, (16, 18, 22));
4580        assert_eq!(TUI_GLOW_BACKGROUND, Color::Rgb(16, 18, 22));
4581    }
4582
4583    #[test]
4584    fn console_palette_starts_lavender_with_fifteen_percent_desaturation() {
4585        assert_eq!(CONSOLE_BACKGROUND_RGB, (42, 42, 46));
4586        assert_eq!(CONSOLE_BACKGROUND, Color::Rgb(42, 42, 46));
4587        assert_eq!(
4588            console_accent_at(Duration::ZERO),
4589            desaturate_console_accent(
4590                CONSOLE_ACCENT_LAVENDER.0,
4591                CONSOLE_ACCENT_LAVENDER.1,
4592                CONSOLE_ACCENT_LAVENDER.2,
4593            )
4594        );
4595    }
4596
4597    #[test]
4598    fn console_accent_transition_changes_all_tui_glow_in_sync() {
4599        let elapsed = console_accent_cycle() / 4;
4600        let canvas = Rect::new(0, 0, 80, 20);
4601        let console = Rect::new(20, 12, 40, 7);
4602        let row = canvas.y + canvas.height - 1;
4603        let left = glow_color_at(elapsed, console.x, canvas.width, row, canvas, console, 1.0)
4604            .expect("left-side glow");
4605        let right = glow_color_at(
4606            elapsed,
4607            console.x + console.width - 1,
4608            canvas.width,
4609            row,
4610            canvas,
4611            console,
4612            1.0,
4613        )
4614        .expect("right-side glow");
4615        let intensity = (console_reach_at(elapsed) / CONSOLE_REACH_MAX)
4616            * GLOW_INTENSITY
4617            * glow_coverage_at(console.x, row, canvas, console);
4618
4619        assert_eq!(
4620            blend_rgb(TUI_GLOW_BACKGROUND, glow_accent_at(elapsed), intensity),
4621            left,
4622            "the left edge uses the shared accent phase"
4623        );
4624        assert_eq!(left, right, "the glow has no horizontal color phase");
4625
4626        let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
4627            canvas.width,
4628            canvas.height,
4629        ))
4630        .expect("test terminal");
4631        terminal
4632            .draw(|frame| {
4633                apply_tui_glow(frame, canvas, console, elapsed, 1.0);
4634                apply_console_top_reflection(frame, console, elapsed, 1.0);
4635                apply_console_background(frame, console, elapsed, 1.0);
4636            })
4637            .expect("render synchronized TUI glow");
4638        let buffer = terminal.backend().buffer();
4639        let left_x = console.x;
4640        let right_x = console.x + console.width - 1;
4641        assert_eq!(
4642            buffer[(left_x, console.y)].bg,
4643            buffer[(right_x, console.y)].bg,
4644            "the glass uses the shared accent phase"
4645        );
4646        assert_eq!(
4647            buffer[(left_x, console.y - 1)].fg,
4648            buffer[(right_x, console.y - 1)].fg,
4649            "the reflection uses the shared accent phase"
4650        );
4651    }
4652
4653    #[test]
4654    fn prompt_uses_two_cells_of_horizontal_console_padding() {
4655        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4656        state.input = "1234567890123456".to_owned();
4657        let area = Rect::new(0, 0, 20, 6);
4658        let mut terminal =
4659            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4660                .expect("test terminal");
4661
4662        terminal
4663            .draw(|frame| draw(frame, &state))
4664            .expect("draw padded prompt");
4665
4666        let input_area = ui_layout(&state, tui_viewport(area)).4;
4667        let prompt = prompt_area(input_area, &state);
4668        assert_eq!(prompt.x, input_area.x + 2);
4669        assert_eq!(prompt.width, input_area.width.saturating_sub(4));
4670        assert_eq!(
4671            terminal.backend().buffer()[(input_area.x + 1, prompt.y)].symbol(),
4672            " ",
4673            "the two left padding cells remain blank"
4674        );
4675        assert_eq!(
4676            terminal.backend().buffer()[(input_area.x + input_area.width - 2, prompt.y)].symbol(),
4677            " ",
4678            "the two right padding cells remain blank"
4679        );
4680        terminal
4681            .backend_mut()
4682            .assert_cursor_position((input_area.x + 2, prompt.y));
4683    }
4684
4685    #[test]
4686    fn prompt_width_reduction_wraps_and_saturates_at_narrow_widths() {
4687        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4688        state.input = "12345".to_owned();
4689        state.cursor = state.input.chars().count();
4690        let input_area = Rect::new(3, 2, 6, 6);
4691        let prompt = prompt_area(input_area, &state);
4692
4693        assert_eq!(prompt.width, 2);
4694        assert_eq!(input_visible_rows(&state, prompt.width), 3);
4695        assert_eq!(bottom_content_heights(&state, input_area).prompt, 3);
4696        assert_eq!(
4697            cursor_row(&state.input, state.cursor, prompt.width as usize),
4698            2
4699        );
4700        state.cursor = 1;
4701        assert!(move_input_cursor_vertical(
4702            &mut state,
4703            prompt_content_width(input_area.width) as usize,
4704            true,
4705        ));
4706        assert_eq!(state.cursor, 3);
4707        assert_eq!(prompt_content_width(0), 0);
4708        assert_eq!(prompt_content_width(1), 0);
4709        assert_eq!(prompt_content_width(2), 0);
4710        assert_eq!(prompt_content_width(3), 0);
4711        assert_eq!(prompt_content_width(4), 0);
4712        assert_eq!(prompt_content_width(5), 1);
4713    }
4714
4715    #[test]
4716    fn ready_submission_bypasses_queue_and_is_not_added_twice_when_started() {
4717        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4718
4719        state.submit_user("send now");
4720
4721        assert!(state.queued_messages.is_empty());
4722        assert_eq!(state.transcript.len(), 1);
4723        assert!(matches!(
4724            &state.transcript[0],
4725            TranscriptItem::User { text, .. } if text == "send now"
4726        ));
4727
4728        // The worker's Started notification still arrives asynchronously, but
4729        // must not promote an already visible direct submission a second time.
4730        state.start_queued_user("send now");
4731        assert_eq!(state.transcript.len(), 1);
4732    }
4733
4734    #[test]
4735    fn busy_submission_remains_queued_until_its_turn_starts() {
4736        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4737        state.busy = true;
4738
4739        state.submit_user("send later");
4740
4741        assert_eq!(state.queued_messages, ["send later"]);
4742        assert!(state.transcript.is_empty());
4743
4744        state.start_queued_user("send later");
4745        assert!(state.queued_messages.is_empty());
4746        assert!(matches!(
4747            &state.transcript[..],
4748            [TranscriptItem::User { text, .. }] if text == "send later"
4749        ));
4750    }
4751
4752    #[test]
4753    fn skill_picker_stays_above_a_visible_message_queue() {
4754        let mut state = UiState::from_history(&[], "secret", "model", None, false)
4755            .with_skill_names(vec!["release-notes".to_owned()]);
4756        state.queue_user("next task");
4757        state.input = "/".to_owned();
4758        state.input_changed();
4759
4760        let area = Rect::new(0, 0, 80, 12);
4761        let (_, picker_area, _, queue_area, input_area, _) = ui_layout(&state, tui_viewport(area));
4762        let picker_area = picker_area.expect("skill picker area");
4763        let queue_area = queue_area.expect("message queue area");
4764        assert_eq!(picker_area.y + picker_area.height, input_area.y);
4765        assert_eq!(queue_area.y, input_area.y + 1);
4766        assert_eq!(queue_area.x, input_area.x + 2);
4767    }
4768
4769    #[test]
4770    fn fresh_sessions_show_the_versioned_gradient_welcome_message() {
4771        let state = UiState::from_history(&[], "secret", "model", None, false);
4772        assert!(state.welcome_visible);
4773
4774        let line = welcome_line();
4775        assert_eq!(line.to_string(), WELCOME_MESSAGE);
4776        assert_eq!(WELCOME_VERSION, concat!("v", env!("CARGO_PKG_VERSION")));
4777        assert_eq!(
4778            line.spans.first().and_then(|span| span.style.fg),
4779            Some(Color::Rgb(
4780                WELCOME_START_COLOR.0,
4781                WELCOME_START_COLOR.1,
4782                WELCOME_START_COLOR.2,
4783            ))
4784        );
4785        assert_eq!(
4786            line.spans.last().and_then(|span| span.style.fg),
4787            Some(Color::Rgb(
4788                WELCOME_END_COLOR.0,
4789                WELCOME_END_COLOR.1,
4790                WELCOME_END_COLOR.2,
4791            ))
4792        );
4793    }
4794
4795    #[test]
4796    fn welcome_image_brightness_is_reduced_without_changing_alpha() {
4797        let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
4798            1,
4799            1,
4800            image::Rgba([200, 100, 0, 37]),
4801        ));
4802        let dimmed = dim_welcome_image(image).to_rgba8();
4803        assert_eq!(dimmed.get_pixel(0, 0).0, [170, 85, 0, 37]);
4804    }
4805
4806    #[test]
4807    fn spacious_welcome_uses_the_embedded_png() {
4808        let image = welcome_image(GREETING_IMAGE_SIZE);
4809        assert_eq!(image.size(), GREETING_IMAGE_SIZE);
4810        let layout = welcome_image_layout(Rect::new(0, 0, 100, 40), 6).expect("image fits");
4811        assert_eq!(layout.image_size, GREETING_IMAGE_SIZE);
4812        assert_eq!(layout.image_area, Rect::new(10, 6, 80, 20));
4813        assert_eq!(layout.intro_area.y, layout.image_area.y + 21);
4814    }
4815
4816    #[test]
4817    fn cramped_welcome_falls_back_to_the_text_greeting() {
4818        assert_eq!(welcome_image_layout(Rect::new(0, 0, 80, 16), 6), None);
4819        assert_eq!(welcome_image_layout(Rect::new(0, 0, 39, 40), 6), None);
4820        let scaled = welcome_image_layout(Rect::new(0, 0, 60, 25), 6).expect("scaled image fits");
4821        assert_eq!(scaled.image_size, Size::new(60, 15));
4822
4823        let state = UiState::from_history(&[], "secret", "model", None, false);
4824        let area = Rect::new(0, 0, 80, 12);
4825        let mut terminal =
4826            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4827                .expect("test terminal");
4828        terminal
4829            .draw(|frame| draw(frame, &state))
4830            .expect("draw text fallback");
4831        let chat_area = ui_layout(&state, tui_viewport(area)).0;
4832        let rows = (chat_area.y..chat_area.y + chat_area.height)
4833            .map(|y| {
4834                (chat_area.x..chat_area.x + chat_area.width)
4835                    .map(|x| terminal.backend().buffer()[(x, y)].symbol())
4836                    .collect::<String>()
4837            })
4838            .collect::<Vec<_>>();
4839        assert!(rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
4840        assert!(!rows
4841            .iter()
4842            .any(|row| row.contains('▀') || row.contains('▄')));
4843    }
4844
4845    #[test]
4846    fn logo_text_renders_by_default_and_greeting_image_replaces_it_when_enabled() {
4847        let logo = logo_lines();
4848        let logo_row_count = LOGO_TEXT.lines().count();
4849        assert_eq!(logo.len(), logo_row_count);
4850        // Every non-space character should carry a gradient color.
4851        assert!(logo.iter().flat_map(|line| &line.spans).any(|span| {
4852            span.content.chars().any(|ch| ch != ' ')
4853                && matches!(span.style.fg, Some(Color::Rgb(..)))
4854        }));
4855
4856        let state = UiState::from_history(&[], "secret", "model", None, false);
4857        let area = Rect::new(0, 0, 100, 50);
4858        let mut terminal =
4859            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4860                .expect("test terminal");
4861        let chat_area = ui_layout(&state, tui_viewport(area)).0;
4862        let intro_lines = welcome_lines(&state.attached_agents);
4863        let greeting_layout =
4864            welcome_image_layout(chat_area, intro_lines.len() as u16).expect("greeting fits");
4865
4866        // Without the flag the logo text renders (no halfblock image cells).
4867        std::env::remove_var("LUCY_GREETING_IMAGE");
4868        terminal
4869            .draw(|frame| draw(frame, &state))
4870            .expect("draw logo text");
4871        let buffer = terminal.backend().buffer();
4872        let rows = (chat_area.y..chat_area.y + chat_area.height)
4873            .map(|y| {
4874                (chat_area.x..chat_area.x + chat_area.width)
4875                    .map(|x| buffer[(x, y)].symbol())
4876                    .collect::<String>()
4877            })
4878            .collect::<Vec<_>>();
4879        assert!(rows
4880            .iter()
4881            .any(|row| row.contains(':') || row.contains('-') || row.contains('=')));
4882        assert!(!rows
4883            .iter()
4884            .any(|row| row.contains('▀') || row.contains('▄')));
4885        assert!(rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
4886
4887        // With the flag set the greeting image renders instead of the logo.
4888        std::env::set_var("LUCY_GREETING_IMAGE", "true");
4889        terminal
4890            .draw(|frame| draw(frame, &state))
4891            .expect("draw greeting");
4892        let buffer = terminal.backend().buffer();
4893        assert_eq!(greeting_layout.image_size, GREETING_IMAGE_SIZE);
4894        assert!(matches!(
4895            buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].symbol(),
4896            "▀" | "▄"
4897        ));
4898        assert!(matches!(
4899            buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].fg,
4900            Color::Rgb(..)
4901        ));
4902        assert!(matches!(
4903            buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].bg,
4904            Color::Rgb(..)
4905        ));
4906        let intro_rows = (greeting_layout.intro_area.y
4907            ..greeting_layout.intro_area.y + greeting_layout.intro_area.height)
4908            .map(|y| {
4909                (greeting_layout.intro_area.x
4910                    ..greeting_layout.intro_area.x + greeting_layout.intro_area.width)
4911                    .map(|x| buffer[(x, y)].symbol())
4912                    .collect::<String>()
4913            })
4914            .collect::<Vec<_>>();
4915        assert!(intro_rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
4916
4917        std::env::remove_var("LUCY_GREETING_IMAGE");
4918    }
4919
4920    #[test]
4921    fn welcome_renders_version_below_title_with_a_blank_line_before_tagline() {
4922        let state = UiState::from_history(&[], "secret", "model", None, false);
4923        let area = Rect::new(0, 0, 80, 12);
4924        let mut terminal =
4925            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4926                .expect("test terminal");
4927        terminal
4928            .draw(|frame| draw(frame, &state))
4929            .expect("draw welcome screen");
4930
4931        let chat_area = ui_layout(&state, tui_viewport(area)).0;
4932        let buffer = terminal.backend().buffer();
4933        let rows = (chat_area.y..chat_area.y + chat_area.height)
4934            .map(|y| {
4935                (chat_area.x..chat_area.x + chat_area.width)
4936                    .map(|x| buffer[(x, y)].symbol())
4937                    .collect::<String>()
4938            })
4939            .collect::<Vec<_>>();
4940        let title_row = rows
4941            .iter()
4942            .position(|row| row.contains(WELCOME_MESSAGE))
4943            .expect("rendered welcome title");
4944        let version_rows = rows
4945            .iter()
4946            .enumerate()
4947            .filter_map(|(row, rendered)| rendered.contains(WELCOME_VERSION).then_some(row))
4948            .collect::<Vec<_>>();
4949
4950        assert_eq!(version_rows, vec![title_row + 1]);
4951        assert!(rows[title_row + 2].trim().is_empty());
4952        assert!(rows[title_row + 3].contains(WELCOME_TAGLINE));
4953
4954        let version_width = WELCOME_VERSION.chars().count() as u16;
4955        let version_x = chat_area.x + (chat_area.width - version_width) / 2;
4956        let version_y = chat_area.y + title_row as u16 + 1;
4957        assert!((version_x..version_x + version_width)
4958            .all(|x| buffer[(x, version_y)].fg == Color::DarkGray));
4959    }
4960
4961    #[test]
4962    fn welcome_shows_the_tagline_and_attached_agents_paths() {
4963        let state = UiState::from_history(&[], "secret", "model", None, false)
4964            .with_attached_agents(vec![
4965                "/workspace/AGENTS.md".to_owned(),
4966                "/workspace/app/AGENTS.md".to_owned(),
4967            ]);
4968        let lines = welcome_lines(&state.attached_agents);
4969
4970        assert_eq!(lines[1].to_string(), WELCOME_VERSION);
4971        assert_eq!(lines[1].style.fg, Some(Color::DarkGray));
4972        assert!(lines[2].to_string().is_empty());
4973        assert_eq!(lines[3].to_string(), WELCOME_TAGLINE);
4974        assert_eq!(lines[3].style.fg, Some(Color::DarkGray));
4975        assert!(lines[4].to_string().is_empty());
4976        assert_eq!(lines[5].to_string(), "Attached AGENTS.md:");
4977        assert_eq!(lines[6].to_string(), "• /workspace/AGENTS.md");
4978        assert_eq!(lines[7].to_string(), "• /workspace/app/AGENTS.md");
4979        assert!(lines[5..]
4980            .iter()
4981            .all(|line| line.style.fg == Some(Color::DarkGray)));
4982    }
4983
4984    #[test]
4985    fn welcome_reports_when_no_agents_file_is_attached() {
4986        let lines = welcome_lines(&[]);
4987        assert_eq!(
4988            lines.last().expect("empty context line").to_string(),
4989            "Attached AGENTS.md: none"
4990        );
4991    }
4992
4993    #[test]
4994    fn resumed_sessions_do_not_show_the_welcome_message() {
4995        let state = UiState::from_history(&[], "secret", "model", None, true);
4996        assert!(!state.welcome_visible);
4997    }
4998
4999    #[test]
5000    fn history_replay_keeps_interruption_after_messages() {
5001        let history = vec![
5002            SessionHistoryRecord::Message {
5003                timestamp: 1,
5004                message: ChatMessage::user("hello".to_owned()),
5005            },
5006            SessionHistoryRecord::Interruption {
5007                timestamp: 2,
5008                reason: "user_cancelled".to_owned(),
5009                phase: "provider_stream".to_owned(),
5010                assistant_text: "partial".to_owned(),
5011                tool_calls: Vec::new(),
5012                tool_results: Vec::new(),
5013            },
5014        ];
5015        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
5016        assert!(matches!(state.transcript[0], TranscriptItem::User { .. }));
5017        assert!(matches!(state.transcript[1], TranscriptItem::Assistant(_)));
5018        assert!(matches!(state.transcript[2], TranscriptItem::Info(_)));
5019        let text = transcript_lines(&state, 80)
5020            .iter()
5021            .map(ToString::to_string)
5022            .collect::<Vec<_>>()
5023            .join("\n");
5024        assert!(!text.contains("choices"));
5025    }
5026
5027    #[test]
5028    fn history_replay_does_not_render_assistant_reasoning_details() {
5029        let mut message = ChatMessage::assistant("visible answer".to_owned(), Vec::new());
5030        message.reasoning_details = Some(vec![serde_json::json!({
5031            "type": "reasoning.text",
5032            "text": "private reasoning"
5033        })]);
5034        let history = [SessionHistoryRecord::Message {
5035            timestamp: 1,
5036            message,
5037        }];
5038        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
5039        let text = transcript_lines(&state, 80)
5040            .iter()
5041            .map(ToString::to_string)
5042            .collect::<Vec<_>>()
5043            .join("\n");
5044        assert!(text.contains("visible answer"));
5045        assert!(!text.contains("private reasoning"));
5046        assert!(!text.contains("reasoning_details"));
5047    }
5048
5049    #[test]
5050    fn history_replay_preserves_repeated_records() {
5051        let history = vec![
5052            SessionHistoryRecord::Message {
5053                timestamp: 1,
5054                message: ChatMessage::assistant("same".to_owned(), Vec::new()),
5055            },
5056            SessionHistoryRecord::Interruption {
5057                timestamp: 2,
5058                reason: "user_cancelled".to_owned(),
5059                phase: "provider_stream".to_owned(),
5060                assistant_text: "same".to_owned(),
5061                tool_calls: Vec::new(),
5062                tool_results: Vec::new(),
5063            },
5064        ];
5065        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
5066        assert_eq!(
5067            state
5068                .transcript
5069                .iter()
5070                .filter(|item| matches!(item, TranscriptItem::Assistant(text) if text == "same"))
5071                .count(),
5072            2
5073        );
5074    }
5075
5076    #[test]
5077    fn user_messages_have_a_single_block_rule_with_inner_and_vertical_padding() {
5078        let history = [SessionHistoryRecord::Message {
5079            timestamp: 1,
5080            message: ChatMessage::user("hello\nworld".to_owned()),
5081        }];
5082        let state = UiState::from_history(&history, "provider-secret", "model", None, false);
5083        let lines = transcript_lines(&state, 12);
5084
5085        assert_eq!(UnicodeWidthStr::width(USER_BORDER_GLYPH), 1);
5086        assert_eq!(lines.len(), 4);
5087        assert_eq!(lines[0].to_string(), "▌");
5088        assert_eq!(lines[1].to_string(), "▌ hello");
5089        assert_eq!(lines[2].to_string(), "▌ world");
5090        assert_eq!(lines[3].to_string(), "▌");
5091        for line in &lines {
5092            assert_eq!(line.spans[0].content, USER_BORDER_GLYPH);
5093            assert_eq!(line.spans[0].style.fg, Some(USER_BORDER_COLOR));
5094            assert!(!line.to_string().contains(['┌', '┐', '└', '┘', '│']));
5095        }
5096        for line in &lines[1..3] {
5097            assert_eq!(line.spans[1].content, " ");
5098            assert_eq!(line.spans[1].style.fg, Some(Color::White));
5099            assert_eq!(line.spans[2].style.fg, Some(Color::White));
5100        }
5101    }
5102
5103    #[test]
5104    fn attached_skill_highlights_its_trigger_in_the_user_message_without_a_notice_line() {
5105        let mut state = UiState::from_history(&[], "secret", "model", None, false)
5106            .with_skill_names(vec!["release-notes".to_owned()]);
5107        state.add_user("/release-notes v1.2.0", "secret");
5108        state.mark_latest_user_skill_attached();
5109
5110        let lines = transcript_lines(&state, 40);
5111        assert_eq!(lines.len(), 3);
5112        assert_eq!(lines[1].spans[1].content, " ");
5113        let cyan_text = lines[1]
5114            .spans
5115            .iter()
5116            .filter(|span| span.style.fg == Some(SKILL_TRIGGER_COLOR))
5117            .map(|span| span.content.as_ref())
5118            .collect::<String>();
5119        assert_eq!(cyan_text, "/release-notes");
5120        assert!(!lines
5121            .iter()
5122            .any(|line| line.to_string().contains("instruction attached")));
5123    }
5124
5125    #[test]
5126    fn transcript_rendering_redacts_history_content() {
5127        let history = [SessionHistoryRecord::Message {
5128            timestamp: 1,
5129            message: ChatMessage::assistant("provider-secret".to_owned(), Vec::new()),
5130        }];
5131        let state = UiState::from_history(&history, "provider-secret", "model", None, false);
5132        let text = transcript_lines(&state, 80)
5133            .iter()
5134            .map(ToString::to_string)
5135            .collect::<Vec<_>>()
5136            .join("\n");
5137        assert!(!text.contains("provider-secret"));
5138    }
5139
5140    #[test]
5141    fn mouse_wheel_disables_following_and_changes_scroll_offset() {
5142        let history = [SessionHistoryRecord::Message {
5143            timestamp: 1,
5144            message: ChatMessage::user("hello".to_owned()),
5145        }];
5146        let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
5147        handle_mouse_event(&mut state, MouseEventKind::ScrollUp, 10);
5148        assert!(!state.auto_scroll);
5149        assert_eq!(state.scroll, 7);
5150        handle_mouse_event(&mut state, MouseEventKind::ScrollDown, 10);
5151        assert!(
5152            state.auto_scroll,
5153            "reaching the bottom resumes transcript following"
5154        );
5155        assert_eq!(state.scroll, 0);
5156        scroll_up(&mut state, 10);
5157        assert!(!state.auto_scroll);
5158        assert_eq!(state.scroll, 7);
5159    }
5160
5161    #[test]
5162    fn transcript_scrollbar_appears_only_when_the_stream_is_scrolled() {
5163        let mut state = UiState::from_history(&[], "provider-secret", "model", None, false);
5164        state.welcome_visible = false;
5165        state.transcript = (0..40)
5166            .map(|index| TranscriptItem::Info(format!("message {index}")))
5167            .collect();
5168        state.auto_scroll = false;
5169        state.scroll = 3;
5170
5171        let area = Rect::new(0, 0, 80, 14);
5172        let mut terminal =
5173            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5174                .expect("test terminal");
5175        terminal
5176            .draw(|frame| draw(frame, &state))
5177            .expect("draw scrolled transcript");
5178
5179        let chat_area = ui_layout(&state, tui_viewport(area)).0;
5180        let scrollbar_x = chat_area.x + chat_area.width - 1;
5181        let buffer = terminal.backend().buffer();
5182        assert!(
5183            (chat_area.y..chat_area.y + chat_area.height).any(|y| {
5184                buffer[(scrollbar_x, y)].symbol() == TRANSCRIPT_SCROLLBAR_THUMB
5185                    && buffer[(scrollbar_x, y)].fg == CONSOLE_STATUS_COLOR
5186            }),
5187            "a scrolled transcript should show a scrollbar thumb"
5188        );
5189        assert!(
5190            (chat_area.y..chat_area.y + chat_area.height)
5191                .any(|y| { buffer[(scrollbar_x, y)].symbol() == TRANSCRIPT_SCROLLBAR_TRACK }),
5192            "a scrolled transcript should show a scrollbar track"
5193        );
5194
5195        state.auto_scroll = true;
5196        state.scroll = 0;
5197        let mut terminal =
5198            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5199                .expect("test terminal");
5200        terminal
5201            .draw(|frame| draw(frame, &state))
5202            .expect("draw following transcript");
5203        let buffer = terminal.backend().buffer();
5204        assert!((chat_area.y..chat_area.y + chat_area.height)
5205            .all(|y| buffer[(scrollbar_x, y)].symbol() != TRANSCRIPT_SCROLLBAR_THUMB));
5206    }
5207
5208    #[test]
5209    fn tool_result_sweep_is_now_twice_as_fast() {
5210        assert_eq!(TOOL_RESULT_SWEEP_DURATION, Duration::from_millis(600));
5211    }
5212
5213    #[test]
5214    fn wrap_text_breaks_long_lines_and_preserves_empty_lines() {
5215        let rows = wrap_text("12345\n\nabc", 3);
5216        assert_eq!(rows, vec!["123", "45", "", "abc"]);
5217    }
5218
5219    #[test]
5220    fn wrap_line_never_returns_an_empty_vec() {
5221        assert_eq!(wrap_line("", 5), vec![""]);
5222        assert_eq!(wrap_line("abc", 5), vec!["abc"]);
5223    }
5224
5225    #[test]
5226    fn multiline_input_arrows_move_cursor_between_explicit_and_wrapped_rows() {
5227        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5228        state.input = "ab\ncd\nef".to_owned();
5229        state.cursor = 1;
5230
5231        assert!(move_input_cursor_vertical(&mut state, 10, true));
5232        assert_eq!(
5233            state.cursor, 4,
5234            "preserve the column on the next explicit row"
5235        );
5236        assert!(move_input_cursor_vertical(&mut state, 10, true));
5237        assert_eq!(state.cursor, 7);
5238        assert!(!move_input_cursor_vertical(&mut state, 10, true));
5239        assert!(move_input_cursor_vertical(&mut state, 10, false));
5240        assert_eq!(state.cursor, 4);
5241
5242        state.input = "abcdef".to_owned();
5243        state.cursor = 1;
5244        assert!(move_input_cursor_vertical(&mut state, 3, true));
5245        assert_eq!(state.cursor, 4, "wrapped rows use the same visual column");
5246        assert!(move_input_cursor_vertical(&mut state, 3, false));
5247        assert_eq!(state.cursor, 1);
5248    }
5249
5250    #[test]
5251    fn completion_event_does_not_release_input_before_worker_finishes() {
5252        let history = [SessionHistoryRecord::Message {
5253            timestamp: 1,
5254            message: ChatMessage::user("hello".to_owned()),
5255        }];
5256        let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
5257        state.busy = true;
5258        state.active_cancel = Some(CancellationToken::new());
5259        state.apply_event(ProtocolEvent::TurnEnd);
5260        assert!(state.busy);
5261        assert!(state.active_cancel.is_some());
5262        assert_eq!(state.status, "finalizing");
5263    }
5264
5265    #[test]
5266    fn transcript_inserts_a_blank_line_between_items() {
5267        let history = [
5268            SessionHistoryRecord::Message {
5269                timestamp: 1,
5270                message: ChatMessage::user("hi".to_owned()),
5271            },
5272            SessionHistoryRecord::Message {
5273                timestamp: 2,
5274                message: ChatMessage::assistant("hello".to_owned(), Vec::new()),
5275            },
5276        ];
5277        let state = UiState::from_history(&history, "secret", "model", None, false);
5278        let lines = transcript_lines(&state, 80);
5279        assert_eq!(lines.len(), 5);
5280        assert_eq!(lines[0].to_string(), "▌");
5281        assert_eq!(lines[1].to_string(), "▌ hi");
5282        assert_eq!(lines[2].to_string(), "▌");
5283        assert_eq!(lines[3].to_string(), "");
5284        assert_eq!(lines[4].to_string(), "hello");
5285    }
5286
5287    #[test]
5288    fn cmd_call_renders_as_a_compact_status_line_without_raw_json() {
5289        let history = vec![
5290            SessionHistoryRecord::Message {
5291                timestamp: 1,
5292                message: ChatMessage::assistant(
5293                    String::new(),
5294                    vec![crate::model::ChatToolCall {
5295                        id: "call-1".to_owned(),
5296                        name: "cmd".to_owned(),
5297                        arguments: r#"{"command":"pwd"}"#.to_owned(),
5298                    }],
5299                ),
5300            },
5301            SessionHistoryRecord::Message {
5302                timestamp: 2,
5303                message: ChatMessage::tool(
5304                    "call-1".to_owned(),
5305                    "cmd".to_owned(),
5306                    serde_json::json!({"exit_code": 0, "stdout": "secret output"}).to_string(),
5307                ),
5308            },
5309        ];
5310        let state = UiState::from_history(&history, "secret", "model", None, false);
5311        let text = transcript_lines(&state, 80)[0].to_string();
5312
5313        assert_eq!(text, "✓ cmd  $ pwd");
5314        assert!(!text.contains("secret output"));
5315        assert!(!text.contains("{\"command\":\"pwd\"}"));
5316    }
5317
5318    #[test]
5319    fn pending_cmd_calls_use_a_compact_running_status() {
5320        let history = [SessionHistoryRecord::Message {
5321            timestamp: 1,
5322            message: ChatMessage::assistant(
5323                String::new(),
5324                vec![crate::model::ChatToolCall {
5325                    id: "call-1".to_owned(),
5326                    name: "cmd".to_owned(),
5327                    arguments: r#"{"command":"pwd"}"#.to_owned(),
5328                }],
5329            ),
5330        }];
5331        let state = UiState::from_history(&history, "secret", "model", None, false);
5332        let line = &transcript_lines(&state, 80)[0];
5333
5334        let text = line.to_string();
5335        let prefix = "· cmd  $ pwd  ";
5336        assert!(text.starts_with(prefix));
5337        assert!(!text.contains("→ running"));
5338        let frame = &text[prefix.len()..];
5339        assert_eq!(frame.chars().count(), 1);
5340        assert!(frame
5341            .chars()
5342            .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
5343        assert!(line
5344            .spans
5345            .iter()
5346            .all(|span| span.style.fg == Some(PENDING_TOOL_COLOR)));
5347    }
5348
5349    #[test]
5350    fn running_tool_indicators_use_a_traditional_spinner_with_their_own_clock() {
5351        assert_eq!(tool_spinner_frame_at(Duration::ZERO), '|');
5352        assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION), '/');
5353        assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 2), '-');
5354        assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 3), '\\');
5355
5356        let state = UiState::from_history(&[], "secret", "model", None, false);
5357        let spinner = running_tool_status(&state);
5358        assert_eq!(spinner.chars().count(), 1);
5359        assert!(spinner
5360            .chars()
5361            .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
5362    }
5363
5364    #[test]
5365    fn successful_cmd_cross_fades_to_teal_from_first_character_to_last() {
5366        let started_at = Instant::now();
5367        let character_count = 12;
5368        let early = started_at + TOOL_RESULT_SWEEP_DURATION / 4;
5369        let halfway = started_at + TOOL_RESULT_SWEEP_DURATION / 2;
5370        let late = started_at + TOOL_RESULT_SWEEP_DURATION * 3 / 4;
5371
5372        assert_eq!(
5373            cmd_result_color_at(
5374                started_at,
5375                started_at,
5376                0,
5377                character_count,
5378                TOOL_SUCCESS_COLOR,
5379            ),
5380            PENDING_TOOL_COLOR,
5381        );
5382        assert_eq!(TOOL_SUCCESS_COLOR, Color::Rgb(0, 210, 175));
5383
5384        let early_first =
5385            cmd_result_color_at(started_at, early, 0, character_count, TOOL_SUCCESS_COLOR);
5386        assert_ne!(early_first, PENDING_TOOL_COLOR);
5387        assert_ne!(early_first, TOOL_SUCCESS_COLOR);
5388        assert_eq!(
5389            cmd_result_color_at(started_at, early, 5, character_count, TOOL_SUCCESS_COLOR),
5390            PENDING_TOOL_COLOR,
5391            "later characters wait while the first character cross-fades"
5392        );
5393
5394        assert_eq!(
5395            cmd_result_color_at(started_at, halfway, 0, character_count, TOOL_SUCCESS_COLOR),
5396            TOOL_SUCCESS_COLOR,
5397        );
5398        let halfway_middle =
5399            cmd_result_color_at(started_at, halfway, 5, character_count, TOOL_SUCCESS_COLOR);
5400        assert_ne!(halfway_middle, PENDING_TOOL_COLOR);
5401        assert_ne!(halfway_middle, TOOL_SUCCESS_COLOR);
5402        assert_eq!(
5403            cmd_result_color_at(
5404                started_at,
5405                halfway,
5406                character_count - 1,
5407                character_count,
5408                TOOL_SUCCESS_COLOR,
5409            ),
5410            PENDING_TOOL_COLOR,
5411        );
5412
5413        let late_last = cmd_result_color_at(
5414            started_at,
5415            late,
5416            character_count - 1,
5417            character_count,
5418            TOOL_SUCCESS_COLOR,
5419        );
5420        assert_ne!(late_last, PENDING_TOOL_COLOR);
5421        assert_ne!(late_last, TOOL_SUCCESS_COLOR);
5422        assert_eq!(
5423            cmd_result_color_at(
5424                started_at,
5425                started_at + TOOL_RESULT_SWEEP_DURATION,
5426                character_count - 1,
5427                character_count,
5428                TOOL_SUCCESS_COLOR,
5429            ),
5430            TOOL_SUCCESS_COLOR,
5431            "the completed sweep keeps the exact teal used during the fade"
5432        );
5433    }
5434
5435    #[test]
5436    fn cmd_result_cross_fade_has_no_abrupt_color_change_between_render_ticks() {
5437        let started_at = Instant::now();
5438        let character_count = 12;
5439        let render_ticks = TOOL_RESULT_SWEEP_DURATION.as_millis() / EVENT_POLL.as_millis();
5440
5441        for target in [TOOL_SUCCESS_COLOR, TOOL_FAILURE_COLOR, TOOL_WARNING_COLOR] {
5442            for character_index in 0..character_count {
5443                let frames = (0..=render_ticks)
5444                    .map(|tick| {
5445                        cmd_result_color_at(
5446                            started_at,
5447                            started_at + EVENT_POLL * tick as u32,
5448                            character_index,
5449                            character_count,
5450                            target,
5451                        )
5452                    })
5453                    .collect::<Vec<_>>();
5454
5455                assert!(frames
5456                    .iter()
5457                    .any(|color| { *color != PENDING_TOOL_COLOR && *color != target }));
5458                assert!(frames.windows(2).all(|pair| {
5459                    let (before_red, before_green, before_blue) = tool_result_color_rgb(pair[0]);
5460                    let (after_red, after_green, after_blue) = tool_result_color_rgb(pair[1]);
5461                    before_red.abs_diff(after_red) <= 90
5462                        && before_green.abs_diff(after_green) <= 90
5463                        && before_blue.abs_diff(after_blue) <= 90
5464                }));
5465                assert_eq!(frames.last(), Some(&target));
5466            }
5467        }
5468    }
5469
5470    #[test]
5471    fn only_live_cmd_results_start_a_result_sweep() {
5472        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5473        let succeeded = serde_json::json!({"exit_code": 0});
5474
5475        state.add_tool_result("historic", "cmd", succeeded.clone());
5476        state.add_live_tool_result("success", "cmd", succeeded);
5477        state.add_live_tool_result("failed", "cmd", serde_json::json!({"exit_code": 1}));
5478
5479        assert!(!state.cmd_result_started_at.contains_key("historic"));
5480        assert!(state.cmd_result_started_at.contains_key("success"));
5481        assert!(state.cmd_result_started_at.contains_key("failed"));
5482    }
5483
5484    #[test]
5485    fn failed_cmd_cross_fades_to_the_same_rgb_red_without_a_final_jump() {
5486        let started_at = Instant::now();
5487        let character_count = 12;
5488        let halfway = started_at + TOOL_RESULT_SWEEP_DURATION / 2;
5489
5490        assert_eq!(
5491            cmd_result_color_at(
5492                started_at,
5493                started_at,
5494                0,
5495                character_count,
5496                TOOL_FAILURE_COLOR,
5497            ),
5498            PENDING_TOOL_COLOR,
5499        );
5500        assert_eq!(
5501            cmd_result_color_at(started_at, halfway, 0, character_count, TOOL_FAILURE_COLOR),
5502            TOOL_FAILURE_COLOR,
5503        );
5504        let intermediate =
5505            cmd_result_color_at(started_at, halfway, 5, character_count, TOOL_FAILURE_COLOR);
5506        assert_ne!(intermediate, PENDING_TOOL_COLOR);
5507        assert_ne!(intermediate, TOOL_FAILURE_COLOR);
5508        assert_eq!(
5509            cmd_result_color_at(
5510                started_at,
5511                halfway,
5512                character_count - 1,
5513                character_count,
5514                TOOL_FAILURE_COLOR,
5515            ),
5516            PENDING_TOOL_COLOR,
5517        );
5518        assert_eq!(
5519            cmd_result_color_at(
5520                started_at,
5521                started_at + TOOL_RESULT_SWEEP_DURATION,
5522                character_count - 1,
5523                character_count,
5524                TOOL_FAILURE_COLOR,
5525            ),
5526            TOOL_FAILURE_COLOR,
5527            "the completed failure sweep keeps the exact RGB red used during the fade"
5528        );
5529    }
5530
5531    #[test]
5532    fn live_failed_cmd_sweep_keeps_the_final_status_text() {
5533        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5534        let result = serde_json::json!({"exit_code": 1});
5535        state.add_live_tool_result("failed", "cmd", result.clone());
5536
5537        let segments = cmd_tool_segments("failed", r#"{"command":"bad"}"#, Some(&result), &state);
5538        let text = segments
5539            .iter()
5540            .map(|(text, _)| text.as_str())
5541            .collect::<String>();
5542
5543        assert_eq!(text, "× cmd  $ bad  → exit 1");
5544    }
5545
5546    #[test]
5547    fn cmd_result_target_colors_follow_the_final_status() {
5548        assert_eq!(
5549            cmd_result_target_color(&serde_json::json!({"exit_code": 0})),
5550            TOOL_SUCCESS_COLOR
5551        );
5552        assert_eq!(
5553            cmd_result_target_color(&serde_json::json!({"exit_code": 1})),
5554            TOOL_FAILURE_COLOR
5555        );
5556        assert_eq!(
5557            cmd_result_target_color(&serde_json::json!({"timed_out": true})),
5558            TOOL_WARNING_COLOR
5559        );
5560    }
5561
5562    #[test]
5563    fn cmd_status_distinguishes_nonzero_exit_timeout_and_cancellation() {
5564        let cases = [
5565            (
5566                serde_json::json!({"exit_code": 127}),
5567                "× cmd  $ bad  → exit 127",
5568            ),
5569            (
5570                serde_json::json!({"timed_out": true, "exit_code": null}),
5571                "! cmd  $ slow  → timeout",
5572            ),
5573            (
5574                serde_json::json!({"canceled": true}),
5575                "! cmd  $ stop  → canceled",
5576            ),
5577        ];
5578        for (result, expected) in cases {
5579            let history = vec![
5580                SessionHistoryRecord::Message {
5581                    timestamp: 1,
5582                    message: ChatMessage::assistant(
5583                        String::new(),
5584                        vec![crate::model::ChatToolCall {
5585                            id: "call-1".to_owned(),
5586                            name: "cmd".to_owned(),
5587                            arguments: serde_json::json!({"command": expected.split("$ ").nth(1).unwrap().split("  ").next().unwrap()}).to_string(),
5588                        }],
5589                    ),
5590                },
5591                SessionHistoryRecord::Message {
5592                    timestamp: 2,
5593                    message: ChatMessage::tool(
5594                        "call-1".to_owned(),
5595                        "cmd".to_owned(),
5596                        result.to_string(),
5597                    ),
5598                },
5599            ];
5600            let state = UiState::from_history(&history, "secret", "model", None, false);
5601            assert_eq!(transcript_lines(&state, 80)[0].to_string(), expected);
5602        }
5603    }
5604
5605    #[test]
5606    fn cmd_line_truncates_long_commands_but_never_renders_output() {
5607        let command = "a".repeat(120);
5608        let arguments = serde_json::json!({"command": command}).to_string();
5609        let history = vec![
5610            SessionHistoryRecord::Message {
5611                timestamp: 1,
5612                message: ChatMessage::assistant(
5613                    String::new(),
5614                    vec![crate::model::ChatToolCall {
5615                        id: "call-1".to_owned(),
5616                        name: "cmd".to_owned(),
5617                        arguments,
5618                    }],
5619                ),
5620            },
5621            SessionHistoryRecord::Message {
5622                timestamp: 2,
5623                message: ChatMessage::tool(
5624                    "call-1".to_owned(),
5625                    "cmd".to_owned(),
5626                    serde_json::json!({"exit_code": 0, "stdout": "output"}).to_string(),
5627                ),
5628            },
5629        ];
5630        let state = UiState::from_history(&history, "secret", "model", None, false);
5631        let text = transcript_lines(&state, 200)[0].to_string();
5632        assert!(text.contains(&format!("$ {}…", "a".repeat(100))));
5633        assert!(!text.contains(&"a".repeat(101)));
5634        assert!(!text.contains("output"));
5635    }
5636
5637    #[test]
5638    fn cmd_lines_remain_compact_for_consecutive_calls() {
5639        let history = vec![
5640            SessionHistoryRecord::Message {
5641                timestamp: 1,
5642                message: ChatMessage::assistant(
5643                    String::new(),
5644                    vec![
5645                        crate::model::ChatToolCall {
5646                            id: "call-first".to_owned(),
5647                            name: "cmd".to_owned(),
5648                            arguments: r#"{"command":"first"}"#.to_owned(),
5649                        },
5650                        crate::model::ChatToolCall {
5651                            id: "call-second".to_owned(),
5652                            name: "cmd".to_owned(),
5653                            arguments: r#"{"command":"second"}"#.to_owned(),
5654                        },
5655                    ],
5656                ),
5657            },
5658            SessionHistoryRecord::Message {
5659                timestamp: 2,
5660                message: ChatMessage::tool(
5661                    "call-first".to_owned(),
5662                    "cmd".to_owned(),
5663                    serde_json::json!({"exit_code": 0}).to_string(),
5664                ),
5665            },
5666            SessionHistoryRecord::Message {
5667                timestamp: 3,
5668                message: ChatMessage::tool(
5669                    "call-second".to_owned(),
5670                    "cmd".to_owned(),
5671                    serde_json::json!({"exit_code": 0}).to_string(),
5672                ),
5673            },
5674        ];
5675        let state = UiState::from_history(&history, "secret", "model", None, false);
5676        let lines = transcript_lines(&state, 200);
5677        assert_eq!(lines[0].to_string(), "✓ cmd  $ first");
5678        assert_eq!(lines[2].to_string(), "✓ cmd  $ second");
5679    }
5680
5681    #[test]
5682    fn cmd_status_styles_use_success_failure_and_pending_colors() {
5683        assert_eq!(
5684            cmd_result_status(&serde_json::json!({"exit_code": 0})).2.fg,
5685            Some(TOOL_SUCCESS_COLOR)
5686        );
5687        assert_eq!(
5688            cmd_result_status(&serde_json::json!({"exit_code": 1})).2.fg,
5689            Some(TOOL_FAILURE_COLOR)
5690        );
5691        assert_eq!(
5692            cmd_tool_segments(
5693                "call-1",
5694                "{\"command\":\"pwd\"}",
5695                None,
5696                &UiState::from_history(&[], "secret", "model", None, false)
5697            )[0]
5698            .1
5699            .fg,
5700            Some(PENDING_TOOL_COLOR)
5701        );
5702    }
5703
5704    #[test]
5705    fn recognized_skill_trigger_is_highlighted_but_arguments_remain_default_colored() {
5706        let trigger = active_skill_trigger("/release-notes v1.2.0", &["release-notes".to_owned()]);
5707        assert_eq!(trigger, Some("/release-notes"));
5708        assert_eq!(SKILL_TRIGGER_COLOR, Color::Rgb(80, 255, 245));
5709
5710        let lines = styled_text_lines(
5711            "/release-notes v1.2.0",
5712            trigger,
5713            80,
5714            Style::default().fg(Color::White),
5715        );
5716        assert_eq!(lines.len(), 1);
5717        assert_eq!(lines[0].to_string(), "/release-notes v1.2.0");
5718        assert_eq!(lines[0].spans[0].content, "/release-notes");
5719        assert_eq!(lines[0].spans[0].style.fg, Some(SKILL_TRIGGER_COLOR));
5720        assert_eq!(lines[0].spans[1].content, " v1.2.0");
5721        assert_eq!(lines[0].spans[1].style.fg, Some(Color::White));
5722    }
5723
5724    #[test]
5725    fn draw_renders_an_active_skill_trigger_in_cyan() {
5726        let mut state = UiState::from_history(&[], "secret", "model", None, false)
5727            .with_skill_names(vec!["release-notes".to_owned()]);
5728        state.input = "/release-notes v1.2.0".to_owned();
5729        state.cursor = state.input.chars().count();
5730
5731        let mut terminal =
5732            Terminal::new(ratatui::backend::TestBackend::new(40, 10)).expect("test terminal");
5733        terminal
5734            .draw(|frame| draw(frame, &state))
5735            .expect("draw input");
5736
5737        // The full-width input block keeps trigger characters bright cyan while the
5738        // argument that follows stays white.
5739        let buffer = terminal.backend().buffer();
5740        let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 40, 10)));
5741        let prompt_area = prompt_area(input_area, &state);
5742        let input_x = prompt_area.x;
5743        let input_y = prompt_area.y;
5744        assert_eq!(buffer[(input_x, input_y)].fg, SKILL_TRIGGER_COLOR);
5745        assert_eq!(
5746            buffer[(input_x + "/release-notes".chars().count() as u16, input_y)].fg,
5747            Color::White
5748        );
5749    }
5750
5751    #[test]
5752    fn main_agent_status_omits_activity_animation_on_idle_and_busy_glass() {
5753        let mut state =
5754            UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
5755        let area = Rect::new(0, 0, 80, 10);
5756        let mut terminal =
5757            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5758                .expect("test terminal");
5759
5760        terminal
5761            .draw(|frame| draw(frame, &state))
5762            .expect("draw ready status");
5763        let viewport = tui_viewport(area);
5764        let status_area = ui_layout(&state, viewport).5;
5765        let expected_context = "Context: 81/100 (81%) █████████░";
5766        let buffer = terminal.backend().buffer();
5767        let idle_row = (status_area.x..status_area.x + status_area.width)
5768            .map(|x| buffer[(x, status_area.y)].symbol())
5769            .collect::<String>();
5770        assert!(idle_row.starts_with("model · default"));
5771        assert!(idle_row.ends_with(expected_context));
5772        for x in status_area.x..status_area.x + status_area.width {
5773            if buffer[(x, status_area.y)].symbol() != " " {
5774                assert_eq!(buffer[(x, status_area.y)].fg, Color::Rgb(144, 144, 148));
5775            }
5776        }
5777
5778        state.set_status("working");
5779        state.busy = true;
5780        state.activity_transition = None;
5781        state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
5782        terminal
5783            .draw(|frame| draw(frame, &state))
5784            .expect("draw working status");
5785        let status_area = ui_layout(&state, viewport).5;
5786        let buffer = terminal.backend().buffer();
5787        let rendered = (status_area.x..status_area.x + status_area.width)
5788            .map(|x| buffer[(x, status_area.y)].symbol())
5789            .collect::<String>();
5790        assert!(rendered.starts_with("model · default "));
5791        assert!(rendered.contains(BUSY_INDICATOR_BLOCK));
5792        assert!(rendered.ends_with(expected_context));
5793        assert!(
5794            (status_area.x..status_area.x + status_area.width)
5795                .any(|x| buffer[(x, status_area.y)].bg != CONSOLE_BACKGROUND),
5796            "the busy status line renders over bright glass"
5797        );
5798    }
5799
5800    #[test]
5801    fn terminal_focus_events_control_cursor_visibility() {
5802        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5803
5804        assert!(handle_terminal_focus_event(&mut state, &Event::FocusLost));
5805        assert!(!state.terminal_focused);
5806        assert!(handle_terminal_focus_event(&mut state, &Event::FocusGained));
5807        assert!(state.terminal_focused);
5808        assert!(!handle_terminal_focus_event(
5809            &mut state,
5810            &Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
5811        ));
5812        assert!(state.terminal_focused);
5813    }
5814
5815    #[test]
5816    fn unfocused_busy_glow_keeps_the_hardware_cursor_hidden() {
5817        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5818        state.set_status("working");
5819        state.set_busy(true);
5820        state.terminal_focused = false;
5821        state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
5822
5823        let mut terminal =
5824            Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
5825        terminal
5826            .draw(|frame| draw(frame, &state))
5827            .expect("draw busy glow");
5828
5829        assert!(
5830            !terminal.backend().cursor_visible(),
5831            "the glow redraw must not re-show the terminal cursor"
5832        );
5833    }
5834
5835    #[test]
5836    fn cjk_input_keeps_the_terminal_cursor_in_the_prompt_without_resetting_activity() {
5837        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5838        state.set_status("working");
5839        state.busy = true;
5840        state.input = "한글".to_owned();
5841        state.cursor = state.input.chars().count();
5842        let activity_started_at = state.activity_started_at;
5843        let tool_animation_epoch = state.tool_animation_epoch;
5844        let sample_at = Instant::now();
5845        let activity_before = state.activity_levels_at(sample_at);
5846
5847        // A committed CJK character must move the hardware cursor by its
5848        // display width, and input edits must not restart either animation.
5849        state.input_changed();
5850        assert_eq!(state.activity_started_at, activity_started_at);
5851        assert_eq!(state.tool_animation_epoch, tool_animation_epoch);
5852        assert_eq!(state.activity_levels_at(sample_at), activity_before);
5853
5854        let area = Rect::new(0, 0, 80, 10);
5855        let mut terminal =
5856            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5857                .expect("test terminal");
5858        terminal
5859            .draw(|frame| draw(frame, &state))
5860            .expect("draw CJK input while working");
5861        let (_, _, _, _, input_area, status_area) = ui_layout(&state, tui_viewport(area));
5862        assert_ne!(input_area.y, status_area.y);
5863        let prompt_area = prompt_area(input_area, &state);
5864        assert!(terminal.backend().cursor_visible());
5865        terminal.backend_mut().assert_cursor_position((
5866            prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
5867            prompt_area.y,
5868        ));
5869    }
5870
5871    #[test]
5872    fn transcript_and_console_are_separated_by_one_blank_row() {
5873        let state = UiState::from_history(&[], "secret", "model", None, false);
5874        let area = Rect::new(0, 0, 80, 10);
5875        let mut terminal =
5876            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5877                .expect("test terminal");
5878
5879        terminal
5880            .draw(|frame| draw(frame, &state))
5881            .expect("draw separated transcript and console");
5882
5883        let (transcript, _, _, _, console, _) = ui_layout(&state, tui_viewport(area));
5884        assert_eq!(transcript.y + transcript.height + 1, console.y);
5885        let gap_y = console.y - 1;
5886        for x in transcript.x..transcript.x + transcript.width {
5887            assert_eq!(terminal.backend().buffer()[(x, gap_y)].symbol(), " ");
5888            assert_eq!(terminal.backend().buffer()[(x, gap_y)].bg, Color::Reset);
5889        }
5890    }
5891
5892    #[test]
5893    fn idle_console_has_external_gutters_uniform_background_and_no_borders() {
5894        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5895        state.input = "prompt".to_owned();
5896        state.cursor = state.input.chars().count();
5897        let area = Rect::new(0, 0, 80, 10);
5898        let mut terminal =
5899            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5900                .expect("test terminal");
5901
5902        terminal
5903            .draw(|frame| draw(frame, &state))
5904            .expect("draw idle console");
5905
5906        let input_area = ui_layout(&state, tui_viewport(area)).4;
5907        let prompt_area = prompt_area(input_area, &state);
5908        let buffer = terminal.backend().buffer();
5909        let bottom_y = area.y + area.height - 1;
5910        assert_eq!(buffer[(area.x, bottom_y)].bg, Color::Reset);
5911        assert_eq!(buffer[(area.x + area.width - 1, bottom_y)].bg, Color::Reset);
5912        for y in input_area.y..input_area.y + input_area.height {
5913            assert_eq!(buffer[(0, y)].bg, Color::Reset);
5914            assert_eq!(buffer[(79, y)].bg, Color::Reset);
5915            for x in input_area.x..input_area.x + input_area.width {
5916                assert_eq!(buffer[(x, y)].bg, CONSOLE_BACKGROUND);
5917            }
5918        }
5919        assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), " ");
5920        assert_eq!(
5921            buffer[(input_area.x + input_area.width - 1, input_area.y)].symbol(),
5922            " "
5923        );
5924        assert_eq!(
5925            buffer[(input_area.x, input_area.y + input_area.height - 1)].symbol(),
5926            " "
5927        );
5928        assert_eq!(
5929            buffer[(
5930                input_area.x + input_area.width - 1,
5931                input_area.y + input_area.height - 1
5932            )]
5933                .symbol(),
5934            " "
5935        );
5936        assert_eq!(buffer[(prompt_area.x, prompt_area.y)].symbol(), "p");
5937        assert_eq!(buffer[(prompt_area.x, prompt_area.y)].fg, Color::White);
5938        terminal.backend_mut().assert_cursor_position((
5939            prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
5940            prompt_area.y,
5941        ));
5942    }
5943
5944    #[test]
5945    fn busy_console_keeps_glass_inside_the_bottom_half_ellipse() {
5946        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5947        state.busy = true;
5948        state.set_status("working");
5949        state.activity_transition = None;
5950        state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
5951        let area = Rect::new(0, 0, 200, 10);
5952        let mut terminal =
5953            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5954                .expect("test terminal");
5955
5956        terminal
5957            .draw(|frame| draw(frame, &state))
5958            .expect("draw busy console");
5959
5960        let viewport = tui_viewport(area);
5961        let (chat_area, _, _, _, input_area, _) = ui_layout(&state, viewport);
5962        let reflection_y = input_area.y - 1;
5963        let glow_floor_y = area.y + area.height - 1;
5964        let edge_y = input_area.y + input_area.height - 1;
5965        let floor = input_area.x + input_area.width / 2;
5966        let left_edge = input_area.x;
5967        let left_outer = left_edge - 1;
5968        let right_edge = input_area.x + input_area.width - 1;
5969        let right_outer = right_edge + 1;
5970        let widening_sample = input_area.x - 22;
5971        let buffer = terminal.backend().buffer();
5972        assert_eq!(chat_area.y + chat_area.height + 1, input_area.y);
5973        assert_eq!(
5974            buffer[(floor, reflection_y)].symbol(),
5975            CONSOLE_REFLECTION_GLYPH,
5976            "the active console reflects along the bottom of the existing gap row"
5977        );
5978        assert_ne!(buffer[(floor, reflection_y)].fg, Color::Reset);
5979        assert_ne!(buffer[(floor, glow_floor_y)].bg, Color::Reset);
5980        assert_ne!(buffer[(left_outer, edge_y)].bg, Color::Reset);
5981        assert_ne!(buffer[(right_outer, edge_y)].bg, Color::Reset);
5982        let left_extent = left_edge - GLOW_HORIZONTAL_SPREAD;
5983        let right_extent = right_edge + GLOW_HORIZONTAL_SPREAD;
5984        assert_ne!(buffer[(left_extent, glow_floor_y)].bg, Color::Reset);
5985        assert_ne!(buffer[(right_extent, glow_floor_y)].bg, Color::Reset);
5986        assert_eq!(buffer[(left_extent - 1, glow_floor_y)].bg, Color::Reset);
5987        assert_eq!(buffer[(right_extent + 1, glow_floor_y)].bg, Color::Reset);
5988        assert_eq!(buffer[(widening_sample, input_area.y)].bg, Color::Reset);
5989        assert_ne!(buffer[(widening_sample, glow_floor_y)].bg, Color::Reset);
5990        assert_eq!(buffer[(area.x, glow_floor_y)].bg, Color::Reset);
5991        for y in input_area.y..input_area.y + input_area.height {
5992            for x in input_area.x..input_area.x + input_area.width {
5993                assert_ne!(buffer[(x, y)].bg, Color::Reset);
5994            }
5995        }
5996        for y in input_area.y..input_area.y + input_area.height {
5997            for x in input_area.x..input_area.x + input_area.width {
5998                assert_ne!(buffer[(x, y)].bg, Color::Reset);
5999                assert!(
6000                    color_distance(CONSOLE_BACKGROUND, buffer[(x, y)].bg)
6001                        < color_distance(TUI_GLOW_BACKGROUND, buffer[(floor, glow_floor_y)].bg)
6002                );
6003            }
6004        }
6005    }
6006
6007    #[test]
6008    fn narrow_busy_console_keeps_both_endpoint_blooms_exposed() {
6009        let mut state = UiState::from_history(&[], "secret", "model", None, false);
6010        state.busy = true;
6011        state.set_status("working");
6012        state.activity_transition = None;
6013        state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
6014        let area = Rect::new(0, 0, 10, 10);
6015        let mut terminal =
6016            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
6017                .expect("test terminal");
6018
6019        terminal
6020            .draw(|frame| draw(frame, &state))
6021            .expect("draw narrow TUI");
6022
6023        let input = ui_layout(&state, tui_viewport(area)).4;
6024        assert_eq!(input.width, 4, "test the minimum inset console width");
6025        let left_edge = input.x;
6026        let right_edge = input.x + input.width - 1;
6027        let buffer = terminal.backend().buffer();
6028        for y in input.y..input.y + input.height {
6029            for (edge, outer) in [(left_edge, left_edge - 1), (right_edge, right_edge + 1)] {
6030                assert_ne!(
6031                    buffer[(outer, y)].bg,
6032                    Color::Reset,
6033                    "the outer glow cell is present at ({outer}, {y})"
6034                );
6035                assert_ne!(
6036                    buffer[(edge, y)].bg,
6037                    buffer[(outer, y)].bg,
6038                    "narrow console glass does not spill into the exposed bloom at ({edge}, {y})"
6039                );
6040            }
6041        }
6042    }
6043
6044    #[test]
6045    fn only_known_leading_skill_commands_activate_input_highlighting() {
6046        let skills = ["release-notes".to_owned()];
6047        assert_eq!(
6048            active_skill_trigger("/missing", &skills),
6049            None,
6050            "unknown commands are rejected by the turn engine and must not look active"
6051        );
6052        assert_eq!(
6053            active_skill_trigger("/skill:release-notes", &skills),
6054            None,
6055            "the removed /skill: wrapper must not look active"
6056        );
6057        assert_eq!(
6058            active_skill_trigger("write /release-notes", &skills),
6059            None,
6060            "only the command prefix accepted by the turn engine is active"
6061        );
6062        assert_eq!(active_skill_trigger("/", &skills), None);
6063    }
6064
6065    #[test]
6066    fn highlighted_skill_trigger_remains_styled_when_wrapped() {
6067        let input = "/release-notes argument";
6068        let trigger = active_skill_trigger(input, &["release-notes".to_owned()]);
6069        let lines = styled_text_lines(input, trigger, 8, Style::default().fg(Color::White));
6070        let highlighted = lines
6071            .iter()
6072            .flat_map(|line| line.spans.iter())
6073            .filter(|span| span.style.fg == Some(SKILL_TRIGGER_COLOR))
6074            .map(|span| span.content.as_ref())
6075            .collect::<String>();
6076        assert_eq!(highlighted, "/release-notes");
6077    }
6078
6079    #[test]
6080    fn input_has_no_prompt_marker_and_trailing_newline_is_visible() {
6081        assert_eq!(input_prompt("hello"), "hello");
6082        assert_eq!(wrap_text("hello\n", 80), vec!["hello", ""]);
6083    }
6084
6085    #[test]
6086    fn input_prompt_wraps_to_multiple_rows_when_long() {
6087        let mut state = UiState::from_history(&[], "secret", "model", None, false);
6088        state.input = "abcdefghij".to_owned();
6089        // width 5: the input wraps across multiple rows without a prompt marker.
6090        let rows = input_visible_rows(&state, 5);
6091        assert!(rows >= 2);
6092    }
6093
6094    #[test]
6095    fn cursor_editing_moves_by_characters_and_preserves_unicode() {
6096        let mut input = "가나".to_owned();
6097        let mut cursor = input.chars().count();
6098        cursor -= 1;
6099        insert_at_cursor(&mut input, &mut cursor, 'x');
6100        assert_eq!(input, "가x나");
6101        assert_eq!(cursor, 2);
6102        assert!(remove_before_cursor(&mut input, &mut cursor));
6103        assert_eq!(input, "가나");
6104        assert_eq!(cursor, 1);
6105    }
6106
6107    #[test]
6108    fn cursor_row_tracks_newlines_and_wrapping() {
6109        assert_eq!(cursor_row("hello\nworld", 6, 80), 1);
6110        assert_eq!(cursor_row("abcdef", 4, 3), 1);
6111    }
6112
6113    #[test]
6114    fn shift_enter_inserts_at_the_cursor_and_moves_it_to_the_new_row() {
6115        let mut input = "beforeafter".to_owned();
6116        let mut cursor = 6;
6117        insert_at_cursor(&mut input, &mut cursor, '\n');
6118
6119        assert_eq!(input, "before\nafter");
6120        assert_eq!(cursor, 7);
6121        assert_eq!(cursor_row(&input, cursor, 80), 1);
6122    }
6123
6124    #[test]
6125    fn shift_enter_renders_the_cursor_on_the_new_input_row() {
6126        let mut state = UiState::from_history(&[], "secret", "model", None, false);
6127        state.input = "beforeafter".to_owned();
6128        state.cursor = 6;
6129        insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
6130
6131        let mut terminal =
6132            Terminal::new(ratatui::backend::TestBackend::new(20, 10)).expect("test terminal");
6133        terminal
6134            .draw(|frame| draw(frame, &state))
6135            .expect("draw input cursor");
6136
6137        // After inserting a newline, the cursor is at the start of the second
6138        // input row.
6139        let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 20, 10)));
6140        let prompt_area = prompt_area(input_area, &state);
6141        terminal
6142            .backend_mut()
6143            .assert_cursor_position((prompt_area.x, prompt_area.y + 1));
6144    }
6145
6146    #[test]
6147    fn tool_results_attach_to_their_matching_call_after_consecutive_calls() {
6148        let history = vec![
6149            SessionHistoryRecord::Message {
6150                timestamp: 1,
6151                message: ChatMessage::assistant(
6152                    String::new(),
6153                    vec![
6154                        crate::model::ChatToolCall {
6155                            id: "call-first".to_owned(),
6156                            name: "cmd".to_owned(),
6157                            arguments: r#"{"command":"first"}"#.to_owned(),
6158                        },
6159                        crate::model::ChatToolCall {
6160                            id: "call-second".to_owned(),
6161                            name: "cmd".to_owned(),
6162                            arguments: r#"{"command":"second"}"#.to_owned(),
6163                        },
6164                    ],
6165                ),
6166            },
6167            SessionHistoryRecord::Message {
6168                timestamp: 2,
6169                message: ChatMessage::tool(
6170                    "call-first".to_owned(),
6171                    "cmd".to_owned(),
6172                    serde_json::json!({"stdout":"first result","stderr":""}).to_string(),
6173                ),
6174            },
6175            SessionHistoryRecord::Message {
6176                timestamp: 3,
6177                message: ChatMessage::tool(
6178                    "call-second".to_owned(),
6179                    "cmd".to_owned(),
6180                    serde_json::json!({"stdout":"second result","stderr":""}).to_string(),
6181                ),
6182            },
6183        ];
6184
6185        let state = UiState::from_history(&history, "secret", "model", None, false);
6186        let lines = transcript_lines(&state, 200);
6187        assert_eq!(
6188            lines.len(),
6189            3,
6190            "only the two call lines and their separator remain"
6191        );
6192        assert_eq!(lines[0].to_string(), "✓ cmd  $ first");
6193        assert_eq!(lines[2].to_string(), "✓ cmd  $ second");
6194    }
6195    #[test]
6196    fn clipped_slash_picker_uses_its_actual_item_rows_for_the_focused_item() {
6197        let mut state = UiState::from_history(&[], "secret", "model", None, false)
6198            .with_skill_names(
6199                ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
6200                    .into_iter()
6201                    .map(str::to_owned)
6202                    .collect(),
6203            );
6204        state.input = "/".to_owned();
6205        state.input_changed();
6206        state.skill_picker_focus = 5;
6207        let mut terminal =
6208            Terminal::new(ratatui::backend::TestBackend::new(30, 5)).expect("test terminal");
6209        terminal
6210            .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 5)))
6211            .expect("draw clipped skill picker");
6212
6213        let buffer = terminal.backend().buffer();
6214        let item_rows = (2..4)
6215            .map(|y| (2..28).map(|x| buffer[(x, y)].symbol()).collect::<String>())
6216            .collect::<Vec<_>>();
6217        assert!(item_rows[0].starts_with("/deploy"));
6218        assert!(item_rows[1].starts_with("/doctor"));
6219        assert_eq!(buffer[(2, 3)].fg, QUEUED_MESSAGE_COLOR);
6220        assert!(buffer[(2, 3)].modifier.contains(Modifier::BOLD));
6221    }
6222}
6223
6224#[cfg(test)]
6225mod skill_picker_tests {
6226    use super::*;
6227
6228    fn skill_names() -> Vec<String> {
6229        ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
6230            .into_iter()
6231            .map(str::to_owned)
6232            .collect()
6233    }
6234
6235    #[test]
6236    fn built_in_commands_share_the_slash_catalog_without_becoming_skills() {
6237        assert_eq!(
6238            command_names(vec!["release-notes".to_owned(), "settings".to_owned()]),
6239            vec!["exit", "release-notes", "settings"]
6240        );
6241        assert_eq!(
6242            builtin_command("/settings ignored arguments"),
6243            Some(BuiltinCommand::Settings)
6244        );
6245        assert_eq!(builtin_command("  /exit  "), Some(BuiltinCommand::Exit));
6246        assert_eq!(builtin_command("/settings-extra"), None);
6247    }
6248
6249    #[test]
6250    fn settings_viewport_follows_focus_instead_of_truncating_the_catalog_head() {
6251        assert_eq!(selection_range(30, 0, 12), 0..12);
6252        assert_eq!(selection_range(30, 11, 12), 0..12);
6253        assert_eq!(selection_range(30, 12, 12), 1..13);
6254        assert_eq!(selection_range(30, 29, 12), 18..30);
6255    }
6256
6257    #[test]
6258    fn model_selection_uses_advertised_efforts_and_preserves_the_current_choice() {
6259        let mut state = UiState::from_history(&[], "secret", "old", Some("medium"), false);
6260        state.open_catalog(Ok(vec![ProviderModel {
6261            id: "openai/gpt-5.6-sol".to_owned(),
6262            efforts: Some(vec![
6263                "max".to_owned(),
6264                "high".to_owned(),
6265                "medium".to_owned(),
6266                "low".to_owned(),
6267            ]),
6268        }]));
6269        state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6270
6271        let SettingsState::Effort { model, focus, .. } =
6272            state.settings.as_ref().expect("effort picker")
6273        else {
6274            panic!("model selection should open the effort picker");
6275        };
6276        assert_eq!(model.id, "openai/gpt-5.6-sol");
6277        assert_eq!(*focus, 3, "default occupies index zero before medium");
6278
6279        let selected = state
6280            .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
6281            .expect("effort selection");
6282        assert_eq!(
6283            selected,
6284            ("openai/gpt-5.6-sol".to_owned(), Some("medium".to_owned()))
6285        );
6286    }
6287
6288    #[test]
6289    fn effort_default_selection_does_not_shift_to_the_first_advertised_effort() {
6290        let mut state = UiState::from_history(&[], "secret", "old", None, false);
6291        state.open_catalog(Ok(vec![ProviderModel {
6292            id: "model".to_owned(),
6293            efforts: Some(vec!["high".to_owned(), "low".to_owned()]),
6294        }]));
6295        state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6296
6297        let selected = state
6298            .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
6299            .expect("default effort selection");
6300        assert_eq!(selected, ("model".to_owned(), None));
6301    }
6302
6303    #[test]
6304    fn reasoning_indicator_changes_to_complete_and_stays_dark_gray() {
6305        let mut state = UiState::from_history(&[], "secret", "model", None, false);
6306        state.show_thinking();
6307
6308        let active_lines = transcript_lines(&state, 80);
6309        let active = active_lines.last().expect("reasoning line");
6310        assert!(active.to_string().starts_with("Reasoning... "));
6311        assert_eq!(active.style.fg, Some(Color::DarkGray));
6312
6313        state.complete_reasoning();
6314        let complete_lines = transcript_lines(&state, 80);
6315        let complete = complete_lines.last().expect("complete line");
6316        assert_eq!(complete.to_string(), "Reasoning Complete");
6317        assert_eq!(complete.style.fg, Some(Color::DarkGray));
6318    }
6319
6320    #[test]
6321    fn slash_picker_filters_only_leading_command_text_and_hides_without_matches() {
6322        let names = skill_names();
6323        assert_eq!(
6324            matching_skill_names("/", &names),
6325            vec!["alpha", "beta", "build", "charlie", "deploy", "doctor"]
6326        );
6327        assert_eq!(matching_skill_names("/b", &names), vec!["beta", "build"]);
6328        assert!(matching_skill_names("/missing", &names).is_empty());
6329        assert!(matching_skill_names("message /b", &names).is_empty());
6330        assert!(matching_skill_names("/beta arguments", &names).is_empty());
6331    }
6332
6333    #[test]
6334    fn slash_picker_focuses_the_top_match_and_moves_within_filtered_results() {
6335        let mut state = UiState::from_history(&[], "secret", "model", None, false)
6336            .with_skill_names(skill_names());
6337        state.input = "/b".to_owned();
6338        state.input_changed();
6339
6340        assert!(state.skill_picker_visible());
6341        assert_eq!(state.skill_picker_focus, 0);
6342        assert!(state.move_skill_picker(true));
6343        assert_eq!(state.skill_picker_focus, 1);
6344        assert!(state.move_skill_picker(true));
6345        assert_eq!(state.skill_picker_focus, 1, "focus does not leave the list");
6346        assert!(state.move_skill_picker(false));
6347        assert_eq!(state.skill_picker_focus, 0);
6348
6349        state.input = "/missing".to_owned();
6350        state.input_changed();
6351        assert!(!state.skill_picker_visible());
6352        assert!(!state.move_skill_picker(true));
6353    }
6354
6355    #[test]
6356    fn focused_builtins_are_distinguished_from_skills() {
6357        let mut state = UiState::from_history(&[], "secret", "model", None, false)
6358            .with_skill_names(command_names(skill_names()));
6359        state.input = "/se".to_owned();
6360        state.input_changed();
6361        assert_eq!(
6362            state.focused_builtin_command(),
6363            Some(BuiltinCommand::Settings)
6364        );
6365
6366        state.input = "/be".to_owned();
6367        state.input_changed();
6368        assert_eq!(state.focused_builtin_command(), None);
6369    }
6370
6371    #[test]
6372    fn selecting_the_focused_skill_leaves_the_completed_command_ready_to_send() {
6373        let mut state = UiState::from_history(&[], "secret", "model", None, false)
6374            .with_skill_names(skill_names());
6375        state.input = "/b".to_owned();
6376        state.input_changed();
6377        state.move_skill_picker(true);
6378
6379        assert!(state.select_focused_skill());
6380        assert_eq!(state.input, "/build");
6381        assert_eq!(state.cursor, "/build".chars().count());
6382        assert!(
6383            !state.skill_picker_visible(),
6384            "the first Enter completes the input rather than sending it"
6385        );
6386        assert!(
6387            !state.select_focused_skill(),
6388            "a second Enter follows the normal send/attachment path"
6389        );
6390    }
6391
6392    #[test]
6393    fn slash_picker_overlays_without_reflowing_the_transcript_when_match_count_changes() {
6394        let mut state = UiState::from_history(&[], "secret", "model", None, false)
6395            .with_skill_names(skill_names());
6396        let area = Rect::new(0, 0, 40, 16);
6397        state.transcript = (0..20)
6398            .map(|index| TranscriptItem::Assistant(format!("message {index}")))
6399            .collect();
6400
6401        state.input = "/a".to_owned();
6402        state.input_changed();
6403        let (narrow_chat, narrow_picker, _, _, narrow_input, _) = ui_layout(&state, area);
6404        let narrow_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
6405
6406        state.input = "/".to_owned();
6407        state.input_changed();
6408        let (broad_chat, broad_picker, _, _, broad_input, _) = ui_layout(&state, area);
6409        let broad_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
6410
6411        assert_ne!(
6412            narrow_picker, broad_picker,
6413            "the overlay may fit its contents"
6414        );
6415        assert_eq!(narrow_chat, broad_chat);
6416        assert_eq!(narrow_input, broad_input);
6417        assert_eq!(
6418            narrow_scroll, broad_scroll,
6419            "the overlay does not reduce the transcript viewport"
6420        );
6421    }
6422
6423    #[test]
6424    fn slash_picker_keeps_the_focused_item_in_its_five_row_viewport() {
6425        assert_eq!(selection_range(20, 0, 5), 0..5);
6426        assert_eq!(selection_range(20, 4, 5), 0..5);
6427        assert_eq!(selection_range(20, 5, 5), 1..6);
6428        assert_eq!(selection_range(20, 19, 5), 15..20);
6429    }
6430
6431    #[test]
6432    fn is_inside_tmux_detection() {
6433        std::env::set_var("TERM_PROGRAM", "tmux");
6434        assert!(is_inside_tmux());
6435        std::env::set_var("TERM_PROGRAM", "TMUX");
6436        assert!(is_inside_tmux());
6437        std::env::set_var("TERM_PROGRAM", "ghostty");
6438        assert!(!is_inside_tmux());
6439        std::env::remove_var("TERM_PROGRAM");
6440        assert!(!is_inside_tmux());
6441    }
6442
6443    #[test]
6444    fn tui_glow_background_is_ghostty_base_101216() {
6445        assert_eq!(TUI_GLOW_BACKGROUND_RGB, (16, 18, 22));
6446        assert_eq!(TUI_GLOW_BACKGROUND, Color::Rgb(16, 18, 22));
6447    }
6448
6449    #[test]
6450    fn idle_console_has_external_gutters_uniform_background_and_no_borders() {
6451        let mut state = UiState::from_history(&[], "secret", "model", None, false);
6452        state.input = "prompt".to_owned();
6453        state.cursor = state.input.chars().count();
6454        let area = Rect::new(0, 0, 80, 10);
6455        let mut terminal =
6456            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
6457                .expect("test terminal");
6458
6459        terminal
6460            .draw(|frame| draw(frame, &state))
6461            .expect("draw idle console");
6462
6463        let input_area = ui_layout(&state, tui_viewport(area)).4;
6464        let prompt_area = prompt_area(input_area, &state);
6465        let buffer = terminal.backend().buffer();
6466        let bottom_y = area.y + area.height - 1;
6467        assert_eq!(buffer[(area.x, bottom_y)].bg, Color::Reset);
6468        assert_eq!(buffer[(area.x + area.width - 1, bottom_y)].bg, Color::Reset);
6469        for y in input_area.y..input_area.y + input_area.height {
6470            assert_eq!(buffer[(0, y)].bg, Color::Reset);
6471            assert_eq!(buffer[(79, y)].bg, Color::Reset);
6472            for x in input_area.x..input_area.x + input_area.width {
6473                assert_eq!(buffer[(x, y)].bg, CONSOLE_BACKGROUND);
6474            }
6475        }
6476        assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), " ");
6477        assert_eq!(
6478            buffer[(input_area.x + input_area.width - 1, input_area.y)].symbol(),
6479            " "
6480        );
6481        assert_eq!(
6482            buffer[(input_area.x, input_area.y + input_area.height - 1)].symbol(),
6483            " "
6484        );
6485        assert_eq!(
6486            buffer[(
6487                input_area.x + input_area.width - 1,
6488                input_area.y + input_area.height - 1
6489            )]
6490                .symbol(),
6491            " "
6492        );
6493        assert_eq!(buffer[(prompt_area.x, prompt_area.y)].symbol(), "p");
6494        assert_eq!(buffer[(prompt_area.x, prompt_area.y)].fg, Color::White);
6495        terminal.backend_mut().assert_cursor_position((
6496            prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
6497            prompt_area.y,
6498        ));
6499    }
6500
6501    #[test]
6502    fn slash_picker_is_rendered_immediately_above_the_input() {
6503        let mut state = UiState::from_history(&[], "secret", "model", None, false)
6504            .with_skill_names(skill_names());
6505        state.input = "/".to_owned();
6506        state.input_changed();
6507        let mut terminal =
6508            Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
6509        terminal
6510            .draw(|frame| draw(frame, &state))
6511            .expect("draw TUI");
6512
6513        let buffer = terminal.backend().buffer();
6514        let area = tui_viewport(Rect::new(0, 0, 40, 12));
6515        let (_, picker_area, _, _, input_area, _) = ui_layout(&state, area);
6516        let picker_area = picker_area.expect("picker area");
6517        // The picker shares a boundary with the prompt; no blank row separates them.
6518        assert_eq!(picker_area.y + picker_area.height, input_area.y);
6519        for (x, y) in [
6520            (picker_area.x, picker_area.y),
6521            (picker_area.x + picker_area.width - 1, picker_area.y),
6522            (picker_area.x, picker_area.y + picker_area.height - 1),
6523            (
6524                picker_area.x + picker_area.width - 1,
6525                picker_area.y + picker_area.height - 1,
6526            ),
6527        ] {
6528            assert_eq!(buffer[(x, y)].symbol(), " ");
6529            assert_eq!(buffer[(x, y)].bg, SKILL_PICKER_BACKGROUND);
6530        }
6531        assert_eq!(
6532            buffer[(picker_area.x + 1, picker_area.y + 1)].bg,
6533            SKILL_PICKER_BACKGROUND
6534        );
6535        assert_eq!(buffer[(picker_area.x + 2, picker_area.y + 1)].symbol(), "[");
6536        assert_eq!(
6537            buffer[(picker_area.x + 2, picker_area.y + 1)].fg,
6538            QUEUED_MESSAGE_COLOR
6539        );
6540        assert_eq!(buffer[(picker_area.x + 2, picker_area.y + 2)].symbol(), "/");
6541        assert_eq!(
6542            buffer[(picker_area.x + 2, picker_area.y + 2)].fg,
6543            QUEUED_MESSAGE_COLOR
6544        );
6545        assert_eq!(
6546            buffer[(picker_area.x + 1, picker_area.y + picker_area.height - 2)].bg,
6547            SKILL_PICKER_BACKGROUND
6548        );
6549        assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), " ");
6550        assert_eq!(buffer[(input_area.x, input_area.y)].bg, CONSOLE_BACKGROUND);
6551    }
6552
6553    #[test]
6554    fn slash_picker_renders_count_with_bold_focus_on_the_picker_surface() {
6555        let mut state = UiState::from_history(&[], "secret", "model", None, false)
6556            .with_skill_names(skill_names());
6557        state.input = "/".to_owned();
6558        state.input_changed();
6559        let mut terminal =
6560            Terminal::new(ratatui::backend::TestBackend::new(30, 8)).expect("test terminal");
6561        terminal
6562            .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 8)))
6563            .expect("draw skill picker");
6564
6565        let buffer = terminal.backend().buffer();
6566        assert_eq!(buffer[(0, 0)].symbol(), " ");
6567        assert_eq!(buffer[(0, 0)].bg, SKILL_PICKER_BACKGROUND);
6568        assert_eq!(buffer[(2, 1)].symbol(), "[");
6569        assert_eq!(buffer[(2, 1)].fg, QUEUED_MESSAGE_COLOR);
6570        assert_eq!(buffer[(2, 2)].symbol(), "/");
6571        assert_eq!(buffer[(2, 2)].fg, QUEUED_MESSAGE_COLOR);
6572        assert!(buffer[(2, 2)].modifier.contains(Modifier::BOLD));
6573        assert_eq!(buffer[(2, 3)].symbol(), "/");
6574        assert_eq!(buffer[(2, 3)].fg, QUEUED_MESSAGE_COLOR);
6575        assert!(!buffer[(2, 3)].modifier.contains(Modifier::BOLD));
6576    }
6577}
6578
6579#[cfg(test)]
6580mod tmux_keyboard_tests {
6581    use super::*;
6582
6583    #[test]
6584    fn is_inside_tmux_detection() {
6585        std::env::set_var("TERM_PROGRAM", "tmux");
6586        assert!(is_inside_tmux());
6587        std::env::set_var("TERM_PROGRAM", "TMUX");
6588        assert!(is_inside_tmux());
6589        std::env::set_var("TERM_PROGRAM", "ghostty");
6590        assert!(!is_inside_tmux());
6591        std::env::remove_var("TERM_PROGRAM");
6592        assert!(!is_inside_tmux());
6593    }
6594}