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