1use std::collections::HashMap;
2use std::io::{self, Write};
3use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
4use std::sync::{Arc, Mutex, OnceLock};
5use std::thread::{self, JoinHandle};
6use std::time::{Duration, Instant};
7
8use crossterm::cursor::{Hide, Show};
9use crossterm::event::{
10 self, DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture, Event,
11 KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags, MouseEventKind,
12 PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
13};
14use crossterm::execute;
15use crossterm::terminal::{
16 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
17};
18use ratatui::backend::CrosstermBackend;
19use ratatui::layout::{Alignment, Rect, Size};
20use ratatui::prelude::Frame;
21use ratatui::style::{Color, Modifier, Style};
22use ratatui::text::{Line, Span};
23use ratatui::widgets::{Block, Borders, Clear, Paragraph};
24use ratatui::Terminal;
25use ratatui_image::picker::Picker;
26use ratatui_image::protocol::Protocol;
27use ratatui_image::{Image as TuiImage, Resize};
28use serde_json::Value;
29use unicode_width::UnicodeWidthStr;
30
31use crate::app::Harness;
32use crate::cancellation::CancellationToken;
33use crate::model::{estimate_context_tokens, ChatMessage};
34use crate::protocol::{EventSink, ProtocolEvent};
35use crate::provider::ProviderModel;
36use crate::redaction::redact_secret;
37use crate::session::SessionHistoryRecord;
38
39const EVENT_POLL: Duration = Duration::from_millis(50);
40const MAX_DISPLAY_INPUT_CHARS: usize = 16 * 1024;
41const MAX_INPUT_ROWS: u16 = 12;
44const TUI_MAX_WIDTH: u16 = 100;
45const WELCOME_MESSAGE: &str = "Coding Agent Harness LUCY";
46const WELCOME_VERSION: &str = concat!("v", env!("CARGO_PKG_VERSION"));
47const WELCOME_TAGLINE: &str = "An ultra-thin harness for tomorrow's most powerful models";
48const GREETING_IMAGE_BYTES: &[u8] = include_bytes!("../assets/greeting.png");
49const GREETING_IMAGE_SIZE: Size = Size::new(80, 20);
50const GREETING_IMAGE_MIN_SIZE: Size = Size::new(40, 10);
51const LOGO_TEXT: &str = include_str!("../logo.txt");
52const LOGO_START_COLOR: (u8, u8, u8) = (165, 200, 250);
54const LOGO_END_COLOR: (u8, u8, u8) = (221, 144, 234);
55const WELCOME_IMAGE_GAP: u16 = 1;
56const WELCOME_IMAGE_BRIGHTNESS_PERCENT: u16 = 85;
57const WELCOME_START_COLOR: (u8, u8, u8) = (180, 130, 245);
58const WELCOME_END_COLOR: (u8, u8, u8) = (0, 180, 180);
59const USER_BORDER_COLOR: Color = Color::Rgb(192, 154, 0);
60const USER_BORDER_GLYPH: &str = "▌";
61const TUI_GLOW_BACKGROUND_RGB: (u8, u8, u8) = (16, 18, 22);
62const TUI_GLOW_BACKGROUND: Color = Color::Rgb(
63 TUI_GLOW_BACKGROUND_RGB.0,
64 TUI_GLOW_BACKGROUND_RGB.1,
65 TUI_GLOW_BACKGROUND_RGB.2,
66);
67const CONSOLE_BACKGROUND_RGB: (u8, u8, u8) = (42, 42, 46);
68const CONSOLE_BACKGROUND: Color = Color::Rgb(
69 CONSOLE_BACKGROUND_RGB.0,
70 CONSOLE_BACKGROUND_RGB.1,
71 CONSOLE_BACKGROUND_RGB.2,
72);
73const CONSOLE_STATUS_COLOR: Color = Color::Rgb(144, 144, 148);
74const CONSOLE_ACCENT_LAVENDER: (u8, u8, u8) = (145, 70, 220);
75const CONSOLE_ACCENT_TEAL: (u8, u8, u8) = (0, 180, 180);
76const CONSOLE_ACCENT_CYCLE_DURATION: Duration = Duration::from_secs(15);
77const CONSOLE_ACCENT_DESATURATION: f32 = 0.15;
78const CONSOLE_GLASS_DESATURATION: f32 = 0.65;
79const CONSOLE_GLASS_TINT: f32 = 0.24;
80const CONSOLE_GLASS_WHITE_TINT: f32 = 0.03;
81const CONSOLE_GLASS_GLOW_THROUGH: f32 = 0.26;
82const CONSOLE_REFLECTION_TINT: f32 = 0.12;
83const CONSOLE_REFLECTION_WHITE_TINT: f32 = 0.20;
84const CONSOLE_REFLECTION_GLYPH: &str = "▁";
85const GLOW_HEIGHT: u16 = 12;
86const GLOW_HORIZONTAL_SPREAD: u16 = 24;
87const GLOW_INTENSITY: f32 = 0.70;
88const GLOW_DESATURATION: f32 = 0.10;
89const CONSOLE_BOUNDARY_CYCLE: Duration = Duration::from_millis(7000);
90const CONSOLE_REACH_MIN: f32 = 0.28;
91const CONSOLE_REACH_MAX: f32 = 0.38;
92const CONSOLE_VISIBILITY_TRANSITION: Duration = Duration::from_millis(600);
93const SKILL_TRIGGER_COLOR: Color = Color::Rgb(80, 255, 245);
94const PENDING_TOOL_COLOR_RGB: (u8, u8, u8) = (255, 165, 0);
95const PENDING_TOOL_COLOR: Color = Color::Rgb(
96 PENDING_TOOL_COLOR_RGB.0,
97 PENDING_TOOL_COLOR_RGB.1,
98 PENDING_TOOL_COLOR_RGB.2,
99);
100const TOOL_RESULT_SWEEP_DURATION: Duration = Duration::from_millis(600);
103const TOOL_RESULT_CHARACTER_FADE_PORTION: f32 = 0.4;
106const TOOL_SUCCESS_COLOR_RGB: (u8, u8, u8) = (0, 210, 175);
107const TOOL_SUCCESS_COLOR: Color = Color::Rgb(
108 TOOL_SUCCESS_COLOR_RGB.0,
109 TOOL_SUCCESS_COLOR_RGB.1,
110 TOOL_SUCCESS_COLOR_RGB.2,
111);
112const TOOL_FAILURE_COLOR: Color = Color::Rgb(255, 0, 0);
113const TOOL_WARNING_COLOR: Color = Color::Rgb(255, 255, 0);
114const QUEUED_MESSAGE_COLOR: Color = Color::Rgb(150, 255, 245);
115const FLOATING_PANEL_BACKGROUND: Color = Color::Rgb(28, 28, 30);
117const SKILL_PICKER_BACKGROUND: Color = FLOATING_PANEL_BACKGROUND;
118const SECTION_CHROME_COLOR: Color = Color::Rgb(0, 180, 180);
119const SKILL_PICKER_MAX_ROWS: usize = 5;
120const BUILTIN_COMMANDS: [&str; 2] = ["settings", "exit"];
121const SETTINGS_MIN_WIDTH: u16 = 36;
122const SETTINGS_MAX_WIDTH: u16 = 88;
123const SETTINGS_MIN_HEIGHT: u16 = 8;
124const SETTINGS_MAX_HEIGHT: u16 = 22;
125
126pub(crate) fn run<W: Write>(mut harness: Harness, resumed: bool, stdout: W) -> Result<(), String> {
127 let secret = harness.provider.api_key();
128 let context_window = harness
129 .context_window
130 .or_else(|| harness.provider.context_window());
131 harness.context_window = context_window;
132 let context_tokens = estimate_context_tokens(&harness.session.provider_messages());
133 let skill_names = command_names(
134 harness
135 .session
136 .skills
137 .iter()
138 .map(|skill| skill.name.clone())
139 .collect(),
140 );
141 let mut state = UiState::from_history(
142 &harness.session.history,
143 &secret,
144 &harness.session.llm.model,
145 harness.session.llm.effort.as_deref(),
146 resumed,
147 )
148 .with_attached_agents(harness.attached_agents.clone())
149 .with_skill_names(skill_names)
150 .with_context(context_window, context_tokens);
151 let (request_tx, request_rx) = mpsc::channel::<WorkerRequest>();
152 let (message_tx, message_rx) = mpsc::channel::<WorkerMessage>();
153
154 let stdout = stdout;
155 enable_raw_mode().map_err(|error| format!("unable to enable terminal input: {error}"))?;
156 let backend = CrosstermBackend::new(stdout);
157 let terminal = match Terminal::new(backend) {
158 Ok(terminal) => terminal,
159 Err(error) => {
160 let _ = disable_raw_mode();
161 return Err(format!("unable to initialize terminal UI: {error}"));
162 }
163 };
164 let mut terminal_guard = TerminalGuard::new(terminal);
165 let backend = terminal_guard.terminal_mut().backend_mut();
166 if let Err(error) = execute!(
167 backend,
168 EnterAlternateScreen,
169 EnableFocusChange,
170 EnableMouseCapture,
171 Hide
172 ) {
173 return Err(format!("unable to enter terminal UI: {error}"));
174 }
175 let keyboard_enhanced = supports_keyboard_enhancement();
180 if keyboard_enhanced {
181 let _ = execute!(
182 backend,
183 PushKeyboardEnhancementFlags(
184 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
185 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES,
186 )
187 );
188 }
189 let in_tmux = is_inside_tmux();
194 if in_tmux {
195 let _ = backend
196 .write_all(b"\x1b[>4;1m")
197 .and_then(|_| backend.flush());
198 }
199 if keyboard_enhanced {
202 terminal_guard.keyboard_enhancement = true;
203 }
204 if in_tmux {
205 terminal_guard.modify_other_keys = true;
206 }
207 let worker = thread::spawn(move || worker_loop(&mut harness, request_rx, message_tx, resumed));
208
209 let result = event_loop(
210 terminal_guard.terminal_mut(),
211 &mut state,
212 &request_tx,
213 &message_rx,
214 );
215
216 if let Some(token) = state.active_cancel.take() {
217 let _ = token.cancel();
218 }
219 let _ = request_tx.send(WorkerRequest::Shutdown);
220 wait_for_worker(worker, Duration::from_secs(2));
221 drop(terminal_guard);
222 result
223}
224
225fn worker_loop(
226 harness: &mut Harness,
227 requests: Receiver<WorkerRequest>,
228 messages: Sender<WorkerMessage>,
229 resumed: bool,
230) {
231 let mut sink = ChannelSink {
232 sender: messages.clone(),
233 };
234 if sink
235 .emit_event(&ProtocolEvent::Session {
236 session_id: harness.session.id.clone(),
237 resumed,
238 })
239 .is_err()
240 {
241 return;
242 }
243
244 loop {
245 let request = match requests.recv_timeout(EVENT_POLL) {
246 Ok(request) => request,
247 Err(mpsc::RecvTimeoutError::Timeout) => continue,
248 Err(mpsc::RecvTimeoutError::Disconnected) => break,
249 };
250 match request {
251 WorkerRequest::Turn { text } => {
252 let cancel = CancellationToken::new();
253 let _ = messages.send(WorkerMessage::Started {
254 cancel: cancel.clone(),
255 user_text: Some(text.clone()),
256 });
257 if let Err(error) = harness.handle_message(&text, &mut sink, Some(&cancel)) {
258 let message = redact_secret(&error, Some(harness.provider.api_key().as_str()));
259 let _ = sink.emit_event(&ProtocolEvent::Error { message });
260 }
261 let _ = messages.send(WorkerMessage::Finished);
262 }
263 WorkerRequest::Catalog => {
264 let _ = messages.send(WorkerMessage::Catalog(
265 harness.provider.models().map_err(|error| error.to_string()),
266 ));
267 }
268 WorkerRequest::ApplySettings { model, effort } => {
269 let result = harness.apply_settings(&harness.home.clone(), model, effort);
270 let _ = messages.send(WorkerMessage::SettingsApplied(
271 result,
272 harness.session.llm.model.clone(),
273 harness.session.llm.effort.clone(),
274 harness.context_window,
275 ));
276 }
277 WorkerRequest::Shutdown => break,
278 }
279 }
280}
281
282fn event_loop<W: Write>(
283 terminal: &mut Terminal<CrosstermBackend<W>>,
284 state: &mut UiState,
285 requests: &Sender<WorkerRequest>,
286 messages: &Receiver<WorkerMessage>,
287) -> Result<(), String> {
288 let mut quitting = false;
289 loop {
290 loop {
291 match messages.try_recv() {
292 Ok(WorkerMessage::Event(event)) => state.apply_event(event),
293 Ok(WorkerMessage::Started { cancel, user_text }) => {
294 if let Some(text) = user_text {
295 state.start_queued_user(&text);
296 }
297 state.active_cancel = Some(cancel);
298 state.set_busy(true);
299 state.set_status("working");
300 }
301 Ok(WorkerMessage::Thinking) => state.show_thinking(),
302 Ok(WorkerMessage::ReasoningCompleted) => state.complete_reasoning(),
303 Ok(WorkerMessage::SkillInstructionAttached) => {
304 state.mark_latest_user_skill_attached()
305 }
306 Ok(WorkerMessage::ContextUsage(tokens)) => state.context_tokens = tokens,
307 Ok(WorkerMessage::CompactionStarted) => state.set_status("compacting"),
308 Ok(WorkerMessage::CompactionFinished {
309 tokens_before,
310 tokens_after,
311 }) => {
312 state.context_tokens = tokens_after;
313 state.set_status("working");
314 state.transcript.push(TranscriptItem::Info(format!(
315 "↻ context compacted ({} → {})",
316 format_context_tokens(tokens_before),
317 format_context_tokens(tokens_after)
318 )));
319 }
320 Ok(WorkerMessage::Catalog(result)) => state.open_catalog(result),
321 Ok(WorkerMessage::SettingsApplied(result, model, effort, context_window)) => {
322 state.settings_applied(result, model, effort, context_window)
323 }
324 Ok(WorkerMessage::Finished) => {
325 release_finished_turn(terminal.backend_mut(), state);
326 match state.status.as_str() {
327 "cancelling" => state.set_status("사용자 중단"),
328 "finalizing" => state.set_status("ready"),
329 _ => {}
330 }
331 if quitting {
332 return Ok(());
333 }
334 }
335 Err(TryRecvError::Empty) => break,
336 Err(TryRecvError::Disconnected) => {
337 if state.busy {
338 return Err("TUI worker stopped unexpectedly".to_owned());
339 }
340 return Ok(());
341 }
342 }
343 }
344
345 let _ = execute!(terminal.backend_mut(), Hide);
352
353 terminal
354 .draw(|frame| draw(frame, state))
355 .map_err(|error| format!("unable to render TUI: {error}"))?;
356
357 if quitting {
358 thread::sleep(EVENT_POLL);
359 continue;
360 }
361 if event::poll(EVENT_POLL)
362 .map_err(|error| format!("unable to read terminal input: {error}"))?
363 {
364 let event =
365 event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
366 if handle_terminal_focus_event(state, &event) {
367 continue;
368 }
369 let key = match event {
370 Event::Mouse(mouse) => {
371 let size = terminal
372 .size()
373 .map_err(|error| format!("unable to read terminal size: {error}"))?;
374 let max_scroll = max_scroll_for_area(state, size);
375 handle_mouse_event(state, mouse.kind, max_scroll);
376 continue;
377 }
378 Event::Key(key) => key,
379 _ => continue,
380 };
381 if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
382 continue;
383 }
384 if is_ctrl_c(&key) {
385 if let Some(token) = state.active_cancel.as_ref() {
386 let _ = token.cancel();
387 quitting = true;
388 } else {
389 return Ok(());
390 }
391 continue;
392 }
393 if !state.busy && state.settings.is_some() {
394 if let Some((model, effort)) = state.handle_settings_key(&key) {
395 state.settings = Some(SettingsState::Applying {
396 model: model.clone(),
397 effort: effort.clone(),
398 });
399 requests
400 .send(WorkerRequest::ApplySettings { model, effort })
401 .map_err(|_| "TUI worker is unavailable".to_owned())?;
402 }
403 continue;
404 }
405 if key.code == KeyCode::Esc {
406 if let Some(token) = state.active_cancel.as_ref() {
407 if token.cancel() {
408 state.set_status("cancelling");
409 }
410 }
411 continue;
412 }
413 match key.code {
414 KeyCode::Enter => {
415 if key.modifiers.contains(KeyModifiers::SHIFT)
420 || key.modifiers.contains(KeyModifiers::ALT)
421 {
422 if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
423 insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
424 state.input_changed();
425 }
426 continue;
427 }
428 let text = if let Some(command) = state.focused_builtin_command() {
431 state.input.clear();
432 format!("/{}", command.name())
433 } else {
434 if state.select_focused_skill() {
435 continue;
436 }
437 std::mem::take(&mut state.input)
438 };
439 state.cursor = 0;
440 if let Some(command) = builtin_command(&text) {
441 state.reset_skill_picker();
442 if state.busy {
443 state.transcript.push(TranscriptItem::Info(format!(
444 "/{} is available when the current turn finishes",
445 command.name()
446 )));
447 continue;
448 }
449 match command {
450 BuiltinCommand::Settings => {
451 state.settings = Some(SettingsState::Loading);
452 requests
453 .send(WorkerRequest::Catalog)
454 .map_err(|_| "TUI worker is unavailable".to_owned())?;
455 continue;
456 }
457 BuiltinCommand::Exit => return Ok(()),
458 }
459 }
460 state.reset_skill_picker();
461 if text.trim().is_empty() {
462 continue;
463 }
464 state.auto_scroll = true;
465 state.scroll = 0;
466 state.submit_user(&text);
467 state.set_busy(true);
468 state.set_status("working");
469 requests
470 .send(WorkerRequest::Turn { text })
471 .map_err(|_| "TUI worker is unavailable".to_owned())?;
472 }
473 KeyCode::Tab => {
474 state.select_focused_skill();
477 }
478 KeyCode::Char(character) => {
479 if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
480 insert_at_cursor(&mut state.input, &mut state.cursor, character);
481 state.input_changed();
482 }
483 }
484 KeyCode::Backspace => {
485 if remove_before_cursor(&mut state.input, &mut state.cursor) {
486 state.input_changed();
487 }
488 }
489 KeyCode::Left => {
490 state.cursor = state.cursor.saturating_sub(1);
491 }
492 KeyCode::Right => {
493 state.cursor = (state.cursor + 1).min(state.input.chars().count());
494 }
495 KeyCode::Home => {
496 state.cursor = 0;
497 }
498 KeyCode::End => {
499 state.cursor = state.input.chars().count();
500 }
501 KeyCode::Up => {
502 let size = terminal
503 .size()
504 .map_err(|error| format!("unable to read terminal size: {error}"))?;
505 let area = tui_viewport(Rect::new(0, 0, size.width, size.height));
506 let input_width = ui_prompt_content_width(area).max(1) as usize;
507 if !move_up_from_input(state, input_width) {
508 let max_scroll = max_scroll_for_area(state, size);
509 scroll_up(state, max_scroll);
510 }
511 }
512 KeyCode::Down => {
513 let size = terminal
514 .size()
515 .map_err(|error| format!("unable to read terminal size: {error}"))?;
516 let area = tui_viewport(Rect::new(0, 0, size.width, size.height));
517 let input_width = ui_prompt_content_width(area).max(1) as usize;
518 if !move_down_from_input(state, input_width) {
519 let max_scroll = max_scroll_for_area(state, size);
520 scroll_down(state, max_scroll);
521 }
522 }
523 KeyCode::PageUp => {
524 let size = terminal
525 .size()
526 .map_err(|error| format!("unable to read terminal size: {error}"))?;
527 let max_scroll = max_scroll_for_area(state, size);
528 scroll_up(state, max_scroll);
529 }
530 KeyCode::PageDown => {
531 let size = terminal
532 .size()
533 .map_err(|error| format!("unable to read terminal size: {error}"))?;
534 let max_scroll = max_scroll_for_area(state, size);
535 scroll_down(state, max_scroll);
536 }
537 _ => {}
538 }
539 }
540 }
541}
542
543fn handle_terminal_focus_event(state: &mut UiState, event: &Event) -> bool {
544 match event {
545 Event::FocusGained => state.terminal_focused = true,
546 Event::FocusLost => state.terminal_focused = false,
547 _ => return false,
548 }
549 true
550}
551
552fn is_ctrl_c(key: &KeyEvent) -> bool {
553 key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
554}
555
556fn handle_mouse_event(state: &mut UiState, kind: MouseEventKind, max_scroll: u16) {
557 match kind {
558 MouseEventKind::ScrollUp => scroll_up(state, max_scroll),
559 MouseEventKind::ScrollDown => scroll_down(state, max_scroll),
560 _ => {}
561 }
562}
563
564fn scroll_up(state: &mut UiState, max_scroll: u16) {
565 if state.auto_scroll {
566 state.scroll = max_scroll;
567 state.auto_scroll = false;
568 } else {
569 state.scroll = state.scroll.min(max_scroll);
570 }
571 state.scroll = state.scroll.saturating_sub(3);
572}
573
574fn scroll_down(state: &mut UiState, max_scroll: u16) {
575 if state.auto_scroll {
576 return;
577 }
578 state.scroll = state.scroll.saturating_add(3).min(max_scroll);
579 if state.scroll == max_scroll {
580 state.auto_scroll = true;
583 state.scroll = 0;
584 }
585}
586
587fn wait_for_worker(worker: JoinHandle<()>, grace: Duration) {
588 let deadline = std::time::Instant::now() + grace;
589 while !worker.is_finished() && std::time::Instant::now() < deadline {
590 thread::sleep(Duration::from_millis(5));
591 }
592 if worker.is_finished() {
593 let _ = worker.join();
594 }
595}
596
597struct TerminalGuard<W: Write> {
598 terminal: Option<Terminal<CrosstermBackend<W>>>,
599 keyboard_enhancement: bool,
600 modify_other_keys: bool,
601}
602
603impl<W: Write> TerminalGuard<W> {
604 fn new(terminal: Terminal<CrosstermBackend<W>>) -> Self {
605 Self {
606 terminal: Some(terminal),
607 keyboard_enhancement: false,
608 modify_other_keys: false,
609 }
610 }
611
612 fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {
613 self.terminal
614 .as_mut()
615 .expect("terminal guard is initialized")
616 }
617}
618
619impl<W: Write> Drop for TerminalGuard<W> {
620 fn drop(&mut self) {
621 let Some(mut terminal) = self.terminal.take() else {
622 return;
623 };
624 if self.modify_other_keys {
625 let _ = terminal
626 .backend_mut()
627 .write_all(b"\x1b[>4;0m")
628 .and_then(|_| terminal.backend_mut().flush());
629 }
630 if self.keyboard_enhancement {
631 let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
632 }
633 let _ = terminal.show_cursor();
634 let _ = disable_raw_mode();
635 let _ = execute!(
636 terminal.backend_mut(),
637 DisableFocusChange,
638 DisableMouseCapture,
639 LeaveAlternateScreen,
640 Show
641 );
642 let _ = terminal.backend_mut().flush();
643 }
644}
645
646fn supports_keyboard_enhancement() -> bool {
651 fn env(name: &str) -> Option<String> {
652 std::env::var(name).ok().map(|value| value.to_lowercase())
653 }
654 let term = env("TERM").unwrap_or_default();
655 let program = env("TERM_PROGRAM").unwrap_or_default();
656 if term.starts_with("xterm-kitty")
657 || term.starts_with("ghostty")
658 || term.starts_with("xterm-ghostty")
659 {
660 return true;
661 }
662 if matches!(
663 program.as_str(),
664 "ghostty" | "kitty" | "wezterm" | "alacritty" | "foot" | "footclient" | "iterm.app"
665 ) {
666 return true;
667 }
668 if program == "tmux" {
673 return true;
674 }
675 false
676}
677
678fn is_inside_tmux() -> bool {
680 std::env::var("TERM_PROGRAM")
681 .map(|value| value.eq_ignore_ascii_case("tmux"))
682 .unwrap_or(false)
683}
684
685#[derive(Debug, Clone, Copy, PartialEq, Eq)]
686enum TurnNotification {
687 Completed,
688 Interrupted,
689 Failed,
690}
691
692impl TurnNotification {
693 fn body(self) -> &'static str {
694 match self {
695 Self::Completed => "Turn complete",
696 Self::Interrupted => "Turn interrupted",
697 Self::Failed => "Turn failed",
698 }
699 }
700}
701
702fn turn_notification_for_status(status: &str) -> TurnNotification {
703 match status {
704 "cancelling" | "사용자 중단" => TurnNotification::Interrupted,
705 "error" => TurnNotification::Failed,
706 _ => TurnNotification::Completed,
707 }
708}
709
710fn send_turn_notification<W: Write>(
716 writer: &mut W,
717 notification: TurnNotification,
718) -> io::Result<()> {
719 writer.write_all(b"\x1b]777;notify;Lucy;")?;
720 writer.write_all(notification.body().as_bytes())?;
721 writer.write_all(b"\x07")?;
722 writer.flush()
723}
724
725fn release_finished_turn<W: Write>(writer: &mut W, state: &mut UiState) {
726 let was_busy = state.busy;
727 let notification = turn_notification_for_status(&state.status);
728 state.set_busy(false);
729 state.active_cancel = None;
730 if was_busy {
731 let _ = send_turn_notification(writer, notification);
734 }
735}
736
737enum WorkerRequest {
738 Turn {
739 text: String,
740 },
741 Catalog,
742 ApplySettings {
743 model: String,
744 effort: Option<String>,
745 },
746 Shutdown,
747}
748
749enum WorkerMessage {
750 Event(ProtocolEvent),
751 Started {
752 cancel: CancellationToken,
753 user_text: Option<String>,
754 },
755 Thinking,
756 ReasoningCompleted,
757 SkillInstructionAttached,
758 ContextUsage(usize),
759 CompactionStarted,
760 CompactionFinished {
761 tokens_before: usize,
762 tokens_after: usize,
763 },
764 Catalog(Result<Vec<ProviderModel>, String>),
765 SettingsApplied(Result<(), String>, String, Option<String>, Option<usize>),
766 Finished,
767}
768
769struct ChannelSink {
770 sender: Sender<WorkerMessage>,
771}
772
773impl EventSink for ChannelSink {
774 fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
775 self.sender
776 .send(WorkerMessage::Event(event.clone()))
777 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
778 }
779
780 fn reasoning_started(&mut self) -> io::Result<()> {
781 self.sender
782 .send(WorkerMessage::Thinking)
783 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
784 }
785
786 fn reasoning_completed(&mut self) -> io::Result<()> {
787 self.sender
788 .send(WorkerMessage::ReasoningCompleted)
789 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
790 }
791
792 fn skill_instruction_attached(&mut self, _name: &str) -> io::Result<()> {
793 self.sender
794 .send(WorkerMessage::SkillInstructionAttached)
795 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
796 }
797
798 fn context_usage(&mut self, tokens: usize) -> io::Result<()> {
799 self.sender
800 .send(WorkerMessage::ContextUsage(tokens))
801 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
802 }
803
804 fn compaction_started(&mut self) -> io::Result<()> {
805 self.sender
806 .send(WorkerMessage::CompactionStarted)
807 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
808 }
809
810 fn compaction_finished(&mut self, tokens_before: usize, tokens_after: usize) -> io::Result<()> {
811 self.sender
812 .send(WorkerMessage::CompactionFinished {
813 tokens_before,
814 tokens_after,
815 })
816 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
817 }
818}
819
820#[derive(Debug, Clone, Copy)]
824struct ConsoleVisibilityTransition {
825 started_at: Instant,
826 from: f32,
827 to: f32,
828}
829
830#[derive(Debug, Clone)]
831struct ActivityTransition {
832 started_at: Instant,
833 from_levels: [usize; PULSE_BAR_PERIODS.len()],
834 to_levels: [usize; PULSE_BAR_PERIODS.len()],
835}
836
837struct UiState {
838 model: String,
839 effort: Option<String>,
840 context_window: Option<usize>,
841 context_tokens: usize,
842 secret: String,
843 transcript: Vec<TranscriptItem>,
844 queued_messages: Vec<String>,
845 input: String,
846 cursor: usize,
847 status: String,
848 busy: bool,
849 terminal_focused: bool,
850 active_cancel: Option<CancellationToken>,
851 scroll: u16,
852 auto_scroll: bool,
853 tool_animation_epoch: Instant,
854 console_animation_epoch: Instant,
855 console_visibility_transition: Option<ConsoleVisibilityTransition>,
856 activity_started_at: Instant,
857 activity_transition: Option<ActivityTransition>,
858 last_active_levels: [usize; PULSE_BAR_PERIODS.len()],
859 last_active_elapsed: Duration,
860 welcome_visible: bool,
861 attached_agents: Vec<String>,
862 cmd_result_started_at: HashMap<String, Instant>,
863 skill_names: Vec<String>,
864 skill_picker_focus: usize,
865 skill_picker_suppressed: bool,
866 settings: Option<SettingsState>,
867}
868
869impl UiState {
870 fn from_history(
871 history: &[SessionHistoryRecord],
872 secret: &str,
873 model: &str,
874 effort: Option<&str>,
875 resumed: bool,
876 ) -> Self {
877 let mut state = Self {
878 model: model.to_owned(),
879 effort: effort.map(str::to_owned),
880 context_window: None,
881 context_tokens: 1,
882 secret: secret.to_owned(),
883 transcript: Vec::new(),
884 queued_messages: Vec::new(),
885 input: String::new(),
886 cursor: 0,
887 status: "ready".to_owned(),
888 busy: false,
889 terminal_focused: true,
890 active_cancel: None,
891 scroll: 0,
892 auto_scroll: true,
893 tool_animation_epoch: Instant::now(),
894 console_animation_epoch: Instant::now(),
895 console_visibility_transition: None,
896 activity_started_at: Instant::now(),
897 activity_transition: None,
898 last_active_levels: [0; PULSE_BAR_PERIODS.len()],
899 last_active_elapsed: Duration::ZERO,
900 welcome_visible: !resumed && history.is_empty(),
901 attached_agents: Vec::new(),
902 cmd_result_started_at: HashMap::new(),
903 skill_names: Vec::new(),
904 skill_picker_focus: 0,
905 skill_picker_suppressed: false,
906 settings: None,
907 };
908 for record in history {
909 state.add_history_record(record);
910 }
911 state
912 }
913
914 fn with_attached_agents(mut self, attached_agents: Vec<String>) -> Self {
915 self.attached_agents = attached_agents;
916 self
917 }
918
919 fn with_skill_names(mut self, skill_names: Vec<String>) -> Self {
920 self.skill_names = skill_names;
921 self
922 }
923
924 fn with_context(mut self, context_window: Option<usize>, context_tokens: usize) -> Self {
925 self.context_window = context_window;
926 self.context_tokens = context_tokens.max(1);
927 self
928 }
929
930 fn matching_skill_names(&self) -> Vec<&str> {
933 matching_skill_names(&self.input, &self.skill_names)
934 }
935
936 fn reset_skill_picker(&mut self) {
937 self.skill_picker_focus = 0;
938 self.skill_picker_suppressed = false;
939 }
940
941 fn skill_picker_visible(&self) -> bool {
942 !self.skill_picker_suppressed && !self.matching_skill_names().is_empty()
943 }
944
945 fn set_busy(&mut self, busy: bool) {
946 self.set_busy_at(busy, Instant::now());
947 }
948
949 fn set_busy_at(&mut self, busy: bool, now: Instant) {
950 if self.busy == busy {
951 return;
952 }
953 let from = self.console_visibility_at(now);
954 if busy && from <= f32::EPSILON {
955 self.console_animation_epoch = now;
956 }
957 self.busy = busy;
958 self.console_visibility_transition = Some(ConsoleVisibilityTransition {
959 started_at: now,
960 from,
961 to: if busy { 1.0 } else { 0.0 },
962 });
963 }
964
965 fn console_visibility_at(&self, now: Instant) -> f32 {
966 let Some(transition) = self.console_visibility_transition else {
967 return if self.busy { 1.0 } else { 0.0 };
968 };
969 let progress = now
970 .saturating_duration_since(transition.started_at)
971 .as_secs_f32()
972 / CONSOLE_VISIBILITY_TRANSITION.as_secs_f32();
973 if progress >= 1.0 {
974 return transition.to;
975 }
976 let progress = progress.clamp(0.0, 1.0);
977 let eased = progress * progress * (3.0 - 2.0 * progress);
978 transition.from + (transition.to - transition.from) * eased
979 }
980
981 fn set_status(&mut self, status: impl Into<String>) {
982 let status = status.into();
983 if self.status == status {
984 return;
985 }
986
987 let now = Instant::now();
988 let current_levels = self.activity_levels_at(now);
989 let current_elapsed = self.working_elapsed_at(now);
990 if matches!(self.status.as_str(), "working" | "compacting") {
991 self.last_active_levels = current_levels;
992 self.last_active_elapsed = current_elapsed;
993 }
994
995 match status.as_str() {
996 "working" if !matches!(self.status.as_str(), "working" | "compacting") => {
997 self.activity_started_at = now;
1001 self.activity_transition = Some(ActivityTransition {
1002 started_at: now,
1003 from_levels: current_levels,
1004 to_levels: pulse_levels_at(PULSE_ENTRY_FRAME),
1005 });
1006 }
1007 "ready" if self.status != "ready" => {
1008 let from_levels = if matches!(self.status.as_str(), "working" | "compacting") {
1012 current_levels
1013 } else {
1014 self.last_active_levels
1015 };
1016 self.activity_transition = Some(ActivityTransition {
1017 started_at: now,
1018 from_levels,
1019 to_levels: [0; PULSE_BAR_PERIODS.len()],
1020 });
1021 }
1022 _ => {}
1023 }
1024 self.status = status;
1025 }
1026
1027 fn activity_levels_at(&self, now: Instant) -> [usize; PULSE_BAR_PERIODS.len()] {
1028 if let Some(transition) = &self.activity_transition {
1029 let elapsed = now.saturating_duration_since(transition.started_at);
1030 if elapsed < ACTIVITY_TRANSITION_DURATION {
1031 return interpolate_pulse_levels(
1032 transition.from_levels,
1033 transition.to_levels,
1034 elapsed,
1035 );
1036 }
1037 }
1038
1039 match self.status.as_str() {
1040 "working" | "compacting" => pulse_levels_at(self.working_elapsed_at(now)),
1041 _ => [0; PULSE_BAR_PERIODS.len()],
1042 }
1043 }
1044
1045 fn console_animation_elapsed_at(&self, now: Instant) -> Duration {
1046 now.saturating_duration_since(self.console_animation_epoch)
1047 }
1048
1049 fn working_elapsed_at(&self, now: Instant) -> Duration {
1050 let elapsed = now.saturating_duration_since(self.activity_started_at);
1051 if self.status == "working" && self.activity_transition.is_some() {
1052 PULSE_ENTRY_FRAME
1053 .checked_add(elapsed.saturating_sub(ACTIVITY_TRANSITION_DURATION))
1054 .unwrap_or(PULSE_ENTRY_FRAME)
1055 } else {
1056 elapsed
1057 }
1058 }
1059
1060 fn input_changed(&mut self) {
1061 self.reset_skill_picker();
1062 }
1063
1064 fn move_skill_picker(&mut self, down: bool) -> bool {
1068 let match_count = self.matching_skill_names().len();
1069 if self.skill_picker_suppressed || match_count == 0 {
1070 return false;
1071 }
1072 if down {
1073 self.skill_picker_focus = (self.skill_picker_focus + 1).min(match_count - 1);
1074 } else {
1075 self.skill_picker_focus = self.skill_picker_focus.saturating_sub(1);
1076 }
1077 true
1078 }
1079
1080 fn focused_builtin_command(&self) -> Option<BuiltinCommand> {
1086 let name = *self.matching_skill_names().get(self.skill_picker_focus)?;
1087 builtin_command(&format!("/{name}"))
1088 }
1089
1090 fn select_focused_skill(&mut self) -> bool {
1091 if self.skill_picker_suppressed {
1092 return false;
1093 }
1094 let Some(name) = self
1095 .matching_skill_names()
1096 .get(self.skill_picker_focus)
1097 .map(|name| (*name).to_owned())
1098 else {
1099 return false;
1100 };
1101 self.input = format!("/{name}");
1102 self.cursor = self.input.chars().count();
1103 self.skill_picker_suppressed = true;
1106 true
1107 }
1108
1109 fn open_catalog(&mut self, result: Result<Vec<ProviderModel>, String>) {
1110 self.settings = Some(match result {
1111 Ok(models) => {
1112 let focus = models
1113 .iter()
1114 .position(|model| model.id == self.model)
1115 .unwrap_or(0);
1116 SettingsState::Models {
1117 models,
1118 query: String::new(),
1119 focus,
1120 }
1121 }
1122 Err(error) => SettingsState::Error(error),
1123 });
1124 }
1125 fn settings_applied(
1126 &mut self,
1127 result: Result<(), String>,
1128 model: String,
1129 effort: Option<String>,
1130 context_window: Option<usize>,
1131 ) {
1132 match result {
1133 Ok(()) => {
1134 self.model = model;
1135 self.effort = effort;
1136 self.context_window = context_window;
1137 self.settings = None;
1138 self.transcript
1139 .push(TranscriptItem::Info("⚙ settings applied".to_owned()));
1140 }
1141 Err(error) => self.settings = Some(SettingsState::Error(error)),
1142 }
1143 }
1144 fn handle_settings_key(&mut self, key: &KeyEvent) -> Option<(String, Option<String>)> {
1145 let current_effort = self.effort.clone();
1146 match self.settings.as_mut()? {
1147 SettingsState::Loading => {
1148 if key.code == KeyCode::Esc {
1149 self.settings = None;
1150 }
1151 }
1152 SettingsState::Applying { .. } => {}
1153 SettingsState::Error(_) => {
1154 if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
1155 self.settings = None;
1156 }
1157 }
1158 SettingsState::Models {
1159 models,
1160 query,
1161 focus,
1162 } => match key.code {
1163 KeyCode::Esc => self.settings = None,
1164 KeyCode::Char(c) => {
1165 query.push(c);
1166 *focus = 0;
1167 }
1168 KeyCode::Backspace => {
1169 query.pop();
1170 *focus = 0;
1171 }
1172 KeyCode::Up => *focus = focus.saturating_sub(1),
1173 KeyCode::Down => {
1174 let n = models
1175 .iter()
1176 .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
1177 .count();
1178 *focus = (*focus + 1).min(n.saturating_sub(1));
1179 }
1180 KeyCode::Enter => {
1181 let selected = models
1182 .iter()
1183 .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
1184 .nth(*focus)
1185 .cloned();
1186 if let Some(model) = selected {
1187 let focus = model
1188 .efforts
1189 .as_ref()
1190 .and_then(|efforts| {
1191 current_effort.as_ref().and_then(|current| {
1192 efforts.iter().position(|effort| effort == current)
1193 })
1194 })
1195 .map_or(0, |index| index + 1);
1196 self.settings = Some(SettingsState::Effort {
1197 model,
1198 input: current_effort.unwrap_or_default(),
1199 focus,
1200 });
1201 }
1202 }
1203 _ => {}
1204 },
1205 SettingsState::Effort {
1206 model,
1207 input,
1208 focus,
1209 } => match key.code {
1210 KeyCode::Esc => self.settings = None,
1211 KeyCode::Char(c) if model.efforts.is_none() => input.push(c),
1212 KeyCode::Backspace if model.efforts.is_none() => {
1213 input.pop();
1214 }
1215 KeyCode::Up => *focus = focus.saturating_sub(1),
1216 KeyCode::Down => {
1217 if let Some(efforts) = &model.efforts {
1218 *focus = (*focus + 1).min(efforts.len());
1219 }
1220 }
1221 KeyCode::Enter => {
1222 let effort = match &model.efforts {
1223 Some(efforts) => {
1224 if *focus == 0 {
1225 None
1226 } else {
1227 efforts.get(focus.saturating_sub(1)).cloned()
1228 }
1229 }
1230 None => (!input.trim().is_empty()).then(|| input.trim().to_owned()),
1231 };
1232 return Some((model.id.clone(), effort));
1233 }
1234 _ => {}
1235 },
1236 };
1237 None
1238 }
1239
1240 fn add_history_record(&mut self, record: &SessionHistoryRecord) {
1241 match record {
1242 SessionHistoryRecord::ProviderSettings { model, effort, .. } => {
1243 self.transcript.push(TranscriptItem::Info(format!(
1244 "⚙ {model} ({})",
1245 effort.as_deref().unwrap_or("default")
1246 )))
1247 }
1248 SessionHistoryRecord::Message { message, .. } => self.add_message(message),
1249 SessionHistoryRecord::Interruption {
1250 assistant_text,
1251 tool_calls,
1252 tool_results,
1253 reason,
1254 phase,
1255 ..
1256 } => {
1257 if !assistant_text.is_empty() {
1258 self.add_assistant_message(assistant_text);
1259 }
1260 for call in tool_calls {
1261 self.add_tool_call(call);
1262 }
1263 for observation in tool_results {
1264 self.add_tool_result(
1265 &observation.id,
1266 &observation.name,
1267 observation.result.clone(),
1268 );
1269 }
1270 self.transcript
1271 .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1272 }
1273 SessionHistoryRecord::Compaction(compaction) => {
1274 self.transcript.push(TranscriptItem::Info(format!(
1275 "↻ context compacted ({} before)",
1276 format_context_tokens(compaction.tokens_before)
1277 )));
1278 }
1279 }
1280 }
1281
1282 fn add_message(&mut self, message: &ChatMessage) {
1283 match message.role.as_str() {
1284 "user" => {
1285 let text = message.content.as_deref().unwrap_or("");
1286 let secret = self.secret.clone();
1287 self.add_user(text, &secret);
1288 }
1289 "assistant" => {
1290 if let Some(content) = message.content.as_deref() {
1291 self.add_assistant_message(content);
1292 }
1293 for call in &message.tool_calls {
1294 self.add_tool_call(call);
1295 }
1296 }
1297 "tool" => {
1298 let result = message
1299 .content
1300 .as_deref()
1301 .and_then(|content| serde_json::from_str::<Value>(content).ok())
1302 .unwrap_or_else(|| Value::String(message.content.clone().unwrap_or_default()));
1303 self.add_tool_result(
1304 message.tool_call_id.as_deref().unwrap_or(""),
1305 message.name.as_deref().unwrap_or("cmd"),
1306 result,
1307 );
1308 }
1309 _ => {}
1310 }
1311 }
1312
1313 fn submit_user(&mut self, text: &str) {
1316 if self.busy {
1317 self.queue_user(text);
1318 } else {
1319 self.add_user(text, &self.secret.clone());
1320 }
1321 }
1322
1323 fn queue_user(&mut self, text: &str) {
1324 self.queued_messages
1325 .push(redact_secret(text, Some(&self.secret)));
1326 }
1327
1328 fn start_queued_user(&mut self, text: &str) {
1329 let safe = redact_secret(text, Some(&self.secret));
1330 let queued = if self.queued_messages.first() == Some(&safe) {
1331 self.queued_messages.remove(0);
1332 true
1333 } else if let Some(index) = self
1334 .queued_messages
1335 .iter()
1336 .position(|queued| queued == &safe)
1337 {
1338 self.queued_messages.remove(index);
1339 true
1340 } else {
1341 false
1342 };
1343 if queued {
1344 self.add_user(text, &self.secret.clone());
1345 }
1346 }
1347
1348 fn add_user(&mut self, text: &str, secret: &str) {
1349 self.welcome_visible = false;
1350 self.transcript.push(TranscriptItem::User {
1351 text: redact_secret(text, Some(secret)),
1352 skill_instruction_attached: false,
1353 });
1354 }
1355
1356 fn mark_latest_user_skill_attached(&mut self) {
1357 if let Some(TranscriptItem::User {
1358 skill_instruction_attached,
1359 ..
1360 }) = self.transcript.last_mut()
1361 {
1362 *skill_instruction_attached = true;
1363 }
1364 }
1365
1366 fn clear_thinking(&mut self) {
1367 if matches!(
1368 self.transcript.last(),
1369 Some(TranscriptItem::Reasoning { complete: false })
1370 ) {
1371 self.transcript.pop();
1372 }
1373 }
1374
1375 fn show_thinking(&mut self) {
1376 self.set_status("working");
1377 if !matches!(
1378 self.transcript.last(),
1379 Some(TranscriptItem::Reasoning { complete: false })
1380 ) {
1381 self.transcript
1382 .push(TranscriptItem::Reasoning { complete: false });
1383 }
1384 }
1385
1386 fn complete_reasoning(&mut self) {
1387 if let Some(TranscriptItem::Reasoning { complete }) = self.transcript.last_mut() {
1388 *complete = true;
1389 }
1390 }
1391
1392 fn add_assistant(&mut self, text: &str) {
1393 self.clear_thinking();
1394 if let Some(TranscriptItem::Assistant(current)) = self.transcript.last_mut() {
1395 current.push_str(text);
1396 } else {
1397 self.add_assistant_message(text);
1398 }
1399 }
1400
1401 fn add_assistant_message(&mut self, text: &str) {
1402 self.transcript
1403 .push(TranscriptItem::Assistant(text.to_owned()));
1404 }
1405
1406 fn add_tool_call(&mut self, call: &crate::model::ChatToolCall) {
1407 self.record_tool_call(call, false);
1408 }
1409
1410 fn add_live_tool_call(&mut self, call: &crate::model::ChatToolCall) {
1411 self.record_tool_call(call, true);
1412 }
1413
1414 fn record_tool_call(&mut self, call: &crate::model::ChatToolCall, _live: bool) {
1415 self.clear_thinking();
1416 self.transcript.push(TranscriptItem::ToolCall {
1417 id: call.id.clone(),
1418 name: call.name.clone(),
1419 arguments: call.arguments.clone(),
1420 });
1421 }
1422
1423 fn add_tool_result(&mut self, id: &str, name: &str, result: Value) {
1424 self.record_tool_result(id, name, result, false);
1425 }
1426
1427 fn add_live_tool_result(&mut self, id: &str, name: &str, result: Value) {
1428 self.record_tool_result(id, name, result, true);
1429 }
1430
1431 fn record_tool_result(&mut self, id: &str, name: &str, result: Value, animate: bool) {
1432 if animate && name == "cmd" {
1433 self.cmd_result_started_at
1434 .insert(id.to_owned(), Instant::now());
1435 }
1436 self.transcript.push(TranscriptItem::ToolResult {
1437 id: id.to_owned(),
1438 name: name.to_owned(),
1439 result,
1440 });
1441 }
1442
1443 fn apply_event(&mut self, event: ProtocolEvent) {
1444 match event {
1445 ProtocolEvent::Session { .. } => {}
1446 ProtocolEvent::AssistantDelta { text } => self.add_assistant(&text),
1447 ProtocolEvent::ToolCall {
1448 id,
1449 name,
1450 arguments,
1451 } => self.add_live_tool_call(&crate::model::ChatToolCall {
1452 id,
1453 name,
1454 arguments,
1455 }),
1456 ProtocolEvent::ToolResult { id, name, result } => {
1457 self.add_live_tool_result(&id, &name, result)
1458 }
1459 ProtocolEvent::TurnEnd => {
1460 self.complete_reasoning();
1461 self.set_status("finalizing");
1462 self.transcript
1463 .push(TranscriptItem::Info("✓ turn complete".to_owned()));
1464 }
1465 ProtocolEvent::TurnInterrupted { reason, phase } => {
1466 self.complete_reasoning();
1467 self.set_status("cancelling");
1468 self.transcript
1469 .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1470 }
1471 ProtocolEvent::Error { message } => {
1472 self.complete_reasoning();
1473 self.set_status("error");
1474 self.transcript.push(TranscriptItem::Error(message));
1475 }
1476 }
1477 }
1478}
1479
1480#[derive(Debug, Clone, PartialEq)]
1481enum TranscriptItem {
1482 User {
1483 text: String,
1484 skill_instruction_attached: bool,
1485 },
1486 Assistant(String),
1487 ToolCall {
1488 id: String,
1489 name: String,
1490 arguments: String,
1491 },
1492 ToolResult {
1493 id: String,
1494 name: String,
1495 result: Value,
1496 },
1497 Error(String),
1498 Info(String),
1499 Reasoning {
1500 complete: bool,
1501 },
1502}
1503
1504fn tui_viewport(area: Rect) -> Rect {
1508 if area.width <= 2 {
1509 return area;
1510 }
1511
1512 let width = area.width.saturating_sub(2).min(TUI_MAX_WIDTH);
1513 let x = area.x + area.width.saturating_sub(width) / 2;
1514 Rect::new(x, area.y, width, area.height)
1515}
1516
1517fn ui_layout(
1518 state: &UiState,
1519 area: Rect,
1520) -> (Rect, Option<Rect>, Option<Rect>, Option<Rect>, Rect, Rect) {
1521 let prompt_rows = input_visible_rows(state, ui_prompt_content_width(area));
1522 let list_height = 0;
1523 let queue_height = message_queue_height(state);
1524 let queue_separator_height = u16::from(queue_height > 0);
1525 let list_separator_height = u16::from(list_height > 0);
1526 let requested_input_height = prompt_rows.clamp(1, MAX_INPUT_ROWS)
1527 + queue_height
1528 + queue_separator_height
1529 + list_height
1530 + list_separator_height
1531 + 1 + 1 + 2; let bottom_margin = u16::from(area.height > 1);
1538 let usable_height = area.height.saturating_sub(bottom_margin);
1539 let input_height = requested_input_height.min(usable_height);
1540 let transcript_gap_height = u16::from(usable_height >= input_height.saturating_add(2));
1541 let chat_height = usable_height.saturating_sub(input_height + transcript_gap_height);
1542 let chat_chunk = bottom_console_area(area, area.y, chat_height);
1543 let input_area = bottom_console_area(
1544 area,
1545 area.y + chat_height + transcript_gap_height,
1546 input_height,
1547 );
1548 let inner = console_content_area(input_area);
1549 let content = bottom_content_heights(state, input_area);
1550 let available_above = input_area.y.saturating_sub(area.y);
1551 let picker_height = skill_picker_height(state).min(available_above);
1552 let picker_area = (picker_height > 0).then(|| {
1553 Rect::new(
1554 input_area.x,
1555 input_area.y - picker_height,
1556 input_area.width,
1557 picker_height,
1558 )
1559 });
1560 let stream_area = None;
1561 let queue_area =
1562 (content.queue > 0).then(|| Rect::new(inner.x, inner.y, inner.width, content.queue));
1563 let status_area = Rect::new(
1564 inner.x,
1565 inner.y + inner.height.saturating_sub(content.status),
1566 inner.width,
1567 content.status,
1568 );
1569 (
1570 chat_chunk,
1571 picker_area,
1572 stream_area,
1573 queue_area,
1574 input_area,
1575 status_area,
1576 )
1577}
1578
1579const CONTENT_HORIZONTAL_MARGIN: u16 = 7;
1583const MIN_CONSOLE_WIDTH: u16 = 14;
1584
1585fn bottom_console_area(area: Rect, y: u16, height: u16) -> Rect {
1586 let horizontal_margin = area.width.saturating_sub(1) / 2;
1587 let margin_cap = if area.width < MIN_CONSOLE_WIDTH {
1588 2
1589 } else {
1590 CONTENT_HORIZONTAL_MARGIN.min(area.width.saturating_sub(MIN_CONSOLE_WIDTH) / 2)
1591 };
1592 let horizontal_margin = horizontal_margin.min(margin_cap);
1593 Rect::new(
1594 area.x.saturating_add(horizontal_margin),
1595 y,
1596 area.width
1597 .saturating_sub(horizontal_margin.saturating_mul(2)),
1598 height,
1599 )
1600}
1601
1602fn ui_prompt_content_width(area: Rect) -> u16 {
1603 prompt_content_width(bottom_console_area(area, area.y, 0).width)
1604}
1605
1606fn console_content_area(input_area: Rect) -> Rect {
1607 let top_padding = input_area.height.min(1);
1608 let bottom_padding = input_area.height.saturating_sub(top_padding).min(1);
1609 Rect::new(
1610 input_area.x.saturating_add(2),
1611 input_area.y.saturating_add(top_padding),
1612 input_area.width.saturating_sub(4),
1613 input_area
1614 .height
1615 .saturating_sub(top_padding + bottom_padding),
1616 )
1617}
1618
1619fn prompt_content_width(input_width: u16) -> u16 {
1620 input_width.saturating_sub(4)
1621}
1622
1623#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1624struct BottomContentHeights {
1625 queue: u16,
1626 queue_separator: u16,
1627 list: u16,
1628 list_separator: u16,
1629 prompt: u16,
1630 status_separator: u16,
1631 status: u16,
1632}
1633
1634fn bottom_content_heights(state: &UiState, input_area: Rect) -> BottomContentHeights {
1638 let mut available = console_content_area(input_area).height;
1639 let status = available.min(1);
1640 available -= status;
1641
1642 let prompt = input_visible_rows(state, prompt_content_width(input_area.width))
1643 .clamp(1, MAX_INPUT_ROWS)
1644 .min(available);
1645 available -= prompt;
1646
1647 let status_separator = u16::from(status > 0 && prompt > 0 && available > 0);
1648 available -= status_separator;
1649
1650 let requested_queue = message_queue_height(state);
1651 let (queue, queue_separator) = if requested_queue > 0 && available >= 3 {
1652 (requested_queue.min(available - 1), 1)
1653 } else {
1654 (0, 0)
1655 };
1656 available -= queue + queue_separator;
1657
1658 let requested_list = 0;
1659 let (list, list_separator) = if requested_list > 0 && available >= 3 {
1660 (requested_list.min(available - 1), 1)
1661 } else {
1662 (0, 0)
1663 };
1664
1665 BottomContentHeights {
1666 queue,
1667 queue_separator,
1668 list,
1669 list_separator,
1670 prompt,
1671 status_separator,
1672 status,
1673 }
1674}
1675
1676fn prompt_area(input_area: Rect, state: &UiState) -> Rect {
1677 let inner = console_content_area(input_area);
1678 let content = bottom_content_heights(state, input_area);
1679 Rect::new(
1680 inner.x,
1681 inner.y + content.queue + content.queue_separator,
1682 inner.width,
1683 content.prompt,
1684 )
1685}
1686
1687fn message_queue_height(state: &UiState) -> u16 {
1688 let messages = state.queued_messages.len().min(u16::MAX as usize - 1) as u16;
1689 u16::from(messages > 0) + messages
1690}
1691
1692fn max_scroll_for_area(state: &UiState, size: Size) -> u16 {
1693 let area = tui_viewport(Rect::new(0, 0, size.width, size.height));
1694 let (chat_chunk, _, _, _, _, _) = ui_layout(state, area);
1695 let chat_height = chat_chunk.height;
1696 let lines = transcript_lines(state, chat_chunk.width);
1697 lines
1698 .len()
1699 .saturating_sub(chat_height as usize)
1700 .min(u16::MAX as usize) as u16
1701}
1702
1703const TRANSCRIPT_SCROLLBAR_TRACK: &str = "┆";
1704const TRANSCRIPT_SCROLLBAR_THUMB: &str = "█";
1705const TRANSCRIPT_SCROLLBAR_TRACK_COLOR: Color = Color::Rgb(72, 72, 76);
1706
1707fn draw_transcript_scrollbar(
1708 frame: &mut Frame<'_>,
1709 area: Rect,
1710 total_lines: usize,
1711 max_scroll: u16,
1712 scroll: u16,
1713) {
1714 if area.width == 0 || area.height == 0 || total_lines == 0 || max_scroll == 0 {
1715 return;
1716 }
1717
1718 let track_height = area.height as usize;
1719 let thumb_height = ((track_height * track_height) / total_lines)
1720 .max(1)
1721 .min(track_height);
1722 let thumb_range = track_height.saturating_sub(thumb_height);
1723 let thumb_start = (usize::from(scroll.min(max_scroll)) * thumb_range / usize::from(max_scroll))
1724 .min(thumb_range);
1725 let x = area.x + area.width - 1;
1726 let buffer = frame.buffer_mut();
1727
1728 for offset in 0..track_height {
1729 let y = area.y + offset as u16;
1730 buffer[(x, y)].set_symbol(TRANSCRIPT_SCROLLBAR_TRACK);
1731 buffer[(x, y)].set_fg(TRANSCRIPT_SCROLLBAR_TRACK_COLOR);
1732 }
1733 for offset in thumb_start..thumb_start + thumb_height {
1734 let y = area.y + offset as u16;
1735 buffer[(x, y)].set_symbol(TRANSCRIPT_SCROLLBAR_THUMB);
1736 buffer[(x, y)].set_fg(CONSOLE_STATUS_COLOR);
1737 }
1738}
1739
1740fn input_visible_rows(state: &UiState, width: u16) -> u16 {
1742 let width = width as usize;
1743 if width == 0 {
1744 return 1;
1745 }
1746 let prompt = input_display_text(state);
1747 let wrapped = wrap_text(&prompt, width);
1748 wrapped.len().max(1) as u16
1749}
1750
1751fn input_prompt(input: &str) -> String {
1752 input.to_owned()
1753}
1754
1755fn input_display_text(state: &UiState) -> String {
1756 redact_secret(&input_prompt(&state.input), Some(&state.secret))
1757}
1758
1759fn command_names(mut skill_names: Vec<String>) -> Vec<String> {
1760 skill_names.extend(BUILTIN_COMMANDS.into_iter().map(str::to_owned));
1761 skill_names.sort();
1762 skill_names.dedup();
1763 skill_names
1764}
1765
1766#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1767enum BuiltinCommand {
1768 Settings,
1769 Exit,
1770}
1771
1772impl BuiltinCommand {
1773 fn name(self) -> &'static str {
1774 match self {
1775 Self::Settings => "settings",
1776 Self::Exit => "exit",
1777 }
1778 }
1779}
1780
1781fn builtin_command(input: &str) -> Option<BuiltinCommand> {
1782 match input.split_whitespace().next()? {
1783 "/settings" => Some(BuiltinCommand::Settings),
1784 "/exit" => Some(BuiltinCommand::Exit),
1785 _ => None,
1786 }
1787}
1788
1789fn matching_skill_names<'a>(input: &str, skill_names: &'a [String]) -> Vec<&'a str> {
1792 let Some(query) = input.strip_prefix('/') else {
1793 return Vec::new();
1794 };
1795 if query.chars().any(char::is_whitespace) {
1796 return Vec::new();
1797 }
1798 skill_names
1799 .iter()
1800 .map(String::as_str)
1801 .filter(|name| name.starts_with(query))
1802 .collect()
1803}
1804
1805fn skill_picker_height(state: &UiState) -> u16 {
1806 if state.skill_picker_visible() {
1807 (state
1809 .matching_skill_names()
1810 .len()
1811 .min(SKILL_PICKER_MAX_ROWS)
1812 + 3) as u16
1813 } else {
1814 0
1815 }
1816}
1817
1818fn active_skill_trigger<'a>(input: &'a str, skill_names: &[String]) -> Option<&'a str> {
1822 let invocation = input.strip_prefix('/')?;
1823 let name = invocation
1824 .split_once(char::is_whitespace)
1825 .map_or(invocation, |(name, _)| name);
1826 if name.is_empty() || !skill_names.iter().any(|skill_name| skill_name == name) {
1827 return None;
1828 }
1829 Some(&input[..1 + name.len()])
1830}
1831
1832fn styled_text_lines(
1835 input: &str,
1836 active_skill_trigger: Option<&str>,
1837 width: usize,
1838 text_style: Style,
1839) -> Vec<Line<'static>> {
1840 let trigger_len = active_skill_trigger.map_or(0, |trigger| trigger.chars().count());
1841 let mut char_offset = 0usize;
1842 let mut lines = Vec::new();
1843
1844 for source_line in input.split('\n') {
1845 for row in wrap_line(source_line, width) {
1846 let mut spans = Vec::new();
1847 let mut text = String::new();
1848 let mut highlighted = None;
1849 for character in row.chars() {
1850 let should_highlight = char_offset < trigger_len;
1851 if highlighted != Some(should_highlight) && !text.is_empty() {
1852 spans.push(styled_text_span(
1853 std::mem::take(&mut text),
1854 highlighted.unwrap_or(false),
1855 text_style,
1856 ));
1857 }
1858 highlighted = Some(should_highlight);
1859 text.push(character);
1860 char_offset += 1;
1861 }
1862 if !text.is_empty() {
1863 spans.push(styled_text_span(
1864 text,
1865 highlighted.unwrap_or(false),
1866 text_style,
1867 ));
1868 }
1869 if spans.is_empty() {
1870 spans.push(Span::styled(String::new(), text_style));
1871 }
1872 lines.push(Line::from(spans));
1873 }
1874 char_offset += 1;
1877 }
1878
1879 lines
1880}
1881
1882fn styled_text_span(text: String, highlighted: bool, text_style: Style) -> Span<'static> {
1883 if highlighted {
1884 Span::styled(text, Style::default().fg(SKILL_TRIGGER_COLOR))
1885 } else {
1886 Span::styled(text, text_style)
1887 }
1888}
1889
1890#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1891struct InputVisualRow {
1892 start: usize,
1893 end: usize,
1894}
1895
1896fn input_visual_rows(input: &str, width: usize) -> Vec<InputVisualRow> {
1897 let width = width.max(1);
1898 let characters = input.chars().collect::<Vec<_>>();
1899 let mut rows = Vec::new();
1900 let mut start = 0;
1901 let mut row_width = 0;
1902
1903 for (index, character) in characters.iter().enumerate() {
1904 if *character == '\n' {
1905 rows.push(InputVisualRow { start, end: index });
1906 start = index + 1;
1907 row_width = 0;
1908 continue;
1909 }
1910
1911 let character_width = unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0);
1912 if row_width + character_width > width && index > start {
1913 rows.push(InputVisualRow { start, end: index });
1914 start = index;
1915 row_width = 0;
1916 }
1917 row_width += character_width;
1918 }
1919
1920 rows.push(InputVisualRow {
1921 start,
1922 end: characters.len(),
1923 });
1924 rows
1925}
1926
1927fn input_cursor_row(input: &str, cursor: usize, width: usize) -> usize {
1928 let rows = input_visual_rows(input, width);
1929 let cursor = cursor.min(input.chars().count());
1930 for (index, row) in rows.iter().enumerate() {
1931 if cursor < row.end {
1932 return index;
1933 }
1934 if cursor == row.end && rows.get(index + 1).is_none_or(|next| next.start != cursor) {
1935 return index;
1936 }
1937 }
1938 rows.len().saturating_sub(1)
1939}
1940
1941fn cursor_row(input: &str, cursor: usize, width: usize) -> u16 {
1942 input_cursor_row(input, cursor, width).min(u16::MAX as usize) as u16
1943}
1944
1945fn move_up_from_input(state: &mut UiState, width: usize) -> bool {
1946 state.move_skill_picker(false) || move_input_cursor_vertical(state, width, false)
1947}
1948
1949fn move_down_from_input(state: &mut UiState, width: usize) -> bool {
1950 let width = width.max(1);
1951 state.move_skill_picker(true) || move_input_cursor_vertical(state, width, true)
1952}
1953
1954fn move_input_cursor_vertical(state: &mut UiState, width: usize, down: bool) -> bool {
1955 let width = width.max(1);
1956 let rows = input_visual_rows(&state.input, width);
1957 let current_row = input_cursor_row(&state.input, state.cursor, width);
1958 let target_row = if down {
1959 current_row + 1
1960 } else {
1961 current_row.saturating_sub(1)
1962 };
1963 if target_row == current_row || target_row >= rows.len() {
1964 return false;
1965 }
1966
1967 let characters = state.input.chars().collect::<Vec<_>>();
1968 let current = rows[current_row];
1969 let cursor = state.cursor.min(current.end);
1970 let desired_column = characters[current.start..cursor]
1971 .iter()
1972 .map(|character| unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0))
1973 .sum::<usize>();
1974 let target = rows[target_row];
1975 let mut column = 0;
1976 let mut target_cursor = target.end;
1977 for (index, character) in characters
1978 .iter()
1979 .enumerate()
1980 .take(target.end)
1981 .skip(target.start)
1982 {
1983 let character_width = unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0);
1984 if column + character_width > desired_column {
1985 target_cursor = index;
1986 break;
1987 }
1988 column += character_width;
1989 if column >= desired_column {
1990 target_cursor = index + 1;
1991 break;
1992 }
1993 }
1994 state.cursor = target_cursor;
1995 true
1996}
1997
1998fn insert_at_cursor(input: &mut String, cursor: &mut usize, character: char) {
1999 let byte_index = input
2000 .char_indices()
2001 .nth(*cursor)
2002 .map_or(input.len(), |(index, _)| index);
2003 input.insert(byte_index, character);
2004 *cursor += 1;
2005}
2006
2007fn remove_before_cursor(input: &mut String, cursor: &mut usize) -> bool {
2008 if *cursor == 0 {
2009 return false;
2010 }
2011 let end = input
2012 .char_indices()
2013 .nth(*cursor)
2014 .map_or(input.len(), |(index, _)| index);
2015 let start = input
2016 .char_indices()
2017 .nth(*cursor - 1)
2018 .map(|(index, _)| index)
2019 .unwrap_or(0);
2020 input.replace_range(start..end, "");
2021 *cursor -= 1;
2022 true
2023}
2024
2025fn draw(frame: &mut Frame<'_>, state: &UiState) {
2026 let full_area = frame.area();
2027 frame.render_widget(Clear, full_area);
2030 let area = tui_viewport(full_area);
2031 let (chat_chunk, picker_area, _, queue_area, input_chunk, status_area) = ui_layout(state, area);
2032
2033 let visible_chat_area = chat_chunk;
2036
2037 let width = chat_chunk.width;
2038 let welcome_image_layout = if state.welcome_visible && greeting_image_enabled() {
2039 let welcome_lines = welcome_lines(&state.attached_agents);
2040 welcome_image_layout(visible_chat_area, welcome_lines.len() as u16)
2041 } else {
2042 None
2043 };
2044 if state.welcome_visible {
2045 let welcome_lines = welcome_lines(&state.attached_agents);
2046 if let Some(layout) = welcome_image_layout {
2047 let welcome = Paragraph::new(welcome_lines).alignment(Alignment::Center);
2048 frame.render_widget(welcome, layout.intro_area);
2049 } else {
2050 let logo = logo_lines();
2051 let logo_gap = 2u16;
2052 let total_height = logo.len() as u16 + logo_gap + welcome_lines.len() as u16;
2053 let lines = if total_height <= visible_chat_area.height {
2056 let mut all = logo;
2057 all.push(Line::raw(""));
2058 all.push(Line::raw(""));
2059 all.extend(welcome_lines);
2060 all
2061 } else {
2062 welcome_lines
2063 };
2064 let welcome_height = (lines.len() as u16).min(visible_chat_area.height);
2065 let welcome_area = Rect::new(
2066 visible_chat_area.x,
2067 visible_chat_area.y + visible_chat_area.height.saturating_sub(welcome_height) / 2,
2068 visible_chat_area.width,
2069 welcome_height,
2070 );
2071 let welcome = Paragraph::new(lines).alignment(Alignment::Center);
2072 frame.render_widget(welcome, welcome_area);
2073 }
2074 } else {
2075 let lines = transcript_lines(state, width);
2076 let available = visible_chat_area.height as usize;
2077 let max_scroll = lines.len().saturating_sub(available).min(u16::MAX as usize) as u16;
2078 let scroll = if state.auto_scroll {
2079 max_scroll
2080 } else {
2081 state.scroll.min(max_scroll)
2082 };
2083 let total_lines = lines.len();
2084 let transcript = Paragraph::new(lines).scroll((scroll, 0));
2085 frame.render_widget(transcript, visible_chat_area);
2086 if !state.auto_scroll {
2087 draw_transcript_scrollbar(frame, visible_chat_area, total_lines, max_scroll, scroll);
2088 }
2089 }
2090
2091 let activity_now = Instant::now();
2092 let activity_elapsed = state.console_animation_elapsed_at(activity_now);
2093 let console_visibility = state.console_visibility_at(activity_now);
2094 apply_tui_glow(
2095 frame,
2096 full_area,
2097 input_chunk,
2098 activity_elapsed,
2099 console_visibility,
2100 );
2101 if chat_chunk.y.saturating_add(chat_chunk.height) < input_chunk.y {
2104 apply_console_top_reflection(frame, input_chunk, activity_elapsed, console_visibility);
2105 }
2106 if let Some(layout) = welcome_image_layout {
2107 let image = welcome_image(layout.image_size);
2108 frame.render_widget(TuiImage::new(image.as_ref()), layout.image_area);
2109 }
2110 if let Some(picker_area) = picker_area {
2111 draw_skill_picker(frame, state, picker_area);
2112 }
2113
2114 if let Some(queue_area) = queue_area {
2115 draw_message_queue(frame, state, queue_area);
2116 }
2117
2118 let input_text_style = Style::default().fg(Color::White);
2119 let prompt_area = prompt_area(input_chunk, state);
2120 let prompt = input_display_text(state);
2121 let input_rows = input_visible_rows(state, prompt_area.width).clamp(1, MAX_INPUT_ROWS);
2122 let wrapped = wrap_text(&prompt, prompt_area.width.max(1) as usize);
2123 let visible = (wrapped.len() as u16)
2124 .clamp(1, input_rows)
2125 .min(prompt_area.height);
2126 let cursor_row = cursor_row(&prompt, state.cursor, prompt_area.width.max(1) as usize);
2127 let bottom_scroll = (wrapped.len() as u16).saturating_sub(visible);
2128 let cursor_scroll = (cursor_row + 1).saturating_sub(visible);
2129 let input_scroll = cursor_scroll.min(bottom_scroll);
2130 let active_skill_trigger = (!state.busy)
2131 .then(|| active_skill_trigger(&prompt, &state.skill_names))
2132 .flatten();
2133 let input_lines = styled_text_lines(
2134 &prompt,
2135 active_skill_trigger,
2136 prompt_area.width.max(1) as usize,
2137 input_text_style,
2138 );
2139 let input = Paragraph::new(input_lines)
2140 .style(input_text_style)
2141 .scroll((input_scroll, 0));
2142 frame.render_widget(input, prompt_area);
2143
2144 let effort = state.effort.as_deref().unwrap_or("default");
2145 frame.render_widget(
2146 Paragraph::new(model_status_line(state, effort, status_area.width)),
2147 status_area,
2148 );
2149
2150 apply_console_background(frame, input_chunk, activity_elapsed, console_visibility);
2151 if let Some(settings) = &state.settings {
2152 draw_settings(frame, settings, area);
2153 }
2154
2155 if state.terminal_focused && state.settings.is_none() && !prompt_area.is_empty() && visible > 0
2158 {
2159 let cursor_prefix: String = prompt.chars().take(state.cursor).collect();
2160 let cursor_rows = wrap_text(&cursor_prefix, prompt_area.width.max(1) as usize);
2161 let cursor_line = cursor_rows.last().map(String::as_str).unwrap_or("");
2162 let cursor_offset = UnicodeWidthStr::width(cursor_line) as u16;
2163 let cursor_x = prompt_area.x + cursor_offset.min(prompt_area.width.saturating_sub(1));
2164 let cursor_y = prompt_area.y
2165 + cursor_row
2166 .saturating_sub(input_scroll)
2167 .min(prompt_area.height.saturating_sub(1));
2168 frame.set_cursor_position((cursor_x, cursor_y));
2169 }
2170}
2171
2172fn draw_message_queue(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
2173 if area.is_empty() || state.queued_messages.is_empty() {
2174 return;
2175 }
2176
2177 let chrome = Style::default().fg(SECTION_CHROME_COLOR);
2178 let message = Style::default().fg(QUEUED_MESSAGE_COLOR);
2179 let mut lines = vec![Line::styled("Queued", chrome)];
2180 lines.extend(
2181 state
2182 .queued_messages
2183 .iter()
2184 .take(area.height.saturating_sub(1) as usize)
2185 .enumerate()
2186 .map(|(index, queued)| {
2187 Line::from(vec![
2188 Span::styled("│ ", chrome),
2189 Span::styled(
2190 format!("{}) {}", index + 1, single_line_preview(queued)),
2191 message,
2192 ),
2193 ])
2194 }),
2195 );
2196 frame.render_widget(Paragraph::new(lines), area);
2197}
2198
2199fn single_line_preview(text: &str) -> String {
2200 truncate_output(&text.replace(['\n', '\r'], " ↵ "))
2201}
2202
2203enum SettingsState {
2204 Loading,
2205 Applying {
2206 model: String,
2207 effort: Option<String>,
2208 },
2209 Error(String),
2210 Models {
2211 models: Vec<ProviderModel>,
2212 query: String,
2213 focus: usize,
2214 },
2215 Effort {
2216 model: ProviderModel,
2217 input: String,
2218 focus: usize,
2219 },
2220}
2221fn draw_settings(frame: &mut Frame<'_>, settings: &SettingsState, area: Rect) {
2222 let width = area
2223 .width
2224 .saturating_sub(2)
2225 .min(SETTINGS_MAX_WIDTH)
2226 .max(SETTINGS_MIN_WIDTH.min(area.width));
2227 let height = area
2228 .height
2229 .saturating_sub(2)
2230 .min(SETTINGS_MAX_HEIGHT)
2231 .max(SETTINGS_MIN_HEIGHT.min(area.height));
2232 let popup = Rect::new(
2233 area.x + area.width.saturating_sub(width) / 2,
2234 area.y + area.height.saturating_sub(height) / 2,
2235 width,
2236 height,
2237 );
2238 frame.render_widget(Clear, popup);
2239 let block = Block::default()
2240 .title(" /settings ")
2241 .borders(Borders::ALL)
2242 .border_style(Style::default().fg(Color::Cyan));
2243 let inner = block.inner(popup);
2244 frame.render_widget(block, popup);
2245
2246 let lines = match settings {
2247 SettingsState::Loading => vec![
2248 Line::styled("Loading provider models…", Style::default().fg(Color::Cyan)),
2249 Line::raw(""),
2250 Line::styled("Esc cancel", Style::default().fg(Color::DarkGray)),
2251 ],
2252 SettingsState::Applying { model, effort } => vec![
2253 Line::styled("Applying selection…", Style::default().fg(Color::Cyan)),
2254 Line::raw(model.clone()),
2255 Line::raw(format!(
2256 "effort: {}",
2257 effort.as_deref().unwrap_or("default")
2258 )),
2259 ],
2260 SettingsState::Error(error) => vec![
2261 Line::styled("Unable to update settings", Style::default().fg(Color::Red)),
2262 Line::raw(""),
2263 Line::raw(error.clone()),
2264 Line::raw(""),
2265 Line::styled("Enter/Esc close", Style::default().fg(Color::DarkGray)),
2266 ],
2267 SettingsState::Models {
2268 models,
2269 query,
2270 focus,
2271 } => {
2272 let query_lower = query.to_lowercase();
2273 let filtered = models
2274 .iter()
2275 .filter(|model| model.id.to_lowercase().contains(&query_lower))
2276 .collect::<Vec<_>>();
2277 let focus = (*focus).min(filtered.len().saturating_sub(1));
2278 let list_rows = inner.height.saturating_sub(4) as usize;
2279 let range = selection_range(filtered.len(), focus, list_rows);
2280 let mut lines = vec![
2281 Line::from(vec![
2282 Span::styled("Model ", Style::default().fg(Color::DarkGray)),
2283 Span::styled(
2284 if query.is_empty() {
2285 "type to filter…"
2286 } else {
2287 query
2288 },
2289 Style::default().fg(if query.is_empty() {
2290 Color::DarkGray
2291 } else {
2292 Color::White
2293 }),
2294 ),
2295 ]),
2296 Line::styled(
2297 format!(
2298 "{} models{}",
2299 filtered.len(),
2300 if filtered.is_empty() {
2301 ""
2302 } else {
2303 " · ↑/↓ move · Enter choose"
2304 }
2305 ),
2306 Style::default().fg(Color::DarkGray),
2307 ),
2308 ];
2309 if filtered.is_empty() {
2310 lines.push(Line::styled(
2311 "No matching models",
2312 Style::default().fg(Color::Yellow),
2313 ));
2314 } else {
2315 for index in range {
2316 let selected = index == focus;
2317 lines.push(Line::styled(
2318 format!(
2319 "{} {}",
2320 if selected { "›" } else { " " },
2321 filtered[index].id
2322 ),
2323 if selected {
2324 Style::default().fg(Color::Black).bg(Color::Cyan)
2325 } else {
2326 Style::default().fg(Color::White)
2327 },
2328 ));
2329 }
2330 }
2331 lines.push(Line::styled(
2332 "Esc cancel",
2333 Style::default().fg(Color::DarkGray),
2334 ));
2335 lines
2336 }
2337 SettingsState::Effort {
2338 model,
2339 input,
2340 focus,
2341 } => {
2342 let mut lines = vec![
2343 Line::styled(model.id.clone(), Style::default().fg(Color::Cyan)),
2344 Line::styled("Reasoning effort", Style::default().fg(Color::DarkGray)),
2345 ];
2346 match &model.efforts {
2347 Some(efforts) => {
2348 let total = efforts.len() + 1;
2349 let focus = (*focus).min(total.saturating_sub(1));
2350 let list_rows = inner.height.saturating_sub(4) as usize;
2351 for index in selection_range(total, focus, list_rows) {
2352 let value = if index == 0 {
2353 "default"
2354 } else {
2355 efforts[index - 1].as_str()
2356 };
2357 let selected = index == focus;
2358 lines.push(Line::styled(
2359 format!("{} {value}", if selected { "›" } else { " " }),
2360 if selected {
2361 Style::default().fg(Color::Black).bg(Color::Cyan)
2362 } else {
2363 Style::default().fg(Color::White)
2364 },
2365 ));
2366 }
2367 lines.push(Line::styled(
2368 "↑/↓ move · Enter save · Esc cancel",
2369 Style::default().fg(Color::DarkGray),
2370 ));
2371 }
2372 None => {
2373 lines.push(Line::raw("Provider did not advertise allowed efforts."));
2374 lines.push(Line::from(vec![
2375 Span::styled("Value ", Style::default().fg(Color::DarkGray)),
2376 Span::styled(
2377 if input.is_empty() { "default" } else { input },
2378 Style::default().fg(Color::White),
2379 ),
2380 ]));
2381 lines.push(Line::styled(
2382 "Type a value · Enter save · Esc cancel",
2383 Style::default().fg(Color::DarkGray),
2384 ));
2385 }
2386 }
2387 lines
2388 }
2389 };
2390 frame.render_widget(Paragraph::new(lines), inner);
2391}
2392
2393fn selection_range(total: usize, focus: usize, max_rows: usize) -> std::ops::Range<usize> {
2394 if total == 0 || max_rows == 0 {
2395 return 0..0;
2396 }
2397 let focus = focus.min(total - 1);
2398 let visible = total.min(max_rows);
2399 let start = focus
2400 .saturating_add(1)
2401 .saturating_sub(visible)
2402 .min(total - visible);
2403 start..start + visible
2404}
2405
2406fn draw_skill_picker(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
2407 let matches = state.matching_skill_names();
2408 let total = matches.len();
2409 if total == 0 || area.is_empty() {
2410 return;
2411 }
2412
2413 frame.render_widget(Clear, area);
2416 let inner = Rect::new(
2417 area.x.saturating_add(2),
2418 area.y.saturating_add(1),
2419 area.width.saturating_sub(4),
2420 area.height.saturating_sub(2),
2421 );
2422 let buffer = frame.buffer_mut();
2423 for y in area.y..area.y.saturating_add(area.height) {
2424 for x in area.x..area.x.saturating_add(area.width) {
2425 buffer[(x, y)].set_bg(SKILL_PICKER_BACKGROUND);
2426 }
2427 }
2428 if inner.is_empty() {
2429 return;
2430 }
2431
2432 let focus = state.skill_picker_focus.min(total - 1);
2433 let header = Line::styled(
2434 format!("[{}/{}]", focus + 1, total),
2435 Style::default().fg(QUEUED_MESSAGE_COLOR),
2436 );
2437 frame.render_widget(
2438 Paragraph::new(header),
2439 Rect::new(inner.x, inner.y, inner.width, 1),
2440 );
2441
2442 let item_rows = inner.height.saturating_sub(1) as usize;
2443 for (row, index) in selection_range(total, focus, item_rows).enumerate() {
2444 let mut style = Style::default().fg(QUEUED_MESSAGE_COLOR);
2445 if index == focus {
2446 style = style.add_modifier(Modifier::BOLD);
2447 }
2448 let skill = Line::styled(format!("/{}", matches[index]), style);
2449 frame.render_widget(
2450 Paragraph::new(skill),
2451 Rect::new(inner.x, inner.y + 1 + row as u16, inner.width, 1),
2452 );
2453 }
2454}
2455
2456fn greeting_image_enabled() -> bool {
2457 std::env::var("LUCY_GREETING_IMAGE").as_deref() == Ok("true")
2458}
2459
2460#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2461struct WelcomeImageLayout {
2462 image_area: Rect,
2463 intro_area: Rect,
2464 image_size: Size,
2465}
2466
2467fn welcome_image_layout(area: Rect, intro_height: u16) -> Option<WelcomeImageLayout> {
2468 let available_height = area
2469 .height
2470 .saturating_sub(intro_height.saturating_add(WELCOME_IMAGE_GAP));
2471 let max_width = area.width.min(GREETING_IMAGE_SIZE.width);
2472 let max_height = available_height.min(GREETING_IMAGE_SIZE.height);
2473 let aspect_width = GREETING_IMAGE_SIZE.width / GREETING_IMAGE_SIZE.height;
2474 let image_height = max_height.min(max_width / aspect_width);
2475 let image_size = Size::new(image_height * aspect_width, image_height);
2476 if image_size.width < GREETING_IMAGE_MIN_SIZE.width
2477 || image_size.height < GREETING_IMAGE_MIN_SIZE.height
2478 {
2479 return None;
2480 }
2481
2482 let group_height = image_size.height + WELCOME_IMAGE_GAP + intro_height;
2483 let group_y = area.y + area.height.saturating_sub(group_height) / 2;
2484 Some(WelcomeImageLayout {
2485 image_area: Rect::new(
2486 area.x + (area.width - image_size.width) / 2,
2487 group_y,
2488 image_size.width,
2489 image_size.height,
2490 ),
2491 intro_area: Rect::new(
2492 area.x,
2493 group_y + image_size.height + WELCOME_IMAGE_GAP,
2494 area.width,
2495 intro_height,
2496 ),
2497 image_size,
2498 })
2499}
2500
2501type WelcomeImageCache = Mutex<HashMap<(u16, u16), Arc<Protocol>>>;
2502
2503fn welcome_image(size: Size) -> Arc<Protocol> {
2504 static IMAGES: OnceLock<WelcomeImageCache> = OnceLock::new();
2505 let images = IMAGES.get_or_init(|| Mutex::new(HashMap::new()));
2506 let mut images = images
2507 .lock()
2508 .expect("welcome image cache should not be poisoned");
2509 images
2510 .entry((size.width, size.height))
2511 .or_insert_with(|| {
2512 let image = image::load_from_memory(GREETING_IMAGE_BYTES)
2513 .expect("embedded greeting PNG should decode");
2514 let image = dim_welcome_image(image);
2515 Arc::new(
2516 Picker::halfblocks()
2517 .new_protocol(image, size, Resize::Fit(None))
2518 .expect("embedded greeting PNG should convert to halfblocks"),
2519 )
2520 })
2521 .clone()
2522}
2523
2524fn dim_welcome_image(image: image::DynamicImage) -> image::DynamicImage {
2525 let mut image = image.to_rgba8();
2526 for pixel in image.pixels_mut() {
2527 for channel in pixel.0.iter_mut().take(3) {
2528 *channel = (u16::from(*channel) * WELCOME_IMAGE_BRIGHTNESS_PERCENT / 100) as u8;
2529 }
2530 }
2531 image::DynamicImage::ImageRgba8(image)
2532}
2533
2534fn logo_lines() -> Vec<Line<'static>> {
2535 let max_width = LOGO_TEXT
2536 .lines()
2537 .map(|line| line.chars().count())
2538 .max()
2539 .unwrap_or(0);
2540 LOGO_TEXT
2541 .lines()
2542 .map(|line| {
2543 let spans: Vec<Span> = line
2544 .chars()
2545 .enumerate()
2546 .map(|(index, character)| {
2547 let progress = if max_width <= 1 {
2548 0.0
2549 } else {
2550 index as f32 / (max_width - 1) as f32
2551 };
2552 let color = Color::Rgb(
2553 interpolate_color(LOGO_START_COLOR.0, LOGO_END_COLOR.0, progress),
2554 interpolate_color(LOGO_START_COLOR.1, LOGO_END_COLOR.1, progress),
2555 interpolate_color(LOGO_START_COLOR.2, LOGO_END_COLOR.2, progress),
2556 );
2557 Span::styled(character.to_string(), Style::default().fg(color))
2558 })
2559 .collect();
2560 Line::from(spans)
2561 })
2562 .collect()
2563}
2564
2565fn welcome_line() -> Line<'static> {
2566 let character_count = WELCOME_MESSAGE.chars().count();
2567 let spans = WELCOME_MESSAGE
2568 .chars()
2569 .enumerate()
2570 .map(|(index, character)| {
2571 let progress = if character_count <= 1 {
2572 0.0
2573 } else {
2574 index as f32 / (character_count - 1) as f32
2575 };
2576 let color = Color::Rgb(
2577 interpolate_color(WELCOME_START_COLOR.0, WELCOME_END_COLOR.0, progress),
2578 interpolate_color(WELCOME_START_COLOR.1, WELCOME_END_COLOR.1, progress),
2579 interpolate_color(WELCOME_START_COLOR.2, WELCOME_END_COLOR.2, progress),
2580 );
2581 Span::styled(character.to_string(), Style::default().fg(color))
2582 })
2583 .collect::<Vec<_>>();
2584 Line::from(spans)
2585}
2586
2587fn interpolate_color(start: u8, end: u8, progress: f32) -> u8 {
2588 (start as f32 + (end as f32 - start as f32) * progress).round() as u8
2589}
2590
2591fn welcome_lines(attached_agents: &[String]) -> Vec<Line<'static>> {
2592 let mut lines = vec![
2593 welcome_line(),
2594 Line::styled(WELCOME_VERSION, Style::default().fg(Color::DarkGray)),
2595 Line::raw(""),
2596 Line::styled(WELCOME_TAGLINE, Style::default().fg(Color::DarkGray)),
2597 Line::raw(""),
2598 ];
2599
2600 if attached_agents.is_empty() {
2601 lines.push(Line::styled(
2602 "Attached AGENTS.md: none",
2603 Style::default().fg(Color::DarkGray),
2604 ));
2605 } else {
2606 lines.push(Line::styled(
2607 "Attached AGENTS.md:",
2608 Style::default().fg(Color::DarkGray),
2609 ));
2610 lines.extend(
2611 attached_agents.iter().map(|path| {
2612 Line::styled(format!("• {path}"), Style::default().fg(Color::DarkGray))
2613 }),
2614 );
2615 }
2616
2617 lines
2618}
2619
2620fn transcript_lines(state: &UiState, width: u16) -> Vec<Line<'static>> {
2621 render_transcript_items(&state.transcript, width.max(1) as usize, state)
2622}
2623
2624fn render_transcript_items(
2625 transcript: &[TranscriptItem],
2626 width: usize,
2627 state: &UiState,
2628) -> Vec<Line<'static>> {
2629 let mut lines = Vec::new();
2630 let mut rendered_item = false;
2631
2632 for (index, item) in transcript.iter().enumerate() {
2633 if is_result_attached_to_call(transcript, index) {
2636 continue;
2637 }
2638 if rendered_item {
2639 lines.push(Line::raw(String::new()));
2640 }
2641 match item {
2642 TranscriptItem::User {
2643 text,
2644 skill_instruction_attached,
2645 } => {
2646 let text = redact_secret(text, Some(&state.secret));
2647 let trigger = skill_instruction_attached
2648 .then(|| active_skill_trigger(&text, &state.skill_names))
2649 .flatten();
2650 push_user_message_block(&mut lines, &text, trigger, width);
2651 }
2652 TranscriptItem::Assistant(text) => {
2653 let text = redact_secret(text, Some(&state.secret));
2654 push_wrapped(&mut lines, &text, width, Style::default());
2655 }
2656 TranscriptItem::ToolCall {
2657 id,
2658 name,
2659 arguments,
2660 } => {
2661 let result = matching_tool_result(transcript, index, id);
2662 let segments = if name == "cmd" {
2663 cmd_tool_segments(id, arguments, result, state)
2664 } else {
2665 generic_tool_segments(name, arguments, result, state)
2666 };
2667 push_spans_wrapped(&mut lines, &segments, width);
2668 }
2669 TranscriptItem::ToolResult {
2670 id: _,
2671 name: _,
2672 result,
2673 } => {
2674 let result_text = format_tool_result(result);
2675 let result_text = redact_secret(&result_text, Some(&state.secret));
2676 push_spans_wrapped(&mut lines, &[(result_text, tool_result_style())], width);
2677 }
2678 TranscriptItem::Error(text) => {
2679 let text = redact_secret(text, Some(&state.secret));
2680 push_wrapped(&mut lines, &text, width, error_style());
2681 }
2682 TranscriptItem::Info(text) => {
2683 let text = redact_secret(text, Some(&state.secret));
2684 push_wrapped(&mut lines, &text, width, info_style());
2685 }
2686 TranscriptItem::Reasoning { complete } => {
2687 let text = if *complete {
2688 "Reasoning Complete".to_owned()
2689 } else {
2690 format!("Reasoning... {}", spinner_frame(state))
2691 };
2692 push_wrapped(&mut lines, &text, width, thinking_style());
2693 }
2694 }
2695 rendered_item = true;
2696 }
2697 if lines.is_empty() {
2698 lines.push(Line::raw(""));
2699 }
2700 lines
2701}
2702
2703fn running_tool_status(state: &UiState) -> String {
2705 tool_spinner_frame(state)
2706}
2707
2708fn cmd_tool_segments(
2709 call_id: &str,
2710 arguments: &str,
2711 result: Option<&Value>,
2712 state: &UiState,
2713) -> Vec<(String, Style)> {
2714 let command = redact_secret(&command_display(arguments), Some(&state.secret));
2715 if let Some(result) = result {
2716 let (icon, status, status_style) = cmd_result_status(result);
2717 if status == "done" || state.cmd_result_started_at.contains_key(call_id) {
2718 let text = if status == "done" {
2719 format!("{icon} cmd $ {command}")
2720 } else {
2721 format!("{icon} cmd $ {command} → {status}")
2722 };
2723 return cmd_result_segments(call_id, &text, cmd_result_target_color(result), state);
2724 }
2725 vec![
2726 (format!("{icon} cmd $ {command} → "), status_style),
2727 (status, status_style),
2728 ]
2729 } else {
2730 vec![
2731 (format!("· cmd $ {command} "), pending_tool_call_style()),
2732 (running_tool_status(state), pending_tool_call_style()),
2733 ]
2734 }
2735}
2736
2737fn cmd_result_segments(
2742 call_id: &str,
2743 text: &str,
2744 target: Color,
2745 state: &UiState,
2746) -> Vec<(String, Style)> {
2747 let now = Instant::now();
2748 let Some(started_at) = state.cmd_result_started_at.get(call_id).copied() else {
2749 return vec![(text.to_owned(), Style::default().fg(target))];
2750 };
2751 if now.saturating_duration_since(started_at) >= TOOL_RESULT_SWEEP_DURATION {
2752 return vec![(text.to_owned(), Style::default().fg(target))];
2753 }
2754
2755 let character_count = text.chars().count();
2756 text.chars()
2757 .enumerate()
2758 .map(|(index, character)| {
2759 (
2760 character.to_string(),
2761 Style::default().fg(cmd_result_color_at(
2762 started_at,
2763 now,
2764 index,
2765 character_count,
2766 target,
2767 )),
2768 )
2769 })
2770 .collect()
2771}
2772
2773fn cmd_result_color_at(
2774 started_at: Instant,
2775 now: Instant,
2776 character_index: usize,
2777 character_count: usize,
2778 target: Color,
2779) -> Color {
2780 let elapsed = now.saturating_duration_since(started_at);
2781 if elapsed >= TOOL_RESULT_SWEEP_DURATION {
2782 return target;
2783 }
2784
2785 let progress = elapsed.as_secs_f32() / TOOL_RESULT_SWEEP_DURATION.as_secs_f32();
2786 let character_position = if character_count <= 1 {
2787 0.0
2788 } else {
2789 character_index as f32 / (character_count - 1) as f32
2790 };
2791 let fade_start = character_position * (1.0 - TOOL_RESULT_CHARACTER_FADE_PORTION);
2792 let character_progress =
2793 ((progress - fade_start) / TOOL_RESULT_CHARACTER_FADE_PORTION).clamp(0.0, 1.0);
2794 let character_progress =
2795 character_progress * character_progress * (3.0 - 2.0 * character_progress);
2796 let (target_red, target_green, target_blue) = tool_result_color_rgb(target);
2797 Color::Rgb(
2798 interpolate_color(PENDING_TOOL_COLOR_RGB.0, target_red, character_progress),
2799 interpolate_color(PENDING_TOOL_COLOR_RGB.1, target_green, character_progress),
2800 interpolate_color(PENDING_TOOL_COLOR_RGB.2, target_blue, character_progress),
2801 )
2802}
2803
2804fn command_display(arguments: &str) -> String {
2805 serde_json::from_str::<Value>(arguments)
2806 .ok()
2807 .and_then(|value| {
2808 value
2809 .get("command")
2810 .and_then(Value::as_str)
2811 .map(str::to_owned)
2812 })
2813 .map(|command| truncate_tool_call(&command))
2814 .unwrap_or_else(|| truncate_tool_call(arguments))
2815}
2816
2817fn cmd_result_target_color(result: &Value) -> Color {
2818 if result
2819 .get("canceled")
2820 .and_then(Value::as_bool)
2821 .unwrap_or(false)
2822 || result
2823 .get("timed_out")
2824 .and_then(Value::as_bool)
2825 .unwrap_or(false)
2826 {
2827 return TOOL_WARNING_COLOR;
2828 }
2829 if result.get("error").is_some()
2830 || matches!(result.get("exit_code").and_then(Value::as_i64), Some(code) if code != 0)
2831 {
2832 return TOOL_FAILURE_COLOR;
2833 }
2834 TOOL_SUCCESS_COLOR
2835}
2836
2837fn tool_result_color_rgb(color: Color) -> (u8, u8, u8) {
2838 let Color::Rgb(red, green, blue) = color else {
2839 unreachable!("cmd result transition colours are RGB")
2840 };
2841 (red, green, blue)
2842}
2843
2844fn cmd_result_status(result: &Value) -> (char, String, Style) {
2845 let target = cmd_result_target_color(result);
2846 if result
2847 .get("canceled")
2848 .and_then(Value::as_bool)
2849 .unwrap_or(false)
2850 {
2851 return ('!', "canceled".to_owned(), Style::default().fg(target));
2852 }
2853 if result
2854 .get("timed_out")
2855 .and_then(Value::as_bool)
2856 .unwrap_or(false)
2857 {
2858 return ('!', "timeout".to_owned(), Style::default().fg(target));
2859 }
2860 if result.get("error").is_some() {
2861 return ('×', "error".to_owned(), Style::default().fg(target));
2862 }
2863 match result.get("exit_code").and_then(Value::as_i64) {
2864 Some(0) => ('✓', "done".to_owned(), Style::default().fg(target)),
2865 Some(code) => ('×', format!("exit {code}"), Style::default().fg(target)),
2866 None => ('✓', "done".to_owned(), Style::default().fg(target)),
2867 }
2868}
2869
2870fn generic_tool_segments(
2871 name: &str,
2872 arguments: &str,
2873 result: Option<&Value>,
2874 state: &UiState,
2875) -> Vec<(String, Style)> {
2876 let call_text = redact_secret(
2877 &format!("[tool:{name} {}]", call_arguments(arguments)),
2878 Some(&state.secret),
2879 );
2880 let mut segments = vec![(
2881 call_text,
2882 if result.is_some() {
2883 tool_call_style()
2884 } else {
2885 pending_tool_call_style()
2886 },
2887 )];
2888 if let Some(result) = result {
2889 let result_text = redact_secret(&format_tool_result(result), Some(&state.secret));
2890 segments.push((" > ".to_owned(), Style::default()));
2891 segments.push((result_text, tool_result_style()));
2892 } else {
2893 segments.push((
2894 format!(" {}", tool_spinner_frame(state)),
2895 pending_tool_call_style(),
2896 ));
2897 }
2898 segments
2899}
2900
2901fn matching_tool_result<'a>(
2902 transcript: &'a [TranscriptItem],
2903 call_index: usize,
2904 call_id: &str,
2905) -> Option<&'a Value> {
2906 transcript
2907 .iter()
2908 .skip(call_index + 1)
2909 .find_map(|item| match item {
2910 TranscriptItem::ToolResult { id, result, .. } if id == call_id => Some(result),
2911 _ => None,
2912 })
2913}
2914
2915fn is_result_attached_to_call(transcript: &[TranscriptItem], result_index: usize) -> bool {
2916 let TranscriptItem::ToolResult { id, .. } = &transcript[result_index] else {
2917 return false;
2918 };
2919 let Some(call_index) = transcript[..result_index].iter().rposition(
2920 |item| matches!(item, TranscriptItem::ToolCall { id: call_id, .. } if call_id == id),
2921 ) else {
2922 return false;
2923 };
2924 !transcript[call_index + 1..result_index].iter().any(
2925 |item| matches!(item, TranscriptItem::ToolResult { id: result_id, .. } if result_id == id),
2926 )
2927}
2928
2929const TOOL_CALL_PREVIEW_CHARS: usize = 100;
2930
2931fn truncate_tool_call(output: &str) -> String {
2932 let mut result: String = output.chars().take(TOOL_CALL_PREVIEW_CHARS).collect();
2933 if output.chars().count() > TOOL_CALL_PREVIEW_CHARS {
2934 result.push('…');
2935 }
2936 result
2937}
2938
2939fn call_arguments(arguments: &str) -> String {
2943 let parsed: Value = match serde_json::from_str(arguments) {
2944 Ok(value) => value,
2945 Err(_) => return truncate_tool_call(arguments),
2946 };
2947 if let Some(command) = parsed.get("command").and_then(Value::as_str) {
2948 return format!("\"{}\"", truncate_tool_call(command));
2949 }
2950 let serialized = serde_json::to_string(&parsed).unwrap_or_else(|_| arguments.to_owned());
2951 truncate_tool_call(&serialized)
2952}
2953
2954fn format_tool_result(result: &Value) -> String {
2958 let stdout = result.get("stdout").and_then(Value::as_str).unwrap_or("");
2959 let stderr = result.get("stderr").and_then(Value::as_str).unwrap_or("");
2960 let output = if !stdout.is_empty() { stdout } else { stderr };
2961 let truncated = truncate_output(output);
2962 let json_string = serde_json::to_string(&truncated).unwrap_or_else(|_| "\"\"".to_owned());
2965 format!("[{json_string}]")
2966}
2967
2968const RESULT_PREVIEW_CHARS: usize = 50;
2969
2970fn truncate_output(output: &str) -> String {
2971 let mut result: String = output.chars().take(RESULT_PREVIEW_CHARS).collect();
2972 if output.chars().count() > RESULT_PREVIEW_CHARS {
2973 result.push('…');
2974 }
2975 result
2976}
2977
2978fn user_message_style() -> Style {
2979 Style::default().fg(USER_BORDER_COLOR)
2980}
2981
2982fn push_user_message_block(
2985 lines: &mut Vec<Line<'static>>,
2986 text: &str,
2987 active_skill_trigger: Option<&str>,
2988 width: usize,
2989) {
2990 if width < 3 {
2991 lines.extend(styled_text_lines(
2992 text,
2993 active_skill_trigger,
2994 width.max(1),
2995 Style::default().fg(Color::White),
2996 ));
2997 return;
2998 }
2999
3000 let border_style = user_message_style();
3001 let rows = styled_text_lines(
3002 text,
3003 active_skill_trigger,
3004 width - 2,
3005 Style::default().fg(Color::White),
3006 );
3007 lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
3008 for row in rows {
3009 let mut spans = Vec::with_capacity(row.spans.len() + 2);
3010 spans.push(Span::styled(USER_BORDER_GLYPH, border_style));
3011 spans.push(Span::styled(" ", Style::default().fg(Color::White)));
3012 spans.extend(row.spans);
3013 lines.push(Line::from(spans));
3014 }
3015 lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
3016}
3017
3018fn tool_call_style() -> Style {
3019 Style::default().fg(Color::Magenta)
3020}
3021
3022fn pending_tool_call_style() -> Style {
3023 Style::default().fg(PENDING_TOOL_COLOR)
3024}
3025
3026fn tool_result_style() -> Style {
3027 Style::default().fg(Color::DarkGray)
3028}
3029
3030fn error_style() -> Style {
3031 Style::default().fg(Color::Red)
3032}
3033
3034fn info_style() -> Style {
3035 Style::default().fg(Color::DarkGray)
3036}
3037
3038fn context_status_text(state: &UiState) -> String {
3039 let used = format_context_tokens(state.context_tokens);
3040 let Some(window) = state.context_window else {
3041 return format!("Context: {used}/? (?%) ??????????");
3042 };
3043 let percentage = context_percentage(state.context_tokens, window);
3044 format!(
3045 "Context: {used}/{} ({percentage}%) {}",
3046 format_context_tokens(window),
3047 context_progress_bar(state.context_tokens, window)
3048 )
3049}
3050
3051fn context_progress_bar(used: usize, window: usize) -> String {
3052 const WIDTH: usize = 10;
3053 let filled = if window == 0 {
3054 0
3055 } else {
3056 (used as u128 * WIDTH as u128)
3057 .div_ceil(window as u128)
3058 .min(WIDTH as u128) as usize
3059 };
3060 format!("{}{}", "█".repeat(filled), "░".repeat(WIDTH - filled))
3061}
3062
3063fn context_status_style(_state: &UiState) -> Style {
3064 Style::default().fg(CONSOLE_STATUS_COLOR)
3065}
3066
3067fn context_percentage(used: usize, window: usize) -> usize {
3068 if window == 0 {
3069 return 0;
3070 }
3071 ((used as u128 * 100).div_ceil(window as u128)) as usize
3072}
3073
3074fn format_context_tokens(tokens: usize) -> String {
3075 if tokens >= 1_000_000 {
3076 format!("{:.2}M", tokens as f64 / 1_000_000.0)
3077 } else if tokens >= 1_000 {
3078 format!("{:.1}K", tokens as f64 / 1_000.0)
3079 } else {
3080 tokens.to_string()
3081 }
3082}
3083
3084fn model_status_line(state: &UiState, effort: &str, width: u16) -> Line<'static> {
3085 model_status_line_at(
3086 state,
3087 effort,
3088 state.console_animation_elapsed_at(Instant::now()),
3089 width,
3090 )
3091}
3092
3093fn model_status_line_at(
3094 state: &UiState,
3095 effort: &str,
3096 elapsed: Duration,
3097 width: u16,
3098) -> Line<'static> {
3099 let model = redact_secret(&state.model, Some(&state.secret));
3100 let effort = redact_secret(effort, Some(&state.secret));
3101 let context = context_status_text(state);
3102 let context_width = UnicodeWidthStr::width(context.as_str());
3103 let model_style = if state.busy {
3104 Style::default().fg(console_accent_at(elapsed))
3105 } else {
3106 context_status_style(state)
3107 };
3108 let status_style = context_status_style(state);
3109 let mut spans = vec![
3110 Span::styled(model, model_style),
3111 Span::styled(format!(" · {effort}"), status_style),
3112 ];
3113 if state.busy {
3114 let accent = console_accent_at(elapsed);
3115 let (head, _) = busy_indicator_position_at(elapsed);
3116 spans.push(Span::raw(" "));
3117 for (index, character) in busy_indicator_frame_at(elapsed).chars().enumerate() {
3118 let distance = if character == BUSY_INDICATOR_BLOCK && index != head {
3119 Some(index.abs_diff(head))
3120 } else {
3121 None
3122 };
3123 let color = busy_indicator_color(accent, distance);
3124 spans.push(Span::styled(
3125 character.to_string(),
3126 Style::default().fg(color),
3127 ));
3128 }
3129 }
3130 let left_width = spans
3131 .iter()
3132 .map(|span| UnicodeWidthStr::width(span.content.as_ref()))
3133 .sum::<usize>();
3134 let gap = usize::from(width).saturating_sub(left_width + context_width);
3135 if gap > 0 {
3136 spans.push(Span::raw(" ".repeat(gap)));
3137 }
3138 spans.push(Span::styled(context, status_style));
3139 Line::from(spans)
3140}
3141
3142fn blend_rgb(from: Color, to: Color, progress: f32) -> Color {
3143 let (from_red, from_green, from_blue) = activity_rgb(from);
3144 let (to_red, to_green, to_blue) = activity_rgb(to);
3145 Color::Rgb(
3146 interpolate_color(from_red, to_red, progress),
3147 interpolate_color(from_green, to_green, progress),
3148 interpolate_color(from_blue, to_blue, progress),
3149 )
3150}
3151
3152fn activity_rgb(color: Color) -> (u8, u8, u8) {
3153 match color {
3154 Color::Rgb(red, green, blue) => (red, green, blue),
3155 Color::Cyan => (0, 255, 255),
3158 _ => unreachable!("activity transition colours are cyan or RGB"),
3159 }
3160}
3161
3162fn console_reach_at(elapsed: Duration) -> f32 {
3163 let phase = elapsed.as_secs_f32() / CONSOLE_BOUNDARY_CYCLE.as_secs_f32();
3164 let midpoint = (CONSOLE_REACH_MIN + CONSOLE_REACH_MAX) / 2.0;
3165 let amplitude = (CONSOLE_REACH_MAX - CONSOLE_REACH_MIN) / 2.0;
3166 midpoint + amplitude * (phase * std::f32::consts::TAU).sin()
3167}
3168
3169fn console_accent_cycle() -> Duration {
3170 CONSOLE_ACCENT_CYCLE_DURATION
3171}
3172
3173fn console_accent_at(elapsed: Duration) -> Color {
3174 let cycle_progress =
3175 (elapsed.as_secs_f32() / console_accent_cycle().as_secs_f32()).rem_euclid(1.0);
3176 let progress = if cycle_progress <= 0.5 {
3177 cycle_progress * 2.0
3178 } else {
3179 (1.0 - cycle_progress) * 2.0
3180 };
3181 desaturate_console_accent(
3182 interpolate_color(CONSOLE_ACCENT_LAVENDER.0, CONSOLE_ACCENT_TEAL.0, progress),
3183 interpolate_color(CONSOLE_ACCENT_LAVENDER.1, CONSOLE_ACCENT_TEAL.1, progress),
3184 interpolate_color(CONSOLE_ACCENT_LAVENDER.2, CONSOLE_ACCENT_TEAL.2, progress),
3185 )
3186}
3187
3188fn desaturate_console_accent(red: u8, green: u8, blue: u8) -> Color {
3189 let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
3190 Color::Rgb(
3191 interpolate_color(red, neutral, CONSOLE_ACCENT_DESATURATION),
3192 interpolate_color(green, neutral, CONSOLE_ACCENT_DESATURATION),
3193 interpolate_color(blue, neutral, CONSOLE_ACCENT_DESATURATION),
3194 )
3195}
3196
3197fn glow_coverage_at(column: u16, row: u16, canvas: Rect, console_area: Rect) -> f32 {
3204 let canvas_bottom = canvas.y.saturating_add(canvas.height);
3205 if canvas.is_empty() || row < canvas.y || row >= canvas_bottom {
3206 return 0.0;
3207 }
3208
3209 let left = console_area.x.max(canvas.x);
3210 let right = console_area
3211 .x
3212 .saturating_add(console_area.width)
3213 .min(canvas.x.saturating_add(canvas.width));
3214 if left >= right {
3215 return 0.0;
3216 }
3217
3218 let x = canvas.x.saturating_add(column);
3219 let center_x = (left as f32 + right.saturating_sub(1) as f32) / 2.0;
3220 let horizontal_radius = (right - left) as f32 / 2.0 + GLOW_HORIZONTAL_SPREAD as f32;
3221 let horizontal_distance = (x as f32 - center_x).abs() / horizontal_radius;
3222 let vertical_distance =
3225 (row.saturating_add(1) as f32 - canvas_bottom as f32).abs() / GLOW_HEIGHT as f32;
3226 let distance = horizontal_distance.hypot(vertical_distance);
3227 let falloff = (1.0 - distance).clamp(0.0, 1.0);
3228 falloff * falloff * (3.0 - 2.0 * falloff)
3229}
3230
3231fn glow_accent_at(elapsed: Duration) -> Color {
3232 glow_accent_with_desaturation_at(elapsed, GLOW_DESATURATION)
3233}
3234
3235fn glow_accent_with_desaturation_at(elapsed: Duration, desaturation: f32) -> Color {
3236 let (red, green, blue) = activity_rgb(console_accent_at(elapsed));
3237 let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
3238 Color::Rgb(
3239 interpolate_color(red, neutral, desaturation),
3240 interpolate_color(green, neutral, desaturation),
3241 interpolate_color(blue, neutral, desaturation),
3242 )
3243}
3244
3245fn glow_color_at(
3246 elapsed: Duration,
3247 column: u16,
3248 _width: u16,
3249 row: u16,
3250 canvas: Rect,
3251 console_area: Rect,
3252 visibility: f32,
3253) -> Option<Color> {
3254 let visibility = visibility.clamp(0.0, 1.0);
3255 let bottom = canvas.y.saturating_add(canvas.height);
3256 if visibility <= 0.0 || row < canvas.y || row >= bottom {
3257 return None;
3258 }
3259
3260 let coverage = glow_coverage_at(column, row, canvas, console_area);
3261 if coverage <= 0.0 {
3262 return None;
3263 }
3264
3265 let intensity =
3266 (console_reach_at(elapsed) / CONSOLE_REACH_MAX) * GLOW_INTENSITY * coverage * visibility;
3267 Some(blend_rgb(
3268 TUI_GLOW_BACKGROUND,
3269 glow_accent_at(elapsed),
3270 intensity,
3271 ))
3272}
3273
3274fn apply_tui_glow(
3275 frame: &mut Frame<'_>,
3276 canvas: Rect,
3277 console_area: Rect,
3278 elapsed: Duration,
3279 visibility: f32,
3280) {
3281 let left = console_area
3282 .x
3283 .saturating_sub(GLOW_HORIZONTAL_SPREAD)
3284 .max(canvas.x);
3285 let right = console_area
3286 .x
3287 .saturating_add(console_area.width)
3288 .saturating_add(GLOW_HORIZONTAL_SPREAD)
3289 .min(canvas.x.saturating_add(canvas.width));
3290 let bottom = canvas.y.saturating_add(canvas.height);
3291 let top = bottom.saturating_sub(GLOW_HEIGHT).max(canvas.y);
3292 let buffer = frame.buffer_mut();
3293 for y in top..bottom {
3294 for x in left..right {
3295 if let Some(color) = glow_color_at(
3296 elapsed,
3297 x.saturating_sub(canvas.x),
3298 canvas.width,
3299 y,
3300 canvas,
3301 console_area,
3302 visibility,
3303 ) {
3304 buffer[(x, y)].set_bg(color);
3305 }
3306 }
3307 }
3308}
3309
3310fn console_glass_color_at(elapsed: Duration, glow: Color, visibility: f32) -> Color {
3311 let (red, green, blue) = activity_rgb(console_accent_at(elapsed));
3312 let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
3313 let glass_accent = Color::Rgb(
3314 interpolate_color(red, neutral, CONSOLE_GLASS_DESATURATION),
3315 interpolate_color(green, neutral, CONSOLE_GLASS_DESATURATION),
3316 interpolate_color(blue, neutral, CONSOLE_GLASS_DESATURATION),
3317 );
3318 let visibility = visibility.clamp(0.0, 1.0);
3319 let tint = blend_rgb(
3320 CONSOLE_BACKGROUND,
3321 glass_accent,
3322 CONSOLE_GLASS_TINT * visibility,
3323 );
3324 let white_tinted = blend_rgb(
3325 tint,
3326 Color::Rgb(255, 255, 255),
3327 CONSOLE_GLASS_WHITE_TINT * visibility,
3328 );
3329 blend_rgb(white_tinted, glow, CONSOLE_GLASS_GLOW_THROUGH * visibility)
3330}
3331
3332fn console_top_reflection_color_at(elapsed: Duration, background: Color, visibility: f32) -> Color {
3334 let visibility = visibility.clamp(0.0, 1.0);
3335 let reflected = blend_rgb(
3336 background,
3337 glow_accent_at(elapsed),
3338 CONSOLE_REFLECTION_TINT * visibility,
3339 );
3340 blend_rgb(
3341 reflected,
3342 Color::Rgb(255, 255, 255),
3343 CONSOLE_REFLECTION_WHITE_TINT * visibility,
3344 )
3345}
3346
3347fn apply_console_top_reflection(
3351 frame: &mut Frame<'_>,
3352 area: Rect,
3353 elapsed: Duration,
3354 visibility: f32,
3355) {
3356 if visibility <= 0.0 || area.y == 0 {
3357 return;
3358 }
3359
3360 let y = area.y - 1;
3361 let buffer = frame.buffer_mut();
3362 for x in area.x..area.x.saturating_add(area.width) {
3363 let background = match buffer[(x, y)].bg {
3364 Color::Rgb(_, _, _) => buffer[(x, y)].bg,
3365 _ => TUI_GLOW_BACKGROUND,
3366 };
3367 buffer[(x, y)].set_symbol(CONSOLE_REFLECTION_GLYPH).set_fg(
3368 console_top_reflection_color_at(elapsed, background, visibility),
3369 );
3370 }
3371}
3372
3373fn apply_console_background(frame: &mut Frame<'_>, area: Rect, elapsed: Duration, visibility: f32) {
3375 let buffer = frame.buffer_mut();
3376 for y in area.y..area.y.saturating_add(area.height) {
3377 for x in area.x..area.x.saturating_add(area.width) {
3378 let glow = match buffer[(x, y)].bg {
3379 Color::Rgb(_, _, _) => buffer[(x, y)].bg,
3380 _ => TUI_GLOW_BACKGROUND,
3381 };
3382 buffer[(x, y)].set_bg(console_glass_color_at(elapsed, glow, visibility));
3383 }
3384 }
3385}
3386
3387fn thinking_style() -> Style {
3388 Style::default().fg(Color::DarkGray)
3389}
3390
3391const PULSE_LEVELS: [char; 7] = ['▁', '▂', '▃', '▅', '▆', '▇', '█'];
3394const PULSE_BAR_PERIODS: [u128; 5] = [12, 16, 20, 24, 15];
3395const PULSE_BAR_PHASES: [u128; 5] = [0, 5, 13, 9, 3];
3396const BUSY_INDICATOR_TRACK_LENGTH: usize = 5;
3397const BUSY_INDICATOR_TAIL_LENGTH: usize = 2;
3398const BUSY_INDICATOR_WIDTH: usize = BUSY_INDICATOR_TRACK_LENGTH + BUSY_INDICATOR_TAIL_LENGTH;
3399const BUSY_INDICATOR_BLOCK: char = '■';
3400const BUSY_INDICATOR_TAIL_OPACITY: [f32; BUSY_INDICATOR_TAIL_LENGTH] = [0.55, 0.25];
3401const BUSY_INDICATOR_PERIOD_TICKS: u128 = (BUSY_INDICATOR_TRACK_LENGTH as u128 - 1) * 2;
3402const BUSY_INDICATOR_TICK: Duration = Duration::from_micros(62_500);
3404const PULSE_TICK: Duration = Duration::from_millis(50);
3405const TOOL_SPINNER_FRAMES: [char; 4] = ['|', '/', '-', '\\'];
3406const TOOL_SPINNER_FRAME_DURATION: Duration = Duration::from_millis(100);
3407
3408const ACTIVITY_TRANSITION_DURATION: Duration = Duration::from_millis(400);
3412const PULSE_ENTRY_FRAME: Duration = Duration::from_millis(950);
3415
3416fn spinner_frame(state: &UiState) -> String {
3417 pulse_frame(state.activity_levels_at(Instant::now()))
3418}
3419
3420#[cfg(test)]
3421fn spinner_frame_at(elapsed: Duration) -> String {
3422 pulse_frame(pulse_levels_at(elapsed))
3423}
3424
3425fn tool_spinner_frame(state: &UiState) -> String {
3429 tool_spinner_frame_at(state.tool_animation_epoch.elapsed()).to_string()
3430}
3431
3432fn tool_spinner_frame_at(elapsed: Duration) -> char {
3433 let frame = (elapsed.as_millis() / TOOL_SPINNER_FRAME_DURATION.as_millis()) as usize;
3434 TOOL_SPINNER_FRAMES[frame % TOOL_SPINNER_FRAMES.len()]
3435}
3436
3437fn pulse_frame(levels: [usize; PULSE_BAR_PERIODS.len()]) -> String {
3438 levels
3439 .into_iter()
3440 .map(|level| PULSE_LEVELS[level])
3441 .collect()
3442}
3443
3444fn busy_indicator_position_at(elapsed: Duration) -> (usize, bool) {
3445 let tick = elapsed.as_micros() / BUSY_INDICATOR_TICK.as_micros();
3446 let phase = tick % BUSY_INDICATOR_PERIOD_TICKS;
3447 if phase < BUSY_INDICATOR_TRACK_LENGTH as u128 {
3448 (
3449 phase as usize,
3450 phase < BUSY_INDICATOR_TRACK_LENGTH as u128 - 1,
3451 )
3452 } else {
3453 ((BUSY_INDICATOR_PERIOD_TICKS - phase) as usize, false)
3454 }
3455}
3456
3457fn busy_indicator_frame_at(elapsed: Duration) -> String {
3458 let (head, moving_right) = busy_indicator_position_at(elapsed);
3459 let mut frame = vec![' '; BUSY_INDICATOR_WIDTH];
3460 frame[head] = BUSY_INDICATOR_BLOCK;
3461 for distance in 1..=BUSY_INDICATOR_TAIL_LENGTH {
3462 let tail = if moving_right {
3463 head.checked_sub(distance)
3464 } else {
3465 head.checked_add(distance)
3466 };
3467 if let Some(tail) = tail.filter(|&index| index < BUSY_INDICATOR_WIDTH) {
3468 frame[tail] = BUSY_INDICATOR_BLOCK;
3469 }
3470 }
3471 frame.into_iter().collect()
3472}
3473
3474fn busy_indicator_color(accent: Color, distance: Option<usize>) -> Color {
3477 let Some(distance) = distance else {
3478 return accent;
3479 };
3480 let Color::Rgb(red, green, blue) = accent else {
3481 return accent;
3482 };
3483 let opacity = BUSY_INDICATOR_TAIL_OPACITY
3484 .get(distance.saturating_sub(1))
3485 .copied()
3486 .unwrap_or(0.0);
3487 Color::Rgb(
3488 interpolate_color(CONSOLE_BACKGROUND_RGB.0, red, opacity),
3489 interpolate_color(CONSOLE_BACKGROUND_RGB.1, green, opacity),
3490 interpolate_color(CONSOLE_BACKGROUND_RGB.2, blue, opacity),
3491 )
3492}
3493fn pulse_levels_at(elapsed: Duration) -> [usize; PULSE_BAR_PERIODS.len()] {
3494 let tick = elapsed.as_millis() / PULSE_TICK.as_millis();
3495 std::array::from_fn(|index| {
3496 pulse_level_at(tick, PULSE_BAR_PERIODS[index], PULSE_BAR_PHASES[index])
3497 })
3498}
3499
3500fn interpolate_pulse_levels(
3501 from: [usize; PULSE_BAR_PERIODS.len()],
3502 to: [usize; PULSE_BAR_PERIODS.len()],
3503 elapsed: Duration,
3504) -> [usize; PULSE_BAR_PERIODS.len()] {
3505 let elapsed = elapsed.min(ACTIVITY_TRANSITION_DURATION).as_millis();
3506 let duration = ACTIVITY_TRANSITION_DURATION.as_millis();
3507 std::array::from_fn(|index| {
3508 let start = from[index] as i128;
3509 let distance = to[index] as i128 - start;
3510 (start + distance * elapsed as i128 / duration as i128) as usize
3511 })
3512}
3513
3514fn pulse_level_at(tick: u128, period: u128, phase: u128) -> usize {
3515 let position = (tick + phase) % period;
3516 let half_period = period / 2;
3517 let distance_from_floor = if position <= half_period {
3518 position
3519 } else {
3520 period - position
3521 };
3522 (distance_from_floor * (PULSE_LEVELS.len() - 1) as u128 / half_period) as usize
3523}
3524
3525fn push_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, style: Style) {
3526 let mut added = false;
3527 for piece in wrap_text(text, width) {
3528 lines.push(Line::styled(piece, style));
3529 added = true;
3530 }
3531 if !added {
3532 lines.push(Line::styled(String::new(), style));
3533 }
3534}
3535
3536fn push_spans_wrapped(lines: &mut Vec<Line<'static>>, segments: &[(String, Style)], width: usize) {
3540 let mut current_spans: Vec<Span<'static>> = Vec::new();
3541 let mut current_width = 0usize;
3542 for (text, style) in segments {
3543 for character in text.chars() {
3544 let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
3545 if current_width + char_width > width && !current_spans.is_empty() {
3546 lines.push(Line::from(std::mem::take(&mut current_spans)));
3547 current_width = 0;
3548 }
3549 let mut buffer = [0u8; 4];
3550 let s = character.encode_utf8(&mut buffer);
3551 current_spans.push(Span::styled(s.to_owned(), *style));
3552 current_width += char_width;
3553 }
3554 }
3555 if current_spans.is_empty() {
3556 current_spans.push(Span::raw(String::new()));
3557 }
3558 lines.push(Line::from(current_spans));
3559}
3560
3561fn wrap_text(text: &str, width: usize) -> Vec<String> {
3567 if width == 0 {
3568 return text.lines().map(str::to_owned).collect();
3569 }
3570 let mut rows = Vec::new();
3571 for line in text.split('\n') {
3574 rows.extend(wrap_line(line, width));
3575 }
3576 if rows.is_empty() {
3577 rows.push(String::new());
3578 }
3579 rows
3580}
3581
3582fn wrap_line(line: &str, width: usize) -> Vec<String> {
3583 let mut rows = Vec::new();
3584 let mut current = String::new();
3585 let mut current_width = 0usize;
3586 for character in line.chars() {
3587 let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
3588 if current_width + char_width > width && !current.is_empty() {
3589 rows.push(std::mem::take(&mut current));
3590 current_width = 0;
3591 }
3592 current.push(character);
3593 current_width += char_width;
3594 }
3595 rows.push(current);
3596 rows
3597}
3598
3599#[cfg(test)]
3600mod tests {
3601 use super::*;
3602
3603 #[test]
3604 fn turn_notifications_use_osc_777_with_fixed_secret_safe_messages() {
3605 let cases = [
3606 (TurnNotification::Completed, "Turn complete"),
3607 (TurnNotification::Interrupted, "Turn interrupted"),
3608 (TurnNotification::Failed, "Turn failed"),
3609 ];
3610
3611 for (notification, body) in cases {
3612 let mut output = Vec::new();
3613 send_turn_notification(&mut output, notification).expect("notification");
3614 assert_eq!(
3615 output,
3616 format!("\x1b]777;notify;Lucy;{body}\x07").into_bytes()
3617 );
3618 }
3619 }
3620
3621 #[test]
3622 fn turn_notifications_follow_the_terminal_turn_status() {
3623 assert_eq!(
3624 turn_notification_for_status("finalizing"),
3625 TurnNotification::Completed
3626 );
3627 assert_eq!(
3628 turn_notification_for_status("cancelling"),
3629 TurnNotification::Interrupted
3630 );
3631 assert_eq!(
3632 turn_notification_for_status("error"),
3633 TurnNotification::Failed
3634 );
3635 }
3636
3637 struct FailingWriter;
3638
3639 impl Write for FailingWriter {
3640 fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
3641 Err(io::Error::other("notification sink unavailable"))
3642 }
3643
3644 fn flush(&mut self) -> io::Result<()> {
3645 Err(io::Error::other("notification sink unavailable"))
3646 }
3647 }
3648
3649 #[test]
3650 fn notification_write_failure_does_not_keep_the_tui_busy() {
3651 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3652 state.busy = true;
3653 state.active_cancel = Some(CancellationToken::new());
3654 let mut writer = FailingWriter;
3655
3656 release_finished_turn(&mut writer, &mut state);
3657
3658 assert!(!state.busy);
3659 assert!(state.active_cancel.is_none());
3660 }
3661
3662 #[test]
3663 fn an_idle_finish_does_not_emit_a_duplicate_notification() {
3664 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3665 let mut output = Vec::new();
3666
3667 release_finished_turn(&mut output, &mut state);
3668
3669 assert!(output.is_empty());
3670 }
3671
3672 #[test]
3673 fn context_status_shows_used_window_and_percentage_in_uniform_gray() {
3674 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3675 .with_context(Some(100_000), 80_000);
3676
3677 assert_eq!(
3678 context_status_text(&state),
3679 "Context: 80.0K/100.0K (80%) ████████░░"
3680 );
3681 assert_eq!(
3682 context_status_style(&state).fg,
3683 Some(Color::Rgb(144, 144, 148))
3684 );
3685
3686 state.context_tokens = 80_001;
3687 assert_eq!(
3688 context_status_text(&state),
3689 "Context: 80.0K/100.0K (81%) █████████░"
3690 );
3691 assert_eq!(
3692 context_status_style(&state).fg,
3693 Some(Color::Rgb(144, 144, 148)),
3694 "crossing the compaction threshold does not recolor the status line"
3695 );
3696 }
3697
3698 #[test]
3699 fn context_status_keeps_percentage_consistent_at_capacity() {
3700 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3701 .with_context(Some(100_000), 99_001);
3702
3703 assert_eq!(
3704 context_status_text(&state),
3705 "Context: 99.0K/100.0K (100%) ██████████"
3706 );
3707
3708 state.context_tokens = 100_000;
3709 assert_eq!(
3710 context_status_text(&state),
3711 "Context: 100.0K/100.0K (100%) ██████████"
3712 );
3713
3714 state.context_tokens = 100_001;
3715 assert_eq!(
3716 context_status_text(&state),
3717 "Context: 100.0K/100.0K (101%) ██████████"
3718 );
3719 }
3720
3721 #[test]
3722 fn context_status_handles_unknown_window_without_highlighting() {
3723 let state = UiState::from_history(&[], "secret", "model", None, false);
3724
3725 assert_eq!(context_status_text(&state), "Context: 1/? (?%) ??????????");
3726 assert_eq!(
3727 context_status_style(&state).fg,
3728 Some(Color::Rgb(144, 144, 148))
3729 );
3730 }
3731
3732 #[test]
3733 fn tui_viewport_reserves_one_column_on_each_side_when_possible() {
3734 assert_eq!(
3735 tui_viewport(Rect::new(0, 0, 80, 10)),
3736 Rect::new(1, 0, 78, 10)
3737 );
3738 assert_eq!(
3739 tui_viewport(Rect::new(0, 0, 2, 10)),
3740 Rect::new(0, 0, 2, 10),
3741 "a two-column terminal cannot reserve two gutters"
3742 );
3743 }
3744
3745 #[test]
3746 fn tui_viewport_caps_at_one_hundred_columns_and_centers_it() {
3747 assert_eq!(
3748 tui_viewport(Rect::new(0, 0, 140, 10)),
3749 Rect::new(20, 0, TUI_MAX_WIDTH, 10)
3750 );
3751 assert_eq!(
3752 tui_viewport(Rect::new(0, 0, 103, 10)),
3753 Rect::new(1, 0, TUI_MAX_WIDTH, 10),
3754 "an odd remaining column stays on the right"
3755 );
3756 }
3757
3758 #[test]
3759 fn bottom_console_has_external_margins_without_losing_internal_padding() {
3760 let state = UiState::from_history(&[], "secret", "model", None, false);
3761 let viewport = tui_viewport(Rect::new(0, 0, 80, 14));
3762 let (chat, _, _, _, console, _) = ui_layout(&state, viewport);
3763 let content = console_content_area(console);
3764
3765 assert_eq!(chat.x, console.x);
3766 assert_eq!(chat.width, console.width);
3767 assert_eq!(
3768 console,
3769 Rect::new(viewport.x + 7, 8, viewport.width - 14, 5)
3770 );
3771 assert_eq!(console.y + console.height, viewport.y + viewport.height - 1);
3772 assert_eq!(content.x, console.x + 2);
3773 assert_eq!(content.width, console.width - 4);
3774 assert_eq!(content.y, console.y + 1);
3775 assert_eq!(content.y + content.height, console.y + console.height - 1);
3776
3777 for (width, margin, console_width) in [
3778 (1, 0, 1),
3779 (2, 0, 2),
3780 (3, 1, 1),
3781 (4, 1, 2),
3782 (5, 2, 1),
3783 (15, 0, 15),
3784 ] {
3785 let console = bottom_console_area(Rect::new(0, 0, width, 4), 0, 4);
3786 assert_eq!(console.x, margin, "width {width}");
3787 assert_eq!(console.width, console_width, "width {width}");
3788 }
3789 }
3790
3791 #[test]
3792 fn inset_console_width_drives_prompt_rows_and_vertical_navigation() {
3793 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3794 state.input = "x".repeat(71);
3795 let viewport = tui_viewport(Rect::new(0, 0, 80, 14));
3796 let console = ui_layout(&state, viewport).4;
3797 let prompt = prompt_area(console, &state);
3798
3799 assert_eq!(ui_prompt_content_width(viewport), prompt.width);
3800 assert_eq!(prompt.width, 60);
3801 assert_eq!(input_visible_rows(&state, prompt.width), 2);
3802 assert!(move_input_cursor_vertical(
3803 &mut state,
3804 ui_prompt_content_width(viewport) as usize,
3805 true,
3806 ));
3807 }
3808
3809 #[test]
3810 fn context_status_is_right_aligned_in_uniform_gray() {
3811 let state =
3812 UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
3813 let mut terminal =
3814 Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
3815
3816 terminal
3817 .draw(|frame| draw(frame, &state))
3818 .expect("draw statusline");
3819
3820 let buffer = terminal.backend().buffer();
3821 let status_area = ui_layout(&state, tui_viewport(Rect::new(0, 0, 80, 10))).5;
3822 let expected_context = "Context: 81/100 (81%) █████████░";
3823 let rendered = (status_area.x..status_area.x + status_area.width)
3824 .map(|x| buffer[(x, status_area.y)].symbol())
3825 .collect::<String>();
3826 assert!(rendered.ends_with(expected_context));
3827 assert_eq!(
3828 buffer[(status_area.x + status_area.width - 1, status_area.y)].symbol(),
3829 "░",
3830 "context is not pushed to the right edge"
3831 );
3832 assert!(rendered.starts_with("model · default"));
3833 for x in status_area.x..status_area.x + status_area.width {
3834 if buffer[(x, status_area.y)].symbol() != " " {
3835 assert_eq!(buffer[(x, status_area.y)].fg, CONSOLE_STATUS_COLOR);
3836 }
3837 }
3838 }
3839
3840 #[test]
3841 fn busy_model_name_and_indicator_share_the_animated_accent_gradient() {
3842 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3843 state.busy = true;
3844 let start = model_status_line_at(&state, "default", Duration::ZERO, 80);
3845 let middle = model_status_line_at(&state, "default", console_accent_cycle() / 2, 80);
3846 let start_accent = console_accent_at(Duration::ZERO);
3847 let middle_accent = console_accent_at(console_accent_cycle() / 2);
3848
3849 assert_eq!(start.spans[0].style.fg, Some(start_accent));
3850 assert_eq!(middle.spans[0].style.fg, Some(middle_accent));
3851 assert_eq!(start.spans[0].content, "model");
3852 assert_eq!(start.spans[1].content, " · default");
3853 assert_eq!(start.spans[2].content, " ");
3854 assert_eq!(start.spans[3].content, BUSY_INDICATOR_BLOCK.to_string());
3855 assert_eq!(start.spans[3].style.fg, Some(start_accent));
3856 assert_eq!(
3857 start.spans.last().unwrap().style.fg,
3858 Some(CONSOLE_STATUS_COLOR)
3859 );
3860 }
3861
3862 #[test]
3863 fn idle_model_status_has_no_busy_indicator() {
3864 let state = UiState::from_history(&[], "secret", "model", None, false);
3865 let start = model_status_line_at(&state, "default", Duration::ZERO, 80);
3866 let middle = model_status_line_at(&state, "default", console_accent_cycle() / 2, 80);
3867
3868 assert_eq!(start.spans[0].content, "model");
3869 assert_eq!(middle.spans[0].content, "model");
3870 assert_eq!(start.spans[0].style.fg, Some(CONSOLE_STATUS_COLOR));
3871 assert_eq!(middle.spans[0].style.fg, Some(CONSOLE_STATUS_COLOR));
3872 }
3873
3874 #[test]
3875 fn busy_indicator_is_a_five_cell_bounce_with_a_two_cell_tail() {
3876 let frames = (0..=BUSY_INDICATOR_PERIOD_TICKS)
3877 .map(|tick| busy_indicator_frame_at(BUSY_INDICATOR_TICK * tick as u32))
3878 .collect::<Vec<_>>();
3879
3880 assert_eq!(frames[0], "■ ");
3881 assert_eq!(frames[1], "■■ ");
3882 assert_eq!(frames[2], "■■■ ");
3883 assert_eq!(frames[4], " ■■■");
3884 assert_eq!(frames[5], " ■■■ ");
3885 assert_eq!(frames[7], " ■■■ ");
3886 assert_eq!(frames[8], frames[0]);
3887 assert!(frames
3888 .iter()
3889 .all(|frame| frame.chars().count() == BUSY_INDICATOR_WIDTH));
3890 assert_eq!(BUSY_INDICATOR_TRACK_LENGTH, 5);
3891 assert_eq!(BUSY_INDICATOR_TAIL_LENGTH, 2);
3892 assert_eq!(BUSY_INDICATOR_TICK, Duration::from_micros(62_500));
3893 }
3894
3895 fn color_distance_from_console(color: Color) -> u32 {
3896 let Color::Rgb(red, green, blue) = color else {
3897 return 0;
3898 };
3899 u32::from(red.abs_diff(CONSOLE_BACKGROUND_RGB.0))
3900 + u32::from(green.abs_diff(CONSOLE_BACKGROUND_RGB.1))
3901 + u32::from(blue.abs_diff(CONSOLE_BACKGROUND_RGB.2))
3902 }
3903
3904 #[test]
3905 fn busy_indicator_tail_uses_same_block_with_progressively_fainter_colors() {
3906 let accent = Color::Rgb(180, 120, 240);
3907 let near = busy_indicator_color(accent, Some(1));
3908 let far = busy_indicator_color(accent, Some(2));
3909
3910 assert_eq!(BUSY_INDICATOR_BLOCK, '■');
3911 assert_ne!(near, accent);
3912 assert_ne!(far, near);
3913 assert!(color_distance_from_console(near) > color_distance_from_console(far));
3914 }
3915
3916 #[test]
3917 fn pulse_spinner_moves_each_bar_one_level_at_a_time() {
3918 let frames = (0..=240)
3919 .map(|tick| spinner_frame_at(PULSE_TICK * tick))
3920 .collect::<Vec<_>>();
3921 assert!(frames.iter().any(|frame| frame != &frames[0]));
3922 assert_eq!(PULSE_TICK, Duration::from_millis(50));
3923
3924 for pair in frames.windows(2) {
3925 let levels = pair
3926 .iter()
3927 .map(|frame| {
3928 frame
3929 .chars()
3930 .map(|bar| {
3931 PULSE_LEVELS
3932 .iter()
3933 .position(|level| *level == bar)
3934 .expect("known pulse level")
3935 })
3936 .collect::<Vec<_>>()
3937 })
3938 .collect::<Vec<_>>();
3939 assert_eq!(levels[0].len(), 5);
3940 assert!(
3941 levels[0]
3942 .iter()
3943 .zip(&levels[1])
3944 .all(|(before, after)| before.abs_diff(*after) <= 1),
3945 "pulse bars must not jump between adjacent ticks: {:?} -> {:?}",
3946 pair[0],
3947 pair[1]
3948 );
3949 }
3950 }
3951
3952 #[test]
3953 fn console_animation_clock_runs_during_entry_and_survives_active_status_changes() {
3954 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3955 state.set_status("working");
3956 let epoch = state.console_animation_epoch;
3957 assert_eq!(
3958 state.console_animation_elapsed_at(epoch + Duration::from_millis(200)),
3959 Duration::from_millis(200),
3960 "the console animation does not freeze during the activity ramp"
3961 );
3962
3963 state.set_status("compacting");
3964 assert_eq!(state.console_animation_epoch, epoch);
3965 state.set_status("working");
3966 assert_eq!(state.console_animation_epoch, epoch);
3967 }
3968
3969 fn color_distance(from: Color, to: Color) -> u16 {
3970 let (from_red, from_green, from_blue) = activity_rgb(from);
3971 let (to_red, to_green, to_blue) = activity_rgb(to);
3972 u16::from(from_red.abs_diff(to_red))
3973 + u16::from(from_green.abs_diff(to_green))
3974 + u16::from(from_blue.abs_diff(to_blue))
3975 }
3976
3977 fn color_saturation(color: Color) -> u8 {
3978 let (red, green, blue) = activity_rgb(color);
3979 red.max(green).max(blue) - red.min(green).min(blue)
3980 }
3981
3982 fn color_luminance(color: Color) -> u32 {
3983 let (red, green, blue) = activity_rgb(color);
3984 299 * u32::from(red) + 587 * u32::from(green) + 114 * u32::from(blue)
3985 }
3986
3987 #[test]
3988 fn glow_coverage_is_a_bottom_anchored_half_ellipse_with_a_twelve_row_cap() {
3989 let canvas = Rect::new(0, 0, 160, 40);
3990 let console = Rect::new(40, 28, 80, 7);
3991 let bottom = canvas.y + canvas.height;
3992 let center_x = console.x + console.width / 2 - 1;
3993 let outer_x = center_x + 35;
3994 let active_rows = (canvas.y..bottom)
3995 .filter(|&row| glow_coverage_at(center_x, row, canvas, console) > 0.0)
3996 .collect::<Vec<_>>();
3997
3998 assert_eq!(GLOW_HEIGHT, 12);
3999 assert_eq!(GLOW_HORIZONTAL_SPREAD, 24);
4000 assert_eq!(
4001 active_rows,
4002 (bottom - GLOW_HEIGHT..bottom).collect::<Vec<_>>()
4003 );
4004 assert_eq!(
4005 glow_coverage_at(center_x, bottom - GLOW_HEIGHT - 1, canvas, console),
4006 0.0,
4007 "the glow never exceeds its twelve-row cap"
4008 );
4009 assert_eq!(
4010 glow_coverage_at(center_x, bottom - 1, canvas, console),
4011 glow_coverage_at(center_x + 1, bottom - 1, canvas, console),
4012 "the bottom edge intersects the ellipse at its horizontal centreline"
4013 );
4014 assert_eq!(
4015 glow_coverage_at(outer_x, bottom - GLOW_HEIGHT, canvas, console),
4016 0.0,
4017 "the top of the half ellipse stays narrow"
4018 );
4019 assert!(
4020 glow_coverage_at(outer_x, bottom - 1, canvas, console) > 0.0,
4021 "the exposed glow grows wider toward the TUI bottom"
4022 );
4023 assert!(
4024 glow_coverage_at(console.x + console.width + 23, bottom - 1, canvas, console) > 0.0,
4025 "the glow extends twenty-four cells beyond each console edge"
4026 );
4027 }
4028
4029 #[test]
4030 fn wider_console_has_a_wider_elliptical_side_falloff() {
4031 let canvas = Rect::new(0, 0, 200, 20);
4032 let wide_console = Rect::new(50, 12, 100, 7);
4033 let narrow_console = Rect::new(50, 12, 4, 7);
4034 let bottom_row = canvas.y + canvas.height - 1;
4035 let offset_from_center = 60;
4036 let wide_sample = wide_console.x + wide_console.width / 2 + offset_from_center;
4037 let narrow_sample = narrow_console.x + narrow_console.width / 2 + offset_from_center;
4038
4039 let wide = glow_coverage_at(wide_sample, bottom_row, canvas, wide_console);
4040 let narrow = glow_coverage_at(narrow_sample, bottom_row, canvas, narrow_console);
4041
4042 assert!(wide > 0.0);
4043 assert_eq!(narrow, 0.0);
4044 assert!(
4045 wide > narrow,
4046 "the horizontal radius follows the console width to form an ellipse rather than fixed endpoint circles"
4047 );
4048 }
4049
4050 #[test]
4051 fn exposed_bloom_is_not_tinted_as_console_glass() {
4052 let canvas = Rect::new(0, 0, 80, 20);
4053 let console = Rect::new(20, 12, 40, 7);
4054 let elapsed = CONSOLE_BOUNDARY_CYCLE / 4;
4055 let source_row = canvas.y + canvas.height - 1;
4056 let exposed = (console.x - 1, source_row);
4057 let inside = (console.x, console.y + console.height - 1);
4058 let exposed_glow = glow_color_at(
4059 elapsed,
4060 exposed.0,
4061 canvas.width,
4062 exposed.1,
4063 canvas,
4064 console,
4065 1.0,
4066 )
4067 .expect("endpoint bloom");
4068 let inside_glow = glow_color_at(
4069 elapsed,
4070 inside.0,
4071 canvas.width,
4072 inside.1,
4073 canvas,
4074 console,
4075 1.0,
4076 )
4077 .expect("source segment glow");
4078 let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
4079 canvas.width,
4080 canvas.height,
4081 ))
4082 .expect("test terminal");
4083
4084 terminal
4085 .draw(|frame| {
4086 apply_tui_glow(frame, canvas, console, elapsed, 1.0);
4087 apply_console_background(frame, console, elapsed, 1.0);
4088 let buffer = frame.buffer_mut();
4089 assert_eq!(buffer[exposed].bg, exposed_glow);
4090 assert_eq!(
4091 buffer[inside].bg,
4092 console_glass_color_at(elapsed, inside_glow, 1.0)
4093 );
4094 })
4095 .expect("render glow and glass");
4096 }
4097
4098 #[test]
4099 fn idle_canvas_has_no_glow() {
4100 let canvas = Rect::new(0, 0, 80, 12);
4101 let console = Rect::new(2, 6, 76, 5);
4102 for row in canvas.y..canvas.y + canvas.height {
4103 for column in 0..canvas.width {
4104 assert_eq!(
4105 glow_color_at(
4106 CONSOLE_BOUNDARY_CYCLE / 4,
4107 column,
4108 canvas.width,
4109 row,
4110 canvas,
4111 console,
4112 0.0,
4113 ),
4114 None,
4115 "idle glow never paints the canvas"
4116 );
4117 }
4118 }
4119 }
4120
4121 #[test]
4122 fn console_visibility_transition_is_smooth_and_reversible() {
4123 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4124 state.set_busy(true);
4125 let entering = state
4126 .console_visibility_transition
4127 .expect("entry transition");
4128 assert_eq!(state.console_visibility_at(entering.started_at), 0.0);
4129 let entry_middle =
4130 state.console_visibility_at(entering.started_at + CONSOLE_VISIBILITY_TRANSITION / 2);
4131 assert!((entry_middle - 0.5).abs() < 0.000_1);
4132 assert_eq!(
4133 state.console_visibility_at(entering.started_at + CONSOLE_VISIBILITY_TRANSITION),
4134 1.0
4135 );
4136
4137 state.console_visibility_transition = None;
4138 state.set_busy(false);
4139 let exiting = state
4140 .console_visibility_transition
4141 .expect("exit transition");
4142 assert_eq!(state.console_visibility_at(exiting.started_at), 1.0);
4143 let exit_middle =
4144 state.console_visibility_at(exiting.started_at + CONSOLE_VISIBILITY_TRANSITION / 2);
4145 assert!((exit_middle - 0.5).abs() < 0.000_1);
4146 assert_eq!(
4147 state.console_visibility_at(exiting.started_at + CONSOLE_VISIBILITY_TRANSITION),
4148 0.0
4149 );
4150 }
4151
4152 #[test]
4153 fn rapid_console_reentry_preserves_glow_phase() {
4154 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4155 let canvas = Rect::new(0, 0, 80, 12);
4156 let console = Rect::new(2, 6, 76, 5);
4157 let start = Instant::now();
4158 state.set_busy_at(true, start);
4159 let epoch = state.console_animation_epoch;
4160 state.set_busy_at(false, start + CONSOLE_VISIBILITY_TRANSITION);
4161 let reversal_at = start + CONSOLE_VISIBILITY_TRANSITION * 3 / 2;
4162 let visibility_before = state.console_visibility_at(reversal_at);
4163 let elapsed_before = state.console_animation_elapsed_at(reversal_at);
4164 let color_before = glow_color_at(
4165 elapsed_before,
4166 40,
4167 canvas.width,
4168 canvas.y + canvas.height - 1,
4169 canvas,
4170 console,
4171 visibility_before,
4172 );
4173
4174 state.set_busy_at(true, reversal_at);
4175
4176 assert_eq!(state.console_animation_epoch, epoch);
4177 assert!((state.console_visibility_at(reversal_at) - visibility_before).abs() < 0.000_1);
4178 assert_eq!(
4179 glow_color_at(
4180 state.console_animation_elapsed_at(reversal_at),
4181 40,
4182 canvas.width,
4183 canvas.y + canvas.height - 1,
4184 canvas,
4185 console,
4186 state.console_visibility_at(reversal_at),
4187 ),
4188 color_before,
4189 "reversing an exit keeps the current glow phase"
4190 );
4191 }
4192
4193 #[test]
4194 fn glow_reach_still_controls_light_intensity() {
4195 let long = CONSOLE_BOUNDARY_CYCLE / 4;
4196 let short = CONSOLE_BOUNDARY_CYCLE * 3 / 4;
4197 let canvas = Rect::new(0, 0, 80, 12);
4198 let console = Rect::new(2, 6, 76, 5);
4199 let row = canvas.y + canvas.height - 1;
4200 let long_glow =
4201 glow_color_at(long, 40, canvas.width, row, canvas, console, 1.0).expect("visible glow");
4202 let short_glow = glow_color_at(short, 40, canvas.width, row, canvas, console, 1.0)
4203 .expect("visible glow");
4204
4205 assert!((console_reach_at(long) - CONSOLE_REACH_MAX).abs() < 0.000_1);
4206 assert!((console_reach_at(short) - CONSOLE_REACH_MIN).abs() < 0.000_1);
4207 assert!(
4208 color_distance(TUI_GLOW_BACKGROUND, long_glow)
4209 > color_distance(TUI_GLOW_BACKGROUND, short_glow)
4210 );
4211 }
4212
4213 #[test]
4214 fn maximum_glow_is_brighter_and_more_saturated_than_the_previous_tuning() {
4215 let canvas = Rect::new(0, 0, 160, 20);
4216 let console = Rect::new(40, 12, 80, 7);
4217 let column = console.x + console.width / 2;
4218 let row = canvas.y + canvas.height - 1;
4219 let elapsed = Duration::ZERO;
4220 let coverage = glow_coverage_at(column, row, canvas, console);
4221 assert_eq!(GLOW_INTENSITY, 0.70);
4222 let current = glow_color_at(elapsed, column, canvas.width, row, canvas, console, 1.0)
4223 .expect("bottom-centre glow");
4224 let previous = blend_rgb(
4225 TUI_GLOW_BACKGROUND,
4226 glow_accent_with_desaturation_at(elapsed, 0.16),
4227 (console_reach_at(elapsed) / CONSOLE_REACH_MAX) * 0.62 * coverage,
4228 );
4229
4230 assert!(
4231 color_luminance(current) > color_luminance(previous),
4232 "the adjusted maximum glow is brighter than the previous maximum"
4233 );
4234 assert!(
4235 color_saturation(current) > color_saturation(previous),
4236 "the adjusted maximum glow is more saturated than the previous maximum"
4237 );
4238 }
4239
4240 #[test]
4241 fn glow_tuning_is_brighter_and_more_saturated_than_before() {
4242 let canvas = Rect::new(0, 0, 160, 20);
4243 let console = Rect::new(40, 12, 80, 7);
4244 let column = console.x + console.width / 2;
4245 let row = canvas.y + canvas.height - 1;
4246 let coverage = glow_coverage_at(column, row, canvas, console);
4247
4248 assert_eq!(GLOW_INTENSITY, 0.70);
4249 assert_eq!(GLOW_DESATURATION, 0.10);
4250 for (phase, elapsed) in [
4251 Duration::ZERO,
4252 console_accent_cycle() / 4,
4253 console_accent_cycle() / 2,
4254 console_accent_cycle() * 3 / 4,
4255 ]
4256 .into_iter()
4257 .enumerate()
4258 {
4259 let current_accent = glow_accent_at(elapsed);
4260 let previous_accent = glow_accent_with_desaturation_at(elapsed, 0.16);
4261 assert!(
4262 color_saturation(current_accent) > color_saturation(previous_accent),
4263 "phase {phase}: reducing glow desaturation raises saturation"
4264 );
4265 let current = glow_color_at(elapsed, column, canvas.width, row, canvas, console, 1.0)
4266 .expect("bottom-centre glow");
4267 let previous = blend_rgb(
4268 TUI_GLOW_BACKGROUND,
4269 glow_accent_with_desaturation_at(elapsed, 0.16),
4270 (console_reach_at(elapsed) / CONSOLE_REACH_MAX) * 0.62 * coverage,
4271 );
4272
4273 assert!(
4274 color_luminance(current) > color_luminance(previous),
4275 "phase {phase}: the rendered glow is brighter than the prior tuning"
4276 );
4277 assert!(
4278 color_saturation(current) > color_saturation(previous),
4279 "phase {phase}: the rendered glow is more saturated than the prior tuning"
4280 );
4281 }
4282 }
4283
4284 #[test]
4285 fn console_is_dark_lower_saturation_glass_over_the_glow() {
4286 let elapsed = CONSOLE_BOUNDARY_CYCLE / 4;
4287 let canvas = Rect::new(0, 0, 80, 12);
4288 let console = Rect::new(2, 6, 76, 5);
4289 let glow = glow_color_at(
4290 elapsed,
4291 40,
4292 canvas.width,
4293 canvas.y + canvas.height - 1,
4294 canvas,
4295 console,
4296 1.0,
4297 )
4298 .expect("visible glow");
4299 let glass = console_glass_color_at(elapsed, glow, 1.0);
4300 let solid_glass = console_glass_color_at(elapsed, CONSOLE_BACKGROUND, 1.0);
4301
4302 assert_eq!(
4303 console_glass_color_at(elapsed, glow, 0.0),
4304 CONSOLE_BACKGROUND
4305 );
4306 assert_ne!(glass, CONSOLE_BACKGROUND);
4307 let (glass_red, glass_green, glass_blue) = activity_rgb(glass);
4308 let (glow_red, glow_green, glow_blue) = activity_rgb(glow);
4309 assert!(
4310 u16::from(glass_red) + u16::from(glass_green) + u16::from(glass_blue)
4311 < u16::from(glow_red) + u16::from(glow_green) + u16::from(glow_blue),
4312 "console glass is darker than the exposed glow"
4313 );
4314 assert!(
4315 color_distance(CONSOLE_BACKGROUND, glass) < color_distance(TUI_GLOW_BACKGROUND, glow),
4316 "glass tint is subtler than the exposed glow"
4317 );
4318 assert!(
4319 color_distance(CONSOLE_BACKGROUND, glass)
4320 > color_distance(CONSOLE_BACKGROUND, solid_glass),
4321 "the glow visibly carries through the stronger glass tint"
4322 );
4323 assert!(
4324 color_saturation(glass) < color_saturation(glow),
4325 "glass tint is less saturated than the exposed glow"
4326 );
4327 }
4328
4329 #[test]
4330 fn console_top_reflection_is_a_thin_active_line_in_the_external_gap() {
4331 assert_eq!(CONSOLE_REFLECTION_WHITE_TINT, 0.20);
4332 let elapsed = Duration::ZERO;
4333 let canvas = Rect::new(0, 0, 20, 8);
4334 let console = Rect::new(2, 3, 16, 4);
4335 let reflection_y = console.y - 1;
4336 let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
4337 canvas.width,
4338 canvas.height,
4339 ))
4340 .expect("test terminal");
4341 terminal
4342 .draw(|frame| {
4343 apply_tui_glow(frame, canvas, console, elapsed, 1.0);
4344 apply_console_top_reflection(frame, console, elapsed, 1.0);
4345 apply_console_background(frame, console, elapsed, 1.0);
4346 })
4347 .expect("render external console reflection");
4348
4349 let reflection_glow = glow_color_at(
4350 elapsed,
4351 console.x,
4352 canvas.width,
4353 reflection_y,
4354 canvas,
4355 console,
4356 1.0,
4357 )
4358 .expect("reflection row glow");
4359 let accent_only_reflection = blend_rgb(
4360 reflection_glow,
4361 glow_accent_at(elapsed),
4362 CONSOLE_REFLECTION_TINT,
4363 );
4364 let previous_reflection =
4365 blend_rgb(accent_only_reflection, Color::Rgb(255, 255, 255), 0.10);
4366 let reflection = console_top_reflection_color_at(elapsed, reflection_glow, 1.0);
4367 let previous_luminance_lift =
4368 color_luminance(previous_reflection) - color_luminance(accent_only_reflection);
4369 let luminance_lift = color_luminance(reflection) - color_luminance(accent_only_reflection);
4370 assert!(
4371 luminance_lift * 100 >= previous_luminance_lift * 195
4372 && luminance_lift * 100 <= previous_luminance_lift * 210,
4373 "doubling the white tint doubles its reflection luminance contribution"
4374 );
4375 let console_glow = glow_color_at(
4376 elapsed,
4377 console.x,
4378 canvas.width,
4379 console.y,
4380 canvas,
4381 console,
4382 1.0,
4383 )
4384 .expect("console glow");
4385 let buffer = terminal.backend().buffer();
4386 assert_eq!(
4387 buffer[(console.x, reflection_y)].symbol(),
4388 CONSOLE_REFLECTION_GLYPH,
4389 "the reflection is drawn in the row above the console"
4390 );
4391 assert_eq!(
4392 buffer[(console.x, reflection_y)].fg,
4393 console_top_reflection_color_at(elapsed, reflection_glow, 1.0),
4394 "the thin reflection follows the glow accent"
4395 );
4396 assert_eq!(
4397 buffer[(console.x, reflection_y - 1)].symbol(),
4398 " ",
4399 "only the closest external row is changed"
4400 );
4401 assert_eq!(
4402 buffer[(console.x, console.y)].bg,
4403 console_glass_color_at(elapsed, console_glow, 1.0),
4404 "the console top row remains normal glass"
4405 );
4406
4407 let mut idle_terminal = Terminal::new(ratatui::backend::TestBackend::new(
4408 canvas.width,
4409 canvas.height,
4410 ))
4411 .expect("idle test terminal");
4412 idle_terminal
4413 .draw(|frame| {
4414 apply_tui_glow(frame, canvas, console, elapsed, 0.0);
4415 apply_console_top_reflection(frame, console, elapsed, 0.0);
4416 apply_console_background(frame, console, elapsed, 0.0);
4417 })
4418 .expect("render idle console");
4419 assert_eq!(
4420 idle_terminal.backend().buffer()[(console.x, reflection_y)].symbol(),
4421 " ",
4422 "idle consoles have no external reflection"
4423 );
4424
4425 let state = UiState::from_history(&[], "secret", "model", None, false);
4426 let viewport = tui_viewport(Rect::new(0, 0, 80, 10));
4427 let (chat, _, _, _, input_area, _) = ui_layout(&state, viewport);
4428 assert_eq!(
4429 chat.y + chat.height + 1,
4430 input_area.y,
4431 "the reflection uses the existing transcript gap rather than adding a row"
4432 );
4433 }
4434
4435 #[test]
4436 fn console_reflection_skips_transcript_when_no_gap_fits() {
4437 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4438 state.welcome_visible = false;
4439 state
4440 .transcript
4441 .push(TranscriptItem::Info("protected transcript".to_owned()));
4442 state.busy = true;
4443 state.activity_transition = None;
4444 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
4445 let area = Rect::new(0, 0, 60, 7);
4446 let mut terminal =
4447 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4448 .expect("test terminal");
4449 terminal
4450 .draw(|frame| draw(frame, &state))
4451 .expect("draw constrained active console");
4452
4453 let (chat, _, _, _, console, _) = ui_layout(&state, tui_viewport(area));
4454 assert_eq!(chat.y + chat.height, console.y, "there is no separator row");
4455 assert_eq!(
4456 terminal.backend().buffer()[(chat.x, chat.y)].symbol(),
4457 "p",
4458 "the active reflection does not overwrite the adjacent transcript"
4459 );
4460 }
4461
4462 #[test]
4463 fn console_reflection_stays_behind_an_active_skill_picker() {
4464 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4465 .with_skill_names(vec!["agent-browser".to_owned()]);
4466 state.input = "/".to_owned();
4467 state.input_changed();
4468 state.busy = true;
4469 state.activity_transition = None;
4470 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
4471 let area = Rect::new(0, 0, 40, 12);
4472 let mut terminal =
4473 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4474 .expect("test terminal");
4475 terminal
4476 .draw(|frame| draw(frame, &state))
4477 .expect("draw active picker");
4478
4479 let (_, picker, _, _, input, _) = ui_layout(&state, tui_viewport(area));
4480 let picker = picker.expect("visible picker");
4481 assert_eq!(picker.y + picker.height, input.y);
4482 let buffer = terminal.backend().buffer();
4483 assert_eq!(
4484 buffer[(picker.x, picker.y + picker.height - 1)].bg,
4485 SKILL_PICKER_BACKGROUND,
4486 "the picker covers the reflection row"
4487 );
4488 assert_ne!(
4489 buffer[(picker.x, picker.y + picker.height - 1)].symbol(),
4490 CONSOLE_REFLECTION_GLYPH,
4491 "the reflection glyph does not show through the picker"
4492 );
4493 }
4494
4495 #[test]
4496 fn console_glass_white_film_brightens_only_visible_glass() {
4497 let elapsed = Duration::ZERO;
4498 let glow = Color::Rgb(80, 82, 86);
4499 let (red, green, blue) = activity_rgb(console_accent_at(elapsed));
4500 let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
4501 let glass_accent = Color::Rgb(
4502 interpolate_color(red, neutral, CONSOLE_GLASS_DESATURATION),
4503 interpolate_color(green, neutral, CONSOLE_GLASS_DESATURATION),
4504 interpolate_color(blue, neutral, CONSOLE_GLASS_DESATURATION),
4505 );
4506 let accent_tinted = blend_rgb(CONSOLE_BACKGROUND, glass_accent, CONSOLE_GLASS_TINT);
4507 let without_white_film = blend_rgb(accent_tinted, glow, CONSOLE_GLASS_GLOW_THROUGH);
4508 let with_white_film = console_glass_color_at(elapsed, glow, 1.0);
4509 let expected = blend_rgb(
4510 blend_rgb(
4511 accent_tinted,
4512 Color::Rgb(255, 255, 255),
4513 CONSOLE_GLASS_WHITE_TINT,
4514 ),
4515 glow,
4516 CONSOLE_GLASS_GLOW_THROUGH,
4517 );
4518
4519 assert_eq!(CONSOLE_GLASS_WHITE_TINT, 0.03);
4520 assert_eq!(
4521 console_glass_color_at(elapsed, glow, 0.0),
4522 CONSOLE_BACKGROUND,
4523 "idle glass uses the configured console background"
4524 );
4525 assert_eq!(
4526 with_white_film, expected,
4527 "the white film is composited before glow-through"
4528 );
4529 let (with_white_red, with_white_green, with_white_blue) = activity_rgb(with_white_film);
4530 let (without_white_red, without_white_green, without_white_blue) =
4531 activity_rgb(without_white_film);
4532 assert!(
4533 u16::from(with_white_red) + u16::from(with_white_green) + u16::from(with_white_blue)
4534 > u16::from(without_white_red)
4535 + u16::from(without_white_green)
4536 + u16::from(without_white_blue),
4537 "the active glass has a visible white film"
4538 );
4539 }
4540
4541 #[test]
4542 fn console_accent_uses_a_fifteen_second_lavender_to_teal_round_trip() {
4543 assert_eq!(console_accent_cycle(), Duration::from_secs(15));
4544 assert_eq!(
4545 console_accent_at(Duration::ZERO),
4546 desaturate_console_accent(
4547 CONSOLE_ACCENT_LAVENDER.0,
4548 CONSOLE_ACCENT_LAVENDER.1,
4549 CONSOLE_ACCENT_LAVENDER.2,
4550 )
4551 );
4552 assert_eq!(
4553 console_accent_at(console_accent_cycle() / 2),
4554 desaturate_console_accent(
4555 CONSOLE_ACCENT_TEAL.0,
4556 CONSOLE_ACCENT_TEAL.1,
4557 CONSOLE_ACCENT_TEAL.2,
4558 )
4559 );
4560 assert_eq!(
4561 console_accent_at(console_accent_cycle()),
4562 console_accent_at(Duration::ZERO)
4563 );
4564 let midpoint = console_accent_at(console_accent_cycle() / 4);
4565 assert_ne!(
4566 midpoint,
4567 console_accent_at(Duration::ZERO),
4568 "the glow transitions continuously instead of holding at lavender"
4569 );
4570 assert_ne!(
4571 midpoint,
4572 console_accent_at(console_accent_cycle() / 2),
4573 "the glow transitions continuously instead of holding at teal"
4574 );
4575 }
4576
4577 #[test]
4578 fn tui_glow_background_is_ghostty_base_101216() {
4579 assert_eq!(TUI_GLOW_BACKGROUND_RGB, (16, 18, 22));
4580 assert_eq!(TUI_GLOW_BACKGROUND, Color::Rgb(16, 18, 22));
4581 }
4582
4583 #[test]
4584 fn console_palette_starts_lavender_with_fifteen_percent_desaturation() {
4585 assert_eq!(CONSOLE_BACKGROUND_RGB, (42, 42, 46));
4586 assert_eq!(CONSOLE_BACKGROUND, Color::Rgb(42, 42, 46));
4587 assert_eq!(
4588 console_accent_at(Duration::ZERO),
4589 desaturate_console_accent(
4590 CONSOLE_ACCENT_LAVENDER.0,
4591 CONSOLE_ACCENT_LAVENDER.1,
4592 CONSOLE_ACCENT_LAVENDER.2,
4593 )
4594 );
4595 }
4596
4597 #[test]
4598 fn console_accent_transition_changes_all_tui_glow_in_sync() {
4599 let elapsed = console_accent_cycle() / 4;
4600 let canvas = Rect::new(0, 0, 80, 20);
4601 let console = Rect::new(20, 12, 40, 7);
4602 let row = canvas.y + canvas.height - 1;
4603 let left = glow_color_at(elapsed, console.x, canvas.width, row, canvas, console, 1.0)
4604 .expect("left-side glow");
4605 let right = glow_color_at(
4606 elapsed,
4607 console.x + console.width - 1,
4608 canvas.width,
4609 row,
4610 canvas,
4611 console,
4612 1.0,
4613 )
4614 .expect("right-side glow");
4615 let intensity = (console_reach_at(elapsed) / CONSOLE_REACH_MAX)
4616 * GLOW_INTENSITY
4617 * glow_coverage_at(console.x, row, canvas, console);
4618
4619 assert_eq!(
4620 blend_rgb(TUI_GLOW_BACKGROUND, glow_accent_at(elapsed), intensity),
4621 left,
4622 "the left edge uses the shared accent phase"
4623 );
4624 assert_eq!(left, right, "the glow has no horizontal color phase");
4625
4626 let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
4627 canvas.width,
4628 canvas.height,
4629 ))
4630 .expect("test terminal");
4631 terminal
4632 .draw(|frame| {
4633 apply_tui_glow(frame, canvas, console, elapsed, 1.0);
4634 apply_console_top_reflection(frame, console, elapsed, 1.0);
4635 apply_console_background(frame, console, elapsed, 1.0);
4636 })
4637 .expect("render synchronized TUI glow");
4638 let buffer = terminal.backend().buffer();
4639 let left_x = console.x;
4640 let right_x = console.x + console.width - 1;
4641 assert_eq!(
4642 buffer[(left_x, console.y)].bg,
4643 buffer[(right_x, console.y)].bg,
4644 "the glass uses the shared accent phase"
4645 );
4646 assert_eq!(
4647 buffer[(left_x, console.y - 1)].fg,
4648 buffer[(right_x, console.y - 1)].fg,
4649 "the reflection uses the shared accent phase"
4650 );
4651 }
4652
4653 #[test]
4654 fn prompt_uses_two_cells_of_horizontal_console_padding() {
4655 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4656 state.input = "1234567890123456".to_owned();
4657 let area = Rect::new(0, 0, 20, 6);
4658 let mut terminal =
4659 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4660 .expect("test terminal");
4661
4662 terminal
4663 .draw(|frame| draw(frame, &state))
4664 .expect("draw padded prompt");
4665
4666 let input_area = ui_layout(&state, tui_viewport(area)).4;
4667 let prompt = prompt_area(input_area, &state);
4668 assert_eq!(prompt.x, input_area.x + 2);
4669 assert_eq!(prompt.width, input_area.width.saturating_sub(4));
4670 assert_eq!(
4671 terminal.backend().buffer()[(input_area.x + 1, prompt.y)].symbol(),
4672 " ",
4673 "the two left padding cells remain blank"
4674 );
4675 assert_eq!(
4676 terminal.backend().buffer()[(input_area.x + input_area.width - 2, prompt.y)].symbol(),
4677 " ",
4678 "the two right padding cells remain blank"
4679 );
4680 terminal
4681 .backend_mut()
4682 .assert_cursor_position((input_area.x + 2, prompt.y));
4683 }
4684
4685 #[test]
4686 fn prompt_width_reduction_wraps_and_saturates_at_narrow_widths() {
4687 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4688 state.input = "12345".to_owned();
4689 state.cursor = state.input.chars().count();
4690 let input_area = Rect::new(3, 2, 6, 6);
4691 let prompt = prompt_area(input_area, &state);
4692
4693 assert_eq!(prompt.width, 2);
4694 assert_eq!(input_visible_rows(&state, prompt.width), 3);
4695 assert_eq!(bottom_content_heights(&state, input_area).prompt, 3);
4696 assert_eq!(
4697 cursor_row(&state.input, state.cursor, prompt.width as usize),
4698 2
4699 );
4700 state.cursor = 1;
4701 assert!(move_input_cursor_vertical(
4702 &mut state,
4703 prompt_content_width(input_area.width) as usize,
4704 true,
4705 ));
4706 assert_eq!(state.cursor, 3);
4707 assert_eq!(prompt_content_width(0), 0);
4708 assert_eq!(prompt_content_width(1), 0);
4709 assert_eq!(prompt_content_width(2), 0);
4710 assert_eq!(prompt_content_width(3), 0);
4711 assert_eq!(prompt_content_width(4), 0);
4712 assert_eq!(prompt_content_width(5), 1);
4713 }
4714
4715 #[test]
4716 fn ready_submission_bypasses_queue_and_is_not_added_twice_when_started() {
4717 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4718
4719 state.submit_user("send now");
4720
4721 assert!(state.queued_messages.is_empty());
4722 assert_eq!(state.transcript.len(), 1);
4723 assert!(matches!(
4724 &state.transcript[0],
4725 TranscriptItem::User { text, .. } if text == "send now"
4726 ));
4727
4728 state.start_queued_user("send now");
4731 assert_eq!(state.transcript.len(), 1);
4732 }
4733
4734 #[test]
4735 fn busy_submission_remains_queued_until_its_turn_starts() {
4736 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4737 state.busy = true;
4738
4739 state.submit_user("send later");
4740
4741 assert_eq!(state.queued_messages, ["send later"]);
4742 assert!(state.transcript.is_empty());
4743
4744 state.start_queued_user("send later");
4745 assert!(state.queued_messages.is_empty());
4746 assert!(matches!(
4747 &state.transcript[..],
4748 [TranscriptItem::User { text, .. }] if text == "send later"
4749 ));
4750 }
4751
4752 #[test]
4753 fn skill_picker_stays_above_a_visible_message_queue() {
4754 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4755 .with_skill_names(vec!["release-notes".to_owned()]);
4756 state.queue_user("next task");
4757 state.input = "/".to_owned();
4758 state.input_changed();
4759
4760 let area = Rect::new(0, 0, 80, 12);
4761 let (_, picker_area, _, queue_area, input_area, _) = ui_layout(&state, tui_viewport(area));
4762 let picker_area = picker_area.expect("skill picker area");
4763 let queue_area = queue_area.expect("message queue area");
4764 assert_eq!(picker_area.y + picker_area.height, input_area.y);
4765 assert_eq!(queue_area.y, input_area.y + 1);
4766 assert_eq!(queue_area.x, input_area.x + 2);
4767 }
4768
4769 #[test]
4770 fn fresh_sessions_show_the_versioned_gradient_welcome_message() {
4771 let state = UiState::from_history(&[], "secret", "model", None, false);
4772 assert!(state.welcome_visible);
4773
4774 let line = welcome_line();
4775 assert_eq!(line.to_string(), WELCOME_MESSAGE);
4776 assert_eq!(WELCOME_VERSION, concat!("v", env!("CARGO_PKG_VERSION")));
4777 assert_eq!(
4778 line.spans.first().and_then(|span| span.style.fg),
4779 Some(Color::Rgb(
4780 WELCOME_START_COLOR.0,
4781 WELCOME_START_COLOR.1,
4782 WELCOME_START_COLOR.2,
4783 ))
4784 );
4785 assert_eq!(
4786 line.spans.last().and_then(|span| span.style.fg),
4787 Some(Color::Rgb(
4788 WELCOME_END_COLOR.0,
4789 WELCOME_END_COLOR.1,
4790 WELCOME_END_COLOR.2,
4791 ))
4792 );
4793 }
4794
4795 #[test]
4796 fn welcome_image_brightness_is_reduced_without_changing_alpha() {
4797 let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
4798 1,
4799 1,
4800 image::Rgba([200, 100, 0, 37]),
4801 ));
4802 let dimmed = dim_welcome_image(image).to_rgba8();
4803 assert_eq!(dimmed.get_pixel(0, 0).0, [170, 85, 0, 37]);
4804 }
4805
4806 #[test]
4807 fn spacious_welcome_uses_the_embedded_png() {
4808 let image = welcome_image(GREETING_IMAGE_SIZE);
4809 assert_eq!(image.size(), GREETING_IMAGE_SIZE);
4810 let layout = welcome_image_layout(Rect::new(0, 0, 100, 40), 6).expect("image fits");
4811 assert_eq!(layout.image_size, GREETING_IMAGE_SIZE);
4812 assert_eq!(layout.image_area, Rect::new(10, 6, 80, 20));
4813 assert_eq!(layout.intro_area.y, layout.image_area.y + 21);
4814 }
4815
4816 #[test]
4817 fn cramped_welcome_falls_back_to_the_text_greeting() {
4818 assert_eq!(welcome_image_layout(Rect::new(0, 0, 80, 16), 6), None);
4819 assert_eq!(welcome_image_layout(Rect::new(0, 0, 39, 40), 6), None);
4820 let scaled = welcome_image_layout(Rect::new(0, 0, 60, 25), 6).expect("scaled image fits");
4821 assert_eq!(scaled.image_size, Size::new(60, 15));
4822
4823 let state = UiState::from_history(&[], "secret", "model", None, false);
4824 let area = Rect::new(0, 0, 80, 12);
4825 let mut terminal =
4826 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4827 .expect("test terminal");
4828 terminal
4829 .draw(|frame| draw(frame, &state))
4830 .expect("draw text fallback");
4831 let chat_area = ui_layout(&state, tui_viewport(area)).0;
4832 let rows = (chat_area.y..chat_area.y + chat_area.height)
4833 .map(|y| {
4834 (chat_area.x..chat_area.x + chat_area.width)
4835 .map(|x| terminal.backend().buffer()[(x, y)].symbol())
4836 .collect::<String>()
4837 })
4838 .collect::<Vec<_>>();
4839 assert!(rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
4840 assert!(!rows
4841 .iter()
4842 .any(|row| row.contains('▀') || row.contains('▄')));
4843 }
4844
4845 #[test]
4846 fn logo_text_renders_by_default_and_greeting_image_replaces_it_when_enabled() {
4847 let logo = logo_lines();
4848 let logo_row_count = LOGO_TEXT.lines().count();
4849 assert_eq!(logo.len(), logo_row_count);
4850 assert!(logo.iter().flat_map(|line| &line.spans).any(|span| {
4852 span.content.chars().any(|ch| ch != ' ')
4853 && matches!(span.style.fg, Some(Color::Rgb(..)))
4854 }));
4855
4856 let state = UiState::from_history(&[], "secret", "model", None, false);
4857 let area = Rect::new(0, 0, 100, 50);
4858 let mut terminal =
4859 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4860 .expect("test terminal");
4861 let chat_area = ui_layout(&state, tui_viewport(area)).0;
4862 let intro_lines = welcome_lines(&state.attached_agents);
4863 let greeting_layout =
4864 welcome_image_layout(chat_area, intro_lines.len() as u16).expect("greeting fits");
4865
4866 std::env::remove_var("LUCY_GREETING_IMAGE");
4868 terminal
4869 .draw(|frame| draw(frame, &state))
4870 .expect("draw logo text");
4871 let buffer = terminal.backend().buffer();
4872 let rows = (chat_area.y..chat_area.y + chat_area.height)
4873 .map(|y| {
4874 (chat_area.x..chat_area.x + chat_area.width)
4875 .map(|x| buffer[(x, y)].symbol())
4876 .collect::<String>()
4877 })
4878 .collect::<Vec<_>>();
4879 assert!(rows
4880 .iter()
4881 .any(|row| row.contains(':') || row.contains('-') || row.contains('=')));
4882 assert!(!rows
4883 .iter()
4884 .any(|row| row.contains('▀') || row.contains('▄')));
4885 assert!(rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
4886
4887 std::env::set_var("LUCY_GREETING_IMAGE", "true");
4889 terminal
4890 .draw(|frame| draw(frame, &state))
4891 .expect("draw greeting");
4892 let buffer = terminal.backend().buffer();
4893 assert_eq!(greeting_layout.image_size, GREETING_IMAGE_SIZE);
4894 assert!(matches!(
4895 buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].symbol(),
4896 "▀" | "▄"
4897 ));
4898 assert!(matches!(
4899 buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].fg,
4900 Color::Rgb(..)
4901 ));
4902 assert!(matches!(
4903 buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].bg,
4904 Color::Rgb(..)
4905 ));
4906 let intro_rows = (greeting_layout.intro_area.y
4907 ..greeting_layout.intro_area.y + greeting_layout.intro_area.height)
4908 .map(|y| {
4909 (greeting_layout.intro_area.x
4910 ..greeting_layout.intro_area.x + greeting_layout.intro_area.width)
4911 .map(|x| buffer[(x, y)].symbol())
4912 .collect::<String>()
4913 })
4914 .collect::<Vec<_>>();
4915 assert!(intro_rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
4916
4917 std::env::remove_var("LUCY_GREETING_IMAGE");
4918 }
4919
4920 #[test]
4921 fn welcome_renders_version_below_title_with_a_blank_line_before_tagline() {
4922 let state = UiState::from_history(&[], "secret", "model", None, false);
4923 let area = Rect::new(0, 0, 80, 12);
4924 let mut terminal =
4925 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4926 .expect("test terminal");
4927 terminal
4928 .draw(|frame| draw(frame, &state))
4929 .expect("draw welcome screen");
4930
4931 let chat_area = ui_layout(&state, tui_viewport(area)).0;
4932 let buffer = terminal.backend().buffer();
4933 let rows = (chat_area.y..chat_area.y + chat_area.height)
4934 .map(|y| {
4935 (chat_area.x..chat_area.x + chat_area.width)
4936 .map(|x| buffer[(x, y)].symbol())
4937 .collect::<String>()
4938 })
4939 .collect::<Vec<_>>();
4940 let title_row = rows
4941 .iter()
4942 .position(|row| row.contains(WELCOME_MESSAGE))
4943 .expect("rendered welcome title");
4944 let version_rows = rows
4945 .iter()
4946 .enumerate()
4947 .filter_map(|(row, rendered)| rendered.contains(WELCOME_VERSION).then_some(row))
4948 .collect::<Vec<_>>();
4949
4950 assert_eq!(version_rows, vec![title_row + 1]);
4951 assert!(rows[title_row + 2].trim().is_empty());
4952 assert!(rows[title_row + 3].contains(WELCOME_TAGLINE));
4953
4954 let version_width = WELCOME_VERSION.chars().count() as u16;
4955 let version_x = chat_area.x + (chat_area.width - version_width) / 2;
4956 let version_y = chat_area.y + title_row as u16 + 1;
4957 assert!((version_x..version_x + version_width)
4958 .all(|x| buffer[(x, version_y)].fg == Color::DarkGray));
4959 }
4960
4961 #[test]
4962 fn welcome_shows_the_tagline_and_attached_agents_paths() {
4963 let state = UiState::from_history(&[], "secret", "model", None, false)
4964 .with_attached_agents(vec![
4965 "/workspace/AGENTS.md".to_owned(),
4966 "/workspace/app/AGENTS.md".to_owned(),
4967 ]);
4968 let lines = welcome_lines(&state.attached_agents);
4969
4970 assert_eq!(lines[1].to_string(), WELCOME_VERSION);
4971 assert_eq!(lines[1].style.fg, Some(Color::DarkGray));
4972 assert!(lines[2].to_string().is_empty());
4973 assert_eq!(lines[3].to_string(), WELCOME_TAGLINE);
4974 assert_eq!(lines[3].style.fg, Some(Color::DarkGray));
4975 assert!(lines[4].to_string().is_empty());
4976 assert_eq!(lines[5].to_string(), "Attached AGENTS.md:");
4977 assert_eq!(lines[6].to_string(), "• /workspace/AGENTS.md");
4978 assert_eq!(lines[7].to_string(), "• /workspace/app/AGENTS.md");
4979 assert!(lines[5..]
4980 .iter()
4981 .all(|line| line.style.fg == Some(Color::DarkGray)));
4982 }
4983
4984 #[test]
4985 fn welcome_reports_when_no_agents_file_is_attached() {
4986 let lines = welcome_lines(&[]);
4987 assert_eq!(
4988 lines.last().expect("empty context line").to_string(),
4989 "Attached AGENTS.md: none"
4990 );
4991 }
4992
4993 #[test]
4994 fn resumed_sessions_do_not_show_the_welcome_message() {
4995 let state = UiState::from_history(&[], "secret", "model", None, true);
4996 assert!(!state.welcome_visible);
4997 }
4998
4999 #[test]
5000 fn history_replay_keeps_interruption_after_messages() {
5001 let history = vec![
5002 SessionHistoryRecord::Message {
5003 timestamp: 1,
5004 message: ChatMessage::user("hello".to_owned()),
5005 },
5006 SessionHistoryRecord::Interruption {
5007 timestamp: 2,
5008 reason: "user_cancelled".to_owned(),
5009 phase: "provider_stream".to_owned(),
5010 assistant_text: "partial".to_owned(),
5011 tool_calls: Vec::new(),
5012 tool_results: Vec::new(),
5013 },
5014 ];
5015 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
5016 assert!(matches!(state.transcript[0], TranscriptItem::User { .. }));
5017 assert!(matches!(state.transcript[1], TranscriptItem::Assistant(_)));
5018 assert!(matches!(state.transcript[2], TranscriptItem::Info(_)));
5019 let text = transcript_lines(&state, 80)
5020 .iter()
5021 .map(ToString::to_string)
5022 .collect::<Vec<_>>()
5023 .join("\n");
5024 assert!(!text.contains("choices"));
5025 }
5026
5027 #[test]
5028 fn history_replay_does_not_render_assistant_reasoning_details() {
5029 let mut message = ChatMessage::assistant("visible answer".to_owned(), Vec::new());
5030 message.reasoning_details = Some(vec![serde_json::json!({
5031 "type": "reasoning.text",
5032 "text": "private reasoning"
5033 })]);
5034 let history = [SessionHistoryRecord::Message {
5035 timestamp: 1,
5036 message,
5037 }];
5038 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
5039 let text = transcript_lines(&state, 80)
5040 .iter()
5041 .map(ToString::to_string)
5042 .collect::<Vec<_>>()
5043 .join("\n");
5044 assert!(text.contains("visible answer"));
5045 assert!(!text.contains("private reasoning"));
5046 assert!(!text.contains("reasoning_details"));
5047 }
5048
5049 #[test]
5050 fn history_replay_preserves_repeated_records() {
5051 let history = vec![
5052 SessionHistoryRecord::Message {
5053 timestamp: 1,
5054 message: ChatMessage::assistant("same".to_owned(), Vec::new()),
5055 },
5056 SessionHistoryRecord::Interruption {
5057 timestamp: 2,
5058 reason: "user_cancelled".to_owned(),
5059 phase: "provider_stream".to_owned(),
5060 assistant_text: "same".to_owned(),
5061 tool_calls: Vec::new(),
5062 tool_results: Vec::new(),
5063 },
5064 ];
5065 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
5066 assert_eq!(
5067 state
5068 .transcript
5069 .iter()
5070 .filter(|item| matches!(item, TranscriptItem::Assistant(text) if text == "same"))
5071 .count(),
5072 2
5073 );
5074 }
5075
5076 #[test]
5077 fn user_messages_have_a_single_block_rule_with_inner_and_vertical_padding() {
5078 let history = [SessionHistoryRecord::Message {
5079 timestamp: 1,
5080 message: ChatMessage::user("hello\nworld".to_owned()),
5081 }];
5082 let state = UiState::from_history(&history, "provider-secret", "model", None, false);
5083 let lines = transcript_lines(&state, 12);
5084
5085 assert_eq!(UnicodeWidthStr::width(USER_BORDER_GLYPH), 1);
5086 assert_eq!(lines.len(), 4);
5087 assert_eq!(lines[0].to_string(), "▌");
5088 assert_eq!(lines[1].to_string(), "▌ hello");
5089 assert_eq!(lines[2].to_string(), "▌ world");
5090 assert_eq!(lines[3].to_string(), "▌");
5091 for line in &lines {
5092 assert_eq!(line.spans[0].content, USER_BORDER_GLYPH);
5093 assert_eq!(line.spans[0].style.fg, Some(USER_BORDER_COLOR));
5094 assert!(!line.to_string().contains(['┌', '┐', '└', '┘', '│']));
5095 }
5096 for line in &lines[1..3] {
5097 assert_eq!(line.spans[1].content, " ");
5098 assert_eq!(line.spans[1].style.fg, Some(Color::White));
5099 assert_eq!(line.spans[2].style.fg, Some(Color::White));
5100 }
5101 }
5102
5103 #[test]
5104 fn attached_skill_highlights_its_trigger_in_the_user_message_without_a_notice_line() {
5105 let mut state = UiState::from_history(&[], "secret", "model", None, false)
5106 .with_skill_names(vec!["release-notes".to_owned()]);
5107 state.add_user("/release-notes v1.2.0", "secret");
5108 state.mark_latest_user_skill_attached();
5109
5110 let lines = transcript_lines(&state, 40);
5111 assert_eq!(lines.len(), 3);
5112 assert_eq!(lines[1].spans[1].content, " ");
5113 let cyan_text = lines[1]
5114 .spans
5115 .iter()
5116 .filter(|span| span.style.fg == Some(SKILL_TRIGGER_COLOR))
5117 .map(|span| span.content.as_ref())
5118 .collect::<String>();
5119 assert_eq!(cyan_text, "/release-notes");
5120 assert!(!lines
5121 .iter()
5122 .any(|line| line.to_string().contains("instruction attached")));
5123 }
5124
5125 #[test]
5126 fn transcript_rendering_redacts_history_content() {
5127 let history = [SessionHistoryRecord::Message {
5128 timestamp: 1,
5129 message: ChatMessage::assistant("provider-secret".to_owned(), Vec::new()),
5130 }];
5131 let state = UiState::from_history(&history, "provider-secret", "model", None, false);
5132 let text = transcript_lines(&state, 80)
5133 .iter()
5134 .map(ToString::to_string)
5135 .collect::<Vec<_>>()
5136 .join("\n");
5137 assert!(!text.contains("provider-secret"));
5138 }
5139
5140 #[test]
5141 fn mouse_wheel_disables_following_and_changes_scroll_offset() {
5142 let history = [SessionHistoryRecord::Message {
5143 timestamp: 1,
5144 message: ChatMessage::user("hello".to_owned()),
5145 }];
5146 let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
5147 handle_mouse_event(&mut state, MouseEventKind::ScrollUp, 10);
5148 assert!(!state.auto_scroll);
5149 assert_eq!(state.scroll, 7);
5150 handle_mouse_event(&mut state, MouseEventKind::ScrollDown, 10);
5151 assert!(
5152 state.auto_scroll,
5153 "reaching the bottom resumes transcript following"
5154 );
5155 assert_eq!(state.scroll, 0);
5156 scroll_up(&mut state, 10);
5157 assert!(!state.auto_scroll);
5158 assert_eq!(state.scroll, 7);
5159 }
5160
5161 #[test]
5162 fn transcript_scrollbar_appears_only_when_the_stream_is_scrolled() {
5163 let mut state = UiState::from_history(&[], "provider-secret", "model", None, false);
5164 state.welcome_visible = false;
5165 state.transcript = (0..40)
5166 .map(|index| TranscriptItem::Info(format!("message {index}")))
5167 .collect();
5168 state.auto_scroll = false;
5169 state.scroll = 3;
5170
5171 let area = Rect::new(0, 0, 80, 14);
5172 let mut terminal =
5173 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5174 .expect("test terminal");
5175 terminal
5176 .draw(|frame| draw(frame, &state))
5177 .expect("draw scrolled transcript");
5178
5179 let chat_area = ui_layout(&state, tui_viewport(area)).0;
5180 let scrollbar_x = chat_area.x + chat_area.width - 1;
5181 let buffer = terminal.backend().buffer();
5182 assert!(
5183 (chat_area.y..chat_area.y + chat_area.height).any(|y| {
5184 buffer[(scrollbar_x, y)].symbol() == TRANSCRIPT_SCROLLBAR_THUMB
5185 && buffer[(scrollbar_x, y)].fg == CONSOLE_STATUS_COLOR
5186 }),
5187 "a scrolled transcript should show a scrollbar thumb"
5188 );
5189 assert!(
5190 (chat_area.y..chat_area.y + chat_area.height)
5191 .any(|y| { buffer[(scrollbar_x, y)].symbol() == TRANSCRIPT_SCROLLBAR_TRACK }),
5192 "a scrolled transcript should show a scrollbar track"
5193 );
5194
5195 state.auto_scroll = true;
5196 state.scroll = 0;
5197 let mut terminal =
5198 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5199 .expect("test terminal");
5200 terminal
5201 .draw(|frame| draw(frame, &state))
5202 .expect("draw following transcript");
5203 let buffer = terminal.backend().buffer();
5204 assert!((chat_area.y..chat_area.y + chat_area.height)
5205 .all(|y| buffer[(scrollbar_x, y)].symbol() != TRANSCRIPT_SCROLLBAR_THUMB));
5206 }
5207
5208 #[test]
5209 fn tool_result_sweep_is_now_twice_as_fast() {
5210 assert_eq!(TOOL_RESULT_SWEEP_DURATION, Duration::from_millis(600));
5211 }
5212
5213 #[test]
5214 fn wrap_text_breaks_long_lines_and_preserves_empty_lines() {
5215 let rows = wrap_text("12345\n\nabc", 3);
5216 assert_eq!(rows, vec!["123", "45", "", "abc"]);
5217 }
5218
5219 #[test]
5220 fn wrap_line_never_returns_an_empty_vec() {
5221 assert_eq!(wrap_line("", 5), vec![""]);
5222 assert_eq!(wrap_line("abc", 5), vec!["abc"]);
5223 }
5224
5225 #[test]
5226 fn multiline_input_arrows_move_cursor_between_explicit_and_wrapped_rows() {
5227 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5228 state.input = "ab\ncd\nef".to_owned();
5229 state.cursor = 1;
5230
5231 assert!(move_input_cursor_vertical(&mut state, 10, true));
5232 assert_eq!(
5233 state.cursor, 4,
5234 "preserve the column on the next explicit row"
5235 );
5236 assert!(move_input_cursor_vertical(&mut state, 10, true));
5237 assert_eq!(state.cursor, 7);
5238 assert!(!move_input_cursor_vertical(&mut state, 10, true));
5239 assert!(move_input_cursor_vertical(&mut state, 10, false));
5240 assert_eq!(state.cursor, 4);
5241
5242 state.input = "abcdef".to_owned();
5243 state.cursor = 1;
5244 assert!(move_input_cursor_vertical(&mut state, 3, true));
5245 assert_eq!(state.cursor, 4, "wrapped rows use the same visual column");
5246 assert!(move_input_cursor_vertical(&mut state, 3, false));
5247 assert_eq!(state.cursor, 1);
5248 }
5249
5250 #[test]
5251 fn completion_event_does_not_release_input_before_worker_finishes() {
5252 let history = [SessionHistoryRecord::Message {
5253 timestamp: 1,
5254 message: ChatMessage::user("hello".to_owned()),
5255 }];
5256 let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
5257 state.busy = true;
5258 state.active_cancel = Some(CancellationToken::new());
5259 state.apply_event(ProtocolEvent::TurnEnd);
5260 assert!(state.busy);
5261 assert!(state.active_cancel.is_some());
5262 assert_eq!(state.status, "finalizing");
5263 }
5264
5265 #[test]
5266 fn transcript_inserts_a_blank_line_between_items() {
5267 let history = [
5268 SessionHistoryRecord::Message {
5269 timestamp: 1,
5270 message: ChatMessage::user("hi".to_owned()),
5271 },
5272 SessionHistoryRecord::Message {
5273 timestamp: 2,
5274 message: ChatMessage::assistant("hello".to_owned(), Vec::new()),
5275 },
5276 ];
5277 let state = UiState::from_history(&history, "secret", "model", None, false);
5278 let lines = transcript_lines(&state, 80);
5279 assert_eq!(lines.len(), 5);
5280 assert_eq!(lines[0].to_string(), "▌");
5281 assert_eq!(lines[1].to_string(), "▌ hi");
5282 assert_eq!(lines[2].to_string(), "▌");
5283 assert_eq!(lines[3].to_string(), "");
5284 assert_eq!(lines[4].to_string(), "hello");
5285 }
5286
5287 #[test]
5288 fn cmd_call_renders_as_a_compact_status_line_without_raw_json() {
5289 let history = vec![
5290 SessionHistoryRecord::Message {
5291 timestamp: 1,
5292 message: ChatMessage::assistant(
5293 String::new(),
5294 vec![crate::model::ChatToolCall {
5295 id: "call-1".to_owned(),
5296 name: "cmd".to_owned(),
5297 arguments: r#"{"command":"pwd"}"#.to_owned(),
5298 }],
5299 ),
5300 },
5301 SessionHistoryRecord::Message {
5302 timestamp: 2,
5303 message: ChatMessage::tool(
5304 "call-1".to_owned(),
5305 "cmd".to_owned(),
5306 serde_json::json!({"exit_code": 0, "stdout": "secret output"}).to_string(),
5307 ),
5308 },
5309 ];
5310 let state = UiState::from_history(&history, "secret", "model", None, false);
5311 let text = transcript_lines(&state, 80)[0].to_string();
5312
5313 assert_eq!(text, "✓ cmd $ pwd");
5314 assert!(!text.contains("secret output"));
5315 assert!(!text.contains("{\"command\":\"pwd\"}"));
5316 }
5317
5318 #[test]
5319 fn pending_cmd_calls_use_a_compact_running_status() {
5320 let history = [SessionHistoryRecord::Message {
5321 timestamp: 1,
5322 message: ChatMessage::assistant(
5323 String::new(),
5324 vec![crate::model::ChatToolCall {
5325 id: "call-1".to_owned(),
5326 name: "cmd".to_owned(),
5327 arguments: r#"{"command":"pwd"}"#.to_owned(),
5328 }],
5329 ),
5330 }];
5331 let state = UiState::from_history(&history, "secret", "model", None, false);
5332 let line = &transcript_lines(&state, 80)[0];
5333
5334 let text = line.to_string();
5335 let prefix = "· cmd $ pwd ";
5336 assert!(text.starts_with(prefix));
5337 assert!(!text.contains("→ running"));
5338 let frame = &text[prefix.len()..];
5339 assert_eq!(frame.chars().count(), 1);
5340 assert!(frame
5341 .chars()
5342 .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
5343 assert!(line
5344 .spans
5345 .iter()
5346 .all(|span| span.style.fg == Some(PENDING_TOOL_COLOR)));
5347 }
5348
5349 #[test]
5350 fn running_tool_indicators_use_a_traditional_spinner_with_their_own_clock() {
5351 assert_eq!(tool_spinner_frame_at(Duration::ZERO), '|');
5352 assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION), '/');
5353 assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 2), '-');
5354 assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 3), '\\');
5355
5356 let state = UiState::from_history(&[], "secret", "model", None, false);
5357 let spinner = running_tool_status(&state);
5358 assert_eq!(spinner.chars().count(), 1);
5359 assert!(spinner
5360 .chars()
5361 .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
5362 }
5363
5364 #[test]
5365 fn successful_cmd_cross_fades_to_teal_from_first_character_to_last() {
5366 let started_at = Instant::now();
5367 let character_count = 12;
5368 let early = started_at + TOOL_RESULT_SWEEP_DURATION / 4;
5369 let halfway = started_at + TOOL_RESULT_SWEEP_DURATION / 2;
5370 let late = started_at + TOOL_RESULT_SWEEP_DURATION * 3 / 4;
5371
5372 assert_eq!(
5373 cmd_result_color_at(
5374 started_at,
5375 started_at,
5376 0,
5377 character_count,
5378 TOOL_SUCCESS_COLOR,
5379 ),
5380 PENDING_TOOL_COLOR,
5381 );
5382 assert_eq!(TOOL_SUCCESS_COLOR, Color::Rgb(0, 210, 175));
5383
5384 let early_first =
5385 cmd_result_color_at(started_at, early, 0, character_count, TOOL_SUCCESS_COLOR);
5386 assert_ne!(early_first, PENDING_TOOL_COLOR);
5387 assert_ne!(early_first, TOOL_SUCCESS_COLOR);
5388 assert_eq!(
5389 cmd_result_color_at(started_at, early, 5, character_count, TOOL_SUCCESS_COLOR),
5390 PENDING_TOOL_COLOR,
5391 "later characters wait while the first character cross-fades"
5392 );
5393
5394 assert_eq!(
5395 cmd_result_color_at(started_at, halfway, 0, character_count, TOOL_SUCCESS_COLOR),
5396 TOOL_SUCCESS_COLOR,
5397 );
5398 let halfway_middle =
5399 cmd_result_color_at(started_at, halfway, 5, character_count, TOOL_SUCCESS_COLOR);
5400 assert_ne!(halfway_middle, PENDING_TOOL_COLOR);
5401 assert_ne!(halfway_middle, TOOL_SUCCESS_COLOR);
5402 assert_eq!(
5403 cmd_result_color_at(
5404 started_at,
5405 halfway,
5406 character_count - 1,
5407 character_count,
5408 TOOL_SUCCESS_COLOR,
5409 ),
5410 PENDING_TOOL_COLOR,
5411 );
5412
5413 let late_last = cmd_result_color_at(
5414 started_at,
5415 late,
5416 character_count - 1,
5417 character_count,
5418 TOOL_SUCCESS_COLOR,
5419 );
5420 assert_ne!(late_last, PENDING_TOOL_COLOR);
5421 assert_ne!(late_last, TOOL_SUCCESS_COLOR);
5422 assert_eq!(
5423 cmd_result_color_at(
5424 started_at,
5425 started_at + TOOL_RESULT_SWEEP_DURATION,
5426 character_count - 1,
5427 character_count,
5428 TOOL_SUCCESS_COLOR,
5429 ),
5430 TOOL_SUCCESS_COLOR,
5431 "the completed sweep keeps the exact teal used during the fade"
5432 );
5433 }
5434
5435 #[test]
5436 fn cmd_result_cross_fade_has_no_abrupt_color_change_between_render_ticks() {
5437 let started_at = Instant::now();
5438 let character_count = 12;
5439 let render_ticks = TOOL_RESULT_SWEEP_DURATION.as_millis() / EVENT_POLL.as_millis();
5440
5441 for target in [TOOL_SUCCESS_COLOR, TOOL_FAILURE_COLOR, TOOL_WARNING_COLOR] {
5442 for character_index in 0..character_count {
5443 let frames = (0..=render_ticks)
5444 .map(|tick| {
5445 cmd_result_color_at(
5446 started_at,
5447 started_at + EVENT_POLL * tick as u32,
5448 character_index,
5449 character_count,
5450 target,
5451 )
5452 })
5453 .collect::<Vec<_>>();
5454
5455 assert!(frames
5456 .iter()
5457 .any(|color| { *color != PENDING_TOOL_COLOR && *color != target }));
5458 assert!(frames.windows(2).all(|pair| {
5459 let (before_red, before_green, before_blue) = tool_result_color_rgb(pair[0]);
5460 let (after_red, after_green, after_blue) = tool_result_color_rgb(pair[1]);
5461 before_red.abs_diff(after_red) <= 90
5462 && before_green.abs_diff(after_green) <= 90
5463 && before_blue.abs_diff(after_blue) <= 90
5464 }));
5465 assert_eq!(frames.last(), Some(&target));
5466 }
5467 }
5468 }
5469
5470 #[test]
5471 fn only_live_cmd_results_start_a_result_sweep() {
5472 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5473 let succeeded = serde_json::json!({"exit_code": 0});
5474
5475 state.add_tool_result("historic", "cmd", succeeded.clone());
5476 state.add_live_tool_result("success", "cmd", succeeded);
5477 state.add_live_tool_result("failed", "cmd", serde_json::json!({"exit_code": 1}));
5478
5479 assert!(!state.cmd_result_started_at.contains_key("historic"));
5480 assert!(state.cmd_result_started_at.contains_key("success"));
5481 assert!(state.cmd_result_started_at.contains_key("failed"));
5482 }
5483
5484 #[test]
5485 fn failed_cmd_cross_fades_to_the_same_rgb_red_without_a_final_jump() {
5486 let started_at = Instant::now();
5487 let character_count = 12;
5488 let halfway = started_at + TOOL_RESULT_SWEEP_DURATION / 2;
5489
5490 assert_eq!(
5491 cmd_result_color_at(
5492 started_at,
5493 started_at,
5494 0,
5495 character_count,
5496 TOOL_FAILURE_COLOR,
5497 ),
5498 PENDING_TOOL_COLOR,
5499 );
5500 assert_eq!(
5501 cmd_result_color_at(started_at, halfway, 0, character_count, TOOL_FAILURE_COLOR),
5502 TOOL_FAILURE_COLOR,
5503 );
5504 let intermediate =
5505 cmd_result_color_at(started_at, halfway, 5, character_count, TOOL_FAILURE_COLOR);
5506 assert_ne!(intermediate, PENDING_TOOL_COLOR);
5507 assert_ne!(intermediate, TOOL_FAILURE_COLOR);
5508 assert_eq!(
5509 cmd_result_color_at(
5510 started_at,
5511 halfway,
5512 character_count - 1,
5513 character_count,
5514 TOOL_FAILURE_COLOR,
5515 ),
5516 PENDING_TOOL_COLOR,
5517 );
5518 assert_eq!(
5519 cmd_result_color_at(
5520 started_at,
5521 started_at + TOOL_RESULT_SWEEP_DURATION,
5522 character_count - 1,
5523 character_count,
5524 TOOL_FAILURE_COLOR,
5525 ),
5526 TOOL_FAILURE_COLOR,
5527 "the completed failure sweep keeps the exact RGB red used during the fade"
5528 );
5529 }
5530
5531 #[test]
5532 fn live_failed_cmd_sweep_keeps_the_final_status_text() {
5533 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5534 let result = serde_json::json!({"exit_code": 1});
5535 state.add_live_tool_result("failed", "cmd", result.clone());
5536
5537 let segments = cmd_tool_segments("failed", r#"{"command":"bad"}"#, Some(&result), &state);
5538 let text = segments
5539 .iter()
5540 .map(|(text, _)| text.as_str())
5541 .collect::<String>();
5542
5543 assert_eq!(text, "× cmd $ bad → exit 1");
5544 }
5545
5546 #[test]
5547 fn cmd_result_target_colors_follow_the_final_status() {
5548 assert_eq!(
5549 cmd_result_target_color(&serde_json::json!({"exit_code": 0})),
5550 TOOL_SUCCESS_COLOR
5551 );
5552 assert_eq!(
5553 cmd_result_target_color(&serde_json::json!({"exit_code": 1})),
5554 TOOL_FAILURE_COLOR
5555 );
5556 assert_eq!(
5557 cmd_result_target_color(&serde_json::json!({"timed_out": true})),
5558 TOOL_WARNING_COLOR
5559 );
5560 }
5561
5562 #[test]
5563 fn cmd_status_distinguishes_nonzero_exit_timeout_and_cancellation() {
5564 let cases = [
5565 (
5566 serde_json::json!({"exit_code": 127}),
5567 "× cmd $ bad → exit 127",
5568 ),
5569 (
5570 serde_json::json!({"timed_out": true, "exit_code": null}),
5571 "! cmd $ slow → timeout",
5572 ),
5573 (
5574 serde_json::json!({"canceled": true}),
5575 "! cmd $ stop → canceled",
5576 ),
5577 ];
5578 for (result, expected) in cases {
5579 let history = vec![
5580 SessionHistoryRecord::Message {
5581 timestamp: 1,
5582 message: ChatMessage::assistant(
5583 String::new(),
5584 vec![crate::model::ChatToolCall {
5585 id: "call-1".to_owned(),
5586 name: "cmd".to_owned(),
5587 arguments: serde_json::json!({"command": expected.split("$ ").nth(1).unwrap().split(" ").next().unwrap()}).to_string(),
5588 }],
5589 ),
5590 },
5591 SessionHistoryRecord::Message {
5592 timestamp: 2,
5593 message: ChatMessage::tool(
5594 "call-1".to_owned(),
5595 "cmd".to_owned(),
5596 result.to_string(),
5597 ),
5598 },
5599 ];
5600 let state = UiState::from_history(&history, "secret", "model", None, false);
5601 assert_eq!(transcript_lines(&state, 80)[0].to_string(), expected);
5602 }
5603 }
5604
5605 #[test]
5606 fn cmd_line_truncates_long_commands_but_never_renders_output() {
5607 let command = "a".repeat(120);
5608 let arguments = serde_json::json!({"command": command}).to_string();
5609 let history = vec![
5610 SessionHistoryRecord::Message {
5611 timestamp: 1,
5612 message: ChatMessage::assistant(
5613 String::new(),
5614 vec![crate::model::ChatToolCall {
5615 id: "call-1".to_owned(),
5616 name: "cmd".to_owned(),
5617 arguments,
5618 }],
5619 ),
5620 },
5621 SessionHistoryRecord::Message {
5622 timestamp: 2,
5623 message: ChatMessage::tool(
5624 "call-1".to_owned(),
5625 "cmd".to_owned(),
5626 serde_json::json!({"exit_code": 0, "stdout": "output"}).to_string(),
5627 ),
5628 },
5629 ];
5630 let state = UiState::from_history(&history, "secret", "model", None, false);
5631 let text = transcript_lines(&state, 200)[0].to_string();
5632 assert!(text.contains(&format!("$ {}…", "a".repeat(100))));
5633 assert!(!text.contains(&"a".repeat(101)));
5634 assert!(!text.contains("output"));
5635 }
5636
5637 #[test]
5638 fn cmd_lines_remain_compact_for_consecutive_calls() {
5639 let history = vec![
5640 SessionHistoryRecord::Message {
5641 timestamp: 1,
5642 message: ChatMessage::assistant(
5643 String::new(),
5644 vec![
5645 crate::model::ChatToolCall {
5646 id: "call-first".to_owned(),
5647 name: "cmd".to_owned(),
5648 arguments: r#"{"command":"first"}"#.to_owned(),
5649 },
5650 crate::model::ChatToolCall {
5651 id: "call-second".to_owned(),
5652 name: "cmd".to_owned(),
5653 arguments: r#"{"command":"second"}"#.to_owned(),
5654 },
5655 ],
5656 ),
5657 },
5658 SessionHistoryRecord::Message {
5659 timestamp: 2,
5660 message: ChatMessage::tool(
5661 "call-first".to_owned(),
5662 "cmd".to_owned(),
5663 serde_json::json!({"exit_code": 0}).to_string(),
5664 ),
5665 },
5666 SessionHistoryRecord::Message {
5667 timestamp: 3,
5668 message: ChatMessage::tool(
5669 "call-second".to_owned(),
5670 "cmd".to_owned(),
5671 serde_json::json!({"exit_code": 0}).to_string(),
5672 ),
5673 },
5674 ];
5675 let state = UiState::from_history(&history, "secret", "model", None, false);
5676 let lines = transcript_lines(&state, 200);
5677 assert_eq!(lines[0].to_string(), "✓ cmd $ first");
5678 assert_eq!(lines[2].to_string(), "✓ cmd $ second");
5679 }
5680
5681 #[test]
5682 fn cmd_status_styles_use_success_failure_and_pending_colors() {
5683 assert_eq!(
5684 cmd_result_status(&serde_json::json!({"exit_code": 0})).2.fg,
5685 Some(TOOL_SUCCESS_COLOR)
5686 );
5687 assert_eq!(
5688 cmd_result_status(&serde_json::json!({"exit_code": 1})).2.fg,
5689 Some(TOOL_FAILURE_COLOR)
5690 );
5691 assert_eq!(
5692 cmd_tool_segments(
5693 "call-1",
5694 "{\"command\":\"pwd\"}",
5695 None,
5696 &UiState::from_history(&[], "secret", "model", None, false)
5697 )[0]
5698 .1
5699 .fg,
5700 Some(PENDING_TOOL_COLOR)
5701 );
5702 }
5703
5704 #[test]
5705 fn recognized_skill_trigger_is_highlighted_but_arguments_remain_default_colored() {
5706 let trigger = active_skill_trigger("/release-notes v1.2.0", &["release-notes".to_owned()]);
5707 assert_eq!(trigger, Some("/release-notes"));
5708 assert_eq!(SKILL_TRIGGER_COLOR, Color::Rgb(80, 255, 245));
5709
5710 let lines = styled_text_lines(
5711 "/release-notes v1.2.0",
5712 trigger,
5713 80,
5714 Style::default().fg(Color::White),
5715 );
5716 assert_eq!(lines.len(), 1);
5717 assert_eq!(lines[0].to_string(), "/release-notes v1.2.0");
5718 assert_eq!(lines[0].spans[0].content, "/release-notes");
5719 assert_eq!(lines[0].spans[0].style.fg, Some(SKILL_TRIGGER_COLOR));
5720 assert_eq!(lines[0].spans[1].content, " v1.2.0");
5721 assert_eq!(lines[0].spans[1].style.fg, Some(Color::White));
5722 }
5723
5724 #[test]
5725 fn draw_renders_an_active_skill_trigger_in_cyan() {
5726 let mut state = UiState::from_history(&[], "secret", "model", None, false)
5727 .with_skill_names(vec!["release-notes".to_owned()]);
5728 state.input = "/release-notes v1.2.0".to_owned();
5729 state.cursor = state.input.chars().count();
5730
5731 let mut terminal =
5732 Terminal::new(ratatui::backend::TestBackend::new(40, 10)).expect("test terminal");
5733 terminal
5734 .draw(|frame| draw(frame, &state))
5735 .expect("draw input");
5736
5737 let buffer = terminal.backend().buffer();
5740 let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 40, 10)));
5741 let prompt_area = prompt_area(input_area, &state);
5742 let input_x = prompt_area.x;
5743 let input_y = prompt_area.y;
5744 assert_eq!(buffer[(input_x, input_y)].fg, SKILL_TRIGGER_COLOR);
5745 assert_eq!(
5746 buffer[(input_x + "/release-notes".chars().count() as u16, input_y)].fg,
5747 Color::White
5748 );
5749 }
5750
5751 #[test]
5752 fn main_agent_status_omits_activity_animation_on_idle_and_busy_glass() {
5753 let mut state =
5754 UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
5755 let area = Rect::new(0, 0, 80, 10);
5756 let mut terminal =
5757 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5758 .expect("test terminal");
5759
5760 terminal
5761 .draw(|frame| draw(frame, &state))
5762 .expect("draw ready status");
5763 let viewport = tui_viewport(area);
5764 let status_area = ui_layout(&state, viewport).5;
5765 let expected_context = "Context: 81/100 (81%) █████████░";
5766 let buffer = terminal.backend().buffer();
5767 let idle_row = (status_area.x..status_area.x + status_area.width)
5768 .map(|x| buffer[(x, status_area.y)].symbol())
5769 .collect::<String>();
5770 assert!(idle_row.starts_with("model · default"));
5771 assert!(idle_row.ends_with(expected_context));
5772 for x in status_area.x..status_area.x + status_area.width {
5773 if buffer[(x, status_area.y)].symbol() != " " {
5774 assert_eq!(buffer[(x, status_area.y)].fg, Color::Rgb(144, 144, 148));
5775 }
5776 }
5777
5778 state.set_status("working");
5779 state.busy = true;
5780 state.activity_transition = None;
5781 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
5782 terminal
5783 .draw(|frame| draw(frame, &state))
5784 .expect("draw working status");
5785 let status_area = ui_layout(&state, viewport).5;
5786 let buffer = terminal.backend().buffer();
5787 let rendered = (status_area.x..status_area.x + status_area.width)
5788 .map(|x| buffer[(x, status_area.y)].symbol())
5789 .collect::<String>();
5790 assert!(rendered.starts_with("model · default "));
5791 assert!(rendered.contains(BUSY_INDICATOR_BLOCK));
5792 assert!(rendered.ends_with(expected_context));
5793 assert!(
5794 (status_area.x..status_area.x + status_area.width)
5795 .any(|x| buffer[(x, status_area.y)].bg != CONSOLE_BACKGROUND),
5796 "the busy status line renders over bright glass"
5797 );
5798 }
5799
5800 #[test]
5801 fn terminal_focus_events_control_cursor_visibility() {
5802 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5803
5804 assert!(handle_terminal_focus_event(&mut state, &Event::FocusLost));
5805 assert!(!state.terminal_focused);
5806 assert!(handle_terminal_focus_event(&mut state, &Event::FocusGained));
5807 assert!(state.terminal_focused);
5808 assert!(!handle_terminal_focus_event(
5809 &mut state,
5810 &Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
5811 ));
5812 assert!(state.terminal_focused);
5813 }
5814
5815 #[test]
5816 fn unfocused_busy_glow_keeps_the_hardware_cursor_hidden() {
5817 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5818 state.set_status("working");
5819 state.set_busy(true);
5820 state.terminal_focused = false;
5821 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
5822
5823 let mut terminal =
5824 Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
5825 terminal
5826 .draw(|frame| draw(frame, &state))
5827 .expect("draw busy glow");
5828
5829 assert!(
5830 !terminal.backend().cursor_visible(),
5831 "the glow redraw must not re-show the terminal cursor"
5832 );
5833 }
5834
5835 #[test]
5836 fn cjk_input_keeps_the_terminal_cursor_in_the_prompt_without_resetting_activity() {
5837 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5838 state.set_status("working");
5839 state.busy = true;
5840 state.input = "한글".to_owned();
5841 state.cursor = state.input.chars().count();
5842 let activity_started_at = state.activity_started_at;
5843 let tool_animation_epoch = state.tool_animation_epoch;
5844 let sample_at = Instant::now();
5845 let activity_before = state.activity_levels_at(sample_at);
5846
5847 state.input_changed();
5850 assert_eq!(state.activity_started_at, activity_started_at);
5851 assert_eq!(state.tool_animation_epoch, tool_animation_epoch);
5852 assert_eq!(state.activity_levels_at(sample_at), activity_before);
5853
5854 let area = Rect::new(0, 0, 80, 10);
5855 let mut terminal =
5856 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5857 .expect("test terminal");
5858 terminal
5859 .draw(|frame| draw(frame, &state))
5860 .expect("draw CJK input while working");
5861 let (_, _, _, _, input_area, status_area) = ui_layout(&state, tui_viewport(area));
5862 assert_ne!(input_area.y, status_area.y);
5863 let prompt_area = prompt_area(input_area, &state);
5864 assert!(terminal.backend().cursor_visible());
5865 terminal.backend_mut().assert_cursor_position((
5866 prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
5867 prompt_area.y,
5868 ));
5869 }
5870
5871 #[test]
5872 fn transcript_and_console_are_separated_by_one_blank_row() {
5873 let state = UiState::from_history(&[], "secret", "model", None, false);
5874 let area = Rect::new(0, 0, 80, 10);
5875 let mut terminal =
5876 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5877 .expect("test terminal");
5878
5879 terminal
5880 .draw(|frame| draw(frame, &state))
5881 .expect("draw separated transcript and console");
5882
5883 let (transcript, _, _, _, console, _) = ui_layout(&state, tui_viewport(area));
5884 assert_eq!(transcript.y + transcript.height + 1, console.y);
5885 let gap_y = console.y - 1;
5886 for x in transcript.x..transcript.x + transcript.width {
5887 assert_eq!(terminal.backend().buffer()[(x, gap_y)].symbol(), " ");
5888 assert_eq!(terminal.backend().buffer()[(x, gap_y)].bg, Color::Reset);
5889 }
5890 }
5891
5892 #[test]
5893 fn idle_console_has_external_gutters_uniform_background_and_no_borders() {
5894 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5895 state.input = "prompt".to_owned();
5896 state.cursor = state.input.chars().count();
5897 let area = Rect::new(0, 0, 80, 10);
5898 let mut terminal =
5899 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5900 .expect("test terminal");
5901
5902 terminal
5903 .draw(|frame| draw(frame, &state))
5904 .expect("draw idle console");
5905
5906 let input_area = ui_layout(&state, tui_viewport(area)).4;
5907 let prompt_area = prompt_area(input_area, &state);
5908 let buffer = terminal.backend().buffer();
5909 let bottom_y = area.y + area.height - 1;
5910 assert_eq!(buffer[(area.x, bottom_y)].bg, Color::Reset);
5911 assert_eq!(buffer[(area.x + area.width - 1, bottom_y)].bg, Color::Reset);
5912 for y in input_area.y..input_area.y + input_area.height {
5913 assert_eq!(buffer[(0, y)].bg, Color::Reset);
5914 assert_eq!(buffer[(79, y)].bg, Color::Reset);
5915 for x in input_area.x..input_area.x + input_area.width {
5916 assert_eq!(buffer[(x, y)].bg, CONSOLE_BACKGROUND);
5917 }
5918 }
5919 assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), " ");
5920 assert_eq!(
5921 buffer[(input_area.x + input_area.width - 1, input_area.y)].symbol(),
5922 " "
5923 );
5924 assert_eq!(
5925 buffer[(input_area.x, input_area.y + input_area.height - 1)].symbol(),
5926 " "
5927 );
5928 assert_eq!(
5929 buffer[(
5930 input_area.x + input_area.width - 1,
5931 input_area.y + input_area.height - 1
5932 )]
5933 .symbol(),
5934 " "
5935 );
5936 assert_eq!(buffer[(prompt_area.x, prompt_area.y)].symbol(), "p");
5937 assert_eq!(buffer[(prompt_area.x, prompt_area.y)].fg, Color::White);
5938 terminal.backend_mut().assert_cursor_position((
5939 prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
5940 prompt_area.y,
5941 ));
5942 }
5943
5944 #[test]
5945 fn busy_console_keeps_glass_inside_the_bottom_half_ellipse() {
5946 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5947 state.busy = true;
5948 state.set_status("working");
5949 state.activity_transition = None;
5950 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
5951 let area = Rect::new(0, 0, 200, 10);
5952 let mut terminal =
5953 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5954 .expect("test terminal");
5955
5956 terminal
5957 .draw(|frame| draw(frame, &state))
5958 .expect("draw busy console");
5959
5960 let viewport = tui_viewport(area);
5961 let (chat_area, _, _, _, input_area, _) = ui_layout(&state, viewport);
5962 let reflection_y = input_area.y - 1;
5963 let glow_floor_y = area.y + area.height - 1;
5964 let edge_y = input_area.y + input_area.height - 1;
5965 let floor = input_area.x + input_area.width / 2;
5966 let left_edge = input_area.x;
5967 let left_outer = left_edge - 1;
5968 let right_edge = input_area.x + input_area.width - 1;
5969 let right_outer = right_edge + 1;
5970 let widening_sample = input_area.x - 22;
5971 let buffer = terminal.backend().buffer();
5972 assert_eq!(chat_area.y + chat_area.height + 1, input_area.y);
5973 assert_eq!(
5974 buffer[(floor, reflection_y)].symbol(),
5975 CONSOLE_REFLECTION_GLYPH,
5976 "the active console reflects along the bottom of the existing gap row"
5977 );
5978 assert_ne!(buffer[(floor, reflection_y)].fg, Color::Reset);
5979 assert_ne!(buffer[(floor, glow_floor_y)].bg, Color::Reset);
5980 assert_ne!(buffer[(left_outer, edge_y)].bg, Color::Reset);
5981 assert_ne!(buffer[(right_outer, edge_y)].bg, Color::Reset);
5982 let left_extent = left_edge - GLOW_HORIZONTAL_SPREAD;
5983 let right_extent = right_edge + GLOW_HORIZONTAL_SPREAD;
5984 assert_ne!(buffer[(left_extent, glow_floor_y)].bg, Color::Reset);
5985 assert_ne!(buffer[(right_extent, glow_floor_y)].bg, Color::Reset);
5986 assert_eq!(buffer[(left_extent - 1, glow_floor_y)].bg, Color::Reset);
5987 assert_eq!(buffer[(right_extent + 1, glow_floor_y)].bg, Color::Reset);
5988 assert_eq!(buffer[(widening_sample, input_area.y)].bg, Color::Reset);
5989 assert_ne!(buffer[(widening_sample, glow_floor_y)].bg, Color::Reset);
5990 assert_eq!(buffer[(area.x, glow_floor_y)].bg, Color::Reset);
5991 for y in input_area.y..input_area.y + input_area.height {
5992 for x in input_area.x..input_area.x + input_area.width {
5993 assert_ne!(buffer[(x, y)].bg, Color::Reset);
5994 }
5995 }
5996 for y in input_area.y..input_area.y + input_area.height {
5997 for x in input_area.x..input_area.x + input_area.width {
5998 assert_ne!(buffer[(x, y)].bg, Color::Reset);
5999 assert!(
6000 color_distance(CONSOLE_BACKGROUND, buffer[(x, y)].bg)
6001 < color_distance(TUI_GLOW_BACKGROUND, buffer[(floor, glow_floor_y)].bg)
6002 );
6003 }
6004 }
6005 }
6006
6007 #[test]
6008 fn narrow_busy_console_keeps_both_endpoint_blooms_exposed() {
6009 let mut state = UiState::from_history(&[], "secret", "model", None, false);
6010 state.busy = true;
6011 state.set_status("working");
6012 state.activity_transition = None;
6013 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
6014 let area = Rect::new(0, 0, 10, 10);
6015 let mut terminal =
6016 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
6017 .expect("test terminal");
6018
6019 terminal
6020 .draw(|frame| draw(frame, &state))
6021 .expect("draw narrow TUI");
6022
6023 let input = ui_layout(&state, tui_viewport(area)).4;
6024 assert_eq!(input.width, 4, "test the minimum inset console width");
6025 let left_edge = input.x;
6026 let right_edge = input.x + input.width - 1;
6027 let buffer = terminal.backend().buffer();
6028 for y in input.y..input.y + input.height {
6029 for (edge, outer) in [(left_edge, left_edge - 1), (right_edge, right_edge + 1)] {
6030 assert_ne!(
6031 buffer[(outer, y)].bg,
6032 Color::Reset,
6033 "the outer glow cell is present at ({outer}, {y})"
6034 );
6035 assert_ne!(
6036 buffer[(edge, y)].bg,
6037 buffer[(outer, y)].bg,
6038 "narrow console glass does not spill into the exposed bloom at ({edge}, {y})"
6039 );
6040 }
6041 }
6042 }
6043
6044 #[test]
6045 fn only_known_leading_skill_commands_activate_input_highlighting() {
6046 let skills = ["release-notes".to_owned()];
6047 assert_eq!(
6048 active_skill_trigger("/missing", &skills),
6049 None,
6050 "unknown commands are rejected by the turn engine and must not look active"
6051 );
6052 assert_eq!(
6053 active_skill_trigger("/skill:release-notes", &skills),
6054 None,
6055 "the removed /skill: wrapper must not look active"
6056 );
6057 assert_eq!(
6058 active_skill_trigger("write /release-notes", &skills),
6059 None,
6060 "only the command prefix accepted by the turn engine is active"
6061 );
6062 assert_eq!(active_skill_trigger("/", &skills), None);
6063 }
6064
6065 #[test]
6066 fn highlighted_skill_trigger_remains_styled_when_wrapped() {
6067 let input = "/release-notes argument";
6068 let trigger = active_skill_trigger(input, &["release-notes".to_owned()]);
6069 let lines = styled_text_lines(input, trigger, 8, Style::default().fg(Color::White));
6070 let highlighted = lines
6071 .iter()
6072 .flat_map(|line| line.spans.iter())
6073 .filter(|span| span.style.fg == Some(SKILL_TRIGGER_COLOR))
6074 .map(|span| span.content.as_ref())
6075 .collect::<String>();
6076 assert_eq!(highlighted, "/release-notes");
6077 }
6078
6079 #[test]
6080 fn input_has_no_prompt_marker_and_trailing_newline_is_visible() {
6081 assert_eq!(input_prompt("hello"), "hello");
6082 assert_eq!(wrap_text("hello\n", 80), vec!["hello", ""]);
6083 }
6084
6085 #[test]
6086 fn input_prompt_wraps_to_multiple_rows_when_long() {
6087 let mut state = UiState::from_history(&[], "secret", "model", None, false);
6088 state.input = "abcdefghij".to_owned();
6089 let rows = input_visible_rows(&state, 5);
6091 assert!(rows >= 2);
6092 }
6093
6094 #[test]
6095 fn cursor_editing_moves_by_characters_and_preserves_unicode() {
6096 let mut input = "가나".to_owned();
6097 let mut cursor = input.chars().count();
6098 cursor -= 1;
6099 insert_at_cursor(&mut input, &mut cursor, 'x');
6100 assert_eq!(input, "가x나");
6101 assert_eq!(cursor, 2);
6102 assert!(remove_before_cursor(&mut input, &mut cursor));
6103 assert_eq!(input, "가나");
6104 assert_eq!(cursor, 1);
6105 }
6106
6107 #[test]
6108 fn cursor_row_tracks_newlines_and_wrapping() {
6109 assert_eq!(cursor_row("hello\nworld", 6, 80), 1);
6110 assert_eq!(cursor_row("abcdef", 4, 3), 1);
6111 }
6112
6113 #[test]
6114 fn shift_enter_inserts_at_the_cursor_and_moves_it_to_the_new_row() {
6115 let mut input = "beforeafter".to_owned();
6116 let mut cursor = 6;
6117 insert_at_cursor(&mut input, &mut cursor, '\n');
6118
6119 assert_eq!(input, "before\nafter");
6120 assert_eq!(cursor, 7);
6121 assert_eq!(cursor_row(&input, cursor, 80), 1);
6122 }
6123
6124 #[test]
6125 fn shift_enter_renders_the_cursor_on_the_new_input_row() {
6126 let mut state = UiState::from_history(&[], "secret", "model", None, false);
6127 state.input = "beforeafter".to_owned();
6128 state.cursor = 6;
6129 insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
6130
6131 let mut terminal =
6132 Terminal::new(ratatui::backend::TestBackend::new(20, 10)).expect("test terminal");
6133 terminal
6134 .draw(|frame| draw(frame, &state))
6135 .expect("draw input cursor");
6136
6137 let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 20, 10)));
6140 let prompt_area = prompt_area(input_area, &state);
6141 terminal
6142 .backend_mut()
6143 .assert_cursor_position((prompt_area.x, prompt_area.y + 1));
6144 }
6145
6146 #[test]
6147 fn tool_results_attach_to_their_matching_call_after_consecutive_calls() {
6148 let history = vec![
6149 SessionHistoryRecord::Message {
6150 timestamp: 1,
6151 message: ChatMessage::assistant(
6152 String::new(),
6153 vec![
6154 crate::model::ChatToolCall {
6155 id: "call-first".to_owned(),
6156 name: "cmd".to_owned(),
6157 arguments: r#"{"command":"first"}"#.to_owned(),
6158 },
6159 crate::model::ChatToolCall {
6160 id: "call-second".to_owned(),
6161 name: "cmd".to_owned(),
6162 arguments: r#"{"command":"second"}"#.to_owned(),
6163 },
6164 ],
6165 ),
6166 },
6167 SessionHistoryRecord::Message {
6168 timestamp: 2,
6169 message: ChatMessage::tool(
6170 "call-first".to_owned(),
6171 "cmd".to_owned(),
6172 serde_json::json!({"stdout":"first result","stderr":""}).to_string(),
6173 ),
6174 },
6175 SessionHistoryRecord::Message {
6176 timestamp: 3,
6177 message: ChatMessage::tool(
6178 "call-second".to_owned(),
6179 "cmd".to_owned(),
6180 serde_json::json!({"stdout":"second result","stderr":""}).to_string(),
6181 ),
6182 },
6183 ];
6184
6185 let state = UiState::from_history(&history, "secret", "model", None, false);
6186 let lines = transcript_lines(&state, 200);
6187 assert_eq!(
6188 lines.len(),
6189 3,
6190 "only the two call lines and their separator remain"
6191 );
6192 assert_eq!(lines[0].to_string(), "✓ cmd $ first");
6193 assert_eq!(lines[2].to_string(), "✓ cmd $ second");
6194 }
6195 #[test]
6196 fn clipped_slash_picker_uses_its_actual_item_rows_for_the_focused_item() {
6197 let mut state = UiState::from_history(&[], "secret", "model", None, false)
6198 .with_skill_names(
6199 ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
6200 .into_iter()
6201 .map(str::to_owned)
6202 .collect(),
6203 );
6204 state.input = "/".to_owned();
6205 state.input_changed();
6206 state.skill_picker_focus = 5;
6207 let mut terminal =
6208 Terminal::new(ratatui::backend::TestBackend::new(30, 5)).expect("test terminal");
6209 terminal
6210 .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 5)))
6211 .expect("draw clipped skill picker");
6212
6213 let buffer = terminal.backend().buffer();
6214 let item_rows = (2..4)
6215 .map(|y| (2..28).map(|x| buffer[(x, y)].symbol()).collect::<String>())
6216 .collect::<Vec<_>>();
6217 assert!(item_rows[0].starts_with("/deploy"));
6218 assert!(item_rows[1].starts_with("/doctor"));
6219 assert_eq!(buffer[(2, 3)].fg, QUEUED_MESSAGE_COLOR);
6220 assert!(buffer[(2, 3)].modifier.contains(Modifier::BOLD));
6221 }
6222}
6223
6224#[cfg(test)]
6225mod skill_picker_tests {
6226 use super::*;
6227
6228 fn skill_names() -> Vec<String> {
6229 ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
6230 .into_iter()
6231 .map(str::to_owned)
6232 .collect()
6233 }
6234
6235 #[test]
6236 fn built_in_commands_share_the_slash_catalog_without_becoming_skills() {
6237 assert_eq!(
6238 command_names(vec!["release-notes".to_owned(), "settings".to_owned()]),
6239 vec!["exit", "release-notes", "settings"]
6240 );
6241 assert_eq!(
6242 builtin_command("/settings ignored arguments"),
6243 Some(BuiltinCommand::Settings)
6244 );
6245 assert_eq!(builtin_command(" /exit "), Some(BuiltinCommand::Exit));
6246 assert_eq!(builtin_command("/settings-extra"), None);
6247 }
6248
6249 #[test]
6250 fn settings_viewport_follows_focus_instead_of_truncating_the_catalog_head() {
6251 assert_eq!(selection_range(30, 0, 12), 0..12);
6252 assert_eq!(selection_range(30, 11, 12), 0..12);
6253 assert_eq!(selection_range(30, 12, 12), 1..13);
6254 assert_eq!(selection_range(30, 29, 12), 18..30);
6255 }
6256
6257 #[test]
6258 fn model_selection_uses_advertised_efforts_and_preserves_the_current_choice() {
6259 let mut state = UiState::from_history(&[], "secret", "old", Some("medium"), false);
6260 state.open_catalog(Ok(vec![ProviderModel {
6261 id: "openai/gpt-5.6-sol".to_owned(),
6262 efforts: Some(vec![
6263 "max".to_owned(),
6264 "high".to_owned(),
6265 "medium".to_owned(),
6266 "low".to_owned(),
6267 ]),
6268 }]));
6269 state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6270
6271 let SettingsState::Effort { model, focus, .. } =
6272 state.settings.as_ref().expect("effort picker")
6273 else {
6274 panic!("model selection should open the effort picker");
6275 };
6276 assert_eq!(model.id, "openai/gpt-5.6-sol");
6277 assert_eq!(*focus, 3, "default occupies index zero before medium");
6278
6279 let selected = state
6280 .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
6281 .expect("effort selection");
6282 assert_eq!(
6283 selected,
6284 ("openai/gpt-5.6-sol".to_owned(), Some("medium".to_owned()))
6285 );
6286 }
6287
6288 #[test]
6289 fn effort_default_selection_does_not_shift_to_the_first_advertised_effort() {
6290 let mut state = UiState::from_history(&[], "secret", "old", None, false);
6291 state.open_catalog(Ok(vec![ProviderModel {
6292 id: "model".to_owned(),
6293 efforts: Some(vec!["high".to_owned(), "low".to_owned()]),
6294 }]));
6295 state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6296
6297 let selected = state
6298 .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
6299 .expect("default effort selection");
6300 assert_eq!(selected, ("model".to_owned(), None));
6301 }
6302
6303 #[test]
6304 fn reasoning_indicator_changes_to_complete_and_stays_dark_gray() {
6305 let mut state = UiState::from_history(&[], "secret", "model", None, false);
6306 state.show_thinking();
6307
6308 let active_lines = transcript_lines(&state, 80);
6309 let active = active_lines.last().expect("reasoning line");
6310 assert!(active.to_string().starts_with("Reasoning... "));
6311 assert_eq!(active.style.fg, Some(Color::DarkGray));
6312
6313 state.complete_reasoning();
6314 let complete_lines = transcript_lines(&state, 80);
6315 let complete = complete_lines.last().expect("complete line");
6316 assert_eq!(complete.to_string(), "Reasoning Complete");
6317 assert_eq!(complete.style.fg, Some(Color::DarkGray));
6318 }
6319
6320 #[test]
6321 fn slash_picker_filters_only_leading_command_text_and_hides_without_matches() {
6322 let names = skill_names();
6323 assert_eq!(
6324 matching_skill_names("/", &names),
6325 vec!["alpha", "beta", "build", "charlie", "deploy", "doctor"]
6326 );
6327 assert_eq!(matching_skill_names("/b", &names), vec!["beta", "build"]);
6328 assert!(matching_skill_names("/missing", &names).is_empty());
6329 assert!(matching_skill_names("message /b", &names).is_empty());
6330 assert!(matching_skill_names("/beta arguments", &names).is_empty());
6331 }
6332
6333 #[test]
6334 fn slash_picker_focuses_the_top_match_and_moves_within_filtered_results() {
6335 let mut state = UiState::from_history(&[], "secret", "model", None, false)
6336 .with_skill_names(skill_names());
6337 state.input = "/b".to_owned();
6338 state.input_changed();
6339
6340 assert!(state.skill_picker_visible());
6341 assert_eq!(state.skill_picker_focus, 0);
6342 assert!(state.move_skill_picker(true));
6343 assert_eq!(state.skill_picker_focus, 1);
6344 assert!(state.move_skill_picker(true));
6345 assert_eq!(state.skill_picker_focus, 1, "focus does not leave the list");
6346 assert!(state.move_skill_picker(false));
6347 assert_eq!(state.skill_picker_focus, 0);
6348
6349 state.input = "/missing".to_owned();
6350 state.input_changed();
6351 assert!(!state.skill_picker_visible());
6352 assert!(!state.move_skill_picker(true));
6353 }
6354
6355 #[test]
6356 fn focused_builtins_are_distinguished_from_skills() {
6357 let mut state = UiState::from_history(&[], "secret", "model", None, false)
6358 .with_skill_names(command_names(skill_names()));
6359 state.input = "/se".to_owned();
6360 state.input_changed();
6361 assert_eq!(
6362 state.focused_builtin_command(),
6363 Some(BuiltinCommand::Settings)
6364 );
6365
6366 state.input = "/be".to_owned();
6367 state.input_changed();
6368 assert_eq!(state.focused_builtin_command(), None);
6369 }
6370
6371 #[test]
6372 fn selecting_the_focused_skill_leaves_the_completed_command_ready_to_send() {
6373 let mut state = UiState::from_history(&[], "secret", "model", None, false)
6374 .with_skill_names(skill_names());
6375 state.input = "/b".to_owned();
6376 state.input_changed();
6377 state.move_skill_picker(true);
6378
6379 assert!(state.select_focused_skill());
6380 assert_eq!(state.input, "/build");
6381 assert_eq!(state.cursor, "/build".chars().count());
6382 assert!(
6383 !state.skill_picker_visible(),
6384 "the first Enter completes the input rather than sending it"
6385 );
6386 assert!(
6387 !state.select_focused_skill(),
6388 "a second Enter follows the normal send/attachment path"
6389 );
6390 }
6391
6392 #[test]
6393 fn slash_picker_overlays_without_reflowing_the_transcript_when_match_count_changes() {
6394 let mut state = UiState::from_history(&[], "secret", "model", None, false)
6395 .with_skill_names(skill_names());
6396 let area = Rect::new(0, 0, 40, 16);
6397 state.transcript = (0..20)
6398 .map(|index| TranscriptItem::Assistant(format!("message {index}")))
6399 .collect();
6400
6401 state.input = "/a".to_owned();
6402 state.input_changed();
6403 let (narrow_chat, narrow_picker, _, _, narrow_input, _) = ui_layout(&state, area);
6404 let narrow_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
6405
6406 state.input = "/".to_owned();
6407 state.input_changed();
6408 let (broad_chat, broad_picker, _, _, broad_input, _) = ui_layout(&state, area);
6409 let broad_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
6410
6411 assert_ne!(
6412 narrow_picker, broad_picker,
6413 "the overlay may fit its contents"
6414 );
6415 assert_eq!(narrow_chat, broad_chat);
6416 assert_eq!(narrow_input, broad_input);
6417 assert_eq!(
6418 narrow_scroll, broad_scroll,
6419 "the overlay does not reduce the transcript viewport"
6420 );
6421 }
6422
6423 #[test]
6424 fn slash_picker_keeps_the_focused_item_in_its_five_row_viewport() {
6425 assert_eq!(selection_range(20, 0, 5), 0..5);
6426 assert_eq!(selection_range(20, 4, 5), 0..5);
6427 assert_eq!(selection_range(20, 5, 5), 1..6);
6428 assert_eq!(selection_range(20, 19, 5), 15..20);
6429 }
6430
6431 #[test]
6432 fn is_inside_tmux_detection() {
6433 std::env::set_var("TERM_PROGRAM", "tmux");
6434 assert!(is_inside_tmux());
6435 std::env::set_var("TERM_PROGRAM", "TMUX");
6436 assert!(is_inside_tmux());
6437 std::env::set_var("TERM_PROGRAM", "ghostty");
6438 assert!(!is_inside_tmux());
6439 std::env::remove_var("TERM_PROGRAM");
6440 assert!(!is_inside_tmux());
6441 }
6442
6443 #[test]
6444 fn tui_glow_background_is_ghostty_base_101216() {
6445 assert_eq!(TUI_GLOW_BACKGROUND_RGB, (16, 18, 22));
6446 assert_eq!(TUI_GLOW_BACKGROUND, Color::Rgb(16, 18, 22));
6447 }
6448
6449 #[test]
6450 fn idle_console_has_external_gutters_uniform_background_and_no_borders() {
6451 let mut state = UiState::from_history(&[], "secret", "model", None, false);
6452 state.input = "prompt".to_owned();
6453 state.cursor = state.input.chars().count();
6454 let area = Rect::new(0, 0, 80, 10);
6455 let mut terminal =
6456 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
6457 .expect("test terminal");
6458
6459 terminal
6460 .draw(|frame| draw(frame, &state))
6461 .expect("draw idle console");
6462
6463 let input_area = ui_layout(&state, tui_viewport(area)).4;
6464 let prompt_area = prompt_area(input_area, &state);
6465 let buffer = terminal.backend().buffer();
6466 let bottom_y = area.y + area.height - 1;
6467 assert_eq!(buffer[(area.x, bottom_y)].bg, Color::Reset);
6468 assert_eq!(buffer[(area.x + area.width - 1, bottom_y)].bg, Color::Reset);
6469 for y in input_area.y..input_area.y + input_area.height {
6470 assert_eq!(buffer[(0, y)].bg, Color::Reset);
6471 assert_eq!(buffer[(79, y)].bg, Color::Reset);
6472 for x in input_area.x..input_area.x + input_area.width {
6473 assert_eq!(buffer[(x, y)].bg, CONSOLE_BACKGROUND);
6474 }
6475 }
6476 assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), " ");
6477 assert_eq!(
6478 buffer[(input_area.x + input_area.width - 1, input_area.y)].symbol(),
6479 " "
6480 );
6481 assert_eq!(
6482 buffer[(input_area.x, input_area.y + input_area.height - 1)].symbol(),
6483 " "
6484 );
6485 assert_eq!(
6486 buffer[(
6487 input_area.x + input_area.width - 1,
6488 input_area.y + input_area.height - 1
6489 )]
6490 .symbol(),
6491 " "
6492 );
6493 assert_eq!(buffer[(prompt_area.x, prompt_area.y)].symbol(), "p");
6494 assert_eq!(buffer[(prompt_area.x, prompt_area.y)].fg, Color::White);
6495 terminal.backend_mut().assert_cursor_position((
6496 prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
6497 prompt_area.y,
6498 ));
6499 }
6500
6501 #[test]
6502 fn slash_picker_is_rendered_immediately_above_the_input() {
6503 let mut state = UiState::from_history(&[], "secret", "model", None, false)
6504 .with_skill_names(skill_names());
6505 state.input = "/".to_owned();
6506 state.input_changed();
6507 let mut terminal =
6508 Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
6509 terminal
6510 .draw(|frame| draw(frame, &state))
6511 .expect("draw TUI");
6512
6513 let buffer = terminal.backend().buffer();
6514 let area = tui_viewport(Rect::new(0, 0, 40, 12));
6515 let (_, picker_area, _, _, input_area, _) = ui_layout(&state, area);
6516 let picker_area = picker_area.expect("picker area");
6517 assert_eq!(picker_area.y + picker_area.height, input_area.y);
6519 for (x, y) in [
6520 (picker_area.x, picker_area.y),
6521 (picker_area.x + picker_area.width - 1, picker_area.y),
6522 (picker_area.x, picker_area.y + picker_area.height - 1),
6523 (
6524 picker_area.x + picker_area.width - 1,
6525 picker_area.y + picker_area.height - 1,
6526 ),
6527 ] {
6528 assert_eq!(buffer[(x, y)].symbol(), " ");
6529 assert_eq!(buffer[(x, y)].bg, SKILL_PICKER_BACKGROUND);
6530 }
6531 assert_eq!(
6532 buffer[(picker_area.x + 1, picker_area.y + 1)].bg,
6533 SKILL_PICKER_BACKGROUND
6534 );
6535 assert_eq!(buffer[(picker_area.x + 2, picker_area.y + 1)].symbol(), "[");
6536 assert_eq!(
6537 buffer[(picker_area.x + 2, picker_area.y + 1)].fg,
6538 QUEUED_MESSAGE_COLOR
6539 );
6540 assert_eq!(buffer[(picker_area.x + 2, picker_area.y + 2)].symbol(), "/");
6541 assert_eq!(
6542 buffer[(picker_area.x + 2, picker_area.y + 2)].fg,
6543 QUEUED_MESSAGE_COLOR
6544 );
6545 assert_eq!(
6546 buffer[(picker_area.x + 1, picker_area.y + picker_area.height - 2)].bg,
6547 SKILL_PICKER_BACKGROUND
6548 );
6549 assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), " ");
6550 assert_eq!(buffer[(input_area.x, input_area.y)].bg, CONSOLE_BACKGROUND);
6551 }
6552
6553 #[test]
6554 fn slash_picker_renders_count_with_bold_focus_on_the_picker_surface() {
6555 let mut state = UiState::from_history(&[], "secret", "model", None, false)
6556 .with_skill_names(skill_names());
6557 state.input = "/".to_owned();
6558 state.input_changed();
6559 let mut terminal =
6560 Terminal::new(ratatui::backend::TestBackend::new(30, 8)).expect("test terminal");
6561 terminal
6562 .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 8)))
6563 .expect("draw skill picker");
6564
6565 let buffer = terminal.backend().buffer();
6566 assert_eq!(buffer[(0, 0)].symbol(), " ");
6567 assert_eq!(buffer[(0, 0)].bg, SKILL_PICKER_BACKGROUND);
6568 assert_eq!(buffer[(2, 1)].symbol(), "[");
6569 assert_eq!(buffer[(2, 1)].fg, QUEUED_MESSAGE_COLOR);
6570 assert_eq!(buffer[(2, 2)].symbol(), "/");
6571 assert_eq!(buffer[(2, 2)].fg, QUEUED_MESSAGE_COLOR);
6572 assert!(buffer[(2, 2)].modifier.contains(Modifier::BOLD));
6573 assert_eq!(buffer[(2, 3)].symbol(), "/");
6574 assert_eq!(buffer[(2, 3)].fg, QUEUED_MESSAGE_COLOR);
6575 assert!(!buffer[(2, 3)].modifier.contains(Modifier::BOLD));
6576 }
6577}
6578
6579#[cfg(test)]
6580mod tmux_keyboard_tests {
6581 use super::*;
6582
6583 #[test]
6584 fn is_inside_tmux_detection() {
6585 std::env::set_var("TERM_PROGRAM", "tmux");
6586 assert!(is_inside_tmux());
6587 std::env::set_var("TERM_PROGRAM", "TMUX");
6588 assert!(is_inside_tmux());
6589 std::env::set_var("TERM_PROGRAM", "ghostty");
6590 assert!(!is_inside_tmux());
6591 std::env::remove_var("TERM_PROGRAM");
6592 assert!(!is_inside_tmux());
6593 }
6594}