Skip to main content

lucy/
tui.rs

1use std::io::{self, Write};
2use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
3use std::thread::{self, JoinHandle};
4use std::time::{Duration, Instant};
5
6use crossterm::cursor::{Hide, Show};
7use crossterm::event::{
8    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
9    KeyModifiers, KeyboardEnhancementFlags, MouseEventKind, PopKeyboardEnhancementFlags,
10    PushKeyboardEnhancementFlags,
11};
12use crossterm::execute;
13use crossterm::terminal::{
14    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
15};
16use ratatui::backend::CrosstermBackend;
17use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect, Size};
18use ratatui::prelude::Frame;
19use ratatui::style::{Color, Style};
20use ratatui::text::{Line, Span};
21use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
22use ratatui::Terminal;
23use serde_json::Value;
24use unicode_width::UnicodeWidthStr;
25
26use crate::app::Harness;
27use crate::cancellation::CancellationToken;
28use crate::model::{estimate_context_tokens, ChatMessage};
29use crate::protocol::{EventSink, ProtocolEvent};
30use crate::provider::ProviderModel;
31use crate::redaction::redact_secret;
32use crate::session::SessionHistoryRecord;
33
34const EVENT_POLL: Duration = Duration::from_millis(50);
35const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
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 PENDING_TOOL_COLOR: Color = Color::Rgb(255, 165, 0);
47// The sequence travels through warm neighbouring hues, then returns along the
48// same path. Each five-second phase blends only its adjacent palette colours.
49const WORKING_GRADIENT_COLORS: [(u8, u8, u8); 4] = [
50    (220, 35, 175), // magenta
51    (235, 45, 65),  // red
52    (255, 130, 25), // orange
53    (235, 45, 65),  // red, returning to magenta
54];
55const WORKING_GRADIENT_CYCLE: Duration = Duration::from_millis(5000);
56const SKILL_PICKER_MAX_ROWS: usize = 5;
57const BUILTIN_COMMANDS: [&str; 2] = ["settings", "exit"];
58const SUBAGENT_PANEL_WIDTH: u16 = 34;
59const SUBAGENT_PANEL_MIN_TERMINAL_WIDTH: u16 = 100;
60const SETTINGS_MIN_WIDTH: u16 = 36;
61const SETTINGS_MAX_WIDTH: u16 = 88;
62const SETTINGS_MIN_HEIGHT: u16 = 8;
63const SETTINGS_MAX_HEIGHT: u16 = 22;
64
65pub(crate) fn run<W: Write>(mut harness: Harness, resumed: bool, stdout: W) -> Result<(), String> {
66    let secret = harness.provider.api_key().to_owned();
67    let context_window = harness
68        .context_window
69        .or_else(|| harness.provider.context_window());
70    harness.context_window = context_window;
71    let context_tokens = estimate_context_tokens(&harness.session.provider_messages());
72    let skill_names = command_names(
73        harness
74            .session
75            .skills
76            .iter()
77            .map(|skill| skill.name.clone())
78            .collect(),
79    );
80    let mut state = UiState::from_history(
81        &harness.session.history,
82        &secret,
83        &harness.session.llm.model,
84        harness.session.llm.effort.as_deref(),
85        resumed,
86    )
87    .with_attached_agents(harness.attached_agents.clone())
88    .with_skill_names(skill_names)
89    .with_context(context_window, context_tokens);
90    let (request_tx, request_rx) = mpsc::channel::<WorkerRequest>();
91    let (message_tx, message_rx) = mpsc::channel::<WorkerMessage>();
92
93    let stdout = stdout;
94    enable_raw_mode().map_err(|error| format!("unable to enable terminal input: {error}"))?;
95    let backend = CrosstermBackend::new(stdout);
96    let terminal = match Terminal::new(backend) {
97        Ok(terminal) => terminal,
98        Err(error) => {
99            let _ = disable_raw_mode();
100            return Err(format!("unable to initialize terminal UI: {error}"));
101        }
102    };
103    let mut terminal_guard = TerminalGuard::new(terminal);
104    let backend = terminal_guard.terminal_mut().backend_mut();
105    if let Err(error) = execute!(backend, EnterAlternateScreen, EnableMouseCapture, Hide) {
106        return Err(format!("unable to enter terminal UI: {error}"));
107    }
108    // Kitty keyboard protocol makes Shift+Enter (and other modified keys)
109    // distinguishable from plain Enter. Only push it on terminals known to
110    // support it; otherwise the enhancement sequence would leak as literal
111    // text on screen.
112    if supports_keyboard_enhancement() {
113        let _ = execute!(
114            backend,
115            PushKeyboardEnhancementFlags(
116                KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
117                    | KeyboardEnhancementFlags::REPORT_EVENT_TYPES,
118            )
119        );
120        terminal_guard.keyboard_enhancement = true;
121    }
122    let worker = thread::spawn(move || worker_loop(&mut harness, request_rx, message_tx, resumed));
123
124    let result = event_loop(
125        terminal_guard.terminal_mut(),
126        &mut state,
127        &request_tx,
128        &message_rx,
129    );
130
131    if let Some(token) = state.active_cancel.take() {
132        let _ = token.cancel();
133    }
134    let _ = request_tx.send(WorkerRequest::Shutdown);
135    wait_for_worker(worker, WORKER_SHUTDOWN_GRACE);
136    drop(terminal_guard);
137    result
138}
139
140fn worker_loop(
141    harness: &mut Harness,
142    requests: Receiver<WorkerRequest>,
143    messages: Sender<WorkerMessage>,
144    resumed: bool,
145) {
146    let mut sink = ChannelSink {
147        sender: messages.clone(),
148    };
149    if sink
150        .emit_event(&ProtocolEvent::Session {
151            session_id: harness.session.id.clone(),
152            resumed,
153        })
154        .is_err()
155    {
156        return;
157    }
158
159    loop {
160        if let Some(completion) = harness.next_subagent_completion() {
161            let task_id = completion.task_id.clone();
162            let result = completion.result.clone();
163            let notification = Harness::subagent_notification(&completion);
164            let _ = messages.send(WorkerMessage::SubagentCompleted { task_id, result });
165            let cancel = CancellationToken::new();
166            let _ = messages.send(WorkerMessage::Started {
167                cancel: cancel.clone(),
168                user_text: None,
169            });
170            if let Err(error) = harness.handle_message(&notification, &mut sink, Some(&cancel)) {
171                let message = redact_secret(&error, Some(harness.provider.api_key()));
172                let _ = sink.emit_event(&ProtocolEvent::Error { message });
173            }
174            let _ = messages.send(WorkerMessage::Finished);
175            continue;
176        }
177        let request = match requests.recv_timeout(EVENT_POLL) {
178            Ok(request) => request,
179            Err(mpsc::RecvTimeoutError::Timeout) => {
180                if let Some(completion) = harness.next_subagent_completion() {
181                    let task_id = completion.task_id.clone();
182                    let result = completion.result.clone();
183                    let notification = Harness::subagent_notification(&completion);
184                    let _ = messages.send(WorkerMessage::SubagentCompleted { task_id, result });
185                    let cancel = CancellationToken::new();
186                    let _ = messages.send(WorkerMessage::Started {
187                        cancel: cancel.clone(),
188                        user_text: None,
189                    });
190                    if let Err(error) =
191                        harness.handle_message(&notification, &mut sink, Some(&cancel))
192                    {
193                        let message = redact_secret(&error, Some(harness.provider.api_key()));
194                        let _ = sink.emit_event(&ProtocolEvent::Error { message });
195                    }
196                    let _ = messages.send(WorkerMessage::Finished);
197                }
198                continue;
199            }
200            Err(mpsc::RecvTimeoutError::Disconnected) => break,
201        };
202        match request {
203            WorkerRequest::Turn { text } => {
204                let cancel = CancellationToken::new();
205                let _ = messages.send(WorkerMessage::Started {
206                    cancel: cancel.clone(),
207                    user_text: Some(text.clone()),
208                });
209                if let Err(error) = harness.handle_message(&text, &mut sink, Some(&cancel)) {
210                    let message = redact_secret(&error, Some(harness.provider.api_key()));
211                    let _ = sink.emit_event(&ProtocolEvent::Error { message });
212                }
213                let _ = messages.send(WorkerMessage::Finished);
214            }
215            WorkerRequest::Catalog => {
216                let _ = messages.send(WorkerMessage::Catalog(
217                    harness.provider.models().map_err(|error| error.to_string()),
218                ));
219            }
220            WorkerRequest::ApplySettings { model, effort } => {
221                let result = harness.apply_settings(&harness.home.clone(), model, effort);
222                let _ = messages.send(WorkerMessage::SettingsApplied(
223                    result,
224                    harness.session.llm.model.clone(),
225                    harness.session.llm.effort.clone(),
226                    harness.context_window,
227                ));
228            }
229            WorkerRequest::Shutdown => break,
230        }
231    }
232}
233
234fn event_loop<W: Write>(
235    terminal: &mut Terminal<CrosstermBackend<W>>,
236    state: &mut UiState,
237    requests: &Sender<WorkerRequest>,
238    messages: &Receiver<WorkerMessage>,
239) -> Result<(), String> {
240    let mut quitting = false;
241    loop {
242        loop {
243            match messages.try_recv() {
244                Ok(WorkerMessage::Event(event)) => state.apply_event(event),
245                Ok(WorkerMessage::SubagentCompleted { task_id, result }) => {
246                    state.complete_subagent(&task_id, result);
247                }
248                Ok(WorkerMessage::Started { cancel, user_text }) => {
249                    if let Some(text) = user_text {
250                        state.start_queued_user(&text);
251                    }
252                    state.active_cancel = Some(cancel);
253                    state.busy = true;
254                    state.status = "working".to_owned();
255                }
256                Ok(WorkerMessage::Thinking) => state.show_thinking(),
257                Ok(WorkerMessage::ReasoningCompleted) => state.complete_reasoning(),
258                Ok(WorkerMessage::SkillInstructionAttached) => {
259                    state.mark_latest_user_skill_attached()
260                }
261                Ok(WorkerMessage::ContextUsage(tokens)) => state.context_tokens = tokens,
262                Ok(WorkerMessage::CompactionStarted) => state.status = "compacting".to_owned(),
263                Ok(WorkerMessage::CompactionFinished {
264                    tokens_before,
265                    tokens_after,
266                }) => {
267                    state.context_tokens = tokens_after;
268                    state.status = "working".to_owned();
269                    state.transcript.push(TranscriptItem::Info(format!(
270                        "↻ context compacted ({} → {})",
271                        format_context_tokens(tokens_before),
272                        format_context_tokens(tokens_after)
273                    )));
274                }
275                Ok(WorkerMessage::Catalog(result)) => state.open_catalog(result),
276                Ok(WorkerMessage::SettingsApplied(result, model, effort, context_window)) => {
277                    state.settings_applied(result, model, effort, context_window)
278                }
279                Ok(WorkerMessage::Finished) => {
280                    release_finished_turn(terminal.backend_mut(), state);
281                    match state.status.as_str() {
282                        "cancelling" => state.status = "사용자 중단".to_owned(),
283                        "finalizing" => state.status = "ready".to_owned(),
284                        _ => {}
285                    }
286                    if quitting {
287                        return Ok(());
288                    }
289                }
290                Err(TryRecvError::Empty) => break,
291                Err(TryRecvError::Disconnected) => {
292                    if state.busy {
293                        return Err("TUI worker stopped unexpectedly".to_owned());
294                    }
295                    return Ok(());
296                }
297            }
298        }
299
300        terminal
301            .draw(|frame| draw(frame, state))
302            .map_err(|error| format!("unable to render TUI: {error}"))?;
303
304        if quitting {
305            thread::sleep(EVENT_POLL);
306            continue;
307        }
308        if event::poll(EVENT_POLL)
309            .map_err(|error| format!("unable to read terminal input: {error}"))?
310        {
311            let event =
312                event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
313            if let Event::Mouse(mouse) = event {
314                let size = terminal
315                    .size()
316                    .map_err(|error| format!("unable to read terminal size: {error}"))?;
317                let max_scroll = max_scroll_for_area(state, size);
318                handle_mouse_event(state, mouse.kind, max_scroll);
319                continue;
320            }
321            let Event::Key(key) = event else {
322                continue;
323            };
324            if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
325                continue;
326            }
327            if is_ctrl_c(&key) {
328                if let Some(token) = state.active_cancel.as_ref() {
329                    let _ = token.cancel();
330                    quitting = true;
331                } else {
332                    return Ok(());
333                }
334                continue;
335            }
336            if !state.busy && state.settings.is_some() {
337                if let Some((model, effort)) = state.handle_settings_key(&key) {
338                    state.settings = Some(SettingsState::Applying {
339                        model: model.clone(),
340                        effort: effort.clone(),
341                    });
342                    requests
343                        .send(WorkerRequest::ApplySettings { model, effort })
344                        .map_err(|_| "TUI worker is unavailable".to_owned())?;
345                }
346                continue;
347            }
348            if key.code == KeyCode::Esc {
349                if let Some(token) = state.active_cancel.as_ref() {
350                    if token.cancel() {
351                        state.status = "cancelling".to_owned();
352                    }
353                }
354                continue;
355            }
356            match key.code {
357                KeyCode::Enter => {
358                    // Shift+Enter (and Alt+Enter fallback) insert a literal
359                    // newline so the user can write multi-line prompts. Plain
360                    // Enter sends the turn. Many terminals cannot distinguish
361                    // Shift+Enter from Enter, so Alt+Enter is also accepted.
362                    if key.modifiers.contains(KeyModifiers::SHIFT)
363                        || key.modifiers.contains(KeyModifiers::ALT)
364                    {
365                        if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
366                            insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
367                            state.input_changed();
368                        }
369                        continue;
370                    }
371                    // A focused built-in is an action, unlike a skill: Enter
372                    // invokes it immediately. Tab remains completion-only.
373                    let text = if let Some(command) = state.focused_builtin_command() {
374                        state.input.clear();
375                        format!("/{}", command.name())
376                    } else {
377                        if state.select_focused_skill() {
378                            continue;
379                        }
380                        std::mem::take(&mut state.input)
381                    };
382                    state.cursor = 0;
383                    if let Some(command) = builtin_command(&text) {
384                        state.reset_skill_picker();
385                        if state.busy {
386                            state.transcript.push(TranscriptItem::Info(format!(
387                                "/{} is available when the current turn finishes",
388                                command.name()
389                            )));
390                            continue;
391                        }
392                        match command {
393                            BuiltinCommand::Settings => {
394                                state.settings = Some(SettingsState::Loading);
395                                requests
396                                    .send(WorkerRequest::Catalog)
397                                    .map_err(|_| "TUI worker is unavailable".to_owned())?;
398                                continue;
399                            }
400                            BuiltinCommand::Exit => return Ok(()),
401                        }
402                    }
403                    state.reset_skill_picker();
404                    if text.trim().is_empty() {
405                        continue;
406                    }
407                    state.auto_scroll = true;
408                    state.scroll = 0;
409                    state.queue_user(&text);
410                    state.busy = true;
411                    state.status = "working".to_owned();
412                    requests
413                        .send(WorkerRequest::Turn { text })
414                        .map_err(|_| "TUI worker is unavailable".to_owned())?;
415                }
416                KeyCode::Tab => {
417                    // Tab completes the focused skill while the slash picker
418                    // is active, using the same first-selection path as Enter.
419                    state.select_focused_skill();
420                }
421                KeyCode::Char(character) => {
422                    if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
423                        insert_at_cursor(&mut state.input, &mut state.cursor, character);
424                        state.input_changed();
425                    }
426                }
427                KeyCode::Backspace => {
428                    if remove_before_cursor(&mut state.input, &mut state.cursor) {
429                        state.input_changed();
430                    }
431                }
432                KeyCode::Left => {
433                    state.cursor = state.cursor.saturating_sub(1);
434                    state.cursor_epoch = Instant::now();
435                }
436                KeyCode::Right => {
437                    state.cursor = (state.cursor + 1).min(state.input.chars().count());
438                    state.cursor_epoch = Instant::now();
439                }
440                KeyCode::Home => {
441                    state.cursor = 0;
442                    state.cursor_epoch = Instant::now();
443                }
444                KeyCode::End => {
445                    state.cursor = state.input.chars().count();
446                    state.cursor_epoch = Instant::now();
447                }
448                KeyCode::Up => {
449                    if !state.move_skill_picker(false) {
450                        let size = terminal
451                            .size()
452                            .map_err(|error| format!("unable to read terminal size: {error}"))?;
453                        let max_scroll = max_scroll_for_area(state, size);
454                        scroll_up(state, max_scroll);
455                    }
456                }
457                KeyCode::Down => {
458                    if !state.move_skill_picker(true) {
459                        let size = terminal
460                            .size()
461                            .map_err(|error| format!("unable to read terminal size: {error}"))?;
462                        let max_scroll = max_scroll_for_area(state, size);
463                        scroll_down(state, max_scroll);
464                    }
465                }
466                KeyCode::PageUp => {
467                    let size = terminal
468                        .size()
469                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
470                    let max_scroll = max_scroll_for_area(state, size);
471                    scroll_up(state, max_scroll);
472                }
473                KeyCode::PageDown => {
474                    let size = terminal
475                        .size()
476                        .map_err(|error| format!("unable to read terminal size: {error}"))?;
477                    let max_scroll = max_scroll_for_area(state, size);
478                    scroll_down(state, max_scroll);
479                }
480                _ => {}
481            }
482        }
483    }
484}
485
486fn is_ctrl_c(key: &KeyEvent) -> bool {
487    key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
488}
489
490fn handle_mouse_event(state: &mut UiState, kind: MouseEventKind, max_scroll: u16) {
491    match kind {
492        MouseEventKind::ScrollUp => scroll_up(state, max_scroll),
493        MouseEventKind::ScrollDown => scroll_down(state, max_scroll),
494        _ => {}
495    }
496}
497
498fn scroll_up(state: &mut UiState, max_scroll: u16) {
499    if state.auto_scroll {
500        state.scroll = max_scroll;
501        state.auto_scroll = false;
502    } else {
503        state.scroll = state.scroll.min(max_scroll);
504    }
505    state.scroll = state.scroll.saturating_sub(3);
506}
507
508fn scroll_down(state: &mut UiState, max_scroll: u16) {
509    if state.auto_scroll {
510        return;
511    }
512    state.scroll = state.scroll.saturating_add(3).min(max_scroll);
513    if state.scroll == max_scroll {
514        // Reaching the real bottom is an explicit request to resume following
515        // the transcript, so subsequent streamed output stays visible.
516        state.auto_scroll = true;
517        state.scroll = 0;
518    }
519}
520
521fn wait_for_worker(worker: JoinHandle<()>, grace: Duration) {
522    let deadline = std::time::Instant::now() + grace;
523    while !worker.is_finished() && std::time::Instant::now() < deadline {
524        thread::sleep(Duration::from_millis(5));
525    }
526    if worker.is_finished() {
527        let _ = worker.join();
528    }
529}
530
531struct TerminalGuard<W: Write> {
532    terminal: Option<Terminal<CrosstermBackend<W>>>,
533    keyboard_enhancement: bool,
534}
535
536impl<W: Write> TerminalGuard<W> {
537    fn new(terminal: Terminal<CrosstermBackend<W>>) -> Self {
538        Self {
539            terminal: Some(terminal),
540            keyboard_enhancement: false,
541        }
542    }
543
544    fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {
545        self.terminal
546            .as_mut()
547            .expect("terminal guard is initialized")
548    }
549}
550
551impl<W: Write> Drop for TerminalGuard<W> {
552    fn drop(&mut self) {
553        let Some(mut terminal) = self.terminal.take() else {
554            return;
555        };
556        if self.keyboard_enhancement {
557            let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
558        }
559        let _ = terminal.show_cursor();
560        let _ = disable_raw_mode();
561        let _ = execute!(
562            terminal.backend_mut(),
563            DisableMouseCapture,
564            LeaveAlternateScreen,
565            Show
566        );
567        let _ = terminal.backend_mut().flush();
568    }
569}
570
571/// Heuristic for terminals that implement the kitty keyboard protocol.
572/// `PushKeyboardEnhancementFlags` is a no-op on supported terminals, but on
573/// unsupported ones the CSI sequence can render as literal text, so it is only
574/// enabled when the terminal advertises support via `TERM`/`TERM_PROGRAM`.
575fn supports_keyboard_enhancement() -> bool {
576    fn env(name: &str) -> Option<String> {
577        std::env::var(name).ok().map(|value| value.to_lowercase())
578    }
579    let term = env("TERM").unwrap_or_default();
580    let program = env("TERM_PROGRAM").unwrap_or_default();
581    if term.starts_with("xterm-kitty")
582        || term.starts_with("ghostty")
583        || term.starts_with("xterm-ghostty")
584    {
585        return true;
586    }
587    matches!(
588        program.as_str(),
589        "ghostty" | "kitty" | "wezterm" | "alacritty" | "foot" | "footclient" | "iterm.app"
590    )
591}
592
593#[derive(Debug, Clone, Copy, PartialEq, Eq)]
594enum TurnNotification {
595    Completed,
596    Interrupted,
597    Failed,
598}
599
600impl TurnNotification {
601    fn body(self) -> &'static str {
602        match self {
603            Self::Completed => "Turn complete",
604            Self::Interrupted => "Turn interrupted",
605            Self::Failed => "Turn failed",
606        }
607    }
608}
609
610fn turn_notification_for_status(status: &str) -> TurnNotification {
611    match status {
612        "cancelling" | "사용자 중단" => TurnNotification::Interrupted,
613        "error" => TurnNotification::Failed,
614        _ => TurnNotification::Completed,
615    }
616}
617
618/// Ask terminal emulators that support OSC 777 to show a desktop notification.
619///
620/// The title and body are fixed Lucy-owned strings rather than model/provider
621/// text, so completion notifications cannot inject terminal control data or
622/// expose a secret. Terminals without OSC 777 support safely ignore the OSC.
623fn send_turn_notification<W: Write>(
624    writer: &mut W,
625    notification: TurnNotification,
626) -> io::Result<()> {
627    writer.write_all(b"\x1b]777;notify;Lucy;")?;
628    writer.write_all(notification.body().as_bytes())?;
629    writer.write_all(b"\x07")?;
630    writer.flush()
631}
632
633fn release_finished_turn<W: Write>(writer: &mut W, state: &mut UiState) {
634    let was_busy = state.busy;
635    let notification = turn_notification_for_status(&state.status);
636    state.busy = false;
637    state.active_cancel = None;
638    if was_busy {
639        // Notification failure must never change the completed turn result or
640        // make the TUI unusable.
641        let _ = send_turn_notification(writer, notification);
642    }
643}
644
645enum WorkerRequest {
646    Turn {
647        text: String,
648    },
649    Catalog,
650    ApplySettings {
651        model: String,
652        effort: Option<String>,
653    },
654    Shutdown,
655}
656
657enum WorkerMessage {
658    Event(ProtocolEvent),
659    Started {
660        cancel: CancellationToken,
661        user_text: Option<String>,
662    },
663    Thinking,
664    ReasoningCompleted,
665    SkillInstructionAttached,
666    ContextUsage(usize),
667    CompactionStarted,
668    CompactionFinished {
669        tokens_before: usize,
670        tokens_after: usize,
671    },
672    Catalog(Result<Vec<ProviderModel>, String>),
673    SettingsApplied(Result<(), String>, String, Option<String>, Option<usize>),
674    SubagentCompleted {
675        task_id: String,
676        result: Value,
677    },
678    Finished,
679}
680
681struct ChannelSink {
682    sender: Sender<WorkerMessage>,
683}
684
685impl EventSink for ChannelSink {
686    fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
687        self.sender
688            .send(WorkerMessage::Event(event.clone()))
689            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
690    }
691
692    fn reasoning_started(&mut self) -> io::Result<()> {
693        self.sender
694            .send(WorkerMessage::Thinking)
695            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
696    }
697
698    fn reasoning_completed(&mut self) -> io::Result<()> {
699        self.sender
700            .send(WorkerMessage::ReasoningCompleted)
701            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
702    }
703
704    fn skill_instruction_attached(&mut self, _name: &str) -> io::Result<()> {
705        self.sender
706            .send(WorkerMessage::SkillInstructionAttached)
707            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
708    }
709
710    fn context_usage(&mut self, tokens: usize) -> io::Result<()> {
711        self.sender
712            .send(WorkerMessage::ContextUsage(tokens))
713            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
714    }
715
716    fn compaction_started(&mut self) -> io::Result<()> {
717        self.sender
718            .send(WorkerMessage::CompactionStarted)
719            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
720    }
721
722    fn compaction_finished(&mut self, tokens_before: usize, tokens_after: usize) -> io::Result<()> {
723        self.sender
724            .send(WorkerMessage::CompactionFinished {
725                tokens_before,
726                tokens_after,
727            })
728            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
729    }
730}
731
732#[derive(Debug, Clone, Copy, PartialEq, Eq)]
733enum SubagentStatus {
734    Queued,
735    Running,
736    Completed,
737    Failed,
738}
739
740#[derive(Debug, Clone, PartialEq)]
741struct SubagentTask {
742    call_id: String,
743    task_id: Option<String>,
744    task: String,
745    model: Option<String>,
746    effort: Option<String>,
747    status: SubagentStatus,
748    result: Option<Value>,
749}
750
751struct UiState {
752    model: String,
753    effort: Option<String>,
754    context_window: Option<usize>,
755    context_tokens: usize,
756    secret: String,
757    transcript: Vec<TranscriptItem>,
758    queued_messages: Vec<String>,
759    input: String,
760    cursor: usize,
761    status: String,
762    busy: bool,
763    active_cancel: Option<CancellationToken>,
764    scroll: u16,
765    auto_scroll: bool,
766    cursor_epoch: Instant,
767    welcome_visible: bool,
768    attached_agents: Vec<String>,
769    subagents: Vec<SubagentTask>,
770    skill_names: Vec<String>,
771    skill_picker_focus: usize,
772    skill_picker_suppressed: bool,
773    settings: Option<SettingsState>,
774}
775
776impl UiState {
777    fn from_history(
778        history: &[SessionHistoryRecord],
779        secret: &str,
780        model: &str,
781        effort: Option<&str>,
782        resumed: bool,
783    ) -> Self {
784        let mut state = Self {
785            model: model.to_owned(),
786            effort: effort.map(str::to_owned),
787            context_window: None,
788            context_tokens: 1,
789            secret: secret.to_owned(),
790            transcript: Vec::new(),
791            queued_messages: Vec::new(),
792            input: String::new(),
793            cursor: 0,
794            status: "ready".to_owned(),
795            busy: false,
796            active_cancel: None,
797            scroll: 0,
798            auto_scroll: true,
799            cursor_epoch: Instant::now(),
800            welcome_visible: !resumed && history.is_empty(),
801            attached_agents: Vec::new(),
802            subagents: Vec::new(),
803            skill_names: Vec::new(),
804            skill_picker_focus: 0,
805            skill_picker_suppressed: false,
806            settings: None,
807        };
808        for record in history {
809            state.add_history_record(record);
810        }
811        state
812    }
813
814    fn with_attached_agents(mut self, attached_agents: Vec<String>) -> Self {
815        self.attached_agents = attached_agents;
816        self
817    }
818
819    fn with_skill_names(mut self, skill_names: Vec<String>) -> Self {
820        self.skill_names = skill_names;
821        self
822    }
823
824    fn with_context(mut self, context_window: Option<usize>, context_tokens: usize) -> Self {
825        self.context_window = context_window;
826        self.context_tokens = context_tokens.max(1);
827        self
828    }
829
830    /// Return matching skills only while the first input character is `/` and
831    /// the user is still writing the command name (rather than its arguments).
832    fn matching_skill_names(&self) -> Vec<&str> {
833        matching_skill_names(&self.input, &self.skill_names)
834    }
835
836    fn reset_skill_picker(&mut self) {
837        self.skill_picker_focus = 0;
838        self.skill_picker_suppressed = false;
839    }
840
841    fn skill_picker_visible(&self) -> bool {
842        !self.skill_picker_suppressed && !self.matching_skill_names().is_empty()
843    }
844
845    fn input_changed(&mut self) {
846        self.reset_skill_picker();
847        self.cursor_epoch = Instant::now();
848    }
849
850    /// Move through the current filter result without wrapping at its ends.
851    /// Returning false lets the caller retain normal transcript scrolling when
852    /// no slash picker is active.
853    fn move_skill_picker(&mut self, down: bool) -> bool {
854        let match_count = self.matching_skill_names().len();
855        if self.skill_picker_suppressed || match_count == 0 {
856            return false;
857        }
858        if down {
859            self.skill_picker_focus = (self.skill_picker_focus + 1).min(match_count - 1);
860        } else {
861            self.skill_picker_focus = self.skill_picker_focus.saturating_sub(1);
862        }
863        true
864    }
865
866    /// Replace the slash query with the focused explicit skill command. The
867    /// normal Enter path then sends that command and the existing turn engine
868    /// attaches the immutable session skill snapshot.
869    /// Return the built-in represented by the focused slash-picker row, if
870    /// any. Built-ins execute on Enter while skills merely complete there.
871    fn focused_builtin_command(&self) -> Option<BuiltinCommand> {
872        let name = *self.matching_skill_names().get(self.skill_picker_focus)?;
873        builtin_command(&format!("/{name}"))
874    }
875
876    fn select_focused_skill(&mut self) -> bool {
877        if self.skill_picker_suppressed {
878            return false;
879        }
880        let Some(name) = self
881            .matching_skill_names()
882            .get(self.skill_picker_focus)
883            .map(|name| (*name).to_owned())
884        else {
885            return false;
886        };
887        self.input = format!("/{name}");
888        self.cursor = self.input.chars().count();
889        // The first Enter chooses a skill; a second Enter sends the completed
890        // command to the normal attachment path.
891        self.skill_picker_suppressed = true;
892        self.cursor_epoch = Instant::now();
893        true
894    }
895
896    fn open_catalog(&mut self, result: Result<Vec<ProviderModel>, String>) {
897        self.settings = Some(match result {
898            Ok(models) => {
899                let focus = models
900                    .iter()
901                    .position(|model| model.id == self.model)
902                    .unwrap_or(0);
903                SettingsState::Models {
904                    models,
905                    query: String::new(),
906                    focus,
907                }
908            }
909            Err(error) => SettingsState::Error(error),
910        });
911    }
912    fn settings_applied(
913        &mut self,
914        result: Result<(), String>,
915        model: String,
916        effort: Option<String>,
917        context_window: Option<usize>,
918    ) {
919        match result {
920            Ok(()) => {
921                self.model = model;
922                self.effort = effort;
923                self.context_window = context_window;
924                self.settings = None;
925                self.transcript
926                    .push(TranscriptItem::Info("⚙ settings applied".to_owned()));
927            }
928            Err(error) => self.settings = Some(SettingsState::Error(error)),
929        }
930    }
931    fn handle_settings_key(&mut self, key: &KeyEvent) -> Option<(String, Option<String>)> {
932        let current_effort = self.effort.clone();
933        match self.settings.as_mut()? {
934            SettingsState::Loading => {
935                if key.code == KeyCode::Esc {
936                    self.settings = None;
937                }
938            }
939            SettingsState::Applying { .. } => {}
940            SettingsState::Error(_) => {
941                if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
942                    self.settings = None;
943                }
944            }
945            SettingsState::Models {
946                models,
947                query,
948                focus,
949            } => match key.code {
950                KeyCode::Esc => self.settings = None,
951                KeyCode::Char(c) => {
952                    query.push(c);
953                    *focus = 0;
954                }
955                KeyCode::Backspace => {
956                    query.pop();
957                    *focus = 0;
958                }
959                KeyCode::Up => *focus = focus.saturating_sub(1),
960                KeyCode::Down => {
961                    let n = models
962                        .iter()
963                        .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
964                        .count();
965                    *focus = (*focus + 1).min(n.saturating_sub(1));
966                }
967                KeyCode::Enter => {
968                    let selected = models
969                        .iter()
970                        .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
971                        .nth(*focus)
972                        .cloned();
973                    if let Some(model) = selected {
974                        let focus = model
975                            .efforts
976                            .as_ref()
977                            .and_then(|efforts| {
978                                current_effort.as_ref().and_then(|current| {
979                                    efforts.iter().position(|effort| effort == current)
980                                })
981                            })
982                            .map_or(0, |index| index + 1);
983                        self.settings = Some(SettingsState::Effort {
984                            model,
985                            input: current_effort.unwrap_or_default(),
986                            focus,
987                        });
988                    }
989                }
990                _ => {}
991            },
992            SettingsState::Effort {
993                model,
994                input,
995                focus,
996            } => match key.code {
997                KeyCode::Esc => self.settings = None,
998                KeyCode::Char(c) if model.efforts.is_none() => input.push(c),
999                KeyCode::Backspace if model.efforts.is_none() => {
1000                    input.pop();
1001                }
1002                KeyCode::Up => *focus = focus.saturating_sub(1),
1003                KeyCode::Down => {
1004                    if let Some(efforts) = &model.efforts {
1005                        *focus = (*focus + 1).min(efforts.len());
1006                    }
1007                }
1008                KeyCode::Enter => {
1009                    let effort = match &model.efforts {
1010                        Some(efforts) => {
1011                            if *focus == 0 {
1012                                None
1013                            } else {
1014                                efforts.get(focus.saturating_sub(1)).cloned()
1015                            }
1016                        }
1017                        None => (!input.trim().is_empty()).then(|| input.trim().to_owned()),
1018                    };
1019                    return Some((model.id.clone(), effort));
1020                }
1021                _ => {}
1022            },
1023        };
1024        None
1025    }
1026
1027    fn add_history_record(&mut self, record: &SessionHistoryRecord) {
1028        match record {
1029            SessionHistoryRecord::ProviderSettings { model, effort, .. } => {
1030                self.transcript.push(TranscriptItem::Info(format!(
1031                    "⚙ model={model} · effort={}",
1032                    effort.as_deref().unwrap_or("default")
1033                )))
1034            }
1035            SessionHistoryRecord::Message { message, .. } => self.add_message(message),
1036            SessionHistoryRecord::Interruption {
1037                assistant_text,
1038                tool_calls,
1039                tool_results,
1040                reason,
1041                phase,
1042                ..
1043            } => {
1044                if !assistant_text.is_empty() {
1045                    self.add_assistant_message(assistant_text);
1046                }
1047                for call in tool_calls {
1048                    self.add_tool_call(call);
1049                }
1050                for observation in tool_results {
1051                    self.add_tool_result(
1052                        &observation.id,
1053                        &observation.name,
1054                        observation.result.clone(),
1055                    );
1056                }
1057                self.transcript
1058                    .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1059            }
1060            SessionHistoryRecord::Compaction(compaction) => {
1061                self.transcript.push(TranscriptItem::Info(format!(
1062                    "↻ context compacted ({} before)",
1063                    format_context_tokens(compaction.tokens_before)
1064                )));
1065            }
1066        }
1067    }
1068
1069    fn add_message(&mut self, message: &ChatMessage) {
1070        match message.role.as_str() {
1071            "user" => {
1072                let text = message.content.as_deref().unwrap_or("");
1073                if let Some((task_id, result)) = parse_subagent_notification(text) {
1074                    self.complete_subagent(&task_id, result);
1075                } else {
1076                    let secret = self.secret.clone();
1077                    self.add_user(text, &secret);
1078                }
1079            }
1080            "assistant" => {
1081                if let Some(content) = message.content.as_deref() {
1082                    self.add_assistant_message(content);
1083                }
1084                for call in &message.tool_calls {
1085                    self.add_tool_call(call);
1086                }
1087            }
1088            "tool" => {
1089                let result = message
1090                    .content
1091                    .as_deref()
1092                    .and_then(|content| serde_json::from_str::<Value>(content).ok())
1093                    .unwrap_or_else(|| Value::String(message.content.clone().unwrap_or_default()));
1094                self.add_tool_result(
1095                    message.tool_call_id.as_deref().unwrap_or(""),
1096                    message.name.as_deref().unwrap_or("cmd"),
1097                    result,
1098                );
1099            }
1100            _ => {}
1101        }
1102    }
1103
1104    fn queue_user(&mut self, text: &str) {
1105        self.queued_messages
1106            .push(redact_secret(text, Some(&self.secret)));
1107    }
1108
1109    fn start_queued_user(&mut self, text: &str) {
1110        let safe = redact_secret(text, Some(&self.secret));
1111        if self.queued_messages.first() == Some(&safe) {
1112            self.queued_messages.remove(0);
1113        } else if let Some(index) = self
1114            .queued_messages
1115            .iter()
1116            .position(|queued| queued == &safe)
1117        {
1118            self.queued_messages.remove(index);
1119        }
1120        self.add_user(text, &self.secret.clone());
1121    }
1122
1123    fn add_user(&mut self, text: &str, secret: &str) {
1124        self.welcome_visible = false;
1125        self.transcript.push(TranscriptItem::User {
1126            text: redact_secret(text, Some(secret)),
1127            skill_instruction_attached: false,
1128        });
1129    }
1130
1131    fn mark_latest_user_skill_attached(&mut self) {
1132        if let Some(TranscriptItem::User {
1133            skill_instruction_attached,
1134            ..
1135        }) = self.transcript.last_mut()
1136        {
1137            *skill_instruction_attached = true;
1138        }
1139    }
1140
1141    fn clear_thinking(&mut self) {
1142        if matches!(
1143            self.transcript.last(),
1144            Some(TranscriptItem::Reasoning { complete: false })
1145        ) {
1146            self.transcript.pop();
1147        }
1148    }
1149
1150    fn show_thinking(&mut self) {
1151        self.status = "working".to_owned();
1152        if !matches!(
1153            self.transcript.last(),
1154            Some(TranscriptItem::Reasoning { complete: false })
1155        ) {
1156            self.transcript
1157                .push(TranscriptItem::Reasoning { complete: false });
1158        }
1159    }
1160
1161    fn complete_reasoning(&mut self) {
1162        if let Some(TranscriptItem::Reasoning { complete }) = self.transcript.last_mut() {
1163            *complete = true;
1164        }
1165    }
1166
1167    fn add_assistant(&mut self, text: &str) {
1168        self.clear_thinking();
1169        if let Some(TranscriptItem::Assistant(current)) = self.transcript.last_mut() {
1170            current.push_str(text);
1171        } else {
1172            self.add_assistant_message(text);
1173        }
1174    }
1175
1176    fn add_assistant_message(&mut self, text: &str) {
1177        self.transcript
1178            .push(TranscriptItem::Assistant(text.to_owned()));
1179    }
1180
1181    fn add_tool_call(&mut self, call: &crate::model::ChatToolCall) {
1182        self.clear_thinking();
1183        self.transcript.push(TranscriptItem::ToolCall {
1184            id: call.id.clone(),
1185            name: call.name.clone(),
1186            arguments: call.arguments.clone(),
1187        });
1188        if call.name == "spawn_subagent" {
1189            self.register_subagent_call(&call.id, &call.arguments);
1190        }
1191    }
1192
1193    fn add_tool_result(&mut self, id: &str, name: &str, result: Value) {
1194        if name == "spawn_subagent" {
1195            self.update_subagent_queued(id, &result);
1196        }
1197        self.transcript.push(TranscriptItem::ToolResult {
1198            id: id.to_owned(),
1199            name: name.to_owned(),
1200            result,
1201        });
1202    }
1203
1204    fn register_subagent_call(&mut self, call_id: &str, arguments: &str) {
1205        if self.subagents.iter().any(|task| task.call_id == call_id) {
1206            return;
1207        }
1208        let parsed = serde_json::from_str::<Value>(arguments).ok();
1209        let task = parsed
1210            .as_ref()
1211            .and_then(|value| value.get("task"))
1212            .and_then(Value::as_str)
1213            .map(|task| redact_secret(task.trim(), Some(&self.secret)))
1214            .filter(|task| !task.is_empty())
1215            .unwrap_or_else(|| "invalid task".to_owned());
1216        let model = parsed
1217            .as_ref()
1218            .and_then(|value| value.get("model"))
1219            .and_then(Value::as_str)
1220            .map(|value| redact_secret(value, Some(&self.secret)));
1221        let effort = parsed
1222            .as_ref()
1223            .and_then(|value| value.get("effort"))
1224            .and_then(Value::as_str)
1225            .map(|value| redact_secret(value, Some(&self.secret)));
1226        self.subagents.push(SubagentTask {
1227            call_id: call_id.to_owned(),
1228            task_id: None,
1229            task,
1230            model,
1231            effort,
1232            status: SubagentStatus::Queued,
1233            result: None,
1234        });
1235    }
1236
1237    fn update_subagent_queued(&mut self, call_id: &str, result: &Value) {
1238        let Some(task) = self
1239            .subagents
1240            .iter_mut()
1241            .find(|task| task.call_id == call_id)
1242        else {
1243            return;
1244        };
1245        task.task_id = result
1246            .get("task_id")
1247            .and_then(Value::as_str)
1248            .map(str::to_owned);
1249        task.status = if result.get("error").is_some() {
1250            SubagentStatus::Failed
1251        } else {
1252            // The queued acknowledgement is emitted after the worker thread
1253            // has been launched, so the persistent panel can show its live
1254            // state as running while the worker continues in the background.
1255            SubagentStatus::Running
1256        };
1257        task.result = Some(result.clone());
1258    }
1259
1260    fn complete_subagent(&mut self, task_id: &str, result: Value) {
1261        if let Some(task) = self
1262            .subagents
1263            .iter_mut()
1264            .find(|task| task.task_id.as_deref() == Some(task_id))
1265        {
1266            task.status = Self::subagent_result_status(&result);
1267            task.result = Some(result);
1268        }
1269    }
1270
1271    fn subagent_result_status(result: &Value) -> SubagentStatus {
1272        if result.get("error").is_some()
1273            || result
1274                .get("cancelled")
1275                .and_then(Value::as_bool)
1276                .unwrap_or(false)
1277        {
1278            SubagentStatus::Failed
1279        } else {
1280            SubagentStatus::Completed
1281        }
1282    }
1283
1284    fn cursor_visible(&self) -> bool {
1285        (self.cursor_epoch.elapsed().as_millis() / CURSOR_BLINK_INTERVAL.as_millis())
1286            .is_multiple_of(2)
1287    }
1288
1289    fn apply_event(&mut self, event: ProtocolEvent) {
1290        match event {
1291            ProtocolEvent::Session { .. } => {}
1292            ProtocolEvent::AssistantDelta { text } => self.add_assistant(&text),
1293            ProtocolEvent::ToolCall {
1294                id,
1295                name,
1296                arguments,
1297            } => self.add_tool_call(&crate::model::ChatToolCall {
1298                id,
1299                name,
1300                arguments,
1301            }),
1302            ProtocolEvent::ToolResult { id, name, result } => {
1303                self.add_tool_result(&id, &name, result)
1304            }
1305            ProtocolEvent::TurnEnd => {
1306                self.complete_reasoning();
1307                self.status = "finalizing".to_owned();
1308                self.transcript
1309                    .push(TranscriptItem::Info("✓ turn complete".to_owned()));
1310            }
1311            ProtocolEvent::TurnInterrupted { reason, phase } => {
1312                self.complete_reasoning();
1313                self.status = "cancelling".to_owned();
1314                self.transcript
1315                    .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1316            }
1317            ProtocolEvent::Error { message } => {
1318                self.complete_reasoning();
1319                self.status = "error".to_owned();
1320                self.transcript.push(TranscriptItem::Error(message));
1321            }
1322        }
1323    }
1324}
1325
1326fn parse_subagent_notification(text: &str) -> Option<(String, Value)> {
1327    let prefix = "Background subagent ";
1328    let suffix = " completed. Deliver this result to the user and continue the task: ";
1329    let rest = text.strip_prefix(prefix)?;
1330    let (task_id, encoded_result) = rest.split_once(suffix)?;
1331    if task_id.is_empty() {
1332        return None;
1333    }
1334    Some((
1335        task_id.to_owned(),
1336        serde_json::from_str(encoded_result).ok()?,
1337    ))
1338}
1339
1340#[derive(Debug, Clone, PartialEq)]
1341enum TranscriptItem {
1342    User {
1343        text: String,
1344        skill_instruction_attached: bool,
1345    },
1346    Assistant(String),
1347    ToolCall {
1348        id: String,
1349        name: String,
1350        arguments: String,
1351    },
1352    ToolResult {
1353        id: String,
1354        name: String,
1355        result: Value,
1356    },
1357    Error(String),
1358    Info(String),
1359    Reasoning {
1360        complete: bool,
1361    },
1362}
1363
1364fn subagent_panel_area(state: &UiState, area: Rect) -> Option<Rect> {
1365    if state.subagents.is_empty() || area.width < SUBAGENT_PANEL_MIN_TERMINAL_WIDTH {
1366        return None;
1367    }
1368    let width = SUBAGENT_PANEL_WIDTH.min(area.width.saturating_sub(40));
1369    (width >= 24).then(|| {
1370        Rect::new(
1371            area.x + area.width.saturating_sub(width),
1372            area.y,
1373            width,
1374            area.height,
1375        )
1376    })
1377}
1378
1379fn content_area(state: &UiState, area: Rect) -> Rect {
1380    let Some(panel) = subagent_panel_area(state, area) else {
1381        return area;
1382    };
1383    Rect::new(
1384        area.x,
1385        area.y,
1386        panel.x.saturating_sub(area.x + 1),
1387        area.height,
1388    )
1389}
1390
1391fn ui_layout(state: &UiState, area: Rect) -> (Rect, Option<Rect>, Rect, Rect) {
1392    let input_rows = input_visible_rows(state, area.width.saturating_sub(2));
1393    let input_height = input_rows.clamp(1, MAX_INPUT_ROWS) + 2;
1394    let chunks = Layout::default()
1395        .direction(Direction::Vertical)
1396        .constraints([
1397            Constraint::Min(1),
1398            Constraint::Length(input_height),
1399            Constraint::Length(1),
1400        ])
1401        .split(area);
1402    let picker_height = skill_picker_height(state);
1403    let picker_area = (picker_height > 0).then(|| {
1404        Rect::new(
1405            chunks[1].x,
1406            chunks[1].y.saturating_sub(picker_height),
1407            chunks[1].width,
1408            picker_height,
1409        )
1410    });
1411    (chunks[0], picker_area, chunks[1], chunks[2])
1412}
1413
1414fn max_scroll_for_area(state: &UiState, size: Size) -> u16 {
1415    let area = Rect::new(0, 0, size.width, size.height);
1416    let (chat_chunk, _, _, _) = ui_layout(state, content_area(state, area));
1417    let chat_height = chat_chunk.height.saturating_sub(1);
1418    let lines = transcript_lines(state, chat_chunk.width);
1419    lines
1420        .len()
1421        .saturating_sub(chat_height as usize)
1422        .min(u16::MAX as usize) as u16
1423}
1424
1425/// Number of wrapped rows the current input occupies at `width`.
1426fn input_visible_rows(state: &UiState, width: u16) -> u16 {
1427    let width = width as usize;
1428    if width == 0 {
1429        return 1;
1430    }
1431    let prompt = input_display_text(state);
1432    let wrapped = wrap_text(&prompt, width);
1433    wrapped.len().max(1) as u16
1434}
1435
1436fn input_prompt(input: &str) -> String {
1437    input.to_owned()
1438}
1439
1440fn input_display_text(state: &UiState) -> String {
1441    redact_secret(&input_prompt(&state.input), Some(&state.secret))
1442}
1443
1444fn command_names(mut skill_names: Vec<String>) -> Vec<String> {
1445    skill_names.extend(BUILTIN_COMMANDS.into_iter().map(str::to_owned));
1446    skill_names.sort();
1447    skill_names.dedup();
1448    skill_names
1449}
1450
1451#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1452enum BuiltinCommand {
1453    Settings,
1454    Exit,
1455}
1456
1457impl BuiltinCommand {
1458    fn name(self) -> &'static str {
1459        match self {
1460            Self::Settings => "settings",
1461            Self::Exit => "exit",
1462        }
1463    }
1464}
1465
1466fn builtin_command(input: &str) -> Option<BuiltinCommand> {
1467    match input.split_whitespace().next()? {
1468        "/settings" => Some(BuiltinCommand::Settings),
1469        "/exit" => Some(BuiltinCommand::Exit),
1470        _ => None,
1471    }
1472}
1473
1474/// The slash picker only accepts a command at the beginning of the message.
1475/// Once whitespace starts arguments, normal message entry resumes.
1476fn matching_skill_names<'a>(input: &str, skill_names: &'a [String]) -> Vec<&'a str> {
1477    let Some(query) = input.strip_prefix('/') else {
1478        return Vec::new();
1479    };
1480    if query.chars().any(char::is_whitespace) {
1481        return Vec::new();
1482    }
1483    skill_names
1484        .iter()
1485        .map(String::as_str)
1486        .filter(|name| name.starts_with(query))
1487        .collect()
1488}
1489
1490fn skill_picker_height(state: &UiState) -> u16 {
1491    if state.skill_picker_visible() {
1492        // Header, visible commands, and the rounded top/bottom border.
1493        (state
1494            .matching_skill_names()
1495            .len()
1496            .min(SKILL_PICKER_MAX_ROWS)
1497            + 3) as u16
1498    } else {
1499        0
1500    }
1501}
1502
1503fn skill_picker_range(total: usize, focus: usize) -> std::ops::Range<usize> {
1504    let start = focus
1505        .saturating_add(1)
1506        .saturating_sub(SKILL_PICKER_MAX_ROWS);
1507    start.min(total)..(start + SKILL_PICKER_MAX_ROWS).min(total)
1508}
1509
1510/// Return the command portion of a currently valid explicit skill invocation.
1511/// This mirrors the command grammar used by the turn engine, while keeping the
1512/// styling concern local to the TUI.
1513fn active_skill_trigger<'a>(input: &'a str, skill_names: &[String]) -> Option<&'a str> {
1514    let invocation = input.strip_prefix('/')?;
1515    let name = invocation
1516        .split_once(char::is_whitespace)
1517        .map_or(invocation, |(name, _)| name);
1518    if name.is_empty() || !skill_names.iter().any(|skill_name| skill_name == name) {
1519        return None;
1520    }
1521    Some(&input[..1 + name.len()])
1522}
1523
1524/// Preserve input wrapping while styling a recognized `/<name>` prefix
1525/// independently from any arguments the user is still entering.
1526fn styled_text_lines(
1527    input: &str,
1528    active_skill_trigger: Option<&str>,
1529    width: usize,
1530    text_style: Style,
1531) -> Vec<Line<'static>> {
1532    let trigger_len = active_skill_trigger.map_or(0, |trigger| trigger.chars().count());
1533    let mut char_offset = 0usize;
1534    let mut lines = Vec::new();
1535
1536    for source_line in input.split('\n') {
1537        for row in wrap_line(source_line, width) {
1538            let mut spans = Vec::new();
1539            let mut text = String::new();
1540            let mut highlighted = None;
1541            for character in row.chars() {
1542                let should_highlight = char_offset < trigger_len;
1543                if highlighted != Some(should_highlight) && !text.is_empty() {
1544                    spans.push(styled_text_span(
1545                        std::mem::take(&mut text),
1546                        highlighted.unwrap_or(false),
1547                        text_style,
1548                    ));
1549                }
1550                highlighted = Some(should_highlight);
1551                text.push(character);
1552                char_offset += 1;
1553            }
1554            if !text.is_empty() {
1555                spans.push(styled_text_span(
1556                    text,
1557                    highlighted.unwrap_or(false),
1558                    text_style,
1559                ));
1560            }
1561            if spans.is_empty() {
1562                spans.push(Span::styled(String::new(), text_style));
1563            }
1564            lines.push(Line::from(spans));
1565        }
1566        // `split` retains empty trailing lines; account for the newline that
1567        // separated this source line from the next one in the character index.
1568        char_offset += 1;
1569    }
1570
1571    lines
1572}
1573
1574fn styled_text_span(text: String, highlighted: bool, text_style: Style) -> Span<'static> {
1575    if highlighted {
1576        Span::styled(text, Style::default().fg(Color::Cyan))
1577    } else {
1578        Span::styled(text, text_style)
1579    }
1580}
1581
1582fn cursor_row(input: &str, cursor: usize, width: usize) -> u16 {
1583    let prefix: String = input.chars().take(cursor).collect();
1584    wrap_text(&prefix, width)
1585        .len()
1586        .saturating_sub(1)
1587        .min(u16::MAX as usize) as u16
1588}
1589
1590fn insert_at_cursor(input: &mut String, cursor: &mut usize, character: char) {
1591    let byte_index = input
1592        .char_indices()
1593        .nth(*cursor)
1594        .map_or(input.len(), |(index, _)| index);
1595    input.insert(byte_index, character);
1596    *cursor += 1;
1597}
1598
1599fn remove_before_cursor(input: &mut String, cursor: &mut usize) -> bool {
1600    if *cursor == 0 {
1601        return false;
1602    }
1603    let end = input
1604        .char_indices()
1605        .nth(*cursor)
1606        .map_or(input.len(), |(index, _)| index);
1607    let start = input
1608        .char_indices()
1609        .nth(*cursor - 1)
1610        .map(|(index, _)| index)
1611        .unwrap_or(0);
1612    input.replace_range(start..end, "");
1613    *cursor -= 1;
1614    true
1615}
1616
1617fn draw(frame: &mut Frame<'_>, state: &UiState) {
1618    let content = content_area(state, frame.area());
1619    let (chat_chunk, picker_area, input_chunk, status_area) = ui_layout(state, content);
1620
1621    // Activity is conversation state, not terminal help text. Keep it in the
1622    // chat viewport so the picker can intentionally overlay its ready state.
1623    let chat_height = chat_chunk.height.saturating_sub(1);
1624    let chat_area = Rect::new(chat_chunk.x, chat_chunk.y, chat_chunk.width, chat_height);
1625    // The skill picker is a true overlay: it must not resize or scroll the transcript.
1626    let visible_chat_area = chat_area;
1627    let activity_area = Rect::new(
1628        chat_chunk.x,
1629        chat_chunk.y + chat_height,
1630        chat_chunk.width,
1631        chat_chunk.height - chat_height,
1632    );
1633
1634    let width = chat_chunk.width;
1635    if state.welcome_visible {
1636        let welcome_lines = welcome_lines(&state.attached_agents);
1637        let welcome_height = (welcome_lines.len() as u16).min(visible_chat_area.height);
1638        let welcome_area = Rect::new(
1639            visible_chat_area.x,
1640            visible_chat_area.y + visible_chat_area.height.saturating_sub(welcome_height) / 2,
1641            visible_chat_area.width,
1642            welcome_height,
1643        );
1644        let welcome = Paragraph::new(welcome_lines).alignment(Alignment::Center);
1645        frame.render_widget(welcome, welcome_area);
1646    } else {
1647        let lines = transcript_lines(state, width);
1648        let available = visible_chat_area.height as usize;
1649        let max_scroll = lines.len().saturating_sub(available).min(u16::MAX as usize) as u16;
1650        let scroll = if state.auto_scroll {
1651            max_scroll
1652        } else {
1653            state.scroll.min(max_scroll)
1654        };
1655        let transcript = Paragraph::new(lines).scroll((scroll, 0));
1656        frame.render_widget(transcript, visible_chat_area);
1657    }
1658
1659    let queued_suffix = (!state.queued_messages.is_empty()).then(|| {
1660        format!(
1661            " · queued {}: {}",
1662            state.queued_messages.len(),
1663            state.queued_messages.join(" | ")
1664        )
1665    });
1666    let activity_text = if state.status == "working" {
1667        format!("{} working", spinner_frame(state))
1668    } else if state.status == "compacting" {
1669        format!("{} compacting", spinner_frame(state))
1670    } else {
1671        format!("● {}", state.status)
1672    };
1673    let activity = activity_line(state, &activity_text, queued_suffix.as_deref());
1674    frame.render_widget(Paragraph::new(activity), activity_area);
1675
1676    if let Some(picker_area) = picker_area {
1677        draw_skill_picker(frame, state, picker_area);
1678    }
1679
1680    let input_style = user_message_style();
1681    let input_text_style = Style::default().fg(Color::White);
1682    let input_block = Block::default()
1683        .borders(Borders::ALL)
1684        .border_type(ratatui::widgets::BorderType::Rounded)
1685        .border_style(input_style);
1686    let input_area = input_block.inner(input_chunk);
1687    let prompt = input_display_text(state);
1688    let input_rows =
1689        input_visible_rows(state, content.width.saturating_sub(2)).clamp(1, MAX_INPUT_ROWS);
1690    let wrapped = wrap_text(&prompt, input_area.width.max(1) as usize);
1691    let visible = (wrapped.len() as u16).clamp(1, input_rows);
1692    let cursor_row = cursor_row(&prompt, state.cursor, input_area.width.max(1) as usize);
1693    let bottom_scroll = (wrapped.len() as u16).saturating_sub(visible);
1694    let cursor_scroll = (cursor_row + 1).saturating_sub(visible);
1695    let input_scroll = bottom_scroll.min(cursor_scroll);
1696    let active_skill_trigger = (!state.busy)
1697        .then(|| active_skill_trigger(&prompt, &state.skill_names))
1698        .flatten();
1699    let input_lines = styled_text_lines(
1700        &prompt,
1701        active_skill_trigger,
1702        input_area.width.max(1) as usize,
1703        input_text_style,
1704    );
1705    let input = Paragraph::new(input_lines)
1706        .style(input_text_style)
1707        .scroll((input_scroll, 0))
1708        .block(input_block);
1709    frame.render_widget(input, input_chunk);
1710
1711    let effort = state.effort.as_deref().unwrap_or("default");
1712    let status_text = format!("model={} · effort={effort}", state.model);
1713    let context_text = context_status_text(state);
1714    let context_width = UnicodeWidthStr::width(context_text.as_str()) as u16;
1715    let context_area_width = context_width.min(status_area.width.saturating_sub(1));
1716    let status_chunks = Layout::default()
1717        .direction(Direction::Horizontal)
1718        .constraints([Constraint::Min(1), Constraint::Length(context_area_width)])
1719        .split(status_area);
1720    let status = Paragraph::new(redact_secret(&status_text, Some(&state.secret)))
1721        .style(Style::default().fg(Color::DarkGray));
1722    frame.render_widget(status, status_chunks[0]);
1723    if context_area_width > 0 {
1724        let context = Paragraph::new(context_text)
1725            .alignment(Alignment::Right)
1726            .style(context_status_style(state));
1727        frame.render_widget(context, status_chunks[1]);
1728    }
1729    if let Some(panel_area) = subagent_panel_area(state, frame.area()) {
1730        draw_subagent_panel(frame, state, panel_area);
1731    }
1732    if let Some(settings) = &state.settings {
1733        draw_settings(frame, settings, frame.area());
1734    }
1735
1736    // Ratatui shows the cursor when a frame requests a position and hides it
1737    // when this branch is skipped, which provides the blink phase.
1738    if state.settings.is_none() && state.cursor_visible() && !input_area.is_empty() && visible > 0 {
1739        let cursor_prefix: String = prompt.chars().take(state.cursor).collect();
1740        let cursor_rows = wrap_text(&cursor_prefix, input_area.width.max(1) as usize);
1741        let cursor_line = cursor_rows.last().map(String::as_str).unwrap_or("");
1742        let cursor_offset = UnicodeWidthStr::width(cursor_line) as u16;
1743        let cursor_x = input_area.x + cursor_offset.min(input_area.width.saturating_sub(1));
1744        let cursor_y = input_area.y + cursor_row.saturating_sub(input_scroll);
1745        frame.set_cursor_position((cursor_x, cursor_y));
1746    }
1747}
1748
1749fn draw_subagent_panel(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
1750    if area.is_empty() {
1751        return;
1752    }
1753    frame.render_widget(Clear, area);
1754    let block = Block::default()
1755        .title(format!(" workers {} ", state.subagents.len()))
1756        .borders(Borders::ALL)
1757        .border_style(Style::default().fg(Color::Cyan));
1758    let inner = block.inner(area);
1759    frame.render_widget(block, area);
1760
1761    let width = inner.width.max(1) as usize;
1762    let mut lines = vec![Line::styled(
1763        "Background tasks",
1764        Style::default().fg(Color::DarkGray),
1765    )];
1766    for (index, task) in state.subagents.iter().enumerate() {
1767        lines.push(Line::raw(String::new()));
1768        let status = subagent_status_label(task.status);
1769        let status_style = subagent_status_style(task.status);
1770        lines.push(Line::from(vec![
1771            Span::styled(
1772                format!("{} ", subagent_status_icon(task.status)),
1773                status_style,
1774            ),
1775            Span::styled(format!("{status}  "), status_style),
1776            Span::styled(format!("#{index}"), Style::default().fg(Color::DarkGray)),
1777        ]));
1778        push_panel_wrapped(
1779            &mut lines,
1780            &format!("  {}", redact_secret(&task.task, Some(&state.secret))),
1781            width,
1782            Style::default().fg(Color::White),
1783        );
1784        if let Some(task_id) = &task.task_id {
1785            push_panel_wrapped(
1786                &mut lines,
1787                &format!("  {task_id}"),
1788                width,
1789                Style::default().fg(Color::DarkGray),
1790            );
1791        }
1792        let model = task.model.as_deref().unwrap_or("session model");
1793        let effort = task.effort.as_deref().unwrap_or("default effort");
1794        push_panel_wrapped(
1795            &mut lines,
1796            &format!("  {model} · {effort}"),
1797            width,
1798            Style::default().fg(Color::DarkGray),
1799        );
1800        if let Some(result) = &task.result {
1801            if matches!(
1802                task.status,
1803                SubagentStatus::Completed | SubagentStatus::Failed
1804            ) {
1805                let summary = subagent_result_preview(result, &state.secret);
1806                push_panel_wrapped(
1807                    &mut lines,
1808                    &format!("  ↳ {summary}"),
1809                    width,
1810                    subagent_status_style(task.status),
1811                );
1812            }
1813        }
1814    }
1815    frame.render_widget(Paragraph::new(lines), inner);
1816}
1817
1818fn push_panel_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, style: Style) {
1819    for row in wrap_text(text, width) {
1820        lines.push(Line::styled(row, style));
1821    }
1822}
1823
1824fn subagent_status_label(status: SubagentStatus) -> &'static str {
1825    match status {
1826        SubagentStatus::Queued => "queued",
1827        SubagentStatus::Running => "running",
1828        SubagentStatus::Completed => "completed",
1829        SubagentStatus::Failed => "error",
1830    }
1831}
1832
1833fn subagent_status_icon(status: SubagentStatus) -> char {
1834    match status {
1835        SubagentStatus::Queued => '○',
1836        SubagentStatus::Running => '●',
1837        SubagentStatus::Completed => '✓',
1838        SubagentStatus::Failed => '×',
1839    }
1840}
1841
1842fn subagent_status_style(status: SubagentStatus) -> Style {
1843    match status {
1844        SubagentStatus::Queued => Style::default().fg(PENDING_TOOL_COLOR),
1845        SubagentStatus::Running => Style::default().fg(Color::Cyan),
1846        SubagentStatus::Completed => Style::default().fg(Color::Green),
1847        SubagentStatus::Failed => Style::default().fg(Color::Red),
1848    }
1849}
1850
1851fn subagent_result_preview(result: &Value, secret: &str) -> String {
1852    let preview = result
1853        .get("output")
1854        .and_then(Value::as_str)
1855        .or_else(|| result.get("error").and_then(Value::as_str))
1856        .or_else(|| {
1857            result
1858                .get("cancelled")
1859                .and_then(Value::as_bool)
1860                .filter(|cancelled| *cancelled)
1861                .map(|_| "cancelled")
1862        })
1863        .unwrap_or("completed");
1864    redact_secret(&truncate_output(preview).replace('\n', " ↵ "), Some(secret))
1865}
1866
1867enum SettingsState {
1868    Loading,
1869    Applying {
1870        model: String,
1871        effort: Option<String>,
1872    },
1873    Error(String),
1874    Models {
1875        models: Vec<ProviderModel>,
1876        query: String,
1877        focus: usize,
1878    },
1879    Effort {
1880        model: ProviderModel,
1881        input: String,
1882        focus: usize,
1883    },
1884}
1885fn draw_settings(frame: &mut Frame<'_>, settings: &SettingsState, area: Rect) {
1886    let width = area
1887        .width
1888        .saturating_sub(2)
1889        .min(SETTINGS_MAX_WIDTH)
1890        .max(SETTINGS_MIN_WIDTH.min(area.width));
1891    let height = area
1892        .height
1893        .saturating_sub(2)
1894        .min(SETTINGS_MAX_HEIGHT)
1895        .max(SETTINGS_MIN_HEIGHT.min(area.height));
1896    let popup = Rect::new(
1897        area.x + area.width.saturating_sub(width) / 2,
1898        area.y + area.height.saturating_sub(height) / 2,
1899        width,
1900        height,
1901    );
1902    frame.render_widget(Clear, popup);
1903    let block = Block::default()
1904        .title(" /settings ")
1905        .borders(Borders::ALL)
1906        .border_style(Style::default().fg(Color::Cyan));
1907    let inner = block.inner(popup);
1908    frame.render_widget(block, popup);
1909
1910    let lines = match settings {
1911        SettingsState::Loading => vec![
1912            Line::styled("Loading provider models…", Style::default().fg(Color::Cyan)),
1913            Line::raw(""),
1914            Line::styled("Esc  cancel", Style::default().fg(Color::DarkGray)),
1915        ],
1916        SettingsState::Applying { model, effort } => vec![
1917            Line::styled("Applying selection…", Style::default().fg(Color::Cyan)),
1918            Line::raw(model.clone()),
1919            Line::raw(format!(
1920                "effort: {}",
1921                effort.as_deref().unwrap_or("default")
1922            )),
1923        ],
1924        SettingsState::Error(error) => vec![
1925            Line::styled("Unable to update settings", Style::default().fg(Color::Red)),
1926            Line::raw(""),
1927            Line::raw(error.clone()),
1928            Line::raw(""),
1929            Line::styled("Enter/Esc  close", Style::default().fg(Color::DarkGray)),
1930        ],
1931        SettingsState::Models {
1932            models,
1933            query,
1934            focus,
1935        } => {
1936            let query_lower = query.to_lowercase();
1937            let filtered = models
1938                .iter()
1939                .filter(|model| model.id.to_lowercase().contains(&query_lower))
1940                .collect::<Vec<_>>();
1941            let focus = (*focus).min(filtered.len().saturating_sub(1));
1942            let list_rows = inner.height.saturating_sub(4) as usize;
1943            let range = selection_range(filtered.len(), focus, list_rows);
1944            let mut lines = vec![
1945                Line::from(vec![
1946                    Span::styled("Model  ", Style::default().fg(Color::DarkGray)),
1947                    Span::styled(
1948                        if query.is_empty() {
1949                            "type to filter…"
1950                        } else {
1951                            query
1952                        },
1953                        Style::default().fg(if query.is_empty() {
1954                            Color::DarkGray
1955                        } else {
1956                            Color::White
1957                        }),
1958                    ),
1959                ]),
1960                Line::styled(
1961                    format!(
1962                        "{} models{}",
1963                        filtered.len(),
1964                        if filtered.is_empty() {
1965                            ""
1966                        } else {
1967                            " · ↑/↓ move · Enter choose"
1968                        }
1969                    ),
1970                    Style::default().fg(Color::DarkGray),
1971                ),
1972            ];
1973            if filtered.is_empty() {
1974                lines.push(Line::styled(
1975                    "No matching models",
1976                    Style::default().fg(Color::Yellow),
1977                ));
1978            } else {
1979                for index in range {
1980                    let selected = index == focus;
1981                    lines.push(Line::styled(
1982                        format!(
1983                            "{} {}",
1984                            if selected { "›" } else { " " },
1985                            filtered[index].id
1986                        ),
1987                        if selected {
1988                            Style::default().fg(Color::Black).bg(Color::Cyan)
1989                        } else {
1990                            Style::default().fg(Color::White)
1991                        },
1992                    ));
1993                }
1994            }
1995            lines.push(Line::styled(
1996                "Esc  cancel",
1997                Style::default().fg(Color::DarkGray),
1998            ));
1999            lines
2000        }
2001        SettingsState::Effort {
2002            model,
2003            input,
2004            focus,
2005        } => {
2006            let mut lines = vec![
2007                Line::styled(model.id.clone(), Style::default().fg(Color::Cyan)),
2008                Line::styled("Reasoning effort", Style::default().fg(Color::DarkGray)),
2009            ];
2010            match &model.efforts {
2011                Some(efforts) => {
2012                    let total = efforts.len() + 1;
2013                    let focus = (*focus).min(total.saturating_sub(1));
2014                    let list_rows = inner.height.saturating_sub(4) as usize;
2015                    for index in selection_range(total, focus, list_rows) {
2016                        let value = if index == 0 {
2017                            "default"
2018                        } else {
2019                            efforts[index - 1].as_str()
2020                        };
2021                        let selected = index == focus;
2022                        lines.push(Line::styled(
2023                            format!("{} {value}", if selected { "›" } else { " " }),
2024                            if selected {
2025                                Style::default().fg(Color::Black).bg(Color::Cyan)
2026                            } else {
2027                                Style::default().fg(Color::White)
2028                            },
2029                        ));
2030                    }
2031                    lines.push(Line::styled(
2032                        "↑/↓ move · Enter save · Esc cancel",
2033                        Style::default().fg(Color::DarkGray),
2034                    ));
2035                }
2036                None => {
2037                    lines.push(Line::raw("Provider did not advertise allowed efforts."));
2038                    lines.push(Line::from(vec![
2039                        Span::styled("Value  ", Style::default().fg(Color::DarkGray)),
2040                        Span::styled(
2041                            if input.is_empty() { "default" } else { input },
2042                            Style::default().fg(Color::White),
2043                        ),
2044                    ]));
2045                    lines.push(Line::styled(
2046                        "Type a value · Enter save · Esc cancel",
2047                        Style::default().fg(Color::DarkGray),
2048                    ));
2049                }
2050            }
2051            lines
2052        }
2053    };
2054    frame.render_widget(Paragraph::new(lines), inner);
2055}
2056
2057fn selection_range(total: usize, focus: usize, max_rows: usize) -> std::ops::Range<usize> {
2058    if total == 0 || max_rows == 0 {
2059        return 0..0;
2060    }
2061    let focus = focus.min(total - 1);
2062    let visible = total.min(max_rows);
2063    let start = focus
2064        .saturating_add(1)
2065        .saturating_sub(visible)
2066        .min(total - visible);
2067    start..start + visible
2068}
2069
2070fn draw_skill_picker(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
2071    let matches = state.matching_skill_names();
2072    let total = matches.len();
2073    if total == 0 || area.is_empty() {
2074        return;
2075    }
2076
2077    // The picker is painted last, over the existing transcript and activity;
2078    // its geometry never participates in the underlying layout.
2079    frame.render_widget(Clear, area);
2080    let block = Block::default()
2081        .borders(Borders::ALL)
2082        .border_type(BorderType::Rounded)
2083        .border_style(Style::default().fg(Color::Cyan));
2084    let inner = block.inner(area);
2085    frame.render_widget(block, area);
2086
2087    let focus = state.skill_picker_focus.min(total - 1);
2088    let header = Line::styled(
2089        format!("[{}/{}]", focus + 1, total),
2090        Style::default().fg(Color::DarkGray),
2091    );
2092    frame.render_widget(
2093        Paragraph::new(header),
2094        Rect::new(inner.x, inner.y, inner.width, 1),
2095    );
2096
2097    for (row, index) in skill_picker_range(total, focus).enumerate() {
2098        let style = if index == focus {
2099            Style::default().fg(Color::Cyan)
2100        } else {
2101            Style::default().fg(Color::DarkGray)
2102        };
2103        let skill = Line::styled(format!("/{}", matches[index]), style);
2104        frame.render_widget(
2105            Paragraph::new(skill),
2106            Rect::new(inner.x, inner.y + 1 + row as u16, inner.width, 1),
2107        );
2108    }
2109}
2110
2111fn welcome_line() -> Line<'static> {
2112    let character_count = WELCOME_MESSAGE.chars().count();
2113    let spans = WELCOME_MESSAGE
2114        .chars()
2115        .enumerate()
2116        .map(|(index, character)| {
2117            let progress = if character_count <= 1 {
2118                0.0
2119            } else {
2120                index as f32 / (character_count - 1) as f32
2121            };
2122            let color = Color::Rgb(
2123                interpolate_color(WELCOME_START_COLOR.0, WELCOME_END_COLOR.0, progress),
2124                interpolate_color(WELCOME_START_COLOR.1, WELCOME_END_COLOR.1, progress),
2125                interpolate_color(WELCOME_START_COLOR.2, WELCOME_END_COLOR.2, progress),
2126            );
2127            Span::styled(character.to_string(), Style::default().fg(color))
2128        })
2129        .collect::<Vec<_>>();
2130    Line::from(spans)
2131}
2132
2133fn interpolate_color(start: u8, end: u8, progress: f32) -> u8 {
2134    (start as f32 + (end as f32 - start as f32) * progress).round() as u8
2135}
2136
2137fn welcome_lines(attached_agents: &[String]) -> Vec<Line<'static>> {
2138    let mut lines = vec![
2139        welcome_line(),
2140        Line::styled(WELCOME_TAGLINE, Style::default().fg(Color::DarkGray)),
2141        Line::raw(""),
2142    ];
2143
2144    if attached_agents.is_empty() {
2145        lines.push(Line::styled(
2146            "Attached AGENTS.md: none",
2147            Style::default().fg(Color::DarkGray),
2148        ));
2149    } else {
2150        lines.push(Line::styled(
2151            "Attached AGENTS.md:",
2152            Style::default().fg(Color::DarkGray),
2153        ));
2154        lines.extend(
2155            attached_agents.iter().map(|path| {
2156                Line::styled(format!("• {path}"), Style::default().fg(Color::DarkGray))
2157            }),
2158        );
2159    }
2160
2161    lines
2162}
2163
2164fn transcript_lines(state: &UiState, width: u16) -> Vec<Line<'static>> {
2165    let width = width.max(1) as usize;
2166    let mut lines = Vec::new();
2167    let mut rendered_item = false;
2168
2169    for (index, item) in state.transcript.iter().enumerate() {
2170        // Results are positioned on their matching call, even when the model
2171        // emitted several calls before execution produced any result.
2172        if is_result_attached_to_call(&state.transcript, index) {
2173            continue;
2174        }
2175        if rendered_item {
2176            lines.push(Line::raw(String::new()));
2177        }
2178        match item {
2179            TranscriptItem::User {
2180                text,
2181                skill_instruction_attached,
2182            } => {
2183                let text = redact_secret(text, Some(&state.secret));
2184                let trigger = skill_instruction_attached
2185                    .then(|| active_skill_trigger(&text, &state.skill_names))
2186                    .flatten();
2187                push_user_message_block(&mut lines, &text, trigger, width);
2188            }
2189            TranscriptItem::Assistant(text) => {
2190                let text = redact_secret(text, Some(&state.secret));
2191                push_wrapped(&mut lines, &text, width, Style::default());
2192            }
2193            TranscriptItem::ToolCall {
2194                id,
2195                name,
2196                arguments,
2197            } => {
2198                let result = matching_tool_result(&state.transcript, index, id);
2199                let segments = if name == "cmd" {
2200                    cmd_tool_segments(arguments, result, state)
2201                } else if name == "spawn_subagent" {
2202                    subagent_tool_segments(id, arguments, state)
2203                } else {
2204                    generic_tool_segments(name, arguments, result, state)
2205                };
2206                push_spans_wrapped(&mut lines, &segments, width);
2207            }
2208            TranscriptItem::ToolResult {
2209                id: _,
2210                name: _,
2211                result,
2212            } => {
2213                let result_text = format_tool_result(result);
2214                let result_text = redact_secret(&result_text, Some(&state.secret));
2215                push_spans_wrapped(&mut lines, &[(result_text, tool_result_style())], width);
2216            }
2217            TranscriptItem::Error(text) => {
2218                let text = redact_secret(text, Some(&state.secret));
2219                push_wrapped(&mut lines, &text, width, error_style());
2220            }
2221            TranscriptItem::Info(text) => {
2222                let text = redact_secret(text, Some(&state.secret));
2223                push_wrapped(&mut lines, &text, width, info_style());
2224            }
2225            TranscriptItem::Reasoning { complete } => {
2226                let text = if *complete {
2227                    "Reasoning Complete".to_owned()
2228                } else {
2229                    format!("Reasoning... {}", spinner_frame(state))
2230                };
2231                push_wrapped(&mut lines, &text, width, thinking_style());
2232            }
2233        }
2234        rendered_item = true;
2235    }
2236    if lines.is_empty() {
2237        lines.push(Line::raw(""));
2238    }
2239    lines
2240}
2241
2242fn cmd_tool_segments(
2243    arguments: &str,
2244    result: Option<&Value>,
2245    state: &UiState,
2246) -> Vec<(String, Style)> {
2247    let command = redact_secret(&command_display(arguments), Some(&state.secret));
2248    let (icon, status, status_style) = result.map(cmd_result_status).unwrap_or((
2249        '·',
2250        "running".to_owned(),
2251        pending_tool_call_style(),
2252    ));
2253    vec![
2254        (format!("{icon} cmd  $ {command}  → "), status_style),
2255        (status, status_style),
2256    ]
2257}
2258
2259fn command_display(arguments: &str) -> String {
2260    serde_json::from_str::<Value>(arguments)
2261        .ok()
2262        .and_then(|value| {
2263            value
2264                .get("command")
2265                .and_then(Value::as_str)
2266                .map(str::to_owned)
2267        })
2268        .map(|command| truncate_output(&command))
2269        .unwrap_or_else(|| truncate_output(arguments))
2270}
2271
2272fn cmd_result_status(result: &Value) -> (char, String, Style) {
2273    if result
2274        .get("canceled")
2275        .and_then(Value::as_bool)
2276        .unwrap_or(false)
2277    {
2278        return (
2279            '!',
2280            "canceled".to_owned(),
2281            Style::default().fg(Color::Yellow),
2282        );
2283    }
2284    if result
2285        .get("timed_out")
2286        .and_then(Value::as_bool)
2287        .unwrap_or(false)
2288    {
2289        return (
2290            '!',
2291            "timeout".to_owned(),
2292            Style::default().fg(Color::Yellow),
2293        );
2294    }
2295    if result.get("error").is_some() {
2296        return ('×', "error".to_owned(), error_style());
2297    }
2298    match result.get("exit_code").and_then(Value::as_i64) {
2299        Some(0) => ('✓', "exit 0".to_owned(), Style::default().fg(Color::Green)),
2300        Some(code) => ('×', format!("exit {code}"), error_style()),
2301        None => ('✓', "done".to_owned(), Style::default().fg(Color::Green)),
2302    }
2303}
2304
2305fn subagent_tool_segments(call_id: &str, arguments: &str, state: &UiState) -> Vec<(String, Style)> {
2306    let task = state
2307        .subagents
2308        .iter()
2309        .find(|task| task.call_id == call_id)
2310        .map(|task| task.task.clone())
2311        .unwrap_or_else(|| command_display(arguments));
2312    let task = redact_secret(&truncate_output(&task), Some(&state.secret));
2313    let status = state
2314        .subagents
2315        .iter()
2316        .find(|candidate| candidate.call_id == call_id)
2317        .map(|task| subagent_status_label(task.status))
2318        .unwrap_or("queued");
2319    let style = state
2320        .subagents
2321        .iter()
2322        .find(|candidate| candidate.call_id == call_id)
2323        .map(|task| subagent_status_style(task.status))
2324        .unwrap_or_else(pending_tool_call_style);
2325    vec![(format!("↗ subagent  {task}  → {status}"), style)]
2326}
2327
2328fn generic_tool_segments(
2329    name: &str,
2330    arguments: &str,
2331    result: Option<&Value>,
2332    state: &UiState,
2333) -> Vec<(String, Style)> {
2334    let call_text = redact_secret(
2335        &format!("[tool:{name} {}]", call_arguments(arguments)),
2336        Some(&state.secret),
2337    );
2338    let mut segments = vec![(
2339        call_text,
2340        if result.is_some() {
2341            tool_call_style()
2342        } else {
2343            pending_tool_call_style()
2344        },
2345    )];
2346    if let Some(result) = result {
2347        let result_text = redact_secret(&format_tool_result(result), Some(&state.secret));
2348        segments.push((" > ".to_owned(), Style::default()));
2349        segments.push((result_text, tool_result_style()));
2350    } else {
2351        segments.push((
2352            format!(" {}", spinner_frame(state)),
2353            pending_tool_call_style(),
2354        ));
2355    }
2356    segments
2357}
2358
2359fn matching_tool_result<'a>(
2360    transcript: &'a [TranscriptItem],
2361    call_index: usize,
2362    call_id: &str,
2363) -> Option<&'a Value> {
2364    transcript
2365        .iter()
2366        .skip(call_index + 1)
2367        .find_map(|item| match item {
2368            TranscriptItem::ToolResult { id, result, .. } if id == call_id => Some(result),
2369            _ => None,
2370        })
2371}
2372
2373fn is_result_attached_to_call(transcript: &[TranscriptItem], result_index: usize) -> bool {
2374    let TranscriptItem::ToolResult { id, .. } = &transcript[result_index] else {
2375        return false;
2376    };
2377    let Some(call_index) = transcript[..result_index].iter().rposition(
2378        |item| matches!(item, TranscriptItem::ToolCall { id: call_id, .. } if call_id == id),
2379    ) else {
2380        return false;
2381    };
2382    !transcript[call_index + 1..result_index].iter().any(
2383        |item| matches!(item, TranscriptItem::ToolResult { id: result_id, .. } if result_id == id),
2384    )
2385}
2386
2387/// Render tool call arguments as the command string inside double quotes, for
2388/// example `"cat README.md"`. Previews are truncated to the same limit as tool
2389/// results; malformed arguments fall back to truncated raw text.
2390fn call_arguments(arguments: &str) -> String {
2391    let parsed: Value = match serde_json::from_str(arguments) {
2392        Ok(value) => value,
2393        Err(_) => return truncate_output(arguments),
2394    };
2395    if let Some(command) = parsed.get("command").and_then(Value::as_str) {
2396        return format!("\"{}\"", truncate_output(command));
2397    }
2398    let serialized = serde_json::to_string(&parsed).unwrap_or_else(|_| arguments.to_owned());
2399    truncate_output(&serialized)
2400}
2401
2402/// Render a tool result as a single-line JSON-string-array literal containing
2403/// stdout (or stderr when stdout is empty). Newlines are escaped so the whole
2404/// result stays on one line. Output is truncated to `RESULT_PREVIEW_CHARS`.
2405fn format_tool_result(result: &Value) -> String {
2406    let stdout = result.get("stdout").and_then(Value::as_str).unwrap_or("");
2407    let stderr = result.get("stderr").and_then(Value::as_str).unwrap_or("");
2408    let output = if !stdout.is_empty() { stdout } else { stderr };
2409    let truncated = truncate_output(output);
2410    // Build a JSON string literal so newlines and quotes are escaped and the
2411    // result renders on a single line as `["..."]`.
2412    let json_string = serde_json::to_string(&truncated).unwrap_or_else(|_| "\"\"".to_owned());
2413    format!("[{json_string}]")
2414}
2415
2416const RESULT_PREVIEW_CHARS: usize = 50;
2417
2418fn truncate_output(output: &str) -> String {
2419    let mut result: String = output.chars().take(RESULT_PREVIEW_CHARS).collect();
2420    if output.chars().count() > RESULT_PREVIEW_CHARS {
2421        result.push('…');
2422    }
2423    result
2424}
2425
2426fn user_message_style() -> Style {
2427    Style::default().fg(USER_BORDER_COLOR)
2428}
2429
2430/// Render a full-width rounded outline so user messages visually match the
2431/// rounded prompt border while assistant and tool output stays borderless.
2432fn push_user_message_block(
2433    lines: &mut Vec<Line<'static>>,
2434    text: &str,
2435    active_skill_trigger: Option<&str>,
2436    width: usize,
2437) {
2438    if width < 2 {
2439        lines.extend(styled_text_lines(
2440            text,
2441            active_skill_trigger,
2442            width.max(1),
2443            Style::default().fg(Color::White),
2444        ));
2445        return;
2446    }
2447
2448    let content_width = width - 2;
2449    let border_style = user_message_style();
2450    let rows = styled_text_lines(
2451        text,
2452        active_skill_trigger,
2453        content_width,
2454        Style::default().fg(Color::White),
2455    );
2456    lines.push(Line::styled(
2457        format!("╭{}╮", "─".repeat(content_width)),
2458        border_style,
2459    ));
2460    for row in rows {
2461        let row_width = UnicodeWidthStr::width(row.to_string().as_str());
2462        let padding = content_width.saturating_sub(row_width);
2463        let mut spans = Vec::with_capacity(row.spans.len() + 3);
2464        spans.push(Span::styled("│", border_style));
2465        spans.extend(row.spans);
2466        spans.push(Span::styled(
2467            " ".repeat(padding),
2468            Style::default().fg(Color::White),
2469        ));
2470        spans.push(Span::styled("│", border_style));
2471        lines.push(Line::from(spans));
2472    }
2473    lines.push(Line::styled(
2474        format!("╰{}╯", "─".repeat(content_width)),
2475        border_style,
2476    ));
2477}
2478
2479fn tool_call_style() -> Style {
2480    Style::default().fg(Color::Magenta)
2481}
2482
2483fn pending_tool_call_style() -> Style {
2484    Style::default().fg(PENDING_TOOL_COLOR)
2485}
2486
2487fn tool_result_style() -> Style {
2488    Style::default().fg(Color::DarkGray)
2489}
2490
2491fn error_style() -> Style {
2492    Style::default().fg(Color::Red)
2493}
2494
2495fn info_style() -> Style {
2496    Style::default().fg(Color::DarkGray)
2497}
2498
2499fn context_status_text(state: &UiState) -> String {
2500    let used = format_context_tokens(state.context_tokens);
2501    let Some(window) = state.context_window else {
2502        return format!("ctx={used}/? (?%)");
2503    };
2504    let percentage = context_percentage(state.context_tokens, window);
2505    format!(
2506        "ctx={used}/{} ({percentage}%)",
2507        format_context_tokens(window)
2508    )
2509}
2510
2511fn context_status_style(state: &UiState) -> Style {
2512    if state
2513        .context_window
2514        .is_some_and(|window| context_over_threshold(state.context_tokens, window))
2515    {
2516        Style::default().fg(PENDING_TOOL_COLOR)
2517    } else {
2518        Style::default().fg(Color::DarkGray)
2519    }
2520}
2521
2522fn context_percentage(used: usize, window: usize) -> usize {
2523    if window == 0 {
2524        return 0;
2525    }
2526    ((used as u128 * 100).div_ceil(window as u128)) as usize
2527}
2528
2529fn context_over_threshold(used: usize, window: usize) -> bool {
2530    window > 0 && (used as u128 * 100) > (window as u128 * 80)
2531}
2532
2533fn format_context_tokens(tokens: usize) -> String {
2534    if tokens >= 1_000_000 {
2535        format!("{:.2}M", tokens as f64 / 1_000_000.0)
2536    } else if tokens >= 1_000 {
2537        format!("{:.1}K", tokens as f64 / 1_000.0)
2538    } else {
2539        tokens.to_string()
2540    }
2541}
2542
2543fn activity_line<'a>(state: &UiState, text: &'a str, queued_suffix: Option<&'a str>) -> Line<'a> {
2544    if state.status != "working" {
2545        return Line::styled(text, activity_style_for(state));
2546    }
2547    let elapsed = state.cursor_epoch.elapsed();
2548    let character_count = text.chars().count().max(1);
2549    let mut spans = text
2550        .chars()
2551        .enumerate()
2552        .map(|(index, character)| {
2553            Span::styled(
2554                character.to_string(),
2555                Style::default().fg(working_gradient_color_at(elapsed, index, character_count)),
2556            )
2557        })
2558        .collect::<Vec<_>>();
2559    if let Some(suffix) = queued_suffix {
2560        // Queue state is information, not activity: retain a stable style so
2561        // the broad working sweep never makes it harder to scan.
2562        spans.push(Span::styled(suffix, Style::default().fg(Color::DarkGray)));
2563    }
2564    Line::from(spans)
2565}
2566
2567fn activity_style_for(state: &UiState) -> Style {
2568    if state.status == "compacting" {
2569        Style::default().fg(PENDING_TOOL_COLOR)
2570    } else {
2571        Style::default().fg(Color::Cyan)
2572    }
2573}
2574
2575fn working_gradient_colors_at(position: f64) -> (Color, Color, f64) {
2576    let position = position.rem_euclid(WORKING_GRADIENT_COLORS.len() as f64);
2577    let phase = position.floor() as usize;
2578    let progress = position.fract();
2579    let start = WORKING_GRADIENT_COLORS[phase];
2580    let end = WORKING_GRADIENT_COLORS[(phase + 1) % WORKING_GRADIENT_COLORS.len()];
2581    (
2582        Color::Rgb(start.0, start.1, start.2),
2583        Color::Rgb(end.0, end.1, end.2),
2584        progress,
2585    )
2586}
2587
2588/// Move a continuous warm gradient across the activity text. Offset each
2589/// character slightly along the same time-based palette, rather than swapping
2590/// whole character bands at a boundary.
2591fn working_gradient_color_at(
2592    elapsed: Duration,
2593    character_index: usize,
2594    character_count: usize,
2595) -> Color {
2596    const SWEEP_WIDTH: f64 = 0.35;
2597
2598    let position = elapsed.as_secs_f64() / WORKING_GRADIENT_CYCLE.as_secs_f64()
2599        + character_index as f64 / character_count.max(1) as f64 * SWEEP_WIDTH;
2600    let (
2601        Color::Rgb(red_start, green_start, blue_start),
2602        Color::Rgb(red_end, green_end, blue_end),
2603        progress,
2604    ) = working_gradient_colors_at(position)
2605    else {
2606        unreachable!("working gradient palette always uses RGB colours")
2607    };
2608
2609    Color::Rgb(
2610        interpolate_color(red_start, red_end, progress as f32),
2611        interpolate_color(green_start, green_end, progress as f32),
2612        interpolate_color(blue_start, blue_end, progress as f32),
2613    )
2614}
2615
2616fn thinking_style() -> Style {
2617    Style::default().fg(Color::DarkGray)
2618}
2619
2620fn spinner_frame(state: &UiState) -> char {
2621    const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
2622    let tick = state.cursor_epoch.elapsed().as_millis() / 100;
2623    FRAMES[(tick as usize) % FRAMES.len()]
2624}
2625
2626fn push_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, style: Style) {
2627    let mut added = false;
2628    for piece in wrap_text(text, width) {
2629        lines.push(Line::styled(piece, style));
2630        added = true;
2631    }
2632    if !added {
2633        lines.push(Line::styled(String::new(), style));
2634    }
2635}
2636
2637/// Push a logical line built from styled segments. When the rendered width
2638/// exceeds `width`, the whole line is character-wrapped; wrapped continuations
2639/// keep the style of the segment they fall on.
2640fn push_spans_wrapped(lines: &mut Vec<Line<'static>>, segments: &[(String, Style)], width: usize) {
2641    let mut current_spans: Vec<Span<'static>> = Vec::new();
2642    let mut current_width = 0usize;
2643    for (text, style) in segments {
2644        for character in text.chars() {
2645            let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
2646            if current_width + char_width > width && !current_spans.is_empty() {
2647                lines.push(Line::from(std::mem::take(&mut current_spans)));
2648                current_width = 0;
2649            }
2650            let mut buffer = [0u8; 4];
2651            let s = character.encode_utf8(&mut buffer);
2652            current_spans.push(Span::styled(s.to_owned(), *style));
2653            current_width += char_width;
2654        }
2655    }
2656    if current_spans.is_empty() {
2657        current_spans.push(Span::raw(String::new()));
2658    }
2659    lines.push(Line::from(current_spans));
2660}
2661
2662/// Wrap `text` into rows no wider than `width` display columns. Wrapping is
2663/// character-based so the row count matches exactly what a non-wrapping
2664/// `Paragraph` renderer draws, which keeps auto-scroll pinned to the true
2665/// bottom of the transcript regardless of terminal width. Empty lines are
2666/// preserved as empty rows.
2667fn wrap_text(text: &str, width: usize) -> Vec<String> {
2668    if width == 0 {
2669        return text.lines().map(str::to_owned).collect();
2670    }
2671    let mut rows = Vec::new();
2672    // `split` preserves a trailing empty row, so Shift+Enter renders an
2673    // immediate new line even before another character is typed.
2674    for line in text.split('\n') {
2675        rows.extend(wrap_line(line, width));
2676    }
2677    if rows.is_empty() {
2678        rows.push(String::new());
2679    }
2680    rows
2681}
2682
2683fn wrap_line(line: &str, width: usize) -> Vec<String> {
2684    let mut rows = Vec::new();
2685    let mut current = String::new();
2686    let mut current_width = 0usize;
2687    for character in line.chars() {
2688        let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
2689        if current_width + char_width > width && !current.is_empty() {
2690            rows.push(std::mem::take(&mut current));
2691            current_width = 0;
2692        }
2693        current.push(character);
2694        current_width += char_width;
2695    }
2696    rows.push(current);
2697    rows
2698}
2699
2700#[cfg(test)]
2701mod tests {
2702    use super::*;
2703
2704    #[test]
2705    fn turn_notifications_use_osc_777_with_fixed_secret_safe_messages() {
2706        let cases = [
2707            (TurnNotification::Completed, "Turn complete"),
2708            (TurnNotification::Interrupted, "Turn interrupted"),
2709            (TurnNotification::Failed, "Turn failed"),
2710        ];
2711
2712        for (notification, body) in cases {
2713            let mut output = Vec::new();
2714            send_turn_notification(&mut output, notification).expect("notification");
2715            assert_eq!(
2716                output,
2717                format!("\x1b]777;notify;Lucy;{body}\x07").into_bytes()
2718            );
2719        }
2720    }
2721
2722    #[test]
2723    fn turn_notifications_follow_the_terminal_turn_status() {
2724        assert_eq!(
2725            turn_notification_for_status("finalizing"),
2726            TurnNotification::Completed
2727        );
2728        assert_eq!(
2729            turn_notification_for_status("cancelling"),
2730            TurnNotification::Interrupted
2731        );
2732        assert_eq!(
2733            turn_notification_for_status("error"),
2734            TurnNotification::Failed
2735        );
2736    }
2737
2738    struct FailingWriter;
2739
2740    impl Write for FailingWriter {
2741        fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
2742            Err(io::Error::other("notification sink unavailable"))
2743        }
2744
2745        fn flush(&mut self) -> io::Result<()> {
2746            Err(io::Error::other("notification sink unavailable"))
2747        }
2748    }
2749
2750    #[test]
2751    fn notification_write_failure_does_not_keep_the_tui_busy() {
2752        let mut state = UiState::from_history(&[], "secret", "model", None, false);
2753        state.busy = true;
2754        state.active_cancel = Some(CancellationToken::new());
2755        let mut writer = FailingWriter;
2756
2757        release_finished_turn(&mut writer, &mut state);
2758
2759        assert!(!state.busy);
2760        assert!(state.active_cancel.is_none());
2761    }
2762
2763    #[test]
2764    fn an_idle_finish_does_not_emit_a_duplicate_notification() {
2765        let mut state = UiState::from_history(&[], "secret", "model", None, false);
2766        let mut output = Vec::new();
2767
2768        release_finished_turn(&mut output, &mut state);
2769
2770        assert!(output.is_empty());
2771    }
2772
2773    #[test]
2774    fn context_status_shows_used_window_and_percentage() {
2775        let mut state = UiState::from_history(&[], "secret", "model", None, false)
2776            .with_context(Some(100_000), 80_000);
2777
2778        assert_eq!(context_status_text(&state), "ctx=80.0K/100.0K (80%)");
2779        assert_eq!(context_status_style(&state).fg, Some(Color::DarkGray));
2780
2781        state.context_tokens = 80_001;
2782        assert_eq!(context_status_text(&state), "ctx=80.0K/100.0K (81%)");
2783        assert_eq!(context_status_style(&state).fg, Some(PENDING_TOOL_COLOR));
2784    }
2785
2786    #[test]
2787    fn context_status_handles_unknown_window_without_highlighting() {
2788        let state = UiState::from_history(&[], "secret", "model", None, false);
2789
2790        assert_eq!(context_status_text(&state), "ctx=1/? (?%)");
2791        assert_eq!(context_status_style(&state).fg, Some(Color::DarkGray));
2792    }
2793
2794    #[test]
2795    fn context_status_is_right_aligned_and_turns_orange_above_eighty_percent() {
2796        let state =
2797            UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
2798        let mut terminal =
2799            Terminal::new(ratatui::backend::TestBackend::new(60, 10)).expect("test terminal");
2800
2801        terminal
2802            .draw(|frame| draw(frame, &state))
2803            .expect("draw statusline");
2804
2805        let buffer = terminal.backend().buffer();
2806        let status_row = 9;
2807        let context = context_status_text(&state);
2808        let context_start = 60 - context.chars().count();
2809        assert_eq!(buffer[(context_start as u16, status_row)].symbol(), "c");
2810        assert_eq!(buffer[(59, status_row)].symbol(), ")");
2811        assert_eq!(buffer[(59, status_row)].fg, PENDING_TOOL_COLOR);
2812    }
2813
2814    #[test]
2815    fn working_activity_uses_a_continuous_warm_gradient() {
2816        let text = "⠋ working";
2817        let colors = text
2818            .chars()
2819            .enumerate()
2820            .map(|(index, _)| {
2821                working_gradient_color_at(Duration::from_millis(2_500), index, text.chars().count())
2822            })
2823            .collect::<Vec<_>>();
2824
2825        assert_eq!(colors.first(), Some(&Color::Rgb(228, 40, 120)));
2826        assert_eq!(colors.last(), Some(&Color::Rgb(232, 43, 86)));
2827        assert!(colors.windows(2).all(|pair| pair[0] != pair[1]));
2828
2829        let (start, end, _) = working_gradient_colors_at(1.0);
2830        assert_eq!(start, Color::Rgb(235, 45, 65));
2831        assert_eq!(end, Color::Rgb(255, 130, 25));
2832
2833        let (start, end, _) = working_gradient_colors_at(3.0);
2834        assert_eq!(start, Color::Rgb(235, 45, 65));
2835        assert_eq!(end, Color::Rgb(220, 35, 175));
2836    }
2837
2838    #[test]
2839    fn queued_activity_suffix_is_not_in_the_working_gradient() {
2840        let mut state = UiState::from_history(&[], "secret", "model", None, false);
2841        state.status = "working".to_owned();
2842        let line = activity_line(&state, "⠋ working", Some(" · queued 1: next task"));
2843        assert!(line.spans[.."⠋ working".chars().count()]
2844            .iter()
2845            .all(|span| span.style.fg != Some(Color::DarkGray)));
2846        assert_eq!(
2847            line.spans.last().expect("queue suffix").style.fg,
2848            Some(Color::DarkGray)
2849        );
2850    }
2851
2852    #[test]
2853    fn fresh_sessions_show_the_versioned_gradient_welcome_message() {
2854        let state = UiState::from_history(&[], "secret", "model", None, false);
2855        assert!(state.welcome_visible);
2856
2857        let line = welcome_line();
2858        assert_eq!(line.to_string(), WELCOME_MESSAGE);
2859        assert_eq!(
2860            line.spans.first().and_then(|span| span.style.fg),
2861            Some(Color::Rgb(
2862                WELCOME_START_COLOR.0,
2863                WELCOME_START_COLOR.1,
2864                WELCOME_START_COLOR.2,
2865            ))
2866        );
2867        assert_eq!(
2868            line.spans.last().and_then(|span| span.style.fg),
2869            Some(Color::Rgb(
2870                WELCOME_END_COLOR.0,
2871                WELCOME_END_COLOR.1,
2872                WELCOME_END_COLOR.2,
2873            ))
2874        );
2875    }
2876
2877    #[test]
2878    fn welcome_shows_the_tagline_and_attached_agents_paths() {
2879        let state = UiState::from_history(&[], "secret", "model", None, false)
2880            .with_attached_agents(vec![
2881                "/workspace/AGENTS.md".to_owned(),
2882                "/workspace/app/AGENTS.md".to_owned(),
2883            ]);
2884        let lines = welcome_lines(&state.attached_agents);
2885
2886        assert_eq!(lines[1].to_string(), WELCOME_TAGLINE);
2887        assert_eq!(lines[1].style.fg, Some(Color::DarkGray));
2888        assert_eq!(lines[3].to_string(), "Attached AGENTS.md:");
2889        assert_eq!(lines[4].to_string(), "• /workspace/AGENTS.md");
2890        assert_eq!(lines[5].to_string(), "• /workspace/app/AGENTS.md");
2891        assert!(lines[3..]
2892            .iter()
2893            .all(|line| line.style.fg == Some(Color::DarkGray)));
2894    }
2895
2896    #[test]
2897    fn welcome_reports_when_no_agents_file_is_attached() {
2898        let lines = welcome_lines(&[]);
2899        assert_eq!(
2900            lines.last().expect("empty context line").to_string(),
2901            "Attached AGENTS.md: none"
2902        );
2903    }
2904
2905    #[test]
2906    fn resumed_sessions_do_not_show_the_welcome_message() {
2907        let state = UiState::from_history(&[], "secret", "model", None, true);
2908        assert!(!state.welcome_visible);
2909    }
2910
2911    #[test]
2912    fn history_replay_keeps_interruption_after_messages() {
2913        let history = vec![
2914            SessionHistoryRecord::Message {
2915                timestamp: 1,
2916                message: ChatMessage::user("hello".to_owned()),
2917            },
2918            SessionHistoryRecord::Interruption {
2919                timestamp: 2,
2920                reason: "user_cancelled".to_owned(),
2921                phase: "provider_stream".to_owned(),
2922                assistant_text: "partial".to_owned(),
2923                tool_calls: Vec::new(),
2924                tool_results: Vec::new(),
2925            },
2926        ];
2927        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
2928        assert!(matches!(state.transcript[0], TranscriptItem::User { .. }));
2929        assert!(matches!(state.transcript[1], TranscriptItem::Assistant(_)));
2930        assert!(matches!(state.transcript[2], TranscriptItem::Info(_)));
2931        let text = transcript_lines(&state, 80)
2932            .iter()
2933            .map(ToString::to_string)
2934            .collect::<Vec<_>>()
2935            .join("\n");
2936        assert!(!text.contains("choices"));
2937    }
2938
2939    #[test]
2940    fn history_replay_does_not_render_assistant_reasoning_details() {
2941        let mut message = ChatMessage::assistant("visible answer".to_owned(), Vec::new());
2942        message.reasoning_details = Some(vec![serde_json::json!({
2943            "type": "reasoning.text",
2944            "text": "private reasoning"
2945        })]);
2946        let history = [SessionHistoryRecord::Message {
2947            timestamp: 1,
2948            message,
2949        }];
2950        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
2951        let text = transcript_lines(&state, 80)
2952            .iter()
2953            .map(ToString::to_string)
2954            .collect::<Vec<_>>()
2955            .join("\n");
2956        assert!(text.contains("visible answer"));
2957        assert!(!text.contains("private reasoning"));
2958        assert!(!text.contains("reasoning_details"));
2959    }
2960
2961    #[test]
2962    fn history_replay_preserves_repeated_records() {
2963        let history = vec![
2964            SessionHistoryRecord::Message {
2965                timestamp: 1,
2966                message: ChatMessage::assistant("same".to_owned(), Vec::new()),
2967            },
2968            SessionHistoryRecord::Interruption {
2969                timestamp: 2,
2970                reason: "user_cancelled".to_owned(),
2971                phase: "provider_stream".to_owned(),
2972                assistant_text: "same".to_owned(),
2973                tool_calls: Vec::new(),
2974                tool_results: Vec::new(),
2975            },
2976        ];
2977        let state = UiState::from_history(&history, "provider-secret", "model", None, true);
2978        assert_eq!(
2979            state
2980                .transcript
2981                .iter()
2982                .filter(|item| matches!(item, TranscriptItem::Assistant(text) if text == "same"))
2983                .count(),
2984            2
2985        );
2986    }
2987
2988    #[test]
2989    fn user_message_borders_remain_muted_yellow_while_its_text_is_white() {
2990        let history = [SessionHistoryRecord::Message {
2991            timestamp: 1,
2992            message: ChatMessage::user("hello".to_owned()),
2993        }];
2994        let state = UiState::from_history(&history, "provider-secret", "model", None, false);
2995        let lines = transcript_lines(&state, 12);
2996
2997        assert_eq!(lines.len(), 3);
2998        assert_eq!(lines[0].to_string(), "╭──────────╮");
2999        assert_eq!(lines[1].to_string(), "│hello     │");
3000        assert_eq!(lines[2].to_string(), "╰──────────╯");
3001        assert_eq!(lines[0].style.fg, Some(USER_BORDER_COLOR));
3002        assert_eq!(lines[2].style.fg, Some(USER_BORDER_COLOR));
3003        assert_eq!(lines[1].spans[0].style.fg, Some(USER_BORDER_COLOR));
3004        assert_eq!(lines[1].spans[1].style.fg, Some(Color::White));
3005        assert_eq!(
3006            lines[1].spans.last().map(|span| span.style.fg),
3007            Some(Some(USER_BORDER_COLOR))
3008        );
3009    }
3010
3011    #[test]
3012    fn attached_skill_highlights_its_trigger_in_the_user_message_without_a_notice_line() {
3013        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3014            .with_skill_names(vec!["release-notes".to_owned()]);
3015        state.add_user("/release-notes v1.2.0", "secret");
3016        state.mark_latest_user_skill_attached();
3017
3018        let lines = transcript_lines(&state, 40);
3019        assert_eq!(lines.len(), 3);
3020        let cyan_text = lines[1]
3021            .spans
3022            .iter()
3023            .filter(|span| span.style.fg == Some(Color::Cyan))
3024            .map(|span| span.content.as_ref())
3025            .collect::<String>();
3026        assert_eq!(cyan_text, "/release-notes");
3027        assert!(!lines
3028            .iter()
3029            .any(|line| line.to_string().contains("instruction attached")));
3030    }
3031
3032    #[test]
3033    fn transcript_rendering_redacts_history_content() {
3034        let history = [SessionHistoryRecord::Message {
3035            timestamp: 1,
3036            message: ChatMessage::assistant("provider-secret".to_owned(), Vec::new()),
3037        }];
3038        let state = UiState::from_history(&history, "provider-secret", "model", None, false);
3039        let text = transcript_lines(&state, 80)
3040            .iter()
3041            .map(ToString::to_string)
3042            .collect::<Vec<_>>()
3043            .join("\n");
3044        assert!(!text.contains("provider-secret"));
3045    }
3046
3047    #[test]
3048    fn mouse_wheel_disables_following_and_changes_scroll_offset() {
3049        let history = [SessionHistoryRecord::Message {
3050            timestamp: 1,
3051            message: ChatMessage::user("hello".to_owned()),
3052        }];
3053        let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
3054        handle_mouse_event(&mut state, MouseEventKind::ScrollUp, 10);
3055        assert!(!state.auto_scroll);
3056        assert_eq!(state.scroll, 7);
3057        handle_mouse_event(&mut state, MouseEventKind::ScrollDown, 10);
3058        assert!(
3059            state.auto_scroll,
3060            "reaching the bottom resumes transcript following"
3061        );
3062        assert_eq!(state.scroll, 0);
3063        scroll_up(&mut state, 10);
3064        assert!(!state.auto_scroll);
3065        assert_eq!(state.scroll, 7);
3066    }
3067
3068    #[test]
3069    fn wrap_text_breaks_long_lines_and_preserves_empty_lines() {
3070        let rows = wrap_text("12345\n\nabc", 3);
3071        assert_eq!(rows, vec!["123", "45", "", "abc"]);
3072    }
3073
3074    #[test]
3075    fn wrap_line_never_returns_an_empty_vec() {
3076        assert_eq!(wrap_line("", 5), vec![""]);
3077        assert_eq!(wrap_line("abc", 5), vec!["abc"]);
3078    }
3079
3080    #[test]
3081    fn completion_event_does_not_release_input_before_worker_finishes() {
3082        let history = [SessionHistoryRecord::Message {
3083            timestamp: 1,
3084            message: ChatMessage::user("hello".to_owned()),
3085        }];
3086        let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
3087        state.busy = true;
3088        state.active_cancel = Some(CancellationToken::new());
3089        state.apply_event(ProtocolEvent::TurnEnd);
3090        assert!(state.busy);
3091        assert!(state.active_cancel.is_some());
3092        assert_eq!(state.status, "finalizing");
3093    }
3094
3095    #[test]
3096    fn transcript_inserts_a_blank_line_between_items() {
3097        let history = [
3098            SessionHistoryRecord::Message {
3099                timestamp: 1,
3100                message: ChatMessage::user("hi".to_owned()),
3101            },
3102            SessionHistoryRecord::Message {
3103                timestamp: 2,
3104                message: ChatMessage::assistant("hello".to_owned(), Vec::new()),
3105            },
3106        ];
3107        let state = UiState::from_history(&history, "secret", "model", None, false);
3108        let lines = transcript_lines(&state, 80);
3109        assert_eq!(lines.len(), 5);
3110        assert_eq!(lines[0].to_string(), format!("╭{}╮", "─".repeat(78)));
3111        assert_eq!(lines[1].to_string(), format!("│hi{}│", " ".repeat(76)));
3112        assert_eq!(lines[2].to_string(), format!("╰{}╯", "─".repeat(78)));
3113        assert_eq!(lines[3].to_string(), "");
3114        assert_eq!(lines[4].to_string(), "hello");
3115    }
3116
3117    #[test]
3118    fn cmd_call_renders_as_a_compact_status_line_without_raw_json() {
3119        let history = vec![
3120            SessionHistoryRecord::Message {
3121                timestamp: 1,
3122                message: ChatMessage::assistant(
3123                    String::new(),
3124                    vec![crate::model::ChatToolCall {
3125                        id: "call-1".to_owned(),
3126                        name: "cmd".to_owned(),
3127                        arguments: r#"{"command":"pwd"}"#.to_owned(),
3128                    }],
3129                ),
3130            },
3131            SessionHistoryRecord::Message {
3132                timestamp: 2,
3133                message: ChatMessage::tool(
3134                    "call-1".to_owned(),
3135                    "cmd".to_owned(),
3136                    serde_json::json!({"exit_code": 0, "stdout": "secret output"}).to_string(),
3137                ),
3138            },
3139        ];
3140        let state = UiState::from_history(&history, "secret", "model", None, false);
3141        let text = transcript_lines(&state, 80)[0].to_string();
3142
3143        assert_eq!(text, "✓ cmd  $ pwd  → exit 0");
3144        assert!(!text.contains("secret output"));
3145        assert!(!text.contains("{\"command\":\"pwd\"}"));
3146    }
3147
3148    #[test]
3149    fn pending_cmd_calls_use_a_compact_running_status() {
3150        let history = [SessionHistoryRecord::Message {
3151            timestamp: 1,
3152            message: ChatMessage::assistant(
3153                String::new(),
3154                vec![crate::model::ChatToolCall {
3155                    id: "call-1".to_owned(),
3156                    name: "cmd".to_owned(),
3157                    arguments: r#"{"command":"pwd"}"#.to_owned(),
3158                }],
3159            ),
3160        }];
3161        let state = UiState::from_history(&history, "secret", "model", None, false);
3162        let line = &transcript_lines(&state, 80)[0];
3163
3164        assert_eq!(line.to_string(), "· cmd  $ pwd  → running");
3165        assert!(line
3166            .spans
3167            .iter()
3168            .all(|span| span.style.fg == Some(PENDING_TOOL_COLOR)));
3169    }
3170
3171    #[test]
3172    fn cmd_status_distinguishes_nonzero_exit_timeout_and_cancellation() {
3173        let cases = [
3174            (
3175                serde_json::json!({"exit_code": 127}),
3176                "× cmd  $ bad  → exit 127",
3177            ),
3178            (
3179                serde_json::json!({"timed_out": true, "exit_code": null}),
3180                "! cmd  $ slow  → timeout",
3181            ),
3182            (
3183                serde_json::json!({"canceled": true}),
3184                "! cmd  $ stop  → canceled",
3185            ),
3186        ];
3187        for (result, expected) in cases {
3188            let history = vec![
3189                SessionHistoryRecord::Message {
3190                    timestamp: 1,
3191                    message: ChatMessage::assistant(
3192                        String::new(),
3193                        vec![crate::model::ChatToolCall {
3194                            id: "call-1".to_owned(),
3195                            name: "cmd".to_owned(),
3196                            arguments: serde_json::json!({"command": expected.split("$ ").nth(1).unwrap().split("  ").next().unwrap()}).to_string(),
3197                        }],
3198                    ),
3199                },
3200                SessionHistoryRecord::Message {
3201                    timestamp: 2,
3202                    message: ChatMessage::tool(
3203                        "call-1".to_owned(),
3204                        "cmd".to_owned(),
3205                        result.to_string(),
3206                    ),
3207                },
3208            ];
3209            let state = UiState::from_history(&history, "secret", "model", None, false);
3210            assert_eq!(transcript_lines(&state, 80)[0].to_string(), expected);
3211        }
3212    }
3213
3214    #[test]
3215    fn cmd_line_truncates_long_commands_but_never_renders_output() {
3216        let command = "a".repeat(80);
3217        let arguments = serde_json::json!({"command": command}).to_string();
3218        let history = vec![
3219            SessionHistoryRecord::Message {
3220                timestamp: 1,
3221                message: ChatMessage::assistant(
3222                    String::new(),
3223                    vec![crate::model::ChatToolCall {
3224                        id: "call-1".to_owned(),
3225                        name: "cmd".to_owned(),
3226                        arguments,
3227                    }],
3228                ),
3229            },
3230            SessionHistoryRecord::Message {
3231                timestamp: 2,
3232                message: ChatMessage::tool(
3233                    "call-1".to_owned(),
3234                    "cmd".to_owned(),
3235                    serde_json::json!({"exit_code": 0, "stdout": "output"}).to_string(),
3236                ),
3237            },
3238        ];
3239        let state = UiState::from_history(&history, "secret", "model", None, false);
3240        let text = transcript_lines(&state, 200)[0].to_string();
3241        assert!(text.contains(&format!("$ {}…", "a".repeat(50))));
3242        assert!(!text.contains(&"a".repeat(51)));
3243        assert!(!text.contains("output"));
3244    }
3245
3246    #[test]
3247    fn cmd_lines_remain_compact_for_consecutive_calls() {
3248        let history = vec![
3249            SessionHistoryRecord::Message {
3250                timestamp: 1,
3251                message: ChatMessage::assistant(
3252                    String::new(),
3253                    vec![
3254                        crate::model::ChatToolCall {
3255                            id: "call-first".to_owned(),
3256                            name: "cmd".to_owned(),
3257                            arguments: r#"{"command":"first"}"#.to_owned(),
3258                        },
3259                        crate::model::ChatToolCall {
3260                            id: "call-second".to_owned(),
3261                            name: "cmd".to_owned(),
3262                            arguments: r#"{"command":"second"}"#.to_owned(),
3263                        },
3264                    ],
3265                ),
3266            },
3267            SessionHistoryRecord::Message {
3268                timestamp: 2,
3269                message: ChatMessage::tool(
3270                    "call-first".to_owned(),
3271                    "cmd".to_owned(),
3272                    serde_json::json!({"exit_code": 0}).to_string(),
3273                ),
3274            },
3275            SessionHistoryRecord::Message {
3276                timestamp: 3,
3277                message: ChatMessage::tool(
3278                    "call-second".to_owned(),
3279                    "cmd".to_owned(),
3280                    serde_json::json!({"exit_code": 0}).to_string(),
3281                ),
3282            },
3283        ];
3284        let state = UiState::from_history(&history, "secret", "model", None, false);
3285        let lines = transcript_lines(&state, 200);
3286        assert_eq!(lines[0].to_string(), "✓ cmd  $ first  → exit 0");
3287        assert_eq!(lines[2].to_string(), "✓ cmd  $ second  → exit 0");
3288    }
3289
3290    #[test]
3291    fn cmd_status_styles_use_success_failure_and_pending_colors() {
3292        assert_eq!(
3293            cmd_result_status(&serde_json::json!({"exit_code": 0})).2.fg,
3294            Some(Color::Green)
3295        );
3296        assert_eq!(
3297            cmd_result_status(&serde_json::json!({"exit_code": 1})).2.fg,
3298            Some(Color::Red)
3299        );
3300        assert_eq!(
3301            cmd_tool_segments(
3302                "{\"command\":\"pwd\"}",
3303                None,
3304                &UiState::from_history(&[], "secret", "model", None, false)
3305            )[0]
3306            .1
3307            .fg,
3308            Some(PENDING_TOOL_COLOR)
3309        );
3310    }
3311
3312    #[test]
3313    fn recognized_skill_trigger_is_highlighted_but_arguments_remain_default_colored() {
3314        let trigger = active_skill_trigger("/release-notes v1.2.0", &["release-notes".to_owned()]);
3315        assert_eq!(trigger, Some("/release-notes"));
3316
3317        let lines = styled_text_lines(
3318            "/release-notes v1.2.0",
3319            trigger,
3320            80,
3321            Style::default().fg(Color::White),
3322        );
3323        assert_eq!(lines.len(), 1);
3324        assert_eq!(lines[0].to_string(), "/release-notes v1.2.0");
3325        assert_eq!(lines[0].spans[0].content, "/release-notes");
3326        assert_eq!(lines[0].spans[0].style.fg, Some(Color::Cyan));
3327        assert_eq!(lines[0].spans[1].content, " v1.2.0");
3328        assert_eq!(lines[0].spans[1].style.fg, Some(Color::White));
3329    }
3330
3331    #[test]
3332    fn draw_renders_an_active_skill_trigger_in_cyan() {
3333        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3334            .with_skill_names(vec!["release-notes".to_owned()]);
3335        state.input = "/release-notes v1.2.0".to_owned();
3336        state.cursor = state.input.chars().count();
3337
3338        let mut terminal =
3339            Terminal::new(ratatui::backend::TestBackend::new(40, 10)).expect("test terminal");
3340        terminal
3341            .draw(|frame| draw(frame, &state))
3342            .expect("draw input");
3343
3344        // The one-row input area starts at (1, 7): trigger characters are cyan,
3345        // while the argument that follows keeps the normal white input color.
3346        let buffer = terminal.backend().buffer();
3347        assert_eq!(buffer[(1, 7)].fg, Color::Cyan);
3348        assert_eq!(buffer[(21, 7)].fg, Color::White);
3349    }
3350
3351    #[test]
3352    fn busy_input_keeps_normal_style_and_a_blinking_input_cursor() {
3353        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3354        state.busy = true;
3355        state.cursor_epoch = Instant::now();
3356
3357        let mut terminal =
3358            Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
3359        terminal
3360            .draw(|frame| draw(frame, &state))
3361            .expect("draw waiting input");
3362
3363        let buffer = terminal.backend().buffer();
3364        assert_eq!(buffer[(0, 6)].fg, USER_BORDER_COLOR);
3365        assert_eq!(buffer[(1, 7)].symbol(), " ");
3366        assert_eq!(input_display_text(&state), "");
3367        terminal.backend_mut().assert_cursor_position((1, 7));
3368        state.input = "queued message".to_owned();
3369        assert_eq!(input_display_text(&state), "queued message");
3370    }
3371
3372    #[test]
3373    fn only_known_leading_skill_commands_activate_input_highlighting() {
3374        let skills = ["release-notes".to_owned()];
3375        assert_eq!(
3376            active_skill_trigger("/missing", &skills),
3377            None,
3378            "unknown commands are rejected by the turn engine and must not look active"
3379        );
3380        assert_eq!(
3381            active_skill_trigger("/skill:release-notes", &skills),
3382            None,
3383            "the removed /skill: wrapper must not look active"
3384        );
3385        assert_eq!(
3386            active_skill_trigger("write /release-notes", &skills),
3387            None,
3388            "only the command prefix accepted by the turn engine is active"
3389        );
3390        assert_eq!(active_skill_trigger("/", &skills), None);
3391    }
3392
3393    #[test]
3394    fn highlighted_skill_trigger_remains_styled_when_wrapped() {
3395        let input = "/release-notes argument";
3396        let trigger = active_skill_trigger(input, &["release-notes".to_owned()]);
3397        let lines = styled_text_lines(input, trigger, 8, Style::default().fg(Color::White));
3398        let highlighted = lines
3399            .iter()
3400            .flat_map(|line| line.spans.iter())
3401            .filter(|span| span.style.fg == Some(Color::Cyan))
3402            .map(|span| span.content.as_ref())
3403            .collect::<String>();
3404        assert_eq!(highlighted, "/release-notes");
3405    }
3406
3407    #[test]
3408    fn input_has_no_prompt_marker_and_trailing_newline_is_visible() {
3409        assert_eq!(input_prompt("hello"), "hello");
3410        assert_eq!(wrap_text("hello\n", 80), vec!["hello", ""]);
3411    }
3412
3413    #[test]
3414    fn input_prompt_wraps_to_multiple_rows_when_long() {
3415        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3416        state.input = "abcdefghij".to_owned();
3417        // width 5: the input wraps across multiple rows without a prompt marker.
3418        let rows = input_visible_rows(&state, 5);
3419        assert!(rows >= 2);
3420    }
3421
3422    #[test]
3423    fn cursor_editing_moves_by_characters_and_preserves_unicode() {
3424        let mut input = "가나".to_owned();
3425        let mut cursor = input.chars().count();
3426        cursor -= 1;
3427        insert_at_cursor(&mut input, &mut cursor, 'x');
3428        assert_eq!(input, "가x나");
3429        assert_eq!(cursor, 2);
3430        assert!(remove_before_cursor(&mut input, &mut cursor));
3431        assert_eq!(input, "가나");
3432        assert_eq!(cursor, 1);
3433    }
3434
3435    #[test]
3436    fn cursor_row_tracks_newlines_and_wrapping() {
3437        assert_eq!(cursor_row("hello\nworld", 6, 80), 1);
3438        assert_eq!(cursor_row("abcdef", 4, 3), 1);
3439    }
3440
3441    #[test]
3442    fn shift_enter_inserts_at_the_cursor_and_moves_it_to_the_new_row() {
3443        let mut input = "beforeafter".to_owned();
3444        let mut cursor = 6;
3445        insert_at_cursor(&mut input, &mut cursor, '\n');
3446
3447        assert_eq!(input, "before\nafter");
3448        assert_eq!(cursor, 7);
3449        assert_eq!(cursor_row(&input, cursor, 80), 1);
3450    }
3451
3452    #[test]
3453    fn shift_enter_renders_the_cursor_on_the_new_input_row() {
3454        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3455        state.input = "beforeafter".to_owned();
3456        state.cursor = 6;
3457        insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
3458        state.cursor_epoch = Instant::now();
3459
3460        let mut terminal =
3461            Terminal::new(ratatui::backend::TestBackend::new(20, 10)).expect("test terminal");
3462        terminal
3463            .draw(|frame| draw(frame, &state))
3464            .expect("draw input cursor");
3465
3466        // The input box starts at row 5; its inner area begins at (1, 6).
3467        // After inserting a newline, the cursor is at the start of row 7.
3468        terminal.backend_mut().assert_cursor_position((1, 7));
3469    }
3470
3471    #[test]
3472    fn tool_results_attach_to_their_matching_call_after_consecutive_calls() {
3473        let history = vec![
3474            SessionHistoryRecord::Message {
3475                timestamp: 1,
3476                message: ChatMessage::assistant(
3477                    String::new(),
3478                    vec![
3479                        crate::model::ChatToolCall {
3480                            id: "call-first".to_owned(),
3481                            name: "cmd".to_owned(),
3482                            arguments: r#"{"command":"first"}"#.to_owned(),
3483                        },
3484                        crate::model::ChatToolCall {
3485                            id: "call-second".to_owned(),
3486                            name: "cmd".to_owned(),
3487                            arguments: r#"{"command":"second"}"#.to_owned(),
3488                        },
3489                    ],
3490                ),
3491            },
3492            SessionHistoryRecord::Message {
3493                timestamp: 2,
3494                message: ChatMessage::tool(
3495                    "call-first".to_owned(),
3496                    "cmd".to_owned(),
3497                    serde_json::json!({"stdout":"first result","stderr":""}).to_string(),
3498                ),
3499            },
3500            SessionHistoryRecord::Message {
3501                timestamp: 3,
3502                message: ChatMessage::tool(
3503                    "call-second".to_owned(),
3504                    "cmd".to_owned(),
3505                    serde_json::json!({"stdout":"second result","stderr":""}).to_string(),
3506                ),
3507            },
3508        ];
3509
3510        let state = UiState::from_history(&history, "secret", "model", None, false);
3511        let lines = transcript_lines(&state, 200);
3512        assert_eq!(
3513            lines.len(),
3514            3,
3515            "only the two call lines and their separator remain"
3516        );
3517        assert_eq!(lines[0].to_string(), "✓ cmd  $ first  → done");
3518        assert_eq!(lines[2].to_string(), "✓ cmd  $ second  → done");
3519    }
3520    #[test]
3521    fn subagent_tasks_keep_metadata_and_move_from_queued_to_done() {
3522        let history = vec![
3523            SessionHistoryRecord::Message {
3524                timestamp: 1,
3525                message: ChatMessage::assistant(
3526                    String::new(),
3527                    vec![crate::model::ChatToolCall {
3528                        id: "call-worker".to_owned(),
3529                        name: "spawn_subagent".to_owned(),
3530                        arguments: serde_json::json!({
3531                            "task": "Inspect the command UI",
3532                            "model": "worker-model",
3533                            "effort": "high"
3534                        })
3535                        .to_string(),
3536                    }],
3537                ),
3538            },
3539            SessionHistoryRecord::Message {
3540                timestamp: 2,
3541                message: ChatMessage::tool(
3542                    "call-worker".to_owned(),
3543                    "spawn_subagent".to_owned(),
3544                    serde_json::json!({"task_id":"subagent-1","status":"queued"}).to_string(),
3545                ),
3546            },
3547        ];
3548        let mut state = UiState::from_history(&history, "secret", "model", None, false);
3549        assert_eq!(state.subagents.len(), 1);
3550        let task = &state.subagents[0];
3551        assert_eq!(task.task, "Inspect the command UI");
3552        assert_eq!(task.task_id.as_deref(), Some("subagent-1"));
3553        assert_eq!(task.model.as_deref(), Some("worker-model"));
3554        assert_eq!(task.effort.as_deref(), Some("high"));
3555        assert_eq!(task.status, SubagentStatus::Running);
3556        assert!(transcript_lines(&state, 80)[0]
3557            .to_string()
3558            .contains("↗ subagent  Inspect the command UI  → running"));
3559
3560        state.complete_subagent(
3561            "subagent-1",
3562            serde_json::json!({"model":"worker-model","output":"finished"}),
3563        );
3564        assert_eq!(state.subagents[0].status, SubagentStatus::Completed);
3565        assert_eq!(
3566            subagent_result_preview(state.subagents[0].result.as_ref().unwrap(), "secret"),
3567            "finished"
3568        );
3569    }
3570
3571    #[test]
3572    fn resumed_subagent_completion_updates_the_panel_without_rendering_internal_prompt() {
3573        let history = vec![
3574            SessionHistoryRecord::Message {
3575                timestamp: 1,
3576                message: ChatMessage::assistant(
3577                    String::new(),
3578                    vec![crate::model::ChatToolCall {
3579                        id: "call-worker".to_owned(),
3580                        name: "spawn_subagent".to_owned(),
3581                        arguments: serde_json::json!({"task":"Inspect"}).to_string(),
3582                    }],
3583                ),
3584            },
3585            SessionHistoryRecord::Message {
3586                timestamp: 2,
3587                message: ChatMessage::tool(
3588                    "call-worker".to_owned(),
3589                    "spawn_subagent".to_owned(),
3590                    serde_json::json!({"task_id":"subagent-1","status":"queued"}).to_string(),
3591                ),
3592            },
3593            SessionHistoryRecord::Message {
3594                timestamp: 3,
3595                message: ChatMessage::user(Harness::subagent_notification(
3596                    &crate::app::SubagentCompletion {
3597                        task_id: "subagent-1".to_owned(),
3598                        result: serde_json::json!({"output":"resumed result"}),
3599                    },
3600                )),
3601            },
3602        ];
3603        let state = UiState::from_history(&history, "secret", "model", None, true);
3604        assert_eq!(state.subagents[0].status, SubagentStatus::Completed);
3605        assert_eq!(
3606            state.subagents[0].result.as_ref().unwrap()["output"],
3607            "resumed result"
3608        );
3609        assert!(!state.transcript.iter().any(|item| {
3610            matches!(item, TranscriptItem::User { text, .. } if text.contains("Background subagent"))
3611        }));
3612    }
3613
3614    #[test]
3615    fn subagent_panel_uses_right_column_only_on_wide_terminals() {
3616        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3617        state.subagents.push(SubagentTask {
3618            call_id: "call-worker".to_owned(),
3619            task_id: Some("subagent-1".to_owned()),
3620            task: "Inspect the command UI".to_owned(),
3621            model: None,
3622            effort: None,
3623            status: SubagentStatus::Queued,
3624            result: None,
3625        });
3626        let wide = Rect::new(0, 0, 120, 20);
3627        let narrow = Rect::new(0, 0, 80, 20);
3628        let panel = subagent_panel_area(&state, wide).expect("wide panel");
3629        assert_eq!(panel.width, SUBAGENT_PANEL_WIDTH);
3630        assert_eq!(panel.x, 86);
3631        assert_eq!(content_area(&state, wide).width, 85);
3632        assert!(subagent_panel_area(&state, narrow).is_none());
3633        assert_eq!(content_area(&state, narrow), narrow);
3634    }
3635
3636    #[test]
3637    fn subagent_panel_renders_status_task_identity_and_result_preview() {
3638        let mut state = UiState::from_history(&[], "provider-secret", "model", None, false);
3639        state.subagents.push(SubagentTask {
3640            call_id: "call-worker".to_owned(),
3641            task_id: Some("subagent-1".to_owned()),
3642            task: "Inspect the command UI".to_owned(),
3643            model: Some("worker-model".to_owned()),
3644            effort: Some("high".to_owned()),
3645            status: SubagentStatus::Completed,
3646            result: Some(serde_json::json!({"output":"worker finished"})),
3647        });
3648        let mut terminal =
3649            Terminal::new(ratatui::backend::TestBackend::new(120, 20)).expect("test terminal");
3650        terminal
3651            .draw(|frame| draw(frame, &state))
3652            .expect("draw panel");
3653        let screen = terminal
3654            .backend()
3655            .buffer()
3656            .content()
3657            .iter()
3658            .map(|cell| cell.symbol())
3659            .collect::<String>();
3660        assert!(screen.contains("workers 1"));
3661        assert!(screen.contains("completed"));
3662        assert!(screen.contains("Inspect the command UI"));
3663        assert!(screen.contains("subagent-1"));
3664        assert!(screen.contains("worker-model · high"));
3665        assert!(screen.contains("worker finished"));
3666        assert!(!screen.contains("provider-secret"));
3667    }
3668}
3669
3670#[cfg(test)]
3671mod skill_picker_tests {
3672    use super::*;
3673
3674    fn skill_names() -> Vec<String> {
3675        ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
3676            .into_iter()
3677            .map(str::to_owned)
3678            .collect()
3679    }
3680
3681    #[test]
3682    fn built_in_commands_share_the_slash_catalog_without_becoming_skills() {
3683        assert_eq!(
3684            command_names(vec!["release-notes".to_owned(), "settings".to_owned()]),
3685            vec!["exit", "release-notes", "settings"]
3686        );
3687        assert_eq!(
3688            builtin_command("/settings ignored arguments"),
3689            Some(BuiltinCommand::Settings)
3690        );
3691        assert_eq!(builtin_command("  /exit  "), Some(BuiltinCommand::Exit));
3692        assert_eq!(builtin_command("/settings-extra"), None);
3693    }
3694
3695    #[test]
3696    fn settings_viewport_follows_focus_instead_of_truncating_the_catalog_head() {
3697        assert_eq!(selection_range(30, 0, 12), 0..12);
3698        assert_eq!(selection_range(30, 11, 12), 0..12);
3699        assert_eq!(selection_range(30, 12, 12), 1..13);
3700        assert_eq!(selection_range(30, 29, 12), 18..30);
3701    }
3702
3703    #[test]
3704    fn model_selection_uses_advertised_efforts_and_preserves_the_current_choice() {
3705        let mut state = UiState::from_history(&[], "secret", "old", Some("medium"), false);
3706        state.open_catalog(Ok(vec![ProviderModel {
3707            id: "openai/gpt-5.6-sol".to_owned(),
3708            efforts: Some(vec![
3709                "max".to_owned(),
3710                "high".to_owned(),
3711                "medium".to_owned(),
3712                "low".to_owned(),
3713            ]),
3714        }]));
3715        state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
3716
3717        let SettingsState::Effort { model, focus, .. } =
3718            state.settings.as_ref().expect("effort picker")
3719        else {
3720            panic!("model selection should open the effort picker");
3721        };
3722        assert_eq!(model.id, "openai/gpt-5.6-sol");
3723        assert_eq!(*focus, 3, "default occupies index zero before medium");
3724
3725        let selected = state
3726            .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3727            .expect("effort selection");
3728        assert_eq!(
3729            selected,
3730            ("openai/gpt-5.6-sol".to_owned(), Some("medium".to_owned()))
3731        );
3732    }
3733
3734    #[test]
3735    fn effort_default_selection_does_not_shift_to_the_first_advertised_effort() {
3736        let mut state = UiState::from_history(&[], "secret", "old", None, false);
3737        state.open_catalog(Ok(vec![ProviderModel {
3738            id: "model".to_owned(),
3739            efforts: Some(vec!["high".to_owned(), "low".to_owned()]),
3740        }]));
3741        state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
3742
3743        let selected = state
3744            .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3745            .expect("default effort selection");
3746        assert_eq!(selected, ("model".to_owned(), None));
3747    }
3748
3749    #[test]
3750    fn reasoning_indicator_changes_to_complete_and_stays_dark_gray() {
3751        let mut state = UiState::from_history(&[], "secret", "model", None, false);
3752        state.show_thinking();
3753
3754        let active_lines = transcript_lines(&state, 80);
3755        let active = active_lines.last().expect("reasoning line");
3756        assert!(active.to_string().starts_with("Reasoning... "));
3757        assert_eq!(active.style.fg, Some(Color::DarkGray));
3758
3759        state.complete_reasoning();
3760        let complete_lines = transcript_lines(&state, 80);
3761        let complete = complete_lines.last().expect("complete line");
3762        assert_eq!(complete.to_string(), "Reasoning Complete");
3763        assert_eq!(complete.style.fg, Some(Color::DarkGray));
3764    }
3765
3766    #[test]
3767    fn slash_picker_filters_only_leading_command_text_and_hides_without_matches() {
3768        let names = skill_names();
3769        assert_eq!(
3770            matching_skill_names("/", &names),
3771            vec!["alpha", "beta", "build", "charlie", "deploy", "doctor"]
3772        );
3773        assert_eq!(matching_skill_names("/b", &names), vec!["beta", "build"]);
3774        assert!(matching_skill_names("/missing", &names).is_empty());
3775        assert!(matching_skill_names("message /b", &names).is_empty());
3776        assert!(matching_skill_names("/beta arguments", &names).is_empty());
3777    }
3778
3779    #[test]
3780    fn slash_picker_focuses_the_top_match_and_moves_within_filtered_results() {
3781        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3782            .with_skill_names(skill_names());
3783        state.input = "/b".to_owned();
3784        state.input_changed();
3785
3786        assert!(state.skill_picker_visible());
3787        assert_eq!(state.skill_picker_focus, 0);
3788        assert!(state.move_skill_picker(true));
3789        assert_eq!(state.skill_picker_focus, 1);
3790        assert!(state.move_skill_picker(true));
3791        assert_eq!(state.skill_picker_focus, 1, "focus does not leave the list");
3792        assert!(state.move_skill_picker(false));
3793        assert_eq!(state.skill_picker_focus, 0);
3794
3795        state.input = "/missing".to_owned();
3796        state.input_changed();
3797        assert!(!state.skill_picker_visible());
3798        assert!(!state.move_skill_picker(true));
3799    }
3800
3801    #[test]
3802    fn focused_builtins_are_distinguished_from_skills() {
3803        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3804            .with_skill_names(command_names(skill_names()));
3805        state.input = "/se".to_owned();
3806        state.input_changed();
3807        assert_eq!(
3808            state.focused_builtin_command(),
3809            Some(BuiltinCommand::Settings)
3810        );
3811
3812        state.input = "/be".to_owned();
3813        state.input_changed();
3814        assert_eq!(state.focused_builtin_command(), None);
3815    }
3816
3817    #[test]
3818    fn selecting_the_focused_skill_leaves_the_completed_command_ready_to_send() {
3819        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3820            .with_skill_names(skill_names());
3821        state.input = "/b".to_owned();
3822        state.input_changed();
3823        state.move_skill_picker(true);
3824
3825        assert!(state.select_focused_skill());
3826        assert_eq!(state.input, "/build");
3827        assert_eq!(state.cursor, "/build".chars().count());
3828        assert!(
3829            !state.skill_picker_visible(),
3830            "the first Enter completes the input rather than sending it"
3831        );
3832        assert!(
3833            !state.select_focused_skill(),
3834            "a second Enter follows the normal send/attachment path"
3835        );
3836    }
3837
3838    #[test]
3839    fn slash_picker_overlays_without_reflowing_the_transcript_when_match_count_changes() {
3840        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3841            .with_skill_names(skill_names());
3842        let area = Rect::new(0, 0, 40, 16);
3843        state.transcript = (0..20)
3844            .map(|index| TranscriptItem::Assistant(format!("message {index}")))
3845            .collect();
3846
3847        state.input = "/a".to_owned();
3848        state.input_changed();
3849        let (narrow_chat, narrow_picker, narrow_input, _) = ui_layout(&state, area);
3850        let narrow_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
3851
3852        state.input = "/".to_owned();
3853        state.input_changed();
3854        let (broad_chat, broad_picker, broad_input, _) = ui_layout(&state, area);
3855        let broad_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
3856
3857        assert_ne!(
3858            narrow_picker, broad_picker,
3859            "the overlay may fit its contents"
3860        );
3861        assert_eq!(narrow_chat, broad_chat);
3862        assert_eq!(narrow_input, broad_input);
3863        assert_eq!(
3864            narrow_scroll, broad_scroll,
3865            "the overlay does not reduce the transcript viewport"
3866        );
3867    }
3868
3869    #[test]
3870    fn slash_picker_keeps_the_focused_item_in_its_five_row_viewport() {
3871        assert_eq!(skill_picker_range(20, 0), 0..5);
3872        assert_eq!(skill_picker_range(20, 4), 0..5);
3873        assert_eq!(skill_picker_range(20, 5), 1..6);
3874        assert_eq!(skill_picker_range(20, 19), 15..20);
3875    }
3876
3877    #[test]
3878    fn slash_picker_overlays_the_ready_activity_indicator() {
3879        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3880            .with_skill_names(vec!["a".to_owned(), "b".to_owned()]);
3881        state.input = "/".to_owned();
3882        state.input_changed();
3883        let mut terminal =
3884            Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
3885        terminal
3886            .draw(|frame| draw(frame, &state))
3887            .expect("draw TUI");
3888
3889        let screen = terminal
3890            .backend()
3891            .buffer()
3892            .content()
3893            .iter()
3894            .map(|cell| cell.symbol())
3895            .collect::<String>();
3896        assert!(screen.contains("/a"));
3897        assert!(
3898            !screen.contains("ready"),
3899            "the skill picker should cover the ready activity indicator"
3900        );
3901    }
3902
3903    #[test]
3904    fn slash_picker_is_rendered_immediately_above_the_input() {
3905        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3906            .with_skill_names(skill_names());
3907        state.input = "/".to_owned();
3908        state.input_changed();
3909        let mut terminal =
3910            Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
3911        terminal
3912            .draw(|frame| draw(frame, &state))
3913            .expect("draw TUI");
3914
3915        let buffer = terminal.backend().buffer();
3916        // The rounded picker is an overlay that ends directly before the input.
3917        assert_eq!(buffer[(0, 0)].symbol(), "╭");
3918        assert_eq!(buffer[(1, 1)].symbol(), "[");
3919        assert_eq!(buffer[(1, 2)].symbol(), "/");
3920        assert_eq!(buffer[(0, 8)].symbol(), "╭");
3921        assert_eq!(buffer[(0, 0)].fg, Color::Cyan);
3922    }
3923
3924    #[test]
3925    fn slash_picker_renders_count_and_distinct_focus_colors() {
3926        let mut state = UiState::from_history(&[], "secret", "model", None, false)
3927            .with_skill_names(skill_names());
3928        state.input = "/".to_owned();
3929        state.input_changed();
3930        let mut terminal =
3931            Terminal::new(ratatui::backend::TestBackend::new(30, 8)).expect("test terminal");
3932        terminal
3933            .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 8)))
3934            .expect("draw skill picker");
3935
3936        let buffer = terminal.backend().buffer();
3937        assert_eq!(buffer[(0, 0)].symbol(), "╭");
3938        assert_eq!(buffer[(0, 0)].fg, Color::Cyan);
3939        assert_eq!(buffer[(1, 1)].symbol(), "[");
3940        assert_eq!(buffer[(1, 1)].fg, Color::DarkGray);
3941        assert_eq!(buffer[(1, 2)].symbol(), "/");
3942        assert_eq!(buffer[(1, 2)].fg, Color::Cyan);
3943        assert_eq!(buffer[(1, 3)].symbol(), "/");
3944        assert_eq!(buffer[(1, 3)].fg, Color::DarkGray);
3945    }
3946}