Skip to main content

lucy/
tui.rs

1use std::collections::{HashMap, HashSet};
2use std::io::{self, Write};
3use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
4use std::sync::{Arc, Mutex, OnceLock};
5use std::thread::{self, JoinHandle};
6use std::time::{Duration, Instant};
7
8use crossterm::cursor::{Hide, Show};
9use crossterm::event::{
10    self, DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture, Event,
11    KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags, MouseEventKind,
12    PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
13};
14use crossterm::execute;
15use crossterm::terminal::{
16    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
17};
18use ratatui::backend::CrosstermBackend;
19use ratatui::layout::{Alignment, Rect, Size};
20use ratatui::prelude::Frame;
21use ratatui::style::{Color, Modifier, Style};
22use ratatui::text::{Line, Span};
23use ratatui::widgets::{Block, Borders, Clear, Paragraph};
24use ratatui::Terminal;
25use ratatui_image::picker::Picker;
26use ratatui_image::protocol::Protocol;
27use ratatui_image::{Image as TuiImage, Resize};
28use serde_json::Value;
29use unicode_width::UnicodeWidthStr;
30
31use crate::app::{Harness, SubagentActivity};
32use crate::cancellation::CancellationToken;
33use crate::model::{estimate_context_tokens, ChatMessage};
34use crate::protocol::{EventSink, ProtocolEvent};
35use crate::provider::ProviderModel;
36use crate::redaction::redact_secret;
37use crate::session::SessionHistoryRecord;
38
39const EVENT_POLL: Duration = Duration::from_millis(50);
40const MAX_DISPLAY_INPUT_CHARS: usize = 16 * 1024;
41const WORKER_SHUTDOWN_GRACE: Duration = Duration::from_secs(2);
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 TUI_GLOW_BACKGROUND_RGB: (u8, u8, u8) = (16, 18, 22);
63const TUI_GLOW_BACKGROUND: Color = Color::Rgb(
64    TUI_GLOW_BACKGROUND_RGB.0,
65    TUI_GLOW_BACKGROUND_RGB.1,
66    TUI_GLOW_BACKGROUND_RGB.2,
67);
68const CONSOLE_BACKGROUND_RGB: (u8, u8, u8) = (42, 42, 46);
69const CONSOLE_BACKGROUND: Color = Color::Rgb(
70    CONSOLE_BACKGROUND_RGB.0,
71    CONSOLE_BACKGROUND_RGB.1,
72    CONSOLE_BACKGROUND_RGB.2,
73);
74const CONSOLE_STATUS_COLOR: Color = Color::Rgb(144, 144, 148);
75const CONSOLE_ACCENT_LAVENDER: (u8, u8, u8) = (145, 70, 220);
76const CONSOLE_ACCENT_TEAL: (u8, u8, u8) = (0, 180, 180);
77const CONSOLE_ACCENT_CYCLE_DURATION: Duration = Duration::from_secs(15);
78const CONSOLE_ACCENT_DESATURATION: f32 = 0.15;
79const CONSOLE_GLASS_DESATURATION: f32 = 0.65;
80const CONSOLE_GLASS_TINT: f32 = 0.24;
81const CONSOLE_GLASS_WHITE_TINT: f32 = 0.03;
82const CONSOLE_GLASS_GLOW_THROUGH: f32 = 0.26;
83const CONSOLE_REFLECTION_TINT: f32 = 0.12;
84const CONSOLE_REFLECTION_WHITE_TINT: f32 = 0.20;
85const CONSOLE_REFLECTION_GLYPH: &str = "▁";
86const GLOW_HEIGHT: u16 = 12;
87const GLOW_HORIZONTAL_SPREAD: u16 = 24;
88const GLOW_INTENSITY: f32 = 0.70;
89const GLOW_DESATURATION: f32 = 0.10;
90const CONSOLE_BOUNDARY_CYCLE: Duration = Duration::from_millis(7000);
91const CONSOLE_REACH_MIN: f32 = 0.28;
92const CONSOLE_REACH_MAX: f32 = 0.38;
93const CONSOLE_VISIBILITY_TRANSITION: Duration = Duration::from_millis(600);
94const SKILL_TRIGGER_COLOR: Color = Color::Rgb(80, 255, 245);
95const PENDING_TOOL_COLOR_RGB: (u8, u8, u8) = (255, 165, 0);
96const PENDING_TOOL_COLOR: Color = Color::Rgb(
97    PENDING_TOOL_COLOR_RGB.0,
98    PENDING_TOOL_COLOR_RGB.1,
99    PENDING_TOOL_COLOR_RGB.2,
100);
101/// A completed `cmd` call first retains its pending orange, then sweeps to the
102/// final result colour from the left edge of the compact tool line.
103const TOOL_RESULT_SWEEP_DURATION: Duration = Duration::from_millis(1200);
104/// Each character spends this portion of the sweep cross-fading. The remaining
105/// time staggers those fades from the first character to the last.
106const TOOL_RESULT_CHARACTER_FADE_PORTION: f32 = 0.4;
107const TOOL_SUCCESS_COLOR_RGB: (u8, u8, u8) = (0, 210, 175);
108const TOOL_SUCCESS_COLOR: Color = Color::Rgb(
109    TOOL_SUCCESS_COLOR_RGB.0,
110    TOOL_SUCCESS_COLOR_RGB.1,
111    TOOL_SUCCESS_COLOR_RGB.2,
112);
113const TOOL_FAILURE_COLOR: Color = Color::Rgb(255, 0, 0);
114const TOOL_WARNING_COLOR: Color = Color::Rgb(255, 255, 0);
115const QUEUED_MESSAGE_COLOR: Color = Color::Rgb(150, 255, 245);
116/// Floating panels are deliberately darker than the console while remaining neutral gray.
117const FLOATING_PANEL_BACKGROUND: Color = Color::Rgb(28, 28, 30);
118const SKILL_PICKER_BACKGROUND: Color = FLOATING_PANEL_BACKGROUND;
119const SECTION_CHROME_COLOR: Color = Color::Rgb(0, 180, 180);
120const SUBAGENT_TITLE_COLOR: Color = Color::Rgb(165, 35, 135);
121const SKILL_PICKER_MAX_ROWS: usize = 5;
122const SUBAGENT_TASK_PREVIEW_CHARS: usize = 25;
123const SUBAGENT_STREAM_PREVIEW_HEIGHT: u16 = 15;
124const SUBAGENT_STREAM_MAX_CHARS: usize = 12 * 1024;
125const SUBAGENT_NOTICE_DURATION: Duration = Duration::from_millis(600);
126const SUBAGENT_NOTICE_FLASH_INTERVAL: Duration = Duration::from_millis(100);
127const BUILTIN_COMMANDS: [&str; 2] = ["settings", "exit"];
128const SUBAGENT_OVERLAY_BACKGROUND: Color = FLOATING_PANEL_BACKGROUND;
129const SETTINGS_MIN_WIDTH: u16 = 36;
130const SETTINGS_MAX_WIDTH: u16 = 88;
131const SETTINGS_MIN_HEIGHT: u16 = 8;
132const SETTINGS_MAX_HEIGHT: u16 = 22;
133
134pub(crate) fn run<W: Write>(mut harness: Harness, resumed: bool, stdout: W) -> Result<(), String> {
135    let secret = harness.provider.api_key().to_owned();
136    let context_window = harness
137        .context_window
138        .or_else(|| harness.provider.context_window());
139    harness.context_window = context_window;
140    let context_tokens = estimate_context_tokens(&harness.session.provider_messages());
141    let skill_names = command_names(
142        harness
143            .session
144            .skills
145            .iter()
146            .map(|skill| skill.name.clone())
147            .collect(),
148    );
149    let mut state = UiState::from_history(
150        &harness.session.history,
151        &secret,
152        &harness.session.llm.model,
153        harness.session.llm.effort.as_deref(),
154        resumed,
155    )
156    .with_attached_agents(harness.attached_agents.clone())
157    .with_skill_names(skill_names)
158    .with_context(context_window, context_tokens);
159    let (request_tx, request_rx) = mpsc::channel::<WorkerRequest>();
160    let (message_tx, message_rx) = mpsc::channel::<WorkerMessage>();
161    let subagent_activity_rx = harness.take_subagent_activity_receiver();
162
163    let stdout = stdout;
164    enable_raw_mode().map_err(|error| format!("unable to enable terminal input: {error}"))?;
165    let backend = CrosstermBackend::new(stdout);
166    let terminal = match Terminal::new(backend) {
167        Ok(terminal) => terminal,
168        Err(error) => {
169            let _ = disable_raw_mode();
170            return Err(format!("unable to initialize terminal UI: {error}"));
171        }
172    };
173    let mut terminal_guard = TerminalGuard::new(terminal);
174    let backend = terminal_guard.terminal_mut().backend_mut();
175    if let Err(error) = execute!(
176        backend,
177        EnterAlternateScreen,
178        EnableFocusChange,
179        EnableMouseCapture,
180        Hide
181    ) {
182        return Err(format!("unable to enter terminal UI: {error}"));
183    }
184    // Kitty keyboard protocol makes Shift+Enter (and other modified keys)
185    // distinguishable from plain Enter. Only push it on terminals known to
186    // support it; otherwise the enhancement sequence would leak as literal
187    // text on screen.
188    let keyboard_enhanced = supports_keyboard_enhancement();
189    if keyboard_enhanced {
190        let _ = execute!(
191            backend,
192            PushKeyboardEnhancementFlags(
193                KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
194                    | KeyboardEnhancementFlags::REPORT_EVENT_TYPES,
195            )
196        );
197    }
198    // tmux does not proxy the kitty keyboard protocol, but it does
199    // recognize modifyOtherKeys (CSI > 4;1m). Enable it so tmux sends
200    // extended key sequences in CSI u format, which crossterm parses
201    // when PushKeyboardEnhancementFlags has been sent.
202    let in_tmux = is_inside_tmux();
203    if in_tmux {
204        let _ = backend
205            .write_all(b"\x1b[>4;1m")
206            .and_then(|_| backend.flush());
207    }
208    // `backend` borrows from `terminal_guard`; all writes are done so
209    // the borrow has ended and we can now set the guard flags.
210    if keyboard_enhanced {
211        terminal_guard.keyboard_enhancement = true;
212    }
213    if in_tmux {
214        terminal_guard.modify_other_keys = true;
215    }
216    let worker = thread::spawn(move || worker_loop(&mut harness, request_rx, message_tx, resumed));
217
218    let result = event_loop(
219        terminal_guard.terminal_mut(),
220        &mut state,
221        &request_tx,
222        &message_rx,
223        &subagent_activity_rx,
224    );
225
226    if let Some(token) = state.active_cancel.take() {
227        let _ = token.cancel();
228    }
229    let _ = request_tx.send(WorkerRequest::Shutdown);
230    wait_for_worker(worker, WORKER_SHUTDOWN_GRACE);
231    drop(terminal_guard);
232    result
233}
234
235fn worker_loop(
236    harness: &mut Harness,
237    requests: Receiver<WorkerRequest>,
238    messages: Sender<WorkerMessage>,
239    resumed: bool,
240) {
241    let mut sink = ChannelSink {
242        sender: messages.clone(),
243    };
244    if sink
245        .emit_event(&ProtocolEvent::Session {
246            session_id: harness.session.id.clone(),
247            resumed,
248        })
249        .is_err()
250    {
251        return;
252    }
253
254    loop {
255        while let Some(activity) = harness.next_subagent_activity() {
256            let _ = messages.send(WorkerMessage::SubagentActivity(activity));
257        }
258        if let Err(error) = harness.collect_completed_subagents(&mut sink) {
259            let message = redact_secret(&error, Some(harness.provider.api_key()));
260            let _ = sink.emit_event(&ProtocolEvent::Error { message });
261        }
262        let request = match requests.recv_timeout(EVENT_POLL) {
263            Ok(request) => request,
264            Err(mpsc::RecvTimeoutError::Timeout) => {
265                while let Some(activity) = harness.next_subagent_activity() {
266                    let _ = messages.send(WorkerMessage::SubagentActivity(activity));
267                }
268                if let Err(error) = harness.collect_completed_subagents(&mut sink) {
269                    let message = redact_secret(&error, Some(harness.provider.api_key()));
270                    let _ = sink.emit_event(&ProtocolEvent::Error { message });
271                }
272                continue;
273            }
274            Err(mpsc::RecvTimeoutError::Disconnected) => break,
275        };
276        match request {
277            WorkerRequest::Turn { text } => {
278                let cancel = CancellationToken::new();
279                let _ = messages.send(WorkerMessage::Started {
280                    cancel: cancel.clone(),
281                    user_text: Some(text.clone()),
282                });
283                if let Err(error) = harness.handle_message(&text, &mut sink, Some(&cancel)) {
284                    let message = redact_secret(&error, Some(harness.provider.api_key()));
285                    let _ = sink.emit_event(&ProtocolEvent::Error { message });
286                }
287                let _ = messages.send(WorkerMessage::Finished);
288            }
289            WorkerRequest::Catalog => {
290                let _ = messages.send(WorkerMessage::Catalog(
291                    harness.provider.models().map_err(|error| error.to_string()),
292                ));
293            }
294            WorkerRequest::ApplySettings { model, effort } => {
295                let result = harness.apply_settings(&harness.home.clone(), model, effort);
296                let _ = messages.send(WorkerMessage::SettingsApplied(
297                    result,
298                    harness.session.llm.model.clone(),
299                    harness.session.llm.effort.clone(),
300                    harness.context_window,
301                ));
302            }
303            WorkerRequest::Shutdown => break,
304        }
305    }
306}
307
308fn event_loop<W: Write>(
309    terminal: &mut Terminal<CrosstermBackend<W>>,
310    state: &mut UiState,
311    requests: &Sender<WorkerRequest>,
312    messages: &Receiver<WorkerMessage>,
313    subagent_activities: &Receiver<SubagentActivity>,
314) -> Result<(), String> {
315    let mut quitting = false;
316    loop {
317        loop {
318            match messages.try_recv() {
319                Ok(WorkerMessage::Event(event)) => state.apply_event(event),
320                Ok(WorkerMessage::SubagentActivity(activity)) => {
321                    state.apply_subagent_activity(activity);
322                }
323                Ok(WorkerMessage::Started { cancel, user_text }) => {
324                    if let Some(text) = user_text {
325                        state.start_queued_user(&text);
326                    }
327                    state.active_cancel = Some(cancel);
328                    state.set_busy(true);
329                    state.set_status("working");
330                }
331                Ok(WorkerMessage::Thinking) => state.show_thinking(),
332                Ok(WorkerMessage::ReasoningCompleted) => state.complete_reasoning(),
333                Ok(WorkerMessage::SkillInstructionAttached) => {
334                    state.mark_latest_user_skill_attached()
335                }
336                Ok(WorkerMessage::ContextUsage(tokens)) => state.context_tokens = tokens,
337                Ok(WorkerMessage::CompactionStarted) => state.set_status("compacting"),
338                Ok(WorkerMessage::CompactionFinished {
339                    tokens_before,
340                    tokens_after,
341                }) => {
342                    state.context_tokens = tokens_after;
343                    state.set_status("working");
344                    state.transcript.push(TranscriptItem::Info(format!(
345                        "↻ context compacted ({} → {})",
346                        format_context_tokens(tokens_before),
347                        format_context_tokens(tokens_after)
348                    )));
349                }
350                Ok(WorkerMessage::Catalog(result)) => state.open_catalog(result),
351                Ok(WorkerMessage::SettingsApplied(result, model, effort, context_window)) => {
352                    state.settings_applied(result, model, effort, context_window)
353                }
354                Ok(WorkerMessage::Finished) => {
355                    release_finished_turn(terminal.backend_mut(), state);
356                    match state.status.as_str() {
357                        "cancelling" => state.set_status("사용자 중단"),
358                        "finalizing" => state.set_status("ready"),
359                        _ => {}
360                    }
361                    if quitting {
362                        return Ok(());
363                    }
364                }
365                Err(TryRecvError::Empty) => break,
366                Err(TryRecvError::Disconnected) => {
367                    if state.busy {
368                        return Err("TUI worker stopped unexpectedly".to_owned());
369                    }
370                    return Ok(());
371                }
372            }
373        }
374
375        while let Ok(activity) = subagent_activities.try_recv() {
376            state.apply_subagent_activity(activity);
377        }
378
379        // Ratatui flushes the buffer diff (which issues MoveTo for every
380        // changed cell) before it hides or shows the cursor. If the hardware
381        // cursor is visible during that flush it briefly appears at each
382        // changed cell — most noticeable across the animated glow region in
383        // busy state. Hide it first so the flush phase never shows it; Ratatui
384        // will re-show it at the prompt position after flush when needed.
385        let _ = execute!(terminal.backend_mut(), Hide);
386
387        terminal
388            .draw(|frame| draw(frame, state))
389            .map_err(|error| format!("unable to render TUI: {error}"))?;
390
391        if quitting {
392            thread::sleep(EVENT_POLL);
393            continue;
394        }
395        if event::poll(EVENT_POLL)
396            .map_err(|error| format!("unable to read terminal input: {error}"))?
397        {
398            let event =
399                event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
400            if handle_terminal_focus_event(state, &event) {
401                continue;
402            }
403            let key = match event {
404                Event::Mouse(mouse) => {
405                    let size = terminal
406                        .size()
407                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
408                    let max_scroll = max_scroll_for_area(state, size);
409                    handle_mouse_event(state, mouse.kind, max_scroll);
410                    continue;
411                }
412                Event::Key(key) => key,
413                _ => continue,
414            };
415            if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
416                continue;
417            }
418            if is_ctrl_c(&key) {
419                if let Some(token) = state.active_cancel.as_ref() {
420                    let _ = token.cancel();
421                    quitting = true;
422                } else {
423                    return Ok(());
424                }
425                continue;
426            }
427            if !state.busy && state.settings.is_some() {
428                if let Some((model, effort)) = state.handle_settings_key(&key) {
429                    state.settings = Some(SettingsState::Applying {
430                        model: model.clone(),
431                        effort: effort.clone(),
432                    });
433                    requests
434                        .send(WorkerRequest::ApplySettings { model, effort })
435                        .map_err(|_| "TUI worker is unavailable".to_owned())?;
436                }
437                continue;
438            }
439            if key.code == KeyCode::Esc {
440                if state.subagent_focus.is_some() {
441                    state.clear_subagent_focus();
442                    continue;
443                }
444                if let Some(token) = state.active_cancel.as_ref() {
445                    if token.cancel() {
446                        state.set_status("cancelling");
447                    }
448                }
449                continue;
450            }
451            // While the background-worker list owns focus, swallow typing so it
452            // cannot mutate the prompt underneath the stream overlay.
453            if state.subagent_focus.is_some()
454                && matches!(
455                    key.code,
456                    KeyCode::Char(_)
457                        | KeyCode::Backspace
458                        | KeyCode::Enter
459                        | KeyCode::Tab
460                        | KeyCode::Left
461                        | KeyCode::Right
462                        | KeyCode::Home
463                        | KeyCode::End
464                )
465            {
466                continue;
467            }
468            match key.code {
469                KeyCode::Enter => {
470                    // Shift+Enter (and Alt+Enter fallback) insert a literal
471                    // newline so the user can write multi-line prompts. Plain
472                    // Enter sends the turn. Many terminals cannot distinguish
473                    // Shift+Enter from Enter, so Alt+Enter is also accepted.
474                    if key.modifiers.contains(KeyModifiers::SHIFT)
475                        || key.modifiers.contains(KeyModifiers::ALT)
476                    {
477                        if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
478                            insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
479                            state.input_changed();
480                        }
481                        continue;
482                    }
483                    // A focused built-in is an action, unlike a skill: Enter
484                    // invokes it immediately. Tab remains completion-only.
485                    let text = if let Some(command) = state.focused_builtin_command() {
486                        state.input.clear();
487                        format!("/{}", command.name())
488                    } else {
489                        if state.select_focused_skill() {
490                            continue;
491                        }
492                        std::mem::take(&mut state.input)
493                    };
494                    state.cursor = 0;
495                    if let Some(command) = builtin_command(&text) {
496                        state.reset_skill_picker();
497                        if state.busy {
498                            state.transcript.push(TranscriptItem::Info(format!(
499                                "/{} is available when the current turn finishes",
500                                command.name()
501                            )));
502                            continue;
503                        }
504                        match command {
505                            BuiltinCommand::Settings => {
506                                state.settings = Some(SettingsState::Loading);
507                                requests
508                                    .send(WorkerRequest::Catalog)
509                                    .map_err(|_| "TUI worker is unavailable".to_owned())?;
510                                continue;
511                            }
512                            BuiltinCommand::Exit => return Ok(()),
513                        }
514                    }
515                    state.reset_skill_picker();
516                    if text.trim().is_empty() {
517                        continue;
518                    }
519                    state.auto_scroll = true;
520                    state.scroll = 0;
521                    state.submit_user(&text);
522                    state.set_busy(true);
523                    state.set_status("working");
524                    requests
525                        .send(WorkerRequest::Turn { text })
526                        .map_err(|_| "TUI worker is unavailable".to_owned())?;
527                }
528                KeyCode::Tab => {
529                    // Tab completes the focused skill while the slash picker
530                    // is active, using the same first-selection path as Enter.
531                    state.select_focused_skill();
532                }
533                KeyCode::Char(character) => {
534                    if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
535                        insert_at_cursor(&mut state.input, &mut state.cursor, character);
536                        state.input_changed();
537                    }
538                }
539                KeyCode::Backspace => {
540                    if remove_before_cursor(&mut state.input, &mut state.cursor) {
541                        state.input_changed();
542                    }
543                }
544                KeyCode::Left => {
545                    state.cursor = state.cursor.saturating_sub(1);
546                }
547                KeyCode::Right => {
548                    state.cursor = (state.cursor + 1).min(state.input.chars().count());
549                }
550                KeyCode::Home => {
551                    state.cursor = 0;
552                }
553                KeyCode::End => {
554                    state.cursor = state.input.chars().count();
555                }
556                KeyCode::Up => {
557                    let size = terminal
558                        .size()
559                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
560                    let area = tui_viewport(Rect::new(0, 0, size.width, size.height));
561                    let input_width = ui_prompt_content_width(area).max(1) as usize;
562                    if !move_up_from_input_or_subagent(state, input_width) {
563                        let max_scroll = max_scroll_for_area(state, size);
564                        scroll_up(state, max_scroll);
565                    }
566                }
567                KeyCode::Down => {
568                    if state.subagent_focus.is_some() {
569                        let _ = state.move_subagent_focus(true);
570                    } else {
571                        let size = terminal
572                            .size()
573                            .map_err(|error| format!("unable to read terminal size: {error}"))?;
574                        let area = tui_viewport(Rect::new(0, 0, size.width, size.height));
575                        let input_width = ui_prompt_content_width(area).max(1) as usize;
576                        if !move_down_from_input(state, input_width) {
577                            let max_scroll = max_scroll_for_area(state, size);
578                            scroll_down(state, max_scroll);
579                        }
580                    }
581                }
582                KeyCode::PageUp => {
583                    let size = terminal
584                        .size()
585                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
586                    let max_scroll = max_scroll_for_area(state, size);
587                    scroll_up(state, max_scroll);
588                }
589                KeyCode::PageDown => {
590                    let size = terminal
591                        .size()
592                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
593                    let max_scroll = max_scroll_for_area(state, size);
594                    scroll_down(state, max_scroll);
595                }
596                _ => {}
597            }
598        }
599    }
600}
601
602fn handle_terminal_focus_event(state: &mut UiState, event: &Event) -> bool {
603    match event {
604        Event::FocusGained => state.terminal_focused = true,
605        Event::FocusLost => state.terminal_focused = false,
606        _ => return false,
607    }
608    true
609}
610
611fn is_ctrl_c(key: &KeyEvent) -> bool {
612    key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
613}
614
615fn handle_mouse_event(state: &mut UiState, kind: MouseEventKind, max_scroll: u16) {
616    match kind {
617        MouseEventKind::ScrollUp => scroll_up(state, max_scroll),
618        MouseEventKind::ScrollDown => scroll_down(state, max_scroll),
619        _ => {}
620    }
621}
622
623fn scroll_up(state: &mut UiState, max_scroll: u16) {
624    if state.auto_scroll {
625        state.scroll = max_scroll;
626        state.auto_scroll = false;
627    } else {
628        state.scroll = state.scroll.min(max_scroll);
629    }
630    state.scroll = state.scroll.saturating_sub(3);
631}
632
633fn scroll_down(state: &mut UiState, max_scroll: u16) {
634    if state.auto_scroll {
635        return;
636    }
637    state.scroll = state.scroll.saturating_add(3).min(max_scroll);
638    if state.scroll == max_scroll {
639        // Reaching the real bottom is an explicit request to resume following
640        // the transcript, so subsequent streamed output stays visible.
641        state.auto_scroll = true;
642        state.scroll = 0;
643    }
644}
645
646fn wait_for_worker(worker: JoinHandle<()>, grace: Duration) {
647    let deadline = std::time::Instant::now() + grace;
648    while !worker.is_finished() && std::time::Instant::now() < deadline {
649        thread::sleep(Duration::from_millis(5));
650    }
651    if worker.is_finished() {
652        let _ = worker.join();
653    }
654}
655
656struct TerminalGuard<W: Write> {
657    terminal: Option<Terminal<CrosstermBackend<W>>>,
658    keyboard_enhancement: bool,
659    modify_other_keys: bool,
660}
661
662impl<W: Write> TerminalGuard<W> {
663    fn new(terminal: Terminal<CrosstermBackend<W>>) -> Self {
664        Self {
665            terminal: Some(terminal),
666            keyboard_enhancement: false,
667            modify_other_keys: false,
668        }
669    }
670
671    fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {
672        self.terminal
673            .as_mut()
674            .expect("terminal guard is initialized")
675    }
676}
677
678impl<W: Write> Drop for TerminalGuard<W> {
679    fn drop(&mut self) {
680        let Some(mut terminal) = self.terminal.take() else {
681            return;
682        };
683        if self.modify_other_keys {
684            let _ = terminal
685                .backend_mut()
686                .write_all(b"\x1b[>4;0m")
687                .and_then(|_| terminal.backend_mut().flush());
688        }
689        if self.keyboard_enhancement {
690            let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
691        }
692        let _ = terminal.show_cursor();
693        let _ = disable_raw_mode();
694        let _ = execute!(
695            terminal.backend_mut(),
696            DisableFocusChange,
697            DisableMouseCapture,
698            LeaveAlternateScreen,
699            Show
700        );
701        let _ = terminal.backend_mut().flush();
702    }
703}
704
705/// Heuristic for terminals that implement the kitty keyboard protocol.
706/// `PushKeyboardEnhancementFlags` is a no-op on supported terminals, but on
707/// unsupported ones the CSI sequence can render as literal text, so it is only
708/// enabled when the terminal advertises support via `TERM`/`TERM_PROGRAM`.
709fn supports_keyboard_enhancement() -> bool {
710    fn env(name: &str) -> Option<String> {
711        std::env::var(name).ok().map(|value| value.to_lowercase())
712    }
713    let term = env("TERM").unwrap_or_default();
714    let program = env("TERM_PROGRAM").unwrap_or_default();
715    if term.starts_with("xterm-kitty")
716        || term.starts_with("ghostty")
717        || term.starts_with("xterm-ghostty")
718    {
719        return true;
720    }
721    if matches!(
722        program.as_str(),
723        "ghostty" | "kitty" | "wezterm" | "alacritty" | "foot" | "footclient" | "iterm.app"
724    ) {
725        return true;
726    }
727    // tmux does not support the kitty keyboard protocol (CSI > flags u)
728    // passthrough, but it does support modifyOtherKeys (CSI > 4;1m). Push
729    // kitty flags anyway so crossterm parses CSI u format sequences, and
730    // separately enable modifyOtherKeys so tmux sends extended keys.
731    if program == "tmux" {
732        return true;
733    }
734    false
735}
736
737/// Whether the process is running inside a tmux session.
738fn is_inside_tmux() -> bool {
739    std::env::var("TERM_PROGRAM")
740        .map(|value| value.eq_ignore_ascii_case("tmux"))
741        .unwrap_or(false)
742}
743
744#[derive(Debug, Clone, Copy, PartialEq, Eq)]
745enum TurnNotification {
746    Completed,
747    Interrupted,
748    Failed,
749}
750
751impl TurnNotification {
752    fn body(self) -> &'static str {
753        match self {
754            Self::Completed => "Turn complete",
755            Self::Interrupted => "Turn interrupted",
756            Self::Failed => "Turn failed",
757        }
758    }
759}
760
761fn turn_notification_for_status(status: &str) -> TurnNotification {
762    match status {
763        "cancelling" | "사용자 중단" => TurnNotification::Interrupted,
764        "error" => TurnNotification::Failed,
765        _ => TurnNotification::Completed,
766    }
767}
768
769/// Ask terminal emulators that support OSC 777 to show a desktop notification.
770///
771/// The title and body are fixed Lucy-owned strings rather than model/provider
772/// text, so completion notifications cannot inject terminal control data or
773/// expose a secret. Terminals without OSC 777 support safely ignore the OSC.
774fn send_turn_notification<W: Write>(
775    writer: &mut W,
776    notification: TurnNotification,
777) -> io::Result<()> {
778    writer.write_all(b"\x1b]777;notify;Lucy;")?;
779    writer.write_all(notification.body().as_bytes())?;
780    writer.write_all(b"\x07")?;
781    writer.flush()
782}
783
784fn release_finished_turn<W: Write>(writer: &mut W, state: &mut UiState) {
785    let was_busy = state.busy;
786    let notification = turn_notification_for_status(&state.status);
787    state.set_busy(false);
788    state.active_cancel = None;
789    if was_busy {
790        // Notification failure must never change the completed turn result or
791        // make the TUI unusable.
792        let _ = send_turn_notification(writer, notification);
793    }
794}
795
796enum WorkerRequest {
797    Turn {
798        text: String,
799    },
800    Catalog,
801    ApplySettings {
802        model: String,
803        effort: Option<String>,
804    },
805    Shutdown,
806}
807
808enum WorkerMessage {
809    Event(ProtocolEvent),
810    Started {
811        cancel: CancellationToken,
812        user_text: Option<String>,
813    },
814    Thinking,
815    ReasoningCompleted,
816    SkillInstructionAttached,
817    ContextUsage(usize),
818    CompactionStarted,
819    CompactionFinished {
820        tokens_before: usize,
821        tokens_after: usize,
822    },
823    Catalog(Result<Vec<ProviderModel>, String>),
824    SettingsApplied(Result<(), String>, String, Option<String>, Option<usize>),
825    SubagentActivity(SubagentActivity),
826    Finished,
827}
828
829struct ChannelSink {
830    sender: Sender<WorkerMessage>,
831}
832
833impl EventSink for ChannelSink {
834    fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
835        self.sender
836            .send(WorkerMessage::Event(event.clone()))
837            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
838    }
839
840    fn reasoning_started(&mut self) -> io::Result<()> {
841        self.sender
842            .send(WorkerMessage::Thinking)
843            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
844    }
845
846    fn reasoning_completed(&mut self) -> io::Result<()> {
847        self.sender
848            .send(WorkerMessage::ReasoningCompleted)
849            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
850    }
851
852    fn skill_instruction_attached(&mut self, _name: &str) -> io::Result<()> {
853        self.sender
854            .send(WorkerMessage::SkillInstructionAttached)
855            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
856    }
857
858    fn context_usage(&mut self, tokens: usize) -> io::Result<()> {
859        self.sender
860            .send(WorkerMessage::ContextUsage(tokens))
861            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
862    }
863
864    fn compaction_started(&mut self) -> io::Result<()> {
865        self.sender
866            .send(WorkerMessage::CompactionStarted)
867            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
868    }
869
870    fn compaction_finished(&mut self, tokens_before: usize, tokens_after: usize) -> io::Result<()> {
871        self.sender
872            .send(WorkerMessage::CompactionFinished {
873                tokens_before,
874                tokens_after,
875            })
876            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
877    }
878}
879
880#[derive(Debug, Clone, Copy, PartialEq, Eq)]
881enum SubagentStatus {
882    Queued,
883    Running,
884    Failed,
885}
886
887#[derive(Debug, Clone, PartialEq)]
888enum SubagentStreamItem {
889    User(String),
890    Assistant(String),
891    Reasoning {
892        complete: bool,
893    },
894    ToolCall {
895        id: String,
896        name: String,
897        arguments: String,
898    },
899    ToolResult {
900        id: String,
901        name: String,
902        result: Value,
903    },
904}
905
906#[derive(Debug, Clone, PartialEq)]
907struct SubagentTask {
908    call_id: String,
909    task_id: Option<String>,
910    task: String,
911    model: Option<String>,
912    effort: Option<String>,
913    status: SubagentStatus,
914    result: Option<Value>,
915    creation_completed: bool,
916    stream: Vec<SubagentStreamItem>,
917    stream_chars: usize,
918}
919
920#[derive(Debug, Clone, Copy, PartialEq, Eq)]
921enum SubagentToolActionKind {
922    Check,
923    Wait,
924    Send,
925    Cancel,
926}
927
928#[derive(Debug, Clone)]
929struct SubagentToolAction {
930    task_id: String,
931    kind: SubagentToolActionKind,
932}
933
934#[derive(Debug, Clone, Copy)]
935enum SubagentListNotice {
936    Flash { started_at: Instant, until: Instant },
937    Waiting,
938    Cancelling,
939}
940
941/// A bounded interpolation between the resting bars and a live pulse frame.
942/// Keeping the source frame lets a completed turn settle instead of snapping
943/// straight from its last pulse height to the resting indicator.
944#[derive(Debug, Clone, Copy)]
945struct ConsoleVisibilityTransition {
946    started_at: Instant,
947    from: f32,
948    to: f32,
949}
950
951#[derive(Debug, Clone)]
952struct ActivityTransition {
953    started_at: Instant,
954    from_levels: [usize; PULSE_BAR_PERIODS.len()],
955    to_levels: [usize; PULSE_BAR_PERIODS.len()],
956}
957
958struct UiState {
959    model: String,
960    effort: Option<String>,
961    context_window: Option<usize>,
962    context_tokens: usize,
963    secret: String,
964    transcript: Vec<TranscriptItem>,
965    queued_messages: Vec<String>,
966    input: String,
967    cursor: usize,
968    status: String,
969    busy: bool,
970    terminal_focused: bool,
971    active_cancel: Option<CancellationToken>,
972    scroll: u16,
973    auto_scroll: bool,
974    tool_animation_epoch: Instant,
975    console_animation_epoch: Instant,
976    console_visibility_transition: Option<ConsoleVisibilityTransition>,
977    activity_started_at: Instant,
978    activity_transition: Option<ActivityTransition>,
979    last_active_levels: [usize; PULSE_BAR_PERIODS.len()],
980    last_active_elapsed: Duration,
981    welcome_visible: bool,
982    attached_agents: Vec<String>,
983    subagents: Vec<SubagentTask>,
984    subagent_focus: Option<usize>,
985    completed_subagent_calls: HashSet<String>,
986    failed_subagent_calls: HashSet<String>,
987    terminal_subagents: HashSet<String>,
988    background_result_tasks: HashMap<String, String>,
989    pending_subagent_activities: HashMap<String, Vec<SubagentActivity>>,
990    subagent_tool_actions: HashMap<String, SubagentToolAction>,
991    subagent_list_notices: HashMap<String, SubagentListNotice>,
992    cancelling_subagents: HashSet<String>,
993    cmd_result_started_at: HashMap<String, Instant>,
994    skill_names: Vec<String>,
995    skill_picker_focus: usize,
996    skill_picker_suppressed: bool,
997    settings: Option<SettingsState>,
998}
999
1000impl UiState {
1001    fn from_history(
1002        history: &[SessionHistoryRecord],
1003        secret: &str,
1004        model: &str,
1005        effort: Option<&str>,
1006        resumed: bool,
1007    ) -> Self {
1008        let mut state = Self {
1009            model: model.to_owned(),
1010            effort: effort.map(str::to_owned),
1011            context_window: None,
1012            context_tokens: 1,
1013            secret: secret.to_owned(),
1014            transcript: Vec::new(),
1015            queued_messages: Vec::new(),
1016            input: String::new(),
1017            cursor: 0,
1018            status: "ready".to_owned(),
1019            busy: false,
1020            terminal_focused: true,
1021            active_cancel: None,
1022            scroll: 0,
1023            auto_scroll: true,
1024            tool_animation_epoch: Instant::now(),
1025            console_animation_epoch: Instant::now(),
1026            console_visibility_transition: None,
1027            activity_started_at: Instant::now(),
1028            activity_transition: None,
1029            last_active_levels: [0; PULSE_BAR_PERIODS.len()],
1030            last_active_elapsed: Duration::ZERO,
1031            welcome_visible: !resumed && history.is_empty(),
1032            attached_agents: Vec::new(),
1033            subagents: Vec::new(),
1034            subagent_focus: None,
1035            completed_subagent_calls: HashSet::new(),
1036            failed_subagent_calls: HashSet::new(),
1037            terminal_subagents: HashSet::new(),
1038            background_result_tasks: HashMap::new(),
1039            pending_subagent_activities: HashMap::new(),
1040            subagent_tool_actions: HashMap::new(),
1041            subagent_list_notices: HashMap::new(),
1042            cancelling_subagents: HashSet::new(),
1043            cmd_result_started_at: HashMap::new(),
1044            skill_names: Vec::new(),
1045            skill_picker_focus: 0,
1046            skill_picker_suppressed: false,
1047            settings: None,
1048        };
1049        for record in history {
1050            state.add_history_record(record);
1051        }
1052        state
1053    }
1054
1055    fn with_attached_agents(mut self, attached_agents: Vec<String>) -> Self {
1056        self.attached_agents = attached_agents;
1057        self
1058    }
1059
1060    fn with_skill_names(mut self, skill_names: Vec<String>) -> Self {
1061        self.skill_names = skill_names;
1062        self
1063    }
1064
1065    fn with_context(mut self, context_window: Option<usize>, context_tokens: usize) -> Self {
1066        self.context_window = context_window;
1067        self.context_tokens = context_tokens.max(1);
1068        self
1069    }
1070
1071    /// Return matching skills only while the first input character is `/` and
1072    /// the user is still writing the command name (rather than its arguments).
1073    fn matching_skill_names(&self) -> Vec<&str> {
1074        matching_skill_names(&self.input, &self.skill_names)
1075    }
1076
1077    fn reset_skill_picker(&mut self) {
1078        self.skill_picker_focus = 0;
1079        self.skill_picker_suppressed = false;
1080    }
1081
1082    fn skill_picker_visible(&self) -> bool {
1083        !self.skill_picker_suppressed && !self.matching_skill_names().is_empty()
1084    }
1085
1086    fn set_busy(&mut self, busy: bool) {
1087        self.set_busy_at(busy, Instant::now());
1088    }
1089
1090    fn set_busy_at(&mut self, busy: bool, now: Instant) {
1091        if self.busy == busy {
1092            return;
1093        }
1094        let from = self.console_visibility_at(now);
1095        if busy && from <= f32::EPSILON {
1096            self.console_animation_epoch = now;
1097        }
1098        self.busy = busy;
1099        self.console_visibility_transition = Some(ConsoleVisibilityTransition {
1100            started_at: now,
1101            from,
1102            to: if busy { 1.0 } else { 0.0 },
1103        });
1104    }
1105
1106    fn console_visibility_at(&self, now: Instant) -> f32 {
1107        let Some(transition) = self.console_visibility_transition else {
1108            return if self.busy { 1.0 } else { 0.0 };
1109        };
1110        let progress = now
1111            .saturating_duration_since(transition.started_at)
1112            .as_secs_f32()
1113            / CONSOLE_VISIBILITY_TRANSITION.as_secs_f32();
1114        if progress >= 1.0 {
1115            return transition.to;
1116        }
1117        let progress = progress.clamp(0.0, 1.0);
1118        let eased = progress * progress * (3.0 - 2.0 * progress);
1119        transition.from + (transition.to - transition.from) * eased
1120    }
1121
1122    fn set_status(&mut self, status: impl Into<String>) {
1123        let status = status.into();
1124        if self.status == status {
1125            return;
1126        }
1127
1128        let now = Instant::now();
1129        let current_levels = self.activity_levels_at(now);
1130        let current_elapsed = self.working_elapsed_at(now);
1131        if matches!(self.status.as_str(), "working" | "compacting") {
1132            self.last_active_levels = current_levels;
1133            self.last_active_elapsed = current_elapsed;
1134        }
1135
1136        match status.as_str() {
1137            "working" if !matches!(self.status.as_str(), "working" | "compacting") => {
1138                // Join a frame whose next pulses continue one level at a time
1139                // after the ramp. Sampling the current bars also makes a new
1140                // turn during the ready settle-down phase continuous.
1141                self.activity_started_at = now;
1142                self.activity_transition = Some(ActivityTransition {
1143                    started_at: now,
1144                    from_levels: current_levels,
1145                    to_levels: pulse_levels_at(PULSE_ENTRY_FRAME),
1146                });
1147            }
1148            "ready" if self.status != "ready" => {
1149                // TurnEnd is commonly followed by Finished before the next
1150                // draw, so retain the most recent working frame even if the
1151                // transient status was already changed to "finalizing".
1152                let from_levels = if matches!(self.status.as_str(), "working" | "compacting") {
1153                    current_levels
1154                } else {
1155                    self.last_active_levels
1156                };
1157                self.activity_transition = Some(ActivityTransition {
1158                    started_at: now,
1159                    from_levels,
1160                    to_levels: [0; PULSE_BAR_PERIODS.len()],
1161                });
1162            }
1163            _ => {}
1164        }
1165        self.status = status;
1166    }
1167
1168    fn activity_levels_at(&self, now: Instant) -> [usize; PULSE_BAR_PERIODS.len()] {
1169        if let Some(transition) = &self.activity_transition {
1170            let elapsed = now.saturating_duration_since(transition.started_at);
1171            if elapsed < ACTIVITY_TRANSITION_DURATION {
1172                return interpolate_pulse_levels(
1173                    transition.from_levels,
1174                    transition.to_levels,
1175                    elapsed,
1176                );
1177            }
1178        }
1179
1180        match self.status.as_str() {
1181            "working" | "compacting" => pulse_levels_at(self.working_elapsed_at(now)),
1182            _ => [0; PULSE_BAR_PERIODS.len()],
1183        }
1184    }
1185
1186    fn console_animation_elapsed_at(&self, now: Instant) -> Duration {
1187        now.saturating_duration_since(self.console_animation_epoch)
1188    }
1189
1190    fn working_elapsed_at(&self, now: Instant) -> Duration {
1191        let elapsed = now.saturating_duration_since(self.activity_started_at);
1192        if self.status == "working" && self.activity_transition.is_some() {
1193            PULSE_ENTRY_FRAME
1194                .checked_add(elapsed.saturating_sub(ACTIVITY_TRANSITION_DURATION))
1195                .unwrap_or(PULSE_ENTRY_FRAME)
1196        } else {
1197            elapsed
1198        }
1199    }
1200
1201    fn input_changed(&mut self) {
1202        self.reset_skill_picker();
1203    }
1204
1205    /// Move through the current filter result without wrapping at its ends.
1206    /// Returning false lets the caller retain normal transcript scrolling when
1207    /// no slash picker is active.
1208    fn move_skill_picker(&mut self, down: bool) -> bool {
1209        let match_count = self.matching_skill_names().len();
1210        if self.skill_picker_suppressed || match_count == 0 {
1211            return false;
1212        }
1213        if down {
1214            self.skill_picker_focus = (self.skill_picker_focus + 1).min(match_count - 1);
1215        } else {
1216            self.skill_picker_focus = self.skill_picker_focus.saturating_sub(1);
1217        }
1218        true
1219    }
1220
1221    /// Replace the slash query with the focused explicit skill command. The
1222    /// normal Enter path then sends that command and the existing turn engine
1223    /// attaches the immutable session skill snapshot.
1224    /// Return the built-in represented by the focused slash-picker row, if
1225    /// any. Built-ins execute on Enter while skills merely complete there.
1226    fn focused_builtin_command(&self) -> Option<BuiltinCommand> {
1227        let name = *self.matching_skill_names().get(self.skill_picker_focus)?;
1228        builtin_command(&format!("/{name}"))
1229    }
1230
1231    fn select_focused_skill(&mut self) -> bool {
1232        if self.skill_picker_suppressed {
1233            return false;
1234        }
1235        let Some(name) = self
1236            .matching_skill_names()
1237            .get(self.skill_picker_focus)
1238            .map(|name| (*name).to_owned())
1239        else {
1240            return false;
1241        };
1242        self.input = format!("/{name}");
1243        self.cursor = self.input.chars().count();
1244        // The first Enter chooses a skill; a second Enter sends the completed
1245        // command to the normal attachment path.
1246        self.skill_picker_suppressed = true;
1247        true
1248    }
1249
1250    fn open_catalog(&mut self, result: Result<Vec<ProviderModel>, String>) {
1251        self.settings = Some(match result {
1252            Ok(models) => {
1253                let focus = models
1254                    .iter()
1255                    .position(|model| model.id == self.model)
1256                    .unwrap_or(0);
1257                SettingsState::Models {
1258                    models,
1259                    query: String::new(),
1260                    focus,
1261                }
1262            }
1263            Err(error) => SettingsState::Error(error),
1264        });
1265    }
1266    fn settings_applied(
1267        &mut self,
1268        result: Result<(), String>,
1269        model: String,
1270        effort: Option<String>,
1271        context_window: Option<usize>,
1272    ) {
1273        match result {
1274            Ok(()) => {
1275                self.model = model;
1276                self.effort = effort;
1277                self.context_window = context_window;
1278                self.settings = None;
1279                self.transcript
1280                    .push(TranscriptItem::Info("⚙ settings applied".to_owned()));
1281            }
1282            Err(error) => self.settings = Some(SettingsState::Error(error)),
1283        }
1284    }
1285    fn handle_settings_key(&mut self, key: &KeyEvent) -> Option<(String, Option<String>)> {
1286        let current_effort = self.effort.clone();
1287        match self.settings.as_mut()? {
1288            SettingsState::Loading => {
1289                if key.code == KeyCode::Esc {
1290                    self.settings = None;
1291                }
1292            }
1293            SettingsState::Applying { .. } => {}
1294            SettingsState::Error(_) => {
1295                if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
1296                    self.settings = None;
1297                }
1298            }
1299            SettingsState::Models {
1300                models,
1301                query,
1302                focus,
1303            } => match key.code {
1304                KeyCode::Esc => self.settings = None,
1305                KeyCode::Char(c) => {
1306                    query.push(c);
1307                    *focus = 0;
1308                }
1309                KeyCode::Backspace => {
1310                    query.pop();
1311                    *focus = 0;
1312                }
1313                KeyCode::Up => *focus = focus.saturating_sub(1),
1314                KeyCode::Down => {
1315                    let n = models
1316                        .iter()
1317                        .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
1318                        .count();
1319                    *focus = (*focus + 1).min(n.saturating_sub(1));
1320                }
1321                KeyCode::Enter => {
1322                    let selected = models
1323                        .iter()
1324                        .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
1325                        .nth(*focus)
1326                        .cloned();
1327                    if let Some(model) = selected {
1328                        let focus = model
1329                            .efforts
1330                            .as_ref()
1331                            .and_then(|efforts| {
1332                                current_effort.as_ref().and_then(|current| {
1333                                    efforts.iter().position(|effort| effort == current)
1334                                })
1335                            })
1336                            .map_or(0, |index| index + 1);
1337                        self.settings = Some(SettingsState::Effort {
1338                            model,
1339                            input: current_effort.unwrap_or_default(),
1340                            focus,
1341                        });
1342                    }
1343                }
1344                _ => {}
1345            },
1346            SettingsState::Effort {
1347                model,
1348                input,
1349                focus,
1350            } => match key.code {
1351                KeyCode::Esc => self.settings = None,
1352                KeyCode::Char(c) if model.efforts.is_none() => input.push(c),
1353                KeyCode::Backspace if model.efforts.is_none() => {
1354                    input.pop();
1355                }
1356                KeyCode::Up => *focus = focus.saturating_sub(1),
1357                KeyCode::Down => {
1358                    if let Some(efforts) = &model.efforts {
1359                        *focus = (*focus + 1).min(efforts.len());
1360                    }
1361                }
1362                KeyCode::Enter => {
1363                    let effort = match &model.efforts {
1364                        Some(efforts) => {
1365                            if *focus == 0 {
1366                                None
1367                            } else {
1368                                efforts.get(focus.saturating_sub(1)).cloned()
1369                            }
1370                        }
1371                        None => (!input.trim().is_empty()).then(|| input.trim().to_owned()),
1372                    };
1373                    return Some((model.id.clone(), effort));
1374                }
1375                _ => {}
1376            },
1377        };
1378        None
1379    }
1380
1381    fn add_history_record(&mut self, record: &SessionHistoryRecord) {
1382        match record {
1383            SessionHistoryRecord::ProviderSettings { model, effort, .. } => {
1384                self.transcript.push(TranscriptItem::Info(format!(
1385                    "⚙ {model} ({})",
1386                    effort.as_deref().unwrap_or("default")
1387                )))
1388            }
1389            SessionHistoryRecord::Message { message, .. } => self.add_message(message),
1390            SessionHistoryRecord::Interruption {
1391                assistant_text,
1392                tool_calls,
1393                tool_results,
1394                reason,
1395                phase,
1396                ..
1397            } => {
1398                if !assistant_text.is_empty() {
1399                    self.add_assistant_message(assistant_text);
1400                }
1401                for call in tool_calls {
1402                    self.add_tool_call(call);
1403                }
1404                for observation in tool_results {
1405                    self.add_tool_result(
1406                        &observation.id,
1407                        &observation.name,
1408                        observation.result.clone(),
1409                    );
1410                }
1411                self.transcript
1412                    .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1413            }
1414            SessionHistoryRecord::BackgroundResultPending(pending) => {
1415                self.background_result_tasks
1416                    .insert(pending.completion_id.clone(), pending.task_id.clone());
1417                self.complete_subagent(&pending.task_id, pending.result.clone());
1418                self.transcript.push(TranscriptItem::SubagentLifecycle {
1419                    completion_id: pending.completion_id.clone(),
1420                    task_id: pending.task_id.clone(),
1421                    status: format!("{:?}", pending.status).to_lowercase(),
1422                    delivered: false,
1423                });
1424            }
1425            SessionHistoryRecord::BackgroundResultDelivered(delivered) => {
1426                let task_id = self
1427                    .background_result_tasks
1428                    .get(&delivered.completion_id)
1429                    .cloned()
1430                    .unwrap_or_else(|| "unknown".to_owned());
1431                self.transcript.push(TranscriptItem::SubagentLifecycle {
1432                    completion_id: delivered.completion_id.clone(),
1433                    task_id,
1434                    status: String::new(),
1435                    delivered: true,
1436                });
1437            }
1438            SessionHistoryRecord::Compaction(compaction) => {
1439                self.transcript.push(TranscriptItem::Info(format!(
1440                    "↻ context compacted ({} before)",
1441                    format_context_tokens(compaction.tokens_before)
1442                )));
1443            }
1444        }
1445    }
1446
1447    fn add_message(&mut self, message: &ChatMessage) {
1448        match message.role.as_str() {
1449            "user" => {
1450                let text = message.content.as_deref().unwrap_or("");
1451                let secret = self.secret.clone();
1452                self.add_user(text, &secret);
1453            }
1454            "assistant" => {
1455                if let Some(content) = message.content.as_deref() {
1456                    self.add_assistant_message(content);
1457                }
1458                for call in &message.tool_calls {
1459                    self.add_tool_call(call);
1460                }
1461            }
1462            "tool" => {
1463                let result = message
1464                    .content
1465                    .as_deref()
1466                    .and_then(|content| serde_json::from_str::<Value>(content).ok())
1467                    .unwrap_or_else(|| Value::String(message.content.clone().unwrap_or_default()));
1468                self.add_tool_result(
1469                    message.tool_call_id.as_deref().unwrap_or(""),
1470                    message.name.as_deref().unwrap_or("cmd"),
1471                    result,
1472                );
1473            }
1474            _ => {}
1475        }
1476    }
1477
1478    /// Show an idle submission in the transcript immediately. Only a turn
1479    /// submitted while another turn is active needs the visible queue.
1480    fn submit_user(&mut self, text: &str) {
1481        if self.busy {
1482            self.queue_user(text);
1483        } else {
1484            self.add_user(text, &self.secret.clone());
1485        }
1486    }
1487
1488    fn queue_user(&mut self, text: &str) {
1489        self.queued_messages
1490            .push(redact_secret(text, Some(&self.secret)));
1491    }
1492
1493    fn start_queued_user(&mut self, text: &str) {
1494        let safe = redact_secret(text, Some(&self.secret));
1495        let queued = if self.queued_messages.first() == Some(&safe) {
1496            self.queued_messages.remove(0);
1497            true
1498        } else if let Some(index) = self
1499            .queued_messages
1500            .iter()
1501            .position(|queued| queued == &safe)
1502        {
1503            self.queued_messages.remove(index);
1504            true
1505        } else {
1506            false
1507        };
1508        if queued {
1509            self.add_user(text, &self.secret.clone());
1510        }
1511    }
1512
1513    fn add_user(&mut self, text: &str, secret: &str) {
1514        self.welcome_visible = false;
1515        self.transcript.push(TranscriptItem::User {
1516            text: redact_secret(text, Some(secret)),
1517            skill_instruction_attached: false,
1518        });
1519    }
1520
1521    fn mark_latest_user_skill_attached(&mut self) {
1522        if let Some(TranscriptItem::User {
1523            skill_instruction_attached,
1524            ..
1525        }) = self.transcript.last_mut()
1526        {
1527            *skill_instruction_attached = true;
1528        }
1529    }
1530
1531    fn clear_thinking(&mut self) {
1532        if matches!(
1533            self.transcript.last(),
1534            Some(TranscriptItem::Reasoning { complete: false })
1535        ) {
1536            self.transcript.pop();
1537        }
1538    }
1539
1540    fn show_thinking(&mut self) {
1541        self.set_status("working");
1542        if !matches!(
1543            self.transcript.last(),
1544            Some(TranscriptItem::Reasoning { complete: false })
1545        ) {
1546            self.transcript
1547                .push(TranscriptItem::Reasoning { complete: false });
1548        }
1549    }
1550
1551    fn complete_reasoning(&mut self) {
1552        if let Some(TranscriptItem::Reasoning { complete }) = self.transcript.last_mut() {
1553            *complete = true;
1554        }
1555    }
1556
1557    fn add_assistant(&mut self, text: &str) {
1558        self.clear_thinking();
1559        if let Some(TranscriptItem::Assistant(current)) = self.transcript.last_mut() {
1560            current.push_str(text);
1561        } else {
1562            self.add_assistant_message(text);
1563        }
1564    }
1565
1566    fn add_assistant_message(&mut self, text: &str) {
1567        self.transcript
1568            .push(TranscriptItem::Assistant(text.to_owned()));
1569    }
1570
1571    fn add_tool_call(&mut self, call: &crate::model::ChatToolCall) {
1572        self.record_tool_call(call, false);
1573    }
1574
1575    fn add_live_tool_call(&mut self, call: &crate::model::ChatToolCall) {
1576        self.record_tool_call(call, true);
1577    }
1578
1579    fn record_tool_call(&mut self, call: &crate::model::ChatToolCall, live: bool) {
1580        self.clear_thinking();
1581        if is_subagent_tool(&call.name) {
1582            if call.name == "spawn_subagent" {
1583                self.register_subagent_call(&call.id, &call.arguments);
1584            } else if live {
1585                self.begin_subagent_tool_action(&call.id, &call.name, &call.arguments);
1586            }
1587            return;
1588        }
1589        self.transcript.push(TranscriptItem::ToolCall {
1590            id: call.id.clone(),
1591            name: call.name.clone(),
1592            arguments: call.arguments.clone(),
1593        });
1594    }
1595
1596    fn add_tool_result(&mut self, id: &str, name: &str, result: Value) {
1597        self.record_tool_result(id, name, result, false);
1598    }
1599
1600    fn add_live_tool_result(&mut self, id: &str, name: &str, result: Value) {
1601        self.record_tool_result(id, name, result, true);
1602    }
1603
1604    fn record_tool_result(&mut self, id: &str, name: &str, result: Value, animate: bool) {
1605        if is_subagent_tool(name) {
1606            if name == "spawn_subagent" {
1607                self.update_subagent_queued(id, &result);
1608            } else if animate {
1609                self.finish_subagent_tool_action(id, &result);
1610            }
1611            if subagent_tool_result_is_error(&result) {
1612                self.transcript.push(TranscriptItem::Error(format!(
1613                    "subagent {name}: {}",
1614                    redact_secret(&subagent_tool_error_message(&result), Some(&self.secret))
1615                )));
1616            }
1617            return;
1618        }
1619        if animate && name == "cmd" {
1620            self.cmd_result_started_at
1621                .insert(id.to_owned(), Instant::now());
1622        }
1623        self.transcript.push(TranscriptItem::ToolResult {
1624            id: id.to_owned(),
1625            name: name.to_owned(),
1626            result,
1627        });
1628    }
1629
1630    fn begin_subagent_tool_action(&mut self, call_id: &str, name: &str, arguments: &str) {
1631        let Some(kind) = subagent_tool_action_kind(name) else {
1632            return;
1633        };
1634        let Some(task_id) = subagent_tool_task_id(arguments) else {
1635            return;
1636        };
1637        self.subagent_tool_actions.insert(
1638            call_id.to_owned(),
1639            SubagentToolAction {
1640                task_id: task_id.clone(),
1641                kind,
1642            },
1643        );
1644        if self.running_subagent_by_id(&task_id).is_none() {
1645            return;
1646        }
1647        if kind == SubagentToolActionKind::Cancel {
1648            self.cancelling_subagents.insert(task_id.clone());
1649        }
1650        let now = Instant::now();
1651        let notice = match kind {
1652            SubagentToolActionKind::Check | SubagentToolActionKind::Send => {
1653                SubagentListNotice::Flash {
1654                    started_at: now,
1655                    until: now + SUBAGENT_NOTICE_DURATION,
1656                }
1657            }
1658            SubagentToolActionKind::Wait => SubagentListNotice::Waiting,
1659            SubagentToolActionKind::Cancel => SubagentListNotice::Cancelling,
1660        };
1661        self.subagent_list_notices.insert(task_id, notice);
1662    }
1663
1664    fn finish_subagent_tool_action(&mut self, call_id: &str, result: &Value) {
1665        let Some(action) = self.subagent_tool_actions.remove(call_id) else {
1666            return;
1667        };
1668        if self.running_subagent_by_id(&action.task_id).is_none()
1669            || subagent_tool_result_is_error(result)
1670        {
1671            self.subagent_list_notices.remove(&action.task_id);
1672            if action.kind == SubagentToolActionKind::Cancel {
1673                self.cancelling_subagents.remove(&action.task_id);
1674            }
1675            return;
1676        }
1677        match action.kind {
1678            SubagentToolActionKind::Check | SubagentToolActionKind::Send => {
1679                let now = Instant::now();
1680                self.subagent_list_notices.insert(
1681                    action.task_id,
1682                    SubagentListNotice::Flash {
1683                        started_at: now,
1684                        until: now + SUBAGENT_NOTICE_DURATION,
1685                    },
1686                );
1687            }
1688            SubagentToolActionKind::Wait => {
1689                self.subagent_list_notices.remove(&action.task_id);
1690            }
1691            SubagentToolActionKind::Cancel => {
1692                self.cancelling_subagents.insert(action.task_id);
1693            }
1694        }
1695    }
1696
1697    fn running_subagent_by_id(&self, task_id: &str) -> Option<&SubagentTask> {
1698        self.subagents.iter().find(|task| {
1699            task.status == SubagentStatus::Running && task.task_id.as_deref() == Some(task_id)
1700        })
1701    }
1702
1703    fn subagent_list_notice_at(&self, task_id: &str, now: Instant) -> Option<SubagentListNotice> {
1704        if self.cancelling_subagents.contains(task_id) {
1705            return Some(SubagentListNotice::Cancelling);
1706        }
1707        match self.subagent_list_notices.get(task_id).copied() {
1708            Some(SubagentListNotice::Flash { until, .. }) if now >= until => None,
1709            notice => notice,
1710        }
1711    }
1712
1713    fn register_subagent_call(&mut self, call_id: &str, arguments: &str) {
1714        if self.subagents.iter().any(|task| task.call_id == call_id) {
1715            return;
1716        }
1717        self.completed_subagent_calls.remove(call_id);
1718        let parsed = serde_json::from_str::<Value>(arguments).ok();
1719        let task = parsed
1720            .as_ref()
1721            .and_then(|value| value.get("task"))
1722            .and_then(Value::as_str)
1723            .map(|task| redact_secret(task.trim(), Some(&self.secret)))
1724            .filter(|task| !task.is_empty())
1725            .unwrap_or_else(|| "invalid task".to_owned());
1726        let model = Some(self.model.clone());
1727        let effort = self.effort.clone();
1728        self.subagents.push(SubagentTask {
1729            call_id: call_id.to_owned(),
1730            task_id: None,
1731            task: task.clone(),
1732            model,
1733            effort,
1734            status: SubagentStatus::Queued,
1735            result: None,
1736            creation_completed: false,
1737            stream: vec![SubagentStreamItem::User(task.clone())],
1738            stream_chars: task.chars().count(),
1739        });
1740    }
1741
1742    fn update_subagent_queued(&mut self, call_id: &str, result: &Value) {
1743        let task_id = {
1744            let Some(task) = self
1745                .subagents
1746                .iter_mut()
1747                .find(|task| task.call_id == call_id)
1748            else {
1749                return;
1750            };
1751            task.task_id = result
1752                .get("task_id")
1753                .and_then(Value::as_str)
1754                .map(str::to_owned);
1755            task.status = if result.get("error").is_some() {
1756                task.creation_completed = false;
1757                SubagentStatus::Failed
1758            } else {
1759                // Creation is complete once the queued acknowledgement carries a
1760                // task id; the worker itself remains live in the list.
1761                task.creation_completed = task.task_id.is_some();
1762                SubagentStatus::Running
1763            };
1764            task.result = Some(result.clone());
1765            task.task_id.clone()
1766        };
1767
1768        if let Some(task_id) = task_id {
1769            if let Some(activities) = self.pending_subagent_activities.remove(&task_id) {
1770                for activity in activities {
1771                    self.apply_subagent_activity(activity);
1772                }
1773            }
1774        }
1775    }
1776
1777    fn complete_subagent(&mut self, task_id: &str, result: Value) {
1778        // Completion is delivered to the main agent through the notification
1779        // message. The task list is only a live background-work view, so do
1780        // not retain finished workers there. Keep the call identity separately
1781        // so its transcript line can still say "completed" after removal.
1782        let removed_index = self
1783            .subagents
1784            .iter()
1785            .position(|task| task.task_id.as_deref() == Some(task_id));
1786        if let Some(index) = removed_index {
1787            let call_id = self.subagents[index].call_id.clone();
1788            if subagent_completion_status(&result) == "completed" {
1789                self.completed_subagent_calls.insert(call_id);
1790            } else {
1791                self.failed_subagent_calls.insert(call_id);
1792            }
1793            self.subagents.remove(index);
1794            self.subagent_focus = match self.subagent_focus {
1795                None => None,
1796                Some(_) if self.subagents.is_empty() => None,
1797                Some(focus) if focus > index => Some(focus - 1),
1798                Some(focus) if focus == index => Some(focus.min(self.subagents.len() - 1)),
1799                Some(focus) => Some(focus),
1800            };
1801        }
1802
1803        self.pending_subagent_activities.remove(task_id);
1804        self.subagent_list_notices.remove(task_id);
1805        self.cancelling_subagents.remove(task_id);
1806        self.terminal_subagents.insert(task_id.to_owned());
1807    }
1808
1809    fn apply_subagent_activity(&mut self, activity: SubagentActivity) {
1810        let task_id = match &activity {
1811            SubagentActivity::Event { task_id, .. }
1812            | SubagentActivity::ReasoningStarted { task_id }
1813            | SubagentActivity::ReasoningCompleted { task_id } => Some(task_id.clone()),
1814        };
1815        if let Some(task_id) = task_id {
1816            let registered = self
1817                .subagents
1818                .iter()
1819                .any(|task| task.task_id.as_deref() == Some(task_id.as_str()));
1820            if !registered {
1821                if !self.terminal_subagents.contains(&task_id) {
1822                    self.pending_subagent_activities
1823                        .entry(task_id)
1824                        .or_default()
1825                        .push(activity);
1826                }
1827                return;
1828            }
1829        }
1830
1831        match activity {
1832            SubagentActivity::ReasoningStarted { task_id } => {
1833                let already_reasoning = self
1834                    .subagents
1835                    .iter()
1836                    .find(|task| task.task_id.as_deref() == Some(task_id.as_str()))
1837                    .is_some_and(|task| {
1838                        matches!(
1839                            task.stream.last(),
1840                            Some(SubagentStreamItem::Reasoning { complete: false })
1841                        )
1842                    });
1843                if !already_reasoning {
1844                    self.append_subagent_stream_item(
1845                        task_id,
1846                        SubagentStreamItem::Reasoning { complete: false },
1847                        0,
1848                    );
1849                }
1850            }
1851            SubagentActivity::ReasoningCompleted { task_id } => {
1852                if let Some(task) = self
1853                    .subagents
1854                    .iter_mut()
1855                    .find(|task| task.task_id.as_deref() == Some(task_id.as_str()))
1856                {
1857                    if let Some(SubagentStreamItem::Reasoning { complete }) = task.stream.last_mut()
1858                    {
1859                        *complete = true;
1860                    }
1861                }
1862            }
1863            SubagentActivity::Event { task_id, event } => {
1864                let (item, chars) = match event {
1865                    ProtocolEvent::AssistantDelta { text } => {
1866                        let text = redact_secret(&text, Some(&self.secret));
1867                        let chars = text.chars().count();
1868                        (SubagentStreamItem::Assistant(text), chars)
1869                    }
1870                    ProtocolEvent::ToolCall {
1871                        id,
1872                        name,
1873                        arguments,
1874                    } => {
1875                        let arguments = redact_secret(&arguments, Some(&self.secret));
1876                        let chars = arguments.chars().count() + name.len() + id.len();
1877                        (
1878                            SubagentStreamItem::ToolCall {
1879                                id,
1880                                name,
1881                                arguments,
1882                            },
1883                            chars,
1884                        )
1885                    }
1886                    ProtocolEvent::ToolResult { id, name, result } => {
1887                        let encoded = result.to_string();
1888                        let chars = encoded.chars().count() + name.len() + id.len();
1889                        (SubagentStreamItem::ToolResult { id, name, result }, chars)
1890                    }
1891                    ProtocolEvent::Session { .. }
1892                    | ProtocolEvent::BackgroundResultPending { .. }
1893                    | ProtocolEvent::BackgroundResultDelivered { .. }
1894                    | ProtocolEvent::TurnEnd
1895                    | ProtocolEvent::TurnInterrupted { .. }
1896                    | ProtocolEvent::Error { .. } => return,
1897                };
1898                self.append_subagent_stream_item(task_id, item, chars);
1899            }
1900        }
1901    }
1902
1903    fn append_subagent_stream_item(
1904        &mut self,
1905        task_id: String,
1906        item: SubagentStreamItem,
1907        chars: usize,
1908    ) {
1909        let Some(task) = self
1910            .subagents
1911            .iter_mut()
1912            .find(|task| task.task_id.as_deref() == Some(task_id.as_str()))
1913        else {
1914            return;
1915        };
1916        // Provider text arrives in deltas. Keep the same assistant message
1917        // together in the preview instead of producing one row per chunk.
1918        if let SubagentStreamItem::Assistant(delta) = &item {
1919            if let Some(SubagentStreamItem::Assistant(existing)) = task.stream.last_mut() {
1920                existing.push_str(delta);
1921                task.stream_chars = task.stream_chars.saturating_add(chars);
1922                trim_subagent_stream(task);
1923                return;
1924            }
1925        }
1926        task.stream.push(item);
1927        task.stream_chars = task.stream_chars.saturating_add(chars);
1928        trim_subagent_stream(task);
1929    }
1930
1931    fn clear_subagent_focus(&mut self) {
1932        self.subagent_focus = None;
1933    }
1934
1935    fn running_subagent_indices(&self) -> Vec<usize> {
1936        self.subagents
1937            .iter()
1938            .enumerate()
1939            .filter_map(|(index, task)| (task.status == SubagentStatus::Running).then_some(index))
1940            .collect()
1941    }
1942
1943    fn focus_subagent_list_from_input(&mut self) -> bool {
1944        let Some(index) = self.running_subagent_indices().first().copied() else {
1945            return false;
1946        };
1947        self.subagent_focus = Some(index);
1948        true
1949    }
1950
1951    fn move_subagent_focus(&mut self, down: bool) -> bool {
1952        let Some(focus) = self.subagent_focus else {
1953            return false;
1954        };
1955        let running = self.running_subagent_indices();
1956        let Some(position) = running.iter().position(|index| *index == focus) else {
1957            self.subagent_focus = None;
1958            return true;
1959        };
1960        self.subagent_focus = if down {
1961            running.get(position + 1).copied()
1962        } else {
1963            position
1964                .checked_sub(1)
1965                .and_then(|previous| running.get(previous).copied())
1966        };
1967        true
1968    }
1969
1970    fn apply_event(&mut self, event: ProtocolEvent) {
1971        match event {
1972            ProtocolEvent::Session { .. } => {}
1973            ProtocolEvent::AssistantDelta { text } => self.add_assistant(&text),
1974            ProtocolEvent::ToolCall {
1975                id,
1976                name,
1977                arguments,
1978            } => self.add_live_tool_call(&crate::model::ChatToolCall {
1979                id,
1980                name,
1981                arguments,
1982            }),
1983            ProtocolEvent::ToolResult { id, name, result } => {
1984                self.add_live_tool_result(&id, &name, result)
1985            }
1986            ProtocolEvent::BackgroundResultPending {
1987                completion_id,
1988                task_id,
1989                status,
1990                result,
1991                ..
1992            } => {
1993                self.background_result_tasks
1994                    .insert(completion_id.clone(), task_id.clone());
1995                self.complete_subagent(&task_id, result);
1996                self.transcript.push(TranscriptItem::SubagentLifecycle {
1997                    completion_id,
1998                    task_id,
1999                    status,
2000                    delivered: false,
2001                });
2002            }
2003            ProtocolEvent::BackgroundResultDelivered {
2004                completion_id,
2005                task_id,
2006                ..
2007            } => {
2008                self.terminal_subagents.insert(task_id.clone());
2009                self.background_result_tasks
2010                    .insert(completion_id.clone(), task_id.clone());
2011                self.transcript.push(TranscriptItem::SubagentLifecycle {
2012                    completion_id,
2013                    task_id,
2014                    status: String::new(),
2015                    delivered: true,
2016                });
2017            }
2018            ProtocolEvent::TurnEnd => {
2019                self.complete_reasoning();
2020                self.set_status("finalizing");
2021                self.transcript
2022                    .push(TranscriptItem::Info("✓ turn complete".to_owned()));
2023            }
2024            ProtocolEvent::TurnInterrupted { reason, phase } => {
2025                self.complete_reasoning();
2026                self.set_status("cancelling");
2027                self.transcript
2028                    .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
2029            }
2030            ProtocolEvent::Error { message } => {
2031                self.complete_reasoning();
2032                self.set_status("error");
2033                self.transcript.push(TranscriptItem::Error(message));
2034            }
2035        }
2036    }
2037}
2038
2039fn trim_subagent_stream(task: &mut SubagentTask) {
2040    if task.stream_chars <= SUBAGENT_STREAM_MAX_CHARS {
2041        return;
2042    }
2043
2044    // Reserve one character for the marker so the retained stream visibly
2045    // distinguishes omitted history from a complete worker message.
2046    let mut chars_to_drop = task
2047        .stream_chars
2048        .saturating_sub(SUBAGENT_STREAM_MAX_CHARS)
2049        .saturating_add(1);
2050    while chars_to_drop > 0 && !task.stream.is_empty() {
2051        let item = task.stream.remove(0);
2052        let item_chars = subagent_stream_item_chars(&item);
2053        task.stream_chars = task.stream_chars.saturating_sub(item_chars);
2054        if item_chars <= chars_to_drop {
2055            chars_to_drop -= item_chars;
2056            continue;
2057        }
2058
2059        match item {
2060            SubagentStreamItem::User(text) => {
2061                let text = truncate_subagent_stream_tail(
2062                    &text,
2063                    item_chars.saturating_sub(chars_to_drop).saturating_add(1),
2064                );
2065                task.stream_chars = task.stream_chars.saturating_add(text.chars().count());
2066                task.stream.insert(0, SubagentStreamItem::User(text));
2067            }
2068            SubagentStreamItem::Assistant(text) => {
2069                let text = truncate_subagent_stream_tail(
2070                    &text,
2071                    item_chars.saturating_sub(chars_to_drop).saturating_add(1),
2072                );
2073                task.stream_chars = task.stream_chars.saturating_add(text.chars().count());
2074                task.stream.insert(0, SubagentStreamItem::Assistant(text));
2075            }
2076            // Never turn structured tool data into assistant text: doing so
2077            // bypasses the shared tool renderer and exposes raw JSON in the
2078            // worker overlay when its bounded history is trimmed.
2079            SubagentStreamItem::Reasoning { .. }
2080            | SubagentStreamItem::ToolCall { .. }
2081            | SubagentStreamItem::ToolResult { .. } => {
2082                task.stream
2083                    .insert(0, SubagentStreamItem::Assistant("…".to_owned()));
2084                task.stream_chars = task.stream_chars.saturating_add(1);
2085            }
2086        }
2087        return;
2088    }
2089
2090    if !task.stream.is_empty() {
2091        task.stream
2092            .insert(0, SubagentStreamItem::Assistant("…".to_owned()));
2093        task.stream_chars = task.stream_chars.saturating_add(1);
2094    }
2095}
2096
2097fn subagent_stream_item_chars(item: &SubagentStreamItem) -> usize {
2098    match item {
2099        SubagentStreamItem::User(text) | SubagentStreamItem::Assistant(text) => {
2100            text.chars().count()
2101        }
2102        SubagentStreamItem::Reasoning { .. } => 0,
2103        SubagentStreamItem::ToolCall {
2104            id,
2105            name,
2106            arguments,
2107        } => id.len() + name.len() + arguments.chars().count(),
2108        SubagentStreamItem::ToolResult { id, name, result } => {
2109            id.len() + name.len() + result.to_string().chars().count()
2110        }
2111    }
2112}
2113
2114fn truncate_subagent_stream_tail(text: &str, limit: usize) -> String {
2115    let chars = text.chars().count();
2116    if chars <= limit {
2117        return text.to_owned();
2118    }
2119    if limit == 0 {
2120        return String::new();
2121    }
2122    let tail = text
2123        .chars()
2124        .skip(chars.saturating_sub(limit.saturating_sub(1)))
2125        .collect::<String>();
2126    format!("…{tail}")
2127}
2128
2129fn subagent_completion_status(result: &Value) -> &'static str {
2130    if result.get("interrupted").is_some() {
2131        "interrupted"
2132    } else if result.get("cancelled").is_some() {
2133        "canceled"
2134    } else if result.get("error").is_some() {
2135        "failed"
2136    } else {
2137        "completed"
2138    }
2139}
2140
2141#[derive(Debug, Clone, PartialEq)]
2142enum TranscriptItem {
2143    User {
2144        text: String,
2145        skill_instruction_attached: bool,
2146    },
2147    Assistant(String),
2148    ToolCall {
2149        id: String,
2150        name: String,
2151        arguments: String,
2152    },
2153    ToolResult {
2154        id: String,
2155        name: String,
2156        result: Value,
2157    },
2158    Error(String),
2159    Info(String),
2160    SubagentLifecycle {
2161        completion_id: String,
2162        task_id: String,
2163        status: String,
2164        delivered: bool,
2165    },
2166    Reasoning {
2167        complete: bool,
2168    },
2169}
2170
2171/// Center the TUI while reserving one terminal cell on each side when possible.
2172/// Extremely narrow terminals retain their full width because two margins would
2173/// leave no usable content area.
2174fn tui_viewport(area: Rect) -> Rect {
2175    if area.width <= 2 {
2176        return area;
2177    }
2178
2179    let width = area.width.saturating_sub(2).min(TUI_MAX_WIDTH);
2180    let x = area.x + area.width.saturating_sub(width) / 2;
2181    Rect::new(x, area.y, width, area.height)
2182}
2183
2184fn ui_layout(
2185    state: &UiState,
2186    area: Rect,
2187) -> (Rect, Option<Rect>, Option<Rect>, Option<Rect>, Rect, Rect) {
2188    let prompt_rows = input_visible_rows(state, ui_prompt_content_width(area));
2189    let list_height = subagent_list_height(state);
2190    let queue_height = message_queue_height(state);
2191    let queue_separator_height = u16::from(queue_height > 0);
2192    let list_separator_height = u16::from(list_height > 0);
2193    let requested_input_height = prompt_rows.clamp(1, MAX_INPUT_ROWS)
2194        + queue_height
2195        + queue_separator_height
2196        + list_height
2197        + list_separator_height
2198        + 1 // prompt/status separator
2199        + 1 // status line
2200        + 2; // blank outer border space
2201             // Preserve a one-row footer around the console when there is room for a
2202             // console at all. On a one-row terminal the console takes that row rather
2203             // than collapsing to an unusable rectangle.
2204    let bottom_margin = u16::from(area.height > 1);
2205    let usable_height = area.height.saturating_sub(bottom_margin);
2206    let input_height = requested_input_height.min(usable_height);
2207    let transcript_gap_height = u16::from(usable_height >= input_height.saturating_add(2));
2208    let chat_height = usable_height.saturating_sub(input_height + transcript_gap_height);
2209    let chat_chunk = bottom_console_area(area, area.y, chat_height);
2210    let input_area = bottom_console_area(
2211        area,
2212        area.y + chat_height + transcript_gap_height,
2213        input_height,
2214    );
2215    let inner = console_content_area(input_area);
2216    let content = bottom_content_heights(state, input_area);
2217    let available_above = input_area.y.saturating_sub(area.y);
2218    let picker_height = skill_picker_height(state).min(available_above);
2219    let picker_area = (picker_height > 0).then(|| {
2220        Rect::new(
2221            input_area.x,
2222            input_area.y - picker_height,
2223            input_area.width,
2224            picker_height,
2225        )
2226    });
2227    let stream_area = subagent_stream_overlay_area(state, input_area, area.y);
2228    let queue_area =
2229        (content.queue > 0).then(|| Rect::new(inner.x, inner.y, inner.width, content.queue));
2230    let status_area = Rect::new(
2231        inner.x,
2232        inner.y + inner.height.saturating_sub(content.status),
2233        inner.width,
2234        content.status,
2235    );
2236    (
2237        chat_chunk,
2238        picker_area,
2239        stream_area,
2240        queue_area,
2241        input_area,
2242        status_area,
2243    )
2244}
2245
2246/// Keep the console inset without allowing margins to consume all available
2247/// width. A narrow terminal sheds margin cells before it sheds the console.
2248fn bottom_console_area(area: Rect, y: u16, height: u16) -> Rect {
2249    let horizontal_margin = area.width.saturating_sub(1) / 2;
2250    let horizontal_margin = horizontal_margin.min(2);
2251    Rect::new(
2252        area.x.saturating_add(horizontal_margin),
2253        y,
2254        area.width
2255            .saturating_sub(horizontal_margin.saturating_mul(2)),
2256        height,
2257    )
2258}
2259
2260fn ui_prompt_content_width(area: Rect) -> u16 {
2261    prompt_content_width(bottom_console_area(area, area.y, 0).width)
2262}
2263
2264fn console_content_area(input_area: Rect) -> Rect {
2265    let top_padding = input_area.height.min(1);
2266    let bottom_padding = input_area.height.saturating_sub(top_padding).min(1);
2267    Rect::new(
2268        input_area.x.saturating_add(2),
2269        input_area.y.saturating_add(top_padding),
2270        input_area.width.saturating_sub(4),
2271        input_area
2272            .height
2273            .saturating_sub(top_padding + bottom_padding),
2274    )
2275}
2276
2277fn prompt_content_width(input_width: u16) -> u16 {
2278    input_width.saturating_sub(4)
2279}
2280
2281#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
2282struct BottomContentHeights {
2283    queue: u16,
2284    queue_separator: u16,
2285    list: u16,
2286    list_separator: u16,
2287    prompt: u16,
2288    status_separator: u16,
2289    status: u16,
2290}
2291
2292// Constrained layouts keep the status and prompt first. Queue and worker
2293// sections each require a header, one entry, and their following spacer so a
2294// clipped console never renders an orphaned section header.
2295fn bottom_content_heights(state: &UiState, input_area: Rect) -> BottomContentHeights {
2296    let mut available = console_content_area(input_area).height;
2297    let status = available.min(1);
2298    available -= status;
2299
2300    let prompt = input_visible_rows(state, prompt_content_width(input_area.width))
2301        .clamp(1, MAX_INPUT_ROWS)
2302        .min(available);
2303    available -= prompt;
2304
2305    let status_separator = u16::from(status > 0 && prompt > 0 && available > 0);
2306    available -= status_separator;
2307
2308    let requested_queue = message_queue_height(state);
2309    let (queue, queue_separator) = if requested_queue > 0 && available >= 3 {
2310        (requested_queue.min(available - 1), 1)
2311    } else {
2312        (0, 0)
2313    };
2314    available -= queue + queue_separator;
2315
2316    let requested_list = subagent_list_height(state);
2317    let (list, list_separator) = if requested_list > 0 && available >= 3 {
2318        (requested_list.min(available - 1), 1)
2319    } else {
2320        (0, 0)
2321    };
2322
2323    BottomContentHeights {
2324        queue,
2325        queue_separator,
2326        list,
2327        list_separator,
2328        prompt,
2329        status_separator,
2330        status,
2331    }
2332}
2333
2334fn prompt_area(input_area: Rect, state: &UiState) -> Rect {
2335    let inner = console_content_area(input_area);
2336    let content = bottom_content_heights(state, input_area);
2337    Rect::new(
2338        inner.x,
2339        inner.y + content.queue + content.queue_separator,
2340        inner.width,
2341        content.prompt,
2342    )
2343}
2344
2345fn subagent_list_area(state: &UiState, input_area: Rect) -> Option<Rect> {
2346    let inner = console_content_area(input_area);
2347    let content = bottom_content_heights(state, input_area);
2348    (content.list > 0).then(|| {
2349        Rect::new(
2350            inner.x,
2351            inner.y
2352                + content.queue
2353                + content.queue_separator
2354                + content.prompt
2355                + content.list_separator,
2356            inner.width,
2357            content.list,
2358        )
2359    })
2360}
2361
2362#[cfg(test)]
2363fn console_spacer_rows(
2364    state: &UiState,
2365    input_area: Rect,
2366) -> (Option<u16>, Option<u16>, Option<u16>) {
2367    let inner = console_content_area(input_area);
2368    let content = bottom_content_heights(state, input_area);
2369    let queue_prompt = (content.queue_separator > 0).then_some(inner.y + content.queue);
2370    let list_prompt = (content.list_separator > 0)
2371        .then_some(inner.y + content.queue + content.queue_separator + content.prompt);
2372    let prompt_status = (content.status_separator > 0)
2373        .then_some(inner.y + inner.height.saturating_sub(content.status + 1));
2374    (queue_prompt, list_prompt, prompt_status)
2375}
2376
2377fn subagent_list_height(state: &UiState) -> u16 {
2378    let running = state
2379        .subagents
2380        .iter()
2381        .filter(|task| task.status == SubagentStatus::Running)
2382        .count()
2383        .min(u16::MAX as usize - 1) as u16;
2384    u16::from(running > 0) + running
2385}
2386
2387/// Focused worker output uses the same transient slot as the slash picker:
2388/// immediately above the input, without changing the transcript viewport.
2389fn subagent_stream_overlay_area(state: &UiState, input_area: Rect, top: u16) -> Option<Rect> {
2390    let focus = state.subagent_focus?;
2391    let task = state.subagents.get(focus)?;
2392    if task.status != SubagentStatus::Running {
2393        return None;
2394    }
2395    let available = input_area.y.saturating_sub(top);
2396    if available == 0 {
2397        return None;
2398    }
2399    let height = SUBAGENT_STREAM_PREVIEW_HEIGHT.min(available);
2400    Some(Rect::new(
2401        input_area.x,
2402        input_area.y - height,
2403        input_area.width,
2404        height,
2405    ))
2406}
2407
2408fn message_queue_height(state: &UiState) -> u16 {
2409    let messages = state.queued_messages.len().min(u16::MAX as usize - 1) as u16;
2410    u16::from(messages > 0) + messages
2411}
2412
2413fn max_scroll_for_area(state: &UiState, size: Size) -> u16 {
2414    let area = tui_viewport(Rect::new(0, 0, size.width, size.height));
2415    let (chat_chunk, _, _, _, _, _) = ui_layout(state, area);
2416    let chat_height = chat_chunk.height;
2417    let lines = transcript_lines(state, chat_chunk.width);
2418    lines
2419        .len()
2420        .saturating_sub(chat_height as usize)
2421        .min(u16::MAX as usize) as u16
2422}
2423
2424/// Number of wrapped rows the current input occupies at `width`.
2425fn input_visible_rows(state: &UiState, width: u16) -> u16 {
2426    let width = width as usize;
2427    if width == 0 {
2428        return 1;
2429    }
2430    let prompt = input_display_text(state);
2431    let wrapped = wrap_text(&prompt, width);
2432    wrapped.len().max(1) as u16
2433}
2434
2435fn input_prompt(input: &str) -> String {
2436    input.to_owned()
2437}
2438
2439fn input_display_text(state: &UiState) -> String {
2440    redact_secret(&input_prompt(&state.input), Some(&state.secret))
2441}
2442
2443fn command_names(mut skill_names: Vec<String>) -> Vec<String> {
2444    skill_names.extend(BUILTIN_COMMANDS.into_iter().map(str::to_owned));
2445    skill_names.sort();
2446    skill_names.dedup();
2447    skill_names
2448}
2449
2450#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2451enum BuiltinCommand {
2452    Settings,
2453    Exit,
2454}
2455
2456impl BuiltinCommand {
2457    fn name(self) -> &'static str {
2458        match self {
2459            Self::Settings => "settings",
2460            Self::Exit => "exit",
2461        }
2462    }
2463}
2464
2465fn builtin_command(input: &str) -> Option<BuiltinCommand> {
2466    match input.split_whitespace().next()? {
2467        "/settings" => Some(BuiltinCommand::Settings),
2468        "/exit" => Some(BuiltinCommand::Exit),
2469        _ => None,
2470    }
2471}
2472
2473/// The slash picker only accepts a command at the beginning of the message.
2474/// Once whitespace starts arguments, normal message entry resumes.
2475fn matching_skill_names<'a>(input: &str, skill_names: &'a [String]) -> Vec<&'a str> {
2476    let Some(query) = input.strip_prefix('/') else {
2477        return Vec::new();
2478    };
2479    if query.chars().any(char::is_whitespace) {
2480        return Vec::new();
2481    }
2482    skill_names
2483        .iter()
2484        .map(String::as_str)
2485        .filter(|name| name.starts_with(query))
2486        .collect()
2487}
2488
2489fn skill_picker_height(state: &UiState) -> u16 {
2490    if state.skill_picker_visible() {
2491        // Header, visible commands, and the vertical inset.
2492        (state
2493            .matching_skill_names()
2494            .len()
2495            .min(SKILL_PICKER_MAX_ROWS)
2496            + 3) as u16
2497    } else {
2498        0
2499    }
2500}
2501
2502/// Return the command portion of a currently valid explicit skill invocation.
2503/// This mirrors the command grammar used by the turn engine, while keeping the
2504/// styling concern local to the TUI.
2505fn active_skill_trigger<'a>(input: &'a str, skill_names: &[String]) -> Option<&'a str> {
2506    let invocation = input.strip_prefix('/')?;
2507    let name = invocation
2508        .split_once(char::is_whitespace)
2509        .map_or(invocation, |(name, _)| name);
2510    if name.is_empty() || !skill_names.iter().any(|skill_name| skill_name == name) {
2511        return None;
2512    }
2513    Some(&input[..1 + name.len()])
2514}
2515
2516/// Preserve input wrapping while styling a recognized `/<name>` prefix
2517/// independently from any arguments the user is still entering.
2518fn styled_text_lines(
2519    input: &str,
2520    active_skill_trigger: Option<&str>,
2521    width: usize,
2522    text_style: Style,
2523) -> Vec<Line<'static>> {
2524    let trigger_len = active_skill_trigger.map_or(0, |trigger| trigger.chars().count());
2525    let mut char_offset = 0usize;
2526    let mut lines = Vec::new();
2527
2528    for source_line in input.split('\n') {
2529        for row in wrap_line(source_line, width) {
2530            let mut spans = Vec::new();
2531            let mut text = String::new();
2532            let mut highlighted = None;
2533            for character in row.chars() {
2534                let should_highlight = char_offset < trigger_len;
2535                if highlighted != Some(should_highlight) && !text.is_empty() {
2536                    spans.push(styled_text_span(
2537                        std::mem::take(&mut text),
2538                        highlighted.unwrap_or(false),
2539                        text_style,
2540                    ));
2541                }
2542                highlighted = Some(should_highlight);
2543                text.push(character);
2544                char_offset += 1;
2545            }
2546            if !text.is_empty() {
2547                spans.push(styled_text_span(
2548                    text,
2549                    highlighted.unwrap_or(false),
2550                    text_style,
2551                ));
2552            }
2553            if spans.is_empty() {
2554                spans.push(Span::styled(String::new(), text_style));
2555            }
2556            lines.push(Line::from(spans));
2557        }
2558        // `split` retains empty trailing lines; account for the newline that
2559        // separated this source line from the next one in the character index.
2560        char_offset += 1;
2561    }
2562
2563    lines
2564}
2565
2566fn styled_text_span(text: String, highlighted: bool, text_style: Style) -> Span<'static> {
2567    if highlighted {
2568        Span::styled(text, Style::default().fg(SKILL_TRIGGER_COLOR))
2569    } else {
2570        Span::styled(text, text_style)
2571    }
2572}
2573
2574#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2575struct InputVisualRow {
2576    start: usize,
2577    end: usize,
2578}
2579
2580fn input_visual_rows(input: &str, width: usize) -> Vec<InputVisualRow> {
2581    let width = width.max(1);
2582    let characters = input.chars().collect::<Vec<_>>();
2583    let mut rows = Vec::new();
2584    let mut start = 0;
2585    let mut row_width = 0;
2586
2587    for (index, character) in characters.iter().enumerate() {
2588        if *character == '\n' {
2589            rows.push(InputVisualRow { start, end: index });
2590            start = index + 1;
2591            row_width = 0;
2592            continue;
2593        }
2594
2595        let character_width = unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0);
2596        if row_width + character_width > width && index > start {
2597            rows.push(InputVisualRow { start, end: index });
2598            start = index;
2599            row_width = 0;
2600        }
2601        row_width += character_width;
2602    }
2603
2604    rows.push(InputVisualRow {
2605        start,
2606        end: characters.len(),
2607    });
2608    rows
2609}
2610
2611fn input_cursor_row(input: &str, cursor: usize, width: usize) -> usize {
2612    let rows = input_visual_rows(input, width);
2613    let cursor = cursor.min(input.chars().count());
2614    for (index, row) in rows.iter().enumerate() {
2615        if cursor < row.end {
2616            return index;
2617        }
2618        if cursor == row.end && rows.get(index + 1).is_none_or(|next| next.start != cursor) {
2619            return index;
2620        }
2621    }
2622    rows.len().saturating_sub(1)
2623}
2624
2625fn cursor_row(input: &str, cursor: usize, width: usize) -> u16 {
2626    input_cursor_row(input, cursor, width).min(u16::MAX as usize) as u16
2627}
2628
2629fn move_up_from_input_or_subagent(state: &mut UiState, width: usize) -> bool {
2630    if state.subagent_focus.is_some() {
2631        return state.move_subagent_focus(false);
2632    }
2633    state.move_skill_picker(false) || move_input_cursor_vertical(state, width, false)
2634}
2635
2636fn move_down_from_input(state: &mut UiState, width: usize) -> bool {
2637    let width = width.max(1);
2638    let rows = input_visual_rows(&state.input, width);
2639    let on_last_row = input_cursor_row(&state.input, state.cursor, width) + 1 == rows.len();
2640    if on_last_row && state.focus_subagent_list_from_input() {
2641        return true;
2642    }
2643    state.move_skill_picker(true) || move_input_cursor_vertical(state, width, true)
2644}
2645
2646fn move_input_cursor_vertical(state: &mut UiState, width: usize, down: bool) -> bool {
2647    let width = width.max(1);
2648    let rows = input_visual_rows(&state.input, width);
2649    let current_row = input_cursor_row(&state.input, state.cursor, width);
2650    let target_row = if down {
2651        current_row + 1
2652    } else {
2653        current_row.saturating_sub(1)
2654    };
2655    if target_row == current_row || target_row >= rows.len() {
2656        return false;
2657    }
2658
2659    let characters = state.input.chars().collect::<Vec<_>>();
2660    let current = rows[current_row];
2661    let cursor = state.cursor.min(current.end);
2662    let desired_column = characters[current.start..cursor]
2663        .iter()
2664        .map(|character| unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0))
2665        .sum::<usize>();
2666    let target = rows[target_row];
2667    let mut column = 0;
2668    let mut target_cursor = target.end;
2669    for (index, character) in characters
2670        .iter()
2671        .enumerate()
2672        .take(target.end)
2673        .skip(target.start)
2674    {
2675        let character_width = unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0);
2676        if column + character_width > desired_column {
2677            target_cursor = index;
2678            break;
2679        }
2680        column += character_width;
2681        if column >= desired_column {
2682            target_cursor = index + 1;
2683            break;
2684        }
2685    }
2686    state.cursor = target_cursor;
2687    true
2688}
2689
2690fn insert_at_cursor(input: &mut String, cursor: &mut usize, character: char) {
2691    let byte_index = input
2692        .char_indices()
2693        .nth(*cursor)
2694        .map_or(input.len(), |(index, _)| index);
2695    input.insert(byte_index, character);
2696    *cursor += 1;
2697}
2698
2699fn remove_before_cursor(input: &mut String, cursor: &mut usize) -> bool {
2700    if *cursor == 0 {
2701        return false;
2702    }
2703    let end = input
2704        .char_indices()
2705        .nth(*cursor)
2706        .map_or(input.len(), |(index, _)| index);
2707    let start = input
2708        .char_indices()
2709        .nth(*cursor - 1)
2710        .map(|(index, _)| index)
2711        .unwrap_or(0);
2712    input.replace_range(start..end, "");
2713    *cursor -= 1;
2714    true
2715}
2716
2717fn draw(frame: &mut Frame<'_>, state: &UiState) {
2718    let full_area = frame.area();
2719    // Clear the outer gutters too, so a resize or overlay cannot leave stale
2720    // cells in the one-column margins.
2721    frame.render_widget(Clear, full_area);
2722    let area = tui_viewport(full_area);
2723    let (chat_chunk, picker_area, overlay_area, queue_area, input_chunk, status_area) =
2724        ui_layout(state, area);
2725
2726    // The queue, running workers, prompt, and status line share one background
2727    // surface. Transient picker and worker-stream surfaces remain above it.
2728    let visible_chat_area = chat_chunk;
2729
2730    let width = chat_chunk.width;
2731    let welcome_image_layout = if state.welcome_visible && greeting_image_enabled() {
2732        let welcome_lines = welcome_lines(&state.attached_agents);
2733        welcome_image_layout(visible_chat_area, welcome_lines.len() as u16)
2734    } else {
2735        None
2736    };
2737    if state.welcome_visible {
2738        let welcome_lines = welcome_lines(&state.attached_agents);
2739        if let Some(layout) = welcome_image_layout {
2740            let welcome = Paragraph::new(welcome_lines).alignment(Alignment::Center);
2741            frame.render_widget(welcome, layout.intro_area);
2742        } else {
2743            let logo = logo_lines();
2744            let logo_gap = 2u16;
2745            let total_height = logo.len() as u16 + logo_gap + welcome_lines.len() as u16;
2746            // Show the logo only when the chat area can fit the logo, gap,
2747            // and welcome text; otherwise fall back to text-only.
2748            let lines = if total_height <= visible_chat_area.height {
2749                let mut all = logo;
2750                all.push(Line::raw(""));
2751                all.push(Line::raw(""));
2752                all.extend(welcome_lines);
2753                all
2754            } else {
2755                welcome_lines
2756            };
2757            let welcome_height = (lines.len() as u16).min(visible_chat_area.height);
2758            let welcome_area = Rect::new(
2759                visible_chat_area.x,
2760                visible_chat_area.y + visible_chat_area.height.saturating_sub(welcome_height) / 2,
2761                visible_chat_area.width,
2762                welcome_height,
2763            );
2764            let welcome = Paragraph::new(lines).alignment(Alignment::Center);
2765            frame.render_widget(welcome, welcome_area);
2766        }
2767    } else {
2768        let lines = transcript_lines(state, width);
2769        let available = visible_chat_area.height as usize;
2770        let max_scroll = lines.len().saturating_sub(available).min(u16::MAX as usize) as u16;
2771        let scroll = if state.auto_scroll {
2772            max_scroll
2773        } else {
2774            state.scroll.min(max_scroll)
2775        };
2776        let transcript = Paragraph::new(lines).scroll((scroll, 0));
2777        frame.render_widget(transcript, visible_chat_area);
2778    }
2779
2780    let activity_now = Instant::now();
2781    let activity_elapsed = state.console_animation_elapsed_at(activity_now);
2782    let console_visibility = state.console_visibility_at(activity_now);
2783    apply_tui_glow(
2784        frame,
2785        full_area,
2786        input_chunk,
2787        activity_elapsed,
2788        console_visibility,
2789    );
2790    // Keep the reflection in the existing transcript gap. On constrained
2791    // layouts the row above the console belongs to the transcript instead.
2792    if chat_chunk.y.saturating_add(chat_chunk.height) < input_chunk.y {
2793        apply_console_top_reflection(frame, input_chunk, activity_elapsed, console_visibility);
2794    }
2795    if let Some(layout) = welcome_image_layout {
2796        let image = welcome_image(layout.image_size);
2797        frame.render_widget(TuiImage::new(image.as_ref()), layout.image_area);
2798    }
2799    if let Some(picker_area) = picker_area {
2800        draw_skill_picker(frame, state, picker_area);
2801    }
2802
2803    if let Some(queue_area) = queue_area {
2804        draw_message_queue(frame, state, queue_area);
2805    }
2806    if let Some(list_area) = subagent_list_area(state, input_chunk) {
2807        draw_subagent_list(frame, state, list_area);
2808    }
2809
2810    let input_text_style = Style::default().fg(Color::White);
2811    let prompt_area = prompt_area(input_chunk, state);
2812    let prompt = input_display_text(state);
2813    let input_rows = input_visible_rows(state, prompt_area.width).clamp(1, MAX_INPUT_ROWS);
2814    let wrapped = wrap_text(&prompt, prompt_area.width.max(1) as usize);
2815    let visible = (wrapped.len() as u16)
2816        .clamp(1, input_rows)
2817        .min(prompt_area.height);
2818    let cursor_row = cursor_row(&prompt, state.cursor, prompt_area.width.max(1) as usize);
2819    let bottom_scroll = (wrapped.len() as u16).saturating_sub(visible);
2820    let cursor_scroll = (cursor_row + 1).saturating_sub(visible);
2821    let input_scroll = cursor_scroll.min(bottom_scroll);
2822    let active_skill_trigger = (!state.busy)
2823        .then(|| active_skill_trigger(&prompt, &state.skill_names))
2824        .flatten();
2825    let input_lines = styled_text_lines(
2826        &prompt,
2827        active_skill_trigger,
2828        prompt_area.width.max(1) as usize,
2829        input_text_style,
2830    );
2831    let input = Paragraph::new(input_lines)
2832        .style(input_text_style)
2833        .scroll((input_scroll, 0));
2834    frame.render_widget(input, prompt_area);
2835
2836    let effort = state.effort.as_deref().unwrap_or("default");
2837    frame.render_widget(
2838        Paragraph::new(model_status_line(state, effort)),
2839        status_area,
2840    );
2841
2842    apply_console_background(frame, input_chunk, activity_elapsed, console_visibility);
2843
2844    // The task overlay is a floating surface: draw it after the transcript,
2845    // picker, input, and status layers so none can cut through its left edge
2846    // or upper-left corner on a constrained terminal layout.
2847    if let Some(overlay_area) = overlay_area {
2848        draw_subagent_stream_overlay(frame, state, overlay_area);
2849    }
2850    if let Some(settings) = &state.settings {
2851        draw_settings(frame, settings, area);
2852    }
2853
2854    // A frame cursor makes Ratatui issue `Show` after every redraw. Only set
2855    // one while focused, so background glow redraws cannot re-show it.
2856    if state.terminal_focused && state.settings.is_none() && !prompt_area.is_empty() && visible > 0
2857    {
2858        let cursor_prefix: String = prompt.chars().take(state.cursor).collect();
2859        let cursor_rows = wrap_text(&cursor_prefix, prompt_area.width.max(1) as usize);
2860        let cursor_line = cursor_rows.last().map(String::as_str).unwrap_or("");
2861        let cursor_offset = UnicodeWidthStr::width(cursor_line) as u16;
2862        let cursor_x = prompt_area.x + cursor_offset.min(prompt_area.width.saturating_sub(1));
2863        let cursor_y = prompt_area.y
2864            + cursor_row
2865                .saturating_sub(input_scroll)
2866                .min(prompt_area.height.saturating_sub(1));
2867        frame.set_cursor_position((cursor_x, cursor_y));
2868    }
2869}
2870
2871fn draw_message_queue(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
2872    if area.is_empty() || state.queued_messages.is_empty() {
2873        return;
2874    }
2875
2876    let chrome = Style::default().fg(SECTION_CHROME_COLOR);
2877    let message = Style::default().fg(QUEUED_MESSAGE_COLOR);
2878    let mut lines = vec![Line::styled("Queued", chrome)];
2879    lines.extend(
2880        state
2881            .queued_messages
2882            .iter()
2883            .take(area.height.saturating_sub(1) as usize)
2884            .enumerate()
2885            .map(|(index, queued)| {
2886                Line::from(vec![
2887                    Span::styled("│ ", chrome),
2888                    Span::styled(
2889                        format!("{}) {}", index + 1, single_line_preview(queued)),
2890                        message,
2891                    ),
2892                ])
2893            }),
2894    );
2895    frame.render_widget(Paragraph::new(lines), area);
2896}
2897
2898// Hashes vary HSV saturation at a fixed 315° magenta hue.
2899const SUBAGENT_ID_COLORS: [Color; 8] = [
2900    Color::Rgb(220, 36, 174),
2901    Color::Rgb(220, 64, 181),
2902    Color::Rgb(220, 92, 188),
2903    Color::Rgb(220, 120, 195),
2904    Color::Rgb(220, 148, 202),
2905    Color::Rgb(220, 176, 209),
2906    Color::Rgb(220, 204, 216),
2907    Color::Rgb(220, 212, 218),
2908];
2909
2910fn is_subagent_tool(name: &str) -> bool {
2911    matches!(
2912        name,
2913        "spawn_subagent" | "check_subagent" | "wait_subagent" | "send_subagent" | "cancel_subagent"
2914    )
2915}
2916
2917fn subagent_tool_action_kind(name: &str) -> Option<SubagentToolActionKind> {
2918    match name {
2919        "check_subagent" => Some(SubagentToolActionKind::Check),
2920        "wait_subagent" => Some(SubagentToolActionKind::Wait),
2921        "send_subagent" => Some(SubagentToolActionKind::Send),
2922        "cancel_subagent" => Some(SubagentToolActionKind::Cancel),
2923        _ => None,
2924    }
2925}
2926
2927fn subagent_tool_task_id(arguments: &str) -> Option<String> {
2928    serde_json::from_str::<Value>(arguments)
2929        .ok()
2930        .and_then(|value| {
2931            value
2932                .get("task_id")
2933                .and_then(Value::as_str)
2934                .map(str::to_owned)
2935        })
2936        .filter(|task_id| !task_id.trim().is_empty())
2937}
2938
2939fn subagent_tool_result_is_error(result: &Value) -> bool {
2940    result.get("error").is_some()
2941        || matches!(
2942            result.get("status").and_then(Value::as_str),
2943            Some("unknown" | "failed" | "parent_canceled")
2944        )
2945}
2946
2947fn subagent_tool_error_message(result: &Value) -> String {
2948    result
2949        .get("error")
2950        .and_then(Value::as_str)
2951        .filter(|message| !message.is_empty())
2952        .map(str::to_owned)
2953        .or_else(|| {
2954            result
2955                .get("status")
2956                .and_then(Value::as_str)
2957                .map(str::to_owned)
2958        })
2959        .unwrap_or_else(|| "failed".to_owned())
2960}
2961
2962fn subagent_id_color(id: &str) -> Color {
2963    let hash = id.bytes().fold(0x811c9dc5u32, |hash, byte| {
2964        hash.wrapping_mul(0x01000193) ^ u32::from(byte)
2965    });
2966    SUBAGENT_ID_COLORS[(hash as usize) % SUBAGENT_ID_COLORS.len()]
2967}
2968
2969fn draw_subagent_list(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
2970    if area.is_empty() {
2971        return;
2972    }
2973    let chrome = Style::default().fg(SUBAGENT_TITLE_COLOR);
2974    frame.render_widget(
2975        Paragraph::new(Line::styled("Subagents", chrome)),
2976        Rect::new(area.x, area.y, area.width, 1),
2977    );
2978
2979    let running = state.running_subagent_indices();
2980    let item_height = area.height.saturating_sub(1) as usize;
2981    let range = state
2982        .subagent_focus
2983        .and_then(|focus| running.iter().position(|index| *index == focus))
2984        .map(|focus| selection_range(running.len(), focus, item_height))
2985        .unwrap_or(0..running.len().min(item_height));
2986    for (row, position) in range.enumerate() {
2987        let index = running[position];
2988        let task = &state.subagents[index];
2989        let selected = state.subagent_focus == Some(index);
2990        let id = task.task_id.as_deref().unwrap_or(&task.call_id);
2991        let notice = state.subagent_list_notice_at(id, Instant::now());
2992        let mut style = Style::default().fg(subagent_id_color(id));
2993        if selected {
2994            style = style.add_modifier(Modifier::BOLD);
2995        }
2996        let preview = truncate_chars(
2997            &task.task.replace(['\n', '\r'], " ↵ "),
2998            SUBAGENT_TASK_PREVIEW_CHARS,
2999        );
3000        let line = match notice {
3001            Some(SubagentListNotice::Waiting) => {
3002                format!("Waiting for {id} {} · {preview}", tool_spinner_frame(state))
3003            }
3004            Some(SubagentListNotice::Cancelling) => {
3005                format!("Cancelling {id} {} · {preview}", tool_spinner_frame(state))
3006            }
3007            Some(SubagentListNotice::Flash { started_at, .. }) => {
3008                if (Instant::now()
3009                    .saturating_duration_since(started_at)
3010                    .as_millis()
3011                    / SUBAGENT_NOTICE_FLASH_INTERVAL.as_millis())
3012                .is_multiple_of(2)
3013                {
3014                    style = style.add_modifier(Modifier::REVERSED);
3015                }
3016                format!("{id} · {preview}")
3017            }
3018            None => format!("{id} · {preview}"),
3019        };
3020        frame.render_widget(
3021            Paragraph::new(Line::from(vec![
3022                Span::styled("│ ", chrome),
3023                Span::styled(line, style),
3024            ])),
3025            Rect::new(area.x, area.y + row as u16 + 1, area.width, 1),
3026        );
3027    }
3028}
3029
3030fn draw_subagent_stream_overlay(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
3031    let Some(index) = state.subagent_focus else {
3032        return;
3033    };
3034    let Some(task) = state.subagents.get(index) else {
3035        return;
3036    };
3037    if area.is_empty() {
3038        return;
3039    }
3040    let inner = Rect::new(
3041        area.x.saturating_add(2),
3042        area.y.saturating_add(1),
3043        area.width.saturating_sub(4),
3044        area.height.saturating_sub(2),
3045    );
3046    frame.render_widget(Clear, area);
3047    let buffer = frame.buffer_mut();
3048    for y in area.y..area.y.saturating_add(area.height) {
3049        for x in area.x..area.x.saturating_add(area.width) {
3050            buffer[(x, y)].set_bg(SUBAGENT_OVERLAY_BACKGROUND);
3051        }
3052    }
3053    if inner.is_empty() {
3054        return;
3055    }
3056
3057    let lines = latest_subagent_stream_lines(task, inner.width.max(1) as usize, state);
3058    let start = lines.len().saturating_sub(inner.height as usize);
3059    frame.render_widget(Paragraph::new(lines[start..].to_vec()), inner);
3060}
3061
3062fn latest_subagent_stream_lines(
3063    task: &SubagentTask,
3064    width: usize,
3065    state: &UiState,
3066) -> Vec<Line<'static>> {
3067    subagent_stream_lines(task, width, state)
3068}
3069
3070fn subagent_stream_lines(task: &SubagentTask, width: usize, state: &UiState) -> Vec<Line<'static>> {
3071    if task.stream.is_empty() {
3072        return vec![Line::raw("waiting for worker output")];
3073    }
3074    let stream = task
3075        .stream
3076        .iter()
3077        .map(|item| match item {
3078            SubagentStreamItem::User(text) => TranscriptItem::User {
3079                text: text.clone(),
3080                skill_instruction_attached: false,
3081            },
3082            SubagentStreamItem::Assistant(text) => TranscriptItem::Assistant(text.clone()),
3083            SubagentStreamItem::Reasoning { complete } => TranscriptItem::Reasoning {
3084                complete: *complete,
3085            },
3086            SubagentStreamItem::ToolCall {
3087                id,
3088                name,
3089                arguments,
3090            } => TranscriptItem::ToolCall {
3091                id: id.clone(),
3092                name: name.clone(),
3093                arguments: arguments.clone(),
3094            },
3095            SubagentStreamItem::ToolResult { id, name, result } => TranscriptItem::ToolResult {
3096                id: id.clone(),
3097                name: name.clone(),
3098                result: result.clone(),
3099            },
3100        })
3101        .collect::<Vec<_>>();
3102    // Worker content uses the same transcript formatting and suppression rules
3103    // as the main stream; only the overlay viewport follows its own tail.
3104    render_transcript_items(&stream, width.max(1), state, true)
3105}
3106
3107fn truncate_chars(text: &str, limit: usize) -> String {
3108    let mut value: String = text.chars().take(limit).collect();
3109    if text.chars().count() > limit {
3110        value.push('…');
3111    }
3112    value
3113}
3114
3115fn single_line_preview(text: &str) -> String {
3116    truncate_output(&text.replace(['\n', '\r'], " ↵ "))
3117}
3118
3119enum SettingsState {
3120    Loading,
3121    Applying {
3122        model: String,
3123        effort: Option<String>,
3124    },
3125    Error(String),
3126    Models {
3127        models: Vec<ProviderModel>,
3128        query: String,
3129        focus: usize,
3130    },
3131    Effort {
3132        model: ProviderModel,
3133        input: String,
3134        focus: usize,
3135    },
3136}
3137fn draw_settings(frame: &mut Frame<'_>, settings: &SettingsState, area: Rect) {
3138    let width = area
3139        .width
3140        .saturating_sub(2)
3141        .min(SETTINGS_MAX_WIDTH)
3142        .max(SETTINGS_MIN_WIDTH.min(area.width));
3143    let height = area
3144        .height
3145        .saturating_sub(2)
3146        .min(SETTINGS_MAX_HEIGHT)
3147        .max(SETTINGS_MIN_HEIGHT.min(area.height));
3148    let popup = Rect::new(
3149        area.x + area.width.saturating_sub(width) / 2,
3150        area.y + area.height.saturating_sub(height) / 2,
3151        width,
3152        height,
3153    );
3154    frame.render_widget(Clear, popup);
3155    let block = Block::default()
3156        .title(" /settings ")
3157        .borders(Borders::ALL)
3158        .border_style(Style::default().fg(Color::Cyan));
3159    let inner = block.inner(popup);
3160    frame.render_widget(block, popup);
3161
3162    let lines = match settings {
3163        SettingsState::Loading => vec![
3164            Line::styled("Loading provider models…", Style::default().fg(Color::Cyan)),
3165            Line::raw(""),
3166            Line::styled("Esc  cancel", Style::default().fg(Color::DarkGray)),
3167        ],
3168        SettingsState::Applying { model, effort } => vec![
3169            Line::styled("Applying selection…", Style::default().fg(Color::Cyan)),
3170            Line::raw(model.clone()),
3171            Line::raw(format!(
3172                "effort: {}",
3173                effort.as_deref().unwrap_or("default")
3174            )),
3175        ],
3176        SettingsState::Error(error) => vec![
3177            Line::styled("Unable to update settings", Style::default().fg(Color::Red)),
3178            Line::raw(""),
3179            Line::raw(error.clone()),
3180            Line::raw(""),
3181            Line::styled("Enter/Esc  close", Style::default().fg(Color::DarkGray)),
3182        ],
3183        SettingsState::Models {
3184            models,
3185            query,
3186            focus,
3187        } => {
3188            let query_lower = query.to_lowercase();
3189            let filtered = models
3190                .iter()
3191                .filter(|model| model.id.to_lowercase().contains(&query_lower))
3192                .collect::<Vec<_>>();
3193            let focus = (*focus).min(filtered.len().saturating_sub(1));
3194            let list_rows = inner.height.saturating_sub(4) as usize;
3195            let range = selection_range(filtered.len(), focus, list_rows);
3196            let mut lines = vec![
3197                Line::from(vec![
3198                    Span::styled("Model  ", Style::default().fg(Color::DarkGray)),
3199                    Span::styled(
3200                        if query.is_empty() {
3201                            "type to filter…"
3202                        } else {
3203                            query
3204                        },
3205                        Style::default().fg(if query.is_empty() {
3206                            Color::DarkGray
3207                        } else {
3208                            Color::White
3209                        }),
3210                    ),
3211                ]),
3212                Line::styled(
3213                    format!(
3214                        "{} models{}",
3215                        filtered.len(),
3216                        if filtered.is_empty() {
3217                            ""
3218                        } else {
3219                            " · ↑/↓ move · Enter choose"
3220                        }
3221                    ),
3222                    Style::default().fg(Color::DarkGray),
3223                ),
3224            ];
3225            if filtered.is_empty() {
3226                lines.push(Line::styled(
3227                    "No matching models",
3228                    Style::default().fg(Color::Yellow),
3229                ));
3230            } else {
3231                for index in range {
3232                    let selected = index == focus;
3233                    lines.push(Line::styled(
3234                        format!(
3235                            "{} {}",
3236                            if selected { "›" } else { " " },
3237                            filtered[index].id
3238                        ),
3239                        if selected {
3240                            Style::default().fg(Color::Black).bg(Color::Cyan)
3241                        } else {
3242                            Style::default().fg(Color::White)
3243                        },
3244                    ));
3245                }
3246            }
3247            lines.push(Line::styled(
3248                "Esc  cancel",
3249                Style::default().fg(Color::DarkGray),
3250            ));
3251            lines
3252        }
3253        SettingsState::Effort {
3254            model,
3255            input,
3256            focus,
3257        } => {
3258            let mut lines = vec![
3259                Line::styled(model.id.clone(), Style::default().fg(Color::Cyan)),
3260                Line::styled("Reasoning effort", Style::default().fg(Color::DarkGray)),
3261            ];
3262            match &model.efforts {
3263                Some(efforts) => {
3264                    let total = efforts.len() + 1;
3265                    let focus = (*focus).min(total.saturating_sub(1));
3266                    let list_rows = inner.height.saturating_sub(4) as usize;
3267                    for index in selection_range(total, focus, list_rows) {
3268                        let value = if index == 0 {
3269                            "default"
3270                        } else {
3271                            efforts[index - 1].as_str()
3272                        };
3273                        let selected = index == focus;
3274                        lines.push(Line::styled(
3275                            format!("{} {value}", if selected { "›" } else { " " }),
3276                            if selected {
3277                                Style::default().fg(Color::Black).bg(Color::Cyan)
3278                            } else {
3279                                Style::default().fg(Color::White)
3280                            },
3281                        ));
3282                    }
3283                    lines.push(Line::styled(
3284                        "↑/↓ move · Enter save · Esc cancel",
3285                        Style::default().fg(Color::DarkGray),
3286                    ));
3287                }
3288                None => {
3289                    lines.push(Line::raw("Provider did not advertise allowed efforts."));
3290                    lines.push(Line::from(vec![
3291                        Span::styled("Value  ", Style::default().fg(Color::DarkGray)),
3292                        Span::styled(
3293                            if input.is_empty() { "default" } else { input },
3294                            Style::default().fg(Color::White),
3295                        ),
3296                    ]));
3297                    lines.push(Line::styled(
3298                        "Type a value · Enter save · Esc cancel",
3299                        Style::default().fg(Color::DarkGray),
3300                    ));
3301                }
3302            }
3303            lines
3304        }
3305    };
3306    frame.render_widget(Paragraph::new(lines), inner);
3307}
3308
3309fn selection_range(total: usize, focus: usize, max_rows: usize) -> std::ops::Range<usize> {
3310    if total == 0 || max_rows == 0 {
3311        return 0..0;
3312    }
3313    let focus = focus.min(total - 1);
3314    let visible = total.min(max_rows);
3315    let start = focus
3316        .saturating_add(1)
3317        .saturating_sub(visible)
3318        .min(total - visible);
3319    start..start + visible
3320}
3321
3322fn draw_skill_picker(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
3323    let matches = state.matching_skill_names();
3324    let total = matches.len();
3325    if total == 0 || area.is_empty() {
3326        return;
3327    }
3328
3329    // The picker is painted last, over the existing transcript and activity;
3330    // its geometry never participates in the underlying layout.
3331    frame.render_widget(Clear, area);
3332    let inner = Rect::new(
3333        area.x.saturating_add(2),
3334        area.y.saturating_add(1),
3335        area.width.saturating_sub(4),
3336        area.height.saturating_sub(2),
3337    );
3338    let buffer = frame.buffer_mut();
3339    for y in area.y..area.y.saturating_add(area.height) {
3340        for x in area.x..area.x.saturating_add(area.width) {
3341            buffer[(x, y)].set_bg(SKILL_PICKER_BACKGROUND);
3342        }
3343    }
3344    if inner.is_empty() {
3345        return;
3346    }
3347
3348    let focus = state.skill_picker_focus.min(total - 1);
3349    let header = Line::styled(
3350        format!("[{}/{}]", focus + 1, total),
3351        Style::default().fg(QUEUED_MESSAGE_COLOR),
3352    );
3353    frame.render_widget(
3354        Paragraph::new(header),
3355        Rect::new(inner.x, inner.y, inner.width, 1),
3356    );
3357
3358    let item_rows = inner.height.saturating_sub(1) as usize;
3359    for (row, index) in selection_range(total, focus, item_rows).enumerate() {
3360        let mut style = Style::default().fg(QUEUED_MESSAGE_COLOR);
3361        if index == focus {
3362            style = style.add_modifier(Modifier::BOLD);
3363        }
3364        let skill = Line::styled(format!("/{}", matches[index]), style);
3365        frame.render_widget(
3366            Paragraph::new(skill),
3367            Rect::new(inner.x, inner.y + 1 + row as u16, inner.width, 1),
3368        );
3369    }
3370}
3371
3372fn greeting_image_enabled() -> bool {
3373    std::env::var("LUCY_GREETING_IMAGE").as_deref() == Ok("true")
3374}
3375
3376#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3377struct WelcomeImageLayout {
3378    image_area: Rect,
3379    intro_area: Rect,
3380    image_size: Size,
3381}
3382
3383fn welcome_image_layout(area: Rect, intro_height: u16) -> Option<WelcomeImageLayout> {
3384    let available_height = area
3385        .height
3386        .saturating_sub(intro_height.saturating_add(WELCOME_IMAGE_GAP));
3387    let max_width = area.width.min(GREETING_IMAGE_SIZE.width);
3388    let max_height = available_height.min(GREETING_IMAGE_SIZE.height);
3389    let aspect_width = GREETING_IMAGE_SIZE.width / GREETING_IMAGE_SIZE.height;
3390    let image_height = max_height.min(max_width / aspect_width);
3391    let image_size = Size::new(image_height * aspect_width, image_height);
3392    if image_size.width < GREETING_IMAGE_MIN_SIZE.width
3393        || image_size.height < GREETING_IMAGE_MIN_SIZE.height
3394    {
3395        return None;
3396    }
3397
3398    let group_height = image_size.height + WELCOME_IMAGE_GAP + intro_height;
3399    let group_y = area.y + area.height.saturating_sub(group_height) / 2;
3400    Some(WelcomeImageLayout {
3401        image_area: Rect::new(
3402            area.x + (area.width - image_size.width) / 2,
3403            group_y,
3404            image_size.width,
3405            image_size.height,
3406        ),
3407        intro_area: Rect::new(
3408            area.x,
3409            group_y + image_size.height + WELCOME_IMAGE_GAP,
3410            area.width,
3411            intro_height,
3412        ),
3413        image_size,
3414    })
3415}
3416
3417type WelcomeImageCache = Mutex<HashMap<(u16, u16), Arc<Protocol>>>;
3418
3419fn welcome_image(size: Size) -> Arc<Protocol> {
3420    static IMAGES: OnceLock<WelcomeImageCache> = OnceLock::new();
3421    let images = IMAGES.get_or_init(|| Mutex::new(HashMap::new()));
3422    let mut images = images
3423        .lock()
3424        .expect("welcome image cache should not be poisoned");
3425    images
3426        .entry((size.width, size.height))
3427        .or_insert_with(|| {
3428            let image = image::load_from_memory(GREETING_IMAGE_BYTES)
3429                .expect("embedded greeting PNG should decode");
3430            let image = dim_welcome_image(image);
3431            Arc::new(
3432                Picker::halfblocks()
3433                    .new_protocol(image, size, Resize::Fit(None))
3434                    .expect("embedded greeting PNG should convert to halfblocks"),
3435            )
3436        })
3437        .clone()
3438}
3439
3440fn dim_welcome_image(image: image::DynamicImage) -> image::DynamicImage {
3441    let mut image = image.to_rgba8();
3442    for pixel in image.pixels_mut() {
3443        for channel in pixel.0.iter_mut().take(3) {
3444            *channel = (u16::from(*channel) * WELCOME_IMAGE_BRIGHTNESS_PERCENT / 100) as u8;
3445        }
3446    }
3447    image::DynamicImage::ImageRgba8(image)
3448}
3449
3450fn logo_lines() -> Vec<Line<'static>> {
3451    let max_width = LOGO_TEXT
3452        .lines()
3453        .map(|line| line.chars().count())
3454        .max()
3455        .unwrap_or(0);
3456    LOGO_TEXT
3457        .lines()
3458        .map(|line| {
3459            let spans: Vec<Span> = line
3460                .chars()
3461                .enumerate()
3462                .map(|(index, character)| {
3463                    let progress = if max_width <= 1 {
3464                        0.0
3465                    } else {
3466                        index as f32 / (max_width - 1) as f32
3467                    };
3468                    let color = Color::Rgb(
3469                        interpolate_color(LOGO_START_COLOR.0, LOGO_END_COLOR.0, progress),
3470                        interpolate_color(LOGO_START_COLOR.1, LOGO_END_COLOR.1, progress),
3471                        interpolate_color(LOGO_START_COLOR.2, LOGO_END_COLOR.2, progress),
3472                    );
3473                    Span::styled(character.to_string(), Style::default().fg(color))
3474                })
3475                .collect();
3476            Line::from(spans)
3477        })
3478        .collect()
3479}
3480
3481fn welcome_line() -> Line<'static> {
3482    let character_count = WELCOME_MESSAGE.chars().count();
3483    let spans = WELCOME_MESSAGE
3484        .chars()
3485        .enumerate()
3486        .map(|(index, character)| {
3487            let progress = if character_count <= 1 {
3488                0.0
3489            } else {
3490                index as f32 / (character_count - 1) as f32
3491            };
3492            let color = Color::Rgb(
3493                interpolate_color(WELCOME_START_COLOR.0, WELCOME_END_COLOR.0, progress),
3494                interpolate_color(WELCOME_START_COLOR.1, WELCOME_END_COLOR.1, progress),
3495                interpolate_color(WELCOME_START_COLOR.2, WELCOME_END_COLOR.2, progress),
3496            );
3497            Span::styled(character.to_string(), Style::default().fg(color))
3498        })
3499        .collect::<Vec<_>>();
3500    Line::from(spans)
3501}
3502
3503fn interpolate_color(start: u8, end: u8, progress: f32) -> u8 {
3504    (start as f32 + (end as f32 - start as f32) * progress).round() as u8
3505}
3506
3507fn welcome_lines(attached_agents: &[String]) -> Vec<Line<'static>> {
3508    let mut lines = vec![
3509        welcome_line(),
3510        Line::styled(WELCOME_VERSION, Style::default().fg(Color::DarkGray)),
3511        Line::raw(""),
3512        Line::styled(WELCOME_TAGLINE, Style::default().fg(Color::DarkGray)),
3513        Line::raw(""),
3514    ];
3515
3516    if attached_agents.is_empty() {
3517        lines.push(Line::styled(
3518            "Attached AGENTS.md: none",
3519            Style::default().fg(Color::DarkGray),
3520        ));
3521    } else {
3522        lines.push(Line::styled(
3523            "Attached AGENTS.md:",
3524            Style::default().fg(Color::DarkGray),
3525        ));
3526        lines.extend(
3527            attached_agents.iter().map(|path| {
3528                Line::styled(format!("• {path}"), Style::default().fg(Color::DarkGray))
3529            }),
3530        );
3531    }
3532
3533    lines
3534}
3535
3536fn transcript_lines(state: &UiState, width: u16) -> Vec<Line<'static>> {
3537    render_transcript_items(&state.transcript, width.max(1) as usize, state, true)
3538}
3539
3540fn render_transcript_items(
3541    transcript: &[TranscriptItem],
3542    width: usize,
3543    state: &UiState,
3544    suppress_subagent_tools: bool,
3545) -> Vec<Line<'static>> {
3546    let mut lines = Vec::new();
3547    let mut rendered_item = false;
3548
3549    for (index, item) in transcript.iter().enumerate() {
3550        // Results are positioned on their matching call, even when the model
3551        // emitted several calls before execution produced any result.
3552        if is_result_attached_to_call(transcript, index) {
3553            continue;
3554        }
3555        if suppress_subagent_tools
3556            && matches!(
3557                item,
3558                TranscriptItem::ToolCall { name, .. } | TranscriptItem::ToolResult { name, .. }
3559                    if is_subagent_tool(name)
3560            )
3561        {
3562            continue;
3563        }
3564        if rendered_item {
3565            lines.push(Line::raw(String::new()));
3566        }
3567        match item {
3568            TranscriptItem::User {
3569                text,
3570                skill_instruction_attached,
3571            } => {
3572                let text = redact_secret(text, Some(&state.secret));
3573                let trigger = skill_instruction_attached
3574                    .then(|| active_skill_trigger(&text, &state.skill_names))
3575                    .flatten();
3576                push_user_message_block(&mut lines, &text, trigger, width);
3577            }
3578            TranscriptItem::Assistant(text) => {
3579                let text = redact_secret(text, Some(&state.secret));
3580                push_wrapped(&mut lines, &text, width, Style::default());
3581            }
3582            TranscriptItem::ToolCall {
3583                id,
3584                name,
3585                arguments,
3586            } => {
3587                let result = matching_tool_result(transcript, index, id);
3588                if !suppress_subagent_tools || !is_subagent_tool(name) {
3589                    let segments = if name == "cmd" {
3590                        cmd_tool_segments(id, arguments, result, state)
3591                    } else {
3592                        generic_tool_segments(name, arguments, result, state)
3593                    };
3594                    push_spans_wrapped(&mut lines, &segments, width);
3595                }
3596            }
3597            TranscriptItem::ToolResult {
3598                id: _,
3599                name: _,
3600                result,
3601            } => {
3602                let result_text = format_tool_result(result);
3603                let result_text = redact_secret(&result_text, Some(&state.secret));
3604                push_spans_wrapped(&mut lines, &[(result_text, tool_result_style())], width);
3605            }
3606            TranscriptItem::Error(text) => {
3607                let text = redact_secret(text, Some(&state.secret));
3608                push_wrapped(&mut lines, &text, width, error_style());
3609            }
3610            TranscriptItem::Info(text) => {
3611                let text = redact_secret(text, Some(&state.secret));
3612                push_wrapped(&mut lines, &text, width, info_style());
3613            }
3614            TranscriptItem::SubagentLifecycle {
3615                completion_id,
3616                task_id,
3617                status,
3618                delivered,
3619            } => {
3620                let (marker, text, style) = if *delivered {
3621                    (
3622                        "✓",
3623                        format!("✓ subagent  {task_id}  · result delivered ({completion_id})"),
3624                        tool_result_style(),
3625                    )
3626                } else {
3627                    let marker = if status == "completed" { "·" } else { "!" };
3628                    let style = if status == "completed" {
3629                        info_style()
3630                    } else {
3631                        error_style()
3632                    };
3633                    (
3634                        marker,
3635                        format!("{marker} subagent  {task_id}  → {status} · result pending ({completion_id})"),
3636                        style,
3637                    )
3638                };
3639                let _ = marker;
3640                push_wrapped(&mut lines, &text, width, style);
3641            }
3642            TranscriptItem::Reasoning { complete } => {
3643                let text = if *complete {
3644                    "Reasoning Complete".to_owned()
3645                } else {
3646                    format!("Reasoning... {}", spinner_frame(state))
3647                };
3648                push_wrapped(&mut lines, &text, width, thinking_style());
3649            }
3650        }
3651        rendered_item = true;
3652    }
3653    if lines.is_empty() {
3654        lines.push(Line::raw(""));
3655    }
3656    lines
3657}
3658
3659/// Tool work can outlive a main-agent turn (for example, background
3660/// subagents), so it uses its own clock instead of the main status animation.
3661fn running_tool_status(state: &UiState) -> String {
3662    tool_spinner_frame(state)
3663}
3664
3665fn cmd_tool_segments(
3666    call_id: &str,
3667    arguments: &str,
3668    result: Option<&Value>,
3669    state: &UiState,
3670) -> Vec<(String, Style)> {
3671    let command = redact_secret(&command_display(arguments), Some(&state.secret));
3672    if let Some(result) = result {
3673        let (icon, status, status_style) = cmd_result_status(result);
3674        if status == "done" || state.cmd_result_started_at.contains_key(call_id) {
3675            let text = if status == "done" {
3676                format!("{icon} cmd  $ {command}")
3677            } else {
3678                format!("{icon} cmd  $ {command}  → {status}")
3679            };
3680            return cmd_result_segments(call_id, &text, cmd_result_target_color(result), state);
3681        }
3682        vec![
3683            (format!("{icon} cmd  $ {command}  → "), status_style),
3684            (status, status_style),
3685        ]
3686    } else {
3687        vec![
3688            (format!("· cmd  $ {command}  "), pending_tool_call_style()),
3689            (running_tool_status(state), pending_tool_call_style()),
3690        ]
3691    }
3692}
3693
3694/// During the brief post-result window, turn the compact `cmd` line from the
3695/// pending orange into its final result colour one character at a time. A few
3696/// adjacent characters blend at the leading edge so the visual is a true
3697/// gradient, rather than a hard colour boundary.
3698fn cmd_result_segments(
3699    call_id: &str,
3700    text: &str,
3701    target: Color,
3702    state: &UiState,
3703) -> Vec<(String, Style)> {
3704    let now = Instant::now();
3705    let Some(started_at) = state.cmd_result_started_at.get(call_id).copied() else {
3706        return vec![(text.to_owned(), Style::default().fg(target))];
3707    };
3708    if now.saturating_duration_since(started_at) >= TOOL_RESULT_SWEEP_DURATION {
3709        return vec![(text.to_owned(), Style::default().fg(target))];
3710    }
3711
3712    let character_count = text.chars().count();
3713    text.chars()
3714        .enumerate()
3715        .map(|(index, character)| {
3716            (
3717                character.to_string(),
3718                Style::default().fg(cmd_result_color_at(
3719                    started_at,
3720                    now,
3721                    index,
3722                    character_count,
3723                    target,
3724                )),
3725            )
3726        })
3727        .collect()
3728}
3729
3730fn cmd_result_color_at(
3731    started_at: Instant,
3732    now: Instant,
3733    character_index: usize,
3734    character_count: usize,
3735    target: Color,
3736) -> Color {
3737    let elapsed = now.saturating_duration_since(started_at);
3738    if elapsed >= TOOL_RESULT_SWEEP_DURATION {
3739        return target;
3740    }
3741
3742    let progress = elapsed.as_secs_f32() / TOOL_RESULT_SWEEP_DURATION.as_secs_f32();
3743    let character_position = if character_count <= 1 {
3744        0.0
3745    } else {
3746        character_index as f32 / (character_count - 1) as f32
3747    };
3748    let fade_start = character_position * (1.0 - TOOL_RESULT_CHARACTER_FADE_PORTION);
3749    let character_progress =
3750        ((progress - fade_start) / TOOL_RESULT_CHARACTER_FADE_PORTION).clamp(0.0, 1.0);
3751    let character_progress =
3752        character_progress * character_progress * (3.0 - 2.0 * character_progress);
3753    let (target_red, target_green, target_blue) = tool_result_color_rgb(target);
3754    Color::Rgb(
3755        interpolate_color(PENDING_TOOL_COLOR_RGB.0, target_red, character_progress),
3756        interpolate_color(PENDING_TOOL_COLOR_RGB.1, target_green, character_progress),
3757        interpolate_color(PENDING_TOOL_COLOR_RGB.2, target_blue, character_progress),
3758    )
3759}
3760
3761fn command_display(arguments: &str) -> String {
3762    serde_json::from_str::<Value>(arguments)
3763        .ok()
3764        .and_then(|value| {
3765            value
3766                .get("command")
3767                .and_then(Value::as_str)
3768                .map(str::to_owned)
3769        })
3770        .map(|command| truncate_tool_call(&command))
3771        .unwrap_or_else(|| truncate_tool_call(arguments))
3772}
3773
3774fn cmd_result_target_color(result: &Value) -> Color {
3775    if result
3776        .get("canceled")
3777        .and_then(Value::as_bool)
3778        .unwrap_or(false)
3779        || result
3780            .get("timed_out")
3781            .and_then(Value::as_bool)
3782            .unwrap_or(false)
3783    {
3784        return TOOL_WARNING_COLOR;
3785    }
3786    if result.get("error").is_some()
3787        || matches!(result.get("exit_code").and_then(Value::as_i64), Some(code) if code != 0)
3788    {
3789        return TOOL_FAILURE_COLOR;
3790    }
3791    TOOL_SUCCESS_COLOR
3792}
3793
3794fn tool_result_color_rgb(color: Color) -> (u8, u8, u8) {
3795    let Color::Rgb(red, green, blue) = color else {
3796        unreachable!("cmd result transition colours are RGB")
3797    };
3798    (red, green, blue)
3799}
3800
3801fn cmd_result_status(result: &Value) -> (char, String, Style) {
3802    let target = cmd_result_target_color(result);
3803    if result
3804        .get("canceled")
3805        .and_then(Value::as_bool)
3806        .unwrap_or(false)
3807    {
3808        return ('!', "canceled".to_owned(), Style::default().fg(target));
3809    }
3810    if result
3811        .get("timed_out")
3812        .and_then(Value::as_bool)
3813        .unwrap_or(false)
3814    {
3815        return ('!', "timeout".to_owned(), Style::default().fg(target));
3816    }
3817    if result.get("error").is_some() {
3818        return ('×', "error".to_owned(), Style::default().fg(target));
3819    }
3820    match result.get("exit_code").and_then(Value::as_i64) {
3821        Some(0) => ('✓', "done".to_owned(), Style::default().fg(target)),
3822        Some(code) => ('×', format!("exit {code}"), Style::default().fg(target)),
3823        None => ('✓', "done".to_owned(), Style::default().fg(target)),
3824    }
3825}
3826
3827fn generic_tool_segments(
3828    name: &str,
3829    arguments: &str,
3830    result: Option<&Value>,
3831    state: &UiState,
3832) -> Vec<(String, Style)> {
3833    let call_text = redact_secret(
3834        &format!("[tool:{name} {}]", call_arguments(arguments)),
3835        Some(&state.secret),
3836    );
3837    let mut segments = vec![(
3838        call_text,
3839        if result.is_some() {
3840            tool_call_style()
3841        } else {
3842            pending_tool_call_style()
3843        },
3844    )];
3845    if let Some(result) = result {
3846        let result_text = redact_secret(&format_tool_result(result), Some(&state.secret));
3847        segments.push((" > ".to_owned(), Style::default()));
3848        segments.push((result_text, tool_result_style()));
3849    } else {
3850        segments.push((
3851            format!(" {}", tool_spinner_frame(state)),
3852            pending_tool_call_style(),
3853        ));
3854    }
3855    segments
3856}
3857
3858fn matching_tool_result<'a>(
3859    transcript: &'a [TranscriptItem],
3860    call_index: usize,
3861    call_id: &str,
3862) -> Option<&'a Value> {
3863    transcript
3864        .iter()
3865        .skip(call_index + 1)
3866        .find_map(|item| match item {
3867            TranscriptItem::ToolResult { id, result, .. } if id == call_id => Some(result),
3868            _ => None,
3869        })
3870}
3871
3872fn is_result_attached_to_call(transcript: &[TranscriptItem], result_index: usize) -> bool {
3873    let TranscriptItem::ToolResult { id, .. } = &transcript[result_index] else {
3874        return false;
3875    };
3876    let Some(call_index) = transcript[..result_index].iter().rposition(
3877        |item| matches!(item, TranscriptItem::ToolCall { id: call_id, .. } if call_id == id),
3878    ) else {
3879        return false;
3880    };
3881    !transcript[call_index + 1..result_index].iter().any(
3882        |item| matches!(item, TranscriptItem::ToolResult { id: result_id, .. } if result_id == id),
3883    )
3884}
3885
3886const TOOL_CALL_PREVIEW_CHARS: usize = 100;
3887
3888fn truncate_tool_call(output: &str) -> String {
3889    let mut result: String = output.chars().take(TOOL_CALL_PREVIEW_CHARS).collect();
3890    if output.chars().count() > TOOL_CALL_PREVIEW_CHARS {
3891        result.push('…');
3892    }
3893    result
3894}
3895
3896/// Render tool call arguments as the command string inside double quotes, for
3897/// example `"cat README.md"`. Tool-call previews are limited to 100 characters;
3898/// malformed arguments fall back to the same bounded raw-text preview.
3899fn call_arguments(arguments: &str) -> String {
3900    let parsed: Value = match serde_json::from_str(arguments) {
3901        Ok(value) => value,
3902        Err(_) => return truncate_tool_call(arguments),
3903    };
3904    if let Some(command) = parsed.get("command").and_then(Value::as_str) {
3905        return format!("\"{}\"", truncate_tool_call(command));
3906    }
3907    let serialized = serde_json::to_string(&parsed).unwrap_or_else(|_| arguments.to_owned());
3908    truncate_tool_call(&serialized)
3909}
3910
3911/// Render a tool result as a single-line JSON-string-array literal containing
3912/// stdout (or stderr when stdout is empty). Newlines are escaped so the whole
3913/// result stays on one line. Output is truncated to `RESULT_PREVIEW_CHARS`.
3914fn format_tool_result(result: &Value) -> String {
3915    let stdout = result.get("stdout").and_then(Value::as_str).unwrap_or("");
3916    let stderr = result.get("stderr").and_then(Value::as_str).unwrap_or("");
3917    let output = if !stdout.is_empty() { stdout } else { stderr };
3918    let truncated = truncate_output(output);
3919    // Build a JSON string literal so newlines and quotes are escaped and the
3920    // result renders on a single line as `["..."]`.
3921    let json_string = serde_json::to_string(&truncated).unwrap_or_else(|_| "\"\"".to_owned());
3922    format!("[{json_string}]")
3923}
3924
3925const RESULT_PREVIEW_CHARS: usize = 50;
3926
3927fn truncate_output(output: &str) -> String {
3928    let mut result: String = output.chars().take(RESULT_PREVIEW_CHARS).collect();
3929    if output.chars().count() > RESULT_PREVIEW_CHARS {
3930        result.push('…');
3931    }
3932    result
3933}
3934
3935fn user_message_style() -> Style {
3936    Style::default().fg(USER_BORDER_COLOR)
3937}
3938
3939/// Render user messages with a one-cell yellow block rule, one inner left
3940/// padding cell, and blank rows above and below; assistant and tool output remains borderless.
3941fn push_user_message_block(
3942    lines: &mut Vec<Line<'static>>,
3943    text: &str,
3944    active_skill_trigger: Option<&str>,
3945    width: usize,
3946) {
3947    if width < 3 {
3948        lines.extend(styled_text_lines(
3949            text,
3950            active_skill_trigger,
3951            width.max(1),
3952            Style::default().fg(Color::White),
3953        ));
3954        return;
3955    }
3956
3957    let border_style = user_message_style();
3958    let rows = styled_text_lines(
3959        text,
3960        active_skill_trigger,
3961        width - 2,
3962        Style::default().fg(Color::White),
3963    );
3964    lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
3965    for row in rows {
3966        let mut spans = Vec::with_capacity(row.spans.len() + 2);
3967        spans.push(Span::styled(USER_BORDER_GLYPH, border_style));
3968        spans.push(Span::styled(" ", Style::default().fg(Color::White)));
3969        spans.extend(row.spans);
3970        lines.push(Line::from(spans));
3971    }
3972    lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
3973}
3974
3975fn tool_call_style() -> Style {
3976    Style::default().fg(Color::Magenta)
3977}
3978
3979fn pending_tool_call_style() -> Style {
3980    Style::default().fg(PENDING_TOOL_COLOR)
3981}
3982
3983fn tool_result_style() -> Style {
3984    Style::default().fg(Color::DarkGray)
3985}
3986
3987fn error_style() -> Style {
3988    Style::default().fg(Color::Red)
3989}
3990
3991fn info_style() -> Style {
3992    Style::default().fg(Color::DarkGray)
3993}
3994
3995fn context_status_text(state: &UiState) -> String {
3996    let used = format_context_tokens(state.context_tokens);
3997    let Some(window) = state.context_window else {
3998        return format!("Context: {used}/? (?%) ??????????");
3999    };
4000    let percentage = context_percentage(state.context_tokens, window);
4001    format!(
4002        "Context: {used}/{} ({percentage}%) {}",
4003        format_context_tokens(window),
4004        context_progress_bar(state.context_tokens, window)
4005    )
4006}
4007
4008fn context_progress_bar(used: usize, window: usize) -> String {
4009    const WIDTH: usize = 10;
4010    let filled = if window == 0 {
4011        0
4012    } else {
4013        (used as u128 * WIDTH as u128)
4014            .div_ceil(window as u128)
4015            .min(WIDTH as u128) as usize
4016    };
4017    format!("{}{}", "█".repeat(filled), "░".repeat(WIDTH - filled))
4018}
4019
4020fn context_status_style(_state: &UiState) -> Style {
4021    Style::default().fg(CONSOLE_STATUS_COLOR)
4022}
4023
4024fn context_percentage(used: usize, window: usize) -> usize {
4025    if window == 0 {
4026        return 0;
4027    }
4028    ((used as u128 * 100).div_ceil(window as u128)) as usize
4029}
4030
4031fn format_context_tokens(tokens: usize) -> String {
4032    if tokens >= 1_000_000 {
4033        format!("{:.2}M", tokens as f64 / 1_000_000.0)
4034    } else if tokens >= 1_000 {
4035        format!("{:.1}K", tokens as f64 / 1_000.0)
4036    } else {
4037        tokens.to_string()
4038    }
4039}
4040
4041fn model_status_line(state: &UiState, effort: &str) -> Line<'static> {
4042    let model = redact_secret(&state.model, Some(&state.secret));
4043    let effort = redact_secret(effort, Some(&state.secret));
4044    Line::styled(
4045        format!("{model} · {effort} | {}", context_status_text(state)),
4046        context_status_style(state),
4047    )
4048}
4049
4050fn blend_rgb(from: Color, to: Color, progress: f32) -> Color {
4051    let (from_red, from_green, from_blue) = activity_rgb(from);
4052    let (to_red, to_green, to_blue) = activity_rgb(to);
4053    Color::Rgb(
4054        interpolate_color(from_red, to_red, progress),
4055        interpolate_color(from_green, to_green, progress),
4056        interpolate_color(from_blue, to_blue, progress),
4057    )
4058}
4059
4060fn activity_rgb(color: Color) -> (u8, u8, u8) {
4061    match color {
4062        Color::Rgb(red, green, blue) => (red, green, blue),
4063        // The resting indicator uses ratatui's named cyan. Convert it to its
4064        // ANSI RGB equivalent while blending into and out of the accent cycle.
4065        Color::Cyan => (0, 255, 255),
4066        _ => unreachable!("activity transition colours are cyan or RGB"),
4067    }
4068}
4069
4070fn console_reach_at(elapsed: Duration) -> f32 {
4071    let phase = elapsed.as_secs_f32() / CONSOLE_BOUNDARY_CYCLE.as_secs_f32();
4072    let midpoint = (CONSOLE_REACH_MIN + CONSOLE_REACH_MAX) / 2.0;
4073    let amplitude = (CONSOLE_REACH_MAX - CONSOLE_REACH_MIN) / 2.0;
4074    midpoint + amplitude * (phase * std::f32::consts::TAU).sin()
4075}
4076
4077fn console_accent_cycle() -> Duration {
4078    CONSOLE_ACCENT_CYCLE_DURATION
4079}
4080
4081fn console_accent_at(elapsed: Duration) -> Color {
4082    let cycle_progress =
4083        (elapsed.as_secs_f32() / console_accent_cycle().as_secs_f32()).rem_euclid(1.0);
4084    let progress = if cycle_progress <= 0.5 {
4085        cycle_progress * 2.0
4086    } else {
4087        (1.0 - cycle_progress) * 2.0
4088    };
4089    desaturate_console_accent(
4090        interpolate_color(CONSOLE_ACCENT_LAVENDER.0, CONSOLE_ACCENT_TEAL.0, progress),
4091        interpolate_color(CONSOLE_ACCENT_LAVENDER.1, CONSOLE_ACCENT_TEAL.1, progress),
4092        interpolate_color(CONSOLE_ACCENT_LAVENDER.2, CONSOLE_ACCENT_TEAL.2, progress),
4093    )
4094}
4095
4096fn desaturate_console_accent(red: u8, green: u8, blue: u8) -> Color {
4097    let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
4098    Color::Rgb(
4099        interpolate_color(red, neutral, CONSOLE_ACCENT_DESATURATION),
4100        interpolate_color(green, neutral, CONSOLE_ACCENT_DESATURATION),
4101        interpolate_color(blue, neutral, CONSOLE_ACCENT_DESATURATION),
4102    )
4103}
4104
4105/// Return a smooth half-elliptical falloff beneath the console.
4106///
4107/// The TUI's bottom edge cuts the ellipse on its horizontal centreline, so the
4108/// visible upper half grows wider toward the bottom. The glow is never taller
4109/// than `GLOW_HEIGHT` terminal rows and reaches farther horizontally than it
4110/// does vertically.
4111fn glow_coverage_at(column: u16, row: u16, canvas: Rect, console_area: Rect) -> f32 {
4112    let canvas_bottom = canvas.y.saturating_add(canvas.height);
4113    if canvas.is_empty() || row < canvas.y || row >= canvas_bottom {
4114        return 0.0;
4115    }
4116
4117    let left = console_area.x.max(canvas.x);
4118    let right = console_area
4119        .x
4120        .saturating_add(console_area.width)
4121        .min(canvas.x.saturating_add(canvas.width));
4122    if left >= right {
4123        return 0.0;
4124    }
4125
4126    let x = canvas.x.saturating_add(column);
4127    let center_x = (left as f32 + right.saturating_sub(1) as f32) / 2.0;
4128    let horizontal_radius = (right - left) as f32 / 2.0 + GLOW_HORIZONTAL_SPREAD as f32;
4129    let horizontal_distance = (x as f32 - center_x).abs() / horizontal_radius;
4130    // Sample the lower edge of each cell: the bottom edge of the TUI is the
4131    // ellipse's centreline while its visible half remains within the canvas.
4132    let vertical_distance =
4133        (row.saturating_add(1) as f32 - canvas_bottom as f32).abs() / GLOW_HEIGHT as f32;
4134    let distance = horizontal_distance.hypot(vertical_distance);
4135    let falloff = (1.0 - distance).clamp(0.0, 1.0);
4136    falloff * falloff * (3.0 - 2.0 * falloff)
4137}
4138
4139fn glow_accent_at(elapsed: Duration) -> Color {
4140    glow_accent_with_desaturation_at(elapsed, GLOW_DESATURATION)
4141}
4142
4143fn glow_accent_with_desaturation_at(elapsed: Duration, desaturation: f32) -> Color {
4144    let (red, green, blue) = activity_rgb(console_accent_at(elapsed));
4145    let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
4146    Color::Rgb(
4147        interpolate_color(red, neutral, desaturation),
4148        interpolate_color(green, neutral, desaturation),
4149        interpolate_color(blue, neutral, desaturation),
4150    )
4151}
4152
4153fn glow_color_at(
4154    elapsed: Duration,
4155    column: u16,
4156    _width: u16,
4157    row: u16,
4158    canvas: Rect,
4159    console_area: Rect,
4160    visibility: f32,
4161) -> Option<Color> {
4162    let visibility = visibility.clamp(0.0, 1.0);
4163    let bottom = canvas.y.saturating_add(canvas.height);
4164    if visibility <= 0.0 || row < canvas.y || row >= bottom {
4165        return None;
4166    }
4167
4168    let coverage = glow_coverage_at(column, row, canvas, console_area);
4169    if coverage <= 0.0 {
4170        return None;
4171    }
4172
4173    let intensity =
4174        (console_reach_at(elapsed) / CONSOLE_REACH_MAX) * GLOW_INTENSITY * coverage * visibility;
4175    Some(blend_rgb(
4176        TUI_GLOW_BACKGROUND,
4177        glow_accent_at(elapsed),
4178        intensity,
4179    ))
4180}
4181
4182fn apply_tui_glow(
4183    frame: &mut Frame<'_>,
4184    canvas: Rect,
4185    console_area: Rect,
4186    elapsed: Duration,
4187    visibility: f32,
4188) {
4189    let left = console_area
4190        .x
4191        .saturating_sub(GLOW_HORIZONTAL_SPREAD)
4192        .max(canvas.x);
4193    let right = console_area
4194        .x
4195        .saturating_add(console_area.width)
4196        .saturating_add(GLOW_HORIZONTAL_SPREAD)
4197        .min(canvas.x.saturating_add(canvas.width));
4198    let bottom = canvas.y.saturating_add(canvas.height);
4199    let top = bottom.saturating_sub(GLOW_HEIGHT).max(canvas.y);
4200    let buffer = frame.buffer_mut();
4201    for y in top..bottom {
4202        for x in left..right {
4203            if let Some(color) = glow_color_at(
4204                elapsed,
4205                x.saturating_sub(canvas.x),
4206                canvas.width,
4207                y,
4208                canvas,
4209                console_area,
4210                visibility,
4211            ) {
4212                buffer[(x, y)].set_bg(color);
4213            }
4214        }
4215    }
4216}
4217
4218fn console_glass_color_at(elapsed: Duration, glow: Color, visibility: f32) -> Color {
4219    let (red, green, blue) = activity_rgb(console_accent_at(elapsed));
4220    let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
4221    let glass_accent = Color::Rgb(
4222        interpolate_color(red, neutral, CONSOLE_GLASS_DESATURATION),
4223        interpolate_color(green, neutral, CONSOLE_GLASS_DESATURATION),
4224        interpolate_color(blue, neutral, CONSOLE_GLASS_DESATURATION),
4225    );
4226    let visibility = visibility.clamp(0.0, 1.0);
4227    let tint = blend_rgb(
4228        CONSOLE_BACKGROUND,
4229        glass_accent,
4230        CONSOLE_GLASS_TINT * visibility,
4231    );
4232    let white_tinted = blend_rgb(
4233        tint,
4234        Color::Rgb(255, 255, 255),
4235        CONSOLE_GLASS_WHITE_TINT * visibility,
4236    );
4237    blend_rgb(white_tinted, glow, CONSOLE_GLASS_GLOW_THROUGH * visibility)
4238}
4239
4240/// Return the faint accent colour used by the external glass reflection.
4241fn console_top_reflection_color_at(elapsed: Duration, background: Color, visibility: f32) -> Color {
4242    let visibility = visibility.clamp(0.0, 1.0);
4243    let reflected = blend_rgb(
4244        background,
4245        glow_accent_at(elapsed),
4246        CONSOLE_REFLECTION_TINT * visibility,
4247    );
4248    blend_rgb(
4249        reflected,
4250        Color::Rgb(255, 255, 255),
4251        CONSOLE_REFLECTION_WHITE_TINT * visibility,
4252    )
4253}
4254
4255/// Draw a one-eighth-cell reflection on the bottom edge of the row immediately
4256/// above the console. This leaves the console's own background uniform while
4257/// keeping the effect thin in terminals that render block glyphs.
4258fn apply_console_top_reflection(
4259    frame: &mut Frame<'_>,
4260    area: Rect,
4261    elapsed: Duration,
4262    visibility: f32,
4263) {
4264    if visibility <= 0.0 || area.y == 0 {
4265        return;
4266    }
4267
4268    let y = area.y - 1;
4269    let buffer = frame.buffer_mut();
4270    for x in area.x..area.x.saturating_add(area.width) {
4271        let background = match buffer[(x, y)].bg {
4272            Color::Rgb(_, _, _) => buffer[(x, y)].bg,
4273            _ => TUI_GLOW_BACKGROUND,
4274        };
4275        buffer[(x, y)].set_symbol(CONSOLE_REFLECTION_GLYPH).set_fg(
4276            console_top_reflection_color_at(elapsed, background, visibility),
4277        );
4278    }
4279}
4280
4281/// Composite the dark, low-saturation glass only over the console rectangle.
4282fn apply_console_background(frame: &mut Frame<'_>, area: Rect, elapsed: Duration, visibility: f32) {
4283    let buffer = frame.buffer_mut();
4284    for y in area.y..area.y.saturating_add(area.height) {
4285        for x in area.x..area.x.saturating_add(area.width) {
4286            let glow = match buffer[(x, y)].bg {
4287                Color::Rgb(_, _, _) => buffer[(x, y)].bg,
4288                _ => TUI_GLOW_BACKGROUND,
4289            };
4290            buffer[(x, y)].set_bg(console_glass_color_at(elapsed, glow, visibility));
4291        }
4292    }
4293}
4294
4295fn thinking_style() -> Style {
4296    Style::default().fg(Color::DarkGray)
4297}
4298
4299// Unicode block elements occupy the full cell width. Rendering them without
4300// separators keeps the five bars visually continuous in terminal fonts.
4301const PULSE_LEVELS: [char; 7] = ['▁', '▂', '▃', '▅', '▆', '▇', '█'];
4302const PULSE_BAR_PERIODS: [u128; 5] = [12, 16, 20, 24, 15];
4303const PULSE_BAR_PHASES: [u128; 5] = [0, 5, 13, 9, 3];
4304const PULSE_TICK: Duration = Duration::from_millis(50);
4305const TOOL_SPINNER_FRAMES: [char; 4] = ['|', '/', '-', '\\'];
4306const TOOL_SPINNER_FRAME_DURATION: Duration = Duration::from_millis(100);
4307
4308/// Five independently phased triangle waves make the bars feel irregular
4309/// without random jumps: every rendered tick changes a bar by at most one
4310/// level, and the combined pattern repeats every 12 seconds.
4311const ACTIVITY_TRANSITION_DURATION: Duration = Duration::from_millis(400);
4312// This frame gives all five bars room to rise from the resting level while
4313// preserving the pulse waveform's one-level-per-tick continuity afterwards.
4314const PULSE_ENTRY_FRAME: Duration = Duration::from_millis(950);
4315
4316fn spinner_frame(state: &UiState) -> String {
4317    pulse_frame(state.activity_levels_at(Instant::now()))
4318}
4319
4320#[cfg(test)]
4321fn spinner_frame_at(elapsed: Duration) -> String {
4322    pulse_frame(pulse_levels_at(elapsed))
4323}
4324
4325/// A compact, traditional spinner for tool calls that are awaiting a result.
4326/// It deliberately has a separate epoch because background work can outlive a
4327/// main-agent turn.
4328fn tool_spinner_frame(state: &UiState) -> String {
4329    tool_spinner_frame_at(state.tool_animation_epoch.elapsed()).to_string()
4330}
4331
4332fn tool_spinner_frame_at(elapsed: Duration) -> char {
4333    let frame = (elapsed.as_millis() / TOOL_SPINNER_FRAME_DURATION.as_millis()) as usize;
4334    TOOL_SPINNER_FRAMES[frame % TOOL_SPINNER_FRAMES.len()]
4335}
4336
4337fn pulse_frame(levels: [usize; PULSE_BAR_PERIODS.len()]) -> String {
4338    levels
4339        .into_iter()
4340        .map(|level| PULSE_LEVELS[level])
4341        .collect()
4342}
4343
4344fn pulse_levels_at(elapsed: Duration) -> [usize; PULSE_BAR_PERIODS.len()] {
4345    let tick = elapsed.as_millis() / PULSE_TICK.as_millis();
4346    std::array::from_fn(|index| {
4347        pulse_level_at(tick, PULSE_BAR_PERIODS[index], PULSE_BAR_PHASES[index])
4348    })
4349}
4350
4351fn interpolate_pulse_levels(
4352    from: [usize; PULSE_BAR_PERIODS.len()],
4353    to: [usize; PULSE_BAR_PERIODS.len()],
4354    elapsed: Duration,
4355) -> [usize; PULSE_BAR_PERIODS.len()] {
4356    let elapsed = elapsed.min(ACTIVITY_TRANSITION_DURATION).as_millis();
4357    let duration = ACTIVITY_TRANSITION_DURATION.as_millis();
4358    std::array::from_fn(|index| {
4359        let start = from[index] as i128;
4360        let distance = to[index] as i128 - start;
4361        (start + distance * elapsed as i128 / duration as i128) as usize
4362    })
4363}
4364
4365fn pulse_level_at(tick: u128, period: u128, phase: u128) -> usize {
4366    let position = (tick + phase) % period;
4367    let half_period = period / 2;
4368    let distance_from_floor = if position <= half_period {
4369        position
4370    } else {
4371        period - position
4372    };
4373    (distance_from_floor * (PULSE_LEVELS.len() - 1) as u128 / half_period) as usize
4374}
4375
4376fn push_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, style: Style) {
4377    let mut added = false;
4378    for piece in wrap_text(text, width) {
4379        lines.push(Line::styled(piece, style));
4380        added = true;
4381    }
4382    if !added {
4383        lines.push(Line::styled(String::new(), style));
4384    }
4385}
4386
4387/// Push a logical line built from styled segments. When the rendered width
4388/// exceeds `width`, the whole line is character-wrapped; wrapped continuations
4389/// keep the style of the segment they fall on.
4390fn push_spans_wrapped(lines: &mut Vec<Line<'static>>, segments: &[(String, Style)], width: usize) {
4391    let mut current_spans: Vec<Span<'static>> = Vec::new();
4392    let mut current_width = 0usize;
4393    for (text, style) in segments {
4394        for character in text.chars() {
4395            let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
4396            if current_width + char_width > width && !current_spans.is_empty() {
4397                lines.push(Line::from(std::mem::take(&mut current_spans)));
4398                current_width = 0;
4399            }
4400            let mut buffer = [0u8; 4];
4401            let s = character.encode_utf8(&mut buffer);
4402            current_spans.push(Span::styled(s.to_owned(), *style));
4403            current_width += char_width;
4404        }
4405    }
4406    if current_spans.is_empty() {
4407        current_spans.push(Span::raw(String::new()));
4408    }
4409    lines.push(Line::from(current_spans));
4410}
4411
4412/// Wrap `text` into rows no wider than `width` display columns. Wrapping is
4413/// character-based so the row count matches exactly what a non-wrapping
4414/// `Paragraph` renderer draws, which keeps auto-scroll pinned to the true
4415/// bottom of the transcript regardless of terminal width. Empty lines are
4416/// preserved as empty rows.
4417fn wrap_text(text: &str, width: usize) -> Vec<String> {
4418    if width == 0 {
4419        return text.lines().map(str::to_owned).collect();
4420    }
4421    let mut rows = Vec::new();
4422    // `split` preserves a trailing empty row, so Shift+Enter renders an
4423    // immediate new line even before another character is typed.
4424    for line in text.split('\n') {
4425        rows.extend(wrap_line(line, width));
4426    }
4427    if rows.is_empty() {
4428        rows.push(String::new());
4429    }
4430    rows
4431}
4432
4433fn wrap_line(line: &str, width: usize) -> Vec<String> {
4434    let mut rows = Vec::new();
4435    let mut current = String::new();
4436    let mut current_width = 0usize;
4437    for character in line.chars() {
4438        let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
4439        if current_width + char_width > width && !current.is_empty() {
4440            rows.push(std::mem::take(&mut current));
4441            current_width = 0;
4442        }
4443        current.push(character);
4444        current_width += char_width;
4445    }
4446    rows.push(current);
4447    rows
4448}
4449
4450#[cfg(test)]
4451mod tests {
4452    use super::*;
4453
4454    fn line_text(line: &Line<'_>) -> String {
4455        line.spans
4456            .iter()
4457            .map(|span| span.content.as_ref())
4458            .collect()
4459    }
4460
4461    #[test]
4462    fn turn_notifications_use_osc_777_with_fixed_secret_safe_messages() {
4463        let cases = [
4464            (TurnNotification::Completed, "Turn complete"),
4465            (TurnNotification::Interrupted, "Turn interrupted"),
4466            (TurnNotification::Failed, "Turn failed"),
4467        ];
4468
4469        for (notification, body) in cases {
4470            let mut output = Vec::new();
4471            send_turn_notification(&mut output, notification).expect("notification");
4472            assert_eq!(
4473                output,
4474                format!("\x1b]777;notify;Lucy;{body}\x07").into_bytes()
4475            );
4476        }
4477    }
4478
4479    #[test]
4480    fn turn_notifications_follow_the_terminal_turn_status() {
4481        assert_eq!(
4482            turn_notification_for_status("finalizing"),
4483            TurnNotification::Completed
4484        );
4485        assert_eq!(
4486            turn_notification_for_status("cancelling"),
4487            TurnNotification::Interrupted
4488        );
4489        assert_eq!(
4490            turn_notification_for_status("error"),
4491            TurnNotification::Failed
4492        );
4493    }
4494
4495    struct FailingWriter;
4496
4497    impl Write for FailingWriter {
4498        fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
4499            Err(io::Error::other("notification sink unavailable"))
4500        }
4501
4502        fn flush(&mut self) -> io::Result<()> {
4503            Err(io::Error::other("notification sink unavailable"))
4504        }
4505    }
4506
4507    #[test]
4508    fn notification_write_failure_does_not_keep_the_tui_busy() {
4509        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4510        state.busy = true;
4511        state.active_cancel = Some(CancellationToken::new());
4512        let mut writer = FailingWriter;
4513
4514        release_finished_turn(&mut writer, &mut state);
4515
4516        assert!(!state.busy);
4517        assert!(state.active_cancel.is_none());
4518    }
4519
4520    #[test]
4521    fn an_idle_finish_does_not_emit_a_duplicate_notification() {
4522        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4523        let mut output = Vec::new();
4524
4525        release_finished_turn(&mut output, &mut state);
4526
4527        assert!(output.is_empty());
4528    }
4529
4530    #[test]
4531    fn context_status_shows_used_window_and_percentage_in_uniform_gray() {
4532        let mut state = UiState::from_history(&[], "secret", "model", None, false)
4533            .with_context(Some(100_000), 80_000);
4534
4535        assert_eq!(
4536            context_status_text(&state),
4537            "Context: 80.0K/100.0K (80%) ████████░░"
4538        );
4539        assert_eq!(
4540            context_status_style(&state).fg,
4541            Some(Color::Rgb(144, 144, 148))
4542        );
4543
4544        state.context_tokens = 80_001;
4545        assert_eq!(
4546            context_status_text(&state),
4547            "Context: 80.0K/100.0K (81%) █████████░"
4548        );
4549        assert_eq!(
4550            context_status_style(&state).fg,
4551            Some(Color::Rgb(144, 144, 148)),
4552            "crossing the compaction threshold does not recolor the status line"
4553        );
4554    }
4555
4556    #[test]
4557    fn context_status_keeps_percentage_and_bar_consistent_at_capacity() {
4558        let mut state = UiState::from_history(&[], "secret", "model", None, false)
4559            .with_context(Some(100_000), 99_001);
4560
4561        assert_eq!(
4562            context_status_text(&state),
4563            "Context: 99.0K/100.0K (100%) ██████████"
4564        );
4565
4566        state.context_tokens = 100_000;
4567        assert_eq!(
4568            context_status_text(&state),
4569            "Context: 100.0K/100.0K (100%) ██████████"
4570        );
4571
4572        state.context_tokens = 100_001;
4573        assert_eq!(
4574            context_status_text(&state),
4575            "Context: 100.0K/100.0K (101%) ██████████"
4576        );
4577    }
4578
4579    #[test]
4580    fn context_status_handles_unknown_window_without_highlighting() {
4581        let state = UiState::from_history(&[], "secret", "model", None, false);
4582
4583        assert_eq!(context_status_text(&state), "Context: 1/? (?%) ??????????");
4584        assert_eq!(
4585            context_status_style(&state).fg,
4586            Some(Color::Rgb(144, 144, 148))
4587        );
4588    }
4589
4590    #[test]
4591    fn tui_viewport_reserves_one_column_on_each_side_when_possible() {
4592        assert_eq!(
4593            tui_viewport(Rect::new(0, 0, 80, 10)),
4594            Rect::new(1, 0, 78, 10)
4595        );
4596        assert_eq!(
4597            tui_viewport(Rect::new(0, 0, 2, 10)),
4598            Rect::new(0, 0, 2, 10),
4599            "a two-column terminal cannot reserve two gutters"
4600        );
4601    }
4602
4603    #[test]
4604    fn tui_viewport_caps_at_one_hundred_columns_and_centers_it() {
4605        assert_eq!(
4606            tui_viewport(Rect::new(0, 0, 140, 10)),
4607            Rect::new(20, 0, TUI_MAX_WIDTH, 10)
4608        );
4609        assert_eq!(
4610            tui_viewport(Rect::new(0, 0, 103, 10)),
4611            Rect::new(1, 0, TUI_MAX_WIDTH, 10),
4612            "an odd remaining column stays on the right"
4613        );
4614    }
4615
4616    #[test]
4617    fn bottom_console_has_external_margins_without_losing_internal_padding() {
4618        let state = UiState::from_history(&[], "secret", "model", None, false);
4619        let viewport = tui_viewport(Rect::new(0, 0, 80, 14));
4620        let (chat, _, _, _, console, _) = ui_layout(&state, viewport);
4621        let content = console_content_area(console);
4622
4623        assert_eq!(chat.x, console.x);
4624        assert_eq!(chat.width, console.width);
4625        assert_eq!(console, Rect::new(viewport.x + 2, 8, viewport.width - 4, 5));
4626        assert_eq!(console.y + console.height, viewport.y + viewport.height - 1);
4627        assert_eq!(content.x, console.x + 2);
4628        assert_eq!(content.width, console.width - 4);
4629        assert_eq!(content.y, console.y + 1);
4630        assert_eq!(content.y + content.height, console.y + console.height - 1);
4631
4632        for (width, margin, console_width) in
4633            [(1, 0, 1), (2, 0, 2), (3, 1, 1), (4, 1, 2), (5, 2, 1)]
4634        {
4635            let console = bottom_console_area(Rect::new(0, 0, width, 4), 0, 4);
4636            assert_eq!(console.x, margin, "width {width}");
4637            assert_eq!(console.width, console_width, "width {width}");
4638        }
4639    }
4640
4641    #[test]
4642    fn inset_console_width_drives_prompt_rows_and_vertical_navigation() {
4643        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4644        state.input = "x".repeat(71);
4645        let viewport = tui_viewport(Rect::new(0, 0, 80, 14));
4646        let console = ui_layout(&state, viewport).4;
4647        let prompt = prompt_area(console, &state);
4648
4649        assert_eq!(ui_prompt_content_width(viewport), prompt.width);
4650        assert_eq!(prompt.width, 70);
4651        assert_eq!(input_visible_rows(&state, prompt.width), 2);
4652        assert!(move_input_cursor_vertical(
4653            &mut state,
4654            ui_prompt_content_width(viewport) as usize,
4655            true,
4656        ));
4657    }
4658
4659    #[test]
4660    fn context_immediately_follows_the_left_status_flow_in_uniform_gray() {
4661        let state =
4662            UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
4663        let mut terminal =
4664            Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
4665
4666        terminal
4667            .draw(|frame| draw(frame, &state))
4668            .expect("draw statusline");
4669
4670        let buffer = terminal.backend().buffer();
4671        let status_area = ui_layout(&state, tui_viewport(Rect::new(0, 0, 80, 10))).5;
4672        let expected = "model · default | Context: 81/100 (81%) █████████░";
4673        let rendered = (status_area.x..status_area.x + expected.chars().count() as u16)
4674            .map(|x| buffer[(x, status_area.y)].symbol())
4675            .collect::<String>();
4676        assert_eq!(rendered, expected);
4677        assert_eq!(
4678            buffer[(
4679                status_area.x + expected.chars().count() as u16,
4680                status_area.y
4681            )]
4682                .symbol(),
4683            " ",
4684            "context is not pushed to the right edge"
4685        );
4686        for x in status_area.x..status_area.x + expected.chars().count() as u16 {
4687            assert_eq!(buffer[(x, status_area.y)].fg, CONSOLE_STATUS_COLOR);
4688        }
4689    }
4690
4691    #[test]
4692    fn pulse_spinner_moves_each_bar_one_level_at_a_time() {
4693        let frames = (0..=240)
4694            .map(|tick| spinner_frame_at(PULSE_TICK * tick))
4695            .collect::<Vec<_>>();
4696        assert!(frames.iter().any(|frame| frame != &frames[0]));
4697        assert_eq!(PULSE_TICK, Duration::from_millis(50));
4698
4699        for pair in frames.windows(2) {
4700            let levels = pair
4701                .iter()
4702                .map(|frame| {
4703                    frame
4704                        .chars()
4705                        .map(|bar| {
4706                            PULSE_LEVELS
4707                                .iter()
4708                                .position(|level| *level == bar)
4709                                .expect("known pulse level")
4710                        })
4711                        .collect::<Vec<_>>()
4712                })
4713                .collect::<Vec<_>>();
4714            assert_eq!(levels[0].len(), 5);
4715            assert!(
4716                levels[0]
4717                    .iter()
4718                    .zip(&levels[1])
4719                    .all(|(before, after)| before.abs_diff(*after) <= 1),
4720                "pulse bars must not jump between adjacent ticks: {:?} -> {:?}",
4721                pair[0],
4722                pair[1]
4723            );
4724        }
4725    }
4726
4727    #[test]
4728    fn console_animation_clock_runs_during_entry_and_survives_active_status_changes() {
4729        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4730        state.set_status("working");
4731        let epoch = state.console_animation_epoch;
4732        assert_eq!(
4733            state.console_animation_elapsed_at(epoch + Duration::from_millis(200)),
4734            Duration::from_millis(200),
4735            "the console animation does not freeze during the activity ramp"
4736        );
4737
4738        state.set_status("compacting");
4739        assert_eq!(state.console_animation_epoch, epoch);
4740        state.set_status("working");
4741        assert_eq!(state.console_animation_epoch, epoch);
4742    }
4743
4744    fn color_distance(from: Color, to: Color) -> u16 {
4745        let (from_red, from_green, from_blue) = activity_rgb(from);
4746        let (to_red, to_green, to_blue) = activity_rgb(to);
4747        u16::from(from_red.abs_diff(to_red))
4748            + u16::from(from_green.abs_diff(to_green))
4749            + u16::from(from_blue.abs_diff(to_blue))
4750    }
4751
4752    fn color_saturation(color: Color) -> u8 {
4753        let (red, green, blue) = activity_rgb(color);
4754        red.max(green).max(blue) - red.min(green).min(blue)
4755    }
4756
4757    fn color_luminance(color: Color) -> u32 {
4758        let (red, green, blue) = activity_rgb(color);
4759        299 * u32::from(red) + 587 * u32::from(green) + 114 * u32::from(blue)
4760    }
4761
4762    #[test]
4763    fn glow_coverage_is_a_bottom_anchored_half_ellipse_with_a_twelve_row_cap() {
4764        let canvas = Rect::new(0, 0, 160, 40);
4765        let console = Rect::new(40, 28, 80, 7);
4766        let bottom = canvas.y + canvas.height;
4767        let center_x = console.x + console.width / 2 - 1;
4768        let outer_x = center_x + 35;
4769        let active_rows = (canvas.y..bottom)
4770            .filter(|&row| glow_coverage_at(center_x, row, canvas, console) > 0.0)
4771            .collect::<Vec<_>>();
4772
4773        assert_eq!(GLOW_HEIGHT, 12);
4774        assert_eq!(GLOW_HORIZONTAL_SPREAD, 24);
4775        assert_eq!(
4776            active_rows,
4777            (bottom - GLOW_HEIGHT..bottom).collect::<Vec<_>>()
4778        );
4779        assert_eq!(
4780            glow_coverage_at(center_x, bottom - GLOW_HEIGHT - 1, canvas, console),
4781            0.0,
4782            "the glow never exceeds its twelve-row cap"
4783        );
4784        assert_eq!(
4785            glow_coverage_at(center_x, bottom - 1, canvas, console),
4786            glow_coverage_at(center_x + 1, bottom - 1, canvas, console),
4787            "the bottom edge intersects the ellipse at its horizontal centreline"
4788        );
4789        assert_eq!(
4790            glow_coverage_at(outer_x, bottom - GLOW_HEIGHT, canvas, console),
4791            0.0,
4792            "the top of the half ellipse stays narrow"
4793        );
4794        assert!(
4795            glow_coverage_at(outer_x, bottom - 1, canvas, console) > 0.0,
4796            "the exposed glow grows wider toward the TUI bottom"
4797        );
4798        assert!(
4799            glow_coverage_at(console.x + console.width + 23, bottom - 1, canvas, console) > 0.0,
4800            "the glow extends twenty-four cells beyond each console edge"
4801        );
4802    }
4803
4804    #[test]
4805    fn wider_console_has_a_wider_elliptical_side_falloff() {
4806        let canvas = Rect::new(0, 0, 200, 20);
4807        let wide_console = Rect::new(50, 12, 100, 7);
4808        let narrow_console = Rect::new(50, 12, 4, 7);
4809        let bottom_row = canvas.y + canvas.height - 1;
4810        let offset_from_center = 60;
4811        let wide_sample = wide_console.x + wide_console.width / 2 + offset_from_center;
4812        let narrow_sample = narrow_console.x + narrow_console.width / 2 + offset_from_center;
4813
4814        let wide = glow_coverage_at(wide_sample, bottom_row, canvas, wide_console);
4815        let narrow = glow_coverage_at(narrow_sample, bottom_row, canvas, narrow_console);
4816
4817        assert!(wide > 0.0);
4818        assert_eq!(narrow, 0.0);
4819        assert!(
4820            wide > narrow,
4821            "the horizontal radius follows the console width to form an ellipse rather than fixed endpoint circles"
4822        );
4823    }
4824
4825    #[test]
4826    fn exposed_bloom_is_not_tinted_as_console_glass() {
4827        let canvas = Rect::new(0, 0, 80, 20);
4828        let console = Rect::new(20, 12, 40, 7);
4829        let elapsed = CONSOLE_BOUNDARY_CYCLE / 4;
4830        let source_row = canvas.y + canvas.height - 1;
4831        let exposed = (console.x - 1, source_row);
4832        let inside = (console.x, console.y + console.height - 1);
4833        let exposed_glow = glow_color_at(
4834            elapsed,
4835            exposed.0,
4836            canvas.width,
4837            exposed.1,
4838            canvas,
4839            console,
4840            1.0,
4841        )
4842        .expect("endpoint bloom");
4843        let inside_glow = glow_color_at(
4844            elapsed,
4845            inside.0,
4846            canvas.width,
4847            inside.1,
4848            canvas,
4849            console,
4850            1.0,
4851        )
4852        .expect("source segment glow");
4853        let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
4854            canvas.width,
4855            canvas.height,
4856        ))
4857        .expect("test terminal");
4858
4859        terminal
4860            .draw(|frame| {
4861                apply_tui_glow(frame, canvas, console, elapsed, 1.0);
4862                apply_console_background(frame, console, elapsed, 1.0);
4863                let buffer = frame.buffer_mut();
4864                assert_eq!(buffer[exposed].bg, exposed_glow);
4865                assert_eq!(
4866                    buffer[inside].bg,
4867                    console_glass_color_at(elapsed, inside_glow, 1.0)
4868                );
4869            })
4870            .expect("render glow and glass");
4871    }
4872
4873    #[test]
4874    fn idle_canvas_has_no_glow() {
4875        let canvas = Rect::new(0, 0, 80, 12);
4876        let console = Rect::new(2, 6, 76, 5);
4877        for row in canvas.y..canvas.y + canvas.height {
4878            for column in 0..canvas.width {
4879                assert_eq!(
4880                    glow_color_at(
4881                        CONSOLE_BOUNDARY_CYCLE / 4,
4882                        column,
4883                        canvas.width,
4884                        row,
4885                        canvas,
4886                        console,
4887                        0.0,
4888                    ),
4889                    None,
4890                    "idle glow never paints the canvas"
4891                );
4892            }
4893        }
4894    }
4895
4896    #[test]
4897    fn console_visibility_transition_is_smooth_and_reversible() {
4898        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4899        state.set_busy(true);
4900        let entering = state
4901            .console_visibility_transition
4902            .expect("entry transition");
4903        assert_eq!(state.console_visibility_at(entering.started_at), 0.0);
4904        let entry_middle =
4905            state.console_visibility_at(entering.started_at + CONSOLE_VISIBILITY_TRANSITION / 2);
4906        assert!((entry_middle - 0.5).abs() < 0.000_1);
4907        assert_eq!(
4908            state.console_visibility_at(entering.started_at + CONSOLE_VISIBILITY_TRANSITION),
4909            1.0
4910        );
4911
4912        state.console_visibility_transition = None;
4913        state.set_busy(false);
4914        let exiting = state
4915            .console_visibility_transition
4916            .expect("exit transition");
4917        assert_eq!(state.console_visibility_at(exiting.started_at), 1.0);
4918        let exit_middle =
4919            state.console_visibility_at(exiting.started_at + CONSOLE_VISIBILITY_TRANSITION / 2);
4920        assert!((exit_middle - 0.5).abs() < 0.000_1);
4921        assert_eq!(
4922            state.console_visibility_at(exiting.started_at + CONSOLE_VISIBILITY_TRANSITION),
4923            0.0
4924        );
4925    }
4926
4927    #[test]
4928    fn rapid_console_reentry_preserves_glow_phase() {
4929        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4930        let canvas = Rect::new(0, 0, 80, 12);
4931        let console = Rect::new(2, 6, 76, 5);
4932        let start = Instant::now();
4933        state.set_busy_at(true, start);
4934        let epoch = state.console_animation_epoch;
4935        state.set_busy_at(false, start + CONSOLE_VISIBILITY_TRANSITION);
4936        let reversal_at = start + CONSOLE_VISIBILITY_TRANSITION * 3 / 2;
4937        let visibility_before = state.console_visibility_at(reversal_at);
4938        let elapsed_before = state.console_animation_elapsed_at(reversal_at);
4939        let color_before = glow_color_at(
4940            elapsed_before,
4941            40,
4942            canvas.width,
4943            canvas.y + canvas.height - 1,
4944            canvas,
4945            console,
4946            visibility_before,
4947        );
4948
4949        state.set_busy_at(true, reversal_at);
4950
4951        assert_eq!(state.console_animation_epoch, epoch);
4952        assert!((state.console_visibility_at(reversal_at) - visibility_before).abs() < 0.000_1);
4953        assert_eq!(
4954            glow_color_at(
4955                state.console_animation_elapsed_at(reversal_at),
4956                40,
4957                canvas.width,
4958                canvas.y + canvas.height - 1,
4959                canvas,
4960                console,
4961                state.console_visibility_at(reversal_at),
4962            ),
4963            color_before,
4964            "reversing an exit keeps the current glow phase"
4965        );
4966    }
4967
4968    #[test]
4969    fn glow_reach_still_controls_light_intensity() {
4970        let long = CONSOLE_BOUNDARY_CYCLE / 4;
4971        let short = CONSOLE_BOUNDARY_CYCLE * 3 / 4;
4972        let canvas = Rect::new(0, 0, 80, 12);
4973        let console = Rect::new(2, 6, 76, 5);
4974        let row = canvas.y + canvas.height - 1;
4975        let long_glow =
4976            glow_color_at(long, 40, canvas.width, row, canvas, console, 1.0).expect("visible glow");
4977        let short_glow = glow_color_at(short, 40, canvas.width, row, canvas, console, 1.0)
4978            .expect("visible glow");
4979
4980        assert!((console_reach_at(long) - CONSOLE_REACH_MAX).abs() < 0.000_1);
4981        assert!((console_reach_at(short) - CONSOLE_REACH_MIN).abs() < 0.000_1);
4982        assert!(
4983            color_distance(TUI_GLOW_BACKGROUND, long_glow)
4984                > color_distance(TUI_GLOW_BACKGROUND, short_glow)
4985        );
4986    }
4987
4988    #[test]
4989    fn maximum_glow_is_brighter_and_more_saturated_than_the_previous_tuning() {
4990        let canvas = Rect::new(0, 0, 160, 20);
4991        let console = Rect::new(40, 12, 80, 7);
4992        let column = console.x + console.width / 2;
4993        let row = canvas.y + canvas.height - 1;
4994        let elapsed = Duration::ZERO;
4995        let coverage = glow_coverage_at(column, row, canvas, console);
4996        assert_eq!(GLOW_INTENSITY, 0.70);
4997        let current = glow_color_at(elapsed, column, canvas.width, row, canvas, console, 1.0)
4998            .expect("bottom-centre glow");
4999        let previous = blend_rgb(
5000            TUI_GLOW_BACKGROUND,
5001            glow_accent_with_desaturation_at(elapsed, 0.16),
5002            (console_reach_at(elapsed) / CONSOLE_REACH_MAX) * 0.62 * coverage,
5003        );
5004
5005        assert!(
5006            color_luminance(current) > color_luminance(previous),
5007            "the adjusted maximum glow is brighter than the previous maximum"
5008        );
5009        assert!(
5010            color_saturation(current) > color_saturation(previous),
5011            "the adjusted maximum glow is more saturated than the previous maximum"
5012        );
5013    }
5014
5015    #[test]
5016    fn glow_tuning_is_brighter_and_more_saturated_than_before() {
5017        let canvas = Rect::new(0, 0, 160, 20);
5018        let console = Rect::new(40, 12, 80, 7);
5019        let column = console.x + console.width / 2;
5020        let row = canvas.y + canvas.height - 1;
5021        let coverage = glow_coverage_at(column, row, canvas, console);
5022
5023        assert_eq!(GLOW_INTENSITY, 0.70);
5024        assert_eq!(GLOW_DESATURATION, 0.10);
5025        for (phase, elapsed) in [
5026            Duration::ZERO,
5027            console_accent_cycle() / 4,
5028            console_accent_cycle() / 2,
5029            console_accent_cycle() * 3 / 4,
5030        ]
5031        .into_iter()
5032        .enumerate()
5033        {
5034            let current_accent = glow_accent_at(elapsed);
5035            let previous_accent = glow_accent_with_desaturation_at(elapsed, 0.16);
5036            assert!(
5037                color_saturation(current_accent) > color_saturation(previous_accent),
5038                "phase {phase}: reducing glow desaturation raises saturation"
5039            );
5040            let current = glow_color_at(elapsed, column, canvas.width, row, canvas, console, 1.0)
5041                .expect("bottom-centre glow");
5042            let previous = blend_rgb(
5043                TUI_GLOW_BACKGROUND,
5044                glow_accent_with_desaturation_at(elapsed, 0.16),
5045                (console_reach_at(elapsed) / CONSOLE_REACH_MAX) * 0.62 * coverage,
5046            );
5047
5048            assert!(
5049                color_luminance(current) > color_luminance(previous),
5050                "phase {phase}: the rendered glow is brighter than the prior tuning"
5051            );
5052            assert!(
5053                color_saturation(current) > color_saturation(previous),
5054                "phase {phase}: the rendered glow is more saturated than the prior tuning"
5055            );
5056        }
5057    }
5058
5059    #[test]
5060    fn console_is_dark_lower_saturation_glass_over_the_glow() {
5061        let elapsed = CONSOLE_BOUNDARY_CYCLE / 4;
5062        let canvas = Rect::new(0, 0, 80, 12);
5063        let console = Rect::new(2, 6, 76, 5);
5064        let glow = glow_color_at(
5065            elapsed,
5066            40,
5067            canvas.width,
5068            canvas.y + canvas.height - 1,
5069            canvas,
5070            console,
5071            1.0,
5072        )
5073        .expect("visible glow");
5074        let glass = console_glass_color_at(elapsed, glow, 1.0);
5075        let solid_glass = console_glass_color_at(elapsed, CONSOLE_BACKGROUND, 1.0);
5076
5077        assert_eq!(
5078            console_glass_color_at(elapsed, glow, 0.0),
5079            CONSOLE_BACKGROUND
5080        );
5081        assert_ne!(glass, CONSOLE_BACKGROUND);
5082        let (glass_red, glass_green, glass_blue) = activity_rgb(glass);
5083        let (glow_red, glow_green, glow_blue) = activity_rgb(glow);
5084        assert!(
5085            u16::from(glass_red) + u16::from(glass_green) + u16::from(glass_blue)
5086                < u16::from(glow_red) + u16::from(glow_green) + u16::from(glow_blue),
5087            "console glass is darker than the exposed glow"
5088        );
5089        assert!(
5090            color_distance(CONSOLE_BACKGROUND, glass) < color_distance(TUI_GLOW_BACKGROUND, glow),
5091            "glass tint is subtler than the exposed glow"
5092        );
5093        assert!(
5094            color_distance(CONSOLE_BACKGROUND, glass)
5095                > color_distance(CONSOLE_BACKGROUND, solid_glass),
5096            "the glow visibly carries through the stronger glass tint"
5097        );
5098        assert!(
5099            color_saturation(glass) < color_saturation(glow),
5100            "glass tint is less saturated than the exposed glow"
5101        );
5102    }
5103
5104    #[test]
5105    fn console_top_reflection_is_a_thin_active_line_in_the_external_gap() {
5106        assert_eq!(CONSOLE_REFLECTION_WHITE_TINT, 0.20);
5107        let elapsed = Duration::ZERO;
5108        let canvas = Rect::new(0, 0, 20, 8);
5109        let console = Rect::new(2, 3, 16, 4);
5110        let reflection_y = console.y - 1;
5111        let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
5112            canvas.width,
5113            canvas.height,
5114        ))
5115        .expect("test terminal");
5116        terminal
5117            .draw(|frame| {
5118                apply_tui_glow(frame, canvas, console, elapsed, 1.0);
5119                apply_console_top_reflection(frame, console, elapsed, 1.0);
5120                apply_console_background(frame, console, elapsed, 1.0);
5121            })
5122            .expect("render external console reflection");
5123
5124        let reflection_glow = glow_color_at(
5125            elapsed,
5126            console.x,
5127            canvas.width,
5128            reflection_y,
5129            canvas,
5130            console,
5131            1.0,
5132        )
5133        .expect("reflection row glow");
5134        let accent_only_reflection = blend_rgb(
5135            reflection_glow,
5136            glow_accent_at(elapsed),
5137            CONSOLE_REFLECTION_TINT,
5138        );
5139        let previous_reflection =
5140            blend_rgb(accent_only_reflection, Color::Rgb(255, 255, 255), 0.10);
5141        let reflection = console_top_reflection_color_at(elapsed, reflection_glow, 1.0);
5142        let previous_luminance_lift =
5143            color_luminance(previous_reflection) - color_luminance(accent_only_reflection);
5144        let luminance_lift = color_luminance(reflection) - color_luminance(accent_only_reflection);
5145        assert!(
5146            luminance_lift * 100 >= previous_luminance_lift * 195
5147                && luminance_lift * 100 <= previous_luminance_lift * 210,
5148            "doubling the white tint doubles its reflection luminance contribution"
5149        );
5150        let console_glow = glow_color_at(
5151            elapsed,
5152            console.x,
5153            canvas.width,
5154            console.y,
5155            canvas,
5156            console,
5157            1.0,
5158        )
5159        .expect("console glow");
5160        let buffer = terminal.backend().buffer();
5161        assert_eq!(
5162            buffer[(console.x, reflection_y)].symbol(),
5163            CONSOLE_REFLECTION_GLYPH,
5164            "the reflection is drawn in the row above the console"
5165        );
5166        assert_eq!(
5167            buffer[(console.x, reflection_y)].fg,
5168            console_top_reflection_color_at(elapsed, reflection_glow, 1.0),
5169            "the thin reflection follows the glow accent"
5170        );
5171        assert_eq!(
5172            buffer[(console.x, reflection_y - 1)].symbol(),
5173            " ",
5174            "only the closest external row is changed"
5175        );
5176        assert_eq!(
5177            buffer[(console.x, console.y)].bg,
5178            console_glass_color_at(elapsed, console_glow, 1.0),
5179            "the console top row remains normal glass"
5180        );
5181
5182        let mut idle_terminal = Terminal::new(ratatui::backend::TestBackend::new(
5183            canvas.width,
5184            canvas.height,
5185        ))
5186        .expect("idle test terminal");
5187        idle_terminal
5188            .draw(|frame| {
5189                apply_tui_glow(frame, canvas, console, elapsed, 0.0);
5190                apply_console_top_reflection(frame, console, elapsed, 0.0);
5191                apply_console_background(frame, console, elapsed, 0.0);
5192            })
5193            .expect("render idle console");
5194        assert_eq!(
5195            idle_terminal.backend().buffer()[(console.x, reflection_y)].symbol(),
5196            " ",
5197            "idle consoles have no external reflection"
5198        );
5199
5200        let state = UiState::from_history(&[], "secret", "model", None, false);
5201        let viewport = tui_viewport(Rect::new(0, 0, 80, 10));
5202        let (chat, _, _, _, input_area, _) = ui_layout(&state, viewport);
5203        assert_eq!(
5204            chat.y + chat.height + 1,
5205            input_area.y,
5206            "the reflection uses the existing transcript gap rather than adding a row"
5207        );
5208    }
5209
5210    #[test]
5211    fn console_reflection_skips_transcript_when_no_gap_fits() {
5212        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5213        state.welcome_visible = false;
5214        state
5215            .transcript
5216            .push(TranscriptItem::Info("protected transcript".to_owned()));
5217        state.busy = true;
5218        state.activity_transition = None;
5219        state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
5220        let area = Rect::new(0, 0, 60, 7);
5221        let mut terminal =
5222            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5223                .expect("test terminal");
5224        terminal
5225            .draw(|frame| draw(frame, &state))
5226            .expect("draw constrained active console");
5227
5228        let (chat, _, _, _, console, _) = ui_layout(&state, tui_viewport(area));
5229        assert_eq!(chat.y + chat.height, console.y, "there is no separator row");
5230        assert_eq!(
5231            terminal.backend().buffer()[(chat.x, chat.y)].symbol(),
5232            "p",
5233            "the active reflection does not overwrite the adjacent transcript"
5234        );
5235    }
5236
5237    #[test]
5238    fn console_reflection_stays_behind_an_active_skill_picker() {
5239        let mut state = UiState::from_history(&[], "secret", "model", None, false)
5240            .with_skill_names(vec!["agent-browser".to_owned()]);
5241        state.input = "/".to_owned();
5242        state.input_changed();
5243        state.busy = true;
5244        state.activity_transition = None;
5245        state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
5246        let area = Rect::new(0, 0, 40, 12);
5247        let mut terminal =
5248            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5249                .expect("test terminal");
5250        terminal
5251            .draw(|frame| draw(frame, &state))
5252            .expect("draw active picker");
5253
5254        let (_, picker, _, _, input, _) = ui_layout(&state, tui_viewport(area));
5255        let picker = picker.expect("visible picker");
5256        assert_eq!(picker.y + picker.height, input.y);
5257        let buffer = terminal.backend().buffer();
5258        assert_eq!(
5259            buffer[(picker.x, picker.y + picker.height - 1)].bg,
5260            SKILL_PICKER_BACKGROUND,
5261            "the picker covers the reflection row"
5262        );
5263        assert_ne!(
5264            buffer[(picker.x, picker.y + picker.height - 1)].symbol(),
5265            CONSOLE_REFLECTION_GLYPH,
5266            "the reflection glyph does not show through the picker"
5267        );
5268    }
5269
5270    #[test]
5271    fn console_glass_white_film_brightens_only_visible_glass() {
5272        let elapsed = Duration::ZERO;
5273        let glow = Color::Rgb(80, 82, 86);
5274        let (red, green, blue) = activity_rgb(console_accent_at(elapsed));
5275        let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
5276        let glass_accent = Color::Rgb(
5277            interpolate_color(red, neutral, CONSOLE_GLASS_DESATURATION),
5278            interpolate_color(green, neutral, CONSOLE_GLASS_DESATURATION),
5279            interpolate_color(blue, neutral, CONSOLE_GLASS_DESATURATION),
5280        );
5281        let accent_tinted = blend_rgb(CONSOLE_BACKGROUND, glass_accent, CONSOLE_GLASS_TINT);
5282        let without_white_film = blend_rgb(accent_tinted, glow, CONSOLE_GLASS_GLOW_THROUGH);
5283        let with_white_film = console_glass_color_at(elapsed, glow, 1.0);
5284        let expected = blend_rgb(
5285            blend_rgb(
5286                accent_tinted,
5287                Color::Rgb(255, 255, 255),
5288                CONSOLE_GLASS_WHITE_TINT,
5289            ),
5290            glow,
5291            CONSOLE_GLASS_GLOW_THROUGH,
5292        );
5293
5294        assert_eq!(CONSOLE_GLASS_WHITE_TINT, 0.03);
5295        assert_eq!(
5296            console_glass_color_at(elapsed, glow, 0.0),
5297            CONSOLE_BACKGROUND,
5298            "idle glass uses the configured console background"
5299        );
5300        assert_eq!(
5301            with_white_film, expected,
5302            "the white film is composited before glow-through"
5303        );
5304        let (with_white_red, with_white_green, with_white_blue) = activity_rgb(with_white_film);
5305        let (without_white_red, without_white_green, without_white_blue) =
5306            activity_rgb(without_white_film);
5307        assert!(
5308            u16::from(with_white_red) + u16::from(with_white_green) + u16::from(with_white_blue)
5309                > u16::from(without_white_red)
5310                    + u16::from(without_white_green)
5311                    + u16::from(without_white_blue),
5312            "the active glass has a visible white film"
5313        );
5314    }
5315
5316    #[test]
5317    fn console_accent_uses_a_fifteen_second_lavender_to_teal_round_trip() {
5318        assert_eq!(console_accent_cycle(), Duration::from_secs(15));
5319        assert_eq!(
5320            console_accent_at(Duration::ZERO),
5321            desaturate_console_accent(
5322                CONSOLE_ACCENT_LAVENDER.0,
5323                CONSOLE_ACCENT_LAVENDER.1,
5324                CONSOLE_ACCENT_LAVENDER.2,
5325            )
5326        );
5327        assert_eq!(
5328            console_accent_at(console_accent_cycle() / 2),
5329            desaturate_console_accent(
5330                CONSOLE_ACCENT_TEAL.0,
5331                CONSOLE_ACCENT_TEAL.1,
5332                CONSOLE_ACCENT_TEAL.2,
5333            )
5334        );
5335        assert_eq!(
5336            console_accent_at(console_accent_cycle()),
5337            console_accent_at(Duration::ZERO)
5338        );
5339        let midpoint = console_accent_at(console_accent_cycle() / 4);
5340        assert_ne!(
5341            midpoint,
5342            console_accent_at(Duration::ZERO),
5343            "the glow transitions continuously instead of holding at lavender"
5344        );
5345        assert_ne!(
5346            midpoint,
5347            console_accent_at(console_accent_cycle() / 2),
5348            "the glow transitions continuously instead of holding at teal"
5349        );
5350    }
5351
5352    #[test]
5353    fn tui_glow_background_is_ghostty_base_101216() {
5354        assert_eq!(TUI_GLOW_BACKGROUND_RGB, (16, 18, 22));
5355        assert_eq!(TUI_GLOW_BACKGROUND, Color::Rgb(16, 18, 22));
5356    }
5357
5358    #[test]
5359    fn console_palette_starts_lavender_with_fifteen_percent_desaturation() {
5360        assert_eq!(CONSOLE_BACKGROUND_RGB, (42, 42, 46));
5361        assert_eq!(CONSOLE_BACKGROUND, Color::Rgb(42, 42, 46));
5362        assert_eq!(
5363            console_accent_at(Duration::ZERO),
5364            desaturate_console_accent(
5365                CONSOLE_ACCENT_LAVENDER.0,
5366                CONSOLE_ACCENT_LAVENDER.1,
5367                CONSOLE_ACCENT_LAVENDER.2,
5368            )
5369        );
5370    }
5371
5372    #[test]
5373    fn floating_panel_backgrounds_are_neutral_gray_and_darker_than_the_console() {
5374        assert_eq!(FLOATING_PANEL_BACKGROUND, Color::Rgb(28, 28, 30));
5375        assert_eq!(SKILL_PICKER_BACKGROUND, FLOATING_PANEL_BACKGROUND);
5376        assert_eq!(SUBAGENT_OVERLAY_BACKGROUND, FLOATING_PANEL_BACKGROUND);
5377    }
5378
5379    #[test]
5380    fn console_accent_transition_changes_all_tui_glow_in_sync() {
5381        let elapsed = console_accent_cycle() / 4;
5382        let canvas = Rect::new(0, 0, 80, 20);
5383        let console = Rect::new(20, 12, 40, 7);
5384        let row = canvas.y + canvas.height - 1;
5385        let left = glow_color_at(elapsed, console.x, canvas.width, row, canvas, console, 1.0)
5386            .expect("left-side glow");
5387        let right = glow_color_at(
5388            elapsed,
5389            console.x + console.width - 1,
5390            canvas.width,
5391            row,
5392            canvas,
5393            console,
5394            1.0,
5395        )
5396        .expect("right-side glow");
5397        let intensity = (console_reach_at(elapsed) / CONSOLE_REACH_MAX)
5398            * GLOW_INTENSITY
5399            * glow_coverage_at(console.x, row, canvas, console);
5400
5401        assert_eq!(
5402            blend_rgb(TUI_GLOW_BACKGROUND, glow_accent_at(elapsed), intensity),
5403            left,
5404            "the left edge uses the shared accent phase"
5405        );
5406        assert_eq!(left, right, "the glow has no horizontal color phase");
5407
5408        let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
5409            canvas.width,
5410            canvas.height,
5411        ))
5412        .expect("test terminal");
5413        terminal
5414            .draw(|frame| {
5415                apply_tui_glow(frame, canvas, console, elapsed, 1.0);
5416                apply_console_top_reflection(frame, console, elapsed, 1.0);
5417                apply_console_background(frame, console, elapsed, 1.0);
5418            })
5419            .expect("render synchronized TUI glow");
5420        let buffer = terminal.backend().buffer();
5421        let left_x = console.x;
5422        let right_x = console.x + console.width - 1;
5423        assert_eq!(
5424            buffer[(left_x, console.y)].bg,
5425            buffer[(right_x, console.y)].bg,
5426            "the glass uses the shared accent phase"
5427        );
5428        assert_eq!(
5429            buffer[(left_x, console.y - 1)].fg,
5430            buffer[(right_x, console.y - 1)].fg,
5431            "the reflection uses the shared accent phase"
5432        );
5433    }
5434
5435    #[test]
5436    fn borderless_console_contains_queue_running_subagent_prompt_and_statusline() {
5437        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5438        state.queue_user("first task");
5439        state.queue_user("second task");
5440        state.subagents.push(SubagentTask {
5441            call_id: "call-worker".to_owned(),
5442            task_id: Some("subagent-1".to_owned()),
5443            task: "Inspect the command UI".to_owned(),
5444            model: Some("worker-model".to_owned()),
5445            effort: Some("high".to_owned()),
5446            status: SubagentStatus::Running,
5447            result: None,
5448            creation_completed: true,
5449            stream: Vec::new(),
5450            stream_chars: 0,
5451        });
5452        state.input = "prompt text".to_owned();
5453        state.cursor = state.input.chars().count();
5454        let area = Rect::new(0, 0, 80, 14);
5455        let mut terminal =
5456            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5457                .expect("test terminal");
5458
5459        terminal
5460            .draw(|frame| draw(frame, &state))
5461            .expect("draw bottom console");
5462        let (_, _, _, queue_area, input_area, status_area) = ui_layout(&state, tui_viewport(area));
5463        let queue_area = queue_area.expect("message queue area");
5464        let list_area = subagent_list_area(&state, input_area).expect("subagent list area");
5465        let prompt_area = prompt_area(input_area, &state);
5466        assert_eq!(
5467            queue_area.height, 3,
5468            "the queue header precedes each message"
5469        );
5470        assert_eq!(
5471            list_area.height, 2,
5472            "the subagent header precedes each worker"
5473        );
5474        assert_eq!(queue_area.y, input_area.y + 1);
5475        let (queue_spacer, list_spacer, status_spacer) = console_spacer_rows(&state, input_area);
5476        let queue_spacer = queue_spacer.expect("queue/prompt spacer");
5477        let list_spacer = list_spacer.expect("prompt/list spacer");
5478        let status_spacer = status_spacer.expect("list/status spacer");
5479        assert_eq!(queue_area.y + queue_area.height, queue_spacer);
5480        assert_eq!(prompt_area.y, queue_spacer + 1);
5481        assert_eq!(prompt_area.y + prompt_area.height, list_spacer);
5482        assert_eq!(list_area.y, list_spacer + 1);
5483        assert_eq!(list_area.y + list_area.height, status_spacer);
5484        assert_eq!(status_area.y, status_spacer + 1);
5485        assert_eq!(status_area.y + 1, input_area.y + input_area.height - 1);
5486        for area in [queue_area, list_area, status_area] {
5487            assert_eq!(area.x, input_area.x + 2);
5488            assert_eq!(area.width, input_area.width.saturating_sub(4));
5489        }
5490        assert_eq!(prompt_area.x, input_area.x + 2);
5491        assert_eq!(prompt_area.width, input_area.width.saturating_sub(4));
5492
5493        let buffer = terminal.backend().buffer();
5494        let border_glyphs = ["┌", "┐", "└", "┘", "├", "┤", "─"];
5495        for y in input_area.y..input_area.y + input_area.height {
5496            for x in input_area.x..input_area.x + input_area.width {
5497                assert!(
5498                    !border_glyphs.contains(&buffer[(x, y)].symbol()),
5499                    "console contains a border glyph at ({x}, {y})"
5500                );
5501                assert_eq!(buffer[(x, y)].bg, CONSOLE_BACKGROUND);
5502            }
5503        }
5504
5505        for y in [
5506            input_area.y,
5507            queue_spacer,
5508            list_spacer,
5509            status_spacer,
5510            input_area.y + input_area.height - 1,
5511        ] {
5512            for x in input_area.x..input_area.x + input_area.width {
5513                assert_eq!(buffer[(x, y)].symbol(), " ");
5514            }
5515        }
5516        for (x, y) in [
5517            (input_area.x, input_area.y),
5518            (input_area.x + input_area.width - 1, input_area.y),
5519            (input_area.x, input_area.y + input_area.height - 1),
5520            (
5521                input_area.x + input_area.width - 1,
5522                input_area.y + input_area.height - 1,
5523            ),
5524        ] {
5525            assert_eq!(buffer[(x, y)].symbol(), " ");
5526            assert_eq!(buffer[(x, y)].bg, CONSOLE_BACKGROUND);
5527        }
5528
5529        let queued_rows = (queue_area.y..queue_area.y + queue_area.height)
5530            .map(|y| {
5531                (queue_area.x..queue_area.x + queue_area.width)
5532                    .map(|x| buffer[(x, y)].symbol())
5533                    .collect::<String>()
5534            })
5535            .collect::<Vec<_>>();
5536        assert!(queued_rows[0].starts_with("Queued"));
5537        assert!(queued_rows[1].contains("│ 1) first task"));
5538        assert!(queued_rows[2].contains("│ 2) second task"));
5539        assert!(!queued_rows[1].contains("Queued 1"));
5540        assert!(!queued_rows[2].contains("Queued 2"));
5541        assert_eq!(
5542            buffer[(queue_area.x, queue_area.y)].fg,
5543            SECTION_CHROME_COLOR
5544        );
5545        assert_eq!(
5546            buffer[(queue_area.x, queue_area.y + 1)].fg,
5547            SECTION_CHROME_COLOR
5548        );
5549        assert_eq!(
5550            buffer[(queue_area.x + 2, queue_area.y + 1)].fg,
5551            QUEUED_MESSAGE_COLOR
5552        );
5553        assert_eq!(buffer[(list_area.x, list_area.y)].fg, SUBAGENT_TITLE_COLOR);
5554        assert_eq!(
5555            buffer[(list_area.x, list_area.y + 1)].fg,
5556            SUBAGENT_TITLE_COLOR
5557        );
5558        assert_eq!(
5559            buffer[(list_area.x + 2, list_area.y + 1)].fg,
5560            subagent_id_color("subagent-1")
5561        );
5562        assert_eq!(
5563            buffer[(status_area.x, status_area.y)].fg,
5564            CONSOLE_STATUS_COLOR
5565        );
5566        let status_row = (status_area.x..status_area.x + status_area.width)
5567            .map(|x| buffer[(x, status_area.y)].symbol())
5568            .collect::<String>();
5569        assert!(status_row.starts_with("model · default | Context: "));
5570        terminal.backend_mut().assert_cursor_position((
5571            prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
5572            prompt_area.y,
5573        ));
5574    }
5575
5576    #[test]
5577    fn prompt_uses_two_cells_of_horizontal_console_padding() {
5578        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5579        state.input = "1234567890123456".to_owned();
5580        let area = Rect::new(0, 0, 20, 6);
5581        let mut terminal =
5582            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5583                .expect("test terminal");
5584
5585        terminal
5586            .draw(|frame| draw(frame, &state))
5587            .expect("draw padded prompt");
5588
5589        let input_area = ui_layout(&state, tui_viewport(area)).4;
5590        let prompt = prompt_area(input_area, &state);
5591        assert_eq!(prompt.x, input_area.x + 2);
5592        assert_eq!(prompt.width, input_area.width.saturating_sub(4));
5593        assert_eq!(
5594            terminal.backend().buffer()[(input_area.x + 1, prompt.y)].symbol(),
5595            " ",
5596            "the two left padding cells remain blank"
5597        );
5598        assert_eq!(
5599            terminal.backend().buffer()[(input_area.x + input_area.width - 2, prompt.y)].symbol(),
5600            " ",
5601            "the two right padding cells remain blank"
5602        );
5603        terminal
5604            .backend_mut()
5605            .assert_cursor_position((input_area.x + 2, prompt.y));
5606    }
5607
5608    #[test]
5609    fn prompt_width_reduction_wraps_and_saturates_at_narrow_widths() {
5610        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5611        state.input = "12345".to_owned();
5612        state.cursor = state.input.chars().count();
5613        let input_area = Rect::new(3, 2, 6, 6);
5614        let prompt = prompt_area(input_area, &state);
5615
5616        assert_eq!(prompt.width, 2);
5617        assert_eq!(input_visible_rows(&state, prompt.width), 3);
5618        assert_eq!(bottom_content_heights(&state, input_area).prompt, 3);
5619        assert_eq!(
5620            cursor_row(&state.input, state.cursor, prompt.width as usize),
5621            2
5622        );
5623        state.cursor = 1;
5624        assert!(move_input_cursor_vertical(
5625            &mut state,
5626            prompt_content_width(input_area.width) as usize,
5627            true,
5628        ));
5629        assert_eq!(state.cursor, 3);
5630        assert_eq!(prompt_content_width(0), 0);
5631        assert_eq!(prompt_content_width(1), 0);
5632        assert_eq!(prompt_content_width(2), 0);
5633        assert_eq!(prompt_content_width(3), 0);
5634        assert_eq!(prompt_content_width(4), 0);
5635        assert_eq!(prompt_content_width(5), 1);
5636    }
5637
5638    #[test]
5639    fn constrained_console_keeps_internal_rects_inside_without_borders() {
5640        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5641        state.queue_user("first task");
5642        state.queue_user("second task");
5643        state.subagents.push(SubagentTask {
5644            call_id: "call-worker".to_owned(),
5645            task_id: Some("subagent-1".to_owned()),
5646            task: "Inspect".to_owned(),
5647            model: None,
5648            effort: None,
5649            status: SubagentStatus::Running,
5650            result: None,
5651            creation_completed: true,
5652            stream: Vec::new(),
5653            stream_chars: 0,
5654        });
5655
5656        for height in 3..=9 {
5657            let area = Rect::new(0, 0, 60, height);
5658            let mut terminal =
5659                Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5660                    .expect("test terminal");
5661            terminal
5662                .draw(|frame| draw(frame, &state))
5663                .expect("draw constrained console");
5664
5665            let (_, _, _, queue, console, status) = ui_layout(&state, tui_viewport(area));
5666            let content = console_content_area(console);
5667            let prompt = prompt_area(console, &state);
5668            let list = subagent_list_area(&state, console);
5669            for child in queue.into_iter().chain(list).chain([prompt, status]) {
5670                assert!(
5671                    child.x >= content.x,
5672                    "height {height}: {child:?} starts left of {content:?}"
5673                );
5674                assert!(
5675                    child.y >= content.y,
5676                    "height {height}: {child:?} starts above {content:?}"
5677                );
5678                assert!(
5679                    child.x + child.width <= content.x + content.width,
5680                    "height {height}: {child:?} ends right of {content:?}"
5681                );
5682                assert!(
5683                    child.y + child.height <= content.y + content.height,
5684                    "height {height}: {child:?} ends below {content:?}"
5685                );
5686            }
5687
5688            let buffer = terminal.backend().buffer();
5689            let border_glyphs = ["┌", "┐", "└", "┘", "├", "┤", "─", "│"];
5690            for y in console.y..console.y + console.height {
5691                assert!(!border_glyphs.contains(&buffer[(console.x, y)].symbol()));
5692                assert!(
5693                    !border_glyphs.contains(&buffer[(console.x + console.width - 1, y)].symbol())
5694                );
5695                let expected_background = CONSOLE_BACKGROUND;
5696                assert_eq!(buffer[(console.x, y)].bg, expected_background);
5697                assert_eq!(
5698                    buffer[(console.x + console.width - 1, y)].bg,
5699                    expected_background
5700                );
5701            }
5702            assert_eq!(
5703                status.y + status.height,
5704                console.y + console.height.saturating_sub(1)
5705            );
5706            let spacers = console_spacer_rows(&state, console);
5707            assert_eq!(spacers.0.is_some(), queue.is_some());
5708            assert_eq!(spacers.1.is_some(), list.is_some());
5709            for y in spacers.0.into_iter().chain(spacers.1).chain(spacers.2) {
5710                for x in console.x..console.x + console.width {
5711                    assert_eq!(buffer[(x, y)].symbol(), " ");
5712                }
5713            }
5714        }
5715    }
5716
5717    #[test]
5718    fn constrained_console_hides_sections_that_cannot_fit_a_header_and_entry() {
5719        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5720        state.queue_user("queued");
5721        state.subagents.push(SubagentTask {
5722            call_id: "call-worker".to_owned(),
5723            task_id: Some("subagent-1".to_owned()),
5724            task: "Inspect".to_owned(),
5725            model: None,
5726            effort: None,
5727            status: SubagentStatus::Running,
5728            result: None,
5729            creation_completed: true,
5730            stream: Vec::new(),
5731            stream_chars: 0,
5732        });
5733
5734        let cramped = bottom_content_heights(&state, Rect::new(0, 0, 80, 7));
5735        assert_eq!(cramped.queue, 0);
5736        assert_eq!(cramped.queue_separator, 0);
5737        assert_eq!(cramped.list, 0);
5738        assert_eq!(cramped.list_separator, 0);
5739
5740        let queue_only = bottom_content_heights(&state, Rect::new(0, 0, 80, 8));
5741        assert_eq!(queue_only.queue, 2);
5742        assert_eq!(queue_only.queue_separator, 1);
5743        assert_eq!(queue_only.list, 0);
5744    }
5745
5746    #[test]
5747    fn constrained_multiline_prompt_scrolls_to_its_last_row_and_keeps_cursor_inside() {
5748        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5749        state.queue_user("first task");
5750        state.queue_user("second task");
5751        state.subagents.push(SubagentTask {
5752            call_id: "call-worker".to_owned(),
5753            task_id: Some("subagent-1".to_owned()),
5754            task: "Inspect".to_owned(),
5755            model: None,
5756            effort: None,
5757            status: SubagentStatus::Running,
5758            result: None,
5759            creation_completed: true,
5760            stream: Vec::new(),
5761            stream_chars: 0,
5762        });
5763        state.input = "first\nsecond\nthird".to_owned();
5764        state.cursor = state.input.chars().count();
5765        let area = Rect::new(0, 0, 40, 6);
5766        let mut terminal =
5767            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5768                .expect("test terminal");
5769        terminal
5770            .draw(|frame| draw(frame, &state))
5771            .expect("draw constrained multiline prompt");
5772
5773        let outer = ui_layout(&state, tui_viewport(area)).4;
5774        let prompt = prompt_area(outer, &state);
5775        assert_eq!(prompt.height, 2);
5776        let rendered = (prompt.y..prompt.y + prompt.height)
5777            .map(|y| {
5778                (prompt.x..prompt.x + prompt.width)
5779                    .map(|x| terminal.backend().buffer()[(x, y)].symbol())
5780                    .collect::<String>()
5781            })
5782            .collect::<Vec<_>>();
5783        assert!(rendered[0].starts_with("second"));
5784        assert!(rendered[1].starts_with("third"));
5785        terminal.backend_mut().assert_cursor_position((
5786            prompt.x + UnicodeWidthStr::width("third") as u16,
5787            prompt.y + prompt.height - 1,
5788        ));
5789    }
5790
5791    #[test]
5792    fn constrained_picker_and_worker_overlay_remain_above_console() {
5793        let mut picker_state = UiState::from_history(&[], "secret", "model", None, false)
5794            .with_skill_names(vec!["release-notes".to_owned()]);
5795        picker_state.input = "/".to_owned();
5796        picker_state.input_changed();
5797        for height in [3, 5] {
5798            let area = Rect::new(0, 0, 60, height);
5799            let (_, picker, _, _, outer, _) = ui_layout(&picker_state, tui_viewport(area));
5800            if let Some(picker) = picker {
5801                assert!(picker.y >= area.y);
5802                assert!(picker.y + picker.height <= outer.y);
5803            }
5804        }
5805
5806        let mut worker_state = UiState::from_history(&[], "secret", "model", None, false);
5807        worker_state.subagents.push(SubagentTask {
5808            call_id: "call-worker".to_owned(),
5809            task_id: Some("subagent-1".to_owned()),
5810            task: "Inspect".to_owned(),
5811            model: None,
5812            effort: None,
5813            status: SubagentStatus::Running,
5814            result: None,
5815            creation_completed: true,
5816            stream: vec![SubagentStreamItem::Assistant("worker output".to_owned())],
5817            stream_chars: 13,
5818        });
5819        assert!(worker_state.focus_subagent_list_from_input());
5820        for height in [3, 5] {
5821            let area = Rect::new(0, 0, 60, height);
5822            let (_, _, overlay, _, outer, _) = ui_layout(&worker_state, tui_viewport(area));
5823            if let Some(overlay) = overlay {
5824                assert!(overlay.y >= area.y);
5825                assert!(overlay.y + overlay.height <= outer.y);
5826            }
5827
5828            let mut terminal =
5829                Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5830                    .expect("test terminal");
5831            terminal
5832                .draw(|frame| draw(frame, &worker_state))
5833                .expect("draw constrained worker overlay");
5834            assert_eq!(
5835                terminal.backend().buffer()[(outer.x, outer.y)].symbol(),
5836                " ",
5837                "height {height}"
5838            );
5839            assert_eq!(
5840                terminal.backend().buffer()[(outer.x, outer.y)].bg,
5841                CONSOLE_BACKGROUND,
5842                "height {height}"
5843            );
5844        }
5845    }
5846
5847    #[test]
5848    fn ready_submission_bypasses_queue_and_is_not_added_twice_when_started() {
5849        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5850
5851        state.submit_user("send now");
5852
5853        assert!(state.queued_messages.is_empty());
5854        assert_eq!(state.transcript.len(), 1);
5855        assert!(matches!(
5856            &state.transcript[0],
5857            TranscriptItem::User { text, .. } if text == "send now"
5858        ));
5859
5860        // The worker's Started notification still arrives asynchronously, but
5861        // must not promote an already visible direct submission a second time.
5862        state.start_queued_user("send now");
5863        assert_eq!(state.transcript.len(), 1);
5864    }
5865
5866    #[test]
5867    fn busy_submission_remains_queued_until_its_turn_starts() {
5868        let mut state = UiState::from_history(&[], "secret", "model", None, false);
5869        state.busy = true;
5870
5871        state.submit_user("send later");
5872
5873        assert_eq!(state.queued_messages, ["send later"]);
5874        assert!(state.transcript.is_empty());
5875
5876        state.start_queued_user("send later");
5877        assert!(state.queued_messages.is_empty());
5878        assert!(matches!(
5879            &state.transcript[..],
5880            [TranscriptItem::User { text, .. }] if text == "send later"
5881        ));
5882    }
5883
5884    #[test]
5885    fn skill_picker_stays_above_a_visible_message_queue() {
5886        let mut state = UiState::from_history(&[], "secret", "model", None, false)
5887            .with_skill_names(vec!["release-notes".to_owned()]);
5888        state.queue_user("next task");
5889        state.input = "/".to_owned();
5890        state.input_changed();
5891
5892        let area = Rect::new(0, 0, 80, 12);
5893        let (_, picker_area, _, queue_area, input_area, _) = ui_layout(&state, tui_viewport(area));
5894        let picker_area = picker_area.expect("skill picker area");
5895        let queue_area = queue_area.expect("message queue area");
5896        assert_eq!(picker_area.y + picker_area.height, input_area.y);
5897        assert_eq!(queue_area.y, input_area.y + 1);
5898        assert_eq!(queue_area.x, input_area.x + 2);
5899    }
5900
5901    #[test]
5902    fn fresh_sessions_show_the_versioned_gradient_welcome_message() {
5903        let state = UiState::from_history(&[], "secret", "model", None, false);
5904        assert!(state.welcome_visible);
5905
5906        let line = welcome_line();
5907        assert_eq!(line.to_string(), WELCOME_MESSAGE);
5908        assert_eq!(WELCOME_VERSION, concat!("v", env!("CARGO_PKG_VERSION")));
5909        assert_eq!(
5910            line.spans.first().and_then(|span| span.style.fg),
5911            Some(Color::Rgb(
5912                WELCOME_START_COLOR.0,
5913                WELCOME_START_COLOR.1,
5914                WELCOME_START_COLOR.2,
5915            ))
5916        );
5917        assert_eq!(
5918            line.spans.last().and_then(|span| span.style.fg),
5919            Some(Color::Rgb(
5920                WELCOME_END_COLOR.0,
5921                WELCOME_END_COLOR.1,
5922                WELCOME_END_COLOR.2,
5923            ))
5924        );
5925    }
5926
5927    #[test]
5928    fn welcome_image_brightness_is_reduced_without_changing_alpha() {
5929        let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
5930            1,
5931            1,
5932            image::Rgba([200, 100, 0, 37]),
5933        ));
5934        let dimmed = dim_welcome_image(image).to_rgba8();
5935        assert_eq!(dimmed.get_pixel(0, 0).0, [170, 85, 0, 37]);
5936    }
5937
5938    #[test]
5939    fn spacious_welcome_uses_the_embedded_png() {
5940        let image = welcome_image(GREETING_IMAGE_SIZE);
5941        assert_eq!(image.size(), GREETING_IMAGE_SIZE);
5942        let layout = welcome_image_layout(Rect::new(0, 0, 100, 40), 6).expect("image fits");
5943        assert_eq!(layout.image_size, GREETING_IMAGE_SIZE);
5944        assert_eq!(layout.image_area, Rect::new(10, 6, 80, 20));
5945        assert_eq!(layout.intro_area.y, layout.image_area.y + 21);
5946    }
5947
5948    #[test]
5949    fn cramped_welcome_falls_back_to_the_text_greeting() {
5950        assert_eq!(welcome_image_layout(Rect::new(0, 0, 80, 16), 6), None);
5951        assert_eq!(welcome_image_layout(Rect::new(0, 0, 39, 40), 6), None);
5952        let scaled = welcome_image_layout(Rect::new(0, 0, 60, 25), 6).expect("scaled image fits");
5953        assert_eq!(scaled.image_size, Size::new(60, 15));
5954
5955        let state = UiState::from_history(&[], "secret", "model", None, false);
5956        let area = Rect::new(0, 0, 80, 12);
5957        let mut terminal =
5958            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5959                .expect("test terminal");
5960        terminal
5961            .draw(|frame| draw(frame, &state))
5962            .expect("draw text fallback");
5963        let chat_area = ui_layout(&state, tui_viewport(area)).0;
5964        let rows = (chat_area.y..chat_area.y + chat_area.height)
5965            .map(|y| {
5966                (chat_area.x..chat_area.x + chat_area.width)
5967                    .map(|x| terminal.backend().buffer()[(x, y)].symbol())
5968                    .collect::<String>()
5969            })
5970            .collect::<Vec<_>>();
5971        assert!(rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
5972        assert!(!rows
5973            .iter()
5974            .any(|row| row.contains('▀') || row.contains('▄')));
5975    }
5976
5977    #[test]
5978    fn logo_text_renders_by_default_and_greeting_image_replaces_it_when_enabled() {
5979        let logo = logo_lines();
5980        let logo_row_count = LOGO_TEXT.lines().count();
5981        assert_eq!(logo.len(), logo_row_count);
5982        // Every non-space character should carry a gradient color.
5983        assert!(logo.iter().flat_map(|line| &line.spans).any(|span| {
5984            span.content.chars().any(|ch| ch != ' ')
5985                && matches!(span.style.fg, Some(Color::Rgb(..)))
5986        }));
5987
5988        let state = UiState::from_history(&[], "secret", "model", None, false);
5989        let area = Rect::new(0, 0, 100, 50);
5990        let mut terminal =
5991            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5992                .expect("test terminal");
5993        let chat_area = ui_layout(&state, tui_viewport(area)).0;
5994        let intro_lines = welcome_lines(&state.attached_agents);
5995        let greeting_layout =
5996            welcome_image_layout(chat_area, intro_lines.len() as u16).expect("greeting fits");
5997
5998        // Without the flag the logo text renders (no halfblock image cells).
5999        std::env::remove_var("LUCY_GREETING_IMAGE");
6000        terminal
6001            .draw(|frame| draw(frame, &state))
6002            .expect("draw logo text");
6003        let buffer = terminal.backend().buffer();
6004        let rows = (chat_area.y..chat_area.y + chat_area.height)
6005            .map(|y| {
6006                (chat_area.x..chat_area.x + chat_area.width)
6007                    .map(|x| buffer[(x, y)].symbol())
6008                    .collect::<String>()
6009            })
6010            .collect::<Vec<_>>();
6011        assert!(rows
6012            .iter()
6013            .any(|row| row.contains(':') || row.contains('-') || row.contains('=')));
6014        assert!(!rows
6015            .iter()
6016            .any(|row| row.contains('▀') || row.contains('▄')));
6017        assert!(rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
6018
6019        // With the flag set the greeting image renders instead of the logo.
6020        std::env::set_var("LUCY_GREETING_IMAGE", "true");
6021        terminal
6022            .draw(|frame| draw(frame, &state))
6023            .expect("draw greeting");
6024        let buffer = terminal.backend().buffer();
6025        assert_eq!(greeting_layout.image_size, GREETING_IMAGE_SIZE);
6026        assert!(matches!(
6027            buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].symbol(),
6028            "▀" | "▄"
6029        ));
6030        assert!(matches!(
6031            buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].fg,
6032            Color::Rgb(..)
6033        ));
6034        assert!(matches!(
6035            buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].bg,
6036            Color::Rgb(..)
6037        ));
6038        let intro_rows = (greeting_layout.intro_area.y
6039            ..greeting_layout.intro_area.y + greeting_layout.intro_area.height)
6040            .map(|y| {
6041                (greeting_layout.intro_area.x
6042                    ..greeting_layout.intro_area.x + greeting_layout.intro_area.width)
6043                    .map(|x| buffer[(x, y)].symbol())
6044                    .collect::<String>()
6045            })
6046            .collect::<Vec<_>>();
6047        assert!(intro_rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
6048
6049        std::env::remove_var("LUCY_GREETING_IMAGE");
6050    }
6051
6052    #[test]
6053    fn welcome_renders_version_below_title_with_a_blank_line_before_tagline() {
6054        let state = UiState::from_history(&[], "secret", "model", None, false);
6055        let area = Rect::new(0, 0, 80, 12);
6056        let mut terminal =
6057            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
6058                .expect("test terminal");
6059        terminal
6060            .draw(|frame| draw(frame, &state))
6061            .expect("draw welcome screen");
6062
6063        let chat_area = ui_layout(&state, tui_viewport(area)).0;
6064        let buffer = terminal.backend().buffer();
6065        let rows = (chat_area.y..chat_area.y + chat_area.height)
6066            .map(|y| {
6067                (chat_area.x..chat_area.x + chat_area.width)
6068                    .map(|x| buffer[(x, y)].symbol())
6069                    .collect::<String>()
6070            })
6071            .collect::<Vec<_>>();
6072        let title_row = rows
6073            .iter()
6074            .position(|row| row.contains(WELCOME_MESSAGE))
6075            .expect("rendered welcome title");
6076        let version_rows = rows
6077            .iter()
6078            .enumerate()
6079            .filter_map(|(row, rendered)| rendered.contains(WELCOME_VERSION).then_some(row))
6080            .collect::<Vec<_>>();
6081
6082        assert_eq!(version_rows, vec![title_row + 1]);
6083        assert!(rows[title_row + 2].trim().is_empty());
6084        assert!(rows[title_row + 3].contains(WELCOME_TAGLINE));
6085
6086        let version_width = WELCOME_VERSION.chars().count() as u16;
6087        let version_x = chat_area.x + (chat_area.width - version_width) / 2;
6088        let version_y = chat_area.y + title_row as u16 + 1;
6089        assert!((version_x..version_x + version_width)
6090            .all(|x| buffer[(x, version_y)].fg == Color::DarkGray));
6091    }
6092
6093    #[test]
6094    fn welcome_shows_the_tagline_and_attached_agents_paths() {
6095        let state = UiState::from_history(&[], "secret", "model", None, false)
6096            .with_attached_agents(vec![
6097                "/workspace/AGENTS.md".to_owned(),
6098                "/workspace/app/AGENTS.md".to_owned(),
6099            ]);
6100        let lines = welcome_lines(&state.attached_agents);
6101
6102        assert_eq!(lines[1].to_string(), WELCOME_VERSION);
6103        assert_eq!(lines[1].style.fg, Some(Color::DarkGray));
6104        assert!(lines[2].to_string().is_empty());
6105        assert_eq!(lines[3].to_string(), WELCOME_TAGLINE);
6106        assert_eq!(lines[3].style.fg, Some(Color::DarkGray));
6107        assert!(lines[4].to_string().is_empty());
6108        assert_eq!(lines[5].to_string(), "Attached AGENTS.md:");
6109        assert_eq!(lines[6].to_string(), "• /workspace/AGENTS.md");
6110        assert_eq!(lines[7].to_string(), "• /workspace/app/AGENTS.md");
6111        assert!(lines[5..]
6112            .iter()
6113            .all(|line| line.style.fg == Some(Color::DarkGray)));
6114    }
6115
6116    #[test]
6117    fn welcome_reports_when_no_agents_file_is_attached() {
6118        let lines = welcome_lines(&[]);
6119        assert_eq!(
6120            lines.last().expect("empty context line").to_string(),
6121            "Attached AGENTS.md: none"
6122        );
6123    }
6124
6125    #[test]
6126    fn resumed_sessions_do_not_show_the_welcome_message() {
6127        let state = UiState::from_history(&[], "secret", "model", None, true);
6128        assert!(!state.welcome_visible);
6129    }
6130
6131    #[test]
6132    fn history_replay_keeps_interruption_after_messages() {
6133        let history = vec![
6134            SessionHistoryRecord::Message {
6135                timestamp: 1,
6136                message: ChatMessage::user("hello".to_owned()),
6137            },
6138            SessionHistoryRecord::Interruption {
6139                timestamp: 2,
6140                reason: "user_cancelled".to_owned(),
6141                phase: "provider_stream".to_owned(),
6142                assistant_text: "partial".to_owned(),
6143                tool_calls: Vec::new(),
6144                tool_results: Vec::new(),
6145            },
6146        ];
6147        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
6148        assert!(matches!(state.transcript[0], TranscriptItem::User { .. }));
6149        assert!(matches!(state.transcript[1], TranscriptItem::Assistant(_)));
6150        assert!(matches!(state.transcript[2], TranscriptItem::Info(_)));
6151        let text = transcript_lines(&state, 80)
6152            .iter()
6153            .map(ToString::to_string)
6154            .collect::<Vec<_>>()
6155            .join("\n");
6156        assert!(!text.contains("choices"));
6157    }
6158
6159    #[test]
6160    fn history_replay_does_not_render_assistant_reasoning_details() {
6161        let mut message = ChatMessage::assistant("visible answer".to_owned(), Vec::new());
6162        message.reasoning_details = Some(vec![serde_json::json!({
6163            "type": "reasoning.text",
6164            "text": "private reasoning"
6165        })]);
6166        let history = [SessionHistoryRecord::Message {
6167            timestamp: 1,
6168            message,
6169        }];
6170        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
6171        let text = transcript_lines(&state, 80)
6172            .iter()
6173            .map(ToString::to_string)
6174            .collect::<Vec<_>>()
6175            .join("\n");
6176        assert!(text.contains("visible answer"));
6177        assert!(!text.contains("private reasoning"));
6178        assert!(!text.contains("reasoning_details"));
6179    }
6180
6181    #[test]
6182    fn history_replay_preserves_repeated_records() {
6183        let history = vec![
6184            SessionHistoryRecord::Message {
6185                timestamp: 1,
6186                message: ChatMessage::assistant("same".to_owned(), Vec::new()),
6187            },
6188            SessionHistoryRecord::Interruption {
6189                timestamp: 2,
6190                reason: "user_cancelled".to_owned(),
6191                phase: "provider_stream".to_owned(),
6192                assistant_text: "same".to_owned(),
6193                tool_calls: Vec::new(),
6194                tool_results: Vec::new(),
6195            },
6196        ];
6197        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
6198        assert_eq!(
6199            state
6200                .transcript
6201                .iter()
6202                .filter(|item| matches!(item, TranscriptItem::Assistant(text) if text == "same"))
6203                .count(),
6204            2
6205        );
6206    }
6207
6208    #[test]
6209    fn user_messages_have_a_single_block_rule_with_inner_and_vertical_padding() {
6210        let history = [SessionHistoryRecord::Message {
6211            timestamp: 1,
6212            message: ChatMessage::user("hello\nworld".to_owned()),
6213        }];
6214        let state = UiState::from_history(&history, "provider-secret", "model", None, false);
6215        let lines = transcript_lines(&state, 12);
6216
6217        assert_eq!(UnicodeWidthStr::width(USER_BORDER_GLYPH), 1);
6218        assert_eq!(lines.len(), 4);
6219        assert_eq!(lines[0].to_string(), "▌");
6220        assert_eq!(lines[1].to_string(), "▌ hello");
6221        assert_eq!(lines[2].to_string(), "▌ world");
6222        assert_eq!(lines[3].to_string(), "▌");
6223        for line in &lines {
6224            assert_eq!(line.spans[0].content, USER_BORDER_GLYPH);
6225            assert_eq!(line.spans[0].style.fg, Some(USER_BORDER_COLOR));
6226            assert!(!line.to_string().contains(['┌', '┐', '└', '┘', '│']));
6227        }
6228        for line in &lines[1..3] {
6229            assert_eq!(line.spans[1].content, " ");
6230            assert_eq!(line.spans[1].style.fg, Some(Color::White));
6231            assert_eq!(line.spans[2].style.fg, Some(Color::White));
6232        }
6233    }
6234
6235    #[test]
6236    fn attached_skill_highlights_its_trigger_in_the_user_message_without_a_notice_line() {
6237        let mut state = UiState::from_history(&[], "secret", "model", None, false)
6238            .with_skill_names(vec!["release-notes".to_owned()]);
6239        state.add_user("/release-notes v1.2.0", "secret");
6240        state.mark_latest_user_skill_attached();
6241
6242        let lines = transcript_lines(&state, 40);
6243        assert_eq!(lines.len(), 3);
6244        assert_eq!(lines[1].spans[1].content, " ");
6245        let cyan_text = lines[1]
6246            .spans
6247            .iter()
6248            .filter(|span| span.style.fg == Some(SKILL_TRIGGER_COLOR))
6249            .map(|span| span.content.as_ref())
6250            .collect::<String>();
6251        assert_eq!(cyan_text, "/release-notes");
6252        assert!(!lines
6253            .iter()
6254            .any(|line| line.to_string().contains("instruction attached")));
6255    }
6256
6257    #[test]
6258    fn transcript_rendering_redacts_history_content() {
6259        let history = [SessionHistoryRecord::Message {
6260            timestamp: 1,
6261            message: ChatMessage::assistant("provider-secret".to_owned(), Vec::new()),
6262        }];
6263        let state = UiState::from_history(&history, "provider-secret", "model", None, false);
6264        let text = transcript_lines(&state, 80)
6265            .iter()
6266            .map(ToString::to_string)
6267            .collect::<Vec<_>>()
6268            .join("\n");
6269        assert!(!text.contains("provider-secret"));
6270    }
6271
6272    #[test]
6273    fn mouse_wheel_disables_following_and_changes_scroll_offset() {
6274        let history = [SessionHistoryRecord::Message {
6275            timestamp: 1,
6276            message: ChatMessage::user("hello".to_owned()),
6277        }];
6278        let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
6279        handle_mouse_event(&mut state, MouseEventKind::ScrollUp, 10);
6280        assert!(!state.auto_scroll);
6281        assert_eq!(state.scroll, 7);
6282        handle_mouse_event(&mut state, MouseEventKind::ScrollDown, 10);
6283        assert!(
6284            state.auto_scroll,
6285            "reaching the bottom resumes transcript following"
6286        );
6287        assert_eq!(state.scroll, 0);
6288        scroll_up(&mut state, 10);
6289        assert!(!state.auto_scroll);
6290        assert_eq!(state.scroll, 7);
6291    }
6292
6293    #[test]
6294    fn wrap_text_breaks_long_lines_and_preserves_empty_lines() {
6295        let rows = wrap_text("12345\n\nabc", 3);
6296        assert_eq!(rows, vec!["123", "45", "", "abc"]);
6297    }
6298
6299    #[test]
6300    fn wrap_line_never_returns_an_empty_vec() {
6301        assert_eq!(wrap_line("", 5), vec![""]);
6302        assert_eq!(wrap_line("abc", 5), vec!["abc"]);
6303    }
6304
6305    #[test]
6306    fn multiline_input_arrows_move_cursor_between_explicit_and_wrapped_rows() {
6307        let mut state = UiState::from_history(&[], "secret", "model", None, false);
6308        state.input = "ab\ncd\nef".to_owned();
6309        state.cursor = 1;
6310
6311        assert!(move_input_cursor_vertical(&mut state, 10, true));
6312        assert_eq!(
6313            state.cursor, 4,
6314            "preserve the column on the next explicit row"
6315        );
6316        assert!(move_input_cursor_vertical(&mut state, 10, true));
6317        assert_eq!(state.cursor, 7);
6318        assert!(!move_input_cursor_vertical(&mut state, 10, true));
6319        assert!(move_input_cursor_vertical(&mut state, 10, false));
6320        assert_eq!(state.cursor, 4);
6321
6322        state.input = "abcdef".to_owned();
6323        state.cursor = 1;
6324        assert!(move_input_cursor_vertical(&mut state, 3, true));
6325        assert_eq!(state.cursor, 4, "wrapped rows use the same visual column");
6326        assert!(move_input_cursor_vertical(&mut state, 3, false));
6327        assert_eq!(state.cursor, 1);
6328    }
6329
6330    #[test]
6331    fn completion_event_does_not_release_input_before_worker_finishes() {
6332        let history = [SessionHistoryRecord::Message {
6333            timestamp: 1,
6334            message: ChatMessage::user("hello".to_owned()),
6335        }];
6336        let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
6337        state.busy = true;
6338        state.active_cancel = Some(CancellationToken::new());
6339        state.apply_event(ProtocolEvent::TurnEnd);
6340        assert!(state.busy);
6341        assert!(state.active_cancel.is_some());
6342        assert_eq!(state.status, "finalizing");
6343    }
6344
6345    #[test]
6346    fn transcript_inserts_a_blank_line_between_items() {
6347        let history = [
6348            SessionHistoryRecord::Message {
6349                timestamp: 1,
6350                message: ChatMessage::user("hi".to_owned()),
6351            },
6352            SessionHistoryRecord::Message {
6353                timestamp: 2,
6354                message: ChatMessage::assistant("hello".to_owned(), Vec::new()),
6355            },
6356        ];
6357        let state = UiState::from_history(&history, "secret", "model", None, false);
6358        let lines = transcript_lines(&state, 80);
6359        assert_eq!(lines.len(), 5);
6360        assert_eq!(lines[0].to_string(), "▌");
6361        assert_eq!(lines[1].to_string(), "▌ hi");
6362        assert_eq!(lines[2].to_string(), "▌");
6363        assert_eq!(lines[3].to_string(), "");
6364        assert_eq!(lines[4].to_string(), "hello");
6365    }
6366
6367    #[test]
6368    fn cmd_call_renders_as_a_compact_status_line_without_raw_json() {
6369        let history = vec![
6370            SessionHistoryRecord::Message {
6371                timestamp: 1,
6372                message: ChatMessage::assistant(
6373                    String::new(),
6374                    vec![crate::model::ChatToolCall {
6375                        id: "call-1".to_owned(),
6376                        name: "cmd".to_owned(),
6377                        arguments: r#"{"command":"pwd"}"#.to_owned(),
6378                    }],
6379                ),
6380            },
6381            SessionHistoryRecord::Message {
6382                timestamp: 2,
6383                message: ChatMessage::tool(
6384                    "call-1".to_owned(),
6385                    "cmd".to_owned(),
6386                    serde_json::json!({"exit_code": 0, "stdout": "secret output"}).to_string(),
6387                ),
6388            },
6389        ];
6390        let state = UiState::from_history(&history, "secret", "model", None, false);
6391        let text = transcript_lines(&state, 80)[0].to_string();
6392
6393        assert_eq!(text, "✓ cmd  $ pwd");
6394        assert!(!text.contains("secret output"));
6395        assert!(!text.contains("{\"command\":\"pwd\"}"));
6396    }
6397
6398    #[test]
6399    fn pending_cmd_calls_use_a_compact_running_status() {
6400        let history = [SessionHistoryRecord::Message {
6401            timestamp: 1,
6402            message: ChatMessage::assistant(
6403                String::new(),
6404                vec![crate::model::ChatToolCall {
6405                    id: "call-1".to_owned(),
6406                    name: "cmd".to_owned(),
6407                    arguments: r#"{"command":"pwd"}"#.to_owned(),
6408                }],
6409            ),
6410        }];
6411        let state = UiState::from_history(&history, "secret", "model", None, false);
6412        let line = &transcript_lines(&state, 80)[0];
6413
6414        let text = line.to_string();
6415        let prefix = "· cmd  $ pwd  ";
6416        assert!(text.starts_with(prefix));
6417        assert!(!text.contains("→ running"));
6418        let frame = &text[prefix.len()..];
6419        assert_eq!(frame.chars().count(), 1);
6420        assert!(frame
6421            .chars()
6422            .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
6423        assert!(line
6424            .spans
6425            .iter()
6426            .all(|span| span.style.fg == Some(PENDING_TOOL_COLOR)));
6427    }
6428
6429    #[test]
6430    fn running_tool_indicators_use_a_traditional_spinner_with_their_own_clock() {
6431        assert_eq!(tool_spinner_frame_at(Duration::ZERO), '|');
6432        assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION), '/');
6433        assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 2), '-');
6434        assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 3), '\\');
6435
6436        let state = UiState::from_history(&[], "secret", "model", None, false);
6437        let spinner = running_tool_status(&state);
6438        assert_eq!(spinner.chars().count(), 1);
6439        assert!(spinner
6440            .chars()
6441            .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
6442    }
6443
6444    #[test]
6445    fn successful_cmd_cross_fades_to_teal_from_first_character_to_last() {
6446        let started_at = Instant::now();
6447        let character_count = 12;
6448        let early = started_at + TOOL_RESULT_SWEEP_DURATION / 4;
6449        let halfway = started_at + TOOL_RESULT_SWEEP_DURATION / 2;
6450        let late = started_at + TOOL_RESULT_SWEEP_DURATION * 3 / 4;
6451
6452        assert_eq!(
6453            cmd_result_color_at(
6454                started_at,
6455                started_at,
6456                0,
6457                character_count,
6458                TOOL_SUCCESS_COLOR,
6459            ),
6460            PENDING_TOOL_COLOR,
6461        );
6462        assert_eq!(TOOL_SUCCESS_COLOR, Color::Rgb(0, 210, 175));
6463
6464        let early_first =
6465            cmd_result_color_at(started_at, early, 0, character_count, TOOL_SUCCESS_COLOR);
6466        assert_ne!(early_first, PENDING_TOOL_COLOR);
6467        assert_ne!(early_first, TOOL_SUCCESS_COLOR);
6468        assert_eq!(
6469            cmd_result_color_at(started_at, early, 5, character_count, TOOL_SUCCESS_COLOR),
6470            PENDING_TOOL_COLOR,
6471            "later characters wait while the first character cross-fades"
6472        );
6473
6474        assert_eq!(
6475            cmd_result_color_at(started_at, halfway, 0, character_count, TOOL_SUCCESS_COLOR),
6476            TOOL_SUCCESS_COLOR,
6477        );
6478        let halfway_middle =
6479            cmd_result_color_at(started_at, halfway, 5, character_count, TOOL_SUCCESS_COLOR);
6480        assert_ne!(halfway_middle, PENDING_TOOL_COLOR);
6481        assert_ne!(halfway_middle, TOOL_SUCCESS_COLOR);
6482        assert_eq!(
6483            cmd_result_color_at(
6484                started_at,
6485                halfway,
6486                character_count - 1,
6487                character_count,
6488                TOOL_SUCCESS_COLOR,
6489            ),
6490            PENDING_TOOL_COLOR,
6491        );
6492
6493        let late_last = cmd_result_color_at(
6494            started_at,
6495            late,
6496            character_count - 1,
6497            character_count,
6498            TOOL_SUCCESS_COLOR,
6499        );
6500        assert_ne!(late_last, PENDING_TOOL_COLOR);
6501        assert_ne!(late_last, TOOL_SUCCESS_COLOR);
6502        assert_eq!(
6503            cmd_result_color_at(
6504                started_at,
6505                started_at + TOOL_RESULT_SWEEP_DURATION,
6506                character_count - 1,
6507                character_count,
6508                TOOL_SUCCESS_COLOR,
6509            ),
6510            TOOL_SUCCESS_COLOR,
6511            "the completed sweep keeps the exact teal used during the fade"
6512        );
6513    }
6514
6515    #[test]
6516    fn cmd_result_cross_fade_has_no_abrupt_color_change_between_render_ticks() {
6517        let started_at = Instant::now();
6518        let character_count = 12;
6519        let render_ticks = TOOL_RESULT_SWEEP_DURATION.as_millis() / EVENT_POLL.as_millis();
6520
6521        for target in [TOOL_SUCCESS_COLOR, TOOL_FAILURE_COLOR, TOOL_WARNING_COLOR] {
6522            for character_index in 0..character_count {
6523                let frames = (0..=render_ticks)
6524                    .map(|tick| {
6525                        cmd_result_color_at(
6526                            started_at,
6527                            started_at + EVENT_POLL * tick as u32,
6528                            character_index,
6529                            character_count,
6530                            target,
6531                        )
6532                    })
6533                    .collect::<Vec<_>>();
6534
6535                assert!(frames
6536                    .iter()
6537                    .any(|color| { *color != PENDING_TOOL_COLOR && *color != target }));
6538                assert!(frames.windows(2).all(|pair| {
6539                    let (before_red, before_green, before_blue) = tool_result_color_rgb(pair[0]);
6540                    let (after_red, after_green, after_blue) = tool_result_color_rgb(pair[1]);
6541                    before_red.abs_diff(after_red) <= 45
6542                        && before_green.abs_diff(after_green) <= 45
6543                        && before_blue.abs_diff(after_blue) <= 45
6544                }));
6545                assert_eq!(frames.last(), Some(&target));
6546            }
6547        }
6548    }
6549
6550    #[test]
6551    fn only_live_cmd_results_start_a_result_sweep() {
6552        let mut state = UiState::from_history(&[], "secret", "model", None, false);
6553        let succeeded = serde_json::json!({"exit_code": 0});
6554
6555        state.add_tool_result("historic", "cmd", succeeded.clone());
6556        state.add_live_tool_result("success", "cmd", succeeded);
6557        state.add_live_tool_result("failed", "cmd", serde_json::json!({"exit_code": 1}));
6558
6559        assert!(!state.cmd_result_started_at.contains_key("historic"));
6560        assert!(state.cmd_result_started_at.contains_key("success"));
6561        assert!(state.cmd_result_started_at.contains_key("failed"));
6562    }
6563
6564    #[test]
6565    fn failed_cmd_cross_fades_to_the_same_rgb_red_without_a_final_jump() {
6566        let started_at = Instant::now();
6567        let character_count = 12;
6568        let halfway = started_at + TOOL_RESULT_SWEEP_DURATION / 2;
6569
6570        assert_eq!(
6571            cmd_result_color_at(
6572                started_at,
6573                started_at,
6574                0,
6575                character_count,
6576                TOOL_FAILURE_COLOR,
6577            ),
6578            PENDING_TOOL_COLOR,
6579        );
6580        assert_eq!(
6581            cmd_result_color_at(started_at, halfway, 0, character_count, TOOL_FAILURE_COLOR),
6582            TOOL_FAILURE_COLOR,
6583        );
6584        let intermediate =
6585            cmd_result_color_at(started_at, halfway, 5, character_count, TOOL_FAILURE_COLOR);
6586        assert_ne!(intermediate, PENDING_TOOL_COLOR);
6587        assert_ne!(intermediate, TOOL_FAILURE_COLOR);
6588        assert_eq!(
6589            cmd_result_color_at(
6590                started_at,
6591                halfway,
6592                character_count - 1,
6593                character_count,
6594                TOOL_FAILURE_COLOR,
6595            ),
6596            PENDING_TOOL_COLOR,
6597        );
6598        assert_eq!(
6599            cmd_result_color_at(
6600                started_at,
6601                started_at + TOOL_RESULT_SWEEP_DURATION,
6602                character_count - 1,
6603                character_count,
6604                TOOL_FAILURE_COLOR,
6605            ),
6606            TOOL_FAILURE_COLOR,
6607            "the completed failure sweep keeps the exact RGB red used during the fade"
6608        );
6609    }
6610
6611    #[test]
6612    fn live_failed_cmd_sweep_keeps_the_final_status_text() {
6613        let mut state = UiState::from_history(&[], "secret", "model", None, false);
6614        let result = serde_json::json!({"exit_code": 1});
6615        state.add_live_tool_result("failed", "cmd", result.clone());
6616
6617        let segments = cmd_tool_segments("failed", r#"{"command":"bad"}"#, Some(&result), &state);
6618        let text = segments
6619            .iter()
6620            .map(|(text, _)| text.as_str())
6621            .collect::<String>();
6622
6623        assert_eq!(text, "× cmd  $ bad  → exit 1");
6624    }
6625
6626    #[test]
6627    fn cmd_result_target_colors_follow_the_final_status() {
6628        assert_eq!(
6629            cmd_result_target_color(&serde_json::json!({"exit_code": 0})),
6630            TOOL_SUCCESS_COLOR
6631        );
6632        assert_eq!(
6633            cmd_result_target_color(&serde_json::json!({"exit_code": 1})),
6634            TOOL_FAILURE_COLOR
6635        );
6636        assert_eq!(
6637            cmd_result_target_color(&serde_json::json!({"timed_out": true})),
6638            TOOL_WARNING_COLOR
6639        );
6640    }
6641
6642    #[test]
6643    fn cmd_status_distinguishes_nonzero_exit_timeout_and_cancellation() {
6644        let cases = [
6645            (
6646                serde_json::json!({"exit_code": 127}),
6647                "× cmd  $ bad  → exit 127",
6648            ),
6649            (
6650                serde_json::json!({"timed_out": true, "exit_code": null}),
6651                "! cmd  $ slow  → timeout",
6652            ),
6653            (
6654                serde_json::json!({"canceled": true}),
6655                "! cmd  $ stop  → canceled",
6656            ),
6657        ];
6658        for (result, expected) in cases {
6659            let history = vec![
6660                SessionHistoryRecord::Message {
6661                    timestamp: 1,
6662                    message: ChatMessage::assistant(
6663                        String::new(),
6664                        vec![crate::model::ChatToolCall {
6665                            id: "call-1".to_owned(),
6666                            name: "cmd".to_owned(),
6667                            arguments: serde_json::json!({"command": expected.split("$ ").nth(1).unwrap().split("  ").next().unwrap()}).to_string(),
6668                        }],
6669                    ),
6670                },
6671                SessionHistoryRecord::Message {
6672                    timestamp: 2,
6673                    message: ChatMessage::tool(
6674                        "call-1".to_owned(),
6675                        "cmd".to_owned(),
6676                        result.to_string(),
6677                    ),
6678                },
6679            ];
6680            let state = UiState::from_history(&history, "secret", "model", None, false);
6681            assert_eq!(transcript_lines(&state, 80)[0].to_string(), expected);
6682        }
6683    }
6684
6685    #[test]
6686    fn cmd_line_truncates_long_commands_but_never_renders_output() {
6687        let command = "a".repeat(120);
6688        let arguments = serde_json::json!({"command": command}).to_string();
6689        let history = vec![
6690            SessionHistoryRecord::Message {
6691                timestamp: 1,
6692                message: ChatMessage::assistant(
6693                    String::new(),
6694                    vec![crate::model::ChatToolCall {
6695                        id: "call-1".to_owned(),
6696                        name: "cmd".to_owned(),
6697                        arguments,
6698                    }],
6699                ),
6700            },
6701            SessionHistoryRecord::Message {
6702                timestamp: 2,
6703                message: ChatMessage::tool(
6704                    "call-1".to_owned(),
6705                    "cmd".to_owned(),
6706                    serde_json::json!({"exit_code": 0, "stdout": "output"}).to_string(),
6707                ),
6708            },
6709        ];
6710        let state = UiState::from_history(&history, "secret", "model", None, false);
6711        let text = transcript_lines(&state, 200)[0].to_string();
6712        assert!(text.contains(&format!("$ {}…", "a".repeat(100))));
6713        assert!(!text.contains(&"a".repeat(101)));
6714        assert!(!text.contains("output"));
6715    }
6716
6717    #[test]
6718    fn cmd_lines_remain_compact_for_consecutive_calls() {
6719        let history = vec![
6720            SessionHistoryRecord::Message {
6721                timestamp: 1,
6722                message: ChatMessage::assistant(
6723                    String::new(),
6724                    vec![
6725                        crate::model::ChatToolCall {
6726                            id: "call-first".to_owned(),
6727                            name: "cmd".to_owned(),
6728                            arguments: r#"{"command":"first"}"#.to_owned(),
6729                        },
6730                        crate::model::ChatToolCall {
6731                            id: "call-second".to_owned(),
6732                            name: "cmd".to_owned(),
6733                            arguments: r#"{"command":"second"}"#.to_owned(),
6734                        },
6735                    ],
6736                ),
6737            },
6738            SessionHistoryRecord::Message {
6739                timestamp: 2,
6740                message: ChatMessage::tool(
6741                    "call-first".to_owned(),
6742                    "cmd".to_owned(),
6743                    serde_json::json!({"exit_code": 0}).to_string(),
6744                ),
6745            },
6746            SessionHistoryRecord::Message {
6747                timestamp: 3,
6748                message: ChatMessage::tool(
6749                    "call-second".to_owned(),
6750                    "cmd".to_owned(),
6751                    serde_json::json!({"exit_code": 0}).to_string(),
6752                ),
6753            },
6754        ];
6755        let state = UiState::from_history(&history, "secret", "model", None, false);
6756        let lines = transcript_lines(&state, 200);
6757        assert_eq!(lines[0].to_string(), "✓ cmd  $ first");
6758        assert_eq!(lines[2].to_string(), "✓ cmd  $ second");
6759    }
6760
6761    #[test]
6762    fn cmd_status_styles_use_success_failure_and_pending_colors() {
6763        assert_eq!(
6764            cmd_result_status(&serde_json::json!({"exit_code": 0})).2.fg,
6765            Some(TOOL_SUCCESS_COLOR)
6766        );
6767        assert_eq!(
6768            cmd_result_status(&serde_json::json!({"exit_code": 1})).2.fg,
6769            Some(TOOL_FAILURE_COLOR)
6770        );
6771        assert_eq!(
6772            cmd_tool_segments(
6773                "call-1",
6774                "{\"command\":\"pwd\"}",
6775                None,
6776                &UiState::from_history(&[], "secret", "model", None, false)
6777            )[0]
6778            .1
6779            .fg,
6780            Some(PENDING_TOOL_COLOR)
6781        );
6782    }
6783
6784    #[test]
6785    fn recognized_skill_trigger_is_highlighted_but_arguments_remain_default_colored() {
6786        let trigger = active_skill_trigger("/release-notes v1.2.0", &["release-notes".to_owned()]);
6787        assert_eq!(trigger, Some("/release-notes"));
6788        assert_eq!(SKILL_TRIGGER_COLOR, Color::Rgb(80, 255, 245));
6789
6790        let lines = styled_text_lines(
6791            "/release-notes v1.2.0",
6792            trigger,
6793            80,
6794            Style::default().fg(Color::White),
6795        );
6796        assert_eq!(lines.len(), 1);
6797        assert_eq!(lines[0].to_string(), "/release-notes v1.2.0");
6798        assert_eq!(lines[0].spans[0].content, "/release-notes");
6799        assert_eq!(lines[0].spans[0].style.fg, Some(SKILL_TRIGGER_COLOR));
6800        assert_eq!(lines[0].spans[1].content, " v1.2.0");
6801        assert_eq!(lines[0].spans[1].style.fg, Some(Color::White));
6802    }
6803
6804    #[test]
6805    fn draw_renders_an_active_skill_trigger_in_cyan() {
6806        let mut state = UiState::from_history(&[], "secret", "model", None, false)
6807            .with_skill_names(vec!["release-notes".to_owned()]);
6808        state.input = "/release-notes v1.2.0".to_owned();
6809        state.cursor = state.input.chars().count();
6810
6811        let mut terminal =
6812            Terminal::new(ratatui::backend::TestBackend::new(40, 10)).expect("test terminal");
6813        terminal
6814            .draw(|frame| draw(frame, &state))
6815            .expect("draw input");
6816
6817        // The full-width input block keeps trigger characters bright cyan while the
6818        // argument that follows stays white.
6819        let buffer = terminal.backend().buffer();
6820        let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 40, 10)));
6821        let prompt_area = prompt_area(input_area, &state);
6822        let input_x = prompt_area.x;
6823        let input_y = prompt_area.y;
6824        assert_eq!(buffer[(input_x, input_y)].fg, SKILL_TRIGGER_COLOR);
6825        assert_eq!(
6826            buffer[(input_x + "/release-notes".chars().count() as u16, input_y)].fg,
6827            Color::White
6828        );
6829    }
6830
6831    #[test]
6832    fn main_agent_status_omits_activity_animation_on_idle_and_busy_glass() {
6833        let mut state =
6834            UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
6835        let area = Rect::new(0, 0, 80, 10);
6836        let mut terminal =
6837            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
6838                .expect("test terminal");
6839
6840        terminal
6841            .draw(|frame| draw(frame, &state))
6842            .expect("draw ready status");
6843        let viewport = tui_viewport(area);
6844        let status_area = ui_layout(&state, viewport).5;
6845        let idle = "model · default | Context: 81/100 (81%) █████████░";
6846        let buffer = terminal.backend().buffer();
6847        let idle_columns = status_area.x..status_area.x + idle.chars().count() as u16;
6848        let idle_row = idle_columns
6849            .clone()
6850            .map(|x| buffer[(x, status_area.y)].symbol())
6851            .collect::<String>();
6852        assert_eq!(idle_row, idle);
6853        for x in idle_columns {
6854            assert_eq!(buffer[(x, status_area.y)].fg, Color::Rgb(144, 144, 148));
6855        }
6856
6857        state.set_status("working");
6858        state.busy = true;
6859        state.activity_transition = None;
6860        state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
6861        terminal
6862            .draw(|frame| draw(frame, &state))
6863            .expect("draw working status");
6864        let status_area = ui_layout(&state, viewport).5;
6865        let expected = "model · default | Context: 81/100 (81%) █████████░";
6866        let buffer = terminal.backend().buffer();
6867        let status_columns = status_area.x..status_area.x + expected.chars().count() as u16;
6868        let rendered = status_columns
6869            .clone()
6870            .map(|x| buffer[(x, status_area.y)].symbol())
6871            .collect::<String>();
6872        assert_eq!(rendered, expected);
6873        assert!(
6874            status_columns
6875                .clone()
6876                .any(|x| buffer[(x, status_area.y)].bg != CONSOLE_BACKGROUND),
6877            "the busy status line renders over bright glass"
6878        );
6879        for x in status_columns {
6880            assert_eq!(buffer[(x, status_area.y)].fg, Color::Rgb(144, 144, 148));
6881        }
6882    }
6883
6884    #[test]
6885    fn terminal_focus_events_control_cursor_visibility() {
6886        let mut state = UiState::from_history(&[], "secret", "model", None, false);
6887
6888        assert!(handle_terminal_focus_event(&mut state, &Event::FocusLost));
6889        assert!(!state.terminal_focused);
6890        assert!(handle_terminal_focus_event(&mut state, &Event::FocusGained));
6891        assert!(state.terminal_focused);
6892        assert!(!handle_terminal_focus_event(
6893            &mut state,
6894            &Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
6895        ));
6896        assert!(state.terminal_focused);
6897    }
6898
6899    #[test]
6900    fn unfocused_busy_glow_keeps_the_hardware_cursor_hidden() {
6901        let mut state = UiState::from_history(&[], "secret", "model", None, false);
6902        state.set_status("working");
6903        state.set_busy(true);
6904        state.terminal_focused = false;
6905        state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
6906
6907        let mut terminal =
6908            Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
6909        terminal
6910            .draw(|frame| draw(frame, &state))
6911            .expect("draw busy glow");
6912
6913        assert!(
6914            !terminal.backend().cursor_visible(),
6915            "the glow redraw must not re-show the terminal cursor"
6916        );
6917    }
6918
6919    #[test]
6920    fn cjk_input_keeps_the_terminal_cursor_in_the_prompt_without_resetting_activity() {
6921        let mut state = UiState::from_history(&[], "secret", "model", None, false);
6922        state.set_status("working");
6923        state.busy = true;
6924        state.input = "한글".to_owned();
6925        state.cursor = state.input.chars().count();
6926        let activity_started_at = state.activity_started_at;
6927        let tool_animation_epoch = state.tool_animation_epoch;
6928        let sample_at = Instant::now();
6929        let activity_before = state.activity_levels_at(sample_at);
6930
6931        // A committed CJK character must move the hardware cursor by its
6932        // display width, and input edits must not restart either animation.
6933        state.input_changed();
6934        assert_eq!(state.activity_started_at, activity_started_at);
6935        assert_eq!(state.tool_animation_epoch, tool_animation_epoch);
6936        assert_eq!(state.activity_levels_at(sample_at), activity_before);
6937
6938        let area = Rect::new(0, 0, 80, 10);
6939        let mut terminal =
6940            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
6941                .expect("test terminal");
6942        terminal
6943            .draw(|frame| draw(frame, &state))
6944            .expect("draw CJK input while working");
6945        let (_, _, _, _, input_area, status_area) = ui_layout(&state, tui_viewport(area));
6946        assert_ne!(input_area.y, status_area.y);
6947        let prompt_area = prompt_area(input_area, &state);
6948        assert!(terminal.backend().cursor_visible());
6949        terminal.backend_mut().assert_cursor_position((
6950            prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
6951            prompt_area.y,
6952        ));
6953    }
6954
6955    #[test]
6956    fn transcript_and_console_are_separated_by_one_blank_row() {
6957        let state = UiState::from_history(&[], "secret", "model", None, false);
6958        let area = Rect::new(0, 0, 80, 10);
6959        let mut terminal =
6960            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
6961                .expect("test terminal");
6962
6963        terminal
6964            .draw(|frame| draw(frame, &state))
6965            .expect("draw separated transcript and console");
6966
6967        let (transcript, _, _, _, console, _) = ui_layout(&state, tui_viewport(area));
6968        assert_eq!(transcript.y + transcript.height + 1, console.y);
6969        let gap_y = console.y - 1;
6970        for x in transcript.x..transcript.x + transcript.width {
6971            assert_eq!(terminal.backend().buffer()[(x, gap_y)].symbol(), " ");
6972            assert_eq!(terminal.backend().buffer()[(x, gap_y)].bg, Color::Reset);
6973        }
6974    }
6975
6976    #[test]
6977    fn idle_console_has_external_gutters_uniform_background_and_no_borders() {
6978        let mut state = UiState::from_history(&[], "secret", "model", None, false);
6979        state.input = "prompt".to_owned();
6980        state.cursor = state.input.chars().count();
6981        let area = Rect::new(0, 0, 80, 10);
6982        let mut terminal =
6983            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
6984                .expect("test terminal");
6985
6986        terminal
6987            .draw(|frame| draw(frame, &state))
6988            .expect("draw idle console");
6989
6990        let input_area = ui_layout(&state, tui_viewport(area)).4;
6991        let prompt_area = prompt_area(input_area, &state);
6992        let buffer = terminal.backend().buffer();
6993        let bottom_y = area.y + area.height - 1;
6994        assert_eq!(buffer[(area.x, bottom_y)].bg, Color::Reset);
6995        assert_eq!(buffer[(area.x + area.width - 1, bottom_y)].bg, Color::Reset);
6996        for y in input_area.y..input_area.y + input_area.height {
6997            assert_eq!(buffer[(0, y)].bg, Color::Reset);
6998            assert_eq!(buffer[(79, y)].bg, Color::Reset);
6999            for x in input_area.x..input_area.x + input_area.width {
7000                assert_eq!(buffer[(x, y)].bg, CONSOLE_BACKGROUND);
7001            }
7002        }
7003        assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), " ");
7004        assert_eq!(
7005            buffer[(input_area.x + input_area.width - 1, input_area.y)].symbol(),
7006            " "
7007        );
7008        assert_eq!(
7009            buffer[(input_area.x, input_area.y + input_area.height - 1)].symbol(),
7010            " "
7011        );
7012        assert_eq!(
7013            buffer[(
7014                input_area.x + input_area.width - 1,
7015                input_area.y + input_area.height - 1
7016            )]
7017                .symbol(),
7018            " "
7019        );
7020        assert_eq!(buffer[(prompt_area.x, prompt_area.y)].symbol(), "p");
7021        assert_eq!(buffer[(prompt_area.x, prompt_area.y)].fg, Color::White);
7022        terminal.backend_mut().assert_cursor_position((
7023            prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
7024            prompt_area.y,
7025        ));
7026    }
7027
7028    #[test]
7029    fn busy_console_keeps_glass_inside_the_bottom_half_ellipse() {
7030        let mut state = UiState::from_history(&[], "secret", "model", None, false);
7031        state.busy = true;
7032        state.set_status("working");
7033        state.activity_transition = None;
7034        state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
7035        let area = Rect::new(0, 0, 200, 10);
7036        let mut terminal =
7037            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
7038                .expect("test terminal");
7039
7040        terminal
7041            .draw(|frame| draw(frame, &state))
7042            .expect("draw busy console");
7043
7044        let viewport = tui_viewport(area);
7045        let (chat_area, _, _, _, input_area, _) = ui_layout(&state, viewport);
7046        let reflection_y = input_area.y - 1;
7047        let glow_floor_y = area.y + area.height - 1;
7048        let edge_y = input_area.y + input_area.height - 1;
7049        let floor = input_area.x + input_area.width / 2;
7050        let left_edge = input_area.x;
7051        let left_outer = left_edge - 1;
7052        let right_edge = input_area.x + input_area.width - 1;
7053        let right_outer = right_edge + 1;
7054        let widening_sample = input_area.x - 22;
7055        let buffer = terminal.backend().buffer();
7056        assert_eq!(chat_area.y + chat_area.height + 1, input_area.y);
7057        assert_eq!(
7058            buffer[(floor, reflection_y)].symbol(),
7059            CONSOLE_REFLECTION_GLYPH,
7060            "the active console reflects along the bottom of the existing gap row"
7061        );
7062        assert_ne!(buffer[(floor, reflection_y)].fg, Color::Reset);
7063        assert_ne!(buffer[(floor, glow_floor_y)].bg, Color::Reset);
7064        assert_ne!(buffer[(left_outer, edge_y)].bg, Color::Reset);
7065        assert_ne!(buffer[(right_outer, edge_y)].bg, Color::Reset);
7066        let left_extent = left_edge - GLOW_HORIZONTAL_SPREAD;
7067        let right_extent = right_edge + GLOW_HORIZONTAL_SPREAD;
7068        assert_ne!(buffer[(left_extent, glow_floor_y)].bg, Color::Reset);
7069        assert_ne!(buffer[(right_extent, glow_floor_y)].bg, Color::Reset);
7070        assert_eq!(buffer[(left_extent - 1, glow_floor_y)].bg, Color::Reset);
7071        assert_eq!(buffer[(right_extent + 1, glow_floor_y)].bg, Color::Reset);
7072        assert_eq!(buffer[(widening_sample, input_area.y)].bg, Color::Reset);
7073        assert_ne!(buffer[(widening_sample, glow_floor_y)].bg, Color::Reset);
7074        assert_eq!(buffer[(area.x, glow_floor_y)].bg, Color::Reset);
7075        for y in input_area.y..input_area.y + input_area.height {
7076            for x in input_area.x..input_area.x + input_area.width {
7077                assert_ne!(buffer[(x, y)].bg, Color::Reset);
7078            }
7079        }
7080        for y in input_area.y..input_area.y + input_area.height {
7081            for x in input_area.x..input_area.x + input_area.width {
7082                assert_ne!(buffer[(x, y)].bg, Color::Reset);
7083                assert!(
7084                    color_distance(CONSOLE_BACKGROUND, buffer[(x, y)].bg)
7085                        < color_distance(TUI_GLOW_BACKGROUND, buffer[(floor, glow_floor_y)].bg)
7086                );
7087            }
7088        }
7089    }
7090
7091    #[test]
7092    fn narrow_busy_console_keeps_both_endpoint_blooms_exposed() {
7093        let mut state = UiState::from_history(&[], "secret", "model", None, false);
7094        state.busy = true;
7095        state.set_status("working");
7096        state.activity_transition = None;
7097        state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
7098        let area = Rect::new(0, 0, 10, 10);
7099        let mut terminal =
7100            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
7101                .expect("test terminal");
7102
7103        terminal
7104            .draw(|frame| draw(frame, &state))
7105            .expect("draw narrow TUI");
7106
7107        let input = ui_layout(&state, tui_viewport(area)).4;
7108        assert_eq!(input.width, 4, "test the minimum inset console width");
7109        let left_edge = input.x;
7110        let right_edge = input.x + input.width - 1;
7111        let buffer = terminal.backend().buffer();
7112        for y in input.y..input.y + input.height {
7113            for (edge, outer) in [(left_edge, left_edge - 1), (right_edge, right_edge + 1)] {
7114                assert_ne!(
7115                    buffer[(outer, y)].bg,
7116                    Color::Reset,
7117                    "the outer glow cell is present at ({outer}, {y})"
7118                );
7119                assert_ne!(
7120                    buffer[(edge, y)].bg,
7121                    buffer[(outer, y)].bg,
7122                    "narrow console glass does not spill into the exposed bloom at ({edge}, {y})"
7123                );
7124            }
7125        }
7126    }
7127
7128    #[test]
7129    fn busy_queue_and_subagent_rows_preserve_the_console_glow_surface() {
7130        let mut state = UiState::from_history(&[], "secret", "model", None, false);
7131        state.busy = true;
7132        state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
7133        state.queue_user("send later");
7134        state.subagents.push(SubagentTask {
7135            call_id: "call-worker".to_owned(),
7136            task_id: Some("subagent-1".to_owned()),
7137            task: "Inspect".to_owned(),
7138            model: None,
7139            effort: None,
7140            status: SubagentStatus::Running,
7141            result: None,
7142            creation_completed: true,
7143            stream: Vec::new(),
7144            stream_chars: 0,
7145        });
7146        let area = Rect::new(0, 0, 80, 18);
7147        let mut terminal =
7148            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
7149                .expect("test terminal");
7150
7151        terminal
7152            .draw(|frame| draw(frame, &state))
7153            .expect("draw busy TUI");
7154
7155        let viewport = tui_viewport(area);
7156        let (_, _, _, queue, input, _) = ui_layout(&state, viewport);
7157        let list = subagent_list_area(&state, input).expect("subagent list");
7158        let elapsed = state.console_animation_elapsed_at(Instant::now());
7159        let visibility = state.console_visibility_at(Instant::now());
7160        let buffer = terminal.backend().buffer();
7161        for section in [queue.expect("message queue"), list] {
7162            let x = section.x + section.width / 2;
7163            let y = section.y;
7164            let glow = glow_color_at(elapsed, x, area.width, y, area, input, visibility)
7165                .unwrap_or(TUI_GLOW_BACKGROUND);
7166            let expected = console_glass_color_at(elapsed, glow, visibility);
7167            assert!(
7168                color_distance(buffer[(x, y)].bg, expected) <= 3,
7169                "section ({x}, {y}) must retain the same glass surface as the console"
7170            );
7171        }
7172    }
7173
7174    #[test]
7175    fn only_known_leading_skill_commands_activate_input_highlighting() {
7176        let skills = ["release-notes".to_owned()];
7177        assert_eq!(
7178            active_skill_trigger("/missing", &skills),
7179            None,
7180            "unknown commands are rejected by the turn engine and must not look active"
7181        );
7182        assert_eq!(
7183            active_skill_trigger("/skill:release-notes", &skills),
7184            None,
7185            "the removed /skill: wrapper must not look active"
7186        );
7187        assert_eq!(
7188            active_skill_trigger("write /release-notes", &skills),
7189            None,
7190            "only the command prefix accepted by the turn engine is active"
7191        );
7192        assert_eq!(active_skill_trigger("/", &skills), None);
7193    }
7194
7195    #[test]
7196    fn highlighted_skill_trigger_remains_styled_when_wrapped() {
7197        let input = "/release-notes argument";
7198        let trigger = active_skill_trigger(input, &["release-notes".to_owned()]);
7199        let lines = styled_text_lines(input, trigger, 8, Style::default().fg(Color::White));
7200        let highlighted = lines
7201            .iter()
7202            .flat_map(|line| line.spans.iter())
7203            .filter(|span| span.style.fg == Some(SKILL_TRIGGER_COLOR))
7204            .map(|span| span.content.as_ref())
7205            .collect::<String>();
7206        assert_eq!(highlighted, "/release-notes");
7207    }
7208
7209    #[test]
7210    fn input_has_no_prompt_marker_and_trailing_newline_is_visible() {
7211        assert_eq!(input_prompt("hello"), "hello");
7212        assert_eq!(wrap_text("hello\n", 80), vec!["hello", ""]);
7213    }
7214
7215    #[test]
7216    fn input_prompt_wraps_to_multiple_rows_when_long() {
7217        let mut state = UiState::from_history(&[], "secret", "model", None, false);
7218        state.input = "abcdefghij".to_owned();
7219        // width 5: the input wraps across multiple rows without a prompt marker.
7220        let rows = input_visible_rows(&state, 5);
7221        assert!(rows >= 2);
7222    }
7223
7224    #[test]
7225    fn cursor_editing_moves_by_characters_and_preserves_unicode() {
7226        let mut input = "가나".to_owned();
7227        let mut cursor = input.chars().count();
7228        cursor -= 1;
7229        insert_at_cursor(&mut input, &mut cursor, 'x');
7230        assert_eq!(input, "가x나");
7231        assert_eq!(cursor, 2);
7232        assert!(remove_before_cursor(&mut input, &mut cursor));
7233        assert_eq!(input, "가나");
7234        assert_eq!(cursor, 1);
7235    }
7236
7237    #[test]
7238    fn cursor_row_tracks_newlines_and_wrapping() {
7239        assert_eq!(cursor_row("hello\nworld", 6, 80), 1);
7240        assert_eq!(cursor_row("abcdef", 4, 3), 1);
7241    }
7242
7243    #[test]
7244    fn shift_enter_inserts_at_the_cursor_and_moves_it_to_the_new_row() {
7245        let mut input = "beforeafter".to_owned();
7246        let mut cursor = 6;
7247        insert_at_cursor(&mut input, &mut cursor, '\n');
7248
7249        assert_eq!(input, "before\nafter");
7250        assert_eq!(cursor, 7);
7251        assert_eq!(cursor_row(&input, cursor, 80), 1);
7252    }
7253
7254    #[test]
7255    fn shift_enter_renders_the_cursor_on_the_new_input_row() {
7256        let mut state = UiState::from_history(&[], "secret", "model", None, false);
7257        state.input = "beforeafter".to_owned();
7258        state.cursor = 6;
7259        insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
7260
7261        let mut terminal =
7262            Terminal::new(ratatui::backend::TestBackend::new(20, 10)).expect("test terminal");
7263        terminal
7264            .draw(|frame| draw(frame, &state))
7265            .expect("draw input cursor");
7266
7267        // After inserting a newline, the cursor is at the start of the second
7268        // input row.
7269        let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 20, 10)));
7270        let prompt_area = prompt_area(input_area, &state);
7271        terminal
7272            .backend_mut()
7273            .assert_cursor_position((prompt_area.x, prompt_area.y + 1));
7274    }
7275
7276    #[test]
7277    fn tool_results_attach_to_their_matching_call_after_consecutive_calls() {
7278        let history = vec![
7279            SessionHistoryRecord::Message {
7280                timestamp: 1,
7281                message: ChatMessage::assistant(
7282                    String::new(),
7283                    vec![
7284                        crate::model::ChatToolCall {
7285                            id: "call-first".to_owned(),
7286                            name: "cmd".to_owned(),
7287                            arguments: r#"{"command":"first"}"#.to_owned(),
7288                        },
7289                        crate::model::ChatToolCall {
7290                            id: "call-second".to_owned(),
7291                            name: "cmd".to_owned(),
7292                            arguments: r#"{"command":"second"}"#.to_owned(),
7293                        },
7294                    ],
7295                ),
7296            },
7297            SessionHistoryRecord::Message {
7298                timestamp: 2,
7299                message: ChatMessage::tool(
7300                    "call-first".to_owned(),
7301                    "cmd".to_owned(),
7302                    serde_json::json!({"stdout":"first result","stderr":""}).to_string(),
7303                ),
7304            },
7305            SessionHistoryRecord::Message {
7306                timestamp: 3,
7307                message: ChatMessage::tool(
7308                    "call-second".to_owned(),
7309                    "cmd".to_owned(),
7310                    serde_json::json!({"stdout":"second result","stderr":""}).to_string(),
7311                ),
7312            },
7313        ];
7314
7315        let state = UiState::from_history(&history, "secret", "model", None, false);
7316        let lines = transcript_lines(&state, 200);
7317        assert_eq!(
7318            lines.len(),
7319            3,
7320            "only the two call lines and their separator remain"
7321        );
7322        assert_eq!(lines[0].to_string(), "✓ cmd  $ first");
7323        assert_eq!(lines[2].to_string(), "✓ cmd  $ second");
7324    }
7325    #[test]
7326    fn subagent_tasks_keep_metadata_until_completion_then_leave_live_list() {
7327        let history = vec![
7328            SessionHistoryRecord::Message {
7329                timestamp: 1,
7330                message: ChatMessage::assistant(
7331                    String::new(),
7332                    vec![crate::model::ChatToolCall {
7333                        id: "call-worker".to_owned(),
7334                        name: "spawn_subagent".to_owned(),
7335                        arguments: serde_json::json!({
7336                            "task": "Inspect the command UI"
7337                        })
7338                        .to_string(),
7339                    }],
7340                ),
7341            },
7342            SessionHistoryRecord::Message {
7343                timestamp: 2,
7344                message: ChatMessage::tool(
7345                    "call-worker".to_owned(),
7346                    "spawn_subagent".to_owned(),
7347                    serde_json::json!({"task_id":"subagent-1","status":"queued"}).to_string(),
7348                ),
7349            },
7350        ];
7351        let mut state =
7352            UiState::from_history(&history, "secret", "worker-model", Some("high"), false);
7353        assert_eq!(state.subagents.len(), 1);
7354        let task = &state.subagents[0];
7355        assert_eq!(task.task, "Inspect the command UI");
7356        assert_eq!(task.task_id.as_deref(), Some("subagent-1"));
7357        assert_eq!(task.model.as_deref(), Some("worker-model"));
7358        assert_eq!(task.effort.as_deref(), Some("high"));
7359        assert_eq!(task.status, SubagentStatus::Running);
7360        assert!(state.transcript.is_empty());
7361
7362        state.apply_event(ProtocolEvent::BackgroundResultPending {
7363            completion_id: "completion-1".to_owned(),
7364            task_id: "subagent-1".to_owned(),
7365            child_session_id: "child-1".to_owned(),
7366            status: "completed".to_owned(),
7367            result: serde_json::json!({"model":"worker-model","output":"finished"}),
7368            completed_at: 1,
7369        });
7370        state.apply_event(ProtocolEvent::BackgroundResultDelivered {
7371            completion_id: "completion-1".to_owned(),
7372            task_id: "subagent-1".to_owned(),
7373            logical_turn_id: "turn-1".to_owned(),
7374            delivery: "synthetic".to_owned(),
7375        });
7376        assert!(
7377            state.subagents.is_empty(),
7378            "completed workers are removed from the live background-task list"
7379        );
7380        let rendered = transcript_lines(&state, 100)
7381            .iter()
7382            .map(Line::to_string)
7383            .collect::<Vec<_>>();
7384        assert!(rendered.iter().any(|line| {
7385            line.contains("subagent-1")
7386                && line.contains("completed")
7387                && line.contains("result pending")
7388        }));
7389
7390        assert!(rendered
7391            .iter()
7392            .any(|line| { line.contains("subagent-1") && line.contains("result delivered") }));
7393        assert_eq!(
7394            state
7395                .transcript
7396                .iter()
7397                .filter(|item| matches!(item, TranscriptItem::SubagentLifecycle { .. }))
7398                .count(),
7399            2,
7400            "pending and delivered are separate transcript transitions"
7401        );
7402    }
7403
7404    #[test]
7405    fn resumed_subagent_completion_clears_the_live_list_without_rendering_internal_prompt() {
7406        let history = vec![
7407            SessionHistoryRecord::Message {
7408                timestamp: 1,
7409                message: ChatMessage::assistant(
7410                    String::new(),
7411                    vec![crate::model::ChatToolCall {
7412                        id: "call-worker".to_owned(),
7413                        name: "spawn_subagent".to_owned(),
7414                        arguments: serde_json::json!({"task":"Inspect"}).to_string(),
7415                    }],
7416                ),
7417            },
7418            SessionHistoryRecord::Message {
7419                timestamp: 2,
7420                message: ChatMessage::tool(
7421                    "call-worker".to_owned(),
7422                    "spawn_subagent".to_owned(),
7423                    serde_json::json!({"task_id":"subagent-1","status":"queued"}).to_string(),
7424                ),
7425            },
7426            SessionHistoryRecord::BackgroundResultPending(
7427                crate::session::BackgroundResultPending {
7428                    timestamp: 3,
7429                    completion_id: "completion-1".to_owned(),
7430                    task_id: "subagent-1".to_owned(),
7431                    child_session_id: "child-1".to_owned(),
7432                    task: "Inspect".to_owned(),
7433                    status: crate::session::ChildSessionStatus::Completed,
7434                    result: serde_json::json!({"output":"resumed result"}),
7435                    completed_at: 3,
7436                },
7437            ),
7438        ];
7439        let state = UiState::from_history(&history, "secret", "model", None, true);
7440        assert!(
7441            state.subagents.is_empty(),
7442            "a completed worker is not restored into the live background-task list"
7443        );
7444        assert!(!state.transcript.iter().any(|item| {
7445            matches!(item, TranscriptItem::User { text, .. } if text.contains("Background subagent"))
7446        }));
7447        assert!(state.transcript.iter().any(|item| {
7448            matches!(
7449                item,
7450                TranscriptItem::SubagentLifecycle { task_id, status, .. }
7451                    if task_id == "subagent-1" && status == "completed"
7452            )
7453        }));
7454    }
7455
7456    #[test]
7457    fn subagent_list_reserves_rows_between_prompt_and_status() {
7458        let mut state = UiState::from_history(&[], "secret", "model", None, false);
7459        state.subagents.push(SubagentTask {
7460            call_id: "call-worker".to_owned(),
7461            task_id: Some("subagent-1".to_owned()),
7462            task: "Inspect the command UI and report findings".to_owned(),
7463            model: Some("worker-model".to_owned()),
7464            effort: Some("high".to_owned()),
7465            status: SubagentStatus::Running,
7466            result: None,
7467            creation_completed: true,
7468            stream: Vec::new(),
7469            stream_chars: 0,
7470        });
7471        let area = Rect::new(0, 0, 80, 20);
7472        let (_, _, _, _, input, status) = ui_layout(&state, area);
7473        let list = subagent_list_area(&state, input).expect("worker list");
7474        let prompt = prompt_area(input, &state);
7475        assert_eq!(prompt.y, input.y + 1);
7476        let (queue_spacer, list_spacer, status_spacer) = console_spacer_rows(&state, input);
7477        assert_eq!(queue_spacer, None);
7478        assert_eq!(list.height, 2);
7479        assert_eq!(list_spacer, Some(prompt.y + prompt.height));
7480        assert_eq!(list.y, list_spacer.expect("list spacer") + 1);
7481        assert_eq!(status_spacer, Some(list.y + list.height));
7482        assert_eq!(status.y, status_spacer.expect("status spacer") + 1);
7483        assert_eq!(status.y + 2, input.y + input.height);
7484
7485        let mut terminal =
7486            Terminal::new(ratatui::backend::TestBackend::new(80, 20)).expect("test terminal");
7487        terminal.draw(|frame| draw(frame, &state)).expect("draw");
7488        let screen = terminal
7489            .backend()
7490            .buffer()
7491            .content()
7492            .iter()
7493            .map(|cell| cell.symbol())
7494            .collect::<String>();
7495        assert!(screen.contains("Subagents"));
7496        assert!(screen.contains("│ subagent-1"));
7497        assert!(!screen.contains("worker-model"));
7498        assert!(!screen.contains("high"));
7499        assert!(screen.contains("Inspect the command UI"));
7500    }
7501
7502    #[test]
7503    fn queued_and_terminal_subagents_do_not_appear_in_the_running_list() {
7504        let mut state = UiState::from_history(&[], "secret", "model", None, false);
7505        state.subagents.push(SubagentTask {
7506            call_id: "call-queued".to_owned(),
7507            task_id: None,
7508            task: "Queued".to_owned(),
7509            model: None,
7510            effort: None,
7511            status: SubagentStatus::Queued,
7512            result: None,
7513            creation_completed: false,
7514            stream: Vec::new(),
7515            stream_chars: 0,
7516        });
7517        state.subagents.push(SubagentTask {
7518            call_id: "call-failed".to_owned(),
7519            task_id: None,
7520            task: "Failed".to_owned(),
7521            model: None,
7522            effort: None,
7523            status: SubagentStatus::Failed,
7524            result: None,
7525            creation_completed: false,
7526            stream: Vec::new(),
7527            stream_chars: 0,
7528        });
7529        assert_eq!(subagent_list_height(&state), 0);
7530        assert!(subagent_list_area(&state, Rect::new(0, 0, 80, 10)).is_none());
7531        assert!(!state.focus_subagent_list_from_input());
7532    }
7533
7534    #[test]
7535    fn subagent_rows_use_stable_id_hash_colors() {
7536        assert_eq!(
7537            subagent_id_color("subagent-1"),
7538            subagent_id_color("subagent-1")
7539        );
7540        assert_ne!(
7541            subagent_id_color("subagent-1"),
7542            subagent_id_color("subagent-2")
7543        );
7544        assert!(SUBAGENT_ID_COLORS.iter().all(|color| {
7545            matches!(color, Color::Rgb(220, green, blue)
7546                if u16::from(*green) < u16::from(*blue)
7547                    && u16::from(*blue) < 220
7548                    && 4 * (u16::from(*blue) - u16::from(*green))
7549                        == 3 * (220 - u16::from(*green)))
7550        }));
7551        assert!(SUBAGENT_ID_COLORS
7552            .windows(2)
7553            .all(|colors| colors[0] != colors[1]));
7554
7555        let mut terminal =
7556            Terminal::new(ratatui::backend::TestBackend::new(80, 2)).expect("test terminal");
7557        let state = UiState::from_history(&[], "secret", "model", None, false);
7558        let mut state = state;
7559        state.subagents.push(SubagentTask {
7560            call_id: "call-worker".to_owned(),
7561            task_id: Some("subagent-1".to_owned()),
7562            task: "Inspect".to_owned(),
7563            model: None,
7564            effort: None,
7565            status: SubagentStatus::Running,
7566            result: None,
7567            creation_completed: true,
7568            stream: Vec::new(),
7569            stream_chars: 0,
7570        });
7571        terminal
7572            .draw(|frame| draw_subagent_list(frame, &state, Rect::new(0, 0, 80, 2)))
7573            .expect("draw subagent list");
7574        assert_eq!(terminal.backend().buffer()[(0, 0)].fg, SUBAGENT_TITLE_COLOR);
7575        assert_eq!(terminal.backend().buffer()[(0, 1)].fg, SUBAGENT_TITLE_COLOR);
7576        assert_eq!(
7577            terminal.backend().buffer()[(2, 1)].fg,
7578            subagent_id_color("subagent-1")
7579        );
7580        assert_eq!(terminal.backend().buffer()[(0, 1)].bg, Color::Reset);
7581    }
7582
7583    #[test]
7584    fn clipped_subagent_list_keeps_the_focused_running_worker_visible() {
7585        let mut state = UiState::from_history(&[], "secret", "model", None, false);
7586        for (index, status) in [
7587            SubagentStatus::Queued,
7588            SubagentStatus::Running,
7589            SubagentStatus::Running,
7590            SubagentStatus::Failed,
7591            SubagentStatus::Running,
7592        ]
7593        .into_iter()
7594        .enumerate()
7595        {
7596            state.subagents.push(SubagentTask {
7597                call_id: format!("call-{index}"),
7598                task_id: Some(format!("subagent-{index}")),
7599                task: format!("task-{index}"),
7600                model: None,
7601                effort: None,
7602                status,
7603                result: None,
7604                creation_completed: status == SubagentStatus::Running,
7605                stream: vec![SubagentStreamItem::Assistant(format!("output-{index}"))],
7606                stream_chars: 8,
7607            });
7608        }
7609        state.subagent_focus = Some(4);
7610
7611        let mut terminal =
7612            Terminal::new(ratatui::backend::TestBackend::new(60, 4)).expect("test terminal");
7613        terminal
7614            .draw(|frame| draw_subagent_list(frame, &state, Rect::new(0, 0, 60, 2)))
7615            .expect("draw clipped subagent list");
7616
7617        let buffer = terminal.backend().buffer();
7618        let rows = (0..2)
7619            .map(|y| (0..60).map(|x| buffer[(x, y)].symbol()).collect::<String>())
7620            .collect::<Vec<_>>();
7621        assert!(rows[0].contains("Subagents"));
7622        assert!(rows[1].contains("subagent-4"));
7623        assert!(buffer[(2, 1)].modifier.contains(Modifier::BOLD));
7624
7625        terminal
7626            .draw(|frame| draw_subagent_stream_overlay(frame, &state, Rect::new(0, 0, 60, 3)))
7627            .expect("draw focused worker overlay");
7628        let screen = terminal
7629            .backend()
7630            .buffer()
7631            .content()
7632            .iter()
7633            .map(|cell| cell.symbol())
7634            .collect::<String>();
7635        assert!(screen.contains("output-4"));
7636    }
7637
7638    #[test]
7639    fn focused_subagent_renders_live_stream_in_picker_slot() {
7640        let mut state = UiState::from_history(&[], "secret", "model", None, false);
7641        state.subagents.push(SubagentTask {
7642            call_id: "call-worker".to_owned(),
7643            task_id: Some("subagent-1".to_owned()),
7644            task: "Inspect".to_owned(),
7645            model: None,
7646            effort: None,
7647            status: SubagentStatus::Running,
7648            result: None,
7649            creation_completed: true,
7650            stream: Vec::new(),
7651            stream_chars: 0,
7652        });
7653        state.apply_subagent_activity(SubagentActivity::Event {
7654            task_id: "subagent-1".to_owned(),
7655            event: ProtocolEvent::AssistantDelta {
7656                text: "worker output".to_owned(),
7657            },
7658        });
7659        assert!(state.focus_subagent_list_from_input());
7660        let area = tui_viewport(Rect::new(0, 0, 80, 30));
7661        let (_, picker, stream, _, input, _) = ui_layout(&state, area);
7662        let stream = stream.expect("stream overlay");
7663        assert_eq!(stream.height, SUBAGENT_STREAM_PREVIEW_HEIGHT);
7664        assert_eq!(stream.y + stream.height, input.y);
7665        assert!(
7666            picker.is_none(),
7667            "focused worker replaces the skill overlay slot"
7668        );
7669
7670        let mut terminal =
7671            Terminal::new(ratatui::backend::TestBackend::new(80, 30)).expect("test terminal");
7672        terminal.draw(|frame| draw(frame, &state)).expect("draw");
7673        let screen = terminal
7674            .backend()
7675            .buffer()
7676            .content()
7677            .iter()
7678            .map(|cell| cell.symbol())
7679            .collect::<String>();
7680        assert!(screen.contains("worker output"));
7681        let buffer = terminal.backend().buffer();
7682        for y in stream.y..stream.y + stream.height {
7683            for x in [
7684                stream.x,
7685                stream.x + 1,
7686                stream.x + stream.width - 2,
7687                stream.x + stream.width - 1,
7688            ] {
7689                assert_eq!(buffer[(x, y)].symbol(), " ");
7690                assert_eq!(buffer[(x, y)].bg, SUBAGENT_OVERLAY_BACKGROUND);
7691            }
7692        }
7693        for x in stream.x..stream.x + stream.width {
7694            assert_eq!(buffer[(x, stream.y)].symbol(), " ");
7695            assert_eq!(buffer[(x, stream.y + stream.height - 1)].symbol(), " ");
7696        }
7697        assert_eq!(buffer[(stream.x + 2, stream.y + 1)].symbol(), "w");
7698        assert_eq!(buffer[(stream.x + 2, stream.y + 1)].fg, Color::Reset);
7699
7700        let narrow_input = Rect::new(0, 4, 80, 2);
7701        assert_eq!(
7702            subagent_stream_overlay_area(&state, narrow_input, 0),
7703            Some(Rect::new(0, 0, 80, 4)),
7704            "only a terminal without 15 rows of space may shrink the preview"
7705        );
7706    }
7707
7708    #[test]
7709    fn subagent_preview_reuses_normalized_events_and_keeps_the_message_stream() {
7710        let mut state = UiState::from_history(&[], "secret", "model", None, false);
7711        state.subagents.push(SubagentTask {
7712            call_id: "call-worker".to_owned(),
7713            task_id: Some("subagent-1".to_owned()),
7714            task: "Inspect".to_owned(),
7715            model: None,
7716            effort: None,
7717            status: SubagentStatus::Running,
7718            result: None,
7719            creation_completed: true,
7720            stream: Vec::new(),
7721            stream_chars: 0,
7722        });
7723
7724        state.apply_subagent_activity(SubagentActivity::Event {
7725            task_id: "subagent-1".to_owned(),
7726            event: ProtocolEvent::AssistantDelta {
7727                text: "first message ".to_owned(),
7728            },
7729        });
7730        state.apply_subagent_activity(SubagentActivity::Event {
7731            task_id: "subagent-1".to_owned(),
7732            event: ProtocolEvent::AssistantDelta {
7733                text: "continued message".to_owned(),
7734            },
7735        });
7736        state.apply_subagent_activity(SubagentActivity::Event {
7737            task_id: "subagent-1".to_owned(),
7738            event: ProtocolEvent::ToolCall {
7739                id: "call-cmd".to_owned(),
7740                name: "cmd".to_owned(),
7741                arguments: serde_json::json!({"command":"pwd"}).to_string(),
7742            },
7743        });
7744        state.apply_subagent_activity(SubagentActivity::Event {
7745            task_id: "subagent-1".to_owned(),
7746            event: ProtocolEvent::ToolResult {
7747                id: "call-cmd".to_owned(),
7748                name: "cmd".to_owned(),
7749                result: serde_json::json!({"stdout":"command output","stderr":""}),
7750            },
7751        });
7752
7753        let task = &state.subagents[0];
7754        let lines = subagent_stream_lines(task, 80, &state);
7755        let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
7756        assert!(rendered.contains("first message continued message"));
7757        assert!(rendered.contains("cmd  $ pwd"));
7758        let expected = vec![
7759            TranscriptItem::Assistant("first message continued message".to_owned()),
7760            TranscriptItem::ToolCall {
7761                id: "call-cmd".to_owned(),
7762                name: "cmd".to_owned(),
7763                arguments: serde_json::json!({"command":"pwd"}).to_string(),
7764            },
7765            TranscriptItem::ToolResult {
7766                id: "call-cmd".to_owned(),
7767                name: "cmd".to_owned(),
7768                result: serde_json::json!({"stdout":"command output","stderr":""}),
7769            },
7770        ];
7771        assert_eq!(
7772            lines,
7773            render_transcript_items(&expected, 80, &state, true),
7774            "worker events use the same transcript-item renderer as the main stream"
7775        );
7776        assert_eq!(
7777            task.stream
7778                .iter()
7779                .filter(|item| matches!(item, SubagentStreamItem::Assistant(_)))
7780                .count(),
7781            1,
7782            "assistant deltas remain one message in the preview"
7783        );
7784    }
7785
7786    #[test]
7787    fn oversized_subagent_assistant_stream_keeps_the_latest_tail() {
7788        let mut state = UiState::from_history(&[], "secret", "model", None, false);
7789        state.subagents.push(SubagentTask {
7790            call_id: "call-worker".to_owned(),
7791            task_id: Some("subagent-1".to_owned()),
7792            task: "Inspect".to_owned(),
7793            model: None,
7794            effort: None,
7795            status: SubagentStatus::Running,
7796            result: None,
7797            creation_completed: true,
7798            stream: vec![SubagentStreamItem::User("Inspect".to_owned())],
7799            stream_chars: "Inspect".chars().count(),
7800        });
7801        let tail = "latest assistant output";
7802        state.apply_subagent_activity(SubagentActivity::Event {
7803            task_id: "subagent-1".to_owned(),
7804            event: ProtocolEvent::AssistantDelta {
7805                text: format!("{}{}", "x".repeat(SUBAGENT_STREAM_MAX_CHARS), tail),
7806            },
7807        });
7808
7809        let task = &state.subagents[0];
7810        assert_eq!(task.stream_chars, SUBAGENT_STREAM_MAX_CHARS);
7811        assert!(matches!(
7812            task.stream.as_slice(),
7813            [SubagentStreamItem::Assistant(text)] if text.starts_with('…') && text.ends_with(tail)
7814        ));
7815        let rendered = subagent_stream_lines(task, 80, &state)
7816            .iter()
7817            .map(line_text)
7818            .collect::<Vec<_>>()
7819            .join("\n");
7820        assert!(rendered.contains(tail));
7821    }
7822
7823    #[test]
7824    fn subagent_stream_trimming_keeps_the_tail_across_items() {
7825        let mut state = UiState::from_history(&[], "secret", "model", None, false);
7826        let earlier = "a".repeat(6_000);
7827        let latest = "b".repeat(7_000);
7828        state.subagents.push(SubagentTask {
7829            call_id: "call-worker".to_owned(),
7830            task_id: Some("subagent-1".to_owned()),
7831            task: "Inspect".to_owned(),
7832            model: None,
7833            effort: None,
7834            status: SubagentStatus::Running,
7835            result: None,
7836            creation_completed: true,
7837            stream: vec![SubagentStreamItem::User(earlier)],
7838            stream_chars: 6_000,
7839        });
7840        state.apply_subagent_activity(SubagentActivity::Event {
7841            task_id: "subagent-1".to_owned(),
7842            event: ProtocolEvent::AssistantDelta {
7843                text: latest.clone(),
7844            },
7845        });
7846
7847        let task = &state.subagents[0];
7848        assert_eq!(task.stream_chars, SUBAGENT_STREAM_MAX_CHARS);
7849        assert!(matches!(
7850            task.stream.as_slice(),
7851            [SubagentStreamItem::User(prefix), SubagentStreamItem::Assistant(text)]
7852                if prefix.starts_with('…') && prefix.ends_with('a') && text == &latest
7853        ));
7854    }
7855
7856    #[test]
7857    fn subagent_stream_trimming_marks_a_wholly_evicted_item() {
7858        let mut state = UiState::from_history(&[], "secret", "model", None, false);
7859        let latest = "b".repeat(SUBAGENT_STREAM_MAX_CHARS - 1);
7860        state.subagents.push(SubagentTask {
7861            call_id: "call-worker".to_owned(),
7862            task_id: Some("subagent-1".to_owned()),
7863            task: "Inspect".to_owned(),
7864            model: None,
7865            effort: None,
7866            status: SubagentStatus::Running,
7867            result: None,
7868            creation_completed: true,
7869            stream: vec![SubagentStreamItem::User("a".repeat(8))],
7870            stream_chars: 8,
7871        });
7872        state.apply_subagent_activity(SubagentActivity::Event {
7873            task_id: "subagent-1".to_owned(),
7874            event: ProtocolEvent::AssistantDelta {
7875                text: latest.clone(),
7876            },
7877        });
7878
7879        let task = &state.subagents[0];
7880        assert_eq!(task.stream_chars, SUBAGENT_STREAM_MAX_CHARS);
7881        assert!(matches!(
7882            task.stream.as_slice(),
7883            [SubagentStreamItem::Assistant(marker), SubagentStreamItem::Assistant(text)]
7884                if marker == "…" && text == &latest
7885        ));
7886    }
7887
7888    #[test]
7889    fn oversized_subagent_tool_result_keeps_structured_truncation_marker() {
7890        let mut state = UiState::from_history(&[], "secret", "model", None, false);
7891        state.subagents.push(SubagentTask {
7892            call_id: "call-worker".to_owned(),
7893            task_id: Some("subagent-1".to_owned()),
7894            task: "Inspect".to_owned(),
7895            model: None,
7896            effort: None,
7897            status: SubagentStatus::Running,
7898            result: None,
7899            creation_completed: true,
7900            stream: vec![SubagentStreamItem::User("Inspect".to_owned())],
7901            stream_chars: "Inspect".chars().count(),
7902        });
7903        state.apply_subagent_activity(SubagentActivity::Event {
7904            task_id: "subagent-1".to_owned(),
7905            event: ProtocolEvent::ToolResult {
7906                id: "call-cmd".to_owned(),
7907                name: "cmd".to_owned(),
7908                result: serde_json::json!({
7909                    "stdout": "x".repeat(SUBAGENT_STREAM_MAX_CHARS),
7910                    "zz_raw_marker": "must stay structured"
7911                }),
7912            },
7913        });
7914
7915        let task = &state.subagents[0];
7916        assert_eq!(task.stream_chars, 1);
7917        assert!(matches!(
7918            task.stream.as_slice(),
7919            [SubagentStreamItem::Assistant(text)] if text == "…"
7920        ));
7921        let rendered = subagent_stream_lines(task, 80, &state)
7922            .iter()
7923            .map(line_text)
7924            .collect::<Vec<_>>()
7925            .join("\n");
7926        assert_eq!(rendered, "…");
7927        assert!(!rendered.contains("zz_raw_marker"));
7928    }
7929
7930    #[test]
7931    fn oversized_subagent_tool_call_keeps_structured_truncation_marker() {
7932        let mut state = UiState::from_history(&[], "secret", "model", None, false);
7933        state.subagents.push(SubagentTask {
7934            call_id: "call-worker".to_owned(),
7935            task_id: Some("subagent-1".to_owned()),
7936            task: "Inspect".to_owned(),
7937            model: None,
7938            effort: None,
7939            status: SubagentStatus::Running,
7940            result: None,
7941            creation_completed: true,
7942            stream: vec![SubagentStreamItem::User("Inspect".to_owned())],
7943            stream_chars: "Inspect".chars().count(),
7944        });
7945        state.apply_subagent_activity(SubagentActivity::Event {
7946            task_id: "subagent-1".to_owned(),
7947            event: ProtocolEvent::ToolCall {
7948                id: "call-cmd".to_owned(),
7949                name: "cmd".to_owned(),
7950                arguments: format!(
7951                    r#"{{"command":"{}","zz_raw_marker":"must stay structured"}}"#,
7952                    "x".repeat(SUBAGENT_STREAM_MAX_CHARS)
7953                ),
7954            },
7955        });
7956
7957        let task = &state.subagents[0];
7958        assert_eq!(task.stream_chars, 1);
7959        assert!(matches!(
7960            task.stream.as_slice(),
7961            [SubagentStreamItem::Assistant(text)] if text == "…"
7962        ));
7963        let rendered = subagent_stream_lines(task, 80, &state)
7964            .iter()
7965            .map(line_text)
7966            .collect::<Vec<_>>()
7967            .join("\n");
7968        assert_eq!(rendered, "…");
7969        assert!(!rendered.contains("zz_raw_marker"));
7970    }
7971
7972    #[test]
7973    fn empty_subagent_stream_shows_a_waiting_placeholder() {
7974        let state = UiState::from_history(&[], "secret", "model", None, false);
7975        let task = SubagentTask {
7976            call_id: "call-worker".to_owned(),
7977            task_id: Some("subagent-1".to_owned()),
7978            task: "Inspect".to_owned(),
7979            model: None,
7980            effort: None,
7981            status: SubagentStatus::Running,
7982            result: None,
7983            creation_completed: true,
7984            stream: Vec::new(),
7985            stream_chars: 0,
7986        };
7987
7988        assert_eq!(
7989            subagent_stream_lines(&task, 80, &state)
7990                .iter()
7991                .map(line_text)
7992                .collect::<Vec<_>>(),
7993            ["waiting for worker output"]
7994        );
7995    }
7996
7997    #[test]
7998    fn subagent_preview_replays_activity_that_arrives_before_spawn_acknowledgement() {
7999        let mut state = UiState::from_history(&[], "secret", "model", None, false);
8000        state.add_tool_call(&crate::model::ChatToolCall {
8001            id: "call-worker".to_owned(),
8002            name: "spawn_subagent".to_owned(),
8003            arguments: serde_json::json!({"task":"Inspect"}).to_string(),
8004        });
8005
8006        state.apply_subagent_activity(SubagentActivity::Event {
8007            task_id: "subagent-1".to_owned(),
8008            event: ProtocolEvent::AssistantDelta {
8009                text: "early worker output".to_owned(),
8010            },
8011        });
8012        assert_eq!(state.pending_subagent_activities.len(), 1);
8013
8014        state.add_live_tool_result(
8015            "call-worker",
8016            "spawn_subagent",
8017            serde_json::json!({"task_id":"subagent-1","status":"queued"}),
8018        );
8019        assert!(state.pending_subagent_activities.is_empty());
8020        let visible = subagent_stream_lines(&state.subagents[0], 80, &state)
8021            .iter()
8022            .map(line_text)
8023            .collect::<Vec<_>>()
8024            .join("\n");
8025        assert!(visible.contains("early worker output"));
8026    }
8027
8028    #[test]
8029    fn subagent_preview_stays_pinned_to_the_latest_stream_lines() {
8030        let mut state = UiState::from_history(&[], "secret", "model", None, false);
8031        state.subagents.push(SubagentTask {
8032            call_id: "call-worker".to_owned(),
8033            task_id: Some("subagent-1".to_owned()),
8034            task: "Inspect".to_owned(),
8035            model: None,
8036            effort: None,
8037            status: SubagentStatus::Running,
8038            result: None,
8039            creation_completed: true,
8040            stream: Vec::new(),
8041            stream_chars: 0,
8042        });
8043
8044        for index in 0..10 {
8045            state.apply_subagent_activity(SubagentActivity::Event {
8046                task_id: "subagent-1".to_owned(),
8047                event: ProtocolEvent::ToolResult {
8048                    id: format!("call-{index}"),
8049                    name: "cmd".to_owned(),
8050                    result: serde_json::json!({"stdout": format!("output-{index}")}),
8051                },
8052            });
8053        }
8054
8055        let visible = latest_subagent_stream_lines(&state.subagents[0], 80, &state);
8056        assert!(visible
8057            .iter()
8058            .any(|line| line_text(line).contains("output-0")));
8059        assert!(visible
8060            .last()
8061            .is_some_and(|line| line_text(line).contains("output-9")));
8062
8063        state.apply_subagent_activity(SubagentActivity::Event {
8064            task_id: "subagent-1".to_owned(),
8065            event: ProtocolEvent::AssistantDelta {
8066                text: "newest live message".to_owned(),
8067            },
8068        });
8069        let visible = latest_subagent_stream_lines(&state.subagents[0], 80, &state);
8070        assert!(visible
8071            .last()
8072            .is_some_and(|line| line_text(line).contains("newest live message")));
8073
8074        state.subagent_focus = Some(0);
8075        let mut terminal =
8076            Terminal::new(ratatui::backend::TestBackend::new(80, 15)).expect("test terminal");
8077        terminal
8078            .draw(|frame| draw_subagent_stream_overlay(frame, &state, Rect::new(0, 0, 80, 15)))
8079            .expect("draw clipped worker overlay");
8080        let buffer = terminal.backend().buffer();
8081        let inner_rows = (1..14)
8082            .map(|y| (2..78).map(|x| buffer[(x, y)].symbol()).collect::<String>())
8083            .collect::<Vec<_>>();
8084        assert!(inner_rows
8085            .iter()
8086            .any(|row| row.contains("newest live message")));
8087        assert!(!inner_rows.iter().any(|row| row.contains("output-0")));
8088    }
8089
8090    #[test]
8091    fn subagent_preview_shows_reasoning_state_and_initial_task_message() {
8092        let mut state = UiState::from_history(&[], "secret", "model", None, false);
8093        state.subagents.push(SubagentTask {
8094            call_id: "call-worker".to_owned(),
8095            task_id: Some("subagent-1".to_owned()),
8096            task: "Inspect".to_owned(),
8097            model: None,
8098            effort: None,
8099            status: SubagentStatus::Running,
8100            result: None,
8101            creation_completed: true,
8102            stream: vec![SubagentStreamItem::User("Inspect".to_owned())],
8103            stream_chars: 7,
8104        });
8105
8106        state.apply_subagent_activity(SubagentActivity::ReasoningStarted {
8107            task_id: "subagent-1".to_owned(),
8108        });
8109        let before = subagent_stream_lines(&state.subagents[0], 80, &state)
8110            .iter()
8111            .map(line_text)
8112            .collect::<Vec<_>>()
8113            .join("\n");
8114        assert!(before.contains("Inspect"));
8115        assert!(before.contains("Reasoning"));
8116
8117        state.apply_subagent_activity(SubagentActivity::ReasoningCompleted {
8118            task_id: "subagent-1".to_owned(),
8119        });
8120        let after = subagent_stream_lines(&state.subagents[0], 80, &state)
8121            .iter()
8122            .map(line_text)
8123            .collect::<Vec<_>>()
8124            .join("\n");
8125        assert!(after.contains("Reasoning Complete"));
8126    }
8127
8128    #[test]
8129    fn down_from_last_input_row_prioritizes_subagent_list_over_skill_picker() {
8130        let mut state = UiState::from_history(&[], "secret", "model", None, false)
8131            .with_skill_names(vec!["settings".to_owned()]);
8132        state.input = "/".to_owned();
8133        state.input_changed();
8134        state.subagents.push(SubagentTask {
8135            call_id: "call-one".to_owned(),
8136            task_id: Some("one".to_owned()),
8137            task: "one".to_owned(),
8138            model: None,
8139            effort: None,
8140            status: SubagentStatus::Running,
8141            result: None,
8142            creation_completed: true,
8143            stream: Vec::new(),
8144            stream_chars: 0,
8145        });
8146
8147        assert!(state.skill_picker_visible());
8148        assert!(move_down_from_input(&mut state, 20));
8149        assert_eq!(state.subagent_focus, Some(0));
8150        assert_eq!(state.skill_picker_focus, 0);
8151        assert!(subagent_stream_overlay_area(&state, Rect::new(0, 4, 80, 2), 0).is_some());
8152        assert!(move_up_from_input_or_subagent(&mut state, 20));
8153        assert_eq!(state.subagent_focus, None);
8154        assert_eq!(state.skill_picker_focus, 0);
8155    }
8156
8157    #[test]
8158    fn subagent_focus_moves_from_prompt_to_list_on_down_and_returns_on_up() {
8159        let mut state = UiState::from_history(&[], "secret", "model", None, false);
8160        state.input = "prompt".to_owned();
8161        state.cursor = state.input.chars().count();
8162        for (call_id, task_id) in [("call-one", "one"), ("call-two", "two")] {
8163            state.subagents.push(SubagentTask {
8164                call_id: call_id.to_owned(),
8165                task_id: Some(task_id.to_owned()),
8166                task: task_id.to_owned(),
8167                model: None,
8168                effort: None,
8169                status: SubagentStatus::Running,
8170                result: None,
8171                creation_completed: true,
8172                stream: Vec::new(),
8173                stream_chars: 0,
8174            });
8175        }
8176
8177        assert_eq!(input_cursor_row(&state.input, state.cursor, 20), 0);
8178        assert!(state.focus_subagent_list_from_input());
8179        assert_eq!(state.subagent_focus, Some(0));
8180        assert!(state.move_subagent_focus(false));
8181        assert_eq!(
8182            state.subagent_focus, None,
8183            "Up from the first row returns to the prompt"
8184        );
8185
8186        assert!(state.focus_subagent_list_from_input());
8187        assert!(state.move_subagent_focus(true));
8188        assert_eq!(
8189            state.subagent_focus,
8190            Some(1),
8191            "Down advances through the list"
8192        );
8193        assert!(state.move_subagent_focus(false));
8194        assert_eq!(state.subagent_focus, Some(0));
8195    }
8196
8197    #[test]
8198    fn subagent_lifecycle_tool_cards_are_suppressed_from_the_transcript() {
8199        let history = vec![
8200            SessionHistoryRecord::Message {
8201                timestamp: 1,
8202                message: ChatMessage::assistant(
8203                    String::new(),
8204                    vec![crate::model::ChatToolCall {
8205                        id: "call-worker".to_owned(),
8206                        name: "spawn_subagent".to_owned(),
8207                        arguments: serde_json::json!({"task":"Inspect"}).to_string(),
8208                    }],
8209                ),
8210            },
8211            SessionHistoryRecord::Message {
8212                timestamp: 2,
8213                message: ChatMessage::tool(
8214                    "call-worker".to_owned(),
8215                    "spawn_subagent".to_owned(),
8216                    serde_json::json!({"task_id":"subagent-1","status":"queued"}).to_string(),
8217                ),
8218            },
8219            SessionHistoryRecord::Message {
8220                timestamp: 3,
8221                message: ChatMessage::assistant(
8222                    String::new(),
8223                    vec![crate::model::ChatToolCall {
8224                        id: "call-check".to_owned(),
8225                        name: "check_subagent".to_owned(),
8226                        arguments: serde_json::json!({"task_id":"subagent-1"}).to_string(),
8227                    }],
8228                ),
8229            },
8230            SessionHistoryRecord::Message {
8231                timestamp: 4,
8232                message: ChatMessage::tool(
8233                    "call-check".to_owned(),
8234                    "check_subagent".to_owned(),
8235                    serde_json::json!({"task_id":"subagent-1","status":"running"}).to_string(),
8236                ),
8237            },
8238        ];
8239        let state = UiState::from_history(&history, "secret", "model", None, false);
8240        assert_eq!(state.subagents.len(), 1);
8241        assert!(state.transcript.is_empty());
8242        assert_eq!(transcript_lines(&state, 100)[0].to_string(), "");
8243    }
8244
8245    #[test]
8246    fn suppressed_lifecycle_tools_do_not_leave_transcript_spacing() {
8247        for name in [
8248            "spawn_subagent",
8249            "check_subagent",
8250            "wait_subagent",
8251            "send_subagent",
8252            "cancel_subagent",
8253        ] {
8254            let state = UiState {
8255                transcript: vec![
8256                    TranscriptItem::Assistant("before".to_owned()),
8257                    TranscriptItem::ToolCall {
8258                        id: "call".to_owned(),
8259                        name: name.to_owned(),
8260                        arguments: "{}".to_owned(),
8261                    },
8262                    TranscriptItem::ToolResult {
8263                        id: "call".to_owned(),
8264                        name: name.to_owned(),
8265                        result: serde_json::json!({"status":"running"}),
8266                    },
8267                    TranscriptItem::Assistant("after".to_owned()),
8268                ],
8269                ..UiState::from_history(&[], "secret", "model", None, false)
8270            };
8271            let lines = transcript_lines(&state, 80)
8272                .iter()
8273                .map(Line::to_string)
8274                .collect::<Vec<_>>();
8275            assert_eq!(lines, ["before", "", "after"], "{name}");
8276        }
8277    }
8278
8279    #[test]
8280    fn subagent_lifecycle_actions_annotate_the_running_list() {
8281        let mut state = UiState::from_history(&[], "secret", "model", None, false);
8282        state.subagents.push(SubagentTask {
8283            call_id: "call-worker".to_owned(),
8284            task_id: Some("subagent-1".to_owned()),
8285            task: "Inspect".to_owned(),
8286            model: None,
8287            effort: None,
8288            status: SubagentStatus::Running,
8289            result: None,
8290            creation_completed: true,
8291            stream: Vec::new(),
8292            stream_chars: 0,
8293        });
8294
8295        let call = |id: &str, name: &str| crate::model::ChatToolCall {
8296            id: id.to_owned(),
8297            name: name.to_owned(),
8298            arguments: serde_json::json!({"task_id":"subagent-1"}).to_string(),
8299        };
8300        state.add_live_tool_call(&call("check", "check_subagent"));
8301        assert!(matches!(
8302            state.subagent_list_notice_at("subagent-1", Instant::now()),
8303            Some(SubagentListNotice::Flash { .. })
8304        ));
8305        state.add_live_tool_result(
8306            "check",
8307            "check_subagent",
8308            serde_json::json!({"task_id":"subagent-1","status":"running"}),
8309        );
8310
8311        state.add_live_tool_call(&call("wait", "wait_subagent"));
8312        assert!(matches!(
8313            state.subagent_list_notice_at("subagent-1", Instant::now()),
8314            Some(SubagentListNotice::Waiting)
8315        ));
8316        let mut terminal =
8317            Terminal::new(ratatui::backend::TestBackend::new(80, 2)).expect("test terminal");
8318        terminal
8319            .draw(|frame| draw_subagent_list(frame, &state, Rect::new(0, 0, 80, 2)))
8320            .expect("draw waiting worker");
8321        let screen = terminal
8322            .backend()
8323            .buffer()
8324            .content()
8325            .iter()
8326            .map(|cell| cell.symbol())
8327            .collect::<String>();
8328        assert!(screen.contains("Waiting for subagent-1"));
8329        state.add_live_tool_result(
8330            "wait",
8331            "wait_subagent",
8332            serde_json::json!({"task_id":"subagent-1","status":"waiting","timed_out":true}),
8333        );
8334        assert!(state
8335            .subagent_list_notice_at("subagent-1", Instant::now())
8336            .is_none());
8337
8338        state.add_live_tool_call(&call("send", "send_subagent"));
8339        assert!(matches!(
8340            state.subagent_list_notice_at("subagent-1", Instant::now()),
8341            Some(SubagentListNotice::Flash { .. })
8342        ));
8343        state.add_live_tool_result(
8344            "send",
8345            "send_subagent",
8346            serde_json::json!({"task_id":"subagent-1","status":"queued"}),
8347        );
8348        state.add_live_tool_call(&call("cancel", "cancel_subagent"));
8349        state.add_live_tool_result(
8350            "cancel",
8351            "cancel_subagent",
8352            serde_json::json!({"task_id":"subagent-1","status":"cancellation_requested"}),
8353        );
8354        assert!(matches!(
8355            state.subagent_list_notice_at("subagent-1", Instant::now()),
8356            Some(SubagentListNotice::Cancelling)
8357        ));
8358    }
8359
8360    #[test]
8361    fn cancelling_notice_survives_other_lifecycle_results_until_terminal() {
8362        let mut state = UiState::from_history(&[], "secret", "model", None, false);
8363        state.subagents.push(SubagentTask {
8364            call_id: "call-worker".to_owned(),
8365            task_id: Some("subagent-1".to_owned()),
8366            task: "Inspect".to_owned(),
8367            model: None,
8368            effort: None,
8369            status: SubagentStatus::Running,
8370            result: None,
8371            creation_completed: true,
8372            stream: Vec::new(),
8373            stream_chars: 0,
8374        });
8375        for (id, name) in [("check", "check_subagent"), ("cancel", "cancel_subagent")] {
8376            state.add_live_tool_call(&crate::model::ChatToolCall {
8377                id: id.to_owned(),
8378                name: name.to_owned(),
8379                arguments: serde_json::json!({"task_id":"subagent-1"}).to_string(),
8380            });
8381        }
8382        state.add_live_tool_result(
8383            "check",
8384            "check_subagent",
8385            serde_json::json!({"task_id":"subagent-1","status":"failed"}),
8386        );
8387        assert!(matches!(
8388            state.subagent_list_notice_at("subagent-1", Instant::now()),
8389            Some(SubagentListNotice::Cancelling)
8390        ));
8391        state.add_live_tool_result(
8392            "cancel",
8393            "cancel_subagent",
8394            serde_json::json!({"task_id":"subagent-1","status":"cancellation_requested"}),
8395        );
8396        assert!(matches!(
8397            state.subagent_list_notice_at("subagent-1", Instant::now()),
8398            Some(SubagentListNotice::Cancelling)
8399        ));
8400
8401        state.complete_subagent("subagent-1", serde_json::json!({"cancelled":true}));
8402        assert!(state
8403            .subagent_list_notice_at("subagent-1", Instant::now())
8404            .is_none());
8405    }
8406
8407    #[test]
8408    fn failed_or_unknown_subagent_actions_remain_transcript_errors() {
8409        let mut state = UiState::from_history(&[], "secret", "model", None, false);
8410        state.add_live_tool_call(&crate::model::ChatToolCall {
8411            id: "check-unknown".to_owned(),
8412            name: "check_subagent".to_owned(),
8413            arguments: serde_json::json!({"task_id":"unknown"}).to_string(),
8414        });
8415        let result = serde_json::json!({"task_id":"unknown","status":"unknown"});
8416        assert!(subagent_tool_result_is_error(&result));
8417        state.add_live_tool_result("check-unknown", "check_subagent", result);
8418        assert!(
8419            matches!(
8420                state.transcript.as_slice(),
8421                [TranscriptItem::Error(message)] if message.contains("unknown")
8422            ),
8423            "{:?}",
8424            state.transcript
8425        );
8426    }
8427
8428    #[test]
8429    fn clipped_slash_picker_uses_its_actual_item_rows_for_the_focused_item() {
8430        let mut state = UiState::from_history(&[], "secret", "model", None, false)
8431            .with_skill_names(
8432                ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
8433                    .into_iter()
8434                    .map(str::to_owned)
8435                    .collect(),
8436            );
8437        state.input = "/".to_owned();
8438        state.input_changed();
8439        state.skill_picker_focus = 5;
8440        let mut terminal =
8441            Terminal::new(ratatui::backend::TestBackend::new(30, 5)).expect("test terminal");
8442        terminal
8443            .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 5)))
8444            .expect("draw clipped skill picker");
8445
8446        let buffer = terminal.backend().buffer();
8447        let item_rows = (2..4)
8448            .map(|y| (2..28).map(|x| buffer[(x, y)].symbol()).collect::<String>())
8449            .collect::<Vec<_>>();
8450        assert!(item_rows[0].starts_with("/deploy"));
8451        assert!(item_rows[1].starts_with("/doctor"));
8452        assert_eq!(buffer[(2, 3)].fg, QUEUED_MESSAGE_COLOR);
8453        assert!(buffer[(2, 3)].modifier.contains(Modifier::BOLD));
8454    }
8455}
8456
8457#[cfg(test)]
8458mod skill_picker_tests {
8459    use super::*;
8460
8461    fn skill_names() -> Vec<String> {
8462        ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
8463            .into_iter()
8464            .map(str::to_owned)
8465            .collect()
8466    }
8467
8468    #[test]
8469    fn built_in_commands_share_the_slash_catalog_without_becoming_skills() {
8470        assert_eq!(
8471            command_names(vec!["release-notes".to_owned(), "settings".to_owned()]),
8472            vec!["exit", "release-notes", "settings"]
8473        );
8474        assert_eq!(
8475            builtin_command("/settings ignored arguments"),
8476            Some(BuiltinCommand::Settings)
8477        );
8478        assert_eq!(builtin_command("  /exit  "), Some(BuiltinCommand::Exit));
8479        assert_eq!(builtin_command("/settings-extra"), None);
8480    }
8481
8482    #[test]
8483    fn settings_viewport_follows_focus_instead_of_truncating_the_catalog_head() {
8484        assert_eq!(selection_range(30, 0, 12), 0..12);
8485        assert_eq!(selection_range(30, 11, 12), 0..12);
8486        assert_eq!(selection_range(30, 12, 12), 1..13);
8487        assert_eq!(selection_range(30, 29, 12), 18..30);
8488    }
8489
8490    #[test]
8491    fn model_selection_uses_advertised_efforts_and_preserves_the_current_choice() {
8492        let mut state = UiState::from_history(&[], "secret", "old", Some("medium"), false);
8493        state.open_catalog(Ok(vec![ProviderModel {
8494            id: "openai/gpt-5.6-sol".to_owned(),
8495            efforts: Some(vec![
8496                "max".to_owned(),
8497                "high".to_owned(),
8498                "medium".to_owned(),
8499                "low".to_owned(),
8500            ]),
8501        }]));
8502        state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
8503
8504        let SettingsState::Effort { model, focus, .. } =
8505            state.settings.as_ref().expect("effort picker")
8506        else {
8507            panic!("model selection should open the effort picker");
8508        };
8509        assert_eq!(model.id, "openai/gpt-5.6-sol");
8510        assert_eq!(*focus, 3, "default occupies index zero before medium");
8511
8512        let selected = state
8513            .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
8514            .expect("effort selection");
8515        assert_eq!(
8516            selected,
8517            ("openai/gpt-5.6-sol".to_owned(), Some("medium".to_owned()))
8518        );
8519    }
8520
8521    #[test]
8522    fn effort_default_selection_does_not_shift_to_the_first_advertised_effort() {
8523        let mut state = UiState::from_history(&[], "secret", "old", None, false);
8524        state.open_catalog(Ok(vec![ProviderModel {
8525            id: "model".to_owned(),
8526            efforts: Some(vec!["high".to_owned(), "low".to_owned()]),
8527        }]));
8528        state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
8529
8530        let selected = state
8531            .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
8532            .expect("default effort selection");
8533        assert_eq!(selected, ("model".to_owned(), None));
8534    }
8535
8536    #[test]
8537    fn reasoning_indicator_changes_to_complete_and_stays_dark_gray() {
8538        let mut state = UiState::from_history(&[], "secret", "model", None, false);
8539        state.show_thinking();
8540
8541        let active_lines = transcript_lines(&state, 80);
8542        let active = active_lines.last().expect("reasoning line");
8543        assert!(active.to_string().starts_with("Reasoning... "));
8544        assert_eq!(active.style.fg, Some(Color::DarkGray));
8545
8546        state.complete_reasoning();
8547        let complete_lines = transcript_lines(&state, 80);
8548        let complete = complete_lines.last().expect("complete line");
8549        assert_eq!(complete.to_string(), "Reasoning Complete");
8550        assert_eq!(complete.style.fg, Some(Color::DarkGray));
8551    }
8552
8553    #[test]
8554    fn slash_picker_filters_only_leading_command_text_and_hides_without_matches() {
8555        let names = skill_names();
8556        assert_eq!(
8557            matching_skill_names("/", &names),
8558            vec!["alpha", "beta", "build", "charlie", "deploy", "doctor"]
8559        );
8560        assert_eq!(matching_skill_names("/b", &names), vec!["beta", "build"]);
8561        assert!(matching_skill_names("/missing", &names).is_empty());
8562        assert!(matching_skill_names("message /b", &names).is_empty());
8563        assert!(matching_skill_names("/beta arguments", &names).is_empty());
8564    }
8565
8566    #[test]
8567    fn slash_picker_focuses_the_top_match_and_moves_within_filtered_results() {
8568        let mut state = UiState::from_history(&[], "secret", "model", None, false)
8569            .with_skill_names(skill_names());
8570        state.input = "/b".to_owned();
8571        state.input_changed();
8572
8573        assert!(state.skill_picker_visible());
8574        assert_eq!(state.skill_picker_focus, 0);
8575        assert!(state.move_skill_picker(true));
8576        assert_eq!(state.skill_picker_focus, 1);
8577        assert!(state.move_skill_picker(true));
8578        assert_eq!(state.skill_picker_focus, 1, "focus does not leave the list");
8579        assert!(state.move_skill_picker(false));
8580        assert_eq!(state.skill_picker_focus, 0);
8581
8582        state.input = "/missing".to_owned();
8583        state.input_changed();
8584        assert!(!state.skill_picker_visible());
8585        assert!(!state.move_skill_picker(true));
8586    }
8587
8588    #[test]
8589    fn focused_builtins_are_distinguished_from_skills() {
8590        let mut state = UiState::from_history(&[], "secret", "model", None, false)
8591            .with_skill_names(command_names(skill_names()));
8592        state.input = "/se".to_owned();
8593        state.input_changed();
8594        assert_eq!(
8595            state.focused_builtin_command(),
8596            Some(BuiltinCommand::Settings)
8597        );
8598
8599        state.input = "/be".to_owned();
8600        state.input_changed();
8601        assert_eq!(state.focused_builtin_command(), None);
8602    }
8603
8604    #[test]
8605    fn selecting_the_focused_skill_leaves_the_completed_command_ready_to_send() {
8606        let mut state = UiState::from_history(&[], "secret", "model", None, false)
8607            .with_skill_names(skill_names());
8608        state.input = "/b".to_owned();
8609        state.input_changed();
8610        state.move_skill_picker(true);
8611
8612        assert!(state.select_focused_skill());
8613        assert_eq!(state.input, "/build");
8614        assert_eq!(state.cursor, "/build".chars().count());
8615        assert!(
8616            !state.skill_picker_visible(),
8617            "the first Enter completes the input rather than sending it"
8618        );
8619        assert!(
8620            !state.select_focused_skill(),
8621            "a second Enter follows the normal send/attachment path"
8622        );
8623    }
8624
8625    #[test]
8626    fn slash_picker_overlays_without_reflowing_the_transcript_when_match_count_changes() {
8627        let mut state = UiState::from_history(&[], "secret", "model", None, false)
8628            .with_skill_names(skill_names());
8629        let area = Rect::new(0, 0, 40, 16);
8630        state.transcript = (0..20)
8631            .map(|index| TranscriptItem::Assistant(format!("message {index}")))
8632            .collect();
8633
8634        state.input = "/a".to_owned();
8635        state.input_changed();
8636        let (narrow_chat, narrow_picker, _, _, narrow_input, _) = ui_layout(&state, area);
8637        let narrow_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
8638
8639        state.input = "/".to_owned();
8640        state.input_changed();
8641        let (broad_chat, broad_picker, _, _, broad_input, _) = ui_layout(&state, area);
8642        let broad_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
8643
8644        assert_ne!(
8645            narrow_picker, broad_picker,
8646            "the overlay may fit its contents"
8647        );
8648        assert_eq!(narrow_chat, broad_chat);
8649        assert_eq!(narrow_input, broad_input);
8650        assert_eq!(
8651            narrow_scroll, broad_scroll,
8652            "the overlay does not reduce the transcript viewport"
8653        );
8654    }
8655
8656    #[test]
8657    fn slash_picker_keeps_the_focused_item_in_its_five_row_viewport() {
8658        assert_eq!(selection_range(20, 0, 5), 0..5);
8659        assert_eq!(selection_range(20, 4, 5), 0..5);
8660        assert_eq!(selection_range(20, 5, 5), 1..6);
8661        assert_eq!(selection_range(20, 19, 5), 15..20);
8662    }
8663
8664    #[test]
8665    fn slash_picker_is_rendered_immediately_above_the_input() {
8666        let mut state = UiState::from_history(&[], "secret", "model", None, false)
8667            .with_skill_names(skill_names());
8668        state.input = "/".to_owned();
8669        state.input_changed();
8670        let mut terminal =
8671            Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
8672        terminal
8673            .draw(|frame| draw(frame, &state))
8674            .expect("draw TUI");
8675
8676        let buffer = terminal.backend().buffer();
8677        let area = tui_viewport(Rect::new(0, 0, 40, 12));
8678        let (_, picker_area, _, _, input_area, _) = ui_layout(&state, area);
8679        let picker_area = picker_area.expect("picker area");
8680        // The picker shares a boundary with the prompt; no blank row separates them.
8681        assert_eq!(picker_area.y + picker_area.height, input_area.y);
8682        for (x, y) in [
8683            (picker_area.x, picker_area.y),
8684            (picker_area.x + picker_area.width - 1, picker_area.y),
8685            (picker_area.x, picker_area.y + picker_area.height - 1),
8686            (
8687                picker_area.x + picker_area.width - 1,
8688                picker_area.y + picker_area.height - 1,
8689            ),
8690        ] {
8691            assert_eq!(buffer[(x, y)].symbol(), " ");
8692            assert_eq!(buffer[(x, y)].bg, SKILL_PICKER_BACKGROUND);
8693        }
8694        assert_eq!(
8695            buffer[(picker_area.x + 1, picker_area.y + 1)].bg,
8696            SKILL_PICKER_BACKGROUND
8697        );
8698        assert_eq!(buffer[(picker_area.x + 2, picker_area.y + 1)].symbol(), "[");
8699        assert_eq!(
8700            buffer[(picker_area.x + 2, picker_area.y + 1)].fg,
8701            QUEUED_MESSAGE_COLOR
8702        );
8703        assert_eq!(buffer[(picker_area.x + 2, picker_area.y + 2)].symbol(), "/");
8704        assert_eq!(
8705            buffer[(picker_area.x + 2, picker_area.y + 2)].fg,
8706            QUEUED_MESSAGE_COLOR
8707        );
8708        assert_eq!(
8709            buffer[(picker_area.x + 1, picker_area.y + picker_area.height - 2)].bg,
8710            SKILL_PICKER_BACKGROUND
8711        );
8712        assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), " ");
8713        assert_eq!(buffer[(input_area.x, input_area.y)].bg, CONSOLE_BACKGROUND);
8714    }
8715
8716    #[test]
8717    fn slash_picker_renders_count_with_bold_focus_on_the_picker_surface() {
8718        let mut state = UiState::from_history(&[], "secret", "model", None, false)
8719            .with_skill_names(skill_names());
8720        state.input = "/".to_owned();
8721        state.input_changed();
8722        let mut terminal =
8723            Terminal::new(ratatui::backend::TestBackend::new(30, 8)).expect("test terminal");
8724        terminal
8725            .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 8)))
8726            .expect("draw skill picker");
8727
8728        let buffer = terminal.backend().buffer();
8729        assert_eq!(buffer[(0, 0)].symbol(), " ");
8730        assert_eq!(buffer[(0, 0)].bg, SKILL_PICKER_BACKGROUND);
8731        assert_eq!(buffer[(2, 1)].symbol(), "[");
8732        assert_eq!(buffer[(2, 1)].fg, QUEUED_MESSAGE_COLOR);
8733        assert_eq!(buffer[(2, 2)].symbol(), "/");
8734        assert_eq!(buffer[(2, 2)].fg, QUEUED_MESSAGE_COLOR);
8735        assert!(buffer[(2, 2)].modifier.contains(Modifier::BOLD));
8736        assert_eq!(buffer[(2, 3)].symbol(), "/");
8737        assert_eq!(buffer[(2, 3)].fg, QUEUED_MESSAGE_COLOR);
8738        assert!(!buffer[(2, 3)].modifier.contains(Modifier::BOLD));
8739    }
8740}
8741
8742#[cfg(test)]
8743mod tmux_keyboard_tests {
8744    use super::*;
8745
8746    #[test]
8747    fn is_inside_tmux_detection() {
8748        std::env::set_var("TERM_PROGRAM", "tmux");
8749        assert!(is_inside_tmux());
8750        std::env::set_var("TERM_PROGRAM", "TMUX");
8751        assert!(is_inside_tmux());
8752        std::env::set_var("TERM_PROGRAM", "ghostty");
8753        assert!(!is_inside_tmux());
8754        std::env::remove_var("TERM_PROGRAM");
8755        assert!(!is_inside_tmux());
8756    }
8757}