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