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