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::{Constraint, Direction, Layout, Rect, Size};
18use ratatui::prelude::Frame;
19use ratatui::style::{Color, Style};
20use ratatui::text::{Line, Span};
21use ratatui::widgets::{Block, Borders, Paragraph};
22use ratatui::Terminal;
23use serde_json::Value;
24use unicode_width::UnicodeWidthStr;
25
26use crate::app::Harness;
27use crate::cancellation::CancellationToken;
28use crate::model::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;
40
41pub(crate) fn run<W: Write>(mut harness: Harness, resumed: bool, stdout: W) -> Result<(), String> {
42 let secret = harness.provider.api_key().to_owned();
43 let mut state = UiState::from_history(
44 &harness.session.history,
45 &secret,
46 &harness.session.id,
47 resumed,
48 );
49 let (request_tx, request_rx) = mpsc::channel::<WorkerRequest>();
50 let (message_tx, message_rx) = mpsc::channel::<WorkerMessage>();
51
52 let stdout = stdout;
53 enable_raw_mode().map_err(|error| format!("unable to enable terminal input: {error}"))?;
54 let backend = CrosstermBackend::new(stdout);
55 let terminal = match Terminal::new(backend) {
56 Ok(terminal) => terminal,
57 Err(error) => {
58 let _ = disable_raw_mode();
59 return Err(format!("unable to initialize terminal UI: {error}"));
60 }
61 };
62 let mut terminal_guard = TerminalGuard::new(terminal);
63 let backend = terminal_guard.terminal_mut().backend_mut();
64 if let Err(error) = execute!(
65 backend,
66 EnterAlternateScreen,
67 EnableMouseCapture,
68 Hide
69 ) {
70 return Err(format!("unable to enter terminal UI: {error}"));
71 }
72 if supports_keyboard_enhancement() {
77 let _ = execute!(
78 backend,
79 PushKeyboardEnhancementFlags(
80 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
81 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES,
82 )
83 );
84 terminal_guard.keyboard_enhancement = true;
85 }
86 let worker = thread::spawn(move || worker_loop(&mut harness, request_rx, message_tx, resumed));
87
88 let result = event_loop(
89 terminal_guard.terminal_mut(),
90 &mut state,
91 &request_tx,
92 &message_rx,
93 );
94
95 if let Some(token) = state.active_cancel.take() {
96 let _ = token.cancel();
97 }
98 let _ = request_tx.send(WorkerRequest::Shutdown);
99 wait_for_worker(worker, WORKER_SHUTDOWN_GRACE);
100 drop(terminal_guard);
101 result
102}
103
104fn worker_loop(
105 harness: &mut Harness,
106 requests: Receiver<WorkerRequest>,
107 messages: Sender<WorkerMessage>,
108 resumed: bool,
109) {
110 let mut sink = ChannelSink {
111 sender: messages.clone(),
112 };
113 if sink
114 .emit_event(&ProtocolEvent::Session {
115 session_id: harness.session.id.clone(),
116 resumed,
117 })
118 .is_err()
119 {
120 return;
121 }
122
123 while let Ok(request) = requests.recv() {
124 match request {
125 WorkerRequest::Turn { text, cancel } => {
126 if let Err(error) = harness.handle_message(&text, &mut sink, Some(&cancel)) {
127 let message = redact_secret(&error, Some(harness.provider.api_key()));
128 let _ = sink.emit_event(&ProtocolEvent::Error { message });
129 }
130 let _ = messages.send(WorkerMessage::Finished);
131 }
132 WorkerRequest::Shutdown => break,
133 }
134 }
135}
136
137fn event_loop<W: Write>(
138 terminal: &mut Terminal<CrosstermBackend<W>>,
139 state: &mut UiState,
140 requests: &Sender<WorkerRequest>,
141 messages: &Receiver<WorkerMessage>,
142) -> Result<(), String> {
143 let mut quitting = false;
144 loop {
145 loop {
146 match messages.try_recv() {
147 Ok(WorkerMessage::Event(event)) => state.apply_event(event),
148 Ok(WorkerMessage::Finished) => {
149 state.busy = false;
150 state.active_cancel = None;
151 discard_pending_input()?;
152 match state.status.as_str() {
153 "cancelling" => state.status = "사용자 중단".to_owned(),
154 "finalizing" => state.status = "ready".to_owned(),
155 _ => {}
156 }
157 if quitting {
158 return Ok(());
159 }
160 }
161 Err(TryRecvError::Empty) => break,
162 Err(TryRecvError::Disconnected) => {
163 if state.busy {
164 return Err("TUI worker stopped unexpectedly".to_owned());
165 }
166 return Ok(());
167 }
168 }
169 }
170
171 terminal
172 .draw(|frame| draw(frame, state))
173 .map_err(|error| format!("unable to render TUI: {error}"))?;
174
175 if quitting {
176 thread::sleep(EVENT_POLL);
177 continue;
178 }
179 if event::poll(EVENT_POLL)
180 .map_err(|error| format!("unable to read terminal input: {error}"))?
181 {
182 let event =
183 event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
184 if let Event::Mouse(mouse) = event {
185 let size = terminal
186 .size()
187 .map_err(|error| format!("unable to read terminal size: {error}"))?;
188 let max_scroll = max_scroll_for_area(state, size);
189 handle_mouse_event(state, mouse.kind, max_scroll);
190 continue;
191 }
192 let Event::Key(key) = event else {
193 continue;
194 };
195 if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
196 continue;
197 }
198 if is_ctrl_c(&key) {
199 if let Some(token) = state.active_cancel.as_ref() {
200 let _ = token.cancel();
201 quitting = true;
202 } else {
203 return Ok(());
204 }
205 continue;
206 }
207 if key.code == KeyCode::Esc {
208 if let Some(token) = state.active_cancel.as_ref() {
209 if token.cancel() {
210 state.status = "cancelling".to_owned();
211 }
212 }
213 continue;
214 }
215 if state.busy {
216 continue;
219 }
220 match key.code {
221 KeyCode::Enter => {
222 if key.modifiers.contains(KeyModifiers::SHIFT)
227 || key.modifiers.contains(KeyModifiers::ALT)
228 {
229 if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
230 state.input.push('\n');
231 }
232 continue;
233 }
234 let text = std::mem::take(&mut state.input);
235 if text.trim().is_empty() {
236 continue;
237 }
238 let secret = state.secret.clone();
239 state.auto_scroll = true;
240 state.scroll = 0;
241 state.add_user(&text, &secret);
242 let cancel = CancellationToken::new();
243 state.active_cancel = Some(cancel.clone());
244 state.busy = true;
245 state.status = "thinking".to_owned();
246 requests
247 .send(WorkerRequest::Turn { text, cancel })
248 .map_err(|_| "TUI worker is unavailable".to_owned())?;
249 }
250 KeyCode::Char(character) => {
251 if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
252 state.input.push(character);
253 }
254 }
255 KeyCode::Backspace => {
256 state.input.pop();
257 }
258 KeyCode::Up | KeyCode::PageUp => {
259 let size = terminal
260 .size()
261 .map_err(|error| format!("unable to read terminal size: {error}"))?;
262 let max_scroll = max_scroll_for_area(state, size);
263 scroll_up(state, max_scroll);
264 }
265 KeyCode::Down | KeyCode::PageDown => {
266 let size = terminal
267 .size()
268 .map_err(|error| format!("unable to read terminal size: {error}"))?;
269 let max_scroll = max_scroll_for_area(state, size);
270 scroll_down(state, max_scroll);
271 }
272 KeyCode::Home => {
273 state.scroll = 0;
274 state.auto_scroll = false;
275 }
276 KeyCode::End => {
277 state.auto_scroll = true;
278 state.scroll = 0;
279 }
280 _ => {}
281 }
282 }
283 }
284}
285
286fn discard_pending_input() -> Result<(), String> {
287 while event::poll(Duration::ZERO)
288 .map_err(|error| format!("unable to read terminal input: {error}"))?
289 {
290 let _ = event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
291 }
292 Ok(())
293}
294
295fn is_ctrl_c(key: &KeyEvent) -> bool {
296 key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
297}
298
299fn handle_mouse_event(state: &mut UiState, kind: MouseEventKind, max_scroll: u16) {
300 match kind {
301 MouseEventKind::ScrollUp => scroll_up(state, max_scroll),
302 MouseEventKind::ScrollDown => scroll_down(state, max_scroll),
303 _ => {}
304 }
305}
306
307fn scroll_up(state: &mut UiState, max_scroll: u16) {
308 if state.auto_scroll {
309 state.scroll = max_scroll;
310 state.auto_scroll = false;
311 } else {
312 state.scroll = state.scroll.min(max_scroll);
313 }
314 state.scroll = state.scroll.saturating_sub(3);
315}
316
317fn scroll_down(state: &mut UiState, max_scroll: u16) {
318 if state.auto_scroll {
319 return;
320 }
321 state.scroll = state.scroll.saturating_add(3).min(max_scroll);
322}
323
324fn wait_for_worker(worker: JoinHandle<()>, grace: Duration) {
325 let deadline = std::time::Instant::now() + grace;
326 while !worker.is_finished() && std::time::Instant::now() < deadline {
327 thread::sleep(Duration::from_millis(5));
328 }
329 if worker.is_finished() {
330 let _ = worker.join();
331 }
332}
333
334struct TerminalGuard<W: Write> {
335 terminal: Option<Terminal<CrosstermBackend<W>>>,
336 keyboard_enhancement: bool,
337}
338
339impl<W: Write> TerminalGuard<W> {
340 fn new(terminal: Terminal<CrosstermBackend<W>>) -> Self {
341 Self {
342 terminal: Some(terminal),
343 keyboard_enhancement: false,
344 }
345 }
346
347 fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {
348 self.terminal
349 .as_mut()
350 .expect("terminal guard is initialized")
351 }
352}
353
354impl<W: Write> Drop for TerminalGuard<W> {
355 fn drop(&mut self) {
356 let Some(mut terminal) = self.terminal.take() else {
357 return;
358 };
359 if self.keyboard_enhancement {
360 let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
361 }
362 let _ = terminal.show_cursor();
363 let _ = disable_raw_mode();
364 let _ = execute!(
365 terminal.backend_mut(),
366 DisableMouseCapture,
367 LeaveAlternateScreen,
368 Show
369 );
370 let _ = terminal.backend_mut().flush();
371 }
372}
373
374fn supports_keyboard_enhancement() -> bool {
379 fn env(name: &str) -> Option<String> {
380 std::env::var(name).ok().map(|value| value.to_lowercase())
381 }
382 let term = env("TERM").unwrap_or_default();
383 let program = env("TERM_PROGRAM").unwrap_or_default();
384 if term.starts_with("xterm-kitty")
385 || term.starts_with("ghostty")
386 || term.starts_with("xterm-ghostty")
387 {
388 return true;
389 }
390 matches!(
391 program.as_str(),
392 "ghostty" | "kitty" | "wezterm" | "alacritty" | "foot" | "footclient" | "iterm.app"
393 )
394}
395
396enum WorkerRequest {
397 Turn {
398 text: String,
399 cancel: CancellationToken,
400 },
401 Shutdown,
402}
403
404enum WorkerMessage {
405 Event(ProtocolEvent),
406 Finished,
407}
408
409struct ChannelSink {
410 sender: Sender<WorkerMessage>,
411}
412
413impl EventSink for ChannelSink {
414 fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
415 self.sender
416 .send(WorkerMessage::Event(event.clone()))
417 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
418 }
419}
420
421struct UiState {
422 session_id: String,
423 resumed: bool,
424 secret: String,
425 transcript: Vec<TranscriptItem>,
426 input: String,
427 status: String,
428 busy: bool,
429 active_cancel: Option<CancellationToken>,
430 scroll: u16,
431 auto_scroll: bool,
432 cursor_epoch: Instant,
433}
434
435impl UiState {
436 fn from_history(
437 history: &[SessionHistoryRecord],
438 secret: &str,
439 session_id: &str,
440 resumed: bool,
441 ) -> Self {
442 let mut state = Self {
443 session_id: session_id.to_owned(),
444 resumed,
445 secret: secret.to_owned(),
446 transcript: Vec::new(),
447 input: String::new(),
448 status: "ready".to_owned(),
449 busy: false,
450 active_cancel: None,
451 scroll: 0,
452 auto_scroll: true,
453 cursor_epoch: Instant::now(),
454 };
455 for record in history {
456 state.add_history_record(record);
457 }
458 state
459 }
460
461 fn add_history_record(&mut self, record: &SessionHistoryRecord) {
462 match record {
463 SessionHistoryRecord::Message { message, .. } => self.add_message(message),
464 SessionHistoryRecord::Interruption {
465 assistant_text,
466 tool_calls,
467 tool_results,
468 reason,
469 phase,
470 ..
471 } => {
472 if !assistant_text.is_empty() {
473 self.add_assistant_message(assistant_text);
474 }
475 for call in tool_calls {
476 self.add_tool_call(call);
477 }
478 for observation in tool_results {
479 self.add_tool_result(
480 &observation.id,
481 &observation.name,
482 observation.result.clone(),
483 );
484 }
485 self.transcript
486 .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
487 }
488 }
489 }
490
491 fn add_message(&mut self, message: &ChatMessage) {
492 match message.role.as_str() {
493 "user" => {
494 let secret = self.secret.clone();
495 self.add_user(message.content.as_deref().unwrap_or(""), &secret);
496 }
497 "assistant" => {
498 if let Some(content) = message.content.as_deref() {
499 self.add_assistant_message(content);
500 }
501 for call in &message.tool_calls {
502 self.add_tool_call(call);
503 }
504 }
505 "tool" => {
506 let result = message
507 .content
508 .as_deref()
509 .and_then(|content| serde_json::from_str::<Value>(content).ok())
510 .unwrap_or_else(|| Value::String(message.content.clone().unwrap_or_default()));
511 self.add_tool_result(
512 message.tool_call_id.as_deref().unwrap_or(""),
513 message.name.as_deref().unwrap_or("cmd"),
514 result,
515 );
516 }
517 _ => {}
518 }
519 }
520
521 fn add_user(&mut self, text: &str, secret: &str) {
522 self.transcript
523 .push(TranscriptItem::User(redact_secret(text, Some(secret))));
524 }
525
526 fn add_assistant(&mut self, text: &str) {
527 if let Some(TranscriptItem::Assistant(current)) = self.transcript.last_mut() {
528 current.push_str(text);
529 } else {
530 self.add_assistant_message(text);
531 }
532 }
533
534 fn add_assistant_message(&mut self, text: &str) {
535 self.transcript
536 .push(TranscriptItem::Assistant(text.to_owned()));
537 }
538
539 fn add_tool_call(&mut self, call: &crate::model::ChatToolCall) {
540 self.transcript.push(TranscriptItem::ToolCall {
541 id: call.id.clone(),
542 name: call.name.clone(),
543 arguments: call.arguments.clone(),
544 });
545 }
546
547 fn add_tool_result(&mut self, id: &str, name: &str, result: Value) {
548 self.transcript.push(TranscriptItem::ToolResult {
549 id: id.to_owned(),
550 name: name.to_owned(),
551 result,
552 });
553 }
554
555 fn cursor_visible(&self) -> bool {
556 !self.busy
557 && (self.cursor_epoch.elapsed().as_millis() / CURSOR_BLINK_INTERVAL.as_millis())
558 .is_multiple_of(2)
559 }
560
561 fn apply_event(&mut self, event: ProtocolEvent) {
562 match event {
563 ProtocolEvent::Session { .. } => {}
564 ProtocolEvent::AssistantDelta { text } => self.add_assistant(&text),
565 ProtocolEvent::ToolCall {
566 id,
567 name,
568 arguments,
569 } => self.add_tool_call(&crate::model::ChatToolCall {
570 id,
571 name,
572 arguments,
573 }),
574 ProtocolEvent::ToolResult { id, name, result } => {
575 self.add_tool_result(&id, &name, result)
576 }
577 ProtocolEvent::TurnEnd => {
578 self.status = "finalizing".to_owned();
579 self.transcript
580 .push(TranscriptItem::Info("✓ turn complete".to_owned()));
581 }
582 ProtocolEvent::TurnInterrupted { reason, phase } => {
583 self.status = "cancelling".to_owned();
584 self.transcript
585 .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
586 }
587 ProtocolEvent::Error { message } => {
588 self.status = "error".to_owned();
589 self.transcript.push(TranscriptItem::Error(message));
590 }
591 }
592 }
593}
594
595#[derive(Debug, Clone, PartialEq)]
596enum TranscriptItem {
597 User(String),
598 Assistant(String),
599 ToolCall {
600 id: String,
601 name: String,
602 arguments: String,
603 },
604 ToolResult {
605 id: String,
606 name: String,
607 result: Value,
608 },
609 Error(String),
610 Info(String),
611}
612
613fn max_scroll_for_area(state: &UiState, size: Size) -> u16 {
614 let area = Rect::new(0, 0, size.width, size.height);
615 let input_rows = input_visible_rows(state, area.width);
616 let input_height = input_rows.clamp(1, MAX_INPUT_ROWS) + 2;
617 let chunks = Layout::default()
618 .direction(Direction::Vertical)
619 .constraints([
620 Constraint::Min(1),
621 Constraint::Length(1),
622 Constraint::Length(input_height),
623 ])
624 .split(area);
625 let lines = transcript_lines(state, chunks[0].width);
626 lines
627 .len()
628 .saturating_sub(chunks[0].height as usize)
629 .min(u16::MAX as usize) as u16
630}
631
632fn input_visible_rows(state: &UiState, width: u16) -> u16 {
635 let width = width as usize;
636 if width == 0 {
637 return 1;
638 }
639 let prompt = input_prompt(&state.input);
640 let wrapped = wrap_text(&prompt, width);
641 wrapped.len().max(1) as u16
642}
643
644fn input_prompt(input: &str) -> String {
645 format!("> {}", input)
646}
647
648fn draw(frame: &mut Frame<'_>, state: &UiState) {
649 let area = frame.area();
650 let input_rows = input_visible_rows(state, area.width).clamp(1, MAX_INPUT_ROWS);
651 let input_height = input_rows + 2;
652 let chunks = Layout::default()
653 .direction(Direction::Vertical)
654 .constraints([
655 Constraint::Min(1),
656 Constraint::Length(1),
657 Constraint::Length(input_height),
658 ])
659 .split(area);
660
661 let width = chunks[0].width;
662 let lines = transcript_lines(state, width);
663 let available = chunks[0].height as usize;
664 let max_scroll = lines.len().saturating_sub(available).min(u16::MAX as usize) as u16;
665 let scroll = if state.auto_scroll {
666 max_scroll
667 } else {
668 state.scroll.min(max_scroll)
669 };
670 let transcript = Paragraph::new(lines).scroll((scroll, 0));
671 frame.render_widget(transcript, chunks[0]);
672
673 let mode = if state.resumed { "resumed" } else { "new" };
674 let status_text = format!(
675 " session={} · {} · {} · Enter send · Shift/Alt+Enter newline · Esc cancel · Ctrl-C exit",
676 state.session_id, mode, state.status
677 );
678 let status = Paragraph::new(redact_secret(&status_text, Some(&state.secret)));
679 frame.render_widget(status, chunks[1]);
680
681 let input_block = Block::default().borders(Borders::TOP | Borders::BOTTOM);
682 let input_area = input_block.inner(chunks[2]);
683 let prompt = redact_secret(&input_prompt(&state.input), Some(&state.secret));
684 let wrapped = wrap_text(&prompt, input_area.width.max(1) as usize);
685 let visible = (wrapped.len() as u16).clamp(1, input_rows);
686 let input_scroll = (wrapped.len() as u16).saturating_sub(visible);
687 let input_lines: Vec<Line<'static>> = wrapped
688 .into_iter()
689 .map(Line::raw)
690 .collect();
691 let input = Paragraph::new(input_lines.clone())
692 .scroll((input_scroll, 0))
693 .block(input_block);
694 frame.render_widget(input, chunks[2]);
695 if state.cursor_visible() && !input_area.is_empty() && visible > 0 {
698 let last_visible_idx = (input_scroll as usize) + (visible as usize) - 1;
701 let last_row = input_lines
702 .get(last_visible_idx)
703 .map(|line| line.to_string())
704 .unwrap_or_default();
705 let cursor_offset = UnicodeWidthStr::width(last_row.as_str()) as u16;
706 let cursor_x = input_area.x + cursor_offset.min(input_area.width.saturating_sub(1));
707 let cursor_y = input_area.y + (visible - 1);
708 frame.set_cursor_position((cursor_x, cursor_y));
709 }
710}
711
712fn transcript_lines(state: &UiState, width: u16) -> Vec<Line<'static>> {
713 let width = width.max(1) as usize;
714 let mut lines = Vec::new();
715 let mut prev: Option<&TranscriptItem> = None;
719 for item in &state.transcript {
720 let is_result_after_call = matches!(item, TranscriptItem::ToolResult { .. })
721 && matches!(prev, Some(TranscriptItem::ToolCall { .. }));
722 if !is_result_after_call && !lines.is_empty() {
723 lines.push(Line::raw(String::new()));
728 }
729 match item {
730 TranscriptItem::User(text) => {
731 let text = redact_secret(text, Some(&state.secret));
732 push_wrapped(&mut lines, &text, width, user_message_style());
733 }
734 TranscriptItem::Assistant(text) => {
735 let text = redact_secret(text, Some(&state.secret));
736 push_wrapped(&mut lines, &text, width, Style::default());
737 }
738 TranscriptItem::ToolCall {
739 id: _,
740 name,
741 arguments,
742 } => {
743 let call_text = format!("[tool:{name} {}]", call_arguments(arguments));
744 let call_text = redact_secret(&call_text, Some(&state.secret));
745 push_spans_wrapped(
746 &mut lines,
747 &[(call_text, tool_call_style())],
748 width,
749 );
750 }
751 TranscriptItem::ToolResult { id: _, name: _, result } => {
752 let result_text = format_tool_result(result);
753 let result_text = redact_secret(&result_text, Some(&state.secret));
754 if is_result_after_call {
755 if let Some(last) = lines.last_mut() {
756 last.spans.push(Span::raw(" > "));
757 last.spans.push(Span::styled(result_text, tool_result_style()));
758 } else {
759 push_spans_wrapped(
760 &mut lines,
761 &[(result_text, tool_result_style())],
762 width,
763 );
764 }
765 } else {
766 push_spans_wrapped(
767 &mut lines,
768 &[(result_text, tool_result_style())],
769 width,
770 );
771 }
772 }
773 TranscriptItem::Error(text) => {
774 let text = redact_secret(text, Some(&state.secret));
775 push_wrapped(&mut lines, &text, width, error_style());
776 }
777 TranscriptItem::Info(text) => {
778 let text = redact_secret(text, Some(&state.secret));
779 push_wrapped(&mut lines, &text, width, info_style());
780 }
781 }
782 prev = Some(item);
783 }
784 if lines.is_empty() {
785 lines.push(Line::raw("type a message"));
786 }
787 lines
788}
789
790fn call_arguments(arguments: &str) -> String {
793 let parsed: Value = match serde_json::from_str(arguments) {
794 Ok(value) => value,
795 Err(_) => return arguments.to_owned(),
796 };
797 if let Some(command) = parsed.get("command").and_then(Value::as_str) {
798 return format!("\"{command}\"");
799 }
800 serde_json::to_string(&parsed).unwrap_or_else(|_| arguments.to_owned())
801}
802
803fn format_tool_result(result: &Value) -> String {
807 let stdout = result.get("stdout").and_then(Value::as_str).unwrap_or("");
808 let stderr = result.get("stderr").and_then(Value::as_str).unwrap_or("");
809 let output = if !stdout.is_empty() { stdout } else { stderr };
810 let truncated = truncate_output(output);
811 let json_string = serde_json::to_string(&truncated).unwrap_or_else(|_| "\"\"".to_owned());
814 format!("[{json_string}]")
815}
816
817const RESULT_PREVIEW_CHARS: usize = 50;
818
819fn truncate_output(output: &str) -> String {
820 let mut result: String = output.chars().take(RESULT_PREVIEW_CHARS).collect();
821 if output.chars().count() > RESULT_PREVIEW_CHARS {
822 result.push('…');
823 }
824 result
825}
826
827fn user_message_style() -> Style {
828 Style::default().fg(Color::Yellow)
829}
830
831fn tool_call_style() -> Style {
832 Style::default().fg(Color::Magenta)
833}
834
835fn tool_result_style() -> Style {
836 Style::default().fg(Color::DarkGray)
837}
838
839fn error_style() -> Style {
840 Style::default().fg(Color::Red)
841}
842
843fn info_style() -> Style {
844 Style::default().fg(Color::DarkGray)
845}
846
847fn push_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, style: Style) {
848 let mut added = false;
849 for piece in wrap_text(text, width) {
850 lines.push(Line::styled(piece, style));
851 added = true;
852 }
853 if !added {
854 lines.push(Line::styled(String::new(), style));
855 }
856}
857
858fn push_spans_wrapped(
862 lines: &mut Vec<Line<'static>>,
863 segments: &[(String, Style)],
864 width: usize,
865) {
866 let mut current_spans: Vec<Span<'static>> = Vec::new();
867 let mut current_width = 0usize;
868 for (text, style) in segments {
869 for character in text.chars() {
870 let char_width =
871 unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
872 if current_width + char_width > width && !current_spans.is_empty() {
873 lines.push(Line::from(std::mem::take(&mut current_spans)));
874 current_width = 0;
875 }
876 let mut buffer = [0u8; 4];
877 let s = character.encode_utf8(&mut buffer);
878 current_spans.push(Span::styled(s.to_owned(), *style));
879 current_width += char_width;
880 }
881 }
882 if current_spans.is_empty() {
883 current_spans.push(Span::raw(String::new()));
884 }
885 lines.push(Line::from(current_spans));
886}
887
888fn wrap_text(text: &str, width: usize) -> Vec<String> {
894 if width == 0 {
895 return text.lines().map(str::to_owned).collect();
896 }
897 let mut rows = Vec::new();
898 for line in text.lines() {
899 rows.extend(wrap_line(line, width));
900 }
901 if rows.is_empty() {
902 rows.push(String::new());
903 }
904 rows
905}
906
907fn wrap_line(line: &str, width: usize) -> Vec<String> {
908 let mut rows = Vec::new();
909 let mut current = String::new();
910 let mut current_width = 0usize;
911 for character in line.chars() {
912 let char_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
913 if current_width + char_width > width && !current.is_empty() {
914 rows.push(std::mem::take(&mut current));
915 current_width = 0;
916 }
917 current.push(character);
918 current_width += char_width;
919 }
920 rows.push(current);
921 rows
922}
923
924#[cfg(test)]
925mod tests {
926 use super::*;
927
928 #[test]
929 fn history_replay_keeps_interruption_after_messages() {
930 let history = vec![
931 SessionHistoryRecord::Message {
932 timestamp: 1,
933 message: ChatMessage::user("hello".to_owned()),
934 },
935 SessionHistoryRecord::Interruption {
936 timestamp: 2,
937 reason: "user_cancelled".to_owned(),
938 phase: "provider_stream".to_owned(),
939 assistant_text: "partial".to_owned(),
940 tool_calls: Vec::new(),
941 tool_results: Vec::new(),
942 },
943 ];
944 let state = UiState::from_history(&history, "provider-secret", "id", true);
945 assert!(matches!(state.transcript[0], TranscriptItem::User(_)));
946 assert!(matches!(state.transcript[1], TranscriptItem::Assistant(_)));
947 assert!(matches!(state.transcript[2], TranscriptItem::Info(_)));
948 let text = transcript_lines(&state, 80)
949 .iter()
950 .map(ToString::to_string)
951 .collect::<Vec<_>>()
952 .join("\n");
953 assert!(!text.contains("choices"));
954 }
955
956 #[test]
957 fn history_replay_does_not_render_assistant_reasoning_details() {
958 let mut message = ChatMessage::assistant("visible answer".to_owned(), Vec::new());
959 message.reasoning_details = Some(vec![serde_json::json!({
960 "type": "reasoning.text",
961 "text": "private reasoning"
962 })]);
963 let history = [SessionHistoryRecord::Message {
964 timestamp: 1,
965 message,
966 }];
967 let state = UiState::from_history(&history, "provider-secret", "id", true);
968 let text = transcript_lines(&state, 80)
969 .iter()
970 .map(ToString::to_string)
971 .collect::<Vec<_>>()
972 .join("\n");
973 assert!(text.contains("visible answer"));
974 assert!(!text.contains("private reasoning"));
975 assert!(!text.contains("reasoning_details"));
976 }
977
978 #[test]
979 fn history_replay_preserves_repeated_records() {
980 let history = vec![
981 SessionHistoryRecord::Message {
982 timestamp: 1,
983 message: ChatMessage::assistant("same".to_owned(), Vec::new()),
984 },
985 SessionHistoryRecord::Interruption {
986 timestamp: 2,
987 reason: "user_cancelled".to_owned(),
988 phase: "provider_stream".to_owned(),
989 assistant_text: "same".to_owned(),
990 tool_calls: Vec::new(),
991 tool_results: Vec::new(),
992 },
993 ];
994 let state = UiState::from_history(&history, "provider-secret", "id", true);
995 assert_eq!(
996 state
997 .transcript
998 .iter()
999 .filter(|item| matches!(item, TranscriptItem::Assistant(text) if text == "same"))
1000 .count(),
1001 2
1002 );
1003 }
1004
1005 #[test]
1006 fn user_lines_use_a_bright_yellow_foreground_without_role_prefixes() {
1007 let history = [SessionHistoryRecord::Message {
1008 timestamp: 1,
1009 message: ChatMessage::user("hello".to_owned()),
1010 }];
1011 let state = UiState::from_history(&history, "provider-secret", "id", false);
1012 let lines = transcript_lines(&state, 80);
1013 assert_eq!(lines.len(), 1);
1014 assert_eq!(lines[0].style.fg, Some(Color::Yellow));
1015 assert_eq!(lines[0].style.bg, None);
1016 assert_eq!(lines[0].to_string(), "hello");
1017 }
1018
1019 #[test]
1020 fn transcript_rendering_redacts_history_content() {
1021 let history = [SessionHistoryRecord::Message {
1022 timestamp: 1,
1023 message: ChatMessage::assistant("provider-secret".to_owned(), Vec::new()),
1024 }];
1025 let state = UiState::from_history(&history, "provider-secret", "id", false);
1026 let text = transcript_lines(&state, 80)
1027 .iter()
1028 .map(ToString::to_string)
1029 .collect::<Vec<_>>()
1030 .join("\n");
1031 assert!(!text.contains("provider-secret"));
1032 }
1033
1034 #[test]
1035 fn mouse_wheel_disables_following_and_changes_scroll_offset() {
1036 let history = [SessionHistoryRecord::Message {
1037 timestamp: 1,
1038 message: ChatMessage::user("hello".to_owned()),
1039 }];
1040 let mut state = UiState::from_history(&history, "provider-secret", "id", false);
1041 handle_mouse_event(&mut state, MouseEventKind::ScrollUp, 10);
1042 assert!(!state.auto_scroll);
1043 assert_eq!(state.scroll, 7);
1044 handle_mouse_event(&mut state, MouseEventKind::ScrollDown, 10);
1045 assert_eq!(state.scroll, 10);
1046 assert!(!state.auto_scroll);
1047 state.scroll = 20;
1048 scroll_up(&mut state, 10);
1049 assert_eq!(state.scroll, 7);
1050 }
1051
1052 #[test]
1053 fn wrap_text_breaks_long_lines_and_preserves_empty_lines() {
1054 let rows = wrap_text("12345\n\nabc", 3);
1055 assert_eq!(rows, vec!["123", "45", "", "abc"]);
1056 }
1057
1058 #[test]
1059 fn wrap_line_never_returns_an_empty_vec() {
1060 assert_eq!(wrap_line("", 5), vec![""]);
1061 assert_eq!(wrap_line("abc", 5), vec!["abc"]);
1062 }
1063
1064 #[test]
1065 fn completion_event_does_not_release_input_before_worker_finishes() {
1066 let history = [SessionHistoryRecord::Message {
1067 timestamp: 1,
1068 message: ChatMessage::user("hello".to_owned()),
1069 }];
1070 let mut state = UiState::from_history(&history, "provider-secret", "id", false);
1071 state.busy = true;
1072 state.active_cancel = Some(CancellationToken::new());
1073 state.apply_event(ProtocolEvent::TurnEnd);
1074 assert!(state.busy);
1075 assert!(state.active_cancel.is_some());
1076 assert_eq!(state.status, "finalizing");
1077 }
1078
1079 #[test]
1080 fn transcript_inserts_a_blank_line_between_items() {
1081 let history = [
1082 SessionHistoryRecord::Message {
1083 timestamp: 1,
1084 message: ChatMessage::user("hi".to_owned()),
1085 },
1086 SessionHistoryRecord::Message {
1087 timestamp: 2,
1088 message: ChatMessage::assistant("hello".to_owned(), Vec::new()),
1089 },
1090 ];
1091 let state = UiState::from_history(&history, "secret", "id", false);
1092 let lines = transcript_lines(&state, 80);
1093 assert_eq!(lines.len(), 3);
1094 assert_eq!(lines[0].to_string(), "hi");
1095 assert_eq!(lines[1].to_string(), "");
1096 assert_eq!(lines[2].to_string(), "hello");
1097 }
1098
1099 #[test]
1100 fn tool_call_renders_as_compact_single_line_with_command() {
1101 let history = [SessionHistoryRecord::Message {
1102 timestamp: 1,
1103 message: ChatMessage::assistant(
1104 String::new(),
1105 vec![crate::model::ChatToolCall {
1106 id: "call-1".to_owned(),
1107 name: "cmd".to_owned(),
1108 arguments: r#"{"command":"pwd"}"#.to_owned(),
1109 }],
1110 ),
1111 }];
1112 let state = UiState::from_history(&history, "secret", "id", false);
1113 let text = transcript_lines(&state, 80)
1114 .iter()
1115 .map(ToString::to_string)
1116 .collect::<Vec<_>>()
1117 .join("\n");
1118 assert!(text.contains("[tool:cmd \"pwd\"]"));
1119 assert!(!text.contains("{\"command\":\"pwd\"}"));
1121 }
1122
1123 #[test]
1124 fn tool_call_and_result_render_on_one_line_with_truncated_stdout() {
1125 let long_stdout = "a".repeat(80);
1126 let history = vec![
1127 SessionHistoryRecord::Message {
1128 timestamp: 1,
1129 message: ChatMessage::assistant(
1130 String::new(),
1131 vec![crate::model::ChatToolCall {
1132 id: "call-1".to_owned(),
1133 name: "cmd".to_owned(),
1134 arguments: r#"{"command":"cat README.md"}"#.to_owned(),
1135 }],
1136 ),
1137 },
1138 SessionHistoryRecord::Message {
1139 timestamp: 2,
1140 message: ChatMessage::tool(
1141 "call-1".to_owned(),
1142 "cmd".to_owned(),
1143 serde_json::json!({
1144 "command": "cat README.md",
1145 "exit_code": 0,
1146 "stdout": long_stdout,
1147 "stderr": "",
1148 })
1149 .to_string(),
1150 ),
1151 },
1152 ];
1153 let state = UiState::from_history(&history, "secret", "id", false);
1154 let lines = transcript_lines(&state, 200);
1155 assert_eq!(lines.len(), 1);
1157 let text = lines[0].to_string();
1158 assert!(text.starts_with("[tool:cmd \"cat README.md\"] > ["));
1159 assert!(text.contains(&"a".repeat(50)));
1161 assert!(text.contains('…'));
1162 assert!(!text.contains(&"a".repeat(51)));
1163 }
1164
1165 #[test]
1166 fn tool_result_falls_back_to_stderr_when_stdout_is_empty() {
1167 let history = vec![
1168 SessionHistoryRecord::Message {
1169 timestamp: 1,
1170 message: ChatMessage::assistant(
1171 String::new(),
1172 vec![crate::model::ChatToolCall {
1173 id: "call-1".to_owned(),
1174 name: "cmd".to_owned(),
1175 arguments: r#"{"command":"bad"}"#.to_owned(),
1176 }],
1177 ),
1178 },
1179 SessionHistoryRecord::Message {
1180 timestamp: 2,
1181 message: ChatMessage::tool(
1182 "call-1".to_owned(),
1183 "cmd".to_owned(),
1184 serde_json::json!({
1185 "command": "bad",
1186 "exit_code": 127,
1187 "stdout": "",
1188 "stderr": "not found",
1189 })
1190 .to_string(),
1191 ),
1192 },
1193 ];
1194 let state = UiState::from_history(&history, "secret", "id", false);
1195 let text = transcript_lines(&state, 200)[0].to_string();
1196 assert!(text.contains("not found"));
1197 assert!(text.contains(" > "));
1198 }
1199
1200 #[test]
1201 fn tool_call_and_result_styles_use_foreground_colors() {
1202 let history = vec![
1203 SessionHistoryRecord::Message {
1204 timestamp: 1,
1205 message: ChatMessage::assistant(
1206 String::new(),
1207 vec![crate::model::ChatToolCall {
1208 id: "call-1".to_owned(),
1209 name: "cmd".to_owned(),
1210 arguments: r#"{"command":"pwd"}"#.to_owned(),
1211 }],
1212 ),
1213 },
1214 SessionHistoryRecord::Message {
1215 timestamp: 2,
1216 message: ChatMessage::tool(
1217 "call-1".to_owned(),
1218 "cmd".to_owned(),
1219 serde_json::json!({"stdout":"x","stderr":""}).to_string(),
1220 ),
1221 },
1222 ];
1223 let state = UiState::from_history(&history, "secret", "id", false);
1224 let lines = transcript_lines(&state, 200);
1225 let spans = &lines[0].spans;
1226 assert_eq!(spans[0].style.fg, Some(Color::Magenta));
1229 let separator = spans.iter().find(|span| span.content == " > ").expect("separator");
1230 assert_eq!(separator.style.fg, None);
1231 let result_span = spans.last().expect("result span");
1232 assert_eq!(result_span.style.fg, Some(Color::DarkGray));
1233 assert_eq!(result_span.content, "[\"x\"]");
1234 }
1235
1236 #[test]
1237 fn input_prompt_wraps_to_multiple_rows_when_long() {
1238 let mut state = UiState::from_history(&[], "secret", "id", false);
1239 state.input = "abcdefghij".to_owned();
1240 let rows = input_visible_rows(&state, 5);
1242 assert!(rows >= 3);
1243 }
1244}