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 {
229 frame.render_widget(Clear, scroll_area);
230 session.transcript_content_changed = false;
231 }
232 frame.render_widget(paragraph, scroll_area);
233}
234
235fn header_reserved_rows(session: &Session) -> u16 {
236 session.header_rows.max(ui::INLINE_HEADER_HEIGHT)
237}
238
239fn input_reserved_rows(session: &Session) -> u16 {
240 header_reserved_rows(session) + session.input_height
241}
242
243pub fn recalculate_transcript_rows(session: &mut Session) {
244 let reserved = input_reserved_rows(session).saturating_add(2); let available = session.view_rows.saturating_sub(reserved).max(1);
246 apply_transcript_rows(session, available);
247}
248fn wrap_block_lines(
249 session: &Session,
250 first_prefix: &str,
251 continuation_prefix: &str,
252 content: Vec<Span<'static>>,
253 max_width: usize,
254 border_style: Style,
255) -> Vec<Line<'static>> {
256 wrap_block_lines_with_options(
257 session,
258 first_prefix,
259 continuation_prefix,
260 content,
261 max_width,
262 border_style,
263 true,
264 )
265}
266
267fn wrap_block_lines_no_right_border(
268 session: &Session,
269 first_prefix: &str,
270 continuation_prefix: &str,
271 content: Vec<Span<'static>>,
272 max_width: usize,
273 border_style: Style,
274) -> Vec<Line<'static>> {
275 wrap_block_lines_with_options(
276 session,
277 first_prefix,
278 continuation_prefix,
279 content,
280 max_width,
281 border_style,
282 false,
283 )
284}
285
286fn wrap_block_lines_with_options(
287 session: &Session,
288 first_prefix: &str,
289 continuation_prefix: &str,
290 content: Vec<Span<'static>>,
291 max_width: usize,
292 border_style: Style,
293 show_right_border: bool,
294) -> Vec<Line<'static>> {
295 if max_width < 2 {
296 let fallback = if show_right_border {
297 format!("{}││", first_prefix)
298 } else {
299 format!("{}│", first_prefix)
300 };
301 return vec![Line::from(fallback).style(border_style)];
302 }
303
304 let right_border = if show_right_border {
305 ui::INLINE_BLOCK_BODY_RIGHT
306 } else {
307 ""
308 };
309 let first_prefix_width = first_prefix.chars().count();
310 let continuation_prefix_width = continuation_prefix.chars().count();
311 let prefix_width = first_prefix_width.max(continuation_prefix_width);
312 let border_width = right_border.chars().count();
313 let consumed_width = prefix_width.saturating_add(border_width);
314 let content_width = max_width.saturating_sub(consumed_width);
315
316 if max_width == usize::MAX {
317 let mut spans = vec![Span::styled(first_prefix.to_owned(), border_style)];
318 spans.extend(content);
319 if show_right_border {
320 spans.push(Span::styled(right_border.to_owned(), border_style));
321 }
322 return vec![Line::from(spans)];
323 }
324
325 let mut wrapped = wrap_line(session, Line::from(content), content_width);
326 if wrapped.is_empty() {
327 wrapped.push(Line::default());
328 }
329
330 for (idx, line) in wrapped.iter_mut().enumerate() {
332 let line_width = line.spans.iter().map(|s| s.width()).sum::<usize>();
333 let padding = if show_right_border {
334 content_width.saturating_sub(line_width)
335 } else {
336 0
337 };
338
339 let active_prefix = if idx == 0 {
340 first_prefix
341 } else {
342 continuation_prefix
343 };
344 let mut new_spans = vec![Span::styled(active_prefix.to_owned(), border_style)];
345 new_spans.append(&mut line.spans);
346 if padding > 0 {
347 new_spans.push(Span::styled(" ".repeat(padding), Style::default()));
348 }
349 if show_right_border {
350 new_spans.push(Span::styled(right_border.to_owned(), border_style));
351 }
352 line.spans = new_spans;
353 }
354
355 wrapped
356}
357
358fn pty_block_has_content(session: &Session, index: usize) -> bool {
359 if session.lines.is_empty() {
360 return false;
361 }
362
363 let mut start = index;
364 while start > 0 {
365 let Some(previous) = session.lines.get(start - 1) else {
366 break;
367 };
368 if previous.kind != InlineMessageKind::Pty {
369 break;
370 }
371 start -= 1;
372 }
373
374 let mut end = index;
375 while end + 1 < session.lines.len() {
376 let Some(next) = session.lines.get(end + 1) else {
377 break;
378 };
379 if next.kind != InlineMessageKind::Pty {
380 break;
381 }
382 end += 1;
383 }
384
385 if start > end || end >= session.lines.len() {
386 tracing::warn!(
387 "invalid range: start={}, end={}, len={}",
388 start,
389 end,
390 session.lines.len()
391 );
392 return false;
393 }
394
395 for line in &session.lines[start..=end] {
396 if line
397 .segments
398 .iter()
399 .any(|segment| !segment.text.trim().is_empty())
400 {
401 return true;
402 }
403 }
404
405 false
406}
407
408fn reflow_pty_lines(session: &Session, index: usize, width: u16) -> Vec<Line<'static>> {
409 let Some(line) = session.lines.get(index) else {
410 return vec![Line::default()];
411 };
412
413 let max_width = if width == 0 {
414 usize::MAX
415 } else {
416 width as usize
417 };
418
419 if !pty_block_has_content(session, index) {
420 return Vec::new();
421 }
422
423 let mut border_style = ratatui_style_from_inline(
424 &session.styles.tool_border_style(),
425 session.theme.foreground,
426 );
427 border_style = border_style.add_modifier(Modifier::DIM);
428
429 let prev_is_pty = index
430 .checked_sub(1)
431 .and_then(|prev| session.lines.get(prev))
432 .map(|prev| prev.kind == InlineMessageKind::Pty)
433 .unwrap_or(false);
434
435 let is_start = !prev_is_pty;
436
437 let mut lines = Vec::new();
438
439 let mut combined = String::new();
440 for segment in &line.segments {
441 combined.push_str(segment.text.as_str());
442 }
443 if is_start && combined.trim().is_empty() {
444 return Vec::new();
445 }
446
447 let fallback = text_fallback(session, InlineMessageKind::Pty).or(session.theme.foreground);
449 let mut body_spans = Vec::new();
450 for segment in &line.segments {
451 let stripped_text = strip_ansi_codes(&segment.text);
452 let mut style = ratatui_style_from_inline(&segment.style, fallback);
453 style = style.add_modifier(Modifier::DIM);
454 body_spans.push(Span::styled(stripped_text.into_owned(), style));
455 }
456
457 let is_thinking_spinner = combined.contains("Thinking...");
459
460 if is_start {
461 if is_thinking_spinner {
463 lines.extend(wrap_block_lines_no_right_border(
465 session,
466 "",
467 "",
468 body_spans,
469 max_width,
470 border_style,
471 ));
472 } else {
473 let body_prefix = " ";
474 let continuation_prefix =
475 text_utils::pty_wrapped_continuation_prefix(body_prefix, combined.as_str());
476 lines.extend(wrap_block_lines_no_right_border(
477 session,
478 body_prefix,
479 continuation_prefix.as_str(),
480 body_spans,
481 max_width,
482 border_style,
483 ));
484 }
485 } else {
486 let body_prefix = " ";
487 let continuation_prefix =
488 text_utils::pty_wrapped_continuation_prefix(body_prefix, combined.as_str());
489 lines.extend(wrap_block_lines_no_right_border(
490 session,
491 body_prefix,
492 continuation_prefix.as_str(),
493 body_spans,
494 max_width,
495 border_style,
496 ));
497 }
498
499 if lines.is_empty() {
500 lines.push(Line::default());
501 }
502
503 lines
504}
505
506fn message_divider_line(session: &Session, width: usize, kind: InlineMessageKind) -> Line<'static> {
507 if width == 0 {
508 return Line::default();
509 }
510
511 let content = ui::INLINE_USER_MESSAGE_DIVIDER_SYMBOL.repeat(width);
512 let style = message_divider_style(session, kind);
513 Line::from(content).style(style)
514}
515
516fn message_divider_style(session: &Session, kind: InlineMessageKind) -> Style {
517 session.styles.message_divider_style(kind)
518}
519
520fn wrap_line(_session: &Session, line: Line<'static>, max_width: usize) -> Vec<Line<'static>> {
521 text_utils::wrap_line(line, max_width)
522}
523
524fn justify_wrapped_lines(
525 session: &Session,
526 lines: Vec<Line<'static>>,
527 max_width: usize,
528 kind: InlineMessageKind,
529) -> Vec<Line<'static>> {
530 if max_width == 0 {
531 return lines;
532 }
533
534 let total = lines.len();
535 let mut justified = Vec::with_capacity(total);
536 let mut in_fenced_block = false;
537 for (index, line) in lines.into_iter().enumerate() {
538 let is_last = index + 1 == total;
539 let mut next_in_fenced_block = in_fenced_block;
540 let is_fence_line = {
541 let line_text_storage: std::borrow::Cow<'_, str> = if line.spans.len() == 1 {
542 std::borrow::Cow::Borrowed(&*line.spans[0].content)
543 } else {
544 std::borrow::Cow::Owned(
545 line.spans
546 .iter()
547 .map(|span| &*span.content)
548 .collect::<String>(),
549 )
550 };
551 let line_text: &str = &line_text_storage;
552 let trimmed_start = line_text.trim_start();
553 trimmed_start.starts_with("```") || trimmed_start.starts_with("~~~")
554 };
555 if is_fence_line {
556 next_in_fenced_block = !in_fenced_block;
557 }
558
559 let processed_line = if is_diff_line(session, &line) {
561 pad_diff_line(session, &line, max_width)
562 } else if kind == InlineMessageKind::Agent
563 && !in_fenced_block
564 && !is_fence_line
565 && should_justify_message_line(session, &line, max_width, is_last)
566 {
567 justify_message_line(session, &line, max_width)
568 } else {
569 line
570 };
571
572 justified.push(processed_line);
573 in_fenced_block = next_in_fenced_block;
574 }
575
576 justified
577}
578
579fn should_justify_message_line(
580 _session: &Session,
581 line: &Line<'static>,
582 max_width: usize,
583 is_last: bool,
584) -> bool {
585 if is_last || max_width == 0 {
586 return false;
587 }
588 if line.spans.len() != 1 {
589 return false;
590 }
591 let text: &str = &line.spans[0].content;
592 if text.trim().is_empty() {
593 return false;
594 }
595 if text.starts_with(char::is_whitespace) {
596 return false;
597 }
598 let trimmed = text.trim();
599 if trimmed.starts_with(|ch: char| ['-', '*', '`', '>', '#'].contains(&ch)) {
600 return false;
601 }
602 if trimmed.contains("```") {
603 return false;
604 }
605 let width = UnicodeWidthStr::width(trimmed);
606 if width >= max_width || width < max_width / 2 {
607 return false;
608 }
609
610 justify_plain_text(text, max_width).is_some()
611}
612
613fn justify_message_line(
614 _session: &Session,
615 line: &Line<'static>,
616 max_width: usize,
617) -> Line<'static> {
618 let span = &line.spans[0];
619 if let Some(justified) = justify_plain_text(&span.content, max_width) {
620 Line::from(justified).style(span.style)
621 } else {
622 line.clone()
623 }
624}
625
626fn is_diff_line(_session: &Session, line: &Line<'static>) -> bool {
627 if line.spans.is_empty() {
631 return false;
632 }
633
634 let has_bg_color = line.spans.iter().any(|span| span.style.bg.is_some());
636 if !has_bg_color {
637 return false;
638 }
639
640 let first_span_char = line.spans[0].content.chars().next();
642 matches!(first_span_char, Some('+') | Some('-') | Some(' '))
643}
644
645fn pad_diff_line(_session: &Session, line: &Line<'static>, max_width: usize) -> Line<'static> {
646 if max_width == 0 || line.spans.is_empty() {
647 return line.clone();
648 }
649
650 let line_width: usize = line
652 .spans
653 .iter()
654 .map(|s| {
655 s.content
656 .chars()
657 .map(|ch| unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1))
658 .sum::<usize>()
659 })
660 .sum();
661
662 let padding_needed = max_width.saturating_sub(line_width);
663
664 if padding_needed == 0 {
665 return line.clone();
666 }
667
668 let padding_style = line
669 .spans
670 .iter()
671 .find_map(|span| span.style.bg)
672 .map(|bg| Style::default().bg(bg))
673 .unwrap_or_default();
674
675 let mut new_spans = Vec::with_capacity(line.spans.len() + 1);
676 new_spans.extend(line.spans.iter().cloned());
677 new_spans.push(Span::styled(" ".repeat(padding_needed), padding_style));
678
679 Line::from(new_spans)
680}
681
682fn prepare_transcript_scroll(
683 session: &mut Session,
684 total_rows: usize,
685 viewport_rows: usize,
686) -> (usize, usize) {
687 let viewport = viewport_rows.max(1);
688 let clamped_total = total_rows.max(1);
689 session.scroll_manager.set_total_rows(clamped_total);
690 session.scroll_manager.set_viewport_rows(viewport as u16);
691 let max_offset = session.scroll_manager.max_offset();
692
693 if session.scroll_manager.offset() > max_offset {
694 session.scroll_manager.set_offset(max_offset);
695 }
696
697 let top_offset = max_offset.saturating_sub(session.scroll_manager.offset());
698 (top_offset, clamped_total)
699}
700
701fn justify_plain_text(text: &str, max_width: usize) -> Option<String> {
703 text_utils::justify_plain_text(text, max_width)
704}