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