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