Skip to content

Commit 3d0812d

Browse files
committed
Fix thinking text turning bright after inline code and other markdown
1 parent 1cedbb6 commit 3d0812d

3 files changed

Lines changed: 151 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to Sofos are documented in this file.
44

55
## [Unreleased]
66

7+
### Fixed
8+
9+
- **The assistant's thinking is now shown fully dimmed.** Inline code, bold, lists, or a heading inside a thinking block used to make the text after them switch back to normal brightness.
10+
711
## [0.3.3] - 2026-05-20
812

913
### Fixed

src/ui/markdown.rs

Lines changed: 143 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,27 @@ const SGR_ITALIC: &str = "\x1b[3m";
1111
/// Heading Start and the restorer; restoring just `\x1b[36m` would silently
1212
/// drop the bold half.
1313
const SGR_HEADING: &str = "\x1b[1;36m";
14-
/// SGR code for the blockquote dim. Strong End (`\x1b[22m`) clears bold
15-
/// *and* faint, and inline Code/Link close with `\x1b[0m` which clears
16-
/// every attribute — so the restorer re-applies this when a tag closes
17-
/// inside a blockquote.
18-
const SGR_BLOCKQUOTE: &str = "\x1b[2m";
14+
/// SGR faint. Used for blockquote bodies and for the ambient dim of a
15+
/// streamed thinking block. `\x1b[22m` and `\x1b[0m` both clear faint,
16+
/// so the restorer re-applies it after those resets.
17+
const SGR_FAINT: &str = "\x1b[2m";
1918

2019
impl UI {
2120
pub fn print_markdown_highlighted(&self, md: &str) -> io::Result<()> {
2221
let mut out = stdout().lock();
23-
self.render_markdown_to(&mut out, md)?;
22+
self.render_markdown_to(&mut out, md, false)?;
2423
out.flush()
2524
}
2625

27-
pub(super) fn render_markdown_to(&self, out: &mut impl io::Write, md: &str) -> io::Result<()> {
26+
/// Render `md` to `out` as ANSI-styled text. With `dimmed`, faint is
27+
/// re-applied after each internal SGR reset so streamed thinking
28+
/// stays dim across inline code, links, and headings.
29+
pub(super) fn render_markdown_to(
30+
&self,
31+
out: &mut impl io::Write,
32+
md: &str,
33+
dimmed: bool,
34+
) -> io::Result<()> {
2835
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
2936

3037
// Re-emit any ambient inline styles after a full SGR reset, so nested inline
@@ -35,6 +42,7 @@ impl UI {
3542
italic: bool,
3643
in_heading: bool,
3744
in_blockquote: bool,
45+
dimmed: bool,
3846
) -> io::Result<()> {
3947
if bold {
4048
write!(out, "{}", SGR_BOLD)?;
@@ -45,8 +53,16 @@ impl UI {
4553
if in_heading {
4654
write!(out, "{}", SGR_HEADING)?;
4755
}
48-
if in_blockquote {
49-
write!(out, "{}", SGR_BLOCKQUOTE)?;
56+
if in_blockquote || dimmed {
57+
write!(out, "{}", SGR_FAINT)?;
58+
}
59+
Ok(())
60+
}
61+
62+
// Re-apply the ambient faint that a block-level SGR reset clears.
63+
fn restore_faint(out: &mut impl io::Write, dimmed: bool) -> io::Result<()> {
64+
if dimmed {
65+
write!(out, "{}", SGR_FAINT)?;
5066
}
5167
Ok(())
5268
}
@@ -69,7 +85,9 @@ impl UI {
6985
}
7086
Event::End(TagEnd::Heading(_)) => {
7187
in_heading = false;
72-
writeln!(out, "\x1b[0m")?;
88+
write!(out, "\x1b[0m")?;
89+
restore_faint(out, dimmed)?;
90+
writeln!(out)?;
7391
}
7492
Event::Start(Tag::Strong) => {
7593
bold = true;
@@ -78,7 +96,7 @@ impl UI {
7896
Event::End(TagEnd::Strong) => {
7997
bold = false;
8098
write!(out, "\x1b[22m")?;
81-
restore_ambient(out, bold, italic, in_heading, in_blockquote)?;
99+
restore_ambient(out, bold, italic, in_heading, in_blockquote, dimmed)?;
82100
}
83101
Event::Start(Tag::Emphasis) => {
84102
italic = true;
@@ -99,11 +117,13 @@ impl UI {
99117
Event::End(TagEnd::CodeBlock) => {
100118
in_code_block = false;
101119
let highlighted = self.highlighter.highlight_code(&code_buf, &code_lang);
102-
writeln!(out, "{}", highlighted)?;
120+
write!(out, "{}", highlighted)?;
121+
restore_faint(out, dimmed)?;
122+
writeln!(out)?;
103123
}
104124
Event::Code(code) => {
105125
write!(out, "\x1b[38;2;175;215;255m{}\x1b[0m", code)?;
106-
restore_ambient(out, bold, italic, in_heading, in_blockquote)?;
126+
restore_ambient(out, bold, italic, in_heading, in_blockquote, dimmed)?;
107127
}
108128
Event::Text(text) => {
109129
if in_code_block {
@@ -129,17 +149,20 @@ impl UI {
129149
Event::End(TagEnd::List(_)) => {}
130150
Event::Start(Tag::Item) => {
131151
write!(out, " {} ", "•".dimmed())?;
152+
restore_faint(out, dimmed)?;
132153
}
133154
Event::End(TagEnd::Item) => {
134155
writeln!(out)?;
135156
}
136157
Event::Start(Tag::BlockQuote(_)) => {
137158
in_blockquote = true;
138-
write!(out, "{}> ", SGR_BLOCKQUOTE)?;
159+
write!(out, "{}> ", SGR_FAINT)?;
139160
}
140161
Event::End(TagEnd::BlockQuote(_)) => {
141162
in_blockquote = false;
142-
writeln!(out, "\x1b[0m")?;
163+
write!(out, "\x1b[0m")?;
164+
restore_faint(out, dimmed)?;
165+
writeln!(out)?;
143166
}
144167
Event::Start(Tag::Link { dest_url, .. }) => {
145168
// OSC 8 URI terminates on BEL/ESC; bypass the wrapper if dest_url has any control byte.
@@ -151,10 +174,12 @@ impl UI {
151174
}
152175
Event::End(TagEnd::Link) => {
153176
write!(out, "\x1b[0m\x1b]8;;\x07")?;
154-
restore_ambient(out, bold, italic, in_heading, in_blockquote)?;
177+
restore_ambient(out, bold, italic, in_heading, in_blockquote, dimmed)?;
155178
}
156179
Event::Rule => {
157-
writeln!(out, "{}", "─".repeat(40).dimmed())?;
180+
write!(out, "{}", "─".repeat(40).dimmed())?;
181+
restore_faint(out, dimmed)?;
182+
writeln!(out)?;
158183
}
159184
_ => {}
160185
}
@@ -235,6 +260,8 @@ pub(super) struct MarkdownStreamRenderer {
235260
/// used by the throttle to decide whether enough new content has
236261
/// arrived to justify another full re-render.
237262
last_safe_end: usize,
263+
/// Passed to the renderer so thinking output keeps its ambient dim.
264+
dimmed: bool,
238265
}
239266

240267
impl MarkdownStreamRenderer {
@@ -243,6 +270,15 @@ impl MarkdownStreamRenderer {
243270
buffer: String::new(),
244271
committed_lines: 0,
245272
last_safe_end: 0,
273+
dimmed: false,
274+
}
275+
}
276+
277+
/// Like [`new`](Self::new), but renders for a dimmed thinking block.
278+
pub(super) fn new_dimmed() -> Self {
279+
Self {
280+
dimmed: true,
281+
..Self::new()
246282
}
247283
}
248284

@@ -320,7 +356,7 @@ impl MarkdownStreamRenderer {
320356
drop_trailing_blank: bool,
321357
) -> io::Result<(String, usize)> {
322358
let mut buf: Vec<u8> = Vec::new();
323-
UI::shared().render_markdown_to(&mut buf, source)?;
359+
UI::shared().render_markdown_to(&mut buf, source, self.dimmed)?;
324360
let rendered = String::from_utf8_lossy(&buf).into_owned();
325361
let lines: Vec<&str> = rendered.split_inclusive('\n').collect();
326362
let mut effective_len = lines.len();
@@ -349,7 +385,14 @@ mod render_tests {
349385
fn render(md: &str) -> String {
350386
let ui = UI::new();
351387
let mut buf = Vec::new();
352-
ui.render_markdown_to(&mut buf, md).unwrap();
388+
ui.render_markdown_to(&mut buf, md, false).unwrap();
389+
String::from_utf8(buf).unwrap()
390+
}
391+
392+
fn render_dimmed(md: &str) -> String {
393+
let ui = UI::new();
394+
let mut buf = Vec::new();
395+
ui.render_markdown_to(&mut buf, md, true).unwrap();
353396
String::from_utf8(buf).unwrap()
354397
}
355398

@@ -432,7 +475,7 @@ mod render_tests {
432475
.find(" rest")
433476
.expect("trailing text must be present");
434477
assert!(
435-
after_strong_end[..rest_idx].contains(SGR_BLOCKQUOTE),
478+
after_strong_end[..rest_idx].contains(SGR_FAINT),
436479
"blockquote dim not restored between Strong End and trailing text; segment={:?}",
437480
&after_strong_end[..rest_idx]
438481
);
@@ -451,7 +494,7 @@ mod render_tests {
451494
.find(" rest")
452495
.expect("trailing text must be present");
453496
assert!(
454-
after_code_reset[..rest_idx].contains(SGR_BLOCKQUOTE),
497+
after_code_reset[..rest_idx].contains(SGR_FAINT),
455498
"blockquote dim not restored between inline Code and trailing text; segment={:?}",
456499
&after_code_reset[..rest_idx]
457500
);
@@ -468,7 +511,7 @@ mod render_tests {
468511
.find(" rest")
469512
.expect("trailing text must be present");
470513
assert!(
471-
after_link_close[..rest_idx].contains(SGR_BLOCKQUOTE),
514+
after_link_close[..rest_idx].contains(SGR_FAINT),
472515
"blockquote dim not restored between Link End and trailing text; segment={:?}",
473516
&after_link_close[..rest_idx]
474517
);
@@ -490,6 +533,83 @@ mod render_tests {
490533
&after_link_close[..rest_idx]
491534
);
492535
}
536+
537+
#[test]
538+
fn code_in_dimmed_restores_faint() {
539+
// Inline Code closes with \x1b[0m, wiping the thinking block's
540+
// ambient faint; the dimmed renderer must put it back.
541+
let out = render_dimmed("the readme uses `gulp watch` then more");
542+
let after_code_reset = out
543+
.split("\x1b[0m")
544+
.nth(1)
545+
.expect("inline Code emits \\x1b[0m");
546+
let rest_idx = after_code_reset
547+
.find(" then more")
548+
.expect("trailing text must be present");
549+
assert!(
550+
after_code_reset[..rest_idx].contains(SGR_FAINT),
551+
"faint not restored between inline Code and trailing text; segment={:?}",
552+
&after_code_reset[..rest_idx]
553+
);
554+
}
555+
556+
#[test]
557+
fn strong_in_dimmed_restores_faint() {
558+
// Strong End emits \x1b[22m, which clears faint as well as bold.
559+
let out = render_dimmed("plain **bold** rest");
560+
let after_strong_end = out
561+
.split("\x1b[22m")
562+
.nth(1)
563+
.expect("Strong End must emit \\x1b[22m");
564+
let rest_idx = after_strong_end
565+
.find(" rest")
566+
.expect("trailing text must be present");
567+
assert!(
568+
after_strong_end[..rest_idx].contains(SGR_FAINT),
569+
"faint not restored between Strong End and trailing text; segment={:?}",
570+
&after_strong_end[..rest_idx]
571+
);
572+
}
573+
574+
#[test]
575+
fn heading_end_in_dimmed_restores_faint() {
576+
// Heading End emits \x1b[0m; the paragraph after it must come
577+
// back dim.
578+
let out = render_dimmed("# Title\n\nbody text");
579+
let body_idx = out.find("body text").expect("body must be present");
580+
let last_reset = out[..body_idx]
581+
.rfind("\x1b[0m")
582+
.expect("Heading End emits \\x1b[0m");
583+
assert!(
584+
out[last_reset..body_idx].contains(SGR_FAINT),
585+
"faint not restored between Heading End and body; segment={:?}",
586+
&out[last_reset..body_idx]
587+
);
588+
}
589+
590+
#[test]
591+
fn code_block_in_dimmed_restores_faint() {
592+
// A code block's syntax highlighting leaves the terminal on a
593+
// non-dim style; the prose after it must come back dim.
594+
let out = render_dimmed("```\nlet x = 1;\n```\n\nafter the block");
595+
assert!(
596+
out.contains("\x1b[2m\nafter the block"),
597+
"faint not restored between code block and trailing text; out={:?}",
598+
out
599+
);
600+
}
601+
602+
#[test]
603+
fn list_item_in_dimmed_restores_faint() {
604+
// The bullet's own styling closes on a reset; the item text
605+
// after it must stay dim.
606+
let out = render_dimmed("- first item\n- second item");
607+
assert!(
608+
out.contains("\x1b[2mfirst item"),
609+
"faint not restored between bullet and item text; out={:?}",
610+
out
611+
);
612+
}
493613
}
494614

495615
#[cfg(test)]
@@ -499,7 +619,7 @@ mod stream_tests {
499619
fn full_render(md: &str) -> String {
500620
let ui = UI::new();
501621
let mut buf = Vec::new();
502-
ui.render_markdown_to(&mut buf, md).unwrap();
622+
ui.render_markdown_to(&mut buf, md, false).unwrap();
503623
String::from_utf8(buf).unwrap()
504624
}
505625

src/ui/mod.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,9 @@ impl UI {
207207
/// assistant text is fed through a [`MarkdownStreamRenderer`] so
208208
/// headings, lists, emphasis, and code fences render with ANSI styling
209209
/// instead of leaking raw markdown to the terminal. Thinking deltas go
210-
/// through a separate renderer of the same type, with the rendered
211-
/// output wrapped in a faint SGR pair so the body keeps the dim
212-
/// "thinking" look without losing markdown formatting.
210+
/// through a separate dim-aware renderer, with the rendered output
211+
/// wrapped in a faint SGR pair so the body keeps the dim "thinking"
212+
/// look without losing markdown formatting.
213213
pub struct StreamPrinter {
214214
thinking_started: AtomicBool,
215215
text_started: AtomicBool,
@@ -223,7 +223,7 @@ impl StreamPrinter {
223223
thinking_started: AtomicBool::new(false),
224224
text_started: AtomicBool::new(false),
225225
text_renderer: Mutex::new(MarkdownStreamRenderer::new()),
226-
thinking_renderer: Mutex::new(MarkdownStreamRenderer::new()),
226+
thinking_renderer: Mutex::new(MarkdownStreamRenderer::new_dimmed()),
227227
}
228228
}
229229

0 commit comments

Comments
 (0)