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