1use std::collections::{HashMap, HashSet};
2use std::io::{self, Write};
3use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
4use std::thread::{self, JoinHandle};
5use std::time::{Duration, Instant};
6
7use crossterm::cursor::{Hide, Show};
8use crossterm::event::{
9 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
10 KeyModifiers, KeyboardEnhancementFlags, MouseEventKind, PopKeyboardEnhancementFlags,
11 PushKeyboardEnhancementFlags,
12};
13use crossterm::execute;
14use crossterm::terminal::{
15 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
16};
17use ratatui::backend::CrosstermBackend;
18use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect, Size};
19use ratatui::prelude::Frame;
20use ratatui::style::{Color, Style};
21use ratatui::text::{Line, Span};
22use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
23use ratatui::Terminal;
24use serde_json::Value;
25use unicode_width::UnicodeWidthStr;
26
27use crate::app::Harness;
28use crate::cancellation::CancellationToken;
29use crate::model::{estimate_context_tokens, ChatMessage};
30use crate::protocol::{EventSink, ProtocolEvent};
31use crate::provider::ProviderModel;
32use crate::redaction::redact_secret;
33use crate::session::SessionHistoryRecord;
34
35const EVENT_POLL: Duration = Duration::from_millis(50);
36const MAX_DISPLAY_INPUT_CHARS: usize = 16 * 1024;
37const WORKER_SHUTDOWN_GRACE: Duration = Duration::from_secs(2);
38const MAX_INPUT_ROWS: u16 = 12;
41const WELCOME_MESSAGE: &str = concat!("Coding Agent Harness LUCY - v", env!("CARGO_PKG_VERSION"),);
42const WELCOME_TAGLINE: &str = "An ultra-thin harness for tomorrow's most powerful models";
43const WELCOME_START_COLOR: (u8, u8, u8) = (0, 180, 180);
44const WELCOME_END_COLOR: (u8, u8, u8) = (255, 215, 0);
45const USER_BORDER_COLOR: Color = Color::Rgb(192, 154, 0);
46const USER_BORDER_GLYPH: &str = "▌";
47const PROMPT_BORDER_START_COLOR: (u8, u8, u8) = (0, 190, 185);
48const PROMPT_BORDER_END_COLOR: (u8, u8, u8) = (0, 205, 85);
49const PENDING_TOOL_COLOR_RGB: (u8, u8, u8) = (255, 165, 0);
50const PENDING_TOOL_COLOR: Color = Color::Rgb(
51 PENDING_TOOL_COLOR_RGB.0,
52 PENDING_TOOL_COLOR_RGB.1,
53 PENDING_TOOL_COLOR_RGB.2,
54);
55const TOOL_SUCCESS_SWEEP_DURATION: Duration = Duration::from_millis(350);
58const TOOL_SUCCESS_SWEEP_WIDTH: f32 = 3.0;
59const TOOL_SUCCESS_GREEN_RGB: (u8, u8, u8) = (0, 128, 0);
60const QUEUED_MESSAGE_BACKGROUND: Color = Color::Rgb(0, 38, 38);
61const QUEUED_MESSAGE_COLOR: Color = Color::Rgb(150, 255, 245);
62const WORKING_GRADIENT_COLORS: [(u8, u8, u8); 4] = [
65 (220, 35, 175), (235, 45, 65), (255, 130, 25), (235, 45, 65), ];
70const WORKING_GRADIENT_CYCLE: Duration = Duration::from_millis(5000);
71const SKILL_PICKER_MAX_ROWS: usize = 5;
72const BUILTIN_COMMANDS: [&str; 2] = ["settings", "exit"];
73const SUBAGENT_OVERLAY_BACKGROUND: Color = Color::Rgb(55, 0, 55);
74const SUBAGENT_OVERLAY_COLOR: Color = Color::Rgb(255, 0, 255);
75const SETTINGS_MIN_WIDTH: u16 = 36;
76const SETTINGS_MAX_WIDTH: u16 = 88;
77const SETTINGS_MIN_HEIGHT: u16 = 8;
78const SETTINGS_MAX_HEIGHT: u16 = 22;
79
80pub(crate) fn run<W: Write>(mut harness: Harness, resumed: bool, stdout: W) -> Result<(), String> {
81 let secret = harness.provider.api_key().to_owned();
82 let context_window = harness
83 .context_window
84 .or_else(|| harness.provider.context_window());
85 harness.context_window = context_window;
86 let context_tokens = estimate_context_tokens(&harness.session.provider_messages());
87 let skill_names = command_names(
88 harness
89 .session
90 .skills
91 .iter()
92 .map(|skill| skill.name.clone())
93 .collect(),
94 );
95 let mut state = UiState::from_history(
96 &harness.session.history,
97 &secret,
98 &harness.session.llm.model,
99 harness.session.llm.effort.as_deref(),
100 resumed,
101 )
102 .with_attached_agents(harness.attached_agents.clone())
103 .with_skill_names(skill_names)
104 .with_context(context_window, context_tokens);
105 let (request_tx, request_rx) = mpsc::channel::<WorkerRequest>();
106 let (message_tx, message_rx) = mpsc::channel::<WorkerMessage>();
107
108 let stdout = stdout;
109 enable_raw_mode().map_err(|error| format!("unable to enable terminal input: {error}"))?;
110 let backend = CrosstermBackend::new(stdout);
111 let terminal = match Terminal::new(backend) {
112 Ok(terminal) => terminal,
113 Err(error) => {
114 let _ = disable_raw_mode();
115 return Err(format!("unable to initialize terminal UI: {error}"));
116 }
117 };
118 let mut terminal_guard = TerminalGuard::new(terminal);
119 let backend = terminal_guard.terminal_mut().backend_mut();
120 if let Err(error) = execute!(backend, EnterAlternateScreen, EnableMouseCapture, Hide) {
121 return Err(format!("unable to enter terminal UI: {error}"));
122 }
123 if supports_keyboard_enhancement() {
128 let _ = execute!(
129 backend,
130 PushKeyboardEnhancementFlags(
131 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
132 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES,
133 )
134 );
135 terminal_guard.keyboard_enhancement = true;
136 }
137 let worker = thread::spawn(move || worker_loop(&mut harness, request_rx, message_tx, resumed));
138
139 let result = event_loop(
140 terminal_guard.terminal_mut(),
141 &mut state,
142 &request_tx,
143 &message_rx,
144 );
145
146 if let Some(token) = state.active_cancel.take() {
147 let _ = token.cancel();
148 }
149 let _ = request_tx.send(WorkerRequest::Shutdown);
150 wait_for_worker(worker, WORKER_SHUTDOWN_GRACE);
151 drop(terminal_guard);
152 result
153}
154
155fn worker_loop(
156 harness: &mut Harness,
157 requests: Receiver<WorkerRequest>,
158 messages: Sender<WorkerMessage>,
159 resumed: bool,
160) {
161 let mut sink = ChannelSink {
162 sender: messages.clone(),
163 };
164 if sink
165 .emit_event(&ProtocolEvent::Session {
166 session_id: harness.session.id.clone(),
167 resumed,
168 })
169 .is_err()
170 {
171 return;
172 }
173
174 loop {
175 if let Some(completion) = harness.next_subagent_completion() {
176 let task_id = completion.task_id.clone();
177 let result = completion.result.clone();
178 let notification = Harness::subagent_notification(&completion);
179 let _ = messages.send(WorkerMessage::SubagentCompleted { task_id, result });
180 let cancel = CancellationToken::new();
181 let _ = messages.send(WorkerMessage::Started {
182 cancel: cancel.clone(),
183 user_text: None,
184 });
185 if let Err(error) = harness.handle_message(¬ification, &mut sink, Some(&cancel)) {
186 let message = redact_secret(&error, Some(harness.provider.api_key()));
187 let _ = sink.emit_event(&ProtocolEvent::Error { message });
188 }
189 let _ = messages.send(WorkerMessage::Finished);
190 continue;
191 }
192 let request = match requests.recv_timeout(EVENT_POLL) {
193 Ok(request) => request,
194 Err(mpsc::RecvTimeoutError::Timeout) => {
195 if let Some(completion) = harness.next_subagent_completion() {
196 let task_id = completion.task_id.clone();
197 let result = completion.result.clone();
198 let notification = Harness::subagent_notification(&completion);
199 let _ = messages.send(WorkerMessage::SubagentCompleted { task_id, result });
200 let cancel = CancellationToken::new();
201 let _ = messages.send(WorkerMessage::Started {
202 cancel: cancel.clone(),
203 user_text: None,
204 });
205 if let Err(error) =
206 harness.handle_message(¬ification, &mut sink, Some(&cancel))
207 {
208 let message = redact_secret(&error, Some(harness.provider.api_key()));
209 let _ = sink.emit_event(&ProtocolEvent::Error { message });
210 }
211 let _ = messages.send(WorkerMessage::Finished);
212 }
213 continue;
214 }
215 Err(mpsc::RecvTimeoutError::Disconnected) => break,
216 };
217 match request {
218 WorkerRequest::Turn { text } => {
219 let cancel = CancellationToken::new();
220 let _ = messages.send(WorkerMessage::Started {
221 cancel: cancel.clone(),
222 user_text: Some(text.clone()),
223 });
224 if let Err(error) = harness.handle_message(&text, &mut sink, Some(&cancel)) {
225 let message = redact_secret(&error, Some(harness.provider.api_key()));
226 let _ = sink.emit_event(&ProtocolEvent::Error { message });
227 }
228 let _ = messages.send(WorkerMessage::Finished);
229 }
230 WorkerRequest::Catalog => {
231 let _ = messages.send(WorkerMessage::Catalog(
232 harness.provider.models().map_err(|error| error.to_string()),
233 ));
234 }
235 WorkerRequest::ApplySettings { model, effort } => {
236 let result = harness.apply_settings(&harness.home.clone(), model, effort);
237 let _ = messages.send(WorkerMessage::SettingsApplied(
238 result,
239 harness.session.llm.model.clone(),
240 harness.session.llm.effort.clone(),
241 harness.context_window,
242 ));
243 }
244 WorkerRequest::Shutdown => break,
245 }
246 }
247}
248
249fn event_loop<W: Write>(
250 terminal: &mut Terminal<CrosstermBackend<W>>,
251 state: &mut UiState,
252 requests: &Sender<WorkerRequest>,
253 messages: &Receiver<WorkerMessage>,
254) -> Result<(), String> {
255 let mut quitting = false;
256 loop {
257 loop {
258 match messages.try_recv() {
259 Ok(WorkerMessage::Event(event)) => state.apply_event(event),
260 Ok(WorkerMessage::SubagentCompleted { task_id, result }) => {
261 state.complete_subagent(&task_id, result);
262 }
263 Ok(WorkerMessage::Started { cancel, user_text }) => {
264 if let Some(text) = user_text {
265 state.start_queued_user(&text);
266 }
267 state.active_cancel = Some(cancel);
268 state.busy = true;
269 state.set_status("working");
270 }
271 Ok(WorkerMessage::Thinking) => state.show_thinking(),
272 Ok(WorkerMessage::ReasoningCompleted) => state.complete_reasoning(),
273 Ok(WorkerMessage::SkillInstructionAttached) => {
274 state.mark_latest_user_skill_attached()
275 }
276 Ok(WorkerMessage::ContextUsage(tokens)) => state.context_tokens = tokens,
277 Ok(WorkerMessage::CompactionStarted) => state.set_status("compacting"),
278 Ok(WorkerMessage::CompactionFinished {
279 tokens_before,
280 tokens_after,
281 }) => {
282 state.context_tokens = tokens_after;
283 state.set_status("working");
284 state.transcript.push(TranscriptItem::Info(format!(
285 "↻ context compacted ({} → {})",
286 format_context_tokens(tokens_before),
287 format_context_tokens(tokens_after)
288 )));
289 }
290 Ok(WorkerMessage::Catalog(result)) => state.open_catalog(result),
291 Ok(WorkerMessage::SettingsApplied(result, model, effort, context_window)) => {
292 state.settings_applied(result, model, effort, context_window)
293 }
294 Ok(WorkerMessage::Finished) => {
295 release_finished_turn(terminal.backend_mut(), state);
296 match state.status.as_str() {
297 "cancelling" => state.set_status("사용자 중단"),
298 "finalizing" => state.set_status("ready"),
299 _ => {}
300 }
301 if quitting {
302 return Ok(());
303 }
304 }
305 Err(TryRecvError::Empty) => break,
306 Err(TryRecvError::Disconnected) => {
307 if state.busy {
308 return Err("TUI worker stopped unexpectedly".to_owned());
309 }
310 return Ok(());
311 }
312 }
313 }
314
315 terminal
316 .draw(|frame| draw(frame, state))
317 .map_err(|error| format!("unable to render TUI: {error}"))?;
318
319 if quitting {
320 thread::sleep(EVENT_POLL);
321 continue;
322 }
323 if event::poll(EVENT_POLL)
324 .map_err(|error| format!("unable to read terminal input: {error}"))?
325 {
326 let event =
327 event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
328 if let Event::Mouse(mouse) = event {
329 let size = terminal
330 .size()
331 .map_err(|error| format!("unable to read terminal size: {error}"))?;
332 let max_scroll = max_scroll_for_area(state, size);
333 handle_mouse_event(state, mouse.kind, max_scroll);
334 continue;
335 }
336 let Event::Key(key) = event else {
337 continue;
338 };
339 if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
340 continue;
341 }
342 if is_ctrl_c(&key) {
343 if let Some(token) = state.active_cancel.as_ref() {
344 let _ = token.cancel();
345 quitting = true;
346 } else {
347 return Ok(());
348 }
349 continue;
350 }
351 if !state.busy && state.settings.is_some() {
352 if let Some((model, effort)) = state.handle_settings_key(&key) {
353 state.settings = Some(SettingsState::Applying {
354 model: model.clone(),
355 effort: effort.clone(),
356 });
357 requests
358 .send(WorkerRequest::ApplySettings { model, effort })
359 .map_err(|_| "TUI worker is unavailable".to_owned())?;
360 }
361 continue;
362 }
363 if key.code == KeyCode::Esc {
364 if let Some(token) = state.active_cancel.as_ref() {
365 if token.cancel() {
366 state.set_status("cancelling");
367 }
368 }
369 continue;
370 }
371 match key.code {
372 KeyCode::Enter => {
373 if key.modifiers.contains(KeyModifiers::SHIFT)
378 || key.modifiers.contains(KeyModifiers::ALT)
379 {
380 if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
381 insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
382 state.input_changed();
383 }
384 continue;
385 }
386 let text = if let Some(command) = state.focused_builtin_command() {
389 state.input.clear();
390 format!("/{}", command.name())
391 } else {
392 if state.select_focused_skill() {
393 continue;
394 }
395 std::mem::take(&mut state.input)
396 };
397 state.cursor = 0;
398 if let Some(command) = builtin_command(&text) {
399 state.reset_skill_picker();
400 if state.busy {
401 state.transcript.push(TranscriptItem::Info(format!(
402 "/{} is available when the current turn finishes",
403 command.name()
404 )));
405 continue;
406 }
407 match command {
408 BuiltinCommand::Settings => {
409 state.settings = Some(SettingsState::Loading);
410 requests
411 .send(WorkerRequest::Catalog)
412 .map_err(|_| "TUI worker is unavailable".to_owned())?;
413 continue;
414 }
415 BuiltinCommand::Exit => return Ok(()),
416 }
417 }
418 state.reset_skill_picker();
419 if text.trim().is_empty() {
420 continue;
421 }
422 state.auto_scroll = true;
423 state.scroll = 0;
424 state.submit_user(&text);
425 state.busy = true;
426 state.set_status("working");
427 requests
428 .send(WorkerRequest::Turn { text })
429 .map_err(|_| "TUI worker is unavailable".to_owned())?;
430 }
431 KeyCode::Tab => {
432 state.select_focused_skill();
435 }
436 KeyCode::Char(character) => {
437 if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
438 insert_at_cursor(&mut state.input, &mut state.cursor, character);
439 state.input_changed();
440 }
441 }
442 KeyCode::Backspace => {
443 if remove_before_cursor(&mut state.input, &mut state.cursor) {
444 state.input_changed();
445 }
446 }
447 KeyCode::Left => {
448 state.cursor = state.cursor.saturating_sub(1);
449 }
450 KeyCode::Right => {
451 state.cursor = (state.cursor + 1).min(state.input.chars().count());
452 }
453 KeyCode::Home => {
454 state.cursor = 0;
455 }
456 KeyCode::End => {
457 state.cursor = state.input.chars().count();
458 }
459 KeyCode::Up => {
460 if !state.move_skill_picker(false) {
461 let size = terminal
462 .size()
463 .map_err(|error| format!("unable to read terminal size: {error}"))?;
464 let max_scroll = max_scroll_for_area(state, size);
465 scroll_up(state, max_scroll);
466 }
467 }
468 KeyCode::Down => {
469 if !state.move_skill_picker(true) {
470 let size = terminal
471 .size()
472 .map_err(|error| format!("unable to read terminal size: {error}"))?;
473 let max_scroll = max_scroll_for_area(state, size);
474 scroll_down(state, max_scroll);
475 }
476 }
477 KeyCode::PageUp => {
478 let size = terminal
479 .size()
480 .map_err(|error| format!("unable to read terminal size: {error}"))?;
481 let max_scroll = max_scroll_for_area(state, size);
482 scroll_up(state, max_scroll);
483 }
484 KeyCode::PageDown => {
485 let size = terminal
486 .size()
487 .map_err(|error| format!("unable to read terminal size: {error}"))?;
488 let max_scroll = max_scroll_for_area(state, size);
489 scroll_down(state, max_scroll);
490 }
491 _ => {}
492 }
493 }
494 }
495}
496
497fn is_ctrl_c(key: &KeyEvent) -> bool {
498 key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
499}
500
501fn handle_mouse_event(state: &mut UiState, kind: MouseEventKind, max_scroll: u16) {
502 match kind {
503 MouseEventKind::ScrollUp => scroll_up(state, max_scroll),
504 MouseEventKind::ScrollDown => scroll_down(state, max_scroll),
505 _ => {}
506 }
507}
508
509fn scroll_up(state: &mut UiState, max_scroll: u16) {
510 if state.auto_scroll {
511 state.scroll = max_scroll;
512 state.auto_scroll = false;
513 } else {
514 state.scroll = state.scroll.min(max_scroll);
515 }
516 state.scroll = state.scroll.saturating_sub(3);
517}
518
519fn scroll_down(state: &mut UiState, max_scroll: u16) {
520 if state.auto_scroll {
521 return;
522 }
523 state.scroll = state.scroll.saturating_add(3).min(max_scroll);
524 if state.scroll == max_scroll {
525 state.auto_scroll = true;
528 state.scroll = 0;
529 }
530}
531
532fn wait_for_worker(worker: JoinHandle<()>, grace: Duration) {
533 let deadline = std::time::Instant::now() + grace;
534 while !worker.is_finished() && std::time::Instant::now() < deadline {
535 thread::sleep(Duration::from_millis(5));
536 }
537 if worker.is_finished() {
538 let _ = worker.join();
539 }
540}
541
542struct TerminalGuard<W: Write> {
543 terminal: Option<Terminal<CrosstermBackend<W>>>,
544 keyboard_enhancement: bool,
545}
546
547impl<W: Write> TerminalGuard<W> {
548 fn new(terminal: Terminal<CrosstermBackend<W>>) -> Self {
549 Self {
550 terminal: Some(terminal),
551 keyboard_enhancement: false,
552 }
553 }
554
555 fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {
556 self.terminal
557 .as_mut()
558 .expect("terminal guard is initialized")
559 }
560}
561
562impl<W: Write> Drop for TerminalGuard<W> {
563 fn drop(&mut self) {
564 let Some(mut terminal) = self.terminal.take() else {
565 return;
566 };
567 if self.keyboard_enhancement {
568 let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
569 }
570 let _ = terminal.show_cursor();
571 let _ = disable_raw_mode();
572 let _ = execute!(
573 terminal.backend_mut(),
574 DisableMouseCapture,
575 LeaveAlternateScreen,
576 Show
577 );
578 let _ = terminal.backend_mut().flush();
579 }
580}
581
582fn supports_keyboard_enhancement() -> bool {
587 fn env(name: &str) -> Option<String> {
588 std::env::var(name).ok().map(|value| value.to_lowercase())
589 }
590 let term = env("TERM").unwrap_or_default();
591 let program = env("TERM_PROGRAM").unwrap_or_default();
592 if term.starts_with("xterm-kitty")
593 || term.starts_with("ghostty")
594 || term.starts_with("xterm-ghostty")
595 {
596 return true;
597 }
598 matches!(
599 program.as_str(),
600 "ghostty" | "kitty" | "wezterm" | "alacritty" | "foot" | "footclient" | "iterm.app"
601 )
602}
603
604#[derive(Debug, Clone, Copy, PartialEq, Eq)]
605enum TurnNotification {
606 Completed,
607 Interrupted,
608 Failed,
609}
610
611impl TurnNotification {
612 fn body(self) -> &'static str {
613 match self {
614 Self::Completed => "Turn complete",
615 Self::Interrupted => "Turn interrupted",
616 Self::Failed => "Turn failed",
617 }
618 }
619}
620
621fn turn_notification_for_status(status: &str) -> TurnNotification {
622 match status {
623 "cancelling" | "사용자 중단" => TurnNotification::Interrupted,
624 "error" => TurnNotification::Failed,
625 _ => TurnNotification::Completed,
626 }
627}
628
629fn send_turn_notification<W: Write>(
635 writer: &mut W,
636 notification: TurnNotification,
637) -> io::Result<()> {
638 writer.write_all(b"\x1b]777;notify;Lucy;")?;
639 writer.write_all(notification.body().as_bytes())?;
640 writer.write_all(b"\x07")?;
641 writer.flush()
642}
643
644fn release_finished_turn<W: Write>(writer: &mut W, state: &mut UiState) {
645 let was_busy = state.busy;
646 let notification = turn_notification_for_status(&state.status);
647 state.busy = false;
648 state.active_cancel = None;
649 if was_busy {
650 let _ = send_turn_notification(writer, notification);
653 }
654}
655
656enum WorkerRequest {
657 Turn {
658 text: String,
659 },
660 Catalog,
661 ApplySettings {
662 model: String,
663 effort: Option<String>,
664 },
665 Shutdown,
666}
667
668enum WorkerMessage {
669 Event(ProtocolEvent),
670 Started {
671 cancel: CancellationToken,
672 user_text: Option<String>,
673 },
674 Thinking,
675 ReasoningCompleted,
676 SkillInstructionAttached,
677 ContextUsage(usize),
678 CompactionStarted,
679 CompactionFinished {
680 tokens_before: usize,
681 tokens_after: usize,
682 },
683 Catalog(Result<Vec<ProviderModel>, String>),
684 SettingsApplied(Result<(), String>, String, Option<String>, Option<usize>),
685 SubagentCompleted {
686 task_id: String,
687 result: Value,
688 },
689 Finished,
690}
691
692struct ChannelSink {
693 sender: Sender<WorkerMessage>,
694}
695
696impl EventSink for ChannelSink {
697 fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
698 self.sender
699 .send(WorkerMessage::Event(event.clone()))
700 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
701 }
702
703 fn reasoning_started(&mut self) -> io::Result<()> {
704 self.sender
705 .send(WorkerMessage::Thinking)
706 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
707 }
708
709 fn reasoning_completed(&mut self) -> io::Result<()> {
710 self.sender
711 .send(WorkerMessage::ReasoningCompleted)
712 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
713 }
714
715 fn skill_instruction_attached(&mut self, _name: &str) -> io::Result<()> {
716 self.sender
717 .send(WorkerMessage::SkillInstructionAttached)
718 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
719 }
720
721 fn context_usage(&mut self, tokens: usize) -> io::Result<()> {
722 self.sender
723 .send(WorkerMessage::ContextUsage(tokens))
724 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
725 }
726
727 fn compaction_started(&mut self) -> io::Result<()> {
728 self.sender
729 .send(WorkerMessage::CompactionStarted)
730 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
731 }
732
733 fn compaction_finished(&mut self, tokens_before: usize, tokens_after: usize) -> io::Result<()> {
734 self.sender
735 .send(WorkerMessage::CompactionFinished {
736 tokens_before,
737 tokens_after,
738 })
739 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
740 }
741}
742
743#[derive(Debug, Clone, Copy, PartialEq, Eq)]
744enum SubagentStatus {
745 Queued,
746 Running,
747 Failed,
748}
749
750#[derive(Debug, Clone, PartialEq)]
751struct SubagentTask {
752 call_id: String,
753 task_id: Option<String>,
754 task: String,
755 model: Option<String>,
756 effort: Option<String>,
757 status: SubagentStatus,
758 result: Option<Value>,
759}
760
761#[derive(Debug, Clone)]
765struct ActivityTransition {
766 started_at: Instant,
767 from_levels: [usize; PULSE_BAR_PERIODS.len()],
768 to_levels: [usize; PULSE_BAR_PERIODS.len()],
769}
770
771struct UiState {
772 model: String,
773 effort: Option<String>,
774 context_window: Option<usize>,
775 context_tokens: usize,
776 secret: String,
777 transcript: Vec<TranscriptItem>,
778 queued_messages: Vec<String>,
779 input: String,
780 cursor: usize,
781 status: String,
782 busy: bool,
783 active_cancel: Option<CancellationToken>,
784 scroll: u16,
785 auto_scroll: bool,
786 tool_animation_epoch: Instant,
787 activity_started_at: Instant,
788 activity_transition: Option<ActivityTransition>,
789 last_active_levels: [usize; PULSE_BAR_PERIODS.len()],
790 last_active_elapsed: Duration,
791 welcome_visible: bool,
792 attached_agents: Vec<String>,
793 subagents: Vec<SubagentTask>,
794 completed_subagent_calls: HashSet<String>,
795 cmd_success_started_at: HashMap<String, Instant>,
796 skill_names: Vec<String>,
797 skill_picker_focus: usize,
798 skill_picker_suppressed: bool,
799 settings: Option<SettingsState>,
800}
801
802impl UiState {
803 fn from_history(
804 history: &[SessionHistoryRecord],
805 secret: &str,
806 model: &str,
807 effort: Option<&str>,
808 resumed: bool,
809 ) -> Self {
810 let mut state = Self {
811 model: model.to_owned(),
812 effort: effort.map(str::to_owned),
813 context_window: None,
814 context_tokens: 1,
815 secret: secret.to_owned(),
816 transcript: Vec::new(),
817 queued_messages: Vec::new(),
818 input: String::new(),
819 cursor: 0,
820 status: "ready".to_owned(),
821 busy: false,
822 active_cancel: None,
823 scroll: 0,
824 auto_scroll: true,
825 tool_animation_epoch: Instant::now(),
826 activity_started_at: Instant::now(),
827 activity_transition: None,
828 last_active_levels: [0; PULSE_BAR_PERIODS.len()],
829 last_active_elapsed: Duration::ZERO,
830 welcome_visible: !resumed && history.is_empty(),
831 attached_agents: Vec::new(),
832 subagents: Vec::new(),
833 completed_subagent_calls: HashSet::new(),
834 cmd_success_started_at: HashMap::new(),
835 skill_names: Vec::new(),
836 skill_picker_focus: 0,
837 skill_picker_suppressed: false,
838 settings: None,
839 };
840 for record in history {
841 state.add_history_record(record);
842 }
843 state
844 }
845
846 fn with_attached_agents(mut self, attached_agents: Vec<String>) -> Self {
847 self.attached_agents = attached_agents;
848 self
849 }
850
851 fn with_skill_names(mut self, skill_names: Vec<String>) -> Self {
852 self.skill_names = skill_names;
853 self
854 }
855
856 fn with_context(mut self, context_window: Option<usize>, context_tokens: usize) -> Self {
857 self.context_window = context_window;
858 self.context_tokens = context_tokens.max(1);
859 self
860 }
861
862 fn matching_skill_names(&self) -> Vec<&str> {
865 matching_skill_names(&self.input, &self.skill_names)
866 }
867
868 fn reset_skill_picker(&mut self) {
869 self.skill_picker_focus = 0;
870 self.skill_picker_suppressed = false;
871 }
872
873 fn skill_picker_visible(&self) -> bool {
874 !self.skill_picker_suppressed && !self.matching_skill_names().is_empty()
875 }
876
877 fn set_status(&mut self, status: impl Into<String>) {
878 let status = status.into();
879 if self.status == status {
880 return;
881 }
882
883 let now = Instant::now();
884 let current_levels = self.activity_levels_at(now);
885 let current_elapsed = self.working_elapsed_at(now);
886 if matches!(self.status.as_str(), "working" | "compacting") {
887 self.last_active_levels = current_levels;
888 self.last_active_elapsed = current_elapsed;
889 }
890
891 match status.as_str() {
892 "working" if !matches!(self.status.as_str(), "working" | "compacting") => {
893 self.activity_started_at = now;
897 self.activity_transition = Some(ActivityTransition {
898 started_at: now,
899 from_levels: current_levels,
900 to_levels: pulse_levels_at(PULSE_ENTRY_FRAME),
901 });
902 }
903 "ready" if self.status != "ready" => {
904 let from_levels = if matches!(self.status.as_str(), "working" | "compacting") {
908 current_levels
909 } else {
910 self.last_active_levels
911 };
912 self.activity_transition = Some(ActivityTransition {
913 started_at: now,
914 from_levels,
915 to_levels: [0; PULSE_BAR_PERIODS.len()],
916 });
917 }
918 _ => {}
919 }
920 self.status = status;
921 }
922
923 fn activity_levels_at(&self, now: Instant) -> [usize; PULSE_BAR_PERIODS.len()] {
924 if let Some(transition) = &self.activity_transition {
925 let elapsed = now.saturating_duration_since(transition.started_at);
926 if elapsed < ACTIVITY_TRANSITION_DURATION {
927 return interpolate_pulse_levels(
928 transition.from_levels,
929 transition.to_levels,
930 elapsed,
931 );
932 }
933 }
934
935 match self.status.as_str() {
936 "working" | "compacting" => pulse_levels_at(self.working_elapsed_at(now)),
937 _ => [0; PULSE_BAR_PERIODS.len()],
938 }
939 }
940
941 fn working_elapsed_at(&self, now: Instant) -> Duration {
942 let elapsed = now.saturating_duration_since(self.activity_started_at);
943 if self.status == "working" && self.activity_transition.is_some() {
944 PULSE_ENTRY_FRAME
945 .checked_add(elapsed.saturating_sub(ACTIVITY_TRANSITION_DURATION))
946 .unwrap_or(PULSE_ENTRY_FRAME)
947 } else {
948 elapsed
949 }
950 }
951
952 fn input_changed(&mut self) {
953 self.reset_skill_picker();
954 }
955
956 fn move_skill_picker(&mut self, down: bool) -> bool {
960 let match_count = self.matching_skill_names().len();
961 if self.skill_picker_suppressed || match_count == 0 {
962 return false;
963 }
964 if down {
965 self.skill_picker_focus = (self.skill_picker_focus + 1).min(match_count - 1);
966 } else {
967 self.skill_picker_focus = self.skill_picker_focus.saturating_sub(1);
968 }
969 true
970 }
971
972 fn focused_builtin_command(&self) -> Option<BuiltinCommand> {
978 let name = *self.matching_skill_names().get(self.skill_picker_focus)?;
979 builtin_command(&format!("/{name}"))
980 }
981
982 fn select_focused_skill(&mut self) -> bool {
983 if self.skill_picker_suppressed {
984 return false;
985 }
986 let Some(name) = self
987 .matching_skill_names()
988 .get(self.skill_picker_focus)
989 .map(|name| (*name).to_owned())
990 else {
991 return false;
992 };
993 self.input = format!("/{name}");
994 self.cursor = self.input.chars().count();
995 self.skill_picker_suppressed = true;
998 true
999 }
1000
1001 fn open_catalog(&mut self, result: Result<Vec<ProviderModel>, String>) {
1002 self.settings = Some(match result {
1003 Ok(models) => {
1004 let focus = models
1005 .iter()
1006 .position(|model| model.id == self.model)
1007 .unwrap_or(0);
1008 SettingsState::Models {
1009 models,
1010 query: String::new(),
1011 focus,
1012 }
1013 }
1014 Err(error) => SettingsState::Error(error),
1015 });
1016 }
1017 fn settings_applied(
1018 &mut self,
1019 result: Result<(), String>,
1020 model: String,
1021 effort: Option<String>,
1022 context_window: Option<usize>,
1023 ) {
1024 match result {
1025 Ok(()) => {
1026 self.model = model;
1027 self.effort = effort;
1028 self.context_window = context_window;
1029 self.settings = None;
1030 self.transcript
1031 .push(TranscriptItem::Info("⚙ settings applied".to_owned()));
1032 }
1033 Err(error) => self.settings = Some(SettingsState::Error(error)),
1034 }
1035 }
1036 fn handle_settings_key(&mut self, key: &KeyEvent) -> Option<(String, Option<String>)> {
1037 let current_effort = self.effort.clone();
1038 match self.settings.as_mut()? {
1039 SettingsState::Loading => {
1040 if key.code == KeyCode::Esc {
1041 self.settings = None;
1042 }
1043 }
1044 SettingsState::Applying { .. } => {}
1045 SettingsState::Error(_) => {
1046 if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
1047 self.settings = None;
1048 }
1049 }
1050 SettingsState::Models {
1051 models,
1052 query,
1053 focus,
1054 } => match key.code {
1055 KeyCode::Esc => self.settings = None,
1056 KeyCode::Char(c) => {
1057 query.push(c);
1058 *focus = 0;
1059 }
1060 KeyCode::Backspace => {
1061 query.pop();
1062 *focus = 0;
1063 }
1064 KeyCode::Up => *focus = focus.saturating_sub(1),
1065 KeyCode::Down => {
1066 let n = models
1067 .iter()
1068 .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
1069 .count();
1070 *focus = (*focus + 1).min(n.saturating_sub(1));
1071 }
1072 KeyCode::Enter => {
1073 let selected = models
1074 .iter()
1075 .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
1076 .nth(*focus)
1077 .cloned();
1078 if let Some(model) = selected {
1079 let focus = model
1080 .efforts
1081 .as_ref()
1082 .and_then(|efforts| {
1083 current_effort.as_ref().and_then(|current| {
1084 efforts.iter().position(|effort| effort == current)
1085 })
1086 })
1087 .map_or(0, |index| index + 1);
1088 self.settings = Some(SettingsState::Effort {
1089 model,
1090 input: current_effort.unwrap_or_default(),
1091 focus,
1092 });
1093 }
1094 }
1095 _ => {}
1096 },
1097 SettingsState::Effort {
1098 model,
1099 input,
1100 focus,
1101 } => match key.code {
1102 KeyCode::Esc => self.settings = None,
1103 KeyCode::Char(c) if model.efforts.is_none() => input.push(c),
1104 KeyCode::Backspace if model.efforts.is_none() => {
1105 input.pop();
1106 }
1107 KeyCode::Up => *focus = focus.saturating_sub(1),
1108 KeyCode::Down => {
1109 if let Some(efforts) = &model.efforts {
1110 *focus = (*focus + 1).min(efforts.len());
1111 }
1112 }
1113 KeyCode::Enter => {
1114 let effort = match &model.efforts {
1115 Some(efforts) => {
1116 if *focus == 0 {
1117 None
1118 } else {
1119 efforts.get(focus.saturating_sub(1)).cloned()
1120 }
1121 }
1122 None => (!input.trim().is_empty()).then(|| input.trim().to_owned()),
1123 };
1124 return Some((model.id.clone(), effort));
1125 }
1126 _ => {}
1127 },
1128 };
1129 None
1130 }
1131
1132 fn add_history_record(&mut self, record: &SessionHistoryRecord) {
1133 match record {
1134 SessionHistoryRecord::ProviderSettings { model, effort, .. } => {
1135 self.transcript.push(TranscriptItem::Info(format!(
1136 "⚙ {model} ({})",
1137 effort.as_deref().unwrap_or("default")
1138 )))
1139 }
1140 SessionHistoryRecord::Message { message, .. } => self.add_message(message),
1141 SessionHistoryRecord::Interruption {
1142 assistant_text,
1143 tool_calls,
1144 tool_results,
1145 reason,
1146 phase,
1147 ..
1148 } => {
1149 if !assistant_text.is_empty() {
1150 self.add_assistant_message(assistant_text);
1151 }
1152 for call in tool_calls {
1153 self.add_tool_call(call);
1154 }
1155 for observation in tool_results {
1156 self.add_tool_result(
1157 &observation.id,
1158 &observation.name,
1159 observation.result.clone(),
1160 );
1161 }
1162 self.transcript
1163 .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1164 }
1165 SessionHistoryRecord::Compaction(compaction) => {
1166 self.transcript.push(TranscriptItem::Info(format!(
1167 "↻ context compacted ({} before)",
1168 format_context_tokens(compaction.tokens_before)
1169 )));
1170 }
1171 }
1172 }
1173
1174 fn add_message(&mut self, message: &ChatMessage) {
1175 match message.role.as_str() {
1176 "user" => {
1177 let text = message.content.as_deref().unwrap_or("");
1178 if let Some((task_id, result)) = parse_subagent_notification(text) {
1179 self.complete_subagent(&task_id, result);
1180 } else {
1181 let secret = self.secret.clone();
1182 self.add_user(text, &secret);
1183 }
1184 }
1185 "assistant" => {
1186 if let Some(content) = message.content.as_deref() {
1187 self.add_assistant_message(content);
1188 }
1189 for call in &message.tool_calls {
1190 self.add_tool_call(call);
1191 }
1192 }
1193 "tool" => {
1194 let result = message
1195 .content
1196 .as_deref()
1197 .and_then(|content| serde_json::from_str::<Value>(content).ok())
1198 .unwrap_or_else(|| Value::String(message.content.clone().unwrap_or_default()));
1199 self.add_tool_result(
1200 message.tool_call_id.as_deref().unwrap_or(""),
1201 message.name.as_deref().unwrap_or("cmd"),
1202 result,
1203 );
1204 }
1205 _ => {}
1206 }
1207 }
1208
1209 fn submit_user(&mut self, text: &str) {
1212 if self.busy {
1213 self.queue_user(text);
1214 } else {
1215 self.add_user(text, &self.secret.clone());
1216 }
1217 }
1218
1219 fn queue_user(&mut self, text: &str) {
1220 self.queued_messages
1221 .push(redact_secret(text, Some(&self.secret)));
1222 }
1223
1224 fn start_queued_user(&mut self, text: &str) {
1225 let safe = redact_secret(text, Some(&self.secret));
1226 let queued = if self.queued_messages.first() == Some(&safe) {
1227 self.queued_messages.remove(0);
1228 true
1229 } else if let Some(index) = self
1230 .queued_messages
1231 .iter()
1232 .position(|queued| queued == &safe)
1233 {
1234 self.queued_messages.remove(index);
1235 true
1236 } else {
1237 false
1238 };
1239 if queued {
1240 self.add_user(text, &self.secret.clone());
1241 }
1242 }
1243
1244 fn add_user(&mut self, text: &str, secret: &str) {
1245 self.welcome_visible = false;
1246 self.transcript.push(TranscriptItem::User {
1247 text: redact_secret(text, Some(secret)),
1248 skill_instruction_attached: false,
1249 });
1250 }
1251
1252 fn mark_latest_user_skill_attached(&mut self) {
1253 if let Some(TranscriptItem::User {
1254 skill_instruction_attached,
1255 ..
1256 }) = self.transcript.last_mut()
1257 {
1258 *skill_instruction_attached = true;
1259 }
1260 }
1261
1262 fn clear_thinking(&mut self) {
1263 if matches!(
1264 self.transcript.last(),
1265 Some(TranscriptItem::Reasoning { complete: false })
1266 ) {
1267 self.transcript.pop();
1268 }
1269 }
1270
1271 fn show_thinking(&mut self) {
1272 self.set_status("working");
1273 if !matches!(
1274 self.transcript.last(),
1275 Some(TranscriptItem::Reasoning { complete: false })
1276 ) {
1277 self.transcript
1278 .push(TranscriptItem::Reasoning { complete: false });
1279 }
1280 }
1281
1282 fn complete_reasoning(&mut self) {
1283 if let Some(TranscriptItem::Reasoning { complete }) = self.transcript.last_mut() {
1284 *complete = true;
1285 }
1286 }
1287
1288 fn add_assistant(&mut self, text: &str) {
1289 self.clear_thinking();
1290 if let Some(TranscriptItem::Assistant(current)) = self.transcript.last_mut() {
1291 current.push_str(text);
1292 } else {
1293 self.add_assistant_message(text);
1294 }
1295 }
1296
1297 fn add_assistant_message(&mut self, text: &str) {
1298 self.transcript
1299 .push(TranscriptItem::Assistant(text.to_owned()));
1300 }
1301
1302 fn add_tool_call(&mut self, call: &crate::model::ChatToolCall) {
1303 self.clear_thinking();
1304 self.transcript.push(TranscriptItem::ToolCall {
1305 id: call.id.clone(),
1306 name: call.name.clone(),
1307 arguments: call.arguments.clone(),
1308 });
1309 if call.name == "spawn_subagent" {
1310 self.register_subagent_call(&call.id, &call.arguments);
1311 }
1312 }
1313
1314 fn add_tool_result(&mut self, id: &str, name: &str, result: Value) {
1315 self.record_tool_result(id, name, result, false);
1316 }
1317
1318 fn add_live_tool_result(&mut self, id: &str, name: &str, result: Value) {
1319 self.record_tool_result(id, name, result, true);
1320 }
1321
1322 fn record_tool_result(&mut self, id: &str, name: &str, result: Value, animate: bool) {
1323 if name == "spawn_subagent" {
1324 self.update_subagent_queued(id, &result);
1325 }
1326 if animate && name == "cmd" && cmd_result_succeeded(&result) {
1327 self.cmd_success_started_at
1328 .insert(id.to_owned(), Instant::now());
1329 }
1330 self.transcript.push(TranscriptItem::ToolResult {
1331 id: id.to_owned(),
1332 name: name.to_owned(),
1333 result,
1334 });
1335 }
1336
1337 fn register_subagent_call(&mut self, call_id: &str, arguments: &str) {
1338 if self.subagents.iter().any(|task| task.call_id == call_id) {
1339 return;
1340 }
1341 self.completed_subagent_calls.remove(call_id);
1342 let parsed = serde_json::from_str::<Value>(arguments).ok();
1343 let task = parsed
1344 .as_ref()
1345 .and_then(|value| value.get("task"))
1346 .and_then(Value::as_str)
1347 .map(|task| redact_secret(task.trim(), Some(&self.secret)))
1348 .filter(|task| !task.is_empty())
1349 .unwrap_or_else(|| "invalid task".to_owned());
1350 let model = parsed
1351 .as_ref()
1352 .and_then(|value| value.get("model"))
1353 .and_then(Value::as_str)
1354 .map(|value| redact_secret(value, Some(&self.secret)));
1355 let effort = parsed
1356 .as_ref()
1357 .and_then(|value| value.get("effort"))
1358 .and_then(Value::as_str)
1359 .map(|value| redact_secret(value, Some(&self.secret)));
1360 self.subagents.push(SubagentTask {
1361 call_id: call_id.to_owned(),
1362 task_id: None,
1363 task,
1364 model,
1365 effort,
1366 status: SubagentStatus::Queued,
1367 result: None,
1368 });
1369 }
1370
1371 fn update_subagent_queued(&mut self, call_id: &str, result: &Value) {
1372 let Some(task) = self
1373 .subagents
1374 .iter_mut()
1375 .find(|task| task.call_id == call_id)
1376 else {
1377 return;
1378 };
1379 task.task_id = result
1380 .get("task_id")
1381 .and_then(Value::as_str)
1382 .map(str::to_owned);
1383 task.status = if result.get("error").is_some() {
1384 SubagentStatus::Failed
1385 } else {
1386 SubagentStatus::Running
1390 };
1391 task.result = Some(result.clone());
1392 }
1393
1394 fn complete_subagent(&mut self, task_id: &str, _result: Value) {
1395 if let Some(task) = self
1400 .subagents
1401 .iter()
1402 .find(|task| task.task_id.as_deref() == Some(task_id))
1403 {
1404 self.completed_subagent_calls.insert(task.call_id.clone());
1405 }
1406 self.subagents
1407 .retain(|task| task.task_id.as_deref() != Some(task_id));
1408 }
1409
1410 fn apply_event(&mut self, event: ProtocolEvent) {
1411 match event {
1412 ProtocolEvent::Session { .. } => {}
1413 ProtocolEvent::AssistantDelta { text } => self.add_assistant(&text),
1414 ProtocolEvent::ToolCall {
1415 id,
1416 name,
1417 arguments,
1418 } => self.add_tool_call(&crate::model::ChatToolCall {
1419 id,
1420 name,
1421 arguments,
1422 }),
1423 ProtocolEvent::ToolResult { id, name, result } => {
1424 self.add_live_tool_result(&id, &name, result)
1425 }
1426 ProtocolEvent::TurnEnd => {
1427 self.complete_reasoning();
1428 self.set_status("finalizing");
1429 self.transcript
1430 .push(TranscriptItem::Info("✓ turn complete".to_owned()));
1431 }
1432 ProtocolEvent::TurnInterrupted { reason, phase } => {
1433 self.complete_reasoning();
1434 self.set_status("cancelling");
1435 self.transcript
1436 .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1437 }
1438 ProtocolEvent::Error { message } => {
1439 self.complete_reasoning();
1440 self.set_status("error");
1441 self.transcript.push(TranscriptItem::Error(message));
1442 }
1443 }
1444 }
1445}
1446
1447fn parse_subagent_notification(text: &str) -> Option<(String, Value)> {
1448 let prefix = "Background subagent ";
1449 let suffix = " completed. Deliver this result to the user and continue the task: ";
1450 let rest = text.strip_prefix(prefix)?;
1451 let (task_id, encoded_result) = rest.split_once(suffix)?;
1452 if task_id.is_empty() {
1453 return None;
1454 }
1455 Some((
1456 task_id.to_owned(),
1457 serde_json::from_str(encoded_result).ok()?,
1458 ))
1459}
1460
1461#[derive(Debug, Clone, PartialEq)]
1462enum TranscriptItem {
1463 User {
1464 text: String,
1465 skill_instruction_attached: bool,
1466 },
1467 Assistant(String),
1468 ToolCall {
1469 id: String,
1470 name: String,
1471 arguments: String,
1472 },
1473 ToolResult {
1474 id: String,
1475 name: String,
1476 result: Value,
1477 },
1478 Error(String),
1479 Info(String),
1480 Reasoning {
1481 complete: bool,
1482 },
1483}
1484
1485#[cfg(test)]
1486fn activity_text(state: &UiState) -> String {
1487 activity_text_at(state, Instant::now())
1488}
1489
1490fn activity_text_at(state: &UiState, now: Instant) -> String {
1491 match state.status.as_str() {
1492 "ready" | "working" | "compacting" => pulse_frame(state.activity_levels_at(now)),
1495 _ => format!("● {}", state.status),
1496 }
1497}
1498
1499fn tui_viewport(area: Rect) -> Rect {
1503 if area.width > 2 {
1504 Rect::new(area.x + 1, area.y, area.width - 2, area.height)
1505 } else {
1506 area
1507 }
1508}
1509
1510fn ui_layout(
1511 state: &UiState,
1512 area: Rect,
1513) -> (Rect, Option<Rect>, Option<Rect>, Option<Rect>, Rect, Rect) {
1514 let input_rows = input_visible_rows(state, area.width.saturating_sub(2));
1515 let queue_height = message_queue_height(state);
1516 let input_height = input_rows.clamp(1, MAX_INPUT_ROWS) + 2;
1517 let chunks = Layout::default()
1518 .direction(Direction::Vertical)
1519 .constraints([
1520 Constraint::Min(1),
1521 Constraint::Length(queue_height),
1522 Constraint::Length(input_height),
1523 Constraint::Length(1),
1524 ])
1525 .split(area);
1526 let picker_height = skill_picker_height(state);
1527 let picker_area = (picker_height > 0).then(|| {
1528 Rect::new(
1531 chunks[1].x,
1532 chunks[1].y.saturating_sub(picker_height),
1533 chunks[1].width,
1534 picker_height,
1535 )
1536 });
1537 let overlay_area = subagent_overlay_area(state, chunks[0]);
1538 let input_area = chunks[2];
1539 let queue_area = (queue_height > 0).then(|| {
1542 Rect::new(
1543 input_area.x.saturating_add(1),
1544 chunks[1].y,
1545 input_area.width.saturating_sub(2),
1546 chunks[1].height,
1547 )
1548 });
1549 (
1550 chunks[0],
1551 picker_area,
1552 overlay_area,
1553 queue_area,
1554 input_area,
1555 chunks[3],
1556 )
1557}
1558
1559fn subagent_overlay_area(state: &UiState, area: Rect) -> Option<Rect> {
1563 if state.subagents.is_empty() || area.is_empty() {
1564 return None;
1565 }
1566 let width = area.width / 3;
1567 if width == 0 {
1568 return None;
1569 }
1570 let height = (state.subagents.len().saturating_add(2))
1571 .min(area.height as usize)
1572 .min(u16::MAX as usize) as u16;
1573 (height > 0).then(|| {
1574 Rect::new(
1575 area.x + area.width.saturating_sub(width),
1576 area.y,
1577 width,
1578 height,
1579 )
1580 })
1581}
1582
1583fn message_queue_height(state: &UiState) -> u16 {
1584 state.queued_messages.len().min(u16::MAX as usize) as u16
1585}
1586
1587fn max_scroll_for_area(state: &UiState, size: Size) -> u16 {
1588 let area = tui_viewport(Rect::new(0, 0, size.width, size.height));
1589 let (chat_chunk, _, _, _, _, _) = ui_layout(state, area);
1590 let chat_height = chat_chunk.height;
1591 let lines = transcript_lines(state, chat_chunk.width);
1592 lines
1593 .len()
1594 .saturating_sub(chat_height as usize)
1595 .min(u16::MAX as usize) as u16
1596}
1597
1598fn input_visible_rows(state: &UiState, width: u16) -> u16 {
1600 let width = width as usize;
1601 if width == 0 {
1602 return 1;
1603 }
1604 let prompt = input_display_text(state);
1605 let wrapped = wrap_text(&prompt, width);
1606 wrapped.len().max(1) as u16
1607}
1608
1609fn input_prompt(input: &str) -> String {
1610 input.to_owned()
1611}
1612
1613fn input_display_text(state: &UiState) -> String {
1614 redact_secret(&input_prompt(&state.input), Some(&state.secret))
1615}
1616
1617fn command_names(mut skill_names: Vec<String>) -> Vec<String> {
1618 skill_names.extend(BUILTIN_COMMANDS.into_iter().map(str::to_owned));
1619 skill_names.sort();
1620 skill_names.dedup();
1621 skill_names
1622}
1623
1624#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1625enum BuiltinCommand {
1626 Settings,
1627 Exit,
1628}
1629
1630impl BuiltinCommand {
1631 fn name(self) -> &'static str {
1632 match self {
1633 Self::Settings => "settings",
1634 Self::Exit => "exit",
1635 }
1636 }
1637}
1638
1639fn builtin_command(input: &str) -> Option<BuiltinCommand> {
1640 match input.split_whitespace().next()? {
1641 "/settings" => Some(BuiltinCommand::Settings),
1642 "/exit" => Some(BuiltinCommand::Exit),
1643 _ => None,
1644 }
1645}
1646
1647fn matching_skill_names<'a>(input: &str, skill_names: &'a [String]) -> Vec<&'a str> {
1650 let Some(query) = input.strip_prefix('/') else {
1651 return Vec::new();
1652 };
1653 if query.chars().any(char::is_whitespace) {
1654 return Vec::new();
1655 }
1656 skill_names
1657 .iter()
1658 .map(String::as_str)
1659 .filter(|name| name.starts_with(query))
1660 .collect()
1661}
1662
1663fn skill_picker_height(state: &UiState) -> u16 {
1664 if state.skill_picker_visible() {
1665 (state
1667 .matching_skill_names()
1668 .len()
1669 .min(SKILL_PICKER_MAX_ROWS)
1670 + 3) as u16
1671 } else {
1672 0
1673 }
1674}
1675
1676fn skill_picker_range(total: usize, focus: usize) -> std::ops::Range<usize> {
1677 let start = focus
1678 .saturating_add(1)
1679 .saturating_sub(SKILL_PICKER_MAX_ROWS);
1680 start.min(total)..(start + SKILL_PICKER_MAX_ROWS).min(total)
1681}
1682
1683fn active_skill_trigger<'a>(input: &'a str, skill_names: &[String]) -> Option<&'a str> {
1687 let invocation = input.strip_prefix('/')?;
1688 let name = invocation
1689 .split_once(char::is_whitespace)
1690 .map_or(invocation, |(name, _)| name);
1691 if name.is_empty() || !skill_names.iter().any(|skill_name| skill_name == name) {
1692 return None;
1693 }
1694 Some(&input[..1 + name.len()])
1695}
1696
1697fn styled_text_lines(
1700 input: &str,
1701 active_skill_trigger: Option<&str>,
1702 width: usize,
1703 text_style: Style,
1704) -> Vec<Line<'static>> {
1705 let trigger_len = active_skill_trigger.map_or(0, |trigger| trigger.chars().count());
1706 let mut char_offset = 0usize;
1707 let mut lines = Vec::new();
1708
1709 for source_line in input.split('\n') {
1710 for row in wrap_line(source_line, width) {
1711 let mut spans = Vec::new();
1712 let mut text = String::new();
1713 let mut highlighted = None;
1714 for character in row.chars() {
1715 let should_highlight = char_offset < trigger_len;
1716 if highlighted != Some(should_highlight) && !text.is_empty() {
1717 spans.push(styled_text_span(
1718 std::mem::take(&mut text),
1719 highlighted.unwrap_or(false),
1720 text_style,
1721 ));
1722 }
1723 highlighted = Some(should_highlight);
1724 text.push(character);
1725 char_offset += 1;
1726 }
1727 if !text.is_empty() {
1728 spans.push(styled_text_span(
1729 text,
1730 highlighted.unwrap_or(false),
1731 text_style,
1732 ));
1733 }
1734 if spans.is_empty() {
1735 spans.push(Span::styled(String::new(), text_style));
1736 }
1737 lines.push(Line::from(spans));
1738 }
1739 char_offset += 1;
1742 }
1743
1744 lines
1745}
1746
1747fn styled_text_span(text: String, highlighted: bool, text_style: Style) -> Span<'static> {
1748 if highlighted {
1749 Span::styled(text, Style::default().fg(Color::Cyan))
1750 } else {
1751 Span::styled(text, text_style)
1752 }
1753}
1754
1755fn cursor_row(input: &str, cursor: usize, width: usize) -> u16 {
1756 let prefix: String = input.chars().take(cursor).collect();
1757 wrap_text(&prefix, width)
1758 .len()
1759 .saturating_sub(1)
1760 .min(u16::MAX as usize) as u16
1761}
1762
1763fn insert_at_cursor(input: &mut String, cursor: &mut usize, character: char) {
1764 let byte_index = input
1765 .char_indices()
1766 .nth(*cursor)
1767 .map_or(input.len(), |(index, _)| index);
1768 input.insert(byte_index, character);
1769 *cursor += 1;
1770}
1771
1772fn remove_before_cursor(input: &mut String, cursor: &mut usize) -> bool {
1773 if *cursor == 0 {
1774 return false;
1775 }
1776 let end = input
1777 .char_indices()
1778 .nth(*cursor)
1779 .map_or(input.len(), |(index, _)| index);
1780 let start = input
1781 .char_indices()
1782 .nth(*cursor - 1)
1783 .map(|(index, _)| index)
1784 .unwrap_or(0);
1785 input.replace_range(start..end, "");
1786 *cursor -= 1;
1787 true
1788}
1789
1790fn draw(frame: &mut Frame<'_>, state: &UiState) {
1791 let full_area = frame.area();
1792 frame.render_widget(Clear, full_area);
1795 let area = tui_viewport(full_area);
1796 let (chat_chunk, picker_area, overlay_area, queue_area, input_chunk, status_area) =
1797 ui_layout(state, area);
1798
1799 let visible_chat_area = chat_chunk;
1802
1803 let width = chat_chunk.width;
1804 if state.welcome_visible {
1805 let welcome_lines = welcome_lines(&state.attached_agents);
1806 let welcome_height = (welcome_lines.len() as u16).min(visible_chat_area.height);
1807 let welcome_area = Rect::new(
1808 visible_chat_area.x,
1809 visible_chat_area.y + visible_chat_area.height.saturating_sub(welcome_height) / 2,
1810 visible_chat_area.width,
1811 welcome_height,
1812 );
1813 let welcome = Paragraph::new(welcome_lines).alignment(Alignment::Center);
1814 frame.render_widget(welcome, welcome_area);
1815 } else {
1816 let lines = transcript_lines(state, width);
1817 let available = visible_chat_area.height as usize;
1818 let max_scroll = lines.len().saturating_sub(available).min(u16::MAX as usize) as u16;
1819 let scroll = if state.auto_scroll {
1820 max_scroll
1821 } else {
1822 state.scroll.min(max_scroll)
1823 };
1824 let transcript = Paragraph::new(lines).scroll((scroll, 0));
1825 frame.render_widget(transcript, visible_chat_area);
1826 }
1827
1828 let activity_now = Instant::now();
1829 let activity_text = activity_text_at(state, activity_now);
1830 let activity_elapsed = state.working_elapsed_at(activity_now);
1831 let activity = activity_line_at(state, &activity_text, activity_elapsed, activity_now);
1832 if let Some(picker_area) = picker_area {
1833 draw_skill_picker(frame, state, picker_area);
1834 }
1835
1836 let input_text_style = Style::default().fg(Color::White);
1837 let input_block = Block::default()
1838 .borders(Borders::ALL)
1839 .border_type(ratatui::widgets::BorderType::Plain);
1840 let prompt_area = input_block.inner(input_chunk);
1841 let prompt = input_display_text(state);
1842 let input_rows =
1843 input_visible_rows(state, input_chunk.width.saturating_sub(2)).clamp(1, MAX_INPUT_ROWS);
1844 let wrapped = wrap_text(&prompt, prompt_area.width.max(1) as usize);
1845 let visible = (wrapped.len() as u16).clamp(1, input_rows);
1846 let cursor_row = cursor_row(&prompt, state.cursor, prompt_area.width.max(1) as usize);
1847 let bottom_scroll = (wrapped.len() as u16).saturating_sub(visible);
1848 let cursor_scroll = (cursor_row + 1).saturating_sub(visible);
1849 let input_scroll = bottom_scroll.min(cursor_scroll);
1850 let active_skill_trigger = (!state.busy)
1851 .then(|| active_skill_trigger(&prompt, &state.skill_names))
1852 .flatten();
1853 let input_lines = styled_text_lines(
1854 &prompt,
1855 active_skill_trigger,
1856 prompt_area.width.max(1) as usize,
1857 input_text_style,
1858 );
1859 frame.render_widget(input_block, input_chunk);
1860 if let Some(queue_area) = queue_area {
1861 draw_message_queue(frame, state, queue_area);
1862 }
1863 let input = Paragraph::new(input_lines)
1864 .style(input_text_style)
1865 .scroll((input_scroll, 0));
1866 frame.render_widget(input, prompt_area);
1867 draw_prompt_border_gradient(frame, input_chunk);
1868
1869 let effort = state.effort.as_deref().unwrap_or("default");
1870 let status_text = format!("{} ({effort})", state.model);
1871 let status = Paragraph::new(redact_secret(&status_text, Some(&state.secret)))
1872 .style(Style::default().fg(Color::DarkGray));
1873 frame.render_widget(status, status_area);
1874
1875 let context_text = context_status_text(state);
1876 let context_width = UnicodeWidthStr::width(context_text.as_str()) as u16;
1877 let context_area_width = context_width.min(status_area.width);
1878 if context_area_width > 0 {
1879 let context_area = Rect::new(
1880 status_area
1881 .x
1882 .saturating_add(status_area.width.saturating_sub(context_area_width)),
1883 status_area.y,
1884 context_area_width,
1885 status_area.height,
1886 );
1887 let context = Paragraph::new(context_text)
1888 .alignment(Alignment::Right)
1889 .style(context_status_style(state));
1890 frame.render_widget(context, context_area);
1891 }
1892
1893 frame.render_widget(
1896 Paragraph::new(activity).alignment(Alignment::Center),
1897 status_area,
1898 );
1899
1900 if let Some(overlay_area) = overlay_area {
1904 draw_subagent_overlay(frame, state, overlay_area);
1905 }
1906 if let Some(settings) = &state.settings {
1907 draw_settings(frame, settings, area);
1908 }
1909
1910 if state.settings.is_none() && !prompt_area.is_empty() && visible > 0 {
1915 let cursor_prefix: String = prompt.chars().take(state.cursor).collect();
1916 let cursor_rows = wrap_text(&cursor_prefix, prompt_area.width.max(1) as usize);
1917 let cursor_line = cursor_rows.last().map(String::as_str).unwrap_or("");
1918 let cursor_offset = UnicodeWidthStr::width(cursor_line) as u16;
1919 let cursor_x = prompt_area.x + cursor_offset.min(prompt_area.width.saturating_sub(1));
1920 let cursor_y = prompt_area.y + cursor_row.saturating_sub(input_scroll);
1921 frame.set_cursor_position((cursor_x, cursor_y));
1922 }
1923}
1924
1925fn draw_message_queue(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
1926 if area.is_empty() {
1927 return;
1928 }
1929
1930 let total = state.queued_messages.len();
1931 let lines = state
1932 .queued_messages
1933 .iter()
1934 .map(|message| Line::raw(format!("Queued {total}: {}", single_line_preview(message))))
1935 .collect::<Vec<_>>();
1936 frame.render_widget(Clear, area);
1937 frame.render_widget(
1938 Paragraph::new(lines).style(
1939 Style::default()
1940 .fg(QUEUED_MESSAGE_COLOR)
1941 .bg(QUEUED_MESSAGE_BACKGROUND),
1942 ),
1943 area,
1944 );
1945}
1946
1947fn draw_subagent_overlay(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
1948 if area.is_empty() {
1949 return;
1950 }
1951
1952 let overlay_style = Style::default()
1953 .fg(SUBAGENT_OVERLAY_COLOR)
1954 .bg(SUBAGENT_OVERLAY_BACKGROUND);
1955 let block = Block::default()
1956 .title("Subagents")
1957 .title_alignment(Alignment::Right)
1958 .borders(Borders::ALL)
1959 .style(overlay_style)
1960 .border_style(overlay_style);
1961 let inner = block.inner(area);
1962 let lines = state
1963 .subagents
1964 .iter()
1965 .map(|task| {
1966 let indicator = subagent_status_indicator(task.status, state);
1967 let task_text = redact_secret(&single_line_preview(&task.task), Some(&state.secret));
1968 Line::styled(format!("{indicator} {task_text}"), overlay_style)
1969 })
1970 .collect::<Vec<_>>();
1971
1972 frame.render_widget(Clear, area);
1975 frame.render_widget(block, area);
1976 frame.render_widget(Paragraph::new(lines).style(overlay_style), inner);
1977}
1978
1979fn subagent_status_indicator(status: SubagentStatus, state: &UiState) -> String {
1980 match status {
1981 SubagentStatus::Queued => "○".to_owned(),
1982 SubagentStatus::Running => tool_spinner_frame(state),
1983 SubagentStatus::Failed => "×".to_owned(),
1984 }
1985}
1986
1987fn single_line_preview(text: &str) -> String {
1988 truncate_output(&text.replace(['\n', '\r'], " ↵ "))
1989}
1990
1991fn subagent_status_label(status: SubagentStatus) -> &'static str {
1992 match status {
1993 SubagentStatus::Queued => "queued",
1994 SubagentStatus::Running => "running",
1995 SubagentStatus::Failed => "error",
1996 }
1997}
1998
1999fn subagent_status_display(status: SubagentStatus, state: &UiState) -> String {
2000 let label = subagent_status_label(status);
2001 if status == SubagentStatus::Running {
2002 format!("{label} {}", tool_spinner_frame(state))
2003 } else {
2004 label.to_owned()
2005 }
2006}
2007
2008fn subagent_status_style(status: SubagentStatus) -> Style {
2009 match status {
2010 SubagentStatus::Queued => Style::default().fg(PENDING_TOOL_COLOR),
2011 SubagentStatus::Running => Style::default().fg(Color::Cyan),
2012 SubagentStatus::Failed => Style::default().fg(Color::Red),
2013 }
2014}
2015
2016enum SettingsState {
2017 Loading,
2018 Applying {
2019 model: String,
2020 effort: Option<String>,
2021 },
2022 Error(String),
2023 Models {
2024 models: Vec<ProviderModel>,
2025 query: String,
2026 focus: usize,
2027 },
2028 Effort {
2029 model: ProviderModel,
2030 input: String,
2031 focus: usize,
2032 },
2033}
2034fn draw_settings(frame: &mut Frame<'_>, settings: &SettingsState, area: Rect) {
2035 let width = area
2036 .width
2037 .saturating_sub(2)
2038 .min(SETTINGS_MAX_WIDTH)
2039 .max(SETTINGS_MIN_WIDTH.min(area.width));
2040 let height = area
2041 .height
2042 .saturating_sub(2)
2043 .min(SETTINGS_MAX_HEIGHT)
2044 .max(SETTINGS_MIN_HEIGHT.min(area.height));
2045 let popup = Rect::new(
2046 area.x + area.width.saturating_sub(width) / 2,
2047 area.y + area.height.saturating_sub(height) / 2,
2048 width,
2049 height,
2050 );
2051 frame.render_widget(Clear, popup);
2052 let block = Block::default()
2053 .title(" /settings ")
2054 .borders(Borders::ALL)
2055 .border_style(Style::default().fg(Color::Cyan));
2056 let inner = block.inner(popup);
2057 frame.render_widget(block, popup);
2058
2059 let lines = match settings {
2060 SettingsState::Loading => vec![
2061 Line::styled("Loading provider models…", Style::default().fg(Color::Cyan)),
2062 Line::raw(""),
2063 Line::styled("Esc cancel", Style::default().fg(Color::DarkGray)),
2064 ],
2065 SettingsState::Applying { model, effort } => vec![
2066 Line::styled("Applying selection…", Style::default().fg(Color::Cyan)),
2067 Line::raw(model.clone()),
2068 Line::raw(format!(
2069 "effort: {}",
2070 effort.as_deref().unwrap_or("default")
2071 )),
2072 ],
2073 SettingsState::Error(error) => vec![
2074 Line::styled("Unable to update settings", Style::default().fg(Color::Red)),
2075 Line::raw(""),
2076 Line::raw(error.clone()),
2077 Line::raw(""),
2078 Line::styled("Enter/Esc close", Style::default().fg(Color::DarkGray)),
2079 ],
2080 SettingsState::Models {
2081 models,
2082 query,
2083 focus,
2084 } => {
2085 let query_lower = query.to_lowercase();
2086 let filtered = models
2087 .iter()
2088 .filter(|model| model.id.to_lowercase().contains(&query_lower))
2089 .collect::<Vec<_>>();
2090 let focus = (*focus).min(filtered.len().saturating_sub(1));
2091 let list_rows = inner.height.saturating_sub(4) as usize;
2092 let range = selection_range(filtered.len(), focus, list_rows);
2093 let mut lines = vec![
2094 Line::from(vec![
2095 Span::styled("Model ", Style::default().fg(Color::DarkGray)),
2096 Span::styled(
2097 if query.is_empty() {
2098 "type to filter…"
2099 } else {
2100 query
2101 },
2102 Style::default().fg(if query.is_empty() {
2103 Color::DarkGray
2104 } else {
2105 Color::White
2106 }),
2107 ),
2108 ]),
2109 Line::styled(
2110 format!(
2111 "{} models{}",
2112 filtered.len(),
2113 if filtered.is_empty() {
2114 ""
2115 } else {
2116 " · ↑/↓ move · Enter choose"
2117 }
2118 ),
2119 Style::default().fg(Color::DarkGray),
2120 ),
2121 ];
2122 if filtered.is_empty() {
2123 lines.push(Line::styled(
2124 "No matching models",
2125 Style::default().fg(Color::Yellow),
2126 ));
2127 } else {
2128 for index in range {
2129 let selected = index == focus;
2130 lines.push(Line::styled(
2131 format!(
2132 "{} {}",
2133 if selected { "›" } else { " " },
2134 filtered[index].id
2135 ),
2136 if selected {
2137 Style::default().fg(Color::Black).bg(Color::Cyan)
2138 } else {
2139 Style::default().fg(Color::White)
2140 },
2141 ));
2142 }
2143 }
2144 lines.push(Line::styled(
2145 "Esc cancel",
2146 Style::default().fg(Color::DarkGray),
2147 ));
2148 lines
2149 }
2150 SettingsState::Effort {
2151 model,
2152 input,
2153 focus,
2154 } => {
2155 let mut lines = vec![
2156 Line::styled(model.id.clone(), Style::default().fg(Color::Cyan)),
2157 Line::styled("Reasoning effort", Style::default().fg(Color::DarkGray)),
2158 ];
2159 match &model.efforts {
2160 Some(efforts) => {
2161 let total = efforts.len() + 1;
2162 let focus = (*focus).min(total.saturating_sub(1));
2163 let list_rows = inner.height.saturating_sub(4) as usize;
2164 for index in selection_range(total, focus, list_rows) {
2165 let value = if index == 0 {
2166 "default"
2167 } else {
2168 efforts[index - 1].as_str()
2169 };
2170 let selected = index == focus;
2171 lines.push(Line::styled(
2172 format!("{} {value}", if selected { "›" } else { " " }),
2173 if selected {
2174 Style::default().fg(Color::Black).bg(Color::Cyan)
2175 } else {
2176 Style::default().fg(Color::White)
2177 },
2178 ));
2179 }
2180 lines.push(Line::styled(
2181 "↑/↓ move · Enter save · Esc cancel",
2182 Style::default().fg(Color::DarkGray),
2183 ));
2184 }
2185 None => {
2186 lines.push(Line::raw("Provider did not advertise allowed efforts."));
2187 lines.push(Line::from(vec![
2188 Span::styled("Value ", Style::default().fg(Color::DarkGray)),
2189 Span::styled(
2190 if input.is_empty() { "default" } else { input },
2191 Style::default().fg(Color::White),
2192 ),
2193 ]));
2194 lines.push(Line::styled(
2195 "Type a value · Enter save · Esc cancel",
2196 Style::default().fg(Color::DarkGray),
2197 ));
2198 }
2199 }
2200 lines
2201 }
2202 };
2203 frame.render_widget(Paragraph::new(lines), inner);
2204}
2205
2206fn selection_range(total: usize, focus: usize, max_rows: usize) -> std::ops::Range<usize> {
2207 if total == 0 || max_rows == 0 {
2208 return 0..0;
2209 }
2210 let focus = focus.min(total - 1);
2211 let visible = total.min(max_rows);
2212 let start = focus
2213 .saturating_add(1)
2214 .saturating_sub(visible)
2215 .min(total - visible);
2216 start..start + visible
2217}
2218
2219fn draw_skill_picker(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
2220 let matches = state.matching_skill_names();
2221 let total = matches.len();
2222 if total == 0 || area.is_empty() {
2223 return;
2224 }
2225
2226 frame.render_widget(Clear, area);
2229 let block = Block::default()
2230 .borders(Borders::ALL)
2231 .border_type(BorderType::Plain)
2232 .border_style(Style::default().fg(Color::Cyan));
2233 let inner = block.inner(area);
2234 frame.render_widget(block, area);
2235
2236 let focus = state.skill_picker_focus.min(total - 1);
2237 let header = Line::styled(
2238 format!("[{}/{}]", focus + 1, total),
2239 Style::default().fg(Color::DarkGray),
2240 );
2241 frame.render_widget(
2242 Paragraph::new(header),
2243 Rect::new(inner.x, inner.y, inner.width, 1),
2244 );
2245
2246 for (row, index) in skill_picker_range(total, focus).enumerate() {
2247 let style = if index == focus {
2248 Style::default().fg(Color::Cyan)
2249 } else {
2250 Style::default().fg(Color::DarkGray)
2251 };
2252 let skill = Line::styled(format!("/{}", matches[index]), style);
2253 frame.render_widget(
2254 Paragraph::new(skill),
2255 Rect::new(inner.x, inner.y + 1 + row as u16, inner.width, 1),
2256 );
2257 }
2258}
2259
2260fn welcome_line() -> Line<'static> {
2261 let character_count = WELCOME_MESSAGE.chars().count();
2262 let spans = WELCOME_MESSAGE
2263 .chars()
2264 .enumerate()
2265 .map(|(index, character)| {
2266 let progress = if character_count <= 1 {
2267 0.0
2268 } else {
2269 index as f32 / (character_count - 1) as f32
2270 };
2271 let color = Color::Rgb(
2272 interpolate_color(WELCOME_START_COLOR.0, WELCOME_END_COLOR.0, progress),
2273 interpolate_color(WELCOME_START_COLOR.1, WELCOME_END_COLOR.1, progress),
2274 interpolate_color(WELCOME_START_COLOR.2, WELCOME_END_COLOR.2, progress),
2275 );
2276 Span::styled(character.to_string(), Style::default().fg(color))
2277 })
2278 .collect::<Vec<_>>();
2279 Line::from(spans)
2280}
2281
2282fn interpolate_color(start: u8, end: u8, progress: f32) -> u8 {
2283 (start as f32 + (end as f32 - start as f32) * progress).round() as u8
2284}
2285
2286fn welcome_lines(attached_agents: &[String]) -> Vec<Line<'static>> {
2287 let mut lines = vec![
2288 welcome_line(),
2289 Line::styled(WELCOME_TAGLINE, Style::default().fg(Color::DarkGray)),
2290 Line::raw(""),
2291 ];
2292
2293 if attached_agents.is_empty() {
2294 lines.push(Line::styled(
2295 "Attached AGENTS.md: none",
2296 Style::default().fg(Color::DarkGray),
2297 ));
2298 } else {
2299 lines.push(Line::styled(
2300 "Attached AGENTS.md:",
2301 Style::default().fg(Color::DarkGray),
2302 ));
2303 lines.extend(
2304 attached_agents.iter().map(|path| {
2305 Line::styled(format!("• {path}"), Style::default().fg(Color::DarkGray))
2306 }),
2307 );
2308 }
2309
2310 lines
2311}
2312
2313fn transcript_lines(state: &UiState, width: u16) -> Vec<Line<'static>> {
2314 let width = width.max(1) as usize;
2315 let mut lines = Vec::new();
2316 let mut rendered_item = false;
2317
2318 for (index, item) in state.transcript.iter().enumerate() {
2319 if is_result_attached_to_call(&state.transcript, index) {
2322 continue;
2323 }
2324 if rendered_item {
2325 lines.push(Line::raw(String::new()));
2326 }
2327 match item {
2328 TranscriptItem::User {
2329 text,
2330 skill_instruction_attached,
2331 } => {
2332 let text = redact_secret(text, Some(&state.secret));
2333 let trigger = skill_instruction_attached
2334 .then(|| active_skill_trigger(&text, &state.skill_names))
2335 .flatten();
2336 push_user_message_block(&mut lines, &text, trigger, width);
2337 }
2338 TranscriptItem::Assistant(text) => {
2339 let text = redact_secret(text, Some(&state.secret));
2340 push_wrapped(&mut lines, &text, width, Style::default());
2341 }
2342 TranscriptItem::ToolCall {
2343 id,
2344 name,
2345 arguments,
2346 } => {
2347 let result = matching_tool_result(&state.transcript, index, id);
2348 let segments = if name == "cmd" {
2349 cmd_tool_segments(id, arguments, result, state)
2350 } else if name == "spawn_subagent" {
2351 subagent_tool_segments(id, arguments, state)
2352 } else {
2353 generic_tool_segments(name, arguments, result, state)
2354 };
2355 push_spans_wrapped(&mut lines, &segments, width);
2356 }
2357 TranscriptItem::ToolResult {
2358 id: _,
2359 name: _,
2360 result,
2361 } => {
2362 let result_text = format_tool_result(result);
2363 let result_text = redact_secret(&result_text, Some(&state.secret));
2364 push_spans_wrapped(&mut lines, &[(result_text, tool_result_style())], width);
2365 }
2366 TranscriptItem::Error(text) => {
2367 let text = redact_secret(text, Some(&state.secret));
2368 push_wrapped(&mut lines, &text, width, error_style());
2369 }
2370 TranscriptItem::Info(text) => {
2371 let text = redact_secret(text, Some(&state.secret));
2372 push_wrapped(&mut lines, &text, width, info_style());
2373 }
2374 TranscriptItem::Reasoning { complete } => {
2375 let text = if *complete {
2376 "Reasoning Complete".to_owned()
2377 } else {
2378 format!("Reasoning... {}", spinner_frame(state))
2379 };
2380 push_wrapped(&mut lines, &text, width, thinking_style());
2381 }
2382 }
2383 rendered_item = true;
2384 }
2385 if lines.is_empty() {
2386 lines.push(Line::raw(""));
2387 }
2388 lines
2389}
2390
2391fn running_tool_status(state: &UiState) -> String {
2394 tool_spinner_frame(state)
2395}
2396
2397fn cmd_tool_segments(
2398 call_id: &str,
2399 arguments: &str,
2400 result: Option<&Value>,
2401 state: &UiState,
2402) -> Vec<(String, Style)> {
2403 let command = redact_secret(&command_display(arguments), Some(&state.secret));
2404 if let Some(result) = result {
2405 let (icon, status, status_style) = cmd_result_status(result);
2406 if status == "done" {
2407 return cmd_success_segments(call_id, &format!("{icon} cmd $ {command}"), state);
2408 }
2409 vec![
2410 (format!("{icon} cmd $ {command} → "), status_style),
2411 (status, status_style),
2412 ]
2413 } else {
2414 vec![
2415 (format!("· cmd $ {command} "), pending_tool_call_style()),
2416 (running_tool_status(state), pending_tool_call_style()),
2417 ]
2418 }
2419}
2420
2421fn cmd_success_segments(call_id: &str, text: &str, state: &UiState) -> Vec<(String, Style)> {
2426 let now = Instant::now();
2427 let started_at = state.cmd_success_started_at.get(call_id).copied();
2428 if started_at.is_none_or(|started_at| {
2429 now.saturating_duration_since(started_at) >= TOOL_SUCCESS_SWEEP_DURATION
2430 }) {
2431 return vec![(text.to_owned(), Style::default().fg(Color::Green))];
2432 }
2433
2434 let character_count = text.chars().count();
2435 text.chars()
2436 .enumerate()
2437 .map(|(index, character)| {
2438 (
2439 character.to_string(),
2440 Style::default().fg(cmd_success_color_at(
2441 started_at.expect("active success sweep"),
2442 now,
2443 index,
2444 character_count,
2445 )),
2446 )
2447 })
2448 .collect()
2449}
2450
2451fn cmd_success_color_at(
2452 started_at: Instant,
2453 now: Instant,
2454 character_index: usize,
2455 character_count: usize,
2456) -> Color {
2457 let elapsed = now.saturating_duration_since(started_at);
2458 if elapsed >= TOOL_SUCCESS_SWEEP_DURATION {
2459 return Color::Green;
2460 }
2461
2462 let progress = elapsed.as_secs_f32() / TOOL_SUCCESS_SWEEP_DURATION.as_secs_f32();
2463 let sweep_distance = character_count as f32 + TOOL_SUCCESS_SWEEP_WIDTH - 1.0;
2466 let front = progress * sweep_distance;
2467 let character_progress =
2468 ((front - character_index as f32) / TOOL_SUCCESS_SWEEP_WIDTH).clamp(0.0, 1.0);
2469 Color::Rgb(
2470 interpolate_color(
2471 PENDING_TOOL_COLOR_RGB.0,
2472 TOOL_SUCCESS_GREEN_RGB.0,
2473 character_progress,
2474 ),
2475 interpolate_color(
2476 PENDING_TOOL_COLOR_RGB.1,
2477 TOOL_SUCCESS_GREEN_RGB.1,
2478 character_progress,
2479 ),
2480 interpolate_color(
2481 PENDING_TOOL_COLOR_RGB.2,
2482 TOOL_SUCCESS_GREEN_RGB.2,
2483 character_progress,
2484 ),
2485 )
2486}
2487
2488fn command_display(arguments: &str) -> String {
2489 serde_json::from_str::<Value>(arguments)
2490 .ok()
2491 .and_then(|value| {
2492 value
2493 .get("command")
2494 .and_then(Value::as_str)
2495 .map(str::to_owned)
2496 })
2497 .map(|command| truncate_output(&command))
2498 .unwrap_or_else(|| truncate_output(arguments))
2499}
2500
2501fn cmd_result_succeeded(result: &Value) -> bool {
2502 !result
2503 .get("canceled")
2504 .and_then(Value::as_bool)
2505 .unwrap_or(false)
2506 && !result
2507 .get("timed_out")
2508 .and_then(Value::as_bool)
2509 .unwrap_or(false)
2510 && result.get("error").is_none()
2511 && !matches!(result.get("exit_code").and_then(Value::as_i64), Some(code) if code != 0)
2512}
2513
2514fn cmd_result_status(result: &Value) -> (char, String, Style) {
2515 if result
2516 .get("canceled")
2517 .and_then(Value::as_bool)
2518 .unwrap_or(false)
2519 {
2520 return (
2521 '!',
2522 "canceled".to_owned(),
2523 Style::default().fg(Color::Yellow),
2524 );
2525 }
2526 if result
2527 .get("timed_out")
2528 .and_then(Value::as_bool)
2529 .unwrap_or(false)
2530 {
2531 return (
2532 '!',
2533 "timeout".to_owned(),
2534 Style::default().fg(Color::Yellow),
2535 );
2536 }
2537 if result.get("error").is_some() {
2538 return ('×', "error".to_owned(), error_style());
2539 }
2540 match result.get("exit_code").and_then(Value::as_i64) {
2541 Some(0) => ('✓', "done".to_owned(), Style::default().fg(Color::Green)),
2542 Some(code) => ('×', format!("exit {code}"), error_style()),
2543 None => ('✓', "done".to_owned(), Style::default().fg(Color::Green)),
2544 }
2545}
2546
2547fn subagent_task_from_arguments(arguments: &str) -> String {
2548 serde_json::from_str::<Value>(arguments)
2549 .ok()
2550 .and_then(|value| value.get("task").and_then(Value::as_str).map(str::to_owned))
2551 .map(|task| truncate_output(&task))
2552 .unwrap_or_else(|| command_display(arguments))
2553}
2554
2555fn subagent_tool_segments(call_id: &str, arguments: &str, state: &UiState) -> Vec<(String, Style)> {
2556 let live_task = state.subagents.iter().find(|task| task.call_id == call_id);
2557 let task = live_task
2558 .map(|task| task.task.clone())
2559 .unwrap_or_else(|| subagent_task_from_arguments(arguments));
2560 let task = redact_secret(&truncate_output(&task), Some(&state.secret));
2561 let (status, style) = if let Some(task) = live_task {
2562 let status = if task.status == SubagentStatus::Running {
2563 running_tool_status(state)
2564 } else {
2565 subagent_status_display(task.status, state)
2566 };
2567 (status, subagent_status_style(task.status))
2568 } else if state.completed_subagent_calls.contains(call_id) {
2569 ("completed".to_owned(), Style::default().fg(Color::Green))
2570 } else {
2571 ("queued".to_owned(), pending_tool_call_style())
2572 };
2573 let separator = if live_task.is_some_and(|task| task.status == SubagentStatus::Running) {
2574 " "
2575 } else {
2576 " → "
2577 };
2578 vec![(format!("↗ subagent {task}{separator}{status}"), style)]
2579}
2580
2581fn generic_tool_segments(
2582 name: &str,
2583 arguments: &str,
2584 result: Option<&Value>,
2585 state: &UiState,
2586) -> Vec<(String, Style)> {
2587 let call_text = redact_secret(
2588 &format!("[tool:{name} {}]", call_arguments(arguments)),
2589 Some(&state.secret),
2590 );
2591 let mut segments = vec![(
2592 call_text,
2593 if result.is_some() {
2594 tool_call_style()
2595 } else {
2596 pending_tool_call_style()
2597 },
2598 )];
2599 if let Some(result) = result {
2600 let result_text = redact_secret(&format_tool_result(result), Some(&state.secret));
2601 segments.push((" > ".to_owned(), Style::default()));
2602 segments.push((result_text, tool_result_style()));
2603 } else {
2604 segments.push((
2605 format!(" {}", tool_spinner_frame(state)),
2606 pending_tool_call_style(),
2607 ));
2608 }
2609 segments
2610}
2611
2612fn matching_tool_result<'a>(
2613 transcript: &'a [TranscriptItem],
2614 call_index: usize,
2615 call_id: &str,
2616) -> Option<&'a Value> {
2617 transcript
2618 .iter()
2619 .skip(call_index + 1)
2620 .find_map(|item| match item {
2621 TranscriptItem::ToolResult { id, result, .. } if id == call_id => Some(result),
2622 _ => None,
2623 })
2624}
2625
2626fn is_result_attached_to_call(transcript: &[TranscriptItem], result_index: usize) -> bool {
2627 let TranscriptItem::ToolResult { id, .. } = &transcript[result_index] else {
2628 return false;
2629 };
2630 let Some(call_index) = transcript[..result_index].iter().rposition(
2631 |item| matches!(item, TranscriptItem::ToolCall { id: call_id, .. } if call_id == id),
2632 ) else {
2633 return false;
2634 };
2635 !transcript[call_index + 1..result_index].iter().any(
2636 |item| matches!(item, TranscriptItem::ToolResult { id: result_id, .. } if result_id == id),
2637 )
2638}
2639
2640fn call_arguments(arguments: &str) -> String {
2644 let parsed: Value = match serde_json::from_str(arguments) {
2645 Ok(value) => value,
2646 Err(_) => return truncate_output(arguments),
2647 };
2648 if let Some(command) = parsed.get("command").and_then(Value::as_str) {
2649 return format!("\"{}\"", truncate_output(command));
2650 }
2651 let serialized = serde_json::to_string(&parsed).unwrap_or_else(|_| arguments.to_owned());
2652 truncate_output(&serialized)
2653}
2654
2655fn format_tool_result(result: &Value) -> String {
2659 let stdout = result.get("stdout").and_then(Value::as_str).unwrap_or("");
2660 let stderr = result.get("stderr").and_then(Value::as_str).unwrap_or("");
2661 let output = if !stdout.is_empty() { stdout } else { stderr };
2662 let truncated = truncate_output(output);
2663 let json_string = serde_json::to_string(&truncated).unwrap_or_else(|_| "\"\"".to_owned());
2666 format!("[{json_string}]")
2667}
2668
2669const RESULT_PREVIEW_CHARS: usize = 50;
2670
2671fn truncate_output(output: &str) -> String {
2672 let mut result: String = output.chars().take(RESULT_PREVIEW_CHARS).collect();
2673 if output.chars().count() > RESULT_PREVIEW_CHARS {
2674 result.push('…');
2675 }
2676 result
2677}
2678
2679fn user_message_style() -> Style {
2680 Style::default().fg(USER_BORDER_COLOR)
2681}
2682
2683fn push_user_message_block(
2686 lines: &mut Vec<Line<'static>>,
2687 text: &str,
2688 active_skill_trigger: Option<&str>,
2689 width: usize,
2690) {
2691 if width < 3 {
2692 lines.extend(styled_text_lines(
2693 text,
2694 active_skill_trigger,
2695 width.max(1),
2696 Style::default().fg(Color::White),
2697 ));
2698 return;
2699 }
2700
2701 let border_style = user_message_style();
2702 let rows = styled_text_lines(
2703 text,
2704 active_skill_trigger,
2705 width - 2,
2706 Style::default().fg(Color::White),
2707 );
2708 lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
2709 for row in rows {
2710 let mut spans = Vec::with_capacity(row.spans.len() + 2);
2711 spans.push(Span::styled(USER_BORDER_GLYPH, border_style));
2712 spans.push(Span::styled(" ", Style::default().fg(Color::White)));
2713 spans.extend(row.spans);
2714 lines.push(Line::from(spans));
2715 }
2716 lines.push(Line::from(Span::styled(USER_BORDER_GLYPH, border_style)));
2717}
2718
2719fn tool_call_style() -> Style {
2720 Style::default().fg(Color::Magenta)
2721}
2722
2723fn pending_tool_call_style() -> Style {
2724 Style::default().fg(PENDING_TOOL_COLOR)
2725}
2726
2727fn tool_result_style() -> Style {
2728 Style::default().fg(Color::DarkGray)
2729}
2730
2731fn error_style() -> Style {
2732 Style::default().fg(Color::Red)
2733}
2734
2735fn info_style() -> Style {
2736 Style::default().fg(Color::DarkGray)
2737}
2738
2739fn context_status_text(state: &UiState) -> String {
2740 let used = format_context_tokens(state.context_tokens);
2741 let Some(window) = state.context_window else {
2742 return format!("ctx={used}/? (?%)");
2743 };
2744 let percentage = context_percentage(state.context_tokens, window);
2745 format!(
2746 "ctx={used}/{} ({percentage}%)",
2747 format_context_tokens(window)
2748 )
2749}
2750
2751fn context_status_style(state: &UiState) -> Style {
2752 if state
2753 .context_window
2754 .is_some_and(|window| context_over_threshold(state.context_tokens, window))
2755 {
2756 Style::default().fg(PENDING_TOOL_COLOR)
2757 } else {
2758 Style::default().fg(Color::DarkGray)
2759 }
2760}
2761
2762fn context_percentage(used: usize, window: usize) -> usize {
2763 if window == 0 {
2764 return 0;
2765 }
2766 ((used as u128 * 100).div_ceil(window as u128)) as usize
2767}
2768
2769fn context_over_threshold(used: usize, window: usize) -> bool {
2770 window > 0 && (used as u128 * 100) > (window as u128 * 80)
2771}
2772
2773fn format_context_tokens(tokens: usize) -> String {
2774 if tokens >= 1_000_000 {
2775 format!("{:.2}M", tokens as f64 / 1_000_000.0)
2776 } else if tokens >= 1_000 {
2777 format!("{:.1}K", tokens as f64 / 1_000.0)
2778 } else {
2779 tokens.to_string()
2780 }
2781}
2782
2783fn activity_line_at<'a>(
2784 state: &UiState,
2785 text: &'a str,
2786 elapsed: Duration,
2787 now: Instant,
2788) -> Line<'a> {
2789 let character_count = text.chars().count().max(1);
2790 match state.status.as_str() {
2791 "working" => Line::from(
2792 text.chars()
2793 .enumerate()
2794 .map(|(index, character)| {
2795 let target = working_gradient_color_at(elapsed, index, character_count);
2796 Span::styled(
2797 character.to_string(),
2798 Style::default().fg(activity_color_at(state, target, now)),
2799 )
2800 })
2801 .collect::<Vec<_>>(),
2802 ),
2803 "ready"
2804 if state
2805 .activity_transition
2806 .as_ref()
2807 .is_some_and(|transition| transition_progress(now, transition) < 1.0) =>
2808 {
2809 Line::from(
2810 text.chars()
2811 .enumerate()
2812 .map(|(index, character)| {
2813 let source = working_gradient_color_at(
2814 state.last_active_elapsed,
2815 index,
2816 character_count,
2817 );
2818 Span::styled(
2819 character.to_string(),
2820 Style::default().fg(activity_color_at(state, source, now)),
2821 )
2822 })
2823 .collect::<Vec<_>>(),
2824 )
2825 }
2826 _ => Line::styled(text, activity_style_at_now(state, elapsed, now)),
2827 }
2828}
2829
2830#[cfg(test)]
2831fn activity_style_at(state: &UiState, elapsed: Duration) -> Style {
2832 activity_style_at_now(state, elapsed, Instant::now())
2833}
2834
2835fn activity_style_at_now(state: &UiState, elapsed: Duration, now: Instant) -> Style {
2836 let target = if state.status == "working" {
2837 working_gradient_color_at(elapsed, 0, 1)
2838 } else if state.status == "compacting" {
2839 PENDING_TOOL_COLOR
2840 } else {
2841 Color::Cyan
2842 };
2843 Style::default().fg(activity_color_at(state, target, now))
2844}
2845
2846fn activity_color_at(state: &UiState, target: Color, now: Instant) -> Color {
2847 let Some(transition) = &state.activity_transition else {
2848 return target;
2849 };
2850 let progress = transition_progress(now, transition);
2851 if progress >= 1.0 {
2852 return target;
2853 }
2854
2855 match state.status.as_str() {
2856 "working" => blend_rgb(Color::Cyan, target, progress),
2857 "ready" => blend_rgb(target, Color::Cyan, progress),
2858 _ => target,
2859 }
2860}
2861
2862fn transition_progress(now: Instant, transition: &ActivityTransition) -> f32 {
2863 let elapsed_ticks = now
2867 .saturating_duration_since(transition.started_at)
2868 .as_millis()
2869 / PULSE_TICK.as_millis();
2870 let transition_ticks = ACTIVITY_TRANSITION_DURATION.as_millis() / PULSE_TICK.as_millis();
2871 (elapsed_ticks as f32 / transition_ticks as f32).min(1.0)
2872}
2873fn blend_rgb(from: Color, to: Color, progress: f32) -> Color {
2874 let (from_red, from_green, from_blue) = activity_rgb(from);
2875 let (to_red, to_green, to_blue) = activity_rgb(to);
2876 Color::Rgb(
2877 interpolate_color(from_red, to_red, progress),
2878 interpolate_color(from_green, to_green, progress),
2879 interpolate_color(from_blue, to_blue, progress),
2880 )
2881}
2882
2883fn activity_rgb(color: Color) -> (u8, u8, u8) {
2884 match color {
2885 Color::Rgb(red, green, blue) => (red, green, blue),
2886 Color::Cyan => (0, 255, 255),
2889 _ => unreachable!("activity transition colours are cyan or RGB"),
2890 }
2891}
2892
2893fn draw_prompt_border_gradient(frame: &mut Frame<'_>, area: Rect) {
2896 if area.is_empty() {
2897 return;
2898 }
2899
2900 let right = area.x + area.width.saturating_sub(1);
2901 let bottom = area.y + area.height.saturating_sub(1);
2902 let buffer = frame.buffer_mut();
2903 for x in area.x..=right {
2904 let style = Style::default().fg(prompt_border_gradient_color_at(x - area.x, area.width));
2905 buffer[(x, area.y)].set_style(style);
2906 buffer[(x, bottom)].set_style(style);
2907 }
2908 for y in area.y..=bottom {
2909 buffer[(area.x, y)].set_style(Style::default().fg(PROMPT_BORDER_START_COLOR.into()));
2910 buffer[(right, y)].set_style(Style::default().fg(PROMPT_BORDER_END_COLOR.into()));
2911 }
2912}
2913
2914fn prompt_border_gradient_color_at(column: u16, width: u16) -> Color {
2915 let progress = column as f32 / width.saturating_sub(1).max(1) as f32;
2916 Color::Rgb(
2917 interpolate_color(
2918 PROMPT_BORDER_START_COLOR.0,
2919 PROMPT_BORDER_END_COLOR.0,
2920 progress,
2921 ),
2922 interpolate_color(
2923 PROMPT_BORDER_START_COLOR.1,
2924 PROMPT_BORDER_END_COLOR.1,
2925 progress,
2926 ),
2927 interpolate_color(
2928 PROMPT_BORDER_START_COLOR.2,
2929 PROMPT_BORDER_END_COLOR.2,
2930 progress,
2931 ),
2932 )
2933}
2934
2935fn working_gradient_colors_at(position: f64) -> (Color, Color, f64) {
2936 let position = position.rem_euclid(WORKING_GRADIENT_COLORS.len() as f64);
2937 let phase = position.floor() as usize;
2938 let progress = position.fract();
2939 let start = WORKING_GRADIENT_COLORS[phase];
2940 let end = WORKING_GRADIENT_COLORS[(phase + 1) % WORKING_GRADIENT_COLORS.len()];
2941 (
2942 Color::Rgb(start.0, start.1, start.2),
2943 Color::Rgb(end.0, end.1, end.2),
2944 progress,
2945 )
2946}
2947
2948fn working_gradient_color_at(
2952 elapsed: Duration,
2953 character_index: usize,
2954 character_count: usize,
2955) -> Color {
2956 const SWEEP_WIDTH: f64 = 0.35;
2957
2958 let position = elapsed.as_secs_f64() / WORKING_GRADIENT_CYCLE.as_secs_f64()
2959 + character_index as f64 / character_count.max(1) as f64 * SWEEP_WIDTH;
2960 let (
2961 Color::Rgb(red_start, green_start, blue_start),
2962 Color::Rgb(red_end, green_end, blue_end),
2963 progress,
2964 ) = working_gradient_colors_at(position)
2965 else {
2966 unreachable!("working gradient palette always uses RGB colours")
2967 };
2968
2969 Color::Rgb(
2970 interpolate_color(red_start, red_end, progress as f32),
2971 interpolate_color(green_start, green_end, progress as f32),
2972 interpolate_color(blue_start, blue_end, progress as f32),
2973 )
2974}
2975
2976fn thinking_style() -> Style {
2977 Style::default().fg(Color::DarkGray)
2978}
2979
2980const PULSE_LEVELS: [char; 7] = ['▁', '▂', '▃', '▅', '▆', '▇', '█'];
2983const PULSE_BAR_PERIODS: [u128; 5] = [12, 16, 20, 24, 15];
2984const PULSE_BAR_PHASES: [u128; 5] = [0, 5, 13, 9, 3];
2985const PULSE_TICK: Duration = Duration::from_millis(50);
2986const TOOL_SPINNER_FRAMES: [char; 4] = ['|', '/', '-', '\\'];
2987const TOOL_SPINNER_FRAME_DURATION: Duration = Duration::from_millis(100);
2988
2989const ACTIVITY_TRANSITION_DURATION: Duration = Duration::from_millis(400);
2993const PULSE_ENTRY_FRAME: Duration = Duration::from_millis(950);
2996
2997fn spinner_frame(state: &UiState) -> String {
2998 pulse_frame(state.activity_levels_at(Instant::now()))
2999}
3000
3001#[cfg(test)]
3002fn spinner_frame_at(elapsed: Duration) -> String {
3003 pulse_frame(pulse_levels_at(elapsed))
3004}
3005
3006fn tool_spinner_frame(state: &UiState) -> String {
3010 tool_spinner_frame_at(state.tool_animation_epoch.elapsed()).to_string()
3011}
3012
3013fn tool_spinner_frame_at(elapsed: Duration) -> char {
3014 let frame = (elapsed.as_millis() / TOOL_SPINNER_FRAME_DURATION.as_millis()) as usize;
3015 TOOL_SPINNER_FRAMES[frame % TOOL_SPINNER_FRAMES.len()]
3016}
3017
3018fn pulse_frame(levels: [usize; PULSE_BAR_PERIODS.len()]) -> String {
3019 levels
3020 .into_iter()
3021 .map(|level| PULSE_LEVELS[level])
3022 .collect()
3023}
3024
3025fn pulse_levels_at(elapsed: Duration) -> [usize; PULSE_BAR_PERIODS.len()] {
3026 let tick = elapsed.as_millis() / PULSE_TICK.as_millis();
3027 std::array::from_fn(|index| {
3028 pulse_level_at(tick, PULSE_BAR_PERIODS[index], PULSE_BAR_PHASES[index])
3029 })
3030}
3031
3032fn interpolate_pulse_levels(
3033 from: [usize; PULSE_BAR_PERIODS.len()],
3034 to: [usize; PULSE_BAR_PERIODS.len()],
3035 elapsed: Duration,
3036) -> [usize; PULSE_BAR_PERIODS.len()] {
3037 let elapsed = elapsed.min(ACTIVITY_TRANSITION_DURATION).as_millis();
3038 let duration = ACTIVITY_TRANSITION_DURATION.as_millis();
3039 std::array::from_fn(|index| {
3040 let start = from[index] as i128;
3041 let distance = to[index] as i128 - start;
3042 (start + distance * elapsed as i128 / duration as i128) as usize
3043 })
3044}
3045
3046fn pulse_level_at(tick: u128, period: u128, phase: u128) -> usize {
3047 let position = (tick + phase) % period;
3048 let half_period = period / 2;
3049 let distance_from_floor = if position <= half_period {
3050 position
3051 } else {
3052 period - position
3053 };
3054 (distance_from_floor * (PULSE_LEVELS.len() - 1) as u128 / half_period) as usize
3055}
3056
3057fn push_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, style: Style) {
3058 let mut added = false;
3059 for piece in wrap_text(text, width) {
3060 lines.push(Line::styled(piece, style));
3061 added = true;
3062 }
3063 if !added {
3064 lines.push(Line::styled(String::new(), style));
3065 }
3066}
3067
3068fn push_spans_wrapped(lines: &mut Vec<Line<'static>>, segments: &[(String, Style)], width: usize) {
3072 let mut current_spans: Vec<Span<'static>> = Vec::new();
3073 let mut current_width = 0usize;
3074 for (text, style) in segments {
3075 for character in text.chars() {
3076 let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
3077 if current_width + char_width > width && !current_spans.is_empty() {
3078 lines.push(Line::from(std::mem::take(&mut current_spans)));
3079 current_width = 0;
3080 }
3081 let mut buffer = [0u8; 4];
3082 let s = character.encode_utf8(&mut buffer);
3083 current_spans.push(Span::styled(s.to_owned(), *style));
3084 current_width += char_width;
3085 }
3086 }
3087 if current_spans.is_empty() {
3088 current_spans.push(Span::raw(String::new()));
3089 }
3090 lines.push(Line::from(current_spans));
3091}
3092
3093fn wrap_text(text: &str, width: usize) -> Vec<String> {
3099 if width == 0 {
3100 return text.lines().map(str::to_owned).collect();
3101 }
3102 let mut rows = Vec::new();
3103 for line in text.split('\n') {
3106 rows.extend(wrap_line(line, width));
3107 }
3108 if rows.is_empty() {
3109 rows.push(String::new());
3110 }
3111 rows
3112}
3113
3114fn wrap_line(line: &str, width: usize) -> Vec<String> {
3115 let mut rows = Vec::new();
3116 let mut current = String::new();
3117 let mut current_width = 0usize;
3118 for character in line.chars() {
3119 let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
3120 if current_width + char_width > width && !current.is_empty() {
3121 rows.push(std::mem::take(&mut current));
3122 current_width = 0;
3123 }
3124 current.push(character);
3125 current_width += char_width;
3126 }
3127 rows.push(current);
3128 rows
3129}
3130
3131#[cfg(test)]
3132mod tests {
3133 use super::*;
3134
3135 #[test]
3136 fn turn_notifications_use_osc_777_with_fixed_secret_safe_messages() {
3137 let cases = [
3138 (TurnNotification::Completed, "Turn complete"),
3139 (TurnNotification::Interrupted, "Turn interrupted"),
3140 (TurnNotification::Failed, "Turn failed"),
3141 ];
3142
3143 for (notification, body) in cases {
3144 let mut output = Vec::new();
3145 send_turn_notification(&mut output, notification).expect("notification");
3146 assert_eq!(
3147 output,
3148 format!("\x1b]777;notify;Lucy;{body}\x07").into_bytes()
3149 );
3150 }
3151 }
3152
3153 #[test]
3154 fn turn_notifications_follow_the_terminal_turn_status() {
3155 assert_eq!(
3156 turn_notification_for_status("finalizing"),
3157 TurnNotification::Completed
3158 );
3159 assert_eq!(
3160 turn_notification_for_status("cancelling"),
3161 TurnNotification::Interrupted
3162 );
3163 assert_eq!(
3164 turn_notification_for_status("error"),
3165 TurnNotification::Failed
3166 );
3167 }
3168
3169 struct FailingWriter;
3170
3171 impl Write for FailingWriter {
3172 fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
3173 Err(io::Error::other("notification sink unavailable"))
3174 }
3175
3176 fn flush(&mut self) -> io::Result<()> {
3177 Err(io::Error::other("notification sink unavailable"))
3178 }
3179 }
3180
3181 #[test]
3182 fn notification_write_failure_does_not_keep_the_tui_busy() {
3183 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3184 state.busy = true;
3185 state.active_cancel = Some(CancellationToken::new());
3186 let mut writer = FailingWriter;
3187
3188 release_finished_turn(&mut writer, &mut state);
3189
3190 assert!(!state.busy);
3191 assert!(state.active_cancel.is_none());
3192 }
3193
3194 #[test]
3195 fn an_idle_finish_does_not_emit_a_duplicate_notification() {
3196 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3197 let mut output = Vec::new();
3198
3199 release_finished_turn(&mut output, &mut state);
3200
3201 assert!(output.is_empty());
3202 }
3203
3204 #[test]
3205 fn context_status_shows_used_window_and_percentage() {
3206 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3207 .with_context(Some(100_000), 80_000);
3208
3209 assert_eq!(context_status_text(&state), "ctx=80.0K/100.0K (80%)");
3210 assert_eq!(context_status_style(&state).fg, Some(Color::DarkGray));
3211
3212 state.context_tokens = 80_001;
3213 assert_eq!(context_status_text(&state), "ctx=80.0K/100.0K (81%)");
3214 assert_eq!(context_status_style(&state).fg, Some(PENDING_TOOL_COLOR));
3215 }
3216
3217 #[test]
3218 fn context_status_handles_unknown_window_without_highlighting() {
3219 let state = UiState::from_history(&[], "secret", "model", None, false);
3220
3221 assert_eq!(context_status_text(&state), "ctx=1/? (?%)");
3222 assert_eq!(context_status_style(&state).fg, Some(Color::DarkGray));
3223 }
3224
3225 #[test]
3226 fn tui_viewport_reserves_one_column_on_each_side_when_possible() {
3227 assert_eq!(
3228 tui_viewport(Rect::new(0, 0, 80, 10)),
3229 Rect::new(1, 0, 78, 10)
3230 );
3231 assert_eq!(
3232 tui_viewport(Rect::new(0, 0, 2, 10)),
3233 Rect::new(0, 0, 2, 10),
3234 "a two-column terminal cannot reserve two gutters"
3235 );
3236 }
3237
3238 #[test]
3239 fn context_status_is_right_aligned_and_turns_orange_above_eighty_percent() {
3240 let state =
3241 UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
3242 let mut terminal =
3243 Terminal::new(ratatui::backend::TestBackend::new(60, 10)).expect("test terminal");
3244
3245 terminal
3246 .draw(|frame| draw(frame, &state))
3247 .expect("draw statusline");
3248
3249 let buffer = terminal.backend().buffer();
3250 let status_area = ui_layout(&state, tui_viewport(Rect::new(0, 0, 60, 10))).5;
3251 let context = context_status_text(&state);
3252 let context_start = status_area.x + status_area.width - context.chars().count() as u16;
3253 let context_end = status_area.x + status_area.width - 1;
3254 assert_eq!(buffer[(context_start, status_area.y)].symbol(), "c");
3255 assert_eq!(buffer[(context_end, status_area.y)].symbol(), ")");
3256 assert_eq!(buffer[(context_end, status_area.y)].fg, PENDING_TOOL_COLOR);
3257 }
3258
3259 #[test]
3260 fn activity_indicator_uses_only_contiguous_block_bars() {
3261 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3262 assert_eq!(activity_text(&state), "▁▁▁▁▁");
3263
3264 state.set_status("working");
3265 let working = activity_text(&state);
3266 assert_eq!(working.chars().count(), 5);
3267 assert!(working.chars().all(|bar| PULSE_LEVELS.contains(&bar)));
3268 assert!(!working.chars().any(char::is_whitespace));
3269 }
3270
3271 #[test]
3272 fn activity_indicator_ramps_up_and_settles_down_one_level_per_tick() {
3273 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3274 state.set_status("working");
3275 let entry = state
3276 .activity_transition
3277 .clone()
3278 .expect("working begins with a ramp");
3279 let entry_frames = (0..=ACTIVITY_TRANSITION_DURATION.as_millis() / PULSE_TICK.as_millis())
3280 .map(|tick| state.activity_levels_at(entry.started_at + PULSE_TICK * tick as u32))
3281 .collect::<Vec<_>>();
3282 assert_eq!(entry_frames.first(), Some(&[0; PULSE_BAR_PERIODS.len()]));
3283 assert_eq!(entry_frames.last(), Some(&entry.to_levels));
3284 assert_levels_change_gradually(&entry_frames, "working entry");
3285
3286 state.activity_transition = None;
3289 state.activity_started_at = Instant::now() - PULSE_ENTRY_FRAME;
3290 state.set_status("ready");
3291 let exit = state
3292 .activity_transition
3293 .clone()
3294 .expect("ready begins with a settle-down ramp");
3295 let exit_frames = (0..=ACTIVITY_TRANSITION_DURATION.as_millis() / PULSE_TICK.as_millis())
3296 .map(|tick| state.activity_levels_at(exit.started_at + PULSE_TICK * tick as u32))
3297 .collect::<Vec<_>>();
3298 assert_eq!(exit_frames.first(), Some(&exit.from_levels));
3299 assert_eq!(exit_frames.last(), Some(&[0; PULSE_BAR_PERIODS.len()]));
3300 assert_levels_change_gradually(&exit_frames, "ready exit");
3301 assert!(exit_frames.windows(2).all(|pair| {
3302 pair[0]
3303 .iter()
3304 .zip(pair[1])
3305 .all(|(before, after)| after <= *before)
3306 }));
3307
3308 let settled_at = exit.started_at + ACTIVITY_TRANSITION_DURATION;
3309 let settled_text = activity_text_at(&state, settled_at);
3310 let settled = activity_line_at(
3311 &state,
3312 &settled_text,
3313 state.working_elapsed_at(settled_at),
3314 settled_at,
3315 );
3316 assert_eq!(
3317 settled.style.fg,
3318 Some(Color::Cyan),
3319 "the completed settle-down transition restores the ready colour"
3320 );
3321 }
3322
3323 fn assert_levels_change_gradually(
3324 frames: &[[usize; PULSE_BAR_PERIODS.len()]],
3325 transition: &str,
3326 ) {
3327 assert!(
3328 frames.windows(2).all(|pair| {
3329 pair[0]
3330 .iter()
3331 .zip(pair[1])
3332 .all(|(before, after)| before.abs_diff(after) <= 1)
3333 }),
3334 "{transition} must not jump more than one pulse level per rendered tick"
3335 );
3336 }
3337
3338 #[test]
3339 fn pulse_spinner_moves_each_bar_one_level_at_a_time() {
3340 let frames = (0..=240)
3341 .map(|tick| spinner_frame_at(PULSE_TICK * tick))
3342 .collect::<Vec<_>>();
3343 assert!(frames.iter().any(|frame| frame != &frames[0]));
3344 assert_eq!(PULSE_TICK, Duration::from_millis(50));
3345
3346 for pair in frames.windows(2) {
3347 let levels = pair
3348 .iter()
3349 .map(|frame| {
3350 frame
3351 .chars()
3352 .map(|bar| {
3353 PULSE_LEVELS
3354 .iter()
3355 .position(|level| *level == bar)
3356 .expect("known pulse level")
3357 })
3358 .collect::<Vec<_>>()
3359 })
3360 .collect::<Vec<_>>();
3361 assert_eq!(levels[0].len(), 5);
3362 assert!(
3363 levels[0]
3364 .iter()
3365 .zip(&levels[1])
3366 .all(|(before, after)| before.abs_diff(*after) <= 1),
3367 "pulse bars must not jump between adjacent ticks: {:?} -> {:?}",
3368 pair[0],
3369 pair[1]
3370 );
3371 }
3372 }
3373
3374 #[test]
3375 fn working_activity_uses_a_continuous_warm_gradient() {
3376 let text = "▁▅▆▆▃";
3377 let colors = text
3378 .chars()
3379 .enumerate()
3380 .map(|(index, _)| {
3381 working_gradient_color_at(Duration::from_millis(2_500), index, text.chars().count())
3382 })
3383 .collect::<Vec<_>>();
3384
3385 assert_eq!(colors.first(), Some(&Color::Rgb(228, 40, 120)));
3386 assert_eq!(colors.last(), Some(&Color::Rgb(232, 43, 89)));
3387 assert!(colors.windows(2).all(|pair| pair[0] != pair[1]));
3388
3389 let (start, end, _) = working_gradient_colors_at(1.0);
3390 assert_eq!(start, Color::Rgb(235, 45, 65));
3391 assert_eq!(end, Color::Rgb(255, 130, 25));
3392
3393 let (start, end, _) = working_gradient_colors_at(3.0);
3394 assert_eq!(start, Color::Rgb(235, 45, 65));
3395 assert_eq!(end, Color::Rgb(220, 35, 175));
3396 }
3397
3398 #[test]
3399 fn queued_messages_render_above_the_prompt_border_with_their_existing_style() {
3400 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3401 state.queue_user("first task");
3402 state.queue_user("second task");
3403 let area = Rect::new(0, 0, 80, 12);
3404 let mut terminal =
3405 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
3406 .expect("test terminal");
3407
3408 let mut rendered_area = Rect::default();
3409 terminal
3410 .draw(|frame| {
3411 rendered_area = frame.area();
3412 draw(frame, &state);
3413 })
3414 .expect("draw queued message");
3415 let (_, _, _, queue_area, input_area, status_area) =
3416 ui_layout(&state, tui_viewport(rendered_area));
3417 let queue_area = queue_area.expect("message queue area");
3418 assert_eq!(queue_area.x, input_area.x + 1);
3419 assert_eq!(queue_area.y + queue_area.height, input_area.y);
3420 assert_eq!(queue_area.width, input_area.width.saturating_sub(2));
3421 assert_eq!(queue_area.height, 2, "each queued message owns one row");
3422 let buffer = terminal.backend().buffer();
3423 let queued_rows = (queue_area.y..queue_area.y + queue_area.height)
3424 .map(|y| {
3425 (queue_area.x..queue_area.x + queue_area.width)
3426 .map(|x| buffer[(x, y)].symbol())
3427 .collect::<String>()
3428 })
3429 .collect::<Vec<_>>();
3430 let status_row = (status_area.x..status_area.x + status_area.width)
3431 .map(|x| buffer[(x, status_area.y)].symbol())
3432 .collect::<String>();
3433 assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), "┌");
3434 assert_eq!(
3435 buffer[(input_area.x + input_area.width - 1, input_area.y)].symbol(),
3436 "┐"
3437 );
3438 assert!(queued_rows[0].contains("Queued 2: first task"));
3439 assert!(queued_rows[1].contains("Queued 2: second task"));
3440 assert!(queued_rows.iter().all(|row| !row.contains('|')));
3441 for y in queue_area.y..queue_area.y + queue_area.height {
3442 assert_eq!(buffer[(queue_area.x, y)].fg, QUEUED_MESSAGE_COLOR);
3443 assert_eq!(
3444 buffer[(queue_area.x + queue_area.width - 1, y)].bg,
3445 QUEUED_MESSAGE_BACKGROUND,
3446 "the dark teal background fills every queue row"
3447 );
3448 }
3449 assert!(!status_row.contains("first task"));
3450 assert!(!status_row.contains("second task"));
3451 }
3452
3453 #[test]
3454 fn ready_submission_bypasses_queue_and_is_not_added_twice_when_started() {
3455 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3456
3457 state.submit_user("send now");
3458
3459 assert!(state.queued_messages.is_empty());
3460 assert_eq!(state.transcript.len(), 1);
3461 assert!(matches!(
3462 &state.transcript[0],
3463 TranscriptItem::User { text, .. } if text == "send now"
3464 ));
3465
3466 state.start_queued_user("send now");
3469 assert_eq!(state.transcript.len(), 1);
3470 }
3471
3472 #[test]
3473 fn busy_submission_remains_queued_until_its_turn_starts() {
3474 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3475 state.busy = true;
3476
3477 state.submit_user("send later");
3478
3479 assert_eq!(state.queued_messages, ["send later"]);
3480 assert!(state.transcript.is_empty());
3481
3482 state.start_queued_user("send later");
3483 assert!(state.queued_messages.is_empty());
3484 assert!(matches!(
3485 &state.transcript[..],
3486 [TranscriptItem::User { text, .. }] if text == "send later"
3487 ));
3488 }
3489
3490 #[test]
3491 fn skill_picker_stays_above_a_visible_message_queue() {
3492 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3493 .with_skill_names(vec!["release-notes".to_owned()]);
3494 state.queue_user("next task");
3495 state.input = "/".to_owned();
3496 state.input_changed();
3497
3498 let area = Rect::new(0, 0, 80, 12);
3499 let (_, picker_area, _, queue_area, input_area, _) = ui_layout(&state, tui_viewport(area));
3500 let picker_area = picker_area.expect("skill picker area");
3501 let queue_area = queue_area.expect("message queue area");
3502 assert_eq!(picker_area.y + picker_area.height, queue_area.y);
3503 assert_eq!(queue_area.y + queue_area.height, input_area.y);
3504 assert_eq!(queue_area.x, input_area.x + 1);
3505 }
3506
3507 #[test]
3508 fn fresh_sessions_show_the_versioned_gradient_welcome_message() {
3509 let state = UiState::from_history(&[], "secret", "model", None, false);
3510 assert!(state.welcome_visible);
3511
3512 let line = welcome_line();
3513 assert_eq!(line.to_string(), WELCOME_MESSAGE);
3514 assert_eq!(
3515 line.spans.first().and_then(|span| span.style.fg),
3516 Some(Color::Rgb(
3517 WELCOME_START_COLOR.0,
3518 WELCOME_START_COLOR.1,
3519 WELCOME_START_COLOR.2,
3520 ))
3521 );
3522 assert_eq!(
3523 line.spans.last().and_then(|span| span.style.fg),
3524 Some(Color::Rgb(
3525 WELCOME_END_COLOR.0,
3526 WELCOME_END_COLOR.1,
3527 WELCOME_END_COLOR.2,
3528 ))
3529 );
3530 }
3531
3532 #[test]
3533 fn welcome_shows_the_tagline_and_attached_agents_paths() {
3534 let state = UiState::from_history(&[], "secret", "model", None, false)
3535 .with_attached_agents(vec![
3536 "/workspace/AGENTS.md".to_owned(),
3537 "/workspace/app/AGENTS.md".to_owned(),
3538 ]);
3539 let lines = welcome_lines(&state.attached_agents);
3540
3541 assert_eq!(lines[1].to_string(), WELCOME_TAGLINE);
3542 assert_eq!(lines[1].style.fg, Some(Color::DarkGray));
3543 assert_eq!(lines[3].to_string(), "Attached AGENTS.md:");
3544 assert_eq!(lines[4].to_string(), "• /workspace/AGENTS.md");
3545 assert_eq!(lines[5].to_string(), "• /workspace/app/AGENTS.md");
3546 assert!(lines[3..]
3547 .iter()
3548 .all(|line| line.style.fg == Some(Color::DarkGray)));
3549 }
3550
3551 #[test]
3552 fn welcome_reports_when_no_agents_file_is_attached() {
3553 let lines = welcome_lines(&[]);
3554 assert_eq!(
3555 lines.last().expect("empty context line").to_string(),
3556 "Attached AGENTS.md: none"
3557 );
3558 }
3559
3560 #[test]
3561 fn resumed_sessions_do_not_show_the_welcome_message() {
3562 let state = UiState::from_history(&[], "secret", "model", None, true);
3563 assert!(!state.welcome_visible);
3564 }
3565
3566 #[test]
3567 fn history_replay_keeps_interruption_after_messages() {
3568 let history = vec![
3569 SessionHistoryRecord::Message {
3570 timestamp: 1,
3571 message: ChatMessage::user("hello".to_owned()),
3572 },
3573 SessionHistoryRecord::Interruption {
3574 timestamp: 2,
3575 reason: "user_cancelled".to_owned(),
3576 phase: "provider_stream".to_owned(),
3577 assistant_text: "partial".to_owned(),
3578 tool_calls: Vec::new(),
3579 tool_results: Vec::new(),
3580 },
3581 ];
3582 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
3583 assert!(matches!(state.transcript[0], TranscriptItem::User { .. }));
3584 assert!(matches!(state.transcript[1], TranscriptItem::Assistant(_)));
3585 assert!(matches!(state.transcript[2], TranscriptItem::Info(_)));
3586 let text = transcript_lines(&state, 80)
3587 .iter()
3588 .map(ToString::to_string)
3589 .collect::<Vec<_>>()
3590 .join("\n");
3591 assert!(!text.contains("choices"));
3592 }
3593
3594 #[test]
3595 fn history_replay_does_not_render_assistant_reasoning_details() {
3596 let mut message = ChatMessage::assistant("visible answer".to_owned(), Vec::new());
3597 message.reasoning_details = Some(vec![serde_json::json!({
3598 "type": "reasoning.text",
3599 "text": "private reasoning"
3600 })]);
3601 let history = [SessionHistoryRecord::Message {
3602 timestamp: 1,
3603 message,
3604 }];
3605 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
3606 let text = transcript_lines(&state, 80)
3607 .iter()
3608 .map(ToString::to_string)
3609 .collect::<Vec<_>>()
3610 .join("\n");
3611 assert!(text.contains("visible answer"));
3612 assert!(!text.contains("private reasoning"));
3613 assert!(!text.contains("reasoning_details"));
3614 }
3615
3616 #[test]
3617 fn history_replay_preserves_repeated_records() {
3618 let history = vec![
3619 SessionHistoryRecord::Message {
3620 timestamp: 1,
3621 message: ChatMessage::assistant("same".to_owned(), Vec::new()),
3622 },
3623 SessionHistoryRecord::Interruption {
3624 timestamp: 2,
3625 reason: "user_cancelled".to_owned(),
3626 phase: "provider_stream".to_owned(),
3627 assistant_text: "same".to_owned(),
3628 tool_calls: Vec::new(),
3629 tool_results: Vec::new(),
3630 },
3631 ];
3632 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
3633 assert_eq!(
3634 state
3635 .transcript
3636 .iter()
3637 .filter(|item| matches!(item, TranscriptItem::Assistant(text) if text == "same"))
3638 .count(),
3639 2
3640 );
3641 }
3642
3643 #[test]
3644 fn user_messages_have_a_single_block_rule_with_inner_and_vertical_padding() {
3645 let history = [SessionHistoryRecord::Message {
3646 timestamp: 1,
3647 message: ChatMessage::user("hello\nworld".to_owned()),
3648 }];
3649 let state = UiState::from_history(&history, "provider-secret", "model", None, false);
3650 let lines = transcript_lines(&state, 12);
3651
3652 assert_eq!(UnicodeWidthStr::width(USER_BORDER_GLYPH), 1);
3653 assert_eq!(lines.len(), 4);
3654 assert_eq!(lines[0].to_string(), "▌");
3655 assert_eq!(lines[1].to_string(), "▌ hello");
3656 assert_eq!(lines[2].to_string(), "▌ world");
3657 assert_eq!(lines[3].to_string(), "▌");
3658 for line in &lines {
3659 assert_eq!(line.spans[0].content, USER_BORDER_GLYPH);
3660 assert_eq!(line.spans[0].style.fg, Some(USER_BORDER_COLOR));
3661 assert!(!line.to_string().contains(['┌', '┐', '└', '┘', '│']));
3662 }
3663 for line in &lines[1..3] {
3664 assert_eq!(line.spans[1].content, " ");
3665 assert_eq!(line.spans[1].style.fg, Some(Color::White));
3666 assert_eq!(line.spans[2].style.fg, Some(Color::White));
3667 }
3668 }
3669
3670 #[test]
3671 fn attached_skill_highlights_its_trigger_in_the_user_message_without_a_notice_line() {
3672 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3673 .with_skill_names(vec!["release-notes".to_owned()]);
3674 state.add_user("/release-notes v1.2.0", "secret");
3675 state.mark_latest_user_skill_attached();
3676
3677 let lines = transcript_lines(&state, 40);
3678 assert_eq!(lines.len(), 3);
3679 assert_eq!(lines[1].spans[1].content, " ");
3680 let cyan_text = lines[1]
3681 .spans
3682 .iter()
3683 .filter(|span| span.style.fg == Some(Color::Cyan))
3684 .map(|span| span.content.as_ref())
3685 .collect::<String>();
3686 assert_eq!(cyan_text, "/release-notes");
3687 assert!(!lines
3688 .iter()
3689 .any(|line| line.to_string().contains("instruction attached")));
3690 }
3691
3692 #[test]
3693 fn transcript_rendering_redacts_history_content() {
3694 let history = [SessionHistoryRecord::Message {
3695 timestamp: 1,
3696 message: ChatMessage::assistant("provider-secret".to_owned(), Vec::new()),
3697 }];
3698 let state = UiState::from_history(&history, "provider-secret", "model", None, false);
3699 let text = transcript_lines(&state, 80)
3700 .iter()
3701 .map(ToString::to_string)
3702 .collect::<Vec<_>>()
3703 .join("\n");
3704 assert!(!text.contains("provider-secret"));
3705 }
3706
3707 #[test]
3708 fn mouse_wheel_disables_following_and_changes_scroll_offset() {
3709 let history = [SessionHistoryRecord::Message {
3710 timestamp: 1,
3711 message: ChatMessage::user("hello".to_owned()),
3712 }];
3713 let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
3714 handle_mouse_event(&mut state, MouseEventKind::ScrollUp, 10);
3715 assert!(!state.auto_scroll);
3716 assert_eq!(state.scroll, 7);
3717 handle_mouse_event(&mut state, MouseEventKind::ScrollDown, 10);
3718 assert!(
3719 state.auto_scroll,
3720 "reaching the bottom resumes transcript following"
3721 );
3722 assert_eq!(state.scroll, 0);
3723 scroll_up(&mut state, 10);
3724 assert!(!state.auto_scroll);
3725 assert_eq!(state.scroll, 7);
3726 }
3727
3728 #[test]
3729 fn wrap_text_breaks_long_lines_and_preserves_empty_lines() {
3730 let rows = wrap_text("12345\n\nabc", 3);
3731 assert_eq!(rows, vec!["123", "45", "", "abc"]);
3732 }
3733
3734 #[test]
3735 fn wrap_line_never_returns_an_empty_vec() {
3736 assert_eq!(wrap_line("", 5), vec![""]);
3737 assert_eq!(wrap_line("abc", 5), vec!["abc"]);
3738 }
3739
3740 #[test]
3741 fn completion_event_does_not_release_input_before_worker_finishes() {
3742 let history = [SessionHistoryRecord::Message {
3743 timestamp: 1,
3744 message: ChatMessage::user("hello".to_owned()),
3745 }];
3746 let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
3747 state.busy = true;
3748 state.active_cancel = Some(CancellationToken::new());
3749 state.apply_event(ProtocolEvent::TurnEnd);
3750 assert!(state.busy);
3751 assert!(state.active_cancel.is_some());
3752 assert_eq!(state.status, "finalizing");
3753 }
3754
3755 #[test]
3756 fn transcript_inserts_a_blank_line_between_items() {
3757 let history = [
3758 SessionHistoryRecord::Message {
3759 timestamp: 1,
3760 message: ChatMessage::user("hi".to_owned()),
3761 },
3762 SessionHistoryRecord::Message {
3763 timestamp: 2,
3764 message: ChatMessage::assistant("hello".to_owned(), Vec::new()),
3765 },
3766 ];
3767 let state = UiState::from_history(&history, "secret", "model", None, false);
3768 let lines = transcript_lines(&state, 80);
3769 assert_eq!(lines.len(), 5);
3770 assert_eq!(lines[0].to_string(), "▌");
3771 assert_eq!(lines[1].to_string(), "▌ hi");
3772 assert_eq!(lines[2].to_string(), "▌");
3773 assert_eq!(lines[3].to_string(), "");
3774 assert_eq!(lines[4].to_string(), "hello");
3775 }
3776
3777 #[test]
3778 fn cmd_call_renders_as_a_compact_status_line_without_raw_json() {
3779 let history = vec![
3780 SessionHistoryRecord::Message {
3781 timestamp: 1,
3782 message: ChatMessage::assistant(
3783 String::new(),
3784 vec![crate::model::ChatToolCall {
3785 id: "call-1".to_owned(),
3786 name: "cmd".to_owned(),
3787 arguments: r#"{"command":"pwd"}"#.to_owned(),
3788 }],
3789 ),
3790 },
3791 SessionHistoryRecord::Message {
3792 timestamp: 2,
3793 message: ChatMessage::tool(
3794 "call-1".to_owned(),
3795 "cmd".to_owned(),
3796 serde_json::json!({"exit_code": 0, "stdout": "secret output"}).to_string(),
3797 ),
3798 },
3799 ];
3800 let state = UiState::from_history(&history, "secret", "model", None, false);
3801 let text = transcript_lines(&state, 80)[0].to_string();
3802
3803 assert_eq!(text, "✓ cmd $ pwd");
3804 assert!(!text.contains("secret output"));
3805 assert!(!text.contains("{\"command\":\"pwd\"}"));
3806 }
3807
3808 #[test]
3809 fn pending_cmd_calls_use_a_compact_running_status() {
3810 let history = [SessionHistoryRecord::Message {
3811 timestamp: 1,
3812 message: ChatMessage::assistant(
3813 String::new(),
3814 vec![crate::model::ChatToolCall {
3815 id: "call-1".to_owned(),
3816 name: "cmd".to_owned(),
3817 arguments: r#"{"command":"pwd"}"#.to_owned(),
3818 }],
3819 ),
3820 }];
3821 let state = UiState::from_history(&history, "secret", "model", None, false);
3822 let line = &transcript_lines(&state, 80)[0];
3823
3824 let text = line.to_string();
3825 let prefix = "· cmd $ pwd ";
3826 assert!(text.starts_with(prefix));
3827 assert!(!text.contains("→ running"));
3828 let frame = &text[prefix.len()..];
3829 assert_eq!(frame.chars().count(), 1);
3830 assert!(frame
3831 .chars()
3832 .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
3833 assert!(line
3834 .spans
3835 .iter()
3836 .all(|span| span.style.fg == Some(PENDING_TOOL_COLOR)));
3837 }
3838
3839 #[test]
3840 fn running_tool_indicators_use_a_traditional_spinner_with_their_own_clock() {
3841 assert_eq!(tool_spinner_frame_at(Duration::ZERO), '|');
3842 assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION), '/');
3843 assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 2), '-');
3844 assert_eq!(tool_spinner_frame_at(TOOL_SPINNER_FRAME_DURATION * 3), '\\');
3845
3846 let state = UiState::from_history(&[], "secret", "model", None, false);
3847 let spinner = running_tool_status(&state);
3848 assert_eq!(spinner.chars().count(), 1);
3849 assert!(spinner
3850 .chars()
3851 .all(|spinner| TOOL_SPINNER_FRAMES.contains(&spinner)));
3852 assert!(
3853 subagent_status_display(SubagentStatus::Running, &state).starts_with("running "),
3854 "the background-task list uses the same spinner"
3855 );
3856 }
3857
3858 #[test]
3859 fn successful_cmd_sweeps_from_orange_to_green_left_to_right() {
3860 let started_at = Instant::now();
3861 let character_count = 12;
3862 let halfway = started_at + Duration::from_millis(175);
3863
3864 assert_eq!(
3865 cmd_success_color_at(started_at, started_at, 0, character_count),
3866 PENDING_TOOL_COLOR,
3867 "a new success must retain the pending orange at the sweep start"
3868 );
3869 assert_eq!(
3870 cmd_success_color_at(started_at, halfway, 0, character_count),
3871 Color::Rgb(
3872 TOOL_SUCCESS_GREEN_RGB.0,
3873 TOOL_SUCCESS_GREEN_RGB.1,
3874 TOOL_SUCCESS_GREEN_RGB.2,
3875 ),
3876 "the left edge completes before the rest of the line"
3877 );
3878 assert_eq!(
3879 cmd_success_color_at(started_at, halfway, 9, character_count),
3880 PENDING_TOOL_COLOR,
3881 "the right edge remains orange until the sweep reaches it"
3882 );
3883 assert_eq!(
3884 cmd_success_color_at(
3885 started_at,
3886 started_at + TOOL_SUCCESS_SWEEP_DURATION,
3887 9,
3888 character_count,
3889 ),
3890 Color::Green,
3891 "the completed sweep settles on the existing success green"
3892 );
3893 }
3894
3895 #[test]
3896 fn only_live_successful_cmd_results_start_a_success_sweep() {
3897 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3898 let succeeded = serde_json::json!({"exit_code": 0});
3899
3900 state.add_tool_result("historic", "cmd", succeeded.clone());
3901 state.add_live_tool_result("success", "cmd", succeeded);
3902 state.add_live_tool_result("failed", "cmd", serde_json::json!({"exit_code": 1}));
3903
3904 assert!(!state.cmd_success_started_at.contains_key("historic"));
3905 assert!(state.cmd_success_started_at.contains_key("success"));
3906 assert!(!state.cmd_success_started_at.contains_key("failed"));
3907 }
3908
3909 #[test]
3910 fn cmd_status_distinguishes_nonzero_exit_timeout_and_cancellation() {
3911 let cases = [
3912 (
3913 serde_json::json!({"exit_code": 127}),
3914 "× cmd $ bad → exit 127",
3915 ),
3916 (
3917 serde_json::json!({"timed_out": true, "exit_code": null}),
3918 "! cmd $ slow → timeout",
3919 ),
3920 (
3921 serde_json::json!({"canceled": true}),
3922 "! cmd $ stop → canceled",
3923 ),
3924 ];
3925 for (result, expected) in cases {
3926 let history = vec![
3927 SessionHistoryRecord::Message {
3928 timestamp: 1,
3929 message: ChatMessage::assistant(
3930 String::new(),
3931 vec![crate::model::ChatToolCall {
3932 id: "call-1".to_owned(),
3933 name: "cmd".to_owned(),
3934 arguments: serde_json::json!({"command": expected.split("$ ").nth(1).unwrap().split(" ").next().unwrap()}).to_string(),
3935 }],
3936 ),
3937 },
3938 SessionHistoryRecord::Message {
3939 timestamp: 2,
3940 message: ChatMessage::tool(
3941 "call-1".to_owned(),
3942 "cmd".to_owned(),
3943 result.to_string(),
3944 ),
3945 },
3946 ];
3947 let state = UiState::from_history(&history, "secret", "model", None, false);
3948 assert_eq!(transcript_lines(&state, 80)[0].to_string(), expected);
3949 }
3950 }
3951
3952 #[test]
3953 fn cmd_line_truncates_long_commands_but_never_renders_output() {
3954 let command = "a".repeat(80);
3955 let arguments = serde_json::json!({"command": command}).to_string();
3956 let history = vec![
3957 SessionHistoryRecord::Message {
3958 timestamp: 1,
3959 message: ChatMessage::assistant(
3960 String::new(),
3961 vec![crate::model::ChatToolCall {
3962 id: "call-1".to_owned(),
3963 name: "cmd".to_owned(),
3964 arguments,
3965 }],
3966 ),
3967 },
3968 SessionHistoryRecord::Message {
3969 timestamp: 2,
3970 message: ChatMessage::tool(
3971 "call-1".to_owned(),
3972 "cmd".to_owned(),
3973 serde_json::json!({"exit_code": 0, "stdout": "output"}).to_string(),
3974 ),
3975 },
3976 ];
3977 let state = UiState::from_history(&history, "secret", "model", None, false);
3978 let text = transcript_lines(&state, 200)[0].to_string();
3979 assert!(text.contains(&format!("$ {}…", "a".repeat(50))));
3980 assert!(!text.contains(&"a".repeat(51)));
3981 assert!(!text.contains("output"));
3982 }
3983
3984 #[test]
3985 fn cmd_lines_remain_compact_for_consecutive_calls() {
3986 let history = vec![
3987 SessionHistoryRecord::Message {
3988 timestamp: 1,
3989 message: ChatMessage::assistant(
3990 String::new(),
3991 vec![
3992 crate::model::ChatToolCall {
3993 id: "call-first".to_owned(),
3994 name: "cmd".to_owned(),
3995 arguments: r#"{"command":"first"}"#.to_owned(),
3996 },
3997 crate::model::ChatToolCall {
3998 id: "call-second".to_owned(),
3999 name: "cmd".to_owned(),
4000 arguments: r#"{"command":"second"}"#.to_owned(),
4001 },
4002 ],
4003 ),
4004 },
4005 SessionHistoryRecord::Message {
4006 timestamp: 2,
4007 message: ChatMessage::tool(
4008 "call-first".to_owned(),
4009 "cmd".to_owned(),
4010 serde_json::json!({"exit_code": 0}).to_string(),
4011 ),
4012 },
4013 SessionHistoryRecord::Message {
4014 timestamp: 3,
4015 message: ChatMessage::tool(
4016 "call-second".to_owned(),
4017 "cmd".to_owned(),
4018 serde_json::json!({"exit_code": 0}).to_string(),
4019 ),
4020 },
4021 ];
4022 let state = UiState::from_history(&history, "secret", "model", None, false);
4023 let lines = transcript_lines(&state, 200);
4024 assert_eq!(lines[0].to_string(), "✓ cmd $ first");
4025 assert_eq!(lines[2].to_string(), "✓ cmd $ second");
4026 }
4027
4028 #[test]
4029 fn cmd_status_styles_use_success_failure_and_pending_colors() {
4030 assert_eq!(
4031 cmd_result_status(&serde_json::json!({"exit_code": 0})).2.fg,
4032 Some(Color::Green)
4033 );
4034 assert_eq!(
4035 cmd_result_status(&serde_json::json!({"exit_code": 1})).2.fg,
4036 Some(Color::Red)
4037 );
4038 assert_eq!(
4039 cmd_tool_segments(
4040 "call-1",
4041 "{\"command\":\"pwd\"}",
4042 None,
4043 &UiState::from_history(&[], "secret", "model", None, false)
4044 )[0]
4045 .1
4046 .fg,
4047 Some(PENDING_TOOL_COLOR)
4048 );
4049 }
4050
4051 #[test]
4052 fn recognized_skill_trigger_is_highlighted_but_arguments_remain_default_colored() {
4053 let trigger = active_skill_trigger("/release-notes v1.2.0", &["release-notes".to_owned()]);
4054 assert_eq!(trigger, Some("/release-notes"));
4055
4056 let lines = styled_text_lines(
4057 "/release-notes v1.2.0",
4058 trigger,
4059 80,
4060 Style::default().fg(Color::White),
4061 );
4062 assert_eq!(lines.len(), 1);
4063 assert_eq!(lines[0].to_string(), "/release-notes v1.2.0");
4064 assert_eq!(lines[0].spans[0].content, "/release-notes");
4065 assert_eq!(lines[0].spans[0].style.fg, Some(Color::Cyan));
4066 assert_eq!(lines[0].spans[1].content, " v1.2.0");
4067 assert_eq!(lines[0].spans[1].style.fg, Some(Color::White));
4068 }
4069
4070 #[test]
4071 fn draw_renders_an_active_skill_trigger_in_cyan() {
4072 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4073 .with_skill_names(vec!["release-notes".to_owned()]);
4074 state.input = "/release-notes v1.2.0".to_owned();
4075 state.cursor = state.input.chars().count();
4076
4077 let mut terminal =
4078 Terminal::new(ratatui::backend::TestBackend::new(40, 10)).expect("test terminal");
4079 terminal
4080 .draw(|frame| draw(frame, &state))
4081 .expect("draw input");
4082
4083 let buffer = terminal.backend().buffer();
4086 let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 40, 10)));
4087 let input_x = input_area.x + 1;
4088 let input_y = input_area.y + 1;
4089 assert_eq!(buffer[(input_x, input_y)].fg, Color::Cyan);
4090 assert_eq!(
4091 buffer[(input_x + "/release-notes".chars().count() as u16, input_y)].fg,
4092 Color::White
4093 );
4094 }
4095
4096 #[test]
4097 fn main_agent_status_is_in_the_bottom_status_line_below_the_prompt_gradient() {
4098 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4099 let area = Rect::new(0, 0, 80, 10);
4100 let mut terminal =
4101 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4102 .expect("test terminal");
4103
4104 terminal
4105 .draw(|frame| draw(frame, &state))
4106 .expect("draw ready status");
4107 let (_, _, _, _, input_area, status_area) = ui_layout(&state, tui_viewport(area));
4108 let buffer = terminal.backend().buffer();
4109 let activity_width = UnicodeWidthStr::width(activity_text(&state).as_str()) as u16;
4110 let activity_start = status_area.x
4111 + status_area
4112 .width
4113 .saturating_sub(activity_width)
4114 .saturating_add(1)
4115 / 2;
4116 let rendered_status = (activity_start..activity_start + activity_width)
4117 .map(|x| buffer[(x, status_area.y)].symbol())
4118 .collect::<String>();
4119 assert_eq!(rendered_status, activity_text(&state));
4120 let left_status = (status_area.x..activity_start)
4121 .map(|x| buffer[(x, status_area.y)].symbol())
4122 .collect::<String>();
4123 assert!(left_status.contains("model (default)"));
4124 assert_eq!(
4125 buffer[(activity_start - 1, status_area.y)].symbol(),
4126 " ",
4127 "the READY indicator uses five lowest bars without text"
4128 );
4129 let viewport = tui_viewport(area);
4130 assert_eq!(
4131 input_area.x, viewport.x,
4132 "the prompt begins after the left TUI gutter"
4133 );
4134 assert_eq!(input_area.width, viewport.width);
4135 assert_eq!(input_area.y + input_area.height, status_area.y);
4136 assert_eq!(
4137 buffer[(activity_start, status_area.y)].fg,
4138 activity_style_at(&state, Duration::ZERO)
4139 .fg
4140 .expect("ready status colour")
4141 );
4142
4143 state.input = "first\nsecond\nthird".to_owned();
4146 state.cursor = state.input.chars().count();
4147 terminal
4148 .draw(|frame| draw(frame, &state))
4149 .expect("draw multiline ready status");
4150 let (_, _, _, _, input_area, status_area) = ui_layout(&state, tui_viewport(area));
4151 let buffer = terminal.backend().buffer();
4152 assert!(
4153 input_area.height > 3,
4154 "the prompt should have grown vertically"
4155 );
4156 assert_eq!(
4157 (activity_start..activity_start + activity_width)
4158 .map(|x| buffer[(x, status_area.y)].symbol())
4159 .collect::<String>(),
4160 "▁▁▁▁▁",
4161 "the READY bars remain centered as the prompt grows"
4162 );
4163 assert_eq!(input_area.y + input_area.height, status_area.y);
4164
4165 state.set_status("working");
4166 state.busy = true;
4167 terminal
4168 .draw(|frame| draw(frame, &state))
4169 .expect("draw working status");
4170 let (_, _, _, _, _input_area, status_area) = ui_layout(&state, tui_viewport(area));
4171 let buffer = terminal.backend().buffer();
4172 let activity_width = UnicodeWidthStr::width(activity_text(&state).as_str()) as u16;
4173 let activity_start = status_area.x
4174 + status_area
4175 .width
4176 .saturating_sub(activity_width)
4177 .saturating_add(1)
4178 / 2;
4179 assert_eq!(
4180 buffer[(activity_start, status_area.y)].fg,
4181 activity_style_at(&state, Duration::ZERO)
4182 .fg
4183 .expect("working status colour")
4184 );
4185 assert!(
4186 PULSE_LEVELS.contains(
4187 &buffer[(activity_start, status_area.y)]
4188 .symbol()
4189 .chars()
4190 .next()
4191 .expect("pulse bar")
4192 ),
4193 "the pulse indicator begins at the exact status-line center"
4194 );
4195 }
4196
4197 #[test]
4198 fn cjk_input_keeps_the_terminal_cursor_in_the_prompt_without_resetting_activity() {
4199 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4200 state.set_status("working");
4201 state.busy = true;
4202 state.input = "한글".to_owned();
4203 state.cursor = state.input.chars().count();
4204 let activity_started_at = state.activity_started_at;
4205 let tool_animation_epoch = state.tool_animation_epoch;
4206 let sample_at = Instant::now();
4207 let activity_before = state.activity_levels_at(sample_at);
4208
4209 state.input_changed();
4212 assert_eq!(state.activity_started_at, activity_started_at);
4213 assert_eq!(state.tool_animation_epoch, tool_animation_epoch);
4214 assert_eq!(state.activity_levels_at(sample_at), activity_before);
4215
4216 let area = Rect::new(0, 0, 80, 10);
4217 let mut terminal =
4218 Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
4219 .expect("test terminal");
4220 terminal
4221 .draw(|frame| draw(frame, &state))
4222 .expect("draw CJK input while working");
4223 let (_, _, _, _, input_area, status_area) = ui_layout(&state, tui_viewport(area));
4224 assert_ne!(input_area.y, status_area.y);
4225 terminal.backend_mut().assert_cursor_position((
4226 input_area.x + 1 + UnicodeWidthStr::width(state.input.as_str()) as u16,
4227 input_area.y + 1,
4228 ));
4229 }
4230
4231 #[test]
4232 fn input_border_has_a_teal_to_green_gradient_and_keeps_the_cursor() {
4233 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4234
4235 let mut terminal =
4236 Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
4237 terminal
4238 .draw(|frame| draw(frame, &state))
4239 .expect("draw ready input");
4240
4241 let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 80, 10)));
4242 let buffer = terminal.backend().buffer();
4243 assert_eq!(buffer[(0, input_area.y)].symbol(), " ");
4244 assert_eq!(buffer[(79, input_area.y)].symbol(), " ");
4245 assert_eq!(
4246 buffer[(input_area.x, input_area.y)].fg,
4247 Color::Rgb(
4248 PROMPT_BORDER_START_COLOR.0,
4249 PROMPT_BORDER_START_COLOR.1,
4250 PROMPT_BORDER_START_COLOR.2,
4251 )
4252 );
4253 assert_eq!(
4254 buffer[(
4255 input_area.x + input_area.width.saturating_sub(1),
4256 input_area.y
4257 )]
4258 .fg,
4259 Color::Rgb(
4260 PROMPT_BORDER_END_COLOR.0,
4261 PROMPT_BORDER_END_COLOR.1,
4262 PROMPT_BORDER_END_COLOR.2,
4263 )
4264 );
4265 assert_eq!(
4266 buffer[(input_area.x, input_area.y + 1)].fg,
4267 Color::Rgb(
4268 PROMPT_BORDER_START_COLOR.0,
4269 PROMPT_BORDER_START_COLOR.1,
4270 PROMPT_BORDER_START_COLOR.2,
4271 ),
4272 "the left side uses the teal endpoint"
4273 );
4274 assert_eq!(
4275 buffer[(
4276 input_area.x + input_area.width.saturating_sub(1),
4277 input_area.y + 1,
4278 )]
4279 .fg,
4280 Color::Rgb(
4281 PROMPT_BORDER_END_COLOR.0,
4282 PROMPT_BORDER_END_COLOR.1,
4283 PROMPT_BORDER_END_COLOR.2,
4284 ),
4285 "the right side uses the green endpoint"
4286 );
4287 assert_ne!(
4288 prompt_border_gradient_color_at(input_area.width / 2, input_area.width),
4289 Color::Rgb(
4290 PROMPT_BORDER_START_COLOR.0,
4291 PROMPT_BORDER_START_COLOR.1,
4292 PROMPT_BORDER_START_COLOR.2,
4293 ),
4294 "middle border cells interpolate away from teal"
4295 );
4296 assert_ne!(
4297 prompt_border_gradient_color_at(input_area.width / 2, input_area.width),
4298 Color::Rgb(
4299 PROMPT_BORDER_END_COLOR.0,
4300 PROMPT_BORDER_END_COLOR.1,
4301 PROMPT_BORDER_END_COLOR.2,
4302 ),
4303 "middle border cells interpolate before green"
4304 );
4305 assert_eq!(
4306 buffer[(input_area.x, input_area.y)].symbol(),
4307 "┌",
4308 "the prompt uses square rather than rounded corners"
4309 );
4310 assert_eq!(
4311 buffer[(
4312 input_area.x + input_area.width.saturating_sub(1),
4313 input_area.y + input_area.height.saturating_sub(1),
4314 )]
4315 .symbol(),
4316 "┘"
4317 );
4318 assert_eq!(buffer[(input_area.x + 1, input_area.y + 1)].symbol(), " ");
4319 assert_eq!(input_display_text(&state), "");
4320 terminal
4321 .backend_mut()
4322 .assert_cursor_position((input_area.x + 1, input_area.y + 1));
4323
4324 state.busy = true;
4325 state.set_status("working");
4326 terminal
4327 .draw(|frame| draw(frame, &state))
4328 .expect("draw working input");
4329 let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 80, 10)));
4330 let buffer = terminal.backend().buffer();
4331 assert_eq!(
4332 buffer[(input_area.x, input_area.y)].fg,
4333 Color::Rgb(
4334 PROMPT_BORDER_START_COLOR.0,
4335 PROMPT_BORDER_START_COLOR.1,
4336 PROMPT_BORDER_START_COLOR.2,
4337 ),
4338 "working state preserves the teal endpoint"
4339 );
4340 assert_eq!(
4341 buffer[(
4342 input_area.x + input_area.width.saturating_sub(1),
4343 input_area.y
4344 )]
4345 .fg,
4346 Color::Rgb(
4347 PROMPT_BORDER_END_COLOR.0,
4348 PROMPT_BORDER_END_COLOR.1,
4349 PROMPT_BORDER_END_COLOR.2,
4350 ),
4351 "working state preserves the green endpoint"
4352 );
4353
4354 state.input = "queued message".to_owned();
4355 assert_eq!(input_display_text(&state), "queued message");
4356 }
4357
4358 #[test]
4359 fn only_known_leading_skill_commands_activate_input_highlighting() {
4360 let skills = ["release-notes".to_owned()];
4361 assert_eq!(
4362 active_skill_trigger("/missing", &skills),
4363 None,
4364 "unknown commands are rejected by the turn engine and must not look active"
4365 );
4366 assert_eq!(
4367 active_skill_trigger("/skill:release-notes", &skills),
4368 None,
4369 "the removed /skill: wrapper must not look active"
4370 );
4371 assert_eq!(
4372 active_skill_trigger("write /release-notes", &skills),
4373 None,
4374 "only the command prefix accepted by the turn engine is active"
4375 );
4376 assert_eq!(active_skill_trigger("/", &skills), None);
4377 }
4378
4379 #[test]
4380 fn highlighted_skill_trigger_remains_styled_when_wrapped() {
4381 let input = "/release-notes argument";
4382 let trigger = active_skill_trigger(input, &["release-notes".to_owned()]);
4383 let lines = styled_text_lines(input, trigger, 8, Style::default().fg(Color::White));
4384 let highlighted = lines
4385 .iter()
4386 .flat_map(|line| line.spans.iter())
4387 .filter(|span| span.style.fg == Some(Color::Cyan))
4388 .map(|span| span.content.as_ref())
4389 .collect::<String>();
4390 assert_eq!(highlighted, "/release-notes");
4391 }
4392
4393 #[test]
4394 fn input_has_no_prompt_marker_and_trailing_newline_is_visible() {
4395 assert_eq!(input_prompt("hello"), "hello");
4396 assert_eq!(wrap_text("hello\n", 80), vec!["hello", ""]);
4397 }
4398
4399 #[test]
4400 fn input_prompt_wraps_to_multiple_rows_when_long() {
4401 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4402 state.input = "abcdefghij".to_owned();
4403 let rows = input_visible_rows(&state, 5);
4405 assert!(rows >= 2);
4406 }
4407
4408 #[test]
4409 fn cursor_editing_moves_by_characters_and_preserves_unicode() {
4410 let mut input = "가나".to_owned();
4411 let mut cursor = input.chars().count();
4412 cursor -= 1;
4413 insert_at_cursor(&mut input, &mut cursor, 'x');
4414 assert_eq!(input, "가x나");
4415 assert_eq!(cursor, 2);
4416 assert!(remove_before_cursor(&mut input, &mut cursor));
4417 assert_eq!(input, "가나");
4418 assert_eq!(cursor, 1);
4419 }
4420
4421 #[test]
4422 fn cursor_row_tracks_newlines_and_wrapping() {
4423 assert_eq!(cursor_row("hello\nworld", 6, 80), 1);
4424 assert_eq!(cursor_row("abcdef", 4, 3), 1);
4425 }
4426
4427 #[test]
4428 fn shift_enter_inserts_at_the_cursor_and_moves_it_to_the_new_row() {
4429 let mut input = "beforeafter".to_owned();
4430 let mut cursor = 6;
4431 insert_at_cursor(&mut input, &mut cursor, '\n');
4432
4433 assert_eq!(input, "before\nafter");
4434 assert_eq!(cursor, 7);
4435 assert_eq!(cursor_row(&input, cursor, 80), 1);
4436 }
4437
4438 #[test]
4439 fn shift_enter_renders_the_cursor_on_the_new_input_row() {
4440 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4441 state.input = "beforeafter".to_owned();
4442 state.cursor = 6;
4443 insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
4444
4445 let mut terminal =
4446 Terminal::new(ratatui::backend::TestBackend::new(20, 10)).expect("test terminal");
4447 terminal
4448 .draw(|frame| draw(frame, &state))
4449 .expect("draw input cursor");
4450
4451 let (_, _, _, _, input_area, _) = ui_layout(&state, tui_viewport(Rect::new(0, 0, 20, 10)));
4454 terminal
4455 .backend_mut()
4456 .assert_cursor_position((input_area.x + 1, input_area.y + 2));
4457 }
4458
4459 #[test]
4460 fn tool_results_attach_to_their_matching_call_after_consecutive_calls() {
4461 let history = vec![
4462 SessionHistoryRecord::Message {
4463 timestamp: 1,
4464 message: ChatMessage::assistant(
4465 String::new(),
4466 vec![
4467 crate::model::ChatToolCall {
4468 id: "call-first".to_owned(),
4469 name: "cmd".to_owned(),
4470 arguments: r#"{"command":"first"}"#.to_owned(),
4471 },
4472 crate::model::ChatToolCall {
4473 id: "call-second".to_owned(),
4474 name: "cmd".to_owned(),
4475 arguments: r#"{"command":"second"}"#.to_owned(),
4476 },
4477 ],
4478 ),
4479 },
4480 SessionHistoryRecord::Message {
4481 timestamp: 2,
4482 message: ChatMessage::tool(
4483 "call-first".to_owned(),
4484 "cmd".to_owned(),
4485 serde_json::json!({"stdout":"first result","stderr":""}).to_string(),
4486 ),
4487 },
4488 SessionHistoryRecord::Message {
4489 timestamp: 3,
4490 message: ChatMessage::tool(
4491 "call-second".to_owned(),
4492 "cmd".to_owned(),
4493 serde_json::json!({"stdout":"second result","stderr":""}).to_string(),
4494 ),
4495 },
4496 ];
4497
4498 let state = UiState::from_history(&history, "secret", "model", None, false);
4499 let lines = transcript_lines(&state, 200);
4500 assert_eq!(
4501 lines.len(),
4502 3,
4503 "only the two call lines and their separator remain"
4504 );
4505 assert_eq!(lines[0].to_string(), "✓ cmd $ first");
4506 assert_eq!(lines[2].to_string(), "✓ cmd $ second");
4507 }
4508 #[test]
4509 fn subagent_tasks_keep_metadata_until_completion_then_leave_live_list() {
4510 let history = vec![
4511 SessionHistoryRecord::Message {
4512 timestamp: 1,
4513 message: ChatMessage::assistant(
4514 String::new(),
4515 vec![crate::model::ChatToolCall {
4516 id: "call-worker".to_owned(),
4517 name: "spawn_subagent".to_owned(),
4518 arguments: serde_json::json!({
4519 "task": "Inspect the command UI",
4520 "model": "worker-model",
4521 "effort": "high"
4522 })
4523 .to_string(),
4524 }],
4525 ),
4526 },
4527 SessionHistoryRecord::Message {
4528 timestamp: 2,
4529 message: ChatMessage::tool(
4530 "call-worker".to_owned(),
4531 "spawn_subagent".to_owned(),
4532 serde_json::json!({"task_id":"subagent-1","status":"queued"}).to_string(),
4533 ),
4534 },
4535 ];
4536 let mut state = UiState::from_history(&history, "secret", "model", None, false);
4537 assert_eq!(state.subagents.len(), 1);
4538 let task = &state.subagents[0];
4539 assert_eq!(task.task, "Inspect the command UI");
4540 assert_eq!(task.task_id.as_deref(), Some("subagent-1"));
4541 assert_eq!(task.model.as_deref(), Some("worker-model"));
4542 assert_eq!(task.effort.as_deref(), Some("high"));
4543 assert_eq!(task.status, SubagentStatus::Running);
4544 assert!(transcript_lines(&state, 80)[0]
4545 .to_string()
4546 .contains("↗ subagent Inspect the command UI "));
4547 assert!(!transcript_lines(&state, 80)[0]
4548 .to_string()
4549 .contains("→ running"));
4550
4551 state.complete_subagent(
4552 "subagent-1",
4553 serde_json::json!({"model":"worker-model","output":"finished"}),
4554 );
4555 assert!(
4556 state.subagents.is_empty(),
4557 "completed workers are removed from the live background-task list"
4558 );
4559 let completed_line = transcript_lines(&state, 80)[0].to_string();
4560 assert!(completed_line.contains("Inspect the command UI"));
4561 assert!(completed_line.contains("→ completed"));
4562 assert!(!completed_line.contains("{\"task\""));
4563 }
4564
4565 #[test]
4566 fn resumed_subagent_completion_clears_the_live_list_without_rendering_internal_prompt() {
4567 let history = vec![
4568 SessionHistoryRecord::Message {
4569 timestamp: 1,
4570 message: ChatMessage::assistant(
4571 String::new(),
4572 vec![crate::model::ChatToolCall {
4573 id: "call-worker".to_owned(),
4574 name: "spawn_subagent".to_owned(),
4575 arguments: serde_json::json!({"task":"Inspect"}).to_string(),
4576 }],
4577 ),
4578 },
4579 SessionHistoryRecord::Message {
4580 timestamp: 2,
4581 message: ChatMessage::tool(
4582 "call-worker".to_owned(),
4583 "spawn_subagent".to_owned(),
4584 serde_json::json!({"task_id":"subagent-1","status":"queued"}).to_string(),
4585 ),
4586 },
4587 SessionHistoryRecord::Message {
4588 timestamp: 3,
4589 message: ChatMessage::user(Harness::subagent_notification(
4590 &crate::app::SubagentCompletion {
4591 task_id: "subagent-1".to_owned(),
4592 result: serde_json::json!({"output":"resumed result"}),
4593 },
4594 )),
4595 },
4596 ];
4597 let state = UiState::from_history(&history, "secret", "model", None, true);
4598 assert!(
4599 state.subagents.is_empty(),
4600 "a completed worker is not restored into the live background-task list"
4601 );
4602 assert!(!state.transcript.iter().any(|item| {
4603 matches!(item, TranscriptItem::User { text, .. } if text.contains("Background subagent"))
4604 }));
4605 }
4606
4607 #[test]
4608 fn subagent_overlay_is_upper_right_one_third_wide_and_grows_per_task() {
4609 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4610 for task_id in ["subagent-1", "subagent-2", "subagent-3"] {
4611 state.subagents.push(SubagentTask {
4612 call_id: format!("call-{task_id}"),
4613 task_id: Some(task_id.to_owned()),
4614 task: format!("Inspect {task_id}"),
4615 model: None,
4616 effort: None,
4617 status: SubagentStatus::Queued,
4618 result: None,
4619 });
4620 }
4621
4622 let area = Rect::new(0, 0, 120, 20);
4623 let overlay = subagent_overlay_area(&state, area).expect("subagent overlay");
4624 assert_eq!(overlay, Rect::new(80, 0, 40, 5));
4625
4626 let (chat, _, layout_overlay, _, input, status) = ui_layout(&state, area);
4629 assert_eq!(layout_overlay, Some(overlay));
4630 assert_eq!(chat.height, 16);
4631 assert_eq!(input.y, 16);
4632 assert_eq!(status.y, 19);
4633
4634 let empty = UiState::from_history(&[], "secret", "model", None, false);
4635 assert_eq!(subagent_overlay_area(&empty, area), None);
4636 }
4637
4638 #[test]
4639 fn subagent_overlay_stays_above_the_picker_without_breaking_its_left_border() {
4640 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4641 .with_skill_names(vec!["alpha".to_owned()]);
4642 state.input = "/".to_owned();
4643 for index in 0..14 {
4644 state.subagents.push(SubagentTask {
4645 call_id: format!("call-{index}"),
4646 task_id: Some(format!("subagent-{index}")),
4647 task: format!("Inspect task {index}"),
4648 model: None,
4649 effort: None,
4650 status: SubagentStatus::Queued,
4651 result: None,
4652 });
4653 }
4654
4655 let full_area = Rect::new(0, 0, 120, 20);
4656 let area = tui_viewport(full_area);
4657 let (_, picker_area, overlay_area, _, _, _) = ui_layout(&state, area);
4658 let picker_area = picker_area.expect("skill picker");
4659 let overlay_area = overlay_area.expect("subagent overlay");
4660 assert!(
4661 picker_area.y > overlay_area.y && picker_area.y < overlay_area.y + overlay_area.height,
4662 "the picker must overlap the task overlay for this layering check"
4663 );
4664
4665 let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
4666 full_area.width,
4667 full_area.height,
4668 ))
4669 .expect("test terminal");
4670 terminal
4671 .draw(|frame| draw(frame, &state))
4672 .expect("draw overlapping picker and overlay");
4673
4674 let buffer = terminal.backend().buffer();
4675 assert_eq!(buffer[(overlay_area.x, overlay_area.y)].symbol(), "┌");
4676 assert_eq!(
4677 buffer[(overlay_area.x, picker_area.y)].symbol(),
4678 "│",
4679 "the later picker render must not cut through the overlay left border"
4680 );
4681 assert_eq!(
4682 buffer[(overlay_area.x, picker_area.y)].bg,
4683 SUBAGENT_OVERLAY_BACKGROUND
4684 );
4685 }
4686
4687 #[test]
4688 fn subagent_overlay_uses_magenta_one_line_indicators_without_status_words() {
4689 let mut state = UiState::from_history(&[], "provider-secret", "model", None, false);
4690 state.subagents.push(SubagentTask {
4691 call_id: "call-worker".to_owned(),
4692 task_id: Some("subagent-1".to_owned()),
4693 task: "Inspect the command UI".to_owned(),
4694 model: Some("worker-model".to_owned()),
4695 effort: Some("high".to_owned()),
4696 status: SubagentStatus::Running,
4697 result: None,
4698 });
4699 let full_area = Rect::new(0, 0, 120, 20);
4700 let area = tui_viewport(full_area);
4701 let overlay = subagent_overlay_area(&state, area).expect("subagent overlay");
4702 let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(
4703 full_area.width,
4704 full_area.height,
4705 ))
4706 .expect("test terminal");
4707 terminal
4708 .draw(|frame| draw(frame, &state))
4709 .expect("draw subagent overlay");
4710
4711 assert_eq!(overlay.width, area.width / 3);
4712 assert_eq!(overlay.x + overlay.width, area.x + area.width);
4713 assert_eq!(overlay.y, area.y);
4714 assert_eq!(overlay.height, 3, "one task plus the two border rows");
4715
4716 let buffer = terminal.backend().buffer();
4717 let screen = buffer
4718 .content()
4719 .iter()
4720 .map(|cell| cell.symbol())
4721 .collect::<String>();
4722 assert!(screen.contains("Subagents"));
4723 assert!(screen.contains("Inspect the command UI"));
4724 assert!(!screen.contains("running"));
4725 assert!(!screen.contains("working"));
4726 assert!(!screen.contains("workers"));
4727 assert!(!screen.contains("subagent-1"));
4728 assert!(!screen.contains("worker-model"));
4729 assert!(!screen.contains("provider-secret"));
4730 assert_eq!(
4731 buffer[(overlay.x + 1, overlay.y + 1)].fg,
4732 SUBAGENT_OVERLAY_COLOR
4733 );
4734 assert_eq!(
4735 buffer[(overlay.x + 1, overlay.y + 1)].bg,
4736 SUBAGENT_OVERLAY_BACKGROUND
4737 );
4738 assert_eq!(
4739 buffer[(overlay.x, overlay.y)].bg,
4740 SUBAGENT_OVERLAY_BACKGROUND
4741 );
4742 assert_eq!(
4743 buffer[(overlay.x, overlay.y)].symbol(),
4744 "┌",
4745 "the upper-left corner remains visible above other TUI layers"
4746 );
4747 assert_eq!(
4748 buffer[(overlay.x, overlay.y + 1)].symbol(),
4749 "│",
4750 "the left border remains continuous below the corner"
4751 );
4752 }
4753}
4754
4755#[cfg(test)]
4756mod skill_picker_tests {
4757 use super::*;
4758
4759 fn skill_names() -> Vec<String> {
4760 ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
4761 .into_iter()
4762 .map(str::to_owned)
4763 .collect()
4764 }
4765
4766 #[test]
4767 fn built_in_commands_share_the_slash_catalog_without_becoming_skills() {
4768 assert_eq!(
4769 command_names(vec!["release-notes".to_owned(), "settings".to_owned()]),
4770 vec!["exit", "release-notes", "settings"]
4771 );
4772 assert_eq!(
4773 builtin_command("/settings ignored arguments"),
4774 Some(BuiltinCommand::Settings)
4775 );
4776 assert_eq!(builtin_command(" /exit "), Some(BuiltinCommand::Exit));
4777 assert_eq!(builtin_command("/settings-extra"), None);
4778 }
4779
4780 #[test]
4781 fn settings_viewport_follows_focus_instead_of_truncating_the_catalog_head() {
4782 assert_eq!(selection_range(30, 0, 12), 0..12);
4783 assert_eq!(selection_range(30, 11, 12), 0..12);
4784 assert_eq!(selection_range(30, 12, 12), 1..13);
4785 assert_eq!(selection_range(30, 29, 12), 18..30);
4786 }
4787
4788 #[test]
4789 fn model_selection_uses_advertised_efforts_and_preserves_the_current_choice() {
4790 let mut state = UiState::from_history(&[], "secret", "old", Some("medium"), false);
4791 state.open_catalog(Ok(vec![ProviderModel {
4792 id: "openai/gpt-5.6-sol".to_owned(),
4793 efforts: Some(vec![
4794 "max".to_owned(),
4795 "high".to_owned(),
4796 "medium".to_owned(),
4797 "low".to_owned(),
4798 ]),
4799 }]));
4800 state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
4801
4802 let SettingsState::Effort { model, focus, .. } =
4803 state.settings.as_ref().expect("effort picker")
4804 else {
4805 panic!("model selection should open the effort picker");
4806 };
4807 assert_eq!(model.id, "openai/gpt-5.6-sol");
4808 assert_eq!(*focus, 3, "default occupies index zero before medium");
4809
4810 let selected = state
4811 .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
4812 .expect("effort selection");
4813 assert_eq!(
4814 selected,
4815 ("openai/gpt-5.6-sol".to_owned(), Some("medium".to_owned()))
4816 );
4817 }
4818
4819 #[test]
4820 fn effort_default_selection_does_not_shift_to_the_first_advertised_effort() {
4821 let mut state = UiState::from_history(&[], "secret", "old", None, false);
4822 state.open_catalog(Ok(vec![ProviderModel {
4823 id: "model".to_owned(),
4824 efforts: Some(vec!["high".to_owned(), "low".to_owned()]),
4825 }]));
4826 state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
4827
4828 let selected = state
4829 .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
4830 .expect("default effort selection");
4831 assert_eq!(selected, ("model".to_owned(), None));
4832 }
4833
4834 #[test]
4835 fn reasoning_indicator_changes_to_complete_and_stays_dark_gray() {
4836 let mut state = UiState::from_history(&[], "secret", "model", None, false);
4837 state.show_thinking();
4838
4839 let active_lines = transcript_lines(&state, 80);
4840 let active = active_lines.last().expect("reasoning line");
4841 assert!(active.to_string().starts_with("Reasoning... "));
4842 assert_eq!(active.style.fg, Some(Color::DarkGray));
4843
4844 state.complete_reasoning();
4845 let complete_lines = transcript_lines(&state, 80);
4846 let complete = complete_lines.last().expect("complete line");
4847 assert_eq!(complete.to_string(), "Reasoning Complete");
4848 assert_eq!(complete.style.fg, Some(Color::DarkGray));
4849 }
4850
4851 #[test]
4852 fn slash_picker_filters_only_leading_command_text_and_hides_without_matches() {
4853 let names = skill_names();
4854 assert_eq!(
4855 matching_skill_names("/", &names),
4856 vec!["alpha", "beta", "build", "charlie", "deploy", "doctor"]
4857 );
4858 assert_eq!(matching_skill_names("/b", &names), vec!["beta", "build"]);
4859 assert!(matching_skill_names("/missing", &names).is_empty());
4860 assert!(matching_skill_names("message /b", &names).is_empty());
4861 assert!(matching_skill_names("/beta arguments", &names).is_empty());
4862 }
4863
4864 #[test]
4865 fn slash_picker_focuses_the_top_match_and_moves_within_filtered_results() {
4866 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4867 .with_skill_names(skill_names());
4868 state.input = "/b".to_owned();
4869 state.input_changed();
4870
4871 assert!(state.skill_picker_visible());
4872 assert_eq!(state.skill_picker_focus, 0);
4873 assert!(state.move_skill_picker(true));
4874 assert_eq!(state.skill_picker_focus, 1);
4875 assert!(state.move_skill_picker(true));
4876 assert_eq!(state.skill_picker_focus, 1, "focus does not leave the list");
4877 assert!(state.move_skill_picker(false));
4878 assert_eq!(state.skill_picker_focus, 0);
4879
4880 state.input = "/missing".to_owned();
4881 state.input_changed();
4882 assert!(!state.skill_picker_visible());
4883 assert!(!state.move_skill_picker(true));
4884 }
4885
4886 #[test]
4887 fn focused_builtins_are_distinguished_from_skills() {
4888 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4889 .with_skill_names(command_names(skill_names()));
4890 state.input = "/se".to_owned();
4891 state.input_changed();
4892 assert_eq!(
4893 state.focused_builtin_command(),
4894 Some(BuiltinCommand::Settings)
4895 );
4896
4897 state.input = "/be".to_owned();
4898 state.input_changed();
4899 assert_eq!(state.focused_builtin_command(), None);
4900 }
4901
4902 #[test]
4903 fn selecting_the_focused_skill_leaves_the_completed_command_ready_to_send() {
4904 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4905 .with_skill_names(skill_names());
4906 state.input = "/b".to_owned();
4907 state.input_changed();
4908 state.move_skill_picker(true);
4909
4910 assert!(state.select_focused_skill());
4911 assert_eq!(state.input, "/build");
4912 assert_eq!(state.cursor, "/build".chars().count());
4913 assert!(
4914 !state.skill_picker_visible(),
4915 "the first Enter completes the input rather than sending it"
4916 );
4917 assert!(
4918 !state.select_focused_skill(),
4919 "a second Enter follows the normal send/attachment path"
4920 );
4921 }
4922
4923 #[test]
4924 fn slash_picker_overlays_without_reflowing_the_transcript_when_match_count_changes() {
4925 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4926 .with_skill_names(skill_names());
4927 let area = Rect::new(0, 0, 40, 16);
4928 state.transcript = (0..20)
4929 .map(|index| TranscriptItem::Assistant(format!("message {index}")))
4930 .collect();
4931
4932 state.input = "/a".to_owned();
4933 state.input_changed();
4934 let (narrow_chat, narrow_picker, _, _, narrow_input, _) = ui_layout(&state, area);
4935 let narrow_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
4936
4937 state.input = "/".to_owned();
4938 state.input_changed();
4939 let (broad_chat, broad_picker, _, _, broad_input, _) = ui_layout(&state, area);
4940 let broad_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
4941
4942 assert_ne!(
4943 narrow_picker, broad_picker,
4944 "the overlay may fit its contents"
4945 );
4946 assert_eq!(narrow_chat, broad_chat);
4947 assert_eq!(narrow_input, broad_input);
4948 assert_eq!(
4949 narrow_scroll, broad_scroll,
4950 "the overlay does not reduce the transcript viewport"
4951 );
4952 }
4953
4954 #[test]
4955 fn slash_picker_keeps_the_focused_item_in_its_five_row_viewport() {
4956 assert_eq!(skill_picker_range(20, 0), 0..5);
4957 assert_eq!(skill_picker_range(20, 4), 0..5);
4958 assert_eq!(skill_picker_range(20, 5), 1..6);
4959 assert_eq!(skill_picker_range(20, 19), 15..20);
4960 }
4961
4962 #[test]
4963 fn slash_picker_leaves_the_ready_indicator_in_the_status_line() {
4964 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4965 .with_skill_names(vec!["a".to_owned(), "b".to_owned()]);
4966 state.input = "/".to_owned();
4967 state.input_changed();
4968 let mut terminal =
4969 Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
4970 terminal
4971 .draw(|frame| draw(frame, &state))
4972 .expect("draw TUI");
4973
4974 let screen = terminal
4975 .backend()
4976 .buffer()
4977 .content()
4978 .iter()
4979 .map(|cell| cell.symbol())
4980 .collect::<String>();
4981 assert!(screen.contains("/a"));
4982 assert!(
4983 screen.contains("▁▁▁▁▁"),
4984 "the READY bars remain in the bottom status line below the picker/input"
4985 );
4986 }
4987
4988 #[test]
4989 fn slash_picker_is_rendered_immediately_above_the_input() {
4990 let mut state = UiState::from_history(&[], "secret", "model", None, false)
4991 .with_skill_names(skill_names());
4992 state.input = "/".to_owned();
4993 state.input_changed();
4994 let mut terminal =
4995 Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
4996 terminal
4997 .draw(|frame| draw(frame, &state))
4998 .expect("draw TUI");
4999
5000 let buffer = terminal.backend().buffer();
5001 let area = tui_viewport(Rect::new(0, 0, 40, 12));
5002 let (_, picker_area, _, _, input_area, _) = ui_layout(&state, area);
5003 let picker_area = picker_area.expect("picker area");
5004 assert_eq!(picker_area.y + picker_area.height, input_area.y);
5006 assert_eq!(buffer[(picker_area.x, picker_area.y)].symbol(), "┌");
5007 assert_eq!(
5008 buffer[(picker_area.x, picker_area.y + picker_area.height - 1)].symbol(),
5009 "└"
5010 );
5011 assert_eq!(buffer[(input_area.x, input_area.y)].symbol(), "┌");
5012 assert_eq!(buffer[(picker_area.x + 1, picker_area.y + 1)].symbol(), "[");
5013 assert_eq!(buffer[(picker_area.x + 1, picker_area.y + 2)].symbol(), "/");
5014 assert_eq!(buffer[(picker_area.x, picker_area.y)].fg, Color::Cyan);
5015 }
5016
5017 #[test]
5018 fn slash_picker_renders_count_and_distinct_focus_colors() {
5019 let mut state = UiState::from_history(&[], "secret", "model", None, false)
5020 .with_skill_names(skill_names());
5021 state.input = "/".to_owned();
5022 state.input_changed();
5023 let mut terminal =
5024 Terminal::new(ratatui::backend::TestBackend::new(30, 8)).expect("test terminal");
5025 terminal
5026 .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 8)))
5027 .expect("draw skill picker");
5028
5029 let buffer = terminal.backend().buffer();
5030 assert_eq!(buffer[(0, 0)].symbol(), "┌");
5031 assert_eq!(buffer[(0, 0)].fg, Color::Cyan);
5032 assert_eq!(buffer[(1, 1)].symbol(), "[");
5033 assert_eq!(buffer[(1, 1)].fg, Color::DarkGray);
5034 assert_eq!(buffer[(1, 2)].symbol(), "/");
5035 assert_eq!(buffer[(1, 2)].fg, Color::Cyan);
5036 assert_eq!(buffer[(1, 3)].symbol(), "/");
5037 assert_eq!(buffer[(1, 3)].fg, Color::DarkGray);
5038 }
5039}