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