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}