Skip to main content

lucy/
tui.rs

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