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 && greeting_image_enabled() {
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
3311fn greeting_image_enabled() -> bool {
3312 std::env::var("LUCY_GREETING_IMAGE").as_deref() == Ok("true")
3313}
3314
3315#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3316struct WelcomeImageLayout {
3317 image_area: Rect,
3318 intro_area: Rect,
3319 image_size: Size,
3320}
3321
3322fn welcome_image_layout(area: Rect, intro_height: u16) -> Option<WelcomeImageLayout> {
3323 let available_height = area
3324 .height
3325 .saturating_sub(intro_height.saturating_add(WELCOME_IMAGE_GAP));
3326 let max_width = area.width.min(WELCOME_IMAGE_SIZE.width);
3327 let max_height = available_height.min(WELCOME_IMAGE_SIZE.height);
3328 let aspect_width = WELCOME_IMAGE_SIZE.width / WELCOME_IMAGE_SIZE.height;
3329 let image_height = max_height.min(max_width / aspect_width);
3330 let image_size = Size::new(image_height * aspect_width, image_height);
3331 if image_size.width < WELCOME_IMAGE_MIN_SIZE.width
3332 || image_size.height < WELCOME_IMAGE_MIN_SIZE.height
3333 {
3334 return None;
3335 }
3336
3337 let group_height = image_size.height + WELCOME_IMAGE_GAP + intro_height;
3338 let group_y = area.y + area.height.saturating_sub(group_height) / 2;
3339 Some(WelcomeImageLayout {
3340 image_area: Rect::new(
3341 area.x + (area.width - image_size.width) / 2,
3342 group_y,
3343 image_size.width,
3344 image_size.height,
3345 ),
3346 intro_area: Rect::new(
3347 area.x,
3348 group_y + image_size.height + WELCOME_IMAGE_GAP,
3349 area.width,
3350 intro_height,
3351 ),
3352 image_size,
3353 })
3354}
3355
3356type WelcomeImageCache = Mutex<HashMap<(u16, u16), Arc<Protocol>>>;
3357
3358fn welcome_image(size: Size) -> Arc<Protocol> {
3359 static IMAGES: OnceLock<WelcomeImageCache> = OnceLock::new();
3360 let images = IMAGES.get_or_init(|| Mutex::new(HashMap::new()));
3361 let mut images = images
3362 .lock()
3363 .expect("welcome image cache should not be poisoned");
3364 images
3365 .entry((size.width, size.height))
3366 .or_insert_with(|| {
3367 let image = image::load_from_memory(WELCOME_IMAGE_BYTES)
3368 .expect("embedded greeting PNG should decode");
3369 let image = dim_welcome_image(image);
3370 Arc::new(
3371 Picker::halfblocks()
3372 .new_protocol(image, size, Resize::Fit(None))
3373 .expect("embedded greeting PNG should convert to halfblocks"),
3374 )
3375 })
3376 .clone()
3377}
3378
3379fn dim_welcome_image(image: image::DynamicImage) -> image::DynamicImage {
3380 let mut image = image.to_rgba8();
3381 for pixel in image.pixels_mut() {
3382 for channel in pixel.0.iter_mut().take(3) {
3383 *channel = (u16::from(*channel) * WELCOME_IMAGE_BRIGHTNESS_PERCENT / 100) as u8;
3384 }
3385 }
3386 image::DynamicImage::ImageRgba8(image)
3387}
3388
3389fn welcome_line() -> Line<'static> {
3390 let character_count = WELCOME_MESSAGE.chars().count();
3391 let spans = WELCOME_MESSAGE
3392 .chars()
3393 .enumerate()
3394 .map(|(index, character)| {
3395 let progress = if character_count <= 1 {
3396 0.0
3397 } else {
3398 index as f32 / (character_count - 1) as f32
3399 };
3400 let color = Color::Rgb(
3401 interpolate_color(WELCOME_START_COLOR.0, WELCOME_END_COLOR.0, progress),
3402 interpolate_color(WELCOME_START_COLOR.1, WELCOME_END_COLOR.1, progress),
3403 interpolate_color(WELCOME_START_COLOR.2, WELCOME_END_COLOR.2, progress),
3404 );
3405 Span::styled(character.to_string(), Style::default().fg(color))
3406 })
3407 .collect::<Vec<_>>();
3408 Line::from(spans)
3409}
3410
3411fn interpolate_color(start: u8, end: u8, progress: f32) -> u8 {
3412 (start as f32 + (end as f32 - start as f32) * progress).round() as u8
3413}
3414
3415fn welcome_lines(attached_agents: &[String]) -> Vec<Line<'static>> {
3416 let mut lines = vec![
3417 welcome_line(),
3418 Line::styled(WELCOME_VERSION, Style::default().fg(Color::DarkGray)),
3419 Line::raw(""),
3420 Line::styled(WELCOME_TAGLINE, Style::default().fg(Color::DarkGray)),
3421 Line::raw(""),
3422 ];
3423
3424 if attached_agents.is_empty() {
3425 lines.push(Line::styled(
3426 "Attached AGENTS.md: none",
3427 Style::default().fg(Color::DarkGray),
3428 ));
3429 } else {
3430 lines.push(Line::styled(
3431 "Attached AGENTS.md:",
3432 Style::default().fg(Color::DarkGray),
3433 ));
3434 lines.extend(
3435 attached_agents.iter().map(|path| {
3436 Line::styled(format!("• {path}"), Style::default().fg(Color::DarkGray))
3437 }),
3438 );
3439 }
3440
3441 lines
3442}
3443
3444fn transcript_lines(state: &UiState, width: u16) -> Vec<Line<'static>> {
3445 render_transcript_items(&state.transcript, width.max(1) as usize, state, true)
3446}
3447
3448fn render_transcript_items(
3449 transcript: &[TranscriptItem],
3450 width: usize,
3451 state: &UiState,
3452 suppress_subagent_tools: bool,
3453) -> Vec<Line<'static>> {
3454 let mut lines = Vec::new();
3455 let mut rendered_item = false;
3456
3457 for (index, item) in transcript.iter().enumerate() {
3458 if is_result_attached_to_call(transcript, index) {
3461 continue;
3462 }
3463 if suppress_subagent_tools
3464 && matches!(
3465 item,
3466 TranscriptItem::ToolCall { name, .. } | TranscriptItem::ToolResult { name, .. }
3467 if is_subagent_tool(name)
3468 )
3469 {
3470 continue;
3471 }
3472 if rendered_item {
3473 lines.push(Line::raw(String::new()));
3474 }
3475 match item {
3476 TranscriptItem::User {
3477 text,
3478 skill_instruction_attached,
3479 } => {
3480 let text = redact_secret(text, Some(&state.secret));
3481 let trigger = skill_instruction_attached
3482 .then(|| active_skill_trigger(&text, &state.skill_names))
3483 .flatten();
3484 push_user_message_block(&mut lines, &text, trigger, width);
3485 }
3486 TranscriptItem::Assistant(text) => {
3487 let text = redact_secret(text, Some(&state.secret));
3488 push_wrapped(&mut lines, &text, width, Style::default());
3489 }
3490 TranscriptItem::ToolCall {
3491 id,
3492 name,
3493 arguments,
3494 } => {
3495 let result = matching_tool_result(transcript, index, id);
3496 if !suppress_subagent_tools || !is_subagent_tool(name) {
3497 let segments = if name == "cmd" {
3498 cmd_tool_segments(id, arguments, result, state)
3499 } else {
3500 generic_tool_segments(name, arguments, result, state)
3501 };
3502 push_spans_wrapped(&mut lines, &segments, width);
3503 }
3504 }
3505 TranscriptItem::ToolResult {
3506 id: _,
3507 name: _,
3508 result,
3509 } => {
3510 let result_text = format_tool_result(result);
3511 let result_text = redact_secret(&result_text, Some(&state.secret));
3512 push_spans_wrapped(&mut lines, &[(result_text, tool_result_style())], width);
3513 }
3514 TranscriptItem::Error(text) => {
3515 let text = redact_secret(text, Some(&state.secret));
3516 push_wrapped(&mut lines, &text, width, error_style());
3517 }
3518 TranscriptItem::Info(text) => {
3519 let text = redact_secret(text, Some(&state.secret));
3520 push_wrapped(&mut lines, &text, width, info_style());
3521 }
3522 TranscriptItem::SubagentLifecycle {
3523 completion_id,
3524 task_id,
3525 status,
3526 delivered,
3527 } => {
3528 let (marker, text, style) = if *delivered {
3529 (
3530 "✓",
3531 format!("✓ subagent {task_id} · result delivered ({completion_id})"),
3532 tool_result_style(),
3533 )
3534 } else {
3535 let marker = if status == "completed" { "·" } else { "!" };
3536 let style = if status == "completed" {
3537 info_style()
3538 } else {
3539 error_style()
3540 };
3541 (
3542 marker,
3543 format!("{marker} subagent {task_id} → {status} · result pending ({completion_id})"),
3544 style,
3545 )
3546 };
3547 let _ = marker;
3548 push_wrapped(&mut lines, &text, width, style);
3549 }
3550 TranscriptItem::Reasoning { complete } => {
3551 let text = if *complete {
3552 "Reasoning Complete".to_owned()
3553 } else {
3554 format!("Reasoning... {}", spinner_frame(state))
3555 };
3556 push_wrapped(&mut lines, &text, width, thinking_style());
3557 }
3558 }
3559 rendered_item = true;
3560 }
3561 if lines.is_empty() {
3562 lines.push(Line::raw(""));
3563 }
3564 lines
3565}
3566
3567fn running_tool_status(state: &UiState) -> String {
3570 tool_spinner_frame(state)
3571}
3572
3573fn cmd_tool_segments(
3574 call_id: &str,
3575 arguments: &str,
3576 result: Option<&Value>,
3577 state: &UiState,
3578) -> Vec<(String, Style)> {
3579 let command = redact_secret(&command_display(arguments), Some(&state.secret));
3580 if let Some(result) = result {
3581 let (icon, status, status_style) = cmd_result_status(result);
3582 if status == "done" || state.cmd_result_started_at.contains_key(call_id) {
3583 let text = if status == "done" {
3584 format!("{icon} cmd $ {command}")
3585 } else {
3586 format!("{icon} cmd $ {command} → {status}")
3587 };
3588 return cmd_result_segments(call_id, &text, cmd_result_target_color(result), state);
3589 }
3590 vec![
3591 (format!("{icon} cmd $ {command} → "), status_style),
3592 (status, status_style),
3593 ]
3594 } else {
3595 vec![
3596 (format!("· cmd $ {command} "), pending_tool_call_style()),
3597 (running_tool_status(state), pending_tool_call_style()),
3598 ]
3599 }
3600}
3601
3602fn cmd_result_segments(
3607 call_id: &str,
3608 text: &str,
3609 target: Color,
3610 state: &UiState,
3611) -> Vec<(String, Style)> {
3612 let now = Instant::now();
3613 let Some(started_at) = state.cmd_result_started_at.get(call_id).copied() else {
3614 return vec![(text.to_owned(), Style::default().fg(target))];
3615 };
3616 if now.saturating_duration_since(started_at) >= TOOL_RESULT_SWEEP_DURATION {
3617 return vec![(text.to_owned(), Style::default().fg(target))];
3618 }
3619
3620 let character_count = text.chars().count();
3621 text.chars()
3622 .enumerate()
3623 .map(|(index, character)| {
3624 (
3625 character.to_string(),
3626 Style::default().fg(cmd_result_color_at(
3627 started_at,
3628 now,
3629 index,
3630 character_count,
3631 target,
3632 )),
3633 )
3634 })
3635 .collect()
3636}
3637
3638fn cmd_result_color_at(
3639 started_at: Instant,
3640 now: Instant,
3641 character_index: usize,
3642 character_count: usize,
3643 target: Color,
3644) -> Color {
3645 let elapsed = now.saturating_duration_since(started_at);
3646 if elapsed >= TOOL_RESULT_SWEEP_DURATION {
3647 return target;
3648 }
3649
3650 let progress = elapsed.as_secs_f32() / TOOL_RESULT_SWEEP_DURATION.as_secs_f32();
3651 let character_position = if character_count <= 1 {
3652 0.0
3653 } else {
3654 character_index as f32 / (character_count - 1) as f32
3655 };
3656 let fade_start = character_position * (1.0 - TOOL_RESULT_CHARACTER_FADE_PORTION);
3657 let character_progress =
3658 ((progress - fade_start) / TOOL_RESULT_CHARACTER_FADE_PORTION).clamp(0.0, 1.0);
3659 let character_progress =
3660 character_progress * character_progress * (3.0 - 2.0 * character_progress);
3661 let (target_red, target_green, target_blue) = tool_result_color_rgb(target);
3662 Color::Rgb(
3663 interpolate_color(PENDING_TOOL_COLOR_RGB.0, target_red, character_progress),
3664 interpolate_color(PENDING_TOOL_COLOR_RGB.1, target_green, character_progress),
3665 interpolate_color(PENDING_TOOL_COLOR_RGB.2, target_blue, character_progress),
3666 )
3667}
3668
3669fn command_display(arguments: &str) -> String {
3670 serde_json::from_str::<Value>(arguments)
3671 .ok()
3672 .and_then(|value| {
3673 value
3674 .get("command")
3675 .and_then(Value::as_str)
3676 .map(str::to_owned)
3677 })
3678 .map(|command| truncate_tool_call(&command))
3679 .unwrap_or_else(|| truncate_tool_call(arguments))
3680}
3681
3682fn cmd_result_target_color(result: &Value) -> Color {
3683 if result
3684 .get("canceled")
3685 .and_then(Value::as_bool)
3686 .unwrap_or(false)
3687 || result
3688 .get("timed_out")
3689 .and_then(Value::as_bool)
3690 .unwrap_or(false)
3691 {
3692 return TOOL_WARNING_COLOR;
3693 }
3694 if result.get("error").is_some()
3695 || matches!(result.get("exit_code").and_then(Value::as_i64), Some(code) if code != 0)
3696 {
3697 return TOOL_FAILURE_COLOR;
3698 }
3699 TOOL_SUCCESS_COLOR
3700}
3701
3702fn tool_result_color_rgb(color: Color) -> (u8, u8, u8) {
3703 let Color::Rgb(red, green, blue) = color else {
3704 unreachable!("cmd result transition colours are RGB")
3705 };
3706 (red, green, blue)
3707}
3708
3709fn cmd_result_status(result: &Value) -> (char, String, Style) {
3710 let target = cmd_result_target_color(result);
3711 if result
3712 .get("canceled")
3713 .and_then(Value::as_bool)
3714 .unwrap_or(false)
3715 {
3716 return ('!', "canceled".to_owned(), Style::default().fg(target));
3717 }
3718 if result
3719 .get("timed_out")
3720 .and_then(Value::as_bool)
3721 .unwrap_or(false)
3722 {
3723 return ('!', "timeout".to_owned(), Style::default().fg(target));
3724 }
3725 if result.get("error").is_some() {
3726 return ('×', "error".to_owned(), Style::default().fg(target));
3727 }
3728 match result.get("exit_code").and_then(Value::as_i64) {
3729 Some(0) => ('✓', "done".to_owned(), Style::default().fg(target)),
3730 Some(code) => ('×', format!("exit {code}"), Style::default().fg(target)),
3731 None => ('✓', "done".to_owned(), Style::default().fg(target)),
3732 }
3733}
3734
3735fn generic_tool_segments(
3736 name: &str,
3737 arguments: &str,
3738 result: Option<&Value>,
3739 state: &UiState,
3740) -> Vec<(String, Style)> {
3741 let call_text = redact_secret(
3742 &format!("[tool:{name} {}]", call_arguments(arguments)),
3743 Some(&state.secret),
3744 );
3745 let mut segments = vec![(
3746 call_text,
3747 if result.is_some() {
3748 tool_call_style()
3749 } else {
3750 pending_tool_call_style()
3751 },
3752 )];
3753 if let Some(result) = result {
3754 let result_text = redact_secret(&format_tool_result(result), Some(&state.secret));
3755 segments.push((" > ".to_owned(), Style::default()));
3756 segments.push((result_text, tool_result_style()));
3757 } else {
3758 segments.push((
3759 format!(" {}", tool_spinner_frame(state)),
3760 pending_tool_call_style(),
3761 ));
3762 }
3763 segments
3764}
3765
3766fn matching_tool_result<'a>(
3767 transcript: &'a [TranscriptItem],
3768 call_index: usize,
3769 call_id: &str,
3770) -> Option<&'a Value> {
3771 transcript
3772 .iter()
3773 .skip(call_index + 1)
3774 .find_map(|item| match item {
3775 TranscriptItem::ToolResult { id, result, .. } if id == call_id => Some(result),
3776 _ => None,
3777 })
3778}
3779
3780fn is_result_attached_to_call(transcript: &[TranscriptItem], result_index: usize) -> bool {
3781 let TranscriptItem::ToolResult { id, .. } = &transcript[result_index] else {
3782 return false;
3783 };
3784 let Some(call_index) = transcript[..result_index].iter().rposition(
3785 |item| matches!(item, TranscriptItem::ToolCall { id: call_id, .. } if call_id == id),
3786 ) else {
3787 return false;
3788 };
3789 !transcript[call_index + 1..result_index].iter().any(
3790 |item| matches!(item, TranscriptItem::ToolResult { id: result_id, .. } if result_id == id),
3791 )
3792}
3793
3794const TOOL_CALL_PREVIEW_CHARS: usize = 100;
3795
3796fn truncate_tool_call(output: &str) -> String {
3797 let mut result: String = output.chars().take(TOOL_CALL_PREVIEW_CHARS).collect();
3798 if output.chars().count() > TOOL_CALL_PREVIEW_CHARS {
3799 result.push('…');
3800 }
3801 result
3802}
3803
3804fn call_arguments(arguments: &str) -> String {
3808 let parsed: Value = match serde_json::from_str(arguments) {
3809 Ok(value) => value,
3810 Err(_) => return truncate_tool_call(arguments),
3811 };
3812 if let Some(command) = parsed.get("command").and_then(Value::as_str) {
3813 return format!("\"{}\"", truncate_tool_call(command));
3814 }
3815 let serialized = serde_json::to_string(&parsed).unwrap_or_else(|_| arguments.to_owned());
3816 truncate_tool_call(&serialized)
3817}
3818
3819fn format_tool_result(result: &Value) -> String {
3823 let stdout = result.get("stdout").and_then(Value::as_str).unwrap_or("");
3824 let stderr = result.get("stderr").and_then(Value::as_str).unwrap_or("");
3825 let output = if !stdout.is_empty() { stdout } else { stderr };
3826 let truncated = truncate_output(output);
3827 let json_string = serde_json::to_string(&truncated).unwrap_or_else(|_| "\"\"".to_owned());
3830 format!("[{json_string}]")
3831}
3832
3833const RESULT_PREVIEW_CHARS: usize = 50;
3834
3835fn truncate_output(output: &str) -> String {
3836 let mut result: String = output.chars().take(RESULT_PREVIEW_CHARS).collect();
3837 if output.chars().count() > RESULT_PREVIEW_CHARS {
3838 result.push('…');
3839 }
3840 result
3841}
3842
3843fn user_message_style() -> Style {
3844 Style::default().fg(USER_BORDER_COLOR)
3845}
3846
3847fn push_user_message_block(
3850 lines: &mut Vec<Line<'static>>,
3851 text: &str,
3852 active_skill_trigger: Option<&str>,
3853 width: usize,
3854) {
3855 if width < 3 {
3856 lines.extend(styled_text_lines(
3857 text,
3858 active_skill_trigger,
3859 width.max(1),
3860 Style::default().fg(Color::White),
3861 ));
3862 return;
3863 }
3864
3865 let border_style = user_message_style();
3866 let rows = styled_text_lines(
3867 text,
3868 active_skill_trigger,
3869 width - 2,
3870 Style::default().fg(Color::White),
3871 );
3872 lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
3873 for row in rows {
3874 let mut spans = Vec::with_capacity(row.spans.len() + 2);
3875 spans.push(Span::styled(USER_BORDER_GLYPH, border_style));
3876 spans.push(Span::styled(" ", Style::default().fg(Color::White)));
3877 spans.extend(row.spans);
3878 lines.push(Line::from(spans));
3879 }
3880 lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
3881}
3882
3883fn tool_call_style() -> Style {
3884 Style::default().fg(Color::Magenta)
3885}
3886
3887fn pending_tool_call_style() -> Style {
3888 Style::default().fg(PENDING_TOOL_COLOR)
3889}
3890
3891fn tool_result_style() -> Style {
3892 Style::default().fg(Color::DarkGray)
3893}
3894
3895fn error_style() -> Style {
3896 Style::default().fg(Color::Red)
3897}
3898
3899fn info_style() -> Style {
3900 Style::default().fg(Color::DarkGray)
3901}
3902
3903fn context_status_text(state: &UiState) -> String {
3904 let used = format_context_tokens(state.context_tokens);
3905 let Some(window) = state.context_window else {
3906 return format!("Context: {used}/? (?%) ??????????");
3907 };
3908 let percentage = context_percentage(state.context_tokens, window);
3909 format!(
3910 "Context: {used}/{} ({percentage}%) {}",
3911 format_context_tokens(window),
3912 context_progress_bar(state.context_tokens, window)
3913 )
3914}
3915
3916fn context_progress_bar(used: usize, window: usize) -> String {
3917 const WIDTH: usize = 10;
3918 let filled = if window == 0 {
3919 0
3920 } else {
3921 (used as u128 * WIDTH as u128)
3922 .div_ceil(window as u128)
3923 .min(WIDTH as u128) as usize
3924 };
3925 format!("{}{}", "█".repeat(filled), "░".repeat(WIDTH - filled))
3926}
3927
3928fn context_status_style(_state: &UiState) -> Style {
3929 Style::default().fg(CONSOLE_STATUS_COLOR)
3930}
3931
3932fn context_percentage(used: usize, window: usize) -> usize {
3933 if window == 0 {
3934 return 0;
3935 }
3936 ((used as u128 * 100).div_ceil(window as u128)) as usize
3937}
3938
3939fn format_context_tokens(tokens: usize) -> String {
3940 if tokens >= 1_000_000 {
3941 format!("{:.2}M", tokens as f64 / 1_000_000.0)
3942 } else if tokens >= 1_000 {
3943 format!("{:.1}K", tokens as f64 / 1_000.0)
3944 } else {
3945 tokens.to_string()
3946 }
3947}
3948
3949fn model_status_line(state: &UiState, effort: &str) -> Line<'static> {
3950 let model = redact_secret(&state.model, Some(&state.secret));
3951 let effort = redact_secret(effort, Some(&state.secret));
3952 Line::styled(
3953 format!("{model} · {effort} | {}", context_status_text(state)),
3954 context_status_style(state),
3955 )
3956}
3957
3958fn blend_rgb(from: Color, to: Color, progress: f32) -> Color {
3959 let (from_red, from_green, from_blue) = activity_rgb(from);
3960 let (to_red, to_green, to_blue) = activity_rgb(to);
3961 Color::Rgb(
3962 interpolate_color(from_red, to_red, progress),
3963 interpolate_color(from_green, to_green, progress),
3964 interpolate_color(from_blue, to_blue, progress),
3965 )
3966}
3967
3968fn activity_rgb(color: Color) -> (u8, u8, u8) {
3969 match color {
3970 Color::Rgb(red, green, blue) => (red, green, blue),
3971 Color::Cyan => (0, 255, 255),
3974 _ => unreachable!("activity transition colours are cyan or RGB"),
3975 }
3976}
3977
3978fn console_reach_at(elapsed: Duration) -> f32 {
3979 let phase = elapsed.as_secs_f32() / CONSOLE_BOUNDARY_CYCLE.as_secs_f32();
3980 let midpoint = (CONSOLE_REACH_MIN + CONSOLE_REACH_MAX) / 2.0;
3981 let amplitude = (CONSOLE_REACH_MAX - CONSOLE_REACH_MIN) / 2.0;
3982 midpoint + amplitude * (phase * std::f32::consts::TAU).sin()
3983}
3984
3985fn console_accent_cycle() -> Duration {
3986 CONSOLE_ACCENT_CYCLE_DURATION
3987}
3988
3989fn console_accent_at(elapsed: Duration) -> Color {
3990 let cycle_progress =
3991 (elapsed.as_secs_f32() / console_accent_cycle().as_secs_f32()).rem_euclid(1.0);
3992 let progress = if cycle_progress <= 0.5 {
3993 cycle_progress * 2.0
3994 } else {
3995 (1.0 - cycle_progress) * 2.0
3996 };
3997 desaturate_console_accent(
3998 interpolate_color(CONSOLE_ACCENT_LAVENDER.0, CONSOLE_ACCENT_TEAL.0, progress),
3999 interpolate_color(CONSOLE_ACCENT_LAVENDER.1, CONSOLE_ACCENT_TEAL.1, progress),
4000 interpolate_color(CONSOLE_ACCENT_LAVENDER.2, CONSOLE_ACCENT_TEAL.2, progress),
4001 )
4002}
4003
4004fn desaturate_console_accent(red: u8, green: u8, blue: u8) -> Color {
4005 let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
4006 Color::Rgb(
4007 interpolate_color(red, neutral, CONSOLE_ACCENT_DESATURATION),
4008 interpolate_color(green, neutral, CONSOLE_ACCENT_DESATURATION),
4009 interpolate_color(blue, neutral, CONSOLE_ACCENT_DESATURATION),
4010 )
4011}
4012
4013fn glow_coverage_at(column: u16, row: u16, canvas: Rect, console_area: Rect) -> f32 {
4020 let canvas_bottom = canvas.y.saturating_add(canvas.height);
4021 if canvas.is_empty() || row < canvas.y || row >= canvas_bottom {
4022 return 0.0;
4023 }
4024
4025 let left = console_area.x.max(canvas.x);
4026 let right = console_area
4027 .x
4028 .saturating_add(console_area.width)
4029 .min(canvas.x.saturating_add(canvas.width));
4030 if left >= right {
4031 return 0.0;
4032 }
4033
4034 let x = canvas.x.saturating_add(column);
4035 let center_x = (left as f32 + right.saturating_sub(1) as f32) / 2.0;
4036 let horizontal_radius = (right - left) as f32 / 2.0 + GLOW_HORIZONTAL_SPREAD as f32;
4037 let horizontal_distance = (x as f32 - center_x).abs() / horizontal_radius;
4038 let vertical_distance =
4041 (row.saturating_add(1) as f32 - canvas_bottom as f32).abs() / GLOW_HEIGHT as f32;
4042 let distance = horizontal_distance.hypot(vertical_distance);
4043 let falloff = (1.0 - distance).clamp(0.0, 1.0);
4044 falloff * falloff * (3.0 - 2.0 * falloff)
4045}
4046
4047fn glow_accent_at(elapsed: Duration) -> Color {
4048 glow_accent_with_desaturation_at(elapsed, GLOW_DESATURATION)
4049}
4050
4051fn glow_accent_with_desaturation_at(elapsed: Duration, desaturation: f32) -> Color {
4052 let (red, green, blue) = activity_rgb(console_accent_at(elapsed));
4053 let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
4054 Color::Rgb(
4055 interpolate_color(red, neutral, desaturation),
4056 interpolate_color(green, neutral, desaturation),
4057 interpolate_color(blue, neutral, desaturation),
4058 )
4059}
4060
4061fn glow_color_at(
4062 elapsed: Duration,
4063 column: u16,
4064 _width: u16,
4065 row: u16,
4066 canvas: Rect,
4067 console_area: Rect,
4068 visibility: f32,
4069) -> Option<Color> {
4070 let visibility = visibility.clamp(0.0, 1.0);
4071 let bottom = canvas.y.saturating_add(canvas.height);
4072 if visibility <= 0.0 || row < canvas.y || row >= bottom {
4073 return None;
4074 }
4075
4076 let coverage = glow_coverage_at(column, row, canvas, console_area);
4077 if coverage <= 0.0 {
4078 return None;
4079 }
4080
4081 let intensity =
4082 (console_reach_at(elapsed) / CONSOLE_REACH_MAX) * GLOW_INTENSITY * coverage * visibility;
4083 Some(blend_rgb(
4084 TUI_GLOW_BACKGROUND,
4085 glow_accent_at(elapsed),
4086 intensity,
4087 ))
4088}
4089
4090fn apply_tui_glow(
4091 frame: &mut Frame<'_>,
4092 canvas: Rect,
4093 console_area: Rect,
4094 elapsed: Duration,
4095 visibility: f32,
4096) {
4097 let left = console_area
4098 .x
4099 .saturating_sub(GLOW_HORIZONTAL_SPREAD)
4100 .max(canvas.x);
4101 let right = console_area
4102 .x
4103 .saturating_add(console_area.width)
4104 .saturating_add(GLOW_HORIZONTAL_SPREAD)
4105 .min(canvas.x.saturating_add(canvas.width));
4106 let bottom = canvas.y.saturating_add(canvas.height);
4107 let top = bottom.saturating_sub(GLOW_HEIGHT).max(canvas.y);
4108 let buffer = frame.buffer_mut();
4109 for y in top..bottom {
4110 for x in left..right {
4111 if let Some(color) = glow_color_at(
4112 elapsed,
4113 x.saturating_sub(canvas.x),
4114 canvas.width,
4115 y,
4116 canvas,
4117 console_area,
4118 visibility,
4119 ) {
4120 buffer[(x, y)].set_bg(color);
4121 }
4122 }
4123 }
4124}
4125
4126fn console_glass_color_at(elapsed: Duration, glow: Color, visibility: f32) -> Color {
4127 let (red, green, blue) = activity_rgb(console_accent_at(elapsed));
4128 let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
4129 let glass_accent = Color::Rgb(
4130 interpolate_color(red, neutral, CONSOLE_GLASS_DESATURATION),
4131 interpolate_color(green, neutral, CONSOLE_GLASS_DESATURATION),
4132 interpolate_color(blue, neutral, CONSOLE_GLASS_DESATURATION),
4133 );
4134 let visibility = visibility.clamp(0.0, 1.0);
4135 let tint = blend_rgb(
4136 CONSOLE_BACKGROUND,
4137 glass_accent,
4138 CONSOLE_GLASS_TINT * visibility,
4139 );
4140 let white_tinted = blend_rgb(
4141 tint,
4142 Color::Rgb(255, 255, 255),
4143 CONSOLE_GLASS_WHITE_TINT * visibility,
4144 );
4145 blend_rgb(white_tinted, glow, CONSOLE_GLASS_GLOW_THROUGH * visibility)
4146}
4147
4148fn console_top_reflection_color_at(elapsed: Duration, background: Color, visibility: f32) -> Color {
4150 let visibility = visibility.clamp(0.0, 1.0);
4151 let reflected = blend_rgb(
4152 background,
4153 glow_accent_at(elapsed),
4154 CONSOLE_REFLECTION_TINT * visibility,
4155 );
4156 blend_rgb(
4157 reflected,
4158 Color::Rgb(255, 255, 255),
4159 CONSOLE_REFLECTION_WHITE_TINT * visibility,
4160 )
4161}
4162
4163fn apply_console_top_reflection(
4167 frame: &mut Frame<'_>,
4168 area: Rect,
4169 elapsed: Duration,
4170 visibility: f32,
4171) {
4172 if visibility <= 0.0 || area.y == 0 {
4173 return;
4174 }
4175
4176 let y = area.y - 1;
4177 let buffer = frame.buffer_mut();
4178 for x in area.x..area.x.saturating_add(area.width) {
4179 let background = match buffer[(x, y)].bg {
4180 Color::Rgb(_, _, _) => buffer[(x, y)].bg,
4181 _ => TUI_GLOW_BACKGROUND,
4182 };
4183 buffer[(x, y)].set_symbol(CONSOLE_REFLECTION_GLYPH).set_fg(
4184 console_top_reflection_color_at(elapsed, background, visibility),
4185 );
4186 }
4187}
4188
4189fn apply_console_background(frame: &mut Frame<'_>, area: Rect, elapsed: Duration, visibility: f32) {
4191 let buffer = frame.buffer_mut();
4192 for y in area.y..area.y.saturating_add(area.height) {
4193 for x in area.x..area.x.saturating_add(area.width) {
4194 let glow = match buffer[(x, y)].bg {
4195 Color::Rgb(_, _, _) => buffer[(x, y)].bg,
4196 _ => TUI_GLOW_BACKGROUND,
4197 };
4198 buffer[(x, y)].set_bg(console_glass_color_at(elapsed, glow, visibility));
4199 }
4200 }
4201}
4202
4203fn thinking_style() -> Style {
4204 Style::default().fg(Color::DarkGray)
4205}
4206
4207const PULSE_LEVELS: [char; 7] = ['▁', '▂', '▃', '▅', '▆', '▇', '█'];
4210const PULSE_BAR_PERIODS: [u128; 5] = [12, 16, 20, 24, 15];
4211const PULSE_BAR_PHASES: [u128; 5] = [0, 5, 13, 9, 3];
4212const PULSE_TICK: Duration = Duration::from_millis(50);
4213const TOOL_SPINNER_FRAMES: [char; 4] = ['|', '/', '-', '\\'];
4214const TOOL_SPINNER_FRAME_DURATION: Duration = Duration::from_millis(100);
4215
4216const ACTIVITY_TRANSITION_DURATION: Duration = Duration::from_millis(400);
4220const PULSE_ENTRY_FRAME: Duration = Duration::from_millis(950);
4223
4224fn spinner_frame(state: &UiState) -> String {
4225 pulse_frame(state.activity_levels_at(Instant::now()))
4226}
4227
4228#[cfg(test)]
4229fn spinner_frame_at(elapsed: Duration) -> String {
4230 pulse_frame(pulse_levels_at(elapsed))
4231}
4232
4233fn tool_spinner_frame(state: &UiState) -> String {
4237 tool_spinner_frame_at(state.tool_animation_epoch.elapsed()).to_string()
4238}
4239
4240fn tool_spinner_frame_at(elapsed: Duration) -> char {
4241 let frame = (elapsed.as_millis() / TOOL_SPINNER_FRAME_DURATION.as_millis()) as usize;
4242 TOOL_SPINNER_FRAMES[frame % TOOL_SPINNER_FRAMES.len()]
4243}
4244
4245fn pulse_frame(levels: [usize; PULSE_BAR_PERIODS.len()]) -> String {
4246 levels
4247 .into_iter()
4248 .map(|level| PULSE_LEVELS[level])
4249 .collect()
4250}
4251
4252fn pulse_levels_at(elapsed: Duration) -> [usize; PULSE_BAR_PERIODS.len()] {
4253 let tick = elapsed.as_millis() / PULSE_TICK.as_millis();
4254 std::array::from_fn(|index| {
4255 pulse_level_at(tick, PULSE_BAR_PERIODS[index], PULSE_BAR_PHASES[index])
4256 })
4257}
4258
4259fn interpolate_pulse_levels(
4260 from: [usize; PULSE_BAR_PERIODS.len()],
4261 to: [usize; PULSE_BAR_PERIODS.len()],
4262 elapsed: Duration,
4263) -> [usize; PULSE_BAR_PERIODS.len()] {
4264 let elapsed = elapsed.min(ACTIVITY_TRANSITION_DURATION).as_millis();
4265 let duration = ACTIVITY_TRANSITION_DURATION.as_millis();
4266 std::array::from_fn(|index| {
4267 let start = from[index] as i128;
4268 let distance = to[index] as i128 - start;
4269 (start + distance * elapsed as i128 / duration as i128) as usize
4270 })
4271}
4272
4273fn pulse_level_at(tick: u128, period: u128, phase: u128) -> usize {
4274 let position = (tick + phase) % period;
4275 let half_period = period / 2;
4276 let distance_from_floor = if position <= half_period {
4277 position
4278 } else {
4279 period - position
4280 };
4281 (distance_from_floor * (PULSE_LEVELS.len() - 1) as u128 / half_period) as usize
4282}
4283
4284fn push_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, style: Style) {
4285 let mut added = false;
4286 for piece in wrap_text(text, width) {
4287 lines.push(Line::styled(piece, style));
4288 added = true;
4289 }
4290 if !added {
4291 lines.push(Line::styled(String::new(), style));
4292 }
4293}
4294
4295fn push_spans_wrapped(lines: &mut Vec<Line<'static>>, segments: &[(String, Style)], width: usize) {
4299 let mut current_spans: Vec<Span<'static>> = Vec::new();
4300 let mut current_width = 0usize;
4301 for (text, style) in segments {
4302 for character in text.chars() {
4303 let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
4304 if current_width + char_width > width && !current_spans.is_empty() {
4305 lines.push(Line::from(std::mem::take(&mut current_spans)));
4306 current_width = 0;
4307 }
4308 let mut buffer = [0u8; 4];
4309 let s = character.encode_utf8(&mut buffer);
4310 current_spans.push(Span::styled(s.to_owned(), *style));
4311 current_width += char_width;
4312 }
4313 }
4314 if current_spans.is_empty() {
4315 current_spans.push(Span::raw(String::new()));
4316 }
4317 lines.push(Line::from(current_spans));
4318}
4319
4320fn wrap_text(text: &str, width: usize) -> Vec<String> {
4326 if width == 0 {
4327 return text.lines().map(str::to_owned).collect();
4328 }
4329 let mut rows = Vec::new();
4330 for line in text.split('\n') {
4333 rows.extend(wrap_line(line, width));
4334 }
4335 if rows.is_empty() {
4336 rows.push(String::new());
4337 }
4338 rows
4339}
4340
4341fn wrap_line(line: &str, width: usize) -> Vec<String> {
4342 let mut rows = Vec::new();
4343 let mut current = String::new();
4344 let mut current_width = 0usize;
4345 for character in line.chars() {
4346 let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
4347 if current_width + char_width > width && !current.is_empty() {
4348 rows.push(std::mem::take(&mut current));
4349 current_width = 0;
4350 }
4351 current.push(character);
4352 current_width += char_width;
4353 }
4354 rows.push(current);
4355 rows
4356}
4357
4358#[cfg(test)]
4359mod tests {
4360 use super::*;
4361
4362 fn line_text(line: &Line<'_>) -> String {
4363 line.spans
4364 .iter()
4365 .map(|span| span.content.as_ref())
4366 .collect()
4367 }
4368
4369 #[test]
4370 fn turn_notifications_use_osc_777_with_fixed_secret_safe_messages() {
4371 let cases = [
4372 (TurnNotification::Completed, "Turn complete"),
4373 (TurnNotification::Interrupted, "Turn interrupted"),
4374 (TurnNotification::Failed, "Turn failed"),
4375 ];
4376
4377 for (notification, body) in cases {
4378 let mut output = Vec::new();
4379 send_turn_notification(&mut output, notification).expect("notification");
4380 assert_eq!(
4381 output,
4382 format!("\x1b]777;notify;Lucy;{body}\x07").into_bytes()
4383 );
4384 }
4385 }
4386
4387 #[test]
4388 fn turn_notifications_follow_the_terminal_turn_status() {
4389 assert_eq!(
4390 turn_notification_for_status("finalizing"),
4391 TurnNotification::Completed
4392 );
4393 assert_eq!(
4394 turn_notification_for_status("cancelling"),
4395 TurnNotification::Interrupted
4396 );
4397 assert_eq!(
4398 turn_notification_for_status("error"),
4399 TurnNotification::Failed
4400 );
4401 }
4402
4403 struct FailingWriter;
4404
4405 impl Write for FailingWriter {
4406 fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
4407 Err(io::Error::other("notification sink unavailable"))
4408 }
4409
4410 fn flush(&mut self) -> io::Result<()> {
4411 Err(io::Error::other("notification sink unavailable"))
4412 }
4413 }
4414
4415 #[test]
4416 fn notification_write_failure_does_not_keep_the_tui_busy() {
4417 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4418 state.busy = true;
4419 state.active_cancel = Some(CancellationToken::new());
4420 let mut writer = FailingWriter;
4421
4422 release_finished_turn(&mut writer, &mut state);
4423
4424 assert!(!state.busy);
4425 assert!(state.active_cancel.is_none());
4426 }
4427
4428 #[test]
4429 fn an_idle_finish_does_not_emit_a_duplicate_notification() {
4430 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4431 let mut output = Vec::new();
4432
4433 release_finished_turn(&mut output, &mut state);
4434
4435 assert!(output.is_empty());
4436 }
4437
4438 #[test]
4439 fn context_status_shows_used_window_and_percentage_in_uniform_gray() {
4440 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4441 .with_context(Some(100_000), 80_000);
4442
4443 assert_eq!(
4444 context_status_text(&state),
4445 "Context: 80.0K/100.0K (80%) ████████░░"
4446 );
4447 assert_eq!(
4448 context_status_style(&state).fg,
4449 Some(Color::Rgb(144, 144, 148))
4450 );
4451
4452 state.context_tokens = 80_001;
4453 assert_eq!(
4454 context_status_text(&state),
4455 "Context: 80.0K/100.0K (81%) █████████░"
4456 );
4457 assert_eq!(
4458 context_status_style(&state).fg,
4459 Some(Color::Rgb(144, 144, 148)),
4460 "crossing the compaction threshold does not recolor the status line"
4461 );
4462 }
4463
4464 #[test]
4465 fn context_status_keeps_percentage_and_bar_consistent_at_capacity() {
4466 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4467 .with_context(Some(100_000), 99_001);
4468
4469 assert_eq!(
4470 context_status_text(&state),
4471 "Context: 99.0K/100.0K (100%) ██████████"
4472 );
4473
4474 state.context_tokens = 100_000;
4475 assert_eq!(
4476 context_status_text(&state),
4477 "Context: 100.0K/100.0K (100%) ██████████"
4478 );
4479
4480 state.context_tokens = 100_001;
4481 assert_eq!(
4482 context_status_text(&state),
4483 "Context: 100.0K/100.0K (101%) ██████████"
4484 );
4485 }
4486
4487 #[test]
4488 fn context_status_handles_unknown_window_without_highlighting() {
4489 let state = UiState::from_history(&[], "secret", "model", None, false);
4490
4491 assert_eq!(context_status_text(&state), "Context: 1/? (?%) ??????????");
4492 assert_eq!(
4493 context_status_style(&state).fg,
4494 Some(Color::Rgb(144, 144, 148))
4495 );
4496 }
4497
4498 #[test]
4499 fn tui_viewport_reserves_one_column_on_each_side_when_possible() {
4500 assert_eq!(
4501 tui_viewport(Rect::new(0, 0, 80, 10)),
4502 Rect::new(1, 0, 78, 10)
4503 );
4504 assert_eq!(
4505 tui_viewport(Rect::new(0, 0, 2, 10)),
4506 Rect::new(0, 0, 2, 10),
4507 "a two-column terminal cannot reserve two gutters"
4508 );
4509 }
4510
4511 #[test]
4512 fn tui_viewport_caps_at_one_hundred_columns_and_centers_it() {
4513 assert_eq!(
4514 tui_viewport(Rect::new(0, 0, 140, 10)),
4515 Rect::new(20, 0, TUI_MAX_WIDTH, 10)
4516 );
4517 assert_eq!(
4518 tui_viewport(Rect::new(0, 0, 103, 10)),
4519 Rect::new(1, 0, TUI_MAX_WIDTH, 10),
4520 "an odd remaining column stays on the right"
4521 );
4522 }
4523
4524 #[test]
4525 fn bottom_console_has_external_margins_without_losing_internal_padding() {
4526 let state = UiState::from_history(&[], "secret", "model", None, false);
4527 let viewport = tui_viewport(Rect::new(0, 0, 80, 14));
4528 let (chat, _, _, _, console, _) = ui_layout(&state, viewport);
4529 let content = console_content_area(console);
4530
4531 assert_eq!(chat.x, console.x);
4532 assert_eq!(chat.width, console.width);
4533 assert_eq!(console, Rect::new(viewport.x + 2, 8, viewport.width - 4, 5));
4534 assert_eq!(console.y + console.height, viewport.y + viewport.height - 1);
4535 assert_eq!(content.x, console.x + 2);
4536 assert_eq!(content.width, console.width - 4);
4537 assert_eq!(content.y, console.y + 1);
4538 assert_eq!(content.y + content.height, console.y + console.height - 1);
4539
4540 for (width, margin, console_width) in
4541 [(1, 0, 1), (2, 0, 2), (3, 1, 1), (4, 1, 2), (5, 2, 1)]
4542 {
4543 let console = bottom_console_area(Rect::new(0, 0, width, 4), 0, 4);
4544 assert_eq!(console.x, margin, "width {width}");
4545 assert_eq!(console.width, console_width, "width {width}");
4546 }
4547 }
4548
4549 #[test]
4550 fn inset_console_width_drives_prompt_rows_and_vertical_navigation() {
4551 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4552 state.input = "x".repeat(71);
4553 let viewport = tui_viewport(Rect::new(0, 0, 80, 14));
4554 let console = ui_layout(&state, viewport).4;
4555 let prompt = prompt_area(console, &state);
4556
4557 assert_eq!(ui_prompt_content_width(viewport), prompt.width);
4558 assert_eq!(prompt.width, 70);
4559 assert_eq!(input_visible_rows(&state, prompt.width), 2);
4560 assert!(move_input_cursor_vertical(
4561 &mut state,
4562 ui_prompt_content_width(viewport) as usize,
4563 true,
4564 ));
4565 }
4566
4567 #[test]
4568 fn context_immediately_follows_the_left_status_flow_in_uniform_gray() {
4569 let state =
4570 UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
4571 let mut terminal =
4572 Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
4573
4574 terminal
4575 .draw(|frame| draw(frame, &state))
4576 .expect("draw statusline");
4577
4578 let buffer = terminal.backend().buffer();
4579 let status_area = ui_layout(&state, tui_viewport(Rect::new(0, 0, 80, 10))).5;
4580 let expected = "model · default | Context: 81/100 (81%) █████████░";
4581 let rendered = (status_area.x..status_area.x + expected.chars().count() as u16)
4582 .map(|x| buffer[(x, status_area.y)].symbol())
4583 .collect::<String>();
4584 assert_eq!(rendered, expected);
4585 assert_eq!(
4586 buffer[(
4587 status_area.x + expected.chars().count() as u16,
4588 status_area.y
4589 )]
4590 .symbol(),
4591 " ",
4592 "context is not pushed to the right edge"
4593 );
4594 for x in status_area.x..status_area.x + expected.chars().count() as u16 {
4595 assert_eq!(buffer[(x, status_area.y)].fg, CONSOLE_STATUS_COLOR);
4596 }
4597 }
4598
4599 #[test]
4600 fn pulse_spinner_moves_each_bar_one_level_at_a_time() {
4601 let frames = (0..=240)
4602 .map(|tick| spinner_frame_at(PULSE_TICK * tick))
4603 .collect::<Vec<_>>();
4604 assert!(frames.iter().any(|frame| frame != &frames[0]));
4605 assert_eq!(PULSE_TICK, Duration::from_millis(50));
4606
4607 for pair in frames.windows(2) {
4608 let levels = pair
4609 .iter()
4610 .map(|frame| {
4611 frame
4612 .chars()
4613 .map(|bar| {
4614 PULSE_LEVELS
4615 .iter()
4616 .position(|level| *level == bar)
4617 .expect("known pulse level")
4618 })
4619 .collect::<Vec<_>>()
4620 })
4621 .collect::<Vec<_>>();
4622 assert_eq!(levels[0].len(), 5);
4623 assert!(
4624 levels[0]
4625 .iter()
4626 .zip(&levels[1])
4627 .all(|(before, after)| before.abs_diff(*after) <= 1),
4628 "pulse bars must not jump between adjacent ticks: {:?} -> {:?}",
4629 pair[0],
4630 pair[1]
4631 );
4632 }
4633 }
4634
4635 #[test]
4636 fn console_animation_clock_runs_during_entry_and_survives_active_status_changes() {
4637 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4638 state.set_status("working");
4639 let epoch = state.console_animation_epoch;
4640 assert_eq!(
4641 state.console_animation_elapsed_at(epoch + Duration::from_millis(200)),
4642 Duration::from_millis(200),
4643 "the console animation does not freeze during the activity ramp"
4644 );
4645
4646 state.set_status("compacting");
4647 assert_eq!(state.console_animation_epoch, epoch);
4648 state.set_status("working");
4649 assert_eq!(state.console_animation_epoch, epoch);
4650 }
4651
4652 fn color_distance(from: Color, to: Color) -> u16 {
4653 let (from_red, from_green, from_blue) = activity_rgb(from);
4654 let (to_red, to_green, to_blue) = activity_rgb(to);
4655 u16::from(from_red.abs_diff(to_red))
4656 + u16::from(from_green.abs_diff(to_green))
4657 + u16::from(from_blue.abs_diff(to_blue))
4658 }
4659
4660 fn color_saturation(color: Color) -> u8 {
4661 let (red, green, blue) = activity_rgb(color);
4662 red.max(green).max(blue) - red.min(green).min(blue)
4663 }
4664
4665 fn color_luminance(color: Color) -> u32 {
4666 let (red, green, blue) = activity_rgb(color);
4667 299 * u32::from(red) + 587 * u32::from(green) + 114 * u32::from(blue)
4668 }
4669
4670 #[test]
4671 fn glow_coverage_is_a_bottom_anchored_half_ellipse_with_a_twelve_row_cap() {
4672 let canvas = Rect::new(0, 0, 160, 40);
4673 let console = Rect::new(40, 28, 80, 7);
4674 let bottom = canvas.y + canvas.height;
4675 let center_x = console.x + console.width / 2 - 1;
4676 let outer_x = center_x + 35;
4677 let active_rows = (canvas.y..bottom)
4678 .filter(|&row| glow_coverage_at(center_x, row, canvas, console) > 0.0)
4679 .collect::<Vec<_>>();
4680
4681 assert_eq!(GLOW_HEIGHT, 12);
4682 assert_eq!(GLOW_HORIZONTAL_SPREAD, 24);
4683 assert_eq!(
4684 active_rows,
4685 (bottom - GLOW_HEIGHT..bottom).collect::<Vec<_>>()
4686 );
4687 assert_eq!(
4688 glow_coverage_at(center_x, bottom - GLOW_HEIGHT - 1, canvas, console),
4689 0.0,
4690 "the glow never exceeds its twelve-row cap"
4691 );
4692 assert_eq!(
4693 glow_coverage_at(center_x, bottom - 1, canvas, console),
4694 glow_coverage_at(center_x + 1, bottom - 1, canvas, console),
4695 "the bottom edge intersects the ellipse at its horizontal centreline"
4696 );
4697 assert_eq!(
4698 glow_coverage_at(outer_x, bottom - GLOW_HEIGHT, canvas, console),
4699 0.0,
4700 "the top of the half ellipse stays narrow"
4701 );
4702 assert!(
4703 glow_coverage_at(outer_x, bottom - 1, canvas, console) > 0.0,
4704 "the exposed glow grows wider toward the TUI bottom"
4705 );
4706 assert!(
4707 glow_coverage_at(console.x + console.width + 23, bottom - 1, canvas, console) > 0.0,
4708 "the glow extends twenty-four cells beyond each console edge"
4709 );
4710 }
4711
4712 #[test]
4713 fn wider_console_has_a_wider_elliptical_side_falloff() {
4714 let canvas = Rect::new(0, 0, 200, 20);
4715 let wide_console = Rect::new(50, 12, 100, 7);
4716 let narrow_console = Rect::new(50, 12, 4, 7);
4717 let bottom_row = canvas.y + canvas.height - 1;
4718 let offset_from_center = 60;
4719 let wide_sample = wide_console.x + wide_console.width / 2 + offset_from_center;
4720 let narrow_sample = narrow_console.x + narrow_console.width / 2 + offset_from_center;
4721
4722 let wide = glow_coverage_at(wide_sample, bottom_row, canvas, wide_console);
4723 let narrow = glow_coverage_at(narrow_sample, bottom_row, canvas, narrow_console);
4724
4725 assert!(wide > 0.0);
4726 assert_eq!(narrow, 0.0);
4727 assert!(
4728 wide > narrow,
4729 "the horizontal radius follows the console width to form an ellipse rather than fixed endpoint circles"
4730 );
4731 }
4732
4733 #[test]
4734 fn exposed_bloom_is_not_tinted_as_console_glass() {
4735 let canvas = Rect::new(0, 0, 80, 20);
4736 let console = Rect::new(20, 12, 40, 7);
4737 let elapsed = CONSOLE_BOUNDARY_CYCLE / 4;
4738 let source_row = canvas.y + canvas.height - 1;
4739 let exposed = (console.x - 1, source_row);
4740 let inside = (console.x, console.y + console.height - 1);
4741 let exposed_glow = glow_color_at(
4742 elapsed,
4743 exposed.0,
4744 canvas.width,
4745 exposed.1,
4746 canvas,
4747 console,
4748 1.0,
4749 )
4750 .expect("endpoint bloom");
4751 let inside_glow = glow_color_at(
4752 elapsed,
4753 inside.0,
4754 canvas.width,
4755 inside.1,
4756 canvas,
4757 console,
4758 1.0,
4759 )
4760 .expect("source segment glow");
4761 let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
4762 canvas.width,
4763 canvas.height,
4764 ))
4765 .expect("test terminal");
4766
4767 terminal
4768 .draw(|frame| {
4769 apply_tui_glow(frame, canvas, console, elapsed, 1.0);
4770 apply_console_background(frame, console, elapsed, 1.0);
4771 let buffer = frame.buffer_mut();
4772 assert_eq!(buffer[exposed].bg, exposed_glow);
4773 assert_eq!(
4774 buffer[inside].bg,
4775 console_glass_color_at(elapsed, inside_glow, 1.0)
4776 );
4777 })
4778 .expect("render glow and glass");
4779 }
4780
4781 #[test]
4782 fn idle_canvas_has_no_glow() {
4783 let canvas = Rect::new(0, 0, 80, 12);
4784 let console = Rect::new(2, 6, 76, 5);
4785 for row in canvas.y..canvas.y + canvas.height {
4786 for column in 0..canvas.width {
4787 assert_eq!(
4788 glow_color_at(
4789 CONSOLE_BOUNDARY_CYCLE / 4,
4790 column,
4791 canvas.width,
4792 row,
4793 canvas,
4794 console,
4795 0.0,
4796 ),
4797 None,
4798 "idle glow never paints the canvas"
4799 );
4800 }
4801 }
4802 }
4803
4804 #[test]
4805 fn console_visibility_transition_is_smooth_and_reversible() {
4806 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4807 state.set_busy(true);
4808 let entering = state
4809 .console_visibility_transition
4810 .expect("entry transition");
4811 assert_eq!(state.console_visibility_at(entering.started_at), 0.0);
4812 let entry_middle =
4813 state.console_visibility_at(entering.started_at + CONSOLE_VISIBILITY_TRANSITION / 2);
4814 assert!((entry_middle - 0.5).abs() < 0.000_1);
4815 assert_eq!(
4816 state.console_visibility_at(entering.started_at + CONSOLE_VISIBILITY_TRANSITION),
4817 1.0
4818 );
4819
4820 state.console_visibility_transition = None;
4821 state.set_busy(false);
4822 let exiting = state
4823 .console_visibility_transition
4824 .expect("exit transition");
4825 assert_eq!(state.console_visibility_at(exiting.started_at), 1.0);
4826 let exit_middle =
4827 state.console_visibility_at(exiting.started_at + CONSOLE_VISIBILITY_TRANSITION / 2);
4828 assert!((exit_middle - 0.5).abs() < 0.000_1);
4829 assert_eq!(
4830 state.console_visibility_at(exiting.started_at + CONSOLE_VISIBILITY_TRANSITION),
4831 0.0
4832 );
4833 }
4834
4835 #[test]
4836 fn rapid_console_reentry_preserves_glow_phase() {
4837 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4838 let canvas = Rect::new(0, 0, 80, 12);
4839 let console = Rect::new(2, 6, 76, 5);
4840 let start = Instant::now();
4841 state.set_busy_at(true, start);
4842 let epoch = state.console_animation_epoch;
4843 state.set_busy_at(false, start + CONSOLE_VISIBILITY_TRANSITION);
4844 let reversal_at = start + CONSOLE_VISIBILITY_TRANSITION * 3 / 2;
4845 let visibility_before = state.console_visibility_at(reversal_at);
4846 let elapsed_before = state.console_animation_elapsed_at(reversal_at);
4847 let color_before = glow_color_at(
4848 elapsed_before,
4849 40,
4850 canvas.width,
4851 canvas.y + canvas.height - 1,
4852 canvas,
4853 console,
4854 visibility_before,
4855 );
4856
4857 state.set_busy_at(true, reversal_at);
4858
4859 assert_eq!(state.console_animation_epoch, epoch);
4860 assert!((state.console_visibility_at(reversal_at) - visibility_before).abs() < 0.000_1);
4861 assert_eq!(
4862 glow_color_at(
4863 state.console_animation_elapsed_at(reversal_at),
4864 40,
4865 canvas.width,
4866 canvas.y + canvas.height - 1,
4867 canvas,
4868 console,
4869 state.console_visibility_at(reversal_at),
4870 ),
4871 color_before,
4872 "reversing an exit keeps the current glow phase"
4873 );
4874 }
4875
4876 #[test]
4877 fn glow_reach_still_controls_light_intensity() {
4878 let long = CONSOLE_BOUNDARY_CYCLE / 4;
4879 let short = CONSOLE_BOUNDARY_CYCLE * 3 / 4;
4880 let canvas = Rect::new(0, 0, 80, 12);
4881 let console = Rect::new(2, 6, 76, 5);
4882 let row = canvas.y + canvas.height - 1;
4883 let long_glow =
4884 glow_color_at(long, 40, canvas.width, row, canvas, console, 1.0).expect("visible glow");
4885 let short_glow = glow_color_at(short, 40, canvas.width, row, canvas, console, 1.0)
4886 .expect("visible glow");
4887
4888 assert!((console_reach_at(long) - CONSOLE_REACH_MAX).abs() < 0.000_1);
4889 assert!((console_reach_at(short) - CONSOLE_REACH_MIN).abs() < 0.000_1);
4890 assert!(
4891 color_distance(TUI_GLOW_BACKGROUND, long_glow)
4892 > color_distance(TUI_GLOW_BACKGROUND, short_glow)
4893 );
4894 }
4895
4896 #[test]
4897 fn maximum_glow_is_brighter_and_more_saturated_than_the_previous_tuning() {
4898 let canvas = Rect::new(0, 0, 160, 20);
4899 let console = Rect::new(40, 12, 80, 7);
4900 let column = console.x + console.width / 2;
4901 let row = canvas.y + canvas.height - 1;
4902 let elapsed = Duration::ZERO;
4903 let coverage = glow_coverage_at(column, row, canvas, console);
4904 assert_eq!(GLOW_INTENSITY, 0.70);
4905 let current = glow_color_at(elapsed, column, canvas.width, row, canvas, console, 1.0)
4906 .expect("bottom-centre glow");
4907 let previous = blend_rgb(
4908 TUI_GLOW_BACKGROUND,
4909 glow_accent_with_desaturation_at(elapsed, 0.16),
4910 (console_reach_at(elapsed) / CONSOLE_REACH_MAX) * 0.62 * coverage,
4911 );
4912
4913 assert!(
4914 color_luminance(current) > color_luminance(previous),
4915 "the adjusted maximum glow is brighter than the previous maximum"
4916 );
4917 assert!(
4918 color_saturation(current) > color_saturation(previous),
4919 "the adjusted maximum glow is more saturated than the previous maximum"
4920 );
4921 }
4922
4923 #[test]
4924 fn glow_tuning_is_brighter_and_more_saturated_than_before() {
4925 let canvas = Rect::new(0, 0, 160, 20);
4926 let console = Rect::new(40, 12, 80, 7);
4927 let column = console.x + console.width / 2;
4928 let row = canvas.y + canvas.height - 1;
4929 let coverage = glow_coverage_at(column, row, canvas, console);
4930
4931 assert_eq!(GLOW_INTENSITY, 0.70);
4932 assert_eq!(GLOW_DESATURATION, 0.10);
4933 for (phase, elapsed) in [
4934 Duration::ZERO,
4935 console_accent_cycle() / 4,
4936 console_accent_cycle() / 2,
4937 console_accent_cycle() * 3 / 4,
4938 ]
4939 .into_iter()
4940 .enumerate()
4941 {
4942 let current_accent = glow_accent_at(elapsed);
4943 let previous_accent = glow_accent_with_desaturation_at(elapsed, 0.16);
4944 assert!(
4945 color_saturation(current_accent) > color_saturation(previous_accent),
4946 "phase {phase}: reducing glow desaturation raises saturation"
4947 );
4948 let current = glow_color_at(elapsed, column, canvas.width, row, canvas, console, 1.0)
4949 .expect("bottom-centre glow");
4950 let previous = blend_rgb(
4951 TUI_GLOW_BACKGROUND,
4952 glow_accent_with_desaturation_at(elapsed, 0.16),
4953 (console_reach_at(elapsed) / CONSOLE_REACH_MAX) * 0.62 * coverage,
4954 );
4955
4956 assert!(
4957 color_luminance(current) > color_luminance(previous),
4958 "phase {phase}: the rendered glow is brighter than the prior tuning"
4959 );
4960 assert!(
4961 color_saturation(current) > color_saturation(previous),
4962 "phase {phase}: the rendered glow is more saturated than the prior tuning"
4963 );
4964 }
4965 }
4966
4967 #[test]
4968 fn console_is_dark_lower_saturation_glass_over_the_glow() {
4969 let elapsed = CONSOLE_BOUNDARY_CYCLE / 4;
4970 let canvas = Rect::new(0, 0, 80, 12);
4971 let console = Rect::new(2, 6, 76, 5);
4972 let glow = glow_color_at(
4973 elapsed,
4974 40,
4975 canvas.width,
4976 canvas.y + canvas.height - 1,
4977 canvas,
4978 console,
4979 1.0,
4980 )
4981 .expect("visible glow");
4982 let glass = console_glass_color_at(elapsed, glow, 1.0);
4983 let solid_glass = console_glass_color_at(elapsed, CONSOLE_BACKGROUND, 1.0);
4984
4985 assert_eq!(
4986 console_glass_color_at(elapsed, glow, 0.0),
4987 CONSOLE_BACKGROUND
4988 );
4989 assert_ne!(glass, CONSOLE_BACKGROUND);
4990 let (glass_red, glass_green, glass_blue) = activity_rgb(glass);
4991 let (glow_red, glow_green, glow_blue) = activity_rgb(glow);
4992 assert!(
4993 u16::from(glass_red) + u16::from(glass_green) + u16::from(glass_blue)
4994 < u16::from(glow_red) + u16::from(glow_green) + u16::from(glow_blue),
4995 "console glass is darker than the exposed glow"
4996 );
4997 assert!(
4998 color_distance(CONSOLE_BACKGROUND, glass) < color_distance(TUI_GLOW_BACKGROUND, glow),
4999 "glass tint is subtler than the exposed glow"
5000 );
5001 assert!(
5002 color_distance(CONSOLE_BACKGROUND, glass)
5003 > color_distance(CONSOLE_BACKGROUND, solid_glass),
5004 "the glow visibly carries through the stronger glass tint"
5005 );
5006 assert!(
5007 color_saturation(glass) < color_saturation(glow),
5008 "glass tint is less saturated than the exposed glow"
5009 );
5010 }
5011
5012 #[test]
5013 fn console_top_reflection_is_a_thin_active_line_in_the_external_gap() {
5014 assert_eq!(CONSOLE_REFLECTION_WHITE_TINT, 0.20);
5015 let elapsed = Duration::ZERO;
5016 let canvas = Rect::new(0, 0, 20, 8);
5017 let console = Rect::new(2, 3, 16, 4);
5018 let reflection_y = console.y - 1;
5019 let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
5020 canvas.width,
5021 canvas.height,
5022 ))
5023 .expect("test terminal");
5024 terminal
5025 .draw(|frame| {
5026 apply_tui_glow(frame, canvas, console, elapsed, 1.0);
5027 apply_console_top_reflection(frame, console, elapsed, 1.0);
5028 apply_console_background(frame, console, elapsed, 1.0);
5029 })
5030 .expect("render external console reflection");
5031
5032 let reflection_glow = glow_color_at(
5033 elapsed,
5034 console.x,
5035 canvas.width,
5036 reflection_y,
5037 canvas,
5038 console,
5039 1.0,
5040 )
5041 .expect("reflection row glow");
5042 let accent_only_reflection = blend_rgb(
5043 reflection_glow,
5044 glow_accent_at(elapsed),
5045 CONSOLE_REFLECTION_TINT,
5046 );
5047 let previous_reflection =
5048 blend_rgb(accent_only_reflection, Color::Rgb(255, 255, 255), 0.10);
5049 let reflection = console_top_reflection_color_at(elapsed, reflection_glow, 1.0);
5050 let previous_luminance_lift =
5051 color_luminance(previous_reflection) - color_luminance(accent_only_reflection);
5052 let luminance_lift = color_luminance(reflection) - color_luminance(accent_only_reflection);
5053 assert!(
5054 luminance_lift * 100 >= previous_luminance_lift * 195
5055 && luminance_lift * 100 <= previous_luminance_lift * 210,
5056 "doubling the white tint doubles its reflection luminance contribution"
5057 );
5058 let console_glow = glow_color_at(
5059 elapsed,
5060 console.x,
5061 canvas.width,
5062 console.y,
5063 canvas,
5064 console,
5065 1.0,
5066 )
5067 .expect("console glow");
5068 let buffer = terminal.backend().buffer();
5069 assert_eq!(
5070 buffer[(console.x, reflection_y)].symbol(),
5071 CONSOLE_REFLECTION_GLYPH,
5072 "the reflection is drawn in the row above the console"
5073 );
5074 assert_eq!(
5075 buffer[(console.x, reflection_y)].fg,
5076 console_top_reflection_color_at(elapsed, reflection_glow, 1.0),
5077 "the thin reflection follows the glow accent"
5078 );
5079 assert_eq!(
5080 buffer[(console.x, reflection_y - 1)].symbol(),
5081 " ",
5082 "only the closest external row is changed"
5083 );
5084 assert_eq!(
5085 buffer[(console.x, console.y)].bg,
5086 console_glass_color_at(elapsed, console_glow, 1.0),
5087 "the console top row remains normal glass"
5088 );
5089
5090 let mut idle_terminal = Terminal::new(ratatui::backend::TestBackend::new(
5091 canvas.width,
5092 canvas.height,
5093 ))
5094 .expect("idle test terminal");
5095 idle_terminal
5096 .draw(|frame| {
5097 apply_tui_glow(frame, canvas, console, elapsed, 0.0);
5098 apply_console_top_reflection(frame, console, elapsed, 0.0);
5099 apply_console_background(frame, console, elapsed, 0.0);
5100 })
5101 .expect("render idle console");
5102 assert_eq!(
5103 idle_terminal.backend().buffer()[(console.x, reflection_y)].symbol(),
5104 " ",
5105 "idle consoles have no external reflection"
5106 );
5107
5108 let state = UiState::from_history(&[], "secret", "model", None, false);
5109 let viewport = tui_viewport(Rect::new(0, 0, 80, 10));
5110 let (chat, _, _, _, input_area, _) = ui_layout(&state, viewport);
5111 assert_eq!(
5112 chat.y + chat.height + 1,
5113 input_area.y,
5114 "the reflection uses the existing transcript gap rather than adding a row"
5115 );
5116 }
5117
5118 #[test]
5119 fn console_reflection_skips_transcript_when_no_gap_fits() {
5120 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5121 state.welcome_visible = false;
5122 state
5123 .transcript
5124 .push(TranscriptItem::Info("protected transcript".to_owned()));
5125 state.busy = true;
5126 state.activity_transition = None;
5127 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
5128 let area = Rect::new(0, 0, 60, 7);
5129 let mut terminal =
5130 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5131 .expect("test terminal");
5132 terminal
5133 .draw(|frame| draw(frame, &state))
5134 .expect("draw constrained active console");
5135
5136 let (chat, _, _, _, console, _) = ui_layout(&state, tui_viewport(area));
5137 assert_eq!(chat.y + chat.height, console.y, "there is no separator row");
5138 assert_eq!(
5139 terminal.backend().buffer()[(chat.x, chat.y)].symbol(),
5140 "p",
5141 "the active reflection does not overwrite the adjacent transcript"
5142 );
5143 }
5144
5145 #[test]
5146 fn console_reflection_stays_behind_an_active_skill_picker() {
5147 let mut state = UiState::from_history(&[], "secret", "model", None, false)
5148 .with_skill_names(vec!["agent-browser".to_owned()]);
5149 state.input = "/".to_owned();
5150 state.input_changed();
5151 state.busy = true;
5152 state.activity_transition = None;
5153 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
5154 let area = Rect::new(0, 0, 40, 12);
5155 let mut terminal =
5156 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5157 .expect("test terminal");
5158 terminal
5159 .draw(|frame| draw(frame, &state))
5160 .expect("draw active picker");
5161
5162 let (_, picker, _, _, input, _) = ui_layout(&state, tui_viewport(area));
5163 let picker = picker.expect("visible picker");
5164 assert_eq!(picker.y + picker.height, input.y);
5165 let buffer = terminal.backend().buffer();
5166 assert_eq!(
5167 buffer[(picker.x, picker.y + picker.height - 1)].bg,
5168 SKILL_PICKER_BACKGROUND,
5169 "the picker covers the reflection row"
5170 );
5171 assert_ne!(
5172 buffer[(picker.x, picker.y + picker.height - 1)].symbol(),
5173 CONSOLE_REFLECTION_GLYPH,
5174 "the reflection glyph does not show through the picker"
5175 );
5176 }
5177
5178 #[test]
5179 fn console_glass_white_film_brightens_only_visible_glass() {
5180 let elapsed = Duration::ZERO;
5181 let glow = Color::Rgb(80, 82, 86);
5182 let (red, green, blue) = activity_rgb(console_accent_at(elapsed));
5183 let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
5184 let glass_accent = Color::Rgb(
5185 interpolate_color(red, neutral, CONSOLE_GLASS_DESATURATION),
5186 interpolate_color(green, neutral, CONSOLE_GLASS_DESATURATION),
5187 interpolate_color(blue, neutral, CONSOLE_GLASS_DESATURATION),
5188 );
5189 let accent_tinted = blend_rgb(CONSOLE_BACKGROUND, glass_accent, CONSOLE_GLASS_TINT);
5190 let without_white_film = blend_rgb(accent_tinted, glow, CONSOLE_GLASS_GLOW_THROUGH);
5191 let with_white_film = console_glass_color_at(elapsed, glow, 1.0);
5192 let expected = blend_rgb(
5193 blend_rgb(
5194 accent_tinted,
5195 Color::Rgb(255, 255, 255),
5196 CONSOLE_GLASS_WHITE_TINT,
5197 ),
5198 glow,
5199 CONSOLE_GLASS_GLOW_THROUGH,
5200 );
5201
5202 assert_eq!(CONSOLE_GLASS_WHITE_TINT, 0.03);
5203 assert_eq!(
5204 console_glass_color_at(elapsed, glow, 0.0),
5205 CONSOLE_BACKGROUND,
5206 "idle glass uses the configured console background"
5207 );
5208 assert_eq!(
5209 with_white_film, expected,
5210 "the white film is composited before glow-through"
5211 );
5212 let (with_white_red, with_white_green, with_white_blue) = activity_rgb(with_white_film);
5213 let (without_white_red, without_white_green, without_white_blue) =
5214 activity_rgb(without_white_film);
5215 assert!(
5216 u16::from(with_white_red) + u16::from(with_white_green) + u16::from(with_white_blue)
5217 > u16::from(without_white_red)
5218 + u16::from(without_white_green)
5219 + u16::from(without_white_blue),
5220 "the active glass has a visible white film"
5221 );
5222 }
5223
5224 #[test]
5225 fn console_accent_uses_a_fifteen_second_lavender_to_teal_round_trip() {
5226 assert_eq!(console_accent_cycle(), Duration::from_secs(15));
5227 assert_eq!(
5228 console_accent_at(Duration::ZERO),
5229 desaturate_console_accent(
5230 CONSOLE_ACCENT_LAVENDER.0,
5231 CONSOLE_ACCENT_LAVENDER.1,
5232 CONSOLE_ACCENT_LAVENDER.2,
5233 )
5234 );
5235 assert_eq!(
5236 console_accent_at(console_accent_cycle() / 2),
5237 desaturate_console_accent(
5238 CONSOLE_ACCENT_TEAL.0,
5239 CONSOLE_ACCENT_TEAL.1,
5240 CONSOLE_ACCENT_TEAL.2,
5241 )
5242 );
5243 assert_eq!(
5244 console_accent_at(console_accent_cycle()),
5245 console_accent_at(Duration::ZERO)
5246 );
5247 let midpoint = console_accent_at(console_accent_cycle() / 4);
5248 assert_ne!(
5249 midpoint,
5250 console_accent_at(Duration::ZERO),
5251 "the glow transitions continuously instead of holding at lavender"
5252 );
5253 assert_ne!(
5254 midpoint,
5255 console_accent_at(console_accent_cycle() / 2),
5256 "the glow transitions continuously instead of holding at teal"
5257 );
5258 }
5259
5260 #[test]
5261 fn tui_glow_background_is_ghostty_base_101216() {
5262 assert_eq!(TUI_GLOW_BACKGROUND_RGB, (16, 18, 22));
5263 assert_eq!(TUI_GLOW_BACKGROUND, Color::Rgb(16, 18, 22));
5264 }
5265
5266 #[test]
5267 fn console_palette_starts_lavender_with_fifteen_percent_desaturation() {
5268 assert_eq!(CONSOLE_BACKGROUND_RGB, (42, 42, 46));
5269 assert_eq!(CONSOLE_BACKGROUND, Color::Rgb(42, 42, 46));
5270 assert_eq!(
5271 console_accent_at(Duration::ZERO),
5272 desaturate_console_accent(
5273 CONSOLE_ACCENT_LAVENDER.0,
5274 CONSOLE_ACCENT_LAVENDER.1,
5275 CONSOLE_ACCENT_LAVENDER.2,
5276 )
5277 );
5278 }
5279
5280 #[test]
5281 fn floating_panel_backgrounds_are_neutral_gray_and_darker_than_the_console() {
5282 assert_eq!(FLOATING_PANEL_BACKGROUND, Color::Rgb(28, 28, 30));
5283 assert_eq!(SKILL_PICKER_BACKGROUND, FLOATING_PANEL_BACKGROUND);
5284 assert_eq!(SUBAGENT_OVERLAY_BACKGROUND, FLOATING_PANEL_BACKGROUND);
5285 }
5286
5287 #[test]
5288 fn console_accent_transition_changes_all_tui_glow_in_sync() {
5289 let elapsed = console_accent_cycle() / 4;
5290 let canvas = Rect::new(0, 0, 80, 20);
5291 let console = Rect::new(20, 12, 40, 7);
5292 let row = canvas.y + canvas.height - 1;
5293 let left = glow_color_at(elapsed, console.x, canvas.width, row, canvas, console, 1.0)
5294 .expect("left-side glow");
5295 let right = glow_color_at(
5296 elapsed,
5297 console.x + console.width - 1,
5298 canvas.width,
5299 row,
5300 canvas,
5301 console,
5302 1.0,
5303 )
5304 .expect("right-side glow");
5305 let intensity = (console_reach_at(elapsed) / CONSOLE_REACH_MAX)
5306 * GLOW_INTENSITY
5307 * glow_coverage_at(console.x, row, canvas, console);
5308
5309 assert_eq!(
5310 blend_rgb(TUI_GLOW_BACKGROUND, glow_accent_at(elapsed), intensity),
5311 left,
5312 "the left edge uses the shared accent phase"
5313 );
5314 assert_eq!(left, right, "the glow has no horizontal color phase");
5315
5316 let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
5317 canvas.width,
5318 canvas.height,
5319 ))
5320 .expect("test terminal");
5321 terminal
5322 .draw(|frame| {
5323 apply_tui_glow(frame, canvas, console, elapsed, 1.0);
5324 apply_console_top_reflection(frame, console, elapsed, 1.0);
5325 apply_console_background(frame, console, elapsed, 1.0);
5326 })
5327 .expect("render synchronized TUI glow");
5328 let buffer = terminal.backend().buffer();
5329 let left_x = console.x;
5330 let right_x = console.x + console.width - 1;
5331 assert_eq!(
5332 buffer[(left_x, console.y)].bg,
5333 buffer[(right_x, console.y)].bg,
5334 "the glass uses the shared accent phase"
5335 );
5336 assert_eq!(
5337 buffer[(left_x, console.y - 1)].fg,
5338 buffer[(right_x, console.y - 1)].fg,
5339 "the reflection uses the shared accent phase"
5340 );
5341 }
5342
5343 #[test]
5344 fn borderless_console_contains_queue_running_subagent_prompt_and_statusline() {
5345 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5346 state.queue_user("first task");
5347 state.queue_user("second task");
5348 state.subagents.push(SubagentTask {
5349 call_id: "call-worker".to_owned(),
5350 task_id: Some("subagent-1".to_owned()),
5351 task: "Inspect the command UI".to_owned(),
5352 model: Some("worker-model".to_owned()),
5353 effort: Some("high".to_owned()),
5354 status: SubagentStatus::Running,
5355 result: None,
5356 creation_completed: true,
5357 stream: Vec::new(),
5358 stream_chars: 0,
5359 });
5360 state.input = "prompt text".to_owned();
5361 state.cursor = state.input.chars().count();
5362 let area = Rect::new(0, 0, 80, 14);
5363 let mut terminal =
5364 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5365 .expect("test terminal");
5366
5367 terminal
5368 .draw(|frame| draw(frame, &state))
5369 .expect("draw bottom console");
5370 let (_, _, _, queue_area, input_area, status_area) = ui_layout(&state, tui_viewport(area));
5371 let queue_area = queue_area.expect("message queue area");
5372 let list_area = subagent_list_area(&state, input_area).expect("subagent list area");
5373 let prompt_area = prompt_area(input_area, &state);
5374 assert_eq!(
5375 queue_area.height, 3,
5376 "the queue header precedes each message"
5377 );
5378 assert_eq!(
5379 list_area.height, 2,
5380 "the subagent header precedes each worker"
5381 );
5382 assert_eq!(queue_area.y, input_area.y + 1);
5383 let (queue_spacer, list_spacer, status_spacer) = console_spacer_rows(&state, input_area);
5384 let queue_spacer = queue_spacer.expect("queue/prompt spacer");
5385 let list_spacer = list_spacer.expect("prompt/list spacer");
5386 let status_spacer = status_spacer.expect("list/status spacer");
5387 assert_eq!(queue_area.y + queue_area.height, queue_spacer);
5388 assert_eq!(prompt_area.y, queue_spacer + 1);
5389 assert_eq!(prompt_area.y + prompt_area.height, list_spacer);
5390 assert_eq!(list_area.y, list_spacer + 1);
5391 assert_eq!(list_area.y + list_area.height, status_spacer);
5392 assert_eq!(status_area.y, status_spacer + 1);
5393 assert_eq!(status_area.y + 1, input_area.y + input_area.height - 1);
5394 for area in [queue_area, list_area, status_area] {
5395 assert_eq!(area.x, input_area.x + 2);
5396 assert_eq!(area.width, input_area.width.saturating_sub(4));
5397 }
5398 assert_eq!(prompt_area.x, input_area.x + 2);
5399 assert_eq!(prompt_area.width, input_area.width.saturating_sub(4));
5400
5401 let buffer = terminal.backend().buffer();
5402 let border_glyphs = ["┌", "┐", "└", "┘", "├", "┤", "─"];
5403 for y in input_area.y..input_area.y + input_area.height {
5404 for x in input_area.x..input_area.x + input_area.width {
5405 assert!(
5406 !border_glyphs.contains(&buffer[(x, y)].symbol()),
5407 "console contains a border glyph at ({x}, {y})"
5408 );
5409 assert_eq!(buffer[(x, y)].bg, CONSOLE_BACKGROUND);
5410 }
5411 }
5412
5413 for y in [
5414 input_area.y,
5415 queue_spacer,
5416 list_spacer,
5417 status_spacer,
5418 input_area.y + input_area.height - 1,
5419 ] {
5420 for x in input_area.x..input_area.x + input_area.width {
5421 assert_eq!(buffer[(x, y)].symbol(), " ");
5422 }
5423 }
5424 for (x, y) in [
5425 (input_area.x, input_area.y),
5426 (input_area.x + input_area.width - 1, input_area.y),
5427 (input_area.x, input_area.y + input_area.height - 1),
5428 (
5429 input_area.x + input_area.width - 1,
5430 input_area.y + input_area.height - 1,
5431 ),
5432 ] {
5433 assert_eq!(buffer[(x, y)].symbol(), " ");
5434 assert_eq!(buffer[(x, y)].bg, CONSOLE_BACKGROUND);
5435 }
5436
5437 let queued_rows = (queue_area.y..queue_area.y + queue_area.height)
5438 .map(|y| {
5439 (queue_area.x..queue_area.x + queue_area.width)
5440 .map(|x| buffer[(x, y)].symbol())
5441 .collect::<String>()
5442 })
5443 .collect::<Vec<_>>();
5444 assert!(queued_rows[0].starts_with("Queued"));
5445 assert!(queued_rows[1].contains("│ 1) first task"));
5446 assert!(queued_rows[2].contains("│ 2) second task"));
5447 assert!(!queued_rows[1].contains("Queued 1"));
5448 assert!(!queued_rows[2].contains("Queued 2"));
5449 assert_eq!(
5450 buffer[(queue_area.x, queue_area.y)].fg,
5451 SECTION_CHROME_COLOR
5452 );
5453 assert_eq!(
5454 buffer[(queue_area.x, queue_area.y + 1)].fg,
5455 SECTION_CHROME_COLOR
5456 );
5457 assert_eq!(
5458 buffer[(queue_area.x + 2, queue_area.y + 1)].fg,
5459 QUEUED_MESSAGE_COLOR
5460 );
5461 assert_eq!(buffer[(list_area.x, list_area.y)].fg, SUBAGENT_TITLE_COLOR);
5462 assert_eq!(
5463 buffer[(list_area.x, list_area.y + 1)].fg,
5464 SUBAGENT_TITLE_COLOR
5465 );
5466 assert_eq!(
5467 buffer[(list_area.x + 2, list_area.y + 1)].fg,
5468 subagent_id_color("subagent-1")
5469 );
5470 assert_eq!(
5471 buffer[(status_area.x, status_area.y)].fg,
5472 CONSOLE_STATUS_COLOR
5473 );
5474 let status_row = (status_area.x..status_area.x + status_area.width)
5475 .map(|x| buffer[(x, status_area.y)].symbol())
5476 .collect::<String>();
5477 assert!(status_row.starts_with("model · default | Context: "));
5478 terminal.backend_mut().assert_cursor_position((
5479 prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
5480 prompt_area.y,
5481 ));
5482 }
5483
5484 #[test]
5485 fn prompt_uses_two_cells_of_horizontal_console_padding() {
5486 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5487 state.input = "1234567890123456".to_owned();
5488 let area = Rect::new(0, 0, 20, 6);
5489 let mut terminal =
5490 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5491 .expect("test terminal");
5492
5493 terminal
5494 .draw(|frame| draw(frame, &state))
5495 .expect("draw padded prompt");
5496
5497 let input_area = ui_layout(&state, tui_viewport(area)).4;
5498 let prompt = prompt_area(input_area, &state);
5499 assert_eq!(prompt.x, input_area.x + 2);
5500 assert_eq!(prompt.width, input_area.width.saturating_sub(4));
5501 assert_eq!(
5502 terminal.backend().buffer()[(input_area.x + 1, prompt.y)].symbol(),
5503 " ",
5504 "the two left padding cells remain blank"
5505 );
5506 assert_eq!(
5507 terminal.backend().buffer()[(input_area.x + input_area.width - 2, prompt.y)].symbol(),
5508 " ",
5509 "the two right padding cells remain blank"
5510 );
5511 terminal
5512 .backend_mut()
5513 .assert_cursor_position((input_area.x + 2, prompt.y));
5514 }
5515
5516 #[test]
5517 fn prompt_width_reduction_wraps_and_saturates_at_narrow_widths() {
5518 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5519 state.input = "12345".to_owned();
5520 state.cursor = state.input.chars().count();
5521 let input_area = Rect::new(3, 2, 6, 6);
5522 let prompt = prompt_area(input_area, &state);
5523
5524 assert_eq!(prompt.width, 2);
5525 assert_eq!(input_visible_rows(&state, prompt.width), 3);
5526 assert_eq!(bottom_content_heights(&state, input_area).prompt, 3);
5527 assert_eq!(
5528 cursor_row(&state.input, state.cursor, prompt.width as usize),
5529 2
5530 );
5531 state.cursor = 1;
5532 assert!(move_input_cursor_vertical(
5533 &mut state,
5534 prompt_content_width(input_area.width) as usize,
5535 true,
5536 ));
5537 assert_eq!(state.cursor, 3);
5538 assert_eq!(prompt_content_width(0), 0);
5539 assert_eq!(prompt_content_width(1), 0);
5540 assert_eq!(prompt_content_width(2), 0);
5541 assert_eq!(prompt_content_width(3), 0);
5542 assert_eq!(prompt_content_width(4), 0);
5543 assert_eq!(prompt_content_width(5), 1);
5544 }
5545
5546 #[test]
5547 fn constrained_console_keeps_internal_rects_inside_without_borders() {
5548 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5549 state.queue_user("first task");
5550 state.queue_user("second task");
5551 state.subagents.push(SubagentTask {
5552 call_id: "call-worker".to_owned(),
5553 task_id: Some("subagent-1".to_owned()),
5554 task: "Inspect".to_owned(),
5555 model: None,
5556 effort: None,
5557 status: SubagentStatus::Running,
5558 result: None,
5559 creation_completed: true,
5560 stream: Vec::new(),
5561 stream_chars: 0,
5562 });
5563
5564 for height in 3..=9 {
5565 let area = Rect::new(0, 0, 60, height);
5566 let mut terminal =
5567 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5568 .expect("test terminal");
5569 terminal
5570 .draw(|frame| draw(frame, &state))
5571 .expect("draw constrained console");
5572
5573 let (_, _, _, queue, console, status) = ui_layout(&state, tui_viewport(area));
5574 let content = console_content_area(console);
5575 let prompt = prompt_area(console, &state);
5576 let list = subagent_list_area(&state, console);
5577 for child in queue.into_iter().chain(list).chain([prompt, status]) {
5578 assert!(
5579 child.x >= content.x,
5580 "height {height}: {child:?} starts left of {content:?}"
5581 );
5582 assert!(
5583 child.y >= content.y,
5584 "height {height}: {child:?} starts above {content:?}"
5585 );
5586 assert!(
5587 child.x + child.width <= content.x + content.width,
5588 "height {height}: {child:?} ends right of {content:?}"
5589 );
5590 assert!(
5591 child.y + child.height <= content.y + content.height,
5592 "height {height}: {child:?} ends below {content:?}"
5593 );
5594 }
5595
5596 let buffer = terminal.backend().buffer();
5597 let border_glyphs = ["┌", "┐", "└", "┘", "├", "┤", "─", "│"];
5598 for y in console.y..console.y + console.height {
5599 assert!(!border_glyphs.contains(&buffer[(console.x, y)].symbol()));
5600 assert!(
5601 !border_glyphs.contains(&buffer[(console.x + console.width - 1, y)].symbol())
5602 );
5603 let expected_background = CONSOLE_BACKGROUND;
5604 assert_eq!(buffer[(console.x, y)].bg, expected_background);
5605 assert_eq!(
5606 buffer[(console.x + console.width - 1, y)].bg,
5607 expected_background
5608 );
5609 }
5610 assert_eq!(
5611 status.y + status.height,
5612 console.y + console.height.saturating_sub(1)
5613 );
5614 let spacers = console_spacer_rows(&state, console);
5615 assert_eq!(spacers.0.is_some(), queue.is_some());
5616 assert_eq!(spacers.1.is_some(), list.is_some());
5617 for y in spacers.0.into_iter().chain(spacers.1).chain(spacers.2) {
5618 for x in console.x..console.x + console.width {
5619 assert_eq!(buffer[(x, y)].symbol(), " ");
5620 }
5621 }
5622 }
5623 }
5624
5625 #[test]
5626 fn constrained_console_hides_sections_that_cannot_fit_a_header_and_entry() {
5627 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5628 state.queue_user("queued");
5629 state.subagents.push(SubagentTask {
5630 call_id: "call-worker".to_owned(),
5631 task_id: Some("subagent-1".to_owned()),
5632 task: "Inspect".to_owned(),
5633 model: None,
5634 effort: None,
5635 status: SubagentStatus::Running,
5636 result: None,
5637 creation_completed: true,
5638 stream: Vec::new(),
5639 stream_chars: 0,
5640 });
5641
5642 let cramped = bottom_content_heights(&state, Rect::new(0, 0, 80, 7));
5643 assert_eq!(cramped.queue, 0);
5644 assert_eq!(cramped.queue_separator, 0);
5645 assert_eq!(cramped.list, 0);
5646 assert_eq!(cramped.list_separator, 0);
5647
5648 let queue_only = bottom_content_heights(&state, Rect::new(0, 0, 80, 8));
5649 assert_eq!(queue_only.queue, 2);
5650 assert_eq!(queue_only.queue_separator, 1);
5651 assert_eq!(queue_only.list, 0);
5652 }
5653
5654 #[test]
5655 fn constrained_multiline_prompt_scrolls_to_its_last_row_and_keeps_cursor_inside() {
5656 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5657 state.queue_user("first task");
5658 state.queue_user("second task");
5659 state.subagents.push(SubagentTask {
5660 call_id: "call-worker".to_owned(),
5661 task_id: Some("subagent-1".to_owned()),
5662 task: "Inspect".to_owned(),
5663 model: None,
5664 effort: None,
5665 status: SubagentStatus::Running,
5666 result: None,
5667 creation_completed: true,
5668 stream: Vec::new(),
5669 stream_chars: 0,
5670 });
5671 state.input = "first\nsecond\nthird".to_owned();
5672 state.cursor = state.input.chars().count();
5673 let area = Rect::new(0, 0, 40, 6);
5674 let mut terminal =
5675 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5676 .expect("test terminal");
5677 terminal
5678 .draw(|frame| draw(frame, &state))
5679 .expect("draw constrained multiline prompt");
5680
5681 let outer = ui_layout(&state, tui_viewport(area)).4;
5682 let prompt = prompt_area(outer, &state);
5683 assert_eq!(prompt.height, 2);
5684 let rendered = (prompt.y..prompt.y + prompt.height)
5685 .map(|y| {
5686 (prompt.x..prompt.x + prompt.width)
5687 .map(|x| terminal.backend().buffer()[(x, y)].symbol())
5688 .collect::<String>()
5689 })
5690 .collect::<Vec<_>>();
5691 assert!(rendered[0].starts_with("second"));
5692 assert!(rendered[1].starts_with("third"));
5693 terminal.backend_mut().assert_cursor_position((
5694 prompt.x + UnicodeWidthStr::width("third") as u16,
5695 prompt.y + prompt.height - 1,
5696 ));
5697 }
5698
5699 #[test]
5700 fn constrained_picker_and_worker_overlay_remain_above_console() {
5701 let mut picker_state = UiState::from_history(&[], "secret", "model", None, false)
5702 .with_skill_names(vec!["release-notes".to_owned()]);
5703 picker_state.input = "/".to_owned();
5704 picker_state.input_changed();
5705 for height in [3, 5] {
5706 let area = Rect::new(0, 0, 60, height);
5707 let (_, picker, _, _, outer, _) = ui_layout(&picker_state, tui_viewport(area));
5708 if let Some(picker) = picker {
5709 assert!(picker.y >= area.y);
5710 assert!(picker.y + picker.height <= outer.y);
5711 }
5712 }
5713
5714 let mut worker_state = UiState::from_history(&[], "secret", "model", None, false);
5715 worker_state.subagents.push(SubagentTask {
5716 call_id: "call-worker".to_owned(),
5717 task_id: Some("subagent-1".to_owned()),
5718 task: "Inspect".to_owned(),
5719 model: None,
5720 effort: None,
5721 status: SubagentStatus::Running,
5722 result: None,
5723 creation_completed: true,
5724 stream: vec![SubagentStreamItem::Assistant("worker output".to_owned())],
5725 stream_chars: 13,
5726 });
5727 assert!(worker_state.focus_subagent_list_from_input());
5728 for height in [3, 5] {
5729 let area = Rect::new(0, 0, 60, height);
5730 let (_, _, overlay, _, outer, _) = ui_layout(&worker_state, tui_viewport(area));
5731 if let Some(overlay) = overlay {
5732 assert!(overlay.y >= area.y);
5733 assert!(overlay.y + overlay.height <= outer.y);
5734 }
5735
5736 let mut terminal =
5737 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5738 .expect("test terminal");
5739 terminal
5740 .draw(|frame| draw(frame, &worker_state))
5741 .expect("draw constrained worker overlay");
5742 assert_eq!(
5743 terminal.backend().buffer()[(outer.x, outer.y)].symbol(),
5744 " ",
5745 "height {height}"
5746 );
5747 assert_eq!(
5748 terminal.backend().buffer()[(outer.x, outer.y)].bg,
5749 CONSOLE_BACKGROUND,
5750 "height {height}"
5751 );
5752 }
5753 }
5754
5755 #[test]
5756 fn ready_submission_bypasses_queue_and_is_not_added_twice_when_started() {
5757 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5758
5759 state.submit_user("send now");
5760
5761 assert!(state.queued_messages.is_empty());
5762 assert_eq!(state.transcript.len(), 1);
5763 assert!(matches!(
5764 &state.transcript[0],
5765 TranscriptItem::User { text, .. } if text == "send now"
5766 ));
5767
5768 state.start_queued_user("send now");
5771 assert_eq!(state.transcript.len(), 1);
5772 }
5773
5774 #[test]
5775 fn busy_submission_remains_queued_until_its_turn_starts() {
5776 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5777 state.busy = true;
5778
5779 state.submit_user("send later");
5780
5781 assert_eq!(state.queued_messages, ["send later"]);
5782 assert!(state.transcript.is_empty());
5783
5784 state.start_queued_user("send later");
5785 assert!(state.queued_messages.is_empty());
5786 assert!(matches!(
5787 &state.transcript[..],
5788 [TranscriptItem::User { text, .. }] if text == "send later"
5789 ));
5790 }
5791
5792 #[test]
5793 fn skill_picker_stays_above_a_visible_message_queue() {
5794 let mut state = UiState::from_history(&[], "secret", "model", None, false)
5795 .with_skill_names(vec!["release-notes".to_owned()]);
5796 state.queue_user("next task");
5797 state.input = "/".to_owned();
5798 state.input_changed();
5799
5800 let area = Rect::new(0, 0, 80, 12);
5801 let (_, picker_area, _, queue_area, input_area, _) = ui_layout(&state, tui_viewport(area));
5802 let picker_area = picker_area.expect("skill picker area");
5803 let queue_area = queue_area.expect("message queue area");
5804 assert_eq!(picker_area.y + picker_area.height, input_area.y);
5805 assert_eq!(queue_area.y, input_area.y + 1);
5806 assert_eq!(queue_area.x, input_area.x + 2);
5807 }
5808
5809 #[test]
5810 fn fresh_sessions_show_the_versioned_gradient_welcome_message() {
5811 let state = UiState::from_history(&[], "secret", "model", None, false);
5812 assert!(state.welcome_visible);
5813
5814 let line = welcome_line();
5815 assert_eq!(line.to_string(), WELCOME_MESSAGE);
5816 assert_eq!(WELCOME_VERSION, concat!("v", env!("CARGO_PKG_VERSION")));
5817 assert_eq!(
5818 line.spans.first().and_then(|span| span.style.fg),
5819 Some(Color::Rgb(
5820 WELCOME_START_COLOR.0,
5821 WELCOME_START_COLOR.1,
5822 WELCOME_START_COLOR.2,
5823 ))
5824 );
5825 assert_eq!(
5826 line.spans.last().and_then(|span| span.style.fg),
5827 Some(Color::Rgb(
5828 WELCOME_END_COLOR.0,
5829 WELCOME_END_COLOR.1,
5830 WELCOME_END_COLOR.2,
5831 ))
5832 );
5833 }
5834
5835 #[test]
5836 fn welcome_image_brightness_is_reduced_without_changing_alpha() {
5837 let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
5838 1,
5839 1,
5840 image::Rgba([200, 100, 0, 37]),
5841 ));
5842 let dimmed = dim_welcome_image(image).to_rgba8();
5843 assert_eq!(dimmed.get_pixel(0, 0).0, [170, 85, 0, 37]);
5844 }
5845
5846 #[test]
5847 fn spacious_welcome_uses_the_embedded_png() {
5848 let image = welcome_image(WELCOME_IMAGE_SIZE);
5849 assert_eq!(image.size(), WELCOME_IMAGE_SIZE);
5850 let layout = welcome_image_layout(Rect::new(0, 0, 100, 40), 6).expect("image fits");
5851 assert_eq!(layout.image_size, WELCOME_IMAGE_SIZE);
5852 assert_eq!(layout.image_area, Rect::new(10, 6, 80, 20));
5853 assert_eq!(layout.intro_area.y, layout.image_area.y + 21);
5854 }
5855
5856 #[test]
5857 fn cramped_welcome_falls_back_to_the_text_greeting() {
5858 assert_eq!(welcome_image_layout(Rect::new(0, 0, 80, 16), 6), None);
5859 assert_eq!(welcome_image_layout(Rect::new(0, 0, 39, 40), 6), None);
5860 let scaled = welcome_image_layout(Rect::new(0, 0, 60, 25), 6).expect("scaled image fits");
5861 assert_eq!(scaled.image_size, Size::new(60, 15));
5862
5863 let state = UiState::from_history(&[], "secret", "model", None, false);
5864 let area = Rect::new(0, 0, 80, 12);
5865 let mut terminal =
5866 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5867 .expect("test terminal");
5868 terminal
5869 .draw(|frame| draw(frame, &state))
5870 .expect("draw text fallback");
5871 let chat_area = ui_layout(&state, tui_viewport(area)).0;
5872 let rows = (chat_area.y..chat_area.y + chat_area.height)
5873 .map(|y| {
5874 (chat_area.x..chat_area.x + chat_area.width)
5875 .map(|x| terminal.backend().buffer()[(x, y)].symbol())
5876 .collect::<String>()
5877 })
5878 .collect::<Vec<_>>();
5879 assert!(rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
5880 assert!(!rows
5881 .iter()
5882 .any(|row| row.contains('▀') || row.contains('▄')));
5883 }
5884
5885 #[test]
5886 fn greeting_image_is_hidden_without_the_env_flag_and_shown_when_enabled() {
5887 let state = UiState::from_history(&[], "secret", "model", None, false);
5888 let area = Rect::new(0, 0, 100, 50);
5889 let mut terminal =
5890 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5891 .expect("test terminal");
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
5897 std::env::remove_var("LUCY_GREETING_IMAGE");
5899 terminal
5900 .draw(|frame| draw(frame, &state))
5901 .expect("draw without greeting flag");
5902 let buffer = terminal.backend().buffer();
5903 assert!(!matches!(
5904 buffer[(layout.image_area.x, layout.image_area.y)].symbol(),
5905 "▀" | "▄"
5906 ));
5907
5908 std::env::set_var("LUCY_GREETING_IMAGE", "true");
5910 terminal
5911 .draw(|frame| draw(frame, &state))
5912 .expect("draw with greeting flag");
5913 let buffer = terminal.backend().buffer();
5914 assert_eq!(layout.image_size, WELCOME_IMAGE_SIZE);
5915 assert!(matches!(
5916 buffer[(layout.image_area.x, layout.image_area.y)].symbol(),
5917 "▀" | "▄"
5918 ));
5919 assert!(matches!(
5920 buffer[(layout.image_area.x, layout.image_area.y)].fg,
5921 Color::Rgb(..)
5922 ));
5923 assert!(matches!(
5924 buffer[(layout.image_area.x, layout.image_area.y)].bg,
5925 Color::Rgb(..)
5926 ));
5927 let first_fg = buffer[(layout.image_area.x, layout.image_area.y)].fg;
5928 let first_bg = buffer[(layout.image_area.x, layout.image_area.y)].bg;
5929 assert!(
5930 (layout.image_area.y..layout.image_area.y + layout.image_area.height)
5931 .flat_map(
5932 |y| (layout.image_area.x..layout.image_area.x + layout.image_area.width)
5933 .map(move |x| (x, y))
5934 )
5935 .any(|(x, y)| buffer[(x, y)].fg != first_fg || buffer[(x, y)].bg != first_bg)
5936 );
5937 let intro_rows = (layout.intro_area.y..layout.intro_area.y + layout.intro_area.height)
5938 .map(|y| {
5939 (layout.intro_area.x..layout.intro_area.x + layout.intro_area.width)
5940 .map(|x| buffer[(x, y)].symbol())
5941 .collect::<String>()
5942 })
5943 .collect::<Vec<_>>();
5944 assert!(intro_rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
5945
5946 std::env::remove_var("LUCY_GREETING_IMAGE");
5947 }
5948
5949 #[test]
5950 fn welcome_renders_version_below_title_with_a_blank_line_before_tagline() {
5951 let state = UiState::from_history(&[], "secret", "model", None, false);
5952 let area = Rect::new(0, 0, 80, 12);
5953 let mut terminal =
5954 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5955 .expect("test terminal");
5956 terminal
5957 .draw(|frame| draw(frame, &state))
5958 .expect("draw welcome screen");
5959
5960 let chat_area = ui_layout(&state, tui_viewport(area)).0;
5961 let buffer = terminal.backend().buffer();
5962 let rows = (chat_area.y..chat_area.y + chat_area.height)
5963 .map(|y| {
5964 (chat_area.x..chat_area.x + chat_area.width)
5965 .map(|x| buffer[(x, y)].symbol())
5966 .collect::<String>()
5967 })
5968 .collect::<Vec<_>>();
5969 let title_row = rows
5970 .iter()
5971 .position(|row| row.contains(WELCOME_MESSAGE))
5972 .expect("rendered welcome title");
5973 let version_rows = rows
5974 .iter()
5975 .enumerate()
5976 .filter_map(|(row, rendered)| rendered.contains(WELCOME_VERSION).then_some(row))
5977 .collect::<Vec<_>>();
5978
5979 assert_eq!(version_rows, vec![title_row + 1]);
5980 assert!(rows[title_row + 2].trim().is_empty());
5981 assert!(rows[title_row + 3].contains(WELCOME_TAGLINE));
5982
5983 let version_width = WELCOME_VERSION.chars().count() as u16;
5984 let version_x = chat_area.x + (chat_area.width - version_width) / 2;
5985 let version_y = chat_area.y + title_row as u16 + 1;
5986 assert!((version_x..version_x + version_width)
5987 .all(|x| buffer[(x, version_y)].fg == Color::DarkGray));
5988 }
5989
5990 #[test]
5991 fn welcome_shows_the_tagline_and_attached_agents_paths() {
5992 let state = UiState::from_history(&[], "secret", "model", None, false)
5993 .with_attached_agents(vec![
5994 "/workspace/AGENTS.md".to_owned(),
5995 "/workspace/app/AGENTS.md".to_owned(),
5996 ]);
5997 let lines = welcome_lines(&state.attached_agents);
5998
5999 assert_eq!(lines[1].to_string(), WELCOME_VERSION);
6000 assert_eq!(lines[1].style.fg, Some(Color::DarkGray));
6001 assert!(lines[2].to_string().is_empty());
6002 assert_eq!(lines[3].to_string(), WELCOME_TAGLINE);
6003 assert_eq!(lines[3].style.fg, Some(Color::DarkGray));
6004 assert!(lines[4].to_string().is_empty());
6005 assert_eq!(lines[5].to_string(), "Attached AGENTS.md:");
6006 assert_eq!(lines[6].to_string(), "• /workspace/AGENTS.md");
6007 assert_eq!(lines[7].to_string(), "• /workspace/app/AGENTS.md");
6008 assert!(lines[5..]
6009 .iter()
6010 .all(|line| line.style.fg == Some(Color::DarkGray)));
6011 }
6012
6013 #[test]
6014 fn welcome_reports_when_no_agents_file_is_attached() {
6015 let lines = welcome_lines(&[]);
6016 assert_eq!(
6017 lines.last().expect("empty context line").to_string(),
6018 "Attached AGENTS.md: none"
6019 );
6020 }
6021
6022 #[test]
6023 fn resumed_sessions_do_not_show_the_welcome_message() {
6024 let state = UiState::from_history(&[], "secret", "model", None, true);
6025 assert!(!state.welcome_visible);
6026 }
6027
6028 #[test]
6029 fn history_replay_keeps_interruption_after_messages() {
6030 let history = vec![
6031 SessionHistoryRecord::Message {
6032 timestamp: 1,
6033 message: ChatMessage::user("hello".to_owned()),
6034 },
6035 SessionHistoryRecord::Interruption {
6036 timestamp: 2,
6037 reason: "user_cancelled".to_owned(),
6038 phase: "provider_stream".to_owned(),
6039 assistant_text: "partial".to_owned(),
6040 tool_calls: Vec::new(),
6041 tool_results: Vec::new(),
6042 },
6043 ];
6044 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
6045 assert!(matches!(state.transcript[0], TranscriptItem::User { .. }));
6046 assert!(matches!(state.transcript[1], TranscriptItem::Assistant(_)));
6047 assert!(matches!(state.transcript[2], TranscriptItem::Info(_)));
6048 let text = transcript_lines(&state, 80)
6049 .iter()
6050 .map(ToString::to_string)
6051 .collect::<Vec<_>>()
6052 .join("\n");
6053 assert!(!text.contains("choices"));
6054 }
6055
6056 #[test]
6057 fn history_replay_does_not_render_assistant_reasoning_details() {
6058 let mut message = ChatMessage::assistant("visible answer".to_owned(), Vec::new());
6059 message.reasoning_details = Some(vec![serde_json::json!({
6060 "type": "reasoning.text",
6061 "text": "private reasoning"
6062 })]);
6063 let history = [SessionHistoryRecord::Message {
6064 timestamp: 1,
6065 message,
6066 }];
6067 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
6068 let text = transcript_lines(&state, 80)
6069 .iter()
6070 .map(ToString::to_string)
6071 .collect::<Vec<_>>()
6072 .join("\n");
6073 assert!(text.contains("visible answer"));
6074 assert!(!text.contains("private reasoning"));
6075 assert!(!text.contains("reasoning_details"));
6076 }
6077
6078 #[test]
6079 fn history_replay_preserves_repeated_records() {
6080 let history = vec![
6081 SessionHistoryRecord::Message {
6082 timestamp: 1,
6083 message: ChatMessage::assistant("same".to_owned(), Vec::new()),
6084 },
6085 SessionHistoryRecord::Interruption {
6086 timestamp: 2,
6087 reason: "user_cancelled".to_owned(),
6088 phase: "provider_stream".to_owned(),
6089 assistant_text: "same".to_owned(),
6090 tool_calls: Vec::new(),
6091 tool_results: Vec::new(),
6092 },
6093 ];
6094 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
6095 assert_eq!(
6096 state
6097 .transcript
6098 .iter()
6099 .filter(|item| matches!(item, TranscriptItem::Assistant(text) if text == "same"))
6100 .count(),
6101 2
6102 );
6103 }
6104
6105 #[test]
6106 fn user_messages_have_a_single_block_rule_with_inner_and_vertical_padding() {
6107 let history = [SessionHistoryRecord::Message {
6108 timestamp: 1,
6109 message: ChatMessage::user("hello\nworld".to_owned()),
6110 }];
6111 let state = UiState::from_history(&history, "provider-secret", "model", None, false);
6112 let lines = transcript_lines(&state, 12);
6113
6114 assert_eq!(UnicodeWidthStr::width(USER_BORDER_GLYPH), 1);
6115 assert_eq!(lines.len(), 4);
6116 assert_eq!(lines[0].to_string(), "▌");
6117 assert_eq!(lines[1].to_string(), "▌ hello");
6118 assert_eq!(lines[2].to_string(), "▌ world");
6119 assert_eq!(lines[3].to_string(), "▌");
6120 for line in &lines {
6121 assert_eq!(line.spans[0].content, USER_BORDER_GLYPH);
6122 assert_eq!(line.spans[0].style.fg, Some(USER_BORDER_COLOR));
6123 assert!(!line.to_string().contains(['┌', '┐', '└', '┘', '│']));
6124 }
6125 for line in &lines[1..3] {
6126 assert_eq!(line.spans[1].content, " ");
6127 assert_eq!(line.spans[1].style.fg, Some(Color::White));
6128 assert_eq!(line.spans[2].style.fg, Some(Color::White));
6129 }
6130 }
6131
6132 #[test]
6133 fn attached_skill_highlights_its_trigger_in_the_user_message_without_a_notice_line() {
6134 let mut state = UiState::from_history(&[], "secret", "model", None, false)
6135 .with_skill_names(vec!["release-notes".to_owned()]);
6136 state.add_user("/release-notes v1.2.0", "secret");
6137 state.mark_latest_user_skill_attached();
6138
6139 let lines = transcript_lines(&state, 40);
6140 assert_eq!(lines.len(), 3);
6141 assert_eq!(lines[1].spans[1].content, " ");
6142 let cyan_text = lines[1]
6143 .spans
6144 .iter()
6145 .filter(|span| span.style.fg == Some(SKILL_TRIGGER_COLOR))
6146 .map(|span| span.content.as_ref())
6147 .collect::<String>();
6148 assert_eq!(cyan_text, "/release-notes");
6149 assert!(!lines
6150 .iter()
6151 .any(|line| line.to_string().contains("instruction attached")));
6152 }
6153
6154 #[test]
6155 fn transcript_rendering_redacts_history_content() {
6156 let history = [SessionHistoryRecord::Message {
6157 timestamp: 1,
6158 message: ChatMessage::assistant("provider-secret".to_owned(), Vec::new()),
6159 }];
6160 let state = UiState::from_history(&history, "provider-secret", "model", None, false);
6161 let text = transcript_lines(&state, 80)
6162 .iter()
6163 .map(ToString::to_string)
6164 .collect::<Vec<_>>()
6165 .join("\n");
6166 assert!(!text.contains("provider-secret"));
6167 }
6168
6169 #[test]
6170 fn mouse_wheel_disables_following_and_changes_scroll_offset() {
6171 let history = [SessionHistoryRecord::Message {
6172 timestamp: 1,
6173 message: ChatMessage::user("hello".to_owned()),
6174 }];
6175 let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
6176 handle_mouse_event(&mut state, MouseEventKind::ScrollUp, 10);
6177 assert!(!state.auto_scroll);
6178 assert_eq!(state.scroll, 7);
6179 handle_mouse_event(&mut state, MouseEventKind::ScrollDown, 10);
6180 assert!(
6181 state.auto_scroll,
6182 "reaching the bottom resumes transcript following"
6183 );
6184 assert_eq!(state.scroll, 0);
6185 scroll_up(&mut state, 10);
6186 assert!(!state.auto_scroll);
6187 assert_eq!(state.scroll, 7);
6188 }
6189
6190 #[test]
6191 fn wrap_text_breaks_long_lines_and_preserves_empty_lines() {
6192 let rows = wrap_text("12345\n\nabc", 3);
6193 assert_eq!(rows, vec!["123", "45", "", "abc"]);
6194 }
6195
6196 #[test]
6197 fn wrap_line_never_returns_an_empty_vec() {
6198 assert_eq!(wrap_line("", 5), vec![""]);
6199 assert_eq!(wrap_line("abc", 5), vec!["abc"]);
6200 }
6201
6202 #[test]
6203 fn multiline_input_arrows_move_cursor_between_explicit_and_wrapped_rows() {
6204 let mut state = UiState::from_history(&[], "secret", "model", None, false);
6205 state.input = "ab\ncd\nef".to_owned();
6206 state.cursor = 1;
6207
6208 assert!(move_input_cursor_vertical(&mut state, 10, true));
6209 assert_eq!(
6210 state.cursor, 4,
6211 "preserve the column on the next explicit row"
6212 );
6213 assert!(move_input_cursor_vertical(&mut state, 10, true));
6214 assert_eq!(state.cursor, 7);
6215 assert!(!move_input_cursor_vertical(&mut state, 10, true));
6216 assert!(move_input_cursor_vertical(&mut state, 10, false));
6217 assert_eq!(state.cursor, 4);
6218
6219 state.input = "abcdef".to_owned();
6220 state.cursor = 1;
6221 assert!(move_input_cursor_vertical(&mut state, 3, true));
6222 assert_eq!(state.cursor, 4, "wrapped rows use the same visual column");
6223 assert!(move_input_cursor_vertical(&mut state, 3, false));
6224 assert_eq!(state.cursor, 1);
6225 }
6226
6227 #[test]
6228 fn completion_event_does_not_release_input_before_worker_finishes() {
6229 let history = [SessionHistoryRecord::Message {
6230 timestamp: 1,
6231 message: ChatMessage::user("hello".to_owned()),
6232 }];
6233 let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
6234 state.busy = true;
6235 state.active_cancel = Some(CancellationToken::new());
6236 state.apply_event(ProtocolEvent::TurnEnd);
6237 assert!(state.busy);
6238 assert!(state.active_cancel.is_some());
6239 assert_eq!(state.status, "finalizing");
6240 }
6241
6242 #[test]
6243 fn transcript_inserts_a_blank_line_between_items() {
6244 let history = [
6245 SessionHistoryRecord::Message {
6246 timestamp: 1,
6247 message: ChatMessage::user("hi".to_owned()),
6248 },
6249 SessionHistoryRecord::Message {
6250 timestamp: 2,
6251 message: ChatMessage::assistant("hello".to_owned(), Vec::new()),
6252 },
6253 ];
6254 let state = UiState::from_history(&history, "secret", "model", None, false);
6255 let lines = transcript_lines(&state, 80);
6256 assert_eq!(lines.len(), 5);
6257 assert_eq!(lines[0].to_string(), "▌");
6258 assert_eq!(lines[1].to_string(), "▌ hi");
6259 assert_eq!(lines[2].to_string(), "▌");
6260 assert_eq!(lines[3].to_string(), "");
6261 assert_eq!(lines[4].to_string(), "hello");
6262 }
6263
6264 #[test]
6265 fn cmd_call_renders_as_a_compact_status_line_without_raw_json() {
6266 let history = vec![
6267 SessionHistoryRecord::Message {
6268 timestamp: 1,
6269 message: ChatMessage::assistant(
6270 String::new(),
6271 vec![crate::model::ChatToolCall {
6272 id: "call-1".to_owned(),
6273 name: "cmd".to_owned(),
6274 arguments: r#"{"command":"pwd"}"#.to_owned(),
6275 }],
6276 ),
6277 },
6278 SessionHistoryRecord::Message {
6279 timestamp: 2,
6280 message: ChatMessage::tool(
6281 "call-1".to_owned(),
6282 "cmd".to_owned(),
6283 serde_json::json!({"exit_code": 0, "stdout": "secret output"}).to_string(),
6284 ),
6285 },
6286 ];
6287 let state = UiState::from_history(&history, "secret", "model", None, false);
6288 let text = transcript_lines(&state, 80)[0].to_string();
6289
6290 assert_eq!(text, "✓ cmd $ pwd");
6291 assert!(!text.contains("secret output"));
6292 assert!(!text.contains("{\"command\":\"pwd\"}"));
6293 }
6294
6295 #[test]
6296 fn pending_cmd_calls_use_a_compact_running_status() {
6297 let history = [SessionHistoryRecord::Message {
6298 timestamp: 1,
6299 message: ChatMessage::assistant(
6300 String::new(),
6301 vec![crate::model::ChatToolCall {
6302 id: "call-1".to_owned(),
6303 name: "cmd".to_owned(),
6304 arguments: r#"{"command":"pwd"}"#.to_owned(),
6305 }],
6306 ),
6307 }];
6308 let state = UiState::from_history(&history, "secret", "model", None, false);
6309 let line = &transcript_lines(&state, 80)[0];
6310
6311 let text = line.to_string();
6312 let prefix = "· cmd $ pwd ";
6313 assert!(text.starts_with(prefix));
6314 assert!(!text.contains("→ running"));
6315 let frame = &text[prefix.len()..];
6316 assert_eq!(frame.chars().count(), 1);
6317 assert!(frame
6318 .chars()
6319 .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
6320 assert!(line
6321 .spans
6322 .iter()
6323 .all(|span| span.style.fg == Some(PENDING_TOOL_COLOR)));
6324 }
6325
6326 #[test]
6327 fn running_tool_indicators_use_a_traditional_spinner_with_their_own_clock() {
6328 assert_eq!(tool_spinner_frame_at(Duration::ZERO), '|');
6329 assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION), '/');
6330 assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 2), '-');
6331 assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 3), '\\');
6332
6333 let state = UiState::from_history(&[], "secret", "model", None, false);
6334 let spinner = running_tool_status(&state);
6335 assert_eq!(spinner.chars().count(), 1);
6336 assert!(spinner
6337 .chars()
6338 .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
6339 }
6340
6341 #[test]
6342 fn successful_cmd_cross_fades_to_teal_from_first_character_to_last() {
6343 let started_at = Instant::now();
6344 let character_count = 12;
6345 let early = started_at + TOOL_RESULT_SWEEP_DURATION / 4;
6346 let halfway = started_at + TOOL_RESULT_SWEEP_DURATION / 2;
6347 let late = started_at + TOOL_RESULT_SWEEP_DURATION * 3 / 4;
6348
6349 assert_eq!(
6350 cmd_result_color_at(
6351 started_at,
6352 started_at,
6353 0,
6354 character_count,
6355 TOOL_SUCCESS_COLOR,
6356 ),
6357 PENDING_TOOL_COLOR,
6358 );
6359 assert_eq!(TOOL_SUCCESS_COLOR, Color::Rgb(0, 210, 175));
6360
6361 let early_first =
6362 cmd_result_color_at(started_at, early, 0, character_count, TOOL_SUCCESS_COLOR);
6363 assert_ne!(early_first, PENDING_TOOL_COLOR);
6364 assert_ne!(early_first, TOOL_SUCCESS_COLOR);
6365 assert_eq!(
6366 cmd_result_color_at(started_at, early, 5, character_count, TOOL_SUCCESS_COLOR),
6367 PENDING_TOOL_COLOR,
6368 "later characters wait while the first character cross-fades"
6369 );
6370
6371 assert_eq!(
6372 cmd_result_color_at(started_at, halfway, 0, character_count, TOOL_SUCCESS_COLOR),
6373 TOOL_SUCCESS_COLOR,
6374 );
6375 let halfway_middle =
6376 cmd_result_color_at(started_at, halfway, 5, character_count, TOOL_SUCCESS_COLOR);
6377 assert_ne!(halfway_middle, PENDING_TOOL_COLOR);
6378 assert_ne!(halfway_middle, TOOL_SUCCESS_COLOR);
6379 assert_eq!(
6380 cmd_result_color_at(
6381 started_at,
6382 halfway,
6383 character_count - 1,
6384 character_count,
6385 TOOL_SUCCESS_COLOR,
6386 ),
6387 PENDING_TOOL_COLOR,
6388 );
6389
6390 let late_last = cmd_result_color_at(
6391 started_at,
6392 late,
6393 character_count - 1,
6394 character_count,
6395 TOOL_SUCCESS_COLOR,
6396 );
6397 assert_ne!(late_last, PENDING_TOOL_COLOR);
6398 assert_ne!(late_last, TOOL_SUCCESS_COLOR);
6399 assert_eq!(
6400 cmd_result_color_at(
6401 started_at,
6402 started_at + TOOL_RESULT_SWEEP_DURATION,
6403 character_count - 1,
6404 character_count,
6405 TOOL_SUCCESS_COLOR,
6406 ),
6407 TOOL_SUCCESS_COLOR,
6408 "the completed sweep keeps the exact teal used during the fade"
6409 );
6410 }
6411
6412 #[test]
6413 fn cmd_result_cross_fade_has_no_abrupt_color_change_between_render_ticks() {
6414 let started_at = Instant::now();
6415 let character_count = 12;
6416 let render_ticks = TOOL_RESULT_SWEEP_DURATION.as_millis() / EVENT_POLL.as_millis();
6417
6418 for target in [TOOL_SUCCESS_COLOR, TOOL_FAILURE_COLOR, TOOL_WARNING_COLOR] {
6419 for character_index in 0..character_count {
6420 let frames = (0..=render_ticks)
6421 .map(|tick| {
6422 cmd_result_color_at(
6423 started_at,
6424 started_at + EVENT_POLL * tick as u32,
6425 character_index,
6426 character_count,
6427 target,
6428 )
6429 })
6430 .collect::<Vec<_>>();
6431
6432 assert!(frames
6433 .iter()
6434 .any(|color| { *color != PENDING_TOOL_COLOR && *color != target }));
6435 assert!(frames.windows(2).all(|pair| {
6436 let (before_red, before_green, before_blue) = tool_result_color_rgb(pair[0]);
6437 let (after_red, after_green, after_blue) = tool_result_color_rgb(pair[1]);
6438 before_red.abs_diff(after_red) <= 45
6439 && before_green.abs_diff(after_green) <= 45
6440 && before_blue.abs_diff(after_blue) <= 45
6441 }));
6442 assert_eq!(frames.last(), Some(&target));
6443 }
6444 }
6445 }
6446
6447 #[test]
6448 fn only_live_cmd_results_start_a_result_sweep() {
6449 let mut state = UiState::from_history(&[], "secret", "model", None, false);
6450 let succeeded = serde_json::json!({"exit_code": 0});
6451
6452 state.add_tool_result("historic", "cmd", succeeded.clone());
6453 state.add_live_tool_result("success", "cmd", succeeded);
6454 state.add_live_tool_result("failed", "cmd", serde_json::json!({"exit_code": 1}));
6455
6456 assert!(!state.cmd_result_started_at.contains_key("historic"));
6457 assert!(state.cmd_result_started_at.contains_key("success"));
6458 assert!(state.cmd_result_started_at.contains_key("failed"));
6459 }
6460
6461 #[test]
6462 fn failed_cmd_cross_fades_to_the_same_rgb_red_without_a_final_jump() {
6463 let started_at = Instant::now();
6464 let character_count = 12;
6465 let halfway = started_at + TOOL_RESULT_SWEEP_DURATION / 2;
6466
6467 assert_eq!(
6468 cmd_result_color_at(
6469 started_at,
6470 started_at,
6471 0,
6472 character_count,
6473 TOOL_FAILURE_COLOR,
6474 ),
6475 PENDING_TOOL_COLOR,
6476 );
6477 assert_eq!(
6478 cmd_result_color_at(started_at, halfway, 0, character_count, TOOL_FAILURE_COLOR),
6479 TOOL_FAILURE_COLOR,
6480 );
6481 let intermediate =
6482 cmd_result_color_at(started_at, halfway, 5, character_count, TOOL_FAILURE_COLOR);
6483 assert_ne!(intermediate, PENDING_TOOL_COLOR);
6484 assert_ne!(intermediate, TOOL_FAILURE_COLOR);
6485 assert_eq!(
6486 cmd_result_color_at(
6487 started_at,
6488 halfway,
6489 character_count - 1,
6490 character_count,
6491 TOOL_FAILURE_COLOR,
6492 ),
6493 PENDING_TOOL_COLOR,
6494 );
6495 assert_eq!(
6496 cmd_result_color_at(
6497 started_at,
6498 started_at + TOOL_RESULT_SWEEP_DURATION,
6499 character_count - 1,
6500 character_count,
6501 TOOL_FAILURE_COLOR,
6502 ),
6503 TOOL_FAILURE_COLOR,
6504 "the completed failure sweep keeps the exact RGB red used during the fade"
6505 );
6506 }
6507
6508 #[test]
6509 fn live_failed_cmd_sweep_keeps_the_final_status_text() {
6510 let mut state = UiState::from_history(&[], "secret", "model", None, false);
6511 let result = serde_json::json!({"exit_code": 1});
6512 state.add_live_tool_result("failed", "cmd", result.clone());
6513
6514 let segments = cmd_tool_segments("failed", r#"{"command":"bad"}"#, Some(&result), &state);
6515 let text = segments
6516 .iter()
6517 .map(|(text, _)| text.as_str())
6518 .collect::<String>();
6519
6520 assert_eq!(text, "× cmd $ bad → exit 1");
6521 }
6522
6523 #[test]
6524 fn cmd_result_target_colors_follow_the_final_status() {
6525 assert_eq!(
6526 cmd_result_target_color(&serde_json::json!({"exit_code": 0})),
6527 TOOL_SUCCESS_COLOR
6528 );
6529 assert_eq!(
6530 cmd_result_target_color(&serde_json::json!({"exit_code": 1})),
6531 TOOL_FAILURE_COLOR
6532 );
6533 assert_eq!(
6534 cmd_result_target_color(&serde_json::json!({"timed_out": true})),
6535 TOOL_WARNING_COLOR
6536 );
6537 }
6538
6539 #[test]
6540 fn cmd_status_distinguishes_nonzero_exit_timeout_and_cancellation() {
6541 let cases = [
6542 (
6543 serde_json::json!({"exit_code": 127}),
6544 "× cmd $ bad → exit 127",
6545 ),
6546 (
6547 serde_json::json!({"timed_out": true, "exit_code": null}),
6548 "! cmd $ slow → timeout",
6549 ),
6550 (
6551 serde_json::json!({"canceled": true}),
6552 "! cmd $ stop → canceled",
6553 ),
6554 ];
6555 for (result, expected) in cases {
6556 let history = vec![
6557 SessionHistoryRecord::Message {
6558 timestamp: 1,
6559 message: ChatMessage::assistant(
6560 String::new(),
6561 vec![crate::model::ChatToolCall {
6562 id: "call-1".to_owned(),
6563 name: "cmd".to_owned(),
6564 arguments: serde_json::json!({"command": expected.split("$ ").nth(1).unwrap().split(" ").next().unwrap()}).to_string(),
6565 }],
6566 ),
6567 },
6568 SessionHistoryRecord::Message {
6569 timestamp: 2,
6570 message: ChatMessage::tool(
6571 "call-1".to_owned(),
6572 "cmd".to_owned(),
6573 result.to_string(),
6574 ),
6575 },
6576 ];
6577 let state = UiState::from_history(&history, "secret", "model", None, false);
6578 assert_eq!(transcript_lines(&state, 80)[0].to_string(), expected);
6579 }
6580 }
6581
6582 #[test]
6583 fn cmd_line_truncates_long_commands_but_never_renders_output() {
6584 let command = "a".repeat(120);
6585 let arguments = serde_json::json!({"command": command}).to_string();
6586 let history = vec![
6587 SessionHistoryRecord::Message {
6588 timestamp: 1,
6589 message: ChatMessage::assistant(
6590 String::new(),
6591 vec![crate::model::ChatToolCall {
6592 id: "call-1".to_owned(),
6593 name: "cmd".to_owned(),
6594 arguments,
6595 }],
6596 ),
6597 },
6598 SessionHistoryRecord::Message {
6599 timestamp: 2,
6600 message: ChatMessage::tool(
6601 "call-1".to_owned(),
6602 "cmd".to_owned(),
6603 serde_json::json!({"exit_code": 0, "stdout": "output"}).to_string(),
6604 ),
6605 },
6606 ];
6607 let state = UiState::from_history(&history, "secret", "model", None, false);
6608 let text = transcript_lines(&state, 200)[0].to_string();
6609 assert!(text.contains(&format!("$ {}…", "a".repeat(100))));
6610 assert!(!text.contains(&"a".repeat(101)));
6611 assert!(!text.contains("output"));
6612 }
6613
6614 #[test]
6615 fn cmd_lines_remain_compact_for_consecutive_calls() {
6616 let history = vec![
6617 SessionHistoryRecord::Message {
6618 timestamp: 1,
6619 message: ChatMessage::assistant(
6620 String::new(),
6621 vec![
6622 crate::model::ChatToolCall {
6623 id: "call-first".to_owned(),
6624 name: "cmd".to_owned(),
6625 arguments: r#"{"command":"first"}"#.to_owned(),
6626 },
6627 crate::model::ChatToolCall {
6628 id: "call-second".to_owned(),
6629 name: "cmd".to_owned(),
6630 arguments: r#"{"command":"second"}"#.to_owned(),
6631 },
6632 ],
6633 ),
6634 },
6635 SessionHistoryRecord::Message {
6636 timestamp: 2,
6637 message: ChatMessage::tool(
6638 "call-first".to_owned(),
6639 "cmd".to_owned(),
6640 serde_json::json!({"exit_code": 0}).to_string(),
6641 ),
6642 },
6643 SessionHistoryRecord::Message {
6644 timestamp: 3,
6645 message: ChatMessage::tool(
6646 "call-second".to_owned(),
6647 "cmd".to_owned(),
6648 serde_json::json!({"exit_code": 0}).to_string(),
6649 ),
6650 },
6651 ];
6652 let state = UiState::from_history(&history, "secret", "model", None, false);
6653 let lines = transcript_lines(&state, 200);
6654 assert_eq!(lines[0].to_string(), "✓ cmd $ first");
6655 assert_eq!(lines[2].to_string(), "✓ cmd $ second");
6656 }
6657
6658 #[test]
6659 fn cmd_status_styles_use_success_failure_and_pending_colors() {
6660 assert_eq!(
6661 cmd_result_status(&serde_json::json!({"exit_code": 0})).2.fg,
6662 Some(TOOL_SUCCESS_COLOR)
6663 );
6664 assert_eq!(
6665 cmd_result_status(&serde_json::json!({"exit_code": 1})).2.fg,
6666 Some(TOOL_FAILURE_COLOR)
6667 );
6668 assert_eq!(
6669 cmd_tool_segments(
6670 "call-1",
6671 "{\"command\":\"pwd\"}",
6672 None,
6673 &UiState::from_history(&[], "secret", "model", None, false)
6674 )[0]
6675 .1
6676 .fg,
6677 Some(PENDING_TOOL_COLOR)
6678 );
6679 }
6680
6681 #[test]
6682 fn recognized_skill_trigger_is_highlighted_but_arguments_remain_default_colored() {
6683 let trigger = active_skill_trigger("/release-notes v1.2.0", &["release-notes".to_owned()]);
6684 assert_eq!(trigger, Some("/release-notes"));
6685 assert_eq!(SKILL_TRIGGER_COLOR, Color::Rgb(80, 255, 245));
6686
6687 let lines = styled_text_lines(
6688 "/release-notes v1.2.0",
6689 trigger,
6690 80,
6691 Style::default().fg(Color::White),
6692 );
6693 assert_eq!(lines.len(), 1);
6694 assert_eq!(lines[0].to_string(), "/release-notes v1.2.0");
6695 assert_eq!(lines[0].spans[0].content, "/release-notes");
6696 assert_eq!(lines[0].spans[0].style.fg, Some(SKILL_TRIGGER_COLOR));
6697 assert_eq!(lines[0].spans[1].content, " v1.2.0");
6698 assert_eq!(lines[0].spans[1].style.fg, Some(Color::White));
6699 }
6700
6701 #[test]
6702 fn draw_renders_an_active_skill_trigger_in_cyan() {
6703 let mut state = UiState::from_history(&[], "secret", "model", None, false)
6704 .with_skill_names(vec!["release-notes".to_owned()]);
6705 state.input = "/release-notes v1.2.0".to_owned();
6706 state.cursor = state.input.chars().count();
6707
6708 let mut terminal =
6709 Terminal::new(ratatui::backend::TestBackend::new(40, 10)).expect("test terminal");
6710 terminal
6711 .draw(|frame| draw(frame, &state))
6712 .expect("draw input");
6713
6714 let buffer = terminal.backend().buffer();
6717 let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 40, 10)));
6718 let prompt_area = prompt_area(input_area, &state);
6719 let input_x = prompt_area.x;
6720 let input_y = prompt_area.y;
6721 assert_eq!(buffer[(input_x, input_y)].fg, SKILL_TRIGGER_COLOR);
6722 assert_eq!(
6723 buffer[(input_x + "/release-notes".chars().count() as u16, input_y)].fg,
6724 Color::White
6725 );
6726 }
6727
6728 #[test]
6729 fn main_agent_status_omits_activity_animation_on_idle_and_busy_glass() {
6730 let mut state =
6731 UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
6732 let area = Rect::new(0, 0, 80, 10);
6733 let mut terminal =
6734 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
6735 .expect("test terminal");
6736
6737 terminal
6738 .draw(|frame| draw(frame, &state))
6739 .expect("draw ready status");
6740 let viewport = tui_viewport(area);
6741 let status_area = ui_layout(&state, viewport).5;
6742 let idle = "model · default | Context: 81/100 (81%) █████████░";
6743 let buffer = terminal.backend().buffer();
6744 let idle_columns = status_area.x..status_area.x + idle.chars().count() as u16;
6745 let idle_row = idle_columns
6746 .clone()
6747 .map(|x| buffer[(x, status_area.y)].symbol())
6748 .collect::<String>();
6749 assert_eq!(idle_row, idle);
6750 for x in idle_columns {
6751 assert_eq!(buffer[(x, status_area.y)].fg, Color::Rgb(144, 144, 148));
6752 }
6753
6754 state.set_status("working");
6755 state.busy = true;
6756 state.activity_transition = None;
6757 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
6758 terminal
6759 .draw(|frame| draw(frame, &state))
6760 .expect("draw working status");
6761 let status_area = ui_layout(&state, viewport).5;
6762 let expected = "model · default | Context: 81/100 (81%) █████████░";
6763 let buffer = terminal.backend().buffer();
6764 let status_columns = status_area.x..status_area.x + expected.chars().count() as u16;
6765 let rendered = status_columns
6766 .clone()
6767 .map(|x| buffer[(x, status_area.y)].symbol())
6768 .collect::<String>();
6769 assert_eq!(rendered, expected);
6770 assert!(
6771 status_columns
6772 .clone()
6773 .any(|x| buffer[(x, status_area.y)].bg != CONSOLE_BACKGROUND),
6774 "the busy status line renders over bright glass"
6775 );
6776 for x in status_columns {
6777 assert_eq!(buffer[(x, status_area.y)].fg, Color::Rgb(144, 144, 148));
6778 }
6779 }
6780
6781 #[test]
6782 fn terminal_focus_events_control_cursor_visibility() {
6783 let mut state = UiState::from_history(&[], "secret", "model", None, false);
6784
6785 assert!(handle_terminal_focus_event(&mut state, &Event::FocusLost));
6786 assert!(!state.terminal_focused);
6787 assert!(handle_terminal_focus_event(&mut state, &Event::FocusGained));
6788 assert!(state.terminal_focused);
6789 assert!(!handle_terminal_focus_event(
6790 &mut state,
6791 &Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
6792 ));
6793 assert!(state.terminal_focused);
6794 }
6795
6796 #[test]
6797 fn unfocused_busy_glow_keeps_the_hardware_cursor_hidden() {
6798 let mut state = UiState::from_history(&[], "secret", "model", None, false);
6799 state.set_status("working");
6800 state.set_busy(true);
6801 state.terminal_focused = false;
6802 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
6803
6804 let mut terminal =
6805 Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
6806 terminal
6807 .draw(|frame| draw(frame, &state))
6808 .expect("draw busy glow");
6809
6810 assert!(
6811 !terminal.backend().cursor_visible(),
6812 "the glow redraw must not re-show the terminal cursor"
6813 );
6814 }
6815
6816 #[test]
6817 fn cjk_input_keeps_the_terminal_cursor_in_the_prompt_without_resetting_activity() {
6818 let mut state = UiState::from_history(&[], "secret", "model", None, false);
6819 state.set_status("working");
6820 state.busy = true;
6821 state.input = "한글".to_owned();
6822 state.cursor = state.input.chars().count();
6823 let activity_started_at = state.activity_started_at;
6824 let tool_animation_epoch = state.tool_animation_epoch;
6825 let sample_at = Instant::now();
6826 let activity_before = state.activity_levels_at(sample_at);
6827
6828 state.input_changed();
6831 assert_eq!(state.activity_started_at, activity_started_at);
6832 assert_eq!(state.tool_animation_epoch, tool_animation_epoch);
6833 assert_eq!(state.activity_levels_at(sample_at), activity_before);
6834
6835 let area = Rect::new(0, 0, 80, 10);
6836 let mut terminal =
6837 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
6838 .expect("test terminal");
6839 terminal
6840 .draw(|frame| draw(frame, &state))
6841 .expect("draw CJK input while working");
6842 let (_, _, _, _, input_area, status_area) = ui_layout(&state, tui_viewport(area));
6843 assert_ne!(input_area.y, status_area.y);
6844 let prompt_area = prompt_area(input_area, &state);
6845 assert!(terminal.backend().cursor_visible());
6846 terminal.backend_mut().assert_cursor_position((
6847 prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
6848 prompt_area.y,
6849 ));
6850 }
6851
6852 #[test]
6853 fn transcript_and_console_are_separated_by_one_blank_row() {
6854 let state = UiState::from_history(&[], "secret", "model", None, false);
6855 let area = Rect::new(0, 0, 80, 10);
6856 let mut terminal =
6857 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
6858 .expect("test terminal");
6859
6860 terminal
6861 .draw(|frame| draw(frame, &state))
6862 .expect("draw separated transcript and console");
6863
6864 let (transcript, _, _, _, console, _) = ui_layout(&state, tui_viewport(area));
6865 assert_eq!(transcript.y + transcript.height + 1, console.y);
6866 let gap_y = console.y - 1;
6867 for x in transcript.x..transcript.x + transcript.width {
6868 assert_eq!(terminal.backend().buffer()[(x, gap_y)].symbol(), " ");
6869 assert_eq!(terminal.backend().buffer()[(x, gap_y)].bg, Color::Reset);
6870 }
6871 }
6872
6873 #[test]
6874 fn idle_console_has_external_gutters_uniform_background_and_no_borders() {
6875 let mut state = UiState::from_history(&[], "secret", "model", None, false);
6876 state.input = "prompt".to_owned();
6877 state.cursor = state.input.chars().count();
6878 let area = Rect::new(0, 0, 80, 10);
6879 let mut terminal =
6880 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
6881 .expect("test terminal");
6882
6883 terminal
6884 .draw(|frame| draw(frame, &state))
6885 .expect("draw idle console");
6886
6887 let input_area = ui_layout(&state, tui_viewport(area)).4;
6888 let prompt_area = prompt_area(input_area, &state);
6889 let buffer = terminal.backend().buffer();
6890 let bottom_y = area.y + area.height - 1;
6891 assert_eq!(buffer[(area.x, bottom_y)].bg, Color::Reset);
6892 assert_eq!(buffer[(area.x + area.width - 1, bottom_y)].bg, Color::Reset);
6893 for y in input_area.y..input_area.y + input_area.height {
6894 assert_eq!(buffer[(0, y)].bg, Color::Reset);
6895 assert_eq!(buffer[(79, y)].bg, Color::Reset);
6896 for x in input_area.x..input_area.x + input_area.width {
6897 assert_eq!(buffer[(x, y)].bg, CONSOLE_BACKGROUND);
6898 }
6899 }
6900 assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), " ");
6901 assert_eq!(
6902 buffer[(input_area.x + input_area.width - 1, input_area.y)].symbol(),
6903 " "
6904 );
6905 assert_eq!(
6906 buffer[(input_area.x, input_area.y + input_area.height - 1)].symbol(),
6907 " "
6908 );
6909 assert_eq!(
6910 buffer[(
6911 input_area.x + input_area.width - 1,
6912 input_area.y + input_area.height - 1
6913 )]
6914 .symbol(),
6915 " "
6916 );
6917 assert_eq!(buffer[(prompt_area.x, prompt_area.y)].symbol(), "p");
6918 assert_eq!(buffer[(prompt_area.x, prompt_area.y)].fg, Color::White);
6919 terminal.backend_mut().assert_cursor_position((
6920 prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
6921 prompt_area.y,
6922 ));
6923 }
6924
6925 #[test]
6926 fn busy_console_keeps_glass_inside_the_bottom_half_ellipse() {
6927 let mut state = UiState::from_history(&[], "secret", "model", None, false);
6928 state.busy = true;
6929 state.set_status("working");
6930 state.activity_transition = None;
6931 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
6932 let area = Rect::new(0, 0, 200, 10);
6933 let mut terminal =
6934 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
6935 .expect("test terminal");
6936
6937 terminal
6938 .draw(|frame| draw(frame, &state))
6939 .expect("draw busy console");
6940
6941 let viewport = tui_viewport(area);
6942 let (chat_area, _, _, _, input_area, _) = ui_layout(&state, viewport);
6943 let reflection_y = input_area.y - 1;
6944 let glow_floor_y = area.y + area.height - 1;
6945 let edge_y = input_area.y + input_area.height - 1;
6946 let floor = input_area.x + input_area.width / 2;
6947 let left_edge = input_area.x;
6948 let left_outer = left_edge - 1;
6949 let right_edge = input_area.x + input_area.width - 1;
6950 let right_outer = right_edge + 1;
6951 let widening_sample = input_area.x - 22;
6952 let buffer = terminal.backend().buffer();
6953 assert_eq!(chat_area.y + chat_area.height + 1, input_area.y);
6954 assert_eq!(
6955 buffer[(floor, reflection_y)].symbol(),
6956 CONSOLE_REFLECTION_GLYPH,
6957 "the active console reflects along the bottom of the existing gap row"
6958 );
6959 assert_ne!(buffer[(floor, reflection_y)].fg, Color::Reset);
6960 assert_ne!(buffer[(floor, glow_floor_y)].bg, Color::Reset);
6961 assert_ne!(buffer[(left_outer, edge_y)].bg, Color::Reset);
6962 assert_ne!(buffer[(right_outer, edge_y)].bg, Color::Reset);
6963 let left_extent = left_edge - GLOW_HORIZONTAL_SPREAD;
6964 let right_extent = right_edge + GLOW_HORIZONTAL_SPREAD;
6965 assert_ne!(buffer[(left_extent, glow_floor_y)].bg, Color::Reset);
6966 assert_ne!(buffer[(right_extent, glow_floor_y)].bg, Color::Reset);
6967 assert_eq!(buffer[(left_extent - 1, glow_floor_y)].bg, Color::Reset);
6968 assert_eq!(buffer[(right_extent + 1, glow_floor_y)].bg, Color::Reset);
6969 assert_eq!(buffer[(widening_sample, input_area.y)].bg, Color::Reset);
6970 assert_ne!(buffer[(widening_sample, glow_floor_y)].bg, Color::Reset);
6971 assert_eq!(buffer[(area.x, glow_floor_y)].bg, Color::Reset);
6972 for y in input_area.y..input_area.y + input_area.height {
6973 for x in input_area.x..input_area.x + input_area.width {
6974 assert_ne!(buffer[(x, y)].bg, Color::Reset);
6975 }
6976 }
6977 for y in input_area.y..input_area.y + input_area.height {
6978 for x in input_area.x..input_area.x + input_area.width {
6979 assert_ne!(buffer[(x, y)].bg, Color::Reset);
6980 assert!(
6981 color_distance(CONSOLE_BACKGROUND, buffer[(x, y)].bg)
6982 < color_distance(TUI_GLOW_BACKGROUND, buffer[(floor, glow_floor_y)].bg)
6983 );
6984 }
6985 }
6986 }
6987
6988 #[test]
6989 fn narrow_busy_console_keeps_both_endpoint_blooms_exposed() {
6990 let mut state = UiState::from_history(&[], "secret", "model", None, false);
6991 state.busy = true;
6992 state.set_status("working");
6993 state.activity_transition = None;
6994 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
6995 let area = Rect::new(0, 0, 10, 10);
6996 let mut terminal =
6997 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
6998 .expect("test terminal");
6999
7000 terminal
7001 .draw(|frame| draw(frame, &state))
7002 .expect("draw narrow TUI");
7003
7004 let input = ui_layout(&state, tui_viewport(area)).4;
7005 assert_eq!(input.width, 4, "test the minimum inset console width");
7006 let left_edge = input.x;
7007 let right_edge = input.x + input.width - 1;
7008 let buffer = terminal.backend().buffer();
7009 for y in input.y..input.y + input.height {
7010 for (edge, outer) in [(left_edge, left_edge - 1), (right_edge, right_edge + 1)] {
7011 assert_ne!(
7012 buffer[(outer, y)].bg,
7013 Color::Reset,
7014 "the outer glow cell is present at ({outer}, {y})"
7015 );
7016 assert_ne!(
7017 buffer[(edge, y)].bg,
7018 buffer[(outer, y)].bg,
7019 "narrow console glass does not spill into the exposed bloom at ({edge}, {y})"
7020 );
7021 }
7022 }
7023 }
7024
7025 #[test]
7026 fn busy_queue_and_subagent_rows_preserve_the_console_glow_surface() {
7027 let mut state = UiState::from_history(&[], "secret", "model", None, false);
7028 state.busy = true;
7029 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
7030 state.queue_user("send later");
7031 state.subagents.push(SubagentTask {
7032 call_id: "call-worker".to_owned(),
7033 task_id: Some("subagent-1".to_owned()),
7034 task: "Inspect".to_owned(),
7035 model: None,
7036 effort: None,
7037 status: SubagentStatus::Running,
7038 result: None,
7039 creation_completed: true,
7040 stream: Vec::new(),
7041 stream_chars: 0,
7042 });
7043 let area = Rect::new(0, 0, 80, 18);
7044 let mut terminal =
7045 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
7046 .expect("test terminal");
7047
7048 terminal
7049 .draw(|frame| draw(frame, &state))
7050 .expect("draw busy TUI");
7051
7052 let viewport = tui_viewport(area);
7053 let (_, _, _, queue, input, _) = ui_layout(&state, viewport);
7054 let list = subagent_list_area(&state, input).expect("subagent list");
7055 let elapsed = state.console_animation_elapsed_at(Instant::now());
7056 let visibility = state.console_visibility_at(Instant::now());
7057 let buffer = terminal.backend().buffer();
7058 for section in [queue.expect("message queue"), list] {
7059 let x = section.x + section.width / 2;
7060 let y = section.y;
7061 let glow = glow_color_at(elapsed, x, area.width, y, area, input, visibility)
7062 .unwrap_or(TUI_GLOW_BACKGROUND);
7063 let expected = console_glass_color_at(elapsed, glow, visibility);
7064 assert!(
7065 color_distance(buffer[(x, y)].bg, expected) <= 3,
7066 "section ({x}, {y}) must retain the same glass surface as the console"
7067 );
7068 }
7069 }
7070
7071 #[test]
7072 fn only_known_leading_skill_commands_activate_input_highlighting() {
7073 let skills = ["release-notes".to_owned()];
7074 assert_eq!(
7075 active_skill_trigger("/missing", &skills),
7076 None,
7077 "unknown commands are rejected by the turn engine and must not look active"
7078 );
7079 assert_eq!(
7080 active_skill_trigger("/skill:release-notes", &skills),
7081 None,
7082 "the removed /skill: wrapper must not look active"
7083 );
7084 assert_eq!(
7085 active_skill_trigger("write /release-notes", &skills),
7086 None,
7087 "only the command prefix accepted by the turn engine is active"
7088 );
7089 assert_eq!(active_skill_trigger("/", &skills), None);
7090 }
7091
7092 #[test]
7093 fn highlighted_skill_trigger_remains_styled_when_wrapped() {
7094 let input = "/release-notes argument";
7095 let trigger = active_skill_trigger(input, &["release-notes".to_owned()]);
7096 let lines = styled_text_lines(input, trigger, 8, Style::default().fg(Color::White));
7097 let highlighted = lines
7098 .iter()
7099 .flat_map(|line| line.spans.iter())
7100 .filter(|span| span.style.fg == Some(SKILL_TRIGGER_COLOR))
7101 .map(|span| span.content.as_ref())
7102 .collect::<String>();
7103 assert_eq!(highlighted, "/release-notes");
7104 }
7105
7106 #[test]
7107 fn input_has_no_prompt_marker_and_trailing_newline_is_visible() {
7108 assert_eq!(input_prompt("hello"), "hello");
7109 assert_eq!(wrap_text("hello\n", 80), vec!["hello", ""]);
7110 }
7111
7112 #[test]
7113 fn input_prompt_wraps_to_multiple_rows_when_long() {
7114 let mut state = UiState::from_history(&[], "secret", "model", None, false);
7115 state.input = "abcdefghij".to_owned();
7116 let rows = input_visible_rows(&state, 5);
7118 assert!(rows >= 2);
7119 }
7120
7121 #[test]
7122 fn cursor_editing_moves_by_characters_and_preserves_unicode() {
7123 let mut input = "가나".to_owned();
7124 let mut cursor = input.chars().count();
7125 cursor -= 1;
7126 insert_at_cursor(&mut input, &mut cursor, 'x');
7127 assert_eq!(input, "가x나");
7128 assert_eq!(cursor, 2);
7129 assert!(remove_before_cursor(&mut input, &mut cursor));
7130 assert_eq!(input, "가나");
7131 assert_eq!(cursor, 1);
7132 }
7133
7134 #[test]
7135 fn cursor_row_tracks_newlines_and_wrapping() {
7136 assert_eq!(cursor_row("hello\nworld", 6, 80), 1);
7137 assert_eq!(cursor_row("abcdef", 4, 3), 1);
7138 }
7139
7140 #[test]
7141 fn shift_enter_inserts_at_the_cursor_and_moves_it_to_the_new_row() {
7142 let mut input = "beforeafter".to_owned();
7143 let mut cursor = 6;
7144 insert_at_cursor(&mut input, &mut cursor, '\n');
7145
7146 assert_eq!(input, "before\nafter");
7147 assert_eq!(cursor, 7);
7148 assert_eq!(cursor_row(&input, cursor, 80), 1);
7149 }
7150
7151 #[test]
7152 fn shift_enter_renders_the_cursor_on_the_new_input_row() {
7153 let mut state = UiState::from_history(&[], "secret", "model", None, false);
7154 state.input = "beforeafter".to_owned();
7155 state.cursor = 6;
7156 insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
7157
7158 let mut terminal =
7159 Terminal::new(ratatui::backend::TestBackend::new(20, 10)).expect("test terminal");
7160 terminal
7161 .draw(|frame| draw(frame, &state))
7162 .expect("draw input cursor");
7163
7164 let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 20, 10)));
7167 let prompt_area = prompt_area(input_area, &state);
7168 terminal
7169 .backend_mut()
7170 .assert_cursor_position((prompt_area.x, prompt_area.y + 1));
7171 }
7172
7173 #[test]
7174 fn tool_results_attach_to_their_matching_call_after_consecutive_calls() {
7175 let history = vec![
7176 SessionHistoryRecord::Message {
7177 timestamp: 1,
7178 message: ChatMessage::assistant(
7179 String::new(),
7180 vec![
7181 crate::model::ChatToolCall {
7182 id: "call-first".to_owned(),
7183 name: "cmd".to_owned(),
7184 arguments: r#"{"command":"first"}"#.to_owned(),
7185 },
7186 crate::model::ChatToolCall {
7187 id: "call-second".to_owned(),
7188 name: "cmd".to_owned(),
7189 arguments: r#"{"command":"second"}"#.to_owned(),
7190 },
7191 ],
7192 ),
7193 },
7194 SessionHistoryRecord::Message {
7195 timestamp: 2,
7196 message: ChatMessage::tool(
7197 "call-first".to_owned(),
7198 "cmd".to_owned(),
7199 serde_json::json!({"stdout":"first result","stderr":""}).to_string(),
7200 ),
7201 },
7202 SessionHistoryRecord::Message {
7203 timestamp: 3,
7204 message: ChatMessage::tool(
7205 "call-second".to_owned(),
7206 "cmd".to_owned(),
7207 serde_json::json!({"stdout":"second result","stderr":""}).to_string(),
7208 ),
7209 },
7210 ];
7211
7212 let state = UiState::from_history(&history, "secret", "model", None, false);
7213 let lines = transcript_lines(&state, 200);
7214 assert_eq!(
7215 lines.len(),
7216 3,
7217 "only the two call lines and their separator remain"
7218 );
7219 assert_eq!(lines[0].to_string(), "✓ cmd $ first");
7220 assert_eq!(lines[2].to_string(), "✓ cmd $ second");
7221 }
7222 #[test]
7223 fn subagent_tasks_keep_metadata_until_completion_then_leave_live_list() {
7224 let history = vec![
7225 SessionHistoryRecord::Message {
7226 timestamp: 1,
7227 message: ChatMessage::assistant(
7228 String::new(),
7229 vec![crate::model::ChatToolCall {
7230 id: "call-worker".to_owned(),
7231 name: "spawn_subagent".to_owned(),
7232 arguments: serde_json::json!({
7233 "task": "Inspect the command UI"
7234 })
7235 .to_string(),
7236 }],
7237 ),
7238 },
7239 SessionHistoryRecord::Message {
7240 timestamp: 2,
7241 message: ChatMessage::tool(
7242 "call-worker".to_owned(),
7243 "spawn_subagent".to_owned(),
7244 serde_json::json!({"task_id":"subagent-1","status":"queued"}).to_string(),
7245 ),
7246 },
7247 ];
7248 let mut state =
7249 UiState::from_history(&history, "secret", "worker-model", Some("high"), false);
7250 assert_eq!(state.subagents.len(), 1);
7251 let task = &state.subagents[0];
7252 assert_eq!(task.task, "Inspect the command UI");
7253 assert_eq!(task.task_id.as_deref(), Some("subagent-1"));
7254 assert_eq!(task.model.as_deref(), Some("worker-model"));
7255 assert_eq!(task.effort.as_deref(), Some("high"));
7256 assert_eq!(task.status, SubagentStatus::Running);
7257 assert!(state.transcript.is_empty());
7258
7259 state.apply_event(ProtocolEvent::BackgroundResultPending {
7260 completion_id: "completion-1".to_owned(),
7261 task_id: "subagent-1".to_owned(),
7262 child_session_id: "child-1".to_owned(),
7263 status: "completed".to_owned(),
7264 result: serde_json::json!({"model":"worker-model","output":"finished"}),
7265 completed_at: 1,
7266 });
7267 state.apply_event(ProtocolEvent::BackgroundResultDelivered {
7268 completion_id: "completion-1".to_owned(),
7269 task_id: "subagent-1".to_owned(),
7270 logical_turn_id: "turn-1".to_owned(),
7271 delivery: "synthetic".to_owned(),
7272 });
7273 assert!(
7274 state.subagents.is_empty(),
7275 "completed workers are removed from the live background-task list"
7276 );
7277 let rendered = transcript_lines(&state, 100)
7278 .iter()
7279 .map(Line::to_string)
7280 .collect::<Vec<_>>();
7281 assert!(rendered.iter().any(|line| {
7282 line.contains("subagent-1")
7283 && line.contains("completed")
7284 && line.contains("result pending")
7285 }));
7286
7287 assert!(rendered
7288 .iter()
7289 .any(|line| { line.contains("subagent-1") && line.contains("result delivered") }));
7290 assert_eq!(
7291 state
7292 .transcript
7293 .iter()
7294 .filter(|item| matches!(item, TranscriptItem::SubagentLifecycle { .. }))
7295 .count(),
7296 2,
7297 "pending and delivered are separate transcript transitions"
7298 );
7299 }
7300
7301 #[test]
7302 fn resumed_subagent_completion_clears_the_live_list_without_rendering_internal_prompt() {
7303 let history = vec![
7304 SessionHistoryRecord::Message {
7305 timestamp: 1,
7306 message: ChatMessage::assistant(
7307 String::new(),
7308 vec![crate::model::ChatToolCall {
7309 id: "call-worker".to_owned(),
7310 name: "spawn_subagent".to_owned(),
7311 arguments: serde_json::json!({"task":"Inspect"}).to_string(),
7312 }],
7313 ),
7314 },
7315 SessionHistoryRecord::Message {
7316 timestamp: 2,
7317 message: ChatMessage::tool(
7318 "call-worker".to_owned(),
7319 "spawn_subagent".to_owned(),
7320 serde_json::json!({"task_id":"subagent-1","status":"queued"}).to_string(),
7321 ),
7322 },
7323 SessionHistoryRecord::BackgroundResultPending(
7324 crate::session::BackgroundResultPending {
7325 timestamp: 3,
7326 completion_id: "completion-1".to_owned(),
7327 task_id: "subagent-1".to_owned(),
7328 child_session_id: "child-1".to_owned(),
7329 task: "Inspect".to_owned(),
7330 status: crate::session::ChildSessionStatus::Completed,
7331 result: serde_json::json!({"output":"resumed result"}),
7332 completed_at: 3,
7333 },
7334 ),
7335 ];
7336 let state = UiState::from_history(&history, "secret", "model", None, true);
7337 assert!(
7338 state.subagents.is_empty(),
7339 "a completed worker is not restored into the live background-task list"
7340 );
7341 assert!(!state.transcript.iter().any(|item| {
7342 matches!(item, TranscriptItem::User { text, .. } if text.contains("Background subagent"))
7343 }));
7344 assert!(state.transcript.iter().any(|item| {
7345 matches!(
7346 item,
7347 TranscriptItem::SubagentLifecycle { task_id, status, .. }
7348 if task_id == "subagent-1" && status == "completed"
7349 )
7350 }));
7351 }
7352
7353 #[test]
7354 fn subagent_list_reserves_rows_between_prompt_and_status() {
7355 let mut state = UiState::from_history(&[], "secret", "model", None, false);
7356 state.subagents.push(SubagentTask {
7357 call_id: "call-worker".to_owned(),
7358 task_id: Some("subagent-1".to_owned()),
7359 task: "Inspect the command UI and report findings".to_owned(),
7360 model: Some("worker-model".to_owned()),
7361 effort: Some("high".to_owned()),
7362 status: SubagentStatus::Running,
7363 result: None,
7364 creation_completed: true,
7365 stream: Vec::new(),
7366 stream_chars: 0,
7367 });
7368 let area = Rect::new(0, 0, 80, 20);
7369 let (_, _, _, _, input, status) = ui_layout(&state, area);
7370 let list = subagent_list_area(&state, input).expect("worker list");
7371 let prompt = prompt_area(input, &state);
7372 assert_eq!(prompt.y, input.y + 1);
7373 let (queue_spacer, list_spacer, status_spacer) = console_spacer_rows(&state, input);
7374 assert_eq!(queue_spacer, None);
7375 assert_eq!(list.height, 2);
7376 assert_eq!(list_spacer, Some(prompt.y + prompt.height));
7377 assert_eq!(list.y, list_spacer.expect("list spacer") + 1);
7378 assert_eq!(status_spacer, Some(list.y + list.height));
7379 assert_eq!(status.y, status_spacer.expect("status spacer") + 1);
7380 assert_eq!(status.y + 2, input.y + input.height);
7381
7382 let mut terminal =
7383 Terminal::new(ratatui::backend::TestBackend::new(80, 20)).expect("test terminal");
7384 terminal.draw(|frame| draw(frame, &state)).expect("draw");
7385 let screen = terminal
7386 .backend()
7387 .buffer()
7388 .content()
7389 .iter()
7390 .map(|cell| cell.symbol())
7391 .collect::<String>();
7392 assert!(screen.contains("Subagents"));
7393 assert!(screen.contains("│ subagent-1"));
7394 assert!(!screen.contains("worker-model"));
7395 assert!(!screen.contains("high"));
7396 assert!(screen.contains("Inspect the command UI"));
7397 }
7398
7399 #[test]
7400 fn queued_and_terminal_subagents_do_not_appear_in_the_running_list() {
7401 let mut state = UiState::from_history(&[], "secret", "model", None, false);
7402 state.subagents.push(SubagentTask {
7403 call_id: "call-queued".to_owned(),
7404 task_id: None,
7405 task: "Queued".to_owned(),
7406 model: None,
7407 effort: None,
7408 status: SubagentStatus::Queued,
7409 result: None,
7410 creation_completed: false,
7411 stream: Vec::new(),
7412 stream_chars: 0,
7413 });
7414 state.subagents.push(SubagentTask {
7415 call_id: "call-failed".to_owned(),
7416 task_id: None,
7417 task: "Failed".to_owned(),
7418 model: None,
7419 effort: None,
7420 status: SubagentStatus::Failed,
7421 result: None,
7422 creation_completed: false,
7423 stream: Vec::new(),
7424 stream_chars: 0,
7425 });
7426 assert_eq!(subagent_list_height(&state), 0);
7427 assert!(subagent_list_area(&state, Rect::new(0, 0, 80, 10)).is_none());
7428 assert!(!state.focus_subagent_list_from_input());
7429 }
7430
7431 #[test]
7432 fn subagent_rows_use_stable_id_hash_colors() {
7433 assert_eq!(
7434 subagent_id_color("subagent-1"),
7435 subagent_id_color("subagent-1")
7436 );
7437 assert_ne!(
7438 subagent_id_color("subagent-1"),
7439 subagent_id_color("subagent-2")
7440 );
7441 assert!(SUBAGENT_ID_COLORS.iter().all(|color| {
7442 matches!(color, Color::Rgb(220, green, blue)
7443 if u16::from(*green) < u16::from(*blue)
7444 && u16::from(*blue) < 220
7445 && 4 * (u16::from(*blue) - u16::from(*green))
7446 == 3 * (220 - u16::from(*green)))
7447 }));
7448 assert!(SUBAGENT_ID_COLORS
7449 .windows(2)
7450 .all(|colors| colors[0] != colors[1]));
7451
7452 let mut terminal =
7453 Terminal::new(ratatui::backend::TestBackend::new(80, 2)).expect("test terminal");
7454 let state = UiState::from_history(&[], "secret", "model", None, false);
7455 let mut state = state;
7456 state.subagents.push(SubagentTask {
7457 call_id: "call-worker".to_owned(),
7458 task_id: Some("subagent-1".to_owned()),
7459 task: "Inspect".to_owned(),
7460 model: None,
7461 effort: None,
7462 status: SubagentStatus::Running,
7463 result: None,
7464 creation_completed: true,
7465 stream: Vec::new(),
7466 stream_chars: 0,
7467 });
7468 terminal
7469 .draw(|frame| draw_subagent_list(frame, &state, Rect::new(0, 0, 80, 2)))
7470 .expect("draw subagent list");
7471 assert_eq!(terminal.backend().buffer()[(0, 0)].fg, SUBAGENT_TITLE_COLOR);
7472 assert_eq!(terminal.backend().buffer()[(0, 1)].fg, SUBAGENT_TITLE_COLOR);
7473 assert_eq!(
7474 terminal.backend().buffer()[(2, 1)].fg,
7475 subagent_id_color("subagent-1")
7476 );
7477 assert_eq!(terminal.backend().buffer()[(0, 1)].bg, Color::Reset);
7478 }
7479
7480 #[test]
7481 fn clipped_subagent_list_keeps_the_focused_running_worker_visible() {
7482 let mut state = UiState::from_history(&[], "secret", "model", None, false);
7483 for (index, status) in [
7484 SubagentStatus::Queued,
7485 SubagentStatus::Running,
7486 SubagentStatus::Running,
7487 SubagentStatus::Failed,
7488 SubagentStatus::Running,
7489 ]
7490 .into_iter()
7491 .enumerate()
7492 {
7493 state.subagents.push(SubagentTask {
7494 call_id: format!("call-{index}"),
7495 task_id: Some(format!("subagent-{index}")),
7496 task: format!("task-{index}"),
7497 model: None,
7498 effort: None,
7499 status,
7500 result: None,
7501 creation_completed: status == SubagentStatus::Running,
7502 stream: vec![SubagentStreamItem::Assistant(format!("output-{index}"))],
7503 stream_chars: 8,
7504 });
7505 }
7506 state.subagent_focus = Some(4);
7507
7508 let mut terminal =
7509 Terminal::new(ratatui::backend::TestBackend::new(60, 4)).expect("test terminal");
7510 terminal
7511 .draw(|frame| draw_subagent_list(frame, &state, Rect::new(0, 0, 60, 2)))
7512 .expect("draw clipped subagent list");
7513
7514 let buffer = terminal.backend().buffer();
7515 let rows = (0..2)
7516 .map(|y| (0..60).map(|x| buffer[(x, y)].symbol()).collect::<String>())
7517 .collect::<Vec<_>>();
7518 assert!(rows[0].contains("Subagents"));
7519 assert!(rows[1].contains("subagent-4"));
7520 assert!(buffer[(2, 1)].modifier.contains(Modifier::BOLD));
7521
7522 terminal
7523 .draw(|frame| draw_subagent_stream_overlay(frame, &state, Rect::new(0, 0, 60, 3)))
7524 .expect("draw focused worker overlay");
7525 let screen = terminal
7526 .backend()
7527 .buffer()
7528 .content()
7529 .iter()
7530 .map(|cell| cell.symbol())
7531 .collect::<String>();
7532 assert!(screen.contains("output-4"));
7533 }
7534
7535 #[test]
7536 fn focused_subagent_renders_live_stream_in_picker_slot() {
7537 let mut state = UiState::from_history(&[], "secret", "model", None, false);
7538 state.subagents.push(SubagentTask {
7539 call_id: "call-worker".to_owned(),
7540 task_id: Some("subagent-1".to_owned()),
7541 task: "Inspect".to_owned(),
7542 model: None,
7543 effort: None,
7544 status: SubagentStatus::Running,
7545 result: None,
7546 creation_completed: true,
7547 stream: Vec::new(),
7548 stream_chars: 0,
7549 });
7550 state.apply_subagent_activity(SubagentActivity::Event {
7551 task_id: "subagent-1".to_owned(),
7552 event: ProtocolEvent::AssistantDelta {
7553 text: "worker output".to_owned(),
7554 },
7555 });
7556 assert!(state.focus_subagent_list_from_input());
7557 let area = tui_viewport(Rect::new(0, 0, 80, 30));
7558 let (_, picker, stream, _, input, _) = ui_layout(&state, area);
7559 let stream = stream.expect("stream overlay");
7560 assert_eq!(stream.height, SUBAGENT_STREAM_PREVIEW_HEIGHT);
7561 assert_eq!(stream.y + stream.height, input.y);
7562 assert!(
7563 picker.is_none(),
7564 "focused worker replaces the skill overlay slot"
7565 );
7566
7567 let mut terminal =
7568 Terminal::new(ratatui::backend::TestBackend::new(80, 30)).expect("test terminal");
7569 terminal.draw(|frame| draw(frame, &state)).expect("draw");
7570 let screen = terminal
7571 .backend()
7572 .buffer()
7573 .content()
7574 .iter()
7575 .map(|cell| cell.symbol())
7576 .collect::<String>();
7577 assert!(screen.contains("worker output"));
7578 let buffer = terminal.backend().buffer();
7579 for y in stream.y..stream.y + stream.height {
7580 for x in [
7581 stream.x,
7582 stream.x + 1,
7583 stream.x + stream.width - 2,
7584 stream.x + stream.width - 1,
7585 ] {
7586 assert_eq!(buffer[(x, y)].symbol(), " ");
7587 assert_eq!(buffer[(x, y)].bg, SUBAGENT_OVERLAY_BACKGROUND);
7588 }
7589 }
7590 for x in stream.x..stream.x + stream.width {
7591 assert_eq!(buffer[(x, stream.y)].symbol(), " ");
7592 assert_eq!(buffer[(x, stream.y + stream.height - 1)].symbol(), " ");
7593 }
7594 assert_eq!(buffer[(stream.x + 2, stream.y + 1)].symbol(), "w");
7595 assert_eq!(buffer[(stream.x + 2, stream.y + 1)].fg, Color::Reset);
7596
7597 let narrow_input = Rect::new(0, 4, 80, 2);
7598 assert_eq!(
7599 subagent_stream_overlay_area(&state, narrow_input, 0),
7600 Some(Rect::new(0, 0, 80, 4)),
7601 "only a terminal without 15 rows of space may shrink the preview"
7602 );
7603 }
7604
7605 #[test]
7606 fn subagent_preview_reuses_normalized_events_and_keeps_the_message_stream() {
7607 let mut state = UiState::from_history(&[], "secret", "model", None, false);
7608 state.subagents.push(SubagentTask {
7609 call_id: "call-worker".to_owned(),
7610 task_id: Some("subagent-1".to_owned()),
7611 task: "Inspect".to_owned(),
7612 model: None,
7613 effort: None,
7614 status: SubagentStatus::Running,
7615 result: None,
7616 creation_completed: true,
7617 stream: Vec::new(),
7618 stream_chars: 0,
7619 });
7620
7621 state.apply_subagent_activity(SubagentActivity::Event {
7622 task_id: "subagent-1".to_owned(),
7623 event: ProtocolEvent::AssistantDelta {
7624 text: "first message ".to_owned(),
7625 },
7626 });
7627 state.apply_subagent_activity(SubagentActivity::Event {
7628 task_id: "subagent-1".to_owned(),
7629 event: ProtocolEvent::AssistantDelta {
7630 text: "continued message".to_owned(),
7631 },
7632 });
7633 state.apply_subagent_activity(SubagentActivity::Event {
7634 task_id: "subagent-1".to_owned(),
7635 event: ProtocolEvent::ToolCall {
7636 id: "call-cmd".to_owned(),
7637 name: "cmd".to_owned(),
7638 arguments: serde_json::json!({"command":"pwd"}).to_string(),
7639 },
7640 });
7641 state.apply_subagent_activity(SubagentActivity::Event {
7642 task_id: "subagent-1".to_owned(),
7643 event: ProtocolEvent::ToolResult {
7644 id: "call-cmd".to_owned(),
7645 name: "cmd".to_owned(),
7646 result: serde_json::json!({"stdout":"command output","stderr":""}),
7647 },
7648 });
7649
7650 let task = &state.subagents[0];
7651 let lines = subagent_stream_lines(task, 80, &state);
7652 let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
7653 assert!(rendered.contains("first message continued message"));
7654 assert!(rendered.contains("cmd $ pwd"));
7655 let expected = vec![
7656 TranscriptItem::Assistant("first message continued message".to_owned()),
7657 TranscriptItem::ToolCall {
7658 id: "call-cmd".to_owned(),
7659 name: "cmd".to_owned(),
7660 arguments: serde_json::json!({"command":"pwd"}).to_string(),
7661 },
7662 TranscriptItem::ToolResult {
7663 id: "call-cmd".to_owned(),
7664 name: "cmd".to_owned(),
7665 result: serde_json::json!({"stdout":"command output","stderr":""}),
7666 },
7667 ];
7668 assert_eq!(
7669 lines,
7670 render_transcript_items(&expected, 80, &state, true),
7671 "worker events use the same transcript-item renderer as the main stream"
7672 );
7673 assert_eq!(
7674 task.stream
7675 .iter()
7676 .filter(|item| matches!(item, SubagentStreamItem::Assistant(_)))
7677 .count(),
7678 1,
7679 "assistant deltas remain one message in the preview"
7680 );
7681 }
7682
7683 #[test]
7684 fn oversized_subagent_assistant_stream_keeps_the_latest_tail() {
7685 let mut state = UiState::from_history(&[], "secret", "model", None, false);
7686 state.subagents.push(SubagentTask {
7687 call_id: "call-worker".to_owned(),
7688 task_id: Some("subagent-1".to_owned()),
7689 task: "Inspect".to_owned(),
7690 model: None,
7691 effort: None,
7692 status: SubagentStatus::Running,
7693 result: None,
7694 creation_completed: true,
7695 stream: vec![SubagentStreamItem::User("Inspect".to_owned())],
7696 stream_chars: "Inspect".chars().count(),
7697 });
7698 let tail = "latest assistant output";
7699 state.apply_subagent_activity(SubagentActivity::Event {
7700 task_id: "subagent-1".to_owned(),
7701 event: ProtocolEvent::AssistantDelta {
7702 text: format!("{}{}", "x".repeat(SUBAGENT_STREAM_MAX_CHARS), tail),
7703 },
7704 });
7705
7706 let task = &state.subagents[0];
7707 assert_eq!(task.stream_chars, SUBAGENT_STREAM_MAX_CHARS);
7708 assert!(matches!(
7709 task.stream.as_slice(),
7710 [SubagentStreamItem::Assistant(text)] if text.starts_with('…') && text.ends_with(tail)
7711 ));
7712 let rendered = subagent_stream_lines(task, 80, &state)
7713 .iter()
7714 .map(line_text)
7715 .collect::<Vec<_>>()
7716 .join("\n");
7717 assert!(rendered.contains(tail));
7718 }
7719
7720 #[test]
7721 fn subagent_stream_trimming_keeps_the_tail_across_items() {
7722 let mut state = UiState::from_history(&[], "secret", "model", None, false);
7723 let earlier = "a".repeat(6_000);
7724 let latest = "b".repeat(7_000);
7725 state.subagents.push(SubagentTask {
7726 call_id: "call-worker".to_owned(),
7727 task_id: Some("subagent-1".to_owned()),
7728 task: "Inspect".to_owned(),
7729 model: None,
7730 effort: None,
7731 status: SubagentStatus::Running,
7732 result: None,
7733 creation_completed: true,
7734 stream: vec![SubagentStreamItem::User(earlier)],
7735 stream_chars: 6_000,
7736 });
7737 state.apply_subagent_activity(SubagentActivity::Event {
7738 task_id: "subagent-1".to_owned(),
7739 event: ProtocolEvent::AssistantDelta {
7740 text: latest.clone(),
7741 },
7742 });
7743
7744 let task = &state.subagents[0];
7745 assert_eq!(task.stream_chars, SUBAGENT_STREAM_MAX_CHARS);
7746 assert!(matches!(
7747 task.stream.as_slice(),
7748 [SubagentStreamItem::User(prefix), SubagentStreamItem::Assistant(text)]
7749 if prefix.starts_with('…') && prefix.ends_with('a') && text == &latest
7750 ));
7751 }
7752
7753 #[test]
7754 fn subagent_stream_trimming_marks_a_wholly_evicted_item() {
7755 let mut state = UiState::from_history(&[], "secret", "model", None, false);
7756 let latest = "b".repeat(SUBAGENT_STREAM_MAX_CHARS - 1);
7757 state.subagents.push(SubagentTask {
7758 call_id: "call-worker".to_owned(),
7759 task_id: Some("subagent-1".to_owned()),
7760 task: "Inspect".to_owned(),
7761 model: None,
7762 effort: None,
7763 status: SubagentStatus::Running,
7764 result: None,
7765 creation_completed: true,
7766 stream: vec![SubagentStreamItem::User("a".repeat(8))],
7767 stream_chars: 8,
7768 });
7769 state.apply_subagent_activity(SubagentActivity::Event {
7770 task_id: "subagent-1".to_owned(),
7771 event: ProtocolEvent::AssistantDelta {
7772 text: latest.clone(),
7773 },
7774 });
7775
7776 let task = &state.subagents[0];
7777 assert_eq!(task.stream_chars, SUBAGENT_STREAM_MAX_CHARS);
7778 assert!(matches!(
7779 task.stream.as_slice(),
7780 [SubagentStreamItem::Assistant(marker), SubagentStreamItem::Assistant(text)]
7781 if marker == "…" && text == &latest
7782 ));
7783 }
7784
7785 #[test]
7786 fn oversized_subagent_tool_result_keeps_structured_truncation_marker() {
7787 let mut state = UiState::from_history(&[], "secret", "model", None, false);
7788 state.subagents.push(SubagentTask {
7789 call_id: "call-worker".to_owned(),
7790 task_id: Some("subagent-1".to_owned()),
7791 task: "Inspect".to_owned(),
7792 model: None,
7793 effort: None,
7794 status: SubagentStatus::Running,
7795 result: None,
7796 creation_completed: true,
7797 stream: vec![SubagentStreamItem::User("Inspect".to_owned())],
7798 stream_chars: "Inspect".chars().count(),
7799 });
7800 state.apply_subagent_activity(SubagentActivity::Event {
7801 task_id: "subagent-1".to_owned(),
7802 event: ProtocolEvent::ToolResult {
7803 id: "call-cmd".to_owned(),
7804 name: "cmd".to_owned(),
7805 result: serde_json::json!({
7806 "stdout": "x".repeat(SUBAGENT_STREAM_MAX_CHARS),
7807 "zz_raw_marker": "must stay structured"
7808 }),
7809 },
7810 });
7811
7812 let task = &state.subagents[0];
7813 assert_eq!(task.stream_chars, 1);
7814 assert!(matches!(
7815 task.stream.as_slice(),
7816 [SubagentStreamItem::Assistant(text)] if text == "…"
7817 ));
7818 let rendered = subagent_stream_lines(task, 80, &state)
7819 .iter()
7820 .map(line_text)
7821 .collect::<Vec<_>>()
7822 .join("\n");
7823 assert_eq!(rendered, "…");
7824 assert!(!rendered.contains("zz_raw_marker"));
7825 }
7826
7827 #[test]
7828 fn oversized_subagent_tool_call_keeps_structured_truncation_marker() {
7829 let mut state = UiState::from_history(&[], "secret", "model", None, false);
7830 state.subagents.push(SubagentTask {
7831 call_id: "call-worker".to_owned(),
7832 task_id: Some("subagent-1".to_owned()),
7833 task: "Inspect".to_owned(),
7834 model: None,
7835 effort: None,
7836 status: SubagentStatus::Running,
7837 result: None,
7838 creation_completed: true,
7839 stream: vec![SubagentStreamItem::User("Inspect".to_owned())],
7840 stream_chars: "Inspect".chars().count(),
7841 });
7842 state.apply_subagent_activity(SubagentActivity::Event {
7843 task_id: "subagent-1".to_owned(),
7844 event: ProtocolEvent::ToolCall {
7845 id: "call-cmd".to_owned(),
7846 name: "cmd".to_owned(),
7847 arguments: format!(
7848 r#"{{"command":"{}","zz_raw_marker":"must stay structured"}}"#,
7849 "x".repeat(SUBAGENT_STREAM_MAX_CHARS)
7850 ),
7851 },
7852 });
7853
7854 let task = &state.subagents[0];
7855 assert_eq!(task.stream_chars, 1);
7856 assert!(matches!(
7857 task.stream.as_slice(),
7858 [SubagentStreamItem::Assistant(text)] if text == "…"
7859 ));
7860 let rendered = subagent_stream_lines(task, 80, &state)
7861 .iter()
7862 .map(line_text)
7863 .collect::<Vec<_>>()
7864 .join("\n");
7865 assert_eq!(rendered, "…");
7866 assert!(!rendered.contains("zz_raw_marker"));
7867 }
7868
7869 #[test]
7870 fn empty_subagent_stream_shows_a_waiting_placeholder() {
7871 let state = UiState::from_history(&[], "secret", "model", None, false);
7872 let task = SubagentTask {
7873 call_id: "call-worker".to_owned(),
7874 task_id: Some("subagent-1".to_owned()),
7875 task: "Inspect".to_owned(),
7876 model: None,
7877 effort: None,
7878 status: SubagentStatus::Running,
7879 result: None,
7880 creation_completed: true,
7881 stream: Vec::new(),
7882 stream_chars: 0,
7883 };
7884
7885 assert_eq!(
7886 subagent_stream_lines(&task, 80, &state)
7887 .iter()
7888 .map(line_text)
7889 .collect::<Vec<_>>(),
7890 ["waiting for worker output"]
7891 );
7892 }
7893
7894 #[test]
7895 fn subagent_preview_replays_activity_that_arrives_before_spawn_acknowledgement() {
7896 let mut state = UiState::from_history(&[], "secret", "model", None, false);
7897 state.add_tool_call(&crate::model::ChatToolCall {
7898 id: "call-worker".to_owned(),
7899 name: "spawn_subagent".to_owned(),
7900 arguments: serde_json::json!({"task":"Inspect"}).to_string(),
7901 });
7902
7903 state.apply_subagent_activity(SubagentActivity::Event {
7904 task_id: "subagent-1".to_owned(),
7905 event: ProtocolEvent::AssistantDelta {
7906 text: "early worker output".to_owned(),
7907 },
7908 });
7909 assert_eq!(state.pending_subagent_activities.len(), 1);
7910
7911 state.add_live_tool_result(
7912 "call-worker",
7913 "spawn_subagent",
7914 serde_json::json!({"task_id":"subagent-1","status":"queued"}),
7915 );
7916 assert!(state.pending_subagent_activities.is_empty());
7917 let visible = subagent_stream_lines(&state.subagents[0], 80, &state)
7918 .iter()
7919 .map(line_text)
7920 .collect::<Vec<_>>()
7921 .join("\n");
7922 assert!(visible.contains("early worker output"));
7923 }
7924
7925 #[test]
7926 fn subagent_preview_stays_pinned_to_the_latest_stream_lines() {
7927 let mut state = UiState::from_history(&[], "secret", "model", None, false);
7928 state.subagents.push(SubagentTask {
7929 call_id: "call-worker".to_owned(),
7930 task_id: Some("subagent-1".to_owned()),
7931 task: "Inspect".to_owned(),
7932 model: None,
7933 effort: None,
7934 status: SubagentStatus::Running,
7935 result: None,
7936 creation_completed: true,
7937 stream: Vec::new(),
7938 stream_chars: 0,
7939 });
7940
7941 for index in 0..10 {
7942 state.apply_subagent_activity(SubagentActivity::Event {
7943 task_id: "subagent-1".to_owned(),
7944 event: ProtocolEvent::ToolResult {
7945 id: format!("call-{index}"),
7946 name: "cmd".to_owned(),
7947 result: serde_json::json!({"stdout": format!("output-{index}")}),
7948 },
7949 });
7950 }
7951
7952 let visible = latest_subagent_stream_lines(&state.subagents[0], 80, &state);
7953 assert!(visible
7954 .iter()
7955 .any(|line| line_text(line).contains("output-0")));
7956 assert!(visible
7957 .last()
7958 .is_some_and(|line| line_text(line).contains("output-9")));
7959
7960 state.apply_subagent_activity(SubagentActivity::Event {
7961 task_id: "subagent-1".to_owned(),
7962 event: ProtocolEvent::AssistantDelta {
7963 text: "newest live message".to_owned(),
7964 },
7965 });
7966 let visible = latest_subagent_stream_lines(&state.subagents[0], 80, &state);
7967 assert!(visible
7968 .last()
7969 .is_some_and(|line| line_text(line).contains("newest live message")));
7970
7971 state.subagent_focus = Some(0);
7972 let mut terminal =
7973 Terminal::new(ratatui::backend::TestBackend::new(80, 15)).expect("test terminal");
7974 terminal
7975 .draw(|frame| draw_subagent_stream_overlay(frame, &state, Rect::new(0, 0, 80, 15)))
7976 .expect("draw clipped worker overlay");
7977 let buffer = terminal.backend().buffer();
7978 let inner_rows = (1..14)
7979 .map(|y| (2..78).map(|x| buffer[(x, y)].symbol()).collect::<String>())
7980 .collect::<Vec<_>>();
7981 assert!(inner_rows
7982 .iter()
7983 .any(|row| row.contains("newest live message")));
7984 assert!(!inner_rows.iter().any(|row| row.contains("output-0")));
7985 }
7986
7987 #[test]
7988 fn subagent_preview_shows_reasoning_state_and_initial_task_message() {
7989 let mut state = UiState::from_history(&[], "secret", "model", None, false);
7990 state.subagents.push(SubagentTask {
7991 call_id: "call-worker".to_owned(),
7992 task_id: Some("subagent-1".to_owned()),
7993 task: "Inspect".to_owned(),
7994 model: None,
7995 effort: None,
7996 status: SubagentStatus::Running,
7997 result: None,
7998 creation_completed: true,
7999 stream: vec![SubagentStreamItem::User("Inspect".to_owned())],
8000 stream_chars: 7,
8001 });
8002
8003 state.apply_subagent_activity(SubagentActivity::ReasoningStarted {
8004 task_id: "subagent-1".to_owned(),
8005 });
8006 let before = subagent_stream_lines(&state.subagents[0], 80, &state)
8007 .iter()
8008 .map(line_text)
8009 .collect::<Vec<_>>()
8010 .join("\n");
8011 assert!(before.contains("Inspect"));
8012 assert!(before.contains("Reasoning"));
8013
8014 state.apply_subagent_activity(SubagentActivity::ReasoningCompleted {
8015 task_id: "subagent-1".to_owned(),
8016 });
8017 let after = subagent_stream_lines(&state.subagents[0], 80, &state)
8018 .iter()
8019 .map(line_text)
8020 .collect::<Vec<_>>()
8021 .join("\n");
8022 assert!(after.contains("Reasoning Complete"));
8023 }
8024
8025 #[test]
8026 fn down_from_last_input_row_prioritizes_subagent_list_over_skill_picker() {
8027 let mut state = UiState::from_history(&[], "secret", "model", None, false)
8028 .with_skill_names(vec!["settings".to_owned()]);
8029 state.input = "/".to_owned();
8030 state.input_changed();
8031 state.subagents.push(SubagentTask {
8032 call_id: "call-one".to_owned(),
8033 task_id: Some("one".to_owned()),
8034 task: "one".to_owned(),
8035 model: None,
8036 effort: None,
8037 status: SubagentStatus::Running,
8038 result: None,
8039 creation_completed: true,
8040 stream: Vec::new(),
8041 stream_chars: 0,
8042 });
8043
8044 assert!(state.skill_picker_visible());
8045 assert!(move_down_from_input(&mut state, 20));
8046 assert_eq!(state.subagent_focus, Some(0));
8047 assert_eq!(state.skill_picker_focus, 0);
8048 assert!(subagent_stream_overlay_area(&state, Rect::new(0, 4, 80, 2), 0).is_some());
8049 assert!(move_up_from_input_or_subagent(&mut state, 20));
8050 assert_eq!(state.subagent_focus, None);
8051 assert_eq!(state.skill_picker_focus, 0);
8052 }
8053
8054 #[test]
8055 fn subagent_focus_moves_from_prompt_to_list_on_down_and_returns_on_up() {
8056 let mut state = UiState::from_history(&[], "secret", "model", None, false);
8057 state.input = "prompt".to_owned();
8058 state.cursor = state.input.chars().count();
8059 for (call_id, task_id) in [("call-one", "one"), ("call-two", "two")] {
8060 state.subagents.push(SubagentTask {
8061 call_id: call_id.to_owned(),
8062 task_id: Some(task_id.to_owned()),
8063 task: task_id.to_owned(),
8064 model: None,
8065 effort: None,
8066 status: SubagentStatus::Running,
8067 result: None,
8068 creation_completed: true,
8069 stream: Vec::new(),
8070 stream_chars: 0,
8071 });
8072 }
8073
8074 assert_eq!(input_cursor_row(&state.input, state.cursor, 20), 0);
8075 assert!(state.focus_subagent_list_from_input());
8076 assert_eq!(state.subagent_focus, Some(0));
8077 assert!(state.move_subagent_focus(false));
8078 assert_eq!(
8079 state.subagent_focus, None,
8080 "Up from the first row returns to the prompt"
8081 );
8082
8083 assert!(state.focus_subagent_list_from_input());
8084 assert!(state.move_subagent_focus(true));
8085 assert_eq!(
8086 state.subagent_focus,
8087 Some(1),
8088 "Down advances through the list"
8089 );
8090 assert!(state.move_subagent_focus(false));
8091 assert_eq!(state.subagent_focus, Some(0));
8092 }
8093
8094 #[test]
8095 fn subagent_lifecycle_tool_cards_are_suppressed_from_the_transcript() {
8096 let history = vec![
8097 SessionHistoryRecord::Message {
8098 timestamp: 1,
8099 message: ChatMessage::assistant(
8100 String::new(),
8101 vec![crate::model::ChatToolCall {
8102 id: "call-worker".to_owned(),
8103 name: "spawn_subagent".to_owned(),
8104 arguments: serde_json::json!({"task":"Inspect"}).to_string(),
8105 }],
8106 ),
8107 },
8108 SessionHistoryRecord::Message {
8109 timestamp: 2,
8110 message: ChatMessage::tool(
8111 "call-worker".to_owned(),
8112 "spawn_subagent".to_owned(),
8113 serde_json::json!({"task_id":"subagent-1","status":"queued"}).to_string(),
8114 ),
8115 },
8116 SessionHistoryRecord::Message {
8117 timestamp: 3,
8118 message: ChatMessage::assistant(
8119 String::new(),
8120 vec![crate::model::ChatToolCall {
8121 id: "call-check".to_owned(),
8122 name: "check_subagent".to_owned(),
8123 arguments: serde_json::json!({"task_id":"subagent-1"}).to_string(),
8124 }],
8125 ),
8126 },
8127 SessionHistoryRecord::Message {
8128 timestamp: 4,
8129 message: ChatMessage::tool(
8130 "call-check".to_owned(),
8131 "check_subagent".to_owned(),
8132 serde_json::json!({"task_id":"subagent-1","status":"running"}).to_string(),
8133 ),
8134 },
8135 ];
8136 let state = UiState::from_history(&history, "secret", "model", None, false);
8137 assert_eq!(state.subagents.len(), 1);
8138 assert!(state.transcript.is_empty());
8139 assert_eq!(transcript_lines(&state, 100)[0].to_string(), "");
8140 }
8141
8142 #[test]
8143 fn suppressed_lifecycle_tools_do_not_leave_transcript_spacing() {
8144 for name in [
8145 "spawn_subagent",
8146 "check_subagent",
8147 "wait_subagent",
8148 "send_subagent",
8149 "cancel_subagent",
8150 ] {
8151 let state = UiState {
8152 transcript: vec![
8153 TranscriptItem::Assistant("before".to_owned()),
8154 TranscriptItem::ToolCall {
8155 id: "call".to_owned(),
8156 name: name.to_owned(),
8157 arguments: "{}".to_owned(),
8158 },
8159 TranscriptItem::ToolResult {
8160 id: "call".to_owned(),
8161 name: name.to_owned(),
8162 result: serde_json::json!({"status":"running"}),
8163 },
8164 TranscriptItem::Assistant("after".to_owned()),
8165 ],
8166 ..UiState::from_history(&[], "secret", "model", None, false)
8167 };
8168 let lines = transcript_lines(&state, 80)
8169 .iter()
8170 .map(Line::to_string)
8171 .collect::<Vec<_>>();
8172 assert_eq!(lines, ["before", "", "after"], "{name}");
8173 }
8174 }
8175
8176 #[test]
8177 fn subagent_lifecycle_actions_annotate_the_running_list() {
8178 let mut state = UiState::from_history(&[], "secret", "model", None, false);
8179 state.subagents.push(SubagentTask {
8180 call_id: "call-worker".to_owned(),
8181 task_id: Some("subagent-1".to_owned()),
8182 task: "Inspect".to_owned(),
8183 model: None,
8184 effort: None,
8185 status: SubagentStatus::Running,
8186 result: None,
8187 creation_completed: true,
8188 stream: Vec::new(),
8189 stream_chars: 0,
8190 });
8191
8192 let call = |id: &str, name: &str| crate::model::ChatToolCall {
8193 id: id.to_owned(),
8194 name: name.to_owned(),
8195 arguments: serde_json::json!({"task_id":"subagent-1"}).to_string(),
8196 };
8197 state.add_live_tool_call(&call("check", "check_subagent"));
8198 assert!(matches!(
8199 state.subagent_list_notice_at("subagent-1", Instant::now()),
8200 Some(SubagentListNotice::Flash { .. })
8201 ));
8202 state.add_live_tool_result(
8203 "check",
8204 "check_subagent",
8205 serde_json::json!({"task_id":"subagent-1","status":"running"}),
8206 );
8207
8208 state.add_live_tool_call(&call("wait", "wait_subagent"));
8209 assert!(matches!(
8210 state.subagent_list_notice_at("subagent-1", Instant::now()),
8211 Some(SubagentListNotice::Waiting)
8212 ));
8213 let mut terminal =
8214 Terminal::new(ratatui::backend::TestBackend::new(80, 2)).expect("test terminal");
8215 terminal
8216 .draw(|frame| draw_subagent_list(frame, &state, Rect::new(0, 0, 80, 2)))
8217 .expect("draw waiting worker");
8218 let screen = terminal
8219 .backend()
8220 .buffer()
8221 .content()
8222 .iter()
8223 .map(|cell| cell.symbol())
8224 .collect::<String>();
8225 assert!(screen.contains("Waiting for subagent-1"));
8226 state.add_live_tool_result(
8227 "wait",
8228 "wait_subagent",
8229 serde_json::json!({"task_id":"subagent-1","status":"waiting","timed_out":true}),
8230 );
8231 assert!(state
8232 .subagent_list_notice_at("subagent-1", Instant::now())
8233 .is_none());
8234
8235 state.add_live_tool_call(&call("send", "send_subagent"));
8236 assert!(matches!(
8237 state.subagent_list_notice_at("subagent-1", Instant::now()),
8238 Some(SubagentListNotice::Flash { .. })
8239 ));
8240 state.add_live_tool_result(
8241 "send",
8242 "send_subagent",
8243 serde_json::json!({"task_id":"subagent-1","status":"queued"}),
8244 );
8245 state.add_live_tool_call(&call("cancel", "cancel_subagent"));
8246 state.add_live_tool_result(
8247 "cancel",
8248 "cancel_subagent",
8249 serde_json::json!({"task_id":"subagent-1","status":"cancellation_requested"}),
8250 );
8251 assert!(matches!(
8252 state.subagent_list_notice_at("subagent-1", Instant::now()),
8253 Some(SubagentListNotice::Cancelling)
8254 ));
8255 }
8256
8257 #[test]
8258 fn cancelling_notice_survives_other_lifecycle_results_until_terminal() {
8259 let mut state = UiState::from_history(&[], "secret", "model", None, false);
8260 state.subagents.push(SubagentTask {
8261 call_id: "call-worker".to_owned(),
8262 task_id: Some("subagent-1".to_owned()),
8263 task: "Inspect".to_owned(),
8264 model: None,
8265 effort: None,
8266 status: SubagentStatus::Running,
8267 result: None,
8268 creation_completed: true,
8269 stream: Vec::new(),
8270 stream_chars: 0,
8271 });
8272 for (id, name) in [("check", "check_subagent"), ("cancel", "cancel_subagent")] {
8273 state.add_live_tool_call(&crate::model::ChatToolCall {
8274 id: id.to_owned(),
8275 name: name.to_owned(),
8276 arguments: serde_json::json!({"task_id":"subagent-1"}).to_string(),
8277 });
8278 }
8279 state.add_live_tool_result(
8280 "check",
8281 "check_subagent",
8282 serde_json::json!({"task_id":"subagent-1","status":"failed"}),
8283 );
8284 assert!(matches!(
8285 state.subagent_list_notice_at("subagent-1", Instant::now()),
8286 Some(SubagentListNotice::Cancelling)
8287 ));
8288 state.add_live_tool_result(
8289 "cancel",
8290 "cancel_subagent",
8291 serde_json::json!({"task_id":"subagent-1","status":"cancellation_requested"}),
8292 );
8293 assert!(matches!(
8294 state.subagent_list_notice_at("subagent-1", Instant::now()),
8295 Some(SubagentListNotice::Cancelling)
8296 ));
8297
8298 state.complete_subagent("subagent-1", serde_json::json!({"cancelled":true}));
8299 assert!(state
8300 .subagent_list_notice_at("subagent-1", Instant::now())
8301 .is_none());
8302 }
8303
8304 #[test]
8305 fn failed_or_unknown_subagent_actions_remain_transcript_errors() {
8306 let mut state = UiState::from_history(&[], "secret", "model", None, false);
8307 state.add_live_tool_call(&crate::model::ChatToolCall {
8308 id: "check-unknown".to_owned(),
8309 name: "check_subagent".to_owned(),
8310 arguments: serde_json::json!({"task_id":"unknown"}).to_string(),
8311 });
8312 let result = serde_json::json!({"task_id":"unknown","status":"unknown"});
8313 assert!(subagent_tool_result_is_error(&result));
8314 state.add_live_tool_result("check-unknown", "check_subagent", result);
8315 assert!(
8316 matches!(
8317 state.transcript.as_slice(),
8318 [TranscriptItem::Error(message)] if message.contains("unknown")
8319 ),
8320 "{:?}",
8321 state.transcript
8322 );
8323 }
8324
8325 #[test]
8326 fn clipped_slash_picker_uses_its_actual_item_rows_for_the_focused_item() {
8327 let mut state = UiState::from_history(&[], "secret", "model", None, false)
8328 .with_skill_names(
8329 ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
8330 .into_iter()
8331 .map(str::to_owned)
8332 .collect(),
8333 );
8334 state.input = "/".to_owned();
8335 state.input_changed();
8336 state.skill_picker_focus = 5;
8337 let mut terminal =
8338 Terminal::new(ratatui::backend::TestBackend::new(30, 5)).expect("test terminal");
8339 terminal
8340 .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 5)))
8341 .expect("draw clipped skill picker");
8342
8343 let buffer = terminal.backend().buffer();
8344 let item_rows = (2..4)
8345 .map(|y| (2..28).map(|x| buffer[(x, y)].symbol()).collect::<String>())
8346 .collect::<Vec<_>>();
8347 assert!(item_rows[0].starts_with("/deploy"));
8348 assert!(item_rows[1].starts_with("/doctor"));
8349 assert_eq!(buffer[(2, 3)].fg, QUEUED_MESSAGE_COLOR);
8350 assert!(buffer[(2, 3)].modifier.contains(Modifier::BOLD));
8351 }
8352}
8353
8354#[cfg(test)]
8355mod skill_picker_tests {
8356 use super::*;
8357
8358 fn skill_names() -> Vec<String> {
8359 ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
8360 .into_iter()
8361 .map(str::to_owned)
8362 .collect()
8363 }
8364
8365 #[test]
8366 fn built_in_commands_share_the_slash_catalog_without_becoming_skills() {
8367 assert_eq!(
8368 command_names(vec!["release-notes".to_owned(), "settings".to_owned()]),
8369 vec!["exit", "release-notes", "settings"]
8370 );
8371 assert_eq!(
8372 builtin_command("/settings ignored arguments"),
8373 Some(BuiltinCommand::Settings)
8374 );
8375 assert_eq!(builtin_command(" /exit "), Some(BuiltinCommand::Exit));
8376 assert_eq!(builtin_command("/settings-extra"), None);
8377 }
8378
8379 #[test]
8380 fn settings_viewport_follows_focus_instead_of_truncating_the_catalog_head() {
8381 assert_eq!(selection_range(30, 0, 12), 0..12);
8382 assert_eq!(selection_range(30, 11, 12), 0..12);
8383 assert_eq!(selection_range(30, 12, 12), 1..13);
8384 assert_eq!(selection_range(30, 29, 12), 18..30);
8385 }
8386
8387 #[test]
8388 fn model_selection_uses_advertised_efforts_and_preserves_the_current_choice() {
8389 let mut state = UiState::from_history(&[], "secret", "old", Some("medium"), false);
8390 state.open_catalog(Ok(vec![ProviderModel {
8391 id: "openai/gpt-5.6-sol".to_owned(),
8392 efforts: Some(vec![
8393 "max".to_owned(),
8394 "high".to_owned(),
8395 "medium".to_owned(),
8396 "low".to_owned(),
8397 ]),
8398 }]));
8399 state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
8400
8401 let SettingsState::Effort { model, focus, .. } =
8402 state.settings.as_ref().expect("effort picker")
8403 else {
8404 panic!("model selection should open the effort picker");
8405 };
8406 assert_eq!(model.id, "openai/gpt-5.6-sol");
8407 assert_eq!(*focus, 3, "default occupies index zero before medium");
8408
8409 let selected = state
8410 .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
8411 .expect("effort selection");
8412 assert_eq!(
8413 selected,
8414 ("openai/gpt-5.6-sol".to_owned(), Some("medium".to_owned()))
8415 );
8416 }
8417
8418 #[test]
8419 fn effort_default_selection_does_not_shift_to_the_first_advertised_effort() {
8420 let mut state = UiState::from_history(&[], "secret", "old", None, false);
8421 state.open_catalog(Ok(vec![ProviderModel {
8422 id: "model".to_owned(),
8423 efforts: Some(vec!["high".to_owned(), "low".to_owned()]),
8424 }]));
8425 state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
8426
8427 let selected = state
8428 .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
8429 .expect("default effort selection");
8430 assert_eq!(selected, ("model".to_owned(), None));
8431 }
8432
8433 #[test]
8434 fn reasoning_indicator_changes_to_complete_and_stays_dark_gray() {
8435 let mut state = UiState::from_history(&[], "secret", "model", None, false);
8436 state.show_thinking();
8437
8438 let active_lines = transcript_lines(&state, 80);
8439 let active = active_lines.last().expect("reasoning line");
8440 assert!(active.to_string().starts_with("Reasoning... "));
8441 assert_eq!(active.style.fg, Some(Color::DarkGray));
8442
8443 state.complete_reasoning();
8444 let complete_lines = transcript_lines(&state, 80);
8445 let complete = complete_lines.last().expect("complete line");
8446 assert_eq!(complete.to_string(), "Reasoning Complete");
8447 assert_eq!(complete.style.fg, Some(Color::DarkGray));
8448 }
8449
8450 #[test]
8451 fn slash_picker_filters_only_leading_command_text_and_hides_without_matches() {
8452 let names = skill_names();
8453 assert_eq!(
8454 matching_skill_names("/", &names),
8455 vec!["alpha", "beta", "build", "charlie", "deploy", "doctor"]
8456 );
8457 assert_eq!(matching_skill_names("/b", &names), vec!["beta", "build"]);
8458 assert!(matching_skill_names("/missing", &names).is_empty());
8459 assert!(matching_skill_names("message /b", &names).is_empty());
8460 assert!(matching_skill_names("/beta arguments", &names).is_empty());
8461 }
8462
8463 #[test]
8464 fn slash_picker_focuses_the_top_match_and_moves_within_filtered_results() {
8465 let mut state = UiState::from_history(&[], "secret", "model", None, false)
8466 .with_skill_names(skill_names());
8467 state.input = "/b".to_owned();
8468 state.input_changed();
8469
8470 assert!(state.skill_picker_visible());
8471 assert_eq!(state.skill_picker_focus, 0);
8472 assert!(state.move_skill_picker(true));
8473 assert_eq!(state.skill_picker_focus, 1);
8474 assert!(state.move_skill_picker(true));
8475 assert_eq!(state.skill_picker_focus, 1, "focus does not leave the list");
8476 assert!(state.move_skill_picker(false));
8477 assert_eq!(state.skill_picker_focus, 0);
8478
8479 state.input = "/missing".to_owned();
8480 state.input_changed();
8481 assert!(!state.skill_picker_visible());
8482 assert!(!state.move_skill_picker(true));
8483 }
8484
8485 #[test]
8486 fn focused_builtins_are_distinguished_from_skills() {
8487 let mut state = UiState::from_history(&[], "secret", "model", None, false)
8488 .with_skill_names(command_names(skill_names()));
8489 state.input = "/se".to_owned();
8490 state.input_changed();
8491 assert_eq!(
8492 state.focused_builtin_command(),
8493 Some(BuiltinCommand::Settings)
8494 );
8495
8496 state.input = "/be".to_owned();
8497 state.input_changed();
8498 assert_eq!(state.focused_builtin_command(), None);
8499 }
8500
8501 #[test]
8502 fn selecting_the_focused_skill_leaves_the_completed_command_ready_to_send() {
8503 let mut state = UiState::from_history(&[], "secret", "model", None, false)
8504 .with_skill_names(skill_names());
8505 state.input = "/b".to_owned();
8506 state.input_changed();
8507 state.move_skill_picker(true);
8508
8509 assert!(state.select_focused_skill());
8510 assert_eq!(state.input, "/build");
8511 assert_eq!(state.cursor, "/build".chars().count());
8512 assert!(
8513 !state.skill_picker_visible(),
8514 "the first Enter completes the input rather than sending it"
8515 );
8516 assert!(
8517 !state.select_focused_skill(),
8518 "a second Enter follows the normal send/attachment path"
8519 );
8520 }
8521
8522 #[test]
8523 fn slash_picker_overlays_without_reflowing_the_transcript_when_match_count_changes() {
8524 let mut state = UiState::from_history(&[], "secret", "model", None, false)
8525 .with_skill_names(skill_names());
8526 let area = Rect::new(0, 0, 40, 16);
8527 state.transcript = (0..20)
8528 .map(|index| TranscriptItem::Assistant(format!("message {index}")))
8529 .collect();
8530
8531 state.input = "/a".to_owned();
8532 state.input_changed();
8533 let (narrow_chat, narrow_picker, _, _, narrow_input, _) = ui_layout(&state, area);
8534 let narrow_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
8535
8536 state.input = "/".to_owned();
8537 state.input_changed();
8538 let (broad_chat, broad_picker, _, _, broad_input, _) = ui_layout(&state, area);
8539 let broad_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
8540
8541 assert_ne!(
8542 narrow_picker, broad_picker,
8543 "the overlay may fit its contents"
8544 );
8545 assert_eq!(narrow_chat, broad_chat);
8546 assert_eq!(narrow_input, broad_input);
8547 assert_eq!(
8548 narrow_scroll, broad_scroll,
8549 "the overlay does not reduce the transcript viewport"
8550 );
8551 }
8552
8553 #[test]
8554 fn slash_picker_keeps_the_focused_item_in_its_five_row_viewport() {
8555 assert_eq!(selection_range(20, 0, 5), 0..5);
8556 assert_eq!(selection_range(20, 4, 5), 0..5);
8557 assert_eq!(selection_range(20, 5, 5), 1..6);
8558 assert_eq!(selection_range(20, 19, 5), 15..20);
8559 }
8560
8561 #[test]
8562 fn slash_picker_is_rendered_immediately_above_the_input() {
8563 let mut state = UiState::from_history(&[], "secret", "model", None, false)
8564 .with_skill_names(skill_names());
8565 state.input = "/".to_owned();
8566 state.input_changed();
8567 let mut terminal =
8568 Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
8569 terminal
8570 .draw(|frame| draw(frame, &state))
8571 .expect("draw TUI");
8572
8573 let buffer = terminal.backend().buffer();
8574 let area = tui_viewport(Rect::new(0, 0, 40, 12));
8575 let (_, picker_area, _, _, input_area, _) = ui_layout(&state, area);
8576 let picker_area = picker_area.expect("picker area");
8577 assert_eq!(picker_area.y + picker_area.height, input_area.y);
8579 for (x, y) in [
8580 (picker_area.x, picker_area.y),
8581 (picker_area.x + picker_area.width - 1, picker_area.y),
8582 (picker_area.x, picker_area.y + picker_area.height - 1),
8583 (
8584 picker_area.x + picker_area.width - 1,
8585 picker_area.y + picker_area.height - 1,
8586 ),
8587 ] {
8588 assert_eq!(buffer[(x, y)].symbol(), " ");
8589 assert_eq!(buffer[(x, y)].bg, SKILL_PICKER_BACKGROUND);
8590 }
8591 assert_eq!(
8592 buffer[(picker_area.x + 1, picker_area.y + 1)].bg,
8593 SKILL_PICKER_BACKGROUND
8594 );
8595 assert_eq!(buffer[(picker_area.x + 2, picker_area.y + 1)].symbol(), "[");
8596 assert_eq!(
8597 buffer[(picker_area.x + 2, picker_area.y + 1)].fg,
8598 QUEUED_MESSAGE_COLOR
8599 );
8600 assert_eq!(buffer[(picker_area.x + 2, picker_area.y + 2)].symbol(), "/");
8601 assert_eq!(
8602 buffer[(picker_area.x + 2, picker_area.y + 2)].fg,
8603 QUEUED_MESSAGE_COLOR
8604 );
8605 assert_eq!(
8606 buffer[(picker_area.x + 1, picker_area.y + picker_area.height - 2)].bg,
8607 SKILL_PICKER_BACKGROUND
8608 );
8609 assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), " ");
8610 assert_eq!(buffer[(input_area.x, input_area.y)].bg, CONSOLE_BACKGROUND);
8611 }
8612
8613 #[test]
8614 fn slash_picker_renders_count_with_bold_focus_on_the_picker_surface() {
8615 let mut state = UiState::from_history(&[], "secret", "model", None, false)
8616 .with_skill_names(skill_names());
8617 state.input = "/".to_owned();
8618 state.input_changed();
8619 let mut terminal =
8620 Terminal::new(ratatui::backend::TestBackend::new(30, 8)).expect("test terminal");
8621 terminal
8622 .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 8)))
8623 .expect("draw skill picker");
8624
8625 let buffer = terminal.backend().buffer();
8626 assert_eq!(buffer[(0, 0)].symbol(), " ");
8627 assert_eq!(buffer[(0, 0)].bg, SKILL_PICKER_BACKGROUND);
8628 assert_eq!(buffer[(2, 1)].symbol(), "[");
8629 assert_eq!(buffer[(2, 1)].fg, QUEUED_MESSAGE_COLOR);
8630 assert_eq!(buffer[(2, 2)].symbol(), "/");
8631 assert_eq!(buffer[(2, 2)].fg, QUEUED_MESSAGE_COLOR);
8632 assert!(buffer[(2, 2)].modifier.contains(Modifier::BOLD));
8633 assert_eq!(buffer[(2, 3)].symbol(), "/");
8634 assert_eq!(buffer[(2, 3)].fg, QUEUED_MESSAGE_COLOR);
8635 assert!(!buffer[(2, 3)].modifier.contains(Modifier::BOLD));
8636 }
8637}