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