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