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