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