Skip to main content

lucy/
tui.rs

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                // A turn owns the input line. ESC and Ctrl-C are the only
201                // controls accepted while provider/tool work is active.
202                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    // Ratatui shows the cursor when a frame requests a position and hides it
633    // when this branch is skipped, which provides the blink phase.
634    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}