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