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