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, 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);
37/// Maximum number of wrapped input rows the input box grows to before it
38/// stops expanding and scrolls its contents internally.
39const 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    // Kitty keyboard protocol makes Shift+Enter (and other modified keys)
73    // distinguishable from plain Enter. Only push it on terminals known to
74    // support it; otherwise the enhancement sequence would leak as literal
75    // text on screen.
76    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                // A turn owns the input line. ESC and Ctrl-C are the only
217                // controls accepted while provider/tool work is active.
218                continue;
219            }
220            match key.code {
221                KeyCode::Enter => {
222                    // Shift+Enter (and Alt+Enter fallback) insert a literal
223                    // newline so the user can write multi-line prompts. Plain
224                    // Enter sends the turn. Many terminals cannot distinguish
225                    // Shift+Enter from Enter, so Alt+Enter is also accepted.
226                    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
374/// Heuristic for terminals that implement the kitty keyboard protocol.
375/// `PushKeyboardEnhancementFlags` is a no-op on supported terminals, but on
376/// unsupported ones the CSI sequence can render as literal text, so it is only
377/// enabled when the terminal advertises support via `TERM`/`TERM_PROGRAM`.
378fn 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
632/// Number of wrapped rows the current input prompt occupies at `width`,
633/// including the leading `> ` marker on the first row.
634fn 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    // Ratatui shows the cursor when a frame requests a position and hides it
696    // when this branch is skipped, which provides the blink phase.
697    if state.cursor_visible() && !input_area.is_empty() && visible > 0 {
698        // The cursor sits at the end of the input. The last visible row is the
699        // row that holds the cursor when the input is scrolled to the bottom.
700        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    // Track the previous rendered item so a ToolResult can be folded onto the
716    // same logical line as its preceding ToolCall instead of producing a
717    // separate transcript block.
718    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            // One blank line between every pair of transcript items so user
724            // messages, assistant messages, and tool calls/results stay
725            // visually separated. A ToolResult that directly follows a
726            // ToolCall is rendered on the same line and skips the separator.
727            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
790/// Render tool call arguments as the command string inside double quotes, for
791/// example `"cat README.md"`. Malformed arguments fall back to the raw text.
792fn 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
803/// Render a tool result as a single-line JSON-string-array literal containing
804/// stdout (or stderr when stdout is empty). Newlines are escaped so the whole
805/// result stays on one line. Output is truncated to `RESULT_PREVIEW_CHARS`.
806fn 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    // Build a JSON string literal so newlines and quotes are escaped and the
812    // result renders on a single line as `["..."]`.
813    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
858/// Push a logical line built from styled segments. When the rendered width
859/// exceeds `width`, the whole line is character-wrapped; wrapped continuations
860/// keep the style of the segment they fall on.
861fn 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
888/// Wrap `text` into rows no wider than `width` display columns. Wrapping is
889/// character-based so the row count matches exactly what a non-wrapping
890/// `Paragraph` renderer draws, which keeps auto-scroll pinned to the true
891/// bottom of the transcript regardless of terminal width. Empty lines are
892/// preserved as empty rows.
893fn 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        // The raw JSON arguments must not appear verbatim.
1120        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        // Call and result share one logical line (no blank line between them).
1156        assert_eq!(lines.len(), 1);
1157        let text = lines[0].to_string();
1158        assert!(text.starts_with("[tool:cmd \"cat README.md\"] > ["));
1159        // stdout is truncated to 50 chars plus the ellipsis.
1160        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        // Call text is split per-character into Magenta spans; the separator
1227        // " > " is a single default-color span; the result is one DarkGray span.
1228        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        // width 5: "> abcdefghij" wraps to "> abc", "defg", "hij".
1241        let rows = input_visible_rows(&state, 5);
1242        assert!(rows >= 3);
1243    }
1244}