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