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 PROMPT_BACKGROUND: Color = Color::Rgb(24, 24, 27);
62const BUSY_INDICATOR_FADE_BASE_RGB: (u8, u8, u8) = (42, 42, 46);
63const CONSOLE_STATUS_COLOR: Color = Color::Rgb(144, 144, 148);
64const CONSOLE_ACCENT_LAVENDER: (u8, u8, u8) = (145, 70, 220);
65const CONSOLE_ACCENT_TEAL: (u8, u8, u8) = (0, 180, 180);
66const CONSOLE_ACCENT_CYCLE_DURATION: Duration = Duration::from_secs(15);
67const CONSOLE_ACCENT_DESATURATION: f32 = 0.15;
68const SKILL_TRIGGER_COLOR: Color = Color::Rgb(80, 255, 245);
69const PENDING_TOOL_COLOR_RGB: (u8, u8, u8) = (255, 165, 0);
70const PENDING_TOOL_COLOR: Color = Color::Rgb(
71 PENDING_TOOL_COLOR_RGB.0,
72 PENDING_TOOL_COLOR_RGB.1,
73 PENDING_TOOL_COLOR_RGB.2,
74);
75const TOOL_RESULT_SWEEP_DURATION: Duration = Duration::from_millis(600);
78const TOOL_RESULT_CHARACTER_FADE_PORTION: f32 = 0.4;
81const TOOL_SUCCESS_COLOR_RGB: (u8, u8, u8) = (0, 210, 175);
82const TOOL_SUCCESS_COLOR: Color = Color::Rgb(
83 TOOL_SUCCESS_COLOR_RGB.0,
84 TOOL_SUCCESS_COLOR_RGB.1,
85 TOOL_SUCCESS_COLOR_RGB.2,
86);
87const TOOL_FAILURE_COLOR: Color = Color::Rgb(255, 0, 0);
88const TOOL_WARNING_COLOR: Color = Color::Rgb(255, 255, 0);
89const QUEUED_MESSAGE_COLOR: Color = Color::Rgb(150, 255, 245);
90const FLOATING_PANEL_BACKGROUND: Color = Color::Rgb(28, 28, 30);
92const SKILL_PICKER_BACKGROUND: Color = FLOATING_PANEL_BACKGROUND;
93const SECTION_CHROME_COLOR: Color = Color::Rgb(0, 180, 180);
94const SKILL_PICKER_MAX_ROWS: usize = 5;
95const BUILTIN_COMMANDS: [&str; 2] = ["settings", "exit"];
96const SETTINGS_MIN_WIDTH: u16 = 36;
97const SETTINGS_MAX_WIDTH: u16 = 88;
98const SETTINGS_MIN_HEIGHT: u16 = 8;
99const SETTINGS_MAX_HEIGHT: u16 = 22;
100
101pub(crate) fn run<W: Write>(mut harness: Harness, resumed: bool, stdout: W) -> Result<(), String> {
102 let secret = harness.provider.api_key();
103 let context_window = harness
104 .context_window
105 .or_else(|| harness.provider.context_window());
106 harness.context_window = context_window;
107 let context_tokens = estimate_context_tokens(&harness.session.provider_messages());
108 let skill_names = command_names(
109 harness
110 .session
111 .skills
112 .iter()
113 .map(|skill| skill.name.clone())
114 .collect(),
115 );
116 let mut state = UiState::from_history(
117 &harness.session.history,
118 &secret,
119 &harness.session.llm.model,
120 harness.session.llm.effort.as_deref(),
121 resumed,
122 )
123 .with_attached_agents(harness.attached_agents.clone())
124 .with_skill_names(skill_names)
125 .with_context(context_window, context_tokens);
126 let (request_tx, request_rx) = mpsc::channel::<WorkerRequest>();
127 let (message_tx, message_rx) = mpsc::channel::<WorkerMessage>();
128
129 let stdout = stdout;
130 enable_raw_mode().map_err(|error| format!("unable to enable terminal input: {error}"))?;
131 let backend = CrosstermBackend::new(stdout);
132 let terminal = match Terminal::new(backend) {
133 Ok(terminal) => terminal,
134 Err(error) => {
135 let _ = disable_raw_mode();
136 return Err(format!("unable to initialize terminal UI: {error}"));
137 }
138 };
139 let mut terminal_guard = TerminalGuard::new(terminal);
140 let backend = terminal_guard.terminal_mut().backend_mut();
141 if let Err(error) = execute!(
142 backend,
143 EnterAlternateScreen,
144 EnableFocusChange,
145 EnableMouseCapture,
146 Hide
147 ) {
148 return Err(format!("unable to enter terminal UI: {error}"));
149 }
150 let keyboard_enhanced = supports_keyboard_enhancement();
155 if keyboard_enhanced {
156 let _ = execute!(
157 backend,
158 PushKeyboardEnhancementFlags(
159 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
160 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES,
161 )
162 );
163 }
164 let in_tmux = is_inside_tmux();
169 if in_tmux {
170 let _ = backend
171 .write_all(b"\x1b[>4;1m")
172 .and_then(|_| backend.flush());
173 }
174 if keyboard_enhanced {
177 terminal_guard.keyboard_enhancement = true;
178 }
179 if in_tmux {
180 terminal_guard.modify_other_keys = true;
181 }
182 let worker = thread::spawn(move || worker_loop(&mut harness, request_rx, message_tx, resumed));
183
184 let result = event_loop(
185 terminal_guard.terminal_mut(),
186 &mut state,
187 &request_tx,
188 &message_rx,
189 );
190
191 if let Some(token) = state.active_cancel.take() {
192 let _ = token.cancel();
193 }
194 let _ = request_tx.send(WorkerRequest::Shutdown);
195 wait_for_worker(worker, Duration::from_secs(2));
196 drop(terminal_guard);
197 result
198}
199
200fn worker_loop(
201 harness: &mut Harness,
202 requests: Receiver<WorkerRequest>,
203 messages: Sender<WorkerMessage>,
204 resumed: bool,
205) {
206 let mut sink = ChannelSink {
207 sender: messages.clone(),
208 };
209 if sink
210 .emit_event(&ProtocolEvent::Session {
211 session_id: harness.session.id.clone(),
212 resumed,
213 })
214 .is_err()
215 {
216 return;
217 }
218
219 loop {
220 let request = match requests.recv_timeout(EVENT_POLL) {
221 Ok(request) => request,
222 Err(mpsc::RecvTimeoutError::Timeout) => {
223 if harness.has_completed_background_commands() {
224 let cancel = CancellationToken::new();
225 let _ = messages.send(WorkerMessage::Started {
226 cancel: cancel.clone(),
227 user_text: None,
228 });
229 if let Err(error) =
230 harness.handle_background_completions(&mut sink, Some(&cancel))
231 {
232 let message =
233 redact_secret(&error, Some(harness.provider.api_key().as_str()));
234 let _ = sink.emit_event(&ProtocolEvent::Error { message });
235 }
236 let _ = messages.send(WorkerMessage::Finished);
237 }
238 continue;
239 }
240 Err(mpsc::RecvTimeoutError::Disconnected) => break,
241 };
242 match request {
243 WorkerRequest::Turn { text } => {
244 let cancel = CancellationToken::new();
245 let _ = messages.send(WorkerMessage::Started {
246 cancel: cancel.clone(),
247 user_text: Some(text.clone()),
248 });
249 if let Err(error) = harness.handle_message(&text, &mut sink, Some(&cancel)) {
250 let message = redact_secret(&error, Some(harness.provider.api_key().as_str()));
251 let _ = sink.emit_event(&ProtocolEvent::Error { message });
252 }
253 let _ = messages.send(WorkerMessage::Finished);
254 }
255 WorkerRequest::Catalog => {
256 let _ = messages.send(WorkerMessage::Catalog(
257 harness.provider.models().map_err(|error| error.to_string()),
258 ));
259 }
260 WorkerRequest::ApplySettings { model, effort } => {
261 let result = harness.apply_settings(&harness.home.clone(), model, effort);
262 let _ = messages.send(WorkerMessage::SettingsApplied(
263 result,
264 harness.session.llm.model.clone(),
265 harness.session.llm.effort.clone(),
266 harness.context_window,
267 ));
268 }
269 WorkerRequest::Shutdown => break,
270 }
271 }
272}
273
274fn event_loop<W: Write>(
275 terminal: &mut Terminal<CrosstermBackend<W>>,
276 state: &mut UiState,
277 requests: &Sender<WorkerRequest>,
278 messages: &Receiver<WorkerMessage>,
279) -> Result<(), String> {
280 let mut quitting = false;
281 loop {
282 loop {
283 match messages.try_recv() {
284 Ok(WorkerMessage::Event(event)) => state.apply_event(event),
285 Ok(WorkerMessage::Started { cancel, user_text }) => {
286 if let Some(text) = user_text {
287 state.start_queued_user(&text);
288 }
289 state.active_cancel = Some(cancel);
290 state.set_busy(true);
291 state.set_status("working");
292 }
293 Ok(WorkerMessage::Thinking) => state.show_thinking(),
294 Ok(WorkerMessage::ReasoningCompleted) => state.complete_reasoning(),
295 Ok(WorkerMessage::SkillInstructionAttached) => {
296 state.mark_latest_user_skill_attached()
297 }
298 Ok(WorkerMessage::ContextUsage(tokens)) => state.context_tokens = tokens,
299 Ok(WorkerMessage::CompactionStarted) => state.set_status("compacting"),
300 Ok(WorkerMessage::CompactionFinished {
301 tokens_before,
302 tokens_after,
303 }) => {
304 state.context_tokens = tokens_after;
305 state.set_status("working");
306 state.transcript.push(TranscriptItem::Info(format!(
307 "↻ context compacted ({} → {})",
308 format_context_tokens(tokens_before),
309 format_context_tokens(tokens_after)
310 )));
311 }
312 Ok(WorkerMessage::Catalog(result)) => state.open_catalog(result),
313 Ok(WorkerMessage::SettingsApplied(result, model, effort, context_window)) => {
314 state.settings_applied(result, model, effort, context_window)
315 }
316 Ok(WorkerMessage::Finished) => {
317 release_finished_turn(terminal.backend_mut(), state);
318 match state.status.as_str() {
319 "cancelling" => state.set_status("사용자 중단"),
320 "finalizing" => state.set_status("ready"),
321 _ => {}
322 }
323 if quitting {
324 return Ok(());
325 }
326 }
327 Err(TryRecvError::Empty) => break,
328 Err(TryRecvError::Disconnected) => {
329 if state.busy {
330 return Err("TUI worker stopped unexpectedly".to_owned());
331 }
332 return Ok(());
333 }
334 }
335 }
336
337 let _ = execute!(terminal.backend_mut(), Hide);
343
344 terminal
345 .draw(|frame| draw(frame, state))
346 .map_err(|error| format!("unable to render TUI: {error}"))?;
347
348 if quitting {
349 thread::sleep(EVENT_POLL);
350 continue;
351 }
352 if event::poll(EVENT_POLL)
353 .map_err(|error| format!("unable to read terminal input: {error}"))?
354 {
355 let event =
356 event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
357 if handle_terminal_focus_event(state, &event) {
358 continue;
359 }
360 let key = match event {
361 Event::Mouse(mouse) => {
362 let size = terminal
363 .size()
364 .map_err(|error| format!("unable to read terminal size: {error}"))?;
365 let max_scroll = max_scroll_for_area(state, size);
366 handle_mouse_event(state, mouse.kind, max_scroll);
367 continue;
368 }
369 Event::Key(key) => key,
370 _ => continue,
371 };
372 if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
373 continue;
374 }
375 if is_ctrl_c(&key) {
376 if let Some(token) = state.active_cancel.as_ref() {
377 let _ = token.cancel();
378 quitting = true;
379 } else {
380 return Ok(());
381 }
382 continue;
383 }
384 if !state.busy && state.settings.is_some() {
385 if let Some((model, effort)) = state.handle_settings_key(&key) {
386 state.settings = Some(SettingsState::Applying {
387 model: model.clone(),
388 effort: effort.clone(),
389 });
390 requests
391 .send(WorkerRequest::ApplySettings { model, effort })
392 .map_err(|_| "TUI worker is unavailable".to_owned())?;
393 }
394 continue;
395 }
396 if key.code == KeyCode::Esc {
397 if let Some(token) = state.active_cancel.as_ref() {
398 if token.cancel() {
399 state.set_status("cancelling");
400 }
401 }
402 continue;
403 }
404 match key.code {
405 KeyCode::Enter => {
406 if key.modifiers.contains(KeyModifiers::SHIFT)
411 || key.modifiers.contains(KeyModifiers::ALT)
412 {
413 if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
414 insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
415 state.input_changed();
416 }
417 continue;
418 }
419 let text = if let Some(command) = state.focused_builtin_command() {
422 state.input.clear();
423 format!("/{}", command.name())
424 } else {
425 if state.select_focused_skill() {
426 continue;
427 }
428 std::mem::take(&mut state.input)
429 };
430 state.cursor = 0;
431 if let Some(command) = builtin_command(&text) {
432 state.reset_skill_picker();
433 if state.busy {
434 state.transcript.push(TranscriptItem::Info(format!(
435 "/{} is available when the current turn finishes",
436 command.name()
437 )));
438 continue;
439 }
440 match command {
441 BuiltinCommand::Settings => {
442 state.settings = Some(SettingsState::Loading);
443 requests
444 .send(WorkerRequest::Catalog)
445 .map_err(|_| "TUI worker is unavailable".to_owned())?;
446 continue;
447 }
448 BuiltinCommand::Exit => return Ok(()),
449 }
450 }
451 state.reset_skill_picker();
452 if text.trim().is_empty() {
453 continue;
454 }
455 state.auto_scroll = true;
456 state.scroll = 0;
457 state.submit_user(&text);
458 state.set_busy(true);
459 state.set_status("working");
460 requests
461 .send(WorkerRequest::Turn { text })
462 .map_err(|_| "TUI worker is unavailable".to_owned())?;
463 }
464 KeyCode::Tab => {
465 state.select_focused_skill();
468 }
469 KeyCode::Char(character) => {
470 if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
471 insert_at_cursor(&mut state.input, &mut state.cursor, character);
472 state.input_changed();
473 }
474 }
475 KeyCode::Backspace => {
476 if remove_before_cursor(&mut state.input, &mut state.cursor) {
477 state.input_changed();
478 }
479 }
480 KeyCode::Left => {
481 state.cursor = state.cursor.saturating_sub(1);
482 }
483 KeyCode::Right => {
484 state.cursor = (state.cursor + 1).min(state.input.chars().count());
485 }
486 KeyCode::Home => {
487 state.cursor = 0;
488 }
489 KeyCode::End => {
490 state.cursor = state.input.chars().count();
491 }
492 KeyCode::Up => {
493 let size = terminal
494 .size()
495 .map_err(|error| format!("unable to read terminal size: {error}"))?;
496 let area = tui_viewport(Rect::new(0, 0, size.width, size.height));
497 let input_width = ui_prompt_content_width(area).max(1) as usize;
498 if !move_up_from_input(state, input_width) {
499 let max_scroll = max_scroll_for_area(state, size);
500 scroll_up(state, max_scroll);
501 }
502 }
503 KeyCode::Down => {
504 let size = terminal
505 .size()
506 .map_err(|error| format!("unable to read terminal size: {error}"))?;
507 let area = tui_viewport(Rect::new(0, 0, size.width, size.height));
508 let input_width = ui_prompt_content_width(area).max(1) as usize;
509 if !move_down_from_input(state, input_width) {
510 let max_scroll = max_scroll_for_area(state, size);
511 scroll_down(state, max_scroll);
512 }
513 }
514 KeyCode::PageUp => {
515 let size = terminal
516 .size()
517 .map_err(|error| format!("unable to read terminal size: {error}"))?;
518 let max_scroll = max_scroll_for_area(state, size);
519 scroll_up(state, max_scroll);
520 }
521 KeyCode::PageDown => {
522 let size = terminal
523 .size()
524 .map_err(|error| format!("unable to read terminal size: {error}"))?;
525 let max_scroll = max_scroll_for_area(state, size);
526 scroll_down(state, max_scroll);
527 }
528 _ => {}
529 }
530 }
531 }
532}
533
534fn handle_terminal_focus_event(state: &mut UiState, event: &Event) -> bool {
535 match event {
536 Event::FocusGained => state.terminal_focused = true,
537 Event::FocusLost => state.terminal_focused = false,
538 _ => return false,
539 }
540 true
541}
542
543fn is_ctrl_c(key: &KeyEvent) -> bool {
544 key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
545}
546
547fn handle_mouse_event(state: &mut UiState, kind: MouseEventKind, max_scroll: u16) {
548 match kind {
549 MouseEventKind::ScrollUp => scroll_up(state, max_scroll),
550 MouseEventKind::ScrollDown => scroll_down(state, max_scroll),
551 _ => {}
552 }
553}
554
555fn scroll_up(state: &mut UiState, max_scroll: u16) {
556 if state.auto_scroll {
557 state.scroll = max_scroll;
558 state.auto_scroll = false;
559 } else {
560 state.scroll = state.scroll.min(max_scroll);
561 }
562 state.scroll = state.scroll.saturating_sub(3);
563}
564
565fn scroll_down(state: &mut UiState, max_scroll: u16) {
566 if state.auto_scroll {
567 return;
568 }
569 state.scroll = state.scroll.saturating_add(3).min(max_scroll);
570 if state.scroll == max_scroll {
571 state.auto_scroll = true;
574 state.scroll = 0;
575 }
576}
577
578fn wait_for_worker(worker: JoinHandle<()>, grace: Duration) {
579 let deadline = std::time::Instant::now() + grace;
580 while !worker.is_finished() && std::time::Instant::now() < deadline {
581 thread::sleep(Duration::from_millis(5));
582 }
583 if worker.is_finished() {
584 let _ = worker.join();
585 }
586}
587
588struct TerminalGuard<W: Write> {
589 terminal: Option<Terminal<CrosstermBackend<W>>>,
590 keyboard_enhancement: bool,
591 modify_other_keys: bool,
592}
593
594impl<W: Write> TerminalGuard<W> {
595 fn new(terminal: Terminal<CrosstermBackend<W>>) -> Self {
596 Self {
597 terminal: Some(terminal),
598 keyboard_enhancement: false,
599 modify_other_keys: false,
600 }
601 }
602
603 fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {
604 self.terminal
605 .as_mut()
606 .expect("terminal guard is initialized")
607 }
608}
609
610impl<W: Write> Drop for TerminalGuard<W> {
611 fn drop(&mut self) {
612 let Some(mut terminal) = self.terminal.take() else {
613 return;
614 };
615 if self.modify_other_keys {
616 let _ = terminal
617 .backend_mut()
618 .write_all(b"\x1b[>4;0m")
619 .and_then(|_| terminal.backend_mut().flush());
620 }
621 if self.keyboard_enhancement {
622 let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
623 }
624 let _ = terminal.show_cursor();
625 let _ = disable_raw_mode();
626 let _ = execute!(
627 terminal.backend_mut(),
628 DisableFocusChange,
629 DisableMouseCapture,
630 LeaveAlternateScreen,
631 Show
632 );
633 let _ = terminal.backend_mut().flush();
634 }
635}
636
637fn supports_keyboard_enhancement() -> bool {
642 fn env(name: &str) -> Option<String> {
643 std::env::var(name).ok().map(|value| value.to_lowercase())
644 }
645 let term = env("TERM").unwrap_or_default();
646 let program = env("TERM_PROGRAM").unwrap_or_default();
647 if term.starts_with("xterm-kitty")
648 || term.starts_with("ghostty")
649 || term.starts_with("xterm-ghostty")
650 {
651 return true;
652 }
653 if matches!(
654 program.as_str(),
655 "ghostty" | "kitty" | "wezterm" | "alacritty" | "foot" | "footclient" | "iterm.app"
656 ) {
657 return true;
658 }
659 if program == "tmux" {
664 return true;
665 }
666 false
667}
668
669fn is_inside_tmux() -> bool {
671 std::env::var("TERM_PROGRAM")
672 .map(|value| value.eq_ignore_ascii_case("tmux"))
673 .unwrap_or(false)
674}
675
676#[derive(Debug, Clone, Copy, PartialEq, Eq)]
677enum TurnNotification {
678 Completed,
679 Interrupted,
680 Failed,
681}
682
683impl TurnNotification {
684 fn body(self) -> &'static str {
685 match self {
686 Self::Completed => "Turn complete",
687 Self::Interrupted => "Turn interrupted",
688 Self::Failed => "Turn failed",
689 }
690 }
691}
692
693fn turn_notification_for_status(status: &str) -> TurnNotification {
694 match status {
695 "cancelling" | "사용자 중단" => TurnNotification::Interrupted,
696 "error" => TurnNotification::Failed,
697 _ => TurnNotification::Completed,
698 }
699}
700
701fn send_turn_notification<W: Write>(
707 writer: &mut W,
708 notification: TurnNotification,
709) -> io::Result<()> {
710 writer.write_all(b"\x1b]777;notify;Lucy;")?;
711 writer.write_all(notification.body().as_bytes())?;
712 writer.write_all(b"\x07")?;
713 writer.flush()
714}
715
716fn release_finished_turn<W: Write>(writer: &mut W, state: &mut UiState) {
717 let was_busy = state.busy;
718 let notification = turn_notification_for_status(&state.status);
719 state.set_busy(false);
720 state.active_cancel = None;
721 if was_busy {
722 let _ = send_turn_notification(writer, notification);
725 }
726}
727
728enum WorkerRequest {
729 Turn {
730 text: String,
731 },
732 Catalog,
733 ApplySettings {
734 model: String,
735 effort: Option<String>,
736 },
737 Shutdown,
738}
739
740enum WorkerMessage {
741 Event(ProtocolEvent),
742 Started {
743 cancel: CancellationToken,
744 user_text: Option<String>,
745 },
746 Thinking,
747 ReasoningCompleted,
748 SkillInstructionAttached,
749 ContextUsage(usize),
750 CompactionStarted,
751 CompactionFinished {
752 tokens_before: usize,
753 tokens_after: usize,
754 },
755 Catalog(Result<Vec<ProviderModel>, String>),
756 SettingsApplied(Result<(), String>, String, Option<String>, Option<usize>),
757 Finished,
758}
759
760struct ChannelSink {
761 sender: Sender<WorkerMessage>,
762}
763
764impl EventSink for ChannelSink {
765 fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
766 self.sender
767 .send(WorkerMessage::Event(event.clone()))
768 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
769 }
770
771 fn reasoning_started(&mut self) -> io::Result<()> {
772 self.sender
773 .send(WorkerMessage::Thinking)
774 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
775 }
776
777 fn reasoning_completed(&mut self) -> io::Result<()> {
778 self.sender
779 .send(WorkerMessage::ReasoningCompleted)
780 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
781 }
782
783 fn skill_instruction_attached(&mut self, _name: &str) -> io::Result<()> {
784 self.sender
785 .send(WorkerMessage::SkillInstructionAttached)
786 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
787 }
788
789 fn context_usage(&mut self, tokens: usize) -> io::Result<()> {
790 self.sender
791 .send(WorkerMessage::ContextUsage(tokens))
792 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
793 }
794
795 fn compaction_started(&mut self) -> io::Result<()> {
796 self.sender
797 .send(WorkerMessage::CompactionStarted)
798 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
799 }
800
801 fn compaction_finished(&mut self, tokens_before: usize, tokens_after: usize) -> io::Result<()> {
802 self.sender
803 .send(WorkerMessage::CompactionFinished {
804 tokens_before,
805 tokens_after,
806 })
807 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
808 }
809}
810
811#[derive(Debug, Clone)]
812struct ActivityTransition {
813 started_at: Instant,
814 from_levels: [usize; PULSE_BAR_PERIODS.len()],
815 to_levels: [usize; PULSE_BAR_PERIODS.len()],
816}
817
818struct UiState {
819 model: String,
820 effort: Option<String>,
821 context_window: Option<usize>,
822 context_tokens: usize,
823 secret: String,
824 transcript: Vec<TranscriptItem>,
825 queued_messages: Vec<String>,
826 input: String,
827 cursor: usize,
828 status: String,
829 busy: bool,
830 terminal_focused: bool,
831 active_cancel: Option<CancellationToken>,
832 scroll: u16,
833 auto_scroll: bool,
834 tool_animation_epoch: Instant,
835 console_animation_epoch: Instant,
836 activity_started_at: Instant,
837 activity_transition: Option<ActivityTransition>,
838 last_active_levels: [usize; PULSE_BAR_PERIODS.len()],
839 last_active_elapsed: Duration,
840 welcome_visible: bool,
841 attached_agents: Vec<String>,
842 cmd_result_started_at: HashMap<String, Instant>,
843 skill_names: Vec<String>,
844 skill_picker_focus: usize,
845 skill_picker_suppressed: bool,
846 settings: Option<SettingsState>,
847}
848
849impl UiState {
850 fn from_history(
851 history: &[SessionHistoryRecord],
852 secret: &str,
853 model: &str,
854 effort: Option<&str>,
855 resumed: bool,
856 ) -> Self {
857 let mut state = Self {
858 model: model.to_owned(),
859 effort: effort.map(str::to_owned),
860 context_window: None,
861 context_tokens: 1,
862 secret: secret.to_owned(),
863 transcript: Vec::new(),
864 queued_messages: Vec::new(),
865 input: String::new(),
866 cursor: 0,
867 status: "ready".to_owned(),
868 busy: false,
869 terminal_focused: true,
870 active_cancel: None,
871 scroll: 0,
872 auto_scroll: true,
873 tool_animation_epoch: Instant::now(),
874 console_animation_epoch: Instant::now(),
875 activity_started_at: Instant::now(),
876 activity_transition: None,
877 last_active_levels: [0; PULSE_BAR_PERIODS.len()],
878 last_active_elapsed: Duration::ZERO,
879 welcome_visible: !resumed && history.is_empty(),
880 attached_agents: Vec::new(),
881 cmd_result_started_at: HashMap::new(),
882 skill_names: Vec::new(),
883 skill_picker_focus: 0,
884 skill_picker_suppressed: false,
885 settings: None,
886 };
887 for record in history {
888 state.add_history_record(record);
889 }
890 state
891 }
892
893 fn with_attached_agents(mut self, attached_agents: Vec<String>) -> Self {
894 self.attached_agents = attached_agents;
895 self
896 }
897
898 fn with_skill_names(mut self, skill_names: Vec<String>) -> Self {
899 self.skill_names = skill_names;
900 self
901 }
902
903 fn with_context(mut self, context_window: Option<usize>, context_tokens: usize) -> Self {
904 self.context_window = context_window;
905 self.context_tokens = context_tokens.max(1);
906 self
907 }
908
909 fn matching_skill_names(&self) -> Vec<&str> {
912 matching_skill_names(&self.input, &self.skill_names)
913 }
914
915 fn reset_skill_picker(&mut self) {
916 self.skill_picker_focus = 0;
917 self.skill_picker_suppressed = false;
918 }
919
920 fn skill_picker_visible(&self) -> bool {
921 !self.skill_picker_suppressed && !self.matching_skill_names().is_empty()
922 }
923
924 fn set_busy(&mut self, busy: bool) {
925 self.set_busy_at(busy, Instant::now());
926 }
927
928 fn set_busy_at(&mut self, busy: bool, now: Instant) {
929 if self.busy == busy {
930 return;
931 }
932 if busy {
933 self.console_animation_epoch = now;
934 }
935 self.busy = busy;
936 }
937
938 fn set_status(&mut self, status: impl Into<String>) {
939 let status = status.into();
940 if self.status == status {
941 return;
942 }
943
944 let now = Instant::now();
945 let current_levels = self.activity_levels_at(now);
946 let current_elapsed = self.working_elapsed_at(now);
947 if matches!(self.status.as_str(), "working" | "compacting") {
948 self.last_active_levels = current_levels;
949 self.last_active_elapsed = current_elapsed;
950 }
951
952 match status.as_str() {
953 "working" if !matches!(self.status.as_str(), "working" | "compacting") => {
954 self.activity_started_at = now;
958 self.activity_transition = Some(ActivityTransition {
959 started_at: now,
960 from_levels: current_levels,
961 to_levels: pulse_levels_at(PULSE_ENTRY_FRAME),
962 });
963 }
964 "ready" if self.status != "ready" => {
965 let from_levels = if matches!(self.status.as_str(), "working" | "compacting") {
969 current_levels
970 } else {
971 self.last_active_levels
972 };
973 self.activity_transition = Some(ActivityTransition {
974 started_at: now,
975 from_levels,
976 to_levels: [0; PULSE_BAR_PERIODS.len()],
977 });
978 }
979 _ => {}
980 }
981 self.status = status;
982 }
983
984 fn activity_levels_at(&self, now: Instant) -> [usize; PULSE_BAR_PERIODS.len()] {
985 if let Some(transition) = &self.activity_transition {
986 let elapsed = now.saturating_duration_since(transition.started_at);
987 if elapsed < ACTIVITY_TRANSITION_DURATION {
988 return interpolate_pulse_levels(
989 transition.from_levels,
990 transition.to_levels,
991 elapsed,
992 );
993 }
994 }
995
996 match self.status.as_str() {
997 "working" | "compacting" => pulse_levels_at(self.working_elapsed_at(now)),
998 _ => [0; PULSE_BAR_PERIODS.len()],
999 }
1000 }
1001
1002 fn console_animation_elapsed_at(&self, now: Instant) -> Duration {
1003 now.saturating_duration_since(self.console_animation_epoch)
1004 }
1005
1006 fn working_elapsed_at(&self, now: Instant) -> Duration {
1007 let elapsed = now.saturating_duration_since(self.activity_started_at);
1008 if self.status == "working" && self.activity_transition.is_some() {
1009 PULSE_ENTRY_FRAME
1010 .checked_add(elapsed.saturating_sub(ACTIVITY_TRANSITION_DURATION))
1011 .unwrap_or(PULSE_ENTRY_FRAME)
1012 } else {
1013 elapsed
1014 }
1015 }
1016
1017 fn input_changed(&mut self) {
1018 self.reset_skill_picker();
1019 }
1020
1021 fn move_skill_picker(&mut self, down: bool) -> bool {
1025 let match_count = self.matching_skill_names().len();
1026 if self.skill_picker_suppressed || match_count == 0 {
1027 return false;
1028 }
1029 if down {
1030 self.skill_picker_focus = (self.skill_picker_focus + 1).min(match_count - 1);
1031 } else {
1032 self.skill_picker_focus = self.skill_picker_focus.saturating_sub(1);
1033 }
1034 true
1035 }
1036
1037 fn focused_builtin_command(&self) -> Option<BuiltinCommand> {
1043 let name = *self.matching_skill_names().get(self.skill_picker_focus)?;
1044 builtin_command(&format!("/{name}"))
1045 }
1046
1047 fn select_focused_skill(&mut self) -> bool {
1048 if self.skill_picker_suppressed {
1049 return false;
1050 }
1051 let Some(name) = self
1052 .matching_skill_names()
1053 .get(self.skill_picker_focus)
1054 .map(|name| (*name).to_owned())
1055 else {
1056 return false;
1057 };
1058 self.input = format!("/{name}");
1059 self.cursor = self.input.chars().count();
1060 self.skill_picker_suppressed = true;
1063 true
1064 }
1065
1066 fn open_catalog(&mut self, result: Result<Vec<ProviderModel>, String>) {
1067 self.settings = Some(match result {
1068 Ok(models) => {
1069 let focus = models
1070 .iter()
1071 .position(|model| model.id == self.model)
1072 .unwrap_or(0);
1073 SettingsState::Models {
1074 models,
1075 query: String::new(),
1076 focus,
1077 }
1078 }
1079 Err(error) => SettingsState::Error(error),
1080 });
1081 }
1082 fn settings_applied(
1083 &mut self,
1084 result: Result<(), String>,
1085 model: String,
1086 effort: Option<String>,
1087 context_window: Option<usize>,
1088 ) {
1089 match result {
1090 Ok(()) => {
1091 self.model = model;
1092 self.effort = effort;
1093 self.context_window = context_window;
1094 self.settings = None;
1095 self.transcript
1096 .push(TranscriptItem::Info("⚙ settings applied".to_owned()));
1097 }
1098 Err(error) => self.settings = Some(SettingsState::Error(error)),
1099 }
1100 }
1101 fn handle_settings_key(&mut self, key: &KeyEvent) -> Option<(String, Option<String>)> {
1102 let current_effort = self.effort.clone();
1103 match self.settings.as_mut()? {
1104 SettingsState::Loading => {
1105 if key.code == KeyCode::Esc {
1106 self.settings = None;
1107 }
1108 }
1109 SettingsState::Applying { .. } => {}
1110 SettingsState::Error(_) => {
1111 if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
1112 self.settings = None;
1113 }
1114 }
1115 SettingsState::Models {
1116 models,
1117 query,
1118 focus,
1119 } => match key.code {
1120 KeyCode::Esc => self.settings = None,
1121 KeyCode::Char(c) => {
1122 query.push(c);
1123 *focus = 0;
1124 }
1125 KeyCode::Backspace => {
1126 query.pop();
1127 *focus = 0;
1128 }
1129 KeyCode::Up => *focus = focus.saturating_sub(1),
1130 KeyCode::Down => {
1131 let n = models
1132 .iter()
1133 .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
1134 .count();
1135 *focus = (*focus + 1).min(n.saturating_sub(1));
1136 }
1137 KeyCode::Enter => {
1138 let selected = models
1139 .iter()
1140 .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
1141 .nth(*focus)
1142 .cloned();
1143 if let Some(model) = selected {
1144 let focus = model
1145 .efforts
1146 .as_ref()
1147 .and_then(|efforts| {
1148 current_effort.as_ref().and_then(|current| {
1149 efforts.iter().position(|effort| effort == current)
1150 })
1151 })
1152 .map_or(0, |index| index + 1);
1153 self.settings = Some(SettingsState::Effort {
1154 model,
1155 input: current_effort.unwrap_or_default(),
1156 focus,
1157 });
1158 }
1159 }
1160 _ => {}
1161 },
1162 SettingsState::Effort {
1163 model,
1164 input,
1165 focus,
1166 } => match key.code {
1167 KeyCode::Esc => self.settings = None,
1168 KeyCode::Char(c) if model.efforts.is_none() => input.push(c),
1169 KeyCode::Backspace if model.efforts.is_none() => {
1170 input.pop();
1171 }
1172 KeyCode::Up => *focus = focus.saturating_sub(1),
1173 KeyCode::Down => {
1174 if let Some(efforts) = &model.efforts {
1175 *focus = (*focus + 1).min(efforts.len());
1176 }
1177 }
1178 KeyCode::Enter => {
1179 let effort = match &model.efforts {
1180 Some(efforts) => {
1181 if *focus == 0 {
1182 None
1183 } else {
1184 efforts.get(focus.saturating_sub(1)).cloned()
1185 }
1186 }
1187 None => (!input.trim().is_empty()).then(|| input.trim().to_owned()),
1188 };
1189 return Some((model.id.clone(), effort));
1190 }
1191 _ => {}
1192 },
1193 };
1194 None
1195 }
1196
1197 fn add_history_record(&mut self, record: &SessionHistoryRecord) {
1198 match record {
1199 SessionHistoryRecord::ProviderSettings { model, effort, .. } => {
1200 self.transcript.push(TranscriptItem::Info(format!(
1201 "⚙ {model} ({})",
1202 effort.as_deref().unwrap_or("default")
1203 )))
1204 }
1205 SessionHistoryRecord::Message { message, .. } => self.add_message(message),
1206 SessionHistoryRecord::Interruption {
1207 assistant_text,
1208 tool_calls,
1209 tool_results,
1210 reason,
1211 phase,
1212 ..
1213 } => {
1214 if !assistant_text.is_empty() {
1215 self.add_assistant_message(assistant_text);
1216 }
1217 for call in tool_calls {
1218 self.add_tool_call(call);
1219 }
1220 for observation in tool_results {
1221 self.add_tool_result(
1222 &observation.id,
1223 &observation.name,
1224 observation.result.clone(),
1225 );
1226 }
1227 self.transcript
1228 .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1229 }
1230 SessionHistoryRecord::Compaction(compaction) => {
1231 self.transcript.push(TranscriptItem::Info(format!(
1232 "↻ context compacted ({} before)",
1233 format_context_tokens(compaction.tokens_before)
1234 )));
1235 }
1236 }
1237 }
1238
1239 fn add_message(&mut self, message: &ChatMessage) {
1240 match message.role.as_str() {
1241 "user" => {
1242 let text = message.content.as_deref().unwrap_or("");
1243 let secret = self.secret.clone();
1244 self.add_user(text, &secret);
1245 }
1246 "assistant" => {
1247 if let Some(content) = message.content.as_deref() {
1248 self.add_assistant_message(content);
1249 }
1250 for call in &message.tool_calls {
1251 self.add_tool_call(call);
1252 }
1253 }
1254 "tool" => {
1255 let result = message
1256 .content
1257 .as_deref()
1258 .and_then(|content| serde_json::from_str::<Value>(content).ok())
1259 .unwrap_or_else(|| Value::String(message.content.clone().unwrap_or_default()));
1260 self.add_tool_result(
1261 message.tool_call_id.as_deref().unwrap_or(""),
1262 message.name.as_deref().unwrap_or("cmd"),
1263 result,
1264 );
1265 }
1266 _ => {}
1267 }
1268 }
1269
1270 fn submit_user(&mut self, text: &str) {
1273 if self.busy {
1274 self.queue_user(text);
1275 } else {
1276 self.add_user(text, &self.secret.clone());
1277 }
1278 }
1279
1280 fn queue_user(&mut self, text: &str) {
1281 self.queued_messages
1282 .push(redact_secret(text, Some(&self.secret)));
1283 }
1284
1285 fn start_queued_user(&mut self, text: &str) {
1286 let safe = redact_secret(text, Some(&self.secret));
1287 let queued = if self.queued_messages.first() == Some(&safe) {
1288 self.queued_messages.remove(0);
1289 true
1290 } else if let Some(index) = self
1291 .queued_messages
1292 .iter()
1293 .position(|queued| queued == &safe)
1294 {
1295 self.queued_messages.remove(index);
1296 true
1297 } else {
1298 false
1299 };
1300 if queued {
1301 self.add_user(text, &self.secret.clone());
1302 }
1303 }
1304
1305 fn add_user(&mut self, text: &str, secret: &str) {
1306 self.welcome_visible = false;
1307 self.transcript.push(TranscriptItem::User {
1308 text: redact_secret(text, Some(secret)),
1309 skill_instruction_attached: false,
1310 });
1311 }
1312
1313 fn mark_latest_user_skill_attached(&mut self) {
1314 if let Some(TranscriptItem::User {
1315 skill_instruction_attached,
1316 ..
1317 }) = self.transcript.last_mut()
1318 {
1319 *skill_instruction_attached = true;
1320 }
1321 }
1322
1323 fn clear_thinking(&mut self) {
1324 if matches!(
1325 self.transcript.last(),
1326 Some(TranscriptItem::Reasoning { complete: false })
1327 ) {
1328 self.transcript.pop();
1329 }
1330 }
1331
1332 fn show_thinking(&mut self) {
1333 self.set_status("working");
1334 if !matches!(
1335 self.transcript.last(),
1336 Some(TranscriptItem::Reasoning { complete: false })
1337 ) {
1338 self.transcript
1339 .push(TranscriptItem::Reasoning { complete: false });
1340 }
1341 }
1342
1343 fn complete_reasoning(&mut self) {
1344 if let Some(TranscriptItem::Reasoning { complete }) = self.transcript.last_mut() {
1345 *complete = true;
1346 }
1347 }
1348
1349 fn add_assistant(&mut self, text: &str) {
1350 self.clear_thinking();
1351 if let Some(TranscriptItem::Assistant(current)) = self.transcript.last_mut() {
1352 current.push_str(text);
1353 } else {
1354 self.add_assistant_message(text);
1355 }
1356 }
1357
1358 fn add_assistant_message(&mut self, text: &str) {
1359 self.transcript
1360 .push(TranscriptItem::Assistant(text.to_owned()));
1361 }
1362
1363 fn add_tool_call(&mut self, call: &crate::model::ChatToolCall) {
1364 self.record_tool_call(call, false);
1365 }
1366
1367 fn add_live_tool_call(&mut self, call: &crate::model::ChatToolCall) {
1368 self.record_tool_call(call, true);
1369 }
1370
1371 fn record_tool_call(&mut self, call: &crate::model::ChatToolCall, _live: bool) {
1372 self.clear_thinking();
1373 self.transcript.push(TranscriptItem::ToolCall {
1374 id: call.id.clone(),
1375 name: call.name.clone(),
1376 arguments: call.arguments.clone(),
1377 });
1378 }
1379
1380 fn add_tool_result(&mut self, id: &str, name: &str, result: Value) {
1381 self.record_tool_result(id, name, result, false);
1382 }
1383
1384 fn add_live_tool_result(&mut self, id: &str, name: &str, result: Value) {
1385 self.record_tool_result(id, name, result, true);
1386 }
1387
1388 fn record_tool_result(&mut self, id: &str, name: &str, result: Value, animate: bool) {
1389 if animate && name == "cmd" {
1390 self.cmd_result_started_at
1391 .insert(id.to_owned(), Instant::now());
1392 }
1393 self.transcript.push(TranscriptItem::ToolResult {
1394 id: id.to_owned(),
1395 name: name.to_owned(),
1396 result,
1397 });
1398 }
1399
1400 fn apply_event(&mut self, event: ProtocolEvent) {
1401 match event {
1402 ProtocolEvent::Session { .. } => {}
1403 ProtocolEvent::AssistantDelta { text } => self.add_assistant(&text),
1404 ProtocolEvent::ToolCall {
1405 id,
1406 name,
1407 arguments,
1408 } => self.add_live_tool_call(&crate::model::ChatToolCall {
1409 id,
1410 name,
1411 arguments,
1412 }),
1413 ProtocolEvent::ToolResult { id, name, result } => {
1414 self.add_live_tool_result(&id, &name, result)
1415 }
1416 ProtocolEvent::TurnEnd => {
1417 self.complete_reasoning();
1418 self.set_status("finalizing");
1419 self.transcript
1420 .push(TranscriptItem::Info("✓ turn complete".to_owned()));
1421 }
1422 ProtocolEvent::TurnInterrupted { reason, phase } => {
1423 self.complete_reasoning();
1424 self.set_status("cancelling");
1425 self.transcript
1426 .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1427 }
1428 ProtocolEvent::Error { message } => {
1429 self.complete_reasoning();
1430 self.set_status("error");
1431 self.transcript.push(TranscriptItem::Error(message));
1432 }
1433 }
1434 }
1435}
1436
1437#[derive(Debug, Clone, PartialEq)]
1438enum TranscriptItem {
1439 User {
1440 text: String,
1441 skill_instruction_attached: bool,
1442 },
1443 Assistant(String),
1444 ToolCall {
1445 id: String,
1446 name: String,
1447 arguments: String,
1448 },
1449 ToolResult {
1450 id: String,
1451 name: String,
1452 result: Value,
1453 },
1454 Error(String),
1455 Info(String),
1456 Reasoning {
1457 complete: bool,
1458 },
1459}
1460
1461fn tui_viewport(area: Rect) -> Rect {
1465 if area.width <= 2 {
1466 return area;
1467 }
1468
1469 let width = area.width.saturating_sub(2).min(TUI_MAX_WIDTH);
1470 let x = area.x + area.width.saturating_sub(width) / 2;
1471 Rect::new(x, area.y, width, area.height)
1472}
1473
1474fn ui_layout(
1475 state: &UiState,
1476 area: Rect,
1477) -> (Rect, Option<Rect>, Option<Rect>, Option<Rect>, Rect, Rect) {
1478 let prompt_rows = input_visible_rows(state, ui_prompt_content_width(area));
1479 let list_height = 0;
1480 let queue_height = message_queue_height(state);
1481 let queue_separator_height = u16::from(queue_height > 0);
1482 let list_separator_height = u16::from(list_height > 0);
1483 let requested_input_height = prompt_rows.clamp(1, MAX_INPUT_ROWS)
1484 + queue_height
1485 + queue_separator_height
1486 + list_height
1487 + list_separator_height
1488 + 1 + 1 + 2; let bottom_margin = u16::from(area.height > 1);
1495 let usable_height = area.height.saturating_sub(bottom_margin);
1496 let input_height = requested_input_height.min(usable_height);
1497 let transcript_gap_height = u16::from(usable_height >= input_height.saturating_add(2));
1498 let chat_height = usable_height.saturating_sub(input_height + transcript_gap_height);
1499 let chat_chunk = bottom_console_area(area, area.y, chat_height);
1500 let input_area = bottom_console_area(
1501 area,
1502 area.y + chat_height + transcript_gap_height,
1503 input_height,
1504 );
1505 let inner = console_content_area(input_area);
1506 let content = bottom_content_heights(state, input_area);
1507 let available_above = input_area.y.saturating_sub(area.y);
1508 let picker_height = skill_picker_height(state).min(available_above);
1509 let picker_area = (picker_height > 0).then(|| {
1510 Rect::new(
1511 input_area.x,
1512 input_area.y - picker_height,
1513 input_area.width,
1514 picker_height,
1515 )
1516 });
1517 let stream_area = None;
1518 let queue_area =
1519 (content.queue > 0).then(|| Rect::new(inner.x, inner.y, inner.width, content.queue));
1520 let status_area = Rect::new(
1521 inner.x,
1522 inner.y + inner.height.saturating_sub(content.status),
1523 inner.width,
1524 content.status,
1525 );
1526 (
1527 chat_chunk,
1528 picker_area,
1529 stream_area,
1530 queue_area,
1531 input_area,
1532 status_area,
1533 )
1534}
1535
1536const CONTENT_HORIZONTAL_MARGIN: u16 = 7;
1540const MIN_CONSOLE_WIDTH: u16 = 14;
1541
1542fn bottom_console_area(area: Rect, y: u16, height: u16) -> Rect {
1543 let horizontal_margin = area.width.saturating_sub(1) / 2;
1544 let margin_cap = if area.width < MIN_CONSOLE_WIDTH {
1545 2
1546 } else {
1547 CONTENT_HORIZONTAL_MARGIN.min(area.width.saturating_sub(MIN_CONSOLE_WIDTH) / 2)
1548 };
1549 let horizontal_margin = horizontal_margin.min(margin_cap);
1550 Rect::new(
1551 area.x.saturating_add(horizontal_margin),
1552 y,
1553 area.width
1554 .saturating_sub(horizontal_margin.saturating_mul(2)),
1555 height,
1556 )
1557}
1558
1559fn ui_prompt_content_width(area: Rect) -> u16 {
1560 prompt_content_width(bottom_console_area(area, area.y, 0).width)
1561}
1562
1563fn console_content_area(input_area: Rect) -> Rect {
1564 let top_padding = input_area.height.min(1);
1565 let bottom_padding = input_area.height.saturating_sub(top_padding).min(1);
1566 Rect::new(
1567 input_area.x.saturating_add(2),
1568 input_area.y.saturating_add(top_padding),
1569 input_area.width.saturating_sub(4),
1570 input_area
1571 .height
1572 .saturating_sub(top_padding + bottom_padding),
1573 )
1574}
1575
1576fn prompt_content_width(input_width: u16) -> u16 {
1577 input_width.saturating_sub(4)
1578}
1579
1580#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1581struct BottomContentHeights {
1582 queue: u16,
1583 queue_separator: u16,
1584 list: u16,
1585 list_separator: u16,
1586 prompt: u16,
1587 status_separator: u16,
1588 status: u16,
1589}
1590
1591fn bottom_content_heights(state: &UiState, input_area: Rect) -> BottomContentHeights {
1595 let mut available = console_content_area(input_area).height;
1596 let status = available.min(1);
1597 available -= status;
1598
1599 let prompt = input_visible_rows(state, prompt_content_width(input_area.width))
1600 .clamp(1, MAX_INPUT_ROWS)
1601 .min(available);
1602 available -= prompt;
1603
1604 let status_separator = u16::from(status > 0 && prompt > 0 && available > 0);
1605 available -= status_separator;
1606
1607 let requested_queue = message_queue_height(state);
1608 let (queue, queue_separator) = if requested_queue > 0 && available >= 3 {
1609 (requested_queue.min(available - 1), 1)
1610 } else {
1611 (0, 0)
1612 };
1613 available -= queue + queue_separator;
1614
1615 let requested_list = 0;
1616 let (list, list_separator) = if requested_list > 0 && available >= 3 {
1617 (requested_list.min(available - 1), 1)
1618 } else {
1619 (0, 0)
1620 };
1621
1622 BottomContentHeights {
1623 queue,
1624 queue_separator,
1625 list,
1626 list_separator,
1627 prompt,
1628 status_separator,
1629 status,
1630 }
1631}
1632
1633fn prompt_area(input_area: Rect, state: &UiState) -> Rect {
1634 let inner = console_content_area(input_area);
1635 let content = bottom_content_heights(state, input_area);
1636 Rect::new(
1637 inner.x,
1638 inner.y + content.queue + content.queue_separator,
1639 inner.width,
1640 content.prompt,
1641 )
1642}
1643
1644fn message_queue_height(state: &UiState) -> u16 {
1645 let messages = state.queued_messages.len().min(u16::MAX as usize - 1) as u16;
1646 u16::from(messages > 0) + messages
1647}
1648
1649fn max_scroll_for_area(state: &UiState, size: Size) -> u16 {
1650 let area = tui_viewport(Rect::new(0, 0, size.width, size.height));
1651 let (chat_chunk, _, _, _, _, _) = ui_layout(state, area);
1652 let chat_height = chat_chunk.height;
1653 let lines = transcript_lines(state, chat_chunk.width);
1654 lines
1655 .len()
1656 .saturating_sub(chat_height as usize)
1657 .min(u16::MAX as usize) as u16
1658}
1659
1660const TRANSCRIPT_SCROLLBAR_TRACK: &str = "┆";
1661const TRANSCRIPT_SCROLLBAR_THUMB: &str = "█";
1662const TRANSCRIPT_SCROLLBAR_TRACK_COLOR: Color = Color::Rgb(72, 72, 76);
1663
1664fn draw_transcript_scrollbar(
1665 frame: &mut Frame<'_>,
1666 area: Rect,
1667 total_lines: usize,
1668 max_scroll: u16,
1669 scroll: u16,
1670) {
1671 if area.width == 0 || area.height == 0 || total_lines == 0 || max_scroll == 0 {
1672 return;
1673 }
1674
1675 let track_height = area.height as usize;
1676 let thumb_height = ((track_height * track_height) / total_lines)
1677 .max(1)
1678 .min(track_height);
1679 let thumb_range = track_height.saturating_sub(thumb_height);
1680 let thumb_start = (usize::from(scroll.min(max_scroll)) * thumb_range / usize::from(max_scroll))
1681 .min(thumb_range);
1682 let x = area.x.saturating_add(area.width);
1685 let frame_right = frame.area().x.saturating_add(frame.area().width);
1686 if x >= frame_right {
1687 return;
1688 }
1689 let buffer = frame.buffer_mut();
1690
1691 for offset in 0..track_height {
1692 let y = area.y + offset as u16;
1693 buffer[(x, y)].set_symbol(TRANSCRIPT_SCROLLBAR_TRACK);
1694 buffer[(x, y)].set_fg(TRANSCRIPT_SCROLLBAR_TRACK_COLOR);
1695 }
1696 for offset in thumb_start..thumb_start + thumb_height {
1697 let y = area.y + offset as u16;
1698 buffer[(x, y)].set_symbol(TRANSCRIPT_SCROLLBAR_THUMB);
1699 buffer[(x, y)].set_fg(CONSOLE_STATUS_COLOR);
1700 }
1701}
1702
1703fn input_visible_rows(state: &UiState, width: u16) -> u16 {
1705 let width = width as usize;
1706 if width == 0 {
1707 return 1;
1708 }
1709 let prompt = input_display_text(state);
1710 let wrapped = wrap_text(&prompt, width);
1711 wrapped.len().max(1) as u16
1712}
1713
1714fn input_prompt(input: &str) -> String {
1715 input.to_owned()
1716}
1717
1718fn input_display_text(state: &UiState) -> String {
1719 redact_secret(&input_prompt(&state.input), Some(&state.secret))
1720}
1721
1722fn command_names(mut skill_names: Vec<String>) -> Vec<String> {
1723 skill_names.extend(BUILTIN_COMMANDS.into_iter().map(str::to_owned));
1724 skill_names.sort();
1725 skill_names.dedup();
1726 skill_names
1727}
1728
1729#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1730enum BuiltinCommand {
1731 Settings,
1732 Exit,
1733}
1734
1735impl BuiltinCommand {
1736 fn name(self) -> &'static str {
1737 match self {
1738 Self::Settings => "settings",
1739 Self::Exit => "exit",
1740 }
1741 }
1742}
1743
1744fn builtin_command(input: &str) -> Option<BuiltinCommand> {
1745 match input.split_whitespace().next()? {
1746 "/settings" => Some(BuiltinCommand::Settings),
1747 "/exit" => Some(BuiltinCommand::Exit),
1748 _ => None,
1749 }
1750}
1751
1752fn matching_skill_names<'a>(input: &str, skill_names: &'a [String]) -> Vec<&'a str> {
1755 let Some(query) = input.strip_prefix('/') else {
1756 return Vec::new();
1757 };
1758 if query.chars().any(char::is_whitespace) {
1759 return Vec::new();
1760 }
1761 skill_names
1762 .iter()
1763 .map(String::as_str)
1764 .filter(|name| name.starts_with(query))
1765 .collect()
1766}
1767
1768fn skill_picker_height(state: &UiState) -> u16 {
1769 if state.skill_picker_visible() {
1770 (state
1772 .matching_skill_names()
1773 .len()
1774 .min(SKILL_PICKER_MAX_ROWS)
1775 + 3) as u16
1776 } else {
1777 0
1778 }
1779}
1780
1781fn active_skill_trigger<'a>(input: &'a str, skill_names: &[String]) -> Option<&'a str> {
1785 let invocation = input.strip_prefix('/')?;
1786 let name = invocation
1787 .split_once(char::is_whitespace)
1788 .map_or(invocation, |(name, _)| name);
1789 if name.is_empty() || !skill_names.iter().any(|skill_name| skill_name == name) {
1790 return None;
1791 }
1792 Some(&input[..1 + name.len()])
1793}
1794
1795fn styled_text_lines(
1798 input: &str,
1799 active_skill_trigger: Option<&str>,
1800 width: usize,
1801 text_style: Style,
1802) -> Vec<Line<'static>> {
1803 let trigger_len = active_skill_trigger.map_or(0, |trigger| trigger.chars().count());
1804 let mut char_offset = 0usize;
1805 let mut lines = Vec::new();
1806
1807 for source_line in input.split('\n') {
1808 for row in wrap_line(source_line, width) {
1809 let mut spans = Vec::new();
1810 let mut text = String::new();
1811 let mut highlighted = None;
1812 for character in row.chars() {
1813 let should_highlight = char_offset < trigger_len;
1814 if highlighted != Some(should_highlight) && !text.is_empty() {
1815 spans.push(styled_text_span(
1816 std::mem::take(&mut text),
1817 highlighted.unwrap_or(false),
1818 text_style,
1819 ));
1820 }
1821 highlighted = Some(should_highlight);
1822 text.push(character);
1823 char_offset += 1;
1824 }
1825 if !text.is_empty() {
1826 spans.push(styled_text_span(
1827 text,
1828 highlighted.unwrap_or(false),
1829 text_style,
1830 ));
1831 }
1832 if spans.is_empty() {
1833 spans.push(Span::styled(String::new(), text_style));
1834 }
1835 lines.push(Line::from(spans));
1836 }
1837 char_offset += 1;
1840 }
1841
1842 lines
1843}
1844
1845fn styled_text_span(text: String, highlighted: bool, text_style: Style) -> Span<'static> {
1846 if highlighted {
1847 Span::styled(text, Style::default().fg(SKILL_TRIGGER_COLOR))
1848 } else {
1849 Span::styled(text, text_style)
1850 }
1851}
1852
1853#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1854struct InputVisualRow {
1855 start: usize,
1856 end: usize,
1857}
1858
1859fn input_visual_rows(input: &str, width: usize) -> Vec<InputVisualRow> {
1860 let width = width.max(1);
1861 let characters = input.chars().collect::<Vec<_>>();
1862 let mut rows = Vec::new();
1863 let mut start = 0;
1864 let mut row_width = 0;
1865
1866 for (index, character) in characters.iter().enumerate() {
1867 if *character == '\n' {
1868 rows.push(InputVisualRow { start, end: index });
1869 start = index + 1;
1870 row_width = 0;
1871 continue;
1872 }
1873
1874 let character_width = unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0);
1875 if row_width + character_width > width && index > start {
1876 rows.push(InputVisualRow { start, end: index });
1877 start = index;
1878 row_width = 0;
1879 }
1880 row_width += character_width;
1881 }
1882
1883 rows.push(InputVisualRow {
1884 start,
1885 end: characters.len(),
1886 });
1887 rows
1888}
1889
1890fn input_cursor_row(input: &str, cursor: usize, width: usize) -> usize {
1891 let rows = input_visual_rows(input, width);
1892 let cursor = cursor.min(input.chars().count());
1893 for (index, row) in rows.iter().enumerate() {
1894 if cursor < row.end {
1895 return index;
1896 }
1897 if cursor == row.end && rows.get(index + 1).is_none_or(|next| next.start != cursor) {
1898 return index;
1899 }
1900 }
1901 rows.len().saturating_sub(1)
1902}
1903
1904fn cursor_row(input: &str, cursor: usize, width: usize) -> u16 {
1905 input_cursor_row(input, cursor, width).min(u16::MAX as usize) as u16
1906}
1907
1908fn move_up_from_input(state: &mut UiState, width: usize) -> bool {
1909 state.move_skill_picker(false) || move_input_cursor_vertical(state, width, false)
1910}
1911
1912fn move_down_from_input(state: &mut UiState, width: usize) -> bool {
1913 let width = width.max(1);
1914 state.move_skill_picker(true) || move_input_cursor_vertical(state, width, true)
1915}
1916
1917fn move_input_cursor_vertical(state: &mut UiState, width: usize, down: bool) -> bool {
1918 let width = width.max(1);
1919 let rows = input_visual_rows(&state.input, width);
1920 let current_row = input_cursor_row(&state.input, state.cursor, width);
1921 let target_row = if down {
1922 current_row + 1
1923 } else {
1924 current_row.saturating_sub(1)
1925 };
1926 if target_row == current_row || target_row >= rows.len() {
1927 return false;
1928 }
1929
1930 let characters = state.input.chars().collect::<Vec<_>>();
1931 let current = rows[current_row];
1932 let cursor = state.cursor.min(current.end);
1933 let desired_column = characters[current.start..cursor]
1934 .iter()
1935 .map(|character| unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0))
1936 .sum::<usize>();
1937 let target = rows[target_row];
1938 let mut column = 0;
1939 let mut target_cursor = target.end;
1940 for (index, character) in characters
1941 .iter()
1942 .enumerate()
1943 .take(target.end)
1944 .skip(target.start)
1945 {
1946 let character_width = unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0);
1947 if column + character_width > desired_column {
1948 target_cursor = index;
1949 break;
1950 }
1951 column += character_width;
1952 if column >= desired_column {
1953 target_cursor = index + 1;
1954 break;
1955 }
1956 }
1957 state.cursor = target_cursor;
1958 true
1959}
1960
1961fn insert_at_cursor(input: &mut String, cursor: &mut usize, character: char) {
1962 let byte_index = input
1963 .char_indices()
1964 .nth(*cursor)
1965 .map_or(input.len(), |(index, _)| index);
1966 input.insert(byte_index, character);
1967 *cursor += 1;
1968}
1969
1970fn remove_before_cursor(input: &mut String, cursor: &mut usize) -> bool {
1971 if *cursor == 0 {
1972 return false;
1973 }
1974 let end = input
1975 .char_indices()
1976 .nth(*cursor)
1977 .map_or(input.len(), |(index, _)| index);
1978 let start = input
1979 .char_indices()
1980 .nth(*cursor - 1)
1981 .map(|(index, _)| index)
1982 .unwrap_or(0);
1983 input.replace_range(start..end, "");
1984 *cursor -= 1;
1985 true
1986}
1987
1988fn draw(frame: &mut Frame<'_>, state: &UiState) {
1989 let full_area = frame.area();
1990 frame.render_widget(Clear, full_area);
1993 let area = tui_viewport(full_area);
1994 let (chat_chunk, picker_area, _, queue_area, input_chunk, status_area) = ui_layout(state, area);
1995
1996 let visible_chat_area = chat_chunk;
1999
2000 let width = chat_chunk.width;
2001 let welcome_image_layout = if state.welcome_visible && greeting_image_enabled() {
2002 let welcome_lines = welcome_lines(&state.attached_agents);
2003 welcome_image_layout(visible_chat_area, welcome_lines.len() as u16)
2004 } else {
2005 None
2006 };
2007 if state.welcome_visible {
2008 let welcome_lines = welcome_lines(&state.attached_agents);
2009 if let Some(layout) = welcome_image_layout {
2010 let welcome = Paragraph::new(welcome_lines).alignment(Alignment::Center);
2011 frame.render_widget(welcome, layout.intro_area);
2012 } else {
2013 let logo = logo_lines();
2014 let logo_gap = 2u16;
2015 let total_height = logo.len() as u16 + logo_gap + welcome_lines.len() as u16;
2016 let lines = if total_height <= visible_chat_area.height {
2019 let mut all = logo;
2020 all.push(Line::raw(""));
2021 all.push(Line::raw(""));
2022 all.extend(welcome_lines);
2023 all
2024 } else {
2025 welcome_lines
2026 };
2027 let welcome_height = (lines.len() as u16).min(visible_chat_area.height);
2028 let welcome_area = Rect::new(
2029 visible_chat_area.x,
2030 visible_chat_area.y + visible_chat_area.height.saturating_sub(welcome_height) / 2,
2031 visible_chat_area.width,
2032 welcome_height,
2033 );
2034 let welcome = Paragraph::new(lines).alignment(Alignment::Center);
2035 frame.render_widget(welcome, welcome_area);
2036 }
2037 } else {
2038 let lines = transcript_lines(state, width);
2039 let available = visible_chat_area.height as usize;
2040 let max_scroll = lines.len().saturating_sub(available).min(u16::MAX as usize) as u16;
2041 let scroll = if state.auto_scroll {
2042 max_scroll
2043 } else {
2044 state.scroll.min(max_scroll)
2045 };
2046 let total_lines = lines.len();
2047 let transcript = Paragraph::new(lines).scroll((scroll, 0));
2048 frame.render_widget(transcript, visible_chat_area);
2049 if !state.auto_scroll {
2050 draw_transcript_scrollbar(frame, visible_chat_area, total_lines, max_scroll, scroll);
2051 }
2052 }
2053
2054 frame.render_widget(
2055 Block::default().style(Style::default().bg(PROMPT_BACKGROUND)),
2056 input_chunk,
2057 );
2058
2059 if let Some(layout) = welcome_image_layout {
2060 let image = welcome_image(layout.image_size);
2061 frame.render_widget(TuiImage::new(image.as_ref()), layout.image_area);
2062 }
2063 if let Some(picker_area) = picker_area {
2064 draw_skill_picker(frame, state, picker_area);
2065 }
2066
2067 if let Some(queue_area) = queue_area {
2068 draw_message_queue(frame, state, queue_area);
2069 }
2070
2071 let input_text_style = Style::default().fg(Color::White);
2072 let prompt_area = prompt_area(input_chunk, state);
2073 let prompt = input_display_text(state);
2074 let input_rows = input_visible_rows(state, prompt_area.width).clamp(1, MAX_INPUT_ROWS);
2075 let wrapped = wrap_text(&prompt, prompt_area.width.max(1) as usize);
2076 let visible = (wrapped.len() as u16)
2077 .clamp(1, input_rows)
2078 .min(prompt_area.height);
2079 let cursor_row = cursor_row(&prompt, state.cursor, prompt_area.width.max(1) as usize);
2080 let bottom_scroll = (wrapped.len() as u16).saturating_sub(visible);
2081 let cursor_scroll = (cursor_row + 1).saturating_sub(visible);
2082 let input_scroll = cursor_scroll.min(bottom_scroll);
2083 let active_skill_trigger = (!state.busy)
2084 .then(|| active_skill_trigger(&prompt, &state.skill_names))
2085 .flatten();
2086 let input_lines = styled_text_lines(
2087 &prompt,
2088 active_skill_trigger,
2089 prompt_area.width.max(1) as usize,
2090 input_text_style,
2091 );
2092 let input = Paragraph::new(input_lines)
2093 .style(input_text_style)
2094 .scroll((input_scroll, 0));
2095 frame.render_widget(input, prompt_area);
2096
2097 let effort = state.effort.as_deref().unwrap_or("default");
2098 frame.render_widget(
2099 Paragraph::new(model_status_line(state, effort, status_area.width)),
2100 status_area,
2101 );
2102
2103 if let Some(settings) = &state.settings {
2104 draw_settings(frame, settings, area);
2105 }
2106
2107 if state.terminal_focused && state.settings.is_none() && !prompt_area.is_empty() && visible > 0
2110 {
2111 let cursor_prefix: String = prompt.chars().take(state.cursor).collect();
2112 let cursor_rows = wrap_text(&cursor_prefix, prompt_area.width.max(1) as usize);
2113 let cursor_line = cursor_rows.last().map(String::as_str).unwrap_or("");
2114 let cursor_offset = UnicodeWidthStr::width(cursor_line) as u16;
2115 let cursor_x = prompt_area.x + cursor_offset.min(prompt_area.width.saturating_sub(1));
2116 let cursor_y = prompt_area.y
2117 + cursor_row
2118 .saturating_sub(input_scroll)
2119 .min(prompt_area.height.saturating_sub(1));
2120 frame.set_cursor_position((cursor_x, cursor_y));
2121 }
2122}
2123
2124fn draw_message_queue(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
2125 if area.is_empty() || state.queued_messages.is_empty() {
2126 return;
2127 }
2128
2129 let chrome = Style::default().fg(SECTION_CHROME_COLOR);
2130 let message = Style::default().fg(QUEUED_MESSAGE_COLOR);
2131 let mut lines = vec![Line::styled("Queued", chrome)];
2132 lines.extend(
2133 state
2134 .queued_messages
2135 .iter()
2136 .take(area.height.saturating_sub(1) as usize)
2137 .enumerate()
2138 .map(|(index, queued)| {
2139 Line::from(vec![
2140 Span::styled("│ ", chrome),
2141 Span::styled(
2142 format!("{}) {}", index + 1, single_line_preview(queued)),
2143 message,
2144 ),
2145 ])
2146 }),
2147 );
2148 frame.render_widget(Paragraph::new(lines), area);
2149}
2150
2151fn single_line_preview(text: &str) -> String {
2152 truncate_output(&text.replace(['\n', '\r'], " ↵ "))
2153}
2154
2155enum SettingsState {
2156 Loading,
2157 Applying {
2158 model: String,
2159 effort: Option<String>,
2160 },
2161 Error(String),
2162 Models {
2163 models: Vec<ProviderModel>,
2164 query: String,
2165 focus: usize,
2166 },
2167 Effort {
2168 model: ProviderModel,
2169 input: String,
2170 focus: usize,
2171 },
2172}
2173fn draw_settings(frame: &mut Frame<'_>, settings: &SettingsState, area: Rect) {
2174 let width = area
2175 .width
2176 .saturating_sub(2)
2177 .min(SETTINGS_MAX_WIDTH)
2178 .max(SETTINGS_MIN_WIDTH.min(area.width));
2179 let height = area
2180 .height
2181 .saturating_sub(2)
2182 .min(SETTINGS_MAX_HEIGHT)
2183 .max(SETTINGS_MIN_HEIGHT.min(area.height));
2184 let popup = Rect::new(
2185 area.x + area.width.saturating_sub(width) / 2,
2186 area.y + area.height.saturating_sub(height) / 2,
2187 width,
2188 height,
2189 );
2190 frame.render_widget(Clear, popup);
2191 let block = Block::default()
2192 .title(" /settings ")
2193 .borders(Borders::ALL)
2194 .border_style(Style::default().fg(Color::Cyan));
2195 let inner = block.inner(popup);
2196 frame.render_widget(block, popup);
2197
2198 let lines = match settings {
2199 SettingsState::Loading => vec![
2200 Line::styled("Loading provider models…", Style::default().fg(Color::Cyan)),
2201 Line::raw(""),
2202 Line::styled("Esc cancel", Style::default().fg(Color::DarkGray)),
2203 ],
2204 SettingsState::Applying { model, effort } => vec![
2205 Line::styled("Applying selection…", Style::default().fg(Color::Cyan)),
2206 Line::raw(model.clone()),
2207 Line::raw(format!(
2208 "effort: {}",
2209 effort.as_deref().unwrap_or("default")
2210 )),
2211 ],
2212 SettingsState::Error(error) => vec![
2213 Line::styled("Unable to update settings", Style::default().fg(Color::Red)),
2214 Line::raw(""),
2215 Line::raw(error.clone()),
2216 Line::raw(""),
2217 Line::styled("Enter/Esc close", Style::default().fg(Color::DarkGray)),
2218 ],
2219 SettingsState::Models {
2220 models,
2221 query,
2222 focus,
2223 } => {
2224 let query_lower = query.to_lowercase();
2225 let filtered = models
2226 .iter()
2227 .filter(|model| model.id.to_lowercase().contains(&query_lower))
2228 .collect::<Vec<_>>();
2229 let focus = (*focus).min(filtered.len().saturating_sub(1));
2230 let list_rows = inner.height.saturating_sub(4) as usize;
2231 let range = selection_range(filtered.len(), focus, list_rows);
2232 let mut lines = vec![
2233 Line::from(vec![
2234 Span::styled("Model ", Style::default().fg(Color::DarkGray)),
2235 Span::styled(
2236 if query.is_empty() {
2237 "type to filter…"
2238 } else {
2239 query
2240 },
2241 Style::default().fg(if query.is_empty() {
2242 Color::DarkGray
2243 } else {
2244 Color::White
2245 }),
2246 ),
2247 ]),
2248 Line::styled(
2249 format!(
2250 "{} models{}",
2251 filtered.len(),
2252 if filtered.is_empty() {
2253 ""
2254 } else {
2255 " · ↑/↓ move · Enter choose"
2256 }
2257 ),
2258 Style::default().fg(Color::DarkGray),
2259 ),
2260 ];
2261 if filtered.is_empty() {
2262 lines.push(Line::styled(
2263 "No matching models",
2264 Style::default().fg(Color::Yellow),
2265 ));
2266 } else {
2267 for index in range {
2268 let selected = index == focus;
2269 lines.push(Line::styled(
2270 format!(
2271 "{} {}",
2272 if selected { "›" } else { " " },
2273 filtered[index].id
2274 ),
2275 if selected {
2276 Style::default().fg(Color::Black).bg(Color::Cyan)
2277 } else {
2278 Style::default().fg(Color::White)
2279 },
2280 ));
2281 }
2282 }
2283 lines.push(Line::styled(
2284 "Esc cancel",
2285 Style::default().fg(Color::DarkGray),
2286 ));
2287 lines
2288 }
2289 SettingsState::Effort {
2290 model,
2291 input,
2292 focus,
2293 } => {
2294 let mut lines = vec![
2295 Line::styled(model.id.clone(), Style::default().fg(Color::Cyan)),
2296 Line::styled("Reasoning effort", Style::default().fg(Color::DarkGray)),
2297 ];
2298 match &model.efforts {
2299 Some(efforts) => {
2300 let total = efforts.len() + 1;
2301 let focus = (*focus).min(total.saturating_sub(1));
2302 let list_rows = inner.height.saturating_sub(4) as usize;
2303 for index in selection_range(total, focus, list_rows) {
2304 let value = if index == 0 {
2305 "default"
2306 } else {
2307 efforts[index - 1].as_str()
2308 };
2309 let selected = index == focus;
2310 lines.push(Line::styled(
2311 format!("{} {value}", if selected { "›" } else { " " }),
2312 if selected {
2313 Style::default().fg(Color::Black).bg(Color::Cyan)
2314 } else {
2315 Style::default().fg(Color::White)
2316 },
2317 ));
2318 }
2319 lines.push(Line::styled(
2320 "↑/↓ move · Enter save · Esc cancel",
2321 Style::default().fg(Color::DarkGray),
2322 ));
2323 }
2324 None => {
2325 lines.push(Line::raw("Provider did not advertise allowed efforts."));
2326 lines.push(Line::from(vec![
2327 Span::styled("Value ", Style::default().fg(Color::DarkGray)),
2328 Span::styled(
2329 if input.is_empty() { "default" } else { input },
2330 Style::default().fg(Color::White),
2331 ),
2332 ]));
2333 lines.push(Line::styled(
2334 "Type a value · Enter save · Esc cancel",
2335 Style::default().fg(Color::DarkGray),
2336 ));
2337 }
2338 }
2339 lines
2340 }
2341 };
2342 frame.render_widget(Paragraph::new(lines), inner);
2343}
2344
2345fn selection_range(total: usize, focus: usize, max_rows: usize) -> std::ops::Range<usize> {
2346 if total == 0 || max_rows == 0 {
2347 return 0..0;
2348 }
2349 let focus = focus.min(total - 1);
2350 let visible = total.min(max_rows);
2351 let start = focus
2352 .saturating_add(1)
2353 .saturating_sub(visible)
2354 .min(total - visible);
2355 start..start + visible
2356}
2357
2358fn draw_skill_picker(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
2359 let matches = state.matching_skill_names();
2360 let total = matches.len();
2361 if total == 0 || area.is_empty() {
2362 return;
2363 }
2364
2365 frame.render_widget(Clear, area);
2368 let inner = Rect::new(
2369 area.x.saturating_add(2),
2370 area.y.saturating_add(1),
2371 area.width.saturating_sub(4),
2372 area.height.saturating_sub(2),
2373 );
2374 let buffer = frame.buffer_mut();
2375 for y in area.y..area.y.saturating_add(area.height) {
2376 for x in area.x..area.x.saturating_add(area.width) {
2377 buffer[(x, y)].set_bg(SKILL_PICKER_BACKGROUND);
2378 }
2379 }
2380 if inner.is_empty() {
2381 return;
2382 }
2383
2384 let focus = state.skill_picker_focus.min(total - 1);
2385 let header = Line::styled(
2386 format!("[{}/{}]", focus + 1, total),
2387 Style::default().fg(QUEUED_MESSAGE_COLOR),
2388 );
2389 frame.render_widget(
2390 Paragraph::new(header),
2391 Rect::new(inner.x, inner.y, inner.width, 1),
2392 );
2393
2394 let item_rows = inner.height.saturating_sub(1) as usize;
2395 for (row, index) in selection_range(total, focus, item_rows).enumerate() {
2396 let mut style = Style::default().fg(QUEUED_MESSAGE_COLOR);
2397 if index == focus {
2398 style = style.add_modifier(Modifier::BOLD);
2399 }
2400 let skill = Line::styled(format!("/{}", matches[index]), style);
2401 frame.render_widget(
2402 Paragraph::new(skill),
2403 Rect::new(inner.x, inner.y + 1 + row as u16, inner.width, 1),
2404 );
2405 }
2406}
2407
2408fn greeting_image_enabled() -> bool {
2409 std::env::var("LUCY_GREETING_IMAGE").as_deref() == Ok("true")
2410}
2411
2412#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2413struct WelcomeImageLayout {
2414 image_area: Rect,
2415 intro_area: Rect,
2416 image_size: Size,
2417}
2418
2419fn welcome_image_layout(area: Rect, intro_height: u16) -> Option<WelcomeImageLayout> {
2420 let available_height = area
2421 .height
2422 .saturating_sub(intro_height.saturating_add(WELCOME_IMAGE_GAP));
2423 let max_width = area.width.min(GREETING_IMAGE_SIZE.width);
2424 let max_height = available_height.min(GREETING_IMAGE_SIZE.height);
2425 let aspect_width = GREETING_IMAGE_SIZE.width / GREETING_IMAGE_SIZE.height;
2426 let image_height = max_height.min(max_width / aspect_width);
2427 let image_size = Size::new(image_height * aspect_width, image_height);
2428 if image_size.width < GREETING_IMAGE_MIN_SIZE.width
2429 || image_size.height < GREETING_IMAGE_MIN_SIZE.height
2430 {
2431 return None;
2432 }
2433
2434 let group_height = image_size.height + WELCOME_IMAGE_GAP + intro_height;
2435 let group_y = area.y + area.height.saturating_sub(group_height) / 2;
2436 Some(WelcomeImageLayout {
2437 image_area: Rect::new(
2438 area.x + (area.width - image_size.width) / 2,
2439 group_y,
2440 image_size.width,
2441 image_size.height,
2442 ),
2443 intro_area: Rect::new(
2444 area.x,
2445 group_y + image_size.height + WELCOME_IMAGE_GAP,
2446 area.width,
2447 intro_height,
2448 ),
2449 image_size,
2450 })
2451}
2452
2453type WelcomeImageCache = Mutex<HashMap<(u16, u16), Arc<Protocol>>>;
2454
2455fn welcome_image(size: Size) -> Arc<Protocol> {
2456 static IMAGES: OnceLock<WelcomeImageCache> = OnceLock::new();
2457 let images = IMAGES.get_or_init(|| Mutex::new(HashMap::new()));
2458 let mut images = images
2459 .lock()
2460 .expect("welcome image cache should not be poisoned");
2461 images
2462 .entry((size.width, size.height))
2463 .or_insert_with(|| {
2464 let image = image::load_from_memory(GREETING_IMAGE_BYTES)
2465 .expect("embedded greeting PNG should decode");
2466 let image = dim_welcome_image(image);
2467 Arc::new(
2468 Picker::halfblocks()
2469 .new_protocol(image, size, Resize::Fit(None))
2470 .expect("embedded greeting PNG should convert to halfblocks"),
2471 )
2472 })
2473 .clone()
2474}
2475
2476fn dim_welcome_image(image: image::DynamicImage) -> image::DynamicImage {
2477 let mut image = image.to_rgba8();
2478 for pixel in image.pixels_mut() {
2479 for channel in pixel.0.iter_mut().take(3) {
2480 *channel = (u16::from(*channel) * WELCOME_IMAGE_BRIGHTNESS_PERCENT / 100) as u8;
2481 }
2482 }
2483 image::DynamicImage::ImageRgba8(image)
2484}
2485
2486fn logo_lines() -> Vec<Line<'static>> {
2487 let max_width = LOGO_TEXT
2488 .lines()
2489 .map(|line| line.chars().count())
2490 .max()
2491 .unwrap_or(0);
2492 LOGO_TEXT
2493 .lines()
2494 .map(|line| {
2495 let spans: Vec<Span> = line
2496 .chars()
2497 .enumerate()
2498 .map(|(index, character)| {
2499 let progress = if max_width <= 1 {
2500 0.0
2501 } else {
2502 index as f32 / (max_width - 1) as f32
2503 };
2504 let color = Color::Rgb(
2505 interpolate_color(LOGO_START_COLOR.0, LOGO_END_COLOR.0, progress),
2506 interpolate_color(LOGO_START_COLOR.1, LOGO_END_COLOR.1, progress),
2507 interpolate_color(LOGO_START_COLOR.2, LOGO_END_COLOR.2, progress),
2508 );
2509 Span::styled(character.to_string(), Style::default().fg(color))
2510 })
2511 .collect();
2512 Line::from(spans)
2513 })
2514 .collect()
2515}
2516
2517fn welcome_line() -> Line<'static> {
2518 let character_count = WELCOME_MESSAGE.chars().count();
2519 let spans = WELCOME_MESSAGE
2520 .chars()
2521 .enumerate()
2522 .map(|(index, character)| {
2523 let progress = if character_count <= 1 {
2524 0.0
2525 } else {
2526 index as f32 / (character_count - 1) as f32
2527 };
2528 let color = Color::Rgb(
2529 interpolate_color(WELCOME_START_COLOR.0, WELCOME_END_COLOR.0, progress),
2530 interpolate_color(WELCOME_START_COLOR.1, WELCOME_END_COLOR.1, progress),
2531 interpolate_color(WELCOME_START_COLOR.2, WELCOME_END_COLOR.2, progress),
2532 );
2533 Span::styled(character.to_string(), Style::default().fg(color))
2534 })
2535 .collect::<Vec<_>>();
2536 Line::from(spans)
2537}
2538
2539fn interpolate_color(start: u8, end: u8, progress: f32) -> u8 {
2540 (start as f32 + (end as f32 - start as f32) * progress).round() as u8
2541}
2542
2543fn welcome_lines(attached_agents: &[String]) -> Vec<Line<'static>> {
2544 let mut lines = vec![
2545 welcome_line(),
2546 Line::styled(WELCOME_VERSION, Style::default().fg(Color::DarkGray)),
2547 Line::raw(""),
2548 Line::styled(WELCOME_TAGLINE, Style::default().fg(Color::DarkGray)),
2549 Line::raw(""),
2550 ];
2551
2552 if attached_agents.is_empty() {
2553 lines.push(Line::styled(
2554 "Attached AGENTS.md: none",
2555 Style::default().fg(Color::DarkGray),
2556 ));
2557 } else {
2558 lines.push(Line::styled(
2559 "Attached AGENTS.md:",
2560 Style::default().fg(Color::DarkGray),
2561 ));
2562 lines.extend(
2563 attached_agents.iter().map(|path| {
2564 Line::styled(format!("• {path}"), Style::default().fg(Color::DarkGray))
2565 }),
2566 );
2567 }
2568
2569 lines
2570}
2571
2572fn transcript_lines(state: &UiState, width: u16) -> Vec<Line<'static>> {
2573 render_transcript_items(&state.transcript, width.max(1) as usize, state)
2574}
2575
2576fn render_transcript_items(
2577 transcript: &[TranscriptItem],
2578 width: usize,
2579 state: &UiState,
2580) -> Vec<Line<'static>> {
2581 let mut lines = Vec::new();
2582 let mut rendered_item = false;
2583
2584 for (index, item) in transcript.iter().enumerate() {
2585 if is_result_attached_to_call(transcript, index) {
2588 continue;
2589 }
2590 if rendered_item {
2591 lines.push(Line::raw(String::new()));
2592 }
2593 match item {
2594 TranscriptItem::User {
2595 text,
2596 skill_instruction_attached,
2597 } => {
2598 let text = redact_secret(text, Some(&state.secret));
2599 let trigger = skill_instruction_attached
2600 .then(|| active_skill_trigger(&text, &state.skill_names))
2601 .flatten();
2602 push_user_message_block(&mut lines, &text, trigger, width);
2603 }
2604 TranscriptItem::Assistant(text) => {
2605 let text = redact_secret(text, Some(&state.secret));
2606 push_wrapped(&mut lines, &text, width, Style::default());
2607 }
2608 TranscriptItem::ToolCall {
2609 id,
2610 name,
2611 arguments,
2612 } => {
2613 let result = matching_tool_result(transcript, index, id);
2614 let segments = if name == "cmd" {
2615 cmd_tool_segments(id, arguments, result, state)
2616 } else {
2617 generic_tool_segments(name, arguments, result, state)
2618 };
2619 push_spans_wrapped(&mut lines, &segments, width);
2620 }
2621 TranscriptItem::ToolResult {
2622 id: _,
2623 name: _,
2624 result,
2625 } => {
2626 let result_text = format_tool_result(result);
2627 let result_text = redact_secret(&result_text, Some(&state.secret));
2628 push_spans_wrapped(&mut lines, &[(result_text, tool_result_style())], width);
2629 }
2630 TranscriptItem::Error(text) => {
2631 let text = redact_secret(text, Some(&state.secret));
2632 push_wrapped(&mut lines, &text, width, error_style());
2633 }
2634 TranscriptItem::Info(text) => {
2635 let text = redact_secret(text, Some(&state.secret));
2636 push_wrapped(&mut lines, &text, width, info_style());
2637 }
2638 TranscriptItem::Reasoning { complete } => {
2639 let text = if *complete {
2640 "Reasoning Complete".to_owned()
2641 } else {
2642 format!("Reasoning... {}", spinner_frame(state))
2643 };
2644 push_wrapped(&mut lines, &text, width, thinking_style());
2645 }
2646 }
2647 rendered_item = true;
2648 }
2649 if lines.is_empty() {
2650 lines.push(Line::raw(""));
2651 }
2652 lines
2653}
2654
2655fn running_tool_status(state: &UiState) -> String {
2657 tool_spinner_frame(state)
2658}
2659
2660fn cmd_tool_segments(
2661 call_id: &str,
2662 arguments: &str,
2663 result: Option<&Value>,
2664 state: &UiState,
2665) -> Vec<(String, Style)> {
2666 let command = redact_secret(&command_display(arguments), Some(&state.secret));
2667 if let Some(result) = result {
2668 let (icon, status, status_style) = cmd_result_status(result);
2669 if status == "done" || state.cmd_result_started_at.contains_key(call_id) {
2670 let text = if status == "done" {
2671 format!("{icon} cmd $ {command}")
2672 } else {
2673 format!("{icon} cmd $ {command} → {status}")
2674 };
2675 return cmd_result_segments(call_id, &text, cmd_result_target_color(result), state);
2676 }
2677 vec![
2678 (format!("{icon} cmd $ {command} → "), status_style),
2679 (status, status_style),
2680 ]
2681 } else {
2682 vec![
2683 (format!("· cmd $ {command} "), pending_tool_call_style()),
2684 (running_tool_status(state), pending_tool_call_style()),
2685 ]
2686 }
2687}
2688
2689fn cmd_result_segments(
2694 call_id: &str,
2695 text: &str,
2696 target: Color,
2697 state: &UiState,
2698) -> Vec<(String, Style)> {
2699 let now = Instant::now();
2700 let Some(started_at) = state.cmd_result_started_at.get(call_id).copied() else {
2701 return vec![(text.to_owned(), Style::default().fg(target))];
2702 };
2703 if now.saturating_duration_since(started_at) >= TOOL_RESULT_SWEEP_DURATION {
2704 return vec![(text.to_owned(), Style::default().fg(target))];
2705 }
2706
2707 let character_count = text.chars().count();
2708 text.chars()
2709 .enumerate()
2710 .map(|(index, character)| {
2711 (
2712 character.to_string(),
2713 Style::default().fg(cmd_result_color_at(
2714 started_at,
2715 now,
2716 index,
2717 character_count,
2718 target,
2719 )),
2720 )
2721 })
2722 .collect()
2723}
2724
2725fn cmd_result_color_at(
2726 started_at: Instant,
2727 now: Instant,
2728 character_index: usize,
2729 character_count: usize,
2730 target: Color,
2731) -> Color {
2732 let elapsed = now.saturating_duration_since(started_at);
2733 if elapsed >= TOOL_RESULT_SWEEP_DURATION {
2734 return target;
2735 }
2736
2737 let progress = elapsed.as_secs_f32() / TOOL_RESULT_SWEEP_DURATION.as_secs_f32();
2738 let character_position = if character_count <= 1 {
2739 0.0
2740 } else {
2741 character_index as f32 / (character_count - 1) as f32
2742 };
2743 let fade_start = character_position * (1.0 - TOOL_RESULT_CHARACTER_FADE_PORTION);
2744 let character_progress =
2745 ((progress - fade_start) / TOOL_RESULT_CHARACTER_FADE_PORTION).clamp(0.0, 1.0);
2746 let character_progress =
2747 character_progress * character_progress * (3.0 - 2.0 * character_progress);
2748 let (target_red, target_green, target_blue) = tool_result_color_rgb(target);
2749 Color::Rgb(
2750 interpolate_color(PENDING_TOOL_COLOR_RGB.0, target_red, character_progress),
2751 interpolate_color(PENDING_TOOL_COLOR_RGB.1, target_green, character_progress),
2752 interpolate_color(PENDING_TOOL_COLOR_RGB.2, target_blue, character_progress),
2753 )
2754}
2755
2756fn command_display(arguments: &str) -> String {
2757 serde_json::from_str::<Value>(arguments)
2758 .ok()
2759 .and_then(|value| {
2760 value
2761 .get("command")
2762 .and_then(Value::as_str)
2763 .map(str::to_owned)
2764 })
2765 .map(|command| truncate_tool_call(&command))
2766 .unwrap_or_else(|| truncate_tool_call(arguments))
2767}
2768
2769fn cmd_result_target_color(result: &Value) -> Color {
2770 if result
2771 .get("canceled")
2772 .and_then(Value::as_bool)
2773 .unwrap_or(false)
2774 || result
2775 .get("timed_out")
2776 .and_then(Value::as_bool)
2777 .unwrap_or(false)
2778 {
2779 return TOOL_WARNING_COLOR;
2780 }
2781 if result.get("error").is_some()
2782 || matches!(result.get("exit_code").and_then(Value::as_i64), Some(code) if code != 0)
2783 {
2784 return TOOL_FAILURE_COLOR;
2785 }
2786 TOOL_SUCCESS_COLOR
2787}
2788
2789fn tool_result_color_rgb(color: Color) -> (u8, u8, u8) {
2790 let Color::Rgb(red, green, blue) = color else {
2791 unreachable!("cmd result transition colours are RGB")
2792 };
2793 (red, green, blue)
2794}
2795
2796fn cmd_result_status(result: &Value) -> (char, String, Style) {
2797 let target = cmd_result_target_color(result);
2798 if result.get("status").and_then(Value::as_str) == Some("running") {
2799 let id = result
2800 .get("background_id")
2801 .and_then(Value::as_str)
2802 .unwrap_or("background");
2803 return ('↗', id.to_owned(), Style::default().fg(target));
2804 }
2805 if result
2806 .get("canceled")
2807 .and_then(Value::as_bool)
2808 .unwrap_or(false)
2809 {
2810 return ('!', "canceled".to_owned(), Style::default().fg(target));
2811 }
2812 if result
2813 .get("timed_out")
2814 .and_then(Value::as_bool)
2815 .unwrap_or(false)
2816 {
2817 return ('!', "timeout".to_owned(), Style::default().fg(target));
2818 }
2819 if result.get("error").is_some() {
2820 return ('×', "error".to_owned(), Style::default().fg(target));
2821 }
2822 match result.get("exit_code").and_then(Value::as_i64) {
2823 Some(0) => ('✓', "done".to_owned(), Style::default().fg(target)),
2824 Some(code) => ('×', format!("exit {code}"), Style::default().fg(target)),
2825 None => ('✓', "done".to_owned(), Style::default().fg(target)),
2826 }
2827}
2828
2829fn generic_tool_segments(
2830 name: &str,
2831 arguments: &str,
2832 result: Option<&Value>,
2833 state: &UiState,
2834) -> Vec<(String, Style)> {
2835 let call_text = redact_secret(
2836 &format!("[tool:{name} {}]", call_arguments(arguments)),
2837 Some(&state.secret),
2838 );
2839 let mut segments = vec![(
2840 call_text,
2841 if result.is_some() {
2842 tool_call_style()
2843 } else {
2844 pending_tool_call_style()
2845 },
2846 )];
2847 if let Some(result) = result {
2848 let result_text = redact_secret(&format_tool_result(result), Some(&state.secret));
2849 segments.push((" > ".to_owned(), Style::default()));
2850 segments.push((result_text, tool_result_style()));
2851 } else {
2852 segments.push((
2853 format!(" {}", tool_spinner_frame(state)),
2854 pending_tool_call_style(),
2855 ));
2856 }
2857 segments
2858}
2859
2860fn matching_tool_result<'a>(
2861 transcript: &'a [TranscriptItem],
2862 call_index: usize,
2863 call_id: &str,
2864) -> Option<&'a Value> {
2865 transcript
2866 .iter()
2867 .skip(call_index + 1)
2868 .find_map(|item| match item {
2869 TranscriptItem::ToolResult { id, result, .. } if id == call_id => Some(result),
2870 _ => None,
2871 })
2872}
2873
2874fn is_result_attached_to_call(transcript: &[TranscriptItem], result_index: usize) -> bool {
2875 let TranscriptItem::ToolResult { id, .. } = &transcript[result_index] else {
2876 return false;
2877 };
2878 let Some(call_index) = transcript[..result_index].iter().rposition(
2879 |item| matches!(item, TranscriptItem::ToolCall { id: call_id, .. } if call_id == id),
2880 ) else {
2881 return false;
2882 };
2883 !transcript[call_index + 1..result_index].iter().any(
2884 |item| matches!(item, TranscriptItem::ToolResult { id: result_id, .. } if result_id == id),
2885 )
2886}
2887
2888const TOOL_CALL_PREVIEW_CHARS: usize = 100;
2889
2890fn truncate_tool_call(output: &str) -> String {
2891 let mut result: String = output.chars().take(TOOL_CALL_PREVIEW_CHARS).collect();
2892 if output.chars().count() > TOOL_CALL_PREVIEW_CHARS {
2893 result.push('…');
2894 }
2895 result
2896}
2897
2898fn call_arguments(arguments: &str) -> String {
2902 let parsed: Value = match serde_json::from_str(arguments) {
2903 Ok(value) => value,
2904 Err(_) => return truncate_tool_call(arguments),
2905 };
2906 if let Some(command) = parsed.get("command").and_then(Value::as_str) {
2907 return format!("\"{}\"", truncate_tool_call(command));
2908 }
2909 let serialized = serde_json::to_string(&parsed).unwrap_or_else(|_| arguments.to_owned());
2910 truncate_tool_call(&serialized)
2911}
2912
2913fn format_tool_result(result: &Value) -> String {
2917 let stdout = result.get("stdout").and_then(Value::as_str).unwrap_or("");
2918 let stderr = result.get("stderr").and_then(Value::as_str).unwrap_or("");
2919 let output = if !stdout.is_empty() { stdout } else { stderr };
2920 let truncated = truncate_output(output);
2921 let json_string = serde_json::to_string(&truncated).unwrap_or_else(|_| "\"\"".to_owned());
2924 format!("[{json_string}]")
2925}
2926
2927const RESULT_PREVIEW_CHARS: usize = 50;
2928
2929fn truncate_output(output: &str) -> String {
2930 let mut result: String = output.chars().take(RESULT_PREVIEW_CHARS).collect();
2931 if output.chars().count() > RESULT_PREVIEW_CHARS {
2932 result.push('…');
2933 }
2934 result
2935}
2936
2937fn user_message_style() -> Style {
2938 Style::default().fg(USER_BORDER_COLOR)
2939}
2940
2941fn push_user_message_block(
2944 lines: &mut Vec<Line<'static>>,
2945 text: &str,
2946 active_skill_trigger: Option<&str>,
2947 width: usize,
2948) {
2949 if width < 3 {
2950 lines.extend(styled_text_lines(
2951 text,
2952 active_skill_trigger,
2953 width.max(1),
2954 Style::default().fg(Color::White),
2955 ));
2956 return;
2957 }
2958
2959 let border_style = user_message_style();
2960 let rows = styled_text_lines(
2961 text,
2962 active_skill_trigger,
2963 width - 2,
2964 Style::default().fg(Color::White),
2965 );
2966 lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
2967 for row in rows {
2968 let mut spans = Vec::with_capacity(row.spans.len() + 2);
2969 spans.push(Span::styled(USER_BORDER_GLYPH, border_style));
2970 spans.push(Span::styled(" ", Style::default().fg(Color::White)));
2971 spans.extend(row.spans);
2972 lines.push(Line::from(spans));
2973 }
2974 lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
2975}
2976
2977fn tool_call_style() -> Style {
2978 Style::default().fg(Color::Magenta)
2979}
2980
2981fn pending_tool_call_style() -> Style {
2982 Style::default().fg(PENDING_TOOL_COLOR)
2983}
2984
2985fn tool_result_style() -> Style {
2986 Style::default().fg(Color::DarkGray)
2987}
2988
2989fn error_style() -> Style {
2990 Style::default().fg(Color::Red)
2991}
2992
2993fn info_style() -> Style {
2994 Style::default().fg(Color::DarkGray)
2995}
2996
2997fn context_status_text(state: &UiState) -> String {
2998 let used = format_context_tokens(state.context_tokens);
2999 let Some(window) = state.context_window else {
3000 return format!("Context: {used}/? (?%) ??????????");
3001 };
3002 let percentage = context_percentage(state.context_tokens, window);
3003 format!(
3004 "Context: {used}/{} ({percentage}%) {}",
3005 format_context_tokens(window),
3006 context_progress_bar(state.context_tokens, window)
3007 )
3008}
3009
3010fn context_progress_bar(used: usize, window: usize) -> String {
3011 const WIDTH: usize = 10;
3012 let filled = if window == 0 {
3013 0
3014 } else {
3015 (used as u128 * WIDTH as u128)
3016 .div_ceil(window as u128)
3017 .min(WIDTH as u128) as usize
3018 };
3019 format!("{}{}", "█".repeat(filled), "░".repeat(WIDTH - filled))
3020}
3021
3022fn context_status_style(_state: &UiState) -> Style {
3023 Style::default().fg(CONSOLE_STATUS_COLOR)
3024}
3025
3026fn context_percentage(used: usize, window: usize) -> usize {
3027 if window == 0 {
3028 return 0;
3029 }
3030 ((used as u128 * 100).div_ceil(window as u128)) as usize
3031}
3032
3033fn format_context_tokens(tokens: usize) -> String {
3034 if tokens >= 1_000_000 {
3035 format!("{:.2}M", tokens as f64 / 1_000_000.0)
3036 } else if tokens >= 1_000 {
3037 format!("{:.1}K", tokens as f64 / 1_000.0)
3038 } else {
3039 tokens.to_string()
3040 }
3041}
3042
3043fn model_status_line(state: &UiState, effort: &str, width: u16) -> Line<'static> {
3044 model_status_line_at(
3045 state,
3046 effort,
3047 state.console_animation_elapsed_at(Instant::now()),
3048 width,
3049 )
3050}
3051
3052fn model_status_line_at(
3053 state: &UiState,
3054 effort: &str,
3055 elapsed: Duration,
3056 width: u16,
3057) -> Line<'static> {
3058 let model = redact_secret(&state.model, Some(&state.secret));
3059 let effort = redact_secret(effort, Some(&state.secret));
3060 let context = context_status_text(state);
3061 let context_width = UnicodeWidthStr::width(context.as_str());
3062 let model_style = if state.busy {
3063 Style::default().fg(console_accent_at(elapsed))
3064 } else {
3065 context_status_style(state)
3066 };
3067 let status_style = context_status_style(state);
3068 let mut spans = vec![
3069 Span::styled(model, model_style),
3070 Span::styled(format!(" · {effort}"), status_style),
3071 ];
3072 if state.busy {
3073 let accent = console_accent_at(elapsed);
3074 let (head, _) = busy_indicator_position_at(elapsed);
3075 spans.push(Span::raw(" "));
3076 for (index, character) in busy_indicator_frame_at(elapsed).chars().enumerate() {
3077 let distance = if character == BUSY_INDICATOR_BLOCK && index != head {
3078 Some(index.abs_diff(head))
3079 } else {
3080 None
3081 };
3082 let color = busy_indicator_color(accent, distance);
3083 spans.push(Span::styled(
3084 character.to_string(),
3085 Style::default().fg(color),
3086 ));
3087 }
3088 }
3089 let left_width = spans
3090 .iter()
3091 .map(|span| UnicodeWidthStr::width(span.content.as_ref()))
3092 .sum::<usize>();
3093 let gap = usize::from(width).saturating_sub(left_width + context_width);
3094 if gap > 0 {
3095 spans.push(Span::raw(" ".repeat(gap)));
3096 }
3097 spans.push(Span::styled(context, status_style));
3098 Line::from(spans)
3099}
3100
3101fn console_accent_cycle() -> Duration {
3102 CONSOLE_ACCENT_CYCLE_DURATION
3103}
3104
3105fn console_accent_at(elapsed: Duration) -> Color {
3106 let cycle_progress =
3107 (elapsed.as_secs_f32() / console_accent_cycle().as_secs_f32()).rem_euclid(1.0);
3108 let progress = if cycle_progress <= 0.5 {
3109 cycle_progress * 2.0
3110 } else {
3111 (1.0 - cycle_progress) * 2.0
3112 };
3113 desaturate_console_accent(
3114 interpolate_color(CONSOLE_ACCENT_LAVENDER.0, CONSOLE_ACCENT_TEAL.0, progress),
3115 interpolate_color(CONSOLE_ACCENT_LAVENDER.1, CONSOLE_ACCENT_TEAL.1, progress),
3116 interpolate_color(CONSOLE_ACCENT_LAVENDER.2, CONSOLE_ACCENT_TEAL.2, progress),
3117 )
3118}
3119
3120fn desaturate_console_accent(red: u8, green: u8, blue: u8) -> Color {
3121 let neutral = ((u16::from(red) + u16::from(green) + u16::from(blue)) / 3) as u8;
3122 Color::Rgb(
3123 interpolate_color(red, neutral, CONSOLE_ACCENT_DESATURATION),
3124 interpolate_color(green, neutral, CONSOLE_ACCENT_DESATURATION),
3125 interpolate_color(blue, neutral, CONSOLE_ACCENT_DESATURATION),
3126 )
3127}
3128
3129fn thinking_style() -> Style {
3130 Style::default().fg(Color::DarkGray)
3131}
3132
3133const PULSE_LEVELS: [char; 7] = ['▁', '▂', '▃', '▅', '▆', '▇', '█'];
3136const PULSE_BAR_PERIODS: [u128; 5] = [12, 16, 20, 24, 15];
3137const PULSE_BAR_PHASES: [u128; 5] = [0, 5, 13, 9, 3];
3138const BUSY_INDICATOR_TRACK_LENGTH: usize = 5;
3139const BUSY_INDICATOR_TAIL_LENGTH: usize = 2;
3140const BUSY_INDICATOR_WIDTH: usize = BUSY_INDICATOR_TRACK_LENGTH + BUSY_INDICATOR_TAIL_LENGTH;
3141const BUSY_INDICATOR_BLOCK: char = '■';
3142const BUSY_INDICATOR_TAIL_OPACITY: [f32; BUSY_INDICATOR_TAIL_LENGTH] = [0.55, 0.25];
3143const BUSY_INDICATOR_PERIOD_TICKS: u128 = (BUSY_INDICATOR_TRACK_LENGTH as u128 - 1) * 2;
3144const BUSY_INDICATOR_TICK: Duration = Duration::from_micros(62_500);
3146const PULSE_TICK: Duration = Duration::from_millis(50);
3147const TOOL_SPINNER_FRAMES: [char; 4] = ['|', '/', '-', '\\'];
3148const TOOL_SPINNER_FRAME_DURATION: Duration = Duration::from_millis(100);
3149
3150const ACTIVITY_TRANSITION_DURATION: Duration = Duration::from_millis(400);
3154const PULSE_ENTRY_FRAME: Duration = Duration::from_millis(950);
3157
3158fn spinner_frame(state: &UiState) -> String {
3159 pulse_frame(state.activity_levels_at(Instant::now()))
3160}
3161
3162#[cfg(test)]
3163fn spinner_frame_at(elapsed: Duration) -> String {
3164 pulse_frame(pulse_levels_at(elapsed))
3165}
3166
3167fn tool_spinner_frame(state: &UiState) -> String {
3171 tool_spinner_frame_at(state.tool_animation_epoch.elapsed()).to_string()
3172}
3173
3174fn tool_spinner_frame_at(elapsed: Duration) -> char {
3175 let frame = (elapsed.as_millis() / TOOL_SPINNER_FRAME_DURATION.as_millis()) as usize;
3176 TOOL_SPINNER_FRAMES[frame % TOOL_SPINNER_FRAMES.len()]
3177}
3178
3179fn pulse_frame(levels: [usize; PULSE_BAR_PERIODS.len()]) -> String {
3180 levels
3181 .into_iter()
3182 .map(|level| PULSE_LEVELS[level])
3183 .collect()
3184}
3185
3186fn busy_indicator_position_at(elapsed: Duration) -> (usize, bool) {
3187 let tick = elapsed.as_micros() / BUSY_INDICATOR_TICK.as_micros();
3188 let phase = tick % BUSY_INDICATOR_PERIOD_TICKS;
3189 if phase < BUSY_INDICATOR_TRACK_LENGTH as u128 {
3190 (
3191 phase as usize,
3192 phase < BUSY_INDICATOR_TRACK_LENGTH as u128 - 1,
3193 )
3194 } else {
3195 ((BUSY_INDICATOR_PERIOD_TICKS - phase) as usize, false)
3196 }
3197}
3198
3199fn busy_indicator_frame_at(elapsed: Duration) -> String {
3200 let (head, moving_right) = busy_indicator_position_at(elapsed);
3201 let mut frame = vec![' '; BUSY_INDICATOR_WIDTH];
3202 frame[head] = BUSY_INDICATOR_BLOCK;
3203 for distance in 1..=BUSY_INDICATOR_TAIL_LENGTH {
3204 let tail = if moving_right {
3205 head.checked_sub(distance)
3206 } else {
3207 head.checked_add(distance)
3208 };
3209 if let Some(tail) = tail.filter(|&index| index < BUSY_INDICATOR_WIDTH) {
3210 frame[tail] = BUSY_INDICATOR_BLOCK;
3211 }
3212 }
3213 frame.into_iter().collect()
3214}
3215
3216fn busy_indicator_color(accent: Color, distance: Option<usize>) -> Color {
3219 let Some(distance) = distance else {
3220 return accent;
3221 };
3222 let Color::Rgb(red, green, blue) = accent else {
3223 return accent;
3224 };
3225 let opacity = BUSY_INDICATOR_TAIL_OPACITY
3226 .get(distance.saturating_sub(1))
3227 .copied()
3228 .unwrap_or(0.0);
3229 Color::Rgb(
3230 interpolate_color(BUSY_INDICATOR_FADE_BASE_RGB.0, red, opacity),
3231 interpolate_color(BUSY_INDICATOR_FADE_BASE_RGB.1, green, opacity),
3232 interpolate_color(BUSY_INDICATOR_FADE_BASE_RGB.2, blue, opacity),
3233 )
3234}
3235fn pulse_levels_at(elapsed: Duration) -> [usize; PULSE_BAR_PERIODS.len()] {
3236 let tick = elapsed.as_millis() / PULSE_TICK.as_millis();
3237 std::array::from_fn(|index| {
3238 pulse_level_at(tick, PULSE_BAR_PERIODS[index], PULSE_BAR_PHASES[index])
3239 })
3240}
3241
3242fn interpolate_pulse_levels(
3243 from: [usize; PULSE_BAR_PERIODS.len()],
3244 to: [usize; PULSE_BAR_PERIODS.len()],
3245 elapsed: Duration,
3246) -> [usize; PULSE_BAR_PERIODS.len()] {
3247 let elapsed = elapsed.min(ACTIVITY_TRANSITION_DURATION).as_millis();
3248 let duration = ACTIVITY_TRANSITION_DURATION.as_millis();
3249 std::array::from_fn(|index| {
3250 let start = from[index] as i128;
3251 let distance = to[index] as i128 - start;
3252 (start + distance * elapsed as i128 / duration as i128) as usize
3253 })
3254}
3255
3256fn pulse_level_at(tick: u128, period: u128, phase: u128) -> usize {
3257 let position = (tick + phase) % period;
3258 let half_period = period / 2;
3259 let distance_from_floor = if position <= half_period {
3260 position
3261 } else {
3262 period - position
3263 };
3264 (distance_from_floor * (PULSE_LEVELS.len() - 1) as u128 / half_period) as usize
3265}
3266
3267fn push_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, style: Style) {
3268 let mut added = false;
3269 for piece in wrap_text(text, width) {
3270 lines.push(Line::styled(piece, style));
3271 added = true;
3272 }
3273 if !added {
3274 lines.push(Line::styled(String::new(), style));
3275 }
3276}
3277
3278fn push_spans_wrapped(lines: &mut Vec<Line<'static>>, segments: &[(String, Style)], width: usize) {
3282 let mut current_spans: Vec<Span<'static>> = Vec::new();
3283 let mut current_width = 0usize;
3284 for (text, style) in segments {
3285 for character in text.chars() {
3286 let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
3287 if current_width + char_width > width && !current_spans.is_empty() {
3288 lines.push(Line::from(std::mem::take(&mut current_spans)));
3289 current_width = 0;
3290 }
3291 let mut buffer = [0u8; 4];
3292 let s = character.encode_utf8(&mut buffer);
3293 current_spans.push(Span::styled(s.to_owned(), *style));
3294 current_width += char_width;
3295 }
3296 }
3297 if current_spans.is_empty() {
3298 current_spans.push(Span::raw(String::new()));
3299 }
3300 lines.push(Line::from(current_spans));
3301}
3302
3303fn wrap_text(text: &str, width: usize) -> Vec<String> {
3309 if width == 0 {
3310 return text.lines().map(str::to_owned).collect();
3311 }
3312 let mut rows = Vec::new();
3313 for line in text.split('\n') {
3316 rows.extend(wrap_line(line, width));
3317 }
3318 if rows.is_empty() {
3319 rows.push(String::new());
3320 }
3321 rows
3322}
3323
3324fn wrap_line(line: &str, width: usize) -> Vec<String> {
3325 let mut rows = Vec::new();
3326 let mut current = String::new();
3327 let mut current_width = 0usize;
3328 for character in line.chars() {
3329 let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
3330 if current_width + char_width > width && !current.is_empty() {
3331 rows.push(std::mem::take(&mut current));
3332 current_width = 0;
3333 }
3334 current.push(character);
3335 current_width += char_width;
3336 }
3337 rows.push(current);
3338 rows
3339}
3340
3341#[cfg(test)]
3342mod tests {
3343 use super::*;
3344
3345 #[test]
3346 fn turn_notifications_use_osc_777_with_fixed_secret_safe_messages() {
3347 let cases = [
3348 (TurnNotification::Completed, "Turn complete"),
3349 (TurnNotification::Interrupted, "Turn interrupted"),
3350 (TurnNotification::Failed, "Turn failed"),
3351 ];
3352
3353 for (notification, body) in cases {
3354 let mut output = Vec::new();
3355 send_turn_notification(&mut output, notification).expect("notification");
3356 assert_eq!(
3357 output,
3358 format!("\x1b]777;notify;Lucy;{body}\x07").into_bytes()
3359 );
3360 }
3361 }
3362
3363 #[test]
3364 fn turn_notifications_follow_the_terminal_turn_status() {
3365 assert_eq!(
3366 turn_notification_for_status("finalizing"),
3367 TurnNotification::Completed
3368 );
3369 assert_eq!(
3370 turn_notification_for_status("cancelling"),
3371 TurnNotification::Interrupted
3372 );
3373 assert_eq!(
3374 turn_notification_for_status("error"),
3375 TurnNotification::Failed
3376 );
3377 }
3378
3379 struct FailingWriter;
3380
3381 impl Write for FailingWriter {
3382 fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
3383 Err(io::Error::other("notification sink unavailable"))
3384 }
3385
3386 fn flush(&mut self) -> io::Result<()> {
3387 Err(io::Error::other("notification sink unavailable"))
3388 }
3389 }
3390
3391 #[test]
3392 fn notification_write_failure_does_not_keep_the_tui_busy() {
3393 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3394 state.busy = true;
3395 state.active_cancel = Some(CancellationToken::new());
3396 let mut writer = FailingWriter;
3397
3398 release_finished_turn(&mut writer, &mut state);
3399
3400 assert!(!state.busy);
3401 assert!(state.active_cancel.is_none());
3402 }
3403
3404 #[test]
3405 fn an_idle_finish_does_not_emit_a_duplicate_notification() {
3406 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3407 let mut output = Vec::new();
3408
3409 release_finished_turn(&mut output, &mut state);
3410
3411 assert!(output.is_empty());
3412 }
3413
3414 #[test]
3415 fn context_status_shows_used_window_and_percentage_in_uniform_gray() {
3416 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3417 .with_context(Some(100_000), 80_000);
3418
3419 assert_eq!(
3420 context_status_text(&state),
3421 "Context: 80.0K/100.0K (80%) ████████░░"
3422 );
3423 assert_eq!(
3424 context_status_style(&state).fg,
3425 Some(Color::Rgb(144, 144, 148))
3426 );
3427
3428 state.context_tokens = 80_001;
3429 assert_eq!(
3430 context_status_text(&state),
3431 "Context: 80.0K/100.0K (81%) █████████░"
3432 );
3433 assert_eq!(
3434 context_status_style(&state).fg,
3435 Some(Color::Rgb(144, 144, 148)),
3436 "crossing the compaction threshold does not recolor the status line"
3437 );
3438 }
3439
3440 #[test]
3441 fn context_status_keeps_percentage_consistent_at_capacity() {
3442 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3443 .with_context(Some(100_000), 99_001);
3444
3445 assert_eq!(
3446 context_status_text(&state),
3447 "Context: 99.0K/100.0K (100%) ██████████"
3448 );
3449
3450 state.context_tokens = 100_000;
3451 assert_eq!(
3452 context_status_text(&state),
3453 "Context: 100.0K/100.0K (100%) ██████████"
3454 );
3455
3456 state.context_tokens = 100_001;
3457 assert_eq!(
3458 context_status_text(&state),
3459 "Context: 100.0K/100.0K (101%) ██████████"
3460 );
3461 }
3462
3463 #[test]
3464 fn context_status_handles_unknown_window_without_highlighting() {
3465 let state = UiState::from_history(&[], "secret", "model", None, false);
3466
3467 assert_eq!(context_status_text(&state), "Context: 1/? (?%) ??????????");
3468 assert_eq!(
3469 context_status_style(&state).fg,
3470 Some(Color::Rgb(144, 144, 148))
3471 );
3472 }
3473
3474 #[test]
3475 fn tui_viewport_reserves_one_column_on_each_side_when_possible() {
3476 assert_eq!(
3477 tui_viewport(Rect::new(0, 0, 80, 10)),
3478 Rect::new(1, 0, 78, 10)
3479 );
3480 assert_eq!(
3481 tui_viewport(Rect::new(0, 0, 2, 10)),
3482 Rect::new(0, 0, 2, 10),
3483 "a two-column terminal cannot reserve two gutters"
3484 );
3485 }
3486
3487 #[test]
3488 fn tui_viewport_caps_at_one_hundred_columns_and_centers_it() {
3489 assert_eq!(
3490 tui_viewport(Rect::new(0, 0, 140, 10)),
3491 Rect::new(20, 0, TUI_MAX_WIDTH, 10)
3492 );
3493 assert_eq!(
3494 tui_viewport(Rect::new(0, 0, 103, 10)),
3495 Rect::new(1, 0, TUI_MAX_WIDTH, 10),
3496 "an odd remaining column stays on the right"
3497 );
3498 }
3499
3500 #[test]
3501 fn bottom_console_has_external_margins_without_losing_internal_padding() {
3502 let state = UiState::from_history(&[], "secret", "model", None, false);
3503 let viewport = tui_viewport(Rect::new(0, 0, 80, 14));
3504 let (chat, _, _, _, console, _) = ui_layout(&state, viewport);
3505 let content = console_content_area(console);
3506
3507 assert_eq!(chat.x, console.x);
3508 assert_eq!(chat.width, console.width);
3509 assert_eq!(
3510 console,
3511 Rect::new(viewport.x + 7, 8, viewport.width - 14, 5)
3512 );
3513 assert_eq!(console.y + console.height, viewport.y + viewport.height - 1);
3514 assert_eq!(content.x, console.x + 2);
3515 assert_eq!(content.width, console.width - 4);
3516 assert_eq!(content.y, console.y + 1);
3517 assert_eq!(content.y + content.height, console.y + console.height - 1);
3518
3519 for (width, margin, console_width) in [
3520 (1, 0, 1),
3521 (2, 0, 2),
3522 (3, 1, 1),
3523 (4, 1, 2),
3524 (5, 2, 1),
3525 (15, 0, 15),
3526 ] {
3527 let console = bottom_console_area(Rect::new(0, 0, width, 4), 0, 4);
3528 assert_eq!(console.x, margin, "width {width}");
3529 assert_eq!(console.width, console_width, "width {width}");
3530 }
3531 }
3532
3533 #[test]
3534 fn inset_console_width_drives_prompt_rows_and_vertical_navigation() {
3535 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3536 state.input = "x".repeat(71);
3537 let viewport = tui_viewport(Rect::new(0, 0, 80, 14));
3538 let console = ui_layout(&state, viewport).4;
3539 let prompt = prompt_area(console, &state);
3540
3541 assert_eq!(ui_prompt_content_width(viewport), prompt.width);
3542 assert_eq!(prompt.width, 60);
3543 assert_eq!(input_visible_rows(&state, prompt.width), 2);
3544 assert!(move_input_cursor_vertical(
3545 &mut state,
3546 ui_prompt_content_width(viewport) as usize,
3547 true,
3548 ));
3549 }
3550
3551 #[test]
3552 fn context_status_is_right_aligned_in_uniform_gray() {
3553 let state =
3554 UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
3555 let mut terminal =
3556 Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
3557
3558 terminal
3559 .draw(|frame| draw(frame, &state))
3560 .expect("draw statusline");
3561
3562 let buffer = terminal.backend().buffer();
3563 let status_area = ui_layout(&state, tui_viewport(Rect::new(0, 0, 80, 10))).5;
3564 let expected_context = "Context: 81/100 (81%) █████████░";
3565 let rendered = (status_area.x..status_area.x + status_area.width)
3566 .map(|x| buffer[(x, status_area.y)].symbol())
3567 .collect::<String>();
3568 assert!(rendered.ends_with(expected_context));
3569 assert_eq!(
3570 buffer[(status_area.x + status_area.width - 1, status_area.y)].symbol(),
3571 "░",
3572 "context is not pushed to the right edge"
3573 );
3574 assert!(rendered.starts_with("model · default"));
3575 for x in status_area.x..status_area.x + status_area.width {
3576 if buffer[(x, status_area.y)].symbol() != " " {
3577 assert_eq!(buffer[(x, status_area.y)].fg, CONSOLE_STATUS_COLOR);
3578 }
3579 }
3580 }
3581
3582 #[test]
3583 fn busy_model_name_and_indicator_share_the_animated_accent_gradient() {
3584 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3585 state.busy = true;
3586 let start = model_status_line_at(&state, "default", Duration::ZERO, 80);
3587 let middle = model_status_line_at(&state, "default", console_accent_cycle() / 2, 80);
3588 let start_accent = console_accent_at(Duration::ZERO);
3589 let middle_accent = console_accent_at(console_accent_cycle() / 2);
3590
3591 assert_eq!(start.spans[0].style.fg, Some(start_accent));
3592 assert_eq!(middle.spans[0].style.fg, Some(middle_accent));
3593 assert_eq!(start.spans[0].content, "model");
3594 assert_eq!(start.spans[1].content, " · default");
3595 assert_eq!(start.spans[2].content, " ");
3596 assert_eq!(start.spans[3].content, BUSY_INDICATOR_BLOCK.to_string());
3597 assert_eq!(start.spans[3].style.fg, Some(start_accent));
3598 assert_eq!(
3599 start.spans.last().unwrap().style.fg,
3600 Some(CONSOLE_STATUS_COLOR)
3601 );
3602 }
3603
3604 #[test]
3605 fn idle_model_status_has_no_busy_indicator() {
3606 let state = UiState::from_history(&[], "secret", "model", None, false);
3607 let start = model_status_line_at(&state, "default", Duration::ZERO, 80);
3608 let middle = model_status_line_at(&state, "default", console_accent_cycle() / 2, 80);
3609
3610 assert_eq!(start.spans[0].content, "model");
3611 assert_eq!(middle.spans[0].content, "model");
3612 assert_eq!(start.spans[0].style.fg, Some(CONSOLE_STATUS_COLOR));
3613 assert_eq!(middle.spans[0].style.fg, Some(CONSOLE_STATUS_COLOR));
3614 }
3615
3616 #[test]
3617 fn busy_indicator_is_a_five_cell_bounce_with_a_two_cell_tail() {
3618 let frames = (0..=BUSY_INDICATOR_PERIOD_TICKS)
3619 .map(|tick| busy_indicator_frame_at(BUSY_INDICATOR_TICK * tick as u32))
3620 .collect::<Vec<_>>();
3621
3622 assert_eq!(frames[0], "■ ");
3623 assert_eq!(frames[1], "■■ ");
3624 assert_eq!(frames[2], "■■■ ");
3625 assert_eq!(frames[4], " ■■■");
3626 assert_eq!(frames[5], " ■■■ ");
3627 assert_eq!(frames[7], " ■■■ ");
3628 assert_eq!(frames[8], frames[0]);
3629 assert!(frames
3630 .iter()
3631 .all(|frame| frame.chars().count() == BUSY_INDICATOR_WIDTH));
3632 assert_eq!(BUSY_INDICATOR_TRACK_LENGTH, 5);
3633 assert_eq!(BUSY_INDICATOR_TAIL_LENGTH, 2);
3634 assert_eq!(BUSY_INDICATOR_TICK, Duration::from_micros(62_500));
3635 }
3636
3637 fn color_distance_from_indicator_base(color: Color) -> u32 {
3638 let Color::Rgb(red, green, blue) = color else {
3639 return 0;
3640 };
3641 u32::from(red.abs_diff(BUSY_INDICATOR_FADE_BASE_RGB.0))
3642 + u32::from(green.abs_diff(BUSY_INDICATOR_FADE_BASE_RGB.1))
3643 + u32::from(blue.abs_diff(BUSY_INDICATOR_FADE_BASE_RGB.2))
3644 }
3645
3646 #[test]
3647 fn busy_indicator_tail_uses_same_block_with_progressively_fainter_colors() {
3648 let accent = Color::Rgb(180, 120, 240);
3649 let near = busy_indicator_color(accent, Some(1));
3650 let far = busy_indicator_color(accent, Some(2));
3651
3652 assert_eq!(BUSY_INDICATOR_BLOCK, '■');
3653 assert_ne!(near, accent);
3654 assert_ne!(far, near);
3655 assert!(color_distance_from_indicator_base(near) > color_distance_from_indicator_base(far));
3656 }
3657
3658 #[test]
3659 fn pulse_spinner_moves_each_bar_one_level_at_a_time() {
3660 let frames = (0..=240)
3661 .map(|tick| spinner_frame_at(PULSE_TICK * tick))
3662 .collect::<Vec<_>>();
3663 assert!(frames.iter().any(|frame| frame != &frames[0]));
3664 assert_eq!(PULSE_TICK, Duration::from_millis(50));
3665
3666 for pair in frames.windows(2) {
3667 let levels = pair
3668 .iter()
3669 .map(|frame| {
3670 frame
3671 .chars()
3672 .map(|bar| {
3673 PULSE_LEVELS
3674 .iter()
3675 .position(|level| *level == bar)
3676 .expect("known pulse level")
3677 })
3678 .collect::<Vec<_>>()
3679 })
3680 .collect::<Vec<_>>();
3681 assert_eq!(levels[0].len(), 5);
3682 assert!(
3683 levels[0]
3684 .iter()
3685 .zip(&levels[1])
3686 .all(|(before, after)| before.abs_diff(*after) <= 1),
3687 "pulse bars must not jump between adjacent ticks: {:?} -> {:?}",
3688 pair[0],
3689 pair[1]
3690 );
3691 }
3692 }
3693
3694 #[test]
3695 fn console_animation_clock_runs_during_entry_and_survives_active_status_changes() {
3696 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3697 state.set_status("working");
3698 let epoch = state.console_animation_epoch;
3699 assert_eq!(
3700 state.console_animation_elapsed_at(epoch + Duration::from_millis(200)),
3701 Duration::from_millis(200),
3702 "the console animation does not freeze during the activity ramp"
3703 );
3704
3705 state.set_status("compacting");
3706 assert_eq!(state.console_animation_epoch, epoch);
3707 state.set_status("working");
3708 assert_eq!(state.console_animation_epoch, epoch);
3709 }
3710
3711 #[test]
3712 fn console_accent_uses_a_fifteen_second_lavender_to_teal_round_trip() {
3713 assert_eq!(console_accent_cycle(), Duration::from_secs(15));
3714 assert_eq!(
3715 console_accent_at(Duration::ZERO),
3716 desaturate_console_accent(
3717 CONSOLE_ACCENT_LAVENDER.0,
3718 CONSOLE_ACCENT_LAVENDER.1,
3719 CONSOLE_ACCENT_LAVENDER.2,
3720 )
3721 );
3722 assert_eq!(
3723 console_accent_at(console_accent_cycle() / 2),
3724 desaturate_console_accent(
3725 CONSOLE_ACCENT_TEAL.0,
3726 CONSOLE_ACCENT_TEAL.1,
3727 CONSOLE_ACCENT_TEAL.2,
3728 )
3729 );
3730 assert_eq!(
3731 console_accent_at(console_accent_cycle()),
3732 console_accent_at(Duration::ZERO)
3733 );
3734 let midpoint = console_accent_at(console_accent_cycle() / 4);
3735 assert_ne!(
3736 midpoint,
3737 console_accent_at(Duration::ZERO),
3738 "the accent transitions continuously instead of holding at lavender"
3739 );
3740 assert_ne!(
3741 midpoint,
3742 console_accent_at(console_accent_cycle() / 2),
3743 "the accent transitions continuously instead of holding at teal"
3744 );
3745 }
3746
3747 #[test]
3748 fn model_status_accent_starts_lavender_with_fifteen_percent_desaturation() {
3749 assert_eq!(
3750 console_accent_at(Duration::ZERO),
3751 desaturate_console_accent(
3752 CONSOLE_ACCENT_LAVENDER.0,
3753 CONSOLE_ACCENT_LAVENDER.1,
3754 CONSOLE_ACCENT_LAVENDER.2,
3755 )
3756 );
3757 }
3758
3759 #[test]
3760 fn prompt_uses_two_cells_of_horizontal_console_padding() {
3761 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3762 state.input = "1234567890123456".to_owned();
3763 let area = Rect::new(0, 0, 20, 6);
3764 let mut terminal =
3765 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
3766 .expect("test terminal");
3767
3768 terminal
3769 .draw(|frame| draw(frame, &state))
3770 .expect("draw padded prompt");
3771
3772 let input_area = ui_layout(&state, tui_viewport(area)).4;
3773 let prompt = prompt_area(input_area, &state);
3774 assert_eq!(prompt.x, input_area.x + 2);
3775 assert_eq!(prompt.width, input_area.width.saturating_sub(4));
3776 assert_eq!(
3777 terminal.backend().buffer()[(input_area.x + 1, prompt.y)].symbol(),
3778 " ",
3779 "the two left padding cells remain blank"
3780 );
3781 assert_eq!(
3782 terminal.backend().buffer()[(input_area.x + input_area.width - 2, prompt.y)].symbol(),
3783 " ",
3784 "the two right padding cells remain blank"
3785 );
3786 terminal
3787 .backend_mut()
3788 .assert_cursor_position((input_area.x + 2, prompt.y));
3789 }
3790
3791 #[test]
3792 fn prompt_width_reduction_wraps_and_saturates_at_narrow_widths() {
3793 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3794 state.input = "12345".to_owned();
3795 state.cursor = state.input.chars().count();
3796 let input_area = Rect::new(3, 2, 6, 6);
3797 let prompt = prompt_area(input_area, &state);
3798
3799 assert_eq!(prompt.width, 2);
3800 assert_eq!(input_visible_rows(&state, prompt.width), 3);
3801 assert_eq!(bottom_content_heights(&state, input_area).prompt, 3);
3802 assert_eq!(
3803 cursor_row(&state.input, state.cursor, prompt.width as usize),
3804 2
3805 );
3806 state.cursor = 1;
3807 assert!(move_input_cursor_vertical(
3808 &mut state,
3809 prompt_content_width(input_area.width) as usize,
3810 true,
3811 ));
3812 assert_eq!(state.cursor, 3);
3813 assert_eq!(prompt_content_width(0), 0);
3814 assert_eq!(prompt_content_width(1), 0);
3815 assert_eq!(prompt_content_width(2), 0);
3816 assert_eq!(prompt_content_width(3), 0);
3817 assert_eq!(prompt_content_width(4), 0);
3818 assert_eq!(prompt_content_width(5), 1);
3819 }
3820
3821 #[test]
3822 fn ready_submission_bypasses_queue_and_is_not_added_twice_when_started() {
3823 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3824
3825 state.submit_user("send now");
3826
3827 assert!(state.queued_messages.is_empty());
3828 assert_eq!(state.transcript.len(), 1);
3829 assert!(matches!(
3830 &state.transcript[0],
3831 TranscriptItem::User { text, .. } if text == "send now"
3832 ));
3833
3834 state.start_queued_user("send now");
3837 assert_eq!(state.transcript.len(), 1);
3838 }
3839
3840 #[test]
3841 fn busy_submission_remains_queued_until_its_turn_starts() {
3842 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3843 state.busy = true;
3844
3845 state.submit_user("send later");
3846
3847 assert_eq!(state.queued_messages, ["send later"]);
3848 assert!(state.transcript.is_empty());
3849
3850 state.start_queued_user("send later");
3851 assert!(state.queued_messages.is_empty());
3852 assert!(matches!(
3853 &state.transcript[..],
3854 [TranscriptItem::User { text, .. }] if text == "send later"
3855 ));
3856 }
3857
3858 #[test]
3859 fn skill_picker_stays_above_a_visible_message_queue() {
3860 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3861 .with_skill_names(vec!["release-notes".to_owned()]);
3862 state.queue_user("next task");
3863 state.input = "/".to_owned();
3864 state.input_changed();
3865
3866 let area = Rect::new(0, 0, 80, 12);
3867 let (_, picker_area, _, queue_area, input_area, _) = ui_layout(&state, tui_viewport(area));
3868 let picker_area = picker_area.expect("skill picker area");
3869 let queue_area = queue_area.expect("message queue area");
3870 assert_eq!(picker_area.y + picker_area.height, input_area.y);
3871 assert_eq!(queue_area.y, input_area.y + 1);
3872 assert_eq!(queue_area.x, input_area.x + 2);
3873 }
3874
3875 #[test]
3876 fn fresh_sessions_show_the_versioned_gradient_welcome_message() {
3877 let state = UiState::from_history(&[], "secret", "model", None, false);
3878 assert!(state.welcome_visible);
3879
3880 let line = welcome_line();
3881 assert_eq!(line.to_string(), WELCOME_MESSAGE);
3882 assert_eq!(WELCOME_VERSION, concat!("v", env!("CARGO_PKG_VERSION")));
3883 assert_eq!(
3884 line.spans.first().and_then(|span| span.style.fg),
3885 Some(Color::Rgb(
3886 WELCOME_START_COLOR.0,
3887 WELCOME_START_COLOR.1,
3888 WELCOME_START_COLOR.2,
3889 ))
3890 );
3891 assert_eq!(
3892 line.spans.last().and_then(|span| span.style.fg),
3893 Some(Color::Rgb(
3894 WELCOME_END_COLOR.0,
3895 WELCOME_END_COLOR.1,
3896 WELCOME_END_COLOR.2,
3897 ))
3898 );
3899 }
3900
3901 #[test]
3902 fn welcome_image_brightness_is_reduced_without_changing_alpha() {
3903 let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
3904 1,
3905 1,
3906 image::Rgba([200, 100, 0, 37]),
3907 ));
3908 let dimmed = dim_welcome_image(image).to_rgba8();
3909 assert_eq!(dimmed.get_pixel(0, 0).0, [170, 85, 0, 37]);
3910 }
3911
3912 #[test]
3913 fn spacious_welcome_uses_the_embedded_png() {
3914 let image = welcome_image(GREETING_IMAGE_SIZE);
3915 assert_eq!(image.size(), GREETING_IMAGE_SIZE);
3916 let layout = welcome_image_layout(Rect::new(0, 0, 100, 40), 6).expect("image fits");
3917 assert_eq!(layout.image_size, GREETING_IMAGE_SIZE);
3918 assert_eq!(layout.image_area, Rect::new(10, 6, 80, 20));
3919 assert_eq!(layout.intro_area.y, layout.image_area.y + 21);
3920 }
3921
3922 #[test]
3923 fn cramped_welcome_falls_back_to_the_text_greeting() {
3924 assert_eq!(welcome_image_layout(Rect::new(0, 0, 80, 16), 6), None);
3925 assert_eq!(welcome_image_layout(Rect::new(0, 0, 39, 40), 6), None);
3926 let scaled = welcome_image_layout(Rect::new(0, 0, 60, 25), 6).expect("scaled image fits");
3927 assert_eq!(scaled.image_size, Size::new(60, 15));
3928
3929 let state = UiState::from_history(&[], "secret", "model", None, false);
3930 let area = Rect::new(0, 0, 80, 12);
3931 let mut terminal =
3932 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
3933 .expect("test terminal");
3934 terminal
3935 .draw(|frame| draw(frame, &state))
3936 .expect("draw text fallback");
3937 let chat_area = ui_layout(&state, tui_viewport(area)).0;
3938 let rows = (chat_area.y..chat_area.y + chat_area.height)
3939 .map(|y| {
3940 (chat_area.x..chat_area.x + chat_area.width)
3941 .map(|x| terminal.backend().buffer()[(x, y)].symbol())
3942 .collect::<String>()
3943 })
3944 .collect::<Vec<_>>();
3945 assert!(rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
3946 assert!(!rows
3947 .iter()
3948 .any(|row| row.contains('▀') || row.contains('▄')));
3949 }
3950
3951 #[test]
3952 fn logo_text_renders_by_default_and_greeting_image_replaces_it_when_enabled() {
3953 let logo = logo_lines();
3954 let logo_row_count = LOGO_TEXT.lines().count();
3955 assert_eq!(logo.len(), logo_row_count);
3956 assert!(logo.iter().flat_map(|line| &line.spans).any(|span| {
3958 span.content.chars().any(|ch| ch != ' ')
3959 && matches!(span.style.fg, Some(Color::Rgb(..)))
3960 }));
3961
3962 let state = UiState::from_history(&[], "secret", "model", None, false);
3963 let area = Rect::new(0, 0, 100, 50);
3964 let mut terminal =
3965 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
3966 .expect("test terminal");
3967 let chat_area = ui_layout(&state, tui_viewport(area)).0;
3968 let intro_lines = welcome_lines(&state.attached_agents);
3969 let greeting_layout =
3970 welcome_image_layout(chat_area, intro_lines.len() as u16).expect("greeting fits");
3971
3972 std::env::remove_var("LUCY_GREETING_IMAGE");
3974 terminal
3975 .draw(|frame| draw(frame, &state))
3976 .expect("draw logo text");
3977 let buffer = terminal.backend().buffer();
3978 let rows = (chat_area.y..chat_area.y + chat_area.height)
3979 .map(|y| {
3980 (chat_area.x..chat_area.x + chat_area.width)
3981 .map(|x| buffer[(x, y)].symbol())
3982 .collect::<String>()
3983 })
3984 .collect::<Vec<_>>();
3985 assert!(rows
3986 .iter()
3987 .any(|row| row.contains(':') || row.contains('-') || row.contains('=')));
3988 assert!(!rows
3989 .iter()
3990 .any(|row| row.contains('▀') || row.contains('▄')));
3991 assert!(rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
3992
3993 std::env::set_var("LUCY_GREETING_IMAGE", "true");
3995 terminal
3996 .draw(|frame| draw(frame, &state))
3997 .expect("draw greeting");
3998 let buffer = terminal.backend().buffer();
3999 assert_eq!(greeting_layout.image_size, GREETING_IMAGE_SIZE);
4000 assert!(matches!(
4001 buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].symbol(),
4002 "▀" | "▄"
4003 ));
4004 assert!(matches!(
4005 buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].fg,
4006 Color::Rgb(..)
4007 ));
4008 assert!(matches!(
4009 buffer[(greeting_layout.image_area.x, greeting_layout.image_area.y)].bg,
4010 Color::Rgb(..)
4011 ));
4012 let intro_rows = (greeting_layout.intro_area.y
4013 ..greeting_layout.intro_area.y + greeting_layout.intro_area.height)
4014 .map(|y| {
4015 (greeting_layout.intro_area.x
4016 ..greeting_layout.intro_area.x + greeting_layout.intro_area.width)
4017 .map(|x| buffer[(x, y)].symbol())
4018 .collect::<String>()
4019 })
4020 .collect::<Vec<_>>();
4021 assert!(intro_rows.iter().any(|row| row.contains(WELCOME_MESSAGE)));
4022
4023 std::env::remove_var("LUCY_GREETING_IMAGE");
4024 }
4025
4026 #[test]
4027 fn welcome_renders_version_below_title_with_a_blank_line_before_tagline() {
4028 let state = UiState::from_history(&[], "secret", "model", None, false);
4029 let area = Rect::new(0, 0, 80, 12);
4030 let mut terminal =
4031 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4032 .expect("test terminal");
4033 terminal
4034 .draw(|frame| draw(frame, &state))
4035 .expect("draw welcome screen");
4036
4037 let chat_area = ui_layout(&state, tui_viewport(area)).0;
4038 let buffer = terminal.backend().buffer();
4039 let rows = (chat_area.y..chat_area.y + chat_area.height)
4040 .map(|y| {
4041 (chat_area.x..chat_area.x + chat_area.width)
4042 .map(|x| buffer[(x, y)].symbol())
4043 .collect::<String>()
4044 })
4045 .collect::<Vec<_>>();
4046 let title_row = rows
4047 .iter()
4048 .position(|row| row.contains(WELCOME_MESSAGE))
4049 .expect("rendered welcome title");
4050 let version_rows = rows
4051 .iter()
4052 .enumerate()
4053 .filter_map(|(row, rendered)| rendered.contains(WELCOME_VERSION).then_some(row))
4054 .collect::<Vec<_>>();
4055
4056 assert_eq!(version_rows, vec![title_row + 1]);
4057 assert!(rows[title_row + 2].trim().is_empty());
4058 assert!(rows[title_row + 3].contains(WELCOME_TAGLINE));
4059
4060 let version_width = WELCOME_VERSION.chars().count() as u16;
4061 let version_x = chat_area.x
4062 + rows[version_rows[0]]
4063 .find(WELCOME_VERSION)
4064 .expect("rendered welcome version") as u16;
4065 let version_y = chat_area.y + title_row as u16 + 1;
4066 assert!((version_x..version_x + version_width)
4067 .all(|x| buffer[(x, version_y)].fg == Color::DarkGray));
4068 }
4069
4070 #[test]
4071 fn welcome_shows_the_tagline_and_attached_agents_paths() {
4072 let state = UiState::from_history(&[], "secret", "model", None, false)
4073 .with_attached_agents(vec![
4074 "/workspace/AGENTS.md".to_owned(),
4075 "/workspace/app/AGENTS.md".to_owned(),
4076 ]);
4077 let lines = welcome_lines(&state.attached_agents);
4078
4079 assert_eq!(lines[1].to_string(), WELCOME_VERSION);
4080 assert_eq!(lines[1].style.fg, Some(Color::DarkGray));
4081 assert!(lines[2].to_string().is_empty());
4082 assert_eq!(lines[3].to_string(), WELCOME_TAGLINE);
4083 assert_eq!(lines[3].style.fg, Some(Color::DarkGray));
4084 assert!(lines[4].to_string().is_empty());
4085 assert_eq!(lines[5].to_string(), "Attached AGENTS.md:");
4086 assert_eq!(lines[6].to_string(), "• /workspace/AGENTS.md");
4087 assert_eq!(lines[7].to_string(), "• /workspace/app/AGENTS.md");
4088 assert!(lines[5..]
4089 .iter()
4090 .all(|line| line.style.fg == Some(Color::DarkGray)));
4091 }
4092
4093 #[test]
4094 fn welcome_reports_when_no_agents_file_is_attached() {
4095 let lines = welcome_lines(&[]);
4096 assert_eq!(
4097 lines.last().expect("empty context line").to_string(),
4098 "Attached AGENTS.md: none"
4099 );
4100 }
4101
4102 #[test]
4103 fn resumed_sessions_do_not_show_the_welcome_message() {
4104 let state = UiState::from_history(&[], "secret", "model", None, true);
4105 assert!(!state.welcome_visible);
4106 }
4107
4108 #[test]
4109 fn history_replay_keeps_interruption_after_messages() {
4110 let history = vec![
4111 SessionHistoryRecord::Message {
4112 timestamp: 1,
4113 message: ChatMessage::user("hello".to_owned()),
4114 },
4115 SessionHistoryRecord::Interruption {
4116 timestamp: 2,
4117 reason: "user_cancelled".to_owned(),
4118 phase: "provider_stream".to_owned(),
4119 assistant_text: "partial".to_owned(),
4120 tool_calls: Vec::new(),
4121 tool_results: Vec::new(),
4122 },
4123 ];
4124 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
4125 assert!(matches!(state.transcript[0], TranscriptItem::User { .. }));
4126 assert!(matches!(state.transcript[1], TranscriptItem::Assistant(_)));
4127 assert!(matches!(state.transcript[2], TranscriptItem::Info(_)));
4128 let text = transcript_lines(&state, 80)
4129 .iter()
4130 .map(ToString::to_string)
4131 .collect::<Vec<_>>()
4132 .join("\n");
4133 assert!(!text.contains("choices"));
4134 }
4135
4136 #[test]
4137 fn history_replay_does_not_render_assistant_reasoning_details() {
4138 let mut message = ChatMessage::assistant("visible answer".to_owned(), Vec::new());
4139 message.reasoning_details = Some(vec![serde_json::json!({
4140 "type": "reasoning.text",
4141 "text": "private reasoning"
4142 })]);
4143 let history = [SessionHistoryRecord::Message {
4144 timestamp: 1,
4145 message,
4146 }];
4147 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
4148 let text = transcript_lines(&state, 80)
4149 .iter()
4150 .map(ToString::to_string)
4151 .collect::<Vec<_>>()
4152 .join("\n");
4153 assert!(text.contains("visible answer"));
4154 assert!(!text.contains("private reasoning"));
4155 assert!(!text.contains("reasoning_details"));
4156 }
4157
4158 #[test]
4159 fn history_replay_preserves_repeated_records() {
4160 let history = vec![
4161 SessionHistoryRecord::Message {
4162 timestamp: 1,
4163 message: ChatMessage::assistant("same".to_owned(), Vec::new()),
4164 },
4165 SessionHistoryRecord::Interruption {
4166 timestamp: 2,
4167 reason: "user_cancelled".to_owned(),
4168 phase: "provider_stream".to_owned(),
4169 assistant_text: "same".to_owned(),
4170 tool_calls: Vec::new(),
4171 tool_results: Vec::new(),
4172 },
4173 ];
4174 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
4175 assert_eq!(
4176 state
4177 .transcript
4178 .iter()
4179 .filter(|item| matches!(item, TranscriptItem::Assistant(text) if text == "same"))
4180 .count(),
4181 2
4182 );
4183 }
4184
4185 #[test]
4186 fn user_messages_have_a_single_block_rule_with_inner_and_vertical_padding() {
4187 let history = [SessionHistoryRecord::Message {
4188 timestamp: 1,
4189 message: ChatMessage::user("hello\nworld".to_owned()),
4190 }];
4191 let state = UiState::from_history(&history, "provider-secret", "model", None, false);
4192 let lines = transcript_lines(&state, 12);
4193
4194 assert_eq!(UnicodeWidthStr::width(USER_BORDER_GLYPH), 1);
4195 assert_eq!(lines.len(), 4);
4196 assert_eq!(lines[0].to_string(), "▌");
4197 assert_eq!(lines[1].to_string(), "▌ hello");
4198 assert_eq!(lines[2].to_string(), "▌ world");
4199 assert_eq!(lines[3].to_string(), "▌");
4200 for line in &lines {
4201 assert_eq!(line.spans[0].content, USER_BORDER_GLYPH);
4202 assert_eq!(line.spans[0].style.fg, Some(USER_BORDER_COLOR));
4203 assert!(!line.to_string().contains(['┌', '┐', '└', '┘', '│']));
4204 }
4205 for line in &lines[1..3] {
4206 assert_eq!(line.spans[1].content, " ");
4207 assert_eq!(line.spans[1].style.fg, Some(Color::White));
4208 assert_eq!(line.spans[2].style.fg, Some(Color::White));
4209 }
4210 }
4211
4212 #[test]
4213 fn attached_skill_highlights_its_trigger_in_the_user_message_without_a_notice_line() {
4214 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4215 .with_skill_names(vec!["release-notes".to_owned()]);
4216 state.add_user("/release-notes v1.2.0", "secret");
4217 state.mark_latest_user_skill_attached();
4218
4219 let lines = transcript_lines(&state, 40);
4220 assert_eq!(lines.len(), 3);
4221 assert_eq!(lines[1].spans[1].content, " ");
4222 let cyan_text = lines[1]
4223 .spans
4224 .iter()
4225 .filter(|span| span.style.fg == Some(SKILL_TRIGGER_COLOR))
4226 .map(|span| span.content.as_ref())
4227 .collect::<String>();
4228 assert_eq!(cyan_text, "/release-notes");
4229 assert!(!lines
4230 .iter()
4231 .any(|line| line.to_string().contains("instruction attached")));
4232 }
4233
4234 #[test]
4235 fn transcript_rendering_redacts_history_content() {
4236 let history = [SessionHistoryRecord::Message {
4237 timestamp: 1,
4238 message: ChatMessage::assistant("provider-secret".to_owned(), Vec::new()),
4239 }];
4240 let state = UiState::from_history(&history, "provider-secret", "model", None, false);
4241 let text = transcript_lines(&state, 80)
4242 .iter()
4243 .map(ToString::to_string)
4244 .collect::<Vec<_>>()
4245 .join("\n");
4246 assert!(!text.contains("provider-secret"));
4247 }
4248
4249 #[test]
4250 fn mouse_wheel_disables_following_and_changes_scroll_offset() {
4251 let history = [SessionHistoryRecord::Message {
4252 timestamp: 1,
4253 message: ChatMessage::user("hello".to_owned()),
4254 }];
4255 let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
4256 handle_mouse_event(&mut state, MouseEventKind::ScrollUp, 10);
4257 assert!(!state.auto_scroll);
4258 assert_eq!(state.scroll, 7);
4259 handle_mouse_event(&mut state, MouseEventKind::ScrollDown, 10);
4260 assert!(
4261 state.auto_scroll,
4262 "reaching the bottom resumes transcript following"
4263 );
4264 assert_eq!(state.scroll, 0);
4265 scroll_up(&mut state, 10);
4266 assert!(!state.auto_scroll);
4267 assert_eq!(state.scroll, 7);
4268 }
4269
4270 #[test]
4271 fn transcript_scrollbar_appears_only_when_the_stream_is_scrolled() {
4272 let mut state = UiState::from_history(&[], "provider-secret", "model", None, false);
4273 state.welcome_visible = false;
4274 let area = Rect::new(0, 0, 80, 14);
4275 let chat_area = ui_layout(&state, tui_viewport(area)).0;
4276 state.transcript = (0..40)
4277 .map(|_| TranscriptItem::Info(format!("{}#", "x".repeat(chat_area.width as usize - 1))))
4278 .collect();
4279 state.auto_scroll = false;
4280 state.scroll = 3;
4281
4282 let mut terminal =
4283 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4284 .expect("test terminal");
4285 terminal
4286 .draw(|frame| draw(frame, &state))
4287 .expect("draw scrolled transcript");
4288
4289 let message_edge_x = chat_area.x + chat_area.width - 1;
4290 let scrollbar_x = chat_area.x + chat_area.width;
4291 let buffer = terminal.backend().buffer();
4292 assert!(
4293 (chat_area.y..chat_area.y + chat_area.height)
4294 .any(|y| buffer[(message_edge_x, y)].symbol() == "#"),
4295 "the scrollbar must not overwrite transcript content at the right edge"
4296 );
4297 assert!(
4298 (chat_area.y..chat_area.y + chat_area.height).any(|y| {
4299 buffer[(scrollbar_x, y)].symbol() == TRANSCRIPT_SCROLLBAR_THUMB
4300 && buffer[(scrollbar_x, y)].fg == CONSOLE_STATUS_COLOR
4301 }),
4302 "a scrolled transcript should show a scrollbar thumb"
4303 );
4304 assert!(
4305 (chat_area.y..chat_area.y + chat_area.height)
4306 .any(|y| { buffer[(scrollbar_x, y)].symbol() == TRANSCRIPT_SCROLLBAR_TRACK }),
4307 "a scrolled transcript should show a scrollbar track"
4308 );
4309
4310 state.auto_scroll = true;
4311 state.scroll = 0;
4312 let mut terminal =
4313 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4314 .expect("test terminal");
4315 terminal
4316 .draw(|frame| draw(frame, &state))
4317 .expect("draw following transcript");
4318 let buffer = terminal.backend().buffer();
4319 assert!((chat_area.y..chat_area.y + chat_area.height)
4320 .all(|y| buffer[(scrollbar_x, y)].symbol() != TRANSCRIPT_SCROLLBAR_THUMB));
4321 }
4322
4323 #[test]
4324 fn tool_result_sweep_is_now_twice_as_fast() {
4325 assert_eq!(TOOL_RESULT_SWEEP_DURATION, Duration::from_millis(600));
4326 }
4327
4328 #[test]
4329 fn wrap_text_breaks_long_lines_and_preserves_empty_lines() {
4330 let rows = wrap_text("12345\n\nabc", 3);
4331 assert_eq!(rows, vec!["123", "45", "", "abc"]);
4332 }
4333
4334 #[test]
4335 fn wrap_line_never_returns_an_empty_vec() {
4336 assert_eq!(wrap_line("", 5), vec![""]);
4337 assert_eq!(wrap_line("abc", 5), vec!["abc"]);
4338 }
4339
4340 #[test]
4341 fn multiline_input_arrows_move_cursor_between_explicit_and_wrapped_rows() {
4342 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4343 state.input = "ab\ncd\nef".to_owned();
4344 state.cursor = 1;
4345
4346 assert!(move_input_cursor_vertical(&mut state, 10, true));
4347 assert_eq!(
4348 state.cursor, 4,
4349 "preserve the column on the next explicit row"
4350 );
4351 assert!(move_input_cursor_vertical(&mut state, 10, true));
4352 assert_eq!(state.cursor, 7);
4353 assert!(!move_input_cursor_vertical(&mut state, 10, true));
4354 assert!(move_input_cursor_vertical(&mut state, 10, false));
4355 assert_eq!(state.cursor, 4);
4356
4357 state.input = "abcdef".to_owned();
4358 state.cursor = 1;
4359 assert!(move_input_cursor_vertical(&mut state, 3, true));
4360 assert_eq!(state.cursor, 4, "wrapped rows use the same visual column");
4361 assert!(move_input_cursor_vertical(&mut state, 3, false));
4362 assert_eq!(state.cursor, 1);
4363 }
4364
4365 #[test]
4366 fn completion_event_does_not_release_input_before_worker_finishes() {
4367 let history = [SessionHistoryRecord::Message {
4368 timestamp: 1,
4369 message: ChatMessage::user("hello".to_owned()),
4370 }];
4371 let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
4372 state.busy = true;
4373 state.active_cancel = Some(CancellationToken::new());
4374 state.apply_event(ProtocolEvent::TurnEnd);
4375 assert!(state.busy);
4376 assert!(state.active_cancel.is_some());
4377 assert_eq!(state.status, "finalizing");
4378 }
4379
4380 #[test]
4381 fn transcript_inserts_a_blank_line_between_items() {
4382 let history = [
4383 SessionHistoryRecord::Message {
4384 timestamp: 1,
4385 message: ChatMessage::user("hi".to_owned()),
4386 },
4387 SessionHistoryRecord::Message {
4388 timestamp: 2,
4389 message: ChatMessage::assistant("hello".to_owned(), Vec::new()),
4390 },
4391 ];
4392 let state = UiState::from_history(&history, "secret", "model", None, false);
4393 let lines = transcript_lines(&state, 80);
4394 assert_eq!(lines.len(), 5);
4395 assert_eq!(lines[0].to_string(), "▌");
4396 assert_eq!(lines[1].to_string(), "▌ hi");
4397 assert_eq!(lines[2].to_string(), "▌");
4398 assert_eq!(lines[3].to_string(), "");
4399 assert_eq!(lines[4].to_string(), "hello");
4400 }
4401
4402 #[test]
4403 fn cmd_call_renders_as_a_compact_status_line_without_raw_json() {
4404 let history = vec![
4405 SessionHistoryRecord::Message {
4406 timestamp: 1,
4407 message: ChatMessage::assistant(
4408 String::new(),
4409 vec![crate::model::ChatToolCall {
4410 id: "call-1".to_owned(),
4411 name: "cmd".to_owned(),
4412 arguments: r#"{"command":"pwd"}"#.to_owned(),
4413 }],
4414 ),
4415 },
4416 SessionHistoryRecord::Message {
4417 timestamp: 2,
4418 message: ChatMessage::tool(
4419 "call-1".to_owned(),
4420 "cmd".to_owned(),
4421 serde_json::json!({"exit_code": 0, "stdout": "secret output"}).to_string(),
4422 ),
4423 },
4424 ];
4425 let state = UiState::from_history(&history, "secret", "model", None, false);
4426 let text = transcript_lines(&state, 80)[0].to_string();
4427
4428 assert_eq!(text, "✓ cmd $ pwd");
4429 assert!(!text.contains("secret output"));
4430 assert!(!text.contains("{\"command\":\"pwd\"}"));
4431 }
4432
4433 #[test]
4434 fn pending_cmd_calls_use_a_compact_running_status() {
4435 let history = [SessionHistoryRecord::Message {
4436 timestamp: 1,
4437 message: ChatMessage::assistant(
4438 String::new(),
4439 vec![crate::model::ChatToolCall {
4440 id: "call-1".to_owned(),
4441 name: "cmd".to_owned(),
4442 arguments: r#"{"command":"pwd"}"#.to_owned(),
4443 }],
4444 ),
4445 }];
4446 let state = UiState::from_history(&history, "secret", "model", None, false);
4447 let line = &transcript_lines(&state, 80)[0];
4448
4449 let text = line.to_string();
4450 let prefix = "· cmd $ pwd ";
4451 assert!(text.starts_with(prefix));
4452 assert!(!text.contains("→ running"));
4453 let frame = &text[prefix.len()..];
4454 assert_eq!(frame.chars().count(), 1);
4455 assert!(frame
4456 .chars()
4457 .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
4458 assert!(line
4459 .spans
4460 .iter()
4461 .all(|span| span.style.fg == Some(PENDING_TOOL_COLOR)));
4462 }
4463
4464 #[test]
4465 fn running_tool_indicators_use_a_traditional_spinner_with_their_own_clock() {
4466 assert_eq!(tool_spinner_frame_at(Duration::ZERO), '|');
4467 assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION), '/');
4468 assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 2), '-');
4469 assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 3), '\\');
4470
4471 let state = UiState::from_history(&[], "secret", "model", None, false);
4472 let spinner = running_tool_status(&state);
4473 assert_eq!(spinner.chars().count(), 1);
4474 assert!(spinner
4475 .chars()
4476 .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
4477 }
4478
4479 #[test]
4480 fn successful_cmd_cross_fades_to_teal_from_first_character_to_last() {
4481 let started_at = Instant::now();
4482 let character_count = 12;
4483 let early = started_at + TOOL_RESULT_SWEEP_DURATION / 4;
4484 let halfway = started_at + TOOL_RESULT_SWEEP_DURATION / 2;
4485 let late = started_at + TOOL_RESULT_SWEEP_DURATION * 3 / 4;
4486
4487 assert_eq!(
4488 cmd_result_color_at(
4489 started_at,
4490 started_at,
4491 0,
4492 character_count,
4493 TOOL_SUCCESS_COLOR,
4494 ),
4495 PENDING_TOOL_COLOR,
4496 );
4497 assert_eq!(TOOL_SUCCESS_COLOR, Color::Rgb(0, 210, 175));
4498
4499 let early_first =
4500 cmd_result_color_at(started_at, early, 0, character_count, TOOL_SUCCESS_COLOR);
4501 assert_ne!(early_first, PENDING_TOOL_COLOR);
4502 assert_ne!(early_first, TOOL_SUCCESS_COLOR);
4503 assert_eq!(
4504 cmd_result_color_at(started_at, early, 5, character_count, TOOL_SUCCESS_COLOR),
4505 PENDING_TOOL_COLOR,
4506 "later characters wait while the first character cross-fades"
4507 );
4508
4509 assert_eq!(
4510 cmd_result_color_at(started_at, halfway, 0, character_count, TOOL_SUCCESS_COLOR),
4511 TOOL_SUCCESS_COLOR,
4512 );
4513 let halfway_middle =
4514 cmd_result_color_at(started_at, halfway, 5, character_count, TOOL_SUCCESS_COLOR);
4515 assert_ne!(halfway_middle, PENDING_TOOL_COLOR);
4516 assert_ne!(halfway_middle, TOOL_SUCCESS_COLOR);
4517 assert_eq!(
4518 cmd_result_color_at(
4519 started_at,
4520 halfway,
4521 character_count - 1,
4522 character_count,
4523 TOOL_SUCCESS_COLOR,
4524 ),
4525 PENDING_TOOL_COLOR,
4526 );
4527
4528 let late_last = cmd_result_color_at(
4529 started_at,
4530 late,
4531 character_count - 1,
4532 character_count,
4533 TOOL_SUCCESS_COLOR,
4534 );
4535 assert_ne!(late_last, PENDING_TOOL_COLOR);
4536 assert_ne!(late_last, TOOL_SUCCESS_COLOR);
4537 assert_eq!(
4538 cmd_result_color_at(
4539 started_at,
4540 started_at + TOOL_RESULT_SWEEP_DURATION,
4541 character_count - 1,
4542 character_count,
4543 TOOL_SUCCESS_COLOR,
4544 ),
4545 TOOL_SUCCESS_COLOR,
4546 "the completed sweep keeps the exact teal used during the fade"
4547 );
4548 }
4549
4550 #[test]
4551 fn cmd_result_cross_fade_has_no_abrupt_color_change_between_render_ticks() {
4552 let started_at = Instant::now();
4553 let character_count = 12;
4554 let render_ticks = TOOL_RESULT_SWEEP_DURATION.as_millis() / EVENT_POLL.as_millis();
4555
4556 for target in [TOOL_SUCCESS_COLOR, TOOL_FAILURE_COLOR, TOOL_WARNING_COLOR] {
4557 for character_index in 0..character_count {
4558 let frames = (0..=render_ticks)
4559 .map(|tick| {
4560 cmd_result_color_at(
4561 started_at,
4562 started_at + EVENT_POLL * tick as u32,
4563 character_index,
4564 character_count,
4565 target,
4566 )
4567 })
4568 .collect::<Vec<_>>();
4569
4570 assert!(frames
4571 .iter()
4572 .any(|color| { *color != PENDING_TOOL_COLOR && *color != target }));
4573 assert!(frames.windows(2).all(|pair| {
4574 let (before_red, before_green, before_blue) = tool_result_color_rgb(pair[0]);
4575 let (after_red, after_green, after_blue) = tool_result_color_rgb(pair[1]);
4576 before_red.abs_diff(after_red) <= 90
4577 && before_green.abs_diff(after_green) <= 90
4578 && before_blue.abs_diff(after_blue) <= 90
4579 }));
4580 assert_eq!(frames.last(), Some(&target));
4581 }
4582 }
4583 }
4584
4585 #[test]
4586 fn only_live_cmd_results_start_a_result_sweep() {
4587 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4588 let succeeded = serde_json::json!({"exit_code": 0});
4589
4590 state.add_tool_result("historic", "cmd", succeeded.clone());
4591 state.add_live_tool_result("success", "cmd", succeeded);
4592 state.add_live_tool_result("failed", "cmd", serde_json::json!({"exit_code": 1}));
4593
4594 assert!(!state.cmd_result_started_at.contains_key("historic"));
4595 assert!(state.cmd_result_started_at.contains_key("success"));
4596 assert!(state.cmd_result_started_at.contains_key("failed"));
4597 }
4598
4599 #[test]
4600 fn failed_cmd_cross_fades_to_the_same_rgb_red_without_a_final_jump() {
4601 let started_at = Instant::now();
4602 let character_count = 12;
4603 let halfway = started_at + TOOL_RESULT_SWEEP_DURATION / 2;
4604
4605 assert_eq!(
4606 cmd_result_color_at(
4607 started_at,
4608 started_at,
4609 0,
4610 character_count,
4611 TOOL_FAILURE_COLOR,
4612 ),
4613 PENDING_TOOL_COLOR,
4614 );
4615 assert_eq!(
4616 cmd_result_color_at(started_at, halfway, 0, character_count, TOOL_FAILURE_COLOR),
4617 TOOL_FAILURE_COLOR,
4618 );
4619 let intermediate =
4620 cmd_result_color_at(started_at, halfway, 5, character_count, TOOL_FAILURE_COLOR);
4621 assert_ne!(intermediate, PENDING_TOOL_COLOR);
4622 assert_ne!(intermediate, TOOL_FAILURE_COLOR);
4623 assert_eq!(
4624 cmd_result_color_at(
4625 started_at,
4626 halfway,
4627 character_count - 1,
4628 character_count,
4629 TOOL_FAILURE_COLOR,
4630 ),
4631 PENDING_TOOL_COLOR,
4632 );
4633 assert_eq!(
4634 cmd_result_color_at(
4635 started_at,
4636 started_at + TOOL_RESULT_SWEEP_DURATION,
4637 character_count - 1,
4638 character_count,
4639 TOOL_FAILURE_COLOR,
4640 ),
4641 TOOL_FAILURE_COLOR,
4642 "the completed failure sweep keeps the exact RGB red used during the fade"
4643 );
4644 }
4645
4646 #[test]
4647 fn live_failed_cmd_sweep_keeps_the_final_status_text() {
4648 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4649 let result = serde_json::json!({"exit_code": 1});
4650 state.add_live_tool_result("failed", "cmd", result.clone());
4651
4652 let segments = cmd_tool_segments("failed", r#"{"command":"bad"}"#, Some(&result), &state);
4653 let text = segments
4654 .iter()
4655 .map(|(text, _)| text.as_str())
4656 .collect::<String>();
4657
4658 assert_eq!(text, "× cmd $ bad → exit 1");
4659 }
4660
4661 #[test]
4662 fn cmd_result_target_colors_follow_the_final_status() {
4663 assert_eq!(
4664 cmd_result_target_color(&serde_json::json!({"exit_code": 0})),
4665 TOOL_SUCCESS_COLOR
4666 );
4667 assert_eq!(
4668 cmd_result_target_color(&serde_json::json!({"exit_code": 1})),
4669 TOOL_FAILURE_COLOR
4670 );
4671 assert_eq!(
4672 cmd_result_target_color(&serde_json::json!({"timed_out": true})),
4673 TOOL_WARNING_COLOR
4674 );
4675 }
4676
4677 #[test]
4678 fn background_cmd_registration_shows_its_running_id() {
4679 let (icon, status, _) = cmd_result_status(&serde_json::json!({
4680 "background_id": "background-1",
4681 "status": "running"
4682 }));
4683 assert_eq!(icon, '↗');
4684 assert_eq!(status, "background-1");
4685 }
4686
4687 #[test]
4688 fn cmd_status_distinguishes_nonzero_exit_timeout_and_cancellation() {
4689 let cases = [
4690 (
4691 serde_json::json!({"exit_code": 127}),
4692 "× cmd $ bad → exit 127",
4693 ),
4694 (
4695 serde_json::json!({"timed_out": true, "exit_code": null}),
4696 "! cmd $ slow → timeout",
4697 ),
4698 (
4699 serde_json::json!({"canceled": true}),
4700 "! cmd $ stop → canceled",
4701 ),
4702 ];
4703 for (result, expected) in cases {
4704 let history = vec![
4705 SessionHistoryRecord::Message {
4706 timestamp: 1,
4707 message: ChatMessage::assistant(
4708 String::new(),
4709 vec![crate::model::ChatToolCall {
4710 id: "call-1".to_owned(),
4711 name: "cmd".to_owned(),
4712 arguments: serde_json::json!({"command": expected.split("$ ").nth(1).unwrap().split(" ").next().unwrap()}).to_string(),
4713 }],
4714 ),
4715 },
4716 SessionHistoryRecord::Message {
4717 timestamp: 2,
4718 message: ChatMessage::tool(
4719 "call-1".to_owned(),
4720 "cmd".to_owned(),
4721 result.to_string(),
4722 ),
4723 },
4724 ];
4725 let state = UiState::from_history(&history, "secret", "model", None, false);
4726 assert_eq!(transcript_lines(&state, 80)[0].to_string(), expected);
4727 }
4728 }
4729
4730 #[test]
4731 fn cmd_line_truncates_long_commands_but_never_renders_output() {
4732 let command = "a".repeat(120);
4733 let arguments = serde_json::json!({"command": command}).to_string();
4734 let history = vec![
4735 SessionHistoryRecord::Message {
4736 timestamp: 1,
4737 message: ChatMessage::assistant(
4738 String::new(),
4739 vec![crate::model::ChatToolCall {
4740 id: "call-1".to_owned(),
4741 name: "cmd".to_owned(),
4742 arguments,
4743 }],
4744 ),
4745 },
4746 SessionHistoryRecord::Message {
4747 timestamp: 2,
4748 message: ChatMessage::tool(
4749 "call-1".to_owned(),
4750 "cmd".to_owned(),
4751 serde_json::json!({"exit_code": 0, "stdout": "output"}).to_string(),
4752 ),
4753 },
4754 ];
4755 let state = UiState::from_history(&history, "secret", "model", None, false);
4756 let text = transcript_lines(&state, 200)[0].to_string();
4757 assert!(text.contains(&format!("$ {}…", "a".repeat(100))));
4758 assert!(!text.contains(&"a".repeat(101)));
4759 assert!(!text.contains("output"));
4760 }
4761
4762 #[test]
4763 fn cmd_lines_remain_compact_for_consecutive_calls() {
4764 let history = vec![
4765 SessionHistoryRecord::Message {
4766 timestamp: 1,
4767 message: ChatMessage::assistant(
4768 String::new(),
4769 vec![
4770 crate::model::ChatToolCall {
4771 id: "call-first".to_owned(),
4772 name: "cmd".to_owned(),
4773 arguments: r#"{"command":"first"}"#.to_owned(),
4774 },
4775 crate::model::ChatToolCall {
4776 id: "call-second".to_owned(),
4777 name: "cmd".to_owned(),
4778 arguments: r#"{"command":"second"}"#.to_owned(),
4779 },
4780 ],
4781 ),
4782 },
4783 SessionHistoryRecord::Message {
4784 timestamp: 2,
4785 message: ChatMessage::tool(
4786 "call-first".to_owned(),
4787 "cmd".to_owned(),
4788 serde_json::json!({"exit_code": 0}).to_string(),
4789 ),
4790 },
4791 SessionHistoryRecord::Message {
4792 timestamp: 3,
4793 message: ChatMessage::tool(
4794 "call-second".to_owned(),
4795 "cmd".to_owned(),
4796 serde_json::json!({"exit_code": 0}).to_string(),
4797 ),
4798 },
4799 ];
4800 let state = UiState::from_history(&history, "secret", "model", None, false);
4801 let lines = transcript_lines(&state, 200);
4802 assert_eq!(lines[0].to_string(), "✓ cmd $ first");
4803 assert_eq!(lines[2].to_string(), "✓ cmd $ second");
4804 }
4805
4806 #[test]
4807 fn cmd_status_styles_use_success_failure_and_pending_colors() {
4808 assert_eq!(
4809 cmd_result_status(&serde_json::json!({"exit_code": 0})).2.fg,
4810 Some(TOOL_SUCCESS_COLOR)
4811 );
4812 assert_eq!(
4813 cmd_result_status(&serde_json::json!({"exit_code": 1})).2.fg,
4814 Some(TOOL_FAILURE_COLOR)
4815 );
4816 assert_eq!(
4817 cmd_tool_segments(
4818 "call-1",
4819 "{\"command\":\"pwd\"}",
4820 None,
4821 &UiState::from_history(&[], "secret", "model", None, false)
4822 )[0]
4823 .1
4824 .fg,
4825 Some(PENDING_TOOL_COLOR)
4826 );
4827 }
4828
4829 #[test]
4830 fn recognized_skill_trigger_is_highlighted_but_arguments_remain_default_colored() {
4831 let trigger = active_skill_trigger("/release-notes v1.2.0", &["release-notes".to_owned()]);
4832 assert_eq!(trigger, Some("/release-notes"));
4833 assert_eq!(SKILL_TRIGGER_COLOR, Color::Rgb(80, 255, 245));
4834
4835 let lines = styled_text_lines(
4836 "/release-notes v1.2.0",
4837 trigger,
4838 80,
4839 Style::default().fg(Color::White),
4840 );
4841 assert_eq!(lines.len(), 1);
4842 assert_eq!(lines[0].to_string(), "/release-notes v1.2.0");
4843 assert_eq!(lines[0].spans[0].content, "/release-notes");
4844 assert_eq!(lines[0].spans[0].style.fg, Some(SKILL_TRIGGER_COLOR));
4845 assert_eq!(lines[0].spans[1].content, " v1.2.0");
4846 assert_eq!(lines[0].spans[1].style.fg, Some(Color::White));
4847 }
4848
4849 #[test]
4850 fn draw_renders_an_active_skill_trigger_in_cyan() {
4851 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4852 .with_skill_names(vec!["release-notes".to_owned()]);
4853 state.input = "/release-notes v1.2.0".to_owned();
4854 state.cursor = state.input.chars().count();
4855
4856 let mut terminal =
4857 Terminal::new(ratatui::backend::TestBackend::new(40, 10)).expect("test terminal");
4858 terminal
4859 .draw(|frame| draw(frame, &state))
4860 .expect("draw input");
4861
4862 let buffer = terminal.backend().buffer();
4865 let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 40, 10)));
4866 let prompt_area = prompt_area(input_area, &state);
4867 let input_x = prompt_area.x;
4868 let input_y = prompt_area.y;
4869 assert_eq!(buffer[(input_x, input_y)].fg, SKILL_TRIGGER_COLOR);
4870 assert_eq!(
4871 buffer[(input_x + "/release-notes".chars().count() as u16, input_y)].fg,
4872 Color::White
4873 );
4874 }
4875
4876 #[test]
4877 fn main_agent_status_omits_activity_animation_on_idle_and_busy_states() {
4878 let mut state =
4879 UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
4880 let area = Rect::new(0, 0, 80, 10);
4881 let mut terminal =
4882 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4883 .expect("test terminal");
4884
4885 terminal
4886 .draw(|frame| draw(frame, &state))
4887 .expect("draw ready status");
4888 let viewport = tui_viewport(area);
4889 let status_area = ui_layout(&state, viewport).5;
4890 let expected_context = "Context: 81/100 (81%) █████████░";
4891 let buffer = terminal.backend().buffer();
4892 let idle_row = (status_area.x..status_area.x + status_area.width)
4893 .map(|x| buffer[(x, status_area.y)].symbol())
4894 .collect::<String>();
4895 assert!(idle_row.starts_with("model · default"));
4896 assert!(idle_row.ends_with(expected_context));
4897 for x in status_area.x..status_area.x + status_area.width {
4898 if buffer[(x, status_area.y)].symbol() != " " {
4899 assert_eq!(buffer[(x, status_area.y)].fg, Color::Rgb(144, 144, 148));
4900 }
4901 }
4902
4903 state.set_status("working");
4904 state.busy = true;
4905 state.activity_transition = None;
4906 state.console_animation_epoch = Instant::now() - console_accent_cycle() / 4;
4907 terminal
4908 .draw(|frame| draw(frame, &state))
4909 .expect("draw working status");
4910 let status_area = ui_layout(&state, viewport).5;
4911 let buffer = terminal.backend().buffer();
4912 let rendered = (status_area.x..status_area.x + status_area.width)
4913 .map(|x| buffer[(x, status_area.y)].symbol())
4914 .collect::<String>();
4915 assert!(rendered.starts_with("model · default "));
4916 assert!(rendered.contains(BUSY_INDICATOR_BLOCK));
4917 assert!(rendered.ends_with(expected_context));
4918 }
4919
4920 #[test]
4921 fn terminal_focus_events_control_cursor_visibility() {
4922 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4923
4924 assert!(handle_terminal_focus_event(&mut state, &Event::FocusLost));
4925 assert!(!state.terminal_focused);
4926 assert!(handle_terminal_focus_event(&mut state, &Event::FocusGained));
4927 assert!(state.terminal_focused);
4928 assert!(!handle_terminal_focus_event(
4929 &mut state,
4930 &Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
4931 ));
4932 assert!(state.terminal_focused);
4933 }
4934
4935 #[test]
4936 fn unfocused_busy_redraw_keeps_the_hardware_cursor_hidden() {
4937 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4938 state.set_status("working");
4939 state.set_busy(true);
4940 state.terminal_focused = false;
4941
4942 let mut terminal =
4943 Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
4944 terminal
4945 .draw(|frame| draw(frame, &state))
4946 .expect("draw busy state");
4947
4948 assert!(
4949 !terminal.backend().cursor_visible(),
4950 "a busy redraw must not re-show the terminal cursor"
4951 );
4952 }
4953
4954 #[test]
4955 fn cjk_input_keeps_the_terminal_cursor_in_the_prompt_without_resetting_activity() {
4956 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4957 state.set_status("working");
4958 state.busy = true;
4959 state.input = "한글".to_owned();
4960 state.cursor = state.input.chars().count();
4961 let activity_started_at = state.activity_started_at;
4962 let tool_animation_epoch = state.tool_animation_epoch;
4963 let sample_at = Instant::now();
4964 let activity_before = state.activity_levels_at(sample_at);
4965
4966 state.input_changed();
4969 assert_eq!(state.activity_started_at, activity_started_at);
4970 assert_eq!(state.tool_animation_epoch, tool_animation_epoch);
4971 assert_eq!(state.activity_levels_at(sample_at), activity_before);
4972
4973 let area = Rect::new(0, 0, 80, 10);
4974 let mut terminal =
4975 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4976 .expect("test terminal");
4977 terminal
4978 .draw(|frame| draw(frame, &state))
4979 .expect("draw CJK input while working");
4980 let (_, _, _, _, input_area, status_area) = ui_layout(&state, tui_viewport(area));
4981 assert_ne!(input_area.y, status_area.y);
4982 let prompt_area = prompt_area(input_area, &state);
4983 assert!(terminal.backend().cursor_visible());
4984 terminal.backend_mut().assert_cursor_position((
4985 prompt_area.x + UnicodeWidthStr::width(state.input.as_str()) as u16,
4986 prompt_area.y,
4987 ));
4988 }
4989
4990 #[test]
4991 fn transcript_and_console_are_separated_by_one_blank_row() {
4992 let state = UiState::from_history(&[], "secret", "model", None, false);
4993 let area = Rect::new(0, 0, 80, 10);
4994 let mut terminal =
4995 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4996 .expect("test terminal");
4997
4998 terminal
4999 .draw(|frame| draw(frame, &state))
5000 .expect("draw separated transcript and console");
5001
5002 let (transcript, _, _, _, console, _) = ui_layout(&state, tui_viewport(area));
5003 assert_eq!(transcript.y + transcript.height + 1, console.y);
5004 let gap_y = console.y - 1;
5005 for x in transcript.x..transcript.x + transcript.width {
5006 assert_eq!(terminal.backend().buffer()[(x, gap_y)].symbol(), " ");
5007 assert_eq!(terminal.backend().buffer()[(x, gap_y)].bg, Color::Reset);
5008 }
5009 }
5010
5011 #[test]
5012 fn prompt_surface_has_a_subtle_dark_background_when_idle_or_busy() {
5013 for busy in [false, true] {
5014 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5015 state.input = "prompt".to_owned();
5016 state.cursor = state.input.chars().count();
5017 state.busy = busy;
5018 let area = Rect::new(0, 0, 80, 10);
5019 let mut terminal =
5020 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
5021 .expect("test terminal");
5022
5023 terminal
5024 .draw(|frame| draw(frame, &state))
5025 .expect("draw prompt surface");
5026
5027 let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(area));
5028 let buffer = terminal.backend().buffer();
5029 for x in 0..area.width {
5030 assert_eq!(buffer[(x, input_area.y - 1)].bg, Color::Reset);
5031 }
5032 for y in input_area.y..input_area.y + input_area.height {
5033 for x in 0..area.width {
5034 let expected = if input_area.contains((x, y).into()) {
5035 PROMPT_BACKGROUND
5036 } else {
5037 Color::Reset
5038 };
5039 assert_eq!(
5040 buffer[(x, y)].bg,
5041 expected,
5042 "busy={busy}: unexpected background at ({x}, {y})"
5043 );
5044 }
5045 }
5046 }
5047 }
5048
5049 #[test]
5050 fn only_known_leading_skill_commands_activate_input_highlighting() {
5051 let skills = ["release-notes".to_owned()];
5052 assert_eq!(
5053 active_skill_trigger("/missing", &skills),
5054 None,
5055 "unknown commands are rejected by the turn engine and must not look active"
5056 );
5057 assert_eq!(
5058 active_skill_trigger("/skill:release-notes", &skills),
5059 None,
5060 "the removed /skill: wrapper must not look active"
5061 );
5062 assert_eq!(
5063 active_skill_trigger("write /release-notes", &skills),
5064 None,
5065 "only the command prefix accepted by the turn engine is active"
5066 );
5067 assert_eq!(active_skill_trigger("/", &skills), None);
5068 }
5069
5070 #[test]
5071 fn highlighted_skill_trigger_remains_styled_when_wrapped() {
5072 let input = "/release-notes argument";
5073 let trigger = active_skill_trigger(input, &["release-notes".to_owned()]);
5074 let lines = styled_text_lines(input, trigger, 8, Style::default().fg(Color::White));
5075 let highlighted = lines
5076 .iter()
5077 .flat_map(|line| line.spans.iter())
5078 .filter(|span| span.style.fg == Some(SKILL_TRIGGER_COLOR))
5079 .map(|span| span.content.as_ref())
5080 .collect::<String>();
5081 assert_eq!(highlighted, "/release-notes");
5082 }
5083
5084 #[test]
5085 fn input_has_no_prompt_marker_and_trailing_newline_is_visible() {
5086 assert_eq!(input_prompt("hello"), "hello");
5087 assert_eq!(wrap_text("hello\n", 80), vec!["hello", ""]);
5088 }
5089
5090 #[test]
5091 fn input_prompt_wraps_to_multiple_rows_when_long() {
5092 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5093 state.input = "abcdefghij".to_owned();
5094 let rows = input_visible_rows(&state, 5);
5096 assert!(rows >= 2);
5097 }
5098
5099 #[test]
5100 fn cursor_editing_moves_by_characters_and_preserves_unicode() {
5101 let mut input = "가나".to_owned();
5102 let mut cursor = input.chars().count();
5103 cursor -= 1;
5104 insert_at_cursor(&mut input, &mut cursor, 'x');
5105 assert_eq!(input, "가x나");
5106 assert_eq!(cursor, 2);
5107 assert!(remove_before_cursor(&mut input, &mut cursor));
5108 assert_eq!(input, "가나");
5109 assert_eq!(cursor, 1);
5110 }
5111
5112 #[test]
5113 fn cursor_row_tracks_newlines_and_wrapping() {
5114 assert_eq!(cursor_row("hello\nworld", 6, 80), 1);
5115 assert_eq!(cursor_row("abcdef", 4, 3), 1);
5116 }
5117
5118 #[test]
5119 fn shift_enter_inserts_at_the_cursor_and_moves_it_to_the_new_row() {
5120 let mut input = "beforeafter".to_owned();
5121 let mut cursor = 6;
5122 insert_at_cursor(&mut input, &mut cursor, '\n');
5123
5124 assert_eq!(input, "before\nafter");
5125 assert_eq!(cursor, 7);
5126 assert_eq!(cursor_row(&input, cursor, 80), 1);
5127 }
5128
5129 #[test]
5130 fn shift_enter_renders_the_cursor_on_the_new_input_row() {
5131 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5132 state.input = "beforeafter".to_owned();
5133 state.cursor = 6;
5134 insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
5135
5136 let mut terminal =
5137 Terminal::new(ratatui::backend::TestBackend::new(20, 10)).expect("test terminal");
5138 terminal
5139 .draw(|frame| draw(frame, &state))
5140 .expect("draw input cursor");
5141
5142 let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 20, 10)));
5145 let prompt_area = prompt_area(input_area, &state);
5146 terminal
5147 .backend_mut()
5148 .assert_cursor_position((prompt_area.x, prompt_area.y + 1));
5149 }
5150
5151 #[test]
5152 fn tool_results_attach_to_their_matching_call_after_consecutive_calls() {
5153 let history = vec![
5154 SessionHistoryRecord::Message {
5155 timestamp: 1,
5156 message: ChatMessage::assistant(
5157 String::new(),
5158 vec![
5159 crate::model::ChatToolCall {
5160 id: "call-first".to_owned(),
5161 name: "cmd".to_owned(),
5162 arguments: r#"{"command":"first"}"#.to_owned(),
5163 },
5164 crate::model::ChatToolCall {
5165 id: "call-second".to_owned(),
5166 name: "cmd".to_owned(),
5167 arguments: r#"{"command":"second"}"#.to_owned(),
5168 },
5169 ],
5170 ),
5171 },
5172 SessionHistoryRecord::Message {
5173 timestamp: 2,
5174 message: ChatMessage::tool(
5175 "call-first".to_owned(),
5176 "cmd".to_owned(),
5177 serde_json::json!({"stdout":"first result","stderr":""}).to_string(),
5178 ),
5179 },
5180 SessionHistoryRecord::Message {
5181 timestamp: 3,
5182 message: ChatMessage::tool(
5183 "call-second".to_owned(),
5184 "cmd".to_owned(),
5185 serde_json::json!({"stdout":"second result","stderr":""}).to_string(),
5186 ),
5187 },
5188 ];
5189
5190 let state = UiState::from_history(&history, "secret", "model", None, false);
5191 let lines = transcript_lines(&state, 200);
5192 assert_eq!(
5193 lines.len(),
5194 3,
5195 "only the two call lines and their separator remain"
5196 );
5197 assert_eq!(lines[0].to_string(), "✓ cmd $ first");
5198 assert_eq!(lines[2].to_string(), "✓ cmd $ second");
5199 }
5200 #[test]
5201 fn clipped_slash_picker_uses_its_actual_item_rows_for_the_focused_item() {
5202 let mut state = UiState::from_history(&[], "secret", "model", None, false)
5203 .with_skill_names(
5204 ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
5205 .into_iter()
5206 .map(str::to_owned)
5207 .collect(),
5208 );
5209 state.input = "/".to_owned();
5210 state.input_changed();
5211 state.skill_picker_focus = 5;
5212 let mut terminal =
5213 Terminal::new(ratatui::backend::TestBackend::new(30, 5)).expect("test terminal");
5214 terminal
5215 .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 5)))
5216 .expect("draw clipped skill picker");
5217
5218 let buffer = terminal.backend().buffer();
5219 let item_rows = (2..4)
5220 .map(|y| (2..28).map(|x| buffer[(x, y)].symbol()).collect::<String>())
5221 .collect::<Vec<_>>();
5222 assert!(item_rows[0].starts_with("/deploy"));
5223 assert!(item_rows[1].starts_with("/doctor"));
5224 assert_eq!(buffer[(2, 3)].fg, QUEUED_MESSAGE_COLOR);
5225 assert!(buffer[(2, 3)].modifier.contains(Modifier::BOLD));
5226 }
5227}
5228
5229#[cfg(test)]
5230mod skill_picker_tests {
5231 use super::*;
5232
5233 fn skill_names() -> Vec<String> {
5234 ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
5235 .into_iter()
5236 .map(str::to_owned)
5237 .collect()
5238 }
5239
5240 #[test]
5241 fn built_in_commands_share_the_slash_catalog_without_becoming_skills() {
5242 assert_eq!(
5243 command_names(vec!["release-notes".to_owned(), "settings".to_owned()]),
5244 vec!["exit", "release-notes", "settings"]
5245 );
5246 assert_eq!(
5247 builtin_command("/settings ignored arguments"),
5248 Some(BuiltinCommand::Settings)
5249 );
5250 assert_eq!(builtin_command(" /exit "), Some(BuiltinCommand::Exit));
5251 assert_eq!(builtin_command("/settings-extra"), None);
5252 }
5253
5254 #[test]
5255 fn settings_viewport_follows_focus_instead_of_truncating_the_catalog_head() {
5256 assert_eq!(selection_range(30, 0, 12), 0..12);
5257 assert_eq!(selection_range(30, 11, 12), 0..12);
5258 assert_eq!(selection_range(30, 12, 12), 1..13);
5259 assert_eq!(selection_range(30, 29, 12), 18..30);
5260 }
5261
5262 #[test]
5263 fn model_selection_uses_advertised_efforts_and_preserves_the_current_choice() {
5264 let mut state = UiState::from_history(&[], "secret", "old", Some("medium"), false);
5265 state.open_catalog(Ok(vec![ProviderModel {
5266 id: "openai/gpt-5.6-sol".to_owned(),
5267 efforts: Some(vec![
5268 "max".to_owned(),
5269 "high".to_owned(),
5270 "medium".to_owned(),
5271 "low".to_owned(),
5272 ]),
5273 }]));
5274 state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
5275
5276 let SettingsState::Effort { model, focus, .. } =
5277 state.settings.as_ref().expect("effort picker")
5278 else {
5279 panic!("model selection should open the effort picker");
5280 };
5281 assert_eq!(model.id, "openai/gpt-5.6-sol");
5282 assert_eq!(*focus, 3, "default occupies index zero before medium");
5283
5284 let selected = state
5285 .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
5286 .expect("effort selection");
5287 assert_eq!(
5288 selected,
5289 ("openai/gpt-5.6-sol".to_owned(), Some("medium".to_owned()))
5290 );
5291 }
5292
5293 #[test]
5294 fn effort_default_selection_does_not_shift_to_the_first_advertised_effort() {
5295 let mut state = UiState::from_history(&[], "secret", "old", None, false);
5296 state.open_catalog(Ok(vec![ProviderModel {
5297 id: "model".to_owned(),
5298 efforts: Some(vec!["high".to_owned(), "low".to_owned()]),
5299 }]));
5300 state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
5301
5302 let selected = state
5303 .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
5304 .expect("default effort selection");
5305 assert_eq!(selected, ("model".to_owned(), None));
5306 }
5307
5308 #[test]
5309 fn reasoning_indicator_changes_to_complete_and_stays_dark_gray() {
5310 let mut state = UiState::from_history(&[], "secret", "model", None, false);
5311 state.show_thinking();
5312
5313 let active_lines = transcript_lines(&state, 80);
5314 let active = active_lines.last().expect("reasoning line");
5315 assert!(active.to_string().starts_with("Reasoning... "));
5316 assert_eq!(active.style.fg, Some(Color::DarkGray));
5317
5318 state.complete_reasoning();
5319 let complete_lines = transcript_lines(&state, 80);
5320 let complete = complete_lines.last().expect("complete line");
5321 assert_eq!(complete.to_string(), "Reasoning Complete");
5322 assert_eq!(complete.style.fg, Some(Color::DarkGray));
5323 }
5324
5325 #[test]
5326 fn slash_picker_filters_only_leading_command_text_and_hides_without_matches() {
5327 let names = skill_names();
5328 assert_eq!(
5329 matching_skill_names("/", &names),
5330 vec!["alpha", "beta", "build", "charlie", "deploy", "doctor"]
5331 );
5332 assert_eq!(matching_skill_names("/b", &names), vec!["beta", "build"]);
5333 assert!(matching_skill_names("/missing", &names).is_empty());
5334 assert!(matching_skill_names("message /b", &names).is_empty());
5335 assert!(matching_skill_names("/beta arguments", &names).is_empty());
5336 }
5337
5338 #[test]
5339 fn slash_picker_focuses_the_top_match_and_moves_within_filtered_results() {
5340 let mut state = UiState::from_history(&[], "secret", "model", None, false)
5341 .with_skill_names(skill_names());
5342 state.input = "/b".to_owned();
5343 state.input_changed();
5344
5345 assert!(state.skill_picker_visible());
5346 assert_eq!(state.skill_picker_focus, 0);
5347 assert!(state.move_skill_picker(true));
5348 assert_eq!(state.skill_picker_focus, 1);
5349 assert!(state.move_skill_picker(true));
5350 assert_eq!(state.skill_picker_focus, 1, "focus does not leave the list");
5351 assert!(state.move_skill_picker(false));
5352 assert_eq!(state.skill_picker_focus, 0);
5353
5354 state.input = "/missing".to_owned();
5355 state.input_changed();
5356 assert!(!state.skill_picker_visible());
5357 assert!(!state.move_skill_picker(true));
5358 }
5359
5360 #[test]
5361 fn focused_builtins_are_distinguished_from_skills() {
5362 let mut state = UiState::from_history(&[], "secret", "model", None, false)
5363 .with_skill_names(command_names(skill_names()));
5364 state.input = "/se".to_owned();
5365 state.input_changed();
5366 assert_eq!(
5367 state.focused_builtin_command(),
5368 Some(BuiltinCommand::Settings)
5369 );
5370
5371 state.input = "/be".to_owned();
5372 state.input_changed();
5373 assert_eq!(state.focused_builtin_command(), None);
5374 }
5375
5376 #[test]
5377 fn selecting_the_focused_skill_leaves_the_completed_command_ready_to_send() {
5378 let mut state = UiState::from_history(&[], "secret", "model", None, false)
5379 .with_skill_names(skill_names());
5380 state.input = "/b".to_owned();
5381 state.input_changed();
5382 state.move_skill_picker(true);
5383
5384 assert!(state.select_focused_skill());
5385 assert_eq!(state.input, "/build");
5386 assert_eq!(state.cursor, "/build".chars().count());
5387 assert!(
5388 !state.skill_picker_visible(),
5389 "the first Enter completes the input rather than sending it"
5390 );
5391 assert!(
5392 !state.select_focused_skill(),
5393 "a second Enter follows the normal send/attachment path"
5394 );
5395 }
5396
5397 #[test]
5398 fn slash_picker_overlays_without_reflowing_the_transcript_when_match_count_changes() {
5399 let mut state = UiState::from_history(&[], "secret", "model", None, false)
5400 .with_skill_names(skill_names());
5401 let area = Rect::new(0, 0, 40, 16);
5402 state.transcript = (0..20)
5403 .map(|index| TranscriptItem::Assistant(format!("message {index}")))
5404 .collect();
5405
5406 state.input = "/a".to_owned();
5407 state.input_changed();
5408 let (narrow_chat, narrow_picker, _, _, narrow_input, _) = ui_layout(&state, area);
5409 let narrow_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
5410
5411 state.input = "/".to_owned();
5412 state.input_changed();
5413 let (broad_chat, broad_picker, _, _, broad_input, _) = ui_layout(&state, area);
5414 let broad_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
5415
5416 assert_ne!(
5417 narrow_picker, broad_picker,
5418 "the overlay may fit its contents"
5419 );
5420 assert_eq!(narrow_chat, broad_chat);
5421 assert_eq!(narrow_input, broad_input);
5422 assert_eq!(
5423 narrow_scroll, broad_scroll,
5424 "the overlay does not reduce the transcript viewport"
5425 );
5426 }
5427
5428 #[test]
5429 fn slash_picker_keeps_the_focused_item_in_its_five_row_viewport() {
5430 assert_eq!(selection_range(20, 0, 5), 0..5);
5431 assert_eq!(selection_range(20, 4, 5), 0..5);
5432 assert_eq!(selection_range(20, 5, 5), 1..6);
5433 assert_eq!(selection_range(20, 19, 5), 15..20);
5434 }
5435
5436 #[test]
5437 fn is_inside_tmux_detection() {
5438 std::env::set_var("TERM_PROGRAM", "tmux");
5439 assert!(is_inside_tmux());
5440 std::env::set_var("TERM_PROGRAM", "TMUX");
5441 assert!(is_inside_tmux());
5442 std::env::set_var("TERM_PROGRAM", "ghostty");
5443 assert!(!is_inside_tmux());
5444 std::env::remove_var("TERM_PROGRAM");
5445 assert!(!is_inside_tmux());
5446 }
5447
5448 #[test]
5449 fn slash_picker_is_rendered_immediately_above_the_input() {
5450 let mut state = UiState::from_history(&[], "secret", "model", None, false)
5451 .with_skill_names(skill_names());
5452 state.input = "/".to_owned();
5453 state.input_changed();
5454 let mut terminal =
5455 Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
5456 terminal
5457 .draw(|frame| draw(frame, &state))
5458 .expect("draw TUI");
5459
5460 let buffer = terminal.backend().buffer();
5461 let area = tui_viewport(Rect::new(0, 0, 40, 12));
5462 let (_, picker_area, _, _, input_area, _) = ui_layout(&state, area);
5463 let picker_area = picker_area.expect("picker area");
5464 assert_eq!(picker_area.y + picker_area.height, input_area.y);
5466 for (x, y) in [
5467 (picker_area.x, picker_area.y),
5468 (picker_area.x + picker_area.width - 1, picker_area.y),
5469 (picker_area.x, picker_area.y + picker_area.height - 1),
5470 (
5471 picker_area.x + picker_area.width - 1,
5472 picker_area.y + picker_area.height - 1,
5473 ),
5474 ] {
5475 assert_eq!(buffer[(x, y)].symbol(), " ");
5476 assert_eq!(buffer[(x, y)].bg, SKILL_PICKER_BACKGROUND);
5477 }
5478 assert_eq!(
5479 buffer[(picker_area.x + 1, picker_area.y + 1)].bg,
5480 SKILL_PICKER_BACKGROUND
5481 );
5482 assert_eq!(buffer[(picker_area.x + 2, picker_area.y + 1)].symbol(), "[");
5483 assert_eq!(
5484 buffer[(picker_area.x + 2, picker_area.y + 1)].fg,
5485 QUEUED_MESSAGE_COLOR
5486 );
5487 assert_eq!(buffer[(picker_area.x + 2, picker_area.y + 2)].symbol(), "/");
5488 assert_eq!(
5489 buffer[(picker_area.x + 2, picker_area.y + 2)].fg,
5490 QUEUED_MESSAGE_COLOR
5491 );
5492 assert_eq!(
5493 buffer[(picker_area.x + 1, picker_area.y + picker_area.height - 2)].bg,
5494 SKILL_PICKER_BACKGROUND
5495 );
5496 assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), " ");
5497 assert_eq!(buffer[(input_area.x, input_area.y)].bg, PROMPT_BACKGROUND);
5498 }
5499
5500 #[test]
5501 fn slash_picker_renders_count_with_bold_focus_on_the_picker_surface() {
5502 let mut state = UiState::from_history(&[], "secret", "model", None, false)
5503 .with_skill_names(skill_names());
5504 state.input = "/".to_owned();
5505 state.input_changed();
5506 let mut terminal =
5507 Terminal::new(ratatui::backend::TestBackend::new(30, 8)).expect("test terminal");
5508 terminal
5509 .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 8)))
5510 .expect("draw skill picker");
5511
5512 let buffer = terminal.backend().buffer();
5513 assert_eq!(buffer[(0, 0)].symbol(), " ");
5514 assert_eq!(buffer[(0, 0)].bg, SKILL_PICKER_BACKGROUND);
5515 assert_eq!(buffer[(2, 1)].symbol(), "[");
5516 assert_eq!(buffer[(2, 1)].fg, QUEUED_MESSAGE_COLOR);
5517 assert_eq!(buffer[(2, 2)].symbol(), "/");
5518 assert_eq!(buffer[(2, 2)].fg, QUEUED_MESSAGE_COLOR);
5519 assert!(buffer[(2, 2)].modifier.contains(Modifier::BOLD));
5520 assert_eq!(buffer[(2, 3)].symbol(), "/");
5521 assert_eq!(buffer[(2, 3)].fg, QUEUED_MESSAGE_COLOR);
5522 assert!(!buffer[(2, 3)].modifier.contains(Modifier::BOLD));
5523 }
5524}
5525
5526#[cfg(test)]
5527mod tmux_keyboard_tests {
5528 use super::*;
5529
5530 #[test]
5531 fn is_inside_tmux_detection() {
5532 std::env::set_var("TERM_PROGRAM", "tmux");
5533 assert!(is_inside_tmux());
5534 std::env::set_var("TERM_PROGRAM", "TMUX");
5535 assert!(is_inside_tmux());
5536 std::env::set_var("TERM_PROGRAM", "ghostty");
5537 assert!(!is_inside_tmux());
5538 std::env::remove_var("TERM_PROGRAM");
5539 assert!(!is_inside_tmux());
5540 }
5541}