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 #[allow(clippy::too_many_lines)]
335 pub(super) fn view(&self) -> String {
336 let view_start = if self.frame_timing.enabled {
337 Some(std::time::Instant::now())
338 } else {
339 None
340 };
341
342 let mut output = String::with_capacity(self.render_buffers.view_capacity_hint());
345
346 self.render_header_into(&mut output);
348 output.push('\n');
349
350 if let Some(tree_ui) = &self.tree_ui {
352 output.push_str(&view_tree_ui(tree_ui, &self.styles));
353 self.render_footer_into(&mut output);
354 return output;
355 }
356
357 let conversation_content = {
362 let content_start = if self.frame_timing.enabled {
363 Some(std::time::Instant::now())
364 } else {
365 None
366 };
367 let mut raw = self.build_conversation_content();
368 if let Some(start) = content_start {
369 self.frame_timing
370 .record_content_build(micros_as_u64(start.elapsed().as_micros()));
371 }
372 let trimmed_len = raw.trim_end().len();
375 raw.truncate(trimmed_len);
376 raw
377 };
378
379 let effective_vp = self.view_effective_conversation_height();
384 {
385 use std::borrow::Cow;
388 let viewport_content: Cow<'_, str> = if conversation_content.is_empty() {
389 Cow::Owned(self.styles.muted_italic.render(&self.startup_welcome))
390 } else {
391 Cow::Borrowed(&conversation_content)
392 };
393
394 let total_lines = memchr::memchr_iter(b'\n', viewport_content.as_bytes()).count() + 1;
398 let start = if self.follow_stream_tail {
399 total_lines.saturating_sub(effective_vp)
400 } else {
401 self.conversation_viewport
402 .y_offset()
403 .min(total_lines.saturating_sub(1))
404 };
405 let end = (start + effective_vp).min(total_lines);
406
407 let mut first = true;
410 for line in viewport_content.lines().skip(start).take(end - start) {
411 if first {
412 first = false;
413 } else {
414 output.push('\n');
415 }
416 output.push_str(line);
417 }
418 output.push('\n');
419
420 if total_lines > effective_vp {
422 let total = total_lines.saturating_sub(effective_vp);
423 let percent = (start * 100).checked_div(total).map_or(100, |p| p.min(100));
424 let indicator = format!(" [{percent}%] ↑/↓ PgUp/PgDn to scroll");
425 output.push_str(&self.styles.muted.render(&indicator));
426 output.push('\n');
427 }
428 }
429 self.render_buffers
432 .return_conversation_buffer(conversation_content);
433
434 if let Some(tool) = &self.current_tool {
436 let progress_str = self.tool_progress.as_ref().map_or_else(String::new, |p| {
437 let secs = p.elapsed_ms / 1000;
438 if secs < 1 {
439 return String::new();
440 }
441 let mut parts = vec![format!("{secs}s")];
442 if p.line_count > 0 {
443 parts.push(format!("{} lines", format_count(p.line_count)));
444 } else if p.byte_count > 0 {
445 parts.push(format!("{} bytes", format_count(p.byte_count)));
446 }
447 if let Some(timeout_ms) = p.timeout_ms {
448 let timeout_s = timeout_ms / 1000;
449 if timeout_s > 0 {
450 parts.push(format!("timeout {timeout_s}s"));
451 }
452 }
453 format!(" ({})", parts.join(" \u{2022} "))
454 });
455 let _ = write!(
456 output,
457 "\n {} {}{} ...\n",
458 self.spinner.view(),
459 self.styles.warning_bold.render(&format!("Running {tool}")),
460 self.styles.muted.render(&progress_str),
461 );
462 }
463
464 if let Some(status) = &self.status_message {
466 let status_style = self.styles.accent.clone().italic();
467 let _ = write!(output, "\n {}\n", status_style.render(status));
468 }
469
470 if let Some(ref picker) = self.session_picker {
472 output.push_str(&self.render_session_picker(picker));
473 }
474
475 if let Some(ref settings_ui) = self.settings_ui {
477 output.push_str(&self.render_settings_ui(settings_ui));
478 }
479
480 if let Some(ref picker) = self.theme_picker {
482 output.push_str(&self.render_theme_picker(picker));
483 }
484
485 if let Some(ref prompt) = self.capability_prompt {
487 output.push_str(&self.render_capability_prompt(prompt));
488 }
489
490 if let Some(ref picker) = self.branch_picker {
492 output.push_str(&self.render_branch_picker(picker));
493 }
494
495 if let Some(ref selector) = self.model_selector {
497 output.push_str(&self.render_model_selector(selector));
498 }
499
500 if self.agent_state == AgentState::Idle
502 && self.session_picker.is_none()
503 && self.settings_ui.is_none()
504 && self.theme_picker.is_none()
505 && self.capability_prompt.is_none()
506 && self.branch_picker.is_none()
507 && self.model_selector.is_none()
508 {
509 output.push_str(&self.render_input());
510
511 if self.autocomplete.open && !self.autocomplete.items.is_empty() {
513 output.push_str(&self.render_autocomplete_dropdown());
514 }
515 } else if self.agent_state != AgentState::Idle {
516 if self.show_processing_status_spinner() {
517 let _ = write!(
520 output,
521 "\n {} {}\n",
522 self.spinner.view(),
523 self.styles.accent.render("Processing...")
524 );
525 }
526
527 if let Some(pending_queue) = self.render_pending_message_queue() {
528 output.push_str(&pending_queue);
529 }
530 }
531
532 self.render_footer_into(&mut output);
534
535 let output = clamp_to_terminal_height(output, self.term_height);
538 let output = normalize_raw_terminal_newlines(output);
539
540 self.render_buffers.set_view_capacity_hint(output.len());
543
544 if let Some(start) = view_start {
545 self.frame_timing
546 .record_frame(micros_as_u64(start.elapsed().as_micros()));
547 }
548
549 output
550 }
551
552 fn render_header_into(&self, output: &mut String) {
555 let model_label = format!("({})", self.model);
556
557 let branch_indicator = self
559 .session
560 .try_lock()
561 .ok()
562 .and_then(|guard| {
563 let info = guard.branch_summary();
564 if info.leaf_count <= 1 {
565 return None;
566 }
567 let current_idx = info
568 .current_leaf
569 .as_ref()
570 .and_then(|leaf| info.leaves.iter().position(|l| l == leaf))
571 .map_or(1, |i| i + 1);
572 Some(format!(" [branch {current_idx}/{}]", info.leaf_count))
573 })
574 .unwrap_or_default();
575
576 let model_key = self.header_binding_hint(AppAction::SelectModel, "ctrl+l");
577 let next_model_key = self.header_binding_hint(AppAction::CycleModelForward, "ctrl+p");
578 let prev_model_key =
579 self.header_binding_hint(AppAction::CycleModelBackward, "ctrl+shift+p");
580 let tools_key = self.header_binding_hint(AppAction::ExpandTools, "ctrl+o");
581 let thinking_key = self.header_binding_hint(AppAction::ToggleThinking, "ctrl+t");
582 let max_width = self.term_width.saturating_sub(2);
583
584 let hints_line = truncate(
585 &format!(
586 "{model_key}: model {next_model_key}: next {prev_model_key}: prev \
587 {tools_key}: tools {thinking_key}: thinking"
588 ),
589 max_width,
590 );
591
592 let resources_line = truncate(
593 &format!(
594 "resources: {} skills, {} prompts, {} themes, {} extensions",
595 self.resources.skills().len(),
596 self.resources.prompts().len(),
597 self.resources.themes().len(),
598 self.resources.extensions().len()
599 ),
600 max_width,
601 );
602
603 let _ = write!(
604 output,
605 " {} {}{}\n {}\n {}\n",
606 self.styles.title.render("Pi"),
607 self.styles.muted.render(&model_label),
608 self.styles.accent.render(&branch_indicator),
609 self.styles.muted.render(&hints_line),
610 self.styles.muted.render(&resources_line),
611 );
612 }
613
614 pub(super) fn render_header(&self) -> String {
615 let mut buf = String::new();
616 self.render_header_into(&mut buf);
617 buf
618 }
619
620 pub(super) fn render_input(&self) -> String {
621 let mut output = String::new();
622
623 let thinking_level = self
624 .session
625 .try_lock()
626 .ok()
627 .and_then(|guard| guard.header.thinking_level.clone())
628 .and_then(|level| level.parse::<ThinkingLevel>().ok())
629 .or_else(|| {
630 self.config
631 .default_thinking_level
632 .as_deref()
633 .and_then(|level| level.parse::<ThinkingLevel>().ok())
634 })
635 .unwrap_or(ThinkingLevel::Off);
636
637 let input_text = self.input.value();
638 let is_bash_mode = parse_bash_command(&input_text).is_some();
639
640 let (thinking_label, thinking_style, thinking_border_style) = match thinking_level {
641 ThinkingLevel::Off => (
642 "off",
643 self.styles.muted_bold.clone(),
644 self.styles.border.clone(),
645 ),
646 ThinkingLevel::Minimal => (
647 "minimal",
648 self.styles.accent.clone(),
649 self.styles.accent.clone(),
650 ),
651 ThinkingLevel::Low => (
652 "low",
653 self.styles.accent.clone(),
654 self.styles.accent.clone(),
655 ),
656 ThinkingLevel::Medium => (
657 "medium",
658 self.styles.accent_bold.clone(),
659 self.styles.accent.clone(),
660 ),
661 ThinkingLevel::High => (
662 "high",
663 self.styles.warning_bold.clone(),
664 self.styles.warning.clone(),
665 ),
666 ThinkingLevel::XHigh => (
667 "xhigh",
668 self.styles.error_bold.clone(),
669 self.styles.error_bold.clone(),
670 ),
671 };
672
673 let thinking_plain = format!("[thinking: {thinking_label}]");
674 let thinking_badge = thinking_style.render(&thinking_plain);
675 let bash_badge = is_bash_mode.then(|| self.styles.warning_bold.render("[bash]"));
676
677 let max_width = self.term_width.saturating_sub(2);
678 let reserved = 2
679 + thinking_plain.chars().count()
680 + if is_bash_mode {
681 2 + "[bash]".chars().count()
682 } else {
683 0
684 };
685 let available_for_mode = max_width.saturating_sub(reserved);
686 let mut mode_text = match self.input_mode {
687 InputMode::SingleLine => "Enter: send Shift+Enter: newline Alt+Enter: multi-line",
688 InputMode::MultiLine => "Alt+Enter: send Enter: newline Esc: single-line",
689 }
690 .to_string();
691 if mode_text.chars().count() > available_for_mode {
692 mode_text = truncate(&mode_text, available_for_mode);
693 }
694 let mut header_line = String::new();
695 header_line.push_str(&self.styles.muted.render(&mode_text));
696 header_line.push_str(" ");
697 header_line.push_str(&thinking_badge);
698 if let Some(bash_badge) = bash_badge {
699 header_line.push_str(" ");
700 header_line.push_str(&bash_badge);
701 }
702 let _ = writeln!(output, "\n {header_line}");
703
704 let padding = " ".repeat(self.editor_padding_x);
705 let line_prefix = format!(" {padding}");
706 let border_style = if is_bash_mode {
707 self.styles.warning_bold.clone()
708 } else {
709 thinking_border_style
710 };
711 let border = border_style.render("│");
712 for line in self.input.view().lines() {
713 output.push_str(&line_prefix);
714 output.push_str(&border);
715 output.push(' ');
716 output.push_str(line);
717 output.push('\n');
718 }
719
720 output
721 }
722
723 fn render_footer_into(&self, output: &mut String) {
726 let total_cost = self.total_usage.cost.total;
727 let cost_str = if total_cost > 0.0 {
728 format!(" (${total_cost:.4})")
729 } else {
730 String::new()
731 };
732
733 let input = self.total_usage.input;
734 let output_tokens = self.total_usage.output;
735 let persistence_str = self.session.try_lock().ok().map_or_else(
736 || "Persist: unavailable".to_string(),
737 |session| {
738 format_persistence_footer_segment(
739 session.autosave_durability_mode(),
740 session.autosave_metrics(),
741 )
742 },
743 );
744 let branch_str = self
745 .git_branch
746 .as_ref()
747 .map_or_else(String::new, |b| format!(" | {b}"));
748 let mode_hint = match self.input_mode {
749 InputMode::SingleLine => "Shift+Enter: newline | Alt+Enter: multi-line",
750 InputMode::MultiLine => "Enter: newline | Alt+Enter: send | Esc: single-line",
751 };
752 let footer_long = format!(
753 "Tokens: {input} in / {output_tokens} out{cost_str}{branch_str} | {persistence_str} | {mode_hint} | /help | Ctrl+C: quit"
754 );
755 let footer_short = format!(
756 "Tokens: {input} in / {output_tokens} out{cost_str}{branch_str} | {persistence_str} | /help | Ctrl+C: quit"
757 );
758 let max_width = self.term_width.saturating_sub(2);
759 let mut footer = if footer_long.chars().count() <= max_width {
760 footer_long
761 } else {
762 footer_short
763 };
764 if footer.chars().count() > max_width {
765 footer = truncate(&footer, max_width);
766 }
767 let _ = write!(output, "\n {}\n", self.styles.muted.render(&footer));
768 }
769
770 pub(super) fn render_footer(&self) -> String {
771 let mut buf = String::new();
772 self.render_footer_into(&mut buf);
773 buf
774 }
775
776 fn render_single_message(&self, msg: &ConversationMessage) -> String {
778 let mut output = String::new();
779 match msg.role {
780 MessageRole::User => {
781 let _ = write!(
782 output,
783 "\n {} {}\n",
784 self.styles.accent_bold.render("You:"),
785 msg.content
786 );
787 }
788 MessageRole::Assistant => {
789 let _ = write!(
790 output,
791 "\n {}\n",
792 self.styles.success_bold.render("Assistant:")
793 );
794
795 if self.thinking_visible {
797 if let Some(thinking) = &msg.thinking {
798 let truncated = truncate(thinking, 100);
799 let _ = writeln!(
800 output,
801 " {}",
802 self.styles
803 .muted_italic
804 .render(&format!("Thinking: {truncated}"))
805 );
806 }
807 }
808
809 let rendered = glamour::Renderer::new()
811 .with_style_config(self.markdown_style.clone())
812 .with_word_wrap(self.term_width.saturating_sub(6).max(40))
813 .render(&msg.content);
814 for line in rendered.lines() {
815 let _ = writeln!(output, " {line}");
816 }
817 }
818 MessageRole::Tool => {
819 let show_expanded = self.tools_expanded && !msg.collapsed;
821 if show_expanded {
822 let rendered = render_tool_message(&msg.content, &self.styles);
823 let _ = write!(output, "\n {rendered}\n");
824 } else {
825 let header = msg.content.lines().next().unwrap_or("Tool output");
826 let line_count = memchr::memchr_iter(b'\n', msg.content.as_bytes()).count() + 1;
827 let summary = format!(
828 "\u{25b6} {} ({line_count} lines, collapsed)",
829 header.trim_end()
830 );
831 let _ = write!(
832 output,
833 "\n {}\n",
834 self.styles.muted_italic.render(&summary)
835 );
836 if self.tools_expanded && msg.collapsed {
838 for (i, line) in msg.content.lines().skip(1).enumerate() {
839 if i >= TOOL_COLLAPSE_PREVIEW_LINES {
840 let remaining = line_count
841 .saturating_sub(1)
842 .saturating_sub(TOOL_COLLAPSE_PREVIEW_LINES);
843 let _ = writeln!(
844 output,
845 " {}",
846 self.styles
847 .muted
848 .render(&format!(" ... {remaining} more lines"))
849 );
850 break;
851 }
852 let _ = writeln!(
853 output,
854 " {}",
855 self.styles.muted.render(&format!(" {line}"))
856 );
857 }
858 }
859 }
860 }
861 MessageRole::System => {
862 let _ = write!(output, "\n {}\n", self.styles.warning.render(&msg.content));
863 }
864 }
865 output
866 }
867
868 pub fn build_conversation_content(&self) -> String {
875 let has_streaming_state =
876 !self.current_response.is_empty() || !self.current_thinking.is_empty();
877 let has_visible_streaming_tail = !self.current_response.is_empty()
878 || (self.thinking_visible && !self.current_thinking.is_empty());
879
880 let mut output = self.render_buffers.take_conversation_buffer();
884
885 if has_streaming_state && self.message_render_cache.prefix_valid(self.messages.len()) {
888 self.message_render_cache.prefix_append_to(&mut output);
891 if has_visible_streaming_tail {
892 self.append_streaming_tail(&mut output);
893 }
894 return output;
895 }
896
897 for (index, msg) in self.messages.iter().enumerate() {
899 let key =
900 MessageRenderCache::compute_key(msg, self.thinking_visible, self.tools_expanded);
901
902 if self
903 .message_render_cache
904 .append_cached(&mut output, index, &key)
905 {
906 continue;
907 }
908 let rendered = self.render_single_message(msg);
909 output.push_str(&rendered);
912 self.message_render_cache.put(index, key, rendered);
913 }
914
915 self.message_render_cache
917 .prefix_set(&output, self.messages.len());
918
919 if has_visible_streaming_tail {
921 self.append_streaming_tail(&mut output);
922 }
923
924 output
925 }
926
927 fn append_streaming_tail(&self, output: &mut String) {
930 let _ = write!(
931 output,
932 "\n {}\n",
933 self.styles.success_bold.render("Assistant:")
934 );
935
936 let content_width = self.term_width.saturating_sub(4).max(1);
937
938 if self.thinking_visible && !self.current_thinking.is_empty() {
940 let truncated = truncate(&self.current_thinking, 100);
941 let thinking_line = format!("Thinking: {truncated}");
942 for segment in wrapped_line_segments(&thinking_line, content_width) {
943 let _ = writeln!(output, " {}", self.styles.muted_italic.render(segment));
944 }
945 }
946
947 if !self.current_response.is_empty() {
950 let markdown_width = self.term_width.saturating_sub(6).max(40);
951 if streaming_needs_markdown_renderer(&self.current_response) {
952 let rendered = render_streaming_markdown_with_glamour(
953 &self.current_response,
954 &self.markdown_style,
955 markdown_width,
956 );
957 for line in rendered.lines() {
958 let _ = writeln!(output, " {line}");
959 }
960 } else {
961 append_streaming_plaintext_to_output(
962 output,
963 &self.current_response,
964 markdown_width,
965 );
966 }
967 }
968 }
969
970 pub(super) fn render_pending_message_queue(&self) -> Option<String> {
971 if self.agent_state == AgentState::Idle {
972 return None;
973 }
974
975 let Ok(queue) = self.message_queue.lock() else {
976 return None;
977 };
978
979 let steering_len = queue.steering_len();
980 let follow_len = queue.follow_up_len();
981 if steering_len == 0 && follow_len == 0 {
982 return None;
983 }
984
985 let max_preview = self.term_width.saturating_sub(24).max(20);
986
987 let mut out = String::new();
988 out.push_str("\n ");
989 out.push_str(&self.styles.muted_bold.render("Pending:"));
990 out.push(' ');
991 out.push_str(
992 &self
993 .styles
994 .accent_bold
995 .render(&format!("{steering_len} steering")),
996 );
997 out.push_str(&self.styles.muted.render(", "));
998 out.push_str(&self.styles.muted.render(&format!("{follow_len} follow-up")));
999 out.push('\n');
1000
1001 if let Some(text) = queue.steering_front() {
1002 let preview = queued_message_preview(text, max_preview);
1003 out.push_str(" ");
1004 out.push_str(&self.styles.accent_bold.render("steering →"));
1005 out.push(' ');
1006 out.push_str(&preview);
1007 out.push('\n');
1008 }
1009
1010 if let Some(text) = queue.follow_up_front() {
1011 let preview = queued_message_preview(text, max_preview);
1012 out.push_str(" ");
1013 out.push_str(&self.styles.muted_bold.render("follow-up →"));
1014 out.push(' ');
1015 out.push_str(&self.styles.muted.render(&preview));
1016 out.push('\n');
1017 }
1018
1019 Some(out)
1020 }
1021
1022 #[allow(clippy::too_many_lines)]
1023 pub(super) fn render_autocomplete_dropdown(&self) -> String {
1024 let mut output = String::new();
1025
1026 let offset = self.autocomplete.scroll_offset();
1027 let visible_count = self
1028 .autocomplete
1029 .max_visible
1030 .min(self.autocomplete.items.len());
1031 let end = (offset + visible_count).min(self.autocomplete.items.len());
1032
1033 let border_style = &self.styles.border;
1035 let selected_style = &self.styles.selection;
1036 let kind_style = &self.styles.warning;
1037 let desc_style = &self.styles.muted_italic;
1038
1039 let width = 60;
1041 let _ = write!(
1042 output,
1043 "\n {}",
1044 border_style.render(&format!("┌{:─<width$}┐", ""))
1045 );
1046
1047 for (idx, item) in self.autocomplete.items[offset..end].iter().enumerate() {
1048 let global_idx = offset + idx;
1049 let is_selected = global_idx == self.autocomplete.selected;
1050
1051 let kind_icon = match item.kind {
1052 AutocompleteItemKind::SlashCommand => "⚡",
1053 AutocompleteItemKind::ExtensionCommand => "🧩",
1054 AutocompleteItemKind::PromptTemplate => "📄",
1055 AutocompleteItemKind::Skill => "🔧",
1056 AutocompleteItemKind::Model => "🤖",
1057 AutocompleteItemKind::File => "📁",
1058 AutocompleteItemKind::Path => "📂",
1059 };
1060
1061 let max_label_len = width.saturating_sub(6);
1062 let label = if item.label.chars().count() > max_label_len {
1063 let mut out = item
1064 .label
1065 .chars()
1066 .take(max_label_len.saturating_sub(1))
1067 .collect::<String>();
1068 out.push('…');
1069 out
1070 } else {
1071 item.label.clone()
1072 };
1073
1074 let line_content = format!("{kind_icon} {label:<max_label_len$}");
1075 let styled_line = if is_selected {
1076 selected_style.render(&line_content)
1077 } else {
1078 format!("{} {label:<max_label_len$}", kind_style.render(kind_icon))
1079 };
1080
1081 let _ = write!(
1082 output,
1083 "\n {}{}{}",
1084 border_style.render("│"),
1085 styled_line,
1086 border_style.render("│")
1087 );
1088
1089 if is_selected {
1090 if let Some(desc) = &item.description {
1091 let truncated_desc = if desc.chars().count() > width.saturating_sub(4) {
1092 let mut out = desc
1093 .chars()
1094 .take(width.saturating_sub(5))
1095 .collect::<String>();
1096 out.push('…');
1097 out
1098 } else {
1099 desc.clone()
1100 };
1101
1102 let _ = write!(
1103 output,
1104 "\n {} {}{}",
1105 border_style.render("│"),
1106 desc_style.render(&truncated_desc),
1107 border_style.render(&format!(
1108 "{:>pad$}│",
1109 "",
1110 pad = width.saturating_sub(2).saturating_sub(truncated_desc.len())
1111 ))
1112 );
1113 }
1114 }
1115 }
1116
1117 if self.autocomplete.items.len() > visible_count {
1118 let shown = format!(
1119 "{}-{} of {}",
1120 offset + 1,
1121 end,
1122 self.autocomplete.items.len()
1123 );
1124 let _ = write!(
1125 output,
1126 "\n {}",
1127 border_style.render(&format!("│{shown:^width$}│"))
1128 );
1129 }
1130
1131 let _ = write!(
1132 output,
1133 "\n {}",
1134 border_style.render(&format!("└{:─<width$}┘", ""))
1135 );
1136
1137 let _ = write!(
1138 output,
1139 "\n {}",
1140 self.styles
1141 .muted_italic
1142 .render("↑/↓ navigate Enter/Tab accept Esc cancel")
1143 );
1144
1145 output
1146 }
1147
1148 #[allow(clippy::too_many_lines)]
1149 pub(super) fn render_session_picker(&self, picker: &SessionPickerOverlay) -> String {
1150 let mut output = String::new();
1151
1152 let _ = writeln!(
1153 output,
1154 "\n {}\n",
1155 self.styles.title.render("Select a session to resume")
1156 );
1157
1158 let query = picker.query();
1159 let search_line = if query.is_empty() {
1160 " > (type to filter sessions)".to_string()
1161 } else {
1162 format!(" > {query}")
1163 };
1164 let _ = writeln!(output, "{}", self.styles.muted.render(&search_line));
1165 let _ = writeln!(
1166 output,
1167 " {}",
1168 self.styles.muted.render("─".repeat(50).as_str())
1169 );
1170 output.push('\n');
1171
1172 if picker.sessions.is_empty() {
1173 let message = if picker.has_query() {
1174 "No sessions match the current filter."
1175 } else {
1176 "No sessions found for this project."
1177 };
1178 let _ = writeln!(output, " {}", self.styles.muted.render(message));
1179 } else {
1180 let _ = writeln!(
1181 output,
1182 " {:<20} {:<30} {:<8} {}",
1183 self.styles.muted_bold.render("Time"),
1184 self.styles.muted_bold.render("Name"),
1185 self.styles.muted_bold.render("Messages"),
1186 self.styles.muted_bold.render("Session ID")
1187 );
1188 output.push_str(" ");
1189 output.push_str(&"-".repeat(78));
1190 output.push('\n');
1191
1192 let offset = picker.scroll_offset();
1193 let visible_count = picker.max_visible.min(picker.sessions.len());
1194 let end = (offset + visible_count).min(picker.sessions.len());
1195
1196 for (idx, session) in picker.sessions[offset..end].iter().enumerate() {
1197 let global_idx = offset + idx;
1198 let is_selected = global_idx == picker.selected;
1199
1200 let prefix = if is_selected { ">" } else { " " };
1201 let time = crate::session_picker::format_time(&session.timestamp);
1202 let name = session
1203 .name
1204 .as_deref()
1205 .unwrap_or("-")
1206 .chars()
1207 .take(28)
1208 .collect::<String>();
1209 let messages = session.message_count.to_string();
1210 let id = crate::session_picker::truncate_session_id(&session.id, 8);
1211
1212 let row = format!(" {time:<20} {name:<30} {messages:<8} {id}");
1213 let rendered = if is_selected {
1214 self.styles.selection.render(&row)
1215 } else {
1216 row
1217 };
1218
1219 let _ = writeln!(output, "{prefix} {rendered}");
1220 }
1221
1222 if picker.sessions.len() > visible_count {
1223 let _ = writeln!(
1224 output,
1225 " {}",
1226 self.styles.muted.render(&format!(
1227 "({}-{} of {})",
1228 offset + 1,
1229 end,
1230 picker.sessions.len()
1231 ))
1232 );
1233 }
1234 }
1235
1236 output.push('\n');
1237 if picker.confirm_delete {
1238 let _ = writeln!(
1239 output,
1240 " {}",
1241 self.styles.warning_bold.render(
1242 picker
1243 .status_message
1244 .as_deref()
1245 .unwrap_or("Delete session? Press y/n to confirm."),
1246 )
1247 );
1248 } else {
1249 let _ = writeln!(
1250 output,
1251 " {}",
1252 self.styles.muted_italic.render(
1253 "Type: filter Backspace: clear ↑/↓/j/k: navigate Enter: select Ctrl+D: delete Esc/q: cancel",
1254 )
1255 );
1256 if let Some(message) = &picker.status_message {
1257 let _ = writeln!(output, " {}", self.styles.warning_bold.render(message));
1258 }
1259 }
1260
1261 output
1262 }
1263
1264 pub(super) fn render_settings_ui(&self, settings_ui: &SettingsUiState) -> String {
1265 let mut output = String::new();
1266
1267 let _ = writeln!(output, "\n {}\n", self.styles.title.render("Settings"));
1268
1269 if settings_ui.entries.is_empty() {
1270 let _ = writeln!(
1271 output,
1272 " {}",
1273 self.styles.muted.render("No settings available.")
1274 );
1275 } else {
1276 let offset = settings_ui.scroll_offset();
1277 let visible_count = settings_ui.max_visible.min(settings_ui.entries.len());
1278 let end = (offset + visible_count).min(settings_ui.entries.len());
1279
1280 for (idx, entry) in settings_ui.entries[offset..end].iter().enumerate() {
1281 let global_idx = offset + idx;
1282 let is_selected = global_idx == settings_ui.selected;
1283
1284 let prefix = if is_selected { ">" } else { " " };
1285 let label = match *entry {
1286 SettingsUiEntry::Summary => "Summary".to_string(),
1287 SettingsUiEntry::Theme => "Theme".to_string(),
1288 SettingsUiEntry::SteeringMode => format!(
1289 "steeringMode: {}",
1290 self.config.steering_queue_mode().as_str()
1291 ),
1292 SettingsUiEntry::FollowUpMode => format!(
1293 "followUpMode: {}",
1294 self.config.follow_up_queue_mode().as_str()
1295 ),
1296 SettingsUiEntry::QuietStartup => format!(
1297 "quietStartup: {}",
1298 bool_label(self.config.quiet_startup.unwrap_or(false))
1299 ),
1300 SettingsUiEntry::CollapseChangelog => format!(
1301 "collapseChangelog: {}",
1302 bool_label(self.config.collapse_changelog.unwrap_or(false))
1303 ),
1304 SettingsUiEntry::HideThinkingBlock => format!(
1305 "hideThinkingBlock: {}",
1306 bool_label(self.config.hide_thinking_block.unwrap_or(false))
1307 ),
1308 SettingsUiEntry::ShowHardwareCursor => format!(
1309 "showHardwareCursor: {}",
1310 bool_label(self.effective_show_hardware_cursor())
1311 ),
1312 SettingsUiEntry::DoubleEscapeAction => format!(
1313 "doubleEscapeAction: {}",
1314 self.config
1315 .double_escape_action
1316 .as_deref()
1317 .unwrap_or("tree")
1318 ),
1319 SettingsUiEntry::EditorPaddingX => {
1320 format!("editorPaddingX: {}", self.editor_padding_x)
1321 }
1322 SettingsUiEntry::AutocompleteMaxVisible => {
1323 format!("autocompleteMaxVisible: {}", self.autocomplete.max_visible)
1324 }
1325 };
1326 let row = format!(" {label}");
1327 let rendered = if is_selected {
1328 self.styles.selection.render(&row)
1329 } else {
1330 row
1331 };
1332
1333 let _ = writeln!(output, "{prefix} {rendered}");
1334 }
1335
1336 if settings_ui.entries.len() > visible_count {
1337 let _ = writeln!(
1338 output,
1339 " {}",
1340 self.styles.muted.render(&format!(
1341 "({}-{} of {})",
1342 offset + 1,
1343 end,
1344 settings_ui.entries.len()
1345 ))
1346 );
1347 }
1348 }
1349
1350 output.push('\n');
1351 let _ = writeln!(
1352 output,
1353 " {}",
1354 self.styles
1355 .muted_italic
1356 .render("↑/↓/j/k: navigate Enter: select Esc/q: cancel")
1357 );
1358
1359 output
1360 }
1361
1362 pub(super) fn render_theme_picker(&self, picker: &ThemePickerOverlay) -> String {
1363 let mut output = String::new();
1364
1365 let _ = writeln!(output, "\n {}\n", self.styles.title.render("Select Theme"));
1366
1367 if picker.items.is_empty() {
1368 let _ = writeln!(output, " {}", self.styles.muted.render("No themes found."));
1369 } else {
1370 let offset = picker.scroll_offset();
1371 let visible_count = picker.max_visible.min(picker.items.len());
1372 let end = (offset + visible_count).min(picker.items.len());
1373
1374 for (idx, item) in picker.items[offset..end].iter().enumerate() {
1375 let global_idx = offset + idx;
1376 let is_selected = global_idx == picker.selected;
1377
1378 let prefix = if is_selected { ">" } else { " " };
1379 let (name, label) = match item {
1380 ThemePickerItem::BuiltIn(name) => {
1381 (name.to_string(), format!("{name} (built-in)"))
1382 }
1383 ThemePickerItem::File(path) => {
1384 let name = Theme::load(path).map_or_else(
1387 |_| {
1388 path.file_stem().map_or_else(
1389 || "unknown".to_string(),
1390 |s| s.to_string_lossy().to_string(),
1391 )
1392 },
1393 |t| t.name,
1394 );
1395 (name.clone(), format!("{name} (custom)"))
1396 }
1397 };
1398
1399 let active = name.eq_ignore_ascii_case(&self.theme.name);
1400 let marker = if active { " *" } else { "" };
1401
1402 let row = format!(" {label}{marker}");
1403 let rendered = if is_selected {
1404 self.styles.selection.render(&row)
1405 } else {
1406 row
1407 };
1408
1409 let _ = writeln!(output, "{prefix} {rendered}");
1410 }
1411
1412 if picker.items.len() > visible_count {
1413 let _ = writeln!(
1414 output,
1415 " {}",
1416 self.styles.muted.render(&format!(
1417 "({}-{} of {})",
1418 offset + 1,
1419 end,
1420 picker.items.len()
1421 ))
1422 );
1423 }
1424 }
1425
1426 output.push('\n');
1427 let _ = writeln!(
1428 output,
1429 " {}",
1430 self.styles
1431 .muted_italic
1432 .render("↑/↓/j/k: navigate Enter: select Esc/q: back")
1433 );
1434
1435 output
1436 }
1437
1438 pub(super) fn render_capability_prompt(&self, prompt: &CapabilityPromptOverlay) -> String {
1439 let mut output = String::new();
1440
1441 let _ = writeln!(
1443 output,
1444 "\n {}",
1445 self.styles.title.render("Extension Permission Request")
1446 );
1447
1448 let _ = writeln!(
1450 output,
1451 " {} requests {}",
1452 self.styles.accent_bold.render(&prompt.extension_id),
1453 self.styles.warning_bold.render(&prompt.capability),
1454 );
1455
1456 if !prompt.description.is_empty() {
1458 let _ = writeln!(
1459 output,
1460 "\n {}",
1461 self.styles.muted.render(&prompt.description),
1462 );
1463 }
1464
1465 output.push('\n');
1467 output.push_str(" ");
1468 for (idx, action) in CapabilityAction::ALL.iter().enumerate() {
1469 let label = action.label();
1470 let rendered = if idx == prompt.focused {
1471 self.styles.selection.render(&format!("[{label}]"))
1472 } else {
1473 self.styles.muted.render(&format!(" {label} "))
1474 };
1475 output.push_str(&rendered);
1476 output.push_str(" ");
1477 }
1478 output.push('\n');
1479
1480 if let Some(secs) = prompt.auto_deny_secs {
1482 let _ = writeln!(
1483 output,
1484 " {}",
1485 self.styles
1486 .muted_italic
1487 .render(&format!("Auto-deny in {secs}s")),
1488 );
1489 }
1490
1491 let _ = writeln!(
1493 output,
1494 " {}",
1495 self.styles
1496 .muted_italic
1497 .render("←/→/Tab: navigate Enter: confirm Esc: deny")
1498 );
1499
1500 output
1501 }
1502
1503 pub(super) fn render_branch_picker(&self, picker: &BranchPickerOverlay) -> String {
1504 let mut output = String::new();
1505
1506 let _ = writeln!(
1507 output,
1508 "\n {}",
1509 self.styles.title.render("Select a branch")
1510 );
1511 let _ = writeln!(
1512 output,
1513 " {}",
1514 self.styles
1515 .muted
1516 .render("-------------------------------------------")
1517 );
1518
1519 if picker.branches.is_empty() {
1520 let _ = writeln!(
1521 output,
1522 " {}",
1523 self.styles.muted_italic.render("No branches found.")
1524 );
1525 } else {
1526 let offset = picker.scroll_offset();
1527 let visible_count = picker.max_visible.min(picker.branches.len());
1528 let end = (offset + visible_count).min(picker.branches.len());
1529
1530 for (idx, branch) in picker.branches[offset..end].iter().enumerate() {
1531 let global_idx = offset + idx;
1532 let is_selected = global_idx == picker.selected;
1533 let prefix = if is_selected { ">" } else { " " };
1534
1535 let current_marker = if branch.is_current { " *" } else { "" };
1536 let msg_count = format!("({} msgs)", branch.message_count);
1537 let preview = if branch.preview.chars().count() > 40 {
1538 let truncated: String = branch.preview.chars().take(37).collect();
1539 format!("{truncated}...")
1540 } else {
1541 branch.preview.clone()
1542 };
1543
1544 let row = format!("{prefix} {preview:<42} {msg_count:>10}{current_marker}");
1545 let rendered = if is_selected {
1546 self.styles.accent_bold.render(&row)
1547 } else if branch.is_current {
1548 self.styles.accent.render(&row)
1549 } else {
1550 self.styles.muted.render(&row)
1551 };
1552 let _ = writeln!(output, " {rendered}");
1553 }
1554 }
1555
1556 let _ = writeln!(
1557 output,
1558 "\n {}",
1559 self.styles
1560 .muted_italic
1561 .render("↑/↓/j/k: navigate Enter: switch Esc: cancel * = current")
1562 );
1563 output
1564 }
1565}
1566
1567#[cfg(test)]
1568mod tests {
1569 use super::*;
1570 use crate::session::{AutosaveDurabilityMode, AutosaveQueueMetrics};
1571
1572 #[test]
1573 fn normalize_raw_terminal_newlines_inserts_crlf() {
1574 let normalized = normalize_raw_terminal_newlines("hello\nworld\n".to_string());
1575 assert_eq!(normalized, "hello\r\nworld\r\n");
1576 }
1577
1578 #[test]
1579 fn normalize_raw_terminal_newlines_preserves_existing_crlf() {
1580 let normalized = normalize_raw_terminal_newlines("hello\r\nworld\r\n".to_string());
1581 assert_eq!(normalized, "hello\r\nworld\r\n");
1582 }
1583
1584 #[test]
1585 fn normalize_raw_terminal_newlines_handles_mixed_newlines() {
1586 let normalized = normalize_raw_terminal_newlines("a\r\nb\nc\r\nd\n".to_string());
1587 assert_eq!(normalized, "a\r\nb\r\nc\r\nd\r\n");
1588 }
1589
1590 #[test]
1591 fn normalize_raw_terminal_newlines_preserves_utf8_content() {
1592 let normalized = normalize_raw_terminal_newlines("αβ\nγ\r\nδ\n".to_string());
1593 assert_eq!(normalized, "αβ\r\nγ\r\nδ\r\n");
1594 }
1595
1596 #[test]
1597 fn clamp_to_terminal_height_noop_when_fits() {
1598 let input = "line1\nline2\nline3".to_string();
1599 assert_eq!(clamp_to_terminal_height(input.clone(), 4), input);
1601 }
1602
1603 #[test]
1604 fn clamp_to_terminal_height_truncates_excess() {
1605 let input = "a\nb\nc\nd\ne\n".to_string(); let clamped = clamp_to_terminal_height(input, 4);
1608 assert_eq!(clamped, "a\nb\nc\nd");
1609 }
1610
1611 #[test]
1612 fn clamp_to_terminal_height_zero_height() {
1613 let clamped = clamp_to_terminal_height("hello\nworld".to_string(), 0);
1614 assert_eq!(clamped, "");
1615 }
1616
1617 #[test]
1618 fn clamp_to_terminal_height_exact_fit() {
1619 let input = "a\nb\nc".to_string();
1621 assert_eq!(clamp_to_terminal_height(input.clone(), 3), input);
1622 }
1623
1624 #[test]
1625 fn clamp_to_terminal_height_trailing_newline() {
1626 let clamped = clamp_to_terminal_height("a\nb\n".to_string(), 2);
1629 assert_eq!(clamped, "a\nb");
1630 }
1631
1632 #[test]
1633 fn persistence_footer_segment_healthy() {
1634 let metrics = AutosaveQueueMetrics {
1635 pending_mutations: 0,
1636 max_pending_mutations: 256,
1637 coalesced_mutations: 0,
1638 backpressure_events: 0,
1639 flush_started: 0,
1640 flush_succeeded: 0,
1641 flush_failed: 0,
1642 last_flush_batch_size: 0,
1643 last_flush_duration_ms: None,
1644 last_flush_trigger: None,
1645 };
1646 assert_eq!(
1647 format_persistence_footer_segment(AutosaveDurabilityMode::Balanced, metrics),
1648 "Persist: balanced"
1649 );
1650 }
1651
1652 #[test]
1653 fn persistence_footer_segment_includes_backlog_and_failures() {
1654 let metrics = AutosaveQueueMetrics {
1655 pending_mutations: 256,
1656 max_pending_mutations: 256,
1657 coalesced_mutations: 99,
1658 backpressure_events: 4,
1659 flush_started: 5,
1660 flush_succeeded: 3,
1661 flush_failed: 2,
1662 last_flush_batch_size: 64,
1663 last_flush_duration_ms: Some(42),
1664 last_flush_trigger: Some(crate::session::AutosaveFlushTrigger::Periodic),
1665 };
1666 let rendered =
1667 format_persistence_footer_segment(AutosaveDurabilityMode::Throughput, metrics);
1668 assert!(rendered.contains("Persist: throughput"));
1669 assert!(rendered.contains("pending 256/256"));
1670 assert!(rendered.contains("flush-fail 2"));
1671 assert!(rendered.contains("backpressure"));
1672 }
1673
1674 #[test]
1675 fn wrapped_plain_line_no_wrap_when_under_width() {
1676 let segments = wrapped_line_segments("hello", 10);
1677 assert_eq!(segments, vec!["hello"]);
1678 }
1679
1680 #[test]
1681 fn wrapped_plain_line_wraps_when_over_width() {
1682 let segments = wrapped_line_segments("abcdef", 4);
1683 assert_eq!(segments, vec!["abcd", "ef"]);
1684 }
1685
1686 #[test]
1687 fn wrapped_plain_line_preserves_empty_line() {
1688 let segments = wrapped_line_segments("", 8);
1689 assert_eq!(segments, vec![""]);
1690 }
1691
1692 #[test]
1693 fn parse_fence_line_detects_backtick_and_tilde_fences() {
1694 assert_eq!(parse_fence_line("```rust"), Some(('`', 3, "rust")));
1695 assert_eq!(parse_fence_line(" ~~~~~"), Some(('~', 5, "")));
1696 assert_eq!(parse_fence_line("`not-a-fence"), None);
1697 }
1698
1699 #[test]
1700 fn parse_fence_line_rejects_four_space_indent() {
1701 assert_eq!(parse_fence_line(" ```rust"), None);
1702 }
1703
1704 #[test]
1705 fn streaming_unclosed_fence_none_when_balanced() {
1706 let markdown = "```rust\nfn main() {}\n```\n";
1707 assert_eq!(streaming_unclosed_fence(markdown), None);
1708 }
1709
1710 #[test]
1711 fn streaming_unclosed_fence_detects_open_backtick_block() {
1712 let markdown = "Heading\n\n```rust\nfn main() {\n println!(\"hi\");";
1713 assert_eq!(streaming_unclosed_fence(markdown), Some(('`', 3)));
1714 }
1715
1716 #[test]
1717 fn streaming_unclosed_fence_does_not_close_on_trailing_text() {
1718 let markdown = "```rust\nfn main() {}\n``` trailing";
1719 assert_eq!(streaming_unclosed_fence(markdown), Some(('`', 3)));
1720 }
1721
1722 #[test]
1723 fn streaming_unclosed_fence_closes_on_whitespace_only_suffix() {
1724 let markdown = "```rust\nfn main() {}\n``` \n";
1725 assert_eq!(streaming_unclosed_fence(markdown), None);
1726 }
1727
1728 #[test]
1729 fn streaming_unclosed_fence_ignores_invalid_backtick_info() {
1730 let markdown = "```a`b\ncontent\n";
1731 assert_eq!(streaming_unclosed_fence(markdown), None);
1732 }
1733
1734 #[test]
1735 fn stabilize_streaming_markdown_closes_unterminated_fence() {
1736 let markdown = "```python\nprint('hello')";
1737 let stabilized = stabilize_streaming_markdown(markdown);
1738 assert_eq!(stabilized.as_ref(), "```python\nprint('hello')\n```");
1739 }
1740
1741 #[test]
1742 fn stabilize_streaming_markdown_preserves_balanced_input() {
1743 let markdown = "# Title\n\n- item\n";
1744 let stabilized = stabilize_streaming_markdown(markdown);
1745 assert_eq!(stabilized.as_ref(), markdown);
1746 }
1747
1748 #[test]
1749 fn streaming_needs_markdown_renderer_false_for_plain_text() {
1750 let markdown = "Starting response... token_1 token_2";
1751 assert!(!streaming_needs_markdown_renderer(markdown));
1752 }
1753
1754 #[test]
1755 fn streaming_needs_markdown_renderer_true_for_heading() {
1756 let markdown = "# Heading";
1757 assert!(streaming_needs_markdown_renderer(markdown));
1758 }
1759
1760 #[test]
1761 fn streaming_needs_markdown_renderer_true_for_underscore_emphasis() {
1762 let markdown = "This is _important_.";
1763 assert!(streaming_needs_markdown_renderer(markdown));
1764 }
1765
1766 #[test]
1767 fn append_streaming_plaintext_to_output_wraps_without_trailing_blank() {
1768 let mut out = String::new();
1769 append_streaming_plaintext_to_output(&mut out, "abcdef\n", 4);
1770 assert_eq!(out, " abcd\n ef\n");
1771 }
1772}