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