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