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, MouseEventKind,
10};
11use crossterm::execute;
12use crossterm::terminal::{
13 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
14};
15use ratatui::backend::CrosstermBackend;
16use ratatui::layout::{Constraint, Direction, Layout, Rect, Size};
17use ratatui::prelude::Frame;
18use ratatui::style::{Color, Style};
19use ratatui::text::Line;
20use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
21use ratatui::Terminal;
22use serde_json::Value;
23use unicode_width::UnicodeWidthStr;
24
25use crate::app::Harness;
26use crate::cancellation::CancellationToken;
27use crate::model::ChatMessage;
28use crate::protocol::{EventSink, ProtocolEvent};
29use crate::redaction::redact_secret;
30use crate::session::SessionHistoryRecord;
31
32const EVENT_POLL: Duration = Duration::from_millis(50);
33const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
34const MAX_DISPLAY_RESULT_CHARS: usize = 8 * 1024;
35const MAX_DISPLAY_INPUT_CHARS: usize = 16 * 1024;
36const WORKER_SHUTDOWN_GRACE: Duration = Duration::from_secs(2);
37
38pub(crate) fn run<W: Write>(mut harness: Harness, resumed: bool, stdout: W) -> Result<(), String> {
39 let secret = harness.provider.api_key().to_owned();
40 let mut state = UiState::from_history(
41 &harness.session.history,
42 &secret,
43 &harness.session.id,
44 resumed,
45 );
46 let (request_tx, request_rx) = mpsc::channel::<WorkerRequest>();
47 let (message_tx, message_rx) = mpsc::channel::<WorkerMessage>();
48
49 let stdout = stdout;
50 enable_raw_mode().map_err(|error| format!("unable to enable terminal input: {error}"))?;
51 let backend = CrosstermBackend::new(stdout);
52 let terminal = match Terminal::new(backend) {
53 Ok(terminal) => terminal,
54 Err(error) => {
55 let _ = disable_raw_mode();
56 return Err(format!("unable to initialize terminal UI: {error}"));
57 }
58 };
59 let mut terminal_guard = TerminalGuard::new(terminal);
60 if let Err(error) = execute!(
61 terminal_guard.terminal_mut().backend_mut(),
62 EnterAlternateScreen,
63 EnableMouseCapture,
64 Hide
65 ) {
66 return Err(format!("unable to enter terminal UI: {error}"));
67 }
68 let worker = thread::spawn(move || worker_loop(&mut harness, request_rx, message_tx, resumed));
69
70 let result = event_loop(
71 terminal_guard.terminal_mut(),
72 &mut state,
73 &request_tx,
74 &message_rx,
75 );
76
77 if let Some(token) = state.active_cancel.take() {
78 let _ = token.cancel();
79 }
80 let _ = request_tx.send(WorkerRequest::Shutdown);
81 wait_for_worker(worker, WORKER_SHUTDOWN_GRACE);
82 drop(terminal_guard);
83 result
84}
85
86fn worker_loop(
87 harness: &mut Harness,
88 requests: Receiver<WorkerRequest>,
89 messages: Sender<WorkerMessage>,
90 resumed: bool,
91) {
92 let mut sink = ChannelSink {
93 sender: messages.clone(),
94 };
95 if sink
96 .emit_event(&ProtocolEvent::Session {
97 session_id: harness.session.id.clone(),
98 resumed,
99 })
100 .is_err()
101 {
102 return;
103 }
104
105 while let Ok(request) = requests.recv() {
106 match request {
107 WorkerRequest::Turn { text, cancel } => {
108 if let Err(error) = harness.handle_message(&text, &mut sink, Some(&cancel)) {
109 let message = redact_secret(&error, Some(harness.provider.api_key()));
110 let _ = sink.emit_event(&ProtocolEvent::Error { message });
111 }
112 let _ = messages.send(WorkerMessage::Finished);
113 }
114 WorkerRequest::Shutdown => break,
115 }
116 }
117}
118
119fn event_loop<W: Write>(
120 terminal: &mut Terminal<CrosstermBackend<W>>,
121 state: &mut UiState,
122 requests: &Sender<WorkerRequest>,
123 messages: &Receiver<WorkerMessage>,
124) -> Result<(), String> {
125 let mut quitting = false;
126 loop {
127 loop {
128 match messages.try_recv() {
129 Ok(WorkerMessage::Event(event)) => state.apply_event(event),
130 Ok(WorkerMessage::Finished) => {
131 state.busy = false;
132 state.active_cancel = None;
133 discard_pending_input()?;
134 match state.status.as_str() {
135 "cancelling" => state.status = "사용자 중단".to_owned(),
136 "finalizing" => state.status = "ready".to_owned(),
137 _ => {}
138 }
139 if quitting {
140 return Ok(());
141 }
142 }
143 Err(TryRecvError::Empty) => break,
144 Err(TryRecvError::Disconnected) => {
145 if state.busy {
146 return Err("TUI worker stopped unexpectedly".to_owned());
147 }
148 return Ok(());
149 }
150 }
151 }
152
153 terminal
154 .draw(|frame| draw(frame, state))
155 .map_err(|error| format!("unable to render TUI: {error}"))?;
156
157 if quitting {
158 thread::sleep(EVENT_POLL);
159 continue;
160 }
161 if event::poll(EVENT_POLL)
162 .map_err(|error| format!("unable to read terminal input: {error}"))?
163 {
164 let event =
165 event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
166 if let Event::Mouse(mouse) = event {
167 let max_scroll = max_scroll_for_area(
168 state,
169 terminal
170 .size()
171 .map_err(|error| format!("unable to read terminal size: {error}"))?,
172 );
173 handle_mouse_event(state, mouse.kind, max_scroll);
174 continue;
175 }
176 let Event::Key(key) = event else {
177 continue;
178 };
179 if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
180 continue;
181 }
182 if is_ctrl_c(&key) {
183 if let Some(token) = state.active_cancel.as_ref() {
184 let _ = token.cancel();
185 quitting = true;
186 } else {
187 return Ok(());
188 }
189 continue;
190 }
191 if key.code == KeyCode::Esc {
192 if let Some(token) = state.active_cancel.as_ref() {
193 if token.cancel() {
194 state.status = "cancelling".to_owned();
195 }
196 }
197 continue;
198 }
199 if state.busy {
200 continue;
203 }
204 match key.code {
205 KeyCode::Enter => {
206 let text = std::mem::take(&mut state.input);
207 if text.trim().is_empty() {
208 continue;
209 }
210 let secret = state.secret.clone();
211 state.auto_scroll = true;
212 state.scroll = 0;
213 state.add_user(&text, &secret);
214 let cancel = CancellationToken::new();
215 state.active_cancel = Some(cancel.clone());
216 state.busy = true;
217 state.status = "thinking".to_owned();
218 requests
219 .send(WorkerRequest::Turn { text, cancel })
220 .map_err(|_| "TUI worker is unavailable".to_owned())?;
221 }
222 KeyCode::Char(character) => {
223 if state.input.chars().count() < MAX_DISPLAY_INPUT_CHARS {
224 state.input.push(character);
225 }
226 }
227 KeyCode::Backspace => {
228 state.input.pop();
229 }
230 KeyCode::Up | KeyCode::PageUp => {
231 let max_scroll = max_scroll_for_area(
232 state,
233 terminal
234 .size()
235 .map_err(|error| format!("unable to read terminal size: {error}"))?,
236 );
237 scroll_up(state, max_scroll);
238 }
239 KeyCode::Down | KeyCode::PageDown => {
240 let max_scroll = max_scroll_for_area(
241 state,
242 terminal
243 .size()
244 .map_err(|error| format!("unable to read terminal size: {error}"))?,
245 );
246 scroll_down(state, max_scroll);
247 }
248 KeyCode::Home => {
249 state.scroll = 0;
250 state.auto_scroll = false;
251 }
252 KeyCode::End => {
253 state.auto_scroll = true;
254 state.scroll = 0;
255 }
256 _ => {}
257 }
258 }
259 }
260}
261
262fn discard_pending_input() -> Result<(), String> {
263 while event::poll(Duration::ZERO)
264 .map_err(|error| format!("unable to read terminal input: {error}"))?
265 {
266 let _ = event::read().map_err(|error| format!("unable to read terminal input: {error}"))?;
267 }
268 Ok(())
269}
270
271fn is_ctrl_c(key: &KeyEvent) -> bool {
272 key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
273}
274
275fn handle_mouse_event(state: &mut UiState, kind: MouseEventKind, max_scroll: u16) {
276 match kind {
277 MouseEventKind::ScrollUp => scroll_up(state, max_scroll),
278 MouseEventKind::ScrollDown => scroll_down(state, max_scroll),
279 _ => {}
280 }
281}
282
283fn scroll_up(state: &mut UiState, max_scroll: u16) {
284 if state.auto_scroll {
285 state.scroll = max_scroll;
286 state.auto_scroll = false;
287 } else {
288 state.scroll = state.scroll.min(max_scroll);
289 }
290 state.scroll = state.scroll.saturating_sub(3);
291}
292
293fn scroll_down(state: &mut UiState, max_scroll: u16) {
294 if state.auto_scroll {
295 return;
296 }
297 state.scroll = state.scroll.saturating_add(3).min(max_scroll);
298}
299
300fn wait_for_worker(worker: JoinHandle<()>, grace: Duration) {
301 let deadline = std::time::Instant::now() + grace;
302 while !worker.is_finished() && std::time::Instant::now() < deadline {
303 thread::sleep(Duration::from_millis(5));
304 }
305 if worker.is_finished() {
306 let _ = worker.join();
307 }
308}
309
310struct TerminalGuard<W: Write> {
311 terminal: Option<Terminal<CrosstermBackend<W>>>,
312}
313
314impl<W: Write> TerminalGuard<W> {
315 fn new(terminal: Terminal<CrosstermBackend<W>>) -> Self {
316 Self {
317 terminal: Some(terminal),
318 }
319 }
320
321 fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {
322 self.terminal
323 .as_mut()
324 .expect("terminal guard is initialized")
325 }
326}
327
328impl<W: Write> Drop for TerminalGuard<W> {
329 fn drop(&mut self) {
330 let Some(mut terminal) = self.terminal.take() else {
331 return;
332 };
333 let _ = terminal.show_cursor();
334 let _ = disable_raw_mode();
335 let _ = execute!(
336 terminal.backend_mut(),
337 DisableMouseCapture,
338 LeaveAlternateScreen,
339 Show
340 );
341 let _ = terminal.backend_mut().flush();
342 }
343}
344
345enum WorkerRequest {
346 Turn {
347 text: String,
348 cancel: CancellationToken,
349 },
350 Shutdown,
351}
352
353enum WorkerMessage {
354 Event(ProtocolEvent),
355 Finished,
356}
357
358struct ChannelSink {
359 sender: Sender<WorkerMessage>,
360}
361
362impl EventSink for ChannelSink {
363 fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
364 self.sender
365 .send(WorkerMessage::Event(event.clone()))
366 .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "TUI closed"))
367 }
368}
369
370struct UiState {
371 session_id: String,
372 resumed: bool,
373 secret: String,
374 transcript: Vec<TranscriptItem>,
375 input: String,
376 status: String,
377 busy: bool,
378 active_cancel: Option<CancellationToken>,
379 scroll: u16,
380 auto_scroll: bool,
381 cursor_epoch: Instant,
382}
383
384impl UiState {
385 fn from_history(
386 history: &[SessionHistoryRecord],
387 secret: &str,
388 session_id: &str,
389 resumed: bool,
390 ) -> Self {
391 let mut state = Self {
392 session_id: session_id.to_owned(),
393 resumed,
394 secret: secret.to_owned(),
395 transcript: Vec::new(),
396 input: String::new(),
397 status: "ready".to_owned(),
398 busy: false,
399 active_cancel: None,
400 scroll: 0,
401 auto_scroll: true,
402 cursor_epoch: Instant::now(),
403 };
404 for record in history {
405 state.add_history_record(record);
406 }
407 state
408 }
409
410 fn add_history_record(&mut self, record: &SessionHistoryRecord) {
411 match record {
412 SessionHistoryRecord::Message { message, .. } => self.add_message(message),
413 SessionHistoryRecord::Interruption {
414 assistant_text,
415 tool_calls,
416 tool_results,
417 reason,
418 phase,
419 ..
420 } => {
421 if !assistant_text.is_empty() {
422 self.add_assistant_message(assistant_text);
423 }
424 for call in tool_calls {
425 self.add_tool_call(call);
426 }
427 for observation in tool_results {
428 self.add_tool_result(
429 &observation.id,
430 &observation.name,
431 observation.result.clone(),
432 );
433 }
434 self.transcript
435 .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
436 }
437 }
438 }
439
440 fn add_message(&mut self, message: &ChatMessage) {
441 match message.role.as_str() {
442 "user" => {
443 let secret = self.secret.clone();
444 self.add_user(message.content.as_deref().unwrap_or(""), &secret);
445 }
446 "assistant" => {
447 if let Some(content) = message.content.as_deref() {
448 self.add_assistant_message(content);
449 }
450 for call in &message.tool_calls {
451 self.add_tool_call(call);
452 }
453 }
454 "tool" => {
455 let result = message
456 .content
457 .as_deref()
458 .and_then(|content| serde_json::from_str::<Value>(content).ok())
459 .unwrap_or_else(|| Value::String(message.content.clone().unwrap_or_default()));
460 self.add_tool_result(
461 message.tool_call_id.as_deref().unwrap_or(""),
462 message.name.as_deref().unwrap_or("cmd"),
463 result,
464 );
465 }
466 _ => {}
467 }
468 }
469
470 fn add_user(&mut self, text: &str, secret: &str) {
471 self.transcript
472 .push(TranscriptItem::User(redact_secret(text, Some(secret))));
473 }
474
475 fn add_assistant(&mut self, text: &str) {
476 if let Some(TranscriptItem::Assistant(current)) = self.transcript.last_mut() {
477 current.push_str(text);
478 } else {
479 self.add_assistant_message(text);
480 }
481 }
482
483 fn add_assistant_message(&mut self, text: &str) {
484 self.transcript
485 .push(TranscriptItem::Assistant(text.to_owned()));
486 }
487
488 fn add_tool_call(&mut self, call: &crate::model::ChatToolCall) {
489 self.transcript.push(TranscriptItem::ToolCall {
490 id: call.id.clone(),
491 name: call.name.clone(),
492 arguments: call.arguments.clone(),
493 });
494 }
495
496 fn add_tool_result(&mut self, id: &str, name: &str, result: Value) {
497 self.transcript.push(TranscriptItem::ToolResult {
498 id: id.to_owned(),
499 name: name.to_owned(),
500 result,
501 });
502 }
503
504 fn cursor_visible(&self) -> bool {
505 !self.busy
506 && (self.cursor_epoch.elapsed().as_millis() / CURSOR_BLINK_INTERVAL.as_millis())
507 .is_multiple_of(2)
508 }
509
510 fn apply_event(&mut self, event: ProtocolEvent) {
511 match event {
512 ProtocolEvent::Session { .. } => {}
513 ProtocolEvent::AssistantDelta { text } => self.add_assistant(&text),
514 ProtocolEvent::ToolCall {
515 id,
516 name,
517 arguments,
518 } => self.add_tool_call(&crate::model::ChatToolCall {
519 id,
520 name,
521 arguments,
522 }),
523 ProtocolEvent::ToolResult { id, name, result } => {
524 self.add_tool_result(&id, &name, result)
525 }
526 ProtocolEvent::TurnEnd => {
527 self.status = "finalizing".to_owned();
528 self.transcript
529 .push(TranscriptItem::Info("✓ turn complete".to_owned()));
530 }
531 ProtocolEvent::TurnInterrupted { reason, phase } => {
532 self.status = "cancelling".to_owned();
533 self.transcript
534 .push(TranscriptItem::Info(format!("! {reason} ({phase})")));
535 }
536 ProtocolEvent::Error { message } => {
537 self.status = "error".to_owned();
538 self.transcript.push(TranscriptItem::Error(message));
539 }
540 }
541 }
542}
543
544#[derive(Debug, Clone, PartialEq)]
545enum TranscriptItem {
546 User(String),
547 Assistant(String),
548 ToolCall {
549 id: String,
550 name: String,
551 arguments: String,
552 },
553 ToolResult {
554 id: String,
555 name: String,
556 result: Value,
557 },
558 Error(String),
559 Info(String),
560}
561
562fn max_scroll_for_area(state: &UiState, size: Size) -> u16 {
563 let area = Rect::new(0, 0, size.width, size.height);
564 let chunks = Layout::default()
565 .direction(Direction::Vertical)
566 .constraints([
567 Constraint::Min(1),
568 Constraint::Length(1),
569 Constraint::Length(3),
570 ])
571 .split(area);
572 let lines = transcript_lines(state);
573 let visual_lines = visual_line_count(&lines, chunks[0].width);
574 visual_lines
575 .saturating_sub(chunks[0].height as usize)
576 .min(u16::MAX as usize) as u16
577}
578
579fn visual_line_count(lines: &[Line<'static>], width: u16) -> usize {
580 if width == 0 {
581 return 0;
582 }
583 let width = width as usize;
584 lines.iter().fold(0, |total, line| {
585 let line_width = line.width().max(1);
586 let rows = line_width.saturating_add(width - 1) / width;
587 total.saturating_add(rows)
588 })
589}
590
591fn draw(frame: &mut Frame<'_>, state: &UiState) {
592 let area = frame.area();
593 let chunks = Layout::default()
594 .direction(Direction::Vertical)
595 .constraints([
596 Constraint::Min(1),
597 Constraint::Length(1),
598 Constraint::Length(3),
599 ])
600 .split(area);
601
602 let lines = transcript_lines(state);
603 let available = chunks[0].height as usize;
604 let visual_lines = visual_line_count(&lines, chunks[0].width);
605 let max_scroll = visual_lines
606 .saturating_sub(available)
607 .min(u16::MAX as usize) as u16;
608 let scroll = if state.auto_scroll {
609 max_scroll
610 } else {
611 state.scroll.min(max_scroll)
612 };
613 let transcript = Paragraph::new(lines)
614 .wrap(Wrap { trim: false })
615 .scroll((scroll, 0));
616 frame.render_widget(transcript, chunks[0]);
617
618 let mode = if state.resumed { "resumed" } else { "new" };
619 let status_text = format!(
620 " session={} · {} · {} · Enter send · Esc cancel · Ctrl-C exit",
621 state.session_id, mode, state.status
622 );
623 let status = Paragraph::new(redact_secret(&status_text, Some(&state.secret)));
624 frame.render_widget(status, chunks[1]);
625
626 let input_text = format!("> {}", state.input);
627 let safe_input = redact_secret(&input_text, Some(&state.secret));
628 let input_block = Block::default().borders(Borders::TOP | Borders::BOTTOM);
629 let input_area = input_block.inner(chunks[2]);
630 let input = Paragraph::new(safe_input.clone()).block(input_block);
631 frame.render_widget(input, chunks[2]);
632 if state.cursor_visible() && !input_area.is_empty() {
635 let cursor_offset = UnicodeWidthStr::width(safe_input.as_str()) as u16;
636 let cursor_x = input_area.x + cursor_offset.min(input_area.width.saturating_sub(1));
637 frame.set_cursor_position((cursor_x, input_area.y));
638 }
639}
640
641fn transcript_lines(state: &UiState) -> Vec<Line<'static>> {
642 let mut lines = Vec::new();
643 for item in &state.transcript {
644 match item {
645 TranscriptItem::User(text) => {
646 let text = redact_secret(text, Some(&state.secret));
647 push_message_lines(&mut lines, &text, user_message_style());
648 }
649 TranscriptItem::Assistant(text) => {
650 let text = redact_secret(text, Some(&state.secret));
651 push_message_lines(&mut lines, &text, Style::default());
652 }
653 TranscriptItem::ToolCall {
654 id,
655 name,
656 arguments,
657 } => {
658 let text =
659 redact_secret(&format!("{name} [{id}] {arguments}"), Some(&state.secret));
660 push_labeled_lines(&mut lines, "tool", &text);
661 }
662 TranscriptItem::ToolResult { id, name, result } => {
663 let text = serde_json::to_string_pretty(result).unwrap_or_else(|_| "{}".to_owned());
664 let text = redact_secret(
665 &format!("{name} [{id}]\n{}", bounded_display(&text)),
666 Some(&state.secret),
667 );
668 push_labeled_lines(&mut lines, "result", &text);
669 }
670 TranscriptItem::Error(text) => {
671 let text = redact_secret(text, Some(&state.secret));
672 push_labeled_lines(&mut lines, "error", &text);
673 }
674 TranscriptItem::Info(text) => {
675 let text = redact_secret(text, Some(&state.secret));
676 push_labeled_lines(&mut lines, "status", &text);
677 }
678 }
679 }
680 if lines.is_empty() {
681 lines.push(Line::raw("type a message"));
682 }
683 lines
684}
685
686fn user_message_style() -> Style {
687 Style::default().bg(Color::Rgb(96, 86, 42))
688}
689
690fn push_message_lines(lines: &mut Vec<Line<'static>>, text: &str, style: Style) {
691 let mut added = false;
692 for line in text.lines() {
693 lines.push(Line::styled(line.to_owned(), style));
694 added = true;
695 }
696 if !added {
697 lines.push(Line::styled(String::new(), style));
698 }
699}
700
701fn push_labeled_lines(lines: &mut Vec<Line<'static>>, label: &str, text: &str) {
702 let mut first = true;
703 for line in text.lines() {
704 let content = if first {
705 format!("{label}> {line}")
706 } else {
707 format!(" {line}")
708 };
709 lines.push(Line::raw(content));
710 first = false;
711 }
712 if first {
713 lines.push(Line::raw(format!("{label}>")));
714 }
715}
716
717fn bounded_display(text: &str) -> String {
718 let mut result = text
719 .chars()
720 .take(MAX_DISPLAY_RESULT_CHARS)
721 .collect::<String>();
722 if text.chars().count() > MAX_DISPLAY_RESULT_CHARS {
723 result.push('…');
724 }
725 result
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731
732 #[test]
733 fn history_replay_keeps_interruption_after_messages() {
734 let history = vec![
735 SessionHistoryRecord::Message {
736 timestamp: 1,
737 message: ChatMessage::user("hello".to_owned()),
738 },
739 SessionHistoryRecord::Interruption {
740 timestamp: 2,
741 reason: "user_cancelled".to_owned(),
742 phase: "provider_stream".to_owned(),
743 assistant_text: "partial".to_owned(),
744 tool_calls: Vec::new(),
745 tool_results: Vec::new(),
746 },
747 ];
748 let state = UiState::from_history(&history, "provider-secret", "id", true);
749 assert!(matches!(state.transcript[0], TranscriptItem::User(_)));
750 assert!(matches!(state.transcript[1], TranscriptItem::Assistant(_)));
751 assert!(matches!(state.transcript[2], TranscriptItem::Info(_)));
752 let text = transcript_lines(&state)
753 .iter()
754 .map(ToString::to_string)
755 .collect::<Vec<_>>()
756 .join("\n");
757 assert!(!text.contains("choices"));
758 }
759
760 #[test]
761 fn history_replay_does_not_render_assistant_reasoning_details() {
762 let mut message = ChatMessage::assistant("visible answer".to_owned(), Vec::new());
763 message.reasoning_details = Some(vec![serde_json::json!({
764 "type": "reasoning.text",
765 "text": "private reasoning"
766 })]);
767 let history = [SessionHistoryRecord::Message {
768 timestamp: 1,
769 message,
770 }];
771 let state = UiState::from_history(&history, "provider-secret", "id", true);
772 let text = transcript_lines(&state)
773 .iter()
774 .map(ToString::to_string)
775 .collect::<Vec<_>>()
776 .join("\n");
777 assert!(text.contains("visible answer"));
778 assert!(!text.contains("private reasoning"));
779 assert!(!text.contains("reasoning_details"));
780 }
781
782 #[test]
783 fn history_replay_preserves_repeated_records() {
784 let history = vec![
785 SessionHistoryRecord::Message {
786 timestamp: 1,
787 message: ChatMessage::assistant("same".to_owned(), Vec::new()),
788 },
789 SessionHistoryRecord::Interruption {
790 timestamp: 2,
791 reason: "user_cancelled".to_owned(),
792 phase: "provider_stream".to_owned(),
793 assistant_text: "same".to_owned(),
794 tool_calls: Vec::new(),
795 tool_results: Vec::new(),
796 },
797 ];
798 let state = UiState::from_history(&history, "provider-secret", "id", true);
799 assert_eq!(
800 state
801 .transcript
802 .iter()
803 .filter(|item| matches!(item, TranscriptItem::Assistant(text) if text == "same"))
804 .count(),
805 2
806 );
807 }
808
809 #[test]
810 fn user_lines_use_a_muted_yellow_background_without_role_prefixes() {
811 let history = [SessionHistoryRecord::Message {
812 timestamp: 1,
813 message: ChatMessage::user("hello".to_owned()),
814 }];
815 let state = UiState::from_history(&history, "provider-secret", "id", false);
816 let lines = transcript_lines(&state);
817 assert_eq!(lines.len(), 1);
818 assert_eq!(lines[0].style.bg, Some(Color::Rgb(96, 86, 42)));
819 assert_eq!(lines[0].to_string(), "hello");
820 }
821
822 #[test]
823 fn transcript_rendering_redacts_history_content() {
824 let history = [SessionHistoryRecord::Message {
825 timestamp: 1,
826 message: ChatMessage::assistant("provider-secret".to_owned(), Vec::new()),
827 }];
828 let state = UiState::from_history(&history, "provider-secret", "id", false);
829 let text = transcript_lines(&state)
830 .iter()
831 .map(ToString::to_string)
832 .collect::<Vec<_>>()
833 .join("\n");
834 assert!(!text.contains("provider-secret"));
835 }
836
837 #[test]
838 fn mouse_wheel_disables_following_and_changes_scroll_offset() {
839 let history = [SessionHistoryRecord::Message {
840 timestamp: 1,
841 message: ChatMessage::user("hello".to_owned()),
842 }];
843 let mut state = UiState::from_history(&history, "provider-secret", "id", false);
844 handle_mouse_event(&mut state, MouseEventKind::ScrollUp, 10);
845 assert!(!state.auto_scroll);
846 assert_eq!(state.scroll, 7);
847 handle_mouse_event(&mut state, MouseEventKind::ScrollDown, 10);
848 assert_eq!(state.scroll, 10);
849 assert!(!state.auto_scroll);
850 state.scroll = 20;
851 scroll_up(&mut state, 10);
852 assert_eq!(state.scroll, 7);
853 }
854
855 #[test]
856 fn visual_line_count_accounts_for_wrapped_lines_and_empty_lines() {
857 let lines = vec![Line::raw("12345"), Line::raw("")];
858 assert_eq!(visual_line_count(&lines, 3), 3);
859 }
860
861 #[test]
862 fn completion_event_does_not_release_input_before_worker_finishes() {
863 let history = [SessionHistoryRecord::Message {
864 timestamp: 1,
865 message: ChatMessage::user("hello".to_owned()),
866 }];
867 let mut state = UiState::from_history(&history, "provider-secret", "id", false);
868 state.busy = true;
869 state.active_cancel = Some(CancellationToken::new());
870 state.apply_event(ProtocolEvent::TurnEnd);
871 assert!(state.busy);
872 assert!(state.active_cancel.is_some());
873 assert_eq!(state.status, "finalizing");
874 }
875}