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