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