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
1245/// The SGR style parameter (with trailing `;`) that precedes a category's
1246/// color, or `""` for none. Keywords render bold, comments italic; the rest
1247/// carry color only. Terminals that do not support a style ignore its code.
1248fn syntax_style(hl: Highlight) -> &'static str {
1249    match hl {
1250        Highlight::Keyword1 | Highlight::Keyword2 => "1;",
1251        Highlight::Comment => "3;",
1252        Highlight::String | Highlight::Number | Highlight::Normal => "",
1253    }
1254}
1255
1256fn keyword_len(kw: &str) -> (usize, bool) {
1257    if let Some(stripped) = kw.strip_suffix('|') {
1258        (stripped.len(), true)
1259    } else {
1260        (kw.len(), false)
1261    }
1262}
1263
1264fn bytes_eq_ci(a: &[u8], b: &[u8]) -> bool {
1265    a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.eq_ignore_ascii_case(y))
1266}
1267
1268fn match_keyword(syn: &Syntax, rest: &[u8]) -> Option<(usize, Highlight)> {
1269    for kw in syn.keywords {
1270        let (klen, secondary) = keyword_len(kw);
1271        if rest.len() < klen {
1272            continue;
1273        }
1274        let kbytes = &kw.as_bytes()[..klen];
1275        let matched = if syn.flags & SYNTAX_CASE_INSENSITIVE != 0 {
1276            bytes_eq_ci(&rest[..klen], kbytes)
1277        } else {
1278            &rest[..klen] == kbytes
1279        };
1280        if !matched {
1281            continue;
1282        }
1283        let follow = rest.get(klen).copied().unwrap_or(0);
1284        if !syntax_separator(follow) {
1285            continue;
1286        }
1287        let hl = if secondary {
1288            Highlight::Keyword2
1289        } else {
1290            Highlight::Keyword1
1291        };
1292        return Some((klen, hl));
1293    }
1294    None
1295}
1296
1297fn number_len(rest: &[u8]) -> usize {
1298    rest.iter()
1299        .take_while(|&&c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'.' | b'+' | b'-'))
1300        .count()
1301}
1302
1303// ---------------------------------------------------------------------------
1304// Token renderer
1305// ---------------------------------------------------------------------------
1306
1307/// Options controlling the token renderer's output.
1308#[derive(Debug, Clone, Copy, Default)]
1309pub struct RenderOptions {
1310    /// Emit ANSI color and attribute sequences.
1311    pub use_color: bool,
1312    /// Interpret `<think>`/`</think>` tags and dim thinking text.
1313    pub format_thinking: bool,
1314    /// Interpret markdown cues (bold, italic, inline code, fences).
1315    pub format_markdown: bool,
1316}
1317
1318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1319enum MdPending {
1320    None,
1321    Star,
1322    Backtick,
1323}
1324
1325const UPTO_MARKER: &[u8] = b"[upto]";
1326const FENCE_LANG_MAX: usize = 31;
1327
1328/// SGR for thinking text: italic, in a barely-visible dark gray (256-color
1329/// index 238). Italic (`3`) sets it apart from the assistant's real output.
1330const THINK_GREY: &[u8] = b"\x1b[3;38;5;238m";
1331
1332/// True for control bytes that must not reach the terminal from model text.
1333/// Tab, newline, and carriage return are layout the renderer relies on.
1334fn is_unsafe_control(b: u8) -> bool {
1335    (b < 0x20 && !matches!(b, b'\t' | b'\n' | b'\r')) || b == 0x7f
1336}
1337
1338/// Strips control bytes from text the model supplies.
1339///
1340/// Every escape this renderer emits it writes itself, so an ESC arriving in
1341/// the stream came from the model, or from the file or web page it is quoting.
1342/// Passed through to a terminal it would run as a control sequence — an OSC 52
1343/// clipboard write, a title change, cursor moves that rewrite earlier output —
1344/// which is a plain injection channel out of any file the model reads. Without
1345/// the ESC the rest of such a sequence is inert text, so it is left visible.
1346/// The TUI front end is unaffected (ratatui drops control characters); this is
1347/// the stdout path's equivalent guard.
1348fn without_control_bytes(bytes: &[u8]) -> Cow<'_, [u8]> {
1349    if bytes.iter().copied().any(is_unsafe_control) {
1350        Cow::Owned(
1351            bytes
1352                .iter()
1353                .copied()
1354                .filter(|&b| !is_unsafe_control(b))
1355                .collect(),
1356        )
1357    } else {
1358        Cow::Borrowed(bytes)
1359    }
1360}
1361
1362/// Streaming markdown-aware terminal renderer for assistant output.
1363///
1364/// Port of `agent_token_renderer`: bold/italic/inline code, fenced code
1365/// blocks with keyword highlighting, grey thinking text, and UTF-8-safe
1366/// byte-at-a-time streaming.
1367#[allow(clippy::struct_excessive_bools)]
1368pub struct TokenRenderer<W: Write> {
1369    sink: W,
1370    opts: RenderOptions,
1371    capture: Option<TailCapture>,
1372
1373    in_think: bool,
1374    color_open: bool,
1375    last_output_newline: bool,
1376    wrote_visible_output: bool,
1377
1378    md_bold: bool,
1379    md_italic: bool,
1380    md_inline_code: bool,
1381    md_code_block: bool,
1382    md_fence_info: bool,
1383    md_code_line_start: bool,
1384    md_code_in_ml_comment: bool,
1385    md_syntax_silent: bool,
1386    md_syntax_has_highlight: bool,
1387    md_pending: MdPending,
1388    md_pending_len: usize,
1389    md_syntax: Option<&'static Syntax>,
1390    md_fence_lang: String,
1391    md_code_line_prefix: Option<String>,
1392    md_code_line_prefix_color: Option<String>,
1393    md_code_highlight_upto: bool,
1394    md_code_line: Vec<u8>,
1395
1396    pending: Vec<u8>,
1397    utf8_pending: [u8; 4],
1398    utf8_pending_len: usize,
1399    utf8_pending_need: usize,
1400}
1401
1402impl<W: Write> fmt::Debug for TokenRenderer<W> {
1403    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1404        f.debug_struct("TokenRenderer")
1405            .field("opts", &self.opts)
1406            .field("in_think", &self.in_think)
1407            .field("md_code_block", &self.md_code_block)
1408            .field("wrote_visible_output", &self.wrote_visible_output)
1409            .field("last_output_newline", &self.last_output_newline)
1410            .finish_non_exhaustive()
1411    }
1412}
1413
1414impl<W: Write> TokenRenderer<W> {
1415    /// Borrows the sink, so a caller rendering into a buffer can harvest
1416    /// bytes without consuming the renderer.
1417    pub fn sink_mut(&mut self) -> &mut W {
1418        &mut self.sink
1419    }
1420
1421    /// Creates a renderer writing to `sink` with the given options.
1422    pub fn new(sink: W, opts: RenderOptions) -> Self {
1423        Self {
1424            sink,
1425            opts,
1426            capture: None,
1427            in_think: false,
1428            color_open: false,
1429            last_output_newline: false,
1430            wrote_visible_output: false,
1431            md_bold: false,
1432            md_italic: false,
1433            md_inline_code: false,
1434            md_code_block: false,
1435            md_fence_info: false,
1436            md_code_line_start: false,
1437            md_code_in_ml_comment: false,
1438            md_syntax_silent: false,
1439            md_syntax_has_highlight: false,
1440            md_pending: MdPending::None,
1441            md_pending_len: 0,
1442            md_syntax: None,
1443            md_fence_lang: String::new(),
1444            md_code_line_prefix: None,
1445            md_code_line_prefix_color: None,
1446            md_code_highlight_upto: false,
1447            md_code_line: Vec::new(),
1448            pending: Vec::new(),
1449            utf8_pending: [0; 4],
1450            utf8_pending_len: 0,
1451            utf8_pending_need: 0,
1452        }
1453    }
1454
1455    /// Attaches a tail capture; output is recorded instead of written.
1456    pub fn set_capture(&mut self, capture: Option<TailCapture>) {
1457        self.capture = capture;
1458    }
1459
1460    /// Detaches and returns the tail capture, if any.
1461    pub fn take_capture(&mut self) -> Option<TailCapture> {
1462        self.capture.take()
1463    }
1464
1465    /// Returns `true` if any visible byte has been emitted.
1466    #[must_use]
1467    pub fn wrote_visible_output(&self) -> bool {
1468        self.wrote_visible_output
1469    }
1470
1471    /// Returns `true` if the last emitted output byte was a newline.
1472    #[must_use]
1473    pub fn last_output_newline(&self) -> bool {
1474        self.last_output_newline
1475    }
1476
1477    /// Sets thinking mode: grey text, markdown disabled.
1478    pub fn set_in_think(&mut self, in_think: bool) {
1479        self.in_think = in_think;
1480    }
1481
1482    /// Streams a chunk of assistant text through the renderer.
1483    pub fn write(&mut self, text: &str) {
1484        self.write_bytes(text.as_bytes());
1485    }
1486
1487    /// Streams raw bytes; UTF-8 sequences may be split across calls.
1488    pub fn write_bytes(&mut self, bytes: &[u8]) {
1489        let bytes = without_control_bytes(bytes);
1490        if self.opts.format_thinking {
1491            self.process(&bytes, false);
1492        } else {
1493            for &b in bytes.iter() {
1494                self.write_char(b);
1495            }
1496        }
1497    }
1498
1499    /// Flushes pending state and emits the trailing blank line.
1500    pub fn finish(&mut self) {
1501        if self.opts.format_thinking {
1502            self.process(&[], true);
1503        }
1504        self.markdown_finish();
1505        self.flush_utf8();
1506        self.reset_color();
1507        if self.wrote_visible_output {
1508            if !self.last_output_newline {
1509                self.out(b"\n");
1510            }
1511            self.out(b"\n");
1512            self.last_output_newline = true;
1513        }
1514        let _ = self.sink.flush();
1515    }
1516
1517    /// Consumes the renderer and returns the sink it was writing into.
1518    ///
1519    /// The Turbo Vision front end renders into a `Vec<u8>` and harvests the
1520    /// ANSI, rather than writing it to a terminal.
1521    pub fn into_sink(self) -> W {
1522        self.sink
1523    }
1524
1525    /// Emits a raw color escape, tracking whether a manual color is open.
1526    pub fn color(&mut self, seq: &str) {
1527        self.markdown_emit_pending_literals();
1528        self.flush_utf8();
1529        let reset = seq.is_empty() || seq == "\x1b[0m";
1530        if self.opts.use_color && !seq.is_empty() {
1531            self.out_str(seq);
1532        }
1533        self.color_open = self.opts.use_color && !reset;
1534    }
1535
1536    /// Emits text verbatim, bypassing markdown but flushing pending state.
1537    pub fn plain(&mut self, s: &str) {
1538        let s = without_control_bytes(s.as_bytes());
1539        self.markdown_emit_pending_literals();
1540        self.flush_utf8();
1541        self.out(&s);
1542        if !s.is_empty() {
1543            self.wrote_visible_output = true;
1544            self.last_output_newline = s.last() == Some(&b'\n');
1545        }
1546    }
1547
1548    /// Re-applies tracked text attributes after external output.
1549    pub fn restore_text_attrs(&mut self) {
1550        if !self.opts.use_color || !self.color_open || !self.has_text_attrs() {
1551            return;
1552        }
1553        self.set_text_attrs();
1554    }
1555
1556    /// Enters code-block streaming mode with an explicit syntax.
1557    pub fn code_stream_begin(&mut self, syntax: &'static Syntax) {
1558        self.reset_color();
1559        self.md_code_block = true;
1560        self.md_inline_code = false;
1561        self.md_fence_info = false;
1562        self.md_code_line_start = true;
1563        self.md_code_in_ml_comment = false;
1564        self.md_syntax = Some(syntax);
1565        self.md_fence_lang.clear();
1566        self.md_code_line_prefix = None;
1567        self.md_code_line_prefix_color = None;
1568        self.md_code_highlight_upto = false;
1569        self.md_code_line.clear();
1570    }
1571
1572    /// Sets a per-line prefix (and its color) restored on repaint.
1573    pub fn code_stream_set_prefix(&mut self, prefix: Option<&str>, color: Option<&str>) {
1574        self.md_code_line_prefix = prefix.map(str::to_owned);
1575        self.md_code_line_prefix_color = color.map(str::to_owned);
1576    }
1577
1578    /// Enables highlighting of the literal `[upto]` marker in code lines.
1579    pub fn code_stream_set_upto_marker(&mut self, enabled: bool) {
1580        self.md_code_highlight_upto = enabled;
1581    }
1582
1583    /// Ends code-block streaming mode, emitting any buffered line.
1584    pub fn code_stream_end(&mut self) {
1585        self.code_end();
1586    }
1587
1588    // -- raw output ---------------------------------------------------------
1589
1590    fn out(&mut self, s: &[u8]) {
1591        if let Some(c) = self.capture.as_mut() {
1592            c.append(s);
1593        } else {
1594            let _ = self.sink.write_all(s);
1595        }
1596    }
1597
1598    fn out_str(&mut self, s: &str) {
1599        self.out(s.as_bytes());
1600    }
1601
1602    fn set_grey(&mut self) {
1603        if self.opts.use_color {
1604            // Barely-visible dark gray so thinking text reads as background
1605            // muttering, clearly distinct from the assistant's real output.
1606            self.out(THINK_GREY);
1607        }
1608    }
1609
1610    fn reset_color(&mut self) {
1611        if self.opts.use_color {
1612            self.out(b"\x1b[0m");
1613        }
1614        self.color_open = false;
1615    }
1616
1617    fn has_text_attrs(&self) -> bool {
1618        self.in_think || self.md_bold || self.md_italic || self.md_inline_code || self.md_code_block
1619    }
1620
1621    fn set_text_attrs(&mut self) {
1622        if !self.opts.use_color {
1623            return;
1624        }
1625        if self.in_think {
1626            self.set_grey();
1627            return;
1628        }
1629        if self.md_code_block {
1630            self.out(b"\x1b[38;5;75m");
1631            return;
1632        } else if self.md_inline_code {
1633            self.out(b"\x1b[36m");
1634        }
1635        if self.md_bold {
1636            self.out(b"\x1b[1m");
1637        }
1638        if self.md_italic {
1639            self.out(b"\x1b[3m");
1640        }
1641    }
1642
1643    fn write_complete_char_raw(&mut self, s: &[u8]) {
1644        let styled = self.opts.use_color && self.has_text_attrs();
1645        if styled && !self.color_open {
1646            self.set_text_attrs();
1647            self.color_open = true;
1648        } else if !styled && self.color_open {
1649            self.reset_color();
1650        }
1651        self.out(s);
1652        if !s.is_empty() {
1653            self.wrote_visible_output = true;
1654        }
1655        self.last_output_newline = s == b"\n";
1656    }
1657
1658    fn flush_utf8(&mut self) {
1659        if self.utf8_pending_len == 0 {
1660            return;
1661        }
1662        let buf: [u8; 4] = self.utf8_pending;
1663        let len = self.utf8_pending_len;
1664        self.write_complete_char_raw(&buf[..len]);
1665        self.utf8_pending_len = 0;
1666        self.utf8_pending_need = 0;
1667    }
1668
1669    fn utf8_need(c: u8) -> usize {
1670        match c {
1671            0xc2..=0xdf => 2,
1672            0xe0..=0xef => 3,
1673            0xf0..=0xf4 => 4,
1674            _ => 1,
1675        }
1676    }
1677
1678    fn write_char_raw(&mut self, c: u8) {
1679        if self.utf8_pending_len > 0 {
1680            if c & 0xc0 == 0x80 && self.utf8_pending_len < self.utf8_pending.len() {
1681                self.utf8_pending[self.utf8_pending_len] = c;
1682                self.utf8_pending_len += 1;
1683                if self.utf8_pending_len == self.utf8_pending_need {
1684                    self.flush_utf8();
1685                }
1686                return;
1687            }
1688            self.flush_utf8();
1689        }
1690
1691        let need = Self::utf8_need(c);
1692        if need == 1 {
1693            self.write_complete_char_raw(&[c]);
1694            return;
1695        }
1696        self.utf8_pending[0] = c;
1697        self.utf8_pending_len = 1;
1698        self.utf8_pending_need = need;
1699    }
1700
1701    /// Writes one byte with markdown attributes temporarily disabled.
1702    fn write_plain_byte(&mut self, c: u8) {
1703        let (bold, italic, inline, block) = (
1704            self.md_bold,
1705            self.md_italic,
1706            self.md_inline_code,
1707            self.md_code_block,
1708        );
1709        self.md_bold = false;
1710        self.md_italic = false;
1711        self.md_inline_code = false;
1712        self.md_code_block = false;
1713        self.write_char_raw(c);
1714        self.md_bold = bold;
1715        self.md_italic = italic;
1716        self.md_inline_code = inline;
1717        self.md_code_block = block;
1718    }
1719
1720    // -- syntax highlighting ------------------------------------------------
1721
1722    fn syntax_write(&mut self, hl: Highlight, s: &[u8]) {
1723        if s.is_empty() {
1724            return;
1725        }
1726        if hl != Highlight::Normal {
1727            self.md_syntax_has_highlight = true;
1728        }
1729        if self.md_syntax_silent {
1730            return;
1731        }
1732        if self.opts.use_color && hl != Highlight::Normal {
1733            let seq = format!("\x1b[{}38;5;{}m", syntax_style(hl), syntax_color(hl));
1734            self.out_str(&seq);
1735        }
1736        self.out(s);
1737        if self.opts.use_color && hl != Highlight::Normal {
1738            self.out(b"\x1b[0m");
1739        }
1740        self.wrote_visible_output = true;
1741        self.last_output_newline = false;
1742    }
1743
1744    fn syntax_write_upto_marker(&mut self) {
1745        self.md_syntax_has_highlight = true;
1746        if self.md_syntax_silent {
1747            return;
1748        }
1749        if self.opts.use_color {
1750            self.out(b"\x1b[38;5;244m[");
1751            self.out(b"\x1b[1;38;5;177mupto");
1752            self.out(b"\x1b[38;5;244m]\x1b[0m");
1753        } else {
1754            self.out(UPTO_MARKER);
1755        }
1756        self.wrote_visible_output = true;
1757        self.last_output_newline = false;
1758    }
1759
1760    #[allow(clippy::too_many_lines)]
1761    fn syntax_emit_line(&mut self, line: &[u8]) {
1762        let syn = self.md_syntax.unwrap_or_else(|| syntax_for_lang(""));
1763        let mut i = 0;
1764        let end = line.len();
1765        let mut prev_sep = true;
1766        let mut prev_hl = Highlight::Normal;
1767
1768        while i < end {
1769            let rest = &line[i..];
1770
1771            if self.md_code_highlight_upto && rest.starts_with(UPTO_MARKER) {
1772                self.syntax_write_upto_marker();
1773                i += UPTO_MARKER.len();
1774                prev_sep = true;
1775                prev_hl = Highlight::Normal;
1776                continue;
1777            }
1778
1779            if self.md_code_in_ml_comment {
1780                if let Some(mce) = syn.multiline_end
1781                    && let Some(pos) = find_sub(rest, mce.as_bytes())
1782                {
1783                    let take = pos + mce.len();
1784                    let seg = &line[i..i + take];
1785                    self.syntax_write(Highlight::Comment, seg);
1786                    i += take;
1787                    self.md_code_in_ml_comment = false;
1788                    prev_sep = true;
1789                    prev_hl = Highlight::Comment;
1790                    continue;
1791                }
1792                let seg = &line[i..];
1793                self.syntax_write(Highlight::Comment, seg);
1794                return;
1795            }
1796
1797            if syn
1798                .singleline_comments
1799                .iter()
1800                .any(|m| rest.starts_with(m.as_bytes()))
1801            {
1802                let seg = &line[i..];
1803                self.syntax_write(Highlight::Comment, seg);
1804                return;
1805            }
1806
1807            if let (Some(mls), Some(mle)) = (syn.multiline_start, syn.multiline_end)
1808                && rest.starts_with(mls.as_bytes())
1809            {
1810                let body = &rest[mls.len()..];
1811                let take = if let Some(pos) = find_sub(body, mle.as_bytes()) {
1812                    mls.len() + pos + mle.len()
1813                } else {
1814                    self.md_code_in_ml_comment = true;
1815                    rest.len()
1816                };
1817                let seg = &line[i..i + take];
1818                self.syntax_write(Highlight::Comment, seg);
1819                i += take;
1820                prev_sep = false;
1821                prev_hl = Highlight::Comment;
1822                continue;
1823            }
1824
1825            let c = rest[0];
1826            if syn.flags & SYNTAX_STRINGS != 0
1827                && (c == b'"'
1828                    || c == b'\''
1829                    || (syn.flags & SYNTAX_BACKTICK_STRINGS != 0 && c == b'`'))
1830            {
1831                let quote = c;
1832                let mut q = 1;
1833                while q < rest.len() {
1834                    if rest[q] == b'\\' && q + 1 < rest.len() {
1835                        q += 2;
1836                        continue;
1837                    }
1838                    q += 1;
1839                    if rest[q - 1] == quote {
1840                        break;
1841                    }
1842                }
1843                let seg = &line[i..i + q];
1844                self.syntax_write(Highlight::String, seg);
1845                i += q;
1846                prev_sep = false;
1847                prev_hl = Highlight::String;
1848                continue;
1849            }
1850
1851            let number_start = c.is_ascii_digit() && (prev_sep || prev_hl == Highlight::Number)
1852                || (c == b'.' && i > 0 && prev_hl == Highlight::Number);
1853            if syn.flags & SYNTAX_NUMBERS != 0 && number_start {
1854                let nlen = number_len(rest);
1855                let seg = &line[i..i + nlen];
1856                self.syntax_write(Highlight::Number, seg);
1857                i += nlen;
1858                prev_sep = false;
1859                prev_hl = Highlight::Number;
1860                continue;
1861            }
1862
1863            if prev_sep && let Some((klen, khl)) = match_keyword(syn, rest) {
1864                let seg = &line[i..i + klen];
1865                self.syntax_write(khl, seg);
1866                i += klen;
1867                prev_sep = false;
1868                prev_hl = khl;
1869                continue;
1870            }
1871
1872            let seg = &line[i..=i];
1873            self.syntax_write(Highlight::Normal, seg);
1874            prev_sep = syntax_separator(c);
1875            prev_hl = Highlight::Normal;
1876            i += 1;
1877        }
1878    }
1879
1880    // -- code block line buffering / repaint ---------------------------------
1881
1882    fn terminal_cols() -> usize {
1883        // Deviation from the C reference: the sink is a generic writer, so we
1884        // cannot ioctl(TIOCGWINSZ); assume the classic 80-column default.
1885        80
1886    }
1887
1888    fn code_line_can_repaint(&self) -> bool {
1889        if !self.opts.use_color || self.capture.is_some() || self.md_code_line.is_empty() {
1890            return false;
1891        }
1892        let cols = Self::terminal_cols();
1893        let prefix_len = self.md_code_line_prefix.as_ref().map_or(0, String::len);
1894        if cols <= 1 || prefix_len + self.md_code_line.len() >= cols {
1895            return false;
1896        }
1897        self.md_code_line
1898            .iter()
1899            .all(|&c| c == b'\r' || (0x20..0x80).contains(&c) && c != 0x1b)
1900    }
1901
1902    fn code_write_line_prefix(&mut self) {
1903        let Some(prefix) = self.md_code_line_prefix.clone() else {
1904            return;
1905        };
1906        let color = self.md_code_line_prefix_color.clone();
1907        if self.opts.use_color
1908            && let Some(col) = &color
1909        {
1910            self.out_str(col);
1911        }
1912        self.out_str(&prefix);
1913        if self.opts.use_color && color.is_some() {
1914            self.out(b"\x1b[0m");
1915        }
1916        self.color_open = false;
1917    }
1918
1919    /// Runs the highlighter silently to learn whether repainting would change
1920    /// the line, preserving multiline-comment state for the caller.
1921    fn code_scan_line(&mut self) -> (bool, bool) {
1922        let old_silent = self.md_syntax_silent;
1923        let old_highlight = self.md_syntax_has_highlight;
1924        let old_ml = self.md_code_in_ml_comment;
1925
1926        self.md_syntax_silent = true;
1927        self.md_syntax_has_highlight = false;
1928        let line = std::mem::take(&mut self.md_code_line);
1929        self.syntax_emit_line(&line);
1930        self.md_code_line = line;
1931        let changed = self.md_syntax_has_highlight;
1932        let final_ml = self.md_code_in_ml_comment;
1933
1934        self.md_code_in_ml_comment = old_ml;
1935        self.md_syntax_silent = old_silent;
1936        self.md_syntax_has_highlight = old_highlight;
1937        (changed, final_ml)
1938    }
1939
1940    fn code_emit_buffered_line(&mut self, with_newline: bool) {
1941        let (changed, final_ml) = self.code_scan_line();
1942        let repaint = changed && self.code_line_can_repaint();
1943        if repaint {
1944            self.reset_color();
1945            self.out(b"\r\x1b[0K");
1946            self.code_write_line_prefix();
1947            let line = std::mem::take(&mut self.md_code_line);
1948            self.syntax_emit_line(&line);
1949            self.md_code_line = line;
1950        } else {
1951            self.md_code_in_ml_comment = final_ml;
1952        }
1953        self.md_code_line.clear();
1954        if with_newline {
1955            self.write_plain_byte(b'\n');
1956            self.wrote_visible_output = true;
1957            self.last_output_newline = true;
1958            self.md_code_line_start = true;
1959        }
1960    }
1961
1962    fn code_byte(&mut self, c: u8) {
1963        if c == b'\n' {
1964            self.code_emit_buffered_line(true);
1965            return;
1966        }
1967        self.md_code_line.push(c);
1968        self.write_plain_byte(c);
1969        if c != b' ' && c != b'\t' && c != b'\r' {
1970            self.md_code_line_start = false;
1971        }
1972    }
1973
1974    fn code_emit_backtick_literals(&mut self, count: usize) {
1975        for _ in 0..count {
1976            self.code_byte(b'`');
1977        }
1978    }
1979
1980    fn code_begin(&mut self) {
1981        self.reset_color();
1982        self.md_code_block = true;
1983        self.md_inline_code = false;
1984        self.md_fence_info = true;
1985        self.md_code_line_start = true;
1986        self.md_code_in_ml_comment = false;
1987        self.md_syntax = Some(syntax_for_lang(""));
1988        self.md_fence_lang.clear();
1989        self.md_code_line_prefix = None;
1990        self.md_code_line_prefix_color = None;
1991        self.md_code_highlight_upto = false;
1992        self.md_code_line.clear();
1993    }
1994
1995    fn code_end(&mut self) {
1996        let only_space = self
1997            .md_code_line
1998            .iter()
1999            .all(|&c| c == b' ' || c == b'\t' || c == b'\r');
2000        if !self.md_code_line.is_empty() && !only_space {
2001            self.code_emit_buffered_line(false);
2002        } else {
2003            self.md_code_line.clear();
2004        }
2005        self.md_code_block = false;
2006        self.md_inline_code = false;
2007        self.md_fence_info = false;
2008        self.md_code_line_start = true;
2009        self.md_code_in_ml_comment = false;
2010        self.md_syntax = None;
2011        self.md_fence_lang.clear();
2012        self.md_code_line_prefix = None;
2013        self.md_code_line_prefix_color = None;
2014    }
2015
2016    // -- markdown state machine ----------------------------------------------
2017
2018    fn markdown_clear_pending(&mut self) {
2019        self.md_pending = MdPending::None;
2020        self.md_pending_len = 0;
2021    }
2022
2023    fn markdown_emit_pending_literals(&mut self) {
2024        let c = match self.md_pending {
2025            MdPending::Star => b'*',
2026            MdPending::Backtick => b'`',
2027            MdPending::None => return,
2028        };
2029        let count = self.md_pending_len;
2030        self.markdown_clear_pending();
2031        if self.md_code_block {
2032            if c == b'`' {
2033                self.code_emit_backtick_literals(count);
2034            } else {
2035                for _ in 0..count {
2036                    self.code_byte(c);
2037                }
2038            }
2039            return;
2040        }
2041        for _ in 0..count {
2042            self.write_char_raw(c);
2043        }
2044    }
2045
2046    fn markdown_commit_backticks(&mut self) {
2047        let count = self.md_pending_len;
2048        self.markdown_clear_pending();
2049        if count >= 3 {
2050            for _ in 0..count {
2051                self.write_plain_byte(b'`');
2052            }
2053            if self.md_code_block {
2054                self.code_end();
2055            } else {
2056                self.code_begin();
2057            }
2058            return;
2059        }
2060        if self.md_code_block {
2061            self.code_emit_backtick_literals(count);
2062            return;
2063        }
2064        // Support both `code` and ``code``.
2065        self.md_inline_code = !self.md_inline_code;
2066    }
2067
2068    fn markdown_feed(&mut self, c: u8) {
2069        if self.md_fence_info {
2070            if c == b'\n' {
2071                if self.md_code_block {
2072                    self.md_syntax = Some(syntax_for_lang(&self.md_fence_lang.clone()));
2073                }
2074                self.write_plain_byte(b'\n');
2075                self.md_fence_info = false;
2076            } else if self.md_code_block {
2077                if self.md_fence_lang.len() < FENCE_LANG_MAX
2078                    && (c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-' | b'+' | b'#'))
2079                {
2080                    self.md_fence_lang.push(char::from(c));
2081                }
2082                self.write_plain_byte(c);
2083            }
2084            return;
2085        }
2086
2087        if self.md_pending == MdPending::Backtick {
2088            if c == b'`' {
2089                self.md_pending_len += 1;
2090                return;
2091            }
2092            self.markdown_commit_backticks();
2093            self.markdown_feed(c);
2094            return;
2095        }
2096
2097        if self.md_pending == MdPending::Star {
2098            self.markdown_clear_pending();
2099            if !self.md_inline_code && !self.md_code_block && c == b'*' {
2100                self.md_bold = !self.md_bold;
2101                return;
2102            }
2103            if !self.md_inline_code
2104                && !self.md_code_block
2105                && (self.md_italic || !matches!(c, b' ' | b'\t' | b'\r' | b'\n'))
2106            {
2107                self.md_italic = !self.md_italic;
2108                self.markdown_feed(c);
2109                return;
2110            }
2111            self.write_char_raw(b'*');
2112            self.markdown_feed(c);
2113            return;
2114        }
2115
2116        if c == b'`' && (!self.md_code_block || self.md_code_line_start) {
2117            self.md_pending = MdPending::Backtick;
2118            self.md_pending_len = 1;
2119            return;
2120        }
2121        if self.md_code_block {
2122            self.code_byte(c);
2123            return;
2124        }
2125        if !self.md_inline_code && c == b'*' {
2126            self.md_pending = MdPending::Star;
2127            self.md_pending_len = 1;
2128            return;
2129        }
2130        self.write_char_raw(c);
2131    }
2132
2133    fn markdown_finish(&mut self) {
2134        // A closing code fence can be the final bytes of the reply; commit a
2135        // full fence instead of leaking the literal ``` marker.
2136        if self.md_pending == MdPending::Backtick && self.md_pending_len >= 3 {
2137            self.markdown_commit_backticks();
2138        } else {
2139            self.markdown_emit_pending_literals();
2140        }
2141        if self.md_code_block && !self.md_code_line.is_empty() {
2142            self.code_emit_buffered_line(false);
2143        }
2144        self.md_bold = false;
2145        self.md_italic = false;
2146        self.md_inline_code = false;
2147        self.md_code_block = false;
2148        self.md_fence_info = false;
2149        self.md_code_line_start = false;
2150        self.md_code_in_ml_comment = false;
2151        self.md_syntax = None;
2152        self.md_fence_lang.clear();
2153        self.md_code_line_prefix = None;
2154        self.md_code_line_prefix_color = None;
2155        self.md_code_highlight_upto = false;
2156        self.md_code_line = Vec::new();
2157    }
2158
2159    fn write_char(&mut self, c: u8) {
2160        if !self.opts.format_markdown || self.in_think {
2161            self.markdown_emit_pending_literals();
2162            self.write_char_raw(c);
2163            return;
2164        }
2165        self.markdown_feed(c);
2166    }
2167
2168    // -- think tag processing --------------------------------------------------
2169
2170    /// Renders text while hiding `<think>` tags and dimming thinking text,
2171    /// holding back a partial control tag split across model tokens.
2172    fn process(&mut self, text: &[u8], finish: bool) {
2173        const THINK_OPEN: &[u8] = b"<think>";
2174        const THINK_CLOSE: &[u8] = b"</think>";
2175
2176        let mut buf = std::mem::take(&mut self.pending);
2177        buf.extend_from_slice(text);
2178
2179        let mut i = 0;
2180        while i < buf.len() {
2181            let cur = &buf[i..];
2182            if cur.starts_with(THINK_OPEN) {
2183                self.in_think = true;
2184                i += THINK_OPEN.len();
2185                continue;
2186            }
2187            if cur.starts_with(THINK_CLOSE) {
2188                self.in_think = false;
2189                self.reset_color();
2190                if !self.last_output_newline {
2191                    self.out(b"\n");
2192                }
2193                self.out(b"\n");
2194                self.last_output_newline = true;
2195                i += THINK_CLOSE.len();
2196                continue;
2197            }
2198            if !finish
2199                && cur[0] == b'<'
2200                && (is_partial_prefix(cur, THINK_OPEN) || is_partial_prefix(cur, THINK_CLOSE))
2201            {
2202                self.pending = cur.to_vec();
2203                return;
2204            }
2205            self.write_char(cur[0]);
2206            i += 1;
2207        }
2208    }
2209}
2210
2211fn is_partial_prefix(p: &[u8], prefix: &[u8]) -> bool {
2212    p.len() < prefix.len() && prefix.starts_with(p)
2213}
2214
2215fn find_sub(haystack: &[u8], needle: &[u8]) -> Option<usize> {
2216    if needle.is_empty() || haystack.len() < needle.len() {
2217        return None;
2218    }
2219    (0..=haystack.len() - needle.len()).find(|&i| &haystack[i..i + needle.len()] == needle)
2220}
2221
2222// ---------------------------------------------------------------------------
2223// Tests
2224// ---------------------------------------------------------------------------
2225
2226#[cfg(test)]
2227mod tests {
2228    use super::*;
2229
2230    fn renderer(opts: RenderOptions) -> TokenRenderer<Vec<u8>> {
2231        TokenRenderer::new(Vec::new(), opts)
2232    }
2233
2234    fn output(r: TokenRenderer<Vec<u8>>) -> String {
2235        String::from_utf8(r.sink).unwrap()
2236    }
2237
2238    const COLOR_MD: RenderOptions = RenderOptions {
2239        use_color: true,
2240        format_thinking: false,
2241        format_markdown: true,
2242    };
2243
2244    #[test]
2245    fn plain_text_passthrough() {
2246        let mut r = renderer(RenderOptions::default());
2247        r.write("hello world\n");
2248        assert!(r.wrote_visible_output());
2249        assert!(r.last_output_newline());
2250        r.finish();
2251        let out = output(r);
2252        assert!(out.starts_with("hello world\n"));
2253        assert!(!out.contains('\x1b'));
2254    }
2255
2256    #[test]
2257    fn model_escape_sequences_never_reach_the_terminal() {
2258        // A file the model quotes back can carry an OSC 52 clipboard write.
2259        let mut r = renderer(RenderOptions::default());
2260        r.write("before\x1b]52;c;cHduZWQ=\x07after");
2261        r.plain("banner \x1b[31mred\x1b[0m\n");
2262        r.finish();
2263        let out = output(r);
2264        assert!(!out.contains('\x1b'), "escape survived: {out:?}");
2265        assert!(!out.contains('\x07'), "bell survived: {out:?}");
2266        // Neutered, not swallowed: the payload stays readable as plain text.
2267        assert!(out.contains("before") && out.contains("after"));
2268        assert!(out.contains("banner "));
2269    }
2270
2271    #[test]
2272    fn bold_and_italic_markers() {
2273        let mut r = renderer(COLOR_MD);
2274        r.write("a **bold** and *ital* b");
2275        r.finish();
2276        let out = output(r);
2277        assert!(out.contains("\x1b[1mbold"), "bold SGR missing: {out:?}");
2278        assert!(out.contains("\x1b[3mital"), "italic SGR missing: {out:?}");
2279        assert!(!out.contains('*'), "markers leaked: {out:?}");
2280    }
2281
2282    #[test]
2283    fn inline_code_cyan() {
2284        let mut r = renderer(COLOR_MD);
2285        r.write("run `ls -la` now");
2286        r.finish();
2287        let out = output(r);
2288        assert!(out.contains("\x1b[36mls -la"), "cyan inline code: {out:?}");
2289        assert!(!out.contains('`'));
2290    }
2291
2292    #[test]
2293    fn fenced_rust_block_keyword_highlighting() {
2294        let mut r = renderer(COLOR_MD);
2295        r.write("```rust\nfn main() { let x = 42; }\n```\n");
2296        r.finish();
2297        let out = output(r);
2298        // Repaint replaces the streamed line with highlighted text.
2299        assert!(out.contains("\r\x1b[0K"), "repaint missing: {out:?}");
2300        assert!(out.contains("\x1b[1;38;5;214mfn"), "kw1 'fn' bold: {out:?}");
2301        assert!(out.contains("\x1b[1;38;5;214mlet"), "kw1 'let' bold: {out:?}");
2302        assert!(out.contains("\x1b[38;5;203m42"), "number: {out:?}");
2303        // As in the C, code streams plain first; the fence markers stay visible.
2304        assert!(
2305            out.contains("```") && out.contains("rust\n"),
2306            "fence line: {out:?}"
2307        );
2308    }
2309
2310    #[test]
2311    fn fenced_block_bolds_keywords_and_italicizes_comments() {
2312        let mut r = renderer(COLOR_MD);
2313        r.write("```rust\nfn main() {} // note\n```\n");
2314        r.finish();
2315        let out = output(r);
2316        // Keywords carry the bold flag alongside their color (kw1 = 214).
2317        assert!(
2318            out.contains("\x1b[1;38;5;214mfn"),
2319            "keyword must be bold: {out:?}"
2320        );
2321        // Comments carry the italic flag alongside their color (244).
2322        assert!(
2323            out.contains("\x1b[3;38;5;244m"),
2324            "comment must be italic: {out:?}"
2325        );
2326    }
2327
2328    #[test]
2329    fn thinking_rendered_grey() {
2330        let mut r = renderer(RenderOptions {
2331            use_color: true,
2332            format_thinking: true,
2333            format_markdown: true,
2334        });
2335        r.write("<think>pondering</think>answer");
2336        r.finish();
2337        let out = output(r);
2338        assert!(
2339            out.contains("\x1b[3;38;5;238mpondering"),
2340            "italic grey think: {out:?}"
2341        );
2342        assert!(!out.contains("<think>"));
2343        assert!(!out.contains("</think>"));
2344        assert!(out.contains("answer"));
2345    }
2346
2347    #[test]
2348    fn partial_think_tag_held_across_writes() {
2349        let mut r = renderer(RenderOptions {
2350            use_color: false,
2351            format_thinking: true,
2352            format_markdown: false,
2353        });
2354        r.write("<thi");
2355        r.write("nk>x</thi");
2356        r.write("nk>y");
2357        r.finish();
2358        let out = output(r);
2359        assert!(!out.contains('<'));
2360        assert!(out.contains('x'));
2361        assert!(out.contains('y'));
2362    }
2363
2364    #[test]
2365    fn utf8_split_across_writes() {
2366        let mut r = renderer(RenderOptions::default());
2367        let euro = "€".as_bytes(); // three bytes
2368        r.write_bytes(&euro[..1]);
2369        r.write_bytes(&euro[1..2]);
2370        r.write_bytes(&euro[2..]);
2371        r.finish();
2372        let out = output(r);
2373        assert!(out.starts_with('€'), "utf-8 reassembly failed: {out:?}");
2374    }
2375
2376    #[test]
2377    fn tail_capture_records_last_bytes() {
2378        let mut cap = TailCapture::new(8);
2379        cap.append(b"0123456789abcdef");
2380        assert_eq!(cap.total(), 16);
2381        assert_eq!(cap.len(), 8);
2382        let taken = cap.take();
2383        assert_eq!(taken, b"89abcdef");
2384        assert!(cap.is_empty());
2385
2386        // Attached to a renderer, output goes to the capture, not the sink.
2387        let mut r = renderer(RenderOptions::default());
2388        r.set_capture(Some(TailCapture::new(64)));
2389        r.write("captured text");
2390        let mut got = r.take_capture().unwrap();
2391        assert_eq!(got.take(), b"captured text");
2392        assert!(r.sink.is_empty());
2393    }
2394
2395    #[test]
2396    fn syntax_lookup_by_lang_and_path() {
2397        assert_eq!(syntax_for_lang("rs").name(), "rust");
2398        assert_eq!(syntax_for_lang("TypeScript").name(), "javascript");
2399        assert_eq!(syntax_for_lang("nosuchlang").name(), "generic");
2400        assert_eq!(syntax_for_path("src/main.rs").name(), "rust");
2401        assert_eq!(syntax_for_path("Dockerfile").name(), "shell");
2402        assert_eq!(syntax_for_path("a/b/Makefile").name(), "shell");
2403        assert_eq!(syntax_for_path("noext").name(), "generic");
2404    }
2405}