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