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