Skip to content

Commit 0b9f760

Browse files
author
Yogthos
committed
refactor(ui): single render pipeline for agent chat with base-color parameter
User wants reasoning + content streams to share ONE rendering engine while keeping their distinct colors (reasoning = thinking voice, content = response voice). All highlights need to remain visible across whichever color a stream picks. ## Single engine: `render_agent_stream` New helper in `src/ui/mod.rs` is the sole rendering path for both Reasoning and Token events: ```rust fn render_agent_stream( buf: &str, start_line: &mut Option<usize>, base_color: Color, renderer: &mut Renderer, ) -> anyhow::Result<()> ``` - Parses `buf` via `markdown_to_styled` so bold / italics / inline code / headings / code blocks / blockquotes stay styled. - `base_color` sets the body / paragraph color — each stream picks its own register without forking the engine. - Replaces in place at the stream's `start_line` anchor so successive chunks update the same buffer region. Reasoning handler now calls `render_agent_stream(reasoning_buf, reasoning_start_line, Color::DarkMagenta, renderer)`. Token handler calls the same with `c_agent()`. No duplicated rendering logic. ## Theme-driven highlights `markdown_to_styled` gains a `base_color: Color` parameter. Internal `theme::agent()` hardcoding is gone; body text uses the passed `base_color`. Highlights still go through their dedicated theme accessors: - Headings → `theme::header()` - Code blocks → `theme::tool()` - Inline emphasis / accents → `theme::accent()` - Blockquotes / trailers → `theme::dim()` Swap the theme, all highlights shift in one place. Swap the `base_color` (per stream), only the body shifts. ## Call sites updated All 4 production callers of `markdown_to_styled` pass an explicit base color: - `ui/mod.rs` Token handler → `c_agent()` (theme-configurable) - `ui/mod.rs` Done / Interjected handlers → `c_agent()` - `ui/events.rs` (history replay) → `line_color` (per-role) - `ui/slash.rs` btw_query → `c_agent()` Plus 3 test sites in `markdown.rs` for `render_table` and the existing markdown tests — all pass explicit `theme::agent()`. ## Why not unify the colors The user asked first about unifying, then reconsidered: reasoning and content carry semantically distinct meanings (model's thinking vs its reply) and a different color communicates that distinction. The shared engine + parameterized base color gives us "single implementation, distinct visual register" cleanly. ## Test 1208/1215 pass (1 pre-existing Clojure failure unchanged; the other 6 are pre-existing apply_patch/memory test flakiness under concurrent execution — they all pass with `--test-threads=1`). Binary at ~/bin/dirge.
1 parent 306f11f commit 0b9f760

4 files changed

Lines changed: 127 additions & 100 deletions

File tree

src/ui/events.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ pub fn render_session(
8888
let max_width = renderer
8989
.content_width()
9090
.saturating_sub(handle.chars().count() + 1);
91-
let mut styled = markdown::markdown_to_styled(&msg.content, max_width);
91+
let mut styled = markdown::markdown_to_styled(&msg.content, max_width, line_color);
9292
for (i, entry) in styled.iter_mut().enumerate() {
9393
if i == 0 {
9494
entry.text = CompactString::from(format!("{} {}", handle, entry.text));

src/ui/markdown.rs

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ fn render_table(
144144
header: &[String],
145145
rows: &[Vec<String>],
146146
max_width: usize,
147+
base_color: Color,
147148
out: &mut Vec<LineEntry>,
148149
) {
149150
use unicode_width::UnicodeWidthStr;
@@ -261,16 +262,28 @@ fn render_table(
261262
for row in rows {
262263
out.push(LineEntry {
263264
text: CompactString::new(&render_row(row, &widths)),
264-
color: crate::ui::theme::agent(),
265+
color: base_color,
265266
});
266267
}
267268
out.push(LineEntry {
268269
text: CompactString::new(""),
269-
color: crate::ui::theme::agent(),
270+
color: base_color,
270271
});
271272
}
272273

273-
pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
274+
/// Render markdown text to styled line entries. `base_color` is the
275+
/// body / paragraph color — the agent's voice. Highlights (headings,
276+
/// code blocks, blockquotes, accents, dim/trailer text) still go
277+
/// through their dedicated `theme::*` accessors, so a single
278+
/// `base_color` swap shifts only the body text while keeping the
279+
/// inline emphasis hierarchy intact.
280+
///
281+
/// Streams that share the markdown engine (Token, Reasoning) pass
282+
/// their stream-specific base color here. Inline ANSI sequences for
283+
/// bold / italic / strikethrough / inline-code ride along inside
284+
/// each LineEntry's text, so visual hierarchy is preserved
285+
/// regardless of the chosen base color.
286+
pub fn markdown_to_styled(text: &str, max_width: usize, base_color: Color) -> Vec<LineEntry> {
274287
if text.is_empty() {
275288
return Vec::new();
276289
}
@@ -315,13 +328,13 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
315328
Event::Start(tag) => match tag {
316329
Tag::Paragraph => {}
317330
Tag::Heading { level, .. } => {
318-
flush_acc(&acc, crate::ui::theme::agent(), max_width, &mut result);
331+
flush_acc(&acc, base_color, max_width, &mut result);
319332
acc.clear();
320333
in_heading = true;
321334
heading_level = level as u32;
322335
}
323336
Tag::CodeBlock(kind) => {
324-
flush_acc(&acc, crate::ui::theme::agent(), max_width, &mut result);
337+
flush_acc(&acc, base_color, max_width, &mut result);
325338
acc.clear();
326339
in_code_block = true;
327340
code_block_lang.clear();
@@ -330,7 +343,7 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
330343
}
331344
}
332345
Tag::BlockQuote(_) => {
333-
flush_acc(&acc, crate::ui::theme::agent(), max_width, &mut result);
346+
flush_acc(&acc, base_color, max_width, &mut result);
334347
acc.clear();
335348
in_blockquote = true;
336349
}
@@ -339,13 +352,13 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
339352
list_item_count = 0;
340353
}
341354
Tag::Item => {
342-
flush_acc(&acc, crate::ui::theme::agent(), max_width, &mut result);
355+
flush_acc(&acc, base_color, max_width, &mut result);
343356
acc.clear();
344357
list_item_count += 1;
345358
}
346359
Tag::FootnoteDefinition(_) => {}
347360
Tag::Table(_) => {
348-
flush_acc(&acc, crate::ui::theme::agent(), max_width, &mut result);
361+
flush_acc(&acc, base_color, max_width, &mut result);
349362
acc.clear();
350363
in_table = true;
351364
table_header.clear();
@@ -402,7 +415,7 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
402415
let color = if in_blockquote {
403416
crate::ui::theme::dim()
404417
} else {
405-
crate::ui::theme::agent()
418+
base_color
406419
};
407420
flush_acc(&acc, color, max_width, &mut result);
408421
acc.clear();
@@ -428,7 +441,7 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
428441
in_heading = false;
429442
result.push(LineEntry {
430443
text: CompactString::new(""),
431-
color: crate::ui::theme::agent(),
444+
color: base_color,
432445
});
433446
}
434447
TagEnd::CodeBlock => {
@@ -467,7 +480,7 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
467480
in_code_block = false;
468481
result.push(LineEntry {
469482
text: CompactString::new(""),
470-
color: crate::ui::theme::agent(),
483+
color: base_color,
471484
});
472485
}
473486
TagEnd::BlockQuote(_) => {
@@ -500,14 +513,14 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
500513
in_blockquote = false;
501514
result.push(LineEntry {
502515
text: CompactString::new(""),
503-
color: crate::ui::theme::agent(),
516+
color: base_color,
504517
});
505518
}
506519
TagEnd::Item => {
507520
let color = if in_blockquote {
508521
crate::ui::theme::dim()
509522
} else {
510-
crate::ui::theme::agent()
523+
base_color
511524
};
512525
let bullet = if ordered_list {
513526
format!(" {}. ", list_item_count)
@@ -554,12 +567,18 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
554567
list_item_count = 0;
555568
result.push(LineEntry {
556569
text: CompactString::new(""),
557-
color: crate::ui::theme::agent(),
570+
color: base_color,
558571
});
559572
}
560573
TagEnd::FootnoteDefinition => {}
561574
TagEnd::Table => {
562-
render_table(&table_header, &table_rows, max_width, &mut result);
575+
render_table(
576+
&table_header,
577+
&table_rows,
578+
max_width,
579+
base_color,
580+
&mut result,
581+
);
563582
in_table = false;
564583
}
565584
TagEnd::TableHead => {
@@ -648,7 +667,7 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
648667
}
649668
}
650669
Event::Rule => {
651-
flush_acc(&acc, crate::ui::theme::agent(), max_width, &mut result);
670+
flush_acc(&acc, base_color, max_width, &mut result);
652671
acc.clear();
653672
let rule: String = std::iter::repeat('─').take(max_width.min(40)).collect();
654673
result.push(LineEntry {
@@ -657,7 +676,7 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
657676
});
658677
result.push(LineEntry {
659678
text: CompactString::new(""),
660-
color: crate::ui::theme::agent(),
679+
color: base_color,
661680
});
662681
}
663682
Event::Html(t) => {
@@ -688,7 +707,7 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
688707
} else if in_heading {
689708
crate::ui::theme::header()
690709
} else {
691-
crate::ui::theme::agent()
710+
base_color
692711
};
693712
flush_acc(&acc, color, max_width, &mut result);
694713
}
@@ -734,7 +753,7 @@ mod tests {
734753
vec!["c.rs".to_string(), "DELETE".to_string(), "❌".to_string()],
735754
];
736755
let mut out = Vec::new();
737-
render_table(&header, &rows, 80, &mut out);
756+
render_table(&header, &rows, 80, crate::ui::theme::agent(), &mut out);
738757
// Drop the trailing empty line; everything else must align.
739758
let non_empty: Vec<&LineEntry> = out.iter().filter(|e| !e.text.is_empty()).collect();
740759
let owned: Vec<LineEntry> = non_empty.into_iter().cloned().collect();
@@ -750,7 +769,7 @@ mod tests {
750769
vec!["3".to_string(), "44".to_string()],
751770
];
752771
let mut out = Vec::new();
753-
render_table(&header, &rows, 80, &mut out);
772+
render_table(&header, &rows, 80, crate::ui::theme::agent(), &mut out);
754773
let owned: Vec<LineEntry> = out.iter().filter(|e| !e.text.is_empty()).cloned().collect();
755774
assert_rows_same_width(&owned);
756775
}
@@ -765,7 +784,7 @@ mod tests {
765784
vec!["🚀 emoji-first".to_string(), "?".to_string()],
766785
];
767786
let mut out = Vec::new();
768-
render_table(&header, &rows, 80, &mut out);
787+
render_table(&header, &rows, 80, crate::ui::theme::agent(), &mut out);
769788
let owned: Vec<LineEntry> = out.iter().filter(|e| !e.text.is_empty()).cloned().collect();
770789
assert_rows_same_width(&owned);
771790
}
@@ -788,7 +807,7 @@ mod tests {
788807
/// accumulator and `\x1b[22m` close on TagEnd::Strong.
789808
#[test]
790809
fn strong_emits_bold_ansi() {
791-
let rendered = markdown_to_styled("the **fox** is quick", 80);
810+
let rendered = markdown_to_styled("the **fox** is quick", 80, crate::ui::theme::agent());
792811
let blob: String = rendered
793812
.iter()
794813
.map(|e| e.text.as_str())
@@ -802,7 +821,7 @@ mod tests {
802821
/// Italic (`*x*`) maps to ANSI 3 / 23.
803822
#[test]
804823
fn emphasis_emits_italic_ansi() {
805-
let rendered = markdown_to_styled("the *fox*", 80);
824+
let rendered = markdown_to_styled("the *fox*", 80, crate::ui::theme::agent());
806825
let blob: String = rendered
807826
.iter()
808827
.map(|e| e.text.as_str())
@@ -816,7 +835,7 @@ mod tests {
816835
/// embeds the tool-color SGR around them.
817836
#[test]
818837
fn inline_code_paints_with_tool_color() {
819-
let rendered = markdown_to_styled("call `fn_name`", 80);
838+
let rendered = markdown_to_styled("call `fn_name`", 80, crate::ui::theme::agent());
820839
let blob: String = rendered
821840
.iter()
822841
.map(|e| e.text.as_str())
@@ -833,7 +852,8 @@ mod tests {
833852
/// coloring (verified by presence of an SGR sequence for `fn`).
834853
#[test]
835854
fn fenced_rust_block_gets_keyword_coloring() {
836-
let rendered = markdown_to_styled("```rust\nfn main() {}\n```", 80);
855+
let rendered =
856+
markdown_to_styled("```rust\nfn main() {}\n```", 80, crate::ui::theme::agent());
837857
let blob: String = rendered
838858
.iter()
839859
.map(|e| e.text.as_str())

0 commit comments

Comments
 (0)