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