Skip to main content

trace_stream/
render.rs

1// Copyright (c) 2026 Enzo Lombardi
2// SPDX-License-Identifier: MIT
3
4//! Assistant markdown rendering: streaming terminal renderer for model output.
5//!
6//! Port of the "Assistant Markdown Rendering" section of `refs/ds4/ds4_agent.c`.
7//! The renderer handles only the cheap markdown cues that make terminal output
8//! readable: `**bold**`, `*italic*`, inline code, and fenced code blocks with a
9//! kilo-style keyword highlighter. It is a streaming parser, so it buffers only
10//! ambiguous marker bytes long enough to decide whether they are formatting or
11//! literal text. It also hides `<think>` tags and renders thinking text grey.
12
13use std::borrow::Cow;
14use std::fmt;
15use std::io::Write;
16
17// ---------------------------------------------------------------------------
18// Tail capture
19// ---------------------------------------------------------------------------
20
21/// Ring buffer recording the last N output bytes plus a total byte count.
22#[derive(Debug, Default)]
23pub struct TailCapture {
24    buf: Vec<u8>,
25    cap: usize,
26    start: usize,
27    len: usize,
28    total: u64,
29}
30
31impl TailCapture {
32    /// Creates a capture that retains at most `cap` trailing bytes.
33    #[must_use]
34    pub fn new(cap: usize) -> Self {
35        Self {
36            buf: Vec::new(),
37            cap,
38            start: 0,
39            len: 0,
40            total: 0,
41        }
42    }
43
44    /// Appends bytes, keeping only the most recent `cap` bytes.
45    pub fn append(&mut self, s: &[u8]) {
46        if s.is_empty() || self.cap == 0 {
47            return;
48        }
49        if self.buf.is_empty() {
50            self.buf = vec![0; self.cap];
51        }
52        self.total += s.len() as u64;
53
54        if s.len() >= self.cap {
55            self.buf.copy_from_slice(&s[s.len() - self.cap..]);
56            self.start = 0;
57            self.len = self.cap;
58            return;
59        }
60
61        for &b in s {
62            if self.len < self.cap {
63                let pos = (self.start + self.len) % self.cap;
64                self.buf[pos] = b;
65                self.len += 1;
66            } else {
67                self.buf[self.start] = b;
68                self.start = (self.start + 1) % self.cap;
69            }
70        }
71    }
72
73    /// Returns the captured bytes in order and resets the capture.
74    pub fn take(&mut self) -> Vec<u8> {
75        let mut out = Vec::with_capacity(self.len);
76        for i in 0..self.len {
77            out.push(self.buf[(self.start + i) % self.cap]);
78        }
79        let cap = self.cap;
80        *self = Self::new(cap);
81        out
82    }
83
84    /// Total number of bytes ever appended.
85    #[must_use]
86    pub fn total(&self) -> u64 {
87        self.total
88    }
89
90    /// Number of bytes currently retained.
91    #[must_use]
92    pub fn len(&self) -> usize {
93        self.len
94    }
95
96    /// Returns `true` when no bytes are retained.
97    #[must_use]
98    pub fn is_empty(&self) -> bool {
99        self.len == 0
100    }
101}
102
103// ---------------------------------------------------------------------------
104// Syntax highlighting tables
105// ---------------------------------------------------------------------------
106
107/// Highlight class assigned to a run of code-block bytes.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109enum Highlight {
110    Normal,
111    Comment,
112    Keyword1,
113    Keyword2,
114    String,
115    Number,
116}
117
118const SYNTAX_NUMBERS: u8 = 1 << 0;
119const SYNTAX_STRINGS: u8 = 1 << 1;
120const SYNTAX_BACKTICK_STRINGS: u8 = 1 << 2;
121const SYNTAX_CASE_INSENSITIVE: u8 = 1 << 3;
122
123/// One language entry of the poor man's code highlighter.
124///
125/// Keywords ending in `|` are secondary (type-like) keywords, following the
126/// kilo convention of the C reference.
127#[derive(Debug)]
128pub struct Syntax {
129    name: &'static str,
130    aliases: &'static str,
131    keywords: &'static [&'static str],
132    singleline_comments: &'static [&'static str],
133    multiline_start: Option<&'static str>,
134    multiline_end: Option<&'static str>,
135    flags: u8,
136}
137
138impl Syntax {
139    /// Canonical language name of this entry.
140    #[must_use]
141    pub fn name(&self) -> &'static str {
142        self.name
143    }
144}
145
146static KW_GENERIC: &[&str] = &[
147    "if",
148    "else",
149    "for",
150    "while",
151    "do",
152    "switch",
153    "case",
154    "default",
155    "break",
156    "continue",
157    "return",
158    "try",
159    "catch",
160    "finally",
161    "throw",
162    "throws",
163    "class",
164    "struct",
165    "enum",
166    "interface",
167    "trait",
168    "impl",
169    "fn",
170    "func",
171    "function",
172    "def",
173    "lambda",
174    "let",
175    "var",
176    "const",
177    "static",
178    "public",
179    "private",
180    "protected",
181    "import",
182    "include",
183    "from",
184    "export",
185    "package",
186    "module",
187    "namespace",
188    "new",
189    "delete",
190    "async",
191    "await",
192    "yield",
193    "match",
194    "type",
195    "true|",
196    "false|",
197    "null|",
198    "nil|",
199    "none|",
200    "None|",
201    "NULL|",
202    "void|",
203    "int|",
204    "long|",
205    "float|",
206    "double|",
207    "char|",
208    "bool|",
209    "string|",
210    "String|",
211    "usize|",
212    "isize|",
213    "u8|",
214    "u16|",
215    "u32|",
216    "u64|",
217    "i8|",
218    "i16|",
219    "i32|",
220    "i64|",
221];
222
223static KW_C: &[&str] = &[
224    "auto",
225    "break",
226    "case",
227    "continue",
228    "default",
229    "do",
230    "else",
231    "enum",
232    "extern",
233    "for",
234    "goto",
235    "if",
236    "register",
237    "return",
238    "sizeof",
239    "static",
240    "struct",
241    "switch",
242    "typedef",
243    "union",
244    "volatile",
245    "while",
246    "alignas",
247    "alignof",
248    "and",
249    "and_eq",
250    "asm",
251    "bitand",
252    "bitor",
253    "class",
254    "compl",
255    "constexpr",
256    "const_cast",
257    "decltype",
258    "delete",
259    "dynamic_cast",
260    "explicit",
261    "export",
262    "false",
263    "friend",
264    "inline",
265    "mutable",
266    "namespace",
267    "new",
268    "noexcept",
269    "not",
270    "not_eq",
271    "nullptr",
272    "operator",
273    "or",
274    "or_eq",
275    "private",
276    "protected",
277    "public",
278    "reinterpret_cast",
279    "static_assert",
280    "static_cast",
281    "template",
282    "this",
283    "thread_local",
284    "throw",
285    "true",
286    "try",
287    "typeid",
288    "typename",
289    "virtual",
290    "xor",
291    "xor_eq",
292    "NULL|",
293    "bool|",
294    "char|",
295    "const|",
296    "double|",
297    "float|",
298    "int|",
299    "long|",
300    "short|",
301    "signed|",
302    "size_t|",
303    "ssize_t|",
304    "uint8_t|",
305    "uint16_t|",
306    "uint32_t|",
307    "uint64_t|",
308    "unsigned|",
309    "void|",
310];
311
312static KW_PYTHON: &[&str] = &[
313    "and", "as", "assert", "async", "await", "break", "case", "class", "continue", "def", "del",
314    "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is",
315    "lambda", "match", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with",
316    "yield", "False|", "None|", "True|", "bool|", "bytes|", "dict|", "float|", "int|", "list|",
317    "object|", "set|", "str|", "tuple|",
318];
319
320static KW_JS: &[&str] = &[
321    "async",
322    "await",
323    "break",
324    "case",
325    "catch",
326    "class",
327    "const",
328    "continue",
329    "debugger",
330    "default",
331    "delete",
332    "do",
333    "else",
334    "export",
335    "extends",
336    "finally",
337    "for",
338    "from",
339    "function",
340    "get",
341    "if",
342    "import",
343    "in",
344    "instanceof",
345    "let",
346    "new",
347    "of",
348    "return",
349    "set",
350    "static",
351    "super",
352    "switch",
353    "this",
354    "throw",
355    "try",
356    "typeof",
357    "var",
358    "void",
359    "while",
360    "with",
361    "yield",
362    "abstract",
363    "as",
364    "declare",
365    "enum",
366    "implements",
367    "interface",
368    "keyof",
369    "namespace",
370    "private",
371    "protected",
372    "public",
373    "readonly",
374    "type",
375    "any|",
376    "boolean|",
377    "false|",
378    "never|",
379    "null|",
380    "number|",
381    "string|",
382    "symbol|",
383    "true|",
384    "undefined|",
385    "unknown|",
386    "void|",
387];
388
389static KW_JAVA: &[&str] = &[
390    "abstract",
391    "assert",
392    "break",
393    "case",
394    "catch",
395    "class",
396    "const",
397    "continue",
398    "default",
399    "do",
400    "else",
401    "enum",
402    "extends",
403    "final",
404    "finally",
405    "for",
406    "goto",
407    "if",
408    "implements",
409    "import",
410    "instanceof",
411    "interface",
412    "native",
413    "new",
414    "package",
415    "private",
416    "protected",
417    "public",
418    "return",
419    "static",
420    "strictfp",
421    "super",
422    "switch",
423    "synchronized",
424    "this",
425    "throw",
426    "throws",
427    "transient",
428    "try",
429    "volatile",
430    "while",
431    "boolean|",
432    "byte|",
433    "char|",
434    "double|",
435    "false|",
436    "float|",
437    "int|",
438    "long|",
439    "null|",
440    "short|",
441    "true|",
442    "void|",
443];
444
445static KW_CSHARP: &[&str] = &[
446    "abstract",
447    "as",
448    "base",
449    "break",
450    "case",
451    "catch",
452    "checked",
453    "class",
454    "const",
455    "continue",
456    "default",
457    "delegate",
458    "do",
459    "else",
460    "enum",
461    "event",
462    "explicit",
463    "extern",
464    "finally",
465    "fixed",
466    "for",
467    "foreach",
468    "goto",
469    "if",
470    "implicit",
471    "in",
472    "interface",
473    "internal",
474    "is",
475    "lock",
476    "namespace",
477    "new",
478    "operator",
479    "out",
480    "override",
481    "params",
482    "private",
483    "protected",
484    "public",
485    "readonly",
486    "ref",
487    "return",
488    "sealed",
489    "sizeof",
490    "stackalloc",
491    "static",
492    "struct",
493    "switch",
494    "this",
495    "throw",
496    "try",
497    "typeof",
498    "unchecked",
499    "unsafe",
500    "using",
501    "virtual",
502    "volatile",
503    "while",
504    "async",
505    "await",
506    "get",
507    "init",
508    "record",
509    "set",
510    "var",
511    "bool|",
512    "byte|",
513    "char|",
514    "decimal|",
515    "double|",
516    "false|",
517    "float|",
518    "int|",
519    "long|",
520    "null|",
521    "object|",
522    "sbyte|",
523    "short|",
524    "string|",
525    "true|",
526    "uint|",
527    "ulong|",
528    "ushort|",
529    "void|",
530];
531
532static KW_GO: &[&str] = &[
533    "break",
534    "case",
535    "chan",
536    "const",
537    "continue",
538    "default",
539    "defer",
540    "else",
541    "fallthrough",
542    "for",
543    "func",
544    "go",
545    "goto",
546    "if",
547    "import",
548    "interface",
549    "map",
550    "package",
551    "range",
552    "return",
553    "select",
554    "struct",
555    "switch",
556    "type",
557    "var",
558    "bool|",
559    "byte|",
560    "complex64|",
561    "complex128|",
562    "error|",
563    "false|",
564    "float32|",
565    "float64|",
566    "int|",
567    "int8|",
568    "int16|",
569    "int32|",
570    "int64|",
571    "nil|",
572    "rune|",
573    "string|",
574    "true|",
575    "uint|",
576    "uint8|",
577    "uint16|",
578    "uint32|",
579    "uint64|",
580    "uintptr|",
581];
582
583static KW_RUST: &[&str] = &[
584    "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
585    "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
586    "return", "self", "Self", "static", "struct", "super", "trait", "type", "unsafe", "use",
587    "where", "while", "bool|", "char|", "false|", "f32|", "f64|", "i8|", "i16|", "i32|", "i64|",
588    "i128|", "isize|", "str|", "String|", "true|", "u8|", "u16|", "u32|", "u64|", "u128|",
589    "usize|",
590];
591
592static KW_SHELL: &[&str] = &[
593    "case", "do", "done", "elif", "else", "esac", "fi", "for", "function", "if", "in", "select",
594    "then", "time", "until", "while", "break", "continue", "return", "export", "local", "readonly",
595    "source", "test", "true|", "false|", "echo|", "printf|", "cd|", "pwd|", "read|", "set|",
596    "unset|", "shift|",
597];
598
599static KW_SQL: &[&str] = &[
600    "add",
601    "alter",
602    "and",
603    "as",
604    "asc",
605    "between",
606    "by",
607    "case",
608    "check",
609    "column",
610    "constraint",
611    "create",
612    "delete",
613    "desc",
614    "distinct",
615    "drop",
616    "else",
617    "end",
618    "exists",
619    "foreign",
620    "from",
621    "group",
622    "having",
623    "in",
624    "index",
625    "insert",
626    "into",
627    "is",
628    "join",
629    "key",
630    "left",
631    "like",
632    "limit",
633    "not",
634    "null",
635    "on",
636    "or",
637    "order",
638    "outer",
639    "primary",
640    "references",
641    "right",
642    "select",
643    "set",
644    "table",
645    "then",
646    "union",
647    "unique",
648    "update",
649    "values",
650    "view",
651    "where",
652    "bigint|",
653    "boolean|",
654    "date|",
655    "decimal|",
656    "false|",
657    "int|",
658    "integer|",
659    "numeric|",
660    "real|",
661    "text|",
662    "timestamp|",
663    "true|",
664    "varchar|",
665];
666
667static KW_RUBY: &[&str] = &[
668    "BEGIN", "END", "alias", "and", "begin", "break", "case", "class", "def", "defined?", "do",
669    "else", "elsif", "end", "ensure", "for", "if", "in", "module", "next", "not", "or", "redo",
670    "rescue", "retry", "return", "self", "super", "then", "undef", "unless", "until", "when",
671    "while", "yield", "false|", "nil|", "true|",
672];
673
674static KW_PHP: &[&str] = &[
675    "abstract",
676    "and",
677    "array",
678    "as",
679    "break",
680    "callable",
681    "case",
682    "catch",
683    "class",
684    "clone",
685    "const",
686    "continue",
687    "declare",
688    "default",
689    "die",
690    "do",
691    "echo",
692    "else",
693    "elseif",
694    "empty",
695    "enddeclare",
696    "endfor",
697    "endforeach",
698    "endif",
699    "endswitch",
700    "endwhile",
701    "eval",
702    "exit",
703    "extends",
704    "final",
705    "finally",
706    "fn",
707    "for",
708    "foreach",
709    "function",
710    "global",
711    "goto",
712    "if",
713    "implements",
714    "include",
715    "include_once",
716    "instanceof",
717    "insteadof",
718    "interface",
719    "isset",
720    "list",
721    "match",
722    "namespace",
723    "new",
724    "or",
725    "print",
726    "private",
727    "protected",
728    "public",
729    "readonly",
730    "require",
731    "require_once",
732    "return",
733    "static",
734    "switch",
735    "throw",
736    "trait",
737    "try",
738    "unset",
739    "use",
740    "var",
741    "while",
742    "xor",
743    "bool|",
744    "false|",
745    "float|",
746    "int|",
747    "null|",
748    "string|",
749    "true|",
750    "void|",
751];
752
753static KW_SWIFT: &[&str] = &[
754    "actor",
755    "as",
756    "associatedtype",
757    "async",
758    "await",
759    "break",
760    "case",
761    "catch",
762    "class",
763    "continue",
764    "default",
765    "defer",
766    "do",
767    "else",
768    "enum",
769    "extension",
770    "fallthrough",
771    "for",
772    "func",
773    "guard",
774    "if",
775    "import",
776    "in",
777    "init",
778    "inout",
779    "is",
780    "let",
781    "nonisolated",
782    "operator",
783    "private",
784    "protocol",
785    "public",
786    "repeat",
787    "return",
788    "self",
789    "Self",
790    "static",
791    "struct",
792    "subscript",
793    "super",
794    "switch",
795    "throw",
796    "throws",
797    "try",
798    "typealias",
799    "var",
800    "where",
801    "while",
802    "Any|",
803    "Bool|",
804    "Double|",
805    "false|",
806    "Float|",
807    "Int|",
808    "nil|",
809    "String|",
810    "true|",
811    "Void|",
812];
813
814static KW_KOTLIN: &[&str] = &[
815    "as",
816    "break",
817    "class",
818    "continue",
819    "do",
820    "else",
821    "false",
822    "for",
823    "fun",
824    "if",
825    "in",
826    "interface",
827    "is",
828    "null",
829    "object",
830    "package",
831    "return",
832    "super",
833    "this",
834    "throw",
835    "true",
836    "try",
837    "typealias",
838    "typeof",
839    "val",
840    "var",
841    "when",
842    "while",
843    "actual",
844    "annotation",
845    "by",
846    "catch",
847    "companion",
848    "const",
849    "constructor",
850    "crossinline",
851    "data",
852    "enum",
853    "expect",
854    "external",
855    "final",
856    "finally",
857    "import",
858    "infix",
859    "init",
860    "inline",
861    "inner",
862    "internal",
863    "lateinit",
864    "noinline",
865    "open",
866    "operator",
867    "out",
868    "override",
869    "private",
870    "protected",
871    "public",
872    "reified",
873    "sealed",
874    "suspend",
875    "tailrec",
876    "vararg",
877    "Any|",
878    "Boolean|",
879    "Byte|",
880    "Char|",
881    "Double|",
882    "Float|",
883    "Int|",
884    "Long|",
885    "Short|",
886    "String|",
887    "Unit|",
888];
889
890static KW_ZIG: &[&str] = &[
891    "addrspace",
892    "align",
893    "allowzero",
894    "and",
895    "anyframe",
896    "anytype",
897    "asm",
898    "async",
899    "await",
900    "break",
901    "callconv",
902    "catch",
903    "comptime",
904    "const",
905    "continue",
906    "defer",
907    "else",
908    "enum",
909    "errdefer",
910    "error",
911    "export",
912    "extern",
913    "fn",
914    "for",
915    "if",
916    "inline",
917    "linksection",
918    "noalias",
919    "noinline",
920    "nosuspend",
921    "opaque",
922    "or",
923    "orelse",
924    "packed",
925    "pub",
926    "resume",
927    "return",
928    "struct",
929    "suspend",
930    "switch",
931    "test",
932    "threadlocal",
933    "try",
934    "union",
935    "unreachable",
936    "usingnamespace",
937    "var",
938    "volatile",
939    "while",
940    "bool|",
941    "false|",
942    "f32|",
943    "f64|",
944    "i32|",
945    "i64|",
946    "null|",
947    "true|",
948    "u8|",
949    "u16|",
950    "u32|",
951    "u64|",
952    "usize|",
953    "void|",
954];
955
956static KW_LUA: &[&str] = &[
957    "and", "break", "do", "else", "elseif", "end", "false", "for", "function", "goto", "if", "in",
958    "local", "nil", "not", "or", "repeat", "return", "then", "true", "until", "while",
959];
960
961static KW_HTML: &[&str] = &[
962    "a", "body", "button", "div", "doctype", "form", "h1", "h2", "h3", "head", "html", "input",
963    "label", "li", "link", "main", "meta", "ol", "option", "p", "script", "section", "select",
964    "span", "style", "table", "tbody", "td", "th", "thead", "title", "tr", "ul", "class|", "href|",
965    "id|", "name|", "rel|", "src|", "type|", "value|",
966];
967
968static KW_CSS: &[&str] = &[
969    "align-items",
970    "background",
971    "border",
972    "bottom",
973    "color",
974    "display",
975    "flex",
976    "font",
977    "font-size",
978    "gap",
979    "grid",
980    "height",
981    "justify-content",
982    "left",
983    "margin",
984    "max-width",
985    "min-width",
986    "padding",
987    "position",
988    "right",
989    "top",
990    "transform",
991    "width",
992    "z-index",
993    "absolute|",
994    "auto|",
995    "block|",
996    "flex|",
997    "grid|",
998    "hidden|",
999    "inline|",
1000    "none|",
1001    "relative|",
1002    "solid|",
1003];
1004
1005static SYNTAXES: &[Syntax] = &[
1006    Syntax {
1007        name: "generic",
1008        aliases: "text txt",
1009        keywords: KW_GENERIC,
1010        singleline_comments: &["//", "#"],
1011        multiline_start: Some("/*"),
1012        multiline_end: Some("*/"),
1013        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS | SYNTAX_BACKTICK_STRINGS,
1014    },
1015    Syntax {
1016        name: "c",
1017        aliases: "c h cpp c++ cc cxx hpp hxx objc objective-c",
1018        keywords: KW_C,
1019        singleline_comments: &["//"],
1020        multiline_start: Some("/*"),
1021        multiline_end: Some("*/"),
1022        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS,
1023    },
1024    Syntax {
1025        name: "python",
1026        aliases: "py python py3",
1027        keywords: KW_PYTHON,
1028        singleline_comments: &["#"],
1029        multiline_start: None,
1030        multiline_end: None,
1031        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS,
1032    },
1033    Syntax {
1034        name: "javascript",
1035        aliases: "js jsx javascript typescript ts tsx node mjs cjs",
1036        keywords: KW_JS,
1037        singleline_comments: &["//"],
1038        multiline_start: Some("/*"),
1039        multiline_end: Some("*/"),
1040        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS | SYNTAX_BACKTICK_STRINGS,
1041    },
1042    Syntax {
1043        name: "java",
1044        aliases: "java",
1045        keywords: KW_JAVA,
1046        singleline_comments: &["//"],
1047        multiline_start: Some("/*"),
1048        multiline_end: Some("*/"),
1049        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS,
1050    },
1051    Syntax {
1052        name: "csharp",
1053        aliases: "cs c# csharp dotnet",
1054        keywords: KW_CSHARP,
1055        singleline_comments: &["//"],
1056        multiline_start: Some("/*"),
1057        multiline_end: Some("*/"),
1058        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS,
1059    },
1060    Syntax {
1061        name: "go",
1062        aliases: "go golang",
1063        keywords: KW_GO,
1064        singleline_comments: &["//"],
1065        multiline_start: Some("/*"),
1066        multiline_end: Some("*/"),
1067        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS | SYNTAX_BACKTICK_STRINGS,
1068    },
1069    Syntax {
1070        name: "rust",
1071        aliases: "rs rust",
1072        keywords: KW_RUST,
1073        singleline_comments: &["//"],
1074        multiline_start: Some("/*"),
1075        multiline_end: Some("*/"),
1076        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS,
1077    },
1078    Syntax {
1079        name: "shell",
1080        aliases: "sh bash zsh shell fish ksh",
1081        keywords: KW_SHELL,
1082        singleline_comments: &["#"],
1083        multiline_start: None,
1084        multiline_end: None,
1085        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS | SYNTAX_BACKTICK_STRINGS,
1086    },
1087    Syntax {
1088        name: "sql",
1089        aliases: "sql postgres mysql sqlite",
1090        keywords: KW_SQL,
1091        singleline_comments: &["--"],
1092        multiline_start: Some("/*"),
1093        multiline_end: Some("*/"),
1094        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS | SYNTAX_CASE_INSENSITIVE,
1095    },
1096    Syntax {
1097        name: "ruby",
1098        aliases: "rb ruby",
1099        keywords: KW_RUBY,
1100        singleline_comments: &["#"],
1101        multiline_start: None,
1102        multiline_end: None,
1103        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS,
1104    },
1105    Syntax {
1106        name: "php",
1107        aliases: "php",
1108        keywords: KW_PHP,
1109        singleline_comments: &["//", "#"],
1110        multiline_start: Some("/*"),
1111        multiline_end: Some("*/"),
1112        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS,
1113    },
1114    Syntax {
1115        name: "swift",
1116        aliases: "swift",
1117        keywords: KW_SWIFT,
1118        singleline_comments: &["//"],
1119        multiline_start: Some("/*"),
1120        multiline_end: Some("*/"),
1121        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS,
1122    },
1123    Syntax {
1124        name: "kotlin",
1125        aliases: "kt kts kotlin",
1126        keywords: KW_KOTLIN,
1127        singleline_comments: &["//"],
1128        multiline_start: Some("/*"),
1129        multiline_end: Some("*/"),
1130        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS,
1131    },
1132    Syntax {
1133        name: "zig",
1134        aliases: "zig",
1135        keywords: KW_ZIG,
1136        singleline_comments: &["//"],
1137        multiline_start: None,
1138        multiline_end: None,
1139        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS,
1140    },
1141    Syntax {
1142        name: "lua",
1143        aliases: "lua",
1144        keywords: KW_LUA,
1145        singleline_comments: &["--"],
1146        multiline_start: None,
1147        multiline_end: None,
1148        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS,
1149    },
1150    Syntax {
1151        name: "html",
1152        aliases: "html htm xml svg",
1153        keywords: KW_HTML,
1154        singleline_comments: &[],
1155        multiline_start: Some("<!--"),
1156        multiline_end: Some("-->"),
1157        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS,
1158    },
1159    Syntax {
1160        name: "css",
1161        aliases: "css scss sass",
1162        keywords: KW_CSS,
1163        singleline_comments: &[],
1164        multiline_start: Some("/*"),
1165        multiline_end: Some("*/"),
1166        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS,
1167    },
1168    Syntax {
1169        name: "json",
1170        aliases: "json jsonc",
1171        keywords: &[],
1172        singleline_comments: &["//"],
1173        multiline_start: Some("/*"),
1174        multiline_end: Some("*/"),
1175        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS,
1176    },
1177    Syntax {
1178        name: "yaml",
1179        aliases: "yaml yml toml ini",
1180        keywords: &[],
1181        singleline_comments: &["#"],
1182        multiline_start: None,
1183        multiline_end: None,
1184        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS,
1185    },
1186    Syntax {
1187        name: "markdown",
1188        aliases: "md markdown",
1189        keywords: KW_GENERIC,
1190        singleline_comments: &[],
1191        multiline_start: Some("<!--"),
1192        multiline_end: Some("-->"),
1193        flags: SYNTAX_NUMBERS | SYNTAX_STRINGS,
1194    },
1195];
1196
1197/// Looks up a syntax entry by language name or alias, defaulting to generic.
1198#[must_use]
1199pub fn syntax_for_lang(lang: &str) -> &'static Syntax {
1200    if !lang.is_empty() {
1201        for s in SYNTAXES {
1202            if s.name.eq_ignore_ascii_case(lang)
1203                || s.aliases
1204                    .split(' ')
1205                    .any(|a| !a.is_empty() && a.eq_ignore_ascii_case(lang))
1206            {
1207                return s;
1208            }
1209        }
1210    }
1211    &SYNTAXES[0]
1212}
1213
1214/// Looks up a syntax entry from a file path's basename and extension.
1215#[must_use]
1216pub fn syntax_for_path(path: &str) -> &'static Syntax {
1217    if path.is_empty() {
1218        return syntax_for_lang("");
1219    }
1220    let base = path.rsplit('/').next().unwrap_or(path);
1221    if base.eq_ignore_ascii_case("Dockerfile") || base.eq_ignore_ascii_case("Makefile") {
1222        return syntax_for_lang("sh");
1223    }
1224    match base.rfind('.') {
1225        Some(dot) if dot + 1 < base.len() => syntax_for_lang(&base[dot + 1..]),
1226        _ => syntax_for_lang(""),
1227    }
1228}
1229
1230fn syntax_separator(c: u8) -> bool {
1231    c == 0 || c.is_ascii_whitespace() || b",.()+-/*=~%[]{}<>:;!&|^?".contains(&c)
1232}
1233
1234fn syntax_color(hl: Highlight) -> u16 {
1235    match hl {
1236        Highlight::Comment => 244,
1237        Highlight::Keyword1 => 214,
1238        Highlight::Keyword2 => 81,
1239        Highlight::String => 150,
1240        Highlight::Number => 203,
1241        Highlight::Normal => 252,
1242    }
1243}
1244
1245fn keyword_len(kw: &str) -> (usize, bool) {
1246    if let Some(stripped) = kw.strip_suffix('|') {
1247        (stripped.len(), true)
1248    } else {
1249        (kw.len(), false)
1250    }
1251}
1252
1253fn bytes_eq_ci(a: &[u8], b: &[u8]) -> bool {
1254    a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.eq_ignore_ascii_case(y))
1255}
1256
1257fn match_keyword(syn: &Syntax, rest: &[u8]) -> Option<(usize, Highlight)> {
1258    for kw in syn.keywords {
1259        let (klen, secondary) = keyword_len(kw);
1260        if rest.len() < klen {
1261            continue;
1262        }
1263        let kbytes = &kw.as_bytes()[..klen];
1264        let matched = if syn.flags & SYNTAX_CASE_INSENSITIVE != 0 {
1265            bytes_eq_ci(&rest[..klen], kbytes)
1266        } else {
1267            &rest[..klen] == kbytes
1268        };
1269        if !matched {
1270            continue;
1271        }
1272        let follow = rest.get(klen).copied().unwrap_or(0);
1273        if !syntax_separator(follow) {
1274            continue;
1275        }
1276        let hl = if secondary {
1277            Highlight::Keyword2
1278        } else {
1279            Highlight::Keyword1
1280        };
1281        return Some((klen, hl));
1282    }
1283    None
1284}
1285
1286fn number_len(rest: &[u8]) -> usize {
1287    rest.iter()
1288        .take_while(|&&c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'.' | b'+' | b'-'))
1289        .count()
1290}
1291
1292// ---------------------------------------------------------------------------
1293// Token renderer
1294// ---------------------------------------------------------------------------
1295
1296/// Options controlling the token renderer's output.
1297#[derive(Debug, Clone, Copy, Default)]
1298pub struct RenderOptions {
1299    /// Emit ANSI color and attribute sequences.
1300    pub use_color: bool,
1301    /// Interpret `<think>`/`</think>` tags and dim thinking text.
1302    pub format_thinking: bool,
1303    /// Interpret markdown cues (bold, italic, inline code, fences).
1304    pub format_markdown: bool,
1305}
1306
1307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1308enum MdPending {
1309    None,
1310    Star,
1311    Backtick,
1312}
1313
1314const UPTO_MARKER: &[u8] = b"[upto]";
1315const FENCE_LANG_MAX: usize = 31;
1316
1317/// SGR for thinking text: italic, in a barely-visible dark gray (256-color
1318/// index 238). Italic (`3`) sets it apart from the assistant's real output.
1319const THINK_GREY: &[u8] = b"\x1b[3;38;5;238m";
1320
1321/// True for control bytes that must not reach the terminal from model text.
1322/// Tab, newline, and carriage return are layout the renderer relies on.
1323fn is_unsafe_control(b: u8) -> bool {
1324    (b < 0x20 && !matches!(b, b'\t' | b'\n' | b'\r')) || b == 0x7f
1325}
1326
1327/// Strips control bytes from text the model supplies.
1328///
1329/// Every escape this renderer emits it writes itself, so an ESC arriving in
1330/// the stream came from the model, or from the file or web page it is quoting.
1331/// Passed through to a terminal it would run as a control sequence — an OSC 52
1332/// clipboard write, a title change, cursor moves that rewrite earlier output —
1333/// which is a plain injection channel out of any file the model reads. Without
1334/// the ESC the rest of such a sequence is inert text, so it is left visible.
1335/// The TUI front end is unaffected (ratatui drops control characters); this is
1336/// the stdout path's equivalent guard.
1337fn without_control_bytes(bytes: &[u8]) -> Cow<'_, [u8]> {
1338    if bytes.iter().copied().any(is_unsafe_control) {
1339        Cow::Owned(
1340            bytes
1341                .iter()
1342                .copied()
1343                .filter(|&b| !is_unsafe_control(b))
1344                .collect(),
1345        )
1346    } else {
1347        Cow::Borrowed(bytes)
1348    }
1349}
1350
1351/// Streaming markdown-aware terminal renderer for assistant output.
1352///
1353/// Port of `agent_token_renderer`: bold/italic/inline code, fenced code
1354/// blocks with keyword highlighting, grey thinking text, and UTF-8-safe
1355/// byte-at-a-time streaming.
1356#[allow(clippy::struct_excessive_bools)]
1357pub struct TokenRenderer<W: Write> {
1358    sink: W,
1359    opts: RenderOptions,
1360    capture: Option<TailCapture>,
1361
1362    in_think: bool,
1363    color_open: bool,
1364    last_output_newline: bool,
1365    wrote_visible_output: bool,
1366
1367    md_bold: bool,
1368    md_italic: bool,
1369    md_inline_code: bool,
1370    md_code_block: bool,
1371    md_fence_info: bool,
1372    md_code_line_start: bool,
1373    md_code_in_ml_comment: bool,
1374    md_syntax_silent: bool,
1375    md_syntax_has_highlight: bool,
1376    md_pending: MdPending,
1377    md_pending_len: usize,
1378    md_syntax: Option<&'static Syntax>,
1379    md_fence_lang: String,
1380    md_code_line_prefix: Option<String>,
1381    md_code_line_prefix_color: Option<String>,
1382    md_code_highlight_upto: bool,
1383    md_code_line: Vec<u8>,
1384
1385    pending: Vec<u8>,
1386    utf8_pending: [u8; 4],
1387    utf8_pending_len: usize,
1388    utf8_pending_need: usize,
1389}
1390
1391impl<W: Write> fmt::Debug for TokenRenderer<W> {
1392    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1393        f.debug_struct("TokenRenderer")
1394            .field("opts", &self.opts)
1395            .field("in_think", &self.in_think)
1396            .field("md_code_block", &self.md_code_block)
1397            .field("wrote_visible_output", &self.wrote_visible_output)
1398            .field("last_output_newline", &self.last_output_newline)
1399            .finish_non_exhaustive()
1400    }
1401}
1402
1403impl<W: Write> TokenRenderer<W> {
1404    /// Borrows the sink, so a caller rendering into a buffer can harvest
1405    /// bytes without consuming the renderer.
1406    pub fn sink_mut(&mut self) -> &mut W {
1407        &mut self.sink
1408    }
1409
1410    /// Creates a renderer writing to `sink` with the given options.
1411    pub fn new(sink: W, opts: RenderOptions) -> Self {
1412        Self {
1413            sink,
1414            opts,
1415            capture: None,
1416            in_think: false,
1417            color_open: false,
1418            last_output_newline: false,
1419            wrote_visible_output: false,
1420            md_bold: false,
1421            md_italic: false,
1422            md_inline_code: false,
1423            md_code_block: false,
1424            md_fence_info: false,
1425            md_code_line_start: false,
1426            md_code_in_ml_comment: false,
1427            md_syntax_silent: false,
1428            md_syntax_has_highlight: false,
1429            md_pending: MdPending::None,
1430            md_pending_len: 0,
1431            md_syntax: None,
1432            md_fence_lang: String::new(),
1433            md_code_line_prefix: None,
1434            md_code_line_prefix_color: None,
1435            md_code_highlight_upto: false,
1436            md_code_line: Vec::new(),
1437            pending: Vec::new(),
1438            utf8_pending: [0; 4],
1439            utf8_pending_len: 0,
1440            utf8_pending_need: 0,
1441        }
1442    }
1443
1444    /// Attaches a tail capture; output is recorded instead of written.
1445    pub fn set_capture(&mut self, capture: Option<TailCapture>) {
1446        self.capture = capture;
1447    }
1448
1449    /// Detaches and returns the tail capture, if any.
1450    pub fn take_capture(&mut self) -> Option<TailCapture> {
1451        self.capture.take()
1452    }
1453
1454    /// Returns `true` if any visible byte has been emitted.
1455    #[must_use]
1456    pub fn wrote_visible_output(&self) -> bool {
1457        self.wrote_visible_output
1458    }
1459
1460    /// Returns `true` if the last emitted output byte was a newline.
1461    #[must_use]
1462    pub fn last_output_newline(&self) -> bool {
1463        self.last_output_newline
1464    }
1465
1466    /// Sets thinking mode: grey text, markdown disabled.
1467    pub fn set_in_think(&mut self, in_think: bool) {
1468        self.in_think = in_think;
1469    }
1470
1471    /// Streams a chunk of assistant text through the renderer.
1472    pub fn write(&mut self, text: &str) {
1473        self.write_bytes(text.as_bytes());
1474    }
1475
1476    /// Streams raw bytes; UTF-8 sequences may be split across calls.
1477    pub fn write_bytes(&mut self, bytes: &[u8]) {
1478        let bytes = without_control_bytes(bytes);
1479        if self.opts.format_thinking {
1480            self.process(&bytes, false);
1481        } else {
1482            for &b in bytes.iter() {
1483                self.write_char(b);
1484            }
1485        }
1486    }
1487
1488    /// Flushes pending state and emits the trailing blank line.
1489    pub fn finish(&mut self) {
1490        if self.opts.format_thinking {
1491            self.process(&[], true);
1492        }
1493        self.markdown_finish();
1494        self.flush_utf8();
1495        self.reset_color();
1496        if self.wrote_visible_output {
1497            if !self.last_output_newline {
1498                self.out(b"\n");
1499            }
1500            self.out(b"\n");
1501            self.last_output_newline = true;
1502        }
1503        let _ = self.sink.flush();
1504    }
1505
1506    /// Consumes the renderer and returns the sink it was writing into.
1507    ///
1508    /// The Turbo Vision front end renders into a `Vec<u8>` and harvests the
1509    /// ANSI, rather than writing it to a terminal.
1510    pub fn into_sink(self) -> W {
1511        self.sink
1512    }
1513
1514    /// Emits a raw color escape, tracking whether a manual color is open.
1515    pub fn color(&mut self, seq: &str) {
1516        self.markdown_emit_pending_literals();
1517        self.flush_utf8();
1518        let reset = seq.is_empty() || seq == "\x1b[0m";
1519        if self.opts.use_color && !seq.is_empty() {
1520            self.out_str(seq);
1521        }
1522        self.color_open = self.opts.use_color && !reset;
1523    }
1524
1525    /// Emits text verbatim, bypassing markdown but flushing pending state.
1526    pub fn plain(&mut self, s: &str) {
1527        let s = without_control_bytes(s.as_bytes());
1528        self.markdown_emit_pending_literals();
1529        self.flush_utf8();
1530        self.out(&s);
1531        if !s.is_empty() {
1532            self.wrote_visible_output = true;
1533            self.last_output_newline = s.last() == Some(&b'\n');
1534        }
1535    }
1536
1537    /// Re-applies tracked text attributes after external output.
1538    pub fn restore_text_attrs(&mut self) {
1539        if !self.opts.use_color || !self.color_open || !self.has_text_attrs() {
1540            return;
1541        }
1542        self.set_text_attrs();
1543    }
1544
1545    /// Enters code-block streaming mode with an explicit syntax.
1546    pub fn code_stream_begin(&mut self, syntax: &'static Syntax) {
1547        self.reset_color();
1548        self.md_code_block = true;
1549        self.md_inline_code = false;
1550        self.md_fence_info = false;
1551        self.md_code_line_start = true;
1552        self.md_code_in_ml_comment = false;
1553        self.md_syntax = Some(syntax);
1554        self.md_fence_lang.clear();
1555        self.md_code_line_prefix = None;
1556        self.md_code_line_prefix_color = None;
1557        self.md_code_highlight_upto = false;
1558        self.md_code_line.clear();
1559    }
1560
1561    /// Sets a per-line prefix (and its color) restored on repaint.
1562    pub fn code_stream_set_prefix(&mut self, prefix: Option<&str>, color: Option<&str>) {
1563        self.md_code_line_prefix = prefix.map(str::to_owned);
1564        self.md_code_line_prefix_color = color.map(str::to_owned);
1565    }
1566
1567    /// Enables highlighting of the literal `[upto]` marker in code lines.
1568    pub fn code_stream_set_upto_marker(&mut self, enabled: bool) {
1569        self.md_code_highlight_upto = enabled;
1570    }
1571
1572    /// Ends code-block streaming mode, emitting any buffered line.
1573    pub fn code_stream_end(&mut self) {
1574        self.code_end();
1575    }
1576
1577    // -- raw output ---------------------------------------------------------
1578
1579    fn out(&mut self, s: &[u8]) {
1580        if let Some(c) = self.capture.as_mut() {
1581            c.append(s);
1582        } else {
1583            let _ = self.sink.write_all(s);
1584        }
1585    }
1586
1587    fn out_str(&mut self, s: &str) {
1588        self.out(s.as_bytes());
1589    }
1590
1591    fn set_grey(&mut self) {
1592        if self.opts.use_color {
1593            // Barely-visible dark gray so thinking text reads as background
1594            // muttering, clearly distinct from the assistant's real output.
1595            self.out(THINK_GREY);
1596        }
1597    }
1598
1599    fn reset_color(&mut self) {
1600        if self.opts.use_color {
1601            self.out(b"\x1b[0m");
1602        }
1603        self.color_open = false;
1604    }
1605
1606    fn has_text_attrs(&self) -> bool {
1607        self.in_think || self.md_bold || self.md_italic || self.md_inline_code || self.md_code_block
1608    }
1609
1610    fn set_text_attrs(&mut self) {
1611        if !self.opts.use_color {
1612            return;
1613        }
1614        if self.in_think {
1615            self.set_grey();
1616            return;
1617        }
1618        if self.md_code_block {
1619            self.out(b"\x1b[38;5;75m");
1620            return;
1621        } else if self.md_inline_code {
1622            self.out(b"\x1b[36m");
1623        }
1624        if self.md_bold {
1625            self.out(b"\x1b[1m");
1626        }
1627        if self.md_italic {
1628            self.out(b"\x1b[3m");
1629        }
1630    }
1631
1632    fn write_complete_char_raw(&mut self, s: &[u8]) {
1633        let styled = self.opts.use_color && self.has_text_attrs();
1634        if styled && !self.color_open {
1635            self.set_text_attrs();
1636            self.color_open = true;
1637        } else if !styled && self.color_open {
1638            self.reset_color();
1639        }
1640        self.out(s);
1641        if !s.is_empty() {
1642            self.wrote_visible_output = true;
1643        }
1644        self.last_output_newline = s == b"\n";
1645    }
1646
1647    fn flush_utf8(&mut self) {
1648        if self.utf8_pending_len == 0 {
1649            return;
1650        }
1651        let buf: [u8; 4] = self.utf8_pending;
1652        let len = self.utf8_pending_len;
1653        self.write_complete_char_raw(&buf[..len]);
1654        self.utf8_pending_len = 0;
1655        self.utf8_pending_need = 0;
1656    }
1657
1658    fn utf8_need(c: u8) -> usize {
1659        match c {
1660            0xc2..=0xdf => 2,
1661            0xe0..=0xef => 3,
1662            0xf0..=0xf4 => 4,
1663            _ => 1,
1664        }
1665    }
1666
1667    fn write_char_raw(&mut self, c: u8) {
1668        if self.utf8_pending_len > 0 {
1669            if c & 0xc0 == 0x80 && self.utf8_pending_len < self.utf8_pending.len() {
1670                self.utf8_pending[self.utf8_pending_len] = c;
1671                self.utf8_pending_len += 1;
1672                if self.utf8_pending_len == self.utf8_pending_need {
1673                    self.flush_utf8();
1674                }
1675                return;
1676            }
1677            self.flush_utf8();
1678        }
1679
1680        let need = Self::utf8_need(c);
1681        if need == 1 {
1682            self.write_complete_char_raw(&[c]);
1683            return;
1684        }
1685        self.utf8_pending[0] = c;
1686        self.utf8_pending_len = 1;
1687        self.utf8_pending_need = need;
1688    }
1689
1690    /// Writes one byte with markdown attributes temporarily disabled.
1691    fn write_plain_byte(&mut self, c: u8) {
1692        let (bold, italic, inline, block) = (
1693            self.md_bold,
1694            self.md_italic,
1695            self.md_inline_code,
1696            self.md_code_block,
1697        );
1698        self.md_bold = false;
1699        self.md_italic = false;
1700        self.md_inline_code = false;
1701        self.md_code_block = false;
1702        self.write_char_raw(c);
1703        self.md_bold = bold;
1704        self.md_italic = italic;
1705        self.md_inline_code = inline;
1706        self.md_code_block = block;
1707    }
1708
1709    // -- syntax highlighting ------------------------------------------------
1710
1711    fn syntax_write(&mut self, hl: Highlight, s: &[u8]) {
1712        if s.is_empty() {
1713            return;
1714        }
1715        if hl != Highlight::Normal {
1716            self.md_syntax_has_highlight = true;
1717        }
1718        if self.md_syntax_silent {
1719            return;
1720        }
1721        if self.opts.use_color && hl != Highlight::Normal {
1722            let seq = format!("\x1b[38;5;{}m", syntax_color(hl));
1723            self.out_str(&seq);
1724        }
1725        self.out(s);
1726        if self.opts.use_color && hl != Highlight::Normal {
1727            self.out(b"\x1b[0m");
1728        }
1729        self.wrote_visible_output = true;
1730        self.last_output_newline = false;
1731    }
1732
1733    fn syntax_write_upto_marker(&mut self) {
1734        self.md_syntax_has_highlight = true;
1735        if self.md_syntax_silent {
1736            return;
1737        }
1738        if self.opts.use_color {
1739            self.out(b"\x1b[38;5;244m[");
1740            self.out(b"\x1b[1;38;5;177mupto");
1741            self.out(b"\x1b[38;5;244m]\x1b[0m");
1742        } else {
1743            self.out(UPTO_MARKER);
1744        }
1745        self.wrote_visible_output = true;
1746        self.last_output_newline = false;
1747    }
1748
1749    #[allow(clippy::too_many_lines)]
1750    fn syntax_emit_line(&mut self, line: &[u8]) {
1751        let syn = self.md_syntax.unwrap_or_else(|| syntax_for_lang(""));
1752        let mut i = 0;
1753        let end = line.len();
1754        let mut prev_sep = true;
1755        let mut prev_hl = Highlight::Normal;
1756
1757        while i < end {
1758            let rest = &line[i..];
1759
1760            if self.md_code_highlight_upto && rest.starts_with(UPTO_MARKER) {
1761                self.syntax_write_upto_marker();
1762                i += UPTO_MARKER.len();
1763                prev_sep = true;
1764                prev_hl = Highlight::Normal;
1765                continue;
1766            }
1767
1768            if self.md_code_in_ml_comment {
1769                if let Some(mce) = syn.multiline_end
1770                    && let Some(pos) = find_sub(rest, mce.as_bytes())
1771                {
1772                    let take = pos + mce.len();
1773                    let seg = &line[i..i + take];
1774                    self.syntax_write(Highlight::Comment, seg);
1775                    i += take;
1776                    self.md_code_in_ml_comment = false;
1777                    prev_sep = true;
1778                    prev_hl = Highlight::Comment;
1779                    continue;
1780                }
1781                let seg = &line[i..];
1782                self.syntax_write(Highlight::Comment, seg);
1783                return;
1784            }
1785
1786            if syn
1787                .singleline_comments
1788                .iter()
1789                .any(|m| rest.starts_with(m.as_bytes()))
1790            {
1791                let seg = &line[i..];
1792                self.syntax_write(Highlight::Comment, seg);
1793                return;
1794            }
1795
1796            if let (Some(mls), Some(mle)) = (syn.multiline_start, syn.multiline_end)
1797                && rest.starts_with(mls.as_bytes())
1798            {
1799                let body = &rest[mls.len()..];
1800                let take = if let Some(pos) = find_sub(body, mle.as_bytes()) {
1801                    mls.len() + pos + mle.len()
1802                } else {
1803                    self.md_code_in_ml_comment = true;
1804                    rest.len()
1805                };
1806                let seg = &line[i..i + take];
1807                self.syntax_write(Highlight::Comment, seg);
1808                i += take;
1809                prev_sep = false;
1810                prev_hl = Highlight::Comment;
1811                continue;
1812            }
1813
1814            let c = rest[0];
1815            if syn.flags & SYNTAX_STRINGS != 0
1816                && (c == b'"'
1817                    || c == b'\''
1818                    || (syn.flags & SYNTAX_BACKTICK_STRINGS != 0 && c == b'`'))
1819            {
1820                let quote = c;
1821                let mut q = 1;
1822                while q < rest.len() {
1823                    if rest[q] == b'\\' && q + 1 < rest.len() {
1824                        q += 2;
1825                        continue;
1826                    }
1827                    q += 1;
1828                    if rest[q - 1] == quote {
1829                        break;
1830                    }
1831                }
1832                let seg = &line[i..i + q];
1833                self.syntax_write(Highlight::String, seg);
1834                i += q;
1835                prev_sep = false;
1836                prev_hl = Highlight::String;
1837                continue;
1838            }
1839
1840            let number_start = c.is_ascii_digit() && (prev_sep || prev_hl == Highlight::Number)
1841                || (c == b'.' && i > 0 && prev_hl == Highlight::Number);
1842            if syn.flags & SYNTAX_NUMBERS != 0 && number_start {
1843                let nlen = number_len(rest);
1844                let seg = &line[i..i + nlen];
1845                self.syntax_write(Highlight::Number, seg);
1846                i += nlen;
1847                prev_sep = false;
1848                prev_hl = Highlight::Number;
1849                continue;
1850            }
1851
1852            if prev_sep && let Some((klen, khl)) = match_keyword(syn, rest) {
1853                let seg = &line[i..i + klen];
1854                self.syntax_write(khl, seg);
1855                i += klen;
1856                prev_sep = false;
1857                prev_hl = khl;
1858                continue;
1859            }
1860
1861            let seg = &line[i..=i];
1862            self.syntax_write(Highlight::Normal, seg);
1863            prev_sep = syntax_separator(c);
1864            prev_hl = Highlight::Normal;
1865            i += 1;
1866        }
1867    }
1868
1869    // -- code block line buffering / repaint ---------------------------------
1870
1871    fn terminal_cols() -> usize {
1872        // Deviation from the C reference: the sink is a generic writer, so we
1873        // cannot ioctl(TIOCGWINSZ); assume the classic 80-column default.
1874        80
1875    }
1876
1877    fn code_line_can_repaint(&self) -> bool {
1878        if !self.opts.use_color || self.capture.is_some() || self.md_code_line.is_empty() {
1879            return false;
1880        }
1881        let cols = Self::terminal_cols();
1882        let prefix_len = self.md_code_line_prefix.as_ref().map_or(0, String::len);
1883        if cols <= 1 || prefix_len + self.md_code_line.len() >= cols {
1884            return false;
1885        }
1886        self.md_code_line
1887            .iter()
1888            .all(|&c| c == b'\r' || (0x20..0x80).contains(&c) && c != 0x1b)
1889    }
1890
1891    fn code_write_line_prefix(&mut self) {
1892        let Some(prefix) = self.md_code_line_prefix.clone() else {
1893            return;
1894        };
1895        let color = self.md_code_line_prefix_color.clone();
1896        if self.opts.use_color
1897            && let Some(col) = &color
1898        {
1899            self.out_str(col);
1900        }
1901        self.out_str(&prefix);
1902        if self.opts.use_color && color.is_some() {
1903            self.out(b"\x1b[0m");
1904        }
1905        self.color_open = false;
1906    }
1907
1908    /// Runs the highlighter silently to learn whether repainting would change
1909    /// the line, preserving multiline-comment state for the caller.
1910    fn code_scan_line(&mut self) -> (bool, bool) {
1911        let old_silent = self.md_syntax_silent;
1912        let old_highlight = self.md_syntax_has_highlight;
1913        let old_ml = self.md_code_in_ml_comment;
1914
1915        self.md_syntax_silent = true;
1916        self.md_syntax_has_highlight = false;
1917        let line = std::mem::take(&mut self.md_code_line);
1918        self.syntax_emit_line(&line);
1919        self.md_code_line = line;
1920        let changed = self.md_syntax_has_highlight;
1921        let final_ml = self.md_code_in_ml_comment;
1922
1923        self.md_code_in_ml_comment = old_ml;
1924        self.md_syntax_silent = old_silent;
1925        self.md_syntax_has_highlight = old_highlight;
1926        (changed, final_ml)
1927    }
1928
1929    fn code_emit_buffered_line(&mut self, with_newline: bool) {
1930        let (changed, final_ml) = self.code_scan_line();
1931        let repaint = changed && self.code_line_can_repaint();
1932        if repaint {
1933            self.reset_color();
1934            self.out(b"\r\x1b[0K");
1935            self.code_write_line_prefix();
1936            let line = std::mem::take(&mut self.md_code_line);
1937            self.syntax_emit_line(&line);
1938            self.md_code_line = line;
1939        } else {
1940            self.md_code_in_ml_comment = final_ml;
1941        }
1942        self.md_code_line.clear();
1943        if with_newline {
1944            self.write_plain_byte(b'\n');
1945            self.wrote_visible_output = true;
1946            self.last_output_newline = true;
1947            self.md_code_line_start = true;
1948        }
1949    }
1950
1951    fn code_byte(&mut self, c: u8) {
1952        if c == b'\n' {
1953            self.code_emit_buffered_line(true);
1954            return;
1955        }
1956        self.md_code_line.push(c);
1957        self.write_plain_byte(c);
1958        if c != b' ' && c != b'\t' && c != b'\r' {
1959            self.md_code_line_start = false;
1960        }
1961    }
1962
1963    fn code_emit_backtick_literals(&mut self, count: usize) {
1964        for _ in 0..count {
1965            self.code_byte(b'`');
1966        }
1967    }
1968
1969    fn code_begin(&mut self) {
1970        self.reset_color();
1971        self.md_code_block = true;
1972        self.md_inline_code = false;
1973        self.md_fence_info = true;
1974        self.md_code_line_start = true;
1975        self.md_code_in_ml_comment = false;
1976        self.md_syntax = Some(syntax_for_lang(""));
1977        self.md_fence_lang.clear();
1978        self.md_code_line_prefix = None;
1979        self.md_code_line_prefix_color = None;
1980        self.md_code_highlight_upto = false;
1981        self.md_code_line.clear();
1982    }
1983
1984    fn code_end(&mut self) {
1985        let only_space = self
1986            .md_code_line
1987            .iter()
1988            .all(|&c| c == b' ' || c == b'\t' || c == b'\r');
1989        if !self.md_code_line.is_empty() && !only_space {
1990            self.code_emit_buffered_line(false);
1991        } else {
1992            self.md_code_line.clear();
1993        }
1994        self.md_code_block = false;
1995        self.md_inline_code = false;
1996        self.md_fence_info = false;
1997        self.md_code_line_start = true;
1998        self.md_code_in_ml_comment = false;
1999        self.md_syntax = None;
2000        self.md_fence_lang.clear();
2001        self.md_code_line_prefix = None;
2002        self.md_code_line_prefix_color = None;
2003    }
2004
2005    // -- markdown state machine ----------------------------------------------
2006
2007    fn markdown_clear_pending(&mut self) {
2008        self.md_pending = MdPending::None;
2009        self.md_pending_len = 0;
2010    }
2011
2012    fn markdown_emit_pending_literals(&mut self) {
2013        let c = match self.md_pending {
2014            MdPending::Star => b'*',
2015            MdPending::Backtick => b'`',
2016            MdPending::None => return,
2017        };
2018        let count = self.md_pending_len;
2019        self.markdown_clear_pending();
2020        if self.md_code_block {
2021            if c == b'`' {
2022                self.code_emit_backtick_literals(count);
2023            } else {
2024                for _ in 0..count {
2025                    self.code_byte(c);
2026                }
2027            }
2028            return;
2029        }
2030        for _ in 0..count {
2031            self.write_char_raw(c);
2032        }
2033    }
2034
2035    fn markdown_commit_backticks(&mut self) {
2036        let count = self.md_pending_len;
2037        self.markdown_clear_pending();
2038        if count >= 3 {
2039            for _ in 0..count {
2040                self.write_plain_byte(b'`');
2041            }
2042            if self.md_code_block {
2043                self.code_end();
2044            } else {
2045                self.code_begin();
2046            }
2047            return;
2048        }
2049        if self.md_code_block {
2050            self.code_emit_backtick_literals(count);
2051            return;
2052        }
2053        // Support both `code` and ``code``.
2054        self.md_inline_code = !self.md_inline_code;
2055    }
2056
2057    fn markdown_feed(&mut self, c: u8) {
2058        if self.md_fence_info {
2059            if c == b'\n' {
2060                if self.md_code_block {
2061                    self.md_syntax = Some(syntax_for_lang(&self.md_fence_lang.clone()));
2062                }
2063                self.write_plain_byte(b'\n');
2064                self.md_fence_info = false;
2065            } else if self.md_code_block {
2066                if self.md_fence_lang.len() < FENCE_LANG_MAX
2067                    && (c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-' | b'+' | b'#'))
2068                {
2069                    self.md_fence_lang.push(char::from(c));
2070                }
2071                self.write_plain_byte(c);
2072            }
2073            return;
2074        }
2075
2076        if self.md_pending == MdPending::Backtick {
2077            if c == b'`' {
2078                self.md_pending_len += 1;
2079                return;
2080            }
2081            self.markdown_commit_backticks();
2082            self.markdown_feed(c);
2083            return;
2084        }
2085
2086        if self.md_pending == MdPending::Star {
2087            self.markdown_clear_pending();
2088            if !self.md_inline_code && !self.md_code_block && c == b'*' {
2089                self.md_bold = !self.md_bold;
2090                return;
2091            }
2092            if !self.md_inline_code
2093                && !self.md_code_block
2094                && (self.md_italic || !matches!(c, b' ' | b'\t' | b'\r' | b'\n'))
2095            {
2096                self.md_italic = !self.md_italic;
2097                self.markdown_feed(c);
2098                return;
2099            }
2100            self.write_char_raw(b'*');
2101            self.markdown_feed(c);
2102            return;
2103        }
2104
2105        if c == b'`' && (!self.md_code_block || self.md_code_line_start) {
2106            self.md_pending = MdPending::Backtick;
2107            self.md_pending_len = 1;
2108            return;
2109        }
2110        if self.md_code_block {
2111            self.code_byte(c);
2112            return;
2113        }
2114        if !self.md_inline_code && c == b'*' {
2115            self.md_pending = MdPending::Star;
2116            self.md_pending_len = 1;
2117            return;
2118        }
2119        self.write_char_raw(c);
2120    }
2121
2122    fn markdown_finish(&mut self) {
2123        // A closing code fence can be the final bytes of the reply; commit a
2124        // full fence instead of leaking the literal ``` marker.
2125        if self.md_pending == MdPending::Backtick && self.md_pending_len >= 3 {
2126            self.markdown_commit_backticks();
2127        } else {
2128            self.markdown_emit_pending_literals();
2129        }
2130        if self.md_code_block && !self.md_code_line.is_empty() {
2131            self.code_emit_buffered_line(false);
2132        }
2133        self.md_bold = false;
2134        self.md_italic = false;
2135        self.md_inline_code = false;
2136        self.md_code_block = false;
2137        self.md_fence_info = false;
2138        self.md_code_line_start = false;
2139        self.md_code_in_ml_comment = false;
2140        self.md_syntax = None;
2141        self.md_fence_lang.clear();
2142        self.md_code_line_prefix = None;
2143        self.md_code_line_prefix_color = None;
2144        self.md_code_highlight_upto = false;
2145        self.md_code_line = Vec::new();
2146    }
2147
2148    fn write_char(&mut self, c: u8) {
2149        if !self.opts.format_markdown || self.in_think {
2150            self.markdown_emit_pending_literals();
2151            self.write_char_raw(c);
2152            return;
2153        }
2154        self.markdown_feed(c);
2155    }
2156
2157    // -- think tag processing --------------------------------------------------
2158
2159    /// Renders text while hiding `<think>` tags and dimming thinking text,
2160    /// holding back a partial control tag split across model tokens.
2161    fn process(&mut self, text: &[u8], finish: bool) {
2162        const THINK_OPEN: &[u8] = b"<think>";
2163        const THINK_CLOSE: &[u8] = b"</think>";
2164
2165        let mut buf = std::mem::take(&mut self.pending);
2166        buf.extend_from_slice(text);
2167
2168        let mut i = 0;
2169        while i < buf.len() {
2170            let cur = &buf[i..];
2171            if cur.starts_with(THINK_OPEN) {
2172                self.in_think = true;
2173                i += THINK_OPEN.len();
2174                continue;
2175            }
2176            if cur.starts_with(THINK_CLOSE) {
2177                self.in_think = false;
2178                self.reset_color();
2179                if !self.last_output_newline {
2180                    self.out(b"\n");
2181                }
2182                self.out(b"\n");
2183                self.last_output_newline = true;
2184                i += THINK_CLOSE.len();
2185                continue;
2186            }
2187            if !finish
2188                && cur[0] == b'<'
2189                && (is_partial_prefix(cur, THINK_OPEN) || is_partial_prefix(cur, THINK_CLOSE))
2190            {
2191                self.pending = cur.to_vec();
2192                return;
2193            }
2194            self.write_char(cur[0]);
2195            i += 1;
2196        }
2197    }
2198}
2199
2200fn is_partial_prefix(p: &[u8], prefix: &[u8]) -> bool {
2201    p.len() < prefix.len() && prefix.starts_with(p)
2202}
2203
2204fn find_sub(haystack: &[u8], needle: &[u8]) -> Option<usize> {
2205    if needle.is_empty() || haystack.len() < needle.len() {
2206        return None;
2207    }
2208    (0..=haystack.len() - needle.len()).find(|&i| &haystack[i..i + needle.len()] == needle)
2209}
2210
2211// ---------------------------------------------------------------------------
2212// Tests
2213// ---------------------------------------------------------------------------
2214
2215#[cfg(test)]
2216mod tests {
2217    use super::*;
2218
2219    fn renderer(opts: RenderOptions) -> TokenRenderer<Vec<u8>> {
2220        TokenRenderer::new(Vec::new(), opts)
2221    }
2222
2223    fn output(r: TokenRenderer<Vec<u8>>) -> String {
2224        String::from_utf8(r.sink).unwrap()
2225    }
2226
2227    const COLOR_MD: RenderOptions = RenderOptions {
2228        use_color: true,
2229        format_thinking: false,
2230        format_markdown: true,
2231    };
2232
2233    #[test]
2234    fn plain_text_passthrough() {
2235        let mut r = renderer(RenderOptions::default());
2236        r.write("hello world\n");
2237        assert!(r.wrote_visible_output());
2238        assert!(r.last_output_newline());
2239        r.finish();
2240        let out = output(r);
2241        assert!(out.starts_with("hello world\n"));
2242        assert!(!out.contains('\x1b'));
2243    }
2244
2245    #[test]
2246    fn model_escape_sequences_never_reach_the_terminal() {
2247        // A file the model quotes back can carry an OSC 52 clipboard write.
2248        let mut r = renderer(RenderOptions::default());
2249        r.write("before\x1b]52;c;cHduZWQ=\x07after");
2250        r.plain("banner \x1b[31mred\x1b[0m\n");
2251        r.finish();
2252        let out = output(r);
2253        assert!(!out.contains('\x1b'), "escape survived: {out:?}");
2254        assert!(!out.contains('\x07'), "bell survived: {out:?}");
2255        // Neutered, not swallowed: the payload stays readable as plain text.
2256        assert!(out.contains("before") && out.contains("after"));
2257        assert!(out.contains("banner "));
2258    }
2259
2260    #[test]
2261    fn bold_and_italic_markers() {
2262        let mut r = renderer(COLOR_MD);
2263        r.write("a **bold** and *ital* b");
2264        r.finish();
2265        let out = output(r);
2266        assert!(out.contains("\x1b[1mbold"), "bold SGR missing: {out:?}");
2267        assert!(out.contains("\x1b[3mital"), "italic SGR missing: {out:?}");
2268        assert!(!out.contains('*'), "markers leaked: {out:?}");
2269    }
2270
2271    #[test]
2272    fn inline_code_cyan() {
2273        let mut r = renderer(COLOR_MD);
2274        r.write("run `ls -la` now");
2275        r.finish();
2276        let out = output(r);
2277        assert!(out.contains("\x1b[36mls -la"), "cyan inline code: {out:?}");
2278        assert!(!out.contains('`'));
2279    }
2280
2281    #[test]
2282    fn fenced_rust_block_keyword_highlighting() {
2283        let mut r = renderer(COLOR_MD);
2284        r.write("```rust\nfn main() { let x = 42; }\n```\n");
2285        r.finish();
2286        let out = output(r);
2287        // Repaint replaces the streamed line with highlighted text.
2288        assert!(out.contains("\r\x1b[0K"), "repaint missing: {out:?}");
2289        assert!(out.contains("\x1b[38;5;214mfn"), "kw1 'fn': {out:?}");
2290        assert!(out.contains("\x1b[38;5;214mlet"), "kw1 'let': {out:?}");
2291        assert!(out.contains("\x1b[38;5;203m42"), "number: {out:?}");
2292        // As in the C, code streams plain first; the fence markers stay visible.
2293        assert!(
2294            out.contains("```") && out.contains("rust\n"),
2295            "fence line: {out:?}"
2296        );
2297    }
2298
2299    #[test]
2300    fn thinking_rendered_grey() {
2301        let mut r = renderer(RenderOptions {
2302            use_color: true,
2303            format_thinking: true,
2304            format_markdown: true,
2305        });
2306        r.write("<think>pondering</think>answer");
2307        r.finish();
2308        let out = output(r);
2309        assert!(
2310            out.contains("\x1b[3;38;5;238mpondering"),
2311            "italic grey think: {out:?}"
2312        );
2313        assert!(!out.contains("<think>"));
2314        assert!(!out.contains("</think>"));
2315        assert!(out.contains("answer"));
2316    }
2317
2318    #[test]
2319    fn partial_think_tag_held_across_writes() {
2320        let mut r = renderer(RenderOptions {
2321            use_color: false,
2322            format_thinking: true,
2323            format_markdown: false,
2324        });
2325        r.write("<thi");
2326        r.write("nk>x</thi");
2327        r.write("nk>y");
2328        r.finish();
2329        let out = output(r);
2330        assert!(!out.contains('<'));
2331        assert!(out.contains('x'));
2332        assert!(out.contains('y'));
2333    }
2334
2335    #[test]
2336    fn utf8_split_across_writes() {
2337        let mut r = renderer(RenderOptions::default());
2338        let euro = "€".as_bytes(); // three bytes
2339        r.write_bytes(&euro[..1]);
2340        r.write_bytes(&euro[1..2]);
2341        r.write_bytes(&euro[2..]);
2342        r.finish();
2343        let out = output(r);
2344        assert!(out.starts_with('€'), "utf-8 reassembly failed: {out:?}");
2345    }
2346
2347    #[test]
2348    fn tail_capture_records_last_bytes() {
2349        let mut cap = TailCapture::new(8);
2350        cap.append(b"0123456789abcdef");
2351        assert_eq!(cap.total(), 16);
2352        assert_eq!(cap.len(), 8);
2353        let taken = cap.take();
2354        assert_eq!(taken, b"89abcdef");
2355        assert!(cap.is_empty());
2356
2357        // Attached to a renderer, output goes to the capture, not the sink.
2358        let mut r = renderer(RenderOptions::default());
2359        r.set_capture(Some(TailCapture::new(64)));
2360        r.write("captured text");
2361        let mut got = r.take_capture().unwrap();
2362        assert_eq!(got.take(), b"captured text");
2363        assert!(r.sink.is_empty());
2364    }
2365
2366    #[test]
2367    fn syntax_lookup_by_lang_and_path() {
2368        assert_eq!(syntax_for_lang("rs").name(), "rust");
2369        assert_eq!(syntax_for_lang("TypeScript").name(), "javascript");
2370        assert_eq!(syntax_for_lang("nosuchlang").name(), "generic");
2371        assert_eq!(syntax_for_path("src/main.rs").name(), "rust");
2372        assert_eq!(syntax_for_path("Dockerfile").name(), "shell");
2373        assert_eq!(syntax_for_path("a/b/Makefile").name(), "shell");
2374        assert_eq!(syntax_for_path("noext").name(), "generic");
2375    }
2376}