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, 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::redaction::redact_secret;
31use crate::session::SessionHistoryRecord;
32
33const EVENT_POLL: Duration = Duration::from_millis(50);
34const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
35const MAX_DISPLAY_INPUT_CHARS: usize = 16 * 1024;
36const WORKER_SHUTDOWN_GRACE: Duration = Duration::from_secs(2);
37const MAX_INPUT_ROWS: u16 = 12;
40const WELCOME_MESSAGE: &str = concat!("Coding Agent Harness LUCY - v", env!("CARGO_PKG_VERSION"),);
41const WELCOME_TAGLINE: &str = "An ultra-thin harness for tomorrow's most powerful models";
42const WELCOME_START_COLOR: (u8, u8, u8) = (0, 180, 180);
43const WELCOME_END_COLOR: (u8, u8, u8) = (255, 215, 0);
44const USER_BORDER_COLOR: Color = Color::Rgb(192, 154, 0);
45const PENDING_TOOL_COLOR: Color = Color::Rgb(255, 165, 0);
46const WAITING_INPUT_MESSAGE: &str = "The agent is working. Please wait until it finishes.";
47const BUSY_INPUT_COLOR: Color = Color::DarkGray;
48const SKILL_PICKER_MAX_ROWS: usize = 5;
49
50pub(crate) fn run<W: Write>(mut harness: Harness, resumed: bool, stdout: W) -> Result<(), String> {
51 let secret = harness.provider.api_key().to_owned();
52 let context_window = harness
53 .context_window
54 .or_else(|| harness.provider.context_window());
55 harness.context_window = context_window;
56 let context_tokens = estimate_context_tokens(&harness.session.provider_messages());
57 let skill_names = harness
58 .session
59 .skills
60 .iter()
61 .map(|skill| skill.name.clone())
62 .collect();
63 let mut state = UiState::from_history(
64 &harness.session.history,
65 &secret,
66 &harness.session.llm.model,
67 harness.session.llm.effort.as_deref(),
68 resumed,
69 )
70 .with_attached_agents(harness.attached_agents.clone())
71 .with_skill_names(skill_names)
72 .with_context(context_window, context_tokens);
73 let (request_tx, request_rx) = mpsc::channel::<WorkerRequest>();
74 let (message_tx, message_rx) = mpsc::channel::<WorkerMessage>();
75
76 let stdout = stdout;
77 enable_raw_mode().map_err(|error| format!("unable to enable terminal input: {error}"))?;
78 let backend = CrosstermBackend::new(stdout);
79 let terminal = match Terminal::new(backend) {
80 Ok(terminal) => terminal,
81 Err(error) => {
82 let _ = disable_raw_mode();
83 return Err(format!("unable to initialize terminal UI: {error}"));
84 }
85 };
86 let mut terminal_guard = TerminalGuard::new(terminal);
87 let backend = terminal_guard.terminal_mut().backend_mut();
88 if let Err(error) = execute!(backend, EnterAlternateScreen, EnableMouseCapture, Hide) {
89 return Err(format!("unable to enter terminal UI: {error}"));
90 }
91 if supports_keyboard_enhancement() {
96 let _ = execute!(
97 backend,
98 PushKeyboardEnhancementFlags(
99 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
100 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES,
101 )
102 );
103 terminal_guard.keyboard_enhancement = true;
104 }
105 let worker = thread::spawn(move || worker_loop(&mut harness, request_rx, message_tx, resumed));
106
107 let result = event_loop(
108 terminal_guard.terminal_mut(),
109 &mut state,
110 &request_tx,
111 &message_rx,
112 );
113
114 if let Some(token) = state.active_cancel.take() {
115 let _ = token.cancel();
116 }
117 let _ = request_tx.send(WorkerRequest::Shutdown);
118 wait_for_worker(worker, WORKER_SHUTDOWN_GRACE);
119 drop(terminal_guard);
120 result
121}
122
123fn worker_loop(
124 harness: &mut Harness,
125 requests: Receiver<WorkerRequest>,
126 messages: Sender<WorkerMessage>,
127 resumed: bool,
128) {
129 let mut sink = ChannelSink {
130 sender: messages.clone(),
131 };
132 if sink
133 .emit_event(&ProtocolEvent::Session {
134 session_id: harness.session.id.clone(),
135 resumed,
136 })
137 .is_err()
138 {
139 return;
140 }
141
142 while let Ok(request) = requests.recv() {
143 match request {
144 WorkerRequest::Turn { text, cancel } => {
145 if let Err(error) = harness.handle_message(&text, &mut sink, Some(&cancel)) {
146 let message = redact_secret(&error, Some(harness.provider.api_key()));
147 let _ = sink.emit_event(&ProtocolEvent::Error { message });
148 }
149 let _ = messages.send(WorkerMessage::Finished);
150 }
151 WorkerRequest::Shutdown => break,
152 }
153 }
154}
155
156fn event_loop<W: Write>(
157 terminal: &mut Terminal<CrosstermBackend<W>>,
158 state: &mut UiState,
159 requests: &Sender<WorkerRequest>,
160 messages: &Receiver<WorkerMessage>,
161) -> Result<(), String> {
162 let mut quitting = false;
163 loop {
164 loop {
165 match messages.try_recv() {
166 Ok(WorkerMessage::Event(event)) => state.apply_event(event),
167 Ok(WorkerMessage::Thinking) => state.show_thinking(),
168 Ok(WorkerMessage::SkillInstructionAttached) => {
169 state.mark_latest_user_skill_attached()
170 }
171 Ok(WorkerMessage::ContextUsage(tokens)) => state.context_tokens = tokens,
172 Ok(WorkerMessage::CompactionStarted) => state.status = "compacting".to_owned(),
173 Ok(WorkerMessage::CompactionFinished {
174 tokens_before,
175 tokens_after,
176 }) => {
177 state.context_tokens = tokens_after;
178 state.status = "working".to_owned();
179 state.transcript.push(TranscriptItem::Info(format!(
180 "↻ context compacted ({} → {})",
181 format_context_tokens(tokens_before),
182 format_context_tokens(tokens_after)
183 )));
184 }
185 Ok(WorkerMessage::Finished) => {
186 release_finished_turn(terminal.backend_mut(), state);
187 discard_pending_input()?;
188 match state.status.as_str() {
189 "cancelling" => state.status = "사용자 중단".to_owned(),
190 "finalizing" => state.status = "ready".to_owned(),
191 _ => {}
192 }
193 if quitting {
194 return Ok(());
195 }
196 }
197 Err(TryRecvError::Empty) => break,
198 Err(TryRecvError::Disconnected) => {
199 if state.busy {
200 return Err("TUI worker stopped unexpectedly".to_owned());
201 }
202 return Ok(());
203 }
204 }
205 }
206
207 terminal
208 .draw(|frame| draw(frame, state))
209 .map_err(|error| format!("unable to render TUI: {error}"))?;
210
211 if quitting {
212 thread::sleep(EVENT_POLL);
213 continue;
214 }
215 if event::poll(EVENT_POLL)
216 .map_err(|error| format!("unable to read terminal input: {error}"))?
217 {
218 let event =
219 event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
220 if let Event::Mouse(mouse) = event {
221 let size = terminal
222 .size()
223 .map_err(|error| format!("unable to read terminal size: {error}"))?;
224 let max_scroll = max_scroll_for_area(state, size);
225 handle_mouse_event(state, mouse.kind, max_scroll);
226 continue;
227 }
228 let Event::Key(key) = event else {
229 continue;
230 };
231 if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
232 continue;
233 }
234 if is_ctrl_c(&key) {
235 if let Some(token) = state.active_cancel.as_ref() {
236 let _ = token.cancel();
237 quitting = true;
238 } else {
239 return Ok(());
240 }
241 continue;
242 }
243 if key.code == KeyCode::Esc {
244 if let Some(token) = state.active_cancel.as_ref() {
245 if token.cancel() {
246 state.status = "cancelling".to_owned();
247 }
248 }
249 continue;
250 }
251 if state.busy {
252 continue;
255 }
256 match key.code {
257 KeyCode::Enter => {
258 if key.modifiers.contains(KeyModifiers::SHIFT)
263 || key.modifiers.contains(KeyModifiers::ALT)
264 {
265 if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
266 insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
267 state.input_changed();
268 }
269 continue;
270 }
271 if state.select_focused_skill() {
272 continue;
273 }
274 let text = std::mem::take(&mut state.input);
275 state.cursor = 0;
276 state.reset_skill_picker();
277 if text.trim().is_empty() {
278 continue;
279 }
280 let secret = state.secret.clone();
281 state.auto_scroll = true;
282 state.scroll = 0;
283 state.add_user(&text, &secret);
284 let cancel = CancellationToken::new();
285 state.active_cancel = Some(cancel.clone());
286 state.busy = true;
287 state.status = "working".to_owned();
288 requests
289 .send(WorkerRequest::Turn { text, cancel })
290 .map_err(|_| "TUI worker is unavailable".to_owned())?;
291 }
292 KeyCode::Char(character) => {
293 if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
294 insert_at_cursor(&mut state.input, &mut state.cursor, character);
295 state.input_changed();
296 }
297 }
298 KeyCode::Backspace => {
299 if remove_before_cursor(&mut state.input, &mut state.cursor) {
300 state.input_changed();
301 }
302 }
303 KeyCode::Left => {
304 state.cursor = state.cursor.saturating_sub(1);
305 state.cursor_epoch = Instant::now();
306 }
307 KeyCode::Right => {
308 state.cursor = (state.cursor + 1).min(state.input.chars().count());
309 state.cursor_epoch = Instant::now();
310 }
311 KeyCode::Home => {
312 state.cursor = 0;
313 state.cursor_epoch = Instant::now();
314 }
315 KeyCode::End => {
316 state.cursor = state.input.chars().count();
317 state.cursor_epoch = Instant::now();
318 }
319 KeyCode::Up => {
320 if !state.move_skill_picker(false) {
321 let size = terminal
322 .size()
323 .map_err(|error| format!("unable to read terminal size: {error}"))?;
324 let max_scroll = max_scroll_for_area(state, size);
325 scroll_up(state, max_scroll);
326 }
327 }
328 KeyCode::Down => {
329 if !state.move_skill_picker(true) {
330 let size = terminal
331 .size()
332 .map_err(|error| format!("unable to read terminal size: {error}"))?;
333 let max_scroll = max_scroll_for_area(state, size);
334 scroll_down(state, max_scroll);
335 }
336 }
337 KeyCode::PageUp => {
338 let size = terminal
339 .size()
340 .map_err(|error| format!("unable to read terminal size: {error}"))?;
341 let max_scroll = max_scroll_for_area(state, size);
342 scroll_up(state, max_scroll);
343 }
344 KeyCode::PageDown => {
345 let size = terminal
346 .size()
347 .map_err(|error| format!("unable to read terminal size: {error}"))?;
348 let max_scroll = max_scroll_for_area(state, size);
349 scroll_down(state, max_scroll);
350 }
351 _ => {}
352 }
353 }
354 }
355}
356
357fn discard_pending_input() -> Result<(), String> {
358 while event::poll(Duration::ZERO)
359 .map_err(|error| format!("unable to read terminal input: {error}"))?
360 {
361 let _ = event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
362 }
363 Ok(())
364}
365
366fn is_ctrl_c(key: &KeyEvent) -> bool {
367 key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
368}
369
370fn handle_mouse_event(state: &mut UiState, kind: MouseEventKind, max_scroll: u16) {
371 match kind {
372 MouseEventKind::ScrollUp => scroll_up(state, max_scroll),
373 MouseEventKind::ScrollDown => scroll_down(state, max_scroll),
374 _ => {}
375 }
376}
377
378fn scroll_up(state: &mut UiState, max_scroll: u16) {
379 if state.auto_scroll {
380 state.scroll = max_scroll;
381 state.auto_scroll = false;
382 } else {
383 state.scroll = state.scroll.min(max_scroll);
384 }
385 state.scroll = state.scroll.saturating_sub(3);
386}
387
388fn scroll_down(state: &mut UiState, max_scroll: u16) {
389 if state.auto_scroll {
390 return;
391 }
392 state.scroll = state.scroll.saturating_add(3).min(max_scroll);
393 if state.scroll == max_scroll {
394 state.auto_scroll = true;
397 state.scroll = 0;
398 }
399}
400
401fn wait_for_worker(worker: JoinHandle<()>, grace: Duration) {
402 let deadline = std::time::Instant::now() + grace;
403 while !worker.is_finished() && std::time::Instant::now() < deadline {
404 thread::sleep(Duration::from_millis(5));
405 }
406 if worker.is_finished() {
407 let _ = worker.join();
408 }
409}
410
411struct TerminalGuard<W: Write> {
412 terminal: Option<Terminal<CrosstermBackend<W>>>,
413 keyboard_enhancement: bool,
414}
415
416impl<W: Write> TerminalGuard<W> {
417 fn new(terminal: Terminal<CrosstermBackend<W>>) -> Self {
418 Self {
419 terminal: Some(terminal),
420 keyboard_enhancement: false,
421 }
422 }
423
424 fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {
425 self.terminal
426 .as_mut()
427 .expect("terminal guard is initialized")
428 }
429}
430
431impl<W: Write> Drop for TerminalGuard<W> {
432 fn drop(&mut self) {
433 let Some(mut terminal) = self.terminal.take() else {
434 return;
435 };
436 if self.keyboard_enhancement {
437 let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
438 }
439 let _ = terminal.show_cursor();
440 let _ = disable_raw_mode();
441 let _ = execute!(
442 terminal.backend_mut(),
443 DisableMouseCapture,
444 LeaveAlternateScreen,
445 Show
446 );
447 let _ = terminal.backend_mut().flush();
448 }
449}
450
451fn supports_keyboard_enhancement() -> bool {
456 fn env(name: &str) -> Option<String> {
457 std::env::var(name).ok().map(|value| value.to_lowercase())
458 }
459 let term = env("TERM").unwrap_or_default();
460 let program = env("TERM_PROGRAM").unwrap_or_default();
461 if term.starts_with("xterm-kitty")
462 || term.starts_with("ghostty")
463 || term.starts_with("xterm-ghostty")
464 {
465 return true;
466 }
467 matches!(
468 program.as_str(),
469 "ghostty" | "kitty" | "wezterm" | "alacritty" | "foot" | "footclient" | "iterm.app"
470 )
471}
472
473#[derive(Debug, Clone, Copy, PartialEq, Eq)]
474enum TurnNotification {
475 Completed,
476 Interrupted,
477 Failed,
478}
479
480impl TurnNotification {
481 fn body(self) -> &'static str {
482 match self {
483 Self::Completed => "Turn complete",
484 Self::Interrupted => "Turn interrupted",
485 Self::Failed => "Turn failed",
486 }
487 }
488}
489
490fn turn_notification_for_status(status: &str) -> TurnNotification {
491 match status {
492 "cancelling" | "사용자 중단" => TurnNotification::Interrupted,
493 "error" => TurnNotification::Failed,
494 _ => TurnNotification::Completed,
495 }
496}
497
498fn send_turn_notification<W: Write>(
504 writer: &mut W,
505 notification: TurnNotification,
506) -> io::Result<()> {
507 writer.write_all(b"\x1b]777;notify;Lucy;")?;
508 writer.write_all(notification.body().as_bytes())?;
509 writer.write_all(b"\x07")?;
510 writer.flush()
511}
512
513fn release_finished_turn<W: Write>(writer: &mut W, state: &mut UiState) {
514 let was_busy = state.busy;
515 let notification = turn_notification_for_status(&state.status);
516 state.busy = false;
517 state.active_cancel = None;
518 if was_busy {
519 let _ = send_turn_notification(writer, notification);
522 }
523}
524
525enum WorkerRequest {
526 Turn {
527 text: String,
528 cancel: CancellationToken,
529 },
530 Shutdown,
531}
532
533enum WorkerMessage {
534 Event(ProtocolEvent),
535 Thinking,
536 SkillInstructionAttached,
537 ContextUsage(usize),
538 CompactionStarted,
539 CompactionFinished {
540 tokens_before: usize,
541 tokens_after: usize,
542 },
543 Finished,
544}
545
546struct ChannelSink {
547 sender: Sender<WorkerMessage>,
548}
549
550impl EventSink for ChannelSink {
551 fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
552 self.sender
553 .send(WorkerMessage::Event(event.clone()))
554 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
555 }
556
557 fn reasoning_started(&mut self) -> io::Result<()> {
558 self.sender
559 .send(WorkerMessage::Thinking)
560 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
561 }
562
563 fn skill_instruction_attached(&mut self, _name: &str) -> io::Result<()> {
564 self.sender
565 .send(WorkerMessage::SkillInstructionAttached)
566 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
567 }
568
569 fn context_usage(&mut self, tokens: usize) -> io::Result<()> {
570 self.sender
571 .send(WorkerMessage::ContextUsage(tokens))
572 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
573 }
574
575 fn compaction_started(&mut self) -> io::Result<()> {
576 self.sender
577 .send(WorkerMessage::CompactionStarted)
578 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
579 }
580
581 fn compaction_finished(&mut self, tokens_before: usize, tokens_after: usize) -> io::Result<()> {
582 self.sender
583 .send(WorkerMessage::CompactionFinished {
584 tokens_before,
585 tokens_after,
586 })
587 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
588 }
589}
590
591struct UiState {
592 model: String,
593 effort: Option<String>,
594 context_window: Option<usize>,
595 context_tokens: usize,
596 secret: String,
597 transcript: Vec<TranscriptItem>,
598 input: String,
599 cursor: usize,
600 status: String,
601 busy: bool,
602 active_cancel: Option<CancellationToken>,
603 scroll: u16,
604 auto_scroll: bool,
605 cursor_epoch: Instant,
606 welcome_visible: bool,
607 attached_agents: Vec<String>,
608 skill_names: Vec<String>,
609 skill_picker_focus: usize,
610 skill_picker_suppressed: bool,
611}
612
613impl UiState {
614 fn from_history(
615 history: &[SessionHistoryRecord],
616 secret: &str,
617 model: &str,
618 effort: Option<&str>,
619 resumed: bool,
620 ) -> Self {
621 let mut state = Self {
622 model: model.to_owned(),
623 effort: effort.map(str::to_owned),
624 context_window: None,
625 context_tokens: 1,
626 secret: secret.to_owned(),
627 transcript: Vec::new(),
628 input: String::new(),
629 cursor: 0,
630 status: "ready".to_owned(),
631 busy: false,
632 active_cancel: None,
633 scroll: 0,
634 auto_scroll: true,
635 cursor_epoch: Instant::now(),
636 welcome_visible: !resumed && history.is_empty(),
637 attached_agents: Vec::new(),
638 skill_names: Vec::new(),
639 skill_picker_focus: 0,
640 skill_picker_suppressed: false,
641 };
642 for record in history {
643 state.add_history_record(record);
644 }
645 state
646 }
647
648 fn with_attached_agents(mut self, attached_agents: Vec<String>) -> Self {
649 self.attached_agents = attached_agents;
650 self
651 }
652
653 fn with_skill_names(mut self, skill_names: Vec<String>) -> Self {
654 self.skill_names = skill_names;
655 self
656 }
657
658 fn with_context(mut self, context_window: Option<usize>, context_tokens: usize) -> Self {
659 self.context_window = context_window;
660 self.context_tokens = context_tokens.max(1);
661 self
662 }
663
664 fn matching_skill_names(&self) -> Vec<&str> {
667 matching_skill_names(&self.input, &self.skill_names)
668 }
669
670 fn reset_skill_picker(&mut self) {
671 self.skill_picker_focus = 0;
672 self.skill_picker_suppressed = false;
673 }
674
675 fn skill_picker_visible(&self) -> bool {
676 !self.skill_picker_suppressed && !self.matching_skill_names().is_empty()
677 }
678
679 fn input_changed(&mut self) {
680 self.reset_skill_picker();
681 self.cursor_epoch = Instant::now();
682 }
683
684 fn move_skill_picker(&mut self, down: bool) -> bool {
688 let match_count = self.matching_skill_names().len();
689 if self.skill_picker_suppressed || match_count == 0 {
690 return false;
691 }
692 if down {
693 self.skill_picker_focus = (self.skill_picker_focus + 1).min(match_count - 1);
694 } else {
695 self.skill_picker_focus = self.skill_picker_focus.saturating_sub(1);
696 }
697 true
698 }
699
700 fn select_focused_skill(&mut self) -> bool {
704 if self.skill_picker_suppressed {
705 return false;
706 }
707 let Some(name) = self
708 .matching_skill_names()
709 .get(self.skill_picker_focus)
710 .map(|name| (*name).to_owned())
711 else {
712 return false;
713 };
714 self.input = format!("/{name}");
715 self.cursor = self.input.chars().count();
716 self.skill_picker_suppressed = true;
719 self.cursor_epoch = Instant::now();
720 true
721 }
722
723 fn add_history_record(&mut self, record: &SessionHistoryRecord) {
724 match record {
725 SessionHistoryRecord::Message { message, .. } => self.add_message(message),
726 SessionHistoryRecord::Interruption {
727 assistant_text,
728 tool_calls,
729 tool_results,
730 reason,
731 phase,
732 ..
733 } => {
734 if !assistant_text.is_empty() {
735 self.add_assistant_message(assistant_text);
736 }
737 for call in tool_calls {
738 self.add_tool_call(call);
739 }
740 for observation in tool_results {
741 self.add_tool_result(
742 &observation.id,
743 &observation.name,
744 observation.result.clone(),
745 );
746 }
747 self.transcript
748 .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
749 }
750 SessionHistoryRecord::Compaction(compaction) => {
751 self.transcript.push(TranscriptItem::Info(format!(
752 "↻ context compacted ({} before)",
753 format_context_tokens(compaction.tokens_before)
754 )));
755 }
756 }
757 }
758
759 fn add_message(&mut self, message: &ChatMessage) {
760 match message.role.as_str() {
761 "user" => {
762 let secret = self.secret.clone();
763 self.add_user(message.content.as_deref().unwrap_or(""), &secret);
764 }
765 "assistant" => {
766 if let Some(content) = message.content.as_deref() {
767 self.add_assistant_message(content);
768 }
769 for call in &message.tool_calls {
770 self.add_tool_call(call);
771 }
772 }
773 "tool" => {
774 let result = message
775 .content
776 .as_deref()
777 .and_then(|content| serde_json::from_str::<Value>(content).ok())
778 .unwrap_or_else(|| Value::String(message.content.clone().unwrap_or_default()));
779 self.add_tool_result(
780 message.tool_call_id.as_deref().unwrap_or(""),
781 message.name.as_deref().unwrap_or("cmd"),
782 result,
783 );
784 }
785 _ => {}
786 }
787 }
788
789 fn add_user(&mut self, text: &str, secret: &str) {
790 self.welcome_visible = false;
791 self.transcript.push(TranscriptItem::User {
792 text: redact_secret(text, Some(secret)),
793 skill_instruction_attached: false,
794 });
795 }
796
797 fn mark_latest_user_skill_attached(&mut self) {
798 if let Some(TranscriptItem::User {
799 skill_instruction_attached,
800 ..
801 }) = self.transcript.last_mut()
802 {
803 *skill_instruction_attached = true;
804 }
805 }
806
807 fn clear_thinking(&mut self) {
808 if matches!(self.transcript.last(), Some(TranscriptItem::Thinking)) {
809 self.transcript.pop();
810 }
811 }
812
813 fn show_thinking(&mut self) {
814 self.status = "working".to_owned();
815 if !matches!(self.transcript.last(), Some(TranscriptItem::Thinking)) {
816 self.transcript.push(TranscriptItem::Thinking);
817 }
818 }
819
820 fn add_assistant(&mut self, text: &str) {
821 self.clear_thinking();
822 if let Some(TranscriptItem::Assistant(current)) = self.transcript.last_mut() {
823 current.push_str(text);
824 } else {
825 self.add_assistant_message(text);
826 }
827 }
828
829 fn add_assistant_message(&mut self, text: &str) {
830 self.transcript
831 .push(TranscriptItem::Assistant(text.to_owned()));
832 }
833
834 fn add_tool_call(&mut self, call: &crate::model::ChatToolCall) {
835 self.clear_thinking();
836 self.transcript.push(TranscriptItem::ToolCall {
837 id: call.id.clone(),
838 name: call.name.clone(),
839 arguments: call.arguments.clone(),
840 });
841 }
842
843 fn add_tool_result(&mut self, id: &str, name: &str, result: Value) {
844 self.transcript.push(TranscriptItem::ToolResult {
845 id: id.to_owned(),
846 name: name.to_owned(),
847 result,
848 });
849 }
850
851 fn cursor_visible(&self) -> bool {
852 !self.busy
853 && (self.cursor_epoch.elapsed().as_millis() / CURSOR_BLINK_INTERVAL.as_millis())
854 .is_multiple_of(2)
855 }
856
857 fn apply_event(&mut self, event: ProtocolEvent) {
858 match event {
859 ProtocolEvent::Session { .. } => {}
860 ProtocolEvent::AssistantDelta { text } => self.add_assistant(&text),
861 ProtocolEvent::ToolCall {
862 id,
863 name,
864 arguments,
865 } => self.add_tool_call(&crate::model::ChatToolCall {
866 id,
867 name,
868 arguments,
869 }),
870 ProtocolEvent::ToolResult { id, name, result } => {
871 self.add_tool_result(&id, &name, result)
872 }
873 ProtocolEvent::TurnEnd => {
874 self.clear_thinking();
875 self.status = "finalizing".to_owned();
876 self.transcript
877 .push(TranscriptItem::Info("✓ turn complete".to_owned()));
878 }
879 ProtocolEvent::TurnInterrupted { reason, phase } => {
880 self.status = "cancelling".to_owned();
881 self.transcript
882 .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
883 }
884 ProtocolEvent::Error { message } => {
885 self.status = "error".to_owned();
886 self.transcript.push(TranscriptItem::Error(message));
887 }
888 }
889 }
890}
891
892#[derive(Debug, Clone, PartialEq)]
893enum TranscriptItem {
894 User {
895 text: String,
896 skill_instruction_attached: bool,
897 },
898 Assistant(String),
899 ToolCall {
900 id: String,
901 name: String,
902 arguments: String,
903 },
904 ToolResult {
905 id: String,
906 name: String,
907 result: Value,
908 },
909 Error(String),
910 Info(String),
911 Thinking,
912}
913
914fn ui_layout(state: &UiState, area: Rect) -> (Rect, Option<Rect>, Rect, Rect) {
915 let input_rows = input_visible_rows(state, area.width.saturating_sub(2));
916 let input_height = input_rows.clamp(1, MAX_INPUT_ROWS) + 2;
917 let chunks = Layout::default()
918 .direction(Direction::Vertical)
919 .constraints([
920 Constraint::Min(1),
921 Constraint::Length(input_height),
922 Constraint::Length(1),
923 ])
924 .split(area);
925 let picker_height = skill_picker_height(state);
926 let picker_area = (picker_height > 0).then(|| {
927 Rect::new(
928 chunks[1].x,
929 chunks[1].y.saturating_sub(picker_height),
930 chunks[1].width,
931 picker_height,
932 )
933 });
934 (chunks[0], picker_area, chunks[1], chunks[2])
935}
936
937fn max_scroll_for_area(state: &UiState, size: Size) -> u16 {
938 let area = Rect::new(0, 0, size.width, size.height);
939 let (chat_chunk, picker_area, _, _) = ui_layout(state, area);
940 let chat_height = chat_chunk.height.saturating_sub(1);
941 let chat_area = Rect::new(chat_chunk.x, chat_chunk.y, chat_chunk.width, chat_height);
942 let visible_chat_height = visible_chat_height(chat_area, picker_area);
943 let lines = transcript_lines(state, chat_chunk.width);
944 lines
945 .len()
946 .saturating_sub(visible_chat_height as usize)
947 .min(u16::MAX as usize) as u16
948}
949
950fn visible_chat_height(chat_area: Rect, picker_area: Option<Rect>) -> u16 {
954 let Some(picker_area) = picker_area else {
955 return chat_area.height;
956 };
957 let overlap_start = chat_area.y.max(picker_area.y);
958 let overlap_end = chat_area
959 .y
960 .saturating_add(chat_area.height)
961 .min(picker_area.y.saturating_add(picker_area.height));
962 chat_area
963 .height
964 .saturating_sub(overlap_end.saturating_sub(overlap_start))
965}
966
967fn input_visible_rows(state: &UiState, width: u16) -> u16 {
969 let width = width as usize;
970 if width == 0 {
971 return 1;
972 }
973 let prompt = input_display_text(state);
974 let wrapped = wrap_text(&prompt, width);
975 wrapped.len().max(1) as u16
976}
977
978fn input_prompt(input: &str) -> String {
979 input.to_owned()
980}
981
982fn input_display_text(state: &UiState) -> String {
983 if state.busy {
984 WAITING_INPUT_MESSAGE.to_owned()
985 } else {
986 redact_secret(&input_prompt(&state.input), Some(&state.secret))
987 }
988}
989
990fn matching_skill_names<'a>(input: &str, skill_names: &'a [String]) -> Vec<&'a str> {
993 let Some(query) = input.strip_prefix('/') else {
994 return Vec::new();
995 };
996 if query.chars().any(char::is_whitespace) {
997 return Vec::new();
998 }
999 skill_names
1000 .iter()
1001 .map(String::as_str)
1002 .filter(|name| name.starts_with(query))
1003 .collect()
1004}
1005
1006fn skill_picker_height(state: &UiState) -> u16 {
1007 if state.skill_picker_visible() {
1008 (state
1009 .matching_skill_names()
1010 .len()
1011 .min(SKILL_PICKER_MAX_ROWS)
1012 + 1) as u16
1013 } else {
1014 0
1015 }
1016}
1017
1018fn skill_picker_range(total: usize, focus: usize) -> std::ops::Range<usize> {
1019 let start = focus
1020 .saturating_add(1)
1021 .saturating_sub(SKILL_PICKER_MAX_ROWS);
1022 start.min(total)..(start + SKILL_PICKER_MAX_ROWS).min(total)
1023}
1024
1025fn active_skill_trigger<'a>(input: &'a str, skill_names: &[String]) -> Option<&'a str> {
1029 let invocation = input.strip_prefix('/')?;
1030 let name = invocation
1031 .split_once(char::is_whitespace)
1032 .map_or(invocation, |(name, _)| name);
1033 if name.is_empty() || !skill_names.iter().any(|skill_name| skill_name == name) {
1034 return None;
1035 }
1036 Some(&input[..1 + name.len()])
1037}
1038
1039fn styled_text_lines(
1042 input: &str,
1043 active_skill_trigger: Option<&str>,
1044 width: usize,
1045 text_style: Style,
1046) -> Vec<Line<'static>> {
1047 let trigger_len = active_skill_trigger.map_or(0, |trigger| trigger.chars().count());
1048 let mut char_offset = 0usize;
1049 let mut lines = Vec::new();
1050
1051 for source_line in input.split('\n') {
1052 for row in wrap_line(source_line, width) {
1053 let mut spans = Vec::new();
1054 let mut text = String::new();
1055 let mut highlighted = None;
1056 for character in row.chars() {
1057 let should_highlight = char_offset < trigger_len;
1058 if highlighted != Some(should_highlight) && !text.is_empty() {
1059 spans.push(styled_text_span(
1060 std::mem::take(&mut text),
1061 highlighted.unwrap_or(false),
1062 text_style,
1063 ));
1064 }
1065 highlighted = Some(should_highlight);
1066 text.push(character);
1067 char_offset += 1;
1068 }
1069 if !text.is_empty() {
1070 spans.push(styled_text_span(
1071 text,
1072 highlighted.unwrap_or(false),
1073 text_style,
1074 ));
1075 }
1076 if spans.is_empty() {
1077 spans.push(Span::styled(String::new(), text_style));
1078 }
1079 lines.push(Line::from(spans));
1080 }
1081 char_offset += 1;
1084 }
1085
1086 lines
1087}
1088
1089fn styled_text_span(text: String, highlighted: bool, text_style: Style) -> Span<'static> {
1090 if highlighted {
1091 Span::styled(text, Style::default().fg(Color::Cyan))
1092 } else {
1093 Span::styled(text, text_style)
1094 }
1095}
1096
1097fn cursor_row(input: &str, cursor: usize, width: usize) -> u16 {
1098 let prefix: String = input.chars().take(cursor).collect();
1099 wrap_text(&prefix, width)
1100 .len()
1101 .saturating_sub(1)
1102 .min(u16::MAX as usize) as u16
1103}
1104
1105fn insert_at_cursor(input: &mut String, cursor: &mut usize, character: char) {
1106 let byte_index = input
1107 .char_indices()
1108 .nth(*cursor)
1109 .map_or(input.len(), |(index, _)| index);
1110 input.insert(byte_index, character);
1111 *cursor += 1;
1112}
1113
1114fn remove_before_cursor(input: &mut String, cursor: &mut usize) -> bool {
1115 if *cursor == 0 {
1116 return false;
1117 }
1118 let end = input
1119 .char_indices()
1120 .nth(*cursor)
1121 .map_or(input.len(), |(index, _)| index);
1122 let start = input
1123 .char_indices()
1124 .nth(*cursor - 1)
1125 .map(|(index, _)| index)
1126 .unwrap_or(0);
1127 input.replace_range(start..end, "");
1128 *cursor -= 1;
1129 true
1130}
1131
1132fn draw(frame: &mut Frame<'_>, state: &UiState) {
1133 let (chat_chunk, picker_area, input_chunk, status_area) = ui_layout(state, frame.area());
1134
1135 let chat_height = chat_chunk.height.saturating_sub(1);
1138 let chat_area = Rect::new(chat_chunk.x, chat_chunk.y, chat_chunk.width, chat_height);
1139 let visible_chat_height = visible_chat_height(chat_area, picker_area);
1140 let visible_chat_area = Rect::new(
1141 chat_area.x,
1142 chat_area.y,
1143 chat_area.width,
1144 visible_chat_height,
1145 );
1146 let activity_area = Rect::new(
1147 chat_chunk.x,
1148 chat_chunk.y + chat_height,
1149 chat_chunk.width,
1150 chat_chunk.height - chat_height,
1151 );
1152
1153 let width = chat_chunk.width;
1154 if state.welcome_visible {
1155 let welcome_lines = welcome_lines(&state.attached_agents);
1156 let welcome_height = (welcome_lines.len() as u16).min(visible_chat_area.height);
1157 let welcome_area = Rect::new(
1158 visible_chat_area.x,
1159 visible_chat_area.y + visible_chat_area.height.saturating_sub(welcome_height) / 2,
1160 visible_chat_area.width,
1161 welcome_height,
1162 );
1163 let welcome = Paragraph::new(welcome_lines).alignment(Alignment::Center);
1164 frame.render_widget(welcome, welcome_area);
1165 } else {
1166 let lines = transcript_lines(state, width);
1167 let available = visible_chat_area.height as usize;
1168 let max_scroll = lines.len().saturating_sub(available).min(u16::MAX as usize) as u16;
1169 let scroll = if state.auto_scroll {
1170 max_scroll
1171 } else {
1172 state.scroll.min(max_scroll)
1173 };
1174 let transcript = Paragraph::new(lines).scroll((scroll, 0));
1175 frame.render_widget(transcript, visible_chat_area);
1176 }
1177
1178 let activity_text = if state.status == "working" {
1179 format!("{} working", spinner_frame(state))
1180 } else if state.status == "compacting" {
1181 format!("{} compacting", spinner_frame(state))
1182 } else {
1183 format!("● {}", state.status)
1184 };
1185 let activity = Line::styled(activity_text, activity_style_for(state));
1186 frame.render_widget(Paragraph::new(activity), activity_area);
1187
1188 if let Some(picker_area) = picker_area {
1189 draw_skill_picker(frame, state, picker_area);
1190 }
1191
1192 let input_style = if state.busy {
1193 waiting_input_style()
1194 } else {
1195 user_message_style()
1196 };
1197 let input_text_style = if state.busy {
1198 waiting_input_style()
1199 } else {
1200 Style::default().fg(Color::White)
1201 };
1202 let input_block = Block::default()
1203 .borders(Borders::ALL)
1204 .border_type(ratatui::widgets::BorderType::Rounded)
1205 .border_style(input_style);
1206 let input_area = input_block.inner(input_chunk);
1207 let prompt = input_display_text(state);
1208 let input_rows =
1209 input_visible_rows(state, frame.area().width.saturating_sub(2)).clamp(1, MAX_INPUT_ROWS);
1210 let wrapped = wrap_text(&prompt, input_area.width.max(1) as usize);
1211 let visible = (wrapped.len() as u16).clamp(1, input_rows);
1212 let cursor_row = cursor_row(&prompt, state.cursor, input_area.width.max(1) as usize);
1213 let bottom_scroll = (wrapped.len() as u16).saturating_sub(visible);
1214 let cursor_scroll = (cursor_row + 1).saturating_sub(visible);
1215 let input_scroll = bottom_scroll.min(cursor_scroll);
1216 let active_skill_trigger = (!state.busy)
1217 .then(|| active_skill_trigger(&prompt, &state.skill_names))
1218 .flatten();
1219 let input_lines = styled_text_lines(
1220 &prompt,
1221 active_skill_trigger,
1222 input_area.width.max(1) as usize,
1223 input_text_style,
1224 );
1225 let input = Paragraph::new(input_lines)
1226 .style(input_text_style)
1227 .scroll((input_scroll, 0))
1228 .block(input_block);
1229 frame.render_widget(input, input_chunk);
1230
1231 let effort = state.effort.as_deref().unwrap_or("default");
1232 let status_text = format!("model={} · effort={effort}", state.model);
1233 let context_text = context_status_text(state);
1234 let context_width = UnicodeWidthStr::width(context_text.as_str()) as u16;
1235 let context_area_width = context_width.min(status_area.width.saturating_sub(1));
1236 let status_chunks = Layout::default()
1237 .direction(Direction::Horizontal)
1238 .constraints([Constraint::Min(1), Constraint::Length(context_area_width)])
1239 .split(status_area);
1240 let status = Paragraph::new(redact_secret(&status_text, Some(&state.secret)))
1241 .style(Style::default().fg(Color::DarkGray));
1242 frame.render_widget(status, status_chunks[0]);
1243 if context_area_width > 0 {
1244 let context = Paragraph::new(context_text)
1245 .alignment(Alignment::Right)
1246 .style(context_status_style(state));
1247 frame.render_widget(context, status_chunks[1]);
1248 }
1249 if state.cursor_visible() && !input_area.is_empty() && visible > 0 {
1252 let cursor_prefix: String = prompt.chars().take(state.cursor).collect();
1253 let cursor_rows = wrap_text(&cursor_prefix, input_area.width.max(1) as usize);
1254 let cursor_line = cursor_rows.last().map(String::as_str).unwrap_or("");
1255 let cursor_offset = UnicodeWidthStr::width(cursor_line) as u16;
1256 let cursor_x = input_area.x + cursor_offset.min(input_area.width.saturating_sub(1));
1257 let cursor_y = input_area.y + cursor_row.saturating_sub(input_scroll);
1258 frame.set_cursor_position((cursor_x, cursor_y));
1259 }
1260}
1261
1262fn draw_skill_picker(frame: &mut Frame<'_>, state: &UiState, area: Rect) {
1263 let matches = state.matching_skill_names();
1264 let total = matches.len();
1265 if total == 0 || area.is_empty() {
1266 return;
1267 }
1268 frame.render_widget(Clear, area);
1272 let focus = state.skill_picker_focus.min(total - 1);
1273 let header = Line::styled(
1274 format!("[{}/{}]", focus + 1, total),
1275 Style::default().fg(Color::DarkGray),
1276 );
1277 frame.render_widget(
1278 Paragraph::new(header),
1279 Rect::new(area.x, area.y, area.width, 1),
1280 );
1281
1282 for (row, index) in skill_picker_range(total, focus).enumerate() {
1283 let style = if index == focus {
1284 Style::default().fg(Color::Cyan)
1285 } else {
1286 Style::default().fg(Color::DarkGray)
1287 };
1288 let skill = Line::styled(format!("/{}", matches[index]), style);
1289 frame.render_widget(
1290 Paragraph::new(skill),
1291 Rect::new(area.x, area.y + 1 + row as u16, area.width, 1),
1292 );
1293 }
1294}
1295
1296fn welcome_line() -> Line<'static> {
1297 let character_count = WELCOME_MESSAGE.chars().count();
1298 let spans = WELCOME_MESSAGE
1299 .chars()
1300 .enumerate()
1301 .map(|(index, character)| {
1302 let progress = if character_count <= 1 {
1303 0.0
1304 } else {
1305 index as f32 / (character_count - 1) as f32
1306 };
1307 let color = Color::Rgb(
1308 interpolate_color(WELCOME_START_COLOR.0, WELCOME_END_COLOR.0, progress),
1309 interpolate_color(WELCOME_START_COLOR.1, WELCOME_END_COLOR.1, progress),
1310 interpolate_color(WELCOME_START_COLOR.2, WELCOME_END_COLOR.2, progress),
1311 );
1312 Span::styled(character.to_string(), Style::default().fg(color))
1313 })
1314 .collect::<Vec<_>>();
1315 Line::from(spans)
1316}
1317
1318fn interpolate_color(start: u8, end: u8, progress: f32) -> u8 {
1319 (start as f32 + (end as f32 - start as f32) * progress).round() as u8
1320}
1321
1322fn welcome_lines(attached_agents: &[String]) -> Vec<Line<'static>> {
1323 let mut lines = vec![
1324 welcome_line(),
1325 Line::styled(WELCOME_TAGLINE, Style::default().fg(Color::DarkGray)),
1326 Line::raw(""),
1327 ];
1328
1329 if attached_agents.is_empty() {
1330 lines.push(Line::styled(
1331 "Attached AGENTS.md: none",
1332 Style::default().fg(Color::DarkGray),
1333 ));
1334 } else {
1335 lines.push(Line::styled(
1336 "Attached AGENTS.md:",
1337 Style::default().fg(Color::DarkGray),
1338 ));
1339 lines.extend(
1340 attached_agents.iter().map(|path| {
1341 Line::styled(format!("• {path}"), Style::default().fg(Color::DarkGray))
1342 }),
1343 );
1344 }
1345
1346 lines
1347}
1348
1349fn transcript_lines(state: &UiState, width: u16) -> Vec<Line<'static>> {
1350 let width = width.max(1) as usize;
1351 let mut lines = Vec::new();
1352 let mut rendered_item = false;
1353
1354 for (index, item) in state.transcript.iter().enumerate() {
1355 if is_result_attached_to_call(&state.transcript, index) {
1358 continue;
1359 }
1360 if rendered_item {
1361 lines.push(Line::raw(String::new()));
1362 }
1363 match item {
1364 TranscriptItem::User {
1365 text,
1366 skill_instruction_attached,
1367 } => {
1368 let text = redact_secret(text, Some(&state.secret));
1369 let trigger = skill_instruction_attached
1370 .then(|| active_skill_trigger(&text, &state.skill_names))
1371 .flatten();
1372 push_user_message_block(&mut lines, &text, trigger, width);
1373 }
1374 TranscriptItem::Assistant(text) => {
1375 let text = redact_secret(text, Some(&state.secret));
1376 push_wrapped(&mut lines, &text, width, Style::default());
1377 }
1378 TranscriptItem::ToolCall {
1379 id,
1380 name,
1381 arguments,
1382 } => {
1383 let call_text = format!("[tool:{name} {}]", call_arguments(arguments));
1384 let call_text = redact_secret(&call_text, Some(&state.secret));
1385 let result = matching_tool_result(&state.transcript, index, id);
1386 let mut segments = vec![(
1387 call_text,
1388 if result.is_some() {
1389 tool_call_style()
1390 } else {
1391 pending_tool_call_style()
1392 },
1393 )];
1394 if let Some(result) = result {
1395 let result_text =
1396 redact_secret(&format_tool_result(result), Some(&state.secret));
1397 segments.push((" > ".to_owned(), Style::default()));
1398 segments.push((result_text, tool_result_style()));
1399 } else {
1400 segments.push((
1401 format!(" {}", spinner_frame(state)),
1402 pending_tool_call_style(),
1403 ));
1404 }
1405 push_spans_wrapped(&mut lines, &segments, width);
1406 }
1407 TranscriptItem::ToolResult {
1408 id: _,
1409 name: _,
1410 result,
1411 } => {
1412 let result_text = format_tool_result(result);
1413 let result_text = redact_secret(&result_text, Some(&state.secret));
1414 push_spans_wrapped(&mut lines, &[(result_text, tool_result_style())], width);
1415 }
1416 TranscriptItem::Error(text) => {
1417 let text = redact_secret(text, Some(&state.secret));
1418 push_wrapped(&mut lines, &text, width, error_style());
1419 }
1420 TranscriptItem::Info(text) => {
1421 let text = redact_secret(text, Some(&state.secret));
1422 push_wrapped(&mut lines, &text, width, info_style());
1423 }
1424 TranscriptItem::Thinking => {
1425 let text = format!("{} Thinking...", spinner_frame(state));
1426 push_wrapped(&mut lines, &text, width, thinking_style());
1427 }
1428 }
1429 rendered_item = true;
1430 }
1431 if lines.is_empty() {
1432 lines.push(Line::raw(""));
1433 }
1434 lines
1435}
1436
1437fn matching_tool_result<'a>(
1438 transcript: &'a [TranscriptItem],
1439 call_index: usize,
1440 call_id: &str,
1441) -> Option<&'a Value> {
1442 transcript
1443 .iter()
1444 .skip(call_index + 1)
1445 .find_map(|item| match item {
1446 TranscriptItem::ToolResult { id, result, .. } if id == call_id => Some(result),
1447 _ => None,
1448 })
1449}
1450
1451fn is_result_attached_to_call(transcript: &[TranscriptItem], result_index: usize) -> bool {
1452 let TranscriptItem::ToolResult { id, .. } = &transcript[result_index] else {
1453 return false;
1454 };
1455 let Some(call_index) = transcript[..result_index].iter().rposition(
1456 |item| matches!(item, TranscriptItem::ToolCall { id: call_id, .. } if call_id == id),
1457 ) else {
1458 return false;
1459 };
1460 !transcript[call_index + 1..result_index].iter().any(
1461 |item| matches!(item, TranscriptItem::ToolResult { id: result_id, .. } if result_id == id),
1462 )
1463}
1464
1465fn call_arguments(arguments: &str) -> String {
1469 let parsed: Value = match serde_json::from_str(arguments) {
1470 Ok(value) => value,
1471 Err(_) => return truncate_output(arguments),
1472 };
1473 if let Some(command) = parsed.get("command").and_then(Value::as_str) {
1474 return format!("\"{}\"", truncate_output(command));
1475 }
1476 let serialized = serde_json::to_string(&parsed).unwrap_or_else(|_| arguments.to_owned());
1477 truncate_output(&serialized)
1478}
1479
1480fn format_tool_result(result: &Value) -> String {
1484 let stdout = result.get("stdout").and_then(Value::as_str).unwrap_or("");
1485 let stderr = result.get("stderr").and_then(Value::as_str).unwrap_or("");
1486 let output = if !stdout.is_empty() { stdout } else { stderr };
1487 let truncated = truncate_output(output);
1488 let json_string = serde_json::to_string(&truncated).unwrap_or_else(|_| "\"\"".to_owned());
1491 format!("[{json_string}]")
1492}
1493
1494const RESULT_PREVIEW_CHARS: usize = 50;
1495
1496fn truncate_output(output: &str) -> String {
1497 let mut result: String = output.chars().take(RESULT_PREVIEW_CHARS).collect();
1498 if output.chars().count() > RESULT_PREVIEW_CHARS {
1499 result.push('…');
1500 }
1501 result
1502}
1503
1504fn user_message_style() -> Style {
1505 Style::default().fg(USER_BORDER_COLOR)
1506}
1507
1508fn push_user_message_block(
1511 lines: &mut Vec<Line<'static>>,
1512 text: &str,
1513 active_skill_trigger: Option<&str>,
1514 width: usize,
1515) {
1516 if width < 2 {
1517 lines.extend(styled_text_lines(
1518 text,
1519 active_skill_trigger,
1520 width.max(1),
1521 Style::default().fg(Color::White),
1522 ));
1523 return;
1524 }
1525
1526 let content_width = width - 2;
1527 let border_style = user_message_style();
1528 let rows = styled_text_lines(
1529 text,
1530 active_skill_trigger,
1531 content_width,
1532 Style::default().fg(Color::White),
1533 );
1534 lines.push(Line::styled(
1535 format!("╭{}╮", "─".repeat(content_width)),
1536 border_style,
1537 ));
1538 for row in rows {
1539 let row_width = UnicodeWidthStr::width(row.to_string().as_str());
1540 let padding = content_width.saturating_sub(row_width);
1541 let mut spans = Vec::with_capacity(row.spans.len() + 3);
1542 spans.push(Span::styled("│", border_style));
1543 spans.extend(row.spans);
1544 spans.push(Span::styled(
1545 " ".repeat(padding),
1546 Style::default().fg(Color::White),
1547 ));
1548 spans.push(Span::styled("│", border_style));
1549 lines.push(Line::from(spans));
1550 }
1551 lines.push(Line::styled(
1552 format!("╰{}╯", "─".repeat(content_width)),
1553 border_style,
1554 ));
1555}
1556
1557fn tool_call_style() -> Style {
1558 Style::default().fg(Color::Magenta)
1559}
1560
1561fn pending_tool_call_style() -> Style {
1562 Style::default().fg(PENDING_TOOL_COLOR)
1563}
1564
1565fn tool_result_style() -> Style {
1566 Style::default().fg(Color::DarkGray)
1567}
1568
1569fn error_style() -> Style {
1570 Style::default().fg(Color::Red)
1571}
1572
1573fn info_style() -> Style {
1574 Style::default().fg(Color::DarkGray)
1575}
1576
1577fn context_status_text(state: &UiState) -> String {
1578 let used = format_context_tokens(state.context_tokens);
1579 let Some(window) = state.context_window else {
1580 return format!("ctx={used}/? (?%)");
1581 };
1582 let percentage = context_percentage(state.context_tokens, window);
1583 format!(
1584 "ctx={used}/{} ({percentage}%)",
1585 format_context_tokens(window)
1586 )
1587}
1588
1589fn context_status_style(state: &UiState) -> Style {
1590 if state
1591 .context_window
1592 .is_some_and(|window| context_over_threshold(state.context_tokens, window))
1593 {
1594 Style::default().fg(PENDING_TOOL_COLOR)
1595 } else {
1596 Style::default().fg(Color::DarkGray)
1597 }
1598}
1599
1600fn context_percentage(used: usize, window: usize) -> usize {
1601 if window == 0 {
1602 return 0;
1603 }
1604 ((used as u128 * 100).div_ceil(window as u128)) as usize
1605}
1606
1607fn context_over_threshold(used: usize, window: usize) -> bool {
1608 window > 0 && (used as u128 * 100) > (window as u128 * 80)
1609}
1610
1611fn format_context_tokens(tokens: usize) -> String {
1612 if tokens >= 1_000_000 {
1613 format!("{:.2}M", tokens as f64 / 1_000_000.0)
1614 } else if tokens >= 1_000 {
1615 format!("{:.1}K", tokens as f64 / 1_000.0)
1616 } else {
1617 tokens.to_string()
1618 }
1619}
1620
1621fn activity_style_for(state: &UiState) -> Style {
1622 if state.status == "working" {
1623 Style::default().fg(Color::LightGreen)
1624 } else if state.status == "compacting" {
1625 Style::default().fg(PENDING_TOOL_COLOR)
1626 } else {
1627 Style::default().fg(Color::Cyan)
1628 }
1629}
1630
1631fn thinking_style() -> Style {
1632 Style::default().fg(Color::DarkGray)
1633}
1634
1635fn waiting_input_style() -> Style {
1636 Style::default().fg(BUSY_INPUT_COLOR)
1637}
1638
1639fn spinner_frame(state: &UiState) -> char {
1640 const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1641 let tick = state.cursor_epoch.elapsed().as_millis() / 100;
1642 FRAMES[(tick as usize) % FRAMES.len()]
1643}
1644
1645fn push_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, style: Style) {
1646 let mut added = false;
1647 for piece in wrap_text(text, width) {
1648 lines.push(Line::styled(piece, style));
1649 added = true;
1650 }
1651 if !added {
1652 lines.push(Line::styled(String::new(), style));
1653 }
1654}
1655
1656fn push_spans_wrapped(lines: &mut Vec<Line<'static>>, segments: &[(String, Style)], width: usize) {
1660 let mut current_spans: Vec<Span<'static>> = Vec::new();
1661 let mut current_width = 0usize;
1662 for (text, style) in segments {
1663 for character in text.chars() {
1664 let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
1665 if current_width + char_width > width && !current_spans.is_empty() {
1666 lines.push(Line::from(std::mem::take(&mut current_spans)));
1667 current_width = 0;
1668 }
1669 let mut buffer = [0u8; 4];
1670 let s = character.encode_utf8(&mut buffer);
1671 current_spans.push(Span::styled(s.to_owned(), *style));
1672 current_width += char_width;
1673 }
1674 }
1675 if current_spans.is_empty() {
1676 current_spans.push(Span::raw(String::new()));
1677 }
1678 lines.push(Line::from(current_spans));
1679}
1680
1681fn wrap_text(text: &str, width: usize) -> Vec<String> {
1687 if width == 0 {
1688 return text.lines().map(str::to_owned).collect();
1689 }
1690 let mut rows = Vec::new();
1691 for line in text.split('\n') {
1694 rows.extend(wrap_line(line, width));
1695 }
1696 if rows.is_empty() {
1697 rows.push(String::new());
1698 }
1699 rows
1700}
1701
1702fn wrap_line(line: &str, width: usize) -> Vec<String> {
1703 let mut rows = Vec::new();
1704 let mut current = String::new();
1705 let mut current_width = 0usize;
1706 for character in line.chars() {
1707 let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
1708 if current_width + char_width > width && !current.is_empty() {
1709 rows.push(std::mem::take(&mut current));
1710 current_width = 0;
1711 }
1712 current.push(character);
1713 current_width += char_width;
1714 }
1715 rows.push(current);
1716 rows
1717}
1718
1719#[cfg(test)]
1720mod tests {
1721 use super::*;
1722
1723 #[test]
1724 fn turn_notifications_use_osc_777_with_fixed_secret_safe_messages() {
1725 let cases = [
1726 (TurnNotification::Completed, "Turn complete"),
1727 (TurnNotification::Interrupted, "Turn interrupted"),
1728 (TurnNotification::Failed, "Turn failed"),
1729 ];
1730
1731 for (notification, body) in cases {
1732 let mut output = Vec::new();
1733 send_turn_notification(&mut output, notification).expect("notification");
1734 assert_eq!(
1735 output,
1736 format!("\x1b]777;notify;Lucy;{body}\x07").into_bytes()
1737 );
1738 }
1739 }
1740
1741 #[test]
1742 fn turn_notifications_follow_the_terminal_turn_status() {
1743 assert_eq!(
1744 turn_notification_for_status("finalizing"),
1745 TurnNotification::Completed
1746 );
1747 assert_eq!(
1748 turn_notification_for_status("cancelling"),
1749 TurnNotification::Interrupted
1750 );
1751 assert_eq!(
1752 turn_notification_for_status("error"),
1753 TurnNotification::Failed
1754 );
1755 }
1756
1757 struct FailingWriter;
1758
1759 impl Write for FailingWriter {
1760 fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
1761 Err(io::Error::other("notification sink unavailable"))
1762 }
1763
1764 fn flush(&mut self) -> io::Result<()> {
1765 Err(io::Error::other("notification sink unavailable"))
1766 }
1767 }
1768
1769 #[test]
1770 fn notification_write_failure_does_not_keep_the_tui_busy() {
1771 let mut state = UiState::from_history(&[], "secret", "model", None, false);
1772 state.busy = true;
1773 state.active_cancel = Some(CancellationToken::new());
1774 let mut writer = FailingWriter;
1775
1776 release_finished_turn(&mut writer, &mut state);
1777
1778 assert!(!state.busy);
1779 assert!(state.active_cancel.is_none());
1780 }
1781
1782 #[test]
1783 fn an_idle_finish_does_not_emit_a_duplicate_notification() {
1784 let mut state = UiState::from_history(&[], "secret", "model", None, false);
1785 let mut output = Vec::new();
1786
1787 release_finished_turn(&mut output, &mut state);
1788
1789 assert!(output.is_empty());
1790 }
1791
1792 #[test]
1793 fn context_status_shows_used_window_and_percentage() {
1794 let mut state = UiState::from_history(&[], "secret", "model", None, false)
1795 .with_context(Some(100_000), 80_000);
1796
1797 assert_eq!(context_status_text(&state), "ctx=80.0K/100.0K (80%)");
1798 assert_eq!(context_status_style(&state).fg, Some(Color::DarkGray));
1799
1800 state.context_tokens = 80_001;
1801 assert_eq!(context_status_text(&state), "ctx=80.0K/100.0K (81%)");
1802 assert_eq!(context_status_style(&state).fg, Some(PENDING_TOOL_COLOR));
1803 }
1804
1805 #[test]
1806 fn context_status_handles_unknown_window_without_highlighting() {
1807 let state = UiState::from_history(&[], "secret", "model", None, false);
1808
1809 assert_eq!(context_status_text(&state), "ctx=1/? (?%)");
1810 assert_eq!(context_status_style(&state).fg, Some(Color::DarkGray));
1811 }
1812
1813 #[test]
1814 fn context_status_is_right_aligned_and_turns_orange_above_eighty_percent() {
1815 let state =
1816 UiState::from_history(&[], "secret", "model", None, false).with_context(Some(100), 81);
1817 let mut terminal =
1818 Terminal::new(ratatui::backend::TestBackend::new(60, 10)).expect("test terminal");
1819
1820 terminal
1821 .draw(|frame| draw(frame, &state))
1822 .expect("draw statusline");
1823
1824 let buffer = terminal.backend().buffer();
1825 let status_row = 9;
1826 let context = context_status_text(&state);
1827 let context_start = 60 - context.chars().count();
1828 assert_eq!(buffer[(context_start as u16, status_row)].symbol(), "c");
1829 assert_eq!(buffer[(59, status_row)].symbol(), ")");
1830 assert_eq!(buffer[(59, status_row)].fg, PENDING_TOOL_COLOR);
1831 }
1832
1833 #[test]
1834 fn fresh_sessions_show_the_versioned_gradient_welcome_message() {
1835 let state = UiState::from_history(&[], "secret", "model", None, false);
1836 assert!(state.welcome_visible);
1837
1838 let line = welcome_line();
1839 assert_eq!(line.to_string(), WELCOME_MESSAGE);
1840 assert_eq!(
1841 line.spans.first().and_then(|span| span.style.fg),
1842 Some(Color::Rgb(
1843 WELCOME_START_COLOR.0,
1844 WELCOME_START_COLOR.1,
1845 WELCOME_START_COLOR.2,
1846 ))
1847 );
1848 assert_eq!(
1849 line.spans.last().and_then(|span| span.style.fg),
1850 Some(Color::Rgb(
1851 WELCOME_END_COLOR.0,
1852 WELCOME_END_COLOR.1,
1853 WELCOME_END_COLOR.2,
1854 ))
1855 );
1856 }
1857
1858 #[test]
1859 fn welcome_shows_the_tagline_and_attached_agents_paths() {
1860 let state = UiState::from_history(&[], "secret", "model", None, false)
1861 .with_attached_agents(vec![
1862 "/workspace/AGENTS.md".to_owned(),
1863 "/workspace/app/AGENTS.md".to_owned(),
1864 ]);
1865 let lines = welcome_lines(&state.attached_agents);
1866
1867 assert_eq!(lines[1].to_string(), WELCOME_TAGLINE);
1868 assert_eq!(lines[1].style.fg, Some(Color::DarkGray));
1869 assert_eq!(lines[3].to_string(), "Attached AGENTS.md:");
1870 assert_eq!(lines[4].to_string(), "• /workspace/AGENTS.md");
1871 assert_eq!(lines[5].to_string(), "• /workspace/app/AGENTS.md");
1872 assert!(lines[3..]
1873 .iter()
1874 .all(|line| line.style.fg == Some(Color::DarkGray)));
1875 }
1876
1877 #[test]
1878 fn welcome_reports_when_no_agents_file_is_attached() {
1879 let lines = welcome_lines(&[]);
1880 assert_eq!(
1881 lines.last().expect("empty context line").to_string(),
1882 "Attached AGENTS.md: none"
1883 );
1884 }
1885
1886 #[test]
1887 fn resumed_sessions_do_not_show_the_welcome_message() {
1888 let state = UiState::from_history(&[], "secret", "model", None, true);
1889 assert!(!state.welcome_visible);
1890 }
1891
1892 #[test]
1893 fn history_replay_keeps_interruption_after_messages() {
1894 let history = vec![
1895 SessionHistoryRecord::Message {
1896 timestamp: 1,
1897 message: ChatMessage::user("hello".to_owned()),
1898 },
1899 SessionHistoryRecord::Interruption {
1900 timestamp: 2,
1901 reason: "user_cancelled".to_owned(),
1902 phase: "provider_stream".to_owned(),
1903 assistant_text: "partial".to_owned(),
1904 tool_calls: Vec::new(),
1905 tool_results: Vec::new(),
1906 },
1907 ];
1908 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
1909 assert!(matches!(state.transcript[0], TranscriptItem::User { .. }));
1910 assert!(matches!(state.transcript[1], TranscriptItem::Assistant(_)));
1911 assert!(matches!(state.transcript[2], TranscriptItem::Info(_)));
1912 let text = transcript_lines(&state, 80)
1913 .iter()
1914 .map(ToString::to_string)
1915 .collect::<Vec<_>>()
1916 .join("\n");
1917 assert!(!text.contains("choices"));
1918 }
1919
1920 #[test]
1921 fn history_replay_does_not_render_assistant_reasoning_details() {
1922 let mut message = ChatMessage::assistant("visible answer".to_owned(), Vec::new());
1923 message.reasoning_details = Some(vec![serde_json::json!({
1924 "type": "reasoning.text",
1925 "text": "private reasoning"
1926 })]);
1927 let history = [SessionHistoryRecord::Message {
1928 timestamp: 1,
1929 message,
1930 }];
1931 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
1932 let text = transcript_lines(&state, 80)
1933 .iter()
1934 .map(ToString::to_string)
1935 .collect::<Vec<_>>()
1936 .join("\n");
1937 assert!(text.contains("visible answer"));
1938 assert!(!text.contains("private reasoning"));
1939 assert!(!text.contains("reasoning_details"));
1940 }
1941
1942 #[test]
1943 fn history_replay_preserves_repeated_records() {
1944 let history = vec![
1945 SessionHistoryRecord::Message {
1946 timestamp: 1,
1947 message: ChatMessage::assistant("same".to_owned(), Vec::new()),
1948 },
1949 SessionHistoryRecord::Interruption {
1950 timestamp: 2,
1951 reason: "user_cancelled".to_owned(),
1952 phase: "provider_stream".to_owned(),
1953 assistant_text: "same".to_owned(),
1954 tool_calls: Vec::new(),
1955 tool_results: Vec::new(),
1956 },
1957 ];
1958 let state = UiState::from_history(&history, "provider-secret", "model", None, true);
1959 assert_eq!(
1960 state
1961 .transcript
1962 .iter()
1963 .filter(|item| matches!(item, TranscriptItem::Assistant(text) if text == "same"))
1964 .count(),
1965 2
1966 );
1967 }
1968
1969 #[test]
1970 fn user_message_borders_remain_muted_yellow_while_its_text_is_white() {
1971 let history = [SessionHistoryRecord::Message {
1972 timestamp: 1,
1973 message: ChatMessage::user("hello".to_owned()),
1974 }];
1975 let state = UiState::from_history(&history, "provider-secret", "model", None, false);
1976 let lines = transcript_lines(&state, 12);
1977
1978 assert_eq!(lines.len(), 3);
1979 assert_eq!(lines[0].to_string(), "╭──────────╮");
1980 assert_eq!(lines[1].to_string(), "│hello │");
1981 assert_eq!(lines[2].to_string(), "╰──────────╯");
1982 assert_eq!(lines[0].style.fg, Some(USER_BORDER_COLOR));
1983 assert_eq!(lines[2].style.fg, Some(USER_BORDER_COLOR));
1984 assert_eq!(lines[1].spans[0].style.fg, Some(USER_BORDER_COLOR));
1985 assert_eq!(lines[1].spans[1].style.fg, Some(Color::White));
1986 assert_eq!(
1987 lines[1].spans.last().map(|span| span.style.fg),
1988 Some(Some(USER_BORDER_COLOR))
1989 );
1990 }
1991
1992 #[test]
1993 fn attached_skill_highlights_its_trigger_in_the_user_message_without_a_notice_line() {
1994 let mut state = UiState::from_history(&[], "secret", "model", None, false)
1995 .with_skill_names(vec!["release-notes".to_owned()]);
1996 state.add_user("/release-notes v1.2.0", "secret");
1997 state.mark_latest_user_skill_attached();
1998
1999 let lines = transcript_lines(&state, 40);
2000 assert_eq!(lines.len(), 3);
2001 let cyan_text = lines[1]
2002 .spans
2003 .iter()
2004 .filter(|span| span.style.fg == Some(Color::Cyan))
2005 .map(|span| span.content.as_ref())
2006 .collect::<String>();
2007 assert_eq!(cyan_text, "/release-notes");
2008 assert!(!lines
2009 .iter()
2010 .any(|line| line.to_string().contains("instruction attached")));
2011 }
2012
2013 #[test]
2014 fn transcript_rendering_redacts_history_content() {
2015 let history = [SessionHistoryRecord::Message {
2016 timestamp: 1,
2017 message: ChatMessage::assistant("provider-secret".to_owned(), Vec::new()),
2018 }];
2019 let state = UiState::from_history(&history, "provider-secret", "model", None, false);
2020 let text = transcript_lines(&state, 80)
2021 .iter()
2022 .map(ToString::to_string)
2023 .collect::<Vec<_>>()
2024 .join("\n");
2025 assert!(!text.contains("provider-secret"));
2026 }
2027
2028 #[test]
2029 fn mouse_wheel_disables_following_and_changes_scroll_offset() {
2030 let history = [SessionHistoryRecord::Message {
2031 timestamp: 1,
2032 message: ChatMessage::user("hello".to_owned()),
2033 }];
2034 let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
2035 handle_mouse_event(&mut state, MouseEventKind::ScrollUp, 10);
2036 assert!(!state.auto_scroll);
2037 assert_eq!(state.scroll, 7);
2038 handle_mouse_event(&mut state, MouseEventKind::ScrollDown, 10);
2039 assert!(
2040 state.auto_scroll,
2041 "reaching the bottom resumes transcript following"
2042 );
2043 assert_eq!(state.scroll, 0);
2044 scroll_up(&mut state, 10);
2045 assert!(!state.auto_scroll);
2046 assert_eq!(state.scroll, 7);
2047 }
2048
2049 #[test]
2050 fn wrap_text_breaks_long_lines_and_preserves_empty_lines() {
2051 let rows = wrap_text("12345\n\nabc", 3);
2052 assert_eq!(rows, vec!["123", "45", "", "abc"]);
2053 }
2054
2055 #[test]
2056 fn wrap_line_never_returns_an_empty_vec() {
2057 assert_eq!(wrap_line("", 5), vec![""]);
2058 assert_eq!(wrap_line("abc", 5), vec!["abc"]);
2059 }
2060
2061 #[test]
2062 fn completion_event_does_not_release_input_before_worker_finishes() {
2063 let history = [SessionHistoryRecord::Message {
2064 timestamp: 1,
2065 message: ChatMessage::user("hello".to_owned()),
2066 }];
2067 let mut state = UiState::from_history(&history, "provider-secret", "model", None, false);
2068 state.busy = true;
2069 state.active_cancel = Some(CancellationToken::new());
2070 state.apply_event(ProtocolEvent::TurnEnd);
2071 assert!(state.busy);
2072 assert!(state.active_cancel.is_some());
2073 assert_eq!(state.status, "finalizing");
2074 }
2075
2076 #[test]
2077 fn transcript_inserts_a_blank_line_between_items() {
2078 let history = [
2079 SessionHistoryRecord::Message {
2080 timestamp: 1,
2081 message: ChatMessage::user("hi".to_owned()),
2082 },
2083 SessionHistoryRecord::Message {
2084 timestamp: 2,
2085 message: ChatMessage::assistant("hello".to_owned(), Vec::new()),
2086 },
2087 ];
2088 let state = UiState::from_history(&history, "secret", "model", None, false);
2089 let lines = transcript_lines(&state, 80);
2090 assert_eq!(lines.len(), 5);
2091 assert_eq!(lines[0].to_string(), format!("╭{}╮", "─".repeat(78)));
2092 assert_eq!(lines[1].to_string(), format!("│hi{}│", " ".repeat(76)));
2093 assert_eq!(lines[2].to_string(), format!("╰{}╯", "─".repeat(78)));
2094 assert_eq!(lines[3].to_string(), "");
2095 assert_eq!(lines[4].to_string(), "hello");
2096 }
2097
2098 #[test]
2099 fn tool_call_renders_as_compact_single_line_with_command() {
2100 let history = [SessionHistoryRecord::Message {
2101 timestamp: 1,
2102 message: ChatMessage::assistant(
2103 String::new(),
2104 vec![crate::model::ChatToolCall {
2105 id: "call-1".to_owned(),
2106 name: "cmd".to_owned(),
2107 arguments: r#"{"command":"pwd"}"#.to_owned(),
2108 }],
2109 ),
2110 }];
2111 let state = UiState::from_history(&history, "secret", "model", None, false);
2112 let text = transcript_lines(&state, 80)
2113 .iter()
2114 .map(ToString::to_string)
2115 .collect::<Vec<_>>()
2116 .join("\n");
2117 assert!(text.contains("[tool:cmd \"pwd\"]"));
2118 assert!(!text.contains("{\"command\":\"pwd\"}"));
2120 }
2121
2122 #[test]
2123 fn pending_tool_calls_are_orange_and_show_a_spinner() {
2124 let history = [SessionHistoryRecord::Message {
2125 timestamp: 1,
2126 message: ChatMessage::assistant(
2127 String::new(),
2128 vec![crate::model::ChatToolCall {
2129 id: "call-1".to_owned(),
2130 name: "cmd".to_owned(),
2131 arguments: r#"{"command":"pwd"}"#.to_owned(),
2132 }],
2133 ),
2134 }];
2135 let state = UiState::from_history(&history, "secret", "model", None, false);
2136 let line = &transcript_lines(&state, 80)[0];
2137
2138 assert!(line.to_string().contains("[tool:cmd \"pwd\"] "));
2139 assert!(line
2140 .spans
2141 .iter()
2142 .all(|span| span.style.fg == Some(PENDING_TOOL_COLOR)));
2143 assert!(line.spans.iter().any(|span| {
2144 span.content
2145 .chars()
2146 .any(|character| "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏".contains(character))
2147 }));
2148 }
2149
2150 #[test]
2151 fn tool_call_truncates_long_arguments() {
2152 let command = "a".repeat(80);
2153 let arguments = serde_json::json!({"command": command}).to_string();
2154 let history = [SessionHistoryRecord::Message {
2155 timestamp: 1,
2156 message: ChatMessage::assistant(
2157 String::new(),
2158 vec![crate::model::ChatToolCall {
2159 id: "call-1".to_owned(),
2160 name: "cmd".to_owned(),
2161 arguments,
2162 }],
2163 ),
2164 }];
2165 let state = UiState::from_history(&history, "secret", "model", None, false);
2166 let text = transcript_lines(&state, 200)[0].to_string();
2167 assert!(text.contains(&format!("[tool:cmd \"{}…\"]", "a".repeat(50))));
2168 assert!(!text.contains(&"a".repeat(51)));
2169 }
2170
2171 #[test]
2172 fn tool_call_and_result_render_on_one_line_with_truncated_stdout() {
2173 let long_stdout = "a".repeat(80);
2174 let history = vec![
2175 SessionHistoryRecord::Message {
2176 timestamp: 1,
2177 message: ChatMessage::assistant(
2178 String::new(),
2179 vec![crate::model::ChatToolCall {
2180 id: "call-1".to_owned(),
2181 name: "cmd".to_owned(),
2182 arguments: r#"{"command":"cat README.md"}"#.to_owned(),
2183 }],
2184 ),
2185 },
2186 SessionHistoryRecord::Message {
2187 timestamp: 2,
2188 message: ChatMessage::tool(
2189 "call-1".to_owned(),
2190 "cmd".to_owned(),
2191 serde_json::json!({
2192 "command": "cat README.md",
2193 "exit_code": 0,
2194 "stdout": long_stdout,
2195 "stderr": "",
2196 })
2197 .to_string(),
2198 ),
2199 },
2200 ];
2201 let state = UiState::from_history(&history, "secret", "model", None, false);
2202 let lines = transcript_lines(&state, 200);
2203 assert_eq!(lines.len(), 1);
2205 let text = lines[0].to_string();
2206 assert!(text.starts_with("[tool:cmd \"cat README.md\"] > ["));
2207 assert!(text.contains(&"a".repeat(50)));
2209 assert!(text.contains('…'));
2210 assert!(!text.contains(&"a".repeat(51)));
2211 }
2212
2213 #[test]
2214 fn tool_result_falls_back_to_stderr_when_stdout_is_empty() {
2215 let history = vec![
2216 SessionHistoryRecord::Message {
2217 timestamp: 1,
2218 message: ChatMessage::assistant(
2219 String::new(),
2220 vec![crate::model::ChatToolCall {
2221 id: "call-1".to_owned(),
2222 name: "cmd".to_owned(),
2223 arguments: r#"{"command":"bad"}"#.to_owned(),
2224 }],
2225 ),
2226 },
2227 SessionHistoryRecord::Message {
2228 timestamp: 2,
2229 message: ChatMessage::tool(
2230 "call-1".to_owned(),
2231 "cmd".to_owned(),
2232 serde_json::json!({
2233 "command": "bad",
2234 "exit_code": 127,
2235 "stdout": "",
2236 "stderr": "not found",
2237 })
2238 .to_string(),
2239 ),
2240 },
2241 ];
2242 let state = UiState::from_history(&history, "secret", "model", None, false);
2243 let text = transcript_lines(&state, 200)[0].to_string();
2244 assert!(text.contains("not found"));
2245 assert!(text.contains(" > "));
2246 }
2247
2248 #[test]
2249 fn tool_call_and_result_styles_use_foreground_colors() {
2250 let history = vec![
2251 SessionHistoryRecord::Message {
2252 timestamp: 1,
2253 message: ChatMessage::assistant(
2254 String::new(),
2255 vec![crate::model::ChatToolCall {
2256 id: "call-1".to_owned(),
2257 name: "cmd".to_owned(),
2258 arguments: r#"{"command":"pwd"}"#.to_owned(),
2259 }],
2260 ),
2261 },
2262 SessionHistoryRecord::Message {
2263 timestamp: 2,
2264 message: ChatMessage::tool(
2265 "call-1".to_owned(),
2266 "cmd".to_owned(),
2267 serde_json::json!({"stdout":"x","stderr":""}).to_string(),
2268 ),
2269 },
2270 ];
2271 let state = UiState::from_history(&history, "secret", "model", None, false);
2272 let lines = transcript_lines(&state, 200);
2273 let spans = &lines[0].spans;
2274 assert_eq!(spans[0].style.fg, Some(Color::Magenta));
2277 assert!(spans.iter().any(|span| span.style.fg.is_none()));
2278 let result_text = spans
2279 .iter()
2280 .filter(|span| span.style.fg == Some(Color::DarkGray))
2281 .map(|span| span.content.as_ref())
2282 .collect::<String>();
2283 assert_eq!(result_text, "[\"x\"]");
2284 assert!(!lines[0]
2285 .to_string()
2286 .chars()
2287 .any(|character| "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏".contains(character)));
2288 }
2289
2290 #[test]
2291 fn recognized_skill_trigger_is_highlighted_but_arguments_remain_default_colored() {
2292 let trigger = active_skill_trigger("/release-notes v1.2.0", &["release-notes".to_owned()]);
2293 assert_eq!(trigger, Some("/release-notes"));
2294
2295 let lines = styled_text_lines(
2296 "/release-notes v1.2.0",
2297 trigger,
2298 80,
2299 Style::default().fg(Color::White),
2300 );
2301 assert_eq!(lines.len(), 1);
2302 assert_eq!(lines[0].to_string(), "/release-notes v1.2.0");
2303 assert_eq!(lines[0].spans[0].content, "/release-notes");
2304 assert_eq!(lines[0].spans[0].style.fg, Some(Color::Cyan));
2305 assert_eq!(lines[0].spans[1].content, " v1.2.0");
2306 assert_eq!(lines[0].spans[1].style.fg, Some(Color::White));
2307 }
2308
2309 #[test]
2310 fn draw_renders_an_active_skill_trigger_in_cyan() {
2311 let mut state = UiState::from_history(&[], "secret", "model", None, false)
2312 .with_skill_names(vec!["release-notes".to_owned()]);
2313 state.input = "/release-notes v1.2.0".to_owned();
2314 state.cursor = state.input.chars().count();
2315
2316 let mut terminal =
2317 Terminal::new(ratatui::backend::TestBackend::new(40, 10)).expect("test terminal");
2318 terminal
2319 .draw(|frame| draw(frame, &state))
2320 .expect("draw input");
2321
2322 let buffer = terminal.backend().buffer();
2325 assert_eq!(buffer[(1, 7)].fg, Color::Cyan);
2326 assert_eq!(buffer[(21, 7)].fg, Color::White);
2327 }
2328
2329 #[test]
2330 fn busy_input_uses_a_dark_gray_border_and_waiting_message() {
2331 let mut state = UiState::from_history(&[], "secret", "model", None, false);
2332 state.busy = true;
2333
2334 let mut terminal =
2335 Terminal::new(ratatui::backend::TestBackend::new(80, 10)).expect("test terminal");
2336 terminal
2337 .draw(|frame| draw(frame, &state))
2338 .expect("draw waiting input");
2339
2340 let buffer = terminal.backend().buffer();
2341 assert_eq!(buffer[(0, 6)].fg, BUSY_INPUT_COLOR);
2342 assert_eq!(buffer[(1, 7)].symbol(), "T");
2343 assert_eq!(buffer[(1, 7)].fg, BUSY_INPUT_COLOR);
2344 assert_eq!(input_display_text(&state), WAITING_INPUT_MESSAGE);
2345 }
2346
2347 #[test]
2348 fn only_known_leading_skill_commands_activate_input_highlighting() {
2349 let skills = ["release-notes".to_owned()];
2350 assert_eq!(
2351 active_skill_trigger("/missing", &skills),
2352 None,
2353 "unknown commands are rejected by the turn engine and must not look active"
2354 );
2355 assert_eq!(
2356 active_skill_trigger("/skill:release-notes", &skills),
2357 None,
2358 "the removed /skill: wrapper must not look active"
2359 );
2360 assert_eq!(
2361 active_skill_trigger("write /release-notes", &skills),
2362 None,
2363 "only the command prefix accepted by the turn engine is active"
2364 );
2365 assert_eq!(active_skill_trigger("/", &skills), None);
2366 }
2367
2368 #[test]
2369 fn highlighted_skill_trigger_remains_styled_when_wrapped() {
2370 let input = "/release-notes argument";
2371 let trigger = active_skill_trigger(input, &["release-notes".to_owned()]);
2372 let lines = styled_text_lines(input, trigger, 8, Style::default().fg(Color::White));
2373 let highlighted = lines
2374 .iter()
2375 .flat_map(|line| line.spans.iter())
2376 .filter(|span| span.style.fg == Some(Color::Cyan))
2377 .map(|span| span.content.as_ref())
2378 .collect::<String>();
2379 assert_eq!(highlighted, "/release-notes");
2380 }
2381
2382 #[test]
2383 fn input_has_no_prompt_marker_and_trailing_newline_is_visible() {
2384 assert_eq!(input_prompt("hello"), "hello");
2385 assert_eq!(wrap_text("hello\n", 80), vec!["hello", ""]);
2386 }
2387
2388 #[test]
2389 fn input_prompt_wraps_to_multiple_rows_when_long() {
2390 let mut state = UiState::from_history(&[], "secret", "model", None, false);
2391 state.input = "abcdefghij".to_owned();
2392 let rows = input_visible_rows(&state, 5);
2394 assert!(rows >= 2);
2395 }
2396
2397 #[test]
2398 fn cursor_editing_moves_by_characters_and_preserves_unicode() {
2399 let mut input = "가나".to_owned();
2400 let mut cursor = input.chars().count();
2401 cursor -= 1;
2402 insert_at_cursor(&mut input, &mut cursor, 'x');
2403 assert_eq!(input, "가x나");
2404 assert_eq!(cursor, 2);
2405 assert!(remove_before_cursor(&mut input, &mut cursor));
2406 assert_eq!(input, "가나");
2407 assert_eq!(cursor, 1);
2408 }
2409
2410 #[test]
2411 fn cursor_row_tracks_newlines_and_wrapping() {
2412 assert_eq!(cursor_row("hello\nworld", 6, 80), 1);
2413 assert_eq!(cursor_row("abcdef", 4, 3), 1);
2414 }
2415
2416 #[test]
2417 fn shift_enter_inserts_at_the_cursor_and_moves_it_to_the_new_row() {
2418 let mut input = "beforeafter".to_owned();
2419 let mut cursor = 6;
2420 insert_at_cursor(&mut input, &mut cursor, '\n');
2421
2422 assert_eq!(input, "before\nafter");
2423 assert_eq!(cursor, 7);
2424 assert_eq!(cursor_row(&input, cursor, 80), 1);
2425 }
2426
2427 #[test]
2428 fn shift_enter_renders_the_cursor_on_the_new_input_row() {
2429 let mut state = UiState::from_history(&[], "secret", "model", None, false);
2430 state.input = "beforeafter".to_owned();
2431 state.cursor = 6;
2432 insert_at_cursor(&mut state.input, &mut state.cursor, '\n');
2433 state.cursor_epoch = Instant::now();
2434
2435 let mut terminal =
2436 Terminal::new(ratatui::backend::TestBackend::new(20, 10)).expect("test terminal");
2437 terminal
2438 .draw(|frame| draw(frame, &state))
2439 .expect("draw input cursor");
2440
2441 terminal.backend_mut().assert_cursor_position((1, 7));
2444 }
2445
2446 #[test]
2447 fn tool_results_attach_to_their_matching_call_after_consecutive_calls() {
2448 let history = vec![
2449 SessionHistoryRecord::Message {
2450 timestamp: 1,
2451 message: ChatMessage::assistant(
2452 String::new(),
2453 vec![
2454 crate::model::ChatToolCall {
2455 id: "call-first".to_owned(),
2456 name: "cmd".to_owned(),
2457 arguments: r#"{"command":"first"}"#.to_owned(),
2458 },
2459 crate::model::ChatToolCall {
2460 id: "call-second".to_owned(),
2461 name: "cmd".to_owned(),
2462 arguments: r#"{"command":"second"}"#.to_owned(),
2463 },
2464 ],
2465 ),
2466 },
2467 SessionHistoryRecord::Message {
2468 timestamp: 2,
2469 message: ChatMessage::tool(
2470 "call-first".to_owned(),
2471 "cmd".to_owned(),
2472 serde_json::json!({"stdout":"first result","stderr":""}).to_string(),
2473 ),
2474 },
2475 SessionHistoryRecord::Message {
2476 timestamp: 3,
2477 message: ChatMessage::tool(
2478 "call-second".to_owned(),
2479 "cmd".to_owned(),
2480 serde_json::json!({"stdout":"second result","stderr":""}).to_string(),
2481 ),
2482 },
2483 ];
2484
2485 let state = UiState::from_history(&history, "secret", "model", None, false);
2486 let lines = transcript_lines(&state, 200);
2487 assert_eq!(
2488 lines.len(),
2489 3,
2490 "only the two call lines and their separator remain"
2491 );
2492 assert_eq!(
2493 lines[0].to_string(),
2494 "[tool:cmd \"first\"] > [\"first result\"]"
2495 );
2496 assert_eq!(
2497 lines[2].to_string(),
2498 "[tool:cmd \"second\"] > [\"second result\"]"
2499 );
2500 }
2501}
2502
2503#[cfg(test)]
2504mod skill_picker_tests {
2505 use super::*;
2506
2507 fn skill_names() -> Vec<String> {
2508 ["alpha", "beta", "build", "charlie", "deploy", "doctor"]
2509 .into_iter()
2510 .map(str::to_owned)
2511 .collect()
2512 }
2513
2514 #[test]
2515 fn slash_picker_filters_only_leading_command_text_and_hides_without_matches() {
2516 let names = skill_names();
2517 assert_eq!(
2518 matching_skill_names("/", &names),
2519 vec!["alpha", "beta", "build", "charlie", "deploy", "doctor"]
2520 );
2521 assert_eq!(matching_skill_names("/b", &names), vec!["beta", "build"]);
2522 assert!(matching_skill_names("/missing", &names).is_empty());
2523 assert!(matching_skill_names("message /b", &names).is_empty());
2524 assert!(matching_skill_names("/beta arguments", &names).is_empty());
2525 }
2526
2527 #[test]
2528 fn slash_picker_focuses_the_top_match_and_moves_within_filtered_results() {
2529 let mut state = UiState::from_history(&[], "secret", "model", None, false)
2530 .with_skill_names(skill_names());
2531 state.input = "/b".to_owned();
2532 state.input_changed();
2533
2534 assert!(state.skill_picker_visible());
2535 assert_eq!(state.skill_picker_focus, 0);
2536 assert!(state.move_skill_picker(true));
2537 assert_eq!(state.skill_picker_focus, 1);
2538 assert!(state.move_skill_picker(true));
2539 assert_eq!(state.skill_picker_focus, 1, "focus does not leave the list");
2540 assert!(state.move_skill_picker(false));
2541 assert_eq!(state.skill_picker_focus, 0);
2542
2543 state.input = "/missing".to_owned();
2544 state.input_changed();
2545 assert!(!state.skill_picker_visible());
2546 assert!(!state.move_skill_picker(true));
2547 }
2548
2549 #[test]
2550 fn enter_selects_the_focused_skill_then_leaves_the_completed_command_ready_to_send() {
2551 let mut state = UiState::from_history(&[], "secret", "model", None, false)
2552 .with_skill_names(skill_names());
2553 state.input = "/b".to_owned();
2554 state.input_changed();
2555 state.move_skill_picker(true);
2556
2557 assert!(state.select_focused_skill());
2558 assert_eq!(state.input, "/build");
2559 assert_eq!(state.cursor, "/build".chars().count());
2560 assert!(
2561 !state.skill_picker_visible(),
2562 "the first Enter completes the input rather than sending it"
2563 );
2564 assert!(
2565 !state.select_focused_skill(),
2566 "a second Enter follows the normal send/attachment path"
2567 );
2568 }
2569
2570 #[test]
2571 fn slash_picker_keeps_the_focused_item_in_its_five_row_viewport() {
2572 assert_eq!(skill_picker_range(20, 0), 0..5);
2573 assert_eq!(skill_picker_range(20, 4), 0..5);
2574 assert_eq!(skill_picker_range(20, 5), 1..6);
2575 assert_eq!(skill_picker_range(20, 19), 15..20);
2576 }
2577
2578 #[test]
2579 fn slash_picker_overlays_the_ready_activity_indicator() {
2580 let mut state = UiState::from_history(&[], "secret", "model", None, false)
2581 .with_skill_names(vec!["a".to_owned(), "b".to_owned()]);
2582 state.input = "/".to_owned();
2583 state.input_changed();
2584 let mut terminal =
2585 Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
2586 terminal
2587 .draw(|frame| draw(frame, &state))
2588 .expect("draw TUI");
2589
2590 let screen = terminal
2591 .backend()
2592 .buffer()
2593 .content()
2594 .iter()
2595 .map(|cell| cell.symbol())
2596 .collect::<String>();
2597 assert!(screen.contains("/a"));
2598 assert!(
2599 !screen.contains("ready"),
2600 "the skill picker should cover the ready activity indicator"
2601 );
2602 }
2603
2604 #[test]
2605 fn slash_picker_is_rendered_immediately_above_the_input() {
2606 let mut state = UiState::from_history(&[], "secret", "model", None, false)
2607 .with_skill_names(skill_names());
2608 state.input = "/".to_owned();
2609 state.input_changed();
2610 let mut terminal =
2611 Terminal::new(ratatui::backend::TestBackend::new(40, 12)).expect("test terminal");
2612 terminal
2613 .draw(|frame| draw(frame, &state))
2614 .expect("draw TUI");
2615
2616 let buffer = terminal.backend().buffer();
2617 assert_eq!(buffer[(0, 2)].symbol(), "[");
2620 assert_eq!(buffer[(0, 7)].symbol(), "/");
2621 assert_eq!(buffer[(0, 8)].symbol(), "╭");
2622 }
2623
2624 #[test]
2625 fn slash_picker_renders_count_and_distinct_focus_colors() {
2626 let mut state = UiState::from_history(&[], "secret", "model", None, false)
2627 .with_skill_names(skill_names());
2628 state.input = "/".to_owned();
2629 state.input_changed();
2630 let mut terminal =
2631 Terminal::new(ratatui::backend::TestBackend::new(30, 8)).expect("test terminal");
2632 terminal
2633 .draw(|frame| draw_skill_picker(frame, &state, Rect::new(0, 0, 30, 6)))
2634 .expect("draw skill picker");
2635
2636 let buffer = terminal.backend().buffer();
2637 assert_eq!(buffer[(0, 0)].symbol(), "[");
2638 assert_eq!(buffer[(0, 0)].fg, Color::DarkGray);
2639 assert_eq!(buffer[(0, 1)].symbol(), "/");
2640 assert_eq!(buffer[(0, 1)].fg, Color::Cyan);
2641 assert_eq!(buffer[(0, 2)].symbol(), "/");
2642 assert_eq!(buffer[(0, 2)].fg, Color::DarkGray);
2643 }
2644}