Skip to main content

lucy/
tui.rs

1use std::collections::{HashMap, HashSet};
2use std::io::{self, Write};
3use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
4use std::thread::{self, JoinHandle};
5use std::time::{Duration, Instant};
6
7use crossterm::cursor::{Hide, Show};
8use crossterm::event::{
9    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
10    KeyModifiers, KeyboardEnhancementFlags, MouseEventKind, PopKeyboardEnhancementFlags,
11    PushKeyboardEnhancementFlags,
12};
13use crossterm::execute;
14use crossterm::terminal::{
15    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
16};
17use ratatui::backend::CrosstermBackend;
18use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect, Size};
19use ratatui::prelude::Frame;
20use ratatui::style::{Color, Style};
21use ratatui::text::{Line, Span};
22use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
23use ratatui::Terminal;
24use serde_json::Value;
25use unicode_width::UnicodeWidthStr;
26
27use crate::app::Harness;
28use crate::cancellation::CancellationToken;
29use crate::model::{estimate_context_tokens, ChatMessage};
30use crate::protocol::{EventSink, ProtocolEvent};
31use crate::provider::ProviderModel;
32use crate::redaction::redact_secret;
33use crate::session::SessionHistoryRecord;
34
35const EVENT_POLL: Duration = Duration::from_millis(50);
36const MAX_DISPLAY_INPUT_CHARS: usize = 16 * 1024;
37const WORKER_SHUTDOWN_GRACE: Duration = Duration::from_secs(2);
38/// Maximum number of wrapped input rows the input box grows to before it
39/// stops expanding and scrolls its contents internally.
40const MAX_INPUT_ROWS: u16 = 12;
41const WELCOME_MESSAGE: &str = concat!("Coding Agent Harness LUCY - v", env!("CARGO_PKG_VERSION"),);
42const WELCOME_TAGLINE: &str = "An ultra-thin harness for tomorrow's most powerful models";
43const WELCOME_START_COLOR: (u8, u8, u8) = (0, 180, 180);
44const WELCOME_END_COLOR: (u8, u8, u8) = (255, 215, 0);
45const USER_BORDER_COLOR: Color = Color::Rgb(192, 154, 0);
46const USER_BORDER_GLYPH: &str = "▌";
47const PROMPT_BORDER_START_COLOR: (u8, u8, u8) = (0, 190, 185);
48const PROMPT_BORDER_END_COLOR: (u8, u8, u8) = (0, 205, 85);
49const PENDING_TOOL_COLOR_RGB: (u8, u8, u8) = (255, 165, 0);
50const PENDING_TOOL_COLOR: Color = Color::Rgb(
51    PENDING_TOOL_COLOR_RGB.0,
52    PENDING_TOOL_COLOR_RGB.1,
53    PENDING_TOOL_COLOR_RGB.2,
54);
55/// A successful `cmd` call first retains its pending orange, then sweeps to
56/// green from the left edge of the compact tool line.
57const TOOL_SUCCESS_SWEEP_DURATION: Duration = Duration::from_millis(350);
58const TOOL_SUCCESS_SWEEP_WIDTH: f32 = 3.0;
59const TOOL_SUCCESS_GREEN_RGB: (u8, u8, u8) = (0, 128, 0);
60const QUEUED_MESSAGE_BACKGROUND: Color = Color::Rgb(0, 38, 38);
61const QUEUED_MESSAGE_COLOR: Color = Color::Rgb(150, 255, 245);
62// The sequence travels through warm neighbouring hues, then returns along the
63// same path. Each five-second phase blends only its adjacent palette colours.
64const WORKING_GRADIENT_COLORS: [(u8, u8, u8); 4] = [
65    (220, 35, 175), // magenta
66    (235, 45, 65),  // red
67    (255, 130, 25), // orange
68    (235, 45, 65),  // red, returning to magenta
69];
70const WORKING_GRADIENT_CYCLE: Duration = Duration::from_millis(5000);
71const SKILL_PICKER_MAX_ROWS: usize = 5;
72const BUILTIN_COMMANDS: [&str; 2] = ["settings", "exit"];
73const SUBAGENT_OVERLAY_BACKGROUND: Color = Color::Rgb(55, 0, 55);
74const SUBAGENT_OVERLAY_COLOR: Color = Color::Rgb(255, 0, 255);
75const SETTINGS_MIN_WIDTH: u16 = 36;
76const SETTINGS_MAX_WIDTH: u16 = 88;
77const SETTINGS_MIN_HEIGHT: u16 = 8;
78const SETTINGS_MAX_HEIGHT: u16 = 22;
79
80pub(crate) fn run<W: Write>(mut harness: Harness, resumed: bool, stdout: W) -> Result<(), String> {
81    let secret = harness.provider.api_key().to_owned();
82    let context_window = harness
83        .context_window
84        .or_else(|| harness.provider.context_window());
85    harness.context_window = context_window;
86    let context_tokens = estimate_context_tokens(&harness.session.provider_messages());
87    let skill_names = command_names(
88        harness
89            .session
90            .skills
91            .iter()
92            .map(|skill| skill.name.clone())
93            .collect(),
94    );
95    let mut state = UiState::from_history(
96        &harness.session.history,
97        &secret,
98        &harness.session.llm.model,
99        harness.session.llm.effort.as_deref(),
100        resumed,
101    )
102    .with_attached_agents(harness.attached_agents.clone())
103    .with_skill_names(skill_names)
104    .with_context(context_window, context_tokens);
105    let (request_tx, request_rx) = mpsc::channel::<WorkerRequest>();
106    let (message_tx, message_rx) = mpsc::channel::<WorkerMessage>();
107
108    let stdout = stdout;
109    enable_raw_mode().map_err(|error| format!("unable to enable terminal input: {error}"))?;
110    let backend = CrosstermBackend::new(stdout);
111    let terminal = match Terminal::new(backend) {
112        Ok(terminal) => terminal,
113        Err(error) => {
114            let _ = disable_raw_mode();
115            return Err(format!("unable to initialize terminal UI: {error}"));
116        }
117    };
118    let mut terminal_guard = TerminalGuard::new(terminal);
119    let backend = terminal_guard.terminal_mut().backend_mut();
120    if let Err(error) = execute!(backend, EnterAlternateScreen, EnableMouseCapture, Hide) {
121        return Err(format!("unable to enter terminal UI: {error}"));
122    }
123    // Kitty keyboard protocol makes Shift+Enter (and other modified keys)
124    // distinguishable from plain Enter. Only push it on terminals known to
125    // support it; otherwise the enhancement sequence would leak as literal
126    // text on screen.
127    if supports_keyboard_enhancement() {
128        let _ = execute!(
129            backend,
130            PushKeyboardEnhancementFlags(
131                KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
132                    | KeyboardEnhancementFlags::REPORT_EVENT_TYPES,
133            )
134        );
135        terminal_guard.keyboard_enhancement = true;
136    }
137    let worker = thread::spawn(move || worker_loop(&mut harness, request_rx, message_tx, resumed));
138
139    let result = event_loop(
140        terminal_guard.terminal_mut(),
141        &mut state,
142        &request_tx,
143        &message_rx,
144    );
145
146    if let Some(token) = state.active_cancel.take() {
147        let _ = token.cancel();
148    }
149    let _ = request_tx.send(WorkerRequest::Shutdown);
150    wait_for_worker(worker, WORKER_SHUTDOWN_GRACE);
151    drop(terminal_guard);
152    result
153}
154
155fn worker_loop(
156    harness: &mut Harness,
157    requests: Receiver<WorkerRequest>,
158    messages: Sender<WorkerMessage>,
159    resumed: bool,
160) {
161    let mut sink = ChannelSink {
162        sender: messages.clone(),
163    };
164    if sink
165        .emit_event(&ProtocolEvent::Session {
166            session_id: harness.session.id.clone(),
167            resumed,
168        })
169        .is_err()
170    {
171        return;
172    }
173
174    loop {
175        if let Some(completion) = harness.next_subagent_completion() {
176            let task_id = completion.task_id.clone();
177            let result = completion.result.clone();
178            let notification = Harness::subagent_notification(&completion);
179            let _ = messages.send(WorkerMessage::SubagentCompleted { task_id, result });
180            let cancel = CancellationToken::new();
181            let _ = messages.send(WorkerMessage::Started {
182                cancel: cancel.clone(),
183                user_text: None,
184            });
185            if let Err(error) = harness.handle_message(&notification, &mut sink, Some(&cancel)) {
186                let message = redact_secret(&error, Some(harness.provider.api_key()));
187                let _ = sink.emit_event(&ProtocolEvent::Error { message });
188            }
189            let _ = messages.send(WorkerMessage::Finished);
190            continue;
191        }
192        let request = match requests.recv_timeout(EVENT_POLL) {
193            Ok(request) => request,
194            Err(mpsc::RecvTimeoutError::Timeout) => {
195                if let Some(completion) = harness.next_subagent_completion() {
196                    let task_id = completion.task_id.clone();
197                    let result = completion.result.clone();
198                    let notification = Harness::subagent_notification(&completion);
199                    let _ = messages.send(WorkerMessage::SubagentCompleted { task_id, result });
200                    let cancel = CancellationToken::new();
201                    let _ = messages.send(WorkerMessage::Started {
202                        cancel: cancel.clone(),
203                        user_text: None,
204                    });
205                    if let Err(error) =
206                        harness.handle_message(&notification, &mut sink, Some(&cancel))
207                    {
208                        let message = redact_secret(&error, Some(harness.provider.api_key()));
209                        let _ = sink.emit_event(&ProtocolEvent::Error { message });
210                    }
211                    let _ = messages.send(WorkerMessage::Finished);
212                }
213                continue;
214            }
215            Err(mpsc::RecvTimeoutError::Disconnected) => break,
216        };
217        match request {
218            WorkerRequest::Turn { text } => {
219                let cancel = CancellationToken::new();
220                let _ = messages.send(WorkerMessage::Started {
221                    cancel: cancel.clone(),
222                    user_text: Some(text.clone()),
223                });
224                if let Err(error) = harness.handle_message(&text, &mut sink, Some(&cancel)) {
225                    let message = redact_secret(&error, Some(harness.provider.api_key()));
226                    let _ = sink.emit_event(&ProtocolEvent::Error { message });
227                }
228                let _ = messages.send(WorkerMessage::Finished);
229            }
230            WorkerRequest::Catalog => {
231                let _ = messages.send(WorkerMessage::Catalog(
232                    harness.provider.models().map_err(|error| error.to_string()),
233                ));
234            }
235            WorkerRequest::ApplySettings { model, effort } => {
236                let result = harness.apply_settings(&harness.home.clone(), model, effort);
237                let _ = messages.send(WorkerMessage::SettingsApplied(
238                    result,
239                    harness.session.llm.model.clone(),
240                    harness.session.llm.effort.clone(),
241                    harness.context_window,
242                ));
243            }
244            WorkerRequest::Shutdown => break,
245        }
246    }
247}
248
249fn event_loop<W: Write>(
250    terminal: &mut Terminal<CrosstermBackend<W>>,
251    state: &mut UiState,
252    requests: &Sender<WorkerRequest>,
253    messages: &Receiver<WorkerMessage>,
254) -> Result<(), String> {
255    let mut quitting = false;
256    loop {
257        loop {
258            match messages.try_recv() {
259                Ok(WorkerMessage::Event(event)) => state.apply_event(event),
260                Ok(WorkerMessage::SubagentCompleted { task_id, result }) => {
261                    state.complete_subagent(&task_id, result);
262                }
263                Ok(WorkerMessage::Started { cancel, user_text }) => {
264                    if let Some(text) = user_text {
265                        state.start_queued_user(&text);
266                    }
267                    state.active_cancel = Some(cancel);
268                    state.busy = true;
269                    state.set_status("working");
270                }
271                Ok(WorkerMessage::Thinking) => state.show_thinking(),
272                Ok(WorkerMessage::ReasoningCompleted) => state.complete_reasoning(),
273                Ok(WorkerMessage::SkillInstructionAttached) => {
274                    state.mark_latest_user_skill_attached()
275                }
276                Ok(WorkerMessage::ContextUsage(tokens)) => state.context_tokens = tokens,
277                Ok(WorkerMessage::CompactionStarted) => state.set_status("compacting"),
278                Ok(WorkerMessage::CompactionFinished {
279                    tokens_before,
280                    tokens_after,
281                }) => {
282                    state.context_tokens = tokens_after;
283                    state.set_status("working");
284                    state.transcript.push(TranscriptItem::Info(format!(
285                        "↻ context compacted ({} → {})",
286                        format_context_tokens(tokens_before),
287                        format_context_tokens(tokens_after)
288                    )));
289                }
290                Ok(WorkerMessage::Catalog(result)) => state.open_catalog(result),
291                Ok(WorkerMessage::SettingsApplied(result, model, effort, context_window)) => {
292                    state.settings_applied(result, model, effort, context_window)
293                }
294                Ok(WorkerMessage::Finished) => {
295                    release_finished_turn(terminal.backend_mut(), state);
296                    match state.status.as_str() {
297                        "cancelling" => state.set_status("사용자 중단"),
298                        "finalizing" => state.set_status("ready"),
299                        _ => {}
300                    }
301                    if quitting {
302                        return Ok(());
303                    }
304                }
305                Err(TryRecvError::Empty) => break,
306                Err(TryRecvError::Disconnected) => {
307                    if state.busy {
308                        return Err("TUI worker stopped unexpectedly".to_owned());
309                    }
310                    return Ok(());
311                }
312            }
313        }
314
315        terminal
316            .draw(|frame| draw(frame, state))
317            .map_err(|error| format!("unable to render TUI: {error}"))?;
318
319        if quitting {
320            thread::sleep(EVENT_POLL);
321            continue;
322        }
323        if event::poll(EVENT_POLL)
324            .map_err(|error| format!("unable to read terminal input: {error}"))?
325        {
326            let event =
327                event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
328            if let Event::Mouse(mouse) = event {
329                let size = terminal
330                    .size()
331                    .map_err(|error| format!("unable to read terminal size: {error}"))?;
332                let max_scroll = max_scroll_for_area(state, size);
333                handle_mouse_event(state, mouse.kind, max_scroll);
334                continue;
335            }
336            let Event::Key(key) = event else {
337                continue;
338            };
339            if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
340                continue;
341            }
342            if is_ctrl_c(&key) {
343                if let Some(token) = state.active_cancel.as_ref() {
344                    let _ = token.cancel();
345                    quitting = true;
346                } else {
347                    return Ok(());
348                }
349                continue;
350            }
351            if !state.busy && state.settings.is_some() {
352                if let Some((model, effort)) = state.handle_settings_key(&key) {
353                    state.settings = Some(SettingsState::Applying {
354                        model: model.clone(),
355                        effort: effort.clone(),
356                    });
357                    requests
358                        .send(WorkerRequest::ApplySettings { model, effort })
359                        .map_err(|_| "TUI worker is unavailable".to_owned())?;
360                }
361                continue;
362            }
363            if key.code == KeyCode::Esc {
364                if let Some(token) = state.active_cancel.as_ref() {
365                    if token.cancel() {
366                        state.set_status("cancelling");
367                    }
368                }
369                continue;
370            }
371            match key.code {
372                KeyCode::Enter => {
373                    // Shift+Enter (and Alt+Enter fallback) insert a literal
374                    // newline so the user can write multi-line prompts. Plain
375                    // Enter sends the turn. Many terminals cannot distinguish
376                    // Shift+Enter from Enter, so Alt+Enter is also accepted.
377                    if key.modifiers.contains(KeyModifiers::SHIFT)
378                        || key.modifiers.contains(KeyModifiers::ALT)
379                    {
380                        if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
381                            insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
382                            state.input_changed();
383                        }
384                        continue;
385                    }
386                    // A focused built-in is an action, unlike a skill: Enter
387                    // invokes it immediately. Tab remains completion-only.
388                    let text = if let Some(command) = state.focused_builtin_command() {
389                        state.input.clear();
390                        format!("/{}", command.name())
391                    } else {
392                        if state.select_focused_skill() {
393                            continue;
394                        }
395                        std::mem::take(&mut state.input)
396                    };
397                    state.cursor = 0;
398                    if let Some(command) = builtin_command(&text) {
399                        state.reset_skill_picker();
400                        if state.busy {
401                            state.transcript.push(TranscriptItem::Info(format!(
402                                "/{} is available when the current turn finishes",
403                                command.name()
404                            )));
405                            continue;
406                        }
407                        match command {
408                            BuiltinCommand::Settings => {
409                                state.settings = Some(SettingsState::Loading);
410                                requests
411                                    .send(WorkerRequest::Catalog)
412                                    .map_err(|_| "TUI worker is unavailable".to_owned())?;
413                                continue;
414                            }
415                            BuiltinCommand::Exit => return Ok(()),
416                        }
417                    }
418                    state.reset_skill_picker();
419                    if text.trim().is_empty() {
420                        continue;
421                    }
422                    state.auto_scroll = true;
423                    state.scroll = 0;
424                    state.submit_user(&text);
425                    state.busy = true;
426                    state.set_status("working");
427                    requests
428                        .send(WorkerRequest::Turn { text })
429                        .map_err(|_| "TUI worker is unavailable".to_owned())?;
430                }
431                KeyCode::Tab => {
432                    // Tab completes the focused skill while the slash picker
433                    // is active, using the same first-selection path as Enter.
434                    state.select_focused_skill();
435                }
436                KeyCode::Char(character) => {
437                    if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
438                        insert_at_cursor(&mut state.input, &mut state.cursor, character);
439                        state.input_changed();
440                    }
441                }
442                KeyCode::Backspace => {
443                    if remove_before_cursor(&mut state.input, &mut state.cursor) {
444                        state.input_changed();
445                    }
446                }
447                KeyCode::Left => {
448                    state.cursor = state.cursor.saturating_sub(1);
449                }
450                KeyCode::Right => {
451                    state.cursor = (state.cursor + 1).min(state.input.chars().count());
452                }
453                KeyCode::Home => {
454                    state.cursor = 0;
455                }
456                KeyCode::End => {
457                    state.cursor = state.input.chars().count();
458                }
459                KeyCode::Up => {
460                    if !state.move_skill_picker(false) {
461                        let size = terminal
462                            .size()
463                            .map_err(|error| format!("unable to read terminal size: {error}"))?;
464                        let max_scroll = max_scroll_for_area(state, size);
465                        scroll_up(state, max_scroll);
466                    }
467                }
468                KeyCode::Down => {
469                    if !state.move_skill_picker(true) {
470                        let size = terminal
471                            .size()
472                            .map_err(|error| format!("unable to read terminal size: {error}"))?;
473                        let max_scroll = max_scroll_for_area(state, size);
474                        scroll_down(state, max_scroll);
475                    }
476                }
477                KeyCode::PageUp => {
478                    let size = terminal
479                        .size()
480                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
481                    let max_scroll = max_scroll_for_area(state, size);
482                    scroll_up(state, max_scroll);
483                }
484                KeyCode::PageDown => {
485                    let size = terminal
486                        .size()
487                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
488                    let max_scroll = max_scroll_for_area(state, size);
489                    scroll_down(state, max_scroll);
490                }
491                _ => {}
492            }
493        }
494    }
495}
496
497fn is_ctrl_c(key: &KeyEvent) -> bool {
498    key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
499}
500
501fn handle_mouse_event(state: &mut UiState, kind: MouseEventKind, max_scroll: u16) {
502    match kind {
503        MouseEventKind::ScrollUp => scroll_up(state, max_scroll),
504        MouseEventKind::ScrollDown => scroll_down(state, max_scroll),
505        _ => {}
506    }
507}
508
509fn scroll_up(state: &mut UiState, max_scroll: u16) {
510    if state.auto_scroll {
511        state.scroll = max_scroll;
512        state.auto_scroll = false;
513    } else {
514        state.scroll = state.scroll.min(max_scroll);
515    }
516    state.scroll = state.scroll.saturating_sub(3);
517}
518
519fn scroll_down(state: &mut UiState, max_scroll: u16) {
520    if state.auto_scroll {
521        return;
522    }
523    state.scroll = state.scroll.saturating_add(3).min(max_scroll);
524    if state.scroll == max_scroll {
525        // Reaching the real bottom is an explicit request to resume following
526        // the transcript, so subsequent streamed output stays visible.
527        state.auto_scroll = true;
528        state.scroll = 0;
529    }
530}
531
532fn wait_for_worker(worker: JoinHandle<()>, grace: Duration) {
533    let deadline = std::time::Instant::now() + grace;
534    while !worker.is_finished() && std::time::Instant::now() < deadline {
535        thread::sleep(Duration::from_millis(5));
536    }
537    if worker.is_finished() {
538        let _ = worker.join();
539    }
540}
541
542struct TerminalGuard<W: Write> {
543    terminal: Option<Terminal<CrosstermBackend<W>>>,
544    keyboard_enhancement: bool,
545}
546
547impl<W: Write> TerminalGuard<W> {
548    fn new(terminal: Terminal<CrosstermBackend<W>>) -> Self {
549        Self {
550            terminal: Some(terminal),
551            keyboard_enhancement: false,
552        }
553    }
554
555    fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {
556        self.terminal
557            .as_mut()
558            .expect("terminal guard is initialized")
559    }
560}
561
562impl<W: Write> Drop for TerminalGuard<W> {
563    fn drop(&mut self) {
564        let Some(mut terminal) = self.terminal.take() else {
565            return;
566        };
567        if self.keyboard_enhancement {
568            let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
569        }
570        let _ = terminal.show_cursor();
571        let _ = disable_raw_mode();
572        let _ = execute!(
573            terminal.backend_mut(),
574            DisableMouseCapture,
575            LeaveAlternateScreen,
576            Show
577        );
578        let _ = terminal.backend_mut().flush();
579    }
580}
581
582/// Heuristic for terminals that implement the kitty keyboard protocol.
583/// `PushKeyboardEnhancementFlags` is a no-op on supported terminals, but on
584/// unsupported ones the CSI sequence can render as literal text, so it is only
585/// enabled when the terminal advertises support via `TERM`/`TERM_PROGRAM`.
586fn supports_keyboard_enhancement() -> bool {
587    fn env(name: &str) -> Option<String> {
588        std::env::var(name).ok().map(|value| value.to_lowercase())
589    }
590    let term = env("TERM").unwrap_or_default();
591    let program = env("TERM_PROGRAM").unwrap_or_default();
592    if term.starts_with("xterm-kitty")
593        || term.starts_with("ghostty")
594        || term.starts_with("xterm-ghostty")
595    {
596        return true;
597    }
598    matches!(
599        program.as_str(),
600        "ghostty" | "kitty" | "wezterm" | "alacritty" | "foot" | "footclient" | "iterm.app"
601    )
602}
603
604#[derive(Debug, Clone, Copy, PartialEq, Eq)]
605enum TurnNotification {
606    Completed,
607    Interrupted,
608    Failed,
609}
610
611impl TurnNotification {
612    fn body(self) -> &'static str {
613        match self {
614            Self::Completed => "Turn complete",
615            Self::Interrupted => "Turn interrupted",
616            Self::Failed => "Turn failed",
617        }
618    }
619}
620
621fn turn_notification_for_status(status: &str) -> TurnNotification {
622    match status {
623        "cancelling" | "사용자 중단" => TurnNotification::Interrupted,
624        "error" => TurnNotification::Failed,
625        _ => TurnNotification::Completed,
626    }
627}
628
629/// Ask terminal emulators that support OSC 777 to show a desktop notification.
630///
631/// The title and body are fixed Lucy-owned strings rather than model/provider
632/// text, so completion notifications cannot inject terminal control data or
633/// expose a secret. Terminals without OSC 777 support safely ignore the OSC.
634fn send_turn_notification<W: Write>(
635    writer: &mut W,
636    notification: TurnNotification,
637) -> io::Result<()> {
638    writer.write_all(b"\x1b]777;notify;Lucy;")?;
639    writer.write_all(notification.body().as_bytes())?;
640    writer.write_all(b"\x07")?;
641    writer.flush()
642}
643
644fn release_finished_turn<W: Write>(writer: &mut W, state: &mut UiState) {
645    let was_busy = state.busy;
646    let notification = turn_notification_for_status(&state.status);
647    state.busy = false;
648    state.active_cancel = None;
649    if was_busy {
650        // Notification failure must never change the completed turn result or
651        // make the TUI unusable.
652        let _ = send_turn_notification(writer, notification);
653    }
654}
655
656enum WorkerRequest {
657    Turn {
658        text: String,
659    },
660    Catalog,
661    ApplySettings {
662        model: String,
663        effort: Option<String>,
664    },
665    Shutdown,
666}
667
668enum WorkerMessage {
669    Event(ProtocolEvent),
670    Started {
671        cancel: CancellationToken,
672        user_text: Option<String>,
673    },
674    Thinking,
675    ReasoningCompleted,
676    SkillInstructionAttached,
677    ContextUsage(usize),
678    CompactionStarted,
679    CompactionFinished {
680        tokens_before: usize,
681        tokens_after: usize,
682    },
683    Catalog(Result<Vec<ProviderModel>, String>),
684    SettingsApplied(Result<(), String>, String, Option<String>, Option<usize>),
685    SubagentCompleted {
686        task_id: String,
687        result: Value,
688    },
689    Finished,
690}
691
692struct ChannelSink {
693    sender: Sender<WorkerMessage>,
694}
695
696impl EventSink for ChannelSink {
697    fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
698        self.sender
699            .send(WorkerMessage::Event(event.clone()))
700            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
701    }
702
703    fn reasoning_started(&mut self) -> io::Result<()> {
704        self.sender
705            .send(WorkerMessage::Thinking)
706            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
707    }
708
709    fn reasoning_completed(&mut self) -> io::Result<()> {
710        self.sender
711            .send(WorkerMessage::ReasoningCompleted)
712            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
713    }
714
715    fn skill_instruction_attached(&mut self, _name: &str) -> io::Result<()> {
716        self.sender
717            .send(WorkerMessage::SkillInstructionAttached)
718            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
719    }
720
721    fn context_usage(&mut self, tokens: usize) -> io::Result<()> {
722        self.sender
723            .send(WorkerMessage::ContextUsage(tokens))
724            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
725    }
726
727    fn compaction_started(&mut self) -> io::Result<()> {
728        self.sender
729            .send(WorkerMessage::CompactionStarted)
730            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
731    }
732
733    fn compaction_finished(&mut self, tokens_before: usize, tokens_after: usize) -> io::Result<()> {
734        self.sender
735            .send(WorkerMessage::CompactionFinished {
736                tokens_before,
737                tokens_after,
738            })
739            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
740    }
741}
742
743#[derive(Debug, Clone, Copy, PartialEq, Eq)]
744enum SubagentStatus {
745    Queued,
746    Running,
747    Failed,
748}
749
750#[derive(Debug, Clone, PartialEq)]
751struct SubagentTask {
752    call_id: String,
753    task_id: Option<String>,
754    task: String,
755    model: Option<String>,
756    effort: Option<String>,
757    status: SubagentStatus,
758    result: Option<Value>,
759}
760
761/// A bounded interpolation between the resting bars and a live pulse frame.
762/// Keeping the source frame lets a completed turn settle instead of snapping
763/// straight from its last pulse height to the resting indicator.
764#[derive(Debug, Clone)]
765struct ActivityTransition {
766    started_at: Instant,
767    from_levels: [usize; PULSE_BAR_PERIODS.len()],
768    to_levels: [usize; PULSE_BAR_PERIODS.len()],
769}
770
771struct UiState {
772    model: String,
773    effort: Option<String>,
774    context_window: Option<usize>,
775    context_tokens: usize,
776    secret: String,
777    transcript: Vec<TranscriptItem>,
778    queued_messages: Vec<String>,
779    input: String,
780    cursor: usize,
781    status: String,
782    busy: bool,
783    active_cancel: Option<CancellationToken>,
784    scroll: u16,
785    auto_scroll: bool,
786    tool_animation_epoch: Instant,
787    activity_started_at: Instant,
788    activity_transition: Option<ActivityTransition>,
789    last_active_levels: [usize; PULSE_BAR_PERIODS.len()],
790    last_active_elapsed: Duration,
791    welcome_visible: bool,
792    attached_agents: Vec<String>,
793    subagents: Vec<SubagentTask>,
794    completed_subagent_calls: HashSet<String>,
795    cmd_success_started_at: HashMap<String, Instant>,
796    skill_names: Vec<String>,
797    skill_picker_focus: usize,
798    skill_picker_suppressed: bool,
799    settings: Option<SettingsState>,
800}
801
802impl UiState {
803    fn from_history(
804        history: &[SessionHistoryRecord],
805        secret: &str,
806        model: &str,
807        effort: Option<&str>,
808        resumed: bool,
809    ) -> Self {
810        let mut state = Self {
811            model: model.to_owned(),
812            effort: effort.map(str::to_owned),
813            context_window: None,
814            context_tokens: 1,
815            secret: secret.to_owned(),
816            transcript: Vec::new(),
817            queued_messages: Vec::new(),
818            input: String::new(),
819            cursor: 0,
820            status: "ready".to_owned(),
821            busy: false,
822            active_cancel: None,
823            scroll: 0,
824            auto_scroll: true,
825            tool_animation_epoch: Instant::now(),
826            activity_started_at: Instant::now(),
827            activity_transition: None,
828            last_active_levels: [0; PULSE_BAR_PERIODS.len()],
829            last_active_elapsed: Duration::ZERO,
830            welcome_visible: !resumed && history.is_empty(),
831            attached_agents: Vec::new(),
832            subagents: Vec::new(),
833            completed_subagent_calls: HashSet::new(),
834            cmd_success_started_at: HashMap::new(),
835            skill_names: Vec::new(),
836            skill_picker_focus: 0,
837            skill_picker_suppressed: false,
838            settings: None,
839        };
840        for record in history {
841            state.add_history_record(record);
842        }
843        state
844    }
845
846    fn with_attached_agents(mut self, attached_agents: Vec<String>) -> Self {
847        self.attached_agents = attached_agents;
848        self
849    }
850
851    fn with_skill_names(mut self, skill_names: Vec<String>) -> Self {
852        self.skill_names = skill_names;
853        self
854    }
855
856    fn with_context(mut self, context_window: Option<usize>, context_tokens: usize) -> Self {
857        self.context_window = context_window;
858        self.context_tokens = context_tokens.max(1);
859        self
860    }
861
862    /// Return matching skills only while the first input character is `/` and
863    /// the user is still writing the command name (rather than its arguments).
864    fn matching_skill_names(&self) -> Vec<&str> {
865        matching_skill_names(&self.input, &self.skill_names)
866    }
867
868    fn reset_skill_picker(&mut self) {
869        self.skill_picker_focus = 0;
870        self.skill_picker_suppressed = false;
871    }
872
873    fn skill_picker_visible(&self) -> bool {
874        !self.skill_picker_suppressed && !self.matching_skill_names().is_empty()
875    }
876
877    fn set_status(&mut self, status: impl Into<String>) {
878        let status = status.into();
879        if self.status == status {
880            return;
881        }
882
883        let now = Instant::now();
884        let current_levels = self.activity_levels_at(now);
885        let current_elapsed = self.working_elapsed_at(now);
886        if matches!(self.status.as_str(), "working" | "compacting") {
887            self.last_active_levels = current_levels;
888            self.last_active_elapsed = current_elapsed;
889        }
890
891        match status.as_str() {
892            "working" if !matches!(self.status.as_str(), "working" | "compacting") => {
893                // Join a frame whose next pulses continue one level at a time
894                // after the ramp. Sampling the current bars also makes a new
895                // turn during the ready settle-down phase continuous.
896                self.activity_started_at = now;
897                self.activity_transition = Some(ActivityTransition {
898                    started_at: now,
899                    from_levels: current_levels,
900                    to_levels: pulse_levels_at(PULSE_ENTRY_FRAME),
901                });
902            }
903            "ready" if self.status != "ready" => {
904                // TurnEnd is commonly followed by Finished before the next
905                // draw, so retain the most recent working frame even if the
906                // transient status was already changed to "finalizing".
907                let from_levels = if matches!(self.status.as_str(), "working" | "compacting") {
908                    current_levels
909                } else {
910                    self.last_active_levels
911                };
912                self.activity_transition = Some(ActivityTransition {
913                    started_at: now,
914                    from_levels,
915                    to_levels: [0; PULSE_BAR_PERIODS.len()],
916                });
917            }
918            _ => {}
919        }
920        self.status = status;
921    }
922
923    fn activity_levels_at(&self, now: Instant) -> [usize; PULSE_BAR_PERIODS.len()] {
924        if let Some(transition) = &self.activity_transition {
925            let elapsed = now.saturating_duration_since(transition.started_at);
926            if elapsed < ACTIVITY_TRANSITION_DURATION {
927                return interpolate_pulse_levels(
928                    transition.from_levels,
929                    transition.to_levels,
930                    elapsed,
931                );
932            }
933        }
934
935        match self.status.as_str() {
936            "working" | "compacting" => pulse_levels_at(self.working_elapsed_at(now)),
937            _ => [0; PULSE_BAR_PERIODS.len()],
938        }
939    }
940
941    fn working_elapsed_at(&self, now: Instant) -> Duration {
942        let elapsed = now.saturating_duration_since(self.activity_started_at);
943        if self.status == "working" && self.activity_transition.is_some() {
944            PULSE_ENTRY_FRAME
945                .checked_add(elapsed.saturating_sub(ACTIVITY_TRANSITION_DURATION))
946                .unwrap_or(PULSE_ENTRY_FRAME)
947        } else {
948            elapsed
949        }
950    }
951
952    fn input_changed(&mut self) {
953        self.reset_skill_picker();
954    }
955
956    /// Move through the current filter result without wrapping at its ends.
957    /// Returning false lets the caller retain normal transcript scrolling when
958    /// no slash picker is active.
959    fn move_skill_picker(&mut self, down: bool) -> bool {
960        let match_count = self.matching_skill_names().len();
961        if self.skill_picker_suppressed || match_count == 0 {
962            return false;
963        }
964        if down {
965            self.skill_picker_focus = (self.skill_picker_focus + 1).min(match_count - 1);
966        } else {
967            self.skill_picker_focus = self.skill_picker_focus.saturating_sub(1);
968        }
969        true
970    }
971
972    /// Replace the slash query with the focused explicit skill command. The
973    /// normal Enter path then sends that command and the existing turn engine
974    /// attaches the immutable session skill snapshot.
975    /// Return the built-in represented by the focused slash-picker row, if
976    /// any. Built-ins execute on Enter while skills merely complete there.
977    fn focused_builtin_command(&self) -> Option<BuiltinCommand> {
978        let name = *self.matching_skill_names().get(self.skill_picker_focus)?;
979        builtin_command(&format!("/{name}"))
980    }
981
982    fn select_focused_skill(&mut self) -> bool {
983        if self.skill_picker_suppressed {
984            return false;
985        }
986        let Some(name) = self
987            .matching_skill_names()
988            .get(self.skill_picker_focus)
989            .map(|name| (*name).to_owned())
990        else {
991            return false;
992        };
993        self.input = format!("/{name}");
994        self.cursor = self.input.chars().count();
995        // The first Enter chooses a skill; a second Enter sends the completed
996        // command to the normal attachment path.
997        self.skill_picker_suppressed = true;
998        true
999    }
1000
1001    fn open_catalog(&mut self, result: Result<Vec<ProviderModel>, String>) {
1002        self.settings = Some(match result {
1003            Ok(models) => {
1004                let focus = models
1005                    .iter()
1006                    .position(|model| model.id == self.model)
1007                    .unwrap_or(0);
1008                SettingsState::Models {
1009                    models,
1010                    query: String::new(),
1011                    focus,
1012                }
1013            }
1014            Err(error) => SettingsState::Error(error),
1015        });
1016    }
1017    fn settings_applied(
1018        &mut self,
1019        result: Result<(), String>,
1020        model: String,
1021        effort: Option<String>,
1022        context_window: Option<usize>,
1023    ) {
1024        match result {
1025            Ok(()) => {
1026                self.model = model;
1027                self.effort = effort;
1028                self.context_window = context_window;
1029                self.settings = None;
1030                self.transcript
1031                    .push(TranscriptItem::Info("⚙ settings applied".to_owned()));
1032            }
1033            Err(error) => self.settings = Some(SettingsState::Error(error)),
1034        }
1035    }
1036    fn handle_settings_key(&mut self, key: &KeyEvent) -> Option<(String, Option<String>)> {
1037        let current_effort = self.effort.clone();
1038        match self.settings.as_mut()? {
1039            SettingsState::Loading => {
1040                if key.code == KeyCode::Esc {
1041                    self.settings = None;
1042                }
1043            }
1044            SettingsState::Applying { .. } => {}
1045            SettingsState::Error(_) => {
1046                if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
1047                    self.settings = None;
1048                }
1049            }
1050            SettingsState::Models {
1051                models,
1052                query,
1053                focus,
1054            } => match key.code {
1055                KeyCode::Esc => self.settings = None,
1056                KeyCode::Char(c) => {
1057                    query.push(c);
1058                    *focus = 0;
1059                }
1060                KeyCode::Backspace => {
1061                    query.pop();
1062                    *focus = 0;
1063                }
1064                KeyCode::Up => *focus = focus.saturating_sub(1),
1065                KeyCode::Down => {
1066                    let n = models
1067                        .iter()
1068                        .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
1069                        .count();
1070                    *focus = (*focus + 1).min(n.saturating_sub(1));
1071                }
1072                KeyCode::Enter => {
1073                    let selected = models
1074                        .iter()
1075                        .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
1076                        .nth(*focus)
1077                        .cloned();
1078                    if let Some(model) = selected {
1079                        let focus = model
1080                            .efforts
1081                            .as_ref()
1082                            .and_then(|efforts| {
1083                                current_effort.as_ref().and_then(|current| {
1084                                    efforts.iter().position(|effort| effort == current)
1085                                })
1086                            })
1087                            .map_or(0, |index| index + 1);
1088                        self.settings = Some(SettingsState::Effort {
1089                            model,
1090                            input: current_effort.unwrap_or_default(),
1091                            focus,
1092                        });
1093                    }
1094                }
1095                _ => {}
1096            },
1097            SettingsState::Effort {
1098                model,
1099                input,
1100                focus,
1101            } => match key.code {
1102                KeyCode::Esc => self.settings = None,
1103                KeyCode::Char(c) if model.efforts.is_none() => input.push(c),
1104                KeyCode::Backspace if model.efforts.is_none() => {
1105                    input.pop();
1106                }
1107                KeyCode::Up => *focus = focus.saturating_sub(1),
1108                KeyCode::Down => {
1109                    if let Some(efforts) = &model.efforts {
1110                        *focus = (*focus + 1).min(efforts.len());
1111                    }
1112                }
1113                KeyCode::Enter => {
1114                    let effort = match &model.efforts {
1115                        Some(efforts) => {
1116                            if *focus == 0 {
1117                                None
1118                            } else {
1119                                efforts.get(focus.saturating_sub(1)).cloned()
1120                            }
1121                        }
1122                        None => (!input.trim().is_empty()).then(|| input.trim().to_owned()),
1123                    };
1124                    return Some((model.id.clone(), effort));
1125                }
1126                _ => {}
1127            },
1128        };
1129        None
1130    }
1131
1132    fn add_history_record(&mut self, record: &SessionHistoryRecord) {
1133        match record {
1134            SessionHistoryRecord::ProviderSettings { model, effort, .. } => {
1135                self.transcript.push(TranscriptItem::Info(format!(
1136                    "⚙ {model} ({})",
1137                    effort.as_deref().unwrap_or("default")
1138                )))
1139            }
1140            SessionHistoryRecord::Message { message, .. } => self.add_message(message),
1141            SessionHistoryRecord::Interruption {
1142                assistant_text,
1143                tool_calls,
1144                tool_results,
1145                reason,
1146                phase,
1147                ..
1148            } => {
1149                if !assistant_text.is_empty() {
1150                    self.add_assistant_message(assistant_text);
1151                }
1152                for call in tool_calls {
1153                    self.add_tool_call(call);
1154                }
1155                for observation in tool_results {
1156                    self.add_tool_result(
1157                        &observation.id,
1158                        &observation.name,
1159                        observation.result.clone(),
1160                    );
1161                }
1162                self.transcript
1163                    .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1164            }
1165            SessionHistoryRecord::Compaction(compaction) => {
1166                self.transcript.push(TranscriptItem::Info(format!(
1167                    "↻ context compacted ({} before)",
1168                    format_context_tokens(compaction.tokens_before)
1169                )));
1170            }
1171        }
1172    }
1173
1174    fn add_message(&mut self, message: &ChatMessage) {
1175        match message.role.as_str() {
1176            "user" => {
1177                let text = message.content.as_deref().unwrap_or("");
1178                if let Some((task_id, result)) = parse_subagent_notification(text) {
1179                    self.complete_subagent(&task_id, result);
1180                } else {
1181                    let secret = self.secret.clone();
1182                    self.add_user(text, &secret);
1183                }
1184            }
1185            "assistant" => {
1186                if let Some(content) = message.content.as_deref() {
1187                    self.add_assistant_message(content);
1188                }
1189                for call in &message.tool_calls {
1190                    self.add_tool_call(call);
1191                }
1192            }
1193            "tool" => {
1194                let result = message
1195                    .content
1196                    .as_deref()
1197                    .and_then(|content| serde_json::from_str::<Value>(content).ok())
1198                    .unwrap_or_else(|| Value::String(message.content.clone().unwrap_or_default()));
1199                self.add_tool_result(
1200                    message.tool_call_id.as_deref().unwrap_or(""),
1201                    message.name.as_deref().unwrap_or("cmd"),
1202                    result,
1203                );
1204            }
1205            _ => {}
1206        }
1207    }
1208
1209    /// Show an idle submission in the transcript immediately. Only a turn
1210    /// submitted while another turn is active needs the visible queue.
1211    fn submit_user(&mut self, text: &str) {
1212        if self.busy {
1213            self.queue_user(text);
1214        } else {
1215            self.add_user(text, &self.secret.clone());
1216        }
1217    }
1218
1219    fn queue_user(&mut self, text: &str) {
1220        self.queued_messages
1221            .push(redact_secret(text, Some(&self.secret)));
1222    }
1223
1224    fn start_queued_user(&mut self, text: &str) {
1225        let safe = redact_secret(text, Some(&self.secret));
1226        let queued = if self.queued_messages.first() == Some(&safe) {
1227            self.queued_messages.remove(0);
1228            true
1229        } else if let Some(index) = self
1230            .queued_messages
1231            .iter()
1232            .position(|queued| queued == &safe)
1233        {
1234            self.queued_messages.remove(index);
1235            true
1236        } else {
1237            false
1238        };
1239        if queued {
1240            self.add_user(text, &self.secret.clone());
1241        }
1242    }
1243
1244    fn add_user(&mut self, text: &str, secret: &str) {
1245        self.welcome_visible = false;
1246        self.transcript.push(TranscriptItem::User {
1247            text: redact_secret(text, Some(secret)),
1248            skill_instruction_attached: false,
1249        });
1250    }
1251
1252    fn mark_latest_user_skill_attached(&mut self) {
1253        if let Some(TranscriptItem::User {
1254            skill_instruction_attached,
1255            ..
1256        }) = self.transcript.last_mut()
1257        {
1258            *skill_instruction_attached = true;
1259        }
1260    }
1261
1262    fn clear_thinking(&mut self) {
1263        if matches!(
1264            self.transcript.last(),
1265            Some(TranscriptItem::Reasoning { complete: false })
1266        ) {
1267            self.transcript.pop();
1268        }
1269    }
1270
1271    fn show_thinking(&mut self) {
1272        self.set_status("working");
1273        if !matches!(
1274            self.transcript.last(),
1275            Some(TranscriptItem::Reasoning { complete: false })
1276        ) {
1277            self.transcript
1278                .push(TranscriptItem::Reasoning { complete: false });
1279        }
1280    }
1281
1282    fn complete_reasoning(&mut self) {
1283        if let Some(TranscriptItem::Reasoning { complete }) = self.transcript.last_mut() {
1284            *complete = true;
1285        }
1286    }
1287
1288    fn add_assistant(&mut self, text: &str) {
1289        self.clear_thinking();
1290        if let Some(TranscriptItem::Assistant(current)) = self.transcript.last_mut() {
1291            current.push_str(text);
1292        } else {
1293            self.add_assistant_message(text);
1294        }
1295    }
1296
1297    fn add_assistant_message(&mut self, text: &str) {
1298        self.transcript
1299            .push(TranscriptItem::Assistant(text.to_owned()));
1300    }
1301
1302    fn add_tool_call(&mut self, call: &crate::model::ChatToolCall) {
1303        self.clear_thinking();
1304        self.transcript.push(TranscriptItem::ToolCall {
1305            id: call.id.clone(),
1306            name: call.name.clone(),
1307            arguments: call.arguments.clone(),
1308        });
1309        if call.name == "spawn_subagent" {
1310            self.register_subagent_call(&call.id, &call.arguments);
1311        }
1312    }
1313
1314    fn add_tool_result(&mut self, id: &str, name: &str, result: Value) {
1315        self.record_tool_result(id, name, result, false);
1316    }
1317
1318    fn add_live_tool_result(&mut self, id: &str, name: &str, result: Value) {
1319        self.record_tool_result(id, name, result, true);
1320    }
1321
1322    fn record_tool_result(&mut self, id: &str, name: &str, result: Value, animate: bool) {
1323        if name == "spawn_subagent" {
1324            self.update_subagent_queued(id, &result);
1325        }
1326        if animate && name == "cmd" && cmd_result_succeeded(&result) {
1327            self.cmd_success_started_at
1328                .insert(id.to_owned(), Instant::now());
1329        }
1330        self.transcript.push(TranscriptItem::ToolResult {
1331            id: id.to_owned(),
1332            name: name.to_owned(),
1333            result,
1334        });
1335    }
1336
1337    fn register_subagent_call(&mut self, call_id: &str, arguments: &str) {
1338        if self.subagents.iter().any(|task| task.call_id == call_id) {
1339            return;
1340        }
1341        self.completed_subagent_calls.remove(call_id);
1342        let parsed = serde_json::from_str::<Value>(arguments).ok();
1343        let task = parsed
1344            .as_ref()
1345            .and_then(|value| value.get("task"))
1346            .and_then(Value::as_str)
1347            .map(|task| redact_secret(task.trim(), Some(&self.secret)))
1348            .filter(|task| !task.is_empty())
1349            .unwrap_or_else(|| "invalid task".to_owned());
1350        let model = parsed
1351            .as_ref()
1352            .and_then(|value| value.get("model"))
1353            .and_then(Value::as_str)
1354            .map(|value| redact_secret(value, Some(&self.secret)));
1355        let effort = parsed
1356            .as_ref()
1357            .and_then(|value| value.get("effort"))
1358            .and_then(Value::as_str)
1359            .map(|value| redact_secret(value, Some(&self.secret)));
1360        self.subagents.push(SubagentTask {
1361            call_id: call_id.to_owned(),
1362            task_id: None,
1363            task,
1364            model,
1365            effort,
1366            status: SubagentStatus::Queued,
1367            result: None,
1368        });
1369    }
1370
1371    fn update_subagent_queued(&mut self, call_id: &str, result: &Value) {
1372        let Some(task) = self
1373            .subagents
1374            .iter_mut()
1375            .find(|task| task.call_id == call_id)
1376        else {
1377            return;
1378        };
1379        task.task_id = result
1380            .get("task_id")
1381            .and_then(Value::as_str)
1382            .map(str::to_owned);
1383        task.status = if result.get("error").is_some() {
1384            SubagentStatus::Failed
1385        } else {
1386            // The queued acknowledgement is emitted after the worker thread
1387            // has been launched, so the persistent panel can show its live
1388            // state as running while the worker continues in the background.
1389            SubagentStatus::Running
1390        };
1391        task.result = Some(result.clone());
1392    }
1393
1394    fn complete_subagent(&mut self, task_id: &str, _result: Value) {
1395        // Completion is delivered to the main agent through the notification
1396        // message. The task list is only a live background-work view, so do
1397        // not retain finished workers there. Keep the call identity separately
1398        // so its transcript line can still say "completed" after removal.
1399        if let Some(task) = self
1400            .subagents
1401            .iter()
1402            .find(|task| task.task_id.as_deref() == Some(task_id))
1403        {
1404            self.completed_subagent_calls.insert(task.call_id.clone());
1405        }
1406        self.subagents
1407            .retain(|task| task.task_id.as_deref() != Some(task_id));
1408    }
1409
1410    fn apply_event(&mut self, event: ProtocolEvent) {
1411        match event {
1412            ProtocolEvent::Session { .. } => {}
1413            ProtocolEvent::AssistantDelta { text } => self.add_assistant(&text),
1414            ProtocolEvent::ToolCall {
1415                id,
1416                name,
1417                arguments,
1418            } => self.add_tool_call(&crate::model::ChatToolCall {
1419                id,
1420                name,
1421                arguments,
1422            }),
1423            ProtocolEvent::ToolResult { id, name, result } => {
1424                self.add_live_tool_result(&id, &name, result)
1425            }
1426            ProtocolEvent::TurnEnd => {
1427                self.complete_reasoning();
1428                self.set_status("finalizing");
1429                self.transcript
1430                    .push(TranscriptItem::Info("✓ turn complete".to_owned()));
1431            }
1432            ProtocolEvent::TurnInterrupted { reason, phase } => {
1433                self.complete_reasoning();
1434                self.set_status("cancelling");
1435                self.transcript
1436                    .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1437            }
1438            ProtocolEvent::Error { message } => {
1439                self.complete_reasoning();
1440                self.set_status("error");
1441                self.transcript.push(TranscriptItem::Error(message));
1442            }
1443        }
1444    }
1445}
1446
1447fn parse_subagent_notification(text: &str) -> Option<(String, Value)> {
1448    let prefix = "Background subagent ";
1449    let suffix = " completed. Deliver this result to the user and continue the task: ";
1450    let rest = text.strip_prefix(prefix)?;
1451    let (task_id, encoded_result) = rest.split_once(suffix)?;
1452    if task_id.is_empty() {
1453        return None;
1454    }
1455    Some((
1456        task_id.to_owned(),
1457        serde_json::from_str(encoded_result).ok()?,
1458    ))
1459}
1460
1461#[derive(Debug, Clone, PartialEq)]
1462enum TranscriptItem {
1463    User {
1464        text: String,
1465        skill_instruction_attached: bool,
1466    },
1467    Assistant(String),
1468    ToolCall {
1469        id: String,
1470        name: String,
1471        arguments: String,
1472    },
1473    ToolResult {
1474        id: String,
1475        name: String,
1476        result: Value,
1477    },
1478    Error(String),
1479    Info(String),
1480    Reasoning {
1481        complete: bool,
1482    },
1483}
1484
1485#[cfg(test)]
1486fn activity_text(state: &UiState) -> String {
1487    activity_text_at(state, Instant::now())
1488}
1489
1490fn activity_text_at(state: &UiState, now: Instant) -> String {
1491    match state.status.as_str() {
1492        // Lower one-eighth blocks occupy the full terminal-cell width, so
1493        // adjoining them creates a continuous low bar with no spacer glyphs.
1494        "ready" | "working" | "compacting" => pulse_frame(state.activity_levels_at(now)),
1495        _ => format!("● {}", state.status),
1496    }
1497}
1498
1499/// Reserve one terminal cell on both sides of the TUI so every rendered
1500/// surface shares the same breathing room. Extremely narrow terminals retain
1501/// their full width because two margins would leave no usable content area.
1502fn tui_viewport(area: Rect) -> Rect {
1503    if area.width > 2 {
1504        Rect::new(area.x + 1, area.y, area.width - 2, area.height)
1505    } else {
1506        area
1507    }
1508}
1509
1510fn ui_layout(
1511    state: &UiState,
1512    area: Rect,
1513) -> (Rect, Option<Rect>, Option<Rect>, Option<Rect>, Rect, Rect) {
1514    let input_rows = input_visible_rows(state, area.width.saturating_sub(2));
1515    let queue_height = message_queue_height(state);
1516    let input_height = input_rows.clamp(1, MAX_INPUT_ROWS) + 2;
1517    let chunks = Layout::default()
1518        .direction(Direction::Vertical)
1519        .constraints([
1520            Constraint::Min(1),
1521            Constraint::Length(queue_height),
1522            Constraint::Length(input_height),
1523            Constraint::Length(1),
1524        ])
1525        .split(area);
1526    let picker_height = skill_picker_height(state);
1527    let picker_area = (picker_height > 0).then(|| {
1528        // The picker sits above queued messages so it never covers the queue
1529        // or the prompt border.
1530        Rect::new(
1531            chunks[1].x,
1532            chunks[1].y.saturating_sub(picker_height),
1533            chunks[1].width,
1534            picker_height,
1535        )
1536    });
1537    let overlay_area = subagent_overlay_area(state, chunks[0]);
1538    let input_area = chunks[2];
1539    // Queue rows remain visually aligned with the prompt's inner content, but
1540    // live in their own strip above the bordered input widget.
1541    let queue_area = (queue_height > 0).then(|| {
1542        Rect::new(
1543            input_area.x.saturating_add(1),
1544            chunks[1].y,
1545            input_area.width.saturating_sub(2),
1546            chunks[1].height,
1547        )
1548    });
1549    (
1550        chunks[0],
1551        picker_area,
1552        overlay_area,
1553        queue_area,
1554        input_area,
1555        chunks[3],
1556    )
1557}
1558
1559/// Active background workers float over the upper-right transcript instead of
1560/// consuming space from the input area. One bordered row is reserved for each
1561/// task, plus the top and bottom border rows.
1562fn subagent_overlay_area(state: &UiState, area: Rect) -> Option<Rect> {
1563    if state.subagents.is_empty() || area.is_empty() {
1564        return None;
1565    }
1566    let width = area.width / 3;
1567    if width == 0 {
1568        return None;
1569    }
1570    let height = (state.subagents.len().saturating_add(2))
1571        .min(area.height as usize)
1572        .min(u16::MAX as usize) as u16;
1573    (height > 0).then(|| {
1574        Rect::new(
1575            area.x + area.width.saturating_sub(width),
1576            area.y,
1577            width,
1578            height,
1579        )
1580    })
1581}
1582
1583fn message_queue_height(state: &UiState) -> u16 {
1584    state.queued_messages.len().min(u16::MAX as usize) as u16
1585}
1586
1587fn max_scroll_for_area(state: &UiState, size: Size) -> u16 {
1588    let area = tui_viewport(Rect::new(0, 0, size.width, size.height));
1589    let (chat_chunk, _, _, _, _, _) = ui_layout(state, area);
1590    let chat_height = chat_chunk.height;
1591    let lines = transcript_lines(state, chat_chunk.width);
1592    lines
1593        .len()
1594        .saturating_sub(chat_height as usize)
1595        .min(u16::MAX as usize) as u16
1596}
1597
1598/// Number of wrapped rows the current input occupies at `width`.
1599fn input_visible_rows(state: &UiState, width: u16) -> u16 {
1600    let width = width as usize;
1601    if width == 0 {
1602        return 1;
1603    }
1604    let prompt = input_display_text(state);
1605    let wrapped = wrap_text(&prompt, width);
1606    wrapped.len().max(1) as u16
1607}
1608
1609fn input_prompt(input: &str) -> String {
1610    input.to_owned()
1611}
1612
1613fn input_display_text(state: &UiState) -> String {
1614    redact_secret(&input_prompt(&state.input), Some(&state.secret))
1615}
1616
1617fn command_names(mut skill_names: Vec<String>) -> Vec<String> {
1618    skill_names.extend(BUILTIN_COMMANDS.into_iter().map(str::to_owned));
1619    skill_names.sort();
1620    skill_names.dedup();
1621    skill_names
1622}
1623
1624#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1625enum BuiltinCommand {
1626    Settings,
1627    Exit,
1628}
1629
1630impl BuiltinCommand {
1631    fn name(self) -> &'static str {
1632        match self {
1633            Self::Settings => "settings",
1634            Self::Exit => "exit",
1635        }
1636    }
1637}
1638
1639fn builtin_command(input: &str) -> Option<BuiltinCommand> {
1640    match input.split_whitespace().next()? {
1641        "/settings" => Some(BuiltinCommand::Settings),
1642        "/exit" => Some(BuiltinCommand::Exit),
1643        _ => None,
1644    }
1645}
1646
1647/// The slash picker only accepts a command at the beginning of the message.
1648/// Once whitespace starts arguments, normal message entry resumes.
1649fn matching_skill_names<'a>(input: &str, skill_names: &'a [String]) -> Vec<&'a str> {
1650    let Some(query) = input.strip_prefix('/') else {
1651        return Vec::new();
1652    };
1653    if query.chars().any(char::is_whitespace) {
1654        return Vec::new();
1655    }
1656    skill_names
1657        .iter()
1658        .map(String::as_str)
1659        .filter(|name| name.starts_with(query))
1660        .collect()
1661}
1662
1663fn skill_picker_height(state: &UiState) -> u16 {
1664    if state.skill_picker_visible() {
1665        // Header, visible commands, and the top/bottom border.
1666        (state
1667            .matching_skill_names()
1668            .len()
1669            .min(SKILL_PICKER_MAX_ROWS)
1670            + 3) as u16
1671    } else {
1672        0
1673    }
1674}
1675
1676fn skill_picker_range(total: usize, focus: usize) -> std::ops::Range<usize> {
1677    let start = focus
1678        .saturating_add(1)
1679        .saturating_sub(SKILL_PICKER_MAX_ROWS);
1680    start.min(total)..(start + SKILL_PICKER_MAX_ROWS).min(total)
1681}
1682
1683/// Return the command portion of a currently valid explicit skill invocation.
1684/// This mirrors the command grammar used by the turn engine, while keeping the
1685/// styling concern local to the TUI.
1686fn active_skill_trigger<'a>(input: &'a str, skill_names: &[String]) -> Option<&'a str> {
1687    let invocation = input.strip_prefix('/')?;
1688    let name = invocation
1689        .split_once(char::is_whitespace)
1690        .map_or(invocation, |(name, _)| name);
1691    if name.is_empty() || !skill_names.iter().any(|skill_name| skill_name == name) {
1692        return None;
1693    }
1694    Some(&input[..1 + name.len()])
1695}
1696
1697/// Preserve input wrapping while styling a recognized `/<name>` prefix
1698/// independently from any arguments the user is still entering.
1699fn styled_text_lines(
1700    input: &str,
1701    active_skill_trigger: Option<&str>,
1702    width: usize,
1703    text_style: Style,
1704) -> Vec<Line<'static>> {
1705    let trigger_len = active_skill_trigger.map_or(0, |trigger| trigger.chars().count());
1706    let mut char_offset = 0usize;
1707    let mut lines = Vec::new();
1708
1709    for source_line in input.split('\n') {
1710        for row in wrap_line(source_line, width) {
1711            let mut spans = Vec::new();
1712            let mut text = String::new();
1713            let mut highlighted = None;
1714            for character in row.chars() {
1715                let should_highlight = char_offset < trigger_len;
1716                if highlighted != Some(should_highlight) && !text.is_empty() {
1717                    spans.push(styled_text_span(
1718                        std::mem::take(&mut text),
1719                        highlighted.unwrap_or(false),
1720                        text_style,
1721                    ));
1722                }
1723                highlighted = Some(should_highlight);
1724                text.push(character);
1725                char_offset += 1;
1726            }
1727            if !text.is_empty() {
1728                spans.push(styled_text_span(
1729                    text,
1730                    highlighted.unwrap_or(false),
1731                    text_style,
1732                ));
1733            }
1734            if spans.is_empty() {
1735                spans.push(Span::styled(String::new(), text_style));
1736            }
1737            lines.push(Line::from(spans));
1738        }
1739        // `split` retains empty trailing lines; account for the newline that
1740        // separated this source line from the next one in the character index.
1741        char_offset += 1;
1742    }
1743
1744    lines
1745}
1746
1747fn styled_text_span(text: String, highlighted: bool, text_style: Style) -> Span<'static> {
1748    if highlighted {
1749        Span::styled(text, Style::default().fg(Color::Cyan))
1750    } else {
1751        Span::styled(text, text_style)
1752    }
1753}
1754
1755fn cursor_row(input: &str, cursor: usize, width: usize) -> u16 {
1756    let prefix: String = input.chars().take(cursor).collect();
1757    wrap_text(&prefix, width)
1758        .len()
1759        .saturating_sub(1)
1760        .min(u16::MAX as usize) as u16
1761}
1762
1763fn insert_at_cursor(input: &mut String, cursor: &mut usize, character: char) {
1764    let byte_index = input
1765        .char_indices()
1766        .nth(*cursor)
1767        .map_or(input.len(), |(index, _)| index);
1768    input.insert(byte_index, character);
1769    *cursor += 1;
1770}
1771
1772fn remove_before_cursor(input: &mut String, cursor: &mut usize) -> bool {
1773    if *cursor == 0 {
1774        return false;
1775    }
1776    let end = input
1777        .char_indices()
1778        .nth(*cursor)
1779        .map_or(input.len(), |(index, _)| index);
1780    let start = input
1781        .char_indices()
1782        .nth(*cursor - 1)
1783        .map(|(index, _)| index)
1784        .unwrap_or(0);
1785    input.replace_range(start..end, "");
1786    *cursor -= 1;
1787    true
1788}
1789
1790fn draw(frame: &mut Frame<'_>, state: &UiState) {
1791    let full_area = frame.area();
1792    // Clear the outer gutters too, so a resize or overlay cannot leave stale
1793    // cells in the one-column margins.
1794    frame.render_widget(Clear, full_area);
1795    let area = tui_viewport(full_area);
1796    let (chat_chunk, picker_area, overlay_area, queue_area, input_chunk, status_area) =
1797        ui_layout(state, area);
1798
1799    // Queued user messages occupy a strip above the bordered prompt; active
1800    // background tasks float over the upper-right transcript.
1801    let visible_chat_area = chat_chunk;
1802
1803    let width = chat_chunk.width;
1804    if state.welcome_visible {
1805        let welcome_lines = welcome_lines(&state.attached_agents);
1806        let welcome_height = (welcome_lines.len() as u16).min(visible_chat_area.height);
1807        let welcome_area = Rect::new(
1808            visible_chat_area.x,
1809            visible_chat_area.y + visible_chat_area.height.saturating_sub(welcome_height) / 2,
1810            visible_chat_area.width,
1811            welcome_height,
1812        );
1813        let welcome = Paragraph::new(welcome_lines).alignment(Alignment::Center);
1814        frame.render_widget(welcome, welcome_area);
1815    } else {
1816        let lines = transcript_lines(state, width);
1817        let available = visible_chat_area.height as usize;
1818        let max_scroll = lines.len().saturating_sub(available).min(u16::MAX as usize) as u16;
1819        let scroll = if state.auto_scroll {
1820            max_scroll
1821        } else {
1822            state.scroll.min(max_scroll)
1823        };
1824        let transcript = Paragraph::new(lines).scroll((scroll, 0));
1825        frame.render_widget(transcript, visible_chat_area);
1826    }
1827
1828    let activity_now = Instant::now();
1829    let activity_text = activity_text_at(state, activity_now);
1830    let activity_elapsed = state.working_elapsed_at(activity_now);
1831    let activity = activity_line_at(state, &activity_text, activity_elapsed, activity_now);
1832    if let Some(picker_area) = picker_area {
1833        draw_skill_picker(frame, state, picker_area);
1834    }
1835
1836    let input_text_style = Style::default().fg(Color::White);
1837    let input_block = Block::default()
1838        .borders(Borders::ALL)
1839        .border_type(ratatui::widgets::BorderType::Plain);
1840    let prompt_area = input_block.inner(input_chunk);
1841    let prompt = input_display_text(state);
1842    let input_rows =
1843        input_visible_rows(state, input_chunk.width.saturating_sub(2)).clamp(1, MAX_INPUT_ROWS);
1844    let wrapped = wrap_text(&prompt, prompt_area.width.max(1) as usize);
1845    let visible = (wrapped.len() as u16).clamp(1, input_rows);
1846    let cursor_row = cursor_row(&prompt, state.cursor, prompt_area.width.max(1) as usize);
1847    let bottom_scroll = (wrapped.len() as u16).saturating_sub(visible);
1848    let cursor_scroll = (cursor_row + 1).saturating_sub(visible);
1849    let input_scroll = bottom_scroll.min(cursor_scroll);
1850    let active_skill_trigger = (!state.busy)
1851        .then(|| active_skill_trigger(&prompt, &state.skill_names))
1852        .flatten();
1853    let input_lines = styled_text_lines(
1854        &prompt,
1855        active_skill_trigger,
1856        prompt_area.width.max(1) as usize,
1857        input_text_style,
1858    );
1859    frame.render_widget(input_block, input_chunk);
1860    if let Some(queue_area) = queue_area {
1861        draw_message_queue(frame, state, queue_area);
1862    }
1863    let input = Paragraph::new(input_lines)
1864        .style(input_text_style)
1865        .scroll((input_scroll, 0));
1866    frame.render_widget(input, prompt_area);
1867    draw_prompt_border_gradient(frame, input_chunk);
1868
1869    let effort = state.effort.as_deref().unwrap_or("default");
1870    let status_text = format!("{} ({effort})", state.model);
1871    let status = Paragraph::new(redact_secret(&status_text, Some(&state.secret)))
1872        .style(Style::default().fg(Color::DarkGray));
1873    frame.render_widget(status, status_area);
1874
1875    let context_text = context_status_text(state);
1876    let context_width = UnicodeWidthStr::width(context_text.as_str()) as u16;
1877    let context_area_width = context_width.min(status_area.width);
1878    if context_area_width > 0 {
1879        let context_area = Rect::new(
1880            status_area
1881                .x
1882                .saturating_add(status_area.width.saturating_sub(context_area_width)),
1883            status_area.y,
1884            context_area_width,
1885            status_area.height,
1886        );
1887        let context = Paragraph::new(context_text)
1888            .alignment(Alignment::Right)
1889            .style(context_status_style(state));
1890        frame.render_widget(context, context_area);
1891    }
1892
1893    // Render last so this remains at the exact center of the full status line,
1894    // rather than the leftover space between metadata columns.
1895    frame.render_widget(
1896        Paragraph::new(activity).alignment(Alignment::Center),
1897        status_area,
1898    );
1899
1900    // The task overlay is a floating surface: draw it after the transcript,
1901    // picker, input, and status layers so none can cut through its left border
1902    // or upper-left corner on a constrained terminal layout.
1903    if let Some(overlay_area) = overlay_area {
1904        draw_subagent_overlay(frame, state, overlay_area);
1905    }
1906    if let Some(settings) = &state.settings {
1907        draw_settings(frame, settings, area);
1908    }
1909
1910    // Keep the terminal cursor anchored to the input on every frame. Terminal
1911    // IMEs place their uncommitted CJK composition at the hardware cursor; a
1912    // blink frame that hides it can otherwise leave that composition beside a
1913    // freshly rendered status indicator instead of the prompt.
1914    if state.settings.is_none() && !prompt_area.is_empty() && visible > 0 {
1915        let cursor_prefix: String = prompt.chars().take(state.cursor).collect();
1916        let cursor_rows = wrap_text(&cursor_prefix, prompt_area.width.max(1) as usize);
1917        let cursor_line = cursor_rows.last().map(String::as_str).unwrap_or("");
1918        let cursor_offset = UnicodeWidthStr::width(cursor_line) as u16;
1919        let cursor_x = prompt_area.x + cursor_offset.min(prompt_area.width.saturating_sub(1));
1920        let cursor_y = prompt_area.y + cursor_row.saturating_sub(input_scroll);
1921        frame.set_cursor_position((cursor_x, cursor_y));
1922    }
1923}
1924
1925fn draw_message_queue(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
1926    if area.is_empty() {
1927        return;
1928    }
1929
1930    let total = state.queued_messages.len();
1931    let lines = state
1932        .queued_messages
1933        .iter()
1934        .map(|message| Line::raw(format!("Queued {total}: {}", single_line_preview(message))))
1935        .collect::<Vec<_>>();
1936    frame.render_widget(Clear, area);
1937    frame.render_widget(
1938        Paragraph::new(lines).style(
1939            Style::default()
1940                .fg(QUEUED_MESSAGE_COLOR)
1941                .bg(QUEUED_MESSAGE_BACKGROUND),
1942        ),
1943        area,
1944    );
1945}
1946
1947fn draw_subagent_overlay(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
1948    if area.is_empty() {
1949        return;
1950    }
1951
1952    let overlay_style = Style::default()
1953        .fg(SUBAGENT_OVERLAY_COLOR)
1954        .bg(SUBAGENT_OVERLAY_BACKGROUND);
1955    let block = Block::default()
1956        .title("Subagents")
1957        .title_alignment(Alignment::Right)
1958        .borders(Borders::ALL)
1959        .style(overlay_style)
1960        .border_style(overlay_style);
1961    let inner = block.inner(area);
1962    let lines = state
1963        .subagents
1964        .iter()
1965        .map(|task| {
1966            let indicator = subagent_status_indicator(task.status, state);
1967            let task_text = redact_secret(&single_line_preview(&task.task), Some(&state.secret));
1968            Line::styled(format!("{indicator} {task_text}"), overlay_style)
1969        })
1970        .collect::<Vec<_>>();
1971
1972    // The floating panel must erase the transcript below it before its size or
1973    // task count changes, otherwise clipped text could remain visible.
1974    frame.render_widget(Clear, area);
1975    frame.render_widget(block, area);
1976    frame.render_widget(Paragraph::new(lines).style(overlay_style), inner);
1977}
1978
1979fn subagent_status_indicator(status: SubagentStatus, state: &UiState) -> String {
1980    match status {
1981        SubagentStatus::Queued => "○".to_owned(),
1982        SubagentStatus::Running => tool_spinner_frame(state),
1983        SubagentStatus::Failed => "×".to_owned(),
1984    }
1985}
1986
1987fn single_line_preview(text: &str) -> String {
1988    truncate_output(&text.replace(['\n', '\r'], " ↵ "))
1989}
1990
1991fn subagent_status_label(status: SubagentStatus) -> &'static str {
1992    match status {
1993        SubagentStatus::Queued => "queued",
1994        SubagentStatus::Running => "running",
1995        SubagentStatus::Failed => "error",
1996    }
1997}
1998
1999fn subagent_status_display(status: SubagentStatus, state: &UiState) -> String {
2000    let label = subagent_status_label(status);
2001    if status == SubagentStatus::Running {
2002        format!("{label} {}", tool_spinner_frame(state))
2003    } else {
2004        label.to_owned()
2005    }
2006}
2007
2008fn subagent_status_style(status: SubagentStatus) -> Style {
2009    match status {
2010        SubagentStatus::Queued => Style::default().fg(PENDING_TOOL_COLOR),
2011        SubagentStatus::Running => Style::default().fg(Color::Cyan),
2012        SubagentStatus::Failed => Style::default().fg(Color::Red),
2013    }
2014}
2015
2016enum SettingsState {
2017    Loading,
2018    Applying {
2019        model: String,
2020        effort: Option<String>,
2021    },
2022    Error(String),
2023    Models {
2024        models: Vec<ProviderModel>,
2025        query: String,
2026        focus: usize,
2027    },
2028    Effort {
2029        model: ProviderModel,
2030        input: String,
2031        focus: usize,
2032    },
2033}
2034fn draw_settings(frame: &mut Frame<'_>, settings: &SettingsState, area: Rect) {
2035    let width = area
2036        .width
2037        .saturating_sub(2)
2038        .min(SETTINGS_MAX_WIDTH)
2039        .max(SETTINGS_MIN_WIDTH.min(area.width));
2040    let height = area
2041        .height
2042        .saturating_sub(2)
2043        .min(SETTINGS_MAX_HEIGHT)
2044        .max(SETTINGS_MIN_HEIGHT.min(area.height));
2045    let popup = Rect::new(
2046        area.x + area.width.saturating_sub(width) / 2,
2047        area.y + area.height.saturating_sub(height) / 2,
2048        width,
2049        height,
2050    );
2051    frame.render_widget(Clear, popup);
2052    let block = Block::default()
2053        .title(" /settings ")
2054        .borders(Borders::ALL)
2055        .border_style(Style::default().fg(Color::Cyan));
2056    let inner = block.inner(popup);
2057    frame.render_widget(block, popup);
2058
2059    let lines = match settings {
2060        SettingsState::Loading => vec![
2061            Line::styled("Loading provider models…", Style::default().fg(Color::Cyan)),
2062            Line::raw(""),
2063            Line::styled("Esc  cancel", Style::default().fg(Color::DarkGray)),
2064        ],
2065        SettingsState::Applying { model, effort } => vec![
2066            Line::styled("Applying selection…", Style::default().fg(Color::Cyan)),
2067            Line::raw(model.clone()),
2068            Line::raw(format!(
2069                "effort: {}",
2070                effort.as_deref().unwrap_or("default")
2071            )),
2072        ],
2073        SettingsState::Error(error) => vec![
2074            Line::styled("Unable to update settings", Style::default().fg(Color::Red)),
2075            Line::raw(""),
2076            Line::raw(error.clone()),
2077            Line::raw(""),
2078            Line::styled("Enter/Esc  close", Style::default().fg(Color::DarkGray)),
2079        ],
2080        SettingsState::Models {
2081            models,
2082            query,
2083            focus,
2084        } => {
2085            let query_lower = query.to_lowercase();
2086            let filtered = models
2087                .iter()
2088                .filter(|model| model.id.to_lowercase().contains(&query_lower))
2089                .collect::<Vec<_>>();
2090            let focus = (*focus).min(filtered.len().saturating_sub(1));
2091            let list_rows = inner.height.saturating_sub(4) as usize;
2092            let range = selection_range(filtered.len(), focus, list_rows);
2093            let mut lines = vec![
2094                Line::from(vec![
2095                    Span::styled("Model  ", Style::default().fg(Color::DarkGray)),
2096                    Span::styled(
2097                        if query.is_empty() {
2098                            "type to filter…"
2099                        } else {
2100                            query
2101                        },
2102                        Style::default().fg(if query.is_empty() {
2103                            Color::DarkGray
2104                        } else {
2105                            Color::White
2106                        }),
2107                    ),
2108                ]),
2109                Line::styled(
2110                    format!(
2111                        "{} models{}",
2112                        filtered.len(),
2113                        if filtered.is_empty() {
2114                            ""
2115                        } else {
2116                            " · ↑/↓ move · Enter choose"
2117                        }
2118                    ),
2119                    Style::default().fg(Color::DarkGray),
2120                ),
2121            ];
2122            if filtered.is_empty() {
2123                lines.push(Line::styled(
2124                    "No matching models",
2125                    Style::default().fg(Color::Yellow),
2126                ));
2127            } else {
2128                for index in range {
2129                    let selected = index == focus;
2130                    lines.push(Line::styled(
2131                        format!(
2132                            "{} {}",
2133                            if selected { "›" } else { " " },
2134                            filtered[index].id
2135                        ),
2136                        if selected {
2137                            Style::default().fg(Color::Black).bg(Color::Cyan)
2138                        } else {
2139                            Style::default().fg(Color::White)
2140                        },
2141                    ));
2142                }
2143            }
2144            lines.push(Line::styled(
2145                "Esc  cancel",
2146                Style::default().fg(Color::DarkGray),
2147            ));
2148            lines
2149        }
2150        SettingsState::Effort {
2151            model,
2152            input,
2153            focus,
2154        } => {
2155            let mut lines = vec![
2156                Line::styled(model.id.clone(), Style::default().fg(Color::Cyan)),
2157                Line::styled("Reasoning effort", Style::default().fg(Color::DarkGray)),
2158            ];
2159            match &model.efforts {
2160                Some(efforts) => {
2161                    let total = efforts.len() + 1;
2162                    let focus = (*focus).min(total.saturating_sub(1));
2163                    let list_rows = inner.height.saturating_sub(4) as usize;
2164                    for index in selection_range(total, focus, list_rows) {
2165                        let value = if index == 0 {
2166                            "default"
2167                        } else {
2168                            efforts[index - 1].as_str()
2169                        };
2170                        let selected = index == focus;
2171                        lines.push(Line::styled(
2172                            format!("{} {value}", if selected { "›" } else { " " }),
2173                            if selected {
2174                                Style::default().fg(Color::Black).bg(Color::Cyan)
2175                            } else {
2176                                Style::default().fg(Color::White)
2177                            },
2178                        ));
2179                    }
2180                    lines.push(Line::styled(
2181                        "↑/↓ move · Enter save · Esc cancel",
2182                        Style::default().fg(Color::DarkGray),
2183                    ));
2184                }
2185                None => {
2186                    lines.push(Line::raw("Provider did not advertise allowed efforts."));
2187                    lines.push(Line::from(vec![
2188                        Span::styled("Value  ", Style::default().fg(Color::DarkGray)),
2189                        Span::styled(
2190                            if input.is_empty() { "default" } else { input },
2191                            Style::default().fg(Color::White),
2192                        ),
2193                    ]));
2194                    lines.push(Line::styled(
2195                        "Type a value · Enter save · Esc cancel",
2196                        Style::default().fg(Color::DarkGray),
2197                    ));
2198                }
2199            }
2200            lines
2201        }
2202    };
2203    frame.render_widget(Paragraph::new(lines), inner);
2204}
2205
2206fn selection_range(total: usize, focus: usize, max_rows: usize) -> std::ops::Range<usize> {
2207    if total == 0 || max_rows == 0 {
2208        return 0..0;
2209    }
2210    let focus = focus.min(total - 1);
2211    let visible = total.min(max_rows);
2212    let start = focus
2213        .saturating_add(1)
2214        .saturating_sub(visible)
2215        .min(total - visible);
2216    start..start + visible
2217}
2218
2219fn draw_skill_picker(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
2220    let matches = state.matching_skill_names();
2221    let total = matches.len();
2222    if total == 0 || area.is_empty() {
2223        return;
2224    }
2225
2226    // The picker is painted last, over the existing transcript and activity;
2227    // its geometry never participates in the underlying layout.
2228    frame.render_widget(Clear, area);
2229    let block = Block::default()
2230        .borders(Borders::ALL)
2231        .border_type(BorderType::Plain)
2232        .border_style(Style::default().fg(Color::Cyan));
2233    let inner = block.inner(area);
2234    frame.render_widget(block, area);
2235
2236    let focus = state.skill_picker_focus.min(total - 1);
2237    let header = Line::styled(
2238        format!("[{}/{}]", focus + 1, total),
2239        Style::default().fg(Color::DarkGray),
2240    );
2241    frame.render_widget(
2242        Paragraph::new(header),
2243        Rect::new(inner.x, inner.y, inner.width, 1),
2244    );
2245
2246    for (row, index) in skill_picker_range(total, focus).enumerate() {
2247        let style = if index == focus {
2248            Style::default().fg(Color::Cyan)
2249        } else {
2250            Style::default().fg(Color::DarkGray)
2251        };
2252        let skill = Line::styled(format!("/{}", matches[index]), style);
2253        frame.render_widget(
2254            Paragraph::new(skill),
2255            Rect::new(inner.x, inner.y + 1 + row as u16, inner.width, 1),
2256        );
2257    }
2258}
2259
2260fn welcome_line() -> Line<'static> {
2261    let character_count = WELCOME_MESSAGE.chars().count();
2262    let spans = WELCOME_MESSAGE
2263        .chars()
2264        .enumerate()
2265        .map(|(index, character)| {
2266            let progress = if character_count <= 1 {
2267                0.0
2268            } else {
2269                index as f32 / (character_count - 1) as f32
2270            };
2271            let color = Color::Rgb(
2272                interpolate_color(WELCOME_START_COLOR.0, WELCOME_END_COLOR.0, progress),
2273                interpolate_color(WELCOME_START_COLOR.1, WELCOME_END_COLOR.1, progress),
2274                interpolate_color(WELCOME_START_COLOR.2, WELCOME_END_COLOR.2, progress),
2275            );
2276            Span::styled(character.to_string(), Style::default().fg(color))
2277        })
2278        .collect::<Vec<_>>();
2279    Line::from(spans)
2280}
2281
2282fn interpolate_color(start: u8, end: u8, progress: f32) -> u8 {
2283    (start as f32 + (end as f32 - start as f32) * progress).round() as u8
2284}
2285
2286fn welcome_lines(attached_agents: &[String]) -> Vec<Line<'static>> {
2287    let mut lines = vec![
2288        welcome_line(),
2289        Line::styled(WELCOME_TAGLINE, Style::default().fg(Color::DarkGray)),
2290        Line::raw(""),
2291    ];
2292
2293    if attached_agents.is_empty() {
2294        lines.push(Line::styled(
2295            "Attached AGENTS.md: none",
2296            Style::default().fg(Color::DarkGray),
2297        ));
2298    } else {
2299        lines.push(Line::styled(
2300            "Attached AGENTS.md:",
2301            Style::default().fg(Color::DarkGray),
2302        ));
2303        lines.extend(
2304            attached_agents.iter().map(|path| {
2305                Line::styled(format!("• {path}"), Style::default().fg(Color::DarkGray))
2306            }),
2307        );
2308    }
2309
2310    lines
2311}
2312
2313fn transcript_lines(state: &UiState, width: u16) -> Vec<Line<'static>> {
2314    let width = width.max(1) as usize;
2315    let mut lines = Vec::new();
2316    let mut rendered_item = false;
2317
2318    for (index, item) in state.transcript.iter().enumerate() {
2319        // Results are positioned on their matching call, even when the model
2320        // emitted several calls before execution produced any result.
2321        if is_result_attached_to_call(&state.transcript, index) {
2322            continue;
2323        }
2324        if rendered_item {
2325            lines.push(Line::raw(String::new()));
2326        }
2327        match item {
2328            TranscriptItem::User {
2329                text,
2330                skill_instruction_attached,
2331            } => {
2332                let text = redact_secret(text, Some(&state.secret));
2333                let trigger = skill_instruction_attached
2334                    .then(|| active_skill_trigger(&text, &state.skill_names))
2335                    .flatten();
2336                push_user_message_block(&mut lines, &text, trigger, width);
2337            }
2338            TranscriptItem::Assistant(text) => {
2339                let text = redact_secret(text, Some(&state.secret));
2340                push_wrapped(&mut lines, &text, width, Style::default());
2341            }
2342            TranscriptItem::ToolCall {
2343                id,
2344                name,
2345                arguments,
2346            } => {
2347                let result = matching_tool_result(&state.transcript, index, id);
2348                let segments = if name == "cmd" {
2349                    cmd_tool_segments(id, arguments, result, state)
2350                } else if name == "spawn_subagent" {
2351                    subagent_tool_segments(id, arguments, state)
2352                } else {
2353                    generic_tool_segments(name, arguments, result, state)
2354                };
2355                push_spans_wrapped(&mut lines, &segments, width);
2356            }
2357            TranscriptItem::ToolResult {
2358                id: _,
2359                name: _,
2360                result,
2361            } => {
2362                let result_text = format_tool_result(result);
2363                let result_text = redact_secret(&result_text, Some(&state.secret));
2364                push_spans_wrapped(&mut lines, &[(result_text, tool_result_style())], width);
2365            }
2366            TranscriptItem::Error(text) => {
2367                let text = redact_secret(text, Some(&state.secret));
2368                push_wrapped(&mut lines, &text, width, error_style());
2369            }
2370            TranscriptItem::Info(text) => {
2371                let text = redact_secret(text, Some(&state.secret));
2372                push_wrapped(&mut lines, &text, width, info_style());
2373            }
2374            TranscriptItem::Reasoning { complete } => {
2375                let text = if *complete {
2376                    "Reasoning Complete".to_owned()
2377                } else {
2378                    format!("Reasoning... {}", spinner_frame(state))
2379                };
2380                push_wrapped(&mut lines, &text, width, thinking_style());
2381            }
2382        }
2383        rendered_item = true;
2384    }
2385    if lines.is_empty() {
2386        lines.push(Line::raw(""));
2387    }
2388    lines
2389}
2390
2391/// Tool work can outlive a main-agent turn (for example, background
2392/// subagents), so it uses its own clock instead of the main status animation.
2393fn running_tool_status(state: &UiState) -> String {
2394    tool_spinner_frame(state)
2395}
2396
2397fn cmd_tool_segments(
2398    call_id: &str,
2399    arguments: &str,
2400    result: Option<&Value>,
2401    state: &UiState,
2402) -> Vec<(String, Style)> {
2403    let command = redact_secret(&command_display(arguments), Some(&state.secret));
2404    if let Some(result) = result {
2405        let (icon, status, status_style) = cmd_result_status(result);
2406        if status == "done" {
2407            return cmd_success_segments(call_id, &format!("{icon} cmd  $ {command}"), state);
2408        }
2409        vec![
2410            (format!("{icon} cmd  $ {command}  → "), status_style),
2411            (status, status_style),
2412        ]
2413    } else {
2414        vec![
2415            (format!("· cmd  $ {command}  "), pending_tool_call_style()),
2416            (running_tool_status(state), pending_tool_call_style()),
2417        ]
2418    }
2419}
2420
2421/// During the brief post-success window, turn the compact `cmd` line from the
2422/// pending orange into green one character at a time. A few adjacent
2423/// characters blend at the leading edge so the visual is a true gradient,
2424/// rather than a hard colour boundary.
2425fn cmd_success_segments(call_id: &str, text: &str, state: &UiState) -> Vec<(String, Style)> {
2426    let now = Instant::now();
2427    let started_at = state.cmd_success_started_at.get(call_id).copied();
2428    if started_at.is_none_or(|started_at| {
2429        now.saturating_duration_since(started_at) >= TOOL_SUCCESS_SWEEP_DURATION
2430    }) {
2431        return vec![(text.to_owned(), Style::default().fg(Color::Green))];
2432    }
2433
2434    let character_count = text.chars().count();
2435    text.chars()
2436        .enumerate()
2437        .map(|(index, character)| {
2438            (
2439                character.to_string(),
2440                Style::default().fg(cmd_success_color_at(
2441                    started_at.expect("active success sweep"),
2442                    now,
2443                    index,
2444                    character_count,
2445                )),
2446            )
2447        })
2448        .collect()
2449}
2450
2451fn cmd_success_color_at(
2452    started_at: Instant,
2453    now: Instant,
2454    character_index: usize,
2455    character_count: usize,
2456) -> Color {
2457    let elapsed = now.saturating_duration_since(started_at);
2458    if elapsed >= TOOL_SUCCESS_SWEEP_DURATION {
2459        return Color::Green;
2460    }
2461
2462    let progress = elapsed.as_secs_f32() / TOOL_SUCCESS_SWEEP_DURATION.as_secs_f32();
2463    // Start the leading blend at the left edge and carry it past the last
2464    // character, so no character has to jump to its settled green at the end.
2465    let sweep_distance = character_count as f32 + TOOL_SUCCESS_SWEEP_WIDTH - 1.0;
2466    let front = progress * sweep_distance;
2467    let character_progress =
2468        ((front - character_index as f32) / TOOL_SUCCESS_SWEEP_WIDTH).clamp(0.0, 1.0);
2469    Color::Rgb(
2470        interpolate_color(
2471            PENDING_TOOL_COLOR_RGB.0,
2472            TOOL_SUCCESS_GREEN_RGB.0,
2473            character_progress,
2474        ),
2475        interpolate_color(
2476            PENDING_TOOL_COLOR_RGB.1,
2477            TOOL_SUCCESS_GREEN_RGB.1,
2478            character_progress,
2479        ),
2480        interpolate_color(
2481            PENDING_TOOL_COLOR_RGB.2,
2482            TOOL_SUCCESS_GREEN_RGB.2,
2483            character_progress,
2484        ),
2485    )
2486}
2487
2488fn command_display(arguments: &str) -> String {
2489    serde_json::from_str::<Value>(arguments)
2490        .ok()
2491        .and_then(|value| {
2492            value
2493                .get("command")
2494                .and_then(Value::as_str)
2495                .map(str::to_owned)
2496        })
2497        .map(|command| truncate_output(&command))
2498        .unwrap_or_else(|| truncate_output(arguments))
2499}
2500
2501fn cmd_result_succeeded(result: &Value) -> bool {
2502    !result
2503        .get("canceled")
2504        .and_then(Value::as_bool)
2505        .unwrap_or(false)
2506        && !result
2507            .get("timed_out")
2508            .and_then(Value::as_bool)
2509            .unwrap_or(false)
2510        && result.get("error").is_none()
2511        && !matches!(result.get("exit_code").and_then(Value::as_i64), Some(code) if code != 0)
2512}
2513
2514fn cmd_result_status(result: &Value) -> (char, String, Style) {
2515    if result
2516        .get("canceled")
2517        .and_then(Value::as_bool)
2518        .unwrap_or(false)
2519    {
2520        return (
2521            '!',
2522            "canceled".to_owned(),
2523            Style::default().fg(Color::Yellow),
2524        );
2525    }
2526    if result
2527        .get("timed_out")
2528        .and_then(Value::as_bool)
2529        .unwrap_or(false)
2530    {
2531        return (
2532            '!',
2533            "timeout".to_owned(),
2534            Style::default().fg(Color::Yellow),
2535        );
2536    }
2537    if result.get("error").is_some() {
2538        return ('×', "error".to_owned(), error_style());
2539    }
2540    match result.get("exit_code").and_then(Value::as_i64) {
2541        Some(0) => ('✓', "done".to_owned(), Style::default().fg(Color::Green)),
2542        Some(code) => ('×', format!("exit {code}"), error_style()),
2543        None => ('✓', "done".to_owned(), Style::default().fg(Color::Green)),
2544    }
2545}
2546
2547fn subagent_task_from_arguments(arguments: &str) -> String {
2548    serde_json::from_str::<Value>(arguments)
2549        .ok()
2550        .and_then(|value| value.get("task").and_then(Value::as_str).map(str::to_owned))
2551        .map(|task| truncate_output(&task))
2552        .unwrap_or_else(|| command_display(arguments))
2553}
2554
2555fn subagent_tool_segments(call_id: &str, arguments: &str, state: &UiState) -> Vec<(String, Style)> {
2556    let live_task = state.subagents.iter().find(|task| task.call_id == call_id);
2557    let task = live_task
2558        .map(|task| task.task.clone())
2559        .unwrap_or_else(|| subagent_task_from_arguments(arguments));
2560    let task = redact_secret(&truncate_output(&task), Some(&state.secret));
2561    let (status, style) = if let Some(task) = live_task {
2562        let status = if task.status == SubagentStatus::Running {
2563            running_tool_status(state)
2564        } else {
2565            subagent_status_display(task.status, state)
2566        };
2567        (status, subagent_status_style(task.status))
2568    } else if state.completed_subagent_calls.contains(call_id) {
2569        ("completed".to_owned(), Style::default().fg(Color::Green))
2570    } else {
2571        ("queued".to_owned(), pending_tool_call_style())
2572    };
2573    let separator = if live_task.is_some_and(|task| task.status == SubagentStatus::Running) {
2574        "  "
2575    } else {
2576        "  → "
2577    };
2578    vec![(format!("↗ subagent  {task}{separator}{status}"), style)]
2579}
2580
2581fn generic_tool_segments(
2582    name: &str,
2583    arguments: &str,
2584    result: Option<&Value>,
2585    state: &UiState,
2586) -> Vec<(String, Style)> {
2587    let call_text = redact_secret(
2588        &format!("[tool:{name} {}]", call_arguments(arguments)),
2589        Some(&state.secret),
2590    );
2591    let mut segments = vec![(
2592        call_text,
2593        if result.is_some() {
2594            tool_call_style()
2595        } else {
2596            pending_tool_call_style()
2597        },
2598    )];
2599    if let Some(result) = result {
2600        let result_text = redact_secret(&format_tool_result(result), Some(&state.secret));
2601        segments.push((" > ".to_owned(), Style::default()));
2602        segments.push((result_text, tool_result_style()));
2603    } else {
2604        segments.push((
2605            format!(" {}", tool_spinner_frame(state)),
2606            pending_tool_call_style(),
2607        ));
2608    }
2609    segments
2610}
2611
2612fn matching_tool_result<'a>(
2613    transcript: &'a [TranscriptItem],
2614    call_index: usize,
2615    call_id: &str,
2616) -> Option<&'a Value> {
2617    transcript
2618        .iter()
2619        .skip(call_index + 1)
2620        .find_map(|item| match item {
2621            TranscriptItem::ToolResult { id, result, .. } if id == call_id => Some(result),
2622            _ => None,
2623        })
2624}
2625
2626fn is_result_attached_to_call(transcript: &[TranscriptItem], result_index: usize) -> bool {
2627    let TranscriptItem::ToolResult { id, .. } = &transcript[result_index] else {
2628        return false;
2629    };
2630    let Some(call_index) = transcript[..result_index].iter().rposition(
2631        |item| matches!(item, TranscriptItem::ToolCall { id: call_id, .. } if call_id == id),
2632    ) else {
2633        return false;
2634    };
2635    !transcript[call_index + 1..result_index].iter().any(
2636        |item| matches!(item, TranscriptItem::ToolResult { id: result_id, .. } if result_id == id),
2637    )
2638}
2639
2640/// Render tool call arguments as the command string inside double quotes, for
2641/// example `"cat README.md"`. Previews are truncated to the same limit as tool
2642/// results; malformed arguments fall back to truncated raw text.
2643fn call_arguments(arguments: &str) -> String {
2644    let parsed: Value = match serde_json::from_str(arguments) {
2645        Ok(value) => value,
2646        Err(_) => return truncate_output(arguments),
2647    };
2648    if let Some(command) = parsed.get("command").and_then(Value::as_str) {
2649        return format!("\"{}\"", truncate_output(command));
2650    }
2651    let serialized = serde_json::to_string(&parsed).unwrap_or_else(|_| arguments.to_owned());
2652    truncate_output(&serialized)
2653}
2654
2655/// Render a tool result as a single-line JSON-string-array literal containing
2656/// stdout (or stderr when stdout is empty). Newlines are escaped so the whole
2657/// result stays on one line. Output is truncated to `RESULT_PREVIEW_CHARS`.
2658fn format_tool_result(result: &Value) -> String {
2659    let stdout = result.get("stdout").and_then(Value::as_str).unwrap_or("");
2660    let stderr = result.get("stderr").and_then(Value::as_str).unwrap_or("");
2661    let output = if !stdout.is_empty() { stdout } else { stderr };
2662    let truncated = truncate_output(output);
2663    // Build a JSON string literal so newlines and quotes are escaped and the
2664    // result renders on a single line as `["..."]`.
2665    let json_string = serde_json::to_string(&truncated).unwrap_or_else(|_| "\"\"".to_owned());
2666    format!("[{json_string}]")
2667}
2668
2669const RESULT_PREVIEW_CHARS: usize = 50;
2670
2671fn truncate_output(output: &str) -> String {
2672    let mut result: String = output.chars().take(RESULT_PREVIEW_CHARS).collect();
2673    if output.chars().count() > RESULT_PREVIEW_CHARS {
2674        result.push('…');
2675    }
2676    result
2677}
2678
2679fn user_message_style() -> Style {
2680    Style::default().fg(USER_BORDER_COLOR)
2681}
2682
2683/// Render user messages with a one-cell yellow block rule, one inner left
2684/// padding cell, and blank rows above and below; assistant and tool output remains borderless.
2685fn push_user_message_block(
2686    lines: &mut Vec<Line<'static>>,
2687    text: &str,
2688    active_skill_trigger: Option<&str>,
2689    width: usize,
2690) {
2691    if width < 3 {
2692        lines.extend(styled_text_lines(
2693            text,
2694            active_skill_trigger,
2695            width.max(1),
2696            Style::default().fg(Color::White),
2697        ));
2698        return;
2699    }
2700
2701    let border_style = user_message_style();
2702    let rows = styled_text_lines(
2703        text,
2704        active_skill_trigger,
2705        width - 2,
2706        Style::default().fg(Color::White),
2707    );
2708    lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
2709    for row in rows {
2710        let mut spans = Vec::with_capacity(row.spans.len() + 2);
2711        spans.push(Span::styled(USER_BORDER_GLYPH, border_style));
2712        spans.push(Span::styled(" ", Style::default().fg(Color::White)));
2713        spans.extend(row.spans);
2714        lines.push(Line::from(spans));
2715    }
2716    lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
2717}
2718
2719fn tool_call_style() -> Style {
2720    Style::default().fg(Color::Magenta)
2721}
2722
2723fn pending_tool_call_style() -> Style {
2724    Style::default().fg(PENDING_TOOL_COLOR)
2725}
2726
2727fn tool_result_style() -> Style {
2728    Style::default().fg(Color::DarkGray)
2729}
2730
2731fn error_style() -> Style {
2732    Style::default().fg(Color::Red)
2733}
2734
2735fn info_style() -> Style {
2736    Style::default().fg(Color::DarkGray)
2737}
2738
2739fn context_status_text(state: &UiState) -> String {
2740    let used = format_context_tokens(state.context_tokens);
2741    let Some(window) = state.context_window else {
2742        return format!("ctx={used}/? (?%)");
2743    };
2744    let percentage = context_percentage(state.context_tokens, window);
2745    format!(
2746        "ctx={used}/{} ({percentage}%)",
2747        format_context_tokens(window)
2748    )
2749}
2750
2751fn context_status_style(state: &UiState) -> Style {
2752    if state
2753        .context_window
2754        .is_some_and(|window| context_over_threshold(state.context_tokens, window))
2755    {
2756        Style::default().fg(PENDING_TOOL_COLOR)
2757    } else {
2758        Style::default().fg(Color::DarkGray)
2759    }
2760}
2761
2762fn context_percentage(used: usize, window: usize) -> usize {
2763    if window == 0 {
2764        return 0;
2765    }
2766    ((used as u128 * 100).div_ceil(window as u128)) as usize
2767}
2768
2769fn context_over_threshold(used: usize, window: usize) -> bool {
2770    window > 0 && (used as u128 * 100) > (window as u128 * 80)
2771}
2772
2773fn format_context_tokens(tokens: usize) -> String {
2774    if tokens >= 1_000_000 {
2775        format!("{:.2}M", tokens as f64 / 1_000_000.0)
2776    } else if tokens >= 1_000 {
2777        format!("{:.1}K", tokens as f64 / 1_000.0)
2778    } else {
2779        tokens.to_string()
2780    }
2781}
2782
2783fn activity_line_at<'a>(
2784    state: &UiState,
2785    text: &'a str,
2786    elapsed: Duration,
2787    now: Instant,
2788) -> Line<'a> {
2789    let character_count = text.chars().count().max(1);
2790    match state.status.as_str() {
2791        "working" => Line::from(
2792            text.chars()
2793                .enumerate()
2794                .map(|(index, character)| {
2795                    let target = working_gradient_color_at(elapsed, index, character_count);
2796                    Span::styled(
2797                        character.to_string(),
2798                        Style::default().fg(activity_color_at(state, target, now)),
2799                    )
2800                })
2801                .collect::<Vec<_>>(),
2802        ),
2803        "ready"
2804            if state
2805                .activity_transition
2806                .as_ref()
2807                .is_some_and(|transition| transition_progress(now, transition) < 1.0) =>
2808        {
2809            Line::from(
2810                text.chars()
2811                    .enumerate()
2812                    .map(|(index, character)| {
2813                        let source = working_gradient_color_at(
2814                            state.last_active_elapsed,
2815                            index,
2816                            character_count,
2817                        );
2818                        Span::styled(
2819                            character.to_string(),
2820                            Style::default().fg(activity_color_at(state, source, now)),
2821                        )
2822                    })
2823                    .collect::<Vec<_>>(),
2824            )
2825        }
2826        _ => Line::styled(text, activity_style_at_now(state, elapsed, now)),
2827    }
2828}
2829
2830#[cfg(test)]
2831fn activity_style_at(state: &UiState, elapsed: Duration) -> Style {
2832    activity_style_at_now(state, elapsed, Instant::now())
2833}
2834
2835fn activity_style_at_now(state: &UiState, elapsed: Duration, now: Instant) -> Style {
2836    let target = if state.status == "working" {
2837        working_gradient_color_at(elapsed, 0, 1)
2838    } else if state.status == "compacting" {
2839        PENDING_TOOL_COLOR
2840    } else {
2841        Color::Cyan
2842    };
2843    Style::default().fg(activity_color_at(state, target, now))
2844}
2845
2846fn activity_color_at(state: &UiState, target: Color, now: Instant) -> Color {
2847    let Some(transition) = &state.activity_transition else {
2848        return target;
2849    };
2850    let progress = transition_progress(now, transition);
2851    if progress >= 1.0 {
2852        return target;
2853    }
2854
2855    match state.status.as_str() {
2856        "working" => blend_rgb(Color::Cyan, target, progress),
2857        "ready" => blend_rgb(target, Color::Cyan, progress),
2858        _ => target,
2859    }
2860}
2861
2862fn transition_progress(now: Instant, transition: &ActivityTransition) -> f32 {
2863    // Rendering is polled at this cadence, so quantize the colour blend to the
2864    // same frames as the bar-height interpolation. This also keeps a single
2865    // draw internally consistent when its status line is inspected twice.
2866    let elapsed_ticks = now
2867        .saturating_duration_since(transition.started_at)
2868        .as_millis()
2869        / PULSE_TICK.as_millis();
2870    let transition_ticks = ACTIVITY_TRANSITION_DURATION.as_millis() / PULSE_TICK.as_millis();
2871    (elapsed_ticks as f32 / transition_ticks as f32).min(1.0)
2872}
2873fn blend_rgb(from: Color, to: Color, progress: f32) -> Color {
2874    let (from_red, from_green, from_blue) = activity_rgb(from);
2875    let (to_red, to_green, to_blue) = activity_rgb(to);
2876    Color::Rgb(
2877        interpolate_color(from_red, to_red, progress),
2878        interpolate_color(from_green, to_green, progress),
2879        interpolate_color(from_blue, to_blue, progress),
2880    )
2881}
2882
2883fn activity_rgb(color: Color) -> (u8, u8, u8) {
2884    match color {
2885        Color::Rgb(red, green, blue) => (red, green, blue),
2886        // The resting indicator uses ratatui's named cyan. Convert it to its
2887        // ANSI RGB equivalent while blending into and out of the warm pulse.
2888        Color::Cyan => (0, 255, 255),
2889        _ => unreachable!("activity transition colours are cyan or RGB"),
2890    }
2891}
2892
2893/// Color the prompt outline from teal at its left edge to green at its right
2894/// edge. Vertical sides retain their respective endpoint colours.
2895fn draw_prompt_border_gradient(frame: &mut Frame<'_>, area: Rect) {
2896    if area.is_empty() {
2897        return;
2898    }
2899
2900    let right = area.x + area.width.saturating_sub(1);
2901    let bottom = area.y + area.height.saturating_sub(1);
2902    let buffer = frame.buffer_mut();
2903    for x in area.x..=right {
2904        let style = Style::default().fg(prompt_border_gradient_color_at(x - area.x, area.width));
2905        buffer[(x, area.y)].set_style(style);
2906        buffer[(x, bottom)].set_style(style);
2907    }
2908    for y in area.y..=bottom {
2909        buffer[(area.x, y)].set_style(Style::default().fg(PROMPT_BORDER_START_COLOR.into()));
2910        buffer[(right, y)].set_style(Style::default().fg(PROMPT_BORDER_END_COLOR.into()));
2911    }
2912}
2913
2914fn prompt_border_gradient_color_at(column: u16, width: u16) -> Color {
2915    let progress = column as f32 / width.saturating_sub(1).max(1) as f32;
2916    Color::Rgb(
2917        interpolate_color(
2918            PROMPT_BORDER_START_COLOR.0,
2919            PROMPT_BORDER_END_COLOR.0,
2920            progress,
2921        ),
2922        interpolate_color(
2923            PROMPT_BORDER_START_COLOR.1,
2924            PROMPT_BORDER_END_COLOR.1,
2925            progress,
2926        ),
2927        interpolate_color(
2928            PROMPT_BORDER_START_COLOR.2,
2929            PROMPT_BORDER_END_COLOR.2,
2930            progress,
2931        ),
2932    )
2933}
2934
2935fn working_gradient_colors_at(position: f64) -> (Color, Color, f64) {
2936    let position = position.rem_euclid(WORKING_GRADIENT_COLORS.len() as f64);
2937    let phase = position.floor() as usize;
2938    let progress = position.fract();
2939    let start = WORKING_GRADIENT_COLORS[phase];
2940    let end = WORKING_GRADIENT_COLORS[(phase + 1) % WORKING_GRADIENT_COLORS.len()];
2941    (
2942        Color::Rgb(start.0, start.1, start.2),
2943        Color::Rgb(end.0, end.1, end.2),
2944        progress,
2945    )
2946}
2947
2948/// Move a continuous warm gradient across the activity text. Offset each
2949/// character slightly along the same time-based palette, rather than swapping
2950/// whole character bands at a boundary.
2951fn working_gradient_color_at(
2952    elapsed: Duration,
2953    character_index: usize,
2954    character_count: usize,
2955) -> Color {
2956    const SWEEP_WIDTH: f64 = 0.35;
2957
2958    let position = elapsed.as_secs_f64() / WORKING_GRADIENT_CYCLE.as_secs_f64()
2959        + character_index as f64 / character_count.max(1) as f64 * SWEEP_WIDTH;
2960    let (
2961        Color::Rgb(red_start, green_start, blue_start),
2962        Color::Rgb(red_end, green_end, blue_end),
2963        progress,
2964    ) = working_gradient_colors_at(position)
2965    else {
2966        unreachable!("working gradient palette always uses RGB colours")
2967    };
2968
2969    Color::Rgb(
2970        interpolate_color(red_start, red_end, progress as f32),
2971        interpolate_color(green_start, green_end, progress as f32),
2972        interpolate_color(blue_start, blue_end, progress as f32),
2973    )
2974}
2975
2976fn thinking_style() -> Style {
2977    Style::default().fg(Color::DarkGray)
2978}
2979
2980// Unicode block elements occupy the full cell width. Rendering them without
2981// separators keeps the five bars visually continuous in terminal fonts.
2982const PULSE_LEVELS: [char; 7] = ['▁', '▂', '▃', '▅', '▆', '▇', '█'];
2983const PULSE_BAR_PERIODS: [u128; 5] = [12, 16, 20, 24, 15];
2984const PULSE_BAR_PHASES: [u128; 5] = [0, 5, 13, 9, 3];
2985const PULSE_TICK: Duration = Duration::from_millis(50);
2986const TOOL_SPINNER_FRAMES: [char; 4] = ['|', '/', '-', '\\'];
2987const TOOL_SPINNER_FRAME_DURATION: Duration = Duration::from_millis(100);
2988
2989/// Five independently phased triangle waves make the bars feel irregular
2990/// without random jumps: every rendered tick changes a bar by at most one
2991/// level, and the combined pattern repeats every 12 seconds.
2992const ACTIVITY_TRANSITION_DURATION: Duration = Duration::from_millis(400);
2993// This frame gives all five bars room to rise from the resting level while
2994// preserving the pulse waveform's one-level-per-tick continuity afterwards.
2995const PULSE_ENTRY_FRAME: Duration = Duration::from_millis(950);
2996
2997fn spinner_frame(state: &UiState) -> String {
2998    pulse_frame(state.activity_levels_at(Instant::now()))
2999}
3000
3001#[cfg(test)]
3002fn spinner_frame_at(elapsed: Duration) -> String {
3003    pulse_frame(pulse_levels_at(elapsed))
3004}
3005
3006/// A compact, traditional spinner for tool calls that are awaiting a result.
3007/// It deliberately has a separate epoch because background work can outlive a
3008/// main-agent turn.
3009fn tool_spinner_frame(state: &UiState) -> String {
3010    tool_spinner_frame_at(state.tool_animation_epoch.elapsed()).to_string()
3011}
3012
3013fn tool_spinner_frame_at(elapsed: Duration) -> char {
3014    let frame = (elapsed.as_millis() / TOOL_SPINNER_FRAME_DURATION.as_millis()) as usize;
3015    TOOL_SPINNER_FRAMES[frame % TOOL_SPINNER_FRAMES.len()]
3016}
3017
3018fn pulse_frame(levels: [usize; PULSE_BAR_PERIODS.len()]) -> String {
3019    levels
3020        .into_iter()
3021        .map(|level| PULSE_LEVELS[level])
3022        .collect()
3023}
3024
3025fn pulse_levels_at(elapsed: Duration) -> [usize; PULSE_BAR_PERIODS.len()] {
3026    let tick = elapsed.as_millis() / PULSE_TICK.as_millis();
3027    std::array::from_fn(|index| {
3028        pulse_level_at(tick, PULSE_BAR_PERIODS[index], PULSE_BAR_PHASES[index])
3029    })
3030}
3031
3032fn interpolate_pulse_levels(
3033    from: [usize; PULSE_BAR_PERIODS.len()],
3034    to: [usize; PULSE_BAR_PERIODS.len()],
3035    elapsed: Duration,
3036) -> [usize; PULSE_BAR_PERIODS.len()] {
3037    let elapsed = elapsed.min(ACTIVITY_TRANSITION_DURATION).as_millis();
3038    let duration = ACTIVITY_TRANSITION_DURATION.as_millis();
3039    std::array::from_fn(|index| {
3040        let start = from[index] as i128;
3041        let distance = to[index] as i128 - start;
3042        (start + distance * elapsed as i128 / duration as i128) as usize
3043    })
3044}
3045
3046fn pulse_level_at(tick: u128, period: u128, phase: u128) -> usize {
3047    let position = (tick + phase) % period;
3048    let half_period = period / 2;
3049    let distance_from_floor = if position <= half_period {
3050        position
3051    } else {
3052        period - position
3053    };
3054    (distance_from_floor * (PULSE_LEVELS.len() - 1) as u128 / half_period) as usize
3055}
3056
3057fn push_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, style: Style) {
3058    let mut added = false;
3059    for piece in wrap_text(text, width) {
3060        lines.push(Line::styled(piece, style));
3061        added = true;
3062    }
3063    if !added {
3064        lines.push(Line::styled(String::new(), style));
3065    }
3066}
3067
3068/// Push a logical line built from styled segments. When the rendered width
3069/// exceeds `width`, the whole line is character-wrapped; wrapped continuations
3070/// keep the style of the segment they fall on.
3071fn push_spans_wrapped(lines: &mut Vec<Line<'static>>, segments: &[(String, Style)], width: usize) {
3072    let mut current_spans: Vec<Span<'static>> = Vec::new();
3073    let mut current_width = 0usize;
3074    for (text, style) in segments {
3075        for character in text.chars() {
3076            let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
3077            if current_width + char_width > width && !current_spans.is_empty() {
3078                lines.push(Line::from(std::mem::take(&mut current_spans)));
3079                current_width = 0;
3080            }
3081            let mut buffer = [0u8; 4];
3082            let s = character.encode_utf8(&mut buffer);
3083            current_spans.push(Span::styled(s.to_owned(), *style));
3084            current_width += char_width;
3085        }
3086    }
3087    if current_spans.is_empty() {
3088        current_spans.push(Span::raw(String::new()));
3089    }
3090    lines.push(Line::from(current_spans));
3091}
3092
3093/// Wrap `text` into rows no wider than `width` display columns. Wrapping is
3094/// character-based so the row count matches exactly what a non-wrapping
3095/// `Paragraph` renderer draws, which keeps auto-scroll pinned to the true
3096/// bottom of the transcript regardless of terminal width. Empty lines are
3097/// preserved as empty rows.
3098fn wrap_text(text: &str, width: usize) -> Vec<String> {
3099    if width == 0 {
3100        return text.lines().map(str::to_owned).collect();
3101    }
3102    let mut rows = Vec::new();
3103    // `split` preserves a trailing empty row, so Shift+Enter renders an
3104    // immediate new line even before another character is typed.
3105    for line in text.split('\n') {
3106        rows.extend(wrap_line(line, width));
3107    }
3108    if rows.is_empty() {
3109        rows.push(String::new());
3110    }
3111    rows
3112}
3113
3114fn wrap_line(line: &str, width: usize) -> Vec<String> {
3115    let mut rows = Vec::new();
3116    let mut current = String::new();
3117    let mut current_width = 0usize;
3118    for character in line.chars() {
3119        let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
3120        if current_width + char_width > width && !current.is_empty() {
3121            rows.push(std::mem::take(&mut current));
3122            current_width = 0;
3123        }
3124        current.push(character);
3125        current_width += char_width;
3126    }
3127    rows.push(current);
3128    rows
3129}
3130
3131#[cfg(test)]
3132mod tests {
3133    use super::*;
3134
3135    #[test]
3136    fn turn_notifications_use_osc_777_with_fixed_secret_safe_messages() {
3137        let cases = [
3138            (TurnNotification::Completed, "Turn complete"),
3139            (TurnNotification::Interrupted, "Turn interrupted"),
3140            (TurnNotification::Failed, "Turn failed"),
3141        ];
3142
3143        for (notification, body) in cases {
3144            let mut output = Vec::new();
3145            send_turn_notification(&mut output, notification).expect("notification");
3146            assert_eq!(
3147                output,
3148                format!("\x1b]777;notify;Lucy;{body}\x07").into_bytes()
3149            );
3150        }
3151    }
3152
3153    #[test]
3154    fn turn_notifications_follow_the_terminal_turn_status() {
3155        assert_eq!(
3156            turn_notification_for_status("finalizing"),
3157            TurnNotification::Completed
3158        );
3159        assert_eq!(
3160            turn_notification_for_status("cancelling"),
3161            TurnNotification::Interrupted
3162        );
3163        assert_eq!(
3164            turn_notification_for_status("error"),
3165            TurnNotification::Failed
3166        );
3167    }
3168
3169    struct FailingWriter;
3170
3171    impl Write for FailingWriter {
3172        fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
3173            Err(io::Error::other("notification sink unavailable"))
3174        }
3175
3176        fn flush(&mut self) -> io::Result<()> {
3177            Err(io::Error::other("notification sink unavailable"))
3178        }
3179    }
3180
3181    #[test]
3182    fn notification_write_failure_does_not_keep_the_tui_busy() {
3183        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3184        state.busy = true;
3185        state.active_cancel = Some(CancellationToken::new());
3186        let mut writer = FailingWriter;
3187
3188        release_finished_turn(&mut writer, &mut state);
3189
3190        assert!(!state.busy);
3191        assert!(state.active_cancel.is_none());
3192    }
3193
3194    #[test]
3195    fn an_idle_finish_does_not_emit_a_duplicate_notification() {
3196        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3197        let mut output = Vec::new();
3198
3199        release_finished_turn(&mut output, &mut state);
3200
3201        assert!(output.is_empty());
3202    }
3203
3204    #[test]
3205    fn context_status_shows_used_window_and_percentage() {
3206        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3207            .with_context(Some(100_000), 80_000);
3208
3209        assert_eq!(context_status_text(&state), "ctx=80.0K/100.0K (80%)");
3210        assert_eq!(context_status_style(&state).fg, Some(Color::DarkGray));
3211
3212        state.context_tokens = 80_001;
3213        assert_eq!(context_status_text(&state), "ctx=80.0K/100.0K (81%)");
3214        assert_eq!(context_status_style(&state).fg, Some(PENDING_TOOL_COLOR));
3215    }
3216
3217    #[test]
3218    fn context_status_handles_unknown_window_without_highlighting() {
3219        let state = UiState::from_history(&[], "secret", "model", None, false);
3220
3221        assert_eq!(context_status_text(&state), "ctx=1/? (?%)");
3222        assert_eq!(context_status_style(&state).fg, Some(Color::DarkGray));
3223    }
3224
3225    #[test]
3226    fn tui_viewport_reserves_one_column_on_each_side_when_possible() {
3227        assert_eq!(
3228            tui_viewport(Rect::new(0, 0, 80, 10)),
3229            Rect::new(1, 0, 78, 10)
3230        );
3231        assert_eq!(
3232            tui_viewport(Rect::new(0, 0, 2, 10)),
3233            Rect::new(0, 0, 2, 10),
3234            "a two-column terminal cannot reserve two gutters"
3235        );
3236    }
3237
3238    #[test]
3239    fn context_status_is_right_aligned_and_turns_orange_above_eighty_percent() {
3240        let state =
3241            UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
3242        let mut terminal =
3243            Terminal::new(ratatui::backend::TestBackend::new(60, 10)).expect("test terminal");
3244
3245        terminal
3246            .draw(|frame| draw(frame, &state))
3247            .expect("draw statusline");
3248
3249        let buffer = terminal.backend().buffer();
3250        let status_area = ui_layout(&state, tui_viewport(Rect::new(0, 0, 60, 10))).5;
3251        let context = context_status_text(&state);
3252        let context_start = status_area.x + status_area.width - context.chars().count() as u16;
3253        let context_end = status_area.x + status_area.width - 1;
3254        assert_eq!(buffer[(context_start, status_area.y)].symbol(), "c");
3255        assert_eq!(buffer[(context_end, status_area.y)].symbol(), ")");
3256        assert_eq!(buffer[(context_end, status_area.y)].fg, PENDING_TOOL_COLOR);
3257    }
3258
3259    #[test]
3260    fn activity_indicator_uses_only_contiguous_block_bars() {
3261        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3262        assert_eq!(activity_text(&state), "▁▁▁▁▁");
3263
3264        state.set_status("working");
3265        let working = activity_text(&state);
3266        assert_eq!(working.chars().count(), 5);
3267        assert!(working.chars().all(|bar| PULSE_LEVELS.contains(&bar)));
3268        assert!(!working.chars().any(char::is_whitespace));
3269    }
3270
3271    #[test]
3272    fn activity_indicator_ramps_up_and_settles_down_one_level_per_tick() {
3273        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3274        state.set_status("working");
3275        let entry = state
3276            .activity_transition
3277            .clone()
3278            .expect("working begins with a ramp");
3279        let entry_frames = (0..=ACTIVITY_TRANSITION_DURATION.as_millis() / PULSE_TICK.as_millis())
3280            .map(|tick| state.activity_levels_at(entry.started_at + PULSE_TICK * tick as u32))
3281            .collect::<Vec<_>>();
3282        assert_eq!(entry_frames.first(), Some(&[0; PULSE_BAR_PERIODS.len()]));
3283        assert_eq!(entry_frames.last(), Some(&entry.to_levels));
3284        assert_levels_change_gradually(&entry_frames, "working entry");
3285
3286        // Complete the entry ramp first so ready samples an active pulse frame
3287        // rather than merely reversing a partially completed entry transition.
3288        state.activity_transition = None;
3289        state.activity_started_at = Instant::now() - PULSE_ENTRY_FRAME;
3290        state.set_status("ready");
3291        let exit = state
3292            .activity_transition
3293            .clone()
3294            .expect("ready begins with a settle-down ramp");
3295        let exit_frames = (0..=ACTIVITY_TRANSITION_DURATION.as_millis() / PULSE_TICK.as_millis())
3296            .map(|tick| state.activity_levels_at(exit.started_at + PULSE_TICK * tick as u32))
3297            .collect::<Vec<_>>();
3298        assert_eq!(exit_frames.first(), Some(&exit.from_levels));
3299        assert_eq!(exit_frames.last(), Some(&[0; PULSE_BAR_PERIODS.len()]));
3300        assert_levels_change_gradually(&exit_frames, "ready exit");
3301        assert!(exit_frames.windows(2).all(|pair| {
3302            pair[0]
3303                .iter()
3304                .zip(pair[1])
3305                .all(|(before, after)| after <= *before)
3306        }));
3307
3308        let settled_at = exit.started_at + ACTIVITY_TRANSITION_DURATION;
3309        let settled_text = activity_text_at(&state, settled_at);
3310        let settled = activity_line_at(
3311            &state,
3312            &settled_text,
3313            state.working_elapsed_at(settled_at),
3314            settled_at,
3315        );
3316        assert_eq!(
3317            settled.style.fg,
3318            Some(Color::Cyan),
3319            "the completed settle-down transition restores the ready colour"
3320        );
3321    }
3322
3323    fn assert_levels_change_gradually(
3324        frames: &[[usize; PULSE_BAR_PERIODS.len()]],
3325        transition: &str,
3326    ) {
3327        assert!(
3328            frames.windows(2).all(|pair| {
3329                pair[0]
3330                    .iter()
3331                    .zip(pair[1])
3332                    .all(|(before, after)| before.abs_diff(after) <= 1)
3333            }),
3334            "{transition} must not jump more than one pulse level per rendered tick"
3335        );
3336    }
3337
3338    #[test]
3339    fn pulse_spinner_moves_each_bar_one_level_at_a_time() {
3340        let frames = (0..=240)
3341            .map(|tick| spinner_frame_at(PULSE_TICK * tick))
3342            .collect::<Vec<_>>();
3343        assert!(frames.iter().any(|frame| frame != &frames[0]));
3344        assert_eq!(PULSE_TICK, Duration::from_millis(50));
3345
3346        for pair in frames.windows(2) {
3347            let levels = pair
3348                .iter()
3349                .map(|frame| {
3350                    frame
3351                        .chars()
3352                        .map(|bar| {
3353                            PULSE_LEVELS
3354                                .iter()
3355                                .position(|level| *level == bar)
3356                                .expect("known pulse level")
3357                        })
3358                        .collect::<Vec<_>>()
3359                })
3360                .collect::<Vec<_>>();
3361            assert_eq!(levels[0].len(), 5);
3362            assert!(
3363                levels[0]
3364                    .iter()
3365                    .zip(&levels[1])
3366                    .all(|(before, after)| before.abs_diff(*after) <= 1),
3367                "pulse bars must not jump between adjacent ticks: {:?} -> {:?}",
3368                pair[0],
3369                pair[1]
3370            );
3371        }
3372    }
3373
3374    #[test]
3375    fn working_activity_uses_a_continuous_warm_gradient() {
3376        let text = "▁▅▆▆▃";
3377        let colors = text
3378            .chars()
3379            .enumerate()
3380            .map(|(index, _)| {
3381                working_gradient_color_at(Duration::from_millis(2_500), index, text.chars().count())
3382            })
3383            .collect::<Vec<_>>();
3384
3385        assert_eq!(colors.first(), Some(&Color::Rgb(228, 40, 120)));
3386        assert_eq!(colors.last(), Some(&Color::Rgb(232, 43, 89)));
3387        assert!(colors.windows(2).all(|pair| pair[0] != pair[1]));
3388
3389        let (start, end, _) = working_gradient_colors_at(1.0);
3390        assert_eq!(start, Color::Rgb(235, 45, 65));
3391        assert_eq!(end, Color::Rgb(255, 130, 25));
3392
3393        let (start, end, _) = working_gradient_colors_at(3.0);
3394        assert_eq!(start, Color::Rgb(235, 45, 65));
3395        assert_eq!(end, Color::Rgb(220, 35, 175));
3396    }
3397
3398    #[test]
3399    fn queued_messages_render_above_the_prompt_border_with_their_existing_style() {
3400        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3401        state.queue_user("first task");
3402        state.queue_user("second task");
3403        let area = Rect::new(0, 0, 80, 12);
3404        let mut terminal =
3405            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
3406                .expect("test terminal");
3407
3408        let mut rendered_area = Rect::default();
3409        terminal
3410            .draw(|frame| {
3411                rendered_area = frame.area();
3412                draw(frame, &state);
3413            })
3414            .expect("draw queued message");
3415        let (_, _, _, queue_area, input_area, status_area) =
3416            ui_layout(&state, tui_viewport(rendered_area));
3417        let queue_area = queue_area.expect("message queue area");
3418        assert_eq!(queue_area.x, input_area.x + 1);
3419        assert_eq!(queue_area.y + queue_area.height, input_area.y);
3420        assert_eq!(queue_area.width, input_area.width.saturating_sub(2));
3421        assert_eq!(queue_area.height, 2, "each queued message owns one row");
3422        let buffer = terminal.backend().buffer();
3423        let queued_rows = (queue_area.y..queue_area.y + queue_area.height)
3424            .map(|y| {
3425                (queue_area.x..queue_area.x + queue_area.width)
3426                    .map(|x| buffer[(x, y)].symbol())
3427                    .collect::<String>()
3428            })
3429            .collect::<Vec<_>>();
3430        let status_row = (status_area.x..status_area.x + status_area.width)
3431            .map(|x| buffer[(x, status_area.y)].symbol())
3432            .collect::<String>();
3433        assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), "┌");
3434        assert_eq!(
3435            buffer[(input_area.x + input_area.width - 1, input_area.y)].symbol(),
3436            "┐"
3437        );
3438        assert!(queued_rows[0].contains("Queued 2: first task"));
3439        assert!(queued_rows[1].contains("Queued 2: second task"));
3440        assert!(queued_rows.iter().all(|row| !row.contains('|')));
3441        for y in queue_area.y..queue_area.y + queue_area.height {
3442            assert_eq!(buffer[(queue_area.x, y)].fg, QUEUED_MESSAGE_COLOR);
3443            assert_eq!(
3444                buffer[(queue_area.x + queue_area.width - 1, y)].bg,
3445                QUEUED_MESSAGE_BACKGROUND,
3446                "the dark teal background fills every queue row"
3447            );
3448        }
3449        assert!(!status_row.contains("first task"));
3450        assert!(!status_row.contains("second task"));
3451    }
3452
3453    #[test]
3454    fn ready_submission_bypasses_queue_and_is_not_added_twice_when_started() {
3455        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3456
3457        state.submit_user("send now");
3458
3459        assert!(state.queued_messages.is_empty());
3460        assert_eq!(state.transcript.len(), 1);
3461        assert!(matches!(
3462            &state.transcript[0],
3463            TranscriptItem::User { text, .. } if text == "send now"
3464        ));
3465
3466        // The worker's Started notification still arrives asynchronously, but
3467        // must not promote an already visible direct submission a second time.
3468        state.start_queued_user("send now");
3469        assert_eq!(state.transcript.len(), 1);
3470    }
3471
3472    #[test]
3473    fn busy_submission_remains_queued_until_its_turn_starts() {
3474        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3475        state.busy = true;
3476
3477        state.submit_user("send later");
3478
3479        assert_eq!(state.queued_messages, ["send later"]);
3480        assert!(state.transcript.is_empty());
3481
3482        state.start_queued_user("send later");
3483        assert!(state.queued_messages.is_empty());
3484        assert!(matches!(
3485            &state.transcript[..],
3486            [TranscriptItem::User { text, .. }] if text == "send later"
3487        ));
3488    }
3489
3490    #[test]
3491    fn skill_picker_stays_above_a_visible_message_queue() {
3492        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3493            .with_skill_names(vec!["release-notes".to_owned()]);
3494        state.queue_user("next task");
3495        state.input = "/".to_owned();
3496        state.input_changed();
3497
3498        let area = Rect::new(0, 0, 80, 12);
3499        let (_, picker_area, _, queue_area, input_area, _) = ui_layout(&state, tui_viewport(area));
3500        let picker_area = picker_area.expect("skill picker area");
3501        let queue_area = queue_area.expect("message queue area");
3502        assert_eq!(picker_area.y + picker_area.height, queue_area.y);
3503        assert_eq!(queue_area.y + queue_area.height, input_area.y);
3504        assert_eq!(queue_area.x, input_area.x + 1);
3505    }
3506
3507    #[test]
3508    fn fresh_sessions_show_the_versioned_gradient_welcome_message() {
3509        let state = UiState::from_history(&[], "secret", "model", None, false);
3510        assert!(state.welcome_visible);
3511
3512        let line = welcome_line();
3513        assert_eq!(line.to_string(), WELCOME_MESSAGE);
3514        assert_eq!(
3515            line.spans.first().and_then(|span| span.style.fg),
3516            Some(Color::Rgb(
3517                WELCOME_START_COLOR.0,
3518                WELCOME_START_COLOR.1,
3519                WELCOME_START_COLOR.2,
3520            ))
3521        );
3522        assert_eq!(
3523            line.spans.last().and_then(|span| span.style.fg),
3524            Some(Color::Rgb(
3525                WELCOME_END_COLOR.0,
3526                WELCOME_END_COLOR.1,
3527                WELCOME_END_COLOR.2,
3528            ))
3529        );
3530    }
3531
3532    #[test]
3533    fn welcome_shows_the_tagline_and_attached_agents_paths() {
3534        let state = UiState::from_history(&[], "secret", "model", None, false)
3535            .with_attached_agents(vec![
3536                "/workspace/AGENTS.md".to_owned(),
3537                "/workspace/app/AGENTS.md".to_owned(),
3538            ]);
3539        let lines = welcome_lines(&state.attached_agents);
3540
3541        assert_eq!(lines[1].to_string(), WELCOME_TAGLINE);
3542        assert_eq!(lines[1].style.fg, Some(Color::DarkGray));
3543        assert_eq!(lines[3].to_string(), "Attached AGENTS.md:");
3544        assert_eq!(lines[4].to_string(), "• /workspace/AGENTS.md");
3545        assert_eq!(lines[5].to_string(), "• /workspace/app/AGENTS.md");
3546        assert!(lines[3..]
3547            .iter()
3548            .all(|line| line.style.fg == Some(Color::DarkGray)));
3549    }
3550
3551    #[test]
3552    fn welcome_reports_when_no_agents_file_is_attached() {
3553        let lines = welcome_lines(&[]);
3554        assert_eq!(
3555            lines.last().expect("empty context line").to_string(),
3556            "Attached AGENTS.md: none"
3557        );
3558    }
3559
3560    #[test]
3561    fn resumed_sessions_do_not_show_the_welcome_message() {
3562        let state = UiState::from_history(&[], "secret", "model", None, true);
3563        assert!(!state.welcome_visible);
3564    }
3565
3566    #[test]
3567    fn history_replay_keeps_interruption_after_messages() {
3568        let history = vec![
3569            SessionHistoryRecord::Message {
3570                timestamp: 1,
3571                message: ChatMessage::user("hello".to_owned()),
3572            },
3573            SessionHistoryRecord::Interruption {
3574                timestamp: 2,
3575                reason: "user_cancelled".to_owned(),
3576                phase: "provider_stream".to_owned(),
3577                assistant_text: "partial".to_owned(),
3578                tool_calls: Vec::new(),
3579                tool_results: Vec::new(),
3580            },
3581        ];
3582        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
3583        assert!(matches!(state.transcript[0], TranscriptItem::User { .. }));
3584        assert!(matches!(state.transcript[1], TranscriptItem::Assistant(_)));
3585        assert!(matches!(state.transcript[2], TranscriptItem::Info(_)));
3586        let text = transcript_lines(&state, 80)
3587            .iter()
3588            .map(ToString::to_string)
3589            .collect::<Vec<_>>()
3590            .join("\n");
3591        assert!(!text.contains("choices"));
3592    }
3593
3594    #[test]
3595    fn history_replay_does_not_render_assistant_reasoning_details() {
3596        let mut message = ChatMessage::assistant("visible answer".to_owned(), Vec::new());
3597        message.reasoning_details = Some(vec![serde_json::json!({
3598            "type": "reasoning.text",
3599            "text": "private reasoning"
3600        })]);
3601        let history = [SessionHistoryRecord::Message {
3602            timestamp: 1,
3603            message,
3604        }];
3605        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
3606        let text = transcript_lines(&state, 80)
3607            .iter()
3608            .map(ToString::to_string)
3609            .collect::<Vec<_>>()
3610            .join("\n");
3611        assert!(text.contains("visible answer"));
3612        assert!(!text.contains("private reasoning"));
3613        assert!(!text.contains("reasoning_details"));
3614    }
3615
3616    #[test]
3617    fn history_replay_preserves_repeated_records() {
3618        let history = vec![
3619            SessionHistoryRecord::Message {
3620                timestamp: 1,
3621                message: ChatMessage::assistant("same".to_owned(), Vec::new()),
3622            },
3623            SessionHistoryRecord::Interruption {
3624                timestamp: 2,
3625                reason: "user_cancelled".to_owned(),
3626                phase: "provider_stream".to_owned(),
3627                assistant_text: "same".to_owned(),
3628                tool_calls: Vec::new(),
3629                tool_results: Vec::new(),
3630            },
3631        ];
3632        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
3633        assert_eq!(
3634            state
3635                .transcript
3636                .iter()
3637                .filter(|item| matches!(item, TranscriptItem::Assistant(text) if text == "same"))
3638                .count(),
3639            2
3640        );
3641    }
3642
3643    #[test]
3644    fn user_messages_have_a_single_block_rule_with_inner_and_vertical_padding() {
3645        let history = [SessionHistoryRecord::Message {
3646            timestamp: 1,
3647            message: ChatMessage::user("hello\nworld".to_owned()),
3648        }];
3649        let state = UiState::from_history(&history, "provider-secret", "model", None, false);
3650        let lines = transcript_lines(&state, 12);
3651
3652        assert_eq!(UnicodeWidthStr::width(USER_BORDER_GLYPH), 1);
3653        assert_eq!(lines.len(), 4);
3654        assert_eq!(lines[0].to_string(), "▌");
3655        assert_eq!(lines[1].to_string(), "▌ hello");
3656        assert_eq!(lines[2].to_string(), "▌ world");
3657        assert_eq!(lines[3].to_string(), "▌");
3658        for line in &lines {
3659            assert_eq!(line.spans[0].content, USER_BORDER_GLYPH);
3660            assert_eq!(line.spans[0].style.fg, Some(USER_BORDER_COLOR));
3661            assert!(!line.to_string().contains(['┌', '┐', '└', '┘', '│']));
3662        }
3663        for line in &lines[1..3] {
3664            assert_eq!(line.spans[1].content, " ");
3665            assert_eq!(line.spans[1].style.fg, Some(Color::White));
3666            assert_eq!(line.spans[2].style.fg, Some(Color::White));
3667        }
3668    }
3669
3670    #[test]
3671    fn attached_skill_highlights_its_trigger_in_the_user_message_without_a_notice_line() {
3672        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3673            .with_skill_names(vec!["release-notes".to_owned()]);
3674        state.add_user("/release-notes v1.2.0", "secret");
3675        state.mark_latest_user_skill_attached();
3676
3677        let lines = transcript_lines(&state, 40);
3678        assert_eq!(lines.len(), 3);
3679        assert_eq!(lines[1].spans[1].content, " ");
3680        let cyan_text = lines[1]
3681            .spans
3682            .iter()
3683            .filter(|span| span.style.fg == Some(Color::Cyan))
3684            .map(|span| span.content.as_ref())
3685            .collect::<String>();
3686        assert_eq!(cyan_text, "/release-notes");
3687        assert!(!lines
3688            .iter()
3689            .any(|line| line.to_string().contains("instruction attached")));
3690    }
3691
3692    #[test]
3693    fn transcript_rendering_redacts_history_content() {
3694        let history = [SessionHistoryRecord::Message {
3695            timestamp: 1,
3696            message: ChatMessage::assistant("provider-secret".to_owned(), Vec::new()),
3697        }];
3698        let state = UiState::from_history(&history, "provider-secret", "model", None, false);
3699        let text = transcript_lines(&state, 80)
3700            .iter()
3701            .map(ToString::to_string)
3702            .collect::<Vec<_>>()
3703            .join("\n");
3704        assert!(!text.contains("provider-secret"));
3705    }
3706
3707    #[test]
3708    fn mouse_wheel_disables_following_and_changes_scroll_offset() {
3709        let history = [SessionHistoryRecord::Message {
3710            timestamp: 1,
3711            message: ChatMessage::user("hello".to_owned()),
3712        }];
3713        let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
3714        handle_mouse_event(&mut state, MouseEventKind::ScrollUp, 10);
3715        assert!(!state.auto_scroll);
3716        assert_eq!(state.scroll, 7);
3717        handle_mouse_event(&mut state, MouseEventKind::ScrollDown, 10);
3718        assert!(
3719            state.auto_scroll,
3720            "reaching the bottom resumes transcript following"
3721        );
3722        assert_eq!(state.scroll, 0);
3723        scroll_up(&mut state, 10);
3724        assert!(!state.auto_scroll);
3725        assert_eq!(state.scroll, 7);
3726    }
3727
3728    #[test]
3729    fn wrap_text_breaks_long_lines_and_preserves_empty_lines() {
3730        let rows = wrap_text("12345\n\nabc", 3);
3731        assert_eq!(rows, vec!["123", "45", "", "abc"]);
3732    }
3733
3734    #[test]
3735    fn wrap_line_never_returns_an_empty_vec() {
3736        assert_eq!(wrap_line("", 5), vec![""]);
3737        assert_eq!(wrap_line("abc", 5), vec!["abc"]);
3738    }
3739
3740    #[test]
3741    fn completion_event_does_not_release_input_before_worker_finishes() {
3742        let history = [SessionHistoryRecord::Message {
3743            timestamp: 1,
3744            message: ChatMessage::user("hello".to_owned()),
3745        }];
3746        let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
3747        state.busy = true;
3748        state.active_cancel = Some(CancellationToken::new());
3749        state.apply_event(ProtocolEvent::TurnEnd);
3750        assert!(state.busy);
3751        assert!(state.active_cancel.is_some());
3752        assert_eq!(state.status, "finalizing");
3753    }
3754
3755    #[test]
3756    fn transcript_inserts_a_blank_line_between_items() {
3757        let history = [
3758            SessionHistoryRecord::Message {
3759                timestamp: 1,
3760                message: ChatMessage::user("hi".to_owned()),
3761            },
3762            SessionHistoryRecord::Message {
3763                timestamp: 2,
3764                message: ChatMessage::assistant("hello".to_owned(), Vec::new()),
3765            },
3766        ];
3767        let state = UiState::from_history(&history, "secret", "model", None, false);
3768        let lines = transcript_lines(&state, 80);
3769        assert_eq!(lines.len(), 5);
3770        assert_eq!(lines[0].to_string(), "▌");
3771        assert_eq!(lines[1].to_string(), "▌ hi");
3772        assert_eq!(lines[2].to_string(), "▌");
3773        assert_eq!(lines[3].to_string(), "");
3774        assert_eq!(lines[4].to_string(), "hello");
3775    }
3776
3777    #[test]
3778    fn cmd_call_renders_as_a_compact_status_line_without_raw_json() {
3779        let history = vec![
3780            SessionHistoryRecord::Message {
3781                timestamp: 1,
3782                message: ChatMessage::assistant(
3783                    String::new(),
3784                    vec![crate::model::ChatToolCall {
3785                        id: "call-1".to_owned(),
3786                        name: "cmd".to_owned(),
3787                        arguments: r#"{"command":"pwd"}"#.to_owned(),
3788                    }],
3789                ),
3790            },
3791            SessionHistoryRecord::Message {
3792                timestamp: 2,
3793                message: ChatMessage::tool(
3794                    "call-1".to_owned(),
3795                    "cmd".to_owned(),
3796                    serde_json::json!({"exit_code": 0, "stdout": "secret output"}).to_string(),
3797                ),
3798            },
3799        ];
3800        let state = UiState::from_history(&history, "secret", "model", None, false);
3801        let text = transcript_lines(&state, 80)[0].to_string();
3802
3803        assert_eq!(text, "✓ cmd  $ pwd");
3804        assert!(!text.contains("secret output"));
3805        assert!(!text.contains("{\"command\":\"pwd\"}"));
3806    }
3807
3808    #[test]
3809    fn pending_cmd_calls_use_a_compact_running_status() {
3810        let history = [SessionHistoryRecord::Message {
3811            timestamp: 1,
3812            message: ChatMessage::assistant(
3813                String::new(),
3814                vec![crate::model::ChatToolCall {
3815                    id: "call-1".to_owned(),
3816                    name: "cmd".to_owned(),
3817                    arguments: r#"{"command":"pwd"}"#.to_owned(),
3818                }],
3819            ),
3820        }];
3821        let state = UiState::from_history(&history, "secret", "model", None, false);
3822        let line = &transcript_lines(&state, 80)[0];
3823
3824        let text = line.to_string();
3825        let prefix = "· cmd  $ pwd  ";
3826        assert!(text.starts_with(prefix));
3827        assert!(!text.contains("→ running"));
3828        let frame = &text[prefix.len()..];
3829        assert_eq!(frame.chars().count(), 1);
3830        assert!(frame
3831            .chars()
3832            .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
3833        assert!(line
3834            .spans
3835            .iter()
3836            .all(|span| span.style.fg == Some(PENDING_TOOL_COLOR)));
3837    }
3838
3839    #[test]
3840    fn running_tool_indicators_use_a_traditional_spinner_with_their_own_clock() {
3841        assert_eq!(tool_spinner_frame_at(Duration::ZERO), '|');
3842        assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION), '/');
3843        assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 2), '-');
3844        assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 3), '\\');
3845
3846        let state = UiState::from_history(&[], "secret", "model", None, false);
3847        let spinner = running_tool_status(&state);
3848        assert_eq!(spinner.chars().count(), 1);
3849        assert!(spinner
3850            .chars()
3851            .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
3852        assert!(
3853            subagent_status_display(SubagentStatus::Running, &state).starts_with("running "),
3854            "the background-task list uses the same spinner"
3855        );
3856    }
3857
3858    #[test]
3859    fn successful_cmd_sweeps_from_orange_to_green_left_to_right() {
3860        let started_at = Instant::now();
3861        let character_count = 12;
3862        let halfway = started_at + Duration::from_millis(175);
3863
3864        assert_eq!(
3865            cmd_success_color_at(started_at, started_at, 0, character_count),
3866            PENDING_TOOL_COLOR,
3867            "a new success must retain the pending orange at the sweep start"
3868        );
3869        assert_eq!(
3870            cmd_success_color_at(started_at, halfway, 0, character_count),
3871            Color::Rgb(
3872                TOOL_SUCCESS_GREEN_RGB.0,
3873                TOOL_SUCCESS_GREEN_RGB.1,
3874                TOOL_SUCCESS_GREEN_RGB.2,
3875            ),
3876            "the left edge completes before the rest of the line"
3877        );
3878        assert_eq!(
3879            cmd_success_color_at(started_at, halfway, 9, character_count),
3880            PENDING_TOOL_COLOR,
3881            "the right edge remains orange until the sweep reaches it"
3882        );
3883        assert_eq!(
3884            cmd_success_color_at(
3885                started_at,
3886                started_at + TOOL_SUCCESS_SWEEP_DURATION,
3887                9,
3888                character_count,
3889            ),
3890            Color::Green,
3891            "the completed sweep settles on the existing success green"
3892        );
3893    }
3894
3895    #[test]
3896    fn only_live_successful_cmd_results_start_a_success_sweep() {
3897        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3898        let succeeded = serde_json::json!({"exit_code": 0});
3899
3900        state.add_tool_result("historic", "cmd", succeeded.clone());
3901        state.add_live_tool_result("success", "cmd", succeeded);
3902        state.add_live_tool_result("failed", "cmd", serde_json::json!({"exit_code": 1}));
3903
3904        assert!(!state.cmd_success_started_at.contains_key("historic"));
3905        assert!(state.cmd_success_started_at.contains_key("success"));
3906        assert!(!state.cmd_success_started_at.contains_key("failed"));
3907    }
3908
3909    #[test]
3910    fn cmd_status_distinguishes_nonzero_exit_timeout_and_cancellation() {
3911        let cases = [
3912            (
3913                serde_json::json!({"exit_code": 127}),
3914                "× cmd  $ bad  → exit 127",
3915            ),
3916            (
3917                serde_json::json!({"timed_out": true, "exit_code": null}),
3918                "! cmd  $ slow  → timeout",
3919            ),
3920            (
3921                serde_json::json!({"canceled": true}),
3922                "! cmd  $ stop  → canceled",
3923            ),
3924        ];
3925        for (result, expected) in cases {
3926            let history = vec![
3927                SessionHistoryRecord::Message {
3928                    timestamp: 1,
3929                    message: ChatMessage::assistant(
3930                        String::new(),
3931                        vec![crate::model::ChatToolCall {
3932                            id: "call-1".to_owned(),
3933                            name: "cmd".to_owned(),
3934                            arguments: serde_json::json!({"command": expected.split("$ ").nth(1).unwrap().split("  ").next().unwrap()}).to_string(),
3935                        }],
3936                    ),
3937                },
3938                SessionHistoryRecord::Message {
3939                    timestamp: 2,
3940                    message: ChatMessage::tool(
3941                        "call-1".to_owned(),
3942                        "cmd".to_owned(),
3943                        result.to_string(),
3944                    ),
3945                },
3946            ];
3947            let state = UiState::from_history(&history, "secret", "model", None, false);
3948            assert_eq!(transcript_lines(&state, 80)[0].to_string(), expected);
3949        }
3950    }
3951
3952    #[test]
3953    fn cmd_line_truncates_long_commands_but_never_renders_output() {
3954        let command = "a".repeat(80);
3955        let arguments = serde_json::json!({"command": command}).to_string();
3956        let history = vec![
3957            SessionHistoryRecord::Message {
3958                timestamp: 1,
3959                message: ChatMessage::assistant(
3960                    String::new(),
3961                    vec![crate::model::ChatToolCall {
3962                        id: "call-1".to_owned(),
3963                        name: "cmd".to_owned(),
3964                        arguments,
3965                    }],
3966                ),
3967            },
3968            SessionHistoryRecord::Message {
3969                timestamp: 2,
3970                message: ChatMessage::tool(
3971                    "call-1".to_owned(),
3972                    "cmd".to_owned(),
3973                    serde_json::json!({"exit_code": 0, "stdout": "output"}).to_string(),
3974                ),
3975            },
3976        ];
3977        let state = UiState::from_history(&history, "secret", "model", None, false);
3978        let text = transcript_lines(&state, 200)[0].to_string();
3979        assert!(text.contains(&format!("$ {}…", "a".repeat(50))));
3980        assert!(!text.contains(&"a".repeat(51)));
3981        assert!(!text.contains("output"));
3982    }
3983
3984    #[test]
3985    fn cmd_lines_remain_compact_for_consecutive_calls() {
3986        let history = vec![
3987            SessionHistoryRecord::Message {
3988                timestamp: 1,
3989                message: ChatMessage::assistant(
3990                    String::new(),
3991                    vec![
3992                        crate::model::ChatToolCall {
3993                            id: "call-first".to_owned(),
3994                            name: "cmd".to_owned(),
3995                            arguments: r#"{"command":"first"}"#.to_owned(),
3996                        },
3997                        crate::model::ChatToolCall {
3998                            id: "call-second".to_owned(),
3999                            name: "cmd".to_owned(),
4000                            arguments: r#"{"command":"second"}"#.to_owned(),
4001                        },
4002                    ],
4003                ),
4004            },
4005            SessionHistoryRecord::Message {
4006                timestamp: 2,
4007                message: ChatMessage::tool(
4008                    "call-first".to_owned(),
4009                    "cmd".to_owned(),
4010                    serde_json::json!({"exit_code": 0}).to_string(),
4011                ),
4012            },
4013            SessionHistoryRecord::Message {
4014                timestamp: 3,
4015                message: ChatMessage::tool(
4016                    "call-second".to_owned(),
4017                    "cmd".to_owned(),
4018                    serde_json::json!({"exit_code": 0}).to_string(),
4019                ),
4020            },
4021        ];
4022        let state = UiState::from_history(&history, "secret", "model", None, false);
4023        let lines = transcript_lines(&state, 200);
4024        assert_eq!(lines[0].to_string(), "✓ cmd  $ first");
4025        assert_eq!(lines[2].to_string(), "✓ cmd  $ second");
4026    }
4027
4028    #[test]
4029    fn cmd_status_styles_use_success_failure_and_pending_colors() {
4030        assert_eq!(
4031            cmd_result_status(&serde_json::json!({"exit_code": 0})).2.fg,
4032            Some(Color::Green)
4033        );
4034        assert_eq!(
4035            cmd_result_status(&serde_json::json!({"exit_code": 1})).2.fg,
4036            Some(Color::Red)
4037        );
4038        assert_eq!(
4039            cmd_tool_segments(
4040                "call-1",
4041                "{\"command\":\"pwd\"}",
4042                None,
4043                &UiState::from_history(&[], "secret", "model", None, false)
4044            )[0]
4045            .1
4046            .fg,
4047            Some(PENDING_TOOL_COLOR)
4048        );
4049    }
4050
4051    #[test]
4052    fn recognized_skill_trigger_is_highlighted_but_arguments_remain_default_colored() {
4053        let trigger = active_skill_trigger("/release-notes v1.2.0", &["release-notes".to_owned()]);
4054        assert_eq!(trigger, Some("/release-notes"));
4055
4056        let lines = styled_text_lines(
4057            "/release-notes v1.2.0",
4058            trigger,
4059            80,
4060            Style::default().fg(Color::White),
4061        );
4062        assert_eq!(lines.len(), 1);
4063        assert_eq!(lines[0].to_string(), "/release-notes v1.2.0");
4064        assert_eq!(lines[0].spans[0].content, "/release-notes");
4065        assert_eq!(lines[0].spans[0].style.fg, Some(Color::Cyan));
4066        assert_eq!(lines[0].spans[1].content, " v1.2.0");
4067        assert_eq!(lines[0].spans[1].style.fg, Some(Color::White));
4068    }
4069
4070    #[test]
4071    fn draw_renders_an_active_skill_trigger_in_cyan() {
4072        let mut state = UiState::from_history(&[], "secret", "model", None, false)
4073            .with_skill_names(vec!["release-notes".to_owned()]);
4074        state.input = "/release-notes v1.2.0".to_owned();
4075        state.cursor = state.input.chars().count();
4076
4077        let mut terminal =
4078            Terminal::new(ratatui::backend::TestBackend::new(40, 10)).expect("test terminal");
4079        terminal
4080            .draw(|frame| draw(frame, &state))
4081            .expect("draw input");
4082
4083        // The full-width input block keeps trigger characters cyan while the
4084        // argument that follows stays white.
4085        let buffer = terminal.backend().buffer();
4086        let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 40, 10)));
4087        let input_x = input_area.x + 1;
4088        let input_y = input_area.y + 1;
4089        assert_eq!(buffer[(input_x, input_y)].fg, Color::Cyan);
4090        assert_eq!(
4091            buffer[(input_x + "/release-notes".chars().count() as u16, input_y)].fg,
4092            Color::White
4093        );
4094    }
4095
4096    #[test]
4097    fn main_agent_status_is_in_the_bottom_status_line_below_the_prompt_gradient() {
4098        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4099        let area = Rect::new(0, 0, 80, 10);
4100        let mut terminal =
4101            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4102                .expect("test terminal");
4103
4104        terminal
4105            .draw(|frame| draw(frame, &state))
4106            .expect("draw ready status");
4107        let (_, _, _, _, input_area, status_area) = ui_layout(&state, tui_viewport(area));
4108        let buffer = terminal.backend().buffer();
4109        let activity_width = UnicodeWidthStr::width(activity_text(&state).as_str()) as u16;
4110        let activity_start = status_area.x
4111            + status_area
4112                .width
4113                .saturating_sub(activity_width)
4114                .saturating_add(1)
4115                / 2;
4116        let rendered_status = (activity_start..activity_start + activity_width)
4117            .map(|x| buffer[(x, status_area.y)].symbol())
4118            .collect::<String>();
4119        assert_eq!(rendered_status, activity_text(&state));
4120        let left_status = (status_area.x..activity_start)
4121            .map(|x| buffer[(x, status_area.y)].symbol())
4122            .collect::<String>();
4123        assert!(left_status.contains("model (default)"));
4124        assert_eq!(
4125            buffer[(activity_start - 1, status_area.y)].symbol(),
4126            " ",
4127            "the READY indicator uses five lowest bars without text"
4128        );
4129        let viewport = tui_viewport(area);
4130        assert_eq!(
4131            input_area.x, viewport.x,
4132            "the prompt begins after the left TUI gutter"
4133        );
4134        assert_eq!(input_area.width, viewport.width);
4135        assert_eq!(input_area.y + input_area.height, status_area.y);
4136        assert_eq!(
4137            buffer[(activity_start, status_area.y)].fg,
4138            activity_style_at(&state, Duration::ZERO)
4139                .fg
4140                .expect("ready status colour")
4141        );
4142
4143        // A multiline prompt grows above the status line without moving the
4144        // status indicator into the input area.
4145        state.input = "first\nsecond\nthird".to_owned();
4146        state.cursor = state.input.chars().count();
4147        terminal
4148            .draw(|frame| draw(frame, &state))
4149            .expect("draw multiline ready status");
4150        let (_, _, _, _, input_area, status_area) = ui_layout(&state, tui_viewport(area));
4151        let buffer = terminal.backend().buffer();
4152        assert!(
4153            input_area.height > 3,
4154            "the prompt should have grown vertically"
4155        );
4156        assert_eq!(
4157            (activity_start..activity_start + activity_width)
4158                .map(|x| buffer[(x, status_area.y)].symbol())
4159                .collect::<String>(),
4160            "▁▁▁▁▁",
4161            "the READY bars remain centered as the prompt grows"
4162        );
4163        assert_eq!(input_area.y + input_area.height, status_area.y);
4164
4165        state.set_status("working");
4166        state.busy = true;
4167        terminal
4168            .draw(|frame| draw(frame, &state))
4169            .expect("draw working status");
4170        let (_, _, _, _, _input_area, status_area) = ui_layout(&state, tui_viewport(area));
4171        let buffer = terminal.backend().buffer();
4172        let activity_width = UnicodeWidthStr::width(activity_text(&state).as_str()) as u16;
4173        let activity_start = status_area.x
4174            + status_area
4175                .width
4176                .saturating_sub(activity_width)
4177                .saturating_add(1)
4178                / 2;
4179        assert_eq!(
4180            buffer[(activity_start, status_area.y)].fg,
4181            activity_style_at(&state, Duration::ZERO)
4182                .fg
4183                .expect("working status colour")
4184        );
4185        assert!(
4186            PULSE_LEVELS.contains(
4187                &buffer[(activity_start, status_area.y)]
4188                    .symbol()
4189                    .chars()
4190                    .next()
4191                    .expect("pulse bar")
4192            ),
4193            "the pulse indicator begins at the exact status-line center"
4194        );
4195    }
4196
4197    #[test]
4198    fn cjk_input_keeps_the_terminal_cursor_in_the_prompt_without_resetting_activity() {
4199        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4200        state.set_status("working");
4201        state.busy = true;
4202        state.input = "한글".to_owned();
4203        state.cursor = state.input.chars().count();
4204        let activity_started_at = state.activity_started_at;
4205        let tool_animation_epoch = state.tool_animation_epoch;
4206        let sample_at = Instant::now();
4207        let activity_before = state.activity_levels_at(sample_at);
4208
4209        // A committed CJK character must move the hardware cursor by its
4210        // display width, and input edits must not restart either animation.
4211        state.input_changed();
4212        assert_eq!(state.activity_started_at, activity_started_at);
4213        assert_eq!(state.tool_animation_epoch, tool_animation_epoch);
4214        assert_eq!(state.activity_levels_at(sample_at), activity_before);
4215
4216        let area = Rect::new(0, 0, 80, 10);
4217        let mut terminal =
4218            Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4219                .expect("test terminal");
4220        terminal
4221            .draw(|frame| draw(frame, &state))
4222            .expect("draw CJK input while working");
4223        let (_, _, _, _, input_area, status_area) = ui_layout(&state, tui_viewport(area));
4224        assert_ne!(input_area.y, status_area.y);
4225        terminal.backend_mut().assert_cursor_position((
4226            input_area.x + 1 + UnicodeWidthStr::width(state.input.as_str()) as u16,
4227            input_area.y + 1,
4228        ));
4229    }
4230
4231    #[test]
4232    fn input_border_has_a_teal_to_green_gradient_and_keeps_the_cursor() {
4233        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4234
4235        let mut terminal =
4236            Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
4237        terminal
4238            .draw(|frame| draw(frame, &state))
4239            .expect("draw ready input");
4240
4241        let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 80, 10)));
4242        let buffer = terminal.backend().buffer();
4243        assert_eq!(buffer[(0, input_area.y)].symbol(), " ");
4244        assert_eq!(buffer[(79, input_area.y)].symbol(), " ");
4245        assert_eq!(
4246            buffer[(input_area.x, input_area.y)].fg,
4247            Color::Rgb(
4248                PROMPT_BORDER_START_COLOR.0,
4249                PROMPT_BORDER_START_COLOR.1,
4250                PROMPT_BORDER_START_COLOR.2,
4251            )
4252        );
4253        assert_eq!(
4254            buffer[(
4255                input_area.x + input_area.width.saturating_sub(1),
4256                input_area.y
4257            )]
4258                .fg,
4259            Color::Rgb(
4260                PROMPT_BORDER_END_COLOR.0,
4261                PROMPT_BORDER_END_COLOR.1,
4262                PROMPT_BORDER_END_COLOR.2,
4263            )
4264        );
4265        assert_eq!(
4266            buffer[(input_area.x, input_area.y + 1)].fg,
4267            Color::Rgb(
4268                PROMPT_BORDER_START_COLOR.0,
4269                PROMPT_BORDER_START_COLOR.1,
4270                PROMPT_BORDER_START_COLOR.2,
4271            ),
4272            "the left side uses the teal endpoint"
4273        );
4274        assert_eq!(
4275            buffer[(
4276                input_area.x + input_area.width.saturating_sub(1),
4277                input_area.y + 1,
4278            )]
4279                .fg,
4280            Color::Rgb(
4281                PROMPT_BORDER_END_COLOR.0,
4282                PROMPT_BORDER_END_COLOR.1,
4283                PROMPT_BORDER_END_COLOR.2,
4284            ),
4285            "the right side uses the green endpoint"
4286        );
4287        assert_ne!(
4288            prompt_border_gradient_color_at(input_area.width / 2, input_area.width),
4289            Color::Rgb(
4290                PROMPT_BORDER_START_COLOR.0,
4291                PROMPT_BORDER_START_COLOR.1,
4292                PROMPT_BORDER_START_COLOR.2,
4293            ),
4294            "middle border cells interpolate away from teal"
4295        );
4296        assert_ne!(
4297            prompt_border_gradient_color_at(input_area.width / 2, input_area.width),
4298            Color::Rgb(
4299                PROMPT_BORDER_END_COLOR.0,
4300                PROMPT_BORDER_END_COLOR.1,
4301                PROMPT_BORDER_END_COLOR.2,
4302            ),
4303            "middle border cells interpolate before green"
4304        );
4305        assert_eq!(
4306            buffer[(input_area.x, input_area.y)].symbol(),
4307            "┌",
4308            "the prompt uses square rather than rounded corners"
4309        );
4310        assert_eq!(
4311            buffer[(
4312                input_area.x + input_area.width.saturating_sub(1),
4313                input_area.y + input_area.height.saturating_sub(1),
4314            )]
4315                .symbol(),
4316            "┘"
4317        );
4318        assert_eq!(buffer[(input_area.x + 1, input_area.y + 1)].symbol(), " ");
4319        assert_eq!(input_display_text(&state), "");
4320        terminal
4321            .backend_mut()
4322            .assert_cursor_position((input_area.x + 1, input_area.y + 1));
4323
4324        state.busy = true;
4325        state.set_status("working");
4326        terminal
4327            .draw(|frame| draw(frame, &state))
4328            .expect("draw working input");
4329        let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 80, 10)));
4330        let buffer = terminal.backend().buffer();
4331        assert_eq!(
4332            buffer[(input_area.x, input_area.y)].fg,
4333            Color::Rgb(
4334                PROMPT_BORDER_START_COLOR.0,
4335                PROMPT_BORDER_START_COLOR.1,
4336                PROMPT_BORDER_START_COLOR.2,
4337            ),
4338            "working state preserves the teal endpoint"
4339        );
4340        assert_eq!(
4341            buffer[(
4342                input_area.x + input_area.width.saturating_sub(1),
4343                input_area.y
4344            )]
4345                .fg,
4346            Color::Rgb(
4347                PROMPT_BORDER_END_COLOR.0,
4348                PROMPT_BORDER_END_COLOR.1,
4349                PROMPT_BORDER_END_COLOR.2,
4350            ),
4351            "working state preserves the green endpoint"
4352        );
4353
4354        state.input = "queued message".to_owned();
4355        assert_eq!(input_display_text(&state), "queued message");
4356    }
4357
4358    #[test]
4359    fn only_known_leading_skill_commands_activate_input_highlighting() {
4360        let skills = ["release-notes".to_owned()];
4361        assert_eq!(
4362            active_skill_trigger("/missing", &skills),
4363            None,
4364            "unknown commands are rejected by the turn engine and must not look active"
4365        );
4366        assert_eq!(
4367            active_skill_trigger("/skill:release-notes", &skills),
4368            None,
4369            "the removed /skill: wrapper must not look active"
4370        );
4371        assert_eq!(
4372            active_skill_trigger("write /release-notes", &skills),
4373            None,
4374            "only the command prefix accepted by the turn engine is active"
4375        );
4376        assert_eq!(active_skill_trigger("/", &skills), None);
4377    }
4378
4379    #[test]
4380    fn highlighted_skill_trigger_remains_styled_when_wrapped() {
4381        let input = "/release-notes argument";
4382        let trigger = active_skill_trigger(input, &["release-notes".to_owned()]);
4383        let lines = styled_text_lines(input, trigger, 8, Style::default().fg(Color::White));
4384        let highlighted = lines
4385            .iter()
4386            .flat_map(|line| line.spans.iter())
4387            .filter(|span| span.style.fg == Some(Color::Cyan))
4388            .map(|span| span.content.as_ref())
4389            .collect::<String>();
4390        assert_eq!(highlighted, "/release-notes");
4391    }
4392
4393    #[test]
4394    fn input_has_no_prompt_marker_and_trailing_newline_is_visible() {
4395        assert_eq!(input_prompt("hello"), "hello");
4396        assert_eq!(wrap_text("hello\n", 80), vec!["hello", ""]);
4397    }
4398
4399    #[test]
4400    fn input_prompt_wraps_to_multiple_rows_when_long() {
4401        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4402        state.input = "abcdefghij".to_owned();
4403        // width 5: the input wraps across multiple rows without a prompt marker.
4404        let rows = input_visible_rows(&state, 5);
4405        assert!(rows >= 2);
4406    }
4407
4408    #[test]
4409    fn cursor_editing_moves_by_characters_and_preserves_unicode() {
4410        let mut input = "가나".to_owned();
4411        let mut cursor = input.chars().count();
4412        cursor -= 1;
4413        insert_at_cursor(&mut input, &mut cursor, 'x');
4414        assert_eq!(input, "가x나");
4415        assert_eq!(cursor, 2);
4416        assert!(remove_before_cursor(&mut input, &mut cursor));
4417        assert_eq!(input, "가나");
4418        assert_eq!(cursor, 1);
4419    }
4420
4421    #[test]
4422    fn cursor_row_tracks_newlines_and_wrapping() {
4423        assert_eq!(cursor_row("hello\nworld", 6, 80), 1);
4424        assert_eq!(cursor_row("abcdef", 4, 3), 1);
4425    }
4426
4427    #[test]
4428    fn shift_enter_inserts_at_the_cursor_and_moves_it_to_the_new_row() {
4429        let mut input = "beforeafter".to_owned();
4430        let mut cursor = 6;
4431        insert_at_cursor(&mut input, &mut cursor, '\n');
4432
4433        assert_eq!(input, "before\nafter");
4434        assert_eq!(cursor, 7);
4435        assert_eq!(cursor_row(&input, cursor, 80), 1);
4436    }
4437
4438    #[test]
4439    fn shift_enter_renders_the_cursor_on_the_new_input_row() {
4440        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4441        state.input = "beforeafter".to_owned();
4442        state.cursor = 6;
4443        insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
4444
4445        let mut terminal =
4446            Terminal::new(ratatui::backend::TestBackend::new(20, 10)).expect("test terminal");
4447        terminal
4448            .draw(|frame| draw(frame, &state))
4449            .expect("draw input cursor");
4450
4451        // After inserting a newline, the cursor is at the start of the second
4452        // input row.
4453        let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 20, 10)));
4454        terminal
4455            .backend_mut()
4456            .assert_cursor_position((input_area.x + 1, input_area.y + 2));
4457    }
4458
4459    #[test]
4460    fn tool_results_attach_to_their_matching_call_after_consecutive_calls() {
4461        let history = vec![
4462            SessionHistoryRecord::Message {
4463                timestamp: 1,
4464                message: ChatMessage::assistant(
4465                    String::new(),
4466                    vec![
4467                        crate::model::ChatToolCall {
4468                            id: "call-first".to_owned(),
4469                            name: "cmd".to_owned(),
4470                            arguments: r#"{"command":"first"}"#.to_owned(),
4471                        },
4472                        crate::model::ChatToolCall {
4473                            id: "call-second".to_owned(),
4474                            name: "cmd".to_owned(),
4475                            arguments: r#"{"command":"second"}"#.to_owned(),
4476                        },
4477                    ],
4478                ),
4479            },
4480            SessionHistoryRecord::Message {
4481                timestamp: 2,
4482                message: ChatMessage::tool(
4483                    "call-first".to_owned(),
4484                    "cmd".to_owned(),
4485                    serde_json::json!({"stdout":"first result","stderr":""}).to_string(),
4486                ),
4487            },
4488            SessionHistoryRecord::Message {
4489                timestamp: 3,
4490                message: ChatMessage::tool(
4491                    "call-second".to_owned(),
4492                    "cmd".to_owned(),
4493                    serde_json::json!({"stdout":"second result","stderr":""}).to_string(),
4494                ),
4495            },
4496        ];
4497
4498        let state = UiState::from_history(&history, "secret", "model", None, false);
4499        let lines = transcript_lines(&state, 200);
4500        assert_eq!(
4501            lines.len(),
4502            3,
4503            "only the two call lines and their separator remain"
4504        );
4505        assert_eq!(lines[0].to_string(), "✓ cmd  $ first");
4506        assert_eq!(lines[2].to_string(), "✓ cmd  $ second");
4507    }
4508    #[test]
4509    fn subagent_tasks_keep_metadata_until_completion_then_leave_live_list() {
4510        let history = vec![
4511            SessionHistoryRecord::Message {
4512                timestamp: 1,
4513                message: ChatMessage::assistant(
4514                    String::new(),
4515                    vec![crate::model::ChatToolCall {
4516                        id: "call-worker".to_owned(),
4517                        name: "spawn_subagent".to_owned(),
4518                        arguments: serde_json::json!({
4519                            "task": "Inspect the command UI",
4520                            "model": "worker-model",
4521                            "effort": "high"
4522                        })
4523                        .to_string(),
4524                    }],
4525                ),
4526            },
4527            SessionHistoryRecord::Message {
4528                timestamp: 2,
4529                message: ChatMessage::tool(
4530                    "call-worker".to_owned(),
4531                    "spawn_subagent".to_owned(),
4532                    serde_json::json!({"task_id":"subagent-1","status":"queued"}).to_string(),
4533                ),
4534            },
4535        ];
4536        let mut state = UiState::from_history(&history, "secret", "model", None, false);
4537        assert_eq!(state.subagents.len(), 1);
4538        let task = &state.subagents[0];
4539        assert_eq!(task.task, "Inspect the command UI");
4540        assert_eq!(task.task_id.as_deref(), Some("subagent-1"));
4541        assert_eq!(task.model.as_deref(), Some("worker-model"));
4542        assert_eq!(task.effort.as_deref(), Some("high"));
4543        assert_eq!(task.status, SubagentStatus::Running);
4544        assert!(transcript_lines(&state, 80)[0]
4545            .to_string()
4546            .contains("↗ subagent  Inspect the command UI  "));
4547        assert!(!transcript_lines(&state, 80)[0]
4548            .to_string()
4549            .contains("→ running"));
4550
4551        state.complete_subagent(
4552            "subagent-1",
4553            serde_json::json!({"model":"worker-model","output":"finished"}),
4554        );
4555        assert!(
4556            state.subagents.is_empty(),
4557            "completed workers are removed from the live background-task list"
4558        );
4559        let completed_line = transcript_lines(&state, 80)[0].to_string();
4560        assert!(completed_line.contains("Inspect the command UI"));
4561        assert!(completed_line.contains("→ completed"));
4562        assert!(!completed_line.contains("{\"task\""));
4563    }
4564
4565    #[test]
4566    fn resumed_subagent_completion_clears_the_live_list_without_rendering_internal_prompt() {
4567        let history = vec![
4568            SessionHistoryRecord::Message {
4569                timestamp: 1,
4570                message: ChatMessage::assistant(
4571                    String::new(),
4572                    vec![crate::model::ChatToolCall {
4573                        id: "call-worker".to_owned(),
4574                        name: "spawn_subagent".to_owned(),
4575                        arguments: serde_json::json!({"task":"Inspect"}).to_string(),
4576                    }],
4577                ),
4578            },
4579            SessionHistoryRecord::Message {
4580                timestamp: 2,
4581                message: ChatMessage::tool(
4582                    "call-worker".to_owned(),
4583                    "spawn_subagent".to_owned(),
4584                    serde_json::json!({"task_id":"subagent-1","status":"queued"}).to_string(),
4585                ),
4586            },
4587            SessionHistoryRecord::Message {
4588                timestamp: 3,
4589                message: ChatMessage::user(Harness::subagent_notification(
4590                    &crate::app::SubagentCompletion {
4591                        task_id: "subagent-1".to_owned(),
4592                        result: serde_json::json!({"output":"resumed result"}),
4593                    },
4594                )),
4595            },
4596        ];
4597        let state = UiState::from_history(&history, "secret", "model", None, true);
4598        assert!(
4599            state.subagents.is_empty(),
4600            "a completed worker is not restored into the live background-task list"
4601        );
4602        assert!(!state.transcript.iter().any(|item| {
4603            matches!(item, TranscriptItem::User { text, .. } if text.contains("Background subagent"))
4604        }));
4605    }
4606
4607    #[test]
4608    fn subagent_overlay_is_upper_right_one_third_wide_and_grows_per_task() {
4609        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4610        for task_id in ["subagent-1", "subagent-2", "subagent-3"] {
4611            state.subagents.push(SubagentTask {
4612                call_id: format!("call-{task_id}"),
4613                task_id: Some(task_id.to_owned()),
4614                task: format!("Inspect {task_id}"),
4615                model: None,
4616                effort: None,
4617                status: SubagentStatus::Queued,
4618                result: None,
4619            });
4620        }
4621
4622        let area = Rect::new(0, 0, 120, 20);
4623        let overlay = subagent_overlay_area(&state, area).expect("subagent overlay");
4624        assert_eq!(overlay, Rect::new(80, 0, 40, 5));
4625
4626        // The overlay floats above the transcript and does not steal rows from
4627        // the input, queue, or status layout.
4628        let (chat, _, layout_overlay, _, input, status) = ui_layout(&state, area);
4629        assert_eq!(layout_overlay, Some(overlay));
4630        assert_eq!(chat.height, 16);
4631        assert_eq!(input.y, 16);
4632        assert_eq!(status.y, 19);
4633
4634        let empty = UiState::from_history(&[], "secret", "model", None, false);
4635        assert_eq!(subagent_overlay_area(&empty, area), None);
4636    }
4637
4638    #[test]
4639    fn subagent_overlay_stays_above_the_picker_without_breaking_its_left_border() {
4640        let mut state = UiState::from_history(&[], "secret", "model", None, false)
4641            .with_skill_names(vec!["alpha".to_owned()]);
4642        state.input = "/".to_owned();
4643        for index in 0..14 {
4644            state.subagents.push(SubagentTask {
4645                call_id: format!("call-{index}"),
4646                task_id: Some(format!("subagent-{index}")),
4647                task: format!("Inspect task {index}"),
4648                model: None,
4649                effort: None,
4650                status: SubagentStatus::Queued,
4651                result: None,
4652            });
4653        }
4654
4655        let full_area = Rect::new(0, 0, 120, 20);
4656        let area = tui_viewport(full_area);
4657        let (_, picker_area, overlay_area, _, _, _) = ui_layout(&state, area);
4658        let picker_area = picker_area.expect("skill picker");
4659        let overlay_area = overlay_area.expect("subagent overlay");
4660        assert!(
4661            picker_area.y > overlay_area.y && picker_area.y < overlay_area.y + overlay_area.height,
4662            "the picker must overlap the task overlay for this layering check"
4663        );
4664
4665        let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
4666            full_area.width,
4667            full_area.height,
4668        ))
4669        .expect("test terminal");
4670        terminal
4671            .draw(|frame| draw(frame, &state))
4672            .expect("draw overlapping picker and overlay");
4673
4674        let buffer = terminal.backend().buffer();
4675        assert_eq!(buffer[(overlay_area.x, overlay_area.y)].symbol(), "┌");
4676        assert_eq!(
4677            buffer[(overlay_area.x, picker_area.y)].symbol(),
4678            "│",
4679            "the later picker render must not cut through the overlay left border"
4680        );
4681        assert_eq!(
4682            buffer[(overlay_area.x, picker_area.y)].bg,
4683            SUBAGENT_OVERLAY_BACKGROUND
4684        );
4685    }
4686
4687    #[test]
4688    fn subagent_overlay_uses_magenta_one_line_indicators_without_status_words() {
4689        let mut state = UiState::from_history(&[], "provider-secret", "model", None, false);
4690        state.subagents.push(SubagentTask {
4691            call_id: "call-worker".to_owned(),
4692            task_id: Some("subagent-1".to_owned()),
4693            task: "Inspect the command UI".to_owned(),
4694            model: Some("worker-model".to_owned()),
4695            effort: Some("high".to_owned()),
4696            status: SubagentStatus::Running,
4697            result: None,
4698        });
4699        let full_area = Rect::new(0, 0, 120, 20);
4700        let area = tui_viewport(full_area);
4701        let overlay = subagent_overlay_area(&state, area).expect("subagent overlay");
4702        let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
4703            full_area.width,
4704            full_area.height,
4705        ))
4706        .expect("test terminal");
4707        terminal
4708            .draw(|frame| draw(frame, &state))
4709            .expect("draw subagent overlay");
4710
4711        assert_eq!(overlay.width, area.width / 3);
4712        assert_eq!(overlay.x + overlay.width, area.x + area.width);
4713        assert_eq!(overlay.y, area.y);
4714        assert_eq!(overlay.height, 3, "one task plus the two border rows");
4715
4716        let buffer = terminal.backend().buffer();
4717        let screen = buffer
4718            .content()
4719            .iter()
4720            .map(|cell| cell.symbol())
4721            .collect::<String>();
4722        assert!(screen.contains("Subagents"));
4723        assert!(screen.contains("Inspect the command UI"));
4724        assert!(!screen.contains("running"));
4725        assert!(!screen.contains("working"));
4726        assert!(!screen.contains("workers"));
4727        assert!(!screen.contains("subagent-1"));
4728        assert!(!screen.contains("worker-model"));
4729        assert!(!screen.contains("provider-secret"));
4730        assert_eq!(
4731            buffer[(overlay.x + 1, overlay.y + 1)].fg,
4732            SUBAGENT_OVERLAY_COLOR
4733        );
4734        assert_eq!(
4735            buffer[(overlay.x + 1, overlay.y + 1)].bg,
4736            SUBAGENT_OVERLAY_BACKGROUND
4737        );
4738        assert_eq!(
4739            buffer[(overlay.x, overlay.y)].bg,
4740            SUBAGENT_OVERLAY_BACKGROUND
4741        );
4742        assert_eq!(
4743            buffer[(overlay.x, overlay.y)].symbol(),
4744            "┌",
4745            "the upper-left corner remains visible above other TUI layers"
4746        );
4747        assert_eq!(
4748            buffer[(overlay.x, overlay.y + 1)].symbol(),
4749            "│",
4750            "the left border remains continuous below the corner"
4751        );
4752    }
4753}
4754
4755#[cfg(test)]
4756mod skill_picker_tests {
4757    use super::*;
4758
4759    fn skill_names() -> Vec<String> {
4760        ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
4761            .into_iter()
4762            .map(str::to_owned)
4763            .collect()
4764    }
4765
4766    #[test]
4767    fn built_in_commands_share_the_slash_catalog_without_becoming_skills() {
4768        assert_eq!(
4769            command_names(vec!["release-notes".to_owned(), "settings".to_owned()]),
4770            vec!["exit", "release-notes", "settings"]
4771        );
4772        assert_eq!(
4773            builtin_command("/settings ignored arguments"),
4774            Some(BuiltinCommand::Settings)
4775        );
4776        assert_eq!(builtin_command("  /exit  "), Some(BuiltinCommand::Exit));
4777        assert_eq!(builtin_command("/settings-extra"), None);
4778    }
4779
4780    #[test]
4781    fn settings_viewport_follows_focus_instead_of_truncating_the_catalog_head() {
4782        assert_eq!(selection_range(30, 0, 12), 0..12);
4783        assert_eq!(selection_range(30, 11, 12), 0..12);
4784        assert_eq!(selection_range(30, 12, 12), 1..13);
4785        assert_eq!(selection_range(30, 29, 12), 18..30);
4786    }
4787
4788    #[test]
4789    fn model_selection_uses_advertised_efforts_and_preserves_the_current_choice() {
4790        let mut state = UiState::from_history(&[], "secret", "old", Some("medium"), false);
4791        state.open_catalog(Ok(vec![ProviderModel {
4792            id: "openai/gpt-5.6-sol".to_owned(),
4793            efforts: Some(vec![
4794                "max".to_owned(),
4795                "high".to_owned(),
4796                "medium".to_owned(),
4797                "low".to_owned(),
4798            ]),
4799        }]));
4800        state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
4801
4802        let SettingsState::Effort { model, focus, .. } =
4803            state.settings.as_ref().expect("effort picker")
4804        else {
4805            panic!("model selection should open the effort picker");
4806        };
4807        assert_eq!(model.id, "openai/gpt-5.6-sol");
4808        assert_eq!(*focus, 3, "default occupies index zero before medium");
4809
4810        let selected = state
4811            .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
4812            .expect("effort selection");
4813        assert_eq!(
4814            selected,
4815            ("openai/gpt-5.6-sol".to_owned(), Some("medium".to_owned()))
4816        );
4817    }
4818
4819    #[test]
4820    fn effort_default_selection_does_not_shift_to_the_first_advertised_effort() {
4821        let mut state = UiState::from_history(&[], "secret", "old", None, false);
4822        state.open_catalog(Ok(vec![ProviderModel {
4823            id: "model".to_owned(),
4824            efforts: Some(vec!["high".to_owned(), "low".to_owned()]),
4825        }]));
4826        state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
4827
4828        let selected = state
4829            .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
4830            .expect("default effort selection");
4831        assert_eq!(selected, ("model".to_owned(), None));
4832    }
4833
4834    #[test]
4835    fn reasoning_indicator_changes_to_complete_and_stays_dark_gray() {
4836        let mut state = UiState::from_history(&[], "secret", "model", None, false);
4837        state.show_thinking();
4838
4839        let active_lines = transcript_lines(&state, 80);
4840        let active = active_lines.last().expect("reasoning line");
4841        assert!(active.to_string().starts_with("Reasoning... "));
4842        assert_eq!(active.style.fg, Some(Color::DarkGray));
4843
4844        state.complete_reasoning();
4845        let complete_lines = transcript_lines(&state, 80);
4846        let complete = complete_lines.last().expect("complete line");
4847        assert_eq!(complete.to_string(), "Reasoning Complete");
4848        assert_eq!(complete.style.fg, Some(Color::DarkGray));
4849    }
4850
4851    #[test]
4852    fn slash_picker_filters_only_leading_command_text_and_hides_without_matches() {
4853        let names = skill_names();
4854        assert_eq!(
4855            matching_skill_names("/", &names),
4856            vec!["alpha", "beta", "build", "charlie", "deploy", "doctor"]
4857        );
4858        assert_eq!(matching_skill_names("/b", &names), vec!["beta", "build"]);
4859        assert!(matching_skill_names("/missing", &names).is_empty());
4860        assert!(matching_skill_names("message /b", &names).is_empty());
4861        assert!(matching_skill_names("/beta arguments", &names).is_empty());
4862    }
4863
4864    #[test]
4865    fn slash_picker_focuses_the_top_match_and_moves_within_filtered_results() {
4866        let mut state = UiState::from_history(&[], "secret", "model", None, false)
4867            .with_skill_names(skill_names());
4868        state.input = "/b".to_owned();
4869        state.input_changed();
4870
4871        assert!(state.skill_picker_visible());
4872        assert_eq!(state.skill_picker_focus, 0);
4873        assert!(state.move_skill_picker(true));
4874        assert_eq!(state.skill_picker_focus, 1);
4875        assert!(state.move_skill_picker(true));
4876        assert_eq!(state.skill_picker_focus, 1, "focus does not leave the list");
4877        assert!(state.move_skill_picker(false));
4878        assert_eq!(state.skill_picker_focus, 0);
4879
4880        state.input = "/missing".to_owned();
4881        state.input_changed();
4882        assert!(!state.skill_picker_visible());
4883        assert!(!state.move_skill_picker(true));
4884    }
4885
4886    #[test]
4887    fn focused_builtins_are_distinguished_from_skills() {
4888        let mut state = UiState::from_history(&[], "secret", "model", None, false)
4889            .with_skill_names(command_names(skill_names()));
4890        state.input = "/se".to_owned();
4891        state.input_changed();
4892        assert_eq!(
4893            state.focused_builtin_command(),
4894            Some(BuiltinCommand::Settings)
4895        );
4896
4897        state.input = "/be".to_owned();
4898        state.input_changed();
4899        assert_eq!(state.focused_builtin_command(), None);
4900    }
4901
4902    #[test]
4903    fn selecting_the_focused_skill_leaves_the_completed_command_ready_to_send() {
4904        let mut state = UiState::from_history(&[], "secret", "model", None, false)
4905            .with_skill_names(skill_names());
4906        state.input = "/b".to_owned();
4907        state.input_changed();
4908        state.move_skill_picker(true);
4909
4910        assert!(state.select_focused_skill());
4911        assert_eq!(state.input, "/build");
4912        assert_eq!(state.cursor, "/build".chars().count());
4913        assert!(
4914            !state.skill_picker_visible(),
4915            "the first Enter completes the input rather than sending it"
4916        );
4917        assert!(
4918            !state.select_focused_skill(),
4919            "a second Enter follows the normal send/attachment path"
4920        );
4921    }
4922
4923    #[test]
4924    fn slash_picker_overlays_without_reflowing_the_transcript_when_match_count_changes() {
4925        let mut state = UiState::from_history(&[], "secret", "model", None, false)
4926            .with_skill_names(skill_names());
4927        let area = Rect::new(0, 0, 40, 16);
4928        state.transcript = (0..20)
4929            .map(|index| TranscriptItem::Assistant(format!("message {index}")))
4930            .collect();
4931
4932        state.input = "/a".to_owned();
4933        state.input_changed();
4934        let (narrow_chat, narrow_picker, _, _, narrow_input, _) = ui_layout(&state, area);
4935        let narrow_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
4936
4937        state.input = "/".to_owned();
4938        state.input_changed();
4939        let (broad_chat, broad_picker, _, _, broad_input, _) = ui_layout(&state, area);
4940        let broad_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
4941
4942        assert_ne!(
4943            narrow_picker, broad_picker,
4944            "the overlay may fit its contents"
4945        );
4946        assert_eq!(narrow_chat, broad_chat);
4947        assert_eq!(narrow_input, broad_input);
4948        assert_eq!(
4949            narrow_scroll, broad_scroll,
4950            "the overlay does not reduce the transcript viewport"
4951        );
4952    }
4953
4954    #[test]
4955    fn slash_picker_keeps_the_focused_item_in_its_five_row_viewport() {
4956        assert_eq!(skill_picker_range(20, 0), 0..5);
4957        assert_eq!(skill_picker_range(20, 4), 0..5);
4958        assert_eq!(skill_picker_range(20, 5), 1..6);
4959        assert_eq!(skill_picker_range(20, 19), 15..20);
4960    }
4961
4962    #[test]
4963    fn slash_picker_leaves_the_ready_indicator_in_the_status_line() {
4964        let mut state = UiState::from_history(&[], "secret", "model", None, false)
4965            .with_skill_names(vec!["a".to_owned(), "b".to_owned()]);
4966        state.input = "/".to_owned();
4967        state.input_changed();
4968        let mut terminal =
4969            Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
4970        terminal
4971            .draw(|frame| draw(frame, &state))
4972            .expect("draw TUI");
4973
4974        let screen = terminal
4975            .backend()
4976            .buffer()
4977            .content()
4978            .iter()
4979            .map(|cell| cell.symbol())
4980            .collect::<String>();
4981        assert!(screen.contains("/a"));
4982        assert!(
4983            screen.contains("▁▁▁▁▁"),
4984            "the READY bars remain in the bottom status line below the picker/input"
4985        );
4986    }
4987
4988    #[test]
4989    fn slash_picker_is_rendered_immediately_above_the_input() {
4990        let mut state = UiState::from_history(&[], "secret", "model", None, false)
4991            .with_skill_names(skill_names());
4992        state.input = "/".to_owned();
4993        state.input_changed();
4994        let mut terminal =
4995            Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
4996        terminal
4997            .draw(|frame| draw(frame, &state))
4998            .expect("draw TUI");
4999
5000        let buffer = terminal.backend().buffer();
5001        let area = tui_viewport(Rect::new(0, 0, 40, 12));
5002        let (_, picker_area, _, _, input_area, _) = ui_layout(&state, area);
5003        let picker_area = picker_area.expect("picker area");
5004        // The square picker shares a boundary with the prompt; no blank row separates them.
5005        assert_eq!(picker_area.y + picker_area.height, input_area.y);
5006        assert_eq!(buffer[(picker_area.x, picker_area.y)].symbol(), "┌");
5007        assert_eq!(
5008            buffer[(picker_area.x, picker_area.y + picker_area.height - 1)].symbol(),
5009            "└"
5010        );
5011        assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), "┌");
5012        assert_eq!(buffer[(picker_area.x + 1, picker_area.y + 1)].symbol(), "[");
5013        assert_eq!(buffer[(picker_area.x + 1, picker_area.y + 2)].symbol(), "/");
5014        assert_eq!(buffer[(picker_area.x, picker_area.y)].fg, Color::Cyan);
5015    }
5016
5017    #[test]
5018    fn slash_picker_renders_count_and_distinct_focus_colors() {
5019        let mut state = UiState::from_history(&[], "secret", "model", None, false)
5020            .with_skill_names(skill_names());
5021        state.input = "/".to_owned();
5022        state.input_changed();
5023        let mut terminal =
5024            Terminal::new(ratatui::backend::TestBackend::new(30, 8)).expect("test terminal");
5025        terminal
5026            .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 8)))
5027            .expect("draw skill picker");
5028
5029        let buffer = terminal.backend().buffer();
5030        assert_eq!(buffer[(0, 0)].symbol(), "┌");
5031        assert_eq!(buffer[(0, 0)].fg, Color::Cyan);
5032        assert_eq!(buffer[(1, 1)].symbol(), "[");
5033        assert_eq!(buffer[(1, 1)].fg, Color::DarkGray);
5034        assert_eq!(buffer[(1, 2)].symbol(), "/");
5035        assert_eq!(buffer[(1, 2)].fg, Color::Cyan);
5036        assert_eq!(buffer[(1, 3)].symbol(), "/");
5037        assert_eq!(buffer[(1, 3)].fg, Color::DarkGray);
5038    }
5039}