revspec 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -94,7 +94,7 @@ Opens a TUI with vim-style navigation. Press `c` on any line to open a thread an
94
94
  The thread popup has two vim-style modes, indicated by border color and label:
95
95
 
96
96
  - **Insert mode** (green border) — type your comment, `Tab` sends, `Esc` switches to normal mode
97
- - **Normal mode** (blue border) — `j/k` and `Ctrl+D/U` scroll the conversation, `gg/G` top/bottom, `c` to reply, `r` to resolve, `q/Esc` to close
97
+ - **Normal mode** (blue border) — `j/k` and `Ctrl+D/U` scroll the conversation, `gg/G` top/bottom, `i/c` to reply, `r` to resolve, `q/Esc` to close
98
98
 
99
99
  ### Markdown rendering
100
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revspec",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "Terminal-based spec review tool with real-time AI conversation",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/tui/app.ts CHANGED
@@ -61,9 +61,12 @@ export async function runTui(
61
61
 
62
62
  // Create and start the live watcher
63
63
  const liveWatcher: LiveWatcher = createLiveWatcher(jsonlPath, (ownerEvents) => {
64
+ let lastReplyThread: { id: string; line: number } | null = null;
64
65
  for (const event of ownerEvents) {
65
66
  if (event.type === "reply" && event.threadId && event.text) {
66
67
  state.addOwnerReply(event.threadId, event.text, event.ts);
68
+ const thread = state.threads.find((t) => t.id === event.threadId);
69
+ if (thread) lastReplyThread = { id: thread.id, line: thread.line };
67
70
  // If the thread popup is open for this thread, push the message in
68
71
  if (activeOverlay?.addMessage && activeOverlay?.threadId === event.threadId) {
69
72
  activeOverlay.addMessage({ author: "owner", text: event.text, ts: event.ts });
@@ -71,6 +74,10 @@ export async function runTui(
71
74
  }
72
75
  }
73
76
  refreshPager();
77
+ // Flash notification for AI replies when not viewing that thread
78
+ if (lastReplyThread && activeOverlay?.threadId !== lastReplyThread.id) {
79
+ showTransient(`AI replied on line ${lastReplyThread.line}`, "info");
80
+ }
74
81
  });
75
82
  liveWatcher.start();
76
83
 
@@ -101,6 +108,12 @@ export async function runTui(
101
108
  rootBox.add(bottomBar.box);
102
109
  renderer.root.add(rootBox);
103
110
 
111
+ // Wrap mode state
112
+ let wrapEnabled = false;
113
+ function currentWrapWidth(): number {
114
+ return wrapEnabled ? renderer.width : 0;
115
+ }
116
+
104
117
  // 7. Initial render
105
118
  function refreshPager(): void {
106
119
  // Spec mutation guard
@@ -111,10 +124,13 @@ export async function runTui(
111
124
  }
112
125
  } catch {}
113
126
 
114
- buildPagerNodes(pager.lineNode, state, searchQuery, state.unreadThreadIds);
127
+ buildPagerNodes(pager.lineNode, state, searchQuery, state.unreadThreadIds, currentWrapWidth());
115
128
  buildTopBar(topBar, specFile, state, state.unreadCount(), specMtimeChanged);
116
- const hasThread = !!state.threadAtLine(state.cursorLine);
117
- buildBottomBar(bottomBar, commandBuffer, hasThread);
129
+ // Don't overwrite transient messages (welcome hint, warnings) during navigation
130
+ if (!messageTimer) {
131
+ const hasThread = !!state.threadAtLine(state.cursorLine);
132
+ buildBottomBar(bottomBar, commandBuffer, hasThread);
133
+ }
118
134
  renderer.requestRender();
119
135
  }
120
136
 
@@ -161,7 +177,7 @@ export async function runTui(
161
177
  // Map visual row back to spec line number (for H/M/L)
162
178
  function visualRowToSpecLine(targetRow: number): number {
163
179
  for (let i = 0; i < state.specLines.length; i++) {
164
- const row = i + countExtraVisualLines(state.specLines, i);
180
+ const row = i + countExtraVisualLines(state.specLines, i, currentWrapWidth());
165
181
  if (row >= targetRow) return i + 1;
166
182
  }
167
183
  return state.lineCount;
@@ -209,7 +225,7 @@ export async function runTui(
209
225
  // Helper: scroll pager to ensure cursor line is visible
210
226
  function ensureCursorVisible(): void {
211
227
  // Map spec line to visual row, accounting for table border extra lines
212
- const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1);
228
+ const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1, currentWrapWidth());
213
229
  const cursorRow = state.cursorLine - 1 + extra;
214
230
  const viewportHeight = Math.max(1, renderer.height - 2); // minus top + bottom bar
215
231
 
@@ -244,6 +260,13 @@ export async function runTui(
244
260
  exitTui(resolve, "session-end");
245
261
  return "exit";
246
262
  }
263
+ // :wrap — toggle line wrapping
264
+ if (cmd === "wrap") {
265
+ wrapEnabled = !wrapEnabled;
266
+ refreshPager();
267
+ showTransient(wrapEnabled ? "Line wrap on" : "Line wrap off", "info");
268
+ return "stay";
269
+ }
247
270
  // :{N} — jump to line number
248
271
  const lineNum = parseInt(cmd, 10);
249
272
  if (!isNaN(lineNum) && lineNum > 0) {
@@ -291,13 +314,24 @@ export async function runTui(
291
314
  }
292
315
  },
293
316
  onResolve: () => {
317
+ let didResolve = false;
294
318
  if (existingThread) {
295
319
  const wasResolved = existingThread.status === "resolved";
320
+ didResolve = !wasResolved;
296
321
  state.resolveThread(existingThread.id);
297
322
  state.markRead(existingThread.id);
298
323
  appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: existingThread.id, author: "reviewer", ts: Date.now() });
299
324
  }
300
325
  dismissOverlay();
326
+ // Auto-advance to next thread only when resolving (not reopening)
327
+ if (didResolve) {
328
+ const nextLine = state.nextThread();
329
+ if (nextLine !== null) {
330
+ state.cursorLine = nextLine;
331
+ ensureCursorVisible();
332
+ }
333
+ }
334
+ refreshPager();
301
335
  },
302
336
  onCancel: () => {
303
337
  if (existingThread) state.markRead(existingThread.id);
@@ -455,8 +489,7 @@ export async function runTui(
455
489
 
456
490
  refreshPager();
457
491
  if (state.threads.length === 0) {
458
- setBottomBarMessage(bottomBar, "Navigate to a line and press c to comment | ? for help", "info");
459
- renderer.requestRender();
492
+ showTransient("Navigate to a line and press c to comment | ? for help", "info", 8000);
460
493
  }
461
494
  renderer.start();
462
495
 
@@ -616,7 +649,7 @@ export async function runTui(
616
649
  refreshPager();
617
650
  break;
618
651
  case "center-cursor": {
619
- const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1);
652
+ const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1, currentWrapWidth());
620
653
  const cursorRow = state.cursorLine - 1 + extra;
621
654
  const halfView = Math.floor(pageSize() / 2);
622
655
  pager.scrollBox.scrollTo(Math.max(0, cursorRow - halfView));
@@ -763,6 +796,9 @@ export async function runTui(
763
796
  liveWatcher.start();
764
797
  dismissOverlay();
765
798
  searchQuery = null;
799
+ jumpList.length = 0;
800
+ jumpList.push(1);
801
+ jumpIndex = 0;
766
802
  ensureCursorVisible();
767
803
  refreshPager();
768
804
  showTransient("Spec rewritten \u2014 review cleared", "success", 2500);
@@ -773,18 +809,7 @@ export async function runTui(
773
809
  break;
774
810
  case "approve":
775
811
  unresolvedGate(() => {
776
- const confirmOverlay = createConfirm({
777
- renderer,
778
- message: "Approve spec and proceed to implementation?",
779
- onConfirm: () => {
780
- dismissOverlay();
781
- exitTui(resolve, "approve");
782
- },
783
- onCancel: () => {
784
- dismissOverlay();
785
- },
786
- });
787
- showOverlay(confirmOverlay);
812
+ exitTui(resolve, "approve");
788
813
  });
789
814
  break;
790
815
  case "next-thread": {
package/src/tui/help.ts CHANGED
@@ -78,7 +78,7 @@ export function createHelp(opts: {
78
78
 
79
79
  addHelpSection(dialog.content, renderer, "Thread Popup", [
80
80
  " New thread: INSERT mode (green border) — type and Tab to send.",
81
- " Existing thread: NORMAL mode (blue border) — read conversation,",
81
+ " Existing thread: NORMAL mode (blue border) — scroll conversation,",
82
82
  " c to reply, r to resolve, q/Esc to close.",
83
83
  ]);
84
84
 
@@ -114,6 +114,7 @@ export function createHelp(opts: {
114
114
  " :q/:wq Quit (warns if unresolved)",
115
115
  " :q! Force quit",
116
116
  " :{N} Jump to line N",
117
+ " :wrap Toggle line wrapping",
117
118
  " Ctrl+C Force quit",
118
119
  ]);
119
120
 
@@ -15,14 +15,16 @@ export function createLiveWatcher(
15
15
  let pollTimer: ReturnType<typeof setInterval> | null = null
16
16
 
17
17
  function check() {
18
- const { events, newOffset } = readEventsFromOffset(jsonlPath, offset)
19
- if (events.length > 0) {
20
- offset = newOffset
21
- const ownerEvents = events.filter((e) => e.author === "owner")
22
- if (ownerEvents.length > 0) {
23
- onOwnerEvents(ownerEvents)
18
+ try {
19
+ const { events, newOffset } = readEventsFromOffset(jsonlPath, offset)
20
+ if (events.length > 0) {
21
+ offset = newOffset
22
+ const ownerEvents = events.filter((e) => e.author === "owner")
23
+ if (ownerEvents.length > 0) {
24
+ onOwnerEvents(ownerEvents)
25
+ }
24
26
  }
25
- }
27
+ } catch {}
26
28
  }
27
29
 
28
30
  return {
package/src/tui/pager.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import type { ReviewState } from "../state/review-state";
2
- import type { Thread } from "../protocol/types";
3
2
  import {
4
3
  ScrollBoxRenderable,
5
4
  TextRenderable,
@@ -10,16 +9,6 @@ import {
10
9
  import { theme } from "./ui/theme";
11
10
  import { parseMarkdownLine, addSegments, collectTable, renderTableBorder, renderTableSeparator, renderTableRow, parseTableCells, type TableBlock } from "./ui/markdown";
12
11
 
13
- const MAX_HINT_LENGTH = 40;
14
-
15
- function threadHint(thread: Thread): string {
16
- if (thread.messages.length === 0) return "";
17
- const last = thread.messages[thread.messages.length - 1];
18
- const text = last.text.replace(/\n/g, " ");
19
- if (text.length <= MAX_HINT_LENGTH) return text;
20
- return text.slice(0, MAX_HINT_LENGTH - 1) + "\u2026";
21
- }
22
-
23
12
  // --- Plain text builder (for tests) ---
24
13
 
25
14
  /**
@@ -64,13 +53,33 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
64
53
  * Inline markdown is parsed and styled per line.
65
54
  * Line numbers and thread hints are dimmed.
66
55
  */
67
- export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>): void {
56
+ /**
57
+ * Word-wrap a string at the given width, breaking at word boundaries.
58
+ */
59
+ function wordWrap(text: string, width: number): string[] {
60
+ if (width <= 0 || text.length <= width) return [text];
61
+ const lines: string[] = [];
62
+ let remaining = text;
63
+ while (remaining.length > width) {
64
+ let breakAt = remaining.lastIndexOf(" ", width);
65
+ if (breakAt <= 0) breakAt = width; // no space found — hard break
66
+ lines.push(remaining.slice(0, breakAt));
67
+ remaining = remaining.slice(breakAt).replace(/^ /, ""); // trim leading space on continuation
68
+ }
69
+ if (remaining.length > 0) lines.push(remaining);
70
+ return lines;
71
+ }
72
+
73
+ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>, wrapWidth?: number): void {
68
74
  lineNode.clear();
69
75
 
70
76
  // Calculate dynamic gutter width based on total line count
71
77
  const numWidth = Math.max(String(state.lineCount).length, 3);
72
- // Blank gutter for table borders: prefix(1) + indicator(1) + numWidth + spaces(2)
73
- const gutterBlank = " ".repeat(2 + numWidth + 2);
78
+ const gutterWidth = 2 + numWidth + 2; // prefix(1) + indicator(1) + numWidth + spaces(2)
79
+ // Blank gutter for table borders and wrap continuations
80
+ const gutterBlank = " ".repeat(gutterWidth);
81
+ // Available content width for wrapping
82
+ const contentWidth = wrapWidth && wrapWidth > gutterWidth ? wrapWidth - gutterWidth : 0;
74
83
 
75
84
  // Pre-scan for table blocks so we can calculate column widths
76
85
  const tableBlocks = new Map<number, TableBlock>();
@@ -127,7 +136,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
127
136
  // Gutter: cursor + indicator + line number (dimmed)
128
137
  lineNode.add(TextNodeRenderable.fromString(
129
138
  `${prefix}`,
130
- { fg: isCursor ? theme.yellow : theme.textDim, bg: isCursor ? theme.backgroundElement : undefined }
139
+ { fg: isCursor ? theme.mauve : theme.textDim, bg: isCursor ? theme.backgroundElement : undefined }
131
140
  ));
132
141
  lineNode.add(TextNodeRenderable.fromString(
133
142
  indicator,
@@ -171,7 +180,10 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
171
180
  lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
172
181
  renderTableBorder(lineNode, tableBlock.colWidths, "bottom");
173
182
  }
174
- } else if (searchQuery && specText.toLowerCase().includes(searchQuery.toLowerCase())) {
183
+ } else if (searchQuery && (() => {
184
+ const cs = searchQuery !== searchQuery.toLowerCase();
185
+ return cs ? specText.includes(searchQuery) : specText.toLowerCase().includes(searchQuery.toLowerCase());
186
+ })()) {
175
187
  // Line contains search match — show colored match segments (no markdown styling)
176
188
  const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
177
189
  const caseSensitive = searchQuery !== searchQuery.toLowerCase();
@@ -187,21 +199,23 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
187
199
  lineNode.add(TextNodeRenderable.fromString(part, { fg: theme.text, bg: isCursor ? theme.backgroundElement : undefined }));
188
200
  }
189
201
  }
202
+ } else if (contentWidth > 0 && specText.length > contentWidth) {
203
+ // Wrap long lines — first chunk gets markdown, continuations get blank gutter + markdown
204
+ const chunks = wordWrap(specText, contentWidth);
205
+ const segments = parseMarkdownLine(chunks[0]);
206
+ addSegments(lineNode, segments, theme.text, isCursor ? theme.backgroundElement : undefined);
207
+ for (let c = 1; c < chunks.length; c++) {
208
+ lineNode.add(TextNodeRenderable.fromString("\n", {}));
209
+ lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
210
+ const contSegments = parseMarkdownLine(chunks[c]);
211
+ addSegments(lineNode, contSegments, theme.text, isCursor ? theme.backgroundElement : undefined);
212
+ }
190
213
  } else {
191
214
  // Parse and render inline markdown
192
215
  const segments = parseMarkdownLine(specText);
193
216
  addSegments(lineNode, segments, theme.text, isCursor ? theme.backgroundElement : undefined);
194
217
  }
195
218
 
196
- // Thread hint (dimmed, inline)
197
- if (thread && thread.messages.length > 0) {
198
- const hint = threadHint(thread);
199
- lineNode.add(TextNodeRenderable.fromString(
200
- ` \u00ab ${hint}`,
201
- { fg: theme.textDim, attributes: TextAttributes.DIM, bg: isCursor ? theme.backgroundElement : undefined }
202
- ));
203
- }
204
-
205
219
  // Newline between lines (except last)
206
220
  if (i < state.specLines.length - 1) {
207
221
  lineNode.add(TextNodeRenderable.fromString("\n", {}));
@@ -212,10 +226,14 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
212
226
  // --- Visual row offset calculation ---
213
227
 
214
228
  /**
215
- * Count extra visual lines (table borders) before a given spec line index.
229
+ * Count extra visual lines (table borders + word wrap) before a given spec line index.
216
230
  * Used to map spec line numbers to actual visual rows in the rendered content.
217
231
  */
218
- export function countExtraVisualLines(specLines: string[], cursorIndex: number): number {
232
+ export function countExtraVisualLines(specLines: string[], cursorIndex: number, wrapWidth?: number): number {
233
+ const numWidth = Math.max(String(specLines.length).length, 3);
234
+ const gutterWidth = 2 + numWidth + 2;
235
+ const contentWidth = wrapWidth && wrapWidth > gutterWidth ? wrapWidth - gutterWidth : 0;
236
+
219
237
  let extra = 0;
220
238
  let i = 0;
221
239
  while (i < specLines.length) {
@@ -232,6 +250,10 @@ export function countExtraVisualLines(specLines: string[], cursorIndex: number):
232
250
  if (cursorIndex >= tableEnd) extra++;
233
251
  continue;
234
252
  }
253
+ // Word wrap: count extra continuation lines
254
+ if (contentWidth > 0 && i < cursorIndex && specLines[i].length > contentWidth) {
255
+ extra += wordWrap(specLines[i], contentWidth).length - 1;
256
+ }
235
257
  i++;
236
258
  }
237
259
  return extra;
@@ -260,7 +282,7 @@ export function createPager(renderer: CliRenderer): PagerComponents {
260
282
  width: "100%",
261
283
  flexGrow: 1,
262
284
  scrollY: true,
263
- scrollX: true,
285
+ scrollX: false,
264
286
  backgroundColor: theme.base,
265
287
  });
266
288
 
@@ -6,7 +6,7 @@ import {
6
6
  } from "@opentui/core";
7
7
  import { theme } from "./ui/theme";
8
8
 
9
- const SPINNER_FRAMES = ["", "", "", "", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
+ const SPINNER_FRAMES = ["|", "/", "-", "\\"];
10
10
 
11
11
  export interface SpinnerOverlay {
12
12
  container: BoxRenderable;
@@ -33,16 +33,16 @@ export function buildTopBar(
33
33
  // Filename — bold
34
34
  t.add(TextNodeRenderable.fromString(` ${name}`, { fg: theme.text, attributes: TextAttributes.BOLD }));
35
35
 
36
- t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
37
-
38
- // Thread summary
39
- if (open > 0 || pending > 0) {
40
- const parts: string[] = [];
41
- if (open > 0) parts.push(`${open} open`);
42
- if (pending > 0) parts.push(`${pending} pending`);
43
- t.add(TextNodeRenderable.fromString(parts.join(", "), { fg: theme.yellow }));
44
- } else {
45
- t.add(TextNodeRenderable.fromString("No active threads", { fg: theme.textMuted }));
36
+ // Thread progress shown when threads exist
37
+ if (state.threads.length > 0) {
38
+ const resolved = state.threads.filter((t) => t.status === "resolved").length;
39
+ const total = state.threads.length;
40
+ t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
41
+ if (resolved === total) {
42
+ t.add(TextNodeRenderable.fromString(`${resolved}/${total} resolved`, { fg: theme.green }));
43
+ } else {
44
+ t.add(TextNodeRenderable.fromString(`${resolved}/${total} resolved`, { fg: theme.yellow }));
45
+ }
46
46
  }
47
47
 
48
48
  // Unread replies
@@ -50,7 +50,7 @@ export function buildTopBar(
50
50
  t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
51
51
  t.add(TextNodeRenderable.fromString(
52
52
  `${unreadCount} new repl${unreadCount === 1 ? "y" : "ies"}`,
53
- { fg: theme.green, attributes: TextAttributes.BOLD }
53
+ { fg: theme.yellow, attributes: TextAttributes.BOLD }
54
54
  ));
55
55
  }
56
56
 
@@ -66,6 +66,16 @@ export function buildTopBar(
66
66
  : `${Math.round(((state.cursorLine - 1) / (state.lineCount - 1)) * 100)}%`;
67
67
  t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
68
68
  t.add(TextNodeRenderable.fromString(`L${state.cursorLine}/${state.lineCount} ${posLabel}`, { fg: theme.textMuted }));
69
+
70
+ // Current section breadcrumb — nearest heading above cursor
71
+ for (let i = state.cursorLine - 1; i >= 0; i--) {
72
+ const match = state.specLines[i].match(/^(#{1,3})\s+(.+)/);
73
+ if (match) {
74
+ t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
75
+ t.add(TextNodeRenderable.fromString(match[2].trim(), { fg: theme.textDim, attributes: TextAttributes.ITALIC }));
76
+ break;
77
+ }
78
+ }
69
79
  }
70
80
 
71
81
  export type MessageIcon = "warn" | "success" | "info";
@@ -57,7 +57,11 @@ function buildTitle(threads: Thread[], mode: FilterMode): string {
57
57
  }
58
58
 
59
59
  function threadsToOptions(threads: Thread[]) {
60
- return threads.map((t) => {
60
+ const STATUS_ORDER: Record<string, number> = { open: 0, pending: 1, resolved: 2 };
61
+ const sorted = [...threads].sort((a, b) =>
62
+ (STATUS_ORDER[a.status] ?? 3) - (STATUS_ORDER[b.status] ?? 3) || a.line - b.line
63
+ );
64
+ return sorted.map((t) => {
61
65
  const icon = STATUS_ICONS[t.status];
62
66
  return {
63
67
  name: `${icon} #${t.id} line ${t.line}: ${previewText(t)}`,
@@ -29,7 +29,7 @@ export function parseInlineMarkdown(text: string): StyledSegment[] {
29
29
  // 7: ~~strikethrough~~
30
30
  // 8: [link text](url) — display text only
31
31
  // 9: `code`
32
- const regex = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|__(.+?)__|_(.+?)_|~~(.+?)~~|\[([^\]]+)\]\([^)]+\)|`([^`]+)`)/g;
32
+ const regex = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|(?<!\w)__(.+?)__(?!\w)|(?<!\w)_(.+?)_(?!\w)|~~(.+?)~~|\[([^\]]+)\]\([^)]+\)|`([^`]+)`)/g;
33
33
  let pos = 0;
34
34
  let match;
35
35
  while ((match = regex.exec(text)) !== null) {
@@ -82,7 +82,7 @@ export function parseMarkdownLine(line: string): StyledSegment[] {
82
82
  const headingMatch = line.match(/^(#{1,6})\s+(.*)/);
83
83
  if (headingMatch) {
84
84
  const level = headingMatch[1].length;
85
- const color = level <= 2 ? theme.blue : theme.mauve;
85
+ const color = level <= 2 ? theme.blue : level === 3 ? theme.mauve : theme.textMuted;
86
86
  // Parse inline markdown within heading text
87
87
  const inner = parseInlineMarkdown(headingMatch[2]);
88
88
  return inner.map((s) => ({