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