1#![allow(dead_code)]
2
3use anstyle::Color as AnsiColorEnum;
4use ratatui::{
5 prelude::*,
6 widgets::{Block, Clear, Paragraph, Wrap},
7};
8use unicode_width::UnicodeWidthStr;
9
10use super::super::style::{ratatui_pty_style_from_inline, ratatui_style_from_inline};
11use super::super::types::{InlineMessageKind, InlineTextStyle};
12use super::terminal_capabilities;
13use super::{Session, TranscriptLine, file_palette::FilePalette, message::MessageLine, text_utils};
14use crate::config::constants::ui;
15
16mod history_picker;
17mod modal_renderer;
18mod palettes;
19mod spans;
20
21pub use history_picker::{render_history_picker, split_inline_history_picker_area};
22pub(crate) use modal_renderer::modal_render_styles;
23pub use modal_renderer::render_modal;
24pub use modal_renderer::split_inline_modal_area;
25pub use palettes::{render_file_palette, split_inline_file_palette_area};
26use spans::{accent_style, border_style, default_style, invalidate_scroll_metrics, text_fallback};
27
28pub(super) fn render_message_spans(session: &Session, index: usize) -> Vec<Span<'static>> {
29 spans::render_message_spans(session, index)
30}
31
32pub(super) fn agent_prefix_spans(session: &Session, line: &MessageLine) -> Vec<Span<'static>> {
33 spans::agent_prefix_spans(session, line)
34}
35
36pub(super) fn strip_ansi_codes(text: &str) -> std::borrow::Cow<'_, str> {
37 spans::strip_ansi_codes(text)
38}
39
40pub(super) fn render_tool_segments(session: &Session, line: &MessageLine) -> Vec<Span<'static>> {
41 spans::render_tool_segments(session, line)
42}
43
44const USER_PREFIX: &str = "";
45
46#[allow(dead_code)]
47pub fn render(session: &mut Session, frame: &mut Frame<'_>) {
48 let size = frame.area();
49 if size.width == 0 || size.height == 0 {
50 return;
51 }
52
53 if session.needs_full_clear {
55 frame.render_widget(Clear, size);
56 session.needs_full_clear = false;
57 }
58
59 session.poll_log_entries();
61
62 let header_lines = session.header_lines();
63 let header_height = session.header_height_from_lines(size.width, &header_lines);
64 if header_height != session.header_rows {
65 session.header_rows = header_height;
66 recalculate_transcript_rows(session);
67 }
68
69 let status_height = if size.width > 0 {
71 ui::INLINE_INPUT_STATUS_HEIGHT
72 } else {
73 0
74 };
75 let inner_width = size
76 .width
77 .saturating_sub(ui::INLINE_INPUT_PADDING_HORIZONTAL.saturating_mul(2));
78 let desired_lines = session.desired_input_lines(inner_width);
79 let block_height = Session::input_block_height_for_lines(desired_lines);
80 let input_height = block_height.saturating_add(status_height);
81 session.apply_input_height(input_height);
82
83 let chunks = Layout::vertical([
84 Constraint::Length(header_height),
85 Constraint::Min(1),
86 Constraint::Length(input_height),
87 ])
88 .split(size);
89
90 let (header_area, transcript_area, input_area) = (chunks[0], chunks[1], chunks[2]);
91
92 apply_view_rows(session, transcript_area.height);
94
95 session.render_header(frame, header_area, &header_lines);
97 if session.show_logs {
98 let split = Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)])
99 .split(transcript_area);
100 let (transcript_body, modal_area) = split_inline_modal_area(session, split[0]);
101 let (transcript_body, file_palette_area) =
102 split_inline_file_palette_area(session, transcript_body);
103 let (transcript_body, history_picker_area) =
104 split_inline_history_picker_area(session, transcript_body);
105 let (transcript_body, slash_area) =
106 super::slash::split_inline_slash_area(session, transcript_body);
107 let interactive_bottom_buffer = transcript_area
108 .height
109 .saturating_sub(transcript_body.height);
110 render_transcript(session, frame, transcript_body, interactive_bottom_buffer);
111 if let Some(modal_area) = modal_area {
112 render_modal(session, frame, modal_area);
113 } else if session.modal_overlay_active() {
114 render_modal(session, frame, size);
115 }
116 if let Some(file_palette_area) = file_palette_area {
117 render_file_palette(session, frame, file_palette_area);
118 }
119 if let Some(history_picker_area) = history_picker_area {
120 render_history_picker(session, frame, history_picker_area);
121 }
122 if let Some(slash_area) = slash_area {
123 super::slash::render_slash_palette(session, frame, slash_area);
124 }
125 render_log_view(session, frame, split[1]);
126 } else {
127 let (transcript_body, modal_area) = split_inline_modal_area(session, transcript_area);
128 let (transcript_body, file_palette_area) =
129 split_inline_file_palette_area(session, transcript_body);
130 let (transcript_body, history_picker_area) =
131 split_inline_history_picker_area(session, transcript_body);
132 let (transcript_body, slash_area) =
133 super::slash::split_inline_slash_area(session, transcript_body);
134 let interactive_bottom_buffer = transcript_area
135 .height
136 .saturating_sub(transcript_body.height);
137 render_transcript(session, frame, transcript_body, interactive_bottom_buffer);
138 if let Some(modal_area) = modal_area {
139 render_modal(session, frame, modal_area);
140 } else if session.modal_overlay_active() {
141 render_modal(session, frame, size);
142 }
143 if let Some(file_palette_area) = file_palette_area {
144 render_file_palette(session, frame, file_palette_area);
145 }
146 if let Some(history_picker_area) = history_picker_area {
147 render_history_picker(session, frame, history_picker_area);
148 }
149 if let Some(slash_area) = slash_area {
150 super::slash::render_slash_palette(session, frame, slash_area);
151 }
152 }
153 session.render_input(frame, input_area);
154}
155
156fn render_log_view(session: &mut Session, frame: &mut Frame<'_>, area: Rect) {
157 let block = Block::bordered()
158 .title("Logs")
159 .border_type(terminal_capabilities::get_border_type())
160 .style(default_style(session))
161 .border_style(border_style(session));
162 let inner = block.inner(area);
163 frame.render_widget(block, area);
164 if inner.height == 0 || inner.width == 0 {
165 return;
166 }
167
168 let paragraph = Paragraph::new((*session.log_text()).clone()).wrap(Wrap { trim: false });
169 frame.render_widget(paragraph, inner);
170}
171
172fn modal_list_highlight_style(session: &Session) -> Style {
173 session.styles.modal_list_highlight_style()
174}
175
176pub fn apply_view_rows(session: &mut Session, rows: u16) {
177 let resolved = rows.max(2);
178 if session.view_rows != resolved {
179 session.view_rows = resolved;
180 invalidate_scroll_metrics(session);
181 }
182 recalculate_transcript_rows(session);
183 session.enforce_scroll_bounds();
184}
185
186pub fn apply_transcript_rows(session: &mut Session, rows: u16) {
187 let resolved = rows.max(1);
188 if session.transcript_rows != resolved {
189 session.transcript_rows = resolved;
190 invalidate_scroll_metrics(session);
191 }
192}
193
194pub fn apply_transcript_width(session: &mut Session, width: u16) {
195 if session.transcript_width != width {
196 session.transcript_width = width;
197 invalidate_scroll_metrics(session);
198 }
199}
200
201fn render_transcript(
202 session: &mut Session,
203 frame: &mut Frame<'_>,
204 area: Rect,
205 interactive_bottom_buffer: u16,
206) {
207 if area.height == 0 || area.width == 0 {
208 return;
209 }
210 let block = Block::new()
211 .border_type(terminal_capabilities::get_border_type())
212 .style(default_style(session))
213 .border_style(border_style(session));
214 let inner = block.inner(area);
215 frame.render_widget(block, area);
216 if inner.height == 0 || inner.width == 0 {
217 return;
218 }
219
220 apply_transcript_rows(session, inner.height);
221
222 let content_width = inner.width;
223 if content_width == 0 {
224 return;
225 }
226 apply_transcript_width(session, content_width);
227
228 let viewport_rows = inner.height as usize;
229 let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING)
230 .saturating_add(usize::from(interactive_bottom_buffer));
231 let effective_padding = padding.min(viewport_rows.saturating_sub(1));
232
233 let total_rows = if session.transcript_content_changed {
236 session.total_transcript_rows(content_width) + effective_padding
237 } else {
238 session
240 .scroll_manager
241 .last_known_total()
242 .unwrap_or_else(|| session.total_transcript_rows(content_width) + effective_padding)
243 };
244 let (top_offset, _clamped_total_rows) =
245 session.prepare_transcript_scroll(total_rows, viewport_rows);
246 let vertical_offset = top_offset.min(session.scroll_manager.max_offset());
247 session.transcript_view_top = vertical_offset;
248
249 let visible_start = vertical_offset;
250 let scroll_area = inner;
251
252 let cached_lines =
254 session.collect_transcript_window_cached(content_width, visible_start, viewport_rows);
255
256 let fill_count = viewport_rows.saturating_sub(cached_lines.len());
258 let visible_lines = if fill_count > 0 || !session.queued_inputs.is_empty() {
259 let mut lines = (*cached_lines).clone();
261 if fill_count > 0 {
262 let target_len = lines.len() + fill_count;
263 lines.resize_with(target_len, TranscriptLine::default);
264 }
265 session.overlay_queue_lines(&mut lines, content_width);
266 lines
267 } else {
268 (*cached_lines).clone()
270 };
271
272 let visible_lines = session.decorate_visible_transcript_links(visible_lines, scroll_area);
273 let paragraph = Paragraph::new(visible_lines).style(default_style(session));
274
275 if session.transcript_content_changed {
278 session.transcript_content_changed = false;
279 }
280 frame.render_widget(paragraph, scroll_area);
281}
282
283fn header_reserved_rows(session: &Session) -> u16 {
284 session.header_rows.max(ui::INLINE_HEADER_HEIGHT)
285}
286
287fn input_reserved_rows(session: &Session) -> u16 {
288 header_reserved_rows(session) + session.input_height
289}
290
291pub fn recalculate_transcript_rows(session: &mut Session) {
292 let reserved = input_reserved_rows(session).saturating_add(2); let available = session.view_rows.saturating_sub(reserved).max(1);
294 apply_transcript_rows(session, available);
295}
296fn wrap_block_lines(
297 session: &Session,
298 first_prefix: &str,
299 continuation_prefix: &str,
300 content: Vec<Span<'static>>,
301 max_width: usize,
302 border_style: Style,
303) -> Vec<Line<'static>> {
304 wrap_block_lines_with_options(
305 session,
306 first_prefix,
307 continuation_prefix,
308 content,
309 max_width,
310 border_style,
311 true,
312 )
313}
314
315fn wrap_block_lines_no_right_border(
316 session: &Session,
317 first_prefix: &str,
318 continuation_prefix: &str,
319 content: Vec<Span<'static>>,
320 max_width: usize,
321 border_style: Style,
322) -> Vec<Line<'static>> {
323 wrap_block_lines_with_options(
324 session,
325 first_prefix,
326 continuation_prefix,
327 content,
328 max_width,
329 border_style,
330 false,
331 )
332}
333
334fn wrap_block_lines_with_options(
335 session: &Session,
336 first_prefix: &str,
337 continuation_prefix: &str,
338 content: Vec<Span<'static>>,
339 max_width: usize,
340 border_style: Style,
341 show_right_border: bool,
342) -> Vec<Line<'static>> {
343 if max_width < 2 {
344 let fallback = if show_right_border {
345 format!("{}││", first_prefix)
346 } else {
347 format!("{}│", first_prefix)
348 };
349 return vec![Line::from(fallback).style(border_style)];
350 }
351
352 let right_border = if show_right_border {
353 ui::INLINE_BLOCK_BODY_RIGHT
354 } else {
355 ""
356 };
357 let first_prefix_width = first_prefix.chars().count();
358 let continuation_prefix_width = continuation_prefix.chars().count();
359 let prefix_width = first_prefix_width.max(continuation_prefix_width);
360 let border_width = right_border.chars().count();
361 let consumed_width = prefix_width.saturating_add(border_width);
362 let content_width = max_width.saturating_sub(consumed_width);
363
364 if max_width == usize::MAX {
365 let mut spans = vec![Span::styled(first_prefix.to_owned(), border_style)];
366 spans.extend(content);
367 if show_right_border {
368 spans.push(Span::styled(right_border.to_owned(), border_style));
369 }
370 return vec![Line::from(spans)];
371 }
372
373 let mut wrapped = wrap_line(session, Line::from(content), content_width);
374 if wrapped.is_empty() {
375 wrapped.push(Line::default());
376 }
377
378 for (idx, line) in wrapped.iter_mut().enumerate() {
380 let line_width = line.spans.iter().map(|s| s.width()).sum::<usize>();
381 let padding = if show_right_border {
382 content_width.saturating_sub(line_width)
383 } else {
384 0
385 };
386
387 let active_prefix = if idx == 0 {
388 first_prefix
389 } else {
390 continuation_prefix
391 };
392 let mut new_spans = vec![Span::styled(active_prefix.to_owned(), border_style)];
393 new_spans.append(&mut line.spans);
394 if padding > 0 {
395 new_spans.push(Span::styled(" ".repeat(padding), Style::default()));
396 }
397 if show_right_border {
398 new_spans.push(Span::styled(right_border.to_owned(), border_style));
399 }
400 line.spans = new_spans;
401 }
402
403 wrapped
404}
405
406fn pty_block_has_content(session: &Session, index: usize) -> bool {
407 if session.lines.is_empty() {
408 return false;
409 }
410
411 let mut start = index;
412 while start > 0 {
413 let Some(previous) = session.lines.get(start - 1) else {
414 break;
415 };
416 if previous.kind != InlineMessageKind::Pty {
417 break;
418 }
419 start -= 1;
420 }
421
422 let mut end = index;
423 while end + 1 < session.lines.len() {
424 let Some(next) = session.lines.get(end + 1) else {
425 break;
426 };
427 if next.kind != InlineMessageKind::Pty {
428 break;
429 }
430 end += 1;
431 }
432
433 if start > end || end >= session.lines.len() {
434 tracing::warn!(
435 "invalid range: start={}, end={}, len={}",
436 start,
437 end,
438 session.lines.len()
439 );
440 return false;
441 }
442
443 for line in &session.lines[start..=end] {
444 if line
445 .segments
446 .iter()
447 .any(|segment| !segment.text.trim().is_empty())
448 {
449 return true;
450 }
451 }
452
453 false
454}
455
456fn reflow_pty_lines(session: &Session, index: usize, width: u16) -> Vec<TranscriptLine> {
457 let Some(line) = session.lines.get(index) else {
458 return vec![TranscriptLine::default()];
459 };
460
461 let max_width = if width == 0 {
462 usize::MAX
463 } else {
464 width as usize
465 };
466
467 if !pty_block_has_content(session, index) {
468 return Vec::new();
469 }
470
471 let mut border_style = ratatui_style_from_inline(
472 &session.styles.tool_border_style(),
473 session.theme.foreground,
474 );
475 border_style = border_style.add_modifier(Modifier::DIM);
476
477 let prev_is_pty = index
478 .checked_sub(1)
479 .and_then(|prev| session.lines.get(prev))
480 .map(|prev| prev.kind == InlineMessageKind::Pty)
481 .unwrap_or(false);
482
483 let is_start = !prev_is_pty;
484
485 let mut lines: Vec<Line<'static>> = Vec::new();
486
487 let mut combined = String::new();
488 for segment in &line.segments {
489 combined.push_str(segment.text.as_str());
490 }
491 if is_start && combined.trim().is_empty() {
492 return Vec::new();
493 }
494
495 let fallback = text_fallback(session, InlineMessageKind::Pty).or(session.theme.foreground);
497 let mut body_spans = Vec::new();
498 for segment in &line.segments {
499 let stripped_text = strip_ansi_codes(&segment.text);
500 let style = ratatui_pty_style_from_inline(&segment.style, fallback);
501 body_spans.push(Span::styled(stripped_text.into_owned(), style));
502 }
503
504 let is_thinking_spinner = combined.contains("Thinking...");
506
507 if is_start {
508 if is_thinking_spinner {
510 lines.extend(wrap_block_lines_no_right_border(
512 session,
513 "",
514 "",
515 body_spans,
516 max_width,
517 border_style,
518 ));
519 } else {
520 let body_prefix = " ";
521 let continuation_prefix =
522 text_utils::pty_wrapped_continuation_prefix(body_prefix, combined.as_str());
523 lines.extend(wrap_block_lines_no_right_border(
524 session,
525 body_prefix,
526 continuation_prefix.as_str(),
527 body_spans,
528 max_width,
529 border_style,
530 ));
531 }
532 } else {
533 let body_prefix = " ";
534 let continuation_prefix =
535 text_utils::pty_wrapped_continuation_prefix(body_prefix, combined.as_str());
536 lines.extend(wrap_block_lines_no_right_border(
537 session,
538 body_prefix,
539 continuation_prefix.as_str(),
540 body_spans,
541 max_width,
542 border_style,
543 ));
544 }
545
546 if lines.is_empty() {
547 lines.push(Line::default());
548 }
549
550 lines
551 .into_iter()
552 .map(|line| TranscriptLine {
553 line,
554 explicit_links: Vec::new(),
555 })
556 .collect()
557}
558
559fn message_divider_line(session: &Session, width: usize, kind: InlineMessageKind) -> Line<'static> {
560 if width == 0 {
561 return Line::default();
562 }
563
564 let content = ui::INLINE_USER_MESSAGE_DIVIDER_SYMBOL.repeat(width);
565 let style = message_divider_style(session, kind);
566 Line::from(content).style(style)
567}
568
569fn message_divider_style(session: &Session, kind: InlineMessageKind) -> Style {
570 session.styles.message_divider_style(kind)
571}
572
573fn wrap_line(_session: &Session, line: Line<'static>, max_width: usize) -> Vec<Line<'static>> {
574 text_utils::wrap_line(line, max_width)
575}
576
577fn justify_wrapped_lines(
578 session: &Session,
579 lines: Vec<Line<'static>>,
580 max_width: usize,
581 kind: InlineMessageKind,
582) -> Vec<Line<'static>> {
583 if max_width == 0 {
584 return lines;
585 }
586
587 let total = lines.len();
588 let mut justified = Vec::with_capacity(total);
589 let mut in_fenced_block = false;
590 for (index, line) in lines.into_iter().enumerate() {
591 let is_last = index + 1 == total;
592 let mut next_in_fenced_block = in_fenced_block;
593 let is_fence_line = {
594 let line_text_storage: std::borrow::Cow<'_, str> = if line.spans.len() == 1 {
595 std::borrow::Cow::Borrowed(&*line.spans[0].content)
596 } else {
597 std::borrow::Cow::Owned(
598 line.spans
599 .iter()
600 .map(|span| &*span.content)
601 .collect::<String>(),
602 )
603 };
604 let line_text: &str = &line_text_storage;
605 let trimmed_start = line_text.trim_start();
606 trimmed_start.starts_with("```") || trimmed_start.starts_with("~~~")
607 };
608 if is_fence_line {
609 next_in_fenced_block = !in_fenced_block;
610 }
611
612 let processed_line = if is_diff_line(session, &line) {
614 pad_diff_line(session, &line, max_width)
615 } else if kind == InlineMessageKind::Agent
616 && !in_fenced_block
617 && !is_fence_line
618 && should_justify_message_line(session, &line, max_width, is_last)
619 {
620 justify_message_line(session, &line, max_width)
621 } else {
622 line
623 };
624
625 justified.push(processed_line);
626 in_fenced_block = next_in_fenced_block;
627 }
628
629 justified
630}
631
632fn should_justify_message_line(
633 _session: &Session,
634 line: &Line<'static>,
635 max_width: usize,
636 is_last: bool,
637) -> bool {
638 if is_last || max_width == 0 {
639 return false;
640 }
641 if line.spans.len() != 1 {
642 return false;
643 }
644 let text: &str = &line.spans[0].content;
645 if text.trim().is_empty() {
646 return false;
647 }
648 if text.starts_with(char::is_whitespace) {
649 return false;
650 }
651 let trimmed = text.trim();
652 if trimmed.starts_with(|ch: char| ['-', '*', '`', '>', '#'].contains(&ch)) {
653 return false;
654 }
655 if trimmed.contains("```") {
656 return false;
657 }
658 let width = UnicodeWidthStr::width(trimmed);
659 if width >= max_width || width < max_width / 2 {
660 return false;
661 }
662
663 justify_plain_text(text, max_width).is_some()
664}
665
666fn justify_message_line(
667 _session: &Session,
668 line: &Line<'static>,
669 max_width: usize,
670) -> Line<'static> {
671 let span = &line.spans[0];
672 if let Some(justified) = justify_plain_text(&span.content, max_width) {
673 Line::from(justified).style(span.style)
674 } else {
675 line.clone()
676 }
677}
678
679fn is_diff_line(_session: &Session, line: &Line<'static>) -> bool {
680 if line.spans.is_empty() {
684 return false;
685 }
686
687 let has_bg_color = line.spans.iter().any(|span| span.style.bg.is_some());
689 if !has_bg_color {
690 return false;
691 }
692
693 let first_span_char = line.spans[0].content.chars().next();
695 matches!(first_span_char, Some('+') | Some('-') | Some(' '))
696}
697
698fn pad_diff_line(_session: &Session, line: &Line<'static>, max_width: usize) -> Line<'static> {
699 if max_width == 0 || line.spans.is_empty() {
700 return line.clone();
701 }
702
703 let line_width: usize = line
705 .spans
706 .iter()
707 .map(|s| {
708 s.content
709 .chars()
710 .map(|ch| unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1))
711 .sum::<usize>()
712 })
713 .sum();
714
715 let padding_needed = max_width.saturating_sub(line_width);
716
717 if padding_needed == 0 {
718 return line.clone();
719 }
720
721 let padding_style = line
722 .spans
723 .iter()
724 .find_map(|span| span.style.bg)
725 .map(|bg| Style::default().bg(bg))
726 .unwrap_or_default();
727
728 let mut new_spans = Vec::with_capacity(line.spans.len() + 1);
729 new_spans.extend(line.spans.iter().cloned());
730 new_spans.push(Span::styled(" ".repeat(padding_needed), padding_style));
731
732 Line::from(new_spans)
733}
734
735fn prepare_transcript_scroll(
736 session: &mut Session,
737 total_rows: usize,
738 viewport_rows: usize,
739) -> (usize, usize) {
740 let viewport = viewport_rows.max(1);
741 let clamped_total = total_rows.max(1);
742 session.scroll_manager.set_total_rows(clamped_total);
743 session.scroll_manager.set_viewport_rows(viewport as u16);
744 let max_offset = session.scroll_manager.max_offset();
745
746 if session.scroll_manager.offset() > max_offset {
747 session.scroll_manager.set_offset(max_offset);
748 }
749
750 let top_offset = max_offset.saturating_sub(session.scroll_manager.offset());
751 (top_offset, clamped_total)
752}
753
754fn justify_plain_text(text: &str, max_width: usize) -> Option<String> {
756 text_utils::justify_plain_text(text, max_width)
757}