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