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(1200);
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
1579fn bottom_console_area(area: Rect, y: u16, height: u16) -> Rect {
1582 let horizontal_margin = area.width.saturating_sub(1) / 2;
1583 let horizontal_margin = horizontal_margin.min(2);
1584 Rect::new(
1585 area.x.saturating_add(horizontal_margin),
1586 y,
1587 area.width
1588 .saturating_sub(horizontal_margin.saturating_mul(2)),
1589 height,
1590 )
1591}
1592
1593fn ui_prompt_content_width(area: Rect) -> u16 {
1594 prompt_content_width(bottom_console_area(area, area.y, 0).width)
1595}
1596
1597fn console_content_area(input_area: Rect) -> Rect {
1598 let top_padding = input_area.height.min(1);
1599 let bottom_padding = input_area.height.saturating_sub(top_padding).min(1);
1600 Rect::new(
1601 input_area.x.saturating_add(2),
1602 input_area.y.saturating_add(top_padding),
1603 input_area.width.saturating_sub(4),
1604 input_area
1605 .height
1606 .saturating_sub(top_padding + bottom_padding),
1607 )
1608}
1609
1610fn prompt_content_width(input_width: u16) -> u16 {
1611 input_width.saturating_sub(4)
1612}
1613
1614#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1615struct BottomContentHeights {
1616 queue: u16,
1617 queue_separator: u16,
1618 list: u16,
1619 list_separator: u16,
1620 prompt: u16,
1621 status_separator: u16,
1622 status: u16,
1623}
1624
1625fn bottom_content_heights(state: &UiState, input_area: Rect) -> BottomContentHeights {
1629 let mut available = console_content_area(input_area).height;
1630 let status = available.min(1);
1631 available -= status;
1632
1633 let prompt = input_visible_rows(state, prompt_content_width(input_area.width))
1634 .clamp(1, MAX_INPUT_ROWS)
1635 .min(available);
1636 available -= prompt;
1637
1638 let status_separator = u16::from(status > 0 && prompt > 0 && available > 0);
1639 available -= status_separator;
1640
1641 let requested_queue = message_queue_height(state);
1642 let (queue, queue_separator) = if requested_queue > 0 && available >= 3 {
1643 (requested_queue.min(available - 1), 1)
1644 } else {
1645 (0, 0)
1646 };
1647 available -= queue + queue_separator;
1648
1649 let requested_list = 0;
1650 let (list, list_separator) = if requested_list > 0 && available >= 3 {
1651 (requested_list.min(available - 1), 1)
1652 } else {
1653 (0, 0)
1654 };
1655
1656 BottomContentHeights {
1657 queue,
1658 queue_separator,
1659 list,
1660 list_separator,
1661 prompt,
1662 status_separator,
1663 status,
1664 }
1665}
1666
1667fn prompt_area(input_area: Rect, state: &UiState) -> Rect {
1668 let inner = console_content_area(input_area);
1669 let content = bottom_content_heights(state, input_area);
1670 Rect::new(
1671 inner.x,
1672 inner.y + content.queue + content.queue_separator,
1673 inner.width,
1674 content.prompt,
1675 )
1676}
1677
1678fn message_queue_height(state: &UiState) -> u16 {
1679 let messages = state.queued_messages.len().min(u16::MAX as usize - 1) as u16;
1680 u16::from(messages > 0) + messages
1681}
1682
1683fn max_scroll_for_area(state: &UiState, size: Size) -> u16 {
1684 let area = tui_viewport(Rect::new(0, 0, size.width, size.height));
1685 let (chat_chunk, _, _, _, _, _) = ui_layout(state, area);
1686 let chat_height = chat_chunk.height;
1687 let lines = transcript_lines(state, chat_chunk.width);
1688 lines
1689 .len()
1690 .saturating_sub(chat_height as usize)
1691 .min(u16::MAX as usize) as u16
1692}
1693
1694fn input_visible_rows(state: &UiState, width: u16) -> u16 {
1696 let width = width as usize;
1697 if width == 0 {
1698 return 1;
1699 }
1700 let prompt = input_display_text(state);
1701 let wrapped = wrap_text(&prompt, width);
1702 wrapped.len().max(1) as u16
1703}
1704
1705fn input_prompt(input: &str) -> String {
1706 input.to_owned()
1707}
1708
1709fn input_display_text(state: &UiState) -> String {
1710 redact_secret(&input_prompt(&state.input), Some(&state.secret))
1711}
1712
1713fn command_names(mut skill_names: Vec<String>) -> Vec<String> {
1714 skill_names.extend(BUILTIN_COMMANDS.into_iter().map(str::to_owned));
1715 skill_names.sort();
1716 skill_names.dedup();
1717 skill_names
1718}
1719
1720#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1721enum BuiltinCommand {
1722 Settings,
1723 Exit,
1724}
1725
1726impl BuiltinCommand {
1727 fn name(self) -> &'static str {
1728 match self {
1729 Self::Settings => "settings",
1730 Self::Exit => "exit",
1731 }
1732 }
1733}
1734
1735fn builtin_command(input: &str) -> Option<BuiltinCommand> {
1736 match input.split_whitespace().next()? {
1737 "/settings" => Some(BuiltinCommand::Settings),
1738 "/exit" => Some(BuiltinCommand::Exit),
1739 _ => None,
1740 }
1741}
1742
1743fn matching_skill_names<'a>(input: &str, skill_names: &'a [String]) -> Vec<&'a str> {
1746 let Some(query) = input.strip_prefix('/') else {
1747 return Vec::new();
1748 };
1749 if query.chars().any(char::is_whitespace) {
1750 return Vec::new();
1751 }
1752 skill_names
1753 .iter()
1754 .map(String::as_str)
1755 .filter(|name| name.starts_with(query))
1756 .collect()
1757}
1758
1759fn skill_picker_height(state: &UiState) -> u16 {
1760 if state.skill_picker_visible() {
1761 (state
1763 .matching_skill_names()
1764 .len()
1765 .min(SKILL_PICKER_MAX_ROWS)
1766 + 3) as u16
1767 } else {
1768 0
1769 }
1770}
1771
1772fn active_skill_trigger<'a>(input: &'a str, skill_names: &[String]) -> Option<&'a str> {
1776 let invocation = input.strip_prefix('/')?;
1777 let name = invocation
1778 .split_once(char::is_whitespace)
1779 .map_or(invocation, |(name, _)| name);
1780 if name.is_empty() || !skill_names.iter().any(|skill_name| skill_name == name) {
1781 return None;
1782 }
1783 Some(&input[..1 + name.len()])
1784}
1785
1786fn styled_text_lines(
1789 input: &str,
1790 active_skill_trigger: Option<&str>,
1791 width: usize,
1792 text_style: Style,
1793) -> Vec<Line<'static>> {
1794 let trigger_len = active_skill_trigger.map_or(0, |trigger| trigger.chars().count());
1795 let mut char_offset = 0usize;
1796 let mut lines = Vec::new();
1797
1798 for source_line in input.split('\n') {
1799 for row in wrap_line(source_line, width) {
1800 let mut spans = Vec::new();
1801 let mut text = String::new();
1802 let mut highlighted = None;
1803 for character in row.chars() {
1804 let should_highlight = char_offset < trigger_len;
1805 if highlighted != Some(should_highlight) && !text.is_empty() {
1806 spans.push(styled_text_span(
1807 std::mem::take(&mut text),
1808 highlighted.unwrap_or(false),
1809 text_style,
1810 ));
1811 }
1812 highlighted = Some(should_highlight);
1813 text.push(character);
1814 char_offset += 1;
1815 }
1816 if !text.is_empty() {
1817 spans.push(styled_text_span(
1818 text,
1819 highlighted.unwrap_or(false),
1820 text_style,
1821 ));
1822 }
1823 if spans.is_empty() {
1824 spans.push(Span::styled(String::new(), text_style));
1825 }
1826 lines.push(Line::from(spans));
1827 }
1828 char_offset += 1;
1831 }
1832
1833 lines
1834}
1835
1836fn styled_text_span(text: String, highlighted: bool, text_style: Style) -> Span<'static> {
1837 if highlighted {
1838 Span::styled(text, Style::default().fg(SKILL_TRIGGER_COLOR))
1839 } else {
1840 Span::styled(text, text_style)
1841 }
1842}
1843
1844#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1845struct InputVisualRow {
1846 start: usize,
1847 end: usize,
1848}
1849
1850fn input_visual_rows(input: &str, width: usize) -> Vec<InputVisualRow> {
1851 let width = width.max(1);
1852 let characters = input.chars().collect::<Vec<_>>();
1853 let mut rows = Vec::new();
1854 let mut start = 0;
1855 let mut row_width = 0;
1856
1857 for (index, character) in characters.iter().enumerate() {
1858 if *character == '\n' {
1859 rows.push(InputVisualRow { start, end: index });
1860 start = index + 1;
1861 row_width = 0;
1862 continue;
1863 }
1864
1865 let character_width = unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0);
1866 if row_width + character_width > width && index > start {
1867 rows.push(InputVisualRow { start, end: index });
1868 start = index;
1869 row_width = 0;
1870 }
1871 row_width += character_width;
1872 }
1873
1874 rows.push(InputVisualRow {
1875 start,
1876 end: characters.len(),
1877 });
1878 rows
1879}
1880
1881fn input_cursor_row(input: &str, cursor: usize, width: usize) -> usize {
1882 let rows = input_visual_rows(input, width);
1883 let cursor = cursor.min(input.chars().count());
1884 for (index, row) in rows.iter().enumerate() {
1885 if cursor < row.end {
1886 return index;
1887 }
1888 if cursor == row.end && rows.get(index + 1).is_none_or(|next| next.start != cursor) {
1889 return index;
1890 }
1891 }
1892 rows.len().saturating_sub(1)
1893}
1894
1895fn cursor_row(input: &str, cursor: usize, width: usize) -> u16 {
1896 input_cursor_row(input, cursor, width).min(u16::MAX as usize) as u16
1897}
1898
1899fn move_up_from_input(state: &mut UiState, width: usize) -> bool {
1900 state.move_skill_picker(false) || move_input_cursor_vertical(state, width, false)
1901}
1902
1903fn move_down_from_input(state: &mut UiState, width: usize) -> bool {
1904 let width = width.max(1);
1905 state.move_skill_picker(true) || move_input_cursor_vertical(state, width, true)
1906}
1907
1908fn move_input_cursor_vertical(state: &mut UiState, width: usize, down: bool) -> bool {
1909 let width = width.max(1);
1910 let rows = input_visual_rows(&state.input, width);
1911 let current_row = input_cursor_row(&state.input, state.cursor, width);
1912 let target_row = if down {
1913 current_row + 1
1914 } else {
1915 current_row.saturating_sub(1)
1916 };
1917 if target_row == current_row || target_row >= rows.len() {
1918 return false;
1919 }
1920
1921 let characters = state.input.chars().collect::<Vec<_>>();
1922 let current = rows[current_row];
1923 let cursor = state.cursor.min(current.end);
1924 let desired_column = characters[current.start..cursor]
1925 .iter()
1926 .map(|character| unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0))
1927 .sum::<usize>();
1928 let target = rows[target_row];
1929 let mut column = 0;
1930 let mut target_cursor = target.end;
1931 for (index, character) in characters
1932 .iter()
1933 .enumerate()
1934 .take(target.end)
1935 .skip(target.start)
1936 {
1937 let character_width = unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0);
1938 if column + character_width > desired_column {
1939 target_cursor = index;
1940 break;
1941 }
1942 column += character_width;
1943 if column >= desired_column {
1944 target_cursor = index + 1;
1945 break;
1946 }
1947 }
1948 state.cursor = target_cursor;
1949 true
1950}
1951
1952fn insert_at_cursor(input: &mut String, cursor: &mut usize, character: char) {
1953 let byte_index = input
1954 .char_indices()
1955 .nth(*cursor)
1956 .map_or(input.len(), |(index, _)| index);
1957 input.insert(byte_index, character);
1958 *cursor += 1;
1959}
1960
1961fn remove_before_cursor(input: &mut String, cursor: &mut usize) -> bool {
1962 if *cursor == 0 {
1963 return false;
1964 }
1965 let end = input
1966 .char_indices()
1967 .nth(*cursor)
1968 .map_or(input.len(), |(index, _)| index);
1969 let start = input
1970 .char_indices()
1971 .nth(*cursor - 1)
1972 .map(|(index, _)| index)
1973 .unwrap_or(0);
1974 input.replace_range(start..end, "");
1975 *cursor -= 1;
1976 true
1977}
1978
1979fn draw(frame: &mut Frame<'_>, state: &UiState) {
1980 let full_area = frame.area();
1981 frame.render_widget(Clear, full_area);
1984 let area = tui_viewport(full_area);
1985 let (chat_chunk, picker_area, _, queue_area, input_chunk, status_area) = ui_layout(state, area);
1986
1987 let visible_chat_area = chat_chunk;
1990
1991 let width = chat_chunk.width;
1992 let welcome_image_layout = if state.welcome_visible && greeting_image_enabled() {
1993 let welcome_lines = welcome_lines(&state.attached_agents);
1994 welcome_image_layout(visible_chat_area, welcome_lines.len() as u16)
1995 } else {
1996 None
1997 };
1998 if state.welcome_visible {
1999 let welcome_lines = welcome_lines(&state.attached_agents);
2000 if let Some(layout) = welcome_image_layout {
2001 let welcome = Paragraph::new(welcome_lines).alignment(Alignment::Center);
2002 frame.render_widget(welcome, layout.intro_area);
2003 } else {
2004 let logo = logo_lines();
2005 let logo_gap = 2u16;
2006 let total_height = logo.len() as u16 + logo_gap + welcome_lines.len() as u16;
2007 let lines = if total_height <= visible_chat_area.height {
2010 let mut all = logo;
2011 all.push(Line::raw(""));
2012 all.push(Line::raw(""));
2013 all.extend(welcome_lines);
2014 all
2015 } else {
2016 welcome_lines
2017 };
2018 let welcome_height = (lines.len() as u16).min(visible_chat_area.height);
2019 let welcome_area = Rect::new(
2020 visible_chat_area.x,
2021 visible_chat_area.y + visible_chat_area.height.saturating_sub(welcome_height) / 2,
2022 visible_chat_area.width,
2023 welcome_height,
2024 );
2025 let welcome = Paragraph::new(lines).alignment(Alignment::Center);
2026 frame.render_widget(welcome, welcome_area);
2027 }
2028 } else {
2029 let lines = transcript_lines(state, width);
2030 let available = visible_chat_area.height as usize;
2031 let max_scroll = lines.len().saturating_sub(available).min(u16::MAX as usize) as u16;
2032 let scroll = if state.auto_scroll {
2033 max_scroll
2034 } else {
2035 state.scroll.min(max_scroll)
2036 };
2037 let transcript = Paragraph::new(lines).scroll((scroll, 0));
2038 frame.render_widget(transcript, visible_chat_area);
2039 }
2040
2041 let activity_now = Instant::now();
2042 let activity_elapsed = state.console_animation_elapsed_at(activity_now);
2043 let console_visibility = state.console_visibility_at(activity_now);
2044 apply_tui_glow(
2045 frame,
2046 full_area,
2047 input_chunk,
2048 activity_elapsed,
2049 console_visibility,
2050 );
2051 if chat_chunk.y.saturating_add(chat_chunk.height) < input_chunk.y {
2054 apply_console_top_reflection(frame, input_chunk, activity_elapsed, console_visibility);
2055 }
2056 if let Some(layout) = welcome_image_layout {
2057 let image = welcome_image(layout.image_size);
2058 frame.render_widget(TuiImage::new(image.as_ref()), layout.image_area);
2059 }
2060 if let Some(picker_area) = picker_area {
2061 draw_skill_picker(frame, state, picker_area);
2062 }
2063
2064 if let Some(queue_area) = queue_area {
2065 draw_message_queue(frame, state, queue_area);
2066 }
2067
2068 let input_text_style = Style::default().fg(Color::White);
2069 let prompt_area = prompt_area(input_chunk, state);
2070 let prompt = input_display_text(state);
2071 let input_rows = input_visible_rows(state, prompt_area.width).clamp(1, MAX_INPUT_ROWS);
2072 let wrapped = wrap_text(&prompt, prompt_area.width.max(1) as usize);
2073 let visible = (wrapped.len() as u16)
2074 .clamp(1, input_rows)
2075 .min(prompt_area.height);
2076 let cursor_row = cursor_row(&prompt, state.cursor, prompt_area.width.max(1) as usize);
2077 let bottom_scroll = (wrapped.len() as u16).saturating_sub(visible);
2078 let cursor_scroll = (cursor_row + 1).saturating_sub(visible);
2079 let input_scroll = cursor_scroll.min(bottom_scroll);
2080 let active_skill_trigger = (!state.busy)
2081 .then(|| active_skill_trigger(&prompt, &state.skill_names))
2082 .flatten();
2083 let input_lines = styled_text_lines(
2084 &prompt,
2085 active_skill_trigger,
2086 prompt_area.width.max(1) as usize,
2087 input_text_style,
2088 );
2089 let input = Paragraph::new(input_lines)
2090 .style(input_text_style)
2091 .scroll((input_scroll, 0));
2092 frame.render_widget(input, prompt_area);
2093
2094 let effort = state.effort.as_deref().unwrap_or("default");
2095 frame.render_widget(
2096 Paragraph::new(model_status_line(state, effort)),
2097 status_area,
2098 );
2099
2100 apply_console_background(frame, input_chunk, activity_elapsed, console_visibility);
2101 if let Some(settings) = &state.settings {
2102 draw_settings(frame, settings, area);
2103 }
2104
2105 if state.terminal_focused && state.settings.is_none() && !prompt_area.is_empty() && visible > 0
2108 {
2109 let cursor_prefix: String = prompt.chars().take(state.cursor).collect();
2110 let cursor_rows = wrap_text(&cursor_prefix, prompt_area.width.max(1) as usize);
2111 let cursor_line = cursor_rows.last().map(String::as_str).unwrap_or("");
2112 let cursor_offset = UnicodeWidthStr::width(cursor_line) as u16;
2113 let cursor_x = prompt_area.x + cursor_offset.min(prompt_area.width.saturating_sub(1));
2114 let cursor_y = prompt_area.y
2115 + cursor_row
2116 .saturating_sub(input_scroll)
2117 .min(prompt_area.height.saturating_sub(1));
2118 frame.set_cursor_position((cursor_x, cursor_y));
2119 }
2120}
2121
2122fn draw_message_queue(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
2123 if area.is_empty() || state.queued_messages.is_empty() {
2124 return;
2125 }
2126
2127 let chrome = Style::default().fg(SECTION_CHROME_COLOR);
2128 let message = Style::default().fg(QUEUED_MESSAGE_COLOR);
2129 let mut lines = vec![Line::styled("Queued", chrome)];
2130 lines.extend(
2131 state
2132 .queued_messages
2133 .iter()
2134 .take(area.height.saturating_sub(1) as usize)
2135 .enumerate()
2136 .map(|(index, queued)| {
2137 Line::from(vec![
2138 Span::styled("│ ", chrome),
2139 Span::styled(
2140 format!("{}) {}", index + 1, single_line_preview(queued)),
2141 message,
2142 ),
2143 ])
2144 }),
2145 );
2146 frame.render_widget(Paragraph::new(lines), area);
2147}
2148
2149fn single_line_preview(text: &str) -> String {
2150 truncate_output(&text.replace(['\n', '\r'], " ↵ "))
2151}
2152
2153enum SettingsState {
2154 Loading,
2155 Applying {
2156 model: String,
2157 effort: Option<String>,
2158 },
2159 Error(String),
2160 Models {
2161 models: Vec<ProviderModel>,
2162 query: String,
2163 focus: usize,
2164 },
2165 Effort {
2166 model: ProviderModel,
2167 input: String,
2168 focus: usize,
2169 },
2170}
2171fn draw_settings(frame: &mut Frame<'_>, settings: &SettingsState, area: Rect) {
2172 let width = area
2173 .width
2174 .saturating_sub(2)
2175 .min(SETTINGS_MAX_WIDTH)
2176 .max(SETTINGS_MIN_WIDTH.min(area.width));
2177 let height = area
2178 .height
2179 .saturating_sub(2)
2180 .min(SETTINGS_MAX_HEIGHT)
2181 .max(SETTINGS_MIN_HEIGHT.min(area.height));
2182 let popup = Rect::new(
2183 area.x + area.width.saturating_sub(width) / 2,
2184 area.y + area.height.saturating_sub(height) / 2,
2185 width,
2186 height,
2187 );
2188 frame.render_widget(Clear, popup);
2189 let block = Block::default()
2190 .title(" /settings ")
2191 .borders(Borders::ALL)
2192 .border_style(Style::default().fg(Color::Cyan));
2193 let inner = block.inner(popup);
2194 frame.render_widget(block, popup);
2195
2196 let lines = match settings {
2197 SettingsState::Loading => vec![
2198 Line::styled("Loading provider models…", Style::default().fg(Color::Cyan)),
2199 Line::raw(""),
2200 Line::styled("Esc cancel", Style::default().fg(Color::DarkGray)),
2201 ],
2202 SettingsState::Applying { model, effort } => vec![
2203 Line::styled("Applying selection…", Style::default().fg(Color::Cyan)),
2204 Line::raw(model.clone()),
2205 Line::raw(format!(
2206 "effort: {}",
2207 effort.as_deref().unwrap_or("default")
2208 )),
2209 ],
2210 SettingsState::Error(error) => vec![
2211 Line::styled("Unable to update settings", Style::default().fg(Color::Red)),
2212 Line::raw(""),
2213 Line::raw(error.clone()),
2214 Line::raw(""),
2215 Line::styled("Enter/Esc close", Style::default().fg(Color::DarkGray)),
2216 ],
2217 SettingsState::Models {
2218 models,
2219 query,
2220 focus,
2221 } => {
2222 let query_lower = query.to_lowercase();
2223 let filtered = models
2224 .iter()
2225 .filter(|model| model.id.to_lowercase().contains(&query_lower))
2226 .collect::<Vec<_>>();
2227 let focus = (*focus).min(filtered.len().saturating_sub(1));
2228 let list_rows = inner.height.saturating_sub(4) as usize;
2229 let range = selection_range(filtered.len(), focus, list_rows);
2230 let mut lines = vec![
2231 Line::from(vec![
2232 Span::styled("Model ", Style::default().fg(Color::DarkGray)),
2233 Span::styled(
2234 if query.is_empty() {
2235 "type to filter…"
2236 } else {
2237 query
2238 },
2239 Style::default().fg(if query.is_empty() {
2240 Color::DarkGray
2241 } else {
2242 Color::White
2243 }),
2244 ),
2245 ]),
2246 Line::styled(
2247 format!(
2248 "{} models{}",
2249 filtered.len(),
2250 if filtered.is_empty() {
2251 ""
2252 } else {
2253 " · ↑/↓ move · Enter choose"
2254 }
2255 ),
2256 Style::default().fg(Color::DarkGray),
2257 ),
2258 ];
2259 if filtered.is_empty() {
2260 lines.push(Line::styled(
2261 "No matching models",
2262 Style::default().fg(Color::Yellow),
2263 ));
2264 } else {
2265 for index in range {
2266 let selected = index == focus;
2267 lines.push(Line::styled(
2268 format!(
2269 "{} {}",
2270 if selected { "›" } else { " " },
2271 filtered[index].id
2272 ),
2273 if selected {
2274 Style::default().fg(Color::Black).bg(Color::Cyan)
2275 } else {
2276 Style::default().fg(Color::White)
2277 },
2278 ));
2279 }
2280 }
2281 lines.push(Line::styled(
2282 "Esc cancel",
2283 Style::default().fg(Color::DarkGray),
2284 ));
2285 lines
2286 }
2287 SettingsState::Effort {
2288 model,
2289 input,
2290 focus,
2291 } => {
2292 let mut lines = vec![
2293 Line::styled(model.id.clone(), Style::default().fg(Color::Cyan)),
2294 Line::styled("Reasoning effort", Style::default().fg(Color::DarkGray)),
2295 ];
2296 match &model.efforts {
2297 Some(efforts) => {
2298 let total = efforts.len() + 1;
2299 let focus = (*focus).min(total.saturating_sub(1));
2300 let list_rows = inner.height.saturating_sub(4) as usize;
2301 for index in selection_range(total, focus, list_rows) {
2302 let value = if index == 0 {
2303 "default"
2304 } else {
2305 efforts[index - 1].as_str()
2306 };
2307 let selected = index == focus;
2308 lines.push(Line::styled(
2309 format!("{} {value}", if selected { "›" } else { " " }),
2310 if selected {
2311 Style::default().fg(Color::Black).bg(Color::Cyan)
2312 } else {
2313 Style::default().fg(Color::White)
2314 },
2315 ));
2316 }
2317 lines.push(Line::styled(
2318 "↑/↓ move · Enter save · Esc cancel",
2319 Style::default().fg(Color::DarkGray),
2320 ));
2321 }
2322 None => {
2323 lines.push(Line::raw("Provider did not advertise allowed efforts."));
2324 lines.push(Line::from(vec![
2325 Span::styled("Value ", Style::default().fg(Color::DarkGray)),
2326 Span::styled(
2327 if input.is_empty() { "default" } else { input },
2328 Style::default().fg(Color::White),
2329 ),
2330 ]));
2331 lines.push(Line::styled(
2332 "Type a value · Enter save · Esc cancel",
2333 Style::default().fg(Color::DarkGray),
2334 ));
2335 }
2336 }
2337 lines
2338 }
2339 };
2340 frame.render_widget(Paragraph::new(lines), inner);
2341}
2342
2343fn selection_range(total: usize, focus: usize, max_rows: usize) -> std::ops::Range<usize> {
2344 if total == 0 || max_rows == 0 {
2345 return 0..0;
2346 }
2347 let focus = focus.min(total - 1);
2348 let visible = total.min(max_rows);
2349 let start = focus
2350 .saturating_add(1)
2351 .saturating_sub(visible)
2352 .min(total - visible);
2353 start..start + visible
2354}
2355
2356fn draw_skill_picker(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
2357 let matches = state.matching_skill_names();
2358 let total = matches.len();
2359 if total == 0 || area.is_empty() {
2360 return;
2361 }
2362
2363 frame.render_widget(Clear, area);
2366 let inner = Rect::new(
2367 area.x.saturating_add(2),
2368 area.y.saturating_add(1),
2369 area.width.saturating_sub(4),
2370 area.height.saturating_sub(2),
2371 );
2372 let buffer = frame.buffer_mut();
2373 for y in area.y..area.y.saturating_add(area.height) {
2374 for x in area.x..area.x.saturating_add(area.width) {
2375 buffer[(x, y)].set_bg(SKILL_PICKER_BACKGROUND);
2376 }
2377 }
2378 if inner.is_empty() {
2379 return;
2380 }
2381
2382 let focus = state.skill_picker_focus.min(total - 1);
2383 let header = Line::styled(
2384 format!("[{}/{}]", focus + 1, total),
2385 Style::default().fg(QUEUED_MESSAGE_COLOR),
2386 );
2387 frame.render_widget(
2388 Paragraph::new(header),
2389 Rect::new(inner.x, inner.y, inner.width, 1),
2390 );
2391
2392 let item_rows = inner.height.saturating_sub(1) as usize;
2393 for (row, index) in selection_range(total, focus, item_rows).enumerate() {
2394 let mut style = Style::default().fg(QUEUED_MESSAGE_COLOR);
2395 if index == focus {
2396 style = style.add_modifier(Modifier::BOLD);
2397 }
2398 let skill = Line::styled(format!("/{}", matches[index]), style);
2399 frame.render_widget(
2400 Paragraph::new(skill),
2401 Rect::new(inner.x, inner.y + 1 + row as u16, inner.width, 1),
2402 );
2403 }
2404}
2405
2406fn greeting_image_enabled() -> bool {
2407 std::env::var("LUCY_GREETING_IMAGE").as_deref() == Ok("true")
2408}
2409
2410#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2411struct WelcomeImageLayout {
2412 image_area: Rect,
2413 intro_area: Rect,
2414 image_size: Size,
2415}
2416
2417fn welcome_image_layout(area: Rect, intro_height: u16) -> Option<WelcomeImageLayout> {
2418 let available_height = area
2419 .height
2420 .saturating_sub(intro_height.saturating_add(WELCOME_IMAGE_GAP));
2421 let max_width = area.width.min(GREETING_IMAGE_SIZE.width);
2422 let max_height = available_height.min(GREETING_IMAGE_SIZE.height);
2423 let aspect_width = GREETING_IMAGE_SIZE.width / GREETING_IMAGE_SIZE.height;
2424 let image_height = max_height.min(max_width / aspect_width);
2425 let image_size = Size::new(image_height * aspect_width, image_height);
2426 if image_size.width < GREETING_IMAGE_MIN_SIZE.width
2427 || image_size.height < GREETING_IMAGE_MIN_SIZE.height
2428 {
2429 return None;
2430 }
2431
2432 let group_height = image_size.height + WELCOME_IMAGE_GAP + intro_height;
2433 let group_y = area.y + area.height.saturating_sub(group_height) / 2;
2434 Some(WelcomeImageLayout {
2435 image_area: Rect::new(
2436 area.x + (area.width - image_size.width) / 2,
2437 group_y,
2438 image_size.width,
2439 image_size.height,
2440 ),
2441 intro_area: Rect::new(
2442 area.x,
2443 group_y + image_size.height + WELCOME_IMAGE_GAP,
2444 area.width,
2445 intro_height,
2446 ),
2447 image_size,
2448 })
2449}
2450
2451type WelcomeImageCache = Mutex<HashMap<(u16, u16), Arc<Protocol>>>;
2452
2453fn welcome_image(size: Size) -> Arc<Protocol> {
2454 static IMAGES: OnceLock<WelcomeImageCache> = OnceLock::new();
2455 let images = IMAGES.get_or_init(|| Mutex::new(HashMap::new()));
2456 let mut images = images
2457 .lock()
2458 .expect("welcome image cache should not be poisoned");
2459 images
2460 .entry((size.width, size.height))
2461 .or_insert_with(|| {
2462 let image = image::load_from_memory(GREETING_IMAGE_BYTES)
2463 .expect("embedded greeting PNG should decode");
2464 let image = dim_welcome_image(image);
2465 Arc::new(
2466 Picker::halfblocks()
2467 .new_protocol(image, size, Resize::Fit(None))
2468 .expect("embedded greeting PNG should convert to halfblocks"),
2469 )
2470 })
2471 .clone()
2472}
2473
2474fn dim_welcome_image(image: image::DynamicImage) -> image::DynamicImage {
2475 let mut image = image.to_rgba8();
2476 for pixel in image.pixels_mut() {
2477 for channel in pixel.0.iter_mut().take(3) {
2478 *channel = (u16::from(*channel) * WELCOME_IMAGE_BRIGHTNESS_PERCENT / 100) as u8;
2479 }
2480 }
2481 image::DynamicImage::ImageRgba8(image)
2482}
2483
2484fn logo_lines() -> Vec<Line<'static>> {
2485 let max_width = LOGO_TEXT
2486 .lines()
2487 .map(|line| line.chars().count())
2488 .max()
2489 .unwrap_or(0);
2490 LOGO_TEXT
2491 .lines()
2492 .map(|line| {
2493 let spans: Vec<Span> = line
2494 .chars()
2495 .enumerate()
2496 .map(|(index, character)| {
2497 let progress = if max_width <= 1 {
2498 0.0
2499 } else {
2500 index as f32 / (max_width - 1) as f32
2501 };
2502 let color = Color::Rgb(
2503 interpolate_color(LOGO_START_COLOR.0, LOGO_END_COLOR.0, progress),
2504 interpolate_color(LOGO_START_COLOR.1, LOGO_END_COLOR.1, progress),
2505 interpolate_color(LOGO_START_COLOR.2, LOGO_END_COLOR.2, progress),
2506 );
2507 Span::styled(character.to_string(), Style::default().fg(color))
2508 })
2509 .collect();
2510 Line::from(spans)
2511 })
2512 .collect()
2513}
2514
2515fn welcome_line() -> Line<'static> {
2516 let character_count = WELCOME_MESSAGE.chars().count();
2517 let spans = WELCOME_MESSAGE
2518 .chars()
2519 .enumerate()
2520 .map(|(index, character)| {
2521 let progress = if character_count <= 1 {
2522 0.0
2523 } else {
2524 index as f32 / (character_count - 1) as f32
2525 };
2526 let color = Color::Rgb(
2527 interpolate_color(WELCOME_START_COLOR.0, WELCOME_END_COLOR.0, progress),
2528 interpolate_color(WELCOME_START_COLOR.1, WELCOME_END_COLOR.1, progress),
2529 interpolate_color(WELCOME_START_COLOR.2, WELCOME_END_COLOR.2, progress),
2530 );
2531 Span::styled(character.to_string(), Style::default().fg(color))
2532 })
2533 .collect::<Vec<_>>();
2534 Line::from(spans)
2535}
2536
2537fn interpolate_color(start: u8, end: u8, progress: f32) -> u8 {
2538 (start as f32 + (end as f32 - start as f32) * progress).round() as u8
2539}
2540
2541fn welcome_lines(attached_agents: &[String]) -> Vec<Line<'static>> {
2542 let mut lines = vec![
2543 welcome_line(),
2544 Line::styled(WELCOME_VERSION, Style::default().fg(Color::DarkGray)),
2545 Line::raw(""),
2546 Line::styled(WELCOME_TAGLINE, Style::default().fg(Color::DarkGray)),
2547 Line::raw(""),
2548 ];
2549
2550 if attached_agents.is_empty() {
2551 lines.push(Line::styled(
2552 "Attached AGENTS.md: none",
2553 Style::default().fg(Color::DarkGray),
2554 ));
2555 } else {
2556 lines.push(Line::styled(
2557 "Attached AGENTS.md:",
2558 Style::default().fg(Color::DarkGray),
2559 ));
2560 lines.extend(
2561 attached_agents.iter().map(|path| {
2562 Line::styled(format!("• {path}"), Style::default().fg(Color::DarkGray))
2563 }),
2564 );
2565 }
2566
2567 lines
2568}
2569
2570fn transcript_lines(state: &UiState, width: u16) -> Vec<Line<'static>> {
2571 render_transcript_items(&state.transcript, width.max(1) as usize, state)
2572}
2573
2574fn render_transcript_items(
2575 transcript: &[TranscriptItem],
2576 width: usize,
2577 state: &UiState,
2578) -> Vec<Line<'static>> {
2579 let mut lines = Vec::new();
2580 let mut rendered_item = false;
2581
2582 for (index, item) in transcript.iter().enumerate() {
2583 if is_result_attached_to_call(transcript, index) {
2586 continue;
2587 }
2588 if rendered_item {
2589 lines.push(Line::raw(String::new()));
2590 }
2591 match item {
2592 TranscriptItem::User {
2593 text,
2594 skill_instruction_attached,
2595 } => {
2596 let text = redact_secret(text, Some(&state.secret));
2597 let trigger = skill_instruction_attached
2598 .then(|| active_skill_trigger(&text, &state.skill_names))
2599 .flatten();
2600 push_user_message_block(&mut lines, &text, trigger, width);
2601 }
2602 TranscriptItem::Assistant(text) => {
2603 let text = redact_secret(text, Some(&state.secret));
2604 push_wrapped(&mut lines, &text, width, Style::default());
2605 }
2606 TranscriptItem::ToolCall {
2607 id,
2608 name,
2609 arguments,
2610 } => {
2611 let result = matching_tool_result(transcript, index, id);
2612 let segments = if name == "cmd" {
2613 cmd_tool_segments(id, arguments, result, state)
2614 } else {
2615 generic_tool_segments(name, arguments, result, state)
2616 };
2617 push_spans_wrapped(&mut lines, &segments, width);
2618 }
2619 TranscriptItem::ToolResult {
2620 id: _,
2621 name: _,
2622 result,
2623 } => {
2624 let result_text = format_tool_result(result);
2625 let result_text = redact_secret(&result_text, Some(&state.secret));
2626 push_spans_wrapped(&mut lines, &[(result_text, tool_result_style())], width);
2627 }
2628 TranscriptItem::Error(text) => {
2629 let text = redact_secret(text, Some(&state.secret));
2630 push_wrapped(&mut lines, &text, width, error_style());
2631 }
2632 TranscriptItem::Info(text) => {
2633 let text = redact_secret(text, Some(&state.secret));
2634 push_wrapped(&mut lines, &text, width, info_style());
2635 }
2636 TranscriptItem::Reasoning { complete } => {
2637 let text = if *complete {
2638 "Reasoning Complete".to_owned()
2639 } else {
2640 format!("Reasoning... {}", spinner_frame(state))
2641 };
2642 push_wrapped(&mut lines, &text, width, thinking_style());
2643 }
2644 }
2645 rendered_item = true;
2646 }
2647 if lines.is_empty() {
2648 lines.push(Line::raw(""));
2649 }
2650 lines
2651}
2652
2653fn running_tool_status(state: &UiState) -> String {
2655 tool_spinner_frame(state)
2656}
2657
2658fn cmd_tool_segments(
2659 call_id: &str,
2660 arguments: &str,
2661 result: Option<&Value>,
2662 state: &UiState,
2663) -> Vec<(String, Style)> {
2664 let command = redact_secret(&command_display(arguments), Some(&state.secret));
2665 if let Some(result) = result {
2666 let (icon, status, status_style) = cmd_result_status(result);
2667 if status == "done" || state.cmd_result_started_at.contains_key(call_id) {
2668 let text = if status == "done" {
2669 format!("{icon} cmd $ {command}")
2670 } else {
2671 format!("{icon} cmd $ {command} → {status}")
2672 };
2673 return cmd_result_segments(call_id, &text, cmd_result_target_color(result), state);
2674 }
2675 vec![
2676 (format!("{icon} cmd $ {command} → "), status_style),
2677 (status, status_style),
2678 ]
2679 } else {
2680 vec![
2681 (format!("· cmd $ {command} "), pending_tool_call_style()),
2682 (running_tool_status(state), pending_tool_call_style()),
2683 ]
2684 }
2685}
2686
2687fn cmd_result_segments(
2692 call_id: &str,
2693 text: &str,
2694 target: Color,
2695 state: &UiState,
2696) -> Vec<(String, Style)> {
2697 let now = Instant::now();
2698 let Some(started_at) = state.cmd_result_started_at.get(call_id).copied() else {
2699 return vec![(text.to_owned(), Style::default().fg(target))];
2700 };
2701 if now.saturating_duration_since(started_at) >= TOOL_RESULT_SWEEP_DURATION {
2702 return vec![(text.to_owned(), Style::default().fg(target))];
2703 }
2704
2705 let character_count = text.chars().count();
2706 text.chars()
2707 .enumerate()
2708 .map(|(index, character)| {
2709 (
2710 character.to_string(),
2711 Style::default().fg(cmd_result_color_at(
2712 started_at,
2713 now,
2714 index,
2715 character_count,
2716 target,
2717 )),
2718 )
2719 })
2720 .collect()
2721}
2722
2723fn cmd_result_color_at(
2724 started_at: Instant,
2725 now: Instant,
2726 character_index: usize,
2727 character_count: usize,
2728 target: Color,
2729) -> Color {
2730 let elapsed = now.saturating_duration_since(started_at);
2731 if elapsed >= TOOL_RESULT_SWEEP_DURATION {
2732 return target;
2733 }
2734
2735 let progress = elapsed.as_secs_f32() / TOOL_RESULT_SWEEP_DURATION.as_secs_f32();
2736 let character_position = if character_count <= 1 {
2737 0.0
2738 } else {
2739 character_index as f32 / (character_count - 1) as f32
2740 };
2741 let fade_start = character_position * (1.0 - TOOL_RESULT_CHARACTER_FADE_PORTION);
2742 let character_progress =
2743 ((progress - fade_start) / TOOL_RESULT_CHARACTER_FADE_PORTION).clamp(0.0, 1.0);
2744 let character_progress =
2745 character_progress * character_progress * (3.0 - 2.0 * character_progress);
2746 let (target_red, target_green, target_blue) = tool_result_color_rgb(target);
2747 Color::Rgb(
2748 interpolate_color(PENDING_TOOL_COLOR_RGB.0, target_red, character_progress),
2749 interpolate_color(PENDING_TOOL_COLOR_RGB.1, target_green, character_progress),
2750 interpolate_color(PENDING_TOOL_COLOR_RGB.2, target_blue, character_progress),
2751 )
2752}
2753
2754fn command_display(arguments: &str) -> String {
2755 serde_json::from_str::<Value>(arguments)
2756 .ok()
2757 .and_then(|value| {
2758 value
2759 .get("command")
2760 .and_then(Value::as_str)
2761 .map(str::to_owned)
2762 })
2763 .map(|command| truncate_tool_call(&command))
2764 .unwrap_or_else(|| truncate_tool_call(arguments))
2765}
2766
2767fn cmd_result_target_color(result: &Value) -> Color {
2768 if result
2769 .get("canceled")
2770 .and_then(Value::as_bool)
2771 .unwrap_or(false)
2772 || result
2773 .get("timed_out")
2774 .and_then(Value::as_bool)
2775 .unwrap_or(false)
2776 {
2777 return TOOL_WARNING_COLOR;
2778 }
2779 if result.get("error").is_some()
2780 || matches!(result.get("exit_code").and_then(Value::as_i64), Some(code) if code != 0)
2781 {
2782 return TOOL_FAILURE_COLOR;
2783 }
2784 TOOL_SUCCESS_COLOR
2785}
2786
2787fn tool_result_color_rgb(color: Color) -> (u8, u8, u8) {
2788 let Color::Rgb(red, green, blue) = color else {
2789 unreachable!("cmd result transition colours are RGB")
2790 };
2791 (red, green, blue)
2792}
2793
2794fn cmd_result_status(result: &Value) -> (char, String, Style) {
2795 let target = cmd_result_target_color(result);
2796 if result
2797 .get("canceled")
2798 .and_then(Value::as_bool)
2799 .unwrap_or(false)
2800 {
2801 return ('!', "canceled".to_owned(), Style::default().fg(target));
2802 }
2803 if result
2804 .get("timed_out")
2805 .and_then(Value::as_bool)
2806 .unwrap_or(false)
2807 {
2808 return ('!', "timeout".to_owned(), Style::default().fg(target));
2809 }
2810 if result.get("error").is_some() {
2811 return ('×', "error".to_owned(), Style::default().fg(target));
2812 }
2813 match result.get("exit_code").and_then(Value::as_i64) {
2814 Some(0) => ('✓', "done".to_owned(), Style::default().fg(target)),
2815 Some(code) => ('×', format!("exit {code}"), Style::default().fg(target)),
2816 None => ('✓', "done".to_owned(), Style::default().fg(target)),
2817 }
2818}
2819
2820fn generic_tool_segments(
2821 name: &str,
2822 arguments: &str,
2823 result: Option<&Value>,
2824 state: &UiState,
2825) -> Vec<(String, Style)> {
2826 let call_text = redact_secret(
2827 &format!("[tool:{name} {}]", call_arguments(arguments)),
2828 Some(&state.secret),
2829 );
2830 let mut segments = vec![(
2831 call_text,
2832 if result.is_some() {
2833 tool_call_style()
2834 } else {
2835 pending_tool_call_style()
2836 },
2837 )];
2838 if let Some(result) = result {
2839 let result_text = redact_secret(&format_tool_result(result), Some(&state.secret));
2840 segments.push((" > ".to_owned(), Style::default()));
2841 segments.push((result_text, tool_result_style()));
2842 } else {
2843 segments.push((
2844 format!(" {}", tool_spinner_frame(state)),
2845 pending_tool_call_style(),
2846 ));
2847 }
2848 segments
2849}
2850
2851fn matching_tool_result<'a>(
2852 transcript: &'a [TranscriptItem],
2853 call_index: usize,
2854 call_id: &str,
2855) -> Option<&'a Value> {
2856 transcript
2857 .iter()
2858 .skip(call_index + 1)
2859 .find_map(|item| match item {
2860 TranscriptItem::ToolResult { id, result, .. } if id == call_id => Some(result),
2861 _ => None,
2862 })
2863}
2864
2865fn is_result_attached_to_call(transcript: &[TranscriptItem], result_index: usize) -> bool {
2866 let TranscriptItem::ToolResult { id, .. } = &transcript[result_index] else {
2867 return false;
2868 };
2869 let Some(call_index) = transcript[..result_index].iter().rposition(
2870 |item| matches!(item, TranscriptItem::ToolCall { id: call_id, .. } if call_id == id),
2871 ) else {
2872 return false;
2873 };
2874 !transcript[call_index + 1..result_index].iter().any(
2875 |item| matches!(item, TranscriptItem::ToolResult { id: result_id, .. } if result_id == id),
2876 )
2877}
2878
2879const TOOL_CALL_PREVIEW_CHARS: usize = 100;
2880
2881fn truncate_tool_call(output: &str) -> String {
2882 let mut result: String = output.chars().take(TOOL_CALL_PREVIEW_CHARS).collect();
2883 if output.chars().count() > TOOL_CALL_PREVIEW_CHARS {
2884 result.push('…');
2885 }
2886 result
2887}
2888
2889fn call_arguments(arguments: &str) -> String {
2893 let parsed: Value = match serde_json::from_str(arguments) {
2894 Ok(value) => value,
2895 Err(_) => return truncate_tool_call(arguments),
2896 };
2897 if let Some(command) = parsed.get("command").and_then(Value::as_str) {
2898 return format!("\"{}\"", truncate_tool_call(command));
2899 }
2900 let serialized = serde_json::to_string(&parsed).unwrap_or_else(|_| arguments.to_owned());
2901 truncate_tool_call(&serialized)
2902}
2903
2904fn format_tool_result(result: &Value) -> String {
2908 let stdout = result.get("stdout").and_then(Value::as_str).unwrap_or("");
2909 let stderr = result.get("stderr").and_then(Value::as_str).unwrap_or("");
2910 let output = if !stdout.is_empty() { stdout } else { stderr };
2911 let truncated = truncate_output(output);
2912 let json_string = serde_json::to_string(&truncated).unwrap_or_else(|_| "\"\"".to_owned());
2915 format!("[{json_string}]")
2916}
2917
2918const RESULT_PREVIEW_CHARS: usize = 50;
2919
2920fn truncate_output(output: &str) -> String {
2921 let mut result: String = output.chars().take(RESULT_PREVIEW_CHARS).collect();
2922 if output.chars().count() > RESULT_PREVIEW_CHARS {
2923 result.push('…');
2924 }
2925 result
2926}
2927
2928fn user_message_style() -> Style {
2929 Style::default().fg(USER_BORDER_COLOR)
2930}
2931
2932fn push_user_message_block(
2935 lines: &mut Vec<Line<'static>>,
2936 text: &str,
2937 active_skill_trigger: Option<&str>,
2938 width: usize,
2939) {
2940 if width < 3 {
2941 lines.extend(styled_text_lines(
2942 text,
2943 active_skill_trigger,
2944 width.max(1),
2945 Style::default().fg(Color::White),
2946 ));
2947 return;
2948 }
2949
2950 let border_style = user_message_style();
2951 let rows = styled_text_lines(
2952 text,
2953 active_skill_trigger,
2954 width - 2,
2955 Style::default().fg(Color::White),
2956 );
2957 lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
2958 for row in rows {
2959 let mut spans = Vec::with_capacity(row.spans.len() + 2);
2960 spans.push(Span::styled(USER_BORDER_GLYPH, border_style));
2961 spans.push(Span::styled(" ", Style::default().fg(Color::White)));
2962 spans.extend(row.spans);
2963 lines.push(Line::from(spans));
2964 }
2965 lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
2966}
2967
2968fn tool_call_style() -> Style {
2969 Style::default().fg(Color::Magenta)
2970}
2971
2972fn pending_tool_call_style() -> Style {
2973 Style::default().fg(PENDING_TOOL_COLOR)
2974}
2975
2976fn tool_result_style() -> Style {
2977 Style::default().fg(Color::DarkGray)
2978}
2979
2980fn error_style() -> Style {
2981 Style::default().fg(Color::Red)
2982}
2983
2984fn info_style() -> Style {
2985 Style::default().fg(Color::DarkGray)
2986}
2987
2988fn context_status_text(state: &UiState) -> String {
2989 let used = format_context_tokens(state.context_tokens);
2990 let Some(window) = state.context_window else {
2991 return format!("Context: {used}/? (?%) ??????????");
2992 };
2993 let percentage = context_percentage(state.context_tokens, window);
2994 format!(
2995 "Context: {used}/{} ({percentage}%) {}",
2996 format_context_tokens(window),
2997 context_progress_bar(state.context_tokens, window)
2998 )
2999}
3000
3001fn context_progress_bar(used: usize, window: usize) -> String {
3002 const WIDTH: usize = 10;
3003 let filled = if window == 0 {
3004 0
3005 } else {
3006 (used as u128 * WIDTH as u128)
3007 .div_ceil(window as u128)
3008 .min(WIDTH as u128) as usize
3009 };
3010 format!("{}{}", "█".repeat(filled), "░".repeat(WIDTH - filled))
3011}
3012
3013fn context_status_style(_state: &UiState) -> Style {
3014 Style::default().fg(CONSOLE_STATUS_COLOR)
3015}
3016
3017fn context_percentage(used: usize, window: usize) -> usize {
3018 if window == 0 {
3019 return 0;
3020 }
3021 ((used as u128 * 100).div_ceil(window as u128)) as usize
3022}
3023
3024fn format_context_tokens(tokens: usize) -> String {
3025 if tokens >= 1_000_000 {
3026 format!("{:.2}M", tokens as f64 / 1_000_000.0)
3027 } else if tokens >= 1_000 {
3028 format!("{:.1}K", tokens as f64 / 1_000.0)
3029 } else {
3030 tokens.to_string()
3031 }
3032}
3033
3034fn model_status_line(state: &UiState, effort: &str) -> Line<'static> {
3035 let model = redact_secret(&state.model, Some(&state.secret));
3036 let effort = redact_secret(effort, Some(&state.secret));
3037 Line::styled(
3038 format!("{model} · {effort} | {}", context_status_text(state)),
3039 context_status_style(state),
3040 )
3041}
3042
3043fn blend_rgb(from: Color, to: Color, progress: f32) -> Color {
3044 let (from_red, from_green, from_blue) = activity_rgb(from);
3045 let (to_red, to_green, to_blue) = activity_rgb(to);
3046 Color::Rgb(
3047 interpolate_color(from_red, to_red, progress),
3048 interpolate_color(from_green, to_green, progress),
3049 interpolate_color(from_blue, to_blue, progress),
3050 )
3051}
3052
3053fn activity_rgb(color: Color) -> (u8, u8, u8) {
3054 match color {
3055 Color::Rgb(red, green, blue) => (red, green, blue),
3056 Color::Cyan => (0, 255, 255),
3059 _ => unreachable!("activity transition colours are cyan or RGB"),
3060 }
3061}
3062
3063fn console_reach_at(elapsed: Duration) -> f32 {
3064 let phase = elapsed.as_secs_f32() / CONSOLE_BOUNDARY_CYCLE.as_secs_f32();
3065 let midpoint = (CONSOLE_REACH_MIN + CONSOLE_REACH_MAX) / 2.0;
3066 let amplitude = (CONSOLE_REACH_MAX - CONSOLE_REACH_MIN) / 2.0;
3067 midpoint + amplitude * (phase * std::f32::consts::TAU).sin()
3068}
3069
3070fn console_accent_cycle() -> Duration {
3071 CONSOLE_ACCENT_CYCLE_DURATION
3072}
3073
3074fn console_accent_at(elapsed: Duration) -> Color {
3075 let cycle_progress =
3076 (elapsed.as_secs_f32() / console_accent_cycle().as_secs_f32()).rem_euclid(1.0);
3077 let progress = if cycle_progress <= 0.5 {
3078 cycle_progress * 2.0
3079 } else {
3080 (1.0 - cycle_progress) * 2.0
3081 };
3082 desaturate_console_accent(
3083 interpolate_color(CONSOLE_ACCENT_LAVENDER.0, CONSOLE_ACCENT_TEAL.0, progress),
3084 interpolate_color(CONSOLE_ACCENT_LAVENDER.1, CONSOLE_ACCENT_TEAL.1, progress),
3085 interpolate_color(CONSOLE_ACCENT_LAVENDER.2, CONSOLE_ACCENT_TEAL.2, progress),
3086 )
3087}
3088
3089fn desaturate_console_accent(red: u8, green: u8, blue: u8) -> Color {
3090 let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
3091 Color::Rgb(
3092 interpolate_color(red, neutral, CONSOLE_ACCENT_DESATURATION),
3093 interpolate_color(green, neutral, CONSOLE_ACCENT_DESATURATION),
3094 interpolate_color(blue, neutral, CONSOLE_ACCENT_DESATURATION),
3095 )
3096}
3097
3098fn glow_coverage_at(column: u16, row: u16, canvas: Rect, console_area: Rect) -> f32 {
3105 let canvas_bottom = canvas.y.saturating_add(canvas.height);
3106 if canvas.is_empty() || row < canvas.y || row >= canvas_bottom {
3107 return 0.0;
3108 }
3109
3110 let left = console_area.x.max(canvas.x);
3111 let right = console_area
3112 .x
3113 .saturating_add(console_area.width)
3114 .min(canvas.x.saturating_add(canvas.width));
3115 if left >= right {
3116 return 0.0;
3117 }
3118
3119 let x = canvas.x.saturating_add(column);
3120 let center_x = (left as f32 + right.saturating_sub(1) as f32) / 2.0;
3121 let horizontal_radius = (right - left) as f32 / 2.0 + GLOW_HORIZONTAL_SPREAD as f32;
3122 let horizontal_distance = (x as f32 - center_x).abs() / horizontal_radius;
3123 let vertical_distance =
3126 (row.saturating_add(1) as f32 - canvas_bottom as f32).abs() / GLOW_HEIGHT as f32;
3127 let distance = horizontal_distance.hypot(vertical_distance);
3128 let falloff = (1.0 - distance).clamp(0.0, 1.0);
3129 falloff * falloff * (3.0 - 2.0 * falloff)
3130}
3131
3132fn glow_accent_at(elapsed: Duration) -> Color {
3133 glow_accent_with_desaturation_at(elapsed, GLOW_DESATURATION)
3134}
3135
3136fn glow_accent_with_desaturation_at(elapsed: Duration, desaturation: f32) -> Color {
3137 let (red, green, blue) = activity_rgb(console_accent_at(elapsed));
3138 let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
3139 Color::Rgb(
3140 interpolate_color(red, neutral, desaturation),
3141 interpolate_color(green, neutral, desaturation),
3142 interpolate_color(blue, neutral, desaturation),
3143 )
3144}
3145
3146fn glow_color_at(
3147 elapsed: Duration,
3148 column: u16,
3149 _width: u16,
3150 row: u16,
3151 canvas: Rect,
3152 console_area: Rect,
3153 visibility: f32,
3154) -> Option<Color> {
3155 let visibility = visibility.clamp(0.0, 1.0);
3156 let bottom = canvas.y.saturating_add(canvas.height);
3157 if visibility <= 0.0 || row < canvas.y || row >= bottom {
3158 return None;
3159 }
3160
3161 let coverage = glow_coverage_at(column, row, canvas, console_area);
3162 if coverage <= 0.0 {
3163 return None;
3164 }
3165
3166 let intensity =
3167 (console_reach_at(elapsed) / CONSOLE_REACH_MAX) * GLOW_INTENSITY * coverage * visibility;
3168 Some(blend_rgb(
3169 TUI_GLOW_BACKGROUND,
3170 glow_accent_at(elapsed),
3171 intensity,
3172 ))
3173}
3174
3175fn apply_tui_glow(
3176 frame: &mut Frame<'_>,
3177 canvas: Rect,
3178 console_area: Rect,
3179 elapsed: Duration,
3180 visibility: f32,
3181) {
3182 let left = console_area
3183 .x
3184 .saturating_sub(GLOW_HORIZONTAL_SPREAD)
3185 .max(canvas.x);
3186 let right = console_area
3187 .x
3188 .saturating_add(console_area.width)
3189 .saturating_add(GLOW_HORIZONTAL_SPREAD)
3190 .min(canvas.x.saturating_add(canvas.width));
3191 let bottom = canvas.y.saturating_add(canvas.height);
3192 let top = bottom.saturating_sub(GLOW_HEIGHT).max(canvas.y);
3193 let buffer = frame.buffer_mut();
3194 for y in top..bottom {
3195 for x in left..right {
3196 if let Some(color) = glow_color_at(
3197 elapsed,
3198 x.saturating_sub(canvas.x),
3199 canvas.width,
3200 y,
3201 canvas,
3202 console_area,
3203 visibility,
3204 ) {
3205 buffer[(x, y)].set_bg(color);
3206 }
3207 }
3208 }
3209}
3210
3211fn console_glass_color_at(elapsed: Duration, glow: Color, visibility: f32) -> Color {
3212 let (red, green, blue) = activity_rgb(console_accent_at(elapsed));
3213 let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
3214 let glass_accent = Color::Rgb(
3215 interpolate_color(red, neutral, CONSOLE_GLASS_DESATURATION),
3216 interpolate_color(green, neutral, CONSOLE_GLASS_DESATURATION),
3217 interpolate_color(blue, neutral, CONSOLE_GLASS_DESATURATION),
3218 );
3219 let visibility = visibility.clamp(0.0, 1.0);
3220 let tint = blend_rgb(
3221 CONSOLE_BACKGROUND,
3222 glass_accent,
3223 CONSOLE_GLASS_TINT * visibility,
3224 );
3225 let white_tinted = blend_rgb(
3226 tint,
3227 Color::Rgb(255, 255, 255),
3228 CONSOLE_GLASS_WHITE_TINT * visibility,
3229 );
3230 blend_rgb(white_tinted, glow, CONSOLE_GLASS_GLOW_THROUGH * visibility)
3231}
3232
3233fn console_top_reflection_color_at(elapsed: Duration, background: Color, visibility: f32) -> Color {
3235 let visibility = visibility.clamp(0.0, 1.0);
3236 let reflected = blend_rgb(
3237 background,
3238 glow_accent_at(elapsed),
3239 CONSOLE_REFLECTION_TINT * visibility,
3240 );
3241 blend_rgb(
3242 reflected,
3243 Color::Rgb(255, 255, 255),
3244 CONSOLE_REFLECTION_WHITE_TINT * visibility,
3245 )
3246}
3247
3248fn apply_console_top_reflection(
3252 frame: &mut Frame<'_>,
3253 area: Rect,
3254 elapsed: Duration,
3255 visibility: f32,
3256) {
3257 if visibility <= 0.0 || area.y == 0 {
3258 return;
3259 }
3260
3261 let y = area.y - 1;
3262 let buffer = frame.buffer_mut();
3263 for x in area.x..area.x.saturating_add(area.width) {
3264 let background = match buffer[(x, y)].bg {
3265 Color::Rgb(_, _, _) => buffer[(x, y)].bg,
3266 _ => TUI_GLOW_BACKGROUND,
3267 };
3268 buffer[(x, y)].set_symbol(CONSOLE_REFLECTION_GLYPH).set_fg(
3269 console_top_reflection_color_at(elapsed, background, visibility),
3270 );
3271 }
3272}
3273
3274fn apply_console_background(frame: &mut Frame<'_>, area: Rect, elapsed: Duration, visibility: f32) {
3276 let buffer = frame.buffer_mut();
3277 for y in area.y..area.y.saturating_add(area.height) {
3278 for x in area.x..area.x.saturating_add(area.width) {
3279 let glow = match buffer[(x, y)].bg {
3280 Color::Rgb(_, _, _) => buffer[(x, y)].bg,
3281 _ => TUI_GLOW_BACKGROUND,
3282 };
3283 buffer[(x, y)].set_bg(console_glass_color_at(elapsed, glow, visibility));
3284 }
3285 }
3286}
3287
3288fn thinking_style() -> Style {
3289 Style::default().fg(Color::DarkGray)
3290}
3291
3292const PULSE_LEVELS: [char; 7] = ['▁', '▂', '▃', '▅', '▆', '▇', '█'];
3295const PULSE_BAR_PERIODS: [u128; 5] = [12, 16, 20, 24, 15];
3296const PULSE_BAR_PHASES: [u128; 5] = [0, 5, 13, 9, 3];
3297const PULSE_TICK: Duration = Duration::from_millis(50);
3298const TOOL_SPINNER_FRAMES: [char; 4] = ['|', '/', '-', '\\'];
3299const TOOL_SPINNER_FRAME_DURATION: Duration = Duration::from_millis(100);
3300
3301const ACTIVITY_TRANSITION_DURATION: Duration = Duration::from_millis(400);
3305const PULSE_ENTRY_FRAME: Duration = Duration::from_millis(950);
3308
3309fn spinner_frame(state: &UiState) -> String {
3310 pulse_frame(state.activity_levels_at(Instant::now()))
3311}
3312
3313#[cfg(test)]
3314fn spinner_frame_at(elapsed: Duration) -> String {
3315 pulse_frame(pulse_levels_at(elapsed))
3316}
3317
3318fn tool_spinner_frame(state: &UiState) -> String {
3322 tool_spinner_frame_at(state.tool_animation_epoch.elapsed()).to_string()
3323}
3324
3325fn tool_spinner_frame_at(elapsed: Duration) -> char {
3326 let frame = (elapsed.as_millis() / TOOL_SPINNER_FRAME_DURATION.as_millis()) as usize;
3327 TOOL_SPINNER_FRAMES[frame % TOOL_SPINNER_FRAMES.len()]
3328}
3329
3330fn pulse_frame(levels: [usize; PULSE_BAR_PERIODS.len()]) -> String {
3331 levels
3332 .into_iter()
3333 .map(|level| PULSE_LEVELS[level])
3334 .collect()
3335}
3336
3337fn pulse_levels_at(elapsed: Duration) -> [usize; PULSE_BAR_PERIODS.len()] {
3338 let tick = elapsed.as_millis() / PULSE_TICK.as_millis();
3339 std::array::from_fn(|index| {
3340 pulse_level_at(tick, PULSE_BAR_PERIODS[index], PULSE_BAR_PHASES[index])
3341 })
3342}
3343
3344fn interpolate_pulse_levels(
3345 from: [usize; PULSE_BAR_PERIODS.len()],
3346 to: [usize; PULSE_BAR_PERIODS.len()],
3347 elapsed: Duration,
3348) -> [usize; PULSE_BAR_PERIODS.len()] {
3349 let elapsed = elapsed.min(ACTIVITY_TRANSITION_DURATION).as_millis();
3350 let duration = ACTIVITY_TRANSITION_DURATION.as_millis();
3351 std::array::from_fn(|index| {
3352 let start = from[index] as i128;
3353 let distance = to[index] as i128 - start;
3354 (start + distance * elapsed as i128 / duration as i128) as usize
3355 })
3356}
3357
3358fn pulse_level_at(tick: u128, period: u128, phase: u128) -> usize {
3359 let position = (tick + phase) % period;
3360 let half_period = period / 2;
3361 let distance_from_floor = if position <= half_period {
3362 position
3363 } else {
3364 period - position
3365 };
3366 (distance_from_floor * (PULSE_LEVELS.len() - 1) as u128 / half_period) as usize
3367}
3368
3369fn push_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, style: Style) {
3370 let mut added = false;
3371 for piece in wrap_text(text, width) {
3372 lines.push(Line::styled(piece, style));
3373 added = true;
3374 }
3375 if !added {
3376 lines.push(Line::styled(String::new(), style));
3377 }
3378}
3379
3380fn push_spans_wrapped(lines: &mut Vec<Line<'static>>, segments: &[(String, Style)], width: usize) {
3384 let mut current_spans: Vec<Span<'static>> = Vec::new();
3385 let mut current_width = 0usize;
3386 for (text, style) in segments {
3387 for character in text.chars() {
3388 let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
3389 if current_width + char_width > width && !current_spans.is_empty() {
3390 lines.push(Line::from(std::mem::take(&mut current_spans)));
3391 current_width = 0;
3392 }
3393 let mut buffer = [0u8; 4];
3394 let s = character.encode_utf8(&mut buffer);
3395 current_spans.push(Span::styled(s.to_owned(), *style));
3396 current_width += char_width;
3397 }
3398 }
3399 if current_spans.is_empty() {
3400 current_spans.push(Span::raw(String::new()));
3401 }
3402 lines.push(Line::from(current_spans));
3403}
3404
3405fn wrap_text(text: &str, width: usize) -> Vec<String> {
3411 if width == 0 {
3412 return text.lines().map(str::to_owned).collect();
3413 }
3414 let mut rows = Vec::new();
3415 for line in text.split('\n') {
3418 rows.extend(wrap_line(line, width));
3419 }
3420 if rows.is_empty() {
3421 rows.push(String::new());
3422 }
3423 rows
3424}
3425
3426fn wrap_line(line: &str, width: usize) -> Vec<String> {
3427 let mut rows = Vec::new();
3428 let mut current = String::new();
3429 let mut current_width = 0usize;
3430 for character in line.chars() {
3431 let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
3432 if current_width + char_width > width && !current.is_empty() {
3433 rows.push(std::mem::take(&mut current));
3434 current_width = 0;
3435 }
3436 current.push(character);
3437 current_width += char_width;
3438 }
3439 rows.push(current);
3440 rows
3441}
3442
3443#[cfg(test)]
3444mod tests {
3445 use super::*;
3446
3447 #[test]
3448 fn turn_notifications_use_osc_777_with_fixed_secret_safe_messages() {
3449 let cases = [
3450 (TurnNotification::Completed, "Turn complete"),
3451 (TurnNotification::Interrupted, "Turn interrupted"),
3452 (TurnNotification::Failed, "Turn failed"),
3453 ];
3454
3455 for (notification, body) in cases {
3456 let mut output = Vec::new();
3457 send_turn_notification(&mut output, notification).expect("notification");
3458 assert_eq!(
3459 output,
3460 format!("\x1b]777;notify;Lucy;{body}\x07").into_bytes()
3461 );
3462 }
3463 }
3464
3465 #[test]
3466 fn turn_notifications_follow_the_terminal_turn_status() {
3467 assert_eq!(
3468 turn_notification_for_status("finalizing"),
3469 TurnNotification::Completed
3470 );
3471 assert_eq!(
3472 turn_notification_for_status("cancelling"),
3473 TurnNotification::Interrupted
3474 );
3475 assert_eq!(
3476 turn_notification_for_status("error"),
3477 TurnNotification::Failed
3478 );
3479 }
3480
3481 struct FailingWriter;
3482
3483 impl Write for FailingWriter {
3484 fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
3485 Err(io::Error::other("notification sink unavailable"))
3486 }
3487
3488 fn flush(&mut self) -> io::Result<()> {
3489 Err(io::Error::other("notification sink unavailable"))
3490 }
3491 }
3492
3493 #[test]
3494 fn notification_write_failure_does_not_keep_the_tui_busy() {
3495 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3496 state.busy = true;
3497 state.active_cancel = Some(CancellationToken::new());
3498 let mut writer = FailingWriter;
3499
3500 release_finished_turn(&mut writer, &mut state);
3501
3502 assert!(!state.busy);
3503 assert!(state.active_cancel.is_none());
3504 }
3505
3506 #[test]
3507 fn an_idle_finish_does_not_emit_a_duplicate_notification() {
3508 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3509 let mut output = Vec::new();
3510
3511 release_finished_turn(&mut output, &mut state);
3512
3513 assert!(output.is_empty());
3514 }
3515
3516 #[test]
3517 fn context_status_shows_used_window_and_percentage_in_uniform_gray() {
3518 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3519 .with_context(Some(100_000), 80_000);
3520
3521 assert_eq!(
3522 context_status_text(&state),
3523 "Context: 80.0K/100.0K (80%) ████████░░"
3524 );
3525 assert_eq!(
3526 context_status_style(&state).fg,
3527 Some(Color::Rgb(144, 144, 148))
3528 );
3529
3530 state.context_tokens = 80_001;
3531 assert_eq!(
3532 context_status_text(&state),
3533 "Context: 80.0K/100.0K (81%) █████████░"
3534 );
3535 assert_eq!(
3536 context_status_style(&state).fg,
3537 Some(Color::Rgb(144, 144, 148)),
3538 "crossing the compaction threshold does not recolor the status line"
3539 );
3540 }
3541
3542 #[test]
3543 fn context_status_keeps_percentage_and_bar_consistent_at_capacity() {
3544 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3545 .with_context(Some(100_000), 99_001);
3546
3547 assert_eq!(
3548 context_status_text(&state),
3549 "Context: 99.0K/100.0K (100%) ██████████"
3550 );
3551
3552 state.context_tokens = 100_000;
3553 assert_eq!(
3554 context_status_text(&state),
3555 "Context: 100.0K/100.0K (100%) ██████████"
3556 );
3557
3558 state.context_tokens = 100_001;
3559 assert_eq!(
3560 context_status_text(&state),
3561 "Context: 100.0K/100.0K (101%) ██████████"
3562 );
3563 }
3564
3565 #[test]
3566 fn context_status_handles_unknown_window_without_highlighting() {
3567 let state = UiState::from_history(&[], "secret", "model", None, false);
3568
3569 assert_eq!(context_status_text(&state), "Context: 1/? (?%) ??????????");
3570 assert_eq!(
3571 context_status_style(&state).fg,
3572 Some(Color::Rgb(144, 144, 148))
3573 );
3574 }
3575
3576 #[test]
3577 fn tui_viewport_reserves_one_column_on_each_side_when_possible() {
3578 assert_eq!(
3579 tui_viewport(Rect::new(0, 0, 80, 10)),
3580 Rect::new(1, 0, 78, 10)
3581 );
3582 assert_eq!(
3583 tui_viewport(Rect::new(0, 0, 2, 10)),
3584 Rect::new(0, 0, 2, 10),
3585 "a two-column terminal cannot reserve two gutters"
3586 );
3587 }
3588
3589 #[test]
3590 fn tui_viewport_caps_at_one_hundred_columns_and_centers_it() {
3591 assert_eq!(
3592 tui_viewport(Rect::new(0, 0, 140, 10)),
3593 Rect::new(20, 0, TUI_MAX_WIDTH, 10)
3594 );
3595 assert_eq!(
3596 tui_viewport(Rect::new(0, 0, 103, 10)),
3597 Rect::new(1, 0, TUI_MAX_WIDTH, 10),
3598 "an odd remaining column stays on the right"
3599 );
3600 }
3601
3602 #[test]
3603 fn bottom_console_has_external_margins_without_losing_internal_padding() {
3604 let state = UiState::from_history(&[], "secret", "model", None, false);
3605 let viewport = tui_viewport(Rect::new(0, 0, 80, 14));
3606 let (chat, _, _, _, console, _) = ui_layout(&state, viewport);
3607 let content = console_content_area(console);
3608
3609 assert_eq!(chat.x, console.x);
3610 assert_eq!(chat.width, console.width);
3611 assert_eq!(console, Rect::new(viewport.x + 2, 8, viewport.width - 4, 5));
3612 assert_eq!(console.y + console.height, viewport.y + viewport.height - 1);
3613 assert_eq!(content.x, console.x + 2);
3614 assert_eq!(content.width, console.width - 4);
3615 assert_eq!(content.y, console.y + 1);
3616 assert_eq!(content.y + content.height, console.y + console.height - 1);
3617
3618 for (width, margin, console_width) in
3619 [(1, 0, 1), (2, 0, 2), (3, 1, 1), (4, 1, 2), (5, 2, 1)]
3620 {
3621 let console = bottom_console_area(Rect::new(0, 0, width, 4), 0, 4);
3622 assert_eq!(console.x, margin, "width {width}");
3623 assert_eq!(console.width, console_width, "width {width}");
3624 }
3625 }
3626
3627 #[test]
3628 fn inset_console_width_drives_prompt_rows_and_vertical_navigation() {
3629 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3630 state.input = "x".repeat(71);
3631 let viewport = tui_viewport(Rect::new(0, 0, 80, 14));
3632 let console = ui_layout(&state, viewport).4;
3633 let prompt = prompt_area(console, &state);
3634
3635 assert_eq!(ui_prompt_content_width(viewport), prompt.width);
3636 assert_eq!(prompt.width, 70);
3637 assert_eq!(input_visible_rows(&state, prompt.width), 2);
3638 assert!(move_input_cursor_vertical(
3639 &mut state,
3640 ui_prompt_content_width(viewport) as usize,
3641 true,
3642 ));
3643 }
3644
3645 #[test]
3646 fn context_immediately_follows_the_left_status_flow_in_uniform_gray() {
3647 let state =
3648 UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
3649 let mut terminal =
3650 Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
3651
3652 terminal
3653 .draw(|frame| draw(frame, &state))
3654 .expect("draw statusline");
3655
3656 let buffer = terminal.backend().buffer();
3657 let status_area = ui_layout(&state, tui_viewport(Rect::new(0, 0, 80, 10))).5;
3658 let expected = "model · default | Context: 81/100 (81%) █████████░";
3659 let rendered = (status_area.x..status_area.x + expected.chars().count() as u16)
3660 .map(|x| buffer[(x, status_area.y)].symbol())
3661 .collect::<String>();
3662 assert_eq!(rendered, expected);
3663 assert_eq!(
3664 buffer[(
3665 status_area.x + expected.chars().count() as u16,
3666 status_area.y
3667 )]
3668 .symbol(),
3669 " ",
3670 "context is not pushed to the right edge"
3671 );
3672 for x in status_area.x..status_area.x + expected.chars().count() as u16 {
3673 assert_eq!(buffer[(x, status_area.y)].fg, CONSOLE_STATUS_COLOR);
3674 }
3675 }
3676
3677 #[test]
3678 fn pulse_spinner_moves_each_bar_one_level_at_a_time() {
3679 let frames = (0..=240)
3680 .map(|tick| spinner_frame_at(PULSE_TICK * tick))
3681 .collect::<Vec<_>>();
3682 assert!(frames.iter().any(|frame| frame != &frames[0]));
3683 assert_eq!(PULSE_TICK, Duration::from_millis(50));
3684
3685 for pair in frames.windows(2) {
3686 let levels = pair
3687 .iter()
3688 .map(|frame| {
3689 frame
3690 .chars()
3691 .map(|bar| {
3692 PULSE_LEVELS
3693 .iter()
3694 .position(|level| *level == bar)
3695 .expect("known pulse level")
3696 })
3697 .collect::<Vec<_>>()
3698 })
3699 .collect::<Vec<_>>();
3700 assert_eq!(levels[0].len(), 5);
3701 assert!(
3702 levels[0]
3703 .iter()
3704 .zip(&levels[1])
3705 .all(|(before, after)| before.abs_diff(*after) <= 1),
3706 "pulse bars must not jump between adjacent ticks: {:?} -> {:?}",
3707 pair[0],
3708 pair[1]
3709 );
3710 }
3711 }
3712
3713 #[test]
3714 fn console_animation_clock_runs_during_entry_and_survives_active_status_changes() {
3715 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3716 state.set_status("working");
3717 let epoch = state.console_animation_epoch;
3718 assert_eq!(
3719 state.console_animation_elapsed_at(epoch + Duration::from_millis(200)),
3720 Duration::from_millis(200),
3721 "the console animation does not freeze during the activity ramp"
3722 );
3723
3724 state.set_status("compacting");
3725 assert_eq!(state.console_animation_epoch, epoch);
3726 state.set_status("working");
3727 assert_eq!(state.console_animation_epoch, epoch);
3728 }
3729
3730 fn color_distance(from: Color, to: Color) -> u16 {
3731 let (from_red, from_green, from_blue) = activity_rgb(from);
3732 let (to_red, to_green, to_blue) = activity_rgb(to);
3733 u16::from(from_red.abs_diff(to_red))
3734 + u16::from(from_green.abs_diff(to_green))
3735 + u16::from(from_blue.abs_diff(to_blue))
3736 }
3737
3738 fn color_saturation(color: Color) -> u8 {
3739 let (red, green, blue) = activity_rgb(color);
3740 red.max(green).max(blue) - red.min(green).min(blue)
3741 }
3742
3743 fn color_luminance(color: Color) -> u32 {
3744 let (red, green, blue) = activity_rgb(color);
3745 299 * u32::from(red) + 587 * u32::from(green) + 114 * u32::from(blue)
3746 }
3747
3748 #[test]
3749 fn glow_coverage_is_a_bottom_anchored_half_ellipse_with_a_twelve_row_cap() {
3750 let canvas = Rect::new(0, 0, 160, 40);
3751 let console = Rect::new(40, 28, 80, 7);
3752 let bottom = canvas.y + canvas.height;
3753 let center_x = console.x + console.width / 2 - 1;
3754 let outer_x = center_x + 35;
3755 let active_rows = (canvas.y..bottom)
3756 .filter(|&row| glow_coverage_at(center_x, row, canvas, console) > 0.0)
3757 .collect::<Vec<_>>();
3758
3759 assert_eq!(GLOW_HEIGHT, 12);
3760 assert_eq!(GLOW_HORIZONTAL_SPREAD, 24);
3761 assert_eq!(
3762 active_rows,
3763 (bottom - GLOW_HEIGHT..bottom).collect::<Vec<_>>()
3764 );
3765 assert_eq!(
3766 glow_coverage_at(center_x, bottom - GLOW_HEIGHT - 1, canvas, console),
3767 0.0,
3768 "the glow never exceeds its twelve-row cap"
3769 );
3770 assert_eq!(
3771 glow_coverage_at(center_x, bottom - 1, canvas, console),
3772 glow_coverage_at(center_x + 1, bottom - 1, canvas, console),
3773 "the bottom edge intersects the ellipse at its horizontal centreline"
3774 );
3775 assert_eq!(
3776 glow_coverage_at(outer_x, bottom - GLOW_HEIGHT, canvas, console),
3777 0.0,
3778 "the top of the half ellipse stays narrow"
3779 );
3780 assert!(
3781 glow_coverage_at(outer_x, bottom - 1, canvas, console) > 0.0,
3782 "the exposed glow grows wider toward the TUI bottom"
3783 );
3784 assert!(
3785 glow_coverage_at(console.x + console.width + 23, bottom - 1, canvas, console) > 0.0,
3786 "the glow extends twenty-four cells beyond each console edge"
3787 );
3788 }
3789
3790 #[test]
3791 fn wider_console_has_a_wider_elliptical_side_falloff() {
3792 let canvas = Rect::new(0, 0, 200, 20);
3793 let wide_console = Rect::new(50, 12, 100, 7);
3794 let narrow_console = Rect::new(50, 12, 4, 7);
3795 let bottom_row = canvas.y + canvas.height - 1;
3796 let offset_from_center = 60;
3797 let wide_sample = wide_console.x + wide_console.width / 2 + offset_from_center;
3798 let narrow_sample = narrow_console.x + narrow_console.width / 2 + offset_from_center;
3799
3800 let wide = glow_coverage_at(wide_sample, bottom_row, canvas, wide_console);
3801 let narrow = glow_coverage_at(narrow_sample, bottom_row, canvas, narrow_console);
3802
3803 assert!(wide > 0.0);
3804 assert_eq!(narrow, 0.0);
3805 assert!(
3806 wide > narrow,
3807 "the horizontal radius follows the console width to form an ellipse rather than fixed endpoint circles"
3808 );
3809 }
3810
3811 #[test]
3812 fn exposed_bloom_is_not_tinted_as_console_glass() {
3813 let canvas = Rect::new(0, 0, 80, 20);
3814 let console = Rect::new(20, 12, 40, 7);
3815 let elapsed = CONSOLE_BOUNDARY_CYCLE / 4;
3816 let source_row = canvas.y + canvas.height - 1;
3817 let exposed = (console.x - 1, source_row);
3818 let inside = (console.x, console.y + console.height - 1);
3819 let exposed_glow = glow_color_at(
3820 elapsed,
3821 exposed.0,
3822 canvas.width,
3823 exposed.1,
3824 canvas,
3825 console,
3826 1.0,
3827 )
3828 .expect("endpoint bloom");
3829 let inside_glow = glow_color_at(
3830 elapsed,
3831 inside.0,
3832 canvas.width,
3833 inside.1,
3834 canvas,
3835 console,
3836 1.0,
3837 )
3838 .expect("source segment glow");
3839 let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
3840 canvas.width,
3841 canvas.height,
3842 ))
3843 .expect("test terminal");
3844
3845 terminal
3846 .draw(|frame| {
3847 apply_tui_glow(frame, canvas, console, elapsed, 1.0);
3848 apply_console_background(frame, console, elapsed, 1.0);
3849 let buffer = frame.buffer_mut();
3850 assert_eq!(buffer[exposed].bg, exposed_glow);
3851 assert_eq!(
3852 buffer[inside].bg,
3853 console_glass_color_at(elapsed, inside_glow, 1.0)
3854 );
3855 })
3856 .expect("render glow and glass");
3857 }
3858
3859 #[test]
3860 fn idle_canvas_has_no_glow() {
3861 let canvas = Rect::new(0, 0, 80, 12);
3862 let console = Rect::new(2, 6, 76, 5);
3863 for row in canvas.y..canvas.y + canvas.height {
3864 for column in 0..canvas.width {
3865 assert_eq!(
3866 glow_color_at(
3867 CONSOLE_BOUNDARY_CYCLE / 4,
3868 column,
3869 canvas.width,
3870 row,
3871 canvas,
3872 console,
3873 0.0,
3874 ),
3875 None,
3876 "idle glow never paints the canvas"
3877 );
3878 }
3879 }
3880 }
3881
3882 #[test]
3883 fn console_visibility_transition_is_smooth_and_reversible() {
3884 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3885 state.set_busy(true);
3886 let entering = state
3887 .console_visibility_transition
3888 .expect("entry transition");
3889 assert_eq!(state.console_visibility_at(entering.started_at), 0.0);
3890 let entry_middle =
3891 state.console_visibility_at(entering.started_at + CONSOLE_VISIBILITY_TRANSITION / 2);
3892 assert!((entry_middle - 0.5).abs() < 0.000_1);
3893 assert_eq!(
3894 state.console_visibility_at(entering.started_at + CONSOLE_VISIBILITY_TRANSITION),
3895 1.0
3896 );
3897
3898 state.console_visibility_transition = None;
3899 state.set_busy(false);
3900 let exiting = state
3901 .console_visibility_transition
3902 .expect("exit transition");
3903 assert_eq!(state.console_visibility_at(exiting.started_at), 1.0);
3904 let exit_middle =
3905 state.console_visibility_at(exiting.started_at + CONSOLE_VISIBILITY_TRANSITION / 2);
3906 assert!((exit_middle - 0.5).abs() < 0.000_1);
3907 assert_eq!(
3908 state.console_visibility_at(exiting.started_at + CONSOLE_VISIBILITY_TRANSITION),
3909 0.0
3910 );
3911 }
3912
3913 #[test]
3914 fn rapid_console_reentry_preserves_glow_phase() {
3915 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3916 let canvas = Rect::new(0, 0, 80, 12);
3917 let console = Rect::new(2, 6, 76, 5);
3918 let start = Instant::now();
3919 state.set_busy_at(true, start);
3920 let epoch = state.console_animation_epoch;
3921 state.set_busy_at(false, start + CONSOLE_VISIBILITY_TRANSITION);
3922 let reversal_at = start + CONSOLE_VISIBILITY_TRANSITION * 3 / 2;
3923 let visibility_before = state.console_visibility_at(reversal_at);
3924 let elapsed_before = state.console_animation_elapsed_at(reversal_at);
3925 let color_before = glow_color_at(
3926 elapsed_before,
3927 40,
3928 canvas.width,
3929 canvas.y + canvas.height - 1,
3930 canvas,
3931 console,
3932 visibility_before,
3933 );
3934
3935 state.set_busy_at(true, reversal_at);
3936
3937 assert_eq!(state.console_animation_epoch, epoch);
3938 assert!((state.console_visibility_at(reversal_at) - visibility_before).abs() < 0.000_1);
3939 assert_eq!(
3940 glow_color_at(
3941 state.console_animation_elapsed_at(reversal_at),
3942 40,
3943 canvas.width,
3944 canvas.y + canvas.height - 1,
3945 canvas,
3946 console,
3947 state.console_visibility_at(reversal_at),
3948 ),
3949 color_before,
3950 "reversing an exit keeps the current glow phase"
3951 );
3952 }
3953
3954 #[test]
3955 fn glow_reach_still_controls_light_intensity() {
3956 let long = CONSOLE_BOUNDARY_CYCLE / 4;
3957 let short = CONSOLE_BOUNDARY_CYCLE * 3 / 4;
3958 let canvas = Rect::new(0, 0, 80, 12);
3959 let console = Rect::new(2, 6, 76, 5);
3960 let row = canvas.y + canvas.height - 1;
3961 let long_glow =
3962 glow_color_at(long, 40, canvas.width, row, canvas, console, 1.0).expect("visible glow");
3963 let short_glow = glow_color_at(short, 40, canvas.width, row, canvas, console, 1.0)
3964 .expect("visible glow");
3965
3966 assert!((console_reach_at(long) - CONSOLE_REACH_MAX).abs() < 0.000_1);
3967 assert!((console_reach_at(short) - CONSOLE_REACH_MIN).abs() < 0.000_1);
3968 assert!(
3969 color_distance(TUI_GLOW_BACKGROUND, long_glow)
3970 > color_distance(TUI_GLOW_BACKGROUND, short_glow)
3971 );
3972 }
3973
3974 #[test]
3975 fn maximum_glow_is_brighter_and_more_saturated_than_the_previous_tuning() {
3976 let canvas = Rect::new(0, 0, 160, 20);
3977 let console = Rect::new(40, 12, 80, 7);
3978 let column = console.x + console.width / 2;
3979 let row = canvas.y + canvas.height - 1;
3980 let elapsed = Duration::ZERO;
3981 let coverage = glow_coverage_at(column, row, canvas, console);
3982 assert_eq!(GLOW_INTENSITY, 0.70);
3983 let current = glow_color_at(elapsed, column, canvas.width, row, canvas, console, 1.0)
3984 .expect("bottom-centre glow");
3985 let previous = blend_rgb(
3986 TUI_GLOW_BACKGROUND,
3987 glow_accent_with_desaturation_at(elapsed, 0.16),
3988 (console_reach_at(elapsed) / CONSOLE_REACH_MAX) * 0.62 * coverage,
3989 );
3990
3991 assert!(
3992 color_luminance(current) > color_luminance(previous),
3993 "the adjusted maximum glow is brighter than the previous maximum"
3994 );
3995 assert!(
3996 color_saturation(current) > color_saturation(previous),
3997 "the adjusted maximum glow is more saturated than the previous maximum"
3998 );
3999 }
4000
4001 #[test]
4002 fn glow_tuning_is_brighter_and_more_saturated_than_before() {
4003 let canvas = Rect::new(0, 0, 160, 20);
4004 let console = Rect::new(40, 12, 80, 7);
4005 let column = console.x + console.width / 2;
4006 let row = canvas.y + canvas.height - 1;
4007 let coverage = glow_coverage_at(column, row, canvas, console);
4008
4009 assert_eq!(GLOW_INTENSITY, 0.70);
4010 assert_eq!(GLOW_DESATURATION, 0.10);
4011 for (phase, elapsed) in [
4012 Duration::ZERO,
4013 console_accent_cycle() / 4,
4014 console_accent_cycle() / 2,
4015 console_accent_cycle() * 3 / 4,
4016 ]
4017 .into_iter()
4018 .enumerate()
4019 {
4020 let current_accent = glow_accent_at(elapsed);
4021 let previous_accent = glow_accent_with_desaturation_at(elapsed, 0.16);
4022 assert!(
4023 color_saturation(current_accent) > color_saturation(previous_accent),
4024 "phase {phase}: reducing glow desaturation raises saturation"
4025 );
4026 let current = glow_color_at(elapsed, column, canvas.width, row, canvas, console, 1.0)
4027 .expect("bottom-centre glow");
4028 let previous = blend_rgb(
4029 TUI_GLOW_BACKGROUND,
4030 glow_accent_with_desaturation_at(elapsed, 0.16),
4031 (console_reach_at(elapsed) / CONSOLE_REACH_MAX) * 0.62 * coverage,
4032 );
4033
4034 assert!(
4035 color_luminance(current) > color_luminance(previous),
4036 "phase {phase}: the rendered glow is brighter than the prior tuning"
4037 );
4038 assert!(
4039 color_saturation(current) > color_saturation(previous),
4040 "phase {phase}: the rendered glow is more saturated than the prior tuning"
4041 );
4042 }
4043 }
4044
4045 #[test]
4046 fn console_is_dark_lower_saturation_glass_over_the_glow() {
4047 let elapsed = CONSOLE_BOUNDARY_CYCLE / 4;
4048 let canvas = Rect::new(0, 0, 80, 12);
4049 let console = Rect::new(2, 6, 76, 5);
4050 let glow = glow_color_at(
4051 elapsed,
4052 40,
4053 canvas.width,
4054 canvas.y + canvas.height - 1,
4055 canvas,
4056 console,
4057 1.0,
4058 )
4059 .expect("visible glow");
4060 let glass = console_glass_color_at(elapsed, glow, 1.0);
4061 let solid_glass = console_glass_color_at(elapsed, CONSOLE_BACKGROUND, 1.0);
4062
4063 assert_eq!(
4064 console_glass_color_at(elapsed, glow, 0.0),
4065 CONSOLE_BACKGROUND
4066 );
4067 assert_ne!(glass, CONSOLE_BACKGROUND);
4068 let (glass_red, glass_green, glass_blue) = activity_rgb(glass);
4069 let (glow_red, glow_green, glow_blue) = activity_rgb(glow);
4070 assert!(
4071 u16::from(glass_red) + u16::from(glass_green) + u16::from(glass_blue)
4072 < u16::from(glow_red) + u16::from(glow_green) + u16::from(glow_blue),
4073 "console glass is darker than the exposed glow"
4074 );
4075 assert!(
4076 color_distance(CONSOLE_BACKGROUND, glass) < color_distance(TUI_GLOW_BACKGROUND, glow),
4077 "glass tint is subtler than the exposed glow"
4078 );
4079 assert!(
4080 color_distance(CONSOLE_BACKGROUND, glass)
4081 > color_distance(CONSOLE_BACKGROUND, solid_glass),
4082 "the glow visibly carries through the stronger glass tint"
4083 );
4084 assert!(
4085 color_saturation(glass) < color_saturation(glow),
4086 "glass tint is less saturated than the exposed glow"
4087 );
4088 }
4089
4090 #[test]
4091 fn console_top_reflection_is_a_thin_active_line_in_the_external_gap() {
4092 assert_eq!(CONSOLE_REFLECTION_WHITE_TINT, 0.20);
4093 let elapsed = Duration::ZERO;
4094 let canvas = Rect::new(0, 0, 20, 8);
4095 let console = Rect::new(2, 3, 16, 4);
4096 let reflection_y = console.y - 1;
4097 let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
4098 canvas.width,
4099 canvas.height,
4100 ))
4101 .expect("test terminal");
4102 terminal
4103 .draw(|frame| {
4104 apply_tui_glow(frame, canvas, console, elapsed, 1.0);
4105 apply_console_top_reflection(frame, console, elapsed, 1.0);
4106 apply_console_background(frame, console, elapsed, 1.0);
4107 })
4108 .expect("render external console reflection");
4109
4110 let reflection_glow = glow_color_at(
4111 elapsed,
4112 console.x,
4113 canvas.width,
4114 reflection_y,
4115 canvas,
4116 console,
4117 1.0,
4118 )
4119 .expect("reflection row glow");
4120 let accent_only_reflection = blend_rgb(
4121 reflection_glow,
4122 glow_accent_at(elapsed),
4123 CONSOLE_REFLECTION_TINT,
4124 );
4125 let previous_reflection =
4126 blend_rgb(accent_only_reflection, Color::Rgb(255, 255, 255), 0.10);
4127 let reflection = console_top_reflection_color_at(elapsed, reflection_glow, 1.0);
4128 let previous_luminance_lift =
4129 color_luminance(previous_reflection) - color_luminance(accent_only_reflection);
4130 let luminance_lift = color_luminance(reflection) - color_luminance(accent_only_reflection);
4131 assert!(
4132 luminance_lift * 100 >= previous_luminance_lift * 195
4133 && luminance_lift * 100 <= previous_luminance_lift * 210,
4134 "doubling the white tint doubles its reflection luminance contribution"
4135 );
4136 let console_glow = glow_color_at(
4137 elapsed,
4138 console.x,
4139 canvas.width,
4140 console.y,
4141 canvas,
4142 console,
4143 1.0,
4144 )
4145 .expect("console glow");
4146 let buffer = terminal.backend().buffer();
4147 assert_eq!(
4148 buffer[(console.x, reflection_y)].symbol(),
4149 CONSOLE_REFLECTION_GLYPH,
4150 "the reflection is drawn in the row above the console"
4151 );
4152 assert_eq!(
4153 buffer[(console.x, reflection_y)].fg,
4154 console_top_reflection_color_at(elapsed, reflection_glow, 1.0),
4155 "the thin reflection follows the glow accent"
4156 );
4157 assert_eq!(
4158 buffer[(console.x, reflection_y - 1)].symbol(),
4159 " ",
4160 "only the closest external row is changed"
4161 );
4162 assert_eq!(
4163 buffer[(console.x, console.y)].bg,
4164 console_glass_color_at(elapsed, console_glow, 1.0),
4165 "the console top row remains normal glass"
4166 );
4167
4168 let mut idle_terminal = Terminal::new(ratatui::backend::TestBackend::new(
4169 canvas.width,
4170 canvas.height,
4171 ))
4172 .expect("idle test terminal");
4173 idle_terminal
4174 .draw(|frame| {
4175 apply_tui_glow(frame, canvas, console, elapsed, 0.0);
4176 apply_console_top_reflection(frame, console, elapsed, 0.0);
4177 apply_console_background(frame, console, elapsed, 0.0);
4178 })
4179 .expect("render idle console");
4180 assert_eq!(
4181 idle_terminal.backend().buffer()[(console.x, reflection_y)].symbol(),
4182 " ",
4183 "idle consoles have no external reflection"
4184 );
4185
4186 let state = UiState::from_history(&[], "secret", "model", None, false);
4187 let viewport = tui_viewport(Rect::new(0, 0, 80, 10));
4188 let (chat, _, _, _, input_area, _) = ui_layout(&state, viewport);
4189 assert_eq!(
4190 chat.y + chat.height + 1,
4191 input_area.y,
4192 "the reflection uses the existing transcript gap rather than adding a row"
4193 );
4194 }
4195
4196 #[test]
4197 fn console_reflection_skips_transcript_when_no_gap_fits() {
4198 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4199 state.welcome_visible = false;
4200 state
4201 .transcript
4202 .push(TranscriptItem::Info("protected transcript".to_owned()));
4203 state.busy = true;
4204 state.activity_transition = None;
4205 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
4206 let area = Rect::new(0, 0, 60, 7);
4207 let mut terminal =
4208 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4209 .expect("test terminal");
4210 terminal
4211 .draw(|frame| draw(frame, &state))
4212 .expect("draw constrained active console");
4213
4214 let (chat, _, _, _, console, _) = ui_layout(&state, tui_viewport(area));
4215 assert_eq!(chat.y + chat.height, console.y, "there is no separator row");
4216 assert_eq!(
4217 terminal.backend().buffer()[(chat.x, chat.y)].symbol(),
4218 "p",
4219 "the active reflection does not overwrite the adjacent transcript"
4220 );
4221 }
4222
4223 #[test]
4224 fn console_reflection_stays_behind_an_active_skill_picker() {
4225 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4226 .with_skill_names(vec!["agent-browser".to_owned()]);
4227 state.input = "/".to_owned();
4228 state.input_changed();
4229 state.busy = true;
4230 state.activity_transition = None;
4231 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
4232 let area = Rect::new(0, 0, 40, 12);
4233 let mut terminal =
4234 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4235 .expect("test terminal");
4236 terminal
4237 .draw(|frame| draw(frame, &state))
4238 .expect("draw active picker");
4239
4240 let (_, picker, _, _, input, _) = ui_layout(&state, tui_viewport(area));
4241 let picker = picker.expect("visible picker");
4242 assert_eq!(picker.y + picker.height, input.y);
4243 let buffer = terminal.backend().buffer();
4244 assert_eq!(
4245 buffer[(picker.x, picker.y + picker.height - 1)].bg,
4246 SKILL_PICKER_BACKGROUND,
4247 "the picker covers the reflection row"
4248 );
4249 assert_ne!(
4250 buffer[(picker.x, picker.y + picker.height - 1)].symbol(),
4251 CONSOLE_REFLECTION_GLYPH,
4252 "the reflection glyph does not show through the picker"
4253 );
4254 }
4255
4256 #[test]
4257 fn console_glass_white_film_brightens_only_visible_glass() {
4258 let elapsed = Duration::ZERO;
4259 let glow = Color::Rgb(80, 82, 86);
4260 let (red, green, blue) = activity_rgb(console_accent_at(elapsed));
4261 let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
4262 let glass_accent = Color::Rgb(
4263 interpolate_color(red, neutral, CONSOLE_GLASS_DESATURATION),
4264 interpolate_color(green, neutral, CONSOLE_GLASS_DESATURATION),
4265 interpolate_color(blue, neutral, CONSOLE_GLASS_DESATURATION),
4266 );
4267 let accent_tinted = blend_rgb(CONSOLE_BACKGROUND, glass_accent, CONSOLE_GLASS_TINT);
4268 let without_white_film = blend_rgb(accent_tinted, glow, CONSOLE_GLASS_GLOW_THROUGH);
4269 let with_white_film = console_glass_color_at(elapsed, glow, 1.0);
4270 let expected = blend_rgb(
4271 blend_rgb(
4272 accent_tinted,
4273 Color::Rgb(255, 255, 255),
4274 CONSOLE_GLASS_WHITE_TINT,
4275 ),
4276 glow,
4277 CONSOLE_GLASS_GLOW_THROUGH,
4278 );
4279
4280 assert_eq!(CONSOLE_GLASS_WHITE_TINT, 0.03);
4281 assert_eq!(
4282 console_glass_color_at(elapsed, glow, 0.0),
4283 CONSOLE_BACKGROUND,
4284 "idle glass uses the configured console background"
4285 );
4286 assert_eq!(
4287 with_white_film, expected,
4288 "the white film is composited before glow-through"
4289 );
4290 let (with_white_red, with_white_green, with_white_blue) = activity_rgb(with_white_film);
4291 let (without_white_red, without_white_green, without_white_blue) =
4292 activity_rgb(without_white_film);
4293 assert!(
4294 u16::from(with_white_red) + u16::from(with_white_green) + u16::from(with_white_blue)
4295 > u16::from(without_white_red)
4296 + u16::from(without_white_green)
4297 + u16::from(without_white_blue),
4298 "the active glass has a visible white film"
4299 );
4300 }
4301
4302 #[test]
4303 fn console_accent_uses_a_fifteen_second_lavender_to_teal_round_trip() {
4304 assert_eq!(console_accent_cycle(), Duration::from_secs(15));
4305 assert_eq!(
4306 console_accent_at(Duration::ZERO),
4307 desaturate_console_accent(
4308 CONSOLE_ACCENT_LAVENDER.0,
4309 CONSOLE_ACCENT_LAVENDER.1,
4310 CONSOLE_ACCENT_LAVENDER.2,
4311 )
4312 );
4313 assert_eq!(
4314 console_accent_at(console_accent_cycle() / 2),
4315 desaturate_console_accent(
4316 CONSOLE_ACCENT_TEAL.0,
4317 CONSOLE_ACCENT_TEAL.1,
4318 CONSOLE_ACCENT_TEAL.2,
4319 )
4320 );
4321 assert_eq!(
4322 console_accent_at(console_accent_cycle()),
4323 console_accent_at(Duration::ZERO)
4324 );
4325 let midpoint = console_accent_at(console_accent_cycle() / 4);
4326 assert_ne!(
4327 midpoint,
4328 console_accent_at(Duration::ZERO),
4329 "the glow transitions continuously instead of holding at lavender"
4330 );
4331 assert_ne!(
4332 midpoint,
4333 console_accent_at(console_accent_cycle() / 2),
4334 "the glow transitions continuously instead of holding at teal"
4335 );
4336 }
4337
4338 #[test]
4339 fn tui_glow_background_is_ghostty_base_101216() {
4340 assert_eq!(TUI_GLOW_BACKGROUND_RGB, (16, 18, 22));
4341 assert_eq!(TUI_GLOW_BACKGROUND, Color::Rgb(16, 18, 22));
4342 }
4343
4344 #[test]
4345 fn console_palette_starts_lavender_with_fifteen_percent_desaturation() {
4346 assert_eq!(CONSOLE_BACKGROUND_RGB, (42, 42, 46));
4347 assert_eq!(CONSOLE_BACKGROUND, Color::Rgb(42, 42, 46));
4348 assert_eq!(
4349 console_accent_at(Duration::ZERO),
4350 desaturate_console_accent(
4351 CONSOLE_ACCENT_LAVENDER.0,
4352 CONSOLE_ACCENT_LAVENDER.1,
4353 CONSOLE_ACCENT_LAVENDER.2,
4354 )
4355 );
4356 }
4357
4358 #[test]
4359 fn console_accent_transition_changes_all_tui_glow_in_sync() {
4360 let elapsed = console_accent_cycle() / 4;
4361 let canvas = Rect::new(0, 0, 80, 20);
4362 let console = Rect::new(20, 12, 40, 7);
4363 let row = canvas.y + canvas.height - 1;
4364 let left = glow_color_at(elapsed, console.x, canvas.width, row, canvas, console, 1.0)
4365 .expect("left-side glow");
4366 let right = glow_color_at(
4367 elapsed,
4368 console.x + console.width - 1,
4369 canvas.width,
4370 row,
4371 canvas,
4372 console,
4373 1.0,
4374 )
4375 .expect("right-side glow");
4376 let intensity = (console_reach_at(elapsed) / CONSOLE_REACH_MAX)
4377 * GLOW_INTENSITY
4378 * glow_coverage_at(console.x, row, canvas, console);
4379
4380 assert_eq!(
4381 blend_rgb(TUI_GLOW_BACKGROUND, glow_accent_at(elapsed), intensity),
4382 left,
4383 "the left edge uses the shared accent phase"
4384 );
4385 assert_eq!(left, right, "the glow has no horizontal color phase");
4386
4387 let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
4388 canvas.width,
4389 canvas.height,
4390 ))
4391 .expect("test terminal");
4392 terminal
4393 .draw(|frame| {
4394 apply_tui_glow(frame, canvas, console, elapsed, 1.0);
4395 apply_console_top_reflection(frame, console, elapsed, 1.0);
4396 apply_console_background(frame, console, elapsed, 1.0);
4397 })
4398 .expect("render synchronized TUI glow");
4399 let buffer = terminal.backend().buffer();
4400 let left_x = console.x;
4401 let right_x = console.x + console.width - 1;
4402 assert_eq!(
4403 buffer[(left_x, console.y)].bg,
4404 buffer[(right_x, console.y)].bg,
4405 "the glass uses the shared accent phase"
4406 );
4407 assert_eq!(
4408 buffer[(left_x, console.y - 1)].fg,
4409 buffer[(right_x, console.y - 1)].fg,
4410 "the reflection uses the shared accent phase"
4411 );
4412 }
4413
4414 #[test]
4415 fn prompt_uses_two_cells_of_horizontal_console_padding() {
4416 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4417 state.input = "1234567890123456".to_owned();
4418 let area = Rect::new(0, 0, 20, 6);
4419 let mut terminal =
4420 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4421 .expect("test terminal");
4422
4423 terminal
4424 .draw(|frame| draw(frame, &state))
4425 .expect("draw padded prompt");
4426
4427 let input_area = ui_layout(&state, tui_viewport(area)).4;
4428 let prompt = prompt_area(input_area, &state);
4429 assert_eq!(prompt.x, input_area.x + 2);
4430 assert_eq!(prompt.width, input_area.width.saturating_sub(4));
4431 assert_eq!(
4432 terminal.backend().buffer()[(input_area.x + 1, prompt.y)].symbol(),
4433 " ",
4434 "the two left padding cells remain blank"
4435 );
4436 assert_eq!(
4437 terminal.backend().buffer()[(input_area.x + input_area.width - 2, prompt.y)].symbol(),
4438 " ",
4439 "the two right padding cells remain blank"
4440 );
4441 terminal
4442 .backend_mut()
4443 .assert_cursor_position((input_area.x + 2, prompt.y));
4444 }
4445
4446 #[test]
4447 fn prompt_width_reduction_wraps_and_saturates_at_narrow_widths() {
4448 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4449 state.input = "12345".to_owned();
4450 state.cursor = state.input.chars().count();
4451 let input_area = Rect::new(3, 2, 6, 6);
4452 let prompt = prompt_area(input_area, &state);
4453
4454 assert_eq!(prompt.width, 2);
4455 assert_eq!(input_visible_rows(&state, prompt.width), 3);
4456 assert_eq!(bottom_content_heights(&state, input_area).prompt, 3);
4457 assert_eq!(
4458 cursor_row(&state.input, state.cursor, prompt.width as usize),
4459 2
4460 );
4461 state.cursor = 1;
4462 assert!(move_input_cursor_vertical(
4463 &mut state,
4464 prompt_content_width(input_area.width) as usize,
4465 true,
4466 ));
4467 assert_eq!(state.cursor, 3);
4468 assert_eq!(prompt_content_width(0), 0);
4469 assert_eq!(prompt_content_width(1), 0);
4470 assert_eq!(prompt_content_width(2), 0);
4471 assert_eq!(prompt_content_width(3), 0);
4472 assert_eq!(prompt_content_width(4), 0);
4473 assert_eq!(prompt_content_width(5), 1);
4474 }
4475
4476 #[test]
4477 fn ready_submission_bypasses_queue_and_is_not_added_twice_when_started() {
4478 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4479
4480 state.submit_user("send now");
4481
4482 assert!(state.queued_messages.is_empty());
4483 assert_eq!(state.transcript.len(), 1);
4484 assert!(matches!(
4485 &state.transcript[0],
4486 TranscriptItem::User { text, .. } if text == "send now"
4487 ));
4488
4489 state.start_queued_user("send now");
4492 assert_eq!(state.transcript.len(), 1);
4493 }
4494
4495 #[test]
4496 fn busy_submission_remains_queued_until_its_turn_starts() {
4497 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4498 state.busy = true;
4499
4500 state.submit_user("send later");
4501
4502 assert_eq!(state.queued_messages, ["send later"]);
4503 assert!(state.transcript.is_empty());
4504
4505 state.start_queued_user("send later");
4506 assert!(state.queued_messages.is_empty());
4507 assert!(matches!(
4508 &state.transcript[..],
4509 [TranscriptItem::User { text, .. }] if text == "send later"
4510 ));
4511 }
4512
4513 #[test]
4514 fn skill_picker_stays_above_a_visible_message_queue() {
4515 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4516 .with_skill_names(vec!["release-notes".to_owned()]);
4517 state.queue_user("next task");
4518 state.input = "/".to_owned();
4519 state.input_changed();
4520
4521 let area = Rect::new(0, 0, 80, 12);
4522 let (_, picker_area, _, queue_area, input_area, _) = ui_layout(&state, tui_viewport(area));
4523 let picker_area = picker_area.expect("skill picker area");
4524 let queue_area = queue_area.expect("message queue area");
4525 assert_eq!(picker_area.y + picker_area.height, input_area.y);
4526 assert_eq!(queue_area.y, input_area.y + 1);
4527 assert_eq!(queue_area.x, input_area.x + 2);
4528 }
4529
4530 #[test]
4531 fn fresh_sessions_show_the_versioned_gradient_welcome_message() {
4532 let state = UiState::from_history(&[], "secret", "model", None, false);
4533 assert!(state.welcome_visible);
4534
4535 let line = welcome_line();
4536 assert_eq!(line.to_string(), WELCOME_MESSAGE);
4537 assert_eq!(WELCOME_VERSION, concat!("v", env!("CARGO_PKG_VERSION")));
4538 assert_eq!(
4539 line.spans.first().and_then(|span| span.style.fg),
4540 Some(Color::Rgb(
4541 WELCOME_START_COLOR.0,
4542 WELCOME_START_COLOR.1,
4543 WELCOME_START_COLOR.2,
4544 ))
4545 );
4546 assert_eq!(
4547 line.spans.last().and_then(|span| span.style.fg),
4548 Some(Color::Rgb(
4549 WELCOME_END_COLOR.0,
4550 WELCOME_END_COLOR.1,
4551 WELCOME_END_COLOR.2,
4552 ))
4553 );
4554 }
4555
4556 #[test]
4557 fn welcome_image_brightness_is_reduced_without_changing_alpha() {
4558 let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
4559 1,
4560 1,
4561 image::Rgba([200, 100, 0, 37]),
4562 ));
4563 let dimmed = dim_welcome_image(image).to_rgba8();
4564 assert_eq!(dimmed.get_pixel(0, 0).0, [170, 85, 0, 37]);
4565 }
4566
4567 #[test]
4568 fn spacious_welcome_uses_the_embedded_png() {
4569 let image = welcome_image(GREETING_IMAGE_SIZE);
4570 assert_eq!(image.size(), GREETING_IMAGE_SIZE);
4571 let layout = welcome_image_layout(Rect::new(0, 0, 100, 40), 6).expect("image fits");
4572 assert_eq!(layout.image_size, GREETING_IMAGE_SIZE);
4573 assert_eq!(layout.image_area, Rect::new(10, 6, 80, 20));
4574 assert_eq!(layout.intro_area.y, layout.image_area.y + 21);
4575 }
4576
4577 #[test]
4578 fn cramped_welcome_falls_back_to_the_text_greeting() {
4579 assert_eq!(welcome_image_layout(Rect::new(0, 0, 80, 16), 6), None);
4580 assert_eq!(welcome_image_layout(Rect::new(0, 0, 39, 40), 6), None);
4581 let scaled = welcome_image_layout(Rect::new(0, 0, 60, 25), 6).expect("scaled image fits");
4582 assert_eq!(scaled.image_size, Size::new(60, 15));
4583
4584 let state = UiState::from_history(&[], "secret", "model", None, false);
4585 let area = Rect::new(0, 0, 80, 12);
4586 let mut terminal =
4587 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4588 .expect("test terminal");
4589 terminal
4590 .draw(|frame| draw(frame, &state))
4591 .expect("draw text fallback");
4592 let chat_area = ui_layout(&state, tui_viewport(area)).0;
4593 let rows = (chat_area.y..chat_area.y + chat_area.height)
4594 .map(|y| {
4595 (chat_area.x..chat_area.x + chat_area.width)
4596 .map(|x| terminal.backend().buffer()[(x, y)].symbol())
4597 .collect::<String>()
4598 })
4599 .collect::<Vec<_>>();
4600 assert!(rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
4601 assert!(!rows
4602 .iter()
4603 .any(|row| row.contains('▀') || row.contains('▄')));
4604 }
4605
4606 #[test]
4607 fn logo_text_renders_by_default_and_greeting_image_replaces_it_when_enabled() {
4608 let logo = logo_lines();
4609 let logo_row_count = LOGO_TEXT.lines().count();
4610 assert_eq!(logo.len(), logo_row_count);
4611 assert!(logo.iter().flat_map(|line| &line.spans).any(|span| {
4613 span.content.chars().any(|ch| ch != ' ')
4614 && matches!(span.style.fg, Some(Color::Rgb(..)))
4615 }));
4616
4617 let state = UiState::from_history(&[], "secret", "model", None, false);
4618 let area = Rect::new(0, 0, 100, 50);
4619 let mut terminal =
4620 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4621 .expect("test terminal");
4622 let chat_area = ui_layout(&state, tui_viewport(area)).0;
4623 let intro_lines = welcome_lines(&state.attached_agents);
4624 let greeting_layout =
4625 welcome_image_layout(chat_area, intro_lines.len() as u16).expect("greeting fits");
4626
4627 std::env::remove_var("LUCY_GREETING_IMAGE");
4629 terminal
4630 .draw(|frame| draw(frame, &state))
4631 .expect("draw logo text");
4632 let buffer = terminal.backend().buffer();
4633 let rows = (chat_area.y..chat_area.y + chat_area.height)
4634 .map(|y| {
4635 (chat_area.x..chat_area.x + chat_area.width)
4636 .map(|x| buffer[(x, y)].symbol())
4637 .collect::<String>()
4638 })
4639 .collect::<Vec<_>>();
4640 assert!(rows
4641 .iter()
4642 .any(|row| row.contains(':') || row.contains('-') || row.contains('=')));
4643 assert!(!rows
4644 .iter()
4645 .any(|row| row.contains('▀') || row.contains('▄')));
4646 assert!(rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
4647
4648 std::env::set_var("LUCY_GREETING_IMAGE", "true");
4650 terminal
4651 .draw(|frame| draw(frame, &state))
4652 .expect("draw greeting");
4653 let buffer = terminal.backend().buffer();
4654 assert_eq!(greeting_layout.image_size, GREETING_IMAGE_SIZE);
4655 assert!(matches!(
4656 buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].symbol(),
4657 "▀" | "▄"
4658 ));
4659 assert!(matches!(
4660 buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].fg,
4661 Color::Rgb(..)
4662 ));
4663 assert!(matches!(
4664 buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].bg,
4665 Color::Rgb(..)
4666 ));
4667 let intro_rows = (greeting_layout.intro_area.y
4668 ..greeting_layout.intro_area.y + greeting_layout.intro_area.height)
4669 .map(|y| {
4670 (greeting_layout.intro_area.x
4671 ..greeting_layout.intro_area.x + greeting_layout.intro_area.width)
4672 .map(|x| buffer[(x, y)].symbol())
4673 .collect::<String>()
4674 })
4675 .collect::<Vec<_>>();
4676 assert!(intro_rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
4677
4678 std::env::remove_var("LUCY_GREETING_IMAGE");
4679 }
4680
4681 #[test]
4682 fn welcome_renders_version_below_title_with_a_blank_line_before_tagline() {
4683 let state = UiState::from_history(&[], "secret", "model", None, false);
4684 let area = Rect::new(0, 0, 80, 12);
4685 let mut terminal =
4686 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4687 .expect("test terminal");
4688 terminal
4689 .draw(|frame| draw(frame, &state))
4690 .expect("draw welcome screen");
4691
4692 let chat_area = ui_layout(&state, tui_viewport(area)).0;
4693 let buffer = terminal.backend().buffer();
4694 let rows = (chat_area.y..chat_area.y + chat_area.height)
4695 .map(|y| {
4696 (chat_area.x..chat_area.x + chat_area.width)
4697 .map(|x| buffer[(x, y)].symbol())
4698 .collect::<String>()
4699 })
4700 .collect::<Vec<_>>();
4701 let title_row = rows
4702 .iter()
4703 .position(|row| row.contains(WELCOME_MESSAGE))
4704 .expect("rendered welcome title");
4705 let version_rows = rows
4706 .iter()
4707 .enumerate()
4708 .filter_map(|(row, rendered)| rendered.contains(WELCOME_VERSION).then_some(row))
4709 .collect::<Vec<_>>();
4710
4711 assert_eq!(version_rows, vec![title_row + 1]);
4712 assert!(rows[title_row + 2].trim().is_empty());
4713 assert!(rows[title_row + 3].contains(WELCOME_TAGLINE));
4714
4715 let version_width = WELCOME_VERSION.chars().count() as u16;
4716 let version_x = chat_area.x + (chat_area.width - version_width) / 2;
4717 let version_y = chat_area.y + title_row as u16 + 1;
4718 assert!((version_x..version_x + version_width)
4719 .all(|x| buffer[(x, version_y)].fg == Color::DarkGray));
4720 }
4721
4722 #[test]
4723 fn welcome_shows_the_tagline_and_attached_agents_paths() {
4724 let state = UiState::from_history(&[], "secret", "model", None, false)
4725 .with_attached_agents(vec![
4726 "/workspace/AGENTS.md".to_owned(),
4727 "/workspace/app/AGENTS.md".to_owned(),
4728 ]);
4729 let lines = welcome_lines(&state.attached_agents);
4730
4731 assert_eq!(lines[1].to_string(), WELCOME_VERSION);
4732 assert_eq!(lines[1].style.fg, Some(Color::DarkGray));
4733 assert!(lines[2].to_string().is_empty());
4734 assert_eq!(lines[3].to_string(), WELCOME_TAGLINE);
4735 assert_eq!(lines[3].style.fg, Some(Color::DarkGray));
4736 assert!(lines[4].to_string().is_empty());
4737 assert_eq!(lines[5].to_string(), "Attached AGENTS.md:");
4738 assert_eq!(lines[6].to_string(), "• /workspace/AGENTS.md");
4739 assert_eq!(lines[7].to_string(), "• /workspace/app/AGENTS.md");
4740 assert!(lines[5..]
4741 .iter()
4742 .all(|line| line.style.fg == Some(Color::DarkGray)));
4743 }
4744
4745 #[test]
4746 fn welcome_reports_when_no_agents_file_is_attached() {
4747 let lines = welcome_lines(&[]);
4748 assert_eq!(
4749 lines.last().expect("empty context line").to_string(),
4750 "Attached AGENTS.md: none"
4751 );
4752 }
4753
4754 #[test]
4755 fn resumed_sessions_do_not_show_the_welcome_message() {
4756 let state = UiState::from_history(&[], "secret", "model", None, true);
4757 assert!(!state.welcome_visible);
4758 }
4759
4760 #[test]
4761 fn history_replay_keeps_interruption_after_messages() {
4762 let history = vec![
4763 SessionHistoryRecord::Message {
4764 timestamp: 1,
4765 message: ChatMessage::user("hello".to_owned()),
4766 },
4767 SessionHistoryRecord::Interruption {
4768 timestamp: 2,
4769 reason: "user_cancelled".to_owned(),
4770 phase: "provider_stream".to_owned(),
4771 assistant_text: "partial".to_owned(),
4772 tool_calls: Vec::new(),
4773 tool_results: Vec::new(),
4774 },
4775 ];
4776 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
4777 assert!(matches!(state.transcript[0], TranscriptItem::User { .. }));
4778 assert!(matches!(state.transcript[1], TranscriptItem::Assistant(_)));
4779 assert!(matches!(state.transcript[2], TranscriptItem::Info(_)));
4780 let text = transcript_lines(&state, 80)
4781 .iter()
4782 .map(ToString::to_string)
4783 .collect::<Vec<_>>()
4784 .join("\n");
4785 assert!(!text.contains("choices"));
4786 }
4787
4788 #[test]
4789 fn history_replay_does_not_render_assistant_reasoning_details() {
4790 let mut message = ChatMessage::assistant("visible answer".to_owned(), Vec::new());
4791 message.reasoning_details = Some(vec![serde_json::json!({
4792 "type": "reasoning.text",
4793 "text": "private reasoning"
4794 })]);
4795 let history = [SessionHistoryRecord::Message {
4796 timestamp: 1,
4797 message,
4798 }];
4799 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
4800 let text = transcript_lines(&state, 80)
4801 .iter()
4802 .map(ToString::to_string)
4803 .collect::<Vec<_>>()
4804 .join("\n");
4805 assert!(text.contains("visible answer"));
4806 assert!(!text.contains("private reasoning"));
4807 assert!(!text.contains("reasoning_details"));
4808 }
4809
4810 #[test]
4811 fn history_replay_preserves_repeated_records() {
4812 let history = vec![
4813 SessionHistoryRecord::Message {
4814 timestamp: 1,
4815 message: ChatMessage::assistant("same".to_owned(), Vec::new()),
4816 },
4817 SessionHistoryRecord::Interruption {
4818 timestamp: 2,
4819 reason: "user_cancelled".to_owned(),
4820 phase: "provider_stream".to_owned(),
4821 assistant_text: "same".to_owned(),
4822 tool_calls: Vec::new(),
4823 tool_results: Vec::new(),
4824 },
4825 ];
4826 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
4827 assert_eq!(
4828 state
4829 .transcript
4830 .iter()
4831 .filter(|item| matches!(item, TranscriptItem::Assistant(text) if text == "same"))
4832 .count(),
4833 2
4834 );
4835 }
4836
4837 #[test]
4838 fn user_messages_have_a_single_block_rule_with_inner_and_vertical_padding() {
4839 let history = [SessionHistoryRecord::Message {
4840 timestamp: 1,
4841 message: ChatMessage::user("hello\nworld".to_owned()),
4842 }];
4843 let state = UiState::from_history(&history, "provider-secret", "model", None, false);
4844 let lines = transcript_lines(&state, 12);
4845
4846 assert_eq!(UnicodeWidthStr::width(USER_BORDER_GLYPH), 1);
4847 assert_eq!(lines.len(), 4);
4848 assert_eq!(lines[0].to_string(), "▌");
4849 assert_eq!(lines[1].to_string(), "▌ hello");
4850 assert_eq!(lines[2].to_string(), "▌ world");
4851 assert_eq!(lines[3].to_string(), "▌");
4852 for line in &lines {
4853 assert_eq!(line.spans[0].content, USER_BORDER_GLYPH);
4854 assert_eq!(line.spans[0].style.fg, Some(USER_BORDER_COLOR));
4855 assert!(!line.to_string().contains(['┌', '┐', '└', '┘', '│']));
4856 }
4857 for line in &lines[1..3] {
4858 assert_eq!(line.spans[1].content, " ");
4859 assert_eq!(line.spans[1].style.fg, Some(Color::White));
4860 assert_eq!(line.spans[2].style.fg, Some(Color::White));
4861 }
4862 }
4863
4864 #[test]
4865 fn attached_skill_highlights_its_trigger_in_the_user_message_without_a_notice_line() {
4866 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4867 .with_skill_names(vec!["release-notes".to_owned()]);
4868 state.add_user("/release-notes v1.2.0", "secret");
4869 state.mark_latest_user_skill_attached();
4870
4871 let lines = transcript_lines(&state, 40);
4872 assert_eq!(lines.len(), 3);
4873 assert_eq!(lines[1].spans[1].content, " ");
4874 let cyan_text = lines[1]
4875 .spans
4876 .iter()
4877 .filter(|span| span.style.fg == Some(SKILL_TRIGGER_COLOR))
4878 .map(|span| span.content.as_ref())
4879 .collect::<String>();
4880 assert_eq!(cyan_text, "/release-notes");
4881 assert!(!lines
4882 .iter()
4883 .any(|line| line.to_string().contains("instruction attached")));
4884 }
4885
4886 #[test]
4887 fn transcript_rendering_redacts_history_content() {
4888 let history = [SessionHistoryRecord::Message {
4889 timestamp: 1,
4890 message: ChatMessage::assistant("provider-secret".to_owned(), Vec::new()),
4891 }];
4892 let state = UiState::from_history(&history, "provider-secret", "model", None, false);
4893 let text = transcript_lines(&state, 80)
4894 .iter()
4895 .map(ToString::to_string)
4896 .collect::<Vec<_>>()
4897 .join("\n");
4898 assert!(!text.contains("provider-secret"));
4899 }
4900
4901 #[test]
4902 fn mouse_wheel_disables_following_and_changes_scroll_offset() {
4903 let history = [SessionHistoryRecord::Message {
4904 timestamp: 1,
4905 message: ChatMessage::user("hello".to_owned()),
4906 }];
4907 let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
4908 handle_mouse_event(&mut state, MouseEventKind::ScrollUp, 10);
4909 assert!(!state.auto_scroll);
4910 assert_eq!(state.scroll, 7);
4911 handle_mouse_event(&mut state, MouseEventKind::ScrollDown, 10);
4912 assert!(
4913 state.auto_scroll,
4914 "reaching the bottom resumes transcript following"
4915 );
4916 assert_eq!(state.scroll, 0);
4917 scroll_up(&mut state, 10);
4918 assert!(!state.auto_scroll);
4919 assert_eq!(state.scroll, 7);
4920 }
4921
4922 #[test]
4923 fn wrap_text_breaks_long_lines_and_preserves_empty_lines() {
4924 let rows = wrap_text("12345\n\nabc", 3);
4925 assert_eq!(rows, vec!["123", "45", "", "abc"]);
4926 }
4927
4928 #[test]
4929 fn wrap_line_never_returns_an_empty_vec() {
4930 assert_eq!(wrap_line("", 5), vec![""]);
4931 assert_eq!(wrap_line("abc", 5), vec!["abc"]);
4932 }
4933
4934 #[test]
4935 fn multiline_input_arrows_move_cursor_between_explicit_and_wrapped_rows() {
4936 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4937 state.input = "ab\ncd\nef".to_owned();
4938 state.cursor = 1;
4939
4940 assert!(move_input_cursor_vertical(&mut state, 10, true));
4941 assert_eq!(
4942 state.cursor, 4,
4943 "preserve the column on the next explicit row"
4944 );
4945 assert!(move_input_cursor_vertical(&mut state, 10, true));
4946 assert_eq!(state.cursor, 7);
4947 assert!(!move_input_cursor_vertical(&mut state, 10, true));
4948 assert!(move_input_cursor_vertical(&mut state, 10, false));
4949 assert_eq!(state.cursor, 4);
4950
4951 state.input = "abcdef".to_owned();
4952 state.cursor = 1;
4953 assert!(move_input_cursor_vertical(&mut state, 3, true));
4954 assert_eq!(state.cursor, 4, "wrapped rows use the same visual column");
4955 assert!(move_input_cursor_vertical(&mut state, 3, false));
4956 assert_eq!(state.cursor, 1);
4957 }
4958
4959 #[test]
4960 fn completion_event_does_not_release_input_before_worker_finishes() {
4961 let history = [SessionHistoryRecord::Message {
4962 timestamp: 1,
4963 message: ChatMessage::user("hello".to_owned()),
4964 }];
4965 let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
4966 state.busy = true;
4967 state.active_cancel = Some(CancellationToken::new());
4968 state.apply_event(ProtocolEvent::TurnEnd);
4969 assert!(state.busy);
4970 assert!(state.active_cancel.is_some());
4971 assert_eq!(state.status, "finalizing");
4972 }
4973
4974 #[test]
4975 fn transcript_inserts_a_blank_line_between_items() {
4976 let history = [
4977 SessionHistoryRecord::Message {
4978 timestamp: 1,
4979 message: ChatMessage::user("hi".to_owned()),
4980 },
4981 SessionHistoryRecord::Message {
4982 timestamp: 2,
4983 message: ChatMessage::assistant("hello".to_owned(), Vec::new()),
4984 },
4985 ];
4986 let state = UiState::from_history(&history, "secret", "model", None, false);
4987 let lines = transcript_lines(&state, 80);
4988 assert_eq!(lines.len(), 5);
4989 assert_eq!(lines[0].to_string(), "▌");
4990 assert_eq!(lines[1].to_string(), "▌ hi");
4991 assert_eq!(lines[2].to_string(), "▌");
4992 assert_eq!(lines[3].to_string(), "");
4993 assert_eq!(lines[4].to_string(), "hello");
4994 }
4995
4996 #[test]
4997 fn cmd_call_renders_as_a_compact_status_line_without_raw_json() {
4998 let history = vec![
4999 SessionHistoryRecord::Message {
5000 timestamp: 1,
5001 message: ChatMessage::assistant(
5002 String::new(),
5003 vec![crate::model::ChatToolCall {
5004 id: "call-1".to_owned(),
5005 name: "cmd".to_owned(),
5006 arguments: r#"{"command":"pwd"}"#.to_owned(),
5007 }],
5008 ),
5009 },
5010 SessionHistoryRecord::Message {
5011 timestamp: 2,
5012 message: ChatMessage::tool(
5013 "call-1".to_owned(),
5014 "cmd".to_owned(),
5015 serde_json::json!({"exit_code": 0, "stdout": "secret output"}).to_string(),
5016 ),
5017 },
5018 ];
5019 let state = UiState::from_history(&history, "secret", "model", None, false);
5020 let text = transcript_lines(&state, 80)[0].to_string();
5021
5022 assert_eq!(text, "✓ cmd $ pwd");
5023 assert!(!text.contains("secret output"));
5024 assert!(!text.contains("{\"command\":\"pwd\"}"));
5025 }
5026
5027 #[test]
5028 fn pending_cmd_calls_use_a_compact_running_status() {
5029 let history = [SessionHistoryRecord::Message {
5030 timestamp: 1,
5031 message: ChatMessage::assistant(
5032 String::new(),
5033 vec![crate::model::ChatToolCall {
5034 id: "call-1".to_owned(),
5035 name: "cmd".to_owned(),
5036 arguments: r#"{"command":"pwd"}"#.to_owned(),
5037 }],
5038 ),
5039 }];
5040 let state = UiState::from_history(&history, "secret", "model", None, false);
5041 let line = &transcript_lines(&state, 80)[0];
5042
5043 let text = line.to_string();
5044 let prefix = "· cmd $ pwd ";
5045 assert!(text.starts_with(prefix));
5046 assert!(!text.contains("→ running"));
5047 let frame = &text[prefix.len()..];
5048 assert_eq!(frame.chars().count(), 1);
5049 assert!(frame
5050 .chars()
5051 .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
5052 assert!(line
5053 .spans
5054 .iter()
5055 .all(|span| span.style.fg == Some(PENDING_TOOL_COLOR)));
5056 }
5057
5058 #[test]
5059 fn running_tool_indicators_use_a_traditional_spinner_with_their_own_clock() {
5060 assert_eq!(tool_spinner_frame_at(Duration::ZERO), '|');
5061 assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION), '/');
5062 assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 2), '-');
5063 assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 3), '\\');
5064
5065 let state = UiState::from_history(&[], "secret", "model", None, false);
5066 let spinner = running_tool_status(&state);
5067 assert_eq!(spinner.chars().count(), 1);
5068 assert!(spinner
5069 .chars()
5070 .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
5071 }
5072
5073 #[test]
5074 fn successful_cmd_cross_fades_to_teal_from_first_character_to_last() {
5075 let started_at = Instant::now();
5076 let character_count = 12;
5077 let early = started_at + TOOL_RESULT_SWEEP_DURATION / 4;
5078 let halfway = started_at + TOOL_RESULT_SWEEP_DURATION / 2;
5079 let late = started_at + TOOL_RESULT_SWEEP_DURATION * 3 / 4;
5080
5081 assert_eq!(
5082 cmd_result_color_at(
5083 started_at,
5084 started_at,
5085 0,
5086 character_count,
5087 TOOL_SUCCESS_COLOR,
5088 ),
5089 PENDING_TOOL_COLOR,
5090 );
5091 assert_eq!(TOOL_SUCCESS_COLOR, Color::Rgb(0, 210, 175));
5092
5093 let early_first =
5094 cmd_result_color_at(started_at, early, 0, character_count, TOOL_SUCCESS_COLOR);
5095 assert_ne!(early_first, PENDING_TOOL_COLOR);
5096 assert_ne!(early_first, TOOL_SUCCESS_COLOR);
5097 assert_eq!(
5098 cmd_result_color_at(started_at, early, 5, character_count, TOOL_SUCCESS_COLOR),
5099 PENDING_TOOL_COLOR,
5100 "later characters wait while the first character cross-fades"
5101 );
5102
5103 assert_eq!(
5104 cmd_result_color_at(started_at, halfway, 0, character_count, TOOL_SUCCESS_COLOR),
5105 TOOL_SUCCESS_COLOR,
5106 );
5107 let halfway_middle =
5108 cmd_result_color_at(started_at, halfway, 5, character_count, TOOL_SUCCESS_COLOR);
5109 assert_ne!(halfway_middle, PENDING_TOOL_COLOR);
5110 assert_ne!(halfway_middle, TOOL_SUCCESS_COLOR);
5111 assert_eq!(
5112 cmd_result_color_at(
5113 started_at,
5114 halfway,
5115 character_count - 1,
5116 character_count,
5117 TOOL_SUCCESS_COLOR,
5118 ),
5119 PENDING_TOOL_COLOR,
5120 );
5121
5122 let late_last = cmd_result_color_at(
5123 started_at,
5124 late,
5125 character_count - 1,
5126 character_count,
5127 TOOL_SUCCESS_COLOR,
5128 );
5129 assert_ne!(late_last, PENDING_TOOL_COLOR);
5130 assert_ne!(late_last, TOOL_SUCCESS_COLOR);
5131 assert_eq!(
5132 cmd_result_color_at(
5133 started_at,
5134 started_at + TOOL_RESULT_SWEEP_DURATION,
5135 character_count - 1,
5136 character_count,
5137 TOOL_SUCCESS_COLOR,
5138 ),
5139 TOOL_SUCCESS_COLOR,
5140 "the completed sweep keeps the exact teal used during the fade"
5141 );
5142 }
5143
5144 #[test]
5145 fn cmd_result_cross_fade_has_no_abrupt_color_change_between_render_ticks() {
5146 let started_at = Instant::now();
5147 let character_count = 12;
5148 let render_ticks = TOOL_RESULT_SWEEP_DURATION.as_millis() / EVENT_POLL.as_millis();
5149
5150 for target in [TOOL_SUCCESS_COLOR, TOOL_FAILURE_COLOR, TOOL_WARNING_COLOR] {
5151 for character_index in 0..character_count {
5152 let frames = (0..=render_ticks)
5153 .map(|tick| {
5154 cmd_result_color_at(
5155 started_at,
5156 started_at + EVENT_POLL * tick as u32,
5157 character_index,
5158 character_count,
5159 target,
5160 )
5161 })
5162 .collect::<Vec<_>>();
5163
5164 assert!(frames
5165 .iter()
5166 .any(|color| { *color != PENDING_TOOL_COLOR && *color != target }));
5167 assert!(frames.windows(2).all(|pair| {
5168 let (before_red, before_green, before_blue) = tool_result_color_rgb(pair[0]);
5169 let (after_red, after_green, after_blue) = tool_result_color_rgb(pair[1]);
5170 before_red.abs_diff(after_red) <= 45
5171 && before_green.abs_diff(after_green) <= 45
5172 && before_blue.abs_diff(after_blue) <= 45
5173 }));
5174 assert_eq!(frames.last(), Some(&target));
5175 }
5176 }
5177 }
5178
5179 #[test]
5180 fn only_live_cmd_results_start_a_result_sweep() {
5181 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5182 let succeeded = serde_json::json!({"exit_code": 0});
5183
5184 state.add_tool_result("historic", "cmd", succeeded.clone());
5185 state.add_live_tool_result("success", "cmd", succeeded);
5186 state.add_live_tool_result("failed", "cmd", serde_json::json!({"exit_code": 1}));
5187
5188 assert!(!state.cmd_result_started_at.contains_key("historic"));
5189 assert!(state.cmd_result_started_at.contains_key("success"));
5190 assert!(state.cmd_result_started_at.contains_key("failed"));
5191 }
5192
5193 #[test]
5194 fn failed_cmd_cross_fades_to_the_same_rgb_red_without_a_final_jump() {
5195 let started_at = Instant::now();
5196 let character_count = 12;
5197 let halfway = started_at + TOOL_RESULT_SWEEP_DURATION / 2;
5198
5199 assert_eq!(
5200 cmd_result_color_at(
5201 started_at,
5202 started_at,
5203 0,
5204 character_count,
5205 TOOL_FAILURE_COLOR,
5206 ),
5207 PENDING_TOOL_COLOR,
5208 );
5209 assert_eq!(
5210 cmd_result_color_at(started_at, halfway, 0, character_count, TOOL_FAILURE_COLOR),
5211 TOOL_FAILURE_COLOR,
5212 );
5213 let intermediate =
5214 cmd_result_color_at(started_at, halfway, 5, character_count, TOOL_FAILURE_COLOR);
5215 assert_ne!(intermediate, PENDING_TOOL_COLOR);
5216 assert_ne!(intermediate, TOOL_FAILURE_COLOR);
5217 assert_eq!(
5218 cmd_result_color_at(
5219 started_at,
5220 halfway,
5221 character_count - 1,
5222 character_count,
5223 TOOL_FAILURE_COLOR,
5224 ),
5225 PENDING_TOOL_COLOR,
5226 );
5227 assert_eq!(
5228 cmd_result_color_at(
5229 started_at,
5230 started_at + TOOL_RESULT_SWEEP_DURATION,
5231 character_count - 1,
5232 character_count,
5233 TOOL_FAILURE_COLOR,
5234 ),
5235 TOOL_FAILURE_COLOR,
5236 "the completed failure sweep keeps the exact RGB red used during the fade"
5237 );
5238 }
5239
5240 #[test]
5241 fn live_failed_cmd_sweep_keeps_the_final_status_text() {
5242 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5243 let result = serde_json::json!({"exit_code": 1});
5244 state.add_live_tool_result("failed", "cmd", result.clone());
5245
5246 let segments = cmd_tool_segments("failed", r#"{"command":"bad"}"#, Some(&result), &state);
5247 let text = segments
5248 .iter()
5249 .map(|(text, _)| text.as_str())
5250 .collect::<String>();
5251
5252 assert_eq!(text, "× cmd $ bad → exit 1");
5253 }
5254
5255 #[test]
5256 fn cmd_result_target_colors_follow_the_final_status() {
5257 assert_eq!(
5258 cmd_result_target_color(&serde_json::json!({"exit_code": 0})),
5259 TOOL_SUCCESS_COLOR
5260 );
5261 assert_eq!(
5262 cmd_result_target_color(&serde_json::json!({"exit_code": 1})),
5263 TOOL_FAILURE_COLOR
5264 );
5265 assert_eq!(
5266 cmd_result_target_color(&serde_json::json!({"timed_out": true})),
5267 TOOL_WARNING_COLOR
5268 );
5269 }
5270
5271 #[test]
5272 fn cmd_status_distinguishes_nonzero_exit_timeout_and_cancellation() {
5273 let cases = [
5274 (
5275 serde_json::json!({"exit_code": 127}),
5276 "× cmd $ bad → exit 127",
5277 ),
5278 (
5279 serde_json::json!({"timed_out": true, "exit_code": null}),
5280 "! cmd $ slow → timeout",
5281 ),
5282 (
5283 serde_json::json!({"canceled": true}),
5284 "! cmd $ stop → canceled",
5285 ),
5286 ];
5287 for (result, expected) in cases {
5288 let history = vec![
5289 SessionHistoryRecord::Message {
5290 timestamp: 1,
5291 message: ChatMessage::assistant(
5292 String::new(),
5293 vec![crate::model::ChatToolCall {
5294 id: "call-1".to_owned(),
5295 name: "cmd".to_owned(),
5296 arguments: serde_json::json!({"command": expected.split("$ ").nth(1).unwrap().split(" ").next().unwrap()}).to_string(),
5297 }],
5298 ),
5299 },
5300 SessionHistoryRecord::Message {
5301 timestamp: 2,
5302 message: ChatMessage::tool(
5303 "call-1".to_owned(),
5304 "cmd".to_owned(),
5305 result.to_string(),
5306 ),
5307 },
5308 ];
5309 let state = UiState::from_history(&history, "secret", "model", None, false);
5310 assert_eq!(transcript_lines(&state, 80)[0].to_string(), expected);
5311 }
5312 }
5313
5314 #[test]
5315 fn cmd_line_truncates_long_commands_but_never_renders_output() {
5316 let command = "a".repeat(120);
5317 let arguments = serde_json::json!({"command": command}).to_string();
5318 let history = vec![
5319 SessionHistoryRecord::Message {
5320 timestamp: 1,
5321 message: ChatMessage::assistant(
5322 String::new(),
5323 vec![crate::model::ChatToolCall {
5324 id: "call-1".to_owned(),
5325 name: "cmd".to_owned(),
5326 arguments,
5327 }],
5328 ),
5329 },
5330 SessionHistoryRecord::Message {
5331 timestamp: 2,
5332 message: ChatMessage::tool(
5333 "call-1".to_owned(),
5334 "cmd".to_owned(),
5335 serde_json::json!({"exit_code": 0, "stdout": "output"}).to_string(),
5336 ),
5337 },
5338 ];
5339 let state = UiState::from_history(&history, "secret", "model", None, false);
5340 let text = transcript_lines(&state, 200)[0].to_string();
5341 assert!(text.contains(&format!("$ {}…", "a".repeat(100))));
5342 assert!(!text.contains(&"a".repeat(101)));
5343 assert!(!text.contains("output"));
5344 }
5345
5346 #[test]
5347 fn cmd_lines_remain_compact_for_consecutive_calls() {
5348 let history = vec![
5349 SessionHistoryRecord::Message {
5350 timestamp: 1,
5351 message: ChatMessage::assistant(
5352 String::new(),
5353 vec![
5354 crate::model::ChatToolCall {
5355 id: "call-first".to_owned(),
5356 name: "cmd".to_owned(),
5357 arguments: r#"{"command":"first"}"#.to_owned(),
5358 },
5359 crate::model::ChatToolCall {
5360 id: "call-second".to_owned(),
5361 name: "cmd".to_owned(),
5362 arguments: r#"{"command":"second"}"#.to_owned(),
5363 },
5364 ],
5365 ),
5366 },
5367 SessionHistoryRecord::Message {
5368 timestamp: 2,
5369 message: ChatMessage::tool(
5370 "call-first".to_owned(),
5371 "cmd".to_owned(),
5372 serde_json::json!({"exit_code": 0}).to_string(),
5373 ),
5374 },
5375 SessionHistoryRecord::Message {
5376 timestamp: 3,
5377 message: ChatMessage::tool(
5378 "call-second".to_owned(),
5379 "cmd".to_owned(),
5380 serde_json::json!({"exit_code": 0}).to_string(),
5381 ),
5382 },
5383 ];
5384 let state = UiState::from_history(&history, "secret", "model", None, false);
5385 let lines = transcript_lines(&state, 200);
5386 assert_eq!(lines[0].to_string(), "✓ cmd $ first");
5387 assert_eq!(lines[2].to_string(), "✓ cmd $ second");
5388 }
5389
5390 #[test]
5391 fn cmd_status_styles_use_success_failure_and_pending_colors() {
5392 assert_eq!(
5393 cmd_result_status(&serde_json::json!({"exit_code": 0})).2.fg,
5394 Some(TOOL_SUCCESS_COLOR)
5395 );
5396 assert_eq!(
5397 cmd_result_status(&serde_json::json!({"exit_code": 1})).2.fg,
5398 Some(TOOL_FAILURE_COLOR)
5399 );
5400 assert_eq!(
5401 cmd_tool_segments(
5402 "call-1",
5403 "{\"command\":\"pwd\"}",
5404 None,
5405 &UiState::from_history(&[], "secret", "model", None, false)
5406 )[0]
5407 .1
5408 .fg,
5409 Some(PENDING_TOOL_COLOR)
5410 );
5411 }
5412
5413 #[test]
5414 fn recognized_skill_trigger_is_highlighted_but_arguments_remain_default_colored() {
5415 let trigger = active_skill_trigger("/release-notes v1.2.0", &["release-notes".to_owned()]);
5416 assert_eq!(trigger, Some("/release-notes"));
5417 assert_eq!(SKILL_TRIGGER_COLOR, Color::Rgb(80, 255, 245));
5418
5419 let lines = styled_text_lines(
5420 "/release-notes v1.2.0",
5421 trigger,
5422 80,
5423 Style::default().fg(Color::White),
5424 );
5425 assert_eq!(lines.len(), 1);
5426 assert_eq!(lines[0].to_string(), "/release-notes v1.2.0");
5427 assert_eq!(lines[0].spans[0].content, "/release-notes");
5428 assert_eq!(lines[0].spans[0].style.fg, Some(SKILL_TRIGGER_COLOR));
5429 assert_eq!(lines[0].spans[1].content, " v1.2.0");
5430 assert_eq!(lines[0].spans[1].style.fg, Some(Color::White));
5431 }
5432
5433 #[test]
5434 fn draw_renders_an_active_skill_trigger_in_cyan() {
5435 let mut state = UiState::from_history(&[], "secret", "model", None, false)
5436 .with_skill_names(vec!["release-notes".to_owned()]);
5437 state.input = "/release-notes v1.2.0".to_owned();
5438 state.cursor = state.input.chars().count();
5439
5440 let mut terminal =
5441 Terminal::new(ratatui::backend::TestBackend::new(40, 10)).expect("test terminal");
5442 terminal
5443 .draw(|frame| draw(frame, &state))
5444 .expect("draw input");
5445
5446 let buffer = terminal.backend().buffer();
5449 let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 40, 10)));
5450 let prompt_area = prompt_area(input_area, &state);
5451 let input_x = prompt_area.x;
5452 let input_y = prompt_area.y;
5453 assert_eq!(buffer[(input_x, input_y)].fg, SKILL_TRIGGER_COLOR);
5454 assert_eq!(
5455 buffer[(input_x + "/release-notes".chars().count() as u16, input_y)].fg,
5456 Color::White
5457 );
5458 }
5459
5460 #[test]
5461 fn main_agent_status_omits_activity_animation_on_idle_and_busy_glass() {
5462 let mut state =
5463 UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
5464 let area = Rect::new(0, 0, 80, 10);
5465 let mut terminal =
5466 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5467 .expect("test terminal");
5468
5469 terminal
5470 .draw(|frame| draw(frame, &state))
5471 .expect("draw ready status");
5472 let viewport = tui_viewport(area);
5473 let status_area = ui_layout(&state, viewport).5;
5474 let idle = "model · default | Context: 81/100 (81%) █████████░";
5475 let buffer = terminal.backend().buffer();
5476 let idle_columns = status_area.x..status_area.x + idle.chars().count() as u16;
5477 let idle_row = idle_columns
5478 .clone()
5479 .map(|x| buffer[(x, status_area.y)].symbol())
5480 .collect::<String>();
5481 assert_eq!(idle_row, idle);
5482 for x in idle_columns {
5483 assert_eq!(buffer[(x, status_area.y)].fg, Color::Rgb(144, 144, 148));
5484 }
5485
5486 state.set_status("working");
5487 state.busy = true;
5488 state.activity_transition = None;
5489 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
5490 terminal
5491 .draw(|frame| draw(frame, &state))
5492 .expect("draw working status");
5493 let status_area = ui_layout(&state, viewport).5;
5494 let expected = "model · default | Context: 81/100 (81%) █████████░";
5495 let buffer = terminal.backend().buffer();
5496 let status_columns = status_area.x..status_area.x + expected.chars().count() as u16;
5497 let rendered = status_columns
5498 .clone()
5499 .map(|x| buffer[(x, status_area.y)].symbol())
5500 .collect::<String>();
5501 assert_eq!(rendered, expected);
5502 assert!(
5503 status_columns
5504 .clone()
5505 .any(|x| buffer[(x, status_area.y)].bg != CONSOLE_BACKGROUND),
5506 "the busy status line renders over bright glass"
5507 );
5508 for x in status_columns {
5509 assert_eq!(buffer[(x, status_area.y)].fg, Color::Rgb(144, 144, 148));
5510 }
5511 }
5512
5513 #[test]
5514 fn terminal_focus_events_control_cursor_visibility() {
5515 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5516
5517 assert!(handle_terminal_focus_event(&mut state, &Event::FocusLost));
5518 assert!(!state.terminal_focused);
5519 assert!(handle_terminal_focus_event(&mut state, &Event::FocusGained));
5520 assert!(state.terminal_focused);
5521 assert!(!handle_terminal_focus_event(
5522 &mut state,
5523 &Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
5524 ));
5525 assert!(state.terminal_focused);
5526 }
5527
5528 #[test]
5529 fn unfocused_busy_glow_keeps_the_hardware_cursor_hidden() {
5530 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5531 state.set_status("working");
5532 state.set_busy(true);
5533 state.terminal_focused = false;
5534 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
5535
5536 let mut terminal =
5537 Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
5538 terminal
5539 .draw(|frame| draw(frame, &state))
5540 .expect("draw busy glow");
5541
5542 assert!(
5543 !terminal.backend().cursor_visible(),
5544 "the glow redraw must not re-show the terminal cursor"
5545 );
5546 }
5547
5548 #[test]
5549 fn cjk_input_keeps_the_terminal_cursor_in_the_prompt_without_resetting_activity() {
5550 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5551 state.set_status("working");
5552 state.busy = true;
5553 state.input = "한글".to_owned();
5554 state.cursor = state.input.chars().count();
5555 let activity_started_at = state.activity_started_at;
5556 let tool_animation_epoch = state.tool_animation_epoch;
5557 let sample_at = Instant::now();
5558 let activity_before = state.activity_levels_at(sample_at);
5559
5560 state.input_changed();
5563 assert_eq!(state.activity_started_at, activity_started_at);
5564 assert_eq!(state.tool_animation_epoch, tool_animation_epoch);
5565 assert_eq!(state.activity_levels_at(sample_at), activity_before);
5566
5567 let area = Rect::new(0, 0, 80, 10);
5568 let mut terminal =
5569 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5570 .expect("test terminal");
5571 terminal
5572 .draw(|frame| draw(frame, &state))
5573 .expect("draw CJK input while working");
5574 let (_, _, _, _, input_area, status_area) = ui_layout(&state, tui_viewport(area));
5575 assert_ne!(input_area.y, status_area.y);
5576 let prompt_area = prompt_area(input_area, &state);
5577 assert!(terminal.backend().cursor_visible());
5578 terminal.backend_mut().assert_cursor_position((
5579 prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
5580 prompt_area.y,
5581 ));
5582 }
5583
5584 #[test]
5585 fn transcript_and_console_are_separated_by_one_blank_row() {
5586 let state = UiState::from_history(&[], "secret", "model", None, false);
5587 let area = Rect::new(0, 0, 80, 10);
5588 let mut terminal =
5589 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5590 .expect("test terminal");
5591
5592 terminal
5593 .draw(|frame| draw(frame, &state))
5594 .expect("draw separated transcript and console");
5595
5596 let (transcript, _, _, _, console, _) = ui_layout(&state, tui_viewport(area));
5597 assert_eq!(transcript.y + transcript.height + 1, console.y);
5598 let gap_y = console.y - 1;
5599 for x in transcript.x..transcript.x + transcript.width {
5600 assert_eq!(terminal.backend().buffer()[(x, gap_y)].symbol(), " ");
5601 assert_eq!(terminal.backend().buffer()[(x, gap_y)].bg, Color::Reset);
5602 }
5603 }
5604
5605 #[test]
5606 fn idle_console_has_external_gutters_uniform_background_and_no_borders() {
5607 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5608 state.input = "prompt".to_owned();
5609 state.cursor = state.input.chars().count();
5610 let area = Rect::new(0, 0, 80, 10);
5611 let mut terminal =
5612 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5613 .expect("test terminal");
5614
5615 terminal
5616 .draw(|frame| draw(frame, &state))
5617 .expect("draw idle console");
5618
5619 let input_area = ui_layout(&state, tui_viewport(area)).4;
5620 let prompt_area = prompt_area(input_area, &state);
5621 let buffer = terminal.backend().buffer();
5622 let bottom_y = area.y + area.height - 1;
5623 assert_eq!(buffer[(area.x, bottom_y)].bg, Color::Reset);
5624 assert_eq!(buffer[(area.x + area.width - 1, bottom_y)].bg, Color::Reset);
5625 for y in input_area.y..input_area.y + input_area.height {
5626 assert_eq!(buffer[(0, y)].bg, Color::Reset);
5627 assert_eq!(buffer[(79, y)].bg, Color::Reset);
5628 for x in input_area.x..input_area.x + input_area.width {
5629 assert_eq!(buffer[(x, y)].bg, CONSOLE_BACKGROUND);
5630 }
5631 }
5632 assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), " ");
5633 assert_eq!(
5634 buffer[(input_area.x + input_area.width - 1, input_area.y)].symbol(),
5635 " "
5636 );
5637 assert_eq!(
5638 buffer[(input_area.x, input_area.y + input_area.height - 1)].symbol(),
5639 " "
5640 );
5641 assert_eq!(
5642 buffer[(
5643 input_area.x + input_area.width - 1,
5644 input_area.y + input_area.height - 1
5645 )]
5646 .symbol(),
5647 " "
5648 );
5649 assert_eq!(buffer[(prompt_area.x, prompt_area.y)].symbol(), "p");
5650 assert_eq!(buffer[(prompt_area.x, prompt_area.y)].fg, Color::White);
5651 terminal.backend_mut().assert_cursor_position((
5652 prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
5653 prompt_area.y,
5654 ));
5655 }
5656
5657 #[test]
5658 fn busy_console_keeps_glass_inside_the_bottom_half_ellipse() {
5659 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5660 state.busy = true;
5661 state.set_status("working");
5662 state.activity_transition = None;
5663 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
5664 let area = Rect::new(0, 0, 200, 10);
5665 let mut terminal =
5666 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5667 .expect("test terminal");
5668
5669 terminal
5670 .draw(|frame| draw(frame, &state))
5671 .expect("draw busy console");
5672
5673 let viewport = tui_viewport(area);
5674 let (chat_area, _, _, _, input_area, _) = ui_layout(&state, viewport);
5675 let reflection_y = input_area.y - 1;
5676 let glow_floor_y = area.y + area.height - 1;
5677 let edge_y = input_area.y + input_area.height - 1;
5678 let floor = input_area.x + input_area.width / 2;
5679 let left_edge = input_area.x;
5680 let left_outer = left_edge - 1;
5681 let right_edge = input_area.x + input_area.width - 1;
5682 let right_outer = right_edge + 1;
5683 let widening_sample = input_area.x - 22;
5684 let buffer = terminal.backend().buffer();
5685 assert_eq!(chat_area.y + chat_area.height + 1, input_area.y);
5686 assert_eq!(
5687 buffer[(floor, reflection_y)].symbol(),
5688 CONSOLE_REFLECTION_GLYPH,
5689 "the active console reflects along the bottom of the existing gap row"
5690 );
5691 assert_ne!(buffer[(floor, reflection_y)].fg, Color::Reset);
5692 assert_ne!(buffer[(floor, glow_floor_y)].bg, Color::Reset);
5693 assert_ne!(buffer[(left_outer, edge_y)].bg, Color::Reset);
5694 assert_ne!(buffer[(right_outer, edge_y)].bg, Color::Reset);
5695 let left_extent = left_edge - GLOW_HORIZONTAL_SPREAD;
5696 let right_extent = right_edge + GLOW_HORIZONTAL_SPREAD;
5697 assert_ne!(buffer[(left_extent, glow_floor_y)].bg, Color::Reset);
5698 assert_ne!(buffer[(right_extent, glow_floor_y)].bg, Color::Reset);
5699 assert_eq!(buffer[(left_extent - 1, glow_floor_y)].bg, Color::Reset);
5700 assert_eq!(buffer[(right_extent + 1, glow_floor_y)].bg, Color::Reset);
5701 assert_eq!(buffer[(widening_sample, input_area.y)].bg, Color::Reset);
5702 assert_ne!(buffer[(widening_sample, glow_floor_y)].bg, Color::Reset);
5703 assert_eq!(buffer[(area.x, glow_floor_y)].bg, Color::Reset);
5704 for y in input_area.y..input_area.y + input_area.height {
5705 for x in input_area.x..input_area.x + input_area.width {
5706 assert_ne!(buffer[(x, y)].bg, Color::Reset);
5707 }
5708 }
5709 for y in input_area.y..input_area.y + input_area.height {
5710 for x in input_area.x..input_area.x + input_area.width {
5711 assert_ne!(buffer[(x, y)].bg, Color::Reset);
5712 assert!(
5713 color_distance(CONSOLE_BACKGROUND, buffer[(x, y)].bg)
5714 < color_distance(TUI_GLOW_BACKGROUND, buffer[(floor, glow_floor_y)].bg)
5715 );
5716 }
5717 }
5718 }
5719
5720 #[test]
5721 fn narrow_busy_console_keeps_both_endpoint_blooms_exposed() {
5722 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5723 state.busy = true;
5724 state.set_status("working");
5725 state.activity_transition = None;
5726 state.console_animation_epoch = Instant::now() - CONSOLE_BOUNDARY_CYCLE / 4;
5727 let area = Rect::new(0, 0, 10, 10);
5728 let mut terminal =
5729 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5730 .expect("test terminal");
5731
5732 terminal
5733 .draw(|frame| draw(frame, &state))
5734 .expect("draw narrow TUI");
5735
5736 let input = ui_layout(&state, tui_viewport(area)).4;
5737 assert_eq!(input.width, 4, "test the minimum inset console width");
5738 let left_edge = input.x;
5739 let right_edge = input.x + input.width - 1;
5740 let buffer = terminal.backend().buffer();
5741 for y in input.y..input.y + input.height {
5742 for (edge, outer) in [(left_edge, left_edge - 1), (right_edge, right_edge + 1)] {
5743 assert_ne!(
5744 buffer[(outer, y)].bg,
5745 Color::Reset,
5746 "the outer glow cell is present at ({outer}, {y})"
5747 );
5748 assert_ne!(
5749 buffer[(edge, y)].bg,
5750 buffer[(outer, y)].bg,
5751 "narrow console glass does not spill into the exposed bloom at ({edge}, {y})"
5752 );
5753 }
5754 }
5755 }
5756
5757 #[test]
5758 fn only_known_leading_skill_commands_activate_input_highlighting() {
5759 let skills = ["release-notes".to_owned()];
5760 assert_eq!(
5761 active_skill_trigger("/missing", &skills),
5762 None,
5763 "unknown commands are rejected by the turn engine and must not look active"
5764 );
5765 assert_eq!(
5766 active_skill_trigger("/skill:release-notes", &skills),
5767 None,
5768 "the removed /skill: wrapper must not look active"
5769 );
5770 assert_eq!(
5771 active_skill_trigger("write /release-notes", &skills),
5772 None,
5773 "only the command prefix accepted by the turn engine is active"
5774 );
5775 assert_eq!(active_skill_trigger("/", &skills), None);
5776 }
5777
5778 #[test]
5779 fn highlighted_skill_trigger_remains_styled_when_wrapped() {
5780 let input = "/release-notes argument";
5781 let trigger = active_skill_trigger(input, &["release-notes".to_owned()]);
5782 let lines = styled_text_lines(input, trigger, 8, Style::default().fg(Color::White));
5783 let highlighted = lines
5784 .iter()
5785 .flat_map(|line| line.spans.iter())
5786 .filter(|span| span.style.fg == Some(SKILL_TRIGGER_COLOR))
5787 .map(|span| span.content.as_ref())
5788 .collect::<String>();
5789 assert_eq!(highlighted, "/release-notes");
5790 }
5791
5792 #[test]
5793 fn input_has_no_prompt_marker_and_trailing_newline_is_visible() {
5794 assert_eq!(input_prompt("hello"), "hello");
5795 assert_eq!(wrap_text("hello\n", 80), vec!["hello", ""]);
5796 }
5797
5798 #[test]
5799 fn input_prompt_wraps_to_multiple_rows_when_long() {
5800 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5801 state.input = "abcdefghij".to_owned();
5802 let rows = input_visible_rows(&state, 5);
5804 assert!(rows >= 2);
5805 }
5806
5807 #[test]
5808 fn cursor_editing_moves_by_characters_and_preserves_unicode() {
5809 let mut input = "가나".to_owned();
5810 let mut cursor = input.chars().count();
5811 cursor -= 1;
5812 insert_at_cursor(&mut input, &mut cursor, 'x');
5813 assert_eq!(input, "가x나");
5814 assert_eq!(cursor, 2);
5815 assert!(remove_before_cursor(&mut input, &mut cursor));
5816 assert_eq!(input, "가나");
5817 assert_eq!(cursor, 1);
5818 }
5819
5820 #[test]
5821 fn cursor_row_tracks_newlines_and_wrapping() {
5822 assert_eq!(cursor_row("hello\nworld", 6, 80), 1);
5823 assert_eq!(cursor_row("abcdef", 4, 3), 1);
5824 }
5825
5826 #[test]
5827 fn shift_enter_inserts_at_the_cursor_and_moves_it_to_the_new_row() {
5828 let mut input = "beforeafter".to_owned();
5829 let mut cursor = 6;
5830 insert_at_cursor(&mut input, &mut cursor, '\n');
5831
5832 assert_eq!(input, "before\nafter");
5833 assert_eq!(cursor, 7);
5834 assert_eq!(cursor_row(&input, cursor, 80), 1);
5835 }
5836
5837 #[test]
5838 fn shift_enter_renders_the_cursor_on_the_new_input_row() {
5839 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5840 state.input = "beforeafter".to_owned();
5841 state.cursor = 6;
5842 insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
5843
5844 let mut terminal =
5845 Terminal::new(ratatui::backend::TestBackend::new(20, 10)).expect("test terminal");
5846 terminal
5847 .draw(|frame| draw(frame, &state))
5848 .expect("draw input cursor");
5849
5850 let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 20, 10)));
5853 let prompt_area = prompt_area(input_area, &state);
5854 terminal
5855 .backend_mut()
5856 .assert_cursor_position((prompt_area.x, prompt_area.y + 1));
5857 }
5858
5859 #[test]
5860 fn tool_results_attach_to_their_matching_call_after_consecutive_calls() {
5861 let history = vec![
5862 SessionHistoryRecord::Message {
5863 timestamp: 1,
5864 message: ChatMessage::assistant(
5865 String::new(),
5866 vec![
5867 crate::model::ChatToolCall {
5868 id: "call-first".to_owned(),
5869 name: "cmd".to_owned(),
5870 arguments: r#"{"command":"first"}"#.to_owned(),
5871 },
5872 crate::model::ChatToolCall {
5873 id: "call-second".to_owned(),
5874 name: "cmd".to_owned(),
5875 arguments: r#"{"command":"second"}"#.to_owned(),
5876 },
5877 ],
5878 ),
5879 },
5880 SessionHistoryRecord::Message {
5881 timestamp: 2,
5882 message: ChatMessage::tool(
5883 "call-first".to_owned(),
5884 "cmd".to_owned(),
5885 serde_json::json!({"stdout":"first result","stderr":""}).to_string(),
5886 ),
5887 },
5888 SessionHistoryRecord::Message {
5889 timestamp: 3,
5890 message: ChatMessage::tool(
5891 "call-second".to_owned(),
5892 "cmd".to_owned(),
5893 serde_json::json!({"stdout":"second result","stderr":""}).to_string(),
5894 ),
5895 },
5896 ];
5897
5898 let state = UiState::from_history(&history, "secret", "model", None, false);
5899 let lines = transcript_lines(&state, 200);
5900 assert_eq!(
5901 lines.len(),
5902 3,
5903 "only the two call lines and their separator remain"
5904 );
5905 assert_eq!(lines[0].to_string(), "✓ cmd $ first");
5906 assert_eq!(lines[2].to_string(), "✓ cmd $ second");
5907 }
5908 #[test]
5909 fn clipped_slash_picker_uses_its_actual_item_rows_for_the_focused_item() {
5910 let mut state = UiState::from_history(&[], "secret", "model", None, false)
5911 .with_skill_names(
5912 ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
5913 .into_iter()
5914 .map(str::to_owned)
5915 .collect(),
5916 );
5917 state.input = "/".to_owned();
5918 state.input_changed();
5919 state.skill_picker_focus = 5;
5920 let mut terminal =
5921 Terminal::new(ratatui::backend::TestBackend::new(30, 5)).expect("test terminal");
5922 terminal
5923 .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 5)))
5924 .expect("draw clipped skill picker");
5925
5926 let buffer = terminal.backend().buffer();
5927 let item_rows = (2..4)
5928 .map(|y| (2..28).map(|x| buffer[(x, y)].symbol()).collect::<String>())
5929 .collect::<Vec<_>>();
5930 assert!(item_rows[0].starts_with("/deploy"));
5931 assert!(item_rows[1].starts_with("/doctor"));
5932 assert_eq!(buffer[(2, 3)].fg, QUEUED_MESSAGE_COLOR);
5933 assert!(buffer[(2, 3)].modifier.contains(Modifier::BOLD));
5934 }
5935}
5936
5937#[cfg(test)]
5938mod skill_picker_tests {
5939 use super::*;
5940
5941 fn skill_names() -> Vec<String> {
5942 ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
5943 .into_iter()
5944 .map(str::to_owned)
5945 .collect()
5946 }
5947
5948 #[test]
5949 fn built_in_commands_share_the_slash_catalog_without_becoming_skills() {
5950 assert_eq!(
5951 command_names(vec!["release-notes".to_owned(), "settings".to_owned()]),
5952 vec!["exit", "release-notes", "settings"]
5953 );
5954 assert_eq!(
5955 builtin_command("/settings ignored arguments"),
5956 Some(BuiltinCommand::Settings)
5957 );
5958 assert_eq!(builtin_command(" /exit "), Some(BuiltinCommand::Exit));
5959 assert_eq!(builtin_command("/settings-extra"), None);
5960 }
5961
5962 #[test]
5963 fn settings_viewport_follows_focus_instead_of_truncating_the_catalog_head() {
5964 assert_eq!(selection_range(30, 0, 12), 0..12);
5965 assert_eq!(selection_range(30, 11, 12), 0..12);
5966 assert_eq!(selection_range(30, 12, 12), 1..13);
5967 assert_eq!(selection_range(30, 29, 12), 18..30);
5968 }
5969
5970 #[test]
5971 fn model_selection_uses_advertised_efforts_and_preserves_the_current_choice() {
5972 let mut state = UiState::from_history(&[], "secret", "old", Some("medium"), false);
5973 state.open_catalog(Ok(vec![ProviderModel {
5974 id: "openai/gpt-5.6-sol".to_owned(),
5975 efforts: Some(vec![
5976 "max".to_owned(),
5977 "high".to_owned(),
5978 "medium".to_owned(),
5979 "low".to_owned(),
5980 ]),
5981 }]));
5982 state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
5983
5984 let SettingsState::Effort { model, focus, .. } =
5985 state.settings.as_ref().expect("effort picker")
5986 else {
5987 panic!("model selection should open the effort picker");
5988 };
5989 assert_eq!(model.id, "openai/gpt-5.6-sol");
5990 assert_eq!(*focus, 3, "default occupies index zero before medium");
5991
5992 let selected = state
5993 .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
5994 .expect("effort selection");
5995 assert_eq!(
5996 selected,
5997 ("openai/gpt-5.6-sol".to_owned(), Some("medium".to_owned()))
5998 );
5999 }
6000
6001 #[test]
6002 fn effort_default_selection_does_not_shift_to_the_first_advertised_effort() {
6003 let mut state = UiState::from_history(&[], "secret", "old", None, false);
6004 state.open_catalog(Ok(vec![ProviderModel {
6005 id: "model".to_owned(),
6006 efforts: Some(vec!["high".to_owned(), "low".to_owned()]),
6007 }]));
6008 state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6009
6010 let selected = state
6011 .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
6012 .expect("default effort selection");
6013 assert_eq!(selected, ("model".to_owned(), None));
6014 }
6015
6016 #[test]
6017 fn reasoning_indicator_changes_to_complete_and_stays_dark_gray() {
6018 let mut state = UiState::from_history(&[], "secret", "model", None, false);
6019 state.show_thinking();
6020
6021 let active_lines = transcript_lines(&state, 80);
6022 let active = active_lines.last().expect("reasoning line");
6023 assert!(active.to_string().starts_with("Reasoning... "));
6024 assert_eq!(active.style.fg, Some(Color::DarkGray));
6025
6026 state.complete_reasoning();
6027 let complete_lines = transcript_lines(&state, 80);
6028 let complete = complete_lines.last().expect("complete line");
6029 assert_eq!(complete.to_string(), "Reasoning Complete");
6030 assert_eq!(complete.style.fg, Some(Color::DarkGray));
6031 }
6032
6033 #[test]
6034 fn slash_picker_filters_only_leading_command_text_and_hides_without_matches() {
6035 let names = skill_names();
6036 assert_eq!(
6037 matching_skill_names("/", &names),
6038 vec!["alpha", "beta", "build", "charlie", "deploy", "doctor"]
6039 );
6040 assert_eq!(matching_skill_names("/b", &names), vec!["beta", "build"]);
6041 assert!(matching_skill_names("/missing", &names).is_empty());
6042 assert!(matching_skill_names("message /b", &names).is_empty());
6043 assert!(matching_skill_names("/beta arguments", &names).is_empty());
6044 }
6045
6046 #[test]
6047 fn slash_picker_focuses_the_top_match_and_moves_within_filtered_results() {
6048 let mut state = UiState::from_history(&[], "secret", "model", None, false)
6049 .with_skill_names(skill_names());
6050 state.input = "/b".to_owned();
6051 state.input_changed();
6052
6053 assert!(state.skill_picker_visible());
6054 assert_eq!(state.skill_picker_focus, 0);
6055 assert!(state.move_skill_picker(true));
6056 assert_eq!(state.skill_picker_focus, 1);
6057 assert!(state.move_skill_picker(true));
6058 assert_eq!(state.skill_picker_focus, 1, "focus does not leave the list");
6059 assert!(state.move_skill_picker(false));
6060 assert_eq!(state.skill_picker_focus, 0);
6061
6062 state.input = "/missing".to_owned();
6063 state.input_changed();
6064 assert!(!state.skill_picker_visible());
6065 assert!(!state.move_skill_picker(true));
6066 }
6067
6068 #[test]
6069 fn focused_builtins_are_distinguished_from_skills() {
6070 let mut state = UiState::from_history(&[], "secret", "model", None, false)
6071 .with_skill_names(command_names(skill_names()));
6072 state.input = "/se".to_owned();
6073 state.input_changed();
6074 assert_eq!(
6075 state.focused_builtin_command(),
6076 Some(BuiltinCommand::Settings)
6077 );
6078
6079 state.input = "/be".to_owned();
6080 state.input_changed();
6081 assert_eq!(state.focused_builtin_command(), None);
6082 }
6083
6084 #[test]
6085 fn selecting_the_focused_skill_leaves_the_completed_command_ready_to_send() {
6086 let mut state = UiState::from_history(&[], "secret", "model", None, false)
6087 .with_skill_names(skill_names());
6088 state.input = "/b".to_owned();
6089 state.input_changed();
6090 state.move_skill_picker(true);
6091
6092 assert!(state.select_focused_skill());
6093 assert_eq!(state.input, "/build");
6094 assert_eq!(state.cursor, "/build".chars().count());
6095 assert!(
6096 !state.skill_picker_visible(),
6097 "the first Enter completes the input rather than sending it"
6098 );
6099 assert!(
6100 !state.select_focused_skill(),
6101 "a second Enter follows the normal send/attachment path"
6102 );
6103 }
6104
6105 #[test]
6106 fn slash_picker_overlays_without_reflowing_the_transcript_when_match_count_changes() {
6107 let mut state = UiState::from_history(&[], "secret", "model", None, false)
6108 .with_skill_names(skill_names());
6109 let area = Rect::new(0, 0, 40, 16);
6110 state.transcript = (0..20)
6111 .map(|index| TranscriptItem::Assistant(format!("message {index}")))
6112 .collect();
6113
6114 state.input = "/a".to_owned();
6115 state.input_changed();
6116 let (narrow_chat, narrow_picker, _, _, narrow_input, _) = ui_layout(&state, area);
6117 let narrow_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
6118
6119 state.input = "/".to_owned();
6120 state.input_changed();
6121 let (broad_chat, broad_picker, _, _, broad_input, _) = ui_layout(&state, area);
6122 let broad_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
6123
6124 assert_ne!(
6125 narrow_picker, broad_picker,
6126 "the overlay may fit its contents"
6127 );
6128 assert_eq!(narrow_chat, broad_chat);
6129 assert_eq!(narrow_input, broad_input);
6130 assert_eq!(
6131 narrow_scroll, broad_scroll,
6132 "the overlay does not reduce the transcript viewport"
6133 );
6134 }
6135
6136 #[test]
6137 fn slash_picker_keeps_the_focused_item_in_its_five_row_viewport() {
6138 assert_eq!(selection_range(20, 0, 5), 0..5);
6139 assert_eq!(selection_range(20, 4, 5), 0..5);
6140 assert_eq!(selection_range(20, 5, 5), 1..6);
6141 assert_eq!(selection_range(20, 19, 5), 15..20);
6142 }
6143
6144 #[test]
6145 fn is_inside_tmux_detection() {
6146 std::env::set_var("TERM_PROGRAM", "tmux");
6147 assert!(is_inside_tmux());
6148 std::env::set_var("TERM_PROGRAM", "TMUX");
6149 assert!(is_inside_tmux());
6150 std::env::set_var("TERM_PROGRAM", "ghostty");
6151 assert!(!is_inside_tmux());
6152 std::env::remove_var("TERM_PROGRAM");
6153 assert!(!is_inside_tmux());
6154 }
6155
6156 #[test]
6157 fn tui_glow_background_is_ghostty_base_101216() {
6158 assert_eq!(TUI_GLOW_BACKGROUND_RGB, (16, 18, 22));
6159 assert_eq!(TUI_GLOW_BACKGROUND, Color::Rgb(16, 18, 22));
6160 }
6161
6162 #[test]
6163 fn idle_console_has_external_gutters_uniform_background_and_no_borders() {
6164 let mut state = UiState::from_history(&[], "secret", "model", None, false);
6165 state.input = "prompt".to_owned();
6166 state.cursor = state.input.chars().count();
6167 let area = Rect::new(0, 0, 80, 10);
6168 let mut terminal =
6169 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
6170 .expect("test terminal");
6171
6172 terminal
6173 .draw(|frame| draw(frame, &state))
6174 .expect("draw idle console");
6175
6176 let input_area = ui_layout(&state, tui_viewport(area)).4;
6177 let prompt_area = prompt_area(input_area, &state);
6178 let buffer = terminal.backend().buffer();
6179 let bottom_y = area.y + area.height - 1;
6180 assert_eq!(buffer[(area.x, bottom_y)].bg, Color::Reset);
6181 assert_eq!(buffer[(area.x + area.width - 1, bottom_y)].bg, Color::Reset);
6182 for y in input_area.y..input_area.y + input_area.height {
6183 assert_eq!(buffer[(0, y)].bg, Color::Reset);
6184 assert_eq!(buffer[(79, y)].bg, Color::Reset);
6185 for x in input_area.x..input_area.x + input_area.width {
6186 assert_eq!(buffer[(x, y)].bg, CONSOLE_BACKGROUND);
6187 }
6188 }
6189 assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), " ");
6190 assert_eq!(
6191 buffer[(input_area.x + input_area.width - 1, input_area.y)].symbol(),
6192 " "
6193 );
6194 assert_eq!(
6195 buffer[(input_area.x, input_area.y + input_area.height - 1)].symbol(),
6196 " "
6197 );
6198 assert_eq!(
6199 buffer[(
6200 input_area.x + input_area.width - 1,
6201 input_area.y + input_area.height - 1
6202 )]
6203 .symbol(),
6204 " "
6205 );
6206 assert_eq!(buffer[(prompt_area.x, prompt_area.y)].symbol(), "p");
6207 assert_eq!(buffer[(prompt_area.x, prompt_area.y)].fg, Color::White);
6208 terminal.backend_mut().assert_cursor_position((
6209 prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
6210 prompt_area.y,
6211 ));
6212 }
6213
6214 #[test]
6215 fn slash_picker_is_rendered_immediately_above_the_input() {
6216 let mut state = UiState::from_history(&[], "secret", "model", None, false)
6217 .with_skill_names(skill_names());
6218 state.input = "/".to_owned();
6219 state.input_changed();
6220 let mut terminal =
6221 Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
6222 terminal
6223 .draw(|frame| draw(frame, &state))
6224 .expect("draw TUI");
6225
6226 let buffer = terminal.backend().buffer();
6227 let area = tui_viewport(Rect::new(0, 0, 40, 12));
6228 let (_, picker_area, _, _, input_area, _) = ui_layout(&state, area);
6229 let picker_area = picker_area.expect("picker area");
6230 assert_eq!(picker_area.y + picker_area.height, input_area.y);
6232 for (x, y) in [
6233 (picker_area.x, picker_area.y),
6234 (picker_area.x + picker_area.width - 1, picker_area.y),
6235 (picker_area.x, picker_area.y + picker_area.height - 1),
6236 (
6237 picker_area.x + picker_area.width - 1,
6238 picker_area.y + picker_area.height - 1,
6239 ),
6240 ] {
6241 assert_eq!(buffer[(x, y)].symbol(), " ");
6242 assert_eq!(buffer[(x, y)].bg, SKILL_PICKER_BACKGROUND);
6243 }
6244 assert_eq!(
6245 buffer[(picker_area.x + 1, picker_area.y + 1)].bg,
6246 SKILL_PICKER_BACKGROUND
6247 );
6248 assert_eq!(buffer[(picker_area.x + 2, picker_area.y + 1)].symbol(), "[");
6249 assert_eq!(
6250 buffer[(picker_area.x + 2, picker_area.y + 1)].fg,
6251 QUEUED_MESSAGE_COLOR
6252 );
6253 assert_eq!(buffer[(picker_area.x + 2, picker_area.y + 2)].symbol(), "/");
6254 assert_eq!(
6255 buffer[(picker_area.x + 2, picker_area.y + 2)].fg,
6256 QUEUED_MESSAGE_COLOR
6257 );
6258 assert_eq!(
6259 buffer[(picker_area.x + 1, picker_area.y + picker_area.height - 2)].bg,
6260 SKILL_PICKER_BACKGROUND
6261 );
6262 assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), " ");
6263 assert_eq!(buffer[(input_area.x, input_area.y)].bg, CONSOLE_BACKGROUND);
6264 }
6265
6266 #[test]
6267 fn slash_picker_renders_count_with_bold_focus_on_the_picker_surface() {
6268 let mut state = UiState::from_history(&[], "secret", "model", None, false)
6269 .with_skill_names(skill_names());
6270 state.input = "/".to_owned();
6271 state.input_changed();
6272 let mut terminal =
6273 Terminal::new(ratatui::backend::TestBackend::new(30, 8)).expect("test terminal");
6274 terminal
6275 .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 8)))
6276 .expect("draw skill picker");
6277
6278 let buffer = terminal.backend().buffer();
6279 assert_eq!(buffer[(0, 0)].symbol(), " ");
6280 assert_eq!(buffer[(0, 0)].bg, SKILL_PICKER_BACKGROUND);
6281 assert_eq!(buffer[(2, 1)].symbol(), "[");
6282 assert_eq!(buffer[(2, 1)].fg, QUEUED_MESSAGE_COLOR);
6283 assert_eq!(buffer[(2, 2)].symbol(), "/");
6284 assert_eq!(buffer[(2, 2)].fg, QUEUED_MESSAGE_COLOR);
6285 assert!(buffer[(2, 2)].modifier.contains(Modifier::BOLD));
6286 assert_eq!(buffer[(2, 3)].symbol(), "/");
6287 assert_eq!(buffer[(2, 3)].fg, QUEUED_MESSAGE_COLOR);
6288 assert!(!buffer[(2, 3)].modifier.contains(Modifier::BOLD));
6289 }
6290}
6291
6292#[cfg(test)]
6293mod tmux_keyboard_tests {
6294 use super::*;
6295
6296 #[test]
6297 fn is_inside_tmux_detection() {
6298 std::env::set_var("TERM_PROGRAM", "tmux");
6299 assert!(is_inside_tmux());
6300 std::env::set_var("TERM_PROGRAM", "TMUX");
6301 assert!(is_inside_tmux());
6302 std::env::set_var("TERM_PROGRAM", "ghostty");
6303 assert!(!is_inside_tmux());
6304 std::env::remove_var("TERM_PROGRAM");
6305 assert!(!is_inside_tmux());
6306 }
6307}