revspec 0.4.0 → 0.6.0

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.
Files changed (33) hide show
  1. package/CLAUDE.md +3 -1
  2. package/README.md +48 -10
  3. package/bun.lock +3 -0
  4. package/package.json +6 -3
  5. package/src/state/review-state.ts +5 -0
  6. package/src/tui/app.ts +65 -34
  7. package/src/tui/comment-input.ts +15 -3
  8. package/src/tui/help.ts +123 -38
  9. package/src/tui/pager.ts +36 -14
  10. package/src/tui/search.ts +9 -4
  11. package/src/tui/status-bar.ts +17 -7
  12. package/src/tui/thread-list.ts +1 -1
  13. package/src/tui/ui/keybinds.ts +4 -2
  14. package/src/tui/ui/markdown.ts +50 -9
  15. package/test/e2e/__snapshots__/snapshot.test.ts.snap +31 -0
  16. package/test/e2e/fixtures/spec.md +36 -0
  17. package/test/e2e/harness.ts +80 -0
  18. package/test/e2e/snapshot.test.ts +182 -0
  19. package/test/{cli-reply.test.ts → integration/cli-reply.test.ts} +2 -2
  20. package/test/{cli-watch.test.ts → integration/cli-watch.test.ts} +2 -2
  21. package/test/{cli.test.ts → integration/cli.test.ts} +3 -3
  22. package/test/{e2e-live.test.ts → integration/e2e-live.test.ts} +4 -4
  23. package/test/{live-interaction.test.ts → integration/live-interaction.test.ts} +4 -4
  24. package/test/{protocol → unit/protocol}/live-events.test.ts +1 -1
  25. package/test/{protocol → unit/protocol}/live-merge.test.ts +3 -3
  26. package/test/{protocol → unit/protocol}/merge.test.ts +2 -2
  27. package/test/{protocol → unit/protocol}/read.test.ts +2 -2
  28. package/test/{protocol → unit/protocol}/types.test.ts +1 -1
  29. package/test/{protocol → unit/protocol}/write.test.ts +2 -2
  30. package/test/{state → unit/state}/review-state.test.ts +2 -2
  31. package/test/{tui → unit/tui}/pager.test.ts +3 -3
  32. package/test/{tui → unit/tui}/ui/keybinds.test.ts +1 -1
  33. /package/test/{opentui-smoke.test.ts → integration/opentui-smoke.test.ts} +0 -0
package/src/tui/help.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  TextRenderable,
3
3
  type CliRenderer,
4
4
  type KeyEvent,
5
+ type ScrollBoxRenderable,
5
6
  } from "@opentui/core";
6
7
  import { theme } from "./ui/theme";
7
8
  import { createDialog } from "./ui/dialog";
@@ -11,6 +12,29 @@ export interface HelpOverlay {
11
12
  cleanup: () => void;
12
13
  }
13
14
 
15
+ function addHelpSection(container: ScrollBoxRenderable, renderer: CliRenderer, title: string, lines: string[]): void {
16
+ // Blank line before section
17
+ container.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
18
+ // Section header in blue
19
+ container.add(new TextRenderable(renderer, {
20
+ content: ` ${title}`,
21
+ width: "100%",
22
+ height: 1,
23
+ fg: theme.blue,
24
+ wrapMode: "none",
25
+ }));
26
+ // Content lines
27
+ for (const line of lines) {
28
+ container.add(new TextRenderable(renderer, {
29
+ content: line,
30
+ width: "100%",
31
+ height: 1,
32
+ fg: theme.text,
33
+ wrapMode: "none",
34
+ }));
35
+ }
36
+ }
37
+
14
38
  /**
15
39
  * Create a help overlay popup showing all keybindings.
16
40
  * Dismissable with `?`, `q`, or `Esc`.
@@ -22,65 +46,84 @@ export function createHelp(opts: {
22
46
  }): HelpOverlay {
23
47
  const { renderer, version, onClose } = opts;
24
48
 
25
- const helpText = [
26
- "",
27
- ` revspec v${version}`,
28
- "",
29
- " Navigation",
30
- " j/k Down/up",
31
- " gg Go to first line / scroll to top",
32
- " G Go to last line / scroll to bottom",
33
- " Ctrl+d/u Half page down/up",
34
- " / Search",
35
- " n/N Next/prev search match",
36
- " Esc Clear search highlights",
37
- " ]t/[t Next/prev thread",
38
- " ]r/[r Next/prev unread thread",
39
- "",
40
- " Review",
41
- " c Comment / view thread / reply",
42
- " r Resolve thread",
43
- " R Resolve all pending",
44
- " dd Delete draft comment (double-tap)",
45
- " l List threads",
46
- " a Approve spec",
47
- "",
48
- " Commands",
49
- " :w Show save status",
50
- " :q Save and quit",
51
- " :wq Save and quit",
52
- " :q! Quit without saving",
53
- "",
54
- ].join("\n");
55
-
56
49
  const dialog = createDialog({
57
50
  renderer,
58
51
  title: "Help",
59
52
  width: "60%",
60
- height: Math.min(26, renderer.height - 2),
53
+ height: Math.min(34, renderer.height - 2),
61
54
  top: "10%",
62
55
  left: "20%",
63
56
  borderColor: theme.info,
64
57
  onDismiss: onClose,
65
58
  hints: [
59
+ { key: "j/k", action: "navigate" },
66
60
  { key: "q/?/Esc", action: "close" },
67
- { key: "j/k", action: "scroll" },
68
61
  ],
69
62
  });
70
63
 
71
- const content = new TextRenderable(renderer, {
72
- content: helpText,
64
+ // Version header
65
+ dialog.content.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
66
+ dialog.content.add(new TextRenderable(renderer, {
67
+ content: ` revspec v${version}`,
73
68
  width: "100%",
74
- fg: theme.text,
69
+ height: 1,
70
+ fg: theme.textMuted,
75
71
  wrapMode: "none",
76
- });
72
+ }));
73
+
74
+ addHelpSection(dialog.content, renderer, "Quick Start", [
75
+ " Navigate to a line and press c to comment.",
76
+ " The AI replies in real-time via the thread popup.",
77
+ " Press r to resolve threads, a to approve the spec.",
78
+ " Use :wq to save and quit when done reviewing.",
79
+ ]);
80
+
81
+ addHelpSection(dialog.content, renderer, "Thread Popup", [
82
+ " Opens in INSERT mode — type and press Tab to send.",
83
+ " Press Esc for NORMAL mode — scroll with j/k/gg/G,",
84
+ " c to reply, r to resolve, q to close.",
85
+ ]);
86
+
87
+ addHelpSection(dialog.content, renderer, "Navigation", [
88
+ " j/k Down/up",
89
+ " gg/G Top/bottom",
90
+ " Ctrl+d/u Half page down/up",
91
+ " zz Center cursor line",
92
+ " / Search (smartcase)",
93
+ " n/N Next/prev match",
94
+ " Esc Clear search",
95
+ " ]t/[t Next/prev thread",
96
+ " ]r/[r Next/prev unread",
97
+ ]);
98
+
99
+ addHelpSection(dialog.content, renderer, "Review", [
100
+ " c Comment / view thread",
101
+ " r Resolve thread (toggle)",
102
+ " R Resolve all pending",
103
+ " dd Delete thread",
104
+ " T List threads",
105
+ " a Approve spec",
106
+ ]);
107
+
108
+ addHelpSection(dialog.content, renderer, "Commands", [
109
+ " :w Merge to review JSON",
110
+ " :wq Merge and quit",
111
+ " :q Quit (blocks if unmerged)",
112
+ " :q! Quit without merging",
113
+ " :{N} Jump to line N",
114
+ " Ctrl+C Quit without merging",
115
+ ]);
77
116
 
78
- dialog.content.add(content);
117
+ // Trailing blank line
118
+ dialog.content.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
119
+
120
+ let pendingG: ReturnType<typeof setTimeout> | null = null;
79
121
 
80
122
  const extraKeyHandler = (key: KeyEvent) => {
81
123
  if (key.name === "q" || key.sequence === "?") {
82
124
  key.preventDefault();
83
125
  key.stopPropagation();
126
+ if (pendingG) { clearTimeout(pendingG); pendingG = null; }
84
127
  onClose();
85
128
  return;
86
129
  }
@@ -98,6 +141,47 @@ export function createHelp(opts: {
98
141
  renderer.requestRender();
99
142
  return;
100
143
  }
144
+ // G = goto bottom
145
+ if (key.name === "g" && key.shift) {
146
+ key.preventDefault();
147
+ key.stopPropagation();
148
+ if (pendingG) { clearTimeout(pendingG); pendingG = null; }
149
+ dialog.content.scrollTo(dialog.content.scrollHeight);
150
+ renderer.requestRender();
151
+ return;
152
+ }
153
+ // gg = goto top
154
+ if (key.name === "g" && !key.shift && !key.ctrl) {
155
+ key.preventDefault();
156
+ key.stopPropagation();
157
+ if (pendingG) {
158
+ clearTimeout(pendingG);
159
+ pendingG = null;
160
+ dialog.content.scrollTo(0);
161
+ renderer.requestRender();
162
+ } else {
163
+ pendingG = setTimeout(() => { pendingG = null; }, 300);
164
+ }
165
+ return;
166
+ }
167
+ // Ctrl+D = half page down
168
+ if (key.ctrl && key.name === "d") {
169
+ key.preventDefault();
170
+ key.stopPropagation();
171
+ const half = Math.max(1, Math.floor((renderer.height - 4) / 2));
172
+ dialog.content.scrollTo(Math.min(dialog.content.scrollTop + half, dialog.content.scrollHeight));
173
+ renderer.requestRender();
174
+ return;
175
+ }
176
+ // Ctrl+U = half page up
177
+ if (key.ctrl && key.name === "u") {
178
+ key.preventDefault();
179
+ key.stopPropagation();
180
+ const half = Math.max(1, Math.floor((renderer.height - 4) / 2));
181
+ dialog.content.scrollTo(Math.max(dialog.content.scrollTop - half, 0));
182
+ renderer.requestRender();
183
+ return;
184
+ }
101
185
  };
102
186
 
103
187
  renderer.keyInput.on("keypress", extraKeyHandler);
@@ -105,6 +189,7 @@ export function createHelp(opts: {
105
189
  return {
106
190
  container: dialog.container,
107
191
  cleanup() {
192
+ if (pendingG) clearTimeout(pendingG);
108
193
  dialog.cleanup();
109
194
  renderer.keyInput.off("keypress", extraKeyHandler);
110
195
  },
package/src/tui/pager.ts CHANGED
@@ -12,12 +12,6 @@ import { parseMarkdownLine, addSegments, collectTable, renderTableBorder, render
12
12
 
13
13
  const MAX_HINT_LENGTH = 40;
14
14
 
15
- function padLineNum(n: number): string {
16
- const s = String(n);
17
- if (s.length >= 4) return s;
18
- return " ".repeat(4 - s.length) + s;
19
- }
20
-
21
15
  function threadHint(thread: Thread): string {
22
16
  if (thread.messages.length === 0) return "";
23
17
  const last = thread.messages[thread.messages.length - 1];
@@ -32,6 +26,7 @@ function threadHint(thread: Thread): string {
32
26
  * Build plain text line-mode content (for testing / plain fallback).
33
27
  */
34
28
  export function buildPagerContent(state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>): string {
29
+ const numWidth = Math.max(String(state.lineCount).length, 3);
35
30
  const lines: string[] = [];
36
31
  for (let i = 0; i < state.specLines.length; i++) {
37
32
  const lineNum = i + 1;
@@ -40,7 +35,8 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
40
35
  const prefix = isCursor ? ">" : " ";
41
36
  let specText = state.specLines[i];
42
37
  if (searchQuery) {
43
- const regex = new RegExp(searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
38
+ const csSensitive = searchQuery !== searchQuery.toLowerCase();
39
+ const regex = new RegExp(searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), csSensitive ? "g" : "gi");
44
40
  specText = specText.replace(regex, (match) => `>>${match}<<`);
45
41
  }
46
42
  let indicator = " ";
@@ -54,7 +50,9 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
54
50
  indicator = "\u258c";
55
51
  }
56
52
  }
57
- lines.push(`${prefix}${indicator}${padLineNum(lineNum)} ${specText}`);
53
+ const numStr = String(lineNum);
54
+ const padded = " ".repeat(numWidth - numStr.length) + numStr;
55
+ lines.push(`${prefix}${indicator}${padded} ${specText}`);
58
56
  }
59
57
  return lines.join("\n");
60
58
  }
@@ -69,6 +67,11 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
69
67
  export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>): void {
70
68
  lineNode.clear();
71
69
 
70
+ // Calculate dynamic gutter width based on total line count
71
+ 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);
74
+
72
75
  // Pre-scan for table blocks so we can calculate column widths
73
76
  const tableBlocks = new Map<number, TableBlock>();
74
77
  for (let i = 0; i < state.specLines.length; i++) {
@@ -81,6 +84,9 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
81
84
  }
82
85
  }
83
86
 
87
+ // Track fenced code block state
88
+ let inCodeBlock = false;
89
+
84
90
  for (let i = 0; i < state.specLines.length; i++) {
85
91
  const lineNum = i + 1;
86
92
  const thread = state.threadAtLine(lineNum);
@@ -113,7 +119,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
113
119
 
114
120
  // Top border before first table row (on its own visual line with blank gutter)
115
121
  if (isTable && relIdx === 0) {
116
- lineNode.add(TextNodeRenderable.fromString(" ", { fg: theme.textDim }));
122
+ lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
117
123
  renderTableBorder(lineNode, tableBlock.colWidths, "top");
118
124
  lineNode.add(TextNodeRenderable.fromString("\n", {}));
119
125
  }
@@ -127,13 +133,28 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
127
133
  indicator,
128
134
  { fg: indicatorColor, bg: isCursor ? theme.backgroundElement : undefined }
129
135
  ));
136
+ const numStr = String(lineNum);
137
+ const paddedNum = " ".repeat(numWidth - numStr.length) + numStr;
130
138
  lineNode.add(TextNodeRenderable.fromString(
131
- `${padLineNum(lineNum)} `,
139
+ `${paddedNum} `,
132
140
  { fg: theme.textDim, attributes: TextAttributes.DIM, bg: isCursor ? theme.backgroundElement : undefined }
133
141
  ));
134
142
 
135
- // Spec text — table or regular markdown
136
- if (isTable) {
143
+ // Spec text — fenced code block, table, or regular markdown
144
+ if (specText.trimStart().startsWith("```")) {
145
+ inCodeBlock = !inCodeBlock;
146
+ // Render the fence line itself as dim
147
+ lineNode.add(TextNodeRenderable.fromString(specText, {
148
+ fg: theme.textDim,
149
+ bg: isCursor ? theme.backgroundElement : undefined,
150
+ }));
151
+ } else if (inCodeBlock) {
152
+ // Inside code block — render as green, no markdown parsing
153
+ lineNode.add(TextNodeRenderable.fromString(specText, {
154
+ fg: theme.green,
155
+ bg: isCursor ? theme.backgroundElement : undefined,
156
+ }));
157
+ } else if (isTable) {
137
158
  if (relIdx === tableBlock.separatorIndex) {
138
159
  // Separator row → box-drawing line
139
160
  renderTableSeparator(lineNode, tableBlock.colWidths);
@@ -147,13 +168,14 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
147
168
  // Bottom border after last row (on its own visual line with blank gutter)
148
169
  if (relIdx === tableBlock.lines.length - 1) {
149
170
  lineNode.add(TextNodeRenderable.fromString("\n", {}));
150
- lineNode.add(TextNodeRenderable.fromString(" ", { fg: theme.textDim }));
171
+ lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
151
172
  renderTableBorder(lineNode, tableBlock.colWidths, "bottom");
152
173
  }
153
174
  } else if (searchQuery) {
154
175
  // When searching, show colored match segments (no markdown styling)
155
176
  const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
156
- const searchRegex = new RegExp(`(${escaped})`, "gi");
177
+ const caseSensitive = searchQuery !== searchQuery.toLowerCase();
178
+ const searchRegex = new RegExp(`(${escaped})`, caseSensitive ? "g" : "gi");
157
179
  const parts = specText.split(searchRegex);
158
180
  for (let p = 0; p < parts.length; p++) {
159
181
  const part = parts[p];
package/src/tui/search.ts CHANGED
@@ -83,18 +83,23 @@ export function createSearch(opts: SearchOptions): SearchOverlay {
83
83
  if (key.name === "return") {
84
84
  key.preventDefault();
85
85
  key.stopPropagation();
86
- const query = input.value.trim().toLowerCase();
87
- if (query.length === 0) {
86
+ const raw = input.value.trim();
87
+ if (raw.length === 0) {
88
88
  onCancel();
89
89
  return;
90
90
  }
91
91
 
92
+ // Smartcase: if query has any uppercase, case-sensitive; otherwise case-insensitive
93
+ const caseSensitive = raw !== raw.toLowerCase();
94
+ const query = caseSensitive ? raw : raw.toLowerCase();
95
+
92
96
  // Search forward from cursor, wrapping around
93
97
  const total = specLines.length;
94
98
  for (let offset = 1; offset <= total; offset++) {
95
99
  const i = (cursorLine - 1 + offset) % total;
96
- if (specLines[i].toLowerCase().includes(query)) {
97
- onResult(i + 1, query); // 1-based line number + query
100
+ const line = caseSensitive ? specLines[i] : specLines[i].toLowerCase();
101
+ if (line.includes(query)) {
102
+ onResult(i + 1, raw); // 1-based line number + original query (preserves case for n/N)
98
103
  return;
99
104
  }
100
105
  }
@@ -2,7 +2,7 @@ import { BoxRenderable, TextRenderable, TextNodeRenderable, TextAttributes, type
2
2
  import type { ReviewState } from "../state/review-state";
3
3
  import { basename } from "path";
4
4
  import { theme } from "./ui/theme";
5
- import { buildHints } from "./ui/hint-bar";
5
+ import { buildHints, type Hint } from "./ui/hint-bar";
6
6
 
7
7
  export interface TopBarComponents {
8
8
  box: BoxRenderable;
@@ -64,23 +64,33 @@ export function buildTopBar(
64
64
  t.add(TextNodeRenderable.fromString(`L${state.cursorLine}/${state.lineCount}`, { fg: theme.textMuted }));
65
65
  }
66
66
 
67
+ /**
68
+ * Set a transient message on the bottom bar (using TextNodes, not .content).
69
+ */
70
+ export function setBottomBarMessage(bar: BottomBarComponents, message: string, fg?: string): void {
71
+ const t = bar.text;
72
+ t.clear();
73
+ t.add(TextNodeRenderable.fromString(message, { fg: fg ?? theme.text }));
74
+ }
75
+
67
76
  /**
68
77
  * Build the bottom bar with styled TextNodes.
69
78
  */
70
- export function buildBottomBar(bar: BottomBarComponents, commandBuffer: string | null): void {
79
+ export function buildBottomBar(bar: BottomBarComponents, commandBuffer: string | null, hasThread?: boolean): void {
71
80
  const t = bar.text;
72
81
  t.clear();
73
82
  if (commandBuffer !== null) {
74
83
  t.add(TextNodeRenderable.fromString(` :${commandBuffer}`, { fg: theme.text }));
75
84
  return;
76
85
  }
77
- const hints = [
78
- { key: "j/k", action: "move" },
86
+ const hints: Hint[] = [
87
+ { key: "j/k", action: "navigate" },
79
88
  { key: "c", action: "comment" },
80
- { key: "r", action: "resolve" },
81
- { key: "/", action: "search" },
82
- { key: "?", action: "help" },
83
89
  ];
90
+ if (hasThread) {
91
+ hints.push({ key: "r", action: "resolve" });
92
+ }
93
+ hints.push({ key: "?", action: "help" });
84
94
  buildHints(t, hints);
85
95
  }
86
96
 
@@ -24,7 +24,7 @@ const MAX_PREVIEW_LENGTH = 50;
24
24
 
25
25
  function previewText(thread: Thread): string {
26
26
  if (thread.messages.length === 0) return "(empty)";
27
- const last = thread.messages[thread.messages.length - 1];
27
+ const last = thread.messages[0];
28
28
  const text = last.text.replace(/\n/g, " ");
29
29
  if (text.length <= MAX_PREVIEW_LENGTH) return text;
30
30
  return text.slice(0, MAX_PREVIEW_LENGTH - 1) + "\u2026";
@@ -53,6 +53,7 @@ export function createKeybindRegistry(bindings: KeyBinding[], timeout = 500): Ke
53
53
 
54
54
  function match(key: KeyEvent): string | null {
55
55
  const keyStr = keyToString(key);
56
+ let skipSequenceCheck = false;
56
57
 
57
58
  if (sequence) {
58
59
  const seq = sequence.first + keyStr;
@@ -61,6 +62,7 @@ export function createKeybindRegistry(bindings: KeyBinding[], timeout = 500): Ke
61
62
 
62
63
  const action = sequenceBindings.get(seq);
63
64
  if (action) return action;
65
+ skipSequenceCheck = true; // Don't start a new sequence with the failed second key
64
66
  }
65
67
 
66
68
  // Check ctrl variants first
@@ -69,8 +71,8 @@ export function createKeybindRegistry(bindings: KeyBinding[], timeout = 500): Ke
69
71
  if (action) return action;
70
72
  }
71
73
 
72
- // Check if this starts a sequence (but not if ctrl is held)
73
- if (!key.ctrl && sequenceStarters.has(keyStr)) {
74
+ // Check if this starts a sequence (but not if ctrl is held, and not if from failed sequence)
75
+ if (!key.ctrl && !skipSequenceCheck && sequenceStarters.has(keyStr)) {
74
76
  sequence = {
75
77
  first: keyStr,
76
78
  timer: setTimeout(() => { sequence = null; }, timeout),
@@ -14,13 +14,22 @@ export interface StyledSegment {
14
14
  }
15
15
 
16
16
  /**
17
- * Parse inline markdown (bold, italic, code) into styled segments.
17
+ * Parse inline markdown (bold italic, bold, italic, code, links, strikethrough) into styled segments.
18
18
  * Strips syntax markers and returns display text with style info.
19
+ * Order matters: longer patterns first (***bold italic*** before **bold** before *italic*).
19
20
  */
20
21
  export function parseInlineMarkdown(text: string): StyledSegment[] {
21
22
  const segments: StyledSegment[] = [];
22
- // Order matters: **bold** before *italic*
23
- const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`)/g;
23
+ // Groups:
24
+ // 2: ***bold italic***
25
+ // 3: **bold**
26
+ // 4: *italic*
27
+ // 5: __bold__
28
+ // 6: _italic_
29
+ // 7: ~~strikethrough~~
30
+ // 8: [link text](url) — display text only
31
+ // 9: `code`
32
+ const regex = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|__(.+?)__|_(.+?)_|~~(.+?)~~|\[([^\]]+)\]\([^)]+\)|`([^`]+)`)/g;
24
33
  let pos = 0;
25
34
  let match;
26
35
  while ((match = regex.exec(text)) !== null) {
@@ -28,14 +37,29 @@ export function parseInlineMarkdown(text: string): StyledSegment[] {
28
37
  segments.push({ text: text.slice(pos, match.index) });
29
38
  }
30
39
  if (match[2] !== undefined) {
31
- // **bold**
32
- segments.push({ text: match[2], attributes: TextAttributes.BOLD });
40
+ // ***bold italic***
41
+ segments.push({ text: match[2], attributes: TextAttributes.BOLD | TextAttributes.ITALIC });
33
42
  } else if (match[3] !== undefined) {
34
- // *italic*
35
- segments.push({ text: match[3], attributes: TextAttributes.ITALIC });
43
+ // **bold**
44
+ segments.push({ text: match[3], attributes: TextAttributes.BOLD });
36
45
  } else if (match[4] !== undefined) {
46
+ // *italic*
47
+ segments.push({ text: match[4], attributes: TextAttributes.ITALIC });
48
+ } else if (match[5] !== undefined) {
49
+ // __bold__
50
+ segments.push({ text: match[5], attributes: TextAttributes.BOLD });
51
+ } else if (match[6] !== undefined) {
52
+ // _italic_
53
+ segments.push({ text: match[6], attributes: TextAttributes.ITALIC });
54
+ } else if (match[7] !== undefined) {
55
+ // ~~strikethrough~~ — use actual terminal strikethrough + dim
56
+ segments.push({ text: match[7], fg: theme.textDim, attributes: TextAttributes.STRIKETHROUGH });
57
+ } else if (match[8] !== undefined) {
58
+ // [link text](url) — show text in blue + underline
59
+ segments.push({ text: match[8], fg: theme.blue, attributes: TextAttributes.UNDERLINE });
60
+ } else if (match[9] !== undefined) {
37
61
  // `code`
38
- segments.push({ text: match[4], fg: theme.green });
62
+ segments.push({ text: match[9], fg: theme.mauve });
39
63
  }
40
64
  pos = match.index + match[0].length;
41
65
  }
@@ -86,6 +110,18 @@ export function parseMarkdownLine(line: string): StyledSegment[] {
86
110
  ];
87
111
  }
88
112
 
113
+ // Task list: - [ ] or - [x]
114
+ const taskMatch = line.match(/^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)/);
115
+ if (taskMatch) {
116
+ const checked = taskMatch[2].toLowerCase() === "x";
117
+ const checkbox = checked ? "\u2611 " : "\u2610 "; // ☑ or ☐
118
+ const color = checked ? theme.green : theme.textDim;
119
+ return [
120
+ { text: taskMatch[1] + checkbox, fg: color },
121
+ ...parseInlineMarkdown(taskMatch[3]),
122
+ ];
123
+ }
124
+
89
125
  // Unordered list: - item, * item, + item
90
126
  const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)/);
91
127
  if (ulMatch) {
@@ -137,10 +173,15 @@ export function parseTableCells(line: string): string[] {
137
173
 
138
174
  /** Compute the display width of a string (strips inline markdown markers). */
139
175
  export function displayWidth(text: string): number {
140
- // Remove **bold**, *italic*, `code` markers to get display length
176
+ // Remove all inline markdown markers to get display length
141
177
  return text
178
+ .replace(/\*\*\*(.+?)\*\*\*/g, "$1")
142
179
  .replace(/\*\*(.+?)\*\*/g, "$1")
143
180
  .replace(/\*(.+?)\*/g, "$1")
181
+ .replace(/__(.+?)__/g, "$1")
182
+ .replace(/_(.+?)_/g, "$1")
183
+ .replace(/~~(.+?)~~/g, "$1")
184
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
144
185
  .replace(/`([^`]+)`/g, "$1")
145
186
  .length;
146
187
  }