1use std::io::{self, Write};
2use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
3use std::thread::{self, JoinHandle};
4use std::time::{Duration, Instant};
5
6use crossterm::cursor::{Hide, Show};
7use crossterm::event::{
8 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
9 KeyModifiers, KeyboardEnhancementFlags, MouseEventKind, PopKeyboardEnhancementFlags,
10 PushKeyboardEnhancementFlags,
11};
12use crossterm::execute;
13use crossterm::terminal::{
14 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
15};
16use ratatui::backend::CrosstermBackend;
17use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect, Size};
18use ratatui::prelude::Frame;
19use ratatui::style::{Color, Style};
20use ratatui::text::{Line, Span};
21use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
22use ratatui::Terminal;
23use serde_json::Value;
24use unicode_width::UnicodeWidthStr;
25
26use crate::app::Harness;
27use crate::cancellation::CancellationToken;
28use crate::model::{estimate_context_tokens, ChatMessage};
29use crate::protocol::{EventSink, ProtocolEvent};
30use crate::provider::ProviderModel;
31use crate::redaction::redact_secret;
32use crate::session::SessionHistoryRecord;
33
34const EVENT_POLL: Duration = Duration::from_millis(50);
35const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
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 PENDING_TOOL_COLOR: Color = Color::Rgb(255, 165, 0);
47const WORKING_GRADIENT_COLORS: [(u8, u8, u8); 4] = [
50 (220, 35, 175), (235, 45, 65), (255, 130, 25), (235, 45, 65), ];
55const WORKING_GRADIENT_CYCLE: Duration = Duration::from_millis(5000);
56const SKILL_PICKER_MAX_ROWS: usize = 5;
57const BUILTIN_COMMANDS: [&str; 2] = ["settings", "exit"];
58const SETTINGS_MIN_WIDTH: u16 = 36;
59const SETTINGS_MAX_WIDTH: u16 = 88;
60const SETTINGS_MIN_HEIGHT: u16 = 8;
61const SETTINGS_MAX_HEIGHT: u16 = 22;
62
63pub(crate) fn run<W: Write>(mut harness: Harness, resumed: bool, stdout: W) -> Result<(), String> {
64 let secret = harness.provider.api_key().to_owned();
65 let context_window = harness
66 .context_window
67 .or_else(|| harness.provider.context_window());
68 harness.context_window = context_window;
69 let context_tokens = estimate_context_tokens(&harness.session.provider_messages());
70 let skill_names = command_names(
71 harness
72 .session
73 .skills
74 .iter()
75 .map(|skill| skill.name.clone())
76 .collect(),
77 );
78 let mut state = UiState::from_history(
79 &harness.session.history,
80 &secret,
81 &harness.session.llm.model,
82 harness.session.llm.effort.as_deref(),
83 resumed,
84 )
85 .with_attached_agents(harness.attached_agents.clone())
86 .with_skill_names(skill_names)
87 .with_context(context_window, context_tokens);
88 let (request_tx, request_rx) = mpsc::channel::<WorkerRequest>();
89 let (message_tx, message_rx) = mpsc::channel::<WorkerMessage>();
90
91 let stdout = stdout;
92 enable_raw_mode().map_err(|error| format!("unable to enable terminal input: {error}"))?;
93 let backend = CrosstermBackend::new(stdout);
94 let terminal = match Terminal::new(backend) {
95 Ok(terminal) => terminal,
96 Err(error) => {
97 let _ = disable_raw_mode();
98 return Err(format!("unable to initialize terminal UI: {error}"));
99 }
100 };
101 let mut terminal_guard = TerminalGuard::new(terminal);
102 let backend = terminal_guard.terminal_mut().backend_mut();
103 if let Err(error) = execute!(backend, EnterAlternateScreen, EnableMouseCapture, Hide) {
104 return Err(format!("unable to enter terminal UI: {error}"));
105 }
106 if supports_keyboard_enhancement() {
111 let _ = execute!(
112 backend,
113 PushKeyboardEnhancementFlags(
114 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
115 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES,
116 )
117 );
118 terminal_guard.keyboard_enhancement = true;
119 }
120 let worker = thread::spawn(move || worker_loop(&mut harness, request_rx, message_tx, resumed));
121
122 let result = event_loop(
123 terminal_guard.terminal_mut(),
124 &mut state,
125 &request_tx,
126 &message_rx,
127 );
128
129 if let Some(token) = state.active_cancel.take() {
130 let _ = token.cancel();
131 }
132 let _ = request_tx.send(WorkerRequest::Shutdown);
133 wait_for_worker(worker, WORKER_SHUTDOWN_GRACE);
134 drop(terminal_guard);
135 result
136}
137
138fn worker_loop(
139 harness: &mut Harness,
140 requests: Receiver<WorkerRequest>,
141 messages: Sender<WorkerMessage>,
142 resumed: bool,
143) {
144 let mut sink = ChannelSink {
145 sender: messages.clone(),
146 };
147 if sink
148 .emit_event(&ProtocolEvent::Session {
149 session_id: harness.session.id.clone(),
150 resumed,
151 })
152 .is_err()
153 {
154 return;
155 }
156
157 loop {
158 if let Some(notification) = harness.next_subagent_notification() {
159 let cancel = CancellationToken::new();
160 let _ = messages.send(WorkerMessage::Started {
161 cancel: cancel.clone(),
162 user_text: None,
163 });
164 if let Err(error) = harness.handle_message(¬ification, &mut sink, Some(&cancel)) {
165 let message = redact_secret(&error, Some(harness.provider.api_key()));
166 let _ = sink.emit_event(&ProtocolEvent::Error { message });
167 }
168 let _ = messages.send(WorkerMessage::Finished);
169 continue;
170 }
171 let request = match requests.recv_timeout(EVENT_POLL) {
172 Ok(request) => request,
173 Err(mpsc::RecvTimeoutError::Timeout) => {
174 if let Some(notification) = harness.next_subagent_notification() {
175 let cancel = CancellationToken::new();
176 let _ = messages.send(WorkerMessage::Started {
177 cancel: cancel.clone(),
178 user_text: None,
179 });
180 if let Err(error) =
181 harness.handle_message(¬ification, &mut sink, Some(&cancel))
182 {
183 let message = redact_secret(&error, Some(harness.provider.api_key()));
184 let _ = sink.emit_event(&ProtocolEvent::Error { message });
185 }
186 let _ = messages.send(WorkerMessage::Finished);
187 }
188 continue;
189 }
190 Err(mpsc::RecvTimeoutError::Disconnected) => break,
191 };
192 match request {
193 WorkerRequest::Turn { text } => {
194 let cancel = CancellationToken::new();
195 let _ = messages.send(WorkerMessage::Started {
196 cancel: cancel.clone(),
197 user_text: Some(text.clone()),
198 });
199 if let Err(error) = harness.handle_message(&text, &mut sink, Some(&cancel)) {
200 let message = redact_secret(&error, Some(harness.provider.api_key()));
201 let _ = sink.emit_event(&ProtocolEvent::Error { message });
202 }
203 let _ = messages.send(WorkerMessage::Finished);
204 }
205 WorkerRequest::Catalog => {
206 let _ = messages.send(WorkerMessage::Catalog(
207 harness.provider.models().map_err(|error| error.to_string()),
208 ));
209 }
210 WorkerRequest::ApplySettings { model, effort } => {
211 let result = harness.apply_settings(&harness.home.clone(), model, effort);
212 let _ = messages.send(WorkerMessage::SettingsApplied(
213 result,
214 harness.session.llm.model.clone(),
215 harness.session.llm.effort.clone(),
216 harness.context_window,
217 ));
218 }
219 WorkerRequest::Shutdown => break,
220 }
221 }
222}
223
224fn event_loop<W: Write>(
225 terminal: &mut Terminal<CrosstermBackend<W>>,
226 state: &mut UiState,
227 requests: &Sender<WorkerRequest>,
228 messages: &Receiver<WorkerMessage>,
229) -> Result<(), String> {
230 let mut quitting = false;
231 loop {
232 loop {
233 match messages.try_recv() {
234 Ok(WorkerMessage::Event(event)) => state.apply_event(event),
235 Ok(WorkerMessage::Started { cancel, user_text }) => {
236 if let Some(text) = user_text {
237 state.start_queued_user(&text);
238 }
239 state.active_cancel = Some(cancel);
240 state.busy = true;
241 state.status = "working".to_owned();
242 }
243 Ok(WorkerMessage::Thinking) => state.show_thinking(),
244 Ok(WorkerMessage::ReasoningCompleted) => state.complete_reasoning(),
245 Ok(WorkerMessage::SkillInstructionAttached) => {
246 state.mark_latest_user_skill_attached()
247 }
248 Ok(WorkerMessage::ContextUsage(tokens)) => state.context_tokens = tokens,
249 Ok(WorkerMessage::CompactionStarted) => state.status = "compacting".to_owned(),
250 Ok(WorkerMessage::CompactionFinished {
251 tokens_before,
252 tokens_after,
253 }) => {
254 state.context_tokens = tokens_after;
255 state.status = "working".to_owned();
256 state.transcript.push(TranscriptItem::Info(format!(
257 "↻ context compacted ({} → {})",
258 format_context_tokens(tokens_before),
259 format_context_tokens(tokens_after)
260 )));
261 }
262 Ok(WorkerMessage::Catalog(result)) => state.open_catalog(result),
263 Ok(WorkerMessage::SettingsApplied(result, model, effort, context_window)) => {
264 state.settings_applied(result, model, effort, context_window)
265 }
266 Ok(WorkerMessage::Finished) => {
267 release_finished_turn(terminal.backend_mut(), state);
268 match state.status.as_str() {
269 "cancelling" => state.status = "사용자 중단".to_owned(),
270 "finalizing" => state.status = "ready".to_owned(),
271 _ => {}
272 }
273 if quitting {
274 return Ok(());
275 }
276 }
277 Err(TryRecvError::Empty) => break,
278 Err(TryRecvError::Disconnected) => {
279 if state.busy {
280 return Err("TUI worker stopped unexpectedly".to_owned());
281 }
282 return Ok(());
283 }
284 }
285 }
286
287 terminal
288 .draw(|frame| draw(frame, state))
289 .map_err(|error| format!("unable to render TUI: {error}"))?;
290
291 if quitting {
292 thread::sleep(EVENT_POLL);
293 continue;
294 }
295 if event::poll(EVENT_POLL)
296 .map_err(|error| format!("unable to read terminal input: {error}"))?
297 {
298 let event =
299 event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
300 if let Event::Mouse(mouse) = event {
301 let size = terminal
302 .size()
303 .map_err(|error| format!("unable to read terminal size: {error}"))?;
304 let max_scroll = max_scroll_for_area(state, size);
305 handle_mouse_event(state, mouse.kind, max_scroll);
306 continue;
307 }
308 let Event::Key(key) = event else {
309 continue;
310 };
311 if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
312 continue;
313 }
314 if is_ctrl_c(&key) {
315 if let Some(token) = state.active_cancel.as_ref() {
316 let _ = token.cancel();
317 quitting = true;
318 } else {
319 return Ok(());
320 }
321 continue;
322 }
323 if !state.busy && state.settings.is_some() {
324 if let Some((model, effort)) = state.handle_settings_key(&key) {
325 state.settings = Some(SettingsState::Applying {
326 model: model.clone(),
327 effort: effort.clone(),
328 });
329 requests
330 .send(WorkerRequest::ApplySettings { model, effort })
331 .map_err(|_| "TUI worker is unavailable".to_owned())?;
332 }
333 continue;
334 }
335 if key.code == KeyCode::Esc {
336 if let Some(token) = state.active_cancel.as_ref() {
337 if token.cancel() {
338 state.status = "cancelling".to_owned();
339 }
340 }
341 continue;
342 }
343 match key.code {
344 KeyCode::Enter => {
345 if key.modifiers.contains(KeyModifiers::SHIFT)
350 || key.modifiers.contains(KeyModifiers::ALT)
351 {
352 if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
353 insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
354 state.input_changed();
355 }
356 continue;
357 }
358 let text = if let Some(command) = state.focused_builtin_command() {
361 state.input.clear();
362 format!("/{}", command.name())
363 } else {
364 if state.select_focused_skill() {
365 continue;
366 }
367 std::mem::take(&mut state.input)
368 };
369 state.cursor = 0;
370 if let Some(command) = builtin_command(&text) {
371 state.reset_skill_picker();
372 if state.busy {
373 state.transcript.push(TranscriptItem::Info(format!(
374 "/{} is available when the current turn finishes",
375 command.name()
376 )));
377 continue;
378 }
379 match command {
380 BuiltinCommand::Settings => {
381 state.settings = Some(SettingsState::Loading);
382 requests
383 .send(WorkerRequest::Catalog)
384 .map_err(|_| "TUI worker is unavailable".to_owned())?;
385 continue;
386 }
387 BuiltinCommand::Exit => return Ok(()),
388 }
389 }
390 state.reset_skill_picker();
391 if text.trim().is_empty() {
392 continue;
393 }
394 state.auto_scroll = true;
395 state.scroll = 0;
396 state.queue_user(&text);
397 state.busy = true;
398 state.status = "working".to_owned();
399 requests
400 .send(WorkerRequest::Turn { text })
401 .map_err(|_| "TUI worker is unavailable".to_owned())?;
402 }
403 KeyCode::Tab => {
404 state.select_focused_skill();
407 }
408 KeyCode::Char(character) => {
409 if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
410 insert_at_cursor(&mut state.input, &mut state.cursor, character);
411 state.input_changed();
412 }
413 }
414 KeyCode::Backspace => {
415 if remove_before_cursor(&mut state.input, &mut state.cursor) {
416 state.input_changed();
417 }
418 }
419 KeyCode::Left => {
420 state.cursor = state.cursor.saturating_sub(1);
421 state.cursor_epoch = Instant::now();
422 }
423 KeyCode::Right => {
424 state.cursor = (state.cursor + 1).min(state.input.chars().count());
425 state.cursor_epoch = Instant::now();
426 }
427 KeyCode::Home => {
428 state.cursor = 0;
429 state.cursor_epoch = Instant::now();
430 }
431 KeyCode::End => {
432 state.cursor = state.input.chars().count();
433 state.cursor_epoch = Instant::now();
434 }
435 KeyCode::Up => {
436 if !state.move_skill_picker(false) {
437 let size = terminal
438 .size()
439 .map_err(|error| format!("unable to read terminal size: {error}"))?;
440 let max_scroll = max_scroll_for_area(state, size);
441 scroll_up(state, max_scroll);
442 }
443 }
444 KeyCode::Down => {
445 if !state.move_skill_picker(true) {
446 let size = terminal
447 .size()
448 .map_err(|error| format!("unable to read terminal size: {error}"))?;
449 let max_scroll = max_scroll_for_area(state, size);
450 scroll_down(state, max_scroll);
451 }
452 }
453 KeyCode::PageUp => {
454 let size = terminal
455 .size()
456 .map_err(|error| format!("unable to read terminal size: {error}"))?;
457 let max_scroll = max_scroll_for_area(state, size);
458 scroll_up(state, max_scroll);
459 }
460 KeyCode::PageDown => {
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_down(state, max_scroll);
466 }
467 _ => {}
468 }
469 }
470 }
471}
472
473fn is_ctrl_c(key: &KeyEvent) -> bool {
474 key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
475}
476
477fn handle_mouse_event(state: &mut UiState, kind: MouseEventKind, max_scroll: u16) {
478 match kind {
479 MouseEventKind::ScrollUp => scroll_up(state, max_scroll),
480 MouseEventKind::ScrollDown => scroll_down(state, max_scroll),
481 _ => {}
482 }
483}
484
485fn scroll_up(state: &mut UiState, max_scroll: u16) {
486 if state.auto_scroll {
487 state.scroll = max_scroll;
488 state.auto_scroll = false;
489 } else {
490 state.scroll = state.scroll.min(max_scroll);
491 }
492 state.scroll = state.scroll.saturating_sub(3);
493}
494
495fn scroll_down(state: &mut UiState, max_scroll: u16) {
496 if state.auto_scroll {
497 return;
498 }
499 state.scroll = state.scroll.saturating_add(3).min(max_scroll);
500 if state.scroll == max_scroll {
501 state.auto_scroll = true;
504 state.scroll = 0;
505 }
506}
507
508fn wait_for_worker(worker: JoinHandle<()>, grace: Duration) {
509 let deadline = std::time::Instant::now() + grace;
510 while !worker.is_finished() && std::time::Instant::now() < deadline {
511 thread::sleep(Duration::from_millis(5));
512 }
513 if worker.is_finished() {
514 let _ = worker.join();
515 }
516}
517
518struct TerminalGuard<W: Write> {
519 terminal: Option<Terminal<CrosstermBackend<W>>>,
520 keyboard_enhancement: bool,
521}
522
523impl<W: Write> TerminalGuard<W> {
524 fn new(terminal: Terminal<CrosstermBackend<W>>) -> Self {
525 Self {
526 terminal: Some(terminal),
527 keyboard_enhancement: false,
528 }
529 }
530
531 fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {
532 self.terminal
533 .as_mut()
534 .expect("terminal guard is initialized")
535 }
536}
537
538impl<W: Write> Drop for TerminalGuard<W> {
539 fn drop(&mut self) {
540 let Some(mut terminal) = self.terminal.take() else {
541 return;
542 };
543 if self.keyboard_enhancement {
544 let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
545 }
546 let _ = terminal.show_cursor();
547 let _ = disable_raw_mode();
548 let _ = execute!(
549 terminal.backend_mut(),
550 DisableMouseCapture,
551 LeaveAlternateScreen,
552 Show
553 );
554 let _ = terminal.backend_mut().flush();
555 }
556}
557
558fn supports_keyboard_enhancement() -> bool {
563 fn env(name: &str) -> Option<String> {
564 std::env::var(name).ok().map(|value| value.to_lowercase())
565 }
566 let term = env("TERM").unwrap_or_default();
567 let program = env("TERM_PROGRAM").unwrap_or_default();
568 if term.starts_with("xterm-kitty")
569 || term.starts_with("ghostty")
570 || term.starts_with("xterm-ghostty")
571 {
572 return true;
573 }
574 matches!(
575 program.as_str(),
576 "ghostty" | "kitty" | "wezterm" | "alacritty" | "foot" | "footclient" | "iterm.app"
577 )
578}
579
580#[derive(Debug, Clone, Copy, PartialEq, Eq)]
581enum TurnNotification {
582 Completed,
583 Interrupted,
584 Failed,
585}
586
587impl TurnNotification {
588 fn body(self) -> &'static str {
589 match self {
590 Self::Completed => "Turn complete",
591 Self::Interrupted => "Turn interrupted",
592 Self::Failed => "Turn failed",
593 }
594 }
595}
596
597fn turn_notification_for_status(status: &str) -> TurnNotification {
598 match status {
599 "cancelling" | "사용자 중단" => TurnNotification::Interrupted,
600 "error" => TurnNotification::Failed,
601 _ => TurnNotification::Completed,
602 }
603}
604
605fn send_turn_notification<W: Write>(
611 writer: &mut W,
612 notification: TurnNotification,
613) -> io::Result<()> {
614 writer.write_all(b"\x1b]777;notify;Lucy;")?;
615 writer.write_all(notification.body().as_bytes())?;
616 writer.write_all(b"\x07")?;
617 writer.flush()
618}
619
620fn release_finished_turn<W: Write>(writer: &mut W, state: &mut UiState) {
621 let was_busy = state.busy;
622 let notification = turn_notification_for_status(&state.status);
623 state.busy = false;
624 state.active_cancel = None;
625 if was_busy {
626 let _ = send_turn_notification(writer, notification);
629 }
630}
631
632enum WorkerRequest {
633 Turn {
634 text: String,
635 },
636 Catalog,
637 ApplySettings {
638 model: String,
639 effort: Option<String>,
640 },
641 Shutdown,
642}
643
644enum WorkerMessage {
645 Event(ProtocolEvent),
646 Started {
647 cancel: CancellationToken,
648 user_text: Option<String>,
649 },
650 Thinking,
651 ReasoningCompleted,
652 SkillInstructionAttached,
653 ContextUsage(usize),
654 CompactionStarted,
655 CompactionFinished {
656 tokens_before: usize,
657 tokens_after: usize,
658 },
659 Catalog(Result<Vec<ProviderModel>, String>),
660 SettingsApplied(Result<(), String>, String, Option<String>, Option<usize>),
661 Finished,
662}
663
664struct ChannelSink {
665 sender: Sender<WorkerMessage>,
666}
667
668impl EventSink for ChannelSink {
669 fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
670 self.sender
671 .send(WorkerMessage::Event(event.clone()))
672 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
673 }
674
675 fn reasoning_started(&mut self) -> io::Result<()> {
676 self.sender
677 .send(WorkerMessage::Thinking)
678 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
679 }
680
681 fn reasoning_completed(&mut self) -> io::Result<()> {
682 self.sender
683 .send(WorkerMessage::ReasoningCompleted)
684 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
685 }
686
687 fn skill_instruction_attached(&mut self, _name: &str) -> io::Result<()> {
688 self.sender
689 .send(WorkerMessage::SkillInstructionAttached)
690 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
691 }
692
693 fn context_usage(&mut self, tokens: usize) -> io::Result<()> {
694 self.sender
695 .send(WorkerMessage::ContextUsage(tokens))
696 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
697 }
698
699 fn compaction_started(&mut self) -> io::Result<()> {
700 self.sender
701 .send(WorkerMessage::CompactionStarted)
702 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
703 }
704
705 fn compaction_finished(&mut self, tokens_before: usize, tokens_after: usize) -> io::Result<()> {
706 self.sender
707 .send(WorkerMessage::CompactionFinished {
708 tokens_before,
709 tokens_after,
710 })
711 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
712 }
713}
714
715struct UiState {
716 model: String,
717 effort: Option<String>,
718 context_window: Option<usize>,
719 context_tokens: usize,
720 secret: String,
721 transcript: Vec<TranscriptItem>,
722 queued_messages: Vec<String>,
723 input: String,
724 cursor: usize,
725 status: String,
726 busy: bool,
727 active_cancel: Option<CancellationToken>,
728 scroll: u16,
729 auto_scroll: bool,
730 cursor_epoch: Instant,
731 welcome_visible: bool,
732 attached_agents: Vec<String>,
733 skill_names: Vec<String>,
734 skill_picker_focus: usize,
735 skill_picker_suppressed: bool,
736 settings: Option<SettingsState>,
737}
738
739impl UiState {
740 fn from_history(
741 history: &[SessionHistoryRecord],
742 secret: &str,
743 model: &str,
744 effort: Option<&str>,
745 resumed: bool,
746 ) -> Self {
747 let mut state = Self {
748 model: model.to_owned(),
749 effort: effort.map(str::to_owned),
750 context_window: None,
751 context_tokens: 1,
752 secret: secret.to_owned(),
753 transcript: Vec::new(),
754 queued_messages: Vec::new(),
755 input: String::new(),
756 cursor: 0,
757 status: "ready".to_owned(),
758 busy: false,
759 active_cancel: None,
760 scroll: 0,
761 auto_scroll: true,
762 cursor_epoch: Instant::now(),
763 welcome_visible: !resumed && history.is_empty(),
764 attached_agents: Vec::new(),
765 skill_names: Vec::new(),
766 skill_picker_focus: 0,
767 skill_picker_suppressed: false,
768 settings: None,
769 };
770 for record in history {
771 state.add_history_record(record);
772 }
773 state
774 }
775
776 fn with_attached_agents(mut self, attached_agents: Vec<String>) -> Self {
777 self.attached_agents = attached_agents;
778 self
779 }
780
781 fn with_skill_names(mut self, skill_names: Vec<String>) -> Self {
782 self.skill_names = skill_names;
783 self
784 }
785
786 fn with_context(mut self, context_window: Option<usize>, context_tokens: usize) -> Self {
787 self.context_window = context_window;
788 self.context_tokens = context_tokens.max(1);
789 self
790 }
791
792 fn matching_skill_names(&self) -> Vec<&str> {
795 matching_skill_names(&self.input, &self.skill_names)
796 }
797
798 fn reset_skill_picker(&mut self) {
799 self.skill_picker_focus = 0;
800 self.skill_picker_suppressed = false;
801 }
802
803 fn skill_picker_visible(&self) -> bool {
804 !self.skill_picker_suppressed && !self.matching_skill_names().is_empty()
805 }
806
807 fn input_changed(&mut self) {
808 self.reset_skill_picker();
809 self.cursor_epoch = Instant::now();
810 }
811
812 fn move_skill_picker(&mut self, down: bool) -> bool {
816 let match_count = self.matching_skill_names().len();
817 if self.skill_picker_suppressed || match_count == 0 {
818 return false;
819 }
820 if down {
821 self.skill_picker_focus = (self.skill_picker_focus + 1).min(match_count - 1);
822 } else {
823 self.skill_picker_focus = self.skill_picker_focus.saturating_sub(1);
824 }
825 true
826 }
827
828 fn focused_builtin_command(&self) -> Option<BuiltinCommand> {
834 let name = *self.matching_skill_names().get(self.skill_picker_focus)?;
835 builtin_command(&format!("/{name}"))
836 }
837
838 fn select_focused_skill(&mut self) -> bool {
839 if self.skill_picker_suppressed {
840 return false;
841 }
842 let Some(name) = self
843 .matching_skill_names()
844 .get(self.skill_picker_focus)
845 .map(|name| (*name).to_owned())
846 else {
847 return false;
848 };
849 self.input = format!("/{name}");
850 self.cursor = self.input.chars().count();
851 self.skill_picker_suppressed = true;
854 self.cursor_epoch = Instant::now();
855 true
856 }
857
858 fn open_catalog(&mut self, result: Result<Vec<ProviderModel>, String>) {
859 self.settings = Some(match result {
860 Ok(models) => {
861 let focus = models
862 .iter()
863 .position(|model| model.id == self.model)
864 .unwrap_or(0);
865 SettingsState::Models {
866 models,
867 query: String::new(),
868 focus,
869 }
870 }
871 Err(error) => SettingsState::Error(error),
872 });
873 }
874 fn settings_applied(
875 &mut self,
876 result: Result<(), String>,
877 model: String,
878 effort: Option<String>,
879 context_window: Option<usize>,
880 ) {
881 match result {
882 Ok(()) => {
883 self.model = model;
884 self.effort = effort;
885 self.context_window = context_window;
886 self.settings = None;
887 self.transcript
888 .push(TranscriptItem::Info("⚙ settings applied".to_owned()));
889 }
890 Err(error) => self.settings = Some(SettingsState::Error(error)),
891 }
892 }
893 fn handle_settings_key(&mut self, key: &KeyEvent) -> Option<(String, Option<String>)> {
894 let current_effort = self.effort.clone();
895 match self.settings.as_mut()? {
896 SettingsState::Loading => {
897 if key.code == KeyCode::Esc {
898 self.settings = None;
899 }
900 }
901 SettingsState::Applying { .. } => {}
902 SettingsState::Error(_) => {
903 if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
904 self.settings = None;
905 }
906 }
907 SettingsState::Models {
908 models,
909 query,
910 focus,
911 } => match key.code {
912 KeyCode::Esc => self.settings = None,
913 KeyCode::Char(c) => {
914 query.push(c);
915 *focus = 0;
916 }
917 KeyCode::Backspace => {
918 query.pop();
919 *focus = 0;
920 }
921 KeyCode::Up => *focus = focus.saturating_sub(1),
922 KeyCode::Down => {
923 let n = models
924 .iter()
925 .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
926 .count();
927 *focus = (*focus + 1).min(n.saturating_sub(1));
928 }
929 KeyCode::Enter => {
930 let selected = models
931 .iter()
932 .filter(|m| m.id.to_lowercase().contains(&query.to_lowercase()))
933 .nth(*focus)
934 .cloned();
935 if let Some(model) = selected {
936 let focus = model
937 .efforts
938 .as_ref()
939 .and_then(|efforts| {
940 current_effort.as_ref().and_then(|current| {
941 efforts.iter().position(|effort| effort == current)
942 })
943 })
944 .map_or(0, |index| index + 1);
945 self.settings = Some(SettingsState::Effort {
946 model,
947 input: current_effort.unwrap_or_default(),
948 focus,
949 });
950 }
951 }
952 _ => {}
953 },
954 SettingsState::Effort {
955 model,
956 input,
957 focus,
958 } => match key.code {
959 KeyCode::Esc => self.settings = None,
960 KeyCode::Char(c) if model.efforts.is_none() => input.push(c),
961 KeyCode::Backspace if model.efforts.is_none() => {
962 input.pop();
963 }
964 KeyCode::Up => *focus = focus.saturating_sub(1),
965 KeyCode::Down => {
966 if let Some(efforts) = &model.efforts {
967 *focus = (*focus + 1).min(efforts.len());
968 }
969 }
970 KeyCode::Enter => {
971 let effort = match &model.efforts {
972 Some(efforts) => {
973 if *focus == 0 {
974 None
975 } else {
976 efforts.get(focus.saturating_sub(1)).cloned()
977 }
978 }
979 None => (!input.trim().is_empty()).then(|| input.trim().to_owned()),
980 };
981 return Some((model.id.clone(), effort));
982 }
983 _ => {}
984 },
985 };
986 None
987 }
988
989 fn add_history_record(&mut self, record: &SessionHistoryRecord) {
990 match record {
991 SessionHistoryRecord::ProviderSettings { model, effort, .. } => {
992 self.transcript.push(TranscriptItem::Info(format!(
993 "⚙ model={model} · effort={}",
994 effort.as_deref().unwrap_or("default")
995 )))
996 }
997 SessionHistoryRecord::Message { message, .. } => self.add_message(message),
998 SessionHistoryRecord::Interruption {
999 assistant_text,
1000 tool_calls,
1001 tool_results,
1002 reason,
1003 phase,
1004 ..
1005 } => {
1006 if !assistant_text.is_empty() {
1007 self.add_assistant_message(assistant_text);
1008 }
1009 for call in tool_calls {
1010 self.add_tool_call(call);
1011 }
1012 for observation in tool_results {
1013 self.add_tool_result(
1014 &observation.id,
1015 &observation.name,
1016 observation.result.clone(),
1017 );
1018 }
1019 self.transcript
1020 .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1021 }
1022 SessionHistoryRecord::Compaction(compaction) => {
1023 self.transcript.push(TranscriptItem::Info(format!(
1024 "↻ context compacted ({} before)",
1025 format_context_tokens(compaction.tokens_before)
1026 )));
1027 }
1028 }
1029 }
1030
1031 fn add_message(&mut self, message: &ChatMessage) {
1032 match message.role.as_str() {
1033 "user" => {
1034 let secret = self.secret.clone();
1035 self.add_user(message.content.as_deref().unwrap_or(""), &secret);
1036 }
1037 "assistant" => {
1038 if let Some(content) = message.content.as_deref() {
1039 self.add_assistant_message(content);
1040 }
1041 for call in &message.tool_calls {
1042 self.add_tool_call(call);
1043 }
1044 }
1045 "tool" => {
1046 let result = message
1047 .content
1048 .as_deref()
1049 .and_then(|content| serde_json::from_str::<Value>(content).ok())
1050 .unwrap_or_else(|| Value::String(message.content.clone().unwrap_or_default()));
1051 self.add_tool_result(
1052 message.tool_call_id.as_deref().unwrap_or(""),
1053 message.name.as_deref().unwrap_or("cmd"),
1054 result,
1055 );
1056 }
1057 _ => {}
1058 }
1059 }
1060
1061 fn queue_user(&mut self, text: &str) {
1062 self.queued_messages
1063 .push(redact_secret(text, Some(&self.secret)));
1064 }
1065
1066 fn start_queued_user(&mut self, text: &str) {
1067 let safe = redact_secret(text, Some(&self.secret));
1068 if self.queued_messages.first() == Some(&safe) {
1069 self.queued_messages.remove(0);
1070 } else if let Some(index) = self
1071 .queued_messages
1072 .iter()
1073 .position(|queued| queued == &safe)
1074 {
1075 self.queued_messages.remove(index);
1076 }
1077 self.add_user(text, &self.secret.clone());
1078 }
1079
1080 fn add_user(&mut self, text: &str, secret: &str) {
1081 self.welcome_visible = false;
1082 self.transcript.push(TranscriptItem::User {
1083 text: redact_secret(text, Some(secret)),
1084 skill_instruction_attached: false,
1085 });
1086 }
1087
1088 fn mark_latest_user_skill_attached(&mut self) {
1089 if let Some(TranscriptItem::User {
1090 skill_instruction_attached,
1091 ..
1092 }) = self.transcript.last_mut()
1093 {
1094 *skill_instruction_attached = true;
1095 }
1096 }
1097
1098 fn clear_thinking(&mut self) {
1099 if matches!(
1100 self.transcript.last(),
1101 Some(TranscriptItem::Reasoning { complete: false })
1102 ) {
1103 self.transcript.pop();
1104 }
1105 }
1106
1107 fn show_thinking(&mut self) {
1108 self.status = "working".to_owned();
1109 if !matches!(
1110 self.transcript.last(),
1111 Some(TranscriptItem::Reasoning { complete: false })
1112 ) {
1113 self.transcript
1114 .push(TranscriptItem::Reasoning { complete: false });
1115 }
1116 }
1117
1118 fn complete_reasoning(&mut self) {
1119 if let Some(TranscriptItem::Reasoning { complete }) = self.transcript.last_mut() {
1120 *complete = true;
1121 }
1122 }
1123
1124 fn add_assistant(&mut self, text: &str) {
1125 self.clear_thinking();
1126 if let Some(TranscriptItem::Assistant(current)) = self.transcript.last_mut() {
1127 current.push_str(text);
1128 } else {
1129 self.add_assistant_message(text);
1130 }
1131 }
1132
1133 fn add_assistant_message(&mut self, text: &str) {
1134 self.transcript
1135 .push(TranscriptItem::Assistant(text.to_owned()));
1136 }
1137
1138 fn add_tool_call(&mut self, call: &crate::model::ChatToolCall) {
1139 self.clear_thinking();
1140 self.transcript.push(TranscriptItem::ToolCall {
1141 id: call.id.clone(),
1142 name: call.name.clone(),
1143 arguments: call.arguments.clone(),
1144 });
1145 }
1146
1147 fn add_tool_result(&mut self, id: &str, name: &str, result: Value) {
1148 self.transcript.push(TranscriptItem::ToolResult {
1149 id: id.to_owned(),
1150 name: name.to_owned(),
1151 result,
1152 });
1153 }
1154
1155 fn cursor_visible(&self) -> bool {
1156 (self.cursor_epoch.elapsed().as_millis() / CURSOR_BLINK_INTERVAL.as_millis())
1157 .is_multiple_of(2)
1158 }
1159
1160 fn apply_event(&mut self, event: ProtocolEvent) {
1161 match event {
1162 ProtocolEvent::Session { .. } => {}
1163 ProtocolEvent::AssistantDelta { text } => self.add_assistant(&text),
1164 ProtocolEvent::ToolCall {
1165 id,
1166 name,
1167 arguments,
1168 } => self.add_tool_call(&crate::model::ChatToolCall {
1169 id,
1170 name,
1171 arguments,
1172 }),
1173 ProtocolEvent::ToolResult { id, name, result } => {
1174 self.add_tool_result(&id, &name, result)
1175 }
1176 ProtocolEvent::TurnEnd => {
1177 self.complete_reasoning();
1178 self.status = "finalizing".to_owned();
1179 self.transcript
1180 .push(TranscriptItem::Info("✓ turn complete".to_owned()));
1181 }
1182 ProtocolEvent::TurnInterrupted { reason, phase } => {
1183 self.complete_reasoning();
1184 self.status = "cancelling".to_owned();
1185 self.transcript
1186 .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
1187 }
1188 ProtocolEvent::Error { message } => {
1189 self.complete_reasoning();
1190 self.status = "error".to_owned();
1191 self.transcript.push(TranscriptItem::Error(message));
1192 }
1193 }
1194 }
1195}
1196
1197#[derive(Debug, Clone, PartialEq)]
1198enum TranscriptItem {
1199 User {
1200 text: String,
1201 skill_instruction_attached: bool,
1202 },
1203 Assistant(String),
1204 ToolCall {
1205 id: String,
1206 name: String,
1207 arguments: String,
1208 },
1209 ToolResult {
1210 id: String,
1211 name: String,
1212 result: Value,
1213 },
1214 Error(String),
1215 Info(String),
1216 Reasoning {
1217 complete: bool,
1218 },
1219}
1220
1221fn ui_layout(state: &UiState, area: Rect) -> (Rect, Option<Rect>, Rect, Rect) {
1222 let input_rows = input_visible_rows(state, area.width.saturating_sub(2));
1223 let input_height = input_rows.clamp(1, MAX_INPUT_ROWS) + 2;
1224 let chunks = Layout::default()
1225 .direction(Direction::Vertical)
1226 .constraints([
1227 Constraint::Min(1),
1228 Constraint::Length(input_height),
1229 Constraint::Length(1),
1230 ])
1231 .split(area);
1232 let picker_height = skill_picker_height(state);
1233 let picker_area = (picker_height > 0).then(|| {
1234 Rect::new(
1235 chunks[1].x,
1236 chunks[1].y.saturating_sub(picker_height),
1237 chunks[1].width,
1238 picker_height,
1239 )
1240 });
1241 (chunks[0], picker_area, chunks[1], chunks[2])
1242}
1243
1244fn max_scroll_for_area(state: &UiState, size: Size) -> u16 {
1245 let area = Rect::new(0, 0, size.width, size.height);
1246 let (chat_chunk, _, _, _) = ui_layout(state, area);
1247 let chat_height = chat_chunk.height.saturating_sub(1);
1248 let lines = transcript_lines(state, chat_chunk.width);
1249 lines
1250 .len()
1251 .saturating_sub(chat_height as usize)
1252 .min(u16::MAX as usize) as u16
1253}
1254
1255fn input_visible_rows(state: &UiState, width: u16) -> u16 {
1257 let width = width as usize;
1258 if width == 0 {
1259 return 1;
1260 }
1261 let prompt = input_display_text(state);
1262 let wrapped = wrap_text(&prompt, width);
1263 wrapped.len().max(1) as u16
1264}
1265
1266fn input_prompt(input: &str) -> String {
1267 input.to_owned()
1268}
1269
1270fn input_display_text(state: &UiState) -> String {
1271 redact_secret(&input_prompt(&state.input), Some(&state.secret))
1272}
1273
1274fn command_names(mut skill_names: Vec<String>) -> Vec<String> {
1275 skill_names.extend(BUILTIN_COMMANDS.into_iter().map(str::to_owned));
1276 skill_names.sort();
1277 skill_names.dedup();
1278 skill_names
1279}
1280
1281#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1282enum BuiltinCommand {
1283 Settings,
1284 Exit,
1285}
1286
1287impl BuiltinCommand {
1288 fn name(self) -> &'static str {
1289 match self {
1290 Self::Settings => "settings",
1291 Self::Exit => "exit",
1292 }
1293 }
1294}
1295
1296fn builtin_command(input: &str) -> Option<BuiltinCommand> {
1297 match input.split_whitespace().next()? {
1298 "/settings" => Some(BuiltinCommand::Settings),
1299 "/exit" => Some(BuiltinCommand::Exit),
1300 _ => None,
1301 }
1302}
1303
1304fn matching_skill_names<'a>(input: &str, skill_names: &'a [String]) -> Vec<&'a str> {
1307 let Some(query) = input.strip_prefix('/') else {
1308 return Vec::new();
1309 };
1310 if query.chars().any(char::is_whitespace) {
1311 return Vec::new();
1312 }
1313 skill_names
1314 .iter()
1315 .map(String::as_str)
1316 .filter(|name| name.starts_with(query))
1317 .collect()
1318}
1319
1320fn skill_picker_height(state: &UiState) -> u16 {
1321 if state.skill_picker_visible() {
1322 (state
1324 .matching_skill_names()
1325 .len()
1326 .min(SKILL_PICKER_MAX_ROWS)
1327 + 3) as u16
1328 } else {
1329 0
1330 }
1331}
1332
1333fn skill_picker_range(total: usize, focus: usize) -> std::ops::Range<usize> {
1334 let start = focus
1335 .saturating_add(1)
1336 .saturating_sub(SKILL_PICKER_MAX_ROWS);
1337 start.min(total)..(start + SKILL_PICKER_MAX_ROWS).min(total)
1338}
1339
1340fn active_skill_trigger<'a>(input: &'a str, skill_names: &[String]) -> Option<&'a str> {
1344 let invocation = input.strip_prefix('/')?;
1345 let name = invocation
1346 .split_once(char::is_whitespace)
1347 .map_or(invocation, |(name, _)| name);
1348 if name.is_empty() || !skill_names.iter().any(|skill_name| skill_name == name) {
1349 return None;
1350 }
1351 Some(&input[..1 + name.len()])
1352}
1353
1354fn styled_text_lines(
1357 input: &str,
1358 active_skill_trigger: Option<&str>,
1359 width: usize,
1360 text_style: Style,
1361) -> Vec<Line<'static>> {
1362 let trigger_len = active_skill_trigger.map_or(0, |trigger| trigger.chars().count());
1363 let mut char_offset = 0usize;
1364 let mut lines = Vec::new();
1365
1366 for source_line in input.split('\n') {
1367 for row in wrap_line(source_line, width) {
1368 let mut spans = Vec::new();
1369 let mut text = String::new();
1370 let mut highlighted = None;
1371 for character in row.chars() {
1372 let should_highlight = char_offset < trigger_len;
1373 if highlighted != Some(should_highlight) && !text.is_empty() {
1374 spans.push(styled_text_span(
1375 std::mem::take(&mut text),
1376 highlighted.unwrap_or(false),
1377 text_style,
1378 ));
1379 }
1380 highlighted = Some(should_highlight);
1381 text.push(character);
1382 char_offset += 1;
1383 }
1384 if !text.is_empty() {
1385 spans.push(styled_text_span(
1386 text,
1387 highlighted.unwrap_or(false),
1388 text_style,
1389 ));
1390 }
1391 if spans.is_empty() {
1392 spans.push(Span::styled(String::new(), text_style));
1393 }
1394 lines.push(Line::from(spans));
1395 }
1396 char_offset += 1;
1399 }
1400
1401 lines
1402}
1403
1404fn styled_text_span(text: String, highlighted: bool, text_style: Style) -> Span<'static> {
1405 if highlighted {
1406 Span::styled(text, Style::default().fg(Color::Cyan))
1407 } else {
1408 Span::styled(text, text_style)
1409 }
1410}
1411
1412fn cursor_row(input: &str, cursor: usize, width: usize) -> u16 {
1413 let prefix: String = input.chars().take(cursor).collect();
1414 wrap_text(&prefix, width)
1415 .len()
1416 .saturating_sub(1)
1417 .min(u16::MAX as usize) as u16
1418}
1419
1420fn insert_at_cursor(input: &mut String, cursor: &mut usize, character: char) {
1421 let byte_index = input
1422 .char_indices()
1423 .nth(*cursor)
1424 .map_or(input.len(), |(index, _)| index);
1425 input.insert(byte_index, character);
1426 *cursor += 1;
1427}
1428
1429fn remove_before_cursor(input: &mut String, cursor: &mut usize) -> bool {
1430 if *cursor == 0 {
1431 return false;
1432 }
1433 let end = input
1434 .char_indices()
1435 .nth(*cursor)
1436 .map_or(input.len(), |(index, _)| index);
1437 let start = input
1438 .char_indices()
1439 .nth(*cursor - 1)
1440 .map(|(index, _)| index)
1441 .unwrap_or(0);
1442 input.replace_range(start..end, "");
1443 *cursor -= 1;
1444 true
1445}
1446
1447fn draw(frame: &mut Frame<'_>, state: &UiState) {
1448 let (chat_chunk, picker_area, input_chunk, status_area) = ui_layout(state, frame.area());
1449
1450 let chat_height = chat_chunk.height.saturating_sub(1);
1453 let chat_area = Rect::new(chat_chunk.x, chat_chunk.y, chat_chunk.width, chat_height);
1454 let visible_chat_area = chat_area;
1456 let activity_area = Rect::new(
1457 chat_chunk.x,
1458 chat_chunk.y + chat_height,
1459 chat_chunk.width,
1460 chat_chunk.height - chat_height,
1461 );
1462
1463 let width = chat_chunk.width;
1464 if state.welcome_visible {
1465 let welcome_lines = welcome_lines(&state.attached_agents);
1466 let welcome_height = (welcome_lines.len() as u16).min(visible_chat_area.height);
1467 let welcome_area = Rect::new(
1468 visible_chat_area.x,
1469 visible_chat_area.y + visible_chat_area.height.saturating_sub(welcome_height) / 2,
1470 visible_chat_area.width,
1471 welcome_height,
1472 );
1473 let welcome = Paragraph::new(welcome_lines).alignment(Alignment::Center);
1474 frame.render_widget(welcome, welcome_area);
1475 } else {
1476 let lines = transcript_lines(state, width);
1477 let available = visible_chat_area.height as usize;
1478 let max_scroll = lines.len().saturating_sub(available).min(u16::MAX as usize) as u16;
1479 let scroll = if state.auto_scroll {
1480 max_scroll
1481 } else {
1482 state.scroll.min(max_scroll)
1483 };
1484 let transcript = Paragraph::new(lines).scroll((scroll, 0));
1485 frame.render_widget(transcript, visible_chat_area);
1486 }
1487
1488 let queued_suffix = (!state.queued_messages.is_empty()).then(|| {
1489 format!(
1490 " · queued {}: {}",
1491 state.queued_messages.len(),
1492 state.queued_messages.join(" | ")
1493 )
1494 });
1495 let activity_text = if state.status == "working" {
1496 format!("{} working", spinner_frame(state))
1497 } else if state.status == "compacting" {
1498 format!("{} compacting", spinner_frame(state))
1499 } else {
1500 format!("● {}", state.status)
1501 };
1502 let activity = activity_line(state, &activity_text, queued_suffix.as_deref());
1503 frame.render_widget(Paragraph::new(activity), activity_area);
1504
1505 if let Some(picker_area) = picker_area {
1506 draw_skill_picker(frame, state, picker_area);
1507 }
1508
1509 let input_style = user_message_style();
1510 let input_text_style = Style::default().fg(Color::White);
1511 let input_block = Block::default()
1512 .borders(Borders::ALL)
1513 .border_type(ratatui::widgets::BorderType::Rounded)
1514 .border_style(input_style);
1515 let input_area = input_block.inner(input_chunk);
1516 let prompt = input_display_text(state);
1517 let input_rows =
1518 input_visible_rows(state, frame.area().width.saturating_sub(2)).clamp(1, MAX_INPUT_ROWS);
1519 let wrapped = wrap_text(&prompt, input_area.width.max(1) as usize);
1520 let visible = (wrapped.len() as u16).clamp(1, input_rows);
1521 let cursor_row = cursor_row(&prompt, state.cursor, input_area.width.max(1) as usize);
1522 let bottom_scroll = (wrapped.len() as u16).saturating_sub(visible);
1523 let cursor_scroll = (cursor_row + 1).saturating_sub(visible);
1524 let input_scroll = bottom_scroll.min(cursor_scroll);
1525 let active_skill_trigger = (!state.busy)
1526 .then(|| active_skill_trigger(&prompt, &state.skill_names))
1527 .flatten();
1528 let input_lines = styled_text_lines(
1529 &prompt,
1530 active_skill_trigger,
1531 input_area.width.max(1) as usize,
1532 input_text_style,
1533 );
1534 let input = Paragraph::new(input_lines)
1535 .style(input_text_style)
1536 .scroll((input_scroll, 0))
1537 .block(input_block);
1538 frame.render_widget(input, input_chunk);
1539
1540 let effort = state.effort.as_deref().unwrap_or("default");
1541 let status_text = format!("model={} · effort={effort}", state.model);
1542 let context_text = context_status_text(state);
1543 let context_width = UnicodeWidthStr::width(context_text.as_str()) as u16;
1544 let context_area_width = context_width.min(status_area.width.saturating_sub(1));
1545 let status_chunks = Layout::default()
1546 .direction(Direction::Horizontal)
1547 .constraints([Constraint::Min(1), Constraint::Length(context_area_width)])
1548 .split(status_area);
1549 let status = Paragraph::new(redact_secret(&status_text, Some(&state.secret)))
1550 .style(Style::default().fg(Color::DarkGray));
1551 frame.render_widget(status, status_chunks[0]);
1552 if context_area_width > 0 {
1553 let context = Paragraph::new(context_text)
1554 .alignment(Alignment::Right)
1555 .style(context_status_style(state));
1556 frame.render_widget(context, status_chunks[1]);
1557 }
1558 if let Some(settings) = &state.settings {
1559 draw_settings(frame, settings, frame.area());
1560 }
1561
1562 if state.settings.is_none() && state.cursor_visible() && !input_area.is_empty() && visible > 0 {
1565 let cursor_prefix: String = prompt.chars().take(state.cursor).collect();
1566 let cursor_rows = wrap_text(&cursor_prefix, input_area.width.max(1) as usize);
1567 let cursor_line = cursor_rows.last().map(String::as_str).unwrap_or("");
1568 let cursor_offset = UnicodeWidthStr::width(cursor_line) as u16;
1569 let cursor_x = input_area.x + cursor_offset.min(input_area.width.saturating_sub(1));
1570 let cursor_y = input_area.y + cursor_row.saturating_sub(input_scroll);
1571 frame.set_cursor_position((cursor_x, cursor_y));
1572 }
1573}
1574
1575enum SettingsState {
1576 Loading,
1577 Applying {
1578 model: String,
1579 effort: Option<String>,
1580 },
1581 Error(String),
1582 Models {
1583 models: Vec<ProviderModel>,
1584 query: String,
1585 focus: usize,
1586 },
1587 Effort {
1588 model: ProviderModel,
1589 input: String,
1590 focus: usize,
1591 },
1592}
1593fn draw_settings(frame: &mut Frame<'_>, settings: &SettingsState, area: Rect) {
1594 let width = area
1595 .width
1596 .saturating_sub(2)
1597 .min(SETTINGS_MAX_WIDTH)
1598 .max(SETTINGS_MIN_WIDTH.min(area.width));
1599 let height = area
1600 .height
1601 .saturating_sub(2)
1602 .min(SETTINGS_MAX_HEIGHT)
1603 .max(SETTINGS_MIN_HEIGHT.min(area.height));
1604 let popup = Rect::new(
1605 area.x + area.width.saturating_sub(width) / 2,
1606 area.y + area.height.saturating_sub(height) / 2,
1607 width,
1608 height,
1609 );
1610 frame.render_widget(Clear, popup);
1611 let block = Block::default()
1612 .title(" /settings ")
1613 .borders(Borders::ALL)
1614 .border_style(Style::default().fg(Color::Cyan));
1615 let inner = block.inner(popup);
1616 frame.render_widget(block, popup);
1617
1618 let lines = match settings {
1619 SettingsState::Loading => vec![
1620 Line::styled("Loading provider models…", Style::default().fg(Color::Cyan)),
1621 Line::raw(""),
1622 Line::styled("Esc cancel", Style::default().fg(Color::DarkGray)),
1623 ],
1624 SettingsState::Applying { model, effort } => vec![
1625 Line::styled("Applying selection…", Style::default().fg(Color::Cyan)),
1626 Line::raw(model.clone()),
1627 Line::raw(format!(
1628 "effort: {}",
1629 effort.as_deref().unwrap_or("default")
1630 )),
1631 ],
1632 SettingsState::Error(error) => vec![
1633 Line::styled("Unable to update settings", Style::default().fg(Color::Red)),
1634 Line::raw(""),
1635 Line::raw(error.clone()),
1636 Line::raw(""),
1637 Line::styled("Enter/Esc close", Style::default().fg(Color::DarkGray)),
1638 ],
1639 SettingsState::Models {
1640 models,
1641 query,
1642 focus,
1643 } => {
1644 let query_lower = query.to_lowercase();
1645 let filtered = models
1646 .iter()
1647 .filter(|model| model.id.to_lowercase().contains(&query_lower))
1648 .collect::<Vec<_>>();
1649 let focus = (*focus).min(filtered.len().saturating_sub(1));
1650 let list_rows = inner.height.saturating_sub(4) as usize;
1651 let range = selection_range(filtered.len(), focus, list_rows);
1652 let mut lines = vec![
1653 Line::from(vec![
1654 Span::styled("Model ", Style::default().fg(Color::DarkGray)),
1655 Span::styled(
1656 if query.is_empty() {
1657 "type to filter…"
1658 } else {
1659 query
1660 },
1661 Style::default().fg(if query.is_empty() {
1662 Color::DarkGray
1663 } else {
1664 Color::White
1665 }),
1666 ),
1667 ]),
1668 Line::styled(
1669 format!(
1670 "{} models{}",
1671 filtered.len(),
1672 if filtered.is_empty() {
1673 ""
1674 } else {
1675 " · ↑/↓ move · Enter choose"
1676 }
1677 ),
1678 Style::default().fg(Color::DarkGray),
1679 ),
1680 ];
1681 if filtered.is_empty() {
1682 lines.push(Line::styled(
1683 "No matching models",
1684 Style::default().fg(Color::Yellow),
1685 ));
1686 } else {
1687 for index in range {
1688 let selected = index == focus;
1689 lines.push(Line::styled(
1690 format!(
1691 "{} {}",
1692 if selected { "›" } else { " " },
1693 filtered[index].id
1694 ),
1695 if selected {
1696 Style::default().fg(Color::Black).bg(Color::Cyan)
1697 } else {
1698 Style::default().fg(Color::White)
1699 },
1700 ));
1701 }
1702 }
1703 lines.push(Line::styled(
1704 "Esc cancel",
1705 Style::default().fg(Color::DarkGray),
1706 ));
1707 lines
1708 }
1709 SettingsState::Effort {
1710 model,
1711 input,
1712 focus,
1713 } => {
1714 let mut lines = vec![
1715 Line::styled(model.id.clone(), Style::default().fg(Color::Cyan)),
1716 Line::styled("Reasoning effort", Style::default().fg(Color::DarkGray)),
1717 ];
1718 match &model.efforts {
1719 Some(efforts) => {
1720 let total = efforts.len() + 1;
1721 let focus = (*focus).min(total.saturating_sub(1));
1722 let list_rows = inner.height.saturating_sub(4) as usize;
1723 for index in selection_range(total, focus, list_rows) {
1724 let value = if index == 0 {
1725 "default"
1726 } else {
1727 efforts[index - 1].as_str()
1728 };
1729 let selected = index == focus;
1730 lines.push(Line::styled(
1731 format!("{} {value}", if selected { "›" } else { " " }),
1732 if selected {
1733 Style::default().fg(Color::Black).bg(Color::Cyan)
1734 } else {
1735 Style::default().fg(Color::White)
1736 },
1737 ));
1738 }
1739 lines.push(Line::styled(
1740 "↑/↓ move · Enter save · Esc cancel",
1741 Style::default().fg(Color::DarkGray),
1742 ));
1743 }
1744 None => {
1745 lines.push(Line::raw("Provider did not advertise allowed efforts."));
1746 lines.push(Line::from(vec![
1747 Span::styled("Value ", Style::default().fg(Color::DarkGray)),
1748 Span::styled(
1749 if input.is_empty() { "default" } else { input },
1750 Style::default().fg(Color::White),
1751 ),
1752 ]));
1753 lines.push(Line::styled(
1754 "Type a value · Enter save · Esc cancel",
1755 Style::default().fg(Color::DarkGray),
1756 ));
1757 }
1758 }
1759 lines
1760 }
1761 };
1762 frame.render_widget(Paragraph::new(lines), inner);
1763}
1764
1765fn selection_range(total: usize, focus: usize, max_rows: usize) -> std::ops::Range<usize> {
1766 if total == 0 || max_rows == 0 {
1767 return 0..0;
1768 }
1769 let focus = focus.min(total - 1);
1770 let visible = total.min(max_rows);
1771 let start = focus
1772 .saturating_add(1)
1773 .saturating_sub(visible)
1774 .min(total - visible);
1775 start..start + visible
1776}
1777
1778fn draw_skill_picker(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
1779 let matches = state.matching_skill_names();
1780 let total = matches.len();
1781 if total == 0 || area.is_empty() {
1782 return;
1783 }
1784
1785 frame.render_widget(Clear, area);
1788 let block = Block::default()
1789 .borders(Borders::ALL)
1790 .border_type(BorderType::Rounded)
1791 .border_style(Style::default().fg(Color::Cyan));
1792 let inner = block.inner(area);
1793 frame.render_widget(block, area);
1794
1795 let focus = state.skill_picker_focus.min(total - 1);
1796 let header = Line::styled(
1797 format!("[{}/{}]", focus + 1, total),
1798 Style::default().fg(Color::DarkGray),
1799 );
1800 frame.render_widget(
1801 Paragraph::new(header),
1802 Rect::new(inner.x, inner.y, inner.width, 1),
1803 );
1804
1805 for (row, index) in skill_picker_range(total, focus).enumerate() {
1806 let style = if index == focus {
1807 Style::default().fg(Color::Cyan)
1808 } else {
1809 Style::default().fg(Color::DarkGray)
1810 };
1811 let skill = Line::styled(format!("/{}", matches[index]), style);
1812 frame.render_widget(
1813 Paragraph::new(skill),
1814 Rect::new(inner.x, inner.y + 1 + row as u16, inner.width, 1),
1815 );
1816 }
1817}
1818
1819fn welcome_line() -> Line<'static> {
1820 let character_count = WELCOME_MESSAGE.chars().count();
1821 let spans = WELCOME_MESSAGE
1822 .chars()
1823 .enumerate()
1824 .map(|(index, character)| {
1825 let progress = if character_count <= 1 {
1826 0.0
1827 } else {
1828 index as f32 / (character_count - 1) as f32
1829 };
1830 let color = Color::Rgb(
1831 interpolate_color(WELCOME_START_COLOR.0, WELCOME_END_COLOR.0, progress),
1832 interpolate_color(WELCOME_START_COLOR.1, WELCOME_END_COLOR.1, progress),
1833 interpolate_color(WELCOME_START_COLOR.2, WELCOME_END_COLOR.2, progress),
1834 );
1835 Span::styled(character.to_string(), Style::default().fg(color))
1836 })
1837 .collect::<Vec<_>>();
1838 Line::from(spans)
1839}
1840
1841fn interpolate_color(start: u8, end: u8, progress: f32) -> u8 {
1842 (start as f32 + (end as f32 - start as f32) * progress).round() as u8
1843}
1844
1845fn welcome_lines(attached_agents: &[String]) -> Vec<Line<'static>> {
1846 let mut lines = vec![
1847 welcome_line(),
1848 Line::styled(WELCOME_TAGLINE, Style::default().fg(Color::DarkGray)),
1849 Line::raw(""),
1850 ];
1851
1852 if attached_agents.is_empty() {
1853 lines.push(Line::styled(
1854 "Attached AGENTS.md: none",
1855 Style::default().fg(Color::DarkGray),
1856 ));
1857 } else {
1858 lines.push(Line::styled(
1859 "Attached AGENTS.md:",
1860 Style::default().fg(Color::DarkGray),
1861 ));
1862 lines.extend(
1863 attached_agents.iter().map(|path| {
1864 Line::styled(format!("• {path}"), Style::default().fg(Color::DarkGray))
1865 }),
1866 );
1867 }
1868
1869 lines
1870}
1871
1872fn transcript_lines(state: &UiState, width: u16) -> Vec<Line<'static>> {
1873 let width = width.max(1) as usize;
1874 let mut lines = Vec::new();
1875 let mut rendered_item = false;
1876
1877 for (index, item) in state.transcript.iter().enumerate() {
1878 if is_result_attached_to_call(&state.transcript, index) {
1881 continue;
1882 }
1883 if rendered_item {
1884 lines.push(Line::raw(String::new()));
1885 }
1886 match item {
1887 TranscriptItem::User {
1888 text,
1889 skill_instruction_attached,
1890 } => {
1891 let text = redact_secret(text, Some(&state.secret));
1892 let trigger = skill_instruction_attached
1893 .then(|| active_skill_trigger(&text, &state.skill_names))
1894 .flatten();
1895 push_user_message_block(&mut lines, &text, trigger, width);
1896 }
1897 TranscriptItem::Assistant(text) => {
1898 let text = redact_secret(text, Some(&state.secret));
1899 push_wrapped(&mut lines, &text, width, Style::default());
1900 }
1901 TranscriptItem::ToolCall {
1902 id,
1903 name,
1904 arguments,
1905 } => {
1906 let call_text = format!("[tool:{name} {}]", call_arguments(arguments));
1907 let call_text = redact_secret(&call_text, Some(&state.secret));
1908 let result = matching_tool_result(&state.transcript, index, id);
1909 let mut segments = vec![(
1910 call_text,
1911 if result.is_some() {
1912 tool_call_style()
1913 } else {
1914 pending_tool_call_style()
1915 },
1916 )];
1917 if let Some(result) = result {
1918 let result_text =
1919 redact_secret(&format_tool_result(result), Some(&state.secret));
1920 segments.push((" > ".to_owned(), Style::default()));
1921 segments.push((result_text, tool_result_style()));
1922 } else {
1923 segments.push((
1924 format!(" {}", spinner_frame(state)),
1925 pending_tool_call_style(),
1926 ));
1927 }
1928 push_spans_wrapped(&mut lines, &segments, width);
1929 }
1930 TranscriptItem::ToolResult {
1931 id: _,
1932 name: _,
1933 result,
1934 } => {
1935 let result_text = format_tool_result(result);
1936 let result_text = redact_secret(&result_text, Some(&state.secret));
1937 push_spans_wrapped(&mut lines, &[(result_text, tool_result_style())], width);
1938 }
1939 TranscriptItem::Error(text) => {
1940 let text = redact_secret(text, Some(&state.secret));
1941 push_wrapped(&mut lines, &text, width, error_style());
1942 }
1943 TranscriptItem::Info(text) => {
1944 let text = redact_secret(text, Some(&state.secret));
1945 push_wrapped(&mut lines, &text, width, info_style());
1946 }
1947 TranscriptItem::Reasoning { complete } => {
1948 let text = if *complete {
1949 "Reasoning Complete".to_owned()
1950 } else {
1951 format!("Reasoning... {}", spinner_frame(state))
1952 };
1953 push_wrapped(&mut lines, &text, width, thinking_style());
1954 }
1955 }
1956 rendered_item = true;
1957 }
1958 if lines.is_empty() {
1959 lines.push(Line::raw(""));
1960 }
1961 lines
1962}
1963
1964fn matching_tool_result<'a>(
1965 transcript: &'a [TranscriptItem],
1966 call_index: usize,
1967 call_id: &str,
1968) -> Option<&'a Value> {
1969 transcript
1970 .iter()
1971 .skip(call_index + 1)
1972 .find_map(|item| match item {
1973 TranscriptItem::ToolResult { id, result, .. } if id == call_id => Some(result),
1974 _ => None,
1975 })
1976}
1977
1978fn is_result_attached_to_call(transcript: &[TranscriptItem], result_index: usize) -> bool {
1979 let TranscriptItem::ToolResult { id, .. } = &transcript[result_index] else {
1980 return false;
1981 };
1982 let Some(call_index) = transcript[..result_index].iter().rposition(
1983 |item| matches!(item, TranscriptItem::ToolCall { id: call_id, .. } if call_id == id),
1984 ) else {
1985 return false;
1986 };
1987 !transcript[call_index + 1..result_index].iter().any(
1988 |item| matches!(item, TranscriptItem::ToolResult { id: result_id, .. } if result_id == id),
1989 )
1990}
1991
1992fn call_arguments(arguments: &str) -> String {
1996 let parsed: Value = match serde_json::from_str(arguments) {
1997 Ok(value) => value,
1998 Err(_) => return truncate_output(arguments),
1999 };
2000 if let Some(command) = parsed.get("command").and_then(Value::as_str) {
2001 return format!("\"{}\"", truncate_output(command));
2002 }
2003 let serialized = serde_json::to_string(&parsed).unwrap_or_else(|_| arguments.to_owned());
2004 truncate_output(&serialized)
2005}
2006
2007fn format_tool_result(result: &Value) -> String {
2011 let stdout = result.get("stdout").and_then(Value::as_str).unwrap_or("");
2012 let stderr = result.get("stderr").and_then(Value::as_str).unwrap_or("");
2013 let output = if !stdout.is_empty() { stdout } else { stderr };
2014 let truncated = truncate_output(output);
2015 let json_string = serde_json::to_string(&truncated).unwrap_or_else(|_| "\"\"".to_owned());
2018 format!("[{json_string}]")
2019}
2020
2021const RESULT_PREVIEW_CHARS: usize = 50;
2022
2023fn truncate_output(output: &str) -> String {
2024 let mut result: String = output.chars().take(RESULT_PREVIEW_CHARS).collect();
2025 if output.chars().count() > RESULT_PREVIEW_CHARS {
2026 result.push('…');
2027 }
2028 result
2029}
2030
2031fn user_message_style() -> Style {
2032 Style::default().fg(USER_BORDER_COLOR)
2033}
2034
2035fn push_user_message_block(
2038 lines: &mut Vec<Line<'static>>,
2039 text: &str,
2040 active_skill_trigger: Option<&str>,
2041 width: usize,
2042) {
2043 if width < 2 {
2044 lines.extend(styled_text_lines(
2045 text,
2046 active_skill_trigger,
2047 width.max(1),
2048 Style::default().fg(Color::White),
2049 ));
2050 return;
2051 }
2052
2053 let content_width = width - 2;
2054 let border_style = user_message_style();
2055 let rows = styled_text_lines(
2056 text,
2057 active_skill_trigger,
2058 content_width,
2059 Style::default().fg(Color::White),
2060 );
2061 lines.push(Line::styled(
2062 format!("╭{}╮", "─".repeat(content_width)),
2063 border_style,
2064 ));
2065 for row in rows {
2066 let row_width = UnicodeWidthStr::width(row.to_string().as_str());
2067 let padding = content_width.saturating_sub(row_width);
2068 let mut spans = Vec::with_capacity(row.spans.len() + 3);
2069 spans.push(Span::styled("│", border_style));
2070 spans.extend(row.spans);
2071 spans.push(Span::styled(
2072 " ".repeat(padding),
2073 Style::default().fg(Color::White),
2074 ));
2075 spans.push(Span::styled("│", border_style));
2076 lines.push(Line::from(spans));
2077 }
2078 lines.push(Line::styled(
2079 format!("╰{}╯", "─".repeat(content_width)),
2080 border_style,
2081 ));
2082}
2083
2084fn tool_call_style() -> Style {
2085 Style::default().fg(Color::Magenta)
2086}
2087
2088fn pending_tool_call_style() -> Style {
2089 Style::default().fg(PENDING_TOOL_COLOR)
2090}
2091
2092fn tool_result_style() -> Style {
2093 Style::default().fg(Color::DarkGray)
2094}
2095
2096fn error_style() -> Style {
2097 Style::default().fg(Color::Red)
2098}
2099
2100fn info_style() -> Style {
2101 Style::default().fg(Color::DarkGray)
2102}
2103
2104fn context_status_text(state: &UiState) -> String {
2105 let used = format_context_tokens(state.context_tokens);
2106 let Some(window) = state.context_window else {
2107 return format!("ctx={used}/? (?%)");
2108 };
2109 let percentage = context_percentage(state.context_tokens, window);
2110 format!(
2111 "ctx={used}/{} ({percentage}%)",
2112 format_context_tokens(window)
2113 )
2114}
2115
2116fn context_status_style(state: &UiState) -> Style {
2117 if state
2118 .context_window
2119 .is_some_and(|window| context_over_threshold(state.context_tokens, window))
2120 {
2121 Style::default().fg(PENDING_TOOL_COLOR)
2122 } else {
2123 Style::default().fg(Color::DarkGray)
2124 }
2125}
2126
2127fn context_percentage(used: usize, window: usize) -> usize {
2128 if window == 0 {
2129 return 0;
2130 }
2131 ((used as u128 * 100).div_ceil(window as u128)) as usize
2132}
2133
2134fn context_over_threshold(used: usize, window: usize) -> bool {
2135 window > 0 && (used as u128 * 100) > (window as u128 * 80)
2136}
2137
2138fn format_context_tokens(tokens: usize) -> String {
2139 if tokens >= 1_000_000 {
2140 format!("{:.2}M", tokens as f64 / 1_000_000.0)
2141 } else if tokens >= 1_000 {
2142 format!("{:.1}K", tokens as f64 / 1_000.0)
2143 } else {
2144 tokens.to_string()
2145 }
2146}
2147
2148fn activity_line<'a>(state: &UiState, text: &'a str, queued_suffix: Option<&'a str>) -> Line<'a> {
2149 if state.status != "working" {
2150 return Line::styled(text, activity_style_for(state));
2151 }
2152 let elapsed = state.cursor_epoch.elapsed();
2153 let character_count = text.chars().count().max(1);
2154 let mut spans = text
2155 .chars()
2156 .enumerate()
2157 .map(|(index, character)| {
2158 Span::styled(
2159 character.to_string(),
2160 Style::default().fg(working_gradient_color_at(elapsed, index, character_count)),
2161 )
2162 })
2163 .collect::<Vec<_>>();
2164 if let Some(suffix) = queued_suffix {
2165 spans.push(Span::styled(suffix, Style::default().fg(Color::DarkGray)));
2168 }
2169 Line::from(spans)
2170}
2171
2172fn activity_style_for(state: &UiState) -> Style {
2173 if state.status == "compacting" {
2174 Style::default().fg(PENDING_TOOL_COLOR)
2175 } else {
2176 Style::default().fg(Color::Cyan)
2177 }
2178}
2179
2180fn working_gradient_colors_at(position: f64) -> (Color, Color, f64) {
2181 let position = position.rem_euclid(WORKING_GRADIENT_COLORS.len() as f64);
2182 let phase = position.floor() as usize;
2183 let progress = position.fract();
2184 let start = WORKING_GRADIENT_COLORS[phase];
2185 let end = WORKING_GRADIENT_COLORS[(phase + 1) % WORKING_GRADIENT_COLORS.len()];
2186 (
2187 Color::Rgb(start.0, start.1, start.2),
2188 Color::Rgb(end.0, end.1, end.2),
2189 progress,
2190 )
2191}
2192
2193fn working_gradient_color_at(
2197 elapsed: Duration,
2198 character_index: usize,
2199 character_count: usize,
2200) -> Color {
2201 const SWEEP_WIDTH: f64 = 0.35;
2202
2203 let position = elapsed.as_secs_f64() / WORKING_GRADIENT_CYCLE.as_secs_f64()
2204 + character_index as f64 / character_count.max(1) as f64 * SWEEP_WIDTH;
2205 let (
2206 Color::Rgb(red_start, green_start, blue_start),
2207 Color::Rgb(red_end, green_end, blue_end),
2208 progress,
2209 ) = working_gradient_colors_at(position)
2210 else {
2211 unreachable!("working gradient palette always uses RGB colours")
2212 };
2213
2214 Color::Rgb(
2215 interpolate_color(red_start, red_end, progress as f32),
2216 interpolate_color(green_start, green_end, progress as f32),
2217 interpolate_color(blue_start, blue_end, progress as f32),
2218 )
2219}
2220
2221fn thinking_style() -> Style {
2222 Style::default().fg(Color::DarkGray)
2223}
2224
2225fn spinner_frame(state: &UiState) -> char {
2226 const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
2227 let tick = state.cursor_epoch.elapsed().as_millis() / 100;
2228 FRAMES[(tick as usize) % FRAMES.len()]
2229}
2230
2231fn push_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, style: Style) {
2232 let mut added = false;
2233 for piece in wrap_text(text, width) {
2234 lines.push(Line::styled(piece, style));
2235 added = true;
2236 }
2237 if !added {
2238 lines.push(Line::styled(String::new(), style));
2239 }
2240}
2241
2242fn push_spans_wrapped(lines: &mut Vec<Line<'static>>, segments: &[(String, Style)], width: usize) {
2246 let mut current_spans: Vec<Span<'static>> = Vec::new();
2247 let mut current_width = 0usize;
2248 for (text, style) in segments {
2249 for character in text.chars() {
2250 let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
2251 if current_width + char_width > width && !current_spans.is_empty() {
2252 lines.push(Line::from(std::mem::take(&mut current_spans)));
2253 current_width = 0;
2254 }
2255 let mut buffer = [0u8; 4];
2256 let s = character.encode_utf8(&mut buffer);
2257 current_spans.push(Span::styled(s.to_owned(), *style));
2258 current_width += char_width;
2259 }
2260 }
2261 if current_spans.is_empty() {
2262 current_spans.push(Span::raw(String::new()));
2263 }
2264 lines.push(Line::from(current_spans));
2265}
2266
2267fn wrap_text(text: &str, width: usize) -> Vec<String> {
2273 if width == 0 {
2274 return text.lines().map(str::to_owned).collect();
2275 }
2276 let mut rows = Vec::new();
2277 for line in text.split('\n') {
2280 rows.extend(wrap_line(line, width));
2281 }
2282 if rows.is_empty() {
2283 rows.push(String::new());
2284 }
2285 rows
2286}
2287
2288fn wrap_line(line: &str, width: usize) -> Vec<String> {
2289 let mut rows = Vec::new();
2290 let mut current = String::new();
2291 let mut current_width = 0usize;
2292 for character in line.chars() {
2293 let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
2294 if current_width + char_width > width && !current.is_empty() {
2295 rows.push(std::mem::take(&mut current));
2296 current_width = 0;
2297 }
2298 current.push(character);
2299 current_width += char_width;
2300 }
2301 rows.push(current);
2302 rows
2303}
2304
2305#[cfg(test)]
2306mod tests {
2307 use super::*;
2308
2309 #[test]
2310 fn turn_notifications_use_osc_777_with_fixed_secret_safe_messages() {
2311 let cases = [
2312 (TurnNotification::Completed, "Turn complete"),
2313 (TurnNotification::Interrupted, "Turn interrupted"),
2314 (TurnNotification::Failed, "Turn failed"),
2315 ];
2316
2317 for (notification, body) in cases {
2318 let mut output = Vec::new();
2319 send_turn_notification(&mut output, notification).expect("notification");
2320 assert_eq!(
2321 output,
2322 format!("\x1b]777;notify;Lucy;{body}\x07").into_bytes()
2323 );
2324 }
2325 }
2326
2327 #[test]
2328 fn turn_notifications_follow_the_terminal_turn_status() {
2329 assert_eq!(
2330 turn_notification_for_status("finalizing"),
2331 TurnNotification::Completed
2332 );
2333 assert_eq!(
2334 turn_notification_for_status("cancelling"),
2335 TurnNotification::Interrupted
2336 );
2337 assert_eq!(
2338 turn_notification_for_status("error"),
2339 TurnNotification::Failed
2340 );
2341 }
2342
2343 struct FailingWriter;
2344
2345 impl Write for FailingWriter {
2346 fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
2347 Err(io::Error::other("notification sink unavailable"))
2348 }
2349
2350 fn flush(&mut self) -> io::Result<()> {
2351 Err(io::Error::other("notification sink unavailable"))
2352 }
2353 }
2354
2355 #[test]
2356 fn notification_write_failure_does_not_keep_the_tui_busy() {
2357 let mut state = UiState::from_history(&[], "secret", "model", None, false);
2358 state.busy = true;
2359 state.active_cancel = Some(CancellationToken::new());
2360 let mut writer = FailingWriter;
2361
2362 release_finished_turn(&mut writer, &mut state);
2363
2364 assert!(!state.busy);
2365 assert!(state.active_cancel.is_none());
2366 }
2367
2368 #[test]
2369 fn an_idle_finish_does_not_emit_a_duplicate_notification() {
2370 let mut state = UiState::from_history(&[], "secret", "model", None, false);
2371 let mut output = Vec::new();
2372
2373 release_finished_turn(&mut output, &mut state);
2374
2375 assert!(output.is_empty());
2376 }
2377
2378 #[test]
2379 fn context_status_shows_used_window_and_percentage() {
2380 let mut state = UiState::from_history(&[], "secret", "model", None, false)
2381 .with_context(Some(100_000), 80_000);
2382
2383 assert_eq!(context_status_text(&state), "ctx=80.0K/100.0K (80%)");
2384 assert_eq!(context_status_style(&state).fg, Some(Color::DarkGray));
2385
2386 state.context_tokens = 80_001;
2387 assert_eq!(context_status_text(&state), "ctx=80.0K/100.0K (81%)");
2388 assert_eq!(context_status_style(&state).fg, Some(PENDING_TOOL_COLOR));
2389 }
2390
2391 #[test]
2392 fn context_status_handles_unknown_window_without_highlighting() {
2393 let state = UiState::from_history(&[], "secret", "model", None, false);
2394
2395 assert_eq!(context_status_text(&state), "ctx=1/? (?%)");
2396 assert_eq!(context_status_style(&state).fg, Some(Color::DarkGray));
2397 }
2398
2399 #[test]
2400 fn context_status_is_right_aligned_and_turns_orange_above_eighty_percent() {
2401 let state =
2402 UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
2403 let mut terminal =
2404 Terminal::new(ratatui::backend::TestBackend::new(60, 10)).expect("test terminal");
2405
2406 terminal
2407 .draw(|frame| draw(frame, &state))
2408 .expect("draw statusline");
2409
2410 let buffer = terminal.backend().buffer();
2411 let status_row = 9;
2412 let context = context_status_text(&state);
2413 let context_start = 60 - context.chars().count();
2414 assert_eq!(buffer[(context_start as u16, status_row)].symbol(), "c");
2415 assert_eq!(buffer[(59, status_row)].symbol(), ")");
2416 assert_eq!(buffer[(59, status_row)].fg, PENDING_TOOL_COLOR);
2417 }
2418
2419 #[test]
2420 fn working_activity_uses_a_continuous_warm_gradient() {
2421 let text = "⠋ working";
2422 let colors = text
2423 .chars()
2424 .enumerate()
2425 .map(|(index, _)| {
2426 working_gradient_color_at(Duration::from_millis(2_500), index, text.chars().count())
2427 })
2428 .collect::<Vec<_>>();
2429
2430 assert_eq!(colors.first(), Some(&Color::Rgb(228, 40, 120)));
2431 assert_eq!(colors.last(), Some(&Color::Rgb(232, 43, 86)));
2432 assert!(colors.windows(2).all(|pair| pair[0] != pair[1]));
2433
2434 let (start, end, _) = working_gradient_colors_at(1.0);
2435 assert_eq!(start, Color::Rgb(235, 45, 65));
2436 assert_eq!(end, Color::Rgb(255, 130, 25));
2437
2438 let (start, end, _) = working_gradient_colors_at(3.0);
2439 assert_eq!(start, Color::Rgb(235, 45, 65));
2440 assert_eq!(end, Color::Rgb(220, 35, 175));
2441 }
2442
2443 #[test]
2444 fn queued_activity_suffix_is_not_in_the_working_gradient() {
2445 let mut state = UiState::from_history(&[], "secret", "model", None, false);
2446 state.status = "working".to_owned();
2447 let line = activity_line(&state, "⠋ working", Some(" · queued 1: next task"));
2448 assert!(line.spans[.."⠋ working".chars().count()]
2449 .iter()
2450 .all(|span| span.style.fg != Some(Color::DarkGray)));
2451 assert_eq!(
2452 line.spans.last().expect("queue suffix").style.fg,
2453 Some(Color::DarkGray)
2454 );
2455 }
2456
2457 #[test]
2458 fn fresh_sessions_show_the_versioned_gradient_welcome_message() {
2459 let state = UiState::from_history(&[], "secret", "model", None, false);
2460 assert!(state.welcome_visible);
2461
2462 let line = welcome_line();
2463 assert_eq!(line.to_string(), WELCOME_MESSAGE);
2464 assert_eq!(
2465 line.spans.first().and_then(|span| span.style.fg),
2466 Some(Color::Rgb(
2467 WELCOME_START_COLOR.0,
2468 WELCOME_START_COLOR.1,
2469 WELCOME_START_COLOR.2,
2470 ))
2471 );
2472 assert_eq!(
2473 line.spans.last().and_then(|span| span.style.fg),
2474 Some(Color::Rgb(
2475 WELCOME_END_COLOR.0,
2476 WELCOME_END_COLOR.1,
2477 WELCOME_END_COLOR.2,
2478 ))
2479 );
2480 }
2481
2482 #[test]
2483 fn welcome_shows_the_tagline_and_attached_agents_paths() {
2484 let state = UiState::from_history(&[], "secret", "model", None, false)
2485 .with_attached_agents(vec![
2486 "/workspace/AGENTS.md".to_owned(),
2487 "/workspace/app/AGENTS.md".to_owned(),
2488 ]);
2489 let lines = welcome_lines(&state.attached_agents);
2490
2491 assert_eq!(lines[1].to_string(), WELCOME_TAGLINE);
2492 assert_eq!(lines[1].style.fg, Some(Color::DarkGray));
2493 assert_eq!(lines[3].to_string(), "Attached AGENTS.md:");
2494 assert_eq!(lines[4].to_string(), "• /workspace/AGENTS.md");
2495 assert_eq!(lines[5].to_string(), "• /workspace/app/AGENTS.md");
2496 assert!(lines[3..]
2497 .iter()
2498 .all(|line| line.style.fg == Some(Color::DarkGray)));
2499 }
2500
2501 #[test]
2502 fn welcome_reports_when_no_agents_file_is_attached() {
2503 let lines = welcome_lines(&[]);
2504 assert_eq!(
2505 lines.last().expect("empty context line").to_string(),
2506 "Attached AGENTS.md: none"
2507 );
2508 }
2509
2510 #[test]
2511 fn resumed_sessions_do_not_show_the_welcome_message() {
2512 let state = UiState::from_history(&[], "secret", "model", None, true);
2513 assert!(!state.welcome_visible);
2514 }
2515
2516 #[test]
2517 fn history_replay_keeps_interruption_after_messages() {
2518 let history = vec![
2519 SessionHistoryRecord::Message {
2520 timestamp: 1,
2521 message: ChatMessage::user("hello".to_owned()),
2522 },
2523 SessionHistoryRecord::Interruption {
2524 timestamp: 2,
2525 reason: "user_cancelled".to_owned(),
2526 phase: "provider_stream".to_owned(),
2527 assistant_text: "partial".to_owned(),
2528 tool_calls: Vec::new(),
2529 tool_results: Vec::new(),
2530 },
2531 ];
2532 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
2533 assert!(matches!(state.transcript[0], TranscriptItem::User { .. }));
2534 assert!(matches!(state.transcript[1], TranscriptItem::Assistant(_)));
2535 assert!(matches!(state.transcript[2], TranscriptItem::Info(_)));
2536 let text = transcript_lines(&state, 80)
2537 .iter()
2538 .map(ToString::to_string)
2539 .collect::<Vec<_>>()
2540 .join("\n");
2541 assert!(!text.contains("choices"));
2542 }
2543
2544 #[test]
2545 fn history_replay_does_not_render_assistant_reasoning_details() {
2546 let mut message = ChatMessage::assistant("visible answer".to_owned(), Vec::new());
2547 message.reasoning_details = Some(vec![serde_json::json!({
2548 "type": "reasoning.text",
2549 "text": "private reasoning"
2550 })]);
2551 let history = [SessionHistoryRecord::Message {
2552 timestamp: 1,
2553 message,
2554 }];
2555 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
2556 let text = transcript_lines(&state, 80)
2557 .iter()
2558 .map(ToString::to_string)
2559 .collect::<Vec<_>>()
2560 .join("\n");
2561 assert!(text.contains("visible answer"));
2562 assert!(!text.contains("private reasoning"));
2563 assert!(!text.contains("reasoning_details"));
2564 }
2565
2566 #[test]
2567 fn history_replay_preserves_repeated_records() {
2568 let history = vec![
2569 SessionHistoryRecord::Message {
2570 timestamp: 1,
2571 message: ChatMessage::assistant("same".to_owned(), Vec::new()),
2572 },
2573 SessionHistoryRecord::Interruption {
2574 timestamp: 2,
2575 reason: "user_cancelled".to_owned(),
2576 phase: "provider_stream".to_owned(),
2577 assistant_text: "same".to_owned(),
2578 tool_calls: Vec::new(),
2579 tool_results: Vec::new(),
2580 },
2581 ];
2582 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
2583 assert_eq!(
2584 state
2585 .transcript
2586 .iter()
2587 .filter(|item| matches!(item, TranscriptItem::Assistant(text) if text == "same"))
2588 .count(),
2589 2
2590 );
2591 }
2592
2593 #[test]
2594 fn user_message_borders_remain_muted_yellow_while_its_text_is_white() {
2595 let history = [SessionHistoryRecord::Message {
2596 timestamp: 1,
2597 message: ChatMessage::user("hello".to_owned()),
2598 }];
2599 let state = UiState::from_history(&history, "provider-secret", "model", None, false);
2600 let lines = transcript_lines(&state, 12);
2601
2602 assert_eq!(lines.len(), 3);
2603 assert_eq!(lines[0].to_string(), "╭──────────╮");
2604 assert_eq!(lines[1].to_string(), "│hello │");
2605 assert_eq!(lines[2].to_string(), "╰──────────╯");
2606 assert_eq!(lines[0].style.fg, Some(USER_BORDER_COLOR));
2607 assert_eq!(lines[2].style.fg, Some(USER_BORDER_COLOR));
2608 assert_eq!(lines[1].spans[0].style.fg, Some(USER_BORDER_COLOR));
2609 assert_eq!(lines[1].spans[1].style.fg, Some(Color::White));
2610 assert_eq!(
2611 lines[1].spans.last().map(|span| span.style.fg),
2612 Some(Some(USER_BORDER_COLOR))
2613 );
2614 }
2615
2616 #[test]
2617 fn attached_skill_highlights_its_trigger_in_the_user_message_without_a_notice_line() {
2618 let mut state = UiState::from_history(&[], "secret", "model", None, false)
2619 .with_skill_names(vec!["release-notes".to_owned()]);
2620 state.add_user("/release-notes v1.2.0", "secret");
2621 state.mark_latest_user_skill_attached();
2622
2623 let lines = transcript_lines(&state, 40);
2624 assert_eq!(lines.len(), 3);
2625 let cyan_text = lines[1]
2626 .spans
2627 .iter()
2628 .filter(|span| span.style.fg == Some(Color::Cyan))
2629 .map(|span| span.content.as_ref())
2630 .collect::<String>();
2631 assert_eq!(cyan_text, "/release-notes");
2632 assert!(!lines
2633 .iter()
2634 .any(|line| line.to_string().contains("instruction attached")));
2635 }
2636
2637 #[test]
2638 fn transcript_rendering_redacts_history_content() {
2639 let history = [SessionHistoryRecord::Message {
2640 timestamp: 1,
2641 message: ChatMessage::assistant("provider-secret".to_owned(), Vec::new()),
2642 }];
2643 let state = UiState::from_history(&history, "provider-secret", "model", None, false);
2644 let text = transcript_lines(&state, 80)
2645 .iter()
2646 .map(ToString::to_string)
2647 .collect::<Vec<_>>()
2648 .join("\n");
2649 assert!(!text.contains("provider-secret"));
2650 }
2651
2652 #[test]
2653 fn mouse_wheel_disables_following_and_changes_scroll_offset() {
2654 let history = [SessionHistoryRecord::Message {
2655 timestamp: 1,
2656 message: ChatMessage::user("hello".to_owned()),
2657 }];
2658 let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
2659 handle_mouse_event(&mut state, MouseEventKind::ScrollUp, 10);
2660 assert!(!state.auto_scroll);
2661 assert_eq!(state.scroll, 7);
2662 handle_mouse_event(&mut state, MouseEventKind::ScrollDown, 10);
2663 assert!(
2664 state.auto_scroll,
2665 "reaching the bottom resumes transcript following"
2666 );
2667 assert_eq!(state.scroll, 0);
2668 scroll_up(&mut state, 10);
2669 assert!(!state.auto_scroll);
2670 assert_eq!(state.scroll, 7);
2671 }
2672
2673 #[test]
2674 fn wrap_text_breaks_long_lines_and_preserves_empty_lines() {
2675 let rows = wrap_text("12345\n\nabc", 3);
2676 assert_eq!(rows, vec!["123", "45", "", "abc"]);
2677 }
2678
2679 #[test]
2680 fn wrap_line_never_returns_an_empty_vec() {
2681 assert_eq!(wrap_line("", 5), vec![""]);
2682 assert_eq!(wrap_line("abc", 5), vec!["abc"]);
2683 }
2684
2685 #[test]
2686 fn completion_event_does_not_release_input_before_worker_finishes() {
2687 let history = [SessionHistoryRecord::Message {
2688 timestamp: 1,
2689 message: ChatMessage::user("hello".to_owned()),
2690 }];
2691 let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
2692 state.busy = true;
2693 state.active_cancel = Some(CancellationToken::new());
2694 state.apply_event(ProtocolEvent::TurnEnd);
2695 assert!(state.busy);
2696 assert!(state.active_cancel.is_some());
2697 assert_eq!(state.status, "finalizing");
2698 }
2699
2700 #[test]
2701 fn transcript_inserts_a_blank_line_between_items() {
2702 let history = [
2703 SessionHistoryRecord::Message {
2704 timestamp: 1,
2705 message: ChatMessage::user("hi".to_owned()),
2706 },
2707 SessionHistoryRecord::Message {
2708 timestamp: 2,
2709 message: ChatMessage::assistant("hello".to_owned(), Vec::new()),
2710 },
2711 ];
2712 let state = UiState::from_history(&history, "secret", "model", None, false);
2713 let lines = transcript_lines(&state, 80);
2714 assert_eq!(lines.len(), 5);
2715 assert_eq!(lines[0].to_string(), format!("╭{}╮", "─".repeat(78)));
2716 assert_eq!(lines[1].to_string(), format!("│hi{}│", " ".repeat(76)));
2717 assert_eq!(lines[2].to_string(), format!("╰{}╯", "─".repeat(78)));
2718 assert_eq!(lines[3].to_string(), "");
2719 assert_eq!(lines[4].to_string(), "hello");
2720 }
2721
2722 #[test]
2723 fn tool_call_renders_as_compact_single_line_with_command() {
2724 let history = [SessionHistoryRecord::Message {
2725 timestamp: 1,
2726 message: ChatMessage::assistant(
2727 String::new(),
2728 vec![crate::model::ChatToolCall {
2729 id: "call-1".to_owned(),
2730 name: "cmd".to_owned(),
2731 arguments: r#"{"command":"pwd"}"#.to_owned(),
2732 }],
2733 ),
2734 }];
2735 let state = UiState::from_history(&history, "secret", "model", None, false);
2736 let text = transcript_lines(&state, 80)
2737 .iter()
2738 .map(ToString::to_string)
2739 .collect::<Vec<_>>()
2740 .join("\n");
2741 assert!(text.contains("[tool:cmd \"pwd\"]"));
2742 assert!(!text.contains("{\"command\":\"pwd\"}"));
2744 }
2745
2746 #[test]
2747 fn pending_tool_calls_are_orange_and_show_a_spinner() {
2748 let history = [SessionHistoryRecord::Message {
2749 timestamp: 1,
2750 message: ChatMessage::assistant(
2751 String::new(),
2752 vec![crate::model::ChatToolCall {
2753 id: "call-1".to_owned(),
2754 name: "cmd".to_owned(),
2755 arguments: r#"{"command":"pwd"}"#.to_owned(),
2756 }],
2757 ),
2758 }];
2759 let state = UiState::from_history(&history, "secret", "model", None, false);
2760 let line = &transcript_lines(&state, 80)[0];
2761
2762 assert!(line.to_string().contains("[tool:cmd \"pwd\"] "));
2763 assert!(line
2764 .spans
2765 .iter()
2766 .all(|span| span.style.fg == Some(PENDING_TOOL_COLOR)));
2767 assert!(line.spans.iter().any(|span| {
2768 span.content
2769 .chars()
2770 .any(|character| "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏".contains(character))
2771 }));
2772 }
2773
2774 #[test]
2775 fn tool_call_truncates_long_arguments() {
2776 let command = "a".repeat(80);
2777 let arguments = serde_json::json!({"command": command}).to_string();
2778 let history = [SessionHistoryRecord::Message {
2779 timestamp: 1,
2780 message: ChatMessage::assistant(
2781 String::new(),
2782 vec![crate::model::ChatToolCall {
2783 id: "call-1".to_owned(),
2784 name: "cmd".to_owned(),
2785 arguments,
2786 }],
2787 ),
2788 }];
2789 let state = UiState::from_history(&history, "secret", "model", None, false);
2790 let text = transcript_lines(&state, 200)[0].to_string();
2791 assert!(text.contains(&format!("[tool:cmd \"{}…\"]", "a".repeat(50))));
2792 assert!(!text.contains(&"a".repeat(51)));
2793 }
2794
2795 #[test]
2796 fn tool_call_and_result_render_on_one_line_with_truncated_stdout() {
2797 let long_stdout = "a".repeat(80);
2798 let history = vec![
2799 SessionHistoryRecord::Message {
2800 timestamp: 1,
2801 message: ChatMessage::assistant(
2802 String::new(),
2803 vec![crate::model::ChatToolCall {
2804 id: "call-1".to_owned(),
2805 name: "cmd".to_owned(),
2806 arguments: r#"{"command":"cat README.md"}"#.to_owned(),
2807 }],
2808 ),
2809 },
2810 SessionHistoryRecord::Message {
2811 timestamp: 2,
2812 message: ChatMessage::tool(
2813 "call-1".to_owned(),
2814 "cmd".to_owned(),
2815 serde_json::json!({
2816 "command": "cat README.md",
2817 "exit_code": 0,
2818 "stdout": long_stdout,
2819 "stderr": "",
2820 })
2821 .to_string(),
2822 ),
2823 },
2824 ];
2825 let state = UiState::from_history(&history, "secret", "model", None, false);
2826 let lines = transcript_lines(&state, 200);
2827 assert_eq!(lines.len(), 1);
2829 let text = lines[0].to_string();
2830 assert!(text.starts_with("[tool:cmd \"cat README.md\"] > ["));
2831 assert!(text.contains(&"a".repeat(50)));
2833 assert!(text.contains('…'));
2834 assert!(!text.contains(&"a".repeat(51)));
2835 }
2836
2837 #[test]
2838 fn tool_result_falls_back_to_stderr_when_stdout_is_empty() {
2839 let history = vec![
2840 SessionHistoryRecord::Message {
2841 timestamp: 1,
2842 message: ChatMessage::assistant(
2843 String::new(),
2844 vec![crate::model::ChatToolCall {
2845 id: "call-1".to_owned(),
2846 name: "cmd".to_owned(),
2847 arguments: r#"{"command":"bad"}"#.to_owned(),
2848 }],
2849 ),
2850 },
2851 SessionHistoryRecord::Message {
2852 timestamp: 2,
2853 message: ChatMessage::tool(
2854 "call-1".to_owned(),
2855 "cmd".to_owned(),
2856 serde_json::json!({
2857 "command": "bad",
2858 "exit_code": 127,
2859 "stdout": "",
2860 "stderr": "not found",
2861 })
2862 .to_string(),
2863 ),
2864 },
2865 ];
2866 let state = UiState::from_history(&history, "secret", "model", None, false);
2867 let text = transcript_lines(&state, 200)[0].to_string();
2868 assert!(text.contains("not found"));
2869 assert!(text.contains(" > "));
2870 }
2871
2872 #[test]
2873 fn tool_call_and_result_styles_use_foreground_colors() {
2874 let history = vec![
2875 SessionHistoryRecord::Message {
2876 timestamp: 1,
2877 message: ChatMessage::assistant(
2878 String::new(),
2879 vec![crate::model::ChatToolCall {
2880 id: "call-1".to_owned(),
2881 name: "cmd".to_owned(),
2882 arguments: r#"{"command":"pwd"}"#.to_owned(),
2883 }],
2884 ),
2885 },
2886 SessionHistoryRecord::Message {
2887 timestamp: 2,
2888 message: ChatMessage::tool(
2889 "call-1".to_owned(),
2890 "cmd".to_owned(),
2891 serde_json::json!({"stdout":"x","stderr":""}).to_string(),
2892 ),
2893 },
2894 ];
2895 let state = UiState::from_history(&history, "secret", "model", None, false);
2896 let lines = transcript_lines(&state, 200);
2897 let spans = &lines[0].spans;
2898 assert_eq!(spans[0].style.fg, Some(Color::Magenta));
2901 assert!(spans.iter().any(|span| span.style.fg.is_none()));
2902 let result_text = spans
2903 .iter()
2904 .filter(|span| span.style.fg == Some(Color::DarkGray))
2905 .map(|span| span.content.as_ref())
2906 .collect::<String>();
2907 assert_eq!(result_text, "[\"x\"]");
2908 assert!(!lines[0]
2909 .to_string()
2910 .chars()
2911 .any(|character| "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏".contains(character)));
2912 }
2913
2914 #[test]
2915 fn recognized_skill_trigger_is_highlighted_but_arguments_remain_default_colored() {
2916 let trigger = active_skill_trigger("/release-notes v1.2.0", &["release-notes".to_owned()]);
2917 assert_eq!(trigger, Some("/release-notes"));
2918
2919 let lines = styled_text_lines(
2920 "/release-notes v1.2.0",
2921 trigger,
2922 80,
2923 Style::default().fg(Color::White),
2924 );
2925 assert_eq!(lines.len(), 1);
2926 assert_eq!(lines[0].to_string(), "/release-notes v1.2.0");
2927 assert_eq!(lines[0].spans[0].content, "/release-notes");
2928 assert_eq!(lines[0].spans[0].style.fg, Some(Color::Cyan));
2929 assert_eq!(lines[0].spans[1].content, " v1.2.0");
2930 assert_eq!(lines[0].spans[1].style.fg, Some(Color::White));
2931 }
2932
2933 #[test]
2934 fn draw_renders_an_active_skill_trigger_in_cyan() {
2935 let mut state = UiState::from_history(&[], "secret", "model", None, false)
2936 .with_skill_names(vec!["release-notes".to_owned()]);
2937 state.input = "/release-notes v1.2.0".to_owned();
2938 state.cursor = state.input.chars().count();
2939
2940 let mut terminal =
2941 Terminal::new(ratatui::backend::TestBackend::new(40, 10)).expect("test terminal");
2942 terminal
2943 .draw(|frame| draw(frame, &state))
2944 .expect("draw input");
2945
2946 let buffer = terminal.backend().buffer();
2949 assert_eq!(buffer[(1, 7)].fg, Color::Cyan);
2950 assert_eq!(buffer[(21, 7)].fg, Color::White);
2951 }
2952
2953 #[test]
2954 fn busy_input_keeps_normal_style_and_a_blinking_input_cursor() {
2955 let mut state = UiState::from_history(&[], "secret", "model", None, false);
2956 state.busy = true;
2957 state.cursor_epoch = Instant::now();
2958
2959 let mut terminal =
2960 Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
2961 terminal
2962 .draw(|frame| draw(frame, &state))
2963 .expect("draw waiting input");
2964
2965 let buffer = terminal.backend().buffer();
2966 assert_eq!(buffer[(0, 6)].fg, USER_BORDER_COLOR);
2967 assert_eq!(buffer[(1, 7)].symbol(), " ");
2968 assert_eq!(input_display_text(&state), "");
2969 terminal.backend_mut().assert_cursor_position((1, 7));
2970 state.input = "queued message".to_owned();
2971 assert_eq!(input_display_text(&state), "queued message");
2972 }
2973
2974 #[test]
2975 fn only_known_leading_skill_commands_activate_input_highlighting() {
2976 let skills = ["release-notes".to_owned()];
2977 assert_eq!(
2978 active_skill_trigger("/missing", &skills),
2979 None,
2980 "unknown commands are rejected by the turn engine and must not look active"
2981 );
2982 assert_eq!(
2983 active_skill_trigger("/skill:release-notes", &skills),
2984 None,
2985 "the removed /skill: wrapper must not look active"
2986 );
2987 assert_eq!(
2988 active_skill_trigger("write /release-notes", &skills),
2989 None,
2990 "only the command prefix accepted by the turn engine is active"
2991 );
2992 assert_eq!(active_skill_trigger("/", &skills), None);
2993 }
2994
2995 #[test]
2996 fn highlighted_skill_trigger_remains_styled_when_wrapped() {
2997 let input = "/release-notes argument";
2998 let trigger = active_skill_trigger(input, &["release-notes".to_owned()]);
2999 let lines = styled_text_lines(input, trigger, 8, Style::default().fg(Color::White));
3000 let highlighted = lines
3001 .iter()
3002 .flat_map(|line| line.spans.iter())
3003 .filter(|span| span.style.fg == Some(Color::Cyan))
3004 .map(|span| span.content.as_ref())
3005 .collect::<String>();
3006 assert_eq!(highlighted, "/release-notes");
3007 }
3008
3009 #[test]
3010 fn input_has_no_prompt_marker_and_trailing_newline_is_visible() {
3011 assert_eq!(input_prompt("hello"), "hello");
3012 assert_eq!(wrap_text("hello\n", 80), vec!["hello", ""]);
3013 }
3014
3015 #[test]
3016 fn input_prompt_wraps_to_multiple_rows_when_long() {
3017 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3018 state.input = "abcdefghij".to_owned();
3019 let rows = input_visible_rows(&state, 5);
3021 assert!(rows >= 2);
3022 }
3023
3024 #[test]
3025 fn cursor_editing_moves_by_characters_and_preserves_unicode() {
3026 let mut input = "가나".to_owned();
3027 let mut cursor = input.chars().count();
3028 cursor -= 1;
3029 insert_at_cursor(&mut input, &mut cursor, 'x');
3030 assert_eq!(input, "가x나");
3031 assert_eq!(cursor, 2);
3032 assert!(remove_before_cursor(&mut input, &mut cursor));
3033 assert_eq!(input, "가나");
3034 assert_eq!(cursor, 1);
3035 }
3036
3037 #[test]
3038 fn cursor_row_tracks_newlines_and_wrapping() {
3039 assert_eq!(cursor_row("hello\nworld", 6, 80), 1);
3040 assert_eq!(cursor_row("abcdef", 4, 3), 1);
3041 }
3042
3043 #[test]
3044 fn shift_enter_inserts_at_the_cursor_and_moves_it_to_the_new_row() {
3045 let mut input = "beforeafter".to_owned();
3046 let mut cursor = 6;
3047 insert_at_cursor(&mut input, &mut cursor, '\n');
3048
3049 assert_eq!(input, "before\nafter");
3050 assert_eq!(cursor, 7);
3051 assert_eq!(cursor_row(&input, cursor, 80), 1);
3052 }
3053
3054 #[test]
3055 fn shift_enter_renders_the_cursor_on_the_new_input_row() {
3056 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3057 state.input = "beforeafter".to_owned();
3058 state.cursor = 6;
3059 insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
3060 state.cursor_epoch = Instant::now();
3061
3062 let mut terminal =
3063 Terminal::new(ratatui::backend::TestBackend::new(20, 10)).expect("test terminal");
3064 terminal
3065 .draw(|frame| draw(frame, &state))
3066 .expect("draw input cursor");
3067
3068 terminal.backend_mut().assert_cursor_position((1, 7));
3071 }
3072
3073 #[test]
3074 fn tool_results_attach_to_their_matching_call_after_consecutive_calls() {
3075 let history = vec![
3076 SessionHistoryRecord::Message {
3077 timestamp: 1,
3078 message: ChatMessage::assistant(
3079 String::new(),
3080 vec![
3081 crate::model::ChatToolCall {
3082 id: "call-first".to_owned(),
3083 name: "cmd".to_owned(),
3084 arguments: r#"{"command":"first"}"#.to_owned(),
3085 },
3086 crate::model::ChatToolCall {
3087 id: "call-second".to_owned(),
3088 name: "cmd".to_owned(),
3089 arguments: r#"{"command":"second"}"#.to_owned(),
3090 },
3091 ],
3092 ),
3093 },
3094 SessionHistoryRecord::Message {
3095 timestamp: 2,
3096 message: ChatMessage::tool(
3097 "call-first".to_owned(),
3098 "cmd".to_owned(),
3099 serde_json::json!({"stdout":"first result","stderr":""}).to_string(),
3100 ),
3101 },
3102 SessionHistoryRecord::Message {
3103 timestamp: 3,
3104 message: ChatMessage::tool(
3105 "call-second".to_owned(),
3106 "cmd".to_owned(),
3107 serde_json::json!({"stdout":"second result","stderr":""}).to_string(),
3108 ),
3109 },
3110 ];
3111
3112 let state = UiState::from_history(&history, "secret", "model", None, false);
3113 let lines = transcript_lines(&state, 200);
3114 assert_eq!(
3115 lines.len(),
3116 3,
3117 "only the two call lines and their separator remain"
3118 );
3119 assert_eq!(
3120 lines[0].to_string(),
3121 "[tool:cmd \"first\"] > [\"first result\"]"
3122 );
3123 assert_eq!(
3124 lines[2].to_string(),
3125 "[tool:cmd \"second\"] > [\"second result\"]"
3126 );
3127 }
3128}
3129
3130#[cfg(test)]
3131mod skill_picker_tests {
3132 use super::*;
3133
3134 fn skill_names() -> Vec<String> {
3135 ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
3136 .into_iter()
3137 .map(str::to_owned)
3138 .collect()
3139 }
3140
3141 #[test]
3142 fn built_in_commands_share_the_slash_catalog_without_becoming_skills() {
3143 assert_eq!(
3144 command_names(vec!["release-notes".to_owned(), "settings".to_owned()]),
3145 vec!["exit", "release-notes", "settings"]
3146 );
3147 assert_eq!(
3148 builtin_command("/settings ignored arguments"),
3149 Some(BuiltinCommand::Settings)
3150 );
3151 assert_eq!(builtin_command(" /exit "), Some(BuiltinCommand::Exit));
3152 assert_eq!(builtin_command("/settings-extra"), None);
3153 }
3154
3155 #[test]
3156 fn settings_viewport_follows_focus_instead_of_truncating_the_catalog_head() {
3157 assert_eq!(selection_range(30, 0, 12), 0..12);
3158 assert_eq!(selection_range(30, 11, 12), 0..12);
3159 assert_eq!(selection_range(30, 12, 12), 1..13);
3160 assert_eq!(selection_range(30, 29, 12), 18..30);
3161 }
3162
3163 #[test]
3164 fn model_selection_uses_advertised_efforts_and_preserves_the_current_choice() {
3165 let mut state = UiState::from_history(&[], "secret", "old", Some("medium"), false);
3166 state.open_catalog(Ok(vec![ProviderModel {
3167 id: "openai/gpt-5.6-sol".to_owned(),
3168 efforts: Some(vec![
3169 "max".to_owned(),
3170 "high".to_owned(),
3171 "medium".to_owned(),
3172 "low".to_owned(),
3173 ]),
3174 }]));
3175 state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
3176
3177 let SettingsState::Effort { model, focus, .. } =
3178 state.settings.as_ref().expect("effort picker")
3179 else {
3180 panic!("model selection should open the effort picker");
3181 };
3182 assert_eq!(model.id, "openai/gpt-5.6-sol");
3183 assert_eq!(*focus, 3, "default occupies index zero before medium");
3184
3185 let selected = state
3186 .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3187 .expect("effort selection");
3188 assert_eq!(
3189 selected,
3190 ("openai/gpt-5.6-sol".to_owned(), Some("medium".to_owned()))
3191 );
3192 }
3193
3194 #[test]
3195 fn effort_default_selection_does_not_shift_to_the_first_advertised_effort() {
3196 let mut state = UiState::from_history(&[], "secret", "old", None, false);
3197 state.open_catalog(Ok(vec![ProviderModel {
3198 id: "model".to_owned(),
3199 efforts: Some(vec!["high".to_owned(), "low".to_owned()]),
3200 }]));
3201 state.handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
3202
3203 let selected = state
3204 .handle_settings_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3205 .expect("default effort selection");
3206 assert_eq!(selected, ("model".to_owned(), None));
3207 }
3208
3209 #[test]
3210 fn reasoning_indicator_changes_to_complete_and_stays_dark_gray() {
3211 let mut state = UiState::from_history(&[], "secret", "model", None, false);
3212 state.show_thinking();
3213
3214 let active_lines = transcript_lines(&state, 80);
3215 let active = active_lines.last().expect("reasoning line");
3216 assert!(active.to_string().starts_with("Reasoning... "));
3217 assert_eq!(active.style.fg, Some(Color::DarkGray));
3218
3219 state.complete_reasoning();
3220 let complete_lines = transcript_lines(&state, 80);
3221 let complete = complete_lines.last().expect("complete line");
3222 assert_eq!(complete.to_string(), "Reasoning Complete");
3223 assert_eq!(complete.style.fg, Some(Color::DarkGray));
3224 }
3225
3226 #[test]
3227 fn slash_picker_filters_only_leading_command_text_and_hides_without_matches() {
3228 let names = skill_names();
3229 assert_eq!(
3230 matching_skill_names("/", &names),
3231 vec!["alpha", "beta", "build", "charlie", "deploy", "doctor"]
3232 );
3233 assert_eq!(matching_skill_names("/b", &names), vec!["beta", "build"]);
3234 assert!(matching_skill_names("/missing", &names).is_empty());
3235 assert!(matching_skill_names("message /b", &names).is_empty());
3236 assert!(matching_skill_names("/beta arguments", &names).is_empty());
3237 }
3238
3239 #[test]
3240 fn slash_picker_focuses_the_top_match_and_moves_within_filtered_results() {
3241 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3242 .with_skill_names(skill_names());
3243 state.input = "/b".to_owned();
3244 state.input_changed();
3245
3246 assert!(state.skill_picker_visible());
3247 assert_eq!(state.skill_picker_focus, 0);
3248 assert!(state.move_skill_picker(true));
3249 assert_eq!(state.skill_picker_focus, 1);
3250 assert!(state.move_skill_picker(true));
3251 assert_eq!(state.skill_picker_focus, 1, "focus does not leave the list");
3252 assert!(state.move_skill_picker(false));
3253 assert_eq!(state.skill_picker_focus, 0);
3254
3255 state.input = "/missing".to_owned();
3256 state.input_changed();
3257 assert!(!state.skill_picker_visible());
3258 assert!(!state.move_skill_picker(true));
3259 }
3260
3261 #[test]
3262 fn focused_builtins_are_distinguished_from_skills() {
3263 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3264 .with_skill_names(command_names(skill_names()));
3265 state.input = "/se".to_owned();
3266 state.input_changed();
3267 assert_eq!(
3268 state.focused_builtin_command(),
3269 Some(BuiltinCommand::Settings)
3270 );
3271
3272 state.input = "/be".to_owned();
3273 state.input_changed();
3274 assert_eq!(state.focused_builtin_command(), None);
3275 }
3276
3277 #[test]
3278 fn selecting_the_focused_skill_leaves_the_completed_command_ready_to_send() {
3279 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3280 .with_skill_names(skill_names());
3281 state.input = "/b".to_owned();
3282 state.input_changed();
3283 state.move_skill_picker(true);
3284
3285 assert!(state.select_focused_skill());
3286 assert_eq!(state.input, "/build");
3287 assert_eq!(state.cursor, "/build".chars().count());
3288 assert!(
3289 !state.skill_picker_visible(),
3290 "the first Enter completes the input rather than sending it"
3291 );
3292 assert!(
3293 !state.select_focused_skill(),
3294 "a second Enter follows the normal send/attachment path"
3295 );
3296 }
3297
3298 #[test]
3299 fn slash_picker_overlays_without_reflowing_the_transcript_when_match_count_changes() {
3300 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3301 .with_skill_names(skill_names());
3302 let area = Rect::new(0, 0, 40, 16);
3303 state.transcript = (0..20)
3304 .map(|index| TranscriptItem::Assistant(format!("message {index}")))
3305 .collect();
3306
3307 state.input = "/a".to_owned();
3308 state.input_changed();
3309 let (narrow_chat, narrow_picker, narrow_input, _) = ui_layout(&state, area);
3310 let narrow_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
3311
3312 state.input = "/".to_owned();
3313 state.input_changed();
3314 let (broad_chat, broad_picker, broad_input, _) = ui_layout(&state, area);
3315 let broad_scroll = max_scroll_for_area(&state, Size::new(area.width, area.height));
3316
3317 assert_ne!(
3318 narrow_picker, broad_picker,
3319 "the overlay may fit its contents"
3320 );
3321 assert_eq!(narrow_chat, broad_chat);
3322 assert_eq!(narrow_input, broad_input);
3323 assert_eq!(
3324 narrow_scroll, broad_scroll,
3325 "the overlay does not reduce the transcript viewport"
3326 );
3327 }
3328
3329 #[test]
3330 fn slash_picker_keeps_the_focused_item_in_its_five_row_viewport() {
3331 assert_eq!(skill_picker_range(20, 0), 0..5);
3332 assert_eq!(skill_picker_range(20, 4), 0..5);
3333 assert_eq!(skill_picker_range(20, 5), 1..6);
3334 assert_eq!(skill_picker_range(20, 19), 15..20);
3335 }
3336
3337 #[test]
3338 fn slash_picker_overlays_the_ready_activity_indicator() {
3339 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3340 .with_skill_names(vec!["a".to_owned(), "b".to_owned()]);
3341 state.input = "/".to_owned();
3342 state.input_changed();
3343 let mut terminal =
3344 Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
3345 terminal
3346 .draw(|frame| draw(frame, &state))
3347 .expect("draw TUI");
3348
3349 let screen = terminal
3350 .backend()
3351 .buffer()
3352 .content()
3353 .iter()
3354 .map(|cell| cell.symbol())
3355 .collect::<String>();
3356 assert!(screen.contains("/a"));
3357 assert!(
3358 !screen.contains("ready"),
3359 "the skill picker should cover the ready activity indicator"
3360 );
3361 }
3362
3363 #[test]
3364 fn slash_picker_is_rendered_immediately_above_the_input() {
3365 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3366 .with_skill_names(skill_names());
3367 state.input = "/".to_owned();
3368 state.input_changed();
3369 let mut terminal =
3370 Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
3371 terminal
3372 .draw(|frame| draw(frame, &state))
3373 .expect("draw TUI");
3374
3375 let buffer = terminal.backend().buffer();
3376 assert_eq!(buffer[(0, 0)].symbol(), "╭");
3378 assert_eq!(buffer[(1, 1)].symbol(), "[");
3379 assert_eq!(buffer[(1, 2)].symbol(), "/");
3380 assert_eq!(buffer[(0, 8)].symbol(), "╭");
3381 assert_eq!(buffer[(0, 0)].fg, Color::Cyan);
3382 }
3383
3384 #[test]
3385 fn slash_picker_renders_count_and_distinct_focus_colors() {
3386 let mut state = UiState::from_history(&[], "secret", "model", None, false)
3387 .with_skill_names(skill_names());
3388 state.input = "/".to_owned();
3389 state.input_changed();
3390 let mut terminal =
3391 Terminal::new(ratatui::backend::TestBackend::new(30, 8)).expect("test terminal");
3392 terminal
3393 .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 8)))
3394 .expect("draw skill picker");
3395
3396 let buffer = terminal.backend().buffer();
3397 assert_eq!(buffer[(0, 0)].symbol(), "╭");
3398 assert_eq!(buffer[(0, 0)].fg, Color::Cyan);
3399 assert_eq!(buffer[(1, 1)].symbol(), "[");
3400 assert_eq!(buffer[(1, 1)].fg, Color::DarkGray);
3401 assert_eq!(buffer[(1, 2)].symbol(), "/");
3402 assert_eq!(buffer[(1, 2)].fg, Color::Cyan);
3403 assert_eq!(buffer[(1, 3)].symbol(), "/");
3404 assert_eq!(buffer[(1, 3)].fg, Color::DarkGray);
3405 }
3406}