Skip to main content

lucy/
tui.rs

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