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