vtcode_core/ui/
ratatui.rs

1use crate::ui::slash::{SlashCommandInfo, suggestions_for};
2use crate::ui::theme;
3use ansi_to_tui::IntoText;
4use anstyle::{AnsiColor, Color as AnsiColorEnum, Effects, Style as AnsiStyle};
5use anyhow::{Context, Result};
6use crossterm::{
7    ExecutableCommand, cursor,
8    event::{
9        DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, EventStream, KeyCode,
10        KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
11    },
12    terminal::{Clear, ClearType, disable_raw_mode, enable_raw_mode},
13};
14use futures::StreamExt;
15use ratatui::{
16    Frame, Terminal, TerminalOptions, Viewport,
17    backend::CrosstermBackend,
18    layout::{Alignment, Constraint, Direction, Layout, Rect},
19    style::{Color, Modifier, Style},
20    text::{Line, Span, Text},
21    widgets::{
22        Block, Borders, Clear as ClearWidget, List, ListItem, ListState, Paragraph, Scrollbar,
23        ScrollbarOrientation, ScrollbarState, Wrap,
24    },
25};
26use serde::de::value::{Error as DeValueError, StrDeserializer};
27use serde_json::Value;
28use std::cmp;
29use std::collections::VecDeque;
30use std::io;
31use std::mem;
32use std::time::{Duration, Instant};
33use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
34use tokio::time::{Interval, MissedTickBehavior, interval};
35use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
36
37const ESCAPE_DOUBLE_MS: u64 = 750;
38const REDRAW_INTERVAL_MS: u64 = 33;
39const MESSAGE_INDENT: usize = 2;
40const NAVIGATION_HINT_TEXT: &str = "↵ send · esc exit";
41const MAX_SLASH_SUGGESTIONS: usize = 6;
42
43#[derive(Clone, Default, PartialEq)]
44pub struct RatatuiTextStyle {
45    pub color: Option<Color>,
46    pub bold: bool,
47    pub italic: bool,
48}
49
50impl RatatuiTextStyle {
51    pub fn merge_color(mut self, fallback: Option<Color>) -> Self {
52        if self.color.is_none() {
53            self.color = fallback;
54        }
55        self
56    }
57
58    fn to_style(&self, fallback: Option<Color>) -> Style {
59        let mut style = Style::default();
60        if let Some(color) = self.color.or(fallback) {
61            style = style.fg(color);
62        }
63        if self.bold {
64            style = style.add_modifier(Modifier::BOLD);
65        }
66        if self.italic {
67            style = style.add_modifier(Modifier::ITALIC);
68        }
69        style
70    }
71}
72
73#[derive(Clone, Default)]
74pub struct RatatuiSegment {
75    pub text: String,
76    pub style: RatatuiTextStyle,
77}
78
79#[derive(Clone, Default)]
80struct StyledLine {
81    segments: Vec<RatatuiSegment>,
82}
83
84impl StyledLine {
85    fn push_segment(&mut self, segment: RatatuiSegment) {
86        if segment.text.is_empty() {
87            return;
88        }
89        self.segments.push(segment);
90    }
91
92    fn has_visible_content(&self) -> bool {
93        self.segments
94            .iter()
95            .any(|segment| segment.text.chars().any(|ch| !ch.is_whitespace()))
96    }
97}
98
99#[derive(Clone)]
100pub struct RatatuiTheme {
101    pub background: Option<Color>,
102    pub foreground: Option<Color>,
103    pub primary: Option<Color>,
104    pub secondary: Option<Color>,
105}
106
107impl Default for RatatuiTheme {
108    fn default() -> Self {
109        Self {
110            background: None,
111            foreground: None,
112            primary: None,
113            secondary: None,
114        }
115    }
116}
117
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub enum RatatuiMessageKind {
120    Agent,
121    Error,
122    Info,
123    Policy,
124    Pty,
125    Tool,
126    User,
127}
128
129pub enum RatatuiCommand {
130    AppendLine {
131        kind: RatatuiMessageKind,
132        segments: Vec<RatatuiSegment>,
133    },
134    Inline {
135        kind: RatatuiMessageKind,
136        segment: RatatuiSegment,
137    },
138    ReplaceLast {
139        count: usize,
140        kind: RatatuiMessageKind,
141        lines: Vec<Vec<RatatuiSegment>>,
142    },
143    SetPrompt {
144        prefix: String,
145        style: RatatuiTextStyle,
146    },
147    SetPlaceholder {
148        hint: Option<String>,
149        style: Option<RatatuiTextStyle>,
150    },
151    SetTheme {
152        theme: RatatuiTheme,
153    },
154    UpdateStatusBar {
155        left: Option<String>,
156        center: Option<String>,
157        right: Option<String>,
158    },
159    SetCursorVisible(bool),
160    SetInputEnabled(bool),
161    Shutdown,
162}
163
164#[derive(Debug, Clone)]
165pub enum RatatuiEvent {
166    Submit(String),
167    Cancel,
168    Exit,
169    Interrupt,
170    ScrollLineUp,
171    ScrollLineDown,
172    ScrollPageUp,
173    ScrollPageDown,
174}
175
176#[derive(Clone)]
177pub struct RatatuiHandle {
178    sender: UnboundedSender<RatatuiCommand>,
179}
180
181impl RatatuiHandle {
182    pub fn append_line(&self, kind: RatatuiMessageKind, segments: Vec<RatatuiSegment>) {
183        if segments.is_empty() {
184            let _ = self.sender.send(RatatuiCommand::AppendLine {
185                kind,
186                segments: vec![RatatuiSegment::default()],
187            });
188        } else {
189            let _ = self
190                .sender
191                .send(RatatuiCommand::AppendLine { kind, segments });
192        }
193    }
194
195    pub fn inline(&self, kind: RatatuiMessageKind, segment: RatatuiSegment) {
196        let _ = self.sender.send(RatatuiCommand::Inline { kind, segment });
197    }
198
199    pub fn replace_last(
200        &self,
201        count: usize,
202        kind: RatatuiMessageKind,
203        lines: Vec<Vec<RatatuiSegment>>,
204    ) {
205        let _ = self
206            .sender
207            .send(RatatuiCommand::ReplaceLast { count, kind, lines });
208    }
209
210    pub fn set_prompt(&self, prefix: String, style: RatatuiTextStyle) {
211        let _ = self
212            .sender
213            .send(RatatuiCommand::SetPrompt { prefix, style });
214    }
215
216    pub fn set_placeholder(&self, hint: Option<String>) {
217        self.set_placeholder_with_style(hint, None);
218    }
219
220    pub fn set_placeholder_with_style(
221        &self,
222        hint: Option<String>,
223        style: Option<RatatuiTextStyle>,
224    ) {
225        let _ = self
226            .sender
227            .send(RatatuiCommand::SetPlaceholder { hint, style });
228    }
229
230    pub fn set_theme(&self, theme: RatatuiTheme) {
231        let _ = self.sender.send(RatatuiCommand::SetTheme { theme });
232    }
233
234    pub fn update_status_bar(
235        &self,
236        left: Option<String>,
237        center: Option<String>,
238        right: Option<String>,
239    ) {
240        let _ = self.sender.send(RatatuiCommand::UpdateStatusBar {
241            left,
242            center,
243            right,
244        });
245    }
246
247    pub fn set_cursor_visible(&self, visible: bool) {
248        let _ = self.sender.send(RatatuiCommand::SetCursorVisible(visible));
249    }
250
251    pub fn set_input_enabled(&self, enabled: bool) {
252        let _ = self.sender.send(RatatuiCommand::SetInputEnabled(enabled));
253    }
254
255    pub fn shutdown(&self) {
256        let _ = self.sender.send(RatatuiCommand::Shutdown);
257    }
258}
259
260pub struct RatatuiSession {
261    pub handle: RatatuiHandle,
262    pub events: UnboundedReceiver<RatatuiEvent>,
263}
264
265pub fn spawn_session(theme: RatatuiTheme, placeholder: Option<String>) -> Result<RatatuiSession> {
266    let (command_tx, command_rx) = mpsc::unbounded_channel();
267    let (event_tx, event_rx) = mpsc::unbounded_channel();
268
269    tokio::spawn(async move {
270        if let Err(err) = run_ratatui(command_rx, event_tx, theme, placeholder).await {
271            tracing::error!(error = ?err, "ratatui session terminated unexpectedly");
272        }
273    });
274
275    Ok(RatatuiSession {
276        handle: RatatuiHandle { sender: command_tx },
277        events: event_rx,
278    })
279}
280
281async fn run_ratatui(
282    commands: UnboundedReceiver<RatatuiCommand>,
283    events: UnboundedSender<RatatuiEvent>,
284    theme: RatatuiTheme,
285    placeholder: Option<String>,
286) -> Result<()> {
287    let mut stdout = io::stdout();
288    let backend = CrosstermBackend::new(&mut stdout);
289    let (_, rows) = crossterm::terminal::size().context("failed to query terminal size")?;
290    let options = TerminalOptions {
291        viewport: Viewport::Inline(rows),
292    };
293    let mut terminal = Terminal::with_options(backend, options)
294        .context("failed to initialize ratatui terminal")?;
295    let _guard = TerminalGuard::new().context("failed to configure terminal for ratatui")?;
296    terminal
297        .clear()
298        .context("failed to clear terminal for ratatui")?;
299
300    let mut app = RatatuiLoop::new(theme, placeholder);
301    let mut command_rx = commands;
302    let mut event_stream = EventStream::new();
303    let mut redraw = true;
304    let mut ticker = create_ticker();
305
306    loop {
307        if redraw {
308            terminal
309                .draw(|frame| app.draw(frame))
310                .context("failed to draw ratatui frame")?;
311            redraw = false;
312        }
313
314        tokio::select! {
315            Some(cmd) = command_rx.recv() => {
316                if app.handle_command(cmd) {
317                    redraw = true;
318                }
319                if app.should_exit() {
320                    break;
321                }
322            }
323            event = event_stream.next() => {
324                match event {
325                    Some(Ok(evt)) => {
326                        if matches!(evt, CrosstermEvent::Resize(_, _)) {
327                            terminal
328                                .autoresize()
329                                .context("failed to autoresize terminal viewport")?;
330                        }
331                        if app.handle_event(evt, &events)? {
332                            redraw = true;
333                        }
334                        if app.should_exit() {
335                            break;
336                        }
337                    }
338                    Some(Err(_)) => {
339                        redraw = true;
340                    }
341                    None => {}
342                }
343            }
344            _ = ticker.tick() => {
345                if app.needs_tick() {
346                    redraw = true;
347                }
348            }
349        }
350
351        if app.should_exit() {
352            break;
353        }
354    }
355
356    terminal.show_cursor().ok();
357    terminal
358        .clear()
359        .context("failed to clear terminal after ratatui session")?;
360
361    Ok(())
362}
363
364struct TerminalGuard {
365    mouse_capture_enabled: bool,
366    cursor_hidden: bool,
367}
368
369impl TerminalGuard {
370    fn new() -> Result<Self> {
371        enable_raw_mode().context("failed to enable raw mode")?;
372        let mut stdout = io::stdout();
373        stdout
374            .execute(EnableMouseCapture)
375            .context("failed to enable mouse capture")?;
376        stdout
377            .execute(cursor::Hide)
378            .context("failed to hide cursor")?;
379        Ok(Self {
380            mouse_capture_enabled: true,
381            cursor_hidden: true,
382        })
383    }
384}
385
386impl Drop for TerminalGuard {
387    fn drop(&mut self) {
388        let _ = disable_raw_mode();
389        let mut stdout = io::stdout();
390        if self.mouse_capture_enabled {
391            let _ = stdout.execute(DisableMouseCapture);
392        }
393        if self.cursor_hidden {
394            let _ = stdout.execute(cursor::Show);
395        }
396        let _ = stdout.execute(Clear(ClearType::FromCursorDown));
397    }
398}
399
400#[derive(Default)]
401struct InputState {
402    value: String,
403    cursor: usize,
404}
405
406impl InputState {
407    fn clear(&mut self) {
408        self.value.clear();
409        self.cursor = 0;
410    }
411
412    fn insert(&mut self, ch: char) {
413        self.value.insert(self.cursor, ch);
414        self.cursor += ch.len_utf8();
415    }
416
417    fn backspace(&mut self) {
418        if self.cursor == 0 {
419            return;
420        }
421        let new_cursor = self.value[..self.cursor]
422            .chars()
423            .next_back()
424            .map(|ch| self.cursor - ch.len_utf8())
425            .unwrap_or(0);
426        self.value.replace_range(new_cursor..self.cursor, "");
427        self.cursor = new_cursor;
428    }
429
430    fn delete(&mut self) {
431        if self.cursor >= self.value.len() {
432            return;
433        }
434        let len = self.value[self.cursor..]
435            .chars()
436            .next()
437            .map(|ch| ch.len_utf8())
438            .unwrap_or(0);
439        let end = self.cursor + len;
440        self.value.replace_range(self.cursor..end, "");
441    }
442
443    fn move_left(&mut self) {
444        if self.cursor == 0 {
445            return;
446        }
447        let new_cursor = self.value[..self.cursor]
448            .chars()
449            .next_back()
450            .map(|ch| self.cursor - ch.len_utf8())
451            .unwrap_or(0);
452        self.cursor = new_cursor;
453    }
454
455    fn move_right(&mut self) {
456        if self.cursor >= self.value.len() {
457            return;
458        }
459        let advance = self.value[self.cursor..]
460            .chars()
461            .next()
462            .map(|ch| ch.len_utf8())
463            .unwrap_or(0);
464        self.cursor += advance;
465    }
466
467    fn move_home(&mut self) {
468        self.cursor = 0;
469    }
470
471    fn move_end(&mut self) {
472        self.cursor = self.value.len();
473    }
474
475    fn take(&mut self) -> String {
476        let mut result = String::new();
477        mem::swap(&mut result, &mut self.value);
478        self.cursor = 0;
479        result
480    }
481
482    fn value(&self) -> &str {
483        &self.value
484    }
485
486    fn width_before_cursor(&self) -> usize {
487        UnicodeWidthStr::width(&self.value[..self.cursor])
488    }
489}
490
491#[derive(Default)]
492struct TranscriptScrollState {
493    offset: usize,
494    viewport_height: usize,
495    content_height: usize,
496}
497
498impl TranscriptScrollState {
499    fn offset(&self) -> usize {
500        self.offset
501    }
502
503    fn update_bounds(&mut self, content_height: usize, viewport_height: usize) {
504        self.content_height = content_height;
505        self.viewport_height = viewport_height;
506        let max_offset = self.max_offset();
507        if self.offset > max_offset {
508            self.offset = max_offset;
509        }
510    }
511
512    fn scroll_to_bottom(&mut self) {
513        self.offset = self.max_offset();
514    }
515
516    fn scroll_up(&mut self) {
517        if self.offset > 0 {
518            self.offset -= 1;
519        }
520    }
521
522    fn scroll_down(&mut self) {
523        let max_offset = self.max_offset();
524        if self.offset < max_offset {
525            self.offset += 1;
526        }
527    }
528
529    fn scroll_page_up(&mut self) {
530        if self.offset == 0 {
531            return;
532        }
533        let step = self.viewport_height.max(1);
534        self.offset = self.offset.saturating_sub(step);
535    }
536
537    fn scroll_page_down(&mut self) {
538        let max_offset = self.max_offset();
539        if self.offset >= max_offset {
540            return;
541        }
542        let step = self.viewport_height.max(1);
543        self.offset = (self.offset + step).min(max_offset);
544    }
545
546    fn is_at_bottom(&self) -> bool {
547        self.offset >= self.max_offset()
548    }
549
550    fn should_follow_new_content(&self) -> bool {
551        self.viewport_height == 0 || self.is_at_bottom()
552    }
553
554    fn max_offset(&self) -> usize {
555        if self.content_height <= self.viewport_height {
556            0
557        } else {
558            self.content_height - self.viewport_height
559        }
560    }
561
562    fn content_height(&self) -> usize {
563        self.content_height
564    }
565
566    fn viewport_height(&self) -> usize {
567        self.viewport_height
568    }
569
570    fn has_overflow(&self) -> bool {
571        self.content_height > self.viewport_height
572    }
573}
574
575#[derive(Clone, Copy, Debug, PartialEq, Eq)]
576enum ScrollFocus {
577    Transcript,
578    Pty,
579}
580
581#[derive(Clone)]
582struct MessageBlock {
583    kind: RatatuiMessageKind,
584    lines: Vec<StyledLine>,
585}
586
587#[derive(Clone, Default)]
588struct StatusBarContent {
589    left: String,
590    center: String,
591    right: String,
592}
593
594impl StatusBarContent {
595    fn new() -> Self {
596        Self {
597            left: "? help · / command".to_string(),
598            center: String::new(),
599            right: NAVIGATION_HINT_TEXT.to_string(),
600        }
601    }
602
603    fn update(&mut self, left: Option<String>, center: Option<String>, right: Option<String>) {
604        if let Some(value) = left {
605            self.left = value;
606        }
607        if let Some(value) = center {
608            self.center = value;
609        }
610        if let Some(value) = right {
611            self.right = value;
612        }
613    }
614}
615
616#[derive(Clone, Copy)]
617struct PtyPlacement {
618    top: usize,
619    height: usize,
620    indent: usize,
621}
622
623#[derive(Default, Clone)]
624struct SelectionState {
625    start: Option<usize>,
626    end: Option<usize>,
627    dragging: bool,
628}
629
630impl SelectionState {
631    fn clear(&mut self) {
632        self.start = None;
633        self.end = None;
634        self.dragging = false;
635    }
636
637    fn begin(&mut self, line: usize) {
638        self.start = Some(line);
639        self.end = Some(line);
640        self.dragging = true;
641    }
642
643    fn update(&mut self, line: usize) {
644        if self.start.is_some() {
645            self.end = Some(line);
646        }
647    }
648
649    fn finish(&mut self) {
650        self.dragging = false;
651    }
652
653    fn is_active(&self) -> bool {
654        self.start.is_some()
655    }
656
657    fn is_dragging(&self) -> bool {
658        self.dragging
659    }
660
661    fn range(&self) -> Option<(usize, usize)> {
662        let start = self.start?;
663        let end = self.end?;
664        if start <= end {
665            Some((start, end))
666        } else {
667            Some((end, start))
668        }
669    }
670}
671
672#[derive(Default)]
673struct SlashSuggestionState {
674    items: Vec<&'static SlashCommandInfo>,
675    list_state: ListState,
676}
677
678impl SlashSuggestionState {
679    fn clear(&mut self) {
680        self.items.clear();
681        self.list_state.select(None);
682    }
683
684    fn update(&mut self, query: &str) {
685        self.items = suggestions_for(query);
686        if self.items.is_empty() {
687            self.list_state.select(None);
688        } else {
689            self.list_state.select(Some(0));
690        }
691    }
692
693    fn is_visible(&self) -> bool {
694        !self.items.is_empty()
695    }
696
697    fn visible_capacity(&self) -> usize {
698        self.items.len().min(MAX_SLASH_SUGGESTIONS)
699    }
700
701    fn desired_height(&self) -> u16 {
702        if !self.is_visible() {
703            return 0;
704        }
705        self.visible_capacity() as u16 + 2
706    }
707
708    fn visible_height(&self, available: u16) -> u16 {
709        if available < 3 || !self.is_visible() {
710            return 0;
711        }
712        self.desired_height().min(available)
713    }
714
715    fn items(&self) -> &[&'static SlashCommandInfo] {
716        &self.items
717    }
718
719    fn list_state(&mut self) -> &mut ListState {
720        &mut self.list_state
721    }
722
723    fn selected_index(&self) -> Option<usize> {
724        self.list_state.selected()
725    }
726
727    fn select_previous(&mut self) -> bool {
728        if self.items.is_empty() {
729            return false;
730        }
731        let current = self.list_state.selected().unwrap_or(0);
732        let len = self.items.len();
733        let next = if current == 0 {
734            len.saturating_sub(1)
735        } else {
736            current.saturating_sub(1)
737        };
738        if len == 0 {
739            self.list_state.select(None);
740            return false;
741        }
742        if current != next {
743            self.list_state.select(Some(next));
744        } else {
745            self.list_state.select(Some(next));
746        }
747        true
748    }
749
750    fn select_next(&mut self) -> bool {
751        if self.items.is_empty() {
752            return false;
753        }
754        let len = self.items.len();
755        let current = self.list_state.selected().unwrap_or(0);
756        let next = if current + 1 >= len { 0 } else { current + 1 };
757        self.list_state.select(Some(next));
758        true
759    }
760
761    fn selected(&self) -> Option<&'static SlashCommandInfo> {
762        let index = self.list_state.selected()?;
763        self.items.get(index).copied()
764    }
765}
766
767const PTY_MAX_LINES: usize = 200;
768const PTY_PANEL_MAX_HEIGHT: usize = 10;
769const PTY_CONTENT_VIEW_LINES: usize = PTY_PANEL_MAX_HEIGHT - 2;
770
771struct PtyPanel {
772    tool_name: Option<String>,
773    command_display: Option<String>,
774    lines: VecDeque<String>,
775    trailing: String,
776    cached: Text<'static>,
777    dirty: bool,
778    cached_height: usize,
779}
780
781impl PtyPanel {
782    fn new() -> Self {
783        Self {
784            tool_name: None,
785            command_display: None,
786            lines: VecDeque::with_capacity(PTY_MAX_LINES),
787            trailing: String::new(),
788            cached: Text::default(),
789            dirty: true,
790            cached_height: 0,
791        }
792    }
793
794    fn reset_output(&mut self) {
795        self.lines.clear();
796        self.trailing.clear();
797        self.cached = Text::default();
798        self.dirty = true;
799        self.cached_height = 0;
800    }
801
802    fn clear(&mut self) {
803        self.tool_name = None;
804        self.command_display = None;
805        self.reset_output();
806    }
807
808    fn set_tool_call(&mut self, tool_name: String, command_display: Option<String>) {
809        self.tool_name = Some(tool_name);
810        self.command_display = command_display;
811        self.reset_output();
812    }
813
814    fn push_line(&mut self, text: &str) {
815        self.push_text(text, true);
816    }
817
818    fn push_inline(&mut self, text: &str) {
819        self.push_text(text, false);
820    }
821
822    fn push_text(&mut self, text: &str, newline: bool) {
823        if text.is_empty() {
824            if newline {
825                self.commit_line();
826            }
827            return;
828        }
829
830        let mut remaining = text;
831        while let Some(index) = remaining.find('\n') {
832            let (segment, rest) = remaining.split_at(index);
833            self.trailing.push_str(segment);
834            self.commit_line();
835            remaining = rest.get(1..).unwrap_or("");
836        }
837
838        if !remaining.is_empty() {
839            self.trailing.push_str(remaining);
840        }
841
842        if newline {
843            if !self.trailing.is_empty() || text.is_empty() {
844                self.commit_line();
845            }
846        }
847
848        self.dirty = true;
849    }
850
851    fn commit_line(&mut self) {
852        let line = mem::take(&mut self.trailing);
853        self.lines.push_back(line);
854        if self.lines.len() > PTY_MAX_LINES {
855            self.lines.pop_front();
856        }
857    }
858
859    fn has_content(&self) -> bool {
860        self.tool_name.is_some()
861            || self.command_display.is_some()
862            || !self.lines.is_empty()
863            || !self.trailing.is_empty()
864    }
865
866    fn command_summary(&self) -> Option<String> {
867        let command = self.command_display.as_ref()?;
868        let trimmed = command.trim();
869        if trimmed.is_empty() {
870            return None;
871        }
872        const MAX_CHARS: usize = 48;
873        let mut summary = String::new();
874        for (index, ch) in trimmed.chars().enumerate() {
875            if index >= MAX_CHARS - 1 {
876                summary.push('…');
877                break;
878            }
879            summary.push(ch);
880        }
881        if summary.is_empty() {
882            Some(trimmed.to_string())
883        } else if summary.ends_with('…') {
884            Some(summary)
885        } else if trimmed.chars().count() > summary.chars().count() {
886            let mut truncated = summary;
887            truncated.push('…');
888            Some(truncated)
889        } else {
890            Some(summary)
891        }
892    }
893
894    fn block_title_text(&self) -> String {
895        let base = self.tool_name.as_deref().unwrap_or("terminal").to_string();
896        if let Some(summary) = self.command_summary() {
897            if summary.is_empty() {
898                base
899            } else {
900                format!("{base} · {summary}")
901            }
902        } else {
903            base
904        }
905    }
906
907    fn view_text(&mut self) -> Text<'static> {
908        if !self.dirty {
909            return self.cached.clone();
910        }
911
912        let mut lines = Vec::new();
913        if let Some(command) = self.command_display.as_ref() {
914            if !command.is_empty() {
915                lines.push(format!("$ {}", command));
916            }
917        }
918        for entry in &self.lines {
919            lines.push(entry.clone());
920        }
921        if !self.trailing.is_empty() {
922            lines.push(self.trailing.clone());
923        }
924
925        let combined = if lines.is_empty() {
926            String::new()
927        } else {
928            lines.join("\n")
929        };
930
931        let parsed = if combined.is_empty() {
932            Text::default()
933        } else {
934            combined
935                .clone()
936                .into_text()
937                .unwrap_or_else(|_| Text::from(combined.clone()))
938        };
939
940        self.cached = parsed.clone();
941        self.dirty = false;
942        self.cached_height = self.cached.height();
943        parsed
944    }
945}
946
947struct TranscriptDisplay {
948    lines: Vec<Line<'static>>,
949    total_height: usize,
950}
951
952struct InputDisplay {
953    lines: Vec<Line<'static>>,
954    cursor: Option<(u16, u16)>,
955    height: u16,
956}
957
958struct InputLayout {
959    block_area: Rect,
960    suggestion_area: Option<Rect>,
961    display: InputDisplay,
962}
963
964struct RatatuiLoop {
965    messages: Vec<MessageBlock>,
966    current_line: StyledLine,
967    current_kind: Option<RatatuiMessageKind>,
968    current_active: bool,
969    prompt_prefix: String,
970    prompt_style: RatatuiTextStyle,
971    input: InputState,
972    base_placeholder: Option<String>,
973    placeholder_hint: Option<String>,
974    show_placeholder: bool,
975    base_placeholder_style: RatatuiTextStyle,
976    placeholder_style: RatatuiTextStyle,
977    should_exit: bool,
978    theme: RatatuiTheme,
979    last_escape: Option<Instant>,
980    transcript_scroll: TranscriptScrollState,
981    transcript_autoscroll: bool,
982    pty_scroll: TranscriptScrollState,
983    pty_autoscroll: bool,
984    scroll_focus: ScrollFocus,
985    transcript_area: Option<Rect>,
986    pty_area: Option<Rect>,
987    pty_block: Option<PtyPlacement>,
988    slash_suggestions: SlashSuggestionState,
989    pty_panel: Option<PtyPanel>,
990    status_bar: StatusBarContent,
991    cursor_visible: bool,
992    input_enabled: bool,
993    selection: SelectionState,
994}
995
996impl RatatuiLoop {
997    fn default_placeholder_style(theme: &RatatuiTheme) -> RatatuiTextStyle {
998        let mut style = RatatuiTextStyle::default();
999        style.italic = true;
1000        style.color = theme
1001            .secondary
1002            .or(theme.foreground)
1003            .or(Some(Color::DarkGray));
1004        style
1005    }
1006
1007    fn new(theme: RatatuiTheme, placeholder: Option<String>) -> Self {
1008        let sanitized_placeholder = placeholder
1009            .map(|hint| hint.trim().to_string())
1010            .filter(|hint| !hint.is_empty());
1011        let base_placeholder = sanitized_placeholder.clone();
1012        let show_placeholder = base_placeholder.is_some();
1013        let base_placeholder_style = Self::default_placeholder_style(&theme);
1014        Self {
1015            messages: Vec::new(),
1016            current_line: StyledLine::default(),
1017            current_kind: None,
1018            current_active: false,
1019            prompt_prefix: "❯ ".to_string(),
1020            prompt_style: RatatuiTextStyle::default(),
1021            input: InputState::default(),
1022            base_placeholder: base_placeholder.clone(),
1023            placeholder_hint: base_placeholder.clone(),
1024            show_placeholder,
1025            base_placeholder_style: base_placeholder_style.clone(),
1026            placeholder_style: base_placeholder_style,
1027            should_exit: false,
1028            theme,
1029            last_escape: None,
1030            transcript_scroll: TranscriptScrollState::default(),
1031            transcript_autoscroll: true,
1032            pty_scroll: TranscriptScrollState::default(),
1033            pty_autoscroll: true,
1034            scroll_focus: ScrollFocus::Transcript,
1035            transcript_area: None,
1036            pty_area: None,
1037            pty_block: None,
1038            slash_suggestions: SlashSuggestionState::default(),
1039            pty_panel: None,
1040            status_bar: StatusBarContent::new(),
1041            cursor_visible: true,
1042            input_enabled: true,
1043            selection: SelectionState::default(),
1044        }
1045    }
1046
1047    fn should_exit(&self) -> bool {
1048        self.should_exit
1049    }
1050
1051    fn needs_tick(&self) -> bool {
1052        false
1053    }
1054
1055    fn handle_command(&mut self, command: RatatuiCommand) -> bool {
1056        match command {
1057            RatatuiCommand::AppendLine { kind, segments } => {
1058                let follow_output = self.transcript_scroll.should_follow_new_content();
1059                let plain = Self::collect_plain_text(&segments);
1060                self.track_pty_metadata(kind, &plain);
1061                let was_active = self.current_active;
1062                self.flush_current_line(was_active);
1063                self.push_line(kind, StyledLine { segments });
1064                self.forward_pty_line(kind, &plain);
1065                if follow_output {
1066                    self.transcript_autoscroll = true;
1067                }
1068                true
1069            }
1070            RatatuiCommand::Inline { kind, segment } => {
1071                let follow_output = self.transcript_scroll.should_follow_new_content();
1072                let plain = segment.text.clone();
1073                self.forward_pty_inline(kind, &plain);
1074                self.append_inline_segment(kind, segment);
1075                if follow_output {
1076                    self.transcript_autoscroll = true;
1077                }
1078                true
1079            }
1080            RatatuiCommand::ReplaceLast { count, kind, lines } => {
1081                let follow_output = self.transcript_scroll.should_follow_new_content();
1082                let follow_pty = self.pty_scroll.should_follow_new_content();
1083                let was_active = self.current_active;
1084                self.flush_current_line(was_active);
1085                if kind == RatatuiMessageKind::Pty {
1086                    if let Some(panel) = self.pty_panel.as_mut() {
1087                        panel.reset_output();
1088                        for segments in &lines {
1089                            let plain = Self::collect_plain_text(segments);
1090                            panel.push_line(&plain);
1091                        }
1092                    }
1093                    if follow_pty {
1094                        self.pty_autoscroll = true;
1095                    }
1096                } else if kind == RatatuiMessageKind::Tool {
1097                    if let Some(first_line) = lines.first() {
1098                        let plain = Self::collect_plain_text(first_line);
1099                        self.track_pty_metadata(kind, &plain);
1100                    }
1101                }
1102                self.remove_last_lines(count);
1103                for segments in lines {
1104                    self.push_line(kind, StyledLine { segments });
1105                }
1106                if follow_output {
1107                    self.transcript_autoscroll = true;
1108                }
1109                true
1110            }
1111            RatatuiCommand::SetPrompt { prefix, style } => {
1112                self.prompt_prefix = prefix;
1113                self.prompt_style = style;
1114                true
1115            }
1116            RatatuiCommand::SetPlaceholder { hint, style } => {
1117                let resolved = hint.or_else(|| self.base_placeholder.clone());
1118                self.placeholder_hint = resolved;
1119                if let Some(new_style) = style {
1120                    self.placeholder_style = new_style;
1121                } else {
1122                    self.placeholder_style = self.base_placeholder_style.clone();
1123                }
1124                self.update_input_state();
1125                true
1126            }
1127            RatatuiCommand::SetTheme { theme } => {
1128                let previous_base = self.base_placeholder_style.clone();
1129                self.theme = theme;
1130                let new_base = Self::default_placeholder_style(&self.theme);
1131                self.base_placeholder_style = new_base.clone();
1132                if self.placeholder_style == previous_base {
1133                    self.placeholder_style = new_base;
1134                }
1135                true
1136            }
1137            RatatuiCommand::UpdateStatusBar {
1138                left,
1139                center,
1140                right,
1141            } => {
1142                self.status_bar.update(left, center, right);
1143                true
1144            }
1145            RatatuiCommand::SetCursorVisible(visible) => {
1146                self.cursor_visible = visible;
1147                true
1148            }
1149            RatatuiCommand::SetInputEnabled(enabled) => {
1150                self.input_enabled = enabled;
1151                if !enabled {
1152                    self.slash_suggestions.clear();
1153                } else {
1154                    self.update_input_state();
1155                }
1156                true
1157            }
1158            RatatuiCommand::Shutdown => {
1159                self.should_exit = true;
1160                true
1161            }
1162        }
1163    }
1164
1165    fn collect_plain_text(segments: &[RatatuiSegment]) -> String {
1166        segments
1167            .iter()
1168            .map(|segment| segment.text.as_str())
1169            .collect::<String>()
1170    }
1171
1172    fn append_inline_segment(&mut self, kind: RatatuiMessageKind, segment: RatatuiSegment) {
1173        let text = segment.text;
1174        let style = segment.style;
1175        if text.is_empty() {
1176            return;
1177        }
1178
1179        if self.current_kind != Some(kind) {
1180            if self.current_active {
1181                self.flush_current_line(true);
1182            }
1183            self.current_kind = Some(kind);
1184        }
1185
1186        let mut parts = text.split('\n').peekable();
1187        let ends_with_newline = text.ends_with('\n');
1188
1189        while let Some(part) = parts.next() {
1190            if !part.is_empty() {
1191                if self.current_kind != Some(kind) {
1192                    self.current_kind = Some(kind);
1193                }
1194                self.current_line.push_segment(RatatuiSegment {
1195                    text: part.to_string(),
1196                    style: style.clone(),
1197                });
1198                self.current_active = true;
1199            }
1200
1201            if parts.peek().is_some() {
1202                self.flush_current_line(true);
1203                self.current_kind = Some(kind);
1204            }
1205        }
1206
1207        if ends_with_newline {
1208            self.flush_current_line(true);
1209            self.current_kind = Some(kind);
1210        }
1211    }
1212
1213    fn flush_current_line(&mut self, force: bool) {
1214        if !force && !self.current_active {
1215            return;
1216        }
1217
1218        if let Some(kind) = self.current_kind {
1219            if !self.current_line.segments.is_empty() || force {
1220                let line = mem::take(&mut self.current_line);
1221                self.push_line(kind, line);
1222            } else {
1223                self.current_line = StyledLine::default();
1224            }
1225        }
1226
1227        self.current_line = StyledLine::default();
1228        self.current_active = false;
1229        self.current_kind = None;
1230    }
1231
1232    fn update_input_state(&mut self) {
1233        self.show_placeholder = self.placeholder_hint.is_some() && self.input.value().is_empty();
1234        self.refresh_slash_suggestions();
1235    }
1236
1237    fn refresh_slash_suggestions(&mut self) {
1238        if let Some(rest) = self.input.value().strip_prefix('/') {
1239            let trimmed = rest.trim_start();
1240            if trimmed.chars().any(char::is_whitespace) {
1241                self.slash_suggestions.clear();
1242                return;
1243            }
1244            let query = trimmed.trim_end();
1245            self.slash_suggestions.update(query);
1246        } else {
1247            self.slash_suggestions.clear();
1248        }
1249    }
1250
1251    fn set_input_text(&mut self, value: String) {
1252        if !self.input_enabled {
1253            return;
1254        }
1255        self.input.value = value;
1256        self.input.cursor = self.input.value.len();
1257        self.update_input_state();
1258        self.transcript_autoscroll = true;
1259    }
1260
1261    fn apply_selected_suggestion(&mut self) -> bool {
1262        if !self.input_enabled {
1263            return false;
1264        }
1265        let Some(selected) = self.slash_suggestions.selected() else {
1266            return false;
1267        };
1268        let raw = self.input.value().to_string();
1269        let remainder = raw
1270            .strip_prefix('/')
1271            .and_then(|rest| {
1272                rest.char_indices()
1273                    .find(|(_, ch)| ch.is_whitespace())
1274                    .map(|(idx, _)| rest[idx..].trim_start().to_string())
1275            })
1276            .unwrap_or_default();
1277
1278        let mut new_value = format!("/{}", selected.name);
1279        if remainder.is_empty() {
1280            new_value.push(' ');
1281        } else {
1282            new_value.push(' ');
1283            new_value.push_str(&remainder);
1284        }
1285        self.set_input_text(new_value);
1286        true
1287    }
1288
1289    fn push_line(&mut self, kind: RatatuiMessageKind, line: StyledLine) {
1290        if kind == RatatuiMessageKind::Agent && !line.has_visible_content() {
1291            return;
1292        }
1293        if let Some(block) = self.messages.last_mut() {
1294            if block.kind == kind {
1295                block.lines.push(line);
1296                return;
1297            }
1298        }
1299
1300        self.messages.push(MessageBlock {
1301            kind,
1302            lines: vec![line],
1303        });
1304    }
1305
1306    fn remove_last_lines(&mut self, mut count: usize) {
1307        while count > 0 {
1308            let Some(block) = self.messages.last_mut() else {
1309                break;
1310            };
1311
1312            if block.lines.len() <= count {
1313                count -= block.lines.len();
1314                self.messages.pop();
1315            } else {
1316                let new_len = block.lines.len() - count;
1317                block.lines.truncate(new_len);
1318                count = 0;
1319            }
1320        }
1321    }
1322
1323    fn ensure_pty_panel(&mut self) -> &mut PtyPanel {
1324        if self.pty_panel.is_none() {
1325            self.pty_panel = Some(PtyPanel::new());
1326        }
1327        self.pty_panel.as_mut().expect("pty_panel must exist")
1328    }
1329
1330    fn track_pty_metadata(&mut self, kind: RatatuiMessageKind, plain: &str) {
1331        if kind != RatatuiMessageKind::Tool {
1332            return;
1333        }
1334        let trimmed = plain.trim();
1335        if let Some(rest) = trimmed.strip_prefix("[TOOL]") {
1336            let mut parts = rest.trim_start().splitn(2, ' ');
1337            let tool_name = parts.next().map(str::trim).unwrap_or("");
1338            let payload = parts.next().map(str::trim).unwrap_or("");
1339            match tool_name {
1340                "run_terminal_cmd" => {
1341                    let command = Self::parse_run_command(payload);
1342                    let panel = self.ensure_pty_panel();
1343                    panel.set_tool_call(tool_name.to_string(), command);
1344                    self.pty_autoscroll = true;
1345                }
1346                "bash_command" => {
1347                    let command = Self::parse_bash_command(payload);
1348                    let panel = self.ensure_pty_panel();
1349                    panel.set_tool_call(tool_name.to_string(), command);
1350                    self.pty_autoscroll = true;
1351                }
1352                _ => {
1353                    if let Some(panel) = self.pty_panel.as_mut() {
1354                        panel.clear();
1355                    }
1356                }
1357            }
1358        }
1359    }
1360
1361    fn parse_run_command(json_segment: &str) -> Option<String> {
1362        let value: Value = serde_json::from_str(json_segment).ok()?;
1363        let array = value.get("command")?.as_array()?;
1364        let mut parts = Vec::with_capacity(array.len());
1365        for entry in array {
1366            if let Some(text) = entry.as_str() {
1367                parts.push(text.to_string());
1368            }
1369        }
1370        if parts.is_empty() {
1371            None
1372        } else {
1373            Some(parts.join(" "))
1374        }
1375    }
1376
1377    fn parse_bash_command(json_segment: &str) -> Option<String> {
1378        let value: Value = serde_json::from_str(json_segment).ok()?;
1379        if let Some(command) = value.get("bash_command").and_then(|val| val.as_str()) {
1380            let trimmed = command.trim();
1381            if trimmed.is_empty() {
1382                None
1383            } else {
1384                Some(trimmed.to_string())
1385            }
1386        } else if let Some(array) = value.get("command").and_then(|val| val.as_array()) {
1387            let mut parts = Vec::with_capacity(array.len());
1388            for entry in array {
1389                if let Some(text) = entry.as_str() {
1390                    parts.push(text.to_string());
1391                }
1392            }
1393            if parts.is_empty() {
1394                None
1395            } else {
1396                Some(parts.join(" "))
1397            }
1398        } else {
1399            None
1400        }
1401    }
1402
1403    fn forward_pty_line(&mut self, kind: RatatuiMessageKind, text: &str) {
1404        if kind != RatatuiMessageKind::Pty {
1405            return;
1406        }
1407        if let Some(panel) = self.pty_panel.as_mut() {
1408            let follow = self.pty_scroll.should_follow_new_content();
1409            panel.push_line(text);
1410            if follow {
1411                self.pty_autoscroll = true;
1412            }
1413        }
1414    }
1415
1416    fn forward_pty_inline(&mut self, kind: RatatuiMessageKind, text: &str) {
1417        if kind != RatatuiMessageKind::Pty {
1418            return;
1419        }
1420        if let Some(panel) = self.pty_panel.as_mut() {
1421            let follow = self.pty_scroll.should_follow_new_content();
1422            panel.push_inline(text);
1423            if follow {
1424                self.pty_autoscroll = true;
1425            }
1426        }
1427    }
1428
1429    fn render_slash_suggestions(&mut self, frame: &mut Frame, area: Rect) {
1430        if !self.slash_suggestions.is_visible() {
1431            return;
1432        }
1433        if area.width <= 2 || area.height < 3 {
1434            return;
1435        }
1436
1437        let capacity = cmp::min(
1438            MAX_SLASH_SUGGESTIONS,
1439            area.height.saturating_sub(2) as usize,
1440        );
1441        if capacity == 0 {
1442            return;
1443        }
1444
1445        let items: Vec<&SlashCommandInfo> = self
1446            .slash_suggestions
1447            .items()
1448            .iter()
1449            .take(capacity)
1450            .copied()
1451            .collect();
1452        if items.is_empty() {
1453            return;
1454        }
1455
1456        if let Some(selected) = self.slash_suggestions.selected_index() {
1457            if selected >= items.len() {
1458                let clamped = items.len().saturating_sub(1);
1459                self.slash_suggestions.list_state.select(Some(clamped));
1460            }
1461        }
1462
1463        let max_name_len = items.iter().map(|info| info.name.len()).max().unwrap_or(0);
1464        let entries: Vec<String> = items
1465            .iter()
1466            .map(|info| {
1467                let mut line = format!("/{:<width$}", info.name, width = max_name_len);
1468                line.push(' ');
1469                line.push_str(info.description);
1470                line
1471            })
1472            .collect();
1473
1474        let max_width = entries
1475            .iter()
1476            .map(|value| UnicodeWidthStr::width(value.as_str()))
1477            .max()
1478            .unwrap_or(0);
1479        let visible_height = entries.len().min(capacity) as u16 + 2;
1480        let height = visible_height.min(area.height);
1481        let required_width = cmp::max(4, cmp::min(area.width as usize, max_width + 4)) as u16;
1482        let suggestion_area = Rect::new(area.x, area.y, required_width, height);
1483        frame.render_widget(ClearWidget, suggestion_area);
1484
1485        let list_items: Vec<ListItem> = entries.into_iter().map(ListItem::new).collect();
1486        let border_style = Style::default().fg(self.theme.primary.unwrap_or(Color::LightBlue));
1487        let list = List::new(list_items)
1488            .block(
1489                Block::default()
1490                    .title(Line::from("? help · / commands"))
1491                    .borders(Borders::ALL)
1492                    .border_style(border_style),
1493            )
1494            .highlight_style(
1495                Style::default()
1496                    .fg(self.theme.primary.unwrap_or(Color::LightBlue))
1497                    .add_modifier(Modifier::BOLD),
1498            );
1499        frame.render_stateful_widget(list, suggestion_area, self.slash_suggestions.list_state());
1500    }
1501
1502    fn handle_event(
1503        &mut self,
1504        event: CrosstermEvent,
1505        events: &UnboundedSender<RatatuiEvent>,
1506    ) -> Result<bool> {
1507        match event {
1508            CrosstermEvent::Key(key) => self.handle_key_event(key, events),
1509            CrosstermEvent::Resize(_, _) => {
1510                self.transcript_autoscroll = true;
1511                self.pty_autoscroll = true;
1512                Ok(true)
1513            }
1514            CrosstermEvent::Mouse(mouse) => self.handle_mouse_event(mouse, events),
1515            CrosstermEvent::FocusGained | CrosstermEvent::FocusLost | CrosstermEvent::Paste(_) => {
1516                Ok(false)
1517            }
1518        }
1519    }
1520
1521    fn handle_key_event(
1522        &mut self,
1523        key: KeyEvent,
1524        events: &UnboundedSender<RatatuiEvent>,
1525    ) -> Result<bool> {
1526        if key.kind == KeyEventKind::Release {
1527            return Ok(false);
1528        }
1529
1530        let suggestions_active = self.slash_suggestions.is_visible();
1531        if suggestions_active {
1532            match key.code {
1533                KeyCode::Up => {
1534                    if self.slash_suggestions.select_previous() {
1535                        return Ok(true);
1536                    }
1537                }
1538                KeyCode::Down => {
1539                    if self.slash_suggestions.select_next() {
1540                        return Ok(true);
1541                    }
1542                }
1543                KeyCode::Char('k') if key.modifiers.is_empty() => {
1544                    self.slash_suggestions.select_previous();
1545                    return Ok(true);
1546                }
1547                KeyCode::Char('j') if key.modifiers.is_empty() => {
1548                    self.slash_suggestions.select_next();
1549                    return Ok(true);
1550                }
1551                KeyCode::Enter | KeyCode::Tab => {
1552                    if self.apply_selected_suggestion() {
1553                        return Ok(true);
1554                    }
1555                }
1556                _ => {}
1557            }
1558        }
1559
1560        match key.code {
1561            KeyCode::Enter => {
1562                if !self.input_enabled {
1563                    return Ok(true);
1564                }
1565                let text = self.input.take();
1566                self.update_input_state();
1567                self.last_escape = None;
1568                let _ = events.send(RatatuiEvent::Submit(text));
1569                self.transcript_autoscroll = true;
1570                Ok(true)
1571            }
1572            KeyCode::Esc => {
1573                if self.input.value().is_empty() {
1574                    let now = Instant::now();
1575                    let double_escape = self
1576                        .last_escape
1577                        .map(|last| {
1578                            now.duration_since(last).as_millis() <= u128::from(ESCAPE_DOUBLE_MS)
1579                        })
1580                        .unwrap_or(false);
1581                    self.last_escape = Some(now);
1582                    if double_escape {
1583                        let _ = events.send(RatatuiEvent::Exit);
1584                        self.should_exit = true;
1585                    } else {
1586                        let _ = events.send(RatatuiEvent::Cancel);
1587                    }
1588                } else {
1589                    if self.input_enabled {
1590                        self.input.clear();
1591                        self.update_input_state();
1592                    }
1593                }
1594                Ok(true)
1595            }
1596            KeyCode::Char('c') | KeyCode::Char('d') | KeyCode::Char('z')
1597                if key.modifiers.contains(KeyModifiers::CONTROL) =>
1598            {
1599                if self.input_enabled {
1600                    self.input.clear();
1601                    self.update_input_state();
1602                }
1603                match key.code {
1604                    KeyCode::Char('c') => {
1605                        let _ = events.send(RatatuiEvent::Interrupt);
1606                    }
1607                    KeyCode::Char('d') => {
1608                        let _ = events.send(RatatuiEvent::Exit);
1609                        self.should_exit = true;
1610                    }
1611                    KeyCode::Char('z') => {
1612                        let _ = events.send(RatatuiEvent::Cancel);
1613                    }
1614                    _ => {}
1615                }
1616                Ok(true)
1617            }
1618            KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1619                self.transcript_scroll.scroll_to_bottom();
1620                self.transcript_autoscroll = true;
1621                self.scroll_focus = ScrollFocus::Transcript;
1622                Ok(true)
1623            }
1624            KeyCode::Char('?') if key.modifiers.is_empty() => {
1625                if self.input_enabled {
1626                    self.set_input_text("/help".to_string());
1627                }
1628                Ok(true)
1629            }
1630            KeyCode::PageUp => {
1631                let focus = if key.modifiers.contains(KeyModifiers::SHIFT) {
1632                    ScrollFocus::Pty
1633                } else {
1634                    self.scroll_focus
1635                };
1636                let handled = self.scroll_page_up_with_focus(focus);
1637                self.scroll_focus = focus;
1638                let _ = events.send(RatatuiEvent::ScrollPageUp);
1639                Ok(handled)
1640            }
1641            KeyCode::PageDown => {
1642                let focus = if key.modifiers.contains(KeyModifiers::SHIFT) {
1643                    ScrollFocus::Pty
1644                } else {
1645                    self.scroll_focus
1646                };
1647                let handled = self.scroll_page_down_with_focus(focus);
1648                self.scroll_focus = focus;
1649                let _ = events.send(RatatuiEvent::ScrollPageDown);
1650                Ok(handled)
1651            }
1652            KeyCode::Up => {
1653                let focus = if key.modifiers.contains(KeyModifiers::SHIFT) {
1654                    ScrollFocus::Pty
1655                } else {
1656                    self.scroll_focus
1657                };
1658                let handled = self.scroll_line_up_with_focus(focus);
1659                self.scroll_focus = focus;
1660                let _ = events.send(RatatuiEvent::ScrollLineUp);
1661                Ok(handled)
1662            }
1663            KeyCode::Down => {
1664                let focus = if key.modifiers.contains(KeyModifiers::SHIFT) {
1665                    ScrollFocus::Pty
1666                } else {
1667                    self.scroll_focus
1668                };
1669                let handled = self.scroll_line_down_with_focus(focus);
1670                self.scroll_focus = focus;
1671                let _ = events.send(RatatuiEvent::ScrollLineDown);
1672                Ok(handled)
1673            }
1674            KeyCode::Backspace => {
1675                if !self.input_enabled {
1676                    return Ok(true);
1677                }
1678                self.input.backspace();
1679                self.update_input_state();
1680                self.transcript_autoscroll = true;
1681                Ok(true)
1682            }
1683            KeyCode::Delete => {
1684                if !self.input_enabled {
1685                    return Ok(true);
1686                }
1687                self.input.delete();
1688                self.update_input_state();
1689                self.transcript_autoscroll = true;
1690                Ok(true)
1691            }
1692            KeyCode::Left => {
1693                if !self.input_enabled {
1694                    return Ok(true);
1695                }
1696                self.input.move_left();
1697                Ok(true)
1698            }
1699            KeyCode::Right => {
1700                if !self.input_enabled {
1701                    return Ok(true);
1702                }
1703                self.input.move_right();
1704                Ok(true)
1705            }
1706            KeyCode::Home => {
1707                if !self.input_enabled {
1708                    return Ok(true);
1709                }
1710                self.input.move_home();
1711                Ok(true)
1712            }
1713            KeyCode::End => {
1714                if !self.input_enabled {
1715                    return Ok(true);
1716                }
1717                self.input.move_end();
1718                Ok(true)
1719            }
1720            KeyCode::Char(ch) => {
1721                if key
1722                    .modifiers
1723                    .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
1724                {
1725                    return Ok(false);
1726                }
1727                if !self.input_enabled {
1728                    return Ok(true);
1729                }
1730                self.input.insert(ch);
1731                self.update_input_state();
1732                self.last_escape = None;
1733                self.transcript_autoscroll = true;
1734                Ok(true)
1735            }
1736            _ => Ok(false),
1737        }
1738    }
1739
1740    fn scroll_state_mut(&mut self, focus: ScrollFocus) -> &mut TranscriptScrollState {
1741        match focus {
1742            ScrollFocus::Transcript => &mut self.transcript_scroll,
1743            ScrollFocus::Pty => &mut self.pty_scroll,
1744        }
1745    }
1746
1747    fn scroll_with<F>(&mut self, focus: ScrollFocus, mut apply: F) -> bool
1748    where
1749        F: FnMut(&mut TranscriptScrollState),
1750    {
1751        let state = self.scroll_state_mut(focus);
1752        let before = state.offset();
1753        apply(state);
1754        let changed = state.offset() != before;
1755        if changed {
1756            match focus {
1757                ScrollFocus::Transcript => self.transcript_autoscroll = false,
1758                ScrollFocus::Pty => self.pty_autoscroll = false,
1759            }
1760            self.scroll_focus = focus;
1761        }
1762        changed
1763    }
1764
1765    fn alternate_focus(focus: ScrollFocus) -> ScrollFocus {
1766        match focus {
1767            ScrollFocus::Transcript => ScrollFocus::Pty,
1768            ScrollFocus::Pty => ScrollFocus::Transcript,
1769        }
1770    }
1771
1772    fn scroll_line_up_with_focus(&mut self, focus: ScrollFocus) -> bool {
1773        if self.scroll_with(focus, |state| state.scroll_up()) {
1774            return true;
1775        }
1776        let alternate = Self::alternate_focus(focus);
1777        self.scroll_with(alternate, |state| state.scroll_up())
1778    }
1779
1780    fn scroll_line_down_with_focus(&mut self, focus: ScrollFocus) -> bool {
1781        if self.scroll_with(focus, |state| state.scroll_down()) {
1782            return true;
1783        }
1784        let alternate = Self::alternate_focus(focus);
1785        self.scroll_with(alternate, |state| state.scroll_down())
1786    }
1787
1788    fn scroll_page_up_with_focus(&mut self, focus: ScrollFocus) -> bool {
1789        if self.scroll_with(focus, |state| state.scroll_page_up()) {
1790            return true;
1791        }
1792        let alternate = Self::alternate_focus(focus);
1793        self.scroll_with(alternate, |state| state.scroll_page_up())
1794    }
1795
1796    fn scroll_page_down_with_focus(&mut self, focus: ScrollFocus) -> bool {
1797        if self.scroll_with(focus, |state| state.scroll_page_down()) {
1798            return true;
1799        }
1800        let alternate = Self::alternate_focus(focus);
1801        self.scroll_with(alternate, |state| state.scroll_page_down())
1802    }
1803
1804    fn highlight_transcript(
1805        &self,
1806        lines: Vec<Line<'static>>,
1807        _offset: usize,
1808    ) -> Vec<Line<'static>> {
1809        let Some((start, end)) = self.selection.range() else {
1810            return lines;
1811        };
1812        let highlight_color = self
1813            .theme
1814            .secondary
1815            .or(self.theme.primary)
1816            .unwrap_or(Color::DarkGray);
1817        let highlight_style = Style::default().bg(highlight_color);
1818
1819        lines
1820            .into_iter()
1821            .enumerate()
1822            .map(|(index, mut line)| {
1823                if index >= start && index <= end {
1824                    if line.spans.is_empty() {
1825                        line.spans
1826                            .push(Span::styled(" ".to_string(), highlight_style));
1827                    } else {
1828                        line.spans = line
1829                            .spans
1830                            .into_iter()
1831                            .map(|mut span| {
1832                                span.style = span.style.patch(highlight_style);
1833                                span
1834                            })
1835                            .collect();
1836                    }
1837                }
1838                line
1839            })
1840            .collect()
1841    }
1842
1843    fn is_in_transcript_area(&self, column: u16, row: u16) -> bool {
1844        self.transcript_area
1845            .map(|area| {
1846                let within_x = column >= area.x && column < area.x.saturating_add(area.width);
1847                let within_y = row >= area.y && row < area.y.saturating_add(area.height);
1848                within_x && within_y
1849            })
1850            .unwrap_or(false)
1851    }
1852
1853    fn transcript_line_index_at(&self, column: u16, row: u16) -> Option<usize> {
1854        let area = self.transcript_area?;
1855        if column < area.x || column >= area.x.saturating_add(area.width) {
1856            return None;
1857        }
1858        if row < area.y || row >= area.y.saturating_add(area.height) {
1859            return None;
1860        }
1861        let relative = usize::from(row.saturating_sub(area.y));
1862        let index = self.transcript_scroll.offset().saturating_add(relative);
1863        let content = self.transcript_scroll.content_height();
1864        if content == 0 {
1865            Some(0)
1866        } else if index >= content {
1867            Some(content.saturating_sub(1))
1868        } else {
1869            Some(index)
1870        }
1871    }
1872
1873    fn update_pty_area(&mut self, text_area: Rect) {
1874        let Some(placement) = self.pty_block else {
1875            self.pty_area = None;
1876            return;
1877        };
1878        if placement.height == 0 || text_area.height == 0 {
1879            self.pty_area = None;
1880            return;
1881        }
1882
1883        let offset = self.transcript_scroll.offset();
1884        let viewport = self.transcript_scroll.viewport_height();
1885        let start = placement.top;
1886        let end = placement.top + placement.height;
1887        let view_start = offset;
1888        let view_end = offset + viewport;
1889        if end <= view_start || start >= view_end {
1890            self.pty_area = None;
1891            return;
1892        }
1893
1894        let visible_start = start.max(view_start) - view_start;
1895        let visible_end = end.min(view_end) - view_start;
1896        if visible_end <= visible_start {
1897            self.pty_area = None;
1898            return;
1899        }
1900
1901        let indent = placement.indent.min(text_area.width as usize) as u16;
1902        let x = text_area.x.saturating_add(indent);
1903        let width = text_area.width.saturating_sub(indent);
1904        let y = text_area.y + visible_start as u16;
1905        let height = (visible_end - visible_start) as u16;
1906        if width == 0 || height == 0 {
1907            self.pty_area = None;
1908            return;
1909        }
1910
1911        self.pty_area = Some(Rect::new(x, y, width, height));
1912    }
1913
1914    fn is_in_pty_area(&self, column: u16, row: u16) -> bool {
1915        self.pty_area
1916            .map(|area| {
1917                let within_x = column >= area.x && column < area.x.saturating_add(area.width);
1918                let within_y = row >= area.y && row < area.y.saturating_add(area.height);
1919                within_x && within_y
1920            })
1921            .unwrap_or(false)
1922    }
1923
1924    fn handle_mouse_event(
1925        &mut self,
1926        mouse: MouseEvent,
1927        events: &UnboundedSender<RatatuiEvent>,
1928    ) -> Result<bool> {
1929        let in_transcript = self.is_in_transcript_area(mouse.column, mouse.row);
1930        let in_pty = self.is_in_pty_area(mouse.column, mouse.row);
1931        let focus = if in_pty {
1932            Some(ScrollFocus::Pty)
1933        } else if in_transcript {
1934            Some(ScrollFocus::Transcript)
1935        } else {
1936            None
1937        };
1938
1939        match mouse.kind {
1940            MouseEventKind::Down(MouseButton::Left) => {
1941                if let Some(line) = self.transcript_line_index_at(mouse.column, mouse.row) {
1942                    self.selection.begin(line);
1943                    self.transcript_autoscroll = false;
1944                    if focus == Some(ScrollFocus::Pty) {
1945                        self.pty_autoscroll = false;
1946                    }
1947                    if let Some(target) = focus {
1948                        self.scroll_focus = target;
1949                    }
1950                    return Ok(true);
1951                } else {
1952                    self.selection.clear();
1953                }
1954            }
1955            MouseEventKind::Drag(MouseButton::Left) => {
1956                if self.selection.is_active() {
1957                    if let Some(line) = self.transcript_line_index_at(mouse.column, mouse.row) {
1958                        self.selection.update(line);
1959                        return Ok(true);
1960                    }
1961                }
1962            }
1963            MouseEventKind::Up(MouseButton::Left) => {
1964                if self.selection.is_dragging() {
1965                    self.selection.finish();
1966                    return Ok(true);
1967                }
1968            }
1969            _ => {}
1970        }
1971
1972        let Some(target) = focus else {
1973            return Ok(false);
1974        };
1975
1976        self.scroll_focus = target;
1977
1978        let handled = match mouse.kind {
1979            MouseEventKind::ScrollUp => {
1980                let scrolled = self.scroll_line_up_with_focus(target);
1981                if scrolled {
1982                    let _ = events.send(RatatuiEvent::ScrollLineUp);
1983                }
1984                scrolled
1985            }
1986            MouseEventKind::ScrollDown => {
1987                let scrolled = self.scroll_line_down_with_focus(target);
1988                if scrolled {
1989                    let _ = events.send(RatatuiEvent::ScrollLineDown);
1990                }
1991                scrolled
1992            }
1993            _ => false,
1994        };
1995
1996        Ok(handled)
1997    }
1998
1999    fn draw(&mut self, frame: &mut Frame) {
2000        let area = frame.area();
2001        if area.width == 0 || area.height == 0 {
2002            return;
2003        }
2004
2005        let (body_area, status_area) = if area.height > 1 {
2006            let segments = Layout::default()
2007                .direction(Direction::Vertical)
2008                .constraints([Constraint::Min(1), Constraint::Length(1)])
2009                .split(area);
2010            (segments[0], Some(segments[1]))
2011        } else {
2012            (area, None)
2013        };
2014
2015        let content_area = body_area;
2016
2017        let (message_area, input_layout) = if content_area.height == 0 {
2018            (
2019                Rect::new(content_area.x, content_area.y, content_area.width, 0),
2020                None,
2021            )
2022        } else {
2023            let inner_width = content_area.width.saturating_sub(2);
2024            let display = self.build_input_display(inner_width);
2025            let mut block_height = display.height.saturating_add(2);
2026            if block_height < 3 {
2027                block_height = 3;
2028            }
2029            let available_for_suggestions = content_area.height.saturating_sub(block_height);
2030            let suggestion_height = self
2031                .slash_suggestions
2032                .visible_height(available_for_suggestions);
2033            let input_total_height = block_height
2034                .saturating_add(suggestion_height)
2035                .min(content_area.height);
2036            let message_height = content_area.height.saturating_sub(input_total_height);
2037            let message_area = Rect::new(
2038                content_area.x,
2039                content_area.y,
2040                content_area.width,
2041                message_height,
2042            );
2043            let input_y = content_area.y.saturating_add(message_height);
2044            let input_container = Rect::new(
2045                content_area.x,
2046                input_y,
2047                content_area.width,
2048                input_total_height,
2049            );
2050            let block_area_height = block_height.min(input_container.height);
2051            let block_area = Rect::new(
2052                input_container.x,
2053                input_container.y,
2054                input_container.width,
2055                block_area_height,
2056            );
2057            let suggestion_area =
2058                if suggestion_height > 0 && input_container.height > block_area_height {
2059                    Some(Rect::new(
2060                        input_container.x,
2061                        input_container.y + block_area_height,
2062                        input_container.width,
2063                        input_container.height.saturating_sub(block_area_height),
2064                    ))
2065                } else {
2066                    None
2067                };
2068            (
2069                message_area,
2070                Some(InputLayout {
2071                    block_area,
2072                    suggestion_area,
2073                    display,
2074                }),
2075            )
2076        };
2077
2078        let foreground_style = self
2079            .theme
2080            .foreground
2081            .map(|fg| Style::default().fg(fg))
2082            .unwrap_or_default();
2083
2084        let mut scrollbar_area = None;
2085
2086        if message_area.width > 0 && message_area.height > 0 {
2087            let viewport_height = usize::from(message_area.height);
2088            let mut display = self.build_display(message_area.width);
2089            self.transcript_scroll
2090                .update_bounds(display.total_height, viewport_height);
2091            if !self.transcript_scroll.is_at_bottom() {
2092                self.transcript_autoscroll = false;
2093            }
2094            if self.transcript_autoscroll {
2095                self.transcript_scroll.scroll_to_bottom();
2096                self.transcript_autoscroll = false;
2097            }
2098
2099            let mut needs_scrollbar =
2100                self.transcript_scroll.has_overflow() && message_area.width > 1;
2101            let text_area = if needs_scrollbar {
2102                let adjusted_width = message_area.width.saturating_sub(1);
2103                display = self.build_display(adjusted_width);
2104                self.transcript_scroll
2105                    .update_bounds(display.total_height, viewport_height);
2106                if !self.transcript_scroll.is_at_bottom() {
2107                    self.transcript_autoscroll = false;
2108                }
2109                if self.transcript_autoscroll {
2110                    self.transcript_scroll.scroll_to_bottom();
2111                    self.transcript_autoscroll = false;
2112                }
2113                needs_scrollbar = self.transcript_scroll.has_overflow();
2114                if needs_scrollbar {
2115                    let segments = Layout::default()
2116                        .direction(Direction::Horizontal)
2117                        .constraints([Constraint::Length(adjusted_width), Constraint::Length(1)])
2118                        .split(message_area);
2119                    scrollbar_area = Some(segments[1]);
2120                    segments[0]
2121                } else {
2122                    message_area
2123                }
2124            } else {
2125                message_area
2126            };
2127
2128            self.transcript_area = Some(text_area);
2129
2130            let offset = self.transcript_scroll.offset();
2131            let highlighted = self.highlight_transcript(display.lines.clone(), offset);
2132            let mut paragraph = Paragraph::new(highlighted).alignment(Alignment::Left);
2133            if offset > 0 {
2134                paragraph = paragraph.scroll((offset as u16, 0));
2135            }
2136            paragraph = paragraph.style(foreground_style);
2137            frame.render_widget(paragraph, text_area);
2138            self.update_pty_area(text_area);
2139
2140            if let Some(scroll_area) = scrollbar_area {
2141                if self.transcript_scroll.has_overflow() && scroll_area.width > 0 {
2142                    let mut scrollbar_state =
2143                        ScrollbarState::new(self.transcript_scroll.content_height())
2144                            .viewport_content_length(self.transcript_scroll.viewport_height())
2145                            .position(self.transcript_scroll.offset());
2146                    let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
2147                    frame.render_stateful_widget(scrollbar, scroll_area, &mut scrollbar_state);
2148                }
2149            }
2150        } else {
2151            self.transcript_scroll.update_bounds(0, 0);
2152            self.transcript_area = Some(message_area);
2153        }
2154
2155        if let Some(layout) = input_layout {
2156            let InputLayout {
2157                block_area,
2158                suggestion_area,
2159                display,
2160            } = layout;
2161            if block_area.width > 2 && block_area.height >= 3 {
2162                let accent = self
2163                    .theme
2164                    .secondary
2165                    .or(self.theme.foreground)
2166                    .unwrap_or(Color::DarkGray);
2167                let line_style = Style::default().fg(accent).add_modifier(Modifier::DIM);
2168                let horizontal = "─".repeat(block_area.width as usize);
2169
2170                let top_area = Rect::new(block_area.x, block_area.y, block_area.width, 1);
2171                let bottom_y = block_area.y + block_area.height.saturating_sub(1);
2172                let bottom_area = Rect::new(block_area.x, bottom_y, block_area.width, 1);
2173                let top_line = Paragraph::new(Line::from(vec![Span::styled(
2174                    horizontal.clone(),
2175                    line_style,
2176                )]));
2177                frame.render_widget(top_line, top_area);
2178                let bottom_line =
2179                    Paragraph::new(Line::from(vec![Span::styled(horizontal, line_style)]));
2180                frame.render_widget(bottom_line, bottom_area);
2181
2182                let input_height = block_area.height.saturating_sub(2);
2183                if input_height > 0 {
2184                    let input_area = Rect::new(
2185                        block_area.x,
2186                        block_area.y + 1,
2187                        block_area.width,
2188                        input_height,
2189                    );
2190                    let paragraph = Paragraph::new(display.lines.clone())
2191                        .wrap(Wrap { trim: false })
2192                        .style(foreground_style);
2193                    frame.render_widget(paragraph, input_area);
2194
2195                    if let Some(area) = suggestion_area {
2196                        if area.width > 0 && area.height > 0 {
2197                            self.render_slash_suggestions(frame, area);
2198                        }
2199                    } else if input_area.width > 0 && input_area.height > 0 {
2200                        self.render_slash_suggestions(frame, input_area);
2201                    }
2202
2203                    if self.cursor_visible {
2204                        if let Some((row, col)) = display.cursor {
2205                            if row < input_area.height && col < input_area.width {
2206                                let cursor_x = input_area.x + col;
2207                                let cursor_y = input_area.y + row;
2208                                frame.set_cursor_position((cursor_x, cursor_y));
2209                            }
2210                        }
2211                    }
2212                }
2213            }
2214        }
2215
2216        if let Some(status_area) = status_area {
2217            if status_area.width > 0 {
2218                let left_text = self.status_bar.left.clone();
2219                let center_text = self.status_bar.center.clone();
2220                let right_text = self.status_bar.right.clone();
2221
2222                let mut left_len = UnicodeWidthStr::width(left_text.as_str()) as u16;
2223                let mut right_len = UnicodeWidthStr::width(right_text.as_str()) as u16;
2224                if left_len > status_area.width {
2225                    left_len = status_area.width;
2226                }
2227                if right_len > status_area.width.saturating_sub(left_len) {
2228                    right_len = status_area.width.saturating_sub(left_len);
2229                }
2230                let center_len = status_area.width.saturating_sub(left_len + right_len);
2231                let sections = Layout::default()
2232                    .direction(Direction::Horizontal)
2233                    .constraints([
2234                        Constraint::Length(left_len),
2235                        Constraint::Length(center_len),
2236                        Constraint::Length(right_len),
2237                    ])
2238                    .split(status_area);
2239
2240                let mut status_style = Style::default()
2241                    .fg(self.theme.foreground.unwrap_or(Color::Gray))
2242                    .add_modifier(Modifier::DIM);
2243                if let Some(background) = self.theme.background {
2244                    status_style = status_style.bg(background);
2245                }
2246
2247                if let Some(area) = sections.get(0) {
2248                    if area.width > 0 {
2249                        let left = Paragraph::new(Line::from(left_text.clone()))
2250                            .alignment(Alignment::Left)
2251                            .style(status_style);
2252                        frame.render_widget(left, *area);
2253                    }
2254                }
2255                if let Some(area) = sections.get(1) {
2256                    if area.width > 0 {
2257                        let center = Paragraph::new(Line::from(center_text.clone()))
2258                            .alignment(Alignment::Center)
2259                            .style(status_style);
2260                        frame.render_widget(center, *area);
2261                    }
2262                }
2263                if let Some(area) = sections.get(2) {
2264                    if area.width > 0 {
2265                        let right = Paragraph::new(Line::from(right_text.clone()))
2266                            .alignment(Alignment::Right)
2267                            .style(status_style);
2268                        frame.render_widget(right, *area);
2269                    }
2270                }
2271            }
2272        }
2273
2274        if self.pty_block.is_none() {
2275            self.pty_area = None;
2276            self.pty_scroll.update_bounds(0, 0);
2277        }
2278    }
2279
2280    fn build_display(&mut self, width: u16) -> TranscriptDisplay {
2281        if width == 0 {
2282            return TranscriptDisplay {
2283                lines: Vec::new(),
2284                total_height: 0,
2285            };
2286        }
2287
2288        self.pty_block = None;
2289        let mut lines = Vec::new();
2290        let mut total_height = 0usize;
2291        let width_usize = width as usize;
2292        let indent_width = MESSAGE_INDENT.min(width_usize);
2293        let mut first_rendered = true;
2294
2295        self.pty_block = None;
2296
2297        for index in 0..self.messages.len() {
2298            let kind = self.messages[index].kind;
2299            if !self.block_has_visible_content(&self.messages[index]) {
2300                continue;
2301            }
2302
2303            let mut placement = None;
2304            let mut block_lines = if kind == RatatuiMessageKind::Pty {
2305                if let Some(lines) = self.build_pty_panel_lines(width_usize, indent_width) {
2306                    placement = Some(PtyPlacement {
2307                        top: 0,
2308                        height: lines.len(),
2309                        indent: indent_width,
2310                    });
2311                    lines
2312                } else {
2313                    Vec::new()
2314                }
2315            } else {
2316                let block = &self.messages[index];
2317                match kind {
2318                    RatatuiMessageKind::User => self.build_user_block(block, width_usize),
2319                    RatatuiMessageKind::Info => {
2320                        self.build_panel_block(block, width_usize, self.kind_color(kind))
2321                    }
2322                    RatatuiMessageKind::Policy => {
2323                        self.build_panel_block(block, width_usize, self.kind_color(kind))
2324                    }
2325                    _ => self.build_response_block(block, width_usize, kind),
2326                }
2327            };
2328
2329            if block_lines.is_empty() {
2330                continue;
2331            }
2332
2333            if !first_rendered {
2334                lines.push(Line::default());
2335                total_height += 1;
2336            }
2337
2338            let block_top = total_height;
2339            total_height += block_lines.len();
2340            lines.append(&mut block_lines);
2341
2342            if let Some(mut placement) = placement {
2343                placement.top = block_top;
2344                placement.height = total_height.saturating_sub(block_top);
2345                self.pty_block = Some(placement);
2346            }
2347
2348            first_rendered = false;
2349        }
2350
2351        if !lines.is_empty() {
2352            lines.push(Line::default());
2353            total_height += 1;
2354        }
2355
2356        TranscriptDisplay {
2357            lines,
2358            total_height,
2359        }
2360    }
2361
2362    fn build_input_display(&self, width: u16) -> InputDisplay {
2363        if width == 0 {
2364            return InputDisplay {
2365                lines: vec![Line::default()],
2366                cursor: None,
2367                height: 1,
2368            };
2369        }
2370
2371        let width_usize = width as usize;
2372        let mut lines = self.wrap_segments(
2373            &self.prompt_segments(),
2374            width_usize,
2375            0,
2376            self.theme.foreground,
2377        );
2378        if lines.is_empty() {
2379            lines.push(Line::default());
2380        }
2381
2382        let prefix_width = UnicodeWidthStr::width(self.prompt_prefix.as_str());
2383        let input_width = if self.show_placeholder {
2384            0
2385        } else {
2386            self.input.width_before_cursor()
2387        };
2388        let placeholder_width = if self.show_placeholder {
2389            self.placeholder_hint
2390                .as_deref()
2391                .map(UnicodeWidthStr::width)
2392                .unwrap_or(0)
2393        } else {
2394            0
2395        };
2396        let cursor_width = prefix_width + input_width + placeholder_width;
2397        let line_width = width_usize.max(1);
2398        let cursor_row = (cursor_width / line_width) as u16;
2399        let cursor_col = (cursor_width % line_width) as u16;
2400        let height = lines.len().max(1) as u16;
2401
2402        InputDisplay {
2403            lines,
2404            cursor: Some((cursor_row, cursor_col)),
2405            height,
2406        }
2407    }
2408
2409    fn block_has_visible_content(&self, block: &MessageBlock) -> bool {
2410        match block.kind {
2411            RatatuiMessageKind::Pty | RatatuiMessageKind::Tool | RatatuiMessageKind::Agent => {
2412                block.lines.iter().any(StyledLine::has_visible_content)
2413            }
2414            _ => true,
2415        }
2416    }
2417
2418    fn build_user_block(&self, block: &MessageBlock, width: usize) -> Vec<Line<'static>> {
2419        let mut prefix_style = RatatuiTextStyle::default();
2420        prefix_style.color = Some(self.kind_color(RatatuiMessageKind::User));
2421        prefix_style.bold = true;
2422        self.build_prefixed_block(block, width, "❯ ", prefix_style, self.theme.foreground)
2423    }
2424
2425    fn build_response_block(
2426        &self,
2427        block: &MessageBlock,
2428        width: usize,
2429        kind: RatatuiMessageKind,
2430    ) -> Vec<Line<'static>> {
2431        let marker = match kind {
2432            RatatuiMessageKind::Agent | RatatuiMessageKind::Tool => "✦",
2433            RatatuiMessageKind::Error => "!",
2434            RatatuiMessageKind::Policy => "ⓘ",
2435            RatatuiMessageKind::User => "❯",
2436            _ => "✻",
2437        };
2438        let prefix = format!("{}{} ", " ".repeat(MESSAGE_INDENT), marker);
2439        let mut style = RatatuiTextStyle::default();
2440        style.color = Some(self.kind_color(kind));
2441        if matches!(kind, RatatuiMessageKind::Agent | RatatuiMessageKind::Error) {
2442            style.bold = true;
2443        }
2444        self.build_prefixed_block(block, width, &prefix, style, self.theme.foreground)
2445    }
2446
2447    fn build_panel_block(
2448        &self,
2449        block: &MessageBlock,
2450        width: usize,
2451        accent: Color,
2452    ) -> Vec<Line<'static>> {
2453        if width < 4 {
2454            let mut fallback = Vec::new();
2455            for line in &block.lines {
2456                let wrapped = self.wrap_segments(&line.segments, width, 0, self.theme.foreground);
2457                fallback.extend(wrapped);
2458            }
2459            return fallback;
2460        }
2461
2462        let border_style = Style::default().fg(accent);
2463        let horizontal = "─".repeat(width.saturating_sub(2));
2464        let mut rendered = Vec::new();
2465        rendered.push(Line::from(vec![Span::styled(
2466            format!("╭{}╮", horizontal),
2467            border_style,
2468        )]));
2469
2470        let content_width = width.saturating_sub(4);
2471        let mut emitted = false;
2472        for line in &block.lines {
2473            let wrapped =
2474                self.wrap_segments(&line.segments, content_width, 0, self.theme.foreground);
2475            if wrapped.is_empty() {
2476                let mut spans = Vec::new();
2477                spans.push(Span::styled("│ ", border_style));
2478                spans.push(Span::raw(" ".repeat(content_width)));
2479                spans.push(Span::styled(" │", border_style));
2480                rendered.push(Line::from(spans));
2481                continue;
2482            }
2483
2484            for wrapped_line in wrapped {
2485                emitted = true;
2486                let mut spans = Vec::new();
2487                spans.push(Span::styled("│ ", border_style));
2488                let mut content_spans = wrapped_line.spans.clone();
2489                let mut occupied = 0usize;
2490                for span in &content_spans {
2491                    occupied += UnicodeWidthStr::width(span.content.as_ref());
2492                }
2493                if occupied < content_width {
2494                    content_spans.push(Span::raw(" ".repeat(content_width - occupied)));
2495                }
2496                spans.extend(content_spans);
2497                spans.push(Span::styled(" │", border_style));
2498                rendered.push(Line::from(spans));
2499            }
2500        }
2501
2502        if !emitted {
2503            let mut spans = Vec::new();
2504            spans.push(Span::styled("│ ", border_style));
2505            spans.push(Span::raw(" ".repeat(content_width)));
2506            spans.push(Span::styled(" │", border_style));
2507            rendered.push(Line::from(spans));
2508        }
2509
2510        rendered.push(Line::from(vec![Span::styled(
2511            format!("╰{}╯", horizontal),
2512            border_style,
2513        )]));
2514        rendered
2515    }
2516
2517    fn build_prefixed_block(
2518        &self,
2519        block: &MessageBlock,
2520        width: usize,
2521        prefix: &str,
2522        prefix_style: RatatuiTextStyle,
2523        fallback: Option<Color>,
2524    ) -> Vec<Line<'static>> {
2525        if width == 0 {
2526            return Vec::new();
2527        }
2528        let prefix_width = UnicodeWidthStr::width(prefix);
2529        if prefix_width >= width {
2530            let mut lines = Vec::new();
2531            for line in &block.lines {
2532                let mut spans = Vec::new();
2533                spans.push(Span::styled(
2534                    prefix.to_string(),
2535                    prefix_style.to_style(fallback),
2536                ));
2537                lines.push(Line::from(spans));
2538                let wrapped = self.wrap_segments(&line.segments, width, 0, fallback);
2539                lines.extend(wrapped);
2540            }
2541            if lines.is_empty() {
2542                lines.push(Line::from(vec![Span::styled(
2543                    prefix.to_string(),
2544                    prefix_style.to_style(fallback),
2545                )]));
2546            }
2547            return lines;
2548        }
2549
2550        let content_width = width - prefix_width;
2551        let continuation = " ".repeat(prefix_width);
2552        let mut rendered = Vec::new();
2553        let mut first = true;
2554        for line in &block.lines {
2555            let wrapped = self.wrap_segments(&line.segments, content_width, 0, fallback);
2556            if wrapped.is_empty() {
2557                let prefix_text = if first { prefix } else { continuation.as_str() };
2558                rendered.push(Line::from(vec![Span::styled(
2559                    prefix_text.to_string(),
2560                    prefix_style.to_style(fallback),
2561                )]));
2562                first = false;
2563                continue;
2564            }
2565
2566            for (index, wrapped_line) in wrapped.into_iter().enumerate() {
2567                let prefix_text = if first && index == 0 {
2568                    prefix
2569                } else {
2570                    continuation.as_str()
2571                };
2572                let mut spans = Vec::new();
2573                spans.push(Span::styled(
2574                    prefix_text.to_string(),
2575                    prefix_style.to_style(fallback),
2576                ));
2577                spans.extend(wrapped_line.spans);
2578                rendered.push(Line::from(spans));
2579            }
2580            first = false;
2581        }
2582
2583        if rendered.is_empty() {
2584            rendered.push(Line::from(vec![Span::styled(
2585                prefix.to_string(),
2586                prefix_style.to_style(fallback),
2587            )]));
2588        }
2589
2590        rendered
2591    }
2592
2593    fn prompt_segments(&self) -> Vec<RatatuiSegment> {
2594        let mut segments = Vec::new();
2595        segments.push(RatatuiSegment {
2596            text: self.prompt_prefix.clone(),
2597            style: self.prompt_style.clone(),
2598        });
2599
2600        if self.show_placeholder {
2601            if let Some(hint) = &self.placeholder_hint {
2602                segments.push(RatatuiSegment {
2603                    text: hint.clone(),
2604                    style: self.placeholder_style.clone(),
2605                });
2606            }
2607        } else {
2608            segments.push(RatatuiSegment {
2609                text: self.input.value().to_string(),
2610                style: RatatuiTextStyle::default(),
2611            });
2612        }
2613
2614        segments
2615    }
2616
2617    fn wrap_segments(
2618        &self,
2619        segments: &[RatatuiSegment],
2620        width: usize,
2621        indent: usize,
2622        fallback: Option<Color>,
2623    ) -> Vec<Line<'static>> {
2624        if width == 0 {
2625            return vec![Line::default()];
2626        }
2627
2628        let mut lines = Vec::new();
2629        let indent_width = indent.min(width);
2630        let indent_text = " ".repeat(indent_width);
2631        let mut current = Vec::new();
2632        let mut current_width = indent_width;
2633
2634        if indent_width > 0 {
2635            current.push(Span::raw(indent_text.clone()));
2636        }
2637
2638        for segment in segments {
2639            let style = segment.style.to_style(fallback);
2640            let mut buffer = String::new();
2641            let mut buffer_width = 0usize;
2642
2643            for ch in segment.text.chars() {
2644                if ch == '\n' {
2645                    if !buffer.is_empty() {
2646                        current.push(Span::styled(buffer.clone(), style));
2647                        buffer.clear();
2648                        buffer_width = 0;
2649                    }
2650                    lines.push(Line::from(current));
2651                    current = Vec::new();
2652                    if indent_width > 0 {
2653                        current.push(Span::raw(indent_text.clone()));
2654                    }
2655                    current_width = indent_width;
2656                    continue;
2657                }
2658
2659                let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
2660                if ch_width == 0 {
2661                    buffer.push(ch);
2662                    continue;
2663                }
2664
2665                if current_width + buffer_width + ch_width > width
2666                    && current_width + buffer_width > indent_width
2667                {
2668                    if !buffer.is_empty() {
2669                        current.push(Span::styled(buffer.clone(), style));
2670                        buffer.clear();
2671                        buffer_width = 0;
2672                    }
2673                    lines.push(Line::from(current));
2674                    current = Vec::new();
2675                    if indent_width > 0 {
2676                        current.push(Span::raw(indent_text.clone()));
2677                    }
2678                    current_width = indent_width;
2679                }
2680
2681                buffer.push(ch);
2682                buffer_width += ch_width;
2683            }
2684
2685            if !buffer.is_empty() {
2686                current.push(Span::styled(buffer.clone(), style));
2687                current_width += buffer_width;
2688                buffer.clear();
2689            }
2690        }
2691
2692        if current.is_empty() {
2693            if indent_width > 0 {
2694                current.push(Span::raw(indent_text));
2695            }
2696        }
2697
2698        lines.push(Line::from(current));
2699        lines
2700    }
2701
2702    fn build_pty_panel_lines(&mut self, width: usize, indent: usize) -> Option<Vec<Line<'static>>> {
2703        let Some(panel) = self.pty_panel.as_mut() else {
2704            self.pty_scroll.update_bounds(0, 0);
2705            return None;
2706        };
2707        if !panel.has_content() {
2708            self.pty_scroll.update_bounds(0, 0);
2709            return None;
2710        }
2711        if width <= indent + 2 {
2712            self.pty_scroll.update_bounds(0, 0);
2713            return None;
2714        }
2715        let available = width - indent;
2716        if available < 3 {
2717            self.pty_scroll.update_bounds(0, 0);
2718            return None;
2719        }
2720        let inner_width = available.saturating_sub(2);
2721        if inner_width == 0 {
2722            self.pty_scroll.update_bounds(0, 0);
2723            return None;
2724        }
2725
2726        let title = panel.block_title_text();
2727        let text = panel.view_text();
2728        let mut wrapped = self.wrap_pty_text(&text, inner_width);
2729        if wrapped.is_empty() {
2730            wrapped.push(Line::default());
2731        }
2732
2733        let total_content = wrapped.len().max(1);
2734        let viewport = cmp::min(total_content, cmp::max(PTY_CONTENT_VIEW_LINES, 1));
2735        self.pty_scroll.update_bounds(total_content, viewport);
2736        if self.pty_autoscroll {
2737            self.pty_scroll.scroll_to_bottom();
2738            self.pty_autoscroll = false;
2739        }
2740
2741        let offset = self.pty_scroll.offset();
2742        let mut visible: Vec<Line<'static>> =
2743            wrapped.into_iter().skip(offset).take(viewport).collect();
2744        while visible.len() < viewport {
2745            visible.push(Line::default());
2746        }
2747
2748        let indent_text = " ".repeat(indent);
2749        let border_color = self
2750            .theme
2751            .secondary
2752            .or(self.theme.primary)
2753            .unwrap_or(Color::LightCyan);
2754        let border_style = Style::default().fg(border_color);
2755        let content_style = Style::default().fg(self.theme.foreground.unwrap_or(Color::Gray));
2756        let mut block_lines = Vec::new();
2757        block_lines.push(self.build_pty_top_line(&indent_text, inner_width, &title, border_style));
2758
2759        for mut line in visible {
2760            let mut spans = Vec::new();
2761            spans.push(Span::raw(indent_text.clone()));
2762            spans.push(Span::styled("│".to_string(), border_style));
2763            let width_used = Self::line_display_width(&line);
2764            spans.append(&mut line.spans);
2765            if width_used < inner_width {
2766                spans.push(Span::styled(
2767                    " ".repeat(inner_width - width_used),
2768                    content_style,
2769                ));
2770            }
2771            spans.push(Span::styled("│".to_string(), border_style));
2772            block_lines.push(Line::from(spans));
2773        }
2774
2775        block_lines.push(self.build_pty_bottom_line(&indent_text, inner_width, border_style));
2776        Some(block_lines)
2777    }
2778
2779    fn wrap_pty_text(&self, text: &Text<'static>, inner_width: usize) -> Vec<Line<'static>> {
2780        if inner_width == 0 {
2781            return vec![Line::default()];
2782        }
2783        if text.lines.is_empty() {
2784            return vec![Line::default()];
2785        }
2786
2787        let mut wrapped = Vec::new();
2788        for raw in &text.lines {
2789            let segments: Vec<RatatuiSegment> = raw
2790                .spans
2791                .iter()
2792                .map(|span| RatatuiSegment {
2793                    text: span.content.to_string(),
2794                    style: Self::style_to_text_style(span.style),
2795                })
2796                .collect();
2797            let mut lines = self.wrap_segments(&segments, inner_width, 0, self.theme.foreground);
2798            if lines.is_empty() {
2799                lines.push(Line::default());
2800            }
2801            wrapped.append(&mut lines);
2802        }
2803
2804        if wrapped.is_empty() {
2805            wrapped.push(Line::default());
2806        }
2807        wrapped
2808    }
2809
2810    fn build_pty_top_line(
2811        &self,
2812        indent_text: &str,
2813        inner_width: usize,
2814        title: &str,
2815        style: Style,
2816    ) -> Line<'static> {
2817        let mut segments = Vec::new();
2818        segments.push(Span::raw(indent_text.to_string()));
2819        segments.push(Span::styled("╭".to_string(), style));
2820        segments.push(Span::styled(
2821            self.compose_pty_title_bar(inner_width, title),
2822            style,
2823        ));
2824        segments.push(Span::styled("╮".to_string(), style));
2825        Line::from(segments)
2826    }
2827
2828    fn build_pty_bottom_line(
2829        &self,
2830        indent_text: &str,
2831        inner_width: usize,
2832        style: Style,
2833    ) -> Line<'static> {
2834        Line::from(vec![
2835            Span::raw(indent_text.to_string()),
2836            Span::styled("╰".to_string(), style),
2837            Span::styled("─".repeat(inner_width), style),
2838            Span::styled("╯".to_string(), style),
2839        ])
2840    }
2841
2842    fn compose_pty_title_bar(&self, inner_width: usize, title: &str) -> String {
2843        if inner_width == 0 {
2844            return String::new();
2845        }
2846        let trimmed = title.trim();
2847        if trimmed.is_empty() || inner_width < 2 {
2848            return "─".repeat(inner_width);
2849        }
2850        let available = inner_width.saturating_sub(2);
2851        if available == 0 {
2852            return "─".repeat(inner_width);
2853        }
2854        let truncated = Self::truncate_to_width(trimmed, available);
2855        let decorated = format!(" {} ", truncated);
2856        let decorated_width = UnicodeWidthStr::width(decorated.as_str()).min(inner_width);
2857        let remaining = inner_width.saturating_sub(decorated_width);
2858        let left = remaining / 2;
2859        let right = remaining - left;
2860        format!("{}{}{}", "─".repeat(left), decorated, "─".repeat(right),)
2861    }
2862
2863    fn truncate_to_width(text: &str, max_width: usize) -> String {
2864        if max_width == 0 {
2865            return String::new();
2866        }
2867        let trimmed = text.trim();
2868        if trimmed.is_empty() {
2869            return String::new();
2870        }
2871        if UnicodeWidthStr::width(trimmed) <= max_width {
2872            return trimmed.to_string();
2873        }
2874        let mut result = String::new();
2875        let mut width_used = 0usize;
2876        let limit = max_width.saturating_sub(1);
2877        for ch in trimmed.chars() {
2878            let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
2879            if ch_width == 0 {
2880                continue;
2881            }
2882            if width_used + ch_width > limit {
2883                break;
2884            }
2885            result.push(ch);
2886            width_used += ch_width;
2887        }
2888        if result.is_empty() {
2889            "…".to_string()
2890        } else {
2891            result.push('…');
2892            result
2893        }
2894    }
2895
2896    fn line_display_width(line: &Line<'_>) -> usize {
2897        line.spans
2898            .iter()
2899            .map(|span| UnicodeWidthStr::width(span.content.as_ref()))
2900            .sum()
2901    }
2902
2903    fn style_to_text_style(style: Style) -> RatatuiTextStyle {
2904        let mut text_style = RatatuiTextStyle::default();
2905        text_style.color = style.fg;
2906        if style.add_modifier.contains(Modifier::BOLD) {
2907            text_style.bold = true;
2908        }
2909        if style.add_modifier.contains(Modifier::ITALIC) {
2910            text_style.italic = true;
2911        }
2912        text_style
2913    }
2914
2915    fn kind_color(&self, kind: RatatuiMessageKind) -> Color {
2916        match kind {
2917            RatatuiMessageKind::Agent => self.theme.primary.unwrap_or(Color::LightCyan),
2918            RatatuiMessageKind::User => self.theme.secondary.unwrap_or(Color::LightGreen),
2919            RatatuiMessageKind::Tool => self.theme.foreground.unwrap_or(Color::LightMagenta),
2920            RatatuiMessageKind::Pty => self.theme.primary.unwrap_or(Color::LightBlue),
2921            RatatuiMessageKind::Info => self.theme.foreground.unwrap_or(Color::Yellow),
2922            RatatuiMessageKind::Policy => self.theme.secondary.unwrap_or(Color::LightYellow),
2923            RatatuiMessageKind::Error => Color::LightRed,
2924        }
2925    }
2926}
2927
2928fn convert_ansi_color(color: AnsiColorEnum) -> Option<Color> {
2929    match color {
2930        AnsiColorEnum::Ansi(ansi) => Some(match ansi {
2931            AnsiColor::Black => Color::Black,
2932            AnsiColor::Red => Color::Red,
2933            AnsiColor::Green => Color::Green,
2934            AnsiColor::Yellow => Color::Yellow,
2935            AnsiColor::Blue => Color::Blue,
2936            AnsiColor::Magenta => Color::Magenta,
2937            AnsiColor::Cyan => Color::Cyan,
2938            AnsiColor::White => Color::White,
2939            AnsiColor::BrightBlack => Color::DarkGray,
2940            AnsiColor::BrightRed => Color::LightRed,
2941            AnsiColor::BrightGreen => Color::LightGreen,
2942            AnsiColor::BrightYellow => Color::LightYellow,
2943            AnsiColor::BrightBlue => Color::LightBlue,
2944            AnsiColor::BrightMagenta => Color::LightMagenta,
2945            AnsiColor::BrightCyan => Color::LightCyan,
2946            AnsiColor::BrightWhite => Color::Gray,
2947        }),
2948        AnsiColorEnum::Ansi256(value) => Some(Color::Indexed(value.0)),
2949        AnsiColorEnum::Rgb(rgb) => Some(Color::Rgb(rgb.0, rgb.1, rgb.2)),
2950    }
2951}
2952
2953fn convert_style_color(style: &AnsiStyle) -> Option<Color> {
2954    style.get_fg_color().and_then(convert_ansi_color)
2955}
2956
2957pub fn convert_style(style: AnsiStyle) -> RatatuiTextStyle {
2958    let mut converted = RatatuiTextStyle::default();
2959    converted.color = convert_style_color(&style);
2960    let effects = style.get_effects();
2961    converted.bold = effects.contains(Effects::BOLD);
2962    converted.italic = effects.contains(Effects::ITALIC);
2963    converted
2964}
2965
2966pub fn parse_tui_color(input: &str) -> Option<Color> {
2967    let deserializer = StrDeserializer::<DeValueError>::new(input);
2968    color_to_tui::deserialize(deserializer).ok()
2969}
2970
2971pub fn theme_from_styles(styles: &theme::ThemeStyles) -> RatatuiTheme {
2972    RatatuiTheme {
2973        background: convert_ansi_color(styles.background),
2974        foreground: convert_ansi_color(styles.foreground),
2975        primary: convert_style_color(&styles.primary),
2976        secondary: convert_style_color(&styles.secondary),
2977    }
2978}
2979
2980fn create_ticker() -> Interval {
2981    let mut ticker = interval(Duration::from_millis(REDRAW_INTERVAL_MS));
2982    ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
2983    ticker
2984}