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