1use super::*;
2use unicode_width::UnicodeWidthChar;
3
4pub(super) fn clamp_to_terminal_height(mut output: String, term_height: usize) -> String {
10 if term_height == 0 {
11 output.clear();
12 return output;
13 }
14 let max_newlines = term_height.saturating_sub(1);
15
16 let bytes = output.as_bytes();
20 let mut pos = 0;
21 for _ in 0..max_newlines {
22 match memchr::memchr(b'\n', &bytes[pos..]) {
23 Some(offset) => pos += offset + 1,
24 None => return output, }
26 }
27 if let Some(offset) = memchr::memchr(b'\n', &bytes[pos..]) {
31 output.truncate(pos + offset);
32 }
33 output
34}
35
36pub(super) fn normalize_raw_terminal_newlines(input: String) -> String {
37 if !input.contains('\n') {
38 return input;
39 }
40
41 let bytes = input.as_bytes();
42 let mut out = String::with_capacity(input.len() + 16);
43 let mut cursor = 0usize;
44
45 for newline_idx in memchr::memchr_iter(b'\n', bytes) {
47 out.push_str(&input[cursor..newline_idx]);
48 if newline_idx == 0 || bytes[newline_idx - 1] != b'\r' {
49 out.push('\r');
50 }
51 out.push('\n');
52 cursor = newline_idx + 1;
53 }
54
55 out.push_str(&input[cursor..]);
56 out
57}
58
59fn wrapped_line_segments(line: &str, max_width: usize) -> Vec<&str> {
64 if max_width == 0 || line.is_empty() {
65 return vec![line];
66 }
67
68 let mut segments = Vec::new();
69 let mut segment_start = 0usize;
70 let mut segment_width = 0usize;
71
72 for (idx, ch) in line.char_indices() {
73 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
74 if segment_width + ch_width > max_width && idx > segment_start {
75 segments.push(&line[segment_start..idx]);
76 segment_start = idx;
77 segment_width = 0;
78 }
79 segment_width += ch_width;
80 }
81
82 segments.push(&line[segment_start..]);
83 segments
84}
85
86#[inline]
87fn starts_with_unordered_list_marker(trimmed: &str) -> bool {
88 let bytes = trimmed.as_bytes();
89 bytes.len() >= 2 && matches!(bytes[0], b'-' | b'+' | b'*') && bytes[1].is_ascii_whitespace()
90}
91
92#[inline]
93fn starts_with_ordered_list_marker(trimmed: &str) -> bool {
94 let bytes = trimmed.as_bytes();
95 let mut idx = 0usize;
96 while idx < bytes.len() && bytes[idx].is_ascii_digit() {
97 idx += 1;
98 }
99
100 idx > 0
101 && idx <= 9
102 && (idx + 1) < bytes.len()
103 && matches!(bytes[idx], b'.' | b')')
104 && bytes[idx + 1].is_ascii_whitespace()
105}
106
107#[inline]
108fn is_repeated_marker_line(trimmed: &str, marker: u8) -> bool {
109 let mut marker_count = 0usize;
110 for byte in trimmed.bytes() {
111 if byte == marker {
112 marker_count += 1;
113 } else if !byte.is_ascii_whitespace() {
114 return false;
115 }
116 }
117 marker_count >= 3
118}
119
120#[inline]
121fn has_potential_underscore_emphasis(markdown: &str) -> bool {
122 let bytes = markdown.as_bytes();
123 for (idx, byte) in bytes.iter().enumerate() {
124 if *byte != b'_' {
125 continue;
126 }
127 let prev_alnum = idx
128 .checked_sub(1)
129 .and_then(|i| bytes.get(i))
130 .is_some_and(u8::is_ascii_alphanumeric);
131 let next_alnum = bytes.get(idx + 1).is_some_and(u8::is_ascii_alphanumeric);
132 if !(prev_alnum && next_alnum) {
133 return true;
134 }
135 }
136 false
137}
138
139fn streaming_needs_markdown_renderer(markdown: &str) -> bool {
140 if markdown.as_bytes().iter().any(|byte| {
142 matches!(
143 *byte,
144 b'`' | b'*' | b'[' | b']' | b'<' | b'>' | b'|' | b'!' | b'~' | b'\t'
145 )
146 }) {
147 return true;
148 }
149 if has_potential_underscore_emphasis(markdown) {
150 return true;
151 }
152
153 for line in markdown.lines() {
155 if line.starts_with(" ") || parse_fence_line(line).is_some() {
156 return true;
157 }
158
159 let trimmed = line.trim_start_matches(' ');
160 let leading_spaces = line.len().saturating_sub(trimmed.len());
161 if leading_spaces > 3 || trimmed.is_empty() {
162 if leading_spaces > 3 {
163 return true;
164 }
165 continue;
166 }
167
168 let first = trimmed.as_bytes()[0];
169 if first == b'#'
170 || first == b'>'
171 || starts_with_unordered_list_marker(trimmed)
172 || starts_with_ordered_list_marker(trimmed)
173 || is_repeated_marker_line(trimmed, b'-')
174 || is_repeated_marker_line(trimmed, b'*')
175 || is_repeated_marker_line(trimmed, b'=')
176 {
177 return true;
178 }
179 }
180
181 false
182}
183
184fn append_wrapped_plain_line_to_output(output: &mut String, line: &str, max_width: usize) {
185 if max_width == 0 || line.is_empty() {
186 let _ = writeln!(output, " {line}");
187 return;
188 }
189
190 let mut segment_start = 0usize;
191 let mut segment_width = 0usize;
192 for (idx, ch) in line.char_indices() {
193 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
194 if segment_width + ch_width > max_width && idx > segment_start {
195 let _ = writeln!(output, " {}", &line[segment_start..idx]);
196 segment_start = idx;
197 segment_width = 0;
198 }
199 segment_width += ch_width;
200 }
201
202 let _ = writeln!(output, " {}", &line[segment_start..]);
203}
204
205fn append_streaming_plaintext_to_output(output: &mut String, markdown: &str, max_width: usize) {
206 for line in markdown.split_terminator('\n') {
207 append_wrapped_plain_line_to_output(output, line, max_width);
208 }
209}
210
211fn render_streaming_markdown_with_glamour(
212 markdown: &str,
213 markdown_style: &GlamourStyleConfig,
214 max_width: usize,
215) -> String {
216 let stabilized_markdown = stabilize_streaming_markdown(markdown);
217 glamour::Renderer::new()
218 .with_style_config(markdown_style.clone())
219 .with_word_wrap(max_width)
220 .render(stabilized_markdown.as_ref())
221}
222
223fn parse_fence_line(line: &str) -> Option<(char, usize, &str)> {
224 let trimmed_line = line.trim_end_matches(['\r', '\n']);
225 let leading_spaces = trimmed_line.chars().take_while(|ch| *ch == ' ').count();
226 if leading_spaces > 3 {
227 return None;
228 }
229
230 let trimmed = trimmed_line.get(leading_spaces..)?;
231 let marker = trimmed.chars().next()?;
232 if marker != '`' && marker != '~' {
233 return None;
234 }
235
236 let mut marker_len = 0usize;
237 for ch in trimmed.chars() {
238 if ch == marker {
239 marker_len += 1;
240 } else {
241 break;
242 }
243 }
244
245 if marker_len >= 3 {
246 Some((
247 marker,
248 marker_len,
249 trimmed.get(marker_len..).unwrap_or_default(),
250 ))
251 } else {
252 None
253 }
254}
255
256fn streaming_unclosed_fence(markdown: &str) -> Option<(char, usize)> {
257 let mut open_fence: Option<(char, usize)> = None;
258
259 for line in markdown.lines() {
260 let Some((marker, marker_len, tail)) = parse_fence_line(line) else {
261 continue;
262 };
263
264 if let Some((open_marker, open_len)) = open_fence {
265 if marker == open_marker && marker_len >= open_len && tail.trim().is_empty() {
266 open_fence = None;
267 }
268 } else {
269 if marker == '`' && tail.contains('`') {
271 continue;
272 }
273 open_fence = Some((marker, marker_len));
274 }
275 }
276
277 open_fence
278}
279
280fn stabilize_streaming_markdown(markdown: &str) -> std::borrow::Cow<'_, str> {
281 let Some((marker, marker_len)) = streaming_unclosed_fence(markdown) else {
282 return std::borrow::Cow::Borrowed(markdown);
283 };
284
285 let mut stabilized = String::with_capacity(markdown.len() + marker_len + 1);
288 stabilized.push_str(markdown);
289 if !stabilized.ends_with('\n') {
290 stabilized.push('\n');
291 }
292 for _ in 0..marker_len {
293 stabilized.push(marker);
294 }
295 std::borrow::Cow::Owned(stabilized)
296}
297
298fn format_persistence_footer_segment(
299 mode: crate::session::AutosaveDurabilityMode,
300 metrics: crate::session::AutosaveQueueMetrics,
301) -> String {
302 let mut details = Vec::new();
303 if metrics.pending_mutations > 0 {
304 details.push(format!(
305 "pending {}/{}",
306 metrics.pending_mutations, metrics.max_pending_mutations
307 ));
308 }
309 if metrics.flush_failed > 0 {
310 details.push(format!("flush-fail {}", metrics.flush_failed));
311 }
312 if metrics.max_pending_mutations > 0
313 && metrics.pending_mutations >= metrics.max_pending_mutations
314 {
315 details.push("backpressure".to_string());
316 }
317
318 if details.is_empty() {
319 format!("Persist: {}", mode.as_str())
320 } else {
321 format!("Persist: {} ({})", mode.as_str(), details.join(", "))
322 }
323}
324
325impl PiApp {
326 fn header_binding_hint(&self, action: AppAction, fallback: &str) -> String {
327 self.keybindings
328 .get_bindings(action)
329 .first()
330 .map_or_else(|| fallback.to_string(), std::string::ToString::to_string)
331 }
332
333 fn render_startup_placeholder(&self) -> String {
334 let mut output = String::new();
335 let plain_width = self.term_width.saturating_sub(4).max(1);
336
337 if !self.startup_welcome.trim().is_empty() {
338 for line in self.startup_welcome.lines() {
339 if line.trim().is_empty() {
340 output.push('\n');
341 continue;
342 }
343 for segment in wrapped_line_segments(line, plain_width) {
344 let _ = writeln!(output, "{}", self.styles.muted_italic.render(segment));
345 }
346 }
347 }
348
349 match &self.startup_changelog {
350 Some(StartupChangelog::Condensed { latest_version }) => {
351 if !output.is_empty() && !output.ends_with('\n') {
352 output.push('\n');
353 }
354 let message =
355 format!("Updated to v{latest_version}. Use /changelog to view full changelog.");
356 for segment in wrapped_line_segments(&message, plain_width) {
357 let _ = writeln!(output, " {}", self.styles.warning.render(segment));
358 }
359 }
360 Some(StartupChangelog::Full { markdown }) => {
361 if !output.is_empty() && !output.ends_with('\n') {
362 output.push('\n');
363 }
364 let _ = writeln!(output, " {}", self.styles.accent_bold.render("What's New"));
365 output.push('\n');
366 let rendered = glamour::Renderer::new()
367 .with_style_config(self.markdown_style.clone())
368 .with_word_wrap(self.term_width.saturating_sub(6).max(40))
369 .render(markdown);
370 for line in rendered.lines() {
371 let _ = writeln!(output, " {line}");
372 }
373 }
374 None => {}
375 }
376
377 output.trim_end().to_string()
378 }
379
380 #[allow(clippy::too_many_lines)]
382 pub(super) fn view(&self) -> String {
383 let view_start = if self.frame_timing.enabled {
384 Some(std::time::Instant::now())
385 } else {
386 None
387 };
388
389 let mut output = String::with_capacity(self.render_buffers.view_capacity_hint());
392
393 self.render_header_into(&mut output);
395 output.push('\n');
396
397 if let Some(tree_ui) = &self.tree_ui {
399 output.push_str(&view_tree_ui(tree_ui, &self.styles));
400 self.render_footer_into(&mut output);
401 return output;
402 }
403
404 let conversation_content = {
409 let content_start = if self.frame_timing.enabled {
410 Some(std::time::Instant::now())
411 } else {
412 None
413 };
414 let mut raw = self.build_conversation_content();
415 if let Some(start) = content_start {
416 self.frame_timing
417 .record_content_build(micros_as_u64(start.elapsed().as_micros()));
418 }
419 let trimmed_len = raw.trim_end().len();
422 raw.truncate(trimmed_len);
423 raw
424 };
425
426 let effective_vp = self.view_effective_conversation_height();
431 {
432 use std::borrow::Cow;
435 let viewport_content: Cow<'_, str> = if conversation_content.is_empty() {
436 Cow::Owned(self.render_startup_placeholder())
437 } else {
438 Cow::Borrowed(&conversation_content)
439 };
440
441 let total_lines = memchr::memchr_iter(b'\n', viewport_content.as_bytes()).count() + 1;
445 let start = if self.follow_stream_tail {
446 total_lines.saturating_sub(effective_vp)
447 } else {
448 self.conversation_viewport
449 .y_offset()
450 .min(total_lines.saturating_sub(1))
451 };
452 let end = (start + effective_vp).min(total_lines);
453
454 let mut first = true;
457 for line in viewport_content.lines().skip(start).take(end - start) {
458 if first {
459 first = false;
460 } else {
461 output.push('\n');
462 }
463 output.push_str(line);
464 }
465 output.push('\n');
466
467 if total_lines > effective_vp {
469 let total = total_lines.saturating_sub(effective_vp);
470 let percent = (start * 100).checked_div(total).map_or(100, |p| p.min(100));
471 let indicator = format!(" [{percent}%] ↑/↓ PgUp/PgDn Shift+Up/Down to scroll");
472 output.push_str(&self.styles.muted.render(&indicator));
473 output.push('\n');
474 }
475 }
476 self.render_buffers
479 .return_conversation_buffer(conversation_content);
480
481 if let Some(tool) = &self.current_tool {
483 let progress_str = self.tool_progress.as_ref().map_or_else(String::new, |p| {
484 let secs = p.elapsed_ms / 1000;
485 if secs < 1 {
486 return String::new();
487 }
488 let mut parts = vec![format!("{secs}s")];
489 if p.line_count > 0 {
490 parts.push(format!("{} lines", format_count(p.line_count)));
491 } else if p.byte_count > 0 {
492 parts.push(format!("{} bytes", format_count(p.byte_count)));
493 }
494 if let Some(timeout_ms) = p.timeout_ms {
495 let timeout_s = timeout_ms / 1000;
496 if timeout_s > 0 {
497 parts.push(format!("timeout {timeout_s}s"));
498 }
499 }
500 format!(" ({})", parts.join(" \u{2022} "))
501 });
502 let _ = write!(
503 output,
504 "\n {} {}{} ...\n",
505 self.spinner.view(),
506 self.styles.warning_bold.render(&format!("Running {tool}")),
507 self.styles.muted.render(&progress_str),
508 );
509 }
510
511 if let Some(status) = &self.status_message {
513 let status_style = self.styles.accent.clone().italic();
514 let _ = write!(output, "\n {}\n", status_style.render(status));
515 }
516
517 if let Some(ref picker) = self.session_picker {
519 output.push_str(&self.render_session_picker(picker));
520 }
521
522 if let Some(ref settings_ui) = self.settings_ui {
524 output.push_str(&self.render_settings_ui(settings_ui));
525 }
526
527 if let Some(ref picker) = self.theme_picker {
529 output.push_str(&self.render_theme_picker(picker));
530 }
531
532 if let Some(ref prompt) = self.capability_prompt {
534 output.push_str(&self.render_capability_prompt(prompt));
535 }
536
537 if let Some(ref overlay) = self.extension_custom_overlay {
539 output.push_str(&self.render_extension_custom_overlay(overlay));
540 }
541
542 if let Some(ref picker) = self.branch_picker {
544 output.push_str(&self.render_branch_picker(picker));
545 }
546
547 if let Some(ref selector) = self.model_selector {
549 output.push_str(&self.render_model_selector(selector));
550 }
551
552 if self.editor_input_is_available() {
554 output.push_str(&self.render_input());
555
556 if self.autocomplete.open && !self.autocomplete.items.is_empty() {
558 output.push_str(&self.render_autocomplete_dropdown());
559 }
560 } else if self.agent_state != AgentState::Idle {
561 if self.show_processing_status_spinner() {
562 let _ = write!(
565 output,
566 "\n {} {}\n",
567 self.spinner.view(),
568 self.styles.accent.render("Processing...")
569 );
570 }
571
572 if let Some(pending_queue) = self.render_pending_message_queue() {
573 output.push_str(&pending_queue);
574 }
575 }
576
577 self.render_footer_into(&mut output);
579
580 let output = clamp_to_terminal_height(output, self.term_height);
583 let output = normalize_raw_terminal_newlines(output);
584
585 self.render_buffers.set_view_capacity_hint(output.len());
588
589 if let Some(start) = view_start {
590 self.frame_timing
591 .record_frame(micros_as_u64(start.elapsed().as_micros()));
592 }
593
594 output
595 }
596
597 fn render_header_into(&self, output: &mut String) {
600 let model_label = format!("({})", self.model);
601
602 let branch_indicator = self
604 .session
605 .try_lock()
606 .ok()
607 .and_then(|guard| {
608 let info = guard.branch_summary();
609 if info.leaf_count <= 1 {
610 return None;
611 }
612 let current_idx = info
613 .current_leaf
614 .as_ref()
615 .and_then(|leaf| info.leaves.iter().position(|l| l == leaf))
616 .map_or(1, |i| i + 1);
617 Some(format!(" [branch {current_idx}/{}]", info.leaf_count))
618 })
619 .unwrap_or_default();
620
621 let model_key = self.header_binding_hint(AppAction::SelectModel, "ctrl+l");
622 let next_model_key = self.header_binding_hint(AppAction::CycleModelForward, "ctrl+p");
623 let prev_model_key =
624 self.header_binding_hint(AppAction::CycleModelBackward, "ctrl+shift+p");
625 let tools_key = self.header_binding_hint(AppAction::ExpandTools, "ctrl+o");
626 let thinking_key = self.header_binding_hint(AppAction::CycleThinkingLevel, "shift+tab");
627 let max_width = self.term_width.saturating_sub(2);
628
629 let hints_line = truncate(
630 &format!(
631 "{model_key}: model {next_model_key}: next {prev_model_key}: prev \
632 {tools_key}: tools {thinking_key}: thinking"
633 ),
634 max_width,
635 );
636
637 let resources_line = truncate(
638 &format!(
639 "resources: {} skills, {} prompts, {} themes, {} extensions",
640 self.resources.skills().len(),
641 self.resources.prompts().len(),
642 self.resources.themes().len(),
643 self.resources.extensions().len()
644 ),
645 max_width,
646 );
647
648 let _ = write!(
649 output,
650 " {} {}{}\n {}\n {}\n",
651 self.styles.title.render("Pi"),
652 self.styles.muted.render(&model_label),
653 self.styles.accent.render(&branch_indicator),
654 self.styles.muted.render(&hints_line),
655 self.styles.muted.render(&resources_line),
656 );
657 }
658
659 pub(super) fn render_header(&self) -> String {
660 let mut buf = String::new();
661 self.render_header_into(&mut buf);
662 buf
663 }
664
665 pub(super) fn render_input(&self) -> String {
666 let mut output = String::new();
667
668 let thinking_level = self
669 .session
670 .try_lock()
671 .ok()
672 .and_then(|guard| guard.header.thinking_level.clone())
673 .and_then(|level| level.parse::<ThinkingLevel>().ok())
674 .or_else(|| {
675 self.config
676 .default_thinking_level
677 .as_deref()
678 .and_then(|level| level.parse::<ThinkingLevel>().ok())
679 })
680 .unwrap_or(ThinkingLevel::Off);
681
682 let input_text = self.input.value();
683 let is_bash_mode = parse_bash_command(&input_text).is_some();
684
685 let (thinking_label, thinking_style, thinking_border_style) = match thinking_level {
686 ThinkingLevel::Off => (
687 "off",
688 self.styles.muted_bold.clone(),
689 self.styles.border.clone(),
690 ),
691 ThinkingLevel::Minimal => (
692 "minimal",
693 self.styles.accent.clone(),
694 self.styles.accent.clone(),
695 ),
696 ThinkingLevel::Low => (
697 "low",
698 self.styles.accent.clone(),
699 self.styles.accent.clone(),
700 ),
701 ThinkingLevel::Medium => (
702 "medium",
703 self.styles.accent_bold.clone(),
704 self.styles.accent.clone(),
705 ),
706 ThinkingLevel::High => (
707 "high",
708 self.styles.warning_bold.clone(),
709 self.styles.warning.clone(),
710 ),
711 ThinkingLevel::XHigh => (
712 "xhigh",
713 self.styles.error_bold.clone(),
714 self.styles.error_bold.clone(),
715 ),
716 };
717
718 let thinking_plain = format!("[thinking: {thinking_label}]");
719 let thinking_badge = thinking_style.render(&thinking_plain);
720 let bash_badge = is_bash_mode.then(|| self.styles.warning_bold.render("[bash]"));
721
722 let max_width = self.term_width.saturating_sub(2);
723 let reserved = 2
724 + thinking_plain.chars().count()
725 + if is_bash_mode {
726 2 + "[bash]".chars().count()
727 } else {
728 0
729 };
730 let available_for_mode = max_width.saturating_sub(reserved);
731 let mut mode_text = match self.input_mode {
732 InputMode::SingleLine => "Enter: send Shift+Enter: newline Alt+Enter: multi-line",
733 InputMode::MultiLine => "Alt+Enter: send Enter: newline Esc: single-line",
734 }
735 .to_string();
736 if mode_text.chars().count() > available_for_mode {
737 mode_text = truncate(&mode_text, available_for_mode);
738 }
739 let mut header_line = String::new();
740 header_line.push_str(&self.styles.muted.render(&mode_text));
741 header_line.push_str(" ");
742 header_line.push_str(&thinking_badge);
743 if let Some(bash_badge) = bash_badge {
744 header_line.push_str(" ");
745 header_line.push_str(&bash_badge);
746 }
747 let _ = writeln!(output, "\n {header_line}");
748
749 let padding = " ".repeat(self.editor_padding_x);
750 let line_prefix = format!(" {padding}");
751 let border_style = if is_bash_mode {
752 self.styles.warning_bold.clone()
753 } else {
754 thinking_border_style
755 };
756 let border = border_style.render("│");
757 for line in self.input.view().lines() {
758 output.push_str(&line_prefix);
759 output.push_str(&border);
760 output.push(' ');
761 output.push_str(line);
762 output.push('\n');
763 }
764
765 output
766 }
767
768 fn render_footer_into(&self, output: &mut String) {
771 let total_cost = self.total_usage.cost.total;
772 let cost_str = if total_cost > 0.0 {
773 format!(" (${total_cost:.4})")
774 } else {
775 String::new()
776 };
777
778 let input = self.total_usage.input;
779 let output_tokens = self.total_usage.output;
780 let persistence_str = self.session.try_lock().ok().map_or_else(
781 || "Persist: unavailable".to_string(),
782 |session| {
783 format_persistence_footer_segment(
784 session.autosave_durability_mode(),
785 session.autosave_metrics(),
786 )
787 },
788 );
789 let branch_str = self
790 .vcs_info
791 .as_ref()
792 .map_or_else(String::new, |b| format!(" | {b}"));
793 let mode_hint = match self.input_mode {
794 InputMode::SingleLine => "Shift+Enter: newline | Alt+Enter: multi-line",
795 InputMode::MultiLine => "Enter: newline | Alt+Enter: send | Esc: single-line",
796 };
797 let footer_long = format!(
798 "Tokens: {input} in / {output_tokens} out{cost_str}{branch_str} | {persistence_str} | {mode_hint} | /help | Ctrl+C: quit"
799 );
800 let footer_short = format!(
801 "Tokens: {input} in / {output_tokens} out{cost_str}{branch_str} | {persistence_str} | /help | Ctrl+C: quit"
802 );
803 let max_width = self.term_width.saturating_sub(2);
804 let mut footer = if footer_long.chars().count() <= max_width {
805 footer_long
806 } else {
807 footer_short
808 };
809 if footer.chars().count() > max_width {
810 footer = truncate(&footer, max_width);
811 }
812 let _ = write!(output, "\n {}\n", self.styles.muted.render(&footer));
813 }
814
815 pub(super) fn render_footer(&self) -> String {
816 let mut buf = String::new();
817 self.render_footer_into(&mut buf);
818 buf
819 }
820
821 fn render_single_message(&self, msg: &ConversationMessage) -> String {
823 let mut output = String::new();
824 match msg.role {
825 MessageRole::User => {
826 let _ = write!(
827 output,
828 "\n {} {}\n",
829 self.styles.accent_bold.render("You:"),
830 msg.content
831 );
832 }
833 MessageRole::Assistant => {
834 let _ = write!(
835 output,
836 "\n {}\n",
837 self.styles.success_bold.render("Assistant:")
838 );
839
840 if self.thinking_visible {
842 if let Some(thinking) = &msg.thinking {
843 let truncated = truncate(thinking, 100);
844 let _ = writeln!(
845 output,
846 " {}",
847 self.styles
848 .muted_italic
849 .render(&format!("Thinking: {truncated}"))
850 );
851 }
852 }
853
854 let rendered = glamour::Renderer::new()
856 .with_style_config(self.markdown_style.clone())
857 .with_word_wrap(self.term_width.saturating_sub(6).max(40))
858 .render(&msg.content);
859 for line in rendered.lines() {
860 let _ = writeln!(output, " {line}");
861 }
862 }
863 MessageRole::Tool => {
864 let show_expanded = self.tools_expanded && !msg.collapsed;
866 if show_expanded {
867 let rendered = render_tool_message(&msg.content, &self.styles);
868 let _ = write!(output, "\n {rendered}\n");
869 } else {
870 let header = msg.content.lines().next().unwrap_or("Tool output");
871 let line_count = memchr::memchr_iter(b'\n', msg.content.as_bytes()).count() + 1;
872 let summary = format!(
873 "\u{25b6} {} ({line_count} lines, collapsed)",
874 header.trim_end()
875 );
876 let _ = write!(
877 output,
878 "\n {}\n",
879 self.styles.muted_italic.render(&summary)
880 );
881 if self.tools_expanded && msg.collapsed {
883 for (i, line) in msg.content.lines().skip(1).enumerate() {
884 if i >= TOOL_COLLAPSE_PREVIEW_LINES {
885 let remaining = line_count
886 .saturating_sub(1)
887 .saturating_sub(TOOL_COLLAPSE_PREVIEW_LINES);
888 let _ = writeln!(
889 output,
890 " {}",
891 self.styles
892 .muted
893 .render(&format!(" ... {remaining} more lines"))
894 );
895 break;
896 }
897 let _ = writeln!(
898 output,
899 " {}",
900 self.styles.muted.render(&format!(" {line}"))
901 );
902 }
903 }
904 }
905 }
906 MessageRole::System => {
907 let _ = write!(output, "\n {}\n", self.styles.warning.render(&msg.content));
908 }
909 }
910 output
911 }
912
913 pub fn build_conversation_content(&self) -> String {
920 let has_streaming_state =
921 !self.current_response.is_empty() || !self.current_thinking.is_empty();
922 let has_visible_streaming_tail = !self.current_response.is_empty()
923 || (self.thinking_visible && !self.current_thinking.is_empty());
924
925 let mut output = self.render_buffers.take_conversation_buffer();
929
930 if has_streaming_state && self.message_render_cache.prefix_valid(self.messages.len()) {
933 self.message_render_cache.prefix_append_to(&mut output);
936 if has_visible_streaming_tail {
937 self.append_streaming_tail(&mut output);
938 }
939 return output;
940 }
941
942 for (index, msg) in self.messages.iter().enumerate() {
944 let key =
945 MessageRenderCache::compute_key(msg, self.thinking_visible, self.tools_expanded);
946
947 if self
948 .message_render_cache
949 .append_cached(&mut output, index, &key)
950 {
951 continue;
952 }
953 let rendered = self.render_single_message(msg);
954 output.push_str(&rendered);
957 self.message_render_cache.put(index, key, rendered);
958 }
959
960 self.message_render_cache
962 .prefix_set(&output, self.messages.len());
963
964 if has_visible_streaming_tail {
966 self.append_streaming_tail(&mut output);
967 }
968
969 output
970 }
971
972 fn append_streaming_tail(&self, output: &mut String) {
975 let _ = write!(
976 output,
977 "\n {}\n",
978 self.styles.success_bold.render("Assistant:")
979 );
980
981 let content_width = self.term_width.saturating_sub(4).max(1);
982
983 if self.thinking_visible && !self.current_thinking.is_empty() {
985 let truncated = truncate(&self.current_thinking, 100);
986 let thinking_line = format!("Thinking: {truncated}");
987 for segment in wrapped_line_segments(&thinking_line, content_width) {
988 let _ = writeln!(output, " {}", self.styles.muted_italic.render(segment));
989 }
990 }
991
992 if !self.current_response.is_empty() {
995 let markdown_width = self.term_width.saturating_sub(6).max(40);
996 if streaming_needs_markdown_renderer(&self.current_response) {
997 let rendered = render_streaming_markdown_with_glamour(
998 &self.current_response,
999 &self.markdown_style,
1000 markdown_width,
1001 );
1002 for line in rendered.lines() {
1003 let _ = writeln!(output, " {line}");
1004 }
1005 } else {
1006 append_streaming_plaintext_to_output(
1007 output,
1008 &self.current_response,
1009 markdown_width,
1010 );
1011 }
1012 }
1013 }
1014
1015 pub(super) fn render_pending_message_queue(&self) -> Option<String> {
1016 if self.agent_state == AgentState::Idle {
1017 return None;
1018 }
1019
1020 let Ok(queue) = self.message_queue.lock() else {
1021 return None;
1022 };
1023
1024 let steering_len = queue.steering_len();
1025 let follow_len = queue.follow_up_len();
1026 if steering_len == 0 && follow_len == 0 {
1027 return None;
1028 }
1029
1030 let max_preview = self.term_width.saturating_sub(24).max(20);
1031
1032 let mut out = String::new();
1033 out.push_str("\n ");
1034 out.push_str(&self.styles.muted_bold.render("Pending:"));
1035 out.push(' ');
1036 out.push_str(
1037 &self
1038 .styles
1039 .accent_bold
1040 .render(&format!("{steering_len} steering")),
1041 );
1042 out.push_str(&self.styles.muted.render(", "));
1043 out.push_str(&self.styles.muted.render(&format!("{follow_len} follow-up")));
1044 out.push('\n');
1045
1046 if let Some(text) = queue.steering_front() {
1047 let preview = queued_message_preview(text, max_preview);
1048 out.push_str(" ");
1049 out.push_str(&self.styles.accent_bold.render("steering →"));
1050 out.push(' ');
1051 out.push_str(&preview);
1052 out.push('\n');
1053 }
1054
1055 if let Some(text) = queue.follow_up_front() {
1056 let preview = queued_message_preview(text, max_preview);
1057 out.push_str(" ");
1058 out.push_str(&self.styles.muted_bold.render("follow-up →"));
1059 out.push(' ');
1060 out.push_str(&self.styles.muted.render(&preview));
1061 out.push('\n');
1062 }
1063
1064 Some(out)
1065 }
1066
1067 #[allow(clippy::too_many_lines)]
1068 pub(super) fn render_autocomplete_dropdown(&self) -> String {
1069 let mut output = String::new();
1070
1071 let offset = self.autocomplete.scroll_offset();
1072 let max_dropdown_rows = self.term_height.saturating_sub(
1075 4 + 1 + 1 + 2 + self.input.height() + 2 + 5,
1078 );
1079 let visible_count = self
1080 .autocomplete
1081 .max_visible
1082 .min(self.autocomplete.items.len())
1083 .min(max_dropdown_rows.max(1));
1084 let end = (offset + visible_count).min(self.autocomplete.items.len());
1085
1086 let border_style = &self.styles.border;
1088 let selected_style = &self.styles.selection;
1089 let kind_style = &self.styles.warning;
1090 let desc_style = &self.styles.muted_italic;
1091
1092 let width = 60;
1094 let _ = write!(
1095 output,
1096 "\n {}",
1097 border_style.render(&format!("┌{:─<width$}┐", ""))
1098 );
1099
1100 for (idx, item) in self.autocomplete.items[offset..end].iter().enumerate() {
1101 use unicode_width::UnicodeWidthStr;
1102 let global_idx = offset + idx;
1103 let is_selected = self.autocomplete.selected == Some(global_idx);
1104
1105 let kind_icon = match item.kind {
1106 AutocompleteItemKind::SlashCommand => "⚡",
1107 AutocompleteItemKind::ExtensionCommand => "🧩",
1108 AutocompleteItemKind::PromptTemplate => "📄",
1109 AutocompleteItemKind::Skill => "🔧",
1110 AutocompleteItemKind::Model => "🤖",
1111 AutocompleteItemKind::File => "📁",
1112 AutocompleteItemKind::Path => "📂",
1113 };
1114
1115 let max_label_len = width.saturating_sub(6);
1116 let label_width = item.label.width();
1117 let label = if label_width > max_label_len {
1118 let mut out = String::with_capacity(max_label_len);
1119 let mut current_width = 0;
1120 for c in item.label.chars() {
1121 let w = c.width().unwrap_or(0);
1122 if current_width + w > max_label_len {
1123 while current_width > max_label_len.saturating_sub(1) {
1124 if let Some(last) = out.pop() {
1125 current_width -= last.width().unwrap_or(0);
1126 } else {
1127 break;
1128 }
1129 }
1130 out.push('…');
1131 break;
1132 }
1133 out.push(c);
1134 current_width += w;
1135 }
1136 out
1137 } else {
1138 item.label.clone()
1139 };
1140
1141 let actual_label_width = label.width();
1142 let padding = " ".repeat(max_label_len.saturating_sub(actual_label_width));
1143 let line_content = format!("{kind_icon} {label}{padding}");
1144 let styled_line = if is_selected {
1145 selected_style.render(&line_content)
1146 } else {
1147 format!("{} {label}{padding}", kind_style.render(kind_icon))
1148 };
1149
1150 let _ = write!(
1151 output,
1152 "\n {}{}{}",
1153 border_style.render("│"),
1154 styled_line,
1155 border_style.render("│")
1156 );
1157
1158 if is_selected {
1159 if let Some(desc) = &item.description {
1160 let max_desc_len = width.saturating_sub(4);
1161 let desc_width = desc.width();
1162 let truncated_desc = if desc_width > max_desc_len {
1163 let mut out = String::with_capacity(max_desc_len);
1164 let mut current_width = 0;
1165 for c in desc.chars() {
1166 let c = if c == '\n' { ' ' } else { c };
1167 let w = c.width().unwrap_or(0);
1168 if current_width + w > max_desc_len {
1169 while current_width > max_desc_len.saturating_sub(1) {
1170 if let Some(last) = out.pop() {
1171 current_width -= last.width().unwrap_or(0);
1172 } else {
1173 break;
1174 }
1175 }
1176 out.push('…');
1177 break;
1178 }
1179 out.push(c);
1180 current_width += w;
1181 }
1182 out
1183 } else {
1184 desc.replace('\n', " ")
1185 };
1186
1187 let _ = write!(
1188 output,
1189 "\n {} {}{}",
1190 border_style.render("│"),
1191 desc_style.render(&truncated_desc),
1192 border_style.render(&format!(
1193 "{:>pad$}│",
1194 "",
1195 pad = width
1196 .saturating_sub(2)
1197 .saturating_sub(truncated_desc.width())
1198 ))
1199 );
1200 }
1201 }
1202 }
1203
1204 if self.autocomplete.items.len() > visible_count {
1205 let shown = format!(
1206 "{}-{} of {}",
1207 offset + 1,
1208 end,
1209 self.autocomplete.items.len()
1210 );
1211 let _ = write!(
1212 output,
1213 "\n {}",
1214 border_style.render(&format!("│{shown:^width$}│"))
1215 );
1216 }
1217
1218 let _ = write!(
1219 output,
1220 "\n {}",
1221 border_style.render(&format!("└{:─<width$}┘", ""))
1222 );
1223
1224 let _ = write!(
1225 output,
1226 "\n {}",
1227 self.styles
1228 .muted_italic
1229 .render("↑/↓ navigate Enter/Tab accept Esc cancel")
1230 );
1231
1232 output
1233 }
1234
1235 #[allow(clippy::too_many_lines)]
1236 pub(super) fn render_session_picker(&self, picker: &SessionPickerOverlay) -> String {
1237 let mut output = String::new();
1238
1239 let _ = writeln!(
1240 output,
1241 "\n {}\n",
1242 self.styles.title.render("Select a session to resume")
1243 );
1244
1245 let query = picker.query();
1246 let search_line = if query.is_empty() {
1247 " > (type to filter sessions)".to_string()
1248 } else {
1249 format!(" > {query}")
1250 };
1251 let _ = writeln!(output, "{}", self.styles.muted.render(&search_line));
1252 let _ = writeln!(
1253 output,
1254 " {}",
1255 self.styles.muted.render("─".repeat(50).as_str())
1256 );
1257 output.push('\n');
1258
1259 if picker.sessions.is_empty() {
1260 let message = if picker.has_query() {
1261 "No sessions match the current filter."
1262 } else {
1263 "No sessions found for this project."
1264 };
1265 let _ = writeln!(output, " {}", self.styles.muted.render(message));
1266 } else {
1267 let _ = writeln!(
1268 output,
1269 " {:<20} {:<30} {:<8} {}",
1270 self.styles.muted_bold.render("Time"),
1271 self.styles.muted_bold.render("Name"),
1272 self.styles.muted_bold.render("Messages"),
1273 self.styles.muted_bold.render("Session ID")
1274 );
1275 output.push_str(" ");
1276 output.push_str(&"-".repeat(78));
1277 output.push('\n');
1278
1279 let offset = picker.scroll_offset();
1280 let visible_count = picker.max_visible.min(picker.sessions.len());
1281 let end = (offset + visible_count).min(picker.sessions.len());
1282
1283 for (idx, session) in picker.sessions[offset..end].iter().enumerate() {
1284 let global_idx = offset + idx;
1285 let is_selected = global_idx == picker.selected;
1286
1287 let prefix = if is_selected { ">" } else { " " };
1288 let time = crate::session_picker::format_time(&session.timestamp);
1289 let name = session
1290 .name
1291 .as_deref()
1292 .unwrap_or("-")
1293 .chars()
1294 .take(28)
1295 .collect::<String>();
1296 let messages = session.message_count.to_string();
1297 let id = crate::session_picker::truncate_session_id(&session.id, 8);
1298
1299 let row = format!(" {time:<20} {name:<30} {messages:<8} {id}");
1300 let rendered = if is_selected {
1301 self.styles.selection.render(&row)
1302 } else {
1303 row
1304 };
1305
1306 let _ = writeln!(output, "{prefix} {rendered}");
1307 }
1308
1309 if picker.sessions.len() > visible_count {
1310 let _ = writeln!(
1311 output,
1312 " {}",
1313 self.styles.muted.render(&format!(
1314 "({}-{} of {})",
1315 offset + 1,
1316 end,
1317 picker.sessions.len()
1318 ))
1319 );
1320 }
1321 }
1322
1323 output.push('\n');
1324 if picker.confirm_delete {
1325 let _ = writeln!(
1326 output,
1327 " {}",
1328 self.styles.warning_bold.render(
1329 picker
1330 .status_message
1331 .as_deref()
1332 .unwrap_or("Delete session? Press y/n to confirm."),
1333 )
1334 );
1335 } else {
1336 let _ = writeln!(
1337 output,
1338 " {}",
1339 self.styles.muted_italic.render(
1340 "Type: filter Backspace: clear ↑/↓/j/k/PgUp/PgDn: navigate Enter: select Ctrl+D: delete Esc/q: cancel",
1341 )
1342 );
1343 if let Some(message) = &picker.status_message {
1344 let _ = writeln!(output, " {}", self.styles.warning_bold.render(message));
1345 }
1346 }
1347
1348 output
1349 }
1350
1351 pub(super) fn render_settings_ui(&self, settings_ui: &SettingsUiState) -> String {
1352 let mut output = String::new();
1353
1354 let _ = writeln!(output, "\n {}\n", self.styles.title.render("Settings"));
1355
1356 if settings_ui.entries.is_empty() {
1357 let _ = writeln!(
1358 output,
1359 " {}",
1360 self.styles.muted.render("No settings available.")
1361 );
1362 } else {
1363 let offset = settings_ui.scroll_offset();
1364 let visible_count = settings_ui.max_visible.min(settings_ui.entries.len());
1365 let end = (offset + visible_count).min(settings_ui.entries.len());
1366
1367 for (idx, entry) in settings_ui.entries[offset..end].iter().enumerate() {
1368 let global_idx = offset + idx;
1369 let is_selected = global_idx == settings_ui.selected;
1370
1371 let prefix = if is_selected { ">" } else { " " };
1372 let label = match *entry {
1373 SettingsUiEntry::Summary => "Summary".to_string(),
1374 SettingsUiEntry::Theme => "Theme".to_string(),
1375 SettingsUiEntry::SteeringMode => format!(
1376 "steeringMode: {}",
1377 self.config.steering_queue_mode().as_str()
1378 ),
1379 SettingsUiEntry::FollowUpMode => format!(
1380 "followUpMode: {}",
1381 self.config.follow_up_queue_mode().as_str()
1382 ),
1383 SettingsUiEntry::DefaultPermissive => format!(
1384 "extensionPolicy.defaultPermissive: {}{}",
1385 bool_label(self.effective_default_permissive()),
1386 if self.default_permissive_changes_require_extension_restart() {
1387 " (restart required)"
1388 } else {
1389 ""
1390 }
1391 ),
1392 SettingsUiEntry::QuietStartup => format!(
1393 "quietStartup: {}",
1394 bool_label(self.config.quiet_startup.unwrap_or(false))
1395 ),
1396 SettingsUiEntry::CollapseChangelog => format!(
1397 "collapseChangelog: {}",
1398 bool_label(self.config.collapse_changelog.unwrap_or(false))
1399 ),
1400 SettingsUiEntry::HideThinkingBlock => format!(
1401 "hideThinkingBlock: {}",
1402 bool_label(self.config.hide_thinking_block.unwrap_or(false))
1403 ),
1404 SettingsUiEntry::ShowHardwareCursor => format!(
1405 "showHardwareCursor: {}",
1406 bool_label(self.effective_show_hardware_cursor())
1407 ),
1408 SettingsUiEntry::DoubleEscapeAction => format!(
1409 "doubleEscapeAction: {}",
1410 self.config
1411 .double_escape_action
1412 .as_deref()
1413 .unwrap_or("tree")
1414 ),
1415 SettingsUiEntry::EditorPaddingX => {
1416 format!("editorPaddingX: {}", self.editor_padding_x)
1417 }
1418 SettingsUiEntry::AutocompleteMaxVisible => {
1419 format!("autocompleteMaxVisible: {}", self.autocomplete.max_visible)
1420 }
1421 };
1422 let row = format!(" {label}");
1423 let rendered = if is_selected {
1424 self.styles.selection.render(&row)
1425 } else {
1426 row
1427 };
1428
1429 let _ = writeln!(output, "{prefix} {rendered}");
1430 }
1431
1432 if settings_ui.entries.len() > visible_count {
1433 let _ = writeln!(
1434 output,
1435 " {}",
1436 self.styles.muted.render(&format!(
1437 "({}-{} of {})",
1438 offset + 1,
1439 end,
1440 settings_ui.entries.len()
1441 ))
1442 );
1443 }
1444 }
1445
1446 output.push('\n');
1447 let _ = writeln!(
1448 output,
1449 " {}",
1450 self.styles
1451 .muted_italic
1452 .render("↑/↓/j/k/PgUp/PgDn: navigate Enter: select Esc/q: cancel")
1453 );
1454
1455 output
1456 }
1457
1458 pub(super) fn render_theme_picker(&self, picker: &ThemePickerOverlay) -> String {
1459 let mut output = String::new();
1460
1461 let _ = writeln!(output, "\n {}\n", self.styles.title.render("Select Theme"));
1462
1463 if picker.items.is_empty() {
1464 let _ = writeln!(output, " {}", self.styles.muted.render("No themes found."));
1465 } else {
1466 let offset = picker.scroll_offset();
1467 let visible_count = picker.max_visible.min(picker.items.len());
1468 let end = (offset + visible_count).min(picker.items.len());
1469
1470 for (idx, item) in picker.items[offset..end].iter().enumerate() {
1471 let global_idx = offset + idx;
1472 let is_selected = global_idx == picker.selected;
1473
1474 let prefix = if is_selected { ">" } else { " " };
1475 let (name, label) = match item {
1476 ThemePickerItem::BuiltIn(name) => {
1477 (name.to_string(), format!("{name} (built-in)"))
1478 }
1479 ThemePickerItem::File { name, .. } => {
1480 (name.clone(), format!("{name} (custom)"))
1481 }
1482 };
1483
1484 let active = name.eq_ignore_ascii_case(&self.theme.name);
1485 let marker = if active { " *" } else { "" };
1486
1487 let row = format!(" {label}{marker}");
1488 let rendered = if is_selected {
1489 self.styles.selection.render(&row)
1490 } else {
1491 row
1492 };
1493
1494 let _ = writeln!(output, "{prefix} {rendered}");
1495 }
1496
1497 if picker.items.len() > visible_count {
1498 let _ = writeln!(
1499 output,
1500 " {}",
1501 self.styles.muted.render(&format!(
1502 "({}-{} of {})",
1503 offset + 1,
1504 end,
1505 picker.items.len()
1506 ))
1507 );
1508 }
1509 }
1510
1511 output.push('\n');
1512 let _ = writeln!(
1513 output,
1514 " {}",
1515 self.styles
1516 .muted_italic
1517 .render("↑/↓/j/k/PgUp/PgDn: navigate Enter: select Esc/q: back")
1518 );
1519
1520 output
1521 }
1522
1523 pub(super) fn render_capability_prompt(&self, prompt: &CapabilityPromptOverlay) -> String {
1524 let mut output = String::new();
1525
1526 let _ = writeln!(
1528 output,
1529 "\n {}",
1530 self.styles.title.render("Extension Permission Request")
1531 );
1532
1533 let _ = writeln!(
1535 output,
1536 " {} requests {}",
1537 self.styles.accent_bold.render(&prompt.extension_id),
1538 self.styles.warning_bold.render(&prompt.capability),
1539 );
1540
1541 if !prompt.description.is_empty() {
1543 let _ = writeln!(
1544 output,
1545 "\n {}",
1546 self.styles.muted.render(&prompt.description),
1547 );
1548 }
1549
1550 output.push('\n');
1552 output.push_str(" ");
1553 for (idx, action) in CapabilityAction::ALL.iter().enumerate() {
1554 let label = action.label();
1555 let rendered = if idx == prompt.focused {
1556 self.styles.selection.render(&format!("[{label}]"))
1557 } else {
1558 self.styles.muted.render(&format!(" {label} "))
1559 };
1560 output.push_str(&rendered);
1561 output.push_str(" ");
1562 }
1563 output.push('\n');
1564
1565 if let Some(secs) = prompt.auto_deny_secs {
1567 let _ = writeln!(
1568 output,
1569 " {}",
1570 self.styles
1571 .muted_italic
1572 .render(&format!("Auto-deny in {secs}s")),
1573 );
1574 }
1575
1576 let _ = writeln!(
1578 output,
1579 " {}",
1580 self.styles
1581 .muted_italic
1582 .render("←/→/Tab: navigate Enter: confirm Esc: deny")
1583 );
1584
1585 output
1586 }
1587
1588 pub(super) fn render_extension_custom_overlay(
1589 &self,
1590 overlay: &ExtensionCustomOverlay,
1591 ) -> String {
1592 let mut output = String::new();
1593 let title = overlay.title.as_deref().unwrap_or("Extension Overlay");
1594 let source = overlay.extension_id.as_deref().unwrap_or("extension");
1595
1596 let _ = writeln!(output, "\n {}", self.styles.title.render(title));
1597 let _ = writeln!(
1598 output,
1599 " {}",
1600 self.styles
1601 .muted
1602 .render(&format!("[{source}] custom UI active"))
1603 );
1604
1605 let max_lines = self.term_height.saturating_sub(12).max(4);
1606 if overlay.lines.is_empty() {
1607 let _ = writeln!(
1608 output,
1609 " {}",
1610 self.styles
1611 .muted_italic
1612 .render("Waiting for extension frame...")
1613 );
1614 } else {
1615 for line in overlay
1616 .lines
1617 .iter()
1618 .skip(overlay.lines.len().saturating_sub(max_lines))
1619 {
1620 let _ = writeln!(output, " {line}");
1621 }
1622 }
1623 let _ = writeln!(
1624 output,
1625 " {}",
1626 self.styles
1627 .muted_italic
1628 .render("Press q to exit extension overlays that support quit")
1629 );
1630
1631 output
1632 }
1633
1634 pub(super) fn render_branch_picker(&self, picker: &BranchPickerOverlay) -> String {
1635 let mut output = String::new();
1636
1637 let _ = writeln!(
1638 output,
1639 "\n {}",
1640 self.styles.title.render("Select a branch")
1641 );
1642 let _ = writeln!(
1643 output,
1644 " {}",
1645 self.styles
1646 .muted
1647 .render("-------------------------------------------")
1648 );
1649
1650 if picker.branches.is_empty() {
1651 let _ = writeln!(
1652 output,
1653 " {}",
1654 self.styles.muted_italic.render("No branches found.")
1655 );
1656 } else {
1657 let offset = picker.scroll_offset();
1658 let visible_count = picker.max_visible.min(picker.branches.len());
1659 let end = (offset + visible_count).min(picker.branches.len());
1660
1661 for (idx, branch) in picker.branches[offset..end].iter().enumerate() {
1662 let global_idx = offset + idx;
1663 let is_selected = global_idx == picker.selected;
1664 let prefix = if is_selected { ">" } else { " " };
1665
1666 let current_marker = if branch.is_current { " *" } else { "" };
1667 let msg_count = format!("({} msgs)", branch.message_count);
1668 let preview = if branch.preview.chars().count() > 40 {
1669 let truncated: String = branch.preview.chars().take(37).collect();
1670 format!("{truncated}...")
1671 } else {
1672 branch.preview.clone()
1673 };
1674
1675 let row = format!("{prefix} {preview:<42} {msg_count:>10}{current_marker}");
1676 let rendered = if is_selected {
1677 self.styles.accent_bold.render(&row)
1678 } else if branch.is_current {
1679 self.styles.accent.render(&row)
1680 } else {
1681 self.styles.muted.render(&row)
1682 };
1683 let _ = writeln!(output, " {rendered}");
1684 }
1685 }
1686
1687 let _ = writeln!(
1688 output,
1689 "\n {}",
1690 self.styles
1691 .muted_italic
1692 .render("↑/↓/j/k/PgUp/PgDn: navigate Enter: switch Esc: cancel * = current")
1693 );
1694 output
1695 }
1696}
1697
1698#[cfg(test)]
1699mod tests {
1700 use super::*;
1701 use crate::session::{AutosaveDurabilityMode, AutosaveQueueMetrics};
1702
1703 #[test]
1704 fn normalize_raw_terminal_newlines_inserts_crlf() {
1705 let normalized = normalize_raw_terminal_newlines("hello\nworld\n".to_string());
1706 assert_eq!(normalized, "hello\r\nworld\r\n");
1707 }
1708
1709 #[test]
1710 fn normalize_raw_terminal_newlines_preserves_existing_crlf() {
1711 let normalized = normalize_raw_terminal_newlines("hello\r\nworld\r\n".to_string());
1712 assert_eq!(normalized, "hello\r\nworld\r\n");
1713 }
1714
1715 #[test]
1716 fn normalize_raw_terminal_newlines_handles_mixed_newlines() {
1717 let normalized = normalize_raw_terminal_newlines("a\r\nb\nc\r\nd\n".to_string());
1718 assert_eq!(normalized, "a\r\nb\r\nc\r\nd\r\n");
1719 }
1720
1721 #[test]
1722 fn normalize_raw_terminal_newlines_preserves_utf8_content() {
1723 let normalized = normalize_raw_terminal_newlines("αβ\nγ\r\nδ\n".to_string());
1724 assert_eq!(normalized, "αβ\r\nγ\r\nδ\r\n");
1725 }
1726
1727 #[test]
1728 fn clamp_to_terminal_height_noop_when_fits() {
1729 let input = "line1\nline2\nline3".to_string();
1730 assert_eq!(clamp_to_terminal_height(input.clone(), 4), input);
1732 }
1733
1734 #[test]
1735 fn clamp_to_terminal_height_truncates_excess() {
1736 let input = "a\nb\nc\nd\ne\n".to_string(); let clamped = clamp_to_terminal_height(input, 4);
1739 assert_eq!(clamped, "a\nb\nc\nd");
1740 }
1741
1742 #[test]
1743 fn clamp_to_terminal_height_zero_height() {
1744 let clamped = clamp_to_terminal_height("hello\nworld".to_string(), 0);
1745 assert_eq!(clamped, "");
1746 }
1747
1748 #[test]
1749 fn clamp_to_terminal_height_exact_fit() {
1750 let input = "a\nb\nc".to_string();
1752 assert_eq!(clamp_to_terminal_height(input.clone(), 3), input);
1753 }
1754
1755 #[test]
1756 fn clamp_to_terminal_height_trailing_newline() {
1757 let clamped = clamp_to_terminal_height("a\nb\n".to_string(), 2);
1760 assert_eq!(clamped, "a\nb");
1761 }
1762
1763 #[test]
1764 fn persistence_footer_segment_healthy() {
1765 let metrics = AutosaveQueueMetrics {
1766 pending_mutations: 0,
1767 max_pending_mutations: 256,
1768 coalesced_mutations: 0,
1769 backpressure_events: 0,
1770 flush_started: 0,
1771 flush_succeeded: 0,
1772 flush_failed: 0,
1773 last_flush_batch_size: 0,
1774 last_flush_duration_ms: None,
1775 last_flush_trigger: None,
1776 };
1777 assert_eq!(
1778 format_persistence_footer_segment(AutosaveDurabilityMode::Balanced, metrics),
1779 "Persist: balanced"
1780 );
1781 }
1782
1783 #[test]
1784 fn persistence_footer_segment_includes_backlog_and_failures() {
1785 let metrics = AutosaveQueueMetrics {
1786 pending_mutations: 256,
1787 max_pending_mutations: 256,
1788 coalesced_mutations: 99,
1789 backpressure_events: 4,
1790 flush_started: 5,
1791 flush_succeeded: 3,
1792 flush_failed: 2,
1793 last_flush_batch_size: 64,
1794 last_flush_duration_ms: Some(42),
1795 last_flush_trigger: Some(crate::session::AutosaveFlushTrigger::Periodic),
1796 };
1797 let rendered =
1798 format_persistence_footer_segment(AutosaveDurabilityMode::Throughput, metrics);
1799 assert!(rendered.contains("Persist: throughput"));
1800 assert!(rendered.contains("pending 256/256"));
1801 assert!(rendered.contains("flush-fail 2"));
1802 assert!(rendered.contains("backpressure"));
1803 }
1804
1805 #[test]
1806 fn wrapped_plain_line_no_wrap_when_under_width() {
1807 let segments = wrapped_line_segments("hello", 10);
1808 assert_eq!(segments, vec!["hello"]);
1809 }
1810
1811 #[test]
1812 fn wrapped_plain_line_wraps_when_over_width() {
1813 let segments = wrapped_line_segments("abcdef", 4);
1814 assert_eq!(segments, vec!["abcd", "ef"]);
1815 }
1816
1817 #[test]
1818 fn wrapped_plain_line_preserves_empty_line() {
1819 let segments = wrapped_line_segments("", 8);
1820 assert_eq!(segments, vec![""]);
1821 }
1822
1823 #[test]
1824 fn parse_fence_line_detects_backtick_and_tilde_fences() {
1825 assert_eq!(parse_fence_line("```rust"), Some(('`', 3, "rust")));
1826 assert_eq!(parse_fence_line(" ~~~~~"), Some(('~', 5, "")));
1827 assert_eq!(parse_fence_line("`not-a-fence"), None);
1828 }
1829
1830 #[test]
1831 fn parse_fence_line_rejects_four_space_indent() {
1832 assert_eq!(parse_fence_line(" ```rust"), None);
1833 }
1834
1835 #[test]
1836 fn streaming_unclosed_fence_none_when_balanced() {
1837 let markdown = "```rust\nfn main() {}\n```\n";
1838 assert_eq!(streaming_unclosed_fence(markdown), None);
1839 }
1840
1841 #[test]
1842 fn streaming_unclosed_fence_detects_open_backtick_block() {
1843 let markdown = "Heading\n\n```rust\nfn main() {\n println!(\"hi\");";
1844 assert_eq!(streaming_unclosed_fence(markdown), Some(('`', 3)));
1845 }
1846
1847 #[test]
1848 fn streaming_unclosed_fence_does_not_close_on_trailing_text() {
1849 let markdown = "```rust\nfn main() {}\n``` trailing";
1850 assert_eq!(streaming_unclosed_fence(markdown), Some(('`', 3)));
1851 }
1852
1853 #[test]
1854 fn streaming_unclosed_fence_closes_on_whitespace_only_suffix() {
1855 let markdown = "```rust\nfn main() {}\n``` \n";
1856 assert_eq!(streaming_unclosed_fence(markdown), None);
1857 }
1858
1859 #[test]
1860 fn streaming_unclosed_fence_ignores_invalid_backtick_info() {
1861 let markdown = "```a`b\ncontent\n";
1862 assert_eq!(streaming_unclosed_fence(markdown), None);
1863 }
1864
1865 #[test]
1866 fn stabilize_streaming_markdown_closes_unterminated_fence() {
1867 let markdown = "```python\nprint('hello')";
1868 let stabilized = stabilize_streaming_markdown(markdown);
1869 assert_eq!(stabilized.as_ref(), "```python\nprint('hello')\n```");
1870 }
1871
1872 #[test]
1873 fn stabilize_streaming_markdown_preserves_balanced_input() {
1874 let markdown = "# Title\n\n- item\n";
1875 let stabilized = stabilize_streaming_markdown(markdown);
1876 assert_eq!(stabilized.as_ref(), markdown);
1877 }
1878
1879 #[test]
1880 fn streaming_needs_markdown_renderer_false_for_plain_text() {
1881 let markdown = "Starting response... token_1 token_2";
1882 assert!(!streaming_needs_markdown_renderer(markdown));
1883 }
1884
1885 #[test]
1886 fn streaming_needs_markdown_renderer_true_for_heading() {
1887 let markdown = "# Heading";
1888 assert!(streaming_needs_markdown_renderer(markdown));
1889 }
1890
1891 #[test]
1892 fn streaming_needs_markdown_renderer_true_for_underscore_emphasis() {
1893 let markdown = "This is _important_.";
1894 assert!(streaming_needs_markdown_renderer(markdown));
1895 }
1896
1897 #[test]
1898 fn append_streaming_plaintext_to_output_wraps_without_trailing_blank() {
1899 let mut out = String::new();
1900 append_streaming_plaintext_to_output(&mut out, "abcdef\n", 4);
1901 assert_eq!(out, " abcd\n ef\n");
1902 }
1903}