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